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.
- package/CHANGELOG.md +152 -1
- package/README.md +99 -4
- package/package.json +4 -3
- package/packages/cli/dist/commands/init.js +43 -7
- package/packages/cli/dist/commands/memory.js +182 -0
- package/packages/cli/dist/commands/setup.js +45 -0
- package/packages/cli/dist/commands/upgrade.js +22 -4
- package/packages/cli/dist/index.js +110 -3
- package/packages/cli/dist/lib/manifest.js +23 -5
- package/packages/cli/dist/lib/subsystems.js +269 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
- package/packages/cli/dist/policy/core/coverage.js +253 -0
- package/packages/cli/dist/policy/core/decision-record.js +130 -44
- package/packages/cli/dist/policy/core/declaration.js +58 -17
- package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
- package/packages/cli/dist/policy/core/probe.js +442 -0
- package/packages/cli/dist/policy/core/validation.js +194 -1
- package/packages/cli/dist/policy/core/vocabulary.js +70 -3
- package/packages/cli/dist/policy/harness/claude.js +9 -1
- package/packages/cli/dist/policy/harness/codex.js +48 -1
- package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
- package/packages/cli/dist/policy/index.js +9 -2
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
- package/templates/agent-os/subagent-routing.json +32 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
- package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
- package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +269 -32
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
- package/templates/agent-os/universal/.claude/settings.json +16 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
- package/templates/agent-os/universal/.codex/config.toml +3 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
- package/templates/agent-os/universal/layers.json +4 -0
- package/templates/hash-history.json +71 -22
- package/templates/release-ledger.json +3 -1
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
- 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.
|
|
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.
|
|
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": "^
|
|
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
|
|
169
|
-
* somebody else's — claiming it
|
|
170
|
-
* user's own document with the rig's.
|
|
171
|
-
*
|
|
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 };
|