create-agent-rig 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +170 -34
  2. package/README.md +12 -6
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/init.js +6 -3
  5. package/packages/cli/dist/commands/upgrade.js +2 -2
  6. package/packages/cli/dist/index.js +46 -8
  7. package/packages/cli/dist/lib/manifest.js +10 -0
  8. package/scripts/prepare.mjs +1 -1
  9. package/templates/agent-os/init/AGENTS.md +15 -5
  10. package/templates/agent-os/init/CLAUDE.md +15 -5
  11. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
  12. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
  13. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
  14. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
  15. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  16. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
  17. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +330 -16
  18. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
  19. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
  20. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +18 -6
  21. package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
  22. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
  23. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
  24. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
  25. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
  26. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
  27. package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
  28. package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
  29. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
  30. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
  31. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  32. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  33. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +29 -0
  34. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  35. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
  36. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
  37. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  38. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
  40. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
  41. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
  42. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
  43. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
  44. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
  45. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
  46. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
  47. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  48. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
  49. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
  50. package/templates/agent-os/universal/.claude/settings.json +5 -1
  51. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
  52. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
  53. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
  54. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  55. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  56. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  57. package/templates/agent-os/universal/.codex/hooks.json +6 -1
  58. package/templates/agent-os/universal/AGENTS.md +3 -1
  59. package/templates/agent-os/universal/CLAUDE.md +3 -1
  60. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  61. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
  62. package/templates/agent-os/universal/layers.json +9 -0
  63. package/templates/hash-history.json +404 -51
  64. package/templates/release-ledger.json +10 -0
  65. package/templates/skeleton/aws-serverless/gitignore +2 -0
  66. package/templates/skeleton/node-service/gitignore +2 -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,12 +65,19 @@
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
 
66
83
  /**
@@ -80,6 +97,17 @@ export const SKIP_CAUSES = Object.freeze([
80
97
  'trigger-auto',
81
98
  'trigger-human',
82
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',
83
111
  ]);
84
112
 
85
113
  /**
@@ -120,8 +148,105 @@ export const HOLDING_CAUSES = Object.freeze([
120
148
  'spacing',
121
149
  'trigger-auto',
122
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',
123
161
  ]);
124
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
+
125
250
  /**
126
251
  * Is this item takeable, and if not, why not?
127
252
  *
@@ -130,7 +255,7 @@ export const HOLDING_CAUSES = Object.freeze([
130
255
  * `cause` tag, so the stop line can say what is holding the queue back without
131
256
  * reading the prose back.
132
257
  */
133
- export const selectionOf = (ticket, { triggersFired = null } = {}) => {
258
+ export const selectionOf = (ticket, { triggersFired = null, owner = null } = {}) => {
134
259
  const reasons = [];
135
260
  const causes = [];
136
261
  const labels = ticket.labels ?? [];
@@ -181,6 +306,33 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
181
306
  // that claim, and it would be false for the item most likely to carry both:
182
307
  // one an owner tightened from auto-gated to human-gated without deleting the
183
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
+
184
336
  if (ticket.trigger === 'human') {
185
337
  reject(
186
338
  'trigger-human',
@@ -210,10 +362,54 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
210
362
  * Reported, never silently corrected: a loop that quietly rewrites the queue's
211
363
  * own metadata removes the evidence that the metadata is unreliable.
212
364
  */
213
- export const hygieneOf = (ticket) => {
365
+ export const hygieneOf = (ticket, { owner = null } = {}) => {
214
366
  const labels = ticket.labels ?? [];
215
367
  const open = (ticket.blockedBy ?? []).filter((blocker) => !blocker.resolved);
216
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
+
217
413
  if (labels.includes('blocked') && open.length === 0) {
218
414
  return {
219
415
  kind: 'stale-blocked-label',
@@ -395,8 +591,10 @@ const clearsSpacing = (lastCompletedTier) =>
395
591
  * than it should have".
396
592
  *
397
593
  * 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.
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).
400
598
  */
401
599
  export const DEFAULT_MAX_GATE_ROUNDS = 2;
402
600
 
@@ -442,12 +640,15 @@ export const gateRoundVerdict = (rounds, max = DEFAULT_MAX_GATE_ROUNDS) => {
442
640
  * items back to back — one unreviewed schema or permissions change is
443
641
  * recoverable; a chain of them compounding overnight is not.
444
642
  */
445
- export const selectNext = (tickets, { lastCompletedTier = null, triggersFired = null } = {}) => {
643
+ export const selectNext = (
644
+ tickets,
645
+ { lastCompletedTier = null, triggersFired = null, owner = null } = {},
646
+ ) => {
446
647
  const skipped = [];
447
648
  const candidates = [];
448
649
 
449
650
  for (const ticket of tickets) {
450
- const selection = selectionOf(ticket, { triggersFired });
651
+ const selection = selectionOf(ticket, { triggersFired, owner });
451
652
  if (!selection.eligible) {
452
653
  skipped.push({
453
654
  id: ticket.id,
@@ -474,6 +675,93 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
474
675
  return { ticket, skipped, candidates: candidates.length };
475
676
  };
476
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
+
477
765
  /**
478
766
  * Split the skipped records into the ones holding takeable work back and the
479
767
  * ones that are simply out of play.
@@ -556,7 +844,12 @@ const parkedNote = (parked) =>
556
844
  ? ''
557
845
  : ` A further ${parked.length} item(s) are parked — ${breakdownOf(parked)}. ` +
558
846
  'Those are not work this run can take and they wait on a human, never on ' +
559
- 'time.';
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
+ '.';
560
853
 
561
854
  /**
562
855
  * The trigger remedies, composed from the tags actually present.
@@ -597,6 +890,35 @@ const triggerNote = (held) => {
597
890
  );
598
891
  };
599
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
+
600
922
  /**
601
923
  * Should the whole run stop? Checked in severity order, because a regression must
602
924
  * not be reported as an empty queue.
@@ -675,7 +997,7 @@ export const stopConditionOf = ({
675
997
  'and the two ask for opposite things: an empty queue wants refilling, ' +
676
998
  'whereas this one still holds work. Spacing clears when a normal item ' +
677
999
  'lands, a blocker when its item closes, in-progress when the other ' +
678
- `session finishes.${triggerNote(held)} Otherwise the action is to ` +
1000
+ `session finishes.${triggerNote(held) + ownerNote(held) + lifecycleNote(held)} Otherwise the action is to ` +
679
1001
  'interleave or to wait, never to refill and never to invent work.',
680
1002
  };
681
1003
  }
@@ -737,5 +1059,153 @@ export const validateProposal = (proposal) => {
737
1059
  'run would prove it worked)',
738
1060
  );
739
1061
  }
1062
+ checkInferenceAgainstMeasurement(proposal);
740
1063
  return proposal;
741
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
+ };