@cairnkeep/cli 2.6.0 → 2.8.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 +35 -1
- package/README.md +45 -8
- package/bin/cairn +21 -3
- package/claude/commands/graphify.md +46 -55
- package/docs/design/meta-agent.md +39 -35
- package/docs/harness-compatibility.md +13 -5
- package/docs/learning/CURRICULUM-MAP.md +10 -3
- package/docs/learning/FEATURE-GUIDE.md +8 -5
- package/docs/learning/README.md +16 -14
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -5
- package/docs/learning/lessons/L01-safe-trial.md +3 -7
- package/docs/learning/lessons/L02-installation.md +6 -8
- package/docs/learning/lessons/L03-first-project.md +1 -5
- package/docs/learning/lessons/L04-memory-fundamentals.md +0 -6
- package/docs/learning/lessons/L05-daily-workflow.md +0 -5
- package/docs/learning/lessons/L06-repository-quality.md +0 -6
- package/docs/learning/lessons/L07-privacy-and-storage.md +0 -5
- package/docs/learning/lessons/L08-multiple-machines.md +0 -5
- package/docs/learning/lessons/L09-domain-knowledge.md +0 -5
- package/docs/learning/lessons/L10-context-exploration.md +0 -5
- package/docs/learning/lessons/L11-containers.md +0 -5
- package/docs/learning/lessons/L12-managed-overlays.md +0 -6
- package/docs/learning/lessons/L13-session-evidence.md +0 -6
- package/docs/learning/lessons/L14-typed-memory.md +0 -6
- package/docs/learning/lessons/L15-capability-governance.md +0 -6
- package/docs/learning/lessons/L16-evaluation.md +0 -7
- package/docs/learning/lessons/L17-agent-boundary.md +12 -14
- package/docs/learning/lessons/L18-local-code-graph.md +97 -0
- package/docs/learning/lessons/L19-validated-skill-improvement.md +87 -0
- package/docs/learning/templates/lesson-template.md +0 -4
- package/docs/learning/tracks/evidence-and-evaluation.md +5 -3
- package/docs/learning/tracks/practitioner.md +5 -4
- package/docs/operating.md +102 -9
- package/docs/privacy-and-data-flow.md +42 -0
- package/docs/skill-improvement.md +124 -0
- package/docs/storage.md +25 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/kimi/skills/graphify/SKILL.md +33 -0
- package/mcp-memory-server/dist/capability-registry.js +2 -2
- package/mcp-memory-server/dist/eval-plan.js +22 -5
- package/mcp-memory-server/dist/eval-process.js +31 -0
- package/mcp-memory-server/dist/eval-runner.js +10 -4
- package/mcp-memory-server/dist/eval-schema.js +1 -1
- package/mcp-memory-server/dist/eval-workspace.js +50 -1
- package/mcp-memory-server/dist/graph-cli.js +386 -0
- package/mcp-memory-server/dist/skill-cli.js +236 -0
- package/mcp-memory-server/dist/skill-evaluation.js +244 -0
- package/mcp-memory-server/dist/skill-schema.js +164 -0
- package/mcp-memory-server/dist/skill-store.js +583 -0
- package/mcp-memory-server/package.json +3 -1
- package/opencode/command/graphify.md +48 -58
- package/package.json +2 -1
- package/pi/prompts/graphify.md +32 -0
- package/schemas/eval-report.schema.json +1 -1
- package/schemas/skill-adapter.schema.json +39 -0
- package/schemas/skill-proposal-protocol.schema.json +97 -0
- package/scripts/bootstrap.sh +2 -1
- package/scripts/completion.sh +55 -6
- package/scripts/smoke-bash32.sh +1 -0
- package/scripts/sync-kimi-assets.sh +77 -0
- package/scripts/sync-pi-assets.sh +52 -31
- package/scripts/test-bootstrap-untracked.sh +21 -0
- package/scripts/test-cli-dispatch.sh +4 -0
- package/scripts/test-completion.sh +16 -0
- package/scripts/test-graph-harness-adapters.sh +46 -0
- package/scripts/test-launcher-hooks.sh +4 -2
- package/scripts/test-learning-docs.sh +13 -11
- package/scripts/test-package-install.sh +24 -0
- package/scripts/test-skill-cli.sh +30 -0
- package/scripts/test-trajectory-hooks.sh +5 -1
- package/scripts/test-uninstall.sh +12 -2
- package/scripts/uninstall.sh +11 -4
- package/templates/graph-policy.md.template +26 -5
- package/docs/learning/PRODUCTION-PLAN.md +0 -153
- package/docs/learning/instructor-guide.md +0 -66
- package/docs/learning/templates/video-script-template.md +0 -50
- package/docs/learning/video-scripts/L00-why-cairnkeep.md +0 -80
- package/docs/learning/video-scripts/L01-safe-trial.md +0 -78
- package/docs/learning/video-scripts/L02-installation.md +0 -97
- package/docs/learning/video-scripts/L03-first-project.md +0 -99
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { constants, existsSync } from "node:fs";
|
|
2
3
|
import { chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, writeFile, } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
@@ -155,7 +156,7 @@ export async function createEvalWorkspace(options) {
|
|
|
155
156
|
};
|
|
156
157
|
try {
|
|
157
158
|
if (options.plan.source.kind === "git") {
|
|
158
|
-
const added = await gitCommand(options.plan.source.repository_root, ["worktree", "add", "--detach", workspace.source_path, options.plan.source.revision], "exit-only");
|
|
159
|
+
const added = await gitCommand(options.plan.source.repository_root, ["worktree", "add", "--quiet", "--detach", workspace.source_path, options.plan.source.revision], "exit-only");
|
|
159
160
|
requireSuccessfulCommand(added, "workspace_add_failed");
|
|
160
161
|
workspace.worktree_registered = true;
|
|
161
162
|
await verifyGitWorkspace(workspace);
|
|
@@ -192,6 +193,54 @@ export async function runTaskPreparation(workspace, options = {}) {
|
|
|
192
193
|
signal: options.signal,
|
|
193
194
|
});
|
|
194
195
|
}
|
|
196
|
+
export async function applyEvalWorkspaceOverlay(workspace, overlay) {
|
|
197
|
+
const segments = overlay.relative_path.split("/");
|
|
198
|
+
if (overlay.relative_path.startsWith("/") || overlay.relative_path.endsWith("/")
|
|
199
|
+
|| overlay.relative_path.includes("\\") || segments.some((segment) => segment === "" || segment === "." || segment === "..")
|
|
200
|
+
|| /[\u0000-\u001f\u007f]/.test(overlay.relative_path)) {
|
|
201
|
+
throw new Error("workspace_overlay_path_invalid");
|
|
202
|
+
}
|
|
203
|
+
const target = resolve(workspace.source_path, overlay.relative_path);
|
|
204
|
+
if (!isContained(workspace.source_path, target) || target === workspace.source_path) {
|
|
205
|
+
throw new Error("workspace_overlay_path_escape");
|
|
206
|
+
}
|
|
207
|
+
let cursor = workspace.source_path;
|
|
208
|
+
for (const segment of segments.slice(0, -1)) {
|
|
209
|
+
cursor = join(cursor, segment);
|
|
210
|
+
try {
|
|
211
|
+
const info = await lstat(cursor);
|
|
212
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
213
|
+
throw new Error("workspace_overlay_parent_unsafe");
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
if (error.code !== "ENOENT")
|
|
217
|
+
throw error;
|
|
218
|
+
await mkdir(cursor, { mode: 0o700 });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const info = await lstat(target);
|
|
223
|
+
if (info.isSymbolicLink() || !info.isFile())
|
|
224
|
+
throw new Error("workspace_overlay_target_unsafe");
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error.code !== "ENOENT")
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
const parent = await realpath(dirname(target));
|
|
231
|
+
const source = await realpath(workspace.source_path);
|
|
232
|
+
if (!isContained(source, parent))
|
|
233
|
+
throw new Error("workspace_overlay_parent_escape");
|
|
234
|
+
const bytes = Buffer.from(overlay.content, "utf8");
|
|
235
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
236
|
+
if (digest !== overlay.digest)
|
|
237
|
+
throw new Error("workspace_overlay_digest_mismatch");
|
|
238
|
+
await writeFile(target, bytes, { mode: 0o600 });
|
|
239
|
+
await chmod(target, 0o600);
|
|
240
|
+
const storedDigest = createHash("sha256").update(await readFile(target)).digest("hex");
|
|
241
|
+
if (storedDigest !== overlay.digest)
|
|
242
|
+
throw new Error("workspace_overlay_write_mismatch");
|
|
243
|
+
}
|
|
195
244
|
function verifierReason(code) {
|
|
196
245
|
return `verifier_${code}`;
|
|
197
246
|
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { resolveCapabilityStatus } from "./capability-config.js";
|
|
7
|
+
import { isGraphCapabilityCompatible } from "./capability-registry.js";
|
|
8
|
+
const INSPECTION_TIMEOUT_MS = 30_000;
|
|
9
|
+
const BUILD_TIMEOUT_MS = 300_000;
|
|
10
|
+
const MAX_GRAPH_BYTES = 256 * 1024 * 1024;
|
|
11
|
+
const MAX_AUXILIARY_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
const MAX_SUBPROCESS_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
13
|
+
const STALE_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
function usage() {
|
|
15
|
+
return `cairn graph — build and inspect the optional local Graphify graph
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
cairn graph build [--force]
|
|
19
|
+
cairn graph query <term>
|
|
20
|
+
cairn graph status
|
|
21
|
+
cairn graph diff
|
|
22
|
+
cairn graph explain <symbol>
|
|
23
|
+
cairn graph path <from> <to>
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
26
|
+
function graphEdges(graph) {
|
|
27
|
+
return Array.isArray(graph.edges) ? graph.edges : Array.isArray(graph.links) ? graph.links : [];
|
|
28
|
+
}
|
|
29
|
+
function graphHyperedges(graph) {
|
|
30
|
+
return Array.isArray(graph.hyperedges) ? graph.hyperedges : [];
|
|
31
|
+
}
|
|
32
|
+
function plural(count, singular) {
|
|
33
|
+
return `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
34
|
+
}
|
|
35
|
+
async function readBoundedFile(path, maximumBytes) {
|
|
36
|
+
const info = await lstat(path);
|
|
37
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) {
|
|
38
|
+
throw new Error("graph artifact is missing, unsafe, or too large.");
|
|
39
|
+
}
|
|
40
|
+
const bytes = await readFile(path);
|
|
41
|
+
if (bytes.byteLength > maximumBytes)
|
|
42
|
+
throw new Error("graph artifact is too large.");
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
function parseGraph(bytes) {
|
|
46
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
47
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
48
|
+
throw new Error("graph.json is not a JSON object.");
|
|
49
|
+
}
|
|
50
|
+
const graph = value;
|
|
51
|
+
if (!Array.isArray(graph.nodes)
|
|
52
|
+
|| (!Array.isArray(graph.edges) && !Array.isArray(graph.links))) {
|
|
53
|
+
throw new Error("graph.json is missing its node or edge arrays.");
|
|
54
|
+
}
|
|
55
|
+
return graph;
|
|
56
|
+
}
|
|
57
|
+
async function readGraph(path) {
|
|
58
|
+
try {
|
|
59
|
+
return parseGraph(await readBoundedFile(path, MAX_GRAPH_BYTES));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code === "ENOENT") {
|
|
63
|
+
throw new Error("no published graph found; run /graphify build first.");
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function publishedGraphPath(projectRoot) {
|
|
69
|
+
return join(projectRoot, ".planning", "graphs", "graph.json");
|
|
70
|
+
}
|
|
71
|
+
async function requireGraphCapability(projectRoot) {
|
|
72
|
+
const status = await resolveCapabilityStatus({ projectRoot });
|
|
73
|
+
const graph = status.capabilities.find(({ id }) => id === "graph");
|
|
74
|
+
const enabled = status.contract_enabled
|
|
75
|
+
? graph?.enabled === true
|
|
76
|
+
: await isGraphCapabilityCompatible(projectRoot);
|
|
77
|
+
if (!enabled) {
|
|
78
|
+
throw new Error("graph capability is disabled; use `cairn capabilities enable graph` with the managed contract, or enable `graphify.enabled` in .planning/config.json.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function graphifyEnvironment() {
|
|
82
|
+
const environment = { PYTHONUNBUFFERED: "1" };
|
|
83
|
+
for (const name of [
|
|
84
|
+
"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL",
|
|
85
|
+
"XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "VIRTUAL_ENV", "PYTHONPATH",
|
|
86
|
+
]) {
|
|
87
|
+
if (process.env[name] !== undefined)
|
|
88
|
+
environment[name] = process.env[name];
|
|
89
|
+
}
|
|
90
|
+
return environment;
|
|
91
|
+
}
|
|
92
|
+
function executeGraphify(projectRoot, args, timeout) {
|
|
93
|
+
const result = spawnSync("graphify", args, {
|
|
94
|
+
cwd: projectRoot,
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
shell: false,
|
|
97
|
+
timeout,
|
|
98
|
+
maxBuffer: MAX_SUBPROCESS_OUTPUT_BYTES,
|
|
99
|
+
env: graphifyEnvironment(),
|
|
100
|
+
});
|
|
101
|
+
if (result.error) {
|
|
102
|
+
const code = result.error.code;
|
|
103
|
+
if (code === "ENOENT") {
|
|
104
|
+
throw new Error("Graphify CLI not found; install it with `uv tool install graphifyy` (or `pipx install graphifyy`).");
|
|
105
|
+
}
|
|
106
|
+
if (code === "ETIMEDOUT")
|
|
107
|
+
throw new Error(`Graphify timed out after ${Math.round(timeout / 1000)} seconds.`);
|
|
108
|
+
if (code === "ENOBUFS")
|
|
109
|
+
throw new Error("Graphify exceeded the 16 MiB subprocess-output limit.");
|
|
110
|
+
throw new Error("Graphify could not start.");
|
|
111
|
+
}
|
|
112
|
+
if (result.signal)
|
|
113
|
+
throw new Error("Graphify terminated before completion.");
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
function forwardResult(result) {
|
|
117
|
+
if (result.stdout)
|
|
118
|
+
process.stdout.write(result.stdout);
|
|
119
|
+
if (result.stderr)
|
|
120
|
+
process.stderr.write(result.stderr);
|
|
121
|
+
if (result.status === 0)
|
|
122
|
+
return true;
|
|
123
|
+
process.exitCode = result.status ?? 1;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
async function ensureSafeDirectory(path) {
|
|
127
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
128
|
+
const info = await lstat(path);
|
|
129
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
130
|
+
throw new Error("graph directory is unsafe.");
|
|
131
|
+
}
|
|
132
|
+
async function ensureSafeGraphBoundary(projectRoot, createGraphs) {
|
|
133
|
+
const planningDirectory = join(projectRoot, ".planning");
|
|
134
|
+
let planningInfo;
|
|
135
|
+
try {
|
|
136
|
+
planningInfo = await lstat(planningDirectory);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error.code === "ENOENT") {
|
|
140
|
+
throw new Error("planning directory not found; run `cairn bootstrap` first.");
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
if (!planningInfo.isDirectory() || planningInfo.isSymbolicLink()) {
|
|
145
|
+
throw new Error("planning directory is unsafe.");
|
|
146
|
+
}
|
|
147
|
+
const graphsDirectory = join(planningDirectory, "graphs");
|
|
148
|
+
try {
|
|
149
|
+
const graphsInfo = await lstat(graphsDirectory);
|
|
150
|
+
if (!graphsInfo.isDirectory() || graphsInfo.isSymbolicLink()) {
|
|
151
|
+
throw new Error("graph directory is unsafe.");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error.code !== "ENOENT")
|
|
156
|
+
throw error;
|
|
157
|
+
if (createGraphs)
|
|
158
|
+
await ensureSafeDirectory(graphsDirectory);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function atomicWrite(path, bytes) {
|
|
162
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
163
|
+
try {
|
|
164
|
+
await writeFile(temporary, bytes, { mode: 0o600, flag: "wx" });
|
|
165
|
+
await rename(temporary, path);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await rm(temporary, { force: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function publishOptional(source, destination) {
|
|
172
|
+
try {
|
|
173
|
+
await atomicWrite(destination, await readBoundedFile(source, MAX_AUXILIARY_BYTES));
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error.code !== "ENOENT")
|
|
178
|
+
throw error;
|
|
179
|
+
await rm(destination, { force: true });
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function isolatePublishedArtifacts(projectRoot) {
|
|
184
|
+
const directory = join(projectRoot, ".planning", "graphs");
|
|
185
|
+
const managedPaths = [
|
|
186
|
+
join(directory, "graph.json"),
|
|
187
|
+
join(directory, "graph.html"),
|
|
188
|
+
join(directory, "GRAPH_REPORT.md"),
|
|
189
|
+
join(directory, ".last-build-snapshot.json"),
|
|
190
|
+
];
|
|
191
|
+
const saved = new Map();
|
|
192
|
+
try {
|
|
193
|
+
for (const [destination, maximumBytes] of [
|
|
194
|
+
[managedPaths[0], MAX_GRAPH_BYTES],
|
|
195
|
+
[managedPaths[1], MAX_AUXILIARY_BYTES],
|
|
196
|
+
[managedPaths[2], MAX_AUXILIARY_BYTES],
|
|
197
|
+
[managedPaths[3], MAX_GRAPH_BYTES],
|
|
198
|
+
]) {
|
|
199
|
+
try {
|
|
200
|
+
saved.set(destination, await readBoundedFile(destination, maximumBytes));
|
|
201
|
+
await rm(destination);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error.code !== "ENOENT")
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const previousBytes = saved.get(join(directory, "graph.json"));
|
|
209
|
+
const previousGraph = previousBytes ? parseGraph(previousBytes) : undefined;
|
|
210
|
+
return {
|
|
211
|
+
previousGraph,
|
|
212
|
+
finish: async (keepNewArtifact) => {
|
|
213
|
+
if (!keepNewArtifact) {
|
|
214
|
+
for (const path of managedPaths)
|
|
215
|
+
await rm(path, { force: true });
|
|
216
|
+
for (const [path, bytes] of saved)
|
|
217
|
+
await atomicWrite(path, bytes);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
for (const [path, bytes] of saved)
|
|
224
|
+
await atomicWrite(path, bytes);
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function buildGraph(projectRoot, force) {
|
|
229
|
+
const outputDirectory = join(projectRoot, "graphify-out");
|
|
230
|
+
try {
|
|
231
|
+
const info = await lstat(outputDirectory);
|
|
232
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
233
|
+
throw new Error("Graphify output directory is unsafe.");
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (error.code !== "ENOENT")
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
const isolatedArtifacts = await isolatePublishedArtifacts(projectRoot);
|
|
240
|
+
let published = false;
|
|
241
|
+
try {
|
|
242
|
+
const result = executeGraphify(projectRoot, ["update", ".", ...(force ? ["--force"] : [])], BUILD_TIMEOUT_MS);
|
|
243
|
+
if (result.status !== 0) {
|
|
244
|
+
forwardResult(result);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (result.stderr)
|
|
248
|
+
process.stderr.write(result.stderr);
|
|
249
|
+
const sourceGraphPath = join(outputDirectory, "graph.json");
|
|
250
|
+
const sourceGraphBytes = await readBoundedFile(sourceGraphPath, MAX_GRAPH_BYTES);
|
|
251
|
+
const sourceGraph = parseGraph(sourceGraphBytes);
|
|
252
|
+
const graphsDirectory = join(projectRoot, ".planning", "graphs");
|
|
253
|
+
await ensureSafeDirectory(graphsDirectory);
|
|
254
|
+
const destinationGraphPath = publishedGraphPath(projectRoot);
|
|
255
|
+
if (isolatedArtifacts.previousGraph) {
|
|
256
|
+
const previous = isolatedArtifacts.previousGraph;
|
|
257
|
+
await atomicWrite(join(graphsDirectory, ".last-build-snapshot.json"), `${JSON.stringify({
|
|
258
|
+
version: 1,
|
|
259
|
+
timestamp: new Date().toISOString(),
|
|
260
|
+
nodes: previous.nodes,
|
|
261
|
+
edges: graphEdges(previous),
|
|
262
|
+
}, null, 2)}\n`);
|
|
263
|
+
}
|
|
264
|
+
await atomicWrite(destinationGraphPath, sourceGraphBytes);
|
|
265
|
+
const publishedNames = ["graph.json"];
|
|
266
|
+
for (const name of ["graph.html", "GRAPH_REPORT.md"]) {
|
|
267
|
+
if (await publishOptional(join(outputDirectory, name), join(graphsDirectory, name)))
|
|
268
|
+
publishedNames.push(name);
|
|
269
|
+
}
|
|
270
|
+
published = true;
|
|
271
|
+
process.stdout.write(`Built local code graph: ${plural(sourceGraph.nodes.length, "node")}, ${plural(graphEdges(sourceGraph).length, "edge")}, ${plural(graphHyperedges(sourceGraph).length, "hyperedge")}.\n`
|
|
272
|
+
+ `Published: ${publishedNames.join(", ")} in .planning/graphs/.\n`);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
await isolatedArtifacts.finish(published);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function showStatus(projectRoot) {
|
|
279
|
+
const path = publishedGraphPath(projectRoot);
|
|
280
|
+
const [graph, info] = await Promise.all([readGraph(path), lstat(path)]);
|
|
281
|
+
const ageMs = Math.max(0, Date.now() - info.mtimeMs);
|
|
282
|
+
process.stdout.write(`Graph: ${plural(graph.nodes.length, "node")}, ${plural(graphEdges(graph).length, "edge")}, ${plural(graphHyperedges(graph).length, "hyperedge")}.\n`
|
|
283
|
+
+ `Freshness: ${ageMs > STALE_AFTER_MS ? "STALE" : "FRESH"}; built ${new Date(info.mtimeMs).toISOString()}.\n`);
|
|
284
|
+
}
|
|
285
|
+
function keyedNodes(graph) {
|
|
286
|
+
return new Map(graph.nodes.flatMap((node) => {
|
|
287
|
+
if (!node || typeof node !== "object" || Array.isArray(node))
|
|
288
|
+
return [];
|
|
289
|
+
const id = node.id;
|
|
290
|
+
return typeof id === "string" ? [[id, node]] : [];
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
function keyedEdges(graph) {
|
|
294
|
+
return new Map(graphEdges(graph).flatMap((edge) => {
|
|
295
|
+
if (!edge || typeof edge !== "object" || Array.isArray(edge))
|
|
296
|
+
return [];
|
|
297
|
+
const row = edge;
|
|
298
|
+
if (typeof row.source !== "string" || typeof row.target !== "string")
|
|
299
|
+
return [];
|
|
300
|
+
return [[`${row.source}\u0000${row.target}\u0000${String(row.relation ?? row.label ?? "")}`, edge]];
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
function delta(current, previous) {
|
|
304
|
+
let added = 0;
|
|
305
|
+
let removed = 0;
|
|
306
|
+
let changed = 0;
|
|
307
|
+
for (const [key, value] of current) {
|
|
308
|
+
if (!previous.has(key))
|
|
309
|
+
added += 1;
|
|
310
|
+
else if (JSON.stringify(previous.get(key)) !== JSON.stringify(value))
|
|
311
|
+
changed += 1;
|
|
312
|
+
}
|
|
313
|
+
for (const key of previous.keys())
|
|
314
|
+
if (!current.has(key))
|
|
315
|
+
removed += 1;
|
|
316
|
+
return { added, removed, changed };
|
|
317
|
+
}
|
|
318
|
+
async function showDiff(projectRoot) {
|
|
319
|
+
const current = await readGraph(publishedGraphPath(projectRoot));
|
|
320
|
+
const snapshotPath = join(projectRoot, ".planning", "graphs", ".last-build-snapshot.json");
|
|
321
|
+
let previous;
|
|
322
|
+
try {
|
|
323
|
+
previous = await readGraph(snapshotPath);
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (error instanceof Error && error.message.startsWith("no published graph found")) {
|
|
327
|
+
throw new Error("no previous graph snapshot found; run /graphify build again after a code change.");
|
|
328
|
+
}
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
const nodes = delta(keyedNodes(current), keyedNodes(previous));
|
|
332
|
+
const edges = delta(keyedEdges(current), keyedEdges(previous));
|
|
333
|
+
process.stdout.write(`Nodes: ${nodes.added} added, ${nodes.removed} removed, ${nodes.changed} changed.\n`
|
|
334
|
+
+ `Edges: ${edges.added} added, ${edges.removed} removed, ${edges.changed} changed.\n`);
|
|
335
|
+
}
|
|
336
|
+
function validInspectionArguments(command, args) {
|
|
337
|
+
const expected = command === "path" ? 2 : 1;
|
|
338
|
+
return args.length === expected
|
|
339
|
+
&& args.every((value) => value.length > 0 && value.length <= 1024 && !value.startsWith("-"));
|
|
340
|
+
}
|
|
341
|
+
async function main() {
|
|
342
|
+
const [command = "help", ...args] = process.argv.slice(2);
|
|
343
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
344
|
+
process.stdout.write(usage());
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const valid = (command === "build" && (args.length === 0 || (args.length === 1 && args[0] === "--force")))
|
|
348
|
+
|| ((command === "status" || command === "diff") && args.length === 0)
|
|
349
|
+
|| (["query", "explain", "path"].includes(command) && validInspectionArguments(command, args));
|
|
350
|
+
if (!valid) {
|
|
351
|
+
process.stderr.write(usage());
|
|
352
|
+
process.exitCode = 2;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const projectRoot = resolve(process.cwd());
|
|
357
|
+
await requireGraphCapability(projectRoot);
|
|
358
|
+
await ensureSafeGraphBoundary(projectRoot, command === "build");
|
|
359
|
+
if (command === "build") {
|
|
360
|
+
await buildGraph(projectRoot, args[0] === "--force");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (command === "status") {
|
|
364
|
+
await showStatus(projectRoot);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (command === "diff") {
|
|
368
|
+
await showDiff(projectRoot);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
await readGraph(publishedGraphPath(projectRoot));
|
|
372
|
+
const result = executeGraphify(projectRoot, [command, ...args, "--graph", publishedGraphPath(projectRoot)], INSPECTION_TIMEOUT_MS);
|
|
373
|
+
forwardResult(result);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
const message = error instanceof Error ? error.message : "operation failed.";
|
|
377
|
+
process.stderr.write(`cairn graph: ${message}\n`);
|
|
378
|
+
process.exitCode = 1;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
process.stdout.on("error", (error) => {
|
|
382
|
+
if (error.code === "EPIPE")
|
|
383
|
+
process.exit(0);
|
|
384
|
+
throw error;
|
|
385
|
+
});
|
|
386
|
+
await main();
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { canonicalDigest } from "./eval-schema.js";
|
|
4
|
+
import { evaluateSkillProposal } from "./skill-evaluation.js";
|
|
5
|
+
import { applySkillProposal, approveSkillCandidate, harvestSkillCandidates, listSkillApplications, listSkillCandidates, listSkillEvaluations, listSkillProposals, proposeSkill, readSkillApplication, readSkillCandidate, readSkillEvaluation, readSkillProposal, rollbackSkillApplication, } from "./skill-store.js";
|
|
6
|
+
process.stdout.on("error", (error) => {
|
|
7
|
+
if (error.code === "EPIPE")
|
|
8
|
+
process.exit(0);
|
|
9
|
+
throw error;
|
|
10
|
+
});
|
|
11
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
12
|
+
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
13
|
+
const KINDS = ["candidate", "proposal", "evaluation", "application"];
|
|
14
|
+
function usage() {
|
|
15
|
+
return `cairn skill: reviewed, evidence-gated skill improvement
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
cairn skill harvest [--project PATH] [--minimum-occurrences N] [--json]
|
|
19
|
+
cairn skill list [--project PATH] [--kind KIND] [--json]
|
|
20
|
+
cairn skill show --kind KIND --id ID [--project PATH] [--json]
|
|
21
|
+
cairn skill review --candidate ID --approve [--project PATH] [--json]
|
|
22
|
+
cairn skill propose --candidate ID --target RELATIVE_PATH --adapter PATH [--edit-budget N] [--project PATH] [--json]
|
|
23
|
+
cairn skill evaluate --proposal ID --exploration-task-set PATH --confirmation-task-set PATH --adapter PATH
|
|
24
|
+
[--output ROOT] [--repetitions N] [--seed VALUE] [--minimum-improvement N]
|
|
25
|
+
[--project PATH] --yes [--json]
|
|
26
|
+
cairn skill apply --proposal ID --evaluation ID --confirm PROPOSAL_DIGEST [--project PATH] [--json]
|
|
27
|
+
cairn skill rollback --application ID --confirm [--project PATH] [--json]
|
|
28
|
+
|
|
29
|
+
KIND is candidate, proposal, evaluation, or application. Proposal generation
|
|
30
|
+
receives evidence only after review. Evaluation is opt-in with CAIRN_EVAL=1,
|
|
31
|
+
uses disjoint committed exploration and confirmation task sets, and invokes the
|
|
32
|
+
operator-owned adapter. Apply is allowed only for an eligible exact evaluation.
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
const valueFlags = new Set([
|
|
36
|
+
"--project", "--minimum-occurrences", "--kind", "--id", "--candidate", "--target", "--adapter",
|
|
37
|
+
"--edit-budget", "--proposal", "--exploration-task-set", "--confirmation-task-set", "--output",
|
|
38
|
+
"--repetitions", "--seed", "--minimum-improvement", "--evaluation", "--application", "--confirm",
|
|
39
|
+
]);
|
|
40
|
+
const booleanFlags = new Set(["--json", "--approve", "--yes"]);
|
|
41
|
+
function parse(args, allowed) {
|
|
42
|
+
const permitted = new Set(allowed);
|
|
43
|
+
const result = new Map();
|
|
44
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
45
|
+
const flag = args[index];
|
|
46
|
+
if (!flag.startsWith("--") || !permitted.has(flag))
|
|
47
|
+
throw new Error(`Unknown option: ${flag}`);
|
|
48
|
+
if (result.has(flag))
|
|
49
|
+
throw new Error(`Option may be supplied only once: ${flag}`);
|
|
50
|
+
if (flag === "--confirm" && (!args[index + 1] || args[index + 1].startsWith("--"))) {
|
|
51
|
+
result.set(flag, true);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (booleanFlags.has(flag)) {
|
|
55
|
+
result.set(flag, true);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!valueFlags.has(flag))
|
|
59
|
+
throw new Error(`Unsupported option: ${flag}`);
|
|
60
|
+
const value = args[index + 1];
|
|
61
|
+
if (!value || value.startsWith("--"))
|
|
62
|
+
throw new Error(`${flag} requires a value.`);
|
|
63
|
+
result.set(flag, value);
|
|
64
|
+
index += 1;
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
function stringValue(flags, name, required = false) {
|
|
69
|
+
const value = flags.get(name);
|
|
70
|
+
if (required && typeof value !== "string")
|
|
71
|
+
throw new Error(`${name} is required.`);
|
|
72
|
+
return typeof value === "string" ? value : undefined;
|
|
73
|
+
}
|
|
74
|
+
function positiveInteger(flags, name, fallback) {
|
|
75
|
+
const raw = stringValue(flags, name);
|
|
76
|
+
if (raw === undefined)
|
|
77
|
+
return fallback;
|
|
78
|
+
if (!/^[1-9][0-9]*$/.test(raw))
|
|
79
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
80
|
+
const value = Number(raw);
|
|
81
|
+
if (!Number.isSafeInteger(value))
|
|
82
|
+
throw new Error(`${name} is outside the supported range.`);
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
function identifier(flags, name) {
|
|
86
|
+
const value = stringValue(flags, name, true);
|
|
87
|
+
if (!ID_PATTERN.test(value))
|
|
88
|
+
throw new Error(`${name} must be a canonical artifact ID.`);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function projectRoot(flags) {
|
|
92
|
+
return resolve(stringValue(flags, "--project") ?? process.cwd());
|
|
93
|
+
}
|
|
94
|
+
function kind(flags, fallback) {
|
|
95
|
+
const value = stringValue(flags, "--kind") ?? fallback;
|
|
96
|
+
if (!value || !KINDS.includes(value))
|
|
97
|
+
throw new Error(`--kind must be one of: ${KINDS.join(", ")}.`);
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function emit(value, json) {
|
|
101
|
+
process.stdout.write(`${JSON.stringify(value, null, json ? 0 : 2)}\n`);
|
|
102
|
+
}
|
|
103
|
+
function list(root, selected) {
|
|
104
|
+
const values = {
|
|
105
|
+
candidates: listSkillCandidates(root),
|
|
106
|
+
proposals: listSkillProposals(root),
|
|
107
|
+
evaluations: listSkillEvaluations(root),
|
|
108
|
+
applications: listSkillApplications(root),
|
|
109
|
+
};
|
|
110
|
+
if (!selected)
|
|
111
|
+
return values;
|
|
112
|
+
return values[`${selected}s`];
|
|
113
|
+
}
|
|
114
|
+
function show(root, selected, id) {
|
|
115
|
+
if (selected === "candidate")
|
|
116
|
+
return readSkillCandidate(root, id);
|
|
117
|
+
if (selected === "proposal")
|
|
118
|
+
return readSkillProposal(root, id);
|
|
119
|
+
if (selected === "evaluation")
|
|
120
|
+
return readSkillEvaluation(root, id);
|
|
121
|
+
return readSkillApplication(root, id);
|
|
122
|
+
}
|
|
123
|
+
async function main() {
|
|
124
|
+
const [command = "help", ...args] = process.argv.slice(2);
|
|
125
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
126
|
+
process.stdout.write(usage());
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (command === "harvest") {
|
|
130
|
+
const flags = parse(args, ["--project", "--minimum-occurrences", "--json"]);
|
|
131
|
+
emit(harvestSkillCandidates({
|
|
132
|
+
projectRoot: projectRoot(flags),
|
|
133
|
+
minimumOccurrences: positiveInteger(flags, "--minimum-occurrences"),
|
|
134
|
+
}), flags.has("--json"));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (command === "list") {
|
|
138
|
+
const flags = parse(args, ["--project", "--kind", "--json"]);
|
|
139
|
+
const selected = stringValue(flags, "--kind") ? kind(flags) : undefined;
|
|
140
|
+
emit(list(projectRoot(flags), selected), flags.has("--json"));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (command === "show") {
|
|
144
|
+
const flags = parse(args, ["--project", "--kind", "--id", "--json"]);
|
|
145
|
+
emit(show(projectRoot(flags), kind(flags), identifier(flags, "--id")), flags.has("--json"));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (command === "review") {
|
|
149
|
+
const flags = parse(args, ["--project", "--candidate", "--approve", "--json"]);
|
|
150
|
+
if (!flags.has("--approve"))
|
|
151
|
+
throw new Error("Review currently requires --approve.");
|
|
152
|
+
emit(approveSkillCandidate(projectRoot(flags), identifier(flags, "--candidate")), flags.has("--json"));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (command === "propose") {
|
|
156
|
+
const flags = parse(args, ["--project", "--candidate", "--target", "--adapter", "--edit-budget", "--json"]);
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const cancel = () => controller.abort();
|
|
159
|
+
process.once("SIGINT", cancel);
|
|
160
|
+
process.once("SIGTERM", cancel);
|
|
161
|
+
try {
|
|
162
|
+
const proposal = await proposeSkill({
|
|
163
|
+
projectRoot: projectRoot(flags),
|
|
164
|
+
candidateId: identifier(flags, "--candidate"),
|
|
165
|
+
targetPath: stringValue(flags, "--target", true),
|
|
166
|
+
adapterPath: stringValue(flags, "--adapter", true),
|
|
167
|
+
editBudget: positiveInteger(flags, "--edit-budget"),
|
|
168
|
+
signal: controller.signal,
|
|
169
|
+
});
|
|
170
|
+
emit({ ...proposal, proposal_digest: canonicalDigest(proposal) }, flags.has("--json"));
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
process.removeListener("SIGINT", cancel);
|
|
174
|
+
process.removeListener("SIGTERM", cancel);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (command === "evaluate") {
|
|
179
|
+
const flags = parse(args, [
|
|
180
|
+
"--project", "--proposal", "--exploration-task-set", "--confirmation-task-set", "--adapter",
|
|
181
|
+
"--output", "--repetitions", "--seed", "--minimum-improvement", "--yes", "--json",
|
|
182
|
+
]);
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const cancel = () => controller.abort();
|
|
185
|
+
process.once("SIGINT", cancel);
|
|
186
|
+
process.once("SIGTERM", cancel);
|
|
187
|
+
try {
|
|
188
|
+
emit(await evaluateSkillProposal({
|
|
189
|
+
projectRoot: projectRoot(flags),
|
|
190
|
+
proposalId: identifier(flags, "--proposal"),
|
|
191
|
+
explorationTaskSetPath: stringValue(flags, "--exploration-task-set", true),
|
|
192
|
+
confirmationTaskSetPath: stringValue(flags, "--confirmation-task-set", true),
|
|
193
|
+
adapterPath: stringValue(flags, "--adapter", true),
|
|
194
|
+
outputRoot: stringValue(flags, "--output"),
|
|
195
|
+
repetitions: positiveInteger(flags, "--repetitions"),
|
|
196
|
+
seed: stringValue(flags, "--seed"),
|
|
197
|
+
minimumImprovement: positiveInteger(flags, "--minimum-improvement"),
|
|
198
|
+
confirm: flags.has("--yes"),
|
|
199
|
+
signal: controller.signal,
|
|
200
|
+
}), flags.has("--json"));
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
process.removeListener("SIGINT", cancel);
|
|
204
|
+
process.removeListener("SIGTERM", cancel);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (command === "apply") {
|
|
209
|
+
const flags = parse(args, ["--project", "--proposal", "--evaluation", "--confirm", "--json"]);
|
|
210
|
+
const confirm = flags.get("--confirm");
|
|
211
|
+
if (typeof confirm !== "string" || !DIGEST_PATTERN.test(confirm)) {
|
|
212
|
+
throw new Error("--confirm requires the exact 64-character proposal digest.");
|
|
213
|
+
}
|
|
214
|
+
emit(applySkillProposal({
|
|
215
|
+
projectRoot: projectRoot(flags),
|
|
216
|
+
proposalId: identifier(flags, "--proposal"),
|
|
217
|
+
evaluationId: identifier(flags, "--evaluation"),
|
|
218
|
+
confirmDigest: confirm,
|
|
219
|
+
}), flags.has("--json"));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (command === "rollback") {
|
|
223
|
+
const flags = parse(args, ["--project", "--application", "--confirm", "--json"]);
|
|
224
|
+
emit(rollbackSkillApplication({
|
|
225
|
+
projectRoot: projectRoot(flags),
|
|
226
|
+
applicationId: identifier(flags, "--application"),
|
|
227
|
+
confirm: flags.has("--confirm"),
|
|
228
|
+
}), flags.has("--json"));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`Unknown skill command: ${command}`);
|
|
232
|
+
}
|
|
233
|
+
main().catch((error) => {
|
|
234
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
});
|