@worca/app 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -36,12 +36,82 @@ import { spawn } from 'node:child_process';
36
36
  import { createInterface } from 'node:readline';
37
37
  import { prepareModelEnv } from './model-env.mjs';
38
38
  import { classifyError, strongestClass } from './recoverable-error.mjs';
39
+ import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
39
40
  import { writeFile, mkdir, appendFile, readFile, access } from 'node:fs/promises';
40
- import { constants as FS } from 'node:fs';
41
+ import { constants as FS, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
41
42
  import { dirname, join } from 'node:path';
43
+ import { tmpdir } from 'node:os';
42
44
 
43
45
  const DEFAULT_BIN = process.env.WORCA_CLAUDE_BIN || process.env.ORCH_CLAUDE_BIN || 'claude';
44
46
 
47
+ // Grace between the abort SIGTERM and the SIGKILL escalation. Claude Code shuts
48
+ // down synchronously (fsync'd ~/.claude.json saves); a SIGKILL that lands inside
49
+ // such a write strands ~/.claude.json.tmp.<pid>.<hex> (2026-08-30: 2099 files,
50
+ // 4.4 GB, all from test runs under IO load). 5 s is generous on an idle disk
51
+ // (SIGTERM exits in ~0.5 s) and only delays a stop when the child is wedged.
52
+ export const DEFAULT_SIGKILL_GRACE_MS = 5000;
53
+ export function sigkillGraceMs() {
54
+ const n = Number(process.env.WORCA_SIGKILL_GRACE_MS);
55
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_SIGKILL_GRACE_MS;
56
+ }
57
+
58
+ /** What `--settings` carries, or null when there is nothing to carry (no hook
59
+ * telemetry, no permission rules) — then the flag is omitted entirely. */
60
+ export function buildSettingsPayload(permissionRules) {
61
+ const hook = buildHookSettings();
62
+ const hasRules = !!permissionRules && Object.values(permissionRules).some((a) => Array.isArray(a) && a.length);
63
+ // Present-but-malformed rules (e.g. `{deny: 'Bash(curl:*)'}`) make the object
64
+ // truthy while hasRules stays false, so the whole policy would drop out of
65
+ // argv silently. Say it once, then take the same no-rules path (fail-open,
66
+ // matching the guardrail-set read path) — the empty/absent cases ({}, {deny: []}, null)
67
+ // are normal and stay quiet.
68
+ if (!hasRules && permissionRules && typeof permissionRules === 'object'
69
+ && Object.values(permissionRules).some((a) => a != null && !Array.isArray(a))) {
70
+ console.warn('[worca] guardrails: permissionRules is malformed (deny/allow/ask must be arrays of strings) — ignoring it; this spawn carries NO permission rules');
71
+ }
72
+ if (!hook && !hasRules) return null;
73
+ const settings = {};
74
+ if (hook) settings.hooks = hook.hooks;
75
+ if (hasRules) settings.permissions = permissionRules;
76
+ return { hook: !!hook, settings };
77
+ }
78
+
79
+ /**
80
+ * Largest command line we hand to spawn() inline (GH #380). Windows caps the
81
+ * whole CreateProcess command line at 32,767 chars and Linux caps a single
82
+ * argument at 128 KiB, and a real task prompt (a 1000-line markdown plus the
83
+ * rendered channel artifacts) sails past both — `spawn ENAMETOOLONG` / E2BIG at
84
+ * the first node. Above this limit the prompt travels on stdin and the system
85
+ * prompt / settings as files (planClaudeInvocation); below it the argv is
86
+ * byte-identical to what it always was. The figure leaves ~12K of headroom
87
+ * under the Windows cap for the exe path, quoting, and flags this measure
88
+ * cannot see, and is deliberately platform-independent so the offload path is
89
+ * exercised (and testable) everywhere, not only on Windows.
90
+ *
91
+ * Inline JSON, or the path of a file holding that same JSON when the invocation
92
+ * is staged (GH #380 — the CLI accepts either).
93
+ */
94
+ export const ARGV_INLINE_LIMIT = 20000;
95
+
96
+ /** Conservative size of the command line spawn() would build: every argument
97
+ * quoted and space-separated, after the binary. Over-counts slightly on
98
+ * purpose (a prompt with embedded quotes grows under Windows escaping). */
99
+ export function argvLength(bin, args) {
100
+ return String(bin || '').length + args.reduce((n, a) => n + String(a).length + 3, 0);
101
+ }
102
+
103
+ /** Log each npm-shim resolution once per process, not once per spawn. */
104
+ const _resolveNoted = new Set();
105
+
106
+ /** The spawn-failure Error for `bin`: the OS message, plus the Windows npm-shim
107
+ * explanation when that is what actually went wrong (ENOENT on a bare name
108
+ * whose only PATH hit is claude.cmd; EINVAL on an explicit .cmd). */
109
+ function spawnFailure(bin, err, prefix) {
110
+ const hint = /ENOENT|EINVAL/.test(String(err && err.code || err && err.message || ''))
111
+ ? explainUnspawnableClaude(bin) : null;
112
+ return new Error(`${prefix}: ${err.message}${hint ? ` — ${hint}` : ''}`);
113
+ }
114
+
45
115
  // Cap for the stderr detail embedded in a non-zero-exit Error message. The
46
116
  // audit trail and the UI error banner consume that message; an uncapped
47
117
  // stderrBuf (hundreds of KB of MCP/retry chatter) must not ride into them when
@@ -108,27 +178,15 @@ export function buildHookSettings() {
108
178
  * flags would be last-wins at the CLI, silently dropping one payload.
109
179
  * [] when there is nothing to say, so the baseline argv is byte-identical.
110
180
  * @param {{deny?:string[],allow?:string[],ask?:string[]}|null|undefined} permissionRules
181
+ * @param {string|null} [settingsFile] staged path (GH #380): `--settings <path>` carries the same JSON
111
182
  * @returns {string[]}
112
183
  */
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;
184
+ export function buildSettingsArgs(permissionRules, settingsFile = null) {
185
+ const payload = buildSettingsPayload(permissionRules);
186
+ if (!payload) return [];
129
187
  const args = [];
130
- if (hook) args.push('--include-hook-events');
131
- args.push('--settings', JSON.stringify(settings));
188
+ if (payload.hook) args.push('--include-hook-events');
189
+ args.push('--settings', settingsFile || JSON.stringify(payload.settings));
132
190
  return args;
133
191
  }
134
192
 
@@ -185,7 +243,7 @@ export function buildSpawnEnv(envScrub, envAllowlist) {
185
243
  * passed through by the orchestrator (handled by caller mapping mock->env or
186
244
  * by passing systemPrompt/prompt markers; we also honor a `mock` field).
187
245
  */
188
- function mockEnabled(opts) {
246
+ export function mockEnabled(opts) {
189
247
  if (opts && opts.mock) return true;
190
248
  const v = process.env.WORCA_MOCK ?? process.env.ORCH_MOCK;
191
249
  return !!v && v !== '0' && v.toLowerCase() !== 'false';
@@ -217,10 +275,23 @@ function mockEnabled(opts) {
217
275
  * @param {string[]} [o.envAllowlist] guardrail: extra env var names to keep under scrub
218
276
  * @param {Record<string,string>} [o.modelEnv] per-model routing env (design §4.4), merged
219
277
  * 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
278
+ * config outranks ambient-env hygiene); reserved keys are re-dropped here defensively.
279
+ * An ANTHROPIC_MODEL key is the WIRE id (#374): it replaces `model` in the spawned
280
+ * `--model` flag, while `model` (the catalog id) stays worca's handle everywhere else
221
281
  * @param {string[]} [o.workspaceWriteTargets] §8.10 MOCK-ONLY member checkouts the mock
222
282
  * implementer writes into instead of `cwd` (empty/absent => today's cwd behavior).
223
283
  * Never reaches argv: `runReal` ignores it by construction.
284
+ * @param {string[]} [o.tools] --tools <list>: the built-in tool allowlist ([] ⇒ `--tools ""`,
285
+ * no built-ins at all; MCP tools are unaffected). Absent ⇒ flag omitted (claude defaults).
286
+ * @param {boolean} [o.strictMcpConfig] --strict-mcp-config: only --mcp-config servers load
287
+ * @param {string[]} [o.settingSources] --setting-sources <list> (e.g. ['project'] drops user hooks/plugins/skills)
288
+ * @param {boolean} [o.disableSlashCommands] --disable-slash-commands
289
+ * @param {boolean} [o.includePartialMessages] --include-partial-messages (stream_event text deltas)
290
+ * @param {number} [o.maxTurns] --max-turns <n> (positive safe integer; else omitted)
291
+ * @param {number|null} [o.maxBudgetUsd] --max-budget-usd <n> (finite > 0; null/else omitted)
292
+ * @param {string} [o.appendSubagentSystemPrompt] --append-subagent-system-prompt <text> (Task children only)
293
+ * All eight are Ask Worca sandbox options (ask-worca-design.md §6.3) and default-off.
294
+ * @param {number} [o.argvInlineLimit] override ARGV_INLINE_LIMIT (GH #380; tests force the staged path)
224
295
  * @returns {Promise<{text:string, exitCode:number}>}
225
296
  */
226
297
  export async function runClaude(o = {}) {
@@ -247,6 +318,17 @@ export async function runClaude(o = {}) {
247
318
  modelEnv,
248
319
  workspaceWriteTargets,
249
320
  resumeSessionId,
321
+ // Ask Worca sandbox hardening (ask-worca-design.md §6.3/§6.8). All default-off:
322
+ // undefined here ⇒ nothing emitted ⇒ every legacy argv stays byte-identical.
323
+ tools,
324
+ strictMcpConfig,
325
+ settingSources,
326
+ disableSlashCommands,
327
+ includePartialMessages,
328
+ maxTurns,
329
+ maxBudgetUsd,
330
+ appendSubagentSystemPrompt,
331
+ argvInlineLimit,
250
332
  bin = DEFAULT_BIN,
251
333
  } = o;
252
334
 
@@ -261,7 +343,7 @@ export async function runClaude(o = {}) {
261
343
  // workspaceWriteTargets is the one option that is mock-ONLY (§8.10) and it must be
262
344
  // named HERE too, or the mock implementer never sees it (this call is a gate, not
263
345
  // a pass-through; test/spawn-args.test.mjs asserts the forwarding end to end).
264
- return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets });
346
+ return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets, permissionMode });
265
347
  }
266
348
 
267
349
  return runReal({
@@ -282,6 +364,15 @@ export async function runClaude(o = {}) {
282
364
  envScrub,
283
365
  envAllowlist,
284
366
  modelEnv,
367
+ tools,
368
+ strictMcpConfig,
369
+ settingSources,
370
+ disableSlashCommands,
371
+ includePartialMessages,
372
+ maxTurns,
373
+ maxBudgetUsd,
374
+ appendSubagentSystemPrompt,
375
+ argvInlineLimit,
285
376
  });
286
377
  }
287
378
 
@@ -308,11 +399,23 @@ export async function runClaude(o = {}) {
308
399
  export function buildClaudeArgs({
309
400
  prompt, systemPrompt, permissionMode, model, effort, allowedTools, resumeSessionId,
310
401
  mcpConfigPath, mcpServerGrants, permissionRules,
311
- }) {
312
- const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--permission-mode', permissionMode];
402
+ // Ask Worca hardening options (ask-worca-design.md §6.3). `tools` is renamed on the
403
+ // way in because the legacy body below already owns a local `tools` (the
404
+ // --allowedTools union).
405
+ tools: builtinTools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
406
+ maxTurns, maxBudgetUsd, appendSubagentSystemPrompt,
407
+ }, delivery = {}) {
408
+ // delivery (GH #380, set only by planClaudeInvocation's staged branch):
409
+ // promptViaStdin -> bare `-p`; the prompt is written to the child's stdin
410
+ // systemPromptFile -> `--append-system-prompt-file <path>` instead of the text
411
+ // settingsFile -> `--settings <path>` instead of the inline JSON
412
+ const { promptViaStdin = false, systemPromptFile = null, settingsFile = null } = delivery;
413
+ const args = promptViaStdin ? ['-p'] : ['-p', prompt];
414
+ args.push('--output-format', 'stream-json', '--verbose', '--permission-mode', permissionMode);
313
415
  if (resumeSessionId) args.push('--resume', resumeSessionId);
314
416
  if (systemPrompt) {
315
- args.push('--append-system-prompt', systemPrompt);
417
+ if (systemPromptFile) args.push('--append-system-prompt-file', systemPromptFile);
418
+ else args.push('--append-system-prompt', systemPrompt);
316
419
  }
317
420
  if (model) {
318
421
  args.push('--model', model);
@@ -323,7 +426,7 @@ export function buildClaudeArgs({
323
426
  // SINGLE inline JSON (two --settings flags would be last-wins at the CLI). [] when
324
427
  // there is neither, so the baseline argv is unchanged; a CLI that rejects these
325
428
  // flags would only ever fail when the operator opted in.
326
- for (const a of buildSettingsArgs(permissionRules)) args.push(a);
429
+ for (const a of buildSettingsArgs(permissionRules, settingsFile)) args.push(a);
327
430
  if (mcpConfigPath) args.push('--mcp-config', mcpConfigPath);
328
431
  const tools = Array.isArray(allowedTools) ? allowedTools.slice() : [];
329
432
  for (const s of (Array.isArray(mcpServerGrants) ? mcpServerGrants : [])) {
@@ -332,43 +435,181 @@ export function buildClaudeArgs({
332
435
  if (tools.length) {
333
436
  args.push('--allowedTools', tools.join(','));
334
437
  }
438
+ // ── Ask Worca hardening flags (ask-worca-design.md §6.3 / §6.8) ──────────────
439
+ // Every one is default-off: absent / false / invalid ⇒ NOTHING is emitted, so
440
+ // every legacy argv stays byte-identical (test/spawn-args.test.mjs). Appended
441
+ // AFTER the legacy block so the baseline prefix never moves. Probed on 2.1.239:
442
+ // `--tools ""` = no built-in tools (MCP tools survive); the hidden `--max-turns`
443
+ // and `--append-subagent-system-prompt` are accepted and enforced.
444
+ // Filter to usable names FIRST, then decide: testing the RAW list while emitting
445
+ // the FILTERED join made `settingSources: [1]` emit `--setting-sources ""` (where
446
+ // `[]` emits nothing) and `['Read', '']` emit a trailing comma.
447
+ const names = (v) => (Array.isArray(v) ? v.filter((s) => typeof s === 'string' && s) : []);
448
+ // --tools is the one list whose empty value is meaningful (`--tools ""` = no
449
+ // built-in tools at all, §6.3), so the ARRAY decides whether the flag is emitted.
450
+ if (Array.isArray(builtinTools)) {
451
+ args.push('--tools', names(builtinTools).join(','));
452
+ }
453
+ if (strictMcpConfig === true) args.push('--strict-mcp-config');
454
+ const sources = names(settingSources);
455
+ if (sources.length) {
456
+ args.push('--setting-sources', sources.join(','));
457
+ }
458
+ if (disableSlashCommands === true) args.push('--disable-slash-commands');
459
+ if (includePartialMessages === true) args.push('--include-partial-messages');
460
+ if (Number.isSafeInteger(maxTurns) && maxTurns > 0) args.push('--max-turns', String(maxTurns));
461
+ if (typeof maxBudgetUsd === 'number' && Number.isFinite(maxBudgetUsd) && maxBudgetUsd > 0) {
462
+ args.push('--max-budget-usd', String(maxBudgetUsd));
463
+ }
464
+ if (typeof appendSubagentSystemPrompt === 'string' && appendSubagentSystemPrompt) {
465
+ args.push('--append-subagent-system-prompt', appendSubagentSystemPrompt);
466
+ }
335
467
  return args;
336
468
  }
337
469
 
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
- });
470
+ /**
471
+ * Decide how ONE invocation reaches the CLI (GH #380). Pure: no I/O.
472
+ * - inline (the common case): `args` is exactly buildClaudeArgs(opts); `stdin`
473
+ * null; `files` empty.
474
+ * - staged (argv over `limit`): the prompt goes on stdin (`-p` reads it — the
475
+ * model sees the exact text, unlike a "read this file" instruction), the
476
+ * system prompt and the settings JSON become files under `dir`, and no
477
+ * argument carries free text any more, so the argv is short by construction.
478
+ * @param {object} opts the buildClaudeArgs options
479
+ * @param {{bin?:string, dir?:string|(() => string), limit?:number}} [o] `dir` may be a
480
+ * factory, called only when staging is actually needed (so the caller creates
481
+ * a temp dir exactly when one will be used)
482
+ * @returns {{args:string[], stdin:string|null, files:{path:string,content:string}[], staged:boolean, inlineLength:number}}
483
+ */
484
+ export function planClaudeInvocation(opts, { bin = DEFAULT_BIN, dir = null, limit = ARGV_INLINE_LIMIT } = {}) {
485
+ const inline = buildClaudeArgs(opts);
486
+ const inlineLength = argvLength(bin, inline);
487
+ if (inlineLength <= limit) return { args: inline, stdin: null, files: [], staged: false, inlineLength };
488
+ if (!dir) throw new Error('planClaudeInvocation: a staging dir is required when the argv is over the limit');
489
+ if (typeof dir === 'function') dir = dir();
490
+ const files = [];
491
+ const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
492
+ const promptViaStdin = prompt.length > 0; // an empty prompt stays `-p ''` — nothing to pipe
493
+ let systemPromptFile = null;
494
+ if (opts.systemPrompt) {
495
+ systemPromptFile = join(dir, 'system-prompt.md');
496
+ files.push({ path: systemPromptFile, content: opts.systemPrompt });
497
+ }
498
+ let settingsFile = null;
499
+ const payload = buildSettingsPayload(opts.permissionRules);
500
+ if (payload) {
501
+ settingsFile = join(dir, 'settings.json');
502
+ files.push({ path: settingsFile, content: JSON.stringify(payload.settings) });
503
+ }
504
+ const args = buildClaudeArgs(opts, { promptViaStdin, systemPromptFile, settingsFile });
505
+ return { args, stdin: promptViaStdin ? prompt : null, files, staged: true, inlineLength };
506
+ }
344
507
 
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);
508
+ /** planClaudeInvocation + the I/O: a private temp dir is created and the files
509
+ * written ONLY on the staged branch (`dir` is null otherwise, so the caller has
510
+ * nothing to clean up). Synchronous on purpose — a few hundred KB once per
511
+ * spawn, and it keeps the spawn sequence in runReal linear. */
512
+ export function stageClaudeInvocation(opts, { bin = DEFAULT_BIN, limit = ARGV_INLINE_LIMIT } = {}) {
513
+ let dir = null;
514
+ const plan = planClaudeInvocation(opts, { bin, limit, dir: () => (dir = mkdtempSync(join(tmpdir(), 'worca-claude-'))) });
515
+ for (const file of plan.files) writeFileSync(file.path, file.content, 'utf8');
516
+ return { ...plan, dir };
517
+ }
348
518
 
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;
519
+ 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 }) {
520
+ return new Promise((resolveP, rejectP) => {
521
+ // Per-model routing env (design §4.4), prepared BEFORE argv: reserved keys
522
+ // are re-dropped here defensively the write path already rejects them, so
523
+ // a drop means a hand-edited settings file and the surviving map is also
524
+ // where the wire id (below) is read from.
525
+ let safeModelEnv = null;
526
+ let wireModelDropped = false;
357
527
  if (modelEnv && Object.keys(modelEnv).length) {
358
528
  const { env: safe, dropped } = prepareModelEnv(modelEnv);
359
529
  for (const k of dropped) {
360
530
  console.warn(`[worca] modelEnv: dropping reserved/invalid key ${JSON.stringify(k)}`);
361
531
  }
362
- if (Object.keys(safe).length) spawnEnv = { ...(guardrailEnv ?? process.env), ...safe };
532
+ // A configured wire id that didn't survive (unresolvable ${VAR}, empty, or
533
+ // whitespace-only) fell into `dropped`: we silently fall back to the catalog
534
+ // id below, so warn specifically — the generic drop line above doesn't say
535
+ // the argv model changed, and the wire-model line never fires (ids match).
536
+ wireModelDropped = 'ANTHROPIC_MODEL' in modelEnv && dropped.includes('ANTHROPIC_MODEL');
537
+ if (Object.keys(safe).length) safeModelEnv = safe;
538
+ }
539
+
540
+ // Wire id (#374): ANTHROPIC_MODEL in the resolved model env names the id the
541
+ // ENDPOINT should see; the catalog id stays worca's handle (config refs, cost
542
+ // flags). Passed as an explicit --model — self-documenting in logs and immune
543
+ // to CLI flag/env precedence — so the env var alone would otherwise be dead.
544
+ const wireModel = safeModelEnv?.ANTHROPIC_MODEL || model;
545
+ if (wireModelDropped && wireModel === model) {
546
+ console.warn(`[worca] model ${JSON.stringify(model ?? '')}: configured wire model was dropped (unresolved/empty) — using the catalog id`);
547
+ } else if (wireModel !== model) {
548
+ console.warn(`[worca] model ${JSON.stringify(model ?? '')}: wire model ${JSON.stringify(wireModel)}`);
363
549
  }
364
550
 
551
+ // Windows + npm-installed Claude Code: the bare name is a .cmd shim Node
552
+ // cannot spawn; resolveClaudeBin swaps in the package's native claude.exe.
553
+ // Everywhere else this is `bin` unchanged. Resolved BEFORE the argv plan so
554
+ // the command-line measure below counts the path that is actually spawned.
555
+ const resolved = resolveClaudeBin(bin);
556
+ if (resolved.note && !_resolveNoted.has(resolved.bin)) {
557
+ _resolveNoted.add(resolved.bin);
558
+ console.warn(`[worca] ${resolved.note}`);
559
+ }
560
+
561
+ // GH #380: inline argv when it fits, else prompt on stdin + files (see
562
+ // ARGV_INLINE_LIMIT). The staging dir, when any, is removed on every
563
+ // terminal path below (finish) and on a failed spawn.
564
+ const limit = Number.isFinite(argvInlineLimit) && argvInlineLimit > 0 ? argvInlineLimit : ARGV_INLINE_LIMIT;
565
+ let plan;
566
+ try {
567
+ plan = stageClaudeInvocation({
568
+ prompt, systemPrompt, permissionMode, model: wireModel, effort, allowedTools, resumeSessionId,
569
+ mcpConfigPath, mcpServerGrants, permissionRules,
570
+ tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
571
+ maxTurns, maxBudgetUsd, appendSubagentSystemPrompt,
572
+ }, { bin: resolved.bin, limit });
573
+ } catch (err) {
574
+ rejectP(new Error(`Failed to stage the claude prompt files: ${err.message}`));
575
+ return;
576
+ }
577
+ const { args } = plan;
578
+ const cleanupStaged = () => {
579
+ if (!plan.dir) return;
580
+ try { rmSync(plan.dir, { recursive: true, force: true }); } catch { /* best effort */ }
581
+ };
582
+ if (plan.staged) {
583
+ console.warn(`[worca] claude argv would be ${plan.inlineLength} chars (limit ${limit}): prompt on stdin, system prompt/settings as files`);
584
+ }
585
+
586
+ // undefined when the guardrail is off, and the spread then adds NO `env` key —
587
+ // spawn inherits process.env exactly as it did before guardrails existed.
588
+ const guardrailEnv = buildSpawnEnv(envScrub, envAllowlist);
589
+
590
+ // Model env merges LAST: it survives scrub and wins collisions (explicit
591
+ // operator config outranks ambient-env hygiene). With no modelEnv (or
592
+ // nothing surviving the filter) the spawn env is byte-identical to the
593
+ // pre-feature behavior, including the undefined -> inherit-process.env case.
594
+ let spawnEnv = guardrailEnv;
595
+ if (safeModelEnv) spawnEnv = { ...(guardrailEnv ?? process.env), ...safeModelEnv };
596
+
365
597
  let child;
366
598
  try {
367
- child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}) });
599
+ child = spawn(resolved.bin, args, {
600
+ cwd, stdio: [plan.stdin != null ? 'pipe' : 'ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}),
601
+ });
368
602
  } catch (err) {
369
- rejectP(new Error(`Failed to spawn ${bin}: ${err.message}`));
603
+ cleanupStaged();
604
+ rejectP(spawnFailure(bin, err, `Failed to spawn ${bin}`));
370
605
  return;
371
606
  }
607
+ if (plan.stdin != null) {
608
+ // A child that dies before draining stdin (bad flag, ENOENT surfaced
609
+ // late) raises EPIPE here; the 'error'/'close' handlers own the real cause.
610
+ child.stdin.on('error', () => {});
611
+ child.stdin.end(plan.stdin, 'utf8');
612
+ }
372
613
 
373
614
  let resultText = '';
374
615
  let assistantText = '';
@@ -396,7 +637,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
396
637
  } catch {
397
638
  /* ignore */
398
639
  }
399
- }, 1500).unref?.();
640
+ }, sigkillGraceMs()).unref?.();
400
641
  };
401
642
  if (signal) {
402
643
  if (signal.aborted) onAbort();
@@ -407,6 +648,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
407
648
  if (settled) return;
408
649
  settled = true;
409
650
  if (signal) signal.removeEventListener?.('abort', onAbort);
651
+ cleanupStaged();
410
652
  fn(arg);
411
653
  };
412
654
 
@@ -486,7 +728,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
486
728
  });
487
729
 
488
730
  child.on('error', (err) => {
489
- finish(rejectP, new Error(`${bin} error: ${err.message}`));
731
+ finish(rejectP, spawnFailure(bin, err, `${bin} error`));
490
732
  });
491
733
 
492
734
  child.on('close', (code) => {
@@ -611,6 +853,24 @@ async function emitLog(onEvent, text) {
611
853
  await new Promise((r) => setTimeout(r, 0));
612
854
  }
613
855
 
856
+ /**
857
+ * The roles the offline mock runner can SERVE — one per arm of the role switch
858
+ * below (the `ask` arm is the Ask-Worca assistant, not a writer role). Exported
859
+ * because three consumers need the vocabulary and none of them may hard-code it:
860
+ * meta v2 validation (an unknown `mockRole` is a warning + drop), GET /api/agents
861
+ * (the Agents view's role picker) and the graph executor's mock-role chain.
862
+ * test/mock-writer-roles.test.mjs parses the switch and pins the lockstep.
863
+ */
864
+ export const MOCK_WRITER_ROLES = new Set([
865
+ 'clarify', 'planner-plan', 'refiner', 'decomposer', 'implementer', 'reviewer', 'plan-review',
866
+ 'workspace-scan', 'agent-gen', 'workspace-reviewer', 'manual-tests-checklist', 'manual-web-ui-testing',
867
+ 'generic-producer', 'generic-verifier',
868
+ ]);
869
+
870
+ /** Named so the executor's mock-role chain and the switch cannot drift apart. */
871
+ export const MOCK_ROLE_CLARIFY = 'clarify';
872
+ export const MOCK_ROLE_DECOMPOSER = 'decomposer';
873
+
614
874
  /**
615
875
  * The mock-fan-out roles (mirror the orchestrator's FANOUT_ELIGIBLE intent): the
616
876
  * roles whose real runs may spawn sub-agents. Keyed by the MOCK_ROLE strings.
@@ -675,6 +935,109 @@ async function emitMockSubAgents(role, onEvent, signal) {
675
935
  }
676
936
  }
677
937
 
938
+ // ── Ask Worca mock role (ask-worca-design.md §6.7) ───────────────────────────
939
+
940
+ /** Emit a raw stream-json frame through the SAME envelope runReal uses (the rl 'line' handler above). */
941
+ function emitRaw(onEvent, raw) {
942
+ const cost = extractResultCost(raw);
943
+ const text = extractText(raw);
944
+ safeEmit(onEvent, { type: raw.type, raw, text: text || undefined, ...(cost != null ? { costUsd: cost } : {}) });
945
+ }
946
+
947
+ const ASK_CONTEXT_BLOCK_RE = /\[worca context\][\s\S]*?\[\/worca context\]\s*/;
948
+
949
+ /**
950
+ * The offline Ask Worca assistant: frames in the shapes probed on claude 2.1.239
951
+ * (system/init → message_start → text deltas → assistant blocks → tool_use /
952
+ * tool_result pairs → message_delta → result), chosen from the USER text so
953
+ * tests control the scenario. Never touches the filesystem, never reads prompt
954
+ * markers, never spawns the MCP child. The limit / failure scenarios emit their
955
+ * `result` frame and then REJECT exactly like the real CLI (exit 1, empty stderr).
956
+ */
957
+ async function mockAsk({ markers, prompt, cwd, onEvent, signal, resumeSessionId }) {
958
+ const userText = String(prompt ?? '').replace(ASK_CONTEXT_BLOCK_RE, '');
959
+ let card = {};
960
+ try { card = markers.MOCK_ASK_CARD ? JSON.parse(markers.MOCK_ASK_CARD) : {}; } catch { card = {}; }
961
+ if (!card || typeof card !== 'object' || Array.isArray(card)) card = {};
962
+ const fail = /\bMOCK_FAIL\b/.test(userText);
963
+ const maxTurns = /\bMOCK_MAX_TURNS\b/.test(userText);
964
+ const maxBudget = /\bMOCK_MAX_BUDGET\b/.test(userText);
965
+ const slow = /\bMOCK_SLOW\b/.test(userText);
966
+ const agents = /\bagents?\b/i.test(userText);
967
+ const propose = /\b(propose|start|run)\b/i.test(userText);
968
+
969
+ const SID = resumeSessionId || 'mock-session-ask-1';
970
+ const USAGE = { input_tokens: 10, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
971
+ const firstLine = userText.split(/\r?\n/).map((l) => l.trim()).find(Boolean) || '';
972
+ const ANSWER = `[mock] ${firstLine.slice(0, 200)}`;
973
+ const init = { type: 'system', subtype: 'init', session_id: SID, cwd, model: 'mock', permissionMode: 'dontAsk',
974
+ tools: ['Task', 'mcp__worca__list_runs', 'mcp__worca__get_run', 'mcp__worca__propose_run'],
975
+ mcp_servers: [{ name: 'worca', status: 'connected' }], plugins: [], skills: [], slash_commands: [], agents: [], uuid: 'mock-uuid-init' };
976
+ 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 });
977
+ 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 });
978
+ const mdelta = { type: 'stream_event', event: { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: USAGE }, parent_tool_use_id: null, session_id: SID };
979
+ 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 });
980
+ 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 });
981
+ 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 });
982
+ 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,
983
+ usage: USAGE, modelUsage: {}, permission_denials: [], terminal_reason: 'completed', result: ANSWER, ...over });
984
+ const MSG1 = 'msg_mock_ask_1';
985
+ const MSG2 = 'msg_mock_ask_2';
986
+
987
+ const frames = [init, mstart(MSG1)];
988
+ if (fail) {
989
+ frames.push(result({ subtype: 'error_during_execution', is_error: true, errors: ['mock failure'], terminal_reason: 'api_error', result: 'mock failure', num_turns: 0 }));
990
+ } else if (maxTurns || maxBudget) {
991
+ frames.push(delta('[mock] '), delta('partial'), atext(MSG1, '[mock] partial'),
992
+ atool(MSG1, 'toolu_mock_1', 'mcp__worca__list_runs', {}), uresult('toolu_mock_1', '[]'));
993
+ frames.push(maxTurns
994
+ ? 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 })
995
+ : result({ subtype: 'error_max_budget_usd', is_error: true, errors: ['Reached maximum budget ($0.0001)'], terminal_reason: 'budget_exhausted', result: undefined }));
996
+ } else {
997
+ let answerMsg = MSG1;
998
+ if (agents) {
999
+ frames.push(
1000
+ atool(MSG1, 'toolu_mock_task', 'Agent', { description: 'count runs', subagent_type: 'general-purpose', prompt: 'count the runs' }),
1001
+ atool('msg_mock_child_1', 'toolu_mock_child_1', 'mcp__worca__list_runs', {}, 'toolu_mock_task'),
1002
+ uresult('toolu_mock_child_1', '[]', 'toolu_mock_task'),
1003
+ uresult('toolu_mock_task', 'count: 0', null, { tool_use_result: {
1004
+ status: 'completed', agentId: 'mock-agent-1', agentType: 'general-purpose', content: [{ type: 'text', text: 'count: 0' }],
1005
+ resolvedModel: 'mock-haiku', totalDurationMs: 10, totalTokens: 1234, totalToolUseCount: 1,
1006
+ usage: { input_tokens: 1000, output_tokens: 234, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
1007
+ } }),
1008
+ );
1009
+ answerMsg = MSG2;
1010
+ }
1011
+ if (propose) {
1012
+ frames.push(delta('[mock] '), delta('preparing '), delta('a run'), atext(MSG1, 'Preparing a run card.'),
1013
+ atool(MSG1, 'toolu_mock_propose', 'mcp__worca__propose_run', card), uresult('toolu_mock_propose', JSON.stringify({ ok: true })));
1014
+ answerMsg = MSG2;
1015
+ }
1016
+ if (answerMsg !== MSG1) frames.push(mstart(answerMsg));
1017
+ frames.push(delta('[mock] '), delta(firstLine.slice(0, 200)), atext(answerMsg, ANSWER), mdelta);
1018
+ frames.push(result(agents
1019
+ ? { modelUsage: { 'mock-haiku': { inputTokens: 1000, outputTokens: 234, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, costUSD: 0, canonicalModel: 'mock-haiku' } } }
1020
+ : {}));
1021
+ }
1022
+
1023
+ safeEmit(onEvent, { type: 'session', sessionId: SID });
1024
+ for (const f of frames) {
1025
+ abortIfNeeded(signal);
1026
+ emitRaw(onEvent, f);
1027
+ await new Promise((r) => setTimeout(r, slow ? 300 : 0));
1028
+ }
1029
+ abortIfNeeded(signal);
1030
+ if (fail || maxTurns || maxBudget) {
1031
+ // Probed on 2.1.239: these subtypes exit 1 with EMPTY stderr, so runReal rejects with the
1032
+ // stdout `result` text (MOCK_FAIL) or 'no stderr' (the limits). turn.mjs (P2) reads the
1033
+ // reducer's resultSubtype before classifying the rejection.
1034
+ const err = new Error(`claude exited with code 1: ${fail ? 'mock failure' : 'no stderr'}`);
1035
+ err.errorClass = null;
1036
+ throw err;
1037
+ }
1038
+ return { text: ANSWER, exitCode: 0 };
1039
+ }
1040
+
678
1041
  function abortIfNeeded(signal) {
679
1042
  if (signal?.aborted) {
680
1043
  const err = new Error('aborted');
@@ -686,8 +1049,20 @@ function abortIfNeeded(signal) {
686
1049
  /**
687
1050
  * Offline mock: emits a few log lines and performs role-appropriate writes.
688
1051
  */
689
- async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets }) {
1052
+ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets, permissionMode }) {
690
1053
  abortIfNeeded(signal);
1054
+ // Ask Worca mock role (ask-worca-design.md §6.7): detected from the SYSTEM PROMPT
1055
+ // ONLY and dispatched before any prompt-sourced marker is honoured — a chat
1056
+ // message containing `MOCK_ASK: /x.json` (or any MOCK_* line) must never reach
1057
+ // the MOCK_ASK file-write arm below, and the user text can never pick the role.
1058
+ // `dontAsk` is the ask recipe's permission mode and has no legacy caller
1059
+ // (spawn.mjs:20), so it takes the ask arm markers or not: a P2 turn that forgot
1060
+ // `turn.mock` must not fall through to parseMarkers(prompt)/inferRole, where the
1061
+ // chat text alone picks a role that writes to the scratch cwd.
1062
+ const sysMarkers = parseMarkers('', systemPrompt);
1063
+ if (sysMarkers.MOCK_ROLE === 'ask' || permissionMode === 'dontAsk') {
1064
+ return mockAsk({ markers: sysMarkers, prompt, cwd, onEvent, signal, resumeSessionId });
1065
+ }
691
1066
  const m = parseMarkers(prompt, systemPrompt);
692
1067
  const role = m.MOCK_ROLE || inferRole(prompt, systemPrompt);
693
1068
  const cycle = Number(m.MOCK_CYCLE || '1') || 1;
@@ -704,7 +1079,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
704
1079
  // Ask-then-resume (spec 2026-07-11): asking replaces the role side effects
705
1080
  // for this invocation; the orchestrator gates the user and resumes. The
706
1081
  // session event above already fired, so the resume has a session id.
707
- if (m.MOCK_ASK) {
1082
+ if (m.MOCK_ASK && permissionMode !== 'dontAsk') { // belt and braces: dontAsk already took the ask arm above
708
1083
  await ensureDir(m.MOCK_ASK);
709
1084
  await writeFile(m.MOCK_ASK, JSON.stringify({
710
1085
  questions: [{ id: 'q1', question: `Mock question from ${role}?`, options: ['Option A', 'Option B'], allowFreeText: true }],
@@ -717,7 +1092,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
717
1092
 
718
1093
  let text = `[mock] role ${role} complete`;
719
1094
  switch (role) {
720
- case 'clarify':
1095
+ case MOCK_ROLE_CLARIFY:
721
1096
  text = await mockClarify(m, cycle, onEvent);
722
1097
  break;
723
1098
  case 'planner-plan':
@@ -726,7 +1101,7 @@ async function runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessi
726
1101
  case 'refiner':
727
1102
  text = await mockRefiner(m, cycle, onEvent);
728
1103
  break;
729
- case 'decomposer':
1104
+ case MOCK_ROLE_DECOMPOSER:
730
1105
  text = await mockDecomposer(m, onEvent);
731
1106
  break;
732
1107
  case 'implementer':
@@ -1210,13 +1585,15 @@ async function mockAgentGen(m, onEvent) {
1210
1585
  ? words[0] + words.slice(1).map((w) => w[0].toUpperCase() + w.slice(1)).join('')
1211
1586
  : 'customAgent';
1212
1587
  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,
1588
+ metaVersion: 2, key, displayName: name, description: `mock-generated agent for ${name}`,
1589
+ color: 'amber', runnerType: 'producer', domain: 'general', fanOut: false,
1590
+ asksQuestions: true, questionsLocked: false, questionsDefault: false, order: 99,
1591
+ inputs: [{ id: 'plan', type: 'md', label: 'Plan' }],
1592
+ outputs: [{ id: 'review', type: 'md', filename: 'review-{cycle}.md' }],
1217
1593
  };
1218
1594
  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`;
1595
+ const md = `# Agent: ${name}\n\nYou are ${name} (deterministic mock body).\n\n`
1596
+ + '## Ports\n\n- `plan` (in, md) — the plan to review.\n- `review` (out, md) — the review this agent writes.\n';
1220
1597
  await ensureDir(m.MOCK_OUT);
1221
1598
  await writeFile(m.MOCK_OUT, md, 'utf8');
1222
1599
  safeEmit(onEvent, { type: 'tool_use', text: `wrote ${m.MOCK_OUT}`, raw: { mock: true, file: m.MOCK_OUT } });