cool-workflow 0.1.95 → 0.1.96

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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/apps/architecture-review/app.json +1 -1
  4. package/apps/architecture-review/workflow.js +3 -3
  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 +2 -1
  11. package/dist/dispatch.js +12 -6
  12. package/dist/evidence-grounding.js +18 -13
  13. package/dist/execution-backend/probes.js +22 -6
  14. package/dist/execution-backend.js +37 -4
  15. package/dist/node-snapshot.js +3 -3
  16. package/dist/orchestrator/lifecycle-operations.js +13 -5
  17. package/dist/orchestrator.js +26 -1
  18. package/dist/reclamation.js +8 -2
  19. package/dist/run-registry/derive.js +4 -1
  20. package/dist/scheduler.js +14 -14
  21. package/dist/schema-validate.js +8 -2
  22. package/dist/state-explosion/helpers.js +4 -21
  23. package/dist/state-explosion/size.js +63 -0
  24. package/dist/state-explosion.js +18 -71
  25. package/dist/state.js +47 -9
  26. package/dist/trust-audit.js +27 -2
  27. package/dist/util/fingerprint.js +19 -0
  28. package/dist/util/fingerprint.test.js +27 -0
  29. package/dist/version.js +1 -1
  30. package/dist/workbench-host.js +11 -0
  31. package/dist/workbench.js +19 -17
  32. package/dist/worker-isolation.js +25 -1
  33. package/docs/agent-delegation-drive.7.md +64 -1
  34. package/docs/cli-mcp-parity.7.md +2 -0
  35. package/docs/contract-migration-tooling.7.md +2 -0
  36. package/docs/control-plane-scheduling.7.md +2 -0
  37. package/docs/durable-state-and-locking.7.md +2 -0
  38. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  39. package/docs/execution-backends.7.md +2 -0
  40. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  41. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  42. package/docs/multi-agent-operator-ux.7.md +2 -0
  43. package/docs/node-snapshot-diff-replay.7.md +2 -0
  44. package/docs/observability-cost-accounting.7.md +2 -0
  45. package/docs/project-index.md +7 -3
  46. package/docs/real-execution-backends.7.md +2 -0
  47. package/docs/release-and-migration.7.md +2 -0
  48. package/docs/release-tooling.7.md +18 -0
  49. package/docs/routines.md +30 -0
  50. package/docs/run-registry-control-plane.7.md +2 -0
  51. package/docs/run-retention-reclamation.7.md +2 -0
  52. package/docs/sandbox-profiles.7.md +15 -0
  53. package/docs/state-explosion-management.7.md +2 -0
  54. package/docs/team-collaboration.7.md +2 -0
  55. package/docs/web-desktop-workbench.7.md +2 -0
  56. package/manifest/plugin.manifest.json +1 -1
  57. package/package.json +5 -3
  58. package/scripts/agents/agent-adapter-core.js +22 -1
  59. package/scripts/agents/claude-p-agent.js +10 -2
  60. package/scripts/agents/codex-agent.js +22 -2
  61. package/scripts/agents/gemini-agent.js +10 -2
  62. package/scripts/agents/opencode-agent.js +10 -2
  63. package/scripts/canonical-apps.js +4 -4
  64. package/scripts/dogfood-release.js +1 -1
  65. package/scripts/golden-path.js +4 -4
  66. package/scripts/release-flow.js +10 -0
  67. package/scripts/release-gate.sh +1 -1
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS = exports.STATE_EXPLOSION_SCHEMA_VERSION = void 0;
4
+ exports.computeStateSize = computeStateSize;
5
+ exports.computeStateSizeWithGraph = computeStateSizeWithGraph;
6
+ const multi_agent_operator_ux_1 = require("../multi-agent-operator-ux");
7
+ exports.STATE_EXPLOSION_SCHEMA_VERSION = 1;
8
+ exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS = {
9
+ graphNodes: 40,
10
+ graphEdges: 60,
11
+ blackboardMessages: 25,
12
+ blackboardRecords: 40,
13
+ collapseBucket: 6,
14
+ totalRecords: 80
15
+ };
16
+ function computeStateSize(run, thresholds = exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS) {
17
+ return computeStateSizeWithGraph(run, thresholds, (0, multi_agent_operator_ux_1.buildMultiAgentOperatorGraph)(run));
18
+ }
19
+ function computeStateSizeWithGraph(run, thresholds, graph) {
20
+ const ma = run.multiAgent || { runs: [], roles: [], groups: [], memberships: [], fanouts: [], fanins: [] };
21
+ const bb = run.blackboard || { topics: [], messages: [], contexts: [], artifacts: [], snapshots: [], decisions: [] };
22
+ const counts = {
23
+ multiAgentRuns: (ma.runs || []).length,
24
+ roles: (ma.roles || []).length,
25
+ groups: (ma.groups || []).length,
26
+ memberships: (ma.memberships || []).length,
27
+ fanouts: (ma.fanouts || []).length,
28
+ fanins: (ma.fanins || []).length,
29
+ topics: (bb.topics || []).length,
30
+ messages: (bb.messages || []).length,
31
+ contexts: (bb.contexts || []).length,
32
+ artifacts: (bb.artifacts || []).length,
33
+ snapshots: (bb.snapshots || []).length,
34
+ decisions: (bb.decisions || []).length,
35
+ graphNodes: graph.nodes.length,
36
+ graphEdges: graph.edges.length
37
+ };
38
+ const total = counts.multiAgentRuns +
39
+ counts.roles +
40
+ counts.groups +
41
+ counts.memberships +
42
+ counts.fanouts +
43
+ counts.fanins +
44
+ counts.topics +
45
+ counts.messages +
46
+ counts.contexts +
47
+ counts.artifacts +
48
+ counts.snapshots +
49
+ counts.decisions;
50
+ const reasons = [];
51
+ if (counts.graphNodes > thresholds.graphNodes)
52
+ reasons.push(`graph has ${counts.graphNodes} nodes (> ${thresholds.graphNodes})`);
53
+ if (counts.graphEdges > thresholds.graphEdges)
54
+ reasons.push(`graph has ${counts.graphEdges} edges (> ${thresholds.graphEdges})`);
55
+ if (counts.messages > thresholds.blackboardMessages)
56
+ reasons.push(`blackboard has ${counts.messages} messages (> ${thresholds.blackboardMessages})`);
57
+ const bbRecords = counts.topics + counts.messages + counts.contexts + counts.artifacts + counts.snapshots + counts.decisions;
58
+ if (bbRecords > thresholds.blackboardRecords)
59
+ reasons.push(`blackboard has ${bbRecords} records (> ${thresholds.blackboardRecords})`);
60
+ if (total > thresholds.totalRecords)
61
+ reasons.push(`run has ${total} multi-agent records (> ${thresholds.totalRecords})`);
62
+ return { ...counts, total, compactionRecommended: reasons.length > 0, reasons: reasons.sort() };
63
+ }
@@ -3,8 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.stateExplosionReportLines = exports.formatBlackboardDigest = exports.formatCompactGraph = exports.formatStateExplosionReport = exports.fingerprintStrings = exports.GRAPH_VIEWS = exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS = exports.STATE_EXPLOSION_SCHEMA_VERSION = void 0;
7
- exports.computeStateSize = computeStateSize;
6
+ exports.stateExplosionReportLines = exports.formatBlackboardDigest = exports.formatCompactGraph = exports.formatStateExplosionReport = exports.fingerprintStrings = exports.GRAPH_VIEWS = exports.STATE_EXPLOSION_SCHEMA_VERSION = exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS = exports.computeStateSizeWithGraph = exports.computeStateSize = void 0;
8
7
  exports.summarizeBlackboardDigest = summarizeBlackboardDigest;
9
8
  exports.buildCompactGraph = buildCompactGraph;
10
9
  exports.buildStateExplosionReport = buildStateExplosionReport;
@@ -21,15 +20,11 @@ const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
21
20
  const trust_audit_1 = require("./trust-audit");
22
21
  const evidence_reasoning_1 = require("./evidence-reasoning");
23
22
  const helpers_1 = require("./state-explosion/helpers");
24
- exports.STATE_EXPLOSION_SCHEMA_VERSION = 1;
25
- exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS = {
26
- graphNodes: 40,
27
- graphEdges: 60,
28
- blackboardMessages: 25,
29
- blackboardRecords: 40,
30
- collapseBucket: 6,
31
- totalRecords: 80
32
- };
23
+ const size_1 = require("./state-explosion/size");
24
+ Object.defineProperty(exports, "computeStateSize", { enumerable: true, get: function () { return size_1.computeStateSize; } });
25
+ Object.defineProperty(exports, "computeStateSizeWithGraph", { enumerable: true, get: function () { return size_1.computeStateSizeWithGraph; } });
26
+ Object.defineProperty(exports, "DEFAULT_STATE_EXPLOSION_THRESHOLDS", { enumerable: true, get: function () { return size_1.DEFAULT_STATE_EXPLOSION_THRESHOLDS; } });
27
+ Object.defineProperty(exports, "STATE_EXPLOSION_SCHEMA_VERSION", { enumerable: true, get: function () { return size_1.STATE_EXPLOSION_SCHEMA_VERSION; } });
33
28
  exports.GRAPH_VIEWS = [
34
29
  "full",
35
30
  "compact",
@@ -79,65 +74,17 @@ function graphKey(view, options) {
79
74
  view,
80
75
  options.focus || "",
81
76
  options.depth === undefined ? "" : String(options.depth),
82
- thresholdsKey(options.thresholds || exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS)
77
+ thresholdsKey(options.thresholds || size_1.DEFAULT_STATE_EXPLOSION_THRESHOLDS)
83
78
  ].join("\0");
84
79
  }
85
80
  // ---------------------------------------------------------------------------
86
- // State size
81
+ // State size (implementation in state-explosion/size.ts)
87
82
  // ---------------------------------------------------------------------------
88
- function computeStateSize(run, thresholds = exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS) {
89
- return computeStateSizeWithGraph(run, thresholds, (0, multi_agent_operator_ux_1.buildMultiAgentOperatorGraph)(run));
90
- }
91
- function computeStateSizeWithGraph(run, thresholds, graph) {
92
- const ma = run.multiAgent || { runs: [], roles: [], groups: [], memberships: [], fanouts: [], fanins: [] };
93
- const bb = run.blackboard || { topics: [], messages: [], contexts: [], artifacts: [], snapshots: [], decisions: [] };
94
- const counts = {
95
- multiAgentRuns: (ma.runs || []).length,
96
- roles: (ma.roles || []).length,
97
- groups: (ma.groups || []).length,
98
- memberships: (ma.memberships || []).length,
99
- fanouts: (ma.fanouts || []).length,
100
- fanins: (ma.fanins || []).length,
101
- topics: (bb.topics || []).length,
102
- messages: (bb.messages || []).length,
103
- contexts: (bb.contexts || []).length,
104
- artifacts: (bb.artifacts || []).length,
105
- snapshots: (bb.snapshots || []).length,
106
- decisions: (bb.decisions || []).length,
107
- graphNodes: graph.nodes.length,
108
- graphEdges: graph.edges.length
109
- };
110
- const total = counts.multiAgentRuns +
111
- counts.roles +
112
- counts.groups +
113
- counts.memberships +
114
- counts.fanouts +
115
- counts.fanins +
116
- counts.topics +
117
- counts.messages +
118
- counts.contexts +
119
- counts.artifacts +
120
- counts.snapshots +
121
- counts.decisions;
122
- const reasons = [];
123
- if (counts.graphNodes > thresholds.graphNodes)
124
- reasons.push(`graph has ${counts.graphNodes} nodes (> ${thresholds.graphNodes})`);
125
- if (counts.graphEdges > thresholds.graphEdges)
126
- reasons.push(`graph has ${counts.graphEdges} edges (> ${thresholds.graphEdges})`);
127
- if (counts.messages > thresholds.blackboardMessages)
128
- reasons.push(`blackboard has ${counts.messages} messages (> ${thresholds.blackboardMessages})`);
129
- const bbRecords = counts.topics + counts.messages + counts.contexts + counts.artifacts + counts.snapshots + counts.decisions;
130
- if (bbRecords > thresholds.blackboardRecords)
131
- reasons.push(`blackboard has ${bbRecords} records (> ${thresholds.blackboardRecords})`);
132
- if (total > thresholds.totalRecords)
133
- reasons.push(`run has ${total} multi-agent records (> ${thresholds.totalRecords})`);
134
- return { ...counts, total, compactionRecommended: reasons.length > 0, reasons: reasons.sort() };
135
- }
136
83
  function stateSizeFor(run, thresholds, context) {
137
84
  const key = thresholdsKey(thresholds);
138
85
  let size = context.stateSizes.get(key);
139
86
  if (!size) {
140
- size = computeStateSizeWithGraph(run, thresholds, fullGraphFor(run, context));
87
+ size = (0, size_1.computeStateSizeWithGraph)(run, thresholds, fullGraphFor(run, context));
141
88
  context.stateSizes.set(key, size);
142
89
  }
143
90
  return size;
@@ -321,7 +268,7 @@ function summarizeBlackboardDigest(run, blackboardId) {
321
268
  ]);
322
269
  const fingerprint = (0, helpers_1.fingerprintRecords)([...topics, ...messages, ...contexts, ...artifacts, ...decisions]);
323
270
  return {
324
- schemaVersion: exports.STATE_EXPLOSION_SCHEMA_VERSION,
271
+ schemaVersion: size_1.STATE_EXPLOSION_SCHEMA_VERSION,
325
272
  runId: run.id,
326
273
  id: `blackboard-digest${boardId ? `:${boardId}` : ""}`,
327
274
  scope: "blackboard",
@@ -368,7 +315,7 @@ function buildCompactGraph(run, view = "compact", options = {}) {
368
315
  return buildCompactGraphWithContext(run, view, options, createStateExplosionBuildContext());
369
316
  }
370
317
  function buildCompactGraphWithContext(run, view, options, context) {
371
- const thresholds = options.thresholds || exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS;
318
+ const thresholds = options.thresholds || size_1.DEFAULT_STATE_EXPLOSION_THRESHOLDS;
372
319
  const key = graphKey(view, { ...options, thresholds });
373
320
  const cached = context.graphRecords.get(key);
374
321
  if (cached)
@@ -517,7 +464,7 @@ function finalizeGraphRecord(run, view, options, full, built) {
517
464
  ...built.syntheticNodes.filter((s) => s.blockedReason).map((s) => s.blockedReason)
518
465
  ]);
519
466
  return {
520
- schemaVersion: exports.STATE_EXPLOSION_SCHEMA_VERSION,
467
+ schemaVersion: size_1.STATE_EXPLOSION_SCHEMA_VERSION,
521
468
  runId: run.id,
522
469
  id: `graph-${view}${options.focus ? `:focus:${(0, helpers_1.slug)(options.focus)}` : ""}`,
523
470
  scope: "run",
@@ -754,7 +701,7 @@ function buildOperatorDigestWithContext(run, thresholds, context) {
754
701
  ...compact.syntheticNodes.map((syn) => syn.expansionCommand)
755
702
  ]);
756
703
  return {
757
- schemaVersion: exports.STATE_EXPLOSION_SCHEMA_VERSION,
704
+ schemaVersion: size_1.STATE_EXPLOSION_SCHEMA_VERSION,
758
705
  runId: run.id,
759
706
  id: "operator-digest",
760
707
  scope: "run",
@@ -817,7 +764,7 @@ function buildStateExplosionReport(run, options = {}) {
817
764
  return buildStateExplosionReportWithContext(run, options, createStateExplosionBuildContext());
818
765
  }
819
766
  function buildStateExplosionReportWithContext(run, options, context) {
820
- const thresholds = options.thresholds || exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS;
767
+ const thresholds = options.thresholds || size_1.DEFAULT_STATE_EXPLOSION_THRESHOLDS;
821
768
  const stateSize = stateSizeFor(run, thresholds, context);
822
769
  const compactGraph = buildCompactGraphWithContext(run, "compact", { thresholds }, context);
823
770
  const criticalPathGraph = buildCompactGraphWithContext(run, "critical-path", { thresholds }, context);
@@ -847,7 +794,7 @@ function buildStateExplosionReportWithContext(run, options, context) {
847
794
  ? `node scripts/cw.js summary refresh ${run.id}`
848
795
  : operatorDigest.nextAction;
849
796
  return {
850
- schemaVersion: exports.STATE_EXPLOSION_SCHEMA_VERSION,
797
+ schemaVersion: size_1.STATE_EXPLOSION_SCHEMA_VERSION,
851
798
  runId: run.id,
852
799
  generatedAt: new Date().toISOString(),
853
800
  stateSize,
@@ -887,7 +834,7 @@ function currentEntryFingerprint(run, entry, records) {
887
834
  * BSD: mechanism (check + refresh); policy (when to call) is at the call site. */
888
835
  function maybeCompactRun(run) {
889
836
  try {
890
- const size = computeStateSize(run);
837
+ const size = (0, size_1.computeStateSize)(run);
891
838
  if (size.compactionRecommended) {
892
839
  refreshStateExplosionSummaries(run);
893
840
  }
@@ -900,7 +847,7 @@ function summariesDir(run) {
900
847
  return node_path_1.default.join(run.paths.runDir, "summaries");
901
848
  }
902
849
  function refreshStateExplosionSummaries(run, options = {}) {
903
- const thresholds = options.thresholds || exports.DEFAULT_STATE_EXPLOSION_THRESHOLDS;
850
+ const thresholds = options.thresholds || size_1.DEFAULT_STATE_EXPLOSION_THRESHOLDS;
904
851
  const context = createStateExplosionBuildContext();
905
852
  const dir = summariesDir(run);
906
853
  node_fs_1.default.mkdirSync(dir, { recursive: true });
@@ -923,7 +870,7 @@ function refreshStateExplosionSummaries(run, options = {}) {
923
870
  const compactGraph = buildCompactGraphWithContext(run, "compact", { thresholds }, context);
924
871
  const reportPath = node_path_1.default.join(dir, "state-explosion-report.json");
925
872
  const index = {
926
- schemaVersion: exports.STATE_EXPLOSION_SCHEMA_VERSION,
873
+ schemaVersion: size_1.STATE_EXPLOSION_SCHEMA_VERSION,
927
874
  runId: run.id,
928
875
  id: "multi-agent-summary-index",
929
876
  scope: "run",
package/dist/state.js CHANGED
@@ -70,6 +70,7 @@ function ensureRunDirs(paths) {
70
70
  function loadRunFromCwd(runId, cwd = process.cwd()) {
71
71
  if (!runId)
72
72
  throw new Error("Missing run id");
73
+ assertSafeRunId(runId);
73
74
  const statePath = node_path_1.default.join(cwd, ".cw", "runs", runId, "state.json");
74
75
  const result = loadRunStateFile(statePath, { dryRun: true });
75
76
  if (result.report.status === "unsupported") {
@@ -98,8 +99,11 @@ function migrateRunStateFile(statePath, options = {}) {
98
99
  }
99
100
  function saveCheckpoint(run) {
100
101
  run.updatedAt = new Date().toISOString();
101
- // state.json is the single source of truth — write it DURABLY (v0.1.40).
102
- writeJson(run.paths.state, run, { durable: true });
102
+ // state.json is the single source of truth — write it DURABLY with a lock
103
+ // so concurrent processes never lose an update (v0.1.95).
104
+ withFileLock(run.paths.state, () => {
105
+ writeJson(run.paths.state, run, { durable: true });
106
+ });
103
107
  }
104
108
  /** Compact a run checkpoint by stripping empty optional arrays and null values
105
109
  * that don't carry semantic meaning (v0.1.60). The normalization layer
@@ -245,6 +249,12 @@ function isContainedPath(candidate, allowed) {
245
249
  // per-run reclamation chain) so a concurrent writer can never lose a record.
246
250
  // O_EXCL (`wx`) is portable (no native flock); a stale holder is stolen so a
247
251
  // crashed process can never wedge the store forever.
252
+ //
253
+ // v0.1.95: the lock mtime is refreshed immediately before fn() runs, and
254
+ // verified AFTER fn() returns — if another process stole the lock mid-operation
255
+ // (the stale check fired and it overwrote our lock), the holder refuses to
256
+ // release and throws. This guards long-running RMW operations (e.g. GC over a
257
+ // large run) against mid-operation theft.
248
258
  // ---------------------------------------------------------------------------
249
259
  const FILE_LOCK_STALE_MS = 30_000;
250
260
  function sleepSync(ms) {
@@ -254,11 +264,12 @@ function sleepSync(ms) {
254
264
  function withFileLock(targetPath, fn) {
255
265
  const lock = `${targetPath}.lock`;
256
266
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(lock), { recursive: true });
267
+ const pid = String(process.pid);
257
268
  let acquired = false;
258
269
  for (let attempt = 0; attempt < 240 && !acquired; attempt++) {
259
270
  try {
260
271
  const fd = node_fs_1.default.openSync(lock, "wx");
261
- node_fs_1.default.writeFileSync(fd, `${process.pid}@${new Date().toISOString()}\n`, "utf8");
272
+ node_fs_1.default.writeFileSync(fd, `${pid}@${new Date().toISOString()}\n`, "utf8");
262
273
  node_fs_1.default.closeSync(fd);
263
274
  acquired = true;
264
275
  }
@@ -272,19 +283,45 @@ function withFileLock(targetPath, fn) {
272
283
  }
273
284
  }
274
285
  catch {
275
- continue; // lock vanished between open and stat — retry immediately
286
+ continue;
276
287
  }
277
288
  sleepSync(25);
278
289
  }
279
290
  }
280
291
  if (!acquired)
281
292
  throw new Error(`could not acquire file lock for ${targetPath}`);
293
+ // Refresh mtime right before the critical section
282
294
  try {
283
- return fn();
295
+ node_fs_1.default.utimesSync(lock, new Date(), new Date());
296
+ }
297
+ catch { /* best-effort */ }
298
+ try {
299
+ const result = fn();
300
+ // Verify lock was not stolen during fn(). The lock content may have changed
301
+ // if another process opened it with wx after stealing it. If our PID is not
302
+ // in the lock file, the lock was stolen — do NOT release the stolen lock
303
+ // (the thief owns it now, and releasing would corrupt its operation).
304
+ try {
305
+ const current = node_fs_1.default.readFileSync(lock, "utf8");
306
+ if (!current.startsWith(pid + "@")) {
307
+ throw new Error(`File lock for ${targetPath} was stolen during the critical section ` +
308
+ `(lock now owned by another process). The operation may have lost ` +
309
+ `cross-process isolation — increase FILE_LOCK_STALE_MS or split the work.`);
310
+ }
311
+ }
312
+ catch (checkError) {
313
+ if (checkError instanceof Error && checkError.message.includes("stolen"))
314
+ throw checkError;
315
+ /* lock vanished — another process already released it, nothing to clean up */
316
+ }
317
+ return result;
284
318
  }
285
319
  finally {
286
320
  try {
287
- node_fs_1.default.rmSync(lock, { force: true });
321
+ // Only release if we still own the lock
322
+ const current = node_fs_1.default.readFileSync(lock, "utf8");
323
+ if (current.startsWith(pid + "@"))
324
+ node_fs_1.default.rmSync(lock, { force: true });
288
325
  }
289
326
  catch {
290
327
  /* releasing a missing lock is fine */
@@ -299,7 +336,8 @@ function safeFileName(value) {
299
336
  * runs directory via a separator or a `..`/`.` component (path traversal). A
300
337
  * real run id is `${workflowId}-${stamp}-${suffix}` (createRunId), and a
301
338
  * workflow id is alnum-bounded [a-z0-9.-] (validateWorkflowId) — all within
302
- * [A-Za-z0-9._-]. Because the charset already forbids any separator, the whole
339
+ * [A-Za-z0-9._:-]. Sub-workflow ids also include `:` from the task id prefix.
340
+ * Because the charset already forbids any separator, the whole
303
341
  * id is ONE path component, so the only values that could traverse are the
304
342
  * components `.` and `..` themselves; an embedded `..` (e.g. a workflow id like
305
343
  * `v1..2`) is a safe directory name and is allowed. A separator, an absolute
@@ -309,8 +347,8 @@ function assertSafeRunId(value, context = "run id") {
309
347
  if (typeof value !== "string" || value.length === 0) {
310
348
  throw new Error(`Invalid ${context}: expected a non-empty string`);
311
349
  }
312
- if (!/^[A-Za-z0-9._-]+$/.test(value) || value === "." || value === "..") {
313
- throw new Error(`Unsafe ${context}: ${JSON.stringify(value)} must be a single path segment ([A-Za-z0-9._-], not '.' or '..')`);
350
+ if (!/^[A-Za-z0-9._:-]+$/.test(value) || value === "." || value === "..") {
351
+ throw new Error(`Unsafe ${context}: ${JSON.stringify(value)} must be a single path segment ([A-Za-z0-9._:-], not '.' or '..')`);
314
352
  }
315
353
  return value;
316
354
  }
@@ -11,6 +11,8 @@ exports.recordTrustAuditEvent = recordTrustAuditEvent;
11
11
  exports.recordSandboxPathDecision = recordSandboxPathDecision;
12
12
  exports.recordSandboxPolicyDecision = recordSandboxPolicyDecision;
13
13
  exports.recordHostAttestation = recordHostAttestation;
14
+ exports.setAuditEventCache = setAuditEventCache;
15
+ exports.clearAuditEventCache = clearAuditEventCache;
14
16
  exports.listTrustAuditEvents = listTrustAuditEvents;
15
17
  exports.searchAuditEvents = searchAuditEvents;
16
18
  exports.summarizeTrustAudit = summarizeTrustAudit;
@@ -300,9 +302,25 @@ function recordHostAttestation(run, input) {
300
302
  source: "host-attested"
301
303
  });
302
304
  }
305
+ // Per-request event log cache (v0.1.95). When set, readEvents returns
306
+ // memoized results keyed by event log path. Clears after each request.
307
+ let _eventLogCache = null;
308
+ function setAuditEventCache(cache) {
309
+ _eventLogCache = cache;
310
+ }
311
+ function clearAuditEventCache() {
312
+ _eventLogCache = null;
313
+ }
303
314
  function listTrustAuditEvents(run) {
304
315
  const audit = ensureTrustAudit(run);
305
- return readEventsRaw(audit.eventLogPath).events.sort(compareEvents);
316
+ if (_eventLogCache) {
317
+ const cached = _eventLogCache.get(audit.eventLogPath);
318
+ if (cached)
319
+ return cached;
320
+ }
321
+ const events = readEventsRaw(audit.eventLogPath).events.sort(compareEvents);
322
+ _eventLogCache?.set(audit.eventLogPath, events);
323
+ return events;
306
324
  }
307
325
  /** Search audit events by kind, worker, or candidate (v0.1.65).
308
326
  * Filters are AND-ed; empty filters match all. */
@@ -515,7 +533,14 @@ function auditRoot(run) {
515
533
  return run.paths.auditDir || node_path_1.default.join(run.paths.runDir, "audit");
516
534
  }
517
535
  function readEvents(eventLogPath) {
518
- return readEventsRaw(eventLogPath).events.sort(compareEvents);
536
+ if (_eventLogCache) {
537
+ const cached = _eventLogCache.get(eventLogPath);
538
+ if (cached)
539
+ return cached;
540
+ }
541
+ const events = readEventsRaw(eventLogPath).events.sort(compareEvents);
542
+ _eventLogCache?.set(eventLogPath, events);
543
+ return events;
519
544
  }
520
545
  function workerRows(events, run) {
521
546
  const workerIds = unique([...(run.workers || []).map((worker) => worker.id), ...events.map((event) => event.workerId || "")]).sort();
@@ -0,0 +1,19 @@
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.fingerprintStrings = fingerprintStrings;
7
+ exports.fingerprintRecords = fingerprintRecords;
8
+ // Deterministic content fingerprint — the single canonical implementation.
9
+ // Replaces duplicated copies in observability.ts and run-registry.ts (v0.1.95).
10
+ // Pure function of its arguments; never imports run state or high-level modules.
11
+ const node_crypto_1 = __importDefault(require("node:crypto"));
12
+ function fingerprintStrings(values) {
13
+ const hash = node_crypto_1.default.createHash("sha256");
14
+ hash.update(JSON.stringify([...values].sort()));
15
+ return `sha256:${hash.digest("hex").slice(0, 32)}`;
16
+ }
17
+ function fingerprintRecords(records) {
18
+ return fingerprintStrings(records.map((r) => `${r.id}:${r.status || ""}`).sort());
19
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // Unit test for the canonical fingerprint utility (v0.1.95).
4
+ // Pure function — no run state or tmpdir needed.
5
+ const strict_1 = require("node:assert/strict");
6
+ const node_test_1 = require("node:test");
7
+ const fingerprint_1 = require("../util/fingerprint");
8
+ (0, node_test_1.test)("fingerprintStrings returns a sha256: prefix", () => {
9
+ const fp = (0, fingerprint_1.fingerprintStrings)(["a", "b"]);
10
+ (0, strict_1.ok)(fp.startsWith("sha256:"), "must have sha256: prefix");
11
+ (0, strict_1.equal)(fp.length, 32 + "sha256:".length, "must be 32 hex chars");
12
+ });
13
+ (0, node_test_1.test)("fingerprintStrings is deterministic and order-independent", () => {
14
+ const a = (0, fingerprint_1.fingerprintStrings)(["b", "a", "c"]);
15
+ const b = (0, fingerprint_1.fingerprintStrings)(["c", "b", "a"]);
16
+ (0, strict_1.equal)(a, b, "same values in different order must produce same fingerprint");
17
+ });
18
+ (0, node_test_1.test)("fingerprintStrings produces distinct values for different inputs", () => {
19
+ const a = (0, fingerprint_1.fingerprintStrings)(["x"]);
20
+ const b = (0, fingerprint_1.fingerprintStrings)(["y"]);
21
+ (0, strict_1.ok)(a !== b, "different inputs must produce different fingerprints");
22
+ });
23
+ (0, node_test_1.test)("fingerprintRecords uses id:status sorted", () => {
24
+ const a = (0, fingerprint_1.fingerprintRecords)([{ id: "b", status: "ok" }, { id: "a", status: "fail" }]);
25
+ const b = (0, fingerprint_1.fingerprintRecords)([{ id: "a", status: "fail" }, { id: "b", status: "ok" }]);
26
+ (0, strict_1.equal)(a, b, "records in different order must produce same fingerprint");
27
+ });
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION = exports.LEGACY_RUN_STATE_SCHEMA_VERSION = exports.CURRENT_RUN_STATE_SCHEMA_VERSION = exports.WORKFLOW_APP_SCHEMA_VERSION = exports.CURRENT_COOL_WORKFLOW_VERSION = void 0;
4
- exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.95";
4
+ exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.96";
5
5
  exports.WORKFLOW_APP_SCHEMA_VERSION = 1;
6
6
  exports.CURRENT_RUN_STATE_SCHEMA_VERSION = 1;
7
7
  exports.LEGACY_RUN_STATE_SCHEMA_VERSION = 0;
@@ -85,7 +85,18 @@ class WorkbenchHost {
85
85
  if ((req.method || "GET").toUpperCase() !== "GET") {
86
86
  return this.send(res, 405, { error: "read-only: only GET is permitted" }, { Allow: "GET" });
87
87
  }
88
+ // Optional token auth (CW_WORKBENCH_TOKEN). When set, every request must
89
+ // carry the token as an Authorization: Bearer header or ?token= query param.
90
+ // Off by default — single-user loopback is already gated by the OS boundary.
88
91
  const url = new URL(req.url || "/", `http://${this.host}`);
92
+ const requiredToken = (process.env.CW_WORKBENCH_TOKEN || "").trim();
93
+ if (requiredToken) {
94
+ const bearer = (req.headers.authorization || "").replace(/^Bearer\s+/i, "").trim();
95
+ const queryToken = url.searchParams.get("token") || "";
96
+ if (bearer !== requiredToken && queryToken !== requiredToken) {
97
+ return this.send(res, 401, { error: "unauthorized: token mismatch" });
98
+ }
99
+ }
89
100
  const route = decodeURIComponent(url.pathname);
90
101
  if (route === "/" || route === "/index.html")
91
102
  return this.sendAsset(res, "index.html");
package/dist/workbench.js CHANGED
@@ -110,23 +110,25 @@ function buildPanels(runner, runId) {
110
110
  * never fabricated. */
111
111
  function buildWorkbenchRunView(runner, runId) {
112
112
  const id = String(runId || "");
113
- let resolved = true;
114
- let error;
115
- try {
116
- runner.loadRun(id);
117
- }
118
- catch (caught) {
119
- resolved = false;
120
- error = caught instanceof Error ? caught.message : String(caught);
121
- }
122
- return {
123
- schemaVersion: 1,
124
- surface: "workbench",
125
- runId: id,
126
- resolved,
127
- ...(error ? { error } : {}),
128
- panels: buildPanels(runner, id)
129
- };
113
+ return runner.loadWithCache((r) => {
114
+ let resolved = true;
115
+ let error;
116
+ try {
117
+ r.loadRun(id);
118
+ }
119
+ catch (caught) {
120
+ resolved = false;
121
+ error = caught instanceof Error ? caught.message : String(caught);
122
+ }
123
+ return {
124
+ schemaVersion: 1,
125
+ surface: "workbench",
126
+ runId: id,
127
+ resolved,
128
+ ...(error ? { error } : {}),
129
+ panels: buildPanels(r, id)
130
+ };
131
+ });
130
132
  }
131
133
  // ---------------------------------------------------------------------------
132
134
  // Cross-run entry (v0.1.28 Run Registry) — composed from already-declared
@@ -393,7 +393,31 @@ function recordWorkerRetryAttempt(run, workerId, attempts, reason, options = {})
393
393
  function validateWorkerBoundary(run, workerId, options = {}) {
394
394
  const scope = requireWorkerScope(run, workerId);
395
395
  const rawPath = String(options.path || scope.resultPath);
396
- return (0, sandbox_profile_1.validateSandboxWrite)(sandboxPolicyForBoundary(run, scope, options), rawPath, workerId);
396
+ const policy = sandboxPolicyForBoundary(run, scope, options);
397
+ const violation = (0, sandbox_profile_1.validateSandboxWrite)(policy, rawPath, workerId);
398
+ if (!violation) {
399
+ // Write paths are enforced by CW at this boundary. Command and network limits
400
+ // are declared in the sandbox policy but enforced by the execution backend
401
+ // (the host/container runtime). Record the policy split transparently so the
402
+ // audit trail shows what CW checked vs what was delegated to the host.
403
+ (0, trust_audit_1.recordTrustAuditEvent)(run, {
404
+ kind: "worker.sandbox-boundary",
405
+ decision: "allowed",
406
+ source: "cw-validated",
407
+ workerId,
408
+ taskId: scope.taskId,
409
+ sandboxProfileId: policy.id,
410
+ policyRef: `execute=${policy.execute.mode} network=${policy.network.mode} env.inherit=${policy.env.inherit}`,
411
+ command: policy.execute.mode,
412
+ networkTarget: policy.network.mode,
413
+ metadata: {
414
+ enforced_by_cw: ["write-paths"],
415
+ delegated_to_host: ["execute", "network", "env"],
416
+ env_inherit: policy.env.inherit
417
+ }
418
+ });
419
+ }
420
+ return violation;
397
421
  }
398
422
  function summarizeWorkers(run) {
399
423
  const workers = listWorkerScopes(run);
@@ -27,6 +27,52 @@ HTTP API. Any API key comes from the agent's *own* inherited env; CW never reads
27
27
  or keeps a record of it. Adding a provider SDK to `package.json` would lose the
28
28
  neutral-audit moat and is the red line.
29
29
 
30
+ ## Architecture — the boundary (core ↔ agent backend ↔ wrappers)
31
+
32
+ The core gives only an INTERFACE: the `agent` execution backend plus a small
33
+ text/process contract. The four vendors (claude / codex / gemini / deepseek)
34
+ live OUTSIDE the core as out-of-process wrapper scripts in `scripts/agents/` —
35
+ pure config, never imported by `src/`, behind the same seam.
36
+
37
+ ```text
38
+ user
39
+ │ cw -q "…" -codex (headline shortcut; also -claude/-gemini/-deepseek)
40
+
41
+ ┌──────────── CW core (src/ — zero runtime deps, imports NO model SDK, holds NO key) ───────────┐
42
+ │ │
43
+ │ command-surface.ts agent-config.ts execution-backend ("agent" driver) │
44
+ │ -codex → builtin:codex ─► builtin:<name> ─► runAgentProcess: │
45
+ │ node <dir>/<name>-agent.js spawnSync(binary, args, shell:false)│
46
+ │ {{input}} {{result}} · inherits env · captures stdout │
47
+ │ (builtin-templates.json: DATA) · records handle{process}+attestation│
48
+ └───────────────────────────────────────┬───────────────────────────────────────────────────────┘
49
+ THE SEAM (text / process contract) │
50
+ in : argv {{input}}=worker input.md {{result}}=worker result.md
51
+ out: wrapper writes result.md + ONE stdout JSON line {model,usage,result} → parseAgentReport
52
+
53
+ ── red line ── core never reads a key; each wrapper resolves its OWN key from inherited env
54
+
55
+ ┌──────── external wrappers (scripts/agents/*.js — "CONFIG, not a CW runtime dependency") ───────┐
56
+ │ claude-p-agent.js codex-agent.js gemini-opencode-agent.js deepseek-agent.js │
57
+ │ │ │ └──────────┬──────────────┘ │
58
+ │ ▼ ▼ ▼ (3-line shims) │
59
+ │ claude -p codex exec opencode-agent.js │
60
+ │ (Anthropic CLI) (-c effort, sandbox) opencode run --model … │
61
+ │ (deepseek + gemini keys live here) │
62
+ └─────────────────────────────────────────────────────────────────────────────────────────────────┘
63
+ Add a vendor = drop a wrapper script + one line in builtin-templates.json — NO core edit.
64
+ ```
65
+
66
+ Each box maps to one source seam: the flag map lives in `command-surface.ts`
67
+ (`-codex` → `builtin:codex`); `agent-config.ts` expands `builtin:<name>` into
68
+ `node <dir>/<name>-agent.js {{input}} {{result}}` by reading
69
+ `builtin-templates.json` (the registry is DATA, not a kernel literal); the
70
+ `agent` driver's `runAgentProcess` does the `spawnSync` and reads back the one
71
+ stdout JSON line via `parseAgentReport`. So the four vendors are **delegated
72
+ agent wrappers, not an event-"hook" system**. The seam is generic: any
73
+ `CW_AGENT_COMMAND="node my-agent.js {{input}} {{result}}"` or a configured HTTP
74
+ endpoint plugs in the same way — the four builtins are just bundled examples.
75
+
30
76
  ## Operator-chosen model is policy; agent-reported model is the attestation
31
77
 
32
78
  Any model id CW passes **into** the agent invocation (`CW_AGENT_MODEL`
@@ -250,7 +296,22 @@ The built-in templates are:
250
296
  claude and codex run their own CLIs; gemini and deepseek route through opencode
251
297
  (where their keys live), each proven by a local, deterministic wrapper smoke
252
298
  (override the model with CW_GEMINI_MODEL / CW_DEEPSEEK_MODEL). GLM stays an
253
- external agent command or HTTP endpoint. CW still imports no model SDK.
299
+ external agent command or HTTP endpoint. CW still imports no model SDK. The same
300
+ headline shortcuts pick these builtins on the top-level CLI: `cw -q "..."
301
+ -claude` / `-codex` / `-gemini` / `-deepseek`.
302
+
303
+ The codex wrapper caps codex's reasoning effort for CW runs so a heavy
304
+ `model_reasoning_effort = "high"` in the user's `~/.codex/config.toml` does not
305
+ make every read/grep turn slow. It passes `codex exec -c
306
+ model_reasoning_effort=<effort>` (default `low`) for THAT run only — the user's
307
+ interactive codex is untouched. Raise it with `CW_CODEX_REASONING_EFFORT`
308
+ (`low` | `medium` | `high`).
309
+
310
+ When an agent hop fails, CW core keeps only the child's stdout + exit code, so a
311
+ bare `failed (exit 1)` hid the real cause (a relay 5xx, an auth error, a killed
312
+ run). Each wrapper now also drops the failed child's stderr to
313
+ `<run>/workers/<worker>/logs/agent-stderr.log`, so the reason is readable after
314
+ the fact without changing the recorded, byte-stable evidence.
254
315
 
255
316
  ## Compatibility
256
317
 
@@ -346,3 +407,5 @@ The one-command `cw -q` headline now routes the question and defaults the repo t
346
407
  0.1.94
347
408
 
348
409
  0.1.95
410
+
411
+ 0.1.96