@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,7 +14,7 @@
14
14
  // prompt so the system prompt is never empty. Interface is locked by docs/ARCHITECTURE.md §3.5.
15
15
 
16
16
  import { runClaude } from './claude-runner.mjs';
17
- import { resolveModelEnv } from './config.mjs';
17
+ import { resolveModelEnv, bridgedModelInfo } from './config.mjs';
18
18
  import { SUBAGENT_AUTO, SUBAGENT_INHERIT, SUBAGENT_MODELS, effectiveSubagentModel } from './model-env.mjs';
19
19
  import { readClarify, readReview } from './protocol.mjs';
20
20
  import { writeClarify, readClarifyRow } from './artifacts.mjs';
@@ -83,6 +83,22 @@ export function ctxFanOut(ctx) {
83
83
  * ctx — from modelHasBaseUrlRouting(effective model), so this stays pure.
84
84
  * A present node wins, mirroring ctxFanOut. Pure + exported for testing.
85
85
  */
86
+ /**
87
+ * The model env for a dispatched node: resolveModelEnv tagged with the
88
+ * execution id (the bridge books its calls per tag). A bridged model whose
89
+ * provider is not usable makes resolveModelEnv throw (fail fast, model-bridge
90
+ * design §8.5) — except under the offline mock, which spawns nothing and must
91
+ * keep running for a catalog that names a Copilot model nobody signed in to.
92
+ */
93
+ function resolveDispatchModelEnv(c, ctx) {
94
+ try {
95
+ return resolveModelEnv(c.model, { tag: ctx.executionId });
96
+ } catch (err) {
97
+ if (c.mock && err && err.bridgeReason) return undefined;
98
+ throw err;
99
+ }
100
+ }
101
+
86
102
  export function ctxEndpointRouted(ctx) {
87
103
  if (!ctx || typeof ctx !== 'object') return false;
88
104
  return !!(ctx.node ? ctx.node.endpointRouted : ctx.endpointRouted);
@@ -429,6 +445,55 @@ export const RESUME_HEADER =
429
445
  'state of your previous work (files/artifacts you already wrote), then continue the\n' +
430
446
  'ORIGINAL task below to completion. Do not redo work that is already done.\n\n';
431
447
 
448
+ /**
449
+ * The per-agent forms section (spec §4): every form the agent may ask with, its
450
+ * data schema, its answer schema and its example. Returns '' for an agent that
451
+ * declares none, which is what keeps every prompt that exists today byte-identical.
452
+ * `path` is the file to write the payload to; omitted for a clarifier, whose
453
+ * Ports block already names its answers port. PURE.
454
+ * @param {Record<string, object>|null|undefined} forms
455
+ * @param {{path?: string}} [opts]
456
+ */
457
+ export function askFormsBlock(forms, { path: askPath } = {}) {
458
+ const entries = Object.entries(forms || {});
459
+ if (!entries.length) return '';
460
+ const body = entries.map(([id, def]) => (
461
+ `### \`${id}\` — ${def.title || id} (version ${Number.isInteger(def.version) ? def.version : 1})\n\n` +
462
+ 'data you must supply:\n```json\n' + JSON.stringify(def.data, null, 2) + '\n```\n\n' +
463
+ 'answer you will receive:\n```json\n' + JSON.stringify(def.answer, null, 2) + '\n```\n\n' +
464
+ 'example data:\n```json\n' + JSON.stringify(def.example, null, 2) + '\n```\n'
465
+ )).join('\n');
466
+ return (
467
+ '## Forms you may ask with\n\n' +
468
+ (askPath ? `Instead of the questions shape, write {"form":"<id>","data":{…}} to: ${askPath}\n` : '') +
469
+ 'A `file` value is a path relative to your working directory or the pipeline dir. ' +
470
+ 'You will be resumed with {"form","version","values"}.\n\n' +
471
+ body
472
+ );
473
+ }
474
+
475
+ /**
476
+ * The one repair round a refused form ask gets (spec §5 gate 2): the exact error
477
+ * list plus the data schema, and the SAME file to rewrite. Rendered by runOpts
478
+ * for BOTH engines — a clarifier never has questionsEnabled, so it could not
479
+ * ride questionsPromptBlock. '' when nothing is being repaired, which is what
480
+ * keeps every other prompt byte-identical. PURE.
481
+ * @param {{formRepair?: {form: string, errors: Array, schema: object|null, file: string}}} ctx
482
+ */
483
+ export function formRepairBlock(ctx) {
484
+ const r = ctx && ctx.formRepair;
485
+ if (!r) return '';
486
+ const lines = (Array.isArray(r.errors) ? r.errors : [])
487
+ .map((e) => `- ${e && e.path ? `${e.path}: ` : ''}${(e && e.message) || 'invalid'}`)
488
+ .join('\n');
489
+ return (
490
+ '\n\n## Your form ask was refused\n\n' +
491
+ `Form \`${r.form}\`:\n${lines || '- invalid'}\n\n` +
492
+ (r.schema ? 'data schema:\n```json\n' + JSON.stringify(r.schema, null, 2) + '\n```\n\n' : '') +
493
+ `Write a corrected {"form","data"} to: ${r.file}\n`
494
+ );
495
+ }
496
+
432
497
  /**
433
498
  * Ask-then-resume prompt block for a questions-enabled node (spec 2026-07-11).
434
499
  * Appended by runOpts, so EVERY producer/verifier runner inherits it with no
@@ -447,16 +512,28 @@ export function questionsPromptBlock(ctx) {
447
512
  const answered = prior.length
448
513
  ? '## Already answered — DO NOT ask these again\n\n' + renderAnswers(prior) + '\n'
449
514
  : '';
515
+ // Form answers already collected for this node, in the exact shape the agent
516
+ // receives them (spec §4). '' when there are none, so the legacy block is
517
+ // byte-identical.
518
+ const priorForms = Array.isArray(ctx.formAnswers) ? ctx.formAnswers : [];
519
+ const formAnswered = priorForms.length
520
+ ? '## Your form answers\n\n' + priorForms
521
+ .map((f) => '```json\n' + JSON.stringify({ form: f.form, version: f.version, values: f.values }, null, 2) + '\n```')
522
+ .join('\n\n') + '\n\n'
523
+ : '';
524
+ const forms = askFormsBlock(ctx.askForms, { path: ctx.questionsFile });
450
525
  if (!ctx.questionsFile) {
451
526
  return (
452
- '\n\n' + answered +
527
+ '\n\n' + answered + formAnswered +
453
528
  '## Asking the user\n\n' +
454
529
  'No more question rounds are available this run — proceed with reasonable assumptions.\n'
455
530
  );
456
531
  }
457
- const mock = prior.length ? '' : mockMarkers({ MOCK_ASK: ctx.questionsFile }) + '\n';
532
+ // MOCK_ASK only while NOTHING has been answered yet — legacy OR form: a resume that
533
+ // re-emitted it would make the offline mock re-ask every round until the cap.
534
+ const mock = (prior.length || priorForms.length) ? '' : mockMarkers({ MOCK_ASK: ctx.questionsFile }) + '\n';
458
535
  return (
459
- '\n\n' + answered +
536
+ '\n\n' + answered + formAnswered +
460
537
  '## Asking the user (enabled)\n\n' +
461
538
  'If a decision materially shapes the outcome and you cannot resolve it from the task, ' +
462
539
  'the inputs, or the codebase — including anything material you are about to silently ' +
@@ -466,6 +543,7 @@ export function questionsPromptBlock(ctx) {
466
543
  '2. STOP immediately — do no further work. You will be resumed with the answers.\n' +
467
544
  'Assume freely on minor choices; on material ones, ask instead of assuming. Never pad, ' +
468
545
  'and never re-ask an answered question.\n\n' +
546
+ (forms ? forms + '\n' : '') +
469
547
  mock
470
548
  );
471
549
  }
@@ -495,7 +573,8 @@ export function runOpts(ctx, { role, prompt, systemPrompt, allowedTools }) {
495
573
  return {
496
574
  cwd: ctx.projectDir,
497
575
  systemPrompt,
498
- prompt: (ctx.resumeSessionId ? RESUME_HEADER + prompt : prompt) + questionsPromptBlock(ctx),
576
+ prompt: (ctx.resumeSessionId ? RESUME_HEADER + prompt : prompt)
577
+ + questionsPromptBlock(ctx) + formRepairBlock(ctx),
499
578
  resumeSessionId: ctx.resumeSessionId,
500
579
  // Grant the role's baseline tools PLUS whatever the agent declared in its
501
580
  // frontmatter (e.g. the Playwright MCP browser_* tools). ctx.node is present
@@ -515,6 +594,12 @@ export function runOpts(ctx, { role, prompt, systemPrompt, allowedTools }) {
515
594
  // by the real runner (it is never a spawn flag).
516
595
  workspaceWriteTargets: workspaceWriteTargetsFor(ctx),
517
596
  permissionMode: c.permissionMode || 'acceptEdits',
597
+ // Agent memory (memory-write-split design D4): the writable copy lives OUTSIDE the cwd, so it
598
+ // rides --add-dir — acceptEdits auto-approves edits "inside your working directory or
599
+ // additionalDirectories" (documented), nothing else about the spawn changes, and the CLI loads
600
+ // nothing from that dir (CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD is never set here).
601
+ // undefined when the run has no mount ⇒ buildClaudeArgs emits nothing and the argv is byte-identical.
602
+ addDirs: typeof ctx.memoryMount === 'string' && ctx.memoryMount ? [ctx.memoryMount] : undefined,
518
603
  model: c.model,
519
604
  effort: c.effort, // per-role effort from the orchestrator
520
605
  // Per-model routing env (design §4.4), resolved HERE — the one funnel every
@@ -524,7 +609,11 @@ export function runOpts(ctx, { role, prompt, systemPrompt, allowedTools }) {
524
609
  // env byte-identical. The sub-agent model policy deliberately does NOT
525
610
  // touch this env: its only wire is the prompt block (subagentModelDirective),
526
611
  // and CLAUDE_CODE_SUBAGENT_MODEL is a reserved model-env key.
527
- modelEnv: resolveModelEnv(c.model),
612
+ modelEnv: resolveDispatchModelEnv(c, ctx),
613
+ // Model bridge (model-bridge-design.md §5.3): a translated model has no
614
+ // server-side web tools, so the runner withholds them. undefined for every
615
+ // non-bridged model ⇒ nothing emitted ⇒ argv byte-identical.
616
+ disallowedTools: bridgedModelInfo(c.model)?.excludeTools,
528
617
  // Agent memory (§4.3): Task-tool sub-agents inherit the rules natively but not
529
618
  // --append-system-prompt, so the pointer block rides the sub-agent flag. undefined when the
530
619
  // run has no mount ⇒ buildClaudeArgs emits nothing and legacy argv stays byte-identical.
@@ -9,10 +9,27 @@
9
9
  //
10
10
  // API 2 adds the chatChannels contribution + persistent channel worker
11
11
  // protocol; API 3 changes the DATA contract: agent sidecars are meta v2 (typed
12
- // ports) and workflows/*.json are v2 graphs. The task-source connector and the
13
- // channel-worker protocols are unchanged across 1 -> 2 -> 3, so a connector-only
14
- // ">=1 <2" plugin and a chat plugin's ">=2 <3" keep negotiating 1 and 2 and keep
15
- // working untouched. The set is what makes that possible: never collapse it to a
16
- // single integer.
17
- export const WORCA_PLUGIN_API = 3;
18
- export const WORCA_PLUGIN_APIS = [1, 2, 3];
12
+ // ports) and workflows/*.json are v2 graphs. API 4 adds ONE thing: an agent
13
+ // sidecar's `ask` block (declared question forms) is honoured. It does NOT
14
+ // touch the sidecar or template contract, which is why WORCA_AGENT_DATA_API
15
+ // exists and is 3 — comparing the data contract against WORCA_PLUGIN_API would
16
+ // silently demote every current ">=3 <4" plugin's v1 sidecars from error to
17
+ // warning on this bump. The task-source connector and the channel-worker
18
+ // protocols are unchanged across 1 -> 2 -> 3 -> 4, so a connector-only
19
+ // ">=1 <2" plugin and a chat plugin's ">=2 <3" keep negotiating 1 and 2 and
20
+ // keep working untouched. The set is what makes that possible: never collapse
21
+ // it to a single integer.
22
+ export const WORCA_PLUGIN_API = 4;
23
+ export const WORCA_PLUGIN_APIS = [1, 2, 3, 4];
24
+
25
+ /** The API whose DATA contract agent sidecars and pipeline templates must meet:
26
+ * meta v2 (typed ports) + version-2 graphs. Introduced by API 3, UNCHANGED by
27
+ * 4. `validatePluginDir`'s hard/soft split and the apiMismatch sentence are
28
+ * about THIS, never about the newest host API. */
29
+ export const WORCA_AGENT_DATA_API = 3;
30
+
31
+ /** The API a plugin must NEGOTIATE (highest member its range admits) for the
32
+ * `ask` blocks of the agents it ships to be honoured. Below it the block is
33
+ * stripped at load and reported as an ignored contribution; the agent keeps
34
+ * working with generic questions (ask-forms spec §10). */
35
+ export const WORCA_ASK_FORMS_API = 4;
@@ -6,11 +6,15 @@
6
6
  import { readFileSync, readdirSync, readlinkSync, existsSync } from 'node:fs';
7
7
  import { join, resolve, dirname, sep, isAbsolute } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { WORCA_PLUGIN_API, WORCA_PLUGIN_APIS } from './plugin-api.mjs';
10
- import { EFFORTS, isReservedModelEnvKey, assertModelCost } from './model-env.mjs';
9
+ import { WORCA_PLUGIN_API, WORCA_PLUGIN_APIS, WORCA_AGENT_DATA_API, WORCA_ASK_FORMS_API } from './plugin-api.mjs';
10
+ import { EFFORTS, isReservedModelEnvKey, assertModelCost, assertModelUpstream, upstreamEnvConflict } from './model-env.mjs';
11
11
  import { validateMetaV2, normalizeAgentMeta, indexByKey } from '../shared/graph/agent-meta.mjs';
12
12
  import { portsFnFor } from '../shared/graph/ports.mjs';
13
13
  import { validateGraph } from '../shared/graph/validate.mjs';
14
+ import { validateScriptMetaV2, normalizeScriptMeta } from '../shared/graph/script-meta.mjs';
15
+ import { normalizeCases } from '../shared/graph/script-cases.mjs';
16
+ import { normalizeAskBlock, validateFormDef } from '../shared/forms/form-def.mjs';
17
+ import { ASK_LIMITS } from '../shared/forms/catalog.mjs';
14
18
 
15
19
  /** Plugin names are kebab-case, machine-unique, dir-name safe (spec §4.1). */
16
20
  export const PLUGIN_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -51,12 +55,32 @@ export function builtinAgentMetas(dir = BUILTIN_AGENTS_DIR) {
51
55
  }
52
56
  return out;
53
57
  }
58
+ /** The built-in script layer (repo scripts/, D20). Same URL math as script-registry's DEFAULT_SCRIPTS_DIR. */
59
+ const BUILTIN_SCRIPTS_DIR = fileURLToPath(new URL('../../scripts/', import.meta.url));
60
+
61
+ /** Normalized built-in script sidecars, for the plugin-template isolation rule: a
62
+ * template may reference built-in scripts (present on every host) plus the plugin's own. */
63
+ export function builtinScriptMetas(dir = BUILTIN_SCRIPTS_DIR) {
64
+ const out = [];
65
+ let files = [];
66
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.meta.json')); } catch { return out; }
67
+ for (const f of files.sort()) {
68
+ let meta = null;
69
+ try { meta = JSON.parse(readFileSync(join(dir, f), 'utf8')); } catch { continue; }
70
+ if (Number(meta?.metaVersion) !== 2 || !KEY_RE.test(String(meta.key || ''))) continue;
71
+ const { meta: norm, errors } = normalizeScriptMeta(meta);
72
+ if (errors.length) continue;
73
+ out.push(norm);
74
+ }
75
+ return out;
76
+ }
77
+
54
78
  const KNOWN_SOURCE = new Set(['id', 'displayName', 'module', 'configSchema', 'inputs', 'multiProfile']);
55
79
  const KNOWN_CHANNEL = new Set(['id', 'displayName', 'platform', 'module', 'ingress', 'capabilities', 'configSchema']);
56
80
  const CHANNEL_INGRESS = new Set(['connect', 'webhook']);
57
81
  const KNOWN_FIELD = new Set(['key', 'type', 'label', 'secret', 'required', 'default', 'help', 'options']);
58
82
  const KNOWN_INPUT = new Set(['key', 'type', 'label', 'default', 'optionsFrom', 'options']);
59
- const KNOWN_MODEL = new Set(['id', 'label', 'efforts', 'env', 'cost']);
83
+ const KNOWN_MODEL = new Set(['id', 'label', 'efforts', 'env', 'cost', 'upstream']);
60
84
  const KNOWN_MODEL_SECRET = new Set(['key', 'label']);
61
85
  /** A manifest env value that defers to the plugin's secrets store (design §9.1). */
62
86
  const isSecretRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
@@ -110,6 +134,13 @@ export function negotiatedApi(range, apis = WORCA_PLUGIN_APIS) {
110
134
  export const NOT_META_V2 = 'not a meta v2 sidecar (declare "metaVersion": 2 with typed inputs/outputs) — plugin API 3 no longer reads channel sidecars';
111
135
  export const NOT_GRAPH_V2 = 'not a version-2 graph template (nodes/wires) — port the "steps" pipeline';
112
136
 
137
+ /** The ONE sentence for a plugin that ships agent ask forms without negotiating
138
+ * the API that honours them. Used verbatim by `agent-registry.scanLayer` (the
139
+ * load-time strip) and by `validatePluginDir` (the author-time warning), so the
140
+ * two can never drift — never re-word it in a second place. */
141
+ export const ASK_NEEDS_API_4 = `ask forms need plugin API ${WORCA_ASK_FORMS_API}`
142
+ + ' (declare "engines": { "worca-cc-api": ">=4 <5" }) — the ask block is ignored and the agent keeps generic questions';
143
+
113
144
  /** The host API a range was BUILT FOR: the lowest integer it accepts. ">=1 <2"
114
145
  * and "1" both answer 1; an unconstrained range answers 0; null when nothing
115
146
  * satisfies it (an unparseable range fails closed in apiSatisfies too).
@@ -128,6 +159,7 @@ export function declaredApi(range) {
128
159
  export function dataContractIssues(absDir) {
129
160
  const agentsV1 = [];
130
161
  const workflowsV1 = [];
162
+ const scriptsV1 = [];
131
163
  const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
132
164
  const agentsDir = join(absDir, 'agents');
133
165
  if (existsSync(agentsDir)) {
@@ -136,6 +168,13 @@ export function dataContractIssues(absDir) {
136
168
  if (raw && Number(raw.metaVersion) !== 2) agentsV1.push(f);
137
169
  }
138
170
  }
171
+ const scDir = join(absDir, 'scripts');
172
+ if (existsSync(scDir)) {
173
+ for (const f of readdirSync(scDir).filter((x) => x.endsWith('.meta.json')).sort()) {
174
+ const raw = read(join(scDir, f));
175
+ if (raw && Number(raw.metaVersion) !== 2) scriptsV1.push(f);
176
+ }
177
+ }
139
178
  const wfDir = join(absDir, 'workflows');
140
179
  if (existsSync(wfDir)) {
141
180
  for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json')).sort()) {
@@ -143,7 +182,7 @@ export function dataContractIssues(absDir) {
143
182
  if (raw && Number(raw.version) !== 2) workflowsV1.push(f);
144
183
  }
145
184
  }
146
- return { agentsV1, workflowsV1 };
185
+ return { agentsV1, workflowsV1, scriptsV1 };
147
186
  }
148
187
 
149
188
  /**
@@ -159,10 +198,11 @@ export function dataContractIssues(absDir) {
159
198
  export function apiMismatch(range, issues) {
160
199
  const agents = (issues && issues.agentsV1 ? issues.agentsV1 : []).length;
161
200
  const workflows = (issues && issues.workflowsV1 ? issues.workflowsV1 : []).length;
162
- if (!agents && !workflows) return null;
201
+ const scripts = (issues && issues.scriptsV1 ? issues.scriptsV1 : []).length;
202
+ if (!agents && !workflows && !scripts) return null;
163
203
  // declaredApi('') is 0 (an unconstrained range accepts everything); report that
164
204
  // as null so the message reads "built for an older version", never "API 0".
165
- const mismatch = { builtFor: declaredApi(range) || null, host: WORCA_PLUGIN_API, agents, workflows };
205
+ const mismatch = { builtFor: declaredApi(range) || null, host: WORCA_PLUGIN_API, agents, workflows, ...(scripts ? { scripts } : {}) };
166
206
  mismatch.message = apiMismatchMessage(mismatch);
167
207
  return mismatch;
168
208
  }
@@ -176,10 +216,10 @@ export function apiMismatch(range, issues) {
176
216
  */
177
217
  export function apiMismatchMessage(mismatch) {
178
218
  if (!mismatch) return '';
179
- const { builtFor, agents, workflows } = mismatch;
219
+ const { builtFor, agents, workflows, scripts } = mismatch;
180
220
  return `built for plugin API ${builtFor ?? 'an older version'}; this version of worca requires `
181
- + `plugin API ${WORCA_PLUGIN_API} for agents and pipeline templates — update or reinstall the plugin `
182
- + `(${agents} agent(s), ${workflows} template(s) ignored)`;
221
+ + `plugin API ${WORCA_AGENT_DATA_API} for agents and pipeline templates — update or reinstall the plugin `
222
+ + `(${agents} agent(s), ${scripts ? `${scripts} script(s), ` : ''}${workflows} template(s) ignored)`;
183
223
  }
184
224
 
185
225
  const str = (v, d = '') => (typeof v === 'string' ? v.trim() : d);
@@ -467,11 +507,28 @@ export function normalizeManifest(raw, { dir = '' } = {}) {
467
507
  errors.push(`${at} ("${id}"): ${e.message}`);
468
508
  return;
469
509
  }
510
+ // A bridged model (model-bridge-design.md §6.4): same shape and validator
511
+ // as a global entry. A plugin cannot ship provider credentials — a
512
+ // `copilot` entry resolves against the user's own sign-in, and an
513
+ // `openai`/`anthropic` apiKey may only be a ${VAR} ref, never a literal.
514
+ let upstream;
515
+ try {
516
+ upstream = assertModelUpstream(m.upstream);
517
+ if (upstream && upstream.apiKey && !/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(upstream.apiKey)) {
518
+ throw new Error('upstream.apiKey in a plugin must be a ${VAR} reference, never a literal key');
519
+ }
520
+ const clash = upstream ? upstreamEnvConflict(env) : null;
521
+ if (clash) { warnings.push(`${at} ("${id}"): env key ${JSON.stringify(clash)} is set by the bridge for an upstream entry — ignored`); delete env[clash]; }
522
+ } catch (e) {
523
+ errors.push(`${at} ("${id}"): ${e.message}`);
524
+ return;
525
+ }
470
526
  models.push({
471
527
  id, label: str(m.label) || id,
472
528
  efforts: efforts.length ? efforts : [...EFFORTS],
473
529
  ...(Object.keys(env).length ? { env } : {}),
474
530
  ...(cost ? { cost } : {}),
531
+ ...(upstream ? { upstream } : {}),
475
532
  });
476
533
  });
477
534
  const mids = models.map((m) => m.id.toLowerCase());
@@ -569,13 +626,13 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
569
626
  // "this plugin does not ship it" or the derived V4/V5/V20/V21 cascade.
570
627
  const ungatedKeys = new Set();
571
628
  const ownMetas = [];
572
- // A range that admits the CURRENT API (or no engines at all) claims to be an
573
- // API-3 plugin, so v1-shaped data is a hard error; --strict promotes it for
574
- // everyone else, because that flag is the plugin AUTHOR's gate. A manifest
575
- // that did not PARSE fails SOFT: its declared API is unknowable, and the JSON
576
- // error above is already the only actionable line.
629
+ // A range that admits the DATA-CONTRACT API (or no engines at all) claims to
630
+ // ship meta v2 sidecars and v2 graphs, so v1-shaped data is a hard error;
631
+ // --strict promotes it for everyone else, because that flag is the plugin
632
+ // AUTHOR's gate. Compared against WORCA_AGENT_DATA_API, never the newest host
633
+ // API: an API-5 host must not make today's ">=3 <4" plugins soft again.
577
634
  const hardData = raw !== null
578
- && (strict || negotiatedApi(raw && raw.engines ? raw.engines['worca-cc-api'] : '') === WORCA_PLUGIN_API);
635
+ && (strict || (negotiatedApi(raw && raw.engines ? raw.engines['worca-cc-api'] : '') ?? -1) >= WORCA_AGENT_DATA_API);
579
636
  const dataLevel = hardData ? 'error' : 'warn';
580
637
  const agentsDir = join(absDir, 'agents');
581
638
  if (existsSync(agentsDir)) {
@@ -602,6 +659,51 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
602
659
  // as shipped is what let a ports-less node reach validateGraph.
603
660
  agentKeys.add(key);
604
661
  ownMetas.push(normalizeAgentMeta(meta).meta);
662
+
663
+ // ── ask forms (spec §10) ────────────────────────────────────────────────
664
+ // The SAME gate 1 the registry, the agent store and the Agents view run.
665
+ // Level: a bad form is a WARNING, because the host's behaviour for one is
666
+ // already defined (it is dropped at load and the agent keeps generic
667
+ // questions), and a plugin whose connector is fine must stay installable.
668
+ // --strict is the AUTHOR's gate, so there it is an error. This split is
669
+ // keyed on `strict` alone, NOT on dataLevel: a form's validity has nothing
670
+ // to do with which API the plugin negotiates.
671
+ if (meta.ask !== undefined && meta.ask !== null) {
672
+ const askLevel = strict ? 'error' : 'warn';
673
+ // normalizeAskBlock is used ONLY for what it alone knows: a whole-block
674
+ // refusal (id '*'). Its per-form `reason` is a collapsed one-liner of the
675
+ // first three errors — right for a registry log line, wrong here, where
676
+ // the author wants every failed rule with its path. So each form goes
677
+ // through validateFormDef directly.
678
+ const blockDrops = normalizeAskBlock(meta.ask).dropped.filter((d) => d.id === '*');
679
+ let declaredForms = 0;
680
+ if (blockDrops.length) {
681
+ for (const d of blockDrops) push(askLevel, `agents/${f}: ask: ${d.reason}`);
682
+ } else {
683
+ const declared = meta.ask.forms && typeof meta.ask.forms === 'object' ? meta.ask.forms : {};
684
+ const ids = Object.keys(declared);
685
+ declaredForms = ids.length;
686
+ if (ids.length > ASK_LIMITS.formsPerAgent) {
687
+ push(askLevel, `agents/${f}: ask.forms: at most ${ASK_LIMITS.formsPerAgent} forms per agent`);
688
+ }
689
+ for (const id of ids) {
690
+ // P1 addresses layout items as `layout#<n>` (depth-first, 1-based);
691
+ // print the path exactly as it comes so the Plugins card, the 422
692
+ // body and this line all name the same item.
693
+ for (const e of validateFormDef(declared[id], { id }).errors) {
694
+ push(askLevel, `agents/${f}: ask.forms."${id}"${e.path ? ` ${e.path}` : ''}: ${e.message}`);
695
+ }
696
+ }
697
+ }
698
+ // A plugin may ship forms it knows an older host will ignore; say so
699
+ // once, by name, and never fail the validate for it. ONE sentence,
700
+ // exported so the load-time strip and this line can never drift. Only
701
+ // when the block DECLARES a form: an empty `forms` map loads as "no
702
+ // forms" (normalizeAgentMeta drops it) and the host ignores nothing.
703
+ if (declaredForms && !(Number(negotiatedApi(raw && raw.engines ? raw.engines['worca-cc-api'] : '')) >= WORCA_ASK_FORMS_API)) {
704
+ push('warn', `agents/${f}: ${ASK_NEEDS_API_4}`);
705
+ }
706
+ }
605
707
  }
606
708
  for (const f of files.filter((x) => x.endsWith('.md'))) {
607
709
  const stem = f.slice(0, -3);
@@ -611,6 +713,61 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
611
713
  }
612
714
  }
613
715
 
716
+ // scripts/: <key>.meta.json + the program it names (spec §9.3). The SAME meta
717
+ // v2 gate the registry loader applies, every failed rule named; every platform
718
+ // entry of `file` must exist inside scripts/ and never escape it.
719
+ const scriptKeys = new Set();
720
+ const ungatedScriptKeys = new Set();
721
+ const ownScriptMetas = [];
722
+ const scriptsDir = join(absDir, 'scripts');
723
+ if (existsSync(scriptsDir)) {
724
+ const files = readdirSync(scriptsDir);
725
+ for (const f of files.filter((x) => x.endsWith('.meta.json'))) {
726
+ const stem = f.slice(0, -'.meta.json'.length);
727
+ let meta = null;
728
+ try { meta = JSON.parse(readFileSync(join(scriptsDir, f), 'utf8')); }
729
+ catch { push('error', `scripts/${f}: invalid JSON`); continue; }
730
+ const key = typeof meta?.key === 'string' ? meta.key : '';
731
+ if (!KEY_RE.test(key)) { push('error', `scripts/${f}: "${key}" must be a valid script key (letters/digits/_-)`); continue; }
732
+ if (key !== stem) push('error', `scripts/${f}: key "${key}" must match the filename stem "${stem}"`);
733
+ if (Number(meta.metaVersion) !== 2) { push(dataLevel, `scripts/${f}: ${NOT_META_V2}`); ungatedScriptKeys.add(key); continue; }
734
+ const { errors } = validateScriptMetaV2(meta);
735
+ for (const e of errors) push('error', `scripts/${f}: ${e}`);
736
+ if (errors.length) { ungatedScriptKeys.add(key); continue; }
737
+ const norm = normalizeScriptMeta(meta).meta;
738
+ // Every platform's program must be a contained, existing file.
739
+ const entries = typeof norm.file === 'string' ? [norm.file] : norm.file ? Object.values(norm.file) : [];
740
+ let fileOk = true;
741
+ for (const rel of entries) {
742
+ if (isAbsolute(rel) || /[\\/]/.test(rel) || rel.includes('..') || !resolve(scriptsDir, rel).startsWith(resolve(scriptsDir) + sep)) {
743
+ push('error', `scripts/${f}: file must be a plain basename inside scripts/ (got "${rel}")`); fileOk = false; continue;
744
+ }
745
+ if (!existsSync(join(scriptsDir, rel))) { push('error', `scripts/${f}: file "${rel}" not found in scripts/`); fileOk = false; }
746
+ }
747
+ if (!fileOk) { ungatedScriptKeys.add(key); continue; }
748
+ scriptKeys.add(key);
749
+ ownScriptMetas.push(norm);
750
+ // <key>.tests.json is optional; when it ships it goes through the SAME
751
+ // normalizer the store, the bench and the UI use, as a SHIPPED set — a
752
+ // case that names a project folder cannot travel to a recipient (W9).
753
+ const casesFile = join(scriptsDir, `${key}.tests.json`);
754
+ if (existsSync(casesFile)) {
755
+ let rawCases = null;
756
+ try { rawCases = JSON.parse(readFileSync(casesFile, 'utf8')); }
757
+ catch { push('error', `scripts/${key}.tests.json: invalid JSON`); continue; }
758
+ for (const e of normalizeCases(rawCases, norm, { shipped: true }).errors) {
759
+ push('error', `scripts/${key}.tests.json: ${e}`);
760
+ }
761
+ }
762
+ }
763
+ // A tests file with no sidecar beside it: the W18 overlay is a USER-layer
764
+ // idea, so inside a plugin it is a file nothing will ever read.
765
+ for (const f of files.filter((x) => x.endsWith('.tests.json'))) {
766
+ const stem = f.slice(0, -'.tests.json'.length);
767
+ if (!files.includes(`${stem}.meta.json`)) push('error', `scripts/${f}: no ${stem}.meta.json beside it`);
768
+ }
769
+ }
770
+
614
771
  // skills/<name>/SKILL.md required (rides the existing injection mechanism, §9.2)
615
772
  const skillsDir = join(absDir, 'skills');
616
773
  if (existsSync(skillsDir)) {
@@ -634,16 +791,19 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
634
791
  if (existsSync(wfDir)) {
635
792
  const hostMetas = Array.isArray(builtinMetas) ? builtinMetas : builtinAgentMetas();
636
793
  const hostKeys = new Set(hostMetas.map((m) => m.key));
637
- const portsFn = portsFnFor(indexByKey([...hostMetas, ...ownMetas]));
794
+ const hostScriptMetas = builtinScriptMetas();
795
+ const hostScriptKeys = new Set(hostScriptMetas.map((m) => m.key));
796
+ const portsFn = portsFnFor(indexByKey([...hostMetas, ...ownMetas]), indexByKey([...hostScriptMetas, ...ownScriptMetas]));
638
797
  for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json'))) {
639
798
  let tpl = null;
640
799
  try { tpl = JSON.parse(readFileSync(join(wfDir, f), 'utf8')); }
641
800
  catch { push('error', `workflows/${f}: invalid JSON`); continue; }
642
801
  if (Number(tpl?.version) !== 2) { push(dataLevel, `workflows/${f}: ${NOT_GRAPH_V2}`); continue; }
643
802
  const nodes = Array.isArray(tpl.nodes) ? tpl.nodes : [];
644
- const keys = nodes.filter((n) => n && n.kind === 'agent').map((n) => n.key).filter(Boolean);
803
+ const agentRefs = nodes.filter((n) => n && n.kind === 'agent').map((n) => n.key).filter(Boolean);
804
+ const scriptRefs = nodes.filter((n) => n && n.kind === 'script').map((n) => n.key).filter(Boolean);
645
805
  let unresolved = false;
646
- for (const k of new Set(keys)) {
806
+ for (const k of new Set(agentRefs)) {
647
807
  if (agentKeys.has(k) || hostKeys.has(k)) continue;
648
808
  if (ungatedKeys.has(k)) {
649
809
  // The sidecar is this plugin's, it just did not pass. Report at the
@@ -655,6 +815,12 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
655
815
  }
656
816
  unresolved = true;
657
817
  }
818
+ for (const k of new Set(scriptRefs)) {
819
+ if (scriptKeys.has(k) || hostScriptKeys.has(k)) continue;
820
+ if (ungatedScriptKeys.has(k)) push(dataLevel, `workflows/${f}: references script key "${k}" whose sidecar is not a valid meta v2 sidecar`);
821
+ else push('error', `workflows/${f}: references script key "${k}" which is neither a built-in nor shipped by this plugin`);
822
+ unresolved = true;
823
+ }
658
824
  // An unresolved key has no ports, so every wire touching it would also
659
825
  // fire V4/V5 — one clear cause beats five derived ones.
660
826
  if (unresolved) continue;
@@ -50,6 +50,7 @@ export function allPluginModels() {
50
50
  plugin: name, id: m.id, label: m.label, efforts: [...m.efforts],
51
51
  ...(m.env ? { env: m.env } : {}),
52
52
  ...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing (config.mjs modelCostConfig)
53
+ ...(m.upstream ? { upstream: m.upstream } : {}), // bridged entry (bridge/registry.mjs)
53
54
  secrets: [...new Set(secrets)],
54
55
  });
55
56
  }
@@ -0,0 +1,118 @@
1
+ // src/core/plugin-script-cases.mjs
2
+ // `worca plugin validate <dir> --run-cases` (spec §8.1): run every case a plugin
3
+ // dir SHIPS through the one bench the app and the CLI use, against the dir's own
4
+ // scripts and a scratch cwd. Deliberately NOT inside plugin-manifest.mjs:
5
+ // validatePluginDir is the cheap synchronous gate every install path calls, and
6
+ // it must never spawn a child process.
7
+
8
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ import { loadScriptRegistry } from './script-registry.mjs';
12
+ import { runBenchOnce } from './script-bench.mjs';
13
+ import { normalizeCases, evaluateExpect } from '../shared/graph/script-cases.mjs';
14
+
15
+ /** One case through the bench: the dir's registry, a scratch cwd, always. */
16
+ async function runOneCase(key, kase, registry, deps) {
17
+ const request = {
18
+ key,
19
+ params: kase.params || {},
20
+ inputs: kase.inputs || {},
21
+ cwd: { kind: 'scratch' },
22
+ ...(kase.ports ? { ports: kase.ports } : {}),
23
+ ...(kase.timeoutMs ? { timeoutMs: kase.timeoutMs } : {}),
24
+ };
25
+ const benchDeps = { registry, agentKeys: [] };
26
+ // The caller's hooks, per case: the live bench (so a signal can stop the child
27
+ // tree) and the streamed lines, tagged with the script the case belongs to.
28
+ if (typeof deps.onBench === 'function') benchDeps.onBench = deps.onBench;
29
+ if (typeof deps.onLine === 'function') {
30
+ benchDeps.onLine = (ev) => deps.onLine({ key, caseId: kase.id, stream: ev && ev.stream, text: ev && ev.text });
31
+ }
32
+ if (deps.runner) benchDeps.runner = deps.runner;
33
+ if (deps.home) benchDeps.home = deps.home;
34
+ if (deps.platform) benchDeps.platform = deps.platform;
35
+ try {
36
+ return await runBenchOnce(request, benchDeps);
37
+ } catch (e) {
38
+ // A transport-level refusal (a bad request, the bench cap) IS this case's
39
+ // result: --run-cases reports per case and never throws at the caller.
40
+ return {
41
+ status: 'error', exitCode: null, runtime: null, durationMs: 0, summary: '', warnings: [],
42
+ fired: [], outputs: {}, verdict: null, envelopePath: null,
43
+ error: { message: e && e.message ? e.message : String(e), tail: [] },
44
+ expect: null, draft: false,
45
+ };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Run every shipped case of every script under `<absDir>/scripts`.
51
+ * @param {string} absDir the plugin folder
52
+ * @param {{runner?: Function, home?: string, platform?: string,
53
+ * onBench?: (bench: object) => void,
54
+ * onLine?: (ev: {key: string, caseId: string, stream: string, text: string}) => void,
55
+ * stopRequested?: () => boolean}} [deps]
56
+ * onBench receives each case's live bench (call `stop()` on it); stopRequested is
57
+ * asked before every case. A STOPPED case fails and ends the batch: `stopped` is
58
+ * true and the cases not reached are absent from the rows.
59
+ * @returns {Promise<{scripts: Array<{key: string, runtime: string, cases: Array<{
60
+ * caseId: string, name: string, status: string, checked: boolean, pass: boolean,
61
+ * diffs: string[], durationMs: number}>}>, passed: number, failed: number,
62
+ * unchecked: number, stopped: boolean, problems: string[]}>}
63
+ */
64
+ export async function runPluginScriptCases(absDir, deps = {}) {
65
+ const dir = join(absDir, 'scripts');
66
+ const out = { scripts: [], passed: 0, failed: 0, unchecked: 0, stopped: false, problems: [] };
67
+ const stopRequested = typeof deps.stopRequested === 'function' ? deps.stopRequested : () => false;
68
+ if (!existsSync(dir)) return out;
69
+ // The plugin dir IS the registry for this run: its scripts load as the only
70
+ // layer. agentKeys is empty on purpose — D16 drops a script whose key a HOST
71
+ // agent holds, which says nothing about a folder that is only being linted.
72
+ const registry = loadScriptRegistry({
73
+ scriptsDir: dir, userScriptsDir: null, includePlugins: false, agentKeys: [],
74
+ });
75
+ for (const f of readdirSync(dir).filter((x) => x.endsWith('.meta.json')).sort()) {
76
+ const key = f.slice(0, -'.meta.json'.length);
77
+ const meta = registry[key];
78
+ if (!meta) continue; // validatePluginDir already named it
79
+ const casesFile = join(dir, `${key}.tests.json`);
80
+ if (!existsSync(casesFile)) continue;
81
+ let raw = null;
82
+ try { raw = JSON.parse(readFileSync(casesFile, 'utf8')); }
83
+ catch (e) { out.problems.push(`scripts/${key}.tests.json: invalid JSON (${e.message})`); continue; }
84
+ const { cases, errors } = normalizeCases(raw, meta, { shipped: true });
85
+ for (const e of errors) out.problems.push(`scripts/${key}.tests.json: ${e}`);
86
+ if (!cases.length) continue;
87
+ const row = { key, runtime: meta.runtime, cases: [] };
88
+ for (const kase of cases) {
89
+ if (stopRequested()) { out.stopped = true; break; }
90
+ const result = await runOneCase(key, kase, registry, deps);
91
+ const ev = evaluateExpect(kase.expect, result);
92
+ const checked = !!ev;
93
+ // `stopped` verified nothing either: it fails, and it ends the batch below.
94
+ const broke = result.status === 'error' || result.status === 'timeout' || result.status === 'stopped';
95
+ // An UNCHECKED case that could not run is still a failure: a shipped
96
+ // script that throws is broken whether or not it declared an expectation.
97
+ // A STOPPED case never passes, checked or not: an expectation that names no
98
+ // verdict (`{ fired: [] }`) is SATISFIED by a run that was cut short.
99
+ const stopped = result.status === 'stopped';
100
+ const pass = !stopped && (checked ? ev.pass : !broke);
101
+ // WHY it broke rides along either way: "expected clean, got error" alone sends
102
+ // the author to the bench to learn that python is missing on the CI host.
103
+ const why = broke ? [result.error ? result.error.message : result.status] : [];
104
+ const diffs = checked ? [...ev.diffs, ...(ev.pass && !stopped ? [] : why)] : why;
105
+ if (!pass) out.failed += 1;
106
+ else if (checked) out.passed += 1;
107
+ else out.unchecked += 1;
108
+ row.cases.push({
109
+ caseId: kase.id, name: kase.name, status: result.status,
110
+ checked, pass, diffs, durationMs: result.durationMs || 0,
111
+ });
112
+ if (result.status === 'stopped') { out.stopped = true; break; }
113
+ }
114
+ if (row.cases.length) out.scripts.push(row);
115
+ if (out.stopped) break;
116
+ }
117
+ return out;
118
+ }