create-agent-rig 0.10.1 → 1.0.1

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 (44) hide show
  1. package/CHANGELOG.md +170 -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/packages/cli/dist/integrations/doctor-guards.js +7 -3
  7. package/templates/agent-os/subagent-routing.json +9 -5
  8. package/templates/agent-os/universal/.agents/skills/diagnose/SKILL.md +43 -0
  9. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +65 -22
  10. package/templates/agent-os/universal/.agents/skills/plan-slices/SKILL.md +30 -0
  11. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
  12. package/templates/agent-os/universal/.agents/skills/release-propose/SKILL.md +74 -0
  13. package/templates/agent-os/universal/.agents/skills/skill-authoring/SKILL.md +39 -0
  14. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +5 -1
  15. package/templates/agent-os/universal/.claude/agents/failure-diagnostician.md +112 -0
  16. package/templates/agent-os/universal/.claude/agents/security-scanner.md +1 -1
  17. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +14 -3
  18. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +142 -14
  19. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +5 -1
  20. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +168 -17
  21. package/templates/agent-os/universal/.claude/rules/invariants.md +33 -0
  22. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +63 -0
  23. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +5 -6
  24. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +71 -14
  25. package/templates/agent-os/universal/.claude/scripts/queue/propose.mjs +139 -0
  26. package/templates/agent-os/universal/.claude/scripts/release-evidence.mjs +188 -0
  27. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +4 -2
  28. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +157 -10
  29. package/templates/agent-os/universal/.claude/skills/diagnose/SKILL.md +43 -0
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +65 -22
  31. package/templates/agent-os/universal/.claude/skills/plan-slices/SKILL.md +30 -0
  32. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
  33. package/templates/agent-os/universal/.claude/skills/release-propose/SKILL.md +74 -0
  34. package/templates/agent-os/universal/.claude/skills/skill-authoring/SKILL.md +39 -0
  35. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
  36. package/templates/agent-os/universal/.codex/agents/failure-diagnostician.toml +6 -0
  37. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  38. package/templates/agent-os/universal/AGENTS.md +11 -5
  39. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +1 -1
  40. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +5 -3
  41. package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +15 -3
  42. package/templates/agent-os/universal/layers.json +12 -0
  43. package/templates/hash-history.json +118 -22
  44. package/templates/release-ledger.json +3 -1
@@ -4,7 +4,11 @@
4
4
  * Claude sends Write/Edit fields directly. Codex sends an apply_patch command,
5
5
  * so added lines are returned for ordinary edits. A move is different: the
6
6
  * destination receives the existing file too, so guards inspect the resulting
7
- * content instead of only the patch additions when inspection succeeds.
7
+ * content instead of only the patch additions when inspection succeeds. A
8
+ * removal — a `*** Delete File:` section, or the source half of a
9
+ * `*** Move to:` — also becomes its own fragment, `removes: true`, `fragment:
10
+ * ''` (RP-214): the path stops existing, which a guard judging paths still
11
+ * needs to see even though there is no text to inspect.
8
12
  *
9
13
  * Inspection is bounded globally per patch: sources, hunks, output, splices,
10
14
  * comparisons, sections and path components.
@@ -240,6 +244,21 @@ function patchFragments(command, payloadCwd) {
240
244
  let current = null;
241
245
  let patchRefusal = null;
242
246
 
247
+ // RP-214: one counter, spent on every path this section resolves — the
248
+ // destination (or the removed path, for a Delete File section) and, for a
249
+ // Move, the source too (below). Still one forward pass, still the same
250
+ // bound.
251
+ const overPathComponentBudget = (value) => {
252
+ budget.pathComponents += String(value ?? '').replaceAll('\\', '/').split('/').length;
253
+ return budget.pathComponents > MAX_PATCH_PATH_COMPONENTS;
254
+ };
255
+ const pathComponentRefusal = () => ({
256
+ filePath: '',
257
+ fragment: '',
258
+ inspectionRefusal: `apply_patch destination path component count exceeds the ${MAX_PATCH_PATH_COMPONENTS}-component inspection limit`,
259
+ appliesToAll: true,
260
+ });
261
+
243
262
  const flush = () => {
244
263
  if (current !== null) {
245
264
  budget.sections += 1;
@@ -254,16 +273,8 @@ function patchFragments(command, payloadCwd) {
254
273
  return false;
255
274
  }
256
275
  const destinationPath = current.moveTo ?? current.sourcePath;
257
- budget.pathComponents += String(destinationPath ?? '')
258
- .replaceAll('\\', '/')
259
- .split('/').length;
260
- if (budget.pathComponents > MAX_PATCH_PATH_COMPONENTS) {
261
- patchRefusal = {
262
- filePath: '',
263
- fragment: '',
264
- inspectionRefusal: `apply_patch destination path component count exceeds the ${MAX_PATCH_PATH_COMPONENTS}-component inspection limit`,
265
- appliesToAll: true,
266
- };
276
+ if (overPathComponentBudget(destinationPath)) {
277
+ patchRefusal = pathComponentRefusal();
267
278
  current = null;
268
279
  return false;
269
280
  }
@@ -274,12 +285,58 @@ function patchFragments(command, payloadCwd) {
274
285
  fragment: '',
275
286
  inspectionRefusal: 'patch destination is outside the repository or cannot be resolved safely',
276
287
  appliesToAll: true,
288
+ // RP-214: a Delete File section has no destination — only the
289
+ // removed path itself — so this refusal is about a removal too;
290
+ // carry the flag so a guard that must not treat a removal as a
291
+ // write (guard-secret-file) can still tell the two apart.
292
+ ...(current.removes ? { removes: true } : {}),
293
+ });
294
+ } else if (current.removes) {
295
+ // RP-214: `*** Delete File: <path>` used to flush only the SECTION
296
+ // BEFORE it and never turn the removed path itself into a fragment —
297
+ // resolved the same way every other verb resolves its path, through
298
+ // `repositoryPatchPath` and the same budgets above, in this same pass.
299
+ fragments.push({
300
+ filePath: destination.resolved,
301
+ rawFilePath: destination.raw,
302
+ fragment: '',
303
+ removes: true,
277
304
  });
278
305
  } else {
279
306
  const moved = current.moveTo
280
307
  ? movedFragment(current, budget)
281
308
  : { fragment: current.additions.join('\n') };
282
- fragments.push({ filePath: destination, ...moved });
309
+ // RP-60: `rawFilePath` is the lexical repo-relative spelling, taken
310
+ // before symlink resolution — carried alongside the resolved
311
+ // `filePath` so a guard can still see a patch destination named
312
+ // through a guarded prefix that is itself a symlink/junction to
313
+ // somewhere else inside the checkout.
314
+ fragments.push({ filePath: destination.resolved, rawFilePath: destination.raw, ...moved });
315
+
316
+ if (current.moveTo) {
317
+ // RP-214: a Move's SOURCE stops existing too — it is a removal the
318
+ // line above never surfaced, because `current.moveTo ?? …`
319
+ // resolves only the destination. One more path through the same
320
+ // resolver and the same budgets, still inside this one flush.
321
+ if (overPathComponentBudget(current.sourcePath)) {
322
+ patchRefusal = pathComponentRefusal();
323
+ current = null;
324
+ return false;
325
+ }
326
+ const source = repositoryPatchPath(current.sourcePath, budget);
327
+ fragments.push(
328
+ source === null
329
+ ? {
330
+ filePath: '',
331
+ fragment: '',
332
+ inspectionRefusal:
333
+ 'patch destination is outside the repository or cannot be resolved safely',
334
+ appliesToAll: true,
335
+ removes: true,
336
+ }
337
+ : { filePath: source.resolved, rawFilePath: source.raw, fragment: '', removes: true },
338
+ );
339
+ }
283
340
  }
284
341
  current = null;
285
342
  }
@@ -298,7 +355,17 @@ function patchFragments(command, payloadCwd) {
298
355
  current.moveTo = move[1];
299
356
  continue;
300
357
  }
301
- if (/^\*\*\* (?:Delete File|End Patch)/.test(line)) {
358
+ // RP-214: a Delete File section becomes its own fragment — `removes:
359
+ // true`, resolved through the same path the other verbs use — instead of
360
+ // only flushing whatever section came before it.
361
+ const del = /^\*\*\* Delete File: (.+)$/.exec(line);
362
+ if (del) {
363
+ if (!flush()) break;
364
+ current = { sourcePath: del[1], moveTo: null, additions: [], hunks: [], activeHunk: null, removes: true };
365
+ if (!flush()) break;
366
+ continue;
367
+ }
368
+ if (/^\*\*\* End Patch/.test(line)) {
302
369
  if (!flush()) break;
303
370
  continue;
304
371
  }
@@ -481,9 +548,88 @@ function inspectionRefusal(current, reason) {
481
548
  return { fragment: current.additions.join('\n'), inspectionRefusal: reason };
482
549
  }
483
550
 
551
+ // RP-244: a Win32 verbatim (`\\?\`) or device-namespace (`\\.\`) prefix, in
552
+ // any slash mix, names the plain drive (or UNC) path underneath it. The
553
+ // backslash-to-slash conversion below would collapse the leading `//` these
554
+ // prefixes depend on, so they are stripped first. Anchored at the very start:
555
+ // a prefix check, not a scan, which keeps this inside the fail-open
556
+ // bounded-work rule.
557
+ //
558
+ // RP-244 round 2: `[\\/]+` (not `[\\/]`) after the `?`/`.` marker, so a
559
+ // doubled separator still strips. A DRIVE match (`\\?\C:\…`) is the only case
560
+ // that yields a plain, prefix-free spelling — every other two-separator input
561
+ // (verbatim UNC, plain UNC, a device path with no drive letter such as
562
+ // `\\?\Volume{GUID}\…`) is judged as UNDECIDABLE by a repository root spelled
563
+ // as a plain path, so it keeps a leading `//` instead, with its remainder
564
+ // normalised as ABSOLUTE (clamped at root) rather than relative — a relative
565
+ // normalisation let a crafted run of `../` segments do unbounded work instead
566
+ // of collapsing at the root.
567
+ const WIN32_VERBATIM_UNC_PREFIX = /^[\\/]{2}[?.][\\/]+UNC(?:[\\/]+|$)/i;
568
+ const WIN32_VERBATIM_DRIVE_PREFIX = /^[\\/]{2}[?.][\\/]+([A-Za-z]:)/;
569
+ // RP-244 round 3: a drive letter followed immediately by a separator — the
570
+ // drive ROOT, as opposed to the drive-RELATIVE `C:foo\bar` spelling below,
571
+ // which carries no separator there. `slashed` has already had every
572
+ // backslash converted to `/` by the time this is applied, so only the
573
+ // forward-slash form is checked.
574
+ const DRIVE_ROOT_PREFIX = /^[A-Za-z]:\//;
575
+ // RP-244 round 4: a drive letter with NO separator immediately following —
576
+ // the DRIVE-RELATIVE spelling, which Win32 resolves against the current
577
+ // directory of that drive rather than the drive root, so (unlike
578
+ // DRIVE_ROOT_PREFIX above) it has no root of its own to clamp at. Mutually
579
+ // exclusive with DRIVE_ROOT_PREFIX by construction: one requires a `/`
580
+ // immediately after the colon, this one requires there is none (including
581
+ // end of string, for a bare `C:`).
582
+ const DRIVE_RELATIVE_PREFIX = /^[A-Za-z]:(?!\/)/;
583
+
584
+ // RP-244 round 3: the DRIVE branch below and the plain fallback both used to
585
+ // run `path.posix.normalize` over the drive letter and its remainder
586
+ // TOGETHER, relatively — so a leading `..` walked straight past the drive
587
+ // letter (`C:/../Users/…` normalised to the relative `Users/…`, not clamped
588
+ // at `C:/`) instead of stopping at the root the way Win32 does, and a long
589
+ // run of `../` segments did unbounded relative work instead of the linear
590
+ // work an absolute normalisation does. Splitting the drive off first and
591
+ // normalising only the remainder, ABSOLUTE, fixes both: `path.posix.normalize`
592
+ // clamps an absolute `..` run at `/` instead of carrying it past the drive.
593
+ //
594
+ // RP-244 round 4: the drive-RELATIVE case (no separator after the colon) got
595
+ // the same together-normalisation treatment via the plain `else` branch below
596
+ // — `path.posix.normalize` reads a leading `C:..` segment as an ordinary
597
+ // filename, not the literal `..`, so a second `..` cancelled it and the
598
+ // drive marker vanished from the result (`C:../../a/b` → `a/b`). Splitting
599
+ // the drive off here too and normalising the remainder alone — RELATIVELY,
600
+ // never absolutely, since a drive-relative spelling has no root to clamp
601
+ // at — keeps a `..` in the remainder from ever reaching back far enough to
602
+ // cancel the marker itself; an empty remainder (a bare `C:`) is left as-is
603
+ // rather than turned into `C:.`, unchanged from before this round.
604
+ function clampAtDriveRoot(slashed) {
605
+ if (DRIVE_ROOT_PREFIX.test(slashed)) {
606
+ return slashed.slice(0, 2) + path.posix.normalize(slashed.slice(2));
607
+ }
608
+ if (DRIVE_RELATIVE_PREFIX.test(slashed)) {
609
+ const remainder = slashed.slice(2);
610
+ return remainder === '' ? slashed : slashed.slice(0, 2) + path.posix.normalize(remainder);
611
+ }
612
+ return path.posix.normalize(slashed);
613
+ }
614
+
484
615
  function normalisePath(value) {
485
- const slashed = String(value ?? '').trim().replaceAll('\\', '/');
486
- return slashed === '' ? '' : path.posix.normalize(slashed);
616
+ const raw = String(value ?? '').trim();
617
+ if (raw === '') return '';
618
+ const driveMatch = WIN32_VERBATIM_DRIVE_PREFIX.exec(raw);
619
+ if (driveMatch) {
620
+ const slashed = raw.replace(WIN32_VERBATIM_DRIVE_PREFIX, '$1').replaceAll('\\', '/');
621
+ return slashed === '' ? '' : clampAtDriveRoot(slashed);
622
+ }
623
+ const uncMatch = WIN32_VERBATIM_UNC_PREFIX.exec(raw);
624
+ if (uncMatch || /^[\\/]{2}/.test(raw)) {
625
+ const rest = (uncMatch ? raw.slice(uncMatch[0].length) : raw.slice(2)).replaceAll('\\', '/');
626
+ // Absolute normalisation, not relative: clamps a leading `../` run at the
627
+ // root instead of carrying it through — the outer `'/' +` restores the
628
+ // `//` marker that `path.posix.normalize` collapses to one.
629
+ return '/' + path.posix.normalize('/' + rest);
630
+ }
631
+ const slashed = raw.replaceAll('\\', '/');
632
+ return slashed === '' ? '' : clampAtDriveRoot(slashed);
487
633
  }
488
634
 
489
635
  function canonicalPatchPath(value) {
@@ -510,6 +656,11 @@ function repositoryPatchPath(value, budget) {
510
656
  const candidate = path.resolve(budget.patchCwd, patchPath);
511
657
  if (!isWithin(budget.repoRoot, candidate)) return null;
512
658
 
659
+ // RP-60: the lexical repo-relative spelling, fixed BEFORE any symlink in the
660
+ // path (a guarded prefix junctioned elsewhere inside the checkout, say) gets
661
+ // resolved away below. One extra string, computed once — not a new loop.
662
+ const raw = path.relative(budget.repoRoot, candidate).split(path.sep).join('/');
663
+
513
664
  let existing = candidate;
514
665
  const suffix = [];
515
666
  while (true) {
@@ -517,7 +668,7 @@ function repositoryPatchPath(value, budget) {
517
668
  const resolved = budget.resolvedDirectories.get(existing);
518
669
  const resolvedCandidate = path.resolve(resolved, ...suffix);
519
670
  if (!isWithin(budget.repoRoot, resolvedCandidate)) return null;
520
- return path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/');
671
+ return { raw, resolved: path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/') };
521
672
  }
522
673
  try {
523
674
  const resolved = realpathSync(existing);
@@ -533,7 +684,7 @@ function repositoryPatchPath(value, budget) {
533
684
  }
534
685
  const resolvedCandidate = path.resolve(resolved, ...suffix);
535
686
  if (!isWithin(budget.repoRoot, resolvedCandidate)) return null;
536
- return path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/');
687
+ return { raw, resolved: path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/') };
537
688
  } catch (error) {
538
689
  if (error?.code !== 'ENOENT') return null;
539
690
  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);
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ // The one repo-root-safe entry point for filing an improvement-triage
3
+ // proposal (RP-209). It resolves its config, and through it the active
4
+ // board's adapter and plan path, from its OWN location — exactly like
5
+ // `index.mjs`'s `projectRoot` — so a session standing in a subdirectory
6
+ // files into the project's real PLAN.md rather than a cwd-relative one
7
+ // that happens not to exist there.
8
+ //
9
+ // node .claude/scripts/queue/propose.mjs --file <proposal.json>
10
+ // node .claude/scripts/queue/propose.mjs --file - # stdin
11
+ // node .claude/scripts/queue/propose.mjs --file <path> --config <queue.json>
12
+ //
13
+ // The proposal object is whatever the active adapter's `proposeTriage`
14
+ // already accepts. The result prints as one JSON line on stdout; the
15
+ // process exits 0 only when `ok === true`. When `RIG_RUN_DIR` is declared,
16
+ // one `proposal` event is recorded in the run journal either way, so a
17
+ // failed filing is journalled as a failure rather than going nowhere
18
+ // silently.
19
+ //
20
+ // See the generator's test/template/queue-propose.test.ts (absent in a
21
+ // generated rig).
22
+ import { readFileSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { loadConfig, optionsWithPlanPath, resolveAdapter } from './index.mjs';
26
+
27
+ const parseArgs = (argv) => {
28
+ const args = { file: null, config: null };
29
+ for (let i = 0; i < argv.length; i += 1) {
30
+ if (argv[i] === '--file') args.file = argv[++i];
31
+ else if (argv[i] === '--config') args.config = argv[++i];
32
+ }
33
+ return args;
34
+ };
35
+
36
+ const readStdin = () =>
37
+ new Promise((resolve, reject) => {
38
+ const chunks = [];
39
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
40
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
41
+ process.stdin.on('error', reject);
42
+ });
43
+
44
+ const readProposalRaw = (file) => (file === '-' ? readStdin() : Promise.resolve(readFileSync(file, 'utf8')));
45
+
46
+ /**
47
+ * Small, deliberately: the journal is a trace of the filing decision, not a
48
+ * second copy of the proposal or of the adapter's whole response.
49
+ */
50
+ const journalDataFor = (result, reason) => {
51
+ const data = { ok: result?.ok === true };
52
+ if (result?.item?.fingerprint !== undefined) data.id = result.item.fingerprint;
53
+ if (result?.filed !== undefined) data.filed = result.filed;
54
+ if (result?.incremented !== undefined) data.incremented = result.incremented;
55
+ if (reason !== undefined) data.reason = reason;
56
+ return data;
57
+ };
58
+
59
+ const main = async () => {
60
+ const args = parseArgs(process.argv.slice(2));
61
+ if (!args.file) {
62
+ process.stderr.write('propose: --file <proposal.json> is required (or --file - for stdin).\n');
63
+ process.exit(1);
64
+ }
65
+
66
+ let raw;
67
+ try {
68
+ raw = await readProposalRaw(args.file);
69
+ } catch (error) {
70
+ process.stderr.write(`propose: could not read ${args.file}: ${error.message}\n`);
71
+ process.exit(1);
72
+ }
73
+
74
+ let proposal;
75
+ try {
76
+ proposal = JSON.parse(raw);
77
+ } catch (error) {
78
+ process.stderr.write(`propose: ${args.file} is not valid JSON: ${error.message}\n`);
79
+ process.exit(1);
80
+ }
81
+
82
+ // Resolved against this file's own URL, not the cwd — the same rule
83
+ // `index.mjs` follows, for the same reason: the CLI runs from the project
84
+ // root, from a worktree, and from a subdirectory the session happens to be
85
+ // standing in.
86
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
87
+ const projectRoot = join(scriptDir, '..', '..', '..');
88
+ const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
89
+
90
+ let result;
91
+ let reason;
92
+ try {
93
+ const config = loadConfig(configPath);
94
+ const adapter = await resolveAdapter(config.adapter ?? 'plan-md');
95
+ const options = optionsWithPlanPath(config.options, configPath);
96
+ result = await adapter.proposeTriage(proposal, options);
97
+ if (result?.ok !== true) reason = result?.why ?? 'proposeTriage returned ok: false';
98
+ } catch (error) {
99
+ reason = error.message ?? String(error);
100
+ result = { ok: false, reason };
101
+ }
102
+
103
+ const exitCode = result?.ok === true ? 0 : 1;
104
+ process.stdout.write(`${JSON.stringify(result)}\n`);
105
+ if (exitCode !== 0) process.stderr.write(`propose: ${reason}\n`);
106
+
107
+ const runDir = process.env.RIG_RUN_DIR;
108
+ if (runDir) {
109
+ let journal = null;
110
+ try {
111
+ journal = await import('../run-journal.mjs');
112
+ journal.recordEvent({
113
+ runDir,
114
+ kind: 'proposal',
115
+ data: journalDataFor(result, reason),
116
+ now: new Date().toISOString(),
117
+ });
118
+ } catch (error) {
119
+ const classify = journal?.isTraceExhausted;
120
+ if (typeof classify === 'function' && classify(error)) {
121
+ // The trace is over; the filing already happened and stands. Loud on
122
+ // stderr, exit code stays whatever the filing decided — mirrors the
123
+ // pattern in `index.mjs` and the `loop` skill's own journal section.
124
+ process.stderr.write(
125
+ `run journal: ${error.message}\n` +
126
+ ` the proposal result above was NOT recorded in ${runDir}. This run's ` +
127
+ "trace ends here; the filing above stands.\n",
128
+ );
129
+ } else {
130
+ process.stderr.write(`run journal: ${error.message}\n`);
131
+ process.exit(1);
132
+ }
133
+ }
134
+ }
135
+
136
+ process.exit(exitCode);
137
+ };
138
+
139
+ main();