@pmelab/gtd 38.2.0 → 40.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.
Files changed (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +66 -58
  3. package/dist/chunks/{reserved-C3PgP7XB.mjs → reserved-Qt5FM6Ia.mjs} +29 -10
  4. package/dist/chunks/{tail-Bkxki2nK.mjs → tail-tAHx_AHe.mjs} +1 -1
  5. package/dist/gtd.bundle.mjs +14 -5
  6. package/dist/testkit.mjs +14 -5
  7. package/dist/workflow-kit.mjs +12 -1
  8. package/dist/workflows/cleanup.mjs +2 -2
  9. package/dist/workflows/feature.mjs +98 -37
  10. package/dist/workflows/fix.mjs +2 -2
  11. package/dist/workflows/review.mjs +2 -2
  12. package/dist/workflows/rollout.mjs +1 -1
  13. package/dist/workflows.mjs +136 -45
  14. package/package.json +1 -1
  15. package/skills/authoring/SKILL.md +3 -2
  16. package/src/cli/Cli.ts +4 -3
  17. package/src/cli/Metrics.test.ts +31 -0
  18. package/src/cli/Metrics.ts +8 -0
  19. package/src/workflow-kit/health.ts +15 -1
  20. package/src/workflows/cleanup/cleanup.test.ts +2 -0
  21. package/src/workflows/feature/coverage.feature +5 -5
  22. package/src/workflows/feature/criteria.test.ts +20 -1
  23. package/src/workflows/feature/criteria.ts +24 -0
  24. package/src/workflows/feature/fast-path.feature +4 -4
  25. package/src/workflows/feature/feature.ts +82 -22
  26. package/src/workflows/feature/flag-mode.feature +2 -2
  27. package/src/workflows/feature/one-step-land.feature +4 -4
  28. package/src/workflows/feature/planning.ts +3 -2
  29. package/src/workflows/feature/quality-unresolved.feature +6 -6
  30. package/src/workflows/feature/refactor-absorbs.feature +3 -3
  31. package/src/workflows/feature/refactor-first.feature +5 -5
  32. package/src/workflows/feature/refactor-rebuild.feature +2 -2
  33. package/src/workflows/feature/refactor-review.feature +8 -8
  34. package/src/workflows/feature/refactor.test.ts +4 -0
  35. package/src/workflows/feature/review-gate.feature +271 -0
  36. package/src/workflows/feature/seam-rejection.feature +4 -4
  37. package/src/workflows/feature/seam.feature +2 -2
  38. package/src/workflows/feature/seam.ts +17 -7
  39. package/src/workflows/feature/slicing.feature +63 -4
  40. package/src/workflows/rollout/rollout.feature +16 -0
  41. package/src/workflows/rollout/rollout.test.ts +2 -0
  42. package/src/workflows/shared/health.test.ts +2 -0
  43. package/src/workflows/shared/health.ts +34 -5
  44. package/src/workflows/shared/recheck.feature +60 -0
  45. package/src/workflows/shared/text.ts +7 -5
  46. package/src/workflows/review/review-door.feature +0 -121
@@ -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-Qt5FM6Ia.mjs";
4
+ import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-tAHx_AHe.mjs";
5
5
  import { posix } from "node:path";
6
6
  //#region src/workflows/shared/fences.ts
7
7
  const markFenced = (text) => {
@@ -112,6 +112,19 @@ const requireCriteria = (file) => {
112
112
  if (criteria.length > 0 && unnamed.length === 0 && twice.length === 0) return;
113
113
  refuse(`gtd land: acceptance-criteria: ${file} has ${criteria.length === 0 ? `no "## Acceptance Criteria" list` : unnamed.length > 0 ? `${unnamed.length} criterion without an ID:\n${unnamed.map((c) => ` - ${c.text}`).join("\n")}` : `duplicate IDs: ${[...new Set(twice)].join(", ")}`} — list every criterion under "## Acceptance Criteria" as "- AC-<n>: <checkable criterion>", each ID unique and never reused`);
114
114
  };
115
+ const idNumber = (id) => Number(id.slice(3));
116
+ /** Why `listed` renumbers or reuses an earlier ID; `undefined` when it does neither. */
117
+ const unstableIds = (listed, ids) => {
118
+ const top = Math.max(0, ...ids.used.map(idNumber));
119
+ const reused = listed.filter((id) => !ids.closed.includes(id) && idNumber(id) <= top);
120
+ if (reused.length === 0) return void 0;
121
+ return `numbers ${reused.join(", ")} at or below AC-${top}, an ID already handed out — keep a built criterion's ID (reword it in place when the review changes it), and number new ones from AC-${top + 1}`;
122
+ };
123
+ /** Refuse criteria in `file` that renumber or reuse an earlier ID. */
124
+ const requireStableIds = (file, ids) => {
125
+ const why = unstableIds(criterionIds(read(file) ?? ""), ids);
126
+ if (why !== void 0) refuse(`gtd land: criteria-ids: ${file} ${why}`);
127
+ };
115
128
  /** Refuse a turn that changed the acceptance criteria in `file`: after the design gate only another design lap may. */
116
129
  const requireCriteriaFrozen = (file) => {
117
130
  const change = changes().get(file);
@@ -267,13 +280,14 @@ const noteGate = async (file, answer) => {
267
280
  if (!hasThreadFor("agent", file)) requireAnswers(file);
268
281
  return true;
269
282
  };
270
- /** Triage the sketch since `base` into requirements, until no product question is open. */
271
- const design = (base) => scope("design", async () => {
283
+ /** Triage the sketch since `base` into requirements, until no product question is open; `guard` holds every round's criteria. */
284
+ const design = (base, guard) => scope("design", async () => {
272
285
  do {
273
286
  await triage(base);
274
287
  requireProgress(REQUIREMENTS);
275
288
  requireReplies(REQUIREMENTS);
276
289
  requireCriteria(REQUIREMENTS);
290
+ guard?.();
277
291
  } while (await noteGate(REQUIREMENTS, answerProductQuestions));
278
292
  });
279
293
  //#endregion
@@ -1085,27 +1099,31 @@ const build = (options) => continuing(phase("build", requireCriteriaIfEdited), a
1085
1099
  }
1086
1100
  };
1087
1101
  });
1088
- function implement(first, tier, amend) {
1089
- const options = typeof first === "object" && first !== null ? first : {
1102
+ async function implement(first, tier, amend) {
1103
+ await implementing(typeof first === "object" && first !== null ? first : {
1090
1104
  reference: first,
1091
1105
  tier,
1092
1106
  amend
1093
- };
1094
- return scope("implement", async () => {
1095
- const since = head();
1096
- const acceptance = options.rebuild?.acceptance;
1097
- const built = options.naive === void 0 || !await applied(options.naive);
1098
- if (built) await build(options);
1099
- else requireCriteriaFrozen(REQUIREMENTS);
1100
- if (built && acceptance !== void 0) requireAcceptance({
1101
- ...acceptance,
1102
- ids: settled(acceptance.ids, options.slice)
1103
- }, since);
1104
- await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
1105
- await requireCoverage(options.base, options.slice);
1106
- await closing(options.more === true);
1107
1107
  });
1108
1108
  }
1109
+ /** `implement`, resolving with the criterion IDs the requirements listed when it closed. */
1110
+ const implementing = (options) => scope("implement", async () => {
1111
+ const since = head();
1112
+ const acceptance = options.rebuild?.acceptance;
1113
+ const built = options.naive === void 0 || !await applied(options.naive);
1114
+ if (built) await build(options);
1115
+ else requireCriteriaFrozen(REQUIREMENTS);
1116
+ if (built && acceptance !== void 0) requireAcceptance({
1117
+ ...acceptance,
1118
+ ids: settled(acceptance.ids, options.slice)
1119
+ }, since);
1120
+ await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
1121
+ await requireCoverage(options.base, options.slice);
1122
+ const ids = criterionIds(read(".gtd/REQUIREMENTS.md") ?? "");
1123
+ await closing(moreToBuild(options.more));
1124
+ return ids;
1125
+ });
1126
+ const moreToBuild = (more) => typeof more === "function" ? more() : more === true;
1109
1127
  /** The last slice's closing, for a last slice a steer left with no criterion to build. */
1110
1128
  const closeOut = () => scope("implement", () => closing(false));
1111
1129
  /** A slice with `more` after it keeps the criteria and the slicing for the next one. */
@@ -1340,32 +1358,37 @@ const unwind = () => {
1340
1358
  const buildFeature = async (firstBase) => {
1341
1359
  const tier = riskTierOn() ? recordRisk() : void 0;
1342
1360
  const slices = await slicing();
1361
+ const handedOut = new Set(listedIds());
1343
1362
  const one = slices.length === 1;
1363
+ const built = (i) => sliceIds(sliceCriteria(slices, i))?.length !== 0;
1344
1364
  for (const i of slices.keys()) {
1345
- const more = i < slices.length - 1;
1346
- const criteria = sliceCriteria(slices, i);
1347
- if (sliceIds(criteria)?.length === 0) {
1348
- if (!more) await closeOut();
1349
- continue;
1350
- }
1365
+ if (!built(i)) continue;
1351
1366
  const n = one ? "" : `-${i + 1}`;
1352
1367
  await buildSlice(i === 0 ? firstBase : head(), {
1353
1368
  tier,
1354
- criteria,
1369
+ criteria: sliceCriteria(slices, i),
1355
1370
  refactor: `refactor${n}`,
1356
1371
  feature: `feature${n}`,
1357
- more
1372
+ more: () => slices.some((_, j) => j > i && built(j)),
1373
+ reviewBase: one ? void 0 : firstBase,
1374
+ handedOut
1358
1375
  });
1359
1376
  }
1360
1377
  };
1361
- /** A naive pass (judged only with `flagMode` on), then implement and the quality lap. */
1378
+ const listedIds = () => criterionIds(read(".gtd/REQUIREMENTS.md") ?? "");
1379
+ const gather = (handedOut) => {
1380
+ for (const id of listedIds()) handedOut.add(id);
1381
+ return [...handedOut];
1382
+ };
1383
+ /** A naive pass (judged only with `flagMode` on), then implement and the quality lap; after the last slice, the review. */
1362
1384
  const buildSlice = async (sliceBase, build) => {
1363
1385
  const seam = await seamBuild(sliceBase, {
1364
1386
  criteria: build.criteria,
1365
1387
  refactor: build.refactor
1366
1388
  });
1389
+ const used = gather(build.handedOut);
1367
1390
  await slice(build.feature, async () => {
1368
- await implement({
1391
+ const closed = await implementing({
1369
1392
  reference: seam.reference,
1370
1393
  tier: build.tier,
1371
1394
  afterRefactor: seam.refactored,
@@ -1375,14 +1398,23 @@ const buildSlice = async (sliceBase, build) => {
1375
1398
  base: build.criteria === void 0 ? void 0 : sliceBase,
1376
1399
  more: build.more
1377
1400
  });
1401
+ const more = build.more();
1378
1402
  const carried = seam.rebuild?.acceptance?.tests ?? [];
1379
1403
  const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path) && !carried.includes(c.path)) ? sliceBase : seam.base;
1380
- const guard = build.more ? () => requireCriteriaFrozen(REQUIREMENTS) : void 0;
1381
- await tail(settleQuality, {
1382
- green: true,
1383
- lensBase: base,
1384
- guard,
1385
- guardRests: false
1404
+ if (more) {
1405
+ const guard = () => requireCriteriaFrozen(REQUIREMENTS);
1406
+ await tail(settleQuality, {
1407
+ green: true,
1408
+ lensBase: base,
1409
+ guard,
1410
+ guardRests: false
1411
+ });
1412
+ return;
1413
+ }
1414
+ const reviewBase = build.reviewBase ?? base;
1415
+ await featureFeedback(await reviewedTail(reviewBase, base), base, {
1416
+ closed,
1417
+ used
1386
1418
  });
1387
1419
  });
1388
1420
  };
@@ -1398,6 +1430,35 @@ const settleQuality = async () => {
1398
1430
  }
1399
1431
  if (rested) await healthy(() => fixCheck());
1400
1432
  };
1433
+ /** The quality lap, then the human review since `base`. */
1434
+ const reviewedTail = (base, lensBase, lap) => tail(async (escalations) => {
1435
+ await settleQuality();
1436
+ return review(base, escalations, { lap });
1437
+ }, {
1438
+ green: true,
1439
+ lensBase
1440
+ });
1441
+ const featureFeedback = async (first, lensBase, ids) => {
1442
+ let outcome = first;
1443
+ let closed = ids.closed;
1444
+ const handedOut = /* @__PURE__ */ new Set([...ids.used, ...ids.closed]);
1445
+ while (outcome.verdict === "feedback") {
1446
+ await reUnwind(outcome);
1447
+ const lap = {
1448
+ closed,
1449
+ used: [...handedOut]
1450
+ };
1451
+ await design(outcome.base, () => {
1452
+ requireStableIds(REQUIREMENTS, lap);
1453
+ gather(handedOut);
1454
+ });
1455
+ closed = await implementing({
1456
+ tier: riskTierOn() ? recordRisk() : void 0,
1457
+ amend: true
1458
+ });
1459
+ outcome = await reviewedTail(outcome.base, lensBase, "feedback");
1460
+ }
1461
+ };
1401
1462
  /** Design — skipped when the sketch's own criteria are judged clear — then `buildFeature`. */
1402
1463
  const planAndBuild = async (firstBase, stated) => {
1403
1464
  if (!await skipDesign(riskTierOn() ? void 0 : stated)) await design(firstBase);
@@ -1444,4 +1505,4 @@ const feature = shipped(ordinaryStart);
1444
1505
  //#region src/workflows/feature/index.ts
1445
1506
  var feature_default = feature;
1446
1507
  //#endregion
1447
- export { COMMENT, REFACTOR_PACKAGE, acceptanceFault, access, afterTail, answerProductQuestions, answerReviewQuestions, buildFeature, buildTail, closeOut, collecting, criteriaOf, criterionIds, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, holdsCallSites, implement, isTestFile, judgeSeam, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireTestCallSitesOnly, requireWithin, reviewedRefactor, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, triage, unwind };
1508
+ export { COMMENT, REFACTOR_PACKAGE, acceptanceFault, access, afterTail, answerProductQuestions, answerReviewQuestions, buildFeature, buildTail, closeOut, collecting, criteriaOf, criterionIds, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, holdsCallSites, implement, implementing, isTestFile, judgeSeam, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireStableIds, requireTestCallSitesOnly, requireWithin, reviewedRefactor, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, triage, unstableIds, unwind };
@@ -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-Qt5FM6Ia.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-tAHx_AHe.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-Qt5FM6Ia.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-tAHx_AHe.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-Qt5FM6Ia.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
  }
@@ -17222,14 +17233,14 @@ What each change does next (then run \`gtd land\`):
17222
17233
  - **Continue** — having undone the sketch by hand, check the test baseline is green and start triage (**start-gate.check**).
17223
17234
  `;
17224
17235
  const qualityUnresolvedMessage = () => `The quality lap's fix turns hit their cap and \`.gtd/QUALITY.md\` still
17225
- holds findings. Nothing reviews the change after this, so the process
17226
- will not end with them open.
17236
+ holds findings. Nothing after this fixes them, so the process will not
17237
+ go on with them open.
17227
17238
 
17228
17239
  Fix what matters by hand, then delete or empty \`.gtd/QUALITY.md\` —
17229
17240
  dropping a finding is your call to make.
17230
17241
 
17231
17242
  What each change does next (then run \`gtd land\`):
17232
- - **Delete or empty \`.gtd/QUALITY.md\`** — the suite runs on your edits, then the process ends (**idle**).
17243
+ - **Delete or empty \`.gtd/QUALITY.md\`** — the suite runs on your edits, then the next slice is built, or after the last one you review the change (**build.review.reviewing**).
17233
17244
  - **Anything else** — rests here again.
17234
17245
  `;
17235
17246
  const startGateBlockedMessage = () => `The test baseline is red — gtd will not start new work on a broken suite.
@@ -17267,8 +17278,10 @@ reword a criterion in place, append a new one with the next unused
17267
17278
  number, delete a dropped one — never renumber, never reuse an ID. When
17268
17279
  the file holds no list (a review loop-back rewrites it), recover the
17269
17280
  last settled one from history (\`git log -p -- .gtd/REQUIREMENTS.md\`)
17270
- and carry its IDs over. Landing is refused while a criterion has no ID or
17271
- two share one.
17281
+ and carry its IDs over: after a review a built criterion keeps its ID,
17282
+ reworded in place when the review changes its behaviour, and new
17283
+ behaviour gets a new ID. Landing is refused while a criterion has no ID
17284
+ or two share one.
17272
17285
  `;
17273
17286
  const riskTierBlock = `
17274
17287
  ## Risk tier and production signals
@@ -18385,6 +18398,19 @@ const baseline = (name, label = "Checking the baseline") => check(name, env.test
18385
18398
  report: FEEDBACK,
18386
18399
  label
18387
18400
  });
18401
+ /**
18402
+ * Re-run a red check as step `name` before a person sees it. Green records
18403
+ * `Gtd-Mark: flaky=<red>` on the re-check's landing and resolves `true`.
18404
+ */
18405
+ const recheck = async (name, red, options = {}) => {
18406
+ const green = await check(name, env.testCommand ?? "", {
18407
+ ...options,
18408
+ report: FEEDBACK,
18409
+ label: "Re-running checks"
18410
+ });
18411
+ if (green) mark("flaky", red);
18412
+ return green;
18413
+ };
18388
18414
  const escalationSteps = (rules) => ({
18389
18415
  limit: 2,
18390
18416
  describe: () => describeEscalation(rules),
@@ -18435,15 +18461,17 @@ const sameFailure = async (previous, current) => {
18435
18461
  });
18436
18462
  return comparable && truncated.length === 0 && answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity));
18437
18463
  };
18464
+ const healthSweep = (options) => [ESCALATION, ...options.sweepOnGreen ?? []];
18438
18465
  const runSuite = (options) => check("health.check", env.testCommand ?? "", {
18439
18466
  report: FEEDBACK,
18440
18467
  label: "Running checks",
18441
- sweepOnGreen: [ESCALATION, ...options.sweepOnGreen ?? []]
18468
+ sweepOnGreen: healthSweep(options)
18442
18469
  });
18443
18470
  /**
18444
18471
  * Loop until the suite is green: check, and on red run `fix` — escalating
18445
18472
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
18446
- * identical to the previous round's.
18473
+ * identical to the previous round's. An escalation re-runs the suite once
18474
+ * first; a green re-run ends the loop.
18447
18475
  */
18448
18476
  const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
18449
18477
  check: () => runSuite(options),
@@ -18452,14 +18480,16 @@ const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
18452
18480
  fixesSoFar: options.fixesSoFar ?? 0,
18453
18481
  sameFailure,
18454
18482
  escalations: options.escalations ?? { rounds: 0 },
18483
+ recheck: () => recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
18455
18484
  escalation: guardedSteps(options.guard, options.describeGuard, options.guardRests, options.rules)
18456
18485
  });
18457
18486
  /**
18458
- * Hold the process at `<name>.blocked` until the suite is green. `guard` runs
18487
+ * Hold the process at `<name>.blocked` until the suite is green, re-running a
18488
+ * red check once as `<name>.recheck` before resting. `guard` runs
18459
18489
  * after each `blocked` landing only — never after a check, whose refusal would
18460
18490
  * re-run the same script forever.
18461
18491
  */
18462
- const gate = (name, message, guard) => gate$1(name, () => baseline("check"), guarded(() => human("blocked", {
18492
+ const gate = (name, message, guard) => gate$1(name, async () => await baseline("check") || recheck("recheck", `${name}.check`), guarded(() => human("blocked", {
18463
18493
  message,
18464
18494
  label: "Baseline is red",
18465
18495
  file: FEEDBACK
@@ -38067,6 +38097,19 @@ const requireCriteria = (file) => {
38067
38097
  if (criteria.length > 0 && unnamed.length === 0 && twice.length === 0) return;
38068
38098
  refuse(`gtd land: acceptance-criteria: ${file} has ${criteria.length === 0 ? `no "## Acceptance Criteria" list` : unnamed.length > 0 ? `${unnamed.length} criterion without an ID:\n${unnamed.map((c) => ` - ${c.text}`).join("\n")}` : `duplicate IDs: ${[...new Set(twice)].join(", ")}`} — list every criterion under "## Acceptance Criteria" as "- AC-<n>: <checkable criterion>", each ID unique and never reused`);
38069
38099
  };
38100
+ const idNumber = (id) => Number(id.slice(3));
38101
+ /** Why `listed` renumbers or reuses an earlier ID; `undefined` when it does neither. */
38102
+ const unstableIds = (listed, ids) => {
38103
+ const top = Math.max(0, ...ids.used.map(idNumber));
38104
+ const reused = listed.filter((id) => !ids.closed.includes(id) && idNumber(id) <= top);
38105
+ if (reused.length === 0) return void 0;
38106
+ return `numbers ${reused.join(", ")} at or below AC-${top}, an ID already handed out — keep a built criterion's ID (reword it in place when the review changes it), and number new ones from AC-${top + 1}`;
38107
+ };
38108
+ /** Refuse criteria in `file` that renumber or reuse an earlier ID. */
38109
+ const requireStableIds = (file, ids) => {
38110
+ const why = unstableIds(criterionIds(read(file) ?? ""), ids);
38111
+ if (why !== void 0) refuse(`gtd land: criteria-ids: ${file} ${why}`);
38112
+ };
38070
38113
  /** Refuse a turn that changed the acceptance criteria in `file`: after the design gate only another design lap may. */
38071
38114
  const requireCriteriaFrozen = (file) => {
38072
38115
  const change = changes().get(file);
@@ -38222,13 +38265,14 @@ const noteGate = async (file, answer) => {
38222
38265
  if (!hasThreadFor("agent", file)) requireAnswers(file);
38223
38266
  return true;
38224
38267
  };
38225
- /** Triage the sketch since `base` into requirements, until no product question is open. */
38226
- const design = (base) => scope("design", async () => {
38268
+ /** Triage the sketch since `base` into requirements, until no product question is open; `guard` holds every round's criteria. */
38269
+ const design = (base, guard) => scope("design", async () => {
38227
38270
  do {
38228
38271
  await triage(base);
38229
38272
  requireProgress(REQUIREMENTS);
38230
38273
  requireReplies(REQUIREMENTS);
38231
38274
  requireCriteria(REQUIREMENTS);
38275
+ guard?.();
38232
38276
  } while (await noteGate(REQUIREMENTS, answerProductQuestions));
38233
38277
  });
38234
38278
  //#endregion
@@ -39040,27 +39084,31 @@ const build = (options) => continuing(phase("build", requireCriteriaIfEdited), a
39040
39084
  }
39041
39085
  };
39042
39086
  });
39043
- function implement(first, tier, amend) {
39044
- const options = typeof first === "object" && first !== null ? first : {
39087
+ async function implement(first, tier, amend) {
39088
+ await implementing(typeof first === "object" && first !== null ? first : {
39045
39089
  reference: first,
39046
39090
  tier,
39047
39091
  amend
39048
- };
39049
- return scope("implement", async () => {
39050
- const since = head$1();
39051
- const acceptance = options.rebuild?.acceptance;
39052
- const built = options.naive === void 0 || !await applied(options.naive);
39053
- if (built) await build(options);
39054
- else requireCriteriaFrozen(REQUIREMENTS);
39055
- if (built && acceptance !== void 0) requireAcceptance({
39056
- ...acceptance,
39057
- ids: settled(acceptance.ids, options.slice)
39058
- }, since);
39059
- await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
39060
- await requireCoverage(options.base, options.slice);
39061
- await closing(options.more === true);
39062
39092
  });
39063
39093
  }
39094
+ /** `implement`, resolving with the criterion IDs the requirements listed when it closed. */
39095
+ const implementing = (options) => scope("implement", async () => {
39096
+ const since = head$1();
39097
+ const acceptance = options.rebuild?.acceptance;
39098
+ const built = options.naive === void 0 || !await applied(options.naive);
39099
+ if (built) await build(options);
39100
+ else requireCriteriaFrozen(REQUIREMENTS);
39101
+ if (built && acceptance !== void 0) requireAcceptance({
39102
+ ...acceptance,
39103
+ ids: settled(acceptance.ids, options.slice)
39104
+ }, since);
39105
+ await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
39106
+ await requireCoverage(options.base, options.slice);
39107
+ const ids = criterionIds(read(".gtd/REQUIREMENTS.md") ?? "");
39108
+ await closing(moreToBuild(options.more));
39109
+ return ids;
39110
+ });
39111
+ const moreToBuild = (more) => typeof more === "function" ? more() : more === true;
39064
39112
  /** The last slice's closing, for a last slice a steer left with no criterion to build. */
39065
39113
  const closeOut = () => scope("implement", () => closing(false));
39066
39114
  /** A slice with `more` after it keeps the criteria and the slicing for the next one. */
@@ -39295,32 +39343,37 @@ const unwind = () => {
39295
39343
  const buildFeature = async (firstBase) => {
39296
39344
  const tier = riskTierOn() ? recordRisk() : void 0;
39297
39345
  const slices = await slicing();
39346
+ const handedOut = new Set(listedIds());
39298
39347
  const one = slices.length === 1;
39348
+ const built = (i) => sliceIds(sliceCriteria(slices, i))?.length !== 0;
39299
39349
  for (const i of slices.keys()) {
39300
- const more = i < slices.length - 1;
39301
- const criteria = sliceCriteria(slices, i);
39302
- if (sliceIds(criteria)?.length === 0) {
39303
- if (!more) await closeOut();
39304
- continue;
39305
- }
39350
+ if (!built(i)) continue;
39306
39351
  const n = one ? "" : `-${i + 1}`;
39307
39352
  await buildSlice(i === 0 ? firstBase : head$1(), {
39308
39353
  tier,
39309
- criteria,
39354
+ criteria: sliceCriteria(slices, i),
39310
39355
  refactor: `refactor${n}`,
39311
39356
  feature: `feature${n}`,
39312
- more
39357
+ more: () => slices.some((_, j) => j > i && built(j)),
39358
+ reviewBase: one ? void 0 : firstBase,
39359
+ handedOut
39313
39360
  });
39314
39361
  }
39315
39362
  };
39316
- /** A naive pass (judged only with `flagMode` on), then implement and the quality lap. */
39363
+ const listedIds = () => criterionIds(read(".gtd/REQUIREMENTS.md") ?? "");
39364
+ const gather = (handedOut) => {
39365
+ for (const id of listedIds()) handedOut.add(id);
39366
+ return [...handedOut];
39367
+ };
39368
+ /** A naive pass (judged only with `flagMode` on), then implement and the quality lap; after the last slice, the review. */
39317
39369
  const buildSlice = async (sliceBase, build) => {
39318
39370
  const seam = await seamBuild(sliceBase, {
39319
39371
  criteria: build.criteria,
39320
39372
  refactor: build.refactor
39321
39373
  });
39374
+ const used = gather(build.handedOut);
39322
39375
  await slice(build.feature, async () => {
39323
- await implement({
39376
+ const closed = await implementing({
39324
39377
  reference: seam.reference,
39325
39378
  tier: build.tier,
39326
39379
  afterRefactor: seam.refactored,
@@ -39330,14 +39383,23 @@ const buildSlice = async (sliceBase, build) => {
39330
39383
  base: build.criteria === void 0 ? void 0 : sliceBase,
39331
39384
  more: build.more
39332
39385
  });
39386
+ const more = build.more();
39333
39387
  const carried = seam.rebuild?.acceptance?.tests ?? [];
39334
39388
  const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path) && !carried.includes(c.path)) ? sliceBase : seam.base;
39335
- const guard = build.more ? () => requireCriteriaFrozen(REQUIREMENTS) : void 0;
39336
- await tail(settleQuality, {
39337
- green: true,
39338
- lensBase: base,
39339
- guard,
39340
- guardRests: false
39389
+ if (more) {
39390
+ const guard = () => requireCriteriaFrozen(REQUIREMENTS);
39391
+ await tail(settleQuality, {
39392
+ green: true,
39393
+ lensBase: base,
39394
+ guard,
39395
+ guardRests: false
39396
+ });
39397
+ return;
39398
+ }
39399
+ const reviewBase = build.reviewBase ?? base;
39400
+ await featureFeedback(await reviewedTail(reviewBase, base), base, {
39401
+ closed,
39402
+ used
39341
39403
  });
39342
39404
  });
39343
39405
  };
@@ -39353,6 +39415,35 @@ const settleQuality = async () => {
39353
39415
  }
39354
39416
  if (rested) await healthy(() => fixCheck());
39355
39417
  };
39418
+ /** The quality lap, then the human review since `base`. */
39419
+ const reviewedTail = (base, lensBase, lap) => tail(async (escalations) => {
39420
+ await settleQuality();
39421
+ return review$1(base, escalations, { lap });
39422
+ }, {
39423
+ green: true,
39424
+ lensBase
39425
+ });
39426
+ const featureFeedback = async (first, lensBase, ids) => {
39427
+ let outcome = first;
39428
+ let closed = ids.closed;
39429
+ const handedOut = /* @__PURE__ */ new Set([...ids.used, ...ids.closed]);
39430
+ while (outcome.verdict === "feedback") {
39431
+ await reUnwind(outcome);
39432
+ const lap = {
39433
+ closed,
39434
+ used: [...handedOut]
39435
+ };
39436
+ await design(outcome.base, () => {
39437
+ requireStableIds(REQUIREMENTS, lap);
39438
+ gather(handedOut);
39439
+ });
39440
+ closed = await implementing({
39441
+ tier: riskTierOn() ? recordRisk() : void 0,
39442
+ amend: true
39443
+ });
39444
+ outcome = await reviewedTail(outcome.base, lensBase, "feedback");
39445
+ }
39446
+ };
39356
39447
  /** Design — skipped when the sketch's own criteria are judged clear — then `buildFeature`. */
39357
39448
  const planAndBuild = async (firstBase, stated) => {
39358
39449
  if (!await skipDesign(riskTierOn() ? void 0 : stated)) await design(firstBase);
@@ -39578,4 +39669,4 @@ var workflows_default = feature;
39578
39669
  */
39579
39670
  const doors = {};
39580
39671
  //#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 };
39672
+ 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, implementing, 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, requireStableIds, 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, unstableIds, unwind };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "38.2.0",
3
+ "version": "40.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": {
@@ -224,8 +224,9 @@ workflow.
224
224
 
225
225
  In the bundled default, `planAndBuild` runs the design phase, then
226
226
  `buildFeature`: the naive pass and its seam judge, then the implementation (the
227
- pass itself on `land`) and the quality lap, ending with no human review. Add a
228
- human sign-off before the build:
227
+ pass itself on `land`), the quality lap and the human review (a review feedback
228
+ lap re-runs design inside `buildFeature` and does not pass it again). Add a
229
+ human sign-off before the first build:
229
230
 
230
231
  ```ts
231
232
  const planAndBuild = async (firstBase: string): Promise<void> => {
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([