@pmelab/gtd 39.0.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 (32) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +54 -53
  3. package/dist/chunks/{reserved-B6tTYjeb.mjs → reserved-Qt5FM6Ia.mjs} +7 -5
  4. package/dist/chunks/{tail-_CZTP7Hu.mjs → tail-tAHx_AHe.mjs} +1 -1
  5. package/dist/workflows/cleanup.mjs +2 -2
  6. package/dist/workflows/feature.mjs +98 -37
  7. package/dist/workflows/fix.mjs +2 -2
  8. package/dist/workflows/review.mjs +2 -2
  9. package/dist/workflows/rollout.mjs +1 -1
  10. package/dist/workflows.mjs +103 -40
  11. package/package.json +1 -1
  12. package/skills/authoring/SKILL.md +3 -2
  13. package/src/workflows/feature/coverage.feature +5 -5
  14. package/src/workflows/feature/criteria.test.ts +20 -1
  15. package/src/workflows/feature/criteria.ts +24 -0
  16. package/src/workflows/feature/fast-path.feature +4 -4
  17. package/src/workflows/feature/feature.ts +82 -22
  18. package/src/workflows/feature/flag-mode.feature +2 -2
  19. package/src/workflows/feature/one-step-land.feature +4 -4
  20. package/src/workflows/feature/planning.ts +3 -2
  21. package/src/workflows/feature/quality-unresolved.feature +6 -6
  22. package/src/workflows/feature/refactor-absorbs.feature +3 -3
  23. package/src/workflows/feature/refactor-first.feature +5 -5
  24. package/src/workflows/feature/refactor-rebuild.feature +2 -2
  25. package/src/workflows/feature/refactor-review.feature +8 -8
  26. package/src/workflows/feature/review-gate.feature +271 -0
  27. package/src/workflows/feature/seam-rejection.feature +4 -4
  28. package/src/workflows/feature/seam.feature +2 -2
  29. package/src/workflows/feature/seam.ts +17 -7
  30. package/src/workflows/feature/slicing.feature +63 -4
  31. package/src/workflows/shared/text.ts +7 -5
  32. package/src/workflows/review/review-door.feature +0 -121
@@ -17233,14 +17233,14 @@ What each change does next (then run \`gtd land\`):
17233
17233
  - **Continue** — having undone the sketch by hand, check the test baseline is green and start triage (**start-gate.check**).
17234
17234
  `;
17235
17235
  const qualityUnresolvedMessage = () => `The quality lap's fix turns hit their cap and \`.gtd/QUALITY.md\` still
17236
- holds findings. Nothing reviews the change after this, so the process
17237
- 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.
17238
17238
 
17239
17239
  Fix what matters by hand, then delete or empty \`.gtd/QUALITY.md\` —
17240
17240
  dropping a finding is your call to make.
17241
17241
 
17242
17242
  What each change does next (then run \`gtd land\`):
17243
- - **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**).
17244
17244
  - **Anything else** — rests here again.
17245
17245
  `;
17246
17246
  const startGateBlockedMessage = () => `The test baseline is red — gtd will not start new work on a broken suite.
@@ -17278,8 +17278,10 @@ reword a criterion in place, append a new one with the next unused
17278
17278
  number, delete a dropped one — never renumber, never reuse an ID. When
17279
17279
  the file holds no list (a review loop-back rewrites it), recover the
17280
17280
  last settled one from history (\`git log -p -- .gtd/REQUIREMENTS.md\`)
17281
- and carry its IDs over. Landing is refused while a criterion has no ID or
17282
- 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.
17283
17285
  `;
17284
17286
  const riskTierBlock = `
17285
17287
  ## Risk tier and production signals
@@ -38095,6 +38097,19 @@ const requireCriteria = (file) => {
38095
38097
  if (criteria.length > 0 && unnamed.length === 0 && twice.length === 0) return;
38096
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`);
38097
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
+ };
38098
38113
  /** Refuse a turn that changed the acceptance criteria in `file`: after the design gate only another design lap may. */
38099
38114
  const requireCriteriaFrozen = (file) => {
38100
38115
  const change = changes().get(file);
@@ -38250,13 +38265,14 @@ const noteGate = async (file, answer) => {
38250
38265
  if (!hasThreadFor("agent", file)) requireAnswers(file);
38251
38266
  return true;
38252
38267
  };
38253
- /** Triage the sketch since `base` into requirements, until no product question is open. */
38254
- 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 () => {
38255
38270
  do {
38256
38271
  await triage(base);
38257
38272
  requireProgress(REQUIREMENTS);
38258
38273
  requireReplies(REQUIREMENTS);
38259
38274
  requireCriteria(REQUIREMENTS);
38275
+ guard?.();
38260
38276
  } while (await noteGate(REQUIREMENTS, answerProductQuestions));
38261
38277
  });
38262
38278
  //#endregion
@@ -39068,27 +39084,31 @@ const build = (options) => continuing(phase("build", requireCriteriaIfEdited), a
39068
39084
  }
39069
39085
  };
39070
39086
  });
39071
- function implement(first, tier, amend) {
39072
- const options = typeof first === "object" && first !== null ? first : {
39087
+ async function implement(first, tier, amend) {
39088
+ await implementing(typeof first === "object" && first !== null ? first : {
39073
39089
  reference: first,
39074
39090
  tier,
39075
39091
  amend
39076
- };
39077
- return scope("implement", async () => {
39078
- const since = head$1();
39079
- const acceptance = options.rebuild?.acceptance;
39080
- const built = options.naive === void 0 || !await applied(options.naive);
39081
- if (built) await build(options);
39082
- else requireCriteriaFrozen(REQUIREMENTS);
39083
- if (built && acceptance !== void 0) requireAcceptance({
39084
- ...acceptance,
39085
- ids: settled(acceptance.ids, options.slice)
39086
- }, since);
39087
- await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
39088
- await requireCoverage(options.base, options.slice);
39089
- await closing(options.more === true);
39090
39092
  });
39091
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;
39092
39112
  /** The last slice's closing, for a last slice a steer left with no criterion to build. */
39093
39113
  const closeOut = () => scope("implement", () => closing(false));
39094
39114
  /** A slice with `more` after it keeps the criteria and the slicing for the next one. */
@@ -39323,32 +39343,37 @@ const unwind = () => {
39323
39343
  const buildFeature = async (firstBase) => {
39324
39344
  const tier = riskTierOn() ? recordRisk() : void 0;
39325
39345
  const slices = await slicing();
39346
+ const handedOut = new Set(listedIds());
39326
39347
  const one = slices.length === 1;
39348
+ const built = (i) => sliceIds(sliceCriteria(slices, i))?.length !== 0;
39327
39349
  for (const i of slices.keys()) {
39328
- const more = i < slices.length - 1;
39329
- const criteria = sliceCriteria(slices, i);
39330
- if (sliceIds(criteria)?.length === 0) {
39331
- if (!more) await closeOut();
39332
- continue;
39333
- }
39350
+ if (!built(i)) continue;
39334
39351
  const n = one ? "" : `-${i + 1}`;
39335
39352
  await buildSlice(i === 0 ? firstBase : head$1(), {
39336
39353
  tier,
39337
- criteria,
39354
+ criteria: sliceCriteria(slices, i),
39338
39355
  refactor: `refactor${n}`,
39339
39356
  feature: `feature${n}`,
39340
- more
39357
+ more: () => slices.some((_, j) => j > i && built(j)),
39358
+ reviewBase: one ? void 0 : firstBase,
39359
+ handedOut
39341
39360
  });
39342
39361
  }
39343
39362
  };
39344
- /** 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. */
39345
39369
  const buildSlice = async (sliceBase, build) => {
39346
39370
  const seam = await seamBuild(sliceBase, {
39347
39371
  criteria: build.criteria,
39348
39372
  refactor: build.refactor
39349
39373
  });
39374
+ const used = gather(build.handedOut);
39350
39375
  await slice(build.feature, async () => {
39351
- await implement({
39376
+ const closed = await implementing({
39352
39377
  reference: seam.reference,
39353
39378
  tier: build.tier,
39354
39379
  afterRefactor: seam.refactored,
@@ -39358,14 +39383,23 @@ const buildSlice = async (sliceBase, build) => {
39358
39383
  base: build.criteria === void 0 ? void 0 : sliceBase,
39359
39384
  more: build.more
39360
39385
  });
39386
+ const more = build.more();
39361
39387
  const carried = seam.rebuild?.acceptance?.tests ?? [];
39362
39388
  const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path) && !carried.includes(c.path)) ? sliceBase : seam.base;
39363
- const guard = build.more ? () => requireCriteriaFrozen(REQUIREMENTS) : void 0;
39364
- await tail(settleQuality, {
39365
- green: true,
39366
- lensBase: base,
39367
- guard,
39368
- 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
39369
39403
  });
39370
39404
  });
39371
39405
  };
@@ -39381,6 +39415,35 @@ const settleQuality = async () => {
39381
39415
  }
39382
39416
  if (rested) await healthy(() => fixCheck());
39383
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
+ };
39384
39447
  /** Design — skipped when the sketch's own criteria are judged clear — then `buildFeature`. */
39385
39448
  const planAndBuild = async (firstBase, stated) => {
39386
39449
  if (!await skipDesign(riskTierOn() ? void 0 : stated)) await design(firstBase);
@@ -39606,4 +39669,4 @@ var workflows_default = feature;
39606
39669
  */
39607
39670
  const doors = {};
39608
39671
  //#endregion
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 };
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": "39.0.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> => {
@@ -55,19 +55,19 @@ Feature: a feature is done only when every acceptance criterion has a passing te
55
55
  And the output of gtd "judge" contains "add is unchanged."
56
56
  And the output of gtd "judge" contains "[AC-1] subtracts"
57
57
 
58
- Scenario: every ID covered ends the feature and marks the IDs it covers
58
+ Scenario: every ID covered moves on to the review and marks the IDs it covers
59
59
  Given judge step "implement.coverage" answers:
60
60
  """
61
61
  [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
62
62
  """
63
63
  When the process is driven
64
- Then the process rests at "idle"
64
+ Then the process rests at "build.review.reviewing"
65
65
  And the commit subjects end with:
66
66
  """
67
67
  gtd(check): implement.applying → implement.health.check
68
68
  gtd(check): implement.health.check → implement.coverage
69
69
  gtd(judge): implement.coverage → implement.closing
70
- gtd(check): implement.closing → idle
70
+ gtd(check): implement.closing → build.review.reviewing
71
71
  """
72
72
  And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
73
73
  And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-2"
@@ -132,7 +132,7 @@ Feature: a feature is done only when every acceptance criterion has a passing te
132
132
  """
133
133
  Then driving the process is refused with "criteria-frozen"
134
134
 
135
- Scenario: overruling at the gate ends the feature, recording the uncovered IDs
135
+ Scenario: overruling at the gate moves on to the review, recording the uncovered IDs
136
136
  Given judge step "implement.coverage" answers:
137
137
  """
138
138
  [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"no","p":0.9}]
@@ -144,7 +144,7 @@ Feature: a feature is done only when every acceptance criterion has a passing te
144
144
  """
145
145
  And step "implement.uncovered" changes nothing
146
146
  When the process is driven
147
- Then the process rests at "idle"
147
+ Then the process rests at "build.review.reviewing"
148
148
  And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
149
149
  And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: uncovered=AC-2"
150
150
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import type { Change } from "../../flows/index.js"
3
- import { criteriaOf, criterionIds, statedCriteria } from "./criteria.js"
3
+ import { criteriaOf, criterionIds, statedCriteria, unstableIds } from "./criteria.js"
4
4
 
5
5
  const added = (path: string, after: string): Change => ({
6
6
  path,
@@ -120,3 +120,22 @@ describe("criterionIds", () => {
120
120
  expect(criterionIds(requirements, { except: ["AC-1", "AC-3"] })).toEqual(["AC-2", "AC-4"])
121
121
  })
122
122
  })
123
+
124
+ describe("unstableIds", () => {
125
+ const ids = { closed: ["AC-1", "AC-3"], used: ["AC-1", "AC-2", "AC-3"] }
126
+
127
+ it("accepts built IDs kept or dropped and new ones numbered above all handed out", () => {
128
+ expect(unstableIds(["AC-3", "AC-4", "AC-7"], ids)).toBeUndefined()
129
+ expect(unstableIds([], ids)).toBeUndefined()
130
+ })
131
+
132
+ it("names a new ID that reuses a number handed out before, even a narrowed-away one", () => {
133
+ expect(unstableIds(["AC-1", "AC-2", "AC-3"], ids)).toMatch(
134
+ /^numbers AC-2 at or below AC-3, .* from AC-4$/,
135
+ )
136
+ })
137
+
138
+ it("names every reused ID", () => {
139
+ expect(unstableIds(["AC-0", "AC-2"], ids)).toMatch(/^numbers AC-0, AC-2 at or below AC-3/)
140
+ })
141
+ })
@@ -151,6 +151,30 @@ export const requireCriteria = (file: string): void => {
151
151
  )
152
152
  }
153
153
 
154
+ /** The IDs a review's feedback lap must respect. */
155
+ export interface StableIds {
156
+ /** Listed when the build closed: each may stay, reworded or not, or go. */
157
+ readonly closed: readonly string[]
158
+ /** Every ID handed out so far: any other is numbered above all of them. */
159
+ readonly used: readonly string[]
160
+ }
161
+
162
+ const idNumber = (id: string): number => Number(id.slice("AC-".length))
163
+
164
+ /** Why `listed` renumbers or reuses an earlier ID; `undefined` when it does neither. */
165
+ export const unstableIds = (listed: readonly string[], ids: StableIds): string | undefined => {
166
+ const top = Math.max(0, ...ids.used.map(idNumber))
167
+ const reused = listed.filter((id) => !ids.closed.includes(id) && idNumber(id) <= top)
168
+ if (reused.length === 0) return undefined
169
+ 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}`
170
+ }
171
+
172
+ /** Refuse criteria in `file` that renumber or reuse an earlier ID. */
173
+ export const requireStableIds = (file: string, ids: StableIds): void => {
174
+ const why = unstableIds(criterionIds(read(file) ?? ""), ids)
175
+ if (why !== undefined) refuse(`gtd land: criteria-ids: ${file} ${why}`)
176
+ }
177
+
154
178
  /** Refuse a turn that changed the acceptance criteria in `file`: after the design gate only another design lap may. */
155
179
  export const requireCriteriaFrozen = (file: string): void => {
156
180
  const change = changes().get(file)
@@ -3,7 +3,7 @@ Feature: a trivial change takes the same few beats at either risk tier
3
3
 
4
4
  With GTD_FLAG_RISK_TIER on, a one-line copy fix goes
5
5
  from idle through the naive pass, the seam judge's land, the applied pass and
6
- the quality lap back to idle. Low tier walks exactly the steps high does; only
6
+ the quality lap to the review gate. Low tier walks exactly the steps high does; only
7
7
  the summary differs.
8
8
 
9
9
  Background:
@@ -32,7 +32,7 @@ Feature: a trivial change takes the same few beats at either risk tier
32
32
  """
33
33
  And step "build.quality.correctness.reviewing" changes nothing
34
34
 
35
- Scenario Outline: a one-line fix runs idle to idle with the same beats at either tier
35
+ Scenario Outline: a one-line fix runs from idle to the review gate with the same beats at either tier
36
36
  Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
37
37
  """
38
38
  # Requirements
@@ -64,7 +64,7 @@ Feature: a trivial change takes the same few beats at either risk tier
64
64
  """
65
65
  And step "implement.applying" runs its script
66
66
  When the process is driven
67
- Then the process rests at "idle"
67
+ Then the process rests at "build.review.reviewing"
68
68
  And "src/greeting.ts" contains "Hello, world"
69
69
  And the commit subjects end with:
70
70
  """
@@ -80,7 +80,7 @@ Feature: a trivial change takes the same few beats at either risk tier
80
80
  gtd(check): implement.health.check → implement.coverage
81
81
  gtd(judge): implement.coverage → implement.closing
82
82
  gtd(check): implement.closing → build.quality.correctness.reviewing
83
- gtd(agent): build.quality.correctness.reviewing → idle
83
+ gtd(agent): build.quality.correctness.reviewing → build.review.reviewing
84
84
  """
85
85
  And the output of gtd "summary" <summary> "needs a human reviewer"
86
86
 
@@ -9,13 +9,16 @@ import {
9
9
  slice,
10
10
  start,
11
11
  } from "../../flows/index.js"
12
- import { gate, healthy } from "../shared/health.js"
12
+ import { gate, healthy, type EscalationCount } from "../shared/health.js"
13
13
  import { flagModeOn, shipped } from "../shared/options.js"
14
14
  import { FEEDBACK, fixCheck, QUALITY, REQUIREMENTS } from "../shared/steps.js"
15
15
  import { refusePendingHandOff, tail } from "../shared/tail.js"
16
16
  import * as t from "../shared/text.js"
17
17
  import {
18
+ criterionIds,
18
19
  requireCriteriaFrozen,
20
+ requireStableIds,
21
+ type StableIds,
19
22
  skipDesign,
20
23
  sliceIds,
21
24
  statedCriteria,
@@ -23,8 +26,8 @@ import {
23
26
  type StatedCriteria,
24
27
  } from "./criteria.js"
25
28
  import { design } from "./planning.js"
26
- import { buildTail, isCode, reUnwind, type ReviewOutcome } from "./review.js"
27
- import { closeOut, implement, seamBuild } from "./seam.js"
29
+ import { buildTail, isCode, reUnwind, review, type ReviewOutcome } from "./review.js"
30
+ import { implement, implementing, seamBuild } from "./seam.js"
28
31
  import { sliceCap, sliceCriteria, slicing } from "./slicing.js"
29
32
  import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
30
33
 
@@ -33,7 +36,9 @@ import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
33
36
  // green-baseline gate → design → per slice of the criteria, a naive pass,
34
37
  // judged with `flagMode` on: it lands as the implementation, or (refactoring
35
38
  // first when the judge asks) is built fresh from → the health loop → the
36
- // quality lap, and the episode ends back at `idle`. Human review is the `review` door's.
39
+ // quality lap → after the last slice, human review of the whole change, which
40
+ // signs off (the episode ends back at `idle`) or sends a design → implement
41
+ // lap.
37
42
 
38
43
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
39
44
  export const unwind = (): Promise<void> => {
@@ -52,40 +57,54 @@ export { reUnwind }
52
57
  export const buildFeature = async (firstBase: string): Promise<void> => {
53
58
  const tier = riskTierOn() ? recordRisk() : undefined
54
59
  const slices = await slicing()
60
+ // Every ID handed out, gathered before a later narrowing can drop one: none is ever reused.
61
+ const handedOut = new Set(listedIds())
55
62
  // A single slice keeps the unnumbered names and builds against every criterion.
56
63
  const one = slices.length === 1
64
+ // A steer may delete every criterion of a later slice: there is nothing to build there.
65
+ const built = (i: number): boolean => sliceIds(sliceCriteria(slices, i))?.length !== 0
57
66
  for (const i of slices.keys()) {
58
- const more = i < slices.length - 1
59
- const criteria = sliceCriteria(slices, i)
60
- // A steer may have deleted every criterion of a later slice: nothing to build.
61
- if (sliceIds(criteria)?.length === 0) {
62
- if (!more) await closeOut()
63
- continue
64
- }
67
+ if (!built(i)) continue
65
68
  const n = one ? "" : `-${i + 1}`
66
69
  await buildSlice(i === 0 ? firstBase : head(), {
67
70
  tier,
68
- criteria,
71
+ criteria: sliceCriteria(slices, i),
69
72
  refactor: `refactor${n}`,
70
73
  feature: `feature${n}`,
71
- more,
74
+ // A steer or a narrowing may empty every later slice.
75
+ more: () => slices.some((_, j) => j > i && built(j)),
76
+ // Every slice but the one is built unreviewed, so the review covers them all.
77
+ reviewBase: one ? undefined : firstBase,
78
+ handedOut,
72
79
  })
73
80
  }
74
81
  }
75
82
 
83
+ const listedIds = (): readonly string[] => criterionIds(read(REQUIREMENTS) ?? "")
84
+
85
+ const gather = (handedOut: Set<string>): readonly string[] => {
86
+ for (const id of listedIds()) handedOut.add(id)
87
+ return [...handedOut]
88
+ }
89
+
76
90
  interface SliceBuild {
77
91
  readonly tier: RiskTier | undefined
78
92
  readonly criteria: CriteriaSlice | undefined
79
93
  readonly refactor: string
80
94
  readonly feature: string
81
- readonly more: boolean
95
+ readonly more: () => boolean
96
+ /** Absent: the slice's own base. */
97
+ readonly reviewBase?: string | undefined
98
+ /** Every criterion ID handed out so far; the slice adds those a steer adds. */
99
+ readonly handedOut: Set<string>
82
100
  }
83
101
 
84
- /** A naive pass (judged only with `flagMode` on), then implement and the quality lap. */
102
+ /** A naive pass (judged only with `flagMode` on), then implement and the quality lap; after the last slice, the review. */
85
103
  const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> => {
86
104
  const seam = await seamBuild(sliceBase, { criteria: build.criteria, refactor: build.refactor })
105
+ const used = gather(build.handedOut)
87
106
  await slice(build.feature, async () => {
88
- await implement({
107
+ const closed = await implementing({
89
108
  reference: seam.reference,
90
109
  tier: build.tier,
91
110
  afterRefactor: seam.refactored,
@@ -95,6 +114,8 @@ const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> =
95
114
  base: build.criteria === undefined ? undefined : sliceBase,
96
115
  more: build.more,
97
116
  })
117
+ // Closing kept the criteria exactly when there is more to build, so this reads as it did.
118
+ const more = build.more()
98
119
  // A feature slice with no code beyond the carried-over tests: the refactor carried the
99
120
  // feature, so the lap covers the whole slice.
100
121
  const carried = seam.rebuild?.acceptance?.tests ?? []
@@ -103,14 +124,19 @@ const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> =
103
124
  !changesSince(seam.base).some((c) => isCode(c.path) && !carried.includes(c.path))
104
125
  ? sliceBase
105
126
  : seam.base
106
- // The criteria stay in the tree for the next slice; no quality fix may move them.
107
- const guard = build.more ? () => requireCriteriaFrozen(REQUIREMENTS) : undefined
108
- await tail(settleQuality, { green: true, lensBase: base, guard, guardRests: false })
127
+ if (more) {
128
+ // The criteria stay in the tree for the next slice; no quality fix may move them.
129
+ const guard = () => requireCriteriaFrozen(REQUIREMENTS)
130
+ await tail(settleQuality, { green: true, lensBase: base, guard, guardRests: false })
131
+ return
132
+ }
133
+ const reviewBase = build.reviewBase ?? base
134
+ await featureFeedback(await reviewedTail(reviewBase, base), base, { closed, used })
109
135
  })
110
136
  }
111
137
 
112
- // The lap is feature's last look: findings the fix cap left must not end the process, nor reach
113
- // the next one's lap, which appends to the file. Empty counts as resolved, as the lap reads it.
138
+ // The lap's findings the fix cap left must not reach the review, nor the next process's lap,
139
+ // which appends to the file. Empty counts as resolved, as the lap reads it.
114
140
  const settleQuality = async (): Promise<void> => {
115
141
  let rested = false
116
142
  while ((read(QUALITY) ?? "").length > 0) {
@@ -121,10 +147,44 @@ const settleQuality = async (): Promise<void> => {
121
147
  file: QUALITY,
122
148
  })
123
149
  }
124
- // The person may have fixed code by hand there; nothing else checks it before idle.
150
+ // The person may have fixed code by hand there; nothing else checks it before the review.
125
151
  if (rested) await healthy(() => fixCheck())
126
152
  }
127
153
 
154
+ /** The quality lap, then the human review since `base`. */
155
+ const reviewedTail = (base: string, lensBase: string, lap?: "feedback"): Promise<ReviewOutcome> =>
156
+ tail(
157
+ async (escalations: EscalationCount) => {
158
+ await settleQuality()
159
+ return review(base, escalations, { lap })
160
+ },
161
+ { green: true, lensBase },
162
+ )
163
+
164
+ // seam.naive / seam.judge never run on a feedback lap: its design → implement is built unjudged
165
+ // and unrefactored, held to every criterion's coverage since the process start.
166
+ const featureFeedback = async (
167
+ first: ReviewOutcome,
168
+ lensBase: string,
169
+ ids: StableIds,
170
+ ): Promise<void> => {
171
+ let outcome = first
172
+ let closed = ids.closed
173
+ const handedOut = new Set([...ids.used, ...ids.closed])
174
+ while (outcome.verdict === "feedback") {
175
+ await reUnwind(outcome)
176
+ const lap = { closed, used: [...handedOut] }
177
+ // Gathered every round, so an ID one round adds and the next drops is never reused.
178
+ await design(outcome.base, () => {
179
+ requireStableIds(REQUIREMENTS, lap)
180
+ gather(handedOut)
181
+ })
182
+ const tier = riskTierOn() ? recordRisk() : undefined
183
+ closed = await implementing({ tier, amend: true })
184
+ outcome = await reviewedTail(outcome.base, lensBase, "feedback")
185
+ }
186
+ }
187
+
128
188
  /** Design — skipped when the sketch's own criteria are judged clear — then `buildFeature`. */
129
189
  export const planAndBuild = async (firstBase: string, stated?: StatedCriteria): Promise<void> => {
130
190
  // GTD_FLAG_RISK_TIER: the tier is proposed at triage and set at the design gate, so no skip.
@@ -67,7 +67,7 @@ Feature: flag mode off lands the naive pass with no release flag, seam judge or
67
67
  """
68
68
  And step "build.quality.correctness.reviewing" changes nothing
69
69
  When the process is driven
70
- Then the process rests at "idle"
70
+ Then the process rests at "build.review.reviewing"
71
71
  And "src/calc.ts" contains "subtract"
72
72
  And "src/calc.test.ts" contains "[AC-1] subtracts"
73
73
  And the commit subjects end with:
@@ -85,5 +85,5 @@ Feature: flag mode off lands the naive pass with no release flag, seam judge or
85
85
  gtd(check): implement.health.check → implement.coverage
86
86
  gtd(judge): implement.coverage → implement.closing
87
87
  gtd(check): implement.closing → build.quality.correctness.reviewing
88
- gtd(agent): build.quality.correctness.reviewing → idle
88
+ gtd(agent): build.quality.correctness.reviewing → build.review.reviewing
89
89
  """
@@ -5,7 +5,7 @@ Feature: a naive pass the judge rules land is itself the implementation
5
5
  With GTD_FLAG_RISK_TIER off, a naive pass that
6
6
  edits an existing source file is judged land: the process skips the approval
7
7
  gate and any refactor, applies the kept pass as it is — no build turn — and
8
- runs the health loop and quality back to idle, with no review. The flag is pinned here so the per-flag test runs never
8
+ runs the health loop and quality lap to the feature review. The flag is pinned here so the per-flag test runs never
9
9
  move it off this path.
10
10
 
11
11
  Background:
@@ -62,9 +62,9 @@ Feature: a naive pass the judge rules land is itself the implementation
62
62
  """
63
63
  And step "build.quality.correctness.reviewing" changes nothing
64
64
 
65
- Scenario: a land judgment applies the naive pass and runs idle to idle with no rebuild
65
+ Scenario: a land judgment applies the naive pass and runs from idle to the review gate with no rebuild
66
66
  When the process is driven
67
- Then the process rests at "idle"
67
+ Then the process rests at "build.review.reviewing"
68
68
  And "src/greeting.ts" contains "Hello, world"
69
69
  And "src/acceptance.test.ts" contains "[AC-1] holds"
70
70
  And the commit subjects end with:
@@ -81,7 +81,7 @@ Feature: a naive pass the judge rules land is itself the implementation
81
81
  gtd(check): implement.health.check → implement.coverage
82
82
  gtd(judge): implement.coverage → implement.closing
83
83
  gtd(check): implement.closing → build.quality.correctness.reviewing
84
- gtd(agent): build.quality.correctness.reviewing → idle
84
+ gtd(agent): build.quality.correctness.reviewing → build.review.reviewing
85
85
  """
86
86
  And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
87
87
  And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
@@ -25,13 +25,14 @@ const noteGate = async (file: string, answer: () => Promise<void>): Promise<bool
25
25
  return true
26
26
  }
27
27
 
28
- /** Triage the sketch since `base` into requirements, until no product question is open. */
29
- export const design = (base: string): Promise<void> =>
28
+ /** Triage the sketch since `base` into requirements, until no product question is open; `guard` holds every round's criteria. */
29
+ export const design = (base: string, guard?: () => void): Promise<void> =>
30
30
  scope("design", async () => {
31
31
  do {
32
32
  await triage(base)
33
33
  requireProgress(REQUIREMENTS)
34
34
  requireReplies(REQUIREMENTS)
35
35
  requireCriteria(REQUIREMENTS)
36
+ guard?.()
36
37
  } while (await noteGate(REQUIREMENTS, answerProductQuestions))
37
38
  })