@worca/app 1.3.0 → 1.4.0

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 (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -14,17 +14,23 @@ import {
14
14
  mkdirSync, rmSync, symlinkSync, renameSync, unlinkSync,
15
15
  } from 'node:fs';
16
16
  import { join, resolve, isAbsolute, sep } from 'node:path';
17
- import { WORCA_PLUGIN_APIS } from './plugin-api.mjs';
18
- import { normalizeManifest, validatePluginDir, apiSatisfies, dataContractIssues, apiMismatch } from './plugin-manifest.mjs';
17
+ import { WORCA_PLUGIN_APIS, WORCA_ASK_FORMS_API } from './plugin-api.mjs';
18
+ import { normalizeManifest, validatePluginDir, apiSatisfies, dataContractIssues, apiMismatch, negotiatedApi } 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
24
  import {
25
- importPluginWorkflows, readPluginWorkflows, removePluginWorkflows, referencedPluginAgents,
25
+ importPluginWorkflows, readPluginWorkflows, removePluginWorkflows,
26
+ referencedPluginAgents, referencedPluginScripts,
26
27
  } from './plugin-workflows.mjs';
27
28
  import { loadAgentRegistry } from './agent-registry.mjs';
29
+ import { loadScriptRegistry } from './script-registry.mjs';
30
+ import { normalizeCases } from '../shared/graph/script-cases.mjs';
31
+ import { normalizeAskBlock, validateFormDef } from '../shared/forms/form-def.mjs';
32
+ import { fileAccepts } from '../shared/forms/answer.mjs';
33
+ import { normalizeScriptMeta, resolvePlatformValue } from '../shared/graph/script-meta.mjs';
28
34
  import { pluginModelSecretStatus } from './plugin-models.mjs';
29
35
  import { referencedPluginModels } from './config.mjs';
30
36
  import { clearBindingsForPlugin } from './source-bindings.mjs';
@@ -62,8 +68,17 @@ function insideDir(dir, rel) {
62
68
  * reviewer must see where) + requested model secrets, skills, workflows, npm
63
69
  * dep count from the lockfile, and the exact setup commands that would run. */
64
70
  export function buildInstallInventory(versionDir) {
65
- const manifest = readManifestAt(versionDir)
71
+ const readManifest = readManifestAt(versionDir);
72
+ const manifest = readManifest
66
73
  ?? { taskSources: [], chatChannels: [], models: [], modelSecrets: [], setup: { node: false, python: null } };
74
+ // Ask forms are honoured only when the plugin NEGOTIATES plugin API 4: below
75
+ // it agent-registry.scanLayer strips the block at load and reports it as an
76
+ // ignored contribution. Consent describes what THIS host will do, so such
77
+ // forms are not promised here — `worca plugin validate` and the ignored line
78
+ // are what name them. An unreadable manifest negotiates null and fails
79
+ // CLOSED, exactly as the registry's plugin layer does (ask-forms spec §10).
80
+ const formsHonoured = readManifest !== null
81
+ && Number(negotiatedApi(readManifest.engines?.worcaApi ?? '')) >= WORCA_ASK_FORMS_API;
67
82
  const agents = [];
68
83
  const aDir = join(versionDir, 'agents');
69
84
  if (existsSync(aDir)) {
@@ -76,13 +91,55 @@ export function buildInstallInventory(versionDir) {
76
91
  // unvalidated dir (the pre-install preview); an escaping agentFile falls
77
92
  // back to the sibling and validatePluginDir refuses the install anyway.
78
93
  let mdFile = `${key}.md`;
94
+ let rawMeta = null;
79
95
  try {
80
- const af = JSON.parse(readFileSync(join(aDir, f), 'utf8'))?.agentFile;
96
+ rawMeta = JSON.parse(readFileSync(join(aDir, f), 'utf8'));
97
+ const af = rawMeta?.agentFile;
81
98
  if (typeof af === 'string' && af.trim() && insideDir(aDir, af.trim())) mdFile = af.trim();
82
- } catch { /* unreadable sidecar: fall back to the sibling */ }
99
+ } catch { /* unreadable sidecar: fall back to the sibling, and declare no forms */ }
83
100
  let tools = [];
84
101
  try { tools = parseFrontmatter(readFileSync(join(aDir, mdFile), 'utf8'))?.tools ?? []; } catch { /* md missing */ }
85
- agents.push({ key, tools });
102
+ // Ask forms (spec §10). Consent states what the reviewer is agreeing to:
103
+ // how many forms this agent can put on screen, and which file types those
104
+ // forms may display FROM THE RUN FOLDER. Both are read from the sidecar's
105
+ // declared schemas — no plugin code runs here, and there is no instance
106
+ // data at consent time, which is why this is fileAccepts(schema) and not
107
+ // fileRefs(schema, data). Only forms that pass gate 1 are listed: the host
108
+ // drops the rest at load, and a consent card must not promise one.
109
+ const forms = [];
110
+ const accepts = new Set();
111
+ const declared = formsHonoured ? normalizeAskBlock(rawMeta?.ask).forms : {};
112
+ for (const [id, def] of Object.entries(declared)) {
113
+ if (!validateFormDef(def, { id }).ok) continue;
114
+ forms.push(id);
115
+ for (const a of fileAccepts(def.data)) accepts.add(a);
116
+ }
117
+ agents.push({ key, tools, forms: forms.sort(), fileTypes: [...accepts].sort() });
118
+ }
119
+ }
120
+ const scripts = [];
121
+ const scDir = join(versionDir, 'scripts');
122
+ if (existsSync(scDir)) {
123
+ for (const f of readdirSync(scDir).filter((x) => x.endsWith('.meta.json')).sort()) {
124
+ const key = f.slice(0, -'.meta.json'.length);
125
+ let raw = null;
126
+ try { raw = JSON.parse(readFileSync(join(scDir, f), 'utf8')); } catch { raw = null; }
127
+ const { meta } = normalizeScriptMeta(raw || {});
128
+ // The consent card and the receipt say how much PROOF ships with the
129
+ // script: a case count the same normalizer would accept (spec §8.1).
130
+ let cases = 0;
131
+ const casesFile = join(scDir, `${key}.tests.json`);
132
+ if (meta && existsSync(casesFile)) {
133
+ try { cases = normalizeCases(JSON.parse(readFileSync(casesFile, 'utf8')), meta, { shipped: true }).cases.length; }
134
+ catch { cases = 0; }
135
+ }
136
+ scripts.push({
137
+ key,
138
+ runtime: meta?.runtime ?? (typeof raw?.runtime === 'string' ? raw.runtime : null),
139
+ file: meta ? resolvePlatformValue(meta.file, process.platform) : null,
140
+ command: meta ? resolvePlatformValue(meta.command, process.platform) : null,
141
+ cases,
142
+ });
86
143
  }
87
144
  }
88
145
  const taskSources = (manifest.taskSources || []).map((s) => ({
@@ -126,7 +183,7 @@ export function buildInstallInventory(versionDir) {
126
183
  const setupCommands = [];
127
184
  if (manifest.setup?.node) setupCommands.push(`npm ci --prefix ${versionDir} --ignore-scripts --omit=dev`);
128
185
  if (manifest.setup?.python === 'pyproject') setupCommands.push(`uv sync --project ${versionDir}`);
129
- return { agents, taskSources, chatChannels, models, modelSecrets, skills: skills.sort(), workflows, depCount, setupCommands };
186
+ return { agents, scripts, taskSources, chatChannels, models, modelSecrets, skills: skills.sort(), workflows, depCount, setupCommands };
130
187
  }
131
188
 
132
189
  /**
@@ -159,9 +216,20 @@ export function ignoredContributions(name, dir, opts = {}) {
159
216
  for (const d of drops) {
160
217
  if (d.origin === `plugin:${name}`) out.push({ file: `agents/${d.file}`, reason: d.reason });
161
218
  }
219
+ // Script drops ride the same channel (script-registry's onDrop); one load unless the caller did it.
220
+ let scriptDrops = opts.scriptDrops;
221
+ let scripts = opts.scripts;
222
+ if (!scriptDrops) {
223
+ scriptDrops = [];
224
+ try { scripts = loadScriptRegistry({ onDrop: (d) => scriptDrops.push(d), agentKeys: Object.keys(registry || {}) }); }
225
+ catch { /* no resolvable home */ }
226
+ }
227
+ for (const d of scriptDrops) {
228
+ if (d.origin === `plugin:${name}`) out.push({ file: `scripts/${d.file}`, reason: d.reason });
229
+ }
162
230
  const skips = opts.workflowSkips
163
231
  ?? (() => {
164
- try { return readPluginWorkflows(name, dir, { registry, quiet: true }).skipped; }
232
+ try { return readPluginWorkflows(name, dir, { registry, scripts, quiet: true }).skipped; }
165
233
  catch { return []; }
166
234
  })();
167
235
  for (const s of skips) out.push({ file: `workflows/${s.file}`, reason: `invalid template (${s.errors.join('; ')})` });
@@ -392,6 +460,67 @@ function gcVersions(name, keep7) {
392
460
  for (const d of entries) if (!keep7.includes(d)) rmSync(join(dir, d), { recursive: true, force: true });
393
461
  }
394
462
 
463
+ /** "3 scripts (node 2, python 1) · 5 cases" — the receipt's and the consent
464
+ * card's one-line summary of what a plugin ships (spec §8.1). Pure. */
465
+ export function scriptsSummary(scripts) {
466
+ const list = Array.isArray(scripts) ? scripts : [];
467
+ if (!list.length) return '';
468
+ const byRuntime = new Map();
469
+ for (const s of list) {
470
+ const r = s && s.runtime ? String(s.runtime) : 'unknown';
471
+ byRuntime.set(r, (byRuntime.get(r) || 0) + 1);
472
+ }
473
+ const runtimes = [...byRuntime.entries()].sort((a, b) => a[0].localeCompare(b[0]))
474
+ .map(([r, n]) => `${r} ${n}`).join(', ');
475
+ const cases = list.reduce((n, s) => n + (Number(s && s.cases) || 0), 0);
476
+ const head = `${list.length} script${list.length === 1 ? '' : 's'} (${runtimes})`;
477
+ return cases ? `${head} · ${cases} case${cases === 1 ? '' : 's'}` : head;
478
+ }
479
+
480
+ /** The notice a python-shipping plugin gets on a host without python (§8.1). */
481
+ export const PYTHON_MISSING_NOTICE = 'python not found';
482
+
483
+ /**
484
+ * The python interpreter probe's verdict, or null when the probe itself is
485
+ * unusable. The import is DYNAMIC on purpose: this module is loaded on almost
486
+ * every path and the probe is needed on two (the receipt, the Plugins list).
487
+ * The bare `probePython({})` call is the one the probe caches for 60 s.
488
+ * @returns {Promise<object|null>}
489
+ */
490
+ export async function pythonProbeOrNull() {
491
+ try {
492
+ const mod = await import('./graph/python-probe.mjs');
493
+ return await mod.probePython({});
494
+ } catch {
495
+ return null; // the probe itself is unusable: no notice, never a claim
496
+ }
497
+ }
498
+
499
+ /**
500
+ * `python not found` when this plugin ships a python script and the host has no
501
+ * usable interpreter. A notice, never a block (spec §8.1) — and a notice that
502
+ * cannot be computed degrades to nothing rather than to a claim.
503
+ * @param {Array<{runtime?: string}>} scripts @param {{probe?: Function}} [opts]
504
+ * @returns {Promise<string|null>}
505
+ */
506
+ export async function pythonNoticeFor(scripts, { probe = pythonProbeOrNull } = {}) {
507
+ if (!(Array.isArray(scripts) ? scripts : []).some((s) => s && s.runtime === 'python')) return null;
508
+ const p = await probe();
509
+ return p && p.ok === false ? PYTHON_MISSING_NOTICE : null;
510
+ }
511
+
512
+ /** Union the two guard lists by workflow id: ONE row per workflow with its keys
513
+ * merged, so a 409 payload never names the same workflow twice. */
514
+ function mergeReferences(agentRefs, scriptRefs) {
515
+ const byId = new Map();
516
+ for (const r of [...agentRefs, ...scriptRefs]) {
517
+ const prev = byId.get(r.workflowId);
518
+ if (prev) prev.keys = [...new Set([...prev.keys, ...r.keys])].sort();
519
+ else byId.set(r.workflowId, { ...r, keys: [...r.keys] });
520
+ }
521
+ return [...byId.values()];
522
+ }
523
+
395
524
  /**
396
525
  * Uninstall (spec §6.3). Reference guard + imported-workflow removal live in
397
526
  * plugin-workflows.mjs: block when a non-plugin workflow still uses this
@@ -404,12 +533,20 @@ export async function uninstallPlugin(name, { purge = false } = {}) {
404
533
  const lock = readPluginsLock();
405
534
  const entry = lock[name];
406
535
  if (!entry) throw new Error(`plugin "${name}" is not installed`);
407
- const refs = referencedPluginAgents(name);
408
- if (refs.length) {
536
+ // Agents AND scripts (spec §6.3, §8.2): a saved workflow that still places one
537
+ // of this plugin's keys blocks the removal, with the referencing list. The
538
+ // agents-only sentence is unchanged — it is what the 409 handler renders today.
539
+ const agentRefs = referencedPluginAgents(name);
540
+ const scriptRefs = referencedPluginScripts(name);
541
+ if (agentRefs.length || scriptRefs.length) {
542
+ const what = agentRefs.length && scriptRefs.length
543
+ ? 'agents and scripts'
544
+ : (agentRefs.length ? 'agents' : 'scripts');
545
+ const refs = mergeReferences(agentRefs, scriptRefs);
409
546
  throw Object.assign(
410
- new Error(`plugin "${name}" agents are referenced by: ${refs.map((r) => r.name).join(', ')} — remove those references first`),
411
- // Payload field is `references` — the one name Task 14's 409 handler and
412
- // Task 18's CLI catch both read (code mirrors deleteAgent, agent-store.mjs:113-118).
547
+ new Error(`plugin "${name}" ${what} are referenced by: ${refs.map((r) => r.name).join(', ')} — remove those references first`),
548
+ // Payload field is `references` — the one name the server's 409 handler
549
+ // and the CLI catch both read (code mirrors deleteAgent).
413
550
  { code: 'REFERENCED', references: refs },
414
551
  );
415
552
  }
@@ -500,6 +637,10 @@ export function listInstalledPlugins() {
500
637
  let registry = null;
501
638
  try { registry = loadAgentRegistry(undefined, { onDrop: (d) => drops.push(d) }); }
502
639
  catch { /* no resolvable home: fall back to the file-derived view */ }
640
+ const scriptDrops = [];
641
+ let scripts = null;
642
+ try { scripts = loadScriptRegistry({ onDrop: (d) => scriptDrops.push(d), agentKeys: Object.keys(registry || {}) }); }
643
+ catch { scripts = null; }
503
644
  return Object.keys(lock).sort().map((name) => {
504
645
  const e = lock[name] || {};
505
646
  const cur = pluginCurrentDir(name);
@@ -523,13 +664,18 @@ export function listInstalledPlugins() {
523
664
  broken: !manifest,
524
665
  apiMismatch: mismatch,
525
666
  contributions: inv
526
- ? { agents: inv.agents.length, taskSources: inv.taskSources.length, chatChannels: inv.chatChannels.length, models: inv.models.length, skills: inv.skills.length, workflows: inv.workflows.length }
527
- : { agents: 0, taskSources: 0, chatChannels: 0, models: 0, skills: 0, workflows: 0 },
667
+ ? { agents: inv.agents.length, scripts: inv.scripts.length, taskSources: inv.taskSources.length, chatChannels: inv.chatChannels.length, models: inv.models.length, skills: inv.skills.length, workflows: inv.workflows.length }
668
+ : { agents: 0, scripts: 0, taskSources: 0, chatChannels: 0, models: 0, skills: 0, workflows: 0 },
669
+ // What the card needs to decide whether the python notice applies, without
670
+ // re-reading the plugin dir.
671
+ scriptRuntimes: inv
672
+ ? inv.scripts.reduce((m, s) => { const r = s.runtime || 'unknown'; m[r] = (m[r] || 0) + 1; return m; }, {})
673
+ : {},
528
674
  // `contributions` counts what the plugin SHIPS (files on disk); `ignored`
529
675
  // names the ones worca refused to load, so the card can stop claiming them.
530
676
  // A disabled plugin contributes nothing BY CHOICE — its agents are not in the
531
677
  // registry, so its templates would misreport V4.
532
- ignored: manifest && e.enabled !== false ? ignoredContributions(name, cur, { registry, drops }) : [],
678
+ ignored: manifest && e.enabled !== false ? ignoredContributions(name, cur, { registry, drops, scripts, scriptDrops }) : [],
533
679
  };
534
680
  });
535
681
  }
@@ -11,6 +11,7 @@ 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 { loadScriptRegistry } from './script-registry.mjs';
14
15
  import { registryPortsFn } from './graph/registry-ports.mjs';
15
16
  import { validateGraph } from '../shared/graph/validate.mjs';
16
17
  import { NOT_GRAPH_V2 } from './plugin-manifest.mjs';
@@ -44,7 +45,7 @@ const normDomain = (raw) => {
44
45
  * callers must not re-log what the importer already logged).
45
46
  * @returns {{ready: Array<object>, skipped: Array<{file:string, errors:string[]}>}}
46
47
  */
47
- export function readPluginWorkflows(name, versionDir, { registry = null, quiet = false } = {}) {
48
+ export function readPluginWorkflows(name, versionDir, { registry = null, scripts = null, quiet = false } = {}) {
48
49
  const origin = `plugin:${name}`;
49
50
  const dir = join(versionDir, 'workflows');
50
51
  let files = [];
@@ -56,7 +57,8 @@ export function readPluginWorkflows(name, versionDir, { registry = null, quiet =
56
57
  // it: agent meta ports plus the universal `await` gate and the flow-card
57
58
  // table. The templates are not in the DB yet, so resolveGraph is unavailable —
58
59
  // registryPortsFn exists for exactly this caller and the server's save route.
59
- const portsFn = registryPortsFn(registry || loadAgentRegistry());
60
+ const reg = registry || loadAgentRegistry();
61
+ const portsFn = registryPortsFn(reg, scripts || loadScriptRegistry({ agentKeys: Object.keys(reg) }));
60
62
  const warn = (msg) => { if (!quiet) console.warn(msg); };
61
63
  const skip = (f, errors) => {
62
64
  skipped.push({ file: f, errors });
@@ -184,17 +186,17 @@ export async function removePluginWorkflows(name) {
184
186
  }
185
187
 
186
188
  /**
187
- * Uninstall guard input (spec §6.3): NON-plugin workflows (user rows and other
188
- * plugins' rows — anything not origin 'plugin:<name>') whose steps JSON references
189
- * one of THIS plugin's agent keys. Keys come from current/agents/*.meta.json.
190
- * Synchronous, never throws: no current/agents (already-broken install) => [].
191
- * @param {string} name
192
- * @returns {Array<{workflowId: string, name: string, keys: string[]}>}
189
+ * The registry keys a plugin ships under `<current>/<subdir>`: the `key` field
190
+ * of every `*.meta.json` there. Synchronous, never throws — an already-broken
191
+ * install (no such dir, malformed sidecar) simply guards nothing.
192
+ * `<key>.tests.json` and program files are skipped by the suffix test.
193
+ * @param {string} name @param {string} subdir 'agents' | 'scripts'
194
+ * @returns {Set<string>}
193
195
  */
194
- export function referencedPluginAgents(name) {
196
+ function shippedKeys(name, subdir) {
195
197
  const keys = new Set();
196
198
  try {
197
- const dir = join(pluginCurrentDir(name), 'agents');
199
+ const dir = join(pluginCurrentDir(name), subdir);
198
200
  for (const f of readdirSync(dir)) {
199
201
  if (!f.endsWith('.meta.json')) continue;
200
202
  try {
@@ -202,13 +204,29 @@ export function referencedPluginAgents(name) {
202
204
  if (typeof k === 'string' && k.trim()) keys.add(k.trim());
203
205
  } catch { /* malformed sidecar: nothing to guard */ }
204
206
  }
205
- } catch { return []; }
206
- if (!keys.size) return [];
207
+ } catch { return keys; }
208
+ return keys;
209
+ }
210
+
211
+ /** The non-plugin workflow rows this guard must read (user rows and OTHER
212
+ * plugins' rows — anything not origin 'plugin:<name>'). */
213
+ function foreignWorkflowRows(name) {
207
214
  getDb();
215
+ return prepare('SELECT id, name, steps, graph FROM workflows WHERE origin IS NULL OR origin != ?')
216
+ .all(`plugin:${name}`);
217
+ }
218
+
219
+ /**
220
+ * Uninstall guard input (spec §6.3): NON-plugin workflows whose steps JSON or
221
+ * v2 graph references one of THIS plugin's agent keys.
222
+ * @param {string} name
223
+ * @returns {Array<{workflowId: string, name: string, keys: string[]}>}
224
+ */
225
+ export function referencedPluginAgents(name) {
226
+ const keys = shippedKeys(name, 'agents');
227
+ if (!keys.size) return [];
208
228
  const out = [];
209
- for (const row of prepare(
210
- 'SELECT id, name, steps, graph FROM workflows WHERE origin IS NULL OR origin != ?',
211
- ).all(`plugin:${name}`)) {
229
+ for (const row of foreignWorkflowRows(name)) {
212
230
  const found = new Set();
213
231
  let steps;
214
232
  try { steps = JSON.parse(row.steps); } catch { steps = null; }
@@ -217,8 +235,8 @@ export function referencedPluginAgents(name) {
217
235
  }
218
236
  let graph;
219
237
  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.
238
+ // v2 rows: only kind 'agent' nodes carry an agent key (a task/end/and/or/
239
+ // combine card never does), so the walk is narrowed rather than key-only.
222
240
  for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) {
223
241
  if (node && node.kind === 'agent' && keys.has(node.key)) found.add(node.key);
224
242
  }
@@ -226,3 +244,39 @@ export function referencedPluginAgents(name) {
226
244
  }
227
245
  return out;
228
246
  }
247
+
248
+ /**
249
+ * The same guard for SHIPPED SCRIPTS (spec §8.2). No `steps` walk: the v1
250
+ * vocabulary had no script card, so only a v2 graph can place one.
251
+ *
252
+ * A key ANOTHER owner holds on this host does not count. `worca workflow export
253
+ * --format plugin` bundles the user's script under its own key, so on the author's
254
+ * host the plugin's copy collides with the user's and is never loaded: the workflow
255
+ * runs the USER's script, and removing the plugin breaks nothing. Without this an
256
+ * author who links their own export could never remove it. A key NOBODY holds (the
257
+ * plugin is disabled) still guards — enabling it again is what would mend the row.
258
+ * @param {string} name
259
+ * @returns {Array<{workflowId: string, name: string, keys: string[]}>}
260
+ */
261
+ export function referencedPluginScripts(name) {
262
+ const keys = shippedKeys(name, 'scripts');
263
+ if (!keys.size) return [];
264
+ let live = {};
265
+ try { live = loadScriptRegistry(); } catch { live = {}; }
266
+ for (const k of [...keys]) {
267
+ const held = Object.hasOwn(live, k) ? live[k] : null;
268
+ if (held && held.origin !== `plugin:${name}`) keys.delete(k);
269
+ }
270
+ if (!keys.size) return [];
271
+ const out = [];
272
+ for (const row of foreignWorkflowRows(name)) {
273
+ const found = new Set();
274
+ let graph;
275
+ try { graph = JSON.parse(row.graph || 'null'); } catch { graph = null; }
276
+ for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) {
277
+ if (node && node.kind === 'script' && keys.has(node.key)) found.add(node.key);
278
+ }
279
+ if (found.size) out.push({ workflowId: row.id, name: row.name, keys: [...found].sort() });
280
+ }
281
+ return out;
282
+ }
@@ -0,0 +1,116 @@
1
+ // src/core/policy/cache.mjs
2
+ // Synchronous reads of the team-policy DISCOVERY CACHE (project_config.extra.teamPolicy) for the
3
+ // modules that apply default-kind fields where they already read settings: config.mjs (default
4
+ // workflow, human in the loop, step models, the model catalog and its routing env),
5
+ // guardrail-store.mjs (policy guardrail sets), ask/limits.mjs (Ask Worca limits).
6
+ //
7
+ // Deliberately a LEAF: it imports only db.mjs, store.mjs and the pure registry/effective pair,
8
+ // never config.mjs or policy/sync.mjs, so config.mjs can import it without a cycle. It never
9
+ // touches git: the cache is written by policy/sync.mjs discovery (server start + hourly, or
10
+ // `worca policy pull`), and a project with no cache simply has no team defaults.
11
+
12
+ import { prepare, getDb } from '../db.mjs';
13
+ import { projectKey } from '../store.mjs';
14
+ import { normalizePolicyDoc } from './registry.mjs';
15
+ import { fieldsForRun } from './effective.mjs';
16
+
17
+ const parse = (s) => { try { const v = JSON.parse(s); return v && typeof v === 'object' ? v : null; } catch { return null; } };
18
+
19
+ /** Every cached, carrying policy on this machine: [{ slug, sha, doc, key }]. Never throws. */
20
+ export function cachedPolicyHomes() {
21
+ try {
22
+ getDb();
23
+ const rows = prepare("SELECT project_key, extra FROM project_config WHERE extra LIKE '%teamPolicy%'").all();
24
+ const out = []; const seen = new Set();
25
+ for (const r of rows) {
26
+ const tp = parse(r.extra)?.teamPolicy;
27
+ if (!tp || !tp.present || !tp.docKnown || tp.delegateTo || tp.unknownSchema || !tp.doc || !tp.slug) continue;
28
+ const slug = String(tp.slug).toLowerCase();
29
+ if (seen.has(slug)) continue;
30
+ seen.add(slug);
31
+ // Re-normalise: the cache was written by this or an older build; the reader is the contract.
32
+ const doc = normalizePolicyDoc(tp.doc).doc;
33
+ if (doc) out.push({ slug, sha: tp.headSha ?? null, doc, key: r.project_key });
34
+ }
35
+ return out;
36
+ } catch { return []; }
37
+ }
38
+
39
+ /**
40
+ * The policy that governs `projectDir` from the cache alone: its own, or the cached home it
41
+ * follows. null when there is none (no branch, not discovered yet, a dangling marker).
42
+ * @returns {{home:string, sha:string|null, doc:object}|null}
43
+ */
44
+ export function cachedPolicyFor(projectDir) {
45
+ if (!projectDir) return null;
46
+ let key;
47
+ try { key = projectKey(projectDir); } catch { return null; }
48
+ return cachedPolicyForKey(key);
49
+ }
50
+
51
+ /** Same as cachedPolicyFor, by project key (Ask Worca's pinned scope carries only the key). */
52
+ export function cachedPolicyForKey(key) {
53
+ if (!key || typeof key !== 'string') return null;
54
+ let tp;
55
+ try {
56
+ getDb();
57
+ const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
58
+ tp = row ? parse(row.extra)?.teamPolicy : null;
59
+ } catch { return null; }
60
+ if (!tp || !tp.present || !tp.docKnown || tp.unknownSchema) return null;
61
+ if (!tp.delegateTo) {
62
+ const doc = tp.doc ? normalizePolicyDoc(tp.doc).doc : null;
63
+ return doc ? { home: String(tp.slug || '').toLowerCase(), sha: tp.headSha ?? null, doc } : null;
64
+ }
65
+ const want = String(tp.delegateTo).toLowerCase();
66
+ const home = cachedPolicyHomes().find((h) => h.slug === want);
67
+ return home ? { home: home.slug, sha: home.sha, doc: home.doc } : null;
68
+ }
69
+
70
+ /** The team entries that apply to project runs of `projectDir`, from the cache: {} when none. */
71
+ export function cachedFieldsFor(projectDir) {
72
+ const p = cachedPolicyFor(projectDir);
73
+ return p ? fieldsForRun(p.doc) : {};
74
+ }
75
+
76
+ /** A single default-kind team value for `projectDir` (or undefined). Soft entries are not defaults. */
77
+ export function teamDefault(projectDir, key) {
78
+ const e = cachedFieldsFor(projectDir)[key];
79
+ return e && e.kind === 'default' ? e.value : undefined;
80
+ }
81
+
82
+ /**
83
+ * The union of the model catalogs every cached home ships, first home wins on an id collision.
84
+ * Each entry carries `home` for the policy badge.
85
+ */
86
+ export function policyCatalogModels() {
87
+ const out = []; const seen = new Set();
88
+ for (const h of cachedPolicyHomes()) {
89
+ for (const m of h.doc.catalogs?.models || []) {
90
+ const lc = m.id.toLowerCase();
91
+ if (seen.has(lc)) continue;
92
+ seen.add(lc);
93
+ out.push({ ...m, home: h.slug });
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** The union of the guardrail sets every cached home ships, as virtual `gp:<id>` sets. */
100
+ export function policyGuardrailSets() {
101
+ const out = []; const seen = new Set();
102
+ for (const h of cachedPolicyHomes()) {
103
+ for (const s of h.doc.catalogs?.guardrailSets || []) {
104
+ const id = `gp:${s.id}`;
105
+ if (seen.has(id.toLowerCase())) continue;
106
+ seen.add(id.toLowerCase());
107
+ out.push({
108
+ id, name: s.name, origin: `policy:${h.slug}`,
109
+ settings: { honorProjectSettings: s.honorProjectSettings !== false, envScrub: s.envScrub === true,
110
+ envAllowlist: [...s.envAllowlist], protectedPaths: [...s.protectedPaths], deny: [...s.deny] },
111
+ createdAt: '1970-01-01T00:00:00.000Z', updatedAt: '1970-01-01T00:00:00.000Z',
112
+ });
113
+ }
114
+ }
115
+ return out;
116
+ }