@pmelab/gtd 46.0.0 → 48.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 (34) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -19
  3. package/dist/chunks/{reserved-BENPy6IJ.mjs → reserved-DF1HD6jL.mjs} +41 -22
  4. package/dist/chunks/{review-DcbgUwr7.mjs → review-Bv40kZu9.mjs} +5 -8
  5. package/dist/chunks/{tail-CDNMZ-2E.mjs → tail-D89fU_wf.mjs} +68 -39
  6. package/dist/gtd.bundle.mjs +203 -867
  7. package/dist/testkit.mjs +89 -53
  8. package/dist/workflows/cleanup.mjs +2 -2
  9. package/dist/workflows/feature.mjs +26 -22
  10. package/dist/workflows/fix.mjs +2 -2
  11. package/dist/workflows/review.mjs +3 -3
  12. package/dist/workflows/rollout.mjs +1 -1
  13. package/dist/workflows.mjs +257 -205
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +0 -2
  16. package/src/cli/Cli.ts +0 -15
  17. package/src/cli/Program.test.ts +0 -1
  18. package/src/cli/Program.ts +0 -8
  19. package/src/cli/ToolCommands.ts +1 -2
  20. package/src/install/Install.ts +89 -35
  21. package/src/testkit/Layers.ts +0 -1
  22. package/src/wire/ContextLimit.ts +1 -1
  23. package/src/workflows/cleanup/cleanup.test.ts +11 -9
  24. package/src/workflows/feature/feature.ts +38 -28
  25. package/src/workflows/feature/lens-review.feature +3 -1
  26. package/src/workflows/feature/slice-findings.feature +198 -0
  27. package/src/workflows/fix/fix-lens-origin.feature +101 -0
  28. package/src/workflows/fix/fix-quality.feature +131 -0
  29. package/src/workflows/index.ts +0 -1
  30. package/src/workflows/shared/lenses.ts +30 -5
  31. package/src/workflows/shared/review.ts +3 -8
  32. package/src/workflows/shared/tail.ts +74 -57
  33. package/src/workflows/shared/text.ts +85 -29
  34. package/src/workflows/feature/quality-unresolved.feature +0 -121
@@ -1685,7 +1685,6 @@ describe("runCommand — refuses in a repository with no commits", () => {
1685
1685
  check: { kind: "check", mode: "qa", file: ".gtd/TODO.md" },
1686
1686
  uncheck: { kind: "uncheck", file: ".gtd/REVIEW.md" },
1687
1687
  install: { kind: "install" },
1688
- loop: { kind: "loop" },
1689
1688
  contextHook: { kind: "contextHook" },
1690
1689
  summary: { kind: "summary" },
1691
1690
  base: { kind: "base" },
@@ -163,7 +163,6 @@ export type Command =
163
163
  }
164
164
  | { readonly kind: "uncheck"; readonly file: string }
165
165
  | { readonly kind: "install" }
166
- | { readonly kind: "loop" }
167
166
  | {
168
167
  readonly kind: "contextHook"
169
168
  readonly soft?: number
@@ -239,9 +238,6 @@ const runInstallCommand = (
239
238
  )
240
239
  })
241
240
 
242
- const runLoopCommand = (): Effect.Effect<void, Error, ToolCommands> =>
243
- Effect.flatMap(ToolCommands, (tools) => tools.loop(selfGtd()))
244
-
245
241
  /**
246
242
  * `planLanding`'s result — a preview of what `gtd land` would do, plus the
247
243
  * combined `script` the driver runs to do it. `script` is
@@ -1221,7 +1217,6 @@ export const needsOf = (kind: Command["kind"]): Needs => {
1221
1217
  switch (kind) {
1222
1218
  case "lsp":
1223
1219
  case "install":
1224
- case "loop":
1225
1220
  case "contextHook":
1226
1221
  case "judgeRun":
1227
1222
  case "strays":
@@ -1240,7 +1235,6 @@ export const standaloneKinds = (): readonly Command["kind"][] => [
1240
1235
  "check",
1241
1236
  "uncheck",
1242
1237
  "install",
1243
- "loop",
1244
1238
  "contextHook",
1245
1239
  "judgeRun",
1246
1240
  "strays",
@@ -1311,8 +1305,6 @@ const dispatchVoidCommand = (
1311
1305
  return runUncheckCommand(command.file)
1312
1306
  case "install":
1313
1307
  return runInstallCommand(out)
1314
- case "loop":
1315
- return runLoopCommand()
1316
1308
  case "contextHook":
1317
1309
  return runContextHookCommand(command, out)
1318
1310
  case "summary":
@@ -3,7 +3,7 @@ import type { Effect } from "effect"
3
3
  import type { UiSettings } from "../flows/index.js"
4
4
  import type { ContextHookRequest } from "../wire/index.js"
5
5
 
6
- /** How to run THIS gtd — a driver (the loop, the LSP) never reads a different version's state than the one it ships with. */
6
+ /** How to run THIS gtd — a tool (the LSP) never reads a different version's state than the one it ships with. */
7
7
  export type SelfGtd = readonly [string, ...string[]]
8
8
 
9
9
  export interface UiRequest {
@@ -38,7 +38,6 @@ export class ToolCommands extends Context.Tag("ToolCommands")<
38
38
  out: { readonly write: (chunk: string) => void; readonly flush: () => void },
39
39
  ) => Effect.Effect<void, Error>
40
40
  readonly lsp: (gtd: SelfGtd) => Effect.Effect<void, Error>
41
- readonly loop: (gtd: SelfGtd) => Effect.Effect<void, Error>
42
41
  /** The hook's stdout: a JSON document, or empty to let the turn go on. */
43
42
  readonly contextHook: (request: ContextHookRequest) => Effect.Effect<string, Error>
44
43
  readonly judge: (request: JudgeRequest) => Promise<string>
@@ -11,8 +11,15 @@ export CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1
11
11
  export BASH_DEFAULT_TIMEOUT_MS=1800000
12
12
  export BASH_MAX_TIMEOUT_MS=7200000
13
13
 
14
+ # gtd's paths, scripts and checks are relative to the repository root.
15
+ cd "$(git rev-parse --show-toplevel)"
16
+
14
17
  beat=1
15
18
  while :; do
19
+ cost=
20
+ model=
21
+ judged_before="\${judged:-}"
22
+ judged=
16
23
  # One value per invocation: \`gtd next --json=<path>\` is a pure read (peek
17
24
  # or dispatch, indistinguishable, always safe to call again), so nothing is
18
25
  # lost by reading \`kind\`/\`idle\` as two separate calls rather than one
@@ -40,14 +47,22 @@ while :; do
40
47
  # \`gtd judge run\` and land the verdict via \`gtd judge answer\`. POSIX sh has
41
48
  # no pipefail, so each step is captured first and only piped on when it
42
49
  # succeeded; if \`gtd judge run\` fails or \`gtd judge answer\` rejects the
43
- # verdict, its stderr reaches you and we fall through to the human hand-off.
50
+ # verdict, its stderr reaches you and the gate is handed to you — on the
51
+ # opening beat too, so it never lands unanswered — as it is when the gate
52
+ # we just answered comes straight back.
44
53
  message)
45
- if [ -n "$(gtd next --json=judge)" ] &&
46
- verdict="$(gtd judge --json | gtd judge run)" &&
47
- script="$(printf '%s\\n' "$verdict" | gtd judge answer --json=script)"; then
48
- printf '%s\\n' "$script" | sh
49
- beat=$((beat + 1))
50
- continue
54
+ if [ -n "$(gtd next --json=judge)" ]; then
55
+ state="$(gtd next --json=state)"
56
+ if [ "$state" != "$judged_before" ] &&
57
+ verdict="$(gtd judge --json | gtd judge run)" &&
58
+ script="$(printf '%s\\n' "$verdict" | gtd judge answer --json=script)"; then
59
+ printf '%s\\n' "$script" | sh
60
+ judged="$state"
61
+ beat=$((beat + 1))
62
+ continue
63
+ fi
64
+ gtd next
65
+ exit 0
51
66
  fi
52
67
  [ "$beat" = 1 ] || {
53
68
  gtd next
@@ -66,45 +81,83 @@ while :; do
66
81
  model="$(gtd next --json=model)"
67
82
  system="$(gtd next --json=system)"
68
83
  validate="$(gtd next --json=validate)"
84
+ context="$(gtd next --json=context)"
69
85
  log="$(gtd next --json=log)"
86
+ # Absolute, since the hook runs wherever the agent last cd'ed to.
87
+ case "$log" in /*) ;; *) log="$(git rev-parse --show-toplevel)/$log" ;; esac
70
88
  # \`model\` is a hint, not a model name — the bundled workflow ships
71
89
  # \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
72
- # with the variables \`gtd loop\` reads; unset, the hint is dropped and
73
- # the agent runs its own default. Any other value is a real model name.
90
+ # from your environment; unset, the hint is dropped and the agent runs
91
+ # its own default. Any other value is a real model name.
74
92
  case "$model" in
75
93
  smart) model="\${GTD_LOOP_MODEL_SMART:-}" ;;
76
94
  base) model="\${GTD_LOOP_MODEL_BASE:-}" ;;
77
95
  esac
78
- # The prompt embeds a full diff, so it goes to the agent over stdin,
79
- # never as an argv positional — argv is capped (~1 MB on macOS, and
80
- # POSIX guarantees only 4 KB, ARG_MAX), and a diff crosses that far
81
- # sooner than you'd expect. Piping plain \`gtd next\` (not
82
- # --json=content) means the diff is rendered once, for this input,
83
- # rather than twice.
84
- agent_turn() { gtd next | claude -p "$1" "$session_id" \\
85
- \${model:+--model "$model"} \\
86
- \${system:+--system-prompt "$system"} \\
87
- --dangerously-skip-permissions >>"$log" 2>&1; }
96
+ # The step's context limit, as a Claude Code hook after every tool
97
+ # call and before compacting. Past \`hard\` it stops the turn and
98
+ # appends why to the stop file, so we land nothing and stop too.
99
+ soft="$(printf '%s' "$context" | sed -n 's/.*"soft":\\([0-9.]*\\).*/\\1/p')"
100
+ hard="$(printf '%s' "$context" | sed -n 's/.*"hard":\\([0-9.]*\\).*/\\1/p')"
101
+ window="\${GTD_LOOP_CONTEXT_WINDOW:-}"
102
+ case "$model" in *'[1m]'* | *'[1M]'*) window="\${window:-1000000}" ;; esac
103
+ hook="gtd context-hook\${soft:+ --soft $soft}\${hard:+ --hard $hard}\${window:+ --window $window}"
104
+ hooks="[{\\"hooks\\":[{\\"type\\":\\"command\\",\\"command\\":\\"$hook\\"}]}]"
105
+ settings="{\\"hooks\\":{\\"PostToolUse\\":$hooks,\\"PreCompact\\":$hooks}}"
106
+ stop="$log.context-stop"
107
+ rm -f "$stop"
108
+ # One turn, its prompt on stdin. The prompt embeds a full diff, so it
109
+ # never goes on argv — argv is capped (~1 MB on macOS, and POSIX
110
+ # guarantees only 4 KB, ARG_MAX), and a diff crosses that far sooner
111
+ # than you'd expect. Every turn passes the same $model/$system:
112
+ # whether \`claude --resume\` re-applies the original session's is a
113
+ # harness detail gtd cannot verify from outside. Its JSON envelope
114
+ # goes to the log, and its \`total_cost_usd\` adds up into $cost. An
115
+ # \`is_error\` envelope (max turns, an error mid-turn) or one another
116
+ # hook stopped fails the turn even at exit 0: landing it would land
117
+ # the failure as work. Only a turn that never ran (no envelope: the
118
+ # session flag was refused) is worth retrying under the other flag.
119
+ turn() {
120
+ out="$(GTD_LOOP_CONTEXT_STOP_FILE="$stop" claude -p "$@" \\
121
+ \${model:+--model "$model"} \\
122
+ \${system:+--system-prompt "$system"} \\
123
+ --settings "$settings" --output-format json \\
124
+ --dangerously-skip-permissions 2>>"$log")" && ok=0 || ok=$?
125
+ printf '%s\\n' "$out" >>"$log"
126
+ case "$out" in *'"type":"result"'*) ran=true ;; *) ran=false ;; esac
127
+ case "$out" in
128
+ *'"is_error":true'* | *'"terminal_reason":"hook_stopped"'*) ok=1 ;;
129
+ esac
130
+ usd="$(printf '%s\\n' "$out" | sed -n 's/.*"total_cost_usd":\\([0-9.eE+-]*\\).*/\\1/p' | tail -n 1)"
131
+ [ -z "$usd" ] || cost="$(awk -v a="\${cost:-0}" -v b="$usd" 'BEGIN { printf "%.6f", a + b }')"
132
+ [ "\${cost:-}" != 0.000000 ] || cost=
133
+ if [ -s "$stop" ]; then
134
+ cat "$stop" >&2
135
+ exit 1
136
+ fi
137
+ return "$ok"
138
+ }
139
+ # Plain \`gtd next\` (not --json=content) renders the diff once.
140
+ gtd next >"$log.prompt"
88
141
  if [ "\${resume:-}" = true ]
89
- then agent_turn --resume || agent_turn --session-id
90
- else agent_turn --session-id || agent_turn --resume
142
+ then first=--resume second=--session-id
143
+ else first=--session-id second=--resume
91
144
  fi
145
+ turn "$first" "$session_id" <"$log.prompt" ||
146
+ { [ "$ran" = false ] && turn "$second" "$session_id" <"$log.prompt"; } || {
147
+ echo "agent turn failed — see $log" >&2
148
+ exit 1
149
+ }
92
150
  n=0
93
151
  while [ -n "\${validate:-}" ] && ! fix="$(sh -c "$validate" 2>&1)"; do
94
152
  n=$((n + 1)) && [ "$n" -gt 3 ] && { printf '%s\\n' "$fix" >&2; exit 1; }
95
- # $fix IS the fix prompt, verbatim — piped for the same reason the
96
- # agent's own turn above is. Whether \`claude --resume\` re-applies the
97
- # original session's model/system prompt is a harness detail gtd
98
- # cannot verify from outside, so this passes the identical
99
- # $model/$system on both calls rather than assume it does —
100
- # otherwise this fix turn might silently fall back to Claude Code's
101
- # own defaults while the turn that produced the file ran under the
102
- # workflow's own model and persona.
103
- printf '%s' "$fix" | claude -p --resume "$session_id" \\
104
- \${model:+--model "$model"} \\
105
- \${system:+--system-prompt "$system"} \\
106
- --dangerously-skip-permissions >>"$log" 2>&1
107
- done ;;
153
+ # $fix IS the fix prompt, verbatim.
154
+ printf '%s' "$fix" >"$log.prompt"
155
+ turn --resume "$session_id" <"$log.prompt" || {
156
+ echo "fix turn failed — see $log" >&2
157
+ exit 1
158
+ }
159
+ done
160
+ rm -f "$log.prompt" ;;
108
161
  esac
109
162
 
110
163
  # Read every value the landing needs BEFORE piping the script to \`sh\`:
@@ -114,7 +167,8 @@ while :; do
114
167
  # before the script runs and changes the tree out from under them.
115
168
  settled="$(gtd land --json=settled)"
116
169
  idle="$(gtd land --json=idle)"
117
- gtd land --json=script | sh
170
+ # A prompt beat's turns record their cost, tagged with the model that ran.
171
+ gtd land --json=script \${cost:+--cost="$cost"} \${cost:+\${model:+--model="$model"}} | sh
118
172
  if [ "\${settled:-}" = true ]; then
119
173
  [ "\${idle:-}" = true ] && gtd next
120
174
  exit 0
@@ -205,7 +205,6 @@ const missingTool = (name: string) => () =>
205
205
  const missingToolCommands: ToolCommands["Type"] = {
206
206
  ui: missingTool("ui"),
207
207
  lsp: missingTool("lsp"),
208
- loop: missingTool("loop"),
209
208
  contextHook: missingTool("context-hook"),
210
209
  judge: () =>
211
210
  Promise.reject(
@@ -1,4 +1,4 @@
1
- // The context-limit decision every driver shares: the loop's `gtd context-hook`
1
+ // The context-limit decision every driver shares: `gtd context-hook`
2
2
  // and the Claude Code mod both answer a hook event through `contextHook`, so
3
3
  // the two cannot drift on thresholds, wording or which tokens count.
4
4
 
@@ -90,6 +90,13 @@ describe("the cleanup guard over every later agent turn", () => {
90
90
  "build.health.check": () => undefined,
91
91
  }
92
92
 
93
+ // One lens finding, judged mechanical, so it reaches the fix turn.
94
+ const MECHANICAL: Actors = {
95
+ "build.quality.correctness.reviewing": (_, r) =>
96
+ r.write(".gtd/QUALITY.md", "# Quality\n\n## a finding\n\nFix it.\n"),
97
+ "build.quality.triage": () => '[{"id":"Q1","answer":"mechanical","p":0.9}]',
98
+ }
99
+
93
100
  // Without the shipped access rules, which would refuse a source write from a read-only turn first: the guard is the second line.
94
101
  const NO_ACCESS = CONFIG.replace(" access,\n", "")
95
102
 
@@ -185,15 +192,11 @@ describe("the cleanup guard over every later agent turn", () => {
185
192
  ).rejects.toThrow("src/b.ts:1 still names GTD_FLAG_LIVE")
186
193
  })
187
194
 
188
- it("refuses the first fix after escalating quality findings that re-adds the env var", async () => {
195
+ it("refuses a quality fix turn that re-adds the env var", async () => {
189
196
  await expect(
190
197
  driven(guarded("correctness", NO_ACCESS), {
191
- "build.quality.correctness.reviewing": (_, r) =>
192
- r.write(".gtd/QUALITY.md", "- a finding\n"),
193
- "build.fix.quality.fixing": () => undefined,
194
- "build.health.describe": () => undefined,
195
- "build.health.stop": () => undefined,
196
- "build.fix": (_, r) => r.write("src/b.ts", NAMED),
198
+ ...MECHANICAL,
199
+ "build.fix.quality.fixing": (_, r) => r.write("src/b.ts", NAMED),
197
200
  }),
198
201
  ).rejects.toThrow("src/b.ts:1 still names GTD_FLAG_LIVE")
199
202
  })
@@ -209,8 +212,7 @@ describe("the cleanup guard over every later agent turn", () => {
209
212
  it("refuses a quality fix turn that restores the registry entry", async () => {
210
213
  await expect(
211
214
  driven(guarded("correctness"), {
212
- "build.quality.correctness.reviewing": (_, r) =>
213
- r.write(".gtd/QUALITY.md", "- a finding\n"),
215
+ ...MECHANICAL,
214
216
  "build.fix.quality.fixing": (_, r) => r.write("src/flags/index.ts", REGISTRY),
215
217
  }),
216
218
  ).rejects.toThrow('still declares flag "live"')
@@ -9,9 +9,10 @@ import {
9
9
  slice,
10
10
  start,
11
11
  } from "../../flows/index.js"
12
- import { gate, healthy } from "../shared/health.js"
12
+ import { gate } from "../shared/health.js"
13
+ import { joinReports, type LensReport } from "../shared/lenses.js"
13
14
  import { flagModeOn, shipped } from "../shared/options.js"
14
- import { FEEDBACK, fixCheck, QUALITY, REQUIREMENTS } from "../shared/steps.js"
15
+ import { FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
15
16
  import { refusePendingHandOff, tail } from "../shared/tail.js"
16
17
  import * as t from "../shared/text.js"
17
18
  import {
@@ -36,7 +37,8 @@ import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
36
37
  // green-baseline gate → design → per slice of the criteria, a naive pass,
37
38
  // judged with `flagMode` on: it lands as the implementation, or (refactoring
38
39
  // first when the judge asks) is built fresh from → the health loop → the
39
- // quality lap → after the last slice, human review of the whole change, which
40
+ // triaged quality lap, whose leftovers are carried on → after the last slice,
41
+ // human review of the whole change and every slice's findings, which
40
42
  // signs off (the episode ends back at `idle`) or sends a design → implement
41
43
  // lap.
42
44
 
@@ -63,10 +65,12 @@ export const buildFeature = async (firstBase: string): Promise<void> => {
63
65
  const one = slices.length === 1
64
66
  // A steer may delete every criterion of a later slice: there is nothing to build there.
65
67
  const built = (i: number): boolean => sliceIds(sliceCriteria(slices, i))?.length !== 0
68
+ const reports: { slice: string; report: LensReport }[] = []
66
69
  for (const i of slices.keys()) {
67
70
  if (!built(i)) continue
68
71
  const n = one ? "" : `-${i + 1}`
69
72
  await buildSlice(i === 0 ? firstBase : head(), {
73
+ number: `${i + 1}`,
70
74
  tier,
71
75
  criteria: sliceCriteria(slices, i),
72
76
  refactor: `refactor${n}`,
@@ -76,6 +80,7 @@ export const buildFeature = async (firstBase: string): Promise<void> => {
76
80
  // Every slice but the one is built unreviewed, so the review covers them all.
77
81
  reviewBase: one ? undefined : firstBase,
78
82
  handedOut,
83
+ laps: reports,
79
84
  })
80
85
  }
81
86
  }
@@ -88,6 +93,8 @@ const gather = (handedOut: Set<string>): readonly string[] => {
88
93
  }
89
94
 
90
95
  interface SliceBuild {
96
+ /** The slice's number, from 1. */
97
+ readonly number: string
91
98
  readonly tier: RiskTier | undefined
92
99
  readonly criteria: CriteriaSlice | undefined
93
100
  readonly refactor: string
@@ -97,6 +104,8 @@ interface SliceBuild {
97
104
  readonly reviewBase?: string | undefined
98
105
  /** Every criterion ID handed out so far; the slice adds those a steer adds. */
99
106
  readonly handedOut: Set<string>
107
+ /** Each earlier slice's lap report, by slice number, for the review after the last one. */
108
+ readonly laps: { slice: string; report: LensReport }[]
100
109
  }
101
110
 
102
111
  /** A naive pass (judged only with `flagMode` on), then implement and the quality lap; after the last slice, the review. */
@@ -127,37 +136,38 @@ const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> =
127
136
  if (more) {
128
137
  // The criteria stay in the tree for the next slice; no quality fix may move them.
129
138
  const guard = () => requireCriteriaFrozen(REQUIREMENTS)
130
- await tail(settleQuality, { green: true, lensBase: base, guard, guardRests: false })
139
+ // No gate between slices: what the lap left waits for the review after the last one.
140
+ const report = await tail(async (_, report) => report, {
141
+ green: true,
142
+ lensBase: base,
143
+ guard,
144
+ guardRests: false,
145
+ })
146
+ build.laps.push({ slice: build.number, report })
131
147
  return
132
148
  }
133
149
  const reviewBase = build.reviewBase ?? base
134
- await featureFeedback(await reviewedTail(reviewBase, base), base, { closed, used })
150
+ const outcome = await reviewedTail(reviewBase, base, { slice: build.number, laps: build.laps })
151
+ await featureFeedback(outcome, base, { closed, used })
135
152
  })
136
153
  }
137
154
 
138
- // Between slices nothing reviews the lap's leftovers, and the next lap appends to the file.
139
- // Empty counts as resolved, as the lap reads it.
140
- const settleQuality = async (): Promise<void> => {
141
- let rested = false
142
- while ((read(QUALITY) ?? "").length > 0) {
143
- rested = true
144
- await human("quality-unresolved", {
145
- message: t.qualityUnresolvedMessage(),
146
- label: "Quality findings left unresolved",
147
- file: QUALITY,
148
- })
149
- }
150
- // The person may have fixed code by hand there; nothing else checks it before the review.
151
- if (rested) await healthy(() => fixCheck())
152
- }
153
-
154
- /** The triaged quality lap, then the human review since `base`. */
155
- const reviewedTail = (base: string, lensBase: string): Promise<ReviewOutcome> =>
156
- tail((escalations, report) => humanReview(base, escalations, { report }), {
157
- green: true,
158
- lensBase,
159
- triage: true,
160
- })
155
+ /** The quality lap of slice `last.slice`, then the human review since `base`, listing what earlier slices' `laps` left too. */
156
+ const reviewedTail = (
157
+ base: string,
158
+ lensBase: string,
159
+ last: { readonly slice: string; readonly laps: SliceBuild["laps"] } = { slice: "", laps: [] },
160
+ ): Promise<ReviewOutcome> =>
161
+ tail(
162
+ (escalations, report) =>
163
+ humanReview(base, escalations, {
164
+ report:
165
+ last.laps.length === 0
166
+ ? report
167
+ : joinReports([...last.laps, { slice: last.slice, report }]),
168
+ }),
169
+ { green: true, lensBase },
170
+ )
161
171
 
162
172
  // seam.naive / seam.judge never run on a feedback lap: its design → implement is built unjudged
163
173
  // and unrefactored, held to every criterion's coverage since the process start.
@@ -74,6 +74,7 @@ Feature: the quality lenses are the only agentic review
74
74
  Should subtract round? The criterion does not say.
75
75
  """
76
76
  And step "build.quality.sorting" runs its script
77
+ And step "build.quality.closing" runs its script
77
78
  And step "build.review.composing" runs its script
78
79
 
79
80
  Scenario: a mechanical finding is fixed and listed, noise dropped and listed, a design one noted
@@ -184,7 +185,8 @@ Feature: the quality lenses are the only agentic review
184
185
  gtd(agent): build.fix.quality.fixing
185
186
  gtd(agent): build.fix.quality.fixing
186
187
  gtd(agent): build.fix.quality.fixing → build.health.check
187
- gtd(check): build.health.check → build.review.composing
188
+ gtd(check): build.health.check → build.quality.closing
189
+ gtd(check): build.quality.closing → build.review.composing
188
190
  gtd(check): build.review.composing → build.review.await-review
189
191
  """
190
192
 
@@ -0,0 +1,198 @@
1
+ @inmem
2
+ Feature: every slice's quality lap is triaged, and its leftovers wait for the review
3
+
4
+ A judge sorts each slice's findings like the last slice's (see
5
+ `lens-review.feature`): mechanical ones are fixed, noise is dropped, design
6
+ ones are never sent to the fixer. Nothing between slices asks a person
7
+ anything — what a lap leaves is carried to the review after the last slice,
8
+ each finding naming its slice and lens.
9
+
10
+ Background:
11
+ Given the shipped workflows, as gtd install scaffolds them
12
+ And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
13
+ And an environment variable "GTD_FLAGMODE" set to "false"
14
+ And an environment variable "GTD_SLICEMAXCRITERIA" set to "1"
15
+ And a file "src/calc.ts" with:
16
+ """
17
+ export const add = (a: number, b: number) => a + b
18
+ """
19
+ And the working tree is committed as "feat: add calculator"
20
+ And a file "TODO.md" with:
21
+ """
22
+ # More arithmetic
23
+
24
+ subtract and multiply.
25
+ """
26
+ And step "unwind" changes nothing
27
+ And step "start-gate.check" changes nothing
28
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
29
+ """
30
+ # Requirements
31
+
32
+ ## Acceptance Criteria
33
+
34
+ - AC-1: subtract(a, b) returns a - b.
35
+ - AC-2: multiply(a, b) returns a * b.
36
+ """
37
+ And step "design.gate.answer" changes nothing
38
+ And step "design.slicing" writes ".gtd/SLICES.md" with:
39
+ """
40
+ # Slices
41
+
42
+ ## Subtract
43
+
44
+ - AC-1
45
+
46
+ ## Multiply
47
+
48
+ - AC-2
49
+ """
50
+ And step "seam.naive", when prompted with "This slice builds AC-1 only", writes "src/sub.test.ts" with:
51
+ """
52
+ it("[AC-1] subtracts", () => {})
53
+ """
54
+ And step "seam.naive", when prompted with "This slice builds AC-2 only", writes "src/mul.test.ts" with:
55
+ """
56
+ it("[AC-2] multiplies", () => {})
57
+ """
58
+ And step "seam.check" changes nothing
59
+ And step "implement.applying" runs its script
60
+ And step "implement.health.check" changes nothing
61
+ And step "implement.closing" runs its script
62
+ And judge step "implement.coverage", when its questions contain "AC-1", answers:
63
+ """
64
+ [{"id":"AC-1","answer":"yes","p":0.9}]
65
+ """
66
+ And judge step "implement.coverage", when its questions contain "AC-2", answers:
67
+ """
68
+ [{"id":"AC-2","answer":"yes","p":0.9}]
69
+ """
70
+ And step "build.quality.sorting" runs its script
71
+ And step "build.quality.closing" runs its script
72
+ And step "build.health.check" changes nothing
73
+ And step "build.review.composing" runs its script
74
+
75
+ Scenario: a finding questioning a criterion reaches the final review, never the fixer
76
+ Given step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
77
+ """
78
+ # Quality
79
+
80
+ ## AC-1 ignores floating point
81
+
82
+ Should subtract round? The criterion does not say.
83
+ """
84
+ And judge step "build.quality.triage" answers:
85
+ """
86
+ [{"id":"Q1","answer":"design","p":0.9}]
87
+ """
88
+ When the process is driven
89
+ Then the process rests at "build.review.await-review"
90
+ And no commit body contains "build.fix.quality.fixing"
91
+ And ".gtd/QUALITY.md" does not exist
92
+ And ".gtd/REVIEW.md" contains:
93
+ """
94
+ ## AC-1 ignores floating point
95
+
96
+ _From slice 1, lens correctness._
97
+
98
+ Should subtract round? The criterion does not say.
99
+ """
100
+ And ".gtd/REVIEW.md" contains:
101
+ """
102
+ ## AC-1 ignores floating point (2)
103
+
104
+ _From slice 2, lens correctness._
105
+ """
106
+ And the commit subjects end with:
107
+ """
108
+ gtd(check): implement.closing → build.quality.correctness.reviewing
109
+ gtd(agent): build.quality.correctness.reviewing → build.quality.triage
110
+ gtd(judge): build.quality.triage → build.quality.sorting
111
+ gtd(check): build.quality.sorting → seam.naive
112
+ gtd(agent): seam.naive → seam.check
113
+ gtd(check): seam.check → implement.applying
114
+ gtd(check): implement.applying → implement.health.check
115
+ gtd(check): implement.health.check → implement.coverage
116
+ gtd(judge): implement.coverage → implement.closing
117
+ gtd(check): implement.closing → build.quality.correctness.reviewing
118
+ gtd(agent): build.quality.correctness.reviewing → build.quality.triage
119
+ gtd(judge): build.quality.triage → build.quality.sorting
120
+ gtd(check): build.quality.sorting → build.review.composing
121
+ gtd(check): build.review.composing → build.review.await-review
122
+ """
123
+
124
+ Scenario: a mechanical finding the fix cap leaves in an early slice is carried, never escalated
125
+ Given step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
126
+ """
127
+ # Quality
128
+
129
+ ## subtract has no negative test
130
+
131
+ Add a test subtracting a larger number.
132
+ """
133
+ And judge step "build.quality.triage" answers:
134
+ """
135
+ [{"id":"Q1","answer":"mechanical","p":0.9}]
136
+ """
137
+ And step "build.fix.quality.fixing" changes nothing
138
+ When the process is driven
139
+ Then the process rests at "build.review.await-review"
140
+ And no commit body contains "build.health.describe"
141
+ And ".gtd/REVIEW.md" contains:
142
+ """
143
+ ## subtract has no negative test
144
+
145
+ _From slice 1, lens correctness._
146
+
147
+ _The fixer left this unresolved._
148
+ """
149
+ And the commit subjects end with:
150
+ """
151
+ gtd(check): implement.closing → build.quality.correctness.reviewing
152
+ gtd(agent): build.quality.correctness.reviewing → build.quality.triage
153
+ gtd(judge): build.quality.triage → build.fix.quality.fixing
154
+ gtd(agent): build.fix.quality.fixing
155
+ gtd(agent): build.fix.quality.fixing
156
+ gtd(agent): build.fix.quality.fixing → build.health.check
157
+ gtd(check): build.health.check → build.quality.closing
158
+ gtd(check): build.quality.closing → seam.naive
159
+ gtd(agent): seam.naive → seam.check
160
+ gtd(check): seam.check → implement.applying
161
+ gtd(check): implement.applying → implement.health.check
162
+ gtd(check): implement.health.check → implement.coverage
163
+ gtd(judge): implement.coverage → implement.closing
164
+ gtd(check): implement.closing → build.quality.correctness.reviewing
165
+ gtd(agent): build.quality.correctness.reviewing → build.quality.triage
166
+ gtd(judge): build.quality.triage → build.fix.quality.fixing
167
+ gtd(agent): build.fix.quality.fixing
168
+ gtd(agent): build.fix.quality.fixing
169
+ gtd(agent): build.fix.quality.fixing → build.health.check
170
+ gtd(check): build.health.check → build.quality.closing
171
+ gtd(check): build.quality.closing → build.review.composing
172
+ gtd(check): build.review.composing → build.review.await-review
173
+ """
174
+
175
+ Scenario: the final review lists every slice's fixes with their commits
176
+ Given step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
177
+ """
178
+ # Quality
179
+
180
+ ## subtract has no negative test
181
+
182
+ Add a test subtracting a larger number.
183
+ """
184
+ And judge step "build.quality.triage" answers:
185
+ """
186
+ [{"id":"Q1","answer":"mechanical","p":0.9}]
187
+ """
188
+ And step "build.fix.quality.fixing" writes "src/edge.test.ts" with:
189
+ """
190
+ it("subtracts a larger number", () => {})
191
+ """
192
+ And step "build.fix.quality.fixing" deletes ".gtd/QUALITY.md"
193
+ When the process is driven
194
+ Then the process rests at "build.review.await-review"
195
+ And ".gtd/REVIEW.md" contains ", slice 1, lens correctness)"
196
+ And ".gtd/REVIEW.md" contains ", slice 2, lens correctness)"
197
+ And ".gtd/REVIEW.md" contains the short hash of the commit "gtd(agent): build.fix.quality.fixing → build.health.check"
198
+ And no commit body contains "build.quality.closing"