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
@@ -32,6 +32,37 @@ runner owns state transitions, dispatch, result recording, verifier gates,
32
32
  commits, and reports. A workflow app owns its own inputs, phases, task
33
33
  prompts, evidence needs, and sandbox profile hints.
34
34
 
35
+ Every CW workflow keeps to one loop, and the loop maps to real framework
36
+ operations:
37
+
38
+ | Loop stage | framework operation | Responsibility |
39
+ | --- | --- | --- |
40
+ | Interpret | `plan()` | Load workflow, check inputs, make tasks |
41
+ | Act | `dispatch()` | Move tasks that can run from pending to running |
42
+ | Observe | `recordResult()` | Read Markdown/JSON-RPC result evidence |
43
+ | Adjust | verifier gates | Check evidence and pick the next phase |
44
+ | Checkpoint | `commitState()` | Take a snapshot of state after important changes |
45
+
46
+ The language split is on purpose: the platform is TypeScript
47
+ (`src/*.ts -> dist/*.js`, strongly typed so it is simple to keep up), while
48
+ workflow apps are plain JavaScript modules (`apps/<app-id>/workflow.js`,
49
+ legacy `workflows/*.workflow.js`) so workflow scripts run without `ts-node`.
50
+
51
+ Verification and verdict tasks give back a `cw:result` JSON fence:
52
+
53
+ ````text
54
+ ```cw:result
55
+ {
56
+ "summary": "short summary",
57
+ "findings": [],
58
+ "evidence": ["/absolute/path/file.ts:42"]
59
+ }
60
+ ```
61
+ ````
62
+
63
+ CW says no to high-priority findings without evidence. This keeps agent work
64
+ nearer to engineering output you can look into than to free talk.
65
+
35
66
  The framework is kept small on purpose. The public app helpers are:
36
67
 
37
68
  - `defineWorkflowApp(definition)`
@@ -2,7 +2,7 @@
2
2
  "_comment": "SINGLE SOURCE OF TRUTH for every vendor manifest. Edit THIS file, then run `npm run gen:manifests`. Do NOT hand-edit the generated vendor manifests (.claude-plugin/, .codex-plugin/, .agents/, .mcp.json) — `npm run gen:manifests -- --check` (run by release:check) will fail if they drift from this source.",
3
3
  "identity": {
4
4
  "name": "cool-workflow",
5
- "version": "0.2.3",
5
+ "version": "0.2.5",
6
6
  "license": "BSD-2-Clause",
7
7
  "homepage": "https://github.com/coo1white/cool-workflow",
8
8
  "author": {
@@ -3,7 +3,7 @@
3
3
  "profiles": {
4
4
  "core": {
5
5
  "description": "Default AI source context: runtime source, apps, package metadata, and agent wrappers; generated artifacts, tests, docs, release records, and long logs are manifest-only.",
6
- "maxLines": 50000,
6
+ "maxLines": 52000,
7
7
  "include": [
8
8
  "plugins/cool-workflow/src/**",
9
9
  "plugins/cool-workflow/apps/**",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "runtime": {
27
27
  "description": "Runtime-kernel source context for state, orchestration, scheduling, execution, and shared types.",
28
- "maxLines": 48000,
28
+ "maxLines": 49000,
29
29
  "include": [
30
30
  "plugins/cool-workflow/src/**",
31
31
  "plugins/cool-workflow/package.json",
@@ -95,7 +95,8 @@
95
95
  "include": [
96
96
  "AGENTS.md",
97
97
  "plugins/cool-workflow/scripts/release-flow.js",
98
- "plugins/cool-workflow/scripts/release-gate.sh",
98
+ "plugins/cool-workflow/scripts/release-gate.js",
99
+ "plugins/cool-workflow/scripts/release-tags.js",
99
100
  "plugins/cool-workflow/scripts/dogfood-release.js",
100
101
  "plugins/cool-workflow/scripts/gen-manifests.js",
101
102
  "plugins/cool-workflow/scripts/version-sync-check.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cool-workflow",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "bin": {
5
5
  "cool-workflow": "scripts/cw.js",
6
6
  "cw": "scripts/cw.js"
@@ -26,6 +26,8 @@
26
26
  "files": [
27
27
  ".claude-plugin/",
28
28
  ".codex-plugin/",
29
+ ".gemini-plugin/",
30
+ ".opencode-plugin/",
29
31
  ".mcp.json",
30
32
  "manifest/",
31
33
  "README.md",
@@ -44,6 +46,7 @@
44
46
  "check": "tsc -p tsconfig.json --noEmit",
45
47
  "dist:check": "node scripts/dist-drift-check.js",
46
48
  "purity:check": "node scripts/purity-gate.js",
49
+ "lang:check": "node scripts/lang-policy-check.js",
47
50
  "golden-path": "node scripts/golden-path.js",
48
51
  "dogfood:release": "node scripts/dogfood-release.js",
49
52
  "canonical-apps": "node scripts/canonical-apps.js",
@@ -55,7 +58,6 @@
55
58
  "bump:version": "node scripts/bump-version.js",
56
59
  "new:feature": "node scripts/new-feature.js",
57
60
  "forward-ref": "node scripts/forward-ref-docs.js",
58
- "verify:container": "node scripts/verify-container-selfref.js",
59
61
  "gen:manifests": "node scripts/gen-manifests.js",
60
62
  "gen:parity": "node scripts/gen-parity-doc.js",
61
63
  "manifest:load-check": "node test/vendor-manifest-load-smoke.js",
@@ -63,6 +65,7 @@
63
65
  "onramp:check": "node scripts/onramp-check.js --check",
64
66
  "version:sync": "node scripts/version-sync-check.js",
65
67
  "release:check": "node scripts/release-check.js",
68
+ "release": "node scripts/release-oneclick.js",
66
69
  "test": "node dist/cli.js version > /dev/null && node test/run-all.js --fast --sample 35",
67
70
  "test:full": "node dist/cli.js version > /dev/null && node test/run-all.js --sample 55",
68
71
  "test:gate": "node dist/cli.js version > /dev/null && node test/run-all.js",
@@ -490,8 +490,34 @@ function buildFailureDetail({ label, code, childStderr, partialText }) {
490
490
  return codeText;
491
491
  }
492
492
 
493
+ // CW may set CW_AGENT_VENDOR_PIDFILE when it runs a shipped wrapper. We write
494
+ // the vendor child's PID there so CW can reap the vendor if it has to SIGKILL
495
+ // this wrapper on a timeout: a SIGKILL is uncatchable, so the wrapper cannot
496
+ // forward the stop to its vendor child itself, and the vendor would otherwise
497
+ // live on as an orphan and keep spending (see execution-backend/agent.ts). The
498
+ // file is removed as soon as the vendor exits, so a stale PID is never reaped.
499
+ // Best-effort: fs errors are ignored -- reaping is a safety net, not a
500
+ // correctness dependency -- and an arbitrary CW_AGENT_COMMAND that never calls
501
+ // this is simply not covered.
502
+ function recordVendorPid(child, env = process.env) {
503
+ const pidfile = env.CW_AGENT_VENDOR_PIDFILE;
504
+ if (!pidfile || !child || !child.pid) return child;
505
+ try { fs.writeFileSync(pidfile, String(child.pid), "utf8"); } catch { /* best-effort */ }
506
+ let cleared = false;
507
+ const clear = () => {
508
+ if (cleared) return;
509
+ cleared = true;
510
+ try { fs.unlinkSync(pidfile); } catch { /* already gone */ }
511
+ };
512
+ child.once("close", clear);
513
+ child.once("exit", clear);
514
+ process.once("exit", clear);
515
+ return child;
516
+ }
517
+
493
518
  module.exports = {
494
519
  RESULT_CONTRACT,
520
+ recordVendorPid, // write the vendor child's PID to CW_AGENT_VENDOR_PIDFILE so cw can reap it on a timeout
495
521
  buildPrompt,
496
522
  streamEnabled,
497
523
  traceEnabled,
@@ -34,7 +34,7 @@ const { spawn, spawnSync } = require("node:child_process");
34
34
  // wrappers instead of carrying a private copy. A drifted inline copy (ASCII
35
35
  // hyphens silently became em-dashes here) meant claude was sent a different
36
36
  // instruction text than the other providers for the same contract.
37
- const { buildPrompt, createRenderer, persistStderr, toolLabel, summarizeToolResult, buildFailureDetail } = require("./agent-adapter-core");
37
+ const { buildPrompt, createRenderer, persistStderr, toolLabel, summarizeToolResult, buildFailureDetail, recordVendorPid } = require("./agent-adapter-core");
38
38
 
39
39
  const inputPath = process.argv[2];
40
40
  const resultPath = process.argv[3];
@@ -103,6 +103,9 @@ const child = spawn(
103
103
  ["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", "Read,Grep,Glob"],
104
104
  { stdio: ["ignore", "pipe", "pipe"] }
105
105
  );
106
+ // Record the vendor PID so cw can reap this claude process if it SIGKILLs the
107
+ // wrapper on a timeout (see agent-adapter-core recordVendorPid).
108
+ recordVendorPid(child);
106
109
 
107
110
  let model;
108
111
  let usage;
@@ -43,6 +43,7 @@ const {
43
43
  flushJsonLines,
44
44
  parseJsonLines,
45
45
  persistStderr,
46
+ recordVendorPid,
46
47
  writeResult
47
48
  } = require("./agent-adapter-core");
48
49
 
@@ -133,6 +134,9 @@ const child = spawn("codex", args, {
133
134
  stdio: ["pipe", "pipe", "pipe"],
134
135
  shell: false
135
136
  });
137
+ // Record the vendor PID so cw can reap this codex process if it SIGKILLs the
138
+ // wrapper on a timeout (see agent-adapter-core recordVendorPid).
139
+ recordVendorPid(child);
136
140
 
137
141
  child.stdin.setDefaultEncoding("utf8");
138
142
  child.stdin.end(prompt);
@@ -20,7 +20,7 @@
20
20
  // Point CW at it (from plugins/cool-workflow/), keeping your inner agent intact:
21
21
  // CW_AGENT_ATTEST_PRIVKEY=$PWD/cw-attest.key \
22
22
  // CW_AGENT_COMMAND="node $PWD/scripts/agents/cw-attest-wrap.js --manifest {{manifest}} -- \
23
- // bash $PWD/scripts/agents/claude-p-agent.sh {{input}} {{result}}"
23
+ // node $PWD/scripts/agents/claude-p-agent.js {{input}} {{result}}"
24
24
  // and configure CW's verify side: CW_AGENT_ATTEST_PUBKEY=$PWD/cw-attest.pub
25
25
  //
26
26
  // Honest posture: if no private key is set, or the agent reports no usage, the
@@ -24,6 +24,7 @@ const {
24
24
  flushJsonLines,
25
25
  parseJsonLines,
26
26
  persistStderr,
27
+ recordVendorPid,
27
28
  writeResult
28
29
  } = require("./agent-adapter-core");
29
30
 
@@ -71,6 +72,9 @@ const child = spawn("gemini", args, {
71
72
  stdio: ["ignore", "pipe", "pipe"],
72
73
  shell: false
73
74
  });
75
+ // Record the vendor PID so cw can reap this gemini process if it SIGKILLs the
76
+ // wrapper on a timeout (see agent-adapter-core recordVendorPid).
77
+ recordVendorPid(child);
74
78
 
75
79
  child.stdout.setEncoding("utf8");
76
80
  child.stdout.on("data", (chunk) => {
@@ -29,6 +29,7 @@ const {
29
29
  flushJsonLines,
30
30
  parseJsonLines,
31
31
  persistStderr,
32
+ recordVendorPid,
32
33
  writeResult
33
34
  } = require("./agent-adapter-core");
34
35
 
@@ -124,6 +125,10 @@ const child = spawn("opencode", args, {
124
125
  stdio: ["ignore", "pipe", "pipe"],
125
126
  shell: false
126
127
  });
128
+ // Record the vendor PID so cw can reap this opencode process (also used by the
129
+ // deepseek wrapper, which re-exports this file) if it SIGKILLs the wrapper on a
130
+ // timeout (see agent-adapter-core recordVendorPid).
131
+ recordVendorPid(child);
127
132
 
128
133
  child.stdout.setEncoding("utf8");
129
134
  child.stdout.on("data", (chunk) => {
@@ -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
+ });
@@ -77,12 +77,18 @@ function main() {
77
77
 
78
78
  const current = JSON.parse(fs.readFileSync(path.join(pluginRoot, "package.json"), "utf8")).version;
79
79
  if (next === current) {
80
- // Idempotent: re-running the bump for the same version is a no-op (exit 0)
81
- // rather than a hard fail. Required by `release-flow --cut --version X` when
82
- // the bump was already landed in a prior PR (e.g. a release-prep PR that
83
- // committed the version surfaces, then a follow-up fix on top cut should
84
- // still be able to commit the verdict and tag without re-bumping).
85
- process.stdout.write(`bump:version: already at ${next}, no surfaces to update\n`);
80
+ // Idempotent for the STRUCTURED surfaces: re-running the bump for the same
81
+ // version skips the writes (exit 0) rather than hard-failing. Required by
82
+ // `release-flow --cut --version X` when the bump was already landed in a
83
+ // prior PR cut should still be able to commit the verdict and tag
84
+ // without re-bumping. But the CONTENT-surface gate must still run: the
85
+ // v0.2.3 cut hit exactly this hole package.json was already bumped, so
86
+ // this early-exit skipped the content check entirely and `--content`
87
+ // never filled the missing docs/RELEASE references. Same-version reruns
88
+ // now gate (or fill) content exactly like a fresh bump does.
89
+ process.stdout.write(`bump:version: structured surfaces already at ${next}\n`);
90
+ const sameVersionContent = handleContentSurfaces(current, next);
91
+ if (!sameVersionContent.ok) fail(sameVersionContent.error);
86
92
  process.exit(0);
87
93
  }
88
94
 
@@ -161,9 +167,11 @@ function main() {
161
167
  // and a RegExp string (`0\\.1\\.32`, from `new RegExp(\`...@0\\.1\\.32\`)`).
162
168
  // A plain replace silently misses the escaped forms — the exact "escaped-dot"
163
169
  // surface that fails version:sync mid-release. Replace all three, most-escaped
164
- // first so the forms can never overlap.
165
- const esc1 = (v) => v.replace(/\./g, "\\.");
166
- const esc2 = (v) => v.replace(/\./g, "\\\\.");
170
+ // first so the forms can never overlap. No regex is ever built from these —
171
+ // the forms feed the plain split/join TEXT replacement below (which is also
172
+ // why split/join, not a regex-escape-shaped replace, builds them).
173
+ const esc1 = (v) => v.split(".").join("\\.");
174
+ const esc2 = (v) => v.split(".").join("\\\\.");
167
175
  const forms = [[esc2(current), esc2(next)], [esc1(current), esc1(next)], [current, next]];
168
176
  for (const rel of targeted) {
169
177
  const abs = path.join(pluginRoot, rel);
@@ -261,7 +269,16 @@ function handleContentSurfaces(current, next) {
261
269
  const abs = path.join(repoRoot, rel);
262
270
  if (!fs.existsSync(abs)) { missing.push({ rel, reason: "file missing" }); continue; }
263
271
  const text = fs.readFileSync(abs, "utf8");
264
- if (!text.includes(needle)) missing.push({ rel, needle });
272
+ // Own-line match, not a plain substring: every entry here is a line this
273
+ // function itself appends (`\n${needle}\n`) when missing, and a bare
274
+ // version number can otherwise coincidentally already appear in the
275
+ // doc's unrelated prose (an example command, a cross-reference) without
276
+ // the real version-history line actually being present. See
277
+ // version-sync-check.js's checkIncludesOwnLine for the matching CI-side
278
+ // gate (found live: v0.2.4's bump masked a real missing footer line in
279
+ // release-tooling.7.md this way).
280
+ const hasOwnLine = text.split("\n").some((line) => line.trim() === needle);
281
+ if (!hasOwnLine) missing.push({ rel, needle });
265
282
  }
266
283
 
267
284
  if (missing.length === 0) {
@@ -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.3",
86
+ "Cool Workflow v0.2.5",
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.3");
120
+ assert.equal(summary.version, "0.2.5");
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.3");
127
+ assert.equal(shown.app.version, "0.2.5");
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.3");
138
+ assert.equal(state.workflow.app.version, "0.2.5");
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
  });