@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +20 -1
  3. package/docs/harness-compatibility.md +6 -0
  4. package/docs/learning/CURRICULUM-MAP.md +3 -1
  5. package/docs/learning/FEATURE-GUIDE.md +6 -2
  6. package/docs/learning/README.md +1 -1
  7. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  8. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  9. package/docs/learning/lessons/L02-installation.md +2 -2
  10. package/docs/learning/lessons/L03-first-project.md +1 -1
  11. package/docs/learning/lessons/L13-session-evidence.md +48 -2
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +1 -1
  15. package/docs/learning/lessons/L22-native-windows.md +2 -2
  16. package/docs/learning/lessons/L23-guided-setup.md +2 -2
  17. package/docs/learning/tracks/evidence-and-evaluation.md +2 -1
  18. package/docs/native-windows.md +9 -0
  19. package/docs/operating.md +43 -0
  20. package/docs/privacy-and-data-flow.md +36 -0
  21. package/docs/quickstart.md +5 -0
  22. package/docs/releasing.md +6 -2
  23. package/docs/storage.md +26 -0
  24. package/docs/work-evidence.md +80 -0
  25. package/examples/eval/bundled-fake.json +1 -1
  26. package/mcp-memory-server/dist/artifact-cli.js +6 -2
  27. package/mcp-memory-server/dist/index.js +27 -0
  28. package/mcp-memory-server/dist/mcp-tool-catalog.js +2 -0
  29. package/mcp-memory-server/dist/trajectory-cli.js +9 -3
  30. package/mcp-memory-server/dist/work-evidence-cli.js +121 -0
  31. package/mcp-memory-server/dist/work-evidence-schema.js +121 -0
  32. package/mcp-memory-server/dist/work-evidence-store.js +516 -0
  33. package/mcp-memory-server/package.json +2 -1
  34. package/package.json +5 -2
  35. package/scripts/cairn-cli.mjs +2 -0
  36. package/scripts/completion.sh +11 -3
  37. package/scripts/doctor.sh +30 -1
  38. package/scripts/test-cli-dispatch.sh +3 -1
  39. package/scripts/test-completion.sh +4 -0
  40. package/scripts/test-package-install.sh +4 -0
  41. package/scripts/test-release-sbom.sh +91 -0
  42. package/scripts/test-uninstall.sh +10 -0
  43. package/scripts/test-windows-native.mjs +3 -0
  44. package/scripts/test-work-evidence-cli.sh +89 -0
  45. package/scripts/verify-docs-parity.sh +36 -0
  46. package/scripts/windows-platform.mjs +15 -2
  47. package/templates/env.example.template +10 -0
  48. package/templates/start-claude.sh.template +18 -0
  49. package/templates/start-codex.sh.template +18 -0
  50. package/templates/start-kimi.sh.template +17 -0
  51. package/templates/start-opencode.sh.template +17 -0
  52. package/templates/start-pi.sh.template +17 -0
  53. 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.12.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
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
+ }