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.
- package/CHANGELOG.md +207 -8
- package/README.md +50 -17
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +82 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +38 -14
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +21 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/templates/agent-os/init/AGENTS.md +191 -0
- package/templates/agent-os/init/CLAUDE.md +61 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
- package/templates/agent-os/universal/.claude/settings.json +7 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +65 -0
- package/templates/agent-os/universal/AGENTS.md +164 -0
- package/templates/agent-os/universal/CLAUDE.md +47 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +36 -2
- package/templates/hash-history.json +2 -1
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -12,24 +12,31 @@
|
|
|
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 } from './core.mjs';
|
|
16
|
+
import { recordEscalation } from '../run-state.mjs';
|
|
16
17
|
|
|
17
18
|
export const name = 'plan-md';
|
|
18
19
|
|
|
19
20
|
const AGENT_QUEUE = /^##\s+Agent queue\s*$/i;
|
|
21
|
+
const OPERATOR_QUEUE = /^##\s+Operator queue\s*$/i;
|
|
20
22
|
const ANY_HEADING = /^##\s+/;
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
|
-
* Locate
|
|
25
|
+
* Locate a `## ` section by LINE RANGE, and say whether it was found at all.
|
|
24
26
|
*
|
|
25
27
|
* "No such heading" and "heading present, nothing under it" used to collapse into
|
|
26
28
|
* the same empty string, so a renamed heading or a bad merge read as a legitimately
|
|
27
29
|
* empty queue — reported as a successful end of session. They are different
|
|
28
30
|
* answers and only one of them is good news.
|
|
31
|
+
*
|
|
32
|
+
* Parameterised by heading because there are now two callers and the alternative
|
|
33
|
+
* was a second copy of this scan — and two implementations of one mechanism
|
|
34
|
+
* disagree eventually, with the unwatched one being the wrong one
|
|
35
|
+
* (`invariants.md`). The Agent-queue caller keeps its own name below.
|
|
29
36
|
*/
|
|
30
|
-
|
|
37
|
+
const sectionRange = (plan, heading) => {
|
|
31
38
|
const lines = String(plan ?? '').split('\n');
|
|
32
|
-
const start = lines.findIndex((line) =>
|
|
39
|
+
const start = lines.findIndex((line) => heading.test(line));
|
|
33
40
|
if (start === -1) return { found: false, lines, start: -1, end: -1 };
|
|
34
41
|
let end = lines.length;
|
|
35
42
|
for (let i = start + 1; i < lines.length; i += 1) {
|
|
@@ -41,6 +48,8 @@ export const readQueue = (plan) => {
|
|
|
41
48
|
return { found: true, lines, start: start + 1, end };
|
|
42
49
|
};
|
|
43
50
|
|
|
51
|
+
export const readQueue = (plan) => sectionRange(plan, AGENT_QUEUE);
|
|
52
|
+
|
|
44
53
|
/**
|
|
45
54
|
* Inline markers, so a flat list can still carry the few facts selection needs.
|
|
46
55
|
* Anything unmarked is a normal, unconditional item — which is the common case
|
|
@@ -134,6 +143,12 @@ export const closeInPlan = (plan, id) => {
|
|
|
134
143
|
return lines.join('\n');
|
|
135
144
|
};
|
|
136
145
|
|
|
146
|
+
// 🔴 The bare default is cwd-relative on purpose. This module is imported
|
|
147
|
+
// directly as well as driven by the CLI, and a direct caller's cwd is the only
|
|
148
|
+
// project it can mean. The CLI, which knows where its config lives, supplies an
|
|
149
|
+
// absolute `planPath` instead — see `optionsWithPlanPath` in `index.mjs`.
|
|
150
|
+
// Resolving this default from the module's own location would point every call at
|
|
151
|
+
// the rig's own tree.
|
|
137
152
|
const planPath = (options) => options?.planPath ?? 'PLAN.md';
|
|
138
153
|
const readPlan = (options) => readFileSync(planPath(options), 'utf8');
|
|
139
154
|
|
|
@@ -185,17 +200,24 @@ export const close = (ticket, { prUrl = null, planPath: p } = {}) => {
|
|
|
185
200
|
export const comment = (ticket, body) => ({
|
|
186
201
|
ok: false,
|
|
187
202
|
journalInstead: `${ticket.id} — ${body}`,
|
|
188
|
-
why: 'PLAN.md has no comment thread: write it as a journal entry in the
|
|
203
|
+
why: 'PLAN.md has no comment thread: write it as a journal entry in journal/YYYY-MM.md for the current month.',
|
|
189
204
|
});
|
|
190
205
|
|
|
191
|
-
export const escalate = (ticket, diagnosis) =>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
206
|
+
export const escalate = (ticket, diagnosis, { env = process.env } = {}) => {
|
|
207
|
+
// The count is recorded even though this adapter cannot mark the item: the two
|
|
208
|
+
// are different facts. "This queue has no per-item state" is a plan-md limit;
|
|
209
|
+
// "two tasks in a row hit a wall" is about the run, and it must stop the run
|
|
210
|
+
// whichever tracker it is reading.
|
|
211
|
+
recordEscalation(env.RIG_RUN_DIR);
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
journalInstead: `escalated ${ticket.id}: ${diagnosis}`,
|
|
215
|
+
why:
|
|
216
|
+
'PLAN.md has no per-item state, so an escalated item cannot be marked ' +
|
|
217
|
+
'unselectable. Move it to the Operator queue in the same edit, or the next ' +
|
|
218
|
+
'run picks it straight back up.',
|
|
219
|
+
};
|
|
220
|
+
};
|
|
199
221
|
|
|
200
222
|
/**
|
|
201
223
|
* A proposal, forced into triage.
|
|
@@ -232,14 +254,193 @@ export const triageItemFor = (proposal) => {
|
|
|
232
254
|
};
|
|
233
255
|
};
|
|
234
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The trailing fields this adapter writes, and the only thing it will ever edit.
|
|
259
|
+
*
|
|
260
|
+
* 🔴 Both patterns are anchored to the END of the line, and that is the whole
|
|
261
|
+
* point. An unanchored `seen ×(\d+)` matched the FIRST such token in the bullet —
|
|
262
|
+
* and a proposal is free text, so a proposal *about* the counter ("render
|
|
263
|
+
* `seen ×2` as twice") put one there itself. Observed: the count read back as 3
|
|
264
|
+
* then 4 while the file still said ×1, and the increment rewrote the number
|
|
265
|
+
* inside the finding — silently altering the human-facing record of what the loop
|
|
266
|
+
* found, which `core.mjs` forbids for queue metadata in as many words.
|
|
267
|
+
*/
|
|
268
|
+
const TAIL = /·\sfingerprint: `([^`]*)`\s·\sseen ×(\d+)\s*$/;
|
|
269
|
+
const COUNT = /seen ×(\d+)\s*$/;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Fold a field onto one line.
|
|
273
|
+
*
|
|
274
|
+
* 🔴 A bullet is spliced into the file as a single array element and joined with
|
|
275
|
+
* newlines, so a `\n` inside a proposal's own prose became REAL lines in the
|
|
276
|
+
* plan. Two things broke at once: the `fingerprint … seen ×N` tail landed on a
|
|
277
|
+
* different physical line from the bullet, which defeated dedup permanently; and
|
|
278
|
+
* a pasted `## Agent queue` line became a heading — on a plan whose Operator
|
|
279
|
+
* queue comes first, `selectNext` then handed the run its own proposal as
|
|
280
|
+
* ordinary work, with the `[triage]` marker stranded on the line above.
|
|
281
|
+
*
|
|
282
|
+
* Not an exotic input: `autonomy.md` tells an escalating run to report "verbatim
|
|
283
|
+
* errors, not summaries", so a stack trace in `finding` is the expected case.
|
|
284
|
+
* The text survives; its layout does not — and "layout" includes interior runs
|
|
285
|
+
* of tabs and spaces, which collapse to one space each. `parsePlan` normalises
|
|
286
|
+
* titles the same way, so this is the house convention rather than a special
|
|
287
|
+
* case, but it is wider than "newlines are folded" and worth knowing before
|
|
288
|
+
* relying on a field surviving byte-for-byte.
|
|
289
|
+
*
|
|
290
|
+
* 🔴 One quantifier over one class, because the obvious spelling was quadratic:
|
|
291
|
+
* putting an unbounded whitespace quantifier in front of a line-terminator class
|
|
292
|
+
* — which is a SUBSET of whitespace — makes the engine re-split a whitespace run
|
|
293
|
+
* at every offset. Measured at 6.7s on 65k spaces, 103s on 256k, and 0s the
|
|
294
|
+
* moment a newline appears after the run, which is why every injection fixture
|
|
295
|
+
* missed it. That is the fourth time this shape has landed in this queue layer;
|
|
296
|
+
* `core.mjs` already says remembering it once was not enough.
|
|
297
|
+
*
|
|
298
|
+
* JS `\s` covers U+2028 and U+2029 as well as the ASCII set, so those fold too.
|
|
299
|
+
* U+0085 and NUL do not, and they survive verbatim — harmless, because
|
|
300
|
+
* `split('\n')` is this adapter's whole line model, but a renderer may show them
|
|
301
|
+
* as breaks.
|
|
302
|
+
*
|
|
303
|
+
* Exported for its cost guard. Measuring the fold through `proposeTriage` meant
|
|
304
|
+
* measuring the plan file it rewrites per call — which grew as the measurement
|
|
305
|
+
* ran, and read DEARER on a faster machine. The guard measures this function
|
|
306
|
+
* directly instead; nothing outside the test imports it.
|
|
307
|
+
*/
|
|
308
|
+
export const oneLine = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The proposal as ONE Operator-queue bullet: a flat list cannot hold a body.
|
|
312
|
+
*
|
|
313
|
+
* The counter goes LAST, after the fingerprint, so the anchors above land on the
|
|
314
|
+
* adapter's own fields whatever the proposal's prose happens to contain.
|
|
315
|
+
*/
|
|
316
|
+
const bulletFor = (item, proposal, seen) =>
|
|
317
|
+
`- **${oneLine(item.title)}** — finding: ${oneLine(proposal.finding)} · ` +
|
|
318
|
+
`part: ${oneLine(proposal.part)} · proof: ${oneLine(proposal.proof)} · ` +
|
|
319
|
+
`fingerprint: \`${item.fingerprint}\` · seen ×${seen}`;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* File a proposal into the **Operator queue** — never the Agent queue.
|
|
323
|
+
*
|
|
324
|
+
* This adapter used to return `ok: false` and instruct a human to file it by
|
|
325
|
+
* hand, which is a channel only in form: over the loop's entire history that
|
|
326
|
+
* instruction produced one triage ticket. A channel nobody walks is where
|
|
327
|
+
* findings go to die, and the findings it loses are the ones about the loop
|
|
328
|
+
* itself.
|
|
329
|
+
*
|
|
330
|
+
* 🔴 Two invariants, and the writing is what puts them at risk:
|
|
331
|
+
* - it appends **only** inside the Operator queue's line range, so the agent can
|
|
332
|
+
* never file itself selectable work;
|
|
333
|
+
* - the `[triage]` marker rides in the title, so even a proposal moved under the
|
|
334
|
+
* wrong heading by hand is still refused by `selectionOf`.
|
|
335
|
+
*
|
|
336
|
+
* Deduping is the shared `duplicateOf` from `core.mjs`, so the three adapters
|
|
337
|
+
* cannot drift into three different answers about what counts as the same
|
|
338
|
+
* proposal. Twenty "queue empty" stops must leave ONE proposal counted twenty
|
|
339
|
+
* times, not twenty proposals.
|
|
340
|
+
*
|
|
341
|
+
* Bounded by construction: work is linear in the section's length, no recursion,
|
|
342
|
+
* no rescanning, and `validateProposal` throws before the file is read — an
|
|
343
|
+
* incomplete proposal cannot leave a partial write behind.
|
|
344
|
+
*
|
|
345
|
+
* One difference from the other two adapters, deliberate and worth knowing:
|
|
346
|
+
* `incremented` is the **fingerprint** here, where `github-issues` returns an
|
|
347
|
+
* issue number and `jira` an issue key. A flat list has no per-item identifier
|
|
348
|
+
* that survives an edit — the line index shifts the moment anything above it
|
|
349
|
+
* changes — and the fingerprint is the only stable name this adapter can give.
|
|
350
|
+
*
|
|
351
|
+
* What this cannot see, stated so nobody relies on cover it lacks:
|
|
352
|
+
* - a filed bullet whose trailing `fingerprint … seen ×N` fields a human has
|
|
353
|
+
* edited away no longer matches, so the next call files a second bullet
|
|
354
|
+
* rather than incrementing. That is the deliberate direction to fail in: a
|
|
355
|
+
* duplicate is visible and cheap, editing somebody else's line is not;
|
|
356
|
+
* - a HUMAN line ending in this adapter's exact `· fingerprint: \`x\` · seen ×N`
|
|
357
|
+
* shape is indistinguishable from one the adapter wrote, and gets incremented
|
|
358
|
+
* instead of the proposal being filed. Reachable only by hand — a proposal's
|
|
359
|
+
* own prose cannot trigger it, because the real tail is always appended after
|
|
360
|
+
* every field — but the check is a shape test, not an authorship proof;
|
|
361
|
+
* - two *distinct* proposals can still be treated as one, by two separate
|
|
362
|
+
* mechanisms in `core.mjs`, both shared by all three adapters and neither
|
|
363
|
+
* fixable here. `fingerprintOf` truncates each part to 40 characters, so two
|
|
364
|
+
* long proposals differing only past that point produce the SAME fingerprint;
|
|
365
|
+
* and `duplicateOf` matches by substring, so a short fingerprint that is a
|
|
366
|
+
* prefix of a longer one matches it even when both are well under 40
|
|
367
|
+
* characters (`f:p:rename-the` against `f:p:rename-the-counter`). The prefix
|
|
368
|
+
* case bites in one direction only: the short one filed second is swallowed,
|
|
369
|
+
* the reverse order files both. When it bites, the caller is told
|
|
370
|
+
* `{ok: true, incremented: <the short fingerprint>}` for a bullet that carries
|
|
371
|
+
* the long one — the same "incremented something that does not exist" shape
|
|
372
|
+
* this adapter has a test for in the quoted-prose case;
|
|
373
|
+
* - a CRLF plan file comes back mixed — `sectionRange` splits on `\n`, so every
|
|
374
|
+
* pre-existing line keeps its `\r` and the appended bullet has none. An
|
|
375
|
+
* increment drops the `\r` from the line it rewrites, because the counter
|
|
376
|
+
* pattern's trailing whitespace is inside the match;
|
|
377
|
+
* - only the FIRST `## Operator queue` is ever written to: `sectionRange` uses
|
|
378
|
+
* `findIndex`, so a second one later in the file is invisible;
|
|
379
|
+
* - a missing plan file throws `ENOENT` from the read rather than returning
|
|
380
|
+
* `ok: false` — loud, so nothing is lost, but it is not one of the structured
|
|
381
|
+
* refusals;
|
|
382
|
+
* - `seen` is returned only by this adapter; the other two report no count at
|
|
383
|
+
* all;
|
|
384
|
+
* - `sectionRange` does not know about code fences, so a fenced
|
|
385
|
+
* "## Operator queue" line inside another section captures the write. The
|
|
386
|
+
* line arithmetic still holds and `parsePlan` truncates at the same line, so
|
|
387
|
+
* the bullet stays unselectable — but a reader sees it under the wrong
|
|
388
|
+
* heading.
|
|
389
|
+
*/
|
|
235
390
|
export const proposeTriage = (proposal, { planPath: p } = {}) => {
|
|
236
391
|
const item = triageItemFor(proposal);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
392
|
+
const file = p ?? 'PLAN.md';
|
|
393
|
+
const plan = readFileSync(file, 'utf8');
|
|
394
|
+
const { found, lines, start, end } = sectionRange(plan, OPERATOR_QUEUE);
|
|
395
|
+
if (!found) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
item,
|
|
399
|
+
why:
|
|
400
|
+
`${file} has no "## Operator queue" heading, so a proposal has nowhere to ` +
|
|
401
|
+
'land that the selection query cannot reach. Add the heading rather than ' +
|
|
402
|
+
'filing this into the Agent queue.',
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 🔴 Only lines carrying THIS adapter's trailing field shape are candidates —
|
|
407
|
+
// which is a test of shape, not proof of authorship, and the limits list above
|
|
408
|
+
// says what a hand-written mimic costs. The other two adapters get
|
|
409
|
+
// that restriction free from their tracker (`--label triage`); plan-md has no
|
|
410
|
+
// label, and "every line in the section" — the first version of this — handed
|
|
411
|
+
// the Operator queue's human prose to the deduper. Observed: a human's
|
|
412
|
+
// "we discussed <fingerprint> in March and decided against it" became the
|
|
413
|
+
// counter for that very proposal, which was then never filed while the caller
|
|
414
|
+
// was told ok: true. The humans' page is not the agent's to edit.
|
|
415
|
+
//
|
|
416
|
+
// And the body handed to `duplicateOf` is the fingerprint FIELD, never the
|
|
417
|
+
// whole bullet: `duplicateOf` matches by substring, so a proposal whose prose
|
|
418
|
+
// quoted another proposal's fingerprint — which the loop does, since that
|
|
419
|
+
// string is what `incremented` returns and what the journal cites — captured
|
|
420
|
+
// that proposal's counter and was never filed, while the caller was told it
|
|
421
|
+
// had incremented a bullet that does not exist.
|
|
422
|
+
const candidates = [];
|
|
423
|
+
for (let i = start; i < end; i += 1) {
|
|
424
|
+
const tail = TAIL.exec(lines[i]);
|
|
425
|
+
if (tail) candidates.push({ id: String(i), body: tail[1] });
|
|
426
|
+
}
|
|
427
|
+
const existing = duplicateOf(item, candidates);
|
|
428
|
+
|
|
429
|
+
if (existing) {
|
|
430
|
+
const at = Number(existing.id);
|
|
431
|
+
const current = Number(TAIL.exec(lines[at])[2]);
|
|
432
|
+
lines[at] = lines[at].replace(COUNT, `seen ×${current + 1}`);
|
|
433
|
+
writeFileSync(file, lines.join('\n'));
|
|
434
|
+
return { ok: true, incremented: item.fingerprint, seen: current + 1, item };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Append after the section's last non-empty line, so any blank line before the
|
|
438
|
+
// next heading survives and the file stays diff-clean. A section that has no
|
|
439
|
+
// blank line — an empty Operator queue whose heading is followed straight by
|
|
440
|
+
// the next one — gains none: the bullet lands flush against that heading.
|
|
441
|
+
let at = end;
|
|
442
|
+
while (at > start && lines[at - 1].trim() === '') at -= 1;
|
|
443
|
+
lines.splice(at, 0, bulletFor(item, proposal, 1));
|
|
444
|
+
writeFileSync(file, lines.join('\n'));
|
|
445
|
+
return { ok: true, filed: item.title, seen: 1, item };
|
|
245
446
|
};
|
|
@@ -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
|
+
};
|