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.
- package/CHANGELOG.md +170 -0
- package/README.md +66 -10
- package/package.json +9 -2
- package/packages/cli/dist/commands/init.js +73 -18
- package/packages/cli/dist/index.js +11 -1
- package/packages/cli/dist/lib/init-settings.js +52 -0
- package/packages/cli/dist/lib/summary.js +19 -5
- package/packages/cli/dist/templates.js +8 -0
- package/templates/agent-os/init/CLAUDE.md +133 -0
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +1 -1
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
- package/templates/agent-os/universal/.claude/queue.json +3 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +170 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
- package/templates/agent-os/universal/.claude/settings.json +4 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/CLAUDE.md +57 -7
- package/templates/agent-os/universal/PLAN.md +28 -2
- package/templates/agent-os/universal/layers.json +20 -1
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/aws-serverless/gitignore +8 -0
- package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
- 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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
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,
|
|
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))
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
}
|