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
@@ -315,17 +315,34 @@ export async function planUpgrade(repoDir, options = {}) {
315
315
  nextFiles[file.rel] = recorded;
316
316
  }
317
317
  else {
318
+ // A path `init` found and left alone carries its provenance in `kept`
319
+ // (RP-182): the reason can then say what happened to the file since,
320
+ // instead of only that no release ever shipped these bytes. It names no
321
+ // version — `manifest.version` is rewritten by every upgrade, so it is
322
+ // not the version of the init that kept the file.
323
+ const kept = recorded === undefined ? manifest?.kept?.[file.rel] : undefined;
324
+ const keptReason = (since) => `kept by init (already here, not the rig's bytes), ${since} since — treated as yours`;
318
325
  actions.push({
319
326
  rel: file.rel,
320
327
  verdict: 'conflict',
321
- reason: recorded === undefined
322
- ? 'not a version this rig ever released — treated as yours'
323
- : 'edited since it was installed',
328
+ reason: kept !== undefined
329
+ ? keptReason(sha256(current) === kept ? 'unchanged' : 'edited')
330
+ : recorded === undefined
331
+ ? 'not a version this rig ever released — treated as yours'
332
+ : 'edited since it was installed',
324
333
  templatePath: file.source,
325
334
  });
326
- // deliberately NOT recorded: the rig does not own these bytes
335
+ // deliberately NOT recorded in `files`: the rig does not own these bytes
327
336
  }
328
337
  }
338
+ // `kept` travels forward untouched, minus every path the plan now vouches
339
+ // for in `files` — a kept file that turned out to be a released version, or
340
+ // is byte-identical to this release, has become the rig's to manage.
341
+ const nextKept = {};
342
+ for (const [rel, hash] of Object.entries(manifest?.kept ?? {})) {
343
+ if (nextFiles[rel] === undefined)
344
+ nextKept[rel] = hash;
345
+ }
329
346
  // With no manifest, "there is a rig here" has to be *recognised*, not
330
347
  // assumed from a file existing: `CLAUDE.md` and `.claude/settings.json` are
331
348
  // in the install set and in nearly every repository ever opened by an agent.
@@ -352,6 +369,7 @@ export async function planUpgrade(repoDir, options = {}) {
352
369
  project,
353
370
  stacks: [...stacks],
354
371
  files: nextFiles,
372
+ ...(Object.keys(nextKept).length > 0 ? { kept: nextKept } : {}),
355
373
  },
356
374
  };
357
375
  }
@@ -2,13 +2,16 @@
2
2
  import { parseArgs } from 'node:util';
3
3
  import { CreateError, createProject } from './commands/create.js';
4
4
  import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
5
+ import { execFileRunner, setupSubsystems } from './commands/setup.js';
5
6
  import { UpgradeError, applyUpgrade, planUpgrade } from './commands/upgrade.js';
6
7
  import { makePalette } from './lib/colors.js';
7
8
  import { readManifest } from './lib/manifest.js';
9
+ import { SubsystemsError, refreshSubsystems, subsystemsManifestPath } from './lib/subsystems.js';
8
10
  import { promptConfirm, promptTarget } from './lib/prompts.js';
9
11
  import { collectGovernance, renderSummary } from './lib/summary.js';
10
12
  import { DEFAULT_TARGET, TARGET_NAMES } from './lib/targets.js';
11
- import { packageVersion } from './lib/version.js';
13
+ import { packageVersion, rigHandshake } from './lib/version.js';
14
+ import { runMemory } from './commands/memory.js';
12
15
  const USAGE = `Usage: create-agent-rig <dir> [options]
13
16
 
14
17
  Scaffolds a new project into <dir>: a Claude Code + Codex agent operating system
@@ -20,7 +23,8 @@ Options
20
23
  required when not a terminal — default: ${DEFAULT_TARGET})
21
24
  --no-git skip git init + the pristine-template baseline commit
22
25
  --no-color plain output (NO_COLOR is respected too)
23
- --version print the version
26
+ --version print the version (--version --json: the contract handshake,
27
+ one JSON object with the name, version and contract version)
24
28
  -h, --help this text
25
29
 
26
30
  Also: create-agent-rig init [--dry-run]
@@ -32,7 +36,70 @@ Also: create-agent-rig init [--dry-run]
32
36
 
33
37
  Also: create-agent-rig upgrade [--dry-run] [--yes]
34
38
  Bring the rig in the CURRENT repo up to this version. Replaces the files it
35
- installed and you did not touch; everything else is reported, never merged.`;
39
+ installed and you did not touch; everything else is reported, never merged.
40
+ Re-runs the subsystem manifest derivation when one exists (see setup).
41
+
42
+ Also: create-agent-rig setup --memory-root <checkout> [--memory-ref <sha>] [--dry-run]
43
+ Record the Memory executable in this machine's subsystem manifest
44
+ (~/.config/create-agent-rig/subsystems.json; %APPDATA% on Windows) from the
45
+ one declared root. Performs the --version --json handshake first and refuses
46
+ a foreign contract major with exit 4 before writing anything.
47
+
48
+ Also: create-agent-rig memory <doctor|load> [args…]
49
+ Run a Memory verb through the registered executable: the --version --json
50
+ handshake first (a foreign contract major exits 4 and the verb never runs),
51
+ then the verb and its arguments, Memory's answer passed through unchanged.
52
+ Your arguments go to Memory as written; a load that names no --timeout-ms
53
+ gets --timeout-ms 45000 appended (Memory's own deadline, kept under the rig's
54
+ 60 s bound). No manifest answers unsupported/absent (exit 0); an invalid
55
+ invocation exits 2.`;
56
+ async function runSetup(rawArgs) {
57
+ let values;
58
+ try {
59
+ ({ values } = parseArgs({
60
+ args: rawArgs,
61
+ options: {
62
+ 'memory-root': { type: 'string' },
63
+ 'memory-ref': { type: 'string' },
64
+ 'dry-run': { type: 'boolean' },
65
+ 'no-color': { type: 'boolean' },
66
+ },
67
+ allowPositionals: false,
68
+ }));
69
+ }
70
+ catch (error) {
71
+ process.stderr.write(`${error.message}\n\n${USAGE}\n`);
72
+ return 1;
73
+ }
74
+ const memoryRoot = values['memory-root'];
75
+ if (memoryRoot === undefined) {
76
+ process.stderr.write(`setup needs --memory-root <checkout>\n\n${USAGE}\n`);
77
+ return 1;
78
+ }
79
+ try {
80
+ const result = await setupSubsystems({
81
+ memoryRoot,
82
+ memoryRef: values['memory-ref'] ?? null,
83
+ dryRun: values['dry-run'] === true,
84
+ });
85
+ if (result.outcome === 'refused') {
86
+ process.stderr.write(`Memory handshake: ${JSON.stringify(result.handshake)}\n`);
87
+ return result.exitCode;
88
+ }
89
+ process.stdout.write(`Memory handshake: ${JSON.stringify(result.handshake)}\n` +
90
+ (result.outcome === 'dry-run'
91
+ ? `Dry run — nothing written (would write ${result.file}).\n`
92
+ : `Wrote ${result.file}\n`));
93
+ return 0;
94
+ }
95
+ catch (error) {
96
+ if (error instanceof SubsystemsError) {
97
+ process.stderr.write(`setup: ${error.message} (${error.code})\n`);
98
+ return 1;
99
+ }
100
+ throw error;
101
+ }
102
+ }
36
103
  async function runInit(rawArgs) {
37
104
  let values;
38
105
  try {
@@ -219,15 +286,48 @@ async function runUpgrade(rawArgs) {
219
286
  }
220
287
  const result = await applyUpgrade(cwd, plan);
221
288
  process.stdout.write(`\nWrote ${result.written.length} files.\n`);
289
+ // The subsystem manifest is machine-scoped and written by `setup`; an
290
+ // upgrade re-runs the same derivation so `installedVersion` follows the
291
+ // executable the root now holds. It is never part of the rig manifest or of
292
+ // `result.written` — that file list is the repository's, this one is the
293
+ // user's. Absent stays absent: creating it is `setup`'s act.
294
+ try {
295
+ const refreshed = await refreshSubsystems({
296
+ file: subsystemsManifestPath(process.env, process.platform),
297
+ run: execFileRunner,
298
+ nodeExecutable: process.execPath,
299
+ platform: process.platform,
300
+ });
301
+ if (refreshed !== 'absent')
302
+ process.stdout.write(`Subsystem manifest: ${typeof refreshed === 'string' ? refreshed : JSON.stringify(refreshed)}\n`);
303
+ }
304
+ catch (error) {
305
+ if (!(error instanceof SubsystemsError))
306
+ throw error;
307
+ process.stdout.write(`Subsystem manifest: not refreshed — ${error.message} (${error.code})\n`);
308
+ }
222
309
  return 0;
223
310
  }
224
311
  async function main() {
225
312
  if (process.argv[2] === 'init') {
226
313
  return runInit(process.argv.slice(3));
227
314
  }
315
+ if (process.argv[2] === 'setup') {
316
+ return runSetup(process.argv.slice(3));
317
+ }
228
318
  if (process.argv[2] === 'upgrade') {
229
319
  return runUpgrade(process.argv.slice(3));
230
320
  }
321
+ if (process.argv[2] === 'memory') {
322
+ // The consumer path of the RP-19 handshake: manifest → `--version --json`
323
+ // → exit 4 on a foreign major → doctor/load passed through (`load` gains a
324
+ // default `--timeout-ms` when the caller names none — commands/memory.ts).
325
+ const [verb = '', ...args] = process.argv.slice(3);
326
+ const result = await runMemory({ verb, args });
327
+ process.stdout.write(result.stdout);
328
+ process.stderr.write(result.stderr);
329
+ return result.exitCode;
330
+ }
231
331
  let positionals;
232
332
  let values;
233
333
  try {
@@ -237,6 +337,9 @@ async function main() {
237
337
  help: { type: 'boolean', short: 'h' },
238
338
  target: { type: 'string' },
239
339
  version: { type: 'boolean' },
340
+ // `--json` is read on `--version` alone: the handshake object of
341
+ // docs/command-contract.md, one JSON line and nothing else on stdout.
342
+ json: { type: 'boolean' },
240
343
  'no-git': { type: 'boolean' },
241
344
  'no-color': { type: 'boolean' },
242
345
  },
@@ -248,6 +351,10 @@ async function main() {
248
351
  return 1;
249
352
  }
250
353
  if (values.version) {
354
+ if (values.json) {
355
+ process.stdout.write(`${JSON.stringify(await rigHandshake())}\n`);
356
+ return 0;
357
+ }
251
358
  process.stdout.write(`${await packageVersion()}\n`);
252
359
  return 0;
253
360
  }
@@ -91,20 +91,38 @@ export function parseManifest(raw) {
91
91
  return null;
92
92
  if (!isStringRecord(m.files))
93
93
  return null;
94
+ // Present in a shape this reader does not accept voids the manifest, exactly
95
+ // as `files` does; absent is every manifest written before the field existed.
96
+ if (m.kept !== undefined && !isStringRecord(m.kept))
97
+ return null;
94
98
  return {
95
99
  version: m.version,
96
100
  kind: m.kind,
97
101
  project: { name: project.name, scope: project.scope, region: project.region },
98
102
  stacks: [...m.stacks],
99
103
  files: { ...m.files },
104
+ ...(m.kept !== undefined ? { kept: { ...m.kept } } : {}),
100
105
  };
101
106
  }
102
- /** Stable bytes: sorted paths, so a re-run produces no diff of its own. */
107
+ const sortedRecord = (record) => {
108
+ const sorted = {};
109
+ for (const rel of Object.keys(record).sort())
110
+ sorted[rel] = record[rel];
111
+ return sorted;
112
+ };
113
+ /**
114
+ * Stable bytes: sorted paths, so a re-run produces no diff of its own. An
115
+ * empty `kept` is omitted, not written as `{}` — a manifest that kept nothing
116
+ * must serialise byte-identical to one written before the key existed.
117
+ */
103
118
  export function serializeManifest(manifest) {
104
- const files = {};
105
- for (const rel of Object.keys(manifest.files).sort())
106
- files[rel] = manifest.files[rel];
107
- return `${JSON.stringify({ ...manifest, files }, null, 2)}\n`;
119
+ const { kept, ...rest } = manifest;
120
+ const body = {
121
+ ...rest,
122
+ files: sortedRecord(manifest.files),
123
+ ...(kept !== undefined && Object.keys(kept).length > 0 ? { kept: sortedRecord(kept) } : {}),
124
+ };
125
+ return `${JSON.stringify(body, null, 2)}\n`;
108
126
  }
109
127
  export async function readManifest(repoDir) {
110
128
  try {
@@ -0,0 +1,269 @@
1
+ // The machine-scoped subsystem manifest (ADR-RP-002 R6, RP-147): installation
2
+ // metadata, not a service registry. One writer (`setup`; `upgrade` re-runs the
3
+ // same derivation), one scope (the user's configuration root), one entry per
4
+ // subsystem carrying the resolved executable invocation and the contract major
5
+ // it must answer. No search path, no fallback chain, no PATH scan: the
6
+ // executable is derived from the one root the owner declared, and every
7
+ // substantive call performs the `--version --json` handshake first.
8
+ //
9
+ // Handshake results carry no path — a status is machine JSON a consumer may
10
+ // print, and the manifest itself is the only place a location is written.
11
+ import { statSync } from 'node:fs';
12
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
13
+ import { randomBytes } from 'node:crypto';
14
+ import path from 'node:path';
15
+ export const SUBSYSTEMS_SCHEMA_VERSION = 1;
16
+ export const MEMORY_CONTRACT_MAJOR = 1;
17
+ const MEMORY_EXECUTABLE_REL = ['shared-memory', 'memory.mjs'];
18
+ const HANDSHAKE_TIMEOUT_MS = 15_000;
19
+ export class SubsystemsError extends Error {
20
+ code;
21
+ constructor(code, message) {
22
+ super(message);
23
+ this.name = 'SubsystemsError';
24
+ this.code = code;
25
+ }
26
+ }
27
+ /**
28
+ * Exactly one location per platform. An unset root is refused, not guessed:
29
+ * a manifest written under a guessed home is a manifest nothing else reads.
30
+ */
31
+ export function subsystemsManifestPath(env, platform) {
32
+ if (platform === 'win32') {
33
+ const root = env.APPDATA;
34
+ if (!root)
35
+ throw new SubsystemsError('config-root-unavailable', 'APPDATA is not set');
36
+ return path.join(root, 'create-agent-rig', 'subsystems.json');
37
+ }
38
+ const home = env.HOME;
39
+ if (!home)
40
+ throw new SubsystemsError('config-root-unavailable', 'HOME is not set');
41
+ return path.join(home, '.config', 'create-agent-rig', 'subsystems.json');
42
+ }
43
+ /** The path module of the DECLARED platform, so an entry's encoding is pinned by the platform argument, not by the host. */
44
+ const pathFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
45
+ export function memoryExecutablePath(memoryRoot, platform) {
46
+ return pathFor(platform).join(memoryRoot, ...MEMORY_EXECUTABLE_REL);
47
+ }
48
+ /**
49
+ * The pure half of the derivation: root → invocation, in the declared
50
+ * platform's encoding. The root is the single declared input
51
+ * (`setup --memory-root`), so it must be absolute under that platform — a
52
+ * relative root would make the entry mean a different executable from every
53
+ * working directory. No filesystem here, so both encodings are pinned by tests
54
+ * on any host.
55
+ */
56
+ export function memoryInvocation(input, platform) {
57
+ if (!pathFor(platform).isAbsolute(input.memoryRoot))
58
+ throw new SubsystemsError('memory-root-relative', 'the Memory root must be an absolute path');
59
+ return [input.nodeExecutable, memoryExecutablePath(input.memoryRoot, platform)];
60
+ }
61
+ function isFile(file) {
62
+ try {
63
+ return statSync(file).isFile();
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ /**
70
+ * The whole derivation: the invocation above, plus the check that the
71
+ * executable is already there on this host — the manifest records what
72
+ * exists, it does not promise what an install will bring. So `platform` is
73
+ * the host's here; the pure half is what a foreign platform's encoding is
74
+ * tested through.
75
+ */
76
+ export function deriveMemoryEntry(input, platform) {
77
+ const invocation = memoryInvocation(input, platform);
78
+ if (!isFile(invocation[1]))
79
+ throw new SubsystemsError('memory-executable-absent', `no ${MEMORY_EXECUTABLE_REL.join('/')} under the declared Memory root`);
80
+ return {
81
+ memoryRoot: input.memoryRoot,
82
+ invocation,
83
+ contractMajor: MEMORY_CONTRACT_MAJOR,
84
+ memoryRef: input.memoryRef,
85
+ installedVersion: input.installedVersion,
86
+ };
87
+ }
88
+ const majorOf = (contractVersion) => {
89
+ const match = /^(\d+)\.\d+$/.exec(contractVersion);
90
+ return match ? Number(match[1]) : null;
91
+ };
92
+ /**
93
+ * `--version --json` and its classification. The consumer compares the
94
+ * contract major alone; a foreign major is the consumer's refusal (exit 4 in
95
+ * `setup`), never something Memory emits. A failure payload is
96
+ * INTEGRATION-FAILED, never absence: the executable answered.
97
+ */
98
+ export async function handshake(entry, run, options = {}) {
99
+ const [file, script] = entry.invocation;
100
+ // Absence is the runner's answer (ENOENT), not a pre-check: the executable
101
+ // the manifest names is exactly what this call must exercise.
102
+ const result = await run(file, [script, '--version', '--json'], {
103
+ timeoutMs: options.timeoutMs ?? HANDSHAKE_TIMEOUT_MS,
104
+ });
105
+ if (result.spawnError) {
106
+ if (result.spawnError.code === 'ENOENT')
107
+ return { status: 'unsupported', reason: 'absent' };
108
+ return { status: 'integration-failed', reason: 'invalid-payload' };
109
+ }
110
+ let payload;
111
+ try {
112
+ payload = JSON.parse(result.stdout);
113
+ }
114
+ catch {
115
+ return { status: 'integration-failed', reason: 'invalid-payload' };
116
+ }
117
+ if (typeof payload !== 'object' || payload === null)
118
+ return { status: 'integration-failed', reason: 'invalid-payload' };
119
+ const record = payload;
120
+ // The executable answered, and answered a failure — a broken VERSION file is
121
+ // the Memory-side shape (RP-19). The manifest's promise about what is
122
+ // installed no longer holds, so this is `manifest-stale`, never absence.
123
+ if (result.code !== 0 || record.result === 'integration-failed')
124
+ return { status: 'integration-failed', reason: 'manifest-stale' };
125
+ const { name, version, contractVersion } = record;
126
+ if (typeof version !== 'string' || typeof contractVersion !== 'string')
127
+ return { status: 'integration-failed', reason: 'invalid-payload' };
128
+ if (name !== 'memory')
129
+ return { status: 'integration-failed', reason: 'manifest-stale' };
130
+ const major = majorOf(contractVersion);
131
+ if (major === null)
132
+ return { status: 'integration-failed', reason: 'manifest-stale' };
133
+ if (major !== MEMORY_CONTRACT_MAJOR)
134
+ return { status: 'foreign-major', contractVersion, requiredMajor: MEMORY_CONTRACT_MAJOR };
135
+ return { status: 'ok', version, contractVersion };
136
+ }
137
+ /** Stable bytes: fixed key order, two-space indent, trailing newline. */
138
+ export function serializeSubsystemsManifest(manifest) {
139
+ const memory = manifest.entries.memory;
140
+ const ordered = {
141
+ schemaVersion: SUBSYSTEMS_SCHEMA_VERSION,
142
+ entries: {
143
+ memory: {
144
+ memoryRoot: memory.memoryRoot,
145
+ invocation: [memory.invocation[0], memory.invocation[1]],
146
+ contractMajor: MEMORY_CONTRACT_MAJOR,
147
+ memoryRef: memory.memoryRef,
148
+ installedVersion: memory.installedVersion,
149
+ },
150
+ },
151
+ };
152
+ return `${JSON.stringify(ordered, null, 2)}\n`;
153
+ }
154
+ const isString = (value) => typeof value === 'string';
155
+ export function parseSubsystemsManifest(text) {
156
+ let parsed;
157
+ try {
158
+ parsed = JSON.parse(text);
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ if (typeof parsed !== 'object' || parsed === null)
164
+ return null;
165
+ const root = parsed;
166
+ if (root.schemaVersion !== SUBSYSTEMS_SCHEMA_VERSION)
167
+ return null;
168
+ const entries = root.entries;
169
+ if (typeof entries !== 'object' || entries === null)
170
+ return null;
171
+ const memory = entries.memory;
172
+ if (typeof memory !== 'object' || memory === null)
173
+ return null;
174
+ const m = memory;
175
+ const invocation = m.invocation;
176
+ if (!isString(m.memoryRoot) ||
177
+ !Array.isArray(invocation) ||
178
+ invocation.length !== 2 ||
179
+ !invocation.every(isString) ||
180
+ m.contractMajor !== MEMORY_CONTRACT_MAJOR ||
181
+ !(m.memoryRef === null || isString(m.memoryRef)) ||
182
+ !isString(m.installedVersion))
183
+ return null;
184
+ return {
185
+ schemaVersion: SUBSYSTEMS_SCHEMA_VERSION,
186
+ entries: {
187
+ memory: {
188
+ memoryRoot: m.memoryRoot,
189
+ // `every(isString)` above proved both elements are strings, but
190
+ // Array.prototype.every does not narrow a tuple's element type.
191
+ invocation: [invocation[0], invocation[1]],
192
+ contractMajor: MEMORY_CONTRACT_MAJOR,
193
+ // Checked above as `null` or a string; the `||` chain does not narrow `m.memoryRef`.
194
+ memoryRef: m.memoryRef,
195
+ installedVersion: m.installedVersion,
196
+ },
197
+ },
198
+ };
199
+ }
200
+ /** `null` when absent — no evidence; a present file that does not parse is an error, not silence. */
201
+ export async function readSubsystemsManifest(file) {
202
+ let text;
203
+ try {
204
+ text = await readFile(file, 'utf8');
205
+ }
206
+ catch (error) {
207
+ if (error.code === 'ENOENT')
208
+ return null;
209
+ throw new SubsystemsError('manifest-unreadable', `cannot read ${path.basename(file)}`);
210
+ }
211
+ const manifest = parseSubsystemsManifest(text);
212
+ if (manifest === null)
213
+ throw new SubsystemsError('manifest-unreadable', `${path.basename(file)} is not a schemaVersion ${SUBSYSTEMS_SCHEMA_VERSION} subsystem manifest`);
214
+ return manifest;
215
+ }
216
+ /**
217
+ * Atomic and idempotent: the bytes land in a sibling temp file and are renamed
218
+ * over the target, so a reader never sees a torn manifest and a re-run of the
219
+ * same derivation produces no diff of its own.
220
+ */
221
+ export async function writeSubsystemsManifest(file, manifest) {
222
+ const dir = path.dirname(file);
223
+ await mkdir(dir, { recursive: true });
224
+ const temp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`);
225
+ try {
226
+ await writeFile(temp, serializeSubsystemsManifest(manifest));
227
+ await rename(temp, file);
228
+ }
229
+ catch (error) {
230
+ // See packages/cli/test/subsystems.test.ts › "removes its temp file and
231
+ // rethrows when the rename is refused".
232
+ await rm(temp, { force: true });
233
+ throw error;
234
+ }
235
+ }
236
+ /**
237
+ * What `upgrade` re-runs: the same derivation from the recorded root, the same
238
+ * handshake, and a rewrite only when the handshake is ok. A manifest that is
239
+ * absent is left absent (`setup` is the one writer that creates it); one whose
240
+ * executable no longer answers is left untouched and the result says why.
241
+ */
242
+ export async function refreshSubsystems(options) {
243
+ const manifest = await readSubsystemsManifest(options.file);
244
+ if (manifest === null)
245
+ return 'absent';
246
+ const previous = manifest.entries.memory;
247
+ let entry;
248
+ try {
249
+ entry = deriveMemoryEntry({
250
+ memoryRoot: previous.memoryRoot,
251
+ nodeExecutable: options.nodeExecutable,
252
+ memoryRef: previous.memoryRef,
253
+ installedVersion: previous.installedVersion,
254
+ }, options.platform);
255
+ }
256
+ catch (error) {
257
+ if (error instanceof SubsystemsError && error.code === 'memory-executable-absent')
258
+ return { status: 'unsupported', reason: 'absent' };
259
+ throw error;
260
+ }
261
+ const result = await handshake(entry, options.run);
262
+ if (result.status !== 'ok')
263
+ return result;
264
+ await writeSubsystemsManifest(options.file, {
265
+ schemaVersion: SUBSYSTEMS_SCHEMA_VERSION,
266
+ entries: { memory: { ...entry, installedVersion: result.version } },
267
+ });
268
+ return 'refreshed';
269
+ }
@@ -13,3 +13,18 @@ export async function packageVersion() {
13
13
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
14
14
  return pkg.version;
15
15
  }
16
+ /**
17
+ * The version of the command contract (`docs/command-contract.md`) this rig
18
+ * implements. Independent of the package version: many releases ship against
19
+ * one contract, and a consumer compares this field's major alone.
20
+ */
21
+ export const RIG_CONTRACT_VERSION = '1.0';
22
+ /** What `--version --json` answers — the handshake object of the contract. */
23
+ export async function rigHandshake() {
24
+ return {
25
+ schemaVersion: 1,
26
+ name: 'create-agent-rig',
27
+ version: await packageVersion(),
28
+ contractVersion: RIG_CONTRACT_VERSION,
29
+ };
30
+ }