cool-workflow 0.1.90 → 0.1.92

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 (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +78 -374
  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/apps/research-synthesis/workflow.js +1 -1
  11. package/dist/capability-core.js +31 -0
  12. package/dist/cli/command-surface.js +55 -8
  13. package/dist/doctor.js +1 -0
  14. package/dist/drive.js +2 -1
  15. package/dist/orchestrator/report.js +6 -1
  16. package/dist/orchestrator.js +3 -0
  17. package/dist/reporter.js +67 -0
  18. package/dist/term.js +73 -1
  19. package/dist/version.js +1 -1
  20. package/docs/agent-delegation-drive.7.md +41 -22
  21. package/docs/canonical-workflow-apps.7.md +12 -0
  22. package/docs/cli-mcp-parity.7.md +4 -0
  23. package/docs/contract-migration-tooling.7.md +4 -0
  24. package/docs/control-plane-scheduling.7.md +4 -0
  25. package/docs/durable-state-and-locking.7.md +4 -0
  26. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  27. package/docs/execution-backends.7.md +4 -0
  28. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  29. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  30. package/docs/multi-agent-operator-ux.7.md +4 -0
  31. package/docs/node-snapshot-diff-replay.7.md +4 -0
  32. package/docs/observability-cost-accounting.7.md +4 -0
  33. package/docs/project-index.md +7 -3
  34. package/docs/real-execution-backends.7.md +4 -0
  35. package/docs/release-and-migration.7.md +4 -0
  36. package/docs/release-tooling.7.md +13 -0
  37. package/docs/run-registry-control-plane.7.md +4 -0
  38. package/docs/run-retention-reclamation.7.md +4 -0
  39. package/docs/state-explosion-management.7.md +4 -0
  40. package/docs/team-collaboration.7.md +4 -0
  41. package/docs/web-desktop-workbench.7.md +4 -0
  42. package/manifest/plugin.manifest.json +1 -1
  43. package/package.json +3 -1
  44. package/scripts/agents/agent-adapter-core.js +289 -4
  45. package/scripts/agents/claude-p-agent.js +45 -19
  46. package/scripts/agents/codex-agent.js +11 -8
  47. package/scripts/agents/gemini-agent.js +11 -8
  48. package/scripts/agents/opencode-agent.js +11 -8
  49. package/scripts/bump-version.js +4 -1
  50. package/scripts/canonical-apps.js +4 -4
  51. package/scripts/dogfood-release.js +1 -1
  52. package/scripts/golden-path.js +4 -4
  53. package/scripts/release-check.js +5 -1
  54. package/scripts/sync-readme.js +123 -0
  55. package/scripts/version-sync-check.js +5 -1
@@ -5,11 +5,11 @@ Generated from the current repository code on 2026-06-21 by `npm run sync:projec
5
5
  ## Snapshot
6
6
 
7
7
  - Package: `cool-workflow`
8
- - Version: `0.1.90`
9
- - Source modules: `67`
8
+ - Version: `0.1.92`
9
+ - Source modules: `68`
10
10
  - Workflow apps: `7`
11
11
  - Docs: `53`
12
- - Smoke tests: `136`
12
+ - Smoke tests: `139`
13
13
  - Repository: https://github.com/coo1white/cool-workflow
14
14
 
15
15
  ## Architecture
@@ -104,6 +104,7 @@ multi-agent host -> topology -> blackboard/coordinator
104
104
  - [onramp.ts](../src/onramp.ts)
105
105
  - [reclamation.ts](../src/reclamation.ts)
106
106
  - [remote-source.ts](../src/remote-source.ts)
107
+ - [reporter.ts](../src/reporter.ts)
107
108
  - [result-normalize.ts](../src/result-normalize.ts)
108
109
  - [run-export.ts](../src/run-export.ts)
109
110
  - [run-registry.ts](../src/run-registry.ts)
@@ -212,6 +213,7 @@ Smoke tests mirror the public contracts. The high-signal suites are:
212
213
  - [cli-mcp-parity-smoke.js](../test/cli-mcp-parity-smoke.js)
213
214
  - [cli-progress-summary-smoke.js](../test/cli-progress-summary-smoke.js)
214
215
  - [cli-recoverable-errors-smoke.js](../test/cli-recoverable-errors-smoke.js)
216
+ - [cli-render-smoke.js](../test/cli-render-smoke.js)
215
217
  - [clones-gc-smoke.js](../test/clones-gc-smoke.js)
216
218
  - [codex-agent-wrapper-smoke.js](../test/codex-agent-wrapper-smoke.js)
217
219
  - [concurrency-default-smoke.js](../test/concurrency-default-smoke.js)
@@ -270,9 +272,11 @@ Smoke tests mirror the public contracts. The high-signal suites are:
270
272
  - [project-index-sync-smoke.js](../test/project-index-sync-smoke.js)
271
273
  - [quickstart-bundle-smoke.js](../test/quickstart-bundle-smoke.js)
272
274
  - [quickstart-check-smoke.js](../test/quickstart-check-smoke.js)
275
+ - [quickstart-corpus-smoke.js](../test/quickstart-corpus-smoke.js)
273
276
  - [quickstart-no-agent-smoke.js](../test/quickstart-no-agent-smoke.js)
274
277
  - [quickstart-readme-path-smoke.js](../test/quickstart-readme-path-smoke.js)
275
278
  - [quickstart-smoke.js](../test/quickstart-smoke.js)
279
+ - [readme-sync-smoke.js](../test/readme-sync-smoke.js)
276
280
  - [readme-trust-claim-smoke.js](../test/readme-trust-claim-smoke.js)
277
281
  - [real-execution-backends-smoke.js](../test/real-execution-backends-smoke.js)
278
282
  - [registry-corrupt-fail-closed-smoke.js](../test/registry-corrupt-fail-closed-smoke.js)
@@ -175,3 +175,7 @@ _No behavioral change in v0.1.88 (the container/remote/ci delegating integration
175
175
  _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
176
176
 
177
177
  0.1.90
178
+
179
+ 0.1.91
180
+
181
+ 0.1.92
@@ -315,3 +315,7 @@ _No behavioral change in v0.1.88 (`release:check` and the durable run-state comp
315
315
  _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
316
316
 
317
317
  0.1.90
318
+
319
+ 0.1.91
320
+
321
+ 0.1.92
@@ -62,6 +62,15 @@ keeps full coverage while cutting the doubled wall time. The steps that stay
62
62
  are the ones NOT covered by `npm test`: build, type check, `npm test`,
63
63
  canonical-apps, golden-path, parity, vendor-manifest drift, and `version:sync`.
64
64
 
65
+ The drift gates `index:check` (`docs/project-index.md`) and `readme:check`
66
+ (`plugins/cool-workflow/README.md`) also run here. The npm package README is
67
+ GENERATED from the repo-root `README.md` by `scripts/sync-readme.js` — it changes
68
+ only the relative image/link URLs npm cannot render (`docs/assets/*` to
69
+ `raw.githubusercontent.com`, `](LICENSE)`/`](plugins/...)` to `.../blob/main/...`),
70
+ so the npm page and the GitHub page stay identical. Edit the GitHub `README.md`,
71
+ then run `npm run sync:readme`; `readme:check` fails the gate if they drift. The
72
+ teeth live in `test/readme-sync-smoke.js`.
73
+
65
74
  For timing work, the smoke runner can write a JSON timing report without changing
66
75
  the normal output:
67
76
 
@@ -253,3 +262,7 @@ The release flow now captures the reviewer's verdict from agent stdout (`release
253
262
  _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
254
263
 
255
264
  0.1.90
265
+
266
+ 0.1.91
267
+
268
+ 0.1.92
@@ -436,3 +436,7 @@ Security: archive import now refuses path-traversal run ids (`..`/absolute/separ
436
436
  _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
437
437
 
438
438
  0.1.90
439
+
440
+ 0.1.91
441
+
442
+ 0.1.92
@@ -226,3 +226,7 @@ _No behavioral change in v0.1.88 (the tiered, append-only, cryptographically-ver
226
226
  _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
227
227
 
228
228
  0.1.90
229
+
230
+ 0.1.91
231
+
232
+ 0.1.92
@@ -304,3 +304,7 @@ _No behavioral change in v0.1.88 (the summarization/compaction layer and its fai
304
304
  _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
305
305
 
306
306
  0.1.90
307
+
308
+ 0.1.91
309
+
310
+ 0.1.92
@@ -240,3 +240,7 @@ _No behavioral change in v0.1.88 (the host-attested actor, append-only approvals
240
240
  _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
241
241
 
242
242
  0.1.90
243
+
244
+ 0.1.91
245
+
246
+ 0.1.92
@@ -248,3 +248,7 @@ _No behavioral change in v0.1.88 (the five operator surfaces and the registry cr
248
248
  _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
249
249
 
250
250
  0.1.90
251
+
252
+ 0.1.91
253
+
254
+ 0.1.92
@@ -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.1.90",
5
+ "version": "0.1.92",
6
6
  "license": "BSD-2-Clause",
7
7
  "homepage": "https://github.com/coo1white/cool-workflow",
8
8
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cool-workflow",
3
- "version": "0.1.90",
3
+ "version": "0.1.92",
4
4
  "bin": {
5
5
  "cool-workflow": "scripts/cw.js",
6
6
  "cw": "scripts/cw.js"
@@ -49,6 +49,8 @@
49
49
  "fixture-compat": "node test/run-fixture-compat-smoke.js",
50
50
  "sync:project-index": "node scripts/sync-project-index.js",
51
51
  "index:check": "node scripts/sync-project-index.js --check",
52
+ "sync:readme": "node scripts/sync-readme.js",
53
+ "readme:check": "node scripts/sync-readme.js --check",
52
54
  "bump:version": "node scripts/bump-version.js",
53
55
  "new:feature": "node scripts/new-feature.js",
54
56
  "forward-ref": "node scripts/forward-ref-docs.js",
@@ -51,6 +51,233 @@ 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
+ // The live "thinking" glyph cycles a sparkle (Claude-Code feel), not a generic braille spinner.
58
+ const SPARKLE = ["✶", "✸", "✹", "✺", "✹", "✸"];
59
+ 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" };
60
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
61
+
62
+ function colorOn(env, stderr) {
63
+ if ((env.NO_COLOR ?? "") !== "" || (env.CW_NO_COLOR ?? "") !== "") return false;
64
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
65
+ return Boolean(stderr.isTTY);
66
+ }
67
+ // Behaviorally IDENTICAL to src/term.ts truncate() — the two copies exist only because the wrapper
68
+ // is a self-contained plain-JS "config" (no import of the TS build). cli-render-smoke cross-checks
69
+ // them on shared cases so this invariant cannot silently drift: maxWidth<=0 → ""; a string that fits
70
+ // returns the ORIGINAL text (ANSI intact); otherwise stripped + sliced + "…".
71
+ function truncate(text, max) {
72
+ if (max <= 0) return "";
73
+ const chars = [...String(text).replace(ANSI_RE, "")];
74
+ if (chars.length <= max) return String(text);
75
+ return max <= 1 ? "…" : `${chars.slice(0, max - 1).join("")}…`;
76
+ }
77
+ function fmtElapsed(ms) {
78
+ const s = ms / 1000;
79
+ return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, "0")}s`;
80
+ }
81
+
82
+ /** A calm, FOLDING live view (interactive) or append-only lines (non-TTY), plus an always-on
83
+ * transcript buffer. Interactive: a rolling window redrawn IN PLACE — the current action (spinner)
84
+ * plus the last WINDOW completed tools (dimmed). Older tools fold away (the full trail is always in
85
+ * the transcript), so the region stays a few rows and never grows into a wall; at the end it
86
+ * collapses to ONE summary line. `action(label)` commits the PREVIOUS action then makes `label` the
87
+ * current spinning action — vendor-neutral folding without needing reliable start/end pairing. */
88
+ function createRenderer(opts = {}) {
89
+ const env = opts.env || process.env;
90
+ const stderr = opts.stderr || process.stderr;
91
+ const label = String(opts.label || "agent"); // provider name, only for the end-of-run summary line
92
+ const interactive = streamEnabled(env) && Boolean(stderr.isTTY);
93
+ // Non-TTY default stays SILENT (CW's Rule of Silence). Plain append-only logging in non-TTY
94
+ // is an explicit opt-in for CI debuggability — `CW_AGENT_STREAM=1`, mirroring CW_DRIVE_PROGRESS=1.
95
+ const plain = streamEnabled(env) && !stderr.isTTY && env.CW_AGENT_STREAM === "1";
96
+ const verbose = env.CW_VERBOSE === "1" || env.CW_VERBOSE === "true" || env.CW_OUTPUT === "full";
97
+ const color = colorOn(env, stderr);
98
+ const paint = (code, text) => (color ? `${code}${text}${ANSI.reset}` : text);
99
+
100
+ // How many completed tools stay visible above the spinner (the rolling window). Env-tunable.
101
+ const WINDOW = Math.max(0, Math.min(20, Number(env.CW_LIVE_ROWS) || 4));
102
+ const INDENT = " ";
103
+ const cols = () => Math.max(20, Math.min(200, Number(stderr.columns) || 80));
104
+
105
+ const transcript = [];
106
+ const recent = []; // [{ label, ms, failed }] — last WINDOW completed actions (interactive)
107
+ let blockRows = 0; // terminal rows the live block occupied last frame (for in-place erase)
108
+ let steps = 0; // total completed actions (for the summary line)
109
+ const runStart = Date.now();
110
+ let timer = null;
111
+ let frame = 0;
112
+ let cursorHidden = false;
113
+ let current = null; // { label, startedAt, failed }
114
+
115
+ const restoreCursor = () => {
116
+ if (cursorHidden) { try { stderr.write(ANSI.showCursor); } catch { /* noop */ } cursorHidden = false; }
117
+ };
118
+ // Cursor hygiene: ALWAYS restore on exit / Ctrl-C / kill, even on crash.
119
+ const onSignal = (sig) => { stop(); process.exit(sig === "SIGINT" ? 130 : 143); };
120
+ if (interactive) {
121
+ process.once("exit", restoreCursor);
122
+ process.once("SIGINT", onSignal);
123
+ process.once("SIGTERM", onSignal);
124
+ }
125
+
126
+ // One completed-tool row, Claude-tree style: ` ● Read(foo.ts)` — the bullet is green (red on
127
+ // failure), the tool name bold + its (arg) dimmed; no per-tool duration. Width-truncated.
128
+ const styledLabel = (label, width) => {
129
+ const text = truncate(label, width - 4);
130
+ const m = /^(\w+)\((.*)\)$/.exec(text);
131
+ if (m) return `${m[1]}${paint(ANSI.dim, `(${m[2]})`)}`;
132
+ return text;
133
+ };
134
+ const doneLine = (item, width) => {
135
+ const bullet = paint(item.failed ? ANSI.red : ANSI.green, "●");
136
+ return `${INDENT}${bullet} ${styledLabel(item.label, width)}`;
137
+ };
138
+ // The live "thinking" row, Claude-tree style: ` ✶ Reading foo.ts… (4s)` — a sparkle + a
139
+ // tool-derived verb phrase + dim elapsed. A non-tool status label is shown as-is (sans trailing …).
140
+ const livePhrase = () => {
141
+ const m = /^(\w+)\((.*)\)$/.exec(current.label);
142
+ if (m) return `${verbFor(m[1])} ${m[2]}`;
143
+ return current.label.replace(/…+$/, "");
144
+ };
145
+ const liveLine = (width) => {
146
+ const elapsed = `(${fmtElapsed(Date.now() - current.startedAt)})`;
147
+ const el = paint(ANSI.dim, elapsed);
148
+ const sp = paint(ANSI.cyan, SPARKLE[frame % SPARKLE.length]);
149
+ // Reserve EXACT room for the fixed decorations + the VARIABLE elapsed string (which grows from 6
150
+ // chars `(4.0s)` to 9 `(100m00s)`): indent(2) + sparkle+space(2) + ellipsis+space(2) + elapsed,
151
+ // minus 1 column of margin. A fixed `width - N` budget overflowed past 1 minute and wrapped.
152
+ const budget = width - 6 - elapsed.length - 1;
153
+ return `${INDENT}${sp} ${truncate(livePhrase(), budget)}${paint(ANSI.dim, "…")} ${el}`;
154
+ };
155
+
156
+ // Erase the previously-drawn block in place: go to its first row, clear to end of screen.
157
+ const eraseBlock = () => {
158
+ if (!interactive || blockRows <= 0) return;
159
+ stderr.write("\r");
160
+ if (blockRows > 1) stderr.write(`\x1b[${blockRows - 1}A`);
161
+ stderr.write("\x1b[0J");
162
+ blockRows = 0;
163
+ };
164
+
165
+ // The `⎿ result` tree line under a completed tool (dimmed), e.g. ` ⎿ 245 lines`.
166
+ const resultLine = (result, width) => `${INDENT} ${paint(ANSI.dim, `⎿ ${truncate(result, width - 6)}`)}`;
167
+
168
+ // Redraw the rolling window (recent ● rows + their ⎿ results + the live spinner) in place.
169
+ const renderBlock = () => {
170
+ if (!interactive) return;
171
+ if (!cursorHidden) { stderr.write(ANSI.hideCursor); cursorHidden = true; }
172
+ eraseBlock();
173
+ const width = cols();
174
+ const lines = [];
175
+ for (const item of recent.slice(-WINDOW)) {
176
+ lines.push(doneLine(item, width));
177
+ if (item.result) lines.push(resultLine(item.result, width));
178
+ }
179
+ if (current) lines.push(liveLine(width));
180
+ if (!lines.length) return;
181
+ // HARD GUARD: cap EVERY emitted line to the terminal width so none can wrap to a 2nd physical
182
+ // row. A wrapped row would make blockRows (logical count) under-count the real cursor movement,
183
+ // so eraseBlock's cursor-up would land a row short and corrupt/creep the block. This also closes
184
+ // the resize path: each frame caps to the CURRENT width, so a narrowed terminal can't desync.
185
+ const safe = lines.map((l) => truncate(l, width));
186
+ stderr.write(safe.join("\n"));
187
+ blockRows = safe.length;
188
+ };
189
+
190
+ const ensureTimer = () => {
191
+ if (interactive && !timer) timer = setInterval(() => { frame++; renderBlock(); }, 90);
192
+ };
193
+
194
+ // Commit the current action: record it to the transcript + roll it into the window (interactive)
195
+ // or append a plain line (CI). The interactive REDRAW happens in the caller (action/stop), so a
196
+ // commit never leaves a permanent per-tool line — that was the 0.1.91 "wall".
197
+ const commit = () => {
198
+ if (!current) return;
199
+ const ms = Date.now() - current.startedAt;
200
+ transcript.push(`- ${current.failed ? "✗" : "✓"} ${current.label} (${fmtElapsed(ms)})`);
201
+ steps += 1;
202
+ if (interactive) {
203
+ recent.push({ label: current.label, id: current.id, result: current.result, failed: current.failed });
204
+ while (recent.length > WINDOW) recent.shift();
205
+ } else if (plain) {
206
+ stderr.write(`${current.failed ? "✗" : "✓"} ${current.label} (${fmtElapsed(ms)})\n`);
207
+ }
208
+ current = null;
209
+ };
210
+
211
+ return {
212
+ /** Begin a new active action (folds the previous one into the rolling window). `id` (a vendor
213
+ * tool_use id) lets a later result() attach to THIS tool even after it has folded — needed when
214
+ * one assistant turn dispatches several tools before their results arrive. */
215
+ action(label, id) {
216
+ commit();
217
+ current = { label: String(label || "working…"), id, startedAt: Date.now(), failed: false };
218
+ ensureTimer();
219
+ if (interactive) renderBlock();
220
+ else if (plain) stderr.write(`→ ${current.label}\n`);
221
+ },
222
+ /** Mark the current action as failed (it folds in as a red ●). */
223
+ fail() { if (current) current.failed = true; },
224
+ /** Attach a short result summary to a tool — rendered as a dim `⎿ summary` tree line. `id` keys
225
+ * it to a SPECIFIC tool_use so a batch of tools dispatched in one turn each keep their own result
226
+ * (without an id it falls back to the current tool). Always recorded to the transcript. */
227
+ result(summary, failed, id) {
228
+ const s = String(summary || "").replace(/\s+/g, " ").trim();
229
+ if (!s && !failed) return;
230
+ // Find the target: prefer the matching id (current, else the most-recent committed item with
231
+ // that id); otherwise the current tool. This keeps earlier batched tools' ⎿ from being lost.
232
+ let target = null;
233
+ if (id != null) {
234
+ if (current && current.id === id) target = current;
235
+ else for (let k = recent.length - 1; k >= 0; k -= 1) { if (recent[k].id === id) { target = recent[k]; break; } }
236
+ }
237
+ if (!target) target = current;
238
+ if (target) { if (s) target.result = s; if (failed) target.failed = true; }
239
+ if (s) transcript.push(` ⎿ ${s}`);
240
+ if (plain && s) stderr.write(` ⎿ ${s}\n`);
241
+ // A result attached to an ALREADY-folded item must redraw the block to show its new ⎿.
242
+ if (interactive && target && target !== current) renderBlock();
243
+ },
244
+ /** Narration text from the model. Always to the transcript; inline only in --verbose (printed
245
+ * ABOVE the live window, which then redraws below it). */
246
+ text(chunk) {
247
+ const t = String(chunk || "").trim();
248
+ if (!t) return;
249
+ transcript.push(t);
250
+ if (!verbose) return;
251
+ if (interactive) { eraseBlock(); stderr.write(`${INDENT}${paint(ANSI.dim, truncate(t, cols() - 2))}\n`); renderBlock(); }
252
+ else if (plain) stderr.write(`${truncate(t, cols())}\n`);
253
+ },
254
+ /** A short status note (e.g. provider summary). Transcript always; inline in --verbose. */
255
+ note(text) { this.text(text); },
256
+ /** Stop the spinner + restore the terminal. Idempotent. */
257
+ finishLive() { stop(); },
258
+ /** Persist the full transcript (narration + tool I/O) regardless of verbosity. */
259
+ writeTranscript(filePath) {
260
+ try { fs.writeFileSync(filePath, `# Agent transcript\n\n${transcript.join("\n")}\n`, "utf8"); } catch { /* advisory */ }
261
+ },
262
+ isVerbose: () => verbose
263
+ };
264
+
265
+ // Stop: fold the last action in, erase the live window, and collapse to ONE permanent summary
266
+ // line (` ✓ <provider> — N steps · 2m41s`) so the worker leaves a single calm trace.
267
+ function stop() {
268
+ if (timer) { clearInterval(timer); timer = null; }
269
+ commit();
270
+ if (interactive) {
271
+ eraseBlock();
272
+ if (steps > 0) {
273
+ const summary = `${paint(ANSI.green, "●")} ${paint(ANSI.dim, `${label} · ${steps} step${steps === 1 ? "" : "s"} · ${fmtElapsed(Date.now() - runStart)}`)}`;
274
+ stderr.write(`${INDENT}${summary}\n`);
275
+ }
276
+ }
277
+ restoreCursor();
278
+ }
279
+ }
280
+
54
281
  function shortText(value, max = 100) {
55
282
  const text = String(value || "").replace(/\s+/g, " ").trim();
56
283
  if (!text) return "";
@@ -96,6 +323,52 @@ function toolArgFromEvent(ev) {
96
323
  return firstString(input.file_path, input.path, input.pattern, input.command, input.query, input.url, input.cmd) || "";
97
324
  }
98
325
 
326
+ // ---- Claude-Code-style tool labels: `ToolName(compactArg)` -------------------------------------
327
+ // File tools show the BASENAME (Read(foo.ts), not Read(/Users/…/foo.ts)); everything else (Bash
328
+ // commands, Grep/Glob patterns) is collapsed + truncated. Shared by every wrapper so the look can't
329
+ // drift per vendor.
330
+ const FILE_TOOLS = /^(Read|Write|Edit|MultiEdit|NotebookEdit|NotebookRead)$/i;
331
+ function compactToolArg(name, rawArg) {
332
+ const s = String(rawArg || "").replace(/\s+/g, " ").trim();
333
+ if (!s) return "";
334
+ if (FILE_TOOLS.test(String(name || "")) && s.includes("/")) return s.split("/").filter(Boolean).pop() || s;
335
+ return s.length > 40 ? `${s.slice(0, 39)}…` : s;
336
+ }
337
+ function toolLabel(name, rawArg) {
338
+ const n = String(name || "").trim();
339
+ const arg = compactToolArg(n, rawArg);
340
+ return arg ? `${n}(${arg})` : n;
341
+ }
342
+ // The present-tense verb for the live "thinking" line, derived from the tool.
343
+ function verbFor(name) {
344
+ const t = String(name || "").toLowerCase();
345
+ if (/read|cat|notebookread/.test(t)) return "Reading";
346
+ if (/write|edit/.test(t)) return "Editing";
347
+ if (/bash|shell|exec|run|command/.test(t)) return "Running";
348
+ if (/grep|glob|search|find|ls|list/.test(t)) return "Searching";
349
+ if (/fetch|web|http|download/.test(t)) return "Fetching";
350
+ if (/task|agent|sub/.test(t)) return "Delegating";
351
+ return "Working";
352
+ }
353
+
354
+ // A short result summary for the `⎿` tree line, derived from the tool + its raw output (Claude-Code
355
+ // feel: `⎿ 245 lines` / `⎿ 12 matches` / `⎿ error`). Heuristic + tool-aware; never throws.
356
+ function summarizeToolResult(name, text, isError) {
357
+ if (isError) return "error";
358
+ const raw = String(text || "");
359
+ const lines = raw.split("\n").filter((l) => l.trim().length).length;
360
+ const t = String(name || "").toLowerCase();
361
+ let s;
362
+ if (/read|cat/.test(t)) s = `${lines} line${lines === 1 ? "" : "s"}`;
363
+ else if (/glob|ls|find/.test(t)) s = `${lines} file${lines === 1 ? "" : "s"}`;
364
+ else if (/grep|search/.test(t)) s = `${lines} match${lines === 1 ? "" : "es"}`;
365
+ else if (/bash|shell|exec|run/.test(t)) {
366
+ const first = raw.split("\n").map((l) => l.trim()).find(Boolean) || "";
367
+ s = lines <= 1 && first ? first : `${lines} line${lines === 1 ? "" : "s"}`;
368
+ } else s = `${lines} line${lines === 1 ? "" : "s"}`;
369
+ return s.length > 40 ? `${s.slice(0, 39)}…` : s;
370
+ }
371
+
99
372
  function textFromEvent(ev) {
100
373
  const delta = maybeObject(ev.delta);
101
374
  const msg = maybeObject(ev.message);
@@ -109,20 +382,28 @@ function renderJsonEvent(provider, ev, state) {
109
382
  const usage = usageFromEvent(ev);
110
383
  if (usage) state.usage = usage;
111
384
 
385
+ const r = state.renderer;
112
386
  const type = String(ev.type || ev.event || ev.kind || "");
387
+ if (ev.is_error === true && r) r.fail();
388
+
113
389
  const toolName = toolNameFromEvent(ev);
114
390
  if (toolName || /tool|command/i.test(type)) {
115
- const arg = shortText(toolArgFromEvent(ev), 80);
116
- trace(` -> ${toolName || type}${arg ? ` ${arg}` : ""}`);
391
+ const label = toolLabel(toolName || type, toolArgFromEvent(ev));
392
+ if (r) r.action(label);
393
+ else trace(` -> ${label}`);
117
394
  return;
118
395
  }
119
396
 
120
397
  const text = textFromEvent(ev);
121
398
  if (text && /assistant|message|delta|text|response|output/i.test(type || "text")) {
122
- trace(` ${shortText(text, 240)}`);
399
+ if (r) r.text(text);
400
+ else trace(` ${shortText(text, 240)}`);
123
401
  } else if (/turn|step|summary|status/i.test(type)) {
124
402
  const status = firstString(ev.status, ev.status_detail, ev.summary, ev.message);
125
- if (status) trace(` . ${provider}: ${shortText(status, 160)}`);
403
+ if (status) {
404
+ if (r) r.note(`${provider}: ${status}`);
405
+ else trace(` . ${provider}: ${shortText(status, 160)}`);
406
+ }
126
407
  }
127
408
  }
128
409
 
@@ -173,6 +454,10 @@ module.exports = {
173
454
  streamEnabled,
174
455
  traceEnabled,
175
456
  trace,
457
+ createRenderer,
458
+ truncate, // exported only so cli-render-smoke can assert it stays identical to term.ts truncate()
459
+ toolLabel, // `ToolName(compactArg)` — shared by the claude wrapper so the label format can't drift
460
+ summarizeToolResult, // `⎿` result summary — shared so vendors derive it identically
176
461
  parseJsonLines,
177
462
  flushJsonLines,
178
463
  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, toolLabel, summarizeToolResult } = require("./agent-adapter-core");
37
38
 
38
39
  const inputPath = process.argv[2];
39
40
  const resultPath = process.argv[3];
@@ -77,32 +78,37 @@ 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
- function shortInput(tool, input) {
81
+ // The shared `toolLabel` (from the core) renders `ToolName(basename)` the Claude-tree label format
82
+ // every vendor uses, so the look can't drift between wrappers.
83
+ function pickInput(input) {
86
84
  if (!input || typeof input !== "object") return "";
87
- const v = input.file_path || input.path || input.pattern || input.command || input.query || input.url || "";
88
- const s = String(v).replace(/\s+/g, " ").trim();
89
- return s ? ` ${s.length > 80 ? s.slice(0, 77) + "…" : s}` : "";
85
+ return input.file_path || input.path || input.pattern || input.command || input.query || input.url || "";
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, label: "claude" });
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) => {
@@ -122,32 +128,53 @@ child.stdout.on("data", (chunk) => {
122
128
  }
123
129
  });
124
130
 
131
+ const toolIds = new Map(); // tool_use id -> tool name, so a later tool_result can name its tool
132
+ function toolResultText(content) {
133
+ if (typeof content === "string") return content;
134
+ if (Array.isArray(content)) return content.map((b) => (b && typeof b.text === "string" ? b.text : "")).join("\n");
135
+ return "";
136
+ }
137
+
125
138
  function renderEvent(ev) {
126
139
  if (ev.type === "assistant" && ev.message) {
127
140
  if (!model && typeof ev.message.model === "string") model = ev.message.model;
128
141
  for (const part of ev.message.content || []) {
129
142
  if (part.type === "text" && part.text && part.text.trim()) {
130
- trace(` ${part.text.trim().replace(/\n+/g, "\n ")}`);
143
+ render.text(part.text.trim());
131
144
  } else if (part.type === "tool_use") {
132
- trace(` → ${part.name}${shortInput(part.name, part.input)}`);
145
+ if (part.id) toolIds.set(part.id, part.name);
146
+ render.action(toolLabel(part.name, pickInput(part.input)), part.id);
147
+ }
148
+ }
149
+ } else if (ev.type === "user" && ev.message) {
150
+ // claude runs the tools itself (headless -p) and echoes each result back as a tool_result block;
151
+ // summarize it into the `⎿` tree line for the tool it belongs to.
152
+ for (const part of ev.message.content || []) {
153
+ if (part && part.type === "tool_result") {
154
+ const name = toolIds.get(part.tool_use_id) || "";
155
+ render.result(summarizeToolResult(name, toolResultText(part.content), part.is_error), part.is_error, part.tool_use_id);
133
156
  }
134
157
  }
135
158
  } else if (ev.type === "system" && ev.subtype === "post_turn_summary" && ev.status_detail) {
136
- trace(` · ${ev.status_detail}`);
159
+ render.note(ev.status_detail);
137
160
  } else if (ev.type === "result") {
138
161
  if (typeof ev.result === "string") resultText = ev.result;
139
162
  if (ev.usage && typeof ev.usage === "object") usage = ev.usage;
140
- if (ev.is_error) trace(" ✗ claude reported an error result");
163
+ if (ev.is_error) render.fail();
141
164
  }
142
165
  }
143
166
 
144
167
  child.on("error", (err) => {
168
+ render.finishLive(); // restore the terminal before exiting
145
169
  process.stderr.write(`claude spawn failed: ${err.message}\n`);
146
170
  process.exit(1);
147
171
  });
148
172
 
149
173
  child.on("close", (code) => {
174
+ render.finishLive(); // stop the spinner + restore the cursor BEFORE any further output
175
+ render.writeTranscript(transcriptPath); // full narration + tool I/O always saved
150
176
  if (code !== 0) {
177
+ if (childStderr.trim()) process.stderr.write(`${childStderr.trim()}\n`);
151
178
  process.stderr.write(`claude exited ${code === null ? "(timeout/killed)" : code}\n`);
152
179
  process.exit(code === null ? 1 : code);
153
180
  }
@@ -158,7 +185,6 @@ child.on("close", (code) => {
158
185
  }
159
186
  // Persist the AGENT's final markdown to the worker's result.md (CW is transport).
160
187
  fs.writeFileSync(resultPath, resultText, "utf8");
161
- trace("● done — result captured");
162
188
  // The single JSON CW consumes on STDOUT (data channel): model + usage + result.
163
189
  process.stdout.write(JSON.stringify({ model, usage, result: resultText }));
164
190
  });