@cairnkeep/cli 2.9.0 → 2.12.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/README.md +83 -29
  3. package/bin/cairn +6 -165
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +42 -5
  6. package/docs/containers.md +10 -0
  7. package/docs/harness-compatibility.md +64 -3
  8. package/docs/learning/CURRICULUM-MAP.md +13 -6
  9. package/docs/learning/FEATURE-GUIDE.md +14 -3
  10. package/docs/learning/README.md +5 -2
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +24 -8
  14. package/docs/learning/lessons/L03-first-project.md +34 -15
  15. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  16. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  17. package/docs/learning/lessons/L21-context-packs.md +1 -1
  18. package/docs/learning/lessons/L22-native-windows.md +83 -0
  19. package/docs/learning/lessons/L23-guided-setup.md +142 -0
  20. package/docs/learning/tracks/operator.md +8 -3
  21. package/docs/learning/tracks/quickstart.md +9 -4
  22. package/docs/native-windows.md +136 -0
  23. package/docs/operating.md +142 -20
  24. package/docs/privacy-and-data-flow.md +41 -1
  25. package/docs/quickstart.md +86 -0
  26. package/docs/storage.md +14 -2
  27. package/examples/eval/bundled-fake.json +1 -1
  28. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  29. package/mcp-memory-server/dist/artifact-store.js +196 -55
  30. package/mcp-memory-server/dist/capability-cli.js +2 -1
  31. package/mcp-memory-server/dist/capability-config.js +4 -3
  32. package/mcp-memory-server/dist/capability-harness.js +34 -7
  33. package/mcp-memory-server/dist/context-pack.js +84 -25
  34. package/mcp-memory-server/dist/eval-cli.js +6 -5
  35. package/mcp-memory-server/dist/eval-plan.js +35 -13
  36. package/mcp-memory-server/dist/eval-process.js +11 -4
  37. package/mcp-memory-server/dist/eval-report.js +7 -5
  38. package/mcp-memory-server/dist/graph-cli.js +7 -1
  39. package/mcp-memory-server/dist/index.js +19 -20
  40. package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
  41. package/mcp-memory-server/dist/note-store.js +12 -3
  42. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  43. package/mcp-memory-server/dist/platform-security.js +102 -0
  44. package/mcp-memory-server/dist/skill-store.js +27 -12
  45. package/mcp-memory-server/package.json +2 -1
  46. package/package.json +6 -4
  47. package/pi/extensions/cairnkeep-memory.ts +61 -0
  48. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  49. package/schemas/cairnkeep-setup.schema.json +36 -0
  50. package/scripts/bootstrap.sh +7 -15
  51. package/scripts/cairn-cli.mjs +119 -0
  52. package/scripts/cairn-container-cli.mjs +121 -0
  53. package/scripts/cairn-hook.mjs +199 -0
  54. package/scripts/completion.sh +17 -6
  55. package/scripts/doctor.sh +58 -0
  56. package/scripts/fix-permissions.mjs +12 -0
  57. package/scripts/harness-registry.mjs +126 -0
  58. package/scripts/memory-wiki-audit.mjs +95 -0
  59. package/scripts/phase26-test-manifest.mjs +26 -0
  60. package/scripts/run-repository-tests.mjs +76 -0
  61. package/scripts/setup-core.mjs +312 -0
  62. package/scripts/setup-reconcile.mjs +332 -0
  63. package/scripts/setup.mjs +342 -0
  64. package/scripts/sync-pi-assets.sh +19 -4
  65. package/scripts/test-bootstrap-untracked.sh +2 -1
  66. package/scripts/test-completion.sh +41 -0
  67. package/scripts/test-doctor.sh +66 -0
  68. package/scripts/test-harness-registry.sh +94 -0
  69. package/scripts/test-learning-docs.sh +52 -3
  70. package/scripts/test-package-install.sh +69 -0
  71. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  72. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  73. package/scripts/test-phase19-eval-lifecycle.sh +12 -2
  74. package/scripts/test-pi-lifecycle.mjs +190 -0
  75. package/scripts/test-setup-compatibility.mjs +187 -0
  76. package/scripts/test-setup-output.sh +131 -0
  77. package/scripts/test-setup-overlay.sh +113 -0
  78. package/scripts/test-setup-preflight.mjs +281 -0
  79. package/scripts/test-setup-reconcile.mjs +257 -0
  80. package/scripts/test-uninstall.sh +50 -0
  81. package/scripts/test-windows-native.mjs +159 -0
  82. package/scripts/uninstall.sh +73 -6
  83. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  84. package/scripts/windows-platform.mjs +733 -0
  85. package/templates/codex-config.toml.template +5 -0
  86. package/templates/start-codex.sh.template +30 -0
@@ -0,0 +1,126 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ /**
4
+ * Declarative registry for every CLI harness supported by project setup.
5
+ *
6
+ * Keep harness-specific project assets and lifecycle guidance here. Consumers
7
+ * must derive choices from this registry instead of maintaining local lists.
8
+ */
9
+ const DEFINITIONS = [
10
+ {
11
+ id: "claude",
12
+ title: "Claude Code",
13
+ launcher: { template: "start-claude.sh.template", path: ".ai/start-claude.sh", mode: 0o755 },
14
+ project_assets: [],
15
+ machine_sync: "cairn sync --apply",
16
+ },
17
+ {
18
+ id: "opencode",
19
+ title: "OpenCode",
20
+ launcher: { template: "start-opencode.sh.template", path: ".ai/start-opencode.sh", mode: 0o755 },
21
+ project_assets: [],
22
+ machine_sync: "cairn sync --apply",
23
+ },
24
+ {
25
+ id: "pi",
26
+ title: "Pi",
27
+ launcher: { template: "start-pi.sh.template", path: ".ai/start-pi.sh", mode: 0o755 },
28
+ project_assets: [],
29
+ machine_sync: "cairn sync-pi --apply",
30
+ },
31
+ {
32
+ id: "kimi",
33
+ title: "Kimi Code",
34
+ launcher: { template: "start-kimi.sh.template", path: ".ai/start-kimi.sh", mode: 0o755 },
35
+ project_assets: [],
36
+ machine_sync: "cairn sync-kimi --apply",
37
+ },
38
+ {
39
+ id: "qwen",
40
+ title: "Qwen Code",
41
+ launcher: { template: "start-qwen.sh.template", path: ".ai/start-qwen.sh", mode: 0o755 },
42
+ project_assets: [],
43
+ machine_sync: null,
44
+ },
45
+ {
46
+ id: "codex",
47
+ title: "Codex CLI",
48
+ launcher: { template: "start-codex.sh.template", path: ".ai/start-codex.sh", mode: 0o755 },
49
+ project_assets: [
50
+ {
51
+ template: "codex-config.toml.template",
52
+ path: ".codex/config.toml",
53
+ mode: 0o644,
54
+ memory: "local",
55
+ // An existing operator-owned TOML file is valid when doctor finds the
56
+ // exact MCP table; it must not become setup/uninstall-owned.
57
+ required: false,
58
+ },
59
+ ],
60
+ machine_sync: null,
61
+ },
62
+ ];
63
+
64
+ function freezeDefinition(definition) {
65
+ const launcher = Object.freeze({ ...definition.launcher });
66
+ const projectAssets = Object.freeze(definition.project_assets.map((asset) => Object.freeze({ ...asset })));
67
+ return Object.freeze({ ...definition, launcher, project_assets: projectAssets });
68
+ }
69
+
70
+ export const HARNESS_REGISTRY = Object.freeze(DEFINITIONS.map(freezeDefinition));
71
+ export const HARNESS_IDS = Object.freeze(HARNESS_REGISTRY.map(({ id }) => id));
72
+
73
+ export function harnessDefinition(id) {
74
+ return HARNESS_REGISTRY.find((definition) => definition.id === id) ?? null;
75
+ }
76
+
77
+ export function harnessProjectAssets(id, memory = "local") {
78
+ const definition = harnessDefinition(id);
79
+ if (!definition) throw new Error(`Unknown harness: ${id}.`);
80
+ return Object.freeze([
81
+ definition.launcher,
82
+ ...definition.project_assets.filter((asset) => !asset.memory || asset.memory === memory),
83
+ ]);
84
+ }
85
+
86
+ export function requiredHarnessAssetPaths(harnesses, memory = "local") {
87
+ return Object.freeze(harnesses.flatMap((id) => harnessProjectAssets(id, memory)
88
+ .filter((asset) => asset.required !== false)
89
+ .map(({ path }) => path)));
90
+ }
91
+
92
+ export function machineSyncCommand(harnesses) {
93
+ const commands = harnesses.map(harnessDefinition).map((definition) => definition?.machine_sync).filter(Boolean);
94
+ if (commands.includes("cairn sync-pi --apply")) return "cairn sync-pi --apply";
95
+ return commands[0] ?? null;
96
+ }
97
+
98
+ function validateRegistry() {
99
+ if (new Set(HARNESS_IDS).size !== HARNESS_IDS.length) throw new Error("Harness registry contains duplicate IDs.");
100
+ for (const definition of HARNESS_REGISTRY) {
101
+ if (!/^[a-z][a-z0-9-]*$/.test(definition.id) || !definition.title) throw new Error("Invalid harness registry entry.");
102
+ const assets = [definition.launcher, ...definition.project_assets];
103
+ if (new Set(assets.map(({ path }) => path)).size !== assets.length) throw new Error(`Duplicate project asset for ${definition.id}.`);
104
+ for (const asset of assets) {
105
+ if (!asset.template || !asset.path || ![0o600, 0o644, 0o755].includes(asset.mode)) throw new Error(`Invalid project asset for ${definition.id}.`);
106
+ }
107
+ }
108
+ }
109
+
110
+ validateRegistry();
111
+
112
+ // The legacy bootstrap is a shell script for Bash 3.2 portability. This bounded
113
+ // output lets it consume the same registry without parsing JavaScript or JSON.
114
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
115
+ if (!new Set(["bootstrap-assets", "ids"]).has(process.argv[2])) {
116
+ console.error("Usage: node harness-registry.mjs bootstrap-assets|ids");
117
+ process.exitCode = 2;
118
+ } else if (process.argv[2] === "ids") {
119
+ process.stdout.write(`${HARNESS_IDS.join(" ")}\n`);
120
+ } else {
121
+ for (const definition of HARNESS_REGISTRY) {
122
+ const asset = definition.launcher;
123
+ process.stdout.write(`${asset.template}\t${asset.path}\t${asset.mode.toString(8)}\n`);
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
+
11
+ function option(name, fallback) {
12
+ const index = process.argv.indexOf(name);
13
+ return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
14
+ }
15
+
16
+ const para = resolve(option("--para-root", join(homedir(), "PARA")));
17
+ const staleDays = Number(option("--stale-days", "30"));
18
+ const reportPath = option("--report", "");
19
+ const cutoff = Date.now() - staleDays * 86_400_000;
20
+ const findings = [];
21
+ let stale = 0;
22
+ let hard = 0;
23
+ let orphan = 0;
24
+ let staged = 0;
25
+ let noteStatus = "";
26
+
27
+ function directories(root, depth = 0) {
28
+ if (!existsSync(root) || depth > 5) return [];
29
+ const result = [];
30
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
31
+ if (!entry.isDirectory()) continue;
32
+ const path = join(root, entry.name);
33
+ if (entry.name === "wiki" && basename(dirname(path)) === ".planning") result.push(path);
34
+ else result.push(...directories(path, depth + 1));
35
+ }
36
+ return result;
37
+ }
38
+
39
+ for (const wiki of directories(para)) {
40
+ const project = resolve(wiki, "..", "..");
41
+ const rows = [];
42
+ const sources = join(wiki, "sources");
43
+ const sourceFiles = existsSync(sources) ? readdirSync(sources).filter((name) => name.endsWith(".md")) : [];
44
+ const staleFiles = sourceFiles.filter((name) => {
45
+ const match = readFileSync(join(sources, name), "utf8").match(/Last reviewed\*?\*?:\s*(\d{4}-\d{2}-\d{2})/i);
46
+ return match && Date.parse(`${match[1]}T00:00:00Z`) < cutoff;
47
+ });
48
+ if (staleFiles.length) { stale += staleFiles.length; rows.push(`- stale source pages (${staleFiles.length}): ${staleFiles.join(", ")}`); }
49
+ const contradictions = join(wiki, "CONTRADICTIONS.md");
50
+ if (existsSync(contradictions)) {
51
+ const open = readFileSync(contradictions, "utf8").match(/wiki:contradictions:open:start -->([\s\S]*?)<!-- wiki:contradictions:open:end/)?.[1] ?? "";
52
+ const count = (open.match(/severity:\s*hard|hard contradiction/gi) ?? []).length;
53
+ if (count) { hard += count; rows.push(`- open HARD contradictions: ${count}`); }
54
+ }
55
+ const indexPath = join(wiki, "index.md");
56
+ if (existsSync(indexPath)) {
57
+ const index = readFileSync(indexPath, "utf8");
58
+ const orphans = sourceFiles.filter((name) => !index.includes(name));
59
+ if (orphans.length) { orphan += orphans.length; rows.push(`- orphan source pages (${orphans.length}): ${orphans.join(", ")}`); }
60
+ }
61
+ const staging = join(project, ".planning", "memory-staging");
62
+ if (existsSync(staging)) {
63
+ const count = readdirSync(staging).filter((name) => name.endsWith(".json") && statSync(join(staging, name)).mtimeMs < cutoff).length;
64
+ if (count) { staged += count; rows.push(`- unreviewed staged memory candidates (>${staleDays} days): ${count}`); }
65
+ }
66
+ if (rows.length) findings.push(`### ${basename(project)}\n\n${rows.join("\n")}`);
67
+ }
68
+
69
+ let noteEligible = /^(1|true|yes|on)$/i.test(process.env.CAIRN_NOTE_DISTILLATION ?? "");
70
+ if (/^(1|true|yes|on)$/i.test(process.env.CAIRN_CAPABILITY_CONTRACT ?? "")) {
71
+ const status = spawnSync(process.execPath, [join(ROOT, "bin", "cairn"), "capabilities", "status", "--json"], { encoding: "utf8", windowsHide: true });
72
+ try {
73
+ const value = JSON.parse(status.stdout);
74
+ noteEligible = value.capabilities?.some((row) => row.id === "notes.distill" && row.enabled === true) ?? false;
75
+ } catch { noteEligible = false; }
76
+ }
77
+ if (noteEligible) {
78
+ const result = spawnSync(process.execPath, [join(ROOT, "bin", "cairn"), "notes", "distill", "--all-projects", "--para-root", para, "--json"], { stdio: "ignore", windowsHide: true });
79
+ noteStatus = result.status === 0 ? "completed" : "failed (deterministic wiki findings remain valid)";
80
+ }
81
+
82
+ const report = `# Memory + Wiki Audit Report
83
+
84
+ - Generated: ${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}
85
+ - Scope: ${para} (staleness threshold: ${staleDays} days)
86
+ - Stale source pages: ${stale}
87
+ - Open hard contradictions: ${hard}
88
+ - Orphan source pages: ${orphan}
89
+ - Unreviewed staged candidates: ${staged}
90
+ ${noteStatus ? `- Note distillation: ${noteStatus}\n` : ""}
91
+
92
+ ${findings.length ? findings.join("\n\n") : "No deterministic findings."}
93
+ `;
94
+ process.stdout.write(report);
95
+ if (reportPath) writeFileSync(resolve(reportPath), report, "utf8");
@@ -0,0 +1,26 @@
1
+ const REQUIRED_PHASE26_TEST_PATHS = Object.freeze([
2
+ "scripts/test-setup-preflight.mjs",
3
+ "scripts/test-setup-reconcile.mjs",
4
+ "scripts/test-setup-compatibility.mjs",
5
+ "scripts/test-pi-lifecycle.mjs",
6
+ "mcp-memory-server/scripts/smoke-pi-mcp-bridge.mjs",
7
+ ]);
8
+
9
+ export const PHASE26_TEST_MANIFEST = Object.freeze(REQUIRED_PHASE26_TEST_PATHS.map((path) => Object.freeze({
10
+ path,
11
+ state: "routine",
12
+ })));
13
+
14
+ export function validatePhase26TestManifest(entries = PHASE26_TEST_MANIFEST) {
15
+ if (!Array.isArray(entries)) throw new Error("Phase 26 test manifest must be an array.");
16
+ if (entries.length !== REQUIRED_PHASE26_TEST_PATHS.length) throw new Error("Phase 26 test manifest entry count drifted.");
17
+ const paths = entries.map((entry) => entry?.path);
18
+ if (JSON.stringify(paths) !== JSON.stringify(REQUIRED_PHASE26_TEST_PATHS)) throw new Error("Phase 26 test manifest paths or order drifted.");
19
+ if (new Set(paths).size !== paths.length) throw new Error("Phase 26 test manifest contains duplicate paths.");
20
+ for (const entry of entries) {
21
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error("Phase 26 test manifest entry must be an object.");
22
+ if (Object.keys(entry).sort().join(",") !== "path,state") throw new Error(`Phase 26 test manifest fields drifted for ${entry.path ?? "unknown"}.`);
23
+ if (entry.state !== "red-only" && entry.state !== "routine") throw new Error(`Invalid Phase 26 test state for ${entry.path}.`);
24
+ }
25
+ return entries;
26
+ }
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readdirSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
5
+ import { dirname, join, relative, resolve, sep } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { PHASE26_TEST_MANIFEST, validatePhase26TestManifest } from "./phase26-test-manifest.mjs";
9
+
10
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11
+
12
+ export function dispatchPhase26Tests(spawn = spawnSync) {
13
+ const results = [];
14
+ for (const entry of validatePhase26TestManifest().filter(({ state }) => state === "routine")) {
15
+ const result = spawn(process.execPath, [join(root, ...entry.path.split("/"))], { cwd: root, stdio: "inherit" });
16
+ results.push({ path: entry.path, status: result.status ?? 1 });
17
+ if (result.status !== 0) break;
18
+ }
19
+ return results;
20
+ }
21
+
22
+ function verifyPhase26RedRegistration() {
23
+ validatePhase26TestManifest();
24
+ const scheduled = dispatchPhase26Tests((_command, args) => {
25
+ throw new Error(`RED-only Phase 26 test was scheduled: ${args[0]}`);
26
+ });
27
+ if (scheduled.length !== 0) throw new Error("RED-only Phase 26 tests must not run in routine dispatch.");
28
+ console.log(`PASS: Phase 26 manifest has ${PHASE26_TEST_MANIFEST.length} exact RED-only entries and routine dispatch schedules none`);
29
+ }
30
+
31
+ function verifyPhase26RoutineRegistration() {
32
+ const entries = validatePhase26TestManifest();
33
+ if (!entries.every(({ state }) => state === "routine")) {
34
+ throw new Error("Every Phase 26 test must be routine after learning and track routing complete.");
35
+ }
36
+ const recorded = [];
37
+ const scheduled = dispatchPhase26Tests((command, args, options) => {
38
+ if (command !== process.execPath) throw new Error("Phase 26 dispatch must use the active Node executable.");
39
+ if (!Array.isArray(args) || args.length !== 1) throw new Error("Phase 26 dispatch arguments drifted.");
40
+ if (options?.cwd !== root || options?.stdio !== "inherit") throw new Error("Phase 26 dispatch options drifted.");
41
+ recorded.push(relative(root, args[0]).split(sep).join("/"));
42
+ return { status: 0 };
43
+ });
44
+ const expected = entries.map(({ path }) => path);
45
+ if (JSON.stringify(recorded) !== JSON.stringify(expected)) throw new Error("Phase 26 executable dispatch paths or order drifted.");
46
+ if (JSON.stringify(scheduled.map(({ path }) => path)) !== JSON.stringify(expected)) throw new Error("Phase 26 dispatch result paths drifted.");
47
+ if (new Set(recorded).size !== expected.length) throw new Error("Phase 26 executable dispatch scheduled a duplicate contract.");
48
+ console.log(`PASS: Phase 26 routine dispatch schedules all ${expected.length} exact contracts once`);
49
+ }
50
+
51
+ function run() {
52
+ const args = process.argv.slice(2);
53
+ if (args.length === 1 && args[0] === "--verify-phase26-registration=red") {
54
+ verifyPhase26RedRegistration();
55
+ return;
56
+ }
57
+ if (args.length === 1 && args[0] === "--verify-phase26-registration=routine") {
58
+ verifyPhase26RoutineRegistration();
59
+ return;
60
+ }
61
+ if (args.length !== 0) throw new Error(`Unknown repository test option: ${args.join(" ")}`);
62
+
63
+ if (process.platform !== "win32") {
64
+ for (const test of readdirSync(join(root, "scripts")).filter((name) => /^test-.*\.sh$/.test(name)).sort()) {
65
+ const result = spawnSync(join(root, "scripts", test), [], { cwd: root, stdio: "inherit" });
66
+ if (result.status !== 0) process.exit(result.status ?? 1);
67
+ }
68
+ }
69
+ const native = spawnSync(process.execPath, [join(root, "scripts", "test-windows-native.mjs")], { cwd: root, stdio: "inherit" });
70
+ if (native.status !== 0) process.exit(native.status ?? 1);
71
+ const phase26 = dispatchPhase26Tests();
72
+ const failed = phase26.find(({ status }) => status !== 0);
73
+ if (failed) process.exit(failed.status);
74
+ }
75
+
76
+ run();
@@ -0,0 +1,312 @@
1
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
+ import { accessSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { dirname, join, parse, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { HARNESS_IDS, harnessProjectAssets } from "./harness-registry.mjs";
7
+
8
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
+ const TEMPLATE_ROOT = join(ROOT, "templates");
10
+ const POLICY_LIMIT_BYTES = 1024 * 1024;
11
+ const GIT_MODES = Object.freeze(["init", "existing", "none"]);
12
+ const HARNESSES = HARNESS_IDS;
13
+ const MEMORY_MODES = Object.freeze(["local", "none"]);
14
+
15
+ const COMMON_ASSETS = Object.freeze([
16
+ ["env.example.template", ".ai/env.example", 0o644],
17
+ ["trajectory-redaction.json.template", ".ai/trajectory-redaction.json", 0o644],
18
+ ["capabilities.json.template", ".ai/capabilities.json", 0o600],
19
+ ["agentfs-gitignore.template", ".agentfs/.gitignore", 0o644],
20
+ ["planning-config.json.template", ".planning/config.json", 0o644],
21
+ ["project-brief.md.template", ".planning/PROJECT-BRIEF.md", 0o644],
22
+ ["wiki-index.md.template", ".planning/wiki/index.md", 0o644],
23
+ ["wiki-policy.md.template", ".planning/wiki/policy.md", 0o644],
24
+ ["wiki-contradictions.md.template", ".planning/wiki/CONTRADICTIONS.md", 0o644],
25
+ ["wiki-log.md.template", ".planning/wiki/LOG.md", 0o644],
26
+ ["alignment-policy.md.template", ".planning/alignment/policy.md", 0o644],
27
+ ["alignment-gap-register.yaml.template", ".planning/alignment/gap-register.yaml", 0o644],
28
+ ["graph-policy.md.template", ".planning/graphs/policy.md", 0o644],
29
+ ["graphs-gitignore.template", ".planning/graphs/.gitignore", 0o644],
30
+ ["security-policy.md.template", ".planning/security/policy.md", 0o644],
31
+ ]);
32
+
33
+ class SetupInputError extends Error {
34
+ constructor(kind, message, status = 1) {
35
+ super(message);
36
+ this.name = "SetupInputError";
37
+ this.kind = kind;
38
+ this.status = status;
39
+ }
40
+ }
41
+
42
+ function usage(message) {
43
+ return new SetupInputError("usage", `${message} Usage: cairn setup PATH --git init|existing|none --harness LIST --memory local|none [--policy PATH] --yes [--json]`, 2);
44
+ }
45
+
46
+ function operational(kind, message) {
47
+ return new SetupInputError(kind, message, 1);
48
+ }
49
+
50
+ function exactKeys(value, allowed, label) {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw operational("unsafe-policy", `Invalid setup policy ${label}.`);
52
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
53
+ if (unknown.length) throw operational("unsafe-policy", `Invalid setup policy: unknown ${label} field ${unknown.join(", ")}.`);
54
+ }
55
+
56
+ function enumValue(value, allowed, label, optional = false) {
57
+ if (optional && value === undefined) return undefined;
58
+ if (typeof value !== "string" || !allowed.includes(value)) throw operational("unsafe-policy", `Invalid setup policy ${label}; allowed values are ${allowed.join(", ")}.`);
59
+ return value;
60
+ }
61
+
62
+ function enumArray(value, allowed, label, optional = false) {
63
+ if (optional && value === undefined) return undefined;
64
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || !allowed.includes(item))) {
65
+ throw operational("unsafe-policy", `Invalid setup policy ${label}; allowed values are ${allowed.join(", ")}.`);
66
+ }
67
+ if (new Set(value).size !== value.length) throw operational("unsafe-policy", `Invalid setup policy ${label}; duplicate values are not allowed.`);
68
+ return allowed.filter((item) => value.includes(item));
69
+ }
70
+
71
+ function deepFreeze(value) {
72
+ if (!value || typeof value !== "object" || ArrayBuffer.isView(value)) return value;
73
+ for (const child of Object.values(value)) deepFreeze(child);
74
+ return Object.freeze(value);
75
+ }
76
+
77
+ function parsePolicy(value) {
78
+ exactKeys(value, ["schema_version", "defaults", "constraints"], "root");
79
+ if (value.schema_version !== 1) throw operational("unsafe-policy", "Invalid setup policy schema version; expected schema_version 1.");
80
+ exactKeys(value.defaults, ["git", "harnesses", "memory"], "defaults");
81
+ exactKeys(value.constraints, ["git", "harnesses", "required_harnesses", "memory"], "constraints");
82
+ const policy = {
83
+ schema_version: 1,
84
+ defaults: {
85
+ ...(value.defaults.git !== undefined ? { git: enumValue(value.defaults.git, GIT_MODES, "defaults.git") } : {}),
86
+ ...(value.defaults.harnesses !== undefined ? { harnesses: enumArray(value.defaults.harnesses, HARNESSES, "defaults.harnesses") } : {}),
87
+ ...(value.defaults.memory !== undefined ? { memory: enumValue(value.defaults.memory, MEMORY_MODES, "defaults.memory") } : {}),
88
+ },
89
+ constraints: {
90
+ ...(value.constraints.git !== undefined ? { git: enumArray(value.constraints.git, GIT_MODES, "constraints.git") } : {}),
91
+ ...(value.constraints.harnesses !== undefined ? { harnesses: enumArray(value.constraints.harnesses, HARNESSES, "constraints.harnesses") } : {}),
92
+ ...(value.constraints.required_harnesses !== undefined ? { required_harnesses: enumArray(value.constraints.required_harnesses, HARNESSES, "constraints.required_harnesses") } : {}),
93
+ ...(value.constraints.memory !== undefined ? { memory: enumArray(value.constraints.memory, MEMORY_MODES, "constraints.memory") } : {}),
94
+ },
95
+ };
96
+ if (policy.defaults.git && policy.constraints.git && !policy.constraints.git.includes(policy.defaults.git)) {
97
+ throw operational("unsafe-policy", "Invalid setup policy: defaults.git violates constraints.git.");
98
+ }
99
+ if (policy.defaults.memory && policy.constraints.memory && !policy.constraints.memory.includes(policy.defaults.memory)) {
100
+ throw operational("unsafe-policy", "Invalid setup policy: defaults.memory violates constraints.memory.");
101
+ }
102
+ if (policy.defaults.harnesses && policy.constraints.harnesses
103
+ && policy.defaults.harnesses.some((name) => !policy.constraints.harnesses.includes(name))) {
104
+ throw operational("unsafe-policy", "Invalid setup policy: defaults.harnesses violates constraints.harnesses.");
105
+ }
106
+ if (policy.constraints.required_harnesses && policy.constraints.harnesses
107
+ && policy.constraints.required_harnesses.some((name) => !policy.constraints.harnesses.includes(name))) {
108
+ throw operational("unsafe-policy", "Invalid setup policy: required harnesses must be allowed by constraints.harnesses.");
109
+ }
110
+ return deepFreeze(policy);
111
+ }
112
+
113
+ export function readSetupPolicy(path, options = {}) {
114
+ const lstat = options.lstat ?? lstatSync;
115
+ const read = options.readFile ?? readFileSync;
116
+ let info;
117
+ try {
118
+ info = lstat(path);
119
+ } catch (error) {
120
+ throw operational("unsafe-policy", `Setup policy could not be read: ${error instanceof Error ? error.message : String(error)}.`);
121
+ }
122
+ if (!info.isFile() || info.isSymbolicLink()) throw operational("unsafe-policy", "Setup policy must be a regular file, not a symbolic link or device.");
123
+ if (info.size > POLICY_LIMIT_BYTES) throw operational("unsafe-policy", "Setup policy exceeds the size limit.");
124
+ if (process.platform !== "win32" && (info.mode & 0o111) !== 0) throw operational("unsafe-policy", "Setup policy must not be executable.");
125
+ let descriptor;
126
+ let bytes;
127
+ try {
128
+ if (options.readFile) bytes = read(path);
129
+ else {
130
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
131
+ const opened = fstatSync(descriptor);
132
+ if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) throw operational("unsafe-policy", "Setup policy changed during validation.");
133
+ if (opened.size > POLICY_LIMIT_BYTES || (process.platform !== "win32" && (opened.mode & 0o111) !== 0)) {
134
+ throw operational("unsafe-policy", "Setup policy type, size, or executable mode is unsafe.");
135
+ }
136
+ bytes = read(descriptor);
137
+ }
138
+ } catch (error) {
139
+ if (error instanceof SetupInputError) throw error;
140
+ throw operational("unsafe-policy", `Setup policy could not be opened safely: ${error instanceof Error ? error.message : String(error)}.`);
141
+ } finally {
142
+ if (descriptor !== undefined) closeSync(descriptor);
143
+ }
144
+ if (bytes.byteLength > POLICY_LIMIT_BYTES) throw operational("unsafe-policy", "Setup policy exceeds the size limit.");
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(bytes.toString("utf8"));
148
+ } catch {
149
+ throw operational("unsafe-policy", "Invalid setup policy JSON syntax.");
150
+ }
151
+ return parsePolicy(parsed);
152
+ }
153
+
154
+ function parseHarnesses(raw) {
155
+ const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
156
+ if (values.length === 0 || values.some((value) => !HARNESSES.includes(value)) || new Set(values).size !== values.length) {
157
+ throw usage(`Invalid harness choice: ${raw || "missing"}.`);
158
+ }
159
+ return HARNESSES.filter((name) => values.includes(name));
160
+ }
161
+
162
+ export function parseSetupArgs(args, options = {}) {
163
+ const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY);
164
+ const parsed = { target: null, git: null, harnesses: null, memory: null, policyPath: null, confirmed: false, json: false };
165
+ const seen = new Set();
166
+ for (let index = 0; index < args.length; index += 1) {
167
+ const arg = args[index];
168
+ if (["--git", "--harness", "--memory", "--policy"].includes(arg)) {
169
+ if (seen.has(arg)) throw usage(`Invalid duplicate ${arg} option.`);
170
+ seen.add(arg);
171
+ const value = args[index + 1];
172
+ if (!value || value.startsWith("--")) throw usage(`Missing value for ${arg}.`);
173
+ index += 1;
174
+ if (arg === "--git") {
175
+ if (!GIT_MODES.includes(value)) throw usage(`Invalid Git mode: ${value}.`);
176
+ parsed.git = value;
177
+ } else if (arg === "--harness") parsed.harnesses = parseHarnesses(value);
178
+ else if (arg === "--memory") {
179
+ if (!MEMORY_MODES.includes(value)) throw usage(`Invalid memory mode: ${value}.`);
180
+ parsed.memory = value;
181
+ } else parsed.policyPath = value;
182
+ } else if (arg === "--yes" || arg === "--json") {
183
+ if (seen.has(arg)) throw usage(`Invalid duplicate ${arg} option.`);
184
+ seen.add(arg);
185
+ if (arg === "--yes") parsed.confirmed = true;
186
+ else parsed.json = true;
187
+ } else if (arg.startsWith("-")) throw usage(`Unknown setup option: ${arg}.`);
188
+ else if (parsed.target !== null) throw usage("Invalid extra target/path positional argument.");
189
+ else parsed.target = arg;
190
+ }
191
+ if (!isTTY) {
192
+ if (!parsed.target) throw usage("Missing setup target path.");
193
+ const canUsePolicyDefaults = Boolean(parsed.policyPath);
194
+ if (!canUsePolicyDefaults && !parsed.git) throw usage("Non-TTY setup requires an explicit Git choice.");
195
+ if (!canUsePolicyDefaults && !parsed.harnesses) throw usage("Non-TTY setup requires an explicit harness choice.");
196
+ if (!canUsePolicyDefaults && !parsed.memory) throw usage("Non-TTY setup requires an explicit memory choice.");
197
+ if (!parsed.confirmed) throw usage("Non-TTY setup requires explicit confirmation with --yes.");
198
+ }
199
+ return deepFreeze(parsed);
200
+ }
201
+
202
+ function pathParts(path) {
203
+ const result = [];
204
+ let cursor = resolve(path);
205
+ const root = parse(cursor).root;
206
+ while (cursor !== root) {
207
+ result.push(cursor);
208
+ cursor = dirname(cursor);
209
+ }
210
+ result.push(root);
211
+ return result.reverse();
212
+ }
213
+
214
+ export function classifySetupTarget(target, options = {}) {
215
+ const lstat = options.lstat ?? lstatSync;
216
+ const exists = options.exists ?? existsSync;
217
+ const readdir = options.readdir ?? readdirSync;
218
+ const realpath = options.realpath ?? realpathSync;
219
+ const access = options.access ?? accessSync;
220
+ const run = options.run ?? spawnSync;
221
+ const absolute = resolve(target);
222
+ let nearest = parse(absolute).root;
223
+ for (const candidate of pathParts(absolute)) {
224
+ if (!exists(candidate)) break;
225
+ const info = lstat(candidate);
226
+ if (info.isSymbolicLink()) throw operational("unsafe-target", "Setup target or an ancestor is a symbolic link and is unsafe.");
227
+ if (!info.isDirectory()) throw operational("unsafe-target", "Setup target ancestor is not a regular directory.");
228
+ nearest = candidate;
229
+ }
230
+ const targetExists = exists(absolute);
231
+ const targetState = targetExists ? (readdir(absolute).length === 0 ? "empty" : "non-empty") : "missing";
232
+ try {
233
+ access(nearest, constants.R_OK | constants.W_OK | constants.X_OK);
234
+ } catch {
235
+ throw operational("target-denied", "Setup target cannot be created or inspected because access to its parent is denied.");
236
+ }
237
+ const canonicalNearest = realpath(nearest);
238
+ if (canonicalNearest !== nearest) throw operational("unsafe-target", "Setup target resolves through an unsafe ancestor.");
239
+
240
+ const runOptions = { encoding: "utf8", shell: false };
241
+ const version = run("git", ["--version"], runOptions);
242
+ if (version.error?.code === "ENOENT") {
243
+ return Object.freeze({ target: absolute, targetState, gitExecutable: "missing", repository: "unknown" });
244
+ }
245
+ if (version.error || version.status !== 0) {
246
+ throw operational("git-probe", "Git availability could not be determined safely.");
247
+ }
248
+ const worktree = run("git", ["-C", targetExists ? absolute : nearest, "rev-parse", "--is-inside-work-tree"], runOptions);
249
+ let repository = "none";
250
+ if (worktree.error?.code === "ENOENT") return Object.freeze({ target: absolute, targetState, gitExecutable: "missing", repository: "unknown" });
251
+ if (worktree.status === 0) repository = worktree.stdout.trim() === "true" ? "work-tree" : "bare";
252
+ return Object.freeze({ target: absolute, targetState, gitExecutable: "available", repository });
253
+ }
254
+
255
+ function requiredChoice(value, name) {
256
+ if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) throw usage(`Missing required ${name} choice.`);
257
+ return value;
258
+ }
259
+
260
+ export function resolveSetupChoices({ parsed, preflight, policy = null, interactive = null }) {
261
+ const git = requiredChoice(parsed.git ?? policy?.defaults?.git ?? interactive?.git, "Git");
262
+ const harnesses = requiredChoice(parsed.harnesses ?? policy?.defaults?.harnesses ?? interactive?.harnesses, "harness");
263
+ const memory = requiredChoice(parsed.memory ?? policy?.defaults?.memory ?? interactive?.memory, "memory");
264
+ const confirmed = parsed.confirmed || interactive?.confirmed === true;
265
+ if (!confirmed) throw usage("Missing setup confirmation; use --yes or confirm interactively.");
266
+ if (!GIT_MODES.includes(git)) throw usage(`Invalid Git choice: ${git}.`);
267
+ if (!Array.isArray(harnesses) || harnesses.length === 0 || harnesses.some((name) => !HARNESSES.includes(name))) throw usage("Invalid harness choice.");
268
+ if (!MEMORY_MODES.includes(memory)) throw usage(`Invalid memory choice: ${memory}.`);
269
+ const normalizedHarnesses = HARNESSES.filter((name) => harnesses.includes(name));
270
+ if (git !== "none" && preflight.gitExecutable === "missing") throw operational("missing-git", `Git executable is required for --git ${git}.`);
271
+ if (git === "existing" && preflight.repository !== "work-tree") throw operational("non-repository", "--git existing requires the target to be inside a Git work tree.");
272
+
273
+ const constraints = policy?.constraints ?? {};
274
+ if (constraints.git && !constraints.git.includes(git)) throw operational("policy-constraint", `Git choice ${git} violates policy constraints.`);
275
+ if (constraints.memory && !constraints.memory.includes(memory)) throw operational("policy-constraint", `Memory choice ${memory} violates policy constraints.`);
276
+ if (constraints.harnesses && normalizedHarnesses.some((name) => !constraints.harnesses.includes(name))) {
277
+ throw operational("policy-constraint", "Harness choice violates policy constraints.");
278
+ }
279
+ const missingRequired = (constraints.required_harnesses ?? []).filter((name) => !normalizedHarnesses.includes(name));
280
+ if (missingRequired.length) throw operational("policy-constraint", `Harness constraint requires ${missingRequired.join(", ")}.`);
281
+ return deepFreeze({ git, harnesses: normalizedHarnesses, memory, confirmed: true, limited: git === "none" });
282
+ }
283
+
284
+ function templateAsset(template, path, mode, harness) {
285
+ const asset = { path, bytes: readFileSync(join(TEMPLATE_ROOT, template)), mode, template, ...(harness ? { harness } : {}) };
286
+ return Object.freeze(asset);
287
+ }
288
+
289
+ export function buildSetupPlan({ target, preflight, choices }) {
290
+ const absolute = resolve(target);
291
+ if (preflight.target && resolve(preflight.target) !== absolute) throw operational("unsafe-target", "Setup target changed after preflight.");
292
+ if (!choices?.confirmed) throw usage("Missing setup confirmation before mutation planning.");
293
+ if (choices.git === "existing" && preflight.repository !== "work-tree") throw operational("non-repository", "Existing Git mode requires a work tree.");
294
+ if (choices.git !== "none" && preflight.gitExecutable === "missing") throw operational("missing-git", "The selected Git mode requires the Git executable.");
295
+ const harnesses = HARNESSES.filter((name) => choices.harnesses.includes(name));
296
+ const assets = [
297
+ ...harnesses.flatMap((harness) => harnessProjectAssets(harness, choices.memory)
298
+ .map(({ template, path, mode }) => templateAsset(template, path, mode, harness))),
299
+ ...COMMON_ASSETS.map(([template, path, mode]) => templateAsset(template, path, mode)),
300
+ ];
301
+ const version = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
302
+ return deepFreeze({
303
+ target: absolute,
304
+ targetState: preflight.targetState,
305
+ version,
306
+ git: choices.git,
307
+ memory: choices.memory,
308
+ harnesses,
309
+ limited: choices.git === "none",
310
+ assets: Object.freeze(assets),
311
+ });
312
+ }