create-agent-rig 0.3.2 → 0.5.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 (109) hide show
  1. package/CHANGELOG.md +294 -10
  2. package/README.md +84 -13
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +41 -8
  5. package/packages/cli/dist/commands/init.js +120 -34
  6. package/packages/cli/dist/commands/upgrade.js +382 -0
  7. package/packages/cli/dist/index.js +132 -21
  8. package/packages/cli/dist/lib/copy-tree.js +44 -7
  9. package/packages/cli/dist/lib/history.js +49 -0
  10. package/packages/cli/dist/lib/init-settings.js +12 -0
  11. package/packages/cli/dist/lib/install-set.js +44 -0
  12. package/packages/cli/dist/lib/manifest.js +111 -0
  13. package/packages/cli/dist/lib/prompts.js +20 -0
  14. package/packages/cli/dist/lib/safe-path.js +71 -0
  15. package/packages/cli/dist/lib/substitute.js +32 -0
  16. package/packages/cli/dist/lib/version.js +15 -0
  17. package/templates/agent-os/init/AGENTS.md +191 -0
  18. package/templates/agent-os/init/CLAUDE.md +61 -9
  19. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  20. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  21. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  22. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  23. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  24. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  25. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  26. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  28. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  29. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  30. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  31. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  32. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  33. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  34. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  35. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  36. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  37. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  38. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  39. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  40. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  41. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  42. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  43. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  44. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  45. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  46. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  50. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  51. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  52. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  53. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  55. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  56. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  57. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  58. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  59. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  60. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  61. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  62. package/templates/agent-os/universal/.claude/settings.json +7 -2
  63. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  64. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +395 -33
  65. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  66. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  67. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  68. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  69. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  70. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  71. package/templates/agent-os/universal/AGENTS.md +164 -0
  72. package/templates/agent-os/universal/CLAUDE.md +47 -14
  73. package/templates/agent-os/universal/PLAN.md +7 -29
  74. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  75. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  76. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  77. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  78. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  79. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  80. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  81. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  82. package/templates/agent-os/universal/journal/README.md +101 -0
  83. package/templates/agent-os/universal/layers.json +36 -2
  84. package/templates/hash-history.json +264 -0
  85. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  86. package/templates/skeleton/aws-serverless/README.md +91 -9
  87. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  88. package/templates/skeleton/aws-serverless/gitignore +37 -0
  89. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  90. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  91. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  92. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  93. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  94. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  95. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  96. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  97. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  98. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  99. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  100. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  101. package/templates/skeleton/node-service/README.md +11 -1
  102. package/templates/skeleton/node-service/gitignore +34 -0
  103. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  104. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  105. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  106. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  107. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  108. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  109. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -63,19 +63,88 @@ export const ADAPTER_CONTRACT = [
63
63
  'proposeTriage',
64
64
  ];
65
65
 
66
+ /**
67
+ * Why an item was passed over, as a closed vocabulary.
68
+ *
69
+ * The reason strings below are written for a human and change freely; a counter
70
+ * that grouped them by re-parsing that prose would break the first time a word
71
+ * did. `SPACING` is the one cause no filter produces — it belongs to the tier
72
+ * ration in `selectNext`, which is why it lives here rather than in `selectionOf`.
73
+ */
74
+ export const SKIP_CAUSES = Object.freeze([
75
+ 'closed',
76
+ 'in-progress',
77
+ 'triage',
78
+ 'escalated',
79
+ 'blocked',
80
+ 'trigger-auto',
81
+ 'trigger-human',
82
+ 'spacing',
83
+ ]);
84
+
85
+ /**
86
+ * The causes that hold a takeable item back — and the reason the stop conditions
87
+ * cannot treat "something was skipped" as one thing.
88
+ *
89
+ * Each of these clears without the QUEUE being refilled: a normal item lands, a
90
+ * blocker closes, another session finishes, a human declares the window. Two of
91
+ * them do need something written, and the distinction cost three rewrites of the
92
+ * stop line to get right — a `trigger-auto` item stays held until the
93
+ * declaration is recorded in the run state, and a `trigger-human` item is freed
94
+ * only by a human editing the item's own marker. They are separate tags for
95
+ * that reason: one tag standing for two remedies makes every sentence about it
96
+ * wrong for one of them. The
97
+ * three causes NOT in this list — `closed`, `triage`, `escalated` — are items
98
+ * out of play, waiting on a human. On a tracker-backed adapter they accumulate:
99
+ * an escalated issue stays open and merely gains a label, and a proposal the
100
+ * loop files stays open too. Counting those as "the queue is full, wait"
101
+ * would make `queue-empty` unreachable from the first stop that escalated or
102
+ * proposed anything — so a drained queue would report "wait and interleave" and
103
+ * the owner would never be told to refill. That is the same refill-versus-wait
104
+ * inversion this split exists to remove, pointing the other way.
105
+ *
106
+ * Under `plan-md` only `triage` is reachable of the three, and it matters that it
107
+ * is: `parsePlan` reads the marker out of the bullet text, which is exactly the
108
+ * case of a proposal that ended up under the wrong heading. `escalated` and
109
+ * `closed` cannot appear there at all — a flat list carries no per-item state, so
110
+ * `parsePlan` hands back `labels: []` and `state: 'open'` for every line. That is
111
+ * an absence of state, NOT an adapter that filed the escalation somewhere safe.
112
+ * `plan-md`'s own `escalate` says so: it writes nothing, returns `ok: false`, and
113
+ * hands back the instruction to move the item to the Operator queue in the same
114
+ * edit — because if that move is not made, the next run picks the item straight
115
+ * back up.
116
+ */
117
+ export const HOLDING_CAUSES = Object.freeze([
118
+ 'blocked',
119
+ 'in-progress',
120
+ 'spacing',
121
+ 'trigger-auto',
122
+ 'trigger-human',
123
+ ]);
124
+
66
125
  /**
67
126
  * Is this item takeable, and if not, why not?
68
127
  *
69
128
  * The filters run in order and every rejection carries a reason: an unexplained
70
- * skip is indistinguishable from a bug in the filter.
129
+ * skip is indistinguishable from a bug in the filter. Each reason also carries a
130
+ * `cause` tag, so the stop line can say what is holding the queue back without
131
+ * reading the prose back.
71
132
  */
72
133
  export const selectionOf = (ticket, { triggersFired = null } = {}) => {
73
134
  const reasons = [];
135
+ const causes = [];
74
136
  const labels = ticket.labels ?? [];
75
137
 
76
- if (ticket.state === 'closed') reasons.push('already closed');
138
+ // Filter order is the order these are pushed in; a cause repeats at most once,
139
+ // because two trigger reasons are still one thing holding the item back.
140
+ const reject = (cause, why) => {
141
+ reasons.push(why);
142
+ if (!causes.includes(cause)) causes.push(cause);
143
+ };
144
+
145
+ if (ticket.state === 'closed') reject('closed', 'already closed');
77
146
  if (ticket.state === 'in-progress') {
78
- reasons.push('already in progress — another session may be on it');
147
+ reject('in-progress', 'already in progress — another session may be on it');
79
148
  }
80
149
 
81
150
  // Belt and braces, and deliberately so. A triage item is a proposal the loop
@@ -83,11 +152,11 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
83
152
  // one careless hand adding that marker closes the loop's feedback path into its
84
153
  // own input — the exact circuit the firewall exists to break.
85
154
  if (ticket.triage || labels.includes('triage')) {
86
- reasons.push('a triage proposal: promotion to work is a human act');
155
+ reject('triage', 'a triage proposal: promotion to work is a human act');
87
156
  }
88
157
 
89
158
  if (labels.includes('escalated')) {
90
- reasons.push('escalated — it is waiting on a human, not on another attempt');
159
+ reject('escalated', 'escalated — it is waiting on a human, not on another attempt');
91
160
  }
92
161
 
93
162
  // 🔴 INVARIANT 1: blockers resolve from LINKS, never from labels.
@@ -99,21 +168,31 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
99
168
  // and takes work whose blocker is still open. Both directions have been seen.
100
169
  const open = (ticket.blockedBy ?? []).filter((blocker) => !blocker.resolved);
101
170
  if (open.length > 0) {
102
- reasons.push(`blocked by ${open.map((b) => b.id).join(', ')} (from links, not labels)`);
171
+ reject('blocked', `blocked by ${open.map((b) => b.id).join(', ')} (from links, not labels)`);
103
172
  }
104
173
 
105
174
  // No trigger label means unconditional, not missing data. Work that is
106
175
  // genuinely conditional says so.
176
+ // ⚠ **The markers are resolved by the adapter, `auto` first**, so an item
177
+ // carrying BOTH reaches here as `auto` and one recorded declaration takes it.
178
+ // Nothing refuses that combination and no hygiene check reports it — so this
179
+ // branch describes the item as the adapter classified it, and claims nothing
180
+ // about what the item's author wrote. "Never self-taken" would be exactly
181
+ // that claim, and it would be false for the item most likely to carry both:
182
+ // one an owner tightened from auto-gated to human-gated without deleting the
183
+ // old marker, where the silent resolution goes to the LESS restrictive gate.
107
184
  if (ticket.trigger === 'human') {
108
- reasons.push(
185
+ reject(
186
+ 'trigger-human',
109
187
  'trigger-human: a window, a demand or a "pass" is a human declaration — ' +
110
- 'never self-taken, only handed over explicitly',
188
+ 'handed over explicitly, never taken on this marker alone',
111
189
  );
112
190
  }
113
191
  if (ticket.trigger === 'auto') {
114
192
  const fired = triggersFired?.[ticket.id];
115
193
  if (fired !== true) {
116
- reasons.push(
194
+ reject(
195
+ 'trigger-auto',
117
196
  fired === undefined
118
197
  ? 'trigger-auto with no verification of the trigger this run — ' +
119
198
  'unverified is not fired'
@@ -122,7 +201,7 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
122
201
  }
123
202
  }
124
203
 
125
- return { eligible: reasons.length === 0, reasons };
204
+ return { eligible: reasons.length === 0, reasons, causes };
126
205
  };
127
206
 
128
207
  /**
@@ -270,13 +349,98 @@ export const sortCandidates = (tickets) =>
270
349
  return String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? ''));
271
350
  });
272
351
 
352
+ /**
353
+ * The tiers a close can record that leave the next elevated item selectable.
354
+ *
355
+ * `normal` is the obvious one. `elevated-prose` is the narrowing: an elevated
356
+ * change every one of whose elevated paths is a document (`state.mjs` decides
357
+ * this from the diff, never from the item's marker). It is still elevated for
358
+ * review — model lane, cold readers, the `human-review` label, the gate sweep —
359
+ * and it does not space the next item, because the ration buys protection from
360
+ * *unreviewed changes compounding overnight* and a document does not run.
361
+ */
362
+ const CLEARS_SPACING = new Set(['normal', 'elevated-prose']);
363
+
364
+ /**
365
+ * Does the last recorded tier leave the elevated ration open?
366
+ *
367
+ * 🔴 **Unknown means the RESTRICTIVE reading, never the permissive one.** Absent
368
+ * is not unknown: `null`/`undefined` is the honest statement that nothing has
369
+ * closed yet — what a fresh checkout with no state file says — and refusing it
370
+ * would make a clone unable to take its first item. Everything else outside the
371
+ * vocabulary holds: the legacy `'elevated'` an older state file still carries, a
372
+ * word in the wrong case, a value that is not a string at all. An `===`
373
+ * comparison against `'elevated-mechanism'` would read every one of those as
374
+ * "nothing elevated closed" and hand out the next elevated item — un-rationing
375
+ * the queue silently, which is the exact failure this seam exists to end.
376
+ *
377
+ * The CLI refuses an out-of-vocabulary tier before selection ever runs
378
+ * (`index.mjs`), so this is the second of two layers rather than the only one —
379
+ * and it is the layer that holds when `selectNext` is called directly.
380
+ */
381
+ const clearsSpacing = (lastCompletedTier) =>
382
+ lastCompletedTier === null ||
383
+ lastCompletedTier === undefined ||
384
+ CLEARS_SPACING.has(lastCompletedTier);
385
+
386
+ /**
387
+ * How many times one branch may enter the review gate before the item stops.
388
+ *
389
+ * 🔴 **Why a cap exists.** Every other stop in `autonomy.md` has something red
390
+ * behind it — a failing check, a conflicting rule, a false premise. A gate that
391
+ * keeps finding fixable prose has nothing red at all, so three strikes never fires
392
+ * and the run has no reason to stop re-entering it. That is not hypothetical: the
393
+ * repository this rulebook was extracted from journalled multi-round gates on single
394
+ * items with the whole suite green throughout, and its `budget` stop arriving "later
395
+ * than it should have".
396
+ *
397
+ * Two is the cap because the second round is what verifies the first round's fixes.
398
+ * A third means they are not converging, which is a diagnosis for a human rather
399
+ * than another pass to buy.
400
+ */
401
+ export const DEFAULT_MAX_GATE_ROUNDS = 2;
402
+
403
+ /**
404
+ * Is this round allowed, and if not, what stops?
405
+ *
406
+ * Pure, and separate from the counter on disk, because the rule and the storage
407
+ * fail differently: a wrong count is a bug in one file, a wrong rule is a bug in
408
+ * every caller. `gate-rounds.mjs` owns the count; this owns the verdict.
409
+ *
410
+ * `rounds` is the number of rounds **including the one about to run**, so a cap of
411
+ * 2 allows rounds 1 and 2 and refuses 3.
412
+ *
413
+ * The refusal is `documented-stall`, and what its diagnosis can contain is narrower
414
+ * than the name suggests: the previous rounds' blockers are not persisted anywhere, so
415
+ * what survives a compaction is the round count. The `loop` skill states the gap; do
416
+ * not write this as if the blockers were available.
417
+ *
418
+ * It is deliberately NOT in `SKIP_CAUSES`: those are reasons an item was passed over
419
+ * during selection, and this is a reason a task ends. One vocabulary holding both
420
+ * would make every sentence about either one wrong.
421
+ */
422
+ export const gateRoundVerdict = (rounds, max = DEFAULT_MAX_GATE_ROUNDS) => {
423
+ // A cap under 1 refuses the FIRST round, which turns the gate off rather than
424
+ // bounding it — the opposite of what this is for. Fail loudly on the
425
+ // configuration rather than quietly on every PR.
426
+ if (!Number.isInteger(max) || max < 1) {
427
+ throw new Error(
428
+ `maxGateRounds must be an integer of at least 1, got ${JSON.stringify(max)}. ` +
429
+ 'A cap below 1 would refuse the first gate round, which disables the gate ' +
430
+ 'instead of bounding it.',
431
+ );
432
+ }
433
+ const exceeded = rounds > max;
434
+ return { rounds, max, exceeded, stop: exceeded ? 'documented-stall' : null };
435
+ };
436
+
273
437
  /**
274
438
  * Pick the next item, or explain why nothing was taken.
275
439
  *
276
440
  * The elevated tier is rationed by **spacing, not counting**: a per-run count is
277
- * meaningless when the run has no end. Never two elevated items back to back —
278
- * one unreviewed schema or permissions change is recoverable; a chain of them
279
- * compounding overnight is not.
441
+ * meaningless when the run has no end. Never two mechanism-touching elevated
442
+ * items back to back — one unreviewed schema or permissions change is
443
+ * recoverable; a chain of them compounding overnight is not.
280
444
  */
281
445
  export const selectNext = (tickets, { lastCompletedTier = null, triggersFired = null } = {}) => {
282
446
  const skipped = [];
@@ -285,15 +449,21 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
285
449
  for (const ticket of tickets) {
286
450
  const selection = selectionOf(ticket, { triggersFired });
287
451
  if (!selection.eligible) {
288
- skipped.push({ id: ticket.id, reason: selection.reasons.join('; ') });
452
+ skipped.push({
453
+ id: ticket.id,
454
+ reason: selection.reasons.join('; '),
455
+ causes: selection.causes,
456
+ });
289
457
  continue;
290
458
  }
291
- if (ticket.tier === 'elevated' && lastCompletedTier === 'elevated') {
459
+ if (ticket.tier === 'elevated' && !clearsSpacing(lastCompletedTier)) {
292
460
  skipped.push({
293
461
  id: ticket.id,
294
462
  reason:
295
- 'elevated, and the last completed item was elevated too — never two back ' +
296
- 'to back. Land a normal item on a healthy runtime first.',
463
+ `elevated, and the last completed change (${JSON.stringify(lastCompletedTier)}) ` +
464
+ 'did not clear the ration — never two back to back. Land a normal item, or ' +
465
+ 'an elevated change that is only prose, on a healthy runtime first.',
466
+ causes: ['spacing'],
297
467
  });
298
468
  continue;
299
469
  }
@@ -304,6 +474,129 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
304
474
  return { ticket, skipped, candidates: candidates.length };
305
475
  };
306
476
 
477
+ /**
478
+ * Split the skipped records into the ones holding takeable work back and the
479
+ * ones that are simply out of play.
480
+ *
481
+ * 🔴 **A parked cause outranks a holding one, per record** — and this precedence
482
+ * is the whole mechanism, not a detail. An escalated item is left CLAIMED on
483
+ * purpose (`escalate` labels it and nothing more), so on a tracker-backed
484
+ * adapter it arrives carrying `['in-progress', 'escalated']`. Let the holding
485
+ * cause win and every escalated item reads as "another session will finish it" —
486
+ * an item no session is on, which only a human clears — and `queue-empty` is
487
+ * unreachable from the first escalation onward. That is the exact defect this
488
+ * split exists to remove, one label over.
489
+ *
490
+ * The rule generalises past that case: a parked cause says the item does not
491
+ * come back into play without a human, and that outlasts any condition which
492
+ * would clear on its own.
493
+ *
494
+ * A record with no cause this module recognises counts as **held**: it is the
495
+ * reading that stops rather than the one that declares the queue drained, and an
496
+ * unclassified skip is exactly where a wrong declaration would come from.
497
+ *
498
+ * Bounded by construction: one forward pass, no recursion, and every array it
499
+ * builds is bounded by the input it walks.
500
+ */
501
+ const partitionSkipped = (skipped) => {
502
+ const held = [];
503
+ const parked = [];
504
+ for (const skip of skipped) {
505
+ const causes = Array.isArray(skip?.causes) ? skip.causes : [];
506
+ const parking = causes.find(
507
+ (cause) => SKIP_CAUSES.includes(cause) && !HOLDING_CAUSES.includes(cause),
508
+ );
509
+ if (parking) {
510
+ parked.push(parking);
511
+ continue;
512
+ }
513
+ held.push(causes.find((cause) => HOLDING_CAUSES.includes(cause)) ?? 'an unnamed filter');
514
+ }
515
+ return { held, parked };
516
+ };
517
+
518
+ /**
519
+ * "3 X, 1 Y" — a list of causes counted, as a partition.
520
+ *
521
+ * Each item counts ONCE, under the one cause it was classified by, so the parts
522
+ * foot to the total beside them. Counting it under every cause it carries would
523
+ * read as a breakdown and silently sum past that number.
524
+ *
525
+ * Bounded by construction: one forward pass, and a tally whose key set is the
526
+ * closed vocabulary plus one bucket for a record that carries no tag at all.
527
+ */
528
+ const breakdownOf = (causes, label = (count, tag) => `${count} ${tag}`) => {
529
+ const tally = new Map();
530
+ for (const cause of causes) tally.set(cause, (tally.get(cause) ?? 0) + 1);
531
+ return [...tally.entries()]
532
+ .sort(([tagA, countA], [tagB, countB]) => countB - countA || tagA.localeCompare(tagB))
533
+ .map(([tag, count]) => label(count, tag))
534
+ .join(', ');
535
+ };
536
+
537
+ const heldBreakdown = (held) => breakdownOf(held, (count, tag) => `${count} held by ${tag}`);
538
+
539
+ /**
540
+ * The parked pile, named rather than left to grow unseen.
541
+ *
542
+ * Reported beside the held count and never summed with it: they ask the owner
543
+ * for different things, and one number covering both would ask for neither.
544
+ *
545
+ * It names the causes the pile actually carries rather than the vocabulary it
546
+ * could have carried. A fixed list would announce `closed` — which no adapter
547
+ * can present, since every one of them drops closed items before selection —
548
+ * while staying silent about which of the reachable ones this pile is made of.
549
+ *
550
+ * It also stops at what it can see. How the pile GROWS differs per adapter (see
551
+ * `HOLDING_CAUSES` above), so a line claiming one mechanism would be false on
552
+ * another, and the stop line is not where that belongs.
553
+ */
554
+ const parkedNote = (parked) =>
555
+ parked.length === 0
556
+ ? ''
557
+ : ` A further ${parked.length} item(s) are parked — ${breakdownOf(parked)}. ` +
558
+ 'Those are not work this run can take and they wait on a human, never on ' +
559
+ 'time.';
560
+
561
+ /**
562
+ * The trigger remedies, composed from the tags actually present.
563
+ *
564
+ * 🔴 **Why this is a function and not a sentence.** The two trigger kinds hold an
565
+ * item back through different mechanisms and are freed by different acts: an
566
+ * `auto` item waits for a declaration to be RECORDED, a `human` item is never
567
+ * self-taken at all and only a human editing the item's own marker frees it.
568
+ * When one cause tag stood for both, every fixed sentence keyed off it was
569
+ * wrong for one kind — the clause was rewritten three times, each revision
570
+ * repairing one sub-case and leaving the other, until the tags were split. So
571
+ * the remedy now follows the pile: unreachable unless the tag that earns it is
572
+ * in it, which makes a one-sided line structurally unavailable rather than
573
+ * merely discouraged.
574
+ *
575
+ * The cost of getting this wrong is not a confusing sentence. Told to record a
576
+ * declaration for a `human` item, an operator runs a command that reports
577
+ * success, changes nothing, and — under an adapter whose ids are list positions
578
+ * — leaves a live record that arms whatever occupies that slot next.
579
+ */
580
+ const triggerNote = (held) => {
581
+ const auto = held.includes('trigger-auto');
582
+ const human = held.includes('trigger-human');
583
+ if (!auto && !human) return '';
584
+ return (
585
+ ' A trigger is the exception, and the two kinds are freed differently:' +
586
+ (auto
587
+ ? ' a trigger-auto item waits for the declaration to be RECORDED — ' +
588
+ '`node .claude/scripts/run-state.mjs trigger <item-id>` — so waiting it ' +
589
+ 'out waits forever;'
590
+ : '') +
591
+ (human
592
+ ? ' an item held as trigger-human is not freed by recording a declaration ' +
593
+ "— that does nothing here; only a human changing the item's own marker " +
594
+ 'frees it;'
595
+ : '') +
596
+ ' both are declarations, not delays.'
597
+ );
598
+ };
599
+
307
600
  /**
308
601
  * Should the whole run stop? Checked in severity order, because a regression must
309
602
  * not be reported as an empty queue.
@@ -314,6 +607,7 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
314
607
  */
315
608
  export const stopConditionOf = ({
316
609
  candidates = 0,
610
+ skipped = [],
317
611
  lastDeployVerdict = null,
318
612
  consecutiveEscalations = 0,
319
613
  killSwitch = false,
@@ -370,13 +664,29 @@ export const stopConditionOf = ({
370
664
  };
371
665
  }
372
666
  if (candidates === 0) {
667
+ const { held, parked } = partitionSkipped(skipped);
668
+ if (held.length > 0) {
669
+ return {
670
+ kind: 'nothing-selectable',
671
+ success: true,
672
+ why:
673
+ `${held.length} item(s) are takeable work held back right now — ` +
674
+ `${heldBreakdown(held)}.${parkedNote(parked)} This is NOT an empty queue, ` +
675
+ 'and the two ask for opposite things: an empty queue wants refilling, ' +
676
+ 'whereas this one still holds work. Spacing clears when a normal item ' +
677
+ 'lands, a blocker when its item closes, in-progress when the other ' +
678
+ `session finishes.${triggerNote(held)} Otherwise the action is to ` +
679
+ 'interleave or to wait, never to refill and never to invent work.',
680
+ };
681
+ }
373
682
  return {
374
683
  kind: 'queue-empty',
375
684
  success: true,
376
685
  why:
377
- 'no item survives the filters. This is a legitimate end of session, not an ' +
378
- 'invitation to refactor: **do not invent work**. Refilling the queue is the ' +
379
- "owner's job.",
686
+ 'no item survives the filters and nothing is merely held back — the queue ' +
687
+ `is genuinely out of work.${parkedNote(parked)} This is a legitimate end of ` +
688
+ 'session, not an invitation to refactor: **do not invent work**. Refilling ' +
689
+ "the queue is the owner's job.",
380
690
  };
381
691
  }
382
692
  return null;
@@ -385,9 +695,10 @@ export const stopConditionOf = ({
385
695
  /**
386
696
  * The stable fingerprint of an improvement proposal.
387
697
  *
388
- * Under a scheduler against a finite queue the most common stop is "queue empty";
389
- * twenty such stops must produce ONE proposal with a count of twenty, not twenty
390
- * proposals. Dedupe by fingerprint, then increment.
698
+ * Under a scheduler against a finite queue the most common stops are the two that
699
+ * hand out nothing — "queue empty" and "nothing selectable"; twenty such stops
700
+ * must produce ONE proposal with a count of twenty, not twenty proposals. Dedupe
701
+ * by fingerprint, then increment.
391
702
  */
392
703
  export const fingerprintOf = ({ finding, part, change }) =>
393
704
  [finding, part, change]
@@ -0,0 +1,160 @@
1
+ /**
2
+ * How many times a branch has entered the review gate.
3
+ *
4
+ * 🔴 **Its own file, and that is the whole design.** The item that asked for this
5
+ * named `queue.state.json` — the file holding `lastCompletedTier` — as the place to
6
+ * put the count. A review round measured what that costs: `recordCompletedTier`
7
+ * writes that file whole, so either the two writers merge or the first close of any
8
+ * item deletes the round count of every branch still in gate. Merging was
9
+ * implemented, and then it turned out to be worse: a state file that fails to parse
10
+ * reads as `{}` to a merging writer, which then writes its snapshot back and the
11
+ * tier is **silently gone** — and a missing tier is the permissive value that lets a
12
+ * second elevated item straight through. A lost round costs one extra review; a lost
13
+ * tier disables the ration the queue is spaced by.
14
+ *
15
+ * Two files, one writer each. The counter cannot damage the ration and `state.mjs`
16
+ * goes back to owning its file outright — so a lost round is now the worst this
17
+ * mechanism can do, where before it could lose the tier.
18
+ *
19
+ * ⚠ **What that does NOT fix, measured rather than reasoned:** `recordGateRound` is
20
+ * read-modify-write with no lock. Eight concurrent calls on one counter recorded
21
+ * **four** rounds — the file is shared across every worktree of a repo by design
22
+ * (`gateRoundsPathFor`), so this is reachable whenever two loops run in one
23
+ * repository at once, not only in one directory. Each lost increment buys one extra
24
+ * allowed round. A lock is not worth it for that: the loop is sequential per task,
25
+ * and the failure is bounded and in the generous direction. What was worth fixing is
26
+ * the crash it came with — a fixed temp filename made the losers of that race fail
27
+ * with `ENOENT` on rename, reporting "could not run" for a condition nothing named.
28
+ */
29
+
30
+ import { renameSync, readFileSync, writeFileSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+
33
+ import { mainCheckoutRoot } from './checkout.mjs';
34
+
35
+ /**
36
+ * Where the counter lives, unless a caller names the file.
37
+ *
38
+ * The default lands in the MAIN checkout even when the gate runs inside a worktree
39
+ * — the same rule `state.mjs` follows, and for the same reason: a task's worktree is
40
+ * shorter-lived than the task.
41
+ */
42
+ export const gateRoundsPathFor = (projectRoot) =>
43
+ join(mainCheckoutRoot(projectRoot), '.claude', 'gate-rounds.json');
44
+
45
+ /**
46
+ * The counts, keyed by branch.
47
+ *
48
+ * An absent file is zero rounds — the normal state of a fresh checkout. A file that
49
+ * exists and does not parse is **refused**, not read as zero: reading it as zero
50
+ * hands the branch a full cap again, and it does so at the exact moment something is
51
+ * already wrong with the file.
52
+ */
53
+ const readRounds = (file) => {
54
+ let raw;
55
+ try {
56
+ raw = readFileSync(file, 'utf8');
57
+ } catch (error) {
58
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return Object.create(null);
59
+ throw new Error(
60
+ `${file} exists but could not be read (${error?.code ?? 'unknown error'}), so the ` +
61
+ 'gate rounds for this branch are unknown. Refusing rather than starting the ' +
62
+ 'count again, which would hand this branch a full cap.',
63
+ { cause: error },
64
+ );
65
+ }
66
+
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(raw);
70
+ } catch (error) {
71
+ throw new Error(
72
+ `${file} is not valid JSON, so the gate rounds cannot be read: ` +
73
+ `${String(error?.message ?? error).split('\n')[0]}. Delete the file to start ` +
74
+ 'the count over — deliberately, rather than by accident.',
75
+ { cause: error },
76
+ );
77
+ }
78
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
79
+ throw new Error(
80
+ `${file} is valid JSON but not an object of branch counts, so the gate rounds ` +
81
+ 'cannot be read. Expected `{ "<branch>": <positive integer> }`. Delete the ' +
82
+ 'file to start the count over.',
83
+ );
84
+ }
85
+
86
+ // A single malformed entry is dropped rather than refused: one bad key costs that
87
+ // branch a fresh cap, while refusing would block every branch in the checkout.
88
+ //
89
+ // 🔴 `Object.create(null)`, not `{}`. A branch named `__proto__` or `constructor`
90
+ // otherwise resolves to an inherited value rather than a count, so the increment
91
+ // produced `"[object Object]1"` — a string, which every numeric comparison against
92
+ // the cap answers `false`, i.e. unlimited rounds. Absurd branch name, real class of
93
+ // defect, and the failure was silent.
94
+ const clean = Object.create(null);
95
+ for (const [branch, count] of Object.entries(parsed)) {
96
+ if (Number.isInteger(count) && count > 0) clean[branch] = count;
97
+ }
98
+ return clean;
99
+ };
100
+
101
+ /**
102
+ * The branch a count belongs to.
103
+ *
104
+ * 🔴 `HEAD` is refused, and it is the case this check exists for. On a detached
105
+ * checkout — mid-rebase, after `gh pr checkout` of a fork, in CI —
106
+ * `git rev-parse --abbrev-ref HEAD` prints the literal string `HEAD`, which is a
107
+ * non-empty string and would key one shared counter for every task in the
108
+ * directory. A review round found exactly that hole under a comment claiming it was
109
+ * closed.
110
+ */
111
+ const requireBranch = (branch) => {
112
+ if (typeof branch !== 'string' || branch.trim() === '') {
113
+ throw new Error(
114
+ `a gate round is counted per branch, and the branch was ${JSON.stringify(branch)}.`,
115
+ );
116
+ }
117
+ if (branch.trim() === 'HEAD') {
118
+ throw new Error(
119
+ 'the branch is the literal "HEAD", which is what a detached checkout reports. ' +
120
+ 'Counting under it would share one budget across every task in this ' +
121
+ 'directory. Check out the branch under review, or pass its name explicitly.',
122
+ );
123
+ }
124
+ return branch.trim();
125
+ };
126
+
127
+ /** How many rounds this branch has recorded. Zero when the file is absent. */
128
+ export const gateRoundsFor = ({ branch, projectRoot, roundsPath } = {}) =>
129
+ readRounds(roundsPath ?? gateRoundsPathFor(projectRoot))[requireBranch(branch)] ?? 0;
130
+
131
+ /**
132
+ * Count one round for this branch and return the new total.
133
+ *
134
+ * Write-then-rename, because a plain write leaves a window where the file on disk is
135
+ * half a JSON document — and the reader above refuses such a file, which would turn
136
+ * a crash mid-write into a gate nobody can run. `run-state.mjs` reached the same
137
+ * conclusion about the same class of file.
138
+ *
139
+ * The temp name carries the pid. A fixed `${file}.tmp` was measured failing: under
140
+ * concurrent calls the first rename consumed the shared temp file and the rest died
141
+ * with `ENOENT` on rename, which this command reports as "could not run" — a
142
+ * diagnosis for a cause nothing named. Per-pid temps make a concurrent loser lose
143
+ * only its increment, which is the bounded failure the docblock above states.
144
+ */
145
+ export const recordGateRound = ({ branch, projectRoot, roundsPath } = {}) => {
146
+ const key = requireBranch(branch);
147
+ const file = roundsPath ?? gateRoundsPathFor(projectRoot);
148
+ const rounds = readRounds(file);
149
+ const next = (rounds[key] ?? 0) + 1;
150
+
151
+ // Spread into a null-prototype object for the same reason `readRounds` builds one:
152
+ // `{ ...rounds }` would give the result a prototype again, and `JSON.stringify`
153
+ // then writes a `__proto__` key that the next read cannot see as its own.
154
+ const updated = Object.assign(Object.create(null), rounds, { [key]: next });
155
+ const temp = `${file}.${process.pid}.tmp`;
156
+ writeFileSync(temp, `${JSON.stringify(updated, null, 2)}\n`);
157
+ renameSync(temp, file);
158
+
159
+ return { rounds: next };
160
+ };
@@ -15,6 +15,7 @@
15
15
  // because it is re-resolved from the blockers themselves on every selection.
16
16
  import { execFileSync } from 'node:child_process';
17
17
  import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
18
+ import { recordEscalation } from '../run-state.mjs';
18
19
 
19
20
  export const name = 'github-issues';
20
21
 
@@ -168,9 +169,12 @@ export const comment = (ticket, body) => {
168
169
  * selection cannot pick it up again. It stays OPEN and stays claimed — moving it
169
170
  * back to a selectable state is how one stuck task gets worked three times.
170
171
  */
171
- export const escalate = (ticket, diagnosis) => {
172
+ export const escalate = (ticket, diagnosis, { env = process.env } = {}) => {
172
173
  ghText(['issue', 'comment', ticket.id, '--body', diagnosis]);
173
174
  ghText(['issue', 'edit', ticket.id, '--add-label', 'escalated']);
175
+ // Counted through the one recorder, never a counter of this adapter's own —
176
+ // "twice in a row" has to mean the same thing on every tracker.
177
+ recordEscalation(env.RIG_RUN_DIR);
174
178
  return { ok: true };
175
179
  };
176
180
 
@@ -208,7 +212,8 @@ export const triageItemFor = (proposal) => {
208
212
  /**
209
213
  * File the proposal — or increment the one already there.
210
214
  *
211
- * Under a scheduler against a finite queue the most common stop is "queue empty";
215
+ * Under a scheduler against a finite queue the most common stops are the two that
216
+ * hand out nothing — "queue empty" and "nothing selectable";
212
217
  * twenty such stops must produce one proposal with a count of twenty.
213
218
  */
214
219
  export const proposeTriage = (proposal, { existing = null } = {}) => {