create-agent-rig 0.8.0 → 0.9.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 (58) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/README.md +99 -4
  3. package/package.json +4 -3
  4. package/packages/cli/dist/commands/init.js +43 -7
  5. package/packages/cli/dist/commands/memory.js +182 -0
  6. package/packages/cli/dist/commands/setup.js +45 -0
  7. package/packages/cli/dist/commands/upgrade.js +22 -4
  8. package/packages/cli/dist/index.js +110 -3
  9. package/packages/cli/dist/lib/manifest.js +23 -5
  10. package/packages/cli/dist/lib/subsystems.js +269 -0
  11. package/packages/cli/dist/lib/version.js +15 -0
  12. package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
  13. package/packages/cli/dist/policy/core/coverage.js +253 -0
  14. package/packages/cli/dist/policy/core/decision-record.js +130 -44
  15. package/packages/cli/dist/policy/core/declaration.js +58 -17
  16. package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
  17. package/packages/cli/dist/policy/core/probe.js +442 -0
  18. package/packages/cli/dist/policy/core/validation.js +194 -1
  19. package/packages/cli/dist/policy/core/vocabulary.js +70 -3
  20. package/packages/cli/dist/policy/harness/claude.js +9 -1
  21. package/packages/cli/dist/policy/harness/codex.js +48 -1
  22. package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
  23. package/packages/cli/dist/policy/index.js +9 -2
  24. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
  25. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
  26. package/templates/agent-os/subagent-routing.json +32 -0
  27. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
  31. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
  32. package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
  33. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
  34. package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
  35. package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
  36. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
  37. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
  38. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
  40. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
  41. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +269 -32
  42. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
  43. package/templates/agent-os/universal/.claude/settings.json +16 -0
  44. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
  45. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
  46. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
  47. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
  48. package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
  49. package/templates/agent-os/universal/.codex/config.toml +3 -0
  50. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
  51. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
  52. package/templates/agent-os/universal/layers.json +4 -0
  53. package/templates/hash-history.json +71 -22
  54. package/templates/release-ledger.json +3 -1
  55. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
  56. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
  57. package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
  58. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
package/CHANGELOG.md CHANGED
@@ -14,6 +14,146 @@ second recorded departure; its own entry states the direction and the reason,
14
14
  and this paragraph deliberately does not restate them — a numbering rule with
15
15
  two copies of its exceptions is the shape 0.8.0 exists to remove.
16
16
 
17
+ ## 0.9.1
18
+
19
+ **A patch, numbered by the owner.** Every entry corrects existing behaviour;
20
+ two of them (RP-182, RP-59) do it by recording or reporting something new.
21
+ Nothing is added to what `create`, `init` or `upgrade` install beyond the
22
+ corrected files, the contract version stays `1.0`, and the application
23
+ skeletons remain deprecated and scheduled for removal in 0.10.0 exactly as
24
+ 0.9.0 announced.
25
+
26
+ ### Fixed
27
+
28
+ - **`init` records the files it kept.** When `init` finds a payload path
29
+ already present and leaves it alone, the manifest now records that file's
30
+ hash under `kept`, so a later `upgrade` can say whether the file is
31
+ unchanged or edited since `init` found it instead of reporting it with no
32
+ history (RP-182). A manifest with nothing kept is written exactly as
33
+ before.
34
+ - **`memory load` passes Memory a deadline of its own.** A `load` that names
35
+ no `--timeout-ms` gets `--timeout-ms 45000` appended, below the rig's 60 s
36
+ kill. A well-formed `--timeout-ms <value>` pair raises that kill to the
37
+ value plus 15 s when that is above 60 s, capped at Node's timer limit of
38
+ 2 147 483 647 ms; any other spelling passes through unchanged. `doctor`'s
39
+ arguments are untouched. The README example adds `--cwd .`, which the Memory
40
+ owner names as part of Memory's own contract (RP-183).
41
+ - **`reconcile-external-prs.mjs` works on a `gh` that does not serve
42
+ `authorAssociation`** from `gh pr list`: the association is fetched
43
+ separately and kept only when it answers for the same pull request, and a
44
+ failed lookup leaves those pull requests untrusted rather than failing the
45
+ sweep. A failure now names the one cause the evidence shows, with terminal
46
+ control sequences and credential shapes removed from the detail (RP-99).
47
+ - **The queue reports a Blocks link the ticket body says was removed but the
48
+ tracker still carries** — a body line of the form
49
+ `The Blocks link A -> B is removed` — as a `link-contradicted-by-body`
50
+ hygiene finding (RP-59).
51
+ - **A Jira triage proposal with a long `change` is filed, not refused by the
52
+ tracker.** Jira rejects a summary over 255 characters; the summary is now
53
+ cut to fit and the full text stays in the description (RP-121).
54
+
55
+ ### Generator CI (not a rig-facing change)
56
+
57
+ Test-fixture cleanup goes through one bounded-retry helper, and an audit holds
58
+ every other recursive removal and every in-repository fixture to a written
59
+ reason. The four child-process time bounds are measured inside the child, and
60
+ the package-manager CLI-start cases carry their own measured budget. The e2e
61
+ installs run in a vitest group of their own, so they no longer compete with
62
+ the template tests on the Windows full-suite runner (RP-158).
63
+
64
+ ## 0.9.0
65
+
66
+ **The harness ↔ Memory boundary is executable, and the rig is a consumer of
67
+ Memory rather than a host for it.** The CLI gains a `setup --memory-root
68
+ <checkout>` command that records the Memory executable in a machine-scoped
69
+ subsystem manifest after a `--version --json` handshake, and a `memory
70
+ <doctor|load>` command that runs the registered executable through that
71
+ handshake and refuses a foreign contract major with exit 4 before any verb
72
+ runs; the CLI itself answers `--version --json` with its name, version and
73
+ contract version. These are commands of the tool, not files in a project: the
74
+ manifest is machine-scoped and nothing Memory-related is added to what
75
+ `create`, `init` or `upgrade` write into a project. None of this puts
76
+ Memory code inside the rig: the executable is spawned across a process
77
+ boundary and only its handshake is parsed (`docs/command-contract.md`, "The
78
+ version handshake"; `docs/decisions/memory-rig-boundary.md`).
79
+
80
+ **Deprecated: the application skeletons.** `create <dir> [--target <name>]`
81
+ still scaffolds `aws-serverless` and `node-service` in this release, unchanged, and they are
82
+ **scheduled for removal in 0.10.0**. The product boundary the owner fixed on
83
+ 2026-09-13 is a package manager for repository-scoped Claude Code and Codex
84
+ configuration — `init` into an existing repository, `upgrade`, `doctor`, the
85
+ Memory handshake — and application scaffolding is outside it. Take `init` for
86
+ a new project's rig; a skeleton generated today is yours and stays yours, but
87
+ `upgrade` will not carry skeleton files forward once they are gone.
88
+
89
+ **Numbered a minor by the rule at the top of this file**: two new commands and
90
+ a new payload are additive.
91
+
92
+ ### Added
93
+
94
+ - **`setup --memory-root <checkout> [--memory-ref <sha>] [--dry-run]`** and the
95
+ machine subsystem manifest it writes (`~/.config/create-agent-rig/subsystems.json`,
96
+ `%APPDATA%` on Windows) — RP-147. `upgrade` re-runs the same derivation when a
97
+ manifest exists, so `installedVersion` follows the executable the root holds.
98
+ - **`memory <doctor|load> [args…]`** through the registered executable, with the
99
+ handshake first and Memory's answer passed through unchanged; `--version
100
+ --json` on the rig bin — RP-19. Exit codes on that surface: 0 unsupported /
101
+ absent, 1 integration-failed, 2 invalid invocation, 3 prerequisite unmet, 4
102
+ foreign contract major.
103
+ - **Role-specific subagent routing** in the payload: one routing table
104
+ (`templates/agent-os/subagent-routing.json`) pins each reviewer's model and
105
+ effort for both harnesses; the Claude agent specs carry the pins, the Codex
106
+ profiles are derived from the same table, and two Claude Code hooks —
107
+ `guard-subagent-model` (refuses a call-site `model` override on a pinned
108
+ reviewer) and `warn-subagent-routing` (says so at session start when an
109
+ effort override is in force) — are wired in `.claude/settings.json`
110
+ (RP-166, RP-173; `docs/decisions/subagent-routing.md`).
111
+ - **`preflight` fails on an unreadable configured queue** instead of selecting
112
+ from nothing (RP-56).
113
+ - **Concurrent sessions on one machine** — the ruling on what shared state
114
+ they may touch, and a bounded rename retry so a Windows gate-round counter
115
+ is not lost to a transient lock (RP-120; `docs/decisions/concurrent-sessions.md`).
116
+ - **A mechanical release preflight**, `node scripts/release-preflight.mjs`,
117
+ for the step the owner types by hand (`docs/releasing.md`).
118
+ - **The Rig-side conformance runner** for the Memory boundary,
119
+ `scripts/memory-conformance.mjs --from <checkout> --json`, and the contract
120
+ schemas under `contracts/conformance/v1/` (RP-13) — generator repository
121
+ only, never delivered to a rig; the authoritative cross-repository run lives
122
+ in the private Memory repository and checks this one out at an exact SHA.
123
+ - **The policy benchmark** (`docs/policy-benchmark.md`, RP-111): adapter-process
124
+ evidence for both harnesses on an immutable snapshot of the tree. It remains
125
+ in 0.9.0; whether it stays is a 0.10.0 decision.
126
+
127
+ ### Fixed
128
+
129
+ - **`edit-input`** — an unreadable `tool_input` is a refusal on every edit
130
+ surface, never a clean edit (RP-85); a widening `--allow` entry no longer
131
+ leaves an unattended run with no flag on disk, and the `loop` skill verifies
132
+ the flag armed instead of trusting that it did (RP-103).
133
+ - **Capability coverage** — the policy capability contract names exactly what
134
+ the code covers, and the two silent passes it had are closed (RP-36); policy
135
+ validators read own-and-enumerable record fields (RP-153).
136
+ - **Windows** — the node-service skeleton's static-dir tests use a native file
137
+ URL (RP-168); the e2e harness spawns package managers and file checks on
138
+ Windows (RP-169); the one PowerShell case carries its own measured budget
139
+ (RP-162); the benchmark's guard-spawning cases are classified UNVERIFIABLE
140
+ on the hosted Windows image rather than reported green on nothing (RP-111),
141
+ and the Windows governance fixtures are set up under a bound that names the
142
+ stage that timed out (RP-172).
143
+ - **Repository scans skip sibling worktrees** under `.claude/worktrees/`, so a
144
+ second checkout is no longer reported as this one's drift (RP-155).
145
+
146
+ ### Generator CI (not a rig-facing change)
147
+
148
+ The pull-request path runs on GitHub-hosted runners only: in `ci.yml`, `ci`
149
+ and the two template checks on Linux and `windows-smoke` (the CLI's unit
150
+ project) on Windows; in `e2e.yml`, the Linux `e2e` job — the full suite with
151
+ the e2e installs and the benchmark — when the pull request touches the CLI,
152
+ the templates, the e2e harness or that workflow. The full suite on Windows
153
+ runs in `e2e.yml` on master, nightly and by dispatch only, and a
154
+ `runner_mode` switch there selects a self-hosted fallback that no pull
155
+ request can reach (`docs/runners.md`).
156
+
17
157
  ## 0.8.0
18
158
 
19
159
  **Three stale second copies, spread over three payload files a rig obeys,
@@ -991,7 +1131,18 @@ sometimes earlier (step 6). Everything before that is mechanical:
991
1131
  `test/template/hash-history.test.ts` › "builds the table from the ledger
992
1132
  alone — tags are a warning source, never an input".
993
1133
 
994
- 8. **Owner:** `npm publish`.
1134
+ 8. **Owner:** `npm publish` — and immediately before it, from the checkout you
1135
+ are about to publish from:
1136
+
1137
+ ```sh
1138
+ node scripts/release-preflight.mjs # exit 0, or it names what to fix
1139
+ ```
1140
+
1141
+ It checks the manifests, the ledger, the checkout, and the tarball `npm pack`
1142
+ would actually produce. What it looks at is the code, not this list; what it
1143
+ cannot see is stated in its own header. It is a preflight, not a gate —
1144
+ nothing runs it for you, and exit 0 is not a verdict on the release.
1145
+
995
1146
  9. **Owner:** smoke the published artifact — `npx create-agent-rig@<version>` in
996
1147
  an empty directory, then `pnpm install && pnpm check` inside it; and
997
1148
  `upgrade --dry-run` in a rig installed from the previous version.
package/README.md CHANGED
@@ -65,7 +65,13 @@ it does not. Each conflict names the file, why it was kept, and the path to the
65
65
  new version so you can diff it yourself.
66
66
 
67
67
  How it knows: `create` and `init` write `.claude/.rig-manifest.json` — the rig
68
- version plus a hash per installed file. **Commit it**; without it in the
68
+ version plus a hash per installed file. A file `init` found already in place and
69
+ left alone is recorded separately, under `kept`, with the hash of the bytes it
70
+ found, and never as the rig's (`packages/cli/test/init.test.ts` › "records what
71
+ it kept, with the sha256 of the bytes actually on disk — never in `files`"). An
72
+ `upgrade` conflict on such a file says it was kept by init and whether it was
73
+ edited since (`packages/cli/test/upgrade.test.ts` › "says "edited since"
74
+ instead, once the disk sha no longer matches what init recorded"). **Commit it**; without it in the
69
75
  repository the command is blind on CI and on a colleague's machine. Rigs
70
76
  installed before 0.4.0 have no manifest, so the package also carries the hashes
71
77
  of every release whose published commit is on record (0.2.0 onward — 0.1.0's
@@ -85,6 +91,44 @@ enough for this one, and a replacement that would stop calling a hook the
85
91
  current wiring names — while that hook's file is still in `.claude/hooks/` — is
86
92
  handed over instead.
87
93
 
94
+ ### Registering Memory on this machine
95
+
96
+ Memory is a separate subsystem with its own version; the rig never imports it
97
+ and never searches for it. `setup` records where it is, once per machine:
98
+
99
+ ```sh
100
+ npx create-agent-rig@latest setup --memory-root ~/claude-config # the checkout that holds shared-memory/memory.mjs
101
+ npx create-agent-rig@latest setup --memory-root ~/claude-config --dry-run # handshake only, write nothing
102
+ ```
103
+
104
+ It derives the invocation from that one root, runs Memory's `--version --json`
105
+ first, and refuses a foreign contract major with exit 4 before writing anything.
106
+ What it writes is one machine-scoped manifest —
107
+ `~/.config/create-agent-rig/subsystems.json` (`%APPDATA%\create-agent-rig\` on
108
+ Windows) — carrying the invocation, the required contract major, the pinned
109
+ Memory ref (`--memory-ref`) and the version the handshake observed. `upgrade`
110
+ re-runs the same derivation when the manifest exists; it never creates one. The
111
+ behaviour is pinned in `packages/cli/test/setup.test.ts` and
112
+ `packages/cli/test/subsystems.test.ts`; the seam itself is ADR-RP-002 R6
113
+ (`docs/decisions/memory-rig-boundary.md`).
114
+
115
+ Both bins answer the same handshake, and the rig consumes Memory only through it
116
+ (RP-19):
117
+
118
+ ```sh
119
+ npx create-agent-rig@latest --version --json # {"schemaVersion":1,"name":"create-agent-rig","version":"…","contractVersion":"1.0"}
120
+ npx create-agent-rig@latest memory doctor --json # handshake first, then Memory's doctor, answer passed through unchanged
121
+ npx create-agent-rig@latest memory load --json --cwd . # same, for load (`--cwd`: Memory's own contract, per its owner — RP-183); arguments pass to Memory verbatim, plus `--timeout-ms 45000` when you name none
122
+ ```
123
+
124
+ `memory` reads the manifest above, runs Memory's `--version --json`, and only
125
+ then the verb: a foreign contract major exits 4 and the verb never runs; no
126
+ manifest (or an executable that has moved) is `unsupported`/`absent`, exit 0;
127
+ an executable that answers but not as the manifest promised — a broken
128
+ `VERSION`, a malformed handshake — is `integration-failed`, exit 1, never
129
+ "absent". Pinned in `packages/cli/test/memory.test.ts` and
130
+ `packages/cli/test/cli-version.test.ts`.
131
+
88
132
  **A file you deleted stays deleted.** The rules invite you to delete the ones
89
133
  whose invariant your project does not have, so an upgrade that quietly restored
90
134
  them would be undoing your work. With a manifest that is direct — it names the
@@ -94,13 +138,42 @@ it covers was there to be removed. The single case nothing can tell apart is a
94
138
  file a **later** release added, which your rig never had — that one is installed,
95
139
  and `--dry-run` lists it before anything is written.
96
140
 
141
+ ### Conformance runner
142
+
143
+ `contracts/conformance/v1/` holds the JSON schemas of the command contract's
144
+ `--version --json`, `doctor --json` and `load --json` answers, and
145
+ `scripts/memory-conformance.mjs` checks a Memory checkout against them:
146
+
147
+ ```sh
148
+ pnpm build
149
+ node scripts/memory-conformance.mjs --from <claude-config checkout> --json [--out report.json]
150
+ ```
151
+
152
+ It is offline by construction — `--from` is mandatory, nothing is fetched, no
153
+ credential is read — and it never imports Memory code or copies a Memory
154
+ fixture into this repository (`test/template/memory-conformance.test.ts` ›
155
+ "carries no fetch, clone or credential: the checkout is always the caller's"
156
+ and › "the repository carries no Memory fixture"). The contract directory is
157
+ this repository's own, not a rig payload: `create`, `init` and `upgrade` do
158
+ not deliver it (`test/template/conformance-contract.test.ts` › "is not
159
+ delivered to rigs: no template carries a conformance contract"). The report's
160
+ rows, its `rigSha` / `memorySha` / `verifierDigest` fields and the `--out`
161
+ file are pinned by the same test file's › "passes every row against a
162
+ well-formed local fixture root and names both SHAs and the verifier digest"
163
+ and › "derives verifierDigest from the runner, its validator and the contract
164
+ files, in that order, and writes the same report to --out". The authoritative
165
+ cross-repository run lives in the private `claude-config` repository, which
166
+ checks this repository out at an explicit full SHA and runs the command above
167
+ against its own tree; the CI here runs only the offline tests.
168
+
97
169
  ## What you get
98
170
 
99
171
  **A system of boundaries, each held by tooling.** An agent (or a human using
100
172
  one) cannot talk its way past them — each guard is a pre-write scan that stops
101
173
  the normal path cold (review and tests back it; the claim is stated exactly,
102
174
  never inflated). The hook implementations live once in `.claude/hooks/` and are
103
- wired by both `.claude/settings.json` and `.codex/hooks.json`:
175
+ wired by both `.claude/settings.json` and `.codex/hooks.json` — except the two
176
+ marked Claude Code, which only `.claude/settings.json` wires:
104
177
 
105
178
  - **`guard-core-purity`** — refuses any edit that puts I/O, clock, randomness,
106
179
  environment access, or a non-allowlisted import into the pure domain core;
@@ -135,7 +208,16 @@ wired by both `.claude/settings.json` and `.codex/hooks.json`:
135
208
  - **`inject-rules`** — re-injects the autonomy rules at session start, so they
136
209
  survive compaction and resumes: the whole file, minus the regions the file
137
210
  itself marks as reference. What is left out is a decision written in
138
- `autonomy.md` on the line above it, not one this hook infers.
211
+ `autonomy.md` on the line above it, not one this hook infers;
212
+ - **`guard-subagent-model`** (Claude Code) — refuses an `Agent` dispatch that
213
+ passes a call-site `model` for a subagent whose definition pins one: the
214
+ definition, not the call, decides which model a gate reads with;
215
+ - **`warn-subagent-routing`** (Claude Code) — at session start, warns when
216
+ `CLAUDE_CODE_SUBAGENT_MODEL_FORCE` is set (it replaces every model pin below),
217
+ when `CLAUDE_CODE_EFFORT_LEVEL` is set (it replaces every effort pin), when
218
+ Claude Code is older than 2.1.251 (the unnamed default then replaces the model
219
+ pins), or when its version cannot be read (so the pins cannot be confirmed to
220
+ hold). It warns and never blocks.
139
221
 
140
222
  **A brake that is a real file.** `touch ~/.claude/<project>-loop-STOP` and no
141
223
  merge lands until it is removed — enforced at the tool layer, so it holds even if
@@ -167,6 +249,19 @@ generator for the invariant→hook→test pattern; `post-deploy-verify` and
167
249
  `ro-debug` on the AWS target), and matching one-page `CLAUDE.md` / `AGENTS.md`
168
250
  maps a fresh session orients by.
169
251
 
252
+ **Each gate reads with a pinned model and effort**, so a SHIP does not change
253
+ meaning with whatever model the session was started on. `code-reviewer`,
254
+ `security-scanner` and `cdk-diff-reviewer` pin `claude-opus-5`; `test-writer` and
255
+ `prose-reviewer` pin `claude-sonnet-5`; all pin `high` effort — and their Codex
256
+ profiles pin `gpt-5.6-sol` / `gpt-5.6-terra` from the same role table. A subagent
257
+ with no definition defaults to `claude-sonnet-5` through
258
+ `CLAUDE_CODE_SUBAGENT_MODEL` in `.claude/settings.json`; its effort cannot be
259
+ pinned and follows the session. The driver session's own model and effort stay
260
+ yours. To change a role in a generated project, edit `model:` / `effort:` in its
261
+ `.claude/agents/<role>.md` — and the matching `.codex/agents/<role>.toml` — in a
262
+ reviewed change; `upgrade` then reports the edited file as yours instead of
263
+ replacing it. Why these values, and what voids them: `docs/decisions/subagent-routing.md`.
264
+
170
265
  **The hooks are examples, not laws.** `.claude/rules/invariants.md` states the
171
266
  pattern behind each one — a stated invariant, a mechanical check, a test for the
172
267
  check — so you can delete the ones whose invariant your project does not have and
@@ -249,6 +344,6 @@ that fails open must do provably bounded work, because fail-open turns every lin
249
344
  of its own work into a potential bypass.
250
345
 
251
346
  Development (from a clone — `PLAN.md` and `demo.sh` live in the repository, not
252
- in the published tarball): `pnpm test` (full), `pnpm test:unit` (fast loop),
347
+ in the published tarball): `pnpm test` (full), `pnpm test:unit` (fast loop), `pnpm test:smoke` (the unit project only — the Windows pull-request lane),
253
348
  `pnpm template:check` (templates in place). The plan of record is `PLAN.md`;
254
349
  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.8.0",
3
+ "version": "0.9.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",
@@ -43,7 +43,8 @@
43
43
  "format": "prettier --write .",
44
44
  "typecheck": "tsc -p packages/cli/tsconfig.json && tsc -p tsconfig.json",
45
45
  "test": "pnpm build && vitest run",
46
- "test:unit": "vitest run --project unit --project template",
46
+ "test:unit": "vitest run --project unit --project template --project benchmark",
47
+ "test:smoke": "vitest run --project unit",
47
48
  "test:e2e": "pnpm build && vitest run --project e2e",
48
49
  "template:install": "pnpm --dir templates/skeleton/aws-serverless install",
49
50
  "template:check": "pnpm --dir templates/skeleton/aws-serverless run check"
@@ -59,6 +60,6 @@
59
60
  "prettier": "^3.9.6",
60
61
  "typescript": "~6.0.3",
61
62
  "typescript-eslint": "^8.65.0",
62
- "vitest": "^4.1.10"
63
+ "vitest": "^5.0.0"
63
64
  }
64
65
  }
@@ -1,4 +1,4 @@
1
- import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { settingsForInstalledHooks } from '../lib/init-settings.js';
4
4
  import { mapConcurrent } from '../lib/copy-tree.js';
@@ -158,17 +158,40 @@ export async function initProject(repoDir, options) {
158
158
  const written = actions.filter(({ verdict }) => verdict === 'written').map(({ rel }) => rel);
159
159
  const skipped = actions.filter(({ verdict }) => verdict === 'skipped').map(({ rel }) => rel);
160
160
  if (!options.dryRun)
161
- await recordInstall(repoDir, written, contents);
161
+ await recordInstall(repoDir, written, skipped, contents);
162
162
  return { written, skipped, plannedCount };
163
163
  }
164
+ /**
165
+ * The text of a regular file, or `null` for anything else at that path — a
166
+ * directory, a symlink, a file this process cannot read. `kept` records only
167
+ * bytes that are the file itself: hashing through a link would put the hash of
168
+ * something outside the repository into a committed manifest, and a read that
169
+ * throws here would abort the install after every other file was written.
170
+ */
171
+ async function readRegularFile(abs) {
172
+ try {
173
+ if (!(await lstat(abs)).isFile())
174
+ return null;
175
+ return await readFile(abs, 'utf8');
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
164
181
  /**
165
182
  * Record what was installed, so a later `upgrade` can tell a file it wrote
166
183
  * from a file the user owns.
167
184
  *
168
- * Only files actually **written** are recorded. A file `init` kept is
169
- * somebody else's — claiming it here would let the next upgrade replace a
170
- * user's own document with the rig's. Earlier entries are preserved: a re-run
171
- * writes nothing and must not therefore un-remember everything.
185
+ * Only files actually **written** are recorded in `files`. A file `init` kept
186
+ * is somebody else's — claiming it there would let the next upgrade replace a
187
+ * user's own document with the rig's. It is recorded in `kept` instead, with
188
+ * the sha256 of the bytes found on disk (RP-182): not a claim of ownership, a
189
+ * record that the rig saw the file and left it — so the next upgrade can say
190
+ * "kept by init, unchanged since" or "edited since" rather than only "not a
191
+ * version this rig ever released". A path already in `files` is never moved to
192
+ * `kept` by a later run that skips it: the rig wrote those bytes. Earlier
193
+ * entries are preserved: a re-run writes nothing and must not therefore
194
+ * un-remember everything.
172
195
  *
173
196
  * 🔴 **`kind`, `project` and `stacks` are preserved, not rewritten.** Reached
174
197
  * inside a rig `create` produced, this used to stamp `kind: 'init'`,
@@ -196,12 +219,24 @@ export async function initProject(repoDir, options) {
196
219
  * leaves is a `create` rig whose `CLAUDE.md` was deleted, and this function is
197
220
  * what makes that case safe.
198
221
  */
199
- async function recordInstall(repoDir, written, contents) {
222
+ async function recordInstall(repoDir, written, skipped, contents) {
200
223
  const previous = await readManifest(repoDir);
201
224
  const name = projectNameFor(repoDir);
202
225
  const files = { ...(previous?.files ?? {}) };
203
226
  for (const rel of written)
204
227
  files[rel] = sha256(contents.get(rel) ?? '');
228
+ const kept = { ...(previous?.kept ?? {}) };
229
+ for (const rel of written)
230
+ delete kept[rel];
231
+ for (const rel of skipped) {
232
+ if (files[rel] !== undefined)
233
+ continue; // the rig wrote it once; still its bytes to vouch for
234
+ const found = await readRegularFile(path.join(repoDir, rel));
235
+ if (found === null)
236
+ delete kept[rel];
237
+ else
238
+ kept[rel] = sha256(found);
239
+ }
205
240
  const manifest = {
206
241
  version: await packageVersion(),
207
242
  kind: previous?.kind ?? 'init',
@@ -211,6 +246,7 @@ async function recordInstall(repoDir, written, contents) {
211
246
  project: previous?.project ?? { name, scope: name, region: '' },
212
247
  stacks: previous?.stacks ?? [],
213
248
  files,
249
+ ...(Object.keys(kept).length > 0 ? { kept } : {}),
214
250
  };
215
251
  await writeManifest(repoDir, manifest);
216
252
  }
@@ -0,0 +1,182 @@
1
+ // `create-agent-rig memory <doctor|load> [args…]`: the Rig side of the
2
+ // RP-19 version handshake. The Rig is a CONSUMER of the Memory subsystem: it
3
+ // resolves the executable from the machine subsystem manifest `setup` wrote
4
+ // (RP-147), performs `--version --json` first, refuses a foreign contract
5
+ // major with exit 4 before doctor/load ever run, and otherwise hands the verb
6
+ // and its arguments to Memory and returns Memory's answer unchanged. The one
7
+ // thing it adds to the arguments: a `load` that names no `--timeout-ms` gets
8
+ // Memory's default internal deadline appended (`deadlinesFor`, RP-183);
9
+ // everything the caller wrote passes through verbatim, and `doctor` is never
10
+ // touched.
11
+ //
12
+ // What it never does, by construction: import Memory code, read Memory's
13
+ // storage tree, or reinterpret a doctor/load payload — the only Memory bytes
14
+ // it parses are the handshake's (`docs/command-contract.md`, "The version
15
+ // handshake"; "Storage-tree ownership"). Pinned in packages/cli/test/memory.test.ts.
16
+ //
17
+ // Exit codes on this surface follow the contract: 0 for UNSUPPORTED/absent
18
+ // (Memory is not registered here, or its executable is gone — the relocation
19
+ // case), 1 for INTEGRATION-FAILED (the executable answered, but not with a
20
+ // handshake the manifest promised — a broken VERSION is `manifest-stale`,
21
+ // never `absent`), 3 for an unmet prerequisite (no configuration root: APPDATA
22
+ // or HOME unset), 4 for a foreign contract major. For 0, 1, 3 and 4 the stdout
23
+ // is exactly one JSON object with no file path in it; an invalid invocation
24
+ // (exit 2 — no verb, or one outside doctor/load) writes nothing to stdout and
25
+ // names the verbs on stderr; human hints always go to stderr.
26
+ import { execFileRunner } from './setup.js';
27
+ import { MEMORY_CONTRACT_MAJOR, SubsystemsError, handshake, readSubsystemsManifest, subsystemsManifestPath, } from '../lib/subsystems.js';
28
+ const MEMORY_VERBS = ['doctor', 'load'];
29
+ /**
30
+ * The outer deadline: the rig kills the Memory child after this many ms. It is
31
+ * the consumer's bound, and it should not be the one that fires first — a kill
32
+ * leaves no JSON answer behind. So `load` always carries an INTERNAL deadline
33
+ * as well (RP-183): Memory's own `--timeout-ms`, which per the Memory owner
34
+ * (RP-183, Jira) it answers with a typed result instead of being killed.
35
+ * `doctor` accepts no such flag per the same source — its args are never
36
+ * touched. Pinned in packages/cli/test/memory.test.ts, "RP-183".
37
+ */
38
+ const VERB_TIMEOUT_MS = 60_000;
39
+ /** Memory's default internal deadline when the caller names none. */
40
+ const DEFAULT_MEMORY_TIMEOUT_MS = 45_000;
41
+ /**
42
+ * Headroom the outer deadline keeps above an explicit internal one. Sized per
43
+ * the Memory owner (RP-183, Jira): Memory's Windows cleanup may return up to
44
+ * 10 s after its own deadline fired; the rest is spawn overhead. The rig does
45
+ * not measure that lag itself.
46
+ */
47
+ const OUTER_MARGIN_MS = 15_000;
48
+ /**
49
+ * The largest delay `execFile`'s timer can hold: Node clamps a larger
50
+ * `setTimeout` to ONE millisecond, which would make the outer kill fire first
51
+ * — the inversion this whole helper exists to prevent. A caller who asks for
52
+ * more than ~24.8 days gets this ceiling as the outer deadline.
53
+ */
54
+ const MAX_TIMER_MS = 2_147_483_647;
55
+ const TIMEOUT_FLAG = '--timeout-ms';
56
+ /** Memory's own grammar for the value: a positive integer, milliseconds. */
57
+ const TIMEOUT_VALUE = /^[1-9][0-9]*$/;
58
+ /**
59
+ * The verb's argument list and outer deadline. `load` with no `--timeout-ms`
60
+ * gets the default appended; a caller-owned flag — any spelling — passes
61
+ * through verbatim, and only a well-formed `--timeout-ms <value>` pair can
62
+ * raise the outer deadline above it. Memory owns the refusal of a malformed
63
+ * value, so the rig neither corrects nor drops one.
64
+ */
65
+ const deadlinesFor = (verb, args) => {
66
+ if (verb !== 'load')
67
+ return { args: [...args], timeoutMs: VERB_TIMEOUT_MS };
68
+ const at = args.findIndex((arg) => arg === TIMEOUT_FLAG || arg.startsWith(`${TIMEOUT_FLAG}=`));
69
+ if (at === -1) {
70
+ return {
71
+ args: [...args, TIMEOUT_FLAG, String(DEFAULT_MEMORY_TIMEOUT_MS)],
72
+ timeoutMs: VERB_TIMEOUT_MS,
73
+ };
74
+ }
75
+ const value = args[at] === TIMEOUT_FLAG ? args[at + 1] : undefined;
76
+ const internal = value !== undefined && TIMEOUT_VALUE.test(value) ? Number(value) : null;
77
+ // A digit string too long for a safe integer — or for a double at all —
78
+ // still lands on the ceiling: `Number` yields a huge float or Infinity, and
79
+ // `Math.min` with the ceiling holds either.
80
+ const timeoutMs = internal !== null
81
+ ? Math.min(Math.max(VERB_TIMEOUT_MS, internal + OUTER_MARGIN_MS), MAX_TIMER_MS)
82
+ : VERB_TIMEOUT_MS;
83
+ return { args: [...args], timeoutMs };
84
+ };
85
+ const jsonLine = (payload) => `${JSON.stringify(payload)}\n`;
86
+ const isVerb = (verb) => MEMORY_VERBS.includes(verb);
87
+ export async function runMemory(options) {
88
+ if (!isVerb(options.verb)) {
89
+ return {
90
+ exitCode: 2,
91
+ stdout: '',
92
+ stderr: `memory needs a verb: create-agent-rig memory <${MEMORY_VERBS.join('|')}> [args…]\n`,
93
+ };
94
+ }
95
+ const env = options.env ?? process.env;
96
+ const platform = options.platform ?? process.platform;
97
+ const run = options.run ?? execFileRunner;
98
+ let file;
99
+ try {
100
+ file = subsystemsManifestPath(env, platform);
101
+ }
102
+ catch (error) {
103
+ if (error instanceof SubsystemsError && error.code === 'config-root-unavailable') {
104
+ // Nothing was attempted: without a configuration root there is no place
105
+ // the manifest could be. The contract's first exit-3 occasion — a
106
+ // variable that is not set — with the variable named, never a path.
107
+ const variable = platform === 'win32' ? 'APPDATA' : 'HOME';
108
+ return {
109
+ exitCode: 3,
110
+ stdout: jsonLine({
111
+ schemaVersion: 1,
112
+ result: 'prerequisites-unmet',
113
+ missing: [{ kind: 'environment', name: variable, detail: 'not set' }],
114
+ }),
115
+ stderr: `memory: ${variable} is not set, so there is no configuration root to read the subsystem manifest from\n`,
116
+ };
117
+ }
118
+ throw error;
119
+ }
120
+ let manifest;
121
+ try {
122
+ manifest = await readSubsystemsManifest(file);
123
+ }
124
+ catch (error) {
125
+ if (error instanceof SubsystemsError)
126
+ return {
127
+ exitCode: 1,
128
+ stdout: jsonLine({ schemaVersion: 1, result: 'integration-failed', reason: 'unreadable' }),
129
+ stderr: `memory: the subsystem manifest is not readable (${error.code}); run setup --memory-root again\n`,
130
+ };
131
+ throw error;
132
+ }
133
+ if (manifest === null)
134
+ return {
135
+ exitCode: 0,
136
+ stdout: jsonLine({ schemaVersion: 1, result: 'unsupported', reason: 'absent' }),
137
+ stderr: 'memory: Memory is not registered on this machine; run setup --memory-root <checkout>\n',
138
+ };
139
+ const entry = manifest.entries.memory;
140
+ const result = await handshake(entry, run);
141
+ if (result.status === 'foreign-major')
142
+ return {
143
+ exitCode: 4,
144
+ stdout: jsonLine({
145
+ schemaVersion: 1,
146
+ result: 'foreign-major',
147
+ contractVersion: result.contractVersion,
148
+ requiredMajor: MEMORY_CONTRACT_MAJOR,
149
+ }),
150
+ stderr: `memory: the registered Memory implements contract ${result.contractVersion}, ` +
151
+ `this rig requires major ${MEMORY_CONTRACT_MAJOR}; ${options.verb} was not run\n`,
152
+ };
153
+ if (result.status === 'unsupported')
154
+ return {
155
+ exitCode: 0,
156
+ stdout: jsonLine({ schemaVersion: 1, result: 'unsupported', reason: 'absent' }),
157
+ stderr: 'memory: the registered Memory executable is no longer there; ' +
158
+ 'run setup --memory-root <checkout> again\n',
159
+ };
160
+ if (result.status === 'integration-failed')
161
+ return {
162
+ exitCode: 1,
163
+ stdout: jsonLine({ schemaVersion: 1, result: 'integration-failed', reason: result.reason }),
164
+ stderr: `memory: the handshake did not answer as the manifest promised (${result.reason}); ${options.verb} was not run\n`,
165
+ };
166
+ const [command, script] = entry.invocation;
167
+ const deadlines = deadlinesFor(options.verb, options.args);
168
+ const answer = await run(command, [script, options.verb, ...deadlines.args], {
169
+ timeoutMs: deadlines.timeoutMs,
170
+ });
171
+ if (answer.spawnError)
172
+ return {
173
+ exitCode: 1,
174
+ stdout: jsonLine({
175
+ schemaVersion: 1,
176
+ result: 'integration-failed',
177
+ reason: 'backend-failed',
178
+ }),
179
+ stderr: `memory: ${options.verb} could not be started (${answer.spawnError.code ?? 'spawn error'})\n`,
180
+ };
181
+ return { exitCode: answer.code, stdout: answer.stdout, stderr: answer.stderr };
182
+ }
@@ -0,0 +1,45 @@
1
+ // `create-agent-rig setup --memory-root <checkout>`: the sole writer of the
2
+ // machine subsystem manifest for 0.9.0 (ADR-RP-002 R6, RP-147). Order is the
3
+ // contract: derive the entry from the one declared root, perform the
4
+ // `--version --json` handshake, refuse a foreign contract major with exit 4
5
+ // before anything is written, then write the manifest atomically.
6
+ import { execFile } from 'node:child_process';
7
+ import { SUBSYSTEMS_SCHEMA_VERSION, SubsystemsError, deriveMemoryEntry, handshake, subsystemsManifestPath, writeSubsystemsManifest, } from '../lib/subsystems.js';
8
+ /** The default runner: one child, one JSON line, a bounded wait. */
9
+ export const execFileRunner = (file, args, { timeoutMs }) => new Promise((resolve) => {
10
+ execFile(file, args, { timeout: timeoutMs, maxBuffer: 64 * 1024, windowsHide: true }, (error, stdout, stderr) => {
11
+ if (error && error.code === 'ENOENT')
12
+ resolve({ code: 1, stdout, stderr, spawnError: error });
13
+ else if (error && typeof error.code === 'number')
14
+ resolve({ code: error.code, stdout, stderr });
15
+ else if (error)
16
+ resolve({ code: 1, stdout, stderr, spawnError: error });
17
+ else
18
+ resolve({ code: 0, stdout, stderr });
19
+ });
20
+ });
21
+ export async function setupSubsystems(options) {
22
+ const env = options.env ?? process.env;
23
+ const platform = options.platform ?? process.platform;
24
+ const run = options.run ?? execFileRunner;
25
+ const file = subsystemsManifestPath(env, platform);
26
+ const entry = deriveMemoryEntry({
27
+ memoryRoot: options.memoryRoot,
28
+ nodeExecutable: options.nodeExecutable ?? process.execPath,
29
+ memoryRef: options.memoryRef ?? null,
30
+ installedVersion: '',
31
+ }, platform);
32
+ const result = await handshake(entry, run);
33
+ if (result.status === 'foreign-major')
34
+ return { outcome: 'refused', exitCode: 4, handshake: result };
35
+ if (result.status !== 'ok')
36
+ return { outcome: 'refused', exitCode: 1, handshake: result };
37
+ if (options.dryRun === true)
38
+ return { outcome: 'dry-run', file, handshake: result };
39
+ await writeSubsystemsManifest(file, {
40
+ schemaVersion: SUBSYSTEMS_SCHEMA_VERSION,
41
+ entries: { memory: { ...entry, installedVersion: result.version } },
42
+ });
43
+ return { outcome: 'written', file, handshake: result };
44
+ }
45
+ export { SubsystemsError };