@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
@@ -10,12 +10,13 @@
10
10
 
11
11
  import { createInterface } from 'node:readline';
12
12
  import { spawn } from 'node:child_process';
13
+ import { fstatSync } from 'node:fs';
13
14
  import { fileURLToPath } from 'node:url';
14
15
  import { dirname, resolve, join, basename } from 'node:path';
15
16
  import process from 'node:process';
16
17
 
17
18
  import { preflightNode } from '../core/preflight-node.mjs';
18
- import { createOrchestrator } from '../core/orchestrator.mjs';
19
+ import { createOrchestratorFor } from '../core/engine-select.mjs';
19
20
  import {
20
21
  addProject,
21
22
  listProjects,
@@ -23,6 +24,7 @@ import {
23
24
  normalizeProjectPath,
24
25
  } from '../core/projects.mjs';
25
26
  import { projectKey } from '../core/store.mjs';
27
+ import { formatExecLine, formatGateHeader, formatRunSummary } from './render.mjs';
26
28
 
27
29
  // ── node:sqlite runtime guard + warning filter ──────────────────────────────────
28
30
  // Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
@@ -46,6 +48,14 @@ const REPO_ROOT = resolve(__dirname, '..', '..');
46
48
 
47
49
  // ── arg parsing ────────────────────────────────────────────────────────────────
48
50
 
51
+ /**
52
+ * The permission modes a pipeline run may be launched with. Deliberately NOT the
53
+ * full set claude accepts: `dontAsk` belongs to the Ask Worca runner alone
54
+ * (core/ask/spawn.mjs), which owns its own spawn options and never comes through
55
+ * here.
56
+ */
57
+ const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk'];
58
+
49
59
  /**
50
60
  * Parse argv into a flags object. Supports "--flag value" and "--flag=value", plus the
51
61
  * boolean flags --mock, --yes/--non-interactive, --ui, -h/--help.
@@ -134,6 +144,12 @@ function parseArgs(argv) {
134
144
  const p = part.trim();
135
145
  if (p) out.extras.push(p);
136
146
  }
147
+ } else if (key === 'permissionMode' && !PERMISSION_MODES.includes(String(value))) {
148
+ // A pipeline run's mode reaches claude-runner as-is. `dontAsk` is a legitimate
149
+ // headless mode for a REAL run (allowedTools decide what runs); only the
150
+ // MOCK runner treats it as the Ask Worca recipe — that pair is refused below,
151
+ // after --mock/WORCA_MOCK are known (review of PR #376).
152
+ fail(`--permission-mode must be one of ${PERMISSION_MODES.join(', ')}, got: ${value}`);
137
153
  } else {
138
154
  out[key] = value;
139
155
  }
@@ -165,12 +181,13 @@ function budgetRefusalDetail(b) {
165
181
  + `resets ${when} (in ${days}d ${hours}h)`;
166
182
  }
167
183
 
168
- const HELP = `worca — deterministic multi-agent pipeline (Plan -> Refine -> Implement -> Review)
184
+ const HELP = `worca — node-graph multi-agent pipelines
169
185
 
170
186
  Usage:
171
187
  worca <subcommand> [args]
172
188
  worca --prompt "<task>" [--project <dir>] [options]
173
189
  worca --file <task.md> [--project <dir>] [options]
190
+ worca "<task>" [--project <dir>] [options] (bare prompt; quote it)
174
191
  worca --ui
175
192
  worca --install <targetDir> [--force]
176
193
 
@@ -182,9 +199,10 @@ Subcommands:
182
199
  [--ignore-cost-cap] Resume past this pipeline's cost cap (persists on the run).
183
200
  doctor Reconcile crashed runs and sweep leftover run roots.
184
201
  plugin <cmd> [...] Manage plugins: add|install|list|update|remove|purge|enable|
185
- disable|doctor|link|init|validate|exec. See: worca plugin help
202
+ disable|doctor|link|reimport|init|validate|exec. See: worca plugin help
186
203
  marketplace <cmd> [...] Manage plugin marketplaces: add|list|refresh|remove. See: worca marketplace help
187
204
  config [get|set|unset] Budget & cost-limit settings
205
+ help Print this help (same as --help).
188
206
 
189
207
  Options:
190
208
  --project <dir> Target project directory (default: cwd)
@@ -194,8 +212,9 @@ Options:
194
212
  --extras <paths> Extra files copied into the pipeline's extras/ folder
195
213
  (comma-separated; repeatable)
196
214
  --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)
215
+ --permission-mode <m> Claude permission mode: default | acceptEdits | plan |
216
+ bypassPermissions (default acceptEdits)
217
+ --workflow <id> Saved pipeline template to run (default: wf_default — the built-in graph)
199
218
  --source-branch <name> Branch to fork the per-run worktree from (default: current HEAD)
200
219
  --branch <name> Feature branch name (default: claude proposes one)
201
220
  --mock Offline mock mode (no claude, no tokens)
@@ -227,18 +246,7 @@ function out(s) {
227
246
  process.stdout.write(s + '\n');
228
247
  }
229
248
 
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
249
 
237
- function statusMark(status) {
238
- if (status === 'done') return c('green', '✓');
239
- if (status === 'start') return c('cyan', '▶');
240
- return c('gray', '•');
241
- }
242
250
 
243
251
  const LEVEL_COLOR = { info: 'reset', debug: 'gray', warn: 'yellow', error: 'red' };
244
252
 
@@ -300,9 +308,10 @@ async function askClarify(rl, questions) {
300
308
  * Ask a loop gate interactively. Shows the open blocking issues and the two choices.
301
309
  * Returns { decision: "continue" | "another" }.
302
310
  */
303
- async function askGate(rl, issues) {
311
+ async function askGate(rl, issues, header) {
304
312
  out('');
305
- out(c('yellow', c('bold', 'Loop gate maximum cycles reached.')));
313
+ // A graph run names the wire and its budget (`? Loop gate · Reviewer → Implementer 3/3 cycles used`).
314
+ out(c('yellow', c('bold', header || 'Loop gate — maximum cycles reached.')));
306
315
  out(c('yellow', 'Open critical/major issues:'));
307
316
  if (!issues || issues.length === 0) {
308
317
  out(' (none reported)');
@@ -347,18 +356,109 @@ async function askRecovery(rl, recovery) {
347
356
 
348
357
  // ── shared drive loop ────────────────────────────────────────────────────────────
349
358
 
359
+ /**
360
+ * Whether stdin could ever deliver an interactive answer.
361
+ *
362
+ * A TTY always can. A pipe, socket or file redirect MAY — a wrapper script that
363
+ * feeds answers is legitimate — so those pass. `/dev/null` never can: it EOFs on
364
+ * the first read, so the run would start, spend a real agent call, and then be
365
+ * abandoned mid-question. Measured on darwin (and matching linux): `< /dev/null`,
366
+ * a closed fd 0 (Node reopens it on /dev/null) and `spawn(…, {stdio:['ignore',…]})`
367
+ * are all non-TTY CHARACTER devices, while a pipe is a fifo/socket and a redirect
368
+ * is a regular file. An fstat that throws answers "yes" — never refuse a run on a
369
+ * guess.
370
+ */
371
+ function stdinCanAnswer() {
372
+ if (process.stdin.isTTY) return true;
373
+ try {
374
+ return !fstatSync(0).isCharacterDevice();
375
+ } catch {
376
+ return true;
377
+ }
378
+ }
379
+
350
380
  /**
351
381
  * Wire readline Q&A, log/phase rendering, and SIGINT pause/stop onto an
352
382
  * orchestrator, then drive it. `start` launches run() or resume(). Returns the
353
383
  * process exit code (0 for done/paused, 1 otherwise).
354
384
  */
355
385
  async function attachAndDrive(orch, flags, start) {
386
+ // Refuse an unanswerable interactive run BEFORE start(). The orchestrator
387
+ // constructor is pure (createPipeline runs inside run()), so nothing exists yet:
388
+ // no pipelines row, no run root, no worktree, no spend. Without this, a
389
+ // `worca --prompt … < /dev/null` reached the first clarify question, printed
390
+ // `Failed to read answer: readline was closed` and exited 0 with the row left
391
+ // `running` — a CI job read success on an abandoned run.
392
+ if (!flags.auto && !stdinCanAnswer()) {
393
+ fail('stdin cannot answer prompts (it is /dev/null or closed) — pass --yes for a non-interactive run.');
394
+ }
356
395
  const rl = flags.auto ? null : makeRl();
357
396
  let answering = false; // serialize interactive prompts vs. log rendering
397
+ let answerFailure = null; // a question we could not answer -> non-zero exit
398
+
399
+ /**
400
+ * Abandon a prompt we cannot answer: stop the run and force a non-zero exit, so
401
+ * the row never stays `running` and no caller reads success off an abandoned run.
402
+ * A pause/stop already in flight owns the outcome — Ctrl+C must still end
403
+ * `paused`, never `stopped`.
404
+ */
405
+ const abandonAnswer = (err) => {
406
+ if (orch.pauseRequested || !orch.state || orch.state.status !== 'running') return;
407
+ answerFailure = err;
408
+ process.stderr.write('worca: cannot continue without an answer — stopping the run. '
409
+ + 'Use --yes for a non-interactive run.\n');
410
+ // Deferred by ONE microtask, for the same reason answers are: _ask emits
411
+ // `question` BEFORE it parks pendingQuestion, so a SYNCHRONOUS throw in the
412
+ // handler below would reach stop() with nothing parked to reject — and the ask
413
+ // parked a moment later would then hang the run forever, which is the very
414
+ // outcome this guard exists to prevent.
415
+ queueMicrotask(() => {
416
+ if (orch.pauseRequested || !orch.state || orch.state.status !== 'running') return;
417
+ orch.stop();
418
+ });
419
+ };
420
+
421
+ // stdin reaching EOF *while a question is open* does not throw: readline simply
422
+ // closes and never invokes the question callback, so the awaited answer never
423
+ // settles, the event loop drains and node exits 0 with the run abandoned. Treat
424
+ // it as a failed answer — but ONLY while the run is still running: our own
425
+ // rl.close() in the finally below also fires with `answering` still true when a
426
+ // prompt was interrupted by Ctrl+C (the pause settles start() first, and the
427
+ // parked rl.question never resolves), and that is not a lost answer.
428
+ if (rl) {
429
+ rl.on('close', () => {
430
+ if (!answering) return;
431
+ if (orch.pauseRequested || !orch.state || orch.state.status !== 'running') return;
432
+ process.stderr.write('Failed to read answer: stdin closed\n');
433
+ abandonAnswer(new Error('stdin closed'));
434
+ });
435
+ }
358
436
 
359
437
  // ── event wiring ──────────────────────────────────────────────────────────────
360
- orch.on('phase', ({ phase, cycle, status }) => {
361
- out(`${statusMark(status)} ${c('bold', phaseLabel(phase, cycle))} ${c('gray', status)}`);
438
+ // The run renders its `exec` stream and nothing else: the v1 `phase` event and
439
+ // its renderer died with the v1 engine, and the preflight/done BOOKENDS are
440
+ // exec rows now (x:preflight:1 / x:done:1) that render nothing.
441
+ orch.on('exec', (ev) => {
442
+ // A user STOP surfaces as `exec error 'aborted'` on the in-flight execution
443
+ // while its ledger row is 'stopped'; the harness prints the stop line itself.
444
+ if (ev.status === 'error' && orch.state && orch.state.status === 'stopped') return;
445
+ // The exec payload carries no durationMs (spec §5.7) — the ledger rows do,
446
+ // and they are final by the time a terminal exec arrives. A composite
447
+ // parent has no row of its own: its slices (parentExecutionId) are summed,
448
+ // time and spend alike; a slice's own event keeps its own cost.
449
+ let e = ev;
450
+ if (ev.status !== 'start') {
451
+ const rows = Array.isArray(orch.state && orch.state.steps) ? orch.state.steps : [];
452
+ // By `executionId`, never by `key` — Task 14's rule for step rows: today's
453
+ // bookends are key-only, and a v1 row's key is a phase name.
454
+ const mine = rows.filter((s) => s && (s.executionId === ev.executionId || s.parentExecutionId === ev.executionId));
455
+ if (mine.length) {
456
+ e = { ...ev, durationMs: mine.reduce((a, s) => a + (s.activeMs || 0), 0) };
457
+ if (ev.kind !== 'task') e.costUsd = mine.reduce((a, s) => a + (s.costUsd || 0), 0);
458
+ }
459
+ }
460
+ const line = formatExecLine(e, orch.state && orch.state.stepper, { color: c });
461
+ if (line) out(line);
362
462
  });
363
463
 
364
464
  orch.on('log', ({ source, level, text }) => {
@@ -375,16 +475,24 @@ async function attachAndDrive(orch, flags, start) {
375
475
  process.stderr.write(c('red', `Error: ${message}`) + '\n');
376
476
  });
377
477
 
378
- orch.on('question', async ({ id, kind, questions, issues, recovery, agent }) => {
478
+ // The WHOLE payload is kept: a graph run's gate question carries `wireId`,
479
+ // which the header formatter resolves against the manifest.
480
+ orch.on('question', async (payload) => {
481
+ const { id, kind, questions, issues, recovery, agent } = payload;
379
482
  if (flags.auto || !rl) return; // auto mode resolves internally
380
483
  answering = true;
381
484
  try {
382
485
  if (kind === 'clarify') {
383
- const payload = await askClarify(rl, questions || []);
384
- orch.answer(id, payload);
486
+ const answer = await askClarify(rl, questions || []);
487
+ orch.answer(id, answer);
385
488
  } else if (kind === 'gate') {
386
- const payload = await askGate(rl, issues || []);
387
- orch.answer(id, payload);
489
+ // One engine: every gate question is a graph question, so the header is
490
+ // built unconditionally. (The `graphRun()` gate that used to guard this
491
+ // died with the phase listener — leaving the call was a ReferenceError
492
+ // waiting for the first interactive gate.)
493
+ const answer = await askGate(rl, issues || [],
494
+ formatGateHeader(payload, orch.state && orch.state.stepper));
495
+ orch.answer(id, answer);
388
496
  } else if (kind === 'recovery') {
389
497
  const payload = await askRecovery(rl, recovery);
390
498
  orch.answer(id, payload);
@@ -395,6 +503,13 @@ async function attachAndDrive(orch, flags, start) {
395
503
  }
396
504
  } catch (err) {
397
505
  process.stderr.write(`Failed to read answer: ${err?.message || err}\n`);
506
+ // Never swallow: orch.answer() was not called, so the ask stays open and the
507
+ // run would hang on it (or be abandoned at EOF with its row left `running`
508
+ // while node exits 0). This is also the arm any THROW inside askClarify /
509
+ // askGate / askRecovery lands in — the shape the P6 graphRun() ReferenceError
510
+ // took — so failing loudly here is what turns that class of bug into a
511
+ // visible failure instead of a silent hang.
512
+ abandonAnswer(err);
398
513
  } finally {
399
514
  answering = false;
400
515
  }
@@ -434,6 +549,13 @@ async function attachAndDrive(orch, flags, start) {
434
549
  out('');
435
550
  if (result?.status === 'done') {
436
551
  out(c('green', c('bold', 'Pipeline complete.')));
552
+ // v2 runs: `Result: <path|value>` (or the amber quiescence line), then
553
+ // `N executions · <active> active · $<cost>`; [] on a v1 run.
554
+ const summary = formatRunSummary(orch.state);
555
+ if (summary.length) {
556
+ out(summary[0].startsWith('Finished at quiescence') ? c('yellow', summary[0]) : summary[0]);
557
+ for (const line of summary.slice(1)) out(line);
558
+ }
437
559
  } else if (result?.status === 'paused') {
438
560
  out(c('yellow', result?.reason ? `Pipeline paused: ${result.reason}` : 'Pipeline paused.'));
439
561
  out(`Resume with: ${c('bold', `worca resume ${orch.state.id}`)}`);
@@ -445,6 +567,8 @@ async function attachAndDrive(orch, flags, start) {
445
567
  if (result?.pipelineDir) {
446
568
  out(`Pipeline directory: ${c('bold', result.pipelineDir)}`);
447
569
  }
570
+ // An unanswered question is a failure even if the run somehow settled `done`.
571
+ if (answerFailure) return 1;
448
572
  return result?.status === 'done' || result?.status === 'paused' ? 0 : 1;
449
573
  }
450
574
 
@@ -570,6 +694,13 @@ async function cmdDoctor() {
570
694
  } catch (err) {
571
695
  process.stderr.write(`worca doctor: reconcile failed: ${err?.message || err}\n`);
572
696
  }
697
+ try {
698
+ const { sweepV1Runs } = await import('../core/db.mjs');
699
+ const swept = sweepV1Runs();
700
+ if (swept.length) out(`retired ${swept.length} run(s) paused on the v1 engine`);
701
+ } catch (err) {
702
+ process.stderr.write(`worca doctor: v1-run sweep failed: ${err?.message || err}\n`);
703
+ }
573
704
  try {
574
705
  // The injected callbacks THROW on a DB failure instead of reporting "no row"
575
706
  // (artifacts.mjs#runRootSweepLookups); the sweep records each throw in `failed`
@@ -589,6 +720,16 @@ async function cmdDoctor() {
589
720
  } catch (err) {
590
721
  process.stderr.write(`worca doctor: run-root sweep failed: ${err?.message || err}\n`);
591
722
  }
723
+ // P4: BEFORE the legacy return 0 — that block short-circuits the whole function
724
+ // whenever the effective mode is not `detached` (the default), so an ask-worktree
725
+ // sweep appended after it would never run for most users.
726
+ try {
727
+ const { sweepAskWorktrees } = await import('../core/ask/worktrees.mjs');
728
+ const res = await sweepAskWorktrees({ log: (level, msg) => out(level === 'warn' ? c('yellow', msg) : msg) });
729
+ out(`ask worktrees: removed ${res.removedDirs} orphan dir(s), dropped ${res.prunedRows} stale row(s), skipped ${res.failed}`);
730
+ } catch (err) {
731
+ process.stderr.write(`worca doctor: ask-worktree sweep failed: ${err?.message || err}\n`);
732
+ }
592
733
  try {
593
734
  // A TOTAL no-op while the effective mode is `legacy`: those paths hold every live
594
735
  // and every paused run, so sweeping them would make the documented §10 rollback
@@ -725,6 +866,19 @@ async function cmdResume(argv) {
725
866
  process.stderr.write(`pipeline ${id} has no resume point\n`);
726
867
  return 1;
727
868
  }
869
+ if (saved.resumePoint.version !== 2) {
870
+ const { V1_RUN_RETIRED } = await import('../core/db.mjs');
871
+ process.stderr.write(`worca resume: ${V1_RUN_RETIRED}\n`);
872
+ return 2;
873
+ }
874
+ // The v1 sweep runs AFTER this run's own guards: sweeping FIRST would NULL the
875
+ // point under test, so the caller would read "has no resume point" instead of
876
+ // the honest retirement message above.
877
+ try {
878
+ const { sweepV1Runs } = await import('../core/db.mjs');
879
+ const swept = sweepV1Runs();
880
+ if (swept.length) out(`retired ${swept.length} run(s) paused on the v1 engine`);
881
+ } catch { /* best-effort: resume still works if the sweep fails */ }
728
882
  if (saved.row.archived_at) {
729
883
  process.stderr.write('worca resume: pipeline is archived\n');
730
884
  return 1;
@@ -781,7 +935,7 @@ async function cmdResume(argv) {
781
935
  return 1;
782
936
  }
783
937
 
784
- const orch = createOrchestrator({
938
+ const orch = await createOrchestratorFor({
785
939
  projectDir,
786
940
  ...(workspace ? { workspace } : {}),
787
941
  claude: { mock },
@@ -808,9 +962,10 @@ Usage:
808
962
  worca plugin enable <name> | disable <name> Toggle without removing files
809
963
  worca plugin doctor [name] [--fix] Health checks (--fix re-runs deterministic setup on failure)
810
964
  worca plugin link <dir> Dev mode: use a local dir as "current"
965
+ worca plugin reimport <name> Re-read the plugin's pipeline templates (a linked dir is live-edited)
811
966
  worca plugin init <name> [--dir <D>] [--with task-source,agents,skills,workflows]
812
967
  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
968
+ worca plugin exec <name> <sourceId> <op> [--args '<json>'] [--profile <id>] [--inspect] Debug one connector op
814
969
  worca plugin channel <name> <channelId> [--check] [--inspect] Run a chat channel worker in the
815
970
  foreground (typed lines = simulated inbound); --check runs
816
971
  the module's validateConfig once and exits
@@ -907,6 +1062,15 @@ function printInventory(inv) {
907
1062
  for (const cmd of i.setupCommands || []) out(` setup: ${cmd}`);
908
1063
  }
909
1064
 
1065
+ /** Contributions worca refused to load (spec §9.3): one yellow line each, so a
1066
+ * receipt or a list never claims an agent/template that exists nowhere. */
1067
+ function printIgnored(ignored) {
1068
+ const list = Array.isArray(ignored) ? ignored : [];
1069
+ if (!list.length) return;
1070
+ out(c('yellow', ` ${list.length} contribution${list.length > 1 ? 's' : ''} ignored:`));
1071
+ for (const i of list) out(c('yellow', ` ${i.file} — ${i.reason}`));
1072
+ }
1073
+
910
1074
  /** kebab plugin name -> camelCase stem for the scaffolded example agent key. */
911
1075
  function camelizePluginName(name) {
912
1076
  return name.replace(/-([a-z0-9])/g, (_, ch) => ch.toUpperCase());
@@ -942,8 +1106,8 @@ async function pluginInit(rest) {
942
1106
  const manifestObj = {
943
1107
  name,
944
1108
  version: '0.1.0',
945
- description: 'Scaffolded worca-cc plugin — edit me',
946
- engines: { 'worca-cc-api': '>=1 <2' },
1109
+ description: 'Scaffolded worca plugin — edit me',
1110
+ engines: { 'worca-cc-api': '>=3 <4' },
947
1111
  };
948
1112
  if (withParts.includes('task-source')) {
949
1113
  manifestObj.taskSources = [{
@@ -994,14 +1158,15 @@ async function pluginInit(rest) {
994
1158
  }
995
1159
  if (withParts.includes('agents')) {
996
1160
  files.set(`agents/${agentKey}.meta.json`, JSON.stringify({
1161
+ metaVersion: 2,
997
1162
  key: agentKey,
998
1163
  displayName: 'Example Helper',
999
1164
  description: `Example agent installed by the ${name} plugin`,
1000
1165
  color: 'amber',
1001
1166
  agentFile: `${agentKey}.md`,
1002
1167
  runnerType: 'producer',
1003
- consumes: ['userPrompt'],
1004
- produces: ['code'],
1168
+ inputs: [{ id: 'task', type: 'md', required: true }],
1169
+ outputs: [{ id: 'notes', type: 'md', filename: 'notes.md', store: 'run' }],
1005
1170
  ...(withParts.includes('skills') ? { requiresSkills: ['example-skill'] } : {}),
1006
1171
  order: 900,
1007
1172
  }, null, 2) + '\n');
@@ -1013,7 +1178,7 @@ async function pluginInit(rest) {
1013
1178
  'model: inherit',
1014
1179
  '---',
1015
1180
  '',
1016
- `You are an example agent shipped by the "${name}" worca-cc plugin.`,
1181
+ `You are an example agent shipped by the "${name}" worca plugin.`,
1017
1182
  'Acknowledge the task you were given and describe what a real agent would do here.',
1018
1183
  '',
1019
1184
  ].join('\n'));
@@ -1033,12 +1198,21 @@ async function pluginInit(rest) {
1033
1198
  files.set('skills/example-skill/helper.sh', '#!/bin/sh\necho "example-skill helper ok"\n');
1034
1199
  }
1035
1200
  if (withParts.includes('workflows')) {
1201
+ // A v2 graph: the Task and End cards are mandatory (V20/V21) and every input
1202
+ // takes exactly one wire (V7). Ports come from the sidecar above.
1036
1203
  files.set('workflows/example-flow.json', JSON.stringify({
1037
1204
  name: `${name} example flow`,
1038
- version: 1,
1205
+ version: 2,
1039
1206
  domain: 'general',
1040
- steps: [[{ id: 's0_0', key: agentKey }]],
1041
- feedbacks: [],
1207
+ nodes: [
1208
+ { id: 'n_task', kind: 'task', x: 40, y: 200, config: {} },
1209
+ { id: 'n_helper', kind: 'agent', key: agentKey, x: 320, y: 200, config: {} },
1210
+ { id: 'n_end', kind: 'end', x: 600, y: 200, config: {} },
1211
+ ],
1212
+ wires: [
1213
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_helper', port: 'task' } },
1214
+ { id: 'w2', from: { node: 'n_helper', port: 'notes' }, to: { node: 'n_end', port: 'result' } },
1215
+ ],
1042
1216
  }, null, 2) + '\n');
1043
1217
  }
1044
1218
  files.set('worca-cc-plugin.json', JSON.stringify(manifestObj, null, 2) + '\n');
@@ -1147,6 +1321,7 @@ async function cmdPlugin(argv) {
1147
1321
  const res = await store.installPlugin({ repoUrl, subdir: entry.subdir, name, sha, ...(marketplace ? { marketplace } : {}) });
1148
1322
  out('installed:');
1149
1323
  printInventory(res.inventory);
1324
+ printIgnored(res.ignored);
1150
1325
  return 0;
1151
1326
  }
1152
1327
 
@@ -1160,6 +1335,8 @@ async function cmdPlugin(argv) {
1160
1335
  const version = p.linked ? 'linked' : p.version || (p.pinnedSha || '').slice(0, 7);
1161
1336
  const flags = [p.enabled ? 'enabled' : 'disabled', ...(p.linked ? ['linked'] : [])].join(', ');
1162
1337
  out(`${p.name}\t${version}\t${flags}\t${contribSummary(p.contributions)}`);
1338
+ if (p.apiMismatch) out(c('yellow', ` ${p.apiMismatch.message}`));
1339
+ printIgnored(p.ignored);
1163
1340
  }
1164
1341
  return 0;
1165
1342
  }
@@ -1259,12 +1436,27 @@ async function cmdPlugin(argv) {
1259
1436
  if (!dir) fail('Usage: worca plugin link <dir>');
1260
1437
  const abs = resolve(process.cwd(), dir);
1261
1438
  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);
1439
+ // Print EVERY level, pass or fail: a link that SUCCEEDS with warnings is
1440
+ // the mid-migration case the author most needs to read (MAJ-12) — an
1441
+ // API-1 plugin keeps linking, and now says why its agent is ignored.
1442
+ for (const p of v.problems) process.stderr.write(`${p.level}: ${p.message}\n`);
1443
+ if (!v.ok) return 2;
1444
+ const linked = await store.linkPlugin(v.manifest.name, abs);
1267
1445
  out(`linked ${v.manifest.name} -> ${abs} (dev mode; doctor will warn)`);
1446
+ const n = linked.workflows.imported.length;
1447
+ if (n) out(` imported ${n} pipeline template${n === 1 ? '' : 's'} — edits to them need: worca plugin reimport ${v.manifest.name}`);
1448
+ printIgnored(store.ignoredContributions(v.manifest.name, abs, { workflowSkips: linked.workflows.skipped }));
1449
+ return 0;
1450
+ }
1451
+
1452
+ case 'reimport': {
1453
+ const a = pluginArgs(rest);
1454
+ const name = a._[0];
1455
+ if (!name) fail('Usage: worca plugin reimport <name>');
1456
+ const r = await store.reimportPlugin(name);
1457
+ const n = r.workflows.imported.length;
1458
+ out(`reimported ${name}: ${n} pipeline template${n === 1 ? '' : 's'}`);
1459
+ printIgnored(r.ignored);
1268
1460
  return 0;
1269
1461
  }
1270
1462
 
@@ -1286,9 +1478,9 @@ async function cmdPlugin(argv) {
1286
1478
  }
1287
1479
 
1288
1480
  case 'exec': {
1289
- const a = pluginArgs(rest, ['--args'], ['--inspect']);
1481
+ const a = pluginArgs(rest, ['--args', '--profile'], ['--inspect']);
1290
1482
  const [name, sourceId, op] = a._;
1291
- if (!name || !sourceId || !op) fail("Usage: worca plugin exec <name> <sourceId> <op> [--args '<json>'] [--inspect]");
1483
+ if (!name || !sourceId || !op) fail("Usage: worca plugin exec <name> <sourceId> <op> [--args '<json>'] [--profile <id>] [--inspect]");
1292
1484
  if (a.inspect) process.env.WORCA_PLUGIN_INSPECT = '1'; // shim spawns the child with --inspect-brk
1293
1485
  let args = {};
1294
1486
  if (a.args) {
@@ -1299,7 +1491,9 @@ async function cmdPlugin(argv) {
1299
1491
  }
1300
1492
  }
1301
1493
  const { callSource } = await import('../core/plugin-shim.mjs');
1302
- const result = await callSource({ plugin: name, sourceId, op, args });
1494
+ // --profile targets one instance of a multi-profile source; absent, the
1495
+ // shim falls back to the implicit default bucket (single-profile case).
1496
+ const result = await callSource({ plugin: name, sourceId, op, args, profile: a.profile || undefined });
1303
1497
  process.stdout.write(JSON.stringify(result, null, 2) + '\n'); // stdout = result ONLY
1304
1498
  return 0;
1305
1499
  }
@@ -1431,8 +1625,51 @@ async function cmdMarketplace(argv) {
1431
1625
 
1432
1626
  const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config']);
1433
1627
 
1628
+ /** Levenshtein distance, two-row. Only ever called on short argv tokens. */
1629
+ function editDistance(a, b) {
1630
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
1631
+ for (let i = 1; i <= a.length; i++) {
1632
+ const row = [i];
1633
+ for (let j = 1; j <= b.length; j++) {
1634
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1635
+ }
1636
+ prev = row;
1637
+ }
1638
+ return prev[b.length];
1639
+ }
1640
+
1641
+ /**
1642
+ * The subcommand a lone positional was probably meant to be, or null.
1643
+ *
1644
+ * A bare positional is a legal prompt (`worca "do the thing"`), so only a SINGLE
1645
+ * whitespace-free token that near-misses a real subcommand counts as a typo: edit
1646
+ * distance <= 2, or a strict prefix of at least 3 characters (`plug` -> `plugin`).
1647
+ * Refusing costs one retry with --prompt; running `worca reusme run-abc123` cuts a
1648
+ * worktree + feature branch and spends real tokens on a task named "reusme".
1649
+ */
1650
+ function nearestSubcommand(token) {
1651
+ if (!token || /\s/.test(token)) return null;
1652
+ // 'help' is spliced into both loops: it is a real CLI arm (the head of main())
1653
+ // but deliberately absent from the dispatch table, so without it a typo of help
1654
+ // itself (`worca hlep`) is distance >= 4 from everything and runs as a PROMPT.
1655
+ for (const name of [...SUBCOMMANDS, 'help']) {
1656
+ if (token.length >= 3 && name.length > token.length && name.startsWith(token)) return name;
1657
+ }
1658
+ let best = null;
1659
+ let bestD = 3; // strictly less than 3 == distance <= 2
1660
+ for (const name of [...SUBCOMMANDS, 'help']) {
1661
+ const d = editDistance(token, name);
1662
+ if (d < bestD) { bestD = d; best = name; }
1663
+ }
1664
+ return best;
1665
+ }
1666
+
1434
1667
  async function main() {
1435
1668
  const sub = process.argv[2];
1669
+ // `worca help` is what every CLI user types first; it is not a subcommand and
1670
+ // not a near-miss of one, so without this line it became a PROMPT and ran a
1671
+ // pipeline named "help" (MIN-51).
1672
+ if (sub === 'help') { process.stdout.write(HELP); return 0; }
1436
1673
  if (SUBCOMMANDS.has(sub)) {
1437
1674
  const rest = process.argv.slice(3);
1438
1675
  if (sub === 'add') return cmdAdd(rest);
@@ -1466,10 +1703,23 @@ async function main() {
1466
1703
  if (flags.mock) {
1467
1704
  process.env.WORCA_MOCK = '1';
1468
1705
  }
1706
+ // The mock runner routes EVERY dontAsk spawn to the Ask Worca mock (claude-runner.mjs
1707
+ // runMock), which writes no pipeline artifact — a mock pipeline under dontAsk dies
1708
+ // at its first artifact read with no hint why. Refuse the PAIR, not the mode.
1709
+ if (flags.permissionMode === 'dontAsk' && /^(1|true|yes|on)$/i.test(String(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK ?? ''))) {
1710
+ fail('--permission-mode dontAsk cannot be combined with --mock: the mock runner reserves it for the Ask Worca assistant.');
1711
+ }
1469
1712
 
1470
1713
  if (!flags.prompt && !flags.file) {
1471
- // Allow a bare positional prompt: `worca "do the thing"`.
1714
+ // Allow a bare positional prompt: `worca "do the thing"`. A lone token that
1715
+ // near-misses a subcommand is a typo, not a task — refuse it here, before a
1716
+ // pipeline row, a worktree or a feature branch exists.
1472
1717
  if (flags._.length) {
1718
+ const meant = nearestSubcommand(flags._[0]);
1719
+ if (meant) {
1720
+ 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")`);
1721
+ fail(`unknown subcommand "${flags._[0]}" — did you mean "${meant}"? (to run a prompt, use --prompt "\u2026")`);
1722
+ }
1473
1723
  flags.prompt = flags._.join(' ');
1474
1724
  } else {
1475
1725
  fail('Provide a task with --prompt "<text>" or --file <markdown>. See --help.');
@@ -1477,6 +1727,19 @@ async function main() {
1477
1727
  }
1478
1728
 
1479
1729
  const projectDir = resolve(flags.project);
1730
+ // A NAMED --file must be readable BEFORE anything starts. The readers used to
1731
+ // swallow the failure and run the whole pipeline on an empty prompt with exit 0
1732
+ // — in real mode that spends tokens and cuts a worktree + feature branch for
1733
+ // nothing. Relative paths resolve against the PROJECT dir, exactly as the
1734
+ // orchestrator's own read does.
1735
+ if (flags.file) {
1736
+ const { readPromptFile } = await import('../core/artifacts.mjs');
1737
+ try {
1738
+ await readPromptFile(projectDir, flags.file);
1739
+ } catch (err) {
1740
+ fail(err && err.message ? err.message : String(err));
1741
+ }
1742
+ }
1480
1743
  // Resolve extras against the shell cwd so relative paths are unambiguous.
1481
1744
  const extras = (flags.extras || []).map((p) => resolve(process.cwd(), p));
1482
1745
 
@@ -1490,13 +1753,24 @@ async function main() {
1490
1753
  return 1;
1491
1754
  }
1492
1755
 
1493
- const orch = createOrchestrator({
1756
+ // Validate --workflow before spawning anything: an unknown or archived template
1757
+ // must fail with one line, not a stack trace half-way through a run. The read row
1758
+ // doubles as createOrchestratorFor's routing hint (it skips a second row read).
1759
+ let row;
1760
+ if (flags.workflow) {
1761
+ const { assertRunnableWorkflow } = await import('../core/workflows.mjs');
1762
+ try { row = await assertRunnableWorkflow(flags.workflow); }
1763
+ catch (err) { fail(`${err && err.message ? err.message : String(err)}`); }
1764
+ }
1765
+
1766
+ const orch = await createOrchestratorFor({
1494
1767
  projectDir,
1495
1768
  prompt: flags.prompt || undefined,
1496
1769
  promptFile: flags.file || undefined,
1497
1770
  title: flags.title || undefined,
1498
1771
  extras,
1499
1772
  workflowId: flags.workflow || undefined,
1773
+ template: row,
1500
1774
  branch: { source: flags.sourceBranch, feature: flags.featureBranch },
1501
1775
  claude: {
1502
1776
  permissionMode: flags.permissionMode,