@worca/app 1.3.0 → 1.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -15,18 +15,27 @@ import { rm, readFile } from 'node:fs/promises';
15
15
 
16
16
  import {
17
17
  RunHarness, isAbort, isPause, pauseErr, firstLine, jsonClone,
18
- clipMiddle, sumStepActive, normalizeClarifyAnswer,
18
+ clipMiddle, sumStepActive, normalizeClarifyAnswer, findDisabledPluginFor,
19
19
  } from './run-harness.mjs';
20
20
  import { resolveGraph, loadAgentFile, GRAPH_DEFAULT_WORKFLOW, writeGraphWorkflow, readWorkflow } from './workflows.mjs';
21
+ import { loadScriptRegistry } from './script-registry.mjs';
21
22
  import { AUTO_WORKFLOW_ID, AUTO_WORKFLOW_NAME } from './graph/builtin-workflows.mjs';
22
23
  import { classifyLoops } from '../shared/graph/loops.mjs';
23
24
  import { buildGraphManifest, manifestTemplate, manifestPortsFn } from '../shared/graph/manifest.mjs';
24
- import { DEFAULT_MAX_CYCLES } from '../shared/graph/constants.mjs';
25
+ import { DEFAULT_MAX_CYCLES, KEYED_KINDS } from '../shared/graph/constants.mjs';
26
+ import { scriptNodeCtx, pythonMissingSentence } from '../shared/graph/script-meta.mjs';
27
+ import { probePython } from './graph/python-probe.mjs';
28
+ import { mockEnabled } from './claude-runner.mjs';
25
29
  import { registryPortsFn } from './graph/registry-ports.mjs';
26
30
  import { createScheduler, sliceExecutionId, QUIESCENCE_WARNING } from './graph/scheduler.mjs';
27
31
  import { runExecution, allocateOutputs, allocateVerdict, readDecomposition } from './graph/executor.mjs';
32
+ import { serialQueue, measureCodeCursor, collectStepEvidence } from './graph/human-evidence.mjs';
33
+ import { estimateStepHours, resolveConstants, sumStepHours } from '../shared/human-estimate.mjs';
34
+ import { diffNumstat } from './git-info.mjs';
35
+ import { humanEstimateOverrides, memoryDefragModel } from './settings.mjs';
28
36
  import { renderPromptArtifact } from './phases.mjs';
29
37
  import { listModels, modelHasBaseUrlRouting, resolveRunConfig } from './config.mjs';
38
+ import { resolveDefragModel, agentPairText } from './memory-defrag-model.mjs';
30
39
  import { assembleShape, ShapeError } from '../shared/graph/assemble.mjs';
31
40
  import { fingerprintProject } from './auto/fingerprint.mjs';
32
41
  import { classifyTask, ClassifierError } from './auto/classify.mjs';
@@ -37,7 +46,8 @@ import {
37
46
  appendAudit, writeReview, reviewKindOf, writeDecomposition, updateTaskStatus,
38
47
  updatePhaseStatus, writeStepQuestions, readStepQuestions,
39
48
  } from './artifacts.mjs';
40
- import { readQuestionsFile } from './protocol.mjs';
49
+ import { readAskFile } from './protocol.mjs';
50
+ import { prepareFormAsk, formAnswerValidator, downgradeQuestion } from './ask-forms.mjs';
41
51
  import { classifyError } from './recoverable-error.mjs';
42
52
  import { resolveFailure, markTerminal, isTerminal } from './failure-policy.mjs';
43
53
 
@@ -67,12 +77,16 @@ export class GraphOrchestrator extends RunHarness {
67
77
  if (!this.opts.workflowId) this.workflowId = GRAPH_DEFAULT_WORKFLOW.id;
68
78
  // (this._runners is assigned by the _initRunners hook the base constructor calls.)
69
79
  this.resolved = null; // resolveGraph's { template, ports, loops, nodes→nodeCtx, wires, agentsByKey, agentKeys }
80
+ this.scriptRegistry = null; // loadScriptRegistry() for this run (D16-filtered); tests override the built-in dir with opts.scriptsDir
70
81
  this._scheduler = null;
71
82
  this._graphSnapshot = null; // last CLEAN scheduler snapshot
72
83
  this._resumeSnapshot = null; // the snapshot a resume restores from
73
84
  this._resumeSessions = null; // Map executionId -> sessionId (one-shot)
74
85
  this._graphError = null; // first genuine execution error (identity preserved)
75
86
  this._planVersion = 0; // {vsuffix} ticks, carried across a resume
87
+ this._humanCursor = null; // cumulative worktree numstat at the last agent/script terminal (money-saved §3.1)
88
+ this._humanCursorReady = false; // baseline measured at the first agent/script start, or restored from the resume point
89
+ this._humanRun = serialQueue(); // measure-then-credit is atomic per execution: slices that end together split, never double
76
90
  this._taskArtifact = null; // the pre-rendered task document
77
91
  this.extrasFiles = [];
78
92
  // Auto workflow (spec §5): the decision loop's state. `feedback`/`round`/`prior`
@@ -111,16 +125,32 @@ export class GraphOrchestrator extends RunHarness {
111
125
  * @returns {Promise<{manifest:object, agentKeys:Set<string>, workflow:{id:string,name:string}}>}
112
126
  */
113
127
  async _resolveTopology(registry) {
128
+ this.scriptRegistry = loadScriptRegistry({ scriptsDir: this.opts.scriptsDir, agentKeys: Object.keys(registry || {}) });
114
129
  if (this.workflowId === AUTO_WORKFLOW_ID) return this._autoBootstrapTopology();
130
+ // Settings › Memory: a defragment run (memoryScope ⇔ wf_memory_defrag, checked in the
131
+ // constructor) resolves its model/effort pair HERE — the one place every entry point reaches
132
+ // (the Memory view's button, New pipeline, an Ask card, a schedule and the CLI all construct
133
+ // this class). resume() never calls this hook: the manifest froze the pair on the node.
134
+ const defrag = this.memoryScope ? await this._defragAgentPair() : null;
115
135
  const resolved = await resolveGraph(this.projectDir, this.workflowId, registry, this.agentsDir, {
116
- isWorkspace: this.isWorkspace,
136
+ isWorkspace: this.isWorkspace, scripts: this.scriptRegistry, ...(defrag && defrag.pair ? { agentPair: defrag.pair } : {}),
117
137
  });
118
138
  this._adoptResolvedGraph(resolved);
139
+ // A setting that failed the catalog check degrades — and says what the run uses INSTEAD, read
140
+ // off the resolved graph (a project's own pick, a team default or the template's model): a run
141
+ // log line, and an audit line queued until the pipeline dir exists (RunHarness._pendingAudits).
142
+ if (defrag && defrag.warning) {
143
+ const text = `${defrag.warning} — the run uses ${agentPairText(this.resolved.nodeCtx)}`;
144
+ this._log('orchestrator', 'warn', text);
145
+ this._pendingAudits.push(`${text}.`);
146
+ }
147
+ this._preflightScriptKeys(this.resolved.scriptKeys);
148
+ await this._preflightScriptRuntimes();
119
149
  // The manifest is built from the RESOLVED template, the resolver's registry
120
150
  // slice and its EFFECTIVE per-node/per-wire values (P2 contract): the run
121
151
  // monitor shows exactly what the engine will run.
122
152
  const manifest = buildGraphManifest(this.resolved.template, this.resolved.agentsByKey, {
123
- overlays: { nodes: this.resolved.nodeCtx, wires: this.resolved.wires },
153
+ overlays: { nodes: this.resolved.nodeCtx, wires: this.resolved.wires }, scripts: this.resolved.scriptsByKey,
124
154
  });
125
155
  return {
126
156
  manifest,
@@ -129,6 +159,27 @@ export class GraphOrchestrator extends RunHarness {
129
159
  };
130
160
  }
131
161
 
162
+ /**
163
+ * Settings › Memory: the pair every agent node of this Memory defragment run uses
164
+ * (memory-defrag-model.mjs) — the one named at start (`claude.model` / `claude.effort`: the
165
+ * CLI's --model, a CLI-made schedule) wins, else the stored setting, validated against THIS
166
+ * project's catalog. A setting that fails the check comes back as `warning` (the caller logs it
167
+ * once the graph says what the run uses instead) — never a refused run. `pair: null` ⇒ the node
168
+ * layers resolve exactly as before.
169
+ * @returns {Promise<{pair: ({model:string, effort:(string|null)}|null), warning: (string|null)}>}
170
+ */
171
+ async _defragAgentPair() {
172
+ const explicit = { model: this.claude.model, effort: this.opts.claude?.effort };
173
+ const stored = memoryDefragModel();
174
+ // The catalog read only when the setting is the one that decides.
175
+ const models = !(typeof explicit.model === 'string' && explicit.model.trim()) && stored.model
176
+ ? await listModels(this.projectDir) : [];
177
+ const r = resolveDefragModel({ explicit, stored, models });
178
+ if (!r.model) return { pair: null, warning: r.warning };
179
+ this._log('orchestrator', 'info', `Memory defragment model: ${r.model}${r.effort ? ` · ${r.effort}` : ''} (${r.source === 'explicit' ? 'named at start' : 'Settings › Memory'})`);
180
+ return { pair: { model: r.model, effort: r.effort }, warning: r.warning };
181
+ }
182
+
132
183
  /** The Auto entry before the decision: an EMPTY graph tagged `deciding`, so
133
184
  * the run row, the Running page and a pre-decision resume point all have a
134
185
  * manifest to carry (spec §5.2). */
@@ -387,7 +438,7 @@ export class GraphOrchestrator extends RunHarness {
387
438
  for (const [nodeId, sel] of Object.entries(tunables || {})) overlayNodes[nodeId] = { ...sel };
388
439
  for (const [nodeId, sel] of Object.entries(answer.nodes || {})) overlayNodes[nodeId] = { ...(overlayNodes[nodeId] || {}), ...sel };
389
440
  const resolved = await resolveGraph(this.projectDir, workflowId, registry, this.agentsDir, {
390
- isWorkspace: false, overlay: { nodes: overlayNodes }, ignoreProjectOverrides: true,
441
+ isWorkspace: false, overlay: { nodes: overlayNodes }, ignoreProjectOverrides: true, scripts: this.scriptRegistry,
391
442
  });
392
443
  if (!this.humanInLoop) {
393
444
  // spec D3: no agent may stop the run to ask (generic — every agent node).
@@ -396,10 +447,12 @@ export class GraphOrchestrator extends RunHarness {
396
447
  this.workflowId = workflowId;
397
448
  this._adoptResolvedGraph(resolved);
398
449
  const manifest = buildGraphManifest(this.resolved.template, this.resolved.agentsByKey, {
399
- overlays: { nodes: this.resolved.nodeCtx, wires: this.resolved.wires },
450
+ overlays: { nodes: this.resolved.nodeCtx, wires: this.resolved.wires }, scripts: this.resolved.scriptsByKey,
400
451
  });
401
452
  manifest.auto = { status: 'decided', via, rounds: round, humanInLoop: this.humanInLoop, workflowId };
402
453
  this._preflightAgentKeys(this.resolved.agentKeys);
454
+ this._preflightScriptKeys(this.resolved.scriptKeys);
455
+ await this._preflightScriptRuntimes();
403
456
  this.state.stepper = manifest;
404
457
  // PR #434 review, finding 3: the pending proposal is kept until HERE. A throw before the
405
458
  // workflowId swap above (mintAutoWorkflowId, writeGraphWorkflow, resolveGraph) unwinds
@@ -466,7 +519,7 @@ export class GraphOrchestrator extends RunHarness {
466
519
 
467
520
  /**
468
521
  * Adopt a resolveGraph result (P2 contract: { template, ports, loops, nodes,
469
- * wires, agentsByKey, agentKeys }). The resolver has ALREADY applied the
522
+ * wires, agentsByKey, agentKeys, scriptsByKey, scriptKeys }). The resolver has ALREADY applied the
470
523
  * workspace substitution AND the workspaceFanOut forcing (spec §5.10 — a META
471
524
  * flag, never a key set) and classified the loops ONCE. This class names the
472
525
  * per-node table `nodeCtx`; nothing is re-derived and no template node is
@@ -516,6 +569,50 @@ export class GraphOrchestrator extends RunHarness {
516
569
  return manifest ? new Set(resolvedFromManifest(manifest, this.registry).agentKeys) : new Set();
517
570
  }
518
571
 
572
+ /** §8.3: every script key must resolve in this run's script registry BEFORE any
573
+ * node executes — the mirror of the base's _preflightAgentKeys, with the same
574
+ * disabled-plugin hint. resolveGraph already refuses an unknown key on a fresh
575
+ * run; this is what catches a plugin withdrawn while the run sat paused. */
576
+ _preflightScriptKeys(scriptKeys) {
577
+ const reg = this.scriptRegistry || {};
578
+ const missing = [];
579
+ for (const key of new Set(scriptKeys || [])) {
580
+ if (!key || Object.hasOwn(reg, key)) continue;
581
+ const plugin = findDisabledPluginFor(key, 'scripts');
582
+ missing.push(plugin
583
+ ? `script "${key}" comes from disabled plugin "${plugin}" — enable it`
584
+ : `script "${key}" is not installed (removed plugin?)`);
585
+ }
586
+ if (missing.length) {
587
+ throw new Error(`Preflight failed: ${missing.length} workflow script key(s) do not resolve:\n` + missing.map((m) => ` - ${m}`).join('\n'));
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Workbench spec §7: a `python` card needs an interpreter on THIS host. That is
593
+ * a run-time fact — the probe is async and the registry loader is not — so it is
594
+ * checked HERE, beside the key preflight, before the pipeline dir exists and
595
+ * long before the first execution, and is never baked into a registry snapshot.
596
+ * The message is the §7 sentence itself (one line per distinct key, first-seen
597
+ * order): for the usual single python card it is EXACTLY that sentence, which
598
+ * the bench, the composer's V4 and the CLI all repeat word for word.
599
+ */
600
+ async _preflightScriptRuntimes() {
601
+ // D13: in a mock run a card with a DECLARED mock spawns nothing (runScriptExecution returns before it
602
+ // probes), so it needs no interpreter — the same condition, read the same way.
603
+ const mocked = mockEnabled({ mock: this.claude?.mock });
604
+ const keys = [];
605
+ for (const nc of Object.values(this.resolved?.nodeCtx || {})) {
606
+ if (nc?.kind !== 'script' || nc.runtime !== 'python' || !nc.key || keys.includes(nc.key)) continue;
607
+ if (mocked && nc.mock && typeof nc.mock === 'object') continue;
608
+ keys.push(nc.key);
609
+ }
610
+ if (!keys.length) return;
611
+ const probe = await probePython();
612
+ if (probe.ok) return;
613
+ throw new Error(keys.map((key) => pythonMissingSentence(key)).join('\n'));
614
+ }
615
+
519
616
  // ── hook 2: run the graph ──────────────────────────────────────────────────
520
617
  /**
521
618
  * The scheduler owns readiness, loop budgets, gates and End; this method owns
@@ -542,7 +639,9 @@ export class GraphOrchestrator extends RunHarness {
542
639
  // every entry agent binds that same file. Byte-identical to v1's seeded task
543
640
  // file (the same renderer), so the Task card's document matches what v1
544
641
  // handed its entry node.
545
- this._taskArtifact = { text: renderPromptArtifact(this.pipeline.promptText, this.extrasFiles) };
642
+ // A Memory defragment run appends the scope's health (run-harness.mjs _defragBrief — '' on
643
+ // every other run, so their document stays byte-identical).
644
+ this._taskArtifact = { text: renderPromptArtifact(this.pipeline.promptText, this.extrasFiles) + await this._defragBrief() };
546
645
 
547
646
  const sched = createScheduler({
548
647
  template: this._schedulerTemplate(),
@@ -648,6 +747,9 @@ export class GraphOrchestrator extends RunHarness {
648
747
  completed: s.status === 'done',
649
748
  })),
650
749
  planVersion: this._planVersion,
750
+ // money-saved §3.1: the cursor at the last completed terminal (paused executions never
751
+ // advance it), so a resume credits the paused execution's pre-pause work at its terminal.
752
+ humanCursor: this._humanCursorReady ? (this._humanCursor ?? { files: 0, insertions: 0, deletions: 0 }) : null,
651
753
  stepModels: this.stepModels,
652
754
  workflowId: this.workflowId,
653
755
  // Auto workflow: the decision state while UNDECIDED (spec §5.6); null once
@@ -755,7 +857,11 @@ export class GraphOrchestrator extends RunHarness {
755
857
  });
756
858
  }
757
859
  }
758
- this._emit('exec', { ...payload, costUsd: step ? (step.costUsd || 0) : 0 });
860
+ this._emit('exec', {
861
+ ...payload, costUsd: step ? (step.costUsd || 0) : 0,
862
+ ...(step && step.runtime != null ? { runtime: step.runtime } : {}),
863
+ ...(step && step.exitCode != null ? { exitCode: step.exitCode } : {}),
864
+ });
759
865
  }
760
866
 
761
867
  /**
@@ -835,6 +941,7 @@ export class GraphOrchestrator extends RunHarness {
835
941
  } catch (err) {
836
942
  return this._settleUnstarted(nc, node, args, err); // allocation failed: no row to mark
837
943
  }
944
+ await this._humanCursorInit(ctx);
838
945
  this._execStep(ctx, 'start');
839
946
  let endMark = 'done';
840
947
  try {
@@ -847,8 +954,16 @@ export class GraphOrchestrator extends RunHarness {
847
954
  // here, not on the next spawn.
848
955
  this._checkAbort();
849
956
  this._checkPause();
850
- if (node.kind !== 'agent') return await this._runFlow(ctx);
851
- this._checkCostLimits(); // budget gate at EVERY agent launch (throws pauseErr)
957
+ if (!KEYED_KINDS.includes(node.kind)) return await this._runFlow(ctx);
958
+ this._checkCostLimits(); // budget gate at EVERY spawn (throws pauseErr)
959
+ if (node.kind === 'script') {
960
+ // A child process through the NODE site: every runner error carries
961
+ // errorClass:null (D9), so _recover lands on the '*' row — pause as
962
+ // REASON.ERROR, resumable, never a "network" retry. No questions, no session.
963
+ const result = await this._runNodeAttempts(nc, ctx);
964
+ await this._afterExecution(nc, ctx, result);
965
+ return result;
966
+ }
852
967
  this._primeQuestions(nc, ctx);
853
968
  let result = await this._runNodeAttempts(nc, ctx);
854
969
  result = await this._questionsLoop(nc, ctx, result);
@@ -909,6 +1024,7 @@ export class GraphOrchestrator extends RunHarness {
909
1024
  }
910
1025
  throw err;
911
1026
  } finally {
1027
+ if (endMark !== 'paused') await this._humanEstimate(ctx);
912
1028
  this._execStep(ctx, endMark);
913
1029
  // A PAUSED slice stays 'running': the resume re-runs the whole composite,
914
1030
  // and a task that never finished must not read as done.
@@ -992,7 +1108,8 @@ export class GraphOrchestrator extends RunHarness {
992
1108
  stepIndex: null,
993
1109
  cycle: ordinal,
994
1110
  uiPhase: this._uiPhaseOf(node.id),
995
- model: nc.model || this.claude.model,
1111
+ // A script has no model: no per-model cost override, no cost-reliability observation.
1112
+ model: nc.kind === 'script' ? null : (nc.model || this.claude.model),
996
1113
  };
997
1114
  return {
998
1115
  // Consumed as `cwd` by phases.mjs (runOpts). runCwd is the run root on a
@@ -1007,8 +1124,9 @@ export class GraphOrchestrator extends RunHarness {
1007
1124
  pipelineId: this.pipeline.id,
1008
1125
  taskPrompt: this.pipeline.promptText,
1009
1126
  toolInstruction: this.toolInstruction,
1010
- memoryBlock: this.memoryBlock || '', // §4.3: the pointer block, rendered once per mount
1011
- memoryMount: this.memory?.mount || null, // absolute mount dir: <runCwd>/.claude/rules/worca (tests + the defrag mock read it)
1127
+ memoryBlock: this.memoryBlock || '', // §4.3: the pointer block, rendered once per mount (names the WRITABLE dirs)
1128
+ memoryMount: this.memory?.mount || null, // the WRITABLE copy: <pipeline.dir>/memory (agents, the defrag mock and tests write here; runOpts passes it as --add-dir)
1129
+ memoryRules: this.memory?.rules || null, // the read-only rules copy inside the cwd (tests read it; agents never write it)
1012
1130
  agentPrompts: this.agentPrompts,
1013
1131
  checkpointRef: this.checkpointRef,
1014
1132
  workspace: this.isWorkspace ? this._workspaceChannel() : undefined,
@@ -1030,7 +1148,7 @@ export class GraphOrchestrator extends RunHarness {
1030
1148
  // model the spawn will actually use, global default included. Live
1031
1149
  // catalog on purpose — a resume re-resolves the env the same way. One
1032
1150
  // settings + plugins-lock read per dispatch; never call this per entry.
1033
- endpointRouted: modelHasBaseUrlRouting(nc.model || this.claude.model),
1151
+ endpointRouted: nc.kind === 'agent' ? modelHasBaseUrlRouting(nc.model || this.claude.model) : false,
1034
1152
  agentPrompt: nc.agentPrompt,
1035
1153
  tools: nc.tools, // frontmatter grants MUST be stamped
1036
1154
  promptHints: nc.promptHints || '',
@@ -1053,6 +1171,10 @@ export class GraphOrchestrator extends RunHarness {
1053
1171
  outputs,
1054
1172
  verdict,
1055
1173
  runCtx,
1174
+ // The script contract (spec §6.1): what script-runner.mjs spawns. Absent on every other kind.
1175
+ script: nc.kind === 'script'
1176
+ ? { meta: nc.meta, runtime: nc.runtime, file: nc.file, command: nc.command, params: nc.params, paramsPort: nc.paramsPort === true, timeoutMs: nc.timeoutMs, mock: nc.mock }
1177
+ : undefined,
1056
1178
  runners: this._runners, // P3's injection seam (runExecution reads ctx.runners)
1057
1179
  resumeSessionId: this._takeResumeSession(executionId),
1058
1180
  ask: (q) => this._enqueueAsk(() => this._ask(q)),
@@ -1108,7 +1230,8 @@ export class GraphOrchestrator extends RunHarness {
1108
1230
  kind: ctx.slice ? 'task' : 'cycle',
1109
1231
  ordinal: ctx.ordinal,
1110
1232
  cycle: ctx.ordinal, // legacy alias the whole UI reads
1111
- agentKey: ctx.node?.key ?? null,
1233
+ agentKey: ctx.node?.kind === 'agent' ? (ctx.node.key ?? null) : null, // agents only (D17: it must not lie)
1234
+ nodeKey: ctx.node?.key ?? null, // every keyed kind
1112
1235
  phase: ctx.node?.key ?? ctx.uiPhase, // legacy column
1113
1236
  stepIndex: null, // a graph has executions, not step indexes
1114
1237
  status,
@@ -1135,6 +1258,11 @@ export class GraphOrchestrator extends RunHarness {
1135
1258
  if (status === 'start') step.endedAt = null;
1136
1259
  }
1137
1260
  if (terminal) step.endedAt = now;
1261
+ if (terminal && ctx.human) {
1262
+ step.humanHours = ctx.human.hours;
1263
+ step.humanSignals = ctx.human.signals;
1264
+ }
1265
+ if (terminal) this.state.humanHours = sumStepHours(this.state.steps);
1138
1266
  if (status === 'start') this._clockResume(key);
1139
1267
  else this._clockPause(key);
1140
1268
  this.state.totalActiveMs = sumStepActive(this.state.steps);
@@ -1161,6 +1289,49 @@ export class GraphOrchestrator extends RunHarness {
1161
1289
  this._persist().catch(() => {});
1162
1290
  }
1163
1291
 
1292
+ /** Intent-to-add every member worktree, then measure. A NEW file is invisible to
1293
+ * `git diff <checkpoint>` until `git add -N` has seen it (the harness stages the same
1294
+ * way before a review); ignoreAbort so the stopped path still measures. Never throws. */
1295
+ async _humanMeasure() {
1296
+ try {
1297
+ return await measureCodeCursor({
1298
+ workDirs: this.workDirs, checkpointRefs: this.checkpointRefs,
1299
+ excludeFor: (k) => this._excludePathspecs(k), numstat: diffNumstat,
1300
+ stage: () => this._stageWorkingTree({ ignoreAbort: true }),
1301
+ });
1302
+ } catch { return null; }
1303
+ }
1304
+
1305
+ /** The baseline at the FIRST agent/script start of a run: under legacy run-root mode the
1306
+ * checkout may already be dirty, and that dirt is not the run's work. A resume restores
1307
+ * the cursor from the resume point instead, so the paused execution's pre-pause work
1308
+ * is credited at its real terminal. Queued, so it lands before any estimate. */
1309
+ _humanCursorInit(ctx) {
1310
+ const kind = ctx?.node?.kind;
1311
+ if (this._humanCursorReady || (kind !== 'agent' && kind !== 'script')) return Promise.resolve();
1312
+ this._humanCursorReady = true;
1313
+ return this._humanRun(async () => { this._humanCursor = await this._humanMeasure(); }).catch(() => {});
1314
+ }
1315
+
1316
+ /** Evidence → hours for ONE terminal execution, serialized with every other measurement of
1317
+ * this run. Never rejects. Flow cards and scripts leave ctx.human null (their row stays
1318
+ * NULL: "not estimated" is not 0); a script still advances the baseline so its own file
1319
+ * changes are never credited to the next agent. */
1320
+ _humanEstimate(ctx) {
1321
+ ctx.human = null;
1322
+ const kind = ctx?.node?.kind;
1323
+ if (kind !== 'agent' && kind !== 'script') return Promise.resolve();
1324
+ return this._humanRun(async () => {
1325
+ const cursorPrev = this._humanCursor;
1326
+ const measured = await this._humanMeasure();
1327
+ if (measured) this._humanCursor = measured;
1328
+ if (kind !== 'agent') return;
1329
+ const evidence = await collectStepEvidence({ ctx, cursorPrev, cursorNow: measured || cursorPrev });
1330
+ const est = estimateStepHours(evidence, resolveConstants(humanEstimateOverrides()));
1331
+ ctx.human = { hours: est.hours, signals: { ...est.signals, method: est.method } };
1332
+ }).catch(() => { ctx.human = null; });
1333
+ }
1334
+
1164
1335
  /** The retry loop around ONE execution — the NODE site of failure-policy.mjs.
1165
1336
  * _recover() resolves the verdict (running the backoff or the recovery prompt
1166
1337
  * on the way); this loop enacts it. A pause throws pauseErr() with
@@ -1241,6 +1412,18 @@ export class GraphOrchestrator extends RunHarness {
1241
1412
  nodeId: ctx.nodeId, executionId: ctx.executionId, port: port.id, cycle: ctx.ordinal,
1242
1413
  });
1243
1414
  }
1415
+ if (nc.kind === 'script') {
1416
+ // The envelope audit copy is an artifact under scripts/ (§6.5); the row gets the runtime facts (D17).
1417
+ if (result?.envelopePath) {
1418
+ this._artifact('envelope', result.envelopePath, { nodeId: ctx.nodeId, executionId: ctx.executionId, port: null, cycle: ctx.ordinal });
1419
+ }
1420
+ const step = this.state.steps.find((s) => s.key === ctx.executionId);
1421
+ if (step) {
1422
+ step.runtime = result?.runtime ?? nc.runtime ?? null;
1423
+ step.exitCode = result?.exitCode ?? null;
1424
+ }
1425
+ return; // no memory sync, no worktree staging
1426
+ }
1244
1427
  // Agent memory (§5): sync the mount back after EVERY execution, slices included.
1245
1428
  await this._syncMemory(nc, ctx);
1246
1429
  if (nc.meta?.sideEffect === 'code' && !ctx.slice) await this._stageWorkingTree();
@@ -1272,6 +1455,14 @@ export class GraphOrchestrator extends RunHarness {
1272
1455
  ctx.questionsAnswered = readStepQuestions(this.pipeline.id)
1273
1456
  .filter((r) => r.nodeId === ctx.nodeId)
1274
1457
  .flatMap((r) => r.answers);
1458
+ // Spec §4: the forms this agent may ask with (absent for every agent that
1459
+ // declares none, which keeps questionsPromptBlock byte-identical), and the
1460
+ // form answers already given for this NODE — same node-scoped filter as the
1461
+ // legacy answers above, for the same reason (a fix cycle is a new execution).
1462
+ ctx.askForms = nc.meta?.ask?.forms || null;
1463
+ ctx.formAnswers = readStepQuestions(this.pipeline.id)
1464
+ .filter((r) => r.nodeId === ctx.nodeId && r.formAnswer)
1465
+ .map((r) => r.formAnswer);
1275
1466
  ctx.questionsFile = this._questionsPath(ctx.nodeId, ctx.ordinal, 1);
1276
1467
  }
1277
1468
 
@@ -1289,6 +1480,66 @@ export class GraphOrchestrator extends RunHarness {
1289
1480
  return join(this.pipeline.dir, `questions-x-${nodeIdSafe}-c${ordinal}-r${round}.json`);
1290
1481
  }
1291
1482
 
1483
+ /**
1484
+ * Gate 2 for a `{form,data}` ask (spec §5). Prepares the ask; on a refusal the
1485
+ * agent is resumed ONCE with the exact error list plus the data schema and the
1486
+ * SAME round file, and its retry is re-read. A second refusal downgrades to one
1487
+ * generic free-text question built from the form's title, and the run log says
1488
+ * why. Never throws, and never consumes a question round — MAX_QUESTION_ROUNDS
1489
+ * still bounds what the USER sees.
1490
+ * @returns {Promise<{ask:object|null, autoValues:object|null, questions:Array, result:object|undefined}>}
1491
+ */
1492
+ async _prepareFormAsk(nc, ctx, first, qPath, round, agentLabel) {
1493
+ const attr = { nodeId: ctx.nodeId, executionId: ctx.executionId, cycle: ctx.ordinal };
1494
+ let payload = first;
1495
+ let result;
1496
+ for (let attempt = 1; attempt <= 2; attempt++) {
1497
+ const prepared = await prepareFormAsk({
1498
+ agentMeta: nc.meta,
1499
+ payload: { form: payload.form, data: payload.data },
1500
+ cwd: ctx.projectDir,
1501
+ pipelineDir: this.pipeline.dir,
1502
+ askId: `questions-${ctx.executionId}-r${round}`,
1503
+ });
1504
+ if (prepared.ok) return { ask: prepared.ask, autoValues: prepared.autoValues, questions: [], result };
1505
+
1506
+ const why = prepared.errors.map((e) => `${e.path ? `${e.path}: ` : ''}${e.message}`).join('; ');
1507
+ this._log(agentLabel, 'warn', `form "${payload.form}" was refused: ${why}`, attr);
1508
+ await appendAudit(this.pipeline.dir,
1509
+ `${agentLabel}: form "${payload.form}" was refused — ${why}`).catch(() => {});
1510
+ if (attempt === 2) break;
1511
+
1512
+ // ONE repair round: the errors + the schema, and the SAME file to rewrite.
1513
+ ctx.formRepair = {
1514
+ form: payload.form,
1515
+ errors: prepared.errors,
1516
+ schema: nc.meta?.ask?.forms?.[payload.form]?.data || null,
1517
+ file: qPath,
1518
+ };
1519
+ await rm(qPath, { force: true }).catch(() => {});
1520
+ const step = this.state.steps.find((s) => s.key === ctx.executionId);
1521
+ if (step?.sessionId) ctx.resumeSessionId = step.sessionId;
1522
+ try {
1523
+ result = await this._runNodeAttempts(nc, ctx);
1524
+ } finally {
1525
+ ctx.formRepair = null;
1526
+ }
1527
+ this._checkAbort();
1528
+ const retry = await readAskFile(qPath);
1529
+ // The agent may give up on the form and write plain questions instead (or
1530
+ // write nothing): take whatever it DID write, exactly as a legacy round would.
1531
+ if (retry.kind !== 'form') {
1532
+ return { ask: null, autoValues: null, questions: retry.kind === 'questions' ? retry.questions : [], result };
1533
+ }
1534
+ payload = retry;
1535
+ }
1536
+ const title = nc.meta?.ask?.forms?.[payload.form]?.title || '';
1537
+ this._log(agentLabel, 'warn', `form "${payload.form}" downgraded to a free-text question`, attr);
1538
+ await appendAudit(this.pipeline.dir,
1539
+ `${agentLabel}: form "${payload.form}" downgraded to a free-text question after two refusals.`).catch(() => {});
1540
+ return { ask: null, autoValues: null, questions: [downgradeQuestion({ form: payload.form, title })], result };
1541
+ }
1542
+
1292
1543
  /**
1293
1544
  * Ask-then-resume rounds. After a successful execution: if the agent wrote this
1294
1545
  * round's questions file, persist the questions, gate the user (serialized —
@@ -1307,14 +1558,74 @@ export class GraphOrchestrator extends RunHarness {
1307
1558
  for (let round = 1; round <= MAX_QUESTION_ROUNDS; round++) {
1308
1559
  const qPath = ctx.questionsFile;
1309
1560
  if (!qPath) break;
1310
- const { questions, malformed } = await readQuestionsFile(qPath);
1311
- if (!questions.length) {
1312
- if (malformed) {
1561
+ // `read`, not `payload`: the legacy body below still declares `const payload` for the answer.
1562
+ const read = await readAskFile(qPath);
1563
+ if (read.kind === 'none') {
1564
+ if (read.malformed) {
1313
1565
  await appendAudit(this.pipeline.dir, `${agentLabel}: questions file was malformed — proceeding without asking (round ${round}).`).catch(() => {});
1314
1566
  }
1315
1567
  break;
1316
1568
  }
1317
1569
  this._checkAbort();
1570
+ let questions = read.kind === 'questions' ? read.questions : [];
1571
+ let formAsk = null;
1572
+ let autoValues = null;
1573
+ if (read.kind === 'form') {
1574
+ // Gate 2 (spec §5). It may spawn ONE repair round of its own, whose
1575
+ // result becomes this round's result; it never consumes a round.
1576
+ const gate = await this._prepareFormAsk(nc, ctx, read, qPath, round, agentLabel);
1577
+ if (gate.result !== undefined) result = gate.result;
1578
+ formAsk = gate.ask;
1579
+ autoValues = gate.autoValues;
1580
+ questions = gate.questions;
1581
+ }
1582
+ if (!formAsk && !questions.length) break;
1583
+ if (formAsk) {
1584
+ // §9: the persisted ask is the FULL resolved snapshot — History renders
1585
+ // it after the agent's sidecar changed or its plugin was removed. The
1586
+ // column is schemaless JSON TEXT, so nothing migrates.
1587
+ await writeStepQuestions(this.pipeline.id, stepKey, round, {
1588
+ agentKey: nc.key, nodeId: ctx.nodeId, questions: formAsk,
1589
+ });
1590
+ this._artifact('questions', qPath, { nodeId: ctx.nodeId, executionId: ctx.executionId, port: null, cycle: ctx.ordinal });
1591
+ await appendAudit(this.pipeline.dir, `${agentLabel} asked with form "${formAsk.form}" (round ${round}).`).catch(() => {});
1592
+ const answered = await this._enqueueAsk(() => this._ask({
1593
+ id: `questions-${stepKey}-r${round}`,
1594
+ kind: 'form',
1595
+ agent: agentLabel,
1596
+ nodeId: ctx.nodeId,
1597
+ executionId: ctx.executionId,
1598
+ askId: formAsk.askId, // ruling X1: the ROUTE token, not `id`
1599
+ form: formAsk.form,
1600
+ version: formAsk.version,
1601
+ title: formAsk.title,
1602
+ surface: formAsk.surface,
1603
+ data: formAsk.data,
1604
+ layout: formAsk.layout,
1605
+ answerSchema: formAsk.answerSchema,
1606
+ fileRefs: formAsk.fileRefs,
1607
+ files: formAsk.files,
1608
+ autoValues, // D10, auto mode only
1609
+ validate: formAnswerValidator(formAsk), // gate 3
1610
+ }));
1611
+ this._checkAbort();
1612
+ const values = (answered && typeof answered === 'object' && answered.values) || {};
1613
+ await writeStepQuestions(this.pipeline.id, stepKey, round, {
1614
+ agentKey: nc.key, nodeId: ctx.nodeId,
1615
+ answers: { kind: 'form', form: formAsk.form, version: formAsk.version, values },
1616
+ });
1617
+ await appendAudit(this.pipeline.dir, `${agentLabel}: form "${formAsk.form}" answered (round ${round}).`).catch(() => {});
1618
+ await rm(qPath, { force: true }).catch(() => {});
1619
+ const step = this.state.steps.find((s) => s.key === stepKey);
1620
+ if (step?.sessionId) ctx.resumeSessionId = step.sessionId;
1621
+ ctx.formAnswers = [...(ctx.formAnswers || []), { form: formAsk.form, version: formAsk.version, values }];
1622
+ ctx.questionsFile = round < MAX_QUESTION_ROUNDS
1623
+ ? this._questionsPath(ctx.nodeId, ctx.ordinal, round + 1)
1624
+ : null;
1625
+ this._log(agentLabel, 'debug', `resuming with form "${formAsk.form}" answers (round ${round})`, attr);
1626
+ result = await this._runNodeAttempts(nc, ctx);
1627
+ continue;
1628
+ }
1318
1629
  await writeStepQuestions(this.pipeline.id, stepKey, round, {
1319
1630
  agentKey: nc.key, nodeId: ctx.nodeId, questions: { questions },
1320
1631
  });
@@ -1458,13 +1769,17 @@ export class GraphOrchestrator extends RunHarness {
1458
1769
  this._resumeSnapshot = rp.snapshot || null;
1459
1770
  this._graphSnapshot = rp.snapshot || null;
1460
1771
  this._planVersion = Number.isFinite(rp.planVersion) ? rp.planVersion : 0;
1772
+ if (rp.humanCursor && typeof rp.humanCursor === 'object') { this._humanCursor = rp.humanCursor; this._humanCursorReady = true; }
1461
1773
  this._clearPauseReason();
1462
1774
  const manifest = rp.manifest || this.state.stepper;
1463
1775
  this.state.stepper = manifest;
1464
- this._adoptResolvedGraph(resolvedFromManifest(manifest, this.registry));
1776
+ this.scriptRegistry = loadScriptRegistry({ scriptsDir: this.opts.scriptsDir, agentKeys: Object.keys(this.registry || {}) });
1777
+ this._adoptResolvedGraph(resolvedFromManifest(manifest, this.registry, this.scriptRegistry));
1465
1778
  // §9.4, unchanged messages: the providing plugin may have been disabled or
1466
1779
  // uninstalled while this run sat paused. (Same place v1 re-preflights.)
1467
1780
  this._preflightAgentKeys(this.resolved.agentKeys);
1781
+ this._preflightScriptKeys(this.resolved.scriptKeys);
1782
+ await this._preflightScriptRuntimes();
1468
1783
  // Prompt bodies + frontmatter tools: the one thing the manifest never carries.
1469
1784
  const cache = new Map();
1470
1785
  for (const nc of Object.values(this.resolved.nodeCtx)) {
@@ -1497,13 +1812,15 @@ export class GraphOrchestrator extends RunHarness {
1497
1812
  * mockRole, displayName).
1498
1813
  * @param {object} manifest a manifest v2
1499
1814
  * @param {Record<string,object>} registry loadAgentRegistry() output
1500
- * @returns {{template:object, ports:Function, loops:object, nodes:Record<string,object>, wires:Record<string,{maxCycles:number}>, agentsByKey:Record<string,object>, agentKeys:Set<string>}}
1815
+ * @param {Record<string,object>} [scripts] loadScriptRegistry() output (script nodes)
1816
+ * @returns {{template:object, ports:Function, loops:object, nodes:Record<string,object>, wires:Record<string,{maxCycles:number}>, agentsByKey:Record<string,object>, agentKeys:Set<string>, scriptsByKey:Record<string,object>, scriptKeys:Set<string>}}
1501
1817
  */
1502
- export function resolvedFromManifest(manifest, registry) {
1818
+ export function resolvedFromManifest(manifest, registry, scripts = {}) {
1503
1819
  const reg = registry && typeof registry === 'object' ? registry : {};
1820
+ const scr = scripts && typeof scripts === 'object' ? scripts : {};
1504
1821
  const template = manifestTemplate(manifest); // restores node.config + loop wire config.maxCycles verbatim
1505
1822
  const manPorts = manifestPortsFn(manifest);
1506
- const regPorts = registryPortsFn(reg);
1823
+ const regPorts = registryPortsFn(reg, scr);
1507
1824
  const ports = (node) => {
1508
1825
  const snap = manPorts(node);
1509
1826
  const live = regPorts(node) || { inputs: [], outputs: [] };
@@ -1522,6 +1839,13 @@ export function resolvedFromManifest(manifest, registry) {
1522
1839
  const nodeCtx = {};
1523
1840
  const keyCounts = new Map();
1524
1841
  for (const mn of manifest.graph?.nodes || []) {
1842
+ if (mn.kind === 'script') {
1843
+ keyCounts.set(mn.key, (keyCounts.get(mn.key) || 0) + 1);
1844
+ // v4 T5: the SAME builder resolveGraph uses, over the manifest cell's AUTHORED config (the manifest keeps
1845
+ // `config` verbatim) and the LIVE registry entry. No entry => a stub (`file: null`) the preflight refuses.
1846
+ nodeCtx[mn.id] = scriptNodeCtx({ id: mn.id, key: mn.key, config: mn.config }, scr[mn.key]);
1847
+ continue;
1848
+ }
1525
1849
  if (mn.kind !== 'agent') {
1526
1850
  nodeCtx[mn.id] = { nodeId: mn.id, kind: mn.kind, key: null, config: { ...(mn.config || {}) } };
1527
1851
  continue;
@@ -1546,7 +1870,7 @@ export function resolvedFromManifest(manifest, registry) {
1546
1870
  };
1547
1871
  }
1548
1872
  for (const nc of Object.values(nodeCtx)) {
1549
- if (nc.kind === 'agent') nc.duplicateKey = (keyCounts.get(nc.key) || 0) > 1;
1873
+ if (KEYED_KINDS.includes(nc.kind)) nc.duplicateKey = (keyCounts.get(nc.key) || 0) > 1;
1550
1874
  }
1551
1875
  const wires = {};
1552
1876
  for (const w of manifest.graph?.wires || []) {
@@ -1559,5 +1883,12 @@ export function resolvedFromManifest(manifest, registry) {
1559
1883
  agentsByKey[nc.key] = nc.meta;
1560
1884
  agentKeys.add(nc.key);
1561
1885
  }
1562
- return { template, ports, loops: classifyLoops(template, ports), nodes: nodeCtx, wires, agentsByKey, agentKeys };
1886
+ const scriptsByKey = {};
1887
+ const scriptKeys = new Set();
1888
+ for (const nc of Object.values(nodeCtx)) {
1889
+ if (nc.kind !== 'script') continue;
1890
+ scriptsByKey[nc.key] = nc.meta;
1891
+ scriptKeys.add(nc.key);
1892
+ }
1893
+ return { template, ports, loops: classifyLoops(template, ports), nodes: nodeCtx, wires, agentsByKey, agentKeys, scriptsByKey, scriptKeys };
1563
1894
  }