create-agent-rig 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +66 -10
  3. package/package.json +9 -2
  4. package/packages/cli/dist/commands/init.js +73 -18
  5. package/packages/cli/dist/index.js +11 -1
  6. package/packages/cli/dist/lib/init-settings.js +52 -0
  7. package/packages/cli/dist/lib/summary.js +19 -5
  8. package/packages/cli/dist/templates.js +8 -0
  9. package/templates/agent-os/init/CLAUDE.md +133 -0
  10. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
  11. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
  12. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +1 -1
  13. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
  14. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
  15. package/templates/agent-os/universal/.claude/queue.json +3 -0
  16. package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
  17. package/templates/agent-os/universal/.claude/rules/invariants.md +170 -0
  18. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
  19. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
  20. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
  21. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
  22. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
  23. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
  24. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
  25. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
  26. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
  27. package/templates/agent-os/universal/.claude/settings.json +4 -0
  28. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
  29. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
  30. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
  31. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  32. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
  33. package/templates/agent-os/universal/CLAUDE.md +57 -7
  34. package/templates/agent-os/universal/PLAN.md +28 -2
  35. package/templates/agent-os/universal/layers.json +20 -1
  36. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
  37. package/templates/skeleton/aws-serverless/gitignore +8 -0
  38. package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
  39. package/templates/skeleton/node-service/gitignore +8 -0
@@ -0,0 +1,239 @@
1
+ // Queue adapter: the Agent queue in PLAN.md.
2
+ //
3
+ // This is the DEFAULT because it is the only adapter that works the moment a
4
+ // project is generated — a fresh project has no remote, no tracker and no CI, and
5
+ // a loop that cannot read its queue until someone provisions one is a loop that
6
+ // never runs on day one.
7
+ //
8
+ // 🔴 **Its limit, stated rather than discovered:** a flat list carries no
9
+ // dependency links. `blockedBy` is therefore always empty here, which means the
10
+ // blocker filter is vacuous — not satisfied, absent. Nothing goes stale either,
11
+ // because there is nothing to keep in step; but the moment work in this project
12
+ // has real dependencies, move to an adapter whose tracker can express them
13
+ // (`github-issues`). Ordering the list by hand is not a dependency graph.
14
+ import { readFileSync, writeFileSync } from 'node:fs';
15
+ import { fingerprintOf, validateProposal } from './core.mjs';
16
+
17
+ export const name = 'plan-md';
18
+
19
+ const AGENT_QUEUE = /^##\s+Agent queue\s*$/i;
20
+ const ANY_HEADING = /^##\s+/;
21
+
22
+ /**
23
+ * Locate the Agent queue by LINE RANGE, and say whether it was found at all.
24
+ *
25
+ * "No such heading" and "heading present, nothing under it" used to collapse into
26
+ * the same empty string, so a renamed heading or a bad merge read as a legitimately
27
+ * empty queue — reported as a successful end of session. They are different
28
+ * answers and only one of them is good news.
29
+ */
30
+ export const readQueue = (plan) => {
31
+ const lines = String(plan ?? '').split('\n');
32
+ const start = lines.findIndex((line) => AGENT_QUEUE.test(line));
33
+ if (start === -1) return { found: false, lines, start: -1, end: -1 };
34
+ let end = lines.length;
35
+ for (let i = start + 1; i < lines.length; i += 1) {
36
+ if (ANY_HEADING.test(lines[i])) {
37
+ end = i;
38
+ break;
39
+ }
40
+ }
41
+ return { found: true, lines, start: start + 1, end };
42
+ };
43
+
44
+ /**
45
+ * Inline markers, so a flat list can still carry the few facts selection needs.
46
+ * Anything unmarked is a normal, unconditional item — which is the common case
47
+ * and should stay the cheapest thing to write.
48
+ */
49
+ const MARKERS = {
50
+ elevated: /\[elevated\]/i,
51
+ triage: /\[triage\]/i,
52
+ triggerAuto: /\[trigger-auto\]/i,
53
+ triggerHuman: /\[trigger-human\]/i,
54
+ };
55
+
56
+ /**
57
+ * Parse the Agent queue into neutral tickets. Order in the file IS the priority.
58
+ *
59
+ * Each ticket records the **physical line** it came from, and that — not its text
60
+ * — is what identifies it for a later write.
61
+ */
62
+ export const parsePlan = (plan) => {
63
+ const { found, lines, start, end } = readQueue(plan);
64
+ if (!found) return [];
65
+
66
+ const items = [];
67
+ let inComment = false;
68
+ for (let index = start; index < end; index += 1) {
69
+ const line = lines[index];
70
+ // A fresh project ships its example items commented out, and a loop that
71
+ // picks up the instructions as work is worse than useless.
72
+ if (inComment) {
73
+ if (line.includes('-->')) inComment = false;
74
+ continue;
75
+ }
76
+ if (line.includes('<!--')) {
77
+ if (!line.includes('-->')) inComment = true;
78
+ continue;
79
+ }
80
+
81
+ const match = /^\s*[-*]\s+(.*\S)\s*$/.exec(line);
82
+ if (!match) continue;
83
+ const raw = match[1];
84
+ const title = raw
85
+ .replace(/\[(elevated|triage|trigger-auto|trigger-human)\]/gi, '')
86
+ .replace(/\s+/g, ' ')
87
+ .trim();
88
+ items.push({
89
+ id: String(items.length + 1),
90
+ title,
91
+ raw,
92
+ line: index, // the identity a write uses — never the text
93
+ url: null,
94
+ state: 'open',
95
+ labels: [],
96
+ tier: MARKERS.elevated.test(raw) ? 'elevated' : 'normal',
97
+ // No links are expressible in a flat list — see the limit at the top.
98
+ blockedBy: [],
99
+ blocks: [],
100
+ priority: items.length,
101
+ createdAt: null,
102
+ triage: MARKERS.triage.test(raw),
103
+ trigger: MARKERS.triggerAuto.test(raw)
104
+ ? 'auto'
105
+ : MARKERS.triggerHuman.test(raw)
106
+ ? 'human'
107
+ : null,
108
+ });
109
+ }
110
+ return items;
111
+ };
112
+
113
+ /**
114
+ * Remove a closed item's line: a queue states what is next, not what is done.
115
+ *
116
+ * Deletes the recorded line index, which is inside the Agent queue by
117
+ * construction. The previous version searched the whole file for a line
118
+ * *containing* the item's text and deleted the first hit — so an item whose title
119
+ * was a prefix of another silently destroyed the wrong entry, and a human's
120
+ * Operator-queue line could be destroyed instead. Verified: closing item 2 of
121
+ * ["fix the parser bug in edge cases", "fix the parser"] deleted item 1.
122
+ */
123
+ export const closeInPlan = (plan, id) => {
124
+ const target = parsePlan(plan).find((ticket) => ticket.id === String(id));
125
+ if (!target) return plan;
126
+ const lines = String(plan).split('\n');
127
+ lines.splice(target.line, 1);
128
+ return lines.join('\n');
129
+ };
130
+
131
+ const planPath = (options) => options?.planPath ?? 'PLAN.md';
132
+ const readPlan = (options) => readFileSync(planPath(options), 'utf8');
133
+
134
+ // --- the adapter contract ------------------------------------------------------
135
+
136
+ /**
137
+ * Throws when the Agent queue section is absent, rather than returning [].
138
+ *
139
+ * A renamed heading or a bad merge is a queue that cannot be read — and the CLI
140
+ * turns that into `queue-unreadable`, not into the `queue-empty` success that a
141
+ * genuinely empty section produces. Collapsing the two meant a structural bug in
142
+ * PLAN.md was reported as "a legitimate end of session".
143
+ */
144
+ export const listEligible = (options = {}) => {
145
+ const plan = readPlan(options);
146
+ if (!readQueue(plan).found) {
147
+ throw new Error(
148
+ `${planPath(options)} has no "## Agent queue" heading, so the queue cannot be ` +
149
+ 'read. That is a structural problem in the file, not an empty queue — fix ' +
150
+ 'the heading rather than treating this as a finished session.',
151
+ );
152
+ }
153
+ return parsePlan(plan);
154
+ };
155
+
156
+ /** Vacuously empty here, and honestly so — a flat list cannot express a dependency. */
157
+ export const resolveBlockers = () => [];
158
+
159
+ /**
160
+ * There is nothing to claim in a text file that two sessions could both hold, so
161
+ * this returns the instruction instead of pretending to lock. The real isolation
162
+ * for concurrent sessions is a worktree (`.claude/skills/worktree-task`).
163
+ */
164
+ export const claim = (ticket) => ({
165
+ ok: true,
166
+ note:
167
+ `PLAN.md cannot express "in progress", so claiming ${ticket.id} is not ` +
168
+ 'observable by another session. If a second session may run, take the task in ' +
169
+ 'its own worktree and say so in the journal.',
170
+ });
171
+
172
+ export const close = (ticket, { prUrl = null, planPath: p } = {}) => {
173
+ const file = p ?? 'PLAN.md';
174
+ writeFileSync(file, closeInPlan(readFileSync(file, 'utf8'), ticket.id));
175
+ return { ok: true, prUrl };
176
+ };
177
+
178
+ /** A flat list has no comment thread; the journal is where this lands. */
179
+ export const comment = (ticket, body) => ({
180
+ ok: false,
181
+ journalInstead: `${ticket.id} — ${body}`,
182
+ why: 'PLAN.md has no comment thread: write it as a journal entry in the same file.',
183
+ });
184
+
185
+ export const escalate = (ticket, diagnosis) => ({
186
+ ok: false,
187
+ journalInstead: `escalated ${ticket.id}: ${diagnosis}`,
188
+ why:
189
+ 'PLAN.md has no per-item state, so an escalated item cannot be marked ' +
190
+ 'unselectable. Move it to the Operator queue in the same edit, or the next ' +
191
+ 'run picks it straight back up.',
192
+ });
193
+
194
+ /**
195
+ * A proposal, forced into triage.
196
+ *
197
+ * 🔴 INVARIANT 2: the agent never creates its own queue items. A proposal is not
198
+ * work — it goes to a state the selection query cannot reach, and promoting it is
199
+ * a human act. Without this, a scheduler plus an improvement loop is a closed
200
+ * circuit that invents and executes its own work.
201
+ */
202
+ export const triageItemFor = (proposal) => {
203
+ validateProposal(proposal);
204
+ const fingerprint = fingerprintOf(proposal);
205
+ return {
206
+ // The `[triage]` marker is load-bearing, not decoration: plan-md has no
207
+ // persisted label field, so placement under the Operator queue used to be the
208
+ // ONLY thing keeping a proposal unselectable. With the marker in the title, a
209
+ // proposal pasted under the wrong heading is still refused by `selectionOf` —
210
+ // the same belt and braces the other two adapters get from a real label.
211
+ title: `proposal: ${proposal.change} [triage]`,
212
+ body: [
213
+ `- **finding** — ${proposal.finding}`,
214
+ `- **part to change** — ${proposal.part}`,
215
+ `- **proposed change** — ${proposal.change}`,
216
+ `- **how the next run proves it** — ${proposal.proof}`,
217
+ '',
218
+ `fingerprint: ${fingerprint}`,
219
+ '',
220
+ 'The loop proposes; the owner patches. Self-applying a change to its own',
221
+ 'rulebook is how an unattended run drifts irreversibly.',
222
+ ].join('\n'),
223
+ labels: ['triage'],
224
+ selectable: false,
225
+ fingerprint,
226
+ };
227
+ };
228
+
229
+ export const proposeTriage = (proposal, { planPath: p } = {}) => {
230
+ const item = triageItemFor(proposal);
231
+ return {
232
+ ok: false,
233
+ item,
234
+ why:
235
+ `PLAN.md has no triage state. Add "${item.title}" to the **Operator queue** ` +
236
+ `of ${p ?? 'PLAN.md'} — never the Agent queue — and keep the fingerprint line ` +
237
+ 'so a later run increments it instead of filing a duplicate.',
238
+ };
239
+ };
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env node
2
+ // Reconcile the lane the journal never sees.
3
+ //
4
+ // Work reaches the default branch through more than one lane. The queue lane
5
+ // leaves a queue item, a journal entry and a cost figure. Work that arrives from
6
+ // outside — an issue someone filed and someone else fixed, a change the owner
7
+ // asked for directly — leaves none of those. So the journal's output and cost
8
+ // blocks describe ONE lane while reading as if they described the repo.
9
+ //
10
+ // This does not instrument the other lane; it cannot, because that lane does not
11
+ // read these skills. It reconciles **after the fact**, from merged PRs, which is
12
+ // the only vantage point this side actually has.
13
+ //
14
+ // node .claude/scripts/reconcile-external-prs.mjs --since 2026-07-20
15
+ // node .claude/scripts/reconcile-external-prs.mjs --since 2026-07-20 --json
16
+ // node .claude/scripts/reconcile-external-prs.mjs --input prs.json # offline
17
+ import { execFileSync } from 'node:child_process';
18
+ import { readFileSync, realpathSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ // Imported, never copied: one home for the elevated declaration and one home for
23
+ // the lane rule. A second copy of either would drift, and each copy would keep
24
+ // passing on its own.
25
+ // `readDeclaredPaths` unions CLAUDE.md with every .claude/rules/*.md declaration,
26
+ // so this sweep sees exactly what the gate sweep sees — including the paths a
27
+ // stack layer contributes for its own shape.
28
+ import { elevatedPathsIn, laneOf, readDeclaredPaths } from './detect-missed-gate.mjs';
29
+
30
+ /**
31
+ * The audit trail for work that already happened: a record born **closed**.
32
+ *
33
+ * It is a record, not work — and it deliberately carries no `ready`-style
34
+ * selectable state. A selectable queue item describing work that already merged
35
+ * is the loop feeding itself, which is the one thing the queue firewall exists
36
+ * to prevent (.claude/skills/loop/SKILL.md).
37
+ */
38
+ export const auditRecordFor = (pr, lane = laneOf(pr)) => ({
39
+ title: `External-lane merge — PR #${pr.number}: ${pr.title}`,
40
+ body: [
41
+ 'Audit record for work that reached the default branch outside the queue.',
42
+ '',
43
+ `- PR: ${pr.url ?? `#${pr.number}`}`,
44
+ lane.closesIssue ? `- Closes issue: #${lane.closesIssue}` : '- Closed no issue.',
45
+ `- Merged: ${pr.mergedAt ?? 'unknown'}`,
46
+ '',
47
+ 'Filed already closed by `.claude/scripts/reconcile-external-prs.mjs`, so the',
48
+ 'merge is traceable from the journal to a record to a PR.',
49
+ '',
50
+ 'This record is **not work**. It must never become selectable, or a run would',
51
+ 'pick up something that has already shipped.',
52
+ ].join('\n'),
53
+ labels: ['external-lane', 'audit'],
54
+ state: 'closed',
55
+ pr: pr.number,
56
+ });
57
+
58
+ /**
59
+ * Split merged PRs by lane, mark the externally-originated ones that crossed an
60
+ * elevated path, and collect findings.
61
+ *
62
+ * Never throws on a malformed record: a sweep that dies on one bad row reports
63
+ * nothing about the other forty.
64
+ */
65
+ export const reconcile = ({ prs = [], elevatedPaths = [], auditRecords = {} } = {}) => {
66
+ const external = [];
67
+ const queue = [];
68
+ const ownerDirected = [];
69
+ const findings = [];
70
+
71
+ const rows = Array.isArray(prs) ? prs : [];
72
+
73
+ for (const pr of rows) {
74
+ if (!pr || typeof pr !== 'object' || pr.number === undefined) continue;
75
+ // Only `classifyPr` used to check this, so an unmerged PR was rendered as a
76
+ // merge in the journal block.
77
+ if (!pr.mergedAt) continue;
78
+
79
+ let lane;
80
+ let elevatedFiles;
81
+ try {
82
+ lane = laneOf(pr);
83
+ // Computed for EVERY lane. It used to run only for `external`, and the lane
84
+ // is decided by the branch name — which a fork contributor chooses freely.
85
+ // So a contributor could opt out of the untrusted-origin mark by naming
86
+ // their branch `feat/12-…`, which is the opposite of what the mark is for.
87
+ elevatedFiles = elevatedPathsIn(pr.files ?? [], elevatedPaths ?? []);
88
+ } catch {
89
+ findings.push({
90
+ pr: pr.number,
91
+ kind: 'unreadable-record',
92
+ why: 'this merged PR could not be read, so its lane and elevated paths are unknown — not clean.',
93
+ });
94
+ continue;
95
+ }
96
+
97
+ // `authorAssociation` is the trustworthy signal when the host supplies it: it
98
+ // comes from the forge, not from the contributor.
99
+ const association = String(pr.authorAssociation ?? '').toUpperCase();
100
+ const trustedAuthor = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association);
101
+ const entry = {
102
+ pr: pr.number,
103
+ title: pr.title,
104
+ mergedAt: pr.mergedAt,
105
+ url: pr.url,
106
+ lane: lane.lane,
107
+ queueRef: lane.queueRef,
108
+ closesIssue: lane.closesIssue,
109
+ elevatedFiles,
110
+ untrustedOrigin: elevatedFiles.length > 0 && !trustedAuthor,
111
+ };
112
+
113
+ if (lane.finding) {
114
+ findings.push({
115
+ pr: pr.number,
116
+ kind: lane.finding,
117
+ why:
118
+ `branch ${pr.headRefName} carries the queue reference ${lane.queueRef} but ` +
119
+ 'the title and body do not. A queue-driven PR should never lose it — and ' +
120
+ 'without the branch this merge would have been counted as external',
121
+ });
122
+ }
123
+
124
+ if (lane.lane === 'external') {
125
+ external.push({ ...entry, auditRecord: auditRecords[pr.number] ?? null });
126
+ } else if (lane.lane === 'queue') {
127
+ queue.push(entry);
128
+ } else {
129
+ ownerDirected.push(entry);
130
+ }
131
+ }
132
+
133
+ return { external, queue, ownerDirected, findings, sweptPrs: rows.length };
134
+ };
135
+
136
+ /** The `external lane` block for the journal in PLAN.md. */
137
+ export const renderJournalBlock = (result) => {
138
+ const lines = ['**external lane**', ''];
139
+
140
+ if (result.external.length === 0) {
141
+ lines.push(
142
+ `- no externally-originated merges in this window (swept ${result.sweptPrs} merged PR(s)).`,
143
+ );
144
+ } else {
145
+ for (const e of result.external) {
146
+ const marks = [
147
+ e.closesIssue ? `closes #${e.closesIssue}` : 'closes no issue',
148
+ e.untrustedOrigin
149
+ ? `⚠ crossed an elevated path (${e.elevatedFiles.join(', ')}) — the gate applies here too`
150
+ : 'no elevated path crossed',
151
+ `audit record: ${e.auditRecord ?? 'not filed'}`,
152
+ ];
153
+ lines.push(`- PR #${e.pr} — ${e.title} · ${marks.join(' · ')}`);
154
+ }
155
+ }
156
+
157
+ for (const f of result.findings) {
158
+ lines.push(`- 🔴 finding · PR #${f.pr} — ${f.why}`);
159
+ }
160
+
161
+ lines.push('');
162
+ lines.push(
163
+ `_Queue lane this window: ${result.queue.length} PR(s); owner-directed: ` +
164
+ `${result.ownerDirected.length}. The cost figures in the journal cover the ` +
165
+ 'queue lane only — read the two together, or a "cheap" session will sit ' +
166
+ 'beside an expensive lane the totals never mention._',
167
+ );
168
+ return lines.join('\n');
169
+ };
170
+
171
+ // --- CLI -----------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Read an offline fixture, or say plainly why it could not be read.
175
+ *
176
+ * A raw SyntaxError stack, or a silent fall-through to the live repo when
177
+ * `--input` was given without a value, are both worse than a one-line diagnosis:
178
+ * this tool's whole value is that "could not look" never renders as "clean".
179
+ */
180
+ const readInput = (file, label) => {
181
+ let parsed;
182
+ try {
183
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
184
+ } catch (error) {
185
+ process.stderr.write(
186
+ `${label}: could not read ${file} as JSON — nothing was checked. ` +
187
+ `${String(error?.message ?? error).split('\n')[0]}\n`,
188
+ );
189
+ process.exit(1);
190
+ }
191
+ if (!Array.isArray(parsed)) {
192
+ process.stderr.write(
193
+ `${label}: ${file} does not contain a JSON array of merged PRs, so nothing ` +
194
+ 'was checked. Expected the shape `gh pr list --json …` produces.\n',
195
+ );
196
+ process.exit(1);
197
+ }
198
+ return parsed;
199
+ };
200
+
201
+ const parseArgs = (argv) => {
202
+ const args = { json: false, since: null, input: null };
203
+ for (let i = 0; i < argv.length; i += 1) {
204
+ if (argv[i] === '--json') args.json = true;
205
+ else if (argv[i] === '--since') args.since = argv[++i];
206
+ else if (argv[i] === '--input') args.input = argv[++i];
207
+ }
208
+ return args;
209
+ };
210
+
211
+ const daysAgo = (n) => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
212
+
213
+ /** Same rule as the gate sweep: "could not look" must never render as "clean". */
214
+ const fetchMergedPrs = (since) => {
215
+ try {
216
+ return JSON.parse(
217
+ execFileSync(
218
+ 'gh',
219
+ [
220
+ 'pr',
221
+ 'list',
222
+ '--state',
223
+ 'merged',
224
+ '--limit',
225
+ '100',
226
+ '--search',
227
+ `merged:>=${since}`,
228
+ '--json',
229
+ 'number,title,body,headRefName,mergedAt,url,files,changedFiles,authorAssociation',
230
+ ],
231
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
232
+ ),
233
+ );
234
+ } catch (error) {
235
+ process.stderr.write(
236
+ 'lane reconciliation: could not list merged PRs — the `gh` CLI is missing, ' +
237
+ 'unauthenticated, or the API is unreachable. No lane was reconciled; do not ' +
238
+ 'record an empty `external lane` block from this run. Use --input <file> to ' +
239
+ 'work offline.\n' +
240
+ ` ${String(error?.stderr ?? error?.message ?? error).trim().split('\n')[0]}\n`,
241
+ );
242
+ process.exit(1);
243
+ }
244
+ };
245
+
246
+ /**
247
+ * Was this file invoked directly?
248
+ *
249
+ * Compared by REALPATH on both sides: ESM resolves `import.meta.url` through
250
+ * symlinks while `process.argv[1]` keeps the path as typed, so a project living
251
+ * under a symlinked directory (a macOS temp dir, a symlinked home, a checkout
252
+ * behind a link) would fail a naive equality check — and the script would exit 0
253
+ * having printed nothing, which reads exactly like "no findings".
254
+ */
255
+ const invokedDirectly = () => {
256
+ if (!process.argv[1]) return false;
257
+ const real = (p) => {
258
+ try {
259
+ return realpathSync(p);
260
+ } catch {
261
+ return p;
262
+ }
263
+ };
264
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
265
+ };
266
+
267
+ if (invokedDirectly()) {
268
+ const args = parseArgs(process.argv.slice(2));
269
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
270
+ const prs = args.input
271
+ ? readInput(args.input, 'lane reconciliation')
272
+ : fetchMergedPrs(args.since ?? daysAgo(7));
273
+ // Lane sorting still works without a declaration; only the elevated marks go
274
+ // missing, and they render as "no elevated path crossed" rather than lying.
275
+ const elevatedPaths = readDeclaredPaths(projectRoot) ?? [];
276
+ const result = reconcile({ prs, elevatedPaths });
277
+ process.stdout.write(
278
+ args.json ? `${JSON.stringify(result, null, 2)}\n` : `${renderJournalBlock(result)}\n`,
279
+ );
280
+ }
@@ -0,0 +1,62 @@
1
+ // The kill switch — one implementation, imported by everything that reads it.
2
+ //
3
+ // It lived in two places once: `guard-bash.mjs` (the hook that enforces it) and
4
+ // `preflight.mjs` (the check that reports it before a run starts). The hole was
5
+ // fixed in the hook and left open in preflight for a whole review cycle, which is
6
+ // the argument for this file existing at all — a brake with two implementations
7
+ // has two chances to be wrong, and the one nobody is looking at is the one that is.
8
+ //
9
+ // 🔴 The env variable may only ADD a brake, never remove one.
10
+ //
11
+ // `AGENT_LOOP_STOP` used to REPLACE the machine-level path, so any value naming a
12
+ // file that does not exist turned the brake off while the operator's real flag sat
13
+ // untouched in their home directory. An override that can disarm a brake is not an
14
+ // override, it is a bypass — and it was reachable from `.claude/settings.json`,
15
+ // which hooks inherit.
16
+ //
17
+ // Machine-level, not repo-level, on purpose: a git worktree is its own project
18
+ // root, so a flag dropped in the main checkout would be invisible to a session
19
+ // running inside one. A brake that is silently absent is worse than no brake.
20
+ import { existsSync } from 'node:fs';
21
+ import { homedir, userInfo } from 'node:os';
22
+ import { delimiter, join } from 'node:path';
23
+
24
+ /** Every path that arms the brake. The machine-level default is always first. */
25
+ export const stopFlags = (env = process.env) => {
26
+ // BOTH homes: `homedir()` honours $HOME, which `.claude/settings.json` can set —
27
+ // pointing it at an empty directory disarmed the brake. `userInfo()` reads the
28
+ // password database and ignores the environment, so the operator's real flag is
29
+ // always among the paths checked.
30
+ const homes = new Set([homedir()]);
31
+ try {
32
+ homes.add(userInfo().homedir);
33
+ } catch {
34
+ // no password entry — the env-derived home is all there is
35
+ }
36
+ const paths = [...homes].map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
37
+ const extra = env.AGENT_LOOP_STOP;
38
+ if (extra) {
39
+ // Filtered and CAPPED before the spread, never after. Spreading an
40
+ // input-derived array is unbounded: 115k empty entries from `':'.repeat(…)`
41
+ // overflowed the argument limit, and the RangeError was swallowed into
42
+ // "allow" by the hook's fail-open catch — disarming every rule while the
43
+ // brake was armed. Bounded work is not a performance concern here, it is the
44
+ // security property.
45
+ const extras = extra
46
+ .split(delimiter)
47
+ .filter(Boolean)
48
+ .slice(0, 32);
49
+ paths.push(extra, ...extras);
50
+ }
51
+ return [...new Set(paths)].slice(0, 64);
52
+ };
53
+
54
+ /** The armed flag file, or null. */
55
+ export const brakeIsOn = (env = process.env) =>
56
+ stopFlags(env).find((path) => {
57
+ try {
58
+ return existsSync(path);
59
+ } catch {
60
+ return false;
61
+ }
62
+ }) ?? null;
@@ -20,6 +20,10 @@
20
20
  {
21
21
  "type": "command",
22
22
  "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-no-verify.mjs\""
23
+ },
24
+ {
25
+ "type": "command",
26
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-bash.mjs\""
23
27
  }
24
28
  ]
25
29
  }