@cairnkeep/cli 2.8.0 → 2.11.0
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 +68 -1
- package/README.md +82 -27
- package/bin/cairn +6 -153
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +41 -4
- package/docs/containers.md +10 -0
- package/docs/context-packs.md +94 -0
- package/docs/harness-compatibility.md +37 -3
- package/docs/learning/CURRICULUM-MAP.md +16 -5
- package/docs/learning/FEATURE-GUIDE.md +15 -4
- package/docs/learning/README.md +8 -2
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +11 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
- package/docs/learning/lessons/L21-context-packs.md +79 -0
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +138 -0
- package/docs/learning/tracks/operator.md +10 -3
- package/docs/learning/tracks/quickstart.md +7 -3
- package/docs/mcp-tool-profiles.md +43 -0
- package/docs/native-windows.md +122 -0
- package/docs/operating.md +157 -18
- package/docs/privacy-and-data-flow.md +51 -1
- package/docs/storage.md +29 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
- package/mcp-memory-server/dist/artifact-store.js +196 -55
- package/mcp-memory-server/dist/capability-cli.js +2 -1
- package/mcp-memory-server/dist/capability-config.js +4 -3
- package/mcp-memory-server/dist/capability-harness.js +34 -7
- package/mcp-memory-server/dist/context-pack-cli.js +185 -0
- package/mcp-memory-server/dist/context-pack.js +889 -0
- package/mcp-memory-server/dist/eval-cli.js +6 -5
- package/mcp-memory-server/dist/eval-plan.js +35 -13
- package/mcp-memory-server/dist/eval-process.js +11 -4
- package/mcp-memory-server/dist/eval-report.js +8 -5
- package/mcp-memory-server/dist/eval-runner.js +2 -0
- package/mcp-memory-server/dist/eval-schema.js +1 -0
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +87 -37
- package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
- package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
- package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
- package/mcp-memory-server/dist/note-store.js +12 -3
- package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
- package/mcp-memory-server/dist/platform-security.js +102 -0
- package/mcp-memory-server/dist/skill-store.js +27 -12
- package/mcp-memory-server/package.json +4 -1
- package/package.json +5 -4
- package/pi/extensions/cairnkeep-memory.ts +61 -0
- package/schemas/cairnkeep-setup-policy.schema.json +55 -0
- package/schemas/cairnkeep-setup.schema.json +36 -0
- package/schemas/context-pack.schema.json +33 -0
- package/schemas/eval-report.schema.json +1 -0
- package/scripts/cairn-cli.mjs +119 -0
- package/scripts/cairn-container-cli.mjs +121 -0
- package/scripts/cairn-hook.mjs +199 -0
- package/scripts/completion.sh +33 -5
- package/scripts/doctor.sh +96 -0
- package/scripts/fix-permissions.mjs +12 -0
- package/scripts/memory-wiki-audit.mjs +95 -0
- package/scripts/phase26-test-manifest.mjs +26 -0
- package/scripts/run-repository-tests.mjs +76 -0
- package/scripts/setup-core.mjs +310 -0
- package/scripts/setup-reconcile.mjs +331 -0
- package/scripts/setup.mjs +306 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-cli-dispatch.sh +6 -0
- package/scripts/test-completion.sh +44 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-learning-docs.sh +53 -3
- package/scripts/test-package-install.sh +81 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +186 -0
- package/scripts/test-setup-output.sh +131 -0
- package/scripts/test-setup-overlay.sh +113 -0
- package/scripts/test-setup-preflight.mjs +281 -0
- package/scripts/test-setup-reconcile.mjs +257 -0
- package/scripts/test-uninstall.sh +71 -0
- package/scripts/test-windows-native.mjs +136 -0
- package/scripts/uninstall.sh +95 -7
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- package/scripts/windows-platform.mjs +725 -0
- package/templates/env.example.template +11 -0
|
@@ -86,6 +86,23 @@ do
|
|
|
86
86
|
[[ -f "$installed_root/$required" ]] || fail "npm tarball omitted $required"
|
|
87
87
|
done
|
|
88
88
|
|
|
89
|
+
for required in \
|
|
90
|
+
schemas/context-pack.schema.json \
|
|
91
|
+
mcp-memory-server/dist/mcp-tool-catalog.js \
|
|
92
|
+
mcp-memory-server/dist/mcp-tool-profile.js \
|
|
93
|
+
mcp-memory-server/dist/mcp-tool-cli.js \
|
|
94
|
+
mcp-memory-server/dist/context-pack.js \
|
|
95
|
+
mcp-memory-server/dist/context-pack-cli.js \
|
|
96
|
+
docs/mcp-tool-profiles.md \
|
|
97
|
+
docs/context-packs.md
|
|
98
|
+
do
|
|
99
|
+
[[ -f "$installed_root/$required" ]] || fail "npm tarball omitted $required"
|
|
100
|
+
done
|
|
101
|
+
CAIRN_PACK_BASE_DIR="$tmp/installed-packs" cairn pack list --json >"$tmp/installed-pack-list.json" || \
|
|
102
|
+
fail "installed package pack CLI failed"
|
|
103
|
+
cairn mcp-tools list --json >"$tmp/installed-mcp-tools.json" || \
|
|
104
|
+
fail "installed package mcp-tools CLI failed"
|
|
105
|
+
|
|
89
106
|
for required in \
|
|
90
107
|
schemas/skill-adapter.schema.json \
|
|
91
108
|
schemas/skill-proposal-protocol.schema.json \
|
|
@@ -314,4 +331,68 @@ fi
|
|
|
314
331
|
[[ "$(sha256sum "$ROOT/package-lock.json" | cut -d' ' -f1)" == "$root_lock_before" ]] || fail "root lockfile changed during pack/install"
|
|
315
332
|
[[ "$(sha256sum "$ROOT/mcp-memory-server/package-lock.json" | cut -d' ' -f1)" == "$server_lock_before" ]] || fail "server lockfile changed during pack/install"
|
|
316
333
|
|
|
334
|
+
phase26_surface_complete=true
|
|
335
|
+
for required in \
|
|
336
|
+
scripts/setup.mjs \
|
|
337
|
+
scripts/setup-core.mjs \
|
|
338
|
+
scripts/setup-reconcile.mjs \
|
|
339
|
+
schemas/cairnkeep-setup.schema.json \
|
|
340
|
+
schemas/cairnkeep-setup-policy.schema.json \
|
|
341
|
+
mcp-memory-server/dist/pi-mcp-bridge.js \
|
|
342
|
+
pi/extensions/cairnkeep-memory.ts \
|
|
343
|
+
docs/operating.md \
|
|
344
|
+
docs/harness-compatibility.md \
|
|
345
|
+
docs/native-windows.md \
|
|
346
|
+
docs/building-an-overlay.md
|
|
347
|
+
do
|
|
348
|
+
[[ -f "$ROOT/$required" ]] || phase26_surface_complete=false
|
|
349
|
+
done
|
|
350
|
+
|
|
351
|
+
if [[ "$phase26_surface_complete" != true ]]; then
|
|
352
|
+
if [[ "${CAIRN_PHASE26_RED:-0}" == 1 ]]; then
|
|
353
|
+
echo "PHASE26_RED:SETUP_PACKAGE_MISSING"
|
|
354
|
+
exit 86
|
|
355
|
+
fi
|
|
356
|
+
echo "SKIP: packaged guided setup and Pi bridge surface is not complete"
|
|
357
|
+
echo "PASS: npm tarball installs a self-contained CLI and MCP server"
|
|
358
|
+
exit 0
|
|
359
|
+
fi
|
|
360
|
+
|
|
361
|
+
for required in \
|
|
362
|
+
scripts/setup.mjs \
|
|
363
|
+
scripts/setup-core.mjs \
|
|
364
|
+
scripts/setup-reconcile.mjs \
|
|
365
|
+
schemas/cairnkeep-setup.schema.json \
|
|
366
|
+
schemas/cairnkeep-setup-policy.schema.json \
|
|
367
|
+
mcp-memory-server/dist/pi-mcp-bridge.js \
|
|
368
|
+
pi/extensions/cairnkeep-memory.ts \
|
|
369
|
+
docs/operating.md \
|
|
370
|
+
docs/harness-compatibility.md \
|
|
371
|
+
docs/native-windows.md \
|
|
372
|
+
docs/building-an-overlay.md
|
|
373
|
+
do
|
|
374
|
+
[[ -f "$installed_root/$required" ]] || fail "npm tarball omitted Phase 26 asset $required"
|
|
375
|
+
done
|
|
376
|
+
|
|
377
|
+
installed_setup_target="$tmp/Installed Setup – Unicode"
|
|
378
|
+
(cd "$tmp" && cairn setup "$installed_setup_target" \
|
|
379
|
+
--git none --harness claude,pi --memory local --yes --json) >"$tmp/installed-setup.json" \
|
|
380
|
+
|| fail "installed cairn setup failed outside the source checkout"
|
|
381
|
+
node - "$tmp/installed-setup.json" "$installed_setup_target" <<'NODE'
|
|
382
|
+
const fs = require("fs");
|
|
383
|
+
const [resultPath, target] = process.argv.slice(2);
|
|
384
|
+
const result = JSON.parse(fs.readFileSync(resultPath, "utf8"));
|
|
385
|
+
if (result.schema_version !== 1 || result.status !== "limited") process.exit(1);
|
|
386
|
+
if (result.git !== "none" || result.memory !== "local") process.exit(1);
|
|
387
|
+
if (JSON.stringify(result.harnesses) !== JSON.stringify(["claude", "pi"])) process.exit(1);
|
|
388
|
+
if (!fs.existsSync(`${target}/.ai/cairnkeep.json`)) process.exit(1);
|
|
389
|
+
NODE
|
|
390
|
+
|
|
391
|
+
if find "$installed_root" -type f \( \
|
|
392
|
+
-name '.env' -o -name '*.tmp' -o -name '*.bak' -o -name 'cairnkeep.json' \
|
|
393
|
+
-o -path '*/.planning/*' -o -path '*/.agentfs/*' \
|
|
394
|
+
\) | grep -q .; then
|
|
395
|
+
fail "npm tarball included temporary or project-private setup state"
|
|
396
|
+
fi
|
|
397
|
+
|
|
317
398
|
echo "PASS: npm tarball installs a self-contained CLI and MCP server"
|
|
@@ -48,7 +48,7 @@ run_shell_syntax_baseline() {
|
|
|
48
48
|
local script
|
|
49
49
|
while IFS= read -r script; do
|
|
50
50
|
bash -n "$script" || return 1
|
|
51
|
-
done < <(find "$ROOT/bin" "$ROOT/scripts" -type f
|
|
51
|
+
done < <(find "$ROOT/bin" "$ROOT/scripts" -type f -name '*.sh' | sort)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
run_baseline() {
|
|
@@ -142,7 +142,7 @@ run_baseline() {
|
|
|
142
142
|
run_baseline_check "$ROOT/scripts/test-completion.sh"
|
|
143
143
|
run_baseline_check "$ROOT/scripts/test-package-install.sh"
|
|
144
144
|
run_baseline_check "$ROOT/scripts/test-uninstall.sh"
|
|
145
|
-
run_baseline_check
|
|
145
|
+
run_baseline_check node --check "$ROOT/bin/cairn"
|
|
146
146
|
run_master_off_identity
|
|
147
147
|
echo "PASS: Phase 18 master-off CLI/package/lifecycle identity baseline"
|
|
148
148
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { runWindowsCommand } from "./windows-platform.mjs";
|
|
10
|
+
|
|
11
|
+
const EXPECTED_RED_EXIT = 86;
|
|
12
|
+
const RED_MARKER = "PHASE26_RED:PI_LIFECYCLE_MISSING";
|
|
13
|
+
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
14
|
+
|
|
15
|
+
function run(command, args, options = {}) {
|
|
16
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout: 20_000, ...options });
|
|
17
|
+
if (result.error) throw result.error;
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertFixtureContract(sandbox) {
|
|
22
|
+
const project = join(sandbox, "Project with spaces – Unicode");
|
|
23
|
+
const piLive = join(sandbox, "Pi Agent");
|
|
24
|
+
mkdirSync(project, { recursive: true });
|
|
25
|
+
mkdirSync(join(piLive, "extensions"), { recursive: true });
|
|
26
|
+
const setupState = {
|
|
27
|
+
schema_version: 1,
|
|
28
|
+
cairnkeep_version: "fixture-1",
|
|
29
|
+
git: "none",
|
|
30
|
+
memory: "local",
|
|
31
|
+
harnesses: ["pi"],
|
|
32
|
+
assets: {},
|
|
33
|
+
};
|
|
34
|
+
assert.equal(setupState.harnesses.includes("pi"), true);
|
|
35
|
+
assert.equal(setupState.harnesses.includes("claude"), false);
|
|
36
|
+
assert.equal(existsSync(join(piLive, "extensions", "cairnkeep-memory.ts")), false, "project setup fixture auto-synced machine state");
|
|
37
|
+
writeFileSync(join(piLive, "extensions", "operator-extension.ts"), "operator bytes\n");
|
|
38
|
+
return { project, piLive };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function productionComplete() {
|
|
42
|
+
const required = [
|
|
43
|
+
"pi/extensions/cairnkeep-memory.ts",
|
|
44
|
+
"mcp-memory-server/dist/pi-mcp-bridge.js",
|
|
45
|
+
"scripts/setup.mjs",
|
|
46
|
+
"scripts/sync-pi-assets.sh",
|
|
47
|
+
"scripts/uninstall.sh",
|
|
48
|
+
"schemas/cairnkeep-setup.schema.json",
|
|
49
|
+
];
|
|
50
|
+
if (!required.every((path) => existsSync(join(root, path)))) return false;
|
|
51
|
+
const sync = readFileSync(join(root, "scripts", "sync-pi-assets.sh"), "utf8");
|
|
52
|
+
const uninstall = readFileSync(join(root, "scripts", "uninstall.sh"), "utf8");
|
|
53
|
+
const windows = readFileSync(join(root, "scripts", "windows-platform.mjs"), "utf8");
|
|
54
|
+
return [sync, uninstall, windows].every((text) => text.includes("extensions/cairnkeep-memory.ts"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function exerciseSelectedSetup(sandbox, fixture) {
|
|
58
|
+
const env = { ...process.env, HOME: join(sandbox, "setup-home") };
|
|
59
|
+
mkdirSync(env.HOME, { recursive: true });
|
|
60
|
+
if (process.platform !== "win32") {
|
|
61
|
+
const result = run(join(root, "bin", "cairn"), [
|
|
62
|
+
"setup", fixture.project,
|
|
63
|
+
"--git", "none",
|
|
64
|
+
"--harness", "pi",
|
|
65
|
+
"--memory", "local",
|
|
66
|
+
"--yes",
|
|
67
|
+
"--json",
|
|
68
|
+
], { env });
|
|
69
|
+
assert.equal(result.status, 0, result.stderr);
|
|
70
|
+
assert.deepEqual(JSON.parse(result.stdout).harnesses, ["pi"]);
|
|
71
|
+
assert.equal(existsSync(join(fixture.project, ".ai", "start-pi.sh")), true);
|
|
72
|
+
assert.equal(existsSync(join(fixture.project, ".ai", "start-claude.sh")), false);
|
|
73
|
+
}
|
|
74
|
+
assert.equal(existsSync(join(fixture.piLive, "extensions", "cairnkeep-memory.ts")), false, "setup auto-synced Pi machine assets");
|
|
75
|
+
|
|
76
|
+
const windowsProject = join(sandbox, "Windows selected project – Unicode");
|
|
77
|
+
let output = "";
|
|
78
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
79
|
+
process.stdout.write = ((chunk) => {
|
|
80
|
+
output += String(chunk);
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const handled = await runWindowsCommand({
|
|
85
|
+
command: "setup",
|
|
86
|
+
args: [windowsProject, "--git", "none", "--harness", "pi", "--memory", "local", "--yes", "--json"],
|
|
87
|
+
root,
|
|
88
|
+
});
|
|
89
|
+
assert.equal(handled, true);
|
|
90
|
+
} finally {
|
|
91
|
+
process.stdout.write = originalWrite;
|
|
92
|
+
}
|
|
93
|
+
assert.deepEqual(JSON.parse(output).harnesses, ["pi"]);
|
|
94
|
+
assert.equal(existsSync(join(windowsProject, ".ai", "start-pi.cmd")), true);
|
|
95
|
+
assert.equal(existsSync(join(windowsProject, ".ai", "start-claude.cmd")), false);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function exerciseWindowsLifecycle(sandbox) {
|
|
99
|
+
const live = join(sandbox, "Windows Pi Agent");
|
|
100
|
+
mkdirSync(join(live, "extensions"), { recursive: true });
|
|
101
|
+
writeFileSync(join(live, "extensions", "operator-extension.ts"), "operator bytes\n");
|
|
102
|
+
process.exitCode = 0;
|
|
103
|
+
await runWindowsCommand({ command: "sync-pi", args: ["--apply", "--live-root", live], root });
|
|
104
|
+
assert.equal(process.exitCode, 0);
|
|
105
|
+
for (const path of [
|
|
106
|
+
"extensions/cairnkeep-memory.ts",
|
|
107
|
+
"extensions/cairnkeep-trajectory.ts",
|
|
108
|
+
"prompts/graphify.md",
|
|
109
|
+
]) assert.equal(existsSync(join(live, ...path.split("/"))), true, `Windows sync omitted ${path}`);
|
|
110
|
+
assert.equal(readFileSync(join(live, "extensions", "operator-extension.ts"), "utf8"), "operator bytes\n");
|
|
111
|
+
|
|
112
|
+
await runWindowsCommand({ command: "sync-pi", args: ["--check", "--live-root", live], root });
|
|
113
|
+
assert.equal(process.exitCode, 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function exercisePosixLifecycle(sandbox, fixture) {
|
|
117
|
+
const env = { ...process.env, HOME: join(sandbox, "home"), PI_CODING_AGENT_DIR: fixture.piLive };
|
|
118
|
+
mkdirSync(env.HOME, { recursive: true });
|
|
119
|
+
let result = run(join(root, "scripts", "sync-pi-assets.sh"), ["--apply", "--live-root", fixture.piLive], { env });
|
|
120
|
+
assert.equal(result.status, 0, result.stderr);
|
|
121
|
+
for (const path of [
|
|
122
|
+
"extensions/cairnkeep-memory.ts",
|
|
123
|
+
"extensions/cairnkeep-trajectory.ts",
|
|
124
|
+
"prompts/graphify.md",
|
|
125
|
+
]) assert.equal(existsSync(join(fixture.piLive, ...path.split("/"))), true, `POSIX sync omitted ${path}`);
|
|
126
|
+
assert.equal(readFileSync(join(fixture.piLive, "extensions", "operator-extension.ts"), "utf8"), "operator bytes\n");
|
|
127
|
+
|
|
128
|
+
const memoryExtension = join(fixture.piLive, "extensions", "cairnkeep-memory.ts");
|
|
129
|
+
rmSync(memoryExtension);
|
|
130
|
+
result = run(join(root, "scripts", "sync-pi-assets.sh"), ["--check", "--live-root", fixture.piLive], { env });
|
|
131
|
+
assert.notEqual(result.status, 0);
|
|
132
|
+
assert.match(result.stderr, /Missing live Pi asset.*cairnkeep-memory\.ts/);
|
|
133
|
+
assert.match(`${result.stdout}${result.stderr}`, /sync-pi-assets\.sh --apply|--apply/);
|
|
134
|
+
|
|
135
|
+
result = run(join(root, "scripts", "sync-pi-assets.sh"), ["--apply", "--live-root", fixture.piLive], { env });
|
|
136
|
+
assert.equal(result.status, 0, result.stderr);
|
|
137
|
+
writeFileSync(memoryExtension, `${readFileSync(memoryExtension, "utf8")}\n// operator-modified fixture\n`);
|
|
138
|
+
const modifiedBytes = readFileSync(memoryExtension);
|
|
139
|
+
|
|
140
|
+
const store = join(env.HOME, ".cairnkeep");
|
|
141
|
+
const packs = join(store, "packs", "objects", "fixture");
|
|
142
|
+
mkdirSync(join(store, "memory"), { recursive: true });
|
|
143
|
+
mkdirSync(packs, { recursive: true });
|
|
144
|
+
writeFileSync(join(store, "memory", "record"), "memory bytes\n");
|
|
145
|
+
writeFileSync(join(packs, "record"), "pack bytes\n");
|
|
146
|
+
result = run(join(root, "scripts", "uninstall.sh"), ["--yes", "--live-root", join(sandbox, "unused-live"), "--pi-live-root", fixture.piLive], {
|
|
147
|
+
env: { ...env, CAIRN_AGENTFS_BASE_DIR: store, CAIRN_PACK_BASE_DIR: join(store, "packs") },
|
|
148
|
+
});
|
|
149
|
+
assert.equal(result.status, 0, result.stderr);
|
|
150
|
+
assert.equal(existsSync(memoryExtension), false);
|
|
151
|
+
assert.equal(readFileSync(join(fixture.piLive, "extensions", "operator-extension.ts"), "utf8"), "operator bytes\n");
|
|
152
|
+
assert.equal(readFileSync(join(store, "memory", "record"), "utf8"), "memory bytes\n");
|
|
153
|
+
assert.equal(readFileSync(join(packs, "record"), "utf8"), "pack bytes\n");
|
|
154
|
+
|
|
155
|
+
const backupName = readdirSync(env.HOME)
|
|
156
|
+
.filter((name) => name.startsWith(".cairnkeep-uninstall-"))
|
|
157
|
+
.sort()
|
|
158
|
+
.at(-1);
|
|
159
|
+
assert.equal(typeof backupName, "string");
|
|
160
|
+
const backup = join(env.HOME, backupName);
|
|
161
|
+
const manifest = readFileSync(join(backup, "manifest.tsv"), "utf8");
|
|
162
|
+
assert.equal(manifest.includes(memoryExtension), true);
|
|
163
|
+
assert.equal(existsSync(join(backup, "revert.sh")), true);
|
|
164
|
+
result = run("bash", [join(backup, "revert.sh")], { env });
|
|
165
|
+
assert.equal(result.status, 0, result.stderr);
|
|
166
|
+
assert.deepEqual(readFileSync(memoryExtension), modifiedBytes);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function main() {
|
|
170
|
+
const sandbox = mkdtempSync(join(tmpdir(), "cairn-pi-lifecycle-"));
|
|
171
|
+
const originalExitCode = process.exitCode;
|
|
172
|
+
try {
|
|
173
|
+
const fixture = assertFixtureContract(sandbox);
|
|
174
|
+
if (!productionComplete()) {
|
|
175
|
+
console.log(RED_MARKER);
|
|
176
|
+
process.exitCode = EXPECTED_RED_EXIT;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
await exerciseSelectedSetup(sandbox, fixture);
|
|
180
|
+
await exerciseWindowsLifecycle(sandbox);
|
|
181
|
+
if (process.platform !== "win32") exercisePosixLifecycle(sandbox, fixture);
|
|
182
|
+
assert.equal(existsSync(join(fixture.piLive, "extensions", "operator-extension.ts")), true);
|
|
183
|
+
console.log("PASS: Pi memory-extension sync, drift, backup, revert, and preservation lifecycle contract");
|
|
184
|
+
} finally {
|
|
185
|
+
if (process.exitCode !== EXPECTED_RED_EXIT) process.exitCode = originalExitCode;
|
|
186
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await main();
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import {
|
|
6
|
+
chmodSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
import { bootstrapWindows } from "./windows-platform.mjs";
|
|
21
|
+
|
|
22
|
+
const EXPECTED_RED_EXIT = 86;
|
|
23
|
+
const RED_MARKER = "PHASE26_RED:SETUP_RECONCILE_MISSING";
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const root = resolve(here, "..");
|
|
26
|
+
const setupCorePath = join(here, "setup-core.mjs");
|
|
27
|
+
const setupReconcilePath = join(here, "setup-reconcile.mjs");
|
|
28
|
+
const HARNESSES = ["claude", "opencode", "pi", "kimi", "qwen"];
|
|
29
|
+
const MANAGED_ROOTS = [".ai", ".planning", ".agentfs"];
|
|
30
|
+
|
|
31
|
+
function run(executable, args, options = {}) {
|
|
32
|
+
const result = spawnSync(executable, args, {
|
|
33
|
+
cwd: options.cwd ?? root,
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
env: { ...process.env, ...options.env },
|
|
36
|
+
shell: false,
|
|
37
|
+
});
|
|
38
|
+
assert.equal(result.status, options.status ?? 0, `${executable} ${args.join(" ")} failed:\n${result.stdout}${result.stderr}`);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function walkFiles(base, prefix = "") {
|
|
43
|
+
if (!existsSync(base)) return [];
|
|
44
|
+
const files = [];
|
|
45
|
+
for (const entry of readdirSync(base, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
46
|
+
const next = prefix ? join(prefix, entry.name) : entry.name;
|
|
47
|
+
if (entry.isDirectory()) files.push(...walkFiles(join(base, entry.name), next));
|
|
48
|
+
else if (entry.isFile()) files.push(next.replaceAll("\\", "/"));
|
|
49
|
+
}
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function snapshot(target) {
|
|
54
|
+
const result = new Map();
|
|
55
|
+
for (const managed of MANAGED_ROOTS) {
|
|
56
|
+
for (const path of walkFiles(join(target, managed), managed)) {
|
|
57
|
+
const absolute = join(target, ...path.split("/"));
|
|
58
|
+
result.set(path, {
|
|
59
|
+
bytes: readFileSync(absolute),
|
|
60
|
+
executable: (statSync(absolute).mode & 0o111) !== 0,
|
|
61
|
+
private: (statSync(absolute).mode & 0o077) === 0,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertSnapshotEqual(actual, expected, label, allowedAdditions = []) {
|
|
69
|
+
const unexpected = [...actual.keys()].filter((path) => !expected.has(path) && !allowedAdditions.includes(path));
|
|
70
|
+
assert.deepEqual(unexpected, [], `${label} added unexpected managed paths`);
|
|
71
|
+
for (const [path, before] of expected) {
|
|
72
|
+
assert.ok(actual.has(path), `${label} omitted ${path}`);
|
|
73
|
+
const after = actual.get(path);
|
|
74
|
+
assert.deepEqual(after.bytes, before.bytes, `${label} changed bytes for ${path}`);
|
|
75
|
+
assert.equal(after.executable, before.executable, `${label} changed executable semantics for ${path}`);
|
|
76
|
+
if (path === ".ai/capabilities.json") assert.equal(after.private, true, `${label} weakened private mode for ${path}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateLegacyOracles(sandbox) {
|
|
81
|
+
const posix = join(sandbox, "posix");
|
|
82
|
+
const windows = join(sandbox, "windows");
|
|
83
|
+
mkdirSync(posix);
|
|
84
|
+
mkdirSync(windows);
|
|
85
|
+
|
|
86
|
+
const first = run("bash", [join(here, "bootstrap.sh"), posix]);
|
|
87
|
+
assert.equal(first.stderr, "");
|
|
88
|
+
assert.match(first.stdout, /Cairnkeep bootstrapped into/);
|
|
89
|
+
for (const harness of HARNESSES) assert.ok(existsSync(join(posix, ".ai", `start-${harness}.sh`)), `${harness} POSIX launcher missing`);
|
|
90
|
+
const posixBefore = snapshot(posix);
|
|
91
|
+
writeFileSync(join(posix, ".ai", "env.example"), "operator-owned\n");
|
|
92
|
+
chmodSync(join(posix, ".ai", "env.example"), 0o640);
|
|
93
|
+
const operatorBytes = readFileSync(join(posix, ".ai", "env.example"));
|
|
94
|
+
const rerun = run("bash", [join(here, "bootstrap.sh"), posix]);
|
|
95
|
+
assert.match(rerun.stdout, /skip \(exists\): \.ai\/env\.example/);
|
|
96
|
+
assert.deepEqual(readFileSync(join(posix, ".ai", "env.example")), operatorBytes);
|
|
97
|
+
|
|
98
|
+
bootstrapWindows(root, [windows]);
|
|
99
|
+
const windowsSnapshot = snapshot(windows);
|
|
100
|
+
for (const harness of HARNESSES) {
|
|
101
|
+
assert.ok(windowsSnapshot.has(`.ai/start-${harness}.sh`), `${harness} Windows shell launcher missing`);
|
|
102
|
+
assert.ok(windowsSnapshot.has(`.ai/start-${harness}.cmd`), `${harness} Windows cmd launcher missing`);
|
|
103
|
+
}
|
|
104
|
+
for (const [path, value] of posixBefore) {
|
|
105
|
+
assert.ok(windowsSnapshot.has(path), `Windows bootstrap omitted POSIX asset ${path}`);
|
|
106
|
+
assert.deepEqual(windowsSnapshot.get(path).bytes, value.bytes, `Windows bootstrap changed common asset ${path}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const plain = join(sandbox, "plain");
|
|
110
|
+
mkdirSync(plain);
|
|
111
|
+
const rejected = run("bash", [join(here, "bootstrap.sh"), "--untracked", plain], { status: 1 });
|
|
112
|
+
assert.match(rejected.stderr, /--untracked requires.*git repository/i);
|
|
113
|
+
for (const managed of MANAGED_ROOTS) assert.equal(existsSync(join(plain, managed)), false, `legacy --untracked failure created ${managed}`);
|
|
114
|
+
|
|
115
|
+
const repo = join(sandbox, "repo");
|
|
116
|
+
mkdirSync(repo);
|
|
117
|
+
run("git", ["init", "-q", repo]);
|
|
118
|
+
run("bash", [join(here, "bootstrap.sh"), "--untracked", repo]);
|
|
119
|
+
assert.equal(run("git", ["-C", repo, "status", "--porcelain"]).stdout, "");
|
|
120
|
+
return { posixBefore, windowsSnapshot };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function loadProduction() {
|
|
124
|
+
const core = await import(pathToFileURL(setupCorePath).href);
|
|
125
|
+
const reconcile = await import(pathToFileURL(setupReconcilePath).href);
|
|
126
|
+
return { core, reconcile };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function assertProductionExports({ core, reconcile }) {
|
|
130
|
+
assert.equal(typeof core.buildSetupPlan, "function");
|
|
131
|
+
assert.equal(typeof reconcile.reconcileSetupPlan, "function");
|
|
132
|
+
assert.equal(typeof reconcile.writeSetupStateAtomic, "function");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function testSetupCompatibility(production, sandbox, legacy) {
|
|
136
|
+
const target = join(sandbox, "setup");
|
|
137
|
+
mkdirSync(target);
|
|
138
|
+
const plan = production.core.buildSetupPlan({
|
|
139
|
+
target,
|
|
140
|
+
preflight: { targetState: "empty", gitExecutable: "available", repository: "work-tree" },
|
|
141
|
+
choices: Object.freeze({ git: "existing", harnesses: HARNESSES, memory: "local", confirmed: true }),
|
|
142
|
+
});
|
|
143
|
+
const result = await production.reconcile.reconcileSetupPlan(plan);
|
|
144
|
+
assert.deepEqual(result.counts, { created: legacy.posixBefore.size, updated: 0, unchanged: 0, skipped: 0 });
|
|
145
|
+
const current = snapshot(target);
|
|
146
|
+
assertSnapshotEqual(current, legacy.posixBefore, "deterministic setup", [".ai/cairnkeep.json"]);
|
|
147
|
+
assert.ok(current.has(".ai/cairnkeep.json"), "setup state missing");
|
|
148
|
+
const state = JSON.parse(current.get(".ai/cairnkeep.json").bytes.toString("utf8"));
|
|
149
|
+
assert.deepEqual(state.harnesses, HARNESSES);
|
|
150
|
+
assert.equal(state.git, "existing");
|
|
151
|
+
assert.equal(state.memory, "local");
|
|
152
|
+
assert.doesNotMatch(JSON.stringify(state), new RegExp(sandbox.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
153
|
+
|
|
154
|
+
const beforeMtimes = new Map([...legacy.posixBefore.keys()].map((path) => [path, statSync(join(target, ...path.split("/"))).mtimeMs]));
|
|
155
|
+
const rerun = await production.reconcile.reconcileSetupPlan(plan);
|
|
156
|
+
assert.equal(rerun.counts.created, 0);
|
|
157
|
+
assert.equal(rerun.counts.updated, 0);
|
|
158
|
+
assert.equal(rerun.counts.skipped, 0);
|
|
159
|
+
for (const [path, mtime] of beforeMtimes) assert.equal(statSync(join(target, ...path.split("/"))).mtimeMs, mtime, `rerun touched ${path}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function main() {
|
|
163
|
+
const sandbox = mkdtempSync(join(tmpdir(), "cairn-setup-compatibility-"));
|
|
164
|
+
try {
|
|
165
|
+
const legacy = validateLegacyOracles(sandbox);
|
|
166
|
+
let production;
|
|
167
|
+
try {
|
|
168
|
+
production = await loadProduction();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const message = String(error?.message ?? "");
|
|
171
|
+
if (error?.code === "ERR_MODULE_NOT_FOUND" && (message.includes("setup-core.mjs") || message.includes("setup-reconcile.mjs"))) {
|
|
172
|
+
console.log(RED_MARKER);
|
|
173
|
+
process.exitCode = EXPECTED_RED_EXIT;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
assertProductionExports(production);
|
|
179
|
+
await testSetupCompatibility(production, sandbox, legacy);
|
|
180
|
+
console.log("PASS: legacy POSIX/Windows bootstrap and deterministic setup compatibility contract");
|
|
181
|
+
} finally {
|
|
182
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await main();
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
5
|
+
EXPECTED_RED_EXIT=86
|
|
6
|
+
RED_MARKER="PHASE26_RED:SETUP_OUTPUT_MISSING"
|
|
7
|
+
MANAGED_PATHS=(.ai .planning .agentfs)
|
|
8
|
+
|
|
9
|
+
fail() { echo "FAIL: $1" >&2; exit 1; }
|
|
10
|
+
|
|
11
|
+
tmp=$(mktemp -d)
|
|
12
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
13
|
+
|
|
14
|
+
assert_no_managed_paths() {
|
|
15
|
+
local target=$1 label=$2 path
|
|
16
|
+
for path in "${MANAGED_PATHS[@]}"; do
|
|
17
|
+
[[ ! -e "$target/$path" ]] || fail "$label created $path"
|
|
18
|
+
done
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Baseline and fixture checks always run, including while this is an intentional
|
|
22
|
+
# RED contract. This keeps routine test discovery useful before setup ships.
|
|
23
|
+
"$ROOT/bin/cairn" help >/dev/null || fail "existing help dispatch failed"
|
|
24
|
+
mkdir -p "$tmp/complete target" "$tmp/limited target" "$tmp/invalid target"
|
|
25
|
+
printf 'operator\n' >"$tmp/invalid target/operator.txt"
|
|
26
|
+
assert_no_managed_paths "$tmp/complete target" "complete fixture"
|
|
27
|
+
assert_no_managed_paths "$tmp/limited target" "limited fixture"
|
|
28
|
+
assert_no_managed_paths "$tmp/invalid target" "invalid fixture"
|
|
29
|
+
|
|
30
|
+
setup_surface_complete=true
|
|
31
|
+
for required in \
|
|
32
|
+
"$ROOT/scripts/setup.mjs" \
|
|
33
|
+
"$ROOT/scripts/setup-core.mjs" \
|
|
34
|
+
"$ROOT/scripts/setup-reconcile.mjs" \
|
|
35
|
+
"$ROOT/schemas/cairnkeep-setup.schema.json" \
|
|
36
|
+
"$ROOT/schemas/cairnkeep-setup-policy.schema.json"; do
|
|
37
|
+
[[ -f "$required" ]] || setup_surface_complete=false
|
|
38
|
+
done
|
|
39
|
+
|
|
40
|
+
if [[ "$setup_surface_complete" != true ]]; then
|
|
41
|
+
if [[ "${CAIRN_PHASE26_RED:-0}" == 1 ]]; then
|
|
42
|
+
echo "$RED_MARKER"
|
|
43
|
+
exit "$EXPECTED_RED_EXIT"
|
|
44
|
+
fi
|
|
45
|
+
echo "SKIP: guided setup output production surface is not complete"
|
|
46
|
+
exit 0
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
complete_json="$tmp/complete.json"
|
|
50
|
+
"$ROOT/bin/cairn" setup "$tmp/complete target" \
|
|
51
|
+
--git init --harness claude,pi --memory local --yes --json >"$complete_json"
|
|
52
|
+
node --input-type=module - "$complete_json" <<'NODE'
|
|
53
|
+
import assert from "node:assert/strict";
|
|
54
|
+
import fs from "node:fs";
|
|
55
|
+
|
|
56
|
+
const result = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
57
|
+
assert.equal(result.schema_version, 1);
|
|
58
|
+
assert.equal(result.status, "complete");
|
|
59
|
+
assert.equal(result.git, "init");
|
|
60
|
+
assert.equal(result.memory, "local");
|
|
61
|
+
assert.deepEqual(result.harnesses, ["claude", "pi"]);
|
|
62
|
+
assert.deepEqual(Object.keys(result.counts).sort(), ["created", "skipped", "unchanged", "updated"]);
|
|
63
|
+
for (const value of Object.values(result.counts)) assert.equal(Number.isInteger(value) && value >= 0, true);
|
|
64
|
+
assert.equal(Array.isArray(result.verification) && result.verification.length > 0, true);
|
|
65
|
+
assert.equal(Array.isArray(result.launch_commands) && result.launch_commands.length, 2);
|
|
66
|
+
assert.equal(result.launch_commands.some((line) => /start-claude\.sh/.test(line)), true);
|
|
67
|
+
assert.equal(result.launch_commands.some((line) => /start-pi\.sh/.test(line)), true);
|
|
68
|
+
assert.equal(Array.isArray(result.recovery), true);
|
|
69
|
+
assert.equal(result.machine_sync.automatic, false);
|
|
70
|
+
assert.match(result.machine_sync.command, /^cairn sync(?:-pi)? --(?:check|apply)/);
|
|
71
|
+
NODE
|
|
72
|
+
|
|
73
|
+
complete_human="$tmp/complete.txt"
|
|
74
|
+
"$ROOT/bin/cairn" setup "$tmp/complete target" \
|
|
75
|
+
--git init --harness claude,pi --memory local --yes >"$complete_human"
|
|
76
|
+
for expected in \
|
|
77
|
+
"Harnesses: claude, pi" \
|
|
78
|
+
"Git mode: init" \
|
|
79
|
+
"Memory mode: local" \
|
|
80
|
+
"Created:" \
|
|
81
|
+
"Updated:" \
|
|
82
|
+
"Unchanged:" \
|
|
83
|
+
"Skipped:" \
|
|
84
|
+
"Verification:" \
|
|
85
|
+
"Launch:" \
|
|
86
|
+
"Recovery:"; do
|
|
87
|
+
grep -qF "$expected" "$complete_human" || fail "human output missing $expected"
|
|
88
|
+
done
|
|
89
|
+
grep -qE 'cairn sync(-pi)? --(check|apply)' "$complete_human" || fail "human output omitted explicit machine sync direction"
|
|
90
|
+
|
|
91
|
+
limited_json="$tmp/limited.json"
|
|
92
|
+
"$ROOT/bin/cairn" setup "$tmp/limited target" \
|
|
93
|
+
--git none --harness kimi --memory none --yes --json >"$limited_json"
|
|
94
|
+
node --input-type=module - "$limited_json" <<'NODE'
|
|
95
|
+
import assert from "node:assert/strict";
|
|
96
|
+
import fs from "node:fs";
|
|
97
|
+
const result = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
98
|
+
assert.equal(result.schema_version, 1);
|
|
99
|
+
assert.equal(result.status, "limited");
|
|
100
|
+
assert.equal(result.git, "none");
|
|
101
|
+
assert.equal(result.memory, "none");
|
|
102
|
+
assert.deepEqual(result.harnesses, ["kimi"]);
|
|
103
|
+
assert.equal(Array.isArray(result.limitations) && result.limitations.length > 0, true);
|
|
104
|
+
assert.equal(result.limitations.some((line) => /git|repository/i.test(line)), true);
|
|
105
|
+
assert.equal(Array.isArray(result.recovery), true);
|
|
106
|
+
NODE
|
|
107
|
+
|
|
108
|
+
set +e
|
|
109
|
+
"$ROOT/bin/cairn" setup "$tmp/invalid target" --yes >"$tmp/incomplete.out" 2>"$tmp/incomplete.err"
|
|
110
|
+
incomplete_status=$?
|
|
111
|
+
"$ROOT/bin/cairn" setup "$tmp/invalid target" --git sometimes --harness claude --memory local --yes >"$tmp/invalid.out" 2>"$tmp/invalid.err"
|
|
112
|
+
invalid_status=$?
|
|
113
|
+
set -e
|
|
114
|
+
[[ "$incomplete_status" -eq 2 ]] || fail "incomplete non-TTY choices must exit 2"
|
|
115
|
+
[[ "$invalid_status" -eq 2 ]] || fail "invalid syntax must exit 2"
|
|
116
|
+
grep -Eqi 'usage|requires|missing' "$tmp/incomplete.err" || fail "incomplete choices lack actionable usage"
|
|
117
|
+
grep -Eqi 'git|invalid|usage' "$tmp/invalid.err" || fail "invalid Git mode lacks actionable usage"
|
|
118
|
+
assert_no_managed_paths "$tmp/invalid target" "usage failures"
|
|
119
|
+
|
|
120
|
+
mkdir -p "$tmp/no-git-bin"
|
|
121
|
+
ln -s "$(command -v node)" "$tmp/no-git-bin/node"
|
|
122
|
+
set +e
|
|
123
|
+
PATH="$tmp/no-git-bin" "$ROOT/bin/cairn" setup "$tmp/operational target" \
|
|
124
|
+
--git existing --harness claude --memory local --yes >"$tmp/operational.out" 2>"$tmp/operational.err"
|
|
125
|
+
operational_status=$?
|
|
126
|
+
set -e
|
|
127
|
+
[[ "$operational_status" -ne 0 && "$operational_status" -ne 2 ]] || fail "missing Git was collapsed into a usage error"
|
|
128
|
+
grep -qi 'git' "$tmp/operational.err" || fail "missing Git failure lacks recovery context"
|
|
129
|
+
assert_no_managed_paths "$tmp/operational target" "operational preflight failure"
|
|
130
|
+
|
|
131
|
+
echo "PASS: guided setup human, JSON, limited, usage, and recovery output contract"
|