@pmelab/gtd 41.0.0 → 43.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 (73) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +107 -53
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-Ch4LLrEj.mjs → reserved-COEMFEpy.mjs} +84 -51
  5. package/dist/chunks/{steering-DVILanVf.mjs → steering-D4LUQ-av.mjs} +95 -12
  6. package/dist/chunks/tail-C_xn8U7U.mjs +246 -0
  7. package/dist/flows.mjs +11 -1
  8. package/dist/gtd.bundle.mjs +498 -30
  9. package/dist/steering.mjs +2 -2
  10. package/dist/testkit.mjs +460 -28
  11. package/dist/web/main.js +42 -13
  12. package/dist/workflow-kit.mjs +61 -25
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +66 -57
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +2 -2
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +446 -141
  19. package/package.json +1 -1
  20. package/src/cli/Cli.test.ts +33 -1
  21. package/src/cli/Cli.ts +70 -6
  22. package/src/cli/History.ts +15 -0
  23. package/src/cli/Judges.test.ts +373 -0
  24. package/src/cli/Judges.ts +624 -0
  25. package/src/cli/Metrics.ts +3 -16
  26. package/src/cli/Program.test.ts +3 -1
  27. package/src/cli/Program.ts +9 -0
  28. package/src/flows/scripts.ts +15 -0
  29. package/src/steering/index.ts +12 -1
  30. package/src/steering/review.test.ts +109 -20
  31. package/src/steering/review.ts +160 -9
  32. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  33. package/src/testkit/FlowScripts.test.ts +17 -0
  34. package/src/testkit/FlowScripts.ts +15 -0
  35. package/src/testkit/steps.ts +31 -0
  36. package/src/workflow-kit/review.test.ts +170 -26
  37. package/src/workflow-kit/review.ts +95 -19
  38. package/src/workflows/feature/coverage.feature +3 -3
  39. package/src/workflows/feature/fast-path.feature +2 -2
  40. package/src/workflows/feature/feature.test.ts +2 -37
  41. package/src/workflows/feature/feature.ts +12 -14
  42. package/src/workflows/feature/flag-mode.feature +2 -2
  43. package/src/workflows/feature/lens-review.feature +246 -0
  44. package/src/workflows/feature/one-step-land.feature +2 -2
  45. package/src/workflows/feature/quality-unresolved.feature +47 -32
  46. package/src/workflows/feature/refactor-absorbs.feature +3 -3
  47. package/src/workflows/feature/refactor-first.feature +5 -5
  48. package/src/workflows/feature/refactor-rebuild.feature +7 -7
  49. package/src/workflows/feature/refactor-review.feature +19 -100
  50. package/src/workflows/feature/refactor.test.ts +12 -27
  51. package/src/workflows/feature/refactor.ts +0 -1
  52. package/src/workflows/feature/review-answers.feature +179 -0
  53. package/src/workflows/feature/review-gate.feature +6 -6
  54. package/src/workflows/feature/review.test.ts +33 -77
  55. package/src/workflows/feature/review.ts +79 -47
  56. package/src/workflows/feature/seam-rejection.feature +2 -2
  57. package/src/workflows/feature/seam.feature +2 -2
  58. package/src/workflows/feature/seam.test.ts +1 -1
  59. package/src/workflows/feature/slicing.feature +6 -6
  60. package/src/workflows/feature/steps.test.ts +0 -12
  61. package/src/workflows/feature/steps.ts +3 -10
  62. package/src/workflows/index.ts +1 -0
  63. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  64. package/src/workflows/shared/access.test.ts +2 -11
  65. package/src/workflows/shared/access.ts +0 -2
  66. package/src/workflows/shared/lenses.test.ts +39 -0
  67. package/src/workflows/shared/lenses.ts +176 -0
  68. package/src/workflows/shared/options.ts +1 -0
  69. package/src/workflows/shared/tail.ts +19 -4
  70. package/src/workflows/shared/text.test.ts +106 -88
  71. package/src/workflows/shared/text.ts +164 -53
  72. package/src/workflows/shared/vars.ts +1 -0
  73. package/dist/chunks/tail-lY1UmJrU.mjs +0 -112
@@ -48,7 +48,7 @@ interface Drive {
48
48
  readonly vars?: Readonly<Record<string, string>>
49
49
  /** The REVIEW.md the human leaves; defaults to `notes` laid over the baseline. */
50
50
  readonly after?: string
51
- /** The REVIEW.md each `review.reviewing` turn writes, in order; also raises the review-turn stop to one past the last. */
51
+ /** The REVIEW.md each `review.composing` turn writes, in order; also raises the review-turn stop to one past the last. */
52
52
  readonly reviewDocs?: readonly (string | undefined)[]
53
53
  /** Let the run pass the second gate, stopping at the first range read after it. */
54
54
  readonly passSecondGate?: boolean
@@ -95,7 +95,7 @@ const drive = async (d: Drive): Promise<Run> => {
95
95
  let awaits = 0
96
96
  let reviews = 0
97
97
  const effects: Record<string, () => void> = {
98
- "review.reviewing": () => {
98
+ "review.composing": () => {
99
99
  const docs = d.reviewDocs
100
100
  if (++reviews > (docs?.length ?? 0) + (docs === undefined ? 1 : 0)) throw new Stop()
101
101
  const written = docs?.[reviews - 1]
@@ -116,6 +116,7 @@ const drive = async (d: Drive): Promise<Run> => {
116
116
  gateMessages.push(request.options.message ?? "")
117
117
  }
118
118
  if (request.kind === "agent") prompts.set(request.name, request.prompt)
119
+ if (request.kind === "run") prompts.set(request.name, request.body)
119
120
  if (request.kind === "judge") asked.push(...request.questions)
120
121
  }
121
122
  const context = fixtureContext(
@@ -184,10 +185,10 @@ describe("the risk-fix pass", () => {
184
185
  it("a marked risk is fixed, kept green, re-reviewed, then rests at the gate", async () => {
185
186
  const run = await drive({ notes: [], reviewDocs: [risky, clean] })
186
187
  expect(run.log.slice(0, 5)).toEqual([
187
- "review.reviewing",
188
+ "review.composing",
188
189
  "review.fix.risks.fixing",
189
190
  "health.check",
190
- "review.reviewing",
191
+ "review.composing",
191
192
  "review.await-review",
192
193
  ])
193
194
  expect(run.prompts.get("review.fix.risks.fixing")).toContain("risk-1")
@@ -199,10 +200,10 @@ describe("the risk-fix pass", () => {
199
200
  const run = await drive({ notes: [], reviewDocs: [risky, risky] })
200
201
  expect(run.log.filter((n) => n === "review.fix.risks.fixing")).toHaveLength(1)
201
202
  expect(run.log.slice(0, 5)).toEqual([
202
- "review.reviewing",
203
+ "review.composing",
203
204
  "review.fix.risks.fixing",
204
205
  "health.check",
205
- "review.reviewing",
206
+ "review.composing",
206
207
  "review.await-review",
207
208
  ])
208
209
  })
@@ -232,7 +233,7 @@ describe("the risk-fix pass", () => {
232
233
  it("no marker means no fix-risks step", async () => {
233
234
  const run = await drive({ notes: [], reviewDocs: [clean] })
234
235
  expect(run.log).not.toContain("review.fix.risks.fixing")
235
- expect(run.log[0]).toBe("review.reviewing")
236
+ expect(run.log[0]).toBe("review.composing")
236
237
  expect(run.log[1]).toBe("review.await-review")
237
238
  })
238
239
 
@@ -243,16 +244,16 @@ describe("the risk-fix pass", () => {
243
244
  reviewDocs: [undefined, risky, clean],
244
245
  })
245
246
  expect(run.log).toEqual([
246
- "review.reviewing",
247
+ "review.composing",
247
248
  "review.await-review",
248
249
  "review.triage",
249
250
  "review.fix.nits.fixing",
250
251
  "health.check",
251
252
  "review.closing",
252
- "review.reviewing",
253
+ "review.composing",
253
254
  "review.fix.risks.fixing",
254
255
  "health.check",
255
- "review.reviewing",
256
+ "review.composing",
256
257
  "review.await-review",
257
258
  ])
258
259
  })
@@ -265,7 +266,7 @@ describe("the guard", () => {
265
266
  it("runs right after the risk-fix turn", async () => {
266
267
  const run = await drive({ notes: [], reviewDocs: [risky, clean], guarded: true })
267
268
  expect(run.log.slice(0, 4)).toEqual([
268
- "review.reviewing",
269
+ "review.composing",
269
270
  "review.fix.risks.fixing",
270
271
  "guard",
271
272
  "health.check",
@@ -443,7 +444,7 @@ describe("review verdict routing", () => {
443
444
  it("an unanswered note counts as edit", async () => {
444
445
  const run = await drive({ notes: ["add — typo", "sub", "mul"], answers: {} })
445
446
  expect(run.log).toEqual([
446
- "review.reviewing",
447
+ "review.composing",
447
448
  "review.await-review",
448
449
  "review.triage",
449
450
  "review.closing",
@@ -478,7 +479,7 @@ describe("review verdict routing", () => {
478
479
  it("only praise closes and signs off with no agent turn", async () => {
479
480
  const run = await drive({ notes, answers: { "note-1": verdict("praise") } })
480
481
  expect(run.log).toEqual([
481
- "review.reviewing",
482
+ "review.composing",
482
483
  "review.await-review",
483
484
  "review.triage",
484
485
  "review.closing",
@@ -492,7 +493,7 @@ describe("review verdict routing", () => {
492
493
  answers: { "note-1": verdict("question"), "note-2": verdict("question") },
493
494
  })
494
495
  expect(run.log).toEqual([
495
- "review.reviewing",
496
+ "review.composing",
496
497
  "review.await-review",
497
498
  "review.triage",
498
499
  "review.answer-review-questions",
@@ -508,17 +509,17 @@ describe("review verdict routing", () => {
508
509
  answers: { "note-1": verdict("question"), "note-2": verdict("nit") },
509
510
  })
510
511
  expect(run.log).toEqual([
511
- "review.reviewing",
512
+ "review.composing",
512
513
  "review.await-review",
513
514
  "review.triage",
514
515
  "review.answer-review-questions",
515
516
  "review.fix.nits.fixing",
516
517
  "health.check",
517
518
  "review.closing",
518
- "review.reviewing",
519
+ "review.composing",
519
520
  ])
520
521
  expect(run.prompts.get("review.fix.nits.fixing")).not.toContain("why?")
521
- expect(run.prompts.get("review.reviewing")).toContain("Carry-over: commit `c4`")
522
+ expect(run.prompts.get("review.composing")).toContain("git show 'c4:.gtd/REVIEW.md'")
522
523
  expect(run.result).toBe("stopped")
523
524
  })
524
525
 
@@ -527,7 +528,8 @@ describe("review verdict routing", () => {
527
528
  notes: ["add — typo", "sub", "mul"],
528
529
  answers: { "note-1": verdict("nit") },
529
530
  })
530
- expect(run.prompts.get("review.reviewing")).not.toContain("Carry-over")
531
+ expect(run.prompts.get("review.composing")).not.toContain("git show")
532
+ expect(run.prompts.get("review.composing")).toContain("## Fixed from your notes")
531
533
  })
532
534
 
533
535
  it("edits run after questions and nits, and the capture names only the edits and the answering commit", async () => {
@@ -536,7 +538,7 @@ describe("review verdict routing", () => {
536
538
  answers: { "note-2": verdict("question"), "note-3": verdict("nit") },
537
539
  })
538
540
  expect(run.log).toEqual([
539
- "review.reviewing",
541
+ "review.composing",
540
542
  "review.await-review",
541
543
  "review.triage",
542
544
  "review.answer-review-questions",
@@ -556,7 +558,7 @@ describe("review verdict routing", () => {
556
558
  it("a noted round with no extractable note collects as one edit, without judging", async () => {
557
559
  const run = await drive({ notes: [], after: BEFORE + "\nSome stray prose.\n" })
558
560
  expect(run.log).toEqual([
559
- "review.reviewing",
561
+ "review.composing",
560
562
  "review.await-review",
561
563
  "review.closing",
562
564
  "review.collecting",
@@ -673,18 +675,19 @@ describe("severity routing", () => {
673
675
  expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
674
676
  })
675
677
 
676
- it("a confident could edit defers and, alone, returns to the review gate", async () => {
678
+ it("a confident could edit is still re-planned, labelled could", async () => {
677
679
  const run = await drive({
678
680
  notes: ["add — quote", "sub", "mul"],
679
681
  answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
680
682
  })
681
683
  expect(run.log).toEqual([
682
- "review.reviewing",
684
+ "review.composing",
683
685
  "review.await-review",
684
686
  "review.triage",
685
- "review.defer",
686
- "review.await-review",
687
+ "review.closing",
688
+ "review.collecting",
687
689
  ])
690
+ expect(run.prompts.get("review.collecting")).toContain("(could)")
688
691
  })
689
692
 
690
693
  it("severity answers on a nit leave it a nit", async () => {
@@ -728,68 +731,19 @@ describe("severity routing", () => {
728
731
  expect(run.judged).toContain("note-1.security")
729
732
  })
730
733
 
731
- it("the gate after a deferral accepts a clean land and lists the deferred notes", async () => {
732
- const run = await drive({
733
- notes: ["add — quote", "sub", "mul"],
734
- answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
735
- })
736
- expect(run.gates).toEqual([true, true])
737
- expect(run.gateMessages[1]).toContain("NOT acted on")
738
- expect(run.gateMessages[1]).toContain("note-1")
739
- expect(run.gateMessages[0]).not.toContain("NOT acted on")
740
- })
741
-
742
734
  it("the first round's gate accepts a clean land", async () => {
743
735
  const run = await drive({ notes: ["add", "sub", "mul"] })
744
736
  expect(run.gates.length).toBeGreaterThan(0)
745
737
  expect(run.gates.every((g) => g)).toBe(true)
746
738
  })
747
739
 
748
- it("a later gate still accepts a clean land after a deferral round with a nit", async () => {
749
- const run = await drive({
750
- notes: ["add — quote", "sub — typo", "mul"],
751
- answers: {
752
- "note-1": verdict("edit"),
753
- ...sev("note-1", could),
754
- "note-2": verdict("nit"),
755
- },
756
- reviewDocs: [undefined, doc(["add", "sub"])],
757
- })
758
- expect(run.log).toContain("review.defer")
759
- expect(run.log.at(-1)).toBe("review.await-review")
760
- expect(run.gates).toEqual([true, true])
761
- })
762
-
763
- it("a question and a could note in one round: the gate's base is the answering commit", async () => {
764
- const run = await drive({
765
- notes: ["add — why?", "sub — quote", "mul"],
766
- answers: {
767
- "note-1": verdict("question"),
768
- "note-2": verdict("edit"),
769
- ...sev("note-2", could),
770
- },
771
- passSecondGate: true,
772
- })
773
- expect(run.log).toEqual([
774
- "review.reviewing",
775
- "review.await-review",
776
- "review.triage",
777
- "review.answer-review-questions",
778
- "review.defer",
779
- "review.await-review",
780
- ])
781
- // c4 is the answering commit, not the defer commit (c5) or the round (c2)
782
- expect(run.since.at(-1)).toBe("c4")
783
- })
784
-
785
740
  it("a blank reviewSeverityMinP makes every confident could critical", async () => {
786
741
  const run = await drive({
787
742
  notes: ["add — quote", "sub", "mul"],
788
743
  answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
789
744
  vars: { reviewSeverityMinP: "" },
790
745
  })
791
- expect(run.log).toContain("review.collecting")
792
- expect(run.log).not.toContain("review.defer")
746
+ expect(run.prompts.get("review.collecting")).toContain("(critical: judge unsure)")
793
747
  })
794
748
  })
795
749
 
@@ -807,7 +761,7 @@ describe("BuildTailOptions.lensBase", () => {
807
761
  if (request.kind === "restart") return Promise.resolve()
808
762
  if (request.kind === "agent" && request.name.startsWith("quality."))
809
763
  prompts.push(request.prompt)
810
- if (request.name === "review.reviewing") throw new Stop()
764
+ if (request.name === "review.composing") throw new Stop()
811
765
  return Promise.resolve()
812
766
  },
813
767
  pushScope: () => undefined,
@@ -1048,10 +1002,12 @@ describe("buildTail without rules", () => {
1048
1002
  if (request.kind === "agent") prompts.set(request.name, request.prompt)
1049
1003
  if (request.name === "health.check") checks++
1050
1004
  if (request.name === "fix.quality.fixing") files.delete(".gtd/QUALITY.md")
1051
- if (request.name === "review.reviewing" || request.name === "health.stop")
1005
+ if (request.name === "review.composing" || request.name === "health.stop")
1052
1006
  throw new Stop()
1053
1007
  return Promise.resolve(
1054
- request.kind === "judge" ? { answers: {}, truncated: [] } : undefined,
1008
+ request.kind === "judge"
1009
+ ? { answers: { Q1: { answer: "mechanical", p: 0.9 } }, truncated: [] }
1010
+ : undefined,
1055
1011
  )
1056
1012
  },
1057
1013
  pushScope: () => undefined,
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  answered,
3
+ changesSince,
3
4
  head,
4
5
  judge,
5
6
  numeric,
6
7
  requireRevert,
7
8
  restoreScript,
8
9
  run,
10
+ showScript,
9
11
  vars,
12
+ writeScript,
10
13
  type JudgeQuestion,
11
14
  type Judgment,
12
15
  type ScopeAccess,
@@ -14,18 +17,19 @@ import {
14
17
  import type { ReviewNote } from "../../steering/index.js"
15
18
  import * as kit from "../../workflow-kit/index.js"
16
19
  import { guarded, healthy, type EscalationCount } from "../shared/health.js"
20
+ import { NO_FINDINGS, type LensReport } from "../shared/lenses.js"
17
21
  import {
18
22
  awaitReview,
19
23
  fixCheck,
20
24
  fixRisks,
25
+ QUALITY,
21
26
  REQUIREMENTS,
22
27
  REVIEW,
23
- reviewing,
24
28
  reviewMissing,
25
29
  } from "../shared/steps.js"
26
30
  import { closeReview, isOptionalObject, tail, type TailOptions } from "../shared/tail.js"
27
31
  import * as t from "../shared/text.js"
28
- import { answerReviewQuestions, collecting, deferNotes, fixNits } from "./steps.js"
32
+ import { answerReviewQuestions, collecting, fixNits } from "./steps.js"
29
33
 
30
34
  export type { ReviewOutcome } from "../../workflow-kit/index.js"
31
35
 
@@ -42,6 +46,12 @@ const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
42
46
 
43
47
  type Tier = "critical" | "should" | "could"
44
48
 
49
+ const TIER_ORDER: Readonly<Record<Tier, number>> = { critical: 0, should: 1, could: 2 }
50
+
51
+ /** Capture order: critical, should, could; within a tier one-way doors, then new concepts. */
52
+ const captureKey = (s: Severity | undefined): number =>
53
+ s === undefined ? 2 : TIER_ORDER[s.tier] * 3 + s.rank
54
+
45
55
  const SEVERITY_ANSWERS: Readonly<Record<string, readonly string[]>> = {
46
56
  security: ["yes", "no"],
47
57
  reversibility: ["one-way", "two-way"],
@@ -56,10 +66,7 @@ interface Severity {
56
66
  readonly rank: 0 | 1 | 2
57
67
  }
58
68
 
59
- /**
60
- * Deferring is the risky direction: anything unanswered, unrecognised, cut or
61
- * below `minP` is `critical`, never `could`.
62
- */
69
+ /** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
63
70
  export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
64
71
  const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
65
72
  const a = (q: string): string | undefined => ans(q)?.answer
@@ -121,7 +128,7 @@ const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
121
128
  * nothing) plus four severity answers. Dismissing a note is the risky
122
129
  * direction, so only a confident non-`edit` verdict on uncut evidence counts;
123
130
  * a cut, unanswered or below-floor note is an `edit`. Severity answers on a
124
- * non-`edit` note are ignored. A confident `could` edit becomes `defer`.
131
+ * non-`edit` note are ignored; on an `edit` they order the collecting turn.
125
132
  */
126
133
  const triage =
127
134
  (severities: Map<string, Severity>) =>
@@ -151,9 +158,8 @@ const triage =
151
158
  ? undefined
152
159
  : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
153
160
  if (verdict !== undefined && !truncated.includes(n.id)) return [n.id, verdict]
154
- const severity = severityOf(n, judgment, severityMinP)
155
- severities.set(n.id, severity)
156
- return [n.id, severity.tier === "could" ? "defer" : "edit"]
161
+ severities.set(n.id, severityOf(n, judgment, severityMinP))
162
+ return [n.id, "edit"]
157
163
  }),
158
164
  )
159
165
  }
@@ -161,10 +167,41 @@ const triage =
161
167
  export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
162
168
 
163
169
  /**
164
- * A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
165
- * reviews and signs off or comments, and each note is judged: edits go to
166
- * another planning lap, questions are answered in place, nits fixed in one
167
- * batch, praise dropped.
170
+ * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
171
+ * turn — and removes what the fixer left in `.gtd/QUALITY.md`. A re-review
172
+ * after fixed nits restores the doc as committed at `carry`, answers kept;
173
+ * a fresh one leaves out the notes the human already answered.
174
+ */
175
+ const composing = (
176
+ base: string,
177
+ report: LensReport,
178
+ carry?: string,
179
+ nitFix?: string,
180
+ answered: ReadonlySet<string> = new Set(),
181
+ ): Promise<void> =>
182
+ run(
183
+ "review.composing",
184
+ carry === undefined
185
+ ? writeScript(
186
+ REVIEW,
187
+ t.composedReview(base, {
188
+ ...report,
189
+ weakened: kit.removedAssertions(changesSince(base)),
190
+ nitFix,
191
+ answered,
192
+ }),
193
+ [QUALITY],
194
+ )
195
+ : showScript(carry, REVIEW, REVIEW),
196
+ { label: "Composing the review", file: REVIEW, base },
197
+ )
198
+
199
+ /**
200
+ * The quality lap's notes reach a human over everything since `base`, who
201
+ * answers each one — fix as suggested, won't fix, or their own answer — and
202
+ * may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
203
+ * answer or comment is judged: edits go to another planning lap, questions
204
+ * are answered in place, nits fixed in one batch, praise dropped.
168
205
  */
169
206
  export const review = (
170
207
  base: string,
@@ -179,40 +216,31 @@ export const review = (
179
216
  "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
180
217
  }
181
218
  const severities = new Map<string, Severity>()
182
- // Armed by a defer-only round to pick the next gate's message; a fresh
183
- // review (`reviewing`) disarms it.
184
- let deferred: readonly t.NoteInput[] = []
219
+ // Set by a nit fix, so the fresh review that follows names its commit.
220
+ let nitFix: string | undefined
221
+ // Every note the human answered in this review, left out of a fresh one.
222
+ const answered = new Set<string>()
185
223
  return kit.review(base, {
186
224
  file: REVIEW,
187
225
  feedback: feedback.file,
188
226
  isCode,
189
227
  reviewing: (b, carry) => {
190
- deferred = []
191
- return reviewing(b, carry, options.lap)
228
+ const fixedAt = nitFix
229
+ nitFix = undefined
230
+ return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt, answered)
192
231
  },
193
232
  fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
194
233
  keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
195
- awaitReview: (b) => {
196
- const notes = deferred
197
- deferred = []
198
- return notes.length > 0
199
- ? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes))
200
- : awaitReview(b)
201
- },
234
+ awaitReview: (b) => awaitReview(b),
202
235
  reviewMissing,
203
236
  collecting: feedback.collecting,
204
237
  triage: triage(severities),
205
- defer: (notes) => {
206
- deferred = notes.map((n) => ({
207
- id: n.id,
208
- anchor: n.anchor,
209
- text: n.text,
210
- label: severities.get(n.id)!.reason,
211
- }))
212
- return deferNotes(deferred, head())
213
- },
238
+ decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
214
239
  answerQuestions: answerReviewQuestions,
215
- fixNits: guarded((notes) => fixNits(notes, fixAccess, rules), guard),
240
+ fixNits: async (notes) => {
241
+ await guarded((n: typeof notes) => fixNits(n, fixAccess, rules), guard)(notes)
242
+ nitFix = head()
243
+ },
216
244
  close: closeReview,
217
245
  captures: {
218
246
  edits: t.reviewEditsCapture,
@@ -221,14 +249,13 @@ export const review = (
221
249
  t.reviewEditNotesCapture(
222
250
  round,
223
251
  edits
224
- .map((n, i) => ({ n, i, s: severities.get(n.id) }))
225
- // critical before should, then one-way doors, then new concepts
226
- .sort(
227
- (x, y) =>
228
- Number(x.s?.tier === "should") - Number(y.s?.tier === "should") ||
229
- (x.s?.rank ?? 2) - (y.s?.rank ?? 2) ||
230
- x.i - y.i,
231
- )
252
+ .map((n, i) => ({
253
+ n,
254
+ i,
255
+ s: severities.get(n.id),
256
+ key: captureKey(severities.get(n.id)),
257
+ }))
258
+ .sort((x, y) => x.key - y.key || x.i - y.i)
232
259
  .map(({ n, s }) => ({
233
260
  id: n.id,
234
261
  anchor: n.anchor,
@@ -259,11 +286,13 @@ export interface BuildTailOptions extends TailOptions {
259
286
  readonly feedback?: FeedbackTarget | undefined
260
287
  /** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
261
288
  readonly fixAccess?: ScopeAccess | undefined
262
- /** Absent: "first". */
289
+ /** @deprecated No reviewer turn reads it since the review is composed. */
263
290
  readonly lap?: t.ReviewRange | undefined
291
+ /** What the triaged quality lap left for the review; absent: nothing. */
292
+ readonly report?: LensReport | undefined
264
293
  }
265
294
 
266
- /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
295
+ /** The build tail: fix (when entered red), keep green unless `green`, the triaged quality lap, then human review since `base`. */
267
296
  export function buildTail(base: string, options?: BuildTailOptions): Promise<kit.ReviewOutcome>
268
297
  /** @deprecated Pass `buildTail(base, { fixFirst, green, ...options })`. */
269
298
  export function buildTail(
@@ -287,7 +316,10 @@ export function buildTail(
287
316
  )
288
317
  }
289
318
  const [base, form] = options
290
- return tail((escalations) => review(base, escalations, form), form)
319
+ return tail((escalations, report) => review(base, escalations, { ...form, report }), {
320
+ ...form,
321
+ triage: true,
322
+ })
291
323
  }
292
324
 
293
325
  const isOptionalBoolean = (value: unknown): value is boolean | null | undefined =>
@@ -100,7 +100,7 @@ Feature: rejected naive passes walk one fixed path through the steer gate to the
100
100
  And step "implement.applying" runs its script
101
101
  When gtd lands
102
102
  And the process is driven
103
- Then the process rests at "build.review.reviewing"
103
+ Then the process rests at "build.review.composing"
104
104
  And the commit subjects end with:
105
105
  """
106
106
  gtd(human): idle → unwind
@@ -124,7 +124,7 @@ Feature: rejected naive passes walk one fixed path through the steer gate to the
124
124
  gtd(check): implement.applying → implement.health.check
125
125
  gtd(check): implement.health.check → implement.coverage
126
126
  gtd(judge): implement.coverage → implement.closing
127
- gtd(check): implement.closing → build.review.reviewing
127
+ gtd(check): implement.closing → build.review.composing
128
128
  """
129
129
  And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
130
130
  And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
@@ -152,7 +152,7 @@ Feature: the naive pass and seam judge decide how a feature is built
152
152
  [{"id":"AC-1","answer":"yes","p":0.9}]
153
153
  """
154
154
  When the process is driven
155
- Then the process rests at "build.review.reviewing"
155
+ Then the process rests at "build.review.composing"
156
156
  And "src/calc.ts" contains "subtract"
157
157
  And the commit subjects end with:
158
158
  """
@@ -160,7 +160,7 @@ Feature: the naive pass and seam judge decide how a feature is built
160
160
  gtd(check): implement.applying → implement.health.check
161
161
  gtd(check): implement.health.check → implement.coverage
162
162
  gtd(judge): implement.coverage → implement.closing
163
- gtd(check): implement.closing → build.review.reviewing
163
+ gtd(check): implement.closing → build.review.composing
164
164
  """
165
165
  And ".gtd/REQUIREMENTS.md" does not exist
166
166
 
@@ -335,7 +335,7 @@ const driveSeam = async (
335
335
  note: () => void files.set(NOTE, "Seam: a shared op"),
336
336
  deriving: () => void files.set(REFACTOR, "package"),
337
337
  closing: () => void files.delete(REFACTOR),
338
- "review.reviewing": () => void files.set(REVIEW, reviewDoc),
338
+ "review.composing": () => void files.set(REVIEW, reviewDoc),
339
339
  "review.closing": () => {
340
340
  files.delete(REVIEW)
341
341
  current = "after-refactor"
@@ -51,7 +51,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
51
51
  [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
52
52
  """
53
53
  When the process is driven
54
- Then the process rests at "build.review.reviewing"
54
+ Then the process rests at "build.review.composing"
55
55
  And the commit subjects end with:
56
56
  """
57
57
  gtd(human): design.gate.answer → seam.naive
@@ -60,7 +60,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
60
60
  gtd(check): implement.applying → implement.health.check
61
61
  gtd(check): implement.health.check → implement.coverage
62
62
  gtd(judge): implement.coverage → implement.closing
63
- gtd(check): implement.closing → build.review.reviewing
63
+ gtd(check): implement.closing → build.review.composing
64
64
  """
65
65
  And the output lines of gtd "slices" start with:
66
66
  """
@@ -112,7 +112,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
112
112
  [{"id":"AC-2","answer":"yes","p":0.9}]
113
113
  """
114
114
  When the process is driven
115
- Then the process rests at "build.review.reviewing"
115
+ Then the process rests at "build.review.composing"
116
116
  And the commit subjects end with:
117
117
  """
118
118
  gtd(agent): design.triage → design.gate.answer
@@ -129,7 +129,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
129
129
  gtd(check): implement.applying → implement.health.check
130
130
  gtd(check): implement.health.check → implement.coverage
131
131
  gtd(judge): implement.coverage → implement.closing
132
- gtd(check): implement.closing → build.review.reviewing
132
+ gtd(check): implement.closing → build.review.composing
133
133
  """
134
134
  And the output lines of gtd "slices" start with:
135
135
  """
@@ -268,7 +268,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
268
268
  """
269
269
  When gtd lands
270
270
  And the process is driven
271
- Then the process rests at "build.review.reviewing"
271
+ Then the process rests at "build.review.composing"
272
272
  And the commit subjects end with:
273
273
  """
274
274
  gtd(human): seam.steer → seam.naive
@@ -277,7 +277,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
277
277
  gtd(check): implement.applying → implement.health.check
278
278
  gtd(check): implement.health.check → implement.coverage
279
279
  gtd(judge): implement.coverage → implement.closing
280
- gtd(check): implement.closing → build.review.reviewing
280
+ gtd(check): implement.closing → build.review.composing
281
281
  """
282
282
  And ".gtd/REQUIREMENTS.md" does not exist
283
283
  And ".gtd/SLICES.md" does not exist
@@ -111,18 +111,6 @@ describe("the feature workflow's own steps declare skills — a step's rendered
111
111
  expect(scopes).toEqual([{ name: "review.fix.nits", access }])
112
112
  expect(steps_.map((r) => ("name" in r ? r.name : ""))).toEqual(["fixing"])
113
113
  })
114
-
115
- it("deferNotes rests at build.review.defer as a script writing .gtd/TODO.md", async () => {
116
- const request = await capture(
117
- () =>
118
- steps.deferNotes([{ id: "note-1", anchor: "a", text: "x", label: "could" }], "abcdef123"),
119
- "build",
120
- )
121
- if (request.kind !== "run") throw new Error("unreachable")
122
- expect(request.name).toBe("review.defer")
123
- expect(request.body).toContain("## Deferred review notes (abcdef1)")
124
- expect(request.options).toMatchObject({ file: ".gtd/TODO.md" })
125
- })
126
114
  })
127
115
 
128
116
  describe("the feature workflow's planning steps run on the planner model", () => {
@@ -1,4 +1,4 @@
1
- import { human, run, scope, type ScopeAccess } from "../../flows/index.js"
1
+ import { human, scope, type ScopeAccess } from "../../flows/index.js"
2
2
  import type { CollectRound } from "../../workflow-kit/index.js"
3
3
  import * as t from "../shared/text.js"
4
4
  import { riskTierOn } from "./tier.js"
@@ -52,8 +52,8 @@ export const fixNits = (
52
52
  }
53
53
 
54
54
  /** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
55
- export const collecting = (capture: string): Promise<void> =>
56
- t.agentWithSkills("review.collecting", t.buildReviewCollectingPrompt(capture), {
55
+ export const collecting = (capture: string, round?: CollectRound): Promise<void> =>
56
+ t.agentWithSkills("review.collecting", t.buildReviewCollectingPrompt(capture, undefined, round), {
57
57
  label: "Collecting your feedback",
58
58
  file: REQUIREMENTS,
59
59
  mode: "qa",
@@ -79,10 +79,3 @@ export const refactorCollecting = (
79
79
  allowEmpty: true,
80
80
  },
81
81
  )
82
-
83
- /** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
84
- export const deferNotes = (notes: readonly t.NoteInput[], round: string): Promise<void> =>
85
- run("review.defer", t.deferNotesScript(notes, round), {
86
- label: "Deferring could-notes to TODO.md",
87
- file: ".gtd/TODO.md",
88
- })