@cairnkeep/cli 2.8.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -1
- package/README.md +82 -27
- package/bin/cairn +6 -153
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +41 -4
- package/docs/containers.md +10 -0
- package/docs/context-packs.md +94 -0
- package/docs/harness-compatibility.md +37 -3
- package/docs/learning/CURRICULUM-MAP.md +16 -5
- package/docs/learning/FEATURE-GUIDE.md +15 -4
- package/docs/learning/README.md +8 -2
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +11 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
- package/docs/learning/lessons/L21-context-packs.md +79 -0
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +138 -0
- package/docs/learning/tracks/operator.md +10 -3
- package/docs/learning/tracks/quickstart.md +7 -3
- package/docs/mcp-tool-profiles.md +43 -0
- package/docs/native-windows.md +122 -0
- package/docs/operating.md +157 -18
- package/docs/privacy-and-data-flow.md +51 -1
- package/docs/storage.md +29 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
- package/mcp-memory-server/dist/artifact-store.js +196 -55
- package/mcp-memory-server/dist/capability-cli.js +2 -1
- package/mcp-memory-server/dist/capability-config.js +4 -3
- package/mcp-memory-server/dist/capability-harness.js +34 -7
- package/mcp-memory-server/dist/context-pack-cli.js +185 -0
- package/mcp-memory-server/dist/context-pack.js +889 -0
- package/mcp-memory-server/dist/eval-cli.js +6 -5
- package/mcp-memory-server/dist/eval-plan.js +35 -13
- package/mcp-memory-server/dist/eval-process.js +11 -4
- package/mcp-memory-server/dist/eval-report.js +8 -5
- package/mcp-memory-server/dist/eval-runner.js +2 -0
- package/mcp-memory-server/dist/eval-schema.js +1 -0
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +87 -37
- package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
- package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
- package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
- package/mcp-memory-server/dist/note-store.js +12 -3
- package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
- package/mcp-memory-server/dist/platform-security.js +102 -0
- package/mcp-memory-server/dist/skill-store.js +27 -12
- package/mcp-memory-server/package.json +4 -1
- package/package.json +5 -4
- package/pi/extensions/cairnkeep-memory.ts +61 -0
- package/schemas/cairnkeep-setup-policy.schema.json +55 -0
- package/schemas/cairnkeep-setup.schema.json +36 -0
- package/schemas/context-pack.schema.json +33 -0
- package/schemas/eval-report.schema.json +1 -0
- package/scripts/cairn-cli.mjs +119 -0
- package/scripts/cairn-container-cli.mjs +121 -0
- package/scripts/cairn-hook.mjs +199 -0
- package/scripts/completion.sh +33 -5
- package/scripts/doctor.sh +96 -0
- package/scripts/fix-permissions.mjs +12 -0
- package/scripts/memory-wiki-audit.mjs +95 -0
- package/scripts/phase26-test-manifest.mjs +26 -0
- package/scripts/run-repository-tests.mjs +76 -0
- package/scripts/setup-core.mjs +310 -0
- package/scripts/setup-reconcile.mjs +331 -0
- package/scripts/setup.mjs +306 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-cli-dispatch.sh +6 -0
- package/scripts/test-completion.sh +44 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-learning-docs.sh +53 -3
- package/scripts/test-package-install.sh +81 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +186 -0
- package/scripts/test-setup-output.sh +131 -0
- package/scripts/test-setup-overlay.sh +113 -0
- package/scripts/test-setup-preflight.mjs +281 -0
- package/scripts/test-setup-reconcile.mjs +257 -0
- package/scripts/test-uninstall.sh +71 -0
- package/scripts/test-windows-native.mjs +136 -0
- package/scripts/uninstall.sh +95 -7
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- package/scripts/windows-platform.mjs +725 -0
- package/templates/env.example.template +11 -0
|
@@ -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 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();
|