@worca/app 1.0.0 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -11,24 +11,30 @@ import { promisify } from 'node:util';
11
11
  import { createHash } from 'node:crypto';
12
12
  import {
13
13
  existsSync, readdirSync, readFileSync, readlinkSync,
14
- mkdirSync, rmSync, symlinkSync, renameSync,
14
+ mkdirSync, rmSync, symlinkSync, renameSync, unlinkSync,
15
15
  } from 'node:fs';
16
- import { join, resolve } from 'node:path';
16
+ import { join, resolve, isAbsolute, sep } from 'node:path';
17
17
  import { WORCA_PLUGIN_APIS } from './plugin-api.mjs';
18
- import { normalizeManifest, validatePluginDir, apiSatisfies } from './plugin-manifest.mjs';
18
+ import { normalizeManifest, validatePluginDir, apiSatisfies, dataContractIssues, apiMismatch } from './plugin-manifest.mjs';
19
19
  import {
20
20
  pluginsRoot, pluginDir, pluginCurrentDir, pluginDataDir, readPluginsLock, writePluginsLock,
21
21
  DIR_NAME_RE,
22
22
  } from './plugins-lock.mjs';
23
23
  import { addPluginRepo, fetchCandidate, exportVersion, repoCacheDir } from './plugin-repo.mjs';
24
- import { importPluginWorkflows, removePluginWorkflows, referencedPluginAgents } from './plugin-workflows.mjs';
24
+ import {
25
+ importPluginWorkflows, readPluginWorkflows, removePluginWorkflows, referencedPluginAgents,
26
+ } from './plugin-workflows.mjs';
27
+ import { loadAgentRegistry } from './agent-registry.mjs';
25
28
  import { pluginModelSecretStatus } from './plugin-models.mjs';
26
29
  import { referencedPluginModels } from './config.mjs';
30
+ import { clearBindingsForPlugin } from './source-bindings.mjs';
27
31
 
28
32
  const execFileP = promisify(execFile);
29
33
  const defaultExec = (cmd, args, opts = {}) =>
30
34
  execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024, ...opts });
31
35
 
36
+ const IS_WINDOWS = process.platform === 'win32';
37
+
32
38
  function readManifestAt(dir) {
33
39
  try {
34
40
  const res = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
@@ -42,6 +48,13 @@ function sha256File(file) {
42
48
  try { return createHash('sha256').update(readFileSync(file)).digest('hex'); } catch { return null; }
43
49
  }
44
50
 
51
+ /** True when the relative path `rel` still resolves INSIDE `dir`. */
52
+ function insideDir(dir, rel) {
53
+ if (isAbsolute(rel) || /\\/.test(rel)) return false;
54
+ const root = resolve(dir);
55
+ return resolve(root, rel).startsWith(root + sep);
56
+ }
57
+
45
58
  /** Private copy of workflows.mjs:66-77 parseFrontmatterTools (module-private there). */
46
59
  function frontmatterTools(text) {
47
60
  const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
@@ -64,8 +77,19 @@ export function buildInstallInventory(versionDir) {
64
77
  if (existsSync(aDir)) {
65
78
  for (const f of readdirSync(aDir).filter((x) => x.endsWith('.meta.json')).sort()) {
66
79
  const key = f.slice(0, -'.meta.json'.length);
80
+ // Consent must describe the bytes the RUNTIME will read: agent-registry
81
+ // stamps agentPath = join(<layer>/agents, meta.agentFile), so reading
82
+ // `${key}.md` here showed a DECOY for any sidecar naming another file
83
+ // (C-1). Containment is re-checked because this also runs on an
84
+ // unvalidated dir (the pre-install preview); an escaping agentFile falls
85
+ // back to the sibling and validatePluginDir refuses the install anyway.
86
+ let mdFile = `${key}.md`;
87
+ try {
88
+ const af = JSON.parse(readFileSync(join(aDir, f), 'utf8'))?.agentFile;
89
+ if (typeof af === 'string' && af.trim() && insideDir(aDir, af.trim())) mdFile = af.trim();
90
+ } catch { /* unreadable sidecar: fall back to the sibling */ }
67
91
  let tools = [];
68
- try { tools = frontmatterTools(readFileSync(join(aDir, `${key}.md`), 'utf8')); } catch { /* md missing */ }
92
+ try { tools = frontmatterTools(readFileSync(join(aDir, mdFile), 'utf8')); } catch { /* md missing */ }
69
93
  agents.push({ key, tools });
70
94
  }
71
95
  }
@@ -113,6 +137,45 @@ export function buildInstallInventory(versionDir) {
113
137
  return { agents, taskSources, chatChannels, models, modelSecrets, skills: skills.sort(), workflows, depCount, setupCommands };
114
138
  }
115
139
 
140
+ /**
141
+ * Contributions a plugin SHIPS but worca IGNORED, as `[{ file, reason }]` sorted
142
+ * by file (`agents/<sidecar>.meta.json`, `workflows/<template>.json`). The two
143
+ * drop sites — the agent registry and the workflow importer — reach only
144
+ * console.warn on their own, so every reporting surface (install receipt,
145
+ * Plugins card, doctor) was free to claim a contribution that does not exist.
146
+ *
147
+ * Generic by construction: nothing here is keyed on an agent key or a plugin
148
+ * name — the drop reasons are authored at the drop sites and carried verbatim.
149
+ *
150
+ * @param {string} name
151
+ * @param {string} dir the plugin's current/ (or version) dir
152
+ * @param {{registry?: object, drops?: Array<{origin:string,file:string,reason:string}>,
153
+ * workflowSkips?: Array<{file:string, errors:string[]}>}} [opts]
154
+ * registry/drops: reuse ONE registry load across plugins; workflowSkips:
155
+ * the importer's OWN result, when the caller just ran it.
156
+ * @returns {Array<{file: string, reason: string}>}
157
+ */
158
+ export function ignoredContributions(name, dir, opts = {}) {
159
+ const out = [];
160
+ let drops = opts.drops;
161
+ let registry = opts.registry;
162
+ if (!drops) {
163
+ drops = [];
164
+ try { registry = loadAgentRegistry(undefined, { onDrop: (d) => drops.push(d) }); }
165
+ catch { /* no resolvable home: report what the files alone can say */ }
166
+ }
167
+ for (const d of drops) {
168
+ if (d.origin === `plugin:${name}`) out.push({ file: `agents/${d.file}`, reason: d.reason });
169
+ }
170
+ const skips = opts.workflowSkips
171
+ ?? (() => {
172
+ try { return readPluginWorkflows(name, dir, { registry, quiet: true }).skipped; }
173
+ catch { return []; }
174
+ })();
175
+ for (const s of skips) out.push({ file: `workflows/${s.file}`, reason: `invalid template (${s.errors.join('; ')})` });
176
+ return out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
177
+ }
178
+
116
179
  /** Declared setup FACTS only (spec §4.1): setup.node -> npm ci (lockfile
117
180
  * required, scripts ignored); setup.python 'pyproject' -> uv sync. */
118
181
  export async function runSetup(versionDir, manifest, { exec = defaultExec } = {}) {
@@ -121,11 +184,17 @@ export async function runSetup(versionDir, manifest, { exec = defaultExec } = {}
121
184
  if (!existsSync(join(versionDir, 'package-lock.json'))) {
122
185
  throw new Error(`setup.node declared but ${join(versionDir, 'package-lock.json')} is missing (npm ci requires a lockfile)`);
123
186
  }
124
- await exec('npm', ['ci', '--prefix', versionDir, '--ignore-scripts', '--omit=dev']);
187
+ // cwd instead of --prefix so no native path is passed as an argument: on
188
+ // Windows npm is `npm.cmd`, which needs a shell (bare `npm` -> ENOENT, and
189
+ // execFile of a `.cmd` -> EINVAL on modern Node), and shell:true does NOT
190
+ // quote args — a `--prefix C:\…` path would be mangled. With the dir as cwd
191
+ // the only args left are flag literals, safe through the shell.
192
+ await exec('npm', ['ci', '--ignore-scripts', '--omit=dev'],
193
+ { cwd: versionDir, shell: IS_WINDOWS });
125
194
  commands.push('npm ci');
126
195
  }
127
196
  if (manifest?.setup?.python === 'pyproject') {
128
- await exec('uv', ['sync', '--project', versionDir]);
197
+ await exec('uv', ['sync'], { cwd: versionDir });
129
198
  commands.push('uv sync');
130
199
  }
131
200
  return { commands };
@@ -152,12 +221,35 @@ function currentTarget(name) {
152
221
  try { return readlinkSync(pluginCurrentDir(name)); } catch { return null; }
153
222
  }
154
223
 
155
- /** Atomic swap (§6.1 step 3): write current.tmp symlink, rename(2) over current. */
224
+ // rmSync is the wrong primitive for a POSIX path that may be a symlink: it
225
+ // stats THROUGH the link, so a link to a directory throws ERR_FS_EISDIR and a
226
+ // DANGLING link reads as "already gone" and survives force:true (Node 23).
227
+ // unlink removes the link itself; the fallback covers a real directory
228
+ // (test fixtures build `current` as a plain dir).
229
+ const rmLink = (path) => {
230
+ try { unlinkSync(path); return; } catch (err) {
231
+ if (err.code === 'ENOENT') return;
232
+ }
233
+ rmSync(path, { recursive: true, force: true });
234
+ };
235
+
236
+ /** Atomic swap (§6.1 step 3): write current.tmp symlink, rename(2) over current.
237
+ * Windows can't create a plain symlink without elevated privileges/Developer
238
+ * Mode, and can't rename() a directory reparse point over an existing one
239
+ * (EPERM). It CAN create a directory JUNCTION unprivileged, so there we
240
+ * remove-then-create a junction in place. A junction needs an ABSOLUTE target
241
+ * (POSIX keeps the relative "versions/<sha7>" so the link survives a moved
242
+ * plugin dir); rmSync on a junction drops only the link, never the target. */
156
243
  function swapCurrent(name, target) {
157
244
  const current = pluginCurrentDir(name);
158
- const tmp = `${current}.tmp`;
159
- rmSync(tmp, { force: true });
160
245
  mkdirSync(pluginDir(name), { recursive: true });
246
+ if (IS_WINDOWS) {
247
+ rmSync(current, { force: true });
248
+ symlinkSync(resolve(pluginDir(name), target), current, 'junction');
249
+ return;
250
+ }
251
+ const tmp = `${current}.tmp`;
252
+ rmLink(tmp);
161
253
  symlinkSync(target, tmp);
162
254
  renameSync(tmp, current);
163
255
  }
@@ -166,19 +258,27 @@ function swapCurrent(name, target) {
166
258
  * dir, restore/remove current, tidy now-empty dirs. Prior state untouched. */
167
259
  function cleanupFailedVersion(name, versionDir, prevCurrent) {
168
260
  rmSync(versionDir, { recursive: true, force: true });
169
- rmSync(`${pluginCurrentDir(name)}.tmp`, { force: true });
261
+ rmLink(`${pluginCurrentDir(name)}.tmp`);
170
262
  if (prevCurrent) { try { swapCurrent(name, prevCurrent); } catch { /* best effort */ } }
171
- else rmSync(pluginCurrentDir(name), { force: true });
263
+ else rmLink(pluginCurrentDir(name));
172
264
  for (const d of [join(pluginDir(name), 'versions'), pluginDir(name)]) {
173
265
  try { if (readdirSync(d).length === 0) rmSync(d, { recursive: true, force: true }); } catch { /* absent */ }
174
266
  }
175
267
  }
176
268
 
269
+ /** Problem lines for a refusal message. `level` is a REPORTING axis, not a
270
+ * cause axis — a plugin whose declared API makes its data problems WARNINGS
271
+ * must never be refused with an empty body, so fall back to every problem when
272
+ * no error line exists. */
273
+ function refusalLines(problems, prefix = ' - ') {
274
+ const errorsOnly = problems.filter((p) => p.level === 'error');
275
+ return (errorsOnly.length ? errorsOnly : problems).map((p) => `${prefix}${p.message}`);
276
+ }
277
+
177
278
  function validated(name, versionDir) {
178
279
  const v = validatePluginDir(versionDir);
179
280
  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')}`);
281
+ throw new Error(`plugin "${name}" failed validation:\n${refusalLines(v.problems).join('\n')}`);
182
282
  }
183
283
  return v.manifest;
184
284
  }
@@ -222,15 +322,20 @@ export async function installPlugin({ repoUrl, subdir = '', name, sha, marketpla
222
322
  // reach installPlugin's catch — cleanupFailedVersion would delete the version
223
323
  // dir a just-written lock entry points at. The install itself already
224
324
  // succeeded; warn and continue (re-import happens on the next update).
325
+ let workflowSkips = null;
225
326
  try {
226
327
  const wf = await importPluginWorkflows(name, versionDir);
328
+ workflowSkips = wf.skipped;
227
329
  for (const s of wf.skipped) {
228
330
  console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
229
331
  }
230
332
  } catch (err) {
231
333
  console.warn(`[plugin-store] ${name}: workflow import failed (${err?.message || err}) — plugin installed; re-import via update`);
232
334
  }
233
- return { ok: true, inventory, warnings };
335
+ // The receipt says what actually landed: `inventory` is what the plugin
336
+ // SHIPS, `ignored` is the subset worca refused to load (spec §9.3 drops).
337
+ const ignored = ignoredContributions(name, versionDir, workflowSkips ? { workflowSkips } : {});
338
+ return { ok: true, inventory, warnings, ignored };
234
339
  } catch (err) {
235
340
  cleanupFailedVersion(name, versionDir, prevCurrent);
236
341
  throw err;
@@ -270,15 +375,18 @@ export async function updatePlugin(name, { exec = defaultExec } = {}) {
270
375
  // §6.2(3): workflow re-import (upsert) after swap + lock update — same
271
376
  // isolation rationale as installPlugin: an import failure must not reach
272
377
  // this catch (cleanupFailedVersion would tear down the now-live version).
378
+ let workflowSkips = null;
273
379
  try {
274
380
  const wf = await importPluginWorkflows(name, versionDir);
381
+ workflowSkips = wf.skipped;
275
382
  for (const s of wf.skipped) {
276
383
  console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
277
384
  }
278
385
  } catch (err) {
279
386
  console.warn(`[plugin-store] ${name}: workflow import failed (${err?.message || err}) — plugin updated; re-import via update`);
280
387
  }
281
- return { ok: true, updated: true, inventory, warnings, ...cand };
388
+ const ignored = ignoredContributions(name, versionDir, workflowSkips ? { workflowSkips } : {});
389
+ return { ok: true, updated: true, inventory, warnings, ignored, ...cand };
282
390
  } catch (err) {
283
391
  cleanupFailedVersion(name, versionDir, prevCurrent);
284
392
  throw err;
@@ -326,7 +434,13 @@ export async function uninstallPlugin(name, { purge = false } = {}) {
326
434
  );
327
435
  }
328
436
  await removePluginWorkflows(name); // throws its ReferencedError with the referencing list
329
- rmSync(pluginCurrentDir(name), { force: true });
437
+ // Bindings live in the DB, not in the plugin's data dir, so they would
438
+ // outlive the uninstall: a stale row silently rebinds a project the moment
439
+ // the plugin is reinstalled with a same-named profile — possibly pointing at
440
+ // a different tracker. Cleared HERE (not only in the server's DELETE route)
441
+ // so the CLI's `worca plugin remove` drops them too.
442
+ clearBindingsForPlugin(name);
443
+ rmLink(pluginCurrentDir(name)); // symlink in real installs, a plain dir in test fixtures
330
444
  rmSync(join(pluginDir(name), 'versions'), { recursive: true, force: true });
331
445
  delete lock[name];
332
446
  writePluginsLock(lock);
@@ -388,11 +502,23 @@ export function setPluginEnabled(name, enabled) {
388
502
  /** Lock + current-manifest merge for the Plugins view / CLI list. */
389
503
  export function listInstalledPlugins() {
390
504
  const lock = readPluginsLock();
505
+ // ONE registry load for the whole list: it is the only place that knows which
506
+ // sidecars were dropped, and re-scanning per plugin would be N full scans.
507
+ const drops = [];
508
+ let registry = null;
509
+ try { registry = loadAgentRegistry(undefined, { onDrop: (d) => drops.push(d) }); }
510
+ catch { /* no resolvable home: fall back to the file-derived view */ }
391
511
  return Object.keys(lock).sort().map((name) => {
392
512
  const e = lock[name] || {};
393
513
  const cur = pluginCurrentDir(name);
394
514
  const manifest = existsSync(cur) ? readManifestAt(cur) : null; // existsSync follows the symlink
395
515
  const inv = manifest ? buildInstallInventory(cur) : null;
516
+ // Read from the raw dir, not the manifest: the mismatch is about the DATA
517
+ // the plugin ships, not about whether its manifest normalized. A plugin
518
+ // whose manifest did not normalize is `broken`, which outranks this.
519
+ const mismatch = manifest
520
+ ? apiMismatch(manifest.engines?.worcaApi ?? '', dataContractIssues(cur))
521
+ : null;
396
522
  return {
397
523
  name,
398
524
  version: e.version ?? null,
@@ -403,9 +529,15 @@ export function listInstalledPlugins() {
403
529
  enabled: e.enabled !== false,
404
530
  linked: e.linked === true,
405
531
  broken: !manifest,
532
+ apiMismatch: mismatch,
406
533
  contributions: inv
407
534
  ? { 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
535
  : { agents: 0, taskSources: 0, chatChannels: 0, models: 0, skills: 0, workflows: 0 },
536
+ // `contributions` counts what the plugin SHIPS (files on disk); `ignored`
537
+ // names the ones worca refused to load, so the card can stop claiming them.
538
+ // A disabled plugin contributes nothing BY CHOICE — its agents are not in the
539
+ // registry, so its templates would misreport V4.
540
+ ignored: manifest && e.enabled !== false ? ignoredContributions(name, cur, { registry, drops }) : [],
409
541
  };
410
542
  });
411
543
  }
@@ -429,6 +561,26 @@ export async function doctorPlugin(name) {
429
561
  if (!resolves) return { ok: false, checks };
430
562
  const manifest = readManifestAt(cur);
431
563
  checks.push(...dirChecks(cur, manifest));
564
+ // The DATA contract check is ADVISORY, so it lives HERE and never in
565
+ // dirChecks: dirChecks also feeds the install precheck, which THROWS on any
566
+ // failing check, and an outdated data contract must never block an install —
567
+ // the plugin's connector/chat channel still works untouched, worca simply
568
+ // ignores the agents and pipeline templates it ships (spec §9, P7.2).
569
+ if (manifest) {
570
+ const mismatch = apiMismatch(manifest.engines?.worcaApi ?? '', dataContractIssues(cur));
571
+ checks.push({ id: 'agents-api', ok: !mismatch,
572
+ detail: mismatch ? mismatch.message : 'agents and pipeline templates are plugin API 3 (meta v2 + graph templates)' });
573
+ }
574
+ // Same gate as the card: a disabled plugin contributes nothing BY CHOICE — its
575
+ // agents are not in the registry, so its templates would misreport V4 and this
576
+ // check would fail a plugin that is merely switched off.
577
+ if (manifest && entry.enabled !== false) {
578
+ const ignored = ignoredContributions(name, cur);
579
+ c('contributions', ignored.length === 0,
580
+ ignored.length
581
+ ? `${ignored.length} ignored: ${ignored.map((i) => `${i.file} — ${i.reason}`).join('; ')}`
582
+ : 'every shipped contribution loaded');
583
+ }
432
584
  if (manifest?.setup?.node && !entry.linked) {
433
585
  const h = sha256File(join(cur, 'package-lock.json'));
434
586
  c('lock-hash', !entry.lockfileHash || h === entry.lockfileHash,
@@ -449,12 +601,28 @@ export async function doctorPlugin(name) {
449
601
  try { shim = await import('./plugin-shim.mjs'); } // Task 11 module — may not exist yet
450
602
  catch (err) { if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err; }
451
603
  if (shim) {
604
+ const { listProfileIds } = await import('./plugin-config.mjs');
452
605
  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));
606
+ // A multi-profile source stores nothing in the implicit default bucket;
607
+ // validating that would flag a fully configured plugin as broken. Check
608
+ // each roster profile instead. An EMPTY roster is not healthy-by-vacuity
609
+ // either: every run of the source is rejected ("profile is required")
610
+ // until one exists, and validating the default bucket instead can even
611
+ // report green off legacy config migrated under 'default' after an
612
+ // upgrade flipped the source to multiProfile — a plugin nothing can run.
613
+ const profiles = s.multiProfile ? listProfileIds(name) : [];
614
+ if (s.multiProfile && !profiles.length) {
615
+ c(`config:${s.id}`, false, 'no profiles yet — create one in Plugins settings (every run is rejected until then)');
616
+ continue;
617
+ }
618
+ for (const profile of profiles.length ? profiles : [undefined]) {
619
+ const id = profile ? `config:${s.id}@${profile}` : `config:${s.id}`;
620
+ try {
621
+ const r = await shim.callSource({ plugin: name, sourceId: s.id, op: 'validateConfig', profile });
622
+ c(id, r?.ok !== false, r?.ok === false ? JSON.stringify(r.errors ?? r) : 'validateConfig ok');
623
+ } catch (err) {
624
+ c(id, false, String(err?.message || err));
625
+ }
458
626
  }
459
627
  }
460
628
  }
@@ -462,14 +630,20 @@ export async function doctorPlugin(name) {
462
630
  return { ok: checks.every((x) => x.ok), checks };
463
631
  }
464
632
 
465
- /** Dev mode (spec §6.6): current -> absolute working dir; lock { linked: true }. */
466
- export function linkPlugin(name, absDir) {
633
+ /**
634
+ * Dev mode (spec §6.6): current -> absolute working dir; lock { linked: true }.
635
+ * ASYNC because linking, like installing, ends with the workflow-template
636
+ * import: `plugin link` is the documented author loop (SKILL.md) and
637
+ * `updatePlugin` refuses a linked plugin, so without this a linked plugin's
638
+ * templates could reach the store by NO path at all. A linked dir is
639
+ * live-edited, so the refresh path afterwards is `worca plugin reimport <name>`.
640
+ * @returns {Promise<{ok:true, name:string, dir:string,
641
+ * workflows:{imported:string[], skipped:Array<{file:string, errors:string[]}>}}>}
642
+ */
643
+ export async function linkPlugin(name, absDir) {
467
644
  const dir = resolve(absDir);
468
645
  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
- }
646
+ if (!v.ok) throw new Error(`cannot link: ${refusalLines(v.problems, '').join('; ')}`);
473
647
  if (v.manifest.name !== name) {
474
648
  throw new Error(`manifest name "${v.manifest.name}" does not match "${name}"`);
475
649
  }
@@ -481,5 +655,38 @@ export function linkPlugin(name, absDir) {
481
655
  installedAt: new Date().toISOString(), linked: true,
482
656
  };
483
657
  writePluginsLock(lock);
484
- return { ok: true, name, dir };
658
+ // Same isolation as installPlugin: an import failure must not undo a link
659
+ // that already landed — the lock and the symlink are correct either way.
660
+ let workflows = { imported: [], skipped: [] };
661
+ try {
662
+ workflows = await importPluginWorkflows(name, dir);
663
+ for (const s of workflows.skipped) {
664
+ console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
665
+ }
666
+ } catch (err) {
667
+ console.warn(`[plugin-store] ${name}: workflow import failed (${err?.message || err}) — plugin linked; re-import with \`worca plugin reimport ${name}\``);
668
+ }
669
+ return { ok: true, name, dir, workflows };
670
+ }
671
+
672
+ /**
673
+ * Re-run the workflow-template import for an INSTALLED OR LINKED plugin
674
+ * (spec §6.6). A linked dir is live-edited and `updatePlugin` refuses linked
675
+ * plugins, so this is the only refresh path a plugin author has; for an
676
+ * installed plugin it is the cheap way to retry an import that failed.
677
+ * Upsert-only: a template file the author DELETED keeps its row until the
678
+ * plugin is removed.
679
+ * @returns {Promise<{ok:true, name:string,
680
+ * workflows:{imported:string[], skipped:Array<{file:string, errors:string[]}>},
681
+ * ignored:Array<{file:string, reason:string}>}>}
682
+ */
683
+ export async function reimportPlugin(name) {
684
+ if (!readPluginsLock()[name]) throw new Error(`plugin "${name}" is not installed`);
685
+ const cur = pluginCurrentDir(name);
686
+ if (!existsSync(cur)) throw new Error(`plugin "${name}": current symlink missing or dangling`);
687
+ const workflows = await importPluginWorkflows(name, cur);
688
+ for (const s of workflows.skipped) {
689
+ console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
690
+ }
691
+ return { ok: true, name, workflows, ignored: ignoredContributions(name, cur, { workflowSkips: workflows.skipped }) };
485
692
  }
@@ -11,7 +11,9 @@ import { join, basename } from 'node:path';
11
11
  import { getDb, prepare, tx } from './db.mjs';
12
12
  import { slugify } from './artifacts.mjs';
13
13
  import { loadAgentRegistry } from './agent-registry.mjs';
14
- import { validateWorkflow } from './workflow-validator.mjs';
14
+ import { registryPortsFn } from './graph/registry-ports.mjs';
15
+ import { validateGraph } from '../shared/graph/validate.mjs';
16
+ import { NOT_GRAPH_V2 } from './plugin-manifest.mjs';
15
17
  import { pluginCurrentDir } from './plugins-lock.mjs';
16
18
 
17
19
  /** Uninstall guard error: plugin workflows are still referenced by project state. */
@@ -31,60 +33,102 @@ const normDomain = (raw) => {
31
33
  };
32
34
 
33
35
  /**
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[]}>}>}
36
+ * READ + VALIDATE every <versionDir>/workflows/*.json WITHOUT touching the DB.
37
+ * Split out of importPluginWorkflows so the Plugins card, the doctor and the
38
+ * install receipt can name a skipped template with the SAME reason the importer
39
+ * logged a second, re-derived reader would be free to disagree with it.
40
+ * @param {string} name
41
+ * @param {string} versionDir
42
+ * @param {{registry?: object, quiet?: boolean}} [opts] registry: reuse ONE
43
+ * loadAgentRegistry() across plugins; quiet: no console.warn (read-only
44
+ * callers must not re-log what the importer already logged).
45
+ * @returns {{ready: Array<object>, skipped: Array<{file:string, errors:string[]}>}}
43
46
  */
44
- export async function importPluginWorkflows(name, versionDir) {
47
+ export function readPluginWorkflows(name, versionDir, { registry = null, quiet = false } = {}) {
45
48
  const origin = `plugin:${name}`;
46
49
  const dir = join(versionDir, 'workflows');
47
50
  let files = [];
48
51
  try { files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort(); } catch { /* no workflows/ */ }
49
- const imported = [];
52
+ const ready = [];
50
53
  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();
54
+ if (!files.length) return { ready, skipped };
55
+ // ONE registry load for the whole read, and the SHARED port synthesis over
56
+ // it: agent meta ports plus the universal `await` gate and the flow-card
57
+ // table. The templates are not in the DB yet, so resolveGraph is unavailable —
58
+ // registryPortsFn exists for exactly this caller and the server's save route.
59
+ const portsFn = registryPortsFn(registry || loadAgentRegistry());
60
+ const warn = (msg) => { if (!quiet) console.warn(msg); };
61
+ const skip = (f, errors) => {
62
+ skipped.push({ file: f, errors });
63
+ warn(`[plugin-workflows] ${name}/${f}: invalid template — skipped (${errors.join('; ')})`);
64
+ };
55
65
  for (const f of files) {
56
66
  let raw;
57
67
  try {
58
68
  raw = JSON.parse(readFileSync(join(dir, f), 'utf8'));
59
69
  } catch (err) {
60
70
  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('; ')})`);
71
+ warn(`[plugin-workflows] ${name}/${f}: unreadable JSON — skipped`);
72
72
  continue;
73
73
  }
74
+ // A v1 `steps` template is called out by name rather than left to V1's
75
+ // generic "version must be 2": the plugin author needs to know their
76
+ // template needs porting, not that a field is off by one. Same clause the
77
+ // validator prints, imported so the two can never drift.
78
+ if (Number(raw?.version) !== 2) { skip(f, [NOT_GRAPH_V2]); continue; }
74
79
  const id = `wfp_${name}_${slugify(basename(f, '.json'))}`;
75
80
  const rowName = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : basename(f, '.json');
81
+ const domain = normDomain(raw.domain);
82
+ const nodes = Array.isArray(raw.nodes) ? raw.nodes : [];
83
+ const wires = Array.isArray(raw.wires) ? raw.wires : [];
84
+ // The FULL kind set {agent, task, and, or, combine, end} and every rule
85
+ // V1-V21 — including V20/V21's mandatory Task + End cards and V7's
86
+ // one-wire-per-input — apply to a plugin template exactly as they do to a
87
+ // hand-composed one. Warnings never block an import (they do not block a
88
+ // save either); they are logged so a template that will misbehave at run
89
+ // time says so at install.
90
+ const { errors, warnings } = validateGraph({ id, name: rowName, version: 2, domain, nodes, wires }, portsFn);
91
+ if (errors.length) { skip(f, errors.map((e) => `${e.code}: ${e.message}`)); continue; }
92
+ for (const w of warnings) warn(`[plugin-workflows] ${name}/${f}: ${w.code}: ${w.message}`);
93
+ // graph holds {nodes, wires, canvas?} ONLY — id/name/domain/origin are row
94
+ // columns, so a rename can never drift between the two.
95
+ const graph = { nodes, wires };
96
+ if (raw.canvas && typeof raw.canvas === 'object') graph.canvas = raw.canvas; // accepted, engine-ignored
97
+ ready.push({ file: f, id, rowName, domain, graph, origin });
98
+ }
99
+ return { ready, skipped };
100
+ }
101
+
102
+ /**
103
+ * Upsert every <versionDir>/workflows/*.json into the workflows table.
104
+ * id = wfp_<plugin>_<slug(filename)>, origin = 'plugin:<plugin>'. Each template
105
+ * is validated by the SHARED `validateGraph` over `registryPortsFn(loadAgentRegistry())`
106
+ * — importing runs AFTER the symlink swap + lock write, so the plugin's own agents resolve. An
107
+ * invalid/unreadable template is skipped with a warning, never thrown (spec §9.3).
108
+ * No workflows/ dir => { imported: [], skipped: [] } (feature-off no-op).
109
+ * @param {string} name plugin name (kebab-case; id stays SAFE_WORKFLOW_ID-legal)
110
+ * @param {string} versionDir the exported version dir (or current/ — same tree)
111
+ * @returns {Promise<{imported: string[], skipped: Array<{file:string, errors:string[]}>}>}
112
+ */
113
+ export async function importPluginWorkflows(name, versionDir) {
114
+ const { ready, skipped } = readPluginWorkflows(name, versionDir);
115
+ const imported = [];
116
+ if (!ready.length && !skipped.length) return { imported, skipped };
117
+ getDb(); // open + migrate: workflows.origin/graph/archived_at exist (V13/V23)
118
+ const now = new Date().toISOString();
119
+ for (const r of ready) {
76
120
  tx(() => {
77
121
  prepare(`
78
- INSERT INTO workflows (id, name, version, domain, steps, feedbacks, origin, created_at, updated_at)
79
- VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)
122
+ INSERT INTO workflows (id, name, version, domain, graph, steps, feedbacks, origin, created_at, updated_at, archived_at)
123
+ VALUES (?, ?, 2, ?, ?, '[]', '[]', ?, ?, ?, NULL)
80
124
  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);
125
+ name = excluded.name, version = 2, domain = excluded.domain,
126
+ graph = excluded.graph, steps = '[]', feedbacks = '[]',
127
+ origin = excluded.origin, updated_at = excluded.updated_at,
128
+ archived_at = NULL
129
+ `).run(r.id, r.rowName, r.domain, JSON.stringify(r.graph), r.origin, now, now);
86
130
  });
87
- imported.push(id);
131
+ imported.push(r.id);
88
132
  }
89
133
  return { imported, skipped };
90
134
  }
@@ -163,15 +207,20 @@ export function referencedPluginAgents(name) {
163
207
  getDb();
164
208
  const out = [];
165
209
  for (const row of prepare(
166
- 'SELECT id, name, steps FROM workflows WHERE origin IS NULL OR origin != ?',
210
+ 'SELECT id, name, steps, graph FROM workflows WHERE origin IS NULL OR origin != ?',
167
211
  ).all(`plugin:${name}`)) {
168
- let steps;
169
- try { steps = JSON.parse(row.steps); } catch { continue; }
170
212
  const found = new Set();
213
+ let steps;
214
+ try { steps = JSON.parse(row.steps); } catch { steps = null; }
171
215
  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
- }
216
+ for (const node of Array.isArray(group) ? group : []) if (node && keys.has(node.key)) found.add(node.key);
217
+ }
218
+ let graph;
219
+ try { graph = JSON.parse(row.graph || 'null'); } catch { graph = null; }
220
+ // v2 rows: only kind 'agent' nodes carry a key (a task/end/and/or/combine
221
+ // card never does), so the walk is narrowed rather than key-only.
222
+ for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) {
223
+ if (node && node.kind === 'agent' && keys.has(node.key)) found.add(node.key);
175
224
  }
176
225
  if (found.size) out.push({ workflowId: row.id, name: row.name, keys: [...found].sort() });
177
226
  }