@worca/app 1.0.0 → 1.1.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 (138) hide show
  1. package/README.md +22 -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 +319 -45
  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 +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,894 @@
1
+ // src/core/graph/executor.mjs
2
+ //
3
+ // The generic execution layer of the node-graph engine: output allocation from
4
+ // filename templates, the "## Ports (this run)" prompt block, prompt assembly, the
5
+ // clarifier gate, and the five flow-node executors.
6
+ //
7
+ // GENERICITY CHARTER (hard rule for this module): there is NO agent-key branch
8
+ // anywhere. Executor selection is `node.kind` + `meta.runnerType`; renderer selection
9
+ // is the port's `as`; mode selection is port FRESHNESS; the offline MOCK role comes
10
+ // from the generic resolution chain below; the decomposition contract renders for
11
+ // any node whose output is wired into an `expands` input. Everything that used to be
12
+ // a bespoke per-role runner is now data on the sidecar.
13
+ //
14
+ // The prompt machinery deliberately REUSES phases.mjs (taskHeader, runOpts,
15
+ // buildSystemPrompt, mockMarkers, siblingsBlock, diffInstruction, the fan-out
16
+ // directives) rather than forking it, so the v2 prompts keep today's load-bearing
17
+ // bytes. `test/graph-prompt-parity.test.mjs` is the contract.
18
+ //
19
+ // ── THE DECOMPOSITION CONTRACT ───────────────────────────────────────────────
20
+ // One document, no owner: ANY producer may emit it on a json output port, and ANY
21
+ // node with an `expands` input may consume it. Neither side is named anywhere in the
22
+ // engine — the relationship IS the wire (`expandsOutputPort`).
23
+ //
24
+ // { "phases": [ { "ordinal": <int>,
25
+ // "tasks": [ { "id": <string>, "title": <string?>,
26
+ // "file": <pipelineDir-relative markdown path> } ] } ] }
27
+ //
28
+ // The parse is TOLERANT: a missing file, invalid JSON, a non-array `phases`, a phase
29
+ // without a usable ordinal or without runnable tasks, and a task missing `id` or
30
+ // `file` are all DROPPED rather than thrown on. `phases.length === 0` means "there is
31
+ // nothing to fan out": the consumer then runs ONE ordinary execution with its expands
32
+ // input left UNBOUND. The composite DRIVER is scheduler.mjs; this module owns the
33
+ // document — including the prompt block that tells a producer where to write the
34
+ // task files and what the manifest looks like.
35
+ import { join, dirname, relative, basename } from 'node:path';
36
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
37
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
38
+
39
+ import {
40
+ runClaude, MOCK_WRITER_ROLES, MOCK_ROLE_CLARIFY, MOCK_ROLE_DECOMPOSER,
41
+ } from '../claude-runner.mjs';
42
+ import { planPath, reviewPath, writeStepQuestions, writeClarify } from '../artifacts.mjs';
43
+ import { readReview, normalizeClarify, normalizeReview, safeParseJson } from '../protocol.mjs';
44
+ import {
45
+ taskHeader, buildSystemPrompt, resolveAgentBody, mockMarkers, runOpts,
46
+ fanOutDirective, ctxFanOut, ctxSubagentModel, ctxEndpointRouted, workspaceFanOutDirective, workspaceDiffInstruction,
47
+ renderAnswers, siblingsBlock, diffInstruction, READ_WRITE_TOOLS, IMPLEMENTER_TOOLS,
48
+ } from '../phases.mjs';
49
+ import { SUBAGENT_MODELS } from '../model-env.mjs';
50
+ import { AWAIT_PORT } from '../../shared/graph/constants.mjs';
51
+
52
+ /** The reserved synthesized gate input. Scheduler-only: it never reaches `bindings`,
53
+ * is never listed in the Ports block, selects no mode, and carries no renderer. */
54
+ const AWAIT_ID = AWAIT_PORT.id;
55
+
56
+ /** The clarifier gate's ask kind — the same token the mock writer role uses, so it is
57
+ * referenced through the imported constant rather than a bare agent-key literal. */
58
+ const CLARIFY_ASK_KIND = MOCK_ROLE_CLARIFY;
59
+
60
+ /** The verdict-contract reminder every node with a declared verdict carries
61
+ * (phases.mjs:879-881, verbatim). */
62
+ export const VERDICT_CONTRACT =
63
+ 'The review JSON shape is { "issues": [ { "severity", "title", "detail", "location" } ], ' +
64
+ '"summary" }. Use severities critical|major|minor|suggestion; only critical/major block the ' +
65
+ 'pipeline.\n\n';
66
+
67
+ // ── allocation ────────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Resolve one filename template into `{ path, store }`. Tokens: `{cycle}` -> the
71
+ * execution ordinal, `{base}` -> the run base name, `{vsuffix}` -> the run-global
72
+ * plan-version suffix ('' for version 1, '-vN' after). `{vsuffix}` CONSUMES one tick
73
+ * of `runCtx.planVersion()`, and only when the template actually carries it.
74
+ * The duplicate-key/slice `prefix` applies to EVERY store: the plans/reviews store is
75
+ * one file per base name (v1 parity), so without it two cards on one agent key —
76
+ * trivial to place in the composer — resolve to ONE persisted path and the later
77
+ * writer clobbers the earlier. The prefix is EMPTY for a single card, so every
78
+ * single-card graph keeps its v1 path byte-for-byte.
79
+ */
80
+ function resolveTemplate(port, { ordinal, runCtx, prefix }) {
81
+ const tpl = String(port.filename);
82
+ const store = port.store || 'run';
83
+ let version = null;
84
+ const nextVersion = () => {
85
+ if (version === null) {
86
+ version = typeof runCtx.planVersion === 'function' ? Number(runCtx.planVersion()) || 1 : 1;
87
+ }
88
+ return version;
89
+ };
90
+ const name = tpl
91
+ .replace(/\{cycle\}/g, String(ordinal))
92
+ .replace(/\{base\}/g, String(runCtx.baseName || ''))
93
+ .replace(/\{vsuffix\}/g, () => (nextVersion() > 1 ? `-v${nextVersion()}` : ''));
94
+
95
+ if (store !== 'project') return { path: join(runCtx.pipelineDir, prefix + name), store };
96
+
97
+ // The prefix rides the discriminating half of each store's name so the -vN
98
+ // linkage still hangs off the node's OWN plan family: plans get it on the base
99
+ // (`<date>-<prefix><base>[-vN].md`), reviews on the kind (`<date>-<base>-<prefix><kind>.md`).
100
+ if ((port.artifactKind || port.id) === 'plan') {
101
+ const v = tpl.includes('{vsuffix}') ? nextVersion() : 1;
102
+ return {
103
+ path: planPath(runCtx.projectDir, prefix + String(runCtx.baseName || ''), v, runCtx.datePrefix, runCtx.workspaceKey),
104
+ store,
105
+ };
106
+ }
107
+ const m = /^\{base\}-(.+)\.md$/.exec(tpl);
108
+ const kind = m ? m[1] : (port.artifactKind || port.id);
109
+ return {
110
+ path: reviewPath(runCtx.projectDir, runCtx.baseName, runCtx.datePrefix, prefix + kind, runCtx.workspaceKey),
111
+ store,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * DUPLICATE-KEY RULE (generic): when two or more agent nodes share one agent key,
117
+ * every `store:'run'` output and the verdict of those nodes is prefixed `<nodeId>-`.
118
+ * `runCtx.slice` extends the same rule to a COMPOSITE fan-out: every sub-execution of
119
+ * one composite shares its parent's ordinal, so without a per-task prefix the parallel
120
+ * slices would resolve to one filename and clobber each other.
121
+ */
122
+ function dupPrefix(node, runCtx) {
123
+ const dup = runCtx && runCtx.duplicateKey ? `${node.id}-` : '';
124
+ const slice = runCtx && runCtx.slice ? `${runCtx.slice}-` : '';
125
+ return dup + slice;
126
+ }
127
+
128
+ /** The combine card's own allocation: one md artifact per emission. */
129
+ function combinePath(node, ordinal, runCtx) {
130
+ return join(runCtx.pipelineDir, `combine-${node.id}-c${ordinal}.md`);
131
+ }
132
+
133
+ /** Where a decomposition's task files live: `<pipelineDir>/tasks` — v1's
134
+ * `join(dirname(decompositionPath), 'tasks')` for a run-store manifest. ONE helper
135
+ * feeds both the prompt block and the mock's `MOCK_TASKS_DIR`. */
136
+ function tasksDirOf(runCtx) {
137
+ return join(String(runCtx?.pipelineDir || ''), 'tasks');
138
+ }
139
+
140
+ /**
141
+ * Allocate this execution's output paths, keyed by port id. Outputs whose templates
142
+ * are IDENTICAL resolve to ONE `{path, store}` object (the refiner's `plan`/`revise`
143
+ * pair is the live case): each distinct template is evaluated exactly ONCE per
144
+ * execution, so a refine cycle consumes one plan version, not two.
145
+ * @returns {Record<string, {path:string, store:string}>}
146
+ */
147
+ export function allocateOutputs({ node, ports, executionId, ordinal = 1, runCtx = {} }) { // eslint-disable-line no-unused-vars
148
+ const out = {};
149
+ if (node?.kind === 'combine') {
150
+ out.out = { path: combinePath(node, ordinal, runCtx), store: 'run' };
151
+ return out;
152
+ }
153
+ const prefix = dupPrefix(node, runCtx);
154
+ const byTemplate = new Map();
155
+ for (const port of ports?.outputs || []) {
156
+ if (!port || !port.filename) continue;
157
+ const cacheKey = JSON.stringify([port.store || 'run', port.filename]);
158
+ if (!byTemplate.has(cacheKey)) {
159
+ byTemplate.set(cacheKey, resolveTemplate(port, { ordinal, runCtx, prefix }));
160
+ }
161
+ out[port.id] = byTemplate.get(cacheKey);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /** The node-level verdict allocation (a verdict is NOT a port). Always lands in the
167
+ * pipeline dir, and carries the duplicate-key prefix for the same reason. */
168
+ export function allocateVerdict({ node, ports, ordinal = 1, runCtx = {} }) {
169
+ const filename = ports?.verdict?.filename;
170
+ if (!filename) return null;
171
+ const { path } = resolveTemplate(
172
+ { id: 'verdict', filename, store: 'run' },
173
+ { ordinal, runCtx, prefix: dupPrefix(node, runCtx) },
174
+ );
175
+ return { path };
176
+ }
177
+
178
+ // ── the Ports block ───────────────────────────────────────────────────────────
179
+
180
+ /** True on a detached WORKSPACE run: cwd is the run root and the members live at
181
+ * `repos/<projectKey>` inside it (the same test phases.mjs#isDetachedWorkspace makes). */
182
+ function detachedWorkspace(ctx) {
183
+ return !!(ctx?.runRoot) && (ctx?.workspace?.projects || []).length > 0;
184
+ }
185
+
186
+ /**
187
+ * The `{diffInstruction}` prompt-hint token: HOW to inspect the implemented changes,
188
+ * as a FRAGMENT that sits inside the hint's own sentence — v1's checklist
189
+ * `changesInstruction` (phases.mjs:1076-1084). Single project: the v1 parenthetical
190
+ * bytes; detached workspace: the v1 run-root sentence + the per-member
191
+ * `git -C repos/<key> diff <ref>` lines. This is NOT the reviewer's diff sentence —
192
+ * that one is a full sentence and belongs to the `as:'worktree'` renderer
193
+ * (`diffInstruction`); the two are different v1 byte sets and never share a helper.
194
+ * Pure + exported (the parity suite pins both arms).
195
+ */
196
+ export function changesInstruction(ctx) {
197
+ const perMember = detachedWorkspace(ctx) ? workspaceDiffInstruction(ctx) : '';
198
+ return perMember
199
+ ? 'in EVERY member checkout — your cwd is the worca-cc run root, not a repository, so ' +
200
+ 'inspect each member on its own:\n\n' + perMember + '\n\n'
201
+ : 'via `git diff` in your cwd';
202
+ }
203
+
204
+ /**
205
+ * Per-port input renderers, selected by the port's `as` (default `file`) — NEVER
206
+ * inferred from the port id or the agent key. These are the generalized form of v1's
207
+ * bespoke prompt arms. `worktree` renders the v1 reviewer bytes: the checkpoint-ref
208
+ * diff sentence, or the per-member `git -C repos/<key> diff <ref>` lines on a
209
+ * detached workspace.
210
+ */
211
+ const INPUT_RENDERERS = {
212
+ file: (t) => t.path || null,
213
+ answers: (t) => (t.path ? `${t.path} (the clarifying questions and the answers already given)` : null),
214
+ 'fix-review': (t) => (t.path
215
+ ? `${t.path} (the review to address — fix EVERY critical and major issue)`
216
+ : null),
217
+ worktree: (t, ctx) => (detachedWorkspace(ctx) ? workspaceDiffInstruction(ctx) : diffInstruction(ctx)),
218
+ };
219
+
220
+ /**
221
+ * The generated "## Ports (this run)" block: every BOUND input bound through its `as`
222
+ * renderer, and every declared output bound to its allocated path. Conditional
223
+ * outputs are listed on EVERY execution (`when` gates token ROUTING only, so a
224
+ * passing verifier still writes its review markdown and its verdict exactly as
225
+ * today). Outputs sharing ONE allocated path render ONE line. The synthesized
226
+ * `await` input is never listed.
227
+ */
228
+ export function portIoBlock({ node, ports, bindings = {}, outputs = {}, verdict = null, ctx = {} }) { // eslint-disable-line no-unused-vars
229
+ const inLines = [];
230
+ for (const port of ports?.inputs || []) {
231
+ if (!port || port.id === AWAIT_ID) continue;
232
+ const token = bindings[port.id];
233
+ if (!token) continue;
234
+ const render = INPUT_RENDERERS[port.as || 'file'] || INPUT_RENDERERS.file;
235
+ const target = render(token, ctx);
236
+ if (!target) continue;
237
+ inLines.push(`- **${port.id}** (${token.type || port.type}) -> ${target}`);
238
+ }
239
+ const outLines = [];
240
+ const byPath = new Map(); // path -> [portId, …] in declared order
241
+ for (const port of ports?.outputs || []) {
242
+ const path = outputs[port?.id]?.path;
243
+ if (!path) continue;
244
+ if (!byPath.has(path)) byPath.set(path, []);
245
+ byPath.get(path).push(port.id);
246
+ }
247
+ for (const [path, ids] of byPath) {
248
+ const also = ids.slice(1).map((id) => `**${id}**`).join(', ');
249
+ outLines.push(`- Write **${ids[0]}**${also ? ` (also ${also})` : ''} to: ${path}`);
250
+ }
251
+ if (verdict?.path) {
252
+ outLines.push(`- Write the **verdict** JSON (machine-readable) to: ${verdict.path}`);
253
+ }
254
+ return (
255
+ '## Ports (this run)\n\n' +
256
+ '### Inputs\n\n' +
257
+ (inLines.length ? inLines.join('\n') : '- (none — work from the request above)') +
258
+ '\n\n### Outputs\n\n' +
259
+ (outLines.length ? outLines.join('\n') : '- (none — report your findings as your final message)') +
260
+ '\n\n'
261
+ );
262
+ }
263
+
264
+ // ── A3: mode selection is port FRESHNESS ──────────────────────────────────────
265
+
266
+ /**
267
+ * Amendment A3 (parity-mandatory): an input's `directive` renders — and its mode
268
+ * applies — ONLY when that port is FRESH for this execution, and only the FIRST such
269
+ * port in DECLARED order wins. A latched loop-input token never selects a mode, which
270
+ * is the token-model equivalent of v1's publish-clears-review. First executions list
271
+ * every bound port as fresh. The synthesized `await` port never participates.
272
+ * @returns {{mode: string|null, directives: Array<{id:string, directive:string, token:object}>}}
273
+ */
274
+ export function selectMode({ ports, bindings = {}, freshPorts }) {
275
+ const fresh = new Set(Array.isArray(freshPorts) ? freshPorts : Object.keys(bindings));
276
+ for (const port of ports?.inputs || []) {
277
+ if (!port || port.id === AWAIT_ID || !port.directive) continue;
278
+ if (!fresh.has(port.id) || !bindings[port.id]) continue;
279
+ return { mode: port.id, directives: [{ id: port.id, directive: String(port.directive), token: bindings[port.id] }] };
280
+ }
281
+ return { mode: null, directives: [] };
282
+ }
283
+
284
+ /** Render the selected mode's arm: the announcement, the directive, and the path it
285
+ * points at. Empty string when no fresh port carries a directive. */
286
+ function modeBlock({ mode, directives }) {
287
+ if (!directives.length) return '';
288
+ return (
289
+ `Mode: ${mode}\n\n` +
290
+ directives
291
+ .map((d) => d.directive.trim() + '\n\n' + (d.token?.path ? `${d.id}: ${d.token.path}\n\n` : ''))
292
+ .join('')
293
+ );
294
+ }
295
+
296
+ // ── graph-derived facts the prompt and the mock chain need ────────────────────
297
+
298
+ /**
299
+ * The input ports of `nodeId` fed by a `kind:'task'` node — what replaces v1's
300
+ * hardcoded agent-key test for "who gets the raw request and the attachments":
301
+ * binding the task document IS the entry relationship.
302
+ * @returns {Set<string>}
303
+ */
304
+ export function taskSourcedPorts(template, nodeId) {
305
+ const kinds = new Map((template?.nodes || []).map((n) => [n.id, n.kind]));
306
+ const out = new Set();
307
+ for (const w of template?.wires || []) {
308
+ if (w?.to?.node === nodeId && kinds.get(w?.from?.node) === 'task') out.add(w.to.port);
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /** This node's output port that is wired into an `expands` input, if any — the
314
+ * graph-derived fact that makes a node "the thing that decomposes" without ever
315
+ * naming a key. Returns the port id, or null. */
316
+ export function expandsOutputPort(template, portsFn, nodeId) {
317
+ const byId = new Map((template?.nodes || []).map((n) => [n.id, n]));
318
+ for (const w of template?.wires || []) {
319
+ if (w?.from?.node !== nodeId) continue;
320
+ const target = byId.get(w?.to?.node);
321
+ if (!target) continue;
322
+ const input = (portsFn(target)?.inputs || []).find((i) => i.id === w.to.port);
323
+ if (input?.expands) return w.from.port;
324
+ }
325
+ return null;
326
+ }
327
+
328
+ // ── the decomposition document ────────────────────────────────────────────────
329
+
330
+ /**
331
+ * Normalize a decomposition document into the engine's canonical shape. PURE and
332
+ * TOTAL: every input — including null, a bare array, a number — resolves to
333
+ * `{ phases: [...] }`, dropping whatever cannot be run rather than throwing. Phases
334
+ * come back ordinal-sorted.
335
+ */
336
+ export function normalizeDecomposition(raw) {
337
+ const phases = [];
338
+ for (const ph of Array.isArray(raw?.phases) ? raw.phases : []) {
339
+ const ordinal = Number(ph?.ordinal);
340
+ if (!Number.isFinite(ordinal)) continue;
341
+ const tasks = (Array.isArray(ph?.tasks) ? ph.tasks : [])
342
+ .filter((t) => t && t.id && t.file)
343
+ .map((t) => ({ id: String(t.id), title: t.title == null ? null : String(t.title), file: String(t.file) }));
344
+ if (!tasks.length) continue; // a phase with nothing to run is not a phase
345
+ phases.push({ ordinal, tasks });
346
+ }
347
+ phases.sort((a, b) => a.ordinal - b.ordinal);
348
+ return { phases };
349
+ }
350
+
351
+ /** Read a decomposition document off disk through the tolerant parse. Never throws
352
+ * and never rejects. */
353
+ export async function readDecomposition(path) {
354
+ if (!path) return { phases: [] };
355
+ try {
356
+ return normalizeDecomposition(JSON.parse(await readFile(path, 'utf8')));
357
+ } catch {
358
+ return { phases: [] };
359
+ }
360
+ }
361
+
362
+ // ── the generic MOCK_ROLE resolution chain ────────────────────────────────────
363
+
364
+ /**
365
+ * Resolve the offline mock writer role, generically:
366
+ * 1. a validated `meta.mockRole` (the builtins pin today's writer table);
367
+ * 2. else a clarifier runner;
368
+ * 3. else a node whose output feeds an `expands` input (graph-derived);
369
+ * 4. else a node with a declared verdict;
370
+ * 5. else the generic producer.
371
+ * The chain can only ever yield a role the mock writer actually handles, which is what
372
+ * lets an all-custom graph complete offline.
373
+ */
374
+ export function resolveMockRole({ meta, expandsPort = null }) {
375
+ const declared = meta?.mockRole;
376
+ if (declared && MOCK_WRITER_ROLES.has(declared)) return declared;
377
+ if (meta?.runnerType === 'clarifier') return MOCK_ROLE_CLARIFY;
378
+ if (expandsPort) return MOCK_ROLE_DECOMPOSER;
379
+ if (meta?.verdict) return 'generic-verifier';
380
+ return 'generic-producer';
381
+ }
382
+
383
+ // ── verdicts ──────────────────────────────────────────────────────────────────
384
+
385
+ /** The text every unparseable verdict file fails with. */
386
+ const BAD_VERDICT_TAIL = 'expected { "issues": [ \u2026 ] }';
387
+
388
+ /**
389
+ * Read a node's verdict JSON back through the protocol normalizer.
390
+ *
391
+ * Two degenerate cases, deliberately split (they are NOT the same failure):
392
+ * - the file was NEVER WRITTEN -> `{issues: [], summary: '', missing: true}`: a clean
393
+ * pass, v1 parity, because an agent that declares a verdict and writes none must not
394
+ * fail a run. `missing` is the flag the caller turns into a warning (and the reason
395
+ * the reviews table skips the row) instead of a phantom zero-issue review.
396
+ * - the file EXISTS but does not parse, or carries no `issues` array -> THROW. The
397
+ * verifier wrote garbage, and on every shipped seed the clean side is wired straight
398
+ * to End, so "no issues" there is indistinguishable from an approval. Fail-fast owns
399
+ * the rest.
400
+ *
401
+ * `readReview` is untouched for its other callers (it is v1 code with its own tolerant
402
+ * contract); the existsSync + parse-failure branch lives here.
403
+ */
404
+ export async function readVerdict(verdictPath) {
405
+ if (!verdictPath) return { issues: [], summary: '' };
406
+ if (!existsSync(verdictPath)) return { issues: [], summary: '', missing: true };
407
+ let text;
408
+ try {
409
+ text = await readFile(verdictPath, 'utf8');
410
+ } catch (err) {
411
+ throw Object.assign(new Error(`verdict file unreadable: ${verdictPath} — ${err?.message || err}`),
412
+ { code: 'BAD_VERDICT' });
413
+ }
414
+ const data = safeParseJson(text);
415
+ if (!data || typeof data !== 'object' || !Array.isArray(data.issues)) {
416
+ throw Object.assign(new Error(`verdict file is not a review JSON: ${verdictPath} — ${BAD_VERDICT_TAIL}`),
417
+ { code: 'BAD_VERDICT' });
418
+ }
419
+ return normalizeReview(data);
420
+ }
421
+
422
+ /** The warning line a missing verdict raises, relative to the pipeline dir so the
423
+ * run log stays readable. */
424
+ function missingVerdictWarning(ctx, verdictPath) {
425
+ const rel = ctx?.pipelineDir ? relative(ctx.pipelineDir, verdictPath) : basename(verdictPath);
426
+ return `verdict file missing: ${ctx?.nodeId || ctx?.node?.id || '?'} ${rel} — treated as clean`;
427
+ }
428
+
429
+ // ── prompt assembly ───────────────────────────────────────────────────────────
430
+
431
+ /** The role-free base instruction, keyed by runnerType — never by an agent key. The
432
+ * producer/verifier sentences are v1's generic runners (phases.mjs:1207-1209 /
433
+ * :1245-1246); the clarifier sentence is v1's buildClarifyPrompt (:581-587) minus
434
+ * its parenthetical aside that named two builtin agents a generic graph need not have. */
435
+ function baseInstruction(runnerType) {
436
+ if (runnerType === 'verifier') {
437
+ return 'You are a verifier. Inspect the inputs below exactly as your role instructions describe, ' +
438
+ 'then write a human-readable review markdown AND a machine-readable review JSON.';
439
+ }
440
+ if (runnerType === 'clarifier') {
441
+ return 'Identify the decisions you cannot safely resolve from the task text or the real ' +
442
+ 'codebase — including things a downstream agent would otherwise silently assume. For ' +
443
+ 'each, produce one conceptual question with 2 to 4 options and a free-text fallback. Ask ' +
444
+ 'only what materially changes the plan (up to 8 questions); never pad, and never split one ' +
445
+ 'decision. For low-impact details, pick a sensible default rather than asking. If you have ' +
446
+ 'no material open questions, write { "questions": [] } to that same path.';
447
+ }
448
+ return 'You are a pipeline agent. Read every input below, do your job exactly as your role ' +
449
+ 'instructions describe, and write EVERY declared output to its exact path.';
450
+ }
451
+
452
+ /** The answers port of a clarifier: its FIRST json output (meta validation guarantees
453
+ * a clarifier declares at least one). */
454
+ function answersPortOf(ports) {
455
+ return (ports?.outputs || []).find((p) => p?.type === 'json') || null;
456
+ }
457
+
458
+ /** The three prompt-hint tokens. `{diffInstruction}` is the changes-inspection
459
+ * fragment (`changesInstruction`), so a hint reads "…the implemented changes (via
460
+ * `git diff` in your cwd)…" exactly as v1's checklist did. */
461
+ function substituteHints(raw, ctx) {
462
+ const hints = String(raw || '').trim();
463
+ if (!hints) return '';
464
+ return hints
465
+ .replace(/\{pipelineDir\}/g, String(ctx.runCtx?.pipelineDir || ctx.pipelineDir || ''))
466
+ .replace(/\{cycle\}/g, String(ctx.ordinal ?? ctx.cycle ?? 1))
467
+ .replace(/\{diffInstruction\}/g, () => changesInstruction(ctx));
468
+ }
469
+
470
+ /**
471
+ * The decomposition contract, rendered for ANY node whose output is wired into an
472
+ * `expands` input (graph-derived — no key is named): where the task files go and what
473
+ * the manifest looks like. v1's decomposer lines (phases.mjs:727-731), byte-faithful;
474
+ * the manifest path itself is the Ports block's output line.
475
+ */
476
+ function decompositionContractBlock(expandsPort, runCtx) {
477
+ if (!expandsPort) return '';
478
+ return (
479
+ `Write each task file under: ${tasksDirOf(runCtx)}/ (name them p<phase>-t<n>-<kebab-title>.md)\n` +
480
+ 'The manifest shape is { "phases": [ { "ordinal", "tasks": [ { "id", "title", "file" } ] } ] }. ' +
481
+ 'Use id "p<ordinal>t<n>" and a pipeline-dir-relative "file" path.\n\n'
482
+ );
483
+ }
484
+
485
+ /** The MOCK marker set for this execution, per the resolution chain. Only markers
486
+ * `runMock` reads are emitted (MOCK_ROLE, MOCK_CYCLE, MOCK_BASE, MOCK_OUT, MOCK_JSON,
487
+ * MOCK_IN, MOCK_PRIOR, MOCK_TASKS_DIR). */
488
+ function markersFor({ role, ordinal, runCtx, outputs, verdict, bindings, ports, expandsPort, priorCount }) {
489
+ const markers = { MOCK_ROLE: role, MOCK_CYCLE: ordinal, MOCK_BASE: runCtx.baseName };
490
+ if (role === MOCK_ROLE_CLARIFY) {
491
+ markers.MOCK_OUT = outputs[answersPortOf(ports)?.id]?.path;
492
+ markers.MOCK_PRIOR = priorCount;
493
+ } else if (role === MOCK_ROLE_DECOMPOSER) {
494
+ markers.MOCK_OUT = outputs[expandsPort]?.path;
495
+ markers.MOCK_TASKS_DIR = tasksDirOf(runCtx);
496
+ } else {
497
+ markers.MOCK_OUT = Object.values(outputs).find((o) => o && o.path)?.path;
498
+ }
499
+ if (verdict?.path) markers.MOCK_JSON = verdict.path;
500
+ const primaryIn = (ports?.inputs || [])
501
+ .filter((p) => p && p.id !== AWAIT_ID)
502
+ .map((p) => bindings[p.id]?.path)
503
+ .find(Boolean);
504
+ if (primaryIn) markers.MOCK_IN = primaryIn;
505
+ return markers;
506
+ }
507
+
508
+ /**
509
+ * Assemble the v2 task prompt. PURE (no IO, no spawn), so prompt behavior is
510
+ * assertable on its own — `test/graph-prompt-parity.test.mjs` drives this directly
511
+ * with the REAL sidecars.
512
+ */
513
+ export function buildAgentPrompt(ctx) {
514
+ const { node, bindings = {}, trigger = {}, ordinal = 1, runCtx = {} } = ctx;
515
+ const ports = ctx.ports || {};
516
+ const meta = ctx.meta || ports;
517
+ const outputs = ctx.outputs || {};
518
+ const verdict = ctx.verdict || null;
519
+ const expandsPort = ctx.expandsPort ?? null;
520
+
521
+ // Who gets the raw request and the attachments: binding a task node's token, or
522
+ // declaring `wantsRequest`. taskHeader reads those decisions off `isEntry` /
523
+ // `inputs` / `extras`, so drive it through them.
524
+ const fromTask = ctx.taskSourcedPorts instanceof Set
525
+ ? ctx.taskSourcedPorts
526
+ : taskSourcedPorts(ctx.template || {}, node?.id);
527
+ const taskBound = Object.keys(bindings).some((id) => fromTask.has(id));
528
+ const headerCtx = {
529
+ ...ctx,
530
+ isEntry: taskBound || meta.wantsRequest === true,
531
+ inputs: {}, // the graph binds ports, not v1 channels
532
+ extras: taskBound ? (ctx.extras || []) : [], // wantsRequest gets the request, never the attachments
533
+ };
534
+
535
+ const relative = detachedWorkspace(ctx);
536
+ const routed = ctxEndpointRouted(ctx);
537
+ const hints = substituteHints(meta.promptHints, ctx);
538
+ const title = meta.displayName || node?.key || node?.id || 'agent';
539
+ const siblings = ctx.slice ? siblingsBlock(ctx.slice.siblings) : '';
540
+ const answersBlock = (ports.inputs || []).some((p) => p?.as === 'answers')
541
+ ? '## Clarifications already answered\n\n' + renderAnswers(ctx.priorAnswers || []) + '\n'
542
+ : '';
543
+
544
+ return (
545
+ taskHeader(headerCtx, title) +
546
+ '\n## What to do\n\n' +
547
+ baseInstruction(meta.runnerType) + '\n\n' +
548
+ (hints ? hints + '\n\n' : '') +
549
+ modeBlock(selectMode({ ports, bindings, freshPorts: trigger.freshPorts })) +
550
+ fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: relative, subagentModel: ctxSubagentModel(ctx), endpointRouted: routed }) +
551
+ workspaceFanOutDirective(meta.workspaceStrategy, ctx.workspace, { relative, endpointRouted: routed }) +
552
+ (siblings ? siblings + '\n' : '') +
553
+ portIoBlock({ node, ports, bindings, outputs, verdict, ctx }) +
554
+ decompositionContractBlock(expandsPort, runCtx) +
555
+ answersBlock +
556
+ (verdict?.path ? VERDICT_CONTRACT : '') +
557
+ mockMarkers(markersFor({
558
+ role: ctx.mockRole || resolveMockRole({ meta, expandsPort }),
559
+ ordinal, runCtx, outputs, verdict, bindings, ports,
560
+ expandsPort,
561
+ priorCount: (ctx.priorAnswers || []).length,
562
+ }))
563
+ );
564
+ }
565
+
566
+ // ── the agent executor ────────────────────────────────────────────────────────
567
+
568
+ async function readJsonMaybe(path) {
569
+ try { return JSON.parse(await readFile(path, 'utf8')); } catch { return null; }
570
+ }
571
+
572
+ /** The answers already given, read off the bound `as:'answers'` port. */
573
+ async function readPriorAnswers(ports, bindings = {}) {
574
+ const port = (ports?.inputs || []).find((p) => p?.as === 'answers');
575
+ const path = port ? bindings[port.id]?.path : null;
576
+ if (!path) return [];
577
+ const json = await readJsonMaybe(path);
578
+ return Array.isArray(json?.answers) ? json.answers : [];
579
+ }
580
+
581
+ /**
582
+ * Prepare an agent execution: allocate whatever the caller did not, resolve the mock
583
+ * role and the prior answers, and assemble both prompts. Shared by the agent and
584
+ * clarifier executors so the two can never drift.
585
+ */
586
+ async function prepare(ctx) {
587
+ const { node, ordinal = 1, runCtx = {} } = ctx;
588
+ const ports = ctx.ports || {};
589
+ const meta = ctx.meta || ports;
590
+ const outputs = ctx.outputs
591
+ || allocateOutputs({ node, ports, executionId: ctx.executionId, ordinal, runCtx });
592
+ const verdict = ctx.verdict !== undefined
593
+ ? ctx.verdict
594
+ : allocateVerdict({ node, ports, ordinal, runCtx });
595
+ const expandsPort = ctx.expandsPort !== undefined
596
+ ? ctx.expandsPort
597
+ : (ctx.template && ctx.portsFn ? expandsOutputPort(ctx.template, ctx.portsFn, node.id) : null);
598
+ const mockRole = resolveMockRole({ meta, expandsPort });
599
+ const priorAnswers = Array.isArray(ctx.priorAnswers)
600
+ ? ctx.priorAnswers
601
+ : await readPriorAnswers(ports, ctx.bindings);
602
+
603
+ const role = node?.key || node?.kind || 'agent';
604
+ const body = resolveAgentBody(ctx, node?.key);
605
+ if (!String(body || '').trim()) {
606
+ console.warn(`[executor] node "${node?.id}": no agent .md body resolved — running with an empty system prompt`);
607
+ }
608
+ const systemPrompt = buildSystemPrompt(ctx.toolInstruction, body, role, ctx.workspace);
609
+ const full = { ...ctx, ports, meta, outputs, verdict, expandsPort, mockRole, priorAnswers };
610
+ const prompt = buildAgentPrompt(full);
611
+ const allowedTools = meta.sideEffect === 'code' ? IMPLEMENTER_TOOLS : READ_WRITE_TOOLS;
612
+ // D3: an EXPLICIT alias pin on an endpoint-routed node is a stored promise the
613
+ // run cannot keep — degrade it (the prompt already carries the same-endpoint
614
+ // block) and say so on the result, which the scheduler folds into
615
+ // state.warnings + the run log. auto/inherit promised no alias: silent.
616
+ const storedPin = ctxSubagentModel(ctx);
617
+ const pinIgnoredWarning = ctxFanOut(ctx) && ctxEndpointRouted(ctx) && SUBAGENT_MODELS.includes(storedPin)
618
+ ? `sub-agent model pin "${storedPin}" ignored: ${node?.key || node?.id || 'agent'} runs on an ` +
619
+ `endpoint-routed model (${JSON.stringify(ctx.claudeOpts?.model ?? '')}) — children run without an explicit model`
620
+ : null;
621
+ return { full, ports, meta, outputs, verdict, role, systemPrompt, prompt, allowedTools, pinIgnoredWarning };
622
+ }
623
+
624
+ /**
625
+ * Spawn through `runOpts` and capture the session id off the `session` event —
626
+ * `runClaude` resolves `{ text, exitCode }` only. The wrapper forwards every event to
627
+ * the caller's `onEvent` unchanged (runOpts already stamps `role` on it).
628
+ */
629
+ async function spawnAgent(full, { role, prompt, systemPrompt, allowedTools }) {
630
+ const opts = runOpts(full, { role, prompt, systemPrompt, allowedTools });
631
+ let sessionId = null;
632
+ const inner = opts.onEvent;
633
+ opts.onEvent = (e) => {
634
+ if (e?.type === 'session' && e.sessionId) sessionId = String(e.sessionId);
635
+ if (typeof inner === 'function') inner(e);
636
+ };
637
+ const { text } = await runClaude(opts);
638
+ return { text, sessionId };
639
+ }
640
+
641
+ /** The output map the scheduler publishes from: an entry per declared port, with a
642
+ * path where one was allocated and an empty payload for void ports. Exported for
643
+ * P4's composite `finish` arm. */
644
+ export function publishable(ports, outputs) {
645
+ const out = {};
646
+ for (const port of ports?.outputs || []) {
647
+ if (!port) continue;
648
+ out[port.id] = outputs[port.id]?.path ? { path: outputs[port.id].path } : {};
649
+ }
650
+ return out;
651
+ }
652
+
653
+ /**
654
+ * The ONE generic agent executor — the generalization of v1's runGenericProducer and
655
+ * runGenericVerifier and of the nine bespoke runners they replace. Selected for
656
+ * `kind:'agent'` with any `runnerType` other than clarifier.
657
+ */
658
+ export async function runAgentExecution(ctx) {
659
+ const { full, ports, meta, outputs, verdict, role, systemPrompt, prompt, allowedTools, pinIgnoredWarning } = await prepare(ctx);
660
+ const { text, sessionId } = await spawnAgent(full, { role, prompt, systemPrompt, allowedTools });
661
+ const review = verdict?.path ? await readVerdict(verdict.path) : null;
662
+ return {
663
+ summary: (text || '').trim() || `${meta.displayName || ctx.node?.key || 'Agent'} completed.`,
664
+ outputs: publishable(ports, outputs),
665
+ verdict: review,
666
+ // Non-fatal problems the scheduler folds into state.warnings + the run log.
667
+ warnings: [
668
+ ...(review?.missing ? [missingVerdictWarning(ctx, verdict.path)] : []),
669
+ ...(pinIgnoredWarning ? [pinIgnoredWarning] : []),
670
+ ],
671
+ sessionId,
672
+ prompt,
673
+ };
674
+ }
675
+
676
+ // ── the clarifier executor ────────────────────────────────────────────────────
677
+
678
+ /**
679
+ * Normalize an ask payload into enriched answers. Accepts `{answers:[{id,choice}]}` or
680
+ * a bare array; any question the user left out falls back to its first option, so
681
+ * downstream consumers never see a gap. Each answer carries its question text so the
682
+ * row and the History UI render the full Q&A without a join.
683
+ */
684
+ function normalizeAnswers(payload, questions) {
685
+ const arr = Array.isArray(payload?.answers) ? payload.answers : Array.isArray(payload) ? payload : [];
686
+ const byId = new Map();
687
+ for (const a of arr) if (a && a.id != null) byId.set(String(a.id), String(a.choice ?? ''));
688
+ return (questions || []).map((q) => ({
689
+ id: q.id,
690
+ question: q.question || '',
691
+ choice: byId.has(q.id) ? byId.get(q.id) : (q.options && q.options.find((o) => o && o.trim())) || '',
692
+ }));
693
+ }
694
+
695
+ /**
696
+ * The clarifier executor — selected by `meta.runnerType === 'clarifier'`, NEVER by an
697
+ * agent key, so any number of clarifier nodes per graph is legal. Spawn → read the
698
+ * questions JSON off the FIRST json output port (malformed or empty is tolerated: no
699
+ * gate, empty answers) → gate the human on `clarify-<nodeId>-<ordinal>` → REWRITE that
700
+ * file as `{questions, answers}` in one idempotent full-file write → publish the (now
701
+ * self-contained) token. The snapshot lands only after the publish, so a mid-gate
702
+ * resume re-runs the gate from the questions half.
703
+ */
704
+ export async function runClarifierExecution(ctx) {
705
+ const { node, ordinal = 1 } = ctx;
706
+ const { full, ports, meta, outputs, role, systemPrompt, prompt, allowedTools, pinIgnoredWarning } = await prepare(ctx);
707
+ const answersPort = answersPortOf(ports);
708
+ const answersPath = outputs[answersPort?.id]?.path;
709
+ if (!answersPath) throw new Error(`clarifier node "${node?.id}": no json output port to write the questions to`);
710
+
711
+ const { sessionId } = await spawnAgent(full, { role, prompt, systemPrompt, allowedTools });
712
+
713
+ const { questions } = normalizeClarify(await readJsonMaybe(answersPath));
714
+ // Non-interactive default (v1 `_ask` auto): no gate ⇒ every question takes its first
715
+ // option (normalizeAnswers' fallback). The scheduler's onAsk never sees clarify asks.
716
+ const ask = typeof ctx.ask === 'function' ? ctx.ask : async () => ({ answers: [] });
717
+ let answers = [];
718
+ if (questions.length) {
719
+ if (ctx.pipelineId) {
720
+ await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
721
+ agentKey: node?.key, nodeId: node?.id, questions: { questions },
722
+ });
723
+ await writeClarify(ctx.pipelineId, { questions: { questions } });
724
+ }
725
+ const payload = await ask({
726
+ id: `${CLARIFY_ASK_KIND}-${node.id}-${ordinal}`,
727
+ kind: CLARIFY_ASK_KIND,
728
+ nodeId: node.id,
729
+ agent: meta.displayName || node.key,
730
+ questions,
731
+ });
732
+ answers = normalizeAnswers(payload, questions);
733
+ if (ctx.pipelineId) {
734
+ await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
735
+ agentKey: node?.key, nodeId: node?.id, answers: { answers },
736
+ });
737
+ await writeClarify(ctx.pipelineId, { answers: { answers } });
738
+ }
739
+ }
740
+
741
+ await mkdir(dirname(answersPath), { recursive: true }).catch(() => {});
742
+ await writeFile(answersPath, JSON.stringify({ questions, answers }, null, 2) + '\n', 'utf8');
743
+ return { outputs: publishable(ports, outputs), questions, answers, sessionId, prompt,
744
+ warnings: pinIgnoredWarning ? [pinIgnoredWarning] : [] };
745
+ }
746
+
747
+ // ── flow executors (pure engine: instant, $0, no process spawn) ───────────────
748
+
749
+ /** Write a file, creating its directory. Synchronous on purpose: the flow cards run
750
+ * inline in the scheduler's walk. */
751
+ function writeOut(path, text) {
752
+ mkdirSync(dirname(path), { recursive: true });
753
+ writeFileSync(path, text, 'utf8');
754
+ }
755
+
756
+ /**
757
+ * The Task card — the source. Fires once at run start and emits the rendered task
758
+ * document (run title + the user's prompt markdown + the attached-files section). The
759
+ * ADAPTER renders it: `ctx.taskArtifact` is `{ path }` (already on disk — the normal
760
+ * case) or `{ text }`. Without either the execution THROWS (fail-fast: a run whose
761
+ * source card has nothing to emit is a wiring bug, never a silent empty token).
762
+ *
763
+ * A2 (parity-mandatory for the mid-stream entry template): with
764
+ * `config.planStoreSeed`, the document ALSO lands in the plans store at version 1, the
765
+ * emitted token IS that plans-store path, and the run's plan-version counter is
766
+ * consumed at 1 — so the next plan-store write allocates `-v2`.
767
+ */
768
+ export function runTaskExecution({ node, taskArtifact, runCtx = {} }) {
769
+ const given = taskArtifact?.path || null;
770
+ let text = typeof taskArtifact?.text === 'string' ? taskArtifact.text : null;
771
+ if (text === null && given) {
772
+ try { text = readFileSync(given, 'utf8'); } catch { text = null; }
773
+ }
774
+ const missing = () => new Error(
775
+ `task node "${node?.id}": no task artifact — the adapter must supply ctx.taskArtifact { path } or { text }`,
776
+ );
777
+
778
+ if (node?.config?.planStoreSeed !== true) {
779
+ if (given) return { outputs: { task: { path: given } } };
780
+ if (text === null) throw missing();
781
+ const target = join(runCtx.pipelineDir, 'task.md');
782
+ writeOut(target, text);
783
+ return { outputs: { task: { path: target } } };
784
+ }
785
+
786
+ if (text === null) throw missing();
787
+ const version = typeof runCtx.planVersion === 'function' ? Number(runCtx.planVersion()) || 1 : 1;
788
+ const seeded = planPath(runCtx.projectDir, runCtx.baseName, version, runCtx.datePrefix, runCtx.workspaceKey);
789
+ writeOut(seeded, text);
790
+ return { outputs: { task: { path: seeded } } };
791
+ }
792
+
793
+ /** The AND card — the pure synchronizer. Payloads are discarded on purpose: its `out`
794
+ * is a static void token, which is what makes it reusable sequencing. */
795
+ export function runAndExecution({ node }) { // eslint-disable-line no-unused-vars
796
+ return { outputs: { out: {} } };
797
+ }
798
+
799
+ /**
800
+ * The OR card — the payload-forwarding valve. INFORMATIONAL in the scheduler path: the
801
+ * scheduler owns any-fresh triggering, freshest selection, the same-drain single
802
+ * emission AND the re-emitted payload (incl. `meta`/`forced`). `bindings` therefore
803
+ * holds EXACTLY ONE entry — the freshest input the scheduler already picked — which
804
+ * this simply forwards.
805
+ */
806
+ export function runOrExecution({ node, bindings = {} }) { // eslint-disable-line no-unused-vars
807
+ const token = Object.values(bindings)[0];
808
+ if (!token) return { outputs: { out: {} } };
809
+ return { outputs: { out: { type: token.type, path: token.path ?? null, value: token.value ?? null } } };
810
+ }
811
+
812
+ /** The End card — the sink. Records the bound result token and emits NO outputs.
813
+ * INFORMATIONAL: the scheduler derives `ended.result` from the token it bound. */
814
+ export function runEndExecution({ node, bindings = {} }) { // eslint-disable-line no-unused-vars
815
+ const token = Object.values(bindings)[0] || null;
816
+ return {
817
+ result: { type: token?.type ?? 'void', path: token?.path ?? null, value: token?.value ?? null },
818
+ };
819
+ }
820
+
821
+ /** Order `in1..inN` numerically so the concatenation follows port order, not lexical
822
+ * order (`in10` must not sort before `in2`). */
823
+ function comparePortIds(a, b) {
824
+ const na = /^in(\d+)$/.exec(a);
825
+ const nb = /^in(\d+)$/.exec(b);
826
+ if (na && nb) return Number(na[1]) - Number(nb[1]);
827
+ return a < b ? -1 : a > b ? 1 : 0;
828
+ }
829
+
830
+ /**
831
+ * The Combine card — the payload-bearing md AND-join. Concatenates its bound inputs in
832
+ * PORT order under `## From <node name>` headings and writes one md artifact. `names`
833
+ * maps port id -> the source node's display name (the dispatcher derives it from the
834
+ * template; P4 may pass registry display names); absent one, the port id stands in.
835
+ */
836
+ export async function runCombineExecution({ node, bindings = {}, allocatedPath, names = {}, ordinal = 1, runCtx = {} }) {
837
+ const path = allocatedPath || combinePath(node, ordinal, runCtx);
838
+ const parts = [];
839
+ for (const portId of Object.keys(bindings).sort(comparePortIds)) {
840
+ const token = bindings[portId];
841
+ if (!token) continue;
842
+ const name = names[portId] || token.meta?.sourceName || portId;
843
+ let body = typeof token.value === 'string' ? token.value : '';
844
+ if (!body && token.path) {
845
+ try { body = await readFile(token.path, 'utf8'); } catch { body = ''; }
846
+ }
847
+ parts.push(`## From ${name}\n\n${body.trim()}\n`);
848
+ }
849
+ await mkdir(dirname(path), { recursive: true }).catch(() => {});
850
+ await writeFile(path, parts.join('\n'), 'utf8');
851
+ return { outputs: { out: { path } } };
852
+ }
853
+
854
+ /** Port id -> the display name of the node wired into it (Combine's headings). v2
855
+ * template nodes carry no `label`; the key (or the node id for a flow card) stands
856
+ * in unless the caller passes `ctx.names` from the registry's displayName. */
857
+ function combineNames(template, nodeId) {
858
+ const byId = new Map((template?.nodes || []).map((n) => [n.id, n]));
859
+ const out = {};
860
+ for (const w of template?.wires || []) {
861
+ if (w?.to?.node !== nodeId) continue;
862
+ const src = byId.get(w.from.node);
863
+ out[w.to.port] = src?.label || src?.key || w.from.node;
864
+ }
865
+ return out;
866
+ }
867
+
868
+ // ── the ONE entry point ───────────────────────────────────────────────────────
869
+
870
+ /**
871
+ * Select and run this execution. Selection is `node.kind` → flow executor, then
872
+ * `kind:'agent'` → `meta.runnerType` — NEVER an agent key. `opts.runners[runnerType]`
873
+ * is the injected seam (v1's `orchestrator.mjs:306` test hook) and wins when present.
874
+ * @param {object} ctx the execution context (see the ctx contract in Task 8)
875
+ * @param {{runners?:Record<string,Function>}} [opts]
876
+ */
877
+ export function runExecution(ctx, opts = {}) {
878
+ const node = ctx?.node || {};
879
+ switch (node.kind) {
880
+ case 'task': return runTaskExecution(ctx);
881
+ case 'and': return runAndExecution(ctx);
882
+ case 'or': return runOrExecution(ctx);
883
+ case 'end': return runEndExecution(ctx);
884
+ case 'combine':
885
+ return runCombineExecution({ ...ctx, names: ctx.names || combineNames(ctx.template, node.id) });
886
+ case 'agent': break;
887
+ default:
888
+ throw new Error(`node "${node.id}": unknown kind "${node.kind}"`);
889
+ }
890
+ const runnerType = ctx.meta?.runnerType || 'producer';
891
+ const injected = (opts.runners || ctx.runners || {})[runnerType];
892
+ if (typeof injected === 'function') return injected(ctx);
893
+ return runnerType === 'clarifier' ? runClarifierExecution(ctx) : runAgentExecution(ctx);
894
+ }