@pmelab/gtd 29.2.3 → 29.2.5

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": "29.2.3",
3
+ "version": "29.2.5",
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
@@ -341,15 +341,16 @@ wait for the next rest. When the process finishes, Claude offers to squash it
341
341
  and open its pull request(s) (or run `/gtd ship`; `GTD_SHIP_MODE` picks one
342
342
  stacked pull request per slice or one commit per slice, see the
343
343
  [driver doc](docs/driver.md)); a process started for an issue ships with
344
- `Closes #N` on its top pull request; re-running `/gtd ship` after a lower
345
- stacked pull request merged restacks the rest (it refuses, with nothing changed,
346
- when that pull request merged anywhere but the default branch (unless into a
347
- lower slice branch before that one merged into it), a slice branch is checked
348
- out in another worktree, an unrelated local branch sits inside the rebased
349
- commits, or a slice's change is already upstream; a failed rebase is rolled
350
- back). A process can change hands at any gate: ask Claude to hand it off (or run
351
- `/gtd throw @dev`) and it opens a draft pull request assigned to them, and
352
- `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
344
+ `Closes #N` on its top pull request; a closed pull request whose branch was
345
+ deleted from origin is left behind and a new one opens; re-running `/gtd ship`
346
+ after a lower stacked pull request merged restacks the rest (it refuses, with
347
+ nothing changed, when that pull request merged anywhere but the default branch
348
+ (unless into a lower slice branch before that one merged into it), a slice
349
+ branch is checked out in another worktree, an unrelated local branch sits inside
350
+ the rebased commits, or a slice's change is already upstream; a failed rebase is
351
+ rolled back). A process can change hands at any gate: ask Claude to hand it off
352
+ (or run `/gtd throw @dev`) and it opens a draft pull request assigned to them,
353
+ and `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
353
354
  requirements can hand the architecture to someone else. Ship, hand-off and throw
354
355
  answer at once and run in the background, with progress on the status line; they
355
356
  report by waking Claude, a toast, a transcript line and a push notification, and
@@ -586,9 +587,11 @@ directly. A judge checks whether it changed anything outside the expected set
586
587
  change is refactored first, in its own slice with its own quality lap
587
588
  (`refactorQualityReviews`) and review, before the feature is implemented and
588
589
  reviewed; a wrong approach is retried twice, then rests for you to steer or
589
- overrule it. `gtd slices` lists the `refactor` and `feature` slices. Feedback at
590
- the feature review re-runs design and the implementation, never the naive pass;
591
- feedback at the refactor review re-runs the refactor, never design; a
590
+ overrule it. `gtd slices` lists the `refactor` and `feature` slices. A refactor
591
+ that already carried the whole feature leaves the implementation nothing to do:
592
+ its empty turn passes, and the feature review covers the whole change. Feedback
593
+ at the feature review re-runs design and the implementation, never the naive
594
+ pass; feedback at the refactor review re-runs the refactor, never design; a
592
595
  refactor-review change the refactor cannot build is refused, and the only way
593
596
  out is `gtd abandon`. A process from 27.5.0 or later whose judge ruled
594
597
  `refactor-first` must `gtd abandon` too. Each slice's quality lap reviews only
@@ -633,7 +633,13 @@ export const NO_PR_FOUND = "no pull requests found"
633
633
 
634
634
  // `gh pr view <branch>`: only NO_PR_FOUND reads as none; any
635
635
  // other failure (auth, network) must refuse, or ship would open a duplicate PR.
636
- async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | string> {
636
+ // A closed PR whose branch origin lost is an abandoned earlier ship and reads as
637
+ // none; `abandoned` is recorded before the push so a retry, with origin holding
638
+ // the branch again, still reads it as none. `gone`: a tracking ref is stale.
639
+ async function readPr(
640
+ io: ShipIo,
641
+ branch: string,
642
+ ): Promise<{ view?: View; gone?: boolean; abandoned?: number } | string> {
637
643
  const v = await io.run([
638
644
  "gh",
639
645
  "pr",
@@ -643,14 +649,41 @@ async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | st
643
649
  "number,title,body,url,state,baseRefName,mergedAt",
644
650
  ])
645
651
  if (v.code !== 0) {
646
- if (v.err.includes(NO_PR_FOUND)) return undefined
652
+ if (v.err.includes(NO_PR_FOUND)) return {}
647
653
  return `gh pr view ${branch} failed: ${v.err.trim()}`
648
654
  }
655
+ let view: View
649
656
  try {
650
- return JSON.parse(v.out) as View
657
+ view = JSON.parse(v.out) as View
651
658
  } catch {
652
659
  return `gh pr view ${branch} returned something unreadable.`
653
660
  }
661
+ if (view.state !== "CLOSED") return { view }
662
+ const key = await io.run(["git", "config", "--local", "--get", ABANDONED_KEY(branch)])
663
+ const recorded = key.out.trim() === String(view.number)
664
+ const ls = await io.run(["git", "ls-remote", "origin", `refs/heads/${branch}`])
665
+ const gone = ls.code === 0 && !ls.out.trim()
666
+ if (recorded) return { gone }
667
+ return gone ? { gone, abandoned: view.number } : { view }
668
+ }
669
+
670
+ const ABANDONED_KEY = (branch: string) => `branch.${branch}.gtdAbandonedPr`
671
+
672
+ // Once the new PR opens, gh pr view finds it: the record has served its purpose.
673
+ async function settled<T extends Shipped>(io: ShipIo, branch: string, done: T) {
674
+ if (done.ok) await io.run(["git", "config", "--local", "--unset", ABANDONED_KEY(branch)])
675
+ return done
676
+ }
677
+
678
+ async function abandon(io: ShipIo, branch: string, pr: number | undefined) {
679
+ if (pr === undefined) return undefined
680
+ const set = await io.run(["git", "config", "--local", ABANDONED_KEY(branch), String(pr)])
681
+ return set.code === 0 ? undefined : `git config failed.\n${set.err}`.trimEnd()
682
+ }
683
+
684
+ async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | string> {
685
+ const read = await readPr(io, branch)
686
+ return typeof read === "string" ? read : read.view
654
687
  }
655
688
 
656
689
  // `dead` names belong to a retired record gtd built itself: not a collision.
@@ -1512,10 +1545,13 @@ async function shipLink(
1512
1545
  const l = links[i]!
1513
1546
  const lower = i ? links[i - 1]! : undefined
1514
1547
  const base = baseOf(links, i, baseBranch)
1515
- const view = await viewPr(io, l.branch)
1516
- if (typeof view === "string")
1517
- return { step: "pull request", why: `${view} ${l.branch} was not pushed; /gtd ship retries.` }
1518
- const pushed = await pushLink(io, git, l, i < links.length - 1)
1548
+ const read = await readPr(io, l.branch)
1549
+ if (typeof read === "string")
1550
+ return { step: "pull request", why: `${read} ${l.branch} was not pushed; /gtd ship retries.` }
1551
+ const { view, gone } = read
1552
+ const marked = await abandon(io, l.branch, read.abandoned)
1553
+ if (marked) return { step: "push", why: marked }
1554
+ const pushed = await pushLink(io, git, { ...l, fresh: l.fresh || gone }, i < links.length - 1)
1519
1555
  if (pushed) return { step: "push", why: pushed }
1520
1556
  const lowerRef = lower?.branch ?? baseRef
1521
1557
  const ctx: Ctx = {
@@ -1528,6 +1564,7 @@ async function shipLink(
1528
1564
  }
1529
1565
  const done = await sliceStep(io, git, ok, l, view, base, lowerRef, ctx)
1530
1566
  if ("step" in done) return done
1567
+ if (!view) await settled(io, l.branch, done)
1531
1568
  if (done.number === undefined && i < links.length - 1)
1532
1569
  return { step: "pull request", why: "its number could not be read from gh's reply." }
1533
1570
  return done
@@ -1764,17 +1801,24 @@ async function pullRequest(
1764
1801
  if (mergeBase === (await git("rev-parse", "HEAD")))
1765
1802
  return fail(`HEAD is an ancestor of ${baseRef}: nothing to describe.`)
1766
1803
 
1767
- const existing = await viewPr(io, branch)
1768
- if (typeof existing === "string")
1804
+ const read = await readPr(io, branch)
1805
+ if (typeof read === "string")
1769
1806
  return fail(
1770
- `${existing} ${branch} was not pushed; the squash and history ref stay, /gtd ship retries.`,
1807
+ `${read} ${branch} was not pushed; the squash and history ref stay, /gtd ship retries.`,
1771
1808
  )
1809
+ const { view: existing, gone } = read
1772
1810
  if (existing && existing.state !== "OPEN")
1773
1811
  return fail(`The pull request for ${branch} is ${existing.state}, not open.`)
1774
- const pushed = await pushBranch(io, branch)
1812
+ const marked = await abandon(io, branch, read.abandoned)
1813
+ if (marked) return fail(`${marked} ${branch} was not pushed; /gtd ship retries.`)
1814
+ const pushed = await pushBranch(io, branch, "HEAD", undefined, gone)
1775
1815
  if (pushed) return fail(pushed)
1776
1816
  if (!existing)
1777
- return createPr(io, git, branch, baseBranch, baseRef, { from: mergeBase, to: "HEAD" })
1817
+ return settled(
1818
+ io,
1819
+ branch,
1820
+ await createPr(io, git, branch, baseBranch, baseRef, { from: mergeBase, to: "HEAD" }),
1821
+ )
1778
1822
  // A branch once shipped as a stack keeps the PR aimed at a slice branch; any
1779
1823
  // other base is the author's own and stays.
1780
1824
  const onSlice = recorded.some((n) => existing.baseRefName === `${branch}-${n}`)
@@ -1365,9 +1365,12 @@ re-implement. It changed these files outside the expected set:
1365
1365
  ${listed(rejection.strays)}
1366
1366
  Start over and choose a different approach; do not rebuild the same diff.
1367
1367
  `}`;
1368
- const implementBuildingPrompt = (reference, tier, amend) => `${stateFileRules}
1368
+ const implementBuildingPrompt = (reference, tier, amend, afterRefactor) => `${stateFileRules}
1369
1369
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1370
- ${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
1370
+ ${afterRefactor === true ? `- A reviewed refactor has just landed and may already carry part of the change,
1371
+ though never a release flag. Build only what is still missing, held to every
1372
+ rule below; if nothing is, change nothing and finish your turn
1373
+ ` : ""}${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
1371
1374
  the re-derived requirements instead of rewriting it
1372
1375
  ` : `- Implement them properly, fitting the codebase's seams and conventions
1373
1376
  `}${reference === void 0 || amend === true ? "" : `- Reference only: \`git show ${reference}\` shows an earlier naive attempt.
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, showScript, vars } from "../flows.mjs";
2
2
  import { fixFindings, lensesOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { E as reviewing, S as fixRisks, T as reviewQuality, W as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, c as escalation, d as healthy, m as QUALITY, u as guarded, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-BduvUQxQ.mjs";
3
+ import { E as reviewing, S as fixRisks, T as reviewQuality, W as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, c as escalation, d as healthy, m as QUALITY, u as guarded, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-C7JrDBnQ.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";
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { _t as registryEntry, a as flagShipped, dt as defaults, ft as envDefaults, ht as flagReferences, i as skills, n as summary, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-BduvUQxQ.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-pgq1Fbbv.mjs";
2
+ import { _t as registryEntry, a as flagShipped, dt as defaults, ft as envDefaults, ht as flagReferences, i as skills, n as summary, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-C7JrDBnQ.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-JAC-v_TW.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 { answered, changes, changesSince, glob, 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 } from "../flows.mjs";
2
2
  import { lensesOf, review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as refactorFixPrompt, A as architectureDecomposePrompt, B as designSystem, C as planner, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as idleMessage, I as buildReviewTriageMessage, J as naivePassPrompt, K as implementBuildingPrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as refactorDroppedRefusal, R as deferNotesScript, S as fixRisks, V as designTriagePrompt, X as packagesItemFixSuitePrompt, Y as packagesItemBuildingPrompt, Z as refactorBuildingPrompt, _ as REVIEW, at as reviewerSystem, b as fixCheck, ct as startGateBlockedMessage, d as healthy, dt as defaults, et as refactorRules, f as ARCHITECTURE, ft as envDefaults, g as REQUIREMENTS, h as REFACTOR, i as skills, it as reviewNotesCapture, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as gate, lt as unwindFailedMessage, n as summary, nt as reviewEditsCapture, o as shipped, ot as seamByHandMessage, p as FEEDBACK, pt as testFileGlobs, q as implementFixPrompt, r as access, st as seamSteerMessage, t as steering, tt as reviewEditNotesCapture, u as guarded, ut as unwindFailure, v as awaitReview, w as reviewMissing, y as coder, z as designGateAnswerMessage } from "../chunks/reserved-BduvUQxQ.mjs";
4
- import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-pgq1Fbbv.mjs";
3
+ import { $ as refactorFixPrompt, A as architectureDecomposePrompt, B as designSystem, C as planner, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as idleMessage, I as buildReviewTriageMessage, J as naivePassPrompt, K as implementBuildingPrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as refactorDroppedRefusal, R as deferNotesScript, S as fixRisks, V as designTriagePrompt, X as packagesItemFixSuitePrompt, Y as packagesItemBuildingPrompt, Z as refactorBuildingPrompt, _ as REVIEW, at as reviewerSystem, b as fixCheck, ct as startGateBlockedMessage, d as healthy, dt as defaults, et as refactorRules, f as ARCHITECTURE, ft as envDefaults, g as REQUIREMENTS, h as REFACTOR, i as skills, it as reviewNotesCapture, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as gate, lt as unwindFailedMessage, n as summary, nt as reviewEditsCapture, o as shipped, ot as seamByHandMessage, p as FEEDBACK, pt as testFileGlobs, q as implementFixPrompt, r as access, st as seamSteerMessage, t as steering, tt as reviewEditNotesCapture, u as guarded, ut as unwindFailure, v as awaitReview, w as reviewMissing, y as coder, z as designGateAnswerMessage } from "../chunks/reserved-C7JrDBnQ.mjs";
4
+ import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-JAC-v_TW.mjs";
5
5
  //#region src/workflows/shared/fences.ts
6
6
  const markFenced = (text) => {
7
7
  let fence;
@@ -475,16 +475,26 @@ const review = (base, escalations = { rounds: 0 }, options = {}) => {
475
475
  }
476
476
  });
477
477
  };
478
- function buildTail(first, second, third = false, fourth = {}) {
479
- if (typeof first !== "string" && typeof first !== "boolean" && typeof second !== "string") return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
480
- const [base, options] = typeof first === "string" ? [first, second ?? {}] : [second, {
481
- ...fourth,
482
- fixFirst: first,
483
- green: third
484
- }];
485
- const { fixFirst, green, ...reviewOptions } = options;
486
- return tail(fixFirst ?? false, (escalations) => review(base, escalations, reviewOptions), green ?? false, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
478
+ function buildTail(first, second, ...rest) {
479
+ const options = callForm(first, second, rest);
480
+ if (options === void 0) return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
481
+ const [base, form] = options;
482
+ const { fixFirst, green, ...reviewOptions } = form;
483
+ return tail(fixFirst ?? false, (escalations) => review(base, escalations, reviewOptions), green ?? false, form.guard, form.lenses, form.guardRests, form.lensBase, form.rules);
487
484
  }
485
+ const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
486
+ const isOptionalBoolean = (value) => value == null || typeof value === "boolean";
487
+ const legacyForm = (fixFirst, base, green, options) => isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options) ? [base, {
488
+ ...options,
489
+ fixFirst: fixFirst ?? void 0,
490
+ green: green ?? void 0
491
+ }] : void 0;
492
+ const currentForm = (base, options, third, fourth) => isOptionalObject(options) && third == null && fourth == null ? [base, options ?? {}] : void 0;
493
+ const callForm = (first, second, [third, fourth, ...extra]) => {
494
+ if (extra.some((value) => value != null)) return void 0;
495
+ if (typeof second === "string") return legacyForm(first, second, third, fourth);
496
+ if (typeof first === "string") return currentForm(first, second, third, fourth);
497
+ };
488
498
  /**
489
499
  * Undo the human's review-round code edits. A path changed since the human
490
500
  * left it is not overwritten; requireRevert names it instead.
@@ -764,10 +774,11 @@ function implement(first, tier, amend) {
764
774
  amend
765
775
  };
766
776
  return scope("implement", async () => {
767
- await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend), {
777
+ await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend, options.afterRefactor), {
768
778
  label: "Implementing",
769
779
  model: coder(),
770
- system: builderSystem()
780
+ system: builderSystem(),
781
+ ...options.afterRefactor === true ? { allowEmpty: true } : {}
771
782
  });
772
783
  await healthy(() => agentWithSkills("fix.suite.fixing", implementFixPrompt(), {
773
784
  label: "Fixing the check",
@@ -830,10 +841,11 @@ const seamBuild = async (base) => {
830
841
  reference,
831
842
  base
832
843
  };
833
- await slice("refactor", () => reviewedRefactor(shape, { reference }));
844
+ const refactored = await slice("refactor", () => reviewedRefactor(shape, { reference }));
834
845
  return {
835
846
  reference,
836
- base: head()
847
+ base: head(),
848
+ refactored
837
849
  };
838
850
  };
839
851
  //#endregion
@@ -869,12 +881,14 @@ const naivePlanAndBuild = async (firstBase) => {
869
881
  await slice("feature", async () => {
870
882
  await implement({
871
883
  reference: seam.reference,
872
- tier
884
+ tier,
885
+ afterRefactor: seam.refactored
873
886
  });
874
- await featureFeedback(await buildTail(seam.base, {
887
+ const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path)) ? firstBase : seam.base;
888
+ await featureFeedback(await buildTail(base, {
875
889
  green: true,
876
- lensBase: seam.base
877
- }), seam.base);
890
+ lensBase: base
891
+ }), base);
878
892
  });
879
893
  };
880
894
  const featureFeedback = async (first, lensBase) => {
@@ -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 { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, _ as REVIEW, dt as defaults, ft as envDefaults, i as skills, n as summary, o as shipped, r as access, s as baseline, t as steering, y as coder } from "../chunks/reserved-BduvUQxQ.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-pgq1Fbbv.mjs";
3
+ import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, _ as REVIEW, dt as defaults, ft as envDefaults, i as skills, n as summary, o as shipped, r as access, s as baseline, t as steering, y as coder } from "../chunks/reserved-C7JrDBnQ.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-JAC-v_TW.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 { dt as defaults, ft as envDefaults, i as skills, l as gate, n as summary, o as shipped, r as access, rt as reviewGateBlockedMessage, t as steering } from "../chunks/reserved-BduvUQxQ.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-pgq1Fbbv.mjs";
2
+ import { dt as defaults, ft as envDefaults, i as skills, l as gate, n as summary, o as shipped, r as access, rt as reviewGateBlockedMessage, t as steering } from "../chunks/reserved-C7JrDBnQ.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-JAC-v_TW.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 { a as flagShipped, dt as defaults, ft as envDefaults, gt as flipProblems, i as skills, l as gate, mt as flagMentions, n as summary, p as FEEDBACK, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-BduvUQxQ.mjs";
2
+ import { a as flagShipped, dt as defaults, ft as envDefaults, gt as flipProblems, i as skills, l as gate, mt as flagMentions, n as summary, p as FEEDBACK, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-C7JrDBnQ.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.";
@@ -18149,9 +18149,12 @@ re-implement. It changed these files outside the expected set:
18149
18149
  ${listed(rejection.strays)}
18150
18150
  Start over and choose a different approach; do not rebuild the same diff.
18151
18151
  `}`;
18152
- const implementBuildingPrompt = (reference, tier, amend) => `${stateFileRules}
18152
+ const implementBuildingPrompt = (reference, tier, amend, afterRefactor) => `${stateFileRules}
18153
18153
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
18154
- ${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
18154
+ ${afterRefactor === true ? `- A reviewed refactor has just landed and may already carry part of the change,
18155
+ though never a release flag. Build only what is still missing, held to every
18156
+ rule below; if nothing is, change nothing and finish your turn
18157
+ ` : ""}${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
18155
18158
  the re-derived requirements instead of rewriting it
18156
18159
  ` : `- Implement them properly, fitting the codebase's seams and conventions
18157
18160
  `}${reference === void 0 || amend === true ? "" : `- Reference only: \`git show ${reference}\` shows an earlier naive attempt.
@@ -38312,16 +38315,26 @@ const review$1 = (base, escalations = { rounds: 0 }, options = {}) => {
38312
38315
  }
38313
38316
  });
38314
38317
  };
38315
- function buildTail(first, second, third = false, fourth = {}) {
38316
- if (typeof first !== "string" && typeof first !== "boolean" && typeof second !== "string") return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
38317
- const [base, options] = typeof first === "string" ? [first, second ?? {}] : [second, {
38318
- ...fourth,
38319
- fixFirst: first,
38320
- green: third
38321
- }];
38322
- const { fixFirst, green, ...reviewOptions } = options;
38323
- return tail(fixFirst ?? false, (escalations) => review$1(base, escalations, reviewOptions), green ?? false, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
38318
+ function buildTail(first, second, ...rest) {
38319
+ const options = callForm(first, second, rest);
38320
+ if (options === void 0) return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
38321
+ const [base, form] = options;
38322
+ const { fixFirst, green, ...reviewOptions } = form;
38323
+ return tail(fixFirst ?? false, (escalations) => review$1(base, escalations, reviewOptions), green ?? false, form.guard, form.lenses, form.guardRests, form.lensBase, form.rules);
38324
38324
  }
38325
+ const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
38326
+ const isOptionalBoolean = (value) => value == null || typeof value === "boolean";
38327
+ const legacyForm = (fixFirst, base, green, options) => isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options) ? [base, {
38328
+ ...options,
38329
+ fixFirst: fixFirst ?? void 0,
38330
+ green: green ?? void 0
38331
+ }] : void 0;
38332
+ const currentForm = (base, options, third, fourth) => isOptionalObject(options) && third == null && fourth == null ? [base, options ?? {}] : void 0;
38333
+ const callForm = (first, second, [third, fourth, ...extra]) => {
38334
+ if (extra.some((value) => value != null)) return void 0;
38335
+ if (typeof second === "string") return legacyForm(first, second, third, fourth);
38336
+ if (typeof first === "string") return currentForm(first, second, third, fourth);
38337
+ };
38325
38338
  /**
38326
38339
  * Undo the human's review-round code edits. A path changed since the human
38327
38340
  * left it is not overwritten; requireRevert names it instead.
@@ -38601,10 +38614,11 @@ function implement(first, tier, amend) {
38601
38614
  amend
38602
38615
  };
38603
38616
  return scope("implement", async () => {
38604
- await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend), {
38617
+ await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend, options.afterRefactor), {
38605
38618
  label: "Implementing",
38606
38619
  model: coder(),
38607
- system: builderSystem()
38620
+ system: builderSystem(),
38621
+ ...options.afterRefactor === true ? { allowEmpty: true } : {}
38608
38622
  });
38609
38623
  await healthy(() => agentWithSkills("fix.suite.fixing", implementFixPrompt(), {
38610
38624
  label: "Fixing the check",
@@ -38667,10 +38681,11 @@ const seamBuild = async (base) => {
38667
38681
  reference,
38668
38682
  base
38669
38683
  };
38670
- await slice("refactor", () => reviewedRefactor(shape, { reference }));
38684
+ const refactored = await slice("refactor", () => reviewedRefactor(shape, { reference }));
38671
38685
  return {
38672
38686
  reference,
38673
- base: head$1()
38687
+ base: head$1(),
38688
+ refactored
38674
38689
  };
38675
38690
  };
38676
38691
  //#endregion
@@ -38706,12 +38721,14 @@ const naivePlanAndBuild = async (firstBase) => {
38706
38721
  await slice("feature", async () => {
38707
38722
  await implement({
38708
38723
  reference: seam.reference,
38709
- tier
38724
+ tier,
38725
+ afterRefactor: seam.refactored
38710
38726
  });
38711
- await featureFeedback(await buildTail(seam.base, {
38727
+ const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path)) ? firstBase : seam.base;
38728
+ await featureFeedback(await buildTail(base, {
38712
38729
  green: true,
38713
- lensBase: seam.base
38714
- }), seam.base);
38730
+ lensBase: base
38731
+ }), base);
38715
38732
  });
38716
38733
  };
38717
38734
  const featureFeedback = async (first, lensBase) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.2.3",
3
+ "version": "29.2.5",
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": {
@@ -50,7 +50,12 @@ const CALC = "export const add = (a: number, b: number) => a + b\n"
50
50
  const naivePlanned = (flag: "0" | "1") => {
51
51
  const repo = testRepo({
52
52
  workflow: CONFIG_SCAFFOLD,
53
- env: { GTD_QUALITYREVIEWS: "", GTD_FLAG_NAIVE_PASS: flag, GTD_FLAG_RISK_TIER: "0" },
53
+ env: {
54
+ GTD_QUALITYREVIEWS: "",
55
+ GTD_REFACTORQUALITYREVIEWS: "",
56
+ GTD_FLAG_NAIVE_PASS: flag,
57
+ GTD_FLAG_RISK_TIER: "0",
58
+ },
54
59
  })
55
60
  repo.write("src/calc.ts", CALC)
56
61
  repo.commit("feat: add calculator")
@@ -59,6 +64,40 @@ const naivePlanned = (flag: "0" | "1") => {
59
64
  }
60
65
 
61
66
  describe("planAndBuild on the naive pass", () => {
67
+ it("a refactor that carried the whole feature: the empty implement turn passes and the review covers the whole change", async () => {
68
+ const repo = naivePlanned("1")
69
+ const SUBTRACT = `${CALC}export const subtract = (a: number, b: number) => a - b\n`
70
+ expect(
71
+ (
72
+ await repo.drive({
73
+ unwind: () => undefined,
74
+ "start-gate.check": () => undefined,
75
+ "design.triage": (_, r) =>
76
+ r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n"),
77
+ "design.gate.answer": () => undefined,
78
+ "seam.naive": (_, r) => r.write("src/calc.ts", SUBTRACT),
79
+ "seam.judge": () => '[{"id":"shape","answer":"refactor-first","p":0.9}]',
80
+ "refactor.deriving": runsItsScript,
81
+ "refactor.building": (_, r) => r.write("src/calc.ts", SUBTRACT),
82
+ "refactor.health.check": () => undefined,
83
+ "refactor.closing": runsItsScript,
84
+ "refactor.build.review.reviewing": (_, r) => r.write(".gtd/REVIEW.md", review()),
85
+ })
86
+ ).state,
87
+ ).toBe("refactor.build.review.await-review")
88
+ await repo.land()
89
+ const rest = await repo.drive({
90
+ "refactor.build.review.closing": runsItsScript,
91
+ "implement.building": () => undefined,
92
+ "implement.health.check": () => undefined,
93
+ "implement.closing": runsItsScript,
94
+ "build.review.reviewing": (_, r) => r.write(".gtd/REVIEW.md", review()),
95
+ })
96
+ expect(rest.state).toBe("build.review.await-review")
97
+ const base = (await repo.gtd("base")).stdout.trim()
98
+ expect(repo.show(base, "src/calc.ts")).toBe(CALC)
99
+ })
100
+
62
101
  it("after land, feedback rests next at design.triage, then implement.building, never seam.naive", async () => {
63
102
  const repo = naivePlanned("1")
64
103
  const requirements = (_: unknown, r: typeof repo) =>
@@ -141,7 +180,12 @@ describe("afterTail feedback lap", () => {
141
180
  prompts.length = 0
142
181
  const repo = testRepo({
143
182
  workflow: TAIL,
144
- env: { GTD_QUALITYREVIEWS: "", GTD_FLAG_NAIVE_PASS: flag, GTD_FLAG_RISK_TIER: "0" },
183
+ env: {
184
+ GTD_QUALITYREVIEWS: "",
185
+ GTD_REFACTORQUALITYREVIEWS: "",
186
+ GTD_FLAG_NAIVE_PASS: flag,
187
+ GTD_FLAG_RISK_TIER: "0",
188
+ },
145
189
  })
146
190
  repo.write("src/calc.ts", CALC)
147
191
  repo.commit("feat: add calculator")
@@ -1,4 +1,14 @@
1
- import { changes, head, human, revertScript, run, slice, start, vars } from "../../flows/index.js"
1
+ import {
2
+ changes,
3
+ changesSince,
4
+ head,
5
+ human,
6
+ revertScript,
7
+ run,
8
+ slice,
9
+ start,
10
+ vars,
11
+ } from "../../flows/index.js"
2
12
  import { gate } from "../shared/health.js"
3
13
  import { shipped } from "../shared/options.js"
4
14
  import { FEEDBACK } from "../shared/steps.js"
@@ -6,7 +16,7 @@ import { refusePendingHandOff } from "../shared/tail.js"
6
16
  import * as t from "../shared/text.js"
7
17
  import { packages } from "./packages.js"
8
18
  import { architecture, design } from "./planning.js"
9
- import { buildTail, reUnwind, type ReviewOutcome } from "./review.js"
19
+ import { buildTail, isCode, reUnwind, type ReviewOutcome } from "./review.js"
10
20
  import { implement, seamBuild } from "./seam.js"
11
21
  import { recordRisk, riskTierOn } from "./tier.js"
12
22
 
@@ -54,11 +64,13 @@ const naivePlanAndBuild = async (firstBase: string): Promise<void> => {
54
64
  const tier = riskTierOn() ? recordRisk() : undefined
55
65
  const seam = await seamBuild(firstBase)
56
66
  await slice("feature", async () => {
57
- await implement({ reference: seam.reference, tier })
58
- await featureFeedback(
59
- await buildTail(seam.base, { green: true, lensBase: seam.base }),
60
- seam.base,
61
- )
67
+ await implement({ reference: seam.reference, tier, afterRefactor: seam.refactored })
68
+ // An empty feature slice: the refactor carried the feature, so review the whole change.
69
+ const base =
70
+ seam.refactored === true && !changesSince(seam.base).some((c) => isCode(c.path))
71
+ ? firstBase
72
+ : seam.base
73
+ await featureFeedback(await buildTail(base, { green: true, lensBase: base }), base)
62
74
  })
63
75
  }
64
76
 
@@ -0,0 +1,125 @@
1
+ # One SDLC path per file, so a changed path reds exactly one fixture — do not merge.
2
+ @inmem
3
+ Feature: a refactor that carried the whole feature still gets a feature review
4
+
5
+ With GTD_FLAG_NAIVE_PASS on and GTD_FLAG_RISK_TIER off, a refactor-first
6
+ refactor can land everything the requirements ask for. The feature slice's
7
+ implement turn then has nothing to author: its empty turn completes the
8
+ step, and with no code changed since the refactor the feature review covers
9
+ the whole change, refactor included.
10
+
11
+ Background:
12
+ Given the shipped workflows, as gtd install scaffolds them
13
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
14
+ And an environment variable "GTD_REFACTORQUALITYREVIEWS" set to ""
15
+ And an environment variable "GTD_FLAG_NAIVE_PASS" set to "1"
16
+ And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
17
+ And a file "src/calc.ts" with:
18
+ """
19
+ export const add = (a: number, b: number) => a + b
20
+ """
21
+ And the working tree is committed as "feat: add calculator"
22
+ And a file "TODO.md" with:
23
+ """
24
+ # Add subtract
25
+
26
+ subtract(a, b) returns the difference.
27
+ """
28
+ And step "unwind" changes nothing
29
+ And step "start-gate.check" changes nothing
30
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
31
+ """
32
+ # Requirements
33
+
34
+ ## Concerns
35
+
36
+ ### Subtract
37
+
38
+ - `subtract(a, b)` returns `a - b`.
39
+ """
40
+ And step "design.gate.answer" changes nothing
41
+
42
+ Scenario: an empty implement turn after a refactor that did it all reaches review and idle
43
+ Given step "seam.naive" writes "src/calc.ts" with:
44
+ """
45
+ export const add = (a: number, b: number) => a + b
46
+ export const subtract = (a: number, b: number) => a - b
47
+ """
48
+ And judge step "seam.judge" answers:
49
+ """
50
+ [{"id":"shape","answer":"refactor-first","p":0.9}]
51
+ """
52
+ And step "refactor.deriving" runs its script
53
+ And step "refactor.building" writes "src/calc.ts" with:
54
+ """
55
+ export const add = (a: number, b: number) => a + b
56
+ export const subtract = (a: number, b: number) => a - b
57
+ """
58
+ And step "refactor.health.check" changes nothing
59
+ And step "refactor.closing" runs its script
60
+ And step "refactor.build.review.reviewing" writes ".gtd/REVIEW.md" with:
61
+ """
62
+ # Review: abc1234
63
+
64
+ <!-- base: 0000000 -->
65
+
66
+ ## calc
67
+
68
+ - [ ] ./src/calc.ts#1-2
69
+ subtract next to add
70
+ """
71
+ And step "refactor.build.review.closing" runs its script
72
+ When the process is driven
73
+ Then the process rests at "refactor.build.review.await-review"
74
+
75
+ Given a file ".gtd/REVIEW.md" with:
76
+ """
77
+ # Review: abc1234
78
+
79
+ <!-- base: 0000000 -->
80
+
81
+ ## calc
82
+
83
+ - [x] ./src/calc.ts#1-2
84
+ subtract next to add
85
+ """
86
+ And step "implement.building" changes nothing
87
+ And step "implement.health.check" changes nothing
88
+ And step "implement.closing" runs its script
89
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
90
+ """
91
+ # Review: abc1234
92
+
93
+ <!-- base: 0000000 -->
94
+
95
+ ## calc
96
+
97
+ - [ ] ./src/calc.ts#1-2
98
+ subtract next to add
99
+ """
100
+ When gtd lands
101
+ And the process is driven
102
+ Then the process rests at "build.review.await-review"
103
+ And ".gtd/REQUIREMENTS.md" does not exist
104
+
105
+ Given step "build.review.closing" runs its script
106
+ When gtd lands
107
+ And the process is driven
108
+ Then the process rests at "idle"
109
+ And "src/calc.ts" contains "subtract"
110
+ And the commit subjects end with:
111
+ """
112
+ gtd(human): refactor.build.review.await-review → refactor.build.review.closing
113
+ gtd(check): refactor.build.review.closing → implement.building
114
+ gtd(agent): implement.building → implement.health.check
115
+ gtd(check): implement.health.check → implement.closing
116
+ gtd(check): implement.closing → build.review.reviewing
117
+ gtd(agent): build.review.reviewing → build.review.await-review
118
+ gtd(human): build.review.await-review → build.review.closing
119
+ gtd(check): build.review.closing → idle
120
+ """
121
+ And the output lines of gtd "slices" start with:
122
+ """
123
+ refactor
124
+ feature
125
+ """
@@ -916,6 +916,83 @@ describe("BuildTailOptions.lensBase", () => {
916
916
  expect(names).toEqual([])
917
917
  })
918
918
 
919
+ const fitsNeither: [string, () => Promise<unknown>][] = [
920
+ ["a boolean with no base", () => buildTail(true as never)],
921
+ ["false followed by an options object", () => buildTail(false as never, {} as never)],
922
+ ["a base followed by a boolean", () => buildTail("base", true as never)],
923
+ ["a base followed by a string array", () => buildTail("base", ["x"] as never)],
924
+ [
925
+ "a base with a third argument",
926
+ () => (buildTail as (...args: unknown[]) => Promise<unknown>)("base", {}, true),
927
+ ],
928
+ ["a number followed by a base", () => buildTail(1 as never, "base")],
929
+ ["a string fixFirst followed by a base", () => buildTail("x" as never, "base")],
930
+ ["a legacy call with a non-boolean green", () => buildTail(false, "base", 1 as never)],
931
+ ["a legacy call with non-object options", () => buildTail(false, "base", true, 1 as never)],
932
+ ["a base followed by a Promise", () => buildTail("base", Promise.resolve({}) as never)],
933
+ [
934
+ "a base with a fifth argument",
935
+ () =>
936
+ (buildTail as (...args: unknown[]) => Promise<unknown>)(
937
+ "base",
938
+ {},
939
+ undefined,
940
+ undefined,
941
+ {},
942
+ ),
943
+ ],
944
+ [
945
+ "a legacy call with a fifth argument",
946
+ () => (buildTail as (...args: unknown[]) => Promise<unknown>)(false, "base", true, {}, {}),
947
+ ],
948
+ ]
949
+ for (const [label, call] of fitsNeither) {
950
+ it(`rejects ${label} before any step runs`, async () => {
951
+ const names: string[] = []
952
+ const rejected = expect(requestNames(call, names)).rejects
953
+ await rejected.toThrow("buildTail(base, options?)")
954
+ await rejected.toThrow("buildTail(fixFirst, base, green?, options?)")
955
+ expect(names).toEqual([])
956
+ })
957
+ }
958
+
959
+ it("reads null and trailing undefined arguments as absent", async () => {
960
+ const omitted = await requestNames(() => buildTail("base"))
961
+ expect(await requestNames(() => buildTail(null as never, "base", null as never))).toEqual(
962
+ omitted,
963
+ )
964
+ expect(
965
+ await requestNames(() =>
966
+ (buildTail as (...args: unknown[]) => Promise<unknown>)(
967
+ "base",
968
+ {},
969
+ null,
970
+ undefined,
971
+ null,
972
+ ),
973
+ ),
974
+ ).toEqual(omitted)
975
+ expect(await requestNames(() => buildTail("base", null as never))).toEqual(omitted)
976
+ expect(await requestNames(() => buildTail(false, "base", false, null as never))).toEqual(
977
+ omitted,
978
+ )
979
+ })
980
+
981
+ it("accepts a class instance as options", async () => {
982
+ class Options {
983
+ green = true
984
+ }
985
+ expect(await requestNames(() => buildTail("base", new Options()))).toEqual(
986
+ await requestNames(() => buildTail("base", { green: true })),
987
+ )
988
+ })
989
+
990
+ it("accepts a null-prototype options object", async () => {
991
+ expect(await requestNames(() => buildTail("base", Object.create(null) as never))).toEqual(
992
+ await requestNames(() => buildTail("base")),
993
+ )
994
+ })
995
+
919
996
  it("still runs buildTail(undefined, base) as a legacy call", async () => {
920
997
  const names: string[] = []
921
998
  await requestNames(() => buildTail(undefined as never, "base"), names)
@@ -158,7 +158,7 @@ const triage =
158
158
  )
159
159
  }
160
160
 
161
- const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
161
+ export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
162
162
 
163
163
  /**
164
164
  * A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
@@ -289,34 +289,71 @@ export function buildTail(
289
289
  export function buildTail(
290
290
  first: boolean | string,
291
291
  second?: string | BuildTailOptions,
292
- third = false,
293
- fourth: Omit<BuildTailOptions, "fixFirst" | "green"> = {},
292
+ ...rest: unknown[]
294
293
  ): Promise<kit.ReviewOutcome> {
294
+ const options = callForm(first, second, rest)
295
295
  // Before any step runs, so a bad call never leaves a half-walked flow.
296
- if (typeof first !== "string" && typeof first !== "boolean" && typeof second !== "string") {
296
+ if (options === undefined) {
297
297
  return Promise.reject(
298
298
  new Error(
299
299
  "buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither",
300
300
  ),
301
301
  )
302
302
  }
303
- const [base, options]: [string, BuildTailOptions] =
304
- typeof first === "string"
305
- ? [first, (second as BuildTailOptions | undefined) ?? {}]
306
- : [second as string, { ...fourth, fixFirst: first, green: third }]
307
- const { fixFirst, green, ...reviewOptions } = options
303
+ const [base, form] = options
304
+ const { fixFirst, green, ...reviewOptions } = form
308
305
  return tail(
309
306
  fixFirst ?? false,
310
307
  (escalations) => review(base, escalations, reviewOptions),
311
308
  green ?? false,
312
- options.guard,
313
- options.lenses,
314
- options.guardRests,
315
- options.lensBase,
316
- options.rules,
309
+ form.guard,
310
+ form.lenses,
311
+ form.guardRests,
312
+ form.lensBase,
313
+ form.rules,
317
314
  )
318
315
  }
319
316
 
317
+ // null reads as absent; an un-awaited Promise is a mistake, not options.
318
+ const isOptionalObject = (value: unknown): value is BuildTailOptions | null | undefined =>
319
+ value == null ||
320
+ (typeof value === "object" &&
321
+ !Array.isArray(value) &&
322
+ typeof (value as { then?: unknown }).then !== "function")
323
+
324
+ const isOptionalBoolean = (value: unknown): value is boolean | null | undefined =>
325
+ value == null || typeof value === "boolean"
326
+
327
+ const legacyForm = (
328
+ fixFirst: unknown,
329
+ base: string,
330
+ green: unknown,
331
+ options: unknown,
332
+ ): [string, BuildTailOptions] | undefined =>
333
+ isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options)
334
+ ? [base, { ...options, fixFirst: fixFirst ?? undefined, green: green ?? undefined }]
335
+ : undefined
336
+
337
+ const currentForm = (
338
+ base: string,
339
+ options: unknown,
340
+ third: unknown,
341
+ fourth: unknown,
342
+ ): [string, BuildTailOptions] | undefined =>
343
+ isOptionalObject(options) && third == null && fourth == null ? [base, options ?? {}] : undefined
344
+
345
+ // gtd.config.ts is untyped at runtime, so every argument is checked against the form chosen.
346
+ const callForm = (
347
+ first: unknown,
348
+ second: unknown,
349
+ [third, fourth, ...extra]: readonly unknown[],
350
+ ): [string, BuildTailOptions] | undefined => {
351
+ if (extra.some((value) => value != null)) return undefined
352
+ if (typeof second === "string") return legacyForm(first, second, third, fourth)
353
+ if (typeof first === "string") return currentForm(first, second, third, fourth)
354
+ return undefined
355
+ }
356
+
320
357
  /**
321
358
  * Undo the human's review-round code edits. A path changed since the human
322
359
  * left it is not overwritten; requireRevert names it instead.
@@ -179,7 +179,7 @@ describe("seamBuild", () => {
179
179
 
180
180
  it("refactor-first runs the reviewed refactor in slice refactor and resolves HEAD after it", async () => {
181
181
  const h = await driveSeam("refactor-first")
182
- expect(h.outcome).toEqual({ reference: "abc", base: "after-refactor" })
182
+ expect(h.outcome).toEqual({ reference: "abc", base: "after-refactor", refactored: true })
183
183
  expect(h.slices).toEqual(["refactor"])
184
184
  expect(h.inside.refactor![0]).toBe("refactor.deriving")
185
185
  expect(h.inside.refactor).toContain("refactor.build.review.closing")
@@ -221,6 +221,7 @@ describe("seamBuild", () => {
221
221
  })
222
222
 
223
223
  describe("implement", () => {
224
+ let lastOptions: Extract<StepRequest, { kind: "agent" }>["options"] | undefined
224
225
  const building = async (
225
226
  reference: string | undefined,
226
227
  tier?: "low" | "high",
@@ -228,6 +229,7 @@ describe("implement", () => {
228
229
  call: () => Promise<void> = () => implement({ reference, tier, amend }),
229
230
  ) => {
230
231
  const requests: StepRequest[] = []
232
+ lastOptions = undefined
231
233
  installContext(
232
234
  fixtureContext(
233
235
  {},
@@ -245,9 +247,24 @@ describe("implement", () => {
245
247
  await call()
246
248
  const request = requests.find((r) => r.kind === "agent" && r.name === "building")
247
249
  if (request?.kind !== "agent") throw new Error("no building turn")
250
+ lastOptions = request.options
248
251
  return request.prompt
249
252
  }
250
253
 
254
+ it("after a refactor, lets an empty turn complete the step and says so", async () => {
255
+ const prompt = await building("abc", undefined, undefined, () =>
256
+ implement({ reference: "abc", afterRefactor: true }),
257
+ )
258
+ expect(lastOptions?.allowEmpty).toBe(true)
259
+ expect(prompt).toMatch(/change\s+nothing and finish your turn/)
260
+ })
261
+
262
+ it("without a refactor, an empty turn stays an attempt", async () => {
263
+ const prompt = await building("abc")
264
+ expect(lastOptions?.allowEmpty).toBeUndefined()
265
+ expect(prompt).not.toMatch(/change\s+nothing/)
266
+ })
267
+
251
268
  it("the deprecated positional form renders the same prompt as the options form", async () => {
252
269
  const modern = await building("abc", "low")
253
270
  const legacy = await building("abc", "low", undefined, () => implement("abc", "low"))
@@ -45,6 +45,8 @@ export interface ImplementOptions {
45
45
  readonly reference?: string | undefined
46
46
  readonly tier?: RiskTier | undefined
47
47
  readonly amend?: boolean | undefined
48
+ /** After a refactor slice, which may already carry the feature: an empty turn completes the step. */
49
+ readonly afterRefactor?: boolean | undefined
48
50
  }
49
51
 
50
52
  export function implement(options?: ImplementOptions): Promise<void>
@@ -64,11 +66,17 @@ export function implement(
64
66
  return scope("implement", async () => {
65
67
  await t.agentWithSkills(
66
68
  "building",
67
- t.implementBuildingPrompt(options.reference, options.tier, options.amend),
69
+ t.implementBuildingPrompt(
70
+ options.reference,
71
+ options.tier,
72
+ options.amend,
73
+ options.afterRefactor,
74
+ ),
68
75
  {
69
76
  label: "Implementing",
70
77
  model: coder(),
71
78
  system: t.builderSystem(),
79
+ ...(options.afterRefactor === true ? { allowEmpty: true } : {}),
72
80
  },
73
81
  )
74
82
  await healthy(() =>
@@ -124,6 +132,7 @@ export interface SeamOutcome {
124
132
  readonly reference: string
125
133
  /** The feature review's base: HEAD after the refactor on refactor-first, else the caller's base. */
126
134
  readonly base: string
135
+ readonly refactored?: boolean
127
136
  }
128
137
 
129
138
  export const seamBuild = async (base: string): Promise<SeamOutcome> => {
@@ -140,6 +149,6 @@ export const seamBuild = async (base: string): Promise<SeamOutcome> => {
140
149
  return { reference, base }
141
150
  }
142
151
  if (refactorStrays(shape).length === 0) return { reference, base }
143
- await slice("refactor", () => reviewedRefactor(shape, { reference }))
144
- return { reference, base: head() }
152
+ const refactored = await slice("refactor", () => reviewedRefactor(shape, { reference }))
153
+ return { reference, base: head(), refactored }
145
154
  }
@@ -1290,17 +1290,25 @@ export const implementBuildingPrompt = (
1290
1290
  reference: string | undefined,
1291
1291
  tier?: "low" | "high",
1292
1292
  amend?: boolean,
1293
+ afterRefactor?: boolean,
1293
1294
  ): string =>
1294
1295
  `${stateFileRules}
1295
1296
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1296
1297
  ${
1297
- amend === true
1298
- ? `- The change already exists in the tree from an earlier lap; amend it to meet
1298
+ afterRefactor === true
1299
+ ? `- A reviewed refactor has just landed and may already carry part of the change,
1300
+ though never a release flag. Build only what is still missing, held to every
1301
+ rule below; if nothing is, change nothing and finish your turn
1302
+ `
1303
+ : ""
1304
+ }${
1305
+ amend === true
1306
+ ? `- The change already exists in the tree from an earlier lap; amend it to meet
1299
1307
  the re-derived requirements instead of rewriting it
1300
1308
  `
1301
- : `- Implement them properly, fitting the codebase's seams and conventions
1309
+ : `- Implement them properly, fitting the codebase's seams and conventions
1302
1310
  `
1303
- }${
1311
+ }${
1304
1312
  reference === undefined || amend === true
1305
1313
  ? ""
1306
1314
  : `- Reference only: \`git show ${reference}\` shows an earlier naive attempt.