create-agent-rig 0.4.0 → 0.6.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 (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -14,8 +14,10 @@
14
14
  // {
15
15
  // id, title, url,
16
16
  // state: 'open' | 'in-progress' | 'closed',
17
- // labels: string[], // informational, never decisive
17
+ // labels: string[], // informational see the note below
18
18
  // tier: 'normal' | 'elevated',
19
+ // lifecycle: 'keep-core' | 're-scope' | 'obsolete' | null, // AR-144, from labels
20
+ // parked: boolean, // scheduling, orthogonal to lifecycle
19
21
  // blockedBy: [{ id, resolved }], // FROM LINKS — see invariant 1
20
22
  // blocks: string[], // ids this one unblocks
21
23
  // priority: number, // lower is more urgent
@@ -48,6 +50,14 @@
48
50
  //
49
51
  // `raw` is the adapter's own record of the line or record it parsed. It is
50
52
  // deliberately NOT read by this file: it exists for the adapter's writes.
53
+ //
54
+ // **"Labels are informational, never decisive" is a statement about
55
+ // DEPENDENCIES** (invariant 1 below): a `blocked`/`ready` label is a snapshot of
56
+ // a fact the links carry authoritatively. It is not a ban on reading labels at
57
+ // all — `tier`, `trigger`, `triage`, `owner`, and (AR-144) `lifecycle` and
58
+ // `parked` are facts that exist ONLY as a marker, so the marker is the source
59
+ // and the adapter maps it onto a named field. The rule is: a label is never
60
+ // read in place of a source that says the same thing better.
51
61
 
52
62
  /**
53
63
  * The operations every adapter provides. A second tracker is an adapter, not a
@@ -55,27 +65,211 @@
55
65
  */
56
66
  export const ADAPTER_CONTRACT = [
57
67
  'listEligible',
68
+ // One item by id, WITHOUT the closed filter `listEligible` applies: the close
69
+ // point has to see an item somebody already closed (AR-135). Each adapter
70
+ // owns how — the decision is not made above the seam.
71
+ 'find',
58
72
  'resolveBlockers',
59
73
  'claim',
60
74
  'close',
61
75
  'comment',
62
76
  'escalate',
63
77
  'proposeTriage',
78
+ // The proposals on file, as `{ id, body }`, so hygiene can read each one's
79
+ // `asOf` and cited paths back (AR-116). Reads only — never a selection input.
80
+ 'listProposals',
64
81
  ];
65
82
 
83
+ /**
84
+ * Why an item was passed over, as a closed vocabulary.
85
+ *
86
+ * The reason strings below are written for a human and change freely; a counter
87
+ * that grouped them by re-parsing that prose would break the first time a word
88
+ * did. `SPACING` is the one cause no filter produces — it belongs to the tier
89
+ * ration in `selectNext`, which is why it lives here rather than in `selectionOf`.
90
+ */
91
+ export const SKIP_CAUSES = Object.freeze([
92
+ 'closed',
93
+ 'in-progress',
94
+ 'triage',
95
+ 'escalated',
96
+ 'blocked',
97
+ 'trigger-auto',
98
+ 'trigger-human',
99
+ 'spacing',
100
+ 'owner',
101
+ // AR-144: the lifecycle vocabulary. `re-scope` and `deferred` (the `parked`
102
+ // label) hold takeable work back until a human acts; `obsolete` is out of play
103
+ // until a human closes it with the evidence — see `lifecycleOf` below.
104
+ // 🔴 The cause for the `parked` LABEL is `deferred`, not `parked`: this module
105
+ // already uses "parked" for the out-of-play pile (`partitionSkipped`), and a
106
+ // parked-labelled item is the opposite — held, takeable, waiting on an un-park.
107
+ // One word for two states is how a stop line gets read backwards.
108
+ 're-scope',
109
+ 'deferred',
110
+ 'obsolete',
111
+ ]);
112
+
113
+ /**
114
+ * The causes that hold a takeable item back — and the reason the stop conditions
115
+ * cannot treat "something was skipped" as one thing.
116
+ *
117
+ * Each of these clears without the QUEUE being refilled: a normal item lands, a
118
+ * blocker closes, another session finishes, a human declares the window. Two of
119
+ * them do need something written, and the distinction cost three rewrites of the
120
+ * stop line to get right — a `trigger-auto` item stays held until the
121
+ * declaration is recorded in the run state, and a `trigger-human` item is freed
122
+ * only by a human editing the item's own marker. They are separate tags for
123
+ * that reason: one tag standing for two remedies makes every sentence about it
124
+ * wrong for one of them. The
125
+ * three causes NOT in this list — `closed`, `triage`, `escalated` — are items
126
+ * out of play, waiting on a human. On a tracker-backed adapter they accumulate:
127
+ * an escalated issue stays open and merely gains a label, and a proposal the
128
+ * loop files stays open too. Counting those as "the queue is full, wait"
129
+ * would make `queue-empty` unreachable from the first stop that escalated or
130
+ * proposed anything — so a drained queue would report "wait and interleave" and
131
+ * the owner would never be told to refill. That is the same refill-versus-wait
132
+ * inversion this split exists to remove, pointing the other way.
133
+ *
134
+ * Under `plan-md` only `triage` is reachable of the three, and it matters that it
135
+ * is: `parsePlan` reads the marker out of the bullet text, which is exactly the
136
+ * case of a proposal that ended up under the wrong heading. `escalated` and
137
+ * `closed` cannot appear there at all — a flat list carries no per-item state, so
138
+ * `parsePlan` hands back `labels: []` and `state: 'open'` for every line. That is
139
+ * an absence of state, NOT an adapter that filed the escalation somewhere safe.
140
+ * `plan-md`'s own `escalate` says so: it writes nothing, returns `ok: false`, and
141
+ * hands back the instruction to move the item to the Operator queue in the same
142
+ * edit — because if that move is not made, the next run picks the item straight
143
+ * back up.
144
+ */
145
+ export const HOLDING_CAUSES = Object.freeze([
146
+ 'blocked',
147
+ 'in-progress',
148
+ 'spacing',
149
+ 'trigger-auto',
150
+ 'trigger-human',
151
+ // Another repository's item (AR-132). It clears the way `trigger-human`
152
+ // does — a human moves the item or re-marks it — never by this checkout
153
+ // doing anything, and never by refilling the queue.
154
+ 'owner',
155
+ // AR-144: both are real, takeable work waiting on one human act — a rewrite
156
+ // that removes `re-scope`, or an un-park. `obsolete` is deliberately NOT
157
+ // here: it waits on a human CLOSE, so it is out of play, and reporting it as
158
+ // "held" would tell the owner to wait for something that only they can do.
159
+ 're-scope',
160
+ 'deferred',
161
+ ]);
162
+
163
+ /**
164
+ * The repository an item belongs to, read out of its labels: `owner-<name>`,
165
+ * one word for one fact on every tracker. `null` when there is none — and
166
+ * absence means unconditional, exactly as it does for the trigger markers.
167
+ * A bare `owner-` names nobody and is read as no marker.
168
+ *
169
+ * Why (AR-132): AR-129 and AR-130 were Rig Platform items sitting in this
170
+ * repository's project. Both were selected as normal spacers and both escalated
171
+ * PREMISE FALSE at the first premise check, consecutively — a run-level stop,
172
+ * spent on work that was never this checkout's to do.
173
+ */
174
+ export const ownerOfLabels = (labels) => {
175
+ for (const label of Array.isArray(labels) ? labels : []) {
176
+ if (typeof label === 'string' && label.startsWith('owner-') && label.length > 6) {
177
+ return label.slice(6);
178
+ }
179
+ }
180
+ return null;
181
+ };
182
+
183
+ /**
184
+ * The lifecycle vocabulary (AR-144), closed and ordered from least to most
185
+ * restrictive. It says what the ITEM is, never when it runs:
186
+ *
187
+ * - `keep-core` — the problem and the responsibility are valid for the current
188
+ * project and the item is executable as written. A statement, not a condition.
189
+ * - `re-scope` — the problem is valid but the item is NOT executable literally:
190
+ * a path, mechanism, boundary or acceptance criterion drifted. A short-lived
191
+ * quarantine: a human re-reads the code, rewrites the item, removes the label.
192
+ * The loop surfaces it (hygiene) and never invents the new scope itself.
193
+ * - `obsolete` — the responsibility is gone or fully superseded. A human verdict:
194
+ * the loop never applies it and never closes an item because it believes
195
+ * another mechanism supersedes it. Closing as obsolete needs a comment naming
196
+ * the evidence or the replacement.
197
+ *
198
+ * `parked` is a separate axis — SCHEDULING, not lifecycle: valid work
199
+ * deliberately not active now. `keep-core + parked` is the normal shape of a
200
+ * deferred item and means "still needed, not now"; the hold is the `parked`.
201
+ *
202
+ * 🔴 None of these is inferred. Not from age, not from a key range, not from old
203
+ * terminology, not from `parked`, not from a migration marker such as
204
+ * `legacy-backlog` — which is retired, and which hygiene reports on any open item
205
+ * still carrying it. A label that looks like "old" is no evidence that the work
206
+ * is unnecessary — the owner's migration audit on AR-144 closed nothing as obsolete.
207
+ */
208
+ export const LIFECYCLE_LABELS = Object.freeze(['keep-core', 're-scope', 'obsolete']);
209
+
210
+ /**
211
+ * Read the lifecycle and the scheduling flag out of a label list — one
212
+ * function for every tracker adapter, so the semantics live above the seam.
213
+ *
214
+ * Several lifecycle labels on one item is a contradiction hygiene reports
215
+ * (`contradictory-lifecycle-labels`); selection meanwhile takes the MOST
216
+ * restrictive reading, because the permissive one is how an item marked
217
+ * `obsolete` by one hand and `keep-core` by another gets worked.
218
+ */
219
+ export const lifecycleOf = (labels) => {
220
+ const list = Array.isArray(labels) ? labels : [];
221
+ let lifecycle = null;
222
+ for (const label of LIFECYCLE_LABELS) if (list.includes(label)) lifecycle = label;
223
+ return { lifecycle, parked: list.includes('parked') };
224
+ };
225
+
226
+ /** The lifecycle labels an item carries, for the contradiction check. */
227
+ const lifecycleLabelsOn = (labels) => {
228
+ const list = Array.isArray(labels) ? labels : [];
229
+ return LIFECYCLE_LABELS.filter((label) => list.includes(label));
230
+ };
231
+
232
+ /**
233
+ * Why an owned item is not this checkout's, or null when it is (or claims no
234
+ * owner). One predicate for selection and for hygiene, so the two cannot
235
+ * disagree about the same item. A checkout that declares no owner cannot
236
+ * confirm a match, and "could not look" is never "it is fine".
237
+ */
238
+ const ownerMismatchOf = (ticket, owner) => {
239
+ if (!ticket.owner) return null;
240
+ if (owner === null || owner === undefined || owner === '') {
241
+ return (
242
+ `owned by ${ticket.owner}, and this checkout declares no owner ` +
243
+ '(options.owner in .claude/queue.json) — a match cannot be confirmed'
244
+ );
245
+ }
246
+ if (ticket.owner === owner) return null;
247
+ return `owned by ${ticket.owner}, and this checkout is ${owner}`;
248
+ };
249
+
66
250
  /**
67
251
  * Is this item takeable, and if not, why not?
68
252
  *
69
253
  * The filters run in order and every rejection carries a reason: an unexplained
70
- * skip is indistinguishable from a bug in the filter.
254
+ * skip is indistinguishable from a bug in the filter. Each reason also carries a
255
+ * `cause` tag, so the stop line can say what is holding the queue back without
256
+ * reading the prose back.
71
257
  */
72
- export const selectionOf = (ticket, { triggersFired = null } = {}) => {
258
+ export const selectionOf = (ticket, { triggersFired = null, owner = null } = {}) => {
73
259
  const reasons = [];
260
+ const causes = [];
74
261
  const labels = ticket.labels ?? [];
75
262
 
76
- if (ticket.state === 'closed') reasons.push('already closed');
263
+ // Filter order is the order these are pushed in; a cause repeats at most once,
264
+ // because two trigger reasons are still one thing holding the item back.
265
+ const reject = (cause, why) => {
266
+ reasons.push(why);
267
+ if (!causes.includes(cause)) causes.push(cause);
268
+ };
269
+
270
+ if (ticket.state === 'closed') reject('closed', 'already closed');
77
271
  if (ticket.state === 'in-progress') {
78
- reasons.push('already in progress — another session may be on it');
272
+ reject('in-progress', 'already in progress — another session may be on it');
79
273
  }
80
274
 
81
275
  // Belt and braces, and deliberately so. A triage item is a proposal the loop
@@ -83,11 +277,11 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
83
277
  // one careless hand adding that marker closes the loop's feedback path into its
84
278
  // own input — the exact circuit the firewall exists to break.
85
279
  if (ticket.triage || labels.includes('triage')) {
86
- reasons.push('a triage proposal: promotion to work is a human act');
280
+ reject('triage', 'a triage proposal: promotion to work is a human act');
87
281
  }
88
282
 
89
283
  if (labels.includes('escalated')) {
90
- reasons.push('escalated — it is waiting on a human, not on another attempt');
284
+ reject('escalated', 'escalated — it is waiting on a human, not on another attempt');
91
285
  }
92
286
 
93
287
  // 🔴 INVARIANT 1: blockers resolve from LINKS, never from labels.
@@ -99,21 +293,58 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
99
293
  // and takes work whose blocker is still open. Both directions have been seen.
100
294
  const open = (ticket.blockedBy ?? []).filter((blocker) => !blocker.resolved);
101
295
  if (open.length > 0) {
102
- reasons.push(`blocked by ${open.map((b) => b.id).join(', ')} (from links, not labels)`);
296
+ reject('blocked', `blocked by ${open.map((b) => b.id).join(', ')} (from links, not labels)`);
103
297
  }
104
298
 
105
299
  // No trigger label means unconditional, not missing data. Work that is
106
300
  // genuinely conditional says so.
301
+ // ⚠ **The markers are resolved by the adapter, `auto` first**, so an item
302
+ // carrying BOTH reaches here as `auto` and one recorded declaration takes it.
303
+ // Nothing refuses that combination and no hygiene check reports it — so this
304
+ // branch describes the item as the adapter classified it, and claims nothing
305
+ // about what the item's author wrote. "Never self-taken" would be exactly
306
+ // that claim, and it would be false for the item most likely to carry both:
307
+ // one an owner tightened from auto-gated to human-gated without deleting the
308
+ // old marker, where the silent resolution goes to the LESS restrictive gate.
309
+ // Another repository's item is held, not taken (AR-132). The marker is the
310
+ // adapter's `owner` field; a checkout names itself in `options.owner`.
311
+ const foreign = ownerMismatchOf(ticket, owner);
312
+ if (foreign) reject('owner', `${foreign} — moving or re-marking it is a human act`);
313
+
314
+ // AR-144: the lifecycle vocabulary, read from the adapter's `lifecycle` and
315
+ // `parked` fields (`lifecycleOf`). `keep-core` never rejects — it is a
316
+ // statement that the item is executable, not a condition on taking it.
317
+ if (ticket.lifecycle === 're-scope') {
318
+ reject(
319
+ 're-scope',
320
+ 're-scope: the problem is valid but the item is not executable as written — ' +
321
+ 'a human rewrites it against the current code and removes the label; the ' +
322
+ 'loop never invents the new scope',
323
+ );
324
+ }
325
+ if (ticket.parked === true) {
326
+ reject('deferred', 'parked (deferred): valid work deliberately not active now — a human un-parks it');
327
+ }
328
+ if (ticket.lifecycle === 'obsolete') {
329
+ reject(
330
+ 'obsolete',
331
+ 'obsolete: a human verdict — a human closes it, with a comment naming the ' +
332
+ 'evidence or the replacement; the loop neither takes it nor closes it',
333
+ );
334
+ }
335
+
107
336
  if (ticket.trigger === 'human') {
108
- reasons.push(
337
+ reject(
338
+ 'trigger-human',
109
339
  'trigger-human: a window, a demand or a "pass" is a human declaration — ' +
110
- 'never self-taken, only handed over explicitly',
340
+ 'handed over explicitly, never taken on this marker alone',
111
341
  );
112
342
  }
113
343
  if (ticket.trigger === 'auto') {
114
344
  const fired = triggersFired?.[ticket.id];
115
345
  if (fired !== true) {
116
- reasons.push(
346
+ reject(
347
+ 'trigger-auto',
117
348
  fired === undefined
118
349
  ? 'trigger-auto with no verification of the trigger this run — ' +
119
350
  'unverified is not fired'
@@ -122,7 +353,7 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
122
353
  }
123
354
  }
124
355
 
125
- return { eligible: reasons.length === 0, reasons };
356
+ return { eligible: reasons.length === 0, reasons, causes };
126
357
  };
127
358
 
128
359
  /**
@@ -131,10 +362,54 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
131
362
  * Reported, never silently corrected: a loop that quietly rewrites the queue's
132
363
  * own metadata removes the evidence that the metadata is unreliable.
133
364
  */
134
- export const hygieneOf = (ticket) => {
365
+ export const hygieneOf = (ticket, { owner = null } = {}) => {
135
366
  const labels = ticket.labels ?? [];
136
367
  const open = (ticket.blockedBy ?? []).filter((blocker) => !blocker.resolved);
137
368
 
369
+ const foreign = ownerMismatchOf(ticket, owner);
370
+ if (foreign) {
371
+ return {
372
+ kind: 'owner-mismatch',
373
+ id: ticket.id,
374
+ why: `${foreign} — it sits in this queue but is not this repository's to do`,
375
+ };
376
+ }
377
+
378
+ // AR-144: the retired migration marker, and the lifecycle contradictions.
379
+ // Reported, never corrected: which label is the wrong one is a human call.
380
+ if (labels.includes('legacy-backlog') && ticket.state !== 'closed') {
381
+ return {
382
+ kind: 'stale-legacy-backlog-label',
383
+ id: ticket.id,
384
+ why:
385
+ 'still carries legacy-backlog, which is retired — it says nothing about ' +
386
+ 'whether the work is needed. Re-mark it keep-core, re-scope or obsolete ' +
387
+ '(and parked if deferred); the loop infers none of those from it',
388
+ };
389
+ }
390
+ const lifecycles = lifecycleLabelsOn(labels);
391
+ if (lifecycles.length > 1) {
392
+ return {
393
+ kind: 'contradictory-lifecycle-labels',
394
+ id: ticket.id,
395
+ why:
396
+ `carries ${lifecycles.join(' and ')} at once, and an item has one lifecycle — ` +
397
+ `selection reads the most restrictive (${lifecycleOf(labels).lifecycle}); ` +
398
+ 'a human removes the wrong one',
399
+ };
400
+ }
401
+ if (ticket.lifecycle === 're-scope' && ticket.state !== 'closed') {
402
+ return {
403
+ kind: 're-scope-pending',
404
+ id: ticket.id,
405
+ why:
406
+ 'marked re-scope: the problem is valid but the item is not executable as ' +
407
+ 'written. It waits on a human rewrite against the current code — a ' +
408
+ 'quarantine, not a backlog category, so it is reported until the label ' +
409
+ 'comes off',
410
+ };
411
+ }
412
+
138
413
  if (labels.includes('blocked') && open.length === 0) {
139
414
  return {
140
415
  kind: 'stale-blocked-label',
@@ -270,30 +545,126 @@ export const sortCandidates = (tickets) =>
270
545
  return String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? ''));
271
546
  });
272
547
 
548
+ /**
549
+ * The tiers a close can record that leave the next elevated item selectable.
550
+ *
551
+ * `normal` is the obvious one. `elevated-prose` is the narrowing: an elevated
552
+ * change every one of whose elevated paths is a document (`state.mjs` decides
553
+ * this from the diff, never from the item's marker). It is still elevated for
554
+ * review — model lane, cold readers, the `human-review` label, the gate sweep —
555
+ * and it does not space the next item, because the ration buys protection from
556
+ * *unreviewed changes compounding overnight* and a document does not run.
557
+ */
558
+ const CLEARS_SPACING = new Set(['normal', 'elevated-prose']);
559
+
560
+ /**
561
+ * Does the last recorded tier leave the elevated ration open?
562
+ *
563
+ * 🔴 **Unknown means the RESTRICTIVE reading, never the permissive one.** Absent
564
+ * is not unknown: `null`/`undefined` is the honest statement that nothing has
565
+ * closed yet — what a fresh checkout with no state file says — and refusing it
566
+ * would make a clone unable to take its first item. Everything else outside the
567
+ * vocabulary holds: the legacy `'elevated'` an older state file still carries, a
568
+ * word in the wrong case, a value that is not a string at all. An `===`
569
+ * comparison against `'elevated-mechanism'` would read every one of those as
570
+ * "nothing elevated closed" and hand out the next elevated item — un-rationing
571
+ * the queue silently, which is the exact failure this seam exists to end.
572
+ *
573
+ * The CLI refuses an out-of-vocabulary tier before selection ever runs
574
+ * (`index.mjs`), so this is the second of two layers rather than the only one —
575
+ * and it is the layer that holds when `selectNext` is called directly.
576
+ */
577
+ const clearsSpacing = (lastCompletedTier) =>
578
+ lastCompletedTier === null ||
579
+ lastCompletedTier === undefined ||
580
+ CLEARS_SPACING.has(lastCompletedTier);
581
+
582
+ /**
583
+ * How many times one branch may enter the review gate before the item stops.
584
+ *
585
+ * 🔴 **Why a cap exists.** Every other stop in `autonomy.md` has something red
586
+ * behind it — a failing check, a conflicting rule, a false premise. A gate that
587
+ * keeps finding fixable prose has nothing red at all, so three strikes never fires
588
+ * and the run has no reason to stop re-entering it. That is not hypothetical: the
589
+ * repository this rulebook was extracted from journalled multi-round gates on single
590
+ * items with the whole suite green throughout, and its `budget` stop arriving "later
591
+ * than it should have".
592
+ *
593
+ * Two is the cap because the second round is what verifies the first round's fixes.
594
+ * A third is a decision for a human rather than another pass to buy — and the
595
+ * refusal says only that the count is spent, never that the fixes "are not
596
+ * converging": on one branch a granted third round found that round 2's fix had
597
+ * opened the mirror of the bug it closed (AR-115).
598
+ */
599
+ export const DEFAULT_MAX_GATE_ROUNDS = 2;
600
+
601
+ /**
602
+ * Is this round allowed, and if not, what stops?
603
+ *
604
+ * Pure, and separate from the counter on disk, because the rule and the storage
605
+ * fail differently: a wrong count is a bug in one file, a wrong rule is a bug in
606
+ * every caller. `gate-rounds.mjs` owns the count; this owns the verdict.
607
+ *
608
+ * `rounds` is the number of rounds **including the one about to run**, so a cap of
609
+ * 2 allows rounds 1 and 2 and refuses 3.
610
+ *
611
+ * The refusal is `documented-stall`, and what its diagnosis can contain is narrower
612
+ * than the name suggests: the previous rounds' blockers are not persisted anywhere, so
613
+ * what survives a compaction is the round count. The `loop` skill states the gap; do
614
+ * not write this as if the blockers were available.
615
+ *
616
+ * It is deliberately NOT in `SKIP_CAUSES`: those are reasons an item was passed over
617
+ * during selection, and this is a reason a task ends. One vocabulary holding both
618
+ * would make every sentence about either one wrong.
619
+ */
620
+ export const gateRoundVerdict = (rounds, max = DEFAULT_MAX_GATE_ROUNDS) => {
621
+ // A cap under 1 refuses the FIRST round, which turns the gate off rather than
622
+ // bounding it — the opposite of what this is for. Fail loudly on the
623
+ // configuration rather than quietly on every PR.
624
+ if (!Number.isInteger(max) || max < 1) {
625
+ throw new Error(
626
+ `maxGateRounds must be an integer of at least 1, got ${JSON.stringify(max)}. ` +
627
+ 'A cap below 1 would refuse the first gate round, which disables the gate ' +
628
+ 'instead of bounding it.',
629
+ );
630
+ }
631
+ const exceeded = rounds > max;
632
+ return { rounds, max, exceeded, stop: exceeded ? 'documented-stall' : null };
633
+ };
634
+
273
635
  /**
274
636
  * Pick the next item, or explain why nothing was taken.
275
637
  *
276
638
  * 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.
639
+ * meaningless when the run has no end. Never two mechanism-touching elevated
640
+ * items back to back — one unreviewed schema or permissions change is
641
+ * recoverable; a chain of them compounding overnight is not.
280
642
  */
281
- export const selectNext = (tickets, { lastCompletedTier = null, triggersFired = null } = {}) => {
643
+ export const selectNext = (
644
+ tickets,
645
+ { lastCompletedTier = null, triggersFired = null, owner = null } = {},
646
+ ) => {
282
647
  const skipped = [];
283
648
  const candidates = [];
284
649
 
285
650
  for (const ticket of tickets) {
286
- const selection = selectionOf(ticket, { triggersFired });
651
+ const selection = selectionOf(ticket, { triggersFired, owner });
287
652
  if (!selection.eligible) {
288
- skipped.push({ id: ticket.id, reason: selection.reasons.join('; ') });
653
+ skipped.push({
654
+ id: ticket.id,
655
+ reason: selection.reasons.join('; '),
656
+ causes: selection.causes,
657
+ });
289
658
  continue;
290
659
  }
291
- if (ticket.tier === 'elevated' && lastCompletedTier === 'elevated') {
660
+ if (ticket.tier === 'elevated' && !clearsSpacing(lastCompletedTier)) {
292
661
  skipped.push({
293
662
  id: ticket.id,
294
663
  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.',
664
+ `elevated, and the last completed change (${JSON.stringify(lastCompletedTier)}) ` +
665
+ 'did not clear the ration — never two back to back. Land a normal item, or ' +
666
+ 'an elevated change that is only prose, on a healthy runtime first.',
667
+ causes: ['spacing'],
297
668
  });
298
669
  continue;
299
670
  }
@@ -304,6 +675,250 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
304
675
  return { ticket, skipped, candidates: candidates.length };
305
676
  };
306
677
 
678
+ /**
679
+ * Revalidation at SELECT — is the item the run is about to take the item the
680
+ * last take-up saw?
681
+ *
682
+ * The snapshot is the ticket's `updatedAt` marker as recorded at the previous
683
+ * take-up in THIS run (`run-state.mjs` › recordTakeUp). One string compare on
684
+ * the tracker's last-modified field, no second network call — the unchanged
685
+ * case costs nothing. That the field moves on every edit, comment and status
686
+ * change is the tracker's contract, assumed here and not checked.
687
+ *
688
+ * 🔴 **`changed` is three-valued, and `null` is the honest one.** An adapter
689
+ * with no marker (`plan-md`) cannot say "unchanged"; it can only say it did not
690
+ * look. Collapsing that into `false` would report a blind spot as a pass, which
691
+ * is the one thing an evidence log must never do. `true` is reserved for a
692
+ * marker that moved: a first sight (no snapshot yet) is `false` with the baseline
693
+ * recorded, not a change.
694
+ *
695
+ * `action` says what the run does with it: `hold` — re-read the item before
696
+ * acting; `continue` — nothing moved; `unverifiable` — no marker to compare.
697
+ *
698
+ * ⚠ Limit: the marker moves on the run's OWN claim and comments too. The
699
+ * tracker adapters re-record the take-up after each write they make (AR-140),
700
+ * so a move made THROUGH the adapter is not a hold — one made by any other
701
+ * route (a hand-posted comment, a connector) still is. This function cannot
702
+ * tell who moved it; the re-read can, and the `loop` skill records that
703
+ * conclusion as a separate `revalidation-outcome` event.
704
+ */
705
+ export const revalidationOf = ({ ticket, snapshot = null }) => {
706
+ const to = typeof ticket?.updatedAt === 'string' ? ticket.updatedAt : null;
707
+ const from = typeof snapshot === 'string' ? snapshot : null;
708
+ // One shape at every point (AR-136): `source` is the list of what moved,
709
+ // `action` the same three words BEFORE_PR and BEFORE_CLOSE use, and the two
710
+ // markers sit under `task` — so a reader of the evidence log needs one parser.
711
+ const base = { ticket: ticket?.id ?? null, point: 'SELECT', task: { from, to } };
712
+ if (to === null) return { ...base, changed: null, source: [], action: 'unverifiable' };
713
+ const changed = from !== null && from !== to;
714
+ return {
715
+ ...base,
716
+ changed,
717
+ source: changed ? ['task:updatedAt'] : [],
718
+ action: changed ? 'hold' : 'continue',
719
+ };
720
+ };
721
+
722
+ /**
723
+ * Revalidation at BEFORE_PR — the aggregate over two sources, pure.
724
+ *
725
+ * `task` is what {@link revalidationOf} returned for the ticket against the
726
+ * take-up snapshot; `mainChanged` is the list of cited paths the default branch
727
+ * changed since the branch forked (`revalidate.mjs` computes it from git). One
728
+ * source name per finding — `task:updatedAt`, `main:<path>` — so a hold names
729
+ * exactly what moved, never "something changed".
730
+ *
731
+ * `changed` keeps the three values of the SELECT point: `true` when any source
732
+ * moved; `null` when nothing moved but the task could not be checked (no
733
+ * snapshot, no marker, no run) — a blind spot on one side is not a clean pass
734
+ * on both; `false` only when both sides were compared and neither moved.
735
+ */
736
+ export const beforePrRevalidationOf = ({ ticket, task = { changed: null }, mainChanged = [] }) => {
737
+ const source = [
738
+ ...(task?.changed === true ? ['task:updatedAt'] : []),
739
+ ...mainChanged.map((path) => `main:${path}`),
740
+ ];
741
+ const changed = source.length > 0 ? true : task?.changed === null ? null : false;
742
+ const action = changed === true ? 'hold' : changed === null ? 'unverifiable' : 'continue';
743
+ return { ticket, point: 'BEFORE_PR', changed, source, action };
744
+ };
745
+
746
+ /**
747
+ * Revalidation at BEFORE_CLOSE — the aggregate over the item's marker and its
748
+ * state, pure. `task` is what {@link revalidationOf} returned against the last
749
+ * validation; `state` is the item's neutral state now. At close the item is
750
+ * expected `in-progress`: `closed` means someone else published it, `open`
751
+ * means someone moved it back, and either is a change the close must not
752
+ * paper over. Same three-valued `changed` and the same actions as BEFORE_PR;
753
+ * `task:updatedAt` is named before `task:state`.
754
+ */
755
+ export const beforeCloseRevalidationOf = ({ ticket, task = { changed: null }, state = null }) => {
756
+ const source = [
757
+ ...(task?.changed === true ? ['task:updatedAt'] : []),
758
+ ...(state !== 'in-progress' ? ['task:state'] : []),
759
+ ];
760
+ const changed = source.length > 0 ? true : task?.changed === null ? null : false;
761
+ const action = changed === true ? 'hold' : changed === null ? 'unverifiable' : 'continue';
762
+ return { ticket, point: 'BEFORE_CLOSE', changed, source, action };
763
+ };
764
+
765
+ /**
766
+ * Split the skipped records into the ones holding takeable work back and the
767
+ * ones that are simply out of play.
768
+ *
769
+ * 🔴 **A parked cause outranks a holding one, per record** — and this precedence
770
+ * is the whole mechanism, not a detail. An escalated item is left CLAIMED on
771
+ * purpose (`escalate` labels it and nothing more), so on a tracker-backed
772
+ * adapter it arrives carrying `['in-progress', 'escalated']`. Let the holding
773
+ * cause win and every escalated item reads as "another session will finish it" —
774
+ * an item no session is on, which only a human clears — and `queue-empty` is
775
+ * unreachable from the first escalation onward. That is the exact defect this
776
+ * split exists to remove, one label over.
777
+ *
778
+ * The rule generalises past that case: a parked cause says the item does not
779
+ * come back into play without a human, and that outlasts any condition which
780
+ * would clear on its own.
781
+ *
782
+ * A record with no cause this module recognises counts as **held**: it is the
783
+ * reading that stops rather than the one that declares the queue drained, and an
784
+ * unclassified skip is exactly where a wrong declaration would come from.
785
+ *
786
+ * Bounded by construction: one forward pass, no recursion, and every array it
787
+ * builds is bounded by the input it walks.
788
+ */
789
+ const partitionSkipped = (skipped) => {
790
+ const held = [];
791
+ const parked = [];
792
+ for (const skip of skipped) {
793
+ const causes = Array.isArray(skip?.causes) ? skip.causes : [];
794
+ const parking = causes.find(
795
+ (cause) => SKIP_CAUSES.includes(cause) && !HOLDING_CAUSES.includes(cause),
796
+ );
797
+ if (parking) {
798
+ parked.push(parking);
799
+ continue;
800
+ }
801
+ held.push(causes.find((cause) => HOLDING_CAUSES.includes(cause)) ?? 'an unnamed filter');
802
+ }
803
+ return { held, parked };
804
+ };
805
+
806
+ /**
807
+ * "3 X, 1 Y" — a list of causes counted, as a partition.
808
+ *
809
+ * Each item counts ONCE, under the one cause it was classified by, so the parts
810
+ * foot to the total beside them. Counting it under every cause it carries would
811
+ * read as a breakdown and silently sum past that number.
812
+ *
813
+ * Bounded by construction: one forward pass, and a tally whose key set is the
814
+ * closed vocabulary plus one bucket for a record that carries no tag at all.
815
+ */
816
+ const breakdownOf = (causes, label = (count, tag) => `${count} ${tag}`) => {
817
+ const tally = new Map();
818
+ for (const cause of causes) tally.set(cause, (tally.get(cause) ?? 0) + 1);
819
+ return [...tally.entries()]
820
+ .sort(([tagA, countA], [tagB, countB]) => countB - countA || tagA.localeCompare(tagB))
821
+ .map(([tag, count]) => label(count, tag))
822
+ .join(', ');
823
+ };
824
+
825
+ const heldBreakdown = (held) => breakdownOf(held, (count, tag) => `${count} held by ${tag}`);
826
+
827
+ /**
828
+ * The parked pile, named rather than left to grow unseen.
829
+ *
830
+ * Reported beside the held count and never summed with it: they ask the owner
831
+ * for different things, and one number covering both would ask for neither.
832
+ *
833
+ * It names the causes the pile actually carries rather than the vocabulary it
834
+ * could have carried. A fixed list would announce `closed` — which no adapter
835
+ * can present, since every one of them drops closed items before selection —
836
+ * while staying silent about which of the reachable ones this pile is made of.
837
+ *
838
+ * It also stops at what it can see. How the pile GROWS differs per adapter (see
839
+ * `HOLDING_CAUSES` above), so a line claiming one mechanism would be false on
840
+ * another, and the stop line is not where that belongs.
841
+ */
842
+ const parkedNote = (parked) =>
843
+ parked.length === 0
844
+ ? ''
845
+ : ` A further ${parked.length} item(s) are parked — ${breakdownOf(parked)}. ` +
846
+ 'Those are not work this run can take and they wait on a human, never on ' +
847
+ 'time' +
848
+ (parked.includes('obsolete')
849
+ ? '; an obsolete item waits on a human close with a comment naming the ' +
850
+ 'evidence or the replacement, which the loop never writes'
851
+ : '') +
852
+ '.';
853
+
854
+ /**
855
+ * The trigger remedies, composed from the tags actually present.
856
+ *
857
+ * 🔴 **Why this is a function and not a sentence.** The two trigger kinds hold an
858
+ * item back through different mechanisms and are freed by different acts: an
859
+ * `auto` item waits for a declaration to be RECORDED, a `human` item is never
860
+ * self-taken at all and only a human editing the item's own marker frees it.
861
+ * When one cause tag stood for both, every fixed sentence keyed off it was
862
+ * wrong for one kind — the clause was rewritten three times, each revision
863
+ * repairing one sub-case and leaving the other, until the tags were split. So
864
+ * the remedy now follows the pile: unreachable unless the tag that earns it is
865
+ * in it, which makes a one-sided line structurally unavailable rather than
866
+ * merely discouraged.
867
+ *
868
+ * The cost of getting this wrong is not a confusing sentence. Told to record a
869
+ * declaration for a `human` item, an operator runs a command that reports
870
+ * success, changes nothing, and — under an adapter whose ids are list positions
871
+ * — leaves a live record that arms whatever occupies that slot next.
872
+ */
873
+ const triggerNote = (held) => {
874
+ const auto = held.includes('trigger-auto');
875
+ const human = held.includes('trigger-human');
876
+ if (!auto && !human) return '';
877
+ return (
878
+ ' A trigger is the exception, and the two kinds are freed differently:' +
879
+ (auto
880
+ ? ' a trigger-auto item waits for the declaration to be RECORDED — ' +
881
+ '`node .claude/scripts/run-state.mjs trigger <item-id>` — so waiting it ' +
882
+ 'out waits forever;'
883
+ : '') +
884
+ (human
885
+ ? ' an item held as trigger-human is not freed by recording a declaration ' +
886
+ "— that does nothing here; only a human changing the item's own marker " +
887
+ 'frees it;'
888
+ : '') +
889
+ ' both are declarations, not delays.'
890
+ );
891
+ };
892
+
893
+ /**
894
+ * The owner remedy (AR-132), present only when the pile carries the tag: an item
895
+ * another repository owns is freed by a human moving or re-marking it — never by
896
+ * waiting, and never by refilling this queue.
897
+ */
898
+ const ownerNote = (held) =>
899
+ held.includes('owner')
900
+ ? ' An item held as owner belongs to another repository (its `owner-<name>` ' +
901
+ "label is not this checkout's `options.owner`): a human moves it to that " +
902
+ "repository's queue or re-marks it; nothing this checkout does frees it."
903
+ : '';
904
+
905
+ /**
906
+ * The lifecycle remedies (AR-144), each present only when its tag is in the pile.
907
+ * Both are human acts on the item itself — neither time nor interleaving frees
908
+ * them, and the loop must not perform either: rewriting a `re-scope` item is
909
+ * authoring its own work, and un-parking is a scheduling decision.
910
+ */
911
+ const lifecycleNote = (held) =>
912
+ (held.includes('re-scope')
913
+ ? ' An item held as re-scope is valid work that is not executable as written: ' +
914
+ 'a human rewrites it against the current code and removes the label; the ' +
915
+ 'loop never invents the new scope.'
916
+ : '') +
917
+ (held.includes('deferred')
918
+ ? ' An item held as deferred carries the parked label — valid work deliberately ' +
919
+ 'not active now: a human un-parks it; nothing this run does frees it.'
920
+ : '');
921
+
307
922
  /**
308
923
  * Should the whole run stop? Checked in severity order, because a regression must
309
924
  * not be reported as an empty queue.
@@ -314,6 +929,7 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
314
929
  */
315
930
  export const stopConditionOf = ({
316
931
  candidates = 0,
932
+ skipped = [],
317
933
  lastDeployVerdict = null,
318
934
  consecutiveEscalations = 0,
319
935
  killSwitch = false,
@@ -370,13 +986,29 @@ export const stopConditionOf = ({
370
986
  };
371
987
  }
372
988
  if (candidates === 0) {
989
+ const { held, parked } = partitionSkipped(skipped);
990
+ if (held.length > 0) {
991
+ return {
992
+ kind: 'nothing-selectable',
993
+ success: true,
994
+ why:
995
+ `${held.length} item(s) are takeable work held back right now — ` +
996
+ `${heldBreakdown(held)}.${parkedNote(parked)} This is NOT an empty queue, ` +
997
+ 'and the two ask for opposite things: an empty queue wants refilling, ' +
998
+ 'whereas this one still holds work. Spacing clears when a normal item ' +
999
+ 'lands, a blocker when its item closes, in-progress when the other ' +
1000
+ `session finishes.${triggerNote(held) + ownerNote(held) + lifecycleNote(held)} Otherwise the action is to ` +
1001
+ 'interleave or to wait, never to refill and never to invent work.',
1002
+ };
1003
+ }
373
1004
  return {
374
1005
  kind: 'queue-empty',
375
1006
  success: true,
376
1007
  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.",
1008
+ 'no item survives the filters and nothing is merely held back the queue ' +
1009
+ `is genuinely out of work.${parkedNote(parked)} This is a legitimate end of ` +
1010
+ 'session, not an invitation to refactor: **do not invent work**. Refilling ' +
1011
+ "the queue is the owner's job.",
380
1012
  };
381
1013
  }
382
1014
  return null;
@@ -385,9 +1017,10 @@ export const stopConditionOf = ({
385
1017
  /**
386
1018
  * The stable fingerprint of an improvement proposal.
387
1019
  *
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.
1020
+ * Under a scheduler against a finite queue the most common stops are the two that
1021
+ * hand out nothing "queue empty" and "nothing selectable"; twenty such stops
1022
+ * must produce ONE proposal with a count of twenty, not twenty proposals. Dedupe
1023
+ * by fingerprint, then increment.
391
1024
  */
392
1025
  export const fingerprintOf = ({ finding, part, change }) =>
393
1026
  [finding, part, change]
@@ -426,5 +1059,153 @@ export const validateProposal = (proposal) => {
426
1059
  'run would prove it worked)',
427
1060
  );
428
1061
  }
1062
+ checkInferenceAgainstMeasurement(proposal);
429
1063
  return proposal;
430
1064
  };
1065
+
1066
+ /**
1067
+ * What a proposal MEASURED and what it INFERRED from that, as two paired
1068
+ * optional fields (AR-142).
1069
+ *
1070
+ * A proposal whose premise was never true had no check at filing, only at
1071
+ * take-up: AR-124 was filed, promoted and claimed before anyone found that its
1072
+ * platform conclusion rested on a probe that had touched one hook. So the two
1073
+ * halves are named separately, and an inference that cites a path the
1074
+ * measurement does not cite is refused here, with both fields and the path
1075
+ * named — the author has to either measure that surface or stop claiming it.
1076
+ *
1077
+ * A "surface" is a cited path, read by `citedPathsOf`: a text scan, so a
1078
+ * surface named indirectly ("the platform") is invisible to this check — it
1079
+ * catches the path-shaped overreach and nothing subtler. Neither field given
1080
+ * files as before; one without the other is refused, because a measurement
1081
+ * without its inference (or the reverse) is exactly the half-formed shape the
1082
+ * pairing exists to make visible.
1083
+ */
1084
+ const checkInferenceAgainstMeasurement = ({ measured, inferred } = {}) => {
1085
+ const has = (value) => typeof value === 'string' && value.trim().length > 0;
1086
+ if (!has(measured) && !has(inferred)) return;
1087
+ if (!has(measured) || !has(inferred)) {
1088
+ const present = has(measured) ? 'measured' : 'inferred';
1089
+ const absent = has(measured) ? 'inferred' : 'measured';
1090
+ throw new Error(
1091
+ `a proposal that names what it ${present} must also name what it ${absent}: ` +
1092
+ `\`${present}\` is given and \`${absent}\` is not — the two are a pair`,
1093
+ );
1094
+ }
1095
+ const touched = new Set(citedPathsOf(measured));
1096
+ const overreach = citedPathsOf(inferred).filter((path) => !touched.has(path));
1097
+ if (overreach.length > 0) {
1098
+ throw new Error(
1099
+ `a proposal's inference names a surface its measurement did not touch: ` +
1100
+ `\`inferred\` cites ${overreach.join(', ')}, which \`measured\` does not. ` +
1101
+ 'Measure that surface, or keep the inference inside what was measured.',
1102
+ );
1103
+ }
1104
+ };
1105
+
1106
+ /**
1107
+ * The commit a proposal was measured against, read back out of its body — or
1108
+ * null when the body carries none (AR-116).
1109
+ *
1110
+ * Seven to forty hex characters, after the literal `asOf: `. An absent marker is
1111
+ * `null`, never "HEAD": the hygiene finding for it is "unanswerable", and a
1112
+ * default that quietly said "current" is the failure this field exists to name.
1113
+ */
1114
+ const AS_OF_IN_BODY = /(?:^|[\s(`"'])asOf: ([0-9a-f]{7,40})(?![0-9a-z])/;
1115
+
1116
+ export const asOfOf = (body) => {
1117
+ const text = typeof body === 'string' ? body : '';
1118
+ const match = AS_OF_IN_BODY.exec(text.slice(0, 20_000));
1119
+ return match ? match[1] : null;
1120
+ };
1121
+
1122
+ /**
1123
+ * The repository paths a proposal's text cites, in order of first mention.
1124
+ *
1125
+ * A path here is a token ending in a source or document extension, with any
1126
+ * directory prefix — `queue/core.mjs`, `.claude/rules/workflow.md`,
1127
+ * `journal/2026-08.md`. It is a text scan, so a path named indirectly ("the
1128
+ * router") is invisible, and a bare word with an extension counts even when it
1129
+ * is not a file — the finding it feeds says "possibly", for exactly this reason.
1130
+ *
1131
+ * Bounded: one pass over at most 20 000 characters, at most 200 matches.
1132
+ */
1133
+ const PATH_IN_TEXT =
1134
+ /(?<![\w./-])((?:[\w.-]+\/)*[\w-][\w.-]*\.(?:mjs|cjs|js|ts|tsx|md|mdx|json|ya?ml|sh|txt))(?![\w/])/g;
1135
+
1136
+ export const citedPathsOf = (text) => {
1137
+ const source = (typeof text === 'string' ? text : '').slice(0, 20_000);
1138
+ const seen = new Set();
1139
+ let match;
1140
+ while (seen.size < 200 && (match = PATH_IN_TEXT.exec(source)) !== null) {
1141
+ seen.add(match[1]);
1142
+ }
1143
+ PATH_IN_TEXT.lastIndex = 0;
1144
+ return [...seen];
1145
+ };
1146
+
1147
+ /**
1148
+ * Has the code a proposal describes moved since the proposal was written?
1149
+ *
1150
+ * Pure: `changedSince` is the list of paths git reports changed between `asOf`
1151
+ * and `head`, or `null` when git could not answer — an unknown commit, a
1152
+ * shallow clone, no checkout at all. The caller runs git; this decides.
1153
+ *
1154
+ * Why it exists: AR-47 and AR-87 both escalated PREMISE FALSE because the merge
1155
+ * that falsified each landed after the proposal was filed, and selection hands
1156
+ * out the oldest proposal first. Nothing on the item said which commit the
1157
+ * finding described, so nothing could say it had been overtaken.
1158
+ *
1159
+ * Every answer short of "clean" is a finding. A proposal without `asOf` is
1160
+ * reported as unanswerable, never as current; one citing no path cannot be
1161
+ * checked by path and says so; a git that could not diff is reported as such.
1162
+ * Silence means: filed against HEAD, or every cited path unchanged since.
1163
+ */
1164
+ export const overtakenOf = ({ id, asOf = null, citedPaths = [], head = null, changedSince = null }) => {
1165
+ if (!asOf) {
1166
+ return {
1167
+ kind: 'proposal-asof-missing',
1168
+ id,
1169
+ why:
1170
+ 'the proposal does not say which commit it was measured against, so whether ' +
1171
+ 'the code it describes has moved since cannot be answered — unanswerable, ' +
1172
+ 'not clean',
1173
+ };
1174
+ }
1175
+ const paths = Array.isArray(citedPaths) ? citedPaths : [];
1176
+ if (paths.length === 0) {
1177
+ return {
1178
+ kind: 'proposal-cites-no-path',
1179
+ id,
1180
+ why:
1181
+ `measured against ${asOf}, but its text names no repository path, so a change ` +
1182
+ 'that overtook it cannot be seen by path — read it before acting on it',
1183
+ };
1184
+ }
1185
+ const current = typeof head === 'string' && (head.startsWith(asOf) || asOf.startsWith(head));
1186
+ if (current) return null;
1187
+ if (changedSince === null) {
1188
+ return {
1189
+ kind: 'proposal-asof-unanswerable',
1190
+ id,
1191
+ why:
1192
+ `git could not list what changed since ${asOf} — an unknown commit, a shallow ` +
1193
+ 'clone, or no checkout — so whether the proposal was overtaken is unanswered',
1194
+ };
1195
+ }
1196
+ // By suffix on a path boundary, because findings cite the way people write —
1197
+ // `queue/core.mjs` for `.claude/scripts/queue/core.mjs` — and an exact match
1198
+ // would read that citation as clean, the one direction of miss this exists to
1199
+ // prevent. `x/not-core.mjs` is not a match for `core.mjs`.
1200
+ const moved = changedSince.filter((changed) =>
1201
+ paths.some((cited) => changed === cited || changed.endsWith(`/${cited}`)),
1202
+ );
1203
+ if (moved.length === 0) return null;
1204
+ return {
1205
+ kind: 'proposal-possibly-overtaken',
1206
+ id,
1207
+ why:
1208
+ `measured against ${asOf}, and ${moved.join(', ')} changed since — re-read the ` +
1209
+ 'proposal against the code before taking it; its premise may have rotted',
1210
+ };
1211
+ };