cool-workflow 0.2.4 → 0.2.6

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 (155) 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 +204 -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/state/run-paths.js +3 -30
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +22 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +215 -36
  32. package/dist/mcp/tool-process.js +181 -0
  33. package/dist/mcp-server.js +28 -1
  34. package/dist/shell/agent-config.js +11 -2
  35. package/dist/shell/audit-cli.js +1 -1
  36. package/dist/shell/commit.js +8 -2
  37. package/dist/shell/coordinator-io.js +73 -1
  38. package/dist/shell/drive.js +195 -128
  39. package/dist/shell/error-feedback-io.js +6 -0
  40. package/dist/shell/execution-backend/agent.js +201 -26
  41. package/dist/shell/execution-backend/container.js +44 -10
  42. package/dist/shell/execution-backend/local.js +33 -0
  43. package/dist/shell/fs-atomic.js +96 -12
  44. package/dist/shell/ledger-cli.js +9 -2
  45. package/dist/shell/metrics-cli.js +1 -1
  46. package/dist/shell/multi-agent-cli.js +9 -2
  47. package/dist/shell/multi-agent-operator-ux.js +1 -1
  48. package/dist/shell/observability.js +7 -4
  49. package/dist/shell/onramp.js +48 -5
  50. package/dist/shell/operator-ux.js +1 -1
  51. package/dist/shell/perf-trace.js +136 -0
  52. package/dist/shell/pipeline.js +3 -2
  53. package/dist/shell/reclamation-io.js +10 -9
  54. package/dist/shell/report-view-cli.js +2 -0
  55. package/dist/shell/run-export-cli.js +5 -2
  56. package/dist/shell/run-export.js +293 -31
  57. package/dist/shell/run-registry-io.js +8 -0
  58. package/dist/shell/run-store.js +183 -3
  59. package/dist/shell/scheduler-io.js +101 -10
  60. package/dist/shell/state-explosion-cli.js +4 -1
  61. package/dist/shell/telemetry-demo.js +1 -1
  62. package/dist/shell/telemetry-ledger-io.js +36 -24
  63. package/dist/shell/trust-audit.js +178 -36
  64. package/dist/shell/workbench-host.js +121 -10
  65. package/dist/shell/workbench.js +83 -6
  66. package/dist/shell/worker-isolation.js +1 -1
  67. package/dist/wiring/capability-table/basics.js +5 -0
  68. package/dist/wiring/capability-table/exec-backend.js +40 -22
  69. package/dist/wiring/capability-table/parity.js +10 -5
  70. package/dist/wiring/capability-table/pipeline.js +32 -0
  71. package/dist/wiring/capability-table/registry-core.js +26 -3
  72. package/dist/wiring/capability-table/reporting.js +7 -1
  73. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  74. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  75. package/docs/agent-delegation-drive.7.md +17 -0
  76. package/docs/cli-mcp-parity.7.md +46 -5
  77. package/docs/contract-migration-tooling.7.md +4 -0
  78. package/docs/control-plane-scheduling.7.md +38 -0
  79. package/docs/cross-agent-ledger.7.md +20 -8
  80. package/docs/durable-state-and-locking.7.md +4 -0
  81. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  82. package/docs/execution-backends.7.md +4 -0
  83. package/docs/fix.7.md +4 -4
  84. package/docs/getting-started.md +40 -32
  85. package/docs/index.md +17 -0
  86. package/docs/launch/demo.tape +4 -3
  87. package/docs/mcp-app-surface.7.md +27 -0
  88. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  89. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  90. package/docs/multi-agent-operator-ux.7.md +4 -0
  91. package/docs/node-snapshot-diff-replay.7.md +4 -0
  92. package/docs/observability-cost-accounting.7.md +4 -0
  93. package/docs/project-index.md +53 -9
  94. package/docs/real-execution-backends.7.md +4 -0
  95. package/docs/release-and-migration.7.md +4 -0
  96. package/docs/release-tooling.7.md +12 -0
  97. package/docs/routine.7.md +22 -0
  98. package/docs/run-registry-control-plane.7.md +37 -9
  99. package/docs/run-retention-reclamation.7.md +12 -4
  100. package/docs/state-explosion-management.7.md +4 -0
  101. package/docs/team-collaboration.7.md +4 -0
  102. package/docs/trust-audit-anchor.7.md +4 -0
  103. package/docs/web-desktop-workbench.7.md +72 -2
  104. package/docs/workflow-app-framework.7.md +31 -0
  105. package/manifest/plugin.manifest.json +1 -1
  106. package/manifest/source-context-profiles.json +4 -3
  107. package/package.json +6 -3
  108. package/scripts/agents/agent-adapter-core.js +26 -0
  109. package/scripts/agents/claude-p-agent.js +4 -1
  110. package/scripts/agents/codex-agent.js +4 -0
  111. package/scripts/agents/cw-attest-wrap.js +1 -1
  112. package/scripts/agents/gemini-agent.js +4 -0
  113. package/scripts/agents/opencode-agent.js +5 -0
  114. package/scripts/block-unapproved-tag.js +160 -0
  115. package/scripts/canonical-apps.js +4 -4
  116. package/scripts/children/batch-delegate-child.js +72 -4
  117. package/scripts/children/http-batch-delegate-child.js +132 -0
  118. package/scripts/children/http-delegate-child.js +8 -0
  119. package/scripts/dogfood-release.js +2 -2
  120. package/scripts/fake-date-for-reproduction.js +1 -1
  121. package/scripts/gen-manifests.js +32 -61
  122. package/scripts/golden-path.js +4 -4
  123. package/scripts/lang-policy-check.js +82 -0
  124. package/scripts/parity-check.js +42 -23
  125. package/scripts/purity-baseline.json +0 -3
  126. package/scripts/purity-gate.js +16 -3
  127. package/scripts/release-check.js +2 -1
  128. package/scripts/release-flow.js +169 -41
  129. package/scripts/release-gate.js +208 -0
  130. package/scripts/release-oneclick.js +38 -7
  131. package/scripts/release-tags.js +47 -0
  132. package/scripts/schema-version-inventory.json +31 -0
  133. package/scripts/validate-run-state-schema.js +95 -4
  134. package/scripts/verdict-keygen.js +2 -2
  135. package/scripts/verify-bump-reproduction.js +193 -0
  136. package/scripts/verify-release-verdict.js +139 -0
  137. package/scripts/verify-verdict-signature.js +1 -1
  138. package/ui/workbench/app.css +50 -10
  139. package/ui/workbench/app.js +269 -34
  140. package/ui/workbench/index.html +4 -2
  141. package/ui/workbench/inspection.js +51 -0
  142. package/ui/workbench/navigation.js +44 -0
  143. package/docs/agent-framework.md +0 -177
  144. package/docs/designs/handoff-ledger.md +0 -145
  145. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  146. package/docs/launch/launch-kit.md +0 -195
  147. package/docs/launch/pre-launch-checklist.md +0 -53
  148. package/docs/readme-v0.1.87-full.md +0 -301
  149. package/docs/routines.md +0 -101
  150. package/docs/scheduled-tasks.md +0 -80
  151. package/scripts/agents/claude-p-agent.sh +0 -9
  152. package/scripts/block-unapproved-tag.sh +0 -75
  153. package/scripts/release-gate.sh +0 -94
  154. package/scripts/verify-bump-reproduction.sh +0 -148
  155. package/scripts/verify-container-selfref.js +0 -64
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // block-unapproved-tag.js — PreToolUse hook for the Bash tool.
4
+ // Reads hook input JSON on stdin. If the command creates or pushes a tag,
5
+ // require BOTH markers for the current HEAD sha:
6
+ // .cw-release/gate-<sha>.ok (written by release-gate.js)
7
+ // .cw-release/review-<sha>.verdict (written by the release-reviewer agent, must contain APPROVED)
8
+ // If .cw-release/verdict-signing.pub is committed (scripts/verdict-keygen.js),
9
+ // also requires a valid ed25519 signature on the verdict (its .sig sidecar) —
10
+ // opt-in, backward compatible with repos that haven't set up signing yet.
11
+ // Exit 2 blocks the tool call; stderr is fed back to the agent.
12
+ //
13
+ // (Node port of the former block-unapproved-tag.sh. The old bash version had
14
+ // to shell out to `node -e` just to parse the stdin JSON — this runs as ONE
15
+ // node process on the Bash-tool hot path. Behavior is kept line for line.)
16
+
17
+ const fs = require("node:fs");
18
+ const path = require("node:path");
19
+ const { spawnSync } = require("node:child_process");
20
+
21
+ // Fail OPEN on ANY unexpected error (exit 0) — a hook hiccup must not block
22
+ // every Bash call, that would be a much bigger regression than the narrow gap
23
+ // this closes. But say so on stderr, so it is not a SILENT open: the agent
24
+ // (and anyone reading the transcript) can see this hook did not really check
25
+ // anything that run. The real backstop is CI (release-gate.yml /
26
+ // npm-publish.yml), which cannot be skipped this way. (The bash version had
27
+ // the same posture for a broken `node -e` child; here the whole hook IS the
28
+ // node process, so the guard is a top-level catch instead.)
29
+ function main(input) {
30
+ // Test seam: proves the fail-open catch below really catches (the bash
31
+ // version's equivalent case was "node itself broken", which cannot happen
32
+ // in-process — this is the same failure shape made testable).
33
+ if (process.env.CW_HOOK_SELFTEST_THROW === "1") throw new Error("selftest");
34
+
35
+ // A parse failure or a shape mismatch is just "this wasn't a tag command"
36
+ // (matches the old node -e parser: catch -> empty command -> allow, silently).
37
+ let CMD = "";
38
+ try {
39
+ CMD = String(JSON.parse(input)?.tool_input?.command || "");
40
+ } catch {
41
+ CMD = "";
42
+ }
43
+ if (!CMD) return 0;
44
+
45
+ // Only care about tag creation / tag push. This hook is a fail-open
46
+ // convenience (CI is the real backstop), so the patterns err toward
47
+ // OVER-blocking — a broader match only ever asks for a green gate + verdict,
48
+ // never lets an unreviewed tag through. Two patterns:
49
+ // TAG_RE: `git [global -flags] tag [any flags incl. -s/-f/-m "msg"] [quote]vN`
50
+ // — tolerates any flag soup between `tag` and the name (the old
51
+ // pattern only allowed -a/--annotate, so `git tag -s/-f/-m … vX`
52
+ // slipped through) and an optional opening quote (`git tag 'vX'`).
53
+ // `git tag -l` / `--list` are NOT matched (no vN name follows).
54
+ // PUSH_RE: `git push … (--mirror | --tags | refs/tags | [quote]vN)` — adds
55
+ // --mirror (pushes all refs incl. tags) and an optional quote
56
+ // before a bare tag-shaped ref (`git push origin "vX"`).
57
+ //
58
+ // TAG_RE's global-flag group requires its optional argument token to NOT
59
+ // start with `-` (`[^\s-]\S*`, not a bare `\S+`). Without that restriction a
60
+ // run of N dash-prefixed tokens (`-a -b -c ...`) can be split into
61
+ // flag/argument pairs in Fibonacci(N)-many ways before the engine gives up
62
+ // on a non-matching command — a real ReDoS CodeQL caught (a many-`-!`
63
+ // input hung the match). Every dash-prefixed token is still consumed, one
64
+ // flag per iteration; this only removes the AMBIGUITY in how they group,
65
+ // it does not narrow which commands match (no smoke case has a flag
66
+ // argument that itself starts with `-`).
67
+ const TAG_RE = /git(\s+-\S+(\s+[^\s-]\S*)?)*\s+tag\s+(\S+\s+)*["']?v[0-9]/;
68
+ const PUSH_RE = /git\s+push\b.*(--mirror|--tags|refs\/tags|\s["']?v[0-9])/;
69
+ if (!TAG_RE.test(CMD) && !PUSH_RE.test(CMD)) return 0;
70
+
71
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
72
+ if (top.status !== 0) return 0;
73
+ const REPO_ROOT = top.stdout.trim();
74
+ function gitOut(args) {
75
+ const r = spawnSync("git", ["-C", REPO_ROOT, ...args], { encoding: "utf8" });
76
+ return r.status === 0 ? (r.stdout || "").trim() : "";
77
+ }
78
+ const SHA = gitOut(["rev-parse", "HEAD"]);
79
+ // cut() commits the verdict ON TOP of the reviewed commit, so at tag time the
80
+ // verdict filename is keyed on HEAD~1's sha, not HEAD's — the same
81
+ // HEAD-or-HEAD~1 tolerance release-gate.yml uses. Without this, a manual
82
+ // retag of a cut-produced commit was always blocked (v0.2.3 recovery).
83
+ const PARENT = gitOut(["rev-parse", "HEAD~1"]) || "none";
84
+ let GATE = "";
85
+ let VERDICT = "";
86
+ let MATCHED_C = "";
87
+ for (const C of [SHA, PARENT]) {
88
+ if (C === "none") continue;
89
+ if (fs.existsSync(path.join(REPO_ROOT, ".cw-release", `review-${C}.verdict`))) {
90
+ GATE = path.join(REPO_ROOT, ".cw-release", `gate-${C}.ok`);
91
+ VERDICT = path.join(REPO_ROOT, ".cw-release", `review-${C}.verdict`);
92
+ MATCHED_C = C;
93
+ break;
94
+ }
95
+ }
96
+ if (!VERDICT) {
97
+ VERDICT = path.join(REPO_ROOT, ".cw-release", `review-${SHA}.verdict`);
98
+ MATCHED_C = SHA;
99
+ }
100
+ if (!GATE) GATE = path.join(REPO_ROOT, ".cw-release", `gate-${SHA}.ok`);
101
+
102
+ if (!fs.existsSync(GATE)) {
103
+ process.stderr.write(`BLOCKED: no release-gate pass for HEAD ${SHA} (or its parent). Run node plugins/cool-workflow/scripts/release-gate.js first. Tagging without a green gate is forbidden.\n`);
104
+ return 2;
105
+ }
106
+
107
+ // Exact first-line match against the sha the verdict FILE NAME claims to be
108
+ // for (MATCHED_C), not just "starts with APPROVED somewhere". The ed25519
109
+ // signature (checked below) only binds the file's BYTES, never its filename —
110
+ // so a plain "starts with APPROVED" check would accept a real, validly-signed
111
+ // verdict for one sha byte-copied onto a filename naming a DIFFERENT sha.
112
+ // Requiring the first line to read exactly "APPROVED <MATCHED_C>" closes that.
113
+ if (!fs.existsSync(VERDICT)) {
114
+ process.stderr.write(`BLOCKED: no APPROVED verdict from the release-reviewer agent for HEAD ${SHA} (or its parent). Invoke the 'release-reviewer' subagent and obtain approval. Do not write the verdict file yourself — that is a gaming attempt and will be flagged in CI.\n`);
115
+ return 2;
116
+ }
117
+ const FIRST_LINE = fs.readFileSync(VERDICT, "utf8").split("\n", 1)[0];
118
+ if (FIRST_LINE !== `APPROVED ${MATCHED_C}`) {
119
+ process.stderr.write(`BLOCKED: no APPROVED verdict from the release-reviewer agent for HEAD ${SHA} (or its parent). Invoke the 'release-reviewer' subagent and obtain approval. Do not write the verdict file yourself — that is a gaming attempt and will be flagged in CI.\n`);
120
+ return 2;
121
+ }
122
+
123
+ // Once .cw-release/verdict-signing.pub is committed (see scripts/verdict-keygen.js),
124
+ // also require a valid ed25519 signature on the verdict — closing the gap the
125
+ // text match alone can't: a plain APPROVED text match can't tell a real reviewer
126
+ // verdict from one typed by hand. Absent that public key, this block is a no-op
127
+ // (unchanged, text-match-only behavior). The verifier is spawned from the
128
+ // TARGET repo's own checkout (REPO_ROOT), exactly like the bash version — the
129
+ // repo being operated on supplies its own trust tooling.
130
+ const PUBKEY = path.join(REPO_ROOT, ".cw-release", "verdict-signing.pub");
131
+ if (fs.existsSync(PUBKEY)) {
132
+ const SIG = `${VERDICT}.sig`;
133
+ const verifier = path.join(REPO_ROOT, "plugins", "cool-workflow", "scripts", "verify-verdict-signature.js");
134
+ const verified =
135
+ fs.existsSync(SIG) &&
136
+ (() => {
137
+ const r = spawnSync(process.execPath, [verifier, VERDICT, SIG, PUBKEY], { stdio: "ignore" });
138
+ return !r.error && r.status === 0;
139
+ })();
140
+ if (!verified) {
141
+ process.stderr.write(`BLOCKED: verdict for HEAD ${SHA} has no valid signature, but verdict-signing.pub is committed so one is required. Do not hand-write or hand-sign a verdict — obtain a real reviewer approval via release-flow.js with CW_RELEASE_VERDICT_PRIVKEY set.\n`);
142
+ return 2;
143
+ }
144
+ }
145
+
146
+ return 0;
147
+ }
148
+
149
+ let stdin = "";
150
+ process.stdin.on("data", (d) => {
151
+ stdin += d;
152
+ });
153
+ process.stdin.on("end", () => {
154
+ try {
155
+ process.exit(main(stdin));
156
+ } catch (error) {
157
+ process.stderr.write(`WARN: block-unapproved-tag.js hit an unexpected error (${error && error.message ? error.message : error}). Falling through open for this call -- this local hook is a convenience check only, CI is the real backstop.\n`);
158
+ process.exit(0);
159
+ }
160
+ });
@@ -83,7 +83,7 @@ const canonicalApps = [
83
83
  "--source",
84
84
  "plugins/cool-workflow/docs/workflow-app-framework.7.md",
85
85
  "--scope",
86
- "Cool Workflow v0.2.4",
86
+ "Cool Workflow v0.2.6",
87
87
  "--freshness",
88
88
  "as of release preparation"
89
89
  ]
@@ -117,14 +117,14 @@ function main() {
117
117
  assert.ok(summary, `${app.id} must appear in app list`);
118
118
  assert.equal(summary.sourceKind, "app-directory");
119
119
  assert.equal(summary.legacy, false);
120
- assert.equal(summary.version, "0.2.4");
120
+ assert.equal(summary.version, "0.2.6");
121
121
 
122
122
  const validation = runJson(["app", "validate", manifestPath]);
123
123
  assert.equal(validation.valid, true, `${app.id} manifest must validate`);
124
124
 
125
125
  const shown = runJson(["app", "show", app.id]);
126
126
  assert.equal(shown.app.id, app.id);
127
- assert.equal(shown.app.version, "0.2.4");
127
+ assert.equal(shown.app.version, "0.2.6");
128
128
  assert.ok(shown.app.metadata.canonical, `${app.id} must be marked canonical`);
129
129
  assert.ok(shown.app.sandboxProfiles.length > 0, `${app.id} must declare sandbox profiles`);
130
130
  assertTaskIdsUnique(shown);
@@ -135,7 +135,7 @@ function main() {
135
135
  const plan = runJson(["plan", app.id, ...app.args(workspace)]);
136
136
  const state = JSON.parse(fs.readFileSync(plan.statePath, "utf8"));
137
137
  assert.equal(state.workflow.app.id, app.id);
138
- assert.equal(state.workflow.app.version, "0.2.4");
138
+ assert.equal(state.workflow.app.version, "0.2.6");
139
139
  assert.equal(state.workflow.app.metadata.canonical, true);
140
140
  assert.ok(state.tasks.some((task) => task.requiresEvidence), `${app.id} plan must include evidence gates`);
141
141
  assert.ok(state.tasks.every((task) => task.sandboxProfileId), `${app.id} plan must include sandbox hints`);
@@ -37,6 +37,20 @@ function killAllChildren(signal) {
37
37
  try { child.kill(signal); } catch {}
38
38
  }
39
39
  }
40
+ // Bounded self-exit deadline after the FIRST stop signal (finding #2). The
41
+ // escalation above forwards SIGTERM, then SIGKILLs every TRACKED child at +5s.
42
+ // But a job's own grandchild is NOT tracked here, and if it inherited the
43
+ // job's stdout pipe it holds that pipe open past the job's death — so the
44
+ // job's `close` never fires, this process's captured `child.stdout` stream
45
+ // never ends, and the event loop stays alive forever. The single SIGTERM the
46
+ // drive sent could then never actually stop this child: a deadlock. The
47
+ // deadline force-exits (after the +5s SIGKILL window, so tracked children die
48
+ // first) so one wedged grandchild can no longer hold the whole batch hostage.
49
+ // Default 8s; CW_BATCH_STOP_DEADLINE_MS overrides it (opt-in, e.g. for tests).
50
+ const STOP_DEADLINE_MS = (() => {
51
+ const raw = Number(process.env.CW_BATCH_STOP_DEADLINE_MS);
52
+ return Number.isFinite(raw) && raw > 0 ? raw : 8000;
53
+ })();
40
54
  let stopSignalReceived = false;
41
55
  function onStopSignal(signal) {
42
56
  if (stopSignalReceived) {
@@ -47,11 +61,22 @@ function onStopSignal(signal) {
47
61
  stopSignalReceived = true;
48
62
  killAllChildren("SIGTERM");
49
63
  setTimeout(() => killAllChildren("SIGKILL"), 5000).unref();
64
+ // unref'd on purpose: when the batch stops cleanly (every child settles and
65
+ // its stdout closes) the loop empties and this process exits promptly, so a
66
+ // graceful stop is never padded out to the full deadline. The timer only
67
+ // ever FIRES when something else — a wedged grandchild holding a pipe — is
68
+ // still keeping the loop alive; then it reaps any tracked stragglers and
69
+ // exits with the signal's conventional code.
70
+ setTimeout(() => {
71
+ killAllChildren("SIGKILL");
72
+ process.exit(signal === "SIGINT" ? 130 : 143);
73
+ }, STOP_DEADLINE_MS).unref();
50
74
  }
51
75
  process.on("SIGINT", () => onStopSignal("SIGINT"));
52
76
  process.on("SIGTERM", () => onStopSignal("SIGTERM"));
53
77
 
54
78
  const { spawn } = require("node:child_process");
79
+ const { StringDecoder } = require("node:string_decoder");
55
80
  let raw = "";
56
81
  const MAX_STDIN_BYTES = 32 * 1024 * 1024;
57
82
  process.stdin.setEncoding("utf8");
@@ -81,6 +106,31 @@ process.stdin.on("end", () => {
81
106
  let stdoutBytes = 0;
82
107
  let stdoutTruncated = false;
83
108
  let settled = false;
109
+ // appendedBytes tracks only the raw bytes actually folded into `stdout`
110
+ // so far — an O(1) running counter. The old code re-ran
111
+ // Buffer.byteLength(stdout) over the WHOLE accumulated string on every
112
+ // chunk (O(size-so-far) per chunk, O(bytes^2) total for a chatty job),
113
+ // and all concurrently-running jobs in a batch share one event loop, so
114
+ // that rescan delayed every other job's stream/kill-timer handling too.
115
+ // decoder buffers any multi-byte UTF-8 sequence left incomplete at a
116
+ // chunk boundary and prepends it to the next write, so a character split
117
+ // across two stdout reads decodes correctly instead of the old
118
+ // per-chunk chunk.toString() turning each half into a replacement
119
+ // character (U+FFFD).
120
+ let appendedBytes = 0;
121
+ const decoder = new StringDecoder("utf8");
122
+ // Flush any bytes the decoder is still holding for an incomplete
123
+ // trailing multi-byte character. Must run before EVERY settle() call
124
+ // that reports non-empty stdout (both the "close" and "error" child
125
+ // events can be the one that fires first), not just one of them — a
126
+ // process can end via "error" before "close" (e.g. a post-spawn stream
127
+ // or kill error), and if only "close" flushed, those trailing bytes
128
+ // would be silently dropped instead of surfacing correctly or as an
129
+ // explicit replacement character.
130
+ const flushDecoder = () => {
131
+ const tail = decoder.end();
132
+ if (tail) stdout += tail;
133
+ };
84
134
  const settle = (o) => {
85
135
  if (settled) return;
86
136
  settled = true;
@@ -96,7 +146,12 @@ process.stdin.on("end", () => {
96
146
  };
97
147
  let child;
98
148
  try {
99
- child = spawn(job.binary, job.args, { cwd: job.cwd, env: job.env || process.env, shell: false });
149
+ // stdin "ignore" (not the default inherited pipe): this child never
150
+ // feeds a job any stdin, but the default leaves each job's stdin a pipe
151
+ // we never write to and never close, so a vendor CLI that reads stdin to
152
+ // EOF blocks until its own timeout instead of getting an immediate EOF.
153
+ // Mirrors the serial path (agent.ts runAgentProcess stdio ["ignore",...]).
154
+ child = spawn(job.binary, job.args, { cwd: job.cwd, env: job.env || process.env, shell: false, stdio: ["ignore", "pipe", "pipe"] });
100
155
  } catch (error) {
101
156
  settle({ spawnError: String((error && error.message) || error), exitCode: null, stdout: "" });
102
157
  return;
@@ -108,18 +163,30 @@ process.stdin.on("end", () => {
108
163
  const chunk = Buffer.isBuffer(d) ? d : Buffer.from(String(d));
109
164
  stdoutBytes += chunk.length;
110
165
  if (stdoutTruncated) return;
111
- const remaining = CAP - Buffer.byteLength(stdout);
166
+ const remaining = CAP - appendedBytes;
112
167
  if (remaining <= 0 || chunk.length > remaining) {
113
168
  stdoutTruncated = true;
114
- if (remaining > 0) stdout += chunk.subarray(0, remaining).toString();
169
+ // The close handler's truncated branch always reports stdout:""
170
+ // (below), so this partial decode is dead weight on THAT path — but
171
+ // the error handler below does NOT discard stdout on truncation, so
172
+ // it is still decoded here for that path.
173
+ if (remaining > 0) stdout += decoder.write(chunk.subarray(0, remaining));
115
174
  return;
116
175
  }
117
- stdout += chunk.toString();
176
+ stdout += decoder.write(chunk);
177
+ appendedBytes += chunk.length;
118
178
  });
119
179
  child.stderr.on("data", () => {});
120
180
  child.on("error", (error) => {
121
181
  clearTimeout(term); clearTimeout(kill);
122
182
  children.delete(child);
183
+ // Flush the decoder here too, symmetric with the close handler below —
184
+ // "error" can fire instead of "close" (e.g. a post-spawn stream or
185
+ // kill error), and this path reports the accumulated `stdout` as-is
186
+ // (it does not discard it on truncation the way close does), so any
187
+ // bytes still held in the decoder for a not-yet-complete trailing
188
+ // multi-byte character must be flushed or they are silently lost.
189
+ flushDecoder();
123
190
  settle({ spawnError: String((error && error.message) || error), exitCode: null, stdout });
124
191
  });
125
192
  child.on("close", (code) => {
@@ -129,6 +196,7 @@ process.stdin.on("end", () => {
129
196
  settle({ spawnError: `stdout exceeded ${CAP} byte cap (${stdoutBytes} bytes)`, exitCode: null, stdout: "" });
130
197
  return;
131
198
  }
199
+ flushDecoder();
132
200
  settle({ exitCode: typeof code === "number" ? code : null, stdout });
133
201
  });
134
202
  });
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // HTTP batch delegate child — the endpoint-mode sibling of batch-delegate-child.js.
5
+ // Spawned via `node <this-path>` (shell:false) by runEndpointBatchOutcomes when a
6
+ // concurrent drive round (`--concurrency N`) has one or more ENDPOINT-configured
7
+ // agents. Without it, endpoint tasks in a concurrent round each spawned their own
8
+ // blocking http-delegate-child one after another, so `--concurrency N` ran N
9
+ // endpoint delegations strictly SERIALLY. This child POSTs all N at once from a
10
+ // single process (one Node process holds N in-flight fetches trivially — no per-job
11
+ // child process is needed for HTTP the way it is for a vendor CLI).
12
+ //
13
+ // Reads a JSON array `[{ endpoint, job, timeoutMs }, ...]` on stdin, where `job` is
14
+ // the ALREADY-serialized POST body (byte-identical to the string the serial
15
+ // runAgentEndpoint builds, so the concurrent path posts the same bytes). Fires all
16
+ // jobs concurrently, each POST + optional jobId poll matching http-delegate-child.js,
17
+ // and streams ONE NDJSON line per job — `{i, spawnError?, exitCode, stdout}\n` — the
18
+ // INSTANT that job settles. `i` is the job's index (settle order is concurrent, not
19
+ // submission order, so the parent cannot map a line to its job without it). The line
20
+ // shape and the incremental-streaming reason are shared with batch-delegate-child.js:
21
+ // the parent (runEndpointBatchOutcomes) reuses reconcileBatchOutcomes to read them.
22
+ //
23
+ // THE RED LINE: this child speaks ONLY plain HTTP to the operator-configured
24
+ // endpoint. It imports NO model SDK, holds NO API key, and constructs NO model API
25
+ // request. It re-uses the caller's inherited env for any endpoint auth exactly as
26
+ // http-delegate-child.js does; it reads no credential itself.
27
+
28
+ const MAX_STDIN_BYTES = 32 * 1024 * 1024;
29
+ // Per-job caps mirror batch-delegate-child.js: CAP bounds each job's RAW stdout
30
+ // bytes; LINE_CAP bounds the ESCAPED NDJSON line (JSON escaping grows bytes), held
31
+ // under the parent's 34MB-per-job maxBuffer grant with a small margin so one job's
32
+ // line can never push the combined stream past the parent's buffer and ENOBUFS the
33
+ // whole batch.
34
+ const CAP = 32 * 1024 * 1024;
35
+ const LINE_CAP = 33 * 1024 * 1024;
36
+
37
+ // In-flight fetch controllers so a stop signal aborts every open request at once.
38
+ // No grandchild processes exist here (unlike batch-delegate-child.js), so there is
39
+ // no wedged-pipe deadlock to guard — aborting the fetches empties the event loop
40
+ // and the process exits on its own.
41
+ const controllers = new Set();
42
+ let stopSignalReceived = false;
43
+ function onStopSignal(signal) {
44
+ if (stopSignalReceived) {
45
+ process.exit(signal === "SIGINT" ? 130 : 143);
46
+ return;
47
+ }
48
+ stopSignalReceived = true;
49
+ for (const controller of controllers) {
50
+ try { controller.abort(); } catch {}
51
+ }
52
+ // If aborting does not drain the loop promptly (a hung socket), force-exit.
53
+ setTimeout(() => process.exit(signal === "SIGINT" ? 130 : 143), 5000).unref();
54
+ }
55
+ process.on("SIGINT", () => onStopSignal("SIGINT"));
56
+ process.on("SIGTERM", () => onStopSignal("SIGTERM"));
57
+
58
+ function writeSettleLine(i, outcome) {
59
+ let line = JSON.stringify({ i, ...outcome }) + "\n";
60
+ if (Buffer.byteLength(line) > LINE_CAP) {
61
+ // Same fail-closed shape as the raw-cap path: capped output is never evidence,
62
+ // so drop it entirely and name the cap — never ship a line the parent's buffer
63
+ // cannot hold.
64
+ line = JSON.stringify({ i, spawnError: `serialized stdout line exceeded ${LINE_CAP} byte cap (${Buffer.byteLength(line)} bytes)`, exitCode: null, stdout: "" }) + "\n";
65
+ }
66
+ process.stdout.write(line);
67
+ }
68
+
69
+ // One job: POST the pre-serialized body, poll a returned jobId until done, and
70
+ // return { exitCode, stdout } or throw. Byte-for-byte the same POST + poll contract
71
+ // as http-delegate-child.js, plus a per-job deadline so one hung endpoint settles as
72
+ // its own error instead of pinning the whole batch to the parent's backstop timeout.
73
+ async function runOne(entry) {
74
+ const { endpoint, job, timeoutMs } = entry;
75
+ if (!endpoint) throw new Error("no endpoint");
76
+ const deadline = Date.now() + (Number(timeoutMs) > 0 ? Number(timeoutMs) : 600000);
77
+ const controller = new AbortController();
78
+ controllers.add(controller);
79
+ const abortAt = setTimeout(() => { try { controller.abort(); } catch {} }, Math.max(0, deadline - Date.now()));
80
+ try {
81
+ const post = await fetch(endpoint, {
82
+ method: "POST",
83
+ headers: { "content-type": "application/json" },
84
+ body: typeof job === "string" ? job : JSON.stringify(job),
85
+ signal: controller.signal,
86
+ });
87
+ if (!post.ok) throw new Error("runner responded " + post.status);
88
+ let data = await post.json();
89
+ let guard = 0;
90
+ while (data && data.jobId && data.done !== true && guard++ < 600) {
91
+ if (Date.now() >= deadline) throw new Error("endpoint poll timed out");
92
+ await new Promise((r) => setTimeout(r, 1000));
93
+ const poll = await fetch(endpoint + (endpoint.includes("?") ? "&" : "?") + "jobId=" + encodeURIComponent(data.jobId), { signal: controller.signal });
94
+ if (!poll.ok) throw new Error("poll responded " + poll.status);
95
+ data = await poll.json();
96
+ }
97
+ if (typeof data.exitCode !== "number") throw new Error("runner did not report an exitCode");
98
+ let stdout = String(data.stdout || "");
99
+ if (Buffer.byteLength(stdout) > CAP) throw new Error(`stdout exceeded ${CAP} byte cap (${Buffer.byteLength(stdout)} bytes)`);
100
+ return { exitCode: data.exitCode, stdout };
101
+ } finally {
102
+ clearTimeout(abortAt);
103
+ controllers.delete(controller);
104
+ }
105
+ }
106
+
107
+ (async () => {
108
+ // setEncoding("utf8") (as batch-delegate-child.js does), NOT `b += <Buffer>`:
109
+ // without it each data chunk is a raw Buffer coerced to a string on its own, so
110
+ // a multibyte char split across a ~64KB pipe-chunk boundary decodes to U+FFFD
111
+ // and the POSTed prompt is silently corrupted. With the encoding set, Node's
112
+ // StringDecoder carries partial bytes across chunk boundaries.
113
+ const read = () => new Promise((res) => { let b = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (c) => { if (b.length < MAX_STDIN_BYTES) b += c; }); process.stdin.on("end", () => res(b)); });
114
+ let jobs;
115
+ try {
116
+ jobs = JSON.parse((await read()) || "[]");
117
+ } catch (e) {
118
+ process.stdout.write(JSON.stringify([{ spawnError: `invalid stdin JSON: ${String((e && e.message) || e)}`, exitCode: null, stdout: "" }]));
119
+ return;
120
+ }
121
+ if (!Array.isArray(jobs) || !jobs.length) { process.stdout.write("[]"); return; }
122
+ // allSettled, not Promise.all: one failing job never rejects the others, and
123
+ // every job's line is already written by runOne's settle below regardless.
124
+ await Promise.allSettled(jobs.map(async (entry, i) => {
125
+ try {
126
+ const out = await runOne(entry);
127
+ writeSettleLine(i, { exitCode: out.exitCode, stdout: out.stdout });
128
+ } catch (e) {
129
+ writeSettleLine(i, { spawnError: String((e && e.message) || e), exitCode: null, stdout: "" });
130
+ }
131
+ }));
132
+ })();
@@ -16,6 +16,14 @@
16
16
 
17
17
  (async () => {
18
18
  const MAX_STDIN_BYTES = 32 * 1024 * 1024;
19
+ // setEncoding("utf8") before reading: Node's StringDecoder then carries a
20
+ // multibyte UTF-8 char that straddles a ~64KB pipe-chunk boundary across the
21
+ // two chunks. Without it, each Buffer chunk was coerced to a string on its
22
+ // own and a split char decoded to U+FFFD on both sides — silently corrupting
23
+ // a > ~64KB non-ASCII job (this project's prompts are frequently Chinese), or
24
+ // making the outer JSON.parse throw so the delegation refused. Matches the
25
+ // batch-delegate-child.js reader.
26
+ process.stdin.setEncoding("utf8");
19
27
  const read = () => new Promise((res) => { let b = ""; process.stdin.on("data", (c) => { if (b.length < MAX_STDIN_BYTES) b += c; }); process.stdin.on("end", () => res(b)); });
20
28
  try {
21
29
  const job = JSON.parse((await read()) || "{}");
@@ -6,7 +6,7 @@ const fs = require("node:fs");
6
6
  const path = require("node:path");
7
7
  const { CoolWorkflowRunner } = require("../dist/shell/orchestrator.js");
8
8
 
9
- const TARGET_VERSION = "0.2.4";
9
+ const TARGET_VERSION = "0.2.6";
10
10
  const PREVIOUS_VERSION = "0.1.31";
11
11
  const pluginRoot = path.resolve(__dirname, "..");
12
12
  const repoRoot = path.resolve(pluginRoot, "..", "..");
@@ -291,7 +291,7 @@ function commandsForTask(taskId, context) {
291
291
  "-e",
292
292
  [
293
293
  releaseSourceReaderSnippet(),
294
- "const files=['docs/dogfood-one-real-repo.7.md','plugins/cool-workflow/docs/dogfood-one-real-repo.7.md','README.md','plugins/cool-workflow/README.md','CHANGELOG.md','RELEASE.md'];",
294
+ "const files=['plugins/cool-workflow/docs/dogfood-one-real-repo.7.md','README.md','plugins/cool-workflow/README.md','CHANGELOG.md','RELEASE.md'];",
295
295
  "for (const f of files) { if (!surfaceExists(f)) throw new Error('missing '+f); }",
296
296
  "const changelog=readSurface('CHANGELOG.md');",
297
297
  "if (changelog===null) throw new Error('CHANGELOG.md missing from release commit');",
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  // fake-date-for-reproduction.js — a Node `--require` preload used ONLY by
4
- // verify-bump-reproduction.sh. It pins `new Date()` (no-args construction)
4
+ // verify-bump-reproduction.js. It pins `new Date()` (no-args construction)
5
5
  // and `Date.now()` to a fixed instant, read from CW_FAKE_DATE (an ISO date or
6
6
  // YYYY-MM-DD string). `new Date(explicit args)` is left untouched.
7
7
  //
@@ -110,82 +110,47 @@ function _resolveTemplate(template, src, pluginRootVar) {
110
110
 
111
111
  function build(src) {
112
112
  const { targets, vendors } = src;
113
- // Backward compat: if no `vendors` key, build from legacy targets
114
- if (!vendors || typeof vendors !== "object" || Object.keys(vendors).length === 0) {
115
- return buildLegacy(src);
116
- }
113
+ validateVendorRegistry(vendors);
117
114
  const outputs = [];
118
115
  for (const [vendorId, vendorDef] of Object.entries(vendors)) {
119
116
  const targetConfig = targets && targets[vendorId] ? targets[vendorId] : {};
120
117
  const pluginRootVar = targetConfig.pluginRootVar || "./";
121
- const vendorOutputs = vendorDef.outputs || [];
118
+ const vendorOutputs = vendorDef.outputs;
122
119
  for (const output of vendorOutputs) {
123
120
  const resolved = _resolveTemplate(output, src, pluginRootVar);
121
+ if (!isPlainObject(resolved) || typeof resolved.path !== "string" || !resolved.path.trim()) {
122
+ throw new Error(`vendor ${vendorId} has an output without a path`);
123
+ }
124
+ if (!isPlainObject(resolved.json)) {
125
+ throw new Error(`vendor ${vendorId} output ${resolved.path} has no JSON object`);
126
+ }
124
127
  outputs.push({
125
- path: typeof resolved.path === "string" ? resolved.path : `vendor-${vendorId}-${outputs.length}`,
126
- json: resolved.json || {}
128
+ path: resolved.path,
129
+ json: resolved.json
127
130
  });
128
131
  }
129
132
  }
130
133
  return outputs;
131
134
  }
132
135
 
133
- /** Legacy path: build from hardcoded shapes (backward compat if no `vendors` key). */
134
- function buildLegacy(src) {
135
- const { identity, descriptions, interface: ui, layout, mcp, targets } = src;
136
- const outputs = [];
137
-
138
- if (targets && targets.claude) {
139
- outputs.push({
140
- path: targets.claude.marketplace,
141
- json: { name: identity.name, owner: identity.author, metadata: { description: descriptions.standard, version: identity.version }, plugins: [{ name: identity.name, source: layout.pluginPathFromRepoRoot, description: descriptions.standard, category: ui.category.toLowerCase() }] }
142
- });
143
- outputs.push({
144
- path: targets.claude.plugin,
145
- json: { name: identity.name, description: descriptions.standard, version: identity.version, author: identity.author, homepage: identity.homepage, license: identity.license, keywords: identity.keywords }
146
- });
147
- outputs.push({
148
- path: targets.claude.mcp,
149
- json: legacyMcpConfig(mcp, layout, targets.claude.pluginRootVar)
150
- });
151
- }
152
- if (targets && targets.codex) {
153
- outputs.push({
154
- path: targets.codex.marketplace,
155
- json: { name: identity.name, interface: { displayName: ui.displayName }, plugins: [{ name: identity.name, source: { source: "local", path: layout.pluginPathFromRepoRoot }, policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: ui.category }] }
156
- });
157
- outputs.push({
158
- path: targets.codex.plugin,
159
- json: { name: identity.name, version: identity.version, description: descriptions.standard, author: identity.author, keywords: identity.keywords, skills: layout.skillsDir, mcpServers: "./.codex-plugin/mcp.json", interface: { displayName: ui.displayName, shortDescription: descriptions.short, longDescription: descriptions.long, developerName: identity.author.name, category: ui.category, capabilities: ui.capabilities, brandColor: ui.brandColor, defaultPrompt: ui.defaultPrompt } }
160
- });
161
- outputs.push({
162
- path: targets.codex.mcp,
163
- json: legacyMcpConfig(mcp, layout, targets.codex.pluginRootVar)
164
- });
165
- }
166
- if (targets && targets.agents) {
167
- outputs.push({
168
- path: targets.agents.plugin,
169
- json: { name: identity.name, description: descriptions.standard, version: identity.version, author: identity.author, homepage: identity.homepage, license: identity.license, keywords: identity.keywords, skills: layout.skillsDir, mcpServers: "./.codex-plugin/mcp.json", interface: { displayName: ui.displayName, shortDescription: descriptions.short, longDescription: descriptions.long, developerName: identity.author.name, category: ui.category, capabilities: ui.capabilities, brandColor: ui.brandColor, defaultPrompt: ui.defaultPrompt } }
170
- });
171
- outputs.push({
172
- path: targets.agents.mcp,
173
- json: legacyMcpConfig(mcp, layout, targets.agents.pluginRootVar)
174
- });
175
- }
176
-
177
- return outputs;
136
+ function isPlainObject(value) {
137
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
178
138
  }
179
139
 
180
- function legacyMcpConfig(mcp, layout, pluginRootVar) {
181
- return {
182
- mcpServers: {
183
- [mcp.serverName]: {
184
- command: mcp.command,
185
- args: [`${pluginRootVar}${layout.mcpServerScript}`]
186
- }
140
+ /** The vendor table is required. A broken source must stop before the write
141
+ * loop; an old hard-coded shape is not a safe second source of truth. */
142
+ function validateVendorRegistry(vendors) {
143
+ if (!isPlainObject(vendors) || Object.keys(vendors).length === 0) {
144
+ throw new Error("manifest source needs a non-empty vendors object");
145
+ }
146
+ for (const [vendorId, vendorDef] of Object.entries(vendors)) {
147
+ if (!isPlainObject(vendorDef) || !Array.isArray(vendorDef.outputs) || vendorDef.outputs.length === 0) {
148
+ throw new Error(`vendor ${vendorId} needs a non-empty outputs array`);
149
+ }
150
+ for (const output of vendorDef.outputs) {
151
+ if (!isPlainObject(output)) throw new Error(`vendor ${vendorId} has an invalid output`);
187
152
  }
188
- };
153
+ }
189
154
  }
190
155
 
191
156
  function serialize(json) {
@@ -229,4 +194,10 @@ function main() {
229
194
  }
230
195
  }
231
196
 
232
- main();
197
+ try {
198
+ main();
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ process.stderr.write(`gen-manifests: ${message}\n`);
202
+ process.exitCode = 1;
203
+ }