create-agent-rig 0.10.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +116 -4
  2. package/README.md +8 -8
  3. package/package.json +2 -2
  4. package/packages/cli/dist/commands/upgrade.js +22 -9
  5. package/packages/cli/dist/index.js +9 -2
  6. package/templates/agent-os/subagent-routing.json +4 -0
  7. package/templates/agent-os/universal/.agents/skills/diagnose/SKILL.md +43 -0
  8. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +65 -22
  9. package/templates/agent-os/universal/.agents/skills/plan-slices/SKILL.md +30 -0
  10. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
  11. package/templates/agent-os/universal/.agents/skills/release-propose/SKILL.md +74 -0
  12. package/templates/agent-os/universal/.agents/skills/skill-authoring/SKILL.md +39 -0
  13. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +4 -0
  14. package/templates/agent-os/universal/.claude/agents/failure-diagnostician.md +112 -0
  15. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +14 -3
  16. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +21 -6
  17. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +13 -3
  18. package/templates/agent-os/universal/.claude/rules/invariants.md +33 -0
  19. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +63 -0
  20. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +5 -6
  21. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +71 -14
  22. package/templates/agent-os/universal/.claude/scripts/queue/propose.mjs +139 -0
  23. package/templates/agent-os/universal/.claude/scripts/release-evidence.mjs +188 -0
  24. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +4 -2
  25. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +8 -0
  26. package/templates/agent-os/universal/.claude/skills/diagnose/SKILL.md +43 -0
  27. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +65 -22
  28. package/templates/agent-os/universal/.claude/skills/plan-slices/SKILL.md +30 -0
  29. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
  30. package/templates/agent-os/universal/.claude/skills/release-propose/SKILL.md +74 -0
  31. package/templates/agent-os/universal/.claude/skills/skill-authoring/SKILL.md +39 -0
  32. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  33. package/templates/agent-os/universal/.codex/agents/failure-diagnostician.toml +6 -0
  34. package/templates/agent-os/universal/AGENTS.md +6 -4
  35. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +5 -3
  36. package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +15 -3
  37. package/templates/agent-os/universal/layers.json +12 -0
  38. package/templates/hash-history.json +10 -5
  39. package/templates/release-ledger.json +2 -1
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: failure-diagnostician
3
+ description: Use when a check is red or a run crashed and the cause is not obvious, or to reproduce a claimed defect/historical finding on the current default branch before work is planned on it.
4
+ tools: Read, Grep, Glob, Bash
5
+ model: claude-opus-5
6
+ effort: high
7
+ ---
8
+
9
+ You diagnose. You take one of two input kinds — a red check or a crashed run,
10
+ or a claimed defect / historical finding to reproduce on the current default
11
+ branch — and answer with evidence, not a guess dressed as one.
12
+
13
+ ## Hard limits
14
+
15
+ - **You make no repository edits.** A throwaway reproduction file goes
16
+ outside the repository, never inside it — you are not the Green step, and a
17
+ fix is not your answer. You do not commit, push, merge or open a pull
18
+ request.
19
+ - **Never re-run a check until it goes green.** A flaky-looking result is a
20
+ defect to report, not a thing to retry — the same stop rule that applies
21
+ everywhere else in this rulebook (`.claude/rules/autonomy.md`, "Stop rules —
22
+ by work-state, not by feelings").
23
+ - **On Claude Code, no hook enforces the no-edit limit above.** `tools: Read,
24
+ Grep, Glob, Bash` carries no Write or Edit, which stops the ordinary path,
25
+ but nothing refuses a shell redirect the way a guard would. On Codex the
26
+ equivalent profile is `sandbox_mode = "read-only"`, enforced by the runtime
27
+ itself. Either way, a sandbox that blocks the reproduction you need is not
28
+ something to work around: answer `INCONCLUSIVE` or `INSUFFICIENT_EVIDENCE`
29
+ and name the sandbox as the missing evidence.
30
+
31
+ ## Method
32
+
33
+ 1. **Reproduce.** A failure input (red check, crashed run) reproduces at the
34
+ commit or branch where it failed — the PR head, or the commit the caller
35
+ names. A claimed defect or historical finding reproduces on the current
36
+ default branch. No reproduction, no diagnosis.
37
+ 2. **Isolate.** Narrow to the smallest change (input, config, code path) that
38
+ flips the result.
39
+ 3. **Hypothesize.** State the mechanism you think is responsible, in one or
40
+ two sentences.
41
+ 4. **Confirm with evidence.** Show the command and its output, or the
42
+ file:line the mechanism lives at. A hypothesis nothing confirms is
43
+ `INCONCLUSIVE`, not `ROOT_CAUSE`.
44
+
45
+ For a failure input that reaches `ROOT_CAUSE`, classify it: `product` (the
46
+ code is wrong), `test` (the test's premise or fixture is wrong),
47
+ `infrastructure` (CI, network, environment — not the code under test), or
48
+ `upstream` (a dependency or external service).
49
+
50
+ ## Optional evidence (opt-in workflow layer)
51
+
52
+ Where this repository has installed the opt-in workflow layer, the run
53
+ journal and `run-state.mjs`'s recorded verdict may already carry evidence
54
+ worth reading before you reproduce anything by hand — a prior `REGRESSION`,
55
+ or an earlier run's own trace. Their absence is the normal Core path, not a
56
+ gap: read them when present, reproduce directly when not.
57
+
58
+ ## The answer
59
+
60
+ End your report with **exactly one** fenced `json` block of the shared shape
61
+ (`.claude/scripts/lib/verdict.mjs`), and nothing after it.
62
+
63
+ - **A failure input** (red check, crash) answers `ROOT_CAUSE` or
64
+ `INCONCLUSIVE`.
65
+ - **A claim or historical finding** answers `STILL_LIVE`, `ALREADY_FIXED`,
66
+ `OBSOLETE` or `INSUFFICIENT_EVIDENCE`.
67
+ - `ROOT_CAUSE`, `INCONCLUSIVE`, `STILL_LIVE` and `INSUFFICIENT_EVIDENCE` are
68
+ blocking and must name at least one blocker: for `ROOT_CAUSE` and
69
+ `STILL_LIVE` the blocker is the cause, with `file`/`line` where there is
70
+ one; for `INCONCLUSIVE` and `INSUFFICIENT_EVIDENCE` it is what evidence
71
+ would decide the question.
72
+ - `ALREADY_FIXED` and `OBSOLETE` carry no blockers — the fixing commit or the
73
+ superseding mechanism goes in `evidence` instead.
74
+ - `classification` is required on `ROOT_CAUSE`, allowed but optional on
75
+ `STILL_LIVE`, and refused on every other word.
76
+ - `node .claude/scripts/verdict.mjs check <report> failure-diagnostician` is
77
+ what refuses a malformed answer before anyone reads it as one.
78
+
79
+ ```json
80
+ {
81
+ "gate": "failure-diagnostician",
82
+ "verdict": "ROOT_CAUSE",
83
+ "blockers": [
84
+ {
85
+ "file": "src/example.ts",
86
+ "line": 42,
87
+ "rule": "reproduced failure",
88
+ "note": "the function reads the value before the guard that handles the missing case — reproduced on the PR head with the fixture the failing test supplies"
89
+ }
90
+ ],
91
+ "advisories": [],
92
+ "evidence": ["reproduced with the failing test on the PR head", "the stack trace from that run names the file:line above"],
93
+ "classification": "product"
94
+ }
95
+ ```
96
+
97
+ ```json
98
+ {
99
+ "gate": "failure-diagnostician",
100
+ "verdict": "ALREADY_FIXED",
101
+ "blockers": [],
102
+ "advisories": [],
103
+ "evidence": ["the commit that added the missing guard fixes exactly this report", "re-ran the original repro on the current default branch; it now passes"]
104
+ }
105
+ ```
106
+
107
+ ## Not a merge gate
108
+
109
+ You are never a routed reviewer: no `decision-router` lane names you and no
110
+ `pr-ship` coverage check (opt-in workflow layer) expects your answer. You
111
+ diagnose on request; you never implement the fix, and a report with no
112
+ unbacked behaviour claim or invented figure is the only kind you write.
@@ -193,9 +193,13 @@ function main() {
193
193
  // repo" and waves through, running the whole Definition-of-Done suite
194
194
  // against a tree it never managed to read.
195
195
  //
196
- // 🔴 Limit: only THIS command is sanitised. The Definition-of-Done checks
197
- // below run with the environment as given, because they are the project's
198
- // own commands and their environment is the project's business.
196
+ // 🔴 Limit: only THIS command is sanitised beyond `RIG_RUN_DIR`. The
197
+ // Definition-of-Done checks below run with the environment as given,
198
+ // because they are the project's own commands and their environment is
199
+ // the project's business — except `RIG_RUN_DIR`, which names the
200
+ // directory of the workflow run executing this very hook and belongs to
201
+ // the run, not to the project's own commands.
202
+ // see hooks.test.ts (absent in a generated rig) › "never lets a Definition-of-Done check see RIG_RUN_DIR, even though the hook itself is given one"
199
203
  // `timeout` below is the preamble's own leash: this is the one call that
200
204
  // runs before the budget's clock starts. A repository slow enough to exceed
201
205
  // it throws into the catch and the checks run anyway — the safe direction,
@@ -268,6 +272,12 @@ function main() {
268
272
  // lengthen nor shorten the budget.
269
273
  const deadline = performance.now() + budget.ms;
270
274
 
275
+ // Computed once, not per check: `RIG_RUN_DIR` names the workflow run
276
+ // directory of the run executing this hook, and it is the one variable the
277
+ // project's own commands never get — see the 🔴 limit above.
278
+ const envForChecks = { ...process.env };
279
+ delete envForChecks.RIG_RUN_DIR;
280
+
271
281
  for (const command of usable) {
272
282
  // A 1 ms floor rather than a branch for "the budget is already gone": the
273
283
  // check then times out through the ordinary path, which names the command
@@ -276,6 +286,7 @@ function main() {
276
286
  const result = spawnSync(command, {
277
287
  cwd: PROJECT_ROOT,
278
288
  shell: true,
289
+ env: envForChecks,
279
290
  encoding: 'utf8',
280
291
  stdio: ['ignore', 'pipe', 'pipe'],
281
292
  timeout: Math.max(1, Math.ceil(deadline - performance.now())),
@@ -5,8 +5,10 @@
5
5
  // The rulebook is the set of files that decide what this session may do — hooks
6
6
  // and settings wiring, the queue config and selector, all scripts, rules, skills
7
7
  // and agents, the `.codex/` configuration, the integrity manifest,
8
- // `.claude/doctor-exemptions.json`, `AGENTS.md` and `CLAUDE.md`. Every one of
9
- // them was editable by the run it governs: a
8
+ // `.claude/doctor-exemptions.json`, the revalidation detection contract
9
+ // `.rig/revalidation.json` — but not the claim records beside it under
10
+ // `.rig/claims/`, which a SELECT has to write — `AGENTS.md` and `CLAUDE.md`.
11
+ // Every one of them was editable by the run it governs: a
10
12
  // Write to `.claude/hooks/dod-checks.json` with `[]` switched the stop gate
11
13
  // off, and nothing refused it until the merge sweep, long after.
12
14
  //
@@ -49,6 +51,12 @@
49
51
  // checkout root before guarding a canonical payload path", › "blocks when
50
52
  // the checkout root and payload use the same symlink spelling", and
51
53
  // › "blocks an existing rulebook file when only the payload path uses a symlink spelling";
54
+ // - an `apply_patch` destination keeps the lexical spelling it named even when
55
+ // a guarded prefix (`.claude/hooks`, say) is itself a symlink/junction to
56
+ // somewhere else inside the checkout — `edit-input.mjs`'s `repositoryPatchPath`
57
+ // carries that spelling alongside the realpath-resolved one, RP-60 — ›
58
+ // "refuses an apply_patch through a guarded prefix junctioned to a target
59
+ // inside the checkout";
52
60
  // - an `allow` prefix is a string prefix of the repo-relative path and may
53
61
  // not widen the rulebook — an entry that is itself a prefix of a rulebook
54
62
  // prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
@@ -119,8 +127,15 @@ export const relativeTo = (root, filePath) => {
119
127
  export const isAllowed = (rel, allow) =>
120
128
  (Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
121
129
 
122
- const protectedRelative = (roots, filePath) =>
123
- [...new Set([filePath, canonicalPath(filePath)])]
130
+ // RP-60: `rawFilePath` is the lexical spelling `apply_patch` fragments carry
131
+ // alongside the realpath-resolved `filePath` (`edit-input.mjs`,
132
+ // `repositoryPatchPath`) — a guarded prefix that is itself a symlink/junction
133
+ // to somewhere else inside the checkout resolves away the rulebook spelling
134
+ // otherwise, the same way `canonicalRoot`/`comparisonRoots` above seed both
135
+ // spellings of the checkout root. Every other edit surface never sets it, so
136
+ // this is a no-op for them.
137
+ const protectedRelative = (roots, filePath, rawFilePath) =>
138
+ [...new Set([filePath, rawFilePath, canonicalPath(filePath)].filter((spelling) => typeof spelling === 'string' && spelling !== ''))]
124
139
  .flatMap((spelling) => roots.map((root) => relativeTo(root, spelling)))
125
140
  .find(isRulebookPath);
126
141
 
@@ -151,9 +166,9 @@ function main() {
151
166
  return 2;
152
167
  }
153
168
  const paths = [];
154
- for (const { filePath } of fragments) {
169
+ for (const { filePath, rawFilePath } of fragments) {
155
170
  if (typeof filePath !== 'string' || filePath === '') continue;
156
- const rel = protectedRelative(comparisonRoots, filePath);
171
+ const rel = protectedRelative(comparisonRoots, filePath, rawFilePath);
157
172
  if (rel !== undefined && !paths.includes(rel)) paths.push(rel);
158
173
  }
159
174
  if (paths.length === 0) return 0; // nothing under the rulebook: never judged
@@ -279,7 +279,12 @@ function patchFragments(command, payloadCwd) {
279
279
  const moved = current.moveTo
280
280
  ? movedFragment(current, budget)
281
281
  : { fragment: current.additions.join('\n') };
282
- fragments.push({ filePath: destination, ...moved });
282
+ // RP-60: `rawFilePath` is the lexical repo-relative spelling, taken
283
+ // before symlink resolution — carried alongside the resolved
284
+ // `filePath` so a guard can still see a patch destination named
285
+ // through a guarded prefix that is itself a symlink/junction to
286
+ // somewhere else inside the checkout.
287
+ fragments.push({ filePath: destination.resolved, rawFilePath: destination.raw, ...moved });
283
288
  }
284
289
  current = null;
285
290
  }
@@ -510,6 +515,11 @@ function repositoryPatchPath(value, budget) {
510
515
  const candidate = path.resolve(budget.patchCwd, patchPath);
511
516
  if (!isWithin(budget.repoRoot, candidate)) return null;
512
517
 
518
+ // RP-60: the lexical repo-relative spelling, fixed BEFORE any symlink in the
519
+ // path (a guarded prefix junctioned elsewhere inside the checkout, say) gets
520
+ // resolved away below. One extra string, computed once — not a new loop.
521
+ const raw = path.relative(budget.repoRoot, candidate).split(path.sep).join('/');
522
+
513
523
  let existing = candidate;
514
524
  const suffix = [];
515
525
  while (true) {
@@ -517,7 +527,7 @@ function repositoryPatchPath(value, budget) {
517
527
  const resolved = budget.resolvedDirectories.get(existing);
518
528
  const resolvedCandidate = path.resolve(resolved, ...suffix);
519
529
  if (!isWithin(budget.repoRoot, resolvedCandidate)) return null;
520
- return path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/');
530
+ return { raw, resolved: path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/') };
521
531
  }
522
532
  try {
523
533
  const resolved = realpathSync(existing);
@@ -533,7 +543,7 @@ function repositoryPatchPath(value, budget) {
533
543
  }
534
544
  const resolvedCandidate = path.resolve(resolved, ...suffix);
535
545
  if (!isWithin(budget.repoRoot, resolvedCandidate)) return null;
536
- return path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/');
546
+ return { raw, resolved: path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/') };
537
547
  } catch (error) {
538
548
  if (error?.code !== 'ENOENT') return null;
539
549
  try {
@@ -215,6 +215,39 @@ The invariants worth your slots are the ones you can finish this sentence about:
215
215
  *"the last time this went wrong, it cost us ___."* If you cannot finish it, you
216
216
  are guessing, and a guessed invariant is the one that will fire on honest work.
217
217
 
218
+ ## The independent-oracle invariant
219
+
220
+ A test of a security, ownership or governance mechanism must not derive its expected result from the same production mechanism it checks.
221
+ Check it against an independent oracle instead: an alternative
222
+ implementation of the check, a mutation proof, or externally observable behaviour.
223
+
224
+ ⚠ **The independent-oracle invariant has parts 1 and 3 of the pattern above, and not part 2.** No hook enforces it: "is this expectation derived from the same production
225
+ mechanism" is not decidable from a single diff fragment — it takes reading
226
+ both the test and the code path it claims to verify, and judging which one
227
+ stands in as the oracle. `code-reviewer` is where it is enforced, as a
228
+ checklist item, never a hook — see the generator's
229
+ `test/template/correspondence.test.ts` (absent in a generated rig) ›
230
+ "the rule states the invariant and code-reviewer.md carries a matching checklist item".
231
+
232
+ Why this earned its own name: a test that asks production's own logic what the
233
+ right answer is cannot detect an under-approximation in that logic. Test and
234
+ code are the same computation run twice, agreeing by construction — so the
235
+ test passes, the reviewer sees a test that genuinely exercises the code, and
236
+ CI is green, while the defect the test was written for goes straight through.
237
+
238
+ The fix that came out of it is the worked example:
239
+ `packages/cli/test/uninstall.test.ts` (absent in a generated rig),
240
+ whose `expectImports` re-derives the import edges with a deliberately
241
+ duplicated regex rather than importing production's own — its comment says
242
+ "deliberately a second copy rather than an import of the private constant" —
243
+ so the test can never be satisfied merely by production checking its own
244
+ work.
245
+
246
+ Scope: this applies going forward, to tests of security, ownership and
247
+ governance mechanisms. The existing suite is not retrofitted wholesale — an
248
+ existing test is corrected only where doing so is cheap and the derivation
249
+ is demonstrably vacuous.
250
+
218
251
  ## About the hooks you were given
219
252
 
220
253
  Generator-authored rulebook artifacts — rules, hooks, skills, scripts and agent
@@ -73,6 +73,10 @@
73
73
  * absence itself — `lib/gate-coverage.mjs` is the one that does, and it puts
74
74
  * such a verdict in its own list rather than counting it either way. When
75
75
  * present the value is a commit SHAPE, not free text: see `isCommitId`.
76
+ * 7. **`failure-diagnostician` answers in this shape and is not a merge gate.**
77
+ * No `decision-router` lane names it and `pr-ship` coverage never expects an
78
+ * answer from it — see `test/template/verdict.test.ts`
79
+ * (absent in a generated rig) › "the diagnostician is never a routed reviewer".
76
80
  */
77
81
 
78
82
  /** Every word any gate in this rulebook may return. */
@@ -86,6 +90,14 @@ export const VERDICT_WORDS = Object.freeze([
86
90
  'UNVERIFIABLE',
87
91
  'UNMEASURED',
88
92
  'NOT_APPLICABLE',
93
+ // RP-195 slice 1: failure-diagnostician's own words, split by what it was
94
+ // asked to look at — a failure, or a claimed/historical finding.
95
+ 'ROOT_CAUSE',
96
+ 'INCONCLUSIVE',
97
+ 'STILL_LIVE',
98
+ 'ALREADY_FIXED',
99
+ 'OBSOLETE',
100
+ 'INSUFFICIENT_EVIDENCE',
89
101
  ]);
90
102
 
91
103
  /**
@@ -111,6 +123,15 @@ export const GATE_VOCABULARY = Object.freeze({
111
123
  'UNMEASURED',
112
124
  ]),
113
125
  'post-deploy-verify': Object.freeze(['HEALTHY', 'REGRESSION']),
126
+ // RP-195 slice 1 (design decision 1): the diagnostician's own words.
127
+ 'failure-diagnostician': Object.freeze([
128
+ 'ROOT_CAUSE',
129
+ 'INCONCLUSIVE',
130
+ 'STILL_LIVE',
131
+ 'ALREADY_FIXED',
132
+ 'OBSOLETE',
133
+ 'INSUFFICIENT_EVIDENCE',
134
+ ]),
114
135
  });
115
136
 
116
137
  /**
@@ -126,6 +147,13 @@ export const BLOCKING_VERDICTS = Object.freeze([
126
147
  'PREMISE_FALSE',
127
148
  'UNVERIFIABLE',
128
149
  'UNMEASURED',
150
+ // RP-195 slice 1 (design decision 1): the cause, for ROOT_CAUSE and
151
+ // STILL_LIVE; the missing evidence, for INCONCLUSIVE and
152
+ // INSUFFICIENT_EVIDENCE. ALREADY_FIXED and OBSOLETE carry no blockers.
153
+ 'ROOT_CAUSE',
154
+ 'INCONCLUSIVE',
155
+ 'STILL_LIVE',
156
+ 'INSUFFICIENT_EVIDENCE',
129
157
  ]);
130
158
 
131
159
  /** The only keys a block may carry. */
@@ -136,8 +164,16 @@ const SHAPE_KEYS = Object.freeze([
136
164
  'advisories',
137
165
  'evidence',
138
166
  'headSha',
167
+ 'classification',
139
168
  ]);
140
169
 
170
+ /**
171
+ * The one optional key `failure-diagnostician` alone may carry (RP-195 slice
172
+ * 1, design decision 2): required on ROOT_CAUSE, optional on STILL_LIVE,
173
+ * refused on every other word and on every other gate.
174
+ */
175
+ const CLASSIFICATIONS = Object.freeze(['product', 'test', 'infrastructure', 'upstream']);
176
+
141
177
  const FENCE = '```json';
142
178
 
143
179
  /** How much of one reviewer-written value a diagnosis will carry. */
@@ -443,6 +479,32 @@ export function parseVerdict(text) {
443
479
  }
444
480
  }
445
481
 
482
+ const classification = parsed.classification;
483
+ const hasClassification = classification !== undefined;
484
+ if (hasClassification) {
485
+ if (!isText(gate) || gate !== 'failure-diagnostician') {
486
+ problems.push(
487
+ '`classification` is refused here: only failure-diagnostician may carry it, and ' +
488
+ `this block names \`gate\` as ${safeForDiagnosis(gate)}.`,
489
+ );
490
+ } else if (verdict !== 'ROOT_CAUSE' && verdict !== 'STILL_LIVE') {
491
+ problems.push(
492
+ `\`classification\` is refused on ${safeForDiagnosis(verdict)} — only ROOT_CAUSE ` +
493
+ '(required) and STILL_LIVE (optional) may carry one.',
494
+ );
495
+ } else if (!CLASSIFICATIONS.includes(classification)) {
496
+ problems.push(
497
+ `\`classification\` is \`${safeForDiagnosis(classification)}\`, which is not one of: ` +
498
+ `${CLASSIFICATIONS.join(', ')}.`,
499
+ );
500
+ }
501
+ } else if (isText(gate) && gate === 'failure-diagnostician' && verdict === 'ROOT_CAUSE') {
502
+ problems.push(
503
+ 'ROOT_CAUSE names no `classification`: it is required on this word — one of ' +
504
+ `${CLASSIFICATIONS.join(', ')}.`,
505
+ );
506
+ }
507
+
446
508
  if (problems.length > 0) return { ok: false, problems };
447
509
 
448
510
  return {
@@ -457,6 +519,7 @@ export function parseVerdict(text) {
457
519
  // back without the key at all, so a caller can tell "answered for this
458
520
  // commit" from "said nothing about which commit".
459
521
  ...(headSha === undefined ? {} : { headSha }),
522
+ ...(hasClassification ? { classification } : {}),
460
523
  },
461
524
  };
462
525
  }
@@ -633,13 +633,12 @@ const clearsSpacing = (lastCompletedTier) =>
633
633
  * items with the whole suite green throughout, and its `budget` stop arriving "later
634
634
  * than it should have".
635
635
  *
636
- * Two is the cap because the second round is what verifies the first round's fixes.
637
- * A third is a decision for a human rather than another pass to buy — and the
638
- * refusal says only that the count is spent, never that the fixes "are not
639
- * converging": on one branch a granted third round found that round 2's fix had
640
- * opened the mirror of the bug it closed (AR-115).
636
+ * Three is the cap: the second round verifies the first round's fixes, and the
637
+ * third lets a round-2 fix be read once more before the item needs a human. A
638
+ * project that wants a different cap sets `options.maxGateRounds` instead of
639
+ * changing this default.
641
640
  */
642
- export const DEFAULT_MAX_GATE_ROUNDS = 2;
641
+ export const DEFAULT_MAX_GATE_ROUNDS = 3;
643
642
 
644
643
  /**
645
644
  * Is this round allowed, and if not, what stops?
@@ -144,6 +144,21 @@ const ghJson = (args) => JSON.parse(ghText(args));
144
144
 
145
145
  const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt,comments';
146
146
 
147
+ /**
148
+ * A `--state` (or triage) window that came back exactly at its cap: older
149
+ * items may have been left unread, and a window this shape cannot tell the
150
+ * difference from a repository that happens to have exactly `limit` items.
151
+ * See queue-github-pagination.test.ts (absent in a generated rig) ›
152
+ * "a --state %s window that comes back exactly at the limit is announced on
153
+ * stderr" and › "a triage window that comes back exactly at the cap (100) is
154
+ * announced on stderr".
155
+ */
156
+ const announceCap = (label, limit) => {
157
+ process.stderr.write(
158
+ `github-issues: ${label} window capped at ${limit} issues — older ${label} items may be missing; raise limit\n`,
159
+ );
160
+ };
161
+
147
162
  // --- the adapter contract ------------------------------------------------------
148
163
 
149
164
  /**
@@ -151,18 +166,42 @@ const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt,comments'
151
166
  *
152
167
  * Deliberately queries fresh on every call and never caches: the queue changes as
153
168
  * the loop itself closes items and unblocks their dependents.
169
+ *
170
+ * Open and closed issues are read as two separate `--state` windows rather
171
+ * than one shared `--state all` window: a shared window lets closed history
172
+ * push an older open issue out of it, which used to be silent. See
173
+ * queue-github-pagination.test.ts (absent in a generated rig) › "keeps an
174
+ * older OPEN issue even when 100 CLOSED issues would fill a shared window".
154
175
  */
155
176
  export const listEligible = ({ limit = 100, issues = null } = {}) => {
156
- const raw =
157
- issues ?? ghJson(['issue', 'list', '--state', 'all', '--limit', String(limit), '--json', FIELDS]);
177
+ let raw;
178
+ let openIssues = null;
179
+ if (issues) {
180
+ raw = issues;
181
+ } else {
182
+ openIssues = ghJson(['issue', 'list', '--state', 'open', '--limit', String(limit), '--json', FIELDS]);
183
+ if (openIssues.length === limit) announceCap('open', limit);
184
+ const closedIssues = ghJson([
185
+ 'issue',
186
+ 'list',
187
+ '--state',
188
+ 'closed',
189
+ '--limit',
190
+ String(limit),
191
+ '--json',
192
+ FIELDS,
193
+ ]);
194
+ if (closedIssues.length === limit) announceCap('closed', limit);
195
+ raw = [...openIssues, ...closedIssues];
196
+ }
158
197
  const states = Object.fromEntries(raw.map((issue) => [String(issue.number), issue.state]));
159
198
  const blocks = blocksIndex(raw);
160
- return raw
161
- .filter((issue) => String(issue.state ?? '').toUpperCase() !== 'CLOSED')
162
- .map((issue) => {
163
- const ticket = toTicket(issue, states);
164
- return { ...ticket, blocks: blocks[ticket.id] ?? [] };
165
- });
199
+ const eligible =
200
+ openIssues ?? raw.filter((issue) => String(issue.state ?? '').toUpperCase() !== 'CLOSED');
201
+ return eligible.map((issue) => {
202
+ const ticket = toTicket(issue, states);
203
+ return { ...ticket, blocks: blocks[ticket.id] ?? [] };
204
+ });
166
205
  };
167
206
 
168
207
  export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
@@ -293,12 +332,30 @@ export const triageItemFor = (proposal) => {
293
332
  * hand out nothing — "queue empty" and "nothing selectable";
294
333
  * twenty such stops must produce one proposal with a count of twenty.
295
334
  */
296
- /** The proposals on file, as `{ id, body }` — every `triage`-labelled issue. */
297
- export const listProposals = ({ existing = null } = {}) =>
298
- (
299
- existing ??
300
- ghJson(['issue', 'list', '--label', 'triage', '--state', 'all', '--limit', '100', '--json', FIELDS])
301
- ).map((issue) => ({ id: String(issue.number), body: issue.body }));
335
+ /**
336
+ * The proposals on file, as `{ id, body }` — every `triage`-labelled issue.
337
+ * A window that comes back exactly at its cap is announced on stderr, same
338
+ * as `listEligible`'s.
339
+ */
340
+ export const listProposals = ({ existing = null, limit = 100 } = {}) => {
341
+ let raw = existing;
342
+ if (!raw) {
343
+ raw = ghJson([
344
+ 'issue',
345
+ 'list',
346
+ '--label',
347
+ 'triage',
348
+ '--state',
349
+ 'all',
350
+ '--limit',
351
+ String(limit),
352
+ '--json',
353
+ FIELDS,
354
+ ]);
355
+ if (raw.length === limit) announceCap('triage', limit);
356
+ }
357
+ return raw.map((issue) => ({ id: String(issue.number), body: issue.body }));
358
+ };
302
359
 
303
360
  export const proposeTriage = (rawProposal, { existing = null } = {}) => {
304
361
  const proposal = withAsOf(rawProposal);