@worca/app 1.0.0 → 1.2.0-rc.1

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/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -1,5 +1,10 @@
1
1
  {
2
2
  "key": "workspaceScanner",
3
+ "metaVersion": 2,
4
+ "mockRole": "workspace-scan",
5
+ "placeable": false,
6
+ "inputs": [{ "id": "task", "type": "md" }],
7
+ "outputs": [{ "id": "workspace", "type": "md", "filename": "workspace-description.md" }],
3
8
  "domain": "shared",
4
9
  "displayName": "Workspace Scan",
5
10
  "description": "Maps how the workspace's projects relate before pipelines run. Shared APIs, schemas, queues, and build dependencies.",
@@ -7,11 +12,7 @@
7
12
  "icon": "<path d=\"M4 7h16M4 12h10M4 17h7\" stroke-linecap=\"round\"/><circle cx=\"18\" cy=\"15\" r=\"3\"/><path d=\"M20.2 17.2L22 19\" stroke-linecap=\"round\"/>",
8
13
  "agentFile": "worca-cc-workspace-scanner.md",
9
14
  "runnerType": "producer",
10
- "loopSource": false,
11
15
  "fanOut": true,
12
- "produces": ["workspace"],
13
- "consumes": ["userPrompt"],
14
- "connectsTo": [],
15
16
  "asksQuestions": false,
16
17
  "order": 0.5,
17
18
  "scope": "workspace-only"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/app",
3
- "version": "1.0.0",
3
+ "version": "1.2.0-rc.1",
4
4
  "description": "Worca — deterministic multi-agent pipeline that drives Claude Code (headless) through Plan -> Refine -> Implement -> Review, with a CLI, an installable /worca skill, and a web UI.",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -46,10 +46,16 @@
46
46
  "smoke": "WORCA_MOCK=1 WORCA_HOME=.worca-cc-smoke node --disable-warning=ExperimentalWarning src/cli/worca-cc.mjs --project sandbox --prompt \"demo task\" --mock --yes",
47
47
  "smoke:workspace": "WORCA_MOCK=1 WORCA_HOME=.worca-cc-smoke node --disable-warning=ExperimentalWarning scripts/smoke-workspace.mjs",
48
48
  "smoke:plugin": "WORCA_MOCK=1 WORCA_HOME=.worca-cc-smoke node --disable-warning=ExperimentalWarning scripts/smoke-plugin.mjs",
49
- "test": "rm -rf .worca-cc-test && WORCA_HOME=.worca-cc-test node --disable-warning=ExperimentalWarning --test test/*.mjs"
49
+ "ask:fixtures": "node --disable-warning=ExperimentalWarning scripts/ask-capture-fixtures.mjs",
50
+ "verify:composer": "node --disable-warning=ExperimentalWarning scripts/verify-composer-cdp.mjs",
51
+ "verify:run-monitor": "node --disable-warning=ExperimentalWarning scripts/verify-run-monitor-cdp.mjs",
52
+ "test": "rm -rf .worca-cc-test && PATH=\"$PWD/test/helpers/no-real-claude:$PATH\" WORCA_HOME=.worca-cc-test node --disable-warning=ExperimentalWarning --test test/*.mjs; s=$?; node test/helpers/no-real-claude/assert-none.mjs || s=1; exit $s"
50
53
  },
51
54
  "dependencies": {
55
+ "@highlightjs/cdn-assets": "11.12.0",
56
+ "dompurify": "3.4.14",
52
57
  "express": "^4.19.2",
58
+ "marked": "18.0.10",
53
59
  "ws": "^8.18.0"
54
60
  },
55
61
  "devDependencies": {
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: worca
3
- description: Run the deterministic multi-agent orchestrator (Plan -> Refine -> Implement -> Review) over a software task in the current project. Triggers on "/worca", "/worca <prompt>", "/worca --ui", and on requests to orchestrate, run the orchestration pipeline, or drive Claude Code through plan/refine/implement/review for a task.
3
+ description: Run the node-graph multi-agent orchestrator over a software task in the current project. Triggers on "/worca", "/worca <prompt>", "/worca --ui", and on requests to orchestrate, run the orchestration pipeline, or drive plan/refine/implement/review for a task.
4
4
  ---
5
5
 
6
6
  # Orchestrate
7
7
 
8
- Drive the current project through the orchestrator pipeline: **Preflight -> Plan -> Refine (loop) -> Implement -> Review (loop) -> Done**. Orchestration is performed by a deterministic Node.js script; this skill just launches it. Artifacts (plans, reviews, pipeline audit logs) are written under `ai-artifacts/` in the project.
8
+ Drive the current project through the selected pipeline template (default `wf_default`: Plan Refine Implement Review End). Orchestration is performed by a deterministic Node.js script; this skill just launches it. Artifacts (plans, reviews, pipeline audit logs) are written under `ai-artifacts/` in the project.
9
9
 
10
10
  The orchestrator repo lives wherever it was installed. `<WORCA_REPO>` below is the absolute path of that repo (the directory containing `src/cli/worca-cc.mjs`). If you installed via `scripts/install.mjs`, the installer rewrites `<WORCA_REPO>` in this file to the real path automatically; otherwise substitute it yourself (or set an `WORCA_REPO` environment variable and use `"$WORCA_REPO"`).
11
11
 
@@ -19,13 +19,13 @@ node <WORCA_REPO>/src/cli/worca-cc.mjs --project "$PWD" --prompt "<args>"
19
19
 
20
20
  - `--project "$PWD"` — operate inside the user's current project (the orchestrator does all file writes here).
21
21
  - `--prompt "<args>"` — everything the user typed after `/worca`. Quote it.
22
- - The CLI streams phase changes and live agent logs to the terminal. When the clarify step needs a decision it shows each question's 2–4 options plus a free-text field; when a refine/review loop hits its cap it shows the open critical/major issues and asks whether to continue or approve another cycle. Answer interactively.
22
+ - The CLI streams every agent execution (start/done, cycle, cost) and live agent logs to the terminal. When the clarify step needs a decision it shows each question's 2–4 options plus a free-text field; when a refine/review loop hits its cap it shows the open critical/major issues and asks whether to continue or approve another cycle. Answer interactively.
23
23
  - On completion it prints the pipeline directory under `ai-artifacts/pipelines/`.
24
24
 
25
25
  Useful flags (pass through when the user asks):
26
26
  - `--file <path.md>` — use a markdown file as the prompt instead of `--prompt`.
27
27
  - `--title "<name>"` — label the pipeline.
28
- - `--max-refine <N>` / `--max-review <N>` change loop caps (default 5 each).
28
+ - `--workflow <id>` run a saved pipeline template instead of the built-in default (`wf_default`).
29
29
  - `--model <m>` / `--permission-mode <m>` — Claude model / permission mode (default `acceptEdits`).
30
30
  - `--mock` — run the full pipeline offline with canned agents (no Claude spawn, no tokens); great for a dry run. Equivalent to setting `WORCA_MOCK=1`.
31
31
  - `--yes` / `--non-interactive` — auto-answer (clarify picks the first option; gates choose "continue"). Use for unattended runs.
@@ -39,7 +39,7 @@ node <WORCA_REPO>/src/cli/worca-cc.mjs \
39
39
 
40
40
  ## /worca --ui — launch the web UI
41
41
 
42
- To start the web app (new-pipeline form, step tracker, live log window, question + loop-gate panels, Stop button, run history):
42
+ To start the web app (new-pipeline form, live pipeline graph, live log window, question + loop-gate panels, Stop button, run history):
43
43
 
44
44
  ```bash
45
45
  node <WORCA_REPO>/src/cli/worca-cc.mjs --ui
@@ -0,0 +1,148 @@
1
+ // src/cli/render.mjs
2
+ //
3
+ // The CLI's rendering of the graph engine's `exec` stream. PURE: no IO, no
4
+ // colour codes of its own — the caller injects `color(name, text)`. Labels come
5
+ // from the run manifest (pipelines.stepper); execution ids are NEVER printed.
6
+ //
7
+ // The line shapes (spec §8, literal):
8
+ // ▶ Implementer #2 · fix ← Reviewer agent start (loop port from trigger.freshPorts; source = the wire's from-node)
9
+ // ▶ task 3/7 · Add schema kind:'task' slice start (indented; index within its phase)
10
+ // ✓ Implementer #2 1m03s · $0.12 agent done (verifiers append " — blocking" / " — clean")
11
+ // ✓ OR · OR → Implementer flow-node done: ONE dim line, no ordinal/duration/cost; the
12
+ // marker only for AND / OR / COMBINE (`✓ Task` is bare)
13
+ // ✗ Reviewer #1 12s — <error> agent error
14
+ // ⏸ Implementer #1 paused agent paused
15
+ // ■ End ← Reviewer.pass → plan-review.md End bound — End's ONLY line
16
+ // Flow nodes never print a start/paused/error line; `skipped` and P8's bookend
17
+ // executions render nothing; `token` events are never rendered.
18
+ import { BOOKEND_EXECUTION_IDS } from '../shared/graph/constants.mjs';
19
+
20
+ const nodesOf = (m) => ((m && m.graph && m.graph.nodes) || []).filter(Boolean);
21
+ const wiresOf = (m) => ((m && m.graph && m.graph.wires) || []).filter(Boolean);
22
+ const nodeOf = (m, id) => nodesOf(m).find((n) => n.id === id) || null;
23
+ const labelOf = (m, id) => { const n = nodeOf(m, id); return (n && (n.label || n.id)) || id; };
24
+ const base = (p) => String(p || '').split('/').filter(Boolean).pop() || '';
25
+ /** Flow kinds whose done line carries a marker (` · AND`, ` · OR → X`, ` · COMBINE → X`). */
26
+ const MARKED_FLOW = new Set(['and', 'or', 'combine']);
27
+
28
+ /** `12s` / `1m03s` / `1h01m` — the CLI's compact shape (NOT the UI's `1m 3s`). */
29
+ export function fmtDur(ms) {
30
+ const s = Math.max(0, Math.round((Number(ms) || 0) / 1000));
31
+ if (s < 60) return `${s}s`;
32
+ const m = Math.floor(s / 60);
33
+ if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`;
34
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`;
35
+ }
36
+ const usd = (n) => `$${(Number(n) || 0).toFixed(2)}`;
37
+
38
+ /** ` · fix ← Reviewer`: the loop port that re-fired this execution
39
+ * (trigger.freshPorts ∩ the node's `loop: true` inputs) and the node that
40
+ * published on the wire that DELIVERED it. Through an OR valve that wire is
41
+ * the valve's out-wire, so the source reads `← OR` (probe P13). */
42
+ function loopSource(ev, node, m) {
43
+ const loopIns = new Set(((node && node.ports && node.ports.inputs) || []).filter((p) => p && p.loop).map((p) => p.id));
44
+ const trig = ev.trigger || {};
45
+ const port = (trig.freshPorts || []).find((p) => loopIns.has(p));
46
+ if (!port) return '';
47
+ const wire = wiresOf(m).find((w) => (trig.wireIds || []).includes(w.id) && w.to && w.to.port === port);
48
+ return wire ? ` · ${port} ← ${labelOf(m, wire.from.node)}` : ` · ${port}`;
49
+ }
50
+
51
+ /** The dim marker an AND/OR/COMBINE card carries: ` · AND`, ` · OR → Implementer`. */
52
+ function flowMarker(node, m, color) {
53
+ const kind = String(node.kind).toUpperCase();
54
+ const out = wiresOf(m).find((w) => w.from && w.from.node === node.id);
55
+ return color('dim', out ? ` · ${kind} → ${labelOf(m, out.to.node)}` : ` · ${kind}`);
56
+ }
57
+
58
+ /** ONE `exec` event -> ONE terminal line ('' when the event renders nothing). */
59
+ export function formatExecLine(ev, manifest, { color = (n, s) => s } = {}) {
60
+ if (!ev || !ev.nodeId) return '';
61
+ if (BOOKEND_EXECUTION_IDS.includes(ev.executionId)) return ''; // P8's preflight/done rows render nothing
62
+ const m = manifest || {};
63
+ const node = nodeOf(m, ev.nodeId);
64
+ const label = labelOf(m, ev.nodeId);
65
+ if (ev.kind === 'task') { // a composite slice: one indented start line, nothing else
66
+ if (ev.status !== 'start') return '';
67
+ const n = ev.taskIndex, t = ev.taskTotal;
68
+ const which = Number.isFinite(n) && Number.isFinite(t) ? ` ${n}/${t}` : '';
69
+ return ` ${color('cyan', '▶')} task${which}${ev.title ? ` · ${ev.title}` : ''}`;
70
+ }
71
+ if (node && node.kind === 'end') { // End renders ONE line: the binding
72
+ if (ev.status !== 'done') return '';
73
+ const wire = wiresOf(m).find((w) => ((ev.trigger && ev.trigger.wireIds) || []).includes(w.id));
74
+ const from = wire ? ` ← ${labelOf(m, wire.from.node)}.${wire.from.port}` : '';
75
+ const r = ev.result || {};
76
+ const tail = r.path ? ` → ${base(r.path)}` : (r.value != null ? ` → ${String(r.value)}` : '');
77
+ return `${color('bold', '■')} ${label}${from}${tail}`;
78
+ }
79
+ if (node && node.kind !== 'agent') { // a flow card: one dim ✓ line, no ordinal / duration / cost
80
+ if (ev.status !== 'done') return '';
81
+ return `${color('green', '✓')} ${label}${MARKED_FLOW.has(node.kind) ? flowMarker(node, m, color) : ''}`;
82
+ }
83
+ const ord = ` #${ev.ordinal ?? 1}`;
84
+ if (ev.status === 'start') return `${color('cyan', '▶')} ${label}${ord}${loopSource(ev, node, m)}`;
85
+ if (ev.status === 'paused') return `${color('yellow', '⏸')} ${label}${ord} paused`;
86
+ const dur = ev.durationMs != null ? ` ${fmtDur(ev.durationMs)}` : '';
87
+ if (ev.status === 'error') return `${color('red', '✗')} ${label}${ord}${dur} — ${ev.error || 'failed'}`;
88
+ if (ev.status !== 'done') return ''; // `skipped` (and anything unknown) renders nothing
89
+ const cost = ev.costUsd != null ? ` · ${usd(ev.costUsd)}` : '';
90
+ const verdict = ev.verdict ? (ev.verdict.missing ? ' — no verdict written (treated as clean)' : ev.verdict.hasBlocking ? ' — blocking' : ' — clean') : '';
91
+ return `${color('green', '✓')} ${label}${ord}${dur}${cost}${verdict}`;
92
+ }
93
+
94
+ /** The interactive gate prompt's header. */
95
+ export function formatGateHeader(payload, manifest) {
96
+ const m = manifest || {};
97
+ const wire = wiresOf(m).find((w) => w.id === (payload && payload.wireId)) || null;
98
+ const where = wire ? ` · ${labelOf(m, wire.from.node)} → ${labelOf(m, wire.to.node)}` : '';
99
+ const max = wire && wire.maxCycles ? Number(wire.maxCycles) : null;
100
+ // The cycle comes from the PAYLOAD's `deliveryNo`, never from the id: a wire that
101
+ // holds more than once mints `gate-<wireId>-<deliveryNo>-h<holdNo>`, so the id's
102
+ // trailing number is the HOLD ordinal, not the delivery (MAJ-11). No deliveryNo
103
+ // (an older resume point, a hand-built payload) falls back to the budget.
104
+ const n = Number(payload && payload.deliveryNo);
105
+ const used = Number.isFinite(n) && n > 0 ? n : max;
106
+ const budget = max ? ` ${used || max}/${max} cycles used` : '';
107
+ return `? Loop gate${where}${budget}`;
108
+ }
109
+
110
+ /** The summary's result line. */
111
+ export function formatResultLine(result) {
112
+ const r = result || {};
113
+ if (r.path) return `Result: ${r.path}`;
114
+ if (r.value != null && r.value !== '') return `Result: ${String(r.value)}`;
115
+ return 'Result: completed';
116
+ }
117
+
118
+ /** `9 executions · 12m00s active · $1.23`. */
119
+ export function formatTotals({ executions = 0, activeMs = 0, costUsd = 0 } = {}) {
120
+ return `${executions} execution${executions === 1 ? '' : 's'} · ${fmtDur(activeMs)} active · ${usd(costUsd)}`;
121
+ }
122
+
123
+ /**
124
+ * The v2 run summary the CLI prints under `Pipeline complete.` — pure, so the
125
+ * quiescence arm and the executions count are testable without spawning a run.
126
+ * Returns [] for a v1 run. An EXECUTION row carries an executionId that is not
127
+ * a bookend id: today's bookends are `_recordStep('preflight'|'done', …)` rows
128
+ * with key 'preflight' / 'done' and NO executionId; P8's become real
129
+ * x:preflight:1 / x:done:1 executions (BOOKEND_EXECUTION_IDS). The active time
130
+ * is the SUM over execution rows — state.totalActiveMs includes preflight.
131
+ */
132
+ export function formatRunSummary(state) {
133
+ const st = state || {};
134
+ if (!(st.stepper && st.stepper.version === 2)) return [];
135
+ const rows = (Array.isArray(st.steps) ? st.steps : [])
136
+ .filter((s) => s && s.executionId && !BOOKEND_EXECUTION_IDS.includes(s.executionId));
137
+ const lines = [];
138
+ // Quiescence is the reducer's rule: the run reached `done` WITHOUT the End node
139
+ // firing. A stopped/errored run also has endReached false and must NOT claim it.
140
+ if (st.endReached === false && String(st.status || '').toLowerCase() === 'done') lines.push('Finished at quiescence — End not reached');
141
+ else lines.push(formatResultLine(st.result));
142
+ lines.push(formatTotals({
143
+ executions: rows.length,
144
+ activeMs: rows.reduce((a, s) => a + (Number(s.activeMs) || 0), 0),
145
+ costUsd: st.totalCostUsd,
146
+ }));
147
+ return lines;
148
+ }