@pmelab/gtd 28.2.0 → 29.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -4
  3. package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
  4. package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
  5. package/dist/testkit.mjs +2 -1
  6. package/dist/workflow-kit.mjs +51 -21
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +247 -96
  9. package/dist/workflows/fix.mjs +2 -2
  10. package/dist/workflows/review.mjs +2 -2
  11. package/dist/workflows/rollout.mjs +1 -1
  12. package/dist/workflows.mjs +528 -262
  13. package/package.json +1 -1
  14. package/src/testkit/TestRepo.ts +3 -0
  15. package/src/testkit/index.ts +1 -1
  16. package/src/testkit/steps.ts +44 -0
  17. package/src/workflow-kit/review.test.ts +366 -8
  18. package/src/workflow-kit/review.ts +109 -26
  19. package/src/workflows/feature/feature.test.ts +313 -1
  20. package/src/workflows/feature/feature.ts +48 -43
  21. package/src/workflows/feature/one-step-land.feature +4 -0
  22. package/src/workflows/feature/refactor-first.feature +54 -24
  23. package/src/workflows/feature/refactor-review.feature +1706 -0
  24. package/src/workflows/feature/refactor.test.ts +592 -2
  25. package/src/workflows/feature/refactor.ts +160 -42
  26. package/src/workflows/feature/review.test.ts +322 -5
  27. package/src/workflows/feature/review.ts +77 -10
  28. package/src/workflows/feature/seam-rejection.feature +4 -0
  29. package/src/workflows/feature/seam.feature +3 -4
  30. package/src/workflows/feature/seam.test.ts +196 -1
  31. package/src/workflows/feature/seam.ts +23 -9
  32. package/src/workflows/feature/steps.test.ts +54 -2
  33. package/src/workflows/feature/steps.ts +45 -9
  34. package/src/workflows/feature/tier.test.ts +4 -0
  35. package/src/workflows/feature/tier.ts +2 -20
  36. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
  37. package/src/workflows/shared/access.test.ts +31 -1
  38. package/src/workflows/shared/access.ts +7 -1
  39. package/src/workflows/shared/fences.ts +19 -0
  40. package/src/workflows/shared/health.ts +32 -12
  41. package/src/workflows/shared/options.test.ts +18 -0
  42. package/src/workflows/shared/options.ts +1 -0
  43. package/src/workflows/shared/prose.ts +4 -2
  44. package/src/workflows/shared/skills.test.ts +20 -1
  45. package/src/workflows/shared/skills.ts +43 -32
  46. package/src/workflows/shared/steps.ts +33 -19
  47. package/src/workflows/shared/tail.test.ts +84 -1
  48. package/src/workflows/shared/tail.ts +30 -12
  49. package/src/workflows/shared/text.test.ts +266 -3
  50. package/src/workflows/shared/text.ts +183 -69
  51. package/src/workflows/shared/vars.ts +12 -2
@@ -7,7 +7,16 @@ import {
7
7
  type StepRequest,
8
8
  } from "../../flows/index.js"
9
9
  import { fixtureContext, type TextContext } from "../shared/text.fixture.js"
10
- import { refactorFirst, requireNoNewFlags, requireWithin, REFACTOR_PACKAGE } from "./refactor.js"
10
+ import { globMatches } from "../../wire/index.js"
11
+ import {
12
+ refactorFirst,
13
+ refactorStrays,
14
+ requireNoNewFlags,
15
+ requireNoTestFiles,
16
+ requireWithin,
17
+ reviewedRefactor,
18
+ REFACTOR_PACKAGE,
19
+ } from "./refactor.js"
11
20
 
12
21
  afterEach(() => installContext(undefined))
13
22
 
@@ -34,6 +43,7 @@ const withChanges = (list: readonly Change[], fn: () => void): string | undefine
34
43
  {
35
44
  changes: () => list,
36
45
  changesSince: () => list,
46
+ matches: globMatches,
37
47
  refuse: (message) => {
38
48
  throw new Error(message)
39
49
  },
@@ -62,6 +72,79 @@ describe("requireWithin", () => {
62
72
  })
63
73
  })
64
74
 
75
+ describe("requireNoTestFiles", () => {
76
+ it("refuses a changed test path, naming it, even when it is a stray", () => {
77
+ const list = [
78
+ change("src/a.test.ts", "a", "b"),
79
+ change("tests/unit/b.ts", undefined, "x", "added"),
80
+ change("src/__tests__/c.ts", "a", undefined, "deleted"),
81
+ change("features/d.feature", "a", "b"),
82
+ ]
83
+ const message = withChanges(list, () => requireNoTestFiles())
84
+ expect(message).toMatch(/^gtd land: refactor-tests: /)
85
+ for (const path of [
86
+ "src/a.test.ts",
87
+ "tests/unit/b.ts",
88
+ "src/__tests__/c.ts",
89
+ "features/d.feature",
90
+ ])
91
+ expect(message).toContain(` - ${path}`)
92
+ })
93
+
94
+ it("passes non-test paths and .gtd/ paths", () => {
95
+ const list = [
96
+ change("src/a.ts", "a", "b"),
97
+ change(".gtd/tests/x.md", "", "x", "added"),
98
+ change(".gtd/REFACTOR.md", "", "x", "added"),
99
+ ]
100
+ expect(withChanges(list, () => requireNoTestFiles())).toBeUndefined()
101
+ })
102
+
103
+ it("names only the test paths", () => {
104
+ const message = withChanges(
105
+ [change("src/a.ts", "a", "b"), change("src/a.spec.ts", "a", "b")],
106
+ () => requireNoTestFiles(),
107
+ )
108
+ expect(message).toContain("src/a.spec.ts")
109
+ expect(message).not.toContain("src/a.ts")
110
+ })
111
+
112
+ it("is not narrowed by a narrowed seamAllow", () => {
113
+ installContext(
114
+ fixtureContext(
115
+ { vars: { seamAllow: "docs/**" } },
116
+ {
117
+ changes: () => [change("src/a.test.ts", "a", "b")],
118
+ matches: globMatches,
119
+ refuse: (message) => {
120
+ throw new Error(message)
121
+ },
122
+ },
123
+ ),
124
+ )
125
+ expect(refusal(() => requireNoTestFiles())).toContain("src/a.test.ts")
126
+ })
127
+
128
+ it("does not refuse other naming such as _test.go or test_*.py", () => {
129
+ const list = [change("pkg/a_test.go", "a", "b"), change("pkg/test_a.py", "a", "b")]
130
+ expect(withChanges(list, () => requireNoTestFiles())).toBeUndefined()
131
+ })
132
+ })
133
+
134
+ describe("refactorStrays", () => {
135
+ it("refactorStrays drops test files, keeps the rest and ignores a narrowed seamAllow", () => {
136
+ installContext(fixtureContext({ vars: { seamAllow: "docs/**" } }, { matches: globMatches }))
137
+ expect(
138
+ refactorStrays({
139
+ choice: "refactor-first",
140
+ strays: ["src/a.ts", "src/a.test.ts"],
141
+ unreadable: [],
142
+ judgment: undefined,
143
+ }),
144
+ ).toEqual(["src/a.ts"])
145
+ })
146
+ })
147
+
65
148
  describe("requireNoNewFlags", () => {
66
149
  const pattern = "\\bGTD_FLAG_\\w+"
67
150
 
@@ -238,7 +321,7 @@ const drive = async (
238
321
  if (phase in turn) return turn[phase] ?? []
239
322
  return checks <= reds ? [change(".gtd/FEEDBACK.md", undefined, "red", "added")] : []
240
323
  },
241
- matches: (path, pattern) => path === pattern,
324
+ matches: globMatches,
242
325
  head: () => "c0ffee0",
243
326
  }),
244
327
  )
@@ -263,6 +346,22 @@ describe("refactorFirst", () => {
263
346
  expect(h.steps).toEqual([])
264
347
  })
265
348
 
349
+ it("resolves false with no step and no scope when every stray is a test file", async () => {
350
+ const h = await drive(shape({ strays: ["src/a.test.ts"] }))
351
+ expect(h.result).toBe(false)
352
+ expect(h.steps).toEqual([])
353
+ expect(h.scopes).toEqual([])
354
+ })
355
+
356
+ it("lists only the non-test strays in the package scope and write access", async () => {
357
+ const h = await drive(shape({ strays: ["src/a.ts", "src/a.test.ts"] }))
358
+ const derive = h.steps.find((s) => s.kind === "run" && s.name === "deriving")
359
+ const body = derive?.kind === "run" ? derive.body : ""
360
+ expect(body).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
361
+ expect(body).not.toContain("a.test.ts")
362
+ expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ".gtd/**"] })
363
+ })
364
+
266
365
  it("throws before any step when a stray holds a flag read the pattern matches zero-length", async () => {
267
366
  const h = await drive(shape(), {
268
367
  text: {
@@ -340,6 +439,21 @@ describe("refactorFirst", () => {
340
439
  expect(names(h)).not.toContain("health.check")
341
440
  })
342
441
 
442
+ it("refuses a build turn changing a test file even when it is a stray", async () => {
443
+ const h = await drive(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
444
+ build: [change("src/a.test.ts", "a", "b")],
445
+ })
446
+ expect((h.result as Error).message).toContain("refactor-tests")
447
+ expect((h.result as Error).message).toContain("src/a.test.ts")
448
+ expect(names(h)).not.toContain("health.check")
449
+ })
450
+
451
+ it("says never a test file in the package rules", async () => {
452
+ const h = await drive(shape())
453
+ expect(bodyOf(h, 0)).toContain("never a test file")
454
+ expect(bodyOf(h, 0)).not.toContain("only when it is one of them")
455
+ })
456
+
343
457
  it("refuses a build turn adding a flag read", async () => {
344
458
  const h = await drive(shape(), { build: [change("src/a.ts", "x", "GTD_FLAG_NEW")] })
345
459
  expect((h.result as Error).message).toContain("GTD_FLAG_NEW")
@@ -464,3 +578,479 @@ describe("refactorFirst", () => {
464
578
  })
465
579
  })
466
580
  })
581
+
582
+ const REVIEW = ".gtd/REVIEW.md"
583
+ const QUALITY = ".gtd/QUALITY.md"
584
+
585
+ const reviewDoc = (note: string): string =>
586
+ [
587
+ "# Review: abc1234",
588
+ "",
589
+ "<!-- base: 0000000000000000000000000000000000000000 -->",
590
+ "",
591
+ "## calc",
592
+ "- [ ] ./src/a.ts#1-1",
593
+ note,
594
+ "",
595
+ ].join("\n")
596
+
597
+ interface Reviewed {
598
+ /** Full step names, scope-qualified like the live replay. */
599
+ readonly names: string[]
600
+ readonly steps: StepRequest[]
601
+ readonly scopes: ScopeOptions[]
602
+ readonly result: boolean | Error
603
+ }
604
+
605
+ interface ReviewedScript {
606
+ /** The note the human leaves at each `review.await-review`, in order; `undefined` signs off. */
607
+ readonly notes?: readonly (string | undefined)[]
608
+ readonly answers?: Readonly<Record<string, { answer: string; p: number }>>
609
+ /** Findings every quality lens leaves behind, until the fix turn removes them. */
610
+ readonly findings?: boolean
611
+ /** Red checks before green. */
612
+ readonly reds?: number
613
+ /** The turn's own changes by full step name. */
614
+ readonly turns?: Readonly<Record<string, readonly Change[]>>
615
+ readonly vars?: Readonly<Record<string, string>>
616
+ /** The collecting turn writes no package. */
617
+ readonly silentCollect?: boolean
618
+ readonly collected?: string
619
+ /** The note the first `review.reviewing` turn writes; later ones write `add`. */
620
+ readonly firstReviewNote?: string
621
+ }
622
+
623
+ /** Runs `reviewedRefactor` against a scripted context; a third await-review stops the run. */
624
+ const driveReviewed = async (
625
+ given: DiffShapeResult,
626
+ script: ReviewedScript = {},
627
+ ): Promise<Reviewed> => {
628
+ const names: string[] = []
629
+ const steps: StepRequest[] = []
630
+ const stack: string[] = []
631
+ const scopes: ScopeOptions[] = []
632
+ const files = new Map<string, string>()
633
+ let last = ""
634
+ let awaits = 0
635
+ let reviews = 0
636
+ let reds = script.reds ?? 0
637
+ let checking = false
638
+ let pending: boolean | Error = false
639
+ const record = (request: Exclude<StepRequest, { kind: "restart" }>): void => {
640
+ last = [...stack, request.name].join(".")
641
+ names.push(last)
642
+ steps.push(request)
643
+ // Red checks apply to the tail's own runs, never the refactor lap's.
644
+ checking = request.name === "health.check" && stack.includes("build")
645
+ if (checking && reds > 0) reds--
646
+ }
647
+ const lensEffects = ["correctness", "ponytail-review", "conventions"].map(
648
+ (lens): [string, () => void] => [
649
+ `quality.${lens}.reviewing`,
650
+ () => {
651
+ if (script.findings) files.set(QUALITY, "- finding")
652
+ },
653
+ ],
654
+ )
655
+ const effects: Record<string, () => void> = {
656
+ ...Object.fromEntries(lensEffects),
657
+ deriving: () => void files.set(REFACTOR_PACKAGE, "package"),
658
+ closing: () => void files.delete(REFACTOR_PACKAGE),
659
+ "review.reviewing": () => {
660
+ files.set(REVIEW, reviewDoc(reviews++ === 0 ? (script.firstReviewNote ?? "add") : "add"))
661
+ },
662
+ "review.closing": () => void files.delete(REVIEW),
663
+ "review.collecting": () => {
664
+ if (!script.silentCollect)
665
+ files.set(REFACTOR_PACKAGE, script.collected ?? "collected concern")
666
+ },
667
+ "fix.quality.fixing": () => void files.delete(QUALITY),
668
+ "review.await-review": () => {
669
+ const note = script.notes?.[awaits]
670
+ if (++awaits > (script.notes?.length ?? 1)) throw new Error("stop")
671
+ files.set(REVIEW, reviewDoc(note ?? "add"))
672
+ },
673
+ }
674
+ installContext(
675
+ fixtureContext(
676
+ { scope: "refactor", vars: script.vars ?? {} },
677
+ {
678
+ step: (request: StepRequest) => {
679
+ if (request.kind === "restart") return Promise.resolve()
680
+ record(request)
681
+ effects[request.name]?.()
682
+ return Promise.resolve(
683
+ request.kind === "judge" ? { answers: script.answers ?? {}, truncated: [] } : undefined,
684
+ )
685
+ },
686
+ refuse: (message) => {
687
+ throw new Error(message)
688
+ },
689
+ pushScope: (scope) => {
690
+ scopes.push(scope)
691
+ stack.push(scope.name ?? "")
692
+ },
693
+ popScope: () => void stack.pop(),
694
+ read: (path) => files.get(path),
695
+ changes: () =>
696
+ checking && reds > 0
697
+ ? [change(".gtd/FEEDBACK.md", undefined, "red", "added")]
698
+ : [...(script.turns?.[last] ?? [])],
699
+ changesSince: () => {
700
+ const after = files.get(REVIEW)
701
+ return after === reviewDoc("add")
702
+ ? []
703
+ : [{ path: REVIEW, status: "modified", before: reviewDoc("add"), after }]
704
+ },
705
+ matches: globMatches,
706
+ head: () => `c${names.length}`,
707
+ },
708
+ ),
709
+ )
710
+ try {
711
+ pending = await reviewedRefactor(given)
712
+ } catch (error) {
713
+ pending = error as Error
714
+ }
715
+ return { names, steps, scopes, result: pending }
716
+ }
717
+
718
+ describe("reviewedRefactor", () => {
719
+ it.each([
720
+ ["land", shape({ choice: "land" })],
721
+ ["reject", shape({ choice: "reject" })],
722
+ ["refactor-first with unreadable strays", shape({ unreadable: ["src/a.ts"] })],
723
+ ])("resolves false with no step for %s", async (_label, given) => {
724
+ const h = await driveReviewed(given)
725
+ expect(h.result).toBe(false)
726
+ expect(h.steps).toEqual([])
727
+ })
728
+
729
+ it("drops test-file strays from scope, access and the collecting fence", async () => {
730
+ const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
731
+ notes: ["add — rename it", undefined],
732
+ })
733
+ const derive = h.steps.find((s) => s.kind === "run" && s.name === "deriving")
734
+ const body = derive?.kind === "run" ? derive.body : ""
735
+ expect(body).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
736
+ expect(body).not.toContain("a.test.ts")
737
+ expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ".gtd/**"] })
738
+ const collecting = h.steps.find((s) => s.kind === "agent" && s.name === "review.collecting")
739
+ const prompt = collecting?.kind === "agent" ? collecting.prompt : ""
740
+ expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
741
+ expect(prompt).not.toContain("a.test.ts")
742
+ })
743
+
744
+ it("resolves false with no step and no refactor scope when every stray is a test file", async () => {
745
+ const h = await driveReviewed(shape({ strays: ["src/a.test.ts"] }))
746
+ expect(h.result).toBe(false)
747
+ expect(h.steps).toEqual([])
748
+ expect(h.scopes).toEqual([])
749
+ })
750
+
751
+ it("closes the package, then runs the tail as refactor.build.* and signs off with true", async () => {
752
+ const h = await driveReviewed(shape())
753
+ expect(h.result).toBe(true)
754
+ expect(h.names).toEqual([
755
+ "refactor.deriving",
756
+ "refactor.building",
757
+ "refactor.health.check",
758
+ "refactor.closing",
759
+ "refactor.build.quality.correctness.reviewing",
760
+ "refactor.build.quality.ponytail-review.reviewing",
761
+ "refactor.build.quality.conventions.reviewing",
762
+ "refactor.build.review.reviewing",
763
+ "refactor.build.review.await-review",
764
+ "refactor.build.review.closing",
765
+ ])
766
+ })
767
+
768
+ it("reviews from HEAD at scope entry", async () => {
769
+ const h = await driveReviewed(shape())
770
+ const reviewing = h.steps.find((s) => s.kind === "agent" && s.name === "review.reviewing")
771
+ expect(reviewing?.kind === "agent" ? reviewing.prompt : "").toContain("base: c0")
772
+ })
773
+
774
+ it("names the entry commit in every lens prompt, on the first lap and after a feedback lap", async () => {
775
+ const lensPrompts = (h: Reviewed) =>
776
+ h.steps.flatMap((s) =>
777
+ s.kind === "agent" && s.name === "quality.correctness.reviewing" ? [s.prompt] : [],
778
+ )
779
+ const first = lensPrompts(await driveReviewed(shape()))
780
+ expect(first).toHaveLength(1)
781
+ expect(first[0]).toContain("from `c0`")
782
+ const after = lensPrompts(
783
+ await driveReviewed(shape(), { notes: ["add — rename it", undefined] }),
784
+ )
785
+ expect(after).toHaveLength(2)
786
+ for (const prompt of after) expect(prompt).toContain("from `c0`")
787
+ })
788
+
789
+ it("takes its quality lenses from refactorQualityReviews, none when blank", async () => {
790
+ const one = await driveReviewed(shape(), { vars: { refactorQualityReviews: "correctness" } })
791
+ expect(one.names.filter((n) => n.includes(".quality."))).toEqual([
792
+ "refactor.build.quality.correctness.reviewing",
793
+ ])
794
+ const none = await driveReviewed(shape(), {
795
+ vars: { refactorQualityReviews: "", qualityReviews: "correctness" },
796
+ })
797
+ expect(none.names.filter((n) => n.includes(".quality."))).toEqual([])
798
+ })
799
+
800
+ describe("on review feedback", () => {
801
+ const run = () =>
802
+ driveReviewed(shape(), {
803
+ notes: ["add — rename it", undefined],
804
+ })
805
+
806
+ it("collects into the refactor package, re-unwinds, then derives and builds again, never a re-plan", async () => {
807
+ const h = await run()
808
+ const i = h.names.indexOf("refactor.build.review.collecting")
809
+ expect(i).toBeGreaterThan(-1)
810
+ expect(h.names.slice(i - 2, i + 6)).toEqual([
811
+ "refactor.build.review.triage",
812
+ "refactor.build.review.closing",
813
+ "refactor.build.review.collecting",
814
+ "refactor.re-unwind",
815
+ "refactor.deriving",
816
+ "refactor.building",
817
+ "refactor.health.check",
818
+ "refactor.closing",
819
+ ])
820
+ expect(h.names.some((n) => n.startsWith("design"))).toBe(false)
821
+ })
822
+
823
+ it("re-derives a package with the collected text under ## Review feedback and the strays fixed", async () => {
824
+ const h = await run()
825
+ const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
826
+ expect(derivings).toHaveLength(2)
827
+ const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
828
+ expect(body).toContain("Review feedback")
829
+ expect(body).toContain("collected concern")
830
+ expect(body).toContain("- `src/a.ts`") // gtd-path-exempt: fixture paths
831
+ expect(body).toContain("- `src/b.ts`") // gtd-path-exempt: fixture paths
832
+ const first = derivings[0]?.kind === "run" ? derivings[0].body : ""
833
+ expect(first).not.toContain("Review feedback")
834
+ })
835
+
836
+ it("builds with the feedback line on the second lap only", async () => {
837
+ const h = await run()
838
+ const builds = h.steps.filter((s) => s.kind === "agent" && s.name === "building")
839
+ const prompts = builds.map((s) => (s.kind === "agent" ? s.prompt : ""))
840
+ expect(prompts[0]).not.toContain("## Review feedback")
841
+ expect(prompts[1]).toContain("## Review feedback")
842
+ })
843
+
844
+ it("nests a collected ## Answered Questions as ### under ## Review feedback", async () => {
845
+ const h = await driveReviewed(shape(), {
846
+ notes: ["add — rename it", undefined],
847
+ collected: "## Rename\n\nrename it\n\n## Answered Questions\n\n### why?\n\nbecause",
848
+ })
849
+ const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
850
+ const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
851
+ expect(body).toContain("## Review feedback")
852
+ expect(body).toContain("### Rename")
853
+ expect(body).toContain("### Answered Questions")
854
+ expect(body).toContain("#### why?")
855
+ expect(body).not.toMatch(/^## Answered Questions/m)
856
+ })
857
+
858
+ it("nests a collected h1 under ## Review feedback and tracks the opening fence marker", async () => {
859
+ const h = await driveReviewed(shape(), {
860
+ notes: ["add — rename it", undefined],
861
+ collected: "# Top\n\n```\n~~~\n# code\n```\n\n## Sub",
862
+ })
863
+ const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
864
+ const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
865
+ expect(body).toContain("### Top")
866
+ expect(body).toContain("'# code'")
867
+ expect(body).toContain("#### Sub")
868
+ })
869
+
870
+ it("caps demoted headings at ###### and shifts the shallowest to ###", async () => {
871
+ const h = await driveReviewed(shape(), {
872
+ notes: ["add — rename it", undefined],
873
+ collected: "# Top\n\n##### Deep\n\n```\n# code\n```\n\nprose # not a heading",
874
+ })
875
+ const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
876
+ const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
877
+ expect(body).toContain("'### Top'")
878
+ expect(body).toContain("'###### Deep'")
879
+ expect(body).not.toContain("#######")
880
+ expect(body).toContain("'# code'")
881
+ expect(body).toContain("'prose # not a heading'")
882
+ })
883
+
884
+ it("keeps a heading fenced inside a four-backtick fence after an inner three-backtick line", async () => {
885
+ const h = await driveReviewed(shape(), {
886
+ notes: ["add — rename it", undefined],
887
+ collected: "## Top\n\n````\n```\n## code\n````\n\n## Sub",
888
+ })
889
+ const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
890
+ const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
891
+ expect(body).toContain("'## code'")
892
+ expect(body).not.toContain("'### code'")
893
+ expect(body).toContain("### Sub")
894
+ })
895
+
896
+ it("treats a three-backtick line indented four spaces as no fence, so the next heading is demoted", async () => {
897
+ const h = await driveReviewed(shape(), {
898
+ notes: ["add — rename it", undefined],
899
+ collected: "## Top\n\n ```\n\n## Sub",
900
+ })
901
+ const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
902
+ const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
903
+ expect(body).toContain("### Sub")
904
+ })
905
+
906
+ it("collects with the shared prompt's refactor block, naming .gtd/REFACTOR.md and the strays", async () => {
907
+ const h = await run()
908
+ const collecting = h.steps.find((s) => s.kind === "agent" && s.name === "review.collecting")
909
+ const prompt = collecting?.kind === "agent" ? collecting.prompt : ""
910
+ expect(prompt).toContain(".gtd/REFACTOR.md")
911
+ expect(prompt).not.toContain(".gtd/REQUIREMENTS.md")
912
+ expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
913
+ expect(prompt).toContain("`.gtd/REFACTOR.md`'s `## Answered Questions`")
914
+ // End of round: the review file is already closed, so no thread reply.
915
+ expect(prompt).toContain("already closed")
916
+ expect(prompt).not.toContain("exactly one `- A:` reply")
917
+ })
918
+ })
919
+
920
+ describe("the collecting prompt variant", () => {
921
+ it("is decided by the round, never by .gtd/REVIEW.md's presence", async () => {
922
+ // The review file is in the tree at the closed round (it is closed only
923
+ // after the collecting capture), yet the closed variant renders.
924
+ const h = await driveReviewed(shape(), { notes: ["add — rename it"], collected: "x" })
925
+ const collecting = h.steps.find((s) => s.kind === "agent" && s.name === "review.collecting")
926
+ const prompt = collecting?.kind === "agent" ? collecting.prompt : ""
927
+ expect(prompt).toContain("already closed")
928
+ expect(prompt).not.toContain("exactly one `- A:` reply")
929
+ expect(prompt).not.toContain("A one-shot footnote needs no reply")
930
+ })
931
+ })
932
+
933
+ describe("a note the collecting turn leaves out", () => {
934
+ it("refuses the landing naming the note and gtd abandon, never signing off", async () => {
935
+ const h = await driveReviewed(shape(), { notes: ["add — rename it"], silentCollect: true })
936
+ const message = (h.result as Error).message
937
+ expect(h.result).toBeInstanceOf(Error)
938
+ expect(message).toContain("refactor-feedback")
939
+ expect(message).toContain("note-1")
940
+ expect(message).toContain("rename it")
941
+ expect(message).toContain("gtd abandon")
942
+ })
943
+ })
944
+
945
+ describe("the guard", () => {
946
+ const outside = [change("lib/other.ts", "a", "b")]
947
+
948
+ it("runs after a quality-fix turn", async () => {
949
+ const h = await driveReviewed(shape(), {
950
+ findings: true,
951
+ turns: { "refactor.build.fix.quality.fixing": outside },
952
+ })
953
+ expect((h.result as Error).message).toContain("lib/other.ts")
954
+ expect(h.names.at(-1)).toBe("refactor.build.fix.quality.fixing")
955
+ })
956
+
957
+ it("runs after a nit-fix turn", async () => {
958
+ const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
959
+ notes: ["add — typo"],
960
+ answers: { "note-1": { answer: "nit", p: 0.9 } },
961
+ turns: { "refactor.build.review.fix.nits.fixing": [change("src/a.test.ts", "a", "b")] },
962
+ })
963
+ expect((h.result as Error).message).toContain("refactor-tests")
964
+ expect(h.names.at(-1)).toBe("refactor.build.review.fix.nits.fixing")
965
+ })
966
+
967
+ it("runs the nit-fix turn under the strays plus .gtd/** as its write access", async () => {
968
+ const write = ["src/a.ts", ".gtd/**"]
969
+ const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
970
+ notes: ["add — typo"],
971
+ answers: { "note-1": { answer: "nit", p: 0.9 } },
972
+ })
973
+ expect(h.scopes).toContainEqual({ name: "review.fix.nits", access: { write } })
974
+ expect(h.names).toContain("refactor.build.review.fix.nits.fixing")
975
+ })
976
+
977
+ it("runs after a fix-check turn", async () => {
978
+ const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
979
+ notes: ["add — typo"],
980
+ answers: { "note-1": { answer: "nit", p: 0.9 } },
981
+ reds: 2,
982
+ turns: { "refactor.build.fix": [change("src/a.test.ts", "x", "y")] },
983
+ })
984
+ expect(h.names).toContain("refactor.build.fix")
985
+ expect((h.result as Error).message).toContain("refactor-tests")
986
+ expect(h.names.at(-1)).toBe("refactor.build.fix")
987
+ })
988
+
989
+ it("never refuses a person's edit at the tail's health.stop", async () => {
990
+ const h = await driveReviewed(shape(), {
991
+ findings: true,
992
+ reds: 5,
993
+ turns: { "refactor.build.health.stop": [change("lib/human.test.ts", "a", "b")] },
994
+ })
995
+ expect(h.names).toContain("refactor.build.health.stop")
996
+ expect(h.result).toBe(true)
997
+ })
998
+
999
+ it("still refuses the tail's escalation describe turn outside the strays", async () => {
1000
+ const h = await driveReviewed(shape(), {
1001
+ findings: true,
1002
+ reds: 5,
1003
+ turns: { "refactor.build.health.describe": outside },
1004
+ })
1005
+ expect((h.result as Error).message).toContain("lib/other.ts")
1006
+ expect(h.names.at(-1)).toBe("refactor.build.health.describe")
1007
+ })
1008
+ })
1009
+ })
1010
+
1011
+ describe("reviewedRefactor's tail prompts", () => {
1012
+ const strays = ["src/a.ts", "src/b.ts"]
1013
+
1014
+ const promptAt = async (name: string, script: ReviewedScript): Promise<string> => {
1015
+ const h = await driveReviewed(shape({ strays }), script)
1016
+ const step = h.steps.find((s, i) => s.kind === "agent" && h.names[i] === name)
1017
+ if (step?.kind !== "agent") throw new Error(`no agent step ${name} in ${h.names.join(", ")}`)
1018
+ return step.prompt
1019
+ }
1020
+
1021
+ it.each([
1022
+ ["refactor.build.fix", { findings: true, reds: 2 }],
1023
+ ["refactor.build.fix.quality.fixing", { findings: true }],
1024
+ [
1025
+ "refactor.build.review.fix.risks.fixing",
1026
+ { firstReviewNote: "Risk: drops the carry", notes: [undefined] },
1027
+ ],
1028
+ [
1029
+ "refactor.build.review.fix.nits.fixing",
1030
+ { notes: ["nit"], answers: { "note-1": { answer: "nit", p: 0.9 } } },
1031
+ ],
1032
+ ["refactor.build.health.describe", { findings: true, reds: 6 }],
1033
+ ] as const)(
1034
+ "%s names every stray and bans test files and new flag reads",
1035
+ async (name, script) => {
1036
+ const prompt = await promptAt(name, script as ReviewedScript)
1037
+ for (const stray of strays) expect(prompt).toContain(`\`${stray}\``)
1038
+ expect(prompt).toContain("never a test file")
1039
+ expect(prompt).toContain("no new flag read")
1040
+ },
1041
+ )
1042
+ })
1043
+
1044
+ describe("reviewedRefactor's review lap wording", () => {
1045
+ it("says the whole refactor since it started on the first and the second round", async () => {
1046
+ const h = await driveReviewed(shape(), { notes: ["add — rename it", undefined] })
1047
+ const prompts = h.steps.flatMap((s) =>
1048
+ s.kind === "agent" && s.name === "review.reviewing" ? [s.prompt] : [],
1049
+ )
1050
+ expect(prompts).toHaveLength(2)
1051
+ for (const prompt of prompts) {
1052
+ expect(prompt).toContain("it covers the whole refactor since it started, on every round")
1053
+ expect(prompt).not.toContain("only what is new since the previous review")
1054
+ }
1055
+ })
1056
+ })