@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.
- package/CHANGELOG.md +46 -1
- package/README.md +54 -25
- package/bin/cairn +6 -165
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +41 -4
- package/docs/containers.md +10 -0
- package/docs/harness-compatibility.md +30 -2
- package/docs/learning/CURRICULUM-MAP.md +9 -3
- package/docs/learning/FEATURE-GUIDE.md +9 -3
- package/docs/learning/README.md +2 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +11 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +138 -0
- package/docs/learning/tracks/operator.md +8 -3
- package/docs/learning/tracks/quickstart.md +7 -3
- package/docs/native-windows.md +122 -0
- package/docs/operating.md +119 -18
- package/docs/privacy-and-data-flow.md +34 -1
- package/docs/storage.md +14 -2
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
- package/mcp-memory-server/dist/artifact-store.js +196 -55
- package/mcp-memory-server/dist/capability-cli.js +2 -1
- package/mcp-memory-server/dist/capability-config.js +4 -3
- package/mcp-memory-server/dist/capability-harness.js +34 -7
- package/mcp-memory-server/dist/context-pack.js +84 -25
- package/mcp-memory-server/dist/eval-cli.js +6 -5
- package/mcp-memory-server/dist/eval-plan.js +35 -13
- package/mcp-memory-server/dist/eval-process.js +11 -4
- package/mcp-memory-server/dist/eval-report.js +7 -5
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +19 -20
- package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
- package/mcp-memory-server/dist/note-store.js +12 -3
- package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
- package/mcp-memory-server/dist/platform-security.js +102 -0
- package/mcp-memory-server/dist/skill-store.js +27 -12
- package/mcp-memory-server/package.json +2 -1
- package/package.json +5 -4
- package/pi/extensions/cairnkeep-memory.ts +61 -0
- package/schemas/cairnkeep-setup-policy.schema.json +55 -0
- package/schemas/cairnkeep-setup.schema.json +36 -0
- package/scripts/cairn-cli.mjs +119 -0
- package/scripts/cairn-container-cli.mjs +121 -0
- package/scripts/cairn-hook.mjs +199 -0
- package/scripts/completion.sh +12 -3
- package/scripts/doctor.sh +57 -0
- package/scripts/fix-permissions.mjs +12 -0
- package/scripts/memory-wiki-audit.mjs +95 -0
- package/scripts/phase26-test-manifest.mjs +26 -0
- package/scripts/run-repository-tests.mjs +76 -0
- package/scripts/setup-core.mjs +310 -0
- package/scripts/setup-reconcile.mjs +331 -0
- package/scripts/setup.mjs +306 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-completion.sh +39 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-learning-docs.sh +52 -3
- package/scripts/test-package-install.sh +64 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +186 -0
- package/scripts/test-setup-output.sh +131 -0
- package/scripts/test-setup-overlay.sh +113 -0
- package/scripts/test-setup-preflight.mjs +281 -0
- package/scripts/test-setup-reconcile.mjs +257 -0
- package/scripts/test-uninstall.sh +50 -0
- package/scripts/test-windows-native.mjs +136 -0
- package/scripts/uninstall.sh +69 -6
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- 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.
|
package/scripts/completion.sh
CHANGED
|
@@ -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();
|