@cairnkeep/cli 2.12.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 +13 -1
- package/README.md +20 -1
- package/docs/harness-compatibility.md +6 -0
- package/docs/learning/CURRICULUM-MAP.md +3 -1
- package/docs/learning/FEATURE-GUIDE.md +6 -2
- package/docs/learning/README.md +1 -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/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 +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +2 -2
- package/docs/learning/tracks/evidence-and-evaluation.md +2 -1
- package/docs/native-windows.md +9 -0
- package/docs/operating.md +43 -0
- package/docs/privacy-and-data-flow.md +36 -0
- package/docs/quickstart.md +5 -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 +1 -1
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +11 -3
- package/scripts/doctor.sh +30 -1
- package/scripts/test-cli-dispatch.sh +3 -1
- package/scripts/test-completion.sh +4 -0
- package/scripts/test-package-install.sh +4 -0
- package/scripts/test-uninstall.sh +10 -0
- package/scripts/test-windows-native.mjs +3 -0
- package/scripts/test-work-evidence-cli.sh +89 -0
- package/scripts/verify-docs-parity.sh +36 -0
- package/scripts/windows-platform.mjs +15 -2
- package/templates/env.example.template +10 -0
- package/templates/start-claude.sh.template +18 -0
- package/templates/start-codex.sh.template +18 -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
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 ...]
|
package/scripts/completion.sh
CHANGED
|
@@ -13,7 +13,7 @@ _cairn_complete() {
|
|
|
13
13
|
COMPREPLY=()
|
|
14
14
|
current=${COMP_WORDS[COMP_CWORD]}
|
|
15
15
|
previous=${COMP_WORDS[COMP_CWORD-1]:-}
|
|
16
|
-
commands="bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help"
|
|
16
|
+
commands="bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help"
|
|
17
17
|
if (( COMP_CWORD == 1 )); then
|
|
18
18
|
COMPREPLY=( $(compgen -W "$commands" -- "$current") )
|
|
19
19
|
return
|
|
@@ -27,6 +27,7 @@ _cairn_complete() {
|
|
|
27
27
|
doctor) COMPREPLY=( $(compgen -W "--repair" -- "$current") ) ;;
|
|
28
28
|
trajectory) COMPREPLY=( $(compgen -W "list show prune --json --dry-run" -- "$current") ) ;;
|
|
29
29
|
artifact) COMPREPLY=( $(compgen -W "list show delete prune --kind --session --json --dry-run --include-protected" -- "$current") ) ;;
|
|
30
|
+
evidence) COMPREPLY=( $(compgen -W "list show delete prune doctor --status --json --dry-run --repair" -- "$current") ) ;;
|
|
30
31
|
capabilities)
|
|
31
32
|
case "${COMP_WORDS[2]:-}" in
|
|
32
33
|
enable|disable)
|
|
@@ -94,6 +95,7 @@ _cairn() {
|
|
|
94
95
|
'doctor:check runtime dependencies and endpoints'
|
|
95
96
|
'trajectory:inspect and prune local session trajectories'
|
|
96
97
|
'artifact:inspect, delete, and prune local artifacts'
|
|
98
|
+
'evidence:inspect and manage local Git-linked work evidence'
|
|
97
99
|
'capabilities:inspect and manage project capability state'
|
|
98
100
|
'mcp-tools:inspect and restrict MCP tool exposure'
|
|
99
101
|
'pack:manage immutable context packs and skill approvals'
|
|
@@ -129,6 +131,7 @@ _cairn() {
|
|
|
129
131
|
*) _values 'artifact command' list show delete prune ;;
|
|
130
132
|
esac
|
|
131
133
|
;;
|
|
134
|
+
evidence) _values 'work evidence command' list show delete prune doctor '--status[filter records]:status:(pending complete)' '--dry-run[report without mutation]' '--repair[remove safe temporary remnants]' '--json[emit JSON]' ;;
|
|
132
135
|
mcp-tools) _values 'MCP tool profile command' list status set reset full read-only custom '--tool[allow an exact tool]:tool:' '--project[project root]:directory:_files -/' '--json[emit JSON]' ;;
|
|
133
136
|
pack) _values 'context pack command' init lock validate install list show remove enable disable update skills approve-skill revoke-skill '--ref[pinned Git ref]:ref:' '--project[project root]:directory:_files -/' '--project-id[remote project ID]:project ID:' '--check[inspect update]' '--apply[apply update]' '--confirm[confirm digest]:digest:' '--json[emit JSON]' ;;
|
|
134
137
|
capabilities)
|
|
@@ -179,8 +182,8 @@ EOF
|
|
|
179
182
|
fish)
|
|
180
183
|
sed "s/__CAIRN_HARNESSES__/$harnesses/g" <<'EOF'
|
|
181
184
|
complete -c cairn -f
|
|
182
|
-
for command in bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help
|
|
183
|
-
complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help" -a $command
|
|
185
|
+
for command in bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help
|
|
186
|
+
complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help" -a $command
|
|
184
187
|
end
|
|
185
188
|
complete -c cairn -n "__fish_seen_subcommand_from sync" -l apply
|
|
186
189
|
complete -c cairn -n "__fish_seen_subcommand_from sync" -l live-root -r
|
|
@@ -204,6 +207,11 @@ complete -c cairn -n "__fish_seen_subcommand_from artifact; and __fish_seen_subc
|
|
|
204
207
|
complete -c cairn -n "__fish_seen_subcommand_from artifact; and __fish_seen_subcommand_from list show delete prune" -l json
|
|
205
208
|
complete -c cairn -n "__fish_seen_subcommand_from artifact; and __fish_seen_subcommand_from delete prune" -l dry-run
|
|
206
209
|
complete -c cairn -n "__fish_seen_subcommand_from artifact; and __fish_seen_subcommand_from prune" -l include-protected
|
|
210
|
+
complete -c cairn -n "__fish_seen_subcommand_from evidence" -a "list show delete prune doctor"
|
|
211
|
+
complete -c cairn -n "__fish_seen_subcommand_from evidence; and __fish_seen_subcommand_from list" -l status -r -a "pending complete"
|
|
212
|
+
complete -c cairn -n "__fish_seen_subcommand_from evidence; and __fish_seen_subcommand_from list show delete prune doctor" -l json
|
|
213
|
+
complete -c cairn -n "__fish_seen_subcommand_from evidence; and __fish_seen_subcommand_from delete prune" -l dry-run
|
|
214
|
+
complete -c cairn -n "__fish_seen_subcommand_from evidence; and __fish_seen_subcommand_from doctor" -l repair
|
|
207
215
|
complete -c cairn -n "__fish_seen_subcommand_from capabilities; and not __fish_seen_subcommand_from list status enable disable reset logging" -a "list status enable disable reset logging"
|
|
208
216
|
complete -c cairn -n "__fish_seen_subcommand_from capabilities; and __fish_seen_subcommand_from enable disable; and not __fish_seen_subcommand_from memory.write memory.search notes.distill wiki graph security.audit route.check context.explore" -a "memory.write memory.search notes.distill wiki graph security.audit route.check context.explore"
|
|
209
217
|
complete -c cairn -n "__fish_seen_subcommand_from capabilities; and __fish_seen_subcommand_from reset; and not __fish_seen_subcommand_from memory.write memory.search notes.distill wiki graph security.audit route.check context.explore" -a "memory.write memory.search notes.distill wiki graph security.audit route.check context.explore"
|
package/scripts/doctor.sh
CHANGED
|
@@ -201,7 +201,36 @@ try {
|
|
|
201
201
|
[[ $artifact_status -eq 0 || "$artifact_state" == "broken" ]] || true
|
|
202
202
|
fi
|
|
203
203
|
|
|
204
|
-
# 9.
|
|
204
|
+
# 9. Project-local Git-linked work-evidence records. Absence is healthy because
|
|
205
|
+
# capture is opt-in. Repair removes safe temporary remnants only; it never
|
|
206
|
+
# finalizes an interrupted session or invents a Git state.
|
|
207
|
+
evidence_cli="$CAIRN_ROOT/mcp-memory-server/dist/work-evidence-cli.js"
|
|
208
|
+
if [[ ! -f "$evidence_cli" ]]; then
|
|
209
|
+
fail "work-evidence diagnostics unavailable — rebuild mcp-memory-server"
|
|
210
|
+
else
|
|
211
|
+
evidence_args=(doctor --json)
|
|
212
|
+
[[ $REPAIR_STORES -eq 1 ]] && evidence_args+=(--repair)
|
|
213
|
+
evidence_json=$(node "$evidence_cli" "${evidence_args[@]}" 2>/dev/null)
|
|
214
|
+
evidence_status=$?
|
|
215
|
+
evidence_state=$(node -e '
|
|
216
|
+
try {
|
|
217
|
+
const value = JSON.parse(process.argv[1])
|
|
218
|
+
if (!value.exists) process.stdout.write("absent")
|
|
219
|
+
else if (value.ok && value.repaired) process.stdout.write("repaired")
|
|
220
|
+
else if (value.ok) process.stdout.write("ok")
|
|
221
|
+
else process.stdout.write("broken")
|
|
222
|
+
} catch { process.stdout.write("invalid") }
|
|
223
|
+
' "$evidence_json" 2>/dev/null)
|
|
224
|
+
case "$evidence_state" in
|
|
225
|
+
absent) skip "work-evidence store (not present — capture is opt-in)" ;;
|
|
226
|
+
repaired) pass "work-evidence store repaired (temporary remnants removed; evidence preserved)" ;;
|
|
227
|
+
ok) pass "work-evidence records and append-only links are valid" ;;
|
|
228
|
+
*) fail "work-evidence validation failed — inspect with: cairn evidence doctor --json" ;;
|
|
229
|
+
esac
|
|
230
|
+
[[ $evidence_status -eq 0 || "$evidence_state" == "broken" ]] || true
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
# 10. Typed metadata overlays and canonical-note transaction state. The internal
|
|
205
234
|
# JSON seam enumerates only contained local databases and the one notes root;
|
|
206
235
|
# raw KV cells and unmarked Markdown bytes are never repair inputs.
|
|
207
236
|
node_cli="$CAIRN_ROOT/mcp-memory-server/dist/node-cli.js"
|