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
package/CHANGELOG.md ADDED
@@ -0,0 +1,170 @@
1
+ # Changelog
2
+
3
+ Notable changes per release. The generated projects are the product, so an entry
4
+ says what a **newly scaffolded project** gains or loses — not what moved inside
5
+ the generator.
6
+
7
+ Versions are published to npm as [`create-agent-rig`](https://www.npmjs.com/package/create-agent-rig);
8
+ `npx github:serhii-baksheiev/create-agent-rig` keeps working for either path.
9
+
10
+ ## 0.3.1
11
+
12
+ `create-agent-rig init` shipped a rig that looked installed and enforced
13
+ nothing. Everything below is that one failure, in its four parts — a repo
14
+ `init`ed with 0.3.0 should be re-run with this version (`--force` to replace the
15
+ CLAUDE.md it wrote).
16
+
17
+ ### Fixed
18
+
19
+ - **The hooks are wired.** `init` laid the hook files down and stopped there: no
20
+ `.claude/settings.json` meant `guard-bash`, `block-no-verify`, `gate-stop-dod`
21
+ and `inject-rules` were never called, while the installed `CLAUDE.md` claimed
22
+ they were enforced at the tool layer. The wiring is now _derived_ from the
23
+ shipped settings, so it names exactly the hooks that travelled — never one that
24
+ did not. Where the repo already has a `settings.json`, `init` keeps it and
25
+ prints the entries to merge rather than failing silently.
26
+ - **The kill switch works.** `init` copied templates byte-for-byte, leaving
27
+ `__PROJECT_NAME__` in six places — including `stop-flag.mjs`, so the brake
28
+ looked for `~/.claude/__PROJECT_NAME__-loop-STOP` while the operator, following
29
+ the instructions in the same install, created `~/.claude/<repo>-loop-STOP`. It
30
+ never fired, and never said so.
31
+ - **The installed `CLAUDE.md` describes the repo it landed in.** It used to be
32
+ the generated monorepo's map — `packages/core/`, `apps/web/`, links to an
33
+ `architecture.md` and two guards that `init` deliberately does not install. It
34
+ is now its own document: what was installed, what was not, and that the
35
+ architecture rules are yours to write.
36
+ - **The elevated-path block names paths that exist.** It seeded
37
+ `packages/db/src/` into repos that have no such directory, so the Tier-2 gate
38
+ sweep reported "clean" while looking at nothing.
39
+
40
+ ### Added
41
+
42
+ - A template test that fails if anything `init` installs references a `.claude`
43
+ file `init` does not install — the drift that produced three of the four
44
+ findings above, now mechanical.
45
+
46
+ ## 0.3.0
47
+
48
+ The factory extraction: a scaffolded project now arrives with a working
49
+ autonomous loop and the mechanisms that watch it, rather than an empty `.claude/`.
50
+
51
+ ### Added
52
+
53
+ - **`guard-bash` hook** — the "Never" tier made mechanical: force-pushing or
54
+ deleting a shared branch, a direct push to the default branch, a production
55
+ deploy trigger, a catastrophic delete. It **parses** the command (quotes
56
+ honoured) instead of pattern-matching, so a commit message that mentions a
57
+ forbidden flag is prose, not a bypass.
58
+ - **A kill switch that is a real file.** `touch ~/.claude/<project>-loop-STOP`
59
+ and no merge lands until it is removed. Everything short of the merge stays
60
+ allowed on purpose — stopping cleanly must not mean losing work.
61
+ - **The queue seam.** `loop` no longer reads one tracker: selection goes through
62
+ `.claude/scripts/queue/`, with a pure core (filters, blocker resolution, tier
63
+ ration, sort, stop conditions) and three adapters — `plan-md` (the default; the
64
+ only one that works before a project has a remote), `github-issues`, `jira`.
65
+ - **Two sweeps that run outside any session** — `detect-missed-gate` finds merges
66
+ that crossed an elevated path with no recorded reviewer verdict;
67
+ `reconcile-external-prs` accounts for work that reached the default branch
68
+ outside the queue. Both exist because a run cannot report its own missed gate.
69
+ - **`preflight`** — the pre-run checks, which also print the items it did _not_
70
+ check, every time.
71
+ - **Skills** — `worktree-task` (isolation when a second session may run) and
72
+ `new-invariant` (a generator for the invariant→hook→test pattern, with a
73
+ working example and its test).
74
+ - **`rules/invariants.md`** — the pattern behind every hook here, stated once, so
75
+ the hooks read as examples rather than as laws.
76
+ - **`aws-cdk` target extras** — the `ro-debug` skill (read-only runtime
77
+ investigation, with the traps that produce confident wrong diagnoses) and the
78
+ transferable AWS rules.
79
+ - **`elevated-paths`** — a declaration in `CLAUDE.md`, composed with any block in
80
+ `.claude/rules/`, naming the paths where Tier-2 changes live.
81
+
82
+ ### Changed
83
+
84
+ - The governance summary counts `.mjs` hooks only — a config file listed as an
85
+ enforced hook overstated the one number this tool exists to make credible.
86
+ - `autonomy.md`: the tier is decided by what a change **touches**, not by what
87
+ the task predicted it would touch.
88
+
89
+ ### Fixed
90
+
91
+ Four review rounds, ten reviewers, on this release's own code. What they caught,
92
+ each reproduced before the fix and re-verified after:
93
+
94
+ - a PR body could **forge its own reviewer verdict** and suppress the gate sweep
95
+ — the body is written by the actor being audited, so only the `human-review`
96
+ label (which needs repository permission) suppresses now;
97
+ - the `plan-md` adapter's close **deleted the wrong line**, destroying a human's
98
+ Operator-queue entry and leaving the shipped item selectable;
99
+ - every `github-issues` write **threw on success** (those `gh` subcommands print
100
+ text, not JSON) — `escalate` posted its diagnosis and then died before applying
101
+ the label that stops the item being re-picked;
102
+ - the kill switch could be **disarmed by an env variable**, and was fixed in the
103
+ hook while the identical hole sat in `preflight` — the brake now has one
104
+ implementation;
105
+ - three **total bypasses** in the guard, each an exception inside its own work
106
+ that the fail-open catch turned into "allow": an unbounded spread, a recursive
107
+ brace expansion, and a quadratic path collapse;
108
+ - a heredoc pre-pass that could **hide any command** from every rule;
109
+ - a ReDoS in the blocker parser reachable by anyone able to open an issue.
110
+
111
+ Rounds 4 and 5, on the fixes themselves:
112
+
113
+ - a here-string (`cat <<<X`) and an arithmetic left shift (`$((1<<n))`) were each
114
+ read as heredoc markers, **hiding every command up to the next matching line**;
115
+ - `git commit -nm "msg"` bypassed the pre-commit gate outright — the one thing
116
+ that hook exists to stop, in the spelling people actually type;
117
+ - with the kill switch armed, `git merge feat/x && git push` still landed a merge
118
+ on the default branch; a push must now name its ref while stopped;
119
+ - pointing `HOME` at an empty directory disarmed the brake; it is now found
120
+ through the password database as well as the environment;
121
+ - `gh --json files` truncates at 100 with **no marker**, and the gate sweep read
122
+ the short list as "touched nothing elevated" — a PR padded past 100 files hid
123
+ its elevated change. The sweep now compares against `changedFiles`;
124
+ - declaring `.claude/` elevated was a no-op, because every `.md` under it counted
125
+ as inert — so a merge rewriting the autonomy tiers passed the gate meant to
126
+ catch exactly that;
127
+ - a quadratic reviewer-name regex cost ~4 s per crafted PR body, minutes across a
128
+ sweep that reports nothing when killed.
129
+
130
+ The README's enforcement claims were overstated and are now scoped to what the
131
+ guard actually inspects, with the omissions listed in the hook itself: only `rm`
132
+ for deletes, only a workflow dispatch for deploys, only a push that names its
133
+ branch, and nothing carried as a flag value.
134
+
135
+ The lesson that generalises is now a rule (`invariants.md`): **a guard that fails
136
+ open must do provably bounded work**, because fail-open makes every line of work
137
+ a potential total bypass — and prefer deleting a rule to adding one.
138
+
139
+ ## 0.2.0
140
+
141
+ Distribution hardening (file modes, the `gitignore`→`.gitignore` trick, a
142
+ pack-path e2e per target), agent-os v2 (`pr-ship`, `post-deploy-verify`,
143
+ `cdk-diff-reviewer`, review-context isolation, session staleness), the `apps/web`
144
+ frontend proving core purity across the wire, the `gate-stop-dod` and
145
+ `inject-rules` hooks, `agent-rig init`, the `loop` skill, and the dev deploy
146
+ workflows.
147
+
148
+ ## 0.1.0
149
+
150
+ First release: the CLI, `agent-os/universal` + the `node-ts`/`aws-cdk` stack
151
+ layers, and the `aws-serverless` and `node-service` targets.
152
+
153
+ ---
154
+
155
+ ## Releasing
156
+
157
+ `npm publish` needs 2FA and cannot be undone, so an agent prepares a release and
158
+ **stops at that command**. Everything before it is mechanical:
159
+
160
+ 1. `pnpm test` — the full suite, including the e2e that generates both targets
161
+ cold and runs their own checks through the git path **and** the pack path.
162
+ 2. `npm pack --dry-run` — confirm the templates, including the dotted `.claude/`
163
+ tree, are in the tarball. This is where scaffolders break, and the git path
164
+ cannot catch it.
165
+ 3. Version in `package.json` (and the private inner package, kept in step).
166
+ 4. This file, and `PLAN.md` if the plan's claims changed.
167
+ 5. `git tag v<version> && git push --tags`.
168
+ 6. **Owner:** `npm publish`.
169
+ 7. **Owner:** smoke the published artifact — `npx create-agent-rig@<version>` in
170
+ an empty directory, then `pnpm install && pnpm check` inside it.
package/README.md CHANGED
@@ -22,7 +22,15 @@ npx create-agent-rig init --dry-run # print the plan, write nothing
22
22
  ```
23
23
 
24
24
  `init` drops in the autonomy tiers, stop rules, workflow, and the enforcement
25
- hooks, and refuses to clobber an existing `CLAUDE.md`.
25
+ hooks **wired**, in a `.claude/settings.json` that names exactly the hooks it
26
+ installed — plus a `CLAUDE.md` that describes that rig rather than the generated
27
+ monorepo. It refuses to clobber an existing `CLAUDE.md`; if the repo already has
28
+ a `.claude/settings.json`, it keeps it and prints the entries to merge, because a
29
+ hook nothing calls is not enforcement.
30
+
31
+ Two things it deliberately leaves to you, and says so in the installed
32
+ `CLAUDE.md`: the Definition-of-Done gate has no `dod-checks.json` (it cannot know
33
+ your commands), and the elevated-path list names only what every repo has.
26
34
 
27
35
  ## What you get
28
36
 
@@ -39,19 +47,55 @@ never inflated). The hooks live in `.claude/hooks/` and are wired in
39
47
  - **`block-no-verify`** — refuses bypassing pre-commit checks (and knows the
40
48
  difference between using the `--no-verify`/`-n` flag and merely mentioning it
41
49
  in a message);
50
+ - **`guard-bash`** — refuses the part of the "Never" tier a text scan can decide:
51
+ a force-push or `--delete` naming a shared branch, a push that names the default
52
+ branch, `gh workflow run`/`gh api …/dispatches` against a production workflow,
53
+ and `rm` on a catastrophic target. It **parses** the command rather than
54
+ pattern-matching it, so a commit message mentioning a forbidden flag is prose,
55
+ not a bypass — and the file states exactly what it does **not** inspect
56
+ (`cdk deploy`, `find -delete`, a bare `git push`, and more);
42
57
  - **`gate-stop-dod`** — refuses to end the session while a Definition-of-Done
43
58
  check is red; it fails open (a missing or corrupt config never makes the
44
59
  session unquittable) and never blocks twice in a row;
45
60
  - **`inject-rules`** — re-injects the autonomy rules at session start, so they
46
61
  survive compaction and resumes.
47
62
 
48
- Around the hooks, the operating system: **autonomy tiers** (what an agent does
49
- alone / after review / never), **stop rules** (three strikes, flaky retry,
50
- session staleness), **subagent gates** (`test-writer`, `code-reviewer`,
51
- `security-scanner`, and `cdk-diff-reviewer` on the AWS target), **skills**
52
- (`pr-ship` pre-merge gate; `loop` queue driver; `post-deploy-verify` with its
53
- binary HEALTHY/REGRESSION verdict on the AWS target), and a one-page
54
- `CLAUDE.md` map a fresh session orients by.
63
+ **A brake that is a real file.** `touch ~/.claude/<project>-loop-STOP` and no
64
+ merge lands until it is removed enforced at the tool layer, so it holds even if
65
+ nothing reads the rule. Everything short of the merge stays allowed on purpose:
66
+ finish the task, push the branch, open the PR, write the journal. Stopping
67
+ cleanly must not mean losing work.
68
+
69
+ **Two sweeps meant to run outside any session** nothing schedules them for you;
70
+ that is deliberate, because a check a run performs on itself is one a hurried run
71
+ skips. `detect-missed-gate` finds merges
72
+ that crossed an elevated path with no recorded reviewer verdict;
73
+ `reconcile-external-prs` accounts for work that reached the default branch outside
74
+ the queue. They exist because the one failure a run cannot report is its own
75
+ missed gate — the run that skipped it is exactly the run that will not mention it.
76
+
77
+ **A queue behind an adapter.** The `loop` driver selects through
78
+ `.claude/scripts/queue/`: a pure core (filters in order, blocker resolution, the
79
+ elevated-tier ration, stop conditions) with adapters for `PLAN.md` (the default,
80
+ working before a project has a remote), GitHub Issues, and Jira. Two rules are
81
+ load-bearing and tested from both directions — **blockers resolve from links,
82
+ never labels**, and **the agent never files its own work items**.
83
+
84
+ Around all of it: **autonomy tiers** (what an agent does alone / after review /
85
+ never), **stop rules** (three strikes, flaky ≠ retry, session staleness),
86
+ **subagent gates** (`test-writer`, `code-reviewer`, `security-scanner`, and
87
+ `cdk-diff-reviewer` on the AWS target), **skills** (`pr-ship` pre-merge gate;
88
+ `loop` queue driver; `worktree-task` for concurrent sessions; `new-invariant`, a
89
+ generator for the invariant→hook→test pattern; `post-deploy-verify` and
90
+ `ro-debug` on the AWS target), and a one-page `CLAUDE.md` map a fresh session
91
+ orients by.
92
+
93
+ **The hooks are examples, not laws.** `.claude/rules/invariants.md` states the
94
+ pattern behind each one — a stated invariant, a mechanical check, a test for the
95
+ check — so you can delete the ones whose invariant your project does not have and
96
+ spend the slot on one it does. An inherited rule nobody chose is worse than an
97
+ empty rule file: the empty one is visibly incomplete, the inherited one is
98
+ invisibly wrong.
55
99
 
56
100
  The skeleton around it is real and runnable — pure core shared by server _and_
57
101
  browser (one schema validates on both sides of the wire), a mandatory usecase
@@ -117,5 +161,17 @@ dependencies fresh to catch upstream breakage early. This repo dogfoods its own
117
161
  rulebook — `CLAUDE.md` and `.claude/` are composed from the templates, and
118
162
  drift fails the suite.
119
163
 
120
- Development: `pnpm test` (full), `pnpm test:unit` (fast loop),
121
- `pnpm template:check` (templates in place). The plan of record is `PLAN.md`.
164
+ **And the enforcement layer is adversarially reviewed, not just tested.** The
165
+ Bash guard went through four review rounds with ten reviewers, who executed it
166
+ rather than read it. They found a PR body that could forge its own reviewer
167
+ verdict, a queue write that deleted the wrong line, and three ways to make the
168
+ guard crash into permitting everything. Each round's findings — including the
169
+ ones introduced by the previous round's _fix_ — are in the git history and in
170
+ `CHANGELOG.md`. The rule that came out of it is now part of what ships: a guard
171
+ that fails open must do provably bounded work, because fail-open turns every line
172
+ of its own work into a potential bypass.
173
+
174
+ Development (from a clone — `PLAN.md` and `demo.sh` live in the repository, not
175
+ in the published tarball): `pnpm test` (full), `pnpm test:unit` (fast loop),
176
+ `pnpm template:check` (templates in place). The plan of record is `PLAN.md`;
177
+ release notes and the release checklist ship in `CHANGELOG.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agent-rig",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
5
5
  "keywords": [
6
6
  "create",
@@ -15,6 +15,12 @@
15
15
  "node-service"
16
16
  ],
17
17
  "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/serhii-baksheiev/create-agent-rig.git"
21
+ },
22
+ "homepage": "https://github.com/serhii-baksheiev/create-agent-rig#readme",
23
+ "bugs": "https://github.com/serhii-baksheiev/create-agent-rig/issues",
18
24
  "type": "module",
19
25
  "bin": {
20
26
  "create-agent-rig": "packages/cli/dist/index.js"
@@ -22,7 +28,8 @@
22
28
  "files": [
23
29
  "packages/cli/dist",
24
30
  "templates",
25
- "scripts/prepare.mjs"
31
+ "scripts/prepare.mjs",
32
+ "CHANGELOG.md"
26
33
  ],
27
34
  "engines": {
28
35
  "node": ">=20"
@@ -1,37 +1,93 @@
1
1
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { agentOsUniversalDir } from '../templates.js';
3
+ import { settingsForInstalledHooks } from '../lib/init-settings.js';
4
+ import { substituteContent } from '../lib/substitute.js';
5
+ import { agentOsInitDir, agentOsUniversalDir } from '../templates.js';
4
6
  /** A user-facing failure: message is printed as-is, no stack trace. */
5
7
  export class InitError extends Error {
6
8
  }
9
+ const SETTINGS = '.claude/settings.json';
7
10
  async function loadManifest() {
8
11
  const raw = await readFile(path.join(agentOsUniversalDir(), 'layers.json'), 'utf8');
9
12
  return JSON.parse(raw);
10
13
  }
14
+ async function exists(p) {
15
+ try {
16
+ await access(p);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /**
24
+ * The name this repo is known by inside the rig. It ends up in a *filename* —
25
+ * `~/.claude/<name>-loop-STOP`, the kill switch — so it is reduced to
26
+ * characters an operator can type into a shell without quoting.
27
+ */
28
+ export function projectNameFor(repoDir) {
29
+ const base = path.basename(path.resolve(repoDir));
30
+ const slug = base
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9._-]+/g, '-')
33
+ .replace(/^[-.]+|[-.]+$/g, '');
34
+ return slug === '' ? 'project' : slug;
35
+ }
11
36
  /**
12
37
  * `init` installs only the PROCESS layer (hooks-and-reach brief §3/§4): rules
13
38
  * that assume nothing about the codebase shape. Architecture rules reference
14
39
  * `packages/core` and friends — installing them into an arbitrary repo would
15
40
  * describe a structure that does not exist, which is worse than no rule.
16
41
  *
17
- * CLAUDE.md is the meta file we bring, but never over an existing one.
42
+ * It also installs two things the process manifest does not name, because both
43
+ * are meaningless in the generated shape and load-bearing here:
44
+ *
45
+ * - `CLAUDE.md` — the map, taken from the init override layer, which describes
46
+ * the rig this command installs rather than the generated monorepo;
47
+ * - `.claude/settings.json` — the wiring, derived from the shipped settings so
48
+ * it names exactly the hooks that travelled.
18
49
  */
19
- async function processFiles(manifest) {
20
- // the process layer, plus CLAUDE.md as the map (guarded separately)
21
- return [...manifest.process, 'CLAUDE.md'];
22
- }
23
- async function exists(p) {
24
- try {
25
- await access(p);
26
- return true;
50
+ export async function initManifest() {
51
+ const manifest = await loadManifest();
52
+ const universal = agentOsUniversalDir();
53
+ const override = agentOsInitDir();
54
+ const files = [];
55
+ for (const rel of [...manifest.process, 'CLAUDE.md']) {
56
+ const overridden = path.join(override, rel);
57
+ files.push({
58
+ rel,
59
+ source: (await exists(overridden)) ? overridden : path.join(universal, rel),
60
+ });
27
61
  }
28
- catch {
29
- return false;
62
+ files.push({ rel: SETTINGS, source: null });
63
+ return files;
64
+ }
65
+ /**
66
+ * Exactly the bytes `init` would write, keyed by destination path — the single
67
+ * source the plan, the install and the template tests all read.
68
+ *
69
+ * Every file the process layer carries is text (asserted by a template test),
70
+ * so substitution can be applied unconditionally: an unsubstituted
71
+ * `__PROJECT_NAME__` in `stop-flag.mjs` is a kill switch that silently never
72
+ * fires.
73
+ */
74
+ export async function initFileContents(repoDir) {
75
+ const projectName = projectNameFor(repoDir);
76
+ const ctx = { projectName, projectScope: projectName, region: '' };
77
+ const files = await initManifest();
78
+ const contents = new Map();
79
+ for (const { rel, source } of files) {
80
+ if (source === null)
81
+ continue;
82
+ contents.set(rel, substituteContent(await readFile(source, 'utf8'), ctx));
30
83
  }
84
+ const installedHooks = new Set(files.map((f) => f.rel).filter((rel) => rel.startsWith('.claude/hooks/')));
85
+ const shipped = JSON.parse(await readFile(path.join(agentOsUniversalDir(), SETTINGS), 'utf8'));
86
+ contents.set(SETTINGS, `${JSON.stringify(settingsForInstalledHooks(shipped, installedHooks), null, 2)}\n`);
87
+ return contents;
31
88
  }
32
89
  export async function planInit(repoDir) {
33
- const manifest = await loadManifest();
34
- const files = await processFiles(manifest);
90
+ const files = (await initManifest()).map((f) => f.rel);
35
91
  const conflicts = [];
36
92
  for (const rel of files) {
37
93
  if (await exists(path.join(repoDir, rel)))
@@ -40,9 +96,7 @@ export async function planInit(repoDir) {
40
96
  return { files: files.map((p) => ({ path: p })), conflicts };
41
97
  }
42
98
  export async function initProject(repoDir, options) {
43
- const manifest = await loadManifest();
44
- const files = await processFiles(manifest);
45
- const universal = agentOsUniversalDir();
99
+ const files = (await initManifest()).map((f) => f.rel);
46
100
  // Refuse to clobber an existing CLAUDE.md unless forced — init edits
47
101
  // someone's working repository (brief §4, non-negotiable).
48
102
  if (!options.force && files.includes('CLAUDE.md')) {
@@ -51,6 +105,7 @@ export async function initProject(repoDir, options) {
51
105
  'Merge the agent-os map in by hand, or re-run with --force to replace it.');
52
106
  }
53
107
  }
108
+ const contents = await initFileContents(repoDir);
54
109
  const written = [];
55
110
  const skipped = [];
56
111
  const plannedCount = files.length;
@@ -65,7 +120,7 @@ export async function initProject(repoDir, options) {
65
120
  if (options.dryRun)
66
121
  continue;
67
122
  await mkdir(path.dirname(dest), { recursive: true });
68
- await writeFile(dest, await readFile(path.join(universal, rel)));
123
+ await writeFile(dest, contents.get(rel) ?? '');
69
124
  written.push(rel);
70
125
  }
71
126
  return { written, skipped, plannedCount };
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { parseArgs } from 'node:util';
6
6
  import { CreateError, createProject } from './commands/create.js';
7
- import { InitError, initProject, planInit } from './commands/init.js';
7
+ import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
8
8
  import { makePalette } from './lib/colors.js';
9
9
  import { promptTarget } from './lib/prompts.js';
10
10
  import { collectGovernance, renderSummary } from './lib/summary.js';
@@ -65,6 +65,16 @@ async function runInit(rawArgs) {
65
65
  process.stdout.write(`\nInstalled ${result.written.length} files` +
66
66
  (result.skipped.length ? `, kept ${result.skipped.length} existing` : '') +
67
67
  '.\n');
68
+ // The one kept file that silently disables everything else: without this
69
+ // wiring the hooks sit on disk and are never called, while the rules claim
70
+ // they are enforced. Say so loudly, and hand over the exact entries.
71
+ if (result.skipped.includes('.claude/settings.json')) {
72
+ const wiring = (await initFileContents(cwd)).get('.claude/settings.json') ?? '';
73
+ process.stdout.write(`\n! .claude/settings.json already exists — it was kept, so the rig's hooks are NOT wired.\n` +
74
+ ` Until you merge these entries into it, nothing enforces the rules:\n\n` +
75
+ wiring.replace(/^/gm, ' ') +
76
+ '\n');
77
+ }
68
78
  return 0;
69
79
  }
70
80
  async function main() {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `init` installs the PROCESS layer only — a subset of the hooks the generated
3
+ * shape gets. The wiring it writes has to match that subset exactly:
4
+ *
5
+ * - wiring a hook file that was not installed makes every matching tool call
6
+ * fail on a missing module;
7
+ * - wiring nothing at all is worse and quieter — the hooks sit on disk, the
8
+ * rules claim they are enforced, and nothing ever calls them.
9
+ *
10
+ * So the wiring is *derived* from the shipped settings.json rather than
11
+ * maintained as a second copy: add a process hook and wire it once, upstream,
12
+ * and init picks it up.
13
+ */
14
+ /** Matches the hook file a wired command runs, e.g. `.claude/hooks/guard-bash.mjs`. */
15
+ const HOOK_REFERENCE = /\.claude\/hooks\/[A-Za-z0-9._-]+\.mjs/;
16
+ function isRecord(value) {
17
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ }
19
+ /** An entry survives unless it names a hook file that is not installed. */
20
+ function keepEntry(entry, installed) {
21
+ if (!isRecord(entry) || typeof entry.command !== 'string')
22
+ return true;
23
+ const referenced = HOOK_REFERENCE.exec(entry.command);
24
+ return referenced === null || installed.has(referenced[0]);
25
+ }
26
+ /** A group survives only with at least one entry left — never as an empty shell. */
27
+ function keepGroup(group, installed) {
28
+ if (!isRecord(group) || !Array.isArray(group.hooks))
29
+ return group;
30
+ const hooks = group.hooks.filter((entry) => keepEntry(entry, installed));
31
+ return hooks.length === 0 ? null : { ...group, hooks };
32
+ }
33
+ /**
34
+ * The shipped settings, narrowed to the hooks actually installed. Shapes this
35
+ * function does not understand are passed through untouched — it filters, it
36
+ * never rewrites.
37
+ */
38
+ export function settingsForInstalledHooks(settings, installed) {
39
+ if (!isRecord(settings) || !isRecord(settings.hooks))
40
+ return settings;
41
+ const events = {};
42
+ for (const [event, groups] of Object.entries(settings.hooks)) {
43
+ if (!Array.isArray(groups)) {
44
+ events[event] = groups;
45
+ continue;
46
+ }
47
+ const kept = groups.map((group) => keepGroup(group, installed)).filter((g) => g !== null);
48
+ if (kept.length > 0)
49
+ events[event] = kept;
50
+ }
51
+ return { ...settings, hooks: events };
52
+ }
@@ -1,21 +1,35 @@
1
1
  import { readdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- const names = async (dir, strip) => {
3
+ const names = async (dir, strip, keep) => {
4
4
  try {
5
- return (await readdir(dir)).map((entry) => entry.replace(strip, '')).sort();
5
+ return (await readdir(dir))
6
+ .filter((entry) => !keep || keep.test(entry))
7
+ .map((entry) => entry.replace(strip, ''))
8
+ .sort();
6
9
  }
7
10
  catch {
8
11
  return [];
9
12
  }
10
13
  };
14
+ /**
15
+ * Hooks whose filename names the *tool* they intercept rather than the
16
+ * invariant they enforce. Stripping the prefix would print "bash", which tells
17
+ * the reader nothing — and a screen that sells enforcement may not be vague.
18
+ */
19
+ const HOOK_LABELS = {
20
+ 'guard-bash': 'never tier',
21
+ };
11
22
  export async function collectGovernance(projectDir) {
12
23
  const claude = path.join(projectDir, '.claude');
13
24
  return {
14
25
  rules: await names(path.join(claude, 'rules'), /\.md$/),
15
26
  agents: await names(path.join(claude, 'agents'), /\.md$/),
16
- hooks: (await names(path.join(claude, 'hooks'), /\.mjs$/))
17
- // guard-core-purity "core purity": the mechanism, not the filename
18
- .map((hook) => hook.replace(/^(guard|block)-/, '').replaceAll('-', ' ')),
27
+ // `.mjs` only: the stack layer also drops config (dod-checks.json) in this
28
+ // directory, and counting a config file as an enforced hook would make the
29
+ // screen overstate the one thing this tool actually sells.
30
+ hooks: (await names(path.join(claude, 'hooks'), /\.mjs$/, /\.mjs$/)).map(
31
+ // guard-core-purity → "core purity": the mechanism, not the filename
32
+ (hook) => HOOK_LABELS[hook] ?? hook.replace(/^(guard|block)-/, '').replaceAll('-', ' ')),
19
33
  skills: await names(path.join(claude, 'skills'), /$^/),
20
34
  };
21
35
  }
@@ -19,3 +19,11 @@ export function agentOsUniversalDir() {
19
19
  export function agentOsStackDir(stack) {
20
20
  return path.join(templatesRoot(), 'agent-os', 'stack', stack);
21
21
  }
22
+ /**
23
+ * Overrides `init` applies on top of the universal layer. A file here replaces
24
+ * its universal namesake when the rig is installed into an existing repo whose
25
+ * shape the generator knows nothing about. `create` never reads this directory.
26
+ */
27
+ export function agentOsInitDir() {
28
+ return path.join(templatesRoot(), 'agent-os', 'init');
29
+ }