@cairnkeep/cli 2.12.0 → 2.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -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/releasing.md +6 -2
- 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 +5 -2
- 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-release-sbom.sh +91 -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,80 @@
|
|
|
1
|
+
# Git-linked work evidence
|
|
2
|
+
|
|
3
|
+
Cairnkeep can record a bounded, local account of the Git state surrounding a
|
|
4
|
+
harness session. The feature is off by default. Enable it in the project's
|
|
5
|
+
`.ai/.env`:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
CAIRN_WORK_EVIDENCE=1
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Generated Claude Code, OpenCode, Pi, Kimi, Qwen and Codex launchers then start
|
|
12
|
+
an evidence record before the harness and settle it after the harness exits.
|
|
13
|
+
The harness still launches when Git is unavailable, the directory is not a Git
|
|
14
|
+
worktree, or evidence capture fails; Cairnkeep prints a warning instead of
|
|
15
|
+
turning optional evidence into an availability dependency.
|
|
16
|
+
|
|
17
|
+
## Recorded metadata
|
|
18
|
+
|
|
19
|
+
Each completed record contains the harness, start/end timestamps, exit status,
|
|
20
|
+
repository-relative identity, and the start/end commit, branch, detached or
|
|
21
|
+
unborn state, dirty state, status digest and workspace digest. It also contains
|
|
22
|
+
a bounded list of paths whose observed state changed during the session and an
|
|
23
|
+
overall evidence digest.
|
|
24
|
+
|
|
25
|
+
Workspace digests are content-derived, including for observed untracked files.
|
|
26
|
+
They do not retain file bodies, but low-entropy candidate content can sometimes
|
|
27
|
+
be tested against a known digest; do not treat hashing as redaction.
|
|
28
|
+
|
|
29
|
+
Records may link to exact trajectories, artifacts and reviewed-memory writes
|
|
30
|
+
created while that launcher-owned session is active. Links are append-only and
|
|
31
|
+
carry identifiers, not copied content. Cairnkeep does not capture keystrokes,
|
|
32
|
+
prompts, shell history or model reasoning.
|
|
33
|
+
|
|
34
|
+
The record describes a time interval, not authorship. Concurrent processes can
|
|
35
|
+
change the same worktree, and a file changed and restored to its starting state
|
|
36
|
+
will not appear as touched. Treat the evidence as integrity-linked operational
|
|
37
|
+
context, not proof that a particular person or model made a change.
|
|
38
|
+
|
|
39
|
+
## Optional patch artifact
|
|
40
|
+
|
|
41
|
+
Patch capture requires two independent feature flags:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
CAIRN_WORK_EVIDENCE_PATCH=1
|
|
45
|
+
CAIRN_ARTIFACT_STORE=1
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The patch is produced at session end from the starting commit to the ending
|
|
49
|
+
worktree. It can therefore include tracked changes that already existed when
|
|
50
|
+
the session started. Untracked file names can appear in metadata, but their
|
|
51
|
+
bodies are not included. The patch is redacted, bounded by the lower of the
|
|
52
|
+
work-evidence and artifact limits, and stored in the existing artifact store.
|
|
53
|
+
Cairnkeep provides no patch apply, restore or replay command.
|
|
54
|
+
|
|
55
|
+
## Inspect and maintain
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
cairn evidence list [--status pending|complete] [--json]
|
|
59
|
+
cairn evidence show <evidence-id-or-prefix> [--json]
|
|
60
|
+
cairn evidence delete <evidence-id-or-prefix> [--dry-run] [--json]
|
|
61
|
+
cairn evidence prune [--dry-run] [--json]
|
|
62
|
+
cairn evidence doctor [--repair] [--json]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Metadata and links live below
|
|
66
|
+
`<repository>/.agentfs/work-evidence/v1/`. Records default to 30-day retention,
|
|
67
|
+
1 MiB per optional patch, 4,096 touched paths and a 64 MiB metadata budget.
|
|
68
|
+
Interrupted sessions remain visibly pending rather than being fabricated as
|
|
69
|
+
complete. `doctor` reports malformed records, broken links, permission drift,
|
|
70
|
+
temporary remnants and stale pending sessions. `--repair` can remove temporary
|
|
71
|
+
remnants, but it does not change records or invent an ending Git state.
|
|
72
|
+
|
|
73
|
+
With the feature enabled, local stdio MCP registers `work_evidence_list` and
|
|
74
|
+
`work_evidence_read`. Both are read-only, non-destructive, idempotent and
|
|
75
|
+
closed-world. They are never exposed by the HTTP transport. MCP profiles can
|
|
76
|
+
further restrict them, but cannot enable the feature.
|
|
77
|
+
|
|
78
|
+
Ordinary uninstall retains the project `.agentfs` boundary. As with other
|
|
79
|
+
project evidence stores, `cairn uninstall --purge-memory` backs up and removes
|
|
80
|
+
it only after explicit confirmation.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.13.1","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
|
@@ -82,7 +82,9 @@ async function captureClaude(projectRoot, args) {
|
|
|
82
82
|
});
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
|
-
await putArtifact(projectRoot, artifactInput(normalized));
|
|
85
|
+
const result = await putArtifact(projectRoot, artifactInput(normalized));
|
|
86
|
+
const { linkActiveWorkEvidence } = await import("./work-evidence-store.js");
|
|
87
|
+
await linkActiveWorkEvidence(projectRoot, { kind: "artifact", artifact_id: result.artifact.artifact_id });
|
|
86
88
|
}
|
|
87
89
|
async function captureOpenCode(projectRoot) {
|
|
88
90
|
const raw = JSON.parse(await readStdin());
|
|
@@ -95,7 +97,9 @@ async function captureOpenCode(projectRoot) {
|
|
|
95
97
|
await recordUnsupportedCompactionAdapter(projectRoot, { harness: "opencode", harness_version: version, reason: "unsupported_version" });
|
|
96
98
|
return;
|
|
97
99
|
}
|
|
98
|
-
await putArtifact(projectRoot, artifactInput(normalized));
|
|
100
|
+
const result = await putArtifact(projectRoot, artifactInput(normalized));
|
|
101
|
+
const { linkActiveWorkEvidence } = await import("./work-evidence-store.js");
|
|
102
|
+
await linkActiveWorkEvidence(projectRoot, { kind: "artifact", artifact_id: result.artifact.artifact_id });
|
|
99
103
|
}
|
|
100
104
|
async function recover(projectRoot, args) {
|
|
101
105
|
const listed = await listArtifacts(projectRoot, { kind: "compaction_summary" });
|
|
@@ -20,6 +20,7 @@ import { attachNodeMetadata, applyReviewedTypedNode, commitMemoryImport, createT
|
|
|
20
20
|
import { contextPackHttpEnabled, contextPacksEnabled, listVisibleContext, readVisibleContext, searchVisibleContext } from "./context-pack.js";
|
|
21
21
|
import { metadataForTool } from "./mcp-tool-catalog.js";
|
|
22
22
|
import { profileAllowsTool, resolveMcpToolProfile } from "./mcp-tool-profile.js";
|
|
23
|
+
import { isWorkEvidenceEnabled } from "./work-evidence-schema.js";
|
|
23
24
|
const MINIMUM_NODE_MAJOR = 22;
|
|
24
25
|
const nodeMajor = Number.parseInt(process.versions.node, 10);
|
|
25
26
|
if (!Number.isInteger(nodeMajor) || nodeMajor < MINIMUM_NODE_MAJOR) {
|
|
@@ -871,6 +872,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
|
|
|
871
872
|
const typedNodesEnabled = isTypedMemoryNodesEnabled();
|
|
872
873
|
const artifactToolsEnabled = isArtifactStoreEnabled() && (!context.remote || isArtifactHttpEnabled());
|
|
873
874
|
const contextPackToolsEnabled = contextPacksEnabled() && (!context.remote || contextPackHttpEnabled());
|
|
875
|
+
const workEvidenceToolsEnabled = isWorkEvidenceEnabled() && !context.remote;
|
|
874
876
|
if (artifactToolsEnabled && context.remote && !context.projectId) {
|
|
875
877
|
throw new ClientContextError("Remote artifact access requires X-Cairn-Project.");
|
|
876
878
|
}
|
|
@@ -936,6 +938,8 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
|
|
|
936
938
|
const { putArtifact } = await import("./artifact-store.js");
|
|
937
939
|
const result = await putArtifact(projectRoot, { ...input, session_ref: sessionRef });
|
|
938
940
|
const artifact = result.artifact;
|
|
941
|
+
const { linkActiveWorkEvidence } = await import("./work-evidence-store.js");
|
|
942
|
+
await linkActiveWorkEvidence(projectRoot, { kind: "artifact", artifact_id: artifact.artifact_id });
|
|
939
943
|
const payload = {
|
|
940
944
|
schema_version: artifact.schema_version,
|
|
941
945
|
artifact_id: artifact.artifact_id,
|
|
@@ -1506,6 +1510,8 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
|
|
|
1506
1510
|
snapshot_key: snapshotKey,
|
|
1507
1511
|
};
|
|
1508
1512
|
});
|
|
1513
|
+
const { linkActiveWorkEvidence } = await import("./work-evidence-store.js");
|
|
1514
|
+
await linkActiveWorkEvidence(process.cwd(), { kind: "reviewed_memory", scope, review_id, key });
|
|
1509
1515
|
return {
|
|
1510
1516
|
content: [{ type: "text", text: asToolText(payload) }],
|
|
1511
1517
|
structuredContent: payload,
|
|
@@ -1929,6 +1935,27 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
|
|
|
1929
1935
|
return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
|
|
1930
1936
|
});
|
|
1931
1937
|
}
|
|
1938
|
+
if (workEvidenceToolsEnabled) {
|
|
1939
|
+
registerTool("work_evidence_list", {
|
|
1940
|
+
description: "List bounded local Git-linked work-evidence records for this project.",
|
|
1941
|
+
inputSchema: z.object({
|
|
1942
|
+
status: z.enum(["pending", "complete"]).optional(),
|
|
1943
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
1944
|
+
}).strict(),
|
|
1945
|
+
}, async ({ status, limit }) => {
|
|
1946
|
+
const { listWorkEvidence } = await import("./work-evidence-store.js");
|
|
1947
|
+
const payload = listWorkEvidence(process.cwd(), { status, limit });
|
|
1948
|
+
return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
|
|
1949
|
+
});
|
|
1950
|
+
registerTool("work_evidence_read", {
|
|
1951
|
+
description: "Read one local Git-linked work-evidence record by exact ID or unambiguous prefix.",
|
|
1952
|
+
inputSchema: z.object({ evidence_id: z.string().min(4).max(40).regex(/^wev_[0-9a-f-]*$/i) }).strict(),
|
|
1953
|
+
}, async ({ evidence_id }) => {
|
|
1954
|
+
const { readWorkEvidence } = await import("./work-evidence-store.js");
|
|
1955
|
+
const payload = readWorkEvidence(evidence_id, process.cwd());
|
|
1956
|
+
return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1932
1959
|
return server;
|
|
1933
1960
|
}
|
|
1934
1961
|
function createMemoryServer(context = {}) {
|
|
@@ -35,6 +35,8 @@ export const MCP_TOOL_CATALOG = {
|
|
|
35
35
|
context_pack_list: observation("List context packs"),
|
|
36
36
|
context_pack_search: observation("Search context packs"),
|
|
37
37
|
context_pack_read: observation("Read context pack file"),
|
|
38
|
+
work_evidence_list: observation("List work evidence"),
|
|
39
|
+
work_evidence_read: observation("Read work evidence"),
|
|
38
40
|
};
|
|
39
41
|
export const MCP_TOOL_NAMES = Object.freeze(Object.keys(MCP_TOOL_CATALOG));
|
|
40
42
|
export function isCairnToolName(value) {
|
|
@@ -62,7 +62,9 @@ async function captureClaude(args) {
|
|
|
62
62
|
const normalized = await normalizeClaudeTranscript(transcriptPath, projectRoot);
|
|
63
63
|
const redacted = redactTrajectory(normalized, projectRoot);
|
|
64
64
|
const fitted = fitTrajectoryToBytes(redacted, limits.sessionMaxBytes);
|
|
65
|
-
await putTrajectory(projectRoot, fitted, limits);
|
|
65
|
+
const stored = await putTrajectory(projectRoot, fitted, limits);
|
|
66
|
+
const { linkActiveWorkEvidence } = await import("./work-evidence-store.js");
|
|
67
|
+
await linkActiveWorkEvidence(projectRoot, { kind: "trajectory", trajectory_id: stored.session_id });
|
|
66
68
|
}
|
|
67
69
|
async function captureOpenCode(args) {
|
|
68
70
|
const [projectRoot] = args;
|
|
@@ -72,7 +74,9 @@ async function captureOpenCode(args) {
|
|
|
72
74
|
const normalized = normalizeOpenCodeSession(JSON.parse(await readStdin()), projectRoot);
|
|
73
75
|
const redacted = redactTrajectory(normalized, projectRoot);
|
|
74
76
|
const fitted = fitTrajectoryToBytes(redacted, limits.sessionMaxBytes);
|
|
75
|
-
await putTrajectory(projectRoot, fitted, limits);
|
|
77
|
+
const stored = await putTrajectory(projectRoot, fitted, limits);
|
|
78
|
+
const { linkActiveWorkEvidence } = await import("./work-evidence-store.js");
|
|
79
|
+
await linkActiveWorkEvidence(projectRoot, { kind: "trajectory", trajectory_id: stored.session_id });
|
|
76
80
|
}
|
|
77
81
|
async function capturePi(args) {
|
|
78
82
|
const [projectRoot] = args;
|
|
@@ -82,7 +86,9 @@ async function capturePi(args) {
|
|
|
82
86
|
const normalized = normalizePiSession(JSON.parse(await readStdin()), projectRoot);
|
|
83
87
|
const redacted = redactTrajectory(normalized, projectRoot);
|
|
84
88
|
const fitted = fitTrajectoryToBytes(redacted, limits.sessionMaxBytes);
|
|
85
|
-
await putTrajectory(projectRoot, fitted, limits);
|
|
89
|
+
const stored = await putTrajectory(projectRoot, fitted, limits);
|
|
90
|
+
const { linkActiveWorkEvidence } = await import("./work-evidence-store.js");
|
|
91
|
+
await linkActiveWorkEvidence(projectRoot, { kind: "trajectory", trajectory_id: stored.session_id });
|
|
86
92
|
}
|
|
87
93
|
async function main() {
|
|
88
94
|
const [command = "help", ...args] = process.argv.slice(2);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { constants as osConstants } from "node:os";
|
|
4
|
+
import { workEvidenceHarnessSchema } from "./work-evidence-schema.js";
|
|
5
|
+
import { activeEvidenceEnvironment, deleteWorkEvidence, doctorWorkEvidence, finishWorkEvidence, listWorkEvidence, pruneWorkEvidence, readWorkEvidence, startWorkEvidence, } from "./work-evidence-store.js";
|
|
6
|
+
import { getWorkEvidenceLimits, isWorkEvidenceEnabled } from "./work-evidence-schema.js";
|
|
7
|
+
process.stdout.on("error", (error) => {
|
|
8
|
+
if (error.code === "EPIPE")
|
|
9
|
+
process.exit(0);
|
|
10
|
+
throw error;
|
|
11
|
+
});
|
|
12
|
+
function usage() {
|
|
13
|
+
return `cairn evidence — opt-in local Git-linked work evidence
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
cairn evidence list [--status pending|complete] [--json]
|
|
17
|
+
cairn evidence show <evidence-id-or-prefix> [--json]
|
|
18
|
+
cairn evidence delete <evidence-id-or-prefix> [--dry-run] [--json]
|
|
19
|
+
cairn evidence prune [--dry-run] [--json]
|
|
20
|
+
cairn evidence doctor [--repair] [--json]
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
23
|
+
function hasFlag(args, flag) {
|
|
24
|
+
return args.includes(flag);
|
|
25
|
+
}
|
|
26
|
+
function option(args, name) {
|
|
27
|
+
const index = args.indexOf(name);
|
|
28
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
29
|
+
}
|
|
30
|
+
function humanList(value) {
|
|
31
|
+
if (value.evidence.length === 0)
|
|
32
|
+
return "No local work evidence found.";
|
|
33
|
+
return value.evidence.map((row) => [row.evidence_id, row.status, row.harness, row.started_at, `${row.links.length} links`].join("\t")).join("\n");
|
|
34
|
+
}
|
|
35
|
+
async function runHarness(args) {
|
|
36
|
+
const separator = args.indexOf("--");
|
|
37
|
+
const harness = workEvidenceHarnessSchema.parse(option(args.slice(0, separator < 0 ? args.length : separator), "--harness"));
|
|
38
|
+
const command = separator >= 0 ? args[separator + 1] : undefined;
|
|
39
|
+
const commandArgs = separator >= 0 ? args.slice(separator + 2) : [];
|
|
40
|
+
if (!command)
|
|
41
|
+
throw new Error("run requires --harness NAME -- COMMAND [ARG...].");
|
|
42
|
+
let evidence;
|
|
43
|
+
if (isWorkEvidenceEnabled()) {
|
|
44
|
+
try {
|
|
45
|
+
evidence = startWorkEvidence(process.cwd(), harness);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
process.stderr.write("cairn evidence: capture unavailable; launching without work evidence\n");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const env = evidence ? { ...process.env, ...activeEvidenceEnvironment(evidence, process.cwd()) } : process.env;
|
|
52
|
+
const result = spawnSync(command, commandArgs, { cwd: process.cwd(), env, stdio: "inherit", shell: false, windowsHide: false });
|
|
53
|
+
const signalNumber = result.signal ? osConstants.signals[result.signal] : undefined;
|
|
54
|
+
const status = result.status ?? (signalNumber ? 128 + signalNumber : 1);
|
|
55
|
+
if (evidence) {
|
|
56
|
+
try {
|
|
57
|
+
await finishWorkEvidence(process.cwd(), evidence.evidence_id, status);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
process.stderr.write("cairn evidence: final capture failed; the evidence remains pending\n");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (result.error)
|
|
64
|
+
throw result.error;
|
|
65
|
+
process.exitCode = status;
|
|
66
|
+
}
|
|
67
|
+
async function main() {
|
|
68
|
+
const [command = "help", ...args] = process.argv.slice(2);
|
|
69
|
+
try {
|
|
70
|
+
if (command === "run") {
|
|
71
|
+
await runHarness(args);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (command === "list") {
|
|
75
|
+
const status = option(args, "--status");
|
|
76
|
+
if (status && !["pending", "complete"].includes(status))
|
|
77
|
+
throw new Error("--status must be pending or complete.");
|
|
78
|
+
const value = listWorkEvidence(process.cwd(), { status });
|
|
79
|
+
process.stdout.write(`${hasFlag(args, "--json") ? JSON.stringify(value) : humanList(value)}\n`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (command === "show") {
|
|
83
|
+
const identifier = args.find((arg) => !arg.startsWith("-"));
|
|
84
|
+
if (!identifier)
|
|
85
|
+
throw new Error("show requires an evidence ID or unambiguous prefix.");
|
|
86
|
+
const value = readWorkEvidence(identifier, process.cwd());
|
|
87
|
+
process.stdout.write(`${hasFlag(args, "--json") ? JSON.stringify(value) : JSON.stringify(value, null, 2)}\n`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (command === "delete") {
|
|
91
|
+
const identifier = args.find((arg) => !arg.startsWith("-"));
|
|
92
|
+
if (!identifier)
|
|
93
|
+
throw new Error("delete requires an evidence ID or unambiguous prefix.");
|
|
94
|
+
const value = deleteWorkEvidence(identifier, process.cwd(), hasFlag(args, "--dry-run"));
|
|
95
|
+
process.stdout.write(`${hasFlag(args, "--json") ? JSON.stringify(value) : `${value.dry_run ? "Would delete" : "Deleted"} ${value.evidence_id}.`}\n`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (command === "prune") {
|
|
99
|
+
const value = pruneWorkEvidence(process.cwd(), getWorkEvidenceLimits(), { dryRun: hasFlag(args, "--dry-run") });
|
|
100
|
+
process.stdout.write(`${hasFlag(args, "--json") ? JSON.stringify(value) : `${value.dry_run ? "Would remove" : "Removed"} ${value.removed.length} evidence record(s).`}\n`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (command === "doctor") {
|
|
104
|
+
const value = doctorWorkEvidence(process.cwd(), hasFlag(args, "--repair"));
|
|
105
|
+
process.stdout.write(`${hasFlag(args, "--json") ? JSON.stringify(value) : JSON.stringify(value, null, 2)}\n`);
|
|
106
|
+
if (!value.ok)
|
|
107
|
+
process.exitCode = 2;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (["help", "--help", "-h"].includes(command)) {
|
|
111
|
+
process.stdout.write(usage());
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`unknown evidence command "${command}".`);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
process.stderr.write(`cairn evidence: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
118
|
+
process.exitCode = 2;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await main();
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const WORK_EVIDENCE_SCHEMA_VERSION = 1;
|
|
3
|
+
export const WORK_EVIDENCE_HARNESSES = ["claude", "opencode", "pi", "kimi", "qwen", "codex"];
|
|
4
|
+
export const WORK_EVIDENCE_DEFAULT_RETENTION_DAYS = 30;
|
|
5
|
+
export const WORK_EVIDENCE_DEFAULT_STORE_MAX_BYTES = 64 * 1024 * 1024;
|
|
6
|
+
export const WORK_EVIDENCE_DEFAULT_MAX_TOUCHED_PATHS = 4096;
|
|
7
|
+
export const WORK_EVIDENCE_DEFAULT_PATCH_MAX_BYTES = 1024 * 1024;
|
|
8
|
+
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
|
9
|
+
const commitSchema = z.string().regex(/^[a-f0-9]{40,64}$/);
|
|
10
|
+
const safeRefSchema = z.string().min(1).max(512).refine((value) => !/[\u0000-\u001f\u007f]/.test(value));
|
|
11
|
+
const pathLabelSchema = z.string().min(1).max(4096).refine((value) => (!value.startsWith("/")
|
|
12
|
+
&& !value.includes("\\")
|
|
13
|
+
&& !value.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
|
14
|
+
&& !/[\u0000-\u001f\u007f]/.test(value)), "Touched paths must be safe repository-relative labels.");
|
|
15
|
+
export const workEvidenceIdSchema = z.string().regex(/^wev_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
16
|
+
export const workEvidenceHarnessSchema = z.enum(WORK_EVIDENCE_HARNESSES);
|
|
17
|
+
export const gitEvidenceSnapshotSchema = z.object({
|
|
18
|
+
head_commit: commitSchema.nullable(),
|
|
19
|
+
branch: safeRefSchema.nullable(),
|
|
20
|
+
detached: z.boolean(),
|
|
21
|
+
unborn: z.boolean(),
|
|
22
|
+
dirty: z.boolean(),
|
|
23
|
+
status_digest: digestSchema,
|
|
24
|
+
workspace_diff_digest: digestSchema,
|
|
25
|
+
}).strict();
|
|
26
|
+
const pathFingerprintSchema = z.object({
|
|
27
|
+
path: pathLabelSchema,
|
|
28
|
+
state_digest: digestSchema,
|
|
29
|
+
}).strict();
|
|
30
|
+
export const pendingWorkEvidenceSchema = z.object({
|
|
31
|
+
schema_version: z.literal(WORK_EVIDENCE_SCHEMA_VERSION),
|
|
32
|
+
evidence_id: workEvidenceIdSchema,
|
|
33
|
+
status: z.literal("pending"),
|
|
34
|
+
harness: workEvidenceHarnessSchema,
|
|
35
|
+
started_at: z.iso.datetime(),
|
|
36
|
+
start: gitEvidenceSnapshotSchema,
|
|
37
|
+
path_fingerprints: z.array(pathFingerprintSchema).max(WORK_EVIDENCE_DEFAULT_MAX_TOUCHED_PATHS),
|
|
38
|
+
}).strict();
|
|
39
|
+
export const workEvidencePatchSchema = z.object({
|
|
40
|
+
requested: z.boolean(),
|
|
41
|
+
scope: z.literal("end-worktree-vs-start-commit").nullable(),
|
|
42
|
+
artifact_id: z.string().regex(/^art_[0-9a-f-]{36}$/i).nullable(),
|
|
43
|
+
unavailable_reason: z.enum([
|
|
44
|
+
"not-requested",
|
|
45
|
+
"artifact-store-disabled",
|
|
46
|
+
"unborn-start",
|
|
47
|
+
"unchanged",
|
|
48
|
+
"capture-failed",
|
|
49
|
+
]).nullable(),
|
|
50
|
+
}).strict();
|
|
51
|
+
export const completeWorkEvidenceSchema = z.object({
|
|
52
|
+
schema_version: z.literal(WORK_EVIDENCE_SCHEMA_VERSION),
|
|
53
|
+
evidence_id: workEvidenceIdSchema,
|
|
54
|
+
status: z.literal("complete"),
|
|
55
|
+
harness: workEvidenceHarnessSchema,
|
|
56
|
+
started_at: z.iso.datetime(),
|
|
57
|
+
ended_at: z.iso.datetime(),
|
|
58
|
+
exit_status: z.number().int().min(0).max(255),
|
|
59
|
+
start: gitEvidenceSnapshotSchema,
|
|
60
|
+
end: gitEvidenceSnapshotSchema,
|
|
61
|
+
touched_paths: z.array(pathLabelSchema).max(WORK_EVIDENCE_DEFAULT_MAX_TOUCHED_PATHS),
|
|
62
|
+
omitted_touched_paths: z.number().int().nonnegative(),
|
|
63
|
+
change_digest: digestSchema,
|
|
64
|
+
patch: workEvidencePatchSchema,
|
|
65
|
+
}).strict();
|
|
66
|
+
export const workEvidenceLinkSchema = z.discriminatedUnion("kind", [
|
|
67
|
+
z.object({
|
|
68
|
+
schema_version: z.literal(WORK_EVIDENCE_SCHEMA_VERSION),
|
|
69
|
+
link_id: digestSchema,
|
|
70
|
+
evidence_id: workEvidenceIdSchema,
|
|
71
|
+
kind: z.literal("trajectory"),
|
|
72
|
+
created_at: z.iso.datetime(),
|
|
73
|
+
trajectory_id: safeRefSchema,
|
|
74
|
+
}).strict(),
|
|
75
|
+
z.object({
|
|
76
|
+
schema_version: z.literal(WORK_EVIDENCE_SCHEMA_VERSION),
|
|
77
|
+
link_id: digestSchema,
|
|
78
|
+
evidence_id: workEvidenceIdSchema,
|
|
79
|
+
kind: z.literal("artifact"),
|
|
80
|
+
created_at: z.iso.datetime(),
|
|
81
|
+
artifact_id: z.string().regex(/^art_[0-9a-f-]{36}$/i),
|
|
82
|
+
}).strict(),
|
|
83
|
+
z.object({
|
|
84
|
+
schema_version: z.literal(WORK_EVIDENCE_SCHEMA_VERSION),
|
|
85
|
+
link_id: digestSchema,
|
|
86
|
+
evidence_id: workEvidenceIdSchema,
|
|
87
|
+
kind: z.literal("reviewed_memory"),
|
|
88
|
+
created_at: z.iso.datetime(),
|
|
89
|
+
scope: safeRefSchema,
|
|
90
|
+
review_id: safeRefSchema,
|
|
91
|
+
key: safeRefSchema,
|
|
92
|
+
}).strict(),
|
|
93
|
+
]);
|
|
94
|
+
export const storedWorkEvidenceSchema = z.discriminatedUnion("status", [pendingWorkEvidenceSchema, completeWorkEvidenceSchema]);
|
|
95
|
+
function truthy(value) {
|
|
96
|
+
return /^(?:1|true|yes|on)$/i.test(value?.trim() ?? "");
|
|
97
|
+
}
|
|
98
|
+
function integerSetting(name, fallback, minimum) {
|
|
99
|
+
const raw = process.env[name]?.trim();
|
|
100
|
+
if (!raw)
|
|
101
|
+
return fallback;
|
|
102
|
+
const value = Number(raw);
|
|
103
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
104
|
+
throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
export function isWorkEvidenceEnabled(value = process.env.CAIRN_WORK_EVIDENCE) {
|
|
109
|
+
return truthy(value);
|
|
110
|
+
}
|
|
111
|
+
export function isWorkEvidencePatchEnabled(value = process.env.CAIRN_WORK_EVIDENCE_PATCH) {
|
|
112
|
+
return truthy(value);
|
|
113
|
+
}
|
|
114
|
+
export function getWorkEvidenceLimits() {
|
|
115
|
+
return {
|
|
116
|
+
retentionDays: integerSetting("CAIRN_WORK_EVIDENCE_RETENTION_DAYS", WORK_EVIDENCE_DEFAULT_RETENTION_DAYS, 0),
|
|
117
|
+
storeMaxBytes: integerSetting("CAIRN_WORK_EVIDENCE_STORE_MAX_BYTES", WORK_EVIDENCE_DEFAULT_STORE_MAX_BYTES, 1024),
|
|
118
|
+
maxTouchedPaths: Math.min(integerSetting("CAIRN_WORK_EVIDENCE_MAX_TOUCHED_PATHS", WORK_EVIDENCE_DEFAULT_MAX_TOUCHED_PATHS, 1), WORK_EVIDENCE_DEFAULT_MAX_TOUCHED_PATHS),
|
|
119
|
+
patchMaxBytes: integerSetting("CAIRN_WORK_EVIDENCE_PATCH_MAX_BYTES", WORK_EVIDENCE_DEFAULT_PATCH_MAX_BYTES, 1024),
|
|
120
|
+
};
|
|
121
|
+
}
|