cool-workflow 0.2.3 → 0.2.5

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 (143) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +202 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +33 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/io.js +6 -20
  18. package/dist/cli/parseargv.js +7 -1
  19. package/dist/core/capability-data.js +339 -10
  20. package/dist/core/format/help.js +80 -4
  21. package/dist/core/format/recovery-hint.js +32 -0
  22. package/dist/core/multi-agent/collaboration.js +35 -6
  23. package/dist/core/multi-agent/runtime.js +7 -0
  24. package/dist/core/multi-agent/trust-policy.js +7 -1
  25. package/dist/core/pipeline/contract.js +7 -0
  26. package/dist/core/pipeline/error-feedback.js +2 -2
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +55 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +124 -13
  32. package/dist/mcp-server.js +20 -0
  33. package/dist/shell/commit.js +8 -2
  34. package/dist/shell/coordinator-io.js +73 -1
  35. package/dist/shell/drive.js +129 -64
  36. package/dist/shell/error-feedback-io.js +6 -0
  37. package/dist/shell/execution-backend/agent.js +205 -24
  38. package/dist/shell/execution-backend/container.js +44 -10
  39. package/dist/shell/execution-backend/local.js +47 -10
  40. package/dist/shell/fs-atomic.js +93 -12
  41. package/dist/shell/ledger-cli.js +9 -2
  42. package/dist/shell/multi-agent-cli.js +5 -1
  43. package/dist/shell/onramp.js +48 -5
  44. package/dist/shell/pipeline-cli.js +20 -2
  45. package/dist/shell/pipeline.js +2 -1
  46. package/dist/shell/reclamation-io.js +76 -7
  47. package/dist/shell/registry-cli.js +4 -0
  48. package/dist/shell/run-export.js +52 -4
  49. package/dist/shell/run-registry-io.js +1 -0
  50. package/dist/shell/run-store.js +156 -0
  51. package/dist/shell/scheduler-io.js +101 -10
  52. package/dist/shell/telemetry-ledger-io.js +36 -24
  53. package/dist/shell/trust-audit.js +104 -10
  54. package/dist/shell/workbench-host.js +121 -10
  55. package/dist/shell/workbench.js +79 -5
  56. package/dist/shell/worker-isolation.js +1 -1
  57. package/dist/shell/workflow-app-loader.js +67 -1
  58. package/dist/wiring/capability-table/basics.js +7 -2
  59. package/dist/wiring/capability-table/exec-backend.js +42 -24
  60. package/dist/wiring/capability-table/multi-agent.js +69 -69
  61. package/dist/wiring/capability-table/pipeline.js +60 -28
  62. package/dist/wiring/capability-table/registry-core.js +29 -6
  63. package/dist/wiring/capability-table/reporting.js +45 -39
  64. package/dist/wiring/capability-table/scheduling-registry.js +66 -60
  65. package/dist/wiring/capability-table/state.js +34 -34
  66. package/dist/wiring/capability-table/trust-ledger.js +66 -24
  67. package/dist/wiring/capability-table/workflow-apps.js +15 -15
  68. package/docs/agent-delegation-drive.7.md +17 -0
  69. package/docs/cli-mcp-parity.7.md +43 -13
  70. package/docs/contract-migration-tooling.7.md +4 -0
  71. package/docs/control-plane-scheduling.7.md +38 -0
  72. package/docs/cross-agent-ledger.7.md +20 -8
  73. package/docs/durable-state-and-locking.7.md +4 -0
  74. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  75. package/docs/execution-backends.7.md +4 -0
  76. package/docs/fix.7.md +4 -4
  77. package/docs/getting-started.md +40 -32
  78. package/docs/index.md +17 -0
  79. package/docs/launch/demo.tape +4 -3
  80. package/docs/mcp-app-surface.7.md +6 -0
  81. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  82. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  83. package/docs/multi-agent-operator-ux.7.md +4 -0
  84. package/docs/node-snapshot-diff-replay.7.md +4 -0
  85. package/docs/observability-cost-accounting.7.md +4 -0
  86. package/docs/project-index.md +43 -9
  87. package/docs/real-execution-backends.7.md +4 -0
  88. package/docs/release-and-migration.7.md +4 -0
  89. package/docs/release-tooling.7.md +22 -0
  90. package/docs/routine.7.md +22 -0
  91. package/docs/run-registry-control-plane.7.md +4 -0
  92. package/docs/run-retention-reclamation.7.md +28 -3
  93. package/docs/state-explosion-management.7.md +4 -0
  94. package/docs/team-collaboration.7.md +4 -0
  95. package/docs/trust-audit-anchor.7.md +4 -0
  96. package/docs/web-desktop-workbench.7.md +26 -2
  97. package/docs/workflow-app-framework.7.md +31 -0
  98. package/manifest/plugin.manifest.json +1 -1
  99. package/manifest/source-context-profiles.json +4 -3
  100. package/package.json +5 -2
  101. package/scripts/agents/agent-adapter-core.js +26 -0
  102. package/scripts/agents/claude-p-agent.js +4 -1
  103. package/scripts/agents/codex-agent.js +4 -0
  104. package/scripts/agents/cw-attest-wrap.js +1 -1
  105. package/scripts/agents/gemini-agent.js +4 -0
  106. package/scripts/agents/opencode-agent.js +5 -0
  107. package/scripts/block-unapproved-tag.js +160 -0
  108. package/scripts/bump-version.js +27 -10
  109. package/scripts/canonical-apps.js +4 -4
  110. package/scripts/children/batch-delegate-child.js +72 -4
  111. package/scripts/children/http-batch-delegate-child.js +132 -0
  112. package/scripts/children/http-delegate-child.js +8 -0
  113. package/scripts/dogfood-release.js +2 -2
  114. package/scripts/fake-date-for-reproduction.js +1 -1
  115. package/scripts/golden-path.js +4 -4
  116. package/scripts/lang-policy-check.js +82 -0
  117. package/scripts/purity-baseline.json +0 -30
  118. package/scripts/purity-gate.js +16 -3
  119. package/scripts/release-check.js +2 -1
  120. package/scripts/release-flow.js +264 -41
  121. package/scripts/release-gate.js +184 -0
  122. package/scripts/release-oneclick.js +438 -0
  123. package/scripts/release-tags.js +47 -0
  124. package/scripts/verdict-keygen.js +2 -2
  125. package/scripts/verify-bump-reproduction.js +193 -0
  126. package/scripts/verify-verdict-signature.js +1 -1
  127. package/scripts/version-sync-check.js +39 -22
  128. package/ui/workbench/app.css +41 -9
  129. package/ui/workbench/app.js +160 -27
  130. package/ui/workbench/index.html +2 -2
  131. package/docs/agent-framework.md +0 -177
  132. package/docs/designs/handoff-ledger.md +0 -145
  133. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  134. package/docs/launch/launch-kit.md +0 -195
  135. package/docs/launch/pre-launch-checklist.md +0 -53
  136. package/docs/readme-v0.1.87-full.md +0 -301
  137. package/docs/routines.md +0 -101
  138. package/docs/scheduled-tasks.md +0 -80
  139. package/scripts/agents/claude-p-agent.sh +0 -9
  140. package/scripts/block-unapproved-tag.sh +0 -60
  141. package/scripts/release-gate.sh +0 -94
  142. package/scripts/verify-bump-reproduction.sh +0 -148
  143. package/scripts/verify-container-selfref.js +0 -64
@@ -36,6 +36,7 @@ exports.listComments = listComments;
36
36
  exports.distinctTargets = distinctTargets;
37
37
  exports.formatReviewStatus = formatReviewStatus;
38
38
  exports.formatCommentList = formatCommentList;
39
+ const cli_args_1 = require("../util/cli-args");
39
40
  const collate_1 = require("../util/collate");
40
41
  exports.COLLABORATION_SCHEMA_VERSION = 1;
41
42
  /** The single, honest stand-in for an absent identity. */
@@ -166,10 +167,10 @@ function buildHandoff(input, handoffCount, runId, now, auditEventId) {
166
167
  auditEventIds: [auditEventId],
167
168
  });
168
169
  }
169
- /** Boolean-coerce a defined tri-state flag; leave `undefined` alone so the
170
+ /** Parse a defined tri-state flag; leave `undefined` alone so the
170
171
  * caller's `?? existing ?? default` chain still governs an unset flag. */
171
- function coerceFlag(value) {
172
- return value === undefined ? undefined : Boolean(value);
172
+ function coerceFlag(value, label) {
173
+ return (0, cli_args_1.parseBoolFlag)(value, label);
173
174
  }
174
175
  function toNumber(value, fallback) {
175
176
  if (value === undefined || value === null || value === "" || value === true)
@@ -201,8 +202,8 @@ function buildReviewPolicy(input, existing, now) {
201
202
  id: existing?.id || createCollabId("policy", 0),
202
203
  requiredApprovals: Math.max(0, Math.floor(toNumber(input.requiredApprovals, existing?.requiredApprovals ?? 0))),
203
204
  authorizedRoles: toStringList(input.authorizedRoles, existing?.authorizedRoles ?? ["*"]),
204
- allowSelfApproval: coerceFlag(input.allowSelfApproval) ?? existing?.allowSelfApproval ?? false,
205
- requireAttestedActor: coerceFlag(input.requireAttestedActor) ?? existing?.requireAttestedActor ?? false,
205
+ allowSelfApproval: coerceFlag(input.allowSelfApproval, "allowSelfApproval") ?? existing?.allowSelfApproval ?? false,
206
+ requireAttestedActor: coerceFlag(input.requireAttestedActor, "requireAttestedActor") ?? existing?.requireAttestedActor ?? false,
206
207
  appliesTo: toTargetKindList(input.appliesTo, existing?.appliesTo ?? ["commit"]),
207
208
  updatedAt: now,
208
209
  };
@@ -235,6 +236,17 @@ function roleAuthorized(roleId, authorizedRoles) {
235
236
  return false;
236
237
  return authorizedRoles.includes(roleId);
237
238
  }
239
+ /** Whether `record` itself counts for anything at all (same eligibility a
240
+ * record needs to be counted as an approval or a veto in the main loop
241
+ * below). A record that fails this can't void another record either —
242
+ * otherwise a disqualified self-approval could still cancel someone
243
+ * else's veto via `supersedes`. */
244
+ function recordCountsAtAll(record, policy, selfIds) {
245
+ const reason = disqualify(record, policy, selfIds);
246
+ if (record.decision === "reject")
247
+ return !reason || reason === "self-approval";
248
+ return !reason;
249
+ }
238
250
  function deriveStatus(gated, required, recorded, rejectionCount, disqualified) {
239
251
  if (!gated)
240
252
  return "approved";
@@ -284,7 +296,24 @@ function deriveReviewState(runId, approvalsAll, target, options = {}) {
284
296
  const related = (options.relatedTargets && options.relatedTargets.length ? options.relatedTargets : [normalized]).map(normalizeTarget);
285
297
  const selfIds = new Set((options.selfActorIds || []).filter(Boolean));
286
298
  const approvals = approvalsAll.filter((record) => matchesAnyTarget(record.target, related));
287
- const supersededIds = new Set(approvals.map((record) => record.supersedes).filter((id) => Boolean(id)));
299
+ // A record's `supersedes` only takes effect when the target is that SAME
300
+ // actor's own prior record (an actor may only supersede their own
301
+ // record, never someone else's) and the superseding record itself is
302
+ // eligible to count for something. Otherwise a disqualified record (e.g.
303
+ // a self-approval a policy forbids) could still void another actor's
304
+ // veto just by naming it in `supersedes`.
305
+ const byId = new Map(approvals.map((record) => [record.id, record]));
306
+ const supersededIds = new Set();
307
+ for (const record of approvals) {
308
+ if (!record.supersedes)
309
+ continue;
310
+ const target = byId.get(record.supersedes);
311
+ if (!target || target.actor.id !== record.actor.id)
312
+ continue;
313
+ if (!recordCountsAtAll(record, policy, selfIds))
314
+ continue;
315
+ supersededIds.add(record.supersedes);
316
+ }
288
317
  const gated = Boolean(policy && policy.requiredApprovals > 0 && policy.appliesTo.includes(normalized.kind));
289
318
  const required = gated ? policy.requiredApprovals : 0;
290
319
  const counted = [];
@@ -651,6 +651,13 @@ function collectAgentFanin(run, input, now) {
651
651
  ...missingRoleIds.map((roleId) => `required role ${roleId} has no membership`),
652
652
  ...missingMembershipIds.map((membershipId) => `membership ${membershipId} has not reported required evidence`),
653
653
  ];
654
+ // An aggregation gate that observed zero members must not report itself
655
+ // ready: with no required roles and no memberships every per-item check
656
+ // above is vacuously empty, which used to yield verifierReady:true for a
657
+ // fan-in over nothing (fail-open).
658
+ if (!requiredRoleIds.length && !scopedMemberships.length) {
659
+ blockedReasons.push("fan-in has no memberships and no required roles — nothing to aggregate");
660
+ }
654
661
  const requiredMemberships = scopedMemberships.filter((membership) => requiredRoleIds.includes(membership.roleId));
655
662
  const blackboardId = input.blackboardId || group.blackboardId || multiAgentRun.blackboardId;
656
663
  const requiresBlackboardEvidence = Boolean(blackboardId || requiredMemberships.some((membership) => membership.blackboardId));
@@ -168,9 +168,15 @@ function evaluatePolicy(policy, operation, topicId, evidenceRefs) {
168
168
  }
169
169
  const missing = missingEvidence(policy, operation, evidenceRefs);
170
170
  if (missing.length)
171
- return `operation ${operation} requires evidence refs: ${missing.join(", ")}`;
171
+ return `operation ${operation} requires evidence refs (at least one; expected kinds: ${missing.join(", ")})`;
172
172
  return undefined;
173
173
  }
174
+ /** The requiredEvidenceFor entries are prose descriptions ("judge
175
+ * messages", "score evidence"), not machine-matchable ids — so this can
176
+ * only check that SOME evidence ref was supplied, not that each named
177
+ * kind is present. The denial message above says exactly that ("at least
178
+ * one; expected kinds: ...") rather than promising a per-item match this
179
+ * check cannot do. */
174
180
  function missingEvidence(policy, operation, evidenceRefs) {
175
181
  if (!policy)
176
182
  return [];
@@ -71,6 +71,13 @@ function createDefaultPipelineContract() {
71
71
  },
72
72
  ],
73
73
  artifactPolicy: { root: ".cw/runs/<run-id>", requireReadablePaths: true },
74
+ // highPriorityRequiresEvidence is carried for byte-compat with the old
75
+ // build's default contract (pinned by SPEC/pipeline-run.md) but is NOT
76
+ // enforced anywhere — no gate reads it, and core has no task-priority
77
+ // concept to key it on. The enforced contract-wide flag is
78
+ // `requireEvidence` (state-node.ts assertRequiredEvidence, runner.ts
79
+ // evidenceSatisfied). The per-stage `requiredEvidence` lists are what
80
+ // actually gate evidence in the default contract.
74
81
  evidencePolicy: { highPriorityRequiresEvidence: true },
75
82
  failurePolicy: { preserveFailureNodes: true, retryableByDefault: false },
76
83
  commitPolicy: { requiresVerifierGate: true, acceptedVerifierStatuses: ["verified"] },
@@ -85,10 +85,10 @@ function sourceFor(classification) {
85
85
  return "contract";
86
86
  return "manual";
87
87
  }
88
- /** Feedback dedup key: joined with `` (runId, code, message, nodeId,
88
+ /** Feedback dedup key: joined with the ASCII unit separator "\u001f" (runId, code, message, nodeId,
89
89
  * stageId, contractId, path). */
90
90
  function feedbackKey(value) {
91
- return [value.runId || "", value.code || "", value.message || "", value.nodeId || "", value.stageId || "", value.contractId || "", value.path || ""].join("");
91
+ return [value.runId || "", value.code || "", value.message || "", value.nodeId || "", value.stageId || "", value.contractId || "", value.path || ""].join("\u001f");
92
92
  }
93
93
  function compactMetadata(metadata) {
94
94
  const compacted = {};
@@ -116,8 +116,13 @@ function extractEvidenceContent(locator, baseDirs, ops, readFile) {
116
116
  const shape = classify(locator);
117
117
  if (shape.kind !== "file" || !shape.pathPart)
118
118
  return undefined;
119
- const lineMatch = locator.match(/:(\d+)$/);
119
+ // Match the same shapes LINE_SUFFIX_RE (classify) accepts: ":<n>" and
120
+ // ":<n>-<m>". A range that only matched classify used to fall through to
121
+ // the head-of-file slice below — returning bytes that had nothing to do
122
+ // with the cited lines.
123
+ const lineMatch = locator.match(/:(\d+)(?:-(\d+))?$/);
120
124
  const lineNum = lineMatch ? Number(lineMatch[1]) : undefined;
125
+ const lineEnd = lineMatch && lineMatch[2] ? Number(lineMatch[2]) : undefined;
121
126
  const candidatePath = ops.isAbsolute(shape.pathPart)
122
127
  ? shape.pathPart
123
128
  : baseDirs.filter(Boolean).map((base) => ops.resolve(base, shape.pathPart)).find((p) => ops.exists(p));
@@ -128,6 +133,13 @@ function extractEvidenceContent(locator, baseDirs, ops, readFile) {
128
133
  return undefined;
129
134
  if (lineNum && lineNum > 0) {
130
135
  const lines = content.split("\n");
136
+ if (lineEnd !== undefined) {
137
+ // Range: return exactly the cited lines; a backwards or out-of-range
138
+ // span returns undefined, never fabricated content.
139
+ if (lineEnd < lineNum || lineNum > lines.length)
140
+ return undefined;
141
+ return lines.slice(lineNum - 1, Math.min(lineEnd, lines.length)).join("\n") || undefined;
142
+ }
131
143
  return lines[lineNum - 1] || undefined;
132
144
  }
133
145
  return content.slice(0, 200);
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ // core/util/cli-args.ts — shared CLI argv / MCP tool-call arg-coercion
3
+ // helpers: required(), optionalArg(), wantsJson().
4
+ //
5
+ // Pure. No fs, no child_process, no net, no process.env, no Date.now(), no
6
+ // Math.random().
7
+ //
8
+ // Moved out of cli/io.ts (architecture-review P2): a cli/-layer file may
9
+ // not be imported by wiring/ (see scripts/purity-gate.js's layer rule),
10
+ // but every wiring/capability-table/*.ts slice called these three pure
11
+ // functions directly to coerce its own handler's args. Shared by CLI argv
12
+ // (cli/dispatch.ts, the wiring slices) AND MCP tool-call args (the same
13
+ // wiring slices' MCP handler bodies) — see core/util/numeric-flag.ts for
14
+ // the same CLI/MCP-shared framing.
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.required = required;
17
+ exports.optionalArg = optionalArg;
18
+ exports.wantsJson = wantsJson;
19
+ exports.parseBoolFlag = parseBoolFlag;
20
+ /** Require a positional/option value or fail with a copy-pasteable recovery tip. */
21
+ function required(value, label) {
22
+ if (!value) {
23
+ throw new Error(`Missing ${label}.\n Tip: find run ids with "cw run list" or create one with "cw quickstart"`);
24
+ }
25
+ return value;
26
+ }
27
+ /** Normalize an optional CLI arg to a trimmed non-empty string, else undefined. */
28
+ function optionalArg(value) {
29
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
30
+ }
31
+ /** True when the caller asked for JSON output (`--json` or `--format json`). */
32
+ function wantsJson(options) {
33
+ return Boolean(options.json || options.format === "json");
34
+ }
35
+ /** Parse a boolean flag value that may arrive as a real boolean (bare
36
+ * `--flag`) or as a CLI/MCP string (`--flag false`). `Boolean("false")` is
37
+ * `true` in JS, so a plain Boolean() coercion silently ENABLES a flag the
38
+ * operator asked to turn off — on a gate-policy flag like
39
+ * `--allow-self-approval false` that is a fail-open. Recognized strings
40
+ * (case-insensitive, trimmed): true/1/yes/on and false/0/no/off/"".
41
+ * Anything else throws — fail closed, never guess. `undefined` and JSON
42
+ * `null` (an MCP caller's "unset") stay `undefined` so a caller's
43
+ * `?? existing ?? default` chain still governs an unset flag. */
44
+ function parseBoolFlag(value, label) {
45
+ if (value === undefined || value === null)
46
+ return undefined;
47
+ if (typeof value === "boolean")
48
+ return value;
49
+ const text = String(value).trim().toLowerCase();
50
+ if (text === "true" || text === "1" || text === "yes" || text === "on")
51
+ return true;
52
+ if (text === "false" || text === "0" || text === "no" || text === "off" || text === "")
53
+ return false;
54
+ throw new Error(`Invalid boolean value for ${label}: "${String(value)}" (use true or false)`);
55
+ }
@@ -7,7 +7,7 @@ exports.MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION = exports.LEGACY_RUN_STATE_SCHEMA
7
7
  // `cw version` output does not change bytes just because the code under it
8
8
  // was rebuilt. See SPEC/cli-surface.md "Exact outputs > Version" and
9
9
  // conformance/cases/version-basic.case.js (regex `/^\d+\.\d+\.\d+\n$/`).
10
- exports.CURRENT_COOL_WORKFLOW_VERSION = "0.2.3";
10
+ exports.CURRENT_COOL_WORKFLOW_VERSION = "0.2.5";
11
11
  // State-kernel schema version constants (SPEC/state-core.md "Version
12
12
  // constants"). Pinned to the old build's src/version.ts byte-for-byte.
13
13
  exports.WORKFLOW_APP_SCHEMA_VERSION = 1;
@@ -81,6 +81,22 @@ function requiredToolArguments(name, value) {
81
81
  }
82
82
  return args;
83
83
  }
84
+ /** MCP clients send JSON, so an argument the CLI would receive as a string
85
+ * can arrive as a number or boolean (e.g. `{"runId": 5}`). Every capability
86
+ * handler coerces its args through optionalString/numberArg/boolArg, all of
87
+ * which expect the CLI's string form — optionalString silently DROPS a
88
+ * number, so `{"runId": 5}` used to look like "no runId given" and returned
89
+ * a success-shaped WRONG answer (the "create a run first" payload). Coerce
90
+ * top-level scalars to their string form so an MCP arg behaves exactly like
91
+ * the CLI's argv, which is always strings. Arrays/objects (e.g.
92
+ * authorizedRoles) pass through untouched; null/undefined stay absent. */
93
+ function coerceScalarArgs(args) {
94
+ const out = {};
95
+ for (const [key, value] of Object.entries(args)) {
96
+ out[key] = typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" ? String(value) : value;
97
+ }
98
+ return out;
99
+ }
84
100
  /** `callTool(name, args)` — resolves `cwd` (SPEC/mcp.md invariant 7),
85
101
  * checks required args, then calls the row's `mcp.handler`. Throws
86
102
  * `Unknown tool: <name>` for a name with no row (src/mcp/tool-call.ts:513
@@ -90,12 +106,16 @@ function callTool(name, rawArgs) {
90
106
  if (!row || !row.mcp) {
91
107
  throw new Error(`Unknown tool: ${name}`);
92
108
  }
93
- const args = requiredToolArguments(name, rawArgs);
109
+ const args = coerceScalarArgs(requiredToolArguments(name, rawArgs));
94
110
  const resolvedArgs = { ...args };
95
111
  const cwdInput = args.cwd;
96
112
  if (typeof cwdInput === "string" && cwdInput.length > 0) {
97
113
  const resolved = path.resolve(cwdInput);
98
- if (!fs.statSync(resolved).isDirectory()) {
114
+ // throwIfNoEntry:false so a MISSING cwd yields the same crafted
115
+ // "not a directory" message as a non-dir path — not a raw ENOENT that
116
+ // gives the recovery-hint matcher nothing to key on.
117
+ const stat = fs.statSync(resolved, { throwIfNoEntry: false });
118
+ if (!stat || !stat.isDirectory()) {
99
119
  throw new Error(`MCP cwd is not a directory: ${resolved}`);
100
120
  }
101
121
  resolvedArgs.cwd = resolved;
@@ -7,8 +7,11 @@
7
7
  // "Invariants and error behavior" #9 / "Edge cases" sections:
8
8
  // - transport: stdin/stdout, ONE JSON object per line, no
9
9
  // Content-Length headers (mcp.md:13);
10
- // - `initialize` -> protocolVersion/capabilities/serverInfo, ignoring
11
- // params (mcp.md:19,263-269);
10
+ // - `initialize` -> protocolVersion/capabilities/serverInfo (mcp.md:
11
+ // 19,263-269); the reply's protocolVersion echoes the client's
12
+ // requested one when it is in SUPPORTED_PROTOCOL_VERSIONS below, and
13
+ // falls back to the newest supported entry otherwise (with today's
14
+ // one-entry list this is byte-identical to the old fixed reply);
12
15
  // - `tools/list` -> { tools: [...] } from core/capability-table.ts via
13
16
  // mcp/dispatch.ts, ignoring params (mcp.md:20,271-277);
14
17
  // - `tools/call` -> { content: [{ type: "text", text: <2-space pretty
@@ -48,12 +51,40 @@
48
51
  // blow V8's per-string limit) becomes a small overflow notice instead
49
52
  // of a multi-hundred-MB payload — every result under the cap is
50
53
  // untouched, so this never affects the parity gate's own fixtures.
54
+ // - `tools/call` FAILURE result shape (post-v0.2.4 robustness hardening,
55
+ // not in the original mcp.md): an unknown tool name, a missing
56
+ // required tool argument, or the tool's own handler throwing is a
57
+ // normal RESULT, not a bare -32000 JSON-RPC protocol error — many MCP
58
+ // hosts never surface a protocol error back to the calling model, so
59
+ // it could not read the message or try again. The result is shaped
60
+ // { content: [{ type: "text", text: <message, plus a "Try: <hint>"
61
+ // line when core/format/recovery-hint.ts's recoveryHint finds one>
62
+ // }], isError: true }, same `resultMessage` helper as the success
63
+ // path. The envelope-level "missing field: name" check (right above
64
+ // this bullet) is unchanged — it still answers -32000.
51
65
  Object.defineProperty(exports, "__esModule", { value: true });
66
+ exports.negotiateProtocolVersion = negotiateProtocolVersion;
52
67
  exports.startServer = startServer;
53
68
  const version_1 = require("../core/version");
54
69
  const safe_json_1 = require("../core/format/safe-json");
70
+ const recovery_hint_1 = require("../core/format/recovery-hint");
55
71
  const dispatch_1 = require("./dispatch");
56
72
  const MAX_LINE_BYTES = 16 * 1024 * 1024;
73
+ /** Protocol versions this server can speak, oldest first. `initialize`
74
+ * echoes the client's `params.protocolVersion` when it is in this list,
75
+ * and answers with the newest entry (the last one) otherwise — the
76
+ * standard MCP version-negotiation shape. With one entry this is
77
+ * behavior-identical to the old hard-coded reply (mechanism first; a
78
+ * second version is a one-line append here). */
79
+ const SUPPORTED_PROTOCOL_VERSIONS = ["2024-11-05"];
80
+ /** Picks the `initialize` reply's protocolVersion from the client's
81
+ * requested one (see SUPPORTED_PROTOCOL_VERSIONS). Exported for the
82
+ * protocol-version smoke; pure. */
83
+ function negotiateProtocolVersion(requested) {
84
+ if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested))
85
+ return requested;
86
+ return SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1];
87
+ }
57
88
  // Tools whose result carries free-form text ORIGINALLY AUTHORED by a
58
89
  // worker/agent/operator/external caller — never computed or validated by
59
90
  // this codebase itself (a blackboard message body, a review comment, a
@@ -117,6 +148,11 @@ function resultMessage(id, result) {
117
148
  if (id !== undefined)
118
149
  message.id = id;
119
150
  return message;
151
+ // NOTE: a notification-shaped request (initialize/tools/list/tools/call
152
+ // with no `id`) still gets a reply here, just without an `id` key — a
153
+ // deliberate deviation from strict JSON-RPC (which says a notification
154
+ // MUST NOT be answered) pinned by SPEC/mcp.md's edge-cases. Revisit only
155
+ // as a deliberate spec change, not as a drive-by fix.
120
156
  }
121
157
  /** Handles one already-parsed JSON-RPC request object. May write zero or
122
158
  * one reply line to stdout. `await`ing callTool's result is a no-op for
@@ -142,8 +178,9 @@ async function handleRequest(message) {
142
178
  try {
143
179
  switch (message.method) {
144
180
  case "initialize": {
181
+ const params = (message.params ?? {});
145
182
  writeMessage(resultMessage(id, {
146
- protocolVersion: "2024-11-05",
183
+ protocolVersion: negotiateProtocolVersion(params.protocolVersion),
147
184
  capabilities: { tools: {} },
148
185
  serverInfo: { name: "cool-workflow", version: version_1.CURRENT_COOL_WORKFLOW_VERSION },
149
186
  }));
@@ -153,6 +190,17 @@ async function handleRequest(message) {
153
190
  writeMessage(resultMessage(id, { tools: (0, dispatch_1.toolDefinitions)() }));
154
191
  return;
155
192
  }
193
+ case "ping": {
194
+ // MCP (2024-11-05, the version negotiateProtocolVersion advertises)
195
+ // makes ping mandatory: reply promptly with an EMPTY result. Hosts
196
+ // ping for keep-alive and may drop a connection that never answers.
197
+ // Answered here in the fast protocol path (not the serial tool
198
+ // queue), so a ping during a long cw_run drive still gets a reply.
199
+ // A ping notification (no id) gets no reply, per JSON-RPC.
200
+ if (hasId)
201
+ writeMessage(resultMessage(id, {}));
202
+ return;
203
+ }
156
204
  case "tools/call": {
157
205
  const params = message.params ?? {};
158
206
  const name = params.name;
@@ -160,12 +208,32 @@ async function handleRequest(message) {
160
208
  throw new Error("MCP tools/call missing required field: name");
161
209
  }
162
210
  const args = params.arguments;
163
- const coreResult = await (0, dispatch_1.callTool)(name, args ?? {});
164
- const content = [{ type: "text", text: (0, safe_json_1.safeJsonStringify)(coreResult) }];
165
- const advisory = untrustedContentAdvisory(name);
166
- if (advisory)
167
- content.push({ type: "text", text: advisory });
168
- writeMessage(resultMessage(id, { content }));
211
+ // A failure from HERE down (an unknown tool name, a missing
212
+ // required tool argument, or the tool's own handler throwing) is a
213
+ // normal call outcome, not a broken request — many MCP hosts never
214
+ // surface a bare JSON-RPC protocol error (-32000) back to the
215
+ // calling model at all, so it could not see the message and try
216
+ // again. Answer with a normal RESULT instead, shaped isError:
217
+ // true, so the model always sees the message (and, when one
218
+ // applies, a "Try: <hint>" recovery line) and can self-correct.
219
+ // The envelope-level "missing field: name" check above stays OUT
220
+ // of this inner try/catch — that one is a malformed request, not
221
+ // a tool-call outcome, and keeps going through the outer
222
+ // try/catch as a -32000 error, unchanged.
223
+ try {
224
+ const coreResult = await (0, dispatch_1.callTool)(name, args ?? {});
225
+ const content = [{ type: "text", text: (0, safe_json_1.safeJsonStringify)(coreResult) }];
226
+ const advisory = untrustedContentAdvisory(name);
227
+ if (advisory)
228
+ content.push({ type: "text", text: advisory });
229
+ writeMessage(resultMessage(id, { content }));
230
+ }
231
+ catch (error) {
232
+ const text = error instanceof Error ? error.message : String(error);
233
+ const hint = (0, recovery_hint_1.recoveryHint)(text);
234
+ const errorText = hint ? `${text}\nTry: ${hint}` : text;
235
+ writeMessage(resultMessage(id, { content: [{ type: "text", text: errorText }], isError: true }));
236
+ }
169
237
  return;
170
238
  }
171
239
  default: {
@@ -205,14 +273,52 @@ async function handleLine(line) {
205
273
  * tool but `cw_run` is still a plain synchronous handler), so this adds
206
274
  * no real delay, but it keeps replies in the same order the requests
207
275
  * arrived even now that one tool (`cw_run`'s live drive loop) can take
208
- * many real event-loop turns to answer. */
276
+ * many real event-loop turns to answer. The one exception is `ping`,
277
+ * answered in handleRequest's fast path before any tool work, so a
278
+ * keep-alive ping still gets a reply while a long drive holds the queue. */
209
279
  function startServer() {
210
280
  process.stdin.setEncoding("utf8");
211
281
  let buffer = "";
282
+ // True while the REST of an oversize line is still streaming in: emit one
283
+ // -32700 for the whole line and skip everything up to its terminating
284
+ // newline, instead of dropping the head and then re-parsing the tail as a
285
+ // fresh (also-failing) line — which produced a second, spurious parse
286
+ // error per 16MB crossed.
287
+ let discarding = false;
212
288
  let queue = Promise.resolve();
289
+ // Chain each task onto `queue` WITH a per-task `.catch`. The `.catch` is
290
+ // load-bearing, not decoration: a task here can reject — a raw
291
+ // `writeMessage` (`process.stdout.write`) that throws mid-reply because the
292
+ // client closed the pipe, or any other throw out of handleLine — and
293
+ // without a handler that one rejection would leave `queue` REJECTED for
294
+ // good, so every later `queue.then(...)` is skipped and the server goes
295
+ // silent and answers no more requests (finding: one bad write poisons the
296
+ // queue). The `.catch` swallows the single failure onto stderr
297
+ // (diagnostics, never stdout data) and hands back a RESOLVED promise, so
298
+ // the next request is still served. Order is still kept: the next task
299
+ // only runs after this one settles.
300
+ const enqueue = (task) => {
301
+ queue = queue.then(task).catch((error) => {
302
+ const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
303
+ process.stderr.write(`cool-workflow mcp: a request failed and its reply was dropped; still serving: ${detail}\n`);
304
+ });
305
+ };
213
306
  process.stdin.on("data", (chunk) => {
214
307
  buffer += chunk;
215
308
  for (;;) {
309
+ if (discarding) {
310
+ // Skip the rest of an oversize line already reported. Until its
311
+ // terminating newline arrives, throw away what we have (so a huge
312
+ // line can't grow the buffer unboundedly); once found, resume
313
+ // normal parsing from the next line with no second error.
314
+ const nl = buffer.indexOf("\n");
315
+ if (nl === -1) {
316
+ buffer = "";
317
+ break;
318
+ }
319
+ buffer = buffer.slice(nl + 1);
320
+ discarding = false;
321
+ }
216
322
  const newlineIndex = buffer.indexOf("\n");
217
323
  if (newlineIndex === -1)
218
324
  break;
@@ -220,11 +326,16 @@ function startServer() {
220
326
  buffer = buffer.slice(newlineIndex + 1);
221
327
  const trimmed = line.trim();
222
328
  if (trimmed)
223
- queue = queue.then(() => handleLine(trimmed));
329
+ enqueue(() => handleLine(trimmed));
224
330
  }
225
- if (buffer.length > MAX_LINE_BYTES) {
331
+ // No newline yet and the pending (unterminated) line already exceeds the
332
+ // cap: report ONCE, drop the head, and discard the rest of this line
333
+ // until its newline arrives (guarded by `discarding` so a >32MB line
334
+ // yields a single -32700, not one per 16MB crossed).
335
+ if (!discarding && buffer.length > MAX_LINE_BYTES) {
226
336
  buffer = "";
227
- queue = queue.then(() => {
337
+ discarding = true;
338
+ enqueue(() => {
228
339
  writeMessage(errorMessage(null, -32700, `Parse error: request line exceeds ${MAX_LINE_BYTES} bytes`));
229
340
  });
230
341
  }
@@ -6,4 +6,24 @@
6
6
  // real MCP client launches it directly with `node`).
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  const server_1 = require("./mcp/server");
9
+ /** Broken-pipe guard for the MCP stdio server, the same idea as
10
+ * cli/entry.ts's `main()`. When an MCP client closes the read end of our
11
+ * stdout part-way through a reply, the raw write gives an async 'error'
12
+ * event that no promise `.catch` can see; with no listener Node comes down
13
+ * hard with a `write EPIPE` stack and exit 1. One process-level listener
14
+ * turns that into a quiet exit 0 — the reader has gone, there is nothing
15
+ * left to say. Any other stream error is thrown again, same as before.
16
+ *
17
+ * This is a small COPY of cli/entry.ts's helper, not an import: the purity
18
+ * gate (scripts/purity-gate.js) forbids an mcp/ file from importing cli/,
19
+ * and pulling in the whole CLI entry graph for five lines would be worse. */
20
+ function exitQuietOnEpipe(stream) {
21
+ stream.on("error", (error) => {
22
+ if (error && error.code === "EPIPE")
23
+ process.exit(0);
24
+ throw error;
25
+ });
26
+ }
27
+ exitQuietOnEpipe(process.stdout);
28
+ exitQuietOnEpipe(process.stderr);
9
29
  (0, server_1.startServer)();
@@ -199,7 +199,13 @@ function commitState(run, input) {
199
199
  // NOT prematurely move the run off "interpret".
200
200
  if (gate.verifierGated)
201
201
  run.loopStage = "checkpoint";
202
- (0, fs_atomic_1.writeJson)(snapshotPath, { commit, run });
202
+ // Only store the commit's own (small, bounded) record. The whole run
203
+ // (all tasks, nodes, workers, feedback, and run.commits itself) used to
204
+ // be embedded here too, but no reader anywhere ever parses a `run` key
205
+ // back out of a commits/<id>.json file — see docs/run-retention-reclamation.7.md.
206
+ // Embedding the growing run made total commit-dir bytes grow like N^2
207
+ // in the number of commits.
208
+ (0, fs_atomic_1.writeJson)(snapshotPath, { commit });
203
209
  run.commits.push(commit);
204
210
  return commit;
205
211
  }
@@ -214,7 +220,7 @@ function recordCommitNode(run, commit, options, gate) {
214
220
  artifacts: [{ id: "snapshot", kind: "json", path: commit.snapshotPath }],
215
221
  evidence: commit.evidence || verifierNode.evidence,
216
222
  metadata: { ...(options.metadata || {}), reason: options.reason, commitId: commit.id, verifierGated: true, checkpoint: false, verifierNodeId: verifierNode.id, candidateId: gate.candidateId, selectionId: gate.selectionId, selectionNodeId: gate.selectionNodeId },
217
- }, { persist: false, persistNode: node_store_1.writeRunNode });
223
+ }, { persist: false, persistNode: node_store_1.writeRunNode, pathExists: fs.existsSync });
218
224
  if (gate.selectionNodeId && commitResult.outputNodeId)
219
225
  linkAdditionalParent(run, gate.selectionNodeId, commitResult.outputNodeId);
220
226
  return commitResult.outputNodeId;
@@ -158,6 +158,75 @@ function dirtySetsFor(state) {
158
158
  function markBlackboardDirty(state, kind, id) {
159
159
  dirtySetsFor(state)[kind].add(id);
160
160
  }
161
+ // Dirty-id tracking for state.messages, PARALLEL to the 5-kind tracking
162
+ // above (not folded in: messages share ONE ordered log file, not a
163
+ // recordPath-per-id file). The only push site is postBlackboardMessage
164
+ // (`state.messages.push(message)`), so the dirty ids at persist time are
165
+ // always exactly the trailing ids of state.messages since the last
166
+ // persist. Kept off the serialized state for the same reason as
167
+ // blackboardDirtySets: a WeakMap, never a field on BlackboardState.
168
+ const blackboardMessagesDirty = new WeakMap();
169
+ function dirtyMessageIdsFor(state) {
170
+ let ids = blackboardMessagesDirty.get(state);
171
+ if (!ids) {
172
+ ids = new Set();
173
+ blackboardMessagesDirty.set(state, ids);
174
+ }
175
+ return ids;
176
+ }
177
+ function markBlackboardMessageDirty(state, id) {
178
+ dirtyMessageIdsFor(state).add(id);
179
+ }
180
+ // Writes messages.jsonl for a persist call, or skips it entirely when
181
+ // nothing changed (the common case: 7 of the 8 persistBlackboardState call
182
+ // sites never touch state.messages).
183
+ //
184
+ // Fast path (the common case for the ONE call site that does change
185
+ // messages, postBlackboardMessage): the dirty ids are exactly the trailing
186
+ // `k` entries of state.messages (the sole push site always appends). Sort
187
+ // only that small batch, check it does not need to interleave with the
188
+ // already-on-disk tail, and APPEND it — no read, no resort, no rewrite of
189
+ // the earlier messages. This turns posting M messages from O(M^2) written
190
+ // bytes into O(M) total.
191
+ //
192
+ // Fallback (a caller-supplied custom --id ties createdAt with an earlier
193
+ // message and sorts BEFORE it, so a plain append would leave the file out
194
+ // of order): resort the whole array and rewrite the whole file, byte-
195
+ // identical to the code path this replaces.
196
+ //
197
+ // Either way state.messages is left fully sorted after this call, exactly
198
+ // as the old unconditional `.sort()` left it — buildBlackboardGraph (which
199
+ // iterates state.messages in raw array order) sees the same order as
200
+ // before.
201
+ function persistBlackboardMessages(run, state) {
202
+ const dirty = dirtyMessageIdsFor(state);
203
+ if (dirty.size === 0)
204
+ return;
205
+ const file = messagesPath(run);
206
+ const total = state.messages.length;
207
+ const k = dirty.size;
208
+ const priorCount = total - k;
209
+ const batch = priorCount >= 0 ? state.messages.slice(priorCount).sort(cb.compareRecords) : [];
210
+ const batchMatchesDirty = batch.length === dirty.size && batch.every((message) => dirty.has(message.id));
211
+ const priorTail = priorCount > 0 ? state.messages[priorCount - 1] : undefined;
212
+ const canAppend = batchMatchesDirty && (!priorTail || cb.compareRecords(priorTail, batch[0]) <= 0);
213
+ if (canAppend) {
214
+ state.messages.splice(priorCount, k, ...batch);
215
+ const lines = batch.map((message) => `${JSON.stringify(message)}\n`).join("");
216
+ const currentBytes = fs.existsSync(file) ? fs.statSync(file).size : 0;
217
+ // Same torn-tail guard as trust-audit's events.jsonl (shared helper):
218
+ // a crash mid-append can leave the log without its final "\n"; put the
219
+ // new lines on their own clean line rather than merging with a torn
220
+ // tail.
221
+ const leadingNewline = currentBytes > 0 && !(0, fs_atomic_1.logEndsWithNewline)(file, currentBytes) ? "\n" : "";
222
+ (0, fs_atomic_1.durableAppendFileSync)(file, leadingNewline + lines);
223
+ }
224
+ else {
225
+ // Same bytes the old unconditional code path always wrote.
226
+ (0, fs_atomic_1.writeTextDurable)(file, state.messages.sort(cb.compareRecords).map((message) => JSON.stringify(message)).join("\n") + (state.messages.length ? "\n" : ""));
227
+ }
228
+ dirty.clear();
229
+ }
161
230
  function linkMultiAgent(run, blackboardId, topicIds, input) {
162
231
  const groupId = input.agentGroupId ?? input.groupId;
163
232
  const roleId = input.agentRoleId ?? input.roleId;
@@ -218,7 +287,9 @@ function persistBlackboardState(run) {
218
287
  decisions: state.decisions.map(cb.indexRow),
219
288
  messages: state.messages.map((message) => ({ id: message.id, blackboardId: message.blackboardId, topicId: message.topicId, createdAt: message.createdAt, status: message.status, author: message.author, evidenceRefs: message.linkedEvidenceRefs, artifactRefIds: message.linkedArtifactRefIds })),
220
289
  });
221
- fs.writeFileSync(messagesPath(run), state.messages.sort(cb.compareRecords).map((message) => JSON.stringify(message)).join("\n") + (state.messages.length ? "\n" : ""), "utf8");
290
+ // messages.jsonl: see persistBlackboardMessages above for the
291
+ // skip-if-unchanged / append-if-safe / full-rewrite-fallback split.
292
+ persistBlackboardMessages(run, state);
222
293
  const dirty = dirtySetsFor(state);
223
294
  for (const id of dirty.topics) {
224
295
  const record = state.topics.find((entry) => entry.id === id);
@@ -308,6 +379,7 @@ function postBlackboardMessage(run, input) {
308
379
  requireArtifactRefs(run, input.artifactRefIds || []);
309
380
  const message = cb.buildMessage(run.id, board, topic, input, id, now(), trust_policy_io_1.hashText, sourceForActorLocal);
310
381
  state.messages.push(message);
382
+ markBlackboardMessageDirty(state, message.id);
311
383
  topic.messageIds = cb.unique([...topic.messageIds, message.id]);
312
384
  board.messageCount = state.messages.filter((entry) => entry.blackboardId === board.id).length;
313
385
  cb.touch(topic, now());