@cairnkeep/cli 2.14.0 → 2.15.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 (60) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +22 -2
  3. package/claude/commands/cairn-work.md +19 -0
  4. package/docs/design/team-mode.md +145 -0
  5. package/docs/harness-compatibility.md +11 -10
  6. package/docs/learning/CURRICULUM-MAP.md +4 -1
  7. package/docs/learning/FEATURE-GUIDE.md +8 -4
  8. package/docs/learning/README.md +2 -1
  9. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  10. package/docs/learning/lessons/L01-safe-trial.md +1 -1
  11. package/docs/learning/lessons/L02-installation.md +1 -1
  12. package/docs/learning/lessons/L03-first-project.md +1 -1
  13. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  14. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  15. package/docs/learning/lessons/L21-context-packs.md +1 -1
  16. package/docs/learning/lessons/L22-native-windows.md +1 -1
  17. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  18. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  19. package/docs/learning/lessons/L25-playbooks.md +114 -0
  20. package/docs/learning/tracks/operator.md +5 -2
  21. package/docs/learning/tracks/practitioner.md +5 -3
  22. package/docs/learning/video-scripts/V25-playbooks.md +38 -0
  23. package/docs/native-windows.md +12 -0
  24. package/docs/operating.md +54 -0
  25. package/docs/privacy-and-data-flow.md +23 -0
  26. package/docs/quickstart.md +17 -1
  27. package/docs/storage.md +22 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/kimi/skills/cairn-work/SKILL.md +13 -0
  30. package/mcp-memory-server/dist/playbook-cli.js +321 -0
  31. package/mcp-memory-server/dist/playbook-receipt.js +245 -0
  32. package/mcp-memory-server/dist/playbook-schema.js +56 -0
  33. package/mcp-memory-server/dist/playbook.js +383 -0
  34. package/mcp-memory-server/package.json +2 -1
  35. package/opencode/command/cairn-work.md +13 -0
  36. package/package.json +1 -1
  37. package/pi/prompts/cairn-work.md +12 -0
  38. package/schemas/playbook.schema.json +29 -0
  39. package/scripts/bootstrap.sh +17 -0
  40. package/scripts/cairn-cli.mjs +2 -0
  41. package/scripts/completion.sh +23 -3
  42. package/scripts/doctor.sh +14 -1
  43. package/scripts/playbook-instructions.mjs +108 -0
  44. package/scripts/setup-core.mjs +1 -0
  45. package/scripts/setup.mjs +8 -0
  46. package/scripts/sync-kimi-assets.sh +3 -3
  47. package/scripts/sync-opencode-memory-assets.sh +2 -1
  48. package/scripts/sync-pi-assets.sh +2 -1
  49. package/scripts/test-cli-dispatch.sh +2 -0
  50. package/scripts/test-completion.sh +11 -2
  51. package/scripts/test-graph-harness-adapters.sh +9 -3
  52. package/scripts/test-harness-registry.sh +11 -0
  53. package/scripts/test-learning-docs.sh +23 -2
  54. package/scripts/test-package-install.sh +26 -0
  55. package/scripts/test-playbook-instructions.sh +60 -0
  56. package/scripts/test-windows-native.mjs +7 -0
  57. package/scripts/uninstall.sh +16 -1
  58. package/scripts/windows-platform.mjs +24 -3
  59. package/templates/playbook-agent-instructions.md.template +14 -0
  60. package/templates/playbooks.json.template +5 -0
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from "node:crypto";
3
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+
7
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const TEMPLATE = join(ROOT, "templates", "playbook-agent-instructions.md.template");
9
+ const START = "<!-- cairnkeep:playbook:v1:start -->";
10
+ const END = "<!-- cairnkeep:playbook:v1:end -->";
11
+ const MAX_BYTES = 1024 * 1024;
12
+
13
+ function safeRead(path) {
14
+ const info = lstatSync(path);
15
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BYTES) throw new Error("AGENTS.md is not a safe bounded regular file.");
16
+ let descriptor;
17
+ try {
18
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
19
+ const opened = fstatSync(descriptor);
20
+ if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > MAX_BYTES) throw new Error("AGENTS.md changed during validation.");
21
+ return { text: readFileSync(descriptor, "utf8"), mode: info.mode & 0o777 };
22
+ } finally {
23
+ if (descriptor !== undefined) closeSync(descriptor);
24
+ }
25
+ }
26
+
27
+ function count(text, marker) {
28
+ return text.split(marker).length - 1;
29
+ }
30
+
31
+ function render(current, block) {
32
+ const starts = count(current, START);
33
+ const ends = count(current, END);
34
+ if (starts === 0 && ends === 0) return `${current.trimEnd()}${current.trim() ? "\n\n" : ""}${block.trim()}\n`;
35
+ if (starts !== 1 || ends !== 1) throw new Error("AGENTS.md contains malformed or duplicate Cairnkeep playbook markers.");
36
+ const start = current.indexOf(START);
37
+ const end = current.indexOf(END, start);
38
+ if (end < start) throw new Error("AGENTS.md contains reversed Cairnkeep playbook markers.");
39
+ return `${current.slice(0, start)}${block.trim()}${current.slice(end + END.length)}`.replace(/\s*$/, "\n");
40
+ }
41
+
42
+ function removeBlock(current) {
43
+ const starts = count(current, START);
44
+ const ends = count(current, END);
45
+ if (starts === 0 && ends === 0) return current;
46
+ if (starts !== 1 || ends !== 1) throw new Error("AGENTS.md contains malformed or duplicate Cairnkeep playbook markers.");
47
+ const start = current.indexOf(START);
48
+ const end = current.indexOf(END, start);
49
+ if (end < start) throw new Error("AGENTS.md contains reversed Cairnkeep playbook markers.");
50
+ return `${current.slice(0, start)}${current.slice(end + END.length)}`.replace(/\n{3,}/g, "\n\n").trimEnd();
51
+ }
52
+
53
+ function atomicWrite(path, text, mode) {
54
+ const temporary = join(dirname(path), `.AGENTS.md.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
55
+ try {
56
+ writeFileSync(temporary, text, { encoding: "utf8", mode, flag: "wx" });
57
+ renameSync(temporary, path);
58
+ } finally {
59
+ rmSync(temporary, { force: true });
60
+ }
61
+ }
62
+
63
+ export function reconcilePlaybookInstructions(projectRoot = process.cwd(), options = {}) {
64
+ const root = resolve(projectRoot);
65
+ const path = join(root, "AGENTS.md");
66
+ if (dirname(path) !== root) throw new Error("AGENTS.md path escapes the project root.");
67
+ const block = readFileSync(TEMPLATE, "utf8");
68
+ const existing = existsSync(path) ? safeRead(path) : { text: "", mode: 0o644 };
69
+ const desired = render(existing.text, block);
70
+ const status = !existsSync(path) ? "created" : desired === existing.text ? "unchanged" : "updated";
71
+ if (!options.check && status !== "unchanged") atomicWrite(path, desired, existing.mode || 0o644);
72
+ return Object.freeze({ schema_version: 1, path, status: options.check && status !== "unchanged" ? `would-${status}` : status });
73
+ }
74
+
75
+ export function removePlaybookInstructions(projectRoot = process.cwd(), options = {}) {
76
+ const root = resolve(projectRoot);
77
+ const path = join(root, "AGENTS.md");
78
+ if (!existsSync(path)) return Object.freeze({ schema_version: 1, path, status: "unchanged" });
79
+ const existing = safeRead(path);
80
+ const desired = removeBlock(existing.text);
81
+ const status = desired === existing.text ? "unchanged" : "removed";
82
+ if (!options.check && status === "removed") {
83
+ if (desired.trim()) atomicWrite(path, `${desired}\n`, existing.mode || 0o644);
84
+ else rmSync(path);
85
+ }
86
+ return Object.freeze({ schema_version: 1, path, status: options.check && status === "removed" ? "would-remove" : status });
87
+ }
88
+
89
+ async function main() {
90
+ const args = process.argv.slice(2);
91
+ const check = args.includes("--check");
92
+ const remove = args.includes("--remove");
93
+ const json = args.includes("--json");
94
+ const values = args.filter((arg) => !["--check", "--remove", "--json"].includes(arg));
95
+ if (values.length > 1) throw new Error("Usage: playbook-instructions.mjs [PROJECT] [--check] [--json]");
96
+ const value = remove
97
+ ? removePlaybookInstructions(values[0] ?? process.cwd(), { check })
98
+ : reconcilePlaybookInstructions(values[0] ?? process.cwd(), { check });
99
+ process.stdout.write(`${json ? JSON.stringify(value) : `${value.status}: ${value.path}`}\n`);
100
+ if (check && value.status !== "unchanged") process.exitCode = 1;
101
+ }
102
+
103
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
104
+ main().catch((error) => {
105
+ process.stderr.write(`cairn playbook instructions: ${error instanceof Error ? error.message : String(error)}\n`);
106
+ process.exitCode = 2;
107
+ });
108
+ }
@@ -16,6 +16,7 @@ const COMMON_ASSETS = Object.freeze([
16
16
  ["env.example.template", ".ai/env.example", 0o644],
17
17
  ["trajectory-redaction.json.template", ".ai/trajectory-redaction.json", 0o644],
18
18
  ["capabilities.json.template", ".ai/capabilities.json", 0o600],
19
+ ["playbooks.json.template", ".ai/playbooks.json", 0o600],
19
20
  ["agentfs-gitignore.template", ".agentfs/.gitignore", 0o644],
20
21
  ["planning-config.json.template", ".planning/config.json", 0o644],
21
22
  ["project-brief.md.template", ".planning/PROJECT-BRIEF.md", 0o644],
package/scripts/setup.mjs CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from "./setup-core.mjs";
23
23
  import { reconcileSetupPlan } from "./setup-reconcile.mjs";
24
24
  import { HARNESS_IDS, machineSyncCommand, requiredHarnessAssetPaths } from "./harness-registry.mjs";
25
+ import { reconcilePlaybookInstructions } from "./playbook-instructions.mjs";
25
26
 
26
27
  const HARNESSES = HARNESS_IDS;
27
28
  const GIT_MODES = Object.freeze(["init", "existing", "none"]);
@@ -33,6 +34,7 @@ const COMMON_SETUP_ASSETS = Object.freeze([
33
34
  ".ai/env.example",
34
35
  ".ai/trajectory-redaction.json",
35
36
  ".ai/capabilities.json",
37
+ ".ai/playbooks.json",
36
38
  ".agentfs/.gitignore",
37
39
  ".planning/config.json",
38
40
  ".planning/PROJECT-BRIEF.md",
@@ -186,6 +188,11 @@ async function executeSetup(args, options) {
186
188
  });
187
189
  let plan = buildSetupPlan({ target, preflight, choices });
188
190
  if (options.augmentPlan) plan = await options.augmentPlan(plan);
191
+ try {
192
+ reconcilePlaybookInstructions(plan.target, { check: true });
193
+ } catch (error) {
194
+ throw operational("unsafe-instructions", `Project AGENTS.md cannot be reconciled safely: ${error instanceof Error ? error.message : String(error)}`);
195
+ }
189
196
 
190
197
  if (interactive && !parsed.confirmed) {
191
198
  renderPlan(plan, options.output);
@@ -200,6 +207,7 @@ async function executeSetup(args, options) {
200
207
  createTarget(plan.target);
201
208
  if (plan.git === "init") initializeGit(plan.target);
202
209
  const reconciliation = await reconcileSetupPlan(plan);
210
+ reconcilePlaybookInstructions(plan.target);
203
211
  return resultFor(plan, reconciliation, options.platform);
204
212
  }
205
213
 
@@ -5,7 +5,7 @@ usage() {
5
5
  cat <<'EOF'
6
6
  Usage: sync-kimi-assets.sh [--check|--apply] [--live-root PATH]
7
7
 
8
- Compare or sync Cairnkeep's thin graph Skill against the live Kimi Code tree.
8
+ Compare or sync Cairnkeep's thin managed Skills against the live Kimi Code tree.
9
9
 
10
10
  Options:
11
11
  --check Verify managed live assets match the repo copies (default)
@@ -19,7 +19,7 @@ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
19
19
  SOURCE_ROOT="$ROOT_DIR/kimi"
20
20
  LIVE_ROOT="${KIMI_CODE_HOME:-$HOME/.kimi-code}"
21
21
  MODE="check"
22
- ASSETS=("skills/graphify/SKILL.md")
22
+ ASSETS=("skills/graphify/SKILL.md" "skills/cairn-work/SKILL.md")
23
23
 
24
24
  while [[ $# -gt 0 ]]; do
25
25
  case "$1" in
@@ -74,4 +74,4 @@ if [[ "$MODE" == "apply" ]]; then
74
74
  fi
75
75
 
76
76
  check_assets
77
- printf 'Kimi graph Skill is in sync under %s\n' "$LIVE_ROOT"
77
+ printf 'Kimi managed Skills are in sync under %s\n' "$LIVE_ROOT"
@@ -16,7 +16,7 @@ Options:
16
16
 
17
17
  Notes:
18
18
  - The repo-managed source of truth lives under ./opencode/
19
- - This script manages the memory-sync / memory-review / code-review commands,
19
+ - This script manages the memory-sync / memory-review / code-review / cairn-work commands,
20
20
  the code-reviewer agent, and the memory-review workflow. The memory-wakeup
21
21
  plugin is handled by sync-opencode-plugin-assets.sh.
22
22
  EOF
@@ -33,6 +33,7 @@ ASSETS=(
33
33
  "command/code-review.md"
34
34
  "command/remember.md"
35
35
  "command/recall.md"
36
+ "command/cairn-work.md"
36
37
  "agents/code-reviewer.md"
37
38
  "workflows/memory-review-workflow.md"
38
39
  )
@@ -24,6 +24,7 @@ ASSETS=(
24
24
  "extensions/cairnkeep-memory.ts"
25
25
  "extensions/cairnkeep-trajectory.ts"
26
26
  "prompts/graphify.md"
27
+ "prompts/cairn-work.md"
27
28
  )
28
29
 
29
30
  while [[ $# -gt 0 ]]; do
@@ -105,4 +106,4 @@ if ! check_assets; then
105
106
  echo "Recovery: sync-pi-assets.sh --apply --live-root PATH" >&2
106
107
  exit 1
107
108
  fi
108
- printf 'Pi extension and graph prompt are in sync under %s\n' "$LIVE_ROOT"
109
+ printf 'Pi extensions and managed prompts are in sync under %s\n' "$LIVE_ROOT"
@@ -16,6 +16,7 @@ tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
16
16
  "$cairn" help | grep -q "cairn notes" || fail "help missing notes"
17
17
  "$cairn" help | grep -q "cairn artifact <list|show|delete|prune>" || fail "help missing artifact"
18
18
  "$cairn" help | grep -q "cairn evidence <list|show|delete|prune|doctor>" || fail "help missing evidence"
19
+ "$cairn" help | grep -q "cairn playbook <list|status|init|set|enable|disable|reset|check|record|receipts|instructions|doctor>" || fail "help missing playbook"
19
20
  "$cairn" help | grep -q "cairn capabilities <list|status|enable|disable|reset|logging>" || fail "help missing capabilities"
20
21
  "$cairn" help | grep -q "cairn mcp-tools <list|status|set|reset>" || fail "help missing mcp-tools"
21
22
  "$cairn" help | grep -q "cairn pack <init|lock|validate|install|list|show|remove|enable|disable|update|skills|approve-skill|revoke-skill>" || fail "help missing pack"
@@ -26,6 +27,7 @@ tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
26
27
  env -u CAIRN_NOTE_DISTILLATION "$cairn" notes --help >/dev/null 2>&1 || fail "cairn notes dispatch"
27
28
  "$cairn" artifact --help | grep -q "cairn artifact list" || fail "cairn artifact dispatch"
28
29
  "$cairn" evidence --help | grep -q "cairn evidence list" || fail "cairn evidence dispatch"
30
+ "$cairn" playbook --help | grep -q "cairn playbook list" || fail "cairn playbook dispatch"
29
31
  "$cairn" capabilities --help >"$tmp/capabilities-help" 2>/dev/null || fail "cairn capabilities dispatch"
30
32
  "$cairn" mcp-tools list --json >"$tmp/mcp-tools.json" || fail "cairn mcp-tools dispatch"
31
33
  CAIRN_PACK_BASE_DIR="$tmp/pack-store" "$cairn" pack list --json >"$tmp/packs.json" || fail "cairn pack dispatch"
@@ -22,7 +22,7 @@ for shell in bash zsh fish; do
22
22
  for capability_id in $capability_ids; do
23
23
  grep -qF "$capability_id" "$tmp/$shell"
24
24
  done
25
- if grep -Eq '(^|[[:space:]"'"'"'])(guard|start|finish)([[:space:]"'"'"']|$)' "$tmp/$shell"; then
25
+ if grep -Eq '(^|[[:space:]"'"'"'])guard([[:space:]"'"'"']|$)' "$tmp/$shell"; then
26
26
  echo "completion exposed a private capability operation for $shell" >&2
27
27
  exit 1
28
28
  fi
@@ -34,7 +34,7 @@ for shell in bash zsh fish; do
34
34
  for capability_id in $capability_ids; do
35
35
  grep -qF "$capability_id" "$tmp/$shell"
36
36
  done
37
- if grep -Eq '(^|[[:space:]"'"'"'])(doctor-diagnosis|guard|start|finish)([[:space:]"'"'"']|$)' "$tmp/$shell"; then
37
+ if grep -Eq '(^|[[:space:]"'"'"'])(doctor-diagnosis|guard)([[:space:]"'"'"']|$)' "$tmp/$shell"; then
38
38
  echo "completion exposed a private eval lifecycle operation for $shell" >&2
39
39
  exit 1
40
40
  fi
@@ -51,6 +51,14 @@ for shell in bash zsh fish; do
51
51
  grep -q 'evidence' "$tmp/$shell"
52
52
  grep -q 'list.*show.*delete.*prune.*doctor\|list show delete prune doctor' "$tmp/$shell"
53
53
  grep -q -- '--status\|-l status' "$tmp/$shell"
54
+ grep -q 'playbook' "$tmp/$shell"
55
+ grep -q 'list.*status.*init.*set.*enable.*disable.*reset.*check.*record.*receipts.*instructions.*doctor\|list status init set enable disable reset check record receipts instructions doctor' "$tmp/$shell"
56
+ for playbook_value in minimal balanced strict context.recall verify.tests review.security learning.capture; do
57
+ grep -qF "$playbook_value" "$tmp/$shell"
58
+ done
59
+ for playbook_flag in --project --json --enforce --changed --complexity --familiarity --risk --public-change --completed --skipped --failed; do
60
+ grep -q -- "$playbook_flag\|-l ${playbook_flag#--}" "$tmp/$shell"
61
+ done
54
62
  done
55
63
 
56
64
  "$ROOT/bin/cairn" help >"$tmp/root-help"
@@ -68,6 +76,7 @@ if grep -q 'doctor-diagnosis' "$tmp/eval-help"; then
68
76
  fi
69
77
  grep -qF 'cairn skill <harvest|list|show|review|propose|evaluate|apply|rollback>' "$tmp/root-help"
70
78
  grep -qF 'cairn evidence <list|show|delete|prune|doctor>' "$tmp/root-help"
79
+ grep -qF 'cairn playbook <list|status|init|set|enable|disable|reset|check|record|receipts|instructions|doctor>' "$tmp/root-help"
71
80
  node "$ROOT/mcp-memory-server/dist/skill-cli.js" --help >"$tmp/skill-help"
72
81
  for command in $skill_commands; do
73
82
  grep -q "cairn skill $command" "$tmp/skill-help"
@@ -9,9 +9,13 @@ fail() { echo "FAIL: $1" >&2; exit 1; }
9
9
 
10
10
  PI_SOURCE="$ROOT/pi/prompts/graphify.md"
11
11
  KIMI_SOURCE="$ROOT/kimi/skills/graphify/SKILL.md"
12
+ PI_PLAYBOOK_SOURCE="$ROOT/pi/prompts/cairn-work.md"
13
+ KIMI_PLAYBOOK_SOURCE="$ROOT/kimi/skills/cairn-work/SKILL.md"
12
14
 
13
15
  [[ -f "$PI_SOURCE" ]] || fail "Pi /graphify prompt source is missing"
14
16
  [[ -f "$KIMI_SOURCE" ]] || fail "Kimi graphify skill source is missing"
17
+ [[ -f "$PI_PLAYBOOK_SOURCE" ]] || fail "Pi cairn-work prompt source is missing"
18
+ [[ -f "$KIMI_PLAYBOOK_SOURCE" ]] || fail "Kimi cairn-work Skill source is missing"
15
19
 
16
20
  for adapter in "$PI_SOURCE" "$KIMI_SOURCE"; do
17
21
  grep -qF 'cairn graph' "$adapter" || fail "adapter does not delegate to cairn graph: $adapter"
@@ -31,16 +35,18 @@ PI_LIVE="$tmp/pi"
31
35
  "$ROOT/scripts/sync-pi-assets.sh" --apply --live-root "$PI_LIVE" >"$tmp/pi-first"
32
36
  [[ -f "$PI_LIVE/extensions/cairnkeep-trajectory.ts" ]] || fail "Pi sync omitted trajectory extension"
33
37
  cmp -s "$PI_SOURCE" "$PI_LIVE/prompts/graphify.md" || fail "Pi sync changed graphify prompt bytes"
38
+ cmp -s "$PI_PLAYBOOK_SOURCE" "$PI_LIVE/prompts/cairn-work.md" || fail "Pi sync changed cairn-work prompt bytes"
34
39
  "$ROOT/scripts/sync-pi-assets.sh" --apply --live-root "$PI_LIVE" >"$tmp/pi-second"
35
- grep -qF 'Applied 0 Pi asset(s); 2 already matched.' "$tmp/pi-second" || fail "Pi sync is not idempotent"
40
+ grep -qF 'Applied 0 Pi asset(s); 3 already matched.' "$tmp/pi-second" || fail "Pi sync is not idempotent"
36
41
 
37
42
  KIMI_LIVE="$tmp/kimi"
38
43
  "$ROOT/scripts/sync-kimi-assets.sh" --apply --live-root "$KIMI_LIVE" >"$tmp/kimi-first"
39
44
  cmp -s "$KIMI_SOURCE" "$KIMI_LIVE/skills/graphify/SKILL.md" || fail "Kimi sync changed graphify skill bytes"
45
+ cmp -s "$KIMI_PLAYBOOK_SOURCE" "$KIMI_LIVE/skills/cairn-work/SKILL.md" || fail "Kimi sync changed cairn-work Skill bytes"
40
46
  "$ROOT/scripts/sync-kimi-assets.sh" --apply --live-root "$KIMI_LIVE" >"$tmp/kimi-second"
41
- grep -qF 'Applied 0 Kimi asset(s); 1 already matched.' "$tmp/kimi-second" || fail "Kimi sync is not idempotent"
47
+ grep -qF 'Applied 0 Kimi asset(s); 2 already matched.' "$tmp/kimi-second" || fail "Kimi sync is not idempotent"
42
48
 
43
49
  "$ROOT/bin/cairn" help | grep -qF 'cairn sync-kimi' || fail "CLI help omits sync-kimi"
44
50
  "$ROOT/bin/cairn" sync-kimi --help >/dev/null || fail "CLI does not dispatch sync-kimi"
45
51
 
46
- echo "PASS: thin Pi and Kimi graph adapters delegate only to cairn graph"
52
+ echo "PASS: thin Pi and Kimi graph/playbook adapters are bounded and idempotent"
@@ -13,6 +13,17 @@ import { spawnSync } from "node:child_process";
13
13
  import { pathToFileURL } from "node:url";
14
14
 
15
15
  const [root, sandbox] = process.argv.slice(2);
16
+ for (const path of [
17
+ "claude/commands/cairn-work.md",
18
+ "opencode/command/cairn-work.md",
19
+ "pi/prompts/cairn-work.md",
20
+ "kimi/skills/cairn-work/SKILL.md",
21
+ ]) {
22
+ const content = readFileSync(join(root, path), "utf8");
23
+ assert.match(content, /cairn playbook check/);
24
+ assert.match(content, /must/);
25
+ assert.match(content, /approval/);
26
+ }
16
27
  const registry = await import(pathToFileURL(join(root, "scripts", "harness-registry.mjs")).href);
17
28
  assert.deepEqual(registry.HARNESS_IDS, ["claude", "opencode", "pi", "kimi", "qwen", "codex"]);
18
29
  assert.equal(new Set(registry.HARNESS_IDS).size, registry.HARNESS_IDS.length);
@@ -22,7 +22,7 @@ done
22
22
 
23
23
  ready=0
24
24
  brief=0
25
- for number in $(seq -w 0 24); do
25
+ for number in $(seq -w 0 25); 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 10 && $brief -eq 15 ]] || {
56
+ [[ $ready -eq 11 && $brief -eq 15 ]] || {
57
57
  echo "unexpected learning status totals: ready=$ready brief=$brief" >&2
58
58
  exit 1
59
59
  }
@@ -143,4 +143,25 @@ grep -Eqi 'cancel|shutdown|child process' "$l23"
143
143
  grep -Eqi '0\.84\.1' "$l23"
144
144
  grep -Eqi 'credentials|secrets|private state' "$l23"
145
145
 
146
+ l25=docs/learning/lessons/L25-playbooks.md
147
+ video25=docs/learning/video-scripts/V25-playbooks.md
148
+ for file in "$l25" "$video25"; do
149
+ [[ -f "$file" ]]
150
+ done
151
+ for phrase in \
152
+ 'cairn playbook check start' \
153
+ 'cairn playbook check finish' \
154
+ '--enforce' \
155
+ 'cairn playbook record' \
156
+ 'unauthenticated' \
157
+ 'does not run'; do
158
+ grep -qF -- "$phrase" "$l25" || {
159
+ echo "L25 missing playbook teaching boundary: $phrase" >&2
160
+ exit 1
161
+ }
162
+ done
163
+ grep -qF 'course-13-playbooks' "$coverage"
164
+ grep -qF 'L25-playbooks.md' docs/learning/tracks/practitioner.md
165
+ grep -qF 'L25-playbooks.md' docs/learning/tracks/operator.md
166
+
146
167
  echo "PASS: public learning path structure, readiness, links, and version alignment"
@@ -48,6 +48,8 @@ bootstrap_output=$(cairn bootstrap "$tmp/project")
48
48
  [[ -x "$tmp/project/.ai/start-qwen.sh" ]] || fail "bootstrap did not install an executable Qwen launcher"
49
49
  [[ -x "$tmp/project/.ai/start-codex.sh" ]] || fail "bootstrap did not install an executable Codex launcher"
50
50
  [[ -f "$tmp/project/.planning/config.json" ]] || fail "bootstrap did not install the planning scaffold"
51
+ [[ -f "$tmp/project/.ai/playbooks.json" ]] || fail "bootstrap did not install the playbook policy"
52
+ grep -qF '<!-- cairnkeep:playbook:v1:start -->' "$tmp/project/AGENTS.md" || fail "bootstrap did not install playbook agent guidance"
51
53
 
52
54
  (cd "$tmp/project" && cairn doctor) >/dev/null || fail "installed package failed cairn doctor"
53
55
  env -u CAIRN_NOTE_DISTILLATION cairn notes --help >/dev/null \
@@ -104,6 +106,30 @@ CAIRN_PACK_BASE_DIR="$tmp/installed-packs" cairn pack list --json >"$tmp/install
104
106
  cairn mcp-tools list --json >"$tmp/installed-mcp-tools.json" || \
105
107
  fail "installed package mcp-tools CLI failed"
106
108
 
109
+ for required in \
110
+ schemas/playbook.schema.json \
111
+ templates/playbooks.json.template \
112
+ templates/playbook-agent-instructions.md.template \
113
+ scripts/playbook-instructions.mjs \
114
+ mcp-memory-server/dist/playbook-schema.js \
115
+ mcp-memory-server/dist/playbook.js \
116
+ mcp-memory-server/dist/playbook-receipt.js \
117
+ mcp-memory-server/dist/playbook-cli.js \
118
+ claude/commands/cairn-work.md \
119
+ opencode/command/cairn-work.md \
120
+ pi/prompts/cairn-work.md \
121
+ kimi/skills/cairn-work/SKILL.md \
122
+ docs/design/team-mode.md \
123
+ docs/learning/lessons/L25-playbooks.md \
124
+ docs/learning/video-scripts/V25-playbooks.md
125
+ do
126
+ [[ -f "$installed_root/$required" ]] || fail "npm tarball omitted $required"
127
+ done
128
+ cairn playbook status --project "$tmp/project" --json >"$tmp/installed-playbook-status.json" || \
129
+ fail "installed package playbook status failed"
130
+ cairn playbook check start --project "$tmp/project" --session installed-playbook --json >"$tmp/installed-playbook-check.json" || \
131
+ fail "installed package playbook check failed"
132
+
107
133
  for required in \
108
134
  schemas/skill-adapter.schema.json \
109
135
  schemas/skill-proposal-protocol.schema.json \
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
5
+ tmp=$(mktemp -d)
6
+ trap 'rm -rf "$tmp"' EXIT
7
+ project="$tmp/project"
8
+ mkdir -p "$project"
9
+ printf '# User rules\n\nKeep this line.\n' >"$project/AGENTS.md"
10
+ chmod 0640 "$project/AGENTS.md"
11
+
12
+ node "$ROOT/scripts/playbook-instructions.mjs" "$project" >/dev/null
13
+ grep -qF 'Keep this line.' "$project/AGENTS.md"
14
+ [[ $(grep -cF '<!-- cairnkeep:playbook:v1:start -->' "$project/AGENTS.md") -eq 1 ]]
15
+ [[ $(stat -c '%a' "$project/AGENTS.md") == 640 ]]
16
+ before=$(sha256sum "$project/AGENTS.md" | cut -d' ' -f1)
17
+ node "$ROOT/scripts/playbook-instructions.mjs" "$project" >/dev/null
18
+ after=$(sha256sum "$project/AGENTS.md" | cut -d' ' -f1)
19
+ [[ "$before" == "$after" ]]
20
+ "$ROOT/bin/cairn" playbook instructions check --project "$project" --json >/dev/null
21
+
22
+ node "$ROOT/scripts/playbook-instructions.mjs" "$project" --remove >/dev/null
23
+ grep -qF 'Keep this line.' "$project/AGENTS.md"
24
+ ! grep -qF 'cairnkeep:playbook' "$project/AGENTS.md"
25
+
26
+ printf '%s\n%s\n%s\n' \
27
+ '<!-- cairnkeep:playbook:v1:start -->' \
28
+ '<!-- cairnkeep:playbook:v1:start -->' \
29
+ '<!-- cairnkeep:playbook:v1:end -->' >"$project/AGENTS.md"
30
+ if node "$ROOT/scripts/playbook-instructions.mjs" "$project" >/dev/null 2>&1; then
31
+ echo 'duplicate managed markers were accepted' >&2
32
+ exit 1
33
+ fi
34
+
35
+ rm "$project/AGENTS.md"
36
+ printf 'neighbor\n' >"$tmp/neighbor"
37
+ ln -s "$tmp/neighbor" "$project/AGENTS.md"
38
+ if node "$ROOT/scripts/playbook-instructions.mjs" "$project" >/dev/null 2>&1; then
39
+ echo 'symlink AGENTS.md was accepted' >&2
40
+ exit 1
41
+ fi
42
+ [[ $(cat "$tmp/neighbor") == neighbor ]]
43
+
44
+ for command in bootstrap setup; do
45
+ unsafe="$tmp/unsafe-$command"
46
+ mkdir -p "$unsafe"
47
+ printf '%s\n%s\n' '<!-- cairnkeep:playbook:v1:start -->' '<!-- cairnkeep:playbook:v1:start -->' >"$unsafe/AGENTS.md"
48
+ if [[ "$command" == bootstrap ]]; then
49
+ if "$ROOT/bin/cairn" bootstrap "$unsafe" >/dev/null 2>&1; then
50
+ echo 'bootstrap accepted malformed managed markers' >&2
51
+ exit 1
52
+ fi
53
+ elif "$ROOT/bin/cairn" setup "$unsafe" --git init --harness codex --memory local --yes >/dev/null 2>&1; then
54
+ echo 'setup accepted malformed managed markers' >&2
55
+ exit 1
56
+ fi
57
+ [[ ! -e "$unsafe/.ai" ]]
58
+ done
59
+
60
+ echo 'PASS: ownership-safe playbook AGENTS.md reconciliation'
@@ -28,6 +28,7 @@ try {
28
28
  process.env.USERPROFILE = sandbox;
29
29
  const project = join(sandbox, "Project with spaces – Unicode");
30
30
  mkdirSync(project, { recursive: true });
31
+ writeFileSync(join(project, "AGENTS.md"), "# User rules\n\nKeep this line.\n");
31
32
  bootstrapWindows(root, [project]);
32
33
  for (const harness of HARNESS_IDS) {
33
34
  assert.ok(existsSync(join(project, ".ai", `start-${harness}.cmd`)), `${harness} cmd launcher`);
@@ -38,6 +39,8 @@ try {
38
39
  assert.match(launcherPowerShell(), /post-exit\.ps1/);
39
40
  assert.match(launcherPowerShell(), /evidence run --harness/);
40
41
  assert.match(launcherPowerShell(), /CAIRN_WORK_EVIDENCE/);
42
+ assert.match(readFileSync(join(project, "AGENTS.md"), "utf8"), /cairnkeep:playbook:v1:start/);
43
+ assert.match(readFileSync(join(project, "AGENTS.md"), "utf8"), /Keep this line\./);
41
44
 
42
45
  const claudeRoot = join(sandbox, "Claude Config");
43
46
  await runWindowsCommand({ command: "sync", args: ["--apply", "--live-root", claudeRoot], root });
@@ -59,6 +62,9 @@ try {
59
62
  assert.equal(readFileSync(join(memory, "project.db"), "utf8"), "sqlite-a");
60
63
  assert.match(powershellCompletion(), /Register-ArgumentCompleter/);
61
64
  assert.match(powershellCompletion(), /evidence/);
65
+ assert.match(powershellCompletion(), /playbook/);
66
+ assert.match(powershellCompletion(), /review\.security/);
67
+ assert.match(powershellCompletion(), /--enforce/);
62
68
 
63
69
  let containerArgs = [];
64
70
  runNativeContainer(["stdio", "--image", "example/windows:1", "--volume", "windows-data"], root, (_engine, args) => { containerArgs = args; });
@@ -68,6 +74,7 @@ try {
68
74
 
69
75
  await runWindowsCommand({ command: "uninstall", args: ["--yes", "--live-root", claudeRoot, project], root });
70
76
  assert.ok(!existsSync(join(project, ".ai", "start-claude.cmd")));
77
+ assert.equal(readFileSync(join(project, "AGENTS.md"), "utf8"), "# User rules\n\nKeep this line.\n");
71
78
  assert.ok(!existsSync(join(claudeRoot, "commands", "remember.md")));
72
79
  const cleanedSettings = JSON.parse(readFileSync(join(claudeRoot, "settings.json"), "utf8"));
73
80
  assert.ok(!JSON.stringify(cleanedSettings).includes("memory-wakeup"));
@@ -49,6 +49,7 @@ PROJECT_AI_FILES=(
49
49
  env.example
50
50
  trajectory-redaction.json
51
51
  capabilities.json
52
+ playbooks.json
52
53
  )
53
54
 
54
55
  usage() {
@@ -150,7 +151,7 @@ if (!exact(state, ["schema_version", "cairnkeep_version", "git", "memory", "harn
150
151
  || typeof state.assets !== "object"
151
152
  || Array.isArray(state.assets)) process.exit(1);
152
153
  const allowed = new Set([
153
- ".ai/env.example", ".ai/trajectory-redaction.json", ".ai/capabilities.json",
154
+ ".ai/env.example", ".ai/trajectory-redaction.json", ".ai/capabilities.json", ".ai/playbooks.json",
154
155
  ".agentfs/.gitignore", ".planning/config.json", ".planning/PROJECT-BRIEF.md",
155
156
  ".planning/wiki/index.md", ".planning/wiki/policy.md", ".planning/wiki/CONTRADICTIONS.md",
156
157
  ".planning/wiki/LOG.md", ".planning/alignment/policy.md", ".planning/alignment/gap-register.yaml",
@@ -213,8 +214,10 @@ echo "Pi adapters:"
213
214
  remove_path "$PI_LIVE_ROOT/extensions/cairnkeep-memory.ts"
214
215
  remove_path "$PI_LIVE_ROOT/extensions/cairnkeep-trajectory.ts"
215
216
  remove_path "$PI_LIVE_ROOT/prompts/graphify.md"
217
+ remove_path "$PI_LIVE_ROOT/prompts/cairn-work.md"
216
218
  echo "Kimi adapter:"
217
219
  remove_path "$KIMI_LIVE_ROOT/skills/graphify/SKILL.md"
220
+ remove_path "$KIMI_LIVE_ROOT/skills/cairn-work/SKILL.md"
218
221
 
219
222
  # 2. settings.json hook registrations (back up whole file, then de-register).
220
223
  SETTINGS="$LIVE_ROOT/settings.json"
@@ -310,6 +313,18 @@ for proj in "${PROJECTS[@]:-}"; do
310
313
  [[ -n "$proj" && -d "$proj" ]] || continue
311
314
  proj=$(cd "$proj" && pwd)
312
315
  echo "Project scaffold: $proj"
316
+ if [[ -f "$proj/AGENTS.md" && ! -L "$proj/AGENTS.md" ]] && grep -qF '<!-- cairnkeep:playbook:v1:start -->' "$proj/AGENTS.md" 2>/dev/null; then
317
+ if [[ $DRY_RUN -eq 1 ]]; then
318
+ echo " would remove Cairnkeep playbook block from $proj/AGENTS.md"
319
+ else
320
+ back_up "$proj/AGENTS.md"
321
+ if node "$CAIRN_ROOT/scripts/playbook-instructions.mjs" "$proj" --remove >/dev/null; then
322
+ echo " removed Cairnkeep playbook block from $proj/AGENTS.md"
323
+ else
324
+ echo " kept AGENTS.md: playbook markers could not be removed safely"
325
+ fi
326
+ fi
327
+ fi
313
328
  # Truthy launchers create this project-local root only for the explicit
314
329
  # capability overlay. Back up the complete managed unit so its native Claude
315
330
  # hook registrations and OpenCode plugin bytes restore together exactly.
@@ -21,11 +21,13 @@ import { spawnSync } from "node:child_process";
21
21
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
22
22
  import { homedir, tmpdir } from "node:os";
23
23
  import { HARNESS_IDS, harnessProjectAssets } from "./harness-registry.mjs";
24
+ import { reconcilePlaybookInstructions, removePlaybookInstructions } from "./playbook-instructions.mjs";
24
25
 
25
26
  const BOOTSTRAP_FILES = [
26
27
  ["env.example.template", ".ai/env.example"],
27
28
  ["trajectory-redaction.json.template", ".ai/trajectory-redaction.json"],
28
29
  ["capabilities.json.template", ".ai/capabilities.json"],
30
+ ["playbooks.json.template", ".ai/playbooks.json"],
29
31
  ["agentfs-gitignore.template", ".agentfs/.gitignore"],
30
32
  ["planning-config.json.template", ".planning/config.json"],
31
33
  ["project-brief.md.template", ".planning/PROJECT-BRIEF.md"],
@@ -257,9 +259,11 @@ export function bootstrapWindows(root, args) {
257
259
  prefix = git(["rev-parse", "--show-prefix"], target).replaceAll("\\", "/");
258
260
  }
259
261
 
262
+ if (!options.has("--untracked")) reconcilePlaybookInstructions(target, { check: true });
263
+
260
264
  const templates = join(root, "templates");
261
265
  for (const [source, destination] of BOOTSTRAP_FILES) {
262
- installFile(join(templates, source), join(target, ...destination.split("/")), destination.endsWith("capabilities.json") ? 0o600 : 0o644);
266
+ installFile(join(templates, source), join(target, ...destination.split("/")), /(?:capabilities|playbooks)\.json$/.test(destination) ? 0o600 : 0o644);
263
267
  }
264
268
  for (const harness of HARNESSES) {
265
269
  const asset = harnessProjectAssets(harness, "local")[0];
@@ -271,6 +275,9 @@ export function bootstrapWindows(root, args) {
271
275
  if (!existsSync(modulePath)) atomicWrite(modulePath, launcherModule(), 0o755);
272
276
  const powershellPath = join(target, ".ai", "start-harness.ps1");
273
277
  if (!existsSync(powershellPath)) atomicWrite(powershellPath, launcherPowerShell(), 0o600);
278
+ if (!options.has("--untracked")) {
279
+ reconcilePlaybookInstructions(target);
280
+ }
274
281
 
275
282
  if (excludeFile) {
276
283
  mkdirSync(dirname(excludeFile), { recursive: true });
@@ -456,6 +463,7 @@ async function doctorWindows(root, args) {
456
463
  ["typed memory", "node-cli.js", ["doctor", "--project-root", process.cwd(), ...(repair ? ["--repair"] : [])]],
457
464
  ["capability", "capability-cli.js", ["doctor", "--json"]],
458
465
  ["context pack", "context-pack-cli.js", ["doctor", "--json"]],
466
+ ["playbook", "playbook-cli.js", ["doctor", "--json"]],
459
467
  ]) {
460
468
  const entry = join(root, "mcp-memory-server", "dist", file);
461
469
  if (!existsSync(entry)) { line("FAIL", `${label} diagnostics are unavailable`); continue; }
@@ -627,9 +635,11 @@ function uninstallWindows(root, args) {
627
635
  join(piLive, "extensions", "cairnkeep-memory.ts"),
628
636
  join(piLive, "extensions", "cairnkeep-trajectory.ts"),
629
637
  join(piLive, "prompts", "graphify.md"),
638
+ join(piLive, "prompts", "cairn-work.md"),
630
639
  );
631
640
  const kimiLive = resolve(expandHome(options.get("--kimi-live-root") || process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code")));
632
641
  targets.push(join(kimiLive, "skills", "graphify", "SKILL.md"));
642
+ targets.push(join(kimiLive, "skills", "cairn-work", "SKILL.md"));
633
643
  for (const project of projects) {
634
644
  const base = resolve(project);
635
645
  for (const [, rel] of BOOTSTRAP_FILES) targets.push(join(base, ...rel.split("/")));
@@ -677,6 +687,16 @@ function uninstallWindows(root, args) {
677
687
  console.log(`updated: ${settingsPath}`);
678
688
  }
679
689
  }
690
+ for (const project of projects) {
691
+ const agents = join(resolve(project), "AGENTS.md");
692
+ if (!existsSync(agents)) continue;
693
+ const info = lstatSync(agents);
694
+ if (!info.isFile() || info.isSymbolicLink()) continue;
695
+ if (!readFileSync(agents, "utf8").includes("<!-- cairnkeep:playbook:v1:start -->")) continue;
696
+ backupTarget(agents);
697
+ removePlaybookInstructions(resolve(project));
698
+ console.log(`removed Cairnkeep playbook block: ${agents}`);
699
+ }
680
700
  for (const target of existing) {
681
701
  backupTarget(target);
682
702
  rmSync(target, { recursive: true, force: true });
@@ -708,9 +728,10 @@ export function powershellCompletion() {
708
728
  const harnesses = HARNESS_IDS.map((id) => `'${id}'`).join(",");
709
729
  return `Register-ArgumentCompleter -Native -CommandName cairn -ScriptBlock {
710
730
  param($wordToComplete, $commandAst, $cursorPosition)
711
- $commands = 'bootstrap','setup','memory-server','sync','sync-pi','sync-kimi','doctor','trajectory','artifact','evidence','capabilities','mcp-tools','pack','notes','eval','skill','graph','memory','audit-timer','uninstall','completion','version','help'
731
+ $commands = 'bootstrap','setup','memory-server','sync','sync-pi','sync-kimi','doctor','trajectory','artifact','evidence','playbook','capabilities','mcp-tools','pack','notes','eval','skill','graph','memory','audit-timer','uninstall','completion','version','help'
712
732
  $setup = '--git','--harness','--memory','--policy','--yes','--json','init','existing','none',${harnesses},'local'
713
- $candidates = if ($commandAst.ToString() -match '^\\s*cairn\\s+setup(?:\\s|$)') { $setup } else { $commands }
733
+ $playbook = 'list','status','init','set','enable','disable','reset','check','record','receipts','instructions','doctor','minimal','balanced','strict','must','should','may','off','start','finish','install','remove','context.recall','context.explore','work.plan','verify.tests','review.repository','review.security','docs.update','learning.capture','--project','--json','--enforce','--complexity','--familiarity','--risk','--public-change','--changed','--change-type','--completed','--skipped','--failed','--actor','--actor-kind','--session','--policy','--decision','--event','--action','--outcome','--reason'
734
+ $candidates = if ($commandAst.ToString() -match '^\\s*cairn\\s+setup(?:\\s|$)') { $setup } elseif ($commandAst.ToString() -match '^\\s*cairn\\s+playbook(?:\\s|$)') { $playbook } else { $commands }
714
735
  $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
715
736
  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
716
737
  }