@pmelab/gtd 18.0.0 → 19.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,109 +1,129 @@
1
1
  import {
2
- answered,
3
- changes,
4
2
  changesSince,
3
+ type Change,
5
4
  glob,
6
5
  head,
7
- judge,
8
- numeric,
9
6
  read,
10
7
  refuse,
11
8
  removeScript,
12
9
  run,
13
10
  scope,
14
- sectionBodies,
15
- vars,
16
- wrote,
17
- type JudgeQuestion,
18
11
  } from "../flows/index.js"
19
- import { packageDiff } from "./diff.js"
20
12
  import { healthy } from "./health.js"
21
- import { ARCHITECTURE, build, fixSpec, fixSuite, reviewPackage, SPEC_FEEDBACK } from "./steps.js"
22
- import * as t from "./text.js"
13
+ import { freezeScenarios, guarded, type FrozenScenarios } from "./scenarios.js"
14
+ import { build, fixSuite } from "./steps.js"
23
15
 
24
- const MAX_SECTIONS = 8
25
- const DIFF_KEY = "diff"
16
+ /** Package 0: the e2e scenarios, written red. */
17
+ export const SCENARIO_PACKAGE = ".gtd/packages/00-e2e-scenarios.md"
26
18
 
27
- const sectionQuestion = (id: string, title: string): JudgeQuestion => ({
28
- id,
29
- primitive: "noul",
30
- instructions: `Is the requirement "${title}" — the "${id}" evidence — already fully satisfied by the code in the "${DIFF_KEY}" evidence?`,
31
- criteria:
32
- "Judge from the requirement text and the diff evidence alone. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing.",
33
- })
19
+ export interface DeclaredTest {
20
+ readonly level: "unit" | "e2e"
21
+ readonly path: string
22
+ }
23
+
24
+ const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/
25
+
26
+ /** The `## Tests` entries of a package file; malformed lines are ignored. */
27
+ export const declaredTests = (packageText: string): readonly DeclaredTest[] => {
28
+ const found: DeclaredTest[] = []
29
+ let inTests = false
30
+ for (const line of packageText.split("\n")) {
31
+ if (/^##\s/.test(line)) inTests = /^##\s+Tests\s*$/.test(line)
32
+ else if (inTests) {
33
+ const match = TEST_LINE.exec(line)
34
+ if (match) found.push({ level: match[1] as DeclaredTest["level"], path: match[2]! })
35
+ }
36
+ }
37
+ return found
38
+ }
34
39
 
35
40
  /**
36
- * Review one freshly built package against its spec: a pre-judge per
37
- * section, then an agent review of the sections it did not confidently
38
- * clear. `since` is the commit the package's own build started from — the
39
- * pre-judge's evidence is a diff over exactly that range, never an earlier
40
- * package's commits. Resolves `true` when approved.
41
+ * Refuse a build or fix turn unless every test `pkg` declares exists and is in the
42
+ * diff since `since`. The package text is read as of `since`, so a builder
43
+ * editing its own `## Tests` cannot dodge the guard; with `.gtd/SATISFIED.md`
44
+ * written, existing in the tree suffices.
41
45
  */
42
- export const specReview = async (pkg: string, since: string): Promise<boolean> => {
43
- const found = sectionBodies(read(pkg) ?? "")
44
- const titles = found.map((section) => section.title)
45
- const judged = titles.length > 0 && titles.length <= MAX_SECTIONS
46
- const evidence: Record<string, string> = {}
47
- const questions: JudgeQuestion[] = []
48
- if (judged) {
49
- found.forEach(({ title, body }, i) => {
50
- evidence[`section-${i + 1}`] = body
51
- questions.push(sectionQuestion(`section-${i + 1}`, title))
52
- })
53
- // The diff's own even share of the budget: one key per section plus
54
- // `diff` itself, so this cap equals what `budgeted` gives it below.
55
- const capBytes = Math.floor(numeric(vars.judgeBudgetBytes, 32768) / (found.length + 1))
56
- evidence[DIFF_KEY] = packageDiff(changesSince(since), capBytes)
57
- }
58
- const { answers, truncated } = await judge("spec.pre", {
59
- questions,
60
- evidence,
61
- message: t.packagesItemSpecPreMessage(),
62
- label: "Judging spec coverage",
63
- })
64
- // A section is cleared only by a confident yes on evidence that was not
65
- // cut — its own body, or the diff every question is judged against.
66
- const clearMinP = numeric(vars.specPreJudge, Infinity)
67
- const failing = titles.filter((_, i) => {
68
- const id = `section-${i + 1}`
69
- return (
70
- !judged ||
71
- truncated.includes(id) ||
72
- truncated.includes(DIFF_KEY) ||
73
- !answered(answers[id], "yes", clearMinP)
46
+ export const requireDeclaredTests = (pkg: string, since: string): void => {
47
+ const range = changesSince(since)
48
+ const text = range.get(pkg)?.before ?? read(pkg) ?? ""
49
+ const satisfied = read(".gtd/SATISFIED.md") !== undefined
50
+ const missing = declaredTests(text)
51
+ .map((test) => test.path)
52
+ .filter((path) =>
53
+ satisfied
54
+ ? read(path) === undefined
55
+ : range.get(path) === undefined ||
56
+ range.get(path)?.status === "deleted" ||
57
+ read(path) === undefined,
74
58
  )
75
- })
76
- if (titles.length > 0 && failing.length === 0) return true
77
- await reviewPackage(pkg, failing)
78
- if (wrote(SPEC_FEEDBACK)) return false
79
- if (changes(SPEC_FEEDBACK).length > 0 || changes().length === 0) return true
80
- return refuse(
81
- "gtd land: no declared pattern matches the pending changes — write .gtd/SPEC_FEEDBACK.md to request changes, or change nothing to approve",
59
+ if (missing.length === 0) return
60
+ refuse(
61
+ `gtd land: declared-tests: ${pkg} declares tests that are ${satisfied ? "missing from the tree" : "not in the package's diff"} — write them first:\n${missing.map((path) => ` - ${path}`).join("\n")}`,
82
62
  )
83
63
  }
84
64
 
85
- /** Build `pkg`, keep the suite green, review it against its spec, and close it out, which removes it. */
86
- export const packageItem = async (pkg: string): Promise<void> => {
87
- const since = head()
88
- await build(pkg)
89
- for (;;) {
90
- await healthy(fixSuite)
91
- if (await specReview(pkg, since)) break
92
- await fixSpec(pkg)
65
+ export interface PackageRange {
66
+ readonly pkg: string
67
+ readonly from: string
68
+ readonly to: string
69
+ }
70
+
71
+ export interface BuiltPlan {
72
+ readonly ranges: readonly PackageRange[]
73
+ /** Paths package 0 added / modified outside `.gtd/`. */
74
+ readonly scenarios: { readonly added: readonly string[]; readonly changed: readonly string[] }
75
+ /** The wording snapshot after this run, to guard the tail and later laps. */
76
+ readonly frozen: FrozenScenarios | undefined
77
+ }
78
+
79
+ /** Build `pkg`, check its declared tests, keep the fast suite green, and close it out, which removes it. */
80
+ export const packageItem = async (pkg: string, frozen?: FrozenScenarios): Promise<PackageRange> => {
81
+ const from = head()
82
+ // Rechecked after every fix too: a fix deleting a red declared test would
83
+ // otherwise green the suite. Before the wording gate, so it refuses the turn.
84
+ const declared = (turn: () => Promise<void>) => async () => {
85
+ await turn()
86
+ requireDeclaredTests(pkg, from)
93
87
  }
94
- // The spent technical plan goes with the first package built from it.
95
- await run("closing", removeScript([pkg, SPEC_FEEDBACK, ".gtd/SATISFIED.md", ARCHITECTURE]), {
88
+ await guarded(
89
+ frozen,
90
+ declared(() => build(pkg)),
91
+ )()
92
+ await healthy(guarded(frozen, declared(fixSuite)), { suite: "fast" })
93
+ await run("closing", removeScript([pkg, ".gtd/SATISFIED.md"]), {
96
94
  label: "Closing out the package",
97
95
  })
96
+ return { pkg, from, to: head() }
98
97
  }
99
98
 
100
99
  /** The first queued package — the one a package step works on. */
101
100
  export const nextPackage = (): string | undefined => [...glob(".gtd/packages/*.md")].sort()[0]
102
101
 
102
+ const outsideGtd = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
103
+
103
104
  /** Build every package file under `.gtd/packages/`, in name order. */
104
- export const packages = (): Promise<void> =>
105
+ export const packages = (carried?: FrozenScenarios): Promise<BuiltPlan> =>
105
106
  scope("packages", async () => {
107
+ let frozen = carried
108
+ const ranges: PackageRange[] = []
109
+ // Captured right after package 0: a later diff to the tree would also hold what 01…N touch.
110
+ let touched: readonly Change[] = []
106
111
  for (let pkg = nextPackage(); pkg !== undefined; pkg = nextPackage()) {
107
- await scope("item", () => packageItem(pkg))
112
+ const range = await scope("item", () =>
113
+ packageItem(pkg, pkg === SCENARIO_PACKAGE ? undefined : frozen),
114
+ )
115
+ ranges.push(range)
116
+ if (pkg === SCENARIO_PACKAGE) {
117
+ touched = changesSince(range.from).filter(
118
+ (c) => outsideGtd(c.path) && c.status !== "deleted",
119
+ )
120
+ frozen = freezeScenarios(
121
+ touched.filter((c) => c.path.endsWith(".feature")).map((c) => c.path),
122
+ frozen,
123
+ )
124
+ }
108
125
  }
126
+ const paths = (status: string): string[] =>
127
+ touched.filter((c) => c.status === status).map((c) => c.path)
128
+ return { ranges, scenarios: { added: paths("added"), changed: paths("modified") }, frozen }
109
129
  })
@@ -1,20 +1,12 @@
1
1
  import {
2
- answered,
3
2
  changes,
4
3
  hasThreadFor,
5
- judge,
6
- moveScript,
7
- numeric,
8
- read,
9
4
  refuse,
10
5
  requireAnswers,
11
6
  requireReplies,
12
7
  requireThreadsClosed,
13
8
  requireProgress,
14
- run,
15
9
  scope,
16
- sections,
17
- vars,
18
10
  } from "../flows/index.js"
19
11
  import {
20
12
  answerProductQuestions,
@@ -25,7 +17,6 @@ import {
25
17
  REQUIREMENTS,
26
18
  triage,
27
19
  } from "./steps.js"
28
- import * as t from "./text.js"
29
20
 
30
21
  /**
31
22
  * Always stop for the human. Resolves `true` when the round changed anything
@@ -62,44 +53,9 @@ export const architecture = (): Promise<void> =>
62
53
  if (changes(".gtd/packages/**").length === 0) {
63
54
  refuse("gtd land: decompose: write at least one package under .gtd/packages/")
64
55
  }
56
+ if (changes(ARCHITECTURE).some((c) => c.status === "deleted")) {
57
+ refuse(
58
+ "gtd land: decompose: keep .gtd/ARCHITECTURE.md — the build tail's full run needs it; it is removed only once that run is green",
59
+ )
60
+ }
65
61
  })
66
-
67
- /** A package file name from a plan's first heading. */
68
- const slug = (title: string): string =>
69
- title
70
- .toLowerCase()
71
- .replace(/[^a-z0-9]+/g, "-")
72
- .replace(/^-+|-+$/g, "") || "package"
73
-
74
- /** Whether the settled plan needs its own architecture pass, or goes straight to one package. */
75
- export const architecturePass = async (): Promise<void> => {
76
- const { answers, truncated } = await judge("architecture-pre", {
77
- questions: [
78
- {
79
- id: "architectureWarranted",
80
- primitive: "noul",
81
- instructions:
82
- "Given the settled concerns in `.gtd/REQUIREMENTS.md` (in state), does this plan warrant a dedicated architecture pass — real structural decisions, multiple integration points, or a non-obvious tradeoff — before packages are written?",
83
- criteria:
84
- "Answer yes if uncertain; a trivial, single-concern, mechanical plan with no real design decision answers no.",
85
- },
86
- ],
87
- evidence: { requirements: read(REQUIREMENTS) ?? "" },
88
- message: t.architecturePreMessage(),
89
- label: "Judging whether this plan warrants an architecture pass",
90
- })
91
- // A plan the budget cut can hide its structural concerns from the judge:
92
- // never skip the architecture pass on it, however confident the "no".
93
- const skip =
94
- truncated.length === 0 &&
95
- answered(answers.architectureWarranted, "no", numeric(vars.architectureSkipMinP, Infinity))
96
- const plan = read(REQUIREMENTS)
97
- if (skip && plan !== undefined) {
98
- const target = `.gtd/packages/01-${slug(sections(plan)[0] ?? "")}.md`
99
- await run("architecture-promote", moveScript(REQUIREMENTS, target), {
100
- label: "Promoting the plan straight to a package",
101
- })
102
- return
103
- }
104
- await architecture()
105
- }
@@ -12,6 +12,23 @@ export const skillsPreamble = `- Load whatever's listed here that your harness a
12
12
  - Never let a loaded skill turn this turn interactive — answer nothing,
13
13
  ask nothing; this runs unattended, with no one at a keyboard`
14
14
 
15
+ /** One bullet per restricted side; empty when neither is. An empty glob list reads "nothing". */
16
+ export const accessPreamble = (access: {
17
+ readonly read: readonly string[] | null
18
+ readonly write: readonly string[] | null
19
+ }): string => {
20
+ const globs = (list: readonly string[]): string =>
21
+ list.length === 0 ? "nothing" : list.join(", ")
22
+ return [
23
+ ...(access.read === null ? [] : [`- This turn may read only: ${globs(access.read)}`]),
24
+ ...(access.write === null
25
+ ? []
26
+ : [
27
+ `- This turn may write only: ${globs(access.write)} — anything else is refused when the turn lands`,
28
+ ]),
29
+ ].join("\n")
30
+ }
31
+
15
32
  export const styleBlock = `- A deliverable, not a chat reply — size follows the work; cut padding
16
33
  - Lead with the answer; never circle back to restate it
17
34
  - Flat, commanding sentences — commit to the claim, never hedge
@@ -60,8 +77,7 @@ export const architectPersona = `You are the technical planning voice in gtd's b
60
77
  once product concerns are settled, reading them cold, no carried
61
78
  conversation. Work out the *how* per concern — structure, data models,
62
79
  tech-stack choices, error handling — raise the technical open
63
- questions, then write one package spec per concern with no further
64
- judgement call: the grouping is already decided.`
80
+ questions, then pin interfaces and tests in the architecture document.`
65
81
 
66
82
  export const reviewerPersona = `You are the independent reviewing mind in gtd's build pipeline —
67
83
  deliberately separate from whoever wrote the code, with no attachment
@@ -72,13 +88,6 @@ and, when asked, fix the small nits the human flagged and the risks
72
88
  you marked yourself, each in one batch.
73
89
  Beyond that you never fix or build anything yourself.`
74
90
 
75
- export const specReviewerPersona = `You are the adversarial spec-conformance checker in gtd's build
76
- pipeline, checking one freshly-built package against its spec. Verify
77
- only: tasks done, criteria met, code sound and consistent with the
78
- codebase. Write feedback only when something is genuinely wrong —
79
- otherwise write nothing; a clean turn IS the approval. Never fix what
80
- you find — naming it precisely enough for a fix turn is the whole job.`
81
-
82
91
  export const builderPersona = `You are the TDD implementer in gtd's build pipeline: build one
83
92
  package's declared scope end to end, tests first, and come back to fix
84
93
  things when redirected — a failing check, or reviewer feedback. Stay
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
2
  import { installContext, type Change, type JudgeAnswer, type StepRequest } from "../flows/index.js"
3
- import { review, type ReviewOutcome } from "./review.js"
3
+ import { fixQualityFindings, review, type ReviewOutcome } from "./review.js"
4
4
  import { unified } from "./index.js"
5
5
  import { fixtureContext } from "./text.fixture.js"
6
6
 
@@ -331,3 +331,90 @@ describe("the default quality lenses", () => {
331
331
  ])
332
332
  })
333
333
  })
334
+
335
+ describe("scenario wording in review's fix turns", () => {
336
+ const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
337
+ const risky = doc(["Risk: drops the carry", "sub"])
338
+
339
+ it("a green-keeping fix after a risk fix that rewrites a scenario stops at review.scenario-wording", async () => {
340
+ const log: string[] = []
341
+ let red = false
342
+ let drifted = false
343
+ installContext(
344
+ fixtureContext(
345
+ {},
346
+ {
347
+ step: (request: StepRequest) => {
348
+ if (request.kind === "restart") return Promise.resolve()
349
+ log.push(request.name)
350
+ if (request.name === "health.check") red = !red && !drifted
351
+ if (request.name === "fix") drifted = true
352
+ if (request.name === "scenario-wording") throw new Stop()
353
+ return Promise.resolve()
354
+ },
355
+ pushScope: () => undefined,
356
+ popScope: () => undefined,
357
+ read: (path) =>
358
+ path === REVIEW
359
+ ? risky
360
+ : path === "e2e/a.feature"
361
+ ? drifted
362
+ ? "Given b"
363
+ : "Given a"
364
+ : undefined,
365
+ changes: (): readonly Change[] =>
366
+ red
367
+ ? [{ path: ".gtd/FEEDBACK.md", status: "added", before: undefined, after: "red" }]
368
+ : [],
369
+ matches: (path, pattern) => path === pattern,
370
+ head: () => "h2",
371
+ start: () => "h0",
372
+ },
373
+ ),
374
+ )
375
+ await review("base", undefined, frozen).catch((e: unknown) => {
376
+ if (!(e instanceof Stop)) throw e
377
+ })
378
+ expect(log.slice(-2)).toEqual(["fix", "scenario-wording"])
379
+ })
380
+ })
381
+
382
+ describe("the quality fix loop", () => {
383
+ it("a fix that resolves QUALITY.md and rewrites a scenario ends after the wording gate", async () => {
384
+ const QUALITY = ".gtd/QUALITY.md"
385
+ const log: string[] = []
386
+ const files = new Map([
387
+ [QUALITY, "- finding"],
388
+ ["e2e/a.feature", "Given a"],
389
+ ])
390
+ let last: readonly Change[] = []
391
+ installContext(
392
+ fixtureContext(
393
+ {},
394
+ {
395
+ step: (request: StepRequest) => {
396
+ if (request.kind === "restart") return Promise.resolve()
397
+ log.push(request.name)
398
+ last = []
399
+ if (request.name === "fix.quality.fixing") {
400
+ files.delete(QUALITY)
401
+ files.set("e2e/a.feature", "Given b")
402
+ last = [{ path: QUALITY, status: "deleted", before: "- finding", after: undefined }]
403
+ }
404
+ return Promise.resolve()
405
+ },
406
+ pushScope: () => undefined,
407
+ popScope: () => undefined,
408
+ read: (path) => files.get(path),
409
+ changes: () => last,
410
+ matches: (path, pattern) => path === pattern,
411
+ head: () => "h2",
412
+ start: () => "h0",
413
+ },
414
+ ),
415
+ )
416
+ const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
417
+ expect(await fixQualityFindings({ rounds: 0 }, frozen)).toBe(true)
418
+ expect(log).toEqual(["fix.quality.fixing", "scenario-wording"])
419
+ })
420
+ })
@@ -19,8 +19,11 @@ import {
19
19
  } from "../flows/index.js"
20
20
  import { reviewNotes, reviewRisks, stripCodeThreads, type ReviewNote } from "../steering/index.js"
21
21
  import { escalation, FIX_CAP, healthy, type EscalationCount } from "./health.js"
22
+ import type { BuiltPlan } from "./packages.js"
23
+ import { guarded, type FrozenScenarios } from "./scenarios.js"
22
24
  import {
23
25
  answerReviewQuestions,
26
+ ARCHITECTURE,
24
27
  awaitReview,
25
28
  collecting,
26
29
  fix,
@@ -54,10 +57,14 @@ export const qualityLap = async (): Promise<"clean" | "findings"> => {
54
57
  }
55
58
 
56
59
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
57
- export const fixQualityFindings = async (escalations: EscalationCount): Promise<boolean> => {
60
+ export const fixQualityFindings = async (
61
+ escalations: EscalationCount,
62
+ frozen?: FrozenScenarios,
63
+ ): Promise<boolean> => {
58
64
  for (let turns = 0; turns < FIX_CAP; turns++) {
59
- await fixQuality()
60
- if (changes(QUALITY).some((c) => c.status === "deleted")) return true
65
+ await guarded(frozen, fixQuality)()
66
+ // Not `changes()`: a wording gate after the fix would be the last step.
67
+ if (read(QUALITY) === undefined) return true
61
68
  }
62
69
  await escalation(escalations)
63
70
  return false
@@ -203,6 +210,7 @@ type Finish =
203
210
  interface Round {
204
211
  readonly round: string
205
212
  readonly escalations: EscalationCount
213
+ readonly frozen: FrozenScenarios | undefined
206
214
  readonly close: () => Promise<void>
207
215
  readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome
208
216
  readonly unfolded: () => Promise<Finish>
@@ -221,8 +229,8 @@ const routeNotes = async (notes: readonly ReviewNote[], r: Round): Promise<Finis
221
229
  answeredAt = head()
222
230
  }
223
231
  if (nits.length > 0) {
224
- await fixNits(nits)
225
- await healthy(fix, { escalations: r.escalations })
232
+ await guarded(r.frozen, () => fixNits(nits), "review")()
233
+ await healthy(guarded(r.frozen, fix, "review"), { escalations: r.escalations })
226
234
  }
227
235
  if (edits.length > 0) {
228
236
  await r.close()
@@ -239,6 +247,7 @@ const finish = async (
239
247
  reviewed: string,
240
248
  collectedAt: string | undefined,
241
249
  escalations: EscalationCount,
250
+ frozen: FrozenScenarios | undefined,
242
251
  ): Promise<Finish> => {
243
252
  // Everything the human did is read before any agent turn runs, so a nit fix
244
253
  // is never counted as a hand-edit.
@@ -268,7 +277,7 @@ const finish = async (
268
277
  if (edited.length > 0) return collectWith(t.reviewEditsCapture(round))
269
278
  const notes = reviewNotes(baselineText, record)
270
279
  if (notes.length === 0) return collectWith(t.reviewNotesCapture(round))
271
- return routeNotes(notes, { round, escalations, close, outcome, unfolded })
280
+ return routeNotes(notes, { round, escalations, frozen, close, outcome, unfolded })
272
281
  }
273
282
 
274
283
  /** Write the review; if it marks risks, fix them, keep green, and write it again — once, so the re-review's own risks reach the human unfixed. */
@@ -276,12 +285,13 @@ const reviewOnce = async (
276
285
  base: string,
277
286
  carry: string | undefined,
278
287
  escalations: EscalationCount,
288
+ frozen: FrozenScenarios | undefined,
279
289
  ): Promise<void> => {
280
290
  await reviewing(base, carry)
281
291
  const risks = reviewRisks(read(REVIEW) ?? "")
282
292
  if (risks.length === 0) return
283
- await fixRisks(risks)
284
- await healthy(fix, { escalations })
293
+ await guarded(frozen, () => fixRisks(risks), "review")()
294
+ await healthy(guarded(frozen, fix, "review"), { escalations })
285
295
  await reviewing(base, carry)
286
296
  }
287
297
 
@@ -294,17 +304,18 @@ const reviewOnce = async (
294
304
  export const review = async (
295
305
  base: string,
296
306
  escalations: EscalationCount = { rounds: 0 },
307
+ frozen?: FrozenScenarios,
297
308
  ): Promise<ReviewOutcome> => {
298
309
  let carry: string | undefined
299
310
  for (;;) {
300
- await reviewOnce(base, carry, escalations)
311
+ await reviewOnce(base, carry, escalations, frozen)
301
312
  carry = undefined
302
313
  let reviewed = head()
303
314
  let collectedAt: string | undefined
304
315
  for (;;) {
305
316
  collectedAt = await converse(base, collectedAt)
306
317
  if (collectedAt === "missing") break
307
- const result = await finish(reviewed, collectedAt, escalations)
318
+ const result = await finish(reviewed, collectedAt, escalations, frozen)
308
319
  if (!("next" in result)) return result
309
320
  if (result.next === "rereview") {
310
321
  carry = result.carry
@@ -316,22 +327,36 @@ export const review = async (
316
327
  }
317
328
 
318
329
  /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
319
- export const buildTail = (fixFirst: boolean, base: string): Promise<ReviewOutcome> =>
330
+ export const buildTail = (
331
+ fixFirst: boolean,
332
+ base: string,
333
+ built?: BuiltPlan,
334
+ ): Promise<ReviewOutcome> =>
320
335
  scope("build", async () => {
336
+ const frozen = built?.frozen
321
337
  const escalations: EscalationCount = { rounds: 0 }
338
+ const guardedFix = guarded(frozen, () => fix())
322
339
  let redFirst = fixFirst
340
+ // fixFirst's own fix + healthy below already is the full run.
341
+ if (!fixFirst) {
342
+ await healthy(
343
+ guarded(frozen, () => fix(built)),
344
+ { escalations, sweepOnGreen: [ARCHITECTURE] },
345
+ )
346
+ }
323
347
  // The lap runs once a tail: after its findings are fixed, review follows.
324
348
  let lapped = false
325
349
  for (;;) {
326
350
  if (redFirst) {
327
- await fix()
328
- await healthy(fix, { fixesSoFar: 1, escalations })
351
+ await guardedFix()
352
+ await healthy(guardedFix, { fixesSoFar: 1, escalations })
329
353
  redFirst = false
330
354
  }
331
355
  const lap = lapped ? "clean" : await qualityLap()
332
356
  lapped = true
333
- if (lap === "clean") return review(base, escalations)
334
- if (await fixQualityFindings(escalations)) await healthy(fix, { escalations })
335
- else redFirst = true
357
+ if (lap === "clean") return review(base, escalations, frozen)
358
+ if (await fixQualityFindings(escalations, frozen)) {
359
+ await healthy(guardedFix, { escalations })
360
+ } else redFirst = true
336
361
  }
337
362
  })