create-agent-rig 0.4.0 → 0.5.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 (105) hide show
  1. package/CHANGELOG.md +207 -8
  2. package/README.md +50 -17
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +82 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +38 -14
  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 +21 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/templates/agent-os/init/AGENTS.md +191 -0
  14. package/templates/agent-os/init/CLAUDE.md +61 -9
  15. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  18. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  19. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  20. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  21. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  22. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  23. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  24. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  26. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  27. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  31. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  32. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  33. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  34. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  35. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  36. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  37. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  38. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  39. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  40. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  41. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  42. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  43. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  44. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  45. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  46. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  47. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  48. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  49. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  50. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  51. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  52. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  53. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  55. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  56. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  57. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  58. package/templates/agent-os/universal/.claude/settings.json +7 -2
  59. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  60. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  62. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  63. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  64. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  65. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  66. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  67. package/templates/agent-os/universal/AGENTS.md +164 -0
  68. package/templates/agent-os/universal/CLAUDE.md +47 -14
  69. package/templates/agent-os/universal/PLAN.md +7 -40
  70. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  71. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  72. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  73. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  74. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  75. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  76. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  77. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  78. package/templates/agent-os/universal/journal/README.md +101 -0
  79. package/templates/agent-os/universal/layers.json +36 -2
  80. package/templates/hash-history.json +2 -1
  81. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  82. package/templates/skeleton/aws-serverless/README.md +91 -9
  83. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  84. package/templates/skeleton/aws-serverless/gitignore +37 -0
  85. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  86. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  87. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  88. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  89. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  90. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  91. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  92. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  93. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  94. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  95. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  96. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  97. package/templates/skeleton/node-service/README.md +11 -1
  98. package/templates/skeleton/node-service/gitignore +34 -0
  99. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  100. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  101. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  102. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  103. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  104. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  105. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -5,6 +5,7 @@
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
7
  // node .claude/scripts/queue/index.mjs hygiene # stale labels and link anomalies
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,13 @@
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 { basename, dirname, join } from 'node:path';
16
17
  import { hygieneOf, selectNext, stopConditionOf } from './core.mjs';
18
+ // One resolver, imported rather than re-derived: writer and reader disagreeing
19
+ // about which checkout they are in is the whole of the worktree defect. It lives
20
+ // apart from `state.mjs` so the read path does not drag the tier computation —
21
+ // and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
22
+ import { mainCheckoutRoot } from './checkout.mjs';
17
23
 
18
24
  const ADAPTERS = {
19
25
  'plan-md': './plan-md.mjs',
@@ -33,7 +39,7 @@ export const resolveAdapter = async (adapterName) => {
33
39
  return import(new URL(modulePath, import.meta.url).href);
34
40
  };
35
41
 
36
- export const COMMANDS = ['next', 'list', 'hygiene'];
42
+ export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round'];
37
43
 
38
44
  /**
39
45
  * A missing config is the normal state of a fresh project. A config that exists
@@ -60,11 +66,158 @@ export const loadConfig = (configPath) => {
60
66
  }
61
67
  };
62
68
 
69
+ /**
70
+ * The state file that travels with a config: `<name>.json` → `<name>.state.json`.
71
+ *
72
+ * Derived from the config path rather than from the project root on purpose. The
73
+ * two must stay a pair: a run pointed at a temp config by `--config` would
74
+ * otherwise read the developer's own leftover state and let a tier from an
75
+ * unrelated checkout decide its selection.
76
+ */
77
+ export const statePathFor = (configPath) => configPath.replace(/(\.json)?$/, '.state.json');
78
+
79
+ /**
80
+ * The project root a config path implies, or `null` when it implies none.
81
+ *
82
+ * A config inside a directory named `.claude` names a project: the rig puts it
83
+ * there, so its parent is the root. Anything else — `--config /tmp/x.json`, a
84
+ * config nested for a test fixture — is a bare file with no project around it,
85
+ * and this returns `null` so the caller keeps whatever default it had.
86
+ *
87
+ * 🔴 Deliberately NOT `join(dirname(configPath), '..')`, which assumes every
88
+ * config sits in a `.claude` directory: pointed at `<dir>/.claude-queue.json` it
89
+ * climbs a level out of the project and looks for the plan in the parent.
90
+ */
91
+ export const projectRootOfConfig = (configPath) => {
92
+ const dir = dirname(configPath);
93
+ return basename(dir) === '.claude' ? dirname(dir) : null;
94
+ };
95
+
96
+ /**
97
+ * The adapter options the CLI hands down, with a plan path that does not depend
98
+ * on the directory the command was typed in.
99
+ *
100
+ * 🔴 The asymmetry this closes: this file resolves its config from
101
+ * `import.meta.url`, so the config is found from any directory — while
102
+ * `plan-md`'s default is the bare relative `'PLAN.md'`, resolved against `cwd`.
103
+ * Running the CLI from a subdirectory therefore found the adapter and then
104
+ * reported `queue-unreadable`, which a run reads as a broken queue rather than as
105
+ * a wrong directory.
106
+ *
107
+ * Two things are left exactly as they were, and both are load-bearing. An
108
+ * explicit `options.planPath` still wins. And when the config implies no project
109
+ * root, nothing is injected — so `plan-md`'s own `'PLAN.md'` still resolves
110
+ * against the caller's cwd, which is the right answer when the adapter is
111
+ * imported directly rather than through this CLI, as the `loop` skill does for
112
+ * `proposeTriage`.
113
+ */
114
+ export const optionsWithPlanPath = (options, configPath) => {
115
+ const root = projectRootOfConfig(configPath);
116
+ if (options?.planPath || root === null) return { ...options };
117
+ return { ...options, planPath: join(root, 'PLAN.md') };
118
+ };
119
+
120
+ /**
121
+ * Every tier a state file may carry, and the reason there are four.
122
+ *
123
+ * `state.mjs` writes three of them: `normal`, and the elevated tier split into
124
+ * `elevated-prose` and `elevated-mechanism` — only the last of which spaces the
125
+ * next item (`core.mjs`). The fourth, the bare `elevated`, is what a state file
126
+ * written before that split still holds; it stays **readable** because a
127
+ * checkout that upgrades mid-run would otherwise refuse to select at all, and
128
+ * `core.mjs` reads it restrictively, so the legacy value rations exactly as it
129
+ * did when it was written.
130
+ */
131
+ const KNOWN_TIERS = ['normal', 'elevated', 'elevated-prose', 'elevated-mechanism'];
132
+
133
+ /**
134
+ * Per-checkout state, kept OUT of the composed config.
135
+ *
136
+ * The config is a template-layer file: a runtime value written into it is drift
137
+ * here and an upgrade conflict in a generated project. So the tier of the last
138
+ * closed item lands beside it, in a gitignored file.
139
+ *
140
+ * Missing is not an error — it means no item has closed yet, and selection
141
+ * proceeds with no spacing. Present-and-unparseable IS: the tier is then
142
+ * *unknown*, and the silent reading of unknown is `null`, which is the
143
+ * permissive value that lets a second elevated item straight through. That is
144
+ * the exact failure this state file exists to end, so it refuses instead.
145
+ */
146
+ export const loadState = (statePath) => {
147
+ let raw;
148
+ try {
149
+ raw = readFileSync(statePath, 'utf8');
150
+ } catch (error) {
151
+ // 🔴 ONLY a genuinely absent file means "nothing has closed yet". A bare
152
+ // `catch` here read EACCES, a directory at the path, and a file left by a
153
+ // container run under another uid as "absent" — each of them silently
154
+ // yielding the permissive `null`, with nothing printed. One failed write
155
+ // would have disabled the ration for good, and no adversary is needed.
156
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return {};
157
+ throw new Error(
158
+ `${statePath} exists but could not be read (${error?.code ?? 'unknown error'}), ` +
159
+ 'so the last completed tier is unknown. Refusing rather than continuing: ' +
160
+ 'unknown reads as "nothing has closed yet", which is the value that lets a ' +
161
+ 'second elevated item through.',
162
+ { cause: error },
163
+ );
164
+ }
165
+
166
+ let parsed;
167
+ try {
168
+ parsed = JSON.parse(raw);
169
+ } catch (error) {
170
+ throw new Error(
171
+ `${statePath} exists but is not valid JSON, so the last completed tier cannot ` +
172
+ `be read: ${String(error?.message ?? error).split('\n')[0]}. Delete the file ` +
173
+ 'if you are unsure — an absent state means "nothing has closed yet", which is ' +
174
+ 'safe; a tier that cannot be read would silently disable the elevated ration.',
175
+ { cause: error },
176
+ );
177
+ }
178
+
179
+ // Syntax is not shape. `"elevated"`, `["elevated"]`, `123` and `"Elevated"` all
180
+ // parse, and a state file shaped like any of them carries no tier at all — the
181
+ // dereference below answers `undefined`, which reads as "nothing has closed
182
+ // yet" and hands out an elevated item while the file looks like a working one.
183
+ // (`core.mjs` now holds on an unrecognised tier rather than shrugging, but a
184
+ // top-level array or number never reaches it as a tier in the first place.)
185
+ // A top-level `null` was worse still: it parsed, then threw a raw TypeError
186
+ // from the dereference, past the message that says the file is safe to delete.
187
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
188
+ throw new Error(
189
+ `${statePath} is valid JSON but not a state object, so the last completed tier ` +
190
+ 'cannot be read. Expected `{ "lastCompletedTier": "normal" | "elevated-prose" ' +
191
+ '| "elevated-mechanism" }` — the legacy `"elevated"` is still accepted, and ' +
192
+ 'held. ' +
193
+ 'Delete the file if you are unsure — an absent state means "nothing has ' +
194
+ 'closed yet", which is safe.',
195
+ );
196
+ }
197
+
198
+ // `null` is allowed and means exactly what an absent file means: nothing has
199
+ // closed yet. It is the honest spelling of an absence, and refusing it would
200
+ // protect nothing — deleting the file has the identical effect.
201
+ const tier = parsed.lastCompletedTier;
202
+ if (tier !== undefined && tier !== null && !KNOWN_TIERS.includes(tier)) {
203
+ throw new Error(
204
+ `${statePath} carries lastCompletedTier: ${JSON.stringify(tier)}, which is not ` +
205
+ `one of ${KNOWN_TIERS.map((t) => `"${t}"`).join(' | ')}. A tier outside the ` +
206
+ 'vocabulary rations on a value nobody wrote, so it would report a working ' +
207
+ 'state file while the spacing rule ran on a guess. Delete the file if you ' +
208
+ 'are unsure.',
209
+ );
210
+ }
211
+
212
+ return parsed;
213
+ };
214
+
63
215
  const parseArgs = (argv) => {
64
- const args = { command: argv[0] ?? 'next', json: false, config: null };
216
+ const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null };
65
217
  for (let i = 1; i < argv.length; i += 1) {
66
218
  if (argv[i] === '--json') args.json = true;
67
219
  else if (argv[i] === '--config') args.config = argv[++i];
220
+ else if (argv[i] === '--branch') args.branch = argv[++i];
68
221
  }
69
222
  return args;
70
223
  };
@@ -118,21 +271,193 @@ if (invokedDirectly()) {
118
271
  process.exit(1);
119
272
  }
120
273
 
274
+ // 🔴 **`gate-round` returns before the tracker is ever touched, and that is
275
+ // load-bearing rather than an optimisation.** `pr-ship` calls this once per
276
+ // round, and every other command below reaches the adapter — so routing this one
277
+ // through the normal path would spend a network call per gate round and, worse,
278
+ // would refuse to count a round while the tracker is unreachable. The counter is
279
+ // a local file and a local config; a Jira outage must not hand a run unlimited
280
+ // rounds.
281
+ if (args.command === 'gate-round') {
282
+ try {
283
+ // 🔴 Inside the `try`, and a review round is why. Left outside it, a rig whose
284
+ // `.claude/scripts/` predates this CLI printed a raw `ERR_MODULE_NOT_FOUND`
285
+ // and exited 1 — which `pr-ship` step 0 would have read as "rounds
286
+ // exhausted" and escalated a healthy item on. This file already records that
287
+ // exact lesson about `resolveAdapter` fifty lines below; the reason it
288
+ // repeated is that the lesson was written as prose and not as a test.
289
+ const { gateRoundVerdict } = await import('./core.mjs');
290
+ const { recordGateRound, gateRoundsPathFor } = await import('./gate-rounds.mjs');
291
+
292
+ const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
293
+ const config = loadConfig(configPath);
294
+ // An explicit `--config` keeps its counter beside it, so a run pointed at a
295
+ // temp config never touches this checkout's real counts.
296
+ const roundsPath = args.config
297
+ ? configPath.replace(/(\.json)?$/, '.gate-rounds.json')
298
+ : gateRoundsPathFor(projectRoot);
299
+
300
+ // 🔴 Validate the cap BEFORE counting. A review round measured what the other
301
+ // order costs: with `maxGateRounds: 0` every attempt recorded a round and then
302
+ // exited 1, while this command's own message told the caller to fix the cause
303
+ // and run step 0 again — so three attempts at a broken config left the branch
304
+ // at three rounds and the next honest call refused a healthy item. A failed
305
+ // run must not spend budget, and the message below must not have to lie about
306
+ // whether it did.
307
+ const verdictFor = (rounds) => gateRoundVerdict(rounds, config.options?.maxGateRounds);
308
+ verdictFor(0);
309
+
310
+ const { rounds } = recordGateRound({ branch: args.branch, roundsPath });
311
+ const verdict = verdictFor(rounds);
312
+
313
+ if (!verdict.exceeded) {
314
+ process.stdout.write(
315
+ `gate round ${verdict.rounds} of ${verdict.max} on ${args.branch}.\n` +
316
+ (verdict.rounds === verdict.max
317
+ ? ' This is the last round this branch gets. If it holds again, the item ' +
318
+ 'is escalated rather than re-reviewed.\n'
319
+ : ''),
320
+ );
321
+ process.exit(0);
322
+ }
323
+
324
+ process.stderr.write(
325
+ `GATE ROUNDS EXHAUSTED — ${verdict.rounds} rounds on ${args.branch}, cap is ` +
326
+ `${verdict.max}: ${verdict.stop}.\n` +
327
+ ' 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' +
331
+ ' Raising the cap to get one more pass on THIS item is the move this ' +
332
+ 'refusal exists to prevent.\n',
333
+ );
334
+ process.exit(2);
335
+ } catch (error) {
336
+ // 🔴 Exit 1, never 2, and the message says so. Exit 2 means one thing only —
337
+ // the rounds are spent — because `pr-ship` acts on it by ending the task. A
338
+ // broken config, an unreadable counter or a detached checkout must not be
339
+ // read as a converging-failure stall.
340
+ process.stderr.write(
341
+ `gate-round could not run: ${error.message}\n` +
342
+ ' 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',
345
+ );
346
+ process.exit(1);
347
+ }
348
+ }
349
+
350
+ // A missing module is NOT read as "no state". A run that could not read its
351
+ // state would silently lose the escalation streak and the regression verdict
352
+ // — the two conditions that exist to stop it — so this fails closed, into a
353
+ // message the operator can act on.
354
+ //
355
+ // 🔴 **Loaded unconditionally, and ahead of the adapter, because the adapters
356
+ // import it statically.** An earlier version loaded it dynamically and only
357
+ // behind `RIG_RUN_DIR`, reasoning that a rig carrying an older
358
+ // `.claude/scripts/` should still get a readable message. That reasoning was
359
+ // sound and the code did not deliver it: all three adapters now
360
+ // `import { recordEscalation } from '../run-state.mjs'`, so a rig missing the
361
+ // module died inside `resolveAdapter` below and the operator saw a raw
362
+ // `Cannot find module` naming an adapter. The refusal was unreachable — and
363
+ // its test passed anyway, because the module's own filename satisfied the
364
+ // pattern it matched on. The load lives here; the READ stays behind the
365
+ // declaration, because a session with no run directory has no state to read
366
+ // and must keep working exactly as before.
367
+ let readState;
368
+ let stopInputsOf;
369
+ try {
370
+ ({ readState, stopInputsOf } = await import('../run-state.mjs'));
371
+ } catch (error) {
372
+ process.stderr.write(
373
+ `run state: ${error.message}\n` +
374
+ ' the run state module could not be loaded, so the escalation streak and the ' +
375
+ 'deploy verdict cannot be read. Those are stop conditions, and a run that ' +
376
+ 'cannot read them must not select work.\n',
377
+ );
378
+ process.exit(1);
379
+ }
380
+
121
381
  let config;
382
+ let state;
122
383
  let adapter;
384
+ // Declared out here because the plan path is derived from it further down.
385
+ let configPath;
123
386
  try {
124
- config = loadConfig(args.config ?? join(projectRoot, '.claude', 'queue.json'));
387
+ configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
388
+ config = loadConfig(configPath);
389
+ // An explicit `--config` keeps its own state beside it, verbatim: a run
390
+ // pointed at a temp config must not pick up this checkout's real tier.
391
+ // Without one, the state comes from the MAIN checkout, so a selection made
392
+ // inside a worktree sees what a close made anywhere recorded.
393
+ state = loadState(
394
+ args.config
395
+ ? statePathFor(configPath)
396
+ : join(mainCheckoutRoot(projectRoot), '.claude', 'queue.state.json'),
397
+ );
125
398
  adapter = await resolveAdapter(config.adapter ?? 'plan-md');
126
399
  } catch (error) {
127
400
  process.stderr.write(`${error.message}\n`);
128
401
  process.exit(1);
129
402
  }
130
403
 
404
+ // 🔴 The run-level stop is decided BEFORE the queue is read, because those
405
+ // conditions are not about the queue. A run that has escalated twice in a row
406
+ // has hit a systemic wall, and a run that deployed a regression must start no
407
+ // new work on top of it — asking the tracker first would spend a network call
408
+ // and, worse, would report `queue-unreadable` for an unreachable tracker when
409
+ // the truthful answer is that this run was already over.
410
+ //
411
+ // The values come from the run's own state file rather than from the
412
+ // session's memory, which is what three of `stopConditionOf`'s parameters
413
+ // were waiting for: every branch was live, and nothing ever supplied them.
414
+ //
415
+ // The fourth, `killSwitch`, is still not passed here and that is deliberate —
416
+ // `guard-bash` denies the merge at the tool layer and preflight reads the flag,
417
+ // so a second answer to "is the brake on" would be the disagreement
418
+ // `invariants.md` forbids. Selection therefore does NOT stop on the brake, and
419
+ // the `loop` skill tells the run to keep checking it between tasks.
420
+ //
421
+ const runState = process.env.RIG_RUN_DIR ? readState(process.env.RIG_RUN_DIR) : {};
422
+ let stopInputs;
423
+ try {
424
+ // Read through the module that owns the vocabulary, never field by field
425
+ // here: a value the reader cannot interpret must not arrive as "no stop",
426
+ // and deciding that at the call site is how the two halves drift.
427
+ stopInputs = stopInputsOf(runState);
428
+ } catch (error) {
429
+ process.stderr.write(
430
+ `${error.message}\n the run declared a state file this cannot act on, so no ` +
431
+ 'item is selected. Stop conditions read from it, and an unreadable one is ' +
432
+ 'not the same as an absent one.\n',
433
+ );
434
+ process.exit(1);
435
+ }
436
+ const runStop = stopConditionOf({
437
+ // `candidates: 1` says "not the empty-queue case" — the queue has not been
438
+ // read yet and must not be reported on here. Only the conditions that
439
+ // outrank it can fire from this call.
440
+ candidates: 1,
441
+ ...stopInputs,
442
+ });
443
+ if (runStop) {
444
+ // `renderNext`, not a second copy of its format — an operator reading two
445
+ // differently-worded stop lines has no way to know they came from one rule.
446
+ process.stdout.write(
447
+ args.json ? `${JSON.stringify({ stop: runStop }, null, 2)}\n` : renderNext(null, runStop),
448
+ );
449
+ // The exit code follows `stop.success`, exactly as the empty-queue path
450
+ // does. A budget stop is a clean end of session — reporting it as a failure
451
+ // would tell a wrapper that the run broke, and `--json` already says
452
+ // `"success": true` right beside the code that contradicted it.
453
+ process.exit(runStop.success ? 0 : 1);
454
+ }
455
+
131
456
  let tickets;
132
457
  try {
133
458
  // Awaited so an adapter may be async (jira) or plain (plan-md, github-issues)
134
459
  // without the CLI caring which.
135
- tickets = await adapter.listEligible(config.options ?? {});
460
+ tickets = await adapter.listEligible(optionsWithPlanPath(config.options, configPath));
136
461
  } catch (error) {
137
462
  // Never fall back to memory or to a stale copy for a queue.
138
463
  const stop = stopConditionOf({ queueReadable: false });
@@ -157,16 +482,96 @@ if (invokedDirectly()) {
157
482
  }
158
483
 
159
484
  const result = selectNext(tickets, {
160
- lastCompletedTier: config.lastCompletedTier ?? null,
161
- triggersFired: config.triggersFired ?? null,
485
+ // The state file wins: it is what a close actually recorded. A tier left in
486
+ // the config is a hand-written hint at best, and it is the composed file, so
487
+ // it cannot be the live value.
488
+ lastCompletedTier: state.lastCompletedTier ?? config.lastCompletedTier ?? null,
489
+ // Same precedence, for the same reason as the tier above: `queue.json` is
490
+ // composed by the sync script and drift-checked, so declaring a trigger
491
+ // fired there means editing a generated file — and the declaration is a
492
+ // fact about THIS run, not about the rig's configuration.
493
+ //
494
+ // The config keeps working as a fallback rather than being dropped: nothing
495
+ // in this repository or its templates ever writes the key, but a rig owner
496
+ // who hand-added one would otherwise find their auto-trigger items silently
497
+ // unselectable, and an item that stops being offered announces itself
498
+ // nowhere.
499
+ //
500
+ // Replacement, not a merge: a per-key merge would make a stale config entry
501
+ // impossible to retract, so "not this time" would again require editing the
502
+ // generated file this move exists to get out of.
503
+ triggersFired: runState.triggersFired ?? config.triggersFired ?? null,
162
504
  });
163
- const stop = result.ticket ? null : stopConditionOf({ candidates: 0 });
505
+ // The skipped records travel with the count: without them "nothing left" and
506
+ // "everything left is held back" both print as an empty queue, and only one of
507
+ // the two means the queue needs refilling.
508
+ const stop = result.ticket
509
+ ? null
510
+ : stopConditionOf({ candidates: 0, skipped: result.skipped });
164
511
 
165
512
  if (args.command === 'list') {
166
513
  process.stdout.write(`${JSON.stringify({ tickets, ...result }, null, 2)}\n`);
167
514
  process.exit(0);
168
515
  }
169
516
 
517
+ // 🔴 A journal nothing calls records nothing. Selection is a gate — it decides
518
+ // what the run works on and why every other item was passed over — so this is
519
+ // the call site the run journal ships with, rather than a writer nobody
520
+ // invokes.
521
+ //
522
+ // It writes only when the run DECLARED its directory. Inventing one here would
523
+ // make this CLI a second owner of the `.claude/runs/<run-id>/` convention, and
524
+ // a default derived from `projectRoot` would land the trace inside the very
525
+ // template tree this repository publishes.
526
+ const runDir = process.env.RIG_RUN_DIR;
527
+ if (runDir) {
528
+ let journal = null;
529
+ try {
530
+ journal = await import('../run-journal.mjs');
531
+ journal.recordDecision({
532
+ runDir,
533
+ gate: 'item-selection',
534
+ verdict: result.ticket ? `taken ${result.ticket.id}` : `stopped ${stop.kind}`,
535
+ why: result.ticket ? result.ticket.title : stop.why,
536
+ // The edge is where the clock is read: the journal itself takes `now` as
537
+ // an argument, which is what keeps its records reproducible.
538
+ now: new Date().toISOString(),
539
+ });
540
+ } catch (error) {
541
+ // 🔴 Two failures wearing one face, and treating them alike was a defect
542
+ // this gate caught. The journal asks the module which one this is — never
543
+ // the message text, which would put the decision in two files and let them
544
+ // drift the day someone improves the wording.
545
+ // `?.` would guard a MISSING module and not a missing export. A rig can
546
+ // carry a run journal older than this CLI — `upgrade` leaves a
547
+ // locally-edited copy beside a new caller — and calling through to an
548
+ // absent export crashed the CLI inside its own error handler: a raw stack
549
+ // trace instead of the failure it was reporting.
550
+ const classify = journal?.isTraceExhausted;
551
+ if (typeof classify === 'function' && classify(error)) {
552
+ // The trace is over and the work is not. This journal cannot accept
553
+ // another record — it is append-only and every write re-reads it — so
554
+ // exiting here would make one collision between two sessions leave the
555
+ // queue unselectable FOREVER. Loud on stderr, and the selection still
556
+ // goes out on stdout, because the queue was never the thing that failed.
557
+ process.stderr.write(
558
+ `run journal: ${error.message}\n` +
559
+ ` the selection below was NOT recorded in ${runDir}. This run's trace ends ` +
560
+ 'here; the queue is fine, and a new run needs a new run directory.\n',
561
+ );
562
+ } else {
563
+ // The other half: the declaration is empty, its directory is not there,
564
+ // the path is not a directory, or the journal module is missing. Each is
565
+ // the run pointing at something that was never set up — one `mkdir` or
566
+ // one corrected variable away — and continuing would produce a run with
567
+ // no trace at all, so this half does stop the selection. (A directory
568
+ // deleted mid-run lands here too, and the same `mkdir` restores it.)
569
+ process.stderr.write(`run journal: ${error.message}\n`);
570
+ process.exit(1);
571
+ }
572
+ }
573
+ }
574
+
170
575
  process.stdout.write(
171
576
  args.json
172
577
  ? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop }, null, 2)}\n`
@@ -18,6 +18,7 @@
18
18
  // { "adapter": "jira", "options": { "project": "ABC" } }
19
19
  // { "adapter": "jira", "options": { "jql": "project = ABC AND ..." } }
20
20
  import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
21
+ import { recordEscalation } from '../run-state.mjs';
21
22
 
22
23
  export const name = 'jira';
23
24
 
@@ -91,7 +92,13 @@ export const toTicket = (issue) => {
91
92
  url: issue.self ?? null,
92
93
  state: category === 'done' ? 'closed' : category === 'indeterminate' ? 'in-progress' : 'open',
93
94
  labels,
94
- tier: labels.includes('human-review') ? 'elevated' : 'normal',
95
+ // The marker is `elevated`, the same word the plan-md adapter reads out of an
96
+ // `[elevated]` line — one name for one fact, so `core.mjs` rations the same
97
+ // way whichever tracker the item came from. It is deliberately NOT
98
+ // `human-review`: on a Jira board that is a workflow label meaning "a human
99
+ // is looking at it", which is a different claim entirely, and reading it as
100
+ // the tier rations the queue on a signal that means something else.
101
+ tier: labels.includes('elevated') ? 'elevated' : 'normal',
95
102
  blockedBy,
96
103
  blocks,
97
104
  priority: PRIORITY[String(fields.priority?.name ?? '').toLowerCase()] ?? 999,
@@ -108,17 +115,38 @@ export const toTicket = (issue) => {
108
115
  };
109
116
  };
110
117
 
118
+ /**
119
+ * The lanes selection never takes, named once so the query and the post-filter
120
+ * below cannot drift apart (`invariants.md`: one mechanism, one implementation).
121
+ *
122
+ * - `triage` is the loop's own filed proposals. Excluding them only by the
123
+ * absence of a ready marker means one careless hand adding that marker closes
124
+ * the loop's feedback path into its own input.
125
+ * - `operator-queue` is the owner's lane. An item sitting there is work a HUMAN
126
+ * has taken, so the loop picking one up is two sessions on one task.
127
+ */
128
+ export const EXCLUDED_LABELS = ['triage', 'operator-queue'];
129
+
111
130
  /**
112
131
  * The selection query.
113
132
  *
114
- * `labels != triage` is belt and braces, and it is deliberate: excluding a
115
- * proposal only by the absence of a ready marker means one careless hand adding
116
- * that marker closes the loop's feedback path into its own input. Excluded
117
- * explicitly, an item carrying BOTH markers is still unselectable.
133
+ * Both lanes are excluded here by label, in the adapter's own filter rather than
134
+ * in an `options.jql` a reinstall would drop.
118
135
  *
119
136
  * ⚠ JQL gotcha that makes the parenthesised form necessary: `labels != x` does
120
137
  * **not** match issues whose labels field is empty. Without `OR labels IS EMPTY`
121
138
  * this query would silently skip every unlabelled item — which is most of them.
139
+ * Both exclusions therefore live INSIDE that one group; a second, unguarded
140
+ * exclusion anywhere in the query reintroduces the hole while still reading right.
141
+ *
142
+ * ⚠ And the group stays FLAT — `labels != a AND labels != b OR labels IS EMPTY`,
143
+ * not `labels NOT IN (a, b) OR …`. Both forms are valid JQL and both were run
144
+ * against a live Jira, returning identical result sets — so this is a
145
+ * readability choice with no behaviour attached, and switching back would be
146
+ * behaviour-neutral. Flat wins because the `NOT IN` form's nested parentheses
147
+ * hide the `IS EMPTY` guard from any reader — human or test — that matches
148
+ * innermost groups. `AND` binds tighter than `OR` in JQL, so the flat form means
149
+ * `(a AND b) OR empty`, which is the intent.
122
150
  */
123
151
  export const buildJql = ({ project = null, jql = null } = {}) => {
124
152
  if (jql) return jql;
@@ -129,9 +157,10 @@ export const buildJql = ({ project = null, jql = null } = {}) => {
129
157
  'is worse than refusing to start.',
130
158
  );
131
159
  }
160
+ const excluded = EXCLUDED_LABELS.map((label) => `labels != "${label}"`).join(' AND ');
132
161
  return (
133
162
  `project = ${project} AND statusCategory != Done ` +
134
- 'AND (labels != triage OR labels IS EMPTY) ' +
163
+ `AND (${excluded} OR labels IS EMPTY) ` +
135
164
  'ORDER BY priority DESC, created ASC'
136
165
  );
137
166
  };
@@ -179,7 +208,13 @@ const request = async (route, { method = 'GET', body = null, env = process.env }
179
208
  // `description` is requested because the triage dedupe matches the fingerprint
180
209
  // inside it. Without it the dedupe silently never matched, so every "queue empty"
181
210
  // stop filed a fresh issue instead of incrementing the one already there.
182
- const FIELDS = 'summary,status,labels,priority,created,issuelinks,description';
211
+ //
212
+ // A LIST, not a comma-joined string: the replacement endpoint takes its arguments
213
+ // in a JSON body, where `fields` is an array. Measured, because the failure mode
214
+ // decides how hard this is to notice: sending the joined form the retired query
215
+ // parameter wanted answers `400 Invalid request payload`, not a 200 with empty
216
+ // fields. It fails loudly, so a wrong value here cannot sit undetected.
217
+ const FIELDS = ['summary', 'status', 'labels', 'priority', 'created', 'issuelinks', 'description'];
183
218
 
184
219
  // --- the adapter contract ------------------------------------------------------
185
220
 
@@ -198,15 +233,39 @@ export const listEligible = async ({
198
233
  // `issues` is the offline seam: the mapping is pure, so every shape it has to
199
234
  // handle is testable without a network or a credential.
200
235
  const response = issues ? { issues } : await search({ project, jql, limit, env });
201
- return response.issues.map(toTicket).filter((ticket) => ticket.state !== 'closed');
236
+ return (
237
+ response.issues
238
+ .map(toTicket)
239
+ .filter((ticket) => ticket.state !== 'closed')
240
+ // Deliberately a SECOND enforcement of the same list the query already
241
+ // carries, and only here: `core.mjs` drops `triage` itself, with its own
242
+ // stated reason, but nothing downstream knows about `operator-queue`. This
243
+ // filter is what still holds when the query is bypassed — an `options.jql`
244
+ // override, or a board whose labels were renamed. Both read EXCLUDED_LABELS,
245
+ // so the two can disagree only by someone editing one of them.
246
+ .filter((ticket) => !ticket.labels.some((label) => EXCLUDED_LABELS.includes(label)))
247
+ );
202
248
  };
203
249
 
250
+ /**
251
+ * 🔴 `POST /rest/api/3/search/jql`, never `GET /rest/api/3/search` — the latter
252
+ * was retired by Atlassian and answers `410 Gone`. Measured against a live Jira,
253
+ * with the same credential answering `200` on `/rest/api/3/myself`, so it is the
254
+ * path and not the auth. Both searching call sites (`listEligible` and the
255
+ * `proposeTriage` dedupe) come through here, which is why one fix covers both.
256
+ *
257
+ * Not handled here on purpose: the response also carries `nextPageToken` for
258
+ * cursor pagination, so a board with more open issues than `limit` still loses
259
+ * its tail — as does a retry policy and a request timeout. Those belong together
260
+ * in one change; half a pagination interface with nothing testing it is worse
261
+ * than none.
262
+ */
204
263
  export const search = async ({ project = null, jql = null, limit = 100, env = process.env } = {}) =>
205
- request(
206
- `/rest/api/3/search?jql=${encodeURIComponent(buildJql({ project, jql }))}` +
207
- `&maxResults=${limit}&fields=${FIELDS}`,
208
- { env },
209
- );
264
+ request('/rest/api/3/search/jql', {
265
+ method: 'POST',
266
+ body: { jql: buildJql({ project, jql }), maxResults: limit, fields: FIELDS },
267
+ env,
268
+ });
210
269
 
211
270
  export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
212
271
 
@@ -272,6 +331,9 @@ export const escalate = async (ticket, diagnosis, { env = process.env } = {}) =>
272
331
  body: { update: { labels: [{ add: 'escalated' }] } },
273
332
  env,
274
333
  });
334
+ // Counted through the one recorder, never a counter of this adapter's own —
335
+ // "twice in a row" has to mean the same thing on every tracker.
336
+ recordEscalation(env.RIG_RUN_DIR);
275
337
  return { ok: true };
276
338
  };
277
339