@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,383 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { constants, existsSync } from "node:fs";
3
+ import { lstat, mkdir, open, realpath, rm, stat } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
6
+ import { PLAYBOOK_ACTION_IDS, PLAYBOOK_CHANGE_TYPES, PLAYBOOK_PROFILES, PLAYBOOK_SCHEMA_VERSION, playbookActorSchema, playbookConfigSchema, playbookEvidenceSchema, playbookSignalsSchema, } from "./playbook-schema.js";
7
+ const MAX_CONFIG_BYTES = 64 * 1024;
8
+ const LOCK_ATTEMPTS = 200;
9
+ const LOCK_WAIT_MS = 10;
10
+ const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
11
+ export const PLAYBOOK_ACTIONS = Object.freeze([
12
+ { id: "context.recall", title: "Recall durable context", command: "/recall <task topic>", description: "Read accepted project memory and derived knowledge before committing to an approach." },
13
+ { id: "context.explore", title: "Explore focused code context", command: "/context-explore <question>", description: "Collect compact citations when the relevant code is unfamiliar or structurally complex." },
14
+ { id: "work.plan", title: "Write a bounded plan", command: "Write and track a concise implementation plan.", description: "Plan non-trivial work before editing and keep the plan proportional to the task." },
15
+ { id: "verify.tests", title: "Run relevant verification", command: "Run the smallest sufficient tests, then broader gates when risk warrants.", description: "Verify changed behavior before claiming completion." },
16
+ { id: "review.repository", title: "Review the repository change", command: "/repo-review", description: "Review bugs, security, and maintainability after material code changes." },
17
+ { id: "review.security", title: "Run the governed security audit", command: "/security-audit", description: "Audit security-sensitive changes through the existing governed workflow." },
18
+ { id: "docs.update", title: "Align documentation", command: "Update and verify affected operating, API, compatibility, and learning documentation.", description: "Keep public behavior and operator guidance synchronized with the implementation." },
19
+ { id: "learning.capture", title: "Capture reviewed learnings", command: "/remember or stage a candidate for /memory-review", description: "Propose stable decisions, pitfalls, and patterns without automatically promoting them." },
20
+ ]);
21
+ const PROFILE_MODES = Object.freeze({
22
+ minimal: Object.freeze({
23
+ "context.recall": "may",
24
+ "context.explore": "may",
25
+ "work.plan": "may",
26
+ "verify.tests": "must",
27
+ "review.repository": "may",
28
+ "review.security": "should",
29
+ "docs.update": "should",
30
+ "learning.capture": "may",
31
+ }),
32
+ balanced: Object.freeze({
33
+ "context.recall": "should",
34
+ "context.explore": "should",
35
+ "work.plan": "should",
36
+ "verify.tests": "must",
37
+ "review.repository": "should",
38
+ "review.security": "must",
39
+ "docs.update": "should",
40
+ "learning.capture": "should",
41
+ }),
42
+ strict: Object.freeze({
43
+ "context.recall": "must",
44
+ "context.explore": "must",
45
+ "work.plan": "must",
46
+ "verify.tests": "must",
47
+ "review.repository": "must",
48
+ "review.security": "must",
49
+ "docs.update": "must",
50
+ "learning.capture": "should",
51
+ }),
52
+ });
53
+ function sha256(value) {
54
+ return createHash("sha256").update(value).digest("hex");
55
+ }
56
+ function delay(milliseconds) {
57
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
58
+ }
59
+ async function canonicalRoot(projectRoot) {
60
+ const root = resolve(projectRoot);
61
+ const canonical = await realpath(root);
62
+ const info = await stat(canonical);
63
+ if (!info.isDirectory())
64
+ throw new Error("Playbook project root is not a directory.");
65
+ return canonical;
66
+ }
67
+ export async function playbookProjectIdentity(projectRoot = process.cwd()) {
68
+ const root = await canonicalRoot(projectRoot);
69
+ const info = await stat(root);
70
+ return sha256(`cairn:playbook-project:v1\0${root}\0${String(info.dev)}\0${String(info.ino)}`);
71
+ }
72
+ async function paths(projectRoot) {
73
+ const root = await canonicalRoot(projectRoot);
74
+ const directory = join(root, ".ai");
75
+ const config = join(directory, "playbooks.json");
76
+ const lock = join(directory, ".playbooks.lock");
77
+ if (dirname(directory) !== root || dirname(config) !== directory || dirname(lock) !== directory) {
78
+ throw new Error("Playbook configuration path is outside the project root.");
79
+ }
80
+ return { root, directory, config, lock };
81
+ }
82
+ async function safeDirectory(directory, create) {
83
+ try {
84
+ const info = await lstat(directory);
85
+ if (!info.isDirectory() || info.isSymbolicLink())
86
+ throw new Error("Playbook configuration directory is unsafe.");
87
+ }
88
+ catch (error) {
89
+ if (error.code !== "ENOENT")
90
+ throw error;
91
+ if (!create)
92
+ return;
93
+ await mkdir(directory, { recursive: true, mode: 0o700 });
94
+ const info = await lstat(directory);
95
+ if (!info.isDirectory() || info.isSymbolicLink())
96
+ throw new Error("Playbook configuration directory is unsafe.");
97
+ }
98
+ }
99
+ async function readBounded(path) {
100
+ let handle;
101
+ try {
102
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
103
+ }
104
+ catch (error) {
105
+ if (error.code === "ENOENT")
106
+ return undefined;
107
+ throw error;
108
+ }
109
+ try {
110
+ const info = await handle.stat();
111
+ if (!info.isFile() || info.size > MAX_CONFIG_BYTES)
112
+ throw new Error("Playbook configuration is invalid.");
113
+ const bytes = await handle.readFile();
114
+ if (bytes.byteLength > MAX_CONFIG_BYTES)
115
+ throw new Error("Playbook configuration is invalid.");
116
+ return bytes;
117
+ }
118
+ finally {
119
+ await handle.close();
120
+ }
121
+ }
122
+ async function readProjectConfig(projectRoot) {
123
+ const { directory, config } = await paths(projectRoot);
124
+ try {
125
+ await safeDirectory(directory, false);
126
+ if (!existsSync(config))
127
+ return { exists: false, issues: [] };
128
+ const info = await lstat(config);
129
+ if (!info.isFile() || info.isSymbolicLink() || !privatePathIsSafe(config)) {
130
+ return { exists: true, issues: ["unsafe-config"] };
131
+ }
132
+ const bytes = await readBounded(config);
133
+ if (!bytes)
134
+ return { exists: false, issues: [] };
135
+ return { config: playbookConfigSchema.parse(JSON.parse(bytes.toString("utf8"))), exists: true, issues: [] };
136
+ }
137
+ catch {
138
+ return { exists: existsSync(config), issues: ["invalid-config"] };
139
+ }
140
+ }
141
+ function orderedOverrides(overrides) {
142
+ const result = {};
143
+ for (const id of PLAYBOOK_ACTION_IDS)
144
+ if (overrides[id] !== undefined)
145
+ result[id] = overrides[id];
146
+ return result;
147
+ }
148
+ function orderedConfig(profile, overrides) {
149
+ return playbookConfigSchema.parse({ schema_version: PLAYBOOK_SCHEMA_VERSION, profile, overrides: orderedOverrides(overrides) });
150
+ }
151
+ export async function resolvePlaybookStatus(options = {}) {
152
+ const projectRoot = options.projectRoot ?? process.cwd();
153
+ const env = options.env ?? process.env;
154
+ const stored = await readProjectConfig(projectRoot);
155
+ const issues = [...stored.issues];
156
+ let profile = stored.config?.profile ?? "balanced";
157
+ let source = stored.config ? "project" : "default";
158
+ const rawEnvironmentProfile = env.CAIRN_PLAYBOOK_PROFILE?.trim();
159
+ if (rawEnvironmentProfile) {
160
+ if (PLAYBOOK_PROFILES.includes(rawEnvironmentProfile)) {
161
+ profile = rawEnvironmentProfile;
162
+ source = "environment";
163
+ }
164
+ else
165
+ issues.push("invalid-environment-profile");
166
+ }
167
+ const overrides = orderedOverrides(stored.config?.overrides ?? {});
168
+ const modes = {};
169
+ for (const id of PLAYBOOK_ACTION_IDS)
170
+ modes[id] = overrides[id] ?? PROFILE_MODES[profile][id];
171
+ const policyDigest = sha256(JSON.stringify({ schema_version: PLAYBOOK_SCHEMA_VERSION, profile, modes: PLAYBOOK_ACTION_IDS.map((id) => [id, modes[id]]) }));
172
+ return {
173
+ schema_version: PLAYBOOK_SCHEMA_VERSION,
174
+ profile,
175
+ source,
176
+ overrides,
177
+ modes,
178
+ policy_digest: policyDigest,
179
+ config_exists: stored.exists,
180
+ issues,
181
+ };
182
+ }
183
+ async function acquireLock(lock) {
184
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
185
+ try {
186
+ await mkdir(lock, { mode: 0o700 });
187
+ return () => rm(lock, { recursive: true, force: true });
188
+ }
189
+ catch (error) {
190
+ if (error.code !== "EEXIST")
191
+ throw error;
192
+ const info = await lstat(lock);
193
+ if (!info.isDirectory() || info.isSymbolicLink())
194
+ throw new Error("Playbook configuration lock is unsafe.");
195
+ await delay(LOCK_WAIT_MS);
196
+ }
197
+ }
198
+ throw new Error("Playbook configuration is locked; retry after the active update finishes.");
199
+ }
200
+ async function atomicConfigWrite(path, value) {
201
+ const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
202
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
203
+ let handle;
204
+ try {
205
+ handle = await open(temporary, "wx", 0o600);
206
+ await handle.writeFile(bytes);
207
+ await handle.sync();
208
+ await handle.close();
209
+ handle = undefined;
210
+ hardenPrivatePath(temporary);
211
+ await atomicReplace(temporary, path);
212
+ hardenPrivatePath(path);
213
+ }
214
+ finally {
215
+ if (handle)
216
+ await handle.close().catch(() => undefined);
217
+ await rm(temporary, { force: true });
218
+ }
219
+ }
220
+ async function mutatePolicy(projectRoot, mutation, requireMissing = false) {
221
+ const target = await paths(projectRoot);
222
+ await safeDirectory(target.directory, true);
223
+ const release = await acquireLock(target.lock);
224
+ try {
225
+ const read = await readProjectConfig(target.root);
226
+ if (read.issues.length)
227
+ throw new Error("Existing playbook configuration is invalid or unsafe.");
228
+ if (requireMissing && read.config)
229
+ throw new Error("Playbook configuration already exists.");
230
+ await atomicConfigWrite(target.config, mutation(read.config));
231
+ }
232
+ finally {
233
+ await release();
234
+ }
235
+ return resolvePlaybookStatus({ projectRoot: target.root, env: {} });
236
+ }
237
+ export async function initializePlaybook(projectRoot, profile) {
238
+ return mutatePolicy(projectRoot, () => orderedConfig(profile, {}), true);
239
+ }
240
+ export async function setPlaybookProfile(projectRoot, profile) {
241
+ return mutatePolicy(projectRoot, (current) => orderedConfig(profile, current?.overrides ?? {}));
242
+ }
243
+ export async function setPlaybookOverride(projectRoot, action, mode) {
244
+ return mutatePolicy(projectRoot, (current) => orderedConfig(current?.profile ?? "balanced", { ...(current?.overrides ?? {}), [action]: mode }));
245
+ }
246
+ export async function resetPlaybookOverride(projectRoot, action) {
247
+ return mutatePolicy(projectRoot, (current) => {
248
+ const overrides = { ...(current?.overrides ?? {}) };
249
+ delete overrides[action];
250
+ return orderedConfig(current?.profile ?? "balanced", overrides);
251
+ });
252
+ }
253
+ export async function resetPlaybook(projectRoot) {
254
+ const target = await paths(projectRoot);
255
+ await safeDirectory(target.directory, false);
256
+ if (!existsSync(target.directory))
257
+ return resolvePlaybookStatus({ projectRoot: target.root, env: {} });
258
+ const release = await acquireLock(target.lock);
259
+ try {
260
+ if (!existsSync(target.config))
261
+ return resolvePlaybookStatus({ projectRoot: target.root, env: {} });
262
+ const info = await lstat(target.config);
263
+ if (!info.isFile() || info.isSymbolicLink())
264
+ throw new Error("Playbook configuration is unsafe.");
265
+ await rm(target.config);
266
+ }
267
+ finally {
268
+ await release();
269
+ }
270
+ return resolvePlaybookStatus({ projectRoot: target.root, env: {} });
271
+ }
272
+ export function normalizeChangedPaths(values) {
273
+ if (values.length > 256)
274
+ throw new Error("Too many changed paths.");
275
+ return [...new Set(values.map((raw) => {
276
+ const value = raw.replaceAll("\\", "/").replace(/^\.\//, "");
277
+ if (!value || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value) || isAbsolute(value)
278
+ || /^[A-Za-z]:\//.test(value) || value.split("/").some((part) => part === ".." || part === "")) {
279
+ throw new Error("Changed paths must be safe project-relative paths.");
280
+ }
281
+ return value;
282
+ }))].sort();
283
+ }
284
+ export function inferChangeTypes(paths, explicit = []) {
285
+ const result = new Set(explicit);
286
+ for (const path of normalizeChangedPaths(paths)) {
287
+ const lower = path.toLowerCase();
288
+ const testFile = /(^|\/)(test|tests|spec|specs|__tests__)(\/|$)|\.(test|spec)\./.test(lower);
289
+ if (testFile)
290
+ result.add("tests");
291
+ if (/(^|\/)(docs?|readme|changelog|license)(\/|\.|$)|\.md$/.test(lower))
292
+ result.add("docs");
293
+ if (/(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|requirements[^/]*\.txt|cargo\.lock|go\.(mod|sum))$/.test(lower))
294
+ result.add("dependencies");
295
+ if (/(^|\/)(auth|security|crypto|permission|permissions|acl|secret|secrets|token|tokens|network|http|cors)(\/|\.|-|_)/.test(lower))
296
+ result.add("security");
297
+ if (/\.(json|ya?ml|toml|ini|conf|config)$|(^|\/)(dockerfile|\.github|\.gitlab-ci\.yml)(\/|$)/.test(lower))
298
+ result.add("config");
299
+ if (!testFile && /\.(c|cc|cpp|cs|go|java|js|jsx|kt|mjs|py|rb|rs|sh|swift|ts|tsx)$/.test(lower))
300
+ result.add("code");
301
+ }
302
+ return PLAYBOOK_CHANGE_TYPES.filter((value) => result.has(value));
303
+ }
304
+ function applicability(id, event, signals) {
305
+ const types = new Set(signals.change_types);
306
+ const materialCode = types.has("code") || types.has("dependencies") || types.has("security");
307
+ switch (id) {
308
+ case "context.recall": return event === "start" ? "Task start benefits from durable project context." : null;
309
+ case "context.explore": return event !== "finish" && (signals.familiarity !== "known" || signals.complexity === "complex")
310
+ ? "Focused exploration applies because the context is not fully known or the task is complex." : null;
311
+ case "work.plan": return event !== "finish" && signals.complexity !== "trivial"
312
+ ? "A bounded plan applies to standard or complex work." : null;
313
+ case "verify.tests": return event === "finish" && materialCode
314
+ ? "Behavior-affecting files changed and require verification." : null;
315
+ case "review.repository": return event === "finish" && materialCode
316
+ ? "Material code, dependency, or security changes warrant repository review." : null;
317
+ case "review.security": return event === "finish" && (types.has("security") || types.has("dependencies") || signals.risk === "security" || signals.risk === "high")
318
+ ? "Security-sensitive paths, dependencies, or elevated risk trigger the governed audit." : null;
319
+ case "docs.update": return event === "finish" && (signals.public_change || types.has("config") || types.has("dependencies"))
320
+ ? "Public behavior, configuration, or dependency behavior may require documentation alignment." : null;
321
+ case "learning.capture": return event === "finish" && (materialCode || signals.complexity !== "trivial")
322
+ ? "Material work should offer stable learnings to the reviewed memory gate." : null;
323
+ }
324
+ }
325
+ function evidenceMap(evidence) {
326
+ const result = new Map();
327
+ for (const entry of evidence) {
328
+ const parsed = playbookEvidenceSchema.parse(entry);
329
+ if (result.has(parsed.action))
330
+ throw new Error(`Duplicate evidence for ${parsed.action}.`);
331
+ result.set(parsed.action, parsed);
332
+ }
333
+ return result;
334
+ }
335
+ export async function evaluatePlaybook(options) {
336
+ if (!SESSION_PATTERN.test(options.sessionId))
337
+ throw new Error("Invalid playbook session ID.");
338
+ const actor = playbookActorSchema.parse(options.actor);
339
+ const signals = playbookSignalsSchema.parse({
340
+ ...options.signals,
341
+ changed_paths: normalizeChangedPaths(options.signals.changed_paths),
342
+ change_types: PLAYBOOK_CHANGE_TYPES.filter((value) => options.signals.change_types.includes(value)),
343
+ });
344
+ const status = await resolvePlaybookStatus({ projectRoot: options.projectRoot, env: options.env });
345
+ const evidence = evidenceMap(options.evidence ?? []);
346
+ const actions = [];
347
+ for (const definition of PLAYBOOK_ACTIONS) {
348
+ const rationale = applicability(definition.id, options.event, signals);
349
+ const mode = status.modes[definition.id];
350
+ if (!rationale || mode === "off")
351
+ continue;
352
+ const observed = evidence.get(definition.id);
353
+ const outcome = observed?.outcome ?? "pending";
354
+ const blocking = mode === "must" && outcome !== "completed";
355
+ actions.push({ ...definition, mode, rationale, outcome, reason: observed?.reason ?? "", blocking });
356
+ }
357
+ const projectIdentity = await playbookProjectIdentity(options.projectRoot);
358
+ const digestInput = {
359
+ schema_version: PLAYBOOK_SCHEMA_VERSION,
360
+ event: options.event,
361
+ project_identity: projectIdentity,
362
+ profile: status.profile,
363
+ policy_digest: status.policy_digest,
364
+ signals,
365
+ actions: actions.map(({ id, mode, outcome, reason }) => ({ id, mode, outcome, reason })),
366
+ };
367
+ const decisionDigest = sha256(JSON.stringify(digestInput));
368
+ return {
369
+ schema_version: PLAYBOOK_SCHEMA_VERSION,
370
+ event: options.event,
371
+ project_identity: projectIdentity,
372
+ actor,
373
+ session_id: options.sessionId,
374
+ profile: status.profile,
375
+ policy_digest: status.policy_digest,
376
+ decision_digest: decisionDigest,
377
+ signals,
378
+ actions,
379
+ blocking_actions: actions.filter(({ blocking }) => blocking).map(({ id }) => id),
380
+ advisory_actions: actions.filter(({ mode, outcome }) => mode !== "must" && outcome === "pending").map(({ id }) => id),
381
+ issues: status.issues,
382
+ };
383
+ }
@@ -51,9 +51,10 @@
51
51
  "check:context-pack": "node scripts/smoke-context-pack.mjs",
52
52
  "check:work-evidence": "node scripts/smoke-work-evidence.mjs",
53
53
  "check:okf": "node scripts/smoke-okf.mjs",
54
+ "check:playbook": "node scripts/smoke-playbook.mjs",
54
55
  "check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
55
56
  "test": "npm run test:smoke",
56
- "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:context-pack && npm run check:work-evidence && npm run check:okf",
57
+ "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:context-pack && npm run check:work-evidence && npm run check:okf && npm run check:playbook",
57
58
  "test:search": "npm run build && npm run check:search",
58
59
  "test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
59
60
  "test:all": "npm run test:memory"
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Evaluate Cairnkeep's project playbook at task start, scope changes, or finish
3
+ ---
4
+
5
+ <!-- managed-by:cairnkeep -->
6
+ # Cairnkeep playbook check
7
+
8
+ Run exactly one local command: `cairn playbook check $ARGUMENTS`.
9
+
10
+ Accept `start`, `check`, or `finish` followed only by documented check options.
11
+ Safely quote every argument. Return the decision and explain which `must`,
12
+ `should`, and `may` actions you will follow. This is policy evaluation only: it
13
+ does not execute an action or grant approval.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
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",
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Evaluate Cairnkeep's bounded workflow playbook
3
+ argument-hint: "start|check|finish [cairn playbook check options]"
4
+ ---
5
+
6
+ <!-- managed-by:cairnkeep -->
7
+ # Cairnkeep playbook check
8
+
9
+ Invoke the shell tool exactly once with `cairn playbook check $ARGUMENTS`.
10
+ Accept only `start`, `check`, or `finish` plus documented check options. Return
11
+ the decision verbatim, then state how you will handle its `must`, `should`, and
12
+ `may` actions. Do not treat the decision as execution or approval.
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/cairnkeep/cairnkeep/main/schemas/playbook.schema.json",
4
+ "title": "Cairnkeep playbook policy v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "profile", "overrides"],
8
+ "properties": {
9
+ "schema_version": { "const": 1 },
10
+ "profile": { "enum": ["minimal", "balanced", "strict"] },
11
+ "overrides": {
12
+ "type": "object",
13
+ "additionalProperties": false,
14
+ "properties": {
15
+ "context.recall": { "$ref": "#/$defs/mode" },
16
+ "context.explore": { "$ref": "#/$defs/mode" },
17
+ "work.plan": { "$ref": "#/$defs/mode" },
18
+ "verify.tests": { "$ref": "#/$defs/mode" },
19
+ "review.repository": { "$ref": "#/$defs/mode" },
20
+ "review.security": { "$ref": "#/$defs/mode" },
21
+ "docs.update": { "$ref": "#/$defs/mode" },
22
+ "learning.capture": { "$ref": "#/$defs/mode" }
23
+ }
24
+ }
25
+ },
26
+ "$defs": {
27
+ "mode": { "enum": ["must", "should", "may", "off"] }
28
+ }
29
+ }
@@ -40,6 +40,18 @@ if [[ $untracked -eq 1 ]]; then
40
40
  exclude_prefix=$(cd "$target" && git rev-parse --show-prefix)
41
41
  fi
42
42
 
43
+ if [[ $untracked -eq 0 ]]; then
44
+ set +e
45
+ node "$CAIRN_ROOT/scripts/playbook-instructions.mjs" "$target" --check >/dev/null 2>&1
46
+ instructions_check=$?
47
+ set -e
48
+ if [[ $instructions_check -gt 1 ]]; then
49
+ echo "Cannot safely reconcile Cairnkeep playbook instructions in $target/AGENTS.md" >&2
50
+ echo "Resolve the managed markers, then retry bootstrap." >&2
51
+ exit 1
52
+ fi
53
+ fi
54
+
43
55
  install_file() {
44
56
  local src="$1" dest="$2" mode="$3"
45
57
  if [[ -e "$dest" ]]; then
@@ -59,6 +71,11 @@ done < <(node "$CAIRN_ROOT/scripts/harness-registry.mjs" bootstrap-assets)
59
71
  install_file "$TPL/env.example.template" "$target/.ai/env.example" 0644
60
72
  install_file "$TPL/trajectory-redaction.json.template" "$target/.ai/trajectory-redaction.json" 0644
61
73
  install_file "$TPL/capabilities.json.template" "$target/.ai/capabilities.json" 0600
74
+ install_file "$TPL/playbooks.json.template" "$target/.ai/playbooks.json" 0600
75
+
76
+ if [[ $untracked -eq 0 ]]; then
77
+ node "$CAIRN_ROOT/scripts/playbook-instructions.mjs" "$target" >/dev/null
78
+ fi
62
79
 
63
80
  # Project-scope memory is private runtime data. Keep it out of version control
64
81
  # even before the first .agentfs/project.db is created.
@@ -18,6 +18,7 @@ const NODE_COMMANDS = new Map([
18
18
  ["skill", "skill-cli.js"],
19
19
  ["graph", "graph-cli.js"],
20
20
  ["evidence", "work-evidence-cli.js"],
21
+ ["playbook", "playbook-cli.js"],
21
22
  ]);
22
23
 
23
24
  const POSIX_COMMANDS = new Map([
@@ -52,6 +53,7 @@ Usage:
52
53
  cairn skill <harvest|list|show|review|propose|evaluate|apply|rollback>
53
54
  cairn graph <build|query|status|diff|explain|path>
54
55
  cairn evidence <list|show|delete|prune|doctor>
56
+ cairn playbook <list|status|init|set|enable|disable|reset|check|record|receipts|instructions|doctor>
55
57
  cairn memory <path|export|import>
56
58
  cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]
57
59
  cairn uninstall [--dry-run] [--yes] [--purge-memory] [--purge-packs] [PROJECT ...]
@@ -13,7 +13,7 @@ _cairn_complete() {
13
13
  COMPREPLY=()
14
14
  current=${COMP_WORDS[COMP_CWORD]}
15
15
  previous=${COMP_WORDS[COMP_CWORD-1]:-}
16
- 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"
16
+ 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"
17
17
  if (( COMP_CWORD == 1 )); then
18
18
  COMPREPLY=( $(compgen -W "$commands" -- "$current") )
19
19
  return
@@ -28,6 +28,7 @@ _cairn_complete() {
28
28
  trajectory) COMPREPLY=( $(compgen -W "list show prune --json --dry-run" -- "$current") ) ;;
29
29
  artifact) COMPREPLY=( $(compgen -W "list show delete prune --kind --session --json --dry-run --include-protected" -- "$current") ) ;;
30
30
  evidence) COMPREPLY=( $(compgen -W "list show delete prune doctor --status --json --dry-run --repair" -- "$current") ) ;;
31
+ playbook) COMPREPLY=( $(compgen -W "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" -- "$current") ) ;;
31
32
  capabilities)
32
33
  case "${COMP_WORDS[2]:-}" in
33
34
  enable|disable)
@@ -96,6 +97,7 @@ _cairn() {
96
97
  'trajectory:inspect and prune local session trajectories'
97
98
  'artifact:inspect, delete, and prune local artifacts'
98
99
  'evidence:inspect and manage local Git-linked work evidence'
100
+ 'playbook:evaluate and record bounded agent workflow policy'
99
101
  'capabilities:inspect and manage project capability state'
100
102
  'mcp-tools:inspect and restrict MCP tool exposure'
101
103
  'pack:manage immutable context packs and skill approvals'
@@ -132,6 +134,7 @@ _cairn() {
132
134
  esac
133
135
  ;;
134
136
  evidence) _values 'work evidence command' list show delete prune doctor '--status[filter records]:status:(pending complete)' '--dry-run[report without mutation]' '--repair[remove safe temporary remnants]' '--json[emit JSON]' ;;
137
+ playbook) _values 'playbook command' 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[project root]:directory:_files -/' '--json[emit JSON]' '--enforce[fail when must evidence is missing]' '--changed[changed project path]:file:_files' '--change-type[change type]:type:(code tests docs config dependencies security)' '--complexity[task complexity]:level:(trivial standard complex)' '--familiarity[context familiarity]:level:(known mixed unfamiliar)' '--risk[task risk]:level:(low normal high security)' '--public-change[public behavior changed]' '--completed[completed action]:action:' '--skipped[skipped action and reason]:evidence:' '--failed[failed action and reason]:evidence:' '--actor[local actor label]:actor:' '--actor-kind[local actor kind]:kind:(user agent service)' '--session[session label]:session:' '--policy[policy digest]:digest:' '--decision[decision digest]:digest:' '--event[lifecycle event]:event:(start check finish)' '--action[canonical action]:action:(context.recall context.explore work.plan verify.tests review.repository review.security docs.update learning.capture)' '--outcome[outcome]:outcome:(completed skipped failed)' '--reason[bounded reason]:reason:' ;;
135
138
  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]' ;;
136
139
  pack) _values 'context pack command' init lock validate install import-okf validate-okf export-okf 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:' '--output[export output directory]:directory:_files -/' '--file[explicit project Markdown file]:file:_files' '--note[promoted shared note ID]:note ID:' '--check[inspect update or export]' '--apply[apply update or export]' '--confirm[confirm digest]:digest:' '--json[emit JSON]' ;;
137
140
  capabilities)
@@ -182,8 +185,8 @@ EOF
182
185
  fish)
183
186
  sed "s/__CAIRN_HARNESSES__/$harnesses/g" <<'EOF'
184
187
  complete -c cairn -f
185
- for command in 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
186
- complete -c cairn -n "not __fish_seen_subcommand_from 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" -a $command
188
+ for command in 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
189
+ complete -c cairn -n "not __fish_seen_subcommand_from 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" -a $command
187
190
  end
188
191
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l apply
189
192
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l live-root -r
@@ -212,6 +215,23 @@ complete -c cairn -n "__fish_seen_subcommand_from evidence; and __fish_seen_subc
212
215
  complete -c cairn -n "__fish_seen_subcommand_from evidence; and __fish_seen_subcommand_from list show delete prune doctor" -l json
213
216
  complete -c cairn -n "__fish_seen_subcommand_from evidence; and __fish_seen_subcommand_from delete prune" -l dry-run
214
217
  complete -c cairn -n "__fish_seen_subcommand_from evidence; and __fish_seen_subcommand_from doctor" -l repair
218
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and not __fish_seen_subcommand_from list status init set enable disable reset check record receipts instructions doctor" -a "list status init set enable disable reset check record receipts instructions doctor"
219
+ complete -c cairn -n "__fish_seen_subcommand_from playbook" -l project -r
220
+ complete -c cairn -n "__fish_seen_subcommand_from playbook" -l json
221
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from init set" -a "minimal balanced strict"
222
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -a "start check finish"
223
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -l enforce
224
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -l changed -r
225
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from enable disable reset" -a "context.recall context.explore work.plan verify.tests review.repository review.security docs.update learning.capture"
226
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from enable" -a "must should may"
227
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from instructions" -a "install check remove"
228
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -l complexity -r -a "trivial standard complex"
229
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -l familiarity -r -a "known mixed unfamiliar"
230
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -l risk -r -a "low normal high security"
231
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -l public-change
232
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -l completed -r
233
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -l skipped -r
234
+ complete -c cairn -n "__fish_seen_subcommand_from playbook; and __fish_seen_subcommand_from check" -l failed -r
215
235
  complete -c cairn -n "__fish_seen_subcommand_from capabilities; and not __fish_seen_subcommand_from list status enable disable reset logging" -a "list status enable disable reset logging"
216
236
  complete -c cairn -n "__fish_seen_subcommand_from capabilities; and __fish_seen_subcommand_from enable disable; and not __fish_seen_subcommand_from memory.write memory.search notes.distill wiki graph security.audit route.check context.explore" -a "memory.write memory.search notes.distill wiki graph security.audit route.check context.explore"
217
237
  complete -c cairn -n "__fish_seen_subcommand_from capabilities; and __fish_seen_subcommand_from reset; and not __fish_seen_subcommand_from memory.write memory.search notes.distill wiki graph security.audit route.check context.explore" -a "memory.write memory.search notes.distill wiki graph security.audit route.check context.explore"
package/scripts/doctor.sh CHANGED
@@ -230,7 +230,20 @@ try {
230
230
  [[ $evidence_status -eq 0 || "$evidence_state" == "broken" ]] || true
231
231
  fi
232
232
 
233
- # 10. Typed metadata overlays and canonical-note transaction state. The internal
233
+ # 10. Project playbook policy and private receipts. Absence is healthy because
234
+ # the built-in balanced profile is the compatibility default.
235
+ playbook_cli="$CAIRN_ROOT/mcp-memory-server/dist/playbook-cli.js"
236
+ if [[ ! -f "$playbook_cli" ]]; then
237
+ fail "playbook diagnostics unavailable — rebuild mcp-memory-server"
238
+ elif playbook_json=$(node "$playbook_cli" doctor --project "$PWD" --json 2>/dev/null) \
239
+ && node -e 'const v=JSON.parse(process.argv[1]);if(v.schema_version!==1||v.ok!==true)process.exit(1)' "$playbook_json"; then
240
+ playbook_count=$(node -e 'const v=JSON.parse(process.argv[1]);process.stdout.write(String(v.receipts.count))' "$playbook_json")
241
+ pass "playbook policy and private receipts are valid ($playbook_count receipt(s))"
242
+ else
243
+ fail "playbook policy or receipts failed validation — inspect with: cairn playbook doctor --json"
244
+ fi
245
+
246
+ # 11. Typed metadata overlays and canonical-note transaction state. The internal
234
247
  # JSON seam enumerates only contained local databases and the one notes root;
235
248
  # raw KV cells and unmarked Markdown bytes are never repair inputs.
236
249
  node_cli="$CAIRN_ROOT/mcp-memory-server/dist/node-cli.js"