@pmelab/gtd 40.0.0 → 41.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": "40.0.0",
3
+ "version": "41.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
@@ -506,8 +506,9 @@ noted in steps 3 and 4 below.
506
506
  as-is, handing that document to the next fix turn as its instruction. Two
507
507
  such rounds with no fix is the terminal stop: the last analysis stays on disk
508
508
  instead of a third being written, and editing it is what gets the next
509
- attempt anywhere new. Before any escalation or red-baseline stop the suite
510
- runs once more; a pass there continues without you and is recorded as
509
+ attempt anywhere new. A loop the retry judge still calls progress at the cap
510
+ gets one extra fix turn first. Before any escalation or red-baseline stop the
511
+ suite runs once more; a pass there continues without you and is recorded as
511
512
  `Gtd-Mark: flaky=<check>` (see
512
513
  [Re-check before a person](docs/configuration.md#re-check-before-a-person)).
513
514
 
@@ -1632,7 +1632,7 @@ const retryQuestion = (comparable) => comparable ? {
1632
1632
  instructions: "state carries tailsNotComparable: one of the two check reports came back empty. There is nothing to compare here; answer from criteria alone. `identical` is FORBIDDEN here.",
1633
1633
  criteria: "new-failure: a materially different failure than before. progress: still red, but closer to green — the default if genuinely unsure."
1634
1634
  };
1635
- const sameFailure = async (previous, current) => {
1635
+ const compare = async (previous, current) => {
1636
1636
  const comparable = current.trim() !== "" && previous.trim() !== "";
1637
1637
  const { answers, truncated } = await judge("health.judge", {
1638
1638
  questions: [retryQuestion(comparable)],
@@ -1643,7 +1643,10 @@ const sameFailure = async (previous, current) => {
1643
1643
  message: healthJudgeMessage(),
1644
1644
  label: "Judging the retry"
1645
1645
  });
1646
- return comparable && truncated.length === 0 && answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity));
1646
+ if (!comparable || truncated.length > 0) return void 0;
1647
+ const minP = numeric(vars.judgeIdenticalMinP, Infinity);
1648
+ if (answered(answers.verdict, "identical", minP)) return "identical";
1649
+ return answered(answers.verdict, "progress", minP) ? "progress" : void 0;
1647
1650
  };
1648
1651
  const healthSweep = (options) => [ESCALATION, ...options.sweepOnGreen ?? []];
1649
1652
  const runSuite = (options) => check("health.check", env.testCommand ?? "", {
@@ -1654,7 +1657,8 @@ const runSuite = (options) => check("health.check", env.testCommand ?? "", {
1654
1657
  /**
1655
1658
  * Loop until the suite is green: check, and on red run `fix` — escalating
1656
1659
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
1657
- * identical to the previous round's. An escalation re-runs the suite once
1660
+ * identical to the previous round's. A judge calling it progress at the cap
1661
+ * buys one extra fix per run of fixes. An escalation re-runs the suite once
1658
1662
  * first; a green re-run ends the loop.
1659
1663
  */
1660
1664
  const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
@@ -1662,7 +1666,7 @@ const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
1662
1666
  report: FEEDBACK,
1663
1667
  fixCap: 3,
1664
1668
  fixesSoFar: options.fixesSoFar ?? 0,
1665
- sameFailure,
1669
+ compare,
1666
1670
  escalations: options.escalations ?? { rounds: 0 },
1667
1671
  recheck: () => recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
1668
1672
  escalation: guardedSteps(options.guard, options.describeGuard, options.guardRests, options.rules)
@@ -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-Qt5FM6Ia.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-Ch4LLrEj.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";
@@ -22,13 +22,33 @@ const recheckReport = async (health) => {
22
22
  if (health.recheck === void 0) return void 0;
23
23
  return await health.recheck() || stripStamp(read(health.report) ?? "");
24
24
  };
25
+ /** Escalate unless the re-check passes: `true` then, else the report the next round compares against. */
26
+ const escalated = async (health, current) => {
27
+ const rechecked = await recheckReport(health);
28
+ if (rechecked === true) {
29
+ health.escalations.rounds = 0;
30
+ return true;
31
+ }
32
+ await escalation(health.escalations, health.escalation);
33
+ return rechecked ?? current;
34
+ };
35
+ const nextMove = async (health, fixes, cap, previous, current) => {
36
+ const capped = fixes >= cap;
37
+ if (capped && cap > health.fixCap) return "escalate";
38
+ const verdict = previous === void 0 ? void 0 : await health.compare(previous, current);
39
+ if (capped && verdict === "progress") return "extend";
40
+ return verdict === "identical" || capped ? "escalate" : void 0;
41
+ };
25
42
  /**
26
43
  * Loop until the suite is green: check, and on red run `fix` — escalating
27
- * first once `fixCap` fixes are spent, or `sameFailure` calls the failure
28
- * identical to the previous round's, unless `recheck` passes.
44
+ * first once `fixCap` fixes are spent, or `compare` calls the failure
45
+ * identical to the previous round's, unless `recheck` passes. A round that
46
+ * reaches the cap while `compare` calls it progress raises the cap by one fix,
47
+ * once per run of fixes between escalations.
29
48
  */
30
49
  const healthy = async (fix, health) => {
31
50
  let fixes = health.fixesSoFar ?? 0;
51
+ let cap = health.fixCap;
32
52
  let previous;
33
53
  for (;;) {
34
54
  if (await health.check()) {
@@ -36,17 +56,15 @@ const healthy = async (fix, health) => {
36
56
  return;
37
57
  }
38
58
  const current = stripStamp(read(health.report) ?? "");
39
- const stuck = previous !== void 0 && await health.sameFailure(previous, current);
59
+ const move = await nextMove(health, fixes, cap, previous, current);
40
60
  previous = current;
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;
48
- await escalation(health.escalations, health.escalation);
61
+ if (move === "extend") cap++;
62
+ else if (move === "escalate") {
63
+ const after = await escalated(health, current);
64
+ if (after === true) return;
65
+ previous = after;
49
66
  fixes = 0;
67
+ cap = health.fixCap;
50
68
  }
51
69
  fixes++;
52
70
  await fix();
@@ -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-Qt5FM6Ia.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-tAHx_AHe.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-Ch4LLrEj.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-lY1UmJrU.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-Qt5FM6Ia.mjs";
4
- import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-tAHx_AHe.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-Ch4LLrEj.mjs";
4
+ import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-lY1UmJrU.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-Qt5FM6Ia.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-tAHx_AHe.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-Ch4LLrEj.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-lY1UmJrU.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-Qt5FM6Ia.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-tAHx_AHe.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-Ch4LLrEj.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-lY1UmJrU.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-Qt5FM6Ia.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-Ch4LLrEj.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.";
@@ -456,13 +456,33 @@ const recheckReport = async (health) => {
456
456
  if (health.recheck === void 0) return void 0;
457
457
  return await health.recheck() || stripStamp(read(health.report) ?? "");
458
458
  };
459
+ /** Escalate unless the re-check passes: `true` then, else the report the next round compares against. */
460
+ const escalated = async (health, current) => {
461
+ const rechecked = await recheckReport(health);
462
+ if (rechecked === true) {
463
+ health.escalations.rounds = 0;
464
+ return true;
465
+ }
466
+ await escalation$1(health.escalations, health.escalation);
467
+ return rechecked ?? current;
468
+ };
469
+ const nextMove = async (health, fixes, cap, previous, current) => {
470
+ const capped = fixes >= cap;
471
+ if (capped && cap > health.fixCap) return "escalate";
472
+ const verdict = previous === void 0 ? void 0 : await health.compare(previous, current);
473
+ if (capped && verdict === "progress") return "extend";
474
+ return verdict === "identical" || capped ? "escalate" : void 0;
475
+ };
459
476
  /**
460
477
  * Loop until the suite is green: check, and on red run `fix` — escalating
461
- * first once `fixCap` fixes are spent, or `sameFailure` calls the failure
462
- * identical to the previous round's, unless `recheck` passes.
478
+ * first once `fixCap` fixes are spent, or `compare` calls the failure
479
+ * identical to the previous round's, unless `recheck` passes. A round that
480
+ * reaches the cap while `compare` calls it progress raises the cap by one fix,
481
+ * once per run of fixes between escalations.
463
482
  */
464
483
  const healthy$1 = async (fix, health) => {
465
484
  let fixes = health.fixesSoFar ?? 0;
485
+ let cap = health.fixCap;
466
486
  let previous;
467
487
  for (;;) {
468
488
  if (await health.check()) {
@@ -470,17 +490,15 @@ const healthy$1 = async (fix, health) => {
470
490
  return;
471
491
  }
472
492
  const current = stripStamp(read(health.report) ?? "");
473
- const stuck = previous !== void 0 && await health.sameFailure(previous, current);
493
+ const move = await nextMove(health, fixes, cap, previous, current);
474
494
  previous = current;
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;
482
- await escalation$1(health.escalations, health.escalation);
495
+ if (move === "extend") cap++;
496
+ else if (move === "escalate") {
497
+ const after = await escalated(health, current);
498
+ if (after === true) return;
499
+ previous = after;
483
500
  fixes = 0;
501
+ cap = health.fixCap;
484
502
  }
485
503
  fixes++;
486
504
  await fix();
@@ -18448,7 +18466,7 @@ const retryQuestion = (comparable) => comparable ? {
18448
18466
  instructions: "state carries tailsNotComparable: one of the two check reports came back empty. There is nothing to compare here; answer from criteria alone. `identical` is FORBIDDEN here.",
18449
18467
  criteria: "new-failure: a materially different failure than before. progress: still red, but closer to green — the default if genuinely unsure."
18450
18468
  };
18451
- const sameFailure = async (previous, current) => {
18469
+ const compare$1 = async (previous, current) => {
18452
18470
  const comparable = current.trim() !== "" && previous.trim() !== "";
18453
18471
  const { answers, truncated } = await judge("health.judge", {
18454
18472
  questions: [retryQuestion(comparable)],
@@ -18459,7 +18477,10 @@ const sameFailure = async (previous, current) => {
18459
18477
  message: healthJudgeMessage(),
18460
18478
  label: "Judging the retry"
18461
18479
  });
18462
- return comparable && truncated.length === 0 && answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity));
18480
+ if (!comparable || truncated.length > 0) return void 0;
18481
+ const minP = numeric(vars.judgeIdenticalMinP, Infinity);
18482
+ if (answered(answers.verdict, "identical", minP)) return "identical";
18483
+ return answered(answers.verdict, "progress", minP) ? "progress" : void 0;
18463
18484
  };
18464
18485
  const healthSweep = (options) => [ESCALATION, ...options.sweepOnGreen ?? []];
18465
18486
  const runSuite = (options) => check("health.check", env.testCommand ?? "", {
@@ -18470,7 +18491,8 @@ const runSuite = (options) => check("health.check", env.testCommand ?? "", {
18470
18491
  /**
18471
18492
  * Loop until the suite is green: check, and on red run `fix` — escalating
18472
18493
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
18473
- * identical to the previous round's. An escalation re-runs the suite once
18494
+ * identical to the previous round's. A judge calling it progress at the cap
18495
+ * buys one extra fix per run of fixes. An escalation re-runs the suite once
18474
18496
  * first; a green re-run ends the loop.
18475
18497
  */
18476
18498
  const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
@@ -18478,7 +18500,7 @@ const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
18478
18500
  report: FEEDBACK,
18479
18501
  fixCap: 3,
18480
18502
  fixesSoFar: options.fixesSoFar ?? 0,
18481
- sameFailure,
18503
+ compare: compare$1,
18482
18504
  escalations: options.escalations ?? { rounds: 0 },
18483
18505
  recheck: () => recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
18484
18506
  escalation: guardedSteps(options.guard, options.describeGuard, options.guardRests, options.rules)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "40.0.0",
3
+ "version": "41.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": {
@@ -32,6 +32,8 @@ export const untilSettled = async (
32
32
  }
33
33
  }
34
34
 
35
+ export type RetryVerdict = "identical" | "progress"
36
+
35
37
  export interface Health {
36
38
  /** One counted run of the suite; resolves `true` when green. */
37
39
  readonly check: () => Promise<boolean>
@@ -39,8 +41,8 @@ export interface Health {
39
41
  readonly report: string
40
42
  readonly fixCap: number
41
43
  readonly fixesSoFar?: number
42
- /** Whether two consecutive red reports are the same failure; `true` escalates at once. */
43
- readonly sameFailure: (previous: string, current: string) => Promise<boolean>
44
+ /** How a red report compares to the previous round's: `identical` escalates at once, `progress` at the cap earns one extra fix. */
45
+ readonly compare: (previous: string, current: string) => Promise<RetryVerdict | undefined>
44
46
  /** Shared with other callers in the same run of red checks. */
45
47
  readonly escalations: EscalationCount
46
48
  readonly escalation: EscalationSteps
@@ -58,13 +60,42 @@ const recheckReport = async (health: Health): Promise<true | string | undefined>
58
60
  return (await health.recheck()) || stripStamp(read(health.report) ?? "")
59
61
  }
60
62
 
63
+ /** Escalate unless the re-check passes: `true` then, else the report the next round compares against. */
64
+ const escalated = async (health: Health, current: string): Promise<true | string> => {
65
+ const rechecked = await recheckReport(health)
66
+ if (rechecked === true) {
67
+ health.escalations.rounds = 0
68
+ return true
69
+ }
70
+ await escalation(health.escalations, health.escalation)
71
+ return rechecked ?? current
72
+ }
73
+
74
+ const nextMove = async (
75
+ health: Health,
76
+ fixes: number,
77
+ cap: number,
78
+ previous: string | undefined,
79
+ current: string,
80
+ ): Promise<"extend" | "escalate" | undefined> => {
81
+ const capped = fixes >= cap
82
+ // Past a raised cap the loop escalates whatever the judge would answer.
83
+ if (capped && cap > health.fixCap) return "escalate"
84
+ const verdict = previous === undefined ? undefined : await health.compare(previous, current)
85
+ if (capped && verdict === "progress") return "extend"
86
+ return verdict === "identical" || capped ? "escalate" : undefined
87
+ }
88
+
61
89
  /**
62
90
  * Loop until the suite is green: check, and on red run `fix` — escalating
63
- * first once `fixCap` fixes are spent, or `sameFailure` calls the failure
64
- * identical to the previous round's, unless `recheck` passes.
91
+ * first once `fixCap` fixes are spent, or `compare` calls the failure
92
+ * identical to the previous round's, unless `recheck` passes. A round that
93
+ * reaches the cap while `compare` calls it progress raises the cap by one fix,
94
+ * once per run of fixes between escalations.
65
95
  */
66
96
  export const healthy = async (fix: () => Promise<void>, health: Health): Promise<void> => {
67
97
  let fixes = health.fixesSoFar ?? 0
98
+ let cap = health.fixCap
68
99
  let previous: string | undefined
69
100
  for (;;) {
70
101
  if (await health.check()) {
@@ -72,17 +103,15 @@ export const healthy = async (fix: () => Promise<void>, health: Health): Promise
72
103
  return
73
104
  }
74
105
  const current = stripStamp(read(health.report) ?? "")
75
- const stuck = previous !== undefined && (await health.sameFailure(previous, current))
106
+ const move = await nextMove(health, fixes, cap, previous, current)
76
107
  previous = current
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
84
- await escalation(health.escalations, health.escalation)
108
+ if (move === "extend") cap++
109
+ else if (move === "escalate") {
110
+ const after = await escalated(health, current)
111
+ if (after === true) return
112
+ previous = after
85
113
  fixes = 0
114
+ cap = health.fixCap
86
115
  }
87
116
  fixes++
88
117
  await fix()
@@ -116,3 +116,33 @@ describe("healthy describeGuard", () => {
116
116
  ).rejects.toThrow("guarded: nope")
117
117
  })
118
118
  })
119
+
120
+ describe("healthy at the fix cap", () => {
121
+ // Blank reports are not comparable; at an 8-byte budget the cut trims both.
122
+ it.each([
123
+ ["could not compare", "", (n: number) => "\n".repeat(n)],
124
+ ["saw only cut", `, judgeBudgetBytes: "8"`, (n: number) => `a long failing report ${n}\n`],
125
+ ])("grants no extra turn on progress the judge %s", async (_, extra, report) => {
126
+ let runs = 0
127
+ const repo = testRepo({
128
+ workflow: [
129
+ `import { healthy } from "@pmelab/gtd/workflows"`,
130
+ `import { agent } from "@pmelab/gtd/flows"`,
131
+ `export const defaults = { judgeIdenticalMinP: "0.7"${extra} }`,
132
+ `export const held = async () => { await agent("first", "go"); await healthy(() => agent("fix", "go")) }`,
133
+ `export default held`,
134
+ ].join("\n"),
135
+ env: { GTD_TEST_COMMAND: "true" },
136
+ })
137
+ await repo.start("held")
138
+ const rest = await repo.drive({
139
+ first: (_, r) => r.write("start", "1\n"),
140
+ "health.check": (_, r) => r.write(".gtd/FEEDBACK.md", report(++runs)),
141
+ "health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
142
+ fix: (_, r) => r.write("f", `${runs}\n`),
143
+ "health.recheck": (_, r) => r.write(".gtd/FEEDBACK.md", "red again\n"),
144
+ })
145
+ expect(rest.state).toBe("health.describe")
146
+ expect(repo.subjects().filter((s) => s.startsWith("gtd(agent): fix")).length).toBe(3)
147
+ })
148
+ })
@@ -105,7 +105,10 @@ export const retryQuestion = (comparable: boolean): JudgeQuestion =>
105
105
  "new-failure: a materially different failure than before. progress: still red, but closer to green — the default if genuinely unsure.",
106
106
  }
107
107
 
108
- const sameFailure = async (previous: string, current: string): Promise<boolean> => {
108
+ const compare = async (
109
+ previous: string,
110
+ current: string,
111
+ ): Promise<kit.RetryVerdict | undefined> => {
109
112
  const comparable = current.trim() !== "" && previous.trim() !== ""
110
113
  const { answers, truncated } = await judge("health.judge", {
111
114
  questions: [retryQuestion(comparable)],
@@ -113,12 +116,11 @@ const sameFailure = async (previous: string, current: string): Promise<boolean>
113
116
  message: t.healthJudgeMessage(),
114
117
  label: "Judging the retry",
115
118
  })
116
- // Evidence the budget cut can make two different reports look alike: never sameness.
117
- return (
118
- comparable &&
119
- truncated.length === 0 &&
120
- answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity))
121
- )
119
+ // Evidence the budget cut can make two different reports look alike: neither sameness nor progress.
120
+ if (!comparable || truncated.length > 0) return undefined
121
+ const minP = numeric(vars.judgeIdenticalMinP, Infinity)
122
+ if (answered(answers.verdict, "identical", minP)) return "identical"
123
+ return answered(answers.verdict, "progress", minP) ? "progress" : undefined
122
124
  }
123
125
 
124
126
  export interface HealthOptions {
@@ -153,7 +155,8 @@ const runSuite = (options: HealthOptions): Promise<boolean> =>
153
155
  /**
154
156
  * Loop until the suite is green: check, and on red run `fix` — escalating
155
157
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
156
- * identical to the previous round's. An escalation re-runs the suite once
158
+ * identical to the previous round's. A judge calling it progress at the cap
159
+ * buys one extra fix per run of fixes. An escalation re-runs the suite once
157
160
  * first; a green re-run ends the loop.
158
161
  */
159
162
  export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
@@ -162,7 +165,7 @@ export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}):
162
165
  report: FEEDBACK,
163
166
  fixCap: FIX_CAP,
164
167
  fixesSoFar: options.fixesSoFar ?? 0,
165
- sameFailure,
168
+ compare,
166
169
  escalations: options.escalations ?? { rounds: 0 },
167
170
  recheck: () =>
168
171
  recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
@@ -0,0 +1,124 @@
1
+ @inmem
2
+ Feature: a fix loop still making progress at the cap gets one extra turn
3
+
4
+ `healthy` escalates once three fix turns are spent. When `health.judge`
5
+ calls that round progress — still red, but closer to green — with at least
6
+ `judgeIdenticalMinP` confidence, one more fix runs first, once per run of
7
+ fixes. The round after it escalates without asking the judge. Any other
8
+ verdict escalates at the cap as before.
9
+
10
+ Background:
11
+ Given a gtd config with:
12
+ """
13
+ import { healthy } from "@pmelab/gtd/workflows"
14
+ import { agent } from "@pmelab/gtd/flows"
15
+
16
+ export const defaults = { judgeIdenticalMinP: "0.7" }
17
+ export const held = async () => {
18
+ await agent("first", "go")
19
+ await healthy(() => agent("fix", "go"))
20
+ }
21
+ export default held
22
+ """
23
+ And step "first" writes "src/a.ts" with:
24
+ """
25
+ export const a = 1
26
+ """
27
+ And step "health.check" writes ".gtd/FEEDBACK.md" with:
28
+ """
29
+ tests failing
30
+ """
31
+ And step "fix" deletes ".gtd/FEEDBACK.md"
32
+ And step "health.recheck" writes ".gtd/FEEDBACK.md" with:
33
+ """
34
+ tests still failing
35
+ """
36
+
37
+ Scenario: progress at the cap buys exactly one extra fix turn
38
+ Given judge step "health.judge" answers:
39
+ """
40
+ [{"id":"verdict","answer":"progress","p":0.9}]
41
+ """
42
+ When gtd starts workflow "held"
43
+ And the process is driven
44
+ Then the process rests at "health.describe"
45
+ And the commit subjects end with:
46
+ """
47
+ gtd(agent): first → health.check
48
+ gtd(check): health.check → fix
49
+ gtd(agent): fix → health.check
50
+ gtd(check): health.check → health.judge
51
+ gtd(judge): health.judge → fix
52
+ gtd(agent): fix → health.check
53
+ gtd(check): health.check → health.judge
54
+ gtd(judge): health.judge → fix
55
+ gtd(agent): fix → health.check
56
+ gtd(check): health.check → health.judge
57
+ gtd(judge): health.judge → fix
58
+ gtd(agent): fix → health.check
59
+ gtd(check): health.check → health.recheck
60
+ gtd(check): health.recheck → health.describe
61
+ """
62
+
63
+ Scenario Outline: <verdict> at <p> escalates at the cap without an extra turn
64
+ Given judge step "health.judge" answers:
65
+ """
66
+ [{"id":"verdict","answer":"<verdict>","p":<p>}]
67
+ """
68
+ When gtd starts workflow "held"
69
+ And the process is driven
70
+ Then the process rests at "health.describe"
71
+ And the commit subjects end with:
72
+ """
73
+ gtd(agent): first → health.check
74
+ gtd(check): health.check → fix
75
+ gtd(agent): fix → health.check
76
+ gtd(check): health.check → health.judge
77
+ gtd(judge): health.judge → fix
78
+ gtd(agent): fix → health.check
79
+ gtd(check): health.check → health.judge
80
+ gtd(judge): health.judge → fix
81
+ gtd(agent): fix → health.check
82
+ gtd(check): health.check → health.judge
83
+ gtd(judge): health.judge → health.recheck
84
+ gtd(check): health.recheck → health.describe
85
+ """
86
+
87
+ Examples:
88
+ | verdict | p |
89
+ | new-failure | 0.9 |
90
+ | progress | 0.5 |
91
+
92
+ Scenario: a person's escalation stop starts a new run of fixes with its own extra turn
93
+ Given judge step "health.judge" answers:
94
+ """
95
+ [{"id":"verdict","answer":"progress","p":0.9}]
96
+ """
97
+ And step "health.describe" writes ".gtd/ESCALATION.md" with:
98
+ """
99
+ try something else
100
+ """
101
+ And step "health.stop" writes "NOTES.md" with:
102
+ """
103
+ narrowed
104
+ """
105
+ When gtd starts workflow "held"
106
+ And the process is driven
107
+ Then the process rests at "health.exhausted"
108
+ And the commit subjects end with:
109
+ """
110
+ gtd(agent): health.describe → health.stop
111
+ gtd(human): health.stop → fix
112
+ gtd(agent): fix → health.check
113
+ gtd(check): health.check → health.judge
114
+ gtd(judge): health.judge → fix
115
+ gtd(agent): fix → health.check
116
+ gtd(check): health.check → health.judge
117
+ gtd(judge): health.judge → fix
118
+ gtd(agent): fix → health.check
119
+ gtd(check): health.check → health.judge
120
+ gtd(judge): health.judge → fix
121
+ gtd(agent): fix → health.check
122
+ gtd(check): health.check → health.recheck
123
+ gtd(check): health.recheck → health.exhausted
124
+ """