@worca/app 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,1520 @@
1
+ #!/usr/bin/env node
2
+ // src/cli/worca-cc.mjs
3
+ //
4
+ // CLI entry point. Parses flags, creates a core orchestrator, subscribes to its events,
5
+ // renders a phase tracker + streamed agent logs to the terminal, and drives interactive
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).
8
+ //
9
+ // ESM, no external dependencies.
10
+
11
+ import { createInterface } from 'node:readline';
12
+ import { spawn } from 'node:child_process';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { dirname, resolve, join, basename } from 'node:path';
15
+ import process from 'node:process';
16
+
17
+ import { preflightNode } from '../core/preflight-node.mjs';
18
+ import { createOrchestrator } from '../core/orchestrator.mjs';
19
+ import {
20
+ addProject,
21
+ listProjects,
22
+ removeProject,
23
+ normalizeProjectPath,
24
+ } from '../core/projects.mjs';
25
+ import { projectKey } from '../core/store.mjs';
26
+
27
+ // ── node:sqlite runtime guard + warning filter ──────────────────────────────────
28
+ // Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
29
+ // stable enough for our use but still flagged experimental). Everything else (deprec-
30
+ // ations, etc.) is re-printed unchanged. Belt-and-suspenders with the npm scripts'
31
+ // --disable-warning=ExperimentalWarning (the primary suppressor): this filter is the
32
+ // direct-bin fallback. We removeAllListeners('warning') FIRST so Node's default
33
+ // printer no longer fires (a bare listener would NOT suppress the warning and would
34
+ // double-print every OTHER warning), then attach our single filtering listener.
35
+ process.removeAllListeners('warning');
36
+ process.on('warning', (w) => {
37
+ if (w && w.name === 'ExperimentalWarning' && /SQLite/i.test(w.message)) return;
38
+ process.stderr.write(`${w?.stack || w?.message || w}\n`);
39
+ });
40
+ // Fail fast on an unsupported Node / missing node:sqlite BEFORE any DB is opened.
41
+ preflightNode();
42
+
43
+ const __filename = fileURLToPath(import.meta.url);
44
+ const __dirname = dirname(__filename);
45
+ const REPO_ROOT = resolve(__dirname, '..', '..');
46
+
47
+ // ── arg parsing ────────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Parse argv into a flags object. Supports "--flag value" and "--flag=value", plus the
51
+ * boolean flags --mock, --yes/--non-interactive, --ui, -h/--help.
52
+ */
53
+ function parseArgs(argv) {
54
+ const out = {
55
+ project: process.cwd(),
56
+ prompt: null,
57
+ file: null,
58
+ title: null,
59
+ extras: [],
60
+ ui: false,
61
+ model: undefined,
62
+ permissionMode: undefined,
63
+ workflow: undefined,
64
+ mock: false,
65
+ auto: false,
66
+ install: null,
67
+ sourceBranch: undefined,
68
+ featureBranch: undefined,
69
+ help: false,
70
+ _: [],
71
+ };
72
+ const takesValue = new Set([
73
+ '--project',
74
+ '--prompt',
75
+ '--file',
76
+ '--title',
77
+ '--extras',
78
+ '--model',
79
+ '--permission-mode',
80
+ '--workflow',
81
+ '--install',
82
+ '--source-branch',
83
+ '--branch',
84
+ ]);
85
+ const map = {
86
+ '--project': 'project',
87
+ '--prompt': 'prompt',
88
+ '--file': 'file',
89
+ '--title': 'title',
90
+ '--extras': 'extras',
91
+ '--model': 'model',
92
+ '--permission-mode': 'permissionMode',
93
+ '--workflow': 'workflow',
94
+ '--install': 'install',
95
+ '--source-branch': 'sourceBranch',
96
+ '--branch': 'featureBranch',
97
+ };
98
+
99
+ for (let i = 0; i < argv.length; i++) {
100
+ let arg = argv[i];
101
+ if (arg === '-h' || arg === '--help') {
102
+ out.help = true;
103
+ continue;
104
+ }
105
+ if (arg === '--mock') {
106
+ out.mock = true;
107
+ continue;
108
+ }
109
+ if (arg === '--yes' || arg === '--non-interactive') {
110
+ out.auto = true;
111
+ continue;
112
+ }
113
+ if (arg === '--ui') {
114
+ out.ui = true;
115
+ continue;
116
+ }
117
+
118
+ let inlineValue;
119
+ const eq = arg.indexOf('=');
120
+ if (arg.startsWith('--') && eq !== -1) {
121
+ inlineValue = arg.slice(eq + 1);
122
+ arg = arg.slice(0, eq);
123
+ }
124
+
125
+ if (takesValue.has(arg)) {
126
+ const key = map[arg];
127
+ const value = inlineValue !== undefined ? inlineValue : argv[++i];
128
+ if (value === undefined) {
129
+ fail(`Flag ${arg} requires a value.`);
130
+ }
131
+ if (key === 'extras') {
132
+ // Comma-separated and/or repeatable; accumulate non-empty paths.
133
+ for (const part of String(value).split(',')) {
134
+ const p = part.trim();
135
+ if (p) out.extras.push(p);
136
+ }
137
+ } else {
138
+ out[key] = value;
139
+ }
140
+ continue;
141
+ }
142
+
143
+ if (arg.startsWith('-')) {
144
+ fail(`Unknown flag: ${arg}`);
145
+ }
146
+ out._.push(arg);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ function fail(msg) {
152
+ process.stderr.write(`worca: ${msg}\n`);
153
+ process.exit(2);
154
+ }
155
+
156
+ /** Budget-refusal line: "$X of $Y spent this week; resets 2026-09-01 00:00 (in 24d 12h)". */
157
+ function budgetRefusalDetail(b) {
158
+ const p = (x) => String(x).padStart(2, '0');
159
+ const d = new Date(b.windowEndMs);
160
+ const when = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
161
+ const days = Math.floor(b.msUntilReset / 86400000);
162
+ const hours = Math.floor((b.msUntilReset % 86400000) / 3600000);
163
+ const periodWord = b.resetPeriod === 'weekly' ? 'week' : 'month';
164
+ return `$${b.windowSpendUsd.toFixed(2)} of $${b.totalLimitUsd.toFixed(2)} spent this ${periodWord}; `
165
+ + `resets ${when} (in ${days}d ${hours}h)`;
166
+ }
167
+
168
+ const HELP = `worca — deterministic multi-agent pipeline (Plan -> Refine -> Implement -> Review)
169
+
170
+ Usage:
171
+ worca <subcommand> [args]
172
+ worca --prompt "<task>" [--project <dir>] [options]
173
+ worca --file <task.md> [--project <dir>] [options]
174
+ worca --ui
175
+ worca --install <targetDir> [--force]
176
+
177
+ Subcommands:
178
+ add [name] [--path <dir>] Register a project. Defaults: name = basename(path), path = cwd.
179
+ list List registered projects (tab-separated; missing dirs are flagged).
180
+ remove <name> Remove a registered project by name (case-insensitive).
181
+ resume <pipelineId> Continue a paused pipeline (re-attaches Claude sessions).
182
+ [--ignore-cost-cap] Resume past this pipeline's cost cap (persists on the run).
183
+ doctor Reconcile crashed runs and sweep leftover run roots.
184
+ plugin <cmd> [...] Manage plugins: add|install|list|update|remove|purge|enable|
185
+ disable|doctor|link|init|validate|exec. See: worca plugin help
186
+ marketplace <cmd> [...] Manage plugin marketplaces: add|list|refresh|remove. See: worca marketplace help
187
+ config [get|set|unset] Budget & cost-limit settings
188
+
189
+ Options:
190
+ --project <dir> Target project directory (default: cwd)
191
+ --prompt <text> Task prompt text
192
+ --file <md> Markdown file used as the prompt (alternative to --prompt)
193
+ --title <text> Human-readable run title
194
+ --extras <paths> Extra files copied into the pipeline's extras/ folder
195
+ (comma-separated; repeatable)
196
+ --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)
199
+ --source-branch <name> Branch to fork the per-run worktree from (default: current HEAD)
200
+ --branch <name> Feature branch name (default: claude proposes one)
201
+ --mock Offline mock mode (no claude, no tokens)
202
+ --yes, --non-interactive Auto-answer clarify (first option) and gates (continue)
203
+ --ui Launch the web UI (ui/server.mjs) and exit
204
+ --install <targetDir> Copy agents + /worca skill into <targetDir>/.claude
205
+ -h, --help Show this help
206
+ `;
207
+
208
+ // ── terminal rendering ───────────────────────────────────────────────────────────
209
+
210
+ const COLORS = {
211
+ reset: '\x1b[0m',
212
+ dim: '\x1b[2m',
213
+ bold: '\x1b[1m',
214
+ green: '\x1b[32m',
215
+ yellow: '\x1b[33m',
216
+ red: '\x1b[31m',
217
+ cyan: '\x1b[36m',
218
+ gray: '\x1b[90m',
219
+ };
220
+ const useColor = process.stdout.isTTY;
221
+ function c(name, s) {
222
+ if (!useColor) return s;
223
+ return `${COLORS[name] || ''}${s}${COLORS.reset}`;
224
+ }
225
+
226
+ function out(s) {
227
+ process.stdout.write(s + '\n');
228
+ }
229
+
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
+
237
+ function statusMark(status) {
238
+ if (status === 'done') return c('green', '✓');
239
+ if (status === 'start') return c('cyan', '▶');
240
+ return c('gray', '•');
241
+ }
242
+
243
+ const LEVEL_COLOR = { info: 'reset', debug: 'gray', warn: 'yellow', error: 'red' };
244
+
245
+ // ── interactive prompts (readline) ───────────────────────────────────────────────
246
+
247
+ function makeRl() {
248
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
249
+ // In a real TTY, readline consumes Ctrl+C itself: with no rl 'SIGINT' listener it
250
+ // silently close()s and the process-level pause/stop ladder in attachAndDrive never
251
+ // sees the 1st Ctrl+C. Forward it so Ctrl+C — including during an open question
252
+ // prompt — routes to pause() (which rejects the pending question with the pause
253
+ // sentinel and unwinds to paused). Not unit-testable without a PTY; verified manually.
254
+ rl.on('SIGINT', () => process.emit('SIGINT'));
255
+ return rl;
256
+ }
257
+
258
+ function question(rl, q) {
259
+ return new Promise((res) => rl.question(q, (a) => res(a)));
260
+ }
261
+
262
+ /**
263
+ * Ask the clarify questions interactively. Each question shows its options (2–4) plus a
264
+ * "type your own" choice. Returns { answers: [{ id, choice }] }.
265
+ */
266
+ async function askClarify(rl, questions) {
267
+ const answers = [];
268
+ for (const q of questions) {
269
+ out('');
270
+ out(c('bold', `Q: ${q.question}`));
271
+ const opts = (q.options || []).filter((o) => o && o.trim());
272
+ opts.forEach((o, i) => out(` ${i + 1}) ${o}`));
273
+ const ownIndex = opts.length + 1;
274
+ out(` ${ownIndex}) type your own`);
275
+ let choice = '';
276
+ while (!choice) {
277
+ const raw = (await question(rl, c('cyan', 'Choose [number or text]: '))).trim();
278
+ if (!raw) {
279
+ // Empty input defaults to the first option.
280
+ choice = opts[0] || '';
281
+ if (choice) break;
282
+ continue;
283
+ }
284
+ const n = Number(raw);
285
+ if (Number.isInteger(n) && n >= 1 && n <= opts.length) {
286
+ choice = opts[n - 1];
287
+ } else if (Number.isInteger(n) && n === ownIndex) {
288
+ choice = (await question(rl, c('cyan', 'Your answer: '))).trim();
289
+ } else {
290
+ // Treat any other free text as the answer directly.
291
+ choice = raw;
292
+ }
293
+ }
294
+ answers.push({ id: q.id, choice });
295
+ }
296
+ return { answers };
297
+ }
298
+
299
+ /**
300
+ * Ask a loop gate interactively. Shows the open blocking issues and the two choices.
301
+ * Returns { decision: "continue" | "another" }.
302
+ */
303
+ async function askGate(rl, issues) {
304
+ out('');
305
+ out(c('yellow', c('bold', 'Loop gate — maximum cycles reached.')));
306
+ out(c('yellow', 'Open critical/major issues:'));
307
+ if (!issues || issues.length === 0) {
308
+ out(' (none reported)');
309
+ } else {
310
+ for (const it of issues) {
311
+ out(` - ${c('red', `[${it.severity}]`)} ${it.title}${it.location ? c('gray', ` (${it.location})`) : ''}`);
312
+ if (it.detail) out(c('gray', ` ${it.detail}`));
313
+ }
314
+ }
315
+ out(' 1) Don\'t have another cycle and continue');
316
+ out(' 2) I approve another cycle');
317
+ let decision = '';
318
+ while (!decision) {
319
+ const raw = (await question(rl, c('cyan', 'Choose [1-2]: '))).trim();
320
+ if (raw === '1' || /^cont/i.test(raw)) decision = 'continue';
321
+ else if (raw === '2' || /^(another|approve)/i.test(raw)) decision = 'another';
322
+ }
323
+ return { decision };
324
+ }
325
+
326
+ /**
327
+ * 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 }.
329
+ */
330
+ async function askRecovery(rl, recovery) {
331
+ const rec = recovery || {};
332
+ out('');
333
+ out(c('yellow', c('bold', `Recoverable ${String(rec.cls || 'error').replace('_', ' ')} error — the pipeline could not reach the model.`)));
334
+ if (rec.message) out(c('gray', ` ${rec.message}`));
335
+ if (rec.cls === 'auth') out(c('gray', ' Fix: re-authenticate (claude setup-token or /login) in another terminal, then retry.'));
336
+ 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');
339
+ let decision = '';
340
+ 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';
344
+ }
345
+ return { decision };
346
+ }
347
+
348
+ // ── shared drive loop ────────────────────────────────────────────────────────────
349
+
350
+ /**
351
+ * Wire readline Q&A, log/phase rendering, and SIGINT pause/stop onto an
352
+ * orchestrator, then drive it. `start` launches run() or resume(). Returns the
353
+ * process exit code (0 for done/paused, 1 otherwise).
354
+ */
355
+ async function attachAndDrive(orch, flags, start) {
356
+ const rl = flags.auto ? null : makeRl();
357
+ let answering = false; // serialize interactive prompts vs. log rendering
358
+
359
+ // ── event wiring ──────────────────────────────────────────────────────────────
360
+ orch.on('phase', ({ phase, cycle, status }) => {
361
+ out(`${statusMark(status)} ${c('bold', phaseLabel(phase, cycle))} ${c('gray', status)}`);
362
+ });
363
+
364
+ orch.on('log', ({ source, level, text }) => {
365
+ if (answering) return; // avoid interleaving with an open question prompt
366
+ const color = LEVEL_COLOR[level] || 'reset';
367
+ out(c('gray', ` [${source}] `) + c(color, text));
368
+ });
369
+
370
+ orch.on('artifact', ({ kind, path }) => {
371
+ out(c('gray', ` ↳ ${kind}: ${path}`));
372
+ });
373
+
374
+ orch.on('error', ({ message }) => {
375
+ process.stderr.write(c('red', `Error: ${message}`) + '\n');
376
+ });
377
+
378
+ orch.on('question', async ({ id, kind, questions, issues, recovery, agent }) => {
379
+ if (flags.auto || !rl) return; // auto mode resolves internally
380
+ answering = true;
381
+ try {
382
+ if (kind === 'clarify') {
383
+ const payload = await askClarify(rl, questions || []);
384
+ orch.answer(id, payload);
385
+ } else if (kind === 'gate') {
386
+ const payload = await askGate(rl, issues || []);
387
+ orch.answer(id, payload);
388
+ } else if (kind === 'recovery') {
389
+ const payload = await askRecovery(rl, recovery);
390
+ orch.answer(id, payload);
391
+ } else if (kind === 'questions') {
392
+ out(c('yellow', c('bold', `${agent || 'Agent'} has questions:`)));
393
+ const payload = await askClarify(rl, questions || []);
394
+ orch.answer(id, payload);
395
+ }
396
+ } catch (err) {
397
+ process.stderr.write(`Failed to read answer: ${err?.message || err}\n`);
398
+ } finally {
399
+ answering = false;
400
+ }
401
+ });
402
+
403
+ // Ctrl+C: 1st -> graceful pause (falls back to stop when not pausable);
404
+ // 2nd -> stop; 3rd -> hard exit.
405
+ let sigints = 0;
406
+ const onSigint = () => {
407
+ sigints += 1;
408
+ if (sigints === 1) {
409
+ if (orch.pause()) {
410
+ out(c('yellow', '\nPausing… (Ctrl+C again to stop instead)'));
411
+ return;
412
+ }
413
+ out(c('yellow', '\nStopping…'));
414
+ orch.stop();
415
+ return;
416
+ }
417
+ if (sigints === 2) {
418
+ out(c('yellow', '\nStopping…'));
419
+ orch.stop();
420
+ return;
421
+ }
422
+ process.exit(130);
423
+ };
424
+ process.on('SIGINT', onSigint);
425
+
426
+ let result;
427
+ try {
428
+ result = await start();
429
+ } finally {
430
+ if (rl) rl.close();
431
+ process.removeListener('SIGINT', onSigint);
432
+ }
433
+
434
+ out('');
435
+ if (result?.status === 'done') {
436
+ out(c('green', c('bold', 'Pipeline complete.')));
437
+ } else if (result?.status === 'paused') {
438
+ out(c('yellow', result?.reason ? `Pipeline paused: ${result.reason}` : 'Pipeline paused.'));
439
+ out(`Resume with: ${c('bold', `worca resume ${orch.state.id}`)}`);
440
+ } else if (result?.status === 'stopped') {
441
+ out(c('yellow', 'Pipeline stopped.'));
442
+ } else {
443
+ out(c('red', `Pipeline ended with status: ${result?.status || 'unknown'}`));
444
+ }
445
+ if (result?.pipelineDir) {
446
+ out(`Pipeline directory: ${c('bold', result.pipelineDir)}`);
447
+ }
448
+ return result?.status === 'done' || result?.status === 'paused' ? 0 : 1;
449
+ }
450
+
451
+ // ── subcommands ──────────────────────────────────────────────────────────────────
452
+
453
+ /** Spawn the web UI server and inherit its stdio. Resolves when it exits. */
454
+ function launchUi() {
455
+ const server = join(REPO_ROOT, 'ui', 'server.mjs');
456
+ out(c('cyan', `Launching web UI: node ${server}`));
457
+ const child = spawn(process.execPath, [server], { stdio: 'inherit' });
458
+ return new Promise((res) => {
459
+ child.on('exit', (code) => res(code ?? 0));
460
+ child.on('error', (err) => {
461
+ process.stderr.write(`Failed to launch UI: ${err.message}\n`);
462
+ res(1);
463
+ });
464
+ });
465
+ }
466
+
467
+ /** Delegate to scripts/install.mjs, forwarding the target dir and any passthrough args. */
468
+ function runInstall(targetDir, passthrough) {
469
+ const script = join(REPO_ROOT, 'scripts', 'install.mjs');
470
+ const args = [script, targetDir, ...passthrough];
471
+ const child = spawn(process.execPath, args, { stdio: 'inherit' });
472
+ return new Promise((res) => {
473
+ child.on('exit', (code) => res(code ?? 0));
474
+ child.on('error', (err) => {
475
+ process.stderr.write(`Failed to run install: ${err.message}\n`);
476
+ res(1);
477
+ });
478
+ });
479
+ }
480
+
481
+ // ── project registry subcommands ──────────────────────────────────────────────
482
+
483
+ /** Parse a tiny argv slice for the `add` subcommand. Supports --path/--path=<dir>. */
484
+ function parseAddArgs(argv) {
485
+ const positionals = [];
486
+ let pathArg = null;
487
+ for (let i = 0; i < argv.length; i++) {
488
+ let a = argv[i];
489
+ let inline;
490
+ const eq = a.indexOf('=');
491
+ if (a.startsWith('--') && eq !== -1) {
492
+ inline = a.slice(eq + 1);
493
+ a = a.slice(0, eq);
494
+ }
495
+ if (a === '--path') {
496
+ const v = inline !== undefined ? inline : argv[++i];
497
+ if (v === undefined) fail('Flag --path requires a value.');
498
+ pathArg = v;
499
+ } else if (a.startsWith('-')) {
500
+ fail(`Unknown flag: ${a}`);
501
+ } else {
502
+ positionals.push(a);
503
+ }
504
+ }
505
+ return { name: positionals[0], path: pathArg };
506
+ }
507
+
508
+ async function cmdAdd(argv) {
509
+ const { name: rawName, path: rawPath } = parseAddArgs(argv);
510
+ // Always route through normalizeProjectPath so display, storage, and
511
+ // basename() all see exactly the same string addProject will persist.
512
+ const target = normalizeProjectPath(rawPath) || resolve(process.cwd());
513
+ const name = (rawName && rawName.trim()) || basename(target);
514
+ try {
515
+ await addProject({ name, path: target });
516
+ out(`Added project "${name}" -> ${target}`);
517
+ return 0;
518
+ } catch (err) {
519
+ process.stderr.write(`worca: ${err?.message || err}\n`);
520
+ return 1;
521
+ }
522
+ }
523
+
524
+ async function cmdList() {
525
+ const items = await listProjects();
526
+ if (items.length === 0) {
527
+ out('No projects registered. Use `worca add` to register one.');
528
+ return 0;
529
+ }
530
+ for (const p of items) {
531
+ const tail = p.exists ? '' : `\t${c('gray', '[missing]')}`;
532
+ out(`${p.name}\t${p.path}${tail}`);
533
+ }
534
+ return 0;
535
+ }
536
+
537
+ async function cmdRemove(argv) {
538
+ const name = (argv[0] || '').trim();
539
+ if (!name) fail('Usage: worca remove <name>');
540
+ const before = await listProjects();
541
+ const after = await removeProject(name);
542
+ if (after.length === before.length) {
543
+ out(`No project named "${name}"`);
544
+ return 1;
545
+ }
546
+ out(`Removed project "${name}"`);
547
+ return 0;
548
+ }
549
+
550
+ /**
551
+ * `worca doctor` — reconcile crashed runs, then sweep `<worcaHome>/runs/*`
552
+ * (§8.12), then sweep the LEGACY `<projectDir>/.worca-cc/worktrees/*` base of every
553
+ * registered project (§6 Phase 7). This is what keeps a CLI-only user (who never
554
+ * boots ui/server.mjs) from accumulating crashed-run roots — and leftovers from the
555
+ * flip to detached run roots — forever. Prints keep/remove/quarantine dispositions
556
+ * and always exits 0 — it is a report, not a gate.
557
+ *
558
+ * Ordering is PINNED (same as the server's bootMaintenance): reconcile FIRST so every
559
+ * stale `running` row is already `interrupted` (a status BOTH sweeps' keep-set
560
+ * protects), then the run-root sweep, then the legacy one.
561
+ */
562
+ async function cmdDoctor() {
563
+ const { reconcileStaleRunning, runRootSweepLookups, legacySweepLookups } = await import('../core/artifacts.mjs');
564
+ const { sweepRunRoots, sweepLegacyWorktreesAll } = await import('../core/worktree.mjs');
565
+ const { worcaHome } = await import('../core/projects.mjs');
566
+ const { runRootMode } = await import('../core/settings.mjs');
567
+ try {
568
+ const { reconciled } = reconcileStaleRunning({ liveIds: [] }); // CLI owns no live runs
569
+ out(`reconciled ${reconciled} stale running record(s) -> interrupted`);
570
+ } catch (err) {
571
+ process.stderr.write(`worca doctor: reconcile failed: ${err?.message || err}\n`);
572
+ }
573
+ try {
574
+ // The injected callbacks THROW on a DB failure instead of reporting "no row"
575
+ // (artifacts.mjs#runRootSweepLookups); the sweep records each throw in `failed`
576
+ // and leaves that run root untouched, so a broken sqlite file can never be read
577
+ // as "every run was deleted, reclaim them all".
578
+ const res = await sweepRunRoots({
579
+ worcaHome: worcaHome(),
580
+ ...runRootSweepLookups(),
581
+ log: (level, msg) => out(level === 'warn' ? c('yellow', msg) : msg),
582
+ });
583
+ out(`run roots: kept ${res.keep.length}, removed ${res.removed.length}, `
584
+ + `quarantined ${res.quarantined.length}, skipped ${res.failed.length}`);
585
+ for (const w of res.warnings) out(c('yellow', ` ! ${w}`));
586
+ if (res.failed.length) {
587
+ out(c('yellow', `${res.failed.length} run root(s) could not be classified and were left untouched.`));
588
+ }
589
+ } catch (err) {
590
+ process.stderr.write(`worca doctor: run-root sweep failed: ${err?.message || err}\n`);
591
+ }
592
+ try {
593
+ // A TOTAL no-op while the effective mode is `legacy`: those paths hold every live
594
+ // and every paused run, so sweeping them would make the documented §10 rollback
595
+ // self-destroying. The mode is read ONCE here, so the legacy default costs not
596
+ // even a DB read.
597
+ const mode = runRootMode();
598
+ if (mode !== 'detached') {
599
+ out(`legacy worktrees: skipped (run-root mode is "${mode}" — that base holds every live and paused run)`);
600
+ return 0;
601
+ }
602
+ const projects = await listProjects();
603
+ // legacySweepLookups THROWS on a DB failure instead of reporting "no row", so a
604
+ // broken sqlite file skips the whole sweep (caught below) rather than reading
605
+ // every worktree of every project as row-less.
606
+ const res = await sweepLegacyWorktreesAll(projects.map((p) => p.path), {
607
+ mode,
608
+ ...legacySweepLookups(),
609
+ log: (level, msg) => out(level === 'warn' ? c('yellow', msg) : msg),
610
+ });
611
+ out(`legacy worktrees: kept ${res.keep.length}, removed ${res.removed.length}, `
612
+ + `quarantined ${res.quarantined.length}, skipped ${res.failed.length} `
613
+ + `across ${res.projects} project(s)`);
614
+ for (const w of res.warnings) out(c('yellow', ` ! ${w}`));
615
+ if (res.failed.length) {
616
+ out(c('yellow', `${res.failed.length} legacy worktree(s) could not be classified and were left untouched.`));
617
+ }
618
+ } catch (err) {
619
+ process.stderr.write(`worca doctor: legacy worktree sweep failed: ${err?.message || err}\n`);
620
+ }
621
+ return 0;
622
+ }
623
+
624
+ /** `worca config` — list/get/set/unset the budget settings.
625
+ * Exit codes: 0 ok, 2 usage/validation (fail()). */
626
+ async function cmdConfig(argv) {
627
+ const settings = await import('../core/settings.mjs');
628
+ const { budgetStatus } = await import('../core/cost-budget.mjs');
629
+
630
+ const KEYS = {
631
+ pipelineCostLimitUsd: {
632
+ aliases: ['pipeline-cost-limit', 'pipeline-cost-limit-usd'],
633
+ read: settings.pipelineCostLimitUsd, write: settings.setPipelineCostLimitUsd, numeric: true,
634
+ },
635
+ totalCostLimitUsd: {
636
+ aliases: ['total-cost-limit', 'total-cost-limit-usd'],
637
+ read: settings.totalCostLimitUsd, write: settings.setTotalCostLimitUsd, numeric: true,
638
+ },
639
+ costLimitResetPeriod: {
640
+ aliases: ['cost-reset-period'],
641
+ read: settings.costLimitResetPeriod, write: settings.setCostLimitResetPeriod, numeric: false,
642
+ },
643
+ };
644
+ const canonical = (name) => {
645
+ if (!name) return null;
646
+ if (KEYS[name]) return name;
647
+ return Object.keys(KEYS).find((k) => KEYS[k].aliases.includes(name)) || null;
648
+ };
649
+ const allowed = () => Object.keys(KEYS).join(', ');
650
+ const show = (k) => {
651
+ const v = KEYS[k].read();
652
+ return v === null || v === undefined ? '(unset)' : String(v);
653
+ };
654
+ const fmtUsd2 = (n) => `$${n.toFixed(2)}`;
655
+ const fmtLocal = (ms) => {
656
+ const d = new Date(ms);
657
+ const p = (x) => String(x).padStart(2, '0');
658
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
659
+ };
660
+ const fmtIn = (ms) => {
661
+ const dDays = Math.floor(ms / 86400000);
662
+ const dHours = Math.floor((ms % 86400000) / 3600000);
663
+ return `${dDays}d ${dHours}h`;
664
+ };
665
+
666
+ const [verb, keyArg, valueArg] = argv;
667
+ if (!verb) {
668
+ for (const k of Object.keys(KEYS)) out(`${k}\t${show(k)}`);
669
+ const b = budgetStatus();
670
+ if (b.totalLimitUsd != null) {
671
+ const periodWord = b.resetPeriod === 'weekly' ? 'week' : 'month';
672
+ out(`window\t${fmtUsd2(b.windowSpendUsd)} of ${fmtUsd2(b.totalLimitUsd)} spent this ${periodWord}; `
673
+ + `resets ${fmtLocal(b.windowEndMs)} (in ${fmtIn(b.msUntilReset)})`);
674
+ }
675
+ return 0;
676
+ }
677
+ if (!['get', 'set', 'unset'].includes(verb)) {
678
+ fail(`Unknown config verb: ${verb}. Use get | set | unset (or no verb to list).`);
679
+ }
680
+ const key = canonical(keyArg);
681
+ if (!key) fail(`Unknown config key: ${keyArg || '(none)'}. Allowed: ${allowed()}`);
682
+ if (verb === 'get') { out(show(key)); return 0; }
683
+ try {
684
+ if (verb === 'unset') { await KEYS[key].write(''); out(`${key}\t(unset)`); return 0; }
685
+ if (valueArg === undefined) fail(`config set ${key} requires a value.`);
686
+ const input = KEYS[key].numeric ? Number(valueArg) : valueArg;
687
+ if (KEYS[key].numeric && !Number.isFinite(input)) {
688
+ fail(`${key} must be a positive number of USD, got: ${valueArg}`);
689
+ }
690
+ await KEYS[key].write(input);
691
+ out(`${key}\t${show(key)}`);
692
+ return 0;
693
+ } catch (err) {
694
+ fail(err && err.message ? err.message : String(err));
695
+ }
696
+ }
697
+
698
+ /** `worca resume <pipelineId>` — continue a paused pipeline from its resume point. */
699
+ async function cmdResume(argv) {
700
+ const id = (argv.find((a) => !a.startsWith('--')) || '').trim();
701
+ if (!id) {
702
+ process.stderr.write('usage: worca resume <pipelineId> [--mock] [--yes] [--ignore-cost-cap]\n');
703
+ return 1;
704
+ }
705
+ const mock = argv.includes('--mock');
706
+ const auto = argv.includes('--yes') || argv.includes('--non-interactive');
707
+ const ignoreCap = argv.includes('--ignore-cost-cap');
708
+ if (mock) process.env.WORCA_MOCK = '1';
709
+
710
+ const { readPipelineForResume, reconcileStaleRunning } = await import('../core/artifacts.mjs');
711
+ try {
712
+ const { reconciled } = reconcileStaleRunning({ liveIds: [] }); // CLI owns no live runs
713
+ if (reconciled) process.stdout.write(`reaped ${reconciled} interrupted pipeline(s)\n`);
714
+ } catch { /* best-effort: resume still works if the sweep fails */ }
715
+ const saved = readPipelineForResume(id);
716
+ if (!saved) {
717
+ process.stderr.write(`pipeline ${id} not found\n`);
718
+ return 1;
719
+ }
720
+ if (saved.row.status !== 'paused' && saved.row.status !== 'interrupted') {
721
+ process.stderr.write(`pipeline ${id} is "${saved.row.status}", not resumable (paused/interrupted only)\n`);
722
+ return 1;
723
+ }
724
+ if (!saved.resumePoint) {
725
+ process.stderr.write(`pipeline ${id} has no resume point\n`);
726
+ return 1;
727
+ }
728
+ if (saved.row.archived_at) {
729
+ process.stderr.write('worca resume: pipeline is archived\n');
730
+ return 1;
731
+ }
732
+ const { budgetStatus, setCostCapOverride, readCostCapOverride } =
733
+ await import('../core/cost-budget.mjs');
734
+ const budget = budgetStatus();
735
+ if (budget.blocked) {
736
+ process.stderr.write(`worca: total cost limit reached: ${budgetRefusalDetail(budget)}. `
737
+ + 'Raise it: worca config set totalCostLimitUsd <usd>\n');
738
+ return 1;
739
+ }
740
+ // Override persists only once the never-bypassable total gate passes.
741
+ if (ignoreCap) setCostCapOverride(id);
742
+ const spentSoFar = Number(saved.row.total_cost_usd || 0);
743
+ if (budget.pipelineLimitUsd != null && spentSoFar >= budget.pipelineLimitUsd
744
+ && !readCostCapOverride(id)) {
745
+ process.stderr.write(`worca: pipeline cost limit reached: $${spentSoFar.toFixed(2)} spent `
746
+ + `>= $${budget.pipelineLimitUsd.toFixed(2)} cap. Resume anyway: worca resume ${id} --ignore-cost-cap\n`);
747
+ return 1;
748
+ }
749
+
750
+ // Resolve projectDir: workspace runs carry dirs in workspace_meta; single-project
751
+ // runs map project_key back through the registry (mirrors ui/server.mjs /api/resume),
752
+ // falling back to the current directory — the default run flow needs no registration,
753
+ // so the `worca resume <id>` hint it prints must work for bare-cwd runs too.
754
+ let projectDir = null;
755
+ let workspace;
756
+ if (saved.row.target === 'workspace' && saved.row.workspace_meta) {
757
+ const meta = JSON.parse(saved.row.workspace_meta);
758
+ projectDir = meta.projects?.[0]?.projectDir || null;
759
+ workspace = meta.workspaceId
760
+ ? {
761
+ id: meta.workspaceId,
762
+ key: saved.row.workspace_key,
763
+ name: meta.workspaceName,
764
+ description: meta.workspaceDescription || '',
765
+ projects: meta.projects || [],
766
+ }
767
+ : undefined;
768
+ } else {
769
+ for (const p of await listProjects()) {
770
+ if (projectKey(p.path) === saved.row.project_key) {
771
+ projectDir = p.path;
772
+ break;
773
+ }
774
+ }
775
+ if (!projectDir && projectKey(resolve(process.cwd())) === saved.row.project_key) {
776
+ projectDir = process.cwd();
777
+ }
778
+ }
779
+ if (!projectDir) {
780
+ process.stderr.write('project for this pipeline is not onboarded (worca add)\n');
781
+ return 1;
782
+ }
783
+
784
+ const orch = createOrchestrator({
785
+ projectDir,
786
+ ...(workspace ? { workspace } : {}),
787
+ claude: { mock },
788
+ auto,
789
+ resume: saved,
790
+ });
791
+ return attachAndDrive(orch, { auto }, () => orch.resume());
792
+ }
793
+
794
+ // ── plugin subcommands ─────────────────────────────────────────────────────────
795
+ // Thin wrappers over src/core/plugin-*.mjs. All imports are lazy (mirrors
796
+ // cmdResume) so non-plugin invocations never load the plugin machinery.
797
+ // Exit codes: 0 ok, 1 failure/abort, 2 usage/validation errors (fail()).
798
+
799
+ const PLUGIN_HELP = `worca plugin — manage worca-cc plugins (task sources, agents, skills, workflows)
800
+
801
+ Usage:
802
+ worca plugin add <repo-url> Register a plugin marketplace (alias of: worca marketplace add)
803
+ worca plugin install <name> [--repo <url>] [--marketplace <id>] [--ref <sha>] [--yes]
804
+ worca plugin list Installed plugins (from plugins.lock.json)
805
+ worca plugin update <name> [--yes] [--diff] Preview commits, diffstat + manifest delta (--diff: full diff), then update
806
+ worca plugin remove <name> [--purge] Uninstall (--purge also deletes data/)
807
+ worca plugin purge <name> Shorthand for remove --purge
808
+ worca plugin enable <name> | disable <name> Toggle without removing files
809
+ worca plugin doctor [name] [--fix] Health checks (--fix re-runs deterministic setup on failure)
810
+ worca plugin link <dir> Dev mode: use a local dir as "current"
811
+ worca plugin init <name> [--dir <D>] [--with task-source,agents,skills,workflows]
812
+ 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
814
+ worca plugin channel <name> <channelId> [--check] [--inspect] Run a chat channel worker in the
815
+ foreground (typed lines = simulated inbound); --check runs
816
+ the module's validateConfig once and exits
817
+
818
+ Exit codes: 0 ok, 1 failure, 2 usage/validation errors.
819
+ `;
820
+
821
+ const MARKETPLACE_HELP = `worca marketplace — manage plugin marketplaces (repos whose plugins show up as installable)
822
+
823
+ Usage:
824
+ worca marketplace add <repo-url|owner/repo|path> Register + sync a marketplace
825
+ worca marketplace list Registered marketplaces + their plugins
826
+ worca marketplace refresh [id] Re-sync one marketplace (or all)
827
+ worca marketplace remove <id> [--yes] Unregister (installed plugins remain)
828
+
829
+ Removing a marketplace only removes discovery — already-installed plugins keep
830
+ working, including updates (install provenance lives in plugins.lock.json).
831
+ Exit codes: 0 ok, 1 failure, 2 usage/validation errors.
832
+ `;
833
+
834
+ /** Tiny per-verb arg parser: positionals plus declared --value / --bool flags. */
835
+ function pluginArgs(argv, valueFlags = [], boolFlags = []) {
836
+ const out = { _: [] };
837
+ for (let i = 0; i < argv.length; i++) {
838
+ let a = argv[i];
839
+ let inline;
840
+ const eq = a.indexOf('=');
841
+ if (a.startsWith('--') && eq !== -1) {
842
+ inline = a.slice(eq + 1);
843
+ a = a.slice(0, eq);
844
+ }
845
+ if (valueFlags.includes(a)) {
846
+ const v = inline !== undefined ? inline : argv[++i];
847
+ if (v === undefined) fail(`Flag ${a} requires a value.`);
848
+ out[a.slice(2)] = v;
849
+ } else if (boolFlags.includes(a)) {
850
+ out[a.slice(2)] = true;
851
+ } else if (a.startsWith('-')) {
852
+ fail(`Unknown flag: ${a}`);
853
+ } else {
854
+ out._.push(a);
855
+ }
856
+ }
857
+ return out;
858
+ }
859
+
860
+ /** y/N confirm via readline; --yes short-circuits to true (scripting contract). */
861
+ async function confirmPlugin(msg, yes) {
862
+ if (yes) return true;
863
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
864
+ rl.on('SIGINT', () => { rl.close(); out(''); process.exit(130); });
865
+ try {
866
+ const a = (await question(rl, c('cyan', `${msg} [y/N] `))).trim();
867
+ return /^y(es)?$/i.test(a);
868
+ } finally {
869
+ rl.close();
870
+ }
871
+ }
872
+
873
+ /** "1 source, 2 agents, 1 skill" from an inventory/contributions bag (defensive:
874
+ * accepts arrays OR the numeric counts listInstalledPlugins() produces). */
875
+ function contribSummary(x) {
876
+ const n = (v) => (Array.isArray(v) ? v.length : Number.isFinite(v) ? v : 0);
877
+ const b = x || {};
878
+ const parts = [
879
+ [n(b.taskSources), 'source', 'sources'],
880
+ [n(b.chatChannels), 'chat channel', 'chat channels'],
881
+ [n(b.agents), 'agent', 'agents'],
882
+ [n(b.skills), 'skill', 'skills'],
883
+ [n(b.workflows), 'workflow', 'workflows'],
884
+ ]
885
+ .filter(([count]) => count > 0)
886
+ .map(([count, one, many]) => `${count} ${count === 1 ? one : many}`);
887
+ return parts.length ? parts.join(', ') : 'no contributions';
888
+ }
889
+
890
+ /** Print the post-export install inventory (spec §6.1 consent items). */
891
+ function printInventory(inv) {
892
+ const i = inv || {};
893
+ for (const s of i.taskSources || []) {
894
+ out(` task source: ${s.id} (${s.displayName})${s.secrets?.length ? ` — secrets: ${s.secrets.join(', ')}` : ''}`);
895
+ }
896
+ for (const ch of i.chatChannels || []) {
897
+ const dirs = [ch.inbound && 'inbound', ch.outbound && 'outbound'].filter(Boolean).join('+');
898
+ out(` chat channel: ${ch.id} (${ch.platform}, ${dirs}, persistent worker)${ch.secrets?.length ? ` — secrets: ${ch.secrets.join(', ')}` : ''}`);
899
+ if (ch.inbound) out(' WARNING: inbound chat can pause/stop/approve runs — bot token or allowed-chat membership controls worca-cc');
900
+ }
901
+ for (const a of i.agents || []) {
902
+ out(` agent: ${a.key}${a.tools?.length ? ` (tools: ${a.tools.join(', ')})` : ''}`);
903
+ }
904
+ for (const s of i.skills || []) out(` skill: ${s}`);
905
+ for (const w of i.workflows || []) out(` workflow: ${w}`);
906
+ if (i.depCount != null) out(` npm dependencies: ${i.depCount}`);
907
+ for (const cmd of i.setupCommands || []) out(` setup: ${cmd}`);
908
+ }
909
+
910
+ /** kebab plugin name -> camelCase stem for the scaffolded example agent key. */
911
+ function camelizePluginName(name) {
912
+ return name.replace(/-([a-z0-9])/g, (_, ch) => ch.toUpperCase());
913
+ }
914
+
915
+ const INIT_PARTS = ['task-source', 'agents', 'skills', 'workflows'];
916
+
917
+ /** `worca plugin init <name>` — scaffold a complete working plugin. */
918
+ async function pluginInit(rest) {
919
+ const a = pluginArgs(rest, ['--dir', '--with'], []);
920
+ const name = a._[0];
921
+ if (!name) fail('Usage: worca plugin init <name> [--dir <D>] [--with task-source,agents,skills,workflows]');
922
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) fail(`plugin name must be kebab-case (got "${name}")`);
923
+ const withParts = a.with ? a.with.split(',').map((s) => s.trim()).filter(Boolean) : INIT_PARTS;
924
+ for (const part of withParts) {
925
+ if (!INIT_PARTS.includes(part)) fail(`unknown --with part "${part}" (known: ${INIT_PARTS.join(', ')})`);
926
+ }
927
+ if (withParts.includes('workflows') && !withParts.includes('agents')) {
928
+ fail('--with workflows requires agents (templates may only reference the plugin\'s own agent keys)');
929
+ }
930
+ const target = resolve(process.cwd(), a.dir || name);
931
+ const { mkdir, writeFile, chmod, readdir } = await import('node:fs/promises');
932
+ try {
933
+ if ((await readdir(target)).length) {
934
+ process.stderr.write(`target dir ${target} exists and is not empty\n`);
935
+ return 1;
936
+ }
937
+ } catch { /* missing dir is the normal case */ }
938
+
939
+ const agentKey = camelizePluginName(name) + 'Helper';
940
+ const files = new Map();
941
+
942
+ const manifestObj = {
943
+ name,
944
+ version: '0.1.0',
945
+ description: 'Scaffolded worca-cc plugin — edit me',
946
+ engines: { 'worca-cc-api': '>=1 <2' },
947
+ };
948
+ if (withParts.includes('task-source')) {
949
+ manifestObj.taskSources = [{
950
+ id: 'main',
951
+ displayName: name,
952
+ module: './connector/index.mjs',
953
+ configSchema: [
954
+ { key: 'token', type: 'text', secret: true, required: false, label: 'API token', help: 'Optional. Use {"$env":"MY_TOKEN"} to read it from the environment.' },
955
+ ],
956
+ inputs: [
957
+ { key: 'filter', type: 'text', label: 'Filter', default: '' },
958
+ { key: 'task', type: 'task-browser', label: 'Task' },
959
+ ],
960
+ }];
961
+ files.set('connector/index.mjs', [
962
+ '// Mock-style task source scaffold. Replace the canned data with real API calls.',
963
+ '// Contract (plugin API v1): validateConfig / listTasks / getTask / reportResult / capabilities.',
964
+ 'const TASKS = [',
965
+ " { id: 'DEMO-1', title: 'First demo task', url: 'https://example.invalid/demo/1', state: 'open', labels: ['demo'], updatedAt: '2026-01-01T00:00:00.000Z' },",
966
+ " { id: 'DEMO-2', title: 'Second demo task', url: 'https://example.invalid/demo/2', state: 'open', labels: [], updatedAt: '2026-01-02T00:00:00.000Z' },",
967
+ '];',
968
+ '',
969
+ 'export default function createTaskSource(ctx) {',
970
+ ' return {',
971
+ ' async validateConfig() {',
972
+ ' return { ok: true };',
973
+ ' },',
974
+ ' async listTasks({ search } = {}) {',
975
+ " const needle = String(search || '').trim().toLowerCase();",
976
+ ' const tasks = needle ? TASKS.filter((t) => t.title.toLowerCase().includes(needle)) : TASKS;',
977
+ ' return { tasks };',
978
+ ' },',
979
+ ' async getTask(id) {',
980
+ ' const t = TASKS.find((x) => x.id === id);',
981
+ ' if (!t) return null;',
982
+ " return { ...t, body: ['## Goal', '', 'Replace this connector with real API calls.'].join('\\n'), meta: { source: 'scaffold' } };",
983
+ ' },',
984
+ ' async reportResult(id, r) {',
985
+ " await ctx.state.set('lastReport', JSON.stringify({ id, status: r.status, summary: r.summary, links: r.links || [] }));",
986
+ ' },',
987
+ ' capabilities() {',
988
+ ' return { writeBack: true, incrementalSync: false };',
989
+ ' },',
990
+ ' };',
991
+ '}',
992
+ '',
993
+ ].join('\n'));
994
+ }
995
+ if (withParts.includes('agents')) {
996
+ files.set(`agents/${agentKey}.meta.json`, JSON.stringify({
997
+ key: agentKey,
998
+ displayName: 'Example Helper',
999
+ description: `Example agent installed by the ${name} plugin`,
1000
+ color: 'amber',
1001
+ agentFile: `${agentKey}.md`,
1002
+ runnerType: 'producer',
1003
+ consumes: ['userPrompt'],
1004
+ produces: ['code'],
1005
+ ...(withParts.includes('skills') ? { requiresSkills: ['example-skill'] } : {}),
1006
+ order: 900,
1007
+ }, null, 2) + '\n');
1008
+ files.set(`agents/${agentKey}.md`, [
1009
+ '---',
1010
+ `name: ${agentKey}`,
1011
+ 'description: Example plugin agent. Replace with real instructions.',
1012
+ 'tools: Read, Grep, Glob',
1013
+ 'model: inherit',
1014
+ '---',
1015
+ '',
1016
+ `You are an example agent shipped by the "${name}" worca-cc plugin.`,
1017
+ 'Acknowledge the task you were given and describe what a real agent would do here.',
1018
+ '',
1019
+ ].join('\n'));
1020
+ }
1021
+ if (withParts.includes('skills')) {
1022
+ files.set('skills/example-skill/SKILL.md', [
1023
+ '---',
1024
+ 'name: example-skill',
1025
+ `description: Example helper skill shipped by the ${name} plugin. Agents run helper.sh via Bash.`,
1026
+ '---',
1027
+ '',
1028
+ '# example-skill',
1029
+ '',
1030
+ 'Run ./helper.sh (relative to this skill directory) to print a deterministic marker line.',
1031
+ '',
1032
+ ].join('\n'));
1033
+ files.set('skills/example-skill/helper.sh', '#!/bin/sh\necho "example-skill helper ok"\n');
1034
+ }
1035
+ if (withParts.includes('workflows')) {
1036
+ files.set('workflows/example-flow.json', JSON.stringify({
1037
+ name: `${name} example flow`,
1038
+ version: 1,
1039
+ domain: 'general',
1040
+ steps: [[{ id: 's0_0', key: agentKey }]],
1041
+ feedbacks: [],
1042
+ }, null, 2) + '\n');
1043
+ }
1044
+ files.set('worca-cc-plugin.json', JSON.stringify(manifestObj, null, 2) + '\n');
1045
+
1046
+ for (const [rel, content] of files) {
1047
+ const dest = join(target, rel);
1048
+ await mkdir(dirname(dest), { recursive: true });
1049
+ await writeFile(dest, content, 'utf8');
1050
+ }
1051
+ if (files.has('skills/example-skill/helper.sh')) {
1052
+ await chmod(join(target, 'skills/example-skill/helper.sh'), 0o755);
1053
+ }
1054
+
1055
+ // Belt-and-suspenders: the scaffold must lint clean, strictly.
1056
+ const { validatePluginDir } = await import('../core/plugin-manifest.mjs');
1057
+ const v = validatePluginDir(target, { strict: true });
1058
+ if (!v.ok) {
1059
+ for (const p of v.problems) process.stderr.write(`${p.level}: ${p.message}\n`);
1060
+ return 1;
1061
+ }
1062
+ out(`scaffolded ${name} at ${target}`);
1063
+ out('next steps:');
1064
+ out(` worca plugin link ${target}`);
1065
+ if (withParts.includes('task-source')) out(` WORCA_MOCK=1 worca plugin exec ${name} main listTasks`);
1066
+ return 0;
1067
+ }
1068
+
1069
+ /** `worca plugin <verb> …` — dispatch. */
1070
+ async function cmdPlugin(argv) {
1071
+ const verb = argv[0];
1072
+ const rest = argv.slice(1);
1073
+ if (!verb || verb === 'help') {
1074
+ process.stdout.write(PLUGIN_HELP);
1075
+ return 0;
1076
+ }
1077
+
1078
+ const store = await import('../core/plugin-store.mjs');
1079
+ const repoMod = await import('../core/plugin-repo.mjs');
1080
+ const manifestMod = await import('../core/plugin-manifest.mjs');
1081
+
1082
+ try {
1083
+ switch (verb) {
1084
+ case 'add': {
1085
+ const a = pluginArgs(rest);
1086
+ const url = a._[0];
1087
+ if (!url) fail('Usage: worca plugin add <repo-url> (alias of: worca marketplace add)');
1088
+ out('note: `worca plugin add` now registers a marketplace (persisted) — same as `worca marketplace add`');
1089
+ return cmdMarketplace(['add', url]);
1090
+ }
1091
+
1092
+ case 'install': {
1093
+ const a = pluginArgs(rest, ['--repo', '--ref', '--marketplace'], ['--yes']);
1094
+ const name = a._[0];
1095
+ if (!name) fail('Usage: worca plugin install <name> [--repo <url>] [--marketplace <id>] [--ref <sha>] [--yes]');
1096
+ const mkt = await import('../core/marketplaces.mjs');
1097
+ try { mkt.seedBuiltinMarketplace(); } catch { /* non-checkout install */ }
1098
+ let repoUrl = a.repo;
1099
+ let marketplace = a.marketplace || null;
1100
+ if (!repoUrl && marketplace) {
1101
+ const m = mkt.listMarketplaces().find((x) => x.id === marketplace);
1102
+ if (!m) fail(`unknown marketplace "${marketplace}" — see: worca marketplace list`);
1103
+ repoUrl = m.url;
1104
+ }
1105
+ if (!repoUrl) {
1106
+ let hit = mkt.resolveInstallSource(name, {});
1107
+ if (!hit && mkt.listMarketplaces().some((m) => !m.lastSync)) {
1108
+ // builtin was seeded with no snapshot (no-git-ops seed) — sync unsynced ones, retry (C5)
1109
+ for (const m of mkt.listMarketplaces()) { if (!m.lastSync) { try { await mkt.syncMarketplace(m.id); } catch { /* tolerate */ } } }
1110
+ hit = mkt.resolveInstallSource(name, {});
1111
+ }
1112
+ if (hit && hit.candidates) {
1113
+ process.stderr.write(`plugin "${name}" exists in ${hit.candidates.length} marketplaces — pass --repo <url> or --marketplace <id>:\n`);
1114
+ for (const cnd of hit.candidates) process.stderr.write(` --marketplace ${cnd.marketplace}\t${cnd.repoUrl}\n`);
1115
+ return 1;
1116
+ }
1117
+ if (hit) {
1118
+ repoUrl = hit.repoUrl;
1119
+ marketplace = marketplace ?? hit.marketplace;
1120
+ }
1121
+ }
1122
+ if (!repoUrl) fail(`plugin "${name}" not found in the lock or any marketplace — pass --repo <url> (or add a marketplace first)`);
1123
+ const found = await repoMod.addPluginRepo(repoUrl);
1124
+ const entry = found.discovered.find((d) => d.name === name);
1125
+ if (!entry) {
1126
+ process.stderr.write(`plugin "${name}" not found in ${repoUrl} (discovered: ${found.discovered.map((d) => d.name).join(', ') || 'none'})\n`);
1127
+ return 1;
1128
+ }
1129
+ const sha = a.ref || found.sha;
1130
+ // Consent summary: everything knowable from the manifest BEFORE any code
1131
+ // is exported or any setup runs (the web UI shows the richer exported
1132
+ // inventory via its endpoint; the CLI prints the store's ground-truth
1133
+ // inventory right after install).
1134
+ const m = entry.manifest;
1135
+ out(`will install ${m.name} ${m.version || ''} @ ${sha.slice(0, 7)} from ${repoUrl}`);
1136
+ if (m.description) out(` ${m.description}`);
1137
+ for (const s of m.taskSources || []) {
1138
+ const secrets = (s.configSchema || []).filter((f) => f.secret).map((f) => f.key);
1139
+ out(` task source: ${s.id} (${s.displayName})${secrets.length ? ` — requests secrets: ${secrets.join(', ')}` : ''}`);
1140
+ }
1141
+ if (m.setup?.node) out(' setup: npm ci --prefix <versionDir> --ignore-scripts --omit=dev');
1142
+ if (m.setup?.python) out(' setup: uv sync --project <versionDir>');
1143
+ if (!(await confirmPlugin('Install?', !!a.yes))) {
1144
+ out('aborted (nothing installed)');
1145
+ return 1;
1146
+ }
1147
+ const res = await store.installPlugin({ repoUrl, subdir: entry.subdir, name, sha, ...(marketplace ? { marketplace } : {}) });
1148
+ out('installed:');
1149
+ printInventory(res.inventory);
1150
+ return 0;
1151
+ }
1152
+
1153
+ case 'list': {
1154
+ const plugins = store.listInstalledPlugins();
1155
+ if (!plugins.length) {
1156
+ out('No plugins installed. Browse marketplaces with `worca marketplace list` or add one with `worca marketplace add <repo-url>`.');
1157
+ return 0;
1158
+ }
1159
+ for (const p of plugins) {
1160
+ const version = p.linked ? 'linked' : p.version || (p.pinnedSha || '').slice(0, 7);
1161
+ const flags = [p.enabled ? 'enabled' : 'disabled', ...(p.linked ? ['linked'] : [])].join(', ');
1162
+ out(`${p.name}\t${version}\t${flags}\t${contribSummary(p.contributions)}`);
1163
+ }
1164
+ return 0;
1165
+ }
1166
+
1167
+ case 'update': {
1168
+ const a = pluginArgs(rest, [], ['--yes', '--diff']);
1169
+ const name = a._[0];
1170
+ if (!name) fail('Usage: worca plugin update <name> [--yes] [--diff]');
1171
+ const cand = await repoMod.fetchCandidate(name, { fullDiff: !!a.diff });
1172
+ if (cand.candidateSha === cand.pinnedSha) {
1173
+ out(`${name} is already up to date (${cand.pinnedSha.slice(0, 7)})`);
1174
+ return 0;
1175
+ }
1176
+ out(`${name}: ${cand.pinnedSha.slice(0, 7)} -> ${cand.candidateSha.slice(0, 7)}`);
1177
+ for (const commit of cand.commits) out(` ${commit.sha.slice(0, 7)} ${commit.subject}`);
1178
+ if (cand.diffstat) out(cand.diffstat);
1179
+ // §6.2 manifest delta — the red-flag review lines.
1180
+ const delta = cand.manifestDelta || {};
1181
+ for (const k of delta.newSecrets || []) out(c('red', ` NEW SECRET requested: ${k}`));
1182
+ for (const s of delta.newTaskSources || []) out(c('yellow', ` new task source: ${s}`));
1183
+ for (const ag of delta.newAgents || []) out(c('yellow', ` new agent: ${ag}`));
1184
+ if (delta.setupChanged) out(c('yellow', ' setup commands changed'));
1185
+ if (a.diff && cand.diffFull) out(cand.diffFull);
1186
+ if (!(await confirmPlugin('Update?', !!a.yes))) {
1187
+ out('aborted (still pinned)');
1188
+ return 1;
1189
+ }
1190
+ await store.updatePlugin(name);
1191
+ out(`updated ${name} to ${cand.candidateSha.slice(0, 7)}`);
1192
+ return 0;
1193
+ }
1194
+
1195
+ case 'remove':
1196
+ case 'purge': {
1197
+ const a = pluginArgs(rest, [], ['--purge']);
1198
+ const name = a._[0];
1199
+ if (!name) fail(`Usage: worca plugin ${verb} <name>${verb === 'remove' ? ' [--purge]' : ''}`);
1200
+ const purge = verb === 'purge' || !!a.purge;
1201
+ await store.uninstallPlugin(name, { purge });
1202
+ out(`removed ${name}`);
1203
+ out(purge ? 'data/ purged (config, secrets, state)' : `data/ kept — remove it with: worca plugin purge ${name}`);
1204
+ return 0;
1205
+ }
1206
+
1207
+ case 'enable':
1208
+ case 'disable': {
1209
+ const a = pluginArgs(rest);
1210
+ const name = a._[0];
1211
+ if (!name) fail(`Usage: worca plugin ${verb} <name>`);
1212
+ store.setPluginEnabled(name, verb === 'enable');
1213
+ out(`${verb}d ${name}`);
1214
+ return 0;
1215
+ }
1216
+
1217
+ case 'doctor': {
1218
+ const a = pluginArgs(rest, [], ['--fix']);
1219
+ const names = a._[0] ? [a._[0]] : store.listInstalledPlugins().map((p) => p.name);
1220
+ if (!names.length) {
1221
+ out('No plugins installed.');
1222
+ return 0;
1223
+ }
1224
+ let allOk = true;
1225
+ for (const name of names) {
1226
+ const report = await store.doctorPlugin(name);
1227
+ out(`${report.ok ? c('green', 'OK ') : c('red', 'FAIL')} ${name}`);
1228
+ for (const check of report.checks) {
1229
+ out(` ${check.ok ? c('green', '✓') : c('red', '✗')} ${check.id}${check.detail ? c('gray', ` — ${check.detail}`) : ''}`);
1230
+ }
1231
+ if (!report.ok) allOk = false;
1232
+ }
1233
+ // §6.4 heal path: --fix re-runs the DETERMINISTIC setup steps (npm ci /
1234
+ // uv sync — never plugin-chosen commands) for every unhealthy plugin.
1235
+ if (!allOk && a.fix) {
1236
+ const { readFileSync } = await import('node:fs');
1237
+ const { pluginCurrentDir } = await import('../core/plugins-lock.mjs');
1238
+ for (const name of names) {
1239
+ try {
1240
+ const cur = pluginCurrentDir(name);
1241
+ const norm = manifestMod.normalizeManifest(
1242
+ JSON.parse(readFileSync(join(cur, 'worca-cc-plugin.json'), 'utf8')), { dir: cur });
1243
+ if (norm.ok) {
1244
+ await store.runSetup(cur, norm.manifest);
1245
+ out(`re-ran setup for ${name}`);
1246
+ }
1247
+ } catch (err) {
1248
+ process.stderr.write(`fix ${name}: ${err?.message || err}\n`);
1249
+ }
1250
+ }
1251
+ return 0;
1252
+ }
1253
+ return allOk ? 0 : 1;
1254
+ }
1255
+
1256
+ case 'link': {
1257
+ const a = pluginArgs(rest);
1258
+ const dir = a._[0];
1259
+ if (!dir) fail('Usage: worca plugin link <dir>');
1260
+ const abs = resolve(process.cwd(), dir);
1261
+ 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);
1267
+ out(`linked ${v.manifest.name} -> ${abs} (dev mode; doctor will warn)`);
1268
+ return 0;
1269
+ }
1270
+
1271
+ case 'init':
1272
+ return await pluginInit(rest);
1273
+
1274
+ case 'validate': {
1275
+ const a = pluginArgs(rest, [], ['--strict']);
1276
+ const dir = a._[0];
1277
+ if (!dir) fail('Usage: worca plugin validate <dir> [--strict]');
1278
+ const v = manifestMod.validatePluginDir(resolve(process.cwd(), dir), { strict: !!a.strict });
1279
+ for (const p of v.problems) {
1280
+ out(`${p.level === 'error' ? c('red', 'error') : c('yellow', 'warn ')}: ${p.message}`);
1281
+ }
1282
+ if (!v.ok) return 2;
1283
+ const warns = v.problems.length;
1284
+ out(`OK: ${v.manifest.name}${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}`);
1285
+ return 0;
1286
+ }
1287
+
1288
+ case 'exec': {
1289
+ const a = pluginArgs(rest, ['--args'], ['--inspect']);
1290
+ const [name, sourceId, op] = a._;
1291
+ if (!name || !sourceId || !op) fail("Usage: worca plugin exec <name> <sourceId> <op> [--args '<json>'] [--inspect]");
1292
+ if (a.inspect) process.env.WORCA_PLUGIN_INSPECT = '1'; // shim spawns the child with --inspect-brk
1293
+ let args = {};
1294
+ if (a.args) {
1295
+ try {
1296
+ args = JSON.parse(a.args);
1297
+ } catch {
1298
+ fail('--args must be valid JSON');
1299
+ }
1300
+ }
1301
+ const { callSource } = await import('../core/plugin-shim.mjs');
1302
+ const result = await callSource({ plugin: name, sourceId, op, args });
1303
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n'); // stdout = result ONLY
1304
+ return 0;
1305
+ }
1306
+
1307
+ case 'channel': {
1308
+ const a = pluginArgs(rest, [], ['--check', '--inspect']);
1309
+ const [name, channelId] = a._;
1310
+ if (!name || !channelId) fail('Usage: worca plugin channel <name> <channelId> [--check] [--inspect]');
1311
+ if (a.inspect) process.env.WORCA_PLUGIN_INSPECT = '1';
1312
+ const { createChannelHost } = await import('../core/chat/channel-host.mjs');
1313
+ if (a.check) {
1314
+ const host = createChannelHost({ logger: () => {} });
1315
+ const result = await host.checkChannel(name, channelId);
1316
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1317
+ return result && result.ok ? 0 : 1;
1318
+ }
1319
+ // Foreground worker: redacted frames echo to stderr; typed lines become
1320
+ // simulated inbound text so the command loop is testable offline.
1321
+ const host = createChannelHost({
1322
+ logger: (level, msg) => process.stderr.write(`${level}: ${msg}\n`),
1323
+ onInbound: (ev) => process.stderr.write(`inbound: ${JSON.stringify(ev.msg)}\n`),
1324
+ onStatus: (ev) => process.stderr.write(`status: ${ev.state}${ev.detail ? ` (${ev.detail})` : ''}\n`),
1325
+ });
1326
+ host.start({ plugin: name, channelId });
1327
+ const row = host.status().find((r) => r.plugin === name && r.channelId === channelId);
1328
+ if (!row) { await host.stop(); fail(`no chat channel "${name}/${channelId}" — is the plugin installed and enabled?`); }
1329
+ process.stderr.write(`worker for ${name}/${channelId} running — type text to simulate inbound, Ctrl-C to exit\n`);
1330
+ const { createInterface } = await import('node:readline');
1331
+ const rl = createInterface({ input: process.stdin });
1332
+ rl.on('line', (line) => {
1333
+ if (!line.trim()) return;
1334
+ try { host.injectInboundMessage(name, channelId, { chatId: 'CLI', userId: 'cli', text: line.trim(), meta: {} }); }
1335
+ catch (err) { process.stderr.write(`inject failed: ${err?.message || err}\n`); }
1336
+ });
1337
+ await new Promise((resolve) => {
1338
+ process.on('SIGINT', resolve);
1339
+ rl.on('close', resolve);
1340
+ });
1341
+ await host.stop();
1342
+ return 0;
1343
+ }
1344
+
1345
+ default:
1346
+ fail(`Unknown plugin subcommand: ${verb}\n\n${PLUGIN_HELP}`);
1347
+ }
1348
+ } catch (err) {
1349
+ const kind = err?.kind ? `[${err.kind}] ` : '';
1350
+ process.stderr.write(`worca plugin ${verb}: ${kind}${err?.message || err}\n`);
1351
+ for (const ref of err?.references || []) {
1352
+ process.stderr.write(` referenced by: ${typeof ref === 'string' ? ref : JSON.stringify(ref)}\n`);
1353
+ }
1354
+ return 1;
1355
+ }
1356
+ }
1357
+
1358
+ /** `worca marketplace <verb> …` — dispatch. Seeds the builtin marketplace
1359
+ * lazily (a no-op file write after the first time; never any git work). */
1360
+ async function cmdMarketplace(argv) {
1361
+ const verb = argv[0];
1362
+ const rest = argv.slice(1);
1363
+ if (!verb || verb === 'help') {
1364
+ process.stdout.write(MARKETPLACE_HELP);
1365
+ return 0;
1366
+ }
1367
+ const mkt = await import('../core/marketplaces.mjs');
1368
+ try { mkt.seedBuiltinMarketplace(); } catch { /* non-checkout install: skip */ }
1369
+ try {
1370
+ switch (verb) {
1371
+ case 'add': {
1372
+ const a = pluginArgs(rest);
1373
+ const url = a._[0];
1374
+ if (!url) fail('Usage: worca marketplace add <repo-url|owner/repo|path>');
1375
+ const entry = await mkt.addMarketplace(url);
1376
+ out(`added marketplace ${entry.name}\t${entry.id}\t@ ${entry.lastSync.sha.slice(0, 7)}`);
1377
+ for (const p of entry.plugins) out(` ${p.name}\t${p.version || (entry.lastSync ? entry.lastSync.sha.slice(0, 7) : '')}\t${p.description || ''}`);
1378
+ for (const w of entry.warnings) out(c('yellow', ` warning: ${w}`));
1379
+ out(`install with: worca plugin install <name>`);
1380
+ return 0;
1381
+ }
1382
+ case 'list': {
1383
+ const entries = mkt.listMarketplaces();
1384
+ if (!entries.length) {
1385
+ out('No marketplaces registered. Add one with `worca marketplace add <repo-url>`.');
1386
+ return 0;
1387
+ }
1388
+ for (const m of entries) {
1389
+ const sync = m.lastSync ? `${m.lastSync.sha.slice(0, 7)} (${m.plugins.length} plugins)` : 'never synced';
1390
+ out(`${m.name}\t${m.id}\t${m.url}\t${sync}${m.builtin ? '\tbuilt-in' : ''}`);
1391
+ for (const w of m.warnings || []) out(c('yellow', ` warning: ${w}`));
1392
+ }
1393
+ return 0;
1394
+ }
1395
+ case 'refresh': {
1396
+ const a = pluginArgs(rest);
1397
+ const entries = a._[0] ? [await mkt.syncMarketplace(a._[0])] : await mkt.refreshAllMarketplaces();
1398
+ for (const m of entries) {
1399
+ const sync = m.lastSync ? `${m.lastSync.sha.slice(0, 7)} (${m.plugins.length} plugins)` : 'never synced';
1400
+ out(`${m.name}\t${sync}`);
1401
+ for (const w of m.warnings || []) out(c('yellow', ` warning: ${w}`));
1402
+ }
1403
+ return 0;
1404
+ }
1405
+ case 'remove': {
1406
+ const a = pluginArgs(rest, [], ['--yes']);
1407
+ const id = a._[0];
1408
+ if (!id) fail('Usage: worca marketplace remove <id> [--yes]');
1409
+ if (!(await confirmPlugin(`Remove marketplace "${id}"? Installed plugins remain.`, !!a.yes))) {
1410
+ out('aborted');
1411
+ return 1;
1412
+ }
1413
+ mkt.removeMarketplace(id);
1414
+ out(`removed marketplace ${id} — installed plugins remain (managed via worca plugin …)`);
1415
+ return 0;
1416
+ }
1417
+ default:
1418
+ fail(`unknown marketplace verb "${verb}" — see: worca marketplace help`);
1419
+ }
1420
+ } catch (err) {
1421
+ const kind = err?.kind ? `[${err.kind}] ` : '';
1422
+ process.stderr.write(`worca marketplace ${verb}: ${kind}${err?.message || err}\n`);
1423
+ for (const ref of err?.references || []) {
1424
+ process.stderr.write(` referenced by: ${typeof ref === 'string' ? ref : JSON.stringify(ref)}\n`);
1425
+ }
1426
+ return 1;
1427
+ }
1428
+ }
1429
+
1430
+ // ── main ──────────────────────────────────────────────────────────────────────────
1431
+
1432
+ const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config']);
1433
+
1434
+ async function main() {
1435
+ const sub = process.argv[2];
1436
+ if (SUBCOMMANDS.has(sub)) {
1437
+ const rest = process.argv.slice(3);
1438
+ if (sub === 'add') return cmdAdd(rest);
1439
+ if (sub === 'list') return cmdList();
1440
+ if (sub === 'remove') return cmdRemove(rest);
1441
+ if (sub === 'resume') return cmdResume(rest);
1442
+ if (sub === 'doctor') return cmdDoctor();
1443
+ if (sub === 'plugin') return cmdPlugin(rest);
1444
+ if (sub === 'marketplace') return cmdMarketplace(rest);
1445
+ if (sub === 'config') return cmdConfig(rest);
1446
+ }
1447
+
1448
+ const flags = parseArgs(process.argv.slice(2));
1449
+
1450
+ if (flags.help) {
1451
+ process.stdout.write(HELP);
1452
+ return 0;
1453
+ }
1454
+
1455
+ if (flags.install) {
1456
+ // Forward --force (and any other extra tokens) to the installer.
1457
+ const passthrough = [];
1458
+ if (process.argv.includes('--force')) passthrough.push('--force');
1459
+ return runInstall(flags.install, passthrough);
1460
+ }
1461
+
1462
+ if (flags.ui) {
1463
+ return launchUi();
1464
+ }
1465
+
1466
+ if (flags.mock) {
1467
+ process.env.WORCA_MOCK = '1';
1468
+ }
1469
+
1470
+ if (!flags.prompt && !flags.file) {
1471
+ // Allow a bare positional prompt: `worca "do the thing"`.
1472
+ if (flags._.length) {
1473
+ flags.prompt = flags._.join(' ');
1474
+ } else {
1475
+ fail('Provide a task with --prompt "<text>" or --file <markdown>. See --help.');
1476
+ }
1477
+ }
1478
+
1479
+ const projectDir = resolve(flags.project);
1480
+ // Resolve extras against the shell cwd so relative paths are unambiguous.
1481
+ const extras = (flags.extras || []).map((p) => resolve(process.cwd(), p));
1482
+
1483
+ // Never create a run that the orchestrator would immediately pause on the total
1484
+ // budget: refuse up front (mock runs included — WORCA_MOCK is already set above).
1485
+ const { budgetStatus } = await import('../core/cost-budget.mjs');
1486
+ const budget = budgetStatus();
1487
+ if (budget.blocked) {
1488
+ process.stderr.write(`worca: total cost limit reached: ${budgetRefusalDetail(budget)}. `
1489
+ + 'Raise it: worca config set totalCostLimitUsd <usd>\n');
1490
+ return 1;
1491
+ }
1492
+
1493
+ const orch = createOrchestrator({
1494
+ projectDir,
1495
+ prompt: flags.prompt || undefined,
1496
+ promptFile: flags.file || undefined,
1497
+ title: flags.title || undefined,
1498
+ extras,
1499
+ workflowId: flags.workflow || undefined,
1500
+ branch: { source: flags.sourceBranch, feature: flags.featureBranch },
1501
+ claude: {
1502
+ permissionMode: flags.permissionMode,
1503
+ model: flags.model,
1504
+ mock: flags.mock,
1505
+ },
1506
+ auto: flags.auto,
1507
+ });
1508
+
1509
+ out(c('bold', `orchestrator — project: ${projectDir}`));
1510
+ if (flags.mock) out(c('yellow', 'mock mode: no claude will be spawned'));
1511
+
1512
+ return attachAndDrive(orch, flags, () => orch.run());
1513
+ }
1514
+
1515
+ main()
1516
+ .then((code) => process.exit(code ?? 0))
1517
+ .catch((err) => {
1518
+ process.stderr.write(`worca: fatal: ${err?.stack || err?.message || err}\n`);
1519
+ process.exit(1);
1520
+ });