cool-workflow 0.1.82 → 0.1.83

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 (79) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +4 -4
  3. package/README.md +124 -120
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/end-to-end-golden-path/app.json +1 -1
  7. package/apps/pr-review-fix-ci/app.json +1 -1
  8. package/apps/release-cut/app.json +1 -1
  9. package/apps/research-synthesis/app.json +1 -1
  10. package/dist/capability-core.js +16 -8
  11. package/dist/capability-registry.js +8 -0
  12. package/dist/cli.js +12 -1
  13. package/dist/commit.js +5 -1
  14. package/dist/doctor.js +153 -0
  15. package/dist/mcp-server.js +11 -0
  16. package/dist/orchestrator.js +13 -0
  17. package/dist/reclamation/hash.js +72 -0
  18. package/dist/reclamation.js +25 -78
  19. package/dist/run-registry/queue.js +6 -7
  20. package/dist/run-registry.js +35 -24
  21. package/dist/scheduler.js +78 -53
  22. package/dist/version.js +1 -1
  23. package/dist/worker-isolation.js +22 -2
  24. package/docs/agent-delegation-drive.7.md +90 -86
  25. package/docs/agent-framework.md +33 -32
  26. package/docs/candidate-scoring.7.md +26 -24
  27. package/docs/canonical-workflow-apps.7.md +40 -40
  28. package/docs/capability-topology-registry.7.md +24 -24
  29. package/docs/cli-mcp-parity.7.md +226 -154
  30. package/docs/contract-migration-tooling.7.md +48 -41
  31. package/docs/control-plane-scheduling.7.md +45 -41
  32. package/docs/coordinator-blackboard.7.md +30 -30
  33. package/docs/dogfood-one-real-repo.7.md +44 -44
  34. package/docs/durable-state-and-locking.7.md +34 -30
  35. package/docs/end-to-end-golden-path.7.md +29 -29
  36. package/docs/error-feedback.7.md +27 -27
  37. package/docs/evidence-adoption-reasoning-chain.7.md +62 -58
  38. package/docs/execution-backends.7.md +84 -80
  39. package/docs/getting-started.md +35 -18
  40. package/docs/index.md +3 -3
  41. package/docs/mcp-app-surface.7.md +64 -64
  42. package/docs/multi-agent-cli-mcp-surface.7.md +82 -77
  43. package/docs/multi-agent-eval-replay-harness.7.md +59 -55
  44. package/docs/multi-agent-operator-ux.7.md +69 -65
  45. package/docs/multi-agent-runtime-core.7.md +39 -39
  46. package/docs/multi-agent-topologies.7.md +24 -24
  47. package/docs/multi-agent-trust-policy-audit.7.md +38 -38
  48. package/docs/node-snapshot-diff-replay.7.md +26 -22
  49. package/docs/observability-cost-accounting.7.md +49 -45
  50. package/docs/operator-ux.7.md +30 -30
  51. package/docs/pipeline-runner.7.md +31 -31
  52. package/docs/project-index.md +10 -5
  53. package/docs/real-execution-backends.7.md +47 -43
  54. package/docs/release-and-migration.7.md +42 -38
  55. package/docs/release-tooling.7.md +53 -49
  56. package/docs/routines.md +16 -16
  57. package/docs/run-registry-control-plane.7.md +120 -116
  58. package/docs/run-retention-reclamation.7.md +45 -41
  59. package/docs/sandbox-profiles.7.md +32 -32
  60. package/docs/scheduled-tasks.md +14 -14
  61. package/docs/security-trust-hardening.7.md +29 -29
  62. package/docs/source-context-profiles.7.md +28 -28
  63. package/docs/state-explosion-management.7.md +63 -59
  64. package/docs/state-node.7.md +8 -8
  65. package/docs/team-collaboration.7.md +62 -58
  66. package/docs/trust-model.md +126 -126
  67. package/docs/unix-principles.md +80 -80
  68. package/docs/vendor-manifest-loadability.7.md +20 -20
  69. package/docs/verifier-gated-commit.7.md +16 -16
  70. package/docs/web-desktop-workbench.7.md +69 -65
  71. package/docs/worker-isolation.7.md +34 -37
  72. package/docs/workflow-app-framework.7.md +38 -38
  73. package/manifest/plugin.manifest.json +4 -4
  74. package/package.json +3 -2
  75. package/scripts/canonical-apps.js +4 -4
  76. package/scripts/dogfood-release.js +1 -1
  77. package/scripts/gen-parity-doc.js +106 -0
  78. package/scripts/golden-path.js +4 -4
  79. package/dist/verifier-registry.js +0 -46
@@ -3,7 +3,7 @@
3
3
  "id": "pr-review-fix-ci",
4
4
  "title": "PR Review Fix CI",
5
5
  "summary": "Review a pull request or branch, inspect CI failures, diagnose actionable issues, optionally patch, verify, and summarize with evidence.",
6
- "version": "0.1.82",
6
+ "version": "0.1.83",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -3,7 +3,7 @@
3
3
  "id": "release-cut",
4
4
  "title": "Release Cut",
5
5
  "summary": "Prepare a release with checklist discipline: version checks, changelog, tests, packaging, release notes, and final verification.",
6
- "version": "0.1.82",
6
+ "version": "0.1.83",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -3,7 +3,7 @@
3
3
  "id": "research-synthesis",
4
4
  "title": "Research Synthesis",
5
5
  "summary": "Split a research question into claims, investigate sources, cross-check evidence, verify claims, and synthesize a concise answer.",
6
- "version": "0.1.82",
6
+ "version": "0.1.83",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -333,13 +333,12 @@ function queueShow(reg, id) {
333
333
  // ---- control-plane scheduling (v0.1.37) -----------------------------------
334
334
  function loadSchedulingPolicy(reg) {
335
335
  const file = reg.schedulingPolicyPath();
336
+ // Absent policy => conservative DEFAULT (an unconfigured backend, which §4
337
+ // permits). But a PRESENT-but-corrupt policy must fail closed: silently
338
+ // substituting defaults would schedule under settings the operator never
339
+ // chose while their broken file sits on disk. Let readJson's throw surface it.
336
340
  if (node_fs_1.default.existsSync(file)) {
337
- try {
338
- return { policy: (0, scheduling_1.normalizeSchedulingPolicy)((0, state_1.readJson)(file)), source: "file" };
339
- }
340
- catch {
341
- /* fall through to default */
342
- }
341
+ return { policy: (0, scheduling_1.normalizeSchedulingPolicy)((0, state_1.readJson)(file)), source: "file" };
343
342
  }
344
343
  return { policy: scheduling_1.DEFAULT_SCHEDULING_POLICY, source: "default" };
345
344
  }
@@ -400,8 +399,17 @@ function schedPolicySet(reg, args) {
400
399
  const current = loadSchedulingPolicy(reg).policy;
401
400
  const patch = {};
402
401
  for (const key of ["maxConcurrent", "maxAttempts", "leaseTtlMs", "backoffBaseMs", "backoffFactor", "backoffCapMs"]) {
403
- if (args[key] !== undefined)
404
- patch[key] = Number(args[key]);
402
+ if (args[key] === undefined)
403
+ continue;
404
+ // Fail closed on a non-numeric flag instead of letting normalizeSchedulingPolicy
405
+ // silently substitute the DEFAULT (which would report source:"file" + exit 0,
406
+ // so the operator believes they set a value they didn't). Matches the
407
+ // Number.isFinite guard the sibling reclaimPolicyFrom already uses.
408
+ const value = Number(args[key]);
409
+ if (!Number.isFinite(value)) {
410
+ throw new Error(`Invalid --${key} "${String(args[key])}": expected a number (e.g. --${key} 4)`);
411
+ }
412
+ patch[key] = value;
405
413
  }
406
414
  const policy = (0, scheduling_1.normalizeSchedulingPolicy)({ ...current, ...patch });
407
415
  (0, state_1.writeJson)(reg.schedulingPolicyPath(), policy);
@@ -52,6 +52,14 @@ const BUILTIN_CAPABILITIES = [
52
52
  cli: { path: ["list"], jsonMode: "default" },
53
53
  mcp: { tool: "cw_list" }
54
54
  },
55
+ {
56
+ capability: "doctor",
57
+ summary: "Diagnose the host for setup problems (Node version, agent backend, agent binary on PATH, git, writable home/repo state) and print an actionable fix per check.",
58
+ entry: "runDoctor",
59
+ surface: "cli-only",
60
+ cli: { path: ["doctor"], jsonMode: "flag" },
61
+ reason: "Environment diagnostics are inherently local to the CLI host — Node version, $PATH, $CW_HOME/cwd writability. An MCP client diagnosing the server process's environment is not meaningful; agents already receive the same readiness facts in their typed results (e.g. status: blocked, agentConfigured). Inspired by `brew doctor`."
62
+ },
55
63
  {
56
64
  capability: "init",
57
65
  summary: "Scaffold a new workflow definition.",
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
21
21
  const multi_agent_eval_1 = require("./multi-agent-eval");
22
22
  const state_explosion_1 = require("./state-explosion");
23
23
  const evidence_reasoning_1 = require("./evidence-reasoning");
24
+ const doctor_1 = require("./doctor");
24
25
  async function main() {
25
26
  const args = (0, orchestrator_1.parseArgv)(process.argv.slice(2));
26
27
  const runner = new orchestrator_1.CoolWorkflowRunner({
@@ -36,6 +37,16 @@ async function main() {
36
37
  case "list":
37
38
  printJson(runner.listWorkflows());
38
39
  return;
40
+ case "doctor": {
41
+ const report = (0, doctor_1.runDoctor)(args.options, process.env, String(args.options.cwd || process.cwd()));
42
+ if (wantsJson(args.options))
43
+ printJson(report);
44
+ else
45
+ process.stdout.write(`${(0, doctor_1.formatDoctorReport)(report)}\n`);
46
+ if (!report.ok)
47
+ process.exitCode = 1;
48
+ return;
49
+ }
39
50
  case "init": {
40
51
  const [workflowId] = args.positionals;
41
52
  if (!workflowId)
@@ -1281,7 +1292,7 @@ async function main() {
1281
1292
  }
1282
1293
  function required(value, label) {
1283
1294
  if (!value)
1284
- throw new Error(`Missing ${label}`);
1295
+ throw new Error(`Missing ${label}. Run "cw.js help" for usage.`);
1285
1296
  return value;
1286
1297
  }
1287
1298
  function optionalArg(value) {
package/dist/commit.js CHANGED
@@ -584,7 +584,11 @@ function readGitHead(cwd) {
584
584
  return (0, node_child_process_1.execFileSync)("git", ["rev-parse", "HEAD"], {
585
585
  cwd,
586
586
  encoding: "utf8",
587
- stdio: ["ignore", "pipe", "ignore"]
587
+ stdio: ["ignore", "pipe", "ignore"],
588
+ // Bound the call: a hung filesystem, a held .git/index.lock, or a
589
+ // credential-helper prompt must not block the commit path forever. A
590
+ // timeout throws, which the catch below maps to "no git head".
591
+ timeout: 5000
588
592
  }).trim();
589
593
  }
590
594
  catch {
package/dist/doctor.js ADDED
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runDoctor = runDoctor;
7
+ exports.formatDoctorReport = formatDoctorReport;
8
+ // `cw doctor` — environment diagnostics, in the spirit of `brew doctor`.
9
+ //
10
+ // Homebrew's `doctor` turned "something is subtly wrong with your setup" into a
11
+ // proactive, named list of problems each paired with a concrete fix. We borrow
12
+ // that idea: instead of letting a missing agent / old Node / unwritable state
13
+ // surface as a confusing mid-run failure, `cw doctor` probes the host up front
14
+ // and prints WHAT is wrong and WHAT TO DO about it.
15
+ //
16
+ // Discipline:
17
+ // - READ-ONLY. Probes versions, $PATH, and the WRITABILITY of the nearest
18
+ // existing ancestor dir (via access(2)) — it never creates `.cw/` or $CW_HOME
19
+ // as a side effect, so running `doctor` changes nothing on disk.
20
+ // - FAIL CLOSED. Any `fail` check ⇒ `ok:false` ⇒ the CLI exits non-zero. A
21
+ // `warn` (e.g. no agent yet — demo/preview still work) does not fail.
22
+ // - TWO RENDERINGS. Human text by default; a stable `--json` payload for scripts.
23
+ const node_fs_1 = __importDefault(require("node:fs"));
24
+ const node_os_1 = __importDefault(require("node:os"));
25
+ const node_path_1 = __importDefault(require("node:path"));
26
+ const node_child_process_1 = require("node:child_process");
27
+ const agent_config_1 = require("./agent-config");
28
+ /** Resolve a bare binary name against $PATH (or accept an explicit path). Returns
29
+ * the resolved path, or undefined when not found. No spawning. */
30
+ function whichBinary(bin, env) {
31
+ if (bin.includes("/") || bin.includes("\\")) {
32
+ try {
33
+ return node_fs_1.default.statSync(bin).isFile() ? bin : undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ const dirs = (env.PATH || "").split(node_path_1.default.delimiter).filter(Boolean);
40
+ const exts = process.platform === "win32" ? (env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
41
+ for (const dir of dirs) {
42
+ for (const ext of exts) {
43
+ const candidate = node_path_1.default.join(dir, bin + ext);
44
+ try {
45
+ if (node_fs_1.default.statSync(candidate).isFile())
46
+ return candidate;
47
+ }
48
+ catch { /* keep looking */ }
49
+ }
50
+ }
51
+ return undefined;
52
+ }
53
+ /** True when `target` could be created/written: walk up to the nearest EXISTING
54
+ * ancestor and require it to be a writable DIRECTORY. (A file in the path is not
55
+ * writable-as-a-dir even though access(2) W_OK on the file itself would pass.)
56
+ * Does NOT create anything — a diagnostic must not have side effects. */
57
+ function dirWritable(target) {
58
+ let dir = node_path_1.default.resolve(target);
59
+ for (;;) {
60
+ let stat;
61
+ try {
62
+ stat = node_fs_1.default.statSync(dir);
63
+ }
64
+ catch {
65
+ const parent = node_path_1.default.dirname(dir);
66
+ if (parent === dir)
67
+ return false;
68
+ dir = parent;
69
+ continue;
70
+ }
71
+ if (!stat.isDirectory())
72
+ return false; // an existing file blocks mkdir beneath it
73
+ try {
74
+ node_fs_1.default.accessSync(dir, node_fs_1.default.constants.W_OK);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ }
82
+ function runDoctor(args = {}, env = process.env, cwd = process.cwd()) {
83
+ const checks = [];
84
+ // 1. Node runtime — the one hard prerequisite (README: v18+).
85
+ const major = Number((process.version.match(/^v(\d+)/) || [])[1]);
86
+ checks.push(Number.isFinite(major) && major >= 18
87
+ ? { name: "node", status: "ok", detail: `Node ${process.version} (>= 18).` }
88
+ : { name: "node", status: "fail", detail: `Node ${process.version} is below the required v18.`, fix: "Install Node.js 18+ (e.g. `brew install node`, or https://nodejs.org)." });
89
+ // 2. Agent backend — CW delegates execution; without one, real runs park.
90
+ const cfg = (0, agent_config_1.resolveAgentConfig)(args, env);
91
+ if (cfg.source === "none") {
92
+ checks.push({
93
+ name: "agent",
94
+ status: "warn",
95
+ detail: "No agent backend configured — `demo` and `--preview` work, but a real run reports status: blocked.",
96
+ fix: 'Pass --agent-command "claude -p", set $CW_AGENT_COMMAND, or use --agent-command builtin:claude.'
97
+ });
98
+ }
99
+ else {
100
+ const binToken = cfg.command ? String(cfg.command).split(/\s+/)[0] : undefined;
101
+ checks.push({
102
+ name: "agent",
103
+ status: "ok",
104
+ detail: `Agent configured from ${cfg.source}${binToken ? `: ${binToken}` : cfg.endpoint ? " (HTTP endpoint)" : ""}.`
105
+ });
106
+ // 2b. If it is a command agent, is the binary actually on PATH?
107
+ if (binToken) {
108
+ const resolved = whichBinary(binToken, env);
109
+ checks.push(resolved
110
+ ? { name: "agent-binary", status: "ok", detail: `Agent binary "${binToken}" found at ${resolved}.` }
111
+ : { name: "agent-binary", status: "warn", detail: `Configured agent binary "${binToken}" is not on $PATH.`, fix: `Install "${binToken}", or correct --agent-command / $CW_AGENT_COMMAND.` });
112
+ }
113
+ }
114
+ // 3. git — only needed for commit provenance (git HEAD); a warn, not a hard fail.
115
+ const git = (0, node_child_process_1.spawnSync)("git", ["--version"], { encoding: "utf8", timeout: 5000 });
116
+ checks.push(!git.error && git.status === 0
117
+ ? { name: "git", status: "ok", detail: (String(git.stdout || "git").trim()) + "." }
118
+ : { name: "git", status: "warn", detail: "git is not available — commit provenance (git HEAD) is recorded as absent.", fix: "Install git (e.g. `brew install git`) if you want commit provenance." });
119
+ // 4. Home registry — the cross-repo run index lives here; must be writable.
120
+ const home = env.CW_HOME && String(env.CW_HOME).trim()
121
+ ? node_path_1.default.resolve(String(env.CW_HOME))
122
+ : node_path_1.default.join(node_os_1.default.homedir(), ".local", "state", "cool-workflow");
123
+ checks.push(dirWritable(home)
124
+ ? { name: "home-registry", status: "ok", detail: `Home registry location is writable (${home}).` }
125
+ : { name: "home-registry", status: "fail", detail: `Home registry location is not writable: ${home}`, fix: "Set $CW_HOME to a writable directory, or fix the permissions." });
126
+ // 5. Working-dir state — per-repo runs land under <cwd>/.cw.
127
+ const cwState = node_path_1.default.join(node_path_1.default.resolve(cwd), ".cw");
128
+ checks.push(dirWritable(cwState)
129
+ ? { name: "repo-state", status: "ok", detail: `Run state location is writable (${cwState}).` }
130
+ : { name: "repo-state", status: "warn", detail: `Cannot write run state under ${cwState}.`, fix: "Run from a writable working directory, or pass --cwd PATH." });
131
+ const fails = checks.filter((c) => c.status === "fail").length;
132
+ const warns = checks.filter((c) => c.status === "warn").length;
133
+ const ok = fails === 0;
134
+ const summary = ok
135
+ ? warns === 0
136
+ ? "ready — all checks passed"
137
+ : `ready, with ${warns} warning${warns === 1 ? "" : "s"}`
138
+ : `${fails} blocking problem${fails === 1 ? "" : "s"} found`;
139
+ return { schemaVersion: 1, ok, checks, summary };
140
+ }
141
+ /** Human rendering (TTY/default). `--json` callers use the report object directly. */
142
+ function formatDoctorReport(report) {
143
+ const glyph = { ok: "✓", warn: "!", fail: "✗" };
144
+ const lines = ["cw doctor"];
145
+ for (const check of report.checks) {
146
+ lines.push(` ${glyph[check.status]} ${check.name}: ${check.detail}`);
147
+ if (check.fix && check.status !== "ok")
148
+ lines.push(` fix: ${check.fix}`);
149
+ }
150
+ lines.push("");
151
+ lines.push(`${report.ok ? "✓" : "✗"} ${report.summary}`);
152
+ return lines.join("\n");
153
+ }
@@ -17,6 +17,11 @@ const runner = new orchestrator_1.CoolWorkflowRunner({
17
17
  });
18
18
  process.stdin.setEncoding("utf8");
19
19
  let buffer = "";
20
+ // A single line-delimited JSON-RPC request is bounded; an un-terminated stream
21
+ // (a peer that never sends a newline, or a slow byte dribble) must not grow the
22
+ // long-lived server's buffer without limit until it OOMs. Cap the unconsumed
23
+ // buffer and fail closed on a frame that exceeds it.
24
+ const MAX_LINE_BYTES = 16 * 1024 * 1024;
20
25
  process.stdin.on("data", (chunk) => {
21
26
  buffer += chunk;
22
27
  let newlineIndex;
@@ -26,6 +31,12 @@ process.stdin.on("data", (chunk) => {
26
31
  if (line)
27
32
  handleLine(line);
28
33
  }
34
+ if (buffer.length > MAX_LINE_BYTES) {
35
+ // No newline in an over-cap buffer ⇒ a malformed or hostile frame. Reject it
36
+ // and drop the partial bytes rather than accumulate toward OOM.
37
+ buffer = "";
38
+ sendError(null, -32700, `Parse error: request line exceeds ${MAX_LINE_BYTES} bytes`);
39
+ }
29
40
  });
30
41
  function handleLine(line) {
31
42
  let message;
@@ -821,6 +821,7 @@ function formatHelp() {
821
821
  "",
822
822
  "Commands:",
823
823
  " list",
824
+ " doctor [--json] (check your setup: Node, agent backend, git, writable state)",
824
825
  " init <workflow-id> [--title TEXT] [--output PATH]",
825
826
  " quickstart [app-id] [--repo PATH] [--question TEXT] [--agent-command CMD] [--once] [--preview]",
826
827
  " plan <workflow-id> [--repo PATH] [--question TEXT] [--invariant TEXT]",
@@ -873,6 +874,18 @@ function formatHelp() {
873
874
  " run search|list|show|resume|archive|rerun|export|import|verify-import [run-id|archive] [--scope repo|home] [--json]",
874
875
  " queue add|list|drain|show [queue-id] [--repo PATH] [--priority N]",
875
876
  " history [--scope repo|home] [--app ID] [--status STATE] [--json]",
877
+ " audit-run <app-id> [--repo PATH] [--question TEXT] [--agent-command CMD]",
878
+ " metrics show|summary <run-id> [--scope repo|home] [--json]",
879
+ " telemetry verify <run-id> [--pubkey PEM|PATH] [--json]",
880
+ " gc plan|run|verify [run-id] [--json]",
881
+ " sched plan|lease|release|complete|reclaim|reset|policy [--json]",
882
+ " migration list|check|prove [target] [--json]",
883
+ " operator status|report <run-id> [--json]",
884
+ " review status|policy <run-id> [--json]",
885
+ " approve|reject|comment <kind> <run-id> <target-id> [--reason TEXT]",
886
+ " handoff <kind> <run-id> <target-id> [--to ROLE]",
887
+ " loop --prompt TEXT [--interval-minutes N]",
888
+ " demo tamper",
876
889
  " workbench view <run-id> [--json]",
877
890
  " workbench serve [--port N] [--scope repo|home] [--once|--json]",
878
891
  ""
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.sha256OfString = sha256OfString;
7
+ exports.sha256OfFile = sha256OfFile;
8
+ exports.dirBytes = dirBytes;
9
+ exports.contentDigest = contentDigest;
10
+ // Content addressing + byte measurement for run reclamation (NO `du` — in-process
11
+ // only). Carved out of reclamation.ts (FreeBSD-audit god-module carve) so the pure
12
+ // content-addressing leaf no longer sits inside the write-ahead reclamation
13
+ // transaction. These are pure functions of their path/string inputs — no run
14
+ // state, no module-level mutable state.
15
+ //
16
+ // BEHAVIOR-PRESERVING — pure code movement, zero logic change. reclamation.ts
17
+ // re-exports the public symbols (sha256OfString/sha256OfFile/dirBytes) so the
18
+ // module's surface stays byte-identical.
19
+ const node_crypto_1 = __importDefault(require("node:crypto"));
20
+ const node_fs_1 = __importDefault(require("node:fs"));
21
+ const node_path_1 = __importDefault(require("node:path"));
22
+ const compare_1 = require("../compare");
23
+ function sha256Hex(value) {
24
+ return node_crypto_1.default.createHash("sha256").update(value).digest("hex");
25
+ }
26
+ function sha256OfString(value) {
27
+ return `sha256:${sha256Hex(value)}`;
28
+ }
29
+ function sha256OfFile(file) {
30
+ return `sha256:${sha256Hex(node_fs_1.default.readFileSync(file))}`;
31
+ }
32
+ /** Walk a path and sum file sizes IN-PROCESS (no `du`). Returns 0 if absent. A
33
+ * file returns its own size; a dir returns the recursive sum. */
34
+ function dirBytes(p) {
35
+ let total = 0;
36
+ let stat;
37
+ try {
38
+ stat = node_fs_1.default.statSync(p);
39
+ }
40
+ catch {
41
+ return 0;
42
+ }
43
+ if (stat.isFile())
44
+ return stat.size;
45
+ if (!stat.isDirectory())
46
+ return 0;
47
+ for (const entry of node_fs_1.default.readdirSync(p, { withFileTypes: true })) {
48
+ total += dirBytes(node_path_1.default.join(p, entry.name));
49
+ }
50
+ return total;
51
+ }
52
+ /** Stable content digest of a path (file = its bytes; dir = digest over each
53
+ * member's relative path + bytes, sorted). Lets the freed-manifest record a
54
+ * single sha per freed dir. */
55
+ function contentDigest(p) {
56
+ const stat = node_fs_1.default.statSync(p);
57
+ if (stat.isFile())
58
+ return sha256OfFile(p);
59
+ const parts = [];
60
+ const walk = (dir, rel) => {
61
+ for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (0, compare_1.compareBytes)(a.name, b.name))) {
62
+ const abs = node_path_1.default.join(dir, entry.name);
63
+ const r = node_path_1.default.join(rel, entry.name);
64
+ if (entry.isDirectory())
65
+ walk(abs, r);
66
+ else
67
+ parts.push(`${r}:${sha256OfFile(abs)}`);
68
+ }
69
+ };
70
+ walk(p, "");
71
+ return sha256OfString(parts.join("\n"));
72
+ }
@@ -29,10 +29,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
29
29
  return (mod && mod.__esModule) ? mod : { "default": mod };
30
30
  };
31
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
- exports.ReclamationError = exports.ReclamationAbort = exports.SKELETON_REQUIRED_KEYS = void 0;
33
- exports.sha256OfString = sha256OfString;
34
- exports.sha256OfFile = sha256OfFile;
35
- exports.dirBytes = dirBytes;
32
+ exports.dirBytes = exports.sha256OfFile = exports.sha256OfString = exports.ReclamationError = exports.ReclamationAbort = exports.SKELETON_REQUIRED_KEYS = void 0;
36
33
  exports.reclaimedLogPath = reclaimedLogPath;
37
34
  exports.loadReclamationLog = loadReclamationLog;
38
35
  exports.extractSkeleton = extractSkeleton;
@@ -49,9 +46,12 @@ exports.runReclamation = runReclamation;
49
46
  exports.reconstructArtifact = reconstructArtifact;
50
47
  exports.verifyReclamation = verifyReclamation;
51
48
  exports.dominantFailureCode = dominantFailureCode;
52
- const node_crypto_1 = __importDefault(require("node:crypto"));
53
49
  const node_fs_1 = __importDefault(require("node:fs"));
54
50
  const node_path_1 = __importDefault(require("node:path"));
51
+ const hash_1 = require("./reclamation/hash");
52
+ Object.defineProperty(exports, "dirBytes", { enumerable: true, get: function () { return hash_1.dirBytes; } });
53
+ Object.defineProperty(exports, "sha256OfFile", { enumerable: true, get: function () { return hash_1.sha256OfFile; } });
54
+ Object.defineProperty(exports, "sha256OfString", { enumerable: true, get: function () { return hash_1.sha256OfString; } });
55
55
  const multi_agent_eval_1 = require("./multi-agent-eval");
56
56
  const node_projection_1 = require("./node-projection");
57
57
  const node_snapshot_1 = require("./node-snapshot");
@@ -95,59 +95,6 @@ class ReclamationError extends Error {
95
95
  }
96
96
  }
97
97
  exports.ReclamationError = ReclamationError;
98
- // ---------------------------------------------------------------------------
99
- // Content addressing + byte measurement (NO `du` — in-process only).
100
- // ---------------------------------------------------------------------------
101
- function sha256Hex(value) {
102
- return node_crypto_1.default.createHash("sha256").update(value).digest("hex");
103
- }
104
- function sha256OfString(value) {
105
- return `sha256:${sha256Hex(value)}`;
106
- }
107
- function sha256OfFile(file) {
108
- return `sha256:${sha256Hex(node_fs_1.default.readFileSync(file))}`;
109
- }
110
- /** Walk a path and sum file sizes IN-PROCESS (no `du`). Returns 0 if absent. A
111
- * file returns its own size; a dir returns the recursive sum. */
112
- function dirBytes(p) {
113
- let total = 0;
114
- let stat;
115
- try {
116
- stat = node_fs_1.default.statSync(p);
117
- }
118
- catch {
119
- return 0;
120
- }
121
- if (stat.isFile())
122
- return stat.size;
123
- if (!stat.isDirectory())
124
- return 0;
125
- for (const entry of node_fs_1.default.readdirSync(p, { withFileTypes: true })) {
126
- total += dirBytes(node_path_1.default.join(p, entry.name));
127
- }
128
- return total;
129
- }
130
- /** Stable content digest of a path (file = its bytes; dir = digest over each
131
- * member's relative path + bytes, sorted). Lets the freed-manifest record a
132
- * single sha per freed dir. */
133
- function contentDigest(p) {
134
- const stat = node_fs_1.default.statSync(p);
135
- if (stat.isFile())
136
- return sha256OfFile(p);
137
- const parts = [];
138
- const walk = (dir, rel) => {
139
- for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (0, compare_1.compareBytes)(a.name, b.name))) {
140
- const abs = node_path_1.default.join(dir, entry.name);
141
- const r = node_path_1.default.join(rel, entry.name);
142
- if (entry.isDirectory())
143
- walk(abs, r);
144
- else
145
- parts.push(`${r}:${sha256OfFile(abs)}`);
146
- }
147
- };
148
- walk(p, "");
149
- return sha256OfString(parts.join("\n"));
150
- }
151
98
  /** Persist a run's authoritative state.json DURABLY (atomic temp → fsync →
152
99
  * rename). The re-point that scratch reclamation depends on MUST be persisted
153
100
  * this way BEFORE any byte is freed — see prepareFree(). */
@@ -220,13 +167,13 @@ function digestEvidenceEntry(entry) {
220
167
  try {
221
168
  const stat = node_fs_1.default.statSync(candidatePath);
222
169
  if (stat.isFile())
223
- return { ref, digest: sha256OfFile(candidatePath) };
170
+ return { ref, digest: (0, hash_1.sha256OfFile)(candidatePath) };
224
171
  }
225
172
  catch {
226
173
  /* fall through to locator digest */
227
174
  }
228
175
  }
229
- return { ref, digest: sha256OfString(ref) };
176
+ return { ref, digest: (0, hash_1.sha256OfString)(ref) };
230
177
  }
231
178
  /** STEP 1: extract + seal the skeleton. Pure read over the run; never mutates. */
232
179
  function extractSkeleton(run) {
@@ -264,7 +211,7 @@ function extractSkeleton(run) {
264
211
  .map(([ref, digest]) => ({ ref, digest }))
265
212
  .sort((a, b) => (0, compare_1.compareBytes)(a.ref, b.ref));
266
213
  const eventLog = auditEventLogPath(run);
267
- const auditLogDigest = node_fs_1.default.existsSync(eventLog) ? sha256OfFile(eventLog) : sha256OfString("");
214
+ const auditLogDigest = node_fs_1.default.existsSync(eventLog) ? (0, hash_1.sha256OfFile)(eventLog) : (0, hash_1.sha256OfString)("");
268
215
  const events = node_fs_1.default.existsSync(eventLog)
269
216
  ? node_fs_1.default
270
217
  .readFileSync(eventLog, "utf8")
@@ -284,18 +231,18 @@ function extractSkeleton(run) {
284
231
  const metricsReport = node_path_1.default.join(run.paths.runDir, "metrics", "metrics-report.json");
285
232
  const costRecord = {
286
233
  tasks: (run.tasks || []).map((task) => ({ taskId: task.id, model: task.usage?.model, source: task.usage?.source })),
287
- metricsDigest: node_fs_1.default.existsSync(metricsReport) ? sha256OfFile(metricsReport) : undefined
234
+ metricsDigest: node_fs_1.default.existsSync(metricsReport) ? (0, hash_1.sha256OfFile)(metricsReport) : undefined
288
235
  };
289
236
  const collaboration = run.collaboration;
290
237
  const collaborationLog = {
291
- digest: sha256OfString((0, multi_agent_eval_1.replayStableStringify)(collaboration || {})),
238
+ digest: (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(collaboration || {})),
292
239
  approvals: collaboration?.approvals?.length || 0,
293
240
  comments: collaboration?.comments?.length || 0,
294
241
  handoffs: collaboration?.handoffs?.length || 0
295
242
  };
296
243
  // Empty (not a hash-of-empty) when state.json is absent, so the skeleton fails
297
244
  // closed — you cannot seal a run whose authoritative state is gone.
298
- const stateDigest = node_fs_1.default.existsSync(run.paths.state) ? sha256OfFile(run.paths.state) : "";
245
+ const stateDigest = node_fs_1.default.existsSync(run.paths.state) ? (0, hash_1.sha256OfFile)(run.paths.state) : "";
299
246
  return {
300
247
  schemaVersion: 1,
301
248
  runId: run.id,
@@ -402,13 +349,13 @@ function buildReferenceGraph(run) {
402
349
  * node-projection field set (node-projection.ts) so reconstruction matches
403
350
  * node-snapshot.ts's body byte-for-byte — the projection can no longer drift. */
404
351
  function snapshotProjectionDigest(node) {
405
- return sha256OfString((0, node_projection_1.nodeProjectionDigestInput)(node));
352
+ return (0, hash_1.sha256OfString)((0, node_projection_1.nodeProjectionDigestInput)(node));
406
353
  }
407
354
  /** Body digest of the RETAINED node (lives in state.json). The reconstruction
408
355
  * verifier re-derives the projection from this retained input. Same shared field
409
356
  * set / canonical bytes as snapshotProjectionDigest. */
410
357
  function nodeBodyDigest(node) {
411
- return sha256OfString((0, node_projection_1.nodeProjectionDigestInput)(node));
358
+ return (0, hash_1.sha256OfString)((0, node_projection_1.nodeProjectionDigestInput)(node));
412
359
  }
413
360
  /** Build the retention plan: which paths are freeable under `policy`, of what
414
361
  * kind, how many bytes, and the resulting capability downgrade. */
@@ -431,7 +378,7 @@ function planReclamation(run, policy = {}) {
431
378
  const resultsCopy = task?.resultPath;
432
379
  if (!resultNodeId || !resultsCopy || !node_fs_1.default.existsSync(resultsCopy))
433
380
  continue;
434
- const bytes = dirBytes(workerDir);
381
+ const bytes = (0, hash_1.dirBytes)(workerDir);
435
382
  if (bytes <= 0)
436
383
  continue;
437
384
  freeable.push({
@@ -478,14 +425,14 @@ function planReclamation(run, policy = {}) {
478
425
  continue; // source node gone → cannot reconstruct → retain
479
426
  if (repointNodeIds.has(node.id))
480
427
  continue; // body will be re-pointed → retain
481
- const bytes = dirBytes(snapFile);
428
+ const bytes = (0, hash_1.dirBytes)(snapFile);
482
429
  if (bytes <= 0)
483
430
  continue;
484
431
  const inputDigest = nodeBodyDigest(node);
485
432
  const recipe = {
486
433
  recipeKind: "node-snapshot-projection",
487
434
  inputDigests: [inputDigest],
488
- inputsDigest: sha256OfString((0, multi_agent_eval_1.replayStableStringify)([inputDigest])),
435
+ inputsDigest: (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)([inputDigest])),
489
436
  expectDigest: snapshotProjectionDigest(node),
490
437
  sourceRef: node.id
491
438
  };
@@ -534,11 +481,11 @@ function planReclamation(run, policy = {}) {
534
481
  return { freeable, bytesToFree, byKind, capability, capabilityReason };
535
482
  }
536
483
  function policyDigestOf(policy) {
537
- return sha256OfString((0, multi_agent_eval_1.replayStableStringify)(policy));
484
+ return (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(policy));
538
485
  }
539
486
  /** genesis prevTombstoneHash = sha256 of the sealed skeleton. */
540
487
  function genesisPrevHash(skeleton) {
541
- return sha256OfString((0, multi_agent_eval_1.replayStableStringify)(skeleton));
488
+ return (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(skeleton));
542
489
  }
543
490
  /** The canonical bytes a tombstoneHash binds: freed-manifest + sealed skeleton +
544
491
  * prevTombstoneHash + capability. Recomputed independently by `gc verify`. */
@@ -551,14 +498,14 @@ function tombstoneHashInput(t) {
551
498
  policyDigest: t.policyDigest,
552
499
  freed: t.freed.map((f) => ({ path: f.path, kind: f.kind, bytes: f.bytes, sha256: f.sha256, recipe: f.recipe || null })),
553
500
  bytesFreed: t.bytesFreed,
554
- skeletonDigest: sha256OfString((0, multi_agent_eval_1.replayStableStringify)(t.skeleton)),
501
+ skeletonDigest: (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(t.skeleton)),
555
502
  capability: t.capability,
556
503
  capabilityReason: t.capabilityReason,
557
504
  prevTombstoneHash: t.prevTombstoneHash
558
505
  });
559
506
  }
560
507
  function computeTombstoneHash(t) {
561
- return sha256OfString(tombstoneHashInput(t));
508
+ return (0, hash_1.sha256OfString)(tombstoneHashInput(t));
562
509
  }
563
510
  function tombstoneId(seq) {
564
511
  // Deterministic (FreeBSD-audit L13): the chain POSITION, not a process-global
@@ -576,7 +523,7 @@ function buildTombstone(run, skeleton, plan, options = {}) {
576
523
  path: entry.path,
577
524
  kind: entry.kind,
578
525
  bytes: entry.bytes,
579
- sha256: contentDigest(entry.absPath),
526
+ sha256: (0, hash_1.contentDigest)(entry.absPath),
580
527
  recipe: entry.recipe
581
528
  }));
582
529
  const base = {
@@ -685,7 +632,7 @@ function freeBulk(run, tombstone) {
685
632
  let freedBytes = 0;
686
633
  for (const entry of tombstone.freed) {
687
634
  const abs = node_path_1.default.join(runDir, entry.path);
688
- const before = dirBytes(abs);
635
+ const before = (0, hash_1.dirBytes)(abs);
689
636
  node_fs_1.default.rmSync(abs, { recursive: true, force: true });
690
637
  freedBytes += before;
691
638
  }
@@ -774,15 +721,15 @@ function reconstructArtifact(run, recipe) {
774
721
  if (recipe.recipeKind === "node-snapshot-projection") {
775
722
  const node = (run.nodes || []).find((n) => n.id === recipe.sourceRef);
776
723
  if (!node) {
777
- return { inputsDigest: sha256OfString("absent"), expectDigest: sha256OfString("absent") };
724
+ return { inputsDigest: (0, hash_1.sha256OfString)("absent"), expectDigest: (0, hash_1.sha256OfString)("absent") };
778
725
  }
779
726
  const inputDigest = nodeBodyDigest(node);
780
- const inputsDigest = sha256OfString((0, multi_agent_eval_1.replayStableStringify)([inputDigest]));
727
+ const inputsDigest = (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)([inputDigest]));
781
728
  const expectDigest = snapshotProjectionDigest(node);
782
729
  return { inputsDigest, expectDigest };
783
730
  }
784
731
  // Unknown recipe kind → fail closed (digest can't match expectDigest).
785
- return { inputsDigest: sha256OfString("unknown-recipe"), expectDigest: sha256OfString("unknown-recipe") };
732
+ return { inputsDigest: (0, hash_1.sha256OfString)("unknown-recipe"), expectDigest: (0, hash_1.sha256OfString)("unknown-recipe") };
786
733
  }
787
734
  /** Re-prove the whole reclamation chain for a run: skeleton schema-complete,
788
735
  * tombstoneHash/prevTombstoneHash chain recomputed-and-untampered, and each