cool-workflow 0.1.89 → 0.1.91

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 (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +42 -1
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/end-to-end-golden-path/app.json +1 -1
  7. package/apps/pr-review-fix-ci/app.json +1 -1
  8. package/apps/release-cut/app.json +1 -1
  9. package/apps/research-synthesis/app.json +1 -1
  10. package/dist/capability-core.js +151 -9
  11. package/dist/capability-registry.js +6 -0
  12. package/dist/cli/command-surface.js +143 -6
  13. package/dist/cli.js +26 -1
  14. package/dist/clones.js +162 -0
  15. package/dist/drive.js +37 -2
  16. package/dist/mcp/tool-call.js +4 -0
  17. package/dist/mcp/tool-definitions.js +5 -0
  18. package/dist/orchestrator/report.js +6 -0
  19. package/dist/orchestrator.js +15 -4
  20. package/dist/remote-source.js +444 -0
  21. package/dist/reporter.js +67 -0
  22. package/dist/term.js +127 -9
  23. package/dist/version.js +1 -1
  24. package/docs/agent-delegation-drive.7.md +41 -22
  25. package/docs/cli-mcp-parity.7.md +9 -2
  26. package/docs/contract-migration-tooling.7.md +4 -0
  27. package/docs/control-plane-scheduling.7.md +4 -0
  28. package/docs/durable-state-and-locking.7.md +4 -0
  29. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  30. package/docs/execution-backends.7.md +4 -0
  31. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  32. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  33. package/docs/multi-agent-operator-ux.7.md +4 -0
  34. package/docs/node-snapshot-diff-replay.7.md +4 -0
  35. package/docs/observability-cost-accounting.7.md +4 -0
  36. package/docs/project-index.md +15 -4
  37. package/docs/real-execution-backends.7.md +4 -0
  38. package/docs/release-and-migration.7.md +4 -0
  39. package/docs/release-tooling.7.md +4 -0
  40. package/docs/remote-source-review.7.md +88 -0
  41. package/docs/run-registry-control-plane.7.md +4 -0
  42. package/docs/run-retention-reclamation.7.md +4 -0
  43. package/docs/state-explosion-management.7.md +4 -0
  44. package/docs/team-collaboration.7.md +4 -0
  45. package/docs/web-desktop-workbench.7.md +4 -0
  46. package/manifest/plugin.manifest.json +1 -1
  47. package/manifest/source-context-profiles.json +1 -1
  48. package/package.json +1 -1
  49. package/scripts/agents/agent-adapter-core.js +137 -3
  50. package/scripts/agents/claude-p-agent.js +24 -15
  51. package/scripts/agents/codex-agent.js +11 -8
  52. package/scripts/agents/gemini-agent.js +11 -8
  53. package/scripts/agents/opencode-agent.js +11 -8
  54. package/scripts/canonical-apps.js +4 -4
  55. package/scripts/dogfood-release.js +1 -1
  56. package/scripts/golden-path.js +4 -4
@@ -51,6 +51,129 @@ function trace(line, env = process.env, stderr = process.stderr) {
51
51
  stderr.write(`${line}\n`);
52
52
  }
53
53
 
54
+ // ---- live renderer (zero-dep, hand-rolled — kept self-contained so this wrapper stays a
55
+ // copyable "config", not a build-coupled dependency) -----------------------------------
56
+
57
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
58
+ const ANSI = { reset: "\x1b[0m", dim: "\x1b[2m", green: "\x1b[32m", red: "\x1b[31m", cyan: "\x1b[36m", hideCursor: "\x1b[?25l", showCursor: "\x1b[?25h", clearLine: "\r\x1b[2K" };
59
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
60
+
61
+ function colorOn(env, stderr) {
62
+ if ((env.NO_COLOR ?? "") !== "" || (env.CW_NO_COLOR ?? "") !== "") return false;
63
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
64
+ return Boolean(stderr.isTTY);
65
+ }
66
+ // Behaviorally IDENTICAL to src/term.ts truncate() — the two copies exist only because the wrapper
67
+ // is a self-contained plain-JS "config" (no import of the TS build). cli-render-smoke cross-checks
68
+ // them on shared cases so this invariant cannot silently drift: maxWidth<=0 → ""; a string that fits
69
+ // returns the ORIGINAL text (ANSI intact); otherwise stripped + sliced + "…".
70
+ function truncate(text, max) {
71
+ if (max <= 0) return "";
72
+ const chars = [...String(text).replace(ANSI_RE, "")];
73
+ if (chars.length <= max) return String(text);
74
+ return max <= 1 ? "…" : `${chars.slice(0, max - 1).join("")}…`;
75
+ }
76
+ function fmtElapsed(ms) {
77
+ const s = ms / 1000;
78
+ return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, "0")}s`;
79
+ }
80
+
81
+ /** A single live status line (interactive) or append-only lines (non-TTY), plus an always-on
82
+ * transcript buffer. `action(label)` commits the PREVIOUS action as ✓/✗ then makes `label` the
83
+ * current spinning action — vendor-neutral folding without needing reliable start/end pairing. */
84
+ function createRenderer(opts = {}) {
85
+ const env = opts.env || process.env;
86
+ const stderr = opts.stderr || process.stderr;
87
+ const interactive = streamEnabled(env) && Boolean(stderr.isTTY);
88
+ // Non-TTY default stays SILENT (CW's Rule of Silence). Plain append-only logging in non-TTY
89
+ // is an explicit opt-in for CI debuggability — `CW_AGENT_STREAM=1`, mirroring CW_DRIVE_PROGRESS=1.
90
+ const plain = streamEnabled(env) && !stderr.isTTY && env.CW_AGENT_STREAM === "1";
91
+ const verbose = env.CW_VERBOSE === "1" || env.CW_VERBOSE === "true" || env.CW_OUTPUT === "full";
92
+ const color = colorOn(env, stderr);
93
+ const width = Math.max(20, Math.min(120, Number(stderr.columns) || 80));
94
+ const paint = (code, text) => (color ? `${code}${text}${ANSI.reset}` : text);
95
+
96
+ const transcript = [];
97
+ let live = ""; // current rendered live line (no trailing newline) when interactive
98
+ let timer = null;
99
+ let frame = 0;
100
+ let cursorHidden = false;
101
+ let current = null; // { label, startedAt, failed }
102
+
103
+ const restoreCursor = () => {
104
+ if (cursorHidden) { try { stderr.write(ANSI.showCursor); } catch { /* noop */ } cursorHidden = false; }
105
+ };
106
+ // Cursor hygiene: ALWAYS restore on exit / Ctrl-C / kill, even on crash.
107
+ const onSignal = (sig) => { stop(); process.exit(sig === "SIGINT" ? 130 : 143); };
108
+ if (interactive) {
109
+ process.once("exit", restoreCursor);
110
+ process.once("SIGINT", onSignal);
111
+ process.once("SIGTERM", onSignal);
112
+ }
113
+
114
+ const renderLive = () => {
115
+ if (!interactive || !current) return;
116
+ if (!cursorHidden) { stderr.write(ANSI.hideCursor); cursorHidden = true; }
117
+ const el = paint(ANSI.dim, fmtElapsed(Date.now() - current.startedAt));
118
+ const sp = paint(ANSI.cyan, SPINNER[frame % SPINNER.length]);
119
+ live = `${sp} ${truncate(current.label, width - 10)} ${el}`;
120
+ stderr.write(`${ANSI.clearLine}${live}`);
121
+ };
122
+ const clearLive = () => { if (interactive && live) { stderr.write(ANSI.clearLine); live = ""; } };
123
+ const ensureTimer = () => {
124
+ if (interactive && !timer) timer = setInterval(() => { frame++; renderLive(); }, 90);
125
+ };
126
+
127
+ const commit = () => {
128
+ if (!current) return;
129
+ const ms = Date.now() - current.startedAt;
130
+ const glyph = current.failed ? paint(ANSI.red, "✗") : paint(ANSI.green, "✓");
131
+ const doneLine = `${glyph} ${paint(ANSI.dim, `${truncate(current.label, width - 12)} (${fmtElapsed(ms)})`)}`;
132
+ transcript.push(`- ${current.failed ? "✗" : "✓"} ${current.label} (${fmtElapsed(ms)})`);
133
+ if (interactive) { clearLive(); stderr.write(`${doneLine}\n`); }
134
+ else if (plain) stderr.write(`${current.failed ? "✗" : "✓"} ${current.label} (${fmtElapsed(ms)})\n`);
135
+ current = null;
136
+ };
137
+
138
+ return {
139
+ /** Begin a new active action (commits + folds the previous one). */
140
+ action(label) {
141
+ commit();
142
+ current = { label: String(label || "working…"), startedAt: Date.now(), failed: false };
143
+ ensureTimer();
144
+ if (interactive) renderLive();
145
+ else if (plain) stderr.write(`→ ${current.label}\n`);
146
+ },
147
+ /** Mark the current action as failed (it commits as ✗). */
148
+ fail() { if (current) current.failed = true; },
149
+ /** Narration text from the model. Always to the transcript; inline only in --verbose. */
150
+ text(chunk) {
151
+ const t = String(chunk || "").trim();
152
+ if (!t) return;
153
+ transcript.push(t);
154
+ if (!verbose) return;
155
+ if (interactive) { clearLive(); stderr.write(`${paint(ANSI.dim, truncate(t, width))}\n`); renderLive(); }
156
+ else if (plain) stderr.write(`${truncate(t, width)}\n`);
157
+ },
158
+ /** A short status note (e.g. provider summary). Transcript always; inline in --verbose. */
159
+ note(text) { this.text(text); },
160
+ /** Stop the spinner + restore the terminal. Idempotent. */
161
+ finishLive() { stop(); },
162
+ /** Persist the full transcript (narration + tool I/O) regardless of verbosity. */
163
+ writeTranscript(filePath) {
164
+ try { fs.writeFileSync(filePath, `# Agent transcript\n\n${transcript.join("\n")}\n`, "utf8"); } catch { /* advisory */ }
165
+ },
166
+ isVerbose: () => verbose
167
+ };
168
+
169
+ function stop() {
170
+ if (timer) { clearInterval(timer); timer = null; }
171
+ commit();
172
+ clearLive();
173
+ restoreCursor();
174
+ }
175
+ }
176
+
54
177
  function shortText(value, max = 100) {
55
178
  const text = String(value || "").replace(/\s+/g, " ").trim();
56
179
  if (!text) return "";
@@ -109,20 +232,29 @@ function renderJsonEvent(provider, ev, state) {
109
232
  const usage = usageFromEvent(ev);
110
233
  if (usage) state.usage = usage;
111
234
 
235
+ const r = state.renderer;
112
236
  const type = String(ev.type || ev.event || ev.kind || "");
237
+ if (ev.is_error === true && r) r.fail();
238
+
113
239
  const toolName = toolNameFromEvent(ev);
114
240
  if (toolName || /tool|command/i.test(type)) {
115
241
  const arg = shortText(toolArgFromEvent(ev), 80);
116
- trace(` -> ${toolName || type}${arg ? ` ${arg}` : ""}`);
242
+ const label = `${toolName || type}${arg ? ` ${arg}` : ""}`;
243
+ if (r) r.action(label);
244
+ else trace(` -> ${label}`);
117
245
  return;
118
246
  }
119
247
 
120
248
  const text = textFromEvent(ev);
121
249
  if (text && /assistant|message|delta|text|response|output/i.test(type || "text")) {
122
- trace(` ${shortText(text, 240)}`);
250
+ if (r) r.text(text);
251
+ else trace(` ${shortText(text, 240)}`);
123
252
  } else if (/turn|step|summary|status/i.test(type)) {
124
253
  const status = firstString(ev.status, ev.status_detail, ev.summary, ev.message);
125
- if (status) trace(` . ${provider}: ${shortText(status, 160)}`);
254
+ if (status) {
255
+ if (r) r.note(`${provider}: ${status}`);
256
+ else trace(` . ${provider}: ${shortText(status, 160)}`);
257
+ }
126
258
  }
127
259
  }
128
260
 
@@ -173,6 +305,8 @@ module.exports = {
173
305
  streamEnabled,
174
306
  traceEnabled,
175
307
  trace,
308
+ createRenderer,
309
+ truncate, // exported only so cli-render-smoke can assert it stays identical to term.ts truncate()
176
310
  parseJsonLines,
177
311
  flushJsonLines,
178
312
  writeResult,
@@ -28,12 +28,13 @@
28
28
  // CW_AGENT_COMMAND="node $(pwd)/scripts/agents/claude-p-agent.js {{input}} {{result}}"
29
29
 
30
30
  const fs = require("node:fs");
31
+ const path = require("node:path");
31
32
  const { spawn, spawnSync } = require("node:child_process");
32
33
  // Share the ONE canonical result contract with the codex/gemini/opencode
33
34
  // wrappers instead of carrying a private copy. A drifted inline copy (ASCII
34
35
  // hyphens silently became em-dashes here) meant claude was sent a different
35
36
  // instruction text than the other providers for the same contract.
36
- const { buildPrompt } = require("./agent-adapter-core");
37
+ const { buildPrompt, createRenderer } = require("./agent-adapter-core");
37
38
 
38
39
  const inputPath = process.argv[2];
39
40
  const resultPath = process.argv[3];
@@ -77,11 +78,6 @@ if (!streamEnabled) {
77
78
  process.exit(0);
78
79
  }
79
80
 
80
- // Live trace → stderr only. Concise; one line per meaningful event.
81
- function trace(line) {
82
- if (!traceEnabled) return;
83
- process.stderr.write(`${line}\n`);
84
- }
85
81
  function shortInput(tool, input) {
86
82
  if (!input || typeof input !== "object") return "";
87
83
  const v = input.file_path || input.path || input.pattern || input.command || input.query || input.url || "";
@@ -89,20 +85,30 @@ function shortInput(tool, input) {
89
85
  return s ? ` ${s.length > 80 ? s.slice(0, 77) + "…" : s}` : "";
90
86
  }
91
87
 
92
- // stream-json so claude emits incremental NDJSON events we can render live, while
93
- // we reconstruct the single {model, usage, result} object CW consumes on stdout.
88
+ // The live view (spinner + folding actions on a TTY, plain append-only when piped, silent when
89
+ // CW_AGENT_STREAM=0) + cursor hygiene + an always-on-disk transcript live in the shared core.
90
+ void traceEnabled; // superseded by the renderer (which does its own TTY/stream gating)
91
+ const render = createRenderer({ env: process.env, stderr: process.stderr });
92
+ const transcriptPath = path.join(path.dirname(resultPath), "transcript.md");
93
+
94
+ // stream-json so claude emits incremental NDJSON events we render live. We CAPTURE claude's
95
+ // own stderr (do NOT inherit) so it can never corrupt the live region; it's surfaced only on a
96
+ // non-zero exit.
94
97
  const child = spawn(
95
98
  "claude",
96
99
  ["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", "Read,Grep,Glob,Bash"],
97
- { stdio: ["ignore", "pipe", "inherit"] } // claude's own stderr → straight through
100
+ { stdio: ["ignore", "pipe", "pipe"] }
98
101
  );
99
102
 
100
103
  let model;
101
104
  let usage;
102
105
  let resultText;
103
106
  let buf = "";
107
+ let childStderr = "";
108
+ child.stderr.setEncoding("utf8");
109
+ child.stderr.on("data", (d) => { if (childStderr.length < 1024 * 1024) childStderr += d; });
104
110
 
105
- trace("claude: reading the repo (read-only)…");
111
+ render.action("claude: reading the repo (read-only)…");
106
112
 
107
113
  child.stdout.setEncoding("utf8");
108
114
  child.stdout.on("data", (chunk) => {
@@ -127,27 +133,31 @@ function renderEvent(ev) {
127
133
  if (!model && typeof ev.message.model === "string") model = ev.message.model;
128
134
  for (const part of ev.message.content || []) {
129
135
  if (part.type === "text" && part.text && part.text.trim()) {
130
- trace(` ${part.text.trim().replace(/\n+/g, "\n ")}`);
136
+ render.text(part.text.trim());
131
137
  } else if (part.type === "tool_use") {
132
- trace(` → ${part.name}${shortInput(part.name, part.input)}`);
138
+ render.action(`${part.name}${shortInput(part.name, part.input)}`);
133
139
  }
134
140
  }
135
141
  } else if (ev.type === "system" && ev.subtype === "post_turn_summary" && ev.status_detail) {
136
- trace(` · ${ev.status_detail}`);
142
+ render.note(ev.status_detail);
137
143
  } else if (ev.type === "result") {
138
144
  if (typeof ev.result === "string") resultText = ev.result;
139
145
  if (ev.usage && typeof ev.usage === "object") usage = ev.usage;
140
- if (ev.is_error) trace(" ✗ claude reported an error result");
146
+ if (ev.is_error) render.fail();
141
147
  }
142
148
  }
143
149
 
144
150
  child.on("error", (err) => {
151
+ render.finishLive(); // restore the terminal before exiting
145
152
  process.stderr.write(`claude spawn failed: ${err.message}\n`);
146
153
  process.exit(1);
147
154
  });
148
155
 
149
156
  child.on("close", (code) => {
157
+ render.finishLive(); // stop the spinner + restore the cursor BEFORE any further output
158
+ render.writeTranscript(transcriptPath); // full narration + tool I/O always saved
150
159
  if (code !== 0) {
160
+ if (childStderr.trim()) process.stderr.write(`${childStderr.trim()}\n`);
151
161
  process.stderr.write(`claude exited ${code === null ? "(timeout/killed)" : code}\n`);
152
162
  process.exit(code === null ? 1 : code);
153
163
  }
@@ -158,7 +168,6 @@ child.on("close", (code) => {
158
168
  }
159
169
  // Persist the AGENT's final markdown to the worker's result.md (CW is transport).
160
170
  fs.writeFileSync(resultPath, resultText, "utf8");
161
- trace("● done — result captured");
162
171
  // The single JSON CW consumes on STDOUT (data channel): model + usage + result.
163
172
  process.stdout.write(JSON.stringify({ model, usage, result: resultText }));
164
173
  });
@@ -20,10 +20,10 @@ const path = require("node:path");
20
20
  const { spawn } = require("node:child_process");
21
21
  const {
22
22
  buildPrompt,
23
+ createRenderer,
23
24
  emitReport,
24
25
  flushJsonLines,
25
26
  parseJsonLines,
26
- trace,
27
27
  writeResult
28
28
  } = require("./agent-adapter-core");
29
29
 
@@ -42,7 +42,9 @@ try {
42
42
  }
43
43
 
44
44
  const prompt = buildPrompt(inputPath);
45
- const state = { provider: "codex", buffer: "", model: undefined, usage: undefined };
45
+ const render = createRenderer({ env: process.env, stderr: process.stderr });
46
+ const transcriptPath = path.join(path.dirname(resultPath), "transcript.md");
47
+ const state = { provider: "codex", buffer: "", model: undefined, usage: undefined, renderer: render };
46
48
  const capturedStdout = [];
47
49
  let childStderr = "";
48
50
  function recordJsonLine(line) {
@@ -54,7 +56,7 @@ function recordJsonLine(line) {
54
56
  }
55
57
  }
56
58
 
57
- trace("* codex: reading the repo (read-only)...");
59
+ render.action("codex: reading the repo (read-only)");
58
60
 
59
61
  const args = [
60
62
  "exec",
@@ -83,21 +85,23 @@ child.stdout.on("data", (chunk) => {
83
85
  parseJsonLines("codex", chunk, state, recordJsonLine);
84
86
  });
85
87
 
88
+ // Capture codex's own stderr (do NOT inherit) so it can never corrupt the live region; it's
89
+ // surfaced only on a non-zero exit.
86
90
  child.stderr.setEncoding("utf8");
87
91
  child.stderr.on("data", (chunk) => {
88
- childStderr += chunk;
89
- if (process.env.CW_AGENT_STREAM !== "0" && process.env.CW_NO_STREAM !== "1" && process.stderr.isTTY) {
90
- process.stderr.write(chunk);
91
- }
92
+ if (childStderr.length < 1024 * 1024) childStderr += chunk;
92
93
  });
93
94
 
94
95
  child.on("error", (error) => {
96
+ render.finishLive();
95
97
  process.stderr.write(`codex spawn failed: ${error.message}\n`);
96
98
  process.exit(1);
97
99
  });
98
100
 
99
101
  child.on("close", (code) => {
100
102
  flushJsonLines("codex", state, recordJsonLine);
103
+ render.finishLive();
104
+ render.writeTranscript(transcriptPath);
101
105
  if (code !== 0) {
102
106
  const detail = childStderr.trim() || `codex exited ${code === null ? "(timeout/killed)" : code}`;
103
107
  process.stderr.write(`${detail}\n`);
@@ -129,6 +133,5 @@ child.on("close", (code) => {
129
133
  process.exit(1);
130
134
  }
131
135
 
132
- trace("* done - result captured");
133
136
  emitReport(state.model, state.usage, resultText);
134
137
  });
@@ -14,13 +14,14 @@
14
14
  // stdout: one JSON object { model, usage, result } for CW provenance.
15
15
  // stderr: optional live trace when CW_AGENT_STREAM=1 and attached to a TTY.
16
16
 
17
+ const path = require("node:path");
17
18
  const { spawn } = require("node:child_process");
18
19
  const {
19
20
  buildPrompt,
21
+ createRenderer,
20
22
  emitReport,
21
23
  flushJsonLines,
22
24
  parseJsonLines,
23
- trace,
24
25
  writeResult
25
26
  } = require("./agent-adapter-core");
26
27
 
@@ -32,7 +33,9 @@ if (!inputPath || !resultPath) {
32
33
  }
33
34
 
34
35
  const prompt = buildPrompt(inputPath);
35
- const state = { provider: "gemini", buffer: "", model: undefined, usage: undefined, textFragments: [], finalResult: undefined };
36
+ const render = createRenderer({ env: process.env, stderr: process.stderr });
37
+ const transcriptPath = path.join(path.dirname(resultPath), "transcript.md");
38
+ const state = { provider: "gemini", buffer: "", model: undefined, usage: undefined, textFragments: [], finalResult: undefined, renderer: render };
36
39
  let childStderr = "";
37
40
  function recordJsonLine(line) {
38
41
  let ev;
@@ -51,7 +54,7 @@ function recordJsonLine(line) {
51
54
  }
52
55
  }
53
56
 
54
- trace("* gemini: reading the repo (read-only)...");
57
+ render.action("gemini: reading the repo (read-only)");
55
58
 
56
59
  const args = [
57
60
  "-p",
@@ -72,21 +75,22 @@ child.stdout.on("data", (chunk) => {
72
75
  parseJsonLines("gemini", chunk, state, recordJsonLine);
73
76
  });
74
77
 
78
+ // Capture gemini's own stderr (do NOT inherit) so it can never corrupt the live region.
75
79
  child.stderr.setEncoding("utf8");
76
80
  child.stderr.on("data", (chunk) => {
77
- childStderr += chunk;
78
- if (process.env.CW_AGENT_STREAM !== "0" && process.env.CW_NO_STREAM !== "1" && process.stderr.isTTY) {
79
- process.stderr.write(chunk);
80
- }
81
+ if (childStderr.length < 1024 * 1024) childStderr += chunk;
81
82
  });
82
83
 
83
84
  child.on("error", (error) => {
85
+ render.finishLive();
84
86
  process.stderr.write(`gemini spawn failed: ${error.message}\n`);
85
87
  process.exit(1);
86
88
  });
87
89
 
88
90
  child.on("close", (code) => {
89
91
  flushJsonLines("gemini", state, recordJsonLine);
92
+ render.finishLive();
93
+ render.writeTranscript(transcriptPath);
90
94
  if (code !== 0) {
91
95
  const detail = childStderr.trim() || `gemini exited ${code === null ? "(timeout/killed)" : code}`;
92
96
  process.stderr.write(`${detail}\n`);
@@ -110,6 +114,5 @@ child.on("close", (code) => {
110
114
  process.exit(1);
111
115
  }
112
116
 
113
- trace("* done - result captured");
114
117
  emitReport(state.model, state.usage, resultText);
115
118
  });
@@ -19,13 +19,14 @@
19
19
  // via execution-backend boundary controls. If OpenCode adds a cleaner read-only
20
20
  // flag, prefer that here.
21
21
 
22
+ const path = require("node:path");
22
23
  const { spawn } = require("node:child_process");
23
24
  const {
24
25
  buildPrompt,
26
+ createRenderer,
25
27
  emitReport,
26
28
  flushJsonLines,
27
29
  parseJsonLines,
28
- trace,
29
30
  writeResult
30
31
  } = require("./agent-adapter-core");
31
32
 
@@ -37,7 +38,9 @@ if (!inputPath || !resultPath) {
37
38
  }
38
39
 
39
40
  const prompt = buildPrompt(inputPath);
40
- const state = { provider: "opencode", buffer: "", model: undefined, usage: undefined, textFragments: [], finalResult: undefined };
41
+ const render = createRenderer({ env: process.env, stderr: process.stderr });
42
+ const transcriptPath = path.join(path.dirname(resultPath), "transcript.md");
43
+ const state = { provider: "opencode", buffer: "", model: undefined, usage: undefined, textFragments: [], finalResult: undefined, renderer: render };
41
44
  let childStderr = "";
42
45
  function recordJsonLine(line) {
43
46
  let ev;
@@ -55,7 +58,7 @@ function recordJsonLine(line) {
55
58
  }
56
59
  }
57
60
 
58
- trace("* opencode: reading the repo...");
61
+ render.action("opencode: reading the repo");
59
62
 
60
63
  const args = [
61
64
  "run",
@@ -76,21 +79,22 @@ child.stdout.on("data", (chunk) => {
76
79
  parseJsonLines("opencode", chunk, state, recordJsonLine);
77
80
  });
78
81
 
82
+ // Capture opencode's own stderr (do NOT inherit) so it can never corrupt the live region.
79
83
  child.stderr.setEncoding("utf8");
80
84
  child.stderr.on("data", (chunk) => {
81
- childStderr += chunk;
82
- if (process.env.CW_AGENT_STREAM !== "0" && process.env.CW_NO_STREAM !== "1" && process.stderr.isTTY) {
83
- process.stderr.write(chunk);
84
- }
85
+ if (childStderr.length < 1024 * 1024) childStderr += chunk;
85
86
  });
86
87
 
87
88
  child.on("error", (error) => {
89
+ render.finishLive();
88
90
  process.stderr.write(`opencode spawn failed: ${error.message}\n`);
89
91
  process.exit(1);
90
92
  });
91
93
 
92
94
  child.on("close", (code) => {
93
95
  flushJsonLines("opencode", state, recordJsonLine);
96
+ render.finishLive();
97
+ render.writeTranscript(transcriptPath);
94
98
  if (code !== 0) {
95
99
  const detail = childStderr.trim() || `opencode exited ${code === null ? "(timeout/killed)" : code}`;
96
100
  process.stderr.write(`${detail}\n`);
@@ -114,6 +118,5 @@ child.on("close", (code) => {
114
118
  process.exit(1);
115
119
  }
116
120
 
117
- trace("* done - result captured");
118
121
  emitReport(state.model, state.usage, resultText);
119
122
  });
@@ -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.89",
86
+ "Cool Workflow v0.1.91",
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.89");
120
+ assert.equal(summary.version, "0.1.91");
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.89");
127
+ assert.equal(shown.app.version, "0.1.91");
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.89");
138
+ assert.equal(state.workflow.app.version, "0.1.91");
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`);
@@ -6,7 +6,7 @@ const fs = require("node:fs");
6
6
  const path = require("node:path");
7
7
  const { CoolWorkflowRunner } = require("../dist/orchestrator.js");
8
8
 
9
- const TARGET_VERSION = "0.1.89";
9
+ const TARGET_VERSION = "0.1.91";
10
10
  const PREVIOUS_VERSION = "0.1.31";
11
11
  const pluginRoot = path.resolve(__dirname, "..");
12
12
  const repoRoot = path.resolve(pluginRoot, "..", "..");
@@ -33,7 +33,7 @@ function main() {
33
33
  const appValidation = runJson(["app", "validate", "end-to-end-golden-path"], pluginRoot);
34
34
  assert.equal(appValidation.valid, true);
35
35
  assert.equal(appValidation.summary.id, "end-to-end-golden-path");
36
- assert.equal(appValidation.summary.version, "0.1.89");
36
+ assert.equal(appValidation.summary.version, "0.1.91");
37
37
 
38
38
  const plan = runJson(
39
39
  [
@@ -42,7 +42,7 @@ function main() {
42
42
  "--repo",
43
43
  tmp,
44
44
  "--question",
45
- "Prove the deterministic v0.1.89 end-to-end golden path."
45
+ "Prove the deterministic v0.1.91 end-to-end golden path."
46
46
  ],
47
47
  pluginRoot
48
48
  );
@@ -52,7 +52,7 @@ function main() {
52
52
 
53
53
  let state = readJson(plan.statePath);
54
54
  assert.equal(state.workflow.app.id, "end-to-end-golden-path");
55
- assert.equal(state.workflow.app.version, "0.1.89");
55
+ assert.equal(state.workflow.app.version, "0.1.91");
56
56
  assert.equal(state.loopStage, "interpret");
57
57
 
58
58
  const dispatch = runJson(["dispatch", plan.runId, "--limit", "1", "--sandbox", "readonly"], tmp);
@@ -195,7 +195,7 @@ function main() {
195
195
  assert.equal(reportPath, plan.reportPath);
196
196
  assert.ok(fs.existsSync(reportPath));
197
197
  const report = fs.readFileSync(reportPath, "utf8");
198
- assert.match(report, /Workflow App: end-to-end-golden-path@0\.1\.89/);
198
+ assert.match(report, /Workflow App: end-to-end-golden-path@0\.1\.91/);
199
199
  assert.match(report, /## Candidates/);
200
200
  assert.match(report, /## Trust Audit/);
201
201
  assert.match(report, /## Acceptance Rationale/);