@cairnkeep/cli 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +51 -7
  3. package/docs/building-an-overlay.md +1 -1
  4. package/docs/harness-compatibility.md +40 -1
  5. package/docs/learning/CURRICULUM-MAP.md +10 -7
  6. package/docs/learning/FEATURE-GUIDE.md +14 -5
  7. package/docs/learning/README.md +4 -3
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  10. package/docs/learning/lessons/L02-installation.md +16 -9
  11. package/docs/learning/lessons/L03-first-project.md +34 -15
  12. package/docs/learning/lessons/L13-session-evidence.md +48 -2
  13. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  14. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  15. package/docs/learning/lessons/L21-context-packs.md +1 -1
  16. package/docs/learning/lessons/L22-native-windows.md +9 -9
  17. package/docs/learning/lessons/L23-guided-setup.md +6 -2
  18. package/docs/learning/tracks/evidence-and-evaluation.md +2 -1
  19. package/docs/learning/tracks/quickstart.md +5 -4
  20. package/docs/native-windows.md +23 -0
  21. package/docs/operating.md +69 -5
  22. package/docs/privacy-and-data-flow.md +44 -1
  23. package/docs/quickstart.md +91 -0
  24. package/docs/storage.md +26 -0
  25. package/docs/work-evidence.md +80 -0
  26. package/examples/eval/bundled-fake.json +1 -1
  27. package/mcp-memory-server/dist/artifact-cli.js +6 -2
  28. package/mcp-memory-server/dist/index.js +27 -0
  29. package/mcp-memory-server/dist/mcp-tool-catalog.js +2 -0
  30. package/mcp-memory-server/dist/trajectory-cli.js +9 -3
  31. package/mcp-memory-server/dist/work-evidence-cli.js +121 -0
  32. package/mcp-memory-server/dist/work-evidence-schema.js +121 -0
  33. package/mcp-memory-server/dist/work-evidence-store.js +516 -0
  34. package/mcp-memory-server/package.json +2 -1
  35. package/package.json +2 -1
  36. package/schemas/cairnkeep-setup-policy.schema.json +3 -3
  37. package/schemas/cairnkeep-setup.schema.json +2 -2
  38. package/scripts/bootstrap.sh +7 -15
  39. package/scripts/cairn-cli.mjs +2 -0
  40. package/scripts/completion.sh +19 -9
  41. package/scripts/doctor.sh +35 -5
  42. package/scripts/harness-registry.mjs +126 -0
  43. package/scripts/setup-core.mjs +4 -2
  44. package/scripts/setup-reconcile.mjs +3 -2
  45. package/scripts/setup.mjs +41 -5
  46. package/scripts/test-bootstrap-untracked.sh +2 -1
  47. package/scripts/test-cli-dispatch.sh +3 -1
  48. package/scripts/test-completion.sh +6 -0
  49. package/scripts/test-harness-registry.sh +94 -0
  50. package/scripts/test-package-install.sh +9 -0
  51. package/scripts/test-phase19-eval-lifecycle.sh +12 -2
  52. package/scripts/test-setup-compatibility.mjs +5 -4
  53. package/scripts/test-uninstall.sh +10 -0
  54. package/scripts/test-windows-native.mjs +27 -1
  55. package/scripts/test-work-evidence-cli.sh +89 -0
  56. package/scripts/uninstall.sh +7 -3
  57. package/scripts/verify-docs-parity.sh +36 -0
  58. package/scripts/windows-platform.mjs +28 -7
  59. package/templates/codex-config.toml.template +5 -0
  60. package/templates/env.example.template +10 -0
  61. package/templates/start-claude.sh.template +18 -0
  62. package/templates/start-codex.sh.template +48 -0
  63. package/templates/start-kimi.sh.template +17 -0
  64. package/templates/start-opencode.sh.template +17 -0
  65. package/templates/start-pi.sh.template +17 -0
  66. package/templates/start-qwen.sh.template +17 -0
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.11.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.13.0","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
+ }