@cairnkeep/cli 2.14.0 → 2.15.1

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 +41 -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 +396 -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,245 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { constants, existsSync, readdirSync } from "node:fs";
3
+ import { link, lstat, mkdir, open, realpath, rm } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { z } from "zod";
6
+ import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
7
+ import { playbookProjectIdentity, resolvePlaybookStatus } from "./playbook.js";
8
+ import { PLAYBOOK_SCHEMA_VERSION, playbookActionIdSchema, playbookActorSchema, playbookEventSchema, playbookOutcomeSchema, playbookReasonSchema, } from "./playbook-schema.js";
9
+ const MAX_RECEIPT_BYTES = 8 * 1024;
10
+ const MAX_RECEIPTS = 10_000;
11
+ const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
12
+ const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
13
+ const RECEIPT_PATTERN = /^pbk_[a-f0-9]{32}$/;
14
+ const receiptSchema = z.strictObject({
15
+ schema_version: z.literal(PLAYBOOK_SCHEMA_VERSION),
16
+ receipt_id: z.string().regex(RECEIPT_PATTERN),
17
+ project_identity: z.string().regex(DIGEST_PATTERN),
18
+ policy_digest: z.string().regex(DIGEST_PATTERN),
19
+ decision_digest: z.string().regex(DIGEST_PATTERN),
20
+ actor: playbookActorSchema,
21
+ session_id: z.string().regex(SESSION_PATTERN),
22
+ event: playbookEventSchema,
23
+ action: playbookActionIdSchema,
24
+ outcome: playbookOutcomeSchema,
25
+ reason: playbookReasonSchema,
26
+ recorded_at: z.string().datetime({ offset: true }),
27
+ });
28
+ function sha256(value) {
29
+ return createHash("sha256").update(value).digest("hex");
30
+ }
31
+ async function layout(projectRoot = process.cwd()) {
32
+ const root = await realpath(resolve(projectRoot));
33
+ const agentfs = join(root, ".agentfs");
34
+ const store = join(agentfs, "playbooks");
35
+ const receipts = join(store, "receipts");
36
+ if (dirname(agentfs) !== root || dirname(store) !== agentfs || dirname(receipts) !== store) {
37
+ throw new Error("Playbook receipt store escapes the project root.");
38
+ }
39
+ return { root, agentfs, store, receipts };
40
+ }
41
+ async function ensureDirectory(path, parent) {
42
+ if (existsSync(path)) {
43
+ const info = await lstat(path);
44
+ if (!info.isDirectory() || info.isSymbolicLink() || !privatePathIsSafe(path)) {
45
+ throw new Error("Playbook receipt storage contains an unsafe path.");
46
+ }
47
+ return;
48
+ }
49
+ const parentInfo = await lstat(parent);
50
+ if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink())
51
+ throw new Error("Playbook receipt storage parent is unsafe.");
52
+ await mkdir(path, { mode: 0o700 });
53
+ hardenPrivatePath(path);
54
+ }
55
+ async function ensureStore(projectRoot) {
56
+ const target = await layout(projectRoot);
57
+ if (!existsSync(target.agentfs))
58
+ await mkdir(target.agentfs, { mode: 0o700 });
59
+ const agentInfo = await lstat(target.agentfs);
60
+ if (!agentInfo.isDirectory() || agentInfo.isSymbolicLink())
61
+ throw new Error("Project .agentfs path is unsafe.");
62
+ await ensureDirectory(target.store, target.agentfs);
63
+ await ensureDirectory(target.receipts, target.store);
64
+ return target.receipts;
65
+ }
66
+ async function safeRead(path) {
67
+ const info = await lstat(path);
68
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_RECEIPT_BYTES || !privatePathIsSafe(path)) {
69
+ throw new Error("Unsafe playbook receipt.");
70
+ }
71
+ let handle;
72
+ try {
73
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
74
+ const opened = await handle.stat();
75
+ if (!opened.isFile() || opened.size > MAX_RECEIPT_BYTES || opened.dev !== info.dev || opened.ino !== info.ino) {
76
+ throw new Error("Playbook receipt changed during validation.");
77
+ }
78
+ return receiptSchema.parse(JSON.parse((await handle.readFile()).toString("utf8")));
79
+ }
80
+ finally {
81
+ await handle?.close().catch(() => undefined);
82
+ }
83
+ }
84
+ async function atomicReceipt(path, value) {
85
+ const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
86
+ if (bytes.byteLength > MAX_RECEIPT_BYTES)
87
+ throw new Error("Playbook receipt exceeds its storage limit.");
88
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
89
+ let handle;
90
+ try {
91
+ handle = await open(temporary, "wx", 0o600);
92
+ await handle.writeFile(bytes);
93
+ await handle.sync();
94
+ await handle.close();
95
+ handle = undefined;
96
+ hardenPrivatePath(temporary);
97
+ try {
98
+ await link(temporary, path);
99
+ }
100
+ catch (error) {
101
+ if (error.code === "EEXIST")
102
+ return false;
103
+ throw error;
104
+ }
105
+ hardenPrivatePath(path);
106
+ return true;
107
+ }
108
+ finally {
109
+ if (handle)
110
+ await handle.close().catch(() => undefined);
111
+ await rm(temporary, { force: true });
112
+ }
113
+ }
114
+ function receiptFiles(directory) {
115
+ if (!existsSync(directory))
116
+ return [];
117
+ return readdirSync(directory).filter((name) => RECEIPT_PATTERN.test(name.replace(/\.json$/, "")) && name.endsWith(".json")).sort();
118
+ }
119
+ export async function recordPlaybookReceipt(options) {
120
+ const projectRoot = options.projectRoot ?? process.cwd();
121
+ if (!DIGEST_PATTERN.test(options.policyDigest) || !DIGEST_PATTERN.test(options.decisionDigest))
122
+ throw new Error("Invalid playbook digest.");
123
+ if (!SESSION_PATTERN.test(options.sessionId))
124
+ throw new Error("Invalid playbook session ID.");
125
+ const actor = playbookActorSchema.parse(options.actor);
126
+ const event = playbookEventSchema.parse(options.event);
127
+ const action = playbookActionIdSchema.parse(options.action);
128
+ const outcome = playbookOutcomeSchema.parse(options.outcome);
129
+ const reason = playbookReasonSchema.parse(options.reason ?? "");
130
+ if ((outcome === "skipped" || outcome === "failed") && reason.trim().length === 0) {
131
+ throw new Error("Skipped or failed playbook actions require a bounded reason.");
132
+ }
133
+ const status = await resolvePlaybookStatus({ projectRoot });
134
+ if (status.issues.length || status.policy_digest !== options.policyDigest)
135
+ throw new Error("Playbook policy digest is stale or invalid.");
136
+ const projectIdentity = await playbookProjectIdentity(projectRoot);
137
+ const identity = {
138
+ schema_version: PLAYBOOK_SCHEMA_VERSION,
139
+ project_identity: projectIdentity,
140
+ policy_digest: options.policyDigest,
141
+ decision_digest: options.decisionDigest,
142
+ actor,
143
+ session_id: options.sessionId,
144
+ event,
145
+ action,
146
+ outcome,
147
+ reason,
148
+ };
149
+ const receiptId = `pbk_${sha256(JSON.stringify(identity)).slice(0, 32)}`;
150
+ const directory = await ensureStore(projectRoot);
151
+ if (receiptFiles(directory).length >= MAX_RECEIPTS)
152
+ throw new Error("Playbook receipt limit reached; archive or remove old project state.");
153
+ const path = join(directory, `${receiptId}.json`);
154
+ if (existsSync(path))
155
+ return safeRead(path);
156
+ const receipt = receiptSchema.parse({ ...identity, receipt_id: receiptId, recorded_at: (options.now ?? new Date()).toISOString() });
157
+ return await atomicReceipt(path, receipt) ? receipt : safeRead(path);
158
+ }
159
+ export async function listPlaybookReceipts(projectRoot = process.cwd()) {
160
+ const { receipts } = await layout(projectRoot);
161
+ if (!existsSync(receipts))
162
+ return { schema_version: PLAYBOOK_SCHEMA_VERSION, receipts: [] };
163
+ const info = await lstat(receipts);
164
+ if (!info.isDirectory() || info.isSymbolicLink())
165
+ throw new Error("Playbook receipt store is unsafe.");
166
+ const values = [];
167
+ for (const name of receiptFiles(receipts))
168
+ values.push(await safeRead(join(receipts, name)));
169
+ values.sort((left, right) => right.recorded_at.localeCompare(left.recorded_at) || left.receipt_id.localeCompare(right.receipt_id));
170
+ return { schema_version: PLAYBOOK_SCHEMA_VERSION, receipts: values };
171
+ }
172
+ export async function readPlaybookReceipt(selector, projectRoot = process.cwd()) {
173
+ if (!/^(?:pbk_)?[a-f0-9]{6,32}$/.test(selector))
174
+ throw new Error("Invalid playbook receipt selector.");
175
+ const values = (await listPlaybookReceipts(projectRoot)).receipts.filter(({ receipt_id }) => receipt_id === selector || receipt_id.startsWith(selector.startsWith("pbk_") ? selector : `pbk_${selector}`));
176
+ if (values.length === 0)
177
+ throw new Error("Playbook receipt not found.");
178
+ if (values.length > 1)
179
+ throw new Error("Playbook receipt selector is ambiguous.");
180
+ return values[0];
181
+ }
182
+ export async function doctorPlaybooks(projectRoot = process.cwd()) {
183
+ const status = await resolvePlaybookStatus({ projectRoot });
184
+ const policyIssues = [...status.issues];
185
+ const receiptIssues = [];
186
+ let count = 0;
187
+ try {
188
+ const target = await layout(projectRoot);
189
+ const ai = join(target.root, ".ai");
190
+ if (existsSync(ai)) {
191
+ const info = await lstat(ai);
192
+ if (!info.isDirectory() || info.isSymbolicLink())
193
+ policyIssues.push("unsafe playbook configuration directory");
194
+ else {
195
+ for (const name of readdirSync(ai).sort()) {
196
+ if (name === ".playbooks.lock" || /^\.playbooks\.json\..+\.tmp$/.test(name)) {
197
+ policyIssues.push(`playbook configuration remnant: ${name}`);
198
+ }
199
+ }
200
+ }
201
+ }
202
+ if (existsSync(target.store)) {
203
+ const info = await lstat(target.store);
204
+ if (!info.isDirectory() || info.isSymbolicLink() || !privatePathIsSafe(target.store)) {
205
+ receiptIssues.push("unsafe playbook receipt root");
206
+ }
207
+ else {
208
+ for (const name of readdirSync(target.store).sort()) {
209
+ if (name !== "receipts")
210
+ receiptIssues.push(`unexpected playbook store entry: ${name}`);
211
+ }
212
+ }
213
+ }
214
+ if (existsSync(target.receipts)) {
215
+ const info = await lstat(target.receipts);
216
+ if (!info.isDirectory() || info.isSymbolicLink() || !privatePathIsSafe(target.receipts))
217
+ receiptIssues.push("unsafe receipt directory");
218
+ else {
219
+ const names = readdirSync(target.receipts).sort();
220
+ for (const name of names) {
221
+ if (!name.endsWith(".json") || !RECEIPT_PATTERN.test(name.slice(0, -5))) {
222
+ receiptIssues.push(`unexpected receipt entry: ${name}`);
223
+ continue;
224
+ }
225
+ try {
226
+ await safeRead(join(target.receipts, name));
227
+ count += 1;
228
+ }
229
+ catch {
230
+ receiptIssues.push(`invalid receipt: ${name}`);
231
+ }
232
+ }
233
+ }
234
+ }
235
+ }
236
+ catch {
237
+ receiptIssues.push("receipt store diagnostic failed");
238
+ }
239
+ return {
240
+ schema_version: PLAYBOOK_SCHEMA_VERSION,
241
+ ok: policyIssues.length === 0 && receiptIssues.length === 0,
242
+ policy: { ok: policyIssues.length === 0, issues: policyIssues },
243
+ receipts: { ok: receiptIssues.length === 0, count, issues: receiptIssues },
244
+ };
245
+ }
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+ export const PLAYBOOK_SCHEMA_VERSION = 1;
3
+ export const PLAYBOOK_PROFILES = ["minimal", "balanced", "strict"];
4
+ export const PLAYBOOK_MODES = ["must", "should", "may", "off"];
5
+ export const PLAYBOOK_EVENTS = ["start", "check", "finish"];
6
+ export const PLAYBOOK_COMPLEXITIES = ["trivial", "standard", "complex"];
7
+ export const PLAYBOOK_FAMILIARITIES = ["known", "mixed", "unfamiliar"];
8
+ export const PLAYBOOK_RISKS = ["low", "normal", "high", "security"];
9
+ export const PLAYBOOK_CHANGE_TYPES = ["code", "tests", "docs", "config", "dependencies", "security"];
10
+ export const PLAYBOOK_OUTCOMES = ["completed", "skipped", "failed"];
11
+ export const PLAYBOOK_ACTOR_KINDS = ["user", "agent", "service"];
12
+ export const PLAYBOOK_ACTION_IDS = [
13
+ "context.recall",
14
+ "context.explore",
15
+ "work.plan",
16
+ "verify.tests",
17
+ "review.repository",
18
+ "review.security",
19
+ "docs.update",
20
+ "learning.capture",
21
+ ];
22
+ export const playbookProfileSchema = z.enum(PLAYBOOK_PROFILES);
23
+ export const playbookModeSchema = z.enum(PLAYBOOK_MODES);
24
+ export const playbookEventSchema = z.enum(PLAYBOOK_EVENTS);
25
+ export const playbookComplexitySchema = z.enum(PLAYBOOK_COMPLEXITIES);
26
+ export const playbookFamiliaritySchema = z.enum(PLAYBOOK_FAMILIARITIES);
27
+ export const playbookRiskSchema = z.enum(PLAYBOOK_RISKS);
28
+ export const playbookChangeTypeSchema = z.enum(PLAYBOOK_CHANGE_TYPES);
29
+ export const playbookOutcomeSchema = z.enum(PLAYBOOK_OUTCOMES);
30
+ export const playbookActorKindSchema = z.enum(PLAYBOOK_ACTOR_KINDS);
31
+ export const playbookActionIdSchema = z.enum(PLAYBOOK_ACTION_IDS);
32
+ export const playbookOverridesSchema = z.partialRecord(playbookActionIdSchema.or(z.never()), playbookModeSchema);
33
+ export const playbookConfigSchema = z.strictObject({
34
+ schema_version: z.literal(PLAYBOOK_SCHEMA_VERSION),
35
+ profile: playbookProfileSchema,
36
+ overrides: playbookOverridesSchema,
37
+ });
38
+ export const playbookActorSchema = z.strictObject({
39
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/),
40
+ kind: playbookActorKindSchema,
41
+ authenticated: z.literal(false),
42
+ });
43
+ export const playbookReasonSchema = z.string().max(512).refine((value) => !/[\u0000-\u001f\u007f]/.test(value), "Playbook reasons must not contain control characters.");
44
+ export const playbookEvidenceSchema = z.strictObject({
45
+ action: playbookActionIdSchema,
46
+ outcome: playbookOutcomeSchema,
47
+ reason: playbookReasonSchema,
48
+ });
49
+ export const playbookSignalsSchema = z.strictObject({
50
+ complexity: playbookComplexitySchema,
51
+ familiarity: playbookFamiliaritySchema,
52
+ risk: playbookRiskSchema,
53
+ public_change: z.boolean(),
54
+ changed_paths: z.array(z.string().min(1).max(512)).max(256),
55
+ change_types: z.array(playbookChangeTypeSchema).max(PLAYBOOK_CHANGE_TYPES.length),
56
+ });