@cairnkeep/cli 2.11.0 → 2.13.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 +33 -1
- package/README.md +51 -7
- package/docs/building-an-overlay.md +1 -1
- package/docs/harness-compatibility.md +40 -1
- package/docs/learning/CURRICULUM-MAP.md +10 -7
- package/docs/learning/FEATURE-GUIDE.md +14 -5
- package/docs/learning/README.md +4 -3
- 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 +16 -9
- package/docs/learning/lessons/L03-first-project.md +34 -15
- package/docs/learning/lessons/L13-session-evidence.md +48 -2
- 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 +1 -1
- package/docs/learning/lessons/L22-native-windows.md +9 -9
- package/docs/learning/lessons/L23-guided-setup.md +6 -2
- package/docs/learning/tracks/evidence-and-evaluation.md +2 -1
- package/docs/learning/tracks/quickstart.md +5 -4
- package/docs/native-windows.md +23 -0
- package/docs/operating.md +69 -5
- package/docs/privacy-and-data-flow.md +44 -1
- package/docs/quickstart.md +91 -0
- package/docs/storage.md +26 -0
- package/docs/work-evidence.md +80 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-cli.js +6 -2
- package/mcp-memory-server/dist/index.js +27 -0
- package/mcp-memory-server/dist/mcp-tool-catalog.js +2 -0
- package/mcp-memory-server/dist/trajectory-cli.js +9 -3
- package/mcp-memory-server/dist/work-evidence-cli.js +121 -0
- package/mcp-memory-server/dist/work-evidence-schema.js +121 -0
- package/mcp-memory-server/dist/work-evidence-store.js +516 -0
- package/mcp-memory-server/package.json +2 -1
- package/package.json +2 -1
- package/schemas/cairnkeep-setup-policy.schema.json +3 -3
- package/schemas/cairnkeep-setup.schema.json +2 -2
- package/scripts/bootstrap.sh +7 -15
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +19 -9
- package/scripts/doctor.sh +35 -5
- package/scripts/harness-registry.mjs +126 -0
- package/scripts/setup-core.mjs +4 -2
- package/scripts/setup-reconcile.mjs +3 -2
- package/scripts/setup.mjs +41 -5
- package/scripts/test-bootstrap-untracked.sh +2 -1
- package/scripts/test-cli-dispatch.sh +3 -1
- package/scripts/test-completion.sh +6 -0
- package/scripts/test-harness-registry.sh +94 -0
- package/scripts/test-package-install.sh +9 -0
- package/scripts/test-phase19-eval-lifecycle.sh +12 -2
- package/scripts/test-setup-compatibility.mjs +5 -4
- package/scripts/test-uninstall.sh +10 -0
- package/scripts/test-windows-native.mjs +27 -1
- package/scripts/test-work-evidence-cli.sh +89 -0
- package/scripts/uninstall.sh +7 -3
- package/scripts/verify-docs-parity.sh +36 -0
- package/scripts/windows-platform.mjs +28 -7
- package/templates/codex-config.toml.template +5 -0
- package/templates/env.example.template +10 -0
- package/templates/start-claude.sh.template +18 -0
- package/templates/start-codex.sh.template +48 -0
- package/templates/start-kimi.sh.template +17 -0
- package/templates/start-opencode.sh.template +17 -0
- package/templates/start-pi.sh.template +17 -0
- package/templates/start-qwen.sh.template +17 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { getArtifactLimits, isArtifactStoreEnabled } from "./artifact-schema.js";
|
|
6
|
+
import { putArtifact } from "./artifact-store.js";
|
|
7
|
+
import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
|
|
8
|
+
import { WORK_EVIDENCE_SCHEMA_VERSION, completeWorkEvidenceSchema, getWorkEvidenceLimits, isWorkEvidenceEnabled, isWorkEvidencePatchEnabled, pendingWorkEvidenceSchema, storedWorkEvidenceSchema, workEvidenceIdSchema, workEvidenceLinkSchema, } from "./work-evidence-schema.js";
|
|
9
|
+
const MAX_RECORD_BYTES = 1024 * 1024;
|
|
10
|
+
const STALE_PENDING_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const EMPTY_DIGEST = createHash("sha256").update("").digest("hex");
|
|
12
|
+
function sha256(value) {
|
|
13
|
+
return createHash("sha256").update(value).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
function canonical(value) {
|
|
16
|
+
if (Array.isArray(value))
|
|
17
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
18
|
+
if (value && typeof value === "object") {
|
|
19
|
+
return `{${Object.entries(value)
|
|
20
|
+
.sort(([left], [right]) => left.localeCompare(right, "en"))
|
|
21
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`).join(",")}}`;
|
|
22
|
+
}
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
function storeRoot(projectRoot) {
|
|
26
|
+
return resolve(projectRoot, ".agentfs", "work-evidence", "v1");
|
|
27
|
+
}
|
|
28
|
+
function recordsRoot(projectRoot) {
|
|
29
|
+
return join(storeRoot(projectRoot), "records");
|
|
30
|
+
}
|
|
31
|
+
function linksRoot(projectRoot, evidenceId) {
|
|
32
|
+
return join(storeRoot(projectRoot), "links", evidenceId);
|
|
33
|
+
}
|
|
34
|
+
function ensureStoreHierarchy(projectRoot, create) {
|
|
35
|
+
const project = resolve(projectRoot);
|
|
36
|
+
const paths = [join(project, ".agentfs"), join(project, ".agentfs", "work-evidence"), storeRoot(project)];
|
|
37
|
+
for (const path of paths) {
|
|
38
|
+
if (!existsSync(path)) {
|
|
39
|
+
if (!create)
|
|
40
|
+
return false;
|
|
41
|
+
mkdirSync(path, { mode: 0o700 });
|
|
42
|
+
}
|
|
43
|
+
const info = lstatSync(path);
|
|
44
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
45
|
+
throw new Error("Work-evidence storage ancestors must be real directories.");
|
|
46
|
+
hardenPrivatePath(path);
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
function assertSafeDirectory(path, create) {
|
|
51
|
+
const parent = dirname(path);
|
|
52
|
+
if (existsSync(parent) && lstatSync(parent).isSymbolicLink())
|
|
53
|
+
throw new Error("Work-evidence storage parent must not be a symlink.");
|
|
54
|
+
if (!existsSync(path)) {
|
|
55
|
+
if (!create)
|
|
56
|
+
return;
|
|
57
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
58
|
+
}
|
|
59
|
+
const info = lstatSync(path);
|
|
60
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
61
|
+
throw new Error("Work-evidence storage must be a real directory.");
|
|
62
|
+
hardenPrivatePath(path);
|
|
63
|
+
}
|
|
64
|
+
function atomicJson(path, value) {
|
|
65
|
+
assertSafeDirectory(dirname(path), true);
|
|
66
|
+
const content = `${canonical(value)}\n`;
|
|
67
|
+
if (Buffer.byteLength(content) > MAX_RECORD_BYTES)
|
|
68
|
+
throw new Error("Work-evidence record exceeds its byte limit.");
|
|
69
|
+
const temporary = join(dirname(path), `.tmp-${basename(path)}-${process.pid}-${randomUUID()}`);
|
|
70
|
+
writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
71
|
+
hardenPrivatePath(temporary);
|
|
72
|
+
if (process.platform === "win32" && existsSync(path)) {
|
|
73
|
+
const backup = `${path}.replace-backup-${randomUUID()}`;
|
|
74
|
+
const result = spawnSync("powershell.exe", [
|
|
75
|
+
"-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
|
|
76
|
+
"[IO.File]::Replace($env:CK_INTERNAL_ATOMIC_SOURCE,$env:CK_INTERNAL_ATOMIC_DESTINATION,$env:CK_INTERNAL_ATOMIC_BACKUP,$true)",
|
|
77
|
+
], {
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
env: { ...process.env, CK_INTERNAL_ATOMIC_SOURCE: temporary, CK_INTERNAL_ATOMIC_DESTINATION: path, CK_INTERNAL_ATOMIC_BACKUP: backup },
|
|
81
|
+
});
|
|
82
|
+
rmSync(backup, { force: true });
|
|
83
|
+
if (result.status !== 0) {
|
|
84
|
+
rmSync(temporary, { force: true });
|
|
85
|
+
throw new Error("Unable to atomically replace the work-evidence record.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else
|
|
89
|
+
renameSync(temporary, path);
|
|
90
|
+
hardenPrivatePath(path);
|
|
91
|
+
}
|
|
92
|
+
function readJson(path) {
|
|
93
|
+
const info = lstatSync(path);
|
|
94
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_RECORD_BYTES || !privatePathIsSafe(path)) {
|
|
95
|
+
throw new Error("Unsafe work-evidence record.");
|
|
96
|
+
}
|
|
97
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
98
|
+
}
|
|
99
|
+
function gitBuffer(root, args, maxBuffer = 16 * 1024 * 1024) {
|
|
100
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
101
|
+
encoding: "buffer",
|
|
102
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
103
|
+
windowsHide: true,
|
|
104
|
+
maxBuffer,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function gitText(root, args) {
|
|
108
|
+
return gitBuffer(root, args).toString("utf8").trim();
|
|
109
|
+
}
|
|
110
|
+
function safePathLabel(raw) {
|
|
111
|
+
const decoded = raw.toString("utf8");
|
|
112
|
+
const roundTrip = Buffer.from(decoded, "utf8").equals(raw);
|
|
113
|
+
const normalized = decoded.replaceAll("\\", "/");
|
|
114
|
+
if (roundTrip
|
|
115
|
+
&& normalized.length > 0
|
|
116
|
+
&& normalized.length <= 4096
|
|
117
|
+
&& !normalized.startsWith("/")
|
|
118
|
+
&& !normalized.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
|
119
|
+
&& !/[\u0000-\u001f\u007f]/.test(normalized))
|
|
120
|
+
return normalized;
|
|
121
|
+
return `opaque-path-${sha256(raw).slice(0, 24)}`;
|
|
122
|
+
}
|
|
123
|
+
function statusEntries(root) {
|
|
124
|
+
const output = gitBuffer(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
125
|
+
const fields = [];
|
|
126
|
+
let start = 0;
|
|
127
|
+
for (let index = 0; index < output.length; index += 1) {
|
|
128
|
+
if (output[index] !== 0)
|
|
129
|
+
continue;
|
|
130
|
+
fields.push(output.subarray(start, index));
|
|
131
|
+
start = index + 1;
|
|
132
|
+
}
|
|
133
|
+
const result = [];
|
|
134
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
135
|
+
const field = fields[index];
|
|
136
|
+
if (field.length < 4 || field[2] !== 0x20)
|
|
137
|
+
continue;
|
|
138
|
+
const code = field.subarray(0, 2).toString("ascii");
|
|
139
|
+
const path = safePathLabel(field.subarray(3));
|
|
140
|
+
if (/^[RC]/.test(code) || /[RC]$/.test(code)) {
|
|
141
|
+
const original = fields[++index];
|
|
142
|
+
result.push({ code, path, ...(original ? { original: safePathLabel(original) } : {}) });
|
|
143
|
+
}
|
|
144
|
+
else
|
|
145
|
+
result.push({ code, path });
|
|
146
|
+
}
|
|
147
|
+
return result
|
|
148
|
+
.filter((entry) => entry.path !== ".agentfs/work-evidence" && !entry.path.startsWith(".agentfs/work-evidence/"))
|
|
149
|
+
.sort((left, right) => left.path.localeCompare(right.path, "en") || left.code.localeCompare(right.code, "en"));
|
|
150
|
+
}
|
|
151
|
+
function pathDigest(root, entry) {
|
|
152
|
+
let indexState = "";
|
|
153
|
+
let worktreeState = "missing";
|
|
154
|
+
try {
|
|
155
|
+
indexState = gitText(root, ["ls-files", "-s", "--", entry.path]);
|
|
156
|
+
}
|
|
157
|
+
catch { /* absent from index */ }
|
|
158
|
+
try {
|
|
159
|
+
worktreeState = gitText(root, ["hash-object", "--no-filters", "--", entry.path]);
|
|
160
|
+
}
|
|
161
|
+
catch { /* deleted or unreadable */ }
|
|
162
|
+
return sha256(canonical({ ...entry, index_state: indexState, worktree_state: worktreeState }));
|
|
163
|
+
}
|
|
164
|
+
function resolveGitRoot(projectRoot) {
|
|
165
|
+
return resolve(gitText(resolve(projectRoot), ["rev-parse", "--show-toplevel"]));
|
|
166
|
+
}
|
|
167
|
+
export function inspectGitState(projectRoot) {
|
|
168
|
+
const root = resolveGitRoot(projectRoot);
|
|
169
|
+
let headCommit = null;
|
|
170
|
+
try {
|
|
171
|
+
headCommit = gitText(root, ["rev-parse", "--verify", "HEAD"]);
|
|
172
|
+
}
|
|
173
|
+
catch { /* unborn */ }
|
|
174
|
+
let branch = null;
|
|
175
|
+
try {
|
|
176
|
+
branch = gitText(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) || null;
|
|
177
|
+
}
|
|
178
|
+
catch { /* detached */ }
|
|
179
|
+
const entries = statusEntries(root);
|
|
180
|
+
const pathFingerprints = entries.map((entry) => ({ path: entry.path, state_digest: pathDigest(root, entry) }));
|
|
181
|
+
const statusDigest = entries.length ? sha256(canonical(entries)) : EMPTY_DIGEST;
|
|
182
|
+
const workspaceDiffDigest = pathFingerprints.length ? sha256(canonical(pathFingerprints)) : EMPTY_DIGEST;
|
|
183
|
+
return {
|
|
184
|
+
root,
|
|
185
|
+
snapshot: {
|
|
186
|
+
head_commit: headCommit,
|
|
187
|
+
branch,
|
|
188
|
+
detached: headCommit !== null && branch === null,
|
|
189
|
+
unborn: headCommit === null,
|
|
190
|
+
dirty: entries.length > 0,
|
|
191
|
+
status_digest: statusDigest,
|
|
192
|
+
workspace_diff_digest: workspaceDiffDigest,
|
|
193
|
+
},
|
|
194
|
+
pathFingerprints,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function recordPath(projectRoot, evidenceId) {
|
|
198
|
+
return join(recordsRoot(projectRoot), `${workEvidenceIdSchema.parse(evidenceId)}.json`);
|
|
199
|
+
}
|
|
200
|
+
function readStored(evidenceId, projectRoot) {
|
|
201
|
+
return storedWorkEvidenceSchema.parse(readJson(recordPath(projectRoot, evidenceId)));
|
|
202
|
+
}
|
|
203
|
+
function readLinks(evidenceId, projectRoot) {
|
|
204
|
+
const root = linksRoot(projectRoot, evidenceId);
|
|
205
|
+
if (!existsSync(root))
|
|
206
|
+
return [];
|
|
207
|
+
assertSafeDirectory(root, false);
|
|
208
|
+
return readdirSync(root).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort()
|
|
209
|
+
.map((name) => workEvidenceLinkSchema.parse(readJson(join(root, name))));
|
|
210
|
+
}
|
|
211
|
+
function resolveIdentifier(identifier, projectRoot) {
|
|
212
|
+
const records = recordsRoot(projectRoot);
|
|
213
|
+
if (!existsSync(records))
|
|
214
|
+
throw new Error(`Work evidence "${identifier}" not found.`);
|
|
215
|
+
assertSafeDirectory(records, false);
|
|
216
|
+
const ids = readdirSync(records).filter((name) => /^wev_[0-9a-f-]{36}\.json$/i.test(name)).map((name) => name.slice(0, -5));
|
|
217
|
+
const matches = ids.filter((id) => id === identifier || id.startsWith(identifier));
|
|
218
|
+
if (matches.length === 0)
|
|
219
|
+
throw new Error(`Work evidence "${identifier}" not found.`);
|
|
220
|
+
const exact = matches.find((id) => id === identifier);
|
|
221
|
+
if (!exact && matches.length > 1)
|
|
222
|
+
throw new Error(`Work-evidence prefix "${identifier}" is ambiguous.`);
|
|
223
|
+
return exact ?? matches[0];
|
|
224
|
+
}
|
|
225
|
+
export function startWorkEvidence(projectRoot, harness, now = new Date()) {
|
|
226
|
+
if (!isWorkEvidenceEnabled())
|
|
227
|
+
throw new Error("Work evidence is not enabled.");
|
|
228
|
+
const state = inspectGitState(projectRoot);
|
|
229
|
+
ensureStoreHierarchy(state.root, true);
|
|
230
|
+
const evidence = pendingWorkEvidenceSchema.parse({
|
|
231
|
+
schema_version: WORK_EVIDENCE_SCHEMA_VERSION,
|
|
232
|
+
evidence_id: `wev_${randomUUID()}`,
|
|
233
|
+
status: "pending",
|
|
234
|
+
harness,
|
|
235
|
+
started_at: now.toISOString(),
|
|
236
|
+
start: state.snapshot,
|
|
237
|
+
path_fingerprints: state.pathFingerprints.slice(0, getWorkEvidenceLimits().maxTouchedPaths),
|
|
238
|
+
});
|
|
239
|
+
atomicJson(recordPath(state.root, evidence.evidence_id), evidence);
|
|
240
|
+
return evidence;
|
|
241
|
+
}
|
|
242
|
+
function changedPaths(start, end, limit) {
|
|
243
|
+
const before = new Map(start.path_fingerprints.map((entry) => [entry.path, entry.state_digest]));
|
|
244
|
+
const after = new Map(end.pathFingerprints.map((entry) => [entry.path, entry.state_digest]));
|
|
245
|
+
const paths = Array.from(new Set([...before.keys(), ...after.keys()]))
|
|
246
|
+
.filter((path) => before.get(path) !== after.get(path))
|
|
247
|
+
.sort((left, right) => left.localeCompare(right, "en"));
|
|
248
|
+
return { paths: paths.slice(0, limit), omitted: Math.max(0, paths.length - limit) };
|
|
249
|
+
}
|
|
250
|
+
function capturePatch(root, start, maxBytes) {
|
|
251
|
+
if (!isWorkEvidencePatchEnabled())
|
|
252
|
+
return { reason: "not-requested" };
|
|
253
|
+
if (!isArtifactStoreEnabled())
|
|
254
|
+
return { reason: "artifact-store-disabled" };
|
|
255
|
+
if (!start.start.head_commit)
|
|
256
|
+
return { reason: "unborn-start" };
|
|
257
|
+
try {
|
|
258
|
+
const output = gitBuffer(root, ["diff", "--binary", "--full-index", "--no-ext-diff", start.start.head_commit, "--"], Math.min(64 * 1024 * 1024, Math.max(16 * 1024 * 1024, maxBytes * 4)));
|
|
259
|
+
if (output.length === 0)
|
|
260
|
+
return { reason: "unchanged" };
|
|
261
|
+
return { text: output.toString("utf8") };
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return { reason: "capture-failed" };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
export async function finishWorkEvidence(projectRoot, evidenceId, exitStatus, options = {}) {
|
|
268
|
+
const root = resolveGitRoot(projectRoot);
|
|
269
|
+
const start = readStored(workEvidenceIdSchema.parse(evidenceId), root);
|
|
270
|
+
if (start.status === "complete")
|
|
271
|
+
return start;
|
|
272
|
+
const limits = options.limits ?? getWorkEvidenceLimits();
|
|
273
|
+
const end = inspectGitState(root);
|
|
274
|
+
if (end.root !== root)
|
|
275
|
+
throw new Error("Work-evidence repository identity changed during the session.");
|
|
276
|
+
const touched = changedPaths(start, end, limits.maxTouchedPaths);
|
|
277
|
+
const patchCapture = capturePatch(root, start, limits.patchMaxBytes);
|
|
278
|
+
let artifactId = null;
|
|
279
|
+
if (patchCapture.text !== undefined) {
|
|
280
|
+
const artifactLimits = getArtifactLimits();
|
|
281
|
+
const result = await putArtifact(root, {
|
|
282
|
+
kind: "diff",
|
|
283
|
+
session_ref: start.evidence_id,
|
|
284
|
+
media_type: "text/x-diff",
|
|
285
|
+
provenance: {
|
|
286
|
+
producer: "cairn-work-evidence",
|
|
287
|
+
source_event: "launcher-exit",
|
|
288
|
+
harness: start.harness === "claude" ? "claude-code" : ["opencode", "pi"].includes(start.harness) ? start.harness : undefined,
|
|
289
|
+
},
|
|
290
|
+
content: { text: patchCapture.text },
|
|
291
|
+
}, { ...artifactLimits, artifactMaxBytes: Math.min(artifactLimits.artifactMaxBytes, limits.patchMaxBytes) });
|
|
292
|
+
artifactId = result.artifact.artifact_id;
|
|
293
|
+
await appendWorkEvidenceLink(root, start.evidence_id, { kind: "artifact", artifact_id: artifactId });
|
|
294
|
+
}
|
|
295
|
+
const completed = completeWorkEvidenceSchema.parse({
|
|
296
|
+
schema_version: WORK_EVIDENCE_SCHEMA_VERSION,
|
|
297
|
+
evidence_id: start.evidence_id,
|
|
298
|
+
status: "complete",
|
|
299
|
+
harness: start.harness,
|
|
300
|
+
started_at: start.started_at,
|
|
301
|
+
ended_at: (options.now ?? new Date()).toISOString(),
|
|
302
|
+
exit_status: Math.max(0, Math.min(255, exitStatus)),
|
|
303
|
+
start: start.start,
|
|
304
|
+
end: end.snapshot,
|
|
305
|
+
touched_paths: touched.paths,
|
|
306
|
+
omitted_touched_paths: touched.omitted,
|
|
307
|
+
change_digest: sha256(canonical({ start: start.start, end: end.snapshot, touched_paths: touched.paths, omitted: touched.omitted })),
|
|
308
|
+
patch: {
|
|
309
|
+
requested: isWorkEvidencePatchEnabled(),
|
|
310
|
+
scope: artifactId ? "end-worktree-vs-start-commit" : null,
|
|
311
|
+
artifact_id: artifactId,
|
|
312
|
+
unavailable_reason: artifactId ? null : (patchCapture.reason ?? "capture-failed"),
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
atomicJson(recordPath(root, start.evidence_id), completed);
|
|
316
|
+
await pruneWorkEvidence(root, limits, { dryRun: false, preserve: completed.evidence_id });
|
|
317
|
+
return completed;
|
|
318
|
+
}
|
|
319
|
+
export async function appendWorkEvidenceLink(projectRoot, evidenceId, value, now = new Date()) {
|
|
320
|
+
if (!isWorkEvidenceEnabled())
|
|
321
|
+
return null;
|
|
322
|
+
const id = workEvidenceIdSchema.parse(evidenceId);
|
|
323
|
+
const root = resolve(projectRoot);
|
|
324
|
+
if (!ensureStoreHierarchy(root, false))
|
|
325
|
+
return null;
|
|
326
|
+
if (!existsSync(recordPath(root, id)))
|
|
327
|
+
return null;
|
|
328
|
+
const linkId = sha256(canonical({ evidence_id: id, ...value }));
|
|
329
|
+
const link = workEvidenceLinkSchema.parse({
|
|
330
|
+
schema_version: WORK_EVIDENCE_SCHEMA_VERSION,
|
|
331
|
+
link_id: linkId,
|
|
332
|
+
evidence_id: id,
|
|
333
|
+
created_at: now.toISOString(),
|
|
334
|
+
...value,
|
|
335
|
+
});
|
|
336
|
+
const path = join(linksRoot(root, id), `${linkId}.json`);
|
|
337
|
+
if (!existsSync(path))
|
|
338
|
+
atomicJson(path, link);
|
|
339
|
+
return link;
|
|
340
|
+
}
|
|
341
|
+
export async function linkActiveWorkEvidence(projectRoot, value) {
|
|
342
|
+
const evidenceId = process.env.CAIRN_WORK_EVIDENCE_ID?.trim();
|
|
343
|
+
if (!evidenceId || !isWorkEvidenceEnabled())
|
|
344
|
+
return null;
|
|
345
|
+
try {
|
|
346
|
+
const root = process.env.CAIRN_WORK_EVIDENCE_ROOT?.trim() || projectRoot;
|
|
347
|
+
return await appendWorkEvidenceLink(resolve(root), evidenceId, value);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function storedWorkEvidenceRows(projectRoot, status) {
|
|
354
|
+
if (!ensureStoreHierarchy(projectRoot, false))
|
|
355
|
+
return [];
|
|
356
|
+
const root = recordsRoot(projectRoot);
|
|
357
|
+
if (!existsSync(root))
|
|
358
|
+
return [];
|
|
359
|
+
assertSafeDirectory(root, false);
|
|
360
|
+
return readdirSync(root).filter((name) => /^wev_[0-9a-f-]{36}\.json$/i.test(name)).map((name) => {
|
|
361
|
+
const path = join(root, name);
|
|
362
|
+
const stored = storedWorkEvidenceSchema.parse(readJson(path));
|
|
363
|
+
const links = readLinks(stored.evidence_id, projectRoot);
|
|
364
|
+
const linkBytes = links.reduce((sum, link) => sum + statSync(join(linksRoot(projectRoot, stored.evidence_id), `${link.link_id}.json`)).size, 0);
|
|
365
|
+
return { stored, links, bytes: statSync(path).size + linkBytes };
|
|
366
|
+
}).filter(({ stored }) => !status || stored.status === status)
|
|
367
|
+
.sort((left, right) => Date.parse(right.stored.started_at) - Date.parse(left.stored.started_at)
|
|
368
|
+
|| right.stored.evidence_id.localeCompare(left.stored.evidence_id, "en"));
|
|
369
|
+
}
|
|
370
|
+
export function listWorkEvidence(projectRoot = process.cwd(), options = {}) {
|
|
371
|
+
const rows = storedWorkEvidenceRows(projectRoot, options.status);
|
|
372
|
+
const limit = options.limit ?? 100;
|
|
373
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
374
|
+
throw new Error("Work-evidence list limit must be between 1 and 1000.");
|
|
375
|
+
return {
|
|
376
|
+
schema_version: WORK_EVIDENCE_SCHEMA_VERSION,
|
|
377
|
+
evidence: rows.slice(0, limit).map(({ stored, links }) => ({ ...stored, links })),
|
|
378
|
+
logical_bytes: rows.reduce((sum, row) => sum + row.bytes, 0),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
export function readWorkEvidence(identifier, projectRoot = process.cwd()) {
|
|
382
|
+
const evidenceId = resolveIdentifier(identifier, projectRoot);
|
|
383
|
+
const stored = readStored(evidenceId, projectRoot);
|
|
384
|
+
return { ...stored, links: readLinks(evidenceId, projectRoot) };
|
|
385
|
+
}
|
|
386
|
+
export function deleteWorkEvidence(identifier, projectRoot = process.cwd(), dryRun = false) {
|
|
387
|
+
const evidenceId = resolveIdentifier(identifier, projectRoot);
|
|
388
|
+
if (!dryRun) {
|
|
389
|
+
rmSync(recordPath(projectRoot, evidenceId), { force: true });
|
|
390
|
+
rmSync(linksRoot(projectRoot, evidenceId), { recursive: true, force: true });
|
|
391
|
+
}
|
|
392
|
+
return { schema_version: WORK_EVIDENCE_SCHEMA_VERSION, evidence_id: evidenceId, deleted: !dryRun, dry_run: dryRun };
|
|
393
|
+
}
|
|
394
|
+
export function pruneWorkEvidence(projectRoot, limits = getWorkEvidenceLimits(), options = {}) {
|
|
395
|
+
const rows = storedWorkEvidenceRows(projectRoot);
|
|
396
|
+
const evidence = rows.map(({ stored, links }) => ({ ...stored, links }));
|
|
397
|
+
const cutoff = Date.now() - limits.retentionDays * 86400000;
|
|
398
|
+
const complete = evidence.filter((row) => row.status === "complete")
|
|
399
|
+
.sort((left, right) => Date.parse(left.ended_at) - Date.parse(right.ended_at));
|
|
400
|
+
const removed = new Map();
|
|
401
|
+
const bytesByEvidence = new Map(rows.map(({ stored, bytes }) => [stored.evidence_id, bytes]));
|
|
402
|
+
for (const row of complete) {
|
|
403
|
+
if (row.evidence_id !== options.preserve && Date.parse(row.ended_at) < cutoff)
|
|
404
|
+
removed.set(row.evidence_id, "age");
|
|
405
|
+
}
|
|
406
|
+
let total = rows.reduce((sum, row) => sum + row.bytes, 0);
|
|
407
|
+
for (const row of complete) {
|
|
408
|
+
if (total <= limits.storeMaxBytes)
|
|
409
|
+
break;
|
|
410
|
+
if (row.evidence_id === options.preserve || removed.has(row.evidence_id))
|
|
411
|
+
continue;
|
|
412
|
+
removed.set(row.evidence_id, "store_budget");
|
|
413
|
+
total -= bytesByEvidence.get(row.evidence_id) ?? 0;
|
|
414
|
+
}
|
|
415
|
+
if (!options.dryRun)
|
|
416
|
+
for (const evidenceId of removed.keys())
|
|
417
|
+
deleteWorkEvidence(evidenceId, projectRoot, false);
|
|
418
|
+
return {
|
|
419
|
+
schema_version: WORK_EVIDENCE_SCHEMA_VERSION,
|
|
420
|
+
dry_run: options.dryRun ?? false,
|
|
421
|
+
removed: Array.from(removed, ([evidence_id, reason]) => ({ evidence_id, reason })),
|
|
422
|
+
remaining_evidence: evidence.length - removed.size,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
export function doctorWorkEvidence(projectRoot = process.cwd(), repair = false) {
|
|
426
|
+
const root = storeRoot(projectRoot);
|
|
427
|
+
if (!existsSync(root))
|
|
428
|
+
return { schema_version: 1, exists: false, ok: true, repaired: false, records: 0, links: 0, issues: [] };
|
|
429
|
+
const issues = [];
|
|
430
|
+
let records = 0;
|
|
431
|
+
let links = 0;
|
|
432
|
+
let repaired = false;
|
|
433
|
+
const temporary = [];
|
|
434
|
+
try {
|
|
435
|
+
ensureStoreHierarchy(projectRoot, false);
|
|
436
|
+
assertSafeDirectory(root, false);
|
|
437
|
+
const recordDir = recordsRoot(projectRoot);
|
|
438
|
+
if (existsSync(recordDir)) {
|
|
439
|
+
assertSafeDirectory(recordDir, false);
|
|
440
|
+
for (const name of readdirSync(recordDir)) {
|
|
441
|
+
const path = join(recordDir, name);
|
|
442
|
+
if (name.startsWith(".tmp-")) {
|
|
443
|
+
temporary.push(path);
|
|
444
|
+
issues.push(`temporary remnant: ${name}`);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (!/^wev_[0-9a-f-]{36}\.json$/i.test(name)) {
|
|
448
|
+
issues.push(`unexpected record: ${name}`);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const row = storedWorkEvidenceSchema.parse(readJson(path));
|
|
453
|
+
records += 1;
|
|
454
|
+
if (row.status === "pending" && Date.now() - Date.parse(row.started_at) > STALE_PENDING_MS)
|
|
455
|
+
issues.push(`stale pending evidence: ${row.evidence_id}`);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
issues.push(`invalid record: ${name}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const linkBase = join(root, "links");
|
|
463
|
+
if (existsSync(linkBase)) {
|
|
464
|
+
assertSafeDirectory(linkBase, false);
|
|
465
|
+
for (const evidenceId of readdirSync(linkBase)) {
|
|
466
|
+
const dir = join(linkBase, evidenceId);
|
|
467
|
+
try {
|
|
468
|
+
workEvidenceIdSchema.parse(evidenceId);
|
|
469
|
+
assertSafeDirectory(dir, false);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
issues.push(`unsafe link directory: ${evidenceId}`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
for (const name of readdirSync(dir)) {
|
|
476
|
+
const path = join(dir, name);
|
|
477
|
+
if (name.startsWith(".tmp-")) {
|
|
478
|
+
temporary.push(path);
|
|
479
|
+
issues.push(`temporary remnant: links/${evidenceId}/${name}`);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
const link = workEvidenceLinkSchema.parse(readJson(path));
|
|
484
|
+
if (link.evidence_id !== evidenceId || `${link.link_id}.json` !== name)
|
|
485
|
+
throw new Error("mismatch");
|
|
486
|
+
links += 1;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
issues.push(`invalid link: ${evidenceId}/${name}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (repair && temporary.length > 0) {
|
|
495
|
+
for (const path of temporary)
|
|
496
|
+
rmSync(path, { force: true });
|
|
497
|
+
for (let index = issues.length - 1; index >= 0; index -= 1)
|
|
498
|
+
if (issues[index].startsWith("temporary remnant:"))
|
|
499
|
+
issues.splice(index, 1);
|
|
500
|
+
repaired = true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
issues.push(error instanceof Error ? error.message : "work-evidence diagnostics failed");
|
|
505
|
+
}
|
|
506
|
+
return { schema_version: 1, exists: true, ok: issues.length === 0, repaired, records, links, issues };
|
|
507
|
+
}
|
|
508
|
+
export function activeEvidenceEnvironment(evidence, projectRoot) {
|
|
509
|
+
return {
|
|
510
|
+
CAIRN_WORK_EVIDENCE_ID: evidence.evidence_id,
|
|
511
|
+
CAIRN_WORK_EVIDENCE_ROOT: resolveGitRoot(projectRoot),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
export function getWorkEvidenceStorePath(projectRoot = process.cwd()) {
|
|
515
|
+
return storeRoot(projectRoot);
|
|
516
|
+
}
|
|
@@ -49,9 +49,10 @@
|
|
|
49
49
|
"check:mcp-trust": "node scripts/smoke-mcp-trust.mjs",
|
|
50
50
|
"check:pi-mcp-bridge": "node scripts/smoke-pi-mcp-bridge.mjs",
|
|
51
51
|
"check:context-pack": "node scripts/smoke-context-pack.mjs",
|
|
52
|
+
"check:work-evidence": "node scripts/smoke-work-evidence.mjs",
|
|
52
53
|
"check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
|
|
53
54
|
"test": "npm run test:smoke",
|
|
54
|
-
"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",
|
|
55
|
+
"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",
|
|
55
56
|
"test:search": "npm run build && npm run check:search",
|
|
56
57
|
"test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
|
|
57
58
|
"test:all": "npm run test:memory"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cairnkeep/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.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",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"memory",
|
|
65
65
|
"agent",
|
|
66
66
|
"claude-code",
|
|
67
|
+
"codex-cli",
|
|
67
68
|
"opencode",
|
|
68
69
|
"qwen-code",
|
|
69
70
|
"pi-coding-agent",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"type": "array",
|
|
17
17
|
"minItems": 1,
|
|
18
18
|
"uniqueItems": true,
|
|
19
|
-
"items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen"]}
|
|
19
|
+
"items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
|
|
20
20
|
},
|
|
21
21
|
"memory": {"enum": ["local", "none"]}
|
|
22
22
|
}
|
|
@@ -35,13 +35,13 @@
|
|
|
35
35
|
"type": "array",
|
|
36
36
|
"minItems": 1,
|
|
37
37
|
"uniqueItems": true,
|
|
38
|
-
"items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen"]}
|
|
38
|
+
"items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
|
|
39
39
|
},
|
|
40
40
|
"required_harnesses": {
|
|
41
41
|
"type": "array",
|
|
42
42
|
"minItems": 1,
|
|
43
43
|
"uniqueItems": true,
|
|
44
|
-
"items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen"]}
|
|
44
|
+
"items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
|
|
45
45
|
},
|
|
46
46
|
"memory": {
|
|
47
47
|
"type": "array",
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
"type": "array",
|
|
15
15
|
"minItems": 1,
|
|
16
16
|
"uniqueItems": true,
|
|
17
|
-
"items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen"]}
|
|
17
|
+
"items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
|
|
18
18
|
},
|
|
19
19
|
"assets": {
|
|
20
20
|
"type": "object",
|
|
21
21
|
"propertyNames": {
|
|
22
|
-
"pattern": "^\\.(?:ai|planning|agentfs)/(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$"
|
|
22
|
+
"pattern": "^\\.(?:ai|planning|agentfs|codex)/(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$"
|
|
23
23
|
},
|
|
24
24
|
"additionalProperties": {
|
|
25
25
|
"type": "object",
|
package/scripts/bootstrap.sh
CHANGED
|
@@ -53,11 +53,9 @@ install_file() {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
# .ai/ launchers + env
|
|
56
|
-
|
|
57
|
-
install_file "$TPL
|
|
58
|
-
|
|
59
|
-
install_file "$TPL/start-kimi.sh.template" "$target/.ai/start-kimi.sh" 0755
|
|
60
|
-
install_file "$TPL/start-qwen.sh.template" "$target/.ai/start-qwen.sh" 0755
|
|
56
|
+
while IFS=$'\t' read -r template destination mode; do
|
|
57
|
+
install_file "$TPL/$template" "$target/$destination" "0$mode"
|
|
58
|
+
done < <(node "$CAIRN_ROOT/scripts/harness-registry.mjs" bootstrap-assets)
|
|
61
59
|
install_file "$TPL/env.example.template" "$target/.ai/env.example" 0644
|
|
62
60
|
install_file "$TPL/trajectory-redaction.json.template" "$target/.ai/trajectory-redaction.json" 0644
|
|
63
61
|
install_file "$TPL/capabilities.json.template" "$target/.ai/capabilities.json" 0600
|
|
@@ -100,13 +98,7 @@ fi
|
|
|
100
98
|
|
|
101
99
|
echo
|
|
102
100
|
echo "Cairnkeep bootstrapped into $target"
|
|
103
|
-
echo "
|
|
104
|
-
echo "
|
|
105
|
-
echo "
|
|
106
|
-
echo "
|
|
107
|
-
echo " For Pi trajectory + /graphify, also run: cairn sync-pi --apply"
|
|
108
|
-
echo " For Kimi /graphify, also run: cairn sync-kimi --apply"
|
|
109
|
-
echo " From a source clone, use bin/cairn in place of cairn."
|
|
110
|
-
echo " 4. Launch: $target/.ai/start-claude.sh"
|
|
111
|
-
echo " Kimi memory-client launch: $target/.ai/start-kimi.sh"
|
|
112
|
-
echo " Qwen memory-client launch: $target/.ai/start-qwen.sh"
|
|
101
|
+
echo "Codex: run 'cairn setup' in the project and select Codex; then review trust and run .ai/start-codex.sh"
|
|
102
|
+
echo "Claude: claude mcp add cairn-memory -s user -- cairn memory-server; cairn sync --apply"
|
|
103
|
+
echo "Optional project overrides: cp .ai/env.example .ai/.env"
|
|
104
|
+
echo "For new projects, prefer 'cairn setup'; guide: docs/quickstart.md"
|
package/scripts/cairn-cli.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const NODE_COMMANDS = new Map([
|
|
|
17
17
|
["eval", "eval-cli.js"],
|
|
18
18
|
["skill", "skill-cli.js"],
|
|
19
19
|
["graph", "graph-cli.js"],
|
|
20
|
+
["evidence", "work-evidence-cli.js"],
|
|
20
21
|
]);
|
|
21
22
|
|
|
22
23
|
const POSIX_COMMANDS = new Map([
|
|
@@ -50,6 +51,7 @@ Usage:
|
|
|
50
51
|
cairn eval <validate|run|ablate|report|prune|delete>
|
|
51
52
|
cairn skill <harvest|list|show|review|propose|evaluate|apply|rollback>
|
|
52
53
|
cairn graph <build|query|status|diff|explain|path>
|
|
54
|
+
cairn evidence <list|show|delete|prune|doctor>
|
|
53
55
|
cairn memory <path|export|import>
|
|
54
56
|
cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]
|
|
55
57
|
cairn uninstall [--dry-run] [--yes] [--purge-memory] [--purge-packs] [PROJECT ...]
|