@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,306 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ existsSync,
6
+ fstatSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ } from "node:fs";
12
+ import { createHash } from "node:crypto";
13
+ import { join, resolve } from "node:path";
14
+ import { createInterface } from "node:readline/promises";
15
+ import { stdin as defaultInput, stdout as defaultOutput, stderr as defaultError } from "node:process";
16
+ import {
17
+ buildSetupPlan,
18
+ classifySetupTarget,
19
+ parseSetupArgs,
20
+ readSetupPolicy,
21
+ resolveSetupChoices,
22
+ } from "./setup-core.mjs";
23
+ import { reconcileSetupPlan } from "./setup-reconcile.mjs";
24
+
25
+ const HARNESSES = Object.freeze(["claude", "opencode", "pi", "kimi", "qwen"]);
26
+ const GIT_MODES = Object.freeze(["init", "existing", "none"]);
27
+ const MEMORY_MODES = Object.freeze(["local", "none"]);
28
+ const STATE_KEYS = Object.freeze(["schema_version", "cairnkeep_version", "git", "memory", "harnesses", "assets"]);
29
+ const ASSET_KEYS = Object.freeze(["digest", "mode", "template"]);
30
+ const SAFE_ASSET = /^\.(?:ai|planning|agentfs)\/(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
31
+ const COMMON_SETUP_ASSETS = Object.freeze([
32
+ ".ai/env.example",
33
+ ".ai/trajectory-redaction.json",
34
+ ".ai/capabilities.json",
35
+ ".agentfs/.gitignore",
36
+ ".planning/config.json",
37
+ ".planning/PROJECT-BRIEF.md",
38
+ ".planning/wiki/index.md",
39
+ ".planning/wiki/policy.md",
40
+ ".planning/wiki/CONTRADICTIONS.md",
41
+ ".planning/wiki/LOG.md",
42
+ ".planning/alignment/policy.md",
43
+ ".planning/alignment/gap-register.yaml",
44
+ ".planning/graphs/policy.md",
45
+ ".planning/graphs/.gitignore",
46
+ ".planning/security/policy.md",
47
+ ]);
48
+
49
+ function operational(kind, message) {
50
+ const error = new Error(message);
51
+ error.name = "SetupOperationalError";
52
+ error.kind = kind;
53
+ error.status = 1;
54
+ return error;
55
+ }
56
+
57
+ function writeLine(stream, value = "") {
58
+ stream.write(`${value}\n`);
59
+ }
60
+
61
+ function parseInteractiveHarnesses(value) {
62
+ const selected = value.split(",").map((item) => item.trim()).filter(Boolean);
63
+ if (selected.length === 0 || new Set(selected).size !== selected.length || selected.some((item) => !HARNESSES.includes(item))) {
64
+ throw operational("interactive-input", `Harnesses must be a comma-separated subset of ${HARNESSES.join(", ")}.`);
65
+ }
66
+ return HARNESSES.filter((name) => selected.includes(name));
67
+ }
68
+
69
+ export async function promptSetupChoices(parsed, streams) {
70
+ const terminal = typeof streams.question === "function"
71
+ ? { question: streams.question, close: streams.closePrompt ?? (() => {}) }
72
+ : createInterface({ input: streams.input, output: streams.output });
73
+ try {
74
+ const target = parsed.target ?? (await terminal.question("Target path: ")).trim();
75
+ if (!target) throw operational("interactive-input", "A setup target path is required.");
76
+ const preflight = classifySetupTarget(target);
77
+ const gitQuestion = preflight.targetState === "missing" || preflight.targetState === "empty"
78
+ ? "Git mode (init recommended; choices: init, existing, none): "
79
+ : preflight.repository === "work-tree"
80
+ ? "Git mode (existing recommended; choices: init, existing, none): "
81
+ : "Git mode (init requires explicit choice for existing non-Git target; choices: init, existing, none): ";
82
+ const git = parsed.git ?? (await terminal.question(gitQuestion)).trim();
83
+ const harnesses = parsed.harnesses ?? parseInteractiveHarnesses(await terminal.question(`Harnesses (${HARNESSES.join(", ")}): `));
84
+ const memory = parsed.memory ?? (await terminal.question("Memory mode (local, none): ")).trim();
85
+ return { target, git, harnesses, memory, preflight, terminal };
86
+ } catch (error) {
87
+ terminal.close();
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ function renderPlan(plan, stream) {
93
+ writeLine(stream, `Target: ${plan.target}`);
94
+ writeLine(stream, `Git mode: ${plan.git}`);
95
+ writeLine(stream, `Harnesses: ${plan.harnesses.join(", ")}`);
96
+ writeLine(stream, `Memory mode: ${plan.memory}`);
97
+ writeLine(stream, "Planned assets:");
98
+ for (const asset of plan.assets) writeLine(stream, ` ${asset.path}`);
99
+ }
100
+
101
+ function launchCommands(plan, platform) {
102
+ const suffix = platform === "win32" ? "cmd" : "sh";
103
+ return plan.harnesses.map((harness) => `.ai/start-${harness}.${suffix}`);
104
+ }
105
+
106
+ function setupRecovery(plan) {
107
+ return [`cairn setup . --git ${plan.git} --harness ${plan.harnesses.join(",")} --memory ${plan.memory} --yes`];
108
+ }
109
+
110
+ function resultFor(plan, reconciliation, platform) {
111
+ const machineCommand = plan.harnesses.includes("pi") ? "cairn sync-pi --apply" : "cairn sync --apply";
112
+ return Object.freeze({
113
+ schema_version: 1,
114
+ status: plan.limited ? "limited" : "complete",
115
+ target: plan.target,
116
+ git: plan.git,
117
+ memory: plan.memory,
118
+ harnesses: plan.harnesses,
119
+ counts: reconciliation.counts,
120
+ changes: reconciliation.changes,
121
+ verification: Object.freeze(["cairn doctor"]),
122
+ launch_commands: Object.freeze(launchCommands(plan, platform)),
123
+ recovery: Object.freeze(setupRecovery(plan)),
124
+ limitations: Object.freeze(plan.limited ? ["Git integration is disabled; repository-aware features are limited."] : []),
125
+ machine_sync: Object.freeze({ automatic: false, command: machineCommand }),
126
+ });
127
+ }
128
+
129
+ export function renderSetupResult(result, stream) {
130
+ writeLine(stream, `Setup status: ${result.status}`);
131
+ writeLine(stream, `Harnesses: ${result.harnesses.join(", ")}`);
132
+ writeLine(stream, `Git mode: ${result.git}`);
133
+ writeLine(stream, `Memory mode: ${result.memory}`);
134
+ writeLine(stream, `Created: ${result.counts.created}`);
135
+ writeLine(stream, `Updated: ${result.counts.updated}`);
136
+ writeLine(stream, `Unchanged: ${result.counts.unchanged}`);
137
+ writeLine(stream, `Skipped: ${result.counts.skipped}`);
138
+ writeLine(stream, `Verification: ${result.verification.join(", ")}`);
139
+ for (const command of result.launch_commands) writeLine(stream, `Launch: ${command}`);
140
+ for (const command of result.recovery) writeLine(stream, `Recovery: ${command}`);
141
+ for (const limitation of result.limitations) writeLine(stream, `Limitation: ${limitation}`);
142
+ writeLine(stream, `Machine sync: ${result.machine_sync.command} (not run automatically)`);
143
+ }
144
+
145
+ function createTarget(target) {
146
+ try {
147
+ if (!existsSync(target)) mkdirSync(target, { recursive: true, mode: 0o755 });
148
+ const info = lstatSync(target);
149
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("target is not a regular directory");
150
+ } catch {
151
+ throw operational("target-denied", "Setup target could not be created safely.");
152
+ }
153
+ }
154
+
155
+ function initializeGit(target) {
156
+ const result = spawnSync("git", ["init", "-q", target], { encoding: "utf8", shell: false });
157
+ if (result.error?.code === "ENOENT") throw operational("missing-git", "Git executable is required for --git init.");
158
+ if (result.error || result.status !== 0) throw operational("git-init", "Git repository initialization failed.");
159
+ }
160
+
161
+ async function executeSetup(args, options) {
162
+ const isTTY = options.isTTY ?? Boolean(options.input.isTTY);
163
+ const parsed = parseSetupArgs(args, { isTTY });
164
+ const policy = parsed.policyPath ? readSetupPolicy(parsed.policyPath) : null;
165
+
166
+ let interactive = null;
167
+ let target = parsed.target;
168
+ let preflight = null;
169
+ if (isTTY && (!target || !parsed.git || !parsed.harnesses || !parsed.memory || !parsed.confirmed)) {
170
+ interactive = await promptSetupChoices(parsed, options);
171
+ target = interactive.target;
172
+ preflight = interactive.preflight;
173
+ }
174
+ preflight ??= classifySetupTarget(target);
175
+ const choices = resolveSetupChoices({
176
+ parsed,
177
+ preflight,
178
+ policy,
179
+ interactive: interactive ? { ...interactive, confirmed: true } : null,
180
+ });
181
+ let plan = buildSetupPlan({ target, preflight, choices });
182
+ if (options.augmentPlan) plan = await options.augmentPlan(plan);
183
+
184
+ if (interactive && !parsed.confirmed) {
185
+ renderPlan(plan, options.output);
186
+ const answer = (await interactive.terminal.question("Apply this setup plan? [y/N] ")).trim().toLowerCase();
187
+ interactive.terminal.close();
188
+ if (answer !== "y" && answer !== "yes") {
189
+ writeLine(options.output, "Setup cancelled; no files were changed.");
190
+ return null;
191
+ }
192
+ } else if (interactive) interactive.terminal.close();
193
+
194
+ createTarget(plan.target);
195
+ if (plan.git === "init") initializeGit(plan.target);
196
+ const reconciliation = await reconcileSetupPlan(plan);
197
+ return resultFor(plan, reconciliation, options.platform);
198
+ }
199
+
200
+ export async function runSetup(args, options = {}) {
201
+ const streams = {
202
+ input: options.input ?? defaultInput,
203
+ output: options.output ?? defaultOutput,
204
+ error: options.error ?? defaultError,
205
+ isTTY: options.isTTY,
206
+ platform: options.platform ?? process.platform,
207
+ augmentPlan: options.augmentPlan,
208
+ question: options.question,
209
+ closePrompt: options.closePrompt,
210
+ };
211
+ try {
212
+ const result = await executeSetup(args, streams);
213
+ if (!result) return 0;
214
+ if (args.includes("--json")) writeLine(streams.output, JSON.stringify(result));
215
+ else renderSetupResult(result, streams.output);
216
+ return 0;
217
+ } catch (error) {
218
+ writeLine(streams.error, error instanceof Error ? error.message : "Setup failed.");
219
+ return Number.isInteger(error?.status) ? error.status : 1;
220
+ }
221
+ }
222
+
223
+ function incompleteDiagnosis(recovery = "cairn setup . --git existing --harness claude --memory local --yes") {
224
+ return Object.freeze({ schema_version: 1, status: "incomplete", code: "setup-state", recovery: Object.freeze([recovery]) });
225
+ }
226
+
227
+ function exactKeys(value, expected) {
228
+ return value && typeof value === "object" && !Array.isArray(value)
229
+ && Object.keys(value).sort().join(",") === [...expected].sort().join(",");
230
+ }
231
+
232
+ function safeStateAsset(path, record) {
233
+ return typeof path === "string"
234
+ && path.length <= 512
235
+ && SAFE_ASSET.test(path)
236
+ && !path.includes("\\")
237
+ && !path.split("/").includes("..")
238
+ && exactKeys(record, ASSET_KEYS)
239
+ && typeof record.digest === "string"
240
+ && /^[a-f0-9]{64}$/.test(record.digest)
241
+ && Number.isInteger(record.mode)
242
+ && record.mode >= 0
243
+ && record.mode <= 0o777
244
+ && typeof record.template === "string"
245
+ && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(record.template);
246
+ }
247
+
248
+ function readPrivateState(path) {
249
+ const info = lstatSync(path);
250
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("unsafe");
251
+ if (process.platform !== "win32" && (info.mode & 0o777) !== 0o600) throw new Error("unsafe");
252
+ let descriptor;
253
+ try {
254
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
255
+ const opened = fstatSync(descriptor);
256
+ if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) throw new Error("unsafe");
257
+ return JSON.parse(readFileSync(descriptor, "utf8"));
258
+ } finally {
259
+ if (descriptor !== undefined) closeSync(descriptor);
260
+ }
261
+ }
262
+
263
+ export function diagnoseSetup(target = ".") {
264
+ const project = resolve(target);
265
+ const statePath = join(project, ".ai", "cairnkeep.json");
266
+ if (!existsSync(statePath)) return Object.freeze({ schema_version: 1, status: "absent", code: "not-configured", recovery: Object.freeze([]) });
267
+ try {
268
+ const state = readPrivateState(statePath);
269
+ if (!exactKeys(state, STATE_KEYS)
270
+ || state.schema_version !== 1
271
+ || typeof state.cairnkeep_version !== "string"
272
+ || state.cairnkeep_version.length === 0
273
+ || state.cairnkeep_version.length > 128
274
+ || !GIT_MODES.includes(state.git)
275
+ || !MEMORY_MODES.includes(state.memory)
276
+ || !Array.isArray(state.harnesses)
277
+ || state.harnesses.length === 0
278
+ || new Set(state.harnesses).size !== state.harnesses.length
279
+ || state.harnesses.some((name) => !HARNESSES.includes(name))
280
+ || !state.assets
281
+ || typeof state.assets !== "object"
282
+ || Array.isArray(state.assets)
283
+ || Object.keys(state.assets).length === 0) return incompleteDiagnosis();
284
+
285
+ const recovery = `cairn setup . --git ${state.git} --harness ${state.harnesses.join(",")} --memory ${state.memory} --yes`;
286
+ for (const [path, record] of Object.entries(state.assets)) {
287
+ if (!safeStateAsset(path, record)) return incompleteDiagnosis(recovery);
288
+ const destination = join(project, ...path.split("/"));
289
+ if (!existsSync(destination)) return incompleteDiagnosis(recovery);
290
+ const info = lstatSync(destination);
291
+ if (!info.isFile() || info.isSymbolicLink()) return incompleteDiagnosis(recovery);
292
+ const digest = createHash("sha256").update(readFileSync(destination)).digest("hex");
293
+ if (digest !== record.digest || (process.platform !== "win32" && (info.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
294
+ }
295
+ const requiredAssets = [...COMMON_SETUP_ASSETS, ...state.harnesses.map((harness) => `.ai/start-${harness}.sh`)];
296
+ if (requiredAssets.some((path) => !Object.hasOwn(state.assets, path))) return incompleteDiagnosis(recovery);
297
+ return Object.freeze({
298
+ schema_version: 1,
299
+ status: state.git === "none" ? "limited" : "complete",
300
+ code: state.git === "none" ? "git-disabled" : "configured",
301
+ recovery: Object.freeze([]),
302
+ });
303
+ } catch {
304
+ return incompleteDiagnosis();
305
+ }
306
+ }
@@ -21,6 +21,7 @@ SOURCE_ROOT="$ROOT_DIR/pi"
21
21
  LIVE_ROOT="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
22
22
  MODE="check"
23
23
  ASSETS=(
24
+ "extensions/cairnkeep-memory.ts"
24
25
  "extensions/cairnkeep-trajectory.ts"
25
26
  "prompts/graphify.md"
26
27
  )
@@ -37,7 +38,7 @@ done
37
38
 
38
39
  render_asset() {
39
40
  local rel="$1"
40
- if [[ "$rel" == "extensions/cairnkeep-trajectory.ts" ]]; then
41
+ if [[ "$rel" == extensions/*.ts ]]; then
41
42
  sed "s|@@INFRA_ROOT@@|$ROOT_DIR|g" "$SOURCE_ROOT/$rel"
42
43
  else
43
44
  cat "$SOURCE_ROOT/$rel"
@@ -74,20 +75,34 @@ ensure_sources
74
75
  if [[ "$MODE" == "apply" ]]; then
75
76
  updated=0
76
77
  unchanged=0
78
+ memory_updated=0
79
+ memory_unchanged=0
77
80
  for rel in "${ASSETS[@]}"; do
78
81
  mkdir -p "$(dirname "$LIVE_ROOT/$rel")"
79
82
  if [[ -f "$LIVE_ROOT/$rel" ]] && cmp -s <(render_asset "$rel") "$LIVE_ROOT/$rel"; then
80
- unchanged=$((unchanged + 1))
83
+ if [[ "$rel" == "extensions/cairnkeep-memory.ts" ]]; then
84
+ memory_unchanged=1
85
+ else
86
+ unchanged=$((unchanged + 1))
87
+ fi
81
88
  continue
82
89
  fi
83
90
  rendered_tmp=$(mktemp)
84
91
  render_asset "$rel" > "$rendered_tmp"
85
92
  install -m 0644 "$rendered_tmp" "$LIVE_ROOT/$rel"
86
93
  rm -f "$rendered_tmp"
87
- updated=$((updated + 1))
94
+ if [[ "$rel" == "extensions/cairnkeep-memory.ts" ]]; then
95
+ memory_updated=1
96
+ else
97
+ updated=$((updated + 1))
98
+ fi
88
99
  done
89
100
  printf 'Applied %s Pi asset(s); %s already matched.\n' "$updated" "$unchanged"
101
+ printf 'Pi memory extension: %s updated; %s already matched.\n' "$memory_updated" "$memory_unchanged"
90
102
  fi
91
103
 
92
- check_assets
104
+ if ! check_assets; then
105
+ echo "Recovery: sync-pi-assets.sh --apply --live-root PATH" >&2
106
+ exit 1
107
+ fi
93
108
  printf 'Pi extension and graph prompt are in sync under %s\n' "$LIVE_ROOT"
@@ -118,5 +118,44 @@ if "$ROOT/bin/cairn" completion unsupported >/dev/null 2>&1; then
118
118
  exit 1
119
119
  fi
120
120
 
121
+ completion_surface_complete=true
122
+ for shell in bash zsh fish; do
123
+ if ! grep -qw 'setup' "$tmp/$shell"; then
124
+ completion_surface_complete=false
125
+ fi
126
+ done
127
+ "$ROOT/bin/cairn" completion powershell >"$tmp/powershell"
128
+ if ! grep -qE "['\"]setup['\"]" "$tmp/powershell"; then
129
+ completion_surface_complete=false
130
+ fi
131
+
132
+ if [[ "$completion_surface_complete" != true ]]; then
133
+ if [[ "${CAIRN_PHASE26_RED:-0}" == 1 ]]; then
134
+ echo "PHASE26_RED:SETUP_COMPLETION_MISSING"
135
+ exit 86
136
+ fi
137
+ echo "SKIP: guided setup completion surface is not complete"
138
+ git -C "$ROOT" diff --check
139
+ echo "PASS: shell completion generation"
140
+ exit 0
141
+ fi
142
+
143
+ setup_flags='--git --harness --memory --policy --yes --json'
144
+ setup_values='init existing none claude opencode pi kimi qwen local'
145
+ for shell in bash zsh fish powershell; do
146
+ for flag in $setup_flags; do
147
+ grep -q -- "$flag\|${flag#--}" "$tmp/$shell" || {
148
+ echo "setup completion omitted $flag for $shell" >&2
149
+ exit 1
150
+ }
151
+ done
152
+ for value in $setup_values; do
153
+ grep -qF "$value" "$tmp/$shell" || {
154
+ echo "setup completion omitted $value for $shell" >&2
155
+ exit 1
156
+ }
157
+ done
158
+ done
159
+
121
160
  git -C "$ROOT" diff --check
122
161
  echo "PASS: shell completion generation"
@@ -454,3 +454,69 @@ for sentinel in prompt-sentinel model-output-sentinel adapter-stderr-sentinel en
454
454
  done
455
455
 
456
456
  echo "PASS: cairn doctor (existing probes plus value-free capability/evaluation diagnostics)"
457
+
458
+ phase26_doctor_complete=true
459
+ for required in \
460
+ "$ROOT/scripts/setup.mjs" \
461
+ "$ROOT/schemas/cairnkeep-setup.schema.json" \
462
+ "$ROOT/pi/extensions/cairnkeep-memory.ts"; do
463
+ [[ -f "$required" ]] || phase26_doctor_complete=false
464
+ done
465
+ grep -qF 'cairnkeep.json' "$doctor" || phase26_doctor_complete=false
466
+ grep -qF 'cairnkeep-memory.ts' "$doctor" || phase26_doctor_complete=false
467
+
468
+ if [[ "$phase26_doctor_complete" != true ]]; then
469
+ if [[ "${CAIRN_PHASE26_RED:-0}" == 1 ]]; then
470
+ echo "PHASE26_RED:SETUP_DOCTOR_MISSING"
471
+ exit 86
472
+ fi
473
+ echo "SKIP: setup and Pi doctor diagnostics are not complete"
474
+ exit 0
475
+ fi
476
+
477
+ setup_home="$tmp/setup-home"
478
+ setup_pi="$setup_home/.pi/agent"
479
+ mkdir -p "$setup_home" "$setup_pi"
480
+ HOME="$setup_home" "$ROOT/scripts/sync-pi-assets.sh" --apply --live-root "$setup_pi" >/dev/null
481
+
482
+ complete_project="$tmp/setup-complete"
483
+ HOME="$setup_home" "$ROOT/bin/cairn" setup "$complete_project" \
484
+ --git init --harness claude,pi --memory local --yes --json >/dev/null
485
+ (cd "$complete_project" && HOME="$setup_home" PI_CODING_AGENT_DIR="$setup_pi" "$doctor") >"$tmp/setup-complete.out" 2>&1 \
486
+ || fail "doctor rejected complete guided setup"
487
+ grep -Eq '\[PASS\].*(project )?setup.*complete' "$tmp/setup-complete.out" || fail "doctor omitted complete setup state"
488
+
489
+ limited_project="$tmp/setup-limited"
490
+ HOME="$setup_home" "$ROOT/bin/cairn" setup "$limited_project" \
491
+ --git none --harness claude --memory none --yes --json >/dev/null
492
+ (cd "$limited_project" && HOME="$setup_home" PI_CODING_AGENT_DIR="$setup_pi" "$doctor") >"$tmp/setup-limited.out" 2>&1 \
493
+ || fail "doctor treated intentional Git-less setup as fatal"
494
+ grep -Eq '\[WARN\].*(setup.*limited|Git-less)' "$tmp/setup-limited.out" || fail "doctor omitted Git-less limited state"
495
+
496
+ incomplete_project="$tmp/setup-incomplete"
497
+ mkdir -p "$incomplete_project/.ai"
498
+ printf '%s\n' '{"schema_version":1,"git":"existing","memory":"local","harnesses":["pi"],"assets":{}}' >"$incomplete_project/.ai/cairnkeep.json"
499
+ set +e
500
+ (cd "$incomplete_project" && HOME="$setup_home" PI_CODING_AGENT_DIR="$setup_pi" "$doctor") >"$tmp/setup-incomplete.out" 2>&1
501
+ incomplete_status=$?
502
+ set -e
503
+ [[ "$incomplete_status" -ne 0 ]] || fail "doctor accepted incomplete setup state"
504
+ grep -Eq '\[FAIL\].*(setup.*incomplete|setup state)' "$tmp/setup-incomplete.out" || fail "doctor omitted incomplete setup diagnosis"
505
+ grep -Eq 'cairn setup .*--git|cairn setup --git' "$tmp/setup-incomplete.out" || fail "incomplete setup lacks exact setup recovery"
506
+
507
+ rm -f "$setup_pi/extensions/cairnkeep-memory.ts"
508
+ set +e
509
+ (cd "$complete_project" && HOME="$setup_home" PI_CODING_AGENT_DIR="$setup_pi" "$doctor") >"$tmp/setup-pi-drift.out" 2>&1
510
+ pi_drift_status=$?
511
+ set -e
512
+ [[ "$pi_drift_status" -ne 0 ]] || fail "doctor accepted missing selected Pi memory extension"
513
+ grep -Eq '\[FAIL\].*Pi.*memory extension|\[FAIL\].*cairnkeep-memory' "$tmp/setup-pi-drift.out" || fail "doctor omitted Pi drift diagnosis"
514
+ grep -qF 'cairn sync-pi --apply' "$tmp/setup-pi-drift.out" || fail "Pi drift lacks exact sync recovery"
515
+
516
+ for output in "$tmp/setup-complete.out" "$tmp/setup-limited.out" "$tmp/setup-incomplete.out" "$tmp/setup-pi-drift.out"; do
517
+ if grep -Eqi 'token[=:]|secret[=:]|credential[=:]|https?://' "$output"; then
518
+ fail "setup doctor exposed unbounded private values"
519
+ fi
520
+ done
521
+
522
+ echo "PASS: complete, limited, incomplete, Pi drift, and recovery doctor contract"
@@ -16,13 +16,13 @@ grep -qF "**Baseline:** Cairnkeep $version" "$feature_guide"
16
16
  grep -qF 'https://github.com/cairnkeep/cairnkeep-course-labs' "$landing"
17
17
  for checkpoint in course-00-app course-01-bootstrap course-02-memory course-03-quality \
18
18
  course-04-operation course-05-evidence course-06-governance course-07-evaluation \
19
- course-08-graph course-09-skill course-10-trust-context; do
19
+ course-08-graph course-09-skill course-10-trust-context course-11-windows; do
20
20
  grep -qF "\`$checkpoint\`" "$coverage"
21
21
  done
22
22
 
23
23
  ready=0
24
24
  brief=0
25
- for number in $(seq -w 0 21); do
25
+ for number in $(seq -w 0 22); do
26
26
  matches=(docs/learning/lessons/L"$number"-*.md)
27
27
  [[ ${#matches[@]} -eq 1 && -f ${matches[0]} ]] || {
28
28
  echo "learning path must contain exactly one L$number lesson" >&2
@@ -53,7 +53,7 @@ grep -qF '`cairn sync-kimi --apply`' "$graph_lesson"
53
53
  grep -qF '`cairn sync-pi --apply`' "$graph_lesson"
54
54
  grep -qF '`graphify-out/`' "$graph_lesson"
55
55
 
56
- [[ $ready -eq 7 && $brief -eq 15 ]] || {
56
+ [[ $ready -eq 8 && $brief -eq 15 ]] || {
57
57
  echo "unexpected learning status totals: ready=$ready brief=$brief" >&2
58
58
  exit 1
59
59
  }
@@ -94,4 +94,53 @@ for (const file of markdownFiles("docs/learning")) {
94
94
  }
95
95
  NODE
96
96
 
97
+ l23=docs/learning/lessons/L23-guided-setup.md
98
+ quickstart=docs/learning/tracks/quickstart.md
99
+ operator=docs/learning/tracks/operator.md
100
+ phase26_learning_complete=true
101
+ [[ -f "$l23" ]] || phase26_learning_complete=false
102
+ grep -qF 'L23-guided-setup.md' "$quickstart" || phase26_learning_complete=false
103
+ grep -qF 'L23-guided-setup.md' "$operator" || phase26_learning_complete=false
104
+
105
+ if [[ "$phase26_learning_complete" != true ]]; then
106
+ if [[ "${CAIRN_PHASE26_RED:-0}" == 1 ]]; then
107
+ echo "PHASE26_RED:GUIDED_SETUP_LEARNING_MISSING"
108
+ exit 86
109
+ fi
110
+ echo "SKIP: Ready L23 guided-setup lesson and track routing are not complete"
111
+ echo "PASS: public learning path structure, readiness, links, and version alignment"
112
+ exit 0
113
+ fi
114
+
115
+ grep -q '^\*\*Status:\*\* Ready' "$l23"
116
+ grep -qF "Tested with:** Cairnkeep $version" "$l23"
117
+ for heading in '## Outcome' '## Exercise' '## Recovery exercise' '## Common failures' '## Privacy and trust boundary'; do
118
+ grep -qxF "$heading" "$l23" || {
119
+ echo "L23 missing required heading: $heading" >&2
120
+ exit 1
121
+ }
122
+ done
123
+ for command in \
124
+ 'cairn setup' \
125
+ '--git init' \
126
+ '--git none' \
127
+ '--harness' \
128
+ '--memory' \
129
+ '--yes' \
130
+ 'cairn sync-pi --apply' \
131
+ 'cairn doctor' \
132
+ 'cairn uninstall'; do
133
+ grep -qF -- "$command" "$l23" || {
134
+ echo "L23 missing guided setup command or mode: $command" >&2
135
+ exit 1
136
+ }
137
+ done
138
+ grep -Eqi 'missing|empty' "$l23"
139
+ grep -Eqi 'deterministic|non-interactive|non-TTY' "$l23"
140
+ grep -Eqi 'local.*stdio|stdio.*local' "$l23"
141
+ grep -Eqi 'limited|Git-less' "$l23"
142
+ grep -Eqi 'cancel|shutdown|child process' "$l23"
143
+ grep -Eqi '0\.84\.1' "$l23"
144
+ grep -Eqi 'credentials|secrets|private state' "$l23"
145
+
97
146
  echo "PASS: public learning path structure, readiness, links, and version alignment"
@@ -331,4 +331,68 @@ fi
331
331
  [[ "$(sha256sum "$ROOT/package-lock.json" | cut -d' ' -f1)" == "$root_lock_before" ]] || fail "root lockfile changed during pack/install"
332
332
  [[ "$(sha256sum "$ROOT/mcp-memory-server/package-lock.json" | cut -d' ' -f1)" == "$server_lock_before" ]] || fail "server lockfile changed during pack/install"
333
333
 
334
+ phase26_surface_complete=true
335
+ for required in \
336
+ scripts/setup.mjs \
337
+ scripts/setup-core.mjs \
338
+ scripts/setup-reconcile.mjs \
339
+ schemas/cairnkeep-setup.schema.json \
340
+ schemas/cairnkeep-setup-policy.schema.json \
341
+ mcp-memory-server/dist/pi-mcp-bridge.js \
342
+ pi/extensions/cairnkeep-memory.ts \
343
+ docs/operating.md \
344
+ docs/harness-compatibility.md \
345
+ docs/native-windows.md \
346
+ docs/building-an-overlay.md
347
+ do
348
+ [[ -f "$ROOT/$required" ]] || phase26_surface_complete=false
349
+ done
350
+
351
+ if [[ "$phase26_surface_complete" != true ]]; then
352
+ if [[ "${CAIRN_PHASE26_RED:-0}" == 1 ]]; then
353
+ echo "PHASE26_RED:SETUP_PACKAGE_MISSING"
354
+ exit 86
355
+ fi
356
+ echo "SKIP: packaged guided setup and Pi bridge surface is not complete"
357
+ echo "PASS: npm tarball installs a self-contained CLI and MCP server"
358
+ exit 0
359
+ fi
360
+
361
+ for required in \
362
+ scripts/setup.mjs \
363
+ scripts/setup-core.mjs \
364
+ scripts/setup-reconcile.mjs \
365
+ schemas/cairnkeep-setup.schema.json \
366
+ schemas/cairnkeep-setup-policy.schema.json \
367
+ mcp-memory-server/dist/pi-mcp-bridge.js \
368
+ pi/extensions/cairnkeep-memory.ts \
369
+ docs/operating.md \
370
+ docs/harness-compatibility.md \
371
+ docs/native-windows.md \
372
+ docs/building-an-overlay.md
373
+ do
374
+ [[ -f "$installed_root/$required" ]] || fail "npm tarball omitted Phase 26 asset $required"
375
+ done
376
+
377
+ installed_setup_target="$tmp/Installed Setup – Unicode"
378
+ (cd "$tmp" && cairn setup "$installed_setup_target" \
379
+ --git none --harness claude,pi --memory local --yes --json) >"$tmp/installed-setup.json" \
380
+ || fail "installed cairn setup failed outside the source checkout"
381
+ node - "$tmp/installed-setup.json" "$installed_setup_target" <<'NODE'
382
+ const fs = require("fs");
383
+ const [resultPath, target] = process.argv.slice(2);
384
+ const result = JSON.parse(fs.readFileSync(resultPath, "utf8"));
385
+ if (result.schema_version !== 1 || result.status !== "limited") process.exit(1);
386
+ if (result.git !== "none" || result.memory !== "local") process.exit(1);
387
+ if (JSON.stringify(result.harnesses) !== JSON.stringify(["claude", "pi"])) process.exit(1);
388
+ if (!fs.existsSync(`${target}/.ai/cairnkeep.json`)) process.exit(1);
389
+ NODE
390
+
391
+ if find "$installed_root" -type f \( \
392
+ -name '.env' -o -name '*.tmp' -o -name '*.bak' -o -name 'cairnkeep.json' \
393
+ -o -path '*/.planning/*' -o -path '*/.agentfs/*' \
394
+ \) | grep -q .; then
395
+ fail "npm tarball included temporary or project-private setup state"
396
+ fi
397
+
334
398
  echo "PASS: npm tarball installs a self-contained CLI and MCP server"
@@ -48,7 +48,7 @@ run_shell_syntax_baseline() {
48
48
  local script
49
49
  while IFS= read -r script; do
50
50
  bash -n "$script" || return 1
51
- done < <(find "$ROOT/bin" "$ROOT/scripts" -type f \( -name '*.sh' -o -name 'cairn' \) | sort)
51
+ done < <(find "$ROOT/bin" "$ROOT/scripts" -type f -name '*.sh' | sort)
52
52
  }
53
53
 
54
54
  run_baseline() {
@@ -142,7 +142,7 @@ run_baseline() {
142
142
  run_baseline_check "$ROOT/scripts/test-completion.sh"
143
143
  run_baseline_check "$ROOT/scripts/test-package-install.sh"
144
144
  run_baseline_check "$ROOT/scripts/test-uninstall.sh"
145
- run_baseline_check bash -n "$ROOT/bin/cairn"
145
+ run_baseline_check node --check "$ROOT/bin/cairn"
146
146
  run_master_off_identity
147
147
  echo "PASS: Phase 18 master-off CLI/package/lifecycle identity baseline"
148
148
  }