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
@@ -12,24 +12,32 @@
12
12
  // has real dependencies, move to an adapter whose tracker can express them
13
13
  // (`github-issues`). Ordering the list by hand is not a dependency graph.
14
14
  import { readFileSync, writeFileSync } from 'node:fs';
15
- import { fingerprintOf, validateProposal } from './core.mjs';
15
+ import { duplicateOf, fingerprintOf, validateProposal, lifecycleOf } from './core.mjs';
16
+ import { withAsOf } from './as-of.mjs';
17
+ import { recordEscalation } from '../run-state.mjs';
16
18
 
17
19
  export const name = 'plan-md';
18
20
 
19
21
  const AGENT_QUEUE = /^##\s+Agent queue\s*$/i;
22
+ const OPERATOR_QUEUE = /^##\s+Operator queue\s*$/i;
20
23
  const ANY_HEADING = /^##\s+/;
21
24
 
22
25
  /**
23
- * Locate the Agent queue by LINE RANGE, and say whether it was found at all.
26
+ * Locate a `## ` section by LINE RANGE, and say whether it was found at all.
24
27
  *
25
28
  * "No such heading" and "heading present, nothing under it" used to collapse into
26
29
  * the same empty string, so a renamed heading or a bad merge read as a legitimately
27
30
  * empty queue — reported as a successful end of session. They are different
28
31
  * answers and only one of them is good news.
32
+ *
33
+ * Parameterised by heading because there are now two callers and the alternative
34
+ * was a second copy of this scan — and two implementations of one mechanism
35
+ * disagree eventually, with the unwatched one being the wrong one
36
+ * (`invariants.md`). The Agent-queue caller keeps its own name below.
29
37
  */
30
- export const readQueue = (plan) => {
38
+ const sectionRange = (plan, heading) => {
31
39
  const lines = String(plan ?? '').split('\n');
32
- const start = lines.findIndex((line) => AGENT_QUEUE.test(line));
40
+ const start = lines.findIndex((line) => heading.test(line));
33
41
  if (start === -1) return { found: false, lines, start: -1, end: -1 };
34
42
  let end = lines.length;
35
43
  for (let i = start + 1; i < lines.length; i += 1) {
@@ -41,6 +49,8 @@ export const readQueue = (plan) => {
41
49
  return { found: true, lines, start: start + 1, end };
42
50
  };
43
51
 
52
+ export const readQueue = (plan) => sectionRange(plan, AGENT_QUEUE);
53
+
44
54
  /**
45
55
  * Inline markers, so a flat list can still carry the few facts selection needs.
46
56
  * Anything unmarked is a normal, unconditional item — which is the common case
@@ -51,6 +61,17 @@ const MARKERS = {
51
61
  triage: /\[triage\]/i,
52
62
  triggerAuto: /\[trigger-auto\]/i,
53
63
  triggerHuman: /\[trigger-human\]/i,
64
+ // `[owner:<name>]` — the repository the item belongs to (AR-132), the same
65
+ // fact the tracker adapters read out of an `owner-<name>` label. Case-exact,
66
+ // because the name is compared exactly to `options.owner`.
67
+ owner: /\[owner:([^\]\s]+)\]/,
68
+ // AR-144: the lifecycle vocabulary and the scheduling flag, the same words the
69
+ // tracker adapters read as labels. Case-insensitive, as the tier and trigger
70
+ // markers are (the owner marker is case-exact, because it is compared to a name).
71
+ keepCore: /\[keep-core\]/i,
72
+ reScope: /\[re-scope\]/i,
73
+ obsolete: /\[obsolete\]/i,
74
+ parked: /\[parked\]/i,
54
75
  };
55
76
 
56
77
  /**
@@ -82,7 +103,10 @@ export const parsePlan = (plan) => {
82
103
  if (!match) continue;
83
104
  const raw = match[1];
84
105
  const title = raw
85
- .replace(/\[(elevated|triage|trigger-auto|trigger-human)\]/gi, '')
106
+ .replace(
107
+ /\[(elevated|triage|trigger-auto|trigger-human|keep-core|re-scope|obsolete|parked|owner:[^\]\s]+)\]/gi,
108
+ '',
109
+ )
86
110
  .replace(/\s+/g, ' ')
87
111
  .trim();
88
112
  items.push({
@@ -105,12 +129,29 @@ export const parsePlan = (plan) => {
105
129
  blocks: [],
106
130
  priority: items.length,
107
131
  createdAt: null,
132
+ // A flat list carries no marker at all, so revalidation at SELECT records
133
+ // `changed: null` for it — a blind spot, never an "unchanged".
134
+ updatedAt: null,
108
135
  triage: MARKERS.triage.test(raw),
109
136
  trigger: MARKERS.triggerAuto.test(raw)
110
137
  ? 'auto'
111
138
  : MARKERS.triggerHuman.test(raw)
112
139
  ? 'human'
113
140
  : null,
141
+ owner: MARKERS.owner.exec(raw)?.[1] ?? null,
142
+ // The markers present, handed to the one precedence rule (`core.mjs` ›
143
+ // lifecycleOf) rather than re-deriving it here. Hygiene cannot report a
144
+ // contradiction on this adapter: a flat list carries no labels for it to read.
145
+ ...lifecycleOf(
146
+ [
147
+ ['keep-core', MARKERS.keepCore],
148
+ ['re-scope', MARKERS.reScope],
149
+ ['obsolete', MARKERS.obsolete],
150
+ ['parked', MARKERS.parked],
151
+ ]
152
+ .filter(([, marker]) => marker.test(raw))
153
+ .map(([label]) => label),
154
+ ),
114
155
  });
115
156
  }
116
157
  return items;
@@ -134,6 +175,12 @@ export const closeInPlan = (plan, id) => {
134
175
  return lines.join('\n');
135
176
  };
136
177
 
178
+ // 🔴 The bare default is cwd-relative on purpose. This module is imported
179
+ // directly as well as driven by the CLI, and a direct caller's cwd is the only
180
+ // project it can mean. The CLI, which knows where its config lives, supplies an
181
+ // absolute `planPath` instead — see `optionsWithPlanPath` in `index.mjs`.
182
+ // Resolving this default from the module's own location would point every call at
183
+ // the rig's own tree.
137
184
  const planPath = (options) => options?.planPath ?? 'PLAN.md';
138
185
  const readPlan = (options) => readFileSync(planPath(options), 'utf8');
139
186
 
@@ -175,27 +222,44 @@ export const claim = (ticket) => ({
175
222
  'its own worktree and say so in the journal.',
176
223
  });
177
224
 
225
+ /** One item by position; a flat list has no closed state, so an absent line is `null`. */
226
+ export const find = (id, options = {}) =>
227
+ parsePlan(readPlan(options)).find((ticket) => ticket.id === String(id)) ?? null;
228
+
178
229
  export const close = (ticket, { prUrl = null, planPath: p } = {}) => {
179
230
  const file = p ?? 'PLAN.md';
180
- writeFileSync(file, closeInPlan(readFileSync(file, 'utf8'), ticket.id));
181
- return { ok: true, prUrl };
231
+ const before = readFileSync(file, 'utf8');
232
+ const present = parsePlan(before).some((item) => item.id === String(ticket.id));
233
+ writeFileSync(file, closeInPlan(before, ticket.id));
234
+ // `transitioned` means the line was there and is now gone — an id the plan
235
+ // never carried closed nothing, and saying otherwise would publish a close
236
+ // that changed no state (AR-135).
237
+ const gone = !parsePlan(readFileSync(file, 'utf8')).some((item) => item.id === String(ticket.id));
238
+ return { ok: true, prUrl, transitioned: present && gone };
182
239
  };
183
240
 
184
241
  /** A flat list has no comment thread; the journal is where this lands. */
185
242
  export const comment = (ticket, body) => ({
186
243
  ok: false,
187
244
  journalInstead: `${ticket.id} — ${body}`,
188
- why: 'PLAN.md has no comment thread: write it as a journal entry in the same file.',
245
+ why: 'PLAN.md has no comment thread: write it as a journal entry in journal/YYYY-MM.md for the current month.',
189
246
  });
190
247
 
191
- export const escalate = (ticket, diagnosis) => ({
192
- ok: false,
193
- journalInstead: `escalated ${ticket.id}: ${diagnosis}`,
194
- why:
195
- 'PLAN.md has no per-item state, so an escalated item cannot be marked ' +
196
- 'unselectable. Move it to the Operator queue in the same edit, or the next ' +
197
- 'run picks it straight back up.',
198
- });
248
+ export const escalate = (ticket, diagnosis, { env = process.env } = {}) => {
249
+ // The count is recorded even though this adapter cannot mark the item: the two
250
+ // are different facts. "This queue has no per-item state" is a plan-md limit;
251
+ // "two tasks in a row hit a wall" is about the run, and it must stop the run
252
+ // whichever tracker it is reading.
253
+ recordEscalation(env.RIG_RUN_DIR);
254
+ return {
255
+ ok: false,
256
+ journalInstead: `escalated ${ticket.id}: ${diagnosis}`,
257
+ why:
258
+ 'PLAN.md has no per-item state, so an escalated item cannot be marked ' +
259
+ 'unselectable. Move it to the Operator queue in the same edit, or the next ' +
260
+ 'run picks it straight back up.',
261
+ };
262
+ };
199
263
 
200
264
  /**
201
265
  * A proposal, forced into triage.
@@ -220,8 +284,10 @@ export const triageItemFor = (proposal) => {
220
284
  `- **part to change** — ${proposal.part}`,
221
285
  `- **proposed change** — ${proposal.change}`,
222
286
  `- **how the next run proves it** — ${proposal.proof}`,
287
+ ...(proposal.measured ? [`- **measured** — ${proposal.measured}`, `- **inferred** — ${proposal.inferred}`] : []),
223
288
  '',
224
289
  `fingerprint: ${fingerprint}`,
290
+ ...(proposal.asOf ? [`asOf: ${proposal.asOf}`] : []),
225
291
  '',
226
292
  'The loop proposes; the owner patches. Self-applying a change to its own',
227
293
  'rulebook is how an unattended run drifts irreversibly.',
@@ -232,14 +298,212 @@ export const triageItemFor = (proposal) => {
232
298
  };
233
299
  };
234
300
 
235
- export const proposeTriage = (proposal, { planPath: p } = {}) => {
301
+ /**
302
+ * The trailing fields this adapter writes, and the only thing it will ever edit.
303
+ *
304
+ * 🔴 Both patterns are anchored to the END of the line, and that is the whole
305
+ * point. An unanchored `seen ×(\d+)` matched the FIRST such token in the bullet —
306
+ * and a proposal is free text, so a proposal *about* the counter ("render
307
+ * `seen ×2` as twice") put one there itself. Observed: the count read back as 3
308
+ * then 4 while the file still said ×1, and the increment rewrote the number
309
+ * inside the finding — silently altering the human-facing record of what the loop
310
+ * found, which `core.mjs` forbids for queue metadata in as many words.
311
+ */
312
+ const TAIL = /·\sfingerprint: `([^`]*)`\s·\sseen ×(\d+)\s*$/;
313
+ const COUNT = /seen ×(\d+)\s*$/;
314
+
315
+ /**
316
+ * Fold a field onto one line.
317
+ *
318
+ * 🔴 A bullet is spliced into the file as a single array element and joined with
319
+ * newlines, so a `\n` inside a proposal's own prose became REAL lines in the
320
+ * plan. Two things broke at once: the `fingerprint … seen ×N` tail landed on a
321
+ * different physical line from the bullet, which defeated dedup permanently; and
322
+ * a pasted `## Agent queue` line became a heading — on a plan whose Operator
323
+ * queue comes first, `selectNext` then handed the run its own proposal as
324
+ * ordinary work, with the `[triage]` marker stranded on the line above.
325
+ *
326
+ * Not an exotic input: `autonomy.md` tells an escalating run to report "verbatim
327
+ * errors, not summaries", so a stack trace in `finding` is the expected case.
328
+ * The text survives; its layout does not — and "layout" includes interior runs
329
+ * of tabs and spaces, which collapse to one space each. `parsePlan` normalises
330
+ * titles the same way, so this is the house convention rather than a special
331
+ * case, but it is wider than "newlines are folded" and worth knowing before
332
+ * relying on a field surviving byte-for-byte.
333
+ *
334
+ * 🔴 One quantifier over one class, because the obvious spelling was quadratic:
335
+ * putting an unbounded whitespace quantifier in front of a line-terminator class
336
+ * — which is a SUBSET of whitespace — makes the engine re-split a whitespace run
337
+ * at every offset. Measured at 6.7s on 65k spaces, 103s on 256k, and 0s the
338
+ * moment a newline appears after the run, which is why every injection fixture
339
+ * missed it. That is the fourth time this shape has landed in this queue layer;
340
+ * `core.mjs` already says remembering it once was not enough.
341
+ *
342
+ * JS `\s` covers U+2028 and U+2029 as well as the ASCII set, so those fold too.
343
+ * U+0085 and NUL do not, and they survive verbatim — harmless, because
344
+ * `split('\n')` is this adapter's whole line model, but a renderer may show them
345
+ * as breaks.
346
+ *
347
+ * Exported for its cost guard. Measuring the fold through `proposeTriage` meant
348
+ * measuring the plan file it rewrites per call — which grew as the measurement
349
+ * ran, and read DEARER on a faster machine. The guard measures this function
350
+ * directly instead; nothing outside the test imports it.
351
+ */
352
+ export const oneLine = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
353
+
354
+ /**
355
+ * The proposal as ONE Operator-queue bullet: a flat list cannot hold a body.
356
+ *
357
+ * The counter goes LAST, after the fingerprint, so the anchors above land on the
358
+ * adapter's own fields whatever the proposal's prose happens to contain.
359
+ */
360
+ const bulletFor = (item, proposal, seen) =>
361
+ `- **${oneLine(item.title)}** — finding: ${oneLine(proposal.finding)} · ` +
362
+ `part: ${oneLine(proposal.part)} · proof: ${oneLine(proposal.proof)} · ` +
363
+ `${proposal.measured ? `measured: ${oneLine(proposal.measured)} · inferred: ${oneLine(proposal.inferred)} · ` : ''}` +
364
+ `${proposal.asOf ? `asOf: ${proposal.asOf} · ` : ''}` +
365
+ `fingerprint: \`${item.fingerprint}\` · seen ×${seen}`;
366
+
367
+ /**
368
+ * The proposals on file, as `{ id, body }` — every Operator-queue line carrying
369
+ * this adapter's trailing field shape, the whole line as the body so `asOfOf`
370
+ * and `citedPathsOf` can read it. An absent Operator queue lists nothing.
371
+ */
372
+ export const listProposals = ({ planPath: p } = {}) => {
373
+ const plan = readFileSync(p ?? 'PLAN.md', 'utf8');
374
+ const { found, lines, start, end } = sectionRange(plan, OPERATOR_QUEUE);
375
+ if (!found) return [];
376
+ const proposals = [];
377
+ for (let i = start; i < end; i += 1) {
378
+ if (TAIL.test(lines[i])) proposals.push({ id: String(i), body: lines[i] });
379
+ }
380
+ return proposals;
381
+ };
382
+
383
+ /**
384
+ * File a proposal into the **Operator queue** — never the Agent queue.
385
+ *
386
+ * This adapter used to return `ok: false` and instruct a human to file it by
387
+ * hand, which is a channel only in form: over the loop's entire history that
388
+ * instruction produced one triage ticket. A channel nobody walks is where
389
+ * findings go to die, and the findings it loses are the ones about the loop
390
+ * itself.
391
+ *
392
+ * 🔴 Two invariants, and the writing is what puts them at risk:
393
+ * - it appends **only** inside the Operator queue's line range, so the agent can
394
+ * never file itself selectable work;
395
+ * - the `[triage]` marker rides in the title, so even a proposal moved under the
396
+ * wrong heading by hand is still refused by `selectionOf`.
397
+ *
398
+ * Deduping is the shared `duplicateOf` from `core.mjs`, so the three adapters
399
+ * cannot drift into three different answers about what counts as the same
400
+ * proposal. Twenty "queue empty" stops must leave ONE proposal counted twenty
401
+ * times, not twenty proposals.
402
+ *
403
+ * Bounded by construction: work is linear in the section's length, no recursion,
404
+ * no rescanning, and `validateProposal` throws before the file is read — an
405
+ * incomplete proposal cannot leave a partial write behind.
406
+ *
407
+ * One difference from the other two adapters, deliberate and worth knowing:
408
+ * `incremented` is the **fingerprint** here, where `github-issues` returns an
409
+ * issue number and `jira` an issue key. A flat list has no per-item identifier
410
+ * that survives an edit — the line index shifts the moment anything above it
411
+ * changes — and the fingerprint is the only stable name this adapter can give.
412
+ *
413
+ * What this cannot see, stated so nobody relies on cover it lacks:
414
+ * - a filed bullet whose trailing `fingerprint … seen ×N` fields a human has
415
+ * edited away no longer matches, so the next call files a second bullet
416
+ * rather than incrementing. That is the deliberate direction to fail in: a
417
+ * duplicate is visible and cheap, editing somebody else's line is not;
418
+ * - a HUMAN line ending in this adapter's exact `· fingerprint: \`x\` · seen ×N`
419
+ * shape is indistinguishable from one the adapter wrote, and gets incremented
420
+ * instead of the proposal being filed. Reachable only by hand — a proposal's
421
+ * own prose cannot trigger it, because the real tail is always appended after
422
+ * every field — but the check is a shape test, not an authorship proof;
423
+ * - two *distinct* proposals can still be treated as one, by two separate
424
+ * mechanisms in `core.mjs`, both shared by all three adapters and neither
425
+ * fixable here. `fingerprintOf` truncates each part to 40 characters, so two
426
+ * long proposals differing only past that point produce the SAME fingerprint;
427
+ * and `duplicateOf` matches by substring, so a short fingerprint that is a
428
+ * prefix of a longer one matches it even when both are well under 40
429
+ * characters (`f:p:rename-the` against `f:p:rename-the-counter`). The prefix
430
+ * case bites in one direction only: the short one filed second is swallowed,
431
+ * the reverse order files both. When it bites, the caller is told
432
+ * `{ok: true, incremented: <the short fingerprint>}` for a bullet that carries
433
+ * the long one — the same "incremented something that does not exist" shape
434
+ * this adapter has a test for in the quoted-prose case;
435
+ * - a CRLF plan file comes back mixed — `sectionRange` splits on `\n`, so every
436
+ * pre-existing line keeps its `\r` and the appended bullet has none. An
437
+ * increment drops the `\r` from the line it rewrites, because the counter
438
+ * pattern's trailing whitespace is inside the match;
439
+ * - only the FIRST `## Operator queue` is ever written to: `sectionRange` uses
440
+ * `findIndex`, so a second one later in the file is invisible;
441
+ * - a missing plan file throws `ENOENT` from the read rather than returning
442
+ * `ok: false` — loud, so nothing is lost, but it is not one of the structured
443
+ * refusals;
444
+ * - `seen` is returned only by this adapter; the other two report no count at
445
+ * all;
446
+ * - `sectionRange` does not know about code fences, so a fenced
447
+ * "## Operator queue" line inside another section captures the write. The
448
+ * line arithmetic still holds and `parsePlan` truncates at the same line, so
449
+ * the bullet stays unselectable — but a reader sees it under the wrong
450
+ * heading.
451
+ */
452
+ export const proposeTriage = (rawProposal, { planPath: p } = {}) => {
453
+ const proposal = withAsOf(rawProposal);
236
454
  const item = triageItemFor(proposal);
237
- return {
238
- ok: false,
239
- item,
240
- why:
241
- `PLAN.md has no triage state. Add "${item.title}" to the **Operator queue** ` +
242
- `of ${p ?? 'PLAN.md'} — never the Agent queue — and keep the fingerprint line ` +
243
- 'so a later run increments it instead of filing a duplicate.',
244
- };
455
+ const file = p ?? 'PLAN.md';
456
+ const plan = readFileSync(file, 'utf8');
457
+ const { found, lines, start, end } = sectionRange(plan, OPERATOR_QUEUE);
458
+ if (!found) {
459
+ return {
460
+ ok: false,
461
+ item,
462
+ why:
463
+ `${file} has no "## Operator queue" heading, so a proposal has nowhere to ` +
464
+ 'land that the selection query cannot reach. Add the heading rather than ' +
465
+ 'filing this into the Agent queue.',
466
+ };
467
+ }
468
+
469
+ // 🔴 Only lines carrying THIS adapter's trailing field shape are candidates —
470
+ // which is a test of shape, not proof of authorship, and the limits list above
471
+ // says what a hand-written mimic costs. The other two adapters get
472
+ // that restriction free from their tracker (`--label triage`); plan-md has no
473
+ // label, and "every line in the section" — the first version of this — handed
474
+ // the Operator queue's human prose to the deduper. Observed: a human's
475
+ // "we discussed <fingerprint> in March and decided against it" became the
476
+ // counter for that very proposal, which was then never filed while the caller
477
+ // was told ok: true. The humans' page is not the agent's to edit.
478
+ //
479
+ // And the body handed to `duplicateOf` is the fingerprint FIELD, never the
480
+ // whole bullet: `duplicateOf` matches by substring, so a proposal whose prose
481
+ // quoted another proposal's fingerprint — which the loop does, since that
482
+ // string is what `incremented` returns and what the journal cites — captured
483
+ // that proposal's counter and was never filed, while the caller was told it
484
+ // had incremented a bullet that does not exist.
485
+ const candidates = [];
486
+ for (let i = start; i < end; i += 1) {
487
+ const tail = TAIL.exec(lines[i]);
488
+ if (tail) candidates.push({ id: String(i), body: tail[1] });
489
+ }
490
+ const existing = duplicateOf(item, candidates);
491
+
492
+ if (existing) {
493
+ const at = Number(existing.id);
494
+ const current = Number(TAIL.exec(lines[at])[2]);
495
+ lines[at] = lines[at].replace(COUNT, `seen ×${current + 1}`);
496
+ writeFileSync(file, lines.join('\n'));
497
+ return { ok: true, incremented: item.fingerprint, seen: current + 1, item };
498
+ }
499
+
500
+ // Append after the section's last non-empty line, so any blank line before the
501
+ // next heading survives and the file stays diff-clean. A section that has no
502
+ // blank line — an empty Operator queue whose heading is followed straight by
503
+ // the next one — gains none: the bullet lands flush against that heading.
504
+ let at = end;
505
+ while (at > start && lines[at - 1].trim() === '') at -= 1;
506
+ lines.splice(at, 0, bulletFor(item, proposal, 1));
507
+ writeFileSync(file, lines.join('\n'));
508
+ return { ok: true, filed: item.title, seen: 1, item };
245
509
  };
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The queue's own state — today, exactly one field: the tier of the last item
3
+ * the loop closed.
4
+ *
5
+ * 🔴 **Why this file exists at all.** `selectNext` rations the elevated tier by
6
+ * spacing: never two elevated items back to back — where the FIRST one is the
7
+ * half of the tier that executes (`tierOf` below), and the second is still any
8
+ * item whose marker says `elevated`. The asymmetry is not an oversight: a
9
+ * candidate has no diff yet, so there is nothing to classify it from, while a
10
+ * close does. It reads
11
+ * `config.lastCompletedTier` — and nothing anywhere wrote it, so the filter was
12
+ * called with `null` on every selection and **the ration never fired between
13
+ * tasks**. The rule was upheld by whichever session happened to read it, which
14
+ * is precisely the guarantee a mechanical filter exists to replace. A filter
15
+ * whose input nobody supplies is indistinguishable from a filter that agrees
16
+ * with you, and neither a green suite nor a reading of `core.mjs` shows it.
17
+ *
18
+ * 🔴 **The tier is computed from the change, never taken from the item's
19
+ * marker.** `autonomy.md`: *"the tier is decided by what the change touches, not
20
+ * by what the task said it would touch"*, and the `loop` skill calls the marker
21
+ * *"a pre-filter, not the authority"*. Rationing on the marker would mean a
22
+ * marker written one tier low silently buys a second elevated item in a row —
23
+ * which is the failure this repo has already recorded, on the very item that
24
+ * produced this module. The marker stays useful as a hint and a hygiene signal;
25
+ * it is not the value anything rations on.
26
+ *
27
+ * That costs no judgement: the gate sweep already decides this question
28
+ * mechanically, and this module calls the sweep's own functions rather than
29
+ * re-deriving the rules. **One mechanism, one implementation**
30
+ * (`invariants.md`) — two files deciding "is this path elevated" would disagree,
31
+ * and the one nobody is looking at would be the wrong one.
32
+ */
33
+
34
+ import { writeFileSync } from 'node:fs';
35
+ import { join } from 'node:path';
36
+
37
+ import { elevatedPathsIn, executesNothing, readDeclaredPaths } from '../detect-missed-gate.mjs';
38
+ import { updateState } from '../run-state.mjs';
39
+ import { mainCheckoutRoot } from './checkout.mjs';
40
+
41
+
42
+ /**
43
+ * The tier of a close, from the elevated paths the change crossed.
44
+ *
45
+ * 🔴 **The elevated tier splits in two, and only the ration reads the split.**
46
+ * `elevated-prose` is still an elevated change everywhere it is REVIEWED — the
47
+ * model lane, the cold readers, the `human-review` label, the gate sweep. It
48
+ * simply does not space the next item, because the rule's own stated purpose is
49
+ * about what compounds: *"one **unreviewed** schema or permissions change is
50
+ * recoverable; a chain of them compounding overnight is not"*. A rule file
51
+ * cannot compound into a broken runtime overnight, because nothing executes it —
52
+ * and in a repository whose rulebook lives under a declared path, spacing on the
53
+ * undivided word halts the queue rather than pacing it.
54
+ *
55
+ * A mixed diff is `elevated-mechanism`: the half that runs decides. Reading the
56
+ * tier off the first path, or off "most of them are documents", would ship a
57
+ * ration any diff can opt out of by also touching a `.md`.
58
+ *
59
+ * The predicate is `executesNothing` — **`.md` only, not `.mdx`** — imported
60
+ * from `detect-missed-gate.mjs` so it sits beside the sweep's own markdown test
61
+ * rather than drifting from it. The two are deliberately different and the
62
+ * difference is the ration's whole subject: the sweep asks *does this need a
63
+ * reviewer*, this asks *can it compound overnight*, and MDX is a program that
64
+ * renders (`docs/decisions/review-lanes.md`).
65
+ *
66
+ * ⚠ **The limit worth knowing before trusting this:** a skill's `SKILL.md` is
67
+ * prose by this test, and some of them carry shell snippets an agent copies and
68
+ * runs. The owner's ruling is that skills stay prose for rationing — they are
69
+ * reviewed like the rules they are, and rewriting a procedure is not the chain
70
+ * of unreviewed compounding changes the ration was bought to stop. It is,
71
+ * however, the weakest ground the "no runtime executes it" justification stands
72
+ * on, and the place to look first if the ration ever turns out too loose.
73
+ *
74
+ * ⚠ Two more limits, both erring toward holding: the test is case-sensitive, so
75
+ * `RULES.MD` records `elevated-mechanism`; and only paths `elevatedPathsIn`
76
+ * already returned reach here, so a non-rulebook `.md` was dropped as inert long
77
+ * before and records `normal` — which clears the ration outright rather than as
78
+ * prose (`docs/decisions/review-lanes.md`).
79
+ */
80
+ const tierOf = (elevated) => {
81
+ if (elevated.length === 0) return 'normal';
82
+ return elevated.every(executesNothing) ? 'elevated-prose' : 'elevated-mechanism';
83
+ };
84
+
85
+ /**
86
+ * Record the tier of the change that just closed an item.
87
+ *
88
+ * `changedFiles` is the diff's file list — `git diff --name-only <base>...<head>`
89
+ * for the merged PR. It is a required argument and not a defaulted one, which is
90
+ * the whole point of the two refusals below.
91
+ *
92
+ * Returns `{ tier, elevatedPaths }`: the value written, and the files that
93
+ * earned it, so the close step can journal *why* rather than just *what*.
94
+ * `elevatedPaths` is always an array — empty on a normal change, never absent,
95
+ * and **unaffected by the prose/mechanism split above**. `elevatedPaths` answers
96
+ * "what did this change cross", which is the gate's question and not the
97
+ * ration's: a prose merge that stopped listing its rulebook files would look
98
+ * clean to the sweep that exists to catch exactly those merges.
99
+ *
100
+ * 🔴 **It writes a state file BESIDE the queue config, never into it — and that
101
+ * is not a preference.** `.claude/queue.json` is composed from the rig's
102
+ * template layer, so a runtime value written into it is drift: the repository's
103
+ * own sync check fails, and in a generated project the next `upgrade` has a
104
+ * conflict on a file the project never edited. The item that asked for this
105
+ * named the config as the target ("the file `selectNext` already reads"); it was
106
+ * right about the reader and wrong about the file, and the drift check is what
107
+ * proved it. Config is composed and tracked; state is per-checkout and ignored.
108
+ *
109
+ * ⚠ **The limit this cannot see, stated rather than covered by a test that
110
+ * would only look like coverage:** a file list that arrived **truncated** — a
111
+ * split on the wrong separator, a hand-trimmed array, a caller that filtered
112
+ * before passing — is indistinguishable here from a complete one, and a
113
+ * truncated list that drops the elevated file records `normal`. The empty-list
114
+ * refusal below does not catch it, because a short list is not an empty one.
115
+ * The caller owns completeness. (A `maxBuffer` overflow is NOT one of these
116
+ * cases: `execFileSync`, which the documented snippet uses, throws `ENOBUFS`
117
+ * rather than returning a short string — measured, so it fails loudly.)
118
+ */
119
+ export const recordCompletedTier = ({ changedFiles, projectRoot, statePath, runDir } = {}) => {
120
+ // 🔴 An absent file list is NOT a normal change. A zero and an unknown look
121
+ // identical in a count and mean opposite things, and guessing `normal` here
122
+ // would rebuild the exact blind spot this module closes: the permissive
123
+ // answer, written confidently, with nothing to show it was never measured.
124
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
125
+ throw new Error(
126
+ 'recordCompletedTier needs the changed file list of the closing change ' +
127
+ '(`git diff --name-only <base>...<head>`). An empty or missing list is an ' +
128
+ 'absence, not a normal-tier change, and writing a tier from it would ration ' +
129
+ 'the queue on a value nobody measured.',
130
+ );
131
+ }
132
+
133
+ // Same refusal, one layer up: a project that declares no elevated path at all
134
+ // would make every change look `normal` forever. `readDeclaredPaths` returns
135
+ // null rather than [] for exactly this case, and the sweep treats it as its
136
+ // own finding rather than as "no findings".
137
+ const declared = readDeclaredPaths(projectRoot);
138
+ if (!declared || declared.length === 0) {
139
+ throw new Error(
140
+ 'nothing in this project declares an elevated path, so no tier can be ' +
141
+ 'computed: add an `elevated-paths` block to CLAUDE.md or a rule file. ' +
142
+ 'Treating the absence as `normal` would ration on a declaration that ' +
143
+ 'does not exist.',
144
+ );
145
+ }
146
+
147
+ const elevated = elevatedPathsIn(changedFiles, declared);
148
+ const tier = tierOf(elevated);
149
+
150
+ // State only. It deliberately does NOT carry `adapter` or `options`: two files
151
+ // answering "which queue is this" is two answers with no rule for which wins,
152
+ // and the loser is whichever one nobody is looking at.
153
+ //
154
+ // The default lands in the MAIN checkout even when the close runs inside a
155
+ // worktree — see `mainCheckoutRoot`. An explicit `statePath` is used verbatim
156
+ // and never re-resolved: it is the escape hatch tests and odd layouts need,
157
+ // and silently relocating it would make it useless.
158
+ //
159
+ // Note the asymmetry, which is deliberate: the DECLARATION is read from the
160
+ // given `projectRoot` (the worktree's own `CLAUDE.md` is the rulebook the
161
+ // change was written against), while the STATE goes to the checkout that
162
+ // outlives the task.
163
+ const file = statePath ?? join(mainCheckoutRoot(projectRoot), '.claude', 'queue.state.json');
164
+ writeFileSync(file, `${JSON.stringify({ lastCompletedTier: tier }, null, 2)}\n`);
165
+
166
+ // The run's own state, when the run declared a directory. Two files because
167
+ // the two values have different lifetimes: the tier rations ACROSS runs and
168
+ // belongs to the checkout, while the escalation streak means "twice in a row
169
+ // in THIS run" — see `run-state.mjs`. Writing either into the other's file
170
+ // silently breaks the rule it exists for.
171
+ //
172
+ // 🔴 A close BREAKS the streak, and that is the point of writing it here.
173
+ // "Two escalations in a row" ends when something lands in between; a counter
174
+ // nothing resets turns the second escalation of a long, otherwise healthy run
175
+ // into a permanent stop.
176
+ //
177
+ // `updateState` merges, so the budget and the trigger record this run has
178
+ // accumulated survive — unlike the whole-file write above, which owns its
179
+ // file outright.
180
+ //
181
+ // The tier goes into both files, and only one of them is read back: selection
182
+ // takes it from the per-checkout file above. The run-state copy is a trace of
183
+ // what this run closed — the item's own state shape names it — not a second
184
+ // input to the ration, and reading it as one would be the per-run clean slate
185
+ // this module exists to prevent.
186
+ //
187
+ // ⚠ **Deliberately untried, unlike the same call inside `recordEscalation`.**
188
+ // There the caller has already mutated a tracker, so a throw would report a
189
+ // successful escalation as a failure and invite a double-posted comment. Here
190
+ // the durable half — the tier the ration reads — is already on disk one line
191
+ // above, and the half that can still fail is the streak reset, whose loss
192
+ // stops the run EARLIER than it needed to. A failure that errs toward
193
+ // stopping is one to hear about, not one to swallow.
194
+ if (runDir) updateState(runDir, { lastCompletedTier: tier, escalations: 0 });
195
+
196
+ return { tier, elevatedPaths: elevated };
197
+ };