@pmelab/gtd 38.2.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "38.2.0",
3
+ "version": "39.0.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -213,9 +213,10 @@ Label: Checking the baseline
213
213
  ```
214
214
 
215
215
  A green suite leaves the tree clean, so the process moves on to triage. A red
216
- suite instead writes `.gtd/FEEDBACK.md`, and the process goes to
217
- `start-gate.blocked` — same two commands, different outcome, decided entirely by
218
- what the beat left in the tree.
216
+ suite instead writes `.gtd/FEEDBACK.md`; the process runs it once more at
217
+ `start-gate.recheck`, and only a second red goes to `start-gate.blocked` — same
218
+ two commands, different outcome, decided entirely by what the beat left in the
219
+ tree.
219
220
 
220
221
  Land the green one, and the beat after it belongs to an agent.
221
222
 
@@ -508,7 +509,10 @@ you open the `review` door.
508
509
  as-is, handing that document to the next fix turn as its instruction. Two
509
510
  such rounds with no fix is the terminal stop: the last analysis stays on disk
510
511
  instead of a third being written, and editing it is what gets the next
511
- attempt anywhere new.
512
+ attempt anywhere new. Before any escalation or red-baseline stop the suite
513
+ runs once more; a pass there continues without you and is recorded as
514
+ `Gtd-Mark: flaky=<check>` (see
515
+ [Re-check before a person](docs/configuration.md#re-check-before-a-person)).
512
516
 
513
517
  4. **You review — the refactor, or on request.** The feature workflow asks for
514
518
  one review only on `refactor-first`: of the refactor slice, before the
@@ -745,6 +749,9 @@ it never replays and a config changed since is no refusal:
745
749
  - **findings** — the net count of `## ` findings each quality-lap lens added to
746
750
  `.gtd/QUALITY.md`, keyed by the lap's lens path (`build.quality.correctness`,
747
751
  `refactor.build.quality.correctness`, …)
752
+ - **flaky** — red checks that passed on their re-check (`Gtd-Mark: flaky=…`),
753
+ keyed by the re-check's full step name (`start-gate.recheck`,
754
+ `build.health.recheck`, …)
748
755
 
749
756
  `--json` prints `{ processes, total }`; `--json=total.refactorFirstRate` prints
750
757
  one value. Model, phase and lens keys may hold dots a path cannot reach: select
@@ -1,4 +1,4 @@
1
- import { accessFor, agent, answered, changesSince, check, codeThreads, defineWorkflow, displayPath, env, head, human, judge, numeric, quote, read, refuse, scope, skillsFor, start, vars } from "../flows.mjs";
1
+ import { accessFor, agent, answered, changesSince, check, codeThreads, defineWorkflow, displayPath, env, head, human, judge, mark, numeric, quote, read, refuse, scope, skillsFor, start, vars } from "../flows.mjs";
2
2
  import { escalation as escalation$1, gate as gate$1, healthy as healthy$1, lensScopes, lensesOf, removedAssertions } from "../workflow-kit.mjs";
3
3
  import { c as String$, g as standardSchemaV1, h as optionalWith, l as Struct, n as Boolean$, s as Number$ } from "./Schema-C6fLlQzx.mjs";
4
4
  //#region src/workflows/shared/registry.ts
@@ -1580,6 +1580,19 @@ const baseline = (name, label = "Checking the baseline") => check(name, env.test
1580
1580
  report: FEEDBACK,
1581
1581
  label
1582
1582
  });
1583
+ /**
1584
+ * Re-run a red check as step `name` before a person sees it. Green records
1585
+ * `Gtd-Mark: flaky=<red>` on the re-check's landing and resolves `true`.
1586
+ */
1587
+ const recheck = async (name, red, options = {}) => {
1588
+ const green = await check(name, env.testCommand ?? "", {
1589
+ ...options,
1590
+ report: FEEDBACK,
1591
+ label: "Re-running checks"
1592
+ });
1593
+ if (green) mark("flaky", red);
1594
+ return green;
1595
+ };
1583
1596
  const escalationSteps = (rules) => ({
1584
1597
  limit: 2,
1585
1598
  describe: () => describeEscalation(rules),
@@ -1630,15 +1643,17 @@ const sameFailure = async (previous, current) => {
1630
1643
  });
1631
1644
  return comparable && truncated.length === 0 && answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity));
1632
1645
  };
1646
+ const healthSweep = (options) => [ESCALATION, ...options.sweepOnGreen ?? []];
1633
1647
  const runSuite = (options) => check("health.check", env.testCommand ?? "", {
1634
1648
  report: FEEDBACK,
1635
1649
  label: "Running checks",
1636
- sweepOnGreen: [ESCALATION, ...options.sweepOnGreen ?? []]
1650
+ sweepOnGreen: healthSweep(options)
1637
1651
  });
1638
1652
  /**
1639
1653
  * Loop until the suite is green: check, and on red run `fix` — escalating
1640
1654
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
1641
- * identical to the previous round's.
1655
+ * identical to the previous round's. An escalation re-runs the suite once
1656
+ * first; a green re-run ends the loop.
1642
1657
  */
1643
1658
  const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
1644
1659
  check: () => runSuite(options),
@@ -1647,14 +1662,16 @@ const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
1647
1662
  fixesSoFar: options.fixesSoFar ?? 0,
1648
1663
  sameFailure,
1649
1664
  escalations: options.escalations ?? { rounds: 0 },
1665
+ recheck: () => recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
1650
1666
  escalation: guardedSteps(options.guard, options.describeGuard, options.guardRests, options.rules)
1651
1667
  });
1652
1668
  /**
1653
- * Hold the process at `<name>.blocked` until the suite is green. `guard` runs
1669
+ * Hold the process at `<name>.blocked` until the suite is green, re-running a
1670
+ * red check once as `<name>.recheck` before resting. `guard` runs
1654
1671
  * after each `blocked` landing only — never after a check, whose refusal would
1655
1672
  * re-run the same script forever.
1656
1673
  */
1657
- const gate = (name, message, guard) => gate$1(name, () => baseline("check"), guarded(() => human("blocked", {
1674
+ const gate = (name, message, guard) => gate$1(name, async () => await baseline("check") || recheck("recheck", `${name}.check`), guarded(() => human("blocked", {
1658
1675
  message,
1659
1676
  label: "Baseline is red",
1660
1677
  file: FEEDBACK
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, showScript, vars } from "../flows.mjs";
2
2
  import { fixFindings, lensEntriesOf, lensOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { C as fixCheck, D as reviewMissing, O as reviewQuality, T as fixRisks, Y as handOffAwaitReviewMessage, d as guarded, f as healthy, h as QUALITY, k as reviewing, l as escalation, w as fixQuality, x as awaitReview, y as REVIEW } from "./reserved-C3PgP7XB.mjs";
3
+ import { C as fixCheck, D as reviewMissing, O as reviewQuality, T as fixRisks, Y as handOffAwaitReviewMessage, d as guarded, f as healthy, h as QUALITY, k as reviewing, l as escalation, w as fixQuality, x as awaitReview, y as REVIEW } from "./reserved-B6tTYjeb.mjs";
4
4
  //#region src/workflows/shared/tail.ts
5
5
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
6
6
  const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
@@ -41406,7 +41406,8 @@ const emptyTally = () => ({
41406
41406
  naive: 0,
41407
41407
  rebuild: 0
41408
41408
  },
41409
- byLens: record()
41409
+ byLens: record(),
41410
+ flaky: record()
41410
41411
  });
41411
41412
  const sum = (record) => Object.values(record).reduce((a, b) => a + b, 0);
41412
41413
  const finish = (t) => ({
@@ -41426,6 +41427,10 @@ const finish = (t) => ({
41426
41427
  findings: {
41427
41428
  total: sum(t.byLens),
41428
41429
  byLens: t.byLens
41430
+ },
41431
+ flaky: {
41432
+ total: sum(t.flaky),
41433
+ byStep: t.flaky
41429
41434
  }
41430
41435
  });
41431
41436
  const tallyCost = (t, open, costs, phase) => {
@@ -41482,6 +41487,7 @@ const processMetrics = (commits) => {
41482
41487
  if (subject.actor === "agent" && CONTINUE.test(at)) add(t.continuations, at.replace(CONTINUE, "$1"), 1);
41483
41488
  const lens = LENS.exec(at)?.[1];
41484
41489
  if (lens !== void 0 && commit.findings > 0) add(t.byLens, lens, commit.findings);
41490
+ if (message.marks.some((m) => m.name === "flaky")) add(t.flaky, at, 1);
41485
41491
  tallyDecision(t, open, at, subject.to);
41486
41492
  }
41487
41493
  return finish(t);
@@ -41496,6 +41502,7 @@ const totalMetrics = (all) => {
41496
41502
  t.doubleWork.naive += m.cost.doubleWork.naive;
41497
41503
  t.doubleWork.rebuild += m.cost.doubleWork.rebuild;
41498
41504
  for (const [k, v] of Object.entries(m.findings.byLens)) add(t.byLens, k, v);
41505
+ for (const [k, v] of Object.entries(m.flaky.byStep)) add(t.flaky, k, v);
41499
41506
  }
41500
41507
  return {
41501
41508
  processes: all.length,
@@ -41533,7 +41540,8 @@ const line = (label, m) => {
41533
41540
  `${label}: ${slices.decided} slices, ${slices.rebuilt} rebuilt (refactor-first ${percent}), ${slices.refactored} refactored`,
41534
41541
  `${continued} continuations${continued > 0 ? ` (${listed(m.continuations)})` : ""}`,
41535
41542
  `cost ${cost.total} (${listed(cost.byPhase)}; by model ${listed(cost.byModel) || "-"}), double work ${cost.doubleWork.total}`,
41536
- `${m.findings.total} findings${m.findings.total > 0 ? ` (${listed(m.findings.byLens)})` : ""}`
41543
+ `${m.findings.total} findings${m.findings.total > 0 ? ` (${listed(m.findings.byLens)})` : ""}`,
41544
+ `${m.flaky.total} flaky${m.flaky.total > 0 ? ` (${listed(m.flaky.byStep)})` : ""}`
41537
41545
  ].join("; ");
41538
41546
  };
41539
41547
  const runHistoryMetricsCommand = (refs, json, out) => gen(function* () {
@@ -42943,9 +42951,10 @@ const COMMAND_ROWS = [
42943
42951
  "Report how the kept processes went: slices whose naive pass",
42944
42952
  "landed or was rebuilt (the refactor-first rate), continuations",
42945
42953
  "per phase, Gtd-Cost by model and phase with the double work of",
42946
- "a rebuild, and quality-lap findings per lens — per process and",
42947
- "in total. Each <ref> is read as by history show, but a branch",
42948
- "name means every process kept from it; none means every ref.",
42954
+ "a rebuild, quality-lap findings per lens, and red checks that",
42955
+ "passed on their re-check — per process and in total. Each <ref>",
42956
+ "is read as by history show, but a branch name means every",
42957
+ "process kept from it; none means every ref.",
42949
42958
  "Reads commit subjects and trailers, never replays, so a",
42950
42959
  "changed config is no refusal. --json prints { processes, total",
42951
42960
  "} as one document; writes nothing"
package/dist/testkit.mjs CHANGED
@@ -10327,7 +10327,8 @@ const emptyTally = () => ({
10327
10327
  naive: 0,
10328
10328
  rebuild: 0
10329
10329
  },
10330
- byLens: record()
10330
+ byLens: record(),
10331
+ flaky: record()
10331
10332
  });
10332
10333
  const sum = (record) => Object.values(record).reduce((a, b) => a + b, 0);
10333
10334
  const finish = (t) => ({
@@ -10347,6 +10348,10 @@ const finish = (t) => ({
10347
10348
  findings: {
10348
10349
  total: sum(t.byLens),
10349
10350
  byLens: t.byLens
10351
+ },
10352
+ flaky: {
10353
+ total: sum(t.flaky),
10354
+ byStep: t.flaky
10350
10355
  }
10351
10356
  });
10352
10357
  const tallyCost = (t, open, costs, phase) => {
@@ -10403,6 +10408,7 @@ const processMetrics = (commits) => {
10403
10408
  if (subject.actor === "agent" && CONTINUE.test(at)) add(t.continuations, at.replace(CONTINUE, "$1"), 1);
10404
10409
  const lens = LENS.exec(at)?.[1];
10405
10410
  if (lens !== void 0 && commit.findings > 0) add(t.byLens, lens, commit.findings);
10411
+ if (message.marks.some((m) => m.name === "flaky")) add(t.flaky, at, 1);
10406
10412
  tallyDecision(t, open, at, subject.to);
10407
10413
  }
10408
10414
  return finish(t);
@@ -10417,6 +10423,7 @@ const totalMetrics = (all) => {
10417
10423
  t.doubleWork.naive += m.cost.doubleWork.naive;
10418
10424
  t.doubleWork.rebuild += m.cost.doubleWork.rebuild;
10419
10425
  for (const [k, v] of Object.entries(m.findings.byLens)) add(t.byLens, k, v);
10426
+ for (const [k, v] of Object.entries(m.flaky.byStep)) add(t.flaky, k, v);
10420
10427
  }
10421
10428
  return {
10422
10429
  processes: all.length,
@@ -10454,7 +10461,8 @@ const line = (label, m) => {
10454
10461
  `${label}: ${slices.decided} slices, ${slices.rebuilt} rebuilt (refactor-first ${percent}), ${slices.refactored} refactored`,
10455
10462
  `${continued} continuations${continued > 0 ? ` (${listed(m.continuations)})` : ""}`,
10456
10463
  `cost ${cost.total} (${listed(cost.byPhase)}; by model ${listed(cost.byModel) || "-"}), double work ${cost.doubleWork.total}`,
10457
- `${m.findings.total} findings${m.findings.total > 0 ? ` (${listed(m.findings.byLens)})` : ""}`
10464
+ `${m.findings.total} findings${m.findings.total > 0 ? ` (${listed(m.findings.byLens)})` : ""}`,
10465
+ `${m.flaky.total} flaky${m.flaky.total > 0 ? ` (${listed(m.flaky.byStep)})` : ""}`
10458
10466
  ].join("; ");
10459
10467
  };
10460
10468
  const runHistoryMetricsCommand = (refs, json, out) => gen(function* () {
@@ -11855,9 +11863,10 @@ const COMMAND_ROWS = [
11855
11863
  "Report how the kept processes went: slices whose naive pass",
11856
11864
  "landed or was rebuilt (the refactor-first rate), continuations",
11857
11865
  "per phase, Gtd-Cost by model and phase with the double work of",
11858
- "a rebuild, and quality-lap findings per lens — per process and",
11859
- "in total. Each <ref> is read as by history show, but a branch",
11860
- "name means every process kept from it; none means every ref.",
11866
+ "a rebuild, quality-lap findings per lens, and red checks that",
11867
+ "passed on their re-check — per process and in total. Each <ref>",
11868
+ "is read as by history show, but a branch name means every",
11869
+ "process kept from it; none means every ref.",
11861
11870
  "Reads commit subjects and trailers, never replays, so a",
11862
11871
  "changed config is no refusal. --json prints { processes, total",
11863
11872
  "} as one document; writes nothing"
@@ -17,10 +17,15 @@ const untilSettled = async (check, pending) => {
17
17
  };
18
18
  const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/;
19
19
  const stripStamp = (text) => text.replace(STAMP, "\n");
20
+ /** `true` when the re-check passed, else the red re-check's report — the one the escalation describes. */
21
+ const recheckReport = async (health) => {
22
+ if (health.recheck === void 0) return void 0;
23
+ return await health.recheck() || stripStamp(read(health.report) ?? "");
24
+ };
20
25
  /**
21
26
  * Loop until the suite is green: check, and on red run `fix` — escalating
22
27
  * first once `fixCap` fixes are spent, or `sameFailure` calls the failure
23
- * identical to the previous round's.
28
+ * identical to the previous round's, unless `recheck` passes.
24
29
  */
25
30
  const healthy = async (fix, health) => {
26
31
  let fixes = health.fixesSoFar ?? 0;
@@ -34,6 +39,12 @@ const healthy = async (fix, health) => {
34
39
  const stuck = previous !== void 0 && await health.sameFailure(previous, current);
35
40
  previous = current;
36
41
  if (stuck || fixes >= health.fixCap) {
42
+ const rechecked = await recheckReport(health);
43
+ if (rechecked === true) {
44
+ health.escalations.rounds = 0;
45
+ return;
46
+ }
47
+ previous = rechecked ?? previous;
37
48
  await escalation(health.escalations, health.escalation);
38
49
  fixes = 0;
39
50
  }
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { Dt as registryEntry, Ot as requireFlag, S as coder, St as envDefaults, Tt as flagReferences, i as skills, n as summary, o as flagShipped, r as access, t as steering, xt as defaults } from "../chunks/reserved-C3PgP7XB.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-Bkxki2nK.mjs";
2
+ import { Dt as registryEntry, Ot as requireFlag, S as coder, St as envDefaults, Tt as flagReferences, i as skills, n as summary, o as flagShipped, r as access, t as steering, xt as defaults } from "../chunks/reserved-B6tTYjeb.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-_CZTP7Hu.mjs";
4
4
  //#region src/workflows/cleanup/cleanup.ts
5
5
  const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
6
6
  const cleanupFlow = async () => {
@@ -1,7 +1,7 @@
1
1
  import { agent, answered, applyScript, changes, changesSince, check, codeChanges, env, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, matches, numeric, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, slice, start, vars, wrote } from "../flows.mjs";
2
2
  import { lensEntriesOf, review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as naiveApplyFailure, A as agentWithSkills, B as coverageQuestion, C as fixCheck, Ct as testFileGlobs, D as reviewMissing, E as planner, F as buildReviewTriageMessage, G as designSystem, H as criterionLine, I as builderSystem, K as designTriagePrompt, L as clearCriteriaMessage, M as buildReviewAwaitAfterDeferMessage, N as buildReviewCollectingPrompt, P as buildReviewFixNitsPrompt, Q as implementFixPrompt, R as clearCriteriaQuestion, S as coder, St as envDefaults, T as fixRisks, U as deferNotesScript, V as coveringPrompt, W as designGateAnswerMessage, X as idleMessage, Z as implementBuildingPrompt, _ as REFACTOR_NOTE, _t as startGateBlockedMessage, a as flagModeOn, at as refactorDroppedRefusal, b as SLICES, bt as unwindFailure, ct as refactorRules, d as guarded, et as naivePassPrompt, f as healthy, ft as reviewNotesCapture, g as REFACTOR, gt as slicingPrompt, h as QUALITY, ht as seamSteerMessage, i as skills, it as refactorBuildingPrompt, j as buildReviewAnswerQuestionsPrompt, k as reviewing, lt as reviewEditNotesCapture, m as HANDOFF, mt as seamByHandMessage, n as summary, nt as oversizedMessage, ot as refactorFixPrompt, p as FEEDBACK, pt as reviewerSystem, r as access, rt as qualityUnresolvedMessage, s as shipped, st as refactorNotePrompt, t as steering, tt as normaliseCriteriaScript, u as gate, ut as reviewEditsCapture, v as REQUIREMENTS, vt as uncoveredMessage, x as awaitReview, xt as defaults, y as REVIEW, yt as unwindFailedMessage, z as coverageMessage } from "../chunks/reserved-C3PgP7XB.mjs";
4
- import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-Bkxki2nK.mjs";
3
+ import { $ as naiveApplyFailure, A as agentWithSkills, B as coverageQuestion, C as fixCheck, Ct as testFileGlobs, D as reviewMissing, E as planner, F as buildReviewTriageMessage, G as designSystem, H as criterionLine, I as builderSystem, K as designTriagePrompt, L as clearCriteriaMessage, M as buildReviewAwaitAfterDeferMessage, N as buildReviewCollectingPrompt, P as buildReviewFixNitsPrompt, Q as implementFixPrompt, R as clearCriteriaQuestion, S as coder, St as envDefaults, T as fixRisks, U as deferNotesScript, V as coveringPrompt, W as designGateAnswerMessage, X as idleMessage, Z as implementBuildingPrompt, _ as REFACTOR_NOTE, _t as startGateBlockedMessage, a as flagModeOn, at as refactorDroppedRefusal, b as SLICES, bt as unwindFailure, ct as refactorRules, d as guarded, et as naivePassPrompt, f as healthy, ft as reviewNotesCapture, g as REFACTOR, gt as slicingPrompt, h as QUALITY, ht as seamSteerMessage, i as skills, it as refactorBuildingPrompt, j as buildReviewAnswerQuestionsPrompt, k as reviewing, lt as reviewEditNotesCapture, m as HANDOFF, mt as seamByHandMessage, n as summary, nt as oversizedMessage, ot as refactorFixPrompt, p as FEEDBACK, pt as reviewerSystem, r as access, rt as qualityUnresolvedMessage, s as shipped, st as refactorNotePrompt, t as steering, tt as normaliseCriteriaScript, u as gate, ut as reviewEditsCapture, v as REQUIREMENTS, vt as uncoveredMessage, x as awaitReview, xt as defaults, y as REVIEW, yt as unwindFailedMessage, z as coverageMessage } from "../chunks/reserved-B6tTYjeb.mjs";
4
+ import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-_CZTP7Hu.mjs";
5
5
  import { posix } from "node:path";
6
6
  //#region src/workflows/shared/fences.ts
7
7
  const markFenced = (text) => {
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
2
  import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
3
- import { A as agentWithSkills, J as fixReviewNotesPrompt, S as coder, St as envDefaults, c as baseline, i as skills, n as summary, q as finisherSystem, r as access, s as shipped, t as steering, xt as defaults, y as REVIEW } from "../chunks/reserved-C3PgP7XB.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-Bkxki2nK.mjs";
3
+ import { A as agentWithSkills, J as fixReviewNotesPrompt, S as coder, St as envDefaults, c as baseline, i as skills, n as summary, q as finisherSystem, r as access, s as shipped, t as steering, xt as defaults, y as REVIEW } from "../chunks/reserved-B6tTYjeb.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-_CZTP7Hu.mjs";
5
5
  //#region src/workflows/fix/fix.ts
6
6
  /**
7
7
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,6 +1,6 @@
1
1
  import { start } from "../flows.mjs";
2
- import { St as envDefaults, dt as reviewGateBlockedMessage, i as skills, n as summary, r as access, s as shipped, t as steering, u as gate, xt as defaults } from "../chunks/reserved-C3PgP7XB.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-Bkxki2nK.mjs";
2
+ import { St as envDefaults, dt as reviewGateBlockedMessage, i as skills, n as summary, r as access, s as shipped, t as steering, u as gate, xt as defaults } from "../chunks/reserved-B6tTYjeb.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-_CZTP7Hu.mjs";
4
4
  //#region src/workflows/review/review.ts
5
5
  /**
6
6
  * Pure review of everything since `reviewBase`: a green gate, the quality
@@ -1,5 +1,5 @@
1
1
  import { agent, changesSince, glob, head, mark, read, refuse, removeScript, run, scope, vars } from "../flows.mjs";
2
- import { Et as flipProblems, Ot as requireFlag, S as coder, St as envDefaults, i as skills, n as summary, o as flagShipped, p as FEEDBACK, r as access, t as steering, u as gate, wt as flagMentions, xt as defaults } from "../chunks/reserved-C3PgP7XB.mjs";
2
+ import { Et as flipProblems, Ot as requireFlag, S as coder, St as envDefaults, i as skills, n as summary, o as flagShipped, p as FEEDBACK, r as access, t as steering, u as gate, wt as flagMentions, xt as defaults } from "../chunks/reserved-B6tTYjeb.mjs";
3
3
  //#region src/workflows/rollout/rollout.ts
4
4
  const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
5
5
  const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";
@@ -451,10 +451,15 @@ const escalation$1 = async (count, steps) => {
451
451
  };
452
452
  const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/;
453
453
  const stripStamp = (text) => text.replace(STAMP, "\n");
454
+ /** `true` when the re-check passed, else the red re-check's report — the one the escalation describes. */
455
+ const recheckReport = async (health) => {
456
+ if (health.recheck === void 0) return void 0;
457
+ return await health.recheck() || stripStamp(read(health.report) ?? "");
458
+ };
454
459
  /**
455
460
  * Loop until the suite is green: check, and on red run `fix` — escalating
456
461
  * first once `fixCap` fixes are spent, or `sameFailure` calls the failure
457
- * identical to the previous round's.
462
+ * identical to the previous round's, unless `recheck` passes.
458
463
  */
459
464
  const healthy$1 = async (fix, health) => {
460
465
  let fixes = health.fixesSoFar ?? 0;
@@ -468,6 +473,12 @@ const healthy$1 = async (fix, health) => {
468
473
  const stuck = previous !== void 0 && await health.sameFailure(previous, current);
469
474
  previous = current;
470
475
  if (stuck || fixes >= health.fixCap) {
476
+ const rechecked = await recheckReport(health);
477
+ if (rechecked === true) {
478
+ health.escalations.rounds = 0;
479
+ return;
480
+ }
481
+ previous = rechecked ?? previous;
471
482
  await escalation$1(health.escalations, health.escalation);
472
483
  fixes = 0;
473
484
  }
@@ -18385,6 +18396,19 @@ const baseline = (name, label = "Checking the baseline") => check(name, env.test
18385
18396
  report: FEEDBACK,
18386
18397
  label
18387
18398
  });
18399
+ /**
18400
+ * Re-run a red check as step `name` before a person sees it. Green records
18401
+ * `Gtd-Mark: flaky=<red>` on the re-check's landing and resolves `true`.
18402
+ */
18403
+ const recheck = async (name, red, options = {}) => {
18404
+ const green = await check(name, env.testCommand ?? "", {
18405
+ ...options,
18406
+ report: FEEDBACK,
18407
+ label: "Re-running checks"
18408
+ });
18409
+ if (green) mark("flaky", red);
18410
+ return green;
18411
+ };
18388
18412
  const escalationSteps = (rules) => ({
18389
18413
  limit: 2,
18390
18414
  describe: () => describeEscalation(rules),
@@ -18435,15 +18459,17 @@ const sameFailure = async (previous, current) => {
18435
18459
  });
18436
18460
  return comparable && truncated.length === 0 && answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity));
18437
18461
  };
18462
+ const healthSweep = (options) => [ESCALATION, ...options.sweepOnGreen ?? []];
18438
18463
  const runSuite = (options) => check("health.check", env.testCommand ?? "", {
18439
18464
  report: FEEDBACK,
18440
18465
  label: "Running checks",
18441
- sweepOnGreen: [ESCALATION, ...options.sweepOnGreen ?? []]
18466
+ sweepOnGreen: healthSweep(options)
18442
18467
  });
18443
18468
  /**
18444
18469
  * Loop until the suite is green: check, and on red run `fix` — escalating
18445
18470
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
18446
- * identical to the previous round's.
18471
+ * identical to the previous round's. An escalation re-runs the suite once
18472
+ * first; a green re-run ends the loop.
18447
18473
  */
18448
18474
  const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
18449
18475
  check: () => runSuite(options),
@@ -18452,14 +18478,16 @@ const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
18452
18478
  fixesSoFar: options.fixesSoFar ?? 0,
18453
18479
  sameFailure,
18454
18480
  escalations: options.escalations ?? { rounds: 0 },
18481
+ recheck: () => recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
18455
18482
  escalation: guardedSteps(options.guard, options.describeGuard, options.guardRests, options.rules)
18456
18483
  });
18457
18484
  /**
18458
- * Hold the process at `<name>.blocked` until the suite is green. `guard` runs
18485
+ * Hold the process at `<name>.blocked` until the suite is green, re-running a
18486
+ * red check once as `<name>.recheck` before resting. `guard` runs
18459
18487
  * after each `blocked` landing only — never after a check, whose refusal would
18460
18488
  * re-run the same script forever.
18461
18489
  */
18462
- const gate = (name, message, guard) => gate$1(name, () => baseline("check"), guarded(() => human("blocked", {
18490
+ const gate = (name, message, guard) => gate$1(name, async () => await baseline("check") || recheck("recheck", `${name}.check`), guarded(() => human("blocked", {
18463
18491
  message,
18464
18492
  label: "Baseline is red",
18465
18493
  file: FEEDBACK
@@ -39578,4 +39606,4 @@ var workflows_default = feature;
39578
39606
  */
39579
39607
  const doors = {};
39580
39608
  //#endregion
39581
- export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, HANDOFF, QUALITY, REFACTOR, REFACTOR_NOTE, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, SLICES, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeOut, closeReview, coder, collecting, criteriaOf, criterionIds, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, holdsCallSites, implement, isTestFile, judgeSeam, lensEntriesOf, lensOf, lensesOf, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, tail, threads, triage, unwind };
39609
+ export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, HANDOFF, QUALITY, REFACTOR, REFACTOR_NOTE, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, SLICES, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeOut, closeReview, coder, collecting, criteriaOf, criterionIds, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, holdsCallSites, implement, isTestFile, judgeSeam, lensEntriesOf, lensOf, lensesOf, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, recheck, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, tail, threads, triage, unwind };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "38.2.0",
3
+ "version": "39.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/src/cli/Cli.ts CHANGED
@@ -838,9 +838,10 @@ const COMMAND_ROWS: readonly CommandRow[] = [
838
838
  "Report how the kept processes went: slices whose naive pass",
839
839
  "landed or was rebuilt (the refactor-first rate), continuations",
840
840
  "per phase, Gtd-Cost by model and phase with the double work of",
841
- "a rebuild, and quality-lap findings per lens — per process and",
842
- "in total. Each <ref> is read as by history show, but a branch",
843
- "name means every process kept from it; none means every ref.",
841
+ "a rebuild, quality-lap findings per lens, and red checks that",
842
+ "passed on their re-check — per process and in total. Each <ref>",
843
+ "is read as by history show, but a branch name means every",
844
+ "process kept from it; none means every ref.",
844
845
  "Reads commit subjects and trailers, never replays, so a",
845
846
  "changed config is no refusal. --json prints { processes, total",
846
847
  "} as one document; writes nothing",
@@ -163,6 +163,37 @@ describe("processMetrics", () => {
163
163
  })
164
164
  })
165
165
 
166
+ describe("flaky re-checks", () => {
167
+ it("counts each flaky re-check under its own full step name", () => {
168
+ const m = processMetrics([
169
+ {
170
+ message:
171
+ "gtd(check): start-gate.recheck → design.triage\n\nGtd-Mark: flaky=start-gate.check",
172
+ findings: 0,
173
+ },
174
+ {
175
+ message: "gtd(check): build.health.recheck → idle\n\nGtd-Mark: flaky=health.check",
176
+ findings: 0,
177
+ },
178
+ {
179
+ message:
180
+ "gtd(check): implement.health.recheck → implement.closing\n\nGtd-Mark: flaky=health.check",
181
+ findings: 0,
182
+ },
183
+ { message: "gtd(agent): seam.naive → seam.check\n\nGtd-Mark: covers=AC-1", findings: 0 },
184
+ { message: "fix: hand-made\n\nGtd-Mark: flaky=x.check", findings: 0 },
185
+ ])
186
+ expect(m.flaky).toEqual({
187
+ total: 3,
188
+ byStep: { "start-gate.recheck": 1, "build.health.recheck": 1, "implement.health.recheck": 1 },
189
+ })
190
+ expect(totalMetrics([m, m]).flaky).toEqual({
191
+ total: 6,
192
+ byStep: { "start-gate.recheck": 2, "build.health.recheck": 2, "implement.health.recheck": 2 },
193
+ })
194
+ })
195
+ })
196
+
166
197
  describe("totalMetrics", () => {
167
198
  it("adds processes and recomputes the rate over every decision", () => {
168
199
  const a = processMetrics([
@@ -45,6 +45,8 @@ export interface Metrics {
45
45
  }
46
46
  }
47
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>> }
48
50
  }
49
51
 
50
52
  type Phase = keyof Metrics["cost"]["byPhase"]
@@ -87,6 +89,7 @@ interface Tally {
87
89
  readonly byPhase: Record<Phase, number>
88
90
  readonly doubleWork: { naive: number; rebuild: number }
89
91
  readonly byLens: Record<string, number>
92
+ readonly flaky: Record<string, number>
90
93
  }
91
94
 
92
95
  const emptyTally = (): Tally => ({
@@ -96,6 +99,7 @@ const emptyTally = (): Tally => ({
96
99
  byPhase: { naive: 0, refactor: 0, implement: 0, other: 0 },
97
100
  doubleWork: { naive: 0, rebuild: 0 },
98
101
  byLens: record(),
102
+ flaky: record(),
99
103
  })
100
104
 
101
105
  const sum = (record: Readonly<Record<string, number>>): number =>
@@ -116,6 +120,7 @@ const finish = (t: Tally): Metrics => ({
116
120
  },
117
121
  },
118
122
  findings: { total: sum(t.byLens), byLens: t.byLens },
123
+ flaky: { total: sum(t.flaky), byStep: t.flaky },
119
124
  })
120
125
 
121
126
  /** The open slice: its naive cost so far, and whether its implement turns rebuild it. */
@@ -186,6 +191,7 @@ export const processMetrics = (commits: readonly MetricCommit[]): Metrics => {
186
191
  add(t.continuations, at.replace(CONTINUE, "$1"), 1)
187
192
  const lens = LENS.exec(at)?.[1]
188
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)
189
195
  tallyDecision(t, open, at, subject.to)
190
196
  }
191
197
  return finish(t)
@@ -202,6 +208,7 @@ export const totalMetrics = (all: readonly Metrics[]): Metrics & { readonly proc
202
208
  t.doubleWork.naive += m.cost.doubleWork.naive
203
209
  t.doubleWork.rebuild += m.cost.doubleWork.rebuild
204
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)
205
212
  }
206
213
  return { processes: all.length, ...finish(t) }
207
214
  }
@@ -257,6 +264,7 @@ const line = (label: string, m: Metrics): string => {
257
264
  `${continued} continuations${continued > 0 ? ` (${listed(m.continuations)})` : ""}`,
258
265
  `cost ${cost.total} (${listed(cost.byPhase)}; by model ${listed(cost.byModel) || "-"}), double work ${cost.doubleWork.total}`,
259
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)})` : ""}`,
260
268
  ].join("; ")
261
269
  }
262
270
 
@@ -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
  )
@@ -0,0 +1,60 @@
1
+ @inmem
2
+ Feature: a red check is re-run once before it reaches a person
3
+
4
+ Before the fix loop escalates, `healthy` re-runs the suite as
5
+ `health.recheck`. Green there ends the loop as if the check had passed, and
6
+ the re-check's landing records `Gtd-Mark: flaky=health.check`. Red again
7
+ escalates as before.
8
+
9
+ Background:
10
+ Given a gtd config with:
11
+ """
12
+ import { healthy } from "@pmelab/gtd/workflows"
13
+ import { agent, human } from "@pmelab/gtd/flows"
14
+
15
+ export const held = async () => {
16
+ await agent("first", "go")
17
+ await healthy(() => agent("fix", "go"))
18
+ await human("done")
19
+ }
20
+ export default held
21
+ """
22
+ And step "first" writes "src/a.ts" with:
23
+ """
24
+ export const a = 1
25
+ """
26
+ And step "health.check" writes ".gtd/FEEDBACK.md" with:
27
+ """
28
+ 1 test failing
29
+ """
30
+ And step "fix" deletes ".gtd/FEEDBACK.md"
31
+ And judge step "health.judge" answers:
32
+ """
33
+ [{"id":"verdict","answer":"progress","p":0.9}]
34
+ """
35
+
36
+ Scenario: a re-check that passes ends the loop without a person, recorded as flaky
37
+ Given step "health.recheck" deletes ".gtd/FEEDBACK.md"
38
+ When gtd starts workflow "held"
39
+ And the process is driven
40
+ Then the process rests at "done"
41
+ And the commit subjects end with:
42
+ """
43
+ gtd(agent): fix → health.check
44
+ gtd(check): health.check → health.judge
45
+ gtd(judge): health.judge → health.recheck
46
+ gtd(check): health.recheck → done
47
+ """
48
+ And the last commit body contains "Gtd-Mark: flaky=health.check"
49
+ And ".gtd/FEEDBACK.md" does not exist
50
+
51
+ Scenario: a re-check that stays red escalates, with nothing recorded as flaky
52
+ Given step "health.recheck" writes ".gtd/FEEDBACK.md" with:
53
+ """
54
+ 1 test still failing
55
+ """
56
+ When gtd starts workflow "held"
57
+ And the process is driven
58
+ Then the process rests at "health.describe"
59
+ And the last commit subject is "gtd(check): health.recheck → health.describe"
60
+ And no commit body contains "Gtd-Mark: flaky="