cool-workflow 0.2.4 → 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 (132) 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 +32 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/parseargv.js +7 -1
  18. package/dist/core/capability-data.js +337 -8
  19. package/dist/core/format/help.js +73 -3
  20. package/dist/core/format/recovery-hint.js +32 -0
  21. package/dist/core/multi-agent/collaboration.js +35 -6
  22. package/dist/core/multi-agent/runtime.js +7 -0
  23. package/dist/core/multi-agent/trust-policy.js +7 -1
  24. package/dist/core/pipeline/contract.js +7 -0
  25. package/dist/core/pipeline/error-feedback.js +2 -2
  26. package/dist/core/trust/evidence-grounding.js +13 -1
  27. package/dist/core/util/cli-args.js +22 -0
  28. package/dist/core/version.js +1 -1
  29. package/dist/mcp/dispatch.js +22 -2
  30. package/dist/mcp/server.js +124 -13
  31. package/dist/mcp-server.js +20 -0
  32. package/dist/shell/commit.js +8 -2
  33. package/dist/shell/coordinator-io.js +73 -1
  34. package/dist/shell/drive.js +120 -63
  35. package/dist/shell/error-feedback-io.js +6 -0
  36. package/dist/shell/execution-backend/agent.js +195 -23
  37. package/dist/shell/execution-backend/container.js +44 -10
  38. package/dist/shell/execution-backend/local.js +32 -0
  39. package/dist/shell/fs-atomic.js +93 -12
  40. package/dist/shell/ledger-cli.js +9 -2
  41. package/dist/shell/multi-agent-cli.js +5 -1
  42. package/dist/shell/onramp.js +48 -5
  43. package/dist/shell/pipeline.js +2 -1
  44. package/dist/shell/reclamation-io.js +10 -9
  45. package/dist/shell/run-export.js +52 -4
  46. package/dist/shell/run-store.js +156 -0
  47. package/dist/shell/scheduler-io.js +101 -10
  48. package/dist/shell/telemetry-ledger-io.js +36 -24
  49. package/dist/shell/trust-audit.js +104 -10
  50. package/dist/shell/workbench-host.js +121 -10
  51. package/dist/shell/workbench.js +79 -5
  52. package/dist/shell/worker-isolation.js +1 -1
  53. package/dist/wiring/capability-table/basics.js +5 -0
  54. package/dist/wiring/capability-table/exec-backend.js +40 -22
  55. package/dist/wiring/capability-table/pipeline.js +32 -0
  56. package/dist/wiring/capability-table/registry-core.js +26 -3
  57. package/dist/wiring/capability-table/reporting.js +7 -1
  58. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  59. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  60. package/docs/agent-delegation-drive.7.md +15 -0
  61. package/docs/cli-mcp-parity.7.md +25 -0
  62. package/docs/contract-migration-tooling.7.md +2 -0
  63. package/docs/control-plane-scheduling.7.md +36 -0
  64. package/docs/cross-agent-ledger.7.md +20 -8
  65. package/docs/durable-state-and-locking.7.md +2 -0
  66. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  67. package/docs/execution-backends.7.md +2 -0
  68. package/docs/fix.7.md +4 -4
  69. package/docs/getting-started.md +40 -32
  70. package/docs/index.md +17 -0
  71. package/docs/launch/demo.tape +4 -3
  72. package/docs/mcp-app-surface.7.md +6 -0
  73. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  74. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  75. package/docs/multi-agent-operator-ux.7.md +2 -0
  76. package/docs/node-snapshot-diff-replay.7.md +2 -0
  77. package/docs/observability-cost-accounting.7.md +2 -0
  78. package/docs/project-index.md +39 -9
  79. package/docs/real-execution-backends.7.md +2 -0
  80. package/docs/release-and-migration.7.md +2 -0
  81. package/docs/release-tooling.7.md +2 -0
  82. package/docs/routine.7.md +22 -0
  83. package/docs/run-registry-control-plane.7.md +2 -0
  84. package/docs/run-retention-reclamation.7.md +10 -4
  85. package/docs/state-explosion-management.7.md +2 -0
  86. package/docs/team-collaboration.7.md +2 -0
  87. package/docs/trust-audit-anchor.7.md +2 -0
  88. package/docs/web-desktop-workbench.7.md +24 -2
  89. package/docs/workflow-app-framework.7.md +31 -0
  90. package/manifest/plugin.manifest.json +1 -1
  91. package/manifest/source-context-profiles.json +4 -3
  92. package/package.json +4 -2
  93. package/scripts/agents/agent-adapter-core.js +26 -0
  94. package/scripts/agents/claude-p-agent.js +4 -1
  95. package/scripts/agents/codex-agent.js +4 -0
  96. package/scripts/agents/cw-attest-wrap.js +1 -1
  97. package/scripts/agents/gemini-agent.js +4 -0
  98. package/scripts/agents/opencode-agent.js +5 -0
  99. package/scripts/block-unapproved-tag.js +160 -0
  100. package/scripts/canonical-apps.js +4 -4
  101. package/scripts/children/batch-delegate-child.js +72 -4
  102. package/scripts/children/http-batch-delegate-child.js +132 -0
  103. package/scripts/children/http-delegate-child.js +8 -0
  104. package/scripts/dogfood-release.js +2 -2
  105. package/scripts/fake-date-for-reproduction.js +1 -1
  106. package/scripts/golden-path.js +4 -4
  107. package/scripts/lang-policy-check.js +82 -0
  108. package/scripts/purity-gate.js +16 -3
  109. package/scripts/release-check.js +2 -1
  110. package/scripts/release-flow.js +128 -30
  111. package/scripts/release-gate.js +184 -0
  112. package/scripts/release-oneclick.js +38 -7
  113. package/scripts/release-tags.js +47 -0
  114. package/scripts/verdict-keygen.js +2 -2
  115. package/scripts/verify-bump-reproduction.js +193 -0
  116. package/scripts/verify-verdict-signature.js +1 -1
  117. package/ui/workbench/app.css +41 -9
  118. package/ui/workbench/app.js +160 -27
  119. package/ui/workbench/index.html +2 -2
  120. package/docs/agent-framework.md +0 -177
  121. package/docs/designs/handoff-ledger.md +0 -145
  122. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  123. package/docs/launch/launch-kit.md +0 -195
  124. package/docs/launch/pre-launch-checklist.md +0 -53
  125. package/docs/readme-v0.1.87-full.md +0 -301
  126. package/docs/routines.md +0 -101
  127. package/docs/scheduled-tasks.md +0 -80
  128. package/scripts/agents/claude-p-agent.sh +0 -9
  129. package/scripts/block-unapproved-tag.sh +0 -75
  130. package/scripts/release-gate.sh +0 -94
  131. package/scripts/verify-bump-reproduction.sh +0 -148
  132. package/scripts/verify-container-selfref.js +0 -64
@@ -30,6 +30,7 @@ exports.formatHelp = formatHelp;
30
30
  exports.formatCommandHelp = formatCommandHelp;
31
31
  exports.formatInfo = formatInfo;
32
32
  exports.formatSearchResults = formatSearchResults;
33
+ exports.formatWorkflowList = formatWorkflowList;
33
34
  const capability_table_1 = require("../capability-table");
34
35
  /** src/orchestrator.ts:934-951 — the "More commands" token set, in the old
35
36
  * build's order (space-joined in the source, pipe-joined for display).
@@ -252,6 +253,7 @@ function formatHelp() {
252
253
  " -claude Use Claude agent",
253
254
  " -codex Use Codex agent",
254
255
  " -gemini Use Gemini (via opencode)",
256
+ " -opencode Use OpenCode agent",
255
257
  " -deepseek Use DeepSeek (via opencode)",
256
258
  " --verbose Show full agent narration live (default: compact)",
257
259
  " --full Verbose, plus the report printed inline at the end",
@@ -276,16 +278,44 @@ function formatHelp() {
276
278
  function cliCommandHelpRows(verb) {
277
279
  return (0, capability_table_1.cliCapabilities)()
278
280
  .filter((row) => row.cli.path[0] === verb && !row.cli.hiddenFromHelp)
279
- .map((row) => ({ command: `cw ${(row.cli.helpPath ?? row.cli.path).join(" ")}`, summary: row.summary }));
281
+ .map((row) => ({
282
+ command: `cw ${(row.cli.helpPath ?? row.cli.path).join(" ")}`,
283
+ summary: row.summary,
284
+ ...(row.cli.flags ? { flags: row.cli.flags } : {}),
285
+ }));
286
+ }
287
+ /** A verb that is only a `caseTokens` alias of another verb's row (e.g.
288
+ * `audit-run` -> `quickstart`) has NO row whose `cli.path[0]` matches it,
289
+ * so `cliCommandHelpRows` above finds nothing for it. This looks the
290
+ * alias up the same way the dispatcher does (registry-core.ts's
291
+ * findCapabilityByCliPath alias branch: a row whose `caseTokens` holds
292
+ * the token) and returns the verb the alias dispatches to. */
293
+ function aliasTargetVerb(verb) {
294
+ for (const row of (0, capability_table_1.cliCapabilities)()) {
295
+ if (row.cli.caseTokens && row.cli.caseTokens.includes(verb) && row.cli.path[0] !== verb) {
296
+ return row.cli.path[0];
297
+ }
298
+ }
299
+ return undefined;
280
300
  }
281
301
  /** src/orchestrator.ts:988-1007 — `formatCommandHelp(verb)`. Unknown verb
282
302
  * gives a SOFT text (never a throw); known verb lists its registry rows,
283
303
  * sorted by command string, padded to the longest command column + 2
284
304
  * (capped at 40, per the old registry's own cap; none of the milestone-1
285
- * fixture rows exceed it). */
305
+ * fixture rows exceed it). An alias verb (a `caseTokens` token like
306
+ * `audit-run`) gets an alias header line plus its target verb's rows —
307
+ * before this fix it fell into the unknown-verb text and, worse, the
308
+ * Did-you-mean line suggested the very verb the user just typed. */
286
309
  function formatCommandHelp(verb, suggestCommand) {
287
310
  const rows = [...(COMMAND_HELP_ROWS[verb] ?? []), ...cliCommandHelpRows(verb)];
288
311
  if (rows.length === 0) {
312
+ const target = aliasTargetVerb(verb);
313
+ if (target) {
314
+ const targetRows = [...(COMMAND_HELP_ROWS[target] ?? []), ...cliCommandHelpRows(target)];
315
+ if (targetRows.length > 0) {
316
+ return renderCommandHelpRows(`cw ${verb} — alias of cw ${target}`, targetRows);
317
+ }
318
+ }
289
319
  const hint = suggestCommand(verb);
290
320
  const lines = [`Unknown command: ${verb}`];
291
321
  if (hint)
@@ -293,13 +323,40 @@ function formatCommandHelp(verb, suggestCommand) {
293
323
  lines.push(" Try: cw help (list all commands)");
294
324
  return `${lines.join("\n")}\n`;
295
325
  }
326
+ return renderCommandHelpRows(`cw ${verb}`, rows);
327
+ }
328
+ /** The row-list rendering `formatCommandHelp` has always done, extracted
329
+ * so the alias page above can render its target's rows under its own
330
+ * header line without a second copy of the sort/pad/Flags logic. */
331
+ function renderCommandHelpRows(header, rows) {
296
332
  const sorted = [...rows].sort((a, b) => (a.command < b.command ? -1 : a.command > b.command ? 1 : 0));
297
333
  const longest = Math.min(40, Math.max(...sorted.map((row) => row.command.length)));
298
- const lines = [`cw ${verb}`, ""];
334
+ const lines = [header, ""];
299
335
  for (const row of sorted) {
300
336
  const padded = row.command.padEnd(longest, " ");
301
337
  lines.push(` ${padded} ${row.summary}`);
302
338
  }
339
+ // Flags block(s): only for rows that declare at least one flag (most
340
+ // rows do not — see CliBinding.flags). Uses the SAME 4-space indent as
341
+ // formatHelp's own "Run cw help <command> ..." note above, on purpose:
342
+ // the CLI/MCP parity help-token parser reads only 2-space lines as
343
+ // command tokens, so any wider indent is a line it already knows to
344
+ // skip. One row's command name is only spelled out in the block's own
345
+ // header when more than one row on this page declares flags (e.g.
346
+ // "cw ledger" lists both propose and review); a single-row page (e.g.
347
+ // "cw doctor") just says "Flags".
348
+ const flaggedRows = sorted.filter((row) => row.flags && row.flags.length > 0);
349
+ if (flaggedRows.length > 0) {
350
+ const oneFlaggedRow = flaggedRows.length === 1;
351
+ for (const row of flaggedRows) {
352
+ const flags = row.flags;
353
+ const flagWidth = Math.min(40, Math.max(...flags.map((flag) => flag.name.length)));
354
+ lines.push("", oneFlaggedRow ? " Flags" : ` Flags (${row.command})`);
355
+ for (const flag of flags) {
356
+ lines.push(` ${flag.name.padEnd(flagWidth, " ")} ${flag.summary}`);
357
+ }
358
+ }
359
+ }
303
360
  return `${lines.join("\n")}\n`;
304
361
  }
305
362
  /** `cw info <id>` human card — MILESTONE 12. Byte-exact in spirit to
@@ -360,3 +417,16 @@ function formatSearchResults(keyword, results) {
360
417
  lines.push("Use cw info <id> for full details.");
361
418
  return lines.join("\n");
362
419
  }
420
+ /** `cw list`'s TTY-only human text (the `list` row's `humanRender`, see
421
+ * core/capability-data.ts's CliBinding). Modeled on formatSearchResults
422
+ * above: one "<id> — <title>" line per workflow, then the same short
423
+ * next-step footer. NEVER printed to a pipe — the row's canonical JSON
424
+ * stays the only piped output (SPEC/cli-surface.md's jsonMode contract:
425
+ * "default" verbs are always JSON on a non-TTY stream). */
426
+ function formatWorkflowList(workflows) {
427
+ const rows = (Array.isArray(workflows) ? workflows : []);
428
+ const lines = rows.map((w) => `${w.id} — ${w.title}`);
429
+ lines.push("");
430
+ lines.push("Use cw info <id> for full details.");
431
+ return lines.join("\n");
432
+ }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ // core/format/recovery-hint.ts — recoveryHint, a pure content-based lookup
3
+ // from a thrown error's message to ONE copy-pasteable follow-up command.
4
+ //
5
+ // src/cli.ts:18-29 in the old build. Moved here (out of cli/entry.ts) so
6
+ // mcp/server.ts can use the same lookup for its own error text without
7
+ // crossing the mcp/-may-never-import-cli/ layer rule that
8
+ // scripts/purity-gate.js enforces — core/ may be read by both cli/ and
9
+ // mcp/. cli/entry.ts now just re-exports this function; its own call
10
+ // site and behavior are unchanged.
11
+ //
12
+ // Content-based (reads the message text, not the call site), so it stays
13
+ // correct no matter which command or tool threw. Returns `undefined`
14
+ // rather than a wrong guess when no pattern matches.
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.recoveryHint = recoveryHint;
17
+ function recoveryHint(message) {
18
+ const m = message.toLowerCase();
19
+ if (m.startsWith("unknown command"))
20
+ return "cw help";
21
+ if (m.includes("not configured") || m.includes("agent backend"))
22
+ return "cw doctor";
23
+ if (m.includes("missing") && m.includes("repo"))
24
+ return 'cw -q "<question>" -dir <project-folder>';
25
+ if (m.includes("app") && (m.includes("not found") || m.includes("not available")))
26
+ return "cw app list";
27
+ if (m.includes("run id") || m.includes("run not found"))
28
+ return "cw run list";
29
+ if (m.includes("missing required input") && m.includes("question"))
30
+ return 'cw -q "<question>"';
31
+ return undefined;
32
+ }
@@ -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);
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.required = required;
17
17
  exports.optionalArg = optionalArg;
18
18
  exports.wantsJson = wantsJson;
19
+ exports.parseBoolFlag = parseBoolFlag;
19
20
  /** Require a positional/option value or fail with a copy-pasteable recovery tip. */
20
21
  function required(value, label) {
21
22
  if (!value) {
@@ -31,3 +32,24 @@ function optionalArg(value) {
31
32
  function wantsJson(options) {
32
33
  return Boolean(options.json || options.format === "json");
33
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.4";
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)();