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
@@ -14,8 +14,9 @@
14
14
  // be a snapshot that nothing updates when the blocker lands; this cannot go stale,
15
15
  // because it is re-resolved from the blockers themselves on every selection.
16
16
  import { execFileSync } from 'node:child_process';
17
- import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
18
- import { recordEscalation } from '../run-state.mjs';
17
+ import { duplicateOf, fingerprintOf, lifecycleOf, ownerOfLabels, validateProposal } from './core.mjs';
18
+ import { withAsOf } from './as-of.mjs';
19
+ import { recordEscalation, recordTakeUp } from '../run-state.mjs';
19
20
 
20
21
  export const name = 'github-issues';
21
22
 
@@ -75,6 +76,11 @@ export const toTicket = (issue, states = {}) => {
75
76
  blocks: [],
76
77
  priority: priorityLabel ? Number(priorityLabel[1]) : 999,
77
78
  createdAt: issue.createdAt ?? null,
79
+ // The take-up marker for revalidation at SELECT: the tracker's own
80
+ // last-modified field, whose contract (moves on edits, comments and state
81
+ // changes) is assumed and not checked here. `null` when the listing did not
82
+ // carry it.
83
+ updatedAt: issue.updatedAt ?? null,
78
84
  // The body travels on the neutral shape so the hygiene checks live in one
79
85
  // place (core.mjs) instead of once per adapter. This adapter also parses it
80
86
  // internally for blocker links — the two readings are independent on
@@ -87,6 +93,10 @@ export const toTicket = (issue, states = {}) => {
87
93
  : labels.includes('trigger-human')
88
94
  ? 'human'
89
95
  : null,
96
+ // The repository this item belongs to (AR-132): `owner-<name>`, or null.
97
+ owner: ownerOfLabels(labels),
98
+ // The lifecycle and the scheduling flag (AR-144), read above the seam.
99
+ ...lifecycleOf(labels),
90
100
  };
91
101
  };
92
102
 
@@ -120,7 +130,7 @@ const ghText = (args) =>
120
130
 
121
131
  const ghJson = (args) => JSON.parse(ghText(args));
122
132
 
123
- const FIELDS = 'number,title,body,state,labels,url,createdAt';
133
+ const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt';
124
134
 
125
135
  // --- the adapter contract ------------------------------------------------------
126
136
 
@@ -146,21 +156,63 @@ export const listEligible = ({ limit = 100, issues = null } = {}) => {
146
156
  export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
147
157
 
148
158
  /** `To Do → In Progress` before the first file is edited, not when the PR opens. */
159
+ /**
160
+ * Re-record the item's marker after a write of this adapter's own (AR-140) —
161
+ * the same rule and the same limit as `jira.mjs` › rebaseline: only writes
162
+ * made through this adapter re-baseline, and a read-back that fails is
163
+ * announced, never thrown.
164
+ */
165
+ const rebaseline = (ticket) => {
166
+ const runDir = process.env.RIG_RUN_DIR;
167
+ if (!runDir) return;
168
+ try {
169
+ const after = ghJson(['issue', 'view', ticket.id, '--json', 'updatedAt']);
170
+ recordTakeUp(runDir, { id: ticket.id, updatedAt: after?.updatedAt ?? null });
171
+ } catch (error) {
172
+ process.stderr.write(
173
+ `#${ticket.id}: the write landed, but its marker was NOT re-recorded in ${runDir} — ` +
174
+ `${error.message}\n`,
175
+ );
176
+ }
177
+ };
178
+
149
179
  export const claim = (ticket) => {
150
180
  ghText(['issue', 'edit', ticket.id, '--add-label', 'in-progress']);
181
+ rebaseline(ticket);
151
182
  return { ok: true };
152
183
  };
153
184
 
185
+ /**
186
+ * One issue by number, closed included — `gh issue view` sees every state,
187
+ * where `listEligible` drops CLOSED for selection's sake. The offline `issues`
188
+ * seam is honoured. `blocks` is empty here: the cross-index needs the whole
189
+ * list, and a single view does not carry it.
190
+ */
191
+ export const find = (id, { issues = null } = {}) => {
192
+ const raw = issues
193
+ ? (issues.find((issue) => String(issue.number) === String(id)) ?? null)
194
+ : ghJson(['issue', 'view', String(id), '--json', FIELDS]);
195
+ return raw ? toTicket(raw, {}) : null;
196
+ };
197
+
154
198
  export const close = (ticket, { prUrl = null } = {}) => {
155
199
  const note = prUrl ? `Landed in ${prUrl}.` : 'Closed by the run.';
156
200
  ghText(['issue', 'comment', ticket.id, '--body', note]);
157
201
  ghText(['issue', 'close', ticket.id]);
158
- ghText(['issue', 'edit', ticket.id, '--remove-label', 'in-progress']);
159
- return { ok: true };
202
+ // Read back, never inferred: `gh issue close` exits 0 on an issue that was
203
+ // already closed, or that a workflow reopened a moment later (AR-135). The
204
+ // claim label goes only once the read-back says CLOSED — a close that did
205
+ // not land leaves the item claimed, exactly as it was found.
206
+ const after = ghJson(['issue', 'view', ticket.id, '--json', 'state']);
207
+ const transitioned = String(after?.state ?? '').toUpperCase() === 'CLOSED';
208
+ if (transitioned) ghText(['issue', 'edit', ticket.id, '--remove-label', 'in-progress']);
209
+ rebaseline(ticket);
210
+ return { ok: true, transitioned };
160
211
  };
161
212
 
162
213
  export const comment = (ticket, body) => {
163
214
  ghText(['issue', 'comment', ticket.id, '--body', body]);
215
+ rebaseline(ticket);
164
216
  return { ok: true };
165
217
  };
166
218
 
@@ -172,6 +224,7 @@ export const comment = (ticket, body) => {
172
224
  export const escalate = (ticket, diagnosis, { env = process.env } = {}) => {
173
225
  ghText(['issue', 'comment', ticket.id, '--body', diagnosis]);
174
226
  ghText(['issue', 'edit', ticket.id, '--add-label', 'escalated']);
227
+ rebaseline(ticket);
175
228
  // Counted through the one recorder, never a counter of this adapter's own —
176
229
  // "twice in a row" has to mean the same thing on every tracker.
177
230
  recordEscalation(env.RIG_RUN_DIR);
@@ -196,8 +249,10 @@ export const triageItemFor = (proposal) => {
196
249
  `- **part to change** — ${proposal.part}`,
197
250
  `- **proposed change** — ${proposal.change}`,
198
251
  `- **how the next run proves it** — ${proposal.proof}`,
252
+ ...(proposal.measured ? [`- **measured** — ${proposal.measured}`, `- **inferred** — ${proposal.inferred}`] : []),
199
253
  '',
200
254
  `fingerprint: ${fingerprint}`,
255
+ ...(proposal.asOf ? [`asOf: ${proposal.asOf}`] : []),
201
256
  '',
202
257
  'The loop proposes; the owner patches. Self-applying a change to its own',
203
258
  'rulebook is how an unattended run drifts irreversibly — and it collides with',
@@ -216,15 +271,17 @@ export const triageItemFor = (proposal) => {
216
271
  * hand out nothing — "queue empty" and "nothing selectable";
217
272
  * twenty such stops must produce one proposal with a count of twenty.
218
273
  */
219
- export const proposeTriage = (proposal, { existing = null } = {}) => {
220
- const item = triageItemFor(proposal);
221
- const found =
274
+ /** The proposals on file, as `{ id, body }` — every `triage`-labelled issue. */
275
+ export const listProposals = ({ existing = null } = {}) =>
276
+ (
222
277
  existing ??
223
- ghJson(['issue', 'list', '--label', 'triage', '--state', 'all', '--limit', '100', '--json', FIELDS]);
224
- const duplicate = duplicateOf(
225
- item,
226
- found.map((issue) => ({ id: String(issue.number), body: issue.body })),
227
- );
278
+ ghJson(['issue', 'list', '--label', 'triage', '--state', 'all', '--limit', '100', '--json', FIELDS])
279
+ ).map((issue) => ({ id: String(issue.number), body: issue.body }));
280
+
281
+ export const proposeTriage = (rawProposal, { existing = null } = {}) => {
282
+ const proposal = withAsOf(rawProposal);
283
+ const item = triageItemFor(proposal);
284
+ const duplicate = duplicateOf(item, listProposals({ existing }));
228
285
 
229
286
  if (duplicate) {
230
287
  ghText([
@@ -237,6 +294,23 @@ export const proposeTriage = (proposal, { existing = null } = {}) => {
237
294
  return { ok: true, incremented: String(duplicate.number), item };
238
295
  }
239
296
 
240
- ghText(['issue', 'create', '--title', item.title, '--body', item.body, '--label', 'triage']);
241
- return { ok: true, filed: item.title, item };
297
+ const url = ghText(['issue', 'create', '--title', item.title, '--body', item.body, '--label', 'triage']);
298
+ // The proposal's own baseline (AR-138), as in jira.mjs: `gh issue create`
299
+ // prints the new issue's URL, whose last segment is its number; the marker
300
+ // is read back with `gh issue view`. No run directory → nothing recorded.
301
+ // Best-effort for the same reason as there: the issue exists by now, and a
302
+ // throw would make the caller file it again.
303
+ const id = /\/(\d+)\s*$/.exec(String(url ?? ''))?.[1] ?? null;
304
+ if (id && process.env.RIG_RUN_DIR) {
305
+ try {
306
+ const after = ghJson(['issue', 'view', id, '--json', 'updatedAt']);
307
+ recordTakeUp(process.env.RIG_RUN_DIR, { id, updatedAt: after?.updatedAt ?? null });
308
+ } catch (error) {
309
+ process.stderr.write(
310
+ `proposeTriage: #${id} is filed, but its baseline was NOT recorded in ` +
311
+ `${process.env.RIG_RUN_DIR} — ${error.message}\n`,
312
+ );
313
+ }
314
+ }
315
+ return { ok: true, filed: item.title, id, item };
242
316
  };
@@ -4,7 +4,7 @@
4
4
  // node .claude/scripts/queue/index.mjs next # the item to take, and why
5
5
  // node .claude/scripts/queue/index.mjs next --json
6
6
  // node .claude/scripts/queue/index.mjs list # every item, with skip reasons
7
- // node .claude/scripts/queue/index.mjs hygiene # stale labels and link anomalies
7
+ // node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies, overtaken proposals
8
8
  // node .claude/scripts/queue/index.mjs gate-round --branch <b> # count a gate round
9
9
  //
10
10
  // The adapter comes from `.claude/queue.json` (`{"adapter": "plan-md"}`) and
@@ -14,12 +14,21 @@
14
14
  import { readFileSync, realpathSync } from 'node:fs';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { basename, dirname, join } from 'node:path';
17
- import { hygieneOf, selectNext, stopConditionOf } from './core.mjs';
17
+ import {
18
+ asOfOf,
19
+ citedPathsOf,
20
+ hygieneOf,
21
+ overtakenOf,
22
+ revalidationOf,
23
+ selectNext,
24
+ stopConditionOf,
25
+ } from './core.mjs';
26
+ import { changedSinceOf, headShaOf } from './as-of.mjs';
18
27
  // One resolver, imported rather than re-derived: writer and reader disagreeing
19
28
  // about which checkout they are in is the whole of the worktree defect. It lives
20
29
  // apart from `state.mjs` so the read path does not drag the tier computation —
21
30
  // and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
22
- import { mainCheckoutRoot } from './checkout.mjs';
31
+ import { checkoutIsShippable, mainCheckoutRoot } from './checkout.mjs';
23
32
 
24
33
  const ADAPTERS = {
25
34
  'plan-md': './plan-md.mjs',
@@ -222,12 +231,20 @@ const parseArgs = (argv) => {
222
231
  return args;
223
232
  };
224
233
 
225
- const renderNext = (result, stop) => {
234
+ const renderNext = (result, stop, revalidation = null) => {
226
235
  if (stop) {
227
236
  const label = stop.kind.replaceAll('-', ' ');
228
237
  return `queue: ${label}${stop.success ? '' : ' (needs attention)'}\n ${stop.why}\n`;
229
238
  }
230
239
  const lines = [`next: ${result.ticket.id} — ${result.ticket.title} [${result.ticket.tier}]`];
240
+ // Only a marker that MOVED earns a line: the unchanged case stays quiet and
241
+ // cheap, and the no-marker case is in the JSON and the event log, not here.
242
+ if (revalidation?.changed === true) {
243
+ lines.push(
244
+ `revalidate: ${revalidation.ticket} hold — ${revalidation.source.join(', ')} ` +
245
+ `(${revalidation.task.from} → ${revalidation.task.to}) — re-read the item before acting`,
246
+ );
247
+ }
231
248
  if (result.skipped.length > 0) {
232
249
  lines.push('', 'skipped:');
233
250
  for (const skip of result.skipped) lines.push(` ${skip.id} — ${skip.reason}`);
@@ -307,6 +324,15 @@ if (invokedDirectly()) {
307
324
  const verdictFor = (rounds) => gateRoundVerdict(rounds, config.options?.maxGateRounds);
308
325
  verdictFor(0);
309
326
 
327
+ // 🔴 And refuse a checkout that cannot ship BEFORE counting (AR-141): a
328
+ // dirty tree or an unpushed HEAD means the round — and the fan-out's
329
+ // verdicts — would name a head that never reaches CI. Two rounds were once
330
+ // counted ahead of a commit pre-commit then refused. Thrown, so it lands
331
+ // in the exit-1 path below: not an exhausted cap, nothing counted.
332
+ const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
333
+ const shippable = checkoutIsShippable(gitRoot);
334
+ if (!shippable.ok) throw new Error(`${shippable.why} (nothing was counted)`);
335
+
310
336
  const { rounds } = recordGateRound({ branch: args.branch, roundsPath });
311
337
  const verdict = verdictFor(rounds);
312
338
 
@@ -325,9 +351,9 @@ if (invokedDirectly()) {
325
351
  `GATE ROUNDS EXHAUSTED — ${verdict.rounds} rounds on ${args.branch}, cap is ` +
326
352
  `${verdict.max}: ${verdict.stop}.\n` +
327
353
  ' Do not run another round. The item stops here and goes back to a human ' +
328
- 'with the round count and whatever the last gate reported — the fixes are ' +
329
- 'not converging, and another pass buys a full reviewer fan-out to discover ' +
330
- 'that again.\n' +
354
+ 'with the round count and whatever the last gate reported. Whether those ' +
355
+ 'rounds were paying off is the reader\'s judgement — this command measured ' +
356
+ 'only the count (AR-115).\n' +
331
357
  ' Raising the cap to get one more pass on THIS item is the move this ' +
332
358
  'refusal exists to prevent.\n',
333
359
  );
@@ -336,12 +362,12 @@ if (invokedDirectly()) {
336
362
  // 🔴 Exit 1, never 2, and the message says so. Exit 2 means one thing only —
337
363
  // the rounds are spent — because `pr-ship` acts on it by ending the task. A
338
364
  // broken config, an unreadable counter or a detached checkout must not be
339
- // read as a converging-failure stall.
365
+ // read as a spent cap.
340
366
  process.stderr.write(
341
367
  `gate-round could not run: ${error.message}\n` +
342
368
  ' This is NOT an exhausted cap (that is exit 2). Fix the cause and run step ' +
343
- '0 again: a bad config, a bad cap or a detached checkout is refused before ' +
344
- 'any round is counted, so retrying costs nothing.\n',
369
+ '0 again: a bad config, a bad cap, a detached checkout, a dirty tree or an ' +
370
+ 'unpushed HEAD is refused before any round is counted, so retrying costs nothing.\n',
345
371
  );
346
372
  process.exit(1);
347
373
  }
@@ -366,8 +392,10 @@ if (invokedDirectly()) {
366
392
  // and must keep working exactly as before.
367
393
  let readState;
368
394
  let stopInputsOf;
395
+ let recordTakeUp;
396
+ let previousTakeUp;
369
397
  try {
370
- ({ readState, stopInputsOf } = await import('../run-state.mjs'));
398
+ ({ readState, stopInputsOf, recordTakeUp, previousTakeUp } = await import('../run-state.mjs'));
371
399
  } catch (error) {
372
400
  process.stderr.write(
373
401
  `run state: ${error.message}\n` +
@@ -470,12 +498,43 @@ if (invokedDirectly()) {
470
498
  }
471
499
 
472
500
  if (args.command === 'hygiene') {
473
- const findings = tickets.map(hygieneOf).filter(Boolean);
501
+ // The proposals on file are checked too (AR-116): a proposal names the commit
502
+ // it was measured against, and one whose cited paths moved since is reported
503
+ // as possibly overtaken. Git runs here, once per distinct `asOf`, against the
504
+ // project this script belongs to; `core.mjs` only decides.
505
+ const proposals = await adapter.listProposals(optionsWithPlanPath(config.options, configPath));
506
+ // The checkout the proposals describe is the project the config names — an
507
+ // explicit `--config <root>/.claude/queue.json` points at that root — and
508
+ // this script's own project only when the config implies none.
509
+ const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
510
+ const head = headShaOf({ cwd: gitRoot });
511
+ const changedByAsOf = new Map();
512
+ const overtaken = proposals
513
+ .map((proposal) => {
514
+ const asOf = asOfOf(proposal.body);
515
+ if (asOf && !changedByAsOf.has(asOf)) {
516
+ changedByAsOf.set(asOf, changedSinceOf({ cwd: gitRoot, asOf, head: head ?? 'HEAD' }));
517
+ }
518
+ return overtakenOf({
519
+ id: proposal.id,
520
+ asOf,
521
+ citedPaths: citedPathsOf(proposal.body),
522
+ head,
523
+ changedSince: asOf ? changedByAsOf.get(asOf) : null,
524
+ });
525
+ })
526
+ .filter(Boolean);
527
+ const owner = config.options?.owner ?? null;
528
+ const findings = [
529
+ ...tickets.map((ticket) => hygieneOf(ticket, { owner })).filter(Boolean),
530
+ ...overtaken,
531
+ ];
474
532
  process.stdout.write(
475
533
  args.json
476
534
  ? `${JSON.stringify({ findings }, null, 2)}\n`
477
535
  : findings.length === 0
478
- ? `queue hygiene: ${tickets.length} item(s) checked — nothing stale.\n`
536
+ ? `queue hygiene: ${tickets.length} item(s) and ${proposals.length} proposal(s) ` +
537
+ 'checked — nothing stale.\n'
479
538
  : `${findings.map((f) => ` [${f.kind}] ${f.id} — ${f.why}`).join('\n')}\n`,
480
539
  );
481
540
  process.exit(0);
@@ -501,6 +560,9 @@ if (invokedDirectly()) {
501
560
  // impossible to retract, so "not this time" would again require editing the
502
561
  // generated file this move exists to get out of.
503
562
  triggersFired: runState.triggersFired ?? config.triggersFired ?? null,
563
+ // This checkout's name, for the owner marker (AR-132). Absent means the
564
+ // checkout cannot confirm a match, and an owned item is held.
565
+ owner: config.options?.owner ?? null,
504
566
  });
505
567
  // The skipped records travel with the count: without them "nothing left" and
506
568
  // "everything left is held back" both print as an empty queue, and only one of
@@ -524,6 +586,31 @@ if (invokedDirectly()) {
524
586
  // a default derived from `projectRoot` would land the trace inside the very
525
587
  // template tree this repository publishes.
526
588
  const runDir = process.env.RIG_RUN_DIR;
589
+ // Revalidation at SELECT (AR-133): the selected item against the marker this
590
+ // run recorded at its last take-up. Computed only under a declared run —
591
+ // there is no snapshot to compare against anywhere else — and `null` in the
592
+ // output then, so a reader can tell "not compared" from "compared, unchanged".
593
+ //
594
+ // The baseline is this run's take-up when it has one; otherwise the newest
595
+ // earlier run's (AR-138) — an item taken up yesterday and re-offered today
596
+ // used to compare against nothing and report a first sight. `baseline` says
597
+ // which it was (`this-run` | `previous-run` | null), and `baselineRun` names
598
+ // the earlier run, so the report can tell the three apart. A rig whose
599
+ // run-state module predates `previousTakeUp` keeps the per-run behaviour.
600
+ let revalidation = null;
601
+ if (runDir && result.ticket) {
602
+ const own = runState.takeUps?.[result.ticket.id];
603
+ const prior =
604
+ own === undefined && typeof previousTakeUp === 'function'
605
+ ? previousTakeUp(runDir, result.ticket.id)
606
+ : null;
607
+ const snapshot = own ?? prior?.updatedAt ?? null;
608
+ revalidation = {
609
+ ...revalidationOf({ ticket: result.ticket, snapshot }),
610
+ baseline: own !== undefined ? 'this-run' : prior ? 'previous-run' : null,
611
+ ...(prior ? { baselineRun: prior.runDir } : {}),
612
+ };
613
+ }
527
614
  if (runDir) {
528
615
  let journal = null;
529
616
  try {
@@ -537,6 +624,27 @@ if (invokedDirectly()) {
537
624
  // an argument, which is what keeps its records reproducible.
538
625
  now: new Date().toISOString(),
539
626
  });
627
+ if (revalidation) {
628
+ // The selection decision keeps its place as the run's first record; the
629
+ // evidence log comes next, and only then (below, after the journal) does
630
+ // the baseline move — a snapshot written before its event would leave a
631
+ // crashed run with a marker and no record of what it was compared against.
632
+ if (typeof journal.recordEvent === 'function') {
633
+ journal.recordEvent({
634
+ runDir,
635
+ kind: 'revalidation',
636
+ data: revalidation,
637
+ now: new Date().toISOString(),
638
+ });
639
+ } else {
640
+ // A rig carrying a run journal older than this CLI: the selection
641
+ // stands, the comparison was made, only its record has nowhere to go.
642
+ process.stderr.write(
643
+ 'run journal: this journal predates revalidation events, so the ' +
644
+ 'revalidation was not recorded.\n',
645
+ );
646
+ }
647
+ }
540
648
  } catch (error) {
541
649
  // 🔴 Two failures wearing one face, and treating them alike was a defect
542
650
  // this gate caught. The journal asks the module which one this is — never
@@ -572,9 +680,24 @@ if (invokedDirectly()) {
572
680
  }
573
681
  }
574
682
 
683
+ if (revalidation) {
684
+ // Its own try, after the journal's: a state file that cannot be written is
685
+ // not the journal failing, and the selection stands either way — the
686
+ // comparison was made and recorded; only the next baseline is lost.
687
+ try {
688
+ recordTakeUp(runDir, { id: result.ticket.id, updatedAt: result.ticket.updatedAt });
689
+ } catch (error) {
690
+ process.stderr.write(
691
+ `run state: the take-up snapshot was NOT recorded in ${runDir} — ${error.message}\n` +
692
+ ' the selection below stands; the baseline was not moved — the next revalidation ' +
693
+ 'of this item compares against the previous one, if any, or has none.\n',
694
+ );
695
+ }
696
+ }
697
+
575
698
  process.stdout.write(
576
699
  args.json
577
- ? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop }, null, 2)}\n`
578
- : renderNext(result, stop),
700
+ ? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop, revalidation }, null, 2)}\n`
701
+ : renderNext(result, stop, revalidation),
579
702
  );
580
703
  }