@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +82 -27
  3. package/bin/cairn +6 -153
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +41 -4
  6. package/docs/containers.md +10 -0
  7. package/docs/context-packs.md +94 -0
  8. package/docs/harness-compatibility.md +37 -3
  9. package/docs/learning/CURRICULUM-MAP.md +16 -5
  10. package/docs/learning/FEATURE-GUIDE.md +15 -4
  11. package/docs/learning/README.md +8 -2
  12. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  13. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  14. package/docs/learning/lessons/L02-installation.md +11 -2
  15. package/docs/learning/lessons/L03-first-project.md +1 -1
  16. package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
  17. package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
  18. package/docs/learning/lessons/L21-context-packs.md +79 -0
  19. package/docs/learning/lessons/L22-native-windows.md +83 -0
  20. package/docs/learning/lessons/L23-guided-setup.md +138 -0
  21. package/docs/learning/tracks/operator.md +10 -3
  22. package/docs/learning/tracks/quickstart.md +7 -3
  23. package/docs/mcp-tool-profiles.md +43 -0
  24. package/docs/native-windows.md +122 -0
  25. package/docs/operating.md +157 -18
  26. package/docs/privacy-and-data-flow.md +51 -1
  27. package/docs/storage.md +29 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  30. package/mcp-memory-server/dist/artifact-store.js +196 -55
  31. package/mcp-memory-server/dist/capability-cli.js +2 -1
  32. package/mcp-memory-server/dist/capability-config.js +4 -3
  33. package/mcp-memory-server/dist/capability-harness.js +34 -7
  34. package/mcp-memory-server/dist/context-pack-cli.js +185 -0
  35. package/mcp-memory-server/dist/context-pack.js +889 -0
  36. package/mcp-memory-server/dist/eval-cli.js +6 -5
  37. package/mcp-memory-server/dist/eval-plan.js +35 -13
  38. package/mcp-memory-server/dist/eval-process.js +11 -4
  39. package/mcp-memory-server/dist/eval-report.js +8 -5
  40. package/mcp-memory-server/dist/eval-runner.js +2 -0
  41. package/mcp-memory-server/dist/eval-schema.js +1 -0
  42. package/mcp-memory-server/dist/graph-cli.js +7 -1
  43. package/mcp-memory-server/dist/index.js +87 -37
  44. package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
  45. package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
  46. package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
  47. package/mcp-memory-server/dist/note-store.js +12 -3
  48. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  49. package/mcp-memory-server/dist/platform-security.js +102 -0
  50. package/mcp-memory-server/dist/skill-store.js +27 -12
  51. package/mcp-memory-server/package.json +4 -1
  52. package/package.json +5 -4
  53. package/pi/extensions/cairnkeep-memory.ts +61 -0
  54. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  55. package/schemas/cairnkeep-setup.schema.json +36 -0
  56. package/schemas/context-pack.schema.json +33 -0
  57. package/schemas/eval-report.schema.json +1 -0
  58. package/scripts/cairn-cli.mjs +119 -0
  59. package/scripts/cairn-container-cli.mjs +121 -0
  60. package/scripts/cairn-hook.mjs +199 -0
  61. package/scripts/completion.sh +33 -5
  62. package/scripts/doctor.sh +96 -0
  63. package/scripts/fix-permissions.mjs +12 -0
  64. package/scripts/memory-wiki-audit.mjs +95 -0
  65. package/scripts/phase26-test-manifest.mjs +26 -0
  66. package/scripts/run-repository-tests.mjs +76 -0
  67. package/scripts/setup-core.mjs +310 -0
  68. package/scripts/setup-reconcile.mjs +331 -0
  69. package/scripts/setup.mjs +306 -0
  70. package/scripts/sync-pi-assets.sh +19 -4
  71. package/scripts/test-cli-dispatch.sh +6 -0
  72. package/scripts/test-completion.sh +44 -0
  73. package/scripts/test-doctor.sh +66 -0
  74. package/scripts/test-learning-docs.sh +53 -3
  75. package/scripts/test-package-install.sh +81 -0
  76. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  77. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  78. package/scripts/test-pi-lifecycle.mjs +190 -0
  79. package/scripts/test-setup-compatibility.mjs +186 -0
  80. package/scripts/test-setup-output.sh +131 -0
  81. package/scripts/test-setup-overlay.sh +113 -0
  82. package/scripts/test-setup-preflight.mjs +281 -0
  83. package/scripts/test-setup-reconcile.mjs +257 -0
  84. package/scripts/test-uninstall.sh +71 -0
  85. package/scripts/test-windows-native.mjs +136 -0
  86. package/scripts/uninstall.sh +95 -7
  87. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  88. package/scripts/windows-platform.mjs +725 -0
  89. package/templates/env.example.template +11 -0
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, rmSync, statSync } from "node:fs";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
+ const SERVER = join(ROOT, "mcp-memory-server", "dist", "index.js");
10
+ const TRAJECTORY = join(ROOT, "mcp-memory-server", "dist", "trajectory-cli.js");
11
+ const ARTIFACT = join(ROOT, "mcp-memory-server", "dist", "artifact-cli.js");
12
+ const CAPABILITY = join(ROOT, "mcp-memory-server", "dist", "capability-cli.js");
13
+
14
+ function enabled(name) {
15
+ return /^(1|true|yes|on)$/i.test(String(process.env[name] ?? "").trim());
16
+ }
17
+
18
+ async function stdin() {
19
+ let input = "";
20
+ process.stdin.setEncoding("utf8");
21
+ for await (const chunk of process.stdin) input += chunk;
22
+ return input;
23
+ }
24
+
25
+ function json(input) {
26
+ try { return JSON.parse(input); } catch { return {}; }
27
+ }
28
+
29
+ function node(entry, args = [], options = {}) {
30
+ return spawnSync(process.execPath, [entry, ...args], { encoding: "utf8", timeout: 3000, windowsHide: true, ...options });
31
+ }
32
+
33
+ function hookOutput(event, prefix, context) {
34
+ if (!context.trim()) return;
35
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: `${prefix}\n\n${context.trim()}` } })}\n`);
36
+ }
37
+
38
+ async function wakeup() {
39
+ const repo = process.cwd();
40
+ const projectDb = join(repo, ".agentfs", "project.db");
41
+ const wiki = join(repo, ".planning", "wiki", "index.md");
42
+ if (!existsSync(projectDb) && !existsSync(wiki) && !enabled("CAIRN_COMPACTION_CAPTURE")) return;
43
+ if (existsSync(projectDb) && existsSync(SERVER)) {
44
+ const result = node(SERVER, ["wakeup"]);
45
+ if (result.stdout) process.stdout.write(`## Project memory (AgentFS)\n${result.stdout}`);
46
+ }
47
+ if (existsSync(wiki)) process.stdout.write(`\n## Wiki index\n${readFileSync(wiki, "utf8")}\n`);
48
+ const contradictions = join(repo, ".planning", "wiki", "CONTRADICTIONS.md");
49
+ if (existsSync(contradictions)) {
50
+ const text = readFileSync(contradictions, "utf8");
51
+ const open = text.match(/<!-- wiki:contradictions:open:start -->([\s\S]*?)<!-- wiki:contradictions:open:end -->/)?.[1] ?? "";
52
+ const hard = open.split(/\r?\n/).filter((line) => /severity:\s*hard/i.test(line));
53
+ if (hard.length) process.stdout.write(`\n## Open HARD contradictions — resolve before dependent work\n${hard.join("\n")}\n`);
54
+ }
55
+ const staging = join(repo, ".planning", "memory-staging");
56
+ if (existsSync(staging)) {
57
+ const count = readdirSync(staging).filter((name) => name.endsWith(".json")).length;
58
+ if (count) process.stdout.write(`\n## Staged memory candidates (${count} session(s)) — UNREVIEWED\nRun /memory-review to accept or discard them.\n`);
59
+ }
60
+ if (enabled("CAIRN_COMPACTION_CAPTURE") && existsSync(ARTIFACT)) {
61
+ const payload = json(await stdin());
62
+ const args = ["recover", repo];
63
+ if (/^[A-Za-z0-9._:/-]{1,160}$/.test(payload.session_id ?? "")) args.push("--session-ref", `claude-code:${payload.session_id}`);
64
+ const result = node(ARTIFACT, args);
65
+ if (result.stdout?.trim()) process.stdout.write(`\n${result.stdout}`);
66
+ }
67
+ }
68
+
69
+ async function capture() {
70
+ const repo = process.cwd();
71
+ const input = json(await stdin());
72
+ const transcript = typeof input.transcript_path === "string" ? resolve(input.transcript_path) : "";
73
+ if (!transcript || !existsSync(transcript)) return;
74
+ if (enabled("CAIRN_TRAJECTORY_CAPTURE") && existsSync(TRAJECTORY)) node(TRAJECTORY, ["capture-claude", transcript, repo]);
75
+ if (!existsSync(join(repo, ".agentfs", "project.db")) || !existsSync(SERVER)
76
+ || !process.env.CAIRN_LLM_API_KEY || !process.env.CAIRN_LLM_EXTRACTION_MODEL) return;
77
+ const helper = join(ROOT, "scripts", "transcript-to-text.mjs");
78
+ const text = node(helper, [transcript]);
79
+ if (!text.stdout?.trim()) return;
80
+ const result = node(SERVER, ["extract", process.env.CAIRN_LLM_EXTRACTION_MODEL], { input: text.stdout, timeout: 20_000 });
81
+ const candidates = json(result.stdout);
82
+ if (!Array.isArray(candidates.candidates) || candidates.candidates.length === 0) return;
83
+ const staging = join(repo, ".planning", "memory-staging");
84
+ mkdirSync(staging, { recursive: true });
85
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
86
+ writeFileSync(join(staging, `${stamp}.json`), `${JSON.stringify(candidates)}\n`, { mode: 0o600 });
87
+ const files = readdirSync(staging).filter((name) => name.endsWith(".json"))
88
+ .map((name) => ({ name, mtime: statSync(join(staging, name)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
89
+ for (const old of files.slice(5)) rmSync(join(staging, old.name), { force: true });
90
+ }
91
+
92
+ async function compact() {
93
+ if (!enabled("CAIRN_COMPACTION_CAPTURE") || !existsSync(ARTIFACT)) return;
94
+ const input = await stdin();
95
+ const version = spawnSync("claude", ["--version"], { encoding: "utf8", timeout: 2000, windowsHide: true }).stdout?.match(/\d+\.\d+\.\d+/)?.[0] ?? "unknown";
96
+ node(ARTIFACT, ["capture-claude", process.cwd(), "--harness-version", version], { input });
97
+ }
98
+
99
+ async function recall() {
100
+ const repo = process.cwd();
101
+ const input = json(await stdin());
102
+ const file = input.tool_input?.file_path || input.tool_input?.path;
103
+ if (typeof file !== "string") return;
104
+ const base = basename(file);
105
+ const stem = base.replace(/\.[^.]+$/, "");
106
+ if (stem.length < 4) return;
107
+ const sections = [];
108
+ if (existsSync(join(repo, ".agentfs", "project.db")) && existsSync(SERVER)) {
109
+ const result = node(SERVER, ["wakeup"]);
110
+ const hits = (result.stdout ?? "").split(/\r?\n/).filter((line) => line.toLowerCase().includes(stem.toLowerCase())).slice(0, 8);
111
+ if (hits.length) sections.push(`## Relevant project memory for ${base}\n\n${hits.join("\n")}`);
112
+ }
113
+ const sources = join(repo, ".planning", "wiki", "sources");
114
+ if (existsSync(sources)) {
115
+ const hits = readdirSync(sources).filter((name) => name.endsWith(".md")).filter((name) => {
116
+ try { return readFileSync(join(sources, name), "utf8").toLowerCase().includes(stem.toLowerCase()); } catch { return false; }
117
+ }).slice(0, 8);
118
+ if (hits.length) sections.push(`## Relevant wiki pages for ${base}\n\n${hits.map((name) => `- ${name}`).join("\n")}`);
119
+ }
120
+ hookOutput("PreToolUse", "Memory recall (auto-injected for this file edit):", sections.join("\n\n"));
121
+ }
122
+
123
+ async function explore() {
124
+ if (!process.env.CAIRN_EXPLORE_BINARY || process.env.CAIRN_EXPLORE_AUTOINVOKE !== "1" || !existsSync(SERVER)) return;
125
+ const prompt = json(await stdin()).prompt;
126
+ if (typeof prompt !== "string" || prompt.length < 10 || prompt.startsWith("/") || /^\s*(ok|yes|no|thanks?)\.?\s*$/i.test(prompt)) return;
127
+ const result = node(SERVER, ["explore", prompt], { timeout: 20_000 });
128
+ const value = json(result.stdout);
129
+ if (value.ok !== true || !Array.isArray(value.citations) || !value.citations.length) return;
130
+ const context = value.citations.slice(0, 40).map((citation) => {
131
+ let line = `${citation.path}:${citation.start_line}-${citation.end_line}`;
132
+ const refs = [];
133
+ if (citation.memory_refs?.length) refs.push(`memory: ${citation.memory_refs.join(", ")}`);
134
+ if (citation.wiki_refs?.length) refs.push(`wiki: ${citation.wiki_refs.join(", ")}`);
135
+ return refs.length ? `${line} <- ${refs.join(" - ")}` : line;
136
+ }).join("\n");
137
+ hookOutput("UserPromptSubmit", "Auto-invoked exploration context (context_explore):", context);
138
+ }
139
+
140
+ function validSession(value) {
141
+ return typeof value === "string" && /^(?:cairn:)?[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(value) && value !== "unknown";
142
+ }
143
+
144
+ async function capabilityStart() {
145
+ const block = () => { process.stdout.write('{"decision":"block","reason":"capability disabled"}\n'); process.exitCode = 2; };
146
+ if (!enabled("CAIRN_CAPABILITY_CONTRACT")) { process.stdout.write("{}\n"); return; }
147
+ if (!existsSync(CAPABILITY)) { block(); return; }
148
+ const value = json(await stdin());
149
+ const commands = new Set(["wiki-ingest", "wiki-query", "wiki-lint", "graphify", "security-audit"]);
150
+ const required = ["session_id", "transcript_path", "cwd", "hook_event_name", "expansion_type", "command_name", "command_args", "command_source", "prompt"];
151
+ const valid = required.every((key) => Object.hasOwn(value, key))
152
+ && value.hook_event_name === "UserPromptExpansion"
153
+ && ["slash_command", "mcp_prompt"].includes(value.expansion_type)
154
+ && commands.has(value.command_name)
155
+ && validSession(value.session_id)
156
+ && resolve(value.cwd || "") === resolve(process.cwd());
157
+ if (!valid) { block(); return; }
158
+ const payload = JSON.stringify({ schema_version: 1, harness: "claude-code", command: value.command_name, session_id: value.session_id, project_root: resolve(process.cwd()) });
159
+ const result = node(CAPABILITY, ["harness-before"], { input: payload });
160
+ if (result.stdout?.trim() === '{"schema_version":1,"decision":"block","reason":"capability-disabled"}') block();
161
+ else process.stdout.write("{}\n");
162
+ }
163
+
164
+ async function capabilityFinish() {
165
+ if (!enabled("CAIRN_CAPABILITY_CONTRACT") || !existsSync(CAPABILITY)) return;
166
+ const value = json(await stdin());
167
+ if (!validSession(value.session_id) || typeof value.hook_event_name !== "string") return;
168
+ let operation;
169
+ let body;
170
+ if (value.hook_event_name === "Stop" && typeof value.stop_hook_active === "boolean") {
171
+ operation = "harness-terminal"; body = { outcome: "success" };
172
+ } else if (value.hook_event_name === "StopFailure" && typeof value.error === "string") {
173
+ operation = "harness-terminal"; body = { outcome: "error" };
174
+ } else if (value.hook_event_name === "SessionEnd" && typeof value.reason === "string") {
175
+ operation = "harness-terminal"; body = { outcome: "abandoned" };
176
+ } else if (value.hook_event_name === "CwdChanged" && typeof value.old_cwd === "string" && typeof value.new_cwd === "string") {
177
+ operation = "harness-cwd"; body = { old_cwd: value.old_cwd, new_cwd: value.new_cwd };
178
+ } else return;
179
+ node(CAPABILITY, [operation], { input: JSON.stringify({ ...body, schema_version: 1, harness: "claude-code", session_id: value.session_id }) });
180
+ }
181
+
182
+ const handlers = new Map([
183
+ ["memory-wakeup", wakeup],
184
+ ["memory-capture", capture],
185
+ ["compaction-capture", compact],
186
+ ["memory-recall", recall],
187
+ ["context-explore-pretask", explore],
188
+ ["capability-command-start", capabilityStart],
189
+ ["capability-command-finish", capabilityFinish],
190
+ ]);
191
+
192
+ const handler = handlers.get(process.argv[2]);
193
+ if (!handler) process.exitCode = 2;
194
+ else handler().catch(() => {
195
+ if (process.argv[2] === "capability-command-start") {
196
+ process.stdout.write('{"decision":"block","reason":"capability disabled"}\n');
197
+ process.exitCode = 2;
198
+ }
199
+ }); // Other harness callbacks are deliberately fail-open.
@@ -11,13 +11,14 @@ _cairn_complete() {
11
11
  COMPREPLY=()
12
12
  current=${COMP_WORDS[COMP_CWORD]}
13
13
  previous=${COMP_WORDS[COMP_CWORD-1]:-}
14
- commands="bootstrap memory-server sync sync-pi sync-kimi doctor trajectory artifact capabilities notes eval skill graph memory audit-timer uninstall completion version help"
14
+ commands="bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help"
15
15
  if (( COMP_CWORD == 1 )); then
16
16
  COMPREPLY=( $(compgen -W "$commands" -- "$current") )
17
17
  return
18
18
  fi
19
19
  case "${COMP_WORDS[1]}" in
20
20
  bootstrap) COMPREPLY=( $(compgen -W "--untracked" -- "$current") ) ;;
21
+ setup) COMPREPLY=( $(compgen -W "--git --harness --memory --policy --yes --json init existing none claude opencode pi kimi qwen local" -- "$current") ) ;;
21
22
  sync) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
22
23
  sync-pi) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
23
24
  sync-kimi) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
@@ -51,6 +52,8 @@ _cairn_complete() {
51
52
  *) COMPREPLY=( $(compgen -W "list status enable disable reset logging" -- "$current") ) ;;
52
53
  esac
53
54
  ;;
55
+ mcp-tools) COMPREPLY=( $(compgen -W "list status set reset full read-only custom --tool --project --json" -- "$current") ) ;;
56
+ pack) COMPREPLY=( $(compgen -W "init lock validate install list show remove enable disable update skills approve-skill revoke-skill --id --version --title --description --license --ref --project --project-id --check --apply --confirm --json" -- "$current") ) ;;
54
57
  notes) COMPREPLY=( $(compgen -W "distill search-error promote doctor --project --session --all-projects --para-root --text --component --with --confirm --repair --json" -- "$current") ) ;;
55
58
  eval)
56
59
  case "${COMP_WORDS[2]:-}" in
@@ -67,7 +70,7 @@ _cairn_complete() {
67
70
  graph) COMPREPLY=( $(compgen -W "build query status diff explain path --force" -- "$current") ) ;;
68
71
  memory) COMPREPLY=( $(compgen -W "path export import" -- "$current") ) ;;
69
72
  audit-timer) COMPREPLY=( $(compgen -W "--on-calendar --para-root --render-only" -- "$current") ) ;;
70
- uninstall) COMPREPLY=( $(compgen -W "--dry-run --yes --purge-memory --live-root --pi-live-root --kimi-live-root" -- "$current") ) ;;
73
+ uninstall) COMPREPLY=( $(compgen -W "--dry-run --yes --purge-memory --purge-packs --live-root --pi-live-root --kimi-live-root" -- "$current") ) ;;
71
74
  completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$current") ) ;;
72
75
  esac
73
76
  }
@@ -81,6 +84,7 @@ _cairn() {
81
84
  local -a commands
82
85
  commands=(
83
86
  'bootstrap:scaffold a project'
87
+ 'setup:configure selected project harnesses'
84
88
  'memory-server:run the memory MCP server'
85
89
  'sync:install the Claude operating layer'
86
90
  'sync-pi:install the Pi trajectory extension and graph prompt'
@@ -89,6 +93,8 @@ _cairn() {
89
93
  'trajectory:inspect and prune local session trajectories'
90
94
  'artifact:inspect, delete, and prune local artifacts'
91
95
  'capabilities:inspect and manage project capability state'
96
+ 'mcp-tools:inspect and restrict MCP tool exposure'
97
+ 'pack:manage immutable context packs and skill approvals'
92
98
  'notes:distill and search local hindsight notes'
93
99
  'eval:run and inspect default-off local evaluations'
94
100
  'skill:review and evaluate evidence-backed skill improvements'
@@ -106,6 +112,7 @@ _cairn() {
106
112
  fi
107
113
  case $words[2] in
108
114
  bootstrap) _arguments '--untracked[keep scaffold out of Git]' '*:project directory:_files -/' ;;
115
+ setup) _arguments '*:project directory:_files -/' '--git[Git mode]:mode:(init existing none)' '--harness[selected harnesses]:harnesses:(claude opencode pi kimi qwen)' '--memory[memory mode]:mode:(local none)' '--policy[setup policy]:file:_files' '--yes[confirm setup]' '--json[emit JSON]' ;;
109
116
  sync) _arguments '--apply[apply changes]' '--live-root[project Claude root]:directory:_files -/' ;;
110
117
  sync-pi) _arguments '--apply[apply changes]' '--live-root[Pi agent root]:directory:_files -/' ;;
111
118
  sync-kimi) _arguments '--apply[apply changes]' '--live-root[Kimi Code root]:directory:_files -/' ;;
@@ -120,6 +127,8 @@ _cairn() {
120
127
  *) _values 'artifact command' list show delete prune ;;
121
128
  esac
122
129
  ;;
130
+ mcp-tools) _values 'MCP tool profile command' list status set reset full read-only custom '--tool[allow an exact tool]:tool:' '--project[project root]:directory:_files -/' '--json[emit JSON]' ;;
131
+ pack) _values 'context pack command' init lock validate install list show remove enable disable update skills approve-skill revoke-skill '--ref[pinned Git ref]:ref:' '--project[project root]:directory:_files -/' '--project-id[remote project ID]:project ID:' '--check[inspect update]' '--apply[apply update]' '--confirm[confirm digest]:digest:' '--json[emit JSON]' ;;
123
132
  capabilities)
124
133
  case $words[3] in
125
134
  list|status) _arguments '--json[emit JSON]' ;;
@@ -157,7 +166,7 @@ _cairn() {
157
166
  graph) _values 'graph command' build query status diff explain path '--force[allow a smaller graph after code deletion]' ;;
158
167
  memory) _values 'memory command' path export import ;;
159
168
  audit-timer) _arguments '--on-calendar[systemd calendar]:calendar:' '--para-root[PARA root]:directory:_files -/' '--render-only[render directory]:directory:_files -/' ;;
160
- uninstall) _arguments '--dry-run[show changes]' '--yes[skip confirmation]' '--purge-memory[delete memory]' '--live-root[project Claude root]:directory:_files -/' '--pi-live-root[Pi agent root]:directory:_files -/' '--kimi-live-root[Kimi Code root]:directory:_files -/' '*:project directory:_files -/' ;;
169
+ uninstall) _arguments '--dry-run[show changes]' '--yes[skip confirmation]' '--purge-memory[delete memory]' '--purge-packs[delete context packs]' '--live-root[project Claude root]:directory:_files -/' '--pi-live-root[Pi agent root]:directory:_files -/' '--kimi-live-root[Kimi Code root]:directory:_files -/' '*:project directory:_files -/' ;;
161
170
  completion) _values 'shell' bash zsh fish ;;
162
171
  *) _arguments '*:argument:_files' ;;
163
172
  esac
@@ -168,8 +177,8 @@ EOF
168
177
  fish)
169
178
  cat <<'EOF'
170
179
  complete -c cairn -f
171
- for command in bootstrap memory-server sync sync-pi sync-kimi doctor trajectory artifact capabilities notes eval skill graph memory audit-timer uninstall completion version help
172
- complete -c cairn -n "not __fish_seen_subcommand_from bootstrap memory-server sync sync-pi sync-kimi doctor trajectory artifact capabilities notes eval skill graph memory audit-timer uninstall completion version help" -a $command
180
+ for command in bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help
181
+ complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help" -a $command
173
182
  end
174
183
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l apply
175
184
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l live-root -r
@@ -178,6 +187,12 @@ complete -c cairn -n "__fish_seen_subcommand_from sync-pi" -l live-root -r
178
187
  complete -c cairn -n "__fish_seen_subcommand_from sync-kimi" -l apply
179
188
  complete -c cairn -n "__fish_seen_subcommand_from sync-kimi" -l live-root -r
180
189
  complete -c cairn -n "__fish_seen_subcommand_from bootstrap" -l untracked
190
+ complete -c cairn -n "__fish_seen_subcommand_from setup" -l git -r -a "init existing none"
191
+ complete -c cairn -n "__fish_seen_subcommand_from setup" -l harness -r -a "claude opencode pi kimi qwen"
192
+ complete -c cairn -n "__fish_seen_subcommand_from setup" -l memory -r -a "local none"
193
+ complete -c cairn -n "__fish_seen_subcommand_from setup" -l policy -r
194
+ complete -c cairn -n "__fish_seen_subcommand_from setup" -l yes
195
+ complete -c cairn -n "__fish_seen_subcommand_from setup" -l json
181
196
  complete -c cairn -n "__fish_seen_subcommand_from memory" -a "path export import"
182
197
  complete -c cairn -n "__fish_seen_subcommand_from doctor" -l repair
183
198
  complete -c cairn -n "__fish_seen_subcommand_from trajectory" -a "list show prune"
@@ -193,6 +208,17 @@ complete -c cairn -n "__fish_seen_subcommand_from capabilities; and __fish_seen_
193
208
  complete -c cairn -n "__fish_seen_subcommand_from capabilities; and __fish_seen_subcommand_from reset" -l all
194
209
  complete -c cairn -n "__fish_seen_subcommand_from capabilities; and __fish_seen_subcommand_from logging; and not __fish_seen_subcommand_from enable disable reset" -a "enable disable reset"
195
210
  complete -c cairn -n "__fish_seen_subcommand_from capabilities; and __fish_seen_subcommand_from list status enable disable reset logging" -l json
211
+ complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -a "list status set reset"
212
+ complete -c cairn -n "__fish_seen_subcommand_from mcp-tools; and __fish_seen_subcommand_from set" -a "full read-only custom"
213
+ complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -l project -r
214
+ complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -l tool -r
215
+ complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -l json
216
+ complete -c cairn -n "__fish_seen_subcommand_from pack" -a "init lock validate install list show remove enable disable update skills approve-skill revoke-skill"
217
+ complete -c cairn -n "__fish_seen_subcommand_from pack" -l project -r
218
+ complete -c cairn -n "__fish_seen_subcommand_from pack" -l project-id -r
219
+ complete -c cairn -n "__fish_seen_subcommand_from pack" -l ref -r
220
+ complete -c cairn -n "__fish_seen_subcommand_from pack" -l confirm -r
221
+ complete -c cairn -n "__fish_seen_subcommand_from pack" -l json
196
222
  complete -c cairn -n "__fish_seen_subcommand_from notes" -a "distill search-error promote doctor"
197
223
  complete -c cairn -n "__fish_seen_subcommand_from eval; and not __fish_seen_subcommand_from validate run ablate report prune delete" -a "validate run ablate report prune delete"
198
224
  complete -c cairn -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from validate run ablate" -l task-set -r
@@ -234,6 +260,8 @@ complete -c cairn -n "__fish_seen_subcommand_from graph; and __fish_seen_subcomm
234
260
  complete -c cairn -n "__fish_seen_subcommand_from completion" -a "bash zsh fish"
235
261
  complete -c cairn -n "__fish_seen_subcommand_from uninstall" -l pi-live-root -r
236
262
  complete -c cairn -n "__fish_seen_subcommand_from uninstall" -l kimi-live-root -r
263
+ complete -c cairn -n "__fish_seen_subcommand_from uninstall" -l purge-memory
264
+ complete -c cairn -n "__fish_seen_subcommand_from uninstall" -l purge-packs
237
265
  EOF
238
266
  ;;
239
267
  *)
package/scripts/doctor.sh CHANGED
@@ -400,6 +400,102 @@ case "$eval_state" in
400
400
  unsafe) warn "evaluation report storage is unsafe" ;;
401
401
  esac
402
402
 
403
+ # 12. MCP least-authority profile. Invalid or unknown custom tool names are a
404
+ # startup failure, so doctor treats them as an actionable configuration error.
405
+ mcp_profile_cli="$CAIRN_ROOT/mcp-memory-server/dist/mcp-tool-cli.js"
406
+ if [[ ! -f "$mcp_profile_cli" ]]; then
407
+ fail "MCP tool profile checker is missing from the installed package"
408
+ elif mcp_profile_json=$(node "$mcp_profile_cli" status --project "$PWD" --json 2>/dev/null); then
409
+ if mcp_profile_summary=$(node - "$mcp_profile_json" <<'NODE'
410
+ try {
411
+ const value = JSON.parse(process.argv[2]);
412
+ if (value.schema_version !== 1 || !["full", "read-only", "custom"].includes(value.mode)
413
+ || !/^[a-f0-9]{64}$/.test(value.profile_digest) || !Array.isArray(value.allowed_tools)) process.exit(1);
414
+ process.stdout.write(`${value.mode}\t${value.profile_digest}`);
415
+ } catch { process.exit(1); }
416
+ NODE
417
+ ); then
418
+ pass "MCP tool profile $(printf '%s' "$mcp_profile_summary" | cut -f1) ($(printf '%s' "$mcp_profile_summary" | cut -f2))"
419
+ else
420
+ fail "MCP tool profile status returned invalid data"
421
+ fi
422
+ else
423
+ fail "MCP tool profile is invalid (check .ai/mcp-tools.json and CAIRN_MCP_TOOL_PROFILE)"
424
+ fi
425
+
426
+ # 13. Context-pack objects and project pointers are verified even when MCP pack
427
+ # retrieval is disabled. The feature gate controls reads, not integrity checks.
428
+ pack_cli="$CAIRN_ROOT/mcp-memory-server/dist/context-pack-cli.js"
429
+ pack_base="${CAIRN_PACK_BASE_DIR:-$HOME/.cairnkeep/packs}"
430
+ pack_base="${pack_base/#\~/$HOME}"
431
+ if [[ ! -e "$pack_base" ]]; then
432
+ skip "context packs (not installed)"
433
+ elif [[ ! -f "$pack_cli" ]]; then
434
+ fail "context-pack checker is missing from the installed package"
435
+ elif pack_json=$(node "$pack_cli" doctor --json 2>/dev/null) && node -e 'const v=JSON.parse(process.argv[1]); if(v.ok!==true)process.exit(1)' "$pack_json"; then
436
+ pack_counts=$(node -e 'const v=JSON.parse(process.argv[1]); process.stdout.write(`${v.objects} object(s), ${v.projects} project pointer(s)`)' "$pack_json")
437
+ pass "context packs healthy ($pack_counts)"
438
+ else
439
+ fail "context-pack objects or project pointers failed integrity checks"
440
+ fi
441
+
442
+ # 14. Guided setup ownership from .ai/cairnkeep.json is diagnosed without
443
+ # repairing project or machine assets. The Node controller returns only bounded
444
+ # states and exact recovery commands; setup remains the sole owner of project
445
+ # reconciliation.
446
+ setup_diagnosis=$(node --input-type=module - "$CAIRN_ROOT/scripts/setup.mjs" "$PWD" <<'NODE' 2>/dev/null
447
+ import { pathToFileURL } from "node:url";
448
+ import { readFileSync } from "node:fs";
449
+ import { join } from "node:path";
450
+ const module = await import(pathToFileURL(process.argv[2]).href);
451
+ const value = module.diagnoseSetup(process.argv[3]);
452
+ const allowed = new Set(["absent", "complete", "limited", "incomplete"]);
453
+ if (value.schema_version !== 1 || !allowed.has(value.status) || !Array.isArray(value.recovery)) process.exit(1);
454
+ let piSelected = false;
455
+ if (value.status === "complete" || value.status === "limited") {
456
+ const state = JSON.parse(readFileSync(join(process.argv[3], ".ai", "cairnkeep.json"), "utf8"));
457
+ if (!Array.isArray(state.harnesses) || state.harnesses.some((name) => !["claude", "opencode", "pi", "kimi", "qwen"].includes(name))) process.exit(1);
458
+ piSelected = state.harnesses.includes("pi");
459
+ }
460
+ process.stdout.write(`${value.status}:${value.code}:${value.recovery.join("|")}:${piSelected ? "yes" : "no"}`);
461
+ NODE
462
+ ) || setup_diagnosis='incomplete:setup-state:cairn setup . --git existing --harness claude --memory local --yes:no'
463
+ IFS=: read -r setup_status setup_code setup_recovery setup_pi_selected <<< "$setup_diagnosis"
464
+ case "$setup_status:$setup_code" in
465
+ absent:*) skip "project setup state (not configured; legacy bootstrap remains supported)" ;;
466
+ complete:configured) pass "project setup is complete" ;;
467
+ limited:git-disabled) warn "project setup is limited (Git-less mode)" ;;
468
+ incomplete:*)
469
+ fail "project setup state is incomplete; run: ${setup_recovery:-cairn setup . --git existing --harness claude --memory local --yes}"
470
+ ;;
471
+ *) fail "project setup state is incomplete; run: cairn setup . --git existing --harness claude --memory local --yes" ;;
472
+ esac
473
+
474
+ # 15. A selected Pi harness requires the explicitly synchronized local bridge
475
+ # extension and its built child entrypoint. Diagnosis compares bounded bytes
476
+ # only; it never starts the bridge or repairs machine state.
477
+ if [[ "${setup_pi_selected:-no}" == "yes" ]]; then
478
+ pi_source="$CAIRN_ROOT/pi/extensions/cairnkeep-memory.ts"
479
+ pi_bridge="$CAIRN_ROOT/mcp-memory-server/dist/pi-mcp-bridge.js"
480
+ pi_live_root="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
481
+ pi_live="$pi_live_root/extensions/cairnkeep-memory.ts"
482
+ if [[ ! -f "$pi_source" || ! -f "$pi_bridge" ]]; then
483
+ fail "Pi memory bridge build is incomplete; reinstall Cairnkeep or rebuild the memory server"
484
+ elif [[ ! -f "$pi_live" ]]; then
485
+ fail "Pi memory extension cairnkeep-memory.ts is missing; run: cairn sync-pi --apply"
486
+ elif node --input-type=module - "$pi_source" "$pi_live" "$CAIRN_ROOT" <<'NODE' >/dev/null 2>&1
487
+ import { readFileSync } from "node:fs";
488
+ const expected = readFileSync(process.argv[2], "utf8").replaceAll("@@INFRA_ROOT@@", process.argv[4]);
489
+ const current = readFileSync(process.argv[3], "utf8");
490
+ process.exit(expected === current ? 0 : 1);
491
+ NODE
492
+ then
493
+ pass "Pi memory extension is synchronized for the selected project harness"
494
+ else
495
+ fail "Pi memory extension cairnkeep-memory.ts has drifted; run: cairn sync-pi --apply"
496
+ fi
497
+ fi
498
+
403
499
  echo
404
500
  if [[ "$fails" -gt 0 ]]; then
405
501
  echo "cairn doctor: $fails configured dependency check(s) failed."
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { chmodSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ if (process.platform === "win32") process.exit(0);
7
+ const result = spawnSync("git", ["ls-files", "-s"], { encoding: "utf8" });
8
+ if (result.status !== 0) process.exit(result.status ?? 1);
9
+ for (const line of result.stdout.split(/\r?\n/)) {
10
+ const match = line.match(/^100755\s+[0-9a-f]+\s+\d+\t(.+)$/);
11
+ if (match) chmodSync(match[1], 0o755);
12
+ }
@@ -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();