@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,1356 @@
|
|
|
1
|
+
// src/core/claude-runner.mjs
|
|
2
|
+
// Spawn Claude Code headless and stream its events, with a fully offline MOCK
|
|
3
|
+
// mode that performs the same role-appropriate side effects so the whole
|
|
4
|
+
// pipeline can run end-to-end without spawning claude or spending tokens.
|
|
5
|
+
//
|
|
6
|
+
// ── MOCK MARKER PROTOCOL (shared with phases.mjs) ────────────────────────────
|
|
7
|
+
// In mock mode the runner does not call any model. Instead it reads simple
|
|
8
|
+
// markers embedded (one per line) in the `prompt` (and, as a fallback, the
|
|
9
|
+
// `systemPrompt`). The phases layer is responsible for emitting these markers.
|
|
10
|
+
//
|
|
11
|
+
// MOCK_ROLE: <role> one of:
|
|
12
|
+
// clarify | planner-plan |
|
|
13
|
+
// refiner | implementer | reviewer
|
|
14
|
+
// MOCK_OUT: <path> primary output artifact path (absolute)
|
|
15
|
+
// - clarify : clarify.json path
|
|
16
|
+
// - planner-plan : plan .md path
|
|
17
|
+
// - refiner : output -vN plan .md path
|
|
18
|
+
// - reviewer : review .md path
|
|
19
|
+
// MOCK_JSON: <path> review json path (refiner + reviewer)
|
|
20
|
+
// MOCK_CYCLE: <n> loop cycle number (refiner + reviewer)
|
|
21
|
+
// MOCK_IN: <path> input plan path (refiner; optional, used to seed -vN)
|
|
22
|
+
// MOCK_BASE: <name> base slug (optional, used for nicer mock content)
|
|
23
|
+
// MOCK_ASK: <path> ask-then-resume questions file (per-step user
|
|
24
|
+
// questions). When present the mock writes ONE canned
|
|
25
|
+
// question there and STOPS (no role side effects); the
|
|
26
|
+
// resumed prompt carries no MOCK_ASK, so the role arm
|
|
27
|
+
// runs then.
|
|
28
|
+
//
|
|
29
|
+
// Markers are matched leniently: "KEY: value" anywhere at the start of a line,
|
|
30
|
+
// case-sensitive keys, value trimmed. Missing markers degrade gracefully.
|
|
31
|
+
// The mock is deterministic: blocking-issue counts decrease with cycle so the
|
|
32
|
+
// orchestrator's refine/review loops always terminate.
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
import { spawn } from 'node:child_process';
|
|
36
|
+
import { createInterface } from 'node:readline';
|
|
37
|
+
import { prepareModelEnv } from './model-env.mjs';
|
|
38
|
+
import { classifyError, strongestClass } from './recoverable-error.mjs';
|
|
39
|
+
import { writeFile, mkdir, appendFile, readFile, access } from 'node:fs/promises';
|
|
40
|
+
import { constants as FS } from 'node:fs';
|
|
41
|
+
import { dirname, join } from 'node:path';
|
|
42
|
+
|
|
43
|
+
const DEFAULT_BIN = process.env.WORCA_CLAUDE_BIN || process.env.ORCH_CLAUDE_BIN || 'claude';
|
|
44
|
+
|
|
45
|
+
// Cap for the stderr detail embedded in a non-zero-exit Error message. The
|
|
46
|
+
// audit trail and the UI error banner consume that message; an uncapped
|
|
47
|
+
// stderrBuf (hundreds of KB of MCP/retry chatter) must not ride into them when
|
|
48
|
+
// every stderr line was already streamed as its own warn event. Classification
|
|
49
|
+
// does NOT ride on the capped message: recovery markers are classified line-by-
|
|
50
|
+
// line as stderr streams (see rlErr) and stamped on the error as `errorClass`.
|
|
51
|
+
const STDERR_DETAIL_MAX = 2000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Translate a pipeline "effort" level into claude CLI argv additions. This is
|
|
55
|
+
* the ONE place that knows the CLI surface for effort.
|
|
56
|
+
*
|
|
57
|
+
* The flag NAME is read from WORCA_EFFORT_FLAG (default "--effort") so it can
|
|
58
|
+
* be retargeted to whatever the installed `claude` actually names it WITHOUT a
|
|
59
|
+
* code change. Empty effort adds nothing (the model's own default is used), so
|
|
60
|
+
* the default run path is never affected by the flag name.
|
|
61
|
+
*
|
|
62
|
+
* NOTE: "--effort" is an ASSUMED default, NOT a verified CLI contract. Confirm
|
|
63
|
+
* it against your installed CLI before relying on per-step effort (see the plan's
|
|
64
|
+
* verification section). If your CLI rejects an unknown flag, a run that sets an
|
|
65
|
+
* effort would fail fast with a non-zero exit; set WORCA_EFFORT_FLAG to fix it.
|
|
66
|
+
*
|
|
67
|
+
* @param {string|undefined} effort one of EFFORTS (medium|high|xhigh|max)
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
70
|
+
export function buildEffortArgs(effort) {
|
|
71
|
+
if (!effort) return [];
|
|
72
|
+
const flag = (process.env.WORCA_EFFORT_FLAG || '--effort').trim() || '--effort';
|
|
73
|
+
return [flag, String(effort)];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether per-sub-agent telemetry via Claude's hook-events is enabled. Feature-
|
|
78
|
+
* detected and DEFAULT OFF: only `WORCA_SUBAGENT_HOOKS` set to a truthy value
|
|
79
|
+
* (anything but "", "0", "false") turns it on. OFF ⇒ runReal adds NO extra flags
|
|
80
|
+
* and the baseline sub-agent lifecycle (tool_use/tool_result) is unaffected.
|
|
81
|
+
*/
|
|
82
|
+
export function subagentHooksEnabled() {
|
|
83
|
+
const v = process.env.WORCA_SUBAGENT_HOOKS;
|
|
84
|
+
return !!v && v !== '0' && v.toLowerCase() !== 'false';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Sub-agent telemetry + the --settings seam ────────────────────────────────
|
|
88
|
+
// Telemetry is GATED (subagentHooksEnabled) and OFF by default. When on it adds
|
|
89
|
+
// `--include-hook-events` (surfaces hook lifecycle on the SAME stdout stream)
|
|
90
|
+
// and registers a no-op `true` PostToolUse hook matched to `Agent` — just enough
|
|
91
|
+
// to make `claude` run+emit the PostToolUse event whose `tool_response` carries
|
|
92
|
+
// totalDurationMs/totalTokens/usage. We read telemetry off the surfaced
|
|
93
|
+
// stream-json event, NOT the hook command's stdout. `--bare`-proof (inline
|
|
94
|
+
// settings need no settings file). The argv contract is on buildSettingsArgs.
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** The telemetry hook-settings OBJECT (see subagentHooksEnabled). null when off. */
|
|
98
|
+
export function buildHookSettings() {
|
|
99
|
+
if (!subagentHooksEnabled()) return null;
|
|
100
|
+
return {
|
|
101
|
+
hooks: { PostToolUse: [{ matcher: 'Agent', hooks: [{ type: 'command', command: 'true', async: true }] }] },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The ONE --settings seam. Telemetry hook settings (gated, default off) and the
|
|
107
|
+
* guardrails `permissions` rules merge into a SINGLE inline JSON — two --settings
|
|
108
|
+
* flags would be last-wins at the CLI, silently dropping one payload.
|
|
109
|
+
* [] when there is nothing to say, so the baseline argv is byte-identical.
|
|
110
|
+
* @param {{deny?:string[],allow?:string[],ask?:string[]}|null|undefined} permissionRules
|
|
111
|
+
* @returns {string[]}
|
|
112
|
+
*/
|
|
113
|
+
export function buildSettingsArgs(permissionRules) {
|
|
114
|
+
const hook = buildHookSettings();
|
|
115
|
+
const hasRules = !!permissionRules && Object.values(permissionRules).some((a) => Array.isArray(a) && a.length);
|
|
116
|
+
// Present-but-malformed rules (e.g. `{deny: 'Bash(curl:*)'}`) make the object
|
|
117
|
+
// truthy while hasRules stays false, so the whole policy would drop out of
|
|
118
|
+
// argv silently. Say it once, then take the same no-rules path (fail-open,
|
|
119
|
+
// matching the guardrail-set read path) — the empty/absent cases ({}, {deny: []}, null)
|
|
120
|
+
// are normal and stay quiet.
|
|
121
|
+
if (!hasRules && permissionRules && typeof permissionRules === 'object'
|
|
122
|
+
&& Object.values(permissionRules).some((a) => a != null && !Array.isArray(a))) {
|
|
123
|
+
console.warn('[worca] guardrails: permissionRules is malformed (deny/allow/ask must be arrays of strings) — ignoring it; this spawn carries NO permission rules');
|
|
124
|
+
}
|
|
125
|
+
if (!hook && !hasRules) return [];
|
|
126
|
+
const settings = {};
|
|
127
|
+
if (hook) settings.hooks = hook.hooks;
|
|
128
|
+
if (hasRules) settings.permissions = permissionRules;
|
|
129
|
+
const args = [];
|
|
130
|
+
if (hook) args.push('--include-hook-events');
|
|
131
|
+
args.push('--settings', JSON.stringify(settings));
|
|
132
|
+
return args;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Back-compat alias for the pre-guardrails name (telemetry-only payload). */
|
|
136
|
+
export function buildHookArgs() {
|
|
137
|
+
return buildSettingsArgs(null);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Base env a headless claude needs to function at all; everything else is
|
|
141
|
+
// withheld under scrub. ANTHROPIC_*/CLAUDE_* prefixes carry the CLI's own auth
|
|
142
|
+
// and configuration and MUST survive, or every scrubbed run would fail auth.
|
|
143
|
+
// The proxy / CA vars are CONNECTIVITY config, not secrets: without them a
|
|
144
|
+
// scrubbed run behind a TLS-intercepting corporate proxy fails TLS on every
|
|
145
|
+
// spawn (the 2.1.220 binary reads all of them). Cloud-provider creds
|
|
146
|
+
// (AWS_*/GOOGLE_APPLICATION_CREDENTIALS/AZURE_*) are intentionally NOT here —
|
|
147
|
+
// a Bedrock/Vertex/Foundry deployment allowlists them per-project (documented).
|
|
148
|
+
const SPAWN_ENV_BASE = [
|
|
149
|
+
'PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL', 'SHELL', 'USER', 'LOGNAME', 'TERM',
|
|
150
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
151
|
+
'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR',
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The spawn env under guardrails. undefined when scrub is off — spawn() then
|
|
156
|
+
* inherits process.env exactly as today. When on: base vars + every `ANTHROPIC_`-
|
|
157
|
+
* and `CLAUDE_`-prefixed var + the per-project allowlist. (Do not rewrite those
|
|
158
|
+
* prefixes with a `*` glob here — the resulting `*` + `/` would end this comment.)
|
|
159
|
+
*
|
|
160
|
+
* We deliberately do NOT set CLAUDE_CODE_SUBPROCESS_ENV_SCRUB. On CLI 2.1.220
|
|
161
|
+
* (live-verified 2026-08-01) a truthy value forces the permission mode to
|
|
162
|
+
* "default", overriding our `--permission-mode acceptEdits` (and, per static
|
|
163
|
+
* analysis, forces a strict sandbox) — which would break scrubbed pipeline runs.
|
|
164
|
+
* Do not reinstate it without re-verifying against the installed CLI.
|
|
165
|
+
*
|
|
166
|
+
* @param {boolean|undefined} envScrub
|
|
167
|
+
* @param {string[]|undefined} envAllowlist
|
|
168
|
+
* @returns {Record<string,string>|undefined}
|
|
169
|
+
*/
|
|
170
|
+
export function buildSpawnEnv(envScrub, envAllowlist) {
|
|
171
|
+
if (!envScrub) return undefined;
|
|
172
|
+
const allow = new Set(Array.isArray(envAllowlist) ? envAllowlist : []);
|
|
173
|
+
const env = {};
|
|
174
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
175
|
+
if (v === undefined) continue;
|
|
176
|
+
if (SPAWN_ENV_BASE.includes(k) || k.startsWith('ANTHROPIC_') || k.startsWith('CLAUDE_') || allow.has(k)) {
|
|
177
|
+
env[k] = v;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return env;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether mock mode is active. Driven by WORCA_MOCK or an explicit opts.mock
|
|
185
|
+
* passed through by the orchestrator (handled by caller mapping mock->env or
|
|
186
|
+
* by passing systemPrompt/prompt markers; we also honor a `mock` field).
|
|
187
|
+
*/
|
|
188
|
+
function mockEnabled(opts) {
|
|
189
|
+
if (opts && opts.mock) return true;
|
|
190
|
+
const v = process.env.WORCA_MOCK ?? process.env.ORCH_MOCK;
|
|
191
|
+
return !!v && v !== '0' && v.toLowerCase() !== 'false';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run Claude headless (or the mock). Streams events via onEvent and resolves
|
|
196
|
+
* with the accumulated assistant/result text and the process exit code.
|
|
197
|
+
*
|
|
198
|
+
* @param {object} o
|
|
199
|
+
* @param {string} o.cwd working directory for claude
|
|
200
|
+
* @param {string} [o.systemPrompt] appended system prompt
|
|
201
|
+
* @param {string} o.prompt the user prompt (-p)
|
|
202
|
+
* @param {string[]} [o.allowedTools] e.g. ["Read","Write","Edit","Bash"]
|
|
203
|
+
* @param {string} [o.permissionMode] e.g. "acceptEdits"
|
|
204
|
+
* @param {string} [o.model] optional model id
|
|
205
|
+
* @param {string} [o.effort] optional reasoning effort
|
|
206
|
+
* @param {(e:{type:string, raw?:any, text?:string})=>void} [o.onEvent]
|
|
207
|
+
* @param {AbortSignal} [o.signal]
|
|
208
|
+
* @param {string} [o.resumeSessionId] resume a previous claude session (--resume)
|
|
209
|
+
* @param {string} [o.bin] claude binary (default "claude")
|
|
210
|
+
* @param {boolean} [o.mock] force mock mode
|
|
211
|
+
* @param {string} [o.mcpConfigPath] §5.5 generated <runRoot>/mcp.json (--mcp-config)
|
|
212
|
+
* @param {string[]} [o.mcpServerGrants] §5.3 `mcp__<server>` grants unioned into --allowedTools
|
|
213
|
+
* @param {{deny?:string[],allow?:string[],ask?:string[]}} [o.permissionRules] guardrail permission
|
|
214
|
+
* rules merged into the single `--settings` payload (absent => argv unchanged)
|
|
215
|
+
* @param {boolean} [o.envScrub] guardrail: spawn with a minimal env instead of
|
|
216
|
+
* inheriting process.env (absent/false => spawn inherits, today's behavior)
|
|
217
|
+
* @param {string[]} [o.envAllowlist] guardrail: extra env var names to keep under scrub
|
|
218
|
+
* @param {Record<string,string>} [o.modelEnv] per-model routing env (design §4.4), merged
|
|
219
|
+
* LAST over the spawn env (it survives scrub and wins collisions — explicit operator
|
|
220
|
+
* config outranks ambient-env hygiene); reserved keys are re-dropped here defensively
|
|
221
|
+
* @param {string[]} [o.workspaceWriteTargets] §8.10 MOCK-ONLY member checkouts the mock
|
|
222
|
+
* implementer writes into instead of `cwd` (empty/absent => today's cwd behavior).
|
|
223
|
+
* Never reaches argv: `runReal` ignores it by construction.
|
|
224
|
+
* @returns {Promise<{text:string, exitCode:number}>}
|
|
225
|
+
*/
|
|
226
|
+
export async function runClaude(o = {}) {
|
|
227
|
+
// NOTE: this destructure + the runReal call below are the GATE, not a
|
|
228
|
+
// pass-through. Every field must be named in BOTH places or it is silently
|
|
229
|
+
// dropped before runReal sees it — a field added only to buildClaudeArgs would
|
|
230
|
+
// never reach argv while a builder-only test still passed
|
|
231
|
+
// (test/spawn-args.test.mjs asserts the forwarding end to end).
|
|
232
|
+
const {
|
|
233
|
+
cwd = process.cwd(),
|
|
234
|
+
systemPrompt = '',
|
|
235
|
+
prompt = '',
|
|
236
|
+
allowedTools,
|
|
237
|
+
permissionMode = 'acceptEdits',
|
|
238
|
+
model,
|
|
239
|
+
effort,
|
|
240
|
+
onEvent = () => {},
|
|
241
|
+
signal,
|
|
242
|
+
mcpConfigPath,
|
|
243
|
+
mcpServerGrants,
|
|
244
|
+
permissionRules,
|
|
245
|
+
envScrub,
|
|
246
|
+
envAllowlist,
|
|
247
|
+
modelEnv,
|
|
248
|
+
workspaceWriteTargets,
|
|
249
|
+
resumeSessionId,
|
|
250
|
+
bin = DEFAULT_BIN,
|
|
251
|
+
} = o;
|
|
252
|
+
|
|
253
|
+
if (signal?.aborted) {
|
|
254
|
+
const err = new Error('aborted');
|
|
255
|
+
err.name = 'AbortError';
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (mockEnabled(o)) {
|
|
260
|
+
// runMock spawns nothing, so the MCP fields are meaningless to it —
|
|
261
|
+
// workspaceWriteTargets is the one option that is mock-ONLY (§8.10) and it must be
|
|
262
|
+
// named HERE too, or the mock implementer never sees it (this call is a gate, not
|
|
263
|
+
// a pass-through; test/spawn-args.test.mjs asserts the forwarding end to end).
|
|
264
|
+
return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return runReal({
|
|
268
|
+
cwd,
|
|
269
|
+
systemPrompt,
|
|
270
|
+
prompt,
|
|
271
|
+
allowedTools,
|
|
272
|
+
permissionMode,
|
|
273
|
+
model,
|
|
274
|
+
effort,
|
|
275
|
+
onEvent,
|
|
276
|
+
signal,
|
|
277
|
+
bin,
|
|
278
|
+
resumeSessionId,
|
|
279
|
+
mcpConfigPath,
|
|
280
|
+
mcpServerGrants,
|
|
281
|
+
permissionRules,
|
|
282
|
+
envScrub,
|
|
283
|
+
envAllowlist,
|
|
284
|
+
modelEnv,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Real execution ───────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/** Pure argv builder for the headless claude spawn (exported for tests).
|
|
291
|
+
* resumeSessionId re-attaches a previous session: `--resume <sid>` makes -p send
|
|
292
|
+
* the prompt as the next user message of THAT session instead of a fresh one.
|
|
293
|
+
*
|
|
294
|
+
* Two §5.3 additions for detached runs, both no-ops when absent (so every legacy
|
|
295
|
+
* argv stays byte-identical):
|
|
296
|
+
* - mcpConfigPath -> `--mcp-config <file>` (E5: config servers connect and their
|
|
297
|
+
* tools are callable in headless -p; it MERGES with `.mcp.json`,
|
|
298
|
+
* user scope, and plugin servers, so `--strict-mcp-config` is
|
|
299
|
+
* deliberately never passed — E11).
|
|
300
|
+
* - mcpServerGrants -> unioned into `--allowedTools`. The server-WILDCARD shape
|
|
301
|
+
* (`mcp__<server>`) is what Phase-0 gate V1 verified as
|
|
302
|
+
* callable under `--permission-mode acceptEdits`
|
|
303
|
+
* (docs/run-root-verification.md, branch (a); argv-attested
|
|
304
|
+
* transcript phase0/out/v1a-rerun.jsonl, with a no-grant
|
|
305
|
+
* negative control proving the grant is load-bearing).
|
|
306
|
+
* `--add-dir` is deliberately absent: it needs an env override to carry memory at
|
|
307
|
+
* all (E2) and no shipped feature uses it (§5.3 / §8.18). */
|
|
308
|
+
export function buildClaudeArgs({
|
|
309
|
+
prompt, systemPrompt, permissionMode, model, effort, allowedTools, resumeSessionId,
|
|
310
|
+
mcpConfigPath, mcpServerGrants, permissionRules,
|
|
311
|
+
}) {
|
|
312
|
+
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--permission-mode', permissionMode];
|
|
313
|
+
if (resumeSessionId) args.push('--resume', resumeSessionId);
|
|
314
|
+
if (systemPrompt) {
|
|
315
|
+
args.push('--append-system-prompt', systemPrompt);
|
|
316
|
+
}
|
|
317
|
+
if (model) {
|
|
318
|
+
args.push('--model', model);
|
|
319
|
+
}
|
|
320
|
+
for (const a of buildEffortArgs(effort)) args.push(a);
|
|
321
|
+
// The ONE --settings seam: gated, default-off per-sub-agent telemetry
|
|
322
|
+
// (WORCA_SUBAGENT_HOOKS) and the guardrails `permissions` rules merge into a
|
|
323
|
+
// SINGLE inline JSON (two --settings flags would be last-wins at the CLI). [] when
|
|
324
|
+
// there is neither, so the baseline argv is unchanged; a CLI that rejects these
|
|
325
|
+
// flags would only ever fail when the operator opted in.
|
|
326
|
+
for (const a of buildSettingsArgs(permissionRules)) args.push(a);
|
|
327
|
+
if (mcpConfigPath) args.push('--mcp-config', mcpConfigPath);
|
|
328
|
+
const tools = Array.isArray(allowedTools) ? allowedTools.slice() : [];
|
|
329
|
+
for (const s of (Array.isArray(mcpServerGrants) ? mcpServerGrants : [])) {
|
|
330
|
+
if (s && !tools.includes(s)) tools.push(s); // union, never a duplicate
|
|
331
|
+
}
|
|
332
|
+
if (tools.length) {
|
|
333
|
+
args.push('--allowedTools', tools.join(','));
|
|
334
|
+
}
|
|
335
|
+
return args;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, model, effort, onEvent, signal, bin, resumeSessionId, mcpConfigPath, mcpServerGrants, permissionRules, envScrub, envAllowlist, modelEnv }) {
|
|
339
|
+
return new Promise((resolveP, rejectP) => {
|
|
340
|
+
const args = buildClaudeArgs({
|
|
341
|
+
prompt, systemPrompt, permissionMode, model, effort, allowedTools, resumeSessionId,
|
|
342
|
+
mcpConfigPath, mcpServerGrants, permissionRules,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// undefined when the guardrail is off, and the spread then adds NO `env` key —
|
|
346
|
+
// spawn inherits process.env exactly as it did before guardrails existed.
|
|
347
|
+
const guardrailEnv = buildSpawnEnv(envScrub, envAllowlist);
|
|
348
|
+
|
|
349
|
+
// Per-model routing env (design §4.4) merges LAST: it survives scrub and
|
|
350
|
+
// wins collisions (explicit operator config outranks ambient-env hygiene),
|
|
351
|
+
// except reserved keys, which are re-dropped here defensively — the write
|
|
352
|
+
// path already rejects them, so a drop means a hand-edited settings file.
|
|
353
|
+
// With no modelEnv (or nothing surviving the filter) the spawn env is
|
|
354
|
+
// byte-identical to the pre-feature behavior, including the undefined
|
|
355
|
+
// -> inherit-process.env case.
|
|
356
|
+
let spawnEnv = guardrailEnv;
|
|
357
|
+
if (modelEnv && Object.keys(modelEnv).length) {
|
|
358
|
+
const { env: safe, dropped } = prepareModelEnv(modelEnv);
|
|
359
|
+
for (const k of dropped) {
|
|
360
|
+
console.warn(`[worca] modelEnv: dropping reserved/invalid key ${JSON.stringify(k)}`);
|
|
361
|
+
}
|
|
362
|
+
if (Object.keys(safe).length) spawnEnv = { ...(guardrailEnv ?? process.env), ...safe };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let child;
|
|
366
|
+
try {
|
|
367
|
+
child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}) });
|
|
368
|
+
} catch (err) {
|
|
369
|
+
rejectP(new Error(`Failed to spawn ${bin}: ${err.message}`));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let resultText = '';
|
|
374
|
+
let assistantText = '';
|
|
375
|
+
let stderrBuf = '';
|
|
376
|
+
// Strongest recovery class seen across ALL stderr lines — classified at
|
|
377
|
+
// receive time, so it survives both the rolling trim and the tail cap.
|
|
378
|
+
let stderrClass = null;
|
|
379
|
+
// In stream-json mode claude reports failures (auth, unknown/unavailable
|
|
380
|
+
// model, API errors) as a terminal `result` event with is_error:true on
|
|
381
|
+
// STDOUT and exits non-zero with EMPTY stderr. Capture that text so a
|
|
382
|
+
// non-zero exit surfaces the real cause instead of an opaque "no stderr".
|
|
383
|
+
let errorDetail = '';
|
|
384
|
+
let settled = false;
|
|
385
|
+
|
|
386
|
+
const onAbort = () => {
|
|
387
|
+
try {
|
|
388
|
+
child.kill('SIGTERM');
|
|
389
|
+
} catch {
|
|
390
|
+
/* ignore */
|
|
391
|
+
}
|
|
392
|
+
// Escalate if it ignores SIGTERM.
|
|
393
|
+
setTimeout(() => {
|
|
394
|
+
try {
|
|
395
|
+
child.kill('SIGKILL');
|
|
396
|
+
} catch {
|
|
397
|
+
/* ignore */
|
|
398
|
+
}
|
|
399
|
+
}, 1500).unref?.();
|
|
400
|
+
};
|
|
401
|
+
if (signal) {
|
|
402
|
+
if (signal.aborted) onAbort();
|
|
403
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const finish = (fn, arg) => {
|
|
407
|
+
if (settled) return;
|
|
408
|
+
settled = true;
|
|
409
|
+
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
410
|
+
fn(arg);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const rl = createInterface({ input: child.stdout });
|
|
414
|
+
rl.on('line', (line) => {
|
|
415
|
+
const trimmed = line.trim();
|
|
416
|
+
if (!trimmed) return;
|
|
417
|
+
let evt;
|
|
418
|
+
try {
|
|
419
|
+
evt = JSON.parse(trimmed);
|
|
420
|
+
} catch {
|
|
421
|
+
// Non-JSON line (rare). Surface as a raw log.
|
|
422
|
+
safeEmit(onEvent, { type: 'log', text: trimmed, raw: trimmed });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const text = extractText(evt);
|
|
426
|
+
// Pause/Resume: surface the session id from the init event so the
|
|
427
|
+
// orchestrator can persist it per step (claude --resume needs it).
|
|
428
|
+
if (evt?.type === 'system' && evt?.subtype === 'init' && typeof evt.session_id === 'string') {
|
|
429
|
+
safeEmit(onEvent, { type: 'session', sessionId: evt.session_id });
|
|
430
|
+
}
|
|
431
|
+
if (evt?.type === 'assistant' && text) assistantText += text;
|
|
432
|
+
if (evt?.type === 'result' && typeof evt.result === 'string') resultText += evt.result;
|
|
433
|
+
// Remember the most specific error text we see, for the non-zero-exit path.
|
|
434
|
+
if (evt?.type === 'result' && evt.is_error) {
|
|
435
|
+
errorDetail =
|
|
436
|
+
(typeof evt.result === 'string' && evt.result.trim()) ||
|
|
437
|
+
(typeof evt.error === 'string' && evt.error.trim()) ||
|
|
438
|
+
errorDetail;
|
|
439
|
+
} else if (!errorDetail && typeof evt?.error === 'string' && evt.error.trim()) {
|
|
440
|
+
errorDetail = evt.error.trim();
|
|
441
|
+
}
|
|
442
|
+
// Surface Claude's hook-event lines (only present under --include-hook-events)
|
|
443
|
+
// as a stable type:'hook-event' the orchestrator reads for sub-agent telemetry.
|
|
444
|
+
// The exact envelope key varies by CLI build; match the documented shapes.
|
|
445
|
+
const isHook = evt?.type === 'hook-event' || evt?.type === 'hook_event' ||
|
|
446
|
+
(typeof evt?.hook_event_name === 'string');
|
|
447
|
+
if (isHook) {
|
|
448
|
+
safeEmit(onEvent, { type: 'hook-event', raw: evt });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const cost = extractResultCost(evt);
|
|
452
|
+
safeEmit(onEvent, {
|
|
453
|
+
type: evt?.type || 'event',
|
|
454
|
+
raw: evt,
|
|
455
|
+
text: text || undefined,
|
|
456
|
+
...(cost != null ? { costUsd: cost } : {}),
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// stderr is a FIRST-CLASS log stream, not just failure evidence. The CLI puts
|
|
461
|
+
// retry/throttle notices (429/529), MCP server chatter, and runtime warnings
|
|
462
|
+
// here on runs that go on to succeed — all of it was previously discarded,
|
|
463
|
+
// since stderrBuf is only read on the non-zero-exit path below.
|
|
464
|
+
//
|
|
465
|
+
// Framed with the SAME readline as stdout: readline decodes through an
|
|
466
|
+
// internal StringDecoder (a multi-byte character split across pipe chunks
|
|
467
|
+
// survives) and treats a lone \r as a line break, so CR-rewriting progress
|
|
468
|
+
// output surfaces live instead of accumulating until exit. Each line is
|
|
469
|
+
// emitted at receive time — the closest available proxy for event time.
|
|
470
|
+
// `stream:'err'` tags the origin channel; the orchestrator decides the level.
|
|
471
|
+
const rlErr = createInterface({ input: child.stderr });
|
|
472
|
+
rlErr.on('line', (line) => {
|
|
473
|
+
// Classify BEFORE buffering: the class must see every line ever printed —
|
|
474
|
+
// an early 401 or session-limit notice followed by hundreds of KB of MCP
|
|
475
|
+
// chatter would otherwise scroll past both the trim and the tail cap.
|
|
476
|
+
stderrClass = strongestClass(stderrClass, classifyError(line));
|
|
477
|
+
stderrBuf += line + '\n'; // still the source of the exit-code detail
|
|
478
|
+
// Rolling tail: bound memory against chatty MCP servers. Trim at 4x the
|
|
479
|
+
// cap down to 2x — amortized, and the kept tail always exceeds
|
|
480
|
+
// STDERR_DETAIL_MAX so the close handler's `… ` marker still fires.
|
|
481
|
+
if (stderrBuf.length > STDERR_DETAIL_MAX * 4) stderrBuf = stderrBuf.slice(-STDERR_DETAIL_MAX * 2);
|
|
482
|
+
const text = line.trim();
|
|
483
|
+
// A pause/stop SIGTERMs the child: whatever it writes while dying (and the
|
|
484
|
+
// torn fragment readline flushes at stream end) is not run output.
|
|
485
|
+
if (text && !signal?.aborted) safeEmit(onEvent, { type: 'stderr', stream: 'err', text });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
child.on('error', (err) => {
|
|
489
|
+
finish(rejectP, new Error(`${bin} error: ${err.message}`));
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
child.on('close', (code) => {
|
|
493
|
+
rl.close();
|
|
494
|
+
rlErr.close(); // readline already flushed its final unterminated line when the stream ended
|
|
495
|
+
if (signal?.aborted) {
|
|
496
|
+
const err = new Error('aborted');
|
|
497
|
+
err.name = 'AbortError';
|
|
498
|
+
finish(rejectP, err);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (code !== 0) {
|
|
502
|
+
const fromStderr = stderrBuf.trim();
|
|
503
|
+
const raw = fromStderr || errorDetail || 'no stderr';
|
|
504
|
+
// Tail, not head: the terminal cause sits at the END of a long stderr.
|
|
505
|
+
const detail = raw.length > STDERR_DETAIL_MAX ? `… ${raw.slice(-STDERR_DETAIL_MAX)}` : raw;
|
|
506
|
+
const err = new Error(`${bin} exited with code ${code}: ${detail}`);
|
|
507
|
+
// The recovery class, judged on the FULL evidence: the per-line stream
|
|
508
|
+
// class when stderr fed the detail, else the (already fully in-memory)
|
|
509
|
+
// stdout errorDetail. classifyError() returns this stamp verbatim, so
|
|
510
|
+
// the tail cap above can never starve recovery — or flip an early auth
|
|
511
|
+
// failure into 'network' because connection chatter filled the tail.
|
|
512
|
+
err.errorClass = fromStderr ? stderrClass : classifyError(raw);
|
|
513
|
+
// Mark the origin channel so the orchestrator can tag its `error` log
|
|
514
|
+
// line with stream:'err' without sniffing the message. Absent when the
|
|
515
|
+
// detail came from the stdout `result` envelope (the common case — see
|
|
516
|
+
// the errorDetail comment above), which is not an stderr line.
|
|
517
|
+
if (fromStderr) err.stream = 'err';
|
|
518
|
+
finish(rejectP, err);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const text = resultText || assistantText;
|
|
522
|
+
finish(resolveP, { text, exitCode: code ?? 0 });
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function safeEmit(onEvent, e) {
|
|
528
|
+
try {
|
|
529
|
+
onEvent(e);
|
|
530
|
+
} catch {
|
|
531
|
+
/* listener errors must not break the stream */
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Pull human-readable text out of a stream-json event. Handles the common
|
|
537
|
+
* Claude Code shapes: { type:"assistant", message:{ content:[{type:"text", text}] } }
|
|
538
|
+
* and { type:"result", result:"..." }.
|
|
539
|
+
*/
|
|
540
|
+
function extractText(evt) {
|
|
541
|
+
if (!evt || typeof evt !== 'object') return '';
|
|
542
|
+
if (typeof evt.result === 'string') return evt.result;
|
|
543
|
+
const content = evt.message?.content ?? evt.content;
|
|
544
|
+
if (Array.isArray(content)) {
|
|
545
|
+
return content
|
|
546
|
+
.filter((c) => c && c.type === 'text' && typeof c.text === 'string')
|
|
547
|
+
.map((c) => c.text)
|
|
548
|
+
.join('');
|
|
549
|
+
}
|
|
550
|
+
if (typeof content === 'string') return content;
|
|
551
|
+
return '';
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Pull the ACTUAL dollar cost out of a stream-json `result` event. Claude Code
|
|
556
|
+
* reports spend for the headless invocation as `total_cost_usd` on the terminal
|
|
557
|
+
* result event (older builds: `cost_usd`). Returns a finite number (INCLUDING 0),
|
|
558
|
+
* or null when the event is not a cost-bearing result (so callers can simply skip
|
|
559
|
+
* null). A genuine zero must survive: `?? ` only falls through on null/undefined,
|
|
560
|
+
* never on 0.
|
|
561
|
+
* @param {any} evt
|
|
562
|
+
* @returns {number|null}
|
|
563
|
+
*/
|
|
564
|
+
export function extractResultCost(evt) {
|
|
565
|
+
if (!evt || typeof evt !== 'object' || evt.type !== 'result') return null;
|
|
566
|
+
const raw = evt.total_cost_usd ?? evt.cost_usd; // accept either spelling; keeps 0
|
|
567
|
+
if (raw == null) return null; // no cost field present
|
|
568
|
+
const n = Number(raw);
|
|
569
|
+
return Number.isFinite(n) && n >= 0 ? n : null; // a negative spend is malformed → no cost
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── Mock execution ───────────────────────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Parse "KEY: value" markers from the prompt (preferred) and systemPrompt.
|
|
576
|
+
*/
|
|
577
|
+
function parseMarkers(prompt, systemPrompt) {
|
|
578
|
+
const markers = {};
|
|
579
|
+
const scan = (txt) => {
|
|
580
|
+
if (!txt) return;
|
|
581
|
+
for (const line of String(txt).split(/\r?\n/)) {
|
|
582
|
+
const m = line.match(/^\s*(MOCK_[A-Z_]+)\s*:\s*(.*)$/);
|
|
583
|
+
if (m) {
|
|
584
|
+
const key = m[1];
|
|
585
|
+
if (markers[key] === undefined) markers[key] = m[2].trim();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
scan(prompt);
|
|
590
|
+
scan(systemPrompt);
|
|
591
|
+
return markers;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function ensureDir(filePath) {
|
|
595
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function exists(p) {
|
|
599
|
+
try {
|
|
600
|
+
await access(p, FS.F_OK);
|
|
601
|
+
return true;
|
|
602
|
+
} catch {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Emit a canned log line and yield to the event loop. */
|
|
608
|
+
async function emitLog(onEvent, text) {
|
|
609
|
+
safeEmit(onEvent, { type: 'assistant', text, raw: { mock: true, text } });
|
|
610
|
+
// Let consumers process the event; keeps mock async-realistic.
|
|
611
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The mock-fan-out roles (mirror the orchestrator's FANOUT_ELIGIBLE intent): the
|
|
616
|
+
* roles whose real runs may spawn sub-agents. Keyed by the MOCK_ROLE strings.
|
|
617
|
+
*/
|
|
618
|
+
const MOCK_FANOUT_ROLES = new Set([
|
|
619
|
+
'planner-plan', 'refiner', 'implementer', 'plan-review',
|
|
620
|
+
'workspace-reviewer', 'workspace-scan',
|
|
621
|
+
]);
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Emit a couple of fake sub-agent spawn (assistant.tool_use Agent) + finish
|
|
625
|
+
* (user.tool_result) events for a fan-out-eligible role so the offline mock
|
|
626
|
+
* exercises the sub-agent lifecycle indicator. No-op for other roles. The ids are
|
|
627
|
+
* role-namespaced so concurrent mock nodes never collide on a tool_use id.
|
|
628
|
+
*/
|
|
629
|
+
async function emitMockSubAgents(role, onEvent, signal) {
|
|
630
|
+
if (!MOCK_FANOUT_ROLES.has(role)) return;
|
|
631
|
+
const labels = ['investigate area A', 'investigate area B'];
|
|
632
|
+
const types = ['general-purpose', 'Explore']; // exercise both a built-in and a named type
|
|
633
|
+
const ids = labels.map((_, i) => `mock_${role}_${i + 1}`);
|
|
634
|
+
|
|
635
|
+
// (1) MAIN-agent skill + MCP-tool use (no parent_tool_use_id) -> the step/group
|
|
636
|
+
// header gets a blue `skill:graphify` pill AND a green three-part
|
|
637
|
+
// `mcp:playwright:browser_snapshot` pill, so an offline run exercises BOTH pill
|
|
638
|
+
// kinds on the header row, not just the sub-agent rows (§7.6).
|
|
639
|
+
safeEmit(onEvent, {
|
|
640
|
+
type: 'assistant',
|
|
641
|
+
raw: { type: 'assistant', message: { content: [
|
|
642
|
+
{ type: 'tool_use', id: `mock_${role}_skill`, name: 'Skill', input: { skill: 'graphify' } },
|
|
643
|
+
{ type: 'tool_use', id: `mock_${role}_mcp`, name: 'mcp__plugin_playwright_playwright__browser_snapshot', input: {} },
|
|
644
|
+
] } },
|
|
645
|
+
});
|
|
646
|
+
// (2) Spawns (one assistant event carrying both Agent tool_use blocks).
|
|
647
|
+
safeEmit(onEvent, {
|
|
648
|
+
type: 'assistant',
|
|
649
|
+
raw: { type: 'assistant', message: { content: ids.map((id, i) => ({
|
|
650
|
+
type: 'tool_use', id, name: 'Agent', input: { description: labels[i], subagent_type: types[i] },
|
|
651
|
+
})) } },
|
|
652
|
+
});
|
|
653
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
654
|
+
abortIfNeeded(signal);
|
|
655
|
+
// (3) The FIRST sub-agent uses a skill + TWO tools of the SAME MCP server (child
|
|
656
|
+
// stream: parent_tool_use_id). Two tools on one server is the §7.1 granularity
|
|
657
|
+
// change made visible offline: it yields TWO pills where it used to yield one.
|
|
658
|
+
safeEmit(onEvent, {
|
|
659
|
+
type: 'assistant',
|
|
660
|
+
raw: { type: 'assistant', parent_tool_use_id: ids[0], message: { content: [
|
|
661
|
+
{ type: 'tool_use', id: `${ids[0]}_s1`, name: 'Skill', input: { skill: 'brainstorming' } },
|
|
662
|
+
{ type: 'tool_use', id: `${ids[0]}_s2`, name: 'mcp__plugin_playwright_playwright__browser_navigate', input: { url: 'http://localhost' } },
|
|
663
|
+
{ type: 'tool_use', id: `${ids[0]}_s3`, name: 'mcp__plugin_playwright_playwright__browser_click', input: { ref: 'e1' } },
|
|
664
|
+
] } },
|
|
665
|
+
});
|
|
666
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
667
|
+
abortIfNeeded(signal);
|
|
668
|
+
// (4) Matching tool_result finishes.
|
|
669
|
+
for (const id of ids) {
|
|
670
|
+
safeEmit(onEvent, {
|
|
671
|
+
type: 'user',
|
|
672
|
+
raw: { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: 'ok' }] } },
|
|
673
|
+
});
|
|
674
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function abortIfNeeded(signal) {
|
|
679
|
+
if (signal?.aborted) {
|
|
680
|
+
const err = new Error('aborted');
|
|
681
|
+
err.name = 'AbortError';
|
|
682
|
+
throw err;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Offline mock: emits a few log lines and performs role-appropriate writes.
|
|
688
|
+
*/
|
|
689
|
+
async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets }) {
|
|
690
|
+
abortIfNeeded(signal);
|
|
691
|
+
const m = parseMarkers(prompt, systemPrompt);
|
|
692
|
+
const role = m.MOCK_ROLE || inferRole(prompt, systemPrompt);
|
|
693
|
+
const cycle = Number(m.MOCK_CYCLE || '1') || 1;
|
|
694
|
+
|
|
695
|
+
// Pause/Resume parity with the real runner: deterministic per-role session ids,
|
|
696
|
+
// and an assertable log line when a session is re-attached.
|
|
697
|
+
const sessionId = `mock-session-${role || 'unknown'}-c${cycle}`;
|
|
698
|
+
safeEmit(onEvent, { type: 'session', sessionId });
|
|
699
|
+
if (resumeSessionId) await emitLog(onEvent, `[mock] resumed session ${resumeSessionId}`);
|
|
700
|
+
|
|
701
|
+
await emitLog(onEvent, `[mock] starting role=${role || 'unknown'} cycle=${cycle}`);
|
|
702
|
+
abortIfNeeded(signal);
|
|
703
|
+
|
|
704
|
+
// Ask-then-resume (spec 2026-07-11): asking replaces the role side effects
|
|
705
|
+
// for this invocation; the orchestrator gates the user and resumes. The
|
|
706
|
+
// session event above already fired, so the resume has a session id.
|
|
707
|
+
if (m.MOCK_ASK) {
|
|
708
|
+
await ensureDir(m.MOCK_ASK);
|
|
709
|
+
await writeFile(m.MOCK_ASK, JSON.stringify({
|
|
710
|
+
questions: [{ id: 'q1', question: `Mock question from ${role}?`, options: ['Option A', 'Option B'], allowFreeText: true }],
|
|
711
|
+
}, null, 2) + '\n', 'utf8');
|
|
712
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${m.MOCK_ASK}`, raw: { mock: true, file: m.MOCK_ASK } });
|
|
713
|
+
safeEmit(onEvent, { type: 'result', costUsd: 0, raw: { mock: true, type: 'result', total_cost_usd: 0 } });
|
|
714
|
+
await emitLog(onEvent, `[mock] questions written; stopping for answers (role=${role})`);
|
|
715
|
+
return { text: '[mock] asked questions', exitCode: 0 };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let text = `[mock] role ${role} complete`;
|
|
719
|
+
switch (role) {
|
|
720
|
+
case 'clarify':
|
|
721
|
+
text = await mockClarify(m, cycle, onEvent);
|
|
722
|
+
break;
|
|
723
|
+
case 'planner-plan':
|
|
724
|
+
text = await mockPlannerPlan(m, onEvent);
|
|
725
|
+
break;
|
|
726
|
+
case 'refiner':
|
|
727
|
+
text = await mockRefiner(m, cycle, onEvent);
|
|
728
|
+
break;
|
|
729
|
+
case 'decomposer':
|
|
730
|
+
text = await mockDecomposer(m, onEvent);
|
|
731
|
+
break;
|
|
732
|
+
case 'implementer':
|
|
733
|
+
text = await mockImplementer(m, cwd, onEvent, workspaceWriteTargets);
|
|
734
|
+
break;
|
|
735
|
+
case 'reviewer':
|
|
736
|
+
text = await mockReviewer(m, cycle, onEvent);
|
|
737
|
+
break;
|
|
738
|
+
case 'plan-review':
|
|
739
|
+
text = await mockPlanReview(m, cycle, onEvent);
|
|
740
|
+
break;
|
|
741
|
+
case 'workspace-scan':
|
|
742
|
+
text = await mockWorkspaceScan(m, prompt, onEvent);
|
|
743
|
+
break;
|
|
744
|
+
case 'agent-gen':
|
|
745
|
+
text = await mockAgentGen(m, onEvent);
|
|
746
|
+
break;
|
|
747
|
+
case 'workspace-reviewer':
|
|
748
|
+
text = await mockWorkspaceReviewer(m, cycle, onEvent);
|
|
749
|
+
break;
|
|
750
|
+
case 'manual-tests-checklist':
|
|
751
|
+
text = await mockManualTestsChecklist(m, onEvent);
|
|
752
|
+
break;
|
|
753
|
+
case 'manual-web-ui-testing':
|
|
754
|
+
text = await mockManualWebUiTesting(m, cycle, onEvent);
|
|
755
|
+
break;
|
|
756
|
+
case 'generic-producer':
|
|
757
|
+
text = await mockGenericProducer(m, onEvent);
|
|
758
|
+
break;
|
|
759
|
+
case 'generic-verifier':
|
|
760
|
+
// Reuses the reviewer mock: writes MOCK_OUT md + MOCK_JSON verdict with the
|
|
761
|
+
// standard cycle-decreasing severity, so generic loops terminate offline.
|
|
762
|
+
text = await mockReviewer(m, cycle, onEvent);
|
|
763
|
+
break;
|
|
764
|
+
default:
|
|
765
|
+
await emitLog(onEvent, `[mock] no side effects for unknown role`);
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
abortIfNeeded(signal);
|
|
770
|
+
// Offline sub-agent indicator: for the fan-out-eligible roles, emit a couple of
|
|
771
|
+
// fake Task/Agent spawn tool_use blocks + matching tool_result finishes so
|
|
772
|
+
// `npm run smoke` exercises the sub-agent lifecycle (squares/pill) with no real
|
|
773
|
+
// claude. Shapes mirror the real stream: spawn = assistant.tool_use(Agent) with
|
|
774
|
+
// an id; finish = user.tool_result with that tool_use_id. Non-fan-out roles emit
|
|
775
|
+
// nothing, so their mock output is unchanged.
|
|
776
|
+
await emitMockSubAgents(role, onEvent, signal);
|
|
777
|
+
abortIfNeeded(signal);
|
|
778
|
+
// No model was called, so the truthful spend is $0. Emit a result event the
|
|
779
|
+
// orchestrator attributes to the current phase, so mock/demo runs still show
|
|
780
|
+
// a (zero) per-phase and total cost in the UI.
|
|
781
|
+
safeEmit(onEvent, { type: 'result', costUsd: 0, raw: { mock: true, type: 'result', total_cost_usd: 0 } });
|
|
782
|
+
await emitLog(onEvent, `[mock] done role=${role}`);
|
|
783
|
+
return { text, exitCode: 0 };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Best-effort role inference if MOCK_ROLE is absent. */
|
|
787
|
+
function inferRole(prompt, systemPrompt) {
|
|
788
|
+
const hay = `${prompt}\n${systemPrompt}`.toLowerCase();
|
|
789
|
+
if (hay.includes('clarif')) return 'clarify';
|
|
790
|
+
if (hay.includes('refine')) return 'refiner';
|
|
791
|
+
if (hay.includes('review')) return 'reviewer';
|
|
792
|
+
if (hay.includes('implement')) return 'implementer';
|
|
793
|
+
if (hay.includes('plan')) return 'planner-plan';
|
|
794
|
+
return 'unknown';
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function mockClarify(m, cycle, onEvent) {
|
|
798
|
+
const out = m.MOCK_OUT;
|
|
799
|
+
// Ask one question while no answers have been fed back; once the user's prior
|
|
800
|
+
// answers are present (MOCK_PRIOR > 0) report no further questions so the
|
|
801
|
+
// orchestrator's clarify loop terminates naturally. This mirrors the real fix:
|
|
802
|
+
// the loop converges because answers are returned to the planner.
|
|
803
|
+
const hasPrior = Number(m.MOCK_PRIOR || '0') > 0;
|
|
804
|
+
const payload = hasPrior
|
|
805
|
+
? { questions: [] }
|
|
806
|
+
: {
|
|
807
|
+
questions: [
|
|
808
|
+
{
|
|
809
|
+
id: 'invalid-input',
|
|
810
|
+
question:
|
|
811
|
+
'How should the feature handle invalid input — fail fast, coerce, or ignore?',
|
|
812
|
+
options: [
|
|
813
|
+
'Fail fast with a clear error',
|
|
814
|
+
'Coerce to a safe default',
|
|
815
|
+
'Ignore and continue',
|
|
816
|
+
'Reject at the boundary', // 4 options — exercises the upper bound
|
|
817
|
+
],
|
|
818
|
+
allowFreeText: true,
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
id: 'delete-behavior',
|
|
822
|
+
question: 'Should delete be a hard delete or a soft delete?',
|
|
823
|
+
options: ['Hard delete', 'Soft delete'], // 2 options — exercises the relaxed floor
|
|
824
|
+
allowFreeText: true,
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
};
|
|
828
|
+
await emitLog(
|
|
829
|
+
onEvent,
|
|
830
|
+
hasPrior
|
|
831
|
+
? '[mock] planner has no further questions'
|
|
832
|
+
: '[mock] planner asking one clarifying question',
|
|
833
|
+
);
|
|
834
|
+
if (!out) return '[mock] clarify: no MOCK_OUT given';
|
|
835
|
+
await ensureDir(out);
|
|
836
|
+
await writeFile(out, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
837
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${out}`, raw: { mock: true, file: out } });
|
|
838
|
+
return JSON.stringify(payload);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Generic producer mock: deterministic content to MOCK_OUT (json if the path
|
|
842
|
+
* ends .json, else markdown). Lets user-defined agents run offline with no
|
|
843
|
+
* bespoke mock branch. */
|
|
844
|
+
async function mockGenericProducer(m, onEvent) {
|
|
845
|
+
const out = m.MOCK_OUT;
|
|
846
|
+
await emitLog(onEvent, '[mock] generic producer writing output artifact');
|
|
847
|
+
if (!out) return '[mock] generic-producer: no MOCK_OUT given';
|
|
848
|
+
const body = out.endsWith('.json')
|
|
849
|
+
? JSON.stringify({ mock: true, note: 'generic artifact' }, null, 2) + '\n'
|
|
850
|
+
: '# Mock artifact\n\nDeterministic generic producer output.\n';
|
|
851
|
+
await ensureDir(out);
|
|
852
|
+
await writeFile(out, body, 'utf8');
|
|
853
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${out}`, raw: { mock: true, file: out } });
|
|
854
|
+
return `[mock] generic artifact written to ${out}`;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function mockPlannerPlan(m, onEvent) {
|
|
858
|
+
const out = m.MOCK_OUT;
|
|
859
|
+
const base = m.MOCK_BASE || 'feature';
|
|
860
|
+
await emitLog(onEvent, '[mock] planner writing initial plan with code snippet');
|
|
861
|
+
if (!out) return '[mock] planner-plan: no MOCK_OUT given';
|
|
862
|
+
const md = mockPlanMarkdown(base, 1);
|
|
863
|
+
await ensureDir(out);
|
|
864
|
+
await writeFile(out, md, 'utf8');
|
|
865
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${out}`, raw: { mock: true, file: out } });
|
|
866
|
+
return `[mock] plan written to ${out}`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function mockPlanMarkdown(base, version) {
|
|
870
|
+
return (
|
|
871
|
+
`# Plan: ${base} (v${version})\n\n` +
|
|
872
|
+
`## Overview\n\n` +
|
|
873
|
+
`Deterministic mock plan for "${base}". Implements a small module using TDD.\n\n` +
|
|
874
|
+
`## Steps\n\n` +
|
|
875
|
+
`1. Write a failing test for the core function.\n` +
|
|
876
|
+
`2. Implement the function until the test passes.\n` +
|
|
877
|
+
`3. Refactor for clarity.\n\n` +
|
|
878
|
+
`## Code Snippets\n\n` +
|
|
879
|
+
'```js\n' +
|
|
880
|
+
`// src/feature.mjs\n` +
|
|
881
|
+
`export function feature(input) {\n` +
|
|
882
|
+
` if (input == null) throw new Error('input required');\n` +
|
|
883
|
+
` return String(input).trim();\n` +
|
|
884
|
+
`}\n` +
|
|
885
|
+
'```\n\n' +
|
|
886
|
+
'```js\n' +
|
|
887
|
+
`// test/feature.test.mjs\n` +
|
|
888
|
+
`import { feature } from '../src/feature.mjs';\n` +
|
|
889
|
+
`import assert from 'node:assert';\n` +
|
|
890
|
+
`assert.equal(feature(' hi '), 'hi');\n` +
|
|
891
|
+
'```\n\n' +
|
|
892
|
+
`## Clarifications (Q&A)\n\n` +
|
|
893
|
+
`- **Q:** How should the feature handle invalid input?\n` +
|
|
894
|
+
` - **A:** Fail fast with a clear error\n`
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function mockRefiner(m, cycle, onEvent) {
|
|
899
|
+
const out = m.MOCK_OUT;
|
|
900
|
+
const jsonPath = m.MOCK_JSON;
|
|
901
|
+
const base = m.MOCK_BASE || 'feature';
|
|
902
|
+
await emitLog(onEvent, `[mock] refiner reviewing plan (cycle ${cycle})`);
|
|
903
|
+
|
|
904
|
+
// Seed the -vN plan from the input plan if available, else from template.
|
|
905
|
+
if (out) {
|
|
906
|
+
let body = '';
|
|
907
|
+
if (m.MOCK_IN && (await exists(m.MOCK_IN))) {
|
|
908
|
+
try {
|
|
909
|
+
body = await readFile(m.MOCK_IN, 'utf8');
|
|
910
|
+
} catch {
|
|
911
|
+
body = '';
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (!body) body = mockPlanMarkdown(base, cycle + 1);
|
|
915
|
+
const refined =
|
|
916
|
+
body +
|
|
917
|
+
`\n## Refinement notes (cycle ${cycle})\n\n` +
|
|
918
|
+
`- Tightened error handling and added an edge-case test.\n`;
|
|
919
|
+
await ensureDir(out);
|
|
920
|
+
await writeFile(out, refined, 'utf8');
|
|
921
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${out}`, raw: { mock: true, file: out } });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Cycle 1 has one blocking (major) issue; cycle >=2 has only minor.
|
|
925
|
+
const review =
|
|
926
|
+
cycle <= 1
|
|
927
|
+
? {
|
|
928
|
+
summary: 'Plan is mostly solid but one major gap remains.',
|
|
929
|
+
issues: [
|
|
930
|
+
{
|
|
931
|
+
severity: 'major',
|
|
932
|
+
title: 'Missing error-path test',
|
|
933
|
+
detail: 'The plan does not test the invalid-input branch.',
|
|
934
|
+
location: 'test/feature.test.mjs',
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
severity: 'minor',
|
|
938
|
+
title: 'Naming',
|
|
939
|
+
detail: 'Consider a more descriptive function name.',
|
|
940
|
+
location: 'src/feature.mjs',
|
|
941
|
+
},
|
|
942
|
+
],
|
|
943
|
+
}
|
|
944
|
+
: {
|
|
945
|
+
summary: 'No blocking issues remain.',
|
|
946
|
+
issues: [
|
|
947
|
+
{
|
|
948
|
+
severity: 'minor',
|
|
949
|
+
title: 'Doc comment',
|
|
950
|
+
detail: 'Add a short JSDoc to the exported function.',
|
|
951
|
+
location: 'src/feature.mjs',
|
|
952
|
+
},
|
|
953
|
+
],
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
if (jsonPath) {
|
|
957
|
+
await ensureDir(jsonPath);
|
|
958
|
+
await writeFile(jsonPath, JSON.stringify(review, null, 2) + '\n', 'utf8');
|
|
959
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${jsonPath}`, raw: { mock: true, file: jsonPath } });
|
|
960
|
+
}
|
|
961
|
+
return JSON.stringify(review);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function mockDecomposer(m, onEvent) {
|
|
965
|
+
const out = m.MOCK_OUT;
|
|
966
|
+
const tasksDir = m.MOCK_TASKS_DIR;
|
|
967
|
+
if (!out || !tasksDir) return '[mock] decomposer: no MOCK_OUT / MOCK_TASKS_DIR given';
|
|
968
|
+
await mkdir(tasksDir, { recursive: true });
|
|
969
|
+
const phases = [
|
|
970
|
+
{ ordinal: 1, tasks: [
|
|
971
|
+
{ id: 'p1t1', title: 'Slice one', file: 'tasks/p1-t1-slice-one.md' },
|
|
972
|
+
{ id: 'p1t2', title: 'Slice two', file: 'tasks/p1-t2-slice-two.md' },
|
|
973
|
+
] },
|
|
974
|
+
{ ordinal: 2, tasks: [
|
|
975
|
+
{ id: 'p2t1', title: 'Slice three', file: 'tasks/p2-t1-slice-three.md' },
|
|
976
|
+
] },
|
|
977
|
+
];
|
|
978
|
+
for (const ph of phases) {
|
|
979
|
+
for (const t of ph.tasks) {
|
|
980
|
+
await writeFile(join(tasksDir, t.file.replace(/^tasks\//, '')),
|
|
981
|
+
`# ${t.title}\n\nSelf-contained mock task for phase ${ph.ordinal}.\n`, 'utf8');
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
await writeFile(out, JSON.stringify({ phases }, null, 2) + '\n', 'utf8');
|
|
985
|
+
await emitLog(onEvent, `[mock] decomposer wrote ${phases.length} phases`);
|
|
986
|
+
return '[mock] decomposer complete';
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* §8.10: the mock's only cwd-dependent role. `workspaceWriteTargets` (threaded
|
|
991
|
+
* runOpts -> runClaude -> runMock -> here) redirects the writes into EVERY member
|
|
992
|
+
* checkout on a detached workspace run, where `cwd` is the run root and therefore no
|
|
993
|
+
* repository: writing there would leave every member clean, commit nothing, and
|
|
994
|
+
* produce an empty patch. Empty/absent targets keep today's exact single-dir
|
|
995
|
+
* behavior — the same `edited …` event and the same returned text — so single-project
|
|
996
|
+
* runs and legacy workspace runs are byte-identical. `ctx.workspace` is NOT available
|
|
997
|
+
* here by design; these absolute paths are the only channel.
|
|
998
|
+
*/
|
|
999
|
+
async function mockImplementer(m, cwd, onEvent, workspaceWriteTargets) {
|
|
1000
|
+
await emitLog(onEvent, '[mock] implementer applying plan via TDD (red-green-refactor)');
|
|
1001
|
+
const targets = Array.isArray(workspaceWriteTargets) && workspaceWriteTargets.length
|
|
1002
|
+
? workspaceWriteTargets
|
|
1003
|
+
: [cwd];
|
|
1004
|
+
// One stamp for the whole invocation, so a multi-member pass is deterministic.
|
|
1005
|
+
const stamp = new Date().toISOString();
|
|
1006
|
+
const written = [];
|
|
1007
|
+
for (const target of targets) {
|
|
1008
|
+
const srcDir = join(target, 'src');
|
|
1009
|
+
const testDir = join(target, 'test');
|
|
1010
|
+
await mkdir(srcDir, { recursive: true });
|
|
1011
|
+
await mkdir(testDir, { recursive: true });
|
|
1012
|
+
|
|
1013
|
+
const srcFile = join(srcDir, 'feature.mjs');
|
|
1014
|
+
const testFile = join(testDir, 'feature.test.mjs');
|
|
1015
|
+
|
|
1016
|
+
// Append (not overwrite) so repeated fix cycles keep producing a non-empty diff.
|
|
1017
|
+
const srcContent =
|
|
1018
|
+
`// generated by mock implementer @ ${stamp}\n` +
|
|
1019
|
+
`export function feature(input) {\n` +
|
|
1020
|
+
` if (input == null) throw new Error('input required');\n` +
|
|
1021
|
+
` return String(input).trim();\n` +
|
|
1022
|
+
`}\n`;
|
|
1023
|
+
if (await exists(srcFile)) {
|
|
1024
|
+
await appendFile(srcFile, `\n// fix pass @ ${stamp}\n`, 'utf8');
|
|
1025
|
+
} else {
|
|
1026
|
+
await writeFile(srcFile, srcContent, 'utf8');
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const testContent =
|
|
1030
|
+
`// generated by mock implementer @ ${stamp}\n` +
|
|
1031
|
+
`import { feature } from '../src/feature.mjs';\n` +
|
|
1032
|
+
`import assert from 'node:assert';\n` +
|
|
1033
|
+
`assert.equal(feature(' hi '), 'hi');\n` +
|
|
1034
|
+
`assert.throws(() => feature(null));\n`;
|
|
1035
|
+
if (await exists(testFile)) {
|
|
1036
|
+
await appendFile(testFile, `\n// fix pass @ ${stamp}\n`, 'utf8');
|
|
1037
|
+
} else {
|
|
1038
|
+
await writeFile(testFile, testContent, 'utf8');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
safeEmit(onEvent, { type: 'tool_use', text: `edited ${srcFile} and ${testFile}`, raw: { mock: true } });
|
|
1042
|
+
written.push({ srcFile, testFile });
|
|
1043
|
+
}
|
|
1044
|
+
if (written.length === 1) {
|
|
1045
|
+
return `[mock] implemented feature in ${written[0].srcFile} with test ${written[0].testFile}`;
|
|
1046
|
+
}
|
|
1047
|
+
return `[mock] implemented feature in ${written.length} member checkouts: ` +
|
|
1048
|
+
written.map((w) => w.srcFile).join(', ');
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function mockReviewer(m, cycle, onEvent) {
|
|
1052
|
+
const mdPath = m.MOCK_OUT;
|
|
1053
|
+
const jsonPath = m.MOCK_JSON;
|
|
1054
|
+
await emitLog(onEvent, `[mock] reviewer reviewing git diff (cycle ${cycle})`);
|
|
1055
|
+
|
|
1056
|
+
// Cycle 1: one major. Cycle >=2: only suggestion. Loop terminates by cycle 2.
|
|
1057
|
+
const review =
|
|
1058
|
+
cycle <= 1
|
|
1059
|
+
? {
|
|
1060
|
+
summary: 'Implementation works but a major issue needs a fix.',
|
|
1061
|
+
issues: [
|
|
1062
|
+
{
|
|
1063
|
+
severity: 'major',
|
|
1064
|
+
title: 'Unhandled empty-string input',
|
|
1065
|
+
detail: 'feature("") returns "" but the plan expects a thrown error.',
|
|
1066
|
+
location: 'src/feature.mjs',
|
|
1067
|
+
},
|
|
1068
|
+
],
|
|
1069
|
+
}
|
|
1070
|
+
: {
|
|
1071
|
+
summary: 'Looks good. Only a suggestion remains.',
|
|
1072
|
+
issues: [
|
|
1073
|
+
{
|
|
1074
|
+
severity: 'suggestion',
|
|
1075
|
+
title: 'Add a usage example',
|
|
1076
|
+
detail: 'A short example in the README would help.',
|
|
1077
|
+
location: 'README.md',
|
|
1078
|
+
},
|
|
1079
|
+
],
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
if (mdPath) {
|
|
1083
|
+
const md =
|
|
1084
|
+
`# Implementation Review (cycle ${cycle})\n\n` +
|
|
1085
|
+
`## Summary\n\n${review.summary}\n\n` +
|
|
1086
|
+
`## Issues\n\n` +
|
|
1087
|
+
review.issues
|
|
1088
|
+
.map((i) => `- **[${i.severity}]** ${i.title} — ${i.detail} (\`${i.location}\`)`)
|
|
1089
|
+
.join('\n') +
|
|
1090
|
+
'\n';
|
|
1091
|
+
await ensureDir(mdPath);
|
|
1092
|
+
await writeFile(mdPath, md, 'utf8');
|
|
1093
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${mdPath}`, raw: { mock: true, file: mdPath } });
|
|
1094
|
+
}
|
|
1095
|
+
if (jsonPath) {
|
|
1096
|
+
await ensureDir(jsonPath);
|
|
1097
|
+
await writeFile(jsonPath, JSON.stringify(review, null, 2) + '\n', 'utf8');
|
|
1098
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${jsonPath}`, raw: { mock: true, file: jsonPath } });
|
|
1099
|
+
}
|
|
1100
|
+
return JSON.stringify(review);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function mockPlanReview(m, cycle, onEvent) {
|
|
1104
|
+
const mdPath = m.MOCK_OUT;
|
|
1105
|
+
const jsonPath = m.MOCK_JSON;
|
|
1106
|
+
await emitLog(onEvent, `[mock] plan reviewer reviewing the plan (cycle ${cycle})`);
|
|
1107
|
+
|
|
1108
|
+
const review =
|
|
1109
|
+
cycle <= 1
|
|
1110
|
+
? {
|
|
1111
|
+
summary: 'Plan is close but one major gap blocks implementation.',
|
|
1112
|
+
issues: [
|
|
1113
|
+
{
|
|
1114
|
+
severity: 'major',
|
|
1115
|
+
title: 'Missing error-path coverage in the plan',
|
|
1116
|
+
detail: 'The plan does not specify a test for the invalid-input branch.',
|
|
1117
|
+
location: 'Steps / Code Snippets',
|
|
1118
|
+
},
|
|
1119
|
+
],
|
|
1120
|
+
}
|
|
1121
|
+
: {
|
|
1122
|
+
summary: 'Plan is correct, complete, and testable.',
|
|
1123
|
+
issues: [
|
|
1124
|
+
{
|
|
1125
|
+
severity: 'suggestion',
|
|
1126
|
+
title: 'Add a short rationale',
|
|
1127
|
+
detail: 'A one-line rationale per step would aid the reviewer.',
|
|
1128
|
+
location: 'Overview',
|
|
1129
|
+
},
|
|
1130
|
+
],
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
if (mdPath) {
|
|
1134
|
+
const md =
|
|
1135
|
+
`# Plan Review (cycle ${cycle})\n\n## Summary\n\n${review.summary}\n\n## Issues\n\n` +
|
|
1136
|
+
review.issues.map((i) => `- **[${i.severity}]** ${i.title} — ${i.detail} (\`${i.location}\`)`).join('\n') +
|
|
1137
|
+
'\n';
|
|
1138
|
+
await ensureDir(mdPath);
|
|
1139
|
+
await writeFile(mdPath, md, 'utf8');
|
|
1140
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${mdPath}`, raw: { mock: true, file: mdPath } });
|
|
1141
|
+
}
|
|
1142
|
+
if (jsonPath) {
|
|
1143
|
+
await ensureDir(jsonPath);
|
|
1144
|
+
await writeFile(jsonPath, JSON.stringify(review, null, 2) + '\n', 'utf8');
|
|
1145
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${jsonPath}`, raw: { mock: true, file: jsonPath } });
|
|
1146
|
+
}
|
|
1147
|
+
return JSON.stringify(review);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Mock the off-pipeline workspace scanner. Writes a deterministic interconnection
|
|
1152
|
+
* description following the §5.8 template (so the wizard textarea is populated in
|
|
1153
|
+
* mock mode) and emits one `INVESTIGATING <key> relations to <other>` log line per
|
|
1154
|
+
* project so the live-status UI can be exercised offline. Project keys are parsed
|
|
1155
|
+
* from the prompt's member lines (the runner does NOT spawn sub-agents — fan-out is
|
|
1156
|
+
* a prompt directive the mock ignores).
|
|
1157
|
+
*/
|
|
1158
|
+
async function mockWorkspaceScan(m, prompt, onEvent) {
|
|
1159
|
+
const out = m.MOCK_OUT;
|
|
1160
|
+
const name = m.MOCK_BASE || 'Workspace';
|
|
1161
|
+
// Parse `(`backtick-key`)` member markers the scan task prompt renders, in order.
|
|
1162
|
+
const keys = [];
|
|
1163
|
+
for (const line of String(prompt || '').split(/\r?\n/)) {
|
|
1164
|
+
const mm = line.match(/^\s*-\s+\*\*.*\*\*\s+\(`([^`]+)`\)/);
|
|
1165
|
+
if (mm) keys.push(mm[1]);
|
|
1166
|
+
}
|
|
1167
|
+
await emitLog(onEvent, `[mock] workspace scanner investigating ${keys.length} project(s)`);
|
|
1168
|
+
// One INVESTIGATING line per project (paired with the next project, round-robin),
|
|
1169
|
+
// then the synthesize line — the changing live-status text the server maps.
|
|
1170
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1171
|
+
const other = keys[(i + 1) % keys.length] || keys[i];
|
|
1172
|
+
await emitLog(onEvent, `INVESTIGATING ${keys[i]} relations to ${other}`);
|
|
1173
|
+
}
|
|
1174
|
+
await emitLog(onEvent, 'SYNTHESIZING workspace description');
|
|
1175
|
+
|
|
1176
|
+
const projects = keys.length ? keys : ['project-a', 'project-b'];
|
|
1177
|
+
const md =
|
|
1178
|
+
`# Workspace: ${name}\n` +
|
|
1179
|
+
`## Overview\n` +
|
|
1180
|
+
`Deterministic mock interconnection description for ${projects.length} member project(s). ` +
|
|
1181
|
+
`The dominant integration theme is a shared REST contract.\n` +
|
|
1182
|
+
`## Projects\n` +
|
|
1183
|
+
projects.map((k) => `- ${k}: member project`).join('\n') + '\n' +
|
|
1184
|
+
`## Interconnections\n` +
|
|
1185
|
+
(projects.length >= 2
|
|
1186
|
+
? `- ${projects[0]} -> ${projects[1]}: REST API; ${projects[0]} calls ${projects[1]}'s HTTP endpoints.\n`
|
|
1187
|
+
: `- (single project — no interconnections)\n`) +
|
|
1188
|
+
`## Change-coordination notes\n` +
|
|
1189
|
+
`- Changes that touch the shared REST contract must be coordinated across both members.\n` +
|
|
1190
|
+
`## Suggested change order\n` +
|
|
1191
|
+
(projects.length >= 2 ? `${projects[1]} before ${projects[0]} (provider before consumer).\n` : `no strict ordering\n`);
|
|
1192
|
+
|
|
1193
|
+
if (!out) return '[mock] workspace-scan: no MOCK_OUT given';
|
|
1194
|
+
await ensureDir(out);
|
|
1195
|
+
await writeFile(out, md, 'utf8');
|
|
1196
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${out}`, raw: { mock: true, file: out } });
|
|
1197
|
+
return `[mock] workspace description written to ${out}`;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Mock the agent builder. Writes a deterministic meta JSON to MOCK_JSON and —
|
|
1202
|
+
* ONLY when MOCK_OUT is present (Mode A) — a deterministic agent body to MOCK_OUT.
|
|
1203
|
+
* Mode B (user-pasted markdown) omits MOCK_OUT so the mock never writes a body.
|
|
1204
|
+
*/
|
|
1205
|
+
async function mockAgentGen(m, onEvent) {
|
|
1206
|
+
const name = m.MOCK_BASE || 'Custom Agent';
|
|
1207
|
+
await emitLog(onEvent, `DRAFTING agent metadata for ${name}`);
|
|
1208
|
+
const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
|
|
1209
|
+
const key = words.length
|
|
1210
|
+
? words[0] + words.slice(1).map((w) => w[0].toUpperCase() + w.slice(1)).join('')
|
|
1211
|
+
: 'customAgent';
|
|
1212
|
+
const meta = {
|
|
1213
|
+
key, displayName: name, description: `mock-generated agent for ${name}`,
|
|
1214
|
+
color: 'amber', runnerType: 'producer', loopSource: false, fanOut: false,
|
|
1215
|
+
asksQuestions: true, questionsLocked: false, questionsDefault: false,
|
|
1216
|
+
consumes: ['plan'], optionalConsumes: [], produces: ['review'], connectsTo: '*', order: 99,
|
|
1217
|
+
};
|
|
1218
|
+
if (m.MOCK_OUT) {
|
|
1219
|
+
const md = `# Agent: ${name}\n\nYou are ${name} (deterministic mock body).\n\n## Inputs\n- the plan\n\n## Outputs\n- a review markdown\n`;
|
|
1220
|
+
await ensureDir(m.MOCK_OUT);
|
|
1221
|
+
await writeFile(m.MOCK_OUT, md, 'utf8');
|
|
1222
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${m.MOCK_OUT}`, raw: { mock: true, file: m.MOCK_OUT } });
|
|
1223
|
+
}
|
|
1224
|
+
if (m.MOCK_JSON) {
|
|
1225
|
+
await ensureDir(m.MOCK_JSON);
|
|
1226
|
+
await writeFile(m.MOCK_JSON, JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
|
1227
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${m.MOCK_JSON}`, raw: { mock: true, file: m.MOCK_JSON } });
|
|
1228
|
+
}
|
|
1229
|
+
return '[mock] agent draft written';
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Mock the in-pipeline workspace reviewer. Mirrors mockReviewer: the blocking-issue
|
|
1234
|
+
* count DECREASES with `cycle` so the workspace review -> implementer loop terminates
|
|
1235
|
+
* deterministically. Writes ONE merged review markdown + ONE merged review JSON
|
|
1236
|
+
* (the union shape the real synthesizer produces, with projectKey-prefixed locations).
|
|
1237
|
+
*/
|
|
1238
|
+
async function mockWorkspaceReviewer(m, cycle, onEvent) {
|
|
1239
|
+
const mdPath = m.MOCK_OUT;
|
|
1240
|
+
const jsonPath = m.MOCK_JSON;
|
|
1241
|
+
await emitLog(onEvent, `[mock] workspace reviewer synthesizing per-project reviews (cycle ${cycle})`);
|
|
1242
|
+
|
|
1243
|
+
// Cycle 1: two major issues across two members (a real union). Cycle >=2: only a
|
|
1244
|
+
// suggestion. The loop terminates by cycle 2 (no critical/major remain).
|
|
1245
|
+
const review =
|
|
1246
|
+
cycle <= 1
|
|
1247
|
+
? {
|
|
1248
|
+
summary: 'Across the member projects, two major issues need a fix before acceptance.',
|
|
1249
|
+
issues: [
|
|
1250
|
+
{
|
|
1251
|
+
severity: 'major',
|
|
1252
|
+
title: 'Unhandled empty-string input',
|
|
1253
|
+
detail: 'feature("") returns "" but the plan expects a thrown error.',
|
|
1254
|
+
location: 'project-a: src/feature.mjs',
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
severity: 'major',
|
|
1258
|
+
title: 'Missing contract validation',
|
|
1259
|
+
detail: 'The consumer does not validate the provider response shape.',
|
|
1260
|
+
location: 'project-b: src/client.mjs',
|
|
1261
|
+
},
|
|
1262
|
+
],
|
|
1263
|
+
}
|
|
1264
|
+
: {
|
|
1265
|
+
summary: 'All member projects look good. Only a suggestion remains.',
|
|
1266
|
+
issues: [
|
|
1267
|
+
{
|
|
1268
|
+
severity: 'suggestion',
|
|
1269
|
+
title: 'Add a usage example',
|
|
1270
|
+
detail: 'A short cross-project example in the README would help.',
|
|
1271
|
+
location: 'project-a: README.md',
|
|
1272
|
+
},
|
|
1273
|
+
],
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
if (mdPath) {
|
|
1277
|
+
const md =
|
|
1278
|
+
`# Workspace Implementation Review (cycle ${cycle})\n\n` +
|
|
1279
|
+
`## Summary\n\n${review.summary}\n\n` +
|
|
1280
|
+
`## Issues (union across all member projects)\n\n` +
|
|
1281
|
+
review.issues
|
|
1282
|
+
.map((i) => `- **[${i.severity}]** ${i.title} — ${i.detail} (\`${i.location}\`)`)
|
|
1283
|
+
.join('\n') +
|
|
1284
|
+
'\n';
|
|
1285
|
+
await ensureDir(mdPath);
|
|
1286
|
+
await writeFile(mdPath, md, 'utf8');
|
|
1287
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${mdPath}`, raw: { mock: true, file: mdPath } });
|
|
1288
|
+
}
|
|
1289
|
+
if (jsonPath) {
|
|
1290
|
+
await ensureDir(jsonPath);
|
|
1291
|
+
await writeFile(jsonPath, JSON.stringify(review, null, 2) + '\n', 'utf8');
|
|
1292
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${jsonPath}`, raw: { mock: true, file: jsonPath } });
|
|
1293
|
+
}
|
|
1294
|
+
return JSON.stringify(review);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async function mockManualTestsChecklist(m, onEvent) {
|
|
1298
|
+
const out = m.MOCK_OUT;
|
|
1299
|
+
await emitLog(onEvent, '[mock] manual-tests author drafting checklist');
|
|
1300
|
+
if (!out) return '[mock] manual-tests-checklist: no MOCK_OUT given';
|
|
1301
|
+
const md =
|
|
1302
|
+
`# Manual Test Checklist\n\n` +
|
|
1303
|
+
`- [ ] App boots without errors — open the app; expect no console errors.\n` +
|
|
1304
|
+
`- [ ] Core flow works — exercise the new feature; expect the documented result.\n` +
|
|
1305
|
+
`- [ ] Invalid input is handled — submit bad input; expect a clear error.\n`;
|
|
1306
|
+
await ensureDir(out);
|
|
1307
|
+
await writeFile(out, md, 'utf8');
|
|
1308
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${out}`, raw: { mock: true, file: out } });
|
|
1309
|
+
return `[mock] manual checklist written to ${out}`;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
async function mockManualWebUiTesting(m, cycle, onEvent) {
|
|
1313
|
+
const mdPath = m.MOCK_OUT;
|
|
1314
|
+
const jsonPath = m.MOCK_JSON;
|
|
1315
|
+
await emitLog(onEvent, `[mock] manual web UI testing run (cycle ${cycle})`);
|
|
1316
|
+
// Cycle 1: one major (a case fails). Cycle >=2: only a suggestion. Terminates by cycle 2.
|
|
1317
|
+
const review =
|
|
1318
|
+
cycle <= 1
|
|
1319
|
+
? {
|
|
1320
|
+
summary: 'One manual case failed in the live UI.',
|
|
1321
|
+
issues: [
|
|
1322
|
+
{
|
|
1323
|
+
severity: 'major',
|
|
1324
|
+
title: 'Core flow case failed',
|
|
1325
|
+
detail: 'The documented result did not appear when exercising the feature.',
|
|
1326
|
+
location: 'manual-tests-checklist.md',
|
|
1327
|
+
},
|
|
1328
|
+
],
|
|
1329
|
+
}
|
|
1330
|
+
: {
|
|
1331
|
+
summary: 'All manual cases passed.',
|
|
1332
|
+
issues: [
|
|
1333
|
+
{
|
|
1334
|
+
severity: 'suggestion',
|
|
1335
|
+
title: 'Add an accessibility pass',
|
|
1336
|
+
detail: 'Consider a keyboard-only walkthrough next time.',
|
|
1337
|
+
location: 'manual-tests-checklist.md',
|
|
1338
|
+
},
|
|
1339
|
+
],
|
|
1340
|
+
};
|
|
1341
|
+
if (mdPath) {
|
|
1342
|
+
const md =
|
|
1343
|
+
`# Manual Web UI Test Result (cycle ${cycle})\n\n## Summary\n\n${review.summary}\n\n## Issues\n\n` +
|
|
1344
|
+
review.issues.map((i) => `- **[${i.severity}]** ${i.title} — ${i.detail} (\`${i.location}\`)`).join('\n') +
|
|
1345
|
+
'\n';
|
|
1346
|
+
await ensureDir(mdPath);
|
|
1347
|
+
await writeFile(mdPath, md, 'utf8');
|
|
1348
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${mdPath}`, raw: { mock: true, file: mdPath } });
|
|
1349
|
+
}
|
|
1350
|
+
if (jsonPath) {
|
|
1351
|
+
await ensureDir(jsonPath);
|
|
1352
|
+
await writeFile(jsonPath, JSON.stringify(review, null, 2) + '\n', 'utf8');
|
|
1353
|
+
safeEmit(onEvent, { type: 'tool_use', text: `wrote ${jsonPath}`, raw: { mock: true, file: jsonPath } });
|
|
1354
|
+
}
|
|
1355
|
+
return JSON.stringify(review);
|
|
1356
|
+
}
|