@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,485 @@
1
+ // src/core/plugin-store.mjs
2
+ // Plugin lifecycle (spec §6): install (consent inventory + atomic symlink swap),
3
+ // update (keep previous, GC last 2), uninstall/purge (data/ kept by default),
4
+ // enable/disable, list, doctor, dev link. Install NEVER executes plugin-chosen
5
+ // code: npm ci --ignore-scripts, no setup-command field, archive exports carry
6
+ // no .git. Any failure before the swap+lock lands removes versions/<sha7> and
7
+ // leaves prior state untouched (§6.1 step 4).
8
+
9
+ import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+ import { createHash } from 'node:crypto';
12
+ import {
13
+ existsSync, readdirSync, readFileSync, readlinkSync,
14
+ mkdirSync, rmSync, symlinkSync, renameSync,
15
+ } from 'node:fs';
16
+ import { join, resolve } from 'node:path';
17
+ import { WORCA_PLUGIN_APIS } from './plugin-api.mjs';
18
+ import { normalizeManifest, validatePluginDir, apiSatisfies } from './plugin-manifest.mjs';
19
+ import {
20
+ pluginsRoot, pluginDir, pluginCurrentDir, pluginDataDir, readPluginsLock, writePluginsLock,
21
+ DIR_NAME_RE,
22
+ } from './plugins-lock.mjs';
23
+ import { addPluginRepo, fetchCandidate, exportVersion, repoCacheDir } from './plugin-repo.mjs';
24
+ import { importPluginWorkflows, removePluginWorkflows, referencedPluginAgents } from './plugin-workflows.mjs';
25
+ import { pluginModelSecretStatus } from './plugin-models.mjs';
26
+ import { referencedPluginModels } from './config.mjs';
27
+
28
+ const execFileP = promisify(execFile);
29
+ const defaultExec = (cmd, args, opts = {}) =>
30
+ execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024, ...opts });
31
+
32
+ function readManifestAt(dir) {
33
+ try {
34
+ const res = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
35
+ return res.ok ? res.manifest : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function sha256File(file) {
42
+ try { return createHash('sha256').update(readFileSync(file)).digest('hex'); } catch { return null; }
43
+ }
44
+
45
+ /** Private copy of workflows.mjs:66-77 parseFrontmatterTools (module-private there). */
46
+ function frontmatterTools(text) {
47
+ const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
48
+ if (!m) return [];
49
+ const line = m[1].split(/\r?\n/).find((l) => /^tools\s*:/.test(l));
50
+ if (!line) return [];
51
+ return line.replace(/^tools\s*:/, '').split(',').map((s) => s.trim()).filter(Boolean);
52
+ }
53
+
54
+ /** The "Will install" consent inventory (spec §6.1, design §9.4): agents +
55
+ * their frontmatter tools, sources + their secret fields, models with their
56
+ * base-URL value VERBATIM (a model env can redirect all API traffic — the
57
+ * reviewer must see where) + requested model secrets, skills, workflows, npm
58
+ * dep count from the lockfile, and the exact setup commands that would run. */
59
+ export function buildInstallInventory(versionDir) {
60
+ const manifest = readManifestAt(versionDir)
61
+ ?? { taskSources: [], chatChannels: [], models: [], modelSecrets: [], setup: { node: false, python: null } };
62
+ const agents = [];
63
+ const aDir = join(versionDir, 'agents');
64
+ if (existsSync(aDir)) {
65
+ for (const f of readdirSync(aDir).filter((x) => x.endsWith('.meta.json')).sort()) {
66
+ const key = f.slice(0, -'.meta.json'.length);
67
+ let tools = [];
68
+ try { tools = frontmatterTools(readFileSync(join(aDir, `${key}.md`), 'utf8')); } catch { /* md missing */ }
69
+ agents.push({ key, tools });
70
+ }
71
+ }
72
+ const taskSources = (manifest.taskSources || []).map((s) => ({
73
+ id: s.id, displayName: s.displayName,
74
+ secrets: (s.configSchema || []).filter((x) => x.secret).map((x) => x.key),
75
+ }));
76
+ // Channel consent is security-loud by design: a chat channel is remote
77
+ // control of worca-cc (approve gates, stop/pause runs) for anyone holding
78
+ // the bot token or sitting in an allow-listed chat (design §4.6).
79
+ const chatChannels = (manifest.chatChannels || []).map((c) => ({
80
+ id: c.id, displayName: c.displayName, platform: c.platform, ingress: c.ingress,
81
+ inbound: c.capabilities?.inbound !== false, outbound: c.capabilities?.outbound !== false,
82
+ secrets: (c.configSchema || []).filter((x) => x.secret).map((x) => x.key),
83
+ }));
84
+ const models = (manifest.models || []).map((m) => {
85
+ const bu = m.env?.ANTHROPIC_BASE_URL;
86
+ return {
87
+ id: m.id, label: m.label, efforts: m.efforts,
88
+ envKeys: Object.keys(m.env ?? {}),
89
+ baseUrl: typeof bu === 'string' ? bu : bu ? `(from secret "${bu.secret}")` : null,
90
+ };
91
+ });
92
+ const modelSecrets = (manifest.modelSecrets || []).map((f) => ({ key: f.key, label: f.label }));
93
+ const skills = [];
94
+ const sDir = join(versionDir, 'skills');
95
+ if (existsSync(sDir)) {
96
+ for (const d of readdirSync(sDir, { withFileTypes: true })) {
97
+ if (d.isDirectory() && existsSync(join(sDir, d.name, 'SKILL.md'))) skills.push(d.name);
98
+ }
99
+ }
100
+ const workflows = [];
101
+ const wDir = join(versionDir, 'workflows');
102
+ if (existsSync(wDir)) {
103
+ for (const f of readdirSync(wDir).filter((x) => x.endsWith('.json')).sort()) workflows.push(f.slice(0, -5));
104
+ }
105
+ let depCount = null;
106
+ try {
107
+ const lock = JSON.parse(readFileSync(join(versionDir, 'package-lock.json'), 'utf8'));
108
+ depCount = Object.keys(lock.packages || {}).filter((k) => k !== '').length;
109
+ } catch { /* no lockfile */ }
110
+ const setupCommands = [];
111
+ if (manifest.setup?.node) setupCommands.push(`npm ci --prefix ${versionDir} --ignore-scripts --omit=dev`);
112
+ if (manifest.setup?.python === 'pyproject') setupCommands.push(`uv sync --project ${versionDir}`);
113
+ return { agents, taskSources, chatChannels, models, modelSecrets, skills: skills.sort(), workflows, depCount, setupCommands };
114
+ }
115
+
116
+ /** Declared setup FACTS only (spec §4.1): setup.node -> npm ci (lockfile
117
+ * required, scripts ignored); setup.python 'pyproject' -> uv sync. */
118
+ export async function runSetup(versionDir, manifest, { exec = defaultExec } = {}) {
119
+ const commands = [];
120
+ if (manifest?.setup?.node) {
121
+ if (!existsSync(join(versionDir, 'package-lock.json'))) {
122
+ throw new Error(`setup.node declared but ${join(versionDir, 'package-lock.json')} is missing (npm ci requires a lockfile)`);
123
+ }
124
+ await exec('npm', ['ci', '--prefix', versionDir, '--ignore-scripts', '--omit=dev']);
125
+ commands.push('npm ci');
126
+ }
127
+ if (manifest?.setup?.python === 'pyproject') {
128
+ await exec('uv', ['sync', '--project', versionDir]);
129
+ commands.push('uv sync');
130
+ }
131
+ return { commands };
132
+ }
133
+
134
+ /** Doctor checks that run against an arbitrary dir — shared by the install
135
+ * precheck (new version dir, pre-swap) and doctorPlugin (current/). */
136
+ function dirChecks(dir, manifest) {
137
+ const checks = [];
138
+ const c = (id, ok, detail) => checks.push({ id, ok: !!ok, detail });
139
+ c('manifest', !!manifest, manifest ? `plugin "${manifest.name}"` : 'worca-cc-plugin.json missing or invalid');
140
+ if (manifest) {
141
+ const range = manifest.engines?.worcaApi;
142
+ c('api', apiSatisfies(range), range ? `requires "${range}", host APIs [${WORCA_PLUGIN_APIS.join(', ')}]` : 'no engines constraint');
143
+ for (const s of manifest.taskSources || []) c(`module:${s.id}`, existsSync(join(dir, s.module)), s.module);
144
+ for (const ch of manifest.chatChannels || []) c(`channel-module:${ch.id}`, existsSync(join(dir, ch.module)), ch.module);
145
+ if (manifest.setup?.node) c('node-deps', existsSync(join(dir, 'node_modules')), 'node_modules present (setup.node)');
146
+ if (manifest.setup?.python === 'pyproject') c('python-venv', existsSync(join(dir, '.venv')), '.venv present (setup.python)');
147
+ }
148
+ return checks;
149
+ }
150
+
151
+ function currentTarget(name) {
152
+ try { return readlinkSync(pluginCurrentDir(name)); } catch { return null; }
153
+ }
154
+
155
+ /** Atomic swap (§6.1 step 3): write current.tmp symlink, rename(2) over current. */
156
+ function swapCurrent(name, target) {
157
+ const current = pluginCurrentDir(name);
158
+ const tmp = `${current}.tmp`;
159
+ rmSync(tmp, { force: true });
160
+ mkdirSync(pluginDir(name), { recursive: true });
161
+ symlinkSync(target, tmp);
162
+ renameSync(tmp, current);
163
+ }
164
+
165
+ /** §6.1 step 4: failure before swap+lock landed -> delete the partial version
166
+ * dir, restore/remove current, tidy now-empty dirs. Prior state untouched. */
167
+ function cleanupFailedVersion(name, versionDir, prevCurrent) {
168
+ rmSync(versionDir, { recursive: true, force: true });
169
+ rmSync(`${pluginCurrentDir(name)}.tmp`, { force: true });
170
+ if (prevCurrent) { try { swapCurrent(name, prevCurrent); } catch { /* best effort */ } }
171
+ else rmSync(pluginCurrentDir(name), { force: true });
172
+ for (const d of [join(pluginDir(name), 'versions'), pluginDir(name)]) {
173
+ try { if (readdirSync(d).length === 0) rmSync(d, { recursive: true, force: true }); } catch { /* absent */ }
174
+ }
175
+ }
176
+
177
+ function validated(name, versionDir) {
178
+ const v = validatePluginDir(versionDir);
179
+ if (!v.ok) {
180
+ const lines = v.problems.filter((p) => p.level === 'error').map((p) => ` - ${p.message}`);
181
+ throw new Error(`plugin "${name}" failed validation:\n${lines.join('\n')}`);
182
+ }
183
+ return v.manifest;
184
+ }
185
+
186
+ function precheck(versionDir, manifest) {
187
+ const bad = dirChecks(versionDir, manifest).filter((x) => !x.ok);
188
+ if (bad.length) {
189
+ throw new Error(`doctor precheck failed: ${bad.map((x) => `${x.id} (${x.detail})`).join('; ')}`);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Install (spec §6.1): ensure cache -> export pinned sha -> validate -> setup ->
195
+ * doctor precheck -> atomic symlink swap -> lock entry. sha omitted -> repo HEAD.
196
+ * On ANY failure: versions/<sha7> removed, prior state untouched, error rethrown.
197
+ */
198
+ export async function installPlugin({ repoUrl, subdir = '', name, sha, marketplace } = {}, { exec = defaultExec } = {}) {
199
+ if (!name) throw new Error('installPlugin: name is required');
200
+ const lock = readPluginsLock();
201
+ if (lock[name]) throw new Error(`plugin "${name}" is already installed`);
202
+ const added = await addPluginRepo(repoUrl, { exec }); // clone-or-fetch the cache
203
+ const pin = sha || added.sha;
204
+ const { versionDir, warnings } = await exportVersion(name, pin, { exec, repoUrl, subdir });
205
+ const prevCurrent = currentTarget(name); // null on first install
206
+ try {
207
+ const manifest = validated(name, versionDir);
208
+ await runSetup(versionDir, manifest, { exec });
209
+ precheck(versionDir, manifest);
210
+ const inventory = buildInstallInventory(versionDir);
211
+ swapCurrent(name, join('versions', pin.slice(0, 7)));
212
+ lock[name] = {
213
+ repo: repoUrl, subdir, pinnedSha: pin,
214
+ version: manifest.version ?? pin.slice(0, 7), // no manifest version -> the SHA is the version (§4.1)
215
+ enabled: true, installedAt: new Date().toISOString(),
216
+ lockfileHash: sha256File(join(versionDir, 'package-lock.json')),
217
+ ...(marketplace ? { marketplace } : {}), // provenance only when it came from one
218
+ };
219
+ writePluginsLock(lock);
220
+ // §6.1(3): workflow template import is the LAST install step (post-swap,
221
+ // post-lock). Own try/catch: an import INFRA failure (DB error) must not
222
+ // reach installPlugin's catch — cleanupFailedVersion would delete the version
223
+ // dir a just-written lock entry points at. The install itself already
224
+ // succeeded; warn and continue (re-import happens on the next update).
225
+ try {
226
+ const wf = await importPluginWorkflows(name, versionDir);
227
+ for (const s of wf.skipped) {
228
+ console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
229
+ }
230
+ } catch (err) {
231
+ console.warn(`[plugin-store] ${name}: workflow import failed (${err?.message || err}) — plugin installed; re-import via update`);
232
+ }
233
+ return { ok: true, inventory, warnings };
234
+ } catch (err) {
235
+ cleanupFailedVersion(name, versionDir, prevCurrent);
236
+ throw err;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Update (spec §6.2): fetch candidate; when it differs, export/setup/precheck/
242
+ * swap/lock. Previous version dir kept; GC keeps the last 2 (rollback =
243
+ * re-point the symlink). The confirm preview is fetchCandidate — callers show
244
+ * it BEFORE invoking this.
245
+ */
246
+ export async function updatePlugin(name, { exec = defaultExec } = {}) {
247
+ const lock = readPluginsLock();
248
+ const entry = lock[name];
249
+ if (!entry) throw new Error(`plugin "${name}" is not installed`);
250
+ if (entry.linked) throw new Error(`plugin "${name}" is dev-linked — update the working dir instead`);
251
+ const cand = await fetchCandidate(name, { exec });
252
+ if (cand.candidateSha === entry.pinnedSha) return { ok: true, updated: false, ...cand };
253
+ const { versionDir, warnings } = await exportVersion(name, cand.candidateSha, { exec });
254
+ const prevCurrent = currentTarget(name);
255
+ try {
256
+ const manifest = validated(name, versionDir);
257
+ await runSetup(versionDir, manifest, { exec });
258
+ precheck(versionDir, manifest);
259
+ const inventory = buildInstallInventory(versionDir);
260
+ const sha7 = cand.candidateSha.slice(0, 7);
261
+ swapCurrent(name, join('versions', sha7));
262
+ lock[name] = {
263
+ ...entry, pinnedSha: cand.candidateSha,
264
+ version: manifest.version ?? sha7,
265
+ updatedAt: new Date().toISOString(),
266
+ lockfileHash: sha256File(join(versionDir, 'package-lock.json')),
267
+ };
268
+ writePluginsLock(lock);
269
+ gcVersions(name, [sha7, entry.pinnedSha.slice(0, 7)]); // keep current + previous
270
+ // §6.2(3): workflow re-import (upsert) after swap + lock update — same
271
+ // isolation rationale as installPlugin: an import failure must not reach
272
+ // this catch (cleanupFailedVersion would tear down the now-live version).
273
+ try {
274
+ const wf = await importPluginWorkflows(name, versionDir);
275
+ for (const s of wf.skipped) {
276
+ console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
277
+ }
278
+ } catch (err) {
279
+ console.warn(`[plugin-store] ${name}: workflow import failed (${err?.message || err}) — plugin updated; re-import via update`);
280
+ }
281
+ return { ok: true, updated: true, inventory, warnings, ...cand };
282
+ } catch (err) {
283
+ cleanupFailedVersion(name, versionDir, prevCurrent);
284
+ throw err;
285
+ }
286
+ }
287
+
288
+ function gcVersions(name, keep7) {
289
+ const dir = join(pluginDir(name), 'versions');
290
+ let entries;
291
+ try { entries = readdirSync(dir); } catch { return; }
292
+ for (const d of entries) if (!keep7.includes(d)) rmSync(join(dir, d), { recursive: true, force: true });
293
+ }
294
+
295
+ /**
296
+ * Uninstall (spec §6.3). Reference guard + imported-workflow removal live in
297
+ * plugin-workflows.mjs: block when a non-plugin workflow still uses this
298
+ * plugin's agents, then remove the imported rows (which itself throws
299
+ * ReferencedError while a project/paused pipeline pins one).
300
+ * data/ (config+secrets+state) is KEPT unless { purge: true } — never silently
301
+ * retain secrets without saying so (the returned note names the leftover path).
302
+ */
303
+ export async function uninstallPlugin(name, { purge = false } = {}) {
304
+ const lock = readPluginsLock();
305
+ const entry = lock[name];
306
+ if (!entry) throw new Error(`plugin "${name}" is not installed`);
307
+ const refs = referencedPluginAgents(name);
308
+ if (refs.length) {
309
+ throw Object.assign(
310
+ new Error(`plugin "${name}" agents are referenced by: ${refs.map((r) => r.name).join(', ')} — remove those references first`),
311
+ // Payload field is `references` — the one name Task 14's 409 handler and
312
+ // Task 18's CLI catch both read (code mirrors deleteAgent, agent-store.mjs:113-118).
313
+ { code: 'REFERENCED', references: refs },
314
+ );
315
+ }
316
+ // Block-with-list model guard (design §9.4): same code/payload contract as
317
+ // the agents guard above. Only ids that would actually stop resolving block —
318
+ // referencedPluginModels applies the shadow/other-plugin/legacy carve-outs.
319
+ const modelRefs = referencedPluginModels(name);
320
+ if (modelRefs.length) {
321
+ const lines = modelRefs.map((r) =>
322
+ `${r.id} (${r.nodes.length + r.steps.length} selection${r.nodes.length + r.steps.length === 1 ? '' : 's'})`);
323
+ throw Object.assign(
324
+ new Error(`plugin "${name}" models are still selected in pipeline configuration: ${lines.join(', ')} — clear those selections (or copy the model to your catalog) first`),
325
+ { code: 'REFERENCED', references: modelRefs },
326
+ );
327
+ }
328
+ await removePluginWorkflows(name); // throws its ReferencedError with the referencing list
329
+ rmSync(pluginCurrentDir(name), { force: true });
330
+ rmSync(join(pluginDir(name), 'versions'), { recursive: true, force: true });
331
+ delete lock[name];
332
+ writePluginsLock(lock);
333
+ // Drop the bare fetch cache only when no other installed plugin shares the repo.
334
+ if (entry.repo && !Object.values(lock).some((e) => e && e.repo === entry.repo)) {
335
+ rmSync(repoCacheDir(entry.repo), { recursive: true, force: true });
336
+ }
337
+ const dataDir = pluginDataDir(name);
338
+ const dataKept = !purge && existsSync(dataDir);
339
+ if (!dataKept) rmSync(pluginDir(name), { recursive: true, force: true });
340
+ return {
341
+ ok: true, dataKept,
342
+ note: dataKept ? `config/secrets/state kept at ${dataDir} — "worca plugin purge ${name}" removes them` : null,
343
+ };
344
+ }
345
+
346
+ /** Leftover data/ dirs from past non-purge uninstalls: dir under pluginsRoot,
347
+ * valid name, NOT in the lock, data/ present. Sorted; [] when root missing.
348
+ * Name filter is plugins-lock's DIR_NAME_RE — single-sourced so it can never
349
+ * disagree with the safeName gate inside pluginDataDir. */
350
+ export function listOrphanPluginData() {
351
+ const root = pluginsRoot();
352
+ if (!existsSync(root)) return [];
353
+ const lock = readPluginsLock();
354
+ return readdirSync(root, { withFileTypes: true })
355
+ .filter((d) => d.isDirectory() && DIR_NAME_RE.test(d.name) && !lock[d.name])
356
+ .filter((d) => existsSync(join(root, d.name, 'data')))
357
+ .map((d) => ({ name: d.name, dataDir: pluginDataDir(d.name) }))
358
+ .sort((a, b) => a.name.localeCompare(b.name));
359
+ }
360
+
361
+ /** Purge an ORPHAN's leftovers (spec §6.3 tail). Installed plugins purge via
362
+ * uninstallPlugin({purge:true}) — refusing here keeps one purge path each. */
363
+ export function purgePluginData(name) {
364
+ if (readPluginsLock()[name]) {
365
+ throw Object.assign(
366
+ new Error(`plugin "${name}" is still installed — uninstall with purge instead`),
367
+ { code: 'INSTALLED' },
368
+ );
369
+ }
370
+ // Invalid name -> worca-cc never created a dir for it (safeName gate), so
371
+ // there are no leftovers; same coercion as safeName keeps the two in lockstep.
372
+ if (!DIR_NAME_RE.test(String(name ?? ''))) throw new Error(`plugin "${name}": nothing to purge`);
373
+ const dir = pluginDir(name); // cannot throw: guarded above
374
+ if (!existsSync(dir)) throw new Error(`plugin "${name}": nothing to purge`);
375
+ rmSync(dir, { recursive: true, force: true });
376
+ return { ok: true, name };
377
+ }
378
+
379
+ /** Enable/disable (spec §6.5): lockfile flag only; no file removal. */
380
+ export function setPluginEnabled(name, enabled) {
381
+ const lock = readPluginsLock();
382
+ if (!lock[name]) throw new Error(`plugin "${name}" is not installed`);
383
+ lock[name] = { ...lock[name], enabled: !!enabled };
384
+ writePluginsLock(lock);
385
+ return { ok: true, name, enabled: !!enabled };
386
+ }
387
+
388
+ /** Lock + current-manifest merge for the Plugins view / CLI list. */
389
+ export function listInstalledPlugins() {
390
+ const lock = readPluginsLock();
391
+ return Object.keys(lock).sort().map((name) => {
392
+ const e = lock[name] || {};
393
+ const cur = pluginCurrentDir(name);
394
+ const manifest = existsSync(cur) ? readManifestAt(cur) : null; // existsSync follows the symlink
395
+ const inv = manifest ? buildInstallInventory(cur) : null;
396
+ return {
397
+ name,
398
+ version: e.version ?? null,
399
+ pinnedSha: e.pinnedSha ?? null,
400
+ repo: e.repo ?? null,
401
+ subdir: e.subdir ?? '',
402
+ marketplace: e.marketplace ?? null, // raw id; name resolution is the API layer's job
403
+ enabled: e.enabled !== false,
404
+ linked: e.linked === true,
405
+ broken: !manifest,
406
+ contributions: inv
407
+ ? { agents: inv.agents.length, taskSources: inv.taskSources.length, chatChannels: inv.chatChannels.length, models: inv.models.length, skills: inv.skills.length, workflows: inv.workflows.length }
408
+ : { agents: 0, taskSources: 0, chatChannels: 0, models: 0, skills: 0, workflows: 0 },
409
+ };
410
+ });
411
+ }
412
+
413
+ /**
414
+ * Doctor (spec §6.4): lock entry, current resolves, manifest + engines still
415
+ * satisfied, modules present, node_modules/.venv when declared, dep-lock hash
416
+ * matches the install stamp, uv on PATH when python. validateConfig ("Test
417
+ * connection") is wired once the shim (Task 11) exists — lazy import, skipped
418
+ * silently until then.
419
+ */
420
+ export async function doctorPlugin(name) {
421
+ const checks = [];
422
+ const c = (id, ok, detail) => checks.push({ id, ok: !!ok, detail });
423
+ const entry = readPluginsLock()[name];
424
+ c('installed', !!entry, entry ? 'lockfile entry present' : `no plugins.lock.json entry for "${name}"`);
425
+ if (!entry) return { ok: false, checks };
426
+ const cur = pluginCurrentDir(name);
427
+ const resolves = existsSync(cur);
428
+ c('current', resolves, resolves ? String(currentTarget(name) ?? cur) : 'current symlink missing or dangling');
429
+ if (!resolves) return { ok: false, checks };
430
+ const manifest = readManifestAt(cur);
431
+ checks.push(...dirChecks(cur, manifest));
432
+ if (manifest?.setup?.node && !entry.linked) {
433
+ const h = sha256File(join(cur, 'package-lock.json'));
434
+ c('lock-hash', !entry.lockfileHash || h === entry.lockfileHash,
435
+ entry.lockfileHash ? 'package-lock.json matches the hash stamped at install' : 'no hash stamped (older install)');
436
+ }
437
+ if (manifest?.setup?.python === 'pyproject') {
438
+ let onPath = true;
439
+ try { await defaultExec('uv', ['--version']); } catch { onPath = false; }
440
+ c('uv', onPath, onPath ? 'uv on PATH' : 'uv not found on PATH (required by setup.python)');
441
+ }
442
+ if (entry.linked) c('linked', true, 'dev-linked plugin — pin/hash checks reduced');
443
+ for (const s of pluginModelSecretStatus(name)) {
444
+ c(`model-secret:${s.key}`, s.set,
445
+ s.set ? `"${s.label}" set` : `model secret "${s.key}" is not set — configure it under Model secrets`);
446
+ }
447
+ if (manifest && (manifest.taskSources || []).length) {
448
+ let shim = null;
449
+ try { shim = await import('./plugin-shim.mjs'); } // Task 11 module — may not exist yet
450
+ catch (err) { if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err; }
451
+ if (shim) {
452
+ for (const s of manifest.taskSources) {
453
+ try {
454
+ const r = await shim.callSource({ plugin: name, sourceId: s.id, op: 'validateConfig' });
455
+ c(`config:${s.id}`, r?.ok !== false, r?.ok === false ? JSON.stringify(r.errors ?? r) : 'validateConfig ok');
456
+ } catch (err) {
457
+ c(`config:${s.id}`, false, String(err?.message || err));
458
+ }
459
+ }
460
+ }
461
+ }
462
+ return { ok: checks.every((x) => x.ok), checks };
463
+ }
464
+
465
+ /** Dev mode (spec §6.6): current -> absolute working dir; lock { linked: true }. */
466
+ export function linkPlugin(name, absDir) {
467
+ const dir = resolve(absDir);
468
+ const v = validatePluginDir(dir);
469
+ if (!v.ok) {
470
+ const lines = v.problems.filter((p) => p.level === 'error').map((p) => p.message);
471
+ throw new Error(`cannot link: ${lines.join('; ')}`);
472
+ }
473
+ if (v.manifest.name !== name) {
474
+ throw new Error(`manifest name "${v.manifest.name}" does not match "${name}"`);
475
+ }
476
+ const lock = readPluginsLock();
477
+ swapCurrent(name, dir); // absolute target; atomic like any other swap
478
+ lock[name] = {
479
+ repo: null, subdir: '', pinnedSha: null,
480
+ version: v.manifest.version ?? 'dev', enabled: true,
481
+ installedAt: new Date().toISOString(), linked: true,
482
+ };
483
+ writePluginsLock(lock);
484
+ return { ok: true, name, dir };
485
+ }
@@ -0,0 +1,179 @@
1
+ // src/core/plugin-workflows.mjs
2
+ // Plugin workflow templates (spec §9.3): import at install/update upserts
3
+ // namespaced rows into the existing `workflows` table (id wfp_<plugin>_<slug>,
4
+ // origin 'plugin:<plugin>' — column added by SCHEMA_V13); uninstall removes
5
+ // plugin-origin rows behind a reference guard. User duplicates (origin NULL)
6
+ // are separate rows and are NEVER touched here.
7
+
8
+ import { readdirSync, readFileSync } from 'node:fs';
9
+ import { join, basename } from 'node:path';
10
+
11
+ import { getDb, prepare, tx } from './db.mjs';
12
+ import { slugify } from './artifacts.mjs';
13
+ import { loadAgentRegistry } from './agent-registry.mjs';
14
+ import { validateWorkflow } from './workflow-validator.mjs';
15
+ import { pluginCurrentDir } from './plugins-lock.mjs';
16
+
17
+ /** Uninstall guard error: plugin workflows are still referenced by project state. */
18
+ export class ReferencedError extends Error {
19
+ constructor(message, references) {
20
+ super(message);
21
+ this.name = 'ReferencedError';
22
+ this.references = references; // [{ workflowId, referencedBy: string[] }]
23
+ }
24
+ }
25
+
26
+ /** Mirrors workflows.mjs normDomain (deliberately duplicated one-liner, same rationale). */
27
+ const DOMAIN_RE = /^[a-z][a-z0-9-]{0,31}$/;
28
+ const normDomain = (raw) => {
29
+ const v = typeof raw === 'string' ? raw.trim() : '';
30
+ return DOMAIN_RE.test(v) ? v : 'general';
31
+ };
32
+
33
+ /**
34
+ * Upsert every <versionDir>/workflows/*.json into the workflows table.
35
+ * id = wfp_<plugin>_<slug(filename)>, origin = 'plugin:<plugin>'. Each template
36
+ * is validated (workflow-validator) against the MERGED registry — importing runs
37
+ * AFTER the symlink swap + lock write, so the plugin's own agents resolve. An
38
+ * invalid/unreadable template is skipped with a warning, never thrown (spec §9.3).
39
+ * No workflows/ dir => { imported: [], skipped: [] } (feature-off no-op).
40
+ * @param {string} name plugin name (kebab-case; id stays SAFE_WORKFLOW_ID-legal)
41
+ * @param {string} versionDir the exported version dir (or current/ — same tree)
42
+ * @returns {Promise<{imported: string[], skipped: Array<{file:string, errors:string[]}>}>}
43
+ */
44
+ export async function importPluginWorkflows(name, versionDir) {
45
+ const origin = `plugin:${name}`;
46
+ const dir = join(versionDir, 'workflows');
47
+ let files = [];
48
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort(); } catch { /* no workflows/ */ }
49
+ const imported = [];
50
+ const skipped = [];
51
+ if (!files.length) return { imported, skipped };
52
+ getDb(); // open + migrate: workflows.origin exists (SCHEMA_V13, Task 10)
53
+ const registry = loadAgentRegistry(); // merged builtin+user+plugin (Task 6)
54
+ const now = new Date().toISOString();
55
+ for (const f of files) {
56
+ let raw;
57
+ try {
58
+ raw = JSON.parse(readFileSync(join(dir, f), 'utf8'));
59
+ } catch (err) {
60
+ skipped.push({ file: f, errors: [`unreadable JSON: ${err.message}`] });
61
+ console.warn(`[plugin-workflows] ${name}/${f}: unreadable JSON — skipped`);
62
+ continue;
63
+ }
64
+ const tpl = {
65
+ steps: Array.isArray(raw?.steps) ? raw.steps : [],
66
+ feedbacks: Array.isArray(raw?.feedbacks) ? raw.feedbacks : [],
67
+ };
68
+ const v = validateWorkflow(tpl, registry);
69
+ if (!v.ok) {
70
+ skipped.push({ file: f, errors: v.errors });
71
+ console.warn(`[plugin-workflows] ${name}/${f}: invalid template — skipped (${v.errors.join('; ')})`);
72
+ continue;
73
+ }
74
+ const id = `wfp_${name}_${slugify(basename(f, '.json'))}`;
75
+ const rowName = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : basename(f, '.json');
76
+ tx(() => {
77
+ prepare(`
78
+ INSERT INTO workflows (id, name, version, domain, steps, feedbacks, origin, created_at, updated_at)
79
+ VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)
80
+ ON CONFLICT(id) DO UPDATE SET
81
+ name = excluded.name, version = 1, domain = excluded.domain,
82
+ steps = excluded.steps, feedbacks = excluded.feedbacks,
83
+ origin = excluded.origin, updated_at = excluded.updated_at
84
+ `).run(id, rowName, normDomain(raw.domain), JSON.stringify(tpl.steps),
85
+ JSON.stringify(tpl.feedbacks), origin, now, now);
86
+ });
87
+ imported.push(id);
88
+ }
89
+ return { imported, skipped };
90
+ }
91
+
92
+ /**
93
+ * Delete this plugin's imported workflow rows (origin = 'plugin:<name>').
94
+ * Guarded: a project_config.active_workflow_id pinning one, or a pipeline whose
95
+ * resume_point.workflowId pins one (resume re-reads the workflow row), throws
96
+ * ReferencedError with the full referencing list — nothing deleted. Scope note:
97
+ * done/stopped rows never trip this — the orchestrator nulls resumePoint on both
98
+ * paths (orchestrator.mjs:527/:557 and :702/:720) and writeState persists the
99
+ * NULL — while paused/interrupted rows legitimately do. ERRORED rows may retain
100
+ * a resume_point (the error path does not clear it) and also block: intended,
101
+ * since an errored run can still be recovered via the recoverable-error gate.
102
+ * @param {string} name
103
+ * @returns {Promise<{removed: string[]}>}
104
+ */
105
+ export async function removePluginWorkflows(name) {
106
+ const origin = `plugin:${name}`;
107
+ getDb();
108
+ const rows = prepare('SELECT id FROM workflows WHERE origin = ?').all(origin);
109
+ if (!rows.length) return { removed: [] };
110
+ const ids = new Set(rows.map((r) => r.id));
111
+
112
+ const references = [];
113
+ for (const cfg of prepare(
114
+ 'SELECT project_key, active_workflow_id FROM project_config WHERE active_workflow_id IS NOT NULL',
115
+ ).all()) {
116
+ if (ids.has(cfg.active_workflow_id)) {
117
+ references.push({ workflowId: cfg.active_workflow_id, referencedBy: [`project_config ${cfg.project_key}`] });
118
+ }
119
+ }
120
+ // archived_at IS NULL for the same reason guardrail-store.mjs filters it: an
121
+ // archived row keeps its resume_point but can never be resumed or seen again,
122
+ // so a pin held there would strand `worca plugin remove` permanently.
123
+ for (const p of prepare(
124
+ 'SELECT id, resume_point FROM pipelines WHERE resume_point IS NOT NULL AND archived_at IS NULL',
125
+ ).all()) {
126
+ try {
127
+ const rp = JSON.parse(p.resume_point);
128
+ if (rp && ids.has(rp.workflowId)) references.push({ workflowId: rp.workflowId, referencedBy: [`pipeline ${p.id}`] });
129
+ } catch { /* corrupt resume point: not a reference */ }
130
+ }
131
+ if (references.length) {
132
+ const lines = references.map((r) => ` - ${r.workflowId} (referenced by ${r.referencedBy.join(', ')})`);
133
+ throw new ReferencedError(
134
+ `cannot remove workflows of plugin "${name}" — still referenced:\n${lines.join('\n')}`,
135
+ references,
136
+ );
137
+ }
138
+ tx(() => { prepare('DELETE FROM workflows WHERE origin = ?').run(origin); });
139
+ return { removed: rows.map((r) => r.id) };
140
+ }
141
+
142
+ /**
143
+ * Uninstall guard input (spec §6.3): NON-plugin workflows (user rows and other
144
+ * plugins' rows — anything not origin 'plugin:<name>') whose steps JSON references
145
+ * one of THIS plugin's agent keys. Keys come from current/agents/*.meta.json.
146
+ * Synchronous, never throws: no current/agents (already-broken install) => [].
147
+ * @param {string} name
148
+ * @returns {Array<{workflowId: string, name: string, keys: string[]}>}
149
+ */
150
+ export function referencedPluginAgents(name) {
151
+ const keys = new Set();
152
+ try {
153
+ const dir = join(pluginCurrentDir(name), 'agents');
154
+ for (const f of readdirSync(dir)) {
155
+ if (!f.endsWith('.meta.json')) continue;
156
+ try {
157
+ const k = JSON.parse(readFileSync(join(dir, f), 'utf8'))?.key;
158
+ if (typeof k === 'string' && k.trim()) keys.add(k.trim());
159
+ } catch { /* malformed sidecar: nothing to guard */ }
160
+ }
161
+ } catch { return []; }
162
+ if (!keys.size) return [];
163
+ getDb();
164
+ const out = [];
165
+ for (const row of prepare(
166
+ 'SELECT id, name, steps FROM workflows WHERE origin IS NULL OR origin != ?',
167
+ ).all(`plugin:${name}`)) {
168
+ let steps;
169
+ try { steps = JSON.parse(row.steps); } catch { continue; }
170
+ const found = new Set();
171
+ for (const group of Array.isArray(steps) ? steps : []) {
172
+ for (const node of Array.isArray(group) ? group : []) {
173
+ if (node && keys.has(node.key)) found.add(node.key);
174
+ }
175
+ }
176
+ if (found.size) out.push({ workflowId: row.id, name: row.name, keys: [...found].sort() });
177
+ }
178
+ return out;
179
+ }