@pmelab/gtd 29.2.6 → 29.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "29.2.6",
3
+ "version": "29.3.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -348,9 +348,11 @@ nothing changed, when that pull request merged anywhere but the default branch
348
348
  (unless into a lower slice branch before that one merged into it), a slice
349
349
  branch is checked out in another worktree, an unrelated local branch sits inside
350
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
351
+ rolled back; to tear a stack down from its worktree, delete the top branch with
352
+ `git push origin --delete`, since `gh pr close --delete-branch` cannot). A
353
+ process can change hands at any gate: ask Claude to hand it off (or run
354
+ `/gtd throw @dev`) and it opens a draft pull request assigned to them, and
355
+ `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
354
356
  requirements can hand the architecture to someone else. Ship, hand-off and throw
355
357
  answer at once and run in the background, with progress on the status line; they
356
358
  report by waking Claude, a toast, a transcript line and a push notification, and
@@ -607,8 +609,9 @@ means `high`. Each lap lands a `Gtd-Mark: risk-tier=<low|high>` trailer and a
607
609
  lap's only. Its "needs a human reviewer" line for `high` is advisory, not a
608
610
  gate.
609
611
 
610
- A `gtd.config.ts` call to `buildTail` that fits neither call form is rejected;
611
- see [docs/configuration.md](docs/configuration.md).
612
+ A `gtd.config.ts` call to `buildTail` or `tail` that fits neither call form is
613
+ rejected; `tail`'s positional form is deprecated for `tail(finish, options?)`.
614
+ See [docs/configuration.md](docs/configuration.md).
612
615
 
613
616
  ### Rolling out and cleaning up release flags
614
617
 
@@ -142,14 +142,15 @@ export function wakePrompt(w: Wake): string {
142
142
  return [...body, ...notes, RULES, extras].join("\n\n")
143
143
  }
144
144
 
145
- // The wake after a background ship or hand-off ended: the model only relays it.
145
+ // The wake after a background ship or hand-off ended. It must not forbid further work:
146
+ // an agent with standing instructions (a review loop, a CI watch) would go idle.
146
147
  export function jobPrompt(job: JobName, outcome: Shipped): string {
147
148
  const name = JOB_NAME[job]
148
149
  if (outcome.ok) {
149
150
  return [
150
151
  `The ${name} the person asked for finished. gtd's report:`,
151
152
  fence(outcome.text),
152
- "Relay the result to the person in a line or two, with any link in it. Do nothing else.",
153
+ "Relay the result to the person in a line or two, with any link in it; then continue with any work the person already asked for.",
153
154
  ].join("\n\n")
154
155
  }
155
156
  const lines = [
@@ -26,13 +26,31 @@ const closeReview = (round) => run("review.closing", removeScript([REVIEW, REVIE
26
26
  const refusePendingHandOff = () => {
27
27
  if (read(".gtd/REVIEW.orig.md") !== void 0) refuse(`gtd: ${REVIEW_ORIG} is a pending hand-off — run \`gtd door fix\`, or remove ${REVIEW} and ${REVIEW_ORIG}.`);
28
28
  };
29
- /**
30
- * The build tail: fix (when entered red), keep green, the quality lap, then
31
- * `finish` — the human review, as the calling workflow runs it. `green`: the
32
- * caller just left the full suite green (the package loop), so no run opens it.
33
- * `guardRests: false` leaves the escalation's human rests unguarded.
34
- */
35
- const tail = (fixFirst, finish, green = false, guard, lenses, guardRests = true, lensBase, rules) => scope("build", async () => {
29
+ function tail(first, second, ...rest) {
30
+ const form = tailForm(first, second, rest);
31
+ if (form === void 0) return Promise.reject(/* @__PURE__ */ new Error("tail takes tail(finish, options?) or the deprecated tail(fixFirst, finish, green?, guard?, lenses?, guardRests?, lensBase?, rules?); this call fits neither"));
32
+ const [finish, options] = form;
33
+ return runTail(finish, options);
34
+ }
35
+ const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
36
+ const tailForm = (first, second, rest) => {
37
+ if (typeof second === "function" && typeof first !== "function") {
38
+ const [green, guard, lenses, guardRests, lensBase, rules] = rest;
39
+ return [second, {
40
+ fixFirst: first,
41
+ green,
42
+ guard,
43
+ lenses,
44
+ guardRests,
45
+ lensBase,
46
+ rules
47
+ }];
48
+ }
49
+ if (typeof first === "function" && isOptionalObject(second) && rest.every((v) => v == null)) return [first, second ?? {}];
50
+ };
51
+ const runTail = (finish, options) => scope("build", async () => {
52
+ const { guard, lenses, lensBase, rules } = options;
53
+ const guardRests = options.guardRests === void 0 ? true : options.guardRests;
36
54
  const escalations = { rounds: 0 };
37
55
  const fix = () => fixCheck(rules);
38
56
  const repair = async () => {
@@ -45,8 +63,8 @@ const tail = (fixFirst, finish, green = false, guard, lenses, guardRests = true,
45
63
  rules
46
64
  });
47
65
  };
48
- if (fixFirst) await repair();
49
- else if (!green) await healthy(fix, {
66
+ if (options.fixFirst) await repair();
67
+ else if (!options.green) await healthy(fix, {
50
68
  escalations,
51
69
  guard,
52
70
  guardRests,
@@ -83,6 +101,9 @@ const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
83
101
  deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
84
102
  });
85
103
  /** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
86
- const handOffTail = (fixFirst, base, guard) => tail(fixFirst, (escalations) => handOffReview(base, escalations, guard), false, guard);
104
+ const handOffTail = (fixFirst, base, guard) => tail((escalations) => handOffReview(base, escalations, guard), {
105
+ fixFirst,
106
+ guard
107
+ });
87
108
  //#endregion
88
- export { tail as a, refusePendingHandOff as i, closeReview as n, handOffTail as r, REVIEW_ORIG as t };
109
+ export { refusePendingHandOff as a, isOptionalObject as i, closeReview as n, tail as o, handOffTail as r, REVIEW_ORIG as t };
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
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-Dmpg6dAp.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-BOJYJctg.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-CLyE5VFR.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
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-Dmpg6dAp.mjs";
4
- import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-BOJYJctg.mjs";
4
+ import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-CLyE5VFR.mjs";
5
5
  //#region src/workflows/shared/fences.ts
6
6
  const markFenced = (text) => {
7
7
  let fence;
@@ -479,10 +479,8 @@ function buildTail(first, second, ...rest) {
479
479
  const options = callForm(first, second, rest);
480
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
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);
482
+ return tail((escalations) => review(base, escalations, form), form);
484
483
  }
485
- const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
486
484
  const isOptionalBoolean = (value) => value == null || typeof value === "boolean";
487
485
  const legacyForm = (fixFirst, base, green, options) => isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options) ? [base, {
488
486
  ...options,
@@ -866,7 +864,7 @@ const planAndBuild = async (firstBase, firstLap = "first") => {
866
864
  await packages();
867
865
  const outcome = await buildTail(base, {
868
866
  green: true,
869
- ...lap === "first" ? {} : { lap }
867
+ lap
870
868
  });
871
869
  if (outcome.verdict === "signoff") return;
872
870
  await reUnwind(outcome);
@@ -903,7 +901,7 @@ const featureFeedback = async (first, lensBase) => {
903
901
  outcome = await buildTail(outcome.base, {
904
902
  green: true,
905
903
  lap: "feedback",
906
- ...lensBase === void 0 ? {} : { lensBase }
904
+ lensBase
907
905
  });
908
906
  }
909
907
  };
@@ -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
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-Dmpg6dAp.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-BOJYJctg.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-CLyE5VFR.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
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-Dmpg6dAp.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-BOJYJctg.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-CLyE5VFR.mjs";
4
4
  //#region src/workflows/review/review.ts
5
5
  /**
6
6
  * Pure review of everything since `reviewBase`: a green gate, the quality
@@ -37791,13 +37791,31 @@ const closeReview = (round) => run("review.closing", removeScript([REVIEW, REVIE
37791
37791
  const refusePendingHandOff = () => {
37792
37792
  if (read(".gtd/REVIEW.orig.md") !== void 0) refuse(`gtd: ${REVIEW_ORIG} is a pending hand-off — run \`gtd door fix\`, or remove ${REVIEW} and ${REVIEW_ORIG}.`);
37793
37793
  };
37794
- /**
37795
- * The build tail: fix (when entered red), keep green, the quality lap, then
37796
- * `finish` — the human review, as the calling workflow runs it. `green`: the
37797
- * caller just left the full suite green (the package loop), so no run opens it.
37798
- * `guardRests: false` leaves the escalation's human rests unguarded.
37799
- */
37800
- const tail = (fixFirst, finish, green = false, guard, lenses, guardRests = true, lensBase, rules) => scope("build", async () => {
37794
+ function tail(first, second, ...rest) {
37795
+ const form = tailForm(first, second, rest);
37796
+ if (form === void 0) return Promise.reject(/* @__PURE__ */ new Error("tail takes tail(finish, options?) or the deprecated tail(fixFirst, finish, green?, guard?, lenses?, guardRests?, lensBase?, rules?); this call fits neither"));
37797
+ const [finish, options] = form;
37798
+ return runTail(finish, options);
37799
+ }
37800
+ const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
37801
+ const tailForm = (first, second, rest) => {
37802
+ if (typeof second === "function" && typeof first !== "function") {
37803
+ const [green, guard, lenses, guardRests, lensBase, rules] = rest;
37804
+ return [second, {
37805
+ fixFirst: first,
37806
+ green,
37807
+ guard,
37808
+ lenses,
37809
+ guardRests,
37810
+ lensBase,
37811
+ rules
37812
+ }];
37813
+ }
37814
+ if (typeof first === "function" && isOptionalObject(second) && rest.every((v) => v == null)) return [first, second ?? {}];
37815
+ };
37816
+ const runTail = (finish, options) => scope("build", async () => {
37817
+ const { guard, lenses, lensBase, rules } = options;
37818
+ const guardRests = options.guardRests === void 0 ? true : options.guardRests;
37801
37819
  const escalations = { rounds: 0 };
37802
37820
  const fix = () => fixCheck(rules);
37803
37821
  const repair = async () => {
@@ -37810,8 +37828,8 @@ const tail = (fixFirst, finish, green = false, guard, lenses, guardRests = true,
37810
37828
  rules
37811
37829
  });
37812
37830
  };
37813
- if (fixFirst) await repair();
37814
- else if (!green) await healthy(fix, {
37831
+ if (options.fixFirst) await repair();
37832
+ else if (!options.green) await healthy(fix, {
37815
37833
  escalations,
37816
37834
  guard,
37817
37835
  guardRests,
@@ -37848,7 +37866,10 @@ const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
37848
37866
  deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
37849
37867
  });
37850
37868
  /** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
37851
- const handOffTail = (fixFirst, base, guard) => tail(fixFirst, (escalations) => handOffReview(base, escalations, guard), false, guard);
37869
+ const handOffTail = (fixFirst, base, guard) => tail((escalations) => handOffReview(base, escalations, guard), {
37870
+ fixFirst,
37871
+ guard
37872
+ });
37852
37873
  //#endregion
37853
37874
  //#region src/workflows/shared/fences.ts
37854
37875
  const markFenced = (text) => {
@@ -38327,10 +38348,8 @@ function buildTail(first, second, ...rest) {
38327
38348
  const options = callForm(first, second, rest);
38328
38349
  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"));
38329
38350
  const [base, form] = options;
38330
- const { fixFirst, green, ...reviewOptions } = form;
38331
- return tail(fixFirst ?? false, (escalations) => review$1(base, escalations, reviewOptions), green ?? false, form.guard, form.lenses, form.guardRests, form.lensBase, form.rules);
38351
+ return tail((escalations) => review$1(base, escalations, form), form);
38332
38352
  }
38333
- const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
38334
38353
  const isOptionalBoolean = (value) => value == null || typeof value === "boolean";
38335
38354
  const legacyForm = (fixFirst, base, green, options) => isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options) ? [base, {
38336
38355
  ...options,
@@ -38714,7 +38733,7 @@ const planAndBuild = async (firstBase, firstLap = "first") => {
38714
38733
  await packages();
38715
38734
  const outcome = await buildTail(base, {
38716
38735
  green: true,
38717
- ...lap === "first" ? {} : { lap }
38736
+ lap
38718
38737
  });
38719
38738
  if (outcome.verdict === "signoff") return;
38720
38739
  await reUnwind(outcome);
@@ -38751,7 +38770,7 @@ const featureFeedback = async (first, lensBase) => {
38751
38770
  outcome = await buildTail(outcome.base, {
38752
38771
  green: true,
38753
38772
  lap: "feedback",
38754
- ...lensBase === void 0 ? {} : { lensBase }
38773
+ lensBase
38755
38774
  });
38756
38775
  }
38757
38776
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.2.6",
3
+ "version": "29.3.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": {
@@ -49,7 +49,7 @@ export const planAndBuild = async (
49
49
  await design(base)
50
50
  await architecture()
51
51
  await packages()
52
- const outcome = await buildTail(base, { green: true, ...(lap === "first" ? {} : { lap }) })
52
+ const outcome = await buildTail(base, { green: true, lap })
53
53
  if (outcome.verdict === "signoff") return
54
54
  await reUnwind(outcome)
55
55
  base = outcome.base
@@ -81,11 +81,7 @@ const featureFeedback = async (first: ReviewOutcome, lensBase?: string): Promise
81
81
  await design(outcome.base)
82
82
  const tier = riskTierOn() ? recordRisk() : undefined
83
83
  await implement({ tier, amend: true })
84
- outcome = await buildTail(outcome.base, {
85
- green: true,
86
- lap: "feedback",
87
- ...(lensBase === undefined ? {} : { lensBase }),
88
- })
84
+ outcome = await buildTail(outcome.base, { green: true, lap: "feedback", lensBase })
89
85
  }
90
86
  }
91
87
 
@@ -5,7 +5,13 @@ export * from "./planning.js"
5
5
  export * from "./packages.js"
6
6
  export * from "./refactor.js"
7
7
  export * from "./seam.js"
8
- export { buildTail, type BuildTailOptions, type ReviewOutcome } from "./review.js"
8
+ export {
9
+ buildTail,
10
+ type BuildTailOptions,
11
+ type FeedbackTarget,
12
+ type ReviewOutcome,
13
+ } from "./review.js"
14
+ export type { ReviewRange } from "../shared/text.js"
9
15
  export * from "./feature.js"
10
16
  export * from "../shared/reserved.js"
11
17
 
@@ -17,7 +17,7 @@ import {
17
17
  } from "./review.js"
18
18
  import type { ReviewNote } from "../../steering/index.js"
19
19
  import * as bundled from "../index.js"
20
- import type { BuildTailOptions as PublicBuildTailOptions } from "./index.js"
20
+ import type * as featureEntry from "./index.js"
21
21
  import { fixtureContext } from "../shared/text.fixture.js"
22
22
  import { toRequest } from "../../judges/index.js"
23
23
 
@@ -999,17 +999,38 @@ describe("BuildTailOptions.lensBase", () => {
999
999
  expect(names).not.toEqual([])
1000
1000
  })
1001
1001
 
1002
- // pins `| undefined` on BuildTailOptions under exactOptionalPropertyTypes (typecheck)
1003
- it("explicitly undefined fixFirst and green default like omitted ones", async () => {
1002
+ // pins `| undefined` on every BuildTailOptions field under exactOptionalPropertyTypes (typecheck)
1003
+ it("explicitly undefined options default like omitted ones", async () => {
1004
1004
  expect(
1005
- await requestNames(() => buildTail("base", { fixFirst: undefined, green: undefined })),
1005
+ await requestNames(() =>
1006
+ buildTail("base", {
1007
+ fixFirst: undefined,
1008
+ green: undefined,
1009
+ guard: undefined,
1010
+ guardRests: undefined,
1011
+ lenses: undefined,
1012
+ feedback: undefined,
1013
+ fixAccess: undefined,
1014
+ lensBase: undefined,
1015
+ rules: undefined,
1016
+ lap: undefined,
1017
+ }),
1018
+ ),
1006
1019
  ).toEqual(await requestNames(() => buildTail("base")))
1007
1020
  })
1008
1021
  })
1009
1022
  })
1010
1023
 
1011
1024
  // pins the public re-exports from the feature entry point and the published workflows entry
1012
- export type _PublicBuildTailOptions = [PublicBuildTailOptions, bundled.BuildTailOptions]
1025
+ export type _PublicBuildTailOptions = [
1026
+ featureEntry.BuildTailOptions,
1027
+ featureEntry.FeedbackTarget,
1028
+ featureEntry.ReviewRange,
1029
+ bundled.BuildTailOptions,
1030
+ bundled.FeedbackTarget,
1031
+ bundled.ReviewRange,
1032
+ bundled.TailOptions,
1033
+ ]
1013
1034
 
1014
1035
  describe("buildTail without rules", () => {
1015
1036
  const RULES = "Change only these paths"
@@ -13,7 +13,7 @@ import {
13
13
  } from "../../flows/index.js"
14
14
  import type { ReviewNote } from "../../steering/index.js"
15
15
  import * as kit from "../../workflow-kit/index.js"
16
- import { guarded, healthy, type EscalationCount, type Guard } from "../shared/health.js"
16
+ import { guarded, healthy, type EscalationCount } from "../shared/health.js"
17
17
  import {
18
18
  awaitReview,
19
19
  fixCheck,
@@ -23,7 +23,7 @@ import {
23
23
  reviewing,
24
24
  reviewMissing,
25
25
  } from "../shared/steps.js"
26
- import { closeReview, tail } from "../shared/tail.js"
26
+ import { closeReview, isOptionalObject, tail, type TailOptions } from "../shared/tail.js"
27
27
  import * as t from "../shared/text.js"
28
28
  import { answerReviewQuestions, collecting, deferNotes, fixNits } from "./steps.js"
29
29
 
@@ -169,7 +169,7 @@ export const isCode = (path: string): boolean => path !== ".gtd" && !path.starts
169
169
  export const review = (
170
170
  base: string,
171
171
  escalations: EscalationCount = { rounds: 0 },
172
- options: BuildTailOptions = {},
172
+ options: Omit<BuildTailOptions, "fixFirst" | "green"> = {},
173
173
  ): Promise<kit.ReviewOutcome> => {
174
174
  const { guard, guardRests, fixAccess, rules } = options
175
175
  const feedback: FeedbackTarget = options.feedback ?? {
@@ -255,26 +255,12 @@ export interface FeedbackTarget {
255
255
  readonly dropped?: (requested: kit.Requested) => string
256
256
  }
257
257
 
258
- export interface BuildTailOptions {
259
- /** Fix first when entered red; default `false`. */
260
- readonly fixFirst?: boolean | undefined
261
- /** Asserts the tree is already green, so the tail skips keep-green; default `false`. */
262
- readonly green?: boolean | undefined
263
- /** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
264
- readonly guard?: Guard
265
- /** `false`: `guard` skips the escalation's human rests. */
266
- readonly guardRests?: boolean
267
- /** Quality-lap lenses; default `lensesOf(vars.qualityReviews)`. */
268
- readonly lenses?: readonly string[]
269
- readonly feedback?: FeedbackTarget
258
+ export interface BuildTailOptions extends TailOptions {
259
+ readonly feedback?: FeedbackTarget | undefined
270
260
  /** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
271
- readonly fixAccess?: ScopeAccess
272
- /** The commit the quality lap reviews from; absent: `start()`. */
273
- readonly lensBase?: string
274
- /** Appended to every fix and describe prompt of the tail. */
275
- readonly rules?: string
261
+ readonly fixAccess?: ScopeAccess | undefined
276
262
  /** Absent: "first". */
277
- readonly lap?: t.ReviewRange
263
+ readonly lap?: t.ReviewRange | undefined
278
264
  }
279
265
 
280
266
  /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
@@ -301,26 +287,9 @@ export function buildTail(
301
287
  )
302
288
  }
303
289
  const [base, form] = options
304
- const { fixFirst, green, ...reviewOptions } = form
305
- return tail(
306
- fixFirst ?? false,
307
- (escalations) => review(base, escalations, reviewOptions),
308
- green ?? false,
309
- form.guard,
310
- form.lenses,
311
- form.guardRests,
312
- form.lensBase,
313
- form.rules,
314
- )
290
+ return tail((escalations) => review(base, escalations, form), form)
315
291
  }
316
292
 
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
293
  const isOptionalBoolean = (value: unknown): value is boolean | null | undefined =>
325
294
  value == null || typeof value === "boolean"
326
295
 
@@ -6,7 +6,7 @@ import { feature } from "./feature/feature.js"
6
6
  export { threads, type ThreadInfo } from "../flows/index.js"
7
7
  export * from "./shared/reserved.js"
8
8
  export { FlagOptions, ShippedOptions } from "./shared/options.js"
9
- export { agentWithSkills } from "./shared/text.js"
9
+ export { agentWithSkills, type ReviewRange } from "./shared/text.js"
10
10
  export * from "./shared/steps.js"
11
11
  export * from "./shared/health.js"
12
12
  export {
@@ -17,6 +17,7 @@ export {
17
17
  qualityLap,
18
18
  REVIEW_ORIG,
19
19
  tail,
20
+ type TailOptions,
20
21
  } from "./shared/tail.js"
21
22
  export { lensesOf } from "../workflow-kit/index.js"
22
23
  export * from "./feature/steps.js"
@@ -24,7 +25,12 @@ export * from "./feature/planning.js"
24
25
  export * from "./feature/packages.js"
25
26
  export * from "./feature/refactor.js"
26
27
  export * from "./feature/seam.js"
27
- export { buildTail, type BuildTailOptions, type ReviewOutcome } from "./feature/review.js"
28
+ export {
29
+ buildTail,
30
+ type BuildTailOptions,
31
+ type FeedbackTarget,
32
+ type ReviewOutcome,
33
+ } from "./feature/review.js"
28
34
  export * from "./feature/feature.js"
29
35
  export { base, review, reviewFlow } from "./review/review.js"
30
36
  export { fix, fixFlow, fixReviewNotes } from "./fix/fix.js"
@@ -260,32 +260,147 @@ describe("the quality lap's lenses", () => {
260
260
 
261
261
  it("tail threads lensBase to every lens turn", async () => {
262
262
  await lap(() =>
263
- tail(
264
- false,
265
- () => Promise.resolve(),
266
- true,
267
- undefined,
268
- ["conventions", "correctness"],
269
- true,
270
- "BASE-SHA",
271
- ),
263
+ tail(() => Promise.resolve(), {
264
+ green: true,
265
+ lenses: ["conventions", "correctness"],
266
+ lensBase: "BASE-SHA",
267
+ }),
272
268
  )
273
269
  expect(prompts).toHaveLength(2)
274
270
  for (const prompt of prompts) expect(prompt).toContain("from `BASE-SHA`")
275
271
  })
276
272
 
273
+ it("the deprecated form threads lensBase from its seventh slot", async () => {
274
+ await lap(() =>
275
+ tail(false, () => Promise.resolve(), true, undefined, ["conventions"], true, "BASE-SHA", "R"),
276
+ )
277
+ expect(prompts).toHaveLength(1)
278
+ expect(prompts[0]).toContain("from `BASE-SHA`")
279
+ expect(prompts[0]).not.toContain("from `R`")
280
+ })
281
+
277
282
  it("tail passes lenses to its lap and a guard after each lens turn", async () => {
278
283
  let guards = 0
279
284
  const log = await lap(() =>
280
- tail(
281
- false,
282
- () => Promise.resolve(),
283
- true,
284
- () => void guards++,
285
- ["conventions"],
286
- ),
285
+ tail(() => Promise.resolve(), {
286
+ green: true,
287
+ guard: () => void guards++,
288
+ lenses: ["conventions"],
289
+ }),
287
290
  )
288
291
  expect(log).toEqual(["quality.conventions.reviewing"])
289
292
  expect(guards).toBe(1)
290
293
  })
291
294
  })
295
+
296
+ describe("tail's call forms", () => {
297
+ class Stop extends Error {}
298
+ const requestNames = async (
299
+ call: () => Promise<unknown>,
300
+ names: string[] = [],
301
+ ): Promise<string[]> => {
302
+ installContext(
303
+ fixtureContext(
304
+ { vars: { qualityReviews: "correctness" } },
305
+ {
306
+ step: (request: StepRequest) => {
307
+ if (request.kind === "restart") return Promise.resolve()
308
+ names.push(`${request.kind}:${request.name}`)
309
+ if (request.kind === "agent") prompts.push(request.prompt)
310
+ if (names.length >= 4) throw new Stop()
311
+ return Promise.resolve()
312
+ },
313
+ pushScope: () => undefined,
314
+ popScope: () => undefined,
315
+ start: () => "START-SHA",
316
+ },
317
+ ),
318
+ )
319
+ await call().catch((error: unknown) => {
320
+ if (!(error instanceof Stop)) throw error
321
+ })
322
+ return names
323
+ }
324
+ const finish = () => Promise.resolve()
325
+
326
+ for (const [fixFirst, green] of [
327
+ [true, false],
328
+ [true, true],
329
+ [false, true],
330
+ [false, false],
331
+ ] as const) {
332
+ it(`the deprecated positional form matches the options form for fixFirst ${fixFirst}, green ${green}`, async () => {
333
+ const modern = await requestNames(() => tail(finish, { fixFirst, green }))
334
+ const legacy = await requestNames(() => tail(fixFirst, finish, green))
335
+ expect(legacy).toEqual(modern)
336
+ })
337
+ }
338
+
339
+ it("the deprecated form passes every positional on", async () => {
340
+ let guards = 0
341
+ const guard = () => void guards++
342
+ const modern = await requestNames(() =>
343
+ tail(finish, { green: true, guard, lenses: ["conventions"], guardRests: false }),
344
+ )
345
+ const modernGuards = guards
346
+ guards = 0
347
+ const legacy = await requestNames(() =>
348
+ tail(false, finish, true, guard, ["conventions"], false),
349
+ )
350
+ expect(legacy).toEqual(modern)
351
+ expect(guards).toBe(modernGuards)
352
+ })
353
+
354
+ it("the deprecated form threads rules from its eighth slot", async () => {
355
+ prompts.length = 0
356
+ await requestNames(() =>
357
+ tail(true, finish, false, undefined, [], true, "BASE-SHA", "RULES-TEXT"),
358
+ )
359
+ expect(prompts.some((p) => p.includes("RULES-TEXT"))).toBe(true)
360
+ expect(prompts.some((p) => p.includes("BASE-SHA"))).toBe(false)
361
+ })
362
+
363
+ it("the deprecated form still runs untyped flags as truthy or falsy", async () => {
364
+ expect(await requestNames(() => tail(1 as never, finish, 0 as never))).toEqual(
365
+ await requestNames(() => tail(finish, { fixFirst: true })),
366
+ )
367
+ })
368
+
369
+ it("reads an explicit undefined in every option as absent", async () => {
370
+ const absent = await requestNames(() => tail(finish))
371
+ const explicit = await requestNames(() =>
372
+ tail(finish, {
373
+ fixFirst: undefined,
374
+ green: undefined,
375
+ guard: undefined,
376
+ lenses: undefined,
377
+ guardRests: undefined,
378
+ lensBase: undefined,
379
+ rules: undefined,
380
+ }),
381
+ )
382
+ expect(explicit).toEqual(absent)
383
+ })
384
+
385
+ const fitsNeither: [string, () => Promise<unknown>][] = [
386
+ ["no finish", () => (tail as (...args: unknown[]) => Promise<unknown>)(false)],
387
+ ["a finish followed by a boolean", () => tail(finish, true as never)],
388
+ ["a finish followed by a function", () => tail(finish, finish as never)],
389
+ ["a finish followed by a Promise", () => tail(finish, Promise.resolve({}) as never)],
390
+ [
391
+ "a finish with a third argument",
392
+ () => (tail as (...args: unknown[]) => Promise<unknown>)(finish, {}, true),
393
+ ],
394
+ ]
395
+ for (const [label, call] of fitsNeither) {
396
+ it(`rejects ${label} before any step runs`, async () => {
397
+ const names: string[] = []
398
+ const rejected = expect(requestNames(call, names)).rejects
399
+ await rejected.toThrow("tail(finish, options?)")
400
+ await rejected.toThrow(
401
+ "tail(fixFirst, finish, green?, guard?, lenses?, guardRests?, lensBase?, rules?)",
402
+ )
403
+ expect(names).toEqual([])
404
+ })
405
+ }
406
+ })
@@ -70,23 +70,88 @@ export const refusePendingHandOff = (): void => {
70
70
  }
71
71
  }
72
72
 
73
+ export interface TailOptions {
74
+ /** Fix first when entered red; default `false`. */
75
+ readonly fixFirst?: boolean | undefined
76
+ /** Asserts the tree is already green, so the tail skips keep-green; default `false`. */
77
+ readonly green?: boolean | undefined
78
+ /** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
79
+ readonly guard?: Guard | undefined
80
+ /** Quality-lap lenses; default `lensesOf(vars.qualityReviews)`. */
81
+ readonly lenses?: readonly string[] | undefined
82
+ /** `false` leaves the escalation's human rests unguarded; default `true`. */
83
+ readonly guardRests?: boolean | undefined
84
+ /** The commit the quality lap reviews from; absent: `start()`. */
85
+ readonly lensBase?: string | undefined
86
+ /** Appended to every fix and describe prompt of the tail. */
87
+ readonly rules?: string | undefined
88
+ }
89
+
73
90
  /**
74
91
  * The build tail: fix (when entered red), keep green, the quality lap, then
75
- * `finish` — the human review, as the calling workflow runs it. `green`: the
76
- * caller just left the full suite green (the package loop), so no run opens it.
77
- * `guardRests: false` leaves the escalation's human rests unguarded.
92
+ * `finish` — the human review, as the calling workflow runs it.
78
93
  */
79
- export const tail = <T>(
94
+ export function tail<T>(
95
+ finish: (escalations: EscalationCount) => Promise<T>,
96
+ options?: TailOptions,
97
+ ): Promise<T>
98
+ /** @deprecated Pass `tail(finish, { fixFirst, green, guard, lenses, guardRests, lensBase, rules })`. */
99
+ export function tail<T>(
80
100
  fixFirst: boolean,
81
101
  finish: (escalations: EscalationCount) => Promise<T>,
82
- green = false,
102
+ green?: boolean,
83
103
  guard?: Guard,
84
104
  lenses?: readonly string[],
85
- guardRests = true,
105
+ guardRests?: boolean,
86
106
  lensBase?: string,
87
107
  rules?: string,
88
- ): Promise<T> =>
108
+ ): Promise<T>
109
+ export function tail<T>(first: unknown, second?: unknown, ...rest: unknown[]): Promise<T> {
110
+ const form = tailForm<T>(first, second, rest)
111
+ // Before any step runs, so a bad call never leaves a half-walked flow.
112
+ if (form === undefined) {
113
+ return Promise.reject(
114
+ new Error(
115
+ "tail takes tail(finish, options?) or the deprecated tail(fixFirst, finish, green?, guard?, lenses?, guardRests?, lensBase?, rules?); this call fits neither",
116
+ ),
117
+ )
118
+ }
119
+ const [finish, options] = form
120
+ return runTail(finish, options)
121
+ }
122
+
123
+ type Finish<T> = (escalations: EscalationCount) => Promise<T>
124
+
125
+ // null reads as absent; an un-awaited Promise is a mistake, not options.
126
+ export const isOptionalObject = (value: unknown): value is object | null | undefined =>
127
+ value == null ||
128
+ (typeof value === "object" &&
129
+ !Array.isArray(value) &&
130
+ typeof (value as { then?: unknown }).then !== "function")
131
+
132
+ // Picks the form by shape only: the deprecated form never validated its
133
+ // positionals, and still runs whatever it ran before.
134
+ const tailForm = <T>(
135
+ first: unknown,
136
+ second: unknown,
137
+ rest: readonly unknown[],
138
+ ): [Finish<T>, TailOptions] | undefined => {
139
+ // A function first is a misplaced finish, never a fixFirst.
140
+ if (typeof second === "function" && typeof first !== "function") {
141
+ const [green, guard, lenses, guardRests, lensBase, rules] = rest
142
+ const options = { fixFirst: first, green, guard, lenses, guardRests, lensBase, rules }
143
+ return [second as Finish<T>, options as TailOptions]
144
+ }
145
+ if (typeof first === "function" && isOptionalObject(second) && rest.every((v) => v == null))
146
+ return [first as Finish<T>, (second as TailOptions | null | undefined) ?? {}]
147
+ return undefined
148
+ }
149
+
150
+ const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
89
151
  scope("build", async () => {
152
+ const { guard, lenses, lensBase, rules } = options
153
+ // Only undefined defaults: a null reaches every escalation as falsy, as it reaches review's.
154
+ const guardRests = options.guardRests === undefined ? true : options.guardRests
90
155
  const escalations: EscalationCount = { rounds: 0 }
91
156
  const fix = () => fixCheck(rules)
92
157
  const repair = async () => {
@@ -94,8 +159,9 @@ export const tail = <T>(
94
159
  await healthy(fix, { fixesSoFar: 1, escalations, guard, guardRests, rules })
95
160
  }
96
161
  // fixFirst's own fix + healthy below already is the full run.
97
- if (fixFirst) await repair()
98
- else if (!green) await healthy(fix, { escalations, guard, guardRests, rules })
162
+ // Truthy, not `true`: an untyped config's flags read as they always did.
163
+ if (options.fixFirst) await repair()
164
+ else if (!options.green) await healthy(fix, { escalations, guard, guardRests, rules })
99
165
  // The lap runs once a tail: after its findings are fixed, review follows.
100
166
  if ((await qualityLap(guard, lenses, lensBase)) === "findings") {
101
167
  if (await fixQualityFindings(escalations, guard, guardRests, rules)) {
@@ -138,4 +204,4 @@ export const handOffReview = (
138
204
 
139
205
  /** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
140
206
  export const handOffTail = (fixFirst: boolean, base: string, guard?: Guard): Promise<void> =>
141
- tail(fixFirst, (escalations) => handOffReview(base, escalations, guard), false, guard)
207
+ tail((escalations) => handOffReview(base, escalations, guard), { fixFirst, guard })