@yagni-app/code 0.3.1 → 0.3.3

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 (65) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/crashReport.d.ts +12 -0
  3. package/dist/crashReport.js +28 -1
  4. package/dist/extension/crashReport.d.ts +18 -0
  5. package/dist/extension/crashReport.js +35 -2
  6. package/dist/extension/footer.d.ts +1 -1
  7. package/dist/extension/hooks.d.ts +111 -0
  8. package/dist/extension/hooks.js +666 -0
  9. package/dist/extension/index.d.ts +13 -6
  10. package/dist/extension/index.js +57 -7
  11. package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
  12. package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
  13. package/dist/extension/permission/dbReadPolicy.js +227 -0
  14. package/dist/extension/{execPolicy.js → permission/execPolicy.js} +99 -8
  15. package/dist/extension/{permission.d.ts → permission/gate.d.ts} +10 -3
  16. package/dist/extension/{permission.js → permission/gate.js} +156 -9
  17. package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
  18. package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
  19. package/dist/extension/permission/index.d.ts +14 -0
  20. package/dist/extension/permission/index.js +14 -0
  21. package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
  22. package/dist/extension/permission/packageManagerPolicy.js +170 -0
  23. package/dist/extension/pipeline/activityFeed.js +19 -5
  24. package/dist/extension/pipeline/checker.d.ts +99 -0
  25. package/dist/extension/pipeline/checker.js +238 -0
  26. package/dist/extension/pipeline/fanout.d.ts +116 -0
  27. package/dist/extension/pipeline/fanout.js +248 -0
  28. package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
  29. package/dist/extension/pipeline/fanoutBeats.js +86 -0
  30. package/dist/extension/pipeline/goCommand.d.ts +14 -0
  31. package/dist/extension/pipeline/goCommand.js +38 -1
  32. package/dist/extension/pipeline/headlessGo.d.ts +163 -0
  33. package/dist/extension/pipeline/headlessGo.js +333 -0
  34. package/dist/extension/pipeline/invocation.d.ts +31 -3
  35. package/dist/extension/pipeline/invocation.js +37 -3
  36. package/dist/extension/pipeline/mission.d.ts +55 -0
  37. package/dist/extension/pipeline/mission.js +70 -0
  38. package/dist/extension/pipeline/orchestrator.d.ts +48 -3
  39. package/dist/extension/pipeline/orchestrator.js +450 -9
  40. package/dist/extension/pipeline/personas.d.ts +16 -1
  41. package/dist/extension/pipeline/personas.js +118 -7
  42. package/dist/extension/pipeline/runSession.d.ts +45 -1
  43. package/dist/extension/pipeline/runState.d.ts +57 -12
  44. package/dist/extension/pipeline/runState.js +60 -18
  45. package/dist/extension/pipeline/runner.js +10 -1
  46. package/dist/extension/pipeline/stages.d.ts +84 -7
  47. package/dist/extension/pipeline/stages.js +166 -0
  48. package/dist/extension/pipeline/tierCap.d.ts +32 -0
  49. package/dist/extension/pipeline/tierCap.js +57 -0
  50. package/dist/extension/pipeline/types.d.ts +130 -1
  51. package/dist/extension/pipeline/types.js +17 -0
  52. package/dist/extension/pipeline/verify.d.ts +86 -3
  53. package/dist/extension/pipeline/verify.js +175 -6
  54. package/dist/extension/subagents.js +13 -0
  55. package/dist/extension/turnLog.d.ts +38 -0
  56. package/dist/extension/turnLog.js +93 -0
  57. package/dist/goHeadless.d.ts +75 -0
  58. package/dist/goHeadless.js +132 -0
  59. package/dist/paths.d.ts +9 -0
  60. package/dist/paths.js +12 -0
  61. package/dist/promptEnrichment.d.ts +1 -1
  62. package/dist/promptEnrichment.js +1 -1
  63. package/package.json +2 -2
  64. /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
  65. /package/dist/extension/{execPolicy.d.ts → permission/execPolicy.d.ts} +0 -0
@@ -3,7 +3,11 @@
3
3
  *
4
4
  * Sequences `runStage` for the build stages (map → plan → implement), threading
5
5
  * each stage's `finalOutput` into the next as `{previous}` (mirrors the subagent
6
- * example's chain). Then it runs the bounded review→fix LOOP: each round fans the
6
+ * example's chain). In MISSION mode (`deps.mission`, from the headless entry's
7
+ * `--plan-file` / `--memo-file`) the build half is entered part-way: an approved
8
+ * plan skips map + plan and seeds implement, an injected memo stands in for the
9
+ * map brief, and the review→fix loop below runs exactly as it always does — see
10
+ * mission.ts. Then it runs the bounded review→fix LOOP: each round fans the
7
11
  * review stage out across the 3 lenses (parallel, bounded by MAX_CONCURRENCY),
8
12
  * parses each lens with `parseFindings` (with ONE cheap format-recovery re-ask
9
13
  * for a healthy lens that broke the findings contract — spec §3e), unions them,
@@ -21,13 +25,16 @@
21
25
  * findings — i.e. a false "reviewed and clean").
22
26
  */
23
27
  import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, EMPTY_RUN_USAGE, exceedsBudget } from "./budget.js";
28
+ import { attributeFindings, checkerFindings, composeFixNote, composeResidue, composeSynthesizerInput, mergeFindings, parseOpenFindings, renderFindings, stripOpenFindings, } from "./checker.js";
29
+ import { auditClaims, parsePartition } from "./fanout.js";
24
30
  import { extractFindingsBlock, hasBlockingFindings, parseFindings, shouldStop, unionFindings } from "./findings.js";
31
+ import { missionSeed, missionSkippedStages, normalizeMission } from "./mission.js";
25
32
  import { withResilience } from "./resilience.js";
26
33
  import { runStage as defaultRunStage } from "./runner.js";
27
- import { makeRunVerify, parseChangedPaths } from "./verify.js";
28
- import { REQUIRED_LENSES, REVIEW_LENSES, reaskStage, reviewStage, selectStages } from "./stages.js";
34
+ import { makeRunVerify, makeWorkstreamCheck, parseChangedPaths, } from "./verify.js";
35
+ import { builderStage, fixerStage, orchestratorStage, partitionReaskStage, PARTITION_CALLER_LABEL, REQUIRED_LENSES, REVIEW_LENSES, reaskStage, reviewStage, selectStages, synthesizerFixStage, synthesizerStage, SYNTHESIZER_CALLER_LABEL, workstreamCallerLabel, } from "./stages.js";
29
36
  import { snapshotWorkspace as defaultSnapshotWorkspace, workspaceChanged } from "./workspace.js";
30
- import { DEFAULT_RESILIENCE_POLICY, MAX_CONCURRENCY, MIN_TOOL_CALLS_FOR_HEALTH, TOOL_ERROR_FAIL_RATE, } from "./types.js";
37
+ 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";
31
38
  /** Error thrown when a build stage fails; carries the partial run for the caller. */
32
39
  export class PipelineStageError extends Error {
33
40
  stageId;
@@ -134,6 +141,35 @@ function serializeBlocking(findings) {
134
141
  })
135
142
  .join("\n");
136
143
  }
144
+ /** Sum a fan's per-child usage into the one figure the fan summary reports. */
145
+ function sumStageUsage(results) {
146
+ return results.reduce((acc, r) => ({
147
+ input: acc.input + r.usage.input,
148
+ output: acc.output + r.usage.output,
149
+ cacheRead: acc.cacheRead + r.usage.cacheRead,
150
+ cacheWrite: acc.cacheWrite + r.usage.cacheWrite,
151
+ cost: acc.cost + r.usage.cost,
152
+ turns: acc.turns + r.usage.turns,
153
+ }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 });
154
+ }
155
+ /**
156
+ * The implement handoff a FAN produces: the recorded decision, then each
157
+ * workstream's own report under its name and claims, then any edit that landed
158
+ * outside every claim. This is what `reviewInput` becomes today; the synthesizer
159
+ * (spec decision 5) takes it as its input and replaces it with a reconciled
160
+ * summary, so the shape is deliberately one a seam-reconciler can read.
161
+ */
162
+ function composeFanHandoff(decision, workstreams, results, violations) {
163
+ const parts = [`Fanned ${workstreams.length} ways: ${decision.reason}`];
164
+ workstreams.forEach((w, i) => {
165
+ const body = results[i]?.finalOutput.trim() || "(this workstream reported nothing)";
166
+ parts.push(`## ${w.name} (${w.tier})\nClaimed: ${w.files.join(", ")}\n\n${body}`);
167
+ });
168
+ if (violations.length > 0) {
169
+ parts.push(["## Out-of-claim edits", "These paths changed outside every workstream's claims:", ...violations.map((p) => `- ${p}`)].join("\n"));
170
+ }
171
+ return parts.join("\n\n");
172
+ }
137
173
  /** Bounded-concurrency map preserving input order (copied from the subagent example). */
138
174
  async function mapWithConcurrencyLimit(items, concurrency, fn) {
139
175
  if (items.length === 0)
@@ -165,6 +201,7 @@ export async function runPipeline(ticket, deps) {
165
201
  });
166
202
  const snapshotFn = deps.snapshotWorkspace ?? defaultSnapshotWorkspace;
167
203
  const runVerifyFn = deps.runVerify ?? makeRunVerify();
204
+ const runWorkstreamCheckFn = deps.runWorkstreamCheck ?? makeWorkstreamCheck();
168
205
  const progress = (p) => deps.onProgress?.(p);
169
206
  // Count of record_decision tool calls that completed without error across the
170
207
  // whole run, tapped off the NDJSON stream (spec §3c: the FINISH trailer's
@@ -185,13 +222,19 @@ export async function runPipeline(ticket, deps) {
185
222
  // backend unreachable) — attribution then degrades to the caller label alone,
186
223
  // never blocking the pipeline.
187
224
  const runId = deps.checkpointMeta?.runId;
188
- const stageDepsFor = (tag) => {
225
+ // `callerLabel` is set only by the implement diamond's children (the
226
+ // partitioner's `go:implement:partition` and each builder's
227
+ // `go:implement:<workstream>`), so per-workstream spend attributes for free the
228
+ // way review lenses do. Every other stage leaves it undefined and keeps the
229
+ // runner's own `go:<stage>` default.
230
+ const stageDepsFor = (tag, callerLabel) => {
189
231
  const onEvent = deps.onEvent;
190
232
  return {
191
233
  cwd: deps.cwd,
192
234
  signal: deps.signal,
193
235
  ...(deps.childEnv ? { env: deps.childEnv } : {}),
194
236
  ...(runId ? { attribution: { runId } } : {}),
237
+ ...(callerLabel ? { callerLabel } : {}),
195
238
  onEvent: (ev) => {
196
239
  if (ev.type === "tool_execution_end" && ev.toolName === "record_decision" && !ev.isError) {
197
240
  decisionsRecorded += 1;
@@ -212,7 +255,24 @@ export async function runPipeline(ticket, deps) {
212
255
  const all = deps.stages ?? selectStages("full");
213
256
  // R3-b: aggregate usage ceiling, checked at each stage boundary.
214
257
  const budget = deps.budget ?? DEFAULT_RUN_BUDGET;
215
- const buildStages = all.filter((s) => BUILD_STAGE_IDS.includes(s.id));
258
+ // Mission mode (spec: sandbox harness parity). The factory produced the plan
259
+ // and a human approved it at the mission's plan gate, so re-running the
260
+ // pipeline's own map/plan would silently discard that approval and hollow out
261
+ // the gate: an injected plan enters the pipeline at implement instead. An
262
+ // injected memo is the repo brief the map stage would have written, so map is
263
+ // skipped whenever either input is present. All three rules are pure (see
264
+ // mission.ts); the orchestrator only applies them to the stage list + seed.
265
+ const mission = normalizeMission(deps.mission);
266
+ const missionSkipped = missionSkippedStages(mission);
267
+ const buildStages = all.filter((s) => BUILD_STAGE_IDS.includes(s.id) && !missionSkipped.includes(s.id));
268
+ if (mission) {
269
+ log("mission_mode", {
270
+ plan: Boolean(mission.plan),
271
+ memo: Boolean(mission.memo),
272
+ skipped: missionSkipped,
273
+ entry: buildStages[0]?.id,
274
+ });
275
+ }
216
276
  // The last build stage's handoff is the working-tree diff (checked by the
217
277
  // no_changes guard), not its prose; only the earlier stages thread text forward.
218
278
  const finalBuildId = buildStages[buildStages.length - 1]?.id;
@@ -270,6 +330,369 @@ export async function runPipeline(ticket, deps) {
270
330
  const priorUsage = resume?.priorUsage ?? EMPTY_RUN_USAGE;
271
331
  const currentUsage = () => addRunUsage(priorUsage, aggregateRunUsage(stages, rounds));
272
332
  const overBudget = () => exceedsBudget(currentUsage(), budget);
333
+ // The map stage's brief, kept for the partitioner's repo context. Absent on a
334
+ // mission run (map is skipped there — the injected memo stands in) and on any
335
+ // run whose map stage did not produce one; the partition contract is explicit
336
+ // that thin context means single-writer, never a guessed partition.
337
+ let mapBrief;
338
+ /**
339
+ * The diamond's CHECKER (spec decision 6): each workstream's own claims
340
+ * typechecked in their package dirs (attribution), then ONE full verify —
341
+ * typecheck plus the repo's own tests, `baselinePaths`-scoped as always — on the
342
+ * merged tree. The full suite runs on the tree that ships, not once per child.
343
+ *
344
+ * Fail-open on both halves: a seam that throws records an honest log line and
345
+ * contributes no findings, because the candidate is real work sitting in the
346
+ * working tree and the review loop's own gate still runs downstream.
347
+ */
348
+ const runChecker = async (targets) => {
349
+ if (deps.signal?.aborted)
350
+ return { scoped: [], verify: null };
351
+ let scoped = [];
352
+ if (targets.length > 0) {
353
+ try {
354
+ scoped = await runWorkstreamCheckFn(deps.cwd, targets, deps.signal);
355
+ }
356
+ catch (err) {
357
+ log("checker_scoped_failed", { error: err instanceof Error ? err.message : String(err) });
358
+ }
359
+ }
360
+ let verify = null;
361
+ try {
362
+ verify = await runVerifyFn(deps.cwd, deps.signal, baselinePaths);
363
+ }
364
+ catch (err) {
365
+ log("checker_verify_failed", { error: err instanceof Error ? err.message : String(err) });
366
+ }
367
+ log("checker_done", {
368
+ scoped: scoped.map((s) => ({ name: s.name, ran: s.ran, findings: s.findings.length })),
369
+ verify: verify ? { ran: verify.ran, ok: verify.ok, findings: verify.findings.length } : null,
370
+ });
371
+ return { scoped, verify };
372
+ };
373
+ /**
374
+ * The checker → synthesizer → bounded fix loop (spec decisions 5, 6 and 7).
375
+ * Returns the text that becomes `reviewInput`.
376
+ *
377
+ * On a FAN the synthesizer always runs: with one shared worktree the union diff
378
+ * is already in place, so its job is the seams (plus the checker's findings and
379
+ * any out-of-claim edits the handoff already names), and its `finalOutput` IS
380
+ * the implement handoff. On the SINGLE-WRITER path there are no seams, so the
381
+ * builder's own output stays the handoff — byte-identical to today whenever
382
+ * verification is clean, which is the point.
383
+ *
384
+ * Then the fix loop: while verification (or the synthesizer's own "## Open
385
+ * findings") still reports concrete defects, re-engage the implicated builders
386
+ * at their ORIGINAL tier scoped to their claims, hand unattributable findings to
387
+ * the synthesizer (the one builder, on the single-writer path), and re-check.
388
+ * Capped at MAX_FIX_TURNS; findings still open at the cap are named in the
389
+ * handoff so the review loop inherits the truth, never a known-broken candidate
390
+ * dressed as clean.
391
+ */
392
+ const runImplementChecker = async (args) => {
393
+ const { stage, workstreams, handoff } = args;
394
+ const fanned = workstreams.length > 0;
395
+ const targets = workstreams.map((w) => ({ name: w.name, files: w.files }));
396
+ let report = await runChecker(targets);
397
+ let body = handoff;
398
+ // Defects the SYNTHESIZER named in its latest summary, kept apart from the
399
+ // deterministic ones: they are only re-read when the synthesizer itself ran
400
+ // again, so a stale "## Open findings" section can never keep the loop alive
401
+ // after the builders have fixed what it pointed at.
402
+ let summaryDefects = [];
403
+ if (fanned) {
404
+ const synth = await runStageFn({ ...synthesizerStage(), tools: stage.tools }, { ticket: promptTicket, previous: composeSynthesizerInput(handoff, report), grounded }, stageDepsFor({ stageId: stage.id }, SYNTHESIZER_CALLER_LABEL));
405
+ stages.push(synth);
406
+ if (!isFailed(synth) && synth.finalOutput.trim()) {
407
+ body = synth.finalOutput;
408
+ summaryDefects = parseOpenFindings(body);
409
+ }
410
+ else {
411
+ // An unreconciled fan is still real work in the tree: keep the fan's own
412
+ // handoff (every workstream named) rather than dropping to nothing.
413
+ log("synthesizer_degraded", { exitCode: synth.exitCode, stopReason: synth.stopReason });
414
+ }
415
+ }
416
+ let open = mergeFindings(checkerFindings(report), summaryDefects);
417
+ let turn = 0;
418
+ while (open.length > 0 && turn < MAX_FIX_TURNS) {
419
+ if (deps.signal?.aborted)
420
+ break;
421
+ const over = overBudget();
422
+ if (over) {
423
+ log("fix_loop_stop", { reason: "budget_exceeded", turn: turn + 1, detail: over });
424
+ break;
425
+ }
426
+ turn += 1;
427
+ const { assigned, unassigned } = attributeFindings(open, workstreams);
428
+ const reengaged = [];
429
+ // The implicated builders, each at its original tier and scoped to its own
430
+ // claims. Parallel under the same ceiling the fan itself ran at, because
431
+ // they are the same writers on the same disjoint file sets.
432
+ if (assigned.length > 0) {
433
+ const ceiling = deps.isUltra?.() ? MAX_FANOUT_CONCURRENCY_ULTRA : MAX_FANOUT_CONCURRENCY;
434
+ const fixes = await mapWithConcurrencyLimit(assigned, ceiling, async (a) => {
435
+ const siblings = workstreams.filter((s) => s.name !== a.workstream.name);
436
+ progress({ kind: "stage_start", stageId: stage.id, workstream: a.workstream.name });
437
+ 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)));
438
+ progress({
439
+ kind: "stage_done",
440
+ stageId: stage.id,
441
+ workstream: a.workstream.name,
442
+ ...(isFailed(fixed) ? { degraded: "failed" } : {}),
443
+ });
444
+ return fixed;
445
+ });
446
+ stages.push(...fixes);
447
+ reengaged.push(...assigned.map((a) => a.workstream.name));
448
+ // A fix turn that CRASHED does not fail the stage: the merged candidate is
449
+ // still real work, the re-check below will simply still see the findings,
450
+ // and the cap bounds how long that can go on. Recorded, never silent.
451
+ const brokenFixes = fixes.map((r, i) => (isFailed(r) ? assigned[i].workstream.name : null)).filter(Boolean);
452
+ if (brokenFixes.length > 0)
453
+ log("fix_turn_child_failed", { turn, workstreams: brokenFixes });
454
+ }
455
+ // Unattributable findings are seam work: the synthesizer's on a fan, and the
456
+ // one builder's on the single-writer path (where nothing is attributable by
457
+ // construction). Run AFTER the builders, never beside them: the synthesizer
458
+ // is the one child with no claims, so it must not write while they do.
459
+ if (unassigned.length > 0) {
460
+ const findingsText = renderFindings(unassigned);
461
+ const fixStage = fanned ? synthesizerFixStage(findingsText) : fixerStage(undefined, findingsText);
462
+ const fixed = await runStageFn({ ...fixStage, tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id }, fanned ? SYNTHESIZER_CALLER_LABEL : undefined));
463
+ stages.push(fixed);
464
+ if (fanned) {
465
+ if (!isFailed(fixed) && fixed.finalOutput.trim()) {
466
+ body = fixed.finalOutput;
467
+ summaryDefects = parseOpenFindings(body);
468
+ }
469
+ reengaged.push("synthesizer");
470
+ }
471
+ else {
472
+ reengaged.push("single writer");
473
+ }
474
+ }
475
+ else {
476
+ // Nobody re-read the summary this turn, so its findings were answered by
477
+ // the builders and the deterministic re-check below is the honest verdict.
478
+ // The section that named them goes with them: the synthesizer does not run
479
+ // again on this path, so an untouched "## Open findings" would ride into
480
+ // the review handoff beside a fix note calling the final check clean.
481
+ if (summaryDefects.length > 0)
482
+ body = stripOpenFindings(body);
483
+ summaryDefects = [];
484
+ }
485
+ progress({ kind: "fix_turn", turn, findings: open.length, reengaged });
486
+ log("fix_turn", { turn, findings: open.length, reengaged });
487
+ report = await runChecker(targets);
488
+ open = mergeFindings(checkerFindings(report), summaryDefects);
489
+ }
490
+ if (open.length > 0) {
491
+ log("fix_loop_open", { turns: turn, findings: open.length });
492
+ return `${body}\n\n${composeResidue(open, turn)}`;
493
+ }
494
+ if (turn > 0)
495
+ return `${body}\n\n${composeFixNote(turn)}`;
496
+ return body;
497
+ };
498
+ /**
499
+ * The implement diamond (spec "Diamond execution"). Returns the StageResult the
500
+ * build loop treats as "the implement stage". The diamond owns `stages` for the
501
+ * whole implement boundary: every real child (partitioner, builders,
502
+ * synthesizer, fixers, and the single writer itself) is pushed here, and the
503
+ * SYNTHETIC fan summary never is — its children already carry the spend.
504
+ *
505
+ * The single-writer path deliberately runs exactly today's `runStageFn` call,
506
+ * with today's arguments and today's untagged deps; the checker gate that
507
+ * follows it changes nothing downstream unless verification actually found
508
+ * something.
509
+ */
510
+ const runImplementDiamond = async (stage, previous, beforeBuild) => {
511
+ const pinned = deps.fanout === "always";
512
+ const partitionCtx = {
513
+ ...(mapBrief?.trim() ? { repoBrief: mapBrief } : {}),
514
+ ...(mission?.memo ? { missionMemo: mission.memo } : {}),
515
+ ...(pinned ? { pinned: true } : {}),
516
+ };
517
+ // The LANE owns the tool whitelist: the blind eval lane strips the grounding
518
+ // tools from its stage list, and a diamond child rebuilt from the default
519
+ // stage data would quietly hand them back. So the partitioner drops
520
+ // `ask_yagni` when the run is blind, and every builder inherits the lane's own
521
+ // implement whitelist rather than PIPELINE_V1's.
522
+ const partitionDef = orchestratorStage(partitionCtx);
523
+ const partitionStage = grounded
524
+ ? partitionDef
525
+ : { ...partitionDef, tools: partitionDef.tools.filter((t) => t !== "ask_yagni") };
526
+ const partitionTag = { stageId: stage.id };
527
+ const runSingleWriter = async (decision) => {
528
+ recordDecision(decision, beforeBuild);
529
+ const written = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
530
+ stages.push(written);
531
+ // A failed or credit-starved writer never reaches the checker: the build
532
+ // loop's own guard turns it into `partial` or the honest stage error, and
533
+ // verifying a tree nobody finished writing would only add noise.
534
+ if (isFailed(written) || creditExhaustionReason(written) || deps.signal?.aborted)
535
+ return written;
536
+ // Decision 7: the fix loop applies identically here — the same checker gate,
537
+ // the same one builder re-engaged, the same cap.
538
+ const text = await runImplementChecker({ stage, workstreams: [], handoff: written.finalOutput });
539
+ return text === written.finalOutput ? written : { ...written, finalOutput: text };
540
+ };
541
+ const partition = await runStageFn(partitionStage, { ticket: promptTicket, previous, grounded }, stageDepsFor(partitionTag, PARTITION_CALLER_LABEL));
542
+ stages.push(partition);
543
+ // A credit-starved partitioner exits 0 with an empty transcript; stop with the
544
+ // actionable message rather than degrading into a fan of equally starved children.
545
+ const partitionCredit = creditExhaustionReason(partition);
546
+ if (partitionCredit) {
547
+ log("pipeline_failed", { stageId: stage.id, reason: partitionCredit });
548
+ throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${partitionCredit}`, stages);
549
+ }
550
+ // Overlapping claims are the HARD rejection (spec decision 3): fail here,
551
+ // naming the colliding paths, so no run ever discovers two writers on one file
552
+ // at merge time. Everything else is soft: one cheap format re-ask, then the
553
+ // recorded single-writer degrade.
554
+ let parsed = isFailed(partition) ? null : parsePartition(partition.finalOutput);
555
+ if (parsed && !parsed.ok && parsed.hard) {
556
+ log("fanout_collision", { collisions: parsed.collisions });
557
+ throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${parsed.reason}`, stages);
558
+ }
559
+ if (parsed && !parsed.ok && partition.finalOutput.trim() && !deps.signal?.aborted) {
560
+ const reask = await runStageFn(partitionReaskStage(partitionStage), { ticket: promptTicket, previous: partition.finalOutput, grounded }, stageDepsFor(partitionTag, PARTITION_CALLER_LABEL));
561
+ stages.push(reask);
562
+ log("fanout_reask", { reason: parsed.reason });
563
+ if (!isFailed(reask) && reask.finalOutput.trim()) {
564
+ const second = parsePartition(reask.finalOutput);
565
+ if (!second.ok && second.hard) {
566
+ log("fanout_collision", { collisions: second.collisions });
567
+ throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${second.reason}`, stages);
568
+ }
569
+ parsed = second;
570
+ }
571
+ }
572
+ if (!parsed || !parsed.ok) {
573
+ const why = !parsed
574
+ ? "the partitioner did not finish, so the plan was built by one writer"
575
+ : `the partition could not be read (${parsed.reason}), so the plan was built by one writer`;
576
+ return runSingleWriter({ mode: "single", reason: why });
577
+ }
578
+ if (parsed.decision.mode === "single")
579
+ return runSingleWriter(parsed.decision);
580
+ const workstreams = parsed.decision.workstreams ?? [];
581
+ // Decision 8: the budget seam is consulted once, and only for a real fan. A
582
+ // pinned benchmark lane skips the consult by design; an absent seam allows,
583
+ // and a seam that THROWS degrades to the single writer rather than crashing
584
+ // a run whose map and plan spend is already sunk.
585
+ if (!pinned && deps.fanoutBudget) {
586
+ let verdict;
587
+ try {
588
+ verdict = await deps.fanoutBudget();
589
+ }
590
+ catch {
591
+ verdict = { allow: false, reason: "the fan-out budget check could not be consulted" };
592
+ }
593
+ if (!verdict.allow) {
594
+ return runSingleWriter({
595
+ mode: "single",
596
+ reason: verdict.reason ?? "the fan-out budget check declined this run",
597
+ });
598
+ }
599
+ }
600
+ recordDecision(parsed.decision, beforeBuild);
601
+ // Width is what the partitioner chose; the session's parallel ceiling is what
602
+ // actually runs at once, so a width-8 partition runs 4-at-a-time outside ultra
603
+ // rather than being refused.
604
+ const ceiling = deps.isUltra?.() ? MAX_FANOUT_CONCURRENCY_ULTRA : MAX_FANOUT_CONCURRENCY;
605
+ log("fanout_start", { width: workstreams.length, concurrency: Math.min(workstreams.length, ceiling) });
606
+ const builders = await mapWithConcurrencyLimit(workstreams, ceiling, async (w) => {
607
+ const siblings = workstreams.filter((s) => s.name !== w.name);
608
+ // Per-builder lifecycle, exactly the review fan-out's shape: each child
609
+ // starts and finishes on its own timing, tagged so interleaved events stay
610
+ // attributed, and callers that ignore the tag still see one implement pair.
611
+ progress({ kind: "stage_start", stageId: stage.id, workstream: w.name });
612
+ const built = await runStageFn({ ...builderStage(w, siblings), tools: stage.tools }, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id, workstream: w.name }, workstreamCallerLabel(w.name)));
613
+ // A child that died says so on its own done beat (the `degraded` flag the
614
+ // review lenses already use), so no surface paints a dead builder as built.
615
+ progress({
616
+ kind: "stage_done",
617
+ stageId: stage.id,
618
+ workstream: w.name,
619
+ ...(isFailed(built) ? { degraded: "failed" } : {}),
620
+ });
621
+ return built;
622
+ });
623
+ stages.push(...builders);
624
+ const builderCredit = builders.map((r) => creditExhaustionReason(r)).find((v) => Boolean(v));
625
+ if (builderCredit) {
626
+ log("pipeline_failed", { stageId: stage.id, reason: builderCredit });
627
+ throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${builderCredit}`, stages);
628
+ }
629
+ // Claim audit (spec decision 4): claims are prefixes, so anything the fan
630
+ // changed outside all of them — minus the tree's pre-run dirt — is a named
631
+ // violation for the synthesizer to reconcile, never a silent edit.
632
+ const afterFan = await snapshotFn(deps.cwd, deps.signal);
633
+ const baseline = new Set(baselinePaths ?? []);
634
+ const changedByFan = parseChangedPaths(afterFan.status).filter((p) => !baseline.has(p));
635
+ const violations = auditClaims(changedByFan, workstreams);
636
+ if (violations.length > 0)
637
+ log("fanout_claim_violations", { paths: violations });
638
+ // Failure is honest and mirrors today's write-stage semantics (decision 9):
639
+ // the summary carries the failure, and the build loop's existing guard turns
640
+ // it into `partial` when the tree changed (siblings' work preserved, the
641
+ // failing workstream named) or the hard PipelineStageError when it did not.
642
+ const failed = builders
643
+ .map((r, i) => ({ r, name: workstreams[i].name }))
644
+ .filter((x) => isFailed(x.r));
645
+ const failureNote = failed
646
+ .map((f) => `workstream "${f.name}" failed: ${toolFailureReason(f.r) ?? f.r.errorMessage ?? f.r.stderr ?? "(no output)"}`)
647
+ .join("; ");
648
+ // The fan's own handoff: the decision, every workstream's report under its
649
+ // name and claims, and any out-of-claim edit. On a healthy fan it is the
650
+ // synthesizer's INPUT (decision 5) and the reconciled summary becomes the
651
+ // handoff; when a builder hard-failed it stands alone, because no partial
652
+ // merge is ever checked, reconciled, or handed to review.
653
+ const handoff = composeFanHandoff(parsed.decision, workstreams, builders, violations);
654
+ const finalOutput = failed.length > 0 ? handoff : await runImplementChecker({ stage, workstreams, handoff });
655
+ const summary = {
656
+ stageId: stage.id,
657
+ agent: stage.agent,
658
+ exitCode: failed.length > 0 ? 1 : 0,
659
+ finalOutput,
660
+ // Summed for honesty in logs and handoffs only: this object is NEVER pushed
661
+ // into `stages` (its children already are), so the run's usage is counted once.
662
+ usage: sumStageUsage(builders),
663
+ stderr: builders.map((r) => r.stderr).filter(Boolean).join("\n"),
664
+ toolCalls: builders.reduce((n, r) => n + r.toolCalls, 0),
665
+ toolErrors: builders.reduce((n, r) => n + r.toolErrors, 0),
666
+ ...(failed.length > 0 ? { stopReason: "error", errorMessage: failureNote } : {}),
667
+ };
668
+ log("fanout_done", { width: workstreams.length, failed: failed.map((f) => f.name), violations: violations.length });
669
+ return summary;
670
+ };
671
+ /** Emit + journal the diamond's verdict the moment it resolves (decision 2). */
672
+ function recordDecision(decision, snapshot) {
673
+ const shape = decision.workstreams?.map((w) => ({
674
+ name: w.name,
675
+ tier: w.tier,
676
+ files: w.files.length,
677
+ }));
678
+ progress({
679
+ kind: "fanout",
680
+ mode: decision.mode,
681
+ ...(decision.width ? { width: decision.width } : {}),
682
+ reason: decision.reason,
683
+ ...(shape ? { workstreams: shape } : {}),
684
+ });
685
+ log("fanout_decision", { mode: decision.mode, width: decision.width, reason: decision.reason });
686
+ writeCheckpoint(mkRecord("fanout_decision", {
687
+ snapshot,
688
+ fanout: {
689
+ mode: decision.mode,
690
+ ...(decision.width ? { width: decision.width } : {}),
691
+ reason: decision.reason,
692
+ ...(shape ? { workstreams: shape } : {}),
693
+ },
694
+ }));
695
+ }
273
696
  if (resume) {
274
697
  // Resume path: the build half already ran and its diff is in the working
275
698
  // tree (the caller's planResume verified the tree still matches the
@@ -283,7 +706,11 @@ export async function runPipeline(ticket, deps) {
283
706
  }
284
707
  }
285
708
  else {
286
- let previous;
709
+ // Mission mode seeds the first surviving build stage with work that already
710
+ // happened: the approved plan (plus the memo as labelled repo context), or
711
+ // the memo alone when only map was skipped. Absent on every interactive run,
712
+ // where the first stage's handoff is undefined exactly as before.
713
+ let previous = missionSeed(mission);
287
714
  // Baseline the working tree BEFORE the (read-only) recon stages so the no-op
288
715
  // guard below can attribute any change to the build half. A git-less / non-repo
289
716
  // cwd yields an untracked snapshot and the guard fails open (see workspace.ts).
@@ -295,8 +722,18 @@ export async function runPipeline(ticket, deps) {
295
722
  for (const stage of buildStages) {
296
723
  progress({ kind: "stage_start", stageId: stage.id });
297
724
  stageEvent(stage.id, "start");
298
- let r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
299
- stages.push(r);
725
+ // The implement stage is a DIAMOND (partition parallel builders
726
+ // checker → synthesizer → bounded fix loop); every other stage is the
727
+ // single child it always was. The diamond owns `stages` for its whole
728
+ // boundary, so nothing is pushed for it here.
729
+ let r;
730
+ if (stage.id === "implement") {
731
+ r = await runImplementDiamond(stage, previous, beforeBuild);
732
+ }
733
+ else {
734
+ r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
735
+ stages.push(r);
736
+ }
300
737
  log("stage_done", { stageId: stage.id, exitCode: r.exitCode });
301
738
  // Retry a non-final build stage ONCE when it exits cleanly but hands off an
302
739
  // incomplete fragment (length-truncated or empty). A reasoning model that
@@ -374,6 +811,10 @@ export async function runPipeline(ticket, deps) {
374
811
  // The plan finish carries its finalOutput so /go can record the plan onto the
375
812
  // work item; no other boundary streams the full stage text.
376
813
  stageEvent(stage.id, "finish", stage.id === "plan" ? { output: r.finalOutput } : undefined);
814
+ // The map brief is the partitioner's repo context on an interactive run
815
+ // (mission runs skip map and hand it the injected memo instead).
816
+ if (stage.id === "map")
817
+ mapBrief = r.finalOutput;
377
818
  previous = r.finalOutput;
378
819
  // R3-b: stop honestly if the build half alone blew the run budget.
379
820
  const over = overBudget();
@@ -9,6 +9,12 @@
9
9
  * - reviewer (business-fit lens): call `review_business_match` and treat a
10
10
  * conflict with a recorded decision as at least High.
11
11
  *
12
+ * The implement diamond adds two more roles: `orchestrator` (the read-only
13
+ * partitioner, carrying the ```partition output contract `parsePartition` reads)
14
+ * and `synthesizer` (the seam-reconciler that writes the review handoff). Its
15
+ * parallel builders reuse the `worker` body plus a per-workstream clause from
16
+ * {@link builderPersonaClause}.
17
+ *
12
18
  * The reviewer also carries the strict, parseable findings output contract so
13
19
  * the `{previous}` handoff into the fix stage parses (see `findings.ts`).
14
20
  *
@@ -17,6 +23,7 @@
17
23
  * `--append-system-prompt` temp file. Inline TS constants (no .md copy step)
18
24
  * keep the whole thing pure and unit-testable.
19
25
  */
26
+ import type { PartitionWorkstream } from "./fanout.js";
20
27
  import type { PipelineStage } from "./types.js";
21
28
  /** Persona body keyed by the agent name referenced in `stages.ts`. */
22
29
  export declare const PERSONA_BODIES: Record<string, string>;
@@ -29,10 +36,18 @@ export declare const PERSONA_BODIES: Record<string, string>;
29
36
  * measurement.
30
37
  */
31
38
  export declare const BLIND_PERSONA_BODIES: Record<string, string>;
39
+ /**
40
+ * The per-workstream clause appended to a fan-out builder's (or fixer's) persona:
41
+ * the files it owns, what its siblings own, and the shared-tree discipline. With
42
+ * no workstream (the single-writer path, including its fix turns) only the
43
+ * shared-tree rules apply, since there is nothing to pin against.
44
+ */
45
+ export declare function builderPersonaClause(workstream?: PartitionWorkstream, siblings?: PartitionWorkstream[]): string;
32
46
  /**
33
47
  * Resolve the system-prompt body for a stage. For non-review stages this is the
34
48
  * role body; for the review stage it appends the lens-specific clause and the
35
- * required findings output contract so the reviewer's output parses.
49
+ * required findings output contract so the reviewer's output parses. A stage
50
+ * carrying a `personaClause` (the fan-out builders) gets it appended last.
36
51
  *
37
52
  * `grounded` defaults true (every real /go stage). The M6 eval passes
38
53
  * `grounded: false` to select the grounding-free bodies + lens clauses for the