@cat-factory/orchestration 0.25.0 → 0.26.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 (41) hide show
  1. package/dist/container.d.ts +14 -0
  2. package/dist/container.d.ts.map +1 -1
  3. package/dist/container.js +64 -0
  4. package/dist/container.js.map +1 -1
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/modules/brainstorm/BrainstormService.d.ts +57 -0
  10. package/dist/modules/brainstorm/BrainstormService.d.ts.map +1 -0
  11. package/dist/modules/brainstorm/BrainstormService.js +117 -0
  12. package/dist/modules/brainstorm/BrainstormService.js.map +1 -0
  13. package/dist/modules/brainstorm/brainstorm.logic.d.ts +38 -0
  14. package/dist/modules/brainstorm/brainstorm.logic.d.ts.map +1 -0
  15. package/dist/modules/brainstorm/brainstorm.logic.js +106 -0
  16. package/dist/modules/brainstorm/brainstorm.logic.js.map +1 -0
  17. package/dist/modules/execution/AgentContextBuilder.d.ts +10 -1
  18. package/dist/modules/execution/AgentContextBuilder.d.ts.map +1 -1
  19. package/dist/modules/execution/AgentContextBuilder.js +33 -4
  20. package/dist/modules/execution/AgentContextBuilder.js.map +1 -1
  21. package/dist/modules/execution/ExecutionService.d.ts +95 -3
  22. package/dist/modules/execution/ExecutionService.d.ts.map +1 -1
  23. package/dist/modules/execution/ExecutionService.js +371 -3
  24. package/dist/modules/execution/ExecutionService.js.map +1 -1
  25. package/dist/modules/execution/ci.logic.d.ts +12 -0
  26. package/dist/modules/execution/ci.logic.d.ts.map +1 -1
  27. package/dist/modules/execution/ci.logic.js +12 -0
  28. package/dist/modules/execution/ci.logic.js.map +1 -1
  29. package/dist/modules/execution/followUp.logic.d.ts +23 -0
  30. package/dist/modules/execution/followUp.logic.d.ts.map +1 -0
  31. package/dist/modules/execution/followUp.logic.js +69 -0
  32. package/dist/modules/execution/followUp.logic.js.map +1 -0
  33. package/dist/modules/notifications/NotificationService.d.ts +8 -7
  34. package/dist/modules/notifications/NotificationService.d.ts.map +1 -1
  35. package/dist/modules/notifications/NotificationService.js +20 -17
  36. package/dist/modules/notifications/NotificationService.js.map +1 -1
  37. package/dist/modules/requirements/RequirementReviewService.d.ts +8 -0
  38. package/dist/modules/requirements/RequirementReviewService.d.ts.map +1 -1
  39. package/dist/modules/requirements/RequirementReviewService.js +11 -1
  40. package/dist/modules/requirements/RequirementReviewService.js.map +1 -1
  41. package/package.json +9 -9
@@ -7,7 +7,8 @@ import { reviewableArtifactOutput } from './artifact-review.logic.js';
7
7
  import { resolveIndividualVendors, } from './individualVendors.logic.js';
8
8
  import { assertFound, ConflictError, getErrorMessage, isModelUsable, NotFoundError, parseLocalModelId, resolveModelRef, sameSubtasks, subscriptionOptionFor, ValidationError, } from '@cat-factory/kernel';
9
9
  import { DEFAULT_MERGE_PRESET } from '@cat-factory/kernel';
10
- import { CONFLICTS_AGENT_KIND, MERGER_AGENT_KIND, REQUIREMENTS_REVIEW_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, BUG_INVESTIGATOR_AGENT_KIND, TRACKER_AGENT_KIND, ANALYSIS_AGENT_KIND, TESTER_AGENT_KIND, HUMAN_TEST_AGENT_KIND, BLUEPRINTS_AGENT_KIND, SPEC_WRITER_AGENT_KIND, } from './ci.logic.js';
10
+ import { CONFLICTS_AGENT_KIND, MERGER_AGENT_KIND, REQUIREMENTS_REVIEW_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, REQUIREMENTS_BRAINSTORM_AGENT_KIND, ARCHITECTURE_BRAINSTORM_AGENT_KIND, BUG_INVESTIGATOR_AGENT_KIND, TRACKER_AGENT_KIND, ANALYSIS_AGENT_KIND, TESTER_AGENT_KIND, HUMAN_TEST_AGENT_KIND, BLUEPRINTS_AGENT_KIND, SPEC_WRITER_AGENT_KIND, } from './ci.logic.js';
11
+ import { DEFAULT_FOLLOW_UP_MAX_LOOPS, FOLLOW_UP_PRODUCER_KIND, followUpsToSendBack, hasPendingFollowUps, renderFollowUpRework, shouldLoopCoder, } from './followUp.logic.js';
11
12
  import { AgentContextBuilder } from './AgentContextBuilder.js';
12
13
  import { CompanionController } from './CompanionController.js';
13
14
  import { inferTechnicalLabel } from './technical.logic.js';
@@ -83,6 +84,7 @@ export class ExecutionService {
83
84
  requirementReviewService;
84
85
  kaizenScheduler;
85
86
  clarityReviewService;
87
+ brainstormServices;
86
88
  environmentProvisioning;
87
89
  environmentTeardown;
88
90
  branchUpdater;
@@ -102,6 +104,9 @@ export class ExecutionService {
102
104
  requirementsKind;
103
105
  /** The clarity (bug-report triage) subject for {@link reviewGate}. */
104
106
  clarityKind;
107
+ /** The two brainstorm (structured-dialogue) subjects for {@link reviewGate}, by stage. */
108
+ requirementsBrainstormKind;
109
+ architectureBrainstormKind;
105
110
  blueprintReconciler;
106
111
  notificationService;
107
112
  workspaceSettingsService;
@@ -128,7 +133,7 @@ export class ExecutionService {
128
133
  * {@link stepResolverFor} and {@link StepCompletionResolver}.
129
134
  */
130
135
  stepResolverCache;
131
- constructor({ workspaceRepository, blockRepository, pipelineRepository, executionRepository, accountRepository, idGenerator, clock, agentExecutor, workRunner, executionEventPublisher, boardService, spendService, documentRepository, taskRepository, requirementReviewRepository, requirementReviewService, kaizenScheduler, clarityReviewRepository, clarityReviewService, fragmentResolver, environmentProvisioning, environmentTeardown, branchUpdater, blueprintReconciler, notificationService, workspaceSettingsService, llmObservability, pullRequestMerger, mergePresetRepository, ticketTrackerProvider, issueWriteback, subscriptionActivationRepository, resolveWorkspaceModelDefault, resolveProviderCapabilities, localTestInfraSupported, resolveRunRepoContext, runInitiatorScope, }) {
136
+ constructor({ workspaceRepository, blockRepository, pipelineRepository, executionRepository, accountRepository, idGenerator, clock, agentExecutor, workRunner, executionEventPublisher, boardService, spendService, documentRepository, taskRepository, requirementReviewRepository, requirementReviewService, kaizenScheduler, clarityReviewRepository, clarityReviewService, brainstormServices, brainstormSessionRepository, fragmentResolver, environmentProvisioning, environmentTeardown, branchUpdater, blueprintReconciler, notificationService, workspaceSettingsService, llmObservability, pullRequestMerger, mergePresetRepository, ticketTrackerProvider, issueWriteback, subscriptionActivationRepository, resolveWorkspaceModelDefault, resolveProviderCapabilities, localTestInfraSupported, resolveRunRepoContext, runInitiatorScope, }) {
132
137
  this.runInitiatorScope = runInitiatorScope ?? ((_initiatedBy, fn) => fn());
133
138
  this.workspaceRepository = workspaceRepository;
134
139
  this.blockRepository = blockRepository;
@@ -145,6 +150,7 @@ export class ExecutionService {
145
150
  this.requirementReviewService = requirementReviewService;
146
151
  this.kaizenScheduler = kaizenScheduler;
147
152
  this.clarityReviewService = clarityReviewService;
153
+ this.brainstormServices = brainstormServices;
148
154
  this.environmentProvisioning = environmentProvisioning;
149
155
  this.environmentTeardown = environmentTeardown;
150
156
  this.branchUpdater = branchUpdater;
@@ -156,6 +162,7 @@ export class ExecutionService {
156
162
  tasks: taskRepository,
157
163
  requirementReviews: requirementReviewRepository,
158
164
  clarityReviews: clarityReviewRepository,
165
+ brainstormSessions: brainstormSessionRepository,
159
166
  environmentProvisioning,
160
167
  fragmentResolver,
161
168
  });
@@ -254,6 +261,8 @@ export class ExecutionService {
254
261
  });
255
262
  this.requirementsKind = this.buildRequirementsKind();
256
263
  this.clarityKind = this.buildClarityKind();
264
+ this.requirementsBrainstormKind = this.buildBrainstormKind('requirements', REQUIREMENTS_BRAINSTORM_AGENT_KIND);
265
+ this.architectureBrainstormKind = this.buildBrainstormKind('architecture', ARCHITECTURE_BRAINSTORM_AGENT_KIND);
257
266
  this.blueprintReconciler = blueprintReconciler;
258
267
  this.notificationService = notificationService;
259
268
  this.workspaceSettingsService = workspaceSettingsService;
@@ -520,6 +529,19 @@ export class ExecutionService {
520
529
  },
521
530
  }
522
531
  : {}),
532
+ // The Follow-up companion is on by default for a `coder` step; the pipeline's
533
+ // per-step `followUps[i] === false` toggle disables it. Seeded empty here; the
534
+ // harness streams items in as the Coder surfaces them (see pollAgentJob).
535
+ ...(kind === FOLLOW_UP_PRODUCER_KIND && pipeline.followUps?.[i] !== false
536
+ ? {
537
+ followUps: {
538
+ enabled: true,
539
+ items: [],
540
+ loops: 0,
541
+ maxLoops: DEFAULT_FOLLOW_UP_MAX_LOOPS,
542
+ },
543
+ }
544
+ : {}),
523
545
  };
524
546
  });
525
547
  if (steps.length === 0) {
@@ -709,7 +731,9 @@ export class ExecutionService {
709
731
  // request) — instead of immediately re-parking. Every other parked step (and a requirements
710
732
  // gate with nothing pending) re-parks on its durable decision id.
711
733
  const reentrantRequirements = (step.agentKind === REQUIREMENTS_REVIEW_AGENT_KIND ||
712
- step.agentKind === CLARITY_REVIEW_AGENT_KIND) &&
734
+ step.agentKind === CLARITY_REVIEW_AGENT_KIND ||
735
+ step.agentKind === REQUIREMENTS_BRAINSTORM_AGENT_KIND ||
736
+ step.agentKind === ARCHITECTURE_BRAINSTORM_AGENT_KIND) &&
713
737
  (!!step.pendingIncorporation || !!step.pendingRecommendation);
714
738
  // The human-testing gate is likewise re-entrant: a human action (confirm / request a
715
739
  // fix / pull main / recreate) records a `pendingAction` on the parked step and wakes
@@ -771,6 +795,15 @@ export class ExecutionService {
771
795
  if (step.agentKind === CLARITY_REVIEW_AGENT_KIND) {
772
796
  return this.reviewGate.evaluate(this.clarityKind, workspaceId, instance, step, block, isFinalStep);
773
797
  }
798
+ // The two brainstorm (structured-dialogue) gates run the inline option-generator and park
799
+ // for the dedicated brainstorm window, driving the same iterative loop as the requirements
800
+ // gate. NOT container/prose agents. Pass-through when the brainstorm module isn't wired.
801
+ if (step.agentKind === REQUIREMENTS_BRAINSTORM_AGENT_KIND) {
802
+ return this.reviewGate.evaluate(this.requirementsBrainstormKind, workspaceId, instance, step, block, isFinalStep);
803
+ }
804
+ if (step.agentKind === ARCHITECTURE_BRAINSTORM_AGENT_KIND) {
805
+ return this.reviewGate.evaluate(this.architectureBrainstormKind, workspaceId, instance, step, block, isFinalStep);
806
+ }
774
807
  // A `human-test` gate spins up an ephemeral environment and PARKS for a human to
775
808
  // validate the change in a live URL before the run continues — NOT a container/prose
776
809
  // agent and NOT a programmatic polling gate (the human is the verdict). It also drives
@@ -993,6 +1026,10 @@ export class ExecutionService {
993
1026
  update.subtasks.total > 0 ? update.subtasks.completed / update.subtasks.total : 0;
994
1027
  changed = true;
995
1028
  }
1029
+ // Append any forward-looking items the Coder streamed since the last poll so the
1030
+ // Follow-up companion lights up + accrues items LIVE while the container still runs.
1031
+ if (this.appendStreamedFollowUps(step, update.followUps))
1032
+ changed = true;
996
1033
  // Refresh the env projection so its status transitions (provisioning→ready→
997
1034
  // expired/torn_down) and any error stay live in the run details during the run.
998
1035
  if (await this.attachEnvironmentProjection(workspaceId, instance.blockId, step)) {
@@ -1145,6 +1182,10 @@ export class ExecutionService {
1145
1182
  if (!block)
1146
1183
  return { kind: 'noop' };
1147
1184
  const isFinalStep = instance.currentStep === instance.steps.length - 1;
1185
+ // Capture any final burst of follow-up items the harness drained on the SAME poll that
1186
+ // observed completion (the tailer is flushed before the job is marked done), so the
1187
+ // completion gate below sees the last items — notably a question that must hold the run.
1188
+ this.appendStreamedFollowUps(step, update.followUps);
1148
1189
  // Clear the handle before recording so a replay re-attaches to nothing.
1149
1190
  step.jobId = undefined;
1150
1191
  return this.recordStepResult(workspaceId, instance, step, isFinalStep, update.result);
@@ -1493,6 +1534,16 @@ export class ExecutionService {
1493
1534
  const reviewable = reviewableArtifactOutput(result);
1494
1535
  if (reviewable !== undefined)
1495
1536
  step.output = reviewable;
1537
+ // Follow-up companion gate: the future-looking Coder surfaced forward-looking items.
1538
+ // Hold the pipeline until every item is decided (an undecided follow-up or an unanswered
1539
+ // question parks the run), then loop the Coder for the items the human queued / answered
1540
+ // (within the loop budget) before the following steps may start. Runs BEFORE the approval
1541
+ // gate so the Coder's follow-ups settle first. A no-op when nothing was surfaced.
1542
+ if (step.followUps?.enabled) {
1543
+ const gated = await this.evaluateFollowUpGate(workspaceId, instance, step);
1544
+ if (gated)
1545
+ return gated;
1546
+ }
1496
1547
  // Human approval gate: a step the pipeline marked `requiresApproval` pauses
1497
1548
  // here once its proposal is ready, so a human can review (and edit) it before
1498
1549
  // the next step runs. We reuse the durable decision wait — returning
@@ -2145,6 +2196,257 @@ export class ExecutionService {
2145
2196
  return;
2146
2197
  await svc.clearWaitingDecision(workspaceId, instance.blockId);
2147
2198
  }
2199
+ // ---- Follow-up companion (future-looking Coder) -------------------------
2200
+ // The Coder streams forward-looking items (loose ends / side-tasks / questions) which
2201
+ // accrue on its `step.followUps` live (see pollAgentJob). At the Coder's completion the
2202
+ // run parks while any item is undecided, then loops the Coder for the items the human
2203
+ // queued / answered (within the loop budget) before the following steps may start.
2204
+ /**
2205
+ * Append the items the harness streamed since the last poll onto the Coder step's
2206
+ * follow-up state as fresh `pending` items. A no-op when the companion is off or nothing
2207
+ * was streamed. Returns whether anything was added (so the poller persists + emits).
2208
+ */
2209
+ appendStreamedFollowUps(step, streamed) {
2210
+ if (!step.followUps?.enabled || !streamed || streamed.length === 0)
2211
+ return false;
2212
+ const now = this.clock.now();
2213
+ for (const s of streamed) {
2214
+ const title = (s.title ?? '').trim();
2215
+ if (!title)
2216
+ continue;
2217
+ step.followUps.items.push({
2218
+ id: this.idGenerator.next('fu'),
2219
+ kind: s.kind === 'question' ? 'question' : 'follow_up',
2220
+ title,
2221
+ detail: s.detail ?? '',
2222
+ ...(s.suggestedAction ? { suggestedAction: s.suggestedAction } : {}),
2223
+ status: 'pending',
2224
+ createdAt: now,
2225
+ updatedAt: now,
2226
+ });
2227
+ }
2228
+ return true;
2229
+ }
2230
+ /**
2231
+ * The Follow-up companion gate, evaluated when the Coder step completes: park the run on
2232
+ * a durable decision while any item is undecided; else loop the Coder for the queued /
2233
+ * answered items (within the budget); else fall through (return undefined) so the normal
2234
+ * advance/finish logic runs. Returns an {@link AdvanceResult} only when it parks or loops.
2235
+ */
2236
+ async evaluateFollowUpGate(workspaceId, instance, step) {
2237
+ const state = step.followUps;
2238
+ if (!state?.enabled)
2239
+ return undefined;
2240
+ if (hasPendingFollowUps(state)) {
2241
+ await this.raiseFollowUpPending(workspaceId, instance, state);
2242
+ return this.parkStepOnDecision(workspaceId, instance, step);
2243
+ }
2244
+ if (shouldLoopCoder(state)) {
2245
+ this.loopCoderForFollowUps(instance, step);
2246
+ await this.updateBlockProgress(workspaceId, instance, 'in_progress');
2247
+ await this.executionRepository.upsert(workspaceId, instance);
2248
+ await this.emitInstance(workspaceId, instance);
2249
+ return { kind: 'continue' };
2250
+ }
2251
+ return undefined;
2252
+ }
2253
+ /**
2254
+ * Reset the Coder step and fold the human's queued follow-ups / answered questions into
2255
+ * its rework so the next pass extends the prior work. Marks those items `sentToCoder` so
2256
+ * a later completion doesn't re-loop them, and counts the loop against the budget. Shared
2257
+ * by the at-completion path ({@link evaluateFollowUpGate}) and the parked-resume path.
2258
+ */
2259
+ loopCoderForFollowUps(instance, step) {
2260
+ const state = step.followUps;
2261
+ const sending = followUpsToSendBack(state);
2262
+ const feedback = renderFollowUpRework(sending);
2263
+ for (const item of sending) {
2264
+ item.sentToCoder = true;
2265
+ item.updatedAt = this.clock.now();
2266
+ }
2267
+ state.loops = (state.loops ?? 0) + 1;
2268
+ // Reset the step for a fresh dispatch; `step.followUps` is intentionally preserved
2269
+ // (resetStepForRerun doesn't touch it) so the surfaced items survive the loop.
2270
+ this.resetStepForRerun(step);
2271
+ step.rework = { previousProposal: '', feedback };
2272
+ this.startStep(step);
2273
+ if (instance.status === 'blocked')
2274
+ instance.status = 'running';
2275
+ }
2276
+ /** Raise the "follow-ups need decisions" inbox card when the Coder parks on undecided items. */
2277
+ async raiseFollowUpPending(workspaceId, instance, state) {
2278
+ if (!this.notificationService)
2279
+ return;
2280
+ const block = await this.blockRepository.get(workspaceId, instance.blockId);
2281
+ if (!block)
2282
+ return;
2283
+ const pending = state.items.filter((i) => i.status === 'pending').length;
2284
+ await this.notificationService.raise(workspaceId, {
2285
+ type: 'followup_pending',
2286
+ blockId: block.id,
2287
+ executionId: instance.id,
2288
+ title: `"${block.title}" surfaced ${pending} follow-up${pending === 1 ? '' : 's'} to decide`,
2289
+ body: 'The Coder flagged forward-looking follow-ups / questions. Open the task to file ' +
2290
+ 'each as an issue, send it back to the Coder, answer it, or dismiss it — the ' +
2291
+ 'pipeline continues once every item is decided.',
2292
+ payload: { pipelineName: instance.pipelineName, findingCount: pending },
2293
+ });
2294
+ }
2295
+ /**
2296
+ * The run's "active" follow-up companion step for a read with no item context (the GET /
2297
+ * the inbox-card open). A pipeline may carry MORE THAN ONE follow-up-enabled Coder step,
2298
+ * so this must not blindly pick the first: prefer the step the run is currently on (a Coder
2299
+ * parked on its follow-up gate), else the latest enabled step that has surfaced items, else
2300
+ * the first enabled one.
2301
+ */
2302
+ activeFollowUpStep(instance) {
2303
+ const current = instance.steps[instance.currentStep];
2304
+ if (current?.followUps?.enabled)
2305
+ return { step: current, index: instance.currentStep };
2306
+ for (let i = instance.steps.length - 1; i >= 0; i--) {
2307
+ const s = instance.steps[i];
2308
+ if (s.followUps?.enabled && s.followUps.items.length > 0)
2309
+ return { step: s, index: i };
2310
+ }
2311
+ const index = instance.steps.findIndex((s) => s.followUps?.enabled);
2312
+ return index >= 0 ? { step: instance.steps[index], index } : undefined;
2313
+ }
2314
+ /** Read a run's live follow-up companion state (the active Coder step's items), or null. */
2315
+ async getFollowUps(workspaceId, executionId) {
2316
+ const instance = await this.executionRepository.get(workspaceId, executionId);
2317
+ if (!instance)
2318
+ throw new NotFoundError('Execution', executionId);
2319
+ return this.activeFollowUpStep(instance)?.step.followUps ?? null;
2320
+ }
2321
+ /**
2322
+ * Locate the run + the Coder step that OWNS the addressed item + the item, throwing 404
2323
+ * when absent. Routes by item id (not "the first enabled step") so a pipeline carrying more
2324
+ * than one follow-up-enabled Coder step decides each item on the step that surfaced it —
2325
+ * otherwise a later Coder's items 404 and its gate can never be cleared.
2326
+ */
2327
+ async loadFollowUpItem(workspaceId, executionId, itemId) {
2328
+ const instance = await this.executionRepository.get(workspaceId, executionId);
2329
+ if (!instance)
2330
+ throw new NotFoundError('Execution', executionId);
2331
+ const index = instance.steps.findIndex((s) => s.followUps?.enabled && s.followUps.items.some((i) => i.id === itemId));
2332
+ if (index < 0)
2333
+ throw new NotFoundError('Follow-up item', itemId);
2334
+ const step = instance.steps[index];
2335
+ const item = step.followUps.items.find((i) => i.id === itemId);
2336
+ return { instance, step, index, item };
2337
+ }
2338
+ /** File a `follow_up` item as a tracker issue (GitHub / Jira), recording the ticket ref. */
2339
+ async fileFollowUp(workspaceId, executionId, itemId) {
2340
+ const { instance, step, index, item } = await this.loadFollowUpItem(workspaceId, executionId, itemId);
2341
+ if (item.kind !== 'follow_up') {
2342
+ throw new ConflictError('Only follow-up items can be filed as issues');
2343
+ }
2344
+ if (!this.ticketTrackerProvider) {
2345
+ throw new ConflictError('No issue tracker is configured for this workspace');
2346
+ }
2347
+ const frameId = (await this.contextBuilder.resolveServiceFrameId(workspaceId, instance.blockId)) ??
2348
+ instance.blockId;
2349
+ const body = [
2350
+ item.detail,
2351
+ item.suggestedAction ? `\n\nSuggested approach: ${item.suggestedAction}` : '',
2352
+ ]
2353
+ .join('')
2354
+ .trim();
2355
+ const ticket = await this.ticketTrackerProvider.createTicket({
2356
+ workspaceId,
2357
+ frameId,
2358
+ title: item.title,
2359
+ body: body || item.title,
2360
+ });
2361
+ if (!ticket) {
2362
+ throw new ConflictError('No issue tracker is configured for this workspace');
2363
+ }
2364
+ item.status = 'filed';
2365
+ item.ticketExternalId = ticket.externalId;
2366
+ item.ticketUrl = ticket.url;
2367
+ item.updatedAt = this.clock.now();
2368
+ await this.driveFollowUpsAfterDecision(workspaceId, instance, step, index);
2369
+ return step.followUps;
2370
+ }
2371
+ /** Queue a `follow_up` item to send back to the Coder on its next pass. */
2372
+ async queueFollowUp(workspaceId, executionId, itemId) {
2373
+ const { instance, step, index, item } = await this.loadFollowUpItem(workspaceId, executionId, itemId);
2374
+ if (item.kind !== 'follow_up') {
2375
+ throw new ConflictError('Only follow-up items can be sent back to the Coder');
2376
+ }
2377
+ item.status = 'queued';
2378
+ item.sentToCoder = false;
2379
+ item.updatedAt = this.clock.now();
2380
+ await this.driveFollowUpsAfterDecision(workspaceId, instance, step, index);
2381
+ return step.followUps;
2382
+ }
2383
+ /** Answer a `question` item; the answer is folded into the Coder's next pass. */
2384
+ async answerFollowUp(workspaceId, executionId, itemId, answer) {
2385
+ const { instance, step, index, item } = await this.loadFollowUpItem(workspaceId, executionId, itemId);
2386
+ if (item.kind !== 'question') {
2387
+ throw new ConflictError('Only question items can be answered');
2388
+ }
2389
+ item.status = 'answered';
2390
+ item.answer = answer;
2391
+ item.sentToCoder = false;
2392
+ item.updatedAt = this.clock.now();
2393
+ await this.driveFollowUpsAfterDecision(workspaceId, instance, step, index);
2394
+ return step.followUps;
2395
+ }
2396
+ /** Dismiss a follow-up / question item without acting on it. */
2397
+ async dismissFollowUp(workspaceId, executionId, itemId) {
2398
+ const { instance, step, index, item } = await this.loadFollowUpItem(workspaceId, executionId, itemId);
2399
+ item.status = 'dismissed';
2400
+ item.updatedAt = this.clock.now();
2401
+ await this.driveFollowUpsAfterDecision(workspaceId, instance, step, index);
2402
+ return step.followUps;
2403
+ }
2404
+ /**
2405
+ * Persist an item decision and, when the run is PARKED on this step's follow-up gate and
2406
+ * every item is now decided, drive it forward: loop the Coder for the queued / answered
2407
+ * items (within the budget), else advance past the gate. When the run is not parked (the
2408
+ * Coder is still running, or it already moved on) this only persists + emits the change.
2409
+ */
2410
+ async driveFollowUpsAfterDecision(workspaceId, instance, step, index) {
2411
+ const parkedHere = instance.status === 'blocked' &&
2412
+ step.approval?.status === 'pending' &&
2413
+ instance.currentStep === index;
2414
+ if (!parkedHere || hasPendingFollowUps(step.followUps)) {
2415
+ // Still collecting decisions (or the run isn't parked on this gate): just record it.
2416
+ await this.executionRepository.upsert(workspaceId, instance);
2417
+ await this.emitInstance(workspaceId, instance);
2418
+ return;
2419
+ }
2420
+ // Every item is decided and the run is parked here: clear the waiting card and either
2421
+ // loop the Coder for the send-back items or advance past the gate.
2422
+ await this.clearWaitingNotification(workspaceId, instance);
2423
+ if (shouldLoopCoder(step.followUps)) {
2424
+ const decisionId = step.approval.id;
2425
+ this.loopCoderForFollowUps(instance, step);
2426
+ await this.updateBlockProgress(workspaceId, instance, 'in_progress');
2427
+ await this.executionRepository.upsert(workspaceId, instance);
2428
+ await this.workRunner.signalDecision(workspaceId, instance.id, decisionId, 'approved');
2429
+ await this.emitInstance(workspaceId, instance);
2430
+ return;
2431
+ }
2432
+ // The follow-up gate is settled and we won't loop. If this step ALSO carries a human
2433
+ // approval gate, hand off to it now instead of advancing — the follow-up park reused
2434
+ // `step.approval`, so advancing here would silently SKIP the approval. Keep the same
2435
+ // parked decision id (the durable driver is already waiting on it), refresh the proposal
2436
+ // to the step output, and re-raise the standard "waiting for input" card (we just cleared
2437
+ // the follow-up one). The human then resolves it through the normal approve / request-
2438
+ // changes path. The follow-up gate already ran BEFORE the approval gate in
2439
+ // recordStepResult, so this preserves that exact ordering across the park.
2440
+ const isFinalStep = index === instance.steps.length - 1;
2441
+ if (step.requiresApproval && !isFinalStep && step.approval?.status === 'pending') {
2442
+ step.approval = { ...step.approval, proposal: step.output ?? '' };
2443
+ await this.executionRepository.upsert(workspaceId, instance);
2444
+ await this.ensureWaitingNotification(workspaceId, instance);
2445
+ await this.emitInstance(workspaceId, instance);
2446
+ return;
2447
+ }
2448
+ await this.advancePastResolvedGate(workspaceId, instance, index);
2449
+ }
2148
2450
  /** Provision inputs (`{{input.*}}`) derived from the block under deployment. */
2149
2451
  deployInputs(block) {
2150
2452
  const inputs = {
@@ -2286,12 +2588,19 @@ export class ExecutionService {
2286
2588
  if (step.agentKind === CLARITY_REVIEW_AGENT_KIND) {
2287
2589
  throw new ConflictError('Resolve the clarity review through its review window, not the approval gate');
2288
2590
  }
2591
+ if (step.agentKind === REQUIREMENTS_BRAINSTORM_AGENT_KIND ||
2592
+ step.agentKind === ARCHITECTURE_BRAINSTORM_AGENT_KIND) {
2593
+ throw new ConflictError('Resolve the brainstorm through its brainstorm window, not the approval gate');
2594
+ }
2289
2595
  if (step.agentKind === HUMAN_TEST_AGENT_KIND) {
2290
2596
  throw new ConflictError('Resolve the human-testing gate through its window (confirm / request a fix), not the approval gate');
2291
2597
  }
2292
2598
  if (step.companion?.exceeded) {
2293
2599
  throw new ConflictError('Resolve this companion review through its iteration-cap prompt, not the approval gate');
2294
2600
  }
2601
+ if (step.followUps?.enabled && step.followUps.items.some((i) => i.status === 'pending')) {
2602
+ throw new ConflictError('Resolve the follow-up companion through its window (file / send back / answer / dismiss), not the approval gate');
2603
+ }
2295
2604
  }
2296
2605
  /**
2297
2606
  * The requirements subject for {@link reviewGate}: closures over the requirements reviewer
@@ -2369,6 +2678,65 @@ export class ExecutionService {
2369
2678
  emit: (ws, review) => this.events.clarityReviewChanged?.(ws, review) ?? Promise.resolve(),
2370
2679
  };
2371
2680
  }
2681
+ /**
2682
+ * A brainstorm (structured-dialogue) subject for {@link reviewGate}, parameterised by stage.
2683
+ * Otherwise identical to the requirements kind — the service handles its own upstream context
2684
+ * (the architecture stage seeds from the refined requirements). The brainstorm services
2685
+ * resolve their model exactly like the requirements reviewer, so the cap knobs are reused.
2686
+ */
2687
+ buildBrainstormKind(stage, agentKind) {
2688
+ const require = () => {
2689
+ const svc = this.brainstormServices?.[stage];
2690
+ if (!svc?.enabled)
2691
+ throw new ConflictError('The brainstorm agent is not configured');
2692
+ return svc;
2693
+ };
2694
+ return {
2695
+ agentKind,
2696
+ entityName: 'Brainstorm session',
2697
+ enabled: () => !!this.brainstormServices?.[stage]?.enabled,
2698
+ getForBlock: (ws, blockId) => require().getForBlock(ws, blockId),
2699
+ review: (ws, block, preset) => require().review(ws, block.id, {
2700
+ maxIterations: preset.maxRequirementIterations,
2701
+ concernThreshold: preset.maxRequirementConcernAllowed,
2702
+ }),
2703
+ reReview: (ws, reviewId, preset) => require().reReview(ws, reviewId, { concernThreshold: preset.maxRequirementConcernAllowed }),
2704
+ incorporate: async (ws, _blockId, reviewId, feedback) => {
2705
+ await require().incorporate(ws, reviewId, { feedback });
2706
+ },
2707
+ markIncorporated: (ws, reviewId) => require().markIncorporated(ws, reviewId),
2708
+ markReReviewing: (ws, reviewId) => require().markReReviewing(ws, reviewId),
2709
+ markIncorporating: (ws, reviewId) => require().markIncorporating(ws, reviewId),
2710
+ grantExtraRound: (ws, reviewId) => require().grantExtraRound(ws, reviewId),
2711
+ emit: (ws, session) => this.events.brainstormSessionChanged?.(ws, session) ?? Promise.resolve(),
2712
+ };
2713
+ }
2714
+ /** Pick the brainstorm kind for a stage (the dedicated window drives both via the same loop). */
2715
+ brainstormKindFor(stage) {
2716
+ return stage === 'architecture'
2717
+ ? this.architectureBrainstormKind
2718
+ : this.requirementsBrainstormKind;
2719
+ }
2720
+ /** Run a fresh brainstorm pass over a block + stage (off-path inspector / window surface). */
2721
+ reviewBrainstorm(workspaceId, blockId, stage) {
2722
+ return this.reviewGate.review(this.brainstormKindFor(stage), workspaceId, blockId);
2723
+ }
2724
+ /** Incorporate the human's picks ASYNCHRONOUSLY (the brainstorm mirror of {@link incorporateRequirements}). */
2725
+ incorporateBrainstorm(workspaceId, blockId, stage, feedback) {
2726
+ return this.reviewGate.incorporate(this.brainstormKindFor(stage), workspaceId, blockId, feedback);
2727
+ }
2728
+ /** Re-run the brainstorm against the converged direction (one more pass). */
2729
+ reReviewBrainstorm(workspaceId, blockId, stage) {
2730
+ return this.reviewGate.reReview(this.brainstormKindFor(stage), workspaceId, blockId);
2731
+ }
2732
+ /** Proceed: settle the brainstorm (last converged direction wins downstream) and advance. */
2733
+ proceedBrainstorm(workspaceId, blockId, stage) {
2734
+ return this.reviewGate.proceed(this.brainstormKindFor(stage), workspaceId, blockId);
2735
+ }
2736
+ /** Resolve a brainstorm that hit its iteration cap (extra-round / proceed / stop-reset). */
2737
+ resolveBrainstormExceeded(workspaceId, blockId, stage, choice) {
2738
+ return this.reviewGate.resolveExceeded(this.brainstormKindFor(stage), workspaceId, blockId, choice);
2739
+ }
2372
2740
  /**
2373
2741
  * Run a fresh reviewer pass over a block's collected requirements, snapshotting the
2374
2742
  * task's merge-preset knobs (iteration budget + tolerated severity) onto the review.