create-agent-rig 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +207 -8
  2. package/README.md +50 -17
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +82 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +38 -14
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +21 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/templates/agent-os/init/AGENTS.md +191 -0
  14. package/templates/agent-os/init/CLAUDE.md +61 -9
  15. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  18. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  19. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  20. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  21. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  22. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  23. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  24. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  26. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  27. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  31. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  32. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  33. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  34. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  35. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  36. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  37. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  38. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  39. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  40. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  41. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  42. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  43. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  44. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  45. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  46. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  47. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  48. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  49. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  50. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  51. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  52. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  53. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  55. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  56. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  57. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  58. package/templates/agent-os/universal/.claude/settings.json +7 -2
  59. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  60. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  62. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  63. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  64. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  65. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  66. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  67. package/templates/agent-os/universal/AGENTS.md +164 -0
  68. package/templates/agent-os/universal/CLAUDE.md +47 -14
  69. package/templates/agent-os/universal/PLAN.md +7 -40
  70. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  71. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  72. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  73. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  74. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  75. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  76. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  77. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  78. package/templates/agent-os/universal/journal/README.md +101 -0
  79. package/templates/agent-os/universal/layers.json +36 -2
  80. package/templates/hash-history.json +2 -1
  81. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  82. package/templates/skeleton/aws-serverless/README.md +91 -9
  83. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  84. package/templates/skeleton/aws-serverless/gitignore +37 -0
  85. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  86. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  87. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  88. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  89. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  90. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  91. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  92. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  93. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  94. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  95. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  96. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  97. package/templates/skeleton/node-service/README.md +11 -1
  98. package/templates/skeleton/node-service/gitignore +34 -0
  99. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  100. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  101. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  102. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  103. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  104. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  105. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -3,9 +3,13 @@
3
3
  // an agent (or a human using the agent) cannot write an impure line into
4
4
  // packages/core/src/ even if it wants to.
5
5
  //
6
- // Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
6
+ // Contract (Claude Code and Codex): JSON on stdin; exit 0 = allow, exit 2 = block, and
7
7
  // stderr is shown to the agent as the reason.
8
+ // Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
9
+ // codex.test.ts › "$guard blocks with a neutral, actionable size-limit refusal"; generated
10
+ // projects do not carry that suite, and a downstream edit requires a local replacement test.
8
11
  import { readFileSync } from 'node:fs';
12
+ import { editFragments } from './lib/edit-input.mjs';
9
13
 
10
14
  /** The only non-relative import the core may use: its schema/validation library. */
11
15
  const ALLOWED_PACKAGES = ['zod'];
@@ -31,17 +35,27 @@ function main() {
31
35
  } catch {
32
36
  return 0; // unparseable payload: not ours to judge
33
37
  }
34
- const toolName = input.tool_name;
35
- const toolInput = input.tool_input ?? {};
36
- if (toolName !== 'Write' && toolName !== 'Edit') return 0;
37
-
38
- const filePath = String(toolInput.file_path ?? '').replaceAll('\\', '/');
39
- if (!CORE_PATH.test(filePath) || !CODE_FILE.test(filePath)) return 0;
40
-
41
- const fragment = String(
42
- (toolName === 'Write' ? toolInput.content : toolInput.new_string) ?? '',
38
+ const fragments = editFragments(input);
39
+ const blocked = fragments.find(
40
+ ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
43
41
  );
44
- const violations = findViolations(fragment);
42
+ const globalRefusal = blocked?.inspectionRefusal;
43
+ if (globalRefusal) {
44
+ process.stderr.write(
45
+ `BLOCKED — cannot safely inspect this edit: ${globalRefusal}\n` +
46
+ // The remedy has to match the refusal: splitting cannot change a
47
+ // container shape, and a fixed line sent the agent into a retry loop
48
+ // on the one path it could not retry out of.
49
+ `${blocked.remedy ?? 'Split it into a smaller patch and retry.'}\n`,
50
+ );
51
+ return 2;
52
+ }
53
+
54
+ const violations = fragments.flatMap(({ filePath, fragment, inspectionRefusal }) => {
55
+ if (!CORE_PATH.test(filePath) || !CODE_FILE.test(filePath)) return [];
56
+ if (inspectionRefusal) return [`cannot safely inspect this move — ${inspectionRefusal}`];
57
+ return findViolations(fragment);
58
+ });
45
59
  if (violations.length === 0) return 0;
46
60
 
47
61
  process.stderr.write(
@@ -0,0 +1,180 @@
1
+ // PreToolUse hook: a credential never enters the repository through an edit.
2
+ //
3
+ // Two arms, because a credential arrives two ways and only one of them has a
4
+ // telltale name:
5
+ //
6
+ // - the PATH is a credential file (`jira.env`, `id_rsa`, `secrets/prod.txt`)
7
+ // - the CONTENT carries a credential VALUE, in a file named nothing special
8
+ //
9
+ // The second is the likelier path and the one nothing in this rulebook refused
10
+ // before: an ignore rule can only ever answer the first.
11
+ //
12
+ // The vocabulary and both matchers come from `../scripts/lib/secrets.mjs`. This
13
+ // file holds no list of its own on purpose — `.claude/rules/invariants.md`, "one
14
+ // mechanism, one implementation": the same invariant written in two places will
15
+ // disagree, and the copy nobody is looking at is the one that is wrong.
16
+ //
17
+ // Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
18
+ // stderr is shown to the agent as the reason.
19
+ //
20
+ // 🔴 LIMITS, stated because a guard's own claim about its reach is the first
21
+ // thing to go stale. Each names the test that pins it where one exists, and says
22
+ // so plainly where none does — a limits comment nothing checks drifts into
23
+ // overstatement, which is the direction that gets a reader hurt. ⚠ Those tests live in the GENERATOR this rig came from, not here;
24
+ // `.claude/rules/invariants.md` ("About the hooks you were given") says the same
25
+ // of this hook's own tests, and the moment you edit it they are yours.
26
+ //
27
+ // There are FOUR:
28
+ //
29
+ // - It sees ONE edit fragment, not the resulting file. A credential assembled
30
+ // across two edits is not seen — see guard-secret-file.test.ts › "does not
31
+ // see a credential split across two edits, because it is shown one fragment
32
+ // at a time". This is the same limit every guard in this directory has,
33
+ // stated in full in `.claude/rules/invariants.md`, "What the enforcement
34
+ // actually is — stated exactly".
35
+ // - It sees only what the AGENT writes. The `toolName` branch in `main` below
36
+ // names the complete surface: `Write`, `Edit`, and `apply_patch`; every other
37
+ // tool returns before inspection. A human editing the file, or a
38
+ // `git commit` of something already on disk, never reaches a PreToolUse hook
39
+ // at all, and no test here can show that: it is a property of the harness,
40
+ // not of this file.
41
+ //
42
+ // 🔴 WHETHER ANYTHING ELSE CATCHES THAT IS NOT A QUESTION THIS FILE CAN
43
+ // ANSWER, and it is the one worth asking before relying on the rule. The
44
+ // layer that covers a human's edit is a COMMIT-TIME check — a git hook, a CI
45
+ // sweep — and this hook cannot see whether one is installed. Look at
46
+ // `.husky/` and the CI workflow rather than assuming: the generator this
47
+ // rulebook came from has both, running the same vocabulary; a freshly
48
+ // generated rig ships neither, and adding one is a decision for the project.
49
+ //
50
+ // - It reads at most the first 2 MB of the text being written, the cap
51
+ // `findSecretValues` applies by default so a fail-open guard cannot be made
52
+ // to hang. A credential past that point is not seen. ⚠ No test here pins
53
+ // this one: the case is pinned one layer down, on the module, by
54
+ // secrets-lib.test.ts › "has a limit even when the caller names none". The CI sweep lifts the
55
+ // cap; this hook cannot, and that asymmetry is the point.
56
+ // - It FAILS OPEN on what it cannot understand — see guard-secret-file.test.ts
57
+ // › "allows a payload that is not JSON at all" and its neighbours. An
58
+ // unparseable payload, a missing field, or an internal throw all allow the
59
+ // edit; a crashed guard that blocks everything gets deleted within the hour.
60
+ //
61
+ // ⚠ **An `apply_patch` command that is PRESENT and is not a shape this guard
62
+ // reads is the other case, and it now REFUSES** — see codex.test.ts ›
63
+ // "refuses, rather than failing open, when apply_patch command is supplied
64
+ // as %s". The line between them is whether the guard can tell: an absent
65
+ // field is a payload it does not understand, a container it detects and
66
+ // cannot read is a decision it can report. That reversed a contract this
67
+ // file previously pinned the other way, so it is stated rather than
68
+ // assumed. What catches the rest is whatever this project has put
69
+ // behind it: review always, a commit-time check once one exists.
70
+ //
71
+ // Failing open is also why every line here does provably bounded work: the scan
72
+ // is capped inside `findSecretValues`, there is no recursion, and the one arm
73
+ // that revisits offsets — the one that judges a candidate's value — is bounded
74
+ // by an explicit per-line candidate cap rather than running to exhaustion. Any
75
+ // unbounded work in a fail-open guard is a total bypass of every rule at once,
76
+ // not just of this one.
77
+ import { readFileSync } from 'node:fs';
78
+
79
+ import { findSecretValues, isCredentialPath } from '../scripts/lib/secrets.mjs';
80
+ import { editFragments } from './lib/edit-input.mjs';
81
+
82
+ /** Where a refusal points the agent, so the block is actionable rather than a wall. */
83
+ const WHERE_CREDENTIALS_BELONG =
84
+ 'Credentials live outside the repo (an env file in your home config, a secret manager) ' +
85
+ 'and reach the process through the environment — see .claude/rules/autonomy.md, "Never".';
86
+
87
+ function main() {
88
+ let input;
89
+ try {
90
+ input = JSON.parse(readFileSync(0, 'utf8'));
91
+ } catch {
92
+ return 0; // unparseable payload: not ours to judge
93
+ }
94
+
95
+ const toolName = input?.tool_name;
96
+ if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'apply_patch') return 0;
97
+
98
+ if (toolName === 'apply_patch') {
99
+ let refused = false;
100
+ for (const { filePath, fragment, inspectionRefusal, remedy, appliesToAll } of editFragments(input)) {
101
+ if (inspectionRefusal) {
102
+ refused = true;
103
+ process.stderr.write(
104
+ `BLOCKED — cannot safely inspect this edit: ${inspectionRefusal}\n` +
105
+ // The remedy has to match the refusal: splitting cannot change a
106
+ // container shape, and a fixed line sent the agent into a retry loop
107
+ // on the one path it could not retry out of.
108
+ `${remedy ?? 'Split it into a smaller patch and retry.'}\n`,
109
+ );
110
+ continue;
111
+ }
112
+ if (isCredentialPath(filePath)) {
113
+ refused = true;
114
+ process.stderr.write(`BLOCKED — "${filePath}" is a credential file, and this repository never carries one.\n${WHERE_CREDENTIALS_BELONG}\n`);
115
+ continue;
116
+ }
117
+ const findings = findSecretValues(fragment);
118
+ if (findings.length > 0 || appliesToAll) {
119
+ refused = true;
120
+ if (findings.length > 0) process.stderr.write(`BLOCKED — this edit writes a credential value into "${filePath}".\n${WHERE_CREDENTIALS_BELONG}\n`);
121
+ }
122
+ }
123
+ return refused ? 2 : 0;
124
+ }
125
+
126
+ const toolInput = input?.tool_input ?? {};
127
+ const filePath = String(toolInput.file_path ?? '').replaceAll('\\', '/');
128
+ if (filePath === '') return 0; // nothing to judge; fail open
129
+
130
+ // The tool sends an absolute path. Judge the repo-relative tail so a checkout
131
+ // living under a directory literally called `secrets` does not make every edit
132
+ // in the project a credential.
133
+ // Trailing slashes stripped: with `CLAUDE_PROJECT_DIR=/repo/` the prefix test
134
+ // below never matches, every path stays absolute, and a checkout that happens
135
+ // to live under a directory called `secrets` has EVERY edit refused. That is
136
+ // the "deleted within the hour" outcome `.claude/rules/invariants.md` warns
137
+ // about — see guard-secret-file.test.ts › "judges the repo-relative path even
138
+ // when the project directory is given with a trailing slash".
139
+ const projectDir = String(process.env.CLAUDE_PROJECT_DIR ?? '')
140
+ .replaceAll('\\', '/')
141
+ .replace(/\/+$/, '');
142
+ const relativePath =
143
+ projectDir !== '' && filePath.startsWith(`${projectDir}/`)
144
+ ? filePath.slice(projectDir.length + 1)
145
+ : filePath;
146
+
147
+ if (isCredentialPath(relativePath)) {
148
+ process.stderr.write(
149
+ `BLOCKED — "${relativePath}" is a credential file, and this repository never carries one.\n` +
150
+ `${WHERE_CREDENTIALS_BELONG}\n` +
151
+ `If this file is a documented placeholder, name it .env.example — that form stays committable.\n`,
152
+ );
153
+ return 2;
154
+ }
155
+
156
+ const fragment = String((toolName === 'Write' ? toolInput.content : toolInput.new_string) ?? '');
157
+ const findings = findSecretValues(fragment);
158
+ if (findings.length === 0) return 0;
159
+
160
+ process.stderr.write(
161
+ `BLOCKED — this edit writes a credential value into "${relativePath}":\n` +
162
+ findings
163
+ .map((finding) => ` - ${finding.id} on line ${finding.line} of the text being written`)
164
+ .join('\n') +
165
+ `\n${WHERE_CREDENTIALS_BELONG}\n` +
166
+ // Deliberately NOT the matched text. A guard that prints what it found has
167
+ // copied the credential into a hook transcript and a terminal scrollback —
168
+ // it has leaked the secret in the act of refusing it.
169
+ `The matched value is deliberately not shown; open the line above to see it.\n`,
170
+ );
171
+ return 2;
172
+ }
173
+
174
+ let status;
175
+ try {
176
+ status = main();
177
+ } catch {
178
+ status = 0; // fail open — see the LIMITS block above
179
+ }
180
+ process.exit(status);
@@ -3,9 +3,13 @@
3
3
  // layer (`…/db`) and never the services. Enforced at the tool layer, same as
4
4
  // core purity: best-effort text scan, failing safe toward a false block.
5
5
  //
6
- // Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
6
+ // Contract (Claude Code and Codex): JSON on stdin; exit 0 = allow, exit 2 = block, and
7
7
  // stderr is shown to the agent as the reason.
8
+ // Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
9
+ // codex.test.ts › "$guard blocks with a neutral, actionable size-limit refusal"; generated
10
+ // projects do not carry that suite, and a downstream edit requires a local replacement test.
8
11
  import { readFileSync } from 'node:fs';
12
+ import { editFragments } from './lib/edit-input.mjs';
9
13
 
10
14
  const WEB_PATH = /(^|\/)apps\/web\//;
11
15
  const CODE_FILE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
@@ -22,21 +26,36 @@ function main() {
22
26
  } catch {
23
27
  return 0; // unparseable payload: not ours to judge
24
28
  }
25
- const toolName = input.tool_name;
26
- const toolInput = input.tool_input ?? {};
27
- if (toolName !== 'Write' && toolName !== 'Edit') return 0;
28
-
29
- const filePath = String(toolInput.file_path ?? '').replaceAll('\\', '/');
30
- if (!WEB_PATH.test(filePath) || !CODE_FILE.test(filePath)) return 0;
31
-
32
- const fragment = String((toolName === 'Write' ? toolInput.content : toolInput.new_string) ?? '');
33
29
  const violations = [];
34
30
  const importRe =
35
31
  /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"]([^'"]+)['"]/gm;
36
- for (const match of fragment.matchAll(importRe)) {
37
- const spec = match[1];
38
- if (FORBIDDEN_WORKSPACE.test(spec) || FORBIDDEN_RELATIVE.test(spec)) {
39
- violations.push(spec);
32
+ const fragments = editFragments(input);
33
+ const blocked = fragments.find(
34
+ ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
35
+ );
36
+ const globalRefusal = blocked?.inspectionRefusal;
37
+ if (globalRefusal) {
38
+ process.stderr.write(
39
+ `BLOCKED — cannot safely inspect this edit: ${globalRefusal}\n` +
40
+ // The remedy has to match the refusal: splitting cannot change a
41
+ // container shape, and a fixed line sent the agent into a retry loop
42
+ // on the one path it could not retry out of.
43
+ `${blocked.remedy ?? 'Split it into a smaller patch and retry.'}\n`,
44
+ );
45
+ return 2;
46
+ }
47
+
48
+ for (const { filePath, fragment, inspectionRefusal } of fragments) {
49
+ if (!WEB_PATH.test(filePath) || !CODE_FILE.test(filePath)) continue;
50
+ if (inspectionRefusal) {
51
+ violations.push(`cannot safely inspect this move — ${inspectionRefusal}`);
52
+ continue;
53
+ }
54
+ for (const match of fragment.matchAll(importRe)) {
55
+ const spec = match[1];
56
+ if (FORBIDDEN_WORKSPACE.test(spec) || FORBIDDEN_RELATIVE.test(spec)) {
57
+ violations.push(spec);
58
+ }
40
59
  }
41
60
  }
42
61
  if (violations.length === 0) return 0;
@@ -10,8 +10,144 @@
10
10
  // The injected content is deliberately STATELESS — rules, never facts about
11
11
  // the moment (mid-session injections are replayed on resume, so timestamps
12
12
  // or SHAs here would lie). And it is only the load-bearing part, not the
13
- // whole rulebook: CLAUDE.md is already loaded by the tool itself.
14
- import { readFileSync } from 'node:fs';
13
+ // whole rulebook.
14
+ //
15
+ // Which part is load-bearing is not this hook's judgment to make: the rule
16
+ // file marks what it does not need injected, and everything else goes. That
17
+ // division of labour is the whole design, and it was arrived at the expensive
18
+ // way. An earlier version of this file selected `## ` sections from a kept
19
+ // list — and four review rounds each found a different way for that selection
20
+ // to return a silently truncated excerpt: a heading inside a code fence, a
21
+ // heading inside a skipped region, a section whose heading and body fell on
22
+ // opposite sides of a marker, a heading inside an HTML block. Each fix closed
23
+ // one spelling and the next round found another, because a parser that infers
24
+ // structure has no bottom. This one does not parse structure at all.
25
+ //
26
+ // ⚠ The saving rests on an assumption this repository cannot enforce: that the
27
+ // tool already loads `.claude/rules/*.md` as project instructions, so injecting
28
+ // the whole file pays for it twice. That is harness behaviour, observable but
29
+ // not pinned here. Where it does not hold, this is a plain subtraction — which
30
+ // is why every ambiguity resolves toward injecting more.
31
+ import { readFileSync, realpathSync } from 'node:fs';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ // Regions the rule file marks as not worth injecting. The marker is explicit
35
+ // and lives in the rule file itself, where the person editing it can see it —
36
+ // an earlier version keyed on heading level instead, which made "what every
37
+ // session is governed by" a silent consequence of a formatting choice.
38
+ const SKIP_OPEN = '<!-- inject:skip -->';
39
+ const SKIP_CLOSE = '<!-- /inject:skip -->';
40
+
41
+ /**
42
+ * A fence opener, as CommonMark defines one: three or more backticks or
43
+ * tildes. The closer has to be the same character and no shorter, which is why
44
+ * this returns the run rather than a boolean — a three-backtick line inside a
45
+ * four-backtick block is content, not the end of the block.
46
+ */
47
+ function fenceRun(line) {
48
+ const trimmed = line.trimStart();
49
+ const char = trimmed[0];
50
+ if (char !== '`' && char !== '~') return null;
51
+ let length = 0;
52
+ while (trimmed[length] === char) length += 1;
53
+ if (length < 3) return null;
54
+ // `bare` is what separates a closer from an opener: an opener may carry an
55
+ // info string (```sh), a closer may not.
56
+ return { char, length, bare: trimmed.slice(length).trim() === '' };
57
+ }
58
+
59
+ /**
60
+ * The whole file, minus every region the file itself marks with
61
+ * `<!-- inject:skip -->` … `<!-- /inject:skip -->`. That is the entire
62
+ * operation: nothing here reads a heading, so no arrangement of headings —
63
+ * indented, inside an HTML block, split across a marker — can change what
64
+ * survives. What is omitted is a decision made in the rule file, by whoever
65
+ * writes the rule, and visible on the line above it.
66
+ *
67
+ * Fenced code is data, not structure, so a marker inside a fence is content.
68
+ * Both fence characters, with the CommonMark closing rule (same character, at
69
+ * least as long, nothing but whitespace after it) — a rules file quotes shell
70
+ * and markdown at each other, and an opener may carry an info string where a
71
+ * closer may not.
72
+ *
73
+ * It returns the input UNCHANGED whenever the markup is malformed: a skip
74
+ * region left open, one closed without being opened, one nested inside
75
+ * another, or a fence left open. Partial output is the dangerous answer,
76
+ * because a governance section can go missing with nothing to notice it; a run
77
+ * that gets the whole file has only paid twice.
78
+ *
79
+ * The limits, so nobody relies on cover that is not here. A marker is
80
+ * recognised only as the first non-whitespace text on its own line — so one
81
+ * inside a blockquote is not a marker, and its text reaches the context. And
82
+ * fence-awareness is the only structure it knows: a BALANCED marker pair
83
+ * written inside an indented code block or an HTML comment is obeyed, so a
84
+ * document that demonstrates the markers loses the lines between them. Show
85
+ * them inside a fence.
86
+ */
87
+ export function excerptAutonomy(markdown) {
88
+ const lines = markdown.split('\n');
89
+ const kept = [];
90
+ let skipping = false;
91
+ let fence = null;
92
+ let malformed = false;
93
+
94
+ for (const line of lines) {
95
+ const run = fenceRun(line);
96
+ if (fence) {
97
+ if (run && run.char === fence.char && run.length >= fence.length && run.bare) fence = null;
98
+ } else if (run) {
99
+ fence = run;
100
+ } else {
101
+ const trimmed = line.trim();
102
+ // Matched by prefix: a marker with something after it is still a marker,
103
+ // and the whole line goes. Requiring the line to be exactly the marker
104
+ // would let `<!-- inject:skip --> note` open nothing and then print
105
+ // itself into the context.
106
+ if (trimmed.startsWith(SKIP_OPEN)) {
107
+ // A second open inside a region is as much a mistake as a stray close,
108
+ // and the two are the same signal: the markers do not pair up.
109
+ malformed ||= skipping;
110
+ skipping = true;
111
+ continue;
112
+ }
113
+ if (trimmed.startsWith(SKIP_CLOSE)) {
114
+ malformed ||= !skipping;
115
+ skipping = false;
116
+ continue;
117
+ }
118
+ }
119
+ if (!skipping) kept.push(line);
120
+ }
121
+
122
+ if (fence || skipping || malformed) return markdown;
123
+ const excerpt = kept.join('\n').trim();
124
+ // Nothing left is the largest possible version of "partial output", and the
125
+ // marker pairing cannot see it: a balanced pair around the whole file is
126
+ // well-formed. A zero, not a threshold — this is deliberately not the byte
127
+ // ratio an earlier round used and deleted.
128
+ return excerpt === '' ? markdown : excerpt;
129
+ }
130
+
131
+ /**
132
+ * Whether this file is being run as a script rather than imported.
133
+ *
134
+ * The realpath on both sides is the point: ESM resolves `import.meta.url`
135
+ * through symlinks while `process.argv[1]` keeps the path as typed, so a
136
+ * project under a symlinked directory — a macOS temp dir, a symlinked home, a
137
+ * checkout behind a link — fails a naive equality check. The hook would then
138
+ * print nothing and exit 0, which reads exactly like a healthy session.
139
+ */
140
+ function invokedDirectly() {
141
+ if (!process.argv[1]) return false;
142
+ const real = (p) => {
143
+ try {
144
+ return realpathSync(p);
145
+ } catch {
146
+ return p;
147
+ }
148
+ };
149
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
150
+ }
15
151
 
16
152
  function main() {
17
153
  let input;
@@ -22,15 +158,47 @@ function main() {
22
158
  }
23
159
  if (input.hook_event_name !== 'SessionStart') return 0;
24
160
 
161
+ let rules;
25
162
  try {
26
- const rules = readFileSync(new URL('../rules/autonomy.md', import.meta.url), 'utf8');
27
- process.stdout.write(
28
- `[agent-os] Autonomy rules refresh — in force regardless of compaction:\n\n${rules}\n`,
29
- );
163
+ rules = readFileSync(new URL('../rules/autonomy.md', import.meta.url), 'utf8');
30
164
  } catch {
31
165
  // no rules file — nothing to inject, never an error
166
+ return 0;
167
+ }
168
+
169
+ // The cut is the only thing allowed to fail here, and its failure resolves
170
+ // the way every other ambiguity in this file does: inject more. Leaving it
171
+ // inside the read's catch meant a throw in the excerpter printed NOTHING and
172
+ // exited 0 — the rules gone, and the session looking healthy.
173
+ let body;
174
+ try {
175
+ body = excerptAutonomy(rules);
176
+ } catch {
177
+ body = rules;
32
178
  }
179
+
180
+ // The banner reports what happened, so it cannot be written once and assumed:
181
+ // on the fallback path nothing was removed, and telling a session to go read
182
+ // four sections it is already holding is the same kind of false report the
183
+ // cut itself is built to avoid.
184
+ // Compared trimmed, because the two paths differ by a trailing newline that
185
+ // is not a removal: the excerpt is trimmed and the fallback is not. A rules
186
+ // file that marks nothing would otherwise be announced as an excerpt — which
187
+ // is the case a project re-scoping its own rulebook lands in, every session.
188
+ const notice =
189
+ body.trim() === rules.trim()
190
+ ? 'This is `.claude/rules/autonomy.md` in full.\n\n'
191
+ : 'This is `.claude/rules/autonomy.md` with the sections it marks as ' +
192
+ 'reference removed — read the file itself for those: how the Tier-2 ' +
193
+ 'gate is swept from outside, how external work is reconciled, ' +
194
+ 'post-deploy verification, and the escalation format.\n\n';
195
+
196
+ process.stdout.write(
197
+ `[agent-os] Autonomy rules refresh — in force regardless of compaction.\n${notice}${body}\n`,
198
+ );
33
199
  return 0;
34
200
  }
35
201
 
36
- process.exit(main());
202
+ if (invokedDirectly()) {
203
+ process.exit(main());
204
+ }