@worca/app 1.0.0 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -34,14 +34,116 @@
34
34
 
35
35
  import { spawn } from 'node:child_process';
36
36
  import { createInterface } from 'node:readline';
37
- import { prepareModelEnv } from './model-env.mjs';
37
+ import { prepareModelEnv, envFlag, describeModelEnv } from './model-env.mjs';
38
+ import { effectiveDebugSpawn } from './settings.mjs';
38
39
  import { classifyError, strongestClass } from './recoverable-error.mjs';
40
+ import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
41
+ import { hostGuardEnabled, hostGuardHookEntry, hostGuardSystemPrompt } from './host-guard.mjs';
39
42
  import { writeFile, mkdir, appendFile, readFile, access } from 'node:fs/promises';
40
- import { constants as FS } from 'node:fs';
43
+ import { constants as FS, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
41
44
  import { dirname, join } from 'node:path';
45
+ import { tmpdir } from 'node:os';
42
46
 
43
47
  const DEFAULT_BIN = process.env.WORCA_CLAUDE_BIN || process.env.ORCH_CLAUDE_BIN || 'claude';
44
48
 
49
+ // Grace between the abort SIGTERM and the SIGKILL escalation. Claude Code shuts
50
+ // down synchronously (fsync'd ~/.claude.json saves); a SIGKILL that lands inside
51
+ // such a write strands ~/.claude.json.tmp.<pid>.<hex> (2026-08-30: 2099 files,
52
+ // 4.4 GB, all from test runs under IO load). 5 s is generous on an idle disk
53
+ // (SIGTERM exits in ~0.5 s) and only delays a stop when the child is wedged.
54
+ export const DEFAULT_SIGKILL_GRACE_MS = 5000;
55
+ export function sigkillGraceMs() {
56
+ const n = Number(process.env.WORCA_SIGKILL_GRACE_MS);
57
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_SIGKILL_GRACE_MS;
58
+ }
59
+
60
+ /** What `--settings` carries, or null when there is nothing to carry (no hook
61
+ * telemetry, no permission rules, no host guard) — then the flag is omitted
62
+ * entirely. `hostGuard` (set by runReal, gated by hostGuardEnabled) merges the
63
+ * host-process-protection PreToolUse hook into the SAME single payload; the
64
+ * returned `hook` flag stays telemetry-only (it drives --include-hook-events,
65
+ * which the guard does not need). */
66
+ export function buildSettingsPayload(permissionRules, { hostGuard = false } = {}) {
67
+ const hook = buildHookSettings();
68
+ const guard = hostGuard && hostGuardEnabled() ? hostGuardHookEntry() : null;
69
+ const hasRules = !!permissionRules && Object.values(permissionRules).some((a) => Array.isArray(a) && a.length);
70
+ // Present-but-malformed rules (e.g. `{deny: 'Bash(curl:*)'}`) make the object
71
+ // truthy while hasRules stays false, so the whole policy would drop out of
72
+ // argv silently. Say it once, then take the same no-rules path (fail-open,
73
+ // matching the guardrail-set read path) — the empty/absent cases ({}, {deny: []}, null)
74
+ // are normal and stay quiet.
75
+ if (!hasRules && permissionRules && typeof permissionRules === 'object'
76
+ && Object.values(permissionRules).some((a) => a != null && !Array.isArray(a))) {
77
+ console.warn('[worca] guardrails: permissionRules is malformed (deny/allow/ask must be arrays of strings) — ignoring it; this spawn carries NO permission rules');
78
+ }
79
+ if (!hook && !hasRules && !guard) return null;
80
+ const settings = {};
81
+ if (hook) settings.hooks = { ...hook.hooks };
82
+ if (guard) {
83
+ settings.hooks = settings.hooks ?? {};
84
+ settings.hooks.PreToolUse = [...(settings.hooks.PreToolUse ?? []), guard];
85
+ }
86
+ if (hasRules) settings.permissions = permissionRules;
87
+ return { hook: !!hook, settings };
88
+ }
89
+
90
+ /**
91
+ * Largest command line we hand to spawn() inline (GH #380). Windows caps the
92
+ * whole CreateProcess command line at 32,767 chars and Linux caps a single
93
+ * argument at 128 KiB, and a real task prompt (a 1000-line markdown plus the
94
+ * rendered channel artifacts) sails past both — `spawn ENAMETOOLONG` / E2BIG at
95
+ * the first node. Above this limit the prompt travels on stdin and the system
96
+ * prompt / settings as files (planClaudeInvocation); below it the argv is
97
+ * byte-identical to what it always was. The figure leaves ~12K of headroom
98
+ * under the Windows cap for the exe path, quoting, and flags this measure
99
+ * cannot see, and is deliberately platform-independent so the offload path is
100
+ * exercised (and testable) everywhere, not only on Windows.
101
+ *
102
+ * Inline JSON, or the path of a file holding that same JSON when the invocation
103
+ * is staged (GH #380 — the CLI accepts either).
104
+ */
105
+ export const ARGV_INLINE_LIMIT = 20000;
106
+
107
+ /** Conservative size of the command line spawn() would build: every argument
108
+ * quoted and space-separated, after the binary. Over-counts slightly on
109
+ * purpose (a prompt with embedded quotes grows under Windows escaping). */
110
+ export function argvLength(bin, args) {
111
+ return String(bin || '').length + args.reduce((n, a) => n + String(a).length + 3, 0);
112
+ }
113
+
114
+ const ARGV_VALUE_PREVIEW = 64;
115
+
116
+ /** A copy of `args` safe to log: EVERY token longer than ARGV_VALUE_PREVIEW is
117
+ * shortened to a 64-char prefix + "…(<N> chars)". Token-level, not flag-aware, on
118
+ * purpose: an inline prompt, the `--settings` JSON (uncapped for a custom rule
119
+ * set), `--allowedTools`, `--mcp-config` — any free-text value buildClaudeArgs
120
+ * adds later — is capped without this list having to track it. Flags and short
121
+ * values pass through verbatim, so argv order is always preserved. Pure. */
122
+ export function redactArgvForLog(args) {
123
+ return args.map((a) => {
124
+ const v = String(a);
125
+ return v.length > ARGV_VALUE_PREVIEW ? `${v.slice(0, ARGV_VALUE_PREVIEW)}…(${v.length} chars)` : v;
126
+ });
127
+ }
128
+
129
+ /** Log each npm-shim resolution once per process, not once per spawn. */
130
+ const _resolveNoted = new Set();
131
+
132
+ /** The spawn-failure Error for `bin`: the OS message, plus the Windows npm-shim
133
+ * explanation when that is what actually went wrong (ENOENT on a bare name
134
+ * whose only PATH hit is claude.cmd; EINVAL on an explicit .cmd). */
135
+ function spawnFailure(bin, err, prefix) {
136
+ const unspawnable = /ENOENT|EINVAL/.test(String(err && err.code || err && err.message || ''));
137
+ const hint = unspawnable ? explainUnspawnableClaude(bin) : null;
138
+ const out = new Error(`${prefix}: ${err.message}${hint ? ` — ${hint}` : ''}`);
139
+ // An unspawnable CLI (not installed / not on PATH) is user-fixable, not a
140
+ // pipeline bug: stamp the recovery class so the orchestrator's gate pauses
141
+ // the run for manual resume instead of hard-failing it (ENOENT matches no
142
+ // message-sniff pattern, so without the stamp it would classify null).
143
+ if (unspawnable) out.errorClass = 'network';
144
+ return out;
145
+ }
146
+
45
147
  // Cap for the stderr detail embedded in a non-zero-exit Error message. The
46
148
  // audit trail and the UI error banner consume that message; an uncapped
47
149
  // stderrBuf (hundreds of KB of MCP/retry chatter) must not ride into them when
@@ -80,10 +182,30 @@ export function buildEffortArgs(effort) {
80
182
  * and the baseline sub-agent lifecycle (tool_use/tool_result) is unaffected.
81
183
  */
82
184
  export function subagentHooksEnabled() {
83
- const v = process.env.WORCA_SUBAGENT_HOOKS;
84
- return !!v && v !== '0' && v.toLowerCase() !== 'false';
185
+ return envFlag('WORCA_SUBAGENT_HOOKS');
85
186
  }
86
187
 
188
+ /**
189
+ * Opt-in spawn diagnostics, DEFAULT OFF. A NON-EMPTY WORCA_DEBUG_SPAWN in the
190
+ * environment wins (envFlag rule: any value but "0"/"false" turns it on, so an
191
+ * exported "0" is an explicit OFF); otherwise the stored `debugSpawnEnabled`
192
+ * setting applies — read fresh per spawn (settings.mjs#effectiveDebugSpawn, the
193
+ * one precedence rule the settings API also reports), so the UI checkbox reaches
194
+ * the next spawn in this process AND in a CLI run with no restart and no env
195
+ * mutation. OFF ⇒ runReal emits NO spawn-debug event and does not touch
196
+ * argv/env, so the spawn path is byte-identical to today. (The once-per-process
197
+ * "routing env applied" confirmation below is a separate, always-on line: it
198
+ * fires only for a model env that carries an ANTHROPIC_* routing key, once per
199
+ * distinct model + env, never per spawn.) Read directly in runReal (not a
200
+ * runClaude option) so it bypasses the runClaude→runReal gate by construction.
201
+ */
202
+ export function debugSpawnEnabled() {
203
+ return effectiveDebugSpawn().enabled;
204
+ }
205
+
206
+ /** Once per process per distinct (model, described env): see runReal. */
207
+ const _routingNoted = new Set();
208
+
87
209
  // ── Sub-agent telemetry + the --settings seam ────────────────────────────────
88
210
  // Telemetry is GATED (subagentHooksEnabled) and OFF by default. When on it adds
89
211
  // `--include-hook-events` (surfaces hook lifecycle on the SAME stdout stream)
@@ -108,27 +230,16 @@ export function buildHookSettings() {
108
230
  * flags would be last-wins at the CLI, silently dropping one payload.
109
231
  * [] when there is nothing to say, so the baseline argv is byte-identical.
110
232
  * @param {{deny?:string[],allow?:string[],ask?:string[]}|null|undefined} permissionRules
233
+ * @param {string|null} [settingsFile] staged path (GH #380): `--settings <path>` carries the same JSON
234
+ * @param {{hostGuard?:boolean}} [opts] host-process guard (runReal sets it; see buildSettingsPayload)
111
235
  * @returns {string[]}
112
236
  */
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;
237
+ export function buildSettingsArgs(permissionRules, settingsFile = null, { hostGuard = false } = {}) {
238
+ const payload = buildSettingsPayload(permissionRules, { hostGuard });
239
+ if (!payload) return [];
129
240
  const args = [];
130
- if (hook) args.push('--include-hook-events');
131
- args.push('--settings', JSON.stringify(settings));
241
+ if (payload.hook) args.push('--include-hook-events');
242
+ args.push('--settings', settingsFile || JSON.stringify(payload.settings));
132
243
  return args;
133
244
  }
134
245
 
@@ -185,10 +296,9 @@ export function buildSpawnEnv(envScrub, envAllowlist) {
185
296
  * passed through by the orchestrator (handled by caller mapping mock->env or
186
297
  * by passing systemPrompt/prompt markers; we also honor a `mock` field).
187
298
  */
188
- function mockEnabled(opts) {
299
+ export function mockEnabled(opts) {
189
300
  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';
301
+ return envFlag('WORCA_MOCK', 'ORCH_MOCK');
192
302
  }
193
303
 
194
304
  /**
@@ -217,10 +327,23 @@ function mockEnabled(opts) {
217
327
  * @param {string[]} [o.envAllowlist] guardrail: extra env var names to keep under scrub
218
328
  * @param {Record<string,string>} [o.modelEnv] per-model routing env (design §4.4), merged
219
329
  * 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
330
+ * config outranks ambient-env hygiene); reserved keys are re-dropped here defensively.
331
+ * An ANTHROPIC_MODEL key is the WIRE id (#374): it replaces `model` in the spawned
332
+ * `--model` flag, while `model` (the catalog id) stays worca's handle everywhere else
221
333
  * @param {string[]} [o.workspaceWriteTargets] §8.10 MOCK-ONLY member checkouts the mock
222
334
  * implementer writes into instead of `cwd` (empty/absent => today's cwd behavior).
223
335
  * Never reaches argv: `runReal` ignores it by construction.
336
+ * @param {string[]} [o.tools] --tools <list>: the built-in tool allowlist ([] ⇒ `--tools ""`,
337
+ * no built-ins at all; MCP tools are unaffected). Absent ⇒ flag omitted (claude defaults).
338
+ * @param {boolean} [o.strictMcpConfig] --strict-mcp-config: only --mcp-config servers load
339
+ * @param {string[]} [o.settingSources] --setting-sources <list> (e.g. ['project'] drops user hooks/plugins/skills)
340
+ * @param {boolean} [o.disableSlashCommands] --disable-slash-commands
341
+ * @param {boolean} [o.includePartialMessages] --include-partial-messages (stream_event text deltas)
342
+ * @param {number} [o.maxTurns] --max-turns <n> (positive safe integer; else omitted)
343
+ * @param {number|null} [o.maxBudgetUsd] --max-budget-usd <n> (finite > 0; null/else omitted)
344
+ * @param {string} [o.appendSubagentSystemPrompt] --append-subagent-system-prompt <text> (Task children only)
345
+ * All eight are Ask Worca sandbox options (ask-worca-design.md §6.3) and default-off.
346
+ * @param {number} [o.argvInlineLimit] override ARGV_INLINE_LIMIT (GH #380; tests force the staged path)
224
347
  * @returns {Promise<{text:string, exitCode:number}>}
225
348
  */
226
349
  export async function runClaude(o = {}) {
@@ -247,6 +370,17 @@ export async function runClaude(o = {}) {
247
370
  modelEnv,
248
371
  workspaceWriteTargets,
249
372
  resumeSessionId,
373
+ // Ask Worca sandbox hardening (ask-worca-design.md §6.3/§6.8). All default-off:
374
+ // undefined here ⇒ nothing emitted ⇒ every legacy argv stays byte-identical.
375
+ tools,
376
+ strictMcpConfig,
377
+ settingSources,
378
+ disableSlashCommands,
379
+ includePartialMessages,
380
+ maxTurns,
381
+ maxBudgetUsd,
382
+ appendSubagentSystemPrompt,
383
+ argvInlineLimit,
250
384
  bin = DEFAULT_BIN,
251
385
  } = o;
252
386
 
@@ -261,7 +395,7 @@ export async function runClaude(o = {}) {
261
395
  // workspaceWriteTargets is the one option that is mock-ONLY (§8.10) and it must be
262
396
  // named HERE too, or the mock implementer never sees it (this call is a gate, not
263
397
  // a pass-through; test/spawn-args.test.mjs asserts the forwarding end to end).
264
- return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets });
398
+ return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets, permissionMode });
265
399
  }
266
400
 
267
401
  return runReal({
@@ -282,6 +416,15 @@ export async function runClaude(o = {}) {
282
416
  envScrub,
283
417
  envAllowlist,
284
418
  modelEnv,
419
+ tools,
420
+ strictMcpConfig,
421
+ settingSources,
422
+ disableSlashCommands,
423
+ includePartialMessages,
424
+ maxTurns,
425
+ maxBudgetUsd,
426
+ appendSubagentSystemPrompt,
427
+ argvInlineLimit,
285
428
  });
286
429
  }
287
430
 
@@ -308,11 +451,23 @@ export async function runClaude(o = {}) {
308
451
  export function buildClaudeArgs({
309
452
  prompt, systemPrompt, permissionMode, model, effort, allowedTools, resumeSessionId,
310
453
  mcpConfigPath, mcpServerGrants, permissionRules,
311
- }) {
312
- const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--permission-mode', permissionMode];
454
+ // Ask Worca hardening options (ask-worca-design.md §6.3). `tools` is renamed on the
455
+ // way in because the legacy body below already owns a local `tools` (the
456
+ // --allowedTools union).
457
+ tools: builtinTools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
458
+ maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, hostGuard,
459
+ }, delivery = {}) {
460
+ // delivery (GH #380, set only by planClaudeInvocation's staged branch):
461
+ // promptViaStdin -> bare `-p`; the prompt is written to the child's stdin
462
+ // systemPromptFile -> `--append-system-prompt-file <path>` instead of the text
463
+ // settingsFile -> `--settings <path>` instead of the inline JSON
464
+ const { promptViaStdin = false, systemPromptFile = null, settingsFile = null } = delivery;
465
+ const args = promptViaStdin ? ['-p'] : ['-p', prompt];
466
+ args.push('--output-format', 'stream-json', '--verbose', '--permission-mode', permissionMode);
313
467
  if (resumeSessionId) args.push('--resume', resumeSessionId);
314
468
  if (systemPrompt) {
315
- args.push('--append-system-prompt', systemPrompt);
469
+ if (systemPromptFile) args.push('--append-system-prompt-file', systemPromptFile);
470
+ else args.push('--append-system-prompt', systemPrompt);
316
471
  }
317
472
  if (model) {
318
473
  args.push('--model', model);
@@ -323,7 +478,7 @@ export function buildClaudeArgs({
323
478
  // SINGLE inline JSON (two --settings flags would be last-wins at the CLI). [] when
324
479
  // there is neither, so the baseline argv is unchanged; a CLI that rejects these
325
480
  // flags would only ever fail when the operator opted in.
326
- for (const a of buildSettingsArgs(permissionRules)) args.push(a);
481
+ for (const a of buildSettingsArgs(permissionRules, settingsFile, { hostGuard })) args.push(a);
327
482
  if (mcpConfigPath) args.push('--mcp-config', mcpConfigPath);
328
483
  const tools = Array.isArray(allowedTools) ? allowedTools.slice() : [];
329
484
  for (const s of (Array.isArray(mcpServerGrants) ? mcpServerGrants : [])) {
@@ -332,43 +487,231 @@ export function buildClaudeArgs({
332
487
  if (tools.length) {
333
488
  args.push('--allowedTools', tools.join(','));
334
489
  }
490
+ // ── Ask Worca hardening flags (ask-worca-design.md §6.3 / §6.8) ──────────────
491
+ // Every one is default-off: absent / false / invalid ⇒ NOTHING is emitted, so
492
+ // every legacy argv stays byte-identical (test/spawn-args.test.mjs). Appended
493
+ // AFTER the legacy block so the baseline prefix never moves. Probed on 2.1.239:
494
+ // `--tools ""` = no built-in tools (MCP tools survive); the hidden `--max-turns`
495
+ // and `--append-subagent-system-prompt` are accepted and enforced.
496
+ // Filter to usable names FIRST, then decide: testing the RAW list while emitting
497
+ // the FILTERED join made `settingSources: [1]` emit `--setting-sources ""` (where
498
+ // `[]` emits nothing) and `['Read', '']` emit a trailing comma.
499
+ const names = (v) => (Array.isArray(v) ? v.filter((s) => typeof s === 'string' && s) : []);
500
+ // --tools is the one list whose empty value is meaningful (`--tools ""` = no
501
+ // built-in tools at all, §6.3), so the ARRAY decides whether the flag is emitted.
502
+ if (Array.isArray(builtinTools)) {
503
+ args.push('--tools', names(builtinTools).join(','));
504
+ }
505
+ if (strictMcpConfig === true) args.push('--strict-mcp-config');
506
+ const sources = names(settingSources);
507
+ if (sources.length) {
508
+ args.push('--setting-sources', sources.join(','));
509
+ }
510
+ if (disableSlashCommands === true) args.push('--disable-slash-commands');
511
+ if (includePartialMessages === true) args.push('--include-partial-messages');
512
+ if (Number.isSafeInteger(maxTurns) && maxTurns > 0) args.push('--max-turns', String(maxTurns));
513
+ if (typeof maxBudgetUsd === 'number' && Number.isFinite(maxBudgetUsd) && maxBudgetUsd > 0) {
514
+ args.push('--max-budget-usd', String(maxBudgetUsd));
515
+ }
516
+ if (typeof appendSubagentSystemPrompt === 'string' && appendSubagentSystemPrompt) {
517
+ args.push('--append-subagent-system-prompt', appendSubagentSystemPrompt);
518
+ }
335
519
  return args;
336
520
  }
337
521
 
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
- });
522
+ /**
523
+ * Decide how ONE invocation reaches the CLI (GH #380). Pure: no I/O.
524
+ * - inline (the common case): `args` is exactly buildClaudeArgs(opts); `stdin`
525
+ * null; `files` empty.
526
+ * - staged (argv over `limit`): the prompt goes on stdin (`-p` reads it — the
527
+ * model sees the exact text, unlike a "read this file" instruction), the
528
+ * system prompt and the settings JSON become files under `dir`, and no
529
+ * argument carries free text any more, so the argv is short by construction.
530
+ * @param {object} opts the buildClaudeArgs options
531
+ * @param {{bin?:string, dir?:string|(() => string), limit?:number}} [o] `dir` may be a
532
+ * factory, called only when staging is actually needed (so the caller creates
533
+ * a temp dir exactly when one will be used)
534
+ * @returns {{args:string[], stdin:string|null, files:{path:string,content:string}[], staged:boolean, inlineLength:number}}
535
+ */
536
+ export function planClaudeInvocation(opts, { bin = DEFAULT_BIN, dir = null, limit = ARGV_INLINE_LIMIT } = {}) {
537
+ const inline = buildClaudeArgs(opts);
538
+ const inlineLength = argvLength(bin, inline);
539
+ if (inlineLength <= limit) return { args: inline, stdin: null, files: [], staged: false, inlineLength };
540
+ if (!dir) throw new Error('planClaudeInvocation: a staging dir is required when the argv is over the limit');
541
+ if (typeof dir === 'function') dir = dir();
542
+ const files = [];
543
+ const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
544
+ const promptViaStdin = prompt.length > 0; // an empty prompt stays `-p ''` — nothing to pipe
545
+ let systemPromptFile = null;
546
+ if (opts.systemPrompt) {
547
+ systemPromptFile = join(dir, 'system-prompt.md');
548
+ files.push({ path: systemPromptFile, content: opts.systemPrompt });
549
+ }
550
+ let settingsFile = null;
551
+ const payload = buildSettingsPayload(opts.permissionRules, { hostGuard: opts.hostGuard });
552
+ if (payload) {
553
+ settingsFile = join(dir, 'settings.json');
554
+ files.push({ path: settingsFile, content: JSON.stringify(payload.settings) });
555
+ }
556
+ const args = buildClaudeArgs(opts, { promptViaStdin, systemPromptFile, settingsFile });
557
+ return { args, stdin: promptViaStdin ? prompt : null, files, staged: true, inlineLength };
558
+ }
344
559
 
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);
560
+ /** planClaudeInvocation + the I/O: a private temp dir is created and the files
561
+ * written ONLY on the staged branch (`dir` is null otherwise, so the caller has
562
+ * nothing to clean up). Synchronous on purpose — a few hundred KB once per
563
+ * spawn, and it keeps the spawn sequence in runReal linear. */
564
+ export function stageClaudeInvocation(opts, { bin = DEFAULT_BIN, limit = ARGV_INLINE_LIMIT } = {}) {
565
+ let dir = null;
566
+ const plan = planClaudeInvocation(opts, { bin, limit, dir: () => (dir = mkdtempSync(join(tmpdir(), 'worca-claude-'))) });
567
+ for (const file of plan.files) writeFileSync(file.path, file.content, 'utf8');
568
+ return { ...plan, dir };
569
+ }
348
570
 
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;
571
+ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, model, effort, onEvent, signal, bin, resumeSessionId, mcpConfigPath, mcpServerGrants, permissionRules, envScrub, envAllowlist, modelEnv, tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages, maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, argvInlineLimit }) {
572
+ return new Promise((resolveP, rejectP) => {
573
+ // Per-model routing env (design §4.4), prepared BEFORE argv: reserved keys
574
+ // are re-dropped here defensively the write path already rejects them, so
575
+ // a drop means a hand-edited settings file and the surviving map is also
576
+ // where the wire id (below) is read from.
577
+ let safeModelEnv = null;
578
+ let wireModelDropped = false;
357
579
  if (modelEnv && Object.keys(modelEnv).length) {
358
580
  const { env: safe, dropped } = prepareModelEnv(modelEnv);
359
581
  for (const k of dropped) {
360
582
  console.warn(`[worca] modelEnv: dropping reserved/invalid key ${JSON.stringify(k)}`);
361
583
  }
362
- if (Object.keys(safe).length) spawnEnv = { ...(guardrailEnv ?? process.env), ...safe };
584
+ // A configured wire id that didn't survive (unresolvable ${VAR}, empty, or
585
+ // whitespace-only) fell into `dropped`: we silently fall back to the catalog
586
+ // id below, so warn specifically — the generic drop line above doesn't say
587
+ // the argv model changed, and the wire-model line never fires (ids match).
588
+ wireModelDropped = 'ANTHROPIC_MODEL' in modelEnv && dropped.includes('ANTHROPIC_MODEL');
589
+ if (Object.keys(safe).length) safeModelEnv = safe;
590
+ }
591
+
592
+ // Wire id (#374): ANTHROPIC_MODEL in the resolved model env names the id the
593
+ // ENDPOINT should see; the catalog id stays worca's handle (config refs, cost
594
+ // flags). Passed as an explicit --model — self-documenting in logs and immune
595
+ // to CLI flag/env precedence — so the env var alone would otherwise be dead.
596
+ const wireModel = safeModelEnv?.ANTHROPIC_MODEL || model;
597
+ if (wireModelDropped && wireModel === model) {
598
+ console.warn(`[worca] model ${JSON.stringify(model ?? '')}: configured wire model was dropped (unresolved/empty) — using the catalog id`);
599
+ } else if (wireModel !== model) {
600
+ console.warn(`[worca] model ${JSON.stringify(model ?? '')}: wire model ${JSON.stringify(wireModel)}`);
601
+ }
602
+ // Confirm a resolved card's routing env actually reached a spawn — even when
603
+ // the wire id equals the catalog id, the case the wire-model line above stays
604
+ // silent for (that silence is exactly what hid a gateway card whose
605
+ // ANTHROPIC_MODEL matched its catalog id). Fires only for an env that carries
606
+ // an ANTHROPIC_* routing key (Ask Worca merges a CLAUDE_CODE_* knob into
607
+ // EVERY turn's env, which is not routing) and once per process per distinct
608
+ // line, like _resolveNoted — never per spawn. describeModelEnv prints the
609
+ // routing keys readable (endpoint, wire id — the diagnostic) and every other
610
+ // key as `<set, N chars>`: ANTHROPIC_AUTH_TOKEN and plugin {secret} values live
611
+ // in this map and no part of them may reach a log. Worded WITHOUT the
612
+ // substrings "wire model"/"modelEnv" — test/spawn-args.test.mjs counts by those.
613
+ const routingApplied = safeModelEnv && Object.keys(safeModelEnv).some((k) => k.startsWith('ANTHROPIC_'))
614
+ ? describeModelEnv(safeModelEnv) : null;
615
+ if (routingApplied) {
616
+ const line = `[worca] model ${JSON.stringify(model ?? '')}: routing env applied: ${routingApplied}`;
617
+ if (!_routingNoted.has(line)) { _routingNoted.add(line); console.warn(line); }
618
+ }
619
+
620
+ // Windows + npm-installed Claude Code: the bare name is a .cmd shim Node
621
+ // cannot spawn; resolveClaudeBin swaps in the package's native claude.exe.
622
+ // Everywhere else this is `bin` unchanged. Resolved BEFORE the argv plan so
623
+ // the command-line measure below counts the path that is actually spawned.
624
+ const resolved = resolveClaudeBin(bin);
625
+ if (resolved.note && !_resolveNoted.has(resolved.bin)) {
626
+ _resolveNoted.add(resolved.bin);
627
+ console.warn(`[worca] ${resolved.note}`);
628
+ }
629
+
630
+ // GH #380: inline argv when it fits, else prompt on stdin + files (see
631
+ // ARGV_INLINE_LIMIT). The staging dir, when any, is removed on every
632
+ // terminal path below (finish) and on a failed spawn.
633
+ const limit = Number.isFinite(argvInlineLimit) && argvInlineLimit > 0 ? argvInlineLimit : ARGV_INLINE_LIMIT;
634
+ // Host guard (host-guard.mjs, 2026-08-31 incident): every REAL spawn — any
635
+ // role, custom agent, plugin agent, ask chat — carries the protection
636
+ // preamble, the PreToolUse hook (hostGuard -> the --settings payload), and
637
+ // WORCA_HOST_PID (below). One kill-switch: WORCA_HOST_GUARD=0. Mock spawns
638
+ // nothing, so runMock stays untouched.
639
+ const guardOn = hostGuardEnabled();
640
+ const guardedSystemPrompt = guardOn
641
+ ? [hostGuardSystemPrompt(process.pid), systemPrompt].filter(Boolean).join('\n\n')
642
+ : systemPrompt;
643
+ let plan;
644
+ try {
645
+ plan = stageClaudeInvocation({
646
+ prompt, systemPrompt: guardedSystemPrompt, hostGuard: guardOn,
647
+ permissionMode, model: wireModel, effort, allowedTools, resumeSessionId,
648
+ mcpConfigPath, mcpServerGrants, permissionRules,
649
+ tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
650
+ maxTurns, maxBudgetUsd, appendSubagentSystemPrompt,
651
+ }, { bin: resolved.bin, limit });
652
+ } catch (err) {
653
+ rejectP(new Error(`Failed to stage the claude prompt files: ${err.message}`));
654
+ return;
655
+ }
656
+ const { args } = plan;
657
+ const cleanupStaged = () => {
658
+ if (!plan.dir) return;
659
+ try { rmSync(plan.dir, { recursive: true, force: true }); } catch { /* best effort */ }
660
+ };
661
+ if (plan.staged) {
662
+ console.warn(`[worca] claude argv would be ${plan.inlineLength} chars (limit ${limit}): prompt on stdin, system prompt/settings as files`);
663
+ }
664
+
665
+ // undefined when the guardrail is off, and the spread then adds NO `env` key —
666
+ // spawn inherits process.env exactly as it did before guardrails existed.
667
+ const guardrailEnv = buildSpawnEnv(envScrub, envAllowlist);
668
+
669
+ // Model env merges LAST: it survives scrub and wins collisions (explicit
670
+ // operator config outranks ambient-env hygiene). With no modelEnv (or
671
+ // nothing surviving the filter) the spawn env is byte-identical to the
672
+ // pre-feature behavior, including the undefined -> inherit-process.env case.
673
+ let spawnEnv = guardrailEnv;
674
+ if (safeModelEnv) spawnEnv = { ...(guardrailEnv ?? process.env), ...safeModelEnv };
675
+
676
+ // WORCA_HOST_PID rides every guarded spawn (the hook reads it; scrub would
677
+ // drop it — WORCA_ is not an allowlisted prefix — so it is added AFTER).
678
+ if (guardOn) spawnEnv = { ...(spawnEnv ?? process.env), WORCA_HOST_PID: String(process.pid) };
679
+
680
+ // Opt-in spawn diagnostics (WORCA_DEBUG_SPAWN, default off — byte-identical spawn
681
+ // path when unset). Everything here is derived from values already computed above
682
+ // (safeModelEnv is null or non-empty, so the routing field is either the described
683
+ // env — secrets as `<set, N chars>` — or "(none)"). Emitted right before spawn so
684
+ // it reflects the exact bin/argv/env handed to the child, ONCE, as the same
685
+ // `stderr` event the child's own stderr rides (run-harness logs it at `warn`
686
+ // into the run stream and live-log.ndjson; nothing here also console.warns, so
687
+ // a run never prints the line twice). Field is `routingEnv`, not `modelEnv`:
688
+ // test/spawn-args.test.mjs counts "modelEnv" warnings for the dropped-key path.
689
+ if (debugSpawnEnabled()) {
690
+ const summary =
691
+ `[worca] spawn-debug: bin=${JSON.stringify(resolved.bin)} `
692
+ + `argv=${JSON.stringify(redactArgvForLog(args))} `
693
+ + `promptViaStdin=${plan.stdin != null} staged=${plan.staged} `
694
+ + `envScrub=${guardrailEnv ? 'on' : 'off'} childEnvKeys=${Object.keys(spawnEnv ?? process.env).length} `
695
+ + `routingEnv=[${safeModelEnv ? describeModelEnv(safeModelEnv) : '(none)'}]`;
696
+ safeEmit(onEvent, { type: 'stderr', stream: 'err', text: summary });
363
697
  }
364
698
 
365
699
  let child;
366
700
  try {
367
- child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}) });
701
+ child = spawn(resolved.bin, args, {
702
+ cwd, stdio: [plan.stdin != null ? 'pipe' : 'ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}),
703
+ });
368
704
  } catch (err) {
369
- rejectP(new Error(`Failed to spawn ${bin}: ${err.message}`));
705
+ cleanupStaged();
706
+ rejectP(spawnFailure(bin, err, `Failed to spawn ${bin}`));
370
707
  return;
371
708
  }
709
+ if (plan.stdin != null) {
710
+ // A child that dies before draining stdin (bad flag, ENOENT surfaced
711
+ // late) raises EPIPE here; the 'error'/'close' handlers own the real cause.
712
+ child.stdin.on('error', () => {});
713
+ child.stdin.end(plan.stdin, 'utf8');
714
+ }
372
715
 
373
716
  let resultText = '';
374
717
  let assistantText = '';
@@ -396,7 +739,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
396
739
  } catch {
397
740
  /* ignore */
398
741
  }
399
- }, 1500).unref?.();
742
+ }, sigkillGraceMs()).unref?.();
400
743
  };
401
744
  if (signal) {
402
745
  if (signal.aborted) onAbort();
@@ -407,6 +750,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
407
750
  if (settled) return;
408
751
  settled = true;
409
752
  if (signal) signal.removeEventListener?.('abort', onAbort);
753
+ cleanupStaged();
410
754
  fn(arg);
411
755
  };
412
756
 
@@ -486,7 +830,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
486
830
  });
487
831
 
488
832
  child.on('error', (err) => {
489
- finish(rejectP, new Error(`${bin} error: ${err.message}`));
833
+ finish(rejectP, spawnFailure(bin, err, `${bin} error`));
490
834
  });
491
835
 
492
836
  child.on('close', (code) => {
@@ -611,6 +955,24 @@ async function emitLog(onEvent, text) {
611
955
  await new Promise((r) => setTimeout(r, 0));
612
956
  }
613
957
 
958
+ /**
959
+ * The roles the offline mock runner can SERVE — one per arm of the role switch
960
+ * below (the `ask` arm is the Ask-Worca assistant, not a writer role). Exported
961
+ * because three consumers need the vocabulary and none of them may hard-code it:
962
+ * meta v2 validation (an unknown `mockRole` is a warning + drop), GET /api/agents
963
+ * (the Agents view's role picker) and the graph executor's mock-role chain.
964
+ * test/mock-writer-roles.test.mjs parses the switch and pins the lockstep.
965
+ */
966
+ export const MOCK_WRITER_ROLES = new Set([
967
+ 'clarify', 'planner-plan', 'refiner', 'decomposer', 'implementer', 'reviewer', 'plan-review',
968
+ 'workspace-scan', 'agent-gen', 'workspace-reviewer', 'manual-tests-checklist', 'manual-web-ui-testing',
969
+ 'generic-producer', 'generic-verifier',
970
+ ]);
971
+
972
+ /** Named so the executor's mock-role chain and the switch cannot drift apart. */
973
+ export const MOCK_ROLE_CLARIFY = 'clarify';
974
+ export const MOCK_ROLE_DECOMPOSER = 'decomposer';
975
+
614
976
  /**
615
977
  * The mock-fan-out roles (mirror the orchestrator's FANOUT_ELIGIBLE intent): the
616
978
  * roles whose real runs may spawn sub-agents. Keyed by the MOCK_ROLE strings.
@@ -675,6 +1037,109 @@ async function emitMockSubAgents(role, onEvent, signal) {
675
1037
  }
676
1038
  }
677
1039
 
1040
+ // ── Ask Worca mock role (ask-worca-design.md §6.7) ───────────────────────────
1041
+
1042
+ /** Emit a raw stream-json frame through the SAME envelope runReal uses (the rl 'line' handler above). */
1043
+ function emitRaw(onEvent, raw) {
1044
+ const cost = extractResultCost(raw);
1045
+ const text = extractText(raw);
1046
+ safeEmit(onEvent, { type: raw.type, raw, text: text || undefined, ...(cost != null ? { costUsd: cost } : {}) });
1047
+ }
1048
+
1049
+ const ASK_CONTEXT_BLOCK_RE = /\[worca context\][\s\S]*?\[\/worca context\]\s*/;
1050
+
1051
+ /**
1052
+ * The offline Ask Worca assistant: frames in the shapes probed on claude 2.1.239
1053
+ * (system/init → message_start → text deltas → assistant blocks → tool_use /
1054
+ * tool_result pairs → message_delta → result), chosen from the USER text so
1055
+ * tests control the scenario. Never touches the filesystem, never reads prompt
1056
+ * markers, never spawns the MCP child. The limit / failure scenarios emit their
1057
+ * `result` frame and then REJECT exactly like the real CLI (exit 1, empty stderr).
1058
+ */
1059
+ async function mockAsk({ markers, prompt, cwd, onEvent, signal, resumeSessionId }) {
1060
+ const userText = String(prompt ?? '').replace(ASK_CONTEXT_BLOCK_RE, '');
1061
+ let card = {};
1062
+ try { card = markers.MOCK_ASK_CARD ? JSON.parse(markers.MOCK_ASK_CARD) : {}; } catch { card = {}; }
1063
+ if (!card || typeof card !== 'object' || Array.isArray(card)) card = {};
1064
+ const fail = /\bMOCK_FAIL\b/.test(userText);
1065
+ const maxTurns = /\bMOCK_MAX_TURNS\b/.test(userText);
1066
+ const maxBudget = /\bMOCK_MAX_BUDGET\b/.test(userText);
1067
+ const slow = /\bMOCK_SLOW\b/.test(userText);
1068
+ const agents = /\bagents?\b/i.test(userText);
1069
+ const propose = /\b(propose|start|run)\b/i.test(userText);
1070
+
1071
+ const SID = resumeSessionId || 'mock-session-ask-1';
1072
+ const USAGE = { input_tokens: 10, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
1073
+ const firstLine = userText.split(/\r?\n/).map((l) => l.trim()).find(Boolean) || '';
1074
+ const ANSWER = `[mock] ${firstLine.slice(0, 200)}`;
1075
+ const init = { type: 'system', subtype: 'init', session_id: SID, cwd, model: 'mock', permissionMode: 'dontAsk',
1076
+ tools: ['Task', 'mcp__worca__list_runs', 'mcp__worca__get_run', 'mcp__worca__propose_run'],
1077
+ mcp_servers: [{ name: 'worca', status: 'connected' }], plugins: [], skills: [], slash_commands: [], agents: [], uuid: 'mock-uuid-init' };
1078
+ const mstart = (id) => ({ type: 'stream_event', event: { type: 'message_start', message: { id, model: 'mock', role: 'assistant', content: [], usage: USAGE } }, parent_tool_use_id: null, session_id: SID });
1079
+ const delta = (t) => ({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: t } }, parent_tool_use_id: null, session_id: SID });
1080
+ const mdelta = { type: 'stream_event', event: { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: USAGE }, parent_tool_use_id: null, session_id: SID };
1081
+ const atext = (id, t) => ({ type: 'assistant', message: { id, model: 'mock', role: 'assistant', content: [{ type: 'text', text: t }], usage: USAGE }, parent_tool_use_id: null, session_id: SID });
1082
+ const atool = (id, toolId, name, input, ptu = null) => ({ type: 'assistant', message: { id, model: 'mock', role: 'assistant', content: [{ type: 'tool_use', id: toolId, name, input, caller: { type: 'direct' } }], usage: USAGE }, parent_tool_use_id: ptu, session_id: SID });
1083
+ const uresult = (toolId, text, ptu = null, extra = {}) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolId, content: [{ type: 'text', text }] }] }, parent_tool_use_id: ptu, session_id: SID, ...extra });
1084
+ const result = (over = {}) => ({ type: 'result', subtype: 'success', is_error: false, duration_ms: 10, duration_api_ms: 8, num_turns: 1, session_id: SID, total_cost_usd: 0,
1085
+ usage: USAGE, modelUsage: {}, permission_denials: [], terminal_reason: 'completed', result: ANSWER, ...over });
1086
+ const MSG1 = 'msg_mock_ask_1';
1087
+ const MSG2 = 'msg_mock_ask_2';
1088
+
1089
+ const frames = [init, mstart(MSG1)];
1090
+ if (fail) {
1091
+ frames.push(result({ subtype: 'error_during_execution', is_error: true, errors: ['mock failure'], terminal_reason: 'api_error', result: 'mock failure', num_turns: 0 }));
1092
+ } else if (maxTurns || maxBudget) {
1093
+ frames.push(delta('[mock] '), delta('partial'), atext(MSG1, '[mock] partial'),
1094
+ atool(MSG1, 'toolu_mock_1', 'mcp__worca__list_runs', {}), uresult('toolu_mock_1', '[]'));
1095
+ frames.push(maxTurns
1096
+ ? result({ subtype: 'error_max_turns', is_error: true, errors: ['Reached maximum number of turns (1)'], terminal_reason: 'max_turns', num_turns: 2, stop_reason: 'tool_use', result: undefined })
1097
+ : result({ subtype: 'error_max_budget_usd', is_error: true, errors: ['Reached maximum budget ($0.0001)'], terminal_reason: 'budget_exhausted', result: undefined }));
1098
+ } else {
1099
+ let answerMsg = MSG1;
1100
+ if (agents) {
1101
+ frames.push(
1102
+ atool(MSG1, 'toolu_mock_task', 'Agent', { description: 'count runs', subagent_type: 'general-purpose', prompt: 'count the runs' }),
1103
+ atool('msg_mock_child_1', 'toolu_mock_child_1', 'mcp__worca__list_runs', {}, 'toolu_mock_task'),
1104
+ uresult('toolu_mock_child_1', '[]', 'toolu_mock_task'),
1105
+ uresult('toolu_mock_task', 'count: 0', null, { tool_use_result: {
1106
+ status: 'completed', agentId: 'mock-agent-1', agentType: 'general-purpose', content: [{ type: 'text', text: 'count: 0' }],
1107
+ resolvedModel: 'mock-haiku', totalDurationMs: 10, totalTokens: 1234, totalToolUseCount: 1,
1108
+ usage: { input_tokens: 1000, output_tokens: 234, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
1109
+ } }),
1110
+ );
1111
+ answerMsg = MSG2;
1112
+ }
1113
+ if (propose) {
1114
+ frames.push(delta('[mock] '), delta('preparing '), delta('a run'), atext(MSG1, 'Preparing a run card.'),
1115
+ atool(MSG1, 'toolu_mock_propose', 'mcp__worca__propose_run', card), uresult('toolu_mock_propose', JSON.stringify({ ok: true })));
1116
+ answerMsg = MSG2;
1117
+ }
1118
+ if (answerMsg !== MSG1) frames.push(mstart(answerMsg));
1119
+ frames.push(delta('[mock] '), delta(firstLine.slice(0, 200)), atext(answerMsg, ANSWER), mdelta);
1120
+ frames.push(result(agents
1121
+ ? { modelUsage: { 'mock-haiku': { inputTokens: 1000, outputTokens: 234, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, costUSD: 0, canonicalModel: 'mock-haiku' } } }
1122
+ : {}));
1123
+ }
1124
+
1125
+ safeEmit(onEvent, { type: 'session', sessionId: SID });
1126
+ for (const f of frames) {
1127
+ abortIfNeeded(signal);
1128
+ emitRaw(onEvent, f);
1129
+ await new Promise((r) => setTimeout(r, slow ? 300 : 0));
1130
+ }
1131
+ abortIfNeeded(signal);
1132
+ if (fail || maxTurns || maxBudget) {
1133
+ // Probed on 2.1.239: these subtypes exit 1 with EMPTY stderr, so runReal rejects with the
1134
+ // stdout `result` text (MOCK_FAIL) or 'no stderr' (the limits). turn.mjs (P2) reads the
1135
+ // reducer's resultSubtype before classifying the rejection.
1136
+ const err = new Error(`claude exited with code 1: ${fail ? 'mock failure' : 'no stderr'}`);
1137
+ err.errorClass = null;
1138
+ throw err;
1139
+ }
1140
+ return { text: ANSWER, exitCode: 0 };
1141
+ }
1142
+
678
1143
  function abortIfNeeded(signal) {
679
1144
  if (signal?.aborted) {
680
1145
  const err = new Error('aborted');
@@ -686,8 +1151,20 @@ function abortIfNeeded(signal) {
686
1151
  /**
687
1152
  * Offline mock: emits a few log lines and performs role-appropriate writes.
688
1153
  */
689
- async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets }) {
1154
+ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets, permissionMode }) {
690
1155
  abortIfNeeded(signal);
1156
+ // Ask Worca mock role (ask-worca-design.md §6.7): detected from the SYSTEM PROMPT
1157
+ // ONLY and dispatched before any prompt-sourced marker is honoured — a chat
1158
+ // message containing `MOCK_ASK: /x.json` (or any MOCK_* line) must never reach
1159
+ // the MOCK_ASK file-write arm below, and the user text can never pick the role.
1160
+ // `dontAsk` is the ask recipe's permission mode and has no legacy caller
1161
+ // (spawn.mjs:20), so it takes the ask arm markers or not: a P2 turn that forgot
1162
+ // `turn.mock` must not fall through to parseMarkers(prompt)/inferRole, where the
1163
+ // chat text alone picks a role that writes to the scratch cwd.
1164
+ const sysMarkers = parseMarkers('', systemPrompt);
1165
+ if (sysMarkers.MOCK_ROLE === 'ask' || permissionMode === 'dontAsk') {
1166
+ return mockAsk({ markers: sysMarkers, prompt, cwd, onEvent, signal, resumeSessionId });
1167
+ }
691
1168
  const m = parseMarkers(prompt, systemPrompt);
692
1169
  const role = m.MOCK_ROLE || inferRole(prompt, systemPrompt);
693
1170
  const cycle = Number(m.MOCK_CYCLE || '1') || 1;
@@ -704,7 +1181,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
704
1181
  // Ask-then-resume (spec 2026-07-11): asking replaces the role side effects
705
1182
  // for this invocation; the orchestrator gates the user and resumes. The
706
1183
  // session event above already fired, so the resume has a session id.
707
- if (m.MOCK_ASK) {
1184
+ if (m.MOCK_ASK && permissionMode !== 'dontAsk') { // belt and braces: dontAsk already took the ask arm above
708
1185
  await ensureDir(m.MOCK_ASK);
709
1186
  await writeFile(m.MOCK_ASK, JSON.stringify({
710
1187
  questions: [{ id: 'q1', question: `Mock question from ${role}?`, options: ['Option A', 'Option B'], allowFreeText: true }],
@@ -717,7 +1194,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
717
1194
 
718
1195
  let text = `[mock] role ${role} complete`;
719
1196
  switch (role) {
720
- case 'clarify':
1197
+ case MOCK_ROLE_CLARIFY:
721
1198
  text = await mockClarify(m, cycle, onEvent);
722
1199
  break;
723
1200
  case 'planner-plan':
@@ -726,7 +1203,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
726
1203
  case 'refiner':
727
1204
  text = await mockRefiner(m, cycle, onEvent);
728
1205
  break;
729
- case 'decomposer':
1206
+ case MOCK_ROLE_DECOMPOSER:
730
1207
  text = await mockDecomposer(m, onEvent);
731
1208
  break;
732
1209
  case 'implementer':
@@ -1210,13 +1687,15 @@ async function mockAgentGen(m, onEvent) {
1210
1687
  ? words[0] + words.slice(1).map((w) => w[0].toUpperCase() + w.slice(1)).join('')
1211
1688
  : 'customAgent';
1212
1689
  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,
1690
+ metaVersion: 2, key, displayName: name, description: `mock-generated agent for ${name}`,
1691
+ color: 'amber', runnerType: 'producer', domain: 'general', fanOut: false,
1692
+ asksQuestions: true, questionsLocked: false, questionsDefault: false, order: 99,
1693
+ inputs: [{ id: 'plan', type: 'md', label: 'Plan' }],
1694
+ outputs: [{ id: 'review', type: 'md', filename: 'review-{cycle}.md' }],
1217
1695
  };
1218
1696
  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`;
1697
+ const md = `# Agent: ${name}\n\nYou are ${name} (deterministic mock body).\n\n`
1698
+ + '## Ports\n\n- `plan` (in, md) — the plan to review.\n- `review` (out, md) — the review this agent writes.\n';
1220
1699
  await ensureDir(m.MOCK_OUT);
1221
1700
  await writeFile(m.MOCK_OUT, md, 'utf8');
1222
1701
  safeEmit(onEvent, { type: 'tool_use', text: `wrote ${m.MOCK_OUT}`, raw: { mock: true, file: m.MOCK_OUT } });