cool-workflow 0.1.80 → 0.1.82

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 (122) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +42 -2
  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/agent-config.js +21 -7
  11. package/dist/candidate-scoring.js +62 -48
  12. package/dist/capability-core.js +152 -96
  13. package/dist/capability-registry.js +160 -174
  14. package/dist/cli.js +90 -100
  15. package/dist/collaboration.js +5 -6
  16. package/dist/commit.js +232 -209
  17. package/dist/compare.js +18 -0
  18. package/dist/coordinator/classify.js +45 -0
  19. package/dist/coordinator/paths.js +42 -0
  20. package/dist/coordinator/util.js +126 -0
  21. package/dist/coordinator.js +127 -300
  22. package/dist/dispatch.js +46 -3
  23. package/dist/drive.js +7 -7
  24. package/dist/error-feedback.js +8 -4
  25. package/dist/evidence-reasoning.js +5 -2
  26. package/dist/execution-backend/agent.js +294 -0
  27. package/dist/execution-backend/probes.js +96 -0
  28. package/dist/execution-backend/util.js +47 -0
  29. package/dist/execution-backend.js +77 -450
  30. package/dist/gates.js +48 -0
  31. package/dist/mcp-server.js +34 -173
  32. package/dist/multi-agent/graph.js +84 -0
  33. package/dist/multi-agent/helpers.js +141 -0
  34. package/dist/multi-agent/ids.js +20 -0
  35. package/dist/multi-agent/paths.js +22 -0
  36. package/dist/multi-agent-eval/format.js +194 -0
  37. package/dist/multi-agent-eval/normalize.js +51 -0
  38. package/dist/multi-agent-eval.js +63 -242
  39. package/dist/multi-agent-host.js +53 -40
  40. package/dist/multi-agent-operator-ux.js +2 -1
  41. package/dist/multi-agent-trust.js +5 -5
  42. package/dist/multi-agent.js +125 -314
  43. package/dist/node-projection.js +59 -0
  44. package/dist/node-snapshot.js +11 -21
  45. package/dist/observability/format.js +61 -0
  46. package/dist/observability/intake.js +98 -0
  47. package/dist/observability.js +14 -160
  48. package/dist/operator-ux/format.js +364 -0
  49. package/dist/operator-ux.js +22 -363
  50. package/dist/orchestrator/lifecycle-operations.js +22 -1
  51. package/dist/orchestrator/report.js +8 -0
  52. package/dist/orchestrator.js +41 -10
  53. package/dist/reclamation.js +32 -55
  54. package/dist/run-export.js +138 -14
  55. package/dist/run-registry/derive.js +172 -0
  56. package/dist/run-registry/format.js +124 -0
  57. package/dist/run-registry/gc.js +251 -0
  58. package/dist/run-registry/policy.js +16 -0
  59. package/dist/run-registry/queue.js +116 -0
  60. package/dist/run-registry.js +78 -593
  61. package/dist/run-state-schema.js +1 -0
  62. package/dist/sandbox-profile.js +43 -2
  63. package/dist/scheduler.js +34 -4
  64. package/dist/state-explosion/format.js +159 -0
  65. package/dist/state-explosion/helpers.js +82 -0
  66. package/dist/state-explosion.js +65 -283
  67. package/dist/state-node.js +19 -4
  68. package/dist/telemetry-attestation.js +55 -0
  69. package/dist/telemetry-demo.js +15 -3
  70. package/dist/telemetry-ledger.js +60 -15
  71. package/dist/topology.js +50 -12
  72. package/dist/triggers.js +33 -14
  73. package/dist/trust-audit.js +215 -71
  74. package/dist/validation.js +328 -0
  75. package/dist/version.js +1 -1
  76. package/dist/worker-isolation/helpers.js +51 -0
  77. package/dist/worker-isolation/paths.js +46 -0
  78. package/dist/worker-isolation.js +182 -173
  79. package/docs/agent-delegation-drive.7.md +14 -0
  80. package/docs/cli-mcp-parity.7.md +5 -0
  81. package/docs/contract-migration-tooling.7.md +3 -0
  82. package/docs/control-plane-scheduling.7.md +3 -0
  83. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +40 -0
  84. package/docs/durable-state-and-locking.7.md +5 -0
  85. package/docs/evidence-adoption-reasoning-chain.7.md +3 -0
  86. package/docs/execution-backends.7.md +3 -0
  87. package/docs/index.md +1 -0
  88. package/docs/launch/launch-kit.md +46 -23
  89. package/docs/launch/pre-launch-checklist.md +14 -14
  90. package/docs/multi-agent-cli-mcp-surface.7.md +5 -0
  91. package/docs/multi-agent-eval-replay-harness.7.md +3 -0
  92. package/docs/multi-agent-operator-ux.7.md +3 -0
  93. package/docs/multi-agent-trust-policy-audit.7.md +27 -0
  94. package/docs/node-snapshot-diff-replay.7.md +3 -0
  95. package/docs/observability-cost-accounting.7.md +3 -0
  96. package/docs/project-index.md +23 -6
  97. package/docs/real-execution-backends.7.md +3 -0
  98. package/docs/release-and-migration.7.md +5 -0
  99. package/docs/release-tooling.7.md +35 -2
  100. package/docs/run-registry-control-plane.7.md +55 -8
  101. package/docs/run-retention-reclamation.7.md +5 -0
  102. package/docs/state-explosion-management.7.md +3 -0
  103. package/docs/team-collaboration.7.md +3 -0
  104. package/docs/trust-model.md +267 -0
  105. package/docs/vendor-manifest-loadability.7.md +43 -0
  106. package/docs/web-desktop-workbench.7.md +3 -0
  107. package/manifest/plugin.manifest.json +1 -1
  108. package/manifest/source-context-profiles.json +1 -1
  109. package/package.json +4 -2
  110. package/scripts/agents/builtin-templates.json +7 -0
  111. package/scripts/bump-version.js +5 -11
  112. package/scripts/canonical-apps-list.js +64 -0
  113. package/scripts/canonical-apps.js +19 -4
  114. package/scripts/children/batch-delegate-child.js +58 -0
  115. package/scripts/children/http-delegate-child.js +39 -0
  116. package/scripts/dogfood-release.js +1 -1
  117. package/scripts/golden-path.js +4 -4
  118. package/scripts/parity-check.js +5 -0
  119. package/scripts/release-check.js +5 -1
  120. package/scripts/release-flow.js +181 -5
  121. package/scripts/version-sync-check.js +5 -8
  122. package/dist/capability-dispatcher.js +0 -86
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cool-workflow",
3
3
  "description": "Auditable workflow control-plane and orchestration runtime: TypeScript dispatch, evidence-gated verification, state commits, scheduling, routines, multi-agent coordination, and MCP. Delegates execution to external agents — never runs models.",
4
- "version": "0.1.80",
4
+ "version": "0.1.82",
5
5
  "author": {
6
6
  "name": "COOLWHITE LLC"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cool-workflow",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "Auditable workflow control-plane and orchestration runtime: TypeScript dispatch, evidence-gated verification, state commits, scheduling, routines, multi-agent coordination, and MCP. Delegates execution to external agents — never runs models.",
5
5
  "author": {
6
6
  "name": "COOLWHITE LLC"
package/README.md CHANGED
@@ -263,6 +263,40 @@ write paths, command execution, network access, and environment exposure. CW
263
263
  stores and validates the policy, while the agent host enforces OS/process
264
264
  runtime controls. See [docs/sandbox-profiles.7.md](docs/sandbox-profiles.7.md).
265
265
 
266
+ ## Quickstart
267
+
268
+ **30-second proof, no install** — see that a recorded telemetry verdict can't be forged:
269
+
270
+ ```bash
271
+ npx cool-workflow demo tamper
272
+ # builds a signed ed25519 ledger, forges it 2 ways, both caught offline
273
+ # -> VERDICT: tamper-evidence holds ✓
274
+ ```
275
+
276
+ **Try a real run** — no clone needed; drive an architecture review with your own agent:
277
+
278
+ ```bash
279
+ npx cool-workflow quickstart architecture-review --repo /path/to/repo \
280
+ --question "Is this architecture sound?" --agent-command builtin:claude
281
+ ```
282
+
283
+ CW DELEGATES worker execution to your own agent. With no `--agent-command` (or
284
+ `CW_AGENT_COMMAND`) the drive fails closed (status `blocked`) — it never fabricates a
285
+ result. `--agent-command builtin:claude` resolves to a bundled read-only `claude -p`
286
+ wrapper (needs `claude` on your PATH).
287
+
288
+ **Re-prove a finished run, offline** (`cw` is the installed bin; or `npx cool-workflow <cmd>`):
289
+
290
+ ```bash
291
+ cw telemetry verify <run-id> # re-checks the hash-chained ledger
292
+ cw telemetry verify <run-id> --pubkey pub.pem # also re-runs ed25519 signature checks
293
+ cw audit verify <run-id> # re-proves the trust-audit hash chain
294
+ ```
295
+
296
+ More: `cw quickstart <app> --preview` (read-only dry run), `cw run resume <run-id> --drive`
297
+ (continue an interrupted run), `cw run inspect-archive <archive>` (integrity-check a
298
+ portable run archive without importing it).
299
+
266
300
  ## Structure
267
301
 
268
302
  ```text
@@ -300,6 +334,10 @@ cool-workflow
300
334
 
301
335
  ## Commands
302
336
 
337
+ Installed via npm, the bin is `cw` (alias `cool-workflow`): e.g. `cw list`,
338
+ `cw quickstart …`. From a cloned source checkout, before `npm run build`, use the
339
+ equivalent `node scripts/cw.js <cmd>` form shown in the examples below.
340
+
303
341
  List bundled workflows:
304
342
 
305
343
  ```bash
@@ -619,7 +657,7 @@ Replaces the linear migration chain with a BFS graph path resolver (`findMigrati
619
657
 
620
658
  ## Vendor-Adapter Registry (v0.1.47)
621
659
 
622
- Data-driven manifest generation: vendor JSON shapes extracted from `gen-manifests.js` into declarative templates in `plugin.manifest.json`. A `_resolveTemplate()` engine resolves `{{path.to.field}}` markers. Adding a new AI platform is pure data.
660
+ Data-driven manifest generation: vendor JSON shapes extracted from `gen-manifests.js` into declarative templates in `plugin.manifest.json`. A `_resolveTemplate()` engine resolves `{{path.to.field}}` markers. Adding a new AI platform is pure data. Cross-vendor is proven by boot, not just by generation: `npm run manifest:load-check` (`node test/vendor-manifest-load-smoke.js`) loads every generated manifest (claude, codex, agents, gemini, opencode) and asserts each exposes the full tool surface (184 tools).
623
661
 
624
662
  ## P2 Fixes (v0.1.48)
625
663
 
@@ -651,7 +689,7 @@ The orchestration vision landed in one release, all reviewer-gated:
651
689
 
652
690
  ## Tamper-evidence demo (v0.1.79)
653
691
 
654
- `cw demo tamper` — a hermetic, one-command proof that a recorded telemetry verdict cannot be forged undetected: it builds a real ed25519-signed ledger, forges it at the ledger layer (verdict flip + recomputed local hash → the chain still breaks) and the signature layer (inflated tokens, reused signature → ed25519 rejects), all verified offline with only the public key. `cw telemetry verify <run>` is the operator-facing half (`cw_telemetry_verify` on MCP).
692
+ `cw demo tamper` — a hermetic, one-command proof that a recorded telemetry verdict cannot be forged undetected: it builds a real ed25519-signed ledger, forges it at the ledger layer (verdict flip + recomputed local hash → the chain still breaks) and the signature layer (inflated tokens, reused signature → ed25519 rejects), all verified offline with only the public key. `cw telemetry verify <run>` (`cw_telemetry_verify` on MCP) is the operator-facing re-proof: by default it recomputes the hash chain on disk so any later edit to a recorded verdict or usage digest is caught; add `--pubkey <pem-or-path>` to re-run each `attested` hop's ed25519 signature check against the stored raw usage too. What this does and does **not** prove — including the single-keyholder ceiling — is documented honestly in [Trust Model & Limitations](docs/trust-model.md); read it before relying on a green verdict.
655
693
 
656
694
  ## Opt-in live agent output during a drive (on main, ships next)
657
695
 
@@ -662,3 +700,5 @@ v0.1.79
662
700
  ## Fast Architecture Review (v0.1.80)
663
701
 
664
702
  Adds the opt-in fast architecture-review lane: scoped JSONL source contexts, diff-aware exports, reusable Map and Assess results, measurable wrapper metrics, actionable background full-review handoff, and userland model policy flags for routing fast/strong workers without changing the full review contract.
703
+
704
+ _This documentation tracks Cool Workflow v0.1.82. See [CHANGELOG](../../CHANGELOG.md) for the release notes._
@@ -3,7 +3,7 @@
3
3
  "id": "architecture-review",
4
4
  "title": "Architecture Review",
5
5
  "summary": "Map a repository architecture, assess risks, verify important findings, and synthesize an evidence-backed verdict.",
6
- "version": "0.1.80",
6
+ "version": "0.1.82",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -3,7 +3,7 @@
3
3
  "id": "architecture-review-fast",
4
4
  "title": "Architecture Review Fast",
5
5
  "summary": "Run a shorter architecture review with parallel map and assess phases for faster first results.",
6
- "version": "0.1.80",
6
+ "version": "0.1.82",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -3,7 +3,7 @@
3
3
  "id": "end-to-end-golden-path",
4
4
  "title": "End-to-End Golden Path",
5
5
  "summary": "Deterministic one-worker workflow app for proving the CW integration chain.",
6
- "version": "0.1.80",
6
+ "version": "0.1.82",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -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.80",
6
+ "version": "0.1.82",
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.80",
6
+ "version": "0.1.82",
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.80",
6
+ "version": "0.1.82",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -139,18 +139,30 @@ function agentConfigFromArgs(args) {
139
139
  // npx/global install, where $(pwd)-relative paths don't exist) can configure a
140
140
  // WORKING agent without knowing where the package landed on disk:
141
141
  // --agent-command builtin:claude (or CW_AGENT_COMMAND=builtin:claude)
142
- // resolves to the packaged claude wrapper invocation. Still pure config — the
143
- // template is an out-of-process delegation script; CW never calls a model API.
144
- const BUILTIN_AGENT_TEMPLATES = {
145
- claude: `node ${node_path_1.default.join(__dirname, "..", "scripts", "agents", "claude-p-agent.js")} {{input}} {{result}}`
146
- };
142
+ // resolves to the packaged wrapper invocation. Still pure config — the template
143
+ // is an out-of-process delegation script; CW never calls a model API.
144
+ //
145
+ // The builtin set is DATA, not a kernel TS literal (FreeBSD-audit L15): it lives
146
+ // in scripts/agents/builtin-templates.json (vendor name -> wrapper script name).
147
+ // Adding a vendor is a content/distribution step (drop a wrapper + a JSON line),
148
+ // not a kernel edit — keeping CW vendor-agnostic at the source level.
149
+ function builtinAgentTemplates() {
150
+ const agentsDir = node_path_1.default.join(__dirname, "..", "scripts", "agents");
151
+ const manifest = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(agentsDir, "builtin-templates.json"), "utf8"));
152
+ const out = {};
153
+ for (const [name, script] of Object.entries(manifest.templates || {})) {
154
+ out[name] = `node ${node_path_1.default.join(agentsDir, script)} {{input}} {{result}}`;
155
+ }
156
+ return out;
157
+ }
147
158
  function expandBuiltinAgentCommand(command) {
148
159
  if (!command || !command.startsWith("builtin:"))
149
160
  return command;
150
161
  const name = command.slice("builtin:".length).trim();
151
- const template = BUILTIN_AGENT_TEMPLATES[name];
162
+ const templates = builtinAgentTemplates();
163
+ const template = templates[name];
152
164
  if (!template) {
153
- throw new Error(`Unknown builtin agent template "${name}" — available: ${Object.keys(BUILTIN_AGENT_TEMPLATES).join(", ")}`);
165
+ throw new Error(`Unknown builtin agent template "${name}" — available: ${Object.keys(templates).join(", ")}`);
154
166
  }
155
167
  return template;
156
168
  }
@@ -200,6 +212,8 @@ function setAgentConfigFile(patch, env = process.env) {
200
212
  endpoint: firstDefined(incoming.endpoint, current.endpoint),
201
213
  model: firstDefined(incoming.model, current.model),
202
214
  timeoutMs: firstDefined(incoming.timeoutMs, current.timeoutMs),
215
+ attestPublicKey: firstDefined(incoming.attestPublicKey, current.attestPublicKey),
216
+ requireAttestedTelemetry: firstDefined(incoming.requireAttestedTelemetry, current.requireAttestedTelemetry),
203
217
  source: "file"
204
218
  };
205
219
  const stored = redacted(merged);
@@ -20,7 +20,16 @@ const state_1 = require("./state");
20
20
  const state_node_1 = require("./state-node");
21
21
  const trust_audit_1 = require("./trust-audit");
22
22
  const collaboration_1 = require("./collaboration");
23
+ const compare_1 = require("./compare");
24
+ const gates_1 = require("./gates");
25
+ const validation_1 = require("./validation");
23
26
  exports.CANDIDATE_SCHEMA_VERSION = 1;
27
+ /** Verdict thresholds on a score's normalized value [0,1], declared once so the
28
+ * numbers carry intent instead of being buried as literals in verdictFor(). A
29
+ * normalized score at-or-above PASS is "pass"; at-or-above WARN (but below
30
+ * PASS) is "warn"; anything lower is "fail". Same numbers as before. */
31
+ const VERDICT_PASS_THRESHOLD = 0.7;
32
+ const VERDICT_WARN_THRESHOLD = 0.4;
24
33
  function createCandidateScoring(options = {}) {
25
34
  return {
26
35
  registerCandidate: (run, input) => registerCandidate(run, input, options),
@@ -39,7 +48,7 @@ function registerCandidate(run, input, options = {}) {
39
48
  if (existing)
40
49
  return existing;
41
50
  const now = new Date().toISOString();
42
- const id = input.id || createCandidateId(input.kind || "manual", input.workerId || input.taskId || input.resultNodeId);
51
+ const id = input.id || createCandidateId(run, input.kind || "manual", input.workerId || input.taskId || input.resultNodeId);
43
52
  const candidate = {
44
53
  schemaVersion: exports.CANDIDATE_SCHEMA_VERSION,
45
54
  id,
@@ -103,13 +112,16 @@ function getCandidate(run, candidateId) {
103
112
  const file = candidateFile(run, candidateId);
104
113
  if (!node_fs_1.default.existsSync(file))
105
114
  return undefined;
106
- const candidate = JSON.parse(node_fs_1.default.readFileSync(file, "utf8"));
115
+ // Fail-closed integrity boundary (F4/F5): validate the parsed record against
116
+ // its type def BEFORE upserting it as a trusted CandidateRecord. A corrupt or
117
+ // forged candidate.json must throw here rather than flow into the run.
118
+ const candidate = (0, validation_1.validateCandidateRecord)(JSON.parse(node_fs_1.default.readFileSync(file, "utf8")));
107
119
  upsertCandidate(run, candidate);
108
120
  return candidate;
109
121
  }
110
122
  function scoreCandidate(run, candidateId, input, options = {}) {
111
123
  const candidate = requireCandidate(run, candidateId);
112
- const scoreId = input.id || createScoreId(candidateId);
124
+ const scoreId = input.id || createScoreId(candidate);
113
125
  const evidence = (0, trust_audit_1.normalizeEvidence)(run, input.evidence || [], {
114
126
  source: "operator-recorded",
115
127
  candidateId,
@@ -238,10 +250,11 @@ function selectCandidate(run, candidateId, options = {}, scoringOptions = {}) {
238
250
  else if (!verifierNode.evidence.length) {
239
251
  failures.push(error("candidate-selection-missing-evidence", `Candidate ${candidateId} verifier node has no evidence`));
240
252
  }
241
- else if (emptyCaptureWarning(run, verifierNode)) {
242
- // HARD no-false-green gate (v0.1.43) — kept in SYNC with the commit gate
243
- // (commit.ts emptyCaptureWarning): a verifier node whose backing result was
244
- // an empty-capture must not be selectable, so selection + commit agree.
253
+ else if ((0, gates_1.emptyCaptureWarning)(run, verifierNode)) {
254
+ // HARD no-false-green gate (v0.1.43) — selection and the commit gate now
255
+ // share ONE emptyCaptureWarning (src/gates.ts), so they CANNOT drift: a
256
+ // verifier node whose backing result was an empty-capture is unselectable
257
+ // here for the same reason it is uncommittable, by construction.
245
258
  failures.push(error("candidate-selection-empty-capture", `Candidate ${candidateId} verifier node has no real evidence (empty-capture result)`));
246
259
  }
247
260
  }
@@ -279,7 +292,7 @@ function selectCandidate(run, candidateId, options = {}, scoringOptions = {}) {
279
292
  const now = new Date().toISOString();
280
293
  const selection = {
281
294
  schemaVersion: exports.CANDIDATE_SCHEMA_VERSION,
282
- id: createSelectionId(candidateId),
295
+ id: createSelectionId(run, candidateId),
283
296
  runId: run.id,
284
297
  candidateId,
285
298
  selectedAt: now,
@@ -305,7 +318,7 @@ function selectCandidate(run, candidateId, options = {}, scoringOptions = {}) {
305
318
  scoreCriteria: bestScore?.criteria,
306
319
  verifierNodeId: candidate.verifierNodeId,
307
320
  evidenceCount: mergeEvidence(candidate.evidence, verifierNode?.evidence || []).length,
308
- sandboxProfileId: sandboxProfileForCandidate(run, candidate),
321
+ sandboxProfileId: (0, gates_1.sandboxProfileForCandidate)(run, candidate),
309
322
  workerId: candidate.workerId,
310
323
  commitGateResult: "passed"
311
324
  }),
@@ -521,7 +534,10 @@ function loadCandidatesFromDisk(run) {
521
534
  .filter((entry) => entry.isDirectory() && entry.name !== "selections")
522
535
  .map((entry) => node_path_1.default.join(candidateRoot(run), entry.name, "candidate.json"))
523
536
  .filter((file) => node_fs_1.default.existsSync(file))
524
- .map((file) => JSON.parse(node_fs_1.default.readFileSync(file, "utf8")));
537
+ // Fail-closed integrity boundary (F4/F5): each candidate.json is validated
538
+ // against CandidateRecord before it merges into the run; a corrupt record
539
+ // throws rather than entering the candidate set as a trusted cast.
540
+ .map((file) => (0, validation_1.validateCandidateRecord)(JSON.parse(node_fs_1.default.readFileSync(file, "utf8"))));
525
541
  }
526
542
  function readScores(run, candidateId) {
527
543
  const dir = node_path_1.default.join(candidateDir(run, candidateId), "scores");
@@ -531,7 +547,10 @@ function readScores(run, candidateId) {
531
547
  .readdirSync(dir)
532
548
  .filter((file) => file.endsWith(".json"))
533
549
  .sort()
534
- .map((file) => JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, file), "utf8")));
550
+ // Fail-closed integrity boundary (F4/F5): a score file is validated against
551
+ // CandidateScore before it can feed ranking/selection. A corrupt score must
552
+ // throw, not silently widen the normalized/verdict surface the gate reads.
553
+ .map((file) => (0, validation_1.validateCandidateScore)(JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, file), "utf8"))));
535
554
  }
536
555
  function candidateArtifacts(run, candidate) {
537
556
  return [
@@ -558,16 +577,16 @@ function inferCandidateKind(input) {
558
577
  return "manual";
559
578
  }
560
579
  function bestScore(scores) {
561
- return [...scores].sort((left, right) => right.normalized - left.normalized || left.createdAt.localeCompare(right.createdAt))[0];
580
+ return [...scores].sort((left, right) => right.normalized - left.normalized || (0, compare_1.compareBytes)(left.createdAt, right.createdAt))[0];
562
581
  }
563
582
  function compareRows(left, right, policy) {
564
583
  const byScore = right.normalized - left.normalized;
565
584
  if (byScore !== 0)
566
585
  return byScore;
567
586
  if (policy.tieBreaker === "candidateId")
568
- return left.candidate.id.localeCompare(right.candidate.id);
569
- const byCreated = left.candidate.createdAt.localeCompare(right.candidate.createdAt);
570
- return byCreated || left.candidate.id.localeCompare(right.candidate.id);
587
+ return (0, compare_1.compareBytes)(left.candidate.id, right.candidate.id);
588
+ const byCreated = (0, compare_1.compareBytes)(left.candidate.createdAt, right.candidate.createdAt);
589
+ return byCreated || (0, compare_1.compareBytes)(left.candidate.id, right.candidate.id);
571
590
  }
572
591
  function detectTies(candidates) {
573
592
  const groups = new Map();
@@ -578,10 +597,15 @@ function detectTies(candidates) {
578
597
  return Array.from(groups.values()).filter((group) => group.length > 1);
579
598
  }
580
599
  function mergePolicy(policy = {}) {
600
+ // NOTE: `policy.criteria` (string[]) is intentionally NOT carried here. A
601
+ // whole-repo grep shows it has no read points — scoring reads each score's
602
+ // own `input.criteria` (Record<string, number>), not this list. Emitting a
603
+ // default `criteria: []` advertised a guarantee the code never honored and
604
+ // could silently drift, so it is dropped. The field stays OPTIONAL on
605
+ // CandidateScoringPolicy / CandidateRanking.policy for forward-compat input.
581
606
  return {
582
607
  id: policy.id || "cw.candidate.default",
583
608
  title: policy.title || "Default Candidate Scoring",
584
- criteria: policy.criteria || [],
585
609
  requireEvidence: policy.requireEvidence ?? true,
586
610
  requireVerifierGate: policy.requireVerifierGate ?? true,
587
611
  minNormalized: policy.minNormalized,
@@ -591,9 +615,9 @@ function mergePolicy(policy = {}) {
591
615
  function verdictFor(normalized, policy) {
592
616
  if (policy.minNormalized !== undefined && normalized < policy.minNormalized)
593
617
  return "fail";
594
- if (normalized >= 0.7)
618
+ if (normalized >= VERDICT_PASS_THRESHOLD)
595
619
  return "pass";
596
- if (normalized >= 0.4)
620
+ if (normalized >= VERDICT_WARN_THRESHOLD)
597
621
  return "warn";
598
622
  return "fail";
599
623
  }
@@ -616,18 +640,26 @@ function indexPath(run) {
616
640
  function rankingPath(run) {
617
641
  return node_path_1.default.join(candidateRoot(run), "ranking.json");
618
642
  }
619
- function createCandidateId(kind, seed) {
620
- const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "Z");
621
- const suffix = Math.random().toString(36).slice(2, 8);
622
- return `candidate-${(0, state_1.safeFileName)(kind)}-${seed ? `${(0, state_1.safeFileName)(seed)}-` : ""}${stamp}-${suffix}`;
623
- }
624
- function createScoreId(candidateId) {
625
- const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "Z");
626
- return `score-${(0, state_1.safeFileName)(candidateId)}-${stamp}-${Math.random().toString(36).slice(2, 8)}`;
627
- }
628
- function createSelectionId(candidateId) {
629
- const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "Z");
630
- return `selection-${(0, state_1.safeFileName)(candidateId)}-${stamp}-${Math.random().toString(36).slice(2, 8)}`;
643
+ // Deterministic candidate id (FreeBSD-audit L12/L13): the candidate's POSITION in
644
+ // the run's candidate set, qualified by kind + seed (a stable worker/task/result
645
+ // id) for readability. No wall-clock stamp, no PRNG suffix — re-running the same
646
+ // workflow mints byte-identical candidate ids, keeping fingerprints replay-stable.
647
+ function createCandidateId(run, kind, seed) {
648
+ const seq = (run.candidates || []).length + 1;
649
+ return `candidate-${(0, state_1.safeFileName)(kind)}-${seed ? `${(0, state_1.safeFileName)(seed)}-` : ""}${String(seq).padStart(4, "0")}`;
650
+ }
651
+ // Deterministic score id (FreeBSD-audit L12/L13): the score's POSITION within its
652
+ // candidate's score list. Scores only ever append, so the sequence is unique per
653
+ // candidate and stable across replays.
654
+ function createScoreId(candidate) {
655
+ const seq = (candidate.scores || []).length + 1;
656
+ return `score-${(0, state_1.safeFileName)(candidate.id)}-${String(seq).padStart(4, "0")}`;
657
+ }
658
+ // Deterministic selection id (FreeBSD-audit L12/L13): the selection's POSITION in
659
+ // the run's append-only selection log. No clock, no PRNG.
660
+ function createSelectionId(run, candidateId) {
661
+ const seq = (run.candidateSelections || []).length + 1;
662
+ return `selection-${(0, state_1.safeFileName)(candidateId)}-${String(seq).padStart(4, "0")}`;
631
663
  }
632
664
  function shouldPersist(options) {
633
665
  return options.persist !== false;
@@ -641,17 +673,6 @@ function error(code, message, options = {}) {
641
673
  details: options.details
642
674
  };
643
675
  }
644
- /** HARD no-false-green gate (v0.1.43) — kept in SYNC with commit.ts. Traces a
645
- * verifier node back to its source result node and returns the empty-capture
646
- * marker (set at ingest via isEmptyCapture) when present. Reads ONLY persisted
647
- * state, so selection replays deterministically. */
648
- function emptyCaptureWarning(run, verifierNode) {
649
- const resultNodeId = (typeof verifierNode.inputs?.inputNodeId === "string" ? verifierNode.inputs.inputNodeId : undefined) ||
650
- verifierNode.parents[0];
651
- const resultNode = resultNodeId ? run.nodes?.find((node) => node.id === resultNodeId) : undefined;
652
- const warning = resultNode?.metadata?.captureWarning;
653
- return typeof warning === "string" && warning ? warning : undefined;
654
- }
655
676
  function mergeCandidates(left, right) {
656
677
  const merged = [...left];
657
678
  for (const candidate of right) {
@@ -688,13 +709,6 @@ function mergeEvidence(left, right) {
688
709
  }
689
710
  return merged;
690
711
  }
691
- function sandboxProfileForCandidate(run, candidate) {
692
- const worker = candidate.workerId ? (run.workers || []).find((entry) => entry.id === candidate.workerId) : undefined;
693
- if (worker?.sandboxProfileId)
694
- return worker.sandboxProfileId;
695
- const task = candidate.taskId ? (run.tasks || []).find((entry) => entry.id === candidate.taskId) : undefined;
696
- return task?.sandboxProfileId;
697
- }
698
712
  function unique(values) {
699
713
  return Array.from(new Set(values.filter(Boolean)));
700
714
  }