@pmelab/gtd 43.0.0 → 45.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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +33 -13
  3. package/dist/chunks/{reserved-COEMFEpy.mjs → reserved-ty1mtlnS.mjs} +155 -3
  4. package/dist/chunks/review-BmoH-9im.mjs +408 -0
  5. package/dist/chunks/{tail-C_xn8U7U.mjs → tail-CM3sVoAb.mjs} +3 -3
  6. package/dist/workflow-kit.mjs +22 -10
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +6 -278
  9. package/dist/workflows/fix.mjs +2 -2
  10. package/dist/workflows/review.mjs +27 -7
  11. package/dist/workflows/rollout.mjs +1 -1
  12. package/dist/workflows.mjs +379 -66
  13. package/package.json +1 -1
  14. package/src/steering/FORMATS.md +4 -4
  15. package/src/workflow-kit/review.test.ts +80 -2
  16. package/src/workflow-kit/review.ts +45 -11
  17. package/src/workflows/feature/coverage.ts +1 -1
  18. package/src/workflows/feature/feature.test.ts +8 -0
  19. package/src/workflows/feature/feature.ts +2 -2
  20. package/src/workflows/feature/index.ts +1 -1
  21. package/src/workflows/feature/refactor.ts +1 -1
  22. package/src/workflows/feature/review-answers.feature +12 -3
  23. package/src/workflows/feature/seam.ts +1 -1
  24. package/src/workflows/feature/standards.feature +286 -0
  25. package/src/workflows/feature/steps.test.ts +2 -64
  26. package/src/workflows/feature/steps.ts +5 -41
  27. package/src/workflows/fix/fix.feature +20 -21
  28. package/src/workflows/index.ts +1 -1
  29. package/src/workflows/review/review-door.feature +158 -0
  30. package/src/workflows/review/review.test.ts +1 -73
  31. package/src/workflows/review/review.ts +34 -6
  32. package/src/workflows/shared/learn.test.ts +154 -0
  33. package/src/workflows/shared/learn.ts +206 -0
  34. package/src/workflows/shared/options.ts +1 -0
  35. package/src/workflows/{feature → shared}/review.test.ts +4 -8
  36. package/src/workflows/{feature → shared}/review.ts +23 -10
  37. package/src/workflows/shared/skills.test.ts +2 -0
  38. package/src/workflows/shared/skills.ts +2 -0
  39. package/src/workflows/shared/standards.test.ts +90 -0
  40. package/src/workflows/shared/standards.ts +156 -0
  41. package/src/workflows/shared/steps.test.ts +64 -2
  42. package/src/workflows/shared/steps.ts +40 -1
  43. package/src/workflows/shared/tail.ts +2 -2
  44. package/src/workflows/shared/text.test.ts +58 -1
  45. package/src/workflows/shared/text.ts +39 -1
  46. package/src/workflows/shared/vars.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "43.0.0",
3
+ "version": "45.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -24,11 +24,11 @@ read them.
24
24
  its first line starts `H:`.
25
25
  - **Review notes**: what a human added to a `review` document since the reviewer
26
26
  wrote it — changed chunk prose, pointer notes, and new one-shot footnotes,
27
- never threads. **This is the hand-off from the `review` workflow to the `fix`
28
- workflow**: `review` leaves the annotated file beside the reviewer's own copy
27
+ never threads. **This is the hand-off from a `fix` review to the next `fix`**:
28
+ the review leaves the annotated file beside the reviewer's own copy
29
29
  (`.gtd/REVIEW.orig.md`), and `fix` reads its work from the notes between the
30
- two and from the risks, so a file `review` wrote must read the same in any
31
- `fix` that consumes it.
30
+ two and from the risks, so a hand-off file must read the same in any `fix`
31
+ that consumes it.
32
32
 
33
33
  ## What may change
34
34
 
@@ -199,8 +199,12 @@ const driveRound = async (
199
199
  /** Nothing but thread edits lands after the in-gate collecting turn. */
200
200
  settled?: boolean
201
201
  noDropped?: boolean
202
+ learn?: ReviewPolicy["learn"]
203
+ settles?: ReviewPolicy["settles"]
204
+ pending?: () => boolean
202
205
  } = {},
203
206
  ) => {
207
+ let reviews = 0
204
208
  const triaged: string[][] = []
205
209
  const captured: string[] = []
206
210
  const editRanges: (string | undefined)[] = []
@@ -211,9 +215,15 @@ const driveRound = async (
211
215
  file: "r.md",
212
216
  feedback: "f.md",
213
217
  isCode: (path: string) => path.endsWith(".ts"),
214
- reviewing: async () => void (state.files["r.md"] = before),
218
+ reviewing: async () => {
219
+ reviews++
220
+ state.files["r.md"] = before
221
+ },
215
222
  fixRisks: async () => {},
216
223
  keepGreen: async () => {},
224
+ learn: opts.learn,
225
+ settles: opts.settles,
226
+ pending: opts.pending,
217
227
  awaitReview: async () => {
218
228
  awaits++
219
229
  if (awaits > 2 || (awaits > 1 && opts.inGate === undefined)) throw new Error("stop")
@@ -270,7 +280,7 @@ const driveRound = async (
270
280
  },
271
281
  } as unknown as ReviewPolicy
272
282
  const result = await review("base", p).catch((e: Error) => e)
273
- return { result, triaged, dropped, captured, rounds, editRanges }
283
+ return { result, triaged, dropped, captured, rounds, editRanges, reviews }
274
284
  }
275
285
 
276
286
  describe("a one-shot footnote is a note, a thread footnote is not", () => {
@@ -561,6 +571,74 @@ describe("a note's answer boxes", () => {
561
571
  })
562
572
  })
563
573
 
574
+ describe("learning from the answers", () => {
575
+ const boxes = (wont: string, own = "_your answer_"): string =>
576
+ doc(
577
+ POINTER,
578
+ "",
579
+ "## Rounding",
580
+ "",
581
+ "Should subtract round?",
582
+ "",
583
+ "Tick exactly one:",
584
+ "",
585
+ "- [ ] fix as suggested",
586
+ `- [${wont}] won't fix`,
587
+ `- [${own === "_your answer_" ? " " : "x"}] ${own}`,
588
+ )
589
+
590
+ it("learn reads every answer once it is marked, before any is routed", async () => {
591
+ const seen: ReviewDecision[][] = []
592
+ let marked: string[] = []
593
+ const r = await driveRound(boxes(" "), boxes("x"), {
594
+ learn: async (ds) => {
595
+ seen.push([...ds])
596
+ marked = [...state.marks]
597
+ },
598
+ })
599
+ expect(seen).toEqual([[expect.objectContaining({ anchor: "Rounding", choice: "wontfix" })]])
600
+ expect(marked).toEqual(["wontfix=Rounding"])
601
+ expect(r.result).toEqual({ verdict: "signoff" })
602
+ })
603
+
604
+ it("an answer the policy settles is learned from, never marked, routed or collected", async () => {
605
+ const seen: ReviewDecision[][] = []
606
+ const r = await driveRound(boxes(" "), boxes("x"), {
607
+ settles: () => true,
608
+ learn: async (ds) => void seen.push([...ds]),
609
+ })
610
+ expect(seen).toHaveLength(1)
611
+ expect(state.marks).toEqual([])
612
+ expect(r.triaged).toEqual([])
613
+ expect(r.rounds).toEqual([])
614
+ expect(r.result).toEqual({ verdict: "signoff" })
615
+ })
616
+
617
+ it("a hand-edit round learns from its answers too", async () => {
618
+ const seen: ReviewDecision[][] = []
619
+ await driveRound(boxes(" "), boxes("x"), {
620
+ handEdit: true,
621
+ learn: async (ds) => void seen.push([...ds]),
622
+ })
623
+ expect(seen).toHaveLength(1)
624
+ })
625
+
626
+ it("learn is not asked when no note was answered", async () => {
627
+ let asked = false
628
+ await driveRound(boxes(" "), `${boxes(" ")}\n\nstray prose`, {
629
+ silent: true,
630
+ learn: async () => void (asked = true),
631
+ })
632
+ expect(asked).toBe(false)
633
+ })
634
+
635
+ it("a sign-off while something is pending writes the review again", async () => {
636
+ const r = await driveRound(boxes(" "), boxes("x"), { pending: () => true })
637
+ expect(r.reviews).toBe(2)
638
+ expect((r.result as Error).message).toBe("stop")
639
+ })
640
+ })
641
+
564
642
  describe("deleted answer boxes", () => {
565
643
  it("are refused even when a reply turn landed them", async () => {
566
644
  const withBoxes = doc(
@@ -70,6 +70,12 @@ export interface ReviewPolicy {
70
70
  readonly fixNits: (notes: readonly ReviewNote[]) => Promise<void>
71
71
  /** Told every note the human answered this round, before any of them is routed. */
72
72
  readonly decided?: (decisions: readonly ReviewDecision[]) => void
73
+ /** An answer the policy settles in `learn` itself: never routed, recorded or told to `decided`. */
74
+ readonly settles?: (decision: ReviewDecision) => boolean
75
+ /** Reads every answer of the round once it is recorded, before any is routed. */
76
+ readonly learn?: (decisions: readonly ReviewDecision[]) => Promise<void>
77
+ /** Something still waits on the human's answer: a sign-off writes the review again instead. */
78
+ readonly pending?: () => boolean
73
79
  /** Remove `file` at the end of a round at commit `round`. */
74
80
  readonly close: (round: string) => Promise<void>
75
81
  /** What a collecting turn reads. */
@@ -222,6 +228,19 @@ const record = (p: ReviewPolicy, decisions: readonly ReviewDecision[]): void =>
222
228
  for (const d of decisions) if (d.choice === "wontfix") mark("wontfix", oneLine(d.anchor))
223
229
  }
224
230
 
231
+ const routed = (p: ReviewPolicy, answers: readonly ReviewDecision[]): readonly ReviewDecision[] =>
232
+ answers.filter((d) => p.settles?.(d) !== true)
233
+
234
+ /** Record the routed answers on the gate's commit, then hand every answer to `learn`. */
235
+ const recordAndLearn = async (
236
+ p: ReviewPolicy,
237
+ decisions: readonly ReviewDecision[],
238
+ answers: readonly ReviewDecision[],
239
+ ): Promise<void> => {
240
+ record(p, decisions)
241
+ if (answers.length > 0 && p.learn !== undefined) await p.learn(answers)
242
+ }
243
+
225
244
  /**
226
245
  * Judge every note, then run each verdict's route: answers, then nit fixes,
227
246
  * then the edits' lap. Of the human's `decisions`, a fix joins the nit batch
@@ -233,8 +252,9 @@ export const routeNotes = async (
233
252
  notes: readonly ReviewNote[],
234
253
  r: Round,
235
254
  decisions: readonly ReviewDecision[] = [],
255
+ answers: readonly ReviewDecision[] = decisions,
236
256
  ): Promise<Finish> => {
237
- record(p, decisions)
257
+ await recordAndLearn(p, decisions, answers)
238
258
  const of = (c: ReviewDecision["choice"]) => decisions.filter((d) => d.choice === c)
239
259
  const fixes = of("fix").map((d, i) => decisionNote(d, `fix-${i + 1}`, d.body))
240
260
  const judged = [
@@ -313,14 +333,14 @@ const settledAfter = (
313
333
 
314
334
  const finishNotes = async (
315
335
  p: ReviewPolicy,
316
- before: string | undefined,
317
- decisions: readonly ReviewDecision[],
336
+ notes: readonly ReviewNote[],
337
+ answers: readonly ReviewDecision[],
318
338
  reopened: boolean,
319
339
  ctx: Parameters<typeof routeNotes>[2],
320
340
  ): Promise<Finish> => {
321
- // An unchanged review file has no baseline entry, which would read as all-new.
322
- const notes = reviewNotes(before ?? "", read(p.file) ?? "")
323
- if (notes.length + decisions.length > 0) return routeNotes(p, notes, ctx, decisions)
341
+ // An answer the policy settles still routes, if only to learn: there is nothing to collect.
342
+ if (notes.length + answers.length > 0)
343
+ return routeNotes(p, notes, ctx, routed(p, answers), answers)
324
344
  if (reopened) return ctx.unfolded()
325
345
  await ctx.close()
326
346
  return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"))
@@ -343,6 +363,12 @@ const decisionsSince = (
343
363
  return reviewDecisions(since.get(p.file)?.before ?? after, after)
344
364
  }
345
365
 
366
+ // An unchanged review file has no baseline entry, which would read as all-new.
367
+ const notesSince = (
368
+ p: ReviewPolicy,
369
+ since: ReturnType<typeof changesSince>,
370
+ ): readonly ReviewNote[] => reviewNotes(since.get(p.file)?.before ?? "", read(p.file) ?? "")
371
+
346
372
  const finish = async (
347
373
  p: ReviewPolicy,
348
374
  reviewed: string,
@@ -368,12 +394,14 @@ const finish = async (
368
394
  // Thread-only edits settled the round, but a change note or hand-edit since
369
395
  // `reviewed` must still reach a collecting turn when a dropped edit is refused.
370
396
  const reopened = isReopened(p, { settled, folded })
371
- const decisions = decisionsSince(p, since)
372
- if ((settled && !reopened && decisions.length === 0) || (edited.length === 0 && !noted))
397
+ const answers = decisionsSince(p, since)
398
+ if ((settled && !reopened && answers.length === 0) || (edited.length === 0 && !noted))
373
399
  return unfolded()
400
+ // Read before `learn`, which may add to the review file.
401
+ const notes = notesSince(p, since)
374
402
  if (edited.length > 0) {
375
403
  // A hand-edit plans a lap; its collecting turn reads the answers in the review.
376
- record(p, decisions)
404
+ await recordAndLearn(p, routed(p, answers), answers)
377
405
  await close()
378
406
  return outcome(
379
407
  await collect(p, p.captures.edits(round, reopened ? reviewed : undefined), "edits", {
@@ -382,7 +410,7 @@ const finish = async (
382
410
  }),
383
411
  )
384
412
  }
385
- return finishNotes(p, since.get(p.file)?.before, decisions, reopened, {
413
+ return finishNotes(p, notes, answers, reopened, {
386
414
  round,
387
415
  close,
388
416
  outcome,
@@ -390,6 +418,12 @@ const finish = async (
390
418
  })
391
419
  }
392
420
 
421
+ /** A sign-off while something waits on the human writes the review again instead. */
422
+ const unlessPending = (p: ReviewPolicy, result: Finish): Finish =>
423
+ "verdict" in result && result.verdict === "signoff" && p.pending?.() === true
424
+ ? { next: "rereview", carry: undefined }
425
+ : result
426
+
393
427
  /** Write the review; if it marks risks, fix them, keep green, and write it again — once, so the re-review's own risks reach the human unfixed. */
394
428
  const reviewOnce = async (
395
429
  p: Pick<ReviewPolicy, "file" | "reviewing" | "fixRisks" | "keepGreen">,
@@ -420,7 +454,7 @@ export const review = async (base: string, p: ReviewPolicy): Promise<ReviewOutco
420
454
  for (;;) {
421
455
  collectedAt = await converse(p, base, reviewed, collectedAt)
422
456
  if (collectedAt === "missing") break
423
- const result = await finish(p, reviewed, collectedAt)
457
+ const result = unlessPending(p, await finish(p, reviewed, collectedAt))
424
458
  if (!("next" in result)) return result
425
459
  if (result.next === "rereview") {
426
460
  carry = result.carry
@@ -19,7 +19,7 @@ import {
19
19
  type CriteriaSlice,
20
20
  } from "./criteria.js"
21
21
  import { isTestFile } from "./refactor.js"
22
- import { isCode } from "./review.js"
22
+ import { isCode } from "../shared/review.js"
23
23
 
24
24
  /** Below this the judge's `yes` is not trusted: a wrongly covered ID ends the feature without its test. */
25
25
  const COVERED_MIN_P = 0.7
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
2
2
  import { runsItsScript, testRepo } from "../../testkit/index.js"
3
3
  import { CONFIG_SCAFFOLD } from "../../install/index.js"
4
4
  import { reviewDoc } from "../shared/config.fixture.js"
5
+ import type * as featureEntry from "./index.js"
5
6
 
6
7
  describe("an ordinary feature start", () => {
7
8
  it("unwinds a captured TODO, checks the baseline and starts design from it", async () => {
@@ -363,3 +364,10 @@ describe("flagMode", () => {
363
364
  expect(repo.subjects()).toEqual(["chore: initial commit"])
364
365
  })
365
366
  })
367
+
368
+ // pins the public re-exports from the feature entry point
369
+ export type _PublicBuildTailOptions = [
370
+ featureEntry.BuildTailOptions,
371
+ featureEntry.FeedbackTarget,
372
+ featureEntry.ReviewRange,
373
+ ]
@@ -26,7 +26,7 @@ import {
26
26
  type StatedCriteria,
27
27
  } from "./criteria.js"
28
28
  import { design } from "./planning.js"
29
- import { buildTail, isCode, reUnwind, review, type ReviewOutcome } from "./review.js"
29
+ import { buildTail, humanReview, isCode, reUnwind, type ReviewOutcome } from "../shared/review.js"
30
30
  import { implement, implementing, seamBuild } from "./seam.js"
31
31
  import { sliceCap, sliceCriteria, slicing } from "./slicing.js"
32
32
  import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
@@ -153,7 +153,7 @@ const settleQuality = async (): Promise<void> => {
153
153
 
154
154
  /** The triaged quality lap, then the human review since `base`. */
155
155
  const reviewedTail = (base: string, lensBase: string): Promise<ReviewOutcome> =>
156
- tail((escalations, report) => review(base, escalations, { report }), {
156
+ tail((escalations, report) => humanReview(base, escalations, { report }), {
157
157
  green: true,
158
158
  lensBase,
159
159
  triage: true,
@@ -11,7 +11,7 @@ export {
11
11
  type BuildTailOptions,
12
12
  type FeedbackTarget,
13
13
  type ReviewOutcome,
14
- } from "./review.js"
14
+ } from "../shared/review.js"
15
15
  export type { ReviewRange } from "../shared/text.js"
16
16
  export * from "./feature.js"
17
17
  export * from "../shared/reserved.js"
@@ -20,7 +20,7 @@ import { healthy, type Guard } from "../shared/health.js"
20
20
  import { coder, FEEDBACK, HANDOFF, REFACTOR } from "../shared/steps.js"
21
21
  import * as t from "../shared/text.js"
22
22
  import { defaults, testFileGlobs } from "../shared/vars.js"
23
- import { buildTail, reUnwind, type FeedbackTarget } from "./review.js"
23
+ import { buildTail, reUnwind, type FeedbackTarget } from "../shared/review.js"
24
24
  import { refactorCollecting } from "./steps.js"
25
25
 
26
26
  export const REFACTOR_PACKAGE = REFACTOR
@@ -114,12 +114,16 @@ Feature: every note in the review is answered with one tick
114
114
  Then landing is refused with "answer-completeness"
115
115
 
116
116
  Scenario: won't fix drops the note, records it, and signs off
117
+ Given judge step "build.review.standards.judge" answers:
118
+ """
119
+ [{"id":"standard-1","answer":"no","p":0.9}]
120
+ """
117
121
  When the process is driven
118
122
  And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
119
123
  And gtd lands
120
124
  And the process is driven
121
125
  Then the process rests at "idle"
122
- And the commit "gtd(human): build.review.await-review → build.review.closing" has a body containing "Gtd-Mark: wontfix=AC-1 ignores floating point"
126
+ And the commit "gtd(human): build.review.await-review → build.review.standards.judge" has a body containing "Gtd-Mark: wontfix=AC-1 ignores floating point"
123
127
  And ".gtd/TODO.md" does not exist
124
128
  And no commit body contains "build.review.triage"
125
129
 
@@ -145,7 +149,11 @@ Feature: every note in the review is answered with one tick
145
149
  Then the process rests at "idle"
146
150
 
147
151
  Scenario: an own answer is judged like a note
148
- Given judge step "build.review.triage" answers:
152
+ Given judge step "build.review.standards.judge" answers:
153
+ """
154
+ [{"id":"standard-1","answer":"no","p":0.9}]
155
+ """
156
+ And judge step "build.review.triage" answers:
149
157
  """
150
158
  [{"id":"answer-1","answer":"nit","p":0.9}]
151
159
  """
@@ -163,7 +171,8 @@ Feature: every note in the review is answered with one tick
163
171
  Then the process rests at "build.review.await-review"
164
172
  And the commit subjects end with:
165
173
  """
166
- gtd(human): build.review.await-review → build.review.triage
174
+ gtd(human): build.review.await-review → build.review.standards.judge
175
+ gtd(judge): build.review.standards.judge → build.review.triage
167
176
  gtd(judge): build.review.triage → build.review.fix.nits.fixing
168
177
  gtd(agent): build.review.fix.nits.fixing → build.health.check
169
178
  gtd(check): build.health.check → build.review.closing
@@ -45,7 +45,7 @@ import {
45
45
  refactorStrays,
46
46
  reviewedRefactor,
47
47
  } from "./refactor.js"
48
- import { isCode } from "./review.js"
48
+ import { isCode } from "../shared/review.js"
49
49
  import { flagModeOn } from "../shared/options.js"
50
50
  import type { RiskTier } from "./tier.js"
51
51
 
@@ -0,0 +1,286 @@
1
+ @inmem
2
+ Feature: the review learns standards from the human's answers
3
+
4
+ A won't-fix tick or an own answer at the review gate is a candidate
5
+ standard. A judge asks whether it is reusable; above `standardMinP` it is
6
+ kept in `.gtd/STANDARDS.md` with no further question, and every later agent
7
+ turn — lenses and coder alike — is told to follow it. One that contradicts
8
+ a standard already kept becomes a note in `.gtd/REVIEW.md` the review cannot
9
+ sign off without: keep the new one, keep the old one, or write the merged
10
+ wording.
11
+
12
+ Background:
13
+ Given the shipped workflows, as gtd install scaffolds them
14
+ And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
15
+ And an environment variable "GTD_FLAGMODE" set to "false"
16
+ And a file "src/calc.ts" with:
17
+ """
18
+ export const add = (a: number, b: number) => a + b
19
+ """
20
+ And the working tree is committed as "feat: add calculator"
21
+ And a file "TODO.md" with:
22
+ """
23
+ # Add subtract
24
+
25
+ subtract(a, b) returns the difference.
26
+ """
27
+ And step "unwind" changes nothing
28
+ And step "start-gate.check" changes nothing
29
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
30
+ """
31
+ # Requirements
32
+
33
+ ## Concerns
34
+
35
+ ### Subtract
36
+
37
+ - `subtract(a, b)` returns `a - b`.
38
+
39
+ ## Acceptance Criteria
40
+
41
+ - AC-1: subtract(a, b) returns a - b.
42
+ """
43
+ And step "design.gate.answer" changes nothing
44
+ And step "seam.naive" writes "src/calc.test.ts" with:
45
+ """
46
+ it("[AC-1] subtracts", () => {})
47
+ """
48
+ And step "seam.naive" writes "src/calc.ts" with:
49
+ """
50
+ export const add = (a: number, b: number) => a + b
51
+ export const subtract = (a: number, b: number) => a - b
52
+ """
53
+ And step "seam.check" changes nothing
54
+ And step "implement.applying" runs its script
55
+ And step "implement.health.check" changes nothing
56
+ And step "implement.closing" runs its script
57
+ And judge step "implement.coverage" answers:
58
+ """
59
+ [{"id":"AC-1","answer":"yes","p":0.9}]
60
+ """
61
+ And step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
62
+ """
63
+ # Quality
64
+
65
+ ## subtract has no class
66
+
67
+ Calculators read better as classes.
68
+ """
69
+ And step "build.quality.sorting" runs its script
70
+ And step "build.review.composing" runs its script
71
+ And judge step "build.quality.triage" answers:
72
+ """
73
+ [{"id":"Q1","answer":"design","p":0.9}]
74
+ """
75
+ And step "build.review.closing" runs its script
76
+ And step "build.review.standards.recording" runs its script
77
+ And step "build.review.standards.raising" runs its script
78
+
79
+ Scenario: a won't-fix the judge calls reusable is kept, and the review signs off
80
+ Given judge step "build.review.standards.judge" answers:
81
+ """
82
+ [{"id":"standard-1","answer":"yes","p":0.9}]
83
+ """
84
+ When the process is driven
85
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
86
+ And gtd lands
87
+ And the process is driven
88
+ Then the process rests at "idle"
89
+ And ".gtd/STANDARDS.md" contains:
90
+ """
91
+ - Won't fix: subtract has no class — Calculators read better as classes.
92
+ """
93
+ And the commit subjects end with:
94
+ """
95
+ gtd(human): build.review.await-review → build.review.standards.judge
96
+ gtd(judge): build.review.standards.judge → build.review.standards.recording
97
+ gtd(check): build.review.standards.recording → build.review.closing
98
+ gtd(check): build.review.closing → idle
99
+ """
100
+
101
+ Scenario: the next review lists the standards added
102
+ Given judge step "build.review.standards.judge" answers:
103
+ """
104
+ [{"id":"standard-1","answer":"yes","p":0.9}]
105
+ """
106
+ And judge step "build.review.triage" answers:
107
+ """
108
+ [{"id":"answer-1","answer":"nit","p":0.9}]
109
+ """
110
+ And step "build.review.fix.nits.fixing" writes "src/calc.ts" with:
111
+ """
112
+ export function add(a: number, b: number) { return a + b }
113
+ export function subtract(a: number, b: number) { return a - b }
114
+ """
115
+ And step "build.health.check" changes nothing
116
+ When the process is driven
117
+ And the text "- [ ] _your answer_" in ".gtd/REVIEW.md" is replaced with "- [x] Calculators are plain functions."
118
+ And gtd lands
119
+ And the process is driven
120
+ Then the process rests at "build.review.await-review"
121
+ And ".gtd/REVIEW.md" contains:
122
+ """
123
+ ## Standards added
124
+
125
+ Learned from your answers and kept in `.gtd/STANDARDS.md`:
126
+
127
+ - Calculators are plain functions. (re: subtract has no class)
128
+ """
129
+
130
+ Scenario: an answer below the threshold is not kept
131
+ Given judge step "build.review.standards.judge" answers:
132
+ """
133
+ [{"id":"standard-1","answer":"yes","p":0.6}]
134
+ """
135
+ When the process is driven
136
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
137
+ And gtd lands
138
+ And the process is driven
139
+ Then the process rests at "idle"
140
+ And ".gtd/STANDARDS.md" does not exist
141
+
142
+ Scenario: a kept standard reaches the lenses and the coder of a later process
143
+ Given a file ".gtd/STANDARDS.md" with:
144
+ """
145
+ # Standards
146
+
147
+ - Won't fix: subtract has no class — Calculators read better as classes.
148
+ """
149
+ And the working tree is committed as "chore: keep a standard"
150
+ And a file "TODO.md" with:
151
+ """
152
+ # Add subtract
153
+
154
+ Please add subtract(a, b).
155
+ """
156
+ And step "seam.naive" is prompted with "- Won't fix: subtract has no class"
157
+ And step "build.quality.correctness.reviewing" is prompted with "- Won't fix: subtract has no class"
158
+ When the process is driven
159
+ Then the process rests at "build.review.await-review"
160
+
161
+ Scenario: a contradicting standard blocks sign-off until the human picks one
162
+ Given a file ".gtd/STANDARDS.md" with:
163
+ """
164
+ # Standards
165
+
166
+ - Calculators are classes.
167
+ """
168
+ And the working tree is committed as "chore: keep a standard"
169
+ And a file "TODO.md" with:
170
+ """
171
+ # Add subtract
172
+
173
+ Please add subtract(a, b).
174
+ """
175
+ And judge step "build.review.standards.judge" answers:
176
+ """
177
+ [{"id":"standard-1","answer":"yes","p":0.9},{"id":"standard-1.contradicts-1","answer":"yes","p":0.8}]
178
+ """
179
+ And judge step "build.review.triage" answers:
180
+ """
181
+ [{"id":"answer-1","answer":"praise","p":0.9}]
182
+ """
183
+ When the process is driven
184
+ And the text "- [ ] _your answer_" in ".gtd/REVIEW.md" is replaced with "- [x] Calculators are plain functions."
185
+ And gtd lands
186
+ And the process is driven
187
+ Then the process rests at "build.review.await-review"
188
+ And ".gtd/STANDARDS.md" contains:
189
+ """
190
+ - Calculators are classes.
191
+
192
+ ## Undecided
193
+
194
+ - Calculators are plain functions. (re: subtract has no class)
195
+ - contradicts: Calculators are classes.
196
+ """
197
+ And ".gtd/REVIEW.md" contains "## Standard conflict "
198
+ And ".gtd/REVIEW.md" contains:
199
+ """
200
+ New: Calculators are plain functions. (re: subtract has no class)
201
+
202
+ Old:
203
+
204
+ - Calculators are classes.
205
+
206
+ Tick exactly one:
207
+
208
+ - [ ] keep the new one (drop the old)
209
+ - [ ] keep the old one (discard the new)
210
+ - [ ] _your answer_
211
+ """
212
+ And ".gtd/REVIEW.md" does not contain "## subtract has no class"
213
+ And landing is refused with "answer-completeness: 1 note(s) in .gtd/REVIEW.md not answered"
214
+ When the text "- [ ] keep the new one (drop the old)" in ".gtd/REVIEW.md" is replaced with "- [x] keep the new one (drop the old)"
215
+ And gtd lands
216
+ And the process is driven
217
+ Then the process rests at "idle"
218
+ And ".gtd/STANDARDS.md" contains "- Calculators are plain functions. (re: subtract has no class)"
219
+ And ".gtd/STANDARDS.md" does not contain "Calculators are classes."
220
+ And ".gtd/STANDARDS.md" does not contain "Undecided"
221
+
222
+ Scenario: keeping the old standard discards the new one
223
+ Given a file ".gtd/STANDARDS.md" with:
224
+ """
225
+ # Standards
226
+
227
+ - Calculators are classes.
228
+ """
229
+ And the working tree is committed as "chore: keep a standard"
230
+ And a file "TODO.md" with:
231
+ """
232
+ # Add subtract
233
+
234
+ Please add subtract(a, b).
235
+ """
236
+ And judge step "build.review.standards.judge" answers:
237
+ """
238
+ [{"id":"standard-1","answer":"yes","p":0.9},{"id":"standard-1.contradicts-1","answer":"yes","p":0.8}]
239
+ """
240
+ When the process is driven
241
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
242
+ And gtd lands
243
+ And the process is driven
244
+ And the text "- [ ] keep the old one (discard the new)" in ".gtd/REVIEW.md" is replaced with "- [x] keep the old one (discard the new)"
245
+ And gtd lands
246
+ And the process is driven
247
+ Then the process rests at "idle"
248
+ And ".gtd/STANDARDS.md" contains:
249
+ """
250
+ # Standards
251
+
252
+ Kept from your review answers for every agent turn. One standard per bullet.
253
+
254
+ - Calculators are classes.
255
+ """
256
+ And ".gtd/STANDARDS.md" does not contain "Won't fix"
257
+
258
+ Scenario: an own answer to a conflict replaces both with its wording
259
+ Given a file ".gtd/STANDARDS.md" with:
260
+ """
261
+ # Standards
262
+
263
+ - Calculators are classes.
264
+ """
265
+ And the working tree is committed as "chore: keep a standard"
266
+ And a file "TODO.md" with:
267
+ """
268
+ # Add subtract
269
+
270
+ Please add subtract(a, b).
271
+ """
272
+ And judge step "build.review.standards.judge" answers:
273
+ """
274
+ [{"id":"standard-1","answer":"yes","p":0.9},{"id":"standard-1.contradicts-1","answer":"yes","p":0.8}]
275
+ """
276
+ When the process is driven
277
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
278
+ And gtd lands
279
+ And the process is driven
280
+ And the text "- [ ] _your answer_" in ".gtd/REVIEW.md" is replaced with "- [x] Calculators with state are classes."
281
+ And gtd lands
282
+ And the process is driven
283
+ Then the process rests at "idle"
284
+ And ".gtd/STANDARDS.md" contains "- Calculators with state are classes."
285
+ And ".gtd/STANDARDS.md" does not contain "- Calculators are classes."
286
+ And ".gtd/STANDARDS.md" does not contain "Won't fix"