@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
@@ -4,18 +4,21 @@
4
4
  // CLI entry point. Parses flags, creates a core orchestrator, subscribes to its events,
5
5
  // renders a phase tracker + streamed agent logs to the terminal, and drives interactive
6
6
  // Q&A (clarify) and loop gates via node:readline. Supports --yes (auto), --mock,
7
- // --install <dir> (delegates to scripts/install.mjs), and --ui (spawns ui/server.mjs).
7
+ // --install <dir> (delegates to scripts/install.mjs), --ui (spawns ui/server.mjs),
8
+ // and -v/-V/--version (also the bare word `version`).
8
9
  //
9
10
  // ESM, no external dependencies.
10
11
 
11
12
  import { createInterface } from 'node:readline';
12
13
  import { spawn } from 'node:child_process';
14
+ import { fstatSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
13
16
  import { fileURLToPath } from 'node:url';
14
17
  import { dirname, resolve, join, basename } from 'node:path';
15
18
  import process from 'node:process';
16
19
 
17
20
  import { preflightNode } from '../core/preflight-node.mjs';
18
- import { createOrchestrator } from '../core/orchestrator.mjs';
21
+ import { createOrchestratorFor } from '../core/engine-select.mjs';
19
22
  import {
20
23
  addProject,
21
24
  listProjects,
@@ -23,6 +26,8 @@ import {
23
26
  normalizeProjectPath,
24
27
  } from '../core/projects.mjs';
25
28
  import { projectKey } from '../core/store.mjs';
29
+ import { formatExecLine, formatGateHeader, formatRunSummary } from './render.mjs';
30
+ import { pauseExitCode, describePauseReason, promptOptions, REASON } from '../core/failure-policy.mjs';
26
31
 
27
32
  // ── node:sqlite runtime guard + warning filter ──────────────────────────────────
28
33
  // Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
@@ -37,6 +42,18 @@ process.on('warning', (w) => {
37
42
  if (w && w.name === 'ExperimentalWarning' && /SQLite/i.test(w.message)) return;
38
43
  process.stderr.write(`${w?.stack || w?.message || w}\n`);
39
44
  });
45
+ // ── --version ──────────────────────────────────────────────────────────────────
46
+ // Answered BEFORE the Node preflight and before any flag validation: "which worca is
47
+ // this?" is the first question asked when something else is broken, so it must work
48
+ // on an unsupported Node and alongside an otherwise-bad command line. The bare word
49
+ // `version` is only honoured in the subcommand slot (like `help`); the flags anywhere.
50
+ // Output is the GNU/gh/go form, `<prog> <semver>`, on stdout, exit 0.
51
+ const PKG_VERSION = createRequire(import.meta.url)('../../package.json').version;
52
+ const VERSION_FLAGS = new Set(['-v', '-V', '--version']);
53
+ if (process.argv[2] === 'version' || process.argv.slice(2).some((a) => VERSION_FLAGS.has(a))) {
54
+ process.stdout.write(`worca ${PKG_VERSION}\n`);
55
+ process.exit(0);
56
+ }
40
57
  // Fail fast on an unsupported Node / missing node:sqlite BEFORE any DB is opened.
41
58
  preflightNode();
42
59
 
@@ -46,9 +63,18 @@ const REPO_ROOT = resolve(__dirname, '..', '..');
46
63
 
47
64
  // ── arg parsing ────────────────────────────────────────────────────────────────
48
65
 
66
+ /**
67
+ * The permission modes a pipeline run may be launched with. Deliberately NOT the
68
+ * full set claude accepts: `dontAsk` belongs to the Ask Worca runner alone
69
+ * (core/ask/spawn.mjs), which owns its own spawn options and never comes through
70
+ * here.
71
+ */
72
+ const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk'];
73
+
49
74
  /**
50
75
  * Parse argv into a flags object. Supports "--flag value" and "--flag=value", plus the
51
- * boolean flags --mock, --yes/--non-interactive, --ui, -h/--help.
76
+ * boolean flags --mock, --yes/--non-interactive, --ui, -h/--help. (-v/-V/--version
77
+ * never reach here: they are answered at module top, before the Node preflight.)
52
78
  */
53
79
  function parseArgs(argv) {
54
80
  const out = {
@@ -134,6 +160,12 @@ function parseArgs(argv) {
134
160
  const p = part.trim();
135
161
  if (p) out.extras.push(p);
136
162
  }
163
+ } else if (key === 'permissionMode' && !PERMISSION_MODES.includes(String(value))) {
164
+ // A pipeline run's mode reaches claude-runner as-is. `dontAsk` is a legitimate
165
+ // headless mode for a REAL run (allowedTools decide what runs); only the
166
+ // MOCK runner treats it as the Ask Worca recipe — that pair is refused below,
167
+ // after --mock/WORCA_MOCK are known (review of PR #376).
168
+ fail(`--permission-mode must be one of ${PERMISSION_MODES.join(', ')}, got: ${value}`);
137
169
  } else {
138
170
  out[key] = value;
139
171
  }
@@ -165,12 +197,13 @@ function budgetRefusalDetail(b) {
165
197
  + `resets ${when} (in ${days}d ${hours}h)`;
166
198
  }
167
199
 
168
- const HELP = `worca — deterministic multi-agent pipeline (Plan -> Refine -> Implement -> Review)
200
+ const HELP = `worca — node-graph multi-agent pipelines
169
201
 
170
202
  Usage:
171
203
  worca <subcommand> [args]
172
204
  worca --prompt "<task>" [--project <dir>] [options]
173
205
  worca --file <task.md> [--project <dir>] [options]
206
+ worca "<task>" [--project <dir>] [options] (bare prompt; quote it)
174
207
  worca --ui
175
208
  worca --install <targetDir> [--force]
176
209
 
@@ -182,9 +215,11 @@ Subcommands:
182
215
  [--ignore-cost-cap] Resume past this pipeline's cost cap (persists on the run).
183
216
  doctor Reconcile crashed runs and sweep leftover run roots.
184
217
  plugin <cmd> [...] Manage plugins: add|install|list|update|remove|purge|enable|
185
- disable|doctor|link|init|validate|exec. See: worca plugin help
218
+ disable|doctor|link|reimport|init|validate|exec. See: worca plugin help
186
219
  marketplace <cmd> [...] Manage plugin marketplaces: add|list|refresh|remove. See: worca marketplace help
187
220
  config [get|set|unset] Budget & cost-limit settings
221
+ help Print this help (same as --help).
222
+ version Print the version (same as --version).
188
223
 
189
224
  Options:
190
225
  --project <dir> Target project directory (default: cwd)
@@ -194,8 +229,9 @@ Options:
194
229
  --extras <paths> Extra files copied into the pipeline's extras/ folder
195
230
  (comma-separated; repeatable)
196
231
  --model <m> Claude model id
197
- --permission-mode <m> Claude permission mode (default acceptEdits)
198
- --workflow <id> Saved workflow id to run (default: wf_default)
232
+ --permission-mode <m> Claude permission mode: default | acceptEdits | plan |
233
+ bypassPermissions (default acceptEdits)
234
+ --workflow <id> Saved pipeline template to run (default: wf_default — the built-in graph)
199
235
  --source-branch <name> Branch to fork the per-run worktree from (default: current HEAD)
200
236
  --branch <name> Feature branch name (default: claude proposes one)
201
237
  --mock Offline mock mode (no claude, no tokens)
@@ -203,6 +239,7 @@ Options:
203
239
  --ui Launch the web UI (ui/server.mjs) and exit
204
240
  --install <targetDir> Copy agents + /worca skill into <targetDir>/.claude
205
241
  -h, --help Show this help
242
+ -v, -V, --version Print the version (worca <semver>) and exit
206
243
  `;
207
244
 
208
245
  // ── terminal rendering ───────────────────────────────────────────────────────────
@@ -227,18 +264,7 @@ function out(s) {
227
264
  process.stdout.write(s + '\n');
228
265
  }
229
266
 
230
- function phaseLabel(phase, cycle) {
231
- if (cycle && (phase === 'refine' || phase === 'review' || phase === 'implement' || phase === 'clarify')) {
232
- return `${phase} #${cycle}`;
233
- }
234
- return phase;
235
- }
236
267
 
237
- function statusMark(status) {
238
- if (status === 'done') return c('green', '✓');
239
- if (status === 'start') return c('cyan', '▶');
240
- return c('gray', '•');
241
- }
242
268
 
243
269
  const LEVEL_COLOR = { info: 'reset', debug: 'gray', warn: 'yellow', error: 'red' };
244
270
 
@@ -300,9 +326,10 @@ async function askClarify(rl, questions) {
300
326
  * Ask a loop gate interactively. Shows the open blocking issues and the two choices.
301
327
  * Returns { decision: "continue" | "another" }.
302
328
  */
303
- async function askGate(rl, issues) {
329
+ async function askGate(rl, issues, header) {
304
330
  out('');
305
- out(c('yellow', c('bold', 'Loop gate maximum cycles reached.')));
331
+ // A graph run names the wire and its budget (`? Loop gate · Reviewer → Implementer 3/3 cycles used`).
332
+ out(c('yellow', c('bold', header || 'Loop gate — maximum cycles reached.')));
306
333
  out(c('yellow', 'Open critical/major issues:'));
307
334
  if (!issues || issues.length === 0) {
308
335
  out(' (none reported)');
@@ -325,40 +352,145 @@ async function askGate(rl, issues) {
325
352
 
326
353
  /**
327
354
  * Ask the user how to handle a recoverable error (auth / rate-limit / quota /
328
- * network). Shows the cause and waits for retry / abort. Returns { decision }.
355
+ * network). Shows the cause and the row's options (failure-policy.mjs: Retry, plus
356
+ * what giving up does — pause or abort). Returns { decision } with the chosen
357
+ * option's id as the wire value.
329
358
  */
330
359
  async function askRecovery(rl, recovery) {
331
360
  const rec = recovery || {};
361
+ const options = Array.isArray(rec.options) && rec.options.length ? rec.options : promptOptions({ outcome: 'pause' });
332
362
  out('');
333
363
  out(c('yellow', c('bold', `Recoverable ${String(rec.cls || 'error').replace('_', ' ')} error — the pipeline could not reach the model.`)));
334
364
  if (rec.message) out(c('gray', ` ${rec.message}`));
335
365
  if (rec.cls === 'auth') out(c('gray', ' Fix: re-authenticate (claude setup-token or /login) in another terminal, then retry.'));
336
366
  else out(c('gray', ' Fix: wait out the limit / restore connectivity / top up credit, then retry.'));
337
- out(' 1) Retry');
338
- out(' 2) Abort the run');
367
+ options.forEach((o, i) => out(` ${i + 1}) ${o.label}`));
339
368
  let decision = '';
340
369
  while (!decision) {
341
- const raw = (await question(rl, c('cyan', 'Choose [1-2]: '))).trim();
342
- if (raw === '1' || /^retry/i.test(raw)) decision = 'retry';
343
- else if (raw === '2' || /^abort/i.test(raw)) decision = 'abort';
370
+ const raw = (await question(rl, c('cyan', `Choose [1-${options.length}]: `))).trim();
371
+ const byNumber = options[Number(raw) - 1];
372
+ if (byNumber) decision = byNumber.id;
373
+ else if (/^retry/i.test(raw)) decision = 'retry';
374
+ // 'pause' and 'abort' both mean give up; the option offered names the verdict.
375
+ else if (/^(pause|abort)/i.test(raw)) decision = options.find((o) => o.id !== 'retry')?.id || 'pause';
344
376
  }
345
377
  return { decision };
346
378
  }
347
379
 
348
380
  // ── shared drive loop ────────────────────────────────────────────────────────────
349
381
 
382
+ /**
383
+ * Whether stdin could ever deliver an interactive answer.
384
+ *
385
+ * A TTY always can. A pipe, socket or file redirect MAY — a wrapper script that
386
+ * feeds answers is legitimate — so those pass. `/dev/null` never can: it EOFs on
387
+ * the first read, so the run would start, spend a real agent call, and then be
388
+ * abandoned mid-question. Measured on darwin (and matching linux): `< /dev/null`,
389
+ * a closed fd 0 (Node reopens it on /dev/null) and `spawn(…, {stdio:['ignore',…]})`
390
+ * are all non-TTY CHARACTER devices, while a pipe is a fifo/socket and a redirect
391
+ * is a regular file. An fstat that throws answers "yes" — never refuse a run on a
392
+ * guess.
393
+ */
394
+ function stdinCanAnswer() {
395
+ if (process.stdin.isTTY) return true;
396
+ try {
397
+ return !fstatSync(0).isCharacterDevice();
398
+ } catch {
399
+ return true;
400
+ }
401
+ }
402
+
350
403
  /**
351
404
  * Wire readline Q&A, log/phase rendering, and SIGINT pause/stop onto an
352
405
  * orchestrator, then drive it. `start` launches run() or resume(). Returns the
353
- * process exit code (0 for done/paused, 1 otherwise).
406
+ * process exit code (pauseExitCode, failure-policy.mjs):
407
+ * 0 done — and an INTERACTIVE pause the user chose or a limit/cap forced (they
408
+ * witnessed it and can resume);
409
+ * 1 a terminal error (a launch failure, an unrecoverable resume, a stop) and an
410
+ * INTERACTIVE pause an error forced;
411
+ * 2 a usage error (fail());
412
+ * 3 any pause under --yes — the run parked itself (auth/quota/usage limit,
413
+ * exhausted retries, an error) with nobody attached to resume it, so a
414
+ * wrapper must not read success. Under --yes a parked run's cause prints
415
+ * on STDOUT with the pause block; only a terminal error reaches stderr.
354
416
  */
355
417
  async function attachAndDrive(orch, flags, start) {
418
+ // Refuse an unanswerable interactive run BEFORE start(). The orchestrator
419
+ // constructor is pure (createPipeline runs inside run()), so nothing exists yet:
420
+ // no pipelines row, no run root, no worktree, no spend. Without this, a
421
+ // `worca --prompt … < /dev/null` reached the first clarify question, printed
422
+ // `Failed to read answer: readline was closed` and exited 0 with the row left
423
+ // `running` — a CI job read success on an abandoned run.
424
+ if (!flags.auto && !stdinCanAnswer()) {
425
+ fail('stdin cannot answer prompts (it is /dev/null or closed) — pass --yes for a non-interactive run.');
426
+ }
356
427
  const rl = flags.auto ? null : makeRl();
357
428
  let answering = false; // serialize interactive prompts vs. log rendering
429
+ let answerFailure = null; // a question we could not answer -> non-zero exit
430
+
431
+ /**
432
+ * Abandon a prompt we cannot answer: stop the run and force a non-zero exit, so
433
+ * the row never stays `running` and no caller reads success off an abandoned run.
434
+ * A pause/stop already in flight owns the outcome — Ctrl+C must still end
435
+ * `paused`, never `stopped`.
436
+ */
437
+ const abandonAnswer = (err) => {
438
+ if (orch.pauseRequested || !orch.state || orch.state.status !== 'running') return;
439
+ answerFailure = err;
440
+ process.stderr.write('worca: cannot continue without an answer — stopping the run. '
441
+ + 'Use --yes for a non-interactive run.\n');
442
+ // Deferred by ONE microtask, for the same reason answers are: _ask emits
443
+ // `question` BEFORE it parks pendingQuestion, so a SYNCHRONOUS throw in the
444
+ // handler below would reach stop() with nothing parked to reject — and the ask
445
+ // parked a moment later would then hang the run forever, which is the very
446
+ // outcome this guard exists to prevent.
447
+ queueMicrotask(() => {
448
+ if (orch.pauseRequested || !orch.state || orch.state.status !== 'running') return;
449
+ orch.stop();
450
+ });
451
+ };
452
+
453
+ // stdin reaching EOF *while a question is open* does not throw: readline simply
454
+ // closes and never invokes the question callback, so the awaited answer never
455
+ // settles, the event loop drains and node exits 0 with the run abandoned. Treat
456
+ // it as a failed answer — but ONLY while the run is still running: our own
457
+ // rl.close() in the finally below also fires with `answering` still true when a
458
+ // prompt was interrupted by Ctrl+C (the pause settles start() first, and the
459
+ // parked rl.question never resolves), and that is not a lost answer.
460
+ if (rl) {
461
+ rl.on('close', () => {
462
+ if (!answering) return;
463
+ if (orch.pauseRequested || !orch.state || orch.state.status !== 'running') return;
464
+ process.stderr.write('Failed to read answer: stdin closed\n');
465
+ abandonAnswer(new Error('stdin closed'));
466
+ });
467
+ }
358
468
 
359
469
  // ── event wiring ──────────────────────────────────────────────────────────────
360
- orch.on('phase', ({ phase, cycle, status }) => {
361
- out(`${statusMark(status)} ${c('bold', phaseLabel(phase, cycle))} ${c('gray', status)}`);
470
+ // The run renders its `exec` stream and nothing else: the v1 `phase` event and
471
+ // its renderer died with the v1 engine, and the preflight/done BOOKENDS are
472
+ // exec rows now (x:preflight:1 / x:done:1) that render nothing.
473
+ orch.on('exec', (ev) => {
474
+ // A user STOP surfaces as `exec error 'aborted'` on the in-flight execution
475
+ // while its ledger row is 'stopped'; the harness prints the stop line itself.
476
+ if (ev.status === 'error' && orch.state && orch.state.status === 'stopped') return;
477
+ // The exec payload carries no durationMs (spec §5.7) — the ledger rows do,
478
+ // and they are final by the time a terminal exec arrives. A composite
479
+ // parent has no row of its own: its slices (parentExecutionId) are summed,
480
+ // time and spend alike; a slice's own event keeps its own cost.
481
+ let e = ev;
482
+ if (ev.status !== 'start') {
483
+ const rows = Array.isArray(orch.state && orch.state.steps) ? orch.state.steps : [];
484
+ // By `executionId`, never by `key` — Task 14's rule for step rows: today's
485
+ // bookends are key-only, and a v1 row's key is a phase name.
486
+ const mine = rows.filter((s) => s && (s.executionId === ev.executionId || s.parentExecutionId === ev.executionId));
487
+ if (mine.length) {
488
+ e = { ...ev, durationMs: mine.reduce((a, s) => a + (s.activeMs || 0), 0) };
489
+ if (ev.kind !== 'task') e.costUsd = mine.reduce((a, s) => a + (s.costUsd || 0), 0);
490
+ }
491
+ }
492
+ const line = formatExecLine(e, orch.state && orch.state.stepper, { color: c });
493
+ if (line) out(line);
362
494
  });
363
495
 
364
496
  orch.on('log', ({ source, level, text }) => {
@@ -375,16 +507,24 @@ async function attachAndDrive(orch, flags, start) {
375
507
  process.stderr.write(c('red', `Error: ${message}`) + '\n');
376
508
  });
377
509
 
378
- orch.on('question', async ({ id, kind, questions, issues, recovery, agent }) => {
510
+ // The WHOLE payload is kept: a graph run's gate question carries `wireId`,
511
+ // which the header formatter resolves against the manifest.
512
+ orch.on('question', async (payload) => {
513
+ const { id, kind, questions, issues, recovery, agent } = payload;
379
514
  if (flags.auto || !rl) return; // auto mode resolves internally
380
515
  answering = true;
381
516
  try {
382
517
  if (kind === 'clarify') {
383
- const payload = await askClarify(rl, questions || []);
384
- orch.answer(id, payload);
518
+ const answer = await askClarify(rl, questions || []);
519
+ orch.answer(id, answer);
385
520
  } else if (kind === 'gate') {
386
- const payload = await askGate(rl, issues || []);
387
- orch.answer(id, payload);
521
+ // One engine: every gate question is a graph question, so the header is
522
+ // built unconditionally. (The `graphRun()` gate that used to guard this
523
+ // died with the phase listener — leaving the call was a ReferenceError
524
+ // waiting for the first interactive gate.)
525
+ const answer = await askGate(rl, issues || [],
526
+ formatGateHeader(payload, orch.state && orch.state.stepper));
527
+ orch.answer(id, answer);
388
528
  } else if (kind === 'recovery') {
389
529
  const payload = await askRecovery(rl, recovery);
390
530
  orch.answer(id, payload);
@@ -395,6 +535,13 @@ async function attachAndDrive(orch, flags, start) {
395
535
  }
396
536
  } catch (err) {
397
537
  process.stderr.write(`Failed to read answer: ${err?.message || err}\n`);
538
+ // Never swallow: orch.answer() was not called, so the ask stays open and the
539
+ // run would hang on it (or be abandoned at EOF with its row left `running`
540
+ // while node exits 0). This is also the arm any THROW inside askClarify /
541
+ // askGate / askRecovery lands in — the shape the P6 graphRun() ReferenceError
542
+ // took — so failing loudly here is what turns that class of bug into a
543
+ // visible failure instead of a silent hang.
544
+ abandonAnswer(err);
398
545
  } finally {
399
546
  answering = false;
400
547
  }
@@ -434,8 +581,30 @@ async function attachAndDrive(orch, flags, start) {
434
581
  out('');
435
582
  if (result?.status === 'done') {
436
583
  out(c('green', c('bold', 'Pipeline complete.')));
584
+ // v2 runs: `Result: <path|value>` (or the amber quiescence line), then
585
+ // `N executions · <active> active · $<cost>`; [] on a v1 run.
586
+ const summary = formatRunSummary(orch.state);
587
+ if (summary.length) {
588
+ out(summary[0].startsWith('Finished at quiescence') ? c('yellow', summary[0]) : summary[0]);
589
+ for (const line of summary.slice(1)) out(line);
590
+ }
437
591
  } else if (result?.status === 'paused') {
438
- out(c('yellow', result?.reason ? `Pipeline paused: ${result.reason}` : 'Pipeline paused.'));
592
+ // An error-pause reads as a failure the user can pick up again: the cause on
593
+ // its own line, then the reassurance that nothing was thrown away.
594
+ if (result.reason === REASON.ERROR) {
595
+ out(c('red', c('bold', 'Pipeline paused after an error.')));
596
+ if (result.detail) out(c('red', ` ${result.detail}`));
597
+ out(c('yellow', 'Nothing was discarded: the worktree and the run position are kept.'));
598
+ } else if (result.reason === REASON.RECOVERABLE) {
599
+ out(c('yellow', c('bold', 'Pipeline paused on a recoverable error — resume once it clears.')));
600
+ if (result.detail) out(c('yellow', ` ${result.detail}`));
601
+ out(c('yellow', 'Nothing was discarded: the worktree and the run position are kept.'));
602
+ } else if (result?.reason) {
603
+ const label = describePauseReason(result.reason) || result.reason;
604
+ out(c('yellow', `Pipeline paused: ${label}${result.detail ? ` — ${result.detail}` : ''}`));
605
+ } else {
606
+ out(c('yellow', 'Pipeline paused.'));
607
+ }
439
608
  out(`Resume with: ${c('bold', `worca resume ${orch.state.id}`)}`);
440
609
  } else if (result?.status === 'stopped') {
441
610
  out(c('yellow', 'Pipeline stopped.'));
@@ -445,7 +614,17 @@ async function attachAndDrive(orch, flags, start) {
445
614
  if (result?.pipelineDir) {
446
615
  out(`Pipeline directory: ${c('bold', result.pipelineDir)}`);
447
616
  }
448
- return result?.status === 'done' || result?.status === 'paused' ? 0 : 1;
617
+ // An unanswered question is a failure even if the run somehow settled `done`.
618
+ if (answerFailure) return 1;
619
+ if (result?.status === 'done') return 0;
620
+ // The exit code for a pause is a consequence of its reason (failure-policy.mjs):
621
+ // 0 only when someone is attached to resume it (interactive — pinned by the
622
+ // MAJ-7 Ctrl+C pitfall test) and no error forced it; 1 for an interactive
623
+ // error-pause; 3 under --yes, where every pause is the run parking ITSELF with
624
+ // nobody left to resume (0 would let a CI job go green on a run that did no
625
+ // work; 2 is fail()'s usage-error code).
626
+ if (result?.status === 'paused') return pauseExitCode(result.reason, flags.auto);
627
+ return 1;
449
628
  }
450
629
 
451
630
  // ── subcommands ──────────────────────────────────────────────────────────────────
@@ -570,6 +749,13 @@ async function cmdDoctor() {
570
749
  } catch (err) {
571
750
  process.stderr.write(`worca doctor: reconcile failed: ${err?.message || err}\n`);
572
751
  }
752
+ try {
753
+ const { sweepV1Runs } = await import('../core/db.mjs');
754
+ const swept = sweepV1Runs();
755
+ if (swept.length) out(`retired ${swept.length} run(s) paused on the v1 engine`);
756
+ } catch (err) {
757
+ process.stderr.write(`worca doctor: v1-run sweep failed: ${err?.message || err}\n`);
758
+ }
573
759
  try {
574
760
  // The injected callbacks THROW on a DB failure instead of reporting "no row"
575
761
  // (artifacts.mjs#runRootSweepLookups); the sweep records each throw in `failed`
@@ -589,6 +775,16 @@ async function cmdDoctor() {
589
775
  } catch (err) {
590
776
  process.stderr.write(`worca doctor: run-root sweep failed: ${err?.message || err}\n`);
591
777
  }
778
+ // P4: BEFORE the legacy return 0 — that block short-circuits the whole function
779
+ // whenever the effective mode is not `detached` (the default), so an ask-worktree
780
+ // sweep appended after it would never run for most users.
781
+ try {
782
+ const { sweepAskWorktrees } = await import('../core/ask/worktrees.mjs');
783
+ const res = await sweepAskWorktrees({ log: (level, msg) => out(level === 'warn' ? c('yellow', msg) : msg) });
784
+ out(`ask worktrees: removed ${res.removedDirs} orphan dir(s), dropped ${res.prunedRows} stale row(s), skipped ${res.failed}`);
785
+ } catch (err) {
786
+ process.stderr.write(`worca doctor: ask-worktree sweep failed: ${err?.message || err}\n`);
787
+ }
592
788
  try {
593
789
  // A TOTAL no-op while the effective mode is `legacy`: those paths hold every live
594
790
  // and every paused run, so sweeping them would make the documented §10 rollback
@@ -725,6 +921,19 @@ async function cmdResume(argv) {
725
921
  process.stderr.write(`pipeline ${id} has no resume point\n`);
726
922
  return 1;
727
923
  }
924
+ if (saved.resumePoint.version !== 2) {
925
+ const { V1_RUN_RETIRED } = await import('../core/db.mjs');
926
+ process.stderr.write(`worca resume: ${V1_RUN_RETIRED}\n`);
927
+ return 2;
928
+ }
929
+ // The v1 sweep runs AFTER this run's own guards: sweeping FIRST would NULL the
930
+ // point under test, so the caller would read "has no resume point" instead of
931
+ // the honest retirement message above.
932
+ try {
933
+ const { sweepV1Runs } = await import('../core/db.mjs');
934
+ const swept = sweepV1Runs();
935
+ if (swept.length) out(`retired ${swept.length} run(s) paused on the v1 engine`);
936
+ } catch { /* best-effort: resume still works if the sweep fails */ }
728
937
  if (saved.row.archived_at) {
729
938
  process.stderr.write('worca resume: pipeline is archived\n');
730
939
  return 1;
@@ -781,7 +990,7 @@ async function cmdResume(argv) {
781
990
  return 1;
782
991
  }
783
992
 
784
- const orch = createOrchestrator({
993
+ const orch = await createOrchestratorFor({
785
994
  projectDir,
786
995
  ...(workspace ? { workspace } : {}),
787
996
  claude: { mock },
@@ -808,9 +1017,10 @@ Usage:
808
1017
  worca plugin enable <name> | disable <name> Toggle without removing files
809
1018
  worca plugin doctor [name] [--fix] Health checks (--fix re-runs deterministic setup on failure)
810
1019
  worca plugin link <dir> Dev mode: use a local dir as "current"
1020
+ worca plugin reimport <name> Re-read the plugin's pipeline templates (a linked dir is live-edited)
811
1021
  worca plugin init <name> [--dir <D>] [--with task-source,agents,skills,workflows]
812
1022
  worca plugin validate <dir> [--strict] Lint a plugin dir (--strict: unknown fields error)
813
- worca plugin exec <name> <sourceId> <op> [--args '<json>'] [--inspect] Debug one connector op
1023
+ worca plugin exec <name> <sourceId> <op> [--args '<json>'] [--profile <id>] [--inspect] Debug one connector op
814
1024
  worca plugin channel <name> <channelId> [--check] [--inspect] Run a chat channel worker in the
815
1025
  foreground (typed lines = simulated inbound); --check runs
816
1026
  the module's validateConfig once and exits
@@ -907,6 +1117,15 @@ function printInventory(inv) {
907
1117
  for (const cmd of i.setupCommands || []) out(` setup: ${cmd}`);
908
1118
  }
909
1119
 
1120
+ /** Contributions worca refused to load (spec §9.3): one yellow line each, so a
1121
+ * receipt or a list never claims an agent/template that exists nowhere. */
1122
+ function printIgnored(ignored) {
1123
+ const list = Array.isArray(ignored) ? ignored : [];
1124
+ if (!list.length) return;
1125
+ out(c('yellow', ` ${list.length} contribution${list.length > 1 ? 's' : ''} ignored:`));
1126
+ for (const i of list) out(c('yellow', ` ${i.file} — ${i.reason}`));
1127
+ }
1128
+
910
1129
  /** kebab plugin name -> camelCase stem for the scaffolded example agent key. */
911
1130
  function camelizePluginName(name) {
912
1131
  return name.replace(/-([a-z0-9])/g, (_, ch) => ch.toUpperCase());
@@ -942,8 +1161,8 @@ async function pluginInit(rest) {
942
1161
  const manifestObj = {
943
1162
  name,
944
1163
  version: '0.1.0',
945
- description: 'Scaffolded worca-cc plugin — edit me',
946
- engines: { 'worca-cc-api': '>=1 <2' },
1164
+ description: 'Scaffolded worca plugin — edit me',
1165
+ engines: { 'worca-cc-api': '>=3 <4' },
947
1166
  };
948
1167
  if (withParts.includes('task-source')) {
949
1168
  manifestObj.taskSources = [{
@@ -994,14 +1213,15 @@ async function pluginInit(rest) {
994
1213
  }
995
1214
  if (withParts.includes('agents')) {
996
1215
  files.set(`agents/${agentKey}.meta.json`, JSON.stringify({
1216
+ metaVersion: 2,
997
1217
  key: agentKey,
998
1218
  displayName: 'Example Helper',
999
1219
  description: `Example agent installed by the ${name} plugin`,
1000
1220
  color: 'amber',
1001
1221
  agentFile: `${agentKey}.md`,
1002
1222
  runnerType: 'producer',
1003
- consumes: ['userPrompt'],
1004
- produces: ['code'],
1223
+ inputs: [{ id: 'task', type: 'md', required: true }],
1224
+ outputs: [{ id: 'notes', type: 'md', filename: 'notes.md', store: 'run' }],
1005
1225
  ...(withParts.includes('skills') ? { requiresSkills: ['example-skill'] } : {}),
1006
1226
  order: 900,
1007
1227
  }, null, 2) + '\n');
@@ -1013,7 +1233,7 @@ async function pluginInit(rest) {
1013
1233
  'model: inherit',
1014
1234
  '---',
1015
1235
  '',
1016
- `You are an example agent shipped by the "${name}" worca-cc plugin.`,
1236
+ `You are an example agent shipped by the "${name}" worca plugin.`,
1017
1237
  'Acknowledge the task you were given and describe what a real agent would do here.',
1018
1238
  '',
1019
1239
  ].join('\n'));
@@ -1033,12 +1253,21 @@ async function pluginInit(rest) {
1033
1253
  files.set('skills/example-skill/helper.sh', '#!/bin/sh\necho "example-skill helper ok"\n');
1034
1254
  }
1035
1255
  if (withParts.includes('workflows')) {
1256
+ // A v2 graph: the Task and End cards are mandatory (V20/V21) and every input
1257
+ // takes exactly one wire (V7). Ports come from the sidecar above.
1036
1258
  files.set('workflows/example-flow.json', JSON.stringify({
1037
1259
  name: `${name} example flow`,
1038
- version: 1,
1260
+ version: 2,
1039
1261
  domain: 'general',
1040
- steps: [[{ id: 's0_0', key: agentKey }]],
1041
- feedbacks: [],
1262
+ nodes: [
1263
+ { id: 'n_task', kind: 'task', x: 40, y: 200, config: {} },
1264
+ { id: 'n_helper', kind: 'agent', key: agentKey, x: 320, y: 200, config: {} },
1265
+ { id: 'n_end', kind: 'end', x: 600, y: 200, config: {} },
1266
+ ],
1267
+ wires: [
1268
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_helper', port: 'task' } },
1269
+ { id: 'w2', from: { node: 'n_helper', port: 'notes' }, to: { node: 'n_end', port: 'result' } },
1270
+ ],
1042
1271
  }, null, 2) + '\n');
1043
1272
  }
1044
1273
  files.set('worca-cc-plugin.json', JSON.stringify(manifestObj, null, 2) + '\n');
@@ -1147,6 +1376,7 @@ async function cmdPlugin(argv) {
1147
1376
  const res = await store.installPlugin({ repoUrl, subdir: entry.subdir, name, sha, ...(marketplace ? { marketplace } : {}) });
1148
1377
  out('installed:');
1149
1378
  printInventory(res.inventory);
1379
+ printIgnored(res.ignored);
1150
1380
  return 0;
1151
1381
  }
1152
1382
 
@@ -1160,6 +1390,8 @@ async function cmdPlugin(argv) {
1160
1390
  const version = p.linked ? 'linked' : p.version || (p.pinnedSha || '').slice(0, 7);
1161
1391
  const flags = [p.enabled ? 'enabled' : 'disabled', ...(p.linked ? ['linked'] : [])].join(', ');
1162
1392
  out(`${p.name}\t${version}\t${flags}\t${contribSummary(p.contributions)}`);
1393
+ if (p.apiMismatch) out(c('yellow', ` ${p.apiMismatch.message}`));
1394
+ printIgnored(p.ignored);
1163
1395
  }
1164
1396
  return 0;
1165
1397
  }
@@ -1259,12 +1491,27 @@ async function cmdPlugin(argv) {
1259
1491
  if (!dir) fail('Usage: worca plugin link <dir>');
1260
1492
  const abs = resolve(process.cwd(), dir);
1261
1493
  const v = manifestMod.validatePluginDir(abs);
1262
- if (!v.ok) {
1263
- for (const p of v.problems) process.stderr.write(`${p.level}: ${p.message}\n`);
1264
- return 2;
1265
- }
1266
- store.linkPlugin(v.manifest.name, abs);
1494
+ // Print EVERY level, pass or fail: a link that SUCCEEDS with warnings is
1495
+ // the mid-migration case the author most needs to read (MAJ-12) — an
1496
+ // API-1 plugin keeps linking, and now says why its agent is ignored.
1497
+ for (const p of v.problems) process.stderr.write(`${p.level}: ${p.message}\n`);
1498
+ if (!v.ok) return 2;
1499
+ const linked = await store.linkPlugin(v.manifest.name, abs);
1267
1500
  out(`linked ${v.manifest.name} -> ${abs} (dev mode; doctor will warn)`);
1501
+ const n = linked.workflows.imported.length;
1502
+ if (n) out(` imported ${n} pipeline template${n === 1 ? '' : 's'} — edits to them need: worca plugin reimport ${v.manifest.name}`);
1503
+ printIgnored(store.ignoredContributions(v.manifest.name, abs, { workflowSkips: linked.workflows.skipped }));
1504
+ return 0;
1505
+ }
1506
+
1507
+ case 'reimport': {
1508
+ const a = pluginArgs(rest);
1509
+ const name = a._[0];
1510
+ if (!name) fail('Usage: worca plugin reimport <name>');
1511
+ const r = await store.reimportPlugin(name);
1512
+ const n = r.workflows.imported.length;
1513
+ out(`reimported ${name}: ${n} pipeline template${n === 1 ? '' : 's'}`);
1514
+ printIgnored(r.ignored);
1268
1515
  return 0;
1269
1516
  }
1270
1517
 
@@ -1286,9 +1533,9 @@ async function cmdPlugin(argv) {
1286
1533
  }
1287
1534
 
1288
1535
  case 'exec': {
1289
- const a = pluginArgs(rest, ['--args'], ['--inspect']);
1536
+ const a = pluginArgs(rest, ['--args', '--profile'], ['--inspect']);
1290
1537
  const [name, sourceId, op] = a._;
1291
- if (!name || !sourceId || !op) fail("Usage: worca plugin exec <name> <sourceId> <op> [--args '<json>'] [--inspect]");
1538
+ if (!name || !sourceId || !op) fail("Usage: worca plugin exec <name> <sourceId> <op> [--args '<json>'] [--profile <id>] [--inspect]");
1292
1539
  if (a.inspect) process.env.WORCA_PLUGIN_INSPECT = '1'; // shim spawns the child with --inspect-brk
1293
1540
  let args = {};
1294
1541
  if (a.args) {
@@ -1299,7 +1546,9 @@ async function cmdPlugin(argv) {
1299
1546
  }
1300
1547
  }
1301
1548
  const { callSource } = await import('../core/plugin-shim.mjs');
1302
- const result = await callSource({ plugin: name, sourceId, op, args });
1549
+ // --profile targets one instance of a multi-profile source; absent, the
1550
+ // shim falls back to the implicit default bucket (single-profile case).
1551
+ const result = await callSource({ plugin: name, sourceId, op, args, profile: a.profile || undefined });
1303
1552
  process.stdout.write(JSON.stringify(result, null, 2) + '\n'); // stdout = result ONLY
1304
1553
  return 0;
1305
1554
  }
@@ -1431,8 +1680,52 @@ async function cmdMarketplace(argv) {
1431
1680
 
1432
1681
  const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config']);
1433
1682
 
1683
+ /** Levenshtein distance, two-row. Only ever called on short argv tokens. */
1684
+ function editDistance(a, b) {
1685
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
1686
+ for (let i = 1; i <= a.length; i++) {
1687
+ const row = [i];
1688
+ for (let j = 1; j <= b.length; j++) {
1689
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1690
+ }
1691
+ prev = row;
1692
+ }
1693
+ return prev[b.length];
1694
+ }
1695
+
1696
+ /**
1697
+ * The subcommand a lone positional was probably meant to be, or null.
1698
+ *
1699
+ * A bare positional is a legal prompt (`worca "do the thing"`), so only a SINGLE
1700
+ * whitespace-free token that near-misses a real subcommand counts as a typo: edit
1701
+ * distance <= 2, or a strict prefix of at least 3 characters (`plug` -> `plugin`).
1702
+ * Refusing costs one retry with --prompt; running `worca reusme run-abc123` cuts a
1703
+ * worktree + feature branch and spends real tokens on a task named "reusme".
1704
+ */
1705
+ function nearestSubcommand(token) {
1706
+ if (!token || /\s/.test(token)) return null;
1707
+ // 'help' and 'version' are spliced into both loops: they are real CLI arms (the
1708
+ // head of main() / the module top) but deliberately absent from the dispatch
1709
+ // table, so without them a typo of either (`worca hlep`, `worca versoin`) is
1710
+ // distance >= 3 from everything and runs as a PROMPT.
1711
+ for (const name of [...SUBCOMMANDS, 'help', 'version']) {
1712
+ if (token.length >= 3 && name.length > token.length && name.startsWith(token)) return name;
1713
+ }
1714
+ let best = null;
1715
+ let bestD = 3; // strictly less than 3 == distance <= 2
1716
+ for (const name of [...SUBCOMMANDS, 'help', 'version']) {
1717
+ const d = editDistance(token, name);
1718
+ if (d < bestD) { bestD = d; best = name; }
1719
+ }
1720
+ return best;
1721
+ }
1722
+
1434
1723
  async function main() {
1435
1724
  const sub = process.argv[2];
1725
+ // `worca help` is what every CLI user types first; it is not a subcommand and
1726
+ // not a near-miss of one, so without this line it became a PROMPT and ran a
1727
+ // pipeline named "help" (MIN-51).
1728
+ if (sub === 'help') { process.stdout.write(HELP); return 0; }
1436
1729
  if (SUBCOMMANDS.has(sub)) {
1437
1730
  const rest = process.argv.slice(3);
1438
1731
  if (sub === 'add') return cmdAdd(rest);
@@ -1466,10 +1759,23 @@ async function main() {
1466
1759
  if (flags.mock) {
1467
1760
  process.env.WORCA_MOCK = '1';
1468
1761
  }
1762
+ // The mock runner routes EVERY dontAsk spawn to the Ask Worca mock (claude-runner.mjs
1763
+ // runMock), which writes no pipeline artifact — a mock pipeline under dontAsk dies
1764
+ // at its first artifact read with no hint why. Refuse the PAIR, not the mode.
1765
+ if (flags.permissionMode === 'dontAsk' && /^(1|true|yes|on)$/i.test(String(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK ?? ''))) {
1766
+ fail('--permission-mode dontAsk cannot be combined with --mock: the mock runner reserves it for the Ask Worca assistant.');
1767
+ }
1469
1768
 
1470
1769
  if (!flags.prompt && !flags.file) {
1471
- // Allow a bare positional prompt: `worca "do the thing"`.
1770
+ // Allow a bare positional prompt: `worca "do the thing"`. A lone token that
1771
+ // near-misses a subcommand is a typo, not a task — refuse it here, before a
1772
+ // pipeline row, a worktree or a feature branch exists.
1472
1773
  if (flags._.length) {
1774
+ const meant = nearestSubcommand(flags._[0]);
1775
+ if (meant) {
1776
+ if (meant === flags._[0]) fail(`"${meant}" is a subcommand and must come first: worca ${meant} [args] (to run a prompt with that word, use --prompt "\u2026")`);
1777
+ fail(`unknown subcommand "${flags._[0]}" — did you mean "${meant}"? (to run a prompt, use --prompt "\u2026")`);
1778
+ }
1473
1779
  flags.prompt = flags._.join(' ');
1474
1780
  } else {
1475
1781
  fail('Provide a task with --prompt "<text>" or --file <markdown>. See --help.');
@@ -1477,6 +1783,19 @@ async function main() {
1477
1783
  }
1478
1784
 
1479
1785
  const projectDir = resolve(flags.project);
1786
+ // A NAMED --file must be readable BEFORE anything starts. The readers used to
1787
+ // swallow the failure and run the whole pipeline on an empty prompt with exit 0
1788
+ // — in real mode that spends tokens and cuts a worktree + feature branch for
1789
+ // nothing. Relative paths resolve against the PROJECT dir, exactly as the
1790
+ // orchestrator's own read does.
1791
+ if (flags.file) {
1792
+ const { readPromptFile } = await import('../core/artifacts.mjs');
1793
+ try {
1794
+ await readPromptFile(projectDir, flags.file);
1795
+ } catch (err) {
1796
+ fail(err && err.message ? err.message : String(err));
1797
+ }
1798
+ }
1480
1799
  // Resolve extras against the shell cwd so relative paths are unambiguous.
1481
1800
  const extras = (flags.extras || []).map((p) => resolve(process.cwd(), p));
1482
1801
 
@@ -1490,13 +1809,24 @@ async function main() {
1490
1809
  return 1;
1491
1810
  }
1492
1811
 
1493
- const orch = createOrchestrator({
1812
+ // Validate --workflow before spawning anything: an unknown or archived template
1813
+ // must fail with one line, not a stack trace half-way through a run. The read row
1814
+ // doubles as createOrchestratorFor's routing hint (it skips a second row read).
1815
+ let row;
1816
+ if (flags.workflow) {
1817
+ const { assertRunnableWorkflow } = await import('../core/workflows.mjs');
1818
+ try { row = await assertRunnableWorkflow(flags.workflow); }
1819
+ catch (err) { fail(`${err && err.message ? err.message : String(err)}`); }
1820
+ }
1821
+
1822
+ const orch = await createOrchestratorFor({
1494
1823
  projectDir,
1495
1824
  prompt: flags.prompt || undefined,
1496
1825
  promptFile: flags.file || undefined,
1497
1826
  title: flags.title || undefined,
1498
1827
  extras,
1499
1828
  workflowId: flags.workflow || undefined,
1829
+ template: row,
1500
1830
  branch: { source: flags.sourceBranch, feature: flags.featureBranch },
1501
1831
  claude: {
1502
1832
  permissionMode: flags.permissionMode,