@cairnkeep/cli 2.13.1 → 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.
- package/CHANGELOG.md +45 -1
- package/README.md +29 -6
- package/claude/commands/cairn-work.md +19 -0
- package/docs/context-packs.md +13 -3
- package/docs/design/team-mode.md +145 -0
- package/docs/harness-compatibility.md +12 -11
- package/docs/learning/CURRICULUM-MAP.md +8 -4
- package/docs/learning/FEATURE-GUIDE.md +13 -5
- package/docs/learning/README.md +3 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +5 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +2 -2
- package/docs/learning/lessons/L24-okf-exchange.md +111 -0
- package/docs/learning/lessons/L25-playbooks.md +114 -0
- package/docs/learning/tracks/operator.md +8 -2
- package/docs/learning/tracks/practitioner.md +5 -3
- package/docs/learning/video-scripts/V24-okf-exchange.md +31 -0
- package/docs/learning/video-scripts/V25-playbooks.md +38 -0
- package/docs/native-windows.md +12 -0
- package/docs/open-knowledge-format.md +100 -0
- package/docs/operating.md +68 -0
- package/docs/privacy-and-data-flow.md +25 -0
- package/docs/quickstart.md +17 -1
- package/docs/storage.md +28 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/kimi/skills/cairn-work/SKILL.md +13 -0
- package/mcp-memory-server/dist/context-pack-cli.js +54 -1
- package/mcp-memory-server/dist/context-pack.js +243 -47
- package/mcp-memory-server/dist/index.js +13 -1
- package/mcp-memory-server/dist/mcp-tool-catalog.js +1 -0
- package/mcp-memory-server/dist/note-store.js +44 -2
- package/mcp-memory-server/dist/okf.js +432 -0
- package/mcp-memory-server/dist/playbook-cli.js +321 -0
- package/mcp-memory-server/dist/playbook-receipt.js +245 -0
- package/mcp-memory-server/dist/playbook-schema.js +56 -0
- package/mcp-memory-server/dist/playbook.js +383 -0
- package/mcp-memory-server/package.json +4 -1
- package/opencode/command/cairn-work.md +13 -0
- package/package.json +2 -1
- package/pi/prompts/cairn-work.md +12 -0
- package/schemas/context-pack.schema.json +9 -0
- package/schemas/playbook.schema.json +29 -0
- package/scripts/bootstrap.sh +17 -0
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +26 -6
- package/scripts/doctor.sh +14 -1
- package/scripts/playbook-instructions.mjs +108 -0
- package/scripts/setup-core.mjs +1 -0
- package/scripts/setup.mjs +8 -0
- package/scripts/sync-kimi-assets.sh +3 -3
- package/scripts/sync-opencode-memory-assets.sh +2 -1
- package/scripts/sync-pi-assets.sh +2 -1
- package/scripts/test-cli-dispatch.sh +2 -0
- package/scripts/test-completion.sh +11 -2
- package/scripts/test-graph-harness-adapters.sh +9 -3
- package/scripts/test-harness-registry.sh +11 -0
- package/scripts/test-learning-docs.sh +23 -2
- package/scripts/test-package-install.sh +26 -0
- package/scripts/test-playbook-instructions.sh +60 -0
- package/scripts/test-windows-native.mjs +7 -0
- package/scripts/uninstall.sh +16 -1
- package/scripts/windows-platform.mjs +24 -3
- package/templates/playbook-agent-instructions.md.template +14 -0
- package/templates/playbooks.json.template +5 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { PLAYBOOK_ACTIONS, evaluatePlaybook, inferChangeTypes, initializePlaybook, resetPlaybook, resetPlaybookOverride, resolvePlaybookStatus, setPlaybookOverride, setPlaybookProfile, } from "./playbook.js";
|
|
3
|
+
import { doctorPlaybooks, listPlaybookReceipts, readPlaybookReceipt, recordPlaybookReceipt } from "./playbook-receipt.js";
|
|
4
|
+
import { PLAYBOOK_ACTION_IDS, PLAYBOOK_CHANGE_TYPES, PLAYBOOK_PROFILES, playbookActionIdSchema, playbookActorKindSchema, playbookComplexitySchema, playbookEventSchema, playbookFamiliaritySchema, playbookModeSchema, playbookOutcomeSchema, playbookProfileSchema, playbookRiskSchema, } from "./playbook-schema.js";
|
|
5
|
+
process.stdout.on("error", (error) => {
|
|
6
|
+
if (error.code === "EPIPE")
|
|
7
|
+
process.exit(0);
|
|
8
|
+
throw error;
|
|
9
|
+
});
|
|
10
|
+
function usage() {
|
|
11
|
+
return `cairn playbook — bounded workflow policy for coding agents
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
cairn playbook list [--json]
|
|
15
|
+
cairn playbook status [--project PATH] [--json]
|
|
16
|
+
cairn playbook init [minimal|balanced|strict] [--project PATH] [--json]
|
|
17
|
+
cairn playbook set minimal|balanced|strict [--project PATH] [--json]
|
|
18
|
+
cairn playbook enable ACTION must|should|may [--project PATH] [--json]
|
|
19
|
+
cairn playbook disable ACTION [--project PATH] [--json]
|
|
20
|
+
cairn playbook reset [ACTION] [--project PATH] [--json]
|
|
21
|
+
cairn playbook check start|check|finish [signals] [evidence] [--enforce] [--json]
|
|
22
|
+
cairn playbook record --policy DIGEST --decision DIGEST --event EVENT --action ACTION --outcome OUTCOME [identity] [--json]
|
|
23
|
+
cairn playbook receipts list|show [RECEIPT] [--project PATH] [--json]
|
|
24
|
+
cairn playbook instructions install|check|remove [--project PATH] [--json]
|
|
25
|
+
cairn playbook doctor [--project PATH] [--json]
|
|
26
|
+
|
|
27
|
+
Check signals:
|
|
28
|
+
--complexity trivial|standard|complex --familiarity known|mixed|unfamiliar
|
|
29
|
+
--risk low|normal|high|security --public-change
|
|
30
|
+
--changed PATH... --change-type TYPE...
|
|
31
|
+
--completed ACTION... --skipped ACTION=REASON...
|
|
32
|
+
--failed ACTION=REASON... --actor ID --actor-kind user|agent|service --session ID
|
|
33
|
+
|
|
34
|
+
Policies select only Cairnkeep-owned actions. Checking never executes an action,
|
|
35
|
+
contacts a network service, or grants approval. Actor identity is caller-supplied
|
|
36
|
+
and unauthenticated until a future team deployment binds it to authentication.
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
const VALUE_OPTIONS = new Set([
|
|
40
|
+
"--project", "--complexity", "--familiarity", "--risk", "--changed", "--change-type",
|
|
41
|
+
"--completed", "--skipped", "--failed", "--actor", "--actor-kind", "--session",
|
|
42
|
+
"--policy", "--decision", "--event", "--action", "--outcome", "--reason",
|
|
43
|
+
]);
|
|
44
|
+
const FLAG_OPTIONS = new Set(["--json", "--enforce", "--public-change"]);
|
|
45
|
+
const MULTI_OPTIONS = new Set(["--changed", "--change-type", "--completed", "--skipped", "--failed"]);
|
|
46
|
+
function parse(raw) {
|
|
47
|
+
const positional = [];
|
|
48
|
+
const options = new Map();
|
|
49
|
+
const flags = new Set();
|
|
50
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
51
|
+
const value = raw[index];
|
|
52
|
+
if (FLAG_OPTIONS.has(value)) {
|
|
53
|
+
if (flags.has(value))
|
|
54
|
+
throw new Error(`Duplicate ${value}.`);
|
|
55
|
+
flags.add(value);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (VALUE_OPTIONS.has(value)) {
|
|
59
|
+
const values = options.get(value) ?? [];
|
|
60
|
+
if (!MULTI_OPTIONS.has(value) && values.length)
|
|
61
|
+
throw new Error(`Duplicate ${value}.`);
|
|
62
|
+
let consumed = 0;
|
|
63
|
+
while (index + 1 < raw.length && !raw[index + 1].startsWith("--")) {
|
|
64
|
+
values.push(raw[index + 1]);
|
|
65
|
+
index += 1;
|
|
66
|
+
consumed += 1;
|
|
67
|
+
if (!MULTI_OPTIONS.has(value))
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
if (consumed === 0)
|
|
71
|
+
throw new Error(`Missing value for ${value}.`);
|
|
72
|
+
options.set(value, values);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (value.startsWith("--"))
|
|
76
|
+
throw new Error(`Unknown option ${value}.`);
|
|
77
|
+
positional.push(value);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
positional,
|
|
81
|
+
options,
|
|
82
|
+
flags,
|
|
83
|
+
json: flags.has("--json"),
|
|
84
|
+
projectRoot: options.get("--project")?.[0] ?? process.cwd(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function one(parsed, name, fallback) {
|
|
88
|
+
return parsed.options.get(name)?.[0] ?? fallback;
|
|
89
|
+
}
|
|
90
|
+
function many(parsed, name) {
|
|
91
|
+
return parsed.options.get(name) ?? [];
|
|
92
|
+
}
|
|
93
|
+
function ensureAllowed(parsed, allowedOptions, allowedFlags) {
|
|
94
|
+
for (const key of parsed.options.keys())
|
|
95
|
+
if (!allowedOptions.includes(key))
|
|
96
|
+
throw new Error(`${key} is not valid for this command.`);
|
|
97
|
+
for (const key of parsed.flags)
|
|
98
|
+
if (!allowedFlags.includes(key))
|
|
99
|
+
throw new Error(`${key} is not valid for this command.`);
|
|
100
|
+
}
|
|
101
|
+
function output(value, json, human) {
|
|
102
|
+
process.stdout.write(`${json ? JSON.stringify(value) : human}\n`);
|
|
103
|
+
}
|
|
104
|
+
function humanStatus(status) {
|
|
105
|
+
return [
|
|
106
|
+
`Profile: ${status.profile}`,
|
|
107
|
+
`Source: ${status.source}`,
|
|
108
|
+
`Policy digest: ${status.policy_digest}`,
|
|
109
|
+
`Configuration: ${status.config_exists ? ".ai/playbooks.json" : "built-in balanced default"}`,
|
|
110
|
+
`Issues: ${status.issues.length ? status.issues.join(", ") : "none"}`,
|
|
111
|
+
...PLAYBOOK_ACTION_IDS.map((id) => `${id}\t${status.modes[id]}${status.overrides[id] ? "\toverride" : ""}`),
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
function parseEvidence(values, outcome) {
|
|
115
|
+
return values.map((value) => {
|
|
116
|
+
const separator = value.indexOf("=");
|
|
117
|
+
const action = separator < 0 ? value : value.slice(0, separator);
|
|
118
|
+
const reason = separator < 0 ? "" : value.slice(separator + 1);
|
|
119
|
+
playbookActionIdSchema.parse(action);
|
|
120
|
+
if (outcome !== "completed" && reason.trim().length === 0)
|
|
121
|
+
throw new Error(`${outcome} evidence requires ACTION=REASON.`);
|
|
122
|
+
return { action: action, outcome, reason };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function actor(parsed) {
|
|
126
|
+
const id = one(parsed, "--actor", process.env.CAIRN_PLAYBOOK_ACTOR ?? "local-agent");
|
|
127
|
+
const kind = playbookActorKindSchema.parse(one(parsed, "--actor-kind", process.env.CAIRN_PLAYBOOK_ACTOR_KIND ?? "agent"));
|
|
128
|
+
return { id, kind, authenticated: false };
|
|
129
|
+
}
|
|
130
|
+
function session(parsed) {
|
|
131
|
+
return one(parsed, "--session", process.env.CAIRN_PLAYBOOK_SESSION ?? `local-${process.pid}`);
|
|
132
|
+
}
|
|
133
|
+
function humanDecision(value) {
|
|
134
|
+
const lines = [
|
|
135
|
+
`Playbook: ${value.profile} (${value.policy_digest})`,
|
|
136
|
+
`Decision: ${value.decision_digest}`,
|
|
137
|
+
`Event: ${value.event}`,
|
|
138
|
+
];
|
|
139
|
+
if (value.actions.length === 0)
|
|
140
|
+
lines.push("Actions: none");
|
|
141
|
+
else {
|
|
142
|
+
lines.push("Actions:");
|
|
143
|
+
for (const row of value.actions) {
|
|
144
|
+
lines.push(` [${row.outcome === "completed" ? "x" : " "}] ${row.mode.toUpperCase()} ${row.id} — ${row.title}`);
|
|
145
|
+
lines.push(` ${row.rationale}`);
|
|
146
|
+
lines.push(` ${row.command}`);
|
|
147
|
+
if (row.reason)
|
|
148
|
+
lines.push(` evidence: ${row.outcome} — ${row.reason}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
lines.push(`Blocking: ${value.blocking_actions.length ? value.blocking_actions.join(", ") : "none"}`);
|
|
152
|
+
lines.push("Actor authentication: unverified local assertion");
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
async function check(parsed) {
|
|
156
|
+
ensureAllowed(parsed, ["--project", "--complexity", "--familiarity", "--risk", "--changed", "--change-type", "--completed", "--skipped", "--failed", "--actor", "--actor-kind", "--session"], ["--json", "--enforce", "--public-change"]);
|
|
157
|
+
if (parsed.positional.length !== 1)
|
|
158
|
+
throw new Error("check requires one lifecycle event.");
|
|
159
|
+
const event = playbookEventSchema.parse(parsed.positional[0]);
|
|
160
|
+
const explicitTypes = many(parsed, "--change-type").map((value) => {
|
|
161
|
+
if (!PLAYBOOK_CHANGE_TYPES.includes(value))
|
|
162
|
+
throw new Error(`Unknown change type ${value}.`);
|
|
163
|
+
return value;
|
|
164
|
+
});
|
|
165
|
+
const changed = many(parsed, "--changed");
|
|
166
|
+
const evidence = [
|
|
167
|
+
...parseEvidence(many(parsed, "--completed"), "completed"),
|
|
168
|
+
...parseEvidence(many(parsed, "--skipped"), "skipped"),
|
|
169
|
+
...parseEvidence(many(parsed, "--failed"), "failed"),
|
|
170
|
+
];
|
|
171
|
+
const value = await evaluatePlaybook({
|
|
172
|
+
projectRoot: parsed.projectRoot,
|
|
173
|
+
event,
|
|
174
|
+
actor: actor(parsed),
|
|
175
|
+
sessionId: session(parsed),
|
|
176
|
+
signals: {
|
|
177
|
+
complexity: playbookComplexitySchema.parse(one(parsed, "--complexity", "standard")),
|
|
178
|
+
familiarity: playbookFamiliaritySchema.parse(one(parsed, "--familiarity", "mixed")),
|
|
179
|
+
risk: playbookRiskSchema.parse(one(parsed, "--risk", "normal")),
|
|
180
|
+
public_change: parsed.flags.has("--public-change"),
|
|
181
|
+
changed_paths: changed,
|
|
182
|
+
change_types: inferChangeTypes(changed, explicitTypes),
|
|
183
|
+
},
|
|
184
|
+
evidence,
|
|
185
|
+
});
|
|
186
|
+
output(value, parsed.json, humanDecision(value));
|
|
187
|
+
if (value.issues.length)
|
|
188
|
+
process.exitCode = 2;
|
|
189
|
+
else if (parsed.flags.has("--enforce") && value.blocking_actions.length)
|
|
190
|
+
process.exitCode = 3;
|
|
191
|
+
}
|
|
192
|
+
async function record(parsed) {
|
|
193
|
+
ensureAllowed(parsed, ["--project", "--policy", "--decision", "--event", "--action", "--outcome", "--reason", "--actor", "--actor-kind", "--session"], ["--json"]);
|
|
194
|
+
if (parsed.positional.length !== 0)
|
|
195
|
+
throw new Error("record accepts options only.");
|
|
196
|
+
const value = await recordPlaybookReceipt({
|
|
197
|
+
projectRoot: parsed.projectRoot,
|
|
198
|
+
policyDigest: one(parsed, "--policy") ?? "",
|
|
199
|
+
decisionDigest: one(parsed, "--decision") ?? "",
|
|
200
|
+
actor: actor(parsed),
|
|
201
|
+
sessionId: session(parsed),
|
|
202
|
+
event: playbookEventSchema.parse(one(parsed, "--event")),
|
|
203
|
+
action: playbookActionIdSchema.parse(one(parsed, "--action")),
|
|
204
|
+
outcome: playbookOutcomeSchema.parse(one(parsed, "--outcome")),
|
|
205
|
+
reason: one(parsed, "--reason", ""),
|
|
206
|
+
});
|
|
207
|
+
output(value, parsed.json, `Recorded ${value.receipt_id}: ${value.action} ${value.outcome}.`);
|
|
208
|
+
}
|
|
209
|
+
async function main() {
|
|
210
|
+
const [command = "help", ...raw] = process.argv.slice(2);
|
|
211
|
+
if (["help", "--help", "-h"].includes(command)) {
|
|
212
|
+
if (raw.length)
|
|
213
|
+
throw new Error("help accepts no arguments.");
|
|
214
|
+
process.stdout.write(usage());
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const parsed = parse(raw);
|
|
218
|
+
if (command === "list") {
|
|
219
|
+
ensureAllowed(parsed, [], ["--json"]);
|
|
220
|
+
if (parsed.positional.length)
|
|
221
|
+
throw new Error("list accepts no arguments.");
|
|
222
|
+
const value = { schema_version: 1, profiles: PLAYBOOK_PROFILES, actions: PLAYBOOK_ACTIONS };
|
|
223
|
+
output(value, parsed.json, PLAYBOOK_ACTIONS.map(({ id, title, command: actionCommand }) => `${id}\t${title}\t${actionCommand}`).join("\n"));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (command === "status") {
|
|
227
|
+
ensureAllowed(parsed, ["--project"], ["--json"]);
|
|
228
|
+
if (parsed.positional.length)
|
|
229
|
+
throw new Error("status accepts no positional arguments.");
|
|
230
|
+
const value = await resolvePlaybookStatus({ projectRoot: parsed.projectRoot });
|
|
231
|
+
output(value, parsed.json, humanStatus(value));
|
|
232
|
+
if (value.issues.length)
|
|
233
|
+
process.exitCode = 2;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (command === "init" || command === "set") {
|
|
237
|
+
ensureAllowed(parsed, ["--project"], ["--json"]);
|
|
238
|
+
if (parsed.positional.length > 1 || (command === "set" && parsed.positional.length !== 1))
|
|
239
|
+
throw new Error(`${command} requires one profile.`);
|
|
240
|
+
const profile = playbookProfileSchema.parse(parsed.positional[0] ?? "balanced");
|
|
241
|
+
const value = command === "init"
|
|
242
|
+
? await initializePlaybook(parsed.projectRoot, profile)
|
|
243
|
+
: await setPlaybookProfile(parsed.projectRoot, profile);
|
|
244
|
+
output(value, parsed.json, humanStatus(value));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (command === "enable" || command === "disable") {
|
|
248
|
+
ensureAllowed(parsed, ["--project"], ["--json"]);
|
|
249
|
+
if (parsed.positional.length !== (command === "enable" ? 2 : 1))
|
|
250
|
+
throw new Error(`${command} has invalid arguments.`);
|
|
251
|
+
const action = playbookActionIdSchema.parse(parsed.positional[0]);
|
|
252
|
+
const mode = command === "disable" ? "off" : playbookModeSchema.parse(parsed.positional[1]);
|
|
253
|
+
if (mode === "off" && command === "enable")
|
|
254
|
+
throw new Error("Use disable ACTION to turn an action off.");
|
|
255
|
+
const value = await setPlaybookOverride(parsed.projectRoot, action, mode);
|
|
256
|
+
output(value, parsed.json, humanStatus(value));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (command === "reset") {
|
|
260
|
+
ensureAllowed(parsed, ["--project"], ["--json"]);
|
|
261
|
+
if (parsed.positional.length > 1)
|
|
262
|
+
throw new Error("reset accepts at most one action.");
|
|
263
|
+
const value = parsed.positional[0]
|
|
264
|
+
? await resetPlaybookOverride(parsed.projectRoot, playbookActionIdSchema.parse(parsed.positional[0]))
|
|
265
|
+
: await resetPlaybook(parsed.projectRoot);
|
|
266
|
+
output(value, parsed.json, humanStatus(value));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (command === "check") {
|
|
270
|
+
await check(parsed);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (command === "record") {
|
|
274
|
+
await record(parsed);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (command === "receipts") {
|
|
278
|
+
ensureAllowed(parsed, ["--project"], ["--json"]);
|
|
279
|
+
const operation = parsed.positional[0] ?? "list";
|
|
280
|
+
if (operation === "list" && parsed.positional.length === 1) {
|
|
281
|
+
const value = await listPlaybookReceipts(parsed.projectRoot);
|
|
282
|
+
output(value, parsed.json, value.receipts.length ? value.receipts.map(({ receipt_id, action, outcome, recorded_at }) => `${receipt_id}\t${outcome}\t${action}\t${recorded_at}`).join("\n") : "No playbook receipts found.");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (operation === "show" && parsed.positional.length === 2) {
|
|
286
|
+
const value = await readPlaybookReceipt(parsed.positional[1], parsed.projectRoot);
|
|
287
|
+
output(value, parsed.json, JSON.stringify(value, null, 2));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
throw new Error("receipts requires list or show RECEIPT.");
|
|
291
|
+
}
|
|
292
|
+
if (command === "doctor") {
|
|
293
|
+
ensureAllowed(parsed, ["--project"], ["--json"]);
|
|
294
|
+
if (parsed.positional.length)
|
|
295
|
+
throw new Error("doctor accepts no positional arguments.");
|
|
296
|
+
const value = await doctorPlaybooks(parsed.projectRoot);
|
|
297
|
+
output(value, parsed.json, JSON.stringify(value, null, 2));
|
|
298
|
+
if (!value.ok)
|
|
299
|
+
process.exitCode = 2;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (command === "instructions") {
|
|
303
|
+
ensureAllowed(parsed, ["--project"], ["--json"]);
|
|
304
|
+
if (parsed.positional.length !== 1 || !["install", "check", "remove"].includes(parsed.positional[0]))
|
|
305
|
+
throw new Error("instructions requires install, check, or remove.");
|
|
306
|
+
const moduleUrl = new URL("../../scripts/playbook-instructions.mjs", import.meta.url).href;
|
|
307
|
+
const instructionModule = await import(moduleUrl);
|
|
308
|
+
const value = parsed.positional[0] === "remove"
|
|
309
|
+
? instructionModule.removePlaybookInstructions(parsed.projectRoot, { check: false })
|
|
310
|
+
: instructionModule.reconcilePlaybookInstructions(parsed.projectRoot, { check: parsed.positional[0] === "check" });
|
|
311
|
+
output(value, parsed.json, `${value.status}: ${value.path}`);
|
|
312
|
+
if (parsed.positional[0] === "check" && value.status !== "unchanged")
|
|
313
|
+
process.exitCode = 1;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
throw new Error(`unknown playbook command "${command}".`);
|
|
317
|
+
}
|
|
318
|
+
main().catch((error) => {
|
|
319
|
+
process.stderr.write(`cairn playbook: ${error instanceof Error ? error.message : "invalid command or policy"}\n`);
|
|
320
|
+
process.exitCode = 2;
|
|
321
|
+
});
|
|
@@ -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
|
+
});
|