@yagni-app/code-staging 1.2.6-staging.1952.1 → 1.2.6-staging.1956.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 (30) hide show
  1. package/dist/extension/branding.d.ts +20 -0
  2. package/dist/extension/branding.js +49 -8
  3. package/dist/extension/index.js +5 -0
  4. package/dist/extension/pipeline/changeScan.d.ts +62 -0
  5. package/dist/extension/pipeline/changeScan.js +307 -0
  6. package/dist/extension/pipeline/cloud.d.ts +205 -0
  7. package/dist/extension/pipeline/cloud.js +382 -0
  8. package/dist/extension/pipeline/continuation.d.ts +7 -3
  9. package/dist/extension/pipeline/continuation.js +29 -13
  10. package/dist/extension/pipeline/findings.d.ts +9 -4
  11. package/dist/extension/pipeline/findings.js +25 -10
  12. package/dist/extension/pipeline/finish.d.ts +11 -1
  13. package/dist/extension/pipeline/finish.js +51 -5
  14. package/dist/extension/pipeline/headlessGo.d.ts +20 -1
  15. package/dist/extension/pipeline/headlessGo.js +205 -33
  16. package/dist/extension/pipeline/orchestrator.d.ts +8 -2
  17. package/dist/extension/pipeline/orchestrator.js +249 -68
  18. package/dist/extension/pipeline/personas.d.ts +8 -3
  19. package/dist/extension/pipeline/personas.js +42 -7
  20. package/dist/extension/pipeline/planOutput.d.ts +42 -0
  21. package/dist/extension/pipeline/planOutput.js +94 -0
  22. package/dist/extension/pipeline/stages.d.ts +34 -5
  23. package/dist/extension/pipeline/stages.js +136 -7
  24. package/dist/extension/pipeline/tierCap.d.ts +22 -1
  25. package/dist/extension/pipeline/tierCap.js +48 -1
  26. package/dist/extension/pipeline/types.d.ts +31 -0
  27. package/dist/extension/pipeline/types.js +2 -0
  28. package/dist/extension/pipeline/verify.d.ts +33 -0
  29. package/dist/extension/pipeline/verify.js +101 -9
  30. package/package.json +2 -2
@@ -26,14 +26,15 @@
26
26
  */
27
27
  import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, DEFAULT_STAGE_ESTIMATE_MS, EMPTY_RUN_USAGE, exceedsBudget, stageWall, stageWontFit, } from "./budget.js";
28
28
  import { attributeFindings, checkerFindings, composeFixNote, composeResidue, composeSynthesizerInput, mergeFindings, parseOpenFindings, renderFindings, stripOpenFindings, } from "./checker.js";
29
- import { auditClaims, parsePartition } from "./fanout.js";
30
- import { extractFindingsBlock, hasBlockingFindings, isBlockingFinding, parseFindings, shouldStop, unionFindings } from "./findings.js";
29
+ import { auditClaims, parsePartition, } from "./fanout.js";
30
+ import { extractFindingsBlock, hasBlockingFindings, isBlockingFinding, parseFindings, shouldStop, unionFindings, } from "./findings.js";
31
31
  import { limitedMapFiles, readTouchedFiles } from "./fixRound.js";
32
32
  import { missionDesignContext, missionSeed, missionSkippedStages, normalizeMission, PINNED_DESIGN_MARKER, } from "./mission.js";
33
33
  import { withResilience } from "./resilience.js";
34
34
  import { runStage as defaultRunStage } from "./runner.js";
35
35
  import { makeRunVerify, makeWorkstreamCheck, parseChangedPaths, } from "./verify.js";
36
- import { builderStage, fixerStage, fixRoundImplementStage, fixRoundMapStage, orchestratorStage, partitionReaskStage, PARTITION_CALLER_LABEL, REQUIRED_LENSES, REVIEW_LENSES, reaskStage, reviewStage, selectStages, synthesizerFixStage, synthesizerStage, SYNTHESIZER_CALLER_LABEL, workstreamCallerLabel, } from "./stages.js";
36
+ import { extractPlanJson } from "./planOutput.js";
37
+ import { builderStage, fixerStage, fixRoundImplementStage, fixRoundMapStage, FULL_LANE, orchestratorStage, partitionReaskStage, PARTITION_CALLER_LABEL, planJsonReaskStage, REQUIRED_LENSES, reaskStage, reviewStage, synthesizerFixStage, synthesizerStage, SYNTHESIZER_CALLER_LABEL, workstreamCallerLabel, } from "./stages.js";
37
38
  import { snapshotWorkspace as defaultSnapshotWorkspace, workspaceChanged } from "./workspace.js";
38
39
  import { DEFAULT_RESILIENCE_POLICY, MAX_CONCURRENCY, MAX_FANOUT_CONCURRENCY, MAX_FANOUT_CONCURRENCY_ULTRA, MAX_FIX_TURNS, MIN_TOOL_CALLS_FOR_HEALTH, TOOL_ERROR_FAIL_RATE, } from "./types.js";
39
40
  /** Each review round's findings as the host stores them: bounded, messages clipped. */
@@ -95,7 +96,10 @@ function toolFailureReason(r) {
95
96
  * that exits 0 while most of its edits failed is not trusted on its prose alone.
96
97
  */
97
98
  function isFailed(r) {
98
- return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted" || toolFailureExceeded(r);
99
+ return (r.exitCode !== 0 ||
100
+ r.stopReason === "error" ||
101
+ r.stopReason === "aborted" ||
102
+ toolFailureExceeded(r));
99
103
  }
100
104
  /**
101
105
  * A non-final build stage threads its `finalOutput` forward as the NEXT stage's
@@ -168,6 +172,10 @@ function creditExhaustionReason(r) {
168
172
  return undefined;
169
173
  return "the workspace is out of YAGNI credits (the model proxy returned 402). Top up credits, then re-run /go.";
170
174
  }
175
+ /** How a reviewer is named in the round's record: its lens, or the lane's one reviewer. */
176
+ function reviewerName(lens) {
177
+ return lens ?? "reviewer";
178
+ }
171
179
  /** Render only the blocking findings as the fix stage's `{previous}` handoff. */
172
180
  function serializeBlocking(findings) {
173
181
  return findings
@@ -203,7 +211,11 @@ function composeFanHandoff(decision, workstreams, results, violations) {
203
211
  parts.push(`## ${w.name} (${w.tier})\nClaimed: ${w.files.join(", ")}\n\n${body}`);
204
212
  });
205
213
  if (violations.length > 0) {
206
- parts.push(["## Out-of-claim edits", "These paths changed outside every workstream's claims:", ...violations.map((p) => `- ${p}`)].join("\n"));
214
+ parts.push([
215
+ "## Out-of-claim edits",
216
+ "These paths changed outside every workstream's claims:",
217
+ ...violations.map((p) => `- ${p}`),
218
+ ].join("\n"));
207
219
  }
208
220
  return parts.join("\n\n");
209
221
  }
@@ -231,7 +243,9 @@ async function mapWithConcurrencyLimit(items, concurrency, fn) {
231
243
  * floor and run past the deadline, so it fails instead of retrying.
232
244
  */
233
245
  export function stageRunnerPolicy(budget) {
234
- return budget.deadlineMs === undefined ? DEFAULT_RESILIENCE_POLICY : { ...DEFAULT_RESILIENCE_POLICY, retryWallTimeout: false };
246
+ return budget.deadlineMs === undefined
247
+ ? DEFAULT_RESILIENCE_POLICY
248
+ : { ...DEFAULT_RESILIENCE_POLICY, retryWallTimeout: false };
235
249
  }
236
250
  /**
237
251
  * The pipeline's default stage runner: `base` under the R1 resilience wrapper
@@ -286,7 +300,9 @@ export async function runPipeline(ticket, deps) {
286
300
  // `go:implement:<workstream>`), so per-workstream spend attributes for free the
287
301
  // way review lenses do. Every other stage leaves it undefined and keeps the
288
302
  // runner's own `go:<stage>` default.
289
- // `writes` marks a build or fix child, the only ones that get `extraArgs`.
303
+ // `writes` marks a child that gets `extraArgs` (the Team's skills): a build or
304
+ // fix child, and on a lane with `skillsEveryStage` (the cloud session) its
305
+ // plan and review children too (spec decision 4).
290
306
  const stageDepsFor = (tag, callerLabel, writes = false) => {
291
307
  const onEvent = deps.onEvent;
292
308
  return {
@@ -311,9 +327,10 @@ export async function runPipeline(ticket, deps) {
311
327
  // The text the stages plan against: the resolved ticket brief (verbatim title +
312
328
  // body) when /go could fetch it, else the raw ticket reference (honest blind).
313
329
  const promptTicket = deps.ticketBrief ?? ticket;
314
- // M6: default grounded + the full stage list; the blind eval lane overrides both.
330
+ // M6: default grounded + the lane's stage list; the blind eval lane overrides both.
315
331
  const grounded = deps.grounded !== false;
316
- const all = deps.stages ?? selectStages("full");
332
+ const lane = deps.lane ?? FULL_LANE;
333
+ const all = deps.stages ?? lane.stages;
317
334
  // R3-b: aggregate usage ceiling, checked at each stage boundary.
318
335
  const budget = deps.budget ?? DEFAULT_RUN_BUDGET;
319
336
  // Mission mode (spec: sandbox harness parity). The factory produced the plan
@@ -376,7 +393,8 @@ export async function runPipeline(ticket, deps) {
376
393
  let reviewInput = resume ? resume.reviewInput : "";
377
394
  const designMarker = PINNED_DESIGN_MARKER;
378
395
  const resumedDesignOffset = reviewInput.indexOf(designMarker);
379
- let designContext = missionDesignContext(mission) ?? (resumedDesignOffset >= 0 ? reviewInput.slice(resumedDesignOffset) : undefined);
396
+ let designContext = missionDesignContext(mission) ??
397
+ (resumedDesignOffset >= 0 ? reviewInput.slice(resumedDesignOffset) : undefined);
380
398
  let stopReason = "clean";
381
399
  // P4: honest note carried out when the verify gate could not give a verdict on
382
400
  // the final round (no command / unrunnable), so /go never claims a build it did
@@ -402,7 +420,10 @@ export async function runPipeline(ticket, deps) {
402
420
  const observedMs = {};
403
421
  const wontFit = (kind) => stageWontFit(budget, kind, observedMs[kind] ?? DEFAULT_STAGE_ESTIMATE_MS[kind], clock());
404
422
  if (deps.continuation) {
405
- log("continuation", { stageAtStop: deps.continuation.stageAtStop, resumedAtRound: resume ? resume.round : null });
423
+ log("continuation", {
424
+ stageAtStop: deps.continuation.stageAtStop,
425
+ resumedAtRound: resume ? resume.round : null,
426
+ });
406
427
  }
407
428
  // The map stage's brief, kept for the partitioner's repo context. Absent on a
408
429
  // mission run (map is skipped there — the injected memo stands in) and on any
@@ -470,7 +491,10 @@ export async function runPipeline(ticket, deps) {
470
491
  const runImplementChecker = async (args) => {
471
492
  const { stage, workstreams, handoff } = args;
472
493
  const fanned = workstreams.length > 0;
473
- const targets = workstreams.map((w) => ({ name: w.name, files: w.files }));
494
+ const targets = workstreams.map((w) => ({
495
+ name: w.name,
496
+ files: w.files,
497
+ }));
474
498
  let report = await runChecker(targets);
475
499
  let body = handoff;
476
500
  // Defects the SYNTHESIZER named in its latest summary, kept apart from the
@@ -513,7 +537,10 @@ export async function runPipeline(ticket, deps) {
513
537
  const fixes = await mapWithConcurrencyLimit(assigned, ceiling, async (a) => {
514
538
  const siblings = workstreams.filter((s) => s.name !== a.workstream.name);
515
539
  progress({ kind: "stage_start", stageId: stage.id, workstream: a.workstream.name });
516
- const fixed = await runStageFn({ ...fixerStage(a.workstream, renderFindings(a.findings), siblings), tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id, workstream: a.workstream.name }, workstreamCallerLabel(a.workstream.name), true));
540
+ const fixed = await runStageFn({
541
+ ...fixerStage(a.workstream, renderFindings(a.findings), siblings),
542
+ tools: stage.tools,
543
+ }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id, workstream: a.workstream.name }, workstreamCallerLabel(a.workstream.name), true));
517
544
  progress({
518
545
  kind: "stage_done",
519
546
  stageId: stage.id,
@@ -527,7 +554,9 @@ export async function runPipeline(ticket, deps) {
527
554
  // A fix turn that CRASHED does not fail the stage: the merged candidate is
528
555
  // still real work, the re-check below will simply still see the findings,
529
556
  // and the cap bounds how long that can go on. Recorded, never silent.
530
- const brokenFixes = fixes.map((r, i) => (isFailed(r) ? assigned[i].workstream.name : null)).filter(Boolean);
557
+ const brokenFixes = fixes
558
+ .map((r, i) => (isFailed(r) ? assigned[i].workstream.name : null))
559
+ .filter(Boolean);
531
560
  if (brokenFixes.length > 0)
532
561
  log("fix_turn_child_failed", { turn, workstreams: brokenFixes });
533
562
  }
@@ -537,7 +566,9 @@ export async function runPipeline(ticket, deps) {
537
566
  // is the one child with no claims, so it must not write while they do.
538
567
  if (unassigned.length > 0) {
539
568
  const findingsText = renderFindings(unassigned);
540
- const fixStage = fanned ? synthesizerFixStage(findingsText) : fixerStage(undefined, findingsText);
569
+ const fixStage = fanned
570
+ ? synthesizerFixStage(findingsText)
571
+ : fixerStage(undefined, findingsText);
541
572
  const fixed = await runStageFn({ ...fixStage, tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id }, fanned ? SYNTHESIZER_CALLER_LABEL : undefined, true));
542
573
  stages.push(fixed);
543
574
  if (fanned) {
@@ -575,6 +606,29 @@ export async function runPipeline(ticket, deps) {
575
606
  return `${body}\n\n${composeFixNote(turn)}`;
576
607
  return body;
577
608
  };
609
+ /**
610
+ * One writer builds the plan, then the implement checker's verify and
611
+ * bounded fix turns run on its tree. The diamond's single-writer verdict
612
+ * runs this, and so does a lane with no fan-out (the cloud lane), where no
613
+ * partitioner runs at all.
614
+ */
615
+ const runOneWriter = async (stage, previous) => {
616
+ const written = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }, undefined, true));
617
+ stages.push(written);
618
+ // A failed or credit-starved writer never reaches the checker: the build
619
+ // loop's own guard turns it into `partial` or the honest stage error, and
620
+ // verifying a tree nobody finished writing would only add noise.
621
+ if (isFailed(written) || creditExhaustionReason(written) || deps.signal?.aborted)
622
+ return written;
623
+ // Decision 7: the fix loop applies identically here: the same checker gate,
624
+ // the same one builder re-engaged, the same cap.
625
+ const text = await runImplementChecker({
626
+ stage,
627
+ workstreams: [],
628
+ handoff: written.finalOutput,
629
+ });
630
+ return text === written.finalOutput ? written : { ...written, finalOutput: text };
631
+ };
578
632
  /**
579
633
  * The implement diamond (spec "Diamond execution"). Returns the StageResult the
580
634
  * build loop treats as "the implement stage". The diamond owns `stages` for the
@@ -606,17 +660,7 @@ export async function runPipeline(ticket, deps) {
606
660
  const partitionTag = { stageId: stage.id };
607
661
  const runSingleWriter = async (decision) => {
608
662
  recordDecision(decision, beforeBuild);
609
- const written = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }, undefined, true));
610
- stages.push(written);
611
- // A failed or credit-starved writer never reaches the checker: the build
612
- // loop's own guard turns it into `partial` or the honest stage error, and
613
- // verifying a tree nobody finished writing would only add noise.
614
- if (isFailed(written) || creditExhaustionReason(written) || deps.signal?.aborted)
615
- return written;
616
- // Decision 7: the fix loop applies identically here — the same checker gate,
617
- // the same one builder re-engaged, the same cap.
618
- const text = await runImplementChecker({ stage, workstreams: [], handoff: written.finalOutput });
619
- return text === written.finalOutput ? written : { ...written, finalOutput: text };
663
+ return runOneWriter(stage, previous);
620
664
  };
621
665
  const partition = await runStageFn(partitionStage, { ticket: promptTicket, previous, grounded }, stageDepsFor(partitionTag, PARTITION_CALLER_LABEL));
622
666
  stages.push(partition);
@@ -682,7 +726,10 @@ export async function runPipeline(ticket, deps) {
682
726
  // actually runs at once, so a width-8 partition runs 4-at-a-time outside ultra
683
727
  // rather than being refused.
684
728
  const ceiling = deps.isUltra?.() ? MAX_FANOUT_CONCURRENCY_ULTRA : MAX_FANOUT_CONCURRENCY;
685
- log("fanout_start", { width: workstreams.length, concurrency: Math.min(workstreams.length, ceiling) });
729
+ log("fanout_start", {
730
+ width: workstreams.length,
731
+ concurrency: Math.min(workstreams.length, ceiling),
732
+ });
686
733
  const builders = await mapWithConcurrencyLimit(workstreams, ceiling, async (w) => {
687
734
  const siblings = workstreams.filter((s) => s.name !== w.name);
688
735
  // Per-builder lifecycle, exactly the review fan-out's shape: each child
@@ -701,7 +748,9 @@ export async function runPipeline(ticket, deps) {
701
748
  return built;
702
749
  });
703
750
  stages.push(...builders);
704
- const builderCredit = builders.map((r) => creditExhaustionReason(r)).find((v) => Boolean(v));
751
+ const builderCredit = builders
752
+ .map((r) => creditExhaustionReason(r))
753
+ .find((v) => Boolean(v));
705
754
  if (builderCredit) {
706
755
  log("pipeline_failed", { stageId: stage.id, reason: builderCredit });
707
756
  throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${builderCredit}`, stages);
@@ -740,12 +789,19 @@ export async function runPipeline(ticket, deps) {
740
789
  // Summed for honesty in logs and handoffs only: this object is NEVER pushed
741
790
  // into `stages` (its children already are), so the run's usage is counted once.
742
791
  usage: sumStageUsage(builders),
743
- stderr: builders.map((r) => r.stderr).filter(Boolean).join("\n"),
792
+ stderr: builders
793
+ .map((r) => r.stderr)
794
+ .filter(Boolean)
795
+ .join("\n"),
744
796
  toolCalls: builders.reduce((n, r) => n + r.toolCalls, 0),
745
797
  toolErrors: builders.reduce((n, r) => n + r.toolErrors, 0),
746
798
  ...(failed.length > 0 ? { stopReason: "error", errorMessage: failureNote } : {}),
747
799
  };
748
- log("fanout_done", { width: workstreams.length, failed: failed.map((f) => f.name), violations: violations.length });
800
+ log("fanout_done", {
801
+ width: workstreams.length,
802
+ failed: failed.map((f) => f.name),
803
+ violations: violations.length,
804
+ });
749
805
  return summary;
750
806
  };
751
807
  /** Emit + journal the diamond's verdict the moment it resolves (decision 2). */
@@ -806,7 +862,10 @@ export async function runPipeline(ticket, deps) {
806
862
  try {
807
863
  const read = await (deps.readTouchedFiles ?? readTouchedFiles)(deps.cwd, fixInputs.baseRef, deps.signal);
808
864
  if (read === null) {
809
- log("fix_round_touched_unread", { baseRef: fixInputs.baseRef ?? null, error: "git could not list the touched files" });
865
+ log("fix_round_touched_unread", {
866
+ baseRef: fixInputs.baseRef ?? null,
867
+ error: "git could not list the touched files",
868
+ });
810
869
  }
811
870
  touched = read ?? [];
812
871
  }
@@ -853,9 +912,9 @@ export async function runPipeline(ticket, deps) {
853
912
  return done("budget_exceeded");
854
913
  }
855
914
  }
856
- // One builder at the standard tier: the partition decision is recorded as
915
+ // One builder at the lane's fix tier: the partition decision is recorded as
857
916
  // single so the run's record still says how it was built.
858
- const implementDef = fixRoundImplementStage(all.find((s) => s.id === "implement"));
917
+ const implementDef = fixRoundImplementStage(all.find((s) => s.id === "implement"), fix.model);
859
918
  const previous = [
860
919
  brief || "(No map brief: work from the findings, files and lines the ticket lists.)",
861
920
  designContext,
@@ -870,9 +929,15 @@ export async function runPipeline(ticket, deps) {
870
929
  log("stage_done", { stageId: "implement", exitCode: written.exitCode });
871
930
  const credit = creditExhaustionReason(written);
872
931
  if (isFailed(written)) {
873
- const why = credit ?? toolFailureReason(written) ?? (written.errorMessage || written.stderr || written.finalOutput || "(no output)");
874
- const killedMidWork = written.exitCode !== 0 || written.stopReason === "aborted" || written.stopReason === "error";
875
- if (killedMidWork && !deps.signal?.aborted && workspaceChanged(beforeBuild, await snapshotFn(deps.cwd, deps.signal))) {
932
+ const why = credit ??
933
+ toolFailureReason(written) ??
934
+ (written.errorMessage || written.stderr || written.finalOutput || "(no output)");
935
+ const killedMidWork = written.exitCode !== 0 ||
936
+ written.stopReason === "aborted" ||
937
+ written.stopReason === "error";
938
+ if (killedMidWork &&
939
+ !deps.signal?.aborted &&
940
+ workspaceChanged(beforeBuild, await snapshotFn(deps.cwd, deps.signal))) {
876
941
  log("pipeline_partial", { stageId: "implement", reason: why });
877
942
  return done("partial");
878
943
  }
@@ -887,7 +952,11 @@ export async function runPipeline(ticket, deps) {
887
952
  return done("aborted");
888
953
  // Verify: the plan's checks (and the pull request's CI jobs) on the tree,
889
954
  // with the same bounded fix turns as any implement stage.
890
- await runImplementChecker({ stage: implementDef, workstreams: [], handoff: written.finalOutput });
955
+ await runImplementChecker({
956
+ stage: implementDef,
957
+ workstreams: [],
958
+ handoff: written.finalOutput,
959
+ });
891
960
  progress({ kind: "stage_done", stageId: "implement" });
892
961
  stageEvent("implement", "finish");
893
962
  if (deps.signal?.aborted)
@@ -905,7 +974,11 @@ export async function runPipeline(ticket, deps) {
905
974
  verifyCommand = undefined;
906
975
  }
907
976
  const open = lastChecker?.open ?? [];
908
- writeCheckpoint(mkRecord("build_done", { snapshot: afterBuild, reviewInput: written.finalOutput, usage: currentUsage() }));
977
+ writeCheckpoint(mkRecord("build_done", {
978
+ snapshot: afterBuild,
979
+ reviewInput: written.finalOutput,
980
+ usage: currentUsage(),
981
+ }));
909
982
  return done(open.length > 0 ? "round_cap" : "clean", open);
910
983
  };
911
984
  const fixRound = !resume && deps.fixRound && !deps.fixRound.replan ? deps.fixRound : undefined;
@@ -920,7 +993,13 @@ export async function runPipeline(ticket, deps) {
920
993
  if (over) {
921
994
  log("pipeline_stop", { reason: "budget_exceeded", where: "resume", detail: over });
922
995
  progress({ kind: "done", stopReason: "budget_exceeded" });
923
- return { stages, rounds, stopReason: "budget_exceeded", findings: rounds[rounds.length - 1]?.findings ?? [], decisionsRecorded };
996
+ return {
997
+ stages,
998
+ rounds,
999
+ stopReason: "budget_exceeded",
1000
+ findings: rounds[rounds.length - 1]?.findings ?? [],
1001
+ decisionsRecorded,
1002
+ };
924
1003
  }
925
1004
  }
926
1005
  else if (fixRound) {
@@ -944,8 +1023,16 @@ export async function runPipeline(ticket, deps) {
944
1023
  baselinePaths = parseChangedPaths(beforeBuild.status);
945
1024
  // Sequential build half — stop at the first failure (mirrors the chain).
946
1025
  for (const stage of buildStages) {
947
- if (stage.id === "plan" && !mission && deps.prepareDesign && !deps.signal?.aborted && !overBudget()) {
948
- const prepared = await deps.prepareDesign({ ticket: promptTicket, exploration: previous ?? "", signal: deps.signal });
1026
+ if (stage.id === "plan" &&
1027
+ !mission &&
1028
+ deps.prepareDesign &&
1029
+ !deps.signal?.aborted &&
1030
+ !overBudget()) {
1031
+ const prepared = await deps.prepareDesign({
1032
+ ticket: promptTicket,
1033
+ exploration: previous ?? "",
1034
+ signal: deps.signal,
1035
+ });
949
1036
  designContext = prepared ? `${designMarker}\n${prepared}` : undefined;
950
1037
  if (designContext)
951
1038
  previous = `${previous ?? ""}\n\n${designContext}`;
@@ -958,10 +1045,12 @@ export async function runPipeline(ticket, deps) {
958
1045
  // boundary, so nothing is pushed for it here.
959
1046
  let r;
960
1047
  if (stage.id === "implement") {
961
- r = await runImplementDiamond(stage, previous, beforeBuild);
1048
+ r = lane.fanout
1049
+ ? await runImplementDiamond(stage, previous, beforeBuild)
1050
+ : await runOneWriter(stage, previous);
962
1051
  }
963
1052
  else {
964
- r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
1053
+ r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }, undefined, lane.skillsEveryStage));
965
1054
  stages.push(r);
966
1055
  }
967
1056
  log("stage_done", { stageId: stage.id, exitCode: r.exitCode });
@@ -973,16 +1062,23 @@ export async function runPipeline(ticket, deps) {
973
1062
  // The final build stage (implement) is exempt: its handoff is the diff, not
974
1063
  // prose, so `incompleteHandoff` never flags it.
975
1064
  if (!isFailed(r) && stage.id !== finalBuildId && incompleteHandoff(r)) {
976
- log("stage_retry", { stageId: stage.id, attempt: 2, reason: "incomplete_handoff", stopReason: r.stopReason });
1065
+ log("stage_retry", {
1066
+ stageId: stage.id,
1067
+ attempt: 2,
1068
+ reason: "incomplete_handoff",
1069
+ stopReason: r.stopReason,
1070
+ });
977
1071
  progress({ kind: "stage_start", stageId: stage.id });
978
1072
  stageEvent(stage.id, "start");
979
- r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
1073
+ r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }, undefined, lane.skillsEveryStage));
980
1074
  stages.push(r);
981
1075
  log("stage_done", { stageId: stage.id, exitCode: r.exitCode, attempt: 2 });
982
1076
  }
983
1077
  const credit = creditExhaustionReason(r);
984
1078
  if (isFailed(r)) {
985
- const why = credit ?? toolFailureReason(r) ?? (r.errorMessage || r.stderr || r.finalOutput || "(no output)");
1079
+ const why = credit ??
1080
+ toolFailureReason(r) ??
1081
+ (r.errorMessage || r.stderr || r.finalOutput || "(no output)");
986
1082
  // A WRITE stage (implement/fix) KILLED mid-work — a timeout or transient blip
987
1083
  // that aborted/errored the child once it had begun editing — leaves a real
988
1084
  // partial diff in the working tree. R1 deliberately does NOT retry such a stage
@@ -1037,10 +1133,36 @@ export async function runPipeline(ticket, deps) {
1037
1133
  throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${problem}`, stages);
1038
1134
  }
1039
1135
  }
1136
+ // A lane whose plan goes to its gate stops here, with the plan as the
1137
+ // run's result. The plan must end on its ```plan-json block; one cheap
1138
+ // re-ask recovers a plan that left it off.
1139
+ if (lane.stopAfterPlan && stage.id === "plan") {
1140
+ let plan = r.finalOutput;
1141
+ if (plan.trim() && extractPlanJson(plan) === null && !deps.signal?.aborted) {
1142
+ const reask = await runStageFn(planJsonReaskStage(stage), { ticket: promptTicket, previous: plan, grounded }, stageDepsFor({ stageId: "plan" }));
1143
+ stages.push(reask);
1144
+ const recovered = !isFailed(reask) && extractPlanJson(reask.finalOutput) !== null;
1145
+ log("plan_json_reask", { recovered });
1146
+ if (recovered)
1147
+ plan = `${plan}\n\n${reask.finalOutput}`;
1148
+ }
1149
+ progress({ kind: "stage_done", stageId: "plan" });
1150
+ stageEvent("plan", "finish", { output: plan });
1151
+ const stop = deps.signal?.aborted
1152
+ ? "aborted"
1153
+ : plan.trim()
1154
+ ? "clean"
1155
+ : "failed";
1156
+ log("pipeline_stop", { reason: stop, where: "plan" });
1157
+ progress({ kind: "done", stopReason: stop });
1158
+ return { stages, rounds, stopReason: stop, findings: [], decisionsRecorded, plan };
1159
+ }
1040
1160
  progress({ kind: "stage_done", stageId: stage.id });
1041
1161
  // The plan finish carries its finalOutput so /go can record the plan onto the
1042
1162
  // work item; no other boundary streams the full stage text.
1043
- stageEvent(stage.id, "finish", stage.id === "plan" ? { output: designContext ? `${r.finalOutput}\n\n${designContext}` : r.finalOutput } : undefined);
1163
+ stageEvent(stage.id, "finish", stage.id === "plan"
1164
+ ? { output: designContext ? `${r.finalOutput}\n\n${designContext}` : r.finalOutput }
1165
+ : undefined);
1044
1166
  // The map brief is the partitioner's repo context on an interactive run
1045
1167
  // (mission runs skip map and hand it the injected memo instead).
1046
1168
  if (stage.id === "map")
@@ -1077,11 +1199,19 @@ export async function runPipeline(ticket, deps) {
1077
1199
  // resume here instead of re-running map→plan→implement.
1078
1200
  writeCheckpoint(mkRecord("build_done", { snapshot: afterBuild, reviewInput, usage: currentUsage() }));
1079
1201
  }
1202
+ // Each round's reviewers: the lane's lenses fanned out, or its one reviewer
1203
+ // (no lens), who judges through every angle.
1204
+ const reviewers = lane.reviewLenses.length > 0 ? [...lane.reviewLenses] : [undefined];
1080
1205
  while (true) {
1081
1206
  const reviewWontFit = wontFit("review");
1082
1207
  if (reviewWontFit) {
1083
1208
  stopReason = "budget_exceeded";
1084
- log("pipeline_stop", { reason: "budget_exceeded", phase: "review", round, detail: reviewWontFit });
1209
+ log("pipeline_stop", {
1210
+ reason: "budget_exceeded",
1211
+ phase: "review",
1212
+ round,
1213
+ detail: reviewWontFit,
1214
+ });
1085
1215
  break;
1086
1216
  }
1087
1217
  const reviewStartedAt = clock();
@@ -1102,12 +1232,12 @@ export async function runPipeline(ticket, deps) {
1102
1232
  verifyError = err ?? new Error("verify failed");
1103
1233
  return null;
1104
1234
  });
1105
- const lensResults = await mapWithConcurrencyLimit(REVIEW_LENSES, MAX_CONCURRENCY, async (lens) => {
1235
+ const lensResults = await mapWithConcurrencyLimit(reviewers, MAX_CONCURRENCY, async (lens) => {
1106
1236
  // Per-lens lifecycle: each lens child is its own agent on the desktop, so it
1107
1237
  // starts and finishes on its OWN timing rather than the whole fan-out's.
1108
1238
  // The terminal panel ignores lens-tagged signals and paints one review row.
1109
1239
  progress({ kind: "stage_start", stageId: "review", lens, round });
1110
- const lensResult = await runStageFn({ ...review, lens }, { ticket: promptTicket, previous: reviewInput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }));
1240
+ const lensResult = await runStageFn({ ...review, lens }, { ticket: promptTicket, previous: reviewInput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }, undefined, lane.skillsEveryStage));
1111
1241
  progress({ kind: "stage_done", stageId: "review", lens, round });
1112
1242
  return lensResult;
1113
1243
  });
@@ -1115,7 +1245,9 @@ export async function runPipeline(ticket, deps) {
1115
1245
  // which parses to zero findings — the loop would stop a false "clean" while
1116
1246
  // every request was failing. Checked on ALL lens results (not just failed
1117
1247
  // ones) for exactly that reason.
1118
- const lensCredit = lensResults.map((r) => creditExhaustionReason(r)).find((v) => Boolean(v));
1248
+ const lensCredit = lensResults
1249
+ .map((r) => creditExhaustionReason(r))
1250
+ .find((v) => Boolean(v));
1119
1251
  if (lensCredit) {
1120
1252
  stopReason = "failed";
1121
1253
  log("pipeline_failed", { phase: "review", round, reason: lensCredit });
@@ -1133,13 +1265,22 @@ export async function runPipeline(ticket, deps) {
1133
1265
  const perLens = [];
1134
1266
  for (let i = 0; i < lensResults.length; i++) {
1135
1267
  const r = lensResults[i];
1136
- const lens = REVIEW_LENSES[i];
1268
+ const lens = reviewers[i];
1137
1269
  if (isFailed(r)) {
1138
1270
  failedLenses.push(lens);
1139
- progress({ kind: "stage_done", stageId: "review", lens, round, findings: 0, blocking: 0, degraded: "failed" });
1271
+ progress({
1272
+ kind: "stage_done",
1273
+ stageId: "review",
1274
+ lens,
1275
+ round,
1276
+ findings: 0,
1277
+ blocking: 0,
1278
+ degraded: "failed",
1279
+ });
1140
1280
  continue;
1141
1281
  }
1142
- let parsed = parseFindings(r.finalOutput, lens);
1282
+ // The lane's one reviewer names each finding's lens; a lens fan child is its lens.
1283
+ let parsed = parseFindings(r.finalOutput, lens, { lensColumn: lens === undefined });
1143
1284
  // Visible signal (not silent): a non-empty reviewer output that parses to
1144
1285
  // zero findings is a likely format miss, not a guaranteed clean review.
1145
1286
  if (parsed.length === 0 && r.finalOutput.trim())
@@ -1158,7 +1299,7 @@ export async function runPipeline(ticket, deps) {
1158
1299
  !deps.signal?.aborted) {
1159
1300
  const reask = await runStageFn(reaskStage(review), { ticket: promptTicket, previous: r.finalOutput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }));
1160
1301
  if (!isFailed(reask))
1161
- parsed = parseFindings(reask.finalOutput, lens);
1302
+ parsed = parseFindings(reask.finalOutput, lens, { lensColumn: lens === undefined });
1162
1303
  log("review_reask", { lens, findings: parsed.length });
1163
1304
  if (parsed.length === 0)
1164
1305
  unparseableLenses.push(lens);
@@ -1195,7 +1336,12 @@ export async function runPipeline(ticket, deps) {
1195
1336
  // check unrunnable) survives honestly — absent reason clears it.
1196
1337
  verifyNote = verify.reason;
1197
1338
  verifyCommand = verify.command;
1198
- log("verify_done", { round, ok: verify.ok, command: verify.command, findings: verify.findings.length });
1339
+ log("verify_done", {
1340
+ round,
1341
+ ok: verify.ok,
1342
+ command: verify.command,
1343
+ findings: verify.findings.length,
1344
+ });
1199
1345
  }
1200
1346
  else if (verify.reason) {
1201
1347
  verifyNote = verify.reason;
@@ -1206,7 +1352,10 @@ export async function runPipeline(ticket, deps) {
1206
1352
  const findings = unionFindings([...perLens, verifyFindings]);
1207
1353
  // Two degradation flavors, one honest list (§3e): a failed lens was skipped
1208
1354
  // outright; an unparseable lens proceeded as zero findings after the re-ask.
1209
- const degraded = [...failedLenses, ...unparseableLenses.map((l) => `${l} (unparseable)`)];
1355
+ const degraded = [
1356
+ ...failedLenses.map(reviewerName),
1357
+ ...unparseableLenses.map((l) => `${reviewerName(l)} (unparseable)`),
1358
+ ];
1210
1359
  const degradedLenses = degraded.length > 0 ? degraded : undefined;
1211
1360
  rounds.push({ round, lensResults, findings, ...(degradedLenses ? { degradedLenses } : {}) });
1212
1361
  log("review_round", {
@@ -1215,8 +1364,17 @@ export async function runPipeline(ticket, deps) {
1215
1364
  blocking: hasBlockingFindings(findings),
1216
1365
  ...(degradedLenses ? { degradedLenses } : {}),
1217
1366
  });
1218
- progress({ kind: "findings", round, total: findings.length, blocking: blockingCount(findings) });
1219
- stageEvent("review", "finish", { round, findings: findings.length, items: roundFindingItems(findings) });
1367
+ progress({
1368
+ kind: "findings",
1369
+ round,
1370
+ total: findings.length,
1371
+ blocking: blockingCount(findings),
1372
+ });
1373
+ stageEvent("review", "finish", {
1374
+ round,
1375
+ findings: findings.length,
1376
+ items: roundFindingItems(findings),
1377
+ });
1220
1378
  // Honest health check BEFORE trusting the verdict: an aborted or crashed
1221
1379
  // review must NOT be read as a clean review (empty output → [] findings →
1222
1380
  // would otherwise stop 'clean'). Mirrors the build half's isFailed guard.
@@ -1231,8 +1389,9 @@ export async function runPipeline(ticket, deps) {
1231
1389
  // the loop cannot proceed without, default: correctness) is the one that
1232
1390
  // failed. Otherwise the surviving lenses carry the round and `degradedLenses`
1233
1391
  // already records the skip honestly.
1234
- const requiredFailed = failedLenses.filter((l) => REQUIRED_LENSES.includes(l));
1235
- if (failedLenses.length === REVIEW_LENSES.length || requiredFailed.length > 0) {
1392
+ // A lane's only reviewer (no lens) is required by definition.
1393
+ const requiredFailed = failedLenses.filter((l) => l === undefined || REQUIRED_LENSES.includes(l));
1394
+ if (failedLenses.length === reviewers.length || requiredFailed.length > 0) {
1236
1395
  stopReason = "failed";
1237
1396
  log("pipeline_failed", { phase: "review", round, failedLenses, requiredFailed });
1238
1397
  break;
@@ -1243,7 +1402,7 @@ export async function runPipeline(ticket, deps) {
1243
1402
  observedMs.review = clock() - reviewStartedAt;
1244
1403
  // The verdict first: a clean review is clean and a capped one is capped, even
1245
1404
  // past a ceiling, since neither spends anything more.
1246
- const decision = shouldStop(findings, round);
1405
+ const decision = shouldStop(findings, round, lane.maxReviewRounds);
1247
1406
  if (decision.stop && decision.reason) {
1248
1407
  stopReason = decision.reason;
1249
1408
  break;
@@ -1253,7 +1412,12 @@ export async function runPipeline(ticket, deps) {
1253
1412
  const overAfterReview = overBudget();
1254
1413
  if (overAfterReview) {
1255
1414
  stopReason = "budget_exceeded";
1256
- log("pipeline_stop", { reason: "budget_exceeded", phase: "review", round, detail: overAfterReview });
1415
+ log("pipeline_stop", {
1416
+ reason: "budget_exceeded",
1417
+ phase: "review",
1418
+ round,
1419
+ detail: overAfterReview,
1420
+ });
1257
1421
  break;
1258
1422
  }
1259
1423
  // The review's findings are recorded; a fix that cannot finish in time would
@@ -1267,7 +1431,13 @@ export async function runPipeline(ticket, deps) {
1267
1431
  const fixStartedAt = clock();
1268
1432
  progress({ kind: "stage_start", stageId: "fix", round });
1269
1433
  stageEvent("fix", "start", { round });
1270
- const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: designContext ? `${serializeBlocking(findings)}\n\n${designContext}` : serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }, undefined, true));
1434
+ const fixResult = await runStageFn(fix, {
1435
+ ticket: promptTicket,
1436
+ previous: designContext
1437
+ ? `${serializeBlocking(findings)}\n\n${designContext}`
1438
+ : serializeBlocking(findings),
1439
+ grounded,
1440
+ }, stageDepsFor({ stageId: "fix", round }, undefined, true));
1271
1441
  observedMs.fix = clock() - fixStartedAt;
1272
1442
  stages.push(fixResult);
1273
1443
  log("stage_done", { stageId: "fix", exitCode: fixResult.exitCode });
@@ -1285,7 +1455,8 @@ export async function runPipeline(ticket, deps) {
1285
1455
  break;
1286
1456
  }
1287
1457
  if (isFailed(fixResult)) {
1288
- const why = toolFailureReason(fixResult) ?? (fixResult.errorMessage || fixResult.stderr || "(no output)");
1458
+ const why = toolFailureReason(fixResult) ??
1459
+ (fixResult.errorMessage || fixResult.stderr || "(no output)");
1289
1460
  // Symmetric with the build-half guard: a fix stage KILLED mid-work (timeout /
1290
1461
  // transient abort/error) leaves the run's reviewed diff (implement + any prior
1291
1462
  // accepted rounds) plus a partial fix in the tree. By here the build half has
@@ -1293,7 +1464,9 @@ export async function runPipeline(ticket, deps) {
1293
1464
  // preservable work — report `partial`, not a bare `failed`. A clean exit-0 fix
1294
1465
  // failed only by the R3-a tool-health gate is the silent landmine (edits never
1295
1466
  // landed) and keeps the hard `failed`.
1296
- const fixKilledMidWork = fixResult.exitCode !== 0 || fixResult.stopReason === "aborted" || fixResult.stopReason === "error";
1467
+ const fixKilledMidWork = fixResult.exitCode !== 0 ||
1468
+ fixResult.stopReason === "aborted" ||
1469
+ fixResult.stopReason === "error";
1297
1470
  if (fixKilledMidWork) {
1298
1471
  stopReason = "partial";
1299
1472
  log("pipeline_partial", { phase: "fix", round, reason: why });
@@ -1314,8 +1487,16 @@ export async function runPipeline(ticket, deps) {
1314
1487
  // crash now can resume at the NEXT round against this fix's output. Snapshot
1315
1488
  // the post-fix tree as the resume consistency key.
1316
1489
  const afterFix = await snapshotFn(deps.cwd, deps.signal);
1317
- reviewInput = designContext ? `${fixResult.finalOutput}\n\n${designContext}` : fixResult.finalOutput;
1318
- writeCheckpoint(mkRecord("round_done", { round, reviewInput, findings, snapshot: afterFix, usage: currentUsage() }));
1490
+ reviewInput = designContext
1491
+ ? `${fixResult.finalOutput}\n\n${designContext}`
1492
+ : fixResult.finalOutput;
1493
+ writeCheckpoint(mkRecord("round_done", {
1494
+ round,
1495
+ reviewInput,
1496
+ findings,
1497
+ snapshot: afterFix,
1498
+ usage: currentUsage(),
1499
+ }));
1319
1500
  round += 1;
1320
1501
  }
1321
1502
  log("pipeline_stop", { reason: stopReason, round });