Phase 0, Step 6: Pilots and gate¶
Spec for the developer agent. Implement exactly this. Where this spec is silent, choose the simplest option and record it in README §7 ("Step 6 deviations and additions"). The Phase 0 README constraints are binding: offline build and test, Postgres from docker compose, no stored code, diffs or file contents, pnpm, Node 24, strict TypeScript. The code after step 5 is described in the repo README §5 and §7; where this spec and the README disagree on an existing interface, the README wins.
Binding rules from earlier steps:
- Payloads are triggers (README §5, "Webhook replay"): handlers validate and enqueue; jobs write only from API data or data Graft computed.
- Lock discipline: no slow work inside withChangeLock. This step writes no PR output, so it takes no change lock.
- Module graph: modules import each other only through index.ts; a module that is imported by another (intent, provenance) reaches back only through signals (src/core/signals.ts).
- Log hygiene: logs, job data and errors carry ids, counts and codes, never PR text, logins of voters, or model output.
- Phase 0 enforces nothing. No code path in this step changes a check conclusion, a branch protection or a PR.
1. Goal¶
Onboarding the two pilot teams is human work (HUMAN-TODO). This step ships the tooling that makes it repeatable and measurable, and decides the Phase 0 gate from data:
- Onboarding kit:
docs/pilots/onboarding.md, three startergraft.yamlfiles, a trailer hook for agent harnesses, an offboarding procedure backed bypnpm graft pilot purge, andpnpm graft pilot doctor. - Baseline:
pnpm graft baselinepulls historical PRs, reviews and timelines and stores per-PR numbers and aggregates of review time. - Gate report:
pnpm graft gate reportcomputes card accuracy from eligible reviewer votes with a Wilson interval and minimum N, plus the side metrics, and prints PASS / FAIL / INSUFFICIENT DATA. - Ongoing metrics: a daily rollup job writes additive counters into
metrics_daily. - Escaped defects:
pnpm graft incident addand label-based detection record incidents per culprit PR (Phase 1 needs the baseline). - Deferred items: step 3 review 2 nits 4 and 7, step 5 review 7 minor 1 and nit 2, and a lookalike-character defence for globs (T13 to T15).
2. Out of scope¶
Do not create stub code for any of these.
- Dashboards, charts, a web UI, or showing tallies on the PR. Markdown + JSON reports only.
- Business-hours or timezone-aware review time. Durations are wall clock minus paused intervals (§8.2).
- Automatic calibration of the step 4
cardAcceptableRateagainst thumbs (manual, HUMAN-TODO). - Enforcement, team membership (
members: read), MCP, post-merge guard (Phase 1). - Expanding a team review request into its members (needs
members: read). Team members are eligible only through their own review or the allow-list. - Any manifest change. Everything here uses existing permissions:
pull_requests: read(reviews, requested reviewers),issues: read(timeline),contents: read, and the already subscribedpull_requestevent (review_requested,labeled,closed). - A baseline for repositories where the App is not installed (
--auth token). Pilots install first, in shadow mode. - Validating
pnpm eval init --sensitive-pathswith the new glob checks.
3. Decisions¶
| # | Area | Choice | Rationale |
|---|---|---|---|
| D1 | Layout | src/modules/pilot/ (handlers, jobs, signal listener, stores) and src/pilot/ (CLI-side libraries: baseline, gate, doctor, purge, stats, report rendering). src/pilot may import db, config, github, core, and modules through their index.ts; nothing imports src/pilot except src/bin. |
Same split as src/policy / modules/policy: the CLI and the worker share code without cycles. |
| D2 | CLI entry | src/bin/graft.ts keeps policy exactly as is and dispatches pilot, baseline, gate, incident, metrics with a dynamic await import("./graft-ops.js"). |
U-CLI-6 requires the static closure of graft.ts to stay free of db, config and modules; graft policy check must keep running with an empty env. |
| D3 | Baseline storage | Database, new tables review_time_runs and review_time_prs (FK to repositories, ON DELETE CASCADE). Rows hold PR numbers, author kind, size bucket, minutes, counts and flags. No titles, bodies, paths, logins (not even hashed), SHAs or code. |
One purge path (installation cascade), the gate report joins in SQL without files on laptops, and nothing in it is sensitive. A JSON export would need the eval set's file discipline (outside git, encrypted, purged per laptop) for no gain. Hashed logins were considered and dropped: no metric needs a user identity once rounds and self-approvals are resolved in memory. |
| D4 | Shadow-period review time | The same extractor runs with --period shadow over the pilot window; the gate report compares the latest shadow run with the latest baseline run per repository. |
One definition for both sides; numbers are comparable by construction. |
| D5 | Reviewer eligibility | A vote counts only if its voter is, for that PR: a requested reviewer (user ever requested while Graft watched), a user who submitted a review (any state), or on the report's allow-list (--allow-file). Always excluded: the PR author, Bot users (already dropped at ingest), logins ending [bot], and GRAFT_AGENT_LOGINS. Eligibility is stored in pr_reviewers (user ids, no logins) by the pilot.reviewers.sync job. |
Step 3 D20 deferred this. Collaborators who drive the agent, drive-by reactions and the author's own thumbs would bias accuracy upward. Storing ids at sync time keeps the report offline and cheap. |
| D6 | Accuracy unit | One effective vote per (displayed card version, user): the latest across reaction and button (existing rule). Only cards with status = 'ok'; votes made while an unavailable or pending display was shown were already dropped. Accuracy = up / (up + down) over eligible effective votes. |
"Per displayed card version" is the card row (head, prompt, inputs); per-user dedupe is the existing effective-vote rule. |
| D7 | Gate decision | Wilson 95% interval. INSUFFICIENT DATA below N_min (§8.1.4). FAIL when the upper bound < 0.90 (N ≥ 50). PASS when p̂ ≥ 0.90 and lower bound ≥ 0.85 and the coverage conditions hold. Otherwise INSUFFICIENT DATA ("inconclusive"), unless N ≥ 400, where p̂ decides. | A point estimate on 40 votes is noise; a strict "lower bound ≥ 0.90" would need ~1,000 votes at p = 0.93. N = 400 bounds the wait. |
| D8 | Rollups | metrics_daily (repository_id, day, counts jsonb, rollup_version), UTC days, additive counters only. Daily cron recomputes the last 7 days (idempotent upsert). Retention 730 days, pruned by the same job; purged with the installation. Accuracy and percentiles are always computed from raw rows at report time. |
Counters sum over any window; votes (dedupe, eligibility changes) and percentiles do not. 730 days covers Phase 0, Phase 1 and a year-over-year look. jsonb lets Phase 1 add counters without a migration; a zod schema guards the keys. |
| D9 | Cost | Cost per model call is computed at spend time from response.model and stored in a new nullable intent_spend.cost_usd. src/eval/pricing.ts moves to src/llm/pricing.ts (eval re-exports it). Rows before 0006 or with an unknown model stay NULL and are reported as "unpriced". |
Card rows only hold the last run's summed tokens across models, so cost cannot be recovered later. |
| D10 | Incidents | Table incidents keyed by repository and culprit PR number (not change id: baseline-period culprits have no change row). Sources: cli and label. Label detection reads the live PR body in a job (payload = trigger) and accepts only Reverts <owner>/<repo>#N (GitHub's revert button) or a line Graft-Culprit: #N. Stored: numbers, source, timestamps, severity. |
Phase 1's "no rise in escaped defects" needs a per-PR baseline. Two strict link forms avoid guessing culprits from prose. |
| D11 | Purge | pnpm graft pilot purge deletes the installations row (every app table cascades from it, verified by I-PURGE-2), webhook_deliveries of the installation or its repositories, and pg-boss jobs whose data names the installation. Refused while the installation is active unless --even-if-active. Writes one pilot_purges row (installation id, counts, time; no login). |
The cascade already exists; a test that enumerates TABLE_NAMES makes a future table without a purge path fail. |
| D12 | Doctor | DB-only health checks (no GitHub call) with OK/WARN/FAIL lines; exit 1 on any FAIL. | Runs anywhere the DB is reachable; safe for pilots' on-call. |
| D13 | Trailers for harnesses | A POSIX prepare-commit-msg hook in scripts/pilot/ adds Graft-* trailers from env (GRAFT_AGENT, GRAFT_MODEL, GRAFT_TASK, GRAFT_SESSION) with git interpret-trailers --if-exists doNothing; it does nothing when GRAFT_AGENT is unset. |
Harness-agnostic: any agent that can set env vars gets provenance; humans are unaffected. |
| D14 | Lookalikes in globs | Reject a glob segment whose letters mix scripts outside UTS #39 "highly restrictive" (glob_invalid detail 9). Warn (never fail) on any non-ASCII character (glob_non_ascii). Applies to graft.yaml globs, sensitive_paths and GRAFT_INTENT_SENSITIVE_PATHS. |
sеcrets/** with a Cyrillic е reads right and never matches, which silently loosens a tightening rule. All-Cyrillic or Japanese paths are legitimate. |
4. Files¶
| File | Change |
|---|---|
src/db/migrations/0006_pilot.ts, index.ts, src/db/schema.ts |
New tables and column (§6); TABLE_NAMES gains 6 tables |
src/llm/pricing.ts (moved), src/eval/pricing.ts |
Move; eval re-exports everything unchanged |
src/github/rate-limit.ts (moved), src/eval/rate-limit.ts |
Move installRateLimit; rename EvalAbort to RateLimitAbort; eval re-exports RateLimitAbort as EvalAbort |
src/core/size.ts |
sizeBucket(changedLines) (the step 4 thresholds); src/eval/sample.ts re-exports it as sizeStratum |
src/core/signals.ts |
ChangeSignal.kind gains "feedback_recorded" |
src/modules/intent/* |
Spend cost (T3), feedback signal (T4), effectiveVotes export (T7), nits (T13) |
src/modules/policy/handlers.ts |
onChangeSignal ignores feedback_recorded |
src/modules/pilot/{index,handlers,reviewers,incidents,rollup,store}.ts |
New module (T4 to T6, T10) |
src/app/registry.ts |
Add pilot |
src/pilot/{baseline,timeline,stats,gate,gate-render,doctor,purge,incident-cli,allow-list}.ts |
CLI libraries |
src/bin/graft.ts, src/bin/graft-ops.ts |
Dispatch (D2) |
src/config/index.ts, src/bin/bootstrap.ts |
New env (§7), glob validation (T14) |
src/policy/globs.ts, errors.ts, parse.ts, src/modules/policy/render.ts, src/core/glob.ts |
T14, T15 |
scripts/pilot/prepare-commit-msg |
Hook (D13), executable |
docs/pilots/onboarding.md, docs/pilots/graft.{service,platform,library}.yaml |
Kit (T1) |
test/helpers/fake-github-api.ts |
Timeline, requested reviewers, pulls.get additions/deletions, labels on PRs |
| README, HUMAN-TODO | T16 |
5. Interfaces¶
// src/modules/intent/index.ts (new export)
export interface EffectiveVote {
cardId: string; changeId: string; repositoryId: number; installationId: number;
userId: number; userLogin: string; verdict: "up" | "down"; source: "reaction" | "check_action";
reactedAt: Date; isPrAuthor: boolean; cardStatus: "ok" | "unavailable";
}
/** Effective vote per (card, user) = latest reacted_at across sources (ties: check_action first,
* as feedbackSummary); then filtered to reactedAt in [since, until). */
export function effectiveVotes(ex: Executor, o: { installationIds: number[]; since: Date; until: Date }): Promise<EffectiveVote[]>;
// src/pilot/stats.ts (pure)
export function wilson(up: number, n: number, z?: number): { lower: number; upper: number } | null; // null when n = 0
export function percentile(sorted: readonly number[], p: number): number | null; // type 7 (linear)
export function summarize(values: readonly number[]): { n: number; p50: number | null; p75: number | null; p90: number | null };
// src/pilot/timeline.ts (pure)
export interface TimelineInput {
createdAt: string; mergedAt: string | null; closedAt: string | null; draftNow: boolean;
authorLogin: string; now: Date;
events: readonly TimelineEvent[]; // normalized from issues.listEventsForTimeline, oldest first
excludedReviewerLogins: readonly string[]; // lowercased agent logins
}
export type TimelineEvent =
| { kind: "review_requested"; at: string; reviewer: { login: string; type: string } | null; team: boolean }
| { kind: "ready_for_review" | "convert_to_draft" | "closed" | "reopened" | "merged"; at: string }
| { kind: "reviewed"; at: string; user: { login: string; type: string } | null; state: string; commitId: string | null };
export interface PrReviewTimes {
startBasis: "review_requested" | "ready_for_review" | "opened";
minutesToFirstApproval: number | null; minutesToMerge: number | null;
reviewRounds: number; mergedWithoutReview: boolean; pausedMinutes: number;
excludedApprovals: { self: number; bot: number };
}
export function reviewTimes(i: TimelineInput): PrReviewTimes;
// src/pilot/gate.ts (pure core)
export type GateVerdict = "PASS" | "FAIL" | "INSUFFICIENT_DATA";
export function decideGate(o: GateInputs): { verdict: GateVerdict; reasons: GateReason[] };
GateReason is a code (below_min_votes, below_min_prs, below_min_voters, pilot_below_min, fewer_than_two_pilots, window_too_short, inconclusive, upper_below_target, coverage_cards, coverage_provenance, point_estimate_decides) plus numbers.
6. Schema (migration 0006_pilot)¶
up (all ON DELETE CASCADE unless stated; no column name may match I-DB-4's regex):
ALTER TABLE intent_spend ADD COLUMN cost_usd numeric(14,8); -- NULL = unpriced
CREATE TABLE pr_reviewers (
change_id uuid NOT NULL REFERENCES changes(id) ON DELETE CASCADE,
user_id bigint NOT NULL,
reason text NOT NULL CHECK (reason IN ('requested','reviewed')),
first_seen_at timestamptz NOT NULL,
PRIMARY KEY (change_id, user_id, reason));
CREATE TABLE review_time_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
repository_id bigint NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
period text NOT NULL CHECK (period IN ('baseline','shadow')),
from_date date NOT NULL, to_date date NOT NULL CHECK (to_date > from_date),
base_ref text NOT NULL,
extractor_version integer NOT NULL,
pr_count integer NOT NULL, excluded_count integer NOT NULL,
aggregates jsonb NOT NULL, -- §8.2.6, validated by zod
requests integer NOT NULL,
extracted_at timestamptz NOT NULL);
CREATE INDEX review_time_runs_repo_idx ON review_time_runs (repository_id, period, extracted_at DESC);
CREATE TABLE review_time_prs (
run_id uuid NOT NULL REFERENCES review_time_runs(id) ON DELETE CASCADE,
pr_number integer NOT NULL,
state text NOT NULL CHECK (state IN ('merged','closed','open')),
author_kind text NOT NULL CHECK (author_kind IN ('agent','human')),
author_kind_reason text NOT NULL,
size_bucket text NOT NULL CHECK (size_bucket IN ('S','M','L','XL')),
start_basis text NOT NULL CHECK (start_basis IN ('review_requested','ready_for_review','opened')),
minutes_to_first_approval numeric(12,1), minutes_to_merge numeric(12,1),
review_rounds integer NOT NULL, merged_without_review boolean NOT NULL,
paused_minutes numeric(12,1) NOT NULL,
excluded_reason text CHECK (excluded_reason IN ('excluded_author','timeline_truncated','commits_truncated')),
PRIMARY KEY (run_id, pr_number));
CREATE TABLE incidents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
repository_id bigint NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
culprit_pr_number integer, -- NULL: label without a link
fix_pr_number integer,
source text NOT NULL CHECK (source IN ('cli','label')),
severity smallint CHECK (severity BETWEEN 1 AND 4),
occurred_at timestamptz NOT NULL,
recorded_at timestamptz NOT NULL,
UNIQUE NULLS NOT DISTINCT (repository_id, culprit_pr_number, fix_pr_number));
CREATE TABLE metrics_daily (
repository_id bigint NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
day date NOT NULL,
rollup_version integer NOT NULL,
counts jsonb NOT NULL,
computed_at timestamptz NOT NULL,
PRIMARY KEY (repository_id, day));
CREATE TABLE pilot_purges ( -- no FK: outlives the purge
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
installation_id bigint NOT NULL,
rows_deleted jsonb NOT NULL, -- {table: count}
purged_at timestamptz NOT NULL);
down drops the six tables (IF EXISTS) and the column. pilot_purges is in TABLE_NAMES but not purged (it holds no personal data; I-PURGE-2 lists it as the one exempt table).
7. Configuration (new env)¶
| Variable | Default | Rule |
|---|---|---|
GRAFT_INCIDENT_LABEL |
incident |
off, or 1-50 chars [A-Za-z0-9 ._:/-] |
GRAFT_METRICS_ROLLUP_CRON |
15 2 * * * |
cronOrOff (as the feedback cron) |
GRAFT_METRICS_EXCLUDED_AUTHORS |
dependabot[bot],renovate[bot] |
loginList; excluded from baseline, rollup PR counts and gate coverage (one constant source for both sides, D4) |
8. Metric definitions¶
All times UTC. A window [since, until) defaults until to the start of today. "Excluded authors" = GRAFT_METRICS_EXCLUDED_AUTHORS.
8.1 Card accuracy (the Phase 0 gate)¶
- Votes.
effectiveVotes(installations, since, until), then keep onlycardStatus = 'ok'. - Eligibility (D5). Keep a vote iff
!isPrAuthor, the login does not end in[bot], is not inGRAFT_AGENT_LOGINS, and ((change_id, user_id)is inpr_reviewerswith any reason, or the lowercased login is in the allow-list). Count dropped votes per cause:author,bot_or_agent,not_reviewer. - Statistic.
n = up + down,p̂ = up / n, Wilson 95% (z = 1.959964):(p̂ + z²/2n ± z·sqrt(p̂(1−p̂)/n + z²/4n²)) / (1 + z²/n). - Minimum sample (N_min), all must hold:
n ≥ 150; ≥ 50 distinct PRs with an eligible vote; ≥ 6 distinct voters; ≥ 2 installations, each with ≥ 40 eligible votes; window ≥ 14 days. - Coverage conditions over PRs opened in the window (not excluded authors, not closed within 10 minutes of opening):
card_coverage= PRs with ≥ 1 display of any status / PRs ≥ 0.98;provenance_coverage= PRs with achange_provenancerow / PRs ≥ 0.98. - Decision (D7), in order: if
n ≥ 50andupper < 0.90→ FAIL (upper_below_target). Else if any N_min condition fails → INSUFFICIENT_DATA (each failing condition is a reason). Else if a coverage condition fails → FAIL (coverage_*). Else ifn ≥ 400→ PASS iffp̂ ≥ 0.90(point_estimate_decides). Else PASS ifp̂ ≥ 0.90 and lower ≥ 0.85, otherwise INSUFFICIENT_DATA (inconclusive). Installation ids that do not exist are a usage error (exit 2). - Also reported (not part of the decision): card-level accuracy (
feedbackSummaryrule: a card with any eligible down is inaccurate), per-installation accuracy and interval, votes by source, dropped votes by cause,dominant_voterwarning when one voter casts > 40% of eligible votes, and "votes on PRs without a reviewer sync" (changes with votes but nopr_reviewersrow).
Edge cases: a vote whose card was regenerated in place keeps its card id (step 3 rule); a user who reacted and pressed a button counts once (latest wins); a retracted reaction is gone after the next poll; a voter whose login changed matches the allow-list by the latest stored login; votes are windowed by the effective vote's reacted_at.
8.2 Review time (baseline and shadow)¶
- Population. PRs of the repository with
created_atin[from, to)andbase.ref=--base(default: the repository's default branch). Listed withpulls.list({state: "all", sort: "created", direction: "desc"}), stopping at the first PR older thanfrom. - Per PR requests.
pulls.get(additions, deletions, draft, merged_at, user),issues.listEventsForTimeline(≤ 10 pages of 100, elsetimeline_truncated),fetchPrCommits+computeProvenancefor author kind (truncated commits → still classified,commits_truncatedonly if the classifier cannot run). Excluded authors → row withexcluded_reason = 'excluded_author', no times. - Paused intervals. A PR is paused while draft or closed. Initially draft iff the first of
ready_for_review/convert_to_draftin the timeline isready_for_review, or there is none anddraftNow. Draft runs from open (if initially draft) or eachconvert_to_draftto the nextready_for_review; closed runs fromclosedto the nextreopened. A duration from S to T isT − S − (paused time inside [S, T]), floored at 0, in minutes with one decimal. - Start S. (a) the first
review_requestedevent whose target is a team, or a user who is not the author, notBot, not[bot]and not an agent login; a request made while paused takes effect when the pause ends; requests after merge are ignored; re-requests never move S. (b) Otherwise the firstready_for_review(PR opened as draft). © Otherwisecreated_at. - Qualifying review: a
reviewedevent by a user who is not the author (case-insensitive login; a self-approval, counted inexcludedApprovals.self), notBot, not[bot], not an agent login (excludedApprovals.bot); statependingis ignored. - First approval A = the first qualifying review with state
approved;minutesToFirstApproval = dur(S, max(A, S)),nullif none. - Merge:
minutesToMerge = dur(S, merged_at),nullunless merged. - Review rounds = number of distinct
commitIdamong qualifying reviews submitted in[S, A](or[S, merged_at], or[S, now]when there is no approval); 0 when none. A review with anullcommit counts as its own round. - Merged without review = merged and no qualifying review of any state before
merged_at. - Aggregates (stored in
review_time_runs.aggregates): for groupsall,agent,human, and eachagent|human × S|M|L|XL:summarize()ofminutesToFirstApproval(non-null), ofminutesToMerge(non-null), ofreviewRounds(merged PRs), plusprs,merged,mergedWithoutReview,open(censored). Excluded rows are not in any group. A cell withn < 5is rendered asn<5in markdown but kept in JSON. - Shadow comparison (gate report): per group
all,agent,human: baseline p50, shadow p50,Δ% = (shadow − baseline) / baselinefor time to first approval and to merge. Labeled "indicative: shadow mode, Graft blocks nothing". Phase 1 target shown alongside: agentΔ%of time to first approval ≤ −50%.
8.3 Side metrics (gate report)¶
| Metric | Formula | Source |
|---|---|---|
| Would-auto-approve share | PRs merged in window whose latest verdict for the merge head (max facts_read_at) is would_approve / PRs merged in window; also split by author kind. MVP target ≥ 30% shown alongside. |
rollup merged_* counters |
| Policy verdict distribution | Same population, counts of would_approve, would_require, would_block, no_policy, policy_invalid, no_verdict |
rollup |
| Verifier disagreement rate | cards created ok: disagree / (agree + disagree); error and skipped shares shown separately |
rollup |
| Card unavailable rate | cards created: unavailable / all, plus per failure_reason (raw query); and per PR: PRs whose last non-pending display is unavailable / PRs with a display |
rollup + raw |
| Cost per PR | Σ intent_spend.cost_usd with spent_at in window / distinct change_id of those rows; plus total tokens, unpriced_runs, unpriced_tokens |
rollup |
| Escaped defects | incidents whose culprit PR was merged in the window (changes.merged_at) per 100 merged PRs, by author kind and by would-approve at merge; baseline rate = incidents on culprits in the latest baseline run's merged PRs per 100 of them |
raw |
8.4 Rollup counters (metrics_daily.counts, rollup_version = 1)¶
Keys (all integers except cost_usd, a decimal string): prs_opened, prs_opened_agent, prs_merged, prs_closed_unmerged (by opened_at / merged_at / closed_at day; excluded authors left out), cards_ok, cards_unavailable, verifier_agree, verifier_disagree, verifier_error, verifier_skipped (by card created_at), displays_ok, displays_unavailable, displays_pending, spend_runs, spend_tokens, cost_usd, unpriced_runs, unpriced_tokens (by spent_at), merged_would_approve, merged_would_require, merged_would_block, merged_no_policy, merged_policy_invalid, merged_no_verdict, merged_would_approve_agent, merged_agent, merged_trivial (final ok card at merge head is trivial) (by merged_at), incidents (by occurred_at), deliveries (by received_at, matched on repository_id). Unknown keys fail the zod parse.
9. Tasks¶
T1. Onboarding kit (docs + hook)¶
docs/pilots/onboarding.md, sections in this order:
1. Prerequisites: GitHub org admin for the install, a repo admin, the security owner, agreement signed (consent for diffs to Anthropic, optional eval-set export), Graft deployment running with ANTHROPIC_API_KEY and budget set.
2. What Graft reads: PR metadata, files list and patches (in memory), commits and messages (in memory), linked issue, reviews, reactions on Graft's comment, timeline (baseline), graft.yaml from the base branch.
3. What Graft stores: list every table by group with its content types (ids, numbers, SHAs, paths, model prose; logins only in changes.author_login, intent_feedback.user_login, policy_verdicts.approvals (≤ 10)). State plainly: no source code or diffs are stored; webhook payloads (which include PR titles and bodies) sit in the job queue until 7 days after their job finishes; logs carry metadata only.
4. Data sent to Anthropic: diffs, PR title/body, linked issue text, for card generation only; retention is the org's Anthropic terms (standard or ZDR, decided per HUMAN-TODO step 3) and must be named in the pilot agreement.
5. Install on selected repositories only: choose "Only select repositories" on the install screen; adding/removing a repo later; repository_selection = 'all' is a doctor WARN.
6. Starter graft.yaml per archetype: docs/pilots/graft.service.yaml (product service: trivial docs auto, auth/billing/migrations 1 human + security, agent+sensitive 2), graft.platform.yaml (infra/terraform/CI: every infra path requires the platform team, agent edits to secrets/** blocked), graft.library.yaml (public API surface src/public/** requires 2, docs/tests trivial auto). Each starts with the yaml-language-server schema line and comments.
7. Provenance trailers: Claude Code (a CLAUDE.md instruction block to copy, plus the hook), other harnesses (Codex, Cursor, Aider, Copilot coding agent, custom scripts): install scripts/pilot/prepare-commit-msg as .git/hooks/prepare-commit-msg (or via core.hooksPath) and export GRAFT_AGENT/GRAFT_MODEL/GRAFT_TASK/GRAFT_SESSION in the agent's environment; the PR-description fallback for harnesses that cannot run hooks; GRAFT_AGENT_LOGINS for agent accounts.
8. Shadow mode: every Graft check concludes neutral; nothing blocks or approves; keep branch protection as is; what reviewers do (read the card, vote 👍/👎 or the buttons; only requested reviewers and reviewers count).
9. Weekly ritual pointer to HUMAN-TODO.
10. Offboarding and deletion: uninstall the App → confirm installations.deleted_at → pnpm graft pilot purge --installation <id> --confirm <account login> --dry-run, then without --dry-run → pnpm eval purge on every labeler machine → ask Anthropic for deletion under the org terms if applicable → record the pilot_purges id in the pilot agreement.
scripts/pilot/prepare-commit-msg (POSIX sh, set -eu): exit 0 when GRAFT_AGENT is unset or empty, or when $2 is merge or squash; otherwise append each set variable with git interpret-trailers --in-place --if-exists doNothing --trailer "Graft-Agent: $GRAFT_AGENT" (and the other three). No other side effect.
AC: U-DOC-1 (each starter file passes parsePolicyText with no warnings); U-DOC-2 (onboarding links resolve to existing files and names every command of §10); U-HOOK-½.
T2. Migration 0006 and schema types¶
As §6; schema.ts interfaces; TABLE_NAMES + 6. AC: I-DB-⅔/⅞/9 updated to roll back and re-apply 0006; I-DB-4 passes; I-DB-10 (new): down then up leaves intent_spend rows intact and cost_usd NULL.
T3. Spend cost¶
Move pricing (D9). In the generation loop, accumulate costUsd per call with cost(res.usage, res.model); a call with an unknown model makes the run's cost null (unpriced). saveCard's ledger insert writes cost_usd. AC: U-GEN-30 (Haiku first pass + Sonnet writer + verifier → exact sum), U-GEN-31 (unknown model → null), I-INT-24 (ledger row has the cost); pnpm eval:ci unchanged.
T4. Reviewer eligibility sync¶
- Signal
feedback_recorded(installation, repository, owner, repo, prNumber, changeId, headSha = card head) emitted byintent.feedback.recordafterrecordedand byintent.feedback.pollwhen it wrote ≥ 1 row.onChangeSignalin policy returns for this kind. - Module
pilot: handler onpull_request.review_requested,pull_request.closedandpull_request_review.submitted(validate, enqueue); listener onfeedback_recorded. Both enqueuepilot.reviewers.sync {installationId, repositoryId, owner, repo, prNumber}with keyrev-sync:{repositoryId}:{prNumber}:{floor(now/3_600_000)}for the listener andrev-sync:{repositoryId}:{prNumber}:{deliveryId}for webhooks. - Job: active installation check;
getChangeByNumber;pulls.listRequestedReviewers(users only) →requested;pulls.listReviews(≤ 3 pages) →reviewedfor every non-null user; insertON CONFLICT DO NOTHINGwithfirst_seen_at = now. Never deletes (union over time). 404 → permanent, ignored. syncReviewers(ctx, …)is exported for the CLI backfill (pnpm graft pilot sync-reviewers, T9).
AC: I-REV-1..5.
T5. Incidents¶
- Handler
pull_request.labeled(label name equalsGRAFT_INCIDENT_LABEL, case-insensitive) andpull_request.closed(merged): enqueuepilot.incident.detectkeyedinc:{repoId}:{pr}:{updated_at}. Off when the label isoff. - Job:
pulls.get; return unless the live PR has the label. Parse the body in memory: lines^Reverts ([\w.-]+)/([\w.-]+)#(\d+)\s*$with owner/repo equal (case-insensitive) to this repository, and^Graft-Culprit:\s*#(\d+)\s*$; at most 5 distinct numbers, excluding the PR itself. For each, insert{culprit, fix = this PR, source 'label', occurred_at = PR created_at}ON CONFLICT DO NOTHING; with none, insert one row withculprit_pr_number NULL. Log counts only. - CLI (T9):
incident add --repo o/r --pr N [--fix-pr M] [--occurred-at ISO] [--severity 1-4],incident list --installation <id> [--json],incident remove --id <uuid>.addwarns (stderr) when N is in neitherchangesnor anyreview_time_prsrow; a label row with NULL culprit for the same fix PR is updated in place.
AC: U-INC-½, I-INC-1..4.
T6. Daily rollup¶
pilot.metrics.rollup scheduled by the worker from GRAFT_METRICS_ROLLUP_CRON (as the feedback cron; off unschedules). Data {} (strict). For each repository of an active installation: compute §8.4 for each day in [today − 7, today) with SQL, upsert, then delete rows with day < today − 730. rollupRange(ctx, {installationId, from, to}) is exported for pnpm graft metrics rollup. Uses ctx.clock. AC: I-ROLL-1..4, U-ROLL-1.
T7. Gate report¶
src/pilot/gate.ts gathers inputs (DB only), decideGate decides, gate-render.ts writes markdown (§11) and JSON (zod gateReportSchema, schemaVersion: 1). effectiveVotes lives in intent's store. Allow-list file: one login per line, # comments, validated as loginList; the report records only its count and SHA-256. AC: U-GATE-1..12, I-GATE-1..6.
T8. Baseline extraction¶
src/pilot/baseline.ts: resolve the repository row by full name (case-insensitive, not removed) → installation token → installRateLimit (minRemaining 100, --max-wait default 15 min, --max-requests default 5,000, injected now/sleep). List, fetch, compute with reviewTimes and computeProvenance (config agent logins and co-author emails), aggregate, then write one run + its rows in one transaction (nothing written on abort). --dry-run prints the aggregates and writes nothing. Output: a summary table of the aggregates; never titles or logins. Exit 3 on RateLimitAbort. AC: U-BASE-1..14, I-BASE-1..5.
T9. CLI (graft-ops.ts)¶
Every command loads .env + loadConfig (ConfigError → exit 2, message only), opens the DB, checks pending migrations (exit 2 run pnpm db:migrate), and closes the DB. Commands needing GitHub build the client factory from config. sync-reviewers and metrics rollup run the job functions inline with a CLI JobContext (noSignals, a queue that throws if used). Exit codes: 0 ok, 1 check failed (doctor FAIL; gate report --check FAIL), 2 usage/config, 3 aborted (rate-limit wait, max requests, purge confirmation mismatch), 4 gate report --check INSUFFICIENT_DATA. All terminal output passes through toTerminalSafe (from src/eval/terminal.ts).
T10. Doctor¶
Checks (each OK | WARN | FAIL + one line; --json gives {checks: [{id, status, detail}]}):
| id | FAIL | WARN |
|---|---|---|
installation |
missing, deleted or suspended | repository_selection = 'all' |
repositories |
0 active | — (lists names and count) |
deliveries |
none in 7 days | none in 72 h |
jobs_failed |
> 10 failed in 24 h | ≥ 1 (counts by job name) |
cards |
PRs opened in 7 days but 0 cards | unavailable share > 20% (7 days), by reason |
feedback |
— | 0 votes in 7 days while cards were shown; votes without a reviewer sync |
policy |
— | latest verdict per repo policy_invalid or no_policy |
budget |
today's tokens ≥ budget for a repo | ≥ 80% |
rollup |
— | no metrics_daily row for yesterday |
crons |
— | feedback or rollup schedule missing in pg-boss |
Failed jobs: rows of ${pgbossSchema}.job with state = 'failed' and data->>'installationId' = $1 (verify the v12 table and column names in node_modules and record them in README §7). AC: I-DOC-1..3.
T11. Purge¶
purgeInstallation(db, {installationId, schema, now, dryRun}): one transaction; counts rows per table first (dry run returns them), then deletes webhook_deliveries (by installation_id or repository_id in the installation's repositories), pg-boss jobs where data->>'installationId' = $1 OR data->'payload'->'installation'->>'id' = $1 (every pg-boss table that holds job rows), then the installations row; inserts pilot_purges. CLI: --confirm must equal account_login (case-insensitive) else exit 3; active installation without --even-if-active → exit 3. AC: I-PURGE-1..4.
T12. Fake GitHub API¶
Add: GET /issues/{n}/timeline (paged, from state.timelines), GET /pulls/{n}/requested_reviewers, pulls.get with additions, deletions, draft, labels, body settable per PR, and state=all listing with created_at ordering. Rate-limit headers already exist.
T13. Step 3 review 2 nits 4 and 7¶
- Nit 4: fix
markPublished's doc comment (the time is taken right after the comment write since step 4) and add the line: "shown_atis our clock a few hundred ms after GitHub accepted the write; GitHub'screated_aton reactions can be skewed against it by up to clock skew, which is negligible for attribution." - Nit 7:
sweepFeedbackwindows onmax(intent_displays.shown_at)(any status) instead ofmax(intent_cards.published_at). AC: I-FB-11.
T14. Environment globs validated (step 5 review 7 minor 1)¶
globList for GRAFT_INTENT_SENSITIVE_PATHS runs validateGlob on each glob; a failure is GRAFT_INTENT_SENSITIVE_PATHS: glob <index> is invalid (<GLOB_REASONS[detail]>) and never echoes the value; the process exits at startup (ConfigError). bootstrap logs one warning per glob with non-ASCII characters ({index, codePoints}). README §7 and HUMAN-TODO note it. AC: U-CFG-30..32.
T15. Glob lookalikes and the exact-text shortcut¶
- Mixed scripts (D14),
glob_invaliddetail 9 — "mixes writing systems in one segment (lookalike letters?)". Per/-segment, the script set of its letters (\p{L}), ignoringCommonandInherited(digits, punctuation,*?[], combining marks), using\p{Script=…}for Latin, Greek, Cyrillic, Armenian, Hebrew, Arabic, Han, Hiragana, Katakana, Hangul, Bopomofo, Thai, Devanagari, Georgian; any other letter is its own scriptOther:<script>(detect with\p{Script=X}for the remaining scripts the engine names, or treat as one unknown bucket that never combines). Allowed: a single script, or a subset of{Latin, Han, Hiragana, Katakana},{Latin, Han, Bopomofo}or{Latin, Han, Hangul}. Everything else returns 9. Runs after detail 2 and before the cost checks.GLOB_REASONS[9]added; JSON Schema unaffected (description only). - Warning
glob_non_ascii.ParseResultok variant gainswarnings: PolicyWarning[]({code: "glob_non_ascii", location, line, column, codePoints: string[] /* "U+0435", at most 5 */}) for every glob inrules[*].match.paths|only_pathsandsensitive_pathswith a code point > 0x7F. Never the glob text. The policy hash is unchanged.graft policy check: human output printsfile:line:col: warning glob_non_ascii <location>: non-ASCII characters U+0435after errors;--jsonaddswarnings; the exit code ignores warnings. Thegraft/policycheck-runoutput.textgains a "Warnings" list (same text, throughescapeProse), not the comment section;LoadedPolicycarries the warnings through the cache. - Exact-text shortcut (step 5 review 7 nit 2). Model picomatch 4.0.7's
input === patternfast path insrc/core/glob.ts: readlib/picomatch.jstest()innode_modules, replicate exactly which strings are compared (normalized glob text, case handling), charge one unit per compared character, and record the reading in README §7. U-PG-7's generator adds paths equal to the glob text.
AC: U-PG-21..25, U-PY-11/12, U-CLI-8, I-POL-22.
T16. Docs¶
README: §1 (step 6 line), §5 (module pilot, src/pilot, the feedback_recorded signal, purge), new "Pilots and gate" section with the CLI table, §6 (no permission change; timeline and requested reviewers use existing reads), §7 "Step 6 deviations and additions". HUMAN-TODO (§14). docs/plan/development-plan.md: nothing.
10. CLI usage¶
pnpm graft pilot doctor --installation 123 [--json]
pnpm graft pilot sync-reviewers --installation 123 --since 2026-11-01
pnpm graft pilot purge --installation 123 --confirm acme [--dry-run] [--even-if-active]
pnpm graft baseline --repo acme/api --from 2026-08-01 --to 2026-10-31 [--period baseline|shadow] [--base main]
[--max-requests 5000] [--max-wait 15m] [--dry-run]
pnpm graft gate report --installation 123 --installation 456 --since 2026-11-01 [--until 2026-11-29]
[--allow-file ~/pilots/allow.txt] [--out ~/pilots/reports] [--json] [--check]
pnpm graft metrics rollup --installation 123 --from 2026-11-01 --to 2026-11-29
pnpm graft incident add --repo acme/api --pr 812 [--fix-pr 830] [--occurred-at 2026-11-12T09:00:00Z] [--severity 2]
pnpm graft incident list --installation 123 [--json]
pnpm graft incident remove --id <uuid>
--out writes gate-<until>.md and gate-<until>.json (mode 0600); without it markdown goes to stdout (--json prints JSON instead). Dates are YYYY-MM-DD (UTC midnight); --to/--until are exclusive.
11. Report markdown sketch¶
# Graft Phase 0 gate — 2026-11-01 to 2026-11-29
**Verdict: INSUFFICIENT DATA** — below_min_votes (128 of 150), pilot_below_min (inst 456: 31 of 40)
Installations: 123 (acme, 2 repos), 456 (globex, 1 repo). Data through 2026-11-28. Allow-list: 3 logins (sha256 1a2b…).
## Card accuracy (eligible reviewer votes)
| Scope | 👍 | 👎 | n | Accuracy | Wilson 95% | PRs | Voters |
| All | 118 | 10 | 128 | 92.2% | 86.2–95.7% | 61 | 9 |
| 123 | 90 | 7 | 97 | 92.8% | … | … | … |
Dropped votes: author 14, bot/agent 0, not a reviewer 22. Card-level accuracy: 88.5% (52 of 59 cards).
Coverage: cards 99.1% (≥ 98%), provenance 100% (≥ 98%).
Warnings: none.
## Policy (merged PRs) | Would approve 34% (target ≥ 30%) | require 58% | block 2% | no policy 6% | …
## Cards | verifier disagrees 7.9% | unavailable 2.1% (budget_exceeded 3, llm_timeout 1) | cost/PR $0.042 (unpriced runs 0)
## Review time (indicative: shadow mode, Graft blocks nothing)
| Group | First approval p50 baseline → shadow | Δ | Merge p50 baseline → shadow | Δ |
| agent | 312 → 240 min | −23% (Phase 1 target ≤ −50%) | … |
## Escaped defects | 1 incident per 100 merged PRs (baseline 0.8) | on would-approve PRs: 0
12. Test plan¶
Offline: fake GitHub API, fake LLM, real Postgres and pg-boss for I-. Test names carry the IDs.
Unit
| ID | Asserts |
|---|---|
| U-GATE-1 | wilson(0,0) is null; wilson(9,10) and wilson(118,128) equal pinned values to 1e-9; wilson(n,n).upper = 1 |
| U-GATE-2 | Eligibility: author vote dropped (author); [bot] and GRAFT_AGENT_LOGINS dropped (bot_or_agent); a non-reviewer dropped (not_reviewer); requested-only, reviewed-only and allow-listed (case-insensitive) kept |
| U-GATE-3 | Per-user dedupe: reaction 👍 at t1 then button 👎 at t2 → one 👎; same user on two card versions → two votes |
| U-GATE-4 | Votes on unavailable cards are not counted |
| U-GATE-5 | N_min: 149 votes → INSUFFICIENT_DATA below_min_votes; 150 with 49 PRs → below_min_prs; 5 voters → below_min_voters; one installation → fewer_than_two_pilots; window 13 days → window_too_short |
| U-GATE-6 | 40 of 50 up (upper < 0.90) → FAIL upper_below_target even though N_min fails |
| U-GATE-7 | 150 votes, p̂ 0.94 → PASS; p̂ 0.90 at n 150 (lower < 0.85) → INSUFFICIENT_DATA inconclusive |
| U-GATE-8 | n 400, p̂ 0.905 → PASS point_estimate_decides; p̂ 0.895 → FAIL |
| U-GATE-9 | Coverage 0.97 with good accuracy → FAIL coverage_cards; provenance 0.97 → FAIL coverage_provenance |
| U-GATE-10 | dominant_voter warning at 41%, not at 40% |
| U-GATE-11 | Markdown rendering byte-for-byte for a fixed input; the JSON parses with gateReportSchema; neither contains any voter login (sentinel logins) |
| U-GATE-12 | Allow-file parsing: comments and blanks skipped; an invalid login → usage error naming the line number only |
| U-BASE-1 | No request, not draft: S = created_at, approval and merge minutes exact |
| U-BASE-2 | Opened as draft, ready_for_review at +2 h, request at +3 h: S = request; request made while draft takes effect at ready_for_review |
| U-BASE-3 | convert_to_draft / ready_for_review after S: paused minutes subtracted from both durations |
| U-BASE-4 | Re-request after changes requested does not move S |
| U-BASE-5 | Request for a team counts; request for the author or a bot does not |
| U-BASE-6 | Self-approval (author login, other case) ignored and counted; bot and agent-login approvals ignored and counted |
| U-BASE-7 | Merged without any review → mergedWithoutReview, approval null, merge minutes set |
| U-BASE-8 | Rounds = distinct commit ids of qualifying reviews in [S, A]; reviews after A ignored; null commit counts as a round |
| U-BASE-9 | Approval before S → 0 minutes; request after merge ignored |
| U-BASE-10 | Closed then reopened then merged: closed interval subtracted |
| U-BASE-11 | Open at now: censored, approval null when none, open counted |
| U-BASE-12 | percentile type 7 on [1..10] (p50 5.5, p90 9.1); summarize([]) → n 0 and nulls |
| U-BASE-13 | Aggregates group by author kind × size; excluded rows are in no group; n<5 cells rendered as n<5 |
| U-BASE-14 | sizeBucket boundaries 50/51, 300/301, 1500/1501 equal step 4's sizeStratum |
| U-ROLL-1 | metricsCountsSchema rejects an unknown key and a negative count |
| U-INC-1 | Body parsing: Reverts acme/api#812 (same repo) → 812; other repo → none; Graft-Culprit: #9 → 9; self number dropped; 7 links → first 5 |
| U-INC-2 | Label config off → handler enqueues nothing; label match is case-insensitive |
| U-DOC-1 | The three starter files parse with no errors and no warnings |
| U-DOC-2 | Every relative link in docs/pilots/onboarding.md exists; every command of §10 appears |
| U-HOOK-1 | In a temp git repo with the hook and GRAFT_AGENT=claude-code GRAFT_TASK=#4 set, git commit -m x yields both trailers once; amending does not duplicate them |
| U-HOOK-2 | Without GRAFT_AGENT the message is unchanged; a merge commit is unchanged |
| U-GEN-30/31 | T3 |
| U-PG-21 | validateGlob("sеcrets/**") (Cyrillic U+0435) → 9; "**/pаyments/**" (U+0430) → 9; Greek ο in infο/** → 9 |
| U-PG-22 | Allowed: docs/руководство/** (all Cyrillic), ドキュメント/設定/** (Katakana + Han), src/한글v2/** (Hangul + Latin), café/** (Latin with combining or precomposed accent), src/日本語_ja/*.md |
| U-PG-23 | Mixing is per segment: руководство/docs/** is valid |
| U-PG-24 | The exact-text shortcut: a path equal to an anchored class glob's text (/[0-9] vs [0-9]) matches like picomatch; the cost is charged |
| U-PG-25 | U-PG-7's differential still has zero unexpected mismatches with text-equal paths added |
| U-PY-11 | A policy with paths: ["docs/руководство/**"] → ok with one glob_non_ascii warning, codePoints capped at 5, no glob text in the warning; hash equals the same policy parsed before this step |
| U-PY-12 | sensitive_paths: ["sеcrets/**"] → glob_invalid detail 9 at sensitive_paths[0] with line and column |
| U-CLI-8 | graft policy check on the warning file: exit 0, prints the warning line; --json has warnings; on the Cyrillic-е file exit 1 with detail 9's text |
| U-CFG-30 | GRAFT_INTENT_SENSITIVE_PATHS="a/**,/**.pem" → ConfigError naming glob 2 and the reason, not the value |
| U-CFG-31 | GRAFT_INTENT_SENSITIVE_PATHS="sеcrets/**" → ConfigError (detail 9) |
| U-CFG-32 | New env defaults and rules (§7): label off, bad label chars, cron off |
| U-CLI-6 | Unchanged and passing (the static closure of graft.ts is still clean) |
Integration
| ID | Asserts |
|---|---|
| I-REV-1 | pull_request.review_requested → one sync job; it stores requested for the requested user |
| I-REV-2 | A review then the sync → reviewed; a second sync adds nothing and deletes nothing after the request is removed |
| I-REV-3 | A button vote emits feedback_recorded → one sync job per change per hour; policy enqueues no policy.evaluate for it |
| I-REV-4 | A reaction poll that writes rows emits the signal; a poll writing nothing does not |
| I-REV-5 | Suspended installation → no GitHub call; 404 on the PR → permanent, no rows |
| I-GATE-1 | Seeded two installations (cards, displays, feedback, reviewers, provenance, verdicts, rollups): the report numbers equal hand-computed values; PASS |
| I-GATE-2 | Same seed with the reviewer rows removed → every vote not_reviewer, INSUFFICIENT_DATA; with an allow-file of all voters → PASS again |
| I-GATE-3 | A third installation's votes do not leak into a report for the first two |
| I-GATE-4 | Shadow vs baseline runs: latest run per period is used; Δ% exact; section labelled indicative |
| I-GATE-5 | Escaped defects: incidents on merged PRs in window and on baseline PRs give the two rates; would-approve split correct |
| I-GATE-6 | --out writes both files with mode 0600; --check exit codes 0/¼ |
| I-BASE-1 | Fake API with 30 PRs over 3 list pages (page size 10): only PRs in [from, to) on the base branch are stored; per-PR numbers equal U-BASE expectations |
| I-BASE-2 | Rate limit: remaining hits the floor → one wait of reset − now on the injected clock; a wait above --max-wait → exit 3 and no run row |
| I-BASE-3 | Author kind from trailers (agent) and from a human PR; an excluded author is stored with excluded_reason and no times |
| I-BASE-4 | Stored rows and aggregates contain no title, body, login or SHA (sentinel strings searched in every column of both tables) |
| I-BASE-5 | --dry-run writes nothing; --period shadow stores shadow |
| I-ROLL-1 | Seeded day: every §8.4 counter equals a hand count, including verdict at merge head (latest facts_read_at) and excluded authors left out |
| I-ROLL-2 | Re-running the rollup is idempotent; a late incident within 7 days updates the day |
| I-ROLL-3 | Rows older than 730 days are deleted; rows of removed repositories are not recomputed |
| I-ROLL-4 | The worker schedules the rollup cron; off unschedules it |
| I-INC-1 | Labeled revert PR whose body says Reverts <repo>#7 → one incident (culprit 7, fix = the PR, source label); replay → still one |
| I-INC-2 | Label without a link → one row with NULL culprit; incident add --fix-pr for that PR fills it in place |
| I-INC-3 | The payload's body is ignored: payload says #7, live body says #9 → culprit 9 |
| I-INC-4 | CLI add/list/remove; unknown culprit warns on stderr |
| I-DOC-1 | Healthy seed → all OK, exit 0 |
| I-DOC-2 | Failed pg-boss jobs, no deliveries in 8 days, over-budget repo → the matching FAILs, exit 1; --json shape |
| I-DOC-3 | Deleted installation → installation FAIL and no other query errors |
| I-PURGE-1 | Seed installations A and B in every table (incl. webhook_deliveries, pg-boss jobs with top-level and payload installation ids, all 0006 tables); purge A → zero rows for A everywhere; B unchanged; one pilot_purges row with counts |
| I-PURGE-2 | Every table in TABLE_NAMES except pilot_purges is covered by the purge's verification map; adding a fake table name to the list fails the check (the test enumerates TABLE_NAMES) |
| I-PURGE-3 | Active installation without --even-if-active → exit 3, nothing deleted; wrong --confirm → exit 3 |
| I-PURGE-4 | --dry-run prints counts and deletes nothing |
| I-FB-11 | A card first shown 40 days ago and shown again yesterday (A→B→A) is polled by the sweep |
| I-INT-24 | T3 |
| I-POL-22 | A base graft.yaml with a non-ASCII glob → the check-run output.text has the Warnings list; the managed comment section is unchanged |
| I-DB-10 | T2 |
13. Mutation checks¶
| Mutation | Caught by |
|---|---|
| Eligibility filter removed (all votes count) | U-GATE-2, I-GATE-2 |
| PR-author exclusion removed | U-GATE-2 |
Votes on unavailable cards counted |
U-GATE-4 |
| Wilson replaced by the normal approximation | U-GATE-1, U-GATE-7 |
| N_min check removed | U-GATE-5 |
| Coverage conditions ignored | U-GATE-9 |
| Paused (draft) time not subtracted | U-BASE-3 |
| Re-request moves S | U-BASE-4 |
| Self-approval counted | U-BASE-6 |
| Requests during draft take effect immediately | U-BASE-2 |
| Rounds count reviews instead of commit ids | U-BASE-8 |
| Run written before the abort check (partial run on abort) | I-BASE-2 |
| Purge skips pg-boss jobs | I-PURGE-1 |
Purge skips webhook_deliveries |
I-PURGE-1 |
onChangeSignal no longer ignores feedback_recorded |
I-REV-3 |
| Incident culprit taken from the payload body | I-INC-3 |
Rollup verdict uses the first instead of the latest facts_read_at |
I-ROLL-1 |
Sweep window back on intent_cards.published_at |
I-FB-11 |
| Detail 9 check removed | U-PG-21, U-PY-12, U-CFG-31 |
Env globs not passed to validateGlob |
U-CFG-30 |
| Warnings change the CLI exit code | U-CLI-8 |
14. HUMAN-TODO additions (append as "## Step 6: Pilots and gate")¶
- Recruit two pilot teams (20-500 engineers, agents open most PRs, GitHub, willing to vote on cards for 4+ weeks). Who: product owner. Produces: names, contacts, signed agreement (consent for diffs to Anthropic, retention terms, optional eval export) →
docs/plan/development-plan.mddecision "Where the pilot teams come from". - Onboarding session per pilot (60 min) following
docs/pilots/onboarding.md: install on selected repos, pick a startergraft.yaml, install the trailer hook in each agent harness, explain shadow mode and voting, collect the allow-list (reviewers who review without being requested). Who: repo owner with the pilot's tech lead. Produces: installation id, allow-file (stored outside git),pilot doctorall OK. - Baseline: right after install,
pnpm graft baseline --repo <r> --from <today − 12 weeks> --to <install date>per pilot repo (at least 4 weeks of history; 2-4 weeks only if history is short). Ask each pilot to list incidents in that period and record them withpnpm graft incident add. Who: repo owner. - Backfill eligibility once after deploying step 6:
pnpm graft pilot sync-reviewers --installation <id> --since <install date>. - Weekly gate review (30 min, same day each week):
pnpm graft pilot doctorper pilot;pnpm graft baseline --period shadow --from <pilot start> --to <today>per repo;pnpm graft gate report --installation A --installation B --since <pilot start> --allow-file … --out ~/graft-reports; read verdict, 👎 cards (open them on GitHub), unavailable reasons, cost; adjust prompts throughpnpm eval runbefore shipping any change. Who: repo owner + product owner. Produces: the report files and a short note per week. - Calling the gate. Call it at the first weekly review where the report says PASS or FAIL, and no earlier than 2 weeks of pilot data; if still INSUFFICIENT DATA after 6 weeks, decide with the pilots whether to extend 2 weeks or record a FAIL for lack of engagement. PASS → start Phase 1. FAIL → prompt work against the eval set, then a fresh 2-week window (
--sincereset). Record the verdict, window, n, interval and report hash indocs/plan/development-plan.md. Who: product owner. - Compare the eval proxy: for PRs in both the pilot eval set and the gate window, compare
cardAcceptableRatewith the thumbs by hand and note the gap. Who: repo owner. - Incident label: agree the label name with each pilot (
GRAFT_INCIDENT_LABEL) and ask them to putReverts owner/repo#N(the GitHub revert button does) orGraft-Culprit: #Nin fix PRs. Who: product owner. - Check
GRAFT_INTENT_SENSITIVE_PATHSstill loads after deploy (startup now rejects globs the matcher does not model and mixed-script segments). Who: repo owner. - Offboarding: when a pilot leaves, follow the onboarding doc's deletion procedure and record the
pilot_purgesid. Who: repo owner.
15. Definition of done¶
From a clean clone with Docker running, and no network beyond install and the image pull:
pnpm install --frozen-lockfile
pnpm db:up
pnpm lint
pnpm typecheck
pnpm test # every ID in §12 present and passing; earlier IDs still passing
pnpm build
pnpm eval:ci # exit 0, baseline unchanged
pnpm graft policy schema --check # exit 0
pnpm graft policy check docs/examples/graft.yaml # exit 0
for f in docs/pilots/graft.*.yaml; do pnpm graft policy check "$f" || exit 1; done
pnpm graft policy check test/fixtures/policy/alias-bomb.yaml; test $? -eq 1
pnpm db:migrate && pnpm db:status # 0001..0006 applied
pnpm db:rollback && pnpm db:status && pnpm db:migrate # only 0006 rolled back, then re-applied
grep -rnE "@ts-(ignore|expect-error)" src/ && exit 1 || true
grep -rn "modules/" src/db/ src/policy/ && exit 1 || true
grep -nE "from \"\.\./(db|config|modules|pilot)" src/bin/graft.ts && exit 1 || true
grep -rn "U-GATE-\|U-BASE-\|I-GATE-\|I-BASE-\|I-PURGE-\|I-REV-\|I-ROLL-\|I-INC-\|U-PG-2[1-5]" test/ | wc -l # non-zero
Manual smoke (record in the PR description): with dev:server and dev:worker running, send installation.created then pull_request.opened; pnpm graft pilot doctor --installation <fixture id> prints the checks (GitHub-dependent ones WARN offline); pnpm graft metrics rollup for the fixture installation writes a row; pnpm graft pilot purge --installation <id> --confirm <login> --dry-run lists counts.
Also required: no column, job payload, log line, report or error message holds PR text, code, globs or voter logins (I-BASE-4, U-GATE-11 and code reading); README and HUMAN-TODO updated per T16; github/app-manifest.json unchanged (U-MAN-1 unchanged).