@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
@@ -16,6 +16,8 @@ import {
16
16
  budgetStatus, costWindowStart, costWindowEnd, allTimeTotals, roundUsd,
17
17
  askWindowedSpendUsd,
18
18
  } from './cost-budget.mjs';
19
+ import { savedUsd } from '../shared/human-estimate.mjs';
20
+ import { effectiveHumanRateUsd } from './human-rate.mjs';
19
21
 
20
22
  const RANGES = ['today', 'week', 'month', 'all'];
21
23
 
@@ -33,7 +35,8 @@ const TOTALS_SELECT = `
33
35
  COALESCE(SUM(pr_url IS NOT NULL), 0) AS prsOpened,
34
36
  COALESCE(SUM(pr_state = 'MERGED'), 0) AS prsMerged,
35
37
  COALESCE(SUM(CASE WHEN p.total_cost_usd > 0 THEN p.total_cost_usd ELSE COALESCE(s.sc, 0) END), 0) AS spend,
36
- COALESCE(SUM(CASE WHEN p.total_active_ms > 0 THEN p.total_active_ms ELSE COALESCE(s.sa, 0) END), 0) AS active
38
+ COALESCE(SUM(CASE WHEN p.total_active_ms > 0 THEN p.total_active_ms ELSE COALESCE(s.sa, 0) END), 0) AS active,
39
+ COALESCE(SUM(p.human_hours), 0) AS humanHours
37
40
  FROM pipelines p
38
41
  LEFT JOIN (SELECT pipeline_id, SUM(cost_usd) sc, SUM(active_ms) sa
39
42
  FROM pipeline_steps GROUP BY pipeline_id) s ON s.pipeline_id = p.id`;
@@ -46,6 +49,7 @@ function totalsRow(sql, isoA, isoB) {
46
49
  prsOpened: row.prsOpened, prsMerged: row.prsMerged,
47
50
  workedMs: Number(row.active || 0),
48
51
  cohortSpendUsd: roundUsd(row.spend || 0),
52
+ humanHours: Math.round(Number(row.humanHours || 0) * 100) / 100,
49
53
  };
50
54
  }
51
55
 
@@ -97,6 +101,28 @@ function askTotals(fromMs, toMs) {
97
101
  return { spendUsd: roundUsd(row?.s || 0), sessions: row?.sessions || 0, turns: row?.turns || 0 };
98
102
  }
99
103
 
104
+ /** "Saved this month/week" for the sidebar spend indicator (money-saved design §10): human
105
+ * hours × rate − spent over the budget's own reset window. The same cohort (runs STARTED in
106
+ * [windowStartMs, windowEndMs), archived included), the same rate and the same spend as the
107
+ * Statistics "Saved" tile for the matching range, so the sidebar and the tile never disagree.
108
+ * A lean SUM rather than cohortTotals(): /api/budget is refetched on every tick while runs
109
+ * are live, and the pipeline_steps join TOTALS_SELECT carries is dead weight for one column.
110
+ * @param {{windowStartMs:number, windowEndMs:number, windowSpendUsd:number}} budget budgetStatus()
111
+ * @returns {{windowHumanHours:number, windowSavedUsd:number}} */
112
+ export function budgetWindowSavings(budget) {
113
+ const row = prepare(`
114
+ SELECT COALESCE(SUM(p.human_hours), 0) AS humanHours
115
+ FROM pipelines p
116
+ WHERE COALESCE(p.started_at, p.updated_at) >= ?
117
+ AND COALESCE(p.started_at, p.updated_at) < ?`)
118
+ .get(new Date(budget.windowStartMs).toISOString(), new Date(budget.windowEndMs).toISOString());
119
+ const windowHumanHours = Math.round(Number(row?.humanHours || 0) * 100) / 100;
120
+ return {
121
+ windowHumanHours,
122
+ windowSavedUsd: savedUsd(windowHumanHours, effectiveHumanRateUsd(), budget.windowSpendUsd),
123
+ };
124
+ }
125
+
100
126
  /** Build zero-filled buckets [{startMs, endMs}] from windowStart through `now`. */
101
127
  function buildBuckets(windowStart, now, bucket) {
102
128
  const out = [];
@@ -122,6 +148,7 @@ function buildBuckets(windowStart, now, bucket) {
122
148
  export function getStats({ range = 'month', now = new Date() } = {}) {
123
149
  if (!RANGES.includes(range)) throw new RangeError(`unknown stats range: ${range}`);
124
150
  const budget = budgetStatus(now);
151
+ const rate = effectiveHumanRateUsd();
125
152
 
126
153
  let windowStart, windowEnd, prevStart, prevEnd, bucket;
127
154
  if (range === 'today') {
@@ -155,6 +182,8 @@ export function getStats({ range = 'month', now = new Date() } = {}) {
155
182
  return {
156
183
  spentUsd: roundUsd(pipelineSpendUsd + ask.spendUsd),
157
184
  pipelineSpendUsd, ask,
185
+ humanHours: c.humanHours,
186
+ savedUsd: savedUsd(c.humanHours, rate, roundUsd(pipelineSpendUsd + ask.spendUsd)),
158
187
  workedMs: c.workedMs,
159
188
  runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
160
189
  paused: c.paused, running: c.running,
@@ -170,6 +199,8 @@ export function getStats({ range = 'month', now = new Date() } = {}) {
170
199
  totals = {
171
200
  spentUsd: roundUsd(at.spendUsd + ask.spendUsd),
172
201
  pipelineSpendUsd: at.spendUsd, ask,
202
+ humanHours: at.humanHours,
203
+ savedUsd: savedUsd(at.humanHours, rate, roundUsd(at.spendUsd + ask.spendUsd)),
173
204
  workedMs: at.activeMs,
174
205
  runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
175
206
  paused: c.paused, running: c.running,
@@ -196,6 +227,6 @@ export function getStats({ range = 'month', now = new Date() } = {}) {
196
227
  return {
197
228
  range, bucket,
198
229
  windowStartMs: windowStart.getTime(), windowEndMs: windowEnd.getTime(),
199
- totals, prev, budget, series,
230
+ totals, prev, budget, series, humanRateUsd: rate,
200
231
  };
201
232
  }
@@ -23,6 +23,8 @@ import { registryPortsFn } from './graph/registry-ports.mjs';
23
23
  import { validateGraph, formatIssue } from '../shared/graph/validate.mjs';
24
24
  import { EFFORTS as EFFORT_LIST } from './model-env.mjs';
25
25
  import { loadAgentRegistry } from './agent-registry.mjs';
26
+ import { loadScriptRegistry } from './script-registry.mjs';
27
+ import { normalizeCases, CASES_VERSION } from '../shared/graph/script-cases.mjs';
26
28
  import { slugify } from './artifacts.mjs';
27
29
  import { isValidSkillName, collectRequiredSkills, resolveSkill, pluginSkillDirs } from './skills.mjs';
28
30
  import { normalizeProjectPath } from './projects.mjs';
@@ -396,6 +398,15 @@ async function buildExportSet({ workflowId, destination, projectDir, slug, inclu
396
398
  // its consumes/produces (derived from ports + wires, §2). Flow cards (task/end/
397
399
  // and/or/combine) are NOT dispatched — only agent nodes become steps.
398
400
  const graph = await resolveGraph(destination === 'project' ? projectDir : null, workflowId, registry);
401
+ // D19: the exported skill tree simulates the graph in Claude Code; a child-process
402
+ // card has no rendering there yet. Refuse loudly rather than export a skill that
403
+ // silently skips the gate.
404
+ const scriptNodes = (graph.template.nodes || []).filter((n) => n && n.kind === 'script');
405
+ if (scriptNodes.length) {
406
+ const names = scriptNodes.map((n) => `${graph.nodes[n.id]?.meta?.displayName || n.key} (${n.id})`);
407
+ throw Object.assign(err(`export to Claude Code does not carry script cards yet: ${names.join(', ')}`, 'UNSUPPORTED'),
408
+ { nodes: scriptNodes.map((n) => n.id) });
409
+ }
399
410
  const manifest = buildGraphManifest(graph.template, graph.agentsByKey,
400
411
  { overlays: { nodes: graph.nodes, wires: graph.wires } });
401
412
  const tnodeById = new Map((graph.template.nodes || []).map((n) => [n.id, n]));
@@ -431,6 +442,18 @@ async function buildExportSet({ workflowId, destination, projectDir, slug, inclu
431
442
  const declared = Array.isArray(node.tools) ? node.tools : [];
432
443
  const stripped = declared.filter((t) => STRIPPED_TOOLS.has(t) && t !== 'AskUserQuestion');
433
444
  if (stripped.length) warnings.push(`node "${node.key}": dropped subagent-incompatible tool(s): ${stripped.join(', ')}`);
445
+ // Ask forms (spec §8) do not travel: a Claude Code subagent has no worca form
446
+ // host, so the exported agent falls back to generic questions — which it
447
+ // already does structurally (makeAgentMd emits frontmatter + the markdown BODY
448
+ // and never reads the sidecar the `ask` block lives in). Say so, or an author
449
+ // ships a workflow believing their form travelled with it. The PLUGIN export
450
+ // is different on purpose: it copies the sidecar byte-identically, because a
451
+ // plugin is re-imported into worca where forms work.
452
+ const formIds = Object.keys((registry[node.key] && registry[node.key].ask && registry[node.key].ask.forms) || {});
453
+ if (formIds.length) {
454
+ warnings.push(`node "${node.key}": ask form(s) ${formIds.join(', ')} are not exported — `
455
+ + 'a console subagent has no worca form host and falls back to generic questions.');
456
+ }
434
457
  }
435
458
  }
436
459
 
@@ -1052,7 +1075,8 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
1052
1075
  // ── The stored graph must be runnable HERE before it is shared: a stranded
1053
1076
  // key (deleted agent) would only surface at the recipient's link.
1054
1077
  const registry = loadAgentRegistry();
1055
- const { errors } = validateGraph({ ...payload, id: tpl.id }, registryPortsFn(registry));
1078
+ const scripts = loadScriptRegistry({ agentKeys: Object.keys(registry) });
1079
+ const { errors } = validateGraph({ ...payload, id: tpl.id }, registryPortsFn(registry, scripts));
1056
1080
  if (errors.length) {
1057
1081
  const summary = summarizeUnknownAgents(errors);
1058
1082
  throw Object.assign(
@@ -1081,6 +1105,27 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
1081
1105
  }
1082
1106
  if (builtins.length) warnings.push(`built-in agent(s) not bundled (present on every Worca host): ${builtins.join(', ')}`);
1083
1107
 
1108
+ // ── Scripts: user-owned are bundled, built-ins never, another plugin's
1109
+ // refused — the same ownership rule the agents block above applies (§8.3).
1110
+ const scriptKeys = [...new Set(payload.nodes.filter((n) => n && n.kind === 'script' && n.key).map((n) => n.key))].sort();
1111
+ const userScripts = [];
1112
+ const foreignScripts = [];
1113
+ const builtinScripts = [];
1114
+ for (const key of scriptKeys) {
1115
+ const meta = scripts[key];
1116
+ if (!meta) continue; // validateGraph refused above
1117
+ const origin = String(meta.origin || '');
1118
+ if (origin === 'builtin') builtinScripts.push(key);
1119
+ else if (origin.startsWith('plugin:')) foreignScripts.push({ key, plugin: origin.slice('plugin:'.length) });
1120
+ else userScripts.push({ key, meta });
1121
+ }
1122
+ if (foreignScripts.length) {
1123
+ const list = foreignScripts.map((f) => `"${f.key}" (owned by plugin "${f.plugin}")`).join(', ');
1124
+ throw err(`cannot bundle ${list} — a shared workflow bundles only your own scripts; `
1125
+ + 'the recipient installs that plugin alongside, or you duplicate the script under your own name', 'UNSUPPORTED');
1126
+ }
1127
+ if (builtinScripts.length) warnings.push(`built-in script(s) not bundled (present on every worca host): ${builtinScripts.join(', ')}`);
1128
+
1084
1129
  // ── Skills the bundled agents require: filled from global/project/other-plugin
1085
1130
  // sources; a Worca-shipped (bundle) skill is skipped, like a built-in agent.
1086
1131
  const depSkills = resolveDepSkills(registry, userAgents.map((a) => a.key), dir, 'plugin', null, repoRoot,
@@ -1100,6 +1145,51 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
1100
1145
  targets.push({ path: join(dir, 'agents', `${key}.md`), text: await readFile(mdPath, 'utf8') });
1101
1146
  targets.push({ path: join(dir, 'agents', `${key}.meta.json`), text: await readFile(sidecarPath, 'utf8') });
1102
1147
  }
1148
+ // Scripts ride like agents: byte-identical sidecar + program. EVERY platform
1149
+ // entry of `file` travels, host platform irrelevant — the recipient may be on
1150
+ // Windows and need the .cmd half (spec §10).
1151
+ const bundledScripts = [];
1152
+ for (const { key, meta } of userScripts) {
1153
+ const srcDir = meta.scriptsDir || (meta.scriptPath ? dirname(meta.scriptPath) : null);
1154
+ const sidecar = srcDir ? join(srcDir, `${key}.meta.json`) : null;
1155
+ if (!sidecar || !existsSync(sidecar)) throw err(`script sidecar not found for "${key}" (${sidecar || key})`, 'NOT_FOUND');
1156
+ targets.push({ path: join(dir, 'scripts', `${key}.meta.json`), text: await readFile(sidecar, 'utf8') });
1157
+ const rels = [...new Set((typeof meta.file === 'string' ? [meta.file] : Object.values(meta.file || {})).filter(Boolean))];
1158
+ for (const rel of rels) {
1159
+ const from = join(srcDir, rel);
1160
+ if (!existsSync(from)) throw err(`script source not found for "${key}" (${from})`, 'NOT_FOUND');
1161
+ targets.push({ path: join(dir, 'scripts', rel), text: await readFile(from, 'utf8') });
1162
+ }
1163
+ // Cases travel too, minus any that name a project folder: a scratch case is
1164
+ // the only kind the recipient can run (W9, §8.3).
1165
+ const casesPath = join(srcDir, `${key}.tests.json`);
1166
+ const casesTarget = join(dir, 'scripts', `${key}.tests.json`);
1167
+ let scratchCases = [];
1168
+ if (existsSync(casesPath)) {
1169
+ let raw = null;
1170
+ try { raw = JSON.parse(await readFile(casesPath, 'utf8')); }
1171
+ catch (e) { warnings.push(`script "${key}" cases: ${e.message}`); }
1172
+ const { cases, errors } = normalizeCases(raw, meta);
1173
+ for (const e of errors) warnings.push(`script "${key}" cases: ${e}`);
1174
+ scratchCases = cases.filter((k) => k.cwd && k.cwd.kind === 'scratch');
1175
+ const dropped = cases.length - scratchCases.length;
1176
+ if (dropped) {
1177
+ warnings.push(`script "${key}": ${dropped} test case(s) that run in a project folder were not bundled `
1178
+ + '— a shared case runs in a scratch folder');
1179
+ }
1180
+ }
1181
+ // The shipped set FOLLOWS the author's: a file an earlier export wrote is
1182
+ // overwritten even with the empty set (the exporter never deletes), so a case
1183
+ // the author removed — or one a port edit made invalid, which would fail the
1184
+ // folder's own validation — does not live on in the plugin.
1185
+ if (scratchCases.length || existsSync(casesTarget)) {
1186
+ targets.push({
1187
+ path: casesTarget,
1188
+ text: JSON.stringify({ version: CASES_VERSION, cases: scratchCases }, null, 2) + '\n',
1189
+ });
1190
+ }
1191
+ bundledScripts.push({ key, runtime: meta.runtime, files: [`${key}.meta.json`, ...rels] });
1192
+ }
1103
1193
  for (const s of depSkills) {
1104
1194
  const skillMd = join(dir, 'skills', s.skill, 'SKILL.md');
1105
1195
  if (s.skipped) skipped.push({ path: skillMd, reason: `skill "${s.skill}" ships with Worca (${s.resolvedFrom}) — not bundled` });
@@ -1124,7 +1214,7 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
1124
1214
  const manifest = existing ? { ...existing } : {
1125
1215
  name, version: '0.1.0',
1126
1216
  description: `Workflows shared from Worca — ${tpl.name}`,
1127
- engines: { 'worca-cc-api': '>=3 <4' },
1217
+ engines: { 'worca-cc-api': '>=4 <5' },
1128
1218
  };
1129
1219
  if (!manifest.name) manifest.name = name;
1130
1220
  let version = typeof manifest.version === 'string' ? manifest.version : '';
@@ -1149,7 +1239,8 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
1149
1239
 
1150
1240
  const plan = {
1151
1241
  dir, name, slug, version: manifest.version || null,
1152
- created, updated, noop, skipped, conflicts: [], warnings, orphans: [], written: [], validation: null,
1242
+ created, updated, noop, skipped, conflicts: [], warnings, orphans: [], written: [],
1243
+ scripts: bundledScripts, validation: null,
1153
1244
  };
1154
1245
  if (dryRun) return plan;
1155
1246
 
@@ -9,17 +9,21 @@
9
9
  // exportGraphJson(id) -> { version:2, name, domain, nodes, wires, canvas? }
10
10
  // saveGraphWorkflow(body) -> composer semantics: keeps a legal `id`, 409 on a minted collision
11
11
  // importGraphWorkflow(body, opts) -> import semantics: id/origin ignored, name suffixed on collision
12
+ // listScriptNodes(graph, scripts) -> the command/code values an import must show first (D18)
13
+ // formatScriptNodes(list) -> SCRIPT_IMPORT_NOTICE + those values, for the CLI
12
14
  // nodeDefaultsError(raw, models, at) -> '' | reason (per-node tunables vs the project-less catalog)
13
15
  //
14
16
  // Errors carry `.code` so surfaces map them to HTTP / exit codes:
15
17
  // BAD_REQUEST | INVALID_GRAPH (+ .errors/.warnings/.summary) | RESERVED_NAME |
16
- // ID_TAKEN (+ .id) | NOT_FOUND | UNSUPPORTED
18
+ // ID_TAKEN (+ .id) | NOT_FOUND | UNSUPPORTED | SCRIPTS_UNCONFIRMED (+ .scriptNodes)
17
19
 
18
20
  import { listModels } from './config.mjs';
19
21
  import { EFFORTS, subagentModelIssue } from './model-env.mjs';
20
22
  import { loadAgentRegistry, DEFAULT_AGENTS_DIR } from './agent-registry.mjs';
23
+ import { loadScriptRegistry } from './script-registry.mjs';
21
24
  import { registryPortsFn } from './graph/registry-ports.mjs';
22
25
  import { validateGraph, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
26
+ import { effectiveScriptParams, CONFIRM_PARAM_TYPES } from '../shared/graph/script-meta.mjs';
23
27
  import { readWorkflow, writeGraphWorkflow } from './workflows.mjs';
24
28
 
25
29
  /** How many `Name (n)` retries an import makes before giving up. */
@@ -27,6 +31,39 @@ export const SUFFIX_CAP = 20;
27
31
 
28
32
  function err(message, code, extra = {}) { return Object.assign(new Error(message), { code, ...extra }); }
29
33
 
34
+ /** D18: the one sentence the CLI prints and the Import dialog shows above the commands. */
35
+ export const SCRIPT_IMPORT_NOTICE = "These commands run on this machine with worca's privileges when the workflow runs.";
36
+
37
+ /** The script nodes an import must show first (D18): every node whose EFFECTIVE
38
+ * params include a command- or code-typed value, with those values. */
39
+ export function listScriptNodes(graph, scripts) {
40
+ const out = [];
41
+ for (const n of Array.isArray(graph?.nodes) ? graph.nodes : []) {
42
+ if (!n || n.kind !== 'script' || !n.key) continue;
43
+ const meta = scripts?.[n.key];
44
+ if (!meta) continue; // V4 reports it; nothing to show
45
+ const values = effectiveScriptParams(meta, n.config);
46
+ const params = {};
47
+ for (const p of meta.params || []) {
48
+ if (CONFIRM_PARAM_TYPES.includes(p.type) && typeof values[p.id] === 'string') params[p.id] = values[p.id];
49
+ }
50
+ if (Object.keys(params).length) out.push({ nodeId: n.id, key: n.key, displayName: meta.displayName || n.key, runtime: meta.runtime, params });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** The CLI block: the notice, then one indented value per command/code param. */
56
+ export function formatScriptNodes(list) {
57
+ const lines = [SCRIPT_IMPORT_NOTICE];
58
+ for (const n of list) {
59
+ for (const [id, value] of Object.entries(n.params)) {
60
+ lines.push(`- ${n.displayName} (${n.nodeId}, ${n.runtime}) ${id}:`);
61
+ for (const l of String(value).split('\n')) lines.push(` ${l}`);
62
+ }
63
+ }
64
+ return lines.join('\n') + '\n';
65
+ }
66
+
30
67
  /**
31
68
  * Validate one node-defaults block against the project-less catalog. Returns an
32
69
  * error message, or '' when the block is acceptable. Mirrors setStep's rules so a
@@ -120,8 +157,8 @@ function prepareGraph(body, { keepId, name }) {
120
157
  return graph;
121
158
  }
122
159
 
123
- /** Catalog + structural validation. Throws BAD_REQUEST / INVALID_GRAPH; returns warnings. */
124
- async function validateForSave(graph, { registry, agentsDir }) {
160
+ /** Catalog + structural validation. Throws BAD_REQUEST / INVALID_GRAPH; returns the warnings and the script registry it used. */
161
+ async function validateForSave(graph, { registry, scripts, agentsDir }) {
125
162
  // Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
126
163
  // so a value the per-project override could not name must not ride in
127
164
  // through a template save. Only AGENT_TUNABLES are handed to nodeDefaultsError —
@@ -133,15 +170,14 @@ async function validateForSave(graph, { registry, agentsDir }) {
133
170
  const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
134
171
  if (bad) throw err(bad, 'BAD_REQUEST');
135
172
  }
136
- const portsFn = registryPortsFn(registry || loadAgentRegistry(agentsDir || DEFAULT_AGENTS_DIR));
137
- const { errors, warnings } = validateGraph({ ...graph, version: 2 }, portsFn);
138
- if (errors.length) {
139
- // The message stays the composer's verbatim 'invalid graph' (app.js renders
140
- // the issue list); `summary` is the one-line V4 fold for the CLI and the
141
- // import button, which have no issue list to show.
142
- throw err('invalid graph', 'INVALID_GRAPH', { errors, warnings, summary: summarizeUnknownAgents(errors) });
143
- }
144
- return warnings;
173
+ const reg = registry || loadAgentRegistry(agentsDir || DEFAULT_AGENTS_DIR);
174
+ const scr = scripts && typeof scripts === 'object' ? scripts : loadScriptRegistry({ agentKeys: Object.keys(reg) });
175
+ const { errors, warnings } = validateGraph({ ...graph, version: 2 }, registryPortsFn(reg, scr));
176
+ // The message stays the composer's verbatim 'invalid graph' (app.js renders
177
+ // the issue list); `summary` is the one-line V4 fold for the CLI and the
178
+ // import button, which have no issue list to show.
179
+ if (errors.length) throw err('invalid graph', 'INVALID_GRAPH', { errors, warnings, summary: summarizeUnknownAgents(errors) });
180
+ return { warnings, scripts: scr };
145
181
  }
146
182
 
147
183
  /**
@@ -152,7 +188,7 @@ async function validateForSave(graph, { registry, agentsDir }) {
152
188
  */
153
189
  export async function saveGraphWorkflow(body, { registry, agentsDir } = {}) {
154
190
  const graph = prepareGraph(body || {}, { keepId: true });
155
- const warnings = await validateForSave(graph, { registry, agentsDir });
191
+ const { warnings } = await validateForSave(graph, { registry, agentsDir });
156
192
  const workflow = await writeGraphWorkflow(graph, { rejectCollision: true });
157
193
  return { workflow, warnings };
158
194
  }
@@ -164,13 +200,26 @@ const SUFFIX_RE = /\s\((\d+)\)$/;
164
200
  * importer mints), `name` overrides the file's name, and a collision with a live
165
201
  * row retries as `Name (2)`, `Name (3)`… up to SUFFIX_CAP — never overwrite.
166
202
  * @param {object} body the exportGraphJson shape
167
- * @param {{name?: string, registry?: object, agentsDir?: string}} [opts]
168
- * @returns {Promise<{workflow: object, warnings: Array, requestedName: string, renamed: boolean}>}
203
+ * @param {{name?: string, registry?: object, scripts?: object, agentsDir?: string, dryRun?: boolean, acceptScripts?: boolean}} [opts]
204
+ * dryRun: validate and list the script nodes (D18) WITHOUT writing — the Import
205
+ * dialog and the CLI confirm from this before the real import.
206
+ * acceptScripts: the caller SHOWED the user every command/code value and the user
207
+ * agreed. Anything but the literal `true` refuses a graph that carries one (P10):
208
+ * the default is the safe one, so a caller that never heard of scripts cannot
209
+ * import a command by accident.
210
+ * @returns {Promise<{workflow: object|null, warnings: Array, requestedName: string, renamed: boolean, scriptNodes: Array}>}
211
+ * @throws code 'SCRIPTS_UNCONFIRMED' (`.scriptNodes`) — nothing was written
169
212
  */
170
- export async function importGraphWorkflow(body, { name, registry, agentsDir } = {}) {
213
+ export async function importGraphWorkflow(body, { name, registry, scripts, agentsDir, dryRun = false, acceptScripts = false } = {}) {
171
214
  const graph = prepareGraph(body || {}, { keepId: false, name });
172
- const warnings = await validateForSave(graph, { registry, agentsDir });
215
+ const { warnings, scripts: scr } = await validateForSave(graph, { registry, scripts, agentsDir });
216
+ const scriptNodes = listScriptNodes(graph, scr);
173
217
  const requestedName = graph.name;
218
+ if (dryRun) return { workflow: null, warnings, requestedName, renamed: false, scriptNodes };
219
+ if (scriptNodes.length && acceptScripts !== true) {
220
+ throw err(`this workflow runs commands on this machine (${scriptNodes.map((n) => `${n.displayName} · ${n.nodeId}`).join(', ')}) — review them, then import again with the scripts accepted`,
221
+ 'SCRIPTS_UNCONFIRMED', { scriptNodes });
222
+ }
174
223
  const m = SUFFIX_RE.exec(requestedName);
175
224
  const base = m ? requestedName.slice(0, m.index) : requestedName;
176
225
  let n = m ? Number(m[1]) : 1;
@@ -178,7 +227,7 @@ export async function importGraphWorkflow(body, { name, registry, agentsDir } =
178
227
  for (let attempt = 0; attempt <= SUFFIX_CAP; attempt++) {
179
228
  try {
180
229
  const workflow = await writeGraphWorkflow({ ...graph, name: candidate }, { rejectCollision: true });
181
- return { workflow, warnings, requestedName, renamed: candidate !== requestedName };
230
+ return { workflow, warnings, requestedName, renamed: candidate !== requestedName, scriptNodes };
182
231
  } catch (e) {
183
232
  if (e?.code === 'RESERVED_NAME') {
184
233
  throw err(`${e.message} (pass a different name to import it)`, 'RESERVED_NAME');
@@ -22,9 +22,13 @@ import { isSubagentModelValue } from './model-env.mjs';
22
22
  const validSubagentModel = (v) => (isSubagentModelValue(v) ? v : undefined);
23
23
  import { slugify } from './artifacts.mjs';
24
24
  import { DEFAULT_AGENTS_DIR, loadAgentRegistry } from './agent-registry.mjs'; // fileURLToPath-based (Windows-safe)
25
+ import { loadScriptRegistry } from './script-registry.mjs';
25
26
  import { readPluginsLock } from './plugins-lock.mjs'; // a DISABLED plugin's rows are hidden
27
+ import { teamDefault } from './policy/cache.mjs'; // team-policy models.steps defaults
26
28
  import { validateGraph, formatIssue, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
27
29
  import { classifyLoops } from '../shared/graph/loops.mjs';
30
+ import { KEYED_KINDS } from '../shared/graph/constants.mjs';
31
+ import { scriptNodeCtx } from '../shared/graph/script-meta.mjs';
28
32
  import {
29
33
  GRAPH_DEFAULT_WORKFLOW, AUTO_WORKFLOW_ID, AUTO_WORKFLOW_NAME, AUTO_WORKFLOW_STUB,
30
34
  GRAPH_MEMORY_DEFRAG_WORKFLOW, MEMORY_DEFRAG_WORKFLOW_ID, MEMORY_DEFRAG_WORKFLOW_NAME, isReservedWorkflowId,
@@ -409,11 +413,13 @@ export async function listWorkflows({ includeArchived = false, includeDisabled =
409
413
  * and must never refuse a run.
410
414
  * @param {object} tpl a version-2 template
411
415
  * @param {Record<string,object>} [registry] injected registry; the live one otherwise
416
+ * @param {Record<string,object>} [scripts] injected script registry; the live one otherwise
412
417
  * @throws {Error} code 'INVALID_GRAPH', `issues` = the validator's error list
413
418
  */
414
- function assertValidGraph(tpl, registry) {
419
+ function assertValidGraph(tpl, registry, scripts) {
415
420
  const reg = registry && typeof registry === 'object' ? registry : loadAgentRegistry();
416
- const { ok, errors } = validateGraph(tpl, registryPortsFn(reg));
421
+ const scr = scripts && typeof scripts === 'object' ? scripts : loadScriptRegistry({ agentKeys: Object.keys(reg) });
422
+ const { ok, errors } = validateGraph(tpl, registryPortsFn(reg, scr));
417
423
  if (ok) return;
418
424
  throw Object.assign(
419
425
  new Error(`workflow "${tpl.id}" no longer matches the agents it uses: `
@@ -428,9 +434,9 @@ function assertValidGraph(tpl, registry) {
428
434
  * `checkGraph:false` is the READ escape hatch (GET /api/workflows/:id): a
429
435
  * stale template must still open in the Composer, or it can never be repaired.
430
436
  * @param {string} id
431
- * @param {{registry?:Record<string,object>, checkGraph?:boolean}} [opts]
437
+ * @param {{registry?:Record<string,object>, scripts?:Record<string,object>, checkGraph?:boolean}} [opts]
432
438
  */
433
- export async function assertRunnableWorkflow(id, { registry, checkGraph = true } = {}) {
439
+ export async function assertRunnableWorkflow(id, { registry, scripts, checkGraph = true } = {}) {
434
440
  const wanted = typeof id === 'string' && id.trim() ? id.trim() : GRAPH_DEFAULT_WORKFLOW.id;
435
441
  // The Auto entry has no graph to check: the run decides one (spec §5.1).
436
442
  if (wanted === AUTO_WORKFLOW_ID) return AUTO_WORKFLOW_STUB;
@@ -445,7 +451,7 @@ export async function assertRunnableWorkflow(id, { registry, checkGraph = true }
445
451
  + `enable the plugin (Plugins view, or: worca plugin enable ${plugin}) to use it`), { code: 'PLUGIN_DISABLED' });
446
452
  }
447
453
  // A v1 row is not a graph: engine-select owns its refusal (V1_RUN_RETIRED).
448
- if (checkGraph && live.version === 2) assertValidGraph(live, registry);
454
+ if (checkGraph && live.version === 2) assertValidGraph(live, registry, scripts);
449
455
  return live;
450
456
  }
451
457
  const archived = await readWorkflow(wanted, { includeArchived: true });
@@ -603,6 +609,7 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
603
609
  // constant) whose agent nodes carry the RESOLVED key after workspace substitution.
604
610
  const tpl = structuredClone(stored);
605
611
  const reg = registry && typeof registry === 'object' ? registry : {};
612
+ const scripts = opts.scripts && typeof opts.scripts === 'object' ? opts.scripts : loadScriptRegistry({ agentKeys: Object.keys(reg) });
606
613
  const isWorkspace = !!opts.isWorkspace;
607
614
  const variants = isWorkspace ? workspaceVariants(reg) : {};
608
615
  // Per-project overlays. An Auto run owns its tuning (spec D7/D9): with
@@ -627,11 +634,39 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
627
634
  // store (projectKey(null) throws); its legacy per-role layer is empty by definition.
628
635
  const stepsCfg = (!ignore && workflowId === GRAPH_DEFAULT_WORKFLOW.id && projectDir) ? (await readConfig(projectDir)).steps : {};
629
636
  const firstDefined = (...vals) => vals.find((v) => v !== undefined);
637
+ // Team policy `models.steps` (a default, team-policy design §8): model and effort per ROLE for
638
+ // roles the project has not configured — below the project's own node and per-role config,
639
+ // above the template's authored config. Cache-only read; absent without a policy.
640
+ const teamSteps = (!ignore && projectDir) ? (teamDefault(projectDir, 'models.steps') || {}) : {};
641
+ // Memory defragment (Settings › Memory, memory-defrag-model.mjs): the run's model/effort PAIR —
642
+ // named at start, else the global setting. The one layer that beats even the project's own node
643
+ // pick (the setting is global by design: no per-project override), and it carries its own
644
+ // effort, so no lower layer's effort rides under a model it was not chosen for. Absent (every
645
+ // other caller, and a defragment run with nothing named or stored) → the layers below, unchanged.
646
+ const pair = opts.agentPair && typeof opts.agentPair.model === 'string' && opts.agentPair.model.trim()
647
+ ? {
648
+ model: opts.agentPair.model.trim(),
649
+ effort: typeof opts.agentPair.effort === 'string' && opts.agentPair.effort ? opts.agentPair.effort : undefined,
650
+ }
651
+ : null;
630
652
 
631
653
  const nodes = {};
632
654
  const agentsByKey = {};
633
655
  const agentKeys = new Set();
656
+ const scriptsByKey = {};
657
+ const scriptKeys = new Set();
634
658
  for (const node of Array.isArray(tpl.nodes) ? tpl.nodes : []) {
659
+ if (node.kind === 'script') {
660
+ const meta = scripts[node.key];
661
+ if (!meta) throw new Error(`unknown script "${node.key}" — no such key in the registry`);
662
+ if (meta.placeable === false) throw new Error(`script "${node.key}" declares placeable: false and cannot be a graph node`);
663
+ // v4 T5: ONE builder for the run-time facts of a placed card — the resume path (Task 9), the offline runner
664
+ // (Task 13) and P1c's bench build the same object from the same function, so they cannot drift apart.
665
+ nodes[node.id] = scriptNodeCtx(node, meta);
666
+ scriptsByKey[node.key] = meta;
667
+ scriptKeys.add(node.key);
668
+ continue;
669
+ }
635
670
  if (node.kind !== 'agent') {
636
671
  nodes[node.id] = { nodeId: node.id, kind: node.kind, key: null, config: { ...(node.config || {}) } };
637
672
  continue;
@@ -653,6 +688,7 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
653
688
  // Legacy per-role config is keyed by the AUTHORED key, so a substituted
654
689
  // variant still inherits the user's model/effort for that role.
655
690
  const legacy = stepsCfg[authored] || {};
691
+ const team = (sel.model || sel.effort || legacy.model || legacy.effort) ? {} : (teamSteps[authored] || {});
656
692
  const cfg = node.config && typeof node.config === 'object' ? node.config : {};
657
693
  nodes[node.id] = {
658
694
  nodeId: node.id,
@@ -666,10 +702,10 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
666
702
  promptHints: typeof meta.promptHints === 'string' ? meta.promptHints : '',
667
703
  tools,
668
704
  config: { ...cfg },
669
- model: firstDefined(sel.model, legacy.model, cfg.model),
705
+ model: pair ? pair.model : firstDefined(sel.model, legacy.model, team.model, cfg.model),
670
706
  // An effort only travels with the model that advertises it: an override
671
707
  // naming its own model must not inherit the lower layer's effort.
672
- effort: firstDefined(sel.effort, legacy.effort, (sel.model || legacy.model) ? undefined : cfg.effort),
708
+ effort: pair ? pair.effort : firstDefined(sel.effort, legacy.effort, team.effort, (sel.model || legacy.model || team.model) ? undefined : cfg.effort),
673
709
  // workspaceFanOut forces fan-out on a workspace run (the generic
674
710
  // replacement for the v1 FANOUT_ELIGIBLE key list).
675
711
  fanOut: isWorkspace && meta.workspaceFanOut
@@ -699,13 +735,13 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
699
735
 
700
736
  // Two nodes sharing one agent key prefix their run-store outputs (executor dupPrefix).
701
737
  const keyCount = new Map();
702
- for (const nc of Object.values(nodes)) if (nc.kind === 'agent') keyCount.set(nc.key, (keyCount.get(nc.key) || 0) + 1);
703
- for (const nc of Object.values(nodes)) if (nc.kind === 'agent') nc.duplicateKey = (keyCount.get(nc.key) || 0) > 1;
738
+ for (const nc of Object.values(nodes)) if (KEYED_KINDS.includes(nc.kind)) keyCount.set(nc.key, (keyCount.get(nc.key) || 0) + 1);
739
+ for (const nc of Object.values(nodes)) if (KEYED_KINDS.includes(nc.kind)) nc.duplicateKey = (keyCount.get(nc.key) || 0) > 1;
704
740
 
705
741
  // Budgets ride LOOP wires only: overlay > authored > DEFAULT_MAX_CYCLES.
706
742
  // portsFn + loops are computed ONCE here and RETURNED — P4 hands them to the
707
743
  // scheduler and the manifest builder instead of re-deriving them.
708
- const portsFn = registryPortsFn(agentsByKey);
744
+ const portsFn = registryPortsFn(agentsByKey, scriptsByKey);
709
745
  const loops = classifyLoops(tpl, portsFn);
710
746
  const { loopWireIds } = loops;
711
747
  const wires = {};
@@ -714,5 +750,5 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
714
750
  const raw = Number(wireCfg[w.id]?.maxCycles ?? w.config?.maxCycles);
715
751
  wires[w.id] = { maxCycles: Number.isInteger(raw) && raw >= 1 ? raw : DEFAULT_MAX_CYCLES };
716
752
  }
717
- return { template: tpl, ports: portsFn, loops, nodes, wires, agentsByKey, agentKeys };
753
+ return { template: tpl, ports: portsFn, loops, nodes, wires, agentsByKey, agentKeys, scriptsByKey, scriptKeys };
718
754
  }
@@ -42,14 +42,14 @@ function err(message, code) { return Object.assign(new Error(message), { code })
42
42
  * @returns {string|null}
43
43
  * @throws err(code: BAD_REQUEST)
44
44
  */
45
- function memberPathFor(paths, candidate) {
45
+ function memberPathFor(paths, candidate, field = 'metricsProject') {
46
46
  if (candidate == null || candidate === '') return null;
47
- if (typeof candidate !== 'string') throw err('metricsProject must be a project path or null', 'BAD_REQUEST');
47
+ if (typeof candidate !== 'string') throw err(`${field} must be a project path or null`, 'BAD_REQUEST');
48
48
  const want = normalizeProjectPath(candidate);
49
49
  // normalizeProjectPath(' ') -> null; canonicalProjectRoot(null) would throw ERR_INVALID_ARG_TYPE -> 500.
50
- if (!want) throw err('metricsProject must be a project path or null', 'BAD_REQUEST');
50
+ if (!want) throw err(`${field} must be a project path or null`, 'BAD_REQUEST');
51
51
  const hit = paths.find((p) => p === want || canonicalProjectRoot(p) === canonicalProjectRoot(want));
52
- if (!hit) throw err('metricsProject must be one of the workspace projects', 'BAD_REQUEST');
52
+ if (!hit) throw err(`${field} must be one of the workspace projects`, 'BAD_REQUEST');
53
53
  return hit;
54
54
  }
55
55
 
@@ -130,6 +130,7 @@ function annotate(entry) {
130
130
  projectKeys: pairs.map((x) => x.key),
131
131
  exists: pairs.map((x) => isDir(x.path)),
132
132
  metricsProject: entry.metricsProject ?? null,
133
+ policyProject: entry.policyProject ?? null,
133
134
  createdAt: entry.createdAt,
134
135
  updatedAt: entry.updatedAt,
135
136
  };
@@ -150,6 +151,7 @@ function rowToEntry(r) {
150
151
  description: typeof r.description === 'string' ? r.description : '',
151
152
  projectPaths: memberPaths(r.id),
152
153
  metricsProject: r.metrics_project ?? null,
154
+ policyProject: r.policy_project ?? null,
153
155
  createdAt: typeof r.created_at === 'string' ? r.created_at : '',
154
156
  updatedAt: typeof r.updated_at === 'string' ? r.updated_at : '',
155
157
  };
@@ -159,7 +161,7 @@ function rowToEntry(r) {
159
161
  function readEntry(id) {
160
162
  getDb();
161
163
  const r = prepare(
162
- 'SELECT id, name, description, metrics_project, created_at, updated_at FROM workspaces WHERE id = ?'
164
+ 'SELECT id, name, description, metrics_project, policy_project, created_at, updated_at FROM workspaces WHERE id = ?'
163
165
  ).get(id);
164
166
  return r ? rowToEntry(r) : null;
165
167
  }
@@ -171,7 +173,7 @@ function readEntry(id) {
171
173
  export async function listWorkspaces() {
172
174
  getDb();
173
175
  const rows = prepare(
174
- 'SELECT id, name, description, metrics_project, created_at, updated_at FROM workspaces ORDER BY created_at, name'
176
+ 'SELECT id, name, description, metrics_project, policy_project, created_at, updated_at FROM workspaces ORDER BY created_at, name'
175
177
  ).all();
176
178
  return rows.map(rowToEntry).map(annotate);
177
179
  }
@@ -242,6 +244,7 @@ export async function createWorkspace(input = {}) {
242
244
  }
243
245
 
244
246
  const metricsProject = memberPathFor(members, input.metricsProject ?? null);
247
+ const policyProject = memberPathFor(members, input.policyProject ?? null, 'policyProject');
245
248
  const id = workspaceKey({ name, projectPaths: members });
246
249
  const hash = rootsHash(members);
247
250
  const now = new Date().toISOString();
@@ -259,8 +262,8 @@ export async function createWorkspace(input = {}) {
259
262
  }
260
263
  }
261
264
  prepare(
262
- 'INSERT INTO workspaces (id, name, description, metrics_project, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
263
- ).run(id, name, description, metricsProject, now, now);
265
+ 'INSERT INTO workspaces (id, name, description, metrics_project, policy_project, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
266
+ ).run(id, name, description, metricsProject, policyProject, now, now);
264
267
  const insMember = prepare(
265
268
  'INSERT INTO workspace_projects (workspace_id, project_key, ordinal) VALUES (?, ?, ?)'
266
269
  );
@@ -270,7 +273,7 @@ export async function createWorkspace(input = {}) {
270
273
 
271
274
  // Return the annotated entry (derived fields recomputed from the persisted paths).
272
275
  return annotate({
273
- id, name, description, projectPaths: members, metricsProject, createdAt: now, updatedAt: now,
276
+ id, name, description, projectPaths: members, metricsProject, policyProject, createdAt: now, updatedAt: now,
274
277
  });
275
278
  }
276
279
 
@@ -299,6 +302,9 @@ export async function updateWorkspace(id, patch = {}) {
299
302
  const metricsProject = Object.prototype.hasOwnProperty.call(patch || {}, 'metricsProject')
300
303
  ? memberPathFor(entry.projectPaths, patch.metricsProject)
301
304
  : entry.metricsProject ?? null;
305
+ const policyProject = Object.prototype.hasOwnProperty.call(patch || {}, 'policyProject')
306
+ ? memberPathFor(entry.projectPaths, patch.policyProject, 'policyProject')
307
+ : entry.policyProject ?? null;
302
308
  const now = new Date().toISOString();
303
309
 
304
310
  tx(() => {
@@ -308,11 +314,11 @@ export async function updateWorkspace(id, patch = {}) {
308
314
  ).get(name, id);
309
315
  if (clash) throw err(`a workspace named "${name}" already exists`, 'DUPLICATE_NAME');
310
316
  prepare(
311
- 'UPDATE workspaces SET name = ?, description = ?, metrics_project = ?, updated_at = ? WHERE id = ?'
312
- ).run(name, description, metricsProject, now, id);
317
+ 'UPDATE workspaces SET name = ?, description = ?, metrics_project = ?, policy_project = ?, updated_at = ? WHERE id = ?'
318
+ ).run(name, description, metricsProject, policyProject, now, id);
313
319
  });
314
320
 
315
- return annotate({ ...entry, name, description, metricsProject, updatedAt: now });
321
+ return annotate({ ...entry, name, description, metricsProject, policyProject, updatedAt: now });
316
322
  }
317
323
 
318
324
  /** Thin setter: edit only the description. */