@pmelab/gtd 28.1.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +28 -17
- package/claude/hooks/assist.ts +1 -1
- package/claude/hooks/register.tsx +1 -1
- package/claude/hooks/ship.ts +963 -143
- package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
- package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
- package/dist/testkit.mjs +2 -1
- package/dist/workflow-kit.mjs +51 -21
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +247 -96
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +528 -262
- package/package.json +1 -1
- package/src/testkit/TestRepo.ts +3 -0
- package/src/testkit/index.ts +1 -1
- package/src/testkit/steps.ts +44 -0
- package/src/workflow-kit/review.test.ts +366 -8
- package/src/workflow-kit/review.ts +109 -26
- package/src/workflows/feature/feature.test.ts +313 -1
- package/src/workflows/feature/feature.ts +48 -43
- package/src/workflows/feature/one-step-land.feature +4 -0
- package/src/workflows/feature/refactor-first.feature +54 -24
- package/src/workflows/feature/refactor-review.feature +1706 -0
- package/src/workflows/feature/refactor.test.ts +592 -2
- package/src/workflows/feature/refactor.ts +160 -42
- package/src/workflows/feature/review.test.ts +322 -5
- package/src/workflows/feature/review.ts +77 -10
- package/src/workflows/feature/seam-rejection.feature +4 -0
- package/src/workflows/feature/seam.feature +3 -4
- package/src/workflows/feature/seam.test.ts +196 -1
- package/src/workflows/feature/seam.ts +23 -9
- package/src/workflows/feature/steps.test.ts +54 -2
- package/src/workflows/feature/steps.ts +45 -9
- package/src/workflows/feature/tier.test.ts +4 -0
- package/src/workflows/feature/tier.ts +2 -20
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
- package/src/workflows/shared/access.test.ts +31 -1
- package/src/workflows/shared/access.ts +7 -1
- package/src/workflows/shared/fences.ts +19 -0
- package/src/workflows/shared/health.ts +32 -12
- package/src/workflows/shared/options.test.ts +18 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/prose.ts +4 -2
- package/src/workflows/shared/skills.test.ts +20 -1
- package/src/workflows/shared/skills.ts +43 -32
- package/src/workflows/shared/steps.ts +33 -19
- package/src/workflows/shared/tail.test.ts +84 -1
- package/src/workflows/shared/tail.ts +30 -12
- package/src/workflows/shared/text.test.ts +266 -4
- package/src/workflows/shared/text.ts +183 -70
- 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 {
|
|
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:
|
|
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
|
+
})
|