@cat-factory/orchestration 0.36.5 → 0.37.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.
@@ -65,6 +65,16 @@ function parseRepoFromPullUrl(url) {
65
65
  return undefined;
66
66
  return { owner: match[1], repo: match[2] };
67
67
  }
68
+ /**
69
+ * The inline review/brainstorm gate kinds, all driven through the {@link ReviewGateController}
70
+ * by the engine's `review-gate` StepHandler. Kept in sync with the handler's `switch`.
71
+ */
72
+ const REVIEW_GATE_AGENT_KINDS = new Set([
73
+ REQUIREMENTS_REVIEW_AGENT_KIND,
74
+ CLARITY_REVIEW_AGENT_KIND,
75
+ REQUIREMENTS_BRAINSTORM_AGENT_KIND,
76
+ ARCHITECTURE_BRAINSTORM_AGENT_KIND,
77
+ ]);
68
78
  /**
69
79
  * The execution engine. It orchestrates a pipeline of agent-performed steps and
70
80
  * is fully deterministic: `advanceInstance` moves one run forward by exactly one
@@ -153,7 +163,12 @@ export class ExecutionService {
153
163
  * and {@link StepHandler}. Engine-internal (no public registration seam).
154
164
  */
155
165
  stepHandlerCache;
156
- constructor({ workspaceRepository, blockRepository, pipelineRepository, executionRepository, accountRepository, idGenerator, clock, agentExecutor, workRunner, executionEventPublisher, boardService, spendService, documentRepository, documentUrlResolver, taskRepository, requirementReviewRepository, requirementReviewService, kaizenScheduler, clarityReviewRepository, clarityReviewService, brainstormServices, brainstormSessionRepository, fragmentResolver, environmentProvisioning, environmentTeardown, branchUpdater, blueprintReconciler, notificationService, binaryArtifactStore, workspaceSettingsService, llmObservability, pullRequestMerger, mergePresetRepository, ticketTrackerProvider, issueWriteback, subscriptionActivationRepository, resolveWorkspaceModelDefault, resolveProviderCapabilities, localTestInfraSupported, resolveRunRepoContext, resolveTesterFallbackDefault, resolveRequireEnvironmentProvider, assertAgentBackendConfigured, runInitiatorScope, }) {
166
+ /**
167
+ * Lazily-built, order-sorted completion-path interceptor list (companion/tester verdict
168
+ * short-circuits). See {@link dispatchStepCompletionInterceptor}. Engine-internal.
169
+ */
170
+ stepCompletionInterceptorCache;
171
+ constructor({ workspaceRepository, blockRepository, pipelineRepository, executionRepository, accountRepository, idGenerator, clock, agentExecutor, workRunner, executionEventPublisher, boardService, spendService, documentRepository, documentUrlResolver, taskRepository, requirementReviewRepository, requirementReviewService, kaizenScheduler, clarityReviewRepository, clarityReviewService, brainstormServices, brainstormSessionRepository, fragmentResolver, environmentProvisioning, environmentTeardown, branchUpdater, blueprintReconciler, notificationService, resolveBinaryArtifactStore, workspaceSettingsService, llmObservability, pullRequestMerger, mergePresetRepository, ticketTrackerProvider, issueWriteback, subscriptionActivationRepository, resolveWorkspaceModelDefault, resolveProviderCapabilities, localTestInfraSupported, resolveRunRepoContext, resolveTesterFallbackDefault, resolveRequireEnvironmentProvider, assertAgentBackendConfigured, runInitiatorScope, }) {
157
172
  this.runInitiatorScope = runInitiatorScope ?? ((_initiatedBy, fn) => fn());
158
173
  this.workspaceRepository = workspaceRepository;
159
174
  this.blockRepository = blockRepository;
@@ -271,7 +286,7 @@ export class ExecutionService {
271
286
  agentExecutor,
272
287
  contextBuilder: this.contextBuilder,
273
288
  notificationService,
274
- ...(binaryArtifactStore ? { binaryArtifactStore } : {}),
289
+ ...(resolveBinaryArtifactStore ? { resolveBinaryArtifactStore } : {}),
275
290
  resolveMergePreset: (ws, block) => this.resolveMergePreset(ws, block),
276
291
  parkStepOnDecision: (ws, i, s, p) => this.parkStepOnDecision(ws, i, s, p),
277
292
  finishStep: (s) => this.finishStep(s),
@@ -819,7 +834,7 @@ export class ExecutionService {
819
834
  }
820
835
  // The fixed run-lifecycle preamble is done; hand the per-kind work to the
821
836
  // engine-internal StepHandler registry (the first handler whose `canHandle` claims
822
- // this step). See {@link dispatchStepHandler} / {@link runStepBody}.
837
+ // this step). See {@link dispatchStepHandler} / {@link handleAgentStep}.
823
838
  return this.dispatchStepHandler({
824
839
  workspaceId,
825
840
  instance,
@@ -830,77 +845,16 @@ export class ExecutionService {
830
845
  });
831
846
  }
832
847
  /**
833
- * The per-step-kind body run by the StepHandler registry once {@link stepInstance}'s
834
- * preamble has completed. Phase 0 of the ExecutionService split: a single fallthrough
835
- * handler delegates the ENTIRE body here unchanged (so dispatch is wired with zero
836
- * behaviour change); later phases lift each branch below into its own handler and shrink
837
- * this method until only the generic container/inline-agent tail remains.
848
+ * The generic container/inline-agent step the lowest-priority StepHandler, claiming
849
+ * every step no more-specific handler did (coder, architect, spec-writer, merger,
850
+ * task-estimator, the container-backed companions, …). Builds the agent context, runs the
851
+ * kind's pre-ops, then either dispatches an async container job and parks (the durable
852
+ * driver polls between sleeps) or runs the inline LLM call and records the result. This is
853
+ * what the dispatch chain falls through to; all the deterministic / gate / inline-review
854
+ * kinds are claimed earlier by their own handlers (see {@link buildStepHandlerRegistry}).
838
855
  */
839
- async runStepBody(ctx) {
856
+ async handleAgentStep(ctx) {
840
857
  const { workspaceId, instance, step, block, isFinalStep, options } = ctx;
841
- // (The `deployer` and `tracker` steps are handled by their own StepHandlers — see
842
- // {@link buildStepHandlerRegistry} — so they no longer branch here.)
843
- // A `requirements-review` step runs the inline reviewer and parks for the dedicated
844
- // review window, driving the iterative answer → incorporate → re-review loop. NOT a
845
- // container/prose agent. Pass-through when the reviewer isn't wired. The clarity gate
846
- // shares the SAME flow (only the subject + persisted doc differ); both run through the
847
- // {@link ReviewGateController}, parameterised by their {@link ReviewKind}.
848
- if (step.agentKind === REQUIREMENTS_REVIEW_AGENT_KIND) {
849
- return this.reviewGate.evaluate(this.requirementsKind, workspaceId, instance, step, block, isFinalStep);
850
- }
851
- // A `clarity-review` step triages the block's bug report (optionally enriched by an
852
- // upstream `bug-investigator` step) and parks for the dedicated review window, driving
853
- // the same iterative loop as the requirements gate. NOT a container/prose agent.
854
- // Pass-through when the reviewer isn't wired.
855
- if (step.agentKind === CLARITY_REVIEW_AGENT_KIND) {
856
- return this.reviewGate.evaluate(this.clarityKind, workspaceId, instance, step, block, isFinalStep);
857
- }
858
- // The two brainstorm (structured-dialogue) gates run the inline option-generator and park
859
- // for the dedicated brainstorm window, driving the same iterative loop as the requirements
860
- // gate. NOT container/prose agents. Pass-through when the brainstorm module isn't wired.
861
- if (step.agentKind === REQUIREMENTS_BRAINSTORM_AGENT_KIND) {
862
- return this.reviewGate.evaluate(this.requirementsBrainstormKind, workspaceId, instance, step, block, isFinalStep);
863
- }
864
- if (step.agentKind === ARCHITECTURE_BRAINSTORM_AGENT_KIND) {
865
- return this.reviewGate.evaluate(this.architectureBrainstormKind, workspaceId, instance, step, block, isFinalStep);
866
- }
867
- // A `human-test` gate spins up an ephemeral environment and PARKS for a human to
868
- // validate the change in a live URL before the run continues — NOT a container/prose
869
- // agent and NOT a programmatic polling gate (the human is the verdict). It also drives
870
- // the same helpers the other gates use on demand: the Tester's `fixer` (from findings)
871
- // and the `conflict-resolver` (after a conflicting pull-main). Degrades to a manual
872
- // (no-env) mode when no ephemeral-environment provider is wired. See {@link HumanTestController}.
873
- if (step.agentKind === HUMAN_TEST_AGENT_KIND) {
874
- return this.humanTestController.evaluate(workspaceId, instance, step, block, isFinalStep);
875
- }
876
- // A `visual-confirmation` gate gathers the UI tester's screenshots + the uploaded
877
- // reference designs and PARKS for a human to review actual-vs-reference, then on demand
878
- // dispatches the Tester's `fixer`. Passes through (auto-advances) when no binary-artifact
879
- // store is wired. See {@link VisualConfirmationController}.
880
- if (step.agentKind === VISUAL_CONFIRM_AGENT_KIND) {
881
- return this.visualConfirmationController.evaluate(workspaceId, instance, step, block, isFinalStep);
882
- }
883
- // A polling gate step (`ci` / `conflicts`) runs a programmatic precheck and only
884
- // escalates to a helper container agent (`ci-fixer` / `conflict-resolver`) on a
885
- // negative verdict — no LLM of its own. Pass-through when the gate's provider is
886
- // not wired. One generic machine drives every gate; see {@link evaluateGate}.
887
- const gate = this.gateFor(step.agentKind);
888
- if (gate) {
889
- return this.evaluateGate(workspaceId, instance, step, block, isFinalStep, gate);
890
- }
891
- // A companion step grades the nearest preceding producer of one of its target
892
- // kinds, looping it back for automatic rework below the threshold (and failing
893
- // the run once the budget is spent) before any human gate. See evaluateCompanion.
894
- //
895
- // INLINE companions (architect-companion / spec-companion) run their LLM grading right
896
- // here. CONTAINER-backed companions (reviewer / doc-reviewer) instead fall through to the
897
- // generic async container dispatch below — they clone the producer's PR branch and review
898
- // the REAL repository — and their verdict is resolved in `recordStepResult` via
899
- // `companionController.resolveContainerVerdict` (which runs the SAME threshold / rework
900
- // loop). A summary-only review is useless; the container reviewer reads the actual diff.
901
- if (isCompanionKind(step.agentKind) && !isContainerBackedCompanion(step.agentKind)) {
902
- return this.companionController.evaluate(workspaceId, instance, step, block, isFinalStep, options);
903
- }
904
858
  // Async (container) steps don't block: dispatch the job and park. The durable
905
859
  // driver polls `pollAgentJob` between sleeps so the run can span far longer
906
860
  // than a single durable step's timeout, while each step stays short. A set
@@ -1549,26 +1503,21 @@ export class ExecutionService {
1549
1503
  await this.emitInstance(workspaceId, instance);
1550
1504
  return { kind: 'awaiting_decision', decisionId: step.decision.id };
1551
1505
  }
1552
- // A container-backed companion (reviewer / doc-reviewer) just finished reviewing the
1553
- // real repository on the producer's PR branch and returned its verdict as `result.custom`.
1554
- // Hand it to the companion loop, which parses the verdict and applies the SAME threshold /
1555
- // rework / human-gate handling an inline companion gets. Routed here (not the normal step
1556
- // completion) so the verdict drives the loop instead of being recorded as plain output.
1557
- if (isCompanionKind(step.agentKind) && isContainerBackedCompanion(step.agentKind)) {
1558
- const companionBlock = await this.blockRepository.get(workspaceId, instance.blockId);
1559
- if (companionBlock) {
1560
- return this.companionController.resolveContainerVerdict(workspaceId, instance, step, companionBlock, isFinalStep, result);
1561
- }
1562
- }
1563
- // A `tester` step returned a structured report. On a withheld greenlight we do
1564
- // NOT finish the step: we loop the `fixer` (within the attempt budget) and
1565
- // re-test, mirroring the CI gate. A greenlight (or no provider) falls through to
1566
- // the normal finish/advance below. Records the report on the step either way.
1567
- if (isTesterKind(step.agentKind) && result.testReport !== undefined) {
1568
- const looped = await this.testerController.resolveTesterResult(workspaceId, instance, step, result);
1569
- if (looped)
1570
- return looped;
1571
- }
1506
+ // Completion-path interceptors short-circuit before the normal finish/advance for the
1507
+ // few kinds whose verdict drives run flow: a container-backed companion applies its
1508
+ // threshold/rework/human-gate loop, and a Tester re-runs its `fixer` on a withheld
1509
+ // greenlight. A non-null outcome replaces the normal completion; null (a Tester
1510
+ // greenlight, or a companion whose block can't be loaded) falls through. See
1511
+ // {@link buildStepCompletionInterceptors}.
1512
+ const intercepted = await this.dispatchStepCompletionInterceptor({
1513
+ workspaceId,
1514
+ instance,
1515
+ step,
1516
+ isFinalStep,
1517
+ result,
1518
+ });
1519
+ if (intercepted)
1520
+ return intercepted;
1572
1521
  // The step completed.
1573
1522
  step.output = result.output ?? '';
1574
1523
  // Surface a registered custom kind's structured JSON on the step so the SPA's
@@ -1612,38 +1561,25 @@ export class ExecutionService {
1612
1561
  .catch(() => { });
1613
1562
  }
1614
1563
  }
1615
- // A Blueprinter step produced a fresh service decomposition. Validate it with
1616
- // the authoritative schema (a bad payload must never touch the board), then
1617
- // reconcile it in place onto the run's service frame.
1618
- if (result.blueprintService !== undefined) {
1619
- await this.ingestBlueprint(workspaceId, instance.blockId, result.blueprintService);
1620
- }
1621
- // A spec-writer step produced the service's unified specification (`spec.json`)
1622
- // and committed it to the implementation branch. Strict-validate it (a bad payload
1623
- // must never be trusted), then nudge clients to refresh.
1624
- if (result.spec !== undefined) {
1625
- await this.ingestSpec(workspaceId, result.spec);
1626
- }
1627
- // Record the spec-writer's BUSINESS-vs-TECHNICAL determination on the step. "No
1628
- // business specs" (a purely technical task) is a valid outcome; the spec-companion's
1629
- // convergence — the one point both signals coexist — combines this with the
1630
- // companion's `technicalCorroborated` verdict to infer the block's `technical` label
1631
- // (see CompanionController). Recorded even when false so a re-run reflects the latest.
1632
- if (step.agentKind === SPEC_WRITER_AGENT_KIND) {
1633
- step.noBusinessSpecs = result.noBusinessSpecs === true;
1634
- }
1635
- // A `task-estimator` step emits a JSON triage (complexity/risk/impact). Parse it
1636
- // tolerantly, persist it on the block (used to gate consensus steps + surfaced in
1637
- // the UI), and replace the raw JSON output with a readable summary. An unparseable
1638
- // estimate leaves the block untouched and keeps the raw output (no run failure).
1639
- // The estimate works the same whether the single-actor estimator or the consensus
1640
- // ranked-scoring variant produced the JSON — both land here.
1641
- if (step.agentKind === TASK_ESTIMATOR_AGENT_KIND) {
1642
- const estimate = coerceTaskEstimate(step.output, result.model ?? step.model ?? null, this.clock.now());
1643
- if (estimate) {
1644
- await this.blockRepository.update(workspaceId, instance.blockId, { estimate });
1645
- step.output = summarizeEstimate(estimate);
1646
- }
1564
+ // Run any POST-COMPLETION resolver registered for this step kind (blueprint/spec
1565
+ // ingestion, task-estimate persistence). It reshapes the agent's structured result into
1566
+ // domain state and may replace `step.output` (the estimator's readable summary). Its
1567
+ // POSITION is load-bearing — it runs after the output is recorded but BEFORE the
1568
+ // reviewable-output rendering and the follow-up/approval gates read `step.output`, so it
1569
+ // sits exactly where the old inline ingestion branches did. See
1570
+ // {@link buildStepResolverRegistry} and {@link StepCompletionResolver.phase}.
1571
+ const postCompletionResolver = this.stepResolverFor(step.agentKind);
1572
+ if (postCompletionResolver?.phase === 'post-completion' &&
1573
+ (postCompletionResolver.applies?.(result) ?? true)) {
1574
+ const resolution = await postCompletionResolver.resolve({
1575
+ workspaceId,
1576
+ instance,
1577
+ step,
1578
+ result,
1579
+ isFinalStep,
1580
+ });
1581
+ if (resolution?.output !== undefined)
1582
+ step.output = resolution.output;
1647
1583
  }
1648
1584
  // A producer that emits a STRUCTURED ARTIFACT (the spec doc, the blueprint tree, …)
1649
1585
  // returns its raw Pi transcript summary as `result.output` — useless for review.
@@ -1702,7 +1638,9 @@ export class ExecutionService {
1702
1638
  // tells `finalizeBlock` to leave it alone.
1703
1639
  const resolver = this.stepResolverFor(step.agentKind);
1704
1640
  let resolverOwnsTerminalStatus = false;
1705
- if (resolver && (resolver.applies?.(result) ?? true)) {
1641
+ if (resolver &&
1642
+ (resolver.phase ?? 'terminal') === 'terminal' &&
1643
+ (resolver.applies?.(result) ?? true)) {
1706
1644
  const resolution = await resolver.resolve({
1707
1645
  workspaceId,
1708
1646
  instance,
@@ -2118,17 +2056,144 @@ export class ExecutionService {
2118
2056
  return this.recordStepResult(workspaceId, instance, step, isFinalStep, result);
2119
2057
  },
2120
2058
  },
2121
- // The generic container/inline-agent fallthrough claims every step no more-specific
2122
- // handler did (today's `runStepBody` tail). Highest order so it always runs last.
2059
+ // The `requirements-review` / `clarity-review` / `requirements-brainstorm` /
2060
+ // `architecture-brainstorm` steps are inline reviewers that park for their dedicated
2061
+ // window and drive the iterative answer → incorporate → re-review loop — NOT
2062
+ // container/prose agents. All four run through the {@link ReviewGateController},
2063
+ // parameterised by their {@link ReviewKind} (the per-case `switch` binds each kind's
2064
+ // review type so `evaluate`'s generic infers). Pass-through when the service isn't wired.
2123
2065
  {
2124
- kind: '*',
2066
+ kind: 'review-gate',
2067
+ order: 120,
2068
+ canHandle: ({ step }) => REVIEW_GATE_AGENT_KINDS.has(step.agentKind),
2069
+ handle: ({ workspaceId, instance, step, block, isFinalStep }) => {
2070
+ switch (step.agentKind) {
2071
+ case REQUIREMENTS_REVIEW_AGENT_KIND:
2072
+ return this.reviewGate.evaluate(this.requirementsKind, workspaceId, instance, step, block, isFinalStep);
2073
+ case CLARITY_REVIEW_AGENT_KIND:
2074
+ return this.reviewGate.evaluate(this.clarityKind, workspaceId, instance, step, block, isFinalStep);
2075
+ case REQUIREMENTS_BRAINSTORM_AGENT_KIND:
2076
+ return this.reviewGate.evaluate(this.requirementsBrainstormKind, workspaceId, instance, step, block, isFinalStep);
2077
+ case ARCHITECTURE_BRAINSTORM_AGENT_KIND:
2078
+ return this.reviewGate.evaluate(this.architectureBrainstormKind, workspaceId, instance, step, block, isFinalStep);
2079
+ // `canHandle` admits only the kinds in REVIEW_GATE_AGENT_KINDS, so every member
2080
+ // must have an explicit case above. Throw loudly if the two ever drift (a new
2081
+ // review kind added to the Set without a case here) rather than silently routing
2082
+ // it to the wrong reviewer.
2083
+ default:
2084
+ throw new Error(`Unhandled review-gate agentKind "${step.agentKind}"`);
2085
+ }
2086
+ },
2087
+ },
2088
+ // A `human-test` gate spins up an ephemeral environment and PARKS for a human to
2089
+ // validate the change in a live URL — NOT a container/prose agent and NOT a
2090
+ // programmatic polling gate (the human is the verdict). Degrades to a manual (no-env)
2091
+ // mode when no ephemeral-environment provider is wired. See {@link HumanTestController}.
2092
+ {
2093
+ kind: HUMAN_TEST_AGENT_KIND,
2094
+ order: 130,
2095
+ canHandle: ({ step }) => step.agentKind === HUMAN_TEST_AGENT_KIND,
2096
+ handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.humanTestController.evaluate(workspaceId, instance, step, block, isFinalStep),
2097
+ },
2098
+ // A `visual-confirmation` gate gathers the UI tester's screenshots + uploaded reference
2099
+ // designs and PARKS for a human to review actual-vs-reference, then on demand dispatches
2100
+ // the Tester's `fixer`. Passes through when no binary-artifact store is wired.
2101
+ // See {@link VisualConfirmationController}.
2102
+ {
2103
+ kind: VISUAL_CONFIRM_AGENT_KIND,
2104
+ order: 140,
2105
+ canHandle: ({ step }) => step.agentKind === VISUAL_CONFIRM_AGENT_KIND,
2106
+ handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.visualConfirmationController.evaluate(workspaceId, instance, step, block, isFinalStep),
2107
+ },
2108
+ // A polling gate step (`ci` / `conflicts` / `post-release-health` / `human-review`) runs
2109
+ // a programmatic precheck and only escalates to a helper container agent on a negative
2110
+ // verdict — no LLM of its own. Pass-through when the gate's provider is not wired. One
2111
+ // generic machine drives every gate; see {@link evaluateGate}. `canHandle` is the gate
2112
+ // registry lookup, so this claims exactly the registered gate kinds.
2113
+ {
2114
+ kind: 'polling-gate',
2115
+ order: 150,
2116
+ canHandle: ({ step }) => this.gateFor(step.agentKind) !== undefined,
2117
+ handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.evaluateGate(workspaceId, instance, step, block, isFinalStep, this.gateFor(step.agentKind)),
2118
+ },
2119
+ // An INLINE companion (architect-companion / spec-companion) grades the nearest
2120
+ // preceding producer right here and loops it back for automatic rework below the
2121
+ // threshold before any human gate. CONTAINER-backed companions (reviewer / doc-reviewer)
2122
+ // do NOT match — they fall through to the generic async container dispatch and have their
2123
+ // verdict resolved by the completion interceptor instead. See {@link CompanionController}.
2124
+ {
2125
+ kind: 'inline-companion',
2126
+ order: 160,
2127
+ canHandle: ({ step }) => isCompanionKind(step.agentKind) && !isContainerBackedCompanion(step.agentKind),
2128
+ handle: ({ workspaceId, instance, step, block, isFinalStep, options }) => this.companionController.evaluate(workspaceId, instance, step, block, isFinalStep, options),
2129
+ },
2130
+ // The generic container/inline-agent step — claims every step no more-specific handler
2131
+ // did. Highest order so it always runs last. See {@link handleAgentStep}.
2132
+ {
2133
+ kind: 'agent',
2125
2134
  order: FALLTHROUGH_STEP_HANDLER_ORDER,
2126
2135
  canHandle: () => true,
2127
- handle: (ctx) => this.runStepBody(ctx),
2136
+ handle: (ctx) => this.handleAgentStep(ctx),
2128
2137
  },
2129
2138
  ];
2130
2139
  return handlers.sort((a, b) => a.order - b.order);
2131
2140
  }
2141
+ /**
2142
+ * Run the first completion-path interceptor that claims this finished step, returning its
2143
+ * short-circuit {@link AdvanceResult} (the companion verdict loop / tester re-test) or
2144
+ * `null` to let `recordStepResult`'s normal finish/advance spine run. Engine-internal,
2145
+ * mirroring {@link dispatchStepHandler}.
2146
+ */
2147
+ async dispatchStepCompletionInterceptor(ctx) {
2148
+ if (!this.stepCompletionInterceptorCache) {
2149
+ this.stepCompletionInterceptorCache = this.buildStepCompletionInterceptors();
2150
+ }
2151
+ for (const interceptor of this.stepCompletionInterceptorCache) {
2152
+ if (interceptor.canIntercept(ctx)) {
2153
+ const outcome = await interceptor.intercept(ctx);
2154
+ if (outcome)
2155
+ return outcome;
2156
+ }
2157
+ }
2158
+ return null;
2159
+ }
2160
+ /**
2161
+ * Build the order-sorted completion-path interceptors (companion / tester verdict
2162
+ * short-circuits), mirroring {@link buildStepHandlerRegistry} — built-ins constructed
2163
+ * inline closing over `this`, no public registration seam.
2164
+ */
2165
+ buildStepCompletionInterceptors() {
2166
+ const interceptors = [
2167
+ // A container-backed companion (reviewer / doc-reviewer) just finished reviewing the
2168
+ // real repository on the producer's PR branch and returned its verdict as
2169
+ // `result.custom`. Hand it to the companion loop, which parses the verdict and applies
2170
+ // the SAME threshold / rework / human-gate handling an inline companion gets. Routed
2171
+ // here (not the normal step completion) so the verdict drives the loop instead of being
2172
+ // recorded as plain output. Falls through (returns null) when the block can't be loaded.
2173
+ {
2174
+ kind: 'companion-verdict',
2175
+ order: 100,
2176
+ canIntercept: ({ step }) => isCompanionKind(step.agentKind) && isContainerBackedCompanion(step.agentKind),
2177
+ intercept: async ({ workspaceId, instance, step, isFinalStep, result }) => {
2178
+ const companionBlock = await this.blockRepository.get(workspaceId, instance.blockId);
2179
+ if (!companionBlock)
2180
+ return null;
2181
+ return this.companionController.resolveContainerVerdict(workspaceId, instance, step, companionBlock, isFinalStep, result);
2182
+ },
2183
+ },
2184
+ // A `tester` step returned a structured report. On a withheld greenlight we do NOT
2185
+ // finish the step: loop the `fixer` (within the attempt budget) and re-test, mirroring
2186
+ // the CI gate. A greenlight (or no provider) returns null and falls through to the
2187
+ // normal finish/advance below. Records the report on the step either way.
2188
+ {
2189
+ kind: 'tester-verdict',
2190
+ order: 110,
2191
+ canIntercept: ({ step, result }) => isTesterKind(step.agentKind) && result.testReport !== undefined,
2192
+ intercept: ({ workspaceId, instance, step, result }) => this.testerController.resolveTesterResult(workspaceId, instance, step, result),
2193
+ },
2194
+ ];
2195
+ return interceptors.sort((a, b) => a.order - b.order);
2196
+ }
2132
2197
  stepResolverFor(agentKind) {
2133
2198
  if (!this.stepResolverCache)
2134
2199
  this.stepResolverCache = this.buildStepResolverRegistry();
@@ -2151,6 +2216,55 @@ export class ExecutionService {
2151
2216
  return { ownsTerminalStatus: true };
2152
2217
  },
2153
2218
  },
2219
+ // POST-COMPLETION resolvers — run at the early slot (after output is recorded, before
2220
+ // the follow-up/approval gates), reshaping the agent's structured result into domain
2221
+ // state. Lifted verbatim from the old inline `recordStepResult` branches.
2222
+ //
2223
+ // A Blueprinter step produced a fresh service decomposition. Validate it with the
2224
+ // authoritative schema (a bad payload must never touch the board), then reconcile it
2225
+ // in place onto the run's service frame.
2226
+ {
2227
+ kind: BLUEPRINTS_AGENT_KIND,
2228
+ phase: 'post-completion',
2229
+ resolve: async ({ workspaceId, instance, result }) => {
2230
+ if (result.blueprintService !== undefined) {
2231
+ await this.ingestBlueprint(workspaceId, instance.blockId, result.blueprintService);
2232
+ }
2233
+ },
2234
+ },
2235
+ // A spec-writer step produced the service's unified specification (`spec.json`) and
2236
+ // committed it to the implementation branch — strict-validate it then nudge clients —
2237
+ // and reports its BUSINESS-vs-TECHNICAL determination. "No business specs" (a purely
2238
+ // technical task) is a valid outcome the spec-companion's convergence later combines
2239
+ // with its `technicalCorroborated` verdict; recorded even when false so a re-run
2240
+ // reflects the latest.
2241
+ {
2242
+ kind: SPEC_WRITER_AGENT_KIND,
2243
+ phase: 'post-completion',
2244
+ resolve: async ({ workspaceId, step, result }) => {
2245
+ if (result.spec !== undefined)
2246
+ await this.ingestSpec(workspaceId, result.spec);
2247
+ step.noBusinessSpecs = result.noBusinessSpecs === true;
2248
+ },
2249
+ },
2250
+ // A `task-estimator` step emits a JSON triage (complexity/risk/impact). Parse it
2251
+ // tolerantly, persist it on the block (used to gate consensus steps + surfaced in the
2252
+ // UI), and replace the raw JSON output with a readable summary. An unparseable estimate
2253
+ // leaves the block untouched and keeps the raw output (no run failure). Works the same
2254
+ // whether the single-actor estimator or the consensus ranked-scoring variant produced
2255
+ // the JSON. Running at the post-completion slot keeps the summary in `step.output`
2256
+ // before the approval gate reads it as the proposal.
2257
+ {
2258
+ kind: TASK_ESTIMATOR_AGENT_KIND,
2259
+ phase: 'post-completion',
2260
+ resolve: async ({ workspaceId, instance, step, result }) => {
2261
+ const estimate = coerceTaskEstimate(step.output ?? '', result.model ?? step.model ?? null, this.clock.now());
2262
+ if (estimate) {
2263
+ await this.blockRepository.update(workspaceId, instance.blockId, { estimate });
2264
+ return { output: summarizeEstimate(estimate) };
2265
+ }
2266
+ },
2267
+ },
2154
2268
  ];
2155
2269
  const map = new Map(resolvers.map((r) => [r.kind, r]));
2156
2270
  // Merge deployment-registered resolvers, mirroring the gate registry below. A