create-agent-rig 0.5.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 (60) hide show
  1. package/CHANGELOG.md +140 -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 +11 -3
  10. package/templates/agent-os/init/CLAUDE.md +11 -3
  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 +298 -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 +8 -1
  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 +28 -3
  23. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  24. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +23 -0
  25. package/templates/agent-os/universal/.claude/rules/autonomy.md +8 -0
  26. package/templates/agent-os/universal/.claude/rules/invariants.md +33 -3
  27. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +19 -1
  28. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  29. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  30. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  31. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
  32. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
  33. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  34. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
  35. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
  36. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
  37. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +138 -15
  38. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +394 -46
  39. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
  40. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  41. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  42. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
  43. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  44. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  45. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
  46. package/templates/agent-os/universal/.claude/settings.json +5 -1
  47. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
  48. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +298 -16
  49. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
  50. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  51. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  52. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  53. package/templates/agent-os/universal/.codex/hooks.json +6 -1
  54. package/templates/agent-os/universal/AGENTS.md +3 -1
  55. package/templates/agent-os/universal/CLAUDE.md +3 -1
  56. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  57. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
  58. package/templates/agent-os/universal/layers.json +9 -0
  59. package/templates/hash-history.json +309 -49
  60. package/templates/release-ledger.json +9 -0
@@ -12,7 +12,8 @@
12
12
  // has real dependencies, move to an adapter whose tracker can express them
13
13
  // (`github-issues`). Ordering the list by hand is not a dependency graph.
14
14
  import { readFileSync, writeFileSync } from 'node:fs';
15
- import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
15
+ import { duplicateOf, fingerprintOf, validateProposal, lifecycleOf } from './core.mjs';
16
+ import { withAsOf } from './as-of.mjs';
16
17
  import { recordEscalation } from '../run-state.mjs';
17
18
 
18
19
  export const name = 'plan-md';
@@ -60,6 +61,17 @@ const MARKERS = {
60
61
  triage: /\[triage\]/i,
61
62
  triggerAuto: /\[trigger-auto\]/i,
62
63
  triggerHuman: /\[trigger-human\]/i,
64
+ // `[owner:<name>]` — the repository the item belongs to (AR-132), the same
65
+ // fact the tracker adapters read out of an `owner-<name>` label. Case-exact,
66
+ // because the name is compared exactly to `options.owner`.
67
+ owner: /\[owner:([^\]\s]+)\]/,
68
+ // AR-144: the lifecycle vocabulary and the scheduling flag, the same words the
69
+ // tracker adapters read as labels. Case-insensitive, as the tier and trigger
70
+ // markers are (the owner marker is case-exact, because it is compared to a name).
71
+ keepCore: /\[keep-core\]/i,
72
+ reScope: /\[re-scope\]/i,
73
+ obsolete: /\[obsolete\]/i,
74
+ parked: /\[parked\]/i,
63
75
  };
64
76
 
65
77
  /**
@@ -91,7 +103,10 @@ export const parsePlan = (plan) => {
91
103
  if (!match) continue;
92
104
  const raw = match[1];
93
105
  const title = raw
94
- .replace(/\[(elevated|triage|trigger-auto|trigger-human)\]/gi, '')
106
+ .replace(
107
+ /\[(elevated|triage|trigger-auto|trigger-human|keep-core|re-scope|obsolete|parked|owner:[^\]\s]+)\]/gi,
108
+ '',
109
+ )
95
110
  .replace(/\s+/g, ' ')
96
111
  .trim();
97
112
  items.push({
@@ -114,12 +129,29 @@ export const parsePlan = (plan) => {
114
129
  blocks: [],
115
130
  priority: items.length,
116
131
  createdAt: null,
132
+ // A flat list carries no marker at all, so revalidation at SELECT records
133
+ // `changed: null` for it — a blind spot, never an "unchanged".
134
+ updatedAt: null,
117
135
  triage: MARKERS.triage.test(raw),
118
136
  trigger: MARKERS.triggerAuto.test(raw)
119
137
  ? 'auto'
120
138
  : MARKERS.triggerHuman.test(raw)
121
139
  ? 'human'
122
140
  : null,
141
+ owner: MARKERS.owner.exec(raw)?.[1] ?? null,
142
+ // The markers present, handed to the one precedence rule (`core.mjs` ›
143
+ // lifecycleOf) rather than re-deriving it here. Hygiene cannot report a
144
+ // contradiction on this adapter: a flat list carries no labels for it to read.
145
+ ...lifecycleOf(
146
+ [
147
+ ['keep-core', MARKERS.keepCore],
148
+ ['re-scope', MARKERS.reScope],
149
+ ['obsolete', MARKERS.obsolete],
150
+ ['parked', MARKERS.parked],
151
+ ]
152
+ .filter(([, marker]) => marker.test(raw))
153
+ .map(([label]) => label),
154
+ ),
123
155
  });
124
156
  }
125
157
  return items;
@@ -190,10 +222,20 @@ export const claim = (ticket) => ({
190
222
  'its own worktree and say so in the journal.',
191
223
  });
192
224
 
225
+ /** One item by position; a flat list has no closed state, so an absent line is `null`. */
226
+ export const find = (id, options = {}) =>
227
+ parsePlan(readPlan(options)).find((ticket) => ticket.id === String(id)) ?? null;
228
+
193
229
  export const close = (ticket, { prUrl = null, planPath: p } = {}) => {
194
230
  const file = p ?? 'PLAN.md';
195
- writeFileSync(file, closeInPlan(readFileSync(file, 'utf8'), ticket.id));
196
- return { ok: true, prUrl };
231
+ const before = readFileSync(file, 'utf8');
232
+ const present = parsePlan(before).some((item) => item.id === String(ticket.id));
233
+ writeFileSync(file, closeInPlan(before, ticket.id));
234
+ // `transitioned` means the line was there and is now gone — an id the plan
235
+ // never carried closed nothing, and saying otherwise would publish a close
236
+ // that changed no state (AR-135).
237
+ const gone = !parsePlan(readFileSync(file, 'utf8')).some((item) => item.id === String(ticket.id));
238
+ return { ok: true, prUrl, transitioned: present && gone };
197
239
  };
198
240
 
199
241
  /** A flat list has no comment thread; the journal is where this lands. */
@@ -242,8 +284,10 @@ export const triageItemFor = (proposal) => {
242
284
  `- **part to change** — ${proposal.part}`,
243
285
  `- **proposed change** — ${proposal.change}`,
244
286
  `- **how the next run proves it** — ${proposal.proof}`,
287
+ ...(proposal.measured ? [`- **measured** — ${proposal.measured}`, `- **inferred** — ${proposal.inferred}`] : []),
245
288
  '',
246
289
  `fingerprint: ${fingerprint}`,
290
+ ...(proposal.asOf ? [`asOf: ${proposal.asOf}`] : []),
247
291
  '',
248
292
  'The loop proposes; the owner patches. Self-applying a change to its own',
249
293
  'rulebook is how an unattended run drifts irreversibly.',
@@ -316,8 +360,26 @@ export const oneLine = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
316
360
  const bulletFor = (item, proposal, seen) =>
317
361
  `- **${oneLine(item.title)}** — finding: ${oneLine(proposal.finding)} · ` +
318
362
  `part: ${oneLine(proposal.part)} · proof: ${oneLine(proposal.proof)} · ` +
363
+ `${proposal.measured ? `measured: ${oneLine(proposal.measured)} · inferred: ${oneLine(proposal.inferred)} · ` : ''}` +
364
+ `${proposal.asOf ? `asOf: ${proposal.asOf} · ` : ''}` +
319
365
  `fingerprint: \`${item.fingerprint}\` · seen ×${seen}`;
320
366
 
367
+ /**
368
+ * The proposals on file, as `{ id, body }` — every Operator-queue line carrying
369
+ * this adapter's trailing field shape, the whole line as the body so `asOfOf`
370
+ * and `citedPathsOf` can read it. An absent Operator queue lists nothing.
371
+ */
372
+ export const listProposals = ({ planPath: p } = {}) => {
373
+ const plan = readFileSync(p ?? 'PLAN.md', 'utf8');
374
+ const { found, lines, start, end } = sectionRange(plan, OPERATOR_QUEUE);
375
+ if (!found) return [];
376
+ const proposals = [];
377
+ for (let i = start; i < end; i += 1) {
378
+ if (TAIL.test(lines[i])) proposals.push({ id: String(i), body: lines[i] });
379
+ }
380
+ return proposals;
381
+ };
382
+
321
383
  /**
322
384
  * File a proposal into the **Operator queue** — never the Agent queue.
323
385
  *
@@ -387,7 +449,8 @@ const bulletFor = (item, proposal, seen) =>
387
449
  * the bullet stays unselectable — but a reader sees it under the wrong
388
450
  * heading.
389
451
  */
390
- export const proposeTriage = (proposal, { planPath: p } = {}) => {
452
+ export const proposeTriage = (rawProposal, { planPath: p } = {}) => {
453
+ const proposal = withAsOf(rawProposal);
391
454
  const item = triageItemFor(proposal);
392
455
  const file = p ?? 'PLAN.md';
393
456
  const plan = readFileSync(file, 'utf8');
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Revalidation at BEFORE_PR — is the branch about to ship still the branch the
4
+ * run took up?
5
+ *
6
+ * node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <id> [--base origin/master] [--config <queue.json>] [--json]
7
+ *
8
+ * Two sources, compared and named separately, because a hold that cannot say
9
+ * WHAT moved sends the run to re-read everything:
10
+ *
11
+ * - `task:updatedAt` — the item's marker now, read through the queue adapter,
12
+ * against the take-up snapshot `queue/index.mjs next` recorded in the run's
13
+ * `state.json` (`takeUps`, AR-133). No snapshot, no marker or no run → that
14
+ * source is `null`: not looked, never "unchanged".
15
+ * - `main:<path>` — what the default branch changed since this branch forked
16
+ * (`git merge-base <base> HEAD` … `<base>`), intersected with the CITED
17
+ * paths. Cited is a labelled assumption, not a recorded fact: the paths the
18
+ * branch itself touches, plus every `blockers[].file` of a `check-premises`
19
+ * record in this run's journal — the files the run said its premises rest
20
+ * on. An unrelated change on the default branch does not hold.
21
+ *
22
+ * At BEFORE_CLOSE (AR-135) there is no git and no main: the sources are
23
+ * `task:updatedAt` against the LAST VALIDATION — the `task.to` of this run's
24
+ * latest `revalidation` event for the item, falling back to the take-up
25
+ * snapshot — and `task:state`, which is expected `in-progress` at close and
26
+ * is a change when someone closed the item or moved it back. The item comes
27
+ * from the adapter's `find`, which sees closed items where `listEligible`
28
+ * drops them; one the tracker no longer offers at all reads `missing`, and
29
+ * either holds on `task:state` (revalidate.test.ts › "holds on task:state when
30
+ * someone already closed the item", › "holds on task:state when the tracker no
31
+ * longer offers the item"). The result lists the item's dependants (`blocks`)
32
+ * and re-reads each one's state through the same `find` (revalidate.test.ts ›
33
+ * "re-reads each dependant's state, and names one the tracker no longer
34
+ * offers") for the loop's write-back.
35
+ *
36
+ * The aggregates are `queue/core.mjs` › beforePrRevalidationOf and
37
+ * beforeCloseRevalidationOf; this file is the I/O around them.
38
+ *
39
+ * `outcome --point <P> --ticket <id> --action-changed true|false [--note …]`
40
+ * (AR-136) is the second half of the evidence: after the re-read, it appends a
41
+ * `revalidation-outcome` record whose `answers` is the seq of the latest
42
+ * `revalidation` for that ticket and point in this run — the join a report
43
+ * needs, made by the writer rather than guessed by the reader. It refuses
44
+ * without a run, without a matching revalidation, and with any word but
45
+ * `true`/`false`, and writes nothing then. Exit 2 on `hold`, 0 on `continue` and `unverifiable`, 1 when
46
+ * the arguments cannot be acted on (unknown point, no ticket, a base that is
47
+ * not a revision) — and then nothing is journalled, because a refusal is not
48
+ * an answer.
49
+ *
50
+ * ⚠ It reads `<base>` as it is in this checkout and never updates the remote
51
+ * ref itself; `pr-ship` step 1 does that before calling this. A stale ref
52
+ * makes this compare against yesterday's main and report `continue` — see
53
+ * revalidate.test.ts › "reads the ref as it is: a stale origin/master reports
54
+ * continue".
55
+ */
56
+
57
+ import { execFileSync } from 'node:child_process';
58
+ import { realpathSync } from 'node:fs';
59
+ import { dirname, join } from 'node:path';
60
+ import { fileURLToPath } from 'node:url';
61
+ import { withoutGitLocation } from './git-env.mjs';
62
+ import { readRun, recordEvent } from './run-journal.mjs';
63
+ import { readState } from './run-state.mjs';
64
+ import { POINTS as ALL_POINTS, REVALIDATES } from './lib/revalidation-points.mjs';
65
+ import { beforeCloseRevalidationOf, beforePrRevalidationOf, revalidationOf } from './queue/core.mjs';
66
+ import { loadConfig, optionsWithPlanPath, resolveAdapter } from './queue/index.mjs';
67
+
68
+ // Derived from the one source, never restated here (AR-137).
69
+ export const POINTS = REVALIDATES;
70
+
71
+ const revisionOrNull = (value) =>
72
+ typeof value === 'string' && value !== '' && !value.startsWith('-') ? value : null;
73
+
74
+ const parseArgs = (argv) => {
75
+ const args = {
76
+ outcome: false,
77
+ point: null,
78
+ ticket: null,
79
+ base: 'origin/master',
80
+ config: null,
81
+ json: false,
82
+ actionChanged: null,
83
+ note: null,
84
+ bad: null,
85
+ };
86
+ for (let i = 0; i < argv.length; i += 1) {
87
+ const arg = argv[i];
88
+ if (i === 0 && arg === 'outcome') args.outcome = true;
89
+ else if (arg === '--json') args.json = true;
90
+ else if (arg === '--action-changed') args.actionChanged = argv[++i] ?? null;
91
+ else if (arg === '--note') args.note = argv[++i] ?? null;
92
+ else if (arg === '--point') args.point = argv[++i] ?? null;
93
+ else if (arg === '--ticket') {
94
+ // The id reaches git-free paths only, but on github-issues it becomes a
95
+ // `gh` argv element: a value starting with `-` would be read as an option.
96
+ const value = revisionOrNull(argv[++i]);
97
+ if (value === null) args.bad = arg;
98
+ else args.ticket = value;
99
+ }
100
+ else if (arg === '--config') args.config = argv[++i] ?? null;
101
+ else if (arg === '--base') {
102
+ const value = revisionOrNull(argv[++i]);
103
+ if (value === null) args.bad = arg;
104
+ else args.base = value;
105
+ } else if (args.bad === null) args.bad = arg;
106
+ }
107
+ return args;
108
+ };
109
+
110
+ const git = (args) =>
111
+ execFileSync('git', args, {
112
+ encoding: 'utf8',
113
+ env: withoutGitLocation(),
114
+ stdio: ['ignore', 'pipe', 'pipe'],
115
+ maxBuffer: 64 * 1024 * 1024,
116
+ });
117
+
118
+ const pathsOf = (raw) => raw.split('\0').filter(Boolean);
119
+
120
+ /**
121
+ * The paths a `check-premises` record in this run named. Only that gate: a
122
+ * reviewer blocker names where a finding is, not what the task rests on.
123
+ */
124
+ const citedByPremises = (runDir) => {
125
+ if (!runDir) return [];
126
+ const { decisions } = readRun({ runDir });
127
+ return decisions
128
+ .filter((record) => record.gate === 'check-premises')
129
+ .flatMap((record) => (Array.isArray(record.blockers) ? record.blockers : []))
130
+ .map((blocker) => blocker?.file)
131
+ .filter((file) => typeof file === 'string' && file !== '');
132
+ };
133
+
134
+ /** The last validation's marker for this item: the latest revalidation event, any point. */
135
+ const lastValidationOf = (runDir, id) => {
136
+ if (!runDir) return null;
137
+ const { events } = readRun({ runDir });
138
+ const last = [...events]
139
+ .reverse()
140
+ .find((e) => e.kind === 'revalidation' && String(e.data?.ticket) === String(id));
141
+ const to = last?.data?.task?.to;
142
+ return typeof to === 'string' ? to : null;
143
+ };
144
+
145
+ const invokedDirectly = () => {
146
+ if (!process.argv[1]) return false;
147
+ const real = (p) => {
148
+ try {
149
+ return realpathSync(p);
150
+ } catch {
151
+ return p;
152
+ }
153
+ };
154
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
155
+ };
156
+
157
+ const refuse = (message) => {
158
+ process.stderr.write(`${message}\n`);
159
+ process.exit(1);
160
+ };
161
+
162
+ if (invokedDirectly()) {
163
+ const args = parseArgs(process.argv.slice(2));
164
+ if (args.bad !== null) refuse(`unrecognised or unusable argument: ${args.bad}`);
165
+ // An outcome may answer any point, SELECT included — that one is written by
166
+ // `queue/index.mjs next`, so it is not in POINTS, which names what THIS
167
+ // script can revalidate.
168
+ const known = args.outcome ? ALL_POINTS : POINTS;
169
+ if (!known.includes(args.point)) {
170
+ refuse(`unknown point: ${args.point ?? '(none)'}. This script knows ${known.join(', ')}.`);
171
+ }
172
+ if (!args.ticket) refuse('--ticket is required: the item whose take-up this branch is.');
173
+
174
+ const runDir = process.env.RIG_RUN_DIR || null;
175
+
176
+ if (args.outcome) {
177
+ if (!runDir) refuse('outcome needs RIG_RUN_DIR: an outcome answers a revalidation in a run, and there is none.');
178
+ if (args.actionChanged !== 'true' && args.actionChanged !== 'false') {
179
+ refuse(`--action-changed must be true or false, got ${args.actionChanged ?? '(none)'}.`);
180
+ }
181
+ const { events } = readRun({ runDir });
182
+ const target = [...events]
183
+ .reverse()
184
+ .find(
185
+ (e) =>
186
+ e.kind === 'revalidation' &&
187
+ String(e.data?.ticket) === String(args.ticket) &&
188
+ e.data?.point === args.point,
189
+ );
190
+ if (!target) {
191
+ refuse(`no revalidation of ${args.ticket} at ${args.point} in ${runDir} for this outcome to answer.`);
192
+ }
193
+ const record = recordEvent({
194
+ runDir,
195
+ kind: 'revalidation-outcome',
196
+ data: {
197
+ ticket: args.ticket,
198
+ point: args.point,
199
+ actionChanged: args.actionChanged === 'true',
200
+ note: args.note,
201
+ answers: target.seq,
202
+ },
203
+ now: new Date().toISOString(),
204
+ });
205
+ process.stdout.write(
206
+ args.json
207
+ ? `${JSON.stringify(record, null, 2)}\n`
208
+ : `revalidation-outcome: ${args.ticket} at ${args.point} answers seq ${target.seq} — actionChanged ${args.actionChanged}\n`,
209
+ );
210
+ process.exit(0);
211
+ }
212
+
213
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
214
+ const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
215
+ const config = loadConfig(configPath);
216
+ const adapter = await resolveAdapter(config.adapter ?? 'plan-md');
217
+ const options = optionsWithPlanPath(config.options, configPath);
218
+
219
+ if (args.point === 'BEFORE_CLOSE') {
220
+ const ticket = await adapter.find(args.ticket, options);
221
+ const takeUp = runDir ? (readState(runDir).takeUps?.[args.ticket] ?? null) : null;
222
+ // The NEWER of the two, not the last validation first (AR-140): an adapter
223
+ // re-records the take-up after each write of its own, and a comment posted
224
+ // after BEFORE_PR would otherwise hold this close on the run's own move.
225
+ // ISO strings compare as text; a missing side yields to the other.
226
+ const lastValidation = lastValidationOf(runDir, args.ticket);
227
+ const baseline =
228
+ lastValidation !== null && takeUp !== null
229
+ ? takeUp > lastValidation
230
+ ? takeUp
231
+ : lastValidation
232
+ : (lastValidation ?? takeUp);
233
+ const task =
234
+ ticket && baseline !== null
235
+ ? revalidationOf({ ticket, snapshot: baseline })
236
+ : { changed: null, task: { from: baseline, to: ticket?.updatedAt ?? null } };
237
+ // Not found is not "in progress": the tracker no longer offers the item.
238
+ const actual = ticket ? ticket.state : 'missing';
239
+ // The dependants' state is RE-READ, not copied off the item: what this close
240
+ // releases is only what is still waiting, and a dependant somebody closed
241
+ // ahead of its blocker is reported as such for the write-back.
242
+ const dependants = Array.isArray(ticket?.blocks) ? ticket.blocks : [];
243
+ const dependantState = {};
244
+ for (const dependant of dependants) {
245
+ dependantState[dependant] = (await adapter.find(dependant, options))?.state ?? 'missing';
246
+ }
247
+ const aggregate = beforeCloseRevalidationOf({ ticket: args.ticket, task, state: actual });
248
+ const result = {
249
+ ...aggregate,
250
+ task: { changed: task.changed, from: task.task.from, to: task.task.to },
251
+ state: { expected: 'in-progress', actual },
252
+ dependants,
253
+ dependantState,
254
+ };
255
+ if (runDir) {
256
+ recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
257
+ }
258
+ if (args.json) {
259
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
260
+ } else {
261
+ const detail = result.source.length > 0 ? ` — ${result.source.join(', ')}` : '';
262
+ process.stdout.write(`revalidate BEFORE_CLOSE: ${args.ticket} ${result.action}${detail}\n`);
263
+ if (result.action === 'hold') {
264
+ process.stdout.write(' re-read the item before closing it; a late change is not published as Done.\n');
265
+ }
266
+ }
267
+ process.exit(result.action === 'hold' ? 2 : 0);
268
+ }
269
+
270
+ let mergeBase;
271
+ try {
272
+ mergeBase = git(['merge-base', args.base, 'HEAD']).trim();
273
+ } catch (error) {
274
+ refuse(`--base ${args.base} is not a revision this checkout can compare against: ${error.message}`);
275
+ }
276
+
277
+ const tickets = await adapter.listEligible(options);
278
+ const ticket = tickets.find((candidate) => String(candidate.id) === String(args.ticket)) ?? null;
279
+
280
+ const snapshot = runDir ? (readState(runDir).takeUps?.[args.ticket] ?? null) : null;
281
+ // At SELECT a missing snapshot is a first sight only when no earlier run
282
+ // took the item up either — `queue/index.mjs` asks `previousTakeUp` (AR-138);
283
+ // here the question is this run's own take-up, which SELECT wrote — and it
284
+ // becomes the baseline;
285
+ // here it is a comparison that cannot be made — the run never recorded a
286
+ // take-up for this item, so `null`, not the SELECT point's `false`.
287
+ const unverifiable = { changed: null, task: { from: snapshot, to: ticket?.updatedAt ?? null } };
288
+ const task = ticket && snapshot !== null ? revalidationOf({ ticket, snapshot }) : unverifiable;
289
+
290
+ const branchPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, 'HEAD']));
291
+ const mainPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, args.base]));
292
+ const cited = [...new Set([...branchPaths, ...citedByPremises(runDir)])];
293
+ const mainChanged = mainPaths.filter((path) => cited.includes(path));
294
+
295
+ const aggregate = beforePrRevalidationOf({ ticket: args.ticket, task, mainChanged });
296
+ const result = {
297
+ ...aggregate,
298
+ task: { changed: task.changed, from: task.task.from, to: task.task.to },
299
+ main: { base: args.base, mergeBase, cited, changed: mainChanged },
300
+ };
301
+
302
+ if (runDir) {
303
+ recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
304
+ }
305
+
306
+ if (args.json) {
307
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
308
+ } else {
309
+ const detail = result.source.length > 0 ? ` — ${result.source.join(', ')}` : '';
310
+ process.stdout.write(`revalidate BEFORE_PR: ${args.ticket} ${result.action}${detail}\n`);
311
+ if (result.action === 'hold') {
312
+ process.stdout.write(' re-read the item and the default branch before opening or updating the PR.\n');
313
+ }
314
+ }
315
+ process.exit(result.action === 'hold' ? 2 : 0);
316
+ }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Revalidation Experiment's report — what the evidence log says, over the
4
+ * run directories of this rig since a date (AR-136).
5
+ *
6
+ * node .claude/scripts/revalidation-report.mjs --since <ISO date> [--runs <dir>] [--json]
7
+ *
8
+ * It reads `<runs>/<run-id>/` — `--runs <dir>`, or by default `.claude/runs/`
9
+ * under the MAIN checkout, resolved through `queue/checkout.mjs` so a report
10
+ * run from a linked worktree reads the runs the loop actually declared
11
+ * (revalidation-evidence.test.ts › "reads the main checkout's runs by default,
12
+ * even from a linked worktree") — through `run-journal.mjs` › readRun, the same
13
+ * reader every gate uses, with the same refusal: a journal whose sequence is
14
+ * broken is not read. Such a run is COUNTED under `skipped`, with the reason,
15
+ * never dropped silently; a report that quietly narrowed its own base would be
16
+ * the kind of number the journal README says will be believed.
17
+ *
18
+ * Per point (SELECT, BEFORE_PR, BEFORE_CLOSE), over `revalidation` events at
19
+ * or after `since`:
20
+ * opportunities — every event; catches — `changed: true`;
21
+ * unverifiable — `changed: null`;
22
+ * actionChanged — catches whose `revalidation-outcome` says true;
23
+ * falseHolds — catches whose outcome says false;
24
+ * unresolved — catches with no outcome at all (the run skipped the re-read).
25
+ * An outcome answers the revalidation whose seq its `answers` names, in the
26
+ * SAME run — an outcome cannot reach across runs. `noise` counts the sources
27
+ * behind the false holds, which is where the mechanism's cost is.
28
+ *
29
+ * The primary metric is `actionChanged`, not `opportunities` or `catches`: a
30
+ * hold that changed nothing is noise, and the report says so by name.
31
+ */
32
+
33
+ import { readdirSync, realpathSync, statSync } from 'node:fs';
34
+ import { dirname, join } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { mainCheckoutRoot } from './queue/checkout.mjs';
37
+ import { readRun } from './run-journal.mjs';
38
+ import { POINTS } from './lib/revalidation-points.mjs';
39
+
40
+ export { POINTS };
41
+
42
+ const emptyCounts = () => ({
43
+ opportunities: 0,
44
+ catches: 0,
45
+ unverifiable: 0,
46
+ actionChanged: 0,
47
+ falseHolds: 0,
48
+ unresolved: 0,
49
+ });
50
+
51
+ /** The report over already-read runs — pure, so the counting is testable alone. */
52
+ export const reportOf = ({ runs, since }) => {
53
+ const sinceMs = Date.parse(since);
54
+ const points = Object.fromEntries(POINTS.map((point) => [point, emptyCounts()]));
55
+ const noise = {};
56
+ const read = [];
57
+ const skipped = [];
58
+ for (const { run, events, error } of runs) {
59
+ if (error) {
60
+ skipped.push({ run, why: error });
61
+ continue;
62
+ }
63
+ read.push(run);
64
+ const outcomes = new Map();
65
+ for (const event of events) {
66
+ if (event.kind === 'revalidation-outcome' && Number.isInteger(event.data?.answers)) {
67
+ outcomes.set(event.data.answers, event.data);
68
+ }
69
+ }
70
+ for (const event of events) {
71
+ if (event.kind !== 'revalidation') continue;
72
+ // Written as "inside the window", so an `at` that does not parse falls
73
+ // OUT — the NaN comparison would otherwise count it in.
74
+ if (!(Date.parse(event.at) >= sinceMs)) continue;
75
+ const bucket = points[event.data?.point];
76
+ if (!bucket) continue;
77
+ bucket.opportunities += 1;
78
+ if (event.data.changed === null) bucket.unverifiable += 1;
79
+ if (event.data.changed !== true) continue;
80
+ bucket.catches += 1;
81
+ const outcome = outcomes.get(event.seq);
82
+ if (!outcome) bucket.unresolved += 1;
83
+ else if (outcome.actionChanged === true) bucket.actionChanged += 1;
84
+ else {
85
+ bucket.falseHolds += 1;
86
+ for (const source of Array.isArray(event.data.source) ? event.data.source : []) {
87
+ noise[source] = (noise[source] ?? 0) + 1;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ const totals = emptyCounts();
93
+ for (const counts of Object.values(points)) {
94
+ for (const key of Object.keys(totals)) totals[key] += counts[key];
95
+ }
96
+ return { since, points, noise, totals, runs: { read: read.length, skipped } };
97
+ };
98
+
99
+ /** Every run directory under `runsDir`, read or refused — never both, never neither. */
100
+ export const readRuns = (runsDir) => {
101
+ let names;
102
+ try {
103
+ names = readdirSync(runsDir).filter((name) => statSync(join(runsDir, name)).isDirectory());
104
+ } catch (error) {
105
+ throw new Error(`cannot list runs in ${runsDir}: ${error.message}`, { cause: error });
106
+ }
107
+ return names.sort().map((run) => {
108
+ try {
109
+ const { events } = readRun({ runDir: join(runsDir, run) });
110
+ return { run, events };
111
+ } catch (error) {
112
+ return { run, error: String(error?.message ?? error) };
113
+ }
114
+ });
115
+ };
116
+
117
+ export const render = (report) => {
118
+ const line = (name, c) =>
119
+ `${name}: ${c.opportunities} opportunities, ${c.catches} catches, ${c.actionChanged} actionChanged, ` +
120
+ `${c.falseHolds} falseHolds, ${c.unresolved} unresolved, ${c.unverifiable} unverifiable`;
121
+ const lines = [`revalidation since ${report.since}`];
122
+ for (const point of POINTS) lines.push(line(point, report.points[point]));
123
+ lines.push(line('totals', report.totals));
124
+ const noise = Object.entries(report.noise);
125
+ lines.push(
126
+ noise.length === 0
127
+ ? 'noise: none'
128
+ : `noise: ${noise.map(([source, count]) => `${source} ×${count}`).join(', ')}`,
129
+ );
130
+ lines.push(`runs: ${report.runs.read} read, ${report.runs.skipped.length} skipped`);
131
+ for (const { run, why } of report.runs.skipped) lines.push(` skipped ${run} — ${why}`);
132
+ return `${lines.join('\n')}\n`;
133
+ };
134
+
135
+ const parseArgs = (argv) => {
136
+ const args = { since: null, runs: null, json: false, bad: null };
137
+ for (let i = 0; i < argv.length; i += 1) {
138
+ const arg = argv[i];
139
+ if (arg === '--json') args.json = true;
140
+ else if (arg === '--since') args.since = argv[++i] ?? null;
141
+ else if (arg === '--runs') args.runs = argv[++i] ?? null;
142
+ else if (args.bad === null) args.bad = arg;
143
+ }
144
+ return args;
145
+ };
146
+
147
+ const invokedDirectly = () => {
148
+ if (!process.argv[1]) return false;
149
+ const real = (p) => {
150
+ try {
151
+ return realpathSync(p);
152
+ } catch {
153
+ return p;
154
+ }
155
+ };
156
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
157
+ };
158
+
159
+ if (invokedDirectly()) {
160
+ const args = parseArgs(process.argv.slice(2));
161
+ const refuse = (message) => {
162
+ process.stderr.write(`${message}\n`);
163
+ process.exit(1);
164
+ };
165
+ if (args.bad !== null) refuse(`unrecognised argument: ${args.bad}`);
166
+ if (!args.since || Number.isNaN(Date.parse(args.since))) {
167
+ refuse(`--since needs an ISO date (got ${args.since ?? '(none)'}); a report with no window reports nothing honest.`);
168
+ }
169
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
170
+ const runsDir =
171
+ args.runs ?? join(mainCheckoutRoot(join(scriptsDir, '..', '..')), '.claude', 'runs');
172
+ let runs;
173
+ try {
174
+ runs = readRuns(runsDir);
175
+ } catch (error) {
176
+ refuse(error.message);
177
+ }
178
+ const report = reportOf({ runs, since: new Date(args.since).toISOString() });
179
+ process.stdout.write(args.json ? `${JSON.stringify(report, null, 2)}\n` : render(report));
180
+ }