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,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,22 +4,32 @@
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
+ // node .claude/scripts/queue/index.mjs board [<name>] # the active board; or switch this checkout to <name>
9
10
  //
10
11
  // The adapter comes from `.claude/queue.json` (`{"adapter": "plan-md"}`) and
11
12
  // defaults to `plan-md`, which is the only adapter that works in a freshly
12
13
  // generated project. An unknown adapter is a hard error, never a fallback: a loop
13
14
  // that silently reads the wrong queue is worse than one that refuses to start.
14
- import { readFileSync, realpathSync } from 'node:fs';
15
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
15
16
  import { fileURLToPath } from 'node:url';
16
17
  import { basename, dirname, join } from 'node:path';
17
- import { hygieneOf, selectNext, stopConditionOf } from './core.mjs';
18
+ import {
19
+ asOfOf,
20
+ citedPathsOf,
21
+ hygieneOf,
22
+ overtakenOf,
23
+ revalidationOf,
24
+ selectNext,
25
+ stopConditionOf,
26
+ } from './core.mjs';
27
+ import { changedSinceOf, headShaOf } from './as-of.mjs';
18
28
  // One resolver, imported rather than re-derived: writer and reader disagreeing
19
29
  // about which checkout they are in is the whole of the worktree defect. It lives
20
30
  // apart from `state.mjs` so the read path does not drag the tier computation —
21
31
  // and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
22
- import { mainCheckoutRoot } from './checkout.mjs';
32
+ import { checkoutIsShippable, mainCheckoutRoot } from './checkout.mjs';
23
33
 
24
34
  const ADAPTERS = {
25
35
  'plan-md': './plan-md.mjs',
@@ -39,7 +49,7 @@ export const resolveAdapter = async (adapterName) => {
39
49
  return import(new URL(modulePath, import.meta.url).href);
40
50
  };
41
51
 
42
- export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round'];
52
+ export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round', 'board'];
43
53
 
44
54
  /**
45
55
  * A missing config is the normal state of a fresh project. A config that exists
@@ -54,8 +64,9 @@ export const loadConfig = (configPath) => {
54
64
  } catch {
55
65
  return {};
56
66
  }
67
+ let parsed;
57
68
  try {
58
- return JSON.parse(raw);
69
+ parsed = JSON.parse(raw);
59
70
  } catch (error) {
60
71
  throw new Error(
61
72
  `${configPath} exists but is not valid JSON, so the configured queue cannot be ` +
@@ -64,6 +75,83 @@ export const loadConfig = (configPath) => {
64
75
  { cause: error },
65
76
  );
66
77
  }
78
+ return resolveBoard(parsed, configPath);
79
+ };
80
+
81
+ /**
82
+ * The selector that travels with a config: `<name>.json` → `<name>.board`.
83
+ *
84
+ * A plain-text file holding one board name. Same class as the state file — a
85
+ * per-checkout runtime value that must never be committed, because the config it
86
+ * sits beside is composed and tracked. Derived from the config path for the same
87
+ * reason `statePathFor` is: a run pointed at a temp config must not switch on
88
+ * this checkout's real selector.
89
+ */
90
+ export const boardPathFor = (configPath) => configPath.replace(/(\.json)?$/, '.board');
91
+
92
+ const isTerminalControl = (char) => {
93
+ const code = char.codePointAt(0);
94
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
95
+ };
96
+
97
+ const assertSafeBoardName = (name, source) => {
98
+ if (typeof name === 'string' && [...name].some(isTerminalControl)) {
99
+ throw new Error(`${source}: board names must not contain terminal control characters.`);
100
+ }
101
+ };
102
+
103
+ const boardNamesOf = (boards, configPath) => {
104
+ const names = Object.keys(boards);
105
+ for (const name of names) assertSafeBoardName(name, configPath);
106
+ return names;
107
+ };
108
+
109
+ /**
110
+ * A config may declare several boards and one default:
111
+ *
112
+ * { "adapter": "jira", "board": "AR",
113
+ * "boards": { "AR": { "project": "AR", "owner": "x" }, "RP": { … } },
114
+ * "options": { "maxGateRounds": 3 } }
115
+ *
116
+ * The active board is the selector file if present, else `board`; its entry is
117
+ * laid over `options`, so a key every board shares stays in `options` and only
118
+ * what differs is per board. A config with no `boards` is returned exactly as it
119
+ * was. A name nobody declared — in the selector or as the default — is refused,
120
+ * never read as "no board": the loop would otherwise run on the shared options
121
+ * alone, and for `jira` that is a different (or no) project.
122
+ */
123
+ export const resolveBoard = (config, configPath) => {
124
+ if (config?.boards === undefined) return config;
125
+ const boards = config.boards;
126
+ if (boards === null || typeof boards !== 'object' || Array.isArray(boards)) {
127
+ throw new Error(`${configPath}: "boards" must be an object of <name> → options.`);
128
+ }
129
+ const known = boardNamesOf(boards, configPath);
130
+ let selected = null;
131
+ let source = 'the "board" key';
132
+ try {
133
+ selected = readFileSync(boardPathFor(configPath), 'utf8').trim();
134
+ source = boardPathFor(configPath);
135
+ } catch (error) {
136
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
137
+ }
138
+ // A selector that exists but is empty is refused, not read as "no selector":
139
+ // a truncated write would otherwise switch the run to the default board while
140
+ // the file still looks like a choice somebody made.
141
+ const active = selected === null ? config.board : selected;
142
+ assertSafeBoardName(active, source);
143
+ if (!active || !known.includes(active)) {
144
+ throw new Error(
145
+ `${source} names board ${JSON.stringify(active ?? null)}, which ${configPath} does not ` +
146
+ `declare. Declared boards: ${known.join(', ')}. Refusing rather than running on the ` +
147
+ 'shared options alone — that would be a different queue than the one configured.',
148
+ );
149
+ }
150
+ const entry = boards[active];
151
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
152
+ throw new Error(`${configPath}: boards.${active} must be an object of adapter options.`);
153
+ }
154
+ return { ...config, board: active, options: { ...(config.options ?? {}), ...entry } };
67
155
  };
68
156
 
69
157
  /**
@@ -213,21 +301,30 @@ export const loadState = (statePath) => {
213
301
  };
214
302
 
215
303
  const parseArgs = (argv) => {
216
- const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null };
304
+ const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null, name: null };
217
305
  for (let i = 1; i < argv.length; i += 1) {
218
306
  if (argv[i] === '--json') args.json = true;
219
307
  else if (argv[i] === '--config') args.config = argv[++i];
220
308
  else if (argv[i] === '--branch') args.branch = argv[++i];
309
+ else if (!argv[i].startsWith('--') && args.name === null) args.name = argv[i];
221
310
  }
222
311
  return args;
223
312
  };
224
313
 
225
- const renderNext = (result, stop) => {
314
+ const renderNext = (result, stop, revalidation = null) => {
226
315
  if (stop) {
227
316
  const label = stop.kind.replaceAll('-', ' ');
228
317
  return `queue: ${label}${stop.success ? '' : ' (needs attention)'}\n ${stop.why}\n`;
229
318
  }
230
319
  const lines = [`next: ${result.ticket.id} — ${result.ticket.title} [${result.ticket.tier}]`];
320
+ // Only a marker that MOVED earns a line: the unchanged case stays quiet and
321
+ // cheap, and the no-marker case is in the JSON and the event log, not here.
322
+ if (revalidation?.changed === true) {
323
+ lines.push(
324
+ `revalidate: ${revalidation.ticket} hold — ${revalidation.source.join(', ')} ` +
325
+ `(${revalidation.task.from} → ${revalidation.task.to}) — re-read the item before acting`,
326
+ );
327
+ }
231
328
  if (result.skipped.length > 0) {
232
329
  lines.push('', 'skipped:');
233
330
  for (const skip of result.skipped) lines.push(` ${skip.id} — ${skip.reason}`);
@@ -271,6 +368,66 @@ if (invokedDirectly()) {
271
368
  process.exit(1);
272
369
  }
273
370
 
371
+ // `board` is local too: it reads the config and writes the selector beside it,
372
+ // and never touches the tracker. Switching is refused on a config that declares
373
+ // no boards — the selector would then be a file nothing reads.
374
+ if (args.command === 'board') {
375
+ try {
376
+ // Resolve before deriving either the checkout root or the selector path.
377
+ // Otherwise a symlinked `.claude` lets the caller authorize one checkout
378
+ // while the write follows the link into another unattended checkout.
379
+ const configPath = realpathSync(args.config ?? join(projectRoot, '.claude', 'queue.json'));
380
+ const raw = JSON.parse(readFileSync(configPath, 'utf8'));
381
+ if (raw?.boards === undefined) {
382
+ throw new Error(`${configPath} declares no boards, so there is nothing to switch between.`);
383
+ }
384
+ if (raw.boards === null || typeof raw.boards !== 'object' || Array.isArray(raw.boards)) {
385
+ throw new Error(`${configPath}: "boards" must be an object of <name> → options. Nothing was written.`);
386
+ }
387
+ const boardNames = boardNamesOf(raw.boards, configPath);
388
+ if (args.name !== null) {
389
+ assertSafeBoardName(args.name, 'the requested board');
390
+ if (!boardNames.includes(args.name)) {
391
+ throw new Error(
392
+ `${JSON.stringify(args.name)} is not a declared board. Declared: ` +
393
+ `${boardNames.join(', ')}. Nothing was written.`,
394
+ );
395
+ }
396
+ // Loaded only on the mutation path. `next`, `list` and `hygiene` also
397
+ // run in partial-install diagnostics where this sibling is deliberately
398
+ // absent; a static import would replace their own actionable refusal
399
+ // with ERR_MODULE_NOT_FOUND before the command could start.
400
+ const { readUnattended } = await import('../unattended-flag.mjs');
401
+ const callerRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
402
+ const targetRoot = projectRootOfConfig(configPath) ?? callerRoot;
403
+ const guardedRoots = [...new Set([callerRoot, targetRoot])];
404
+ const unattended = guardedRoots
405
+ .map((root) => ({ root, mode: readUnattended({ ...process.env, CLAUDE_PROJECT_DIR: root }) }))
406
+ .find(({ mode }) => mode.on);
407
+ if (unattended) {
408
+ throw new Error(
409
+ `board switching is refused while checkout ${unattended.root} is unattended. ` +
410
+ 'The read-only `board` report remains available; disarm the loop before re-aiming its queue.',
411
+ );
412
+ }
413
+ writeFileSync(boardPathFor(configPath), `${args.name}\n`);
414
+ }
415
+ const config = loadConfig(configPath);
416
+ const report = { board: config.board, boards: boardNames, options: config.options };
417
+ process.stdout.write(
418
+ args.json
419
+ ? `${JSON.stringify(report)}\n`
420
+ : `board: ${report.board}${args.name !== null ? ' (switched)' : ''}\n` +
421
+ ` declared: ${report.boards.join(', ')}\n` +
422
+ ` selector: ${boardPathFor(configPath)}\n`,
423
+ );
424
+ process.exit(0);
425
+ } catch (error) {
426
+ process.stderr.write(`board could not run: ${error.message}\n`);
427
+ process.exit(1);
428
+ }
429
+ }
430
+
274
431
  // 🔴 **`gate-round` returns before the tracker is ever touched, and that is
275
432
  // load-bearing rather than an optimisation.** `pr-ship` calls this once per
276
433
  // round, and every other command below reaches the adapter — so routing this one
@@ -307,6 +464,15 @@ if (invokedDirectly()) {
307
464
  const verdictFor = (rounds) => gateRoundVerdict(rounds, config.options?.maxGateRounds);
308
465
  verdictFor(0);
309
466
 
467
+ // 🔴 And refuse a checkout that cannot ship BEFORE counting (AR-141): a
468
+ // dirty tree or an unpushed HEAD means the round — and the fan-out's
469
+ // verdicts — would name a head that never reaches CI. Two rounds were once
470
+ // counted ahead of a commit pre-commit then refused. Thrown, so it lands
471
+ // in the exit-1 path below: not an exhausted cap, nothing counted.
472
+ const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
473
+ const shippable = checkoutIsShippable(gitRoot);
474
+ if (!shippable.ok) throw new Error(`${shippable.why} (nothing was counted)`);
475
+
310
476
  const { rounds } = recordGateRound({ branch: args.branch, roundsPath });
311
477
  const verdict = verdictFor(rounds);
312
478
 
@@ -325,9 +491,9 @@ if (invokedDirectly()) {
325
491
  `GATE ROUNDS EXHAUSTED — ${verdict.rounds} rounds on ${args.branch}, cap is ` +
326
492
  `${verdict.max}: ${verdict.stop}.\n` +
327
493
  ' 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' +
494
+ 'with the round count and whatever the last gate reported. Whether those ' +
495
+ 'rounds were paying off is the reader\'s judgement this command measured ' +
496
+ 'only the count (AR-115).\n' +
331
497
  ' Raising the cap to get one more pass on THIS item is the move this ' +
332
498
  'refusal exists to prevent.\n',
333
499
  );
@@ -336,12 +502,12 @@ if (invokedDirectly()) {
336
502
  // 🔴 Exit 1, never 2, and the message says so. Exit 2 means one thing only —
337
503
  // the rounds are spent — because `pr-ship` acts on it by ending the task. A
338
504
  // broken config, an unreadable counter or a detached checkout must not be
339
- // read as a converging-failure stall.
505
+ // read as a spent cap.
340
506
  process.stderr.write(
341
507
  `gate-round could not run: ${error.message}\n` +
342
508
  ' 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',
509
+ '0 again: a bad config, a bad cap, a detached checkout, a dirty tree or an ' +
510
+ 'unpushed HEAD is refused before any round is counted, so retrying costs nothing.\n',
345
511
  );
346
512
  process.exit(1);
347
513
  }
@@ -366,8 +532,10 @@ if (invokedDirectly()) {
366
532
  // and must keep working exactly as before.
367
533
  let readState;
368
534
  let stopInputsOf;
535
+ let recordTakeUp;
536
+ let previousTakeUp;
369
537
  try {
370
- ({ readState, stopInputsOf } = await import('../run-state.mjs'));
538
+ ({ readState, stopInputsOf, recordTakeUp, previousTakeUp } = await import('../run-state.mjs'));
371
539
  } catch (error) {
372
540
  process.stderr.write(
373
541
  `run state: ${error.message}\n` +
@@ -470,12 +638,43 @@ if (invokedDirectly()) {
470
638
  }
471
639
 
472
640
  if (args.command === 'hygiene') {
473
- const findings = tickets.map(hygieneOf).filter(Boolean);
641
+ // The proposals on file are checked too (AR-116): a proposal names the commit
642
+ // it was measured against, and one whose cited paths moved since is reported
643
+ // as possibly overtaken. Git runs here, once per distinct `asOf`, against the
644
+ // project this script belongs to; `core.mjs` only decides.
645
+ const proposals = await adapter.listProposals(optionsWithPlanPath(config.options, configPath));
646
+ // The checkout the proposals describe is the project the config names — an
647
+ // explicit `--config <root>/.claude/queue.json` points at that root — and
648
+ // this script's own project only when the config implies none.
649
+ const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
650
+ const head = headShaOf({ cwd: gitRoot });
651
+ const changedByAsOf = new Map();
652
+ const overtaken = proposals
653
+ .map((proposal) => {
654
+ const asOf = asOfOf(proposal.body);
655
+ if (asOf && !changedByAsOf.has(asOf)) {
656
+ changedByAsOf.set(asOf, changedSinceOf({ cwd: gitRoot, asOf, head: head ?? 'HEAD' }));
657
+ }
658
+ return overtakenOf({
659
+ id: proposal.id,
660
+ asOf,
661
+ citedPaths: citedPathsOf(proposal.body),
662
+ head,
663
+ changedSince: asOf ? changedByAsOf.get(asOf) : null,
664
+ });
665
+ })
666
+ .filter(Boolean);
667
+ const owner = config.options?.owner ?? null;
668
+ const findings = [
669
+ ...tickets.map((ticket) => hygieneOf(ticket, { owner })).filter(Boolean),
670
+ ...overtaken,
671
+ ];
474
672
  process.stdout.write(
475
673
  args.json
476
674
  ? `${JSON.stringify({ findings }, null, 2)}\n`
477
675
  : findings.length === 0
478
- ? `queue hygiene: ${tickets.length} item(s) checked nothing stale.\n`
676
+ ? `queue hygiene: ${tickets.length} item(s) and ${proposals.length} proposal(s) ` +
677
+ 'checked — nothing stale.\n'
479
678
  : `${findings.map((f) => ` [${f.kind}] ${f.id} — ${f.why}`).join('\n')}\n`,
480
679
  );
481
680
  process.exit(0);
@@ -501,6 +700,9 @@ if (invokedDirectly()) {
501
700
  // impossible to retract, so "not this time" would again require editing the
502
701
  // generated file this move exists to get out of.
503
702
  triggersFired: runState.triggersFired ?? config.triggersFired ?? null,
703
+ // This checkout's name, for the owner marker (AR-132). Absent means the
704
+ // checkout cannot confirm a match, and an owned item is held.
705
+ owner: config.options?.owner ?? null,
504
706
  });
505
707
  // The skipped records travel with the count: without them "nothing left" and
506
708
  // "everything left is held back" both print as an empty queue, and only one of
@@ -524,6 +726,31 @@ if (invokedDirectly()) {
524
726
  // a default derived from `projectRoot` would land the trace inside the very
525
727
  // template tree this repository publishes.
526
728
  const runDir = process.env.RIG_RUN_DIR;
729
+ // Revalidation at SELECT (AR-133): the selected item against the marker this
730
+ // run recorded at its last take-up. Computed only under a declared run —
731
+ // there is no snapshot to compare against anywhere else — and `null` in the
732
+ // output then, so a reader can tell "not compared" from "compared, unchanged".
733
+ //
734
+ // The baseline is this run's take-up when it has one; otherwise the newest
735
+ // earlier run's (AR-138) — an item taken up yesterday and re-offered today
736
+ // used to compare against nothing and report a first sight. `baseline` says
737
+ // which it was (`this-run` | `previous-run` | null), and `baselineRun` names
738
+ // the earlier run, so the report can tell the three apart. A rig whose
739
+ // run-state module predates `previousTakeUp` keeps the per-run behaviour.
740
+ let revalidation = null;
741
+ if (runDir && result.ticket) {
742
+ const own = runState.takeUps?.[result.ticket.id];
743
+ const prior =
744
+ own === undefined && typeof previousTakeUp === 'function'
745
+ ? previousTakeUp(runDir, result.ticket.id)
746
+ : null;
747
+ const snapshot = own ?? prior?.updatedAt ?? null;
748
+ revalidation = {
749
+ ...revalidationOf({ ticket: result.ticket, snapshot }),
750
+ baseline: own !== undefined ? 'this-run' : prior ? 'previous-run' : null,
751
+ ...(prior ? { baselineRun: prior.runDir } : {}),
752
+ };
753
+ }
527
754
  if (runDir) {
528
755
  let journal = null;
529
756
  try {
@@ -537,6 +764,27 @@ if (invokedDirectly()) {
537
764
  // an argument, which is what keeps its records reproducible.
538
765
  now: new Date().toISOString(),
539
766
  });
767
+ if (revalidation) {
768
+ // The selection decision keeps its place as the run's first record; the
769
+ // evidence log comes next, and only then (below, after the journal) does
770
+ // the baseline move — a snapshot written before its event would leave a
771
+ // crashed run with a marker and no record of what it was compared against.
772
+ if (typeof journal.recordEvent === 'function') {
773
+ journal.recordEvent({
774
+ runDir,
775
+ kind: 'revalidation',
776
+ data: revalidation,
777
+ now: new Date().toISOString(),
778
+ });
779
+ } else {
780
+ // A rig carrying a run journal older than this CLI: the selection
781
+ // stands, the comparison was made, only its record has nowhere to go.
782
+ process.stderr.write(
783
+ 'run journal: this journal predates revalidation events, so the ' +
784
+ 'revalidation was not recorded.\n',
785
+ );
786
+ }
787
+ }
540
788
  } catch (error) {
541
789
  // 🔴 Two failures wearing one face, and treating them alike was a defect
542
790
  // this gate caught. The journal asks the module which one this is — never
@@ -572,9 +820,24 @@ if (invokedDirectly()) {
572
820
  }
573
821
  }
574
822
 
823
+ if (revalidation) {
824
+ // Its own try, after the journal's: a state file that cannot be written is
825
+ // not the journal failing, and the selection stands either way — the
826
+ // comparison was made and recorded; only the next baseline is lost.
827
+ try {
828
+ recordTakeUp(runDir, { id: result.ticket.id, updatedAt: result.ticket.updatedAt });
829
+ } catch (error) {
830
+ process.stderr.write(
831
+ `run state: the take-up snapshot was NOT recorded in ${runDir} — ${error.message}\n` +
832
+ ' the selection below stands; the baseline was not moved — the next revalidation ' +
833
+ 'of this item compares against the previous one, if any, or has none.\n',
834
+ );
835
+ }
836
+ }
837
+
575
838
  process.stdout.write(
576
839
  args.json
577
- ? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop }, null, 2)}\n`
578
- : renderNext(result, stop),
840
+ ? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop, revalidation }, null, 2)}\n`
841
+ : renderNext(result, stop, revalidation),
579
842
  );
580
843
  }