create-agent-rig 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -4,7 +4,8 @@
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
+ // node .claude/scripts/queue/index.mjs gate-round --branch <b> # count a gate round
8
9
  //
9
10
  // The adapter comes from `.claude/queue.json` (`{"adapter": "plan-md"}`) and
10
11
  // defaults to `plan-md`, which is the only adapter that works in a freshly
@@ -12,8 +13,22 @@
12
13
  // that silently reads the wrong queue is worse than one that refuses to start.
13
14
  import { readFileSync, realpathSync } from 'node:fs';
14
15
  import { fileURLToPath } from 'node:url';
15
- import { dirname, join } from 'node:path';
16
- import { hygieneOf, selectNext, stopConditionOf } from './core.mjs';
16
+ import { basename, dirname, join } from 'node:path';
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';
27
+ // One resolver, imported rather than re-derived: writer and reader disagreeing
28
+ // about which checkout they are in is the whole of the worktree defect. It lives
29
+ // apart from `state.mjs` so the read path does not drag the tier computation —
30
+ // and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
31
+ import { checkoutIsShippable, mainCheckoutRoot } from './checkout.mjs';
17
32
 
18
33
  const ADAPTERS = {
19
34
  'plan-md': './plan-md.mjs',
@@ -33,7 +48,7 @@ export const resolveAdapter = async (adapterName) => {
33
48
  return import(new URL(modulePath, import.meta.url).href);
34
49
  };
35
50
 
36
- export const COMMANDS = ['next', 'list', 'hygiene'];
51
+ export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round'];
37
52
 
38
53
  /**
39
54
  * A missing config is the normal state of a fresh project. A config that exists
@@ -60,21 +75,176 @@ export const loadConfig = (configPath) => {
60
75
  }
61
76
  };
62
77
 
78
+ /**
79
+ * The state file that travels with a config: `<name>.json` → `<name>.state.json`.
80
+ *
81
+ * Derived from the config path rather than from the project root on purpose. The
82
+ * two must stay a pair: a run pointed at a temp config by `--config` would
83
+ * otherwise read the developer's own leftover state and let a tier from an
84
+ * unrelated checkout decide its selection.
85
+ */
86
+ export const statePathFor = (configPath) => configPath.replace(/(\.json)?$/, '.state.json');
87
+
88
+ /**
89
+ * The project root a config path implies, or `null` when it implies none.
90
+ *
91
+ * A config inside a directory named `.claude` names a project: the rig puts it
92
+ * there, so its parent is the root. Anything else — `--config /tmp/x.json`, a
93
+ * config nested for a test fixture — is a bare file with no project around it,
94
+ * and this returns `null` so the caller keeps whatever default it had.
95
+ *
96
+ * 🔴 Deliberately NOT `join(dirname(configPath), '..')`, which assumes every
97
+ * config sits in a `.claude` directory: pointed at `<dir>/.claude-queue.json` it
98
+ * climbs a level out of the project and looks for the plan in the parent.
99
+ */
100
+ export const projectRootOfConfig = (configPath) => {
101
+ const dir = dirname(configPath);
102
+ return basename(dir) === '.claude' ? dirname(dir) : null;
103
+ };
104
+
105
+ /**
106
+ * The adapter options the CLI hands down, with a plan path that does not depend
107
+ * on the directory the command was typed in.
108
+ *
109
+ * 🔴 The asymmetry this closes: this file resolves its config from
110
+ * `import.meta.url`, so the config is found from any directory — while
111
+ * `plan-md`'s default is the bare relative `'PLAN.md'`, resolved against `cwd`.
112
+ * Running the CLI from a subdirectory therefore found the adapter and then
113
+ * reported `queue-unreadable`, which a run reads as a broken queue rather than as
114
+ * a wrong directory.
115
+ *
116
+ * Two things are left exactly as they were, and both are load-bearing. An
117
+ * explicit `options.planPath` still wins. And when the config implies no project
118
+ * root, nothing is injected — so `plan-md`'s own `'PLAN.md'` still resolves
119
+ * against the caller's cwd, which is the right answer when the adapter is
120
+ * imported directly rather than through this CLI, as the `loop` skill does for
121
+ * `proposeTriage`.
122
+ */
123
+ export const optionsWithPlanPath = (options, configPath) => {
124
+ const root = projectRootOfConfig(configPath);
125
+ if (options?.planPath || root === null) return { ...options };
126
+ return { ...options, planPath: join(root, 'PLAN.md') };
127
+ };
128
+
129
+ /**
130
+ * Every tier a state file may carry, and the reason there are four.
131
+ *
132
+ * `state.mjs` writes three of them: `normal`, and the elevated tier split into
133
+ * `elevated-prose` and `elevated-mechanism` — only the last of which spaces the
134
+ * next item (`core.mjs`). The fourth, the bare `elevated`, is what a state file
135
+ * written before that split still holds; it stays **readable** because a
136
+ * checkout that upgrades mid-run would otherwise refuse to select at all, and
137
+ * `core.mjs` reads it restrictively, so the legacy value rations exactly as it
138
+ * did when it was written.
139
+ */
140
+ const KNOWN_TIERS = ['normal', 'elevated', 'elevated-prose', 'elevated-mechanism'];
141
+
142
+ /**
143
+ * Per-checkout state, kept OUT of the composed config.
144
+ *
145
+ * The config is a template-layer file: a runtime value written into it is drift
146
+ * here and an upgrade conflict in a generated project. So the tier of the last
147
+ * closed item lands beside it, in a gitignored file.
148
+ *
149
+ * Missing is not an error — it means no item has closed yet, and selection
150
+ * proceeds with no spacing. Present-and-unparseable IS: the tier is then
151
+ * *unknown*, and the silent reading of unknown is `null`, which is the
152
+ * permissive value that lets a second elevated item straight through. That is
153
+ * the exact failure this state file exists to end, so it refuses instead.
154
+ */
155
+ export const loadState = (statePath) => {
156
+ let raw;
157
+ try {
158
+ raw = readFileSync(statePath, 'utf8');
159
+ } catch (error) {
160
+ // 🔴 ONLY a genuinely absent file means "nothing has closed yet". A bare
161
+ // `catch` here read EACCES, a directory at the path, and a file left by a
162
+ // container run under another uid as "absent" — each of them silently
163
+ // yielding the permissive `null`, with nothing printed. One failed write
164
+ // would have disabled the ration for good, and no adversary is needed.
165
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return {};
166
+ throw new Error(
167
+ `${statePath} exists but could not be read (${error?.code ?? 'unknown error'}), ` +
168
+ 'so the last completed tier is unknown. Refusing rather than continuing: ' +
169
+ 'unknown reads as "nothing has closed yet", which is the value that lets a ' +
170
+ 'second elevated item through.',
171
+ { cause: error },
172
+ );
173
+ }
174
+
175
+ let parsed;
176
+ try {
177
+ parsed = JSON.parse(raw);
178
+ } catch (error) {
179
+ throw new Error(
180
+ `${statePath} exists but is not valid JSON, so the last completed tier cannot ` +
181
+ `be read: ${String(error?.message ?? error).split('\n')[0]}. Delete the file ` +
182
+ 'if you are unsure — an absent state means "nothing has closed yet", which is ' +
183
+ 'safe; a tier that cannot be read would silently disable the elevated ration.',
184
+ { cause: error },
185
+ );
186
+ }
187
+
188
+ // Syntax is not shape. `"elevated"`, `["elevated"]`, `123` and `"Elevated"` all
189
+ // parse, and a state file shaped like any of them carries no tier at all — the
190
+ // dereference below answers `undefined`, which reads as "nothing has closed
191
+ // yet" and hands out an elevated item while the file looks like a working one.
192
+ // (`core.mjs` now holds on an unrecognised tier rather than shrugging, but a
193
+ // top-level array or number never reaches it as a tier in the first place.)
194
+ // A top-level `null` was worse still: it parsed, then threw a raw TypeError
195
+ // from the dereference, past the message that says the file is safe to delete.
196
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
197
+ throw new Error(
198
+ `${statePath} is valid JSON but not a state object, so the last completed tier ` +
199
+ 'cannot be read. Expected `{ "lastCompletedTier": "normal" | "elevated-prose" ' +
200
+ '| "elevated-mechanism" }` — the legacy `"elevated"` is still accepted, and ' +
201
+ 'held. ' +
202
+ 'Delete the file if you are unsure — an absent state means "nothing has ' +
203
+ 'closed yet", which is safe.',
204
+ );
205
+ }
206
+
207
+ // `null` is allowed and means exactly what an absent file means: nothing has
208
+ // closed yet. It is the honest spelling of an absence, and refusing it would
209
+ // protect nothing — deleting the file has the identical effect.
210
+ const tier = parsed.lastCompletedTier;
211
+ if (tier !== undefined && tier !== null && !KNOWN_TIERS.includes(tier)) {
212
+ throw new Error(
213
+ `${statePath} carries lastCompletedTier: ${JSON.stringify(tier)}, which is not ` +
214
+ `one of ${KNOWN_TIERS.map((t) => `"${t}"`).join(' | ')}. A tier outside the ` +
215
+ 'vocabulary rations on a value nobody wrote, so it would report a working ' +
216
+ 'state file while the spacing rule ran on a guess. Delete the file if you ' +
217
+ 'are unsure.',
218
+ );
219
+ }
220
+
221
+ return parsed;
222
+ };
223
+
63
224
  const parseArgs = (argv) => {
64
- const args = { command: argv[0] ?? 'next', json: false, config: null };
225
+ const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null };
65
226
  for (let i = 1; i < argv.length; i += 1) {
66
227
  if (argv[i] === '--json') args.json = true;
67
228
  else if (argv[i] === '--config') args.config = argv[++i];
229
+ else if (argv[i] === '--branch') args.branch = argv[++i];
68
230
  }
69
231
  return args;
70
232
  };
71
233
 
72
- const renderNext = (result, stop) => {
234
+ const renderNext = (result, stop, revalidation = null) => {
73
235
  if (stop) {
74
236
  const label = stop.kind.replaceAll('-', ' ');
75
237
  return `queue: ${label}${stop.success ? '' : ' (needs attention)'}\n ${stop.why}\n`;
76
238
  }
77
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
+ }
78
248
  if (result.skipped.length > 0) {
79
249
  lines.push('', 'skipped:');
80
250
  for (const skip of result.skipped) lines.push(` ${skip.id} — ${skip.reason}`);
@@ -118,21 +288,204 @@ if (invokedDirectly()) {
118
288
  process.exit(1);
119
289
  }
120
290
 
291
+ // 🔴 **`gate-round` returns before the tracker is ever touched, and that is
292
+ // load-bearing rather than an optimisation.** `pr-ship` calls this once per
293
+ // round, and every other command below reaches the adapter — so routing this one
294
+ // through the normal path would spend a network call per gate round and, worse,
295
+ // would refuse to count a round while the tracker is unreachable. The counter is
296
+ // a local file and a local config; a Jira outage must not hand a run unlimited
297
+ // rounds.
298
+ if (args.command === 'gate-round') {
299
+ try {
300
+ // 🔴 Inside the `try`, and a review round is why. Left outside it, a rig whose
301
+ // `.claude/scripts/` predates this CLI printed a raw `ERR_MODULE_NOT_FOUND`
302
+ // and exited 1 — which `pr-ship` step 0 would have read as "rounds
303
+ // exhausted" and escalated a healthy item on. This file already records that
304
+ // exact lesson about `resolveAdapter` fifty lines below; the reason it
305
+ // repeated is that the lesson was written as prose and not as a test.
306
+ const { gateRoundVerdict } = await import('./core.mjs');
307
+ const { recordGateRound, gateRoundsPathFor } = await import('./gate-rounds.mjs');
308
+
309
+ const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
310
+ const config = loadConfig(configPath);
311
+ // An explicit `--config` keeps its counter beside it, so a run pointed at a
312
+ // temp config never touches this checkout's real counts.
313
+ const roundsPath = args.config
314
+ ? configPath.replace(/(\.json)?$/, '.gate-rounds.json')
315
+ : gateRoundsPathFor(projectRoot);
316
+
317
+ // 🔴 Validate the cap BEFORE counting. A review round measured what the other
318
+ // order costs: with `maxGateRounds: 0` every attempt recorded a round and then
319
+ // exited 1, while this command's own message told the caller to fix the cause
320
+ // and run step 0 again — so three attempts at a broken config left the branch
321
+ // at three rounds and the next honest call refused a healthy item. A failed
322
+ // run must not spend budget, and the message below must not have to lie about
323
+ // whether it did.
324
+ const verdictFor = (rounds) => gateRoundVerdict(rounds, config.options?.maxGateRounds);
325
+ verdictFor(0);
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
+
336
+ const { rounds } = recordGateRound({ branch: args.branch, roundsPath });
337
+ const verdict = verdictFor(rounds);
338
+
339
+ if (!verdict.exceeded) {
340
+ process.stdout.write(
341
+ `gate round ${verdict.rounds} of ${verdict.max} on ${args.branch}.\n` +
342
+ (verdict.rounds === verdict.max
343
+ ? ' This is the last round this branch gets. If it holds again, the item ' +
344
+ 'is escalated rather than re-reviewed.\n'
345
+ : ''),
346
+ );
347
+ process.exit(0);
348
+ }
349
+
350
+ process.stderr.write(
351
+ `GATE ROUNDS EXHAUSTED — ${verdict.rounds} rounds on ${args.branch}, cap is ` +
352
+ `${verdict.max}: ${verdict.stop}.\n` +
353
+ ' Do not run another round. The item stops here and goes back to a human ' +
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' +
357
+ ' Raising the cap to get one more pass on THIS item is the move this ' +
358
+ 'refusal exists to prevent.\n',
359
+ );
360
+ process.exit(2);
361
+ } catch (error) {
362
+ // 🔴 Exit 1, never 2, and the message says so. Exit 2 means one thing only —
363
+ // the rounds are spent — because `pr-ship` acts on it by ending the task. A
364
+ // broken config, an unreadable counter or a detached checkout must not be
365
+ // read as a spent cap.
366
+ process.stderr.write(
367
+ `gate-round could not run: ${error.message}\n` +
368
+ ' This is NOT an exhausted cap (that is exit 2). Fix the cause and run step ' +
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',
371
+ );
372
+ process.exit(1);
373
+ }
374
+ }
375
+
376
+ // A missing module is NOT read as "no state". A run that could not read its
377
+ // state would silently lose the escalation streak and the regression verdict
378
+ // — the two conditions that exist to stop it — so this fails closed, into a
379
+ // message the operator can act on.
380
+ //
381
+ // 🔴 **Loaded unconditionally, and ahead of the adapter, because the adapters
382
+ // import it statically.** An earlier version loaded it dynamically and only
383
+ // behind `RIG_RUN_DIR`, reasoning that a rig carrying an older
384
+ // `.claude/scripts/` should still get a readable message. That reasoning was
385
+ // sound and the code did not deliver it: all three adapters now
386
+ // `import { recordEscalation } from '../run-state.mjs'`, so a rig missing the
387
+ // module died inside `resolveAdapter` below and the operator saw a raw
388
+ // `Cannot find module` naming an adapter. The refusal was unreachable — and
389
+ // its test passed anyway, because the module's own filename satisfied the
390
+ // pattern it matched on. The load lives here; the READ stays behind the
391
+ // declaration, because a session with no run directory has no state to read
392
+ // and must keep working exactly as before.
393
+ let readState;
394
+ let stopInputsOf;
395
+ let recordTakeUp;
396
+ let previousTakeUp;
397
+ try {
398
+ ({ readState, stopInputsOf, recordTakeUp, previousTakeUp } = await import('../run-state.mjs'));
399
+ } catch (error) {
400
+ process.stderr.write(
401
+ `run state: ${error.message}\n` +
402
+ ' the run state module could not be loaded, so the escalation streak and the ' +
403
+ 'deploy verdict cannot be read. Those are stop conditions, and a run that ' +
404
+ 'cannot read them must not select work.\n',
405
+ );
406
+ process.exit(1);
407
+ }
408
+
121
409
  let config;
410
+ let state;
122
411
  let adapter;
412
+ // Declared out here because the plan path is derived from it further down.
413
+ let configPath;
123
414
  try {
124
- config = loadConfig(args.config ?? join(projectRoot, '.claude', 'queue.json'));
415
+ configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
416
+ config = loadConfig(configPath);
417
+ // An explicit `--config` keeps its own state beside it, verbatim: a run
418
+ // pointed at a temp config must not pick up this checkout's real tier.
419
+ // Without one, the state comes from the MAIN checkout, so a selection made
420
+ // inside a worktree sees what a close made anywhere recorded.
421
+ state = loadState(
422
+ args.config
423
+ ? statePathFor(configPath)
424
+ : join(mainCheckoutRoot(projectRoot), '.claude', 'queue.state.json'),
425
+ );
125
426
  adapter = await resolveAdapter(config.adapter ?? 'plan-md');
126
427
  } catch (error) {
127
428
  process.stderr.write(`${error.message}\n`);
128
429
  process.exit(1);
129
430
  }
130
431
 
432
+ // 🔴 The run-level stop is decided BEFORE the queue is read, because those
433
+ // conditions are not about the queue. A run that has escalated twice in a row
434
+ // has hit a systemic wall, and a run that deployed a regression must start no
435
+ // new work on top of it — asking the tracker first would spend a network call
436
+ // and, worse, would report `queue-unreadable` for an unreachable tracker when
437
+ // the truthful answer is that this run was already over.
438
+ //
439
+ // The values come from the run's own state file rather than from the
440
+ // session's memory, which is what three of `stopConditionOf`'s parameters
441
+ // were waiting for: every branch was live, and nothing ever supplied them.
442
+ //
443
+ // The fourth, `killSwitch`, is still not passed here and that is deliberate —
444
+ // `guard-bash` denies the merge at the tool layer and preflight reads the flag,
445
+ // so a second answer to "is the brake on" would be the disagreement
446
+ // `invariants.md` forbids. Selection therefore does NOT stop on the brake, and
447
+ // the `loop` skill tells the run to keep checking it between tasks.
448
+ //
449
+ const runState = process.env.RIG_RUN_DIR ? readState(process.env.RIG_RUN_DIR) : {};
450
+ let stopInputs;
451
+ try {
452
+ // Read through the module that owns the vocabulary, never field by field
453
+ // here: a value the reader cannot interpret must not arrive as "no stop",
454
+ // and deciding that at the call site is how the two halves drift.
455
+ stopInputs = stopInputsOf(runState);
456
+ } catch (error) {
457
+ process.stderr.write(
458
+ `${error.message}\n the run declared a state file this cannot act on, so no ` +
459
+ 'item is selected. Stop conditions read from it, and an unreadable one is ' +
460
+ 'not the same as an absent one.\n',
461
+ );
462
+ process.exit(1);
463
+ }
464
+ const runStop = stopConditionOf({
465
+ // `candidates: 1` says "not the empty-queue case" — the queue has not been
466
+ // read yet and must not be reported on here. Only the conditions that
467
+ // outrank it can fire from this call.
468
+ candidates: 1,
469
+ ...stopInputs,
470
+ });
471
+ if (runStop) {
472
+ // `renderNext`, not a second copy of its format — an operator reading two
473
+ // differently-worded stop lines has no way to know they came from one rule.
474
+ process.stdout.write(
475
+ args.json ? `${JSON.stringify({ stop: runStop }, null, 2)}\n` : renderNext(null, runStop),
476
+ );
477
+ // The exit code follows `stop.success`, exactly as the empty-queue path
478
+ // does. A budget stop is a clean end of session — reporting it as a failure
479
+ // would tell a wrapper that the run broke, and `--json` already says
480
+ // `"success": true` right beside the code that contradicted it.
481
+ process.exit(runStop.success ? 0 : 1);
482
+ }
483
+
131
484
  let tickets;
132
485
  try {
133
486
  // Awaited so an adapter may be async (jira) or plain (plan-md, github-issues)
134
487
  // without the CLI caring which.
135
- tickets = await adapter.listEligible(config.options ?? {});
488
+ tickets = await adapter.listEligible(optionsWithPlanPath(config.options, configPath));
136
489
  } catch (error) {
137
490
  // Never fall back to memory or to a stale copy for a queue.
138
491
  const stop = stopConditionOf({ queueReadable: false });
@@ -145,31 +498,206 @@ if (invokedDirectly()) {
145
498
  }
146
499
 
147
500
  if (args.command === 'hygiene') {
148
- 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
+ ];
149
532
  process.stdout.write(
150
533
  args.json
151
534
  ? `${JSON.stringify({ findings }, null, 2)}\n`
152
535
  : findings.length === 0
153
- ? `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'
154
538
  : `${findings.map((f) => ` [${f.kind}] ${f.id} — ${f.why}`).join('\n')}\n`,
155
539
  );
156
540
  process.exit(0);
157
541
  }
158
542
 
159
543
  const result = selectNext(tickets, {
160
- lastCompletedTier: config.lastCompletedTier ?? null,
161
- triggersFired: config.triggersFired ?? null,
544
+ // The state file wins: it is what a close actually recorded. A tier left in
545
+ // the config is a hand-written hint at best, and it is the composed file, so
546
+ // it cannot be the live value.
547
+ lastCompletedTier: state.lastCompletedTier ?? config.lastCompletedTier ?? null,
548
+ // Same precedence, for the same reason as the tier above: `queue.json` is
549
+ // composed by the sync script and drift-checked, so declaring a trigger
550
+ // fired there means editing a generated file — and the declaration is a
551
+ // fact about THIS run, not about the rig's configuration.
552
+ //
553
+ // The config keeps working as a fallback rather than being dropped: nothing
554
+ // in this repository or its templates ever writes the key, but a rig owner
555
+ // who hand-added one would otherwise find their auto-trigger items silently
556
+ // unselectable, and an item that stops being offered announces itself
557
+ // nowhere.
558
+ //
559
+ // Replacement, not a merge: a per-key merge would make a stale config entry
560
+ // impossible to retract, so "not this time" would again require editing the
561
+ // generated file this move exists to get out of.
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,
162
566
  });
163
- const stop = result.ticket ? null : stopConditionOf({ candidates: 0 });
567
+ // The skipped records travel with the count: without them "nothing left" and
568
+ // "everything left is held back" both print as an empty queue, and only one of
569
+ // the two means the queue needs refilling.
570
+ const stop = result.ticket
571
+ ? null
572
+ : stopConditionOf({ candidates: 0, skipped: result.skipped });
164
573
 
165
574
  if (args.command === 'list') {
166
575
  process.stdout.write(`${JSON.stringify({ tickets, ...result }, null, 2)}\n`);
167
576
  process.exit(0);
168
577
  }
169
578
 
579
+ // 🔴 A journal nothing calls records nothing. Selection is a gate — it decides
580
+ // what the run works on and why every other item was passed over — so this is
581
+ // the call site the run journal ships with, rather than a writer nobody
582
+ // invokes.
583
+ //
584
+ // It writes only when the run DECLARED its directory. Inventing one here would
585
+ // make this CLI a second owner of the `.claude/runs/<run-id>/` convention, and
586
+ // a default derived from `projectRoot` would land the trace inside the very
587
+ // template tree this repository publishes.
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
+ }
614
+ if (runDir) {
615
+ let journal = null;
616
+ try {
617
+ journal = await import('../run-journal.mjs');
618
+ journal.recordDecision({
619
+ runDir,
620
+ gate: 'item-selection',
621
+ verdict: result.ticket ? `taken ${result.ticket.id}` : `stopped ${stop.kind}`,
622
+ why: result.ticket ? result.ticket.title : stop.why,
623
+ // The edge is where the clock is read: the journal itself takes `now` as
624
+ // an argument, which is what keeps its records reproducible.
625
+ now: new Date().toISOString(),
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
+ }
648
+ } catch (error) {
649
+ // 🔴 Two failures wearing one face, and treating them alike was a defect
650
+ // this gate caught. The journal asks the module which one this is — never
651
+ // the message text, which would put the decision in two files and let them
652
+ // drift the day someone improves the wording.
653
+ // `?.` would guard a MISSING module and not a missing export. A rig can
654
+ // carry a run journal older than this CLI — `upgrade` leaves a
655
+ // locally-edited copy beside a new caller — and calling through to an
656
+ // absent export crashed the CLI inside its own error handler: a raw stack
657
+ // trace instead of the failure it was reporting.
658
+ const classify = journal?.isTraceExhausted;
659
+ if (typeof classify === 'function' && classify(error)) {
660
+ // The trace is over and the work is not. This journal cannot accept
661
+ // another record — it is append-only and every write re-reads it — so
662
+ // exiting here would make one collision between two sessions leave the
663
+ // queue unselectable FOREVER. Loud on stderr, and the selection still
664
+ // goes out on stdout, because the queue was never the thing that failed.
665
+ process.stderr.write(
666
+ `run journal: ${error.message}\n` +
667
+ ` the selection below was NOT recorded in ${runDir}. This run's trace ends ` +
668
+ 'here; the queue is fine, and a new run needs a new run directory.\n',
669
+ );
670
+ } else {
671
+ // The other half: the declaration is empty, its directory is not there,
672
+ // the path is not a directory, or the journal module is missing. Each is
673
+ // the run pointing at something that was never set up — one `mkdir` or
674
+ // one corrected variable away — and continuing would produce a run with
675
+ // no trace at all, so this half does stop the selection. (A directory
676
+ // deleted mid-run lands here too, and the same `mkdir` restores it.)
677
+ process.stderr.write(`run journal: ${error.message}\n`);
678
+ process.exit(1);
679
+ }
680
+ }
681
+ }
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
+
170
698
  process.stdout.write(
171
699
  args.json
172
- ? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop }, null, 2)}\n`
173
- : renderNext(result, stop),
700
+ ? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop, revalidation }, null, 2)}\n`
701
+ : renderNext(result, stop, revalidation),
174
702
  );
175
703
  }