@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
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // The generic execution layer of the node-graph engine: output allocation from
4
4
  // filename templates, the "## Ports (this run)" prompt block, prompt assembly, the
5
- // clarifier gate, and the five flow-node executors.
5
+ // clarifier gate, the script dispatch and the five flow-node executors.
6
6
  //
7
7
  // GENERICITY CHARTER (hard rule for this module): there is NO agent-key branch
8
8
  // anywhere. Executor selection is `node.kind` + `meta.runnerType`; renderer selection
@@ -32,22 +32,25 @@
32
32
  // input left UNBOUND. The composite DRIVER is scheduler.mjs; this module owns the
33
33
  // document — including the prompt block that tells a producer where to write the
34
34
  // task files and what the manifest looks like.
35
- import { join, dirname, relative, basename } from 'node:path';
36
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
35
+ import { join, dirname, relative } from 'node:path';
36
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
37
37
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
38
38
 
39
39
  import {
40
40
  runClaude, MOCK_WRITER_ROLES, MOCK_ROLE_CLARIFY, MOCK_ROLE_DECOMPOSER,
41
41
  } from '../claude-runner.mjs';
42
42
  import { planPath, reviewPath, writeStepQuestions, writeClarify } from '../artifacts.mjs';
43
- import { readReview, normalizeClarify, normalizeReview, safeParseJson } from '../protocol.mjs';
43
+ import { readReview, classifyAskPayload } from '../protocol.mjs';
44
+ import { prepareFormAsk, formAnswerValidator, downgradeQuestion } from '../ask-forms.mjs';
44
45
  import {
45
46
  taskHeader, buildSystemPrompt, resolveAgentBody, mockMarkers, runOpts,
46
47
  fanOutDirective, ctxFanOut, ctxSubagentModel, ctxEndpointRouted, workspaceFanOutDirective, workspaceDiffInstruction,
47
48
  renderAnswers, siblingsBlock, diffInstruction, READ_WRITE_TOOLS, IMPLEMENTER_TOOLS, MEMORY_TOOLS,
49
+ askFormsBlock,
48
50
  } from '../phases.mjs';
49
51
  import { SUBAGENT_MODELS } from '../model-env.mjs';
50
52
  import { AWAIT_PORT } from '../../shared/graph/constants.mjs';
53
+ import { runScriptExecution } from './script-runner.mjs';
51
54
 
52
55
  /** The reserved synthesized gate input. Scheduler-only: it never reaches `bindings`,
53
56
  * is never listed in the Ports block, selects no mode, and carries no renderer. */
@@ -382,68 +385,31 @@ export function resolveMockRole({ meta, expandsPort = null }) {
382
385
 
383
386
  // ── verdicts ──────────────────────────────────────────────────────────────────
384
387
 
385
- /** The text every unparseable verdict file fails with. */
386
- const BAD_VERDICT_TAIL = 'expected { "issues": [ \u2026 ] }';
387
-
388
- /**
389
- * Read a node's verdict JSON back through the protocol normalizer.
390
- *
391
- * Two degenerate cases, deliberately split (they are NOT the same failure):
392
- * - the file was NEVER WRITTEN -> `{issues: [], summary: '', missing: true}`: a clean
393
- * pass, v1 parity, because an agent that declares a verdict and writes none must not
394
- * fail a run. `missing` is the flag the caller turns into a warning (and the reason
395
- * the reviews table skips the row) instead of a phantom zero-issue review.
396
- * - the file EXISTS but does not parse, or carries no `issues` array -> THROW. The
397
- * verifier wrote garbage, and on every shipped seed the clean side is wired straight
398
- * to End, so "no issues" there is indistinguishable from an approval. Fail-fast owns
399
- * the rest.
400
- *
401
- * `readReview` is untouched for its other callers (it is v1 code with its own tolerant
402
- * contract); the existsSync + parse-failure branch lives here.
403
- */
404
- export async function readVerdict(verdictPath) {
405
- if (!verdictPath) return { issues: [], summary: '' };
406
- if (!existsSync(verdictPath)) return { issues: [], summary: '', missing: true };
407
- let text;
408
- try {
409
- text = await readFile(verdictPath, 'utf8');
410
- } catch (err) {
411
- throw Object.assign(new Error(`verdict file unreadable: ${verdictPath} — ${err?.message || err}`),
412
- { code: 'BAD_VERDICT' });
413
- }
414
- const data = safeParseJson(text);
415
- if (!data || typeof data !== 'object' || !Array.isArray(data.issues)) {
416
- throw Object.assign(new Error(`verdict file is not a review JSON: ${verdictPath} — ${BAD_VERDICT_TAIL}`),
417
- { code: 'BAD_VERDICT' });
418
- }
419
- return normalizeReview(data);
420
- }
421
-
422
- /** The warning line a missing verdict raises, relative to the pipeline dir so the
423
- * run log stays readable. */
424
- function missingVerdictWarning(ctx, verdictPath) {
425
- const rel = ctx?.pipelineDir ? relative(ctx.pipelineDir, verdictPath) : basename(verdictPath);
426
- return `verdict file missing: ${ctx?.nodeId || ctx?.node?.id || '?'} ${rel} — treated as clean`;
427
- }
388
+ import { readVerdict, missingVerdictWarning, publishable } from './exec-io.mjs';
389
+ export { readVerdict, publishable };
428
390
 
429
391
  // ── prompt assembly ───────────────────────────────────────────────────────────
430
392
 
431
393
  /** The role-free base instruction, keyed by runnerType — never by an agent key. The
432
394
  * producer/verifier sentences are v1's generic runners (phases.mjs:1207-1209 /
433
395
  * :1245-1246); the clarifier sentence is v1's buildClarifyPrompt (:581-587) minus
434
- * its parenthetical aside that named two builtin agents a generic graph need not have. */
435
- function baseInstruction(runnerType) {
396
+ * its parenthetical aside that named two builtin agents a generic graph need not have.
397
+ * §4: a clarifier that declares ask forms gets the forms section appended; one that
398
+ * declares none returns today's string byte for byte. */
399
+ function baseInstruction(runnerType, meta = null) {
436
400
  if (runnerType === 'verifier') {
437
401
  return 'You are a verifier. Inspect the inputs below exactly as your role instructions describe, ' +
438
402
  'then write a human-readable review markdown AND a machine-readable review JSON.';
439
403
  }
440
404
  if (runnerType === 'clarifier') {
441
- return 'Identify the decisions you cannot safely resolve from the task text or the real ' +
405
+ const clarify = 'Identify the decisions you cannot safely resolve from the task text or the real ' +
442
406
  'codebase — including things a downstream agent would otherwise silently assume. For ' +
443
407
  'each, produce one conceptual question with 2 to 4 options and a free-text fallback. Ask ' +
444
408
  'only what materially changes the plan (up to 8 questions); never pad, and never split one ' +
445
409
  'decision. For low-impact details, pick a sensible default rather than asking. If you have ' +
446
410
  'no material open questions, write { "questions": [] } to that same path.';
411
+ const forms = askFormsBlock(meta?.ask?.forms);
412
+ return forms ? `${clarify}\n\n${forms}` : clarify;
447
413
  }
448
414
  return 'You are a pipeline agent. Read every input below, do your job exactly as your role ' +
449
415
  'instructions describe, and write EVERY declared output to its exact path.';
@@ -544,7 +510,7 @@ export function buildAgentPrompt(ctx) {
544
510
  return (
545
511
  taskHeader(headerCtx, title) +
546
512
  '\n## What to do\n\n' +
547
- baseInstruction(meta.runnerType) + '\n\n' +
513
+ baseInstruction(meta.runnerType, meta) + '\n\n' +
548
514
  (hints ? hints + '\n\n' : '') +
549
515
  modeBlock(selectMode({ ports, bindings, freshPorts: trigger.freshPorts })) +
550
516
  fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: relative, subagentModel: ctxSubagentModel(ctx), endpointRouted: routed }) +
@@ -575,6 +541,16 @@ async function readPriorAnswers(ports, bindings = {}) {
575
541
  const path = port ? bindings[port.id]?.path : null;
576
542
  if (!path) return [];
577
543
  const json = await readJsonMaybe(path);
544
+ // A clarifier that answered with a FORM writes {form, version, values}; project
545
+ // it mechanically so a downstream `as:'answers'` port still renders something
546
+ // (E18). P4 may replace this with the shared text projection.
547
+ if (json && typeof json === 'object' && typeof json.form === 'string' && json.values && typeof json.values === 'object') {
548
+ return Object.entries(json.values).map(([field, value]) => ({
549
+ id: field,
550
+ question: field,
551
+ choice: typeof value === 'string' ? value : JSON.stringify(value),
552
+ }));
553
+ }
578
554
  return Array.isArray(json?.answers) ? json.answers : [];
579
555
  }
580
556
 
@@ -645,18 +621,6 @@ async function spawnAgent(full, { role, prompt, systemPrompt, allowedTools }) {
645
621
  return { text, sessionId };
646
622
  }
647
623
 
648
- /** The output map the scheduler publishes from: an entry per declared port, with a
649
- * path where one was allocated and an empty payload for void ports. Exported for
650
- * P4's composite `finish` arm. */
651
- export function publishable(ports, outputs) {
652
- const out = {};
653
- for (const port of ports?.outputs || []) {
654
- if (!port) continue;
655
- out[port.id] = outputs[port.id]?.path ? { path: outputs[port.id].path } : {};
656
- }
657
- return out;
658
- }
659
-
660
624
  /**
661
625
  * The ONE generic agent executor — the generalization of v1's runGenericProducer and
662
626
  * runGenericVerifier and of the nine bespoke runners they replace. Selected for
@@ -690,6 +654,9 @@ export async function runAgentExecution(ctx) {
690
654
  * row and the History UI render the full Q&A without a join.
691
655
  */
692
656
  function normalizeAnswers(payload, questions) {
657
+ // A form answer is `{form, version, values}` and is NEVER flattened here: the
658
+ // "fill with the first option" fallback below is legacy-kind only (spec §5).
659
+ if (payload && typeof payload === 'object' && typeof payload.form === 'string' && payload.values) return [];
693
660
  const arr = Array.isArray(payload?.answers) ? payload.answers : Array.isArray(payload) ? payload : [];
694
661
  const byId = new Map();
695
662
  for (const a of arr) if (a && a.id != null) byId.set(String(a.id), String(a.choice ?? ''));
@@ -700,6 +667,52 @@ function normalizeAnswers(payload, questions) {
700
667
  }));
701
668
  }
702
669
 
670
+ /**
671
+ * Gate 2 for a clarifier's `{form,data}` ask (spec §5). One repair spawn on a
672
+ * refusal — resumed on the SAME session (`sessionId` is the id captured off the
673
+ * FIRST spawn; the repair re-attaches it exactly as _questionsLoop resumes a
674
+ * producer), with formRepairBlock rendering the error list and the schema through
675
+ * runOpts — then a downgrade to one generic free-text question. Never throws. The
676
+ * returned `sessionId` is the repair's own when it reported one, else the first's.
677
+ * @returns {Promise<{ask:object|null, autoValues:object|null, questions:Array, sessionId:string|null}>}
678
+ */
679
+ async function prepareClarifierForm({ full, meta, node, ordinal, answersPath, payload, warnings, spawn, sessionId: first = null }) {
680
+ const askId = `${CLARIFY_ASK_KIND}-${node.id}-${ordinal}`;
681
+ let current = payload;
682
+ let sessionId = first || null;
683
+ for (let attempt = 1; attempt <= 2; attempt++) {
684
+ const prepared = await prepareFormAsk({
685
+ agentMeta: meta,
686
+ payload: { form: current.form, data: current.data },
687
+ cwd: full.projectDir,
688
+ pipelineDir: full.runCtx?.pipelineDir || full.pipelineDir,
689
+ askId,
690
+ });
691
+ if (prepared.ok) return { ask: prepared.ask, autoValues: prepared.autoValues, questions: [], sessionId };
692
+ const why = prepared.errors.map((e) => `${e.path ? `${e.path}: ` : ''}${e.message}`).join('; ');
693
+ warnings.push(`${meta.displayName || node.key}: form "${current.form}" was refused — ${why}`);
694
+ if (attempt === 2) break;
695
+ const repairCtx = {
696
+ ...full,
697
+ resumeSessionId: sessionId || full.resumeSessionId,
698
+ formRepair: {
699
+ form: current.form, errors: prepared.errors,
700
+ schema: meta?.ask?.forms?.[current.form]?.data || null, file: answersPath,
701
+ },
702
+ };
703
+ const again = await spawnAgent(repairCtx, spawn);
704
+ sessionId = again.sessionId ?? sessionId;
705
+ const retry = classifyAskPayload(await readJsonMaybe(answersPath));
706
+ if (retry.kind !== 'form') {
707
+ return { ask: null, autoValues: null, questions: retry.kind === 'questions' ? retry.questions : [], sessionId };
708
+ }
709
+ current = retry;
710
+ }
711
+ const title = meta?.ask?.forms?.[current.form]?.title || '';
712
+ warnings.push(`${meta.displayName || node.key}: form "${current.form}" downgraded to a free-text question after two refusals.`);
713
+ return { ask: null, autoValues: null, questions: [downgradeQuestion({ form: current.form, title })], sessionId };
714
+ }
715
+
703
716
  /**
704
717
  * The clarifier executor — selected by `meta.runnerType === 'clarifier'`, NEVER by an
705
718
  * agent key, so any number of clarifier nodes per graph is legal. Spawn → read the
@@ -716,12 +729,64 @@ export async function runClarifierExecution(ctx) {
716
729
  const answersPath = outputs[answersPort?.id]?.path;
717
730
  if (!answersPath) throw new Error(`clarifier node "${node?.id}": no json output port to write the questions to`);
718
731
 
719
- const { sessionId } = await spawnAgent(full, { role, prompt, systemPrompt, allowedTools });
732
+ let sessionId;
733
+ ({ sessionId } = await spawnAgent(full, { role, prompt, systemPrompt, allowedTools }));
720
734
 
721
- const { questions } = normalizeClarify(await readJsonMaybe(answersPath));
735
+ const raw = await readJsonMaybe(answersPath);
736
+ const payload = classifyAskPayload(raw);
722
737
  // Non-interactive default (v1 `_ask` auto): no gate ⇒ every question takes its first
723
738
  // option (normalizeAnswers' fallback). The scheduler's onAsk never sees clarify asks.
724
- const ask = typeof ctx.ask === 'function' ? ctx.ask : async () => ({ answers: [] });
739
+ const ask = typeof ctx.ask === 'function' ? ctx.ask : null;
740
+ const warnings = pinIgnoredWarning ? [pinIgnoredWarning] : [];
741
+ let questions = payload.kind === 'questions' ? payload.questions : [];
742
+
743
+ if (payload.kind === 'form') {
744
+ // Gate 2 (spec §5), with the ONE repair spawn the producer loop also gets —
745
+ // a clarifier has no _questionsLoop, so the repair is a resumed spawn here.
746
+ const gate = await prepareClarifierForm({
747
+ full, meta, node, ordinal, answersPath, payload, warnings,
748
+ spawn: { role, systemPrompt, allowedTools, prompt },
749
+ sessionId, // E6: the repair RESUMES the first spawn's session
750
+ });
751
+ if (gate.ask) {
752
+ const answered = await (ask
753
+ ? ask({
754
+ // `id` is the QUESTION id — the same string the legacy arm below uses,
755
+ // so `orch.answer(id, …)` is uniform. `askId` is the route token
756
+ // prepareFormAsk minted from it, which may differ whenever a node id
757
+ // carries a character outside [A-Za-z0-9_-] (ruling X1).
758
+ id: `${CLARIFY_ASK_KIND}-${node.id}-${ordinal}`,
759
+ kind: 'form', nodeId: node.id, agent: meta.displayName || node.key,
760
+ askId: gate.ask.askId, form: gate.ask.form, version: gate.ask.version, title: gate.ask.title,
761
+ surface: gate.ask.surface,
762
+ data: gate.ask.data, layout: gate.ask.layout, answerSchema: gate.ask.answerSchema,
763
+ fileRefs: gate.ask.fileRefs, files: gate.ask.files,
764
+ autoValues: gate.autoValues, validate: formAnswerValidator(gate.ask),
765
+ })
766
+ // No gate at all (auto): D10's auto answer, exactly what _ask would return.
767
+ : Promise.resolve({ form: gate.ask.form, version: gate.ask.version, values: gate.autoValues || {} }));
768
+ const values = (answered && typeof answered === 'object' && answered.values) || {};
769
+ if (ctx.pipelineId) {
770
+ await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
771
+ agentKey: node?.key, nodeId: node?.id, questions: gate.ask,
772
+ });
773
+ await writeClarify(ctx.pipelineId, { questions: gate.ask });
774
+ const answerRow = { kind: 'form', form: gate.ask.form, version: gate.ask.version, values };
775
+ await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
776
+ agentKey: node?.key, nodeId: node?.id, answers: answerRow,
777
+ });
778
+ await writeClarify(ctx.pipelineId, { answers: answerRow });
779
+ }
780
+ await mkdir(dirname(answersPath), { recursive: true }).catch(() => {});
781
+ await writeFile(answersPath, JSON.stringify({ form: gate.ask.form, version: gate.ask.version, values }, null, 2) + '\n', 'utf8');
782
+ return { outputs: publishable(ports, outputs), questions: [], answers: [],
783
+ form: gate.ask.form, version: gate.ask.version, values,
784
+ sessionId: gate.sessionId ?? sessionId, prompt, warnings };
785
+ }
786
+ questions = gate.questions; // the downgrade takes the legacy arm below
787
+ sessionId = gate.sessionId ?? sessionId;
788
+ }
789
+
725
790
  let answers = [];
726
791
  if (questions.length) {
727
792
  if (ctx.pipelineId) {
@@ -730,14 +795,14 @@ export async function runClarifierExecution(ctx) {
730
795
  });
731
796
  await writeClarify(ctx.pipelineId, { questions: { questions } });
732
797
  }
733
- const payload = await ask({
798
+ const answerPayload = await (ask || (async () => ({ answers: [] })))({
734
799
  id: `${CLARIFY_ASK_KIND}-${node.id}-${ordinal}`,
735
800
  kind: CLARIFY_ASK_KIND,
736
801
  nodeId: node.id,
737
802
  agent: meta.displayName || node.key,
738
803
  questions,
739
804
  });
740
- answers = normalizeAnswers(payload, questions);
805
+ answers = normalizeAnswers(answerPayload, questions);
741
806
  if (ctx.pipelineId) {
742
807
  await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
743
808
  agentKey: node?.key, nodeId: node?.id, answers: { answers },
@@ -748,8 +813,7 @@ export async function runClarifierExecution(ctx) {
748
813
 
749
814
  await mkdir(dirname(answersPath), { recursive: true }).catch(() => {});
750
815
  await writeFile(answersPath, JSON.stringify({ questions, answers }, null, 2) + '\n', 'utf8');
751
- return { outputs: publishable(ports, outputs), questions, answers, sessionId, prompt,
752
- warnings: pinIgnoredWarning ? [pinIgnoredWarning] : [] };
816
+ return { outputs: publishable(ports, outputs), questions, answers, sessionId, prompt, warnings };
753
817
  }
754
818
 
755
819
  // ── flow executors (pure engine: instant, $0, no process spawn) ───────────────
@@ -891,6 +955,11 @@ export function runExecution(ctx, opts = {}) {
891
955
  case 'end': return runEndExecution(ctx);
892
956
  case 'combine':
893
957
  return runCombineExecution({ ...ctx, names: ctx.names || combineNames(ctx.template, node.id) });
958
+ case 'script': {
959
+ // A child process, not a Claude spawn (spec §6.1). `runners.script` is the test seam.
960
+ const injectedScript = (opts.runners || ctx.runners || {}).script;
961
+ return typeof injectedScript === 'function' ? injectedScript(ctx) : runScriptExecution(ctx);
962
+ }
894
963
  case 'agent': break;
895
964
  default:
896
965
  throw new Error(`node "${node.id}": unknown kind "${node.kind}"`);
@@ -0,0 +1,131 @@
1
+ // src/core/graph/human-evidence.mjs
2
+ // Per-execution EVIDENCE for the human-hours estimate (money-saved design §3). Agent-agnostic
3
+ // by construction: it reads node.kind, the meta's runnerType/sideEffect/humanEffort, the port
4
+ // TYPES and filename templates, the allocated output paths, the bound input paths and the
5
+ // worktree — never an agent key. All file reads are best-effort: a missing or unreadable file
6
+ // contributes nothing.
7
+ import { readFile } from 'node:fs/promises';
8
+ import { proseWords, jsonItems } from '../../shared/human-estimate.mjs';
9
+
10
+ const REVISION_RE = /-v\d+\.md$/i; // `{vsuffix}` renders as `-vN` for N > 1 (executor.mjs resolveTemplate)
11
+
12
+ /**
13
+ * A promise chain: `run(fn)` runs fn after every earlier fn has settled and returns fn's own
14
+ * promise. A rejection reaches that caller only; the chain itself never rejects. The
15
+ * orchestrator puts every cursor measurement of a run through one queue, so two fan-out
16
+ * slices ending together cannot both read the same cursor and credit one delta twice.
17
+ */
18
+ export function serialQueue() {
19
+ let tail = Promise.resolve();
20
+ return (fn) => {
21
+ const next = tail.then(fn);
22
+ tail = next.catch(() => {});
23
+ return next;
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Cumulative numstat over every member work dir against its checkpoint ref. `null` when no
29
+ * member has a checkpoint. `stage` (optional) runs ONCE before measuring: `git diff
30
+ * <checkpoint>` never lists an untracked file, so the caller passes the harness's intent-to-add
31
+ * staging (`_stageWorkingTree`) — the same staging the reviewer's diff relies on. The exclude
32
+ * pathspecs are the set `_buildResults` / `_commitWork` use, so the cursor and results.json agree.
33
+ * @param {{workDirs: Map<string,string>, checkpointRefs: Record<string,string>, excludeFor:(key:string)=>string[], numstat:Function, stage?:Function}} o
34
+ * @returns {Promise<{files:number, insertions:number, deletions:number}|null>}
35
+ */
36
+ export async function measureCodeCursor({ workDirs, checkpointRefs, excludeFor, numstat, stage = null }) {
37
+ if (typeof stage === 'function') { try { await stage(); } catch { /* best-effort: measure what is visible */ } }
38
+ let seen = false;
39
+ const out = { files: 0, insertions: 0, deletions: 0 };
40
+ for (const [key, dir] of workDirs?.entries?.() || []) {
41
+ const base = checkpointRefs?.[key];
42
+ if (!base) continue;
43
+ seen = true;
44
+ let m;
45
+ try { m = await numstat(dir, base, undefined, excludeFor(key) || []); } catch { continue; }
46
+ for (const row of m.values()) {
47
+ if (row.binary) continue;
48
+ out.files += 1;
49
+ out.insertions += row.added | 0;
50
+ out.deletions += row.removed | 0;
51
+ }
52
+ }
53
+ return seen ? out : null;
54
+ }
55
+
56
+ /** now − prev, each component clamped at 0. `prev` null means "from zero". `now` null → null. */
57
+ export function codeDelta(now, prev) {
58
+ if (!now) return null;
59
+ const p = prev || { files: 0, insertions: 0, deletions: 0 };
60
+ return {
61
+ files: Math.max(0, (now.files | 0) - (p.files | 0)),
62
+ insertions: Math.max(0, (now.insertions | 0) - (p.insertions | 0)),
63
+ deletions: Math.max(0, (now.deletions | 0) - (p.deletions | 0)),
64
+ };
65
+ }
66
+
67
+ async function readText(read, path) {
68
+ if (!path) return null;
69
+ try { return await read(path, 'utf8'); } catch { return null; }
70
+ }
71
+
72
+ function jsonItemsOf(text) {
73
+ try { return jsonItems(JSON.parse(text)); } catch { return 0; }
74
+ }
75
+
76
+ /**
77
+ * @param {{ctx: object, cursorPrev: object|null, cursorNow: object|null, read?: Function}} o
78
+ * ctx is the orchestrator's execution ctx (_execCtx): node, ordinal, meta, ports, outputs,
79
+ * bindings, verdict. cursorPrev = the cumulative cursor at the previous terminal (this step's
80
+ * start), cursorNow = the cursor measured at this step's terminal.
81
+ */
82
+ export async function collectStepEvidence({ ctx, cursorPrev = null, cursorNow = null, read = readFile }) {
83
+ const nodeKind = ctx?.node?.kind || 'agent';
84
+ const isAgent = nodeKind === 'agent';
85
+ const meta = ctx?.meta || {};
86
+ const cycle = Number.isInteger(ctx?.ordinal) && ctx.ordinal > 0 ? ctx.ordinal : 1;
87
+ const agent = isAgent ? { runnerType: meta.runnerType, sideEffect: meta.sideEffect, humanEffort: meta.humanEffort } : null;
88
+
89
+ // Outputs: one entry per DISTINCT allocated path (a refiner's plan/revise pair is one file).
90
+ const outputs = [];
91
+ const seen = new Set();
92
+ for (const port of ctx?.ports?.outputs || []) {
93
+ if (!port || (port.type !== 'md' && port.type !== 'json')) continue;
94
+ const path = ctx?.outputs?.[port.id]?.path;
95
+ if (!path || seen.has(path)) continue;
96
+ seen.add(path);
97
+ const text = await readText(read, path);
98
+ if (text == null) continue; // not produced (a `when`-gated port, or the step died early)
99
+ if (port.type === 'md') {
100
+ // A revision: this port versions its file ({vsuffix}) AND the allocated name carries -vN,
101
+ // or the execution itself is a re-run (cycle > 1). The template check keeps a base name
102
+ // that merely ends in "-v2" from reading as a revision.
103
+ const versioned = String(port.filename || '').includes('{vsuffix}') && REVISION_RE.test(path);
104
+ outputs.push({ type: 'md', words: proseWords(text), revision: versioned || cycle > 1 });
105
+ } else {
106
+ outputs.push({ type: 'json', items: jsonItemsOf(text) });
107
+ }
108
+ }
109
+ const verdictPath = ctx?.verdict?.path;
110
+ if (verdictPath && !seen.has(verdictPath)) {
111
+ const text = await readText(read, verdictPath);
112
+ if (text != null) outputs.push({ type: 'json', items: jsonItemsOf(text) });
113
+ }
114
+
115
+ // Reads: verifiers only — the diff as it stood when the step STARTED plus every md input.
116
+ let reads = null;
117
+ if (isAgent && meta.runnerType === 'verifier') {
118
+ const inputs = ctx?.ports?.inputs || [];
119
+ const readsWorktree = inputs.some((p) => p && p.as === 'worktree');
120
+ const diffLines = readsWorktree && cursorPrev ? (cursorPrev.insertions | 0) + (cursorPrev.deletions | 0) : 0;
121
+ let words = 0;
122
+ for (const port of inputs) {
123
+ if (!port || port.type !== 'md') continue;
124
+ const text = await readText(read, ctx?.bindings?.[port.id]?.path);
125
+ if (text != null) words += proseWords(text);
126
+ }
127
+ reads = { diffLines, words };
128
+ }
129
+
130
+ return { nodeKind, agent, cycle, code: isAgent ? codeDelta(cursorNow, cursorPrev) : null, outputs, reads };
131
+ }
@@ -0,0 +1,172 @@
1
+ // src/core/graph/python-probe.mjs
2
+ // Can this host run a python script card? (scripts-workbench spec §7.) ONE answer,
3
+ // read by everything: the runner before it spawns, the run preflight, the bench,
4
+ // GET /api/scripts/runtimes and the Scripts page picker. Nothing else probes.
5
+ //
6
+ // Each candidate runs a stdlib one-liner and must print a parseable version
7
+ // array, so the Windows Store stub — an app-execution alias named python.exe that
8
+ // prints nothing and exits 9009 (or pops the Store) — fails the parse and is
9
+ // skipped instead of being reported as a working interpreter.
10
+ //
11
+ // Every seam is injectable (env, platform, spawn, settings, timeoutMs) so the
12
+ // Windows candidate order is unit-tested on macOS and Linux with no python
13
+ // installed. The 60 s cache covers the BARE call only: a unit test that injects a
14
+ // fake spawn can never poison what the server reads next.
15
+ import { spawn as nodeSpawn } from 'node:child_process';
16
+ import path from 'node:path';
17
+ import { pythonPath } from '../settings.mjs';
18
+
19
+ /** 3.8 is the floor: importlib.util.spec_from_file_location + asyncio.run + f-strings. */
20
+ export const PYTHON_MIN = Object.freeze([3, 8]);
21
+ export const PROBE_TIMEOUT_MS = 5000;
22
+ export const PROBE_CACHE_MS = 60000;
23
+ /** The version answer is ~12 bytes. Anything that prints more than this is not a python, and an unbounded
24
+ * accumulator here is a HOST crash: `yes` fills a V8 string ("Invalid string length") well inside the 5 s window. */
25
+ export const PROBE_OUTPUT_MAX = 4096;
26
+ /** Standard library, one line, no f-string: it must at least PARSE on whatever the user points at. */
27
+ export const VERSION_SCRIPT = 'import sys,json;print(json.dumps(list(sys.version_info[:3])))';
28
+
29
+ const blank = (v) => typeof v !== 'string' || v.trim() === '';
30
+
31
+ /**
32
+ * An explicit interpreter with a directory part is made ABSOLUTE here, once. The
33
+ * probe spawns from the server's cwd and the runner spawns from the RUN's cwd (a
34
+ * worktree somewhere else), so a relative `.venv/bin/python` would pass the probe
35
+ * and then fail every execution with ENOENT. A bare name (`python3.12`) has no
36
+ * directory part and stays a PATH lookup, which is cwd-independent.
37
+ */
38
+ function explicitCommand(value, platform, cwd) {
39
+ const v = value.trim();
40
+ const p = platform === 'win32' ? path.win32 : path.posix;
41
+ const hasDir = platform === 'win32' ? /[\\/]/.test(v) : v.includes('/');
42
+ return [hasDir && !p.isAbsolute(v) ? p.resolve(cwd, v) : v];
43
+ }
44
+ const label = (command) => command.join(' ');
45
+
46
+ /**
47
+ * The candidates, in order (§7). An EXPLICIT interpreter is AUTHORITATIVE: with
48
+ * WORCA_PYTHON (or the pythonPath setting) set, that one is tried and the probe
49
+ * stops — the failure sentence tells the user to set WORCA_PYTHON, so a silent
50
+ * fall-through to some other python would make that advice a lie, and a venv
51
+ * pointer must never be quietly replaced by the system interpreter. The value is
52
+ * ONE executable path, used verbatim (`C:\Program Files\Python312\python.exe`).
53
+ * @param {{env?:object, platform?:string, settings?:object|null, cwd?:string}} [o] `settings` is a test seam; null reads the store
54
+ */
55
+ export function pythonCandidates({ env = process.env, platform = process.platform, settings = null, cwd = process.cwd() } = {}) {
56
+ if (!blank(env.WORCA_PYTHON)) return [{ command: explicitCommand(env.WORCA_PYTHON, platform, cwd), source: 'WORCA_PYTHON' }];
57
+ const configured = settings ? settings.pythonPath : pythonPath();
58
+ if (!blank(configured)) return [{ command: explicitCommand(configured, platform, cwd), source: 'pythonPath' }];
59
+ if (platform === 'win32') {
60
+ return [{ command: ['py', '-3'], source: 'path' }, { command: ['python'], source: 'path' }, { command: ['python3'], source: 'path' }];
61
+ }
62
+ return [{ command: ['python3'], source: 'path' }, { command: ['python'], source: 'path' }];
63
+ }
64
+
65
+ /** `[3, 12, 4]` from the version script's stdout; null for anything else (the Store stub prints nothing). */
66
+ export function parseVersion(text) {
67
+ const lines = String(text || '').trim().split('\n');
68
+ const last = lines[lines.length - 1].trim();
69
+ if (!last) return null;
70
+ let v;
71
+ try { v = JSON.parse(last); } catch { return null; }
72
+ if (!Array.isArray(v) || v.length !== 3 || !v.every((n) => Number.isInteger(n) && n >= 0)) return null;
73
+ return v;
74
+ }
75
+
76
+ export const meetsFloor = (v) => v[0] > PYTHON_MIN[0] || (v[0] === PYTHON_MIN[0] && v[1] >= PYTHON_MIN[1]);
77
+
78
+ /**
79
+ * Ask ONE candidate for its version. Never throws, never rejects.
80
+ * Windows: `py.exe` launches `python.exe` as a child, so the timeout kill reaches
81
+ * the launcher only — acceptable for a one-liner that exits immediately, and a
82
+ * tree kill here would mean importing script-runner.mjs, which imports this module.
83
+ * @returns {Promise<{version:number[]}|{error:string}>}
84
+ */
85
+ function askVersion(command, { spawn, timeoutMs }) {
86
+ return new Promise((resolve) => {
87
+ let child;
88
+ try {
89
+ child = spawn(command[0], [...command.slice(1), '-c', VERSION_SCRIPT], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
90
+ } catch (err) {
91
+ resolve({ error: err?.message || String(err) });
92
+ return;
93
+ }
94
+ let out = '';
95
+ let settled = false;
96
+ const done = (v) => {
97
+ if (settled) return;
98
+ settled = true;
99
+ clearTimeout(timer);
100
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
101
+ resolve(v);
102
+ };
103
+ const timer = setTimeout(() => done({ error: `no answer in ${Math.round(timeoutMs / 1000)} s` }), timeoutMs);
104
+ child.stdout?.setEncoding('utf8').on('data', (c) => {
105
+ if (settled) return;
106
+ out += c;
107
+ if (out.length > PROBE_OUTPUT_MAX) done({ error: 'printed more than a version' });
108
+ });
109
+ child.stderr?.resume(); // drained, never read: a stub's banner is not an answer
110
+ child.on('error', (err) => done({ error: err?.message || String(err) }));
111
+ child.on('close', (code) => {
112
+ const version = parseVersion(out);
113
+ done(version ? { version } : { error: `exit ${code} with no version` });
114
+ });
115
+ });
116
+ }
117
+
118
+ /** The one sentence the picker shows as its `title` and the run/bench repeat. */
119
+ function failureReason(candidates, tried, tooOld) {
120
+ if (tooOld) return `python ${tooOld.version.join('.')} at "${label(tooOld.command)}" is older than 3.8 — point WORCA_PYTHON at a newer interpreter`;
121
+ const first = candidates[0];
122
+ if (first?.source === 'WORCA_PYTHON') return `WORCA_PYTHON "${label(first.command)}" is not a working python`;
123
+ if (first?.source === 'pythonPath') return `the pythonPath setting "${label(first.command)}" is not a working python`;
124
+ return `no python 3.8 or newer found (tried ${tried.join(', ')})`;
125
+ }
126
+
127
+ async function runProbe({ env, platform, spawn, settings, timeoutMs, cwd }) {
128
+ const candidates = pythonCandidates({ env, platform, settings, cwd });
129
+ const tried = [];
130
+ let tooOld = null;
131
+ for (const c of candidates) {
132
+ const r = await askVersion(c.command, { spawn, timeoutMs });
133
+ tried.push(label(c.command));
134
+ if (r.version && meetsFloor(r.version)) return { ok: true, command: c.command, version: r.version };
135
+ if (r.version && !tooOld) tooOld = { command: c.command, version: r.version };
136
+ }
137
+ return { ok: false, reason: failureReason(candidates, tried, tooOld) };
138
+ }
139
+
140
+ let cached = null; // { at, promise } — the BARE call only
141
+
142
+ /**
143
+ * The host's python (§7). Cached 60 s per process when called with NO options;
144
+ * any option at all (a test seam) bypasses and never fills the cache.
145
+ * @returns {Promise<{ok:true, command:string[], version:number[]}|{ok:false, reason:string}>}
146
+ */
147
+ export function probePython(opts = {}) {
148
+ const bare = Object.keys(opts).length === 0;
149
+ if (bare && cached && Date.now() - cached.at < PROBE_CACHE_MS) return cached.promise;
150
+ // NEVER rejects: express 4 does not catch an async handler's rejection, the bare promise is CACHED for 60 s,
151
+ // and an unhandled rejection takes the server down. Whatever goes wrong here is "no python", with the reason.
152
+ const promise = runProbe({
153
+ env: opts.env || process.env,
154
+ platform: opts.platform || process.platform,
155
+ spawn: opts.spawn || nodeSpawn,
156
+ settings: opts.settings || null,
157
+ timeoutMs: Number.isInteger(opts.timeoutMs) ? opts.timeoutMs : PROBE_TIMEOUT_MS,
158
+ cwd: opts.cwd,
159
+ }).catch((err) => ({ ok: false, reason: `the python probe failed: ${err?.message || err}` }));
160
+ if (bare) cached = { at: Date.now(), promise };
161
+ return promise;
162
+ }
163
+
164
+ /** Drop the 60 s cache. Tests only — product code never calls it. */
165
+ export function resetPythonProbe() { cached = null; }
166
+
167
+ /** The `python` arm of GET /api/scripts/runtimes (spec §3.4). */
168
+ export function pythonRuntimeState(probe) {
169
+ return probe?.ok
170
+ ? { ok: true, version: probe.version.join('.'), command: probe.command }
171
+ : { ok: false, reason: probe?.reason || 'python was not probed' };
172
+ }
@@ -1,12 +1,16 @@
1
1
  // src/core/graph/registry-ports.mjs
2
- // Engine-side glue: a loaded agent registry -> the shared portsFn. Lives in
3
- // src/core (not src/shared) because it exists only to bridge the Node-side
4
- // registry shape; the resolution logic itself is shared.
2
+ // Engine-side glue: the loaded agent registry AND the loaded script registry ->
3
+ // the shared portsFn. Lives in src/core (not src/shared) because it exists only
4
+ // to bridge the Node-side registry shapes; the resolution logic itself is shared.
5
5
  import { portsFnFor } from '../../shared/graph/ports.mjs';
6
6
  import { indexByKey } from '../../shared/graph/agent-meta.mjs';
7
7
 
8
- /** @param {Record<string,object>|object[]} registry loadAgentRegistry() output (or a list) */
9
- export function registryPortsFn(registry) {
8
+ /**
9
+ * @param {Record<string,object>|object[]} registry loadAgentRegistry() output (or a list)
10
+ * @param {Record<string,object>|object[]} [scripts] loadScriptRegistry() output (or a list); absent = no scripts resolve
11
+ */
12
+ export function registryPortsFn(registry, scripts = {}) {
10
13
  const list = Array.isArray(registry) ? registry : Object.values(registry || {});
11
- return portsFnFor(indexByKey(list));
14
+ const scriptList = Array.isArray(scripts) ? scripts : Object.values(scripts || {});
15
+ return portsFnFor(indexByKey(list), indexByKey(scriptList));
12
16
  }