@cairnkeep/cli 2.9.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 (78) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +54 -25
  3. package/bin/cairn +6 -165
  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/harness-compatibility.md +30 -2
  8. package/docs/learning/CURRICULUM-MAP.md +9 -3
  9. package/docs/learning/FEATURE-GUIDE.md +9 -3
  10. package/docs/learning/README.md +2 -0
  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 +11 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  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 +138 -0
  20. package/docs/learning/tracks/operator.md +8 -3
  21. package/docs/learning/tracks/quickstart.md +7 -3
  22. package/docs/native-windows.md +122 -0
  23. package/docs/operating.md +119 -18
  24. package/docs/privacy-and-data-flow.md +34 -1
  25. package/docs/storage.md +14 -2
  26. package/examples/eval/bundled-fake.json +1 -1
  27. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  28. package/mcp-memory-server/dist/artifact-store.js +196 -55
  29. package/mcp-memory-server/dist/capability-cli.js +2 -1
  30. package/mcp-memory-server/dist/capability-config.js +4 -3
  31. package/mcp-memory-server/dist/capability-harness.js +34 -7
  32. package/mcp-memory-server/dist/context-pack.js +84 -25
  33. package/mcp-memory-server/dist/eval-cli.js +6 -5
  34. package/mcp-memory-server/dist/eval-plan.js +35 -13
  35. package/mcp-memory-server/dist/eval-process.js +11 -4
  36. package/mcp-memory-server/dist/eval-report.js +7 -5
  37. package/mcp-memory-server/dist/graph-cli.js +7 -1
  38. package/mcp-memory-server/dist/index.js +19 -20
  39. package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
  40. package/mcp-memory-server/dist/note-store.js +12 -3
  41. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  42. package/mcp-memory-server/dist/platform-security.js +102 -0
  43. package/mcp-memory-server/dist/skill-store.js +27 -12
  44. package/mcp-memory-server/package.json +2 -1
  45. package/package.json +5 -4
  46. package/pi/extensions/cairnkeep-memory.ts +61 -0
  47. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  48. package/schemas/cairnkeep-setup.schema.json +36 -0
  49. package/scripts/cairn-cli.mjs +119 -0
  50. package/scripts/cairn-container-cli.mjs +121 -0
  51. package/scripts/cairn-hook.mjs +199 -0
  52. package/scripts/completion.sh +12 -3
  53. package/scripts/doctor.sh +57 -0
  54. package/scripts/fix-permissions.mjs +12 -0
  55. package/scripts/memory-wiki-audit.mjs +95 -0
  56. package/scripts/phase26-test-manifest.mjs +26 -0
  57. package/scripts/run-repository-tests.mjs +76 -0
  58. package/scripts/setup-core.mjs +310 -0
  59. package/scripts/setup-reconcile.mjs +331 -0
  60. package/scripts/setup.mjs +306 -0
  61. package/scripts/sync-pi-assets.sh +19 -4
  62. package/scripts/test-completion.sh +39 -0
  63. package/scripts/test-doctor.sh +66 -0
  64. package/scripts/test-learning-docs.sh +52 -3
  65. package/scripts/test-package-install.sh +64 -0
  66. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  67. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  68. package/scripts/test-pi-lifecycle.mjs +190 -0
  69. package/scripts/test-setup-compatibility.mjs +186 -0
  70. package/scripts/test-setup-output.sh +131 -0
  71. package/scripts/test-setup-overlay.sh +113 -0
  72. package/scripts/test-setup-preflight.mjs +281 -0
  73. package/scripts/test-setup-reconcile.mjs +257 -0
  74. package/scripts/test-uninstall.sh +50 -0
  75. package/scripts/test-windows-native.mjs +136 -0
  76. package/scripts/uninstall.sh +69 -6
  77. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  78. package/scripts/windows-platform.mjs +725 -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 mcp-tools pack 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") ) ;;
@@ -83,6 +84,7 @@ _cairn() {
83
84
  local -a commands
84
85
  commands=(
85
86
  'bootstrap:scaffold a project'
87
+ 'setup:configure selected project harnesses'
86
88
  'memory-server:run the memory MCP server'
87
89
  'sync:install the Claude operating layer'
88
90
  'sync-pi:install the Pi trajectory extension and graph prompt'
@@ -110,6 +112,7 @@ _cairn() {
110
112
  fi
111
113
  case $words[2] in
112
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]' ;;
113
116
  sync) _arguments '--apply[apply changes]' '--live-root[project Claude root]:directory:_files -/' ;;
114
117
  sync-pi) _arguments '--apply[apply changes]' '--live-root[Pi agent root]:directory:_files -/' ;;
115
118
  sync-kimi) _arguments '--apply[apply changes]' '--live-root[Kimi Code root]:directory:_files -/' ;;
@@ -174,8 +177,8 @@ EOF
174
177
  fish)
175
178
  cat <<'EOF'
176
179
  complete -c cairn -f
177
- for command in bootstrap 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
178
- complete -c cairn -n "not __fish_seen_subcommand_from bootstrap 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
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
179
182
  end
180
183
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l apply
181
184
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l live-root -r
@@ -184,6 +187,12 @@ complete -c cairn -n "__fish_seen_subcommand_from sync-pi" -l live-root -r
184
187
  complete -c cairn -n "__fish_seen_subcommand_from sync-kimi" -l apply
185
188
  complete -c cairn -n "__fish_seen_subcommand_from sync-kimi" -l live-root -r
186
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
187
196
  complete -c cairn -n "__fish_seen_subcommand_from memory" -a "path export import"
188
197
  complete -c cairn -n "__fish_seen_subcommand_from doctor" -l repair
189
198
  complete -c cairn -n "__fish_seen_subcommand_from trajectory" -a "list show prune"
package/scripts/doctor.sh CHANGED
@@ -439,6 +439,63 @@ else
439
439
  fail "context-pack objects or project pointers failed integrity checks"
440
440
  fi
441
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
+
442
499
  echo
443
500
  if [[ "$fails" -gt 0 ]]; then
444
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();