cool-workflow 0.1.85 → 0.1.87

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 (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +53 -332
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/architecture-review-fast/workflow.js +2 -0
  7. package/apps/end-to-end-golden-path/app.json +1 -1
  8. package/apps/pr-review-fix-ci/app.json +1 -1
  9. package/apps/release-cut/app.json +1 -1
  10. package/apps/research-synthesis/app.json +1 -1
  11. package/dist/capability-core.js +131 -3
  12. package/dist/capability-registry.js +108 -50
  13. package/dist/cli/command-surface.js +83 -10
  14. package/dist/cli.js +2 -1
  15. package/dist/doctor.js +50 -5
  16. package/dist/execution-backend.js +8 -0
  17. package/dist/mcp/tool-call.js +428 -0
  18. package/dist/mcp/tool-definitions.js +1027 -0
  19. package/dist/mcp-surface.js +5 -1442
  20. package/dist/onramp.js +421 -0
  21. package/dist/operator-ux/format.js +21 -15
  22. package/dist/operator-ux.js +2 -1
  23. package/dist/orchestrator.js +190 -87
  24. package/dist/term.js +93 -0
  25. package/dist/version.js +1 -1
  26. package/dist/workbench-host.js +1 -8
  27. package/docs/agent-delegation-drive.7.md +45 -8
  28. package/docs/cli-mcp-parity.7.md +41 -6
  29. package/docs/contract-migration-tooling.7.md +6 -0
  30. package/docs/control-plane-scheduling.7.md +6 -0
  31. package/docs/dogfood-one-real-repo.7.md +9 -1
  32. package/docs/durable-state-and-locking.7.md +6 -0
  33. package/docs/evidence-adoption-reasoning-chain.7.md +6 -0
  34. package/docs/execution-backends.7.md +6 -0
  35. package/docs/getting-started.md +40 -2
  36. package/docs/index.md +51 -38
  37. package/docs/multi-agent-cli-mcp-surface.7.md +6 -0
  38. package/docs/multi-agent-eval-replay-harness.7.md +6 -0
  39. package/docs/multi-agent-operator-ux.7.md +6 -0
  40. package/docs/node-snapshot-diff-replay.7.md +6 -0
  41. package/docs/observability-cost-accounting.7.md +6 -0
  42. package/docs/project-index.md +24 -7
  43. package/docs/real-execution-backends.7.md +6 -0
  44. package/docs/release-and-migration.7.md +7 -1
  45. package/docs/release-history.md +352 -0
  46. package/docs/release-tooling.7.md +28 -0
  47. package/docs/run-registry-control-plane.7.md +6 -0
  48. package/docs/run-retention-reclamation.7.md +6 -0
  49. package/docs/state-explosion-management.7.md +6 -0
  50. package/docs/team-collaboration.7.md +6 -0
  51. package/docs/web-desktop-workbench.7.md +6 -0
  52. package/manifest/plugin.manifest.json +1 -1
  53. package/manifest/source-context-profiles.json +1 -1
  54. package/package.json +2 -1
  55. package/scripts/agents/agent-adapter-core.js +180 -0
  56. package/scripts/agents/builtin-templates.json +4 -1
  57. package/scripts/agents/codex-agent.js +134 -0
  58. package/scripts/agents/gemini-agent.js +115 -0
  59. package/scripts/agents/opencode-agent.js +119 -0
  60. package/scripts/architecture-review-fast.js +44 -2
  61. package/scripts/canonical-apps.js +4 -4
  62. package/scripts/dogfood-release.js +55 -133
  63. package/scripts/golden-path.js +4 -4
  64. package/scripts/onramp-check.js +47 -0
  65. package/scripts/parity-check.js +741 -2
  66. package/scripts/release-check.js +5 -3
  67. package/scripts/release-gate.sh +1 -1
  68. package/scripts/source-context.js +31 -5
  69. package/scripts/version-sync-check.js +4 -2
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+
6
+ const RESULT_CONTRACT = `
7
+ === HOW TO RETURN YOUR ANSWER (overrides any 'write to result.md' instruction above) ===
8
+ You have NO file-write access. Do NOT attempt to write, create, or edit any file -
9
+ result.md is persisted FOR YOU from your final message, so writing it yourself is
10
+ neither needed nor possible. Use ONLY read-only tools (read files, grep, list).
11
+ Respond with ONLY your FINAL answer as Markdown, and it MUST END WITH a fenced
12
+ cw:result block that EXACTLY follows this schema:
13
+
14
+ \`\`\`cw:result
15
+ {
16
+ "summary": "one-paragraph direct answer",
17
+ "findings": [
18
+ {
19
+ "id": "unique-kebab-id",
20
+ "title": "short risk title",
21
+ "severity": "P0",
22
+ "classification": "real",
23
+ "evidence": ["path/to/file.ts:42"]
24
+ }
25
+ ],
26
+ "evidence": ["path/to/file.ts:42", "path/to/other.ts:10"]
27
+ }
28
+ \`\`\`
29
+
30
+ HARD RULES (the result is REJECTED otherwise):
31
+ - Every object in "findings" MUST have a unique "id" (non-empty string).
32
+ - "classification", if present, MUST be one of: real, conditional, non-issue, unknown.
33
+ - Any finding with "severity" P0, P1, or P2 MUST include a NON-EMPTY "evidence" array.
34
+ - The top-level "evidence" array MUST be NON-EMPTY with REAL file:line locators from this repo.
35
+ - If you have no structured findings, use "findings": [] (empty) - never omit a finding's id.`;
36
+
37
+ function buildPrompt(inputPath) {
38
+ return `${fs.readFileSync(inputPath, "utf8")}\n${RESULT_CONTRACT}`;
39
+ }
40
+
41
+ function streamEnabled(env = process.env) {
42
+ return env.CW_AGENT_STREAM === "1" && env.CW_NO_STREAM !== "1";
43
+ }
44
+
45
+ function traceEnabled(env = process.env, stderr = process.stderr) {
46
+ return streamEnabled(env) && Boolean(stderr.isTTY);
47
+ }
48
+
49
+ function trace(line, env = process.env, stderr = process.stderr) {
50
+ if (!traceEnabled(env, stderr)) return;
51
+ stderr.write(`${line}\n`);
52
+ }
53
+
54
+ function shortText(value, max = 100) {
55
+ const text = String(value || "").replace(/\s+/g, " ").trim();
56
+ if (!text) return "";
57
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
58
+ }
59
+
60
+ function maybeObject(value) {
61
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
62
+ }
63
+
64
+ function firstString(...values) {
65
+ for (const value of values) if (typeof value === "string" && value.trim()) return value.trim();
66
+ return undefined;
67
+ }
68
+
69
+ function firstObject(...values) {
70
+ for (const value of values) {
71
+ const obj = maybeObject(value);
72
+ if (obj) return obj;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ function modelFromEvent(ev) {
78
+ const msg = maybeObject(ev.message);
79
+ return firstString(ev.model, ev.model_id, ev.modelId, msg && msg.model, ev.provider_model);
80
+ }
81
+
82
+ function usageFromEvent(ev) {
83
+ const msg = maybeObject(ev.message);
84
+ return firstObject(ev.usage, ev.token_usage, ev.tokenUsage, msg && msg.usage);
85
+ }
86
+
87
+ function toolNameFromEvent(ev) {
88
+ const item = maybeObject(ev.item);
89
+ const call = maybeObject(ev.tool_call || ev.toolCall);
90
+ return firstString(ev.name, ev.tool, ev.tool_name, ev.toolName, item && item.name, call && call.name);
91
+ }
92
+
93
+ function toolArgFromEvent(ev) {
94
+ const input = maybeObject(ev.input) || maybeObject(ev.arguments) || maybeObject(ev.args) || maybeObject(ev.item && ev.item.input);
95
+ if (!input) return "";
96
+ return firstString(input.file_path, input.path, input.pattern, input.command, input.query, input.url, input.cmd) || "";
97
+ }
98
+
99
+ function textFromEvent(ev) {
100
+ const delta = maybeObject(ev.delta);
101
+ const msg = maybeObject(ev.message);
102
+ return firstString(ev.text, ev.delta, ev.content, ev.output, delta && delta.text, msg && msg.content);
103
+ }
104
+
105
+ function renderJsonEvent(provider, ev, state) {
106
+ if (!ev || typeof ev !== "object") return;
107
+ const model = modelFromEvent(ev);
108
+ if (model && !state.model) state.model = model;
109
+ const usage = usageFromEvent(ev);
110
+ if (usage) state.usage = usage;
111
+
112
+ const type = String(ev.type || ev.event || ev.kind || "");
113
+ const toolName = toolNameFromEvent(ev);
114
+ if (toolName || /tool|command/i.test(type)) {
115
+ const arg = shortText(toolArgFromEvent(ev), 80);
116
+ trace(` -> ${toolName || type}${arg ? ` ${arg}` : ""}`);
117
+ return;
118
+ }
119
+
120
+ const text = textFromEvent(ev);
121
+ if (text && /assistant|message|delta|text|response|output/i.test(type || "text")) {
122
+ trace(` ${shortText(text, 240)}`);
123
+ } else if (/turn|step|summary|status/i.test(type)) {
124
+ const status = firstString(ev.status, ev.status_detail, ev.summary, ev.message);
125
+ if (status) trace(` . ${provider}: ${shortText(status, 160)}`);
126
+ }
127
+ }
128
+
129
+ function parseJsonLines(provider, chunk, state, onLine) {
130
+ state.buffer = `${state.buffer || ""}${chunk}`;
131
+ let nl;
132
+ while ((nl = state.buffer.indexOf("\n")) >= 0) {
133
+ const line = state.buffer.slice(0, nl).trim();
134
+ state.buffer = state.buffer.slice(nl + 1);
135
+ if (!line) continue;
136
+ if (onLine) onLine(line);
137
+ let ev;
138
+ try {
139
+ ev = JSON.parse(line);
140
+ } catch {
141
+ continue;
142
+ }
143
+ renderJsonEvent(provider, ev, state);
144
+ }
145
+ }
146
+
147
+ function flushJsonLines(provider, state, onLine) {
148
+ const line = String(state.buffer || "").trim();
149
+ state.buffer = "";
150
+ if (!line) return;
151
+ if (onLine) onLine(line);
152
+ try {
153
+ renderJsonEvent(provider, JSON.parse(line), state);
154
+ } catch {
155
+ /* ignore incomplete/non-JSON tail */
156
+ }
157
+ }
158
+
159
+ function writeResult(resultPath, resultText) {
160
+ if (typeof resultText !== "string" || !resultText.trim()) {
161
+ throw new Error("agent produced no final result");
162
+ }
163
+ fs.writeFileSync(resultPath, resultText, "utf8");
164
+ }
165
+
166
+ function emitReport(model, usage, resultText) {
167
+ process.stdout.write(JSON.stringify({ model, usage, result: resultText }));
168
+ }
169
+
170
+ module.exports = {
171
+ RESULT_CONTRACT,
172
+ buildPrompt,
173
+ streamEnabled,
174
+ traceEnabled,
175
+ trace,
176
+ parseJsonLines,
177
+ flushJsonLines,
178
+ writeResult,
179
+ emitReport
180
+ };
@@ -2,6 +2,9 @@
2
2
  "schemaVersion": 1,
3
3
  "comment": "Builtin agent-delegation templates as DATA, not a hand-edited kernel TS literal (FreeBSD-audit L15). Each entry maps a vendor name to the wrapper script in THIS directory that `builtin:<name>` (or CW_AGENT_COMMAND=builtin:<name>) resolves to. Adding a vendor is a content/distribution step: drop a wrapper script here + add a line below — NO kernel edit. Still pure config: the wrapper is an out-of-process delegation script; CW never imports or calls a model API.",
4
4
  "templates": {
5
- "claude": "claude-p-agent.js"
5
+ "claude": "claude-p-agent.js",
6
+ "codex": "codex-agent.js",
7
+ "gemini": "gemini-agent.js",
8
+ "opencode": "opencode-agent.js"
6
9
  }
7
10
  }
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // codex-agent.js - Codex CLI adapter for CW Agent Delegation Drive.
5
+ //
6
+ // This is a CONFIG wrapper, not a CW runtime dependency. CW spawns this script
7
+ // out-of-process; this script spawns `codex exec` out-of-process. Vendor JSONL
8
+ // parsing stays here in userland policy. CW core only captures stdout and, when
9
+ // opted in, forwards stderr.
10
+ //
11
+ // Contract:
12
+ // argv[2] = {{input}} worker input.md
13
+ // argv[3] = {{result}} worker result.md to persist
14
+ //
15
+ // stdout: one JSON object { model, usage, result } for CW provenance.
16
+ // stderr: optional live trace when CW_AGENT_STREAM=1 and attached to a TTY.
17
+
18
+ const fs = require("node:fs");
19
+ const path = require("node:path");
20
+ const { spawn } = require("node:child_process");
21
+ const {
22
+ buildPrompt,
23
+ emitReport,
24
+ flushJsonLines,
25
+ parseJsonLines,
26
+ trace,
27
+ writeResult
28
+ } = require("./agent-adapter-core");
29
+
30
+ const inputPath = process.argv[2];
31
+ const resultPath = process.argv[3];
32
+ if (!inputPath || !resultPath) {
33
+ process.stderr.write("usage: codex-agent.js <inputPath> <resultPath> (CW substitutes {{input}} {{result}})\n");
34
+ process.exit(2);
35
+ }
36
+
37
+ const finalPath = path.join(path.dirname(resultPath), `.codex-last-message-${process.pid}.md`);
38
+ try {
39
+ fs.rmSync(finalPath, { force: true });
40
+ } catch {
41
+ /* best effort */
42
+ }
43
+
44
+ const prompt = buildPrompt(inputPath);
45
+ const state = { provider: "codex", buffer: "", model: undefined, usage: undefined };
46
+ const capturedStdout = [];
47
+ let childStderr = "";
48
+ function recordJsonLine(line) {
49
+ capturedStdout.push(line);
50
+ try {
51
+ JSON.parse(line);
52
+ } catch {
53
+ state.invalidJson = true;
54
+ }
55
+ }
56
+
57
+ trace("* codex: reading the repo (read-only)...");
58
+
59
+ const args = [
60
+ "exec",
61
+ "--json",
62
+ "--output-last-message",
63
+ finalPath,
64
+ "--sandbox",
65
+ "read-only",
66
+ "--ask-for-approval",
67
+ "never",
68
+ "--color",
69
+ "never",
70
+ "-"
71
+ ];
72
+
73
+ const child = spawn("codex", args, {
74
+ stdio: ["pipe", "pipe", "pipe"],
75
+ shell: false
76
+ });
77
+
78
+ child.stdin.setDefaultEncoding("utf8");
79
+ child.stdin.end(prompt);
80
+
81
+ child.stdout.setEncoding("utf8");
82
+ child.stdout.on("data", (chunk) => {
83
+ parseJsonLines("codex", chunk, state, recordJsonLine);
84
+ });
85
+
86
+ child.stderr.setEncoding("utf8");
87
+ child.stderr.on("data", (chunk) => {
88
+ childStderr += chunk;
89
+ if (process.env.CW_AGENT_STREAM === "1" && process.env.CW_NO_STREAM !== "1" && process.stderr.isTTY) {
90
+ process.stderr.write(chunk);
91
+ }
92
+ });
93
+
94
+ child.on("error", (error) => {
95
+ process.stderr.write(`codex spawn failed: ${error.message}\n`);
96
+ process.exit(1);
97
+ });
98
+
99
+ child.on("close", (code) => {
100
+ flushJsonLines("codex", state, recordJsonLine);
101
+ if (code !== 0) {
102
+ const detail = childStderr.trim() || `codex exited ${code === null ? "(timeout/killed)" : code}`;
103
+ process.stderr.write(`${detail}\n`);
104
+ process.exit(code === null ? 1 : code);
105
+ }
106
+ if (state.invalidJson) {
107
+ process.stderr.write("codex --json produced a non-JSONL stdout line - refusing to trust the result\n");
108
+ process.exit(1);
109
+ }
110
+
111
+ let resultText = "";
112
+ try {
113
+ resultText = fs.readFileSync(finalPath, "utf8");
114
+ } catch {
115
+ process.stderr.write("codex produced no final output file - refusing to fabricate a result\n");
116
+ process.exit(1);
117
+ } finally {
118
+ try {
119
+ fs.rmSync(finalPath, { force: true });
120
+ } catch {
121
+ /* best effort */
122
+ }
123
+ }
124
+
125
+ try {
126
+ writeResult(resultPath, resultText);
127
+ } catch (error) {
128
+ process.stderr.write(`codex produced no final result: ${error.message}\n`);
129
+ process.exit(1);
130
+ }
131
+
132
+ trace("* done - result captured");
133
+ emitReport(state.model, state.usage, resultText);
134
+ });
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // gemini-agent.js - Gemini CLI adapter for CW Agent Delegation Drive.
5
+ //
6
+ // This is a CONFIG wrapper, not a CW runtime dependency. CW spawns this script
7
+ // out-of-process; this script spawns `gemini -p` out-of-process. Vendor NDJSON
8
+ // parsing stays here in userland policy.
9
+ //
10
+ // Contract:
11
+ // argv[2] = {{input}} worker input.md
12
+ // argv[3] = {{result}} worker result.md to persist
13
+ //
14
+ // stdout: one JSON object { model, usage, result } for CW provenance.
15
+ // stderr: optional live trace when CW_AGENT_STREAM=1 and attached to a TTY.
16
+
17
+ const { spawn } = require("node:child_process");
18
+ const {
19
+ buildPrompt,
20
+ emitReport,
21
+ flushJsonLines,
22
+ parseJsonLines,
23
+ trace,
24
+ writeResult
25
+ } = require("./agent-adapter-core");
26
+
27
+ const inputPath = process.argv[2];
28
+ const resultPath = process.argv[3];
29
+ if (!inputPath || !resultPath) {
30
+ process.stderr.write("usage: gemini-agent.js <inputPath> <resultPath> (CW substitutes {{input}} {{result}})\n");
31
+ process.exit(2);
32
+ }
33
+
34
+ const prompt = buildPrompt(inputPath);
35
+ const state = { provider: "gemini", buffer: "", model: undefined, usage: undefined, textFragments: [], finalResult: undefined };
36
+ let childStderr = "";
37
+ function recordJsonLine(line) {
38
+ let ev;
39
+ try {
40
+ ev = JSON.parse(line);
41
+ } catch {
42
+ state.invalidJson = true;
43
+ return;
44
+ }
45
+ // Prefer the final result event text; delta events are incremental fallback.
46
+ if (ev.result && typeof ev.result === "string") {
47
+ state.finalResult = ev.result;
48
+ } else {
49
+ const text = typeof ev.text === "string" ? ev.text : (ev.delta ? (typeof ev.delta === "string" ? ev.delta : ev.delta.text) : undefined);
50
+ if (typeof text === "string" && text.trim()) state.textFragments.push(text);
51
+ }
52
+ }
53
+
54
+ trace("* gemini: reading the repo (read-only)...");
55
+
56
+ const args = [
57
+ "-p",
58
+ prompt,
59
+ "--output-format",
60
+ "stream-json",
61
+ "--approval-mode",
62
+ "plan"
63
+ ];
64
+
65
+ const child = spawn("gemini", args, {
66
+ stdio: ["ignore", "pipe", "pipe"],
67
+ shell: false
68
+ });
69
+
70
+ child.stdout.setEncoding("utf8");
71
+ child.stdout.on("data", (chunk) => {
72
+ parseJsonLines("gemini", chunk, state, recordJsonLine);
73
+ });
74
+
75
+ child.stderr.setEncoding("utf8");
76
+ child.stderr.on("data", (chunk) => {
77
+ childStderr += chunk;
78
+ if (process.env.CW_AGENT_STREAM === "1" && process.env.CW_NO_STREAM !== "1" && process.stderr.isTTY) {
79
+ process.stderr.write(chunk);
80
+ }
81
+ });
82
+
83
+ child.on("error", (error) => {
84
+ process.stderr.write(`gemini spawn failed: ${error.message}\n`);
85
+ process.exit(1);
86
+ });
87
+
88
+ child.on("close", (code) => {
89
+ flushJsonLines("gemini", state, recordJsonLine);
90
+ if (code !== 0) {
91
+ const detail = childStderr.trim() || `gemini exited ${code === null ? "(timeout/killed)" : code}`;
92
+ process.stderr.write(`${detail}\n`);
93
+ process.exit(code === null ? 1 : code);
94
+ }
95
+ if (state.invalidJson) {
96
+ process.stderr.write("gemini --output-format stream-json produced a non-JSONL stdout line - refusing to trust the result\n");
97
+ process.exit(1);
98
+ }
99
+
100
+ const resultText = state.finalResult || state.textFragments.join("\n\n");
101
+ if (!resultText.trim()) {
102
+ process.stderr.write("gemini produced no result text - refusing to fabricate a result\n");
103
+ process.exit(1);
104
+ }
105
+
106
+ try {
107
+ writeResult(resultPath, resultText);
108
+ } catch (error) {
109
+ process.stderr.write(`gemini produced no final result: ${error.message}\n`);
110
+ process.exit(1);
111
+ }
112
+
113
+ trace("* done - result captured");
114
+ emitReport(state.model, state.usage, resultText);
115
+ });
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // opencode-agent.js - OpenCode CLI adapter for CW Agent Delegation Drive.
5
+ //
6
+ // This is a CONFIG wrapper, not a CW runtime dependency. CW spawns this script
7
+ // out-of-process; this script spawns `opencode run` out-of-process. Vendor JSONL
8
+ // parsing stays here in userland policy.
9
+ //
10
+ // Contract:
11
+ // argv[2] = {{input}} worker input.md
12
+ // argv[3] = {{result}} worker result.md to persist
13
+ //
14
+ // stdout: one JSON object { model, usage, result } for CW provenance.
15
+ // stderr: optional live trace when CW_AGENT_STREAM=1 and attached to a TTY.
16
+ //
17
+ // NOTE: --dangerously-skip-permissions is used because OpenCode lacks a native
18
+ // --read-only or --allowed-tools flag. CW's sandbox layer enforces write safety
19
+ // via execution-backend boundary controls. If OpenCode adds a cleaner read-only
20
+ // flag, prefer that here.
21
+
22
+ const { spawn } = require("node:child_process");
23
+ const {
24
+ buildPrompt,
25
+ emitReport,
26
+ flushJsonLines,
27
+ parseJsonLines,
28
+ trace,
29
+ writeResult
30
+ } = require("./agent-adapter-core");
31
+
32
+ const inputPath = process.argv[2];
33
+ const resultPath = process.argv[3];
34
+ if (!inputPath || !resultPath) {
35
+ process.stderr.write("usage: opencode-agent.js <inputPath> <resultPath> (CW substitutes {{input}} {{result}})\n");
36
+ process.exit(2);
37
+ }
38
+
39
+ const prompt = buildPrompt(inputPath);
40
+ const state = { provider: "opencode", buffer: "", model: undefined, usage: undefined, textFragments: [], finalResult: undefined };
41
+ let childStderr = "";
42
+ function recordJsonLine(line) {
43
+ let ev;
44
+ try {
45
+ ev = JSON.parse(line);
46
+ } catch {
47
+ state.invalidJson = true;
48
+ return;
49
+ }
50
+ if (ev.result && typeof ev.result === "string") {
51
+ state.finalResult = ev.result;
52
+ } else {
53
+ const text = typeof ev.text === "string" ? ev.text : (ev.delta ? (typeof ev.delta === "string" ? ev.delta : ev.delta.text) : undefined);
54
+ if (typeof text === "string" && text.trim()) state.textFragments.push(text);
55
+ }
56
+ }
57
+
58
+ trace("* opencode: reading the repo...");
59
+
60
+ const args = [
61
+ "run",
62
+ "--format",
63
+ "json",
64
+ "--dangerously-skip-permissions",
65
+ "--prompt",
66
+ prompt
67
+ ];
68
+
69
+ const child = spawn("opencode", args, {
70
+ stdio: ["ignore", "pipe", "pipe"],
71
+ shell: false
72
+ });
73
+
74
+ child.stdout.setEncoding("utf8");
75
+ child.stdout.on("data", (chunk) => {
76
+ parseJsonLines("opencode", chunk, state, recordJsonLine);
77
+ });
78
+
79
+ child.stderr.setEncoding("utf8");
80
+ child.stderr.on("data", (chunk) => {
81
+ childStderr += chunk;
82
+ if (process.env.CW_AGENT_STREAM === "1" && process.env.CW_NO_STREAM !== "1" && process.stderr.isTTY) {
83
+ process.stderr.write(chunk);
84
+ }
85
+ });
86
+
87
+ child.on("error", (error) => {
88
+ process.stderr.write(`opencode spawn failed: ${error.message}\n`);
89
+ process.exit(1);
90
+ });
91
+
92
+ child.on("close", (code) => {
93
+ flushJsonLines("opencode", state, recordJsonLine);
94
+ if (code !== 0) {
95
+ const detail = childStderr.trim() || `opencode exited ${code === null ? "(timeout/killed)" : code}`;
96
+ process.stderr.write(`${detail}\n`);
97
+ process.exit(code === null ? 1 : code);
98
+ }
99
+ if (state.invalidJson) {
100
+ process.stderr.write("opencode --format json produced a non-JSONL stdout line - refusing to trust the result\n");
101
+ process.exit(1);
102
+ }
103
+
104
+ const resultText = state.finalResult || state.textFragments.join("\n\n");
105
+ if (!resultText.trim()) {
106
+ process.stderr.write("opencode produced no result text - refusing to fabricate a result\n");
107
+ process.exit(1);
108
+ }
109
+
110
+ try {
111
+ writeResult(resultPath, resultText);
112
+ } catch (error) {
113
+ process.stderr.write(`opencode produced no final result: ${error.message}\n`);
114
+ process.exit(1);
115
+ }
116
+
117
+ trace("* done - result captured");
118
+ emitReport(state.model, state.usage, resultText);
119
+ });
@@ -308,11 +308,13 @@ function buildMetrics(started, contextText, sourceContextElapsedMs, fastReview,
308
308
  const steps = Array.isArray(fastReview?.steps) ? fastReview.steps : [];
309
309
  const handleKinds = countBy(steps.map((step) => step && step.handleKind).filter(Boolean));
310
310
  const actions = countBy(steps.map((step) => step && step.action).filter(Boolean));
311
+ const taskMetrics = buildTaskMetrics(fastReview, steps);
311
312
  return {
312
313
  totalElapsedMs: elapsedMs(started),
313
314
  sourceContext: {
314
315
  elapsedMs: sourceContextElapsedMs,
315
- bytes: Buffer.byteLength(contextText, "utf8")
316
+ bytes: Buffer.byteLength(contextText, "utf8"),
317
+ digest: `sha256:${crypto.createHash("sha256").update(contextText, "utf8").digest("hex")}`
316
318
  },
317
319
  fastReview: {
318
320
  elapsedMs: fastReviewElapsedMs,
@@ -323,12 +325,52 @@ function buildMetrics(started, contextText, sourceContextElapsedMs, fastReview,
323
325
  actions,
324
326
  handleKinds,
325
327
  resultCacheHits: Number(handleKinds["result-cache"] || 0),
326
- agentSpawns: steps.filter((step) => step && step.backendId === "agent" && step.handleKind && step.handleKind !== "result-cache").length
328
+ agentSpawns: steps.filter((step) => step && step.backendId === "agent" && step.handleKind && step.handleKind !== "result-cache").length,
329
+ taskMetrics
327
330
  },
328
331
  ...(fullReviewScheduleElapsedMs === undefined ? {} : { fullReviewSchedule: { elapsedMs: fullReviewScheduleElapsedMs } })
329
332
  };
330
333
  }
331
334
 
335
+ function buildTaskMetrics(fastReview, steps) {
336
+ const statePath = stringArg(fastReview?.statePath);
337
+ const run = readRunState(statePath);
338
+ const tasks = Array.isArray(run?.tasks) ? run.tasks : [];
339
+ const byTask = new Map(tasks.map((task) => [task.id, task]));
340
+ return steps
341
+ .filter((step) => step && step.taskId)
342
+ .map((step) => {
343
+ const task = byTask.get(step.taskId) || {};
344
+ return {
345
+ phase: step.phase || task.phase || "",
346
+ taskId: step.taskId,
347
+ action: step.action,
348
+ status: step.status,
349
+ elapsedMs: taskElapsedMs(task),
350
+ handleKind: step.handleKind || "",
351
+ agentSpawned: Boolean(step.backendId === "agent" && step.handleKind && step.handleKind !== "result-cache"),
352
+ resultCacheHit: step.handleKind === "result-cache",
353
+ reportedModel: step.reportedModel || ""
354
+ };
355
+ });
356
+ }
357
+
358
+ function readRunState(statePath) {
359
+ if (!statePath) return null;
360
+ try {
361
+ return JSON.parse(fs.readFileSync(statePath, "utf8"));
362
+ } catch {
363
+ return null;
364
+ }
365
+ }
366
+
367
+ function taskElapsedMs(task) {
368
+ const started = Date.parse(task?.startedAt || task?.dispatchedAt || "");
369
+ const completed = Date.parse(task?.completedAt || "");
370
+ if (!Number.isFinite(started) || !Number.isFinite(completed)) return null;
371
+ return Math.max(0, completed - started);
372
+ }
373
+
332
374
  function countBy(values) {
333
375
  const counts = {};
334
376
  for (const value of values) counts[String(value)] = (counts[String(value)] || 0) + 1;
@@ -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.1.85",
86
+ "Cool Workflow v0.1.87",
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.1.85");
120
+ assert.equal(summary.version, "0.1.87");
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.1.85");
127
+ assert.equal(shown.app.version, "0.1.87");
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.1.85");
138
+ assert.equal(state.workflow.app.version, "0.1.87");
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`);