@pmelab/gtd 38.1.0 → 39.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.
@@ -0,0 +1,318 @@
1
+ import { Effect, Option } from "effect"
2
+ import { GitService } from "../platform/index.js"
3
+ import { parseCommitMessage } from "../replay/index.js"
4
+ import { UNATTRIBUTED_MODEL } from "../wire/index.js"
5
+ import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
6
+ import { writeJson, type ArtifactOut, type CommandRequirements, type JsonMode } from "./Output.js"
7
+
8
+ // Read from commit subjects and trailers alone, never by replay: a metric over
9
+ // old histories must not refuse because today's config has moved on. The step
10
+ // names below are the bundled feature workflow's public ones.
11
+
12
+ const QUALITY = ".gtd/QUALITY.md"
13
+
14
+ export interface MetricCommit {
15
+ readonly message: string
16
+ /** Findings this commit added to the quality lap's file; only a reviewing turn's count is read. */
17
+ readonly findings: number
18
+ }
19
+
20
+ export interface Metrics {
21
+ readonly slices: {
22
+ readonly decided: number
23
+ readonly landed: number
24
+ readonly rebuilt: number
25
+ readonly refactored: number
26
+ }
27
+ /** `rebuilt / decided`, to three places; null without a decision. */
28
+ readonly refactorFirstRate: number | null
29
+ /** Continuation turns per phase, keyed by the step without its `continue` segment. */
30
+ readonly continuations: Readonly<Record<string, number>>
31
+ readonly cost: {
32
+ readonly total: number
33
+ readonly byModel: Readonly<Record<string, number>>
34
+ readonly byPhase: {
35
+ readonly naive: number
36
+ readonly refactor: number
37
+ readonly implement: number
38
+ readonly other: number
39
+ }
40
+ /** A rebuilt slice's naive pass and the implement turns that rebuilt it. */
41
+ readonly doubleWork: {
42
+ readonly naive: number
43
+ readonly rebuild: number
44
+ readonly total: number
45
+ }
46
+ }
47
+ readonly findings: { readonly total: number; readonly byLens: Readonly<Record<string, number>> }
48
+ /** Keyed by the re-check's full step name, from its subject: the mark's value is scope-relative. */
49
+ readonly flaky: { readonly total: number; readonly byStep: Readonly<Record<string, number>> }
50
+ }
51
+
52
+ type Phase = keyof Metrics["cost"]["byPhase"]
53
+
54
+ // Keyed by the lap's path without `.reviewing`, so the refactor lap's lenses stay apart from the feature's.
55
+ const LENS = /^((?:.*\.)?quality\.[^.]+)\.reviewing$/
56
+
57
+ const CONTINUE = /(^|\.)continue\./
58
+
59
+ const inSeam = (step: string): boolean => step.startsWith("seam.")
60
+
61
+ // A naive retry after a continuation runs under `seam.fresh.naive`: any seam step named `naive` is the pass.
62
+ const phaseOf = (step: string): Phase => {
63
+ const scope = step.split(".")[0]
64
+ if (inSeam(step) && step.endsWith(".naive")) return "naive"
65
+ if (step === "seam.note" || scope === "refactor") return "refactor"
66
+ if (scope === "implement") return "implement"
67
+ return "other"
68
+ }
69
+
70
+ const round = (n: number, places = 6): number => {
71
+ const f = 10 ** places
72
+ return Math.round(n * f) / f
73
+ }
74
+
75
+ // Keys are model, step and lens names from history: a prototype-free record keeps `constructor` a plain key.
76
+ const record = (): Record<string, number> => Object.create(null) as Record<string, number>
77
+
78
+ const add = (into: Record<string, number>, key: string, n: number): void => {
79
+ into[key] = (into[key] ?? 0) + n
80
+ }
81
+
82
+ const roundAll = (record: Record<string, number>): Record<string, number> =>
83
+ Object.fromEntries(Object.entries(record).map(([k, v]) => [k, round(v)]))
84
+
85
+ interface Tally {
86
+ readonly slices: { decided: number; landed: number; rebuilt: number; refactored: number }
87
+ readonly continuations: Record<string, number>
88
+ readonly byModel: Record<string, number>
89
+ readonly byPhase: Record<Phase, number>
90
+ readonly doubleWork: { naive: number; rebuild: number }
91
+ readonly byLens: Record<string, number>
92
+ readonly flaky: Record<string, number>
93
+ }
94
+
95
+ const emptyTally = (): Tally => ({
96
+ slices: { decided: 0, landed: 0, rebuilt: 0, refactored: 0 },
97
+ continuations: record(),
98
+ byModel: record(),
99
+ byPhase: { naive: 0, refactor: 0, implement: 0, other: 0 },
100
+ doubleWork: { naive: 0, rebuild: 0 },
101
+ byLens: record(),
102
+ flaky: record(),
103
+ })
104
+
105
+ const sum = (record: Readonly<Record<string, number>>): number =>
106
+ Object.values(record).reduce((a, b) => a + b, 0)
107
+
108
+ const finish = (t: Tally): Metrics => ({
109
+ slices: t.slices,
110
+ refactorFirstRate: t.slices.decided === 0 ? null : round(t.slices.rebuilt / t.slices.decided, 3),
111
+ continuations: t.continuations,
112
+ cost: {
113
+ total: round(sum(t.byModel)),
114
+ byModel: roundAll(t.byModel),
115
+ byPhase: roundAll(t.byPhase) as Metrics["cost"]["byPhase"],
116
+ doubleWork: {
117
+ naive: round(t.doubleWork.naive),
118
+ rebuild: round(t.doubleWork.rebuild),
119
+ total: round(t.doubleWork.naive + t.doubleWork.rebuild),
120
+ },
121
+ },
122
+ findings: { total: sum(t.byLens), byLens: t.byLens },
123
+ flaky: { total: sum(t.flaky), byStep: t.flaky },
124
+ })
125
+
126
+ /** The open slice: its naive cost so far, and whether its implement turns rebuild it. */
127
+ interface Slice {
128
+ naive: number
129
+ rebuilding: boolean
130
+ /** Landed, not yet applied, with its naive cost: an apply that fails turns it into a rebuild. */
131
+ applying: number | undefined
132
+ }
133
+
134
+ const tallyCost = (
135
+ t: Tally,
136
+ open: Slice,
137
+ costs: ReturnType<typeof parseCommitMessage>["cost"],
138
+ phase: Phase,
139
+ ): void => {
140
+ for (const { cost, model } of costs) {
141
+ add(t.byModel, model ?? UNATTRIBUTED_MODEL, cost)
142
+ t.byPhase[phase] += cost
143
+ if (phase === "naive") open.naive += cost
144
+ if (phase === "implement" && open.rebuilding) t.doubleWork.rebuild += cost
145
+ }
146
+ }
147
+
148
+ /** A slice is decided where its turns leave the seam: for `implement.applying` the naive pass landed — unless it fails to apply and is built afresh — anywhere else it is rebuilt. */
149
+ const tallyDecision = (t: Tally, open: Slice, at: string, to: string): void => {
150
+ // The slice's closing ends its rebuild: later implement turns (an amend after review) are new work.
151
+ if (at === "implement.closing") open.rebuilding = false
152
+ if (open.applying !== undefined && at === "implement.applying") {
153
+ const naive = open.applying
154
+ open.applying = undefined
155
+ if (to !== "implement.building") return
156
+ t.slices.landed--
157
+ rebuild(t, open, naive)
158
+ return
159
+ }
160
+ if (!inSeam(at) || inSeam(to)) return
161
+ t.slices.decided++
162
+ const naive = open.naive
163
+ open.naive = 0
164
+ if (to === "implement.applying") {
165
+ open.applying = naive
166
+ open.rebuilding = false
167
+ t.slices.landed++
168
+ return
169
+ }
170
+ rebuild(t, open, naive)
171
+ if (at === "seam.note") t.slices.refactored++
172
+ }
173
+
174
+ const rebuild = (t: Tally, open: Slice, naive: number): void => {
175
+ open.rebuilding = true
176
+ t.slices.rebuilt++
177
+ t.doubleWork.naive += naive
178
+ }
179
+
180
+ /** Oldest first; a commit gtd did not write is skipped. */
181
+ export const processMetrics = (commits: readonly MetricCommit[]): Metrics => {
182
+ const t = emptyTally()
183
+ const open: Slice = { naive: 0, rebuilding: false, applying: undefined }
184
+ for (const commit of commits) {
185
+ const message = parseCommitMessage(commit.message)
186
+ const subject = message.parsed
187
+ if (subject === undefined) continue
188
+ const at = subject.from ?? subject.to
189
+ tallyCost(t, open, message.cost, phaseOf(at))
190
+ if (subject.actor === "agent" && CONTINUE.test(at))
191
+ add(t.continuations, at.replace(CONTINUE, "$1"), 1)
192
+ const lens = LENS.exec(at)?.[1]
193
+ if (lens !== undefined && commit.findings > 0) add(t.byLens, lens, commit.findings)
194
+ if (message.marks.some((m) => m.name === "flaky")) add(t.flaky, at, 1)
195
+ tallyDecision(t, open, at, subject.to)
196
+ }
197
+ return finish(t)
198
+ }
199
+
200
+ export const totalMetrics = (all: readonly Metrics[]): Metrics & { readonly processes: number } => {
201
+ const t = emptyTally()
202
+ for (const m of all) {
203
+ for (const key of Object.keys(t.slices) as (keyof Tally["slices"])[])
204
+ t.slices[key] += m.slices[key]
205
+ for (const [k, v] of Object.entries(m.continuations)) add(t.continuations, k, v)
206
+ for (const [k, v] of Object.entries(m.cost.byModel)) add(t.byModel, k, v)
207
+ for (const key of Object.keys(t.byPhase) as Phase[]) t.byPhase[key] += m.cost.byPhase[key]
208
+ t.doubleWork.naive += m.cost.doubleWork.naive
209
+ t.doubleWork.rebuild += m.cost.doubleWork.rebuild
210
+ for (const [k, v] of Object.entries(m.findings.byLens)) add(t.byLens, k, v)
211
+ for (const [k, v] of Object.entries(m.flaky.byStep)) add(t.flaky, k, v)
212
+ }
213
+ return { processes: all.length, ...finish(t) }
214
+ }
215
+
216
+ const chunks = (content: string | undefined): number =>
217
+ content === undefined ? 0 : (content.match(/^## /gm) ?? []).length
218
+
219
+ export interface ProcessMetrics extends Metrics {
220
+ /** The opening commit's `Gtd-Workflow`; null for the default workflow. */
221
+ readonly workflow: string | null
222
+ }
223
+
224
+ export const readProcessMetrics = (
225
+ entry: Pick<HistoryEntry, "entry" | "tip">,
226
+ ): Effect.Effect<ProcessMetrics, Error, GitService> =>
227
+ Effect.gen(function* () {
228
+ const git = yield* GitService
229
+ const base = Option.getOrUndefined(yield* git.readRefOption(`${entry.entry}^`))
230
+ const commits = yield* git.commitHistory(base, entry.tip)
231
+ const quality = (hash: string) =>
232
+ git.readFileAtRef(hash, QUALITY).pipe(
233
+ Effect.map((content): string | undefined => content),
234
+ Effect.orElseSucceed(() => undefined),
235
+ )
236
+ // Only a commit that touched the file changes it: carry the last content forward.
237
+ let count = chunks(base === undefined ? undefined : yield* quality(base))
238
+ const read: MetricCommit[] = []
239
+ for (const { hash, message, touched } of commits) {
240
+ const before = count
241
+ if (touched.includes(QUALITY)) count = chunks(yield* quality(hash))
242
+ read.push({ message, findings: Math.max(0, count - before) })
243
+ }
244
+ const workflow =
245
+ commits[0] === undefined ? undefined : parseCommitMessage(commits[0].message).workflow
246
+ return { workflow: workflow ?? null, ...processMetrics(read) }
247
+ })
248
+
249
+ // Keys come from fetched history: never let them put control bytes on a terminal.
250
+ // eslint-disable-next-line no-control-regex -- the control range IS the point
251
+ const printable = (s: string): string => s.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, "?")
252
+
253
+ const listed = (record: Readonly<Record<string, number>>): string =>
254
+ Object.entries(record)
255
+ .map(([k, v]) => `${printable(k)} ${v}`)
256
+ .join(", ")
257
+
258
+ const line = (label: string, m: Metrics): string => {
259
+ const { slices, cost } = m
260
+ const percent = m.refactorFirstRate === null ? "-" : `${Math.round(m.refactorFirstRate * 100)}%`
261
+ const continued = sum(m.continuations)
262
+ return [
263
+ `${label}: ${slices.decided} slices, ${slices.rebuilt} rebuilt (refactor-first ${percent}), ${slices.refactored} refactored`,
264
+ `${continued} continuations${continued > 0 ? ` (${listed(m.continuations)})` : ""}`,
265
+ `cost ${cost.total} (${listed(cost.byPhase)}; by model ${listed(cost.byModel) || "-"}), double work ${cost.doubleWork.total}`,
266
+ `${m.findings.total} findings${m.findings.total > 0 ? ` (${listed(m.findings.byLens)})` : ""}`,
267
+ `${m.flaky.total} flaky${m.flaky.total > 0 ? ` (${listed(m.flaky.byStep)})` : ""}`,
268
+ ].join("; ")
269
+ }
270
+
271
+ export const runHistoryMetricsCommand = (
272
+ refs: readonly string[],
273
+ json: JsonMode,
274
+ out: ArtifactOut,
275
+ ): Effect.Effect<void, Error, CommandRequirements> =>
276
+ Effect.gen(function* () {
277
+ const entries = yield* historyEntries
278
+ const chosen =
279
+ refs.length === 0
280
+ ? entries
281
+ : (yield* Effect.forEach(refs, (ref) =>
282
+ // A branch name means every process kept from it, not just its newest.
283
+ resolveHistoryRef(ref, entries).pipe(
284
+ Effect.map((hit) =>
285
+ hit.branch === ref ? entries.filter((e) => e.branch === ref) : [hit],
286
+ ),
287
+ ),
288
+ )).flat()
289
+ const unique = chosen.filter((e, i) => chosen.findIndex((o) => o.ref === e.ref) === i)
290
+ const processes = yield* Effect.forEach(
291
+ unique,
292
+ (e) =>
293
+ readProcessMetrics(e).pipe(
294
+ Effect.map((metrics) => ({
295
+ ref: e.ref,
296
+ entry: e.entry,
297
+ branch: e.branch,
298
+ tip: e.tip,
299
+ ...metrics,
300
+ })),
301
+ ),
302
+ { concurrency: 4 },
303
+ )
304
+ const document = { processes, total: totalMetrics(processes) }
305
+ if (yield* writeJson(json, out, document)) return
306
+ if (processes.length === 0) {
307
+ out.write("no history refs; run gtd history fetch | sh to pull them\n")
308
+ return
309
+ }
310
+ const lines = processes.map((p) =>
311
+ line(
312
+ `${p.entry.slice(0, 7)} ${printable(p.branch ?? "-")}${p.workflow === null ? "" : ` (${printable(p.workflow)})`}`,
313
+ p,
314
+ ),
315
+ )
316
+ lines.push(line(`total (${processes.length} processes)`, document.total))
317
+ out.write(`${lines.join("\n")}\n`)
318
+ })
package/src/cli/Output.ts CHANGED
@@ -68,3 +68,16 @@ export type CommandRequirements =
68
68
  | ToolCommands
69
69
 
70
70
  export type SuccessExit = typeof EXIT_OK | typeof EXIT_STRAYS_FOUND
71
+
72
+ /** Writes `document` per `json` (bare → whole, `=path` → one value); returns false for `off`. */
73
+ export const writeJson = (
74
+ json: JsonMode,
75
+ out: ArtifactOut,
76
+ document: unknown,
77
+ ): Effect.Effect<boolean, Error> =>
78
+ Effect.gen(function* () {
79
+ if (json.kind === "off") return false
80
+ if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`)
81
+ else yield* writeSelection(out, document, json.path)
82
+ return true
83
+ })
@@ -1694,6 +1694,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1694
1694
  judgeRun: { kind: "judgeRun", provider: "fixed" },
1695
1695
  historyList: { kind: "historyList" },
1696
1696
  historyShow: { kind: "historyShow", ref: "x" },
1697
+ historyMetrics: { kind: "historyMetrics", refs: [] },
1697
1698
  historyFetch: { kind: "historyFetch", remote: "origin" },
1698
1699
  strays: { kind: "strays", base: "HEAD", tip: "HEAD", globs: [] },
1699
1700
  }
@@ -1705,7 +1706,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1705
1706
  const NO_COMMITS_MESSAGE =
1706
1707
  "gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
1707
1708
 
1708
- it("derives exactly the seventeen non-standalone kinds — a canary for the table-driven cases below", () => {
1709
+ it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
1709
1710
  expect(stateKinds.sort()).toEqual(
1710
1711
  [
1711
1712
  "abandon",
@@ -1724,6 +1725,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1724
1725
  "judgeAnswer",
1725
1726
  "historyList",
1726
1727
  "historyShow",
1728
+ "historyMetrics",
1727
1729
  "historyFetch",
1728
1730
  ].sort(),
1729
1731
  )
@@ -109,6 +109,7 @@ export {
109
109
  } from "./Output.js"
110
110
  import { runStraysCommand } from "./Strays.js"
111
111
  import { runHistoryFetchCommand, runHistoryListCommand, runHistoryShowCommand } from "./History.js"
112
+ import { runHistoryMetricsCommand } from "./Metrics.js"
112
113
 
113
114
  /**
114
115
  * `Edge.ts`'s `Rest` is module-private (`.gtd/packages/05-step-core.md`
@@ -174,6 +175,7 @@ export type Command =
174
175
  | { readonly kind: "judgeAnswer" }
175
176
  | { readonly kind: "historyList" }
176
177
  | { readonly kind: "historyShow"; readonly ref: string }
178
+ | { readonly kind: "historyMetrics"; readonly refs: readonly string[] }
177
179
  | { readonly kind: "historyFetch"; readonly remote: string }
178
180
  | {
179
181
  readonly kind: "strays"
@@ -1320,6 +1322,8 @@ const dispatchVoidCommand = (
1320
1322
  return runHistoryListCommand(json, out)
1321
1323
  case "historyShow":
1322
1324
  return runHistoryShowCommand(command.ref, json, out)
1325
+ case "historyMetrics":
1326
+ return runHistoryMetricsCommand(command.refs, json, out)
1323
1327
  case "historyFetch":
1324
1328
  return runHistoryFetchCommand(command.remote, out)
1325
1329
  }
@@ -44,16 +44,24 @@ export interface Health {
44
44
  /** Shared with other callers in the same run of red checks. */
45
45
  readonly escalations: EscalationCount
46
46
  readonly escalation: EscalationSteps
47
+ /** Runs the suite once more before a person is asked; `true` ends the loop as if `check` had passed. */
48
+ readonly recheck?: () => Promise<boolean>
47
49
  }
48
50
 
49
51
  // check() stamps the report with the commit it ran at, so equal failures differ there.
50
52
  const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/
51
53
  const stripStamp = (text: string): string => text.replace(STAMP, "\n")
52
54
 
55
+ /** `true` when the re-check passed, else the red re-check's report — the one the escalation describes. */
56
+ const recheckReport = async (health: Health): Promise<true | string | undefined> => {
57
+ if (health.recheck === undefined) return undefined
58
+ return (await health.recheck()) || stripStamp(read(health.report) ?? "")
59
+ }
60
+
53
61
  /**
54
62
  * Loop until the suite is green: check, and on red run `fix` — escalating
55
63
  * first once `fixCap` fixes are spent, or `sameFailure` calls the failure
56
- * identical to the previous round's.
64
+ * identical to the previous round's, unless `recheck` passes.
57
65
  */
58
66
  export const healthy = async (fix: () => Promise<void>, health: Health): Promise<void> => {
59
67
  let fixes = health.fixesSoFar ?? 0
@@ -67,6 +75,12 @@ export const healthy = async (fix: () => Promise<void>, health: Health): Promise
67
75
  const stuck = previous !== undefined && (await health.sameFailure(previous, current))
68
76
  previous = current
69
77
  if (stuck || fixes >= health.fixCap) {
78
+ const rechecked = await recheckReport(health)
79
+ if (rechecked === true) {
80
+ health.escalations.rounds = 0
81
+ return
82
+ }
83
+ previous = rechecked ?? previous
70
84
  await escalation(health.escalations, health.escalation)
71
85
  fixes = 0
72
86
  }
@@ -160,6 +160,7 @@ describe("the cleanup guard over every later agent turn", () => {
160
160
  "build.health.check": (_, r) => {
161
161
  if (!stopped) r.write(FEEDBACK, `failing ${runs++}\n`)
162
162
  },
163
+ "build.health.recheck": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
163
164
  "build.health.describe": () => undefined,
164
165
  "build.health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
165
166
  "build.fix": (_, r) => r.write("src/other.ts", `export const n = ${runs}\n`),
@@ -176,6 +177,7 @@ describe("the cleanup guard over every later agent turn", () => {
176
177
  await expect(
177
178
  driven(guarded("", NO_ACCESS), {
178
179
  "build.health.check": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
180
+ "build.health.recheck": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
179
181
  "build.health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
180
182
  "build.fix": (_, r) => r.write("src/other.ts", `export const n = ${runs}\n`),
181
183
  "build.health.describe": (_, r) => r.write("src/b.ts", NAMED),
@@ -436,6 +436,8 @@ const drive = async (
436
436
  checks++
437
437
  phase = "check"
438
438
  }
439
+ // Re-runs the red check it follows, so it reads red as well.
440
+ if (request.kind === "run" && name === "health.recheck") phase = "check"
439
441
  return Promise.resolve(
440
442
  request.kind === "judge" ? { answers: {}, truncated: [] } : undefined,
441
443
  )
@@ -806,6 +808,8 @@ const driveReviewed = async (
806
808
  // Red checks apply to the tail's own runs, never the refactor lap's.
807
809
  checking = request.name === "health.check" && stack.includes("build")
808
810
  if (checking && reds > 0) reds--
811
+ // Re-runs the red check it follows, so it reads red as well.
812
+ if (request.name === "health.recheck" && stack.includes("build")) checking = true
809
813
  }
810
814
  const lensEffects = ["correctness", "ponytail-review", "conventions"].map(
811
815
  (lens): [string, () => void] => [
@@ -157,6 +157,10 @@ Feature: rollout turns one release flag default-on and touches nothing else
157
157
  """
158
158
  1 test failing
159
159
  """
160
+ And step "rollout.suite.recheck" writes ".gtd/FEEDBACK.md" with:
161
+ """
162
+ 1 test still failing
163
+ """
160
164
  When gtd starts workflow "rollout" with "--var flag=alpha"
161
165
  And the process is driven
162
166
  Then the process rests at "rollout.suite.blocked"
@@ -176,6 +180,10 @@ Feature: rollout turns one release flag default-on and touches nothing else
176
180
  """
177
181
  1 test failing
178
182
  """
183
+ And step "rollout.suite.recheck" writes ".gtd/FEEDBACK.md" with:
184
+ """
185
+ 1 test still failing
186
+ """
179
187
  When gtd starts workflow "rollout" with "--var flag=alpha"
180
188
  And the process is driven
181
189
  Then the process rests at "rollout.suite.blocked"
@@ -200,6 +208,10 @@ Feature: rollout turns one release flag default-on and touches nothing else
200
208
  """
201
209
  1 test failing
202
210
  """
211
+ And step "rollout.suite.recheck" writes ".gtd/FEEDBACK.md" with:
212
+ """
213
+ 1 test still failing
214
+ """
203
215
  When gtd starts workflow "rollout" with "--var flag=alpha"
204
216
  And the process is driven
205
217
  Then the process rests at "rollout.suite.blocked"
@@ -229,6 +241,10 @@ Feature: rollout turns one release flag default-on and touches nothing else
229
241
  """
230
242
  1 test failing
231
243
  """
244
+ And step "rollout.suite.recheck" writes ".gtd/FEEDBACK.md" with:
245
+ """
246
+ 1 test still failing
247
+ """
232
248
  When gtd starts workflow "rollout" with "--var flag=alpha"
233
249
  And the process is driven
234
250
  Then the process rests at "rollout.suite.blocked"
@@ -114,6 +114,7 @@ describe("the rollout workflow", () => {
114
114
  const rest = await repo.drive({
115
115
  "rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
116
116
  "rollout.suite.check": (_, r) => r.write(".gtd/FEEDBACK.md", "1 failing\n"),
117
+ "rollout.suite.recheck": (_, r) => r.write(".gtd/FEEDBACK.md", "still 1 failing\n"),
117
118
  })
118
119
  expect(rest).toMatchObject({ state: "rollout.suite.blocked", actor: "human" })
119
120
  })
@@ -125,6 +126,7 @@ describe("the rollout workflow", () => {
125
126
  await repo.drive({
126
127
  "rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
127
128
  "rollout.suite.check": (_, r) => r.write(".gtd/FEEDBACK.md", "1 failing\n"),
129
+ "rollout.suite.recheck": (_, r) => r.write(".gtd/FEEDBACK.md", "still 1 failing\n"),
128
130
  })
129
131
  return repo
130
132
  }
@@ -27,6 +27,7 @@ describe("gate guard", () => {
27
27
  const red = {
28
28
  first: (_: unknown, r: W) => r.write("f", "1\n"),
29
29
  "suite.check": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", "red\n"),
30
+ "suite.recheck": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", "red again\n"),
30
31
  }
31
32
 
32
33
  it("runs the guard after a blocked landing and lets its refusal stop it", async () => {
@@ -66,6 +67,7 @@ describe("healthy describeGuard", () => {
66
67
  let runs = 0
67
68
  const handlers = (extra: Record<string, unknown>) => ({
68
69
  "health.check": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", `red ${runs++}\n`),
70
+ "health.recheck": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", `red ${runs++}\n`),
69
71
  "health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
70
72
  fix: (_: unknown, r: W) => r.write("f", `${runs}\n`),
71
73
  first: (_: unknown, r: W) => r.write("start", "1\n"),
@@ -4,8 +4,10 @@ import {
4
4
  env,
5
5
  human,
6
6
  judge,
7
+ mark,
7
8
  numeric,
8
9
  vars,
10
+ type CheckOptions,
9
11
  type JudgeQuestion,
10
12
  } from "../../flows/index.js"
11
13
  import * as kit from "../../workflow-kit/index.js"
@@ -19,6 +21,24 @@ export const FIX_CAP = 3
19
21
  export const baseline = (name: string, label = "Checking the baseline"): Promise<boolean> =>
20
22
  check(name, env.testCommand ?? "", { report: FEEDBACK, label })
21
23
 
24
+ /**
25
+ * Re-run a red check as step `name` before a person sees it. Green records
26
+ * `Gtd-Mark: flaky=<red>` on the re-check's landing and resolves `true`.
27
+ */
28
+ export const recheck = async (
29
+ name: string,
30
+ red: string,
31
+ options: Pick<CheckOptions, "sweepOnGreen"> = {},
32
+ ): Promise<boolean> => {
33
+ const green = await check(name, env.testCommand ?? "", {
34
+ ...options,
35
+ report: FEEDBACK,
36
+ label: "Re-running checks",
37
+ })
38
+ if (green) mark("flaky", red)
39
+ return green
40
+ }
41
+
22
42
  export type { EscalationCount } from "../../workflow-kit/index.js"
23
43
 
24
44
  const escalationSteps = (rules?: string): kit.EscalationSteps => ({
@@ -117,18 +137,24 @@ export interface HealthOptions {
117
137
  readonly rules?: string | undefined
118
138
  }
119
139
 
140
+ // Swept only on green: an unresolved analysis survives every retry.
141
+ const healthSweep = (options: HealthOptions): string[] => [
142
+ ESCALATION,
143
+ ...(options.sweepOnGreen ?? []),
144
+ ]
145
+
120
146
  const runSuite = (options: HealthOptions): Promise<boolean> =>
121
147
  check("health.check", env.testCommand ?? "", {
122
148
  report: FEEDBACK,
123
149
  label: "Running checks",
124
- // Swept only on green: an unresolved analysis survives every retry.
125
- sweepOnGreen: [ESCALATION, ...(options.sweepOnGreen ?? [])],
150
+ sweepOnGreen: healthSweep(options),
126
151
  })
127
152
 
128
153
  /**
129
154
  * Loop until the suite is green: check, and on red run `fix` — escalating
130
155
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
131
- * identical to the previous round's.
156
+ * identical to the previous round's. An escalation re-runs the suite once
157
+ * first; a green re-run ends the loop.
132
158
  */
133
159
  export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
134
160
  kit.healthy(guarded(fix, options.guard), {
@@ -138,6 +164,8 @@ export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}):
138
164
  fixesSoFar: options.fixesSoFar ?? 0,
139
165
  sameFailure,
140
166
  escalations: options.escalations ?? { rounds: 0 },
167
+ recheck: () =>
168
+ recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
141
169
  escalation: guardedSteps(
142
170
  options.guard,
143
171
  options.describeGuard,
@@ -147,13 +175,14 @@ export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}):
147
175
  })
148
176
 
149
177
  /**
150
- * Hold the process at `<name>.blocked` until the suite is green. `guard` runs
178
+ * Hold the process at `<name>.blocked` until the suite is green, re-running a
179
+ * red check once as `<name>.recheck` before resting. `guard` runs
151
180
  * after each `blocked` landing only — never after a check, whose refusal would
152
181
  * re-run the same script forever.
153
182
  */
154
183
  export const gate = (name: string, message: string, guard?: Guard): Promise<void> =>
155
184
  kit.gate(
156
185
  name,
157
- () => baseline("check"),
186
+ async () => (await baseline("check")) || recheck("recheck", `${name}.check`),
158
187
  guarded(() => human("blocked", { message, label: "Baseline is red", file: FEEDBACK }), guard),
159
188
  )