@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.
- package/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- 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
|
+
}
|