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
|
@@ -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:
|
|
322
|
-
?
|
|
323
|
-
:
|
|
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
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
}
|