@yagni-app/code-staging 0.0.0 → 0.1.0-staging.1002.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 (147) hide show
  1. package/LICENSE.md +55 -0
  2. package/README.md +166 -0
  3. package/dist/branding.d.ts +25 -0
  4. package/dist/branding.js +27 -0
  5. package/dist/claudeCompat.d.ts +78 -0
  6. package/dist/claudeCompat.js +198 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.js +326 -0
  9. package/dist/config.d.ts +19 -0
  10. package/dist/config.js +28 -0
  11. package/dist/credentials.d.ts +41 -0
  12. package/dist/credentials.js +75 -0
  13. package/dist/distribution.d.ts +15 -0
  14. package/dist/distribution.js +45 -0
  15. package/dist/doctor.d.ts +94 -0
  16. package/dist/doctor.js +343 -0
  17. package/dist/extension/advisor.d.ts +84 -0
  18. package/dist/extension/advisor.js +101 -0
  19. package/dist/extension/askAdvisorTool.d.ts +83 -0
  20. package/dist/extension/askAdvisorTool.js +188 -0
  21. package/dist/extension/askYagniTool.d.ts +39 -0
  22. package/dist/extension/askYagniTool.js +63 -0
  23. package/dist/extension/bless.d.ts +45 -0
  24. package/dist/extension/bless.js +74 -0
  25. package/dist/extension/branding.d.ts +48 -0
  26. package/dist/extension/branding.js +119 -0
  27. package/dist/extension/chipEditor.d.ts +117 -0
  28. package/dist/extension/chipEditor.js +369 -0
  29. package/dist/extension/config.d.ts +112 -0
  30. package/dist/extension/config.js +108 -0
  31. package/dist/extension/costHud.d.ts +73 -0
  32. package/dist/extension/costHud.js +123 -0
  33. package/dist/extension/decisionCapture.d.ts +52 -0
  34. package/dist/extension/decisionCapture.js +66 -0
  35. package/dist/extension/decisions.d.ts +83 -0
  36. package/dist/extension/decisions.js +200 -0
  37. package/dist/extension/diagnostics.d.ts +41 -0
  38. package/dist/extension/diagnostics.js +110 -0
  39. package/dist/extension/index.d.ts +116 -0
  40. package/dist/extension/index.js +388 -0
  41. package/dist/extension/initDone.d.ts +28 -0
  42. package/dist/extension/initDone.js +66 -0
  43. package/dist/extension/initPass.d.ts +152 -0
  44. package/dist/extension/initPass.js +394 -0
  45. package/dist/extension/nextWorkTool.d.ts +51 -0
  46. package/dist/extension/nextWorkTool.js +80 -0
  47. package/dist/extension/permission.d.ts +91 -0
  48. package/dist/extension/permission.js +236 -0
  49. package/dist/extension/pipeline/activity.d.ts +37 -0
  50. package/dist/extension/pipeline/activity.js +151 -0
  51. package/dist/extension/pipeline/activityFeed.d.ts +59 -0
  52. package/dist/extension/pipeline/activityFeed.js +175 -0
  53. package/dist/extension/pipeline/budget.d.ts +48 -0
  54. package/dist/extension/pipeline/budget.js +68 -0
  55. package/dist/extension/pipeline/checkpoint.d.ts +31 -0
  56. package/dist/extension/pipeline/checkpoint.js +176 -0
  57. package/dist/extension/pipeline/eval.d.ts +168 -0
  58. package/dist/extension/pipeline/eval.js +182 -0
  59. package/dist/extension/pipeline/events.d.ts +56 -0
  60. package/dist/extension/pipeline/events.js +147 -0
  61. package/dist/extension/pipeline/findings.d.ts +42 -0
  62. package/dist/extension/pipeline/findings.js +144 -0
  63. package/dist/extension/pipeline/finish.d.ts +128 -0
  64. package/dist/extension/pipeline/finish.js +307 -0
  65. package/dist/extension/pipeline/goCommand.d.ts +128 -0
  66. package/dist/extension/pipeline/goCommand.js +972 -0
  67. package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
  68. package/dist/extension/pipeline/goCompareCommand.js +184 -0
  69. package/dist/extension/pipeline/goFlags.d.ts +40 -0
  70. package/dist/extension/pipeline/goFlags.js +46 -0
  71. package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
  72. package/dist/extension/pipeline/goStatusCommands.js +188 -0
  73. package/dist/extension/pipeline/invocation.d.ts +45 -0
  74. package/dist/extension/pipeline/invocation.js +64 -0
  75. package/dist/extension/pipeline/orchestrator.d.ts +131 -0
  76. package/dist/extension/pipeline/orchestrator.js +636 -0
  77. package/dist/extension/pipeline/personas.d.ts +44 -0
  78. package/dist/extension/pipeline/personas.js +248 -0
  79. package/dist/extension/pipeline/resilience.d.ts +85 -0
  80. package/dist/extension/pipeline/resilience.js +166 -0
  81. package/dist/extension/pipeline/resume.d.ts +18 -0
  82. package/dist/extension/pipeline/resume.js +106 -0
  83. package/dist/extension/pipeline/runRegistry.d.ts +112 -0
  84. package/dist/extension/pipeline/runRegistry.js +202 -0
  85. package/dist/extension/pipeline/runSession.d.ts +152 -0
  86. package/dist/extension/pipeline/runSession.js +167 -0
  87. package/dist/extension/pipeline/runState.d.ts +158 -0
  88. package/dist/extension/pipeline/runState.js +264 -0
  89. package/dist/extension/pipeline/runner.d.ts +79 -0
  90. package/dist/extension/pipeline/runner.js +298 -0
  91. package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
  92. package/dist/extension/pipeline/scrubSecrets.js +32 -0
  93. package/dist/extension/pipeline/stages.d.ts +51 -0
  94. package/dist/extension/pipeline/stages.js +113 -0
  95. package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
  96. package/dist/extension/pipeline/ticketResolution.js +75 -0
  97. package/dist/extension/pipeline/types.d.ts +436 -0
  98. package/dist/extension/pipeline/types.js +48 -0
  99. package/dist/extension/pipeline/verify.d.ts +277 -0
  100. package/dist/extension/pipeline/verify.js +758 -0
  101. package/dist/extension/pipeline/workspace.d.ts +32 -0
  102. package/dist/extension/pipeline/workspace.js +73 -0
  103. package/dist/extension/pipeline/worktree.d.ts +107 -0
  104. package/dist/extension/pipeline/worktree.js +200 -0
  105. package/dist/extension/provider.d.ts +11 -0
  106. package/dist/extension/provider.js +26 -0
  107. package/dist/extension/recall.d.ts +93 -0
  108. package/dist/extension/recall.js +187 -0
  109. package/dist/extension/recordContextTool.d.ts +38 -0
  110. package/dist/extension/recordContextTool.js +85 -0
  111. package/dist/extension/recordDecisionTool.d.ts +52 -0
  112. package/dist/extension/recordDecisionTool.js +102 -0
  113. package/dist/extension/repoDocs.d.ts +81 -0
  114. package/dist/extension/repoDocs.js +260 -0
  115. package/dist/extension/resilientFetch.d.ts +60 -0
  116. package/dist/extension/resilientFetch.js +133 -0
  117. package/dist/extension/reviewTool.d.ts +34 -0
  118. package/dist/extension/reviewTool.js +81 -0
  119. package/dist/extension/spool.d.ts +92 -0
  120. package/dist/extension/spool.js +266 -0
  121. package/dist/extension/stateHome.d.ts +2 -0
  122. package/dist/extension/stateHome.js +6 -0
  123. package/dist/extension/subagents.d.ts +135 -0
  124. package/dist/extension/subagents.js +281 -0
  125. package/dist/extension/surface.d.ts +10 -0
  126. package/dist/extension/surface.js +12 -0
  127. package/dist/extension/todos.d.ts +110 -0
  128. package/dist/extension/todos.js +217 -0
  129. package/dist/extension/tokenProvider.d.ts +93 -0
  130. package/dist/extension/tokenProvider.js +234 -0
  131. package/dist/launch.d.ts +76 -0
  132. package/dist/launch.js +111 -0
  133. package/dist/login.d.ts +45 -0
  134. package/dist/login.js +142 -0
  135. package/dist/logout.d.ts +14 -0
  136. package/dist/logout.js +34 -0
  137. package/dist/paths.d.ts +31 -0
  138. package/dist/paths.js +87 -0
  139. package/dist/piPackage.d.ts +33 -0
  140. package/dist/piPackage.js +71 -0
  141. package/dist/profiles.d.ts +80 -0
  142. package/dist/profiles.js +222 -0
  143. package/dist/refresh.d.ts +70 -0
  144. package/dist/refresh.js +117 -0
  145. package/dist/upgrade.d.ts +97 -0
  146. package/dist/upgrade.js +284 -0
  147. package/package.json +39 -3
@@ -0,0 +1,636 @@
1
+ /**
2
+ * The pipeline driver.
3
+ *
4
+ * Sequences `runStage` for the build stages (map → plan → implement), threading
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
7
+ * review stage out across the 3 lenses (parallel, bounded by MAX_CONCURRENCY),
8
+ * parses each lens with `parseFindings` (with ONE cheap format-recovery re-ask
9
+ * for a healthy lens that broke the findings contract — spec §3e), unions them,
10
+ * and asks the PURE `shouldStop`. If not stopped it runs the fix stage against
11
+ * ONLY the blocking findings, increments the round, and re-reviews.
12
+ *
13
+ * ALL branching is delegated to the pure `shouldStop` / `parseFindings`; the
14
+ * orchestrator is just sequencing + an injectable `runStage`, so it is
15
+ * unit-tested with a fake `runStage`. The explicit stop reason is always logged
16
+ * (spec §7.3 — no silent truncation). An early build-stage failure short-circuits
17
+ * the pipeline by throwing (the /go handler catches and reports it). In the
18
+ * review→fix loop, a user abort or a crashed review/fix child is surfaced as an
19
+ * honest `aborted` / `failed` stopReason rather than being mis-read as `clean`
20
+ * (an aborted/crashed child has empty output, which otherwise parses to zero
21
+ * findings — i.e. a false "reviewed and clean").
22
+ */
23
+ import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, EMPTY_RUN_USAGE, exceedsBudget } from "./budget.js";
24
+ import { extractFindingsBlock, hasBlockingFindings, parseFindings, shouldStop, unionFindings } from "./findings.js";
25
+ import { withResilience } from "./resilience.js";
26
+ 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";
29
+ 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";
31
+ /** Error thrown when a build stage fails; carries the partial run for the caller. */
32
+ export class PipelineStageError extends Error {
33
+ stageId;
34
+ partial;
35
+ constructor(stageId, message, partial) {
36
+ super(message);
37
+ this.name = "PipelineStageError";
38
+ this.stageId = stageId;
39
+ this.partial = partial;
40
+ }
41
+ }
42
+ const BUILD_STAGE_IDS = ["map", "plan", "implement"];
43
+ /**
44
+ * Stages whose whole value is in landed edits, so a high tool-error rate there is
45
+ * a real failure (R3-a). Read-only recon/review stages are intentionally exempt:
46
+ * a grep/read that misses is normal navigation, not a broken stage.
47
+ */
48
+ const WRITE_STAGE_IDS = ["implement", "fix"];
49
+ /**
50
+ * R3-a: true when a WRITE stage exited cleanly yet most of its tool calls errored
51
+ * — the silent partial-implement landmine, where the model narrates a tidy
52
+ * summary while its edits never landed. Conservative on purpose: only write
53
+ * stages are gated, and BOTH a meaningful sample (MIN_TOOL_CALLS_FOR_HEALTH) and
54
+ * a clear-majority error rate (above TOOL_ERROR_FAIL_RATE) must hold, so a couple
55
+ * of expected errors never fail a stage.
56
+ */
57
+ function toolFailureExceeded(r) {
58
+ if (!WRITE_STAGE_IDS.includes(r.stageId))
59
+ return false;
60
+ if (r.toolCalls < MIN_TOOL_CALLS_FOR_HEALTH)
61
+ return false;
62
+ return r.toolErrors / r.toolCalls > TOOL_ERROR_FAIL_RATE;
63
+ }
64
+ /** The honest reason string for a tool-failure-flagged stage, else undefined. */
65
+ function toolFailureReason(r) {
66
+ if (!toolFailureExceeded(r))
67
+ return undefined;
68
+ return `tool failures: ${r.toolErrors} of ${r.toolCalls} tool calls errored, so its edits likely did not land despite a clean-looking summary. Re-run /go.`;
69
+ }
70
+ /**
71
+ * A stage outcome counts as a failure on a non-zero exit, an error/abort stop, or
72
+ * (for a write stage) a tool-error rate over the threshold (R3-a) — so a stage
73
+ * that exits 0 while most of its edits failed is not trusted on its prose alone.
74
+ */
75
+ function isFailed(r) {
76
+ return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted" || toolFailureExceeded(r);
77
+ }
78
+ /**
79
+ * A non-final build stage threads its `finalOutput` forward as the NEXT stage's
80
+ * only input, so an empty or truncated handoff silently starves it — the bug: a
81
+ * cut-off plan fed to implement, which then writes nothing and bottoms out at a
82
+ * confusing `no_changes`. This converts that silent fragment into an honest,
83
+ * actionable stop the user sees BEFORE implement runs on nothing. Returns the
84
+ * stop reason when the handoff is unusable, else undefined.
85
+ *
86
+ * `length` is pi's max_tokens truncation (the literal "plan appears to be cut
87
+ * off" case) — a deterministic smoking gun. An empty body is the other half.
88
+ * We deliberately do NOT content-match a "plan shape" (unreliable, and would
89
+ * false-positive on legitimately short stages); persona hardening handles the
90
+ * interstitial-fragment flavor. The FINAL build stage (implement) is exempt: its
91
+ * real handoff is the working-tree diff, governed by the `no_changes` guard
92
+ * below, so an empty prose summary there is not a broken handoff.
93
+ */
94
+ function incompleteHandoff(r) {
95
+ if (r.stopReason === "length") {
96
+ return "its output was cut off before it finished (the model reached its length limit), so the handoff to the next stage is incomplete. Re-run /go.";
97
+ }
98
+ if (r.finalOutput.trim().length === 0) {
99
+ return "it finished without producing any output to hand to the next stage. Re-run /go.";
100
+ }
101
+ return undefined;
102
+ }
103
+ /**
104
+ * Credit-exhaustion shapes the model proxy emits when the workspace runs out of
105
+ * credits: the workspace-budget 402 body ("This workspace is out of credits."),
106
+ * the OpenAI-style `insufficient_quota` code, and the bare HTTP status. Matched
107
+ * against stderr/errorMessage only — never a stage's real output.
108
+ */
109
+ const CREDIT_EXHAUSTION_RE = /out of credits|insufficient_quota|payment required|\b402\b/;
110
+ /**
111
+ * The silent-402 landmine: when the workspace runs out of credits mid-run, the
112
+ * proxy 402s every model request, the child pi exits 0 with an EMPTY transcript,
113
+ * and the run otherwise dies as a confusing `no_changes` / empty-handoff / false
114
+ * "clean" stop with the real cause buried in stderr. Detect it and stop with an
115
+ * actionable message instead. Gated on the stage having produced no real output
116
+ * (or having failed outright) so a healthy stage whose stderr happens to contain
117
+ * a matching token can never trip it.
118
+ */
119
+ function creditExhaustionReason(r) {
120
+ if (r.finalOutput.trim().length > 0 && !isFailed(r))
121
+ return undefined;
122
+ const hay = `${r.errorMessage ?? ""}\n${r.stderr}`;
123
+ if (!CREDIT_EXHAUSTION_RE.test(hay))
124
+ return undefined;
125
+ return "the workspace is out of YAGNI credits (the model proxy returned 402). Top up credits, then re-run /go.";
126
+ }
127
+ /** Render only the blocking findings as the fix stage's `{previous}` handoff. */
128
+ function serializeBlocking(findings) {
129
+ return findings
130
+ .filter((f) => f.severity === "critical" || f.severity === "high")
131
+ .map((f) => {
132
+ const loc = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ""}` : "";
133
+ return `${f.severity} | ${loc} | ${f.message}`;
134
+ })
135
+ .join("\n");
136
+ }
137
+ /** Bounded-concurrency map preserving input order (copied from the subagent example). */
138
+ async function mapWithConcurrencyLimit(items, concurrency, fn) {
139
+ if (items.length === 0)
140
+ return [];
141
+ const limit = Math.max(1, Math.min(concurrency, items.length));
142
+ const results = new Array(items.length);
143
+ let nextIndex = 0;
144
+ const workers = new Array(limit).fill(null).map(async () => {
145
+ while (true) {
146
+ const current = nextIndex++;
147
+ if (current >= items.length)
148
+ return;
149
+ results[current] = await fn(items[current], current);
150
+ }
151
+ });
152
+ await Promise.all(workers);
153
+ return results;
154
+ }
155
+ export async function runPipeline(ticket, deps) {
156
+ const log = (event, data) => deps.logger?.(event, data);
157
+ // R1: wrap the real child runner with in-loop resilience (idle/wall timeout +
158
+ // transient-only retry) by DEFAULT, so every /go survives a transient blip
159
+ // across its up-to-8 spawns. A caller that injects its own `runStage` (the unit
160
+ // tests, a custom lane) bypasses the wrapper untouched. Per-attempt telemetry
161
+ // rides the existing logger seam, so resume + the checkpoint journal are unaffected.
162
+ const runStageFn = deps.runStage ??
163
+ withResilience(defaultRunStage, DEFAULT_RESILIENCE_POLICY, {
164
+ telemetry: (rec) => log("resilience_attempt", rec),
165
+ });
166
+ const snapshotFn = deps.snapshotWorkspace ?? defaultSnapshotWorkspace;
167
+ const runVerifyFn = deps.runVerify ?? makeRunVerify();
168
+ const progress = (p) => deps.onProgress?.(p);
169
+ // Count of record_decision tool calls that completed without error across the
170
+ // whole run, tapped off the NDJSON stream (spec §3c: the FINISH trailer's
171
+ // YAGNI-Decisions line). COUNT only — decision ids are not observable without
172
+ // widening JsonEvent.
173
+ let decisionsRecorded = 0;
174
+ // Per-stage runner deps that tag each tapped event with the emitting stage
175
+ // (and, for the parallel review fan-out, that lens + round). Built fresh per
176
+ // call so every review lens captures its OWN tag — interleaved parallel events
177
+ // stay attributed to review/<lens>. The tap is ALWAYS installed (the
178
+ // decisionsRecorded count must not depend on a caller wiring onEvent);
179
+ // forwarding to the caller's onEvent stays optional.
180
+ const stageDepsFor = (tag) => {
181
+ const onEvent = deps.onEvent;
182
+ return {
183
+ cwd: deps.cwd,
184
+ signal: deps.signal,
185
+ ...(deps.childEnv ? { env: deps.childEnv } : {}),
186
+ onEvent: (ev) => {
187
+ if (ev.type === "tool_execution_end" && ev.toolName === "record_decision" && !ev.isError) {
188
+ decisionsRecorded += 1;
189
+ }
190
+ onEvent?.(ev, tag);
191
+ },
192
+ };
193
+ };
194
+ const blockingCount = (findings) => findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
195
+ // Work-page run-tracking hook: fires at each stage boundary, independent of the
196
+ // terminal live feed's structured `progress`. Optional + fail-soft via onStage.
197
+ const stageEvent = (stage, phase, extra) => deps.onStage?.({ stage, phase, ...extra });
198
+ // The text the stages plan against: the resolved ticket brief (verbatim title +
199
+ // body) when /go could fetch it, else the raw ticket reference (honest blind).
200
+ const promptTicket = deps.ticketBrief ?? ticket;
201
+ // M6: default grounded + the full stage list; the blind eval lane overrides both.
202
+ const grounded = deps.grounded !== false;
203
+ const all = deps.stages ?? selectStages("full");
204
+ // R3-b: aggregate usage ceiling, checked at each stage boundary.
205
+ const budget = deps.budget ?? DEFAULT_RUN_BUDGET;
206
+ const buildStages = all.filter((s) => BUILD_STAGE_IDS.includes(s.id));
207
+ // The last build stage's handoff is the working-tree diff (checked by the
208
+ // no_changes guard), not its prose; only the earlier stages thread text forward.
209
+ const finalBuildId = buildStages[buildStages.length - 1]?.id;
210
+ // Derive review + fix from `all` (not PIPELINE_V1) so the blind eval lane's
211
+ // grounding-stripped tool lists are honored on these stages too.
212
+ const review = all.find((s) => s.id === "review") ?? reviewStage();
213
+ const fix = all.find((s) => s.id === "fix");
214
+ if (!fix)
215
+ throw new Error("PIPELINE_V1 is missing the fix stage");
216
+ // Fail-soft checkpoint helpers (no-op when no store is injected). A checkpoint
217
+ // write must NEVER break the pipeline, so even a misbehaving store is caught.
218
+ const meta = deps.checkpointMeta;
219
+ const writeCheckpoint = (rec) => {
220
+ try {
221
+ deps.checkpoint?.record(rec);
222
+ }
223
+ catch {
224
+ /* fail-soft */
225
+ }
226
+ };
227
+ const mkRecord = (kind, extra) => ({
228
+ v: 1,
229
+ kind,
230
+ sessionKey: meta?.sessionKey ?? "",
231
+ ts: Date.now(),
232
+ ticket,
233
+ ...(meta?.runId ? { runId: meta.runId } : {}),
234
+ ...(meta?.workItemId ? { workItemId: meta.workItemId } : {}),
235
+ ...(meta?.repo ? { repo: meta.repo } : {}),
236
+ ...(meta?.branch ? { branch: meta.branch } : {}),
237
+ ...extra,
238
+ });
239
+ const resume = deps.resumeFrom?.mode === "resume_loop" ? deps.resumeFrom : undefined;
240
+ const stages = [];
241
+ // Seed the loop state. On resume we re-enter the review→fix loop directly,
242
+ // carrying the journal's prior rounds for an honest final count; on a fresh run
243
+ // the build half below fills `reviewInput` from the implement output.
244
+ const rounds = resume ? [...resume.priorRounds] : [];
245
+ let round = resume ? resume.round : 1;
246
+ let reviewInput = resume ? resume.reviewInput : "";
247
+ let stopReason = "clean";
248
+ // P4: honest note carried out when the verify gate could not give a verdict on
249
+ // the final round (no command / unrunnable), so /go never claims a build it did
250
+ // not actually check. Cleared whenever a later round produces a real verdict.
251
+ let verifyNote;
252
+ // The FINAL round's verify command label(s) when it RAN — feeds the FINISH
253
+ // trailer's `YAGNI-Verify: passed (<cmds>)` honestly (spec §3c). Cleared when
254
+ // a later round's verify is skipped (the final gate did not actually run).
255
+ let verifyCommand;
256
+ // The tree's pre-run dirt (paths already changed before the build half), so the
257
+ // verify gate scopes to THIS run's own diff. Set on the fresh path once the
258
+ // baseline snapshot is taken; absent on resume (the build already ran), where the
259
+ // gate falls back to the whole-tree scope.
260
+ let baselinePaths;
261
+ const priorUsage = resume?.priorUsage ?? EMPTY_RUN_USAGE;
262
+ const currentUsage = () => addRunUsage(priorUsage, aggregateRunUsage(stages, rounds));
263
+ const overBudget = () => exceedsBudget(currentUsage(), budget);
264
+ if (resume) {
265
+ // Resume path: the build half already ran and its diff is in the working
266
+ // tree (the caller's planResume verified the tree still matches the
267
+ // checkpoint), so we skip map→plan→implement and the no-op guard entirely.
268
+ log("pipeline_resume", { round, priorRounds: resume.priorRounds.length });
269
+ const over = overBudget();
270
+ if (over) {
271
+ log("pipeline_stop", { reason: "budget_exceeded", where: "resume", detail: over });
272
+ progress({ kind: "done", stopReason: "budget_exceeded" });
273
+ return { stages, rounds, stopReason: "budget_exceeded", findings: rounds[rounds.length - 1]?.findings ?? [], decisionsRecorded };
274
+ }
275
+ }
276
+ else {
277
+ let previous;
278
+ // Baseline the working tree BEFORE the (read-only) recon stages so the no-op
279
+ // guard below can attribute any change to the build half. A git-less / non-repo
280
+ // cwd yields an untracked snapshot and the guard fails open (see workspace.ts).
281
+ const beforeBuild = await snapshotFn(deps.cwd, deps.signal);
282
+ writeCheckpoint(mkRecord("run_start", { snapshot: beforeBuild }));
283
+ // Record the pre-run dirt so the verify gate scopes to this run's OWN diff.
284
+ baselinePaths = parseChangedPaths(beforeBuild.status);
285
+ // Sequential build half — stop at the first failure (mirrors the chain).
286
+ for (const stage of buildStages) {
287
+ progress({ kind: "stage_start", stageId: stage.id });
288
+ stageEvent(stage.id, "start");
289
+ let r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
290
+ stages.push(r);
291
+ log("stage_done", { stageId: stage.id, exitCode: r.exitCode });
292
+ // Retry a non-final build stage ONCE when it exits cleanly but hands off an
293
+ // incomplete fragment (length-truncated or empty). A reasoning model that
294
+ // over-explored and ran out of budget — e.g. a plan ending mid-"let me verify
295
+ // one more thing" — often converges on a fresh attempt; a task that
296
+ // systematically won't converge still stops honestly after the retry below.
297
+ // The final build stage (implement) is exempt: its handoff is the diff, not
298
+ // prose, so `incompleteHandoff` never flags it.
299
+ if (!isFailed(r) && stage.id !== finalBuildId && incompleteHandoff(r)) {
300
+ log("stage_retry", { stageId: stage.id, attempt: 2, reason: "incomplete_handoff", stopReason: r.stopReason });
301
+ progress({ kind: "stage_start", stageId: stage.id });
302
+ stageEvent(stage.id, "start");
303
+ r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
304
+ stages.push(r);
305
+ log("stage_done", { stageId: stage.id, exitCode: r.exitCode, attempt: 2 });
306
+ }
307
+ const credit = creditExhaustionReason(r);
308
+ if (isFailed(r)) {
309
+ const why = credit ?? toolFailureReason(r) ?? (r.errorMessage || r.stderr || r.finalOutput || "(no output)");
310
+ // A WRITE stage (implement/fix) KILLED mid-work — a timeout or transient blip
311
+ // that aborted/errored the child once it had begun editing — leaves a real
312
+ // partial diff in the working tree. R1 deliberately does NOT retry such a stage
313
+ // (double-apply guard), so don't discard the work as a bare failure: stop with
314
+ // an honest `partial` outcome that points the user at their tree. Excluded on
315
+ // purpose: a clean exit-0 stage failed only by the R3-a tool-health gate is the
316
+ // silent partial-implement landmine (edits never landed), NOT real preservable
317
+ // work — it keeps the hard-fail. Read-only stages, write stages that changed
318
+ // nothing, and a user abort also keep the honest hard-fail below. Conservative:
319
+ // we never feed a possibly-truncated diff into the review loop.
320
+ const killedMidWork = r.exitCode !== 0 || r.stopReason === "aborted" || r.stopReason === "error";
321
+ if (killedMidWork &&
322
+ !deps.signal?.aborted &&
323
+ WRITE_STAGE_IDS.includes(stage.id)) {
324
+ const afterFail = await snapshotFn(deps.cwd, deps.signal);
325
+ if (workspaceChanged(beforeBuild, afterFail)) {
326
+ log("pipeline_partial", { stageId: stage.id, reason: why });
327
+ progress({ kind: "done", stopReason: "partial" });
328
+ return { stages, rounds, stopReason: "partial", findings: [], decisionsRecorded };
329
+ }
330
+ }
331
+ log("pipeline_failed", { stageId: stage.id, reason: why });
332
+ // No structured progress on a build failure: the union has no failed
333
+ // variant, and the /go handler reports the thrown error + clears the feed.
334
+ throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${why}`, stages);
335
+ }
336
+ // A 402-starved stage exits 0 with an empty transcript (not `isFailed`), so
337
+ // it would otherwise fall through to the generic empty-handoff / no_changes
338
+ // stops with the real cause buried. Stop with the actionable message.
339
+ if (credit) {
340
+ log("pipeline_failed", { stageId: stage.id, reason: credit });
341
+ throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${credit}`, stages);
342
+ }
343
+ // Honest handoff-completeness guard: a build stage can exit 0 yet hand the
344
+ // next stage an empty / truncated fragment. Stop honestly here rather than
345
+ // letting implement run on garbage and bottom out at `no_changes`.
346
+ if (stage.id !== finalBuildId) {
347
+ const problem = incompleteHandoff(r);
348
+ if (problem && stage.id === "plan") {
349
+ // The plan's consumer — the worker — is explicitly built to implement from a
350
+ // partial or cut-off plan (WORKER_BODY: "if the plan is missing, partial, or
351
+ // appears cut off … implement the ticket directly"). GLM-5.2 reliably
352
+ // over-reasons this stage into a length-truncation; stopping here throws away
353
+ // real partial-plan work and never gives the worker its fallback. So hand the
354
+ // partial plan forward and let the worker fill the gaps from the ticket.
355
+ log("plan_incomplete_proceeding", { stageId: stage.id, reason: problem });
356
+ }
357
+ else if (problem) {
358
+ // Other build stages (map) have no such robust downstream consumer, so an
359
+ // incomplete handoff still stops honestly here.
360
+ log("pipeline_incomplete_handoff", { stageId: stage.id, reason: problem });
361
+ throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${problem}`, stages);
362
+ }
363
+ }
364
+ progress({ kind: "stage_done", stageId: stage.id });
365
+ // The plan finish carries its finalOutput so /go can record the plan onto the
366
+ // work item; no other boundary streams the full stage text.
367
+ stageEvent(stage.id, "finish", stage.id === "plan" ? { output: r.finalOutput } : undefined);
368
+ previous = r.finalOutput;
369
+ // R3-b: stop honestly if the build half alone blew the run budget.
370
+ const over = overBudget();
371
+ if (over) {
372
+ log("pipeline_stop", { reason: "budget_exceeded", where: stage.id, detail: over });
373
+ progress({ kind: "done", stopReason: "budget_exceeded" });
374
+ return { stages, rounds, stopReason: "budget_exceeded", findings: [], decisionsRecorded };
375
+ }
376
+ }
377
+ // No-op guard (spec §7.3 — no silent truncation): the review half is
378
+ // diff-driven, so an implement stage that exits 0 having changed nothing would
379
+ // sail through as `clean` (reviewers `git diff`, see nothing, find nothing).
380
+ // Stop HONESTLY with `no_changes` instead of reporting a false "reviewed and
381
+ // clean". Skipped (fails open) when the tree state is unknowable — see
382
+ // workspaceChanged. A user abort mid-build is the more truthful reason.
383
+ if (deps.signal?.aborted) {
384
+ log("pipeline_aborted", { phase: "build" });
385
+ progress({ kind: "done", stopReason: "aborted" });
386
+ return { stages, rounds, stopReason: "aborted", findings: [], decisionsRecorded };
387
+ }
388
+ const afterBuild = await snapshotFn(deps.cwd, deps.signal);
389
+ if (!workspaceChanged(beforeBuild, afterBuild)) {
390
+ log("pipeline_stop", { reason: "no_changes" });
391
+ progress({ kind: "done", stopReason: "no_changes" });
392
+ return { stages, rounds, stopReason: "no_changes", findings: [], decisionsRecorded };
393
+ }
394
+ reviewInput = previous ?? "";
395
+ // Build half complete + the tree changed: checkpoint the implement output as
396
+ // the round-1 review handoff so a crash in the (expensive) review loop can
397
+ // resume here instead of re-running map→plan→implement.
398
+ writeCheckpoint(mkRecord("build_done", { snapshot: afterBuild, reviewInput, usage: currentUsage() }));
399
+ }
400
+ while (true) {
401
+ progress({ kind: "stage_start", stageId: "review", round });
402
+ stageEvent("review", "start", { round });
403
+ // P4 verify runs CONCURRENTLY with the lens fan-out: it is deterministic,
404
+ // independent of lens output, and the lenses are read-only — the tree it
405
+ // checks is exactly the tree they review. Run serially it added real wall
406
+ // time per round (a cold toolchain run costs minutes) that the slower lens
407
+ // fan-out absorbs entirely. The immediate `.catch` parks a rejection until
408
+ // the await below (which rethrows it at the same point the serial call
409
+ // threw), so a seam that rejects mid-fan-out can't crash the process as an
410
+ // unhandled rejection.
411
+ let verifyError;
412
+ const verifyPromise = deps.signal?.aborted
413
+ ? null
414
+ : runVerifyFn(deps.cwd, deps.signal, baselinePaths).catch((err) => {
415
+ verifyError = err ?? new Error("verify failed");
416
+ return null;
417
+ });
418
+ const lensResults = await mapWithConcurrencyLimit(REVIEW_LENSES, MAX_CONCURRENCY, async (lens) => {
419
+ // Per-lens lifecycle: each lens child is its own agent on the desktop, so it
420
+ // starts and finishes on its OWN timing rather than the whole fan-out's.
421
+ // The terminal panel ignores lens-tagged signals and paints one review row.
422
+ progress({ kind: "stage_start", stageId: "review", lens, round });
423
+ const lensResult = await runStageFn({ ...review, lens }, { ticket: promptTicket, previous: reviewInput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }));
424
+ progress({ kind: "stage_done", stageId: "review", lens, round });
425
+ return lensResult;
426
+ });
427
+ // Credit exhaustion mid-review: a 402-starved lens exits 0 with EMPTY output,
428
+ // which parses to zero findings — the loop would stop a false "clean" while
429
+ // every request was failing. Checked on ALL lens results (not just failed
430
+ // ones) for exactly that reason.
431
+ const lensCredit = lensResults.map((r) => creditExhaustionReason(r)).find((v) => Boolean(v));
432
+ if (lensCredit) {
433
+ stopReason = "failed";
434
+ log("pipeline_failed", { phase: "review", round, reason: lensCredit });
435
+ break;
436
+ }
437
+ // R2 partial-failure tolerance: split the lens outcomes into healthy vs
438
+ // failed. A single transient lens failure must NOT discard the lenses that
439
+ // succeeded, so we parse findings only from the healthy lenses, proceed on
440
+ // those survivors, and record the skipped lenses as `degradedLenses`. The
441
+ // round fails only when EVERY lens failed or a REQUIRED lens is the one that
442
+ // failed (see the health check below). A failed lens has empty/garbage
443
+ // output, so it is never parsed for findings.
444
+ const failedLenses = [];
445
+ const unparseableLenses = [];
446
+ const perLens = [];
447
+ for (let i = 0; i < lensResults.length; i++) {
448
+ const r = lensResults[i];
449
+ const lens = REVIEW_LENSES[i];
450
+ if (isFailed(r)) {
451
+ failedLenses.push(lens);
452
+ progress({ kind: "stage_done", stageId: "review", lens, round, findings: 0, blocking: 0, degraded: "failed" });
453
+ continue;
454
+ }
455
+ let parsed = parseFindings(r.finalOutput, lens);
456
+ // Visible signal (not silent): a non-empty reviewer output that parses to
457
+ // zero findings is a likely format miss, not a guaranteed clean review.
458
+ if (parsed.length === 0 && r.finalOutput.trim())
459
+ log("review_parse_warning", { lens });
460
+ // §3e findings-contract re-ask: a HEALTHY lens whose non-empty prose has
461
+ // NO ```findings fence broke the output contract (an explicit empty fence
462
+ // is a real clean review and is left alone). Re-ask that lens ONCE on the
463
+ // cheap efficient tier — a pure format-recovery pass whose `{previous}` is
464
+ // the lens's own output — and take whatever it re-emits. Still nothing →
465
+ // proceed (today's behavior) but record the lens as `<lens> (unparseable)`
466
+ // in degradedLenses so a zero-findings round built on an unreadable review
467
+ // is never silent.
468
+ if (parsed.length === 0 &&
469
+ r.finalOutput.trim() &&
470
+ extractFindingsBlock(r.finalOutput) == null &&
471
+ !deps.signal?.aborted) {
472
+ const reask = await runStageFn(reaskStage(review), { ticket: promptTicket, previous: r.finalOutput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }));
473
+ if (!isFailed(reask))
474
+ parsed = parseFindings(reask.finalOutput, lens);
475
+ log("review_reask", { lens, findings: parsed.length });
476
+ if (parsed.length === 0)
477
+ unparseableLenses.push(lens);
478
+ }
479
+ // The count exists only now: findings are parsed after the children return,
480
+ // so a lens row gains its count a beat after it goes ✔. Honest, not eager.
481
+ progress({
482
+ kind: "stage_done",
483
+ stageId: "review",
484
+ lens,
485
+ round,
486
+ findings: parsed.length,
487
+ blocking: blockingCount(parsed),
488
+ ...(unparseableLenses.includes(lens) ? { degraded: "unparseable" } : {}),
489
+ });
490
+ perLens.push(parsed);
491
+ }
492
+ // P4: the deterministic verify gate runs every round (so a fix that breaks the
493
+ // build is caught next round) and folds any failure in as critical findings
494
+ // BEFORE shouldStop, so the loop cannot stop `clean` while the build is broken
495
+ // and the fix stage receives the real errors via serializeBlocking. Started
496
+ // above, concurrent with the lens fan-out; awaited (and a seam rejection
497
+ // rethrown) here. Skipped if the run was already aborting; fail-open (no
498
+ // findings, honest note) otherwise.
499
+ let verifyFindings = [];
500
+ if (verifyPromise) {
501
+ const verify = await verifyPromise;
502
+ if (verify === null)
503
+ throw verifyError;
504
+ verifyFindings = verify.findings;
505
+ if (verify.ran) {
506
+ // A real verdict supersedes any earlier skip note, but a PARTIAL note
507
+ // from the ran gate (spec §3d: "tests could not run", one package's
508
+ // check unrunnable) survives honestly — absent reason clears it.
509
+ verifyNote = verify.reason;
510
+ verifyCommand = verify.command;
511
+ log("verify_done", { round, ok: verify.ok, command: verify.command, findings: verify.findings.length });
512
+ }
513
+ else if (verify.reason) {
514
+ verifyNote = verify.reason;
515
+ verifyCommand = undefined; // the FINAL round's gate did not run
516
+ log("verify_skipped", { round, reason: verify.reason });
517
+ }
518
+ }
519
+ const findings = unionFindings([...perLens, verifyFindings]);
520
+ // Two degradation flavors, one honest list (§3e): a failed lens was skipped
521
+ // outright; an unparseable lens proceeded as zero findings after the re-ask.
522
+ const degraded = [...failedLenses, ...unparseableLenses.map((l) => `${l} (unparseable)`)];
523
+ const degradedLenses = degraded.length > 0 ? degraded : undefined;
524
+ rounds.push({ round, lensResults, findings, ...(degradedLenses ? { degradedLenses } : {}) });
525
+ log("review_round", {
526
+ round,
527
+ findings: findings.length,
528
+ blocking: hasBlockingFindings(findings),
529
+ ...(degradedLenses ? { degradedLenses } : {}),
530
+ });
531
+ progress({ kind: "findings", round, total: findings.length, blocking: blockingCount(findings) });
532
+ stageEvent("review", "finish", { round, findings: findings.length });
533
+ // Honest health check BEFORE trusting the verdict: an aborted or crashed
534
+ // review must NOT be read as a clean review (empty output → [] findings →
535
+ // would otherwise stop 'clean'). Mirrors the build half's isFailed guard.
536
+ // The unhealthy paths intentionally skip the review `stage_done` so the feed
537
+ // can mark the in-flight stage failed off the closing `done` signal.
538
+ if (deps.signal?.aborted) {
539
+ stopReason = "aborted";
540
+ log("pipeline_aborted", { phase: "review", round });
541
+ break;
542
+ }
543
+ // Fail the round ONLY when every lens failed, or a REQUIRED lens (the one
544
+ // the loop cannot proceed without, default: correctness) is the one that
545
+ // failed. Otherwise the surviving lenses carry the round and `degradedLenses`
546
+ // already records the skip honestly.
547
+ const requiredFailed = failedLenses.filter((l) => REQUIRED_LENSES.includes(l));
548
+ if (failedLenses.length === REVIEW_LENSES.length || requiredFailed.length > 0) {
549
+ stopReason = "failed";
550
+ log("pipeline_failed", { phase: "review", round, failedLenses, requiredFailed });
551
+ break;
552
+ }
553
+ if (degradedLenses)
554
+ log("review_degraded", { round, degradedLenses });
555
+ progress({ kind: "stage_done", stageId: "review", round });
556
+ // R3-b: stop honestly if the review fan-out pushed the run over budget, before
557
+ // spending another fix + round. The round's findings are already recorded.
558
+ const overAfterReview = overBudget();
559
+ if (overAfterReview) {
560
+ stopReason = "budget_exceeded";
561
+ log("pipeline_stop", { reason: "budget_exceeded", phase: "review", round, detail: overAfterReview });
562
+ break;
563
+ }
564
+ const decision = shouldStop(findings, round);
565
+ if (decision.stop && decision.reason) {
566
+ stopReason = decision.reason;
567
+ break;
568
+ }
569
+ progress({ kind: "stage_start", stageId: "fix", round });
570
+ stageEvent("fix", "start", { round });
571
+ const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }));
572
+ stages.push(fixResult);
573
+ log("stage_done", { stageId: "fix", exitCode: fixResult.exitCode });
574
+ if (deps.signal?.aborted) {
575
+ stopReason = "aborted";
576
+ log("pipeline_aborted", { phase: "fix", round });
577
+ break;
578
+ }
579
+ // Same silent-402 guard as the lenses: a credit-starved fix exits 0 empty and
580
+ // would hand the next round an empty reviewInput instead of stopping honestly.
581
+ const fixCredit = creditExhaustionReason(fixResult);
582
+ if (fixCredit) {
583
+ stopReason = "failed";
584
+ log("pipeline_failed", { phase: "fix", round, reason: fixCredit });
585
+ break;
586
+ }
587
+ if (isFailed(fixResult)) {
588
+ const why = toolFailureReason(fixResult) ?? (fixResult.errorMessage || fixResult.stderr || "(no output)");
589
+ // Symmetric with the build-half guard: a fix stage KILLED mid-work (timeout /
590
+ // transient abort/error) leaves the run's reviewed diff (implement + any prior
591
+ // accepted rounds) plus a partial fix in the tree. By here the build half has
592
+ // already changed the tree (we passed the no_changes guard), so there is always
593
+ // preservable work — report `partial`, not a bare `failed`. A clean exit-0 fix
594
+ // failed only by the R3-a tool-health gate is the silent landmine (edits never
595
+ // landed) and keeps the hard `failed`.
596
+ const fixKilledMidWork = fixResult.exitCode !== 0 || fixResult.stopReason === "aborted" || fixResult.stopReason === "error";
597
+ if (fixKilledMidWork) {
598
+ stopReason = "partial";
599
+ log("pipeline_partial", { phase: "fix", round, reason: why });
600
+ }
601
+ else {
602
+ stopReason = "failed";
603
+ log("pipeline_failed", { phase: "fix", round, reason: why });
604
+ }
605
+ break;
606
+ }
607
+ progress({ kind: "stage_done", stageId: "fix", round });
608
+ stageEvent("fix", "finish", { round });
609
+ // R3-b: stop honestly if this round crossed the budget, before the next round.
610
+ const overAfterFix = overBudget();
611
+ if (overAfterFix) {
612
+ stopReason = "budget_exceeded";
613
+ log("pipeline_stop", { reason: "budget_exceeded", phase: "fix", round, detail: overAfterFix });
614
+ break;
615
+ }
616
+ // This whole round (review + fix) completed and the loop will continue, so a
617
+ // crash now can resume at the NEXT round against this fix's output. Snapshot
618
+ // the post-fix tree as the resume consistency key.
619
+ const afterFix = await snapshotFn(deps.cwd, deps.signal);
620
+ writeCheckpoint(mkRecord("round_done", { round, reviewInput: fixResult.finalOutput, findings, snapshot: afterFix, usage: currentUsage() }));
621
+ reviewInput = fixResult.finalOutput;
622
+ round += 1;
623
+ }
624
+ log("pipeline_stop", { reason: stopReason, round });
625
+ progress({ kind: "done", stopReason });
626
+ return {
627
+ stages,
628
+ rounds,
629
+ stopReason,
630
+ findings: rounds[rounds.length - 1]?.findings ?? [],
631
+ decisionsRecorded,
632
+ ...(verifyNote ? { verifyNote } : {}),
633
+ ...(verifyCommand ? { verifyCommand } : {}),
634
+ };
635
+ }
636
+ //# sourceMappingURL=orchestrator.js.map