@cairnkeep/cli 2.22.2 → 2.22.3

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 CHANGED
@@ -5,6 +5,17 @@ All notable user-facing changes are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.22.3] - 2026-10-07
9
+
10
+ ### Fixed
11
+
12
+ - Diagnose preserved custom setup files separately from generated-file
13
+ ownership. A complete scaffold no longer fails merely because setup skipped
14
+ caller-owned files or its ownership map is empty. Keep missing/unsafe asset,
15
+ launcher-executable and recorded digest/mode checks; never adopt, rewrite or
16
+ remove custom files during diagnosis. Add cross-platform regression controls
17
+ and clarify operating and guided-setup learning documentation.
18
+
8
19
  ## [2.22.2] - 2026-10-07
9
20
 
10
21
  ### Security
@@ -1,6 +1,6 @@
1
1
  # Curriculum Coverage Map
2
2
 
3
- **Baseline:** Cairnkeep 2.22.2
3
+ **Baseline:** Cairnkeep 2.22.3
4
4
  **Last reviewed:** 2026-10-07
5
5
 
6
6
  This map prevents the course from drifting behind the product. It assigns each
@@ -1,6 +1,6 @@
1
1
  # Cairnkeep feature guide
2
2
 
3
- **Baseline:** Cairnkeep 2.22.2
3
+ **Baseline:** Cairnkeep 2.22.3
4
4
 
5
5
  This is the step-back map for choosing and operating Cairnkeep features. The
6
6
  [course labs](https://github.com/cairnkeep/cairnkeep-course-labs) provide one
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 10 minutes
6
- **Tested with:** Cairnkeep 2.22.2
6
+ **Tested with:** Cairnkeep 2.22.3
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 15 minutes
6
- **Tested with:** Cairnkeep 2.22.2
6
+ **Tested with:** Cairnkeep 2.22.3
7
7
 
8
8
  ## Outcome
9
9
 
@@ -26,7 +26,7 @@ ephemeral; the named course volume is persistent.
26
26
  1. Pull the public image:
27
27
 
28
28
  ```bash
29
- podman pull ghcr.io/cairnkeep/cairnkeep:2.22.2
29
+ podman pull ghcr.io/cairnkeep/cairnkeep:2.22.3
30
30
  ```
31
31
 
32
32
  2. Start the stdio server with a read-only root filesystem and no Linux
@@ -38,7 +38,7 @@ ephemeral; the named course volume is persistent.
38
38
  --read-only --cap-drop=all --security-opt=no-new-privileges \
39
39
  --tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 \
40
40
  --volume cairnkeep-course-data:/data:Z,U \
41
- ghcr.io/cairnkeep/cairnkeep:2.22.2 stdio
41
+ ghcr.io/cairnkeep/cairnkeep:2.22.3 stdio
42
42
  ```
43
43
 
44
44
  3. The process waits quietly for MCP input. Press `Ctrl-C` after confirming it
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 25 minutes
6
- **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
7
7
 
8
8
  ## Outcome
9
9
 
@@ -47,7 +47,7 @@ sync. Codex project trust remains an explicit operator decision.
47
47
  2. Install Cairnkeep:
48
48
 
49
49
  ```bash
50
- npm install --global @cairnkeep/cli@2.22.2
50
+ npm install --global @cairnkeep/cli@2.22.3
51
51
  cairn --version
52
52
  ```
53
53
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 25 minutes
6
- **Tested with:** Cairnkeep 2.22.2
6
+ **Tested with:** Cairnkeep 2.22.3
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Evidence and Evaluation
5
5
  **Time:** 50 minutes
6
- **Tested with:** Cairnkeep 2.22.2
6
+ **Tested with:** Cairnkeep 2.22.3
7
7
 
8
8
  ## Outcome
9
9
 
@@ -1,7 +1,7 @@
1
1
  # L20 - Least-authority MCP tool profiles
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
5
5
  **Time:** 25 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L21 - Immutable context packs and skill approval
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
5
5
  **Time:** 40 minutes
6
6
 
7
7
  ## Outcome
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Operator
5
5
  **Time:** 30 minutes
6
- **Tested with:** Cairnkeep 2.22.2, Windows x64, and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.22.3, Windows x64, and Node.js 22 or newer
7
7
 
8
8
  ## Outcome
9
9
 
@@ -16,7 +16,7 @@ perform a reversible uninstall without relying on WSL or Git Bash.
16
16
  From PowerShell:
17
17
 
18
18
  ```powershell
19
- npm install --global @cairnkeep/cli@2.22.2
19
+ npm install --global @cairnkeep/cli@2.22.3
20
20
  cairn version
21
21
 
22
22
  $Lab = Join-Path $env:TEMP 'Cairnkeep Windows Lab'
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart and Operator
5
5
  **Time:** 35 minutes
6
- **Tested with:** Cairnkeep 2.22.2; Pi 0.84.1; Node.js 22, 24, and 26
6
+ **Tested with:** Cairnkeep 2.22.3; Pi 0.84.1; Node.js 22, 24, and 26
7
7
 
8
8
  ## Outcome
9
9
 
@@ -54,6 +54,13 @@ records the selected Pi harness in private `.ai/cairnkeep.json`, and reports
54
54
  created, updated, unchanged, and skipped assets. Repeating the same `cairn
55
55
  setup` command should report the managed files as unchanged.
56
56
 
57
+ When an existing project has customized launchers or scaffold files, setup
58
+ preserves and skips them. Its private asset map tracks ownership, not a full
59
+ inventory. Doctor accepts safely present custom files without adopting them,
60
+ but still rejects missing files, links, non-executable POSIX launchers and
61
+ changes to recorded owned assets. Do not replace a custom file just to satisfy
62
+ a generated digest. Diagnosis does not change file contents or ownership.
63
+
57
64
  Project setup never changes the Pi installation. Apply and check machine assets
58
65
  explicitly, then diagnose the project again:
59
66
 
@@ -1,7 +1,7 @@
1
1
  # L24 - Reviewed knowledge exchange
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
5
5
  **Time:** 35 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L25 - Bounded workflow playbooks
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
5
5
  **Time:** 35 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L26 - Context intelligence with explicit authority
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
5
5
 
6
6
  ## Outcome
7
7
 
@@ -1,6 +1,6 @@
1
1
  # L27 - Evaluate context companions without moving authority
2
2
 
3
- **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
3
+ **Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
4
4
 
5
5
  ## Outcome
6
6
 
@@ -1,7 +1,7 @@
1
1
  # L28 - Verify Cairnkeep's security posture
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.22.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
5
5
 
6
6
  ## Outcome
7
7
 
@@ -11,7 +11,7 @@ and choose least authority for a network-facing MCP client.
11
11
 
12
12
  ## Prerequisites
13
13
 
14
- - Install Cairnkeep 2.22.2 or use a disposable source checkout.
14
+ - Install Cairnkeep 2.22.3 or use a disposable source checkout.
15
15
  - Complete [L20 - Least-authority MCP tool profiles](L20-mcp-tool-profiles.md).
16
16
  - Use synthetic values only; never paste a real bearer token into a recording,
17
17
  terminal transcript, or issue.
package/docs/operating.md CHANGED
@@ -94,6 +94,12 @@ returns the same schema-v1 result. The private mode-`0600`
94
94
  selected harnesses, and digests/modes/template identifiers for setup-owned
95
95
  assets. It contains no credentials, endpoints, or absolute paths.
96
96
 
97
+ The asset map records ownership, not every file in the project. Existing custom
98
+ files that setup skips remain caller-owned. Doctor checks that every required
99
+ selected asset is a regular file under non-symlinked scaffold directories and
100
+ that POSIX launchers are executable, without adopting or rewriting custom files.
101
+ An empty ownership map is valid when all required assets were already present.
102
+
97
103
  Setup never installs or refreshes machine-level harness assets. Its
98
104
  `machine_sync.automatic` field is always false, and the human output labels each
99
105
  reported command as not run automatically. The JSON result's
@@ -1072,7 +1078,9 @@ Unsupported schema, SQLite failure, invalid full records, or digest corruption
1072
1078
  fails untouched with guidance to preserve the database before manual recovery.
1073
1079
 
1074
1080
  When `.ai/cairnkeep.json` exists, doctor also validates its private state,
1075
- selected assets, digests, and modes. It reports `limited` for explicit
1081
+ selected assets, and the digests and modes of recorded setup-owned files.
1082
+ Preserved caller-owned files need safe presence, not a generated-file digest;
1083
+ doctor never claims ownership of them. It reports `limited` for explicit
1076
1084
  `--git none`, fails an incomplete or drifted setup with the recorded
1077
1085
  deterministic `cairn setup` recovery command, and checks the Pi machine
1078
1086
  extension when Pi was selected. `cairn sync-pi --apply` repairs that
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.22.2","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.22.3","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.22.2",
3
+ "version": "2.22.3",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/scripts/setup.mjs CHANGED
@@ -344,6 +344,21 @@ function safeStateAsset(path, record) {
344
344
  && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(record.template);
345
345
  }
346
346
 
347
+ function safeRequiredAsset(project, path, launcher) {
348
+ // Presence is not ownership: setup intentionally skips pre-existing custom
349
+ // files. Validate the selected asset without adopting or reading its content.
350
+ const parts = path.split("/");
351
+ let destination = project;
352
+ for (const part of parts.slice(0, -1)) {
353
+ destination = join(destination, part);
354
+ const parent = lstatSync(destination);
355
+ if (!parent.isDirectory() || parent.isSymbolicLink()) return false;
356
+ }
357
+ const info = lstatSync(join(destination, parts.at(-1)));
358
+ return info.isFile() && !info.isSymbolicLink()
359
+ && (!launcher || process.platform === "win32" || (info.mode & 0o100) !== 0);
360
+ }
361
+
347
362
  function readPrivateState(path) {
348
363
  const info = lstatSync(path);
349
364
  if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("unsafe");
@@ -435,8 +450,7 @@ export function diagnoseSetup(target = ".") {
435
450
  || state.harnesses.some((name) => !HARNESSES.includes(name))
436
451
  || !state.assets
437
452
  || typeof state.assets !== "object"
438
- || Array.isArray(state.assets)
439
- || Object.keys(state.assets).length === 0) return incompleteDiagnosis();
453
+ || Array.isArray(state.assets)) return incompleteDiagnosis();
440
454
 
441
455
  const recovery = `cairn setup . --git ${state.git} --harness ${state.harnesses.join(",")} --memory ${state.memory} --yes`;
442
456
  for (const [path, record] of Object.entries(state.assets)) {
@@ -449,7 +463,8 @@ export function diagnoseSetup(target = ".") {
449
463
  if (digest !== record.digest || (process.platform !== "win32" && (info.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
450
464
  }
451
465
  const requiredAssets = [...COMMON_SETUP_ASSETS, ...requiredHarnessAssetPaths(state.harnesses, state.memory)];
452
- if (requiredAssets.some((path) => !Object.hasOwn(state.assets, path))) return incompleteDiagnosis(recovery);
466
+ const launchers = new Set(state.harnesses.map((id) => HARNESS_REGISTRY.find((harness) => harness.id === id).launcher.path));
467
+ if (requiredAssets.some((path) => !safeRequiredAsset(project, path, launchers.has(path)))) return incompleteDiagnosis(recovery);
453
468
  if (state.memory === "local" && state.harnesses.includes("codex")) {
454
469
  if (!hasCodexMemoryConfig(project)) return incompleteDiagnosis(recovery);
455
470
  if (hasCodexRemoteMemoryConflict(project)) {
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import assert from "node:assert/strict";
3
+ import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { buildSetupPlan } from "./setup-core.mjs";
7
+ import { reconcileSetupPlan } from "./setup-reconcile.mjs";
8
+ import { diagnoseSetup } from "./setup.mjs";
9
+
10
+ const sandbox = mkdtempSync(join(tmpdir(), "cairn-setup-diagnosis-"));
11
+ try {
12
+ const project = join(sandbox, "project");
13
+ mkdirSync(project);
14
+ const plan = buildSetupPlan({
15
+ target: project,
16
+ preflight: { targetState: "empty", gitExecutable: "available", repository: "work-tree" },
17
+ choices: { git: "existing", harnesses: ["claude", "opencode", "omp"], memory: "local", confirmed: true },
18
+ });
19
+ const customPaths = [".ai/env.example", ".ai/start-claude.sh", ".ai/start-opencode.sh", ".planning/graphs/policy.md"];
20
+ for (const path of customPaths) {
21
+ const file = join(project, path);
22
+ mkdirSync(dirname(file), { recursive: true });
23
+ writeFileSync(file, path.endsWith(".sh") ? "#!/bin/sh\n# Project-owned launcher\nexit 0\n" : "Project-owned configuration\n");
24
+ chmodSync(file, path.endsWith(".sh") ? 0o755 : 0o640);
25
+ }
26
+ const before = new Map(customPaths.map(path => [path, { bytes: readFileSync(join(project, path)), mode: statSync(join(project, path)).mode, mtime: statSync(join(project, path)).mtimeMs }]));
27
+ const result = await reconcileSetupPlan(plan);
28
+ assert.equal(result.counts.skipped, customPaths.length);
29
+ for (const path of customPaths) assert.equal(Object.hasOwn(result.state.assets, path), false, "custom file became setup-owned");
30
+ const statePath = join(project, ".ai/cairnkeep.json");
31
+ const stateBytes = readFileSync(statePath);
32
+ assert.equal(diagnoseSetup(project).status, "complete", "safe preserved custom assets must not make setup incomplete");
33
+ for (const [path, saved] of before) {
34
+ assert.deepEqual(readFileSync(join(project, path)), saved.bytes);
35
+ assert.equal(statSync(join(project, path)).mode, saved.mode);
36
+ assert.equal(statSync(join(project, path)).mtimeMs, saved.mtime);
37
+ }
38
+ assert.deepEqual(readFileSync(statePath), stateBytes, "diagnosis rewrote ownership state");
39
+
40
+ for (const path of customPaths) {
41
+ const file = join(project, path), saved = `${file}.saved`;
42
+ renameSync(file, saved);
43
+ assert.equal(diagnoseSetup(project).status, "incomplete", "missing unowned asset accepted");
44
+ mkdirSync(file);
45
+ assert.equal(diagnoseSetup(project).status, "incomplete", "directory asset accepted");
46
+ rmSync(file, { recursive: true });
47
+ if (process.platform !== "win32") {
48
+ symlinkSync(saved, file);
49
+ assert.equal(diagnoseSetup(project).status, "incomplete", "symlink asset accepted");
50
+ rmSync(file);
51
+ }
52
+ renameSync(saved, file);
53
+ }
54
+ if (process.platform !== "win32") {
55
+ const launcher = join(project, ".ai/start-claude.sh");
56
+ chmodSync(launcher, 0o644);
57
+ assert.equal(diagnoseSetup(project).status, "incomplete", "non-executable custom launcher accepted");
58
+ chmodSync(launcher, 0o755);
59
+ const directory = join(project, ".planning/graphs"), saved = `${directory}.saved`;
60
+ renameSync(directory, saved);
61
+ symlinkSync(saved, directory, "dir");
62
+ assert.equal(diagnoseSetup(project).status, "incomplete", "symlinked asset ancestor accepted");
63
+ rmSync(directory);
64
+ renameSync(saved, directory);
65
+ }
66
+ const owned = join(project, ".ai/start-omp.sh"), ownedBytes = readFileSync(owned);
67
+ writeFileSync(owned, "owned drift\n");
68
+ assert.equal(diagnoseSetup(project).status, "incomplete", "owned digest drift accepted");
69
+ writeFileSync(owned, ownedBytes);
70
+ assert.equal(diagnoseSetup(project).status, "complete");
71
+
72
+ // An ownership map can legitimately be empty when every asset was preserved.
73
+ const unowned = { ...result.state, assets: {} };
74
+ writeFileSync(statePath, JSON.stringify(unowned) + "\n");
75
+ chmodSync(statePath, 0o600);
76
+ assert.equal(diagnoseSetup(project).status, "complete", "all-custom setup falsely requires owned files");
77
+ assert.deepEqual(JSON.parse(readFileSync(statePath)), unowned);
78
+ assert.equal(existsSync(join(project, ".ai/start-omp.sh")), true);
79
+
80
+ const codexProject = join(sandbox, "codex");
81
+ mkdirSync(codexProject);
82
+ const codexPlan = buildSetupPlan({
83
+ target: codexProject,
84
+ preflight: { targetState: "empty", gitExecutable: "available", repository: "work-tree" },
85
+ choices: { git: "existing", harnesses: ["codex"], memory: "local", confirmed: true },
86
+ });
87
+ const codexResult = await reconcileSetupPlan(codexPlan);
88
+ const codexState = join(codexProject, ".ai/cairnkeep.json");
89
+ writeFileSync(codexState, JSON.stringify({ ...codexResult.state, assets: {} }) + "\n");
90
+ chmodSync(codexState, 0o600);
91
+ assert.equal(diagnoseSetup(codexProject).status, "complete");
92
+ writeFileSync(join(codexProject, ".codex/config.toml"), "# Caller-owned but no memory server configured\n");
93
+ assert.equal(diagnoseSetup(codexProject).status, "incomplete", "empty ownership map bypassed Codex MCP validation");
94
+ console.log("PASS: setup diagnosis preserves custom ownership and rejects missing, unsafe, non-executable and drifted assets");
95
+ } finally {
96
+ rmSync(sandbox, { recursive: true, force: true });
97
+ }
@@ -19,6 +19,8 @@ import { runNativeContainer } from "./cairn-container-cli.mjs";
19
19
  import { HARNESS_IDS } from "./harness-registry.mjs";
20
20
 
21
21
  const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
22
+ // Runs on real Windows and POSIX, independently of shell-only doctor checks.
23
+ await import("./test-setup-diagnosis.mjs");
22
24
  const sandbox = mkdtempSync(join(tmpdir(), "cairn-windows-native-"));
23
25
  const originalBase = process.env.CAIRN_AGENTFS_BASE_DIR;
24
26
  const originalPackBase = process.env.CAIRN_PACK_BASE_DIR;