@worca/app 0.0.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 +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
// src/core/phases.mjs
|
|
2
|
+
//
|
|
3
|
+
// Per-phase agent runners. Each runner:
|
|
4
|
+
// - loads the matching agents/*.md body (passed in via ctx.agentPrompts) and uses it
|
|
5
|
+
// as the *appended* system prompt, *prepended* with the preflight toolInstruction,
|
|
6
|
+
// - builds a per-role task prompt that ALSO carries the MOCK markers the offline mock
|
|
7
|
+
// runner parses (MOCK_ROLE / MOCK_OUT / MOCK_JSON / MOCK_CYCLE / MOCK_IN / MOCK_BASE),
|
|
8
|
+
// so a full pipeline can run with WORCA_MOCK=1 without spawning claude,
|
|
9
|
+
// - sets allowedTools appropriate for the role,
|
|
10
|
+
// - calls runClaude, then reads the produced artifact back through protocol and returns
|
|
11
|
+
// the contracted shape.
|
|
12
|
+
//
|
|
13
|
+
// If an agent .md body is missing/empty, each runner falls back to a sensible inline role
|
|
14
|
+
// prompt so the system prompt is never empty. Interface is locked by docs/ARCHITECTURE.md §3.5.
|
|
15
|
+
|
|
16
|
+
import { runClaude } from './claude-runner.mjs';
|
|
17
|
+
import { resolveModelEnv } from './config.mjs';
|
|
18
|
+
import { readClarify, readReview } from './protocol.mjs';
|
|
19
|
+
import { writeClarify, readClarifyRow } from './artifacts.mjs';
|
|
20
|
+
import { renderAttachmentsBlock } from './channels.mjs';
|
|
21
|
+
|
|
22
|
+
// ── allowedTools per role ──────────────────────────────────────────────────────
|
|
23
|
+
// `Skill` lets agents invoke project (.claude/skills) and personal (~/.claude/skills)
|
|
24
|
+
// skills via the Skill tool; without it, headless `claude -p` denies skill calls.
|
|
25
|
+
const READ_WRITE_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob', 'Skill'];
|
|
26
|
+
// Implementer additionally gets MultiEdit for larger, multi-hunk edits.
|
|
27
|
+
const IMPLEMENTER_TOOLS = ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Grep', 'Glob', 'Skill'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Effective `--allowedTools` for a node: the role's baseline file/exec tools UNION
|
|
31
|
+
* the agent's frontmatter-declared tools (e.g. the Playwright MCP `browser_*` tools).
|
|
32
|
+
*
|
|
33
|
+
* Frontmatter only ADDS to the baseline — an agent that omits Write still keeps it
|
|
34
|
+
* (so it can write its artifact JSON), and declaring MCP tools in the `.md` is all a
|
|
35
|
+
* future agent needs to have them granted to its headless `claude -p` run. The list
|
|
36
|
+
* is de-duplicated with the base entries kept first (stable, readable argv order).
|
|
37
|
+
*
|
|
38
|
+
* `declared` is `ctx.node?.tools`, already parsed from frontmatter by resolveWorkflow
|
|
39
|
+
* (workflows.mjs parseFrontmatterTools). It is undefined for the clarify pre-step
|
|
40
|
+
* (which runs off _phaseCtx and has no node), so callers pass it straight through and
|
|
41
|
+
* get the base list back unchanged.
|
|
42
|
+
*
|
|
43
|
+
* @param {string[]} base the role's default allow-list (READ_WRITE_TOOLS / IMPLEMENTER_TOOLS)
|
|
44
|
+
* @param {string[]|undefined} declared node.tools from frontmatter, may be undefined
|
|
45
|
+
* @returns {string[]} de-duplicated union, base entries first
|
|
46
|
+
*/
|
|
47
|
+
export function effectiveAllowedTools(base, declared, fanOut = false) {
|
|
48
|
+
const out = Array.isArray(base) ? [...base] : [];
|
|
49
|
+
const seen = new Set(out);
|
|
50
|
+
const add = (t) => {
|
|
51
|
+
const name = String(t || '').trim();
|
|
52
|
+
if (name && !seen.has(name)) { seen.add(name); out.push(name); }
|
|
53
|
+
};
|
|
54
|
+
for (const t of Array.isArray(declared) ? declared : []) add(t);
|
|
55
|
+
// Fan-out: unlock the sub-agent tool so this agent can spawn its own sub-agents.
|
|
56
|
+
// Grant BOTH names defensively: the installed `claude` CLI's sub-agent tool is
|
|
57
|
+
// named `Task`; an allowed-but-nonexistent name is harmless. (Do NOT rely on the
|
|
58
|
+
// orchestrator's `toolTarget` log-formatter as proof either name is honored.)
|
|
59
|
+
if (fanOut) { add('Task'); add('Agent'); }
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether this run may fan out (spawn Task/Agent sub-agents). For a DISPATCHED
|
|
65
|
+
* node the decision is the node's own `fanOut` (resolved by resolveWorkflow from
|
|
66
|
+
* config > role > sidecar). The clarify pre-step has NO node (it runs off
|
|
67
|
+
* _phaseCtx), so it carries a context-level `fanOut` instead. A present node wins
|
|
68
|
+
* so a node that opted out is never overridden. Pure + exported for testing.
|
|
69
|
+
*/
|
|
70
|
+
export function ctxFanOut(ctx) {
|
|
71
|
+
if (!ctx || typeof ctx !== 'object') return false;
|
|
72
|
+
return !!(ctx.node ? ctx.node.fanOut : ctx.fanOut);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── run-root mode gates for the §5.8 prompt variants ───────────────────────────
|
|
76
|
+
// EVERY Phase-4 prompt variant is gated on `runRootMode === 'detached'`; the
|
|
77
|
+
// workspace-specific ones are ADDITIONALLY gated on `isWorkspace` (§6 Phase 4).
|
|
78
|
+
// Inside phases.mjs the detached signal is `ctx.runRoot`: orchestrator._nodeCtx sets
|
|
79
|
+
// it to a path ONLY on a detached run and to null under legacy. Reading the CTX
|
|
80
|
+
// rather than re-reading WORCA_RUN_ROOT here is deliberate — the orchestrator
|
|
81
|
+
// consults the mode once per pipeline and a RESUMED run rides its RECORDED mode
|
|
82
|
+
// (§8.14), so an env flip mid-run can never make a prompt lie about the layout.
|
|
83
|
+
|
|
84
|
+
/** True on a detached run (single OR workspace). Pure; tolerates any ctx. */
|
|
85
|
+
function isDetachedRun(ctx) {
|
|
86
|
+
return !!(ctx && ctx.runRoot);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The workspace channel's member list, or [] when this is not a workspace run. */
|
|
90
|
+
function wsMembers(ctx) {
|
|
91
|
+
return ctx && ctx.workspace && Array.isArray(ctx.workspace.projects) ? ctx.workspace.projects : [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True only for a DETACHED WORKSPACE node: cwd is the run root and the members live
|
|
96
|
+
* at `repos/<projectKey>` inside it. This is the gate for every workspace-specific
|
|
97
|
+
* prompt variant (run-root task header, relative render, `git -C repos/<key>`
|
|
98
|
+
* directives, §8.21's `omitProjectAgents`). Legacy workspace runs and every
|
|
99
|
+
* single-project run are false, so their prompts keep today's bytes.
|
|
100
|
+
*/
|
|
101
|
+
function isDetachedWorkspace(ctx) {
|
|
102
|
+
return isDetachedRun(ctx) && wsMembers(ctx).length > 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Render-only relative checkout path for a member (§5.8: never persisted anywhere). */
|
|
106
|
+
function relRepo(p) {
|
|
107
|
+
return p && p.projectKey ? `repos/${p.projectKey}` : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fan-out-gated prompt block: when a run may fan out, tell the agent to actually
|
|
112
|
+
* parallelize multi-area codebase research instead of exploring serially. Empty
|
|
113
|
+
* string when off, so non-fan-out task prompts are unchanged. Pure + exported.
|
|
114
|
+
*
|
|
115
|
+
* `omitProjectAgents` (§8.21, passed by callers on DETACHED WORKSPACE runs only)
|
|
116
|
+
* drops the project half of the `subagent_type` sentence: with cwd = `<runRoot>`
|
|
117
|
+
* there is no project `.claude/agents` to discover, so promising it would make a
|
|
118
|
+
* named `subagent_type` fail to resolve at Task time. `~/.claude/agents` stays
|
|
119
|
+
* promised — it is inherited env and remains true. Single mode (both run-root modes)
|
|
120
|
+
* and legacy workspace runs keep today's byte-identical sentence.
|
|
121
|
+
*/
|
|
122
|
+
export function fanOutDirective(fanOut, { omitProjectAgents = false } = {}) {
|
|
123
|
+
if (!fanOut) return '';
|
|
124
|
+
const subagentSentence = omitProjectAgents
|
|
125
|
+
? 'Pick the BEST-FIT `subagent_type`: your personal agents (`~/.claude/agents`) are available by ' +
|
|
126
|
+
'name — prefer a purpose-built one when it fits the sub-task, else fall back to ' +
|
|
127
|
+
'`"general-purpose"` (or `"Explore"` for pure code search). This run starts at the worca-cc run ' +
|
|
128
|
+
'root, so no member project\'s own agents are discoverable by name.'
|
|
129
|
+
: 'Pick the BEST-FIT `subagent_type`: this project\'s own agents (`.claude/agents`) and your personal ' +
|
|
130
|
+
'agents (`~/.claude/agents`) are available by name — prefer a purpose-built one when it fits the ' +
|
|
131
|
+
'sub-task, else fall back to `"general-purpose"` (or `"Explore"` for pure code search).';
|
|
132
|
+
return (
|
|
133
|
+
'## Fan-out ENABLED — parallelize your research\n\n' +
|
|
134
|
+
'The Task/Agent tool is in your tool list this run. For any non-trivial task that spans more ' +
|
|
135
|
+
'than one file or area, DISPATCH parallel read-only research sub-agents NOW — one per distinct ' +
|
|
136
|
+
'area (e.g. UI vs. server vs. store vs. tests) — explore them concurrently, then synthesize their ' +
|
|
137
|
+
'reports yourself. Do NOT investigate every area serially with Read/Grep when the work splits ' +
|
|
138
|
+
'into independent areas.\n\n' +
|
|
139
|
+
subagentSentence + '\n\n' +
|
|
140
|
+
'Skills are available too: this project\'s and your personal skills (`.claude/skills`, ' +
|
|
141
|
+
'`~/.claude/skills`) can be invoked via the Skill tool — by you AND by the sub-agents you spawn — ' +
|
|
142
|
+
'use any that fit (e.g. design, framework-pattern, knowledge-graph) instead of guessing conventions.\n\n' +
|
|
143
|
+
'Sub-agents are strictly READ-ONLY investigators: YOU write every artifact. Skip fan-out only for a ' +
|
|
144
|
+
'trivial, single-file change.\n\n'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The `## Workspace Context` preamble injected into EVERY agent on a workspace run,
|
|
150
|
+
* after the toolInstruction and before the role body. Pure + exported. Returns ''
|
|
151
|
+
* when there is no workspace (or no description), so single-project system prompts
|
|
152
|
+
* are byte-identical. The frozen description is injected VERBATIM — no length cap
|
|
153
|
+
* (its size is bounded by the workspace-scanner prompt). Accepts either the bus
|
|
154
|
+
* channel shape (`workspaceDescription`, see orchestrator.mjs#_workspaceChannel) or
|
|
155
|
+
* a plain `description` field.
|
|
156
|
+
* @param {{workspaceDescription?:string, description?:string, projects?:Array<{projectName?:string}>}|null|undefined} ws
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
export function workspaceContextBlock(ws) {
|
|
160
|
+
const desc = String((ws && (ws.workspaceDescription ?? ws.description)) || '').trim();
|
|
161
|
+
if (!desc) return '';
|
|
162
|
+
const names = (ws.projects || []).map((p) => p.projectName).filter(Boolean).join(', ');
|
|
163
|
+
return `## Workspace Context\n\n${desc}\n\nMember projects: ${names}.\n`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The strategy-specific fan-out directive for a workspace node. Pure + exported.
|
|
168
|
+
* Each block tells the agent to spawn one read-only/owning sub-agent per unit
|
|
169
|
+
* (project / plan task / touched project), merge deterministically by sorted
|
|
170
|
+
* projectKey/taskId, and — the binding anti-explosion rule (§5.6) — NEVER let a
|
|
171
|
+
* sub-agent re-fan-out. Returns '' when there is no workspace or the strategy is
|
|
172
|
+
* unknown, so non-workspace task prompts are unchanged.
|
|
173
|
+
* `relative` (§5.8, set by callers on DETACHED WORKSPACE runs only) swaps the
|
|
174
|
+
* "cwd into the named worktree" routing for in-place `git -C repos/<key> …` work:
|
|
175
|
+
* every member checkout is INSIDE the shared cwd (`<runRoot>`), so a sub-agent must
|
|
176
|
+
* not chdir anywhere. Merge order and the anti-recursion clause are identical in
|
|
177
|
+
* both variants, and the legacy text is byte-identical to today.
|
|
178
|
+
* @param {'explore'|'task'|'review'} strategy
|
|
179
|
+
* @param {{projects?:Array<{projectName?:string,projectKey?:string}>}|null|undefined} ws
|
|
180
|
+
* @param {{relative?:boolean}} [opts]
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
export function workspaceFanOutDirective(strategy, ws, { relative = false } = {}) {
|
|
184
|
+
if (!ws) return '';
|
|
185
|
+
const ANTI_RECURSION =
|
|
186
|
+
'Sub-agents are strictly single-level: a sub-agent MUST NOT re-fan-out ' +
|
|
187
|
+
'(it must never spawn its own Task/Agent sub-agents). YOU synthesize every ' +
|
|
188
|
+
'merged artifact yourself.\n\n';
|
|
189
|
+
if (strategy === 'explore') {
|
|
190
|
+
return (
|
|
191
|
+
'## Workspace fan-out — explore across member projects\n\n' +
|
|
192
|
+
'Dispatch ONE read-only Explore sub-agent per member project (cap 8) to survey ' +
|
|
193
|
+
(relative
|
|
194
|
+
? 'its checkout at `./repos/<projectKey>` inside the shared cwd (modules, public ' +
|
|
195
|
+
'API, deps) — read files there directly and use `git -C repos/<projectKey> …` ' +
|
|
196
|
+
'for history — and return a brief. '
|
|
197
|
+
: 'its worktree (modules, public API, deps) and return a brief. ') +
|
|
198
|
+
'Then write the ' +
|
|
199
|
+
'SINGLE unified plan yourself, with findings under per-project headings and ' +
|
|
200
|
+
'every plan TASK tagged `Projects: <projectKey>[, ...]` for the project(s) it ' +
|
|
201
|
+
'touches. Merge the briefs in sorted `projectKey` order (never completion ' +
|
|
202
|
+
'order). ' + ANTI_RECURSION
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (strategy === 'task') {
|
|
206
|
+
return (
|
|
207
|
+
'## Workspace fan-out — one sub-agent per plan task\n\n' +
|
|
208
|
+
'Read the plan\'s `## Tasks`; dispatch ONE implementer sub-agent per task ' +
|
|
209
|
+
'(cap 8, `subagent_type:"general-purpose"`), each editing ONLY the worktree(s) ' +
|
|
210
|
+
(relative
|
|
211
|
+
? 'of the project(s) named in that task\'s `Projects:` tag — operate in ' +
|
|
212
|
+
'`./repos/<projectKey>` (edit files there and run git as ' +
|
|
213
|
+
'`git -C repos/<projectKey> …`; NEVER chdir out of the run root). '
|
|
214
|
+
: 'of the project(s) named in that task\'s `Projects:` tag (cwd into the named ' +
|
|
215
|
+
'worktree). ') +
|
|
216
|
+
'Do NOT edit any project not named by a task. Schedule two tasks ' +
|
|
217
|
+
'that touch the SAME project sequentially (no overlapping ownership in a wave). ' +
|
|
218
|
+
'Merge results in plan-task (`taskId`) order. ' + ANTI_RECURSION
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (strategy === 'review') {
|
|
222
|
+
return (
|
|
223
|
+
'## Workspace fan-out — one reviewer per touched project\n\n' +
|
|
224
|
+
'Dispatch ONE reviewer sub-agent per TOUCHED member project (cap 8) — skip a ' +
|
|
225
|
+
'project whose diff against its checkpoint is empty. Each sub-agent reviews its ' +
|
|
226
|
+
(relative
|
|
227
|
+
? 'project\'s diff with `git -C repos/<projectKey> diff <checkpointRef>` (the ' +
|
|
228
|
+
'refs are listed in `## Workspace projects` above) against the plan and ' +
|
|
229
|
+
'reports issues. '
|
|
230
|
+
: 'project\'s `checkpointRef...feature` diff against the plan and reports issues. ') +
|
|
231
|
+
'Then YOU synthesize ONE review markdown + ONE verdict JSON: the UNION of every ' +
|
|
232
|
+
'critical/major issue (never collapse or drop one), sorted by `projectKey` then ' +
|
|
233
|
+
'severity, each issue location prefixed with `"<projectKey>: "`. ' + ANTI_RECURSION
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return '';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── inline fallbacks (used only when agents/*.md is missing/empty) ──────────────
|
|
240
|
+
const FALLBACK_PROMPTS = {
|
|
241
|
+
clarify:
|
|
242
|
+
'You are the Clarify agent. Before a software task is planned you surface the decisions that ' +
|
|
243
|
+
'materially change the plan and cannot be resolved from the task text or the codebase — ' +
|
|
244
|
+
'including things downstream agents would otherwise silently assume. For each, write a ' +
|
|
245
|
+
'conceptual question offering 2 to 4 options plus a free-text field. Ask up to 8 questions, ' +
|
|
246
|
+
'but never pad. Output a JSON file (path given in the task) shaped as ' +
|
|
247
|
+
'{ "questions": [ { "id", "question", "options": [ ... ], "allowFreeText": true } ] }. ' +
|
|
248
|
+
'If you genuinely have no open questions, write { "questions": [] }. You never write a plan.',
|
|
249
|
+
'planner-plan':
|
|
250
|
+
'You are the Planner. Write a thorough implementation plan to the markdown path given in ' +
|
|
251
|
+
'the task. The plan MUST include concrete code snippets for the features and MUST end with ' +
|
|
252
|
+
'a "## Clarifications (Q&A)" section listing what was asked and how the user answered. ' +
|
|
253
|
+
'When done, hand off naming the plan file location.',
|
|
254
|
+
refiner:
|
|
255
|
+
'You are the Plan Refiner. Critically review the given plan (including its code snippets), ' +
|
|
256
|
+
'write a refined version to the output path, and emit a review JSON ' +
|
|
257
|
+
'({ "issues": [ { "severity", "title", "detail", "location" } ], "summary" }) using ' +
|
|
258
|
+
'severities critical|major|minor|suggestion. Only critical/major are blocking.',
|
|
259
|
+
implementer:
|
|
260
|
+
'You are the Implementer. Follow the latest plan with NO deviation, using TDD ' +
|
|
261
|
+
'(red-green-refactor). Deviate only if something does not work at all. In fix mode, address ' +
|
|
262
|
+
'every critical/major issue in the referenced review.',
|
|
263
|
+
reviewer:
|
|
264
|
+
'You are the Code Reviewer. Review the git diff of what was implemented against the plan. ' +
|
|
265
|
+
'Write a human-readable review markdown AND a review JSON ' +
|
|
266
|
+
'({ "issues": [ { "severity", "title", "detail", "location" } ], "summary" }). ' +
|
|
267
|
+
'Use severities critical|major|minor|suggestion; only critical/major block.',
|
|
268
|
+
'manual-tests-checklist':
|
|
269
|
+
'You are the Manual Tests author. Read the plan and the implemented diff, then write a ' +
|
|
270
|
+
'markdown checklist of manual test cases (each a `- [ ]` line with steps + expected result) ' +
|
|
271
|
+
'to the path given in the task.',
|
|
272
|
+
'manual-web-ui-testing':
|
|
273
|
+
'You are the Manual Web UI Tester. Run each case in the manual checklist against the live ' +
|
|
274
|
+
'web UI using the Playwright tools, then write a result markdown AND a review JSON ' +
|
|
275
|
+
'({ "issues": [ { "severity", "title", "detail", "location" } ], "summary" }). Use severities ' +
|
|
276
|
+
'critical|major|minor|suggestion; a failing case is at least major.',
|
|
277
|
+
'plan-review':
|
|
278
|
+
'You are the Plan Reviewer. Review the implementation PLAN (its structure, correctness, ' +
|
|
279
|
+
'completeness, feasibility, and code snippets) against the original request and the real ' +
|
|
280
|
+
'codebase. Do NOT rewrite the plan. Write a human-readable review markdown AND a review JSON ' +
|
|
281
|
+
'({ "issues": [ { "severity", "title", "detail", "location" } ], "summary" }) using severities ' +
|
|
282
|
+
'critical|major|minor|suggestion; only critical/major block (the planner then revises).',
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Build the full appended system prompt: toolInstruction first (if any), then — on
|
|
287
|
+
* a workspace run — the `## Workspace Context` block, then the agent body (or a
|
|
288
|
+
* sensible inline fallback when the body is missing/empty). The optional 4th
|
|
289
|
+
* `workspace` arg is the read-only workspace metadata; absent it,
|
|
290
|
+
* workspaceContextBlock returns '' and the prompt is byte-identical to today's
|
|
291
|
+
* single-project prompt. Exported for testing.
|
|
292
|
+
*/
|
|
293
|
+
export function buildSystemPrompt(toolInstruction, agentBody, role, workspace) {
|
|
294
|
+
const parts = [];
|
|
295
|
+
const tool = (toolInstruction || '').trim();
|
|
296
|
+
if (tool) parts.push(tool);
|
|
297
|
+
const ws = workspaceContextBlock(workspace); // '' when not a workspace run
|
|
298
|
+
if (ws) parts.push(ws);
|
|
299
|
+
const body = (agentBody || '').trim();
|
|
300
|
+
parts.push(body || FALLBACK_PROMPTS[role] || '');
|
|
301
|
+
return parts.filter(Boolean).join('\n\n');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the agent .md body for a runner: the node's own resolved `agentPrompt`
|
|
306
|
+
* (stamped by resolveWorkflow from its meta.agentFile — built-in OR user layer)
|
|
307
|
+
* wins; the orchestrator's bulk-loaded ctx.agentPrompts[key] is the fallback (the
|
|
308
|
+
* clarify pre-step and direct-unit ctxs have no node); FALLBACK_PROMPTS[role]
|
|
309
|
+
* backstops inside buildSystemPrompt. Single resolution path for EVERY runner —
|
|
310
|
+
* this is what fixes the decomposer's empty system prompt (agentPrompts never
|
|
311
|
+
* carried a `decomposer` key and FALLBACK_PROMPTS has no `decomposer` role).
|
|
312
|
+
* Exported for testing.
|
|
313
|
+
*/
|
|
314
|
+
export function resolveAgentBody(ctx, key) {
|
|
315
|
+
const nodeBody = typeof ctx?.node?.agentPrompt === 'string' ? ctx.node.agentPrompt.trim() : '';
|
|
316
|
+
if (nodeBody) return ctx.node.agentPrompt;
|
|
317
|
+
return ctx?.agentPrompts?.[key];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Render the MOCK marker block appended to every task prompt. */
|
|
321
|
+
function mockMarkers(fields) {
|
|
322
|
+
const lines = [];
|
|
323
|
+
for (const [key, val] of Object.entries(fields)) {
|
|
324
|
+
if (val === undefined || val === null || val === '') continue;
|
|
325
|
+
lines.push(`${key}: ${val}`);
|
|
326
|
+
}
|
|
327
|
+
return lines.join('\n');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Prepended to the task prompt when a node re-attaches to an interrupted session. */
|
|
331
|
+
export const RESUME_HEADER =
|
|
332
|
+
'## Resumed session\n\n' +
|
|
333
|
+
'You were interrupted mid-task and this session has been resumed. First verify the\n' +
|
|
334
|
+
'state of your previous work (files/artifacts you already wrote), then continue the\n' +
|
|
335
|
+
'ORIGINAL task below to completion. Do not redo work that is already done.\n\n';
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Ask-then-resume prompt block for a questions-enabled node (spec 2026-07-11).
|
|
339
|
+
* Appended by runOpts, so EVERY producer/verifier runner inherits it with no
|
|
340
|
+
* per-runner edits. ctx fields (set by the orchestrator per attempt):
|
|
341
|
+
* questionsEnabled gate (node.askQuestions minus clarifier/decomposed/auto)
|
|
342
|
+
* questionsFile absolute path for THIS round's questions JSON; null when
|
|
343
|
+
* rounds are exhausted (closing note instead of directive)
|
|
344
|
+
* questionsAnswered [{id,question,choice}] already answered for this node
|
|
345
|
+
* MOCK_ASK is emitted only on the first round (no prior answers) so the offline
|
|
346
|
+
* mock asks exactly once, then performs its normal role side effects on resume.
|
|
347
|
+
* Pure + exported for testing; returns '' when disabled (prompts byte-identical).
|
|
348
|
+
*/
|
|
349
|
+
export function questionsPromptBlock(ctx) {
|
|
350
|
+
if (!ctx || !ctx.questionsEnabled) return '';
|
|
351
|
+
const prior = Array.isArray(ctx.questionsAnswered) ? ctx.questionsAnswered : [];
|
|
352
|
+
const answered = prior.length
|
|
353
|
+
? '## Already answered — DO NOT ask these again\n\n' + renderAnswers(prior) + '\n'
|
|
354
|
+
: '';
|
|
355
|
+
if (!ctx.questionsFile) {
|
|
356
|
+
return (
|
|
357
|
+
'\n\n' + answered +
|
|
358
|
+
'## Asking the user\n\n' +
|
|
359
|
+
'No more question rounds are available this run — proceed with reasonable assumptions.\n'
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
const mock = prior.length ? '' : mockMarkers({ MOCK_ASK: ctx.questionsFile }) + '\n';
|
|
363
|
+
return (
|
|
364
|
+
'\n\n' + answered +
|
|
365
|
+
'## Asking the user (enabled)\n\n' +
|
|
366
|
+
'If a decision genuinely blocks correct work and cannot be resolved from the task, the ' +
|
|
367
|
+
'inputs, or the codebase:\n' +
|
|
368
|
+
'1. Write {"questions":[{"id","question","options":[2-4 strings],"allowFreeText":true}]} ' +
|
|
369
|
+
`(max 8 questions) to: ${ctx.questionsFile}\n` +
|
|
370
|
+
'2. STOP immediately — do no further work. You will be resumed with the answers.\n' +
|
|
371
|
+
'Prefer reasonable assumptions for minor choices; never pad, and never re-ask an ' +
|
|
372
|
+
'answered question.\n\n' +
|
|
373
|
+
mock
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* §8.10 mock write targets: every member's ABSOLUTE worktree dir on a DETACHED
|
|
379
|
+
* WORKSPACE run, `[]` otherwise. The offline mock implementer writes `src/` +
|
|
380
|
+
* `test/` into each entry instead of into its cwd, which under detached is the run
|
|
381
|
+
* root — a directory that is no repo, so member checkouts would stay clean, nothing
|
|
382
|
+
* would be committed, and the concatenated patch would be empty.
|
|
383
|
+
*
|
|
384
|
+
* The `ctx.runRoot` gate is LOAD-BEARING, not decoration: `ctx.runRoot` is non-null
|
|
385
|
+
* only on detached runs, and without the gate a LEGACY workspace mock run would
|
|
386
|
+
* start writing into every member worktree instead of only its cwd (the primary's
|
|
387
|
+
* worktree) — a behavior change on the exact path §10's rollback contract promises
|
|
388
|
+
* is byte-identical. Paths stay absolute on purpose (§5.8 keeps the bus channel
|
|
389
|
+
* absolute for exactly this kind of consumer). Pure + exported for testing.
|
|
390
|
+
*/
|
|
391
|
+
export function workspaceWriteTargetsFor(ctx) {
|
|
392
|
+
if (!ctx || !ctx.runRoot) return [];
|
|
393
|
+
return (ctx.workspace?.projects || []).map((p) => p.worktreeDir).filter(Boolean);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Map the orchestrator's claudeOpts into runClaude options shared by every role. */
|
|
397
|
+
function runOpts(ctx, { role, prompt, systemPrompt, allowedTools }) {
|
|
398
|
+
const c = ctx.claudeOpts || {};
|
|
399
|
+
return {
|
|
400
|
+
cwd: ctx.projectDir,
|
|
401
|
+
systemPrompt,
|
|
402
|
+
prompt: (ctx.resumeSessionId ? RESUME_HEADER + prompt : prompt) + questionsPromptBlock(ctx),
|
|
403
|
+
resumeSessionId: ctx.resumeSessionId,
|
|
404
|
+
// Grant the role's baseline tools PLUS whatever the agent declared in its
|
|
405
|
+
// frontmatter (e.g. the Playwright MCP browser_* tools). ctx.node is present
|
|
406
|
+
// for every dispatched node (orchestrator._nodeCtx); the clarify pre-step has
|
|
407
|
+
// no node, so this falls back to the base list. Fixes "Browser permission not
|
|
408
|
+
// granted" for the Manual Web UI Testing agent and makes future MCP agents work.
|
|
409
|
+
allowedTools: effectiveAllowedTools(allowedTools, ctx.node?.tools, ctxFanOut(ctx)),
|
|
410
|
+
// §5.5/§5.3 detached-run context: the generated merged MCP config and one
|
|
411
|
+
// `mcp__<server>` grant per merged server. Both are undefined/[] on legacy runs,
|
|
412
|
+
// so the argv is byte-identical there. The grants are unioned inside the runner
|
|
413
|
+
// rather than here, which keeps effectiveAllowedTools' frontmatter semantics
|
|
414
|
+
// intact (a grant is not a declared tool).
|
|
415
|
+
mcpConfigPath: ctx.mcpConfigPath,
|
|
416
|
+
mcpServerGrants: ctx.mcpServerGrants,
|
|
417
|
+
// §8.10: offline-mock write targets. [] on legacy runs and on every single-project
|
|
418
|
+
// run, where the mock keeps writing at its cwd exactly as today. Ignored entirely
|
|
419
|
+
// by the real runner (it is never a spawn flag).
|
|
420
|
+
workspaceWriteTargets: workspaceWriteTargetsFor(ctx),
|
|
421
|
+
permissionMode: c.permissionMode || 'acceptEdits',
|
|
422
|
+
model: c.model,
|
|
423
|
+
effort: c.effort, // per-role effort from the orchestrator
|
|
424
|
+
// Per-model routing env (design §4.4), resolved HERE — the one funnel every
|
|
425
|
+
// dispatched node/role passes through — so _phaseCtx/_nodeCtx and the
|
|
426
|
+
// workspace-scan path all inherit it without per-caller edits. undefined
|
|
427
|
+
// when the model carries no env (or no model is set), keeping the spawn
|
|
428
|
+
// env byte-identical.
|
|
429
|
+
modelEnv: resolveModelEnv(c.model),
|
|
430
|
+
// Guardrails: worca policy + lifted repo deny rules as {deny,...} rules ->
|
|
431
|
+
// ONE --settings payload; envScrub/envAllowlist -> spawn env. All undefined
|
|
432
|
+
// when the project has no guardrails, so the argv and env stay byte-identical
|
|
433
|
+
// (the runner treats undefined as "absent").
|
|
434
|
+
permissionRules: c.permissionRules,
|
|
435
|
+
envScrub: c.envScrub,
|
|
436
|
+
envAllowlist: c.envAllowlist,
|
|
437
|
+
bin: c.bin,
|
|
438
|
+
mock: c.mock,
|
|
439
|
+
signal: ctx.signal,
|
|
440
|
+
onEvent: (e) => {
|
|
441
|
+
if (typeof ctx.onEvent === 'function') ctx.onEvent({ ...e, role });
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export const _runOptsForTests = runOpts;
|
|
447
|
+
|
|
448
|
+
/** A compact task header reused across roles. Exported for testing. */
|
|
449
|
+
export function taskHeader(ctx, title) {
|
|
450
|
+
// Who gets the raw request + attachments? The ENTRY node (step 0) always, PLUS any
|
|
451
|
+
// userPrompt consumer (so the planner keeps the user's attachments even though Clarify
|
|
452
|
+
// is now the entry). Refiner/reviewer/planReviewer get the request text by policy but
|
|
453
|
+
// work off upstream artifacts, not attachments.
|
|
454
|
+
const key = ctx.node?.key;
|
|
455
|
+
const isEntry = !!ctx.isEntry;
|
|
456
|
+
const consumesPrompt = !ctx.inputs || ('userPrompt' in ctx.inputs);
|
|
457
|
+
const wantsPrompt = isEntry || consumesPrompt || key === 'refiner' || key === 'reviewer' || key === 'planReviewer';
|
|
458
|
+
const requestBlock = wantsPrompt
|
|
459
|
+
? `## Original request\n\n${(ctx.taskPrompt || '').trim() || '(no prompt text)'}\n`
|
|
460
|
+
: `## Upstream input\n\nYour input is the output of the preceding step(s); the file paths to read are named below.\n`;
|
|
461
|
+
const attachBlock = (isEntry || consumesPrompt) ? renderAttachmentsBlock(ctx.extras) : '';
|
|
462
|
+
const detached = isDetachedRun(ctx);
|
|
463
|
+
const wsRun = isDetachedWorkspace(ctx);
|
|
464
|
+
// §5.8: workspace mode names the RUN ROOT as cwd and the members as ./repos/<key>;
|
|
465
|
+
// single mode (in BOTH run-root modes) keeps today's line byte-identical, because
|
|
466
|
+
// its cwd really is that project's own checkout.
|
|
467
|
+
const locationLine = wsRun
|
|
468
|
+
? `Run root (your cwd): ${ctx.runRoot}\n` +
|
|
469
|
+
`Member projects: ${wsMembers(ctx)
|
|
470
|
+
.map((p) => `./${relRepo(p) || p.worktreeDir} (${p.projectName || p.projectKey})`)
|
|
471
|
+
.join(', ')}\n` +
|
|
472
|
+
`You are in no single project: the cwd is a worca-cc run root that belongs to no member, ` +
|
|
473
|
+
`and ALL code work happens inside the listed member worktrees.\n`
|
|
474
|
+
: `Project directory (your cwd): ${ctx.projectDir}\n`;
|
|
475
|
+
// Skills hint. The detached sentence is truthful ONLY under detached, where
|
|
476
|
+
// project + root skills are COPIED into `<cwd>/.claude/skills` for the run (§5.7);
|
|
477
|
+
// legacy delivers neither (skills.mjs copies bundle/plugin entries only), so the
|
|
478
|
+
// legacy sentence stays exactly as today.
|
|
479
|
+
const skillsHint = detached
|
|
480
|
+
? `Project and root skills are mounted at .claude/skills for this run (in addition to your ` +
|
|
481
|
+
`personal ~/.claude/skills) and are available via the Skill tool — invoke any that fit ` +
|
|
482
|
+
`(e.g. design, framework-pattern, or knowledge-graph skills) rather than guessing ` +
|
|
483
|
+
`conventions.\n\n`
|
|
484
|
+
: `Project and personal skills (.claude/skills in this project and ~/.claude/skills) are ` +
|
|
485
|
+
`available via the Skill tool — invoke any that fit (e.g. design, framework-pattern, or ` +
|
|
486
|
+
`knowledge-graph skills) rather than guessing conventions.\n\n`;
|
|
487
|
+
return (
|
|
488
|
+
`# Task: ${title}\n\n` +
|
|
489
|
+
locationLine +
|
|
490
|
+
`Pipeline directory (shared artifacts): ${ctx.pipelineDir}\n\n` +
|
|
491
|
+
skillsHint +
|
|
492
|
+
workspaceProjectsBlock(ctx.workspace, { relative: wsRun }) +
|
|
493
|
+
requestBlock +
|
|
494
|
+
attachBlock
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* On a workspace run, a `## Workspace projects` block listing each member's
|
|
500
|
+
* checkout and checkpoint ref (its diff base), so the driving agent knows where to
|
|
501
|
+
* work and what to diff against. Returns '' when there is no workspace, so
|
|
502
|
+
* single-project task headers are byte-identical.
|
|
503
|
+
*
|
|
504
|
+
* `relative` (detached workspace runs only, §5.8) renders each checkout as
|
|
505
|
+
* `repos/<projectKey>` — a RENDER-TIME token: the bus channel and
|
|
506
|
+
* `state.branches[*].worktreeDir` stay ABSOLUTE (resume rehydrates member worktrees
|
|
507
|
+
* from them, so a relative path there would hard-fail every workspace resume). It
|
|
508
|
+
* also adds each member's graph location, because the scalar graph instruction is
|
|
509
|
+
* meaningless at a non-repo cwd. Legacy keeps today's absolute render + cwd-into
|
|
510
|
+
* wording.
|
|
511
|
+
* @param {{projects?:Array<{projectKey?,projectName?,worktreeDir?,checkpointRef?,graphInstruction?}>}|null|undefined} ws
|
|
512
|
+
* @param {{relative?:boolean}} [opts]
|
|
513
|
+
*/
|
|
514
|
+
function workspaceProjectsBlock(ws, { relative = false } = {}) {
|
|
515
|
+
const projects = ws && Array.isArray(ws.projects) ? ws.projects : [];
|
|
516
|
+
if (projects.length === 0) return '';
|
|
517
|
+
const lines = projects.map((p) => {
|
|
518
|
+
const rel = relRepo(p);
|
|
519
|
+
// The `(pending)` tolerance is kept: before worktrees exist there is no dir, and
|
|
520
|
+
// a member with no projectKey cannot be relativized either.
|
|
521
|
+
const where = (relative && rel) || p.worktreeDir || '(pending)';
|
|
522
|
+
const graph = relative && rel && String(p.graphInstruction || '').trim()
|
|
523
|
+
? `, graph \`${rel}/graphify-out/\`` : '';
|
|
524
|
+
return `- **${p.projectName || p.projectKey}** (\`${p.projectKey}\`): worktree \`${where}\`` +
|
|
525
|
+
`, diff base \`${p.checkpointRef || '(none)'}\`${graph}`;
|
|
526
|
+
});
|
|
527
|
+
const intro = relative
|
|
528
|
+
? `This run spans the member projects below, each checked out INSIDE your cwd. Work in ` +
|
|
529
|
+
`a member in place — edit \`repos/<key>/…\` and run git as \`git -C repos/<key> …\` ` +
|
|
530
|
+
`(never chdir out of the run root) — and diff it against that project's checkpoint:`
|
|
531
|
+
: `This run spans the member projects below. A fan-out sub-agent cwds into the ` +
|
|
532
|
+
`named worktree and diffs against that project's checkpoint:`;
|
|
533
|
+
return (
|
|
534
|
+
`## Workspace projects\n\n` +
|
|
535
|
+
intro + `\n\n` +
|
|
536
|
+
lines.join('\n') +
|
|
537
|
+
`\n\n`
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Per-member diff hints for a DETACHED WORKSPACE node, '' otherwise (single mode in
|
|
543
|
+
* both run-root modes, and legacy workspace runs, keep today's cwd-scoped wording).
|
|
544
|
+
* At a run-root cwd there is no working tree to `git diff` and the scalar
|
|
545
|
+
* `ctx.checkpointRef` is meaningless, so every consumer that used to say "via `git
|
|
546
|
+
* diff` in your cwd" renders these lines instead (§5.8). Pure + exported.
|
|
547
|
+
*/
|
|
548
|
+
export function workspaceDiffInstruction(ctx) {
|
|
549
|
+
if (!isDetachedWorkspace(ctx)) return '';
|
|
550
|
+
return wsMembers(ctx)
|
|
551
|
+
.map((p) => `- **${p.projectName || p.projectKey}**: \`git -C ${relRepo(p)} diff ` +
|
|
552
|
+
`${p.checkpointRef || 'HEAD'}\``)
|
|
553
|
+
.join('\n');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Build the clarify task prompt. When the user has already answered questions in
|
|
558
|
+
* an earlier round, those are injected so the planner never re-asks them.
|
|
559
|
+
* Exported for testing. Pure (no IO).
|
|
560
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
561
|
+
* @param {{ round?: number, priorAnswers?: Array<{id,question,choice}> }} [opts]
|
|
562
|
+
*/
|
|
563
|
+
export function buildClarifyPrompt(ctx, opts = {}) {
|
|
564
|
+
const round = Number(opts.round) > 0 ? Number(opts.round) : 1;
|
|
565
|
+
const priorAnswers = Array.isArray(opts.priorAnswers) ? opts.priorAnswers : [];
|
|
566
|
+
const outPath = joinPipeline(ctx.pipelineDir, 'clarify.json');
|
|
567
|
+
const role = 'clarify';
|
|
568
|
+
const answered =
|
|
569
|
+
priorAnswers.length > 0
|
|
570
|
+
? '## Already answered — DO NOT ask these again\n\n' +
|
|
571
|
+
'The user already answered the questions below in an earlier round. Do NOT re-ask, ' +
|
|
572
|
+
'rephrase, or split them. Ask ONLY genuinely new questions that are still material and ' +
|
|
573
|
+
'not implied by these answers. If nothing material remains open, write ' +
|
|
574
|
+
'{ "questions": [] } to the path below.\n\n' +
|
|
575
|
+
renderAnswers(priorAnswers) +
|
|
576
|
+
'\n'
|
|
577
|
+
: '';
|
|
578
|
+
return (
|
|
579
|
+
taskHeader(ctx, 'Clarify before planning') +
|
|
580
|
+
'\n## What to do\n\n' +
|
|
581
|
+
'Identify the decisions you cannot safely resolve from the task text or the real ' +
|
|
582
|
+
'codebase — including things a downstream agent (planner/implementer) would otherwise ' +
|
|
583
|
+
'silently assume. For each, produce one conceptual question with 2 to 4 options and a ' +
|
|
584
|
+
'free-text fallback. Ask only what materially changes the plan (up to 8 questions); never ' +
|
|
585
|
+
'pad, and never split one decision. For low-impact details, pick a sensible default rather ' +
|
|
586
|
+
'than asking. If you have no material open questions, write { "questions": [] } to that ' +
|
|
587
|
+
'same path.\n\n' +
|
|
588
|
+
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: isDetachedWorkspace(ctx) }) +
|
|
589
|
+
`Write the clarify JSON to: ${outPath}\n\n` +
|
|
590
|
+
answered +
|
|
591
|
+
mockMarkers({
|
|
592
|
+
MOCK_ROLE: role,
|
|
593
|
+
MOCK_OUT: outPath,
|
|
594
|
+
MOCK_CYCLE: round,
|
|
595
|
+
MOCK_PRIOR: priorAnswers.length,
|
|
596
|
+
})
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Clarify agent. Writes clarify.json; returns { questions }.
|
|
602
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
603
|
+
* @param {{ round?: number, priorAnswers?: Array<{id,question,choice}> }} [opts]
|
|
604
|
+
* `priorAnswers` are the Q&A already resolved in earlier rounds; injecting them
|
|
605
|
+
* lets the planner ask only NEW questions, so the loop terminates naturally.
|
|
606
|
+
*/
|
|
607
|
+
export async function runClarify(ctx, opts = {}) {
|
|
608
|
+
const round = Number(opts.round) > 0 ? Number(opts.round) : 1;
|
|
609
|
+
const priorAnswers = Array.isArray(opts.priorAnswers) ? opts.priorAnswers : [];
|
|
610
|
+
const role = 'clarify';
|
|
611
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'clarify'), role, ctx.workspace);
|
|
612
|
+
const prompt = buildClarifyPrompt(ctx, { round, priorAnswers });
|
|
613
|
+
|
|
614
|
+
await runClaude(runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }));
|
|
615
|
+
|
|
616
|
+
// The agent wrote clarify.json into the run dir as transient scratch; parse it
|
|
617
|
+
// ONCE here, then make the DB the authoritative store. When ctx.pipelineId is
|
|
618
|
+
// present (every real dispatched run) we ingest the normalized questions into the
|
|
619
|
+
// clarify row and read them back from the row, so the planner loop consumes the DB
|
|
620
|
+
// — not the FS file. Absent a pipelineId (pure unit ctx) we return the FS-parsed
|
|
621
|
+
// value unchanged, so phases.mjs stays independently testable.
|
|
622
|
+
const clarify = await readClarify(ctx.pipelineDir);
|
|
623
|
+
if (ctx.pipelineId) {
|
|
624
|
+
await writeClarify(ctx.pipelineId, { questions: { questions: clarify.questions } });
|
|
625
|
+
const row = readClarifyRow(ctx.pipelineId);
|
|
626
|
+
const questions = row.questions?.questions ?? clarify.questions;
|
|
627
|
+
return { questions };
|
|
628
|
+
}
|
|
629
|
+
return { questions: clarify.questions };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Planner — plan role. Writes the plan markdown; returns { planPath }.
|
|
634
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
635
|
+
* @param {{ answers: Array<{id,question,choice}>, planFilePath: string, baseName: string }} opts
|
|
636
|
+
*/
|
|
637
|
+
export async function runPlannerPlan(ctx, opts) {
|
|
638
|
+
const { answers = [], planFilePath, baseName, reviewPath } = opts || {};
|
|
639
|
+
const role = 'planner-plan';
|
|
640
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'planner'), role, ctx.workspace);
|
|
641
|
+
const replanBlock = reviewPath
|
|
642
|
+
? '\n## Revise to address the review\n\n' +
|
|
643
|
+
'A reviewer found issues with the previous plan. Re-plan from scratch (cold start) and ' +
|
|
644
|
+
'address EVERY critical and major finding in the review below. Preserve the ' +
|
|
645
|
+
'"## Clarifications (Q&A)" section.\n\n' +
|
|
646
|
+
`Review to address: ${reviewPath}\n`
|
|
647
|
+
: '';
|
|
648
|
+
const prompt =
|
|
649
|
+
taskHeader(ctx, reviewPath ? 'Revise the implementation plan' : 'Write the implementation plan') +
|
|
650
|
+
'\n## What to do\n\n' +
|
|
651
|
+
'Write a complete, build-ready implementation plan. It MUST contain concrete code snippets ' +
|
|
652
|
+
'for the features and MUST end with a "## Clarifications (Q&A)" section reproducing the ' +
|
|
653
|
+
'questions and the user answers below so the reviewer can see them.\n\n' +
|
|
654
|
+
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: isDetachedWorkspace(ctx) }) +
|
|
655
|
+
workspaceFanOutDirective('explore', ctx.workspace, { relative: isDetachedWorkspace(ctx) }) +
|
|
656
|
+
`Write the plan markdown to: ${planFilePath}\n` +
|
|
657
|
+
replanBlock +
|
|
658
|
+
'\n## Clarifications already answered\n\n' +
|
|
659
|
+
renderAnswers(answers) +
|
|
660
|
+
'\n' +
|
|
661
|
+
mockMarkers({ MOCK_ROLE: role, MOCK_OUT: planFilePath, MOCK_BASE: baseName, MOCK_IN: reviewPath });
|
|
662
|
+
|
|
663
|
+
await runClaude(runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }));
|
|
664
|
+
|
|
665
|
+
return { planPath: planFilePath };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Plan Refiner — one cycle. Reads inPlanPath, writes refined plan to outPlanPath and a
|
|
670
|
+
* review JSON to reviewJsonPath. Returns { outPlanPath, review }.
|
|
671
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
672
|
+
* @param {{ inPlanPath: string, outPlanPath: string, cycle: number, reviewJsonPath: string }} opts
|
|
673
|
+
*/
|
|
674
|
+
export async function runRefiner(ctx, opts) {
|
|
675
|
+
const { inPlanPath, outPlanPath, cycle, reviewJsonPath } = opts || {};
|
|
676
|
+
const role = 'refiner';
|
|
677
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'refiner'), role, ctx.workspace);
|
|
678
|
+
const prompt =
|
|
679
|
+
taskHeader(ctx, `Refine the plan (cycle ${cycle})`) +
|
|
680
|
+
'\n## What to do\n\n' +
|
|
681
|
+
`Read the current plan, critically review it INCLUDING its code snippets, then write an ` +
|
|
682
|
+
`improved version and a machine-readable review.\n\n` +
|
|
683
|
+
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: isDetachedWorkspace(ctx) }) +
|
|
684
|
+
workspaceFanOutDirective('explore', ctx.workspace, { relative: isDetachedWorkspace(ctx) }) +
|
|
685
|
+
`Current plan to refine: ${inPlanPath}\n` +
|
|
686
|
+
`Write the refined plan to: ${outPlanPath}\n` +
|
|
687
|
+
`Write the review JSON to: ${reviewJsonPath}\n\n` +
|
|
688
|
+
'The review JSON shape is { "issues": [ { "severity", "title", "detail", "location" } ], ' +
|
|
689
|
+
'"summary" }. Use severities critical|major|minor|suggestion. Mark a finding critical/major ' +
|
|
690
|
+
'only if it must be fixed before implementation.\n\n' +
|
|
691
|
+
mockMarkers({
|
|
692
|
+
MOCK_ROLE: role,
|
|
693
|
+
MOCK_OUT: outPlanPath,
|
|
694
|
+
MOCK_JSON: reviewJsonPath,
|
|
695
|
+
MOCK_CYCLE: cycle,
|
|
696
|
+
MOCK_IN: inPlanPath,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
await runClaude(runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }));
|
|
700
|
+
|
|
701
|
+
const review = await readReview(reviewJsonPath);
|
|
702
|
+
return { outPlanPath, review };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Decomposer — breaks the plan into vertical-slice task files + a decomposition.json
|
|
707
|
+
* manifest. Reads planPath; writes tasks/ + decompositionPath. Returns
|
|
708
|
+
* { decompositionPath, decomposition } where decomposition is the parsed manifest.
|
|
709
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
710
|
+
* @param {{ planPath: string, decompositionPath: string }} opts
|
|
711
|
+
*/
|
|
712
|
+
export async function runDecomposer(ctx, opts) {
|
|
713
|
+
const { join, dirname } = await import('node:path');
|
|
714
|
+
const { planPath, decompositionPath } = opts || {};
|
|
715
|
+
const role = 'decomposer';
|
|
716
|
+
const tasksDir = join(dirname(decompositionPath), 'tasks');
|
|
717
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'decomposer'), role, ctx.workspace);
|
|
718
|
+
const prompt =
|
|
719
|
+
taskHeader(ctx, 'Decompose the plan into vertical-slice tasks') +
|
|
720
|
+
'\n## What to do\n\n' +
|
|
721
|
+
'Read the approved plan and break it into tracer-bullet vertical slices grouped into ' +
|
|
722
|
+
'ordered phases. Within a phase, tasks must be parallel-safe and edit DISJOINT files; ' +
|
|
723
|
+
'dependencies are expressed only as phase order. Write each task as a SELF-CONTAINED ' +
|
|
724
|
+
'markdown file so an implementer needs nothing but that file.\n\n' +
|
|
725
|
+
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: isDetachedWorkspace(ctx) }) +
|
|
726
|
+
`Plan to decompose: ${planPath}\n` +
|
|
727
|
+
`Write each task file under: ${tasksDir}/ (name them p<phase>-t<n>-<kebab-title>.md)\n` +
|
|
728
|
+
`Write the manifest JSON to: ${decompositionPath}\n\n` +
|
|
729
|
+
'The manifest shape is { "phases": [ { "ordinal", "tasks": [ { "id", "title", "file" } ] } ] }. ' +
|
|
730
|
+
'Use id "p<ordinal>t<n>" and a pipeline-dir-relative "file" path.\n\n' +
|
|
731
|
+
mockMarkers({
|
|
732
|
+
MOCK_ROLE: role,
|
|
733
|
+
MOCK_OUT: decompositionPath,
|
|
734
|
+
MOCK_TASKS_DIR: tasksDir,
|
|
735
|
+
MOCK_IN: planPath,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
await runClaude(runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }));
|
|
739
|
+
|
|
740
|
+
const decomposition = await readDecomposition(decompositionPath);
|
|
741
|
+
return { decompositionPath, decomposition };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Parse a decomposition.json manifest; tolerant ({phases:[]} on any error). */
|
|
745
|
+
async function readDecomposition(path) {
|
|
746
|
+
const { readFile } = await import('node:fs/promises');
|
|
747
|
+
try {
|
|
748
|
+
const raw = JSON.parse(await readFile(path, 'utf8'));
|
|
749
|
+
return { phases: Array.isArray(raw?.phases) ? raw.phases : [] };
|
|
750
|
+
} catch {
|
|
751
|
+
return { phases: [] };
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* The shared-working-tree warning for a decomposed task that runs alongside phase
|
|
757
|
+
* siblings. Parallel implementers share ONE tree with no locking, so the block
|
|
758
|
+
* pins down the only safe behaviors: own files only, scoped tests, no tree-wide
|
|
759
|
+
* git ops. Empty string when there are no siblings (solo task in its phase).
|
|
760
|
+
* @param {Array<{id:string,title?:string,file?:string}>|undefined} siblings
|
|
761
|
+
*/
|
|
762
|
+
function siblingsBlock(siblings) {
|
|
763
|
+
if (!Array.isArray(siblings) || !siblings.length) return '';
|
|
764
|
+
const lines = siblings
|
|
765
|
+
.map((s) => `- ${s.id}${s.title ? ` "${s.title}"` : ''}${s.file ? ` (${s.file})` : ''}`)
|
|
766
|
+
.join('\n');
|
|
767
|
+
return (
|
|
768
|
+
`\n## Parallel siblings — shared working tree\n\n` +
|
|
769
|
+
`${siblings.length} other implementer(s) are editing THIS SAME working tree right now, each on its own task:\n` +
|
|
770
|
+
`${lines}\n\n` +
|
|
771
|
+
`Hard rules:\n` +
|
|
772
|
+
`1. Edit ONLY the files your TASK file lists. If you need another file, DO NOT touch it — record a deviation and stop that step.\n` +
|
|
773
|
+
`2. Run tests SCOPED to your slice (the TASK file's verify command or your own test files). Do NOT run the full suite — siblings' in-progress red tests make it nondeterministic. Full-suite verification happens after the phase.\n` +
|
|
774
|
+
`3. A failure in a file you do not own is a sibling's work in progress. Ignore it. Never edit or "fix" a sibling's file.\n` +
|
|
775
|
+
`4. No tree-wide git operations: no stash, no checkout --, no reset, no clean, no add, no commit.\n`
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Build the implementer task body. Pure (exported for tests). When `taskPath` is
|
|
781
|
+
* present (a decomposed run), the self-contained task file is authoritative and the
|
|
782
|
+
* plan is reference/context only — the implementer no longer reads the whole plan;
|
|
783
|
+
* `siblings` (the OTHER tasks of the same phase) appends the shared-tree rules.
|
|
784
|
+
* Absent a taskPath, behavior is byte-identical to today (plan is authoritative)
|
|
785
|
+
* and siblings are ignored, as they are in fix mode (the fix pass is always solo).
|
|
786
|
+
* @param {{ mode:'implement'|'fix', planPath:string, reviewPath?:string, taskPath?:string, siblings?:Array<{id:string,title?:string,file?:string}> }} o
|
|
787
|
+
*/
|
|
788
|
+
export function implementerBody({ mode = 'implement', planPath, reviewPath, taskPath, siblings } = {}) {
|
|
789
|
+
if (mode === 'fix') {
|
|
790
|
+
// VERBATIM from the original phases.mjs fix-mode body.
|
|
791
|
+
return (
|
|
792
|
+
`Address EVERY critical and major issue in the review below, then re-run the tests. ` +
|
|
793
|
+
`Follow the plan; deviate only if something does not work at all.\n\n` +
|
|
794
|
+
`Plan: ${planPath}\n` +
|
|
795
|
+
`Review to fix: ${reviewPath}\n`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
if (taskPath) {
|
|
799
|
+
return (
|
|
800
|
+
`Implement the task below using TDD (red-green-refactor). The TASK file is a ` +
|
|
801
|
+
`self-contained vertical slice and is AUTHORITATIVE — do exactly what it says and ` +
|
|
802
|
+
`nothing outside its scope. The plan is reference/context only; you do NOT need to ` +
|
|
803
|
+
`read the whole plan.\n\n` +
|
|
804
|
+
`TASK (authoritative, self-contained): ${taskPath}\n` +
|
|
805
|
+
`Plan (reference only): ${planPath}\n` +
|
|
806
|
+
siblingsBlock(siblings)
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
// VERBATIM from the original phases.mjs implement-mode body.
|
|
810
|
+
return (
|
|
811
|
+
`Implement the plan using TDD (red-green-refactor). Follow it with NO deviation; ` +
|
|
812
|
+
`deviate slightly only if a step does not work at all.\n\n` +
|
|
813
|
+
`Plan: ${planPath}\n`
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Implementer — implement or fix. Returns { summary }.
|
|
819
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
820
|
+
* @param {{ planPath: string, reviewPath?: string, taskPath?: string, siblings?: Array<{id:string,title?:string,file?:string}>, mode: "implement"|"fix" }} opts
|
|
821
|
+
*/
|
|
822
|
+
export async function runImplementer(ctx, opts) {
|
|
823
|
+
const { planPath, reviewPath, taskPath, siblings, mode = 'implement' } = opts || {};
|
|
824
|
+
const role = 'implementer';
|
|
825
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'implementer'), role, ctx.workspace);
|
|
826
|
+
|
|
827
|
+
const body = implementerBody({ mode, planPath, reviewPath, taskPath, siblings });
|
|
828
|
+
|
|
829
|
+
const prompt =
|
|
830
|
+
taskHeader(ctx, mode === 'fix' ? 'Fix the implementation' : 'Implement the plan') +
|
|
831
|
+
'\n## What to do\n\n' +
|
|
832
|
+
body +
|
|
833
|
+
'\n' +
|
|
834
|
+
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: isDetachedWorkspace(ctx) }) +
|
|
835
|
+
workspaceFanOutDirective('task', ctx.workspace, { relative: isDetachedWorkspace(ctx) }) +
|
|
836
|
+
'Work inside the project directory (your cwd). Commit nothing; just edit files and tests.\n\n' +
|
|
837
|
+
mockMarkers({ MOCK_ROLE: role, MOCK_IN: taskPath || planPath, MOCK_OUT: reviewPath });
|
|
838
|
+
|
|
839
|
+
const { text } = await runClaude(
|
|
840
|
+
runOpts(ctx, { role, prompt, systemPrompt, allowedTools: IMPLEMENTER_TOOLS }),
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
const summary = (text || '').trim() || `Implementer (${mode}) completed.`;
|
|
844
|
+
return { summary };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Code Reviewer — one cycle. Writes review markdown + review JSON. Returns { review }.
|
|
849
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
850
|
+
* @param {{ planPath: string, reviewMdPath: string, reviewJsonPath: string, cycle: number }} opts
|
|
851
|
+
*/
|
|
852
|
+
export async function runReviewer(ctx, opts) {
|
|
853
|
+
const { planPath, reviewMdPath, reviewJsonPath, cycle } = opts || {};
|
|
854
|
+
const role = 'reviewer';
|
|
855
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'reviewer'), role, ctx.workspace);
|
|
856
|
+
// Prefer diffing against the recorded checkpoint commit. New files are made
|
|
857
|
+
// visible via the orchestrator's intent-to-add staging after each implement
|
|
858
|
+
// pass, so `git diff <ref>` and `git status` both show greenfield work.
|
|
859
|
+
const ref = (ctx.checkpointRef || '').trim();
|
|
860
|
+
const diffInstruction = ref
|
|
861
|
+
? `Inspect the diff with \`git diff ${ref}\` (the orchestrator's pre-implementation ` +
|
|
862
|
+
`checkpoint) and \`git status\` in your cwd. New/untracked files are intent-to-added, ` +
|
|
863
|
+
`so they DO appear in that diff; use \`git status\` to cross-check.`
|
|
864
|
+
: 'Inspect the diff with `git diff` and `git status` in your cwd. If `git diff` looks ' +
|
|
865
|
+
'empty, the changes may be newly-created files — confirm with `git status` and ' +
|
|
866
|
+
'`git diff HEAD`.';
|
|
867
|
+
const prompt =
|
|
868
|
+
taskHeader(ctx, `Review the implementation (cycle ${cycle})`) +
|
|
869
|
+
'\n## What to do\n\n' +
|
|
870
|
+
'Review the git diff of what was implemented against the plan. Write a human-readable review ' +
|
|
871
|
+
'markdown AND a machine-readable review JSON. ' +
|
|
872
|
+
diffInstruction +
|
|
873
|
+
'\n\n' +
|
|
874
|
+
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: isDetachedWorkspace(ctx) }) +
|
|
875
|
+
workspaceFanOutDirective('review', ctx.workspace, { relative: isDetachedWorkspace(ctx) }) +
|
|
876
|
+
`Plan that was implemented: ${planPath}\n` +
|
|
877
|
+
`Write the review markdown to: ${reviewMdPath}\n` +
|
|
878
|
+
`Write the review JSON to: ${reviewJsonPath}\n\n` +
|
|
879
|
+
'The review JSON shape is { "issues": [ { "severity", "title", "detail", "location" } ], ' +
|
|
880
|
+
'"summary" }. Use severities critical|major|minor|suggestion; only critical/major block the ' +
|
|
881
|
+
'pipeline.\n\n' +
|
|
882
|
+
mockMarkers({
|
|
883
|
+
MOCK_ROLE: role,
|
|
884
|
+
MOCK_OUT: reviewMdPath,
|
|
885
|
+
MOCK_JSON: reviewJsonPath,
|
|
886
|
+
MOCK_CYCLE: cycle,
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
await runClaude(runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }));
|
|
890
|
+
|
|
891
|
+
const review = await readReview(reviewJsonPath);
|
|
892
|
+
return { review };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Plan Reviewer — one cycle. Reviews the PLAN markdown (no git diff). Writes a review
|
|
897
|
+
* markdown + review JSON. Returns { review }.
|
|
898
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
899
|
+
* @param {{ planPath: string, reviewMdPath: string, reviewJsonPath: string, cycle: number }} opts
|
|
900
|
+
*/
|
|
901
|
+
export async function runPlanReviewer(ctx, opts) {
|
|
902
|
+
const { planPath, reviewMdPath, reviewJsonPath, cycle } = opts || {};
|
|
903
|
+
const role = 'plan-review';
|
|
904
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'planReviewer'), role, ctx.workspace);
|
|
905
|
+
const prompt =
|
|
906
|
+
taskHeader(ctx, `Review the plan (cycle ${cycle})`) +
|
|
907
|
+
'\n## What to do\n\n' +
|
|
908
|
+
'Review the implementation PLAN against the original request and the real codebase. Do NOT ' +
|
|
909
|
+
'rewrite the plan. Write a human-readable review markdown AND a machine-readable review JSON.\n\n' +
|
|
910
|
+
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: isDetachedWorkspace(ctx) }) +
|
|
911
|
+
workspaceFanOutDirective('explore', ctx.workspace, { relative: isDetachedWorkspace(ctx) }) +
|
|
912
|
+
`Plan to review: ${planPath}\n` +
|
|
913
|
+
`Write the review markdown to: ${reviewMdPath}\n` +
|
|
914
|
+
`Write the review JSON to: ${reviewJsonPath}\n\n` +
|
|
915
|
+
'The review JSON shape is { "issues": [ { "severity", "title", "detail", "location" } ], ' +
|
|
916
|
+
'"summary" }. Use severities critical|major|minor|suggestion; only critical/major block (the ' +
|
|
917
|
+
'planner then revises).\n\n' +
|
|
918
|
+
mockMarkers({
|
|
919
|
+
MOCK_ROLE: role,
|
|
920
|
+
MOCK_OUT: reviewMdPath,
|
|
921
|
+
MOCK_JSON: reviewJsonPath,
|
|
922
|
+
MOCK_CYCLE: cycle,
|
|
923
|
+
MOCK_IN: planPath,
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
await runClaude(runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }));
|
|
927
|
+
|
|
928
|
+
const review = await readReview(reviewJsonPath);
|
|
929
|
+
return { review };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Workspace Reviewer — verifier (in-pipeline, loopSource). The workspace-run
|
|
934
|
+
* replacement for runReviewer: fan out one reviewer sub-agent per CHANGED member
|
|
935
|
+
* (each diffing `checkpointRefs[projectKey]...feature` inside that member's
|
|
936
|
+
* worktree — the `## Workspace projects` block in the task header names each
|
|
937
|
+
* worktree dir + checkpoint), then synthesize ONE review markdown + ONE
|
|
938
|
+
* review-cycleN.json that is the UNION of every critical/major issue, sorted by
|
|
939
|
+
* projectKey then severity. Reuses protocol.readReview / hasBlocking unchanged, so
|
|
940
|
+
* the orchestrator's review->implementer loop gates identically. Returns { review }.
|
|
941
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
942
|
+
* @param {{ planPath: string, reviewMdPath: string, reviewJsonPath: string, cycle: number }} opts
|
|
943
|
+
*/
|
|
944
|
+
export async function runWorkspaceReviewer(ctx, opts) {
|
|
945
|
+
const { planPath, reviewMdPath, reviewJsonPath, cycle } = opts || {};
|
|
946
|
+
const role = 'workspace-reviewer';
|
|
947
|
+
// The body is the contract (C10: no FALLBACK_PROMPTS entry); the system prompt
|
|
948
|
+
// ALSO carries the `## Workspace Context` block via ctx.workspace.
|
|
949
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'workspaceReviewer'), role, ctx.workspace);
|
|
950
|
+
const prompt =
|
|
951
|
+
taskHeader(ctx, `Review the workspace implementation (cycle ${cycle})`) +
|
|
952
|
+
'\n## What to do\n\n' +
|
|
953
|
+
'Review what was implemented across the member projects against the plan. Write a SINGLE ' +
|
|
954
|
+
'human-readable review markdown AND a SINGLE machine-readable review JSON.\n\n' +
|
|
955
|
+
workspaceFanOutDirective('review', ctx.workspace, { relative: isDetachedWorkspace(ctx) }) +
|
|
956
|
+
`Plan that was implemented: ${planPath}\n` +
|
|
957
|
+
`Write the merged review markdown to: ${reviewMdPath}\n` +
|
|
958
|
+
`Write the merged review JSON to: ${reviewJsonPath}\n\n` +
|
|
959
|
+
'The review JSON shape is { "issues": [ { "severity", "title", "detail", "location" } ], ' +
|
|
960
|
+
'"summary" }. Use severities critical|major|minor|suggestion; only critical/major block the ' +
|
|
961
|
+
'pipeline. The issue list is the UNION of every per-project critical/major issue (never ' +
|
|
962
|
+
'collapse one), sorted by projectKey then severity, each location prefixed "<projectKey>: ".\n\n' +
|
|
963
|
+
mockMarkers({
|
|
964
|
+
MOCK_ROLE: role,
|
|
965
|
+
MOCK_OUT: reviewMdPath,
|
|
966
|
+
MOCK_JSON: reviewJsonPath,
|
|
967
|
+
MOCK_CYCLE: cycle,
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
await runClaude(runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }));
|
|
971
|
+
|
|
972
|
+
const review = await readReview(reviewJsonPath);
|
|
973
|
+
return { review };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Workspace Scan — off-pipeline producer (NOT a workflow node, NOT routed through
|
|
978
|
+
* runners.mjs). The wizard's scan engine (M5: workspace-scan.mjs) calls this
|
|
979
|
+
* directly to investigate cross-project relations and write the editable
|
|
980
|
+
* interconnection description. It IS the scanner, so it gets NO `## Workspace
|
|
981
|
+
* Context` block injected (4th buildSystemPrompt arg is undefined). The task prompt
|
|
982
|
+
* names every member + its graph path, carries the scan fan-out directive and the
|
|
983
|
+
* §5.8 description template, and emits an `INVESTIGATING <key> relations to <other>`
|
|
984
|
+
* line per investigation so the server's scan-event mapper turns those into the
|
|
985
|
+
* CHANGING live status (structured `phase` is owned by the engine, not the agent).
|
|
986
|
+
* Writes ONE markdown string to `pipelineDir/workspace-description.md` (or
|
|
987
|
+
* opts.outPath) and returns it. Mockable via MOCK_ROLE 'workspace-scan'.
|
|
988
|
+
* @param {import('./phases.mjs').PhaseContext} ctx ctx.projects = sorted members
|
|
989
|
+
* @param {{ outPath?: string, name?: string }} [opts]
|
|
990
|
+
* @returns {Promise<{ description: string, outPath: string }>}
|
|
991
|
+
*/
|
|
992
|
+
export async function runWorkspaceScan(ctx, opts = {}) {
|
|
993
|
+
const role = 'workspace-scanner'; // prompt-role string (FALLBACK lookup only); MOCK_ROLE differs (C3)
|
|
994
|
+
const projects = Array.isArray(ctx.projects) ? ctx.projects : [];
|
|
995
|
+
const name = opts.name || ctx.workspaceName || 'Workspace';
|
|
996
|
+
const outPath = opts.outPath || joinPipeline(ctx.pipelineDir, 'workspace-description.md');
|
|
997
|
+
// The scanner IS the source of the workspace description, so it does NOT receive
|
|
998
|
+
// an injected workspace block (4th arg undefined). The body is the contract (C10).
|
|
999
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'workspaceScanner'), role, undefined);
|
|
1000
|
+
|
|
1001
|
+
const memberLines = projects.map((p) =>
|
|
1002
|
+
`- **${p.projectName || p.projectKey}** (\`${p.projectKey}\`): investigate \`${p.scanDir || p.projectDir}\`` +
|
|
1003
|
+
`${p.graphify ? ' (graphify-out/ available)' : ''}`,
|
|
1004
|
+
).join('\n');
|
|
1005
|
+
|
|
1006
|
+
const prompt =
|
|
1007
|
+
`# Task: Scan workspace interconnections — ${name}\n\n` +
|
|
1008
|
+
`Pipeline directory (shared artifacts): ${ctx.pipelineDir}\n\n` +
|
|
1009
|
+
`## Member projects to investigate\n\n${memberLines || '(no members)'}\n\n` +
|
|
1010
|
+
'## What to do\n\n' +
|
|
1011
|
+
'Discover how these projects interconnect (REST APIs, shared DB/migrations, build deps, ' +
|
|
1012
|
+
'message/queue, shared libs) and write ONE editable interconnection description.\n\n' +
|
|
1013
|
+
// scan-fanout: one read-only investigator per project (cap 8). NO omitProjectAgents:
|
|
1014
|
+
// the scanner is OFF-pipeline (it runs before any run root exists, with cwd inside a
|
|
1015
|
+
// member's real dir), so its project `.claude/agents` really are discoverable (§8.21
|
|
1016
|
+
// covers run-root cwds only).
|
|
1017
|
+
fanOutDirective(true) +
|
|
1018
|
+
'Dispatch ONE read-only investigator per member project (cap 8); merge their reports in sorted ' +
|
|
1019
|
+
'`projectKey` order and synthesize the single description yourself. Investigators MUST NOT ' +
|
|
1020
|
+
're-fan-out.\n\n' +
|
|
1021
|
+
'Announce each investigation with a line `INVESTIGATING <projectKey> relations to <otherKey>` ' +
|
|
1022
|
+
'and the merge with `SYNTHESIZING workspace description`.\n\n' +
|
|
1023
|
+
'## Description template (write EXACTLY these sections)\n\n' +
|
|
1024
|
+
'```\n' +
|
|
1025
|
+
`# Workspace: ${name}\n` +
|
|
1026
|
+
'## Overview\n<2-4 sentences: the project set + dominant integration theme>\n' +
|
|
1027
|
+
'## Projects\n- <projectName>: <one-line role>\n' +
|
|
1028
|
+
'## Interconnections\n- <A> -> <B>: <REST API | shared DB / migration | build dep | message/queue | shared lib>; <detail>\n' +
|
|
1029
|
+
'## Change-coordination notes\n- <coordination note>\n' +
|
|
1030
|
+
'## Suggested change order\n<topological hint, else "no strict ordering">\n' +
|
|
1031
|
+
'```\n\n' +
|
|
1032
|
+
`Write the interconnection description markdown to: ${outPath}\n\n` +
|
|
1033
|
+
mockMarkers({
|
|
1034
|
+
MOCK_ROLE: 'workspace-scan', // C3: scanner MOCK marker is workspace-scan (NOT the prompt-role)
|
|
1035
|
+
MOCK_OUT: outPath,
|
|
1036
|
+
MOCK_BASE: name,
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
const { text } = await runClaude(
|
|
1040
|
+
runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }),
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
// The written file is the authoritative description; read it back so callers
|
|
1044
|
+
// (the M5 scan engine) get the produced text. Dynamic import keeps the static
|
|
1045
|
+
// import surface focused (mirrors the orchestrator's dynamic protocol import).
|
|
1046
|
+
let description = '';
|
|
1047
|
+
try {
|
|
1048
|
+
const { readFile } = await import('node:fs/promises');
|
|
1049
|
+
description = await readFile(outPath, 'utf8');
|
|
1050
|
+
} catch {
|
|
1051
|
+
description = (text || '').trim();
|
|
1052
|
+
}
|
|
1053
|
+
return { description, outPath };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Manual Tests Checklist — producer. Reads the plan (and any implementation diff)
|
|
1058
|
+
* and writes a markdown checklist of manual test cases as a pipeline artifact.
|
|
1059
|
+
* Returns { checklistPath, summary }.
|
|
1060
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
1061
|
+
* @param {{ planPath: string, checklistPath: string }} opts
|
|
1062
|
+
*/
|
|
1063
|
+
export async function runManualTestsChecklist(ctx, opts) {
|
|
1064
|
+
const { planPath, checklistPath } = opts || {};
|
|
1065
|
+
const role = 'manual-tests-checklist';
|
|
1066
|
+
const systemPrompt = buildSystemPrompt(
|
|
1067
|
+
ctx.toolInstruction,
|
|
1068
|
+
resolveAgentBody(ctx, 'manualTestsChecklist'),
|
|
1069
|
+
role,
|
|
1070
|
+
ctx.workspace,
|
|
1071
|
+
);
|
|
1072
|
+
// §5.8: at a run-root cwd there is no working tree to `git diff`, so the changes
|
|
1073
|
+
// are named PER MEMBER. Single mode (both modes) and legacy workspace runs keep
|
|
1074
|
+
// today's byte-identical sentence.
|
|
1075
|
+
const perMemberDiff = workspaceDiffInstruction(ctx);
|
|
1076
|
+
const changesInstruction = perMemberDiff
|
|
1077
|
+
? 'Read the implementation plan and the implemented changes in EVERY member checkout — your ' +
|
|
1078
|
+
'cwd is the worca-cc run root, not a repository, so inspect each member on its own:\n\n' +
|
|
1079
|
+
perMemberDiff +
|
|
1080
|
+
'\n\nThen write a markdown checklist of concrete manual test cases a human can run against ' +
|
|
1081
|
+
'the app. Each case: a `- [ ]` line with steps and the expected result.\n\n'
|
|
1082
|
+
: 'Read the implementation plan and the implemented changes (via `git diff` in your cwd), ' +
|
|
1083
|
+
'then write a markdown checklist of concrete manual test cases a human can run against the ' +
|
|
1084
|
+
'app. Each case: a `- [ ]` line with steps and the expected result.\n\n';
|
|
1085
|
+
const prompt =
|
|
1086
|
+
taskHeader(ctx, 'Draft a manual test checklist') +
|
|
1087
|
+
'\n## What to do\n\n' +
|
|
1088
|
+
changesInstruction +
|
|
1089
|
+
`Plan: ${planPath}\n` +
|
|
1090
|
+
`Write the checklist markdown to: ${checklistPath}\n\n` +
|
|
1091
|
+
mockMarkers({ MOCK_ROLE: role, MOCK_OUT: checklistPath, MOCK_IN: planPath });
|
|
1092
|
+
|
|
1093
|
+
const { text } = await runClaude(
|
|
1094
|
+
runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }),
|
|
1095
|
+
);
|
|
1096
|
+
|
|
1097
|
+
const summary = (text || '').trim() || 'Manual test checklist written.';
|
|
1098
|
+
return { checklistPath, summary };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Manual web UI testing — verifier (loopSource). Drives the running web UI through
|
|
1103
|
+
* the manual checklist (Playwright MCP, declared in the agent frontmatter) and
|
|
1104
|
+
* emits the protocol review verdict JSON. Returns { review }.
|
|
1105
|
+
* @param {import('./phases.mjs').PhaseContext} ctx
|
|
1106
|
+
* @param {{ checklistPath: string, reviewMdPath: string, reviewJsonPath: string, cycle: number }} opts
|
|
1107
|
+
*/
|
|
1108
|
+
export async function runManualWebUiTesting(ctx, opts) {
|
|
1109
|
+
const { checklistPath, reviewMdPath, reviewJsonPath, cycle } = opts || {};
|
|
1110
|
+
const role = 'manual-web-ui-testing';
|
|
1111
|
+
const systemPrompt = buildSystemPrompt(
|
|
1112
|
+
ctx.toolInstruction,
|
|
1113
|
+
resolveAgentBody(ctx, 'manualWebUiTesting'),
|
|
1114
|
+
role,
|
|
1115
|
+
ctx.workspace,
|
|
1116
|
+
);
|
|
1117
|
+
const prompt =
|
|
1118
|
+
taskHeader(ctx, `Run the manual web UI tests (cycle ${cycle})`) +
|
|
1119
|
+
'\n## What to do\n\n' +
|
|
1120
|
+
'Execute the manual test checklist against the running web UI using the Playwright tools. ' +
|
|
1121
|
+
'Write a human-readable result markdown AND a machine-readable review JSON.\n\n' +
|
|
1122
|
+
`Checklist to run: ${checklistPath}\n` +
|
|
1123
|
+
`Write the result markdown to: ${reviewMdPath}\n` +
|
|
1124
|
+
`Write the review JSON to: ${reviewJsonPath}\n\n` +
|
|
1125
|
+
'The review JSON shape is { "issues": [ { "severity", "title", "detail", "location" } ], ' +
|
|
1126
|
+
'"summary" }. Use severities critical|major|minor|suggestion; only critical/major block the ' +
|
|
1127
|
+
'pipeline (a failing manual case is at least major).\n\n' +
|
|
1128
|
+
mockMarkers({
|
|
1129
|
+
MOCK_ROLE: role,
|
|
1130
|
+
MOCK_OUT: reviewMdPath,
|
|
1131
|
+
MOCK_JSON: reviewJsonPath,
|
|
1132
|
+
MOCK_CYCLE: cycle,
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
await runClaude(runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }));
|
|
1136
|
+
|
|
1137
|
+
const review = await readReview(reviewJsonPath);
|
|
1138
|
+
return { review };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// ── generic runners (metadata-declared agents, zero bespoke core code) ──────────
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Pure: render the generic `## Inputs` / `## Outputs` blocks from the node's
|
|
1145
|
+
* typed channel handles (allocate()/bindInputs() output). userPrompt is skipped
|
|
1146
|
+
* (the task header already carries the request); the worktree channel renders an
|
|
1147
|
+
* inspect hint instead of a path. Exported for testing.
|
|
1148
|
+
*/
|
|
1149
|
+
export function genericIoBlock(inputs = {}, outputs = {}) {
|
|
1150
|
+
const inLines = [];
|
|
1151
|
+
for (const [c, h] of Object.entries(inputs || {})) {
|
|
1152
|
+
if (!h || c === 'userPrompt') continue;
|
|
1153
|
+
const p = h.path || h.mdPath
|
|
1154
|
+
|| (h.kind === 'worktree' ? '(the working tree — inspect with `git diff` / `git status` in your cwd)' : null)
|
|
1155
|
+
// Detached workspace runs: cwd is the run root, so there is no single working
|
|
1156
|
+
// tree to inspect and the scalar checkpoint ref is meaningless — name every
|
|
1157
|
+
// member checkout WITH ITS OWN diff base (§5.8). The `runroot` kind is emitted
|
|
1158
|
+
// only when (detached && isWorkspace) (orchestrator._ioBus), so this branch is
|
|
1159
|
+
// mode-gated at the source.
|
|
1160
|
+
|| (h.kind === 'runroot'
|
|
1161
|
+
? `(the member checkouts under your cwd — inspect each with \`git -C repos/<key> diff <base>\`: ${
|
|
1162
|
+
(h.repos || [])
|
|
1163
|
+
.map((r) => `${r.relDir || `repos/${r.projectKey}`} (diff base ${r.checkpointRef || 'HEAD'})`)
|
|
1164
|
+
.join(', ') || 'no members'})`
|
|
1165
|
+
: null);
|
|
1166
|
+
if (p) inLines.push(`- ${c}: ${p}`);
|
|
1167
|
+
}
|
|
1168
|
+
const outLines = [];
|
|
1169
|
+
for (const [c, h] of Object.entries(outputs || {})) {
|
|
1170
|
+
if (!h) continue;
|
|
1171
|
+
if (h.kind === 'review') {
|
|
1172
|
+
if (h.mdPath) outLines.push(`- Write the ${c} markdown (human-readable review) to: ${h.mdPath}`);
|
|
1173
|
+
if (h.jsonPath) outLines.push(`- Write the ${c} JSON (machine-readable verdict) to: ${h.jsonPath}`);
|
|
1174
|
+
} else if (h.path) {
|
|
1175
|
+
outLines.push(`- Write ${c} to: ${h.path}`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return (
|
|
1179
|
+
'## Inputs\n\n' +
|
|
1180
|
+
(inLines.length ? inLines.join('\n') : '- (none — work from the request above)') +
|
|
1181
|
+
'\n\n## Outputs\n\n' +
|
|
1182
|
+
(outLines.length ? outLines.join('\n') : '- (none — report your findings as your final message)') +
|
|
1183
|
+
'\n\n'
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Generic producer — any metadata-declared producer with no bespoke branch.
|
|
1189
|
+
* Prompt = taskHeader + role hints + Inputs/Outputs channel->path lists; the
|
|
1190
|
+
* system prompt body is the agent's own .md (node.agentPrompt). Returns { summary }.
|
|
1191
|
+
*/
|
|
1192
|
+
export async function runGenericProducer(ctx) {
|
|
1193
|
+
const key = ctx.node?.key || 'agent';
|
|
1194
|
+
const role = `generic:${key}`; // no FALLBACK entry: the .md body is the contract
|
|
1195
|
+
const body = resolveAgentBody(ctx, key);
|
|
1196
|
+
if (!String(body || '').trim()) {
|
|
1197
|
+
console.warn(`[phases] generic producer "${key}": no agent .md body resolved — running with an empty system prompt`);
|
|
1198
|
+
}
|
|
1199
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, body, role, ctx.workspace);
|
|
1200
|
+
const outputs = ctx.outputs || {};
|
|
1201
|
+
const primary = Object.values(outputs).find((h) => h && h.path)?.path;
|
|
1202
|
+
const hints = (ctx.node?.promptHints || '').trim();
|
|
1203
|
+
const prompt =
|
|
1204
|
+
taskHeader(ctx, `Run agent "${key}"`) +
|
|
1205
|
+
'\n## What to do\n\n' +
|
|
1206
|
+
'You are a pipeline agent. Read every input below, do your job exactly as your role ' +
|
|
1207
|
+
'instructions describe, and write EVERY declared output to its exact path.\n\n' +
|
|
1208
|
+
(hints ? hints + '\n\n' : '') +
|
|
1209
|
+
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: isDetachedWorkspace(ctx) }) +
|
|
1210
|
+
genericIoBlock(ctx.inputs, outputs) +
|
|
1211
|
+
mockMarkers({ MOCK_ROLE: 'generic-producer', MOCK_OUT: primary, MOCK_CYCLE: ctx.cycle });
|
|
1212
|
+
|
|
1213
|
+
const { text } = await runClaude(
|
|
1214
|
+
runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }),
|
|
1215
|
+
);
|
|
1216
|
+
return { summary: (text || '').trim() || `Agent ${key} completed.` };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Generic verifier — any metadata-declared verifier with no bespoke branch. Emits
|
|
1221
|
+
* the standard protocol review (md + json); paths come from the allocated `review`
|
|
1222
|
+
* output (pipeline-local `<key>-review-cycleN.*` when the node mints no review).
|
|
1223
|
+
* Returns { review, reviewMdPath } for runners.verifier's verdict wrap.
|
|
1224
|
+
*/
|
|
1225
|
+
export async function runGenericVerifier(ctx) {
|
|
1226
|
+
const key = ctx.node?.key || 'agent';
|
|
1227
|
+
const role = `generic:${key}`;
|
|
1228
|
+
const body = resolveAgentBody(ctx, key);
|
|
1229
|
+
if (!String(body || '').trim()) {
|
|
1230
|
+
console.warn(`[phases] generic verifier "${key}": no agent .md body resolved — running with an empty system prompt`);
|
|
1231
|
+
}
|
|
1232
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, body, role, ctx.workspace);
|
|
1233
|
+
const cycle = Number(ctx.cycle) > 0 ? Number(ctx.cycle) : 1;
|
|
1234
|
+
const { review: reviewOut, ...otherOutputs } = ctx.outputs || {};
|
|
1235
|
+
const reviewMdPath = reviewOut?.mdPath ?? joinPipeline(ctx.pipelineDir, `${key}-review-cycle${cycle}.md`);
|
|
1236
|
+
const reviewJsonPath = reviewOut?.jsonPath ?? joinPipeline(ctx.pipelineDir, `${key}-review-cycle${cycle}.json`);
|
|
1237
|
+
const hints = (ctx.node?.promptHints || '').trim();
|
|
1238
|
+
// Route the (possibly fallback-pathed) review handle through the IO block so the
|
|
1239
|
+
// Outputs section never renders the "(none — report as final message)" placeholder
|
|
1240
|
+
// in contradiction with the review-write instructions that follow.
|
|
1241
|
+
const ioOutputs = { ...otherOutputs, review: { kind: 'review', mdPath: reviewMdPath, jsonPath: reviewJsonPath } };
|
|
1242
|
+
const prompt =
|
|
1243
|
+
taskHeader(ctx, `Verify: ${key} (cycle ${cycle})`) +
|
|
1244
|
+
'\n## What to do\n\n' +
|
|
1245
|
+
'You are a verifier. Inspect the inputs below exactly as your role instructions describe, ' +
|
|
1246
|
+
'then write a human-readable review markdown AND a machine-readable review JSON.\n\n' +
|
|
1247
|
+
(hints ? hints + '\n\n' : '') +
|
|
1248
|
+
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: isDetachedWorkspace(ctx) }) +
|
|
1249
|
+
genericIoBlock(ctx.inputs, ioOutputs) +
|
|
1250
|
+
'The review JSON shape is { "issues": [ { "severity", "title", "detail", "location" } ], ' +
|
|
1251
|
+
'"summary" }. Use severities critical|major|minor|suggestion; only critical/major block the ' +
|
|
1252
|
+
'pipeline.\n\n' +
|
|
1253
|
+
mockMarkers({ MOCK_ROLE: 'generic-verifier', MOCK_OUT: reviewMdPath, MOCK_JSON: reviewJsonPath, MOCK_CYCLE: cycle });
|
|
1254
|
+
|
|
1255
|
+
await runClaude(runOpts(ctx, { role, prompt, systemPrompt, allowedTools: READ_WRITE_TOOLS }));
|
|
1256
|
+
|
|
1257
|
+
const review = await readReview(reviewJsonPath);
|
|
1258
|
+
return { review, reviewMdPath };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// ── small local helpers ────────────────────────────────────────────────────────
|
|
1262
|
+
|
|
1263
|
+
/** Join a file name onto the pipeline dir without importing node:path's full surface. */
|
|
1264
|
+
function joinPipeline(pipelineDir, name) {
|
|
1265
|
+
const base = String(pipelineDir || '').replace(/\/+$/, '');
|
|
1266
|
+
return `${base}/${name}`;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** Render the answered clarifications as a markdown Q&A list for the plan prompt. */
|
|
1270
|
+
export function renderAnswers(answers) {
|
|
1271
|
+
if (!Array.isArray(answers) || answers.length === 0) {
|
|
1272
|
+
return '_No clarifying questions were asked._\n';
|
|
1273
|
+
}
|
|
1274
|
+
return (
|
|
1275
|
+
answers
|
|
1276
|
+
.map((a) => `- **Q:** ${String(a.question || '').trim()} — **A:** ${String(a.choice || '').trim()}`)
|
|
1277
|
+
.join('\n') + '\n'
|
|
1278
|
+
);
|
|
1279
|
+
}
|