@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +33 -13
- package/dist/chunks/{reserved-COEMFEpy.mjs → reserved-ty1mtlnS.mjs} +155 -3
- package/dist/chunks/review-BmoH-9im.mjs +408 -0
- package/dist/chunks/{tail-C_xn8U7U.mjs → tail-CM3sVoAb.mjs} +3 -3
- package/dist/workflow-kit.mjs +22 -10
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +6 -278
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +27 -7
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +379 -66
- package/package.json +1 -1
- package/src/steering/FORMATS.md +4 -4
- package/src/workflow-kit/review.test.ts +80 -2
- package/src/workflow-kit/review.ts +45 -11
- package/src/workflows/feature/coverage.ts +1 -1
- package/src/workflows/feature/feature.test.ts +8 -0
- package/src/workflows/feature/feature.ts +2 -2
- package/src/workflows/feature/index.ts +1 -1
- package/src/workflows/feature/refactor.ts +1 -1
- package/src/workflows/feature/review-answers.feature +12 -3
- package/src/workflows/feature/seam.ts +1 -1
- package/src/workflows/feature/standards.feature +286 -0
- package/src/workflows/feature/steps.test.ts +2 -64
- package/src/workflows/feature/steps.ts +5 -41
- package/src/workflows/fix/fix.feature +20 -21
- package/src/workflows/index.ts +1 -1
- package/src/workflows/review/review-door.feature +158 -0
- package/src/workflows/review/review.test.ts +1 -73
- package/src/workflows/review/review.ts +34 -6
- package/src/workflows/shared/learn.test.ts +154 -0
- package/src/workflows/shared/learn.ts +206 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/{feature → shared}/review.test.ts +4 -8
- package/src/workflows/{feature → shared}/review.ts +23 -10
- package/src/workflows/shared/skills.test.ts +2 -0
- package/src/workflows/shared/skills.ts +2 -0
- package/src/workflows/shared/standards.test.ts +90 -0
- package/src/workflows/shared/standards.ts +156 -0
- package/src/workflows/shared/steps.test.ts +64 -2
- package/src/workflows/shared/steps.ts +40 -1
- package/src/workflows/shared/tail.ts +2 -2
- package/src/workflows/shared/text.test.ts +58 -1
- package/src/workflows/shared/text.ts +39 -1
- package/src/workflows/shared/vars.ts +2 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import
|
|
4
|
-
import { captureStep
|
|
3
|
+
import type { StepRequest } from "../../flows/index.js"
|
|
4
|
+
import { captureStep } from "../shared/text.fixture.js"
|
|
5
5
|
import * as steps from "./steps.js"
|
|
6
6
|
|
|
7
7
|
const capture = (
|
|
@@ -49,68 +49,6 @@ describe("the feature workflow's own steps declare skills — a step's rendered
|
|
|
49
49
|
expect(closed).toContain("already closed")
|
|
50
50
|
expect(closed).not.toContain("exactly one `- A:` reply")
|
|
51
51
|
})
|
|
52
|
-
|
|
53
|
-
it("answerReviewQuestions answers inline in .gtd/REVIEW.md as the reviewer, with its bundled skills", async () => {
|
|
54
|
-
const request = await capture(
|
|
55
|
-
() =>
|
|
56
|
-
steps.answerReviewQuestions([{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "why?" }]),
|
|
57
|
-
"build",
|
|
58
|
-
)
|
|
59
|
-
if (request.kind !== "agent") throw new Error("unreachable")
|
|
60
|
-
expect(request.name).toBe("review.answer-review-questions")
|
|
61
|
-
expect(request.options).toMatchObject({
|
|
62
|
-
file: ".gtd/REVIEW.md",
|
|
63
|
-
mode: "review",
|
|
64
|
-
})
|
|
65
|
-
expect(request.prompt).toContain("code-review-and-quality")
|
|
66
|
-
expect(request.prompt).toContain("note-1 — calc ./a.ts#1-1")
|
|
67
|
-
expect(request.prompt).toContain("`A: ` line")
|
|
68
|
-
expect(request.prompt).toContain("gtd check review .gtd/REVIEW.md")
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it("fixNits fixes every nit in one turn and leaves .gtd/REVIEW.md alone", async () => {
|
|
72
|
-
const request = await capture(
|
|
73
|
-
() =>
|
|
74
|
-
steps.fixNits([
|
|
75
|
-
{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "typo" },
|
|
76
|
-
{ id: "note-2", anchor: "calc ./a.ts#2-2", text: "semicolon" },
|
|
77
|
-
]),
|
|
78
|
-
"build",
|
|
79
|
-
)
|
|
80
|
-
if (request.kind !== "agent") throw new Error("unreachable")
|
|
81
|
-
expect(request.name).toBe("review.fix.nits.fixing")
|
|
82
|
-
expect(request.prompt).toContain("incremental-implementation, code-simplification")
|
|
83
|
-
expect(request.prompt).toContain("typo")
|
|
84
|
-
expect(request.prompt).toContain("semicolon")
|
|
85
|
-
expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it("fixNits with access runs in a review.fix.nits scope, the step named fixing inside it", async () => {
|
|
89
|
-
const scopes: ScopeOptions[] = []
|
|
90
|
-
const steps_: StepRequest[] = []
|
|
91
|
-
const access = { write: ["src/a.ts", ".gtd/**"] }
|
|
92
|
-
installContext(
|
|
93
|
-
fixtureContext(
|
|
94
|
-
{ scope: "refactor.build" },
|
|
95
|
-
{
|
|
96
|
-
step: (request) => {
|
|
97
|
-
steps_.push(request)
|
|
98
|
-
return Promise.resolve()
|
|
99
|
-
},
|
|
100
|
-
pushScope: (s) => void scopes.push(s),
|
|
101
|
-
popScope: () => undefined,
|
|
102
|
-
changesSince: () => [],
|
|
103
|
-
},
|
|
104
|
-
),
|
|
105
|
-
)
|
|
106
|
-
try {
|
|
107
|
-
await steps.fixNits([], access)
|
|
108
|
-
} finally {
|
|
109
|
-
installContext(undefined)
|
|
110
|
-
}
|
|
111
|
-
expect(scopes).toEqual([{ name: "review.fix.nits", access }])
|
|
112
|
-
expect(steps_.map((r) => ("name" in r ? r.name : ""))).toEqual(["fixing"])
|
|
113
|
-
})
|
|
114
52
|
})
|
|
115
53
|
|
|
116
54
|
describe("the feature workflow's planning steps run on the planner model", () => {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { human
|
|
1
|
+
import { human } from "../../flows/index.js"
|
|
2
2
|
import type { CollectRound } from "../../workflow-kit/index.js"
|
|
3
3
|
import * as t from "../shared/text.js"
|
|
4
4
|
import { riskTierOn } from "./tier.js"
|
|
5
|
-
import { planner, REFACTOR, REQUIREMENTS
|
|
5
|
+
import { planner, REFACTOR, REQUIREMENTS } from "../shared/steps.js"
|
|
6
|
+
|
|
7
|
+
// Kept on the published feature entry.
|
|
8
|
+
export { answerReviewQuestions, collecting, fixNits } from "../shared/steps.js"
|
|
6
9
|
|
|
7
10
|
/** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
|
|
8
11
|
export const triage = (base: string): Promise<void> =>
|
|
@@ -23,45 +26,6 @@ export const answerProductQuestions = (): Promise<void> =>
|
|
|
23
26
|
acceptClean: true,
|
|
24
27
|
})
|
|
25
28
|
|
|
26
|
-
/** Answer every `question` note inline in `.gtd/REVIEW.md`. */
|
|
27
|
-
export const answerReviewQuestions = (notes: readonly t.NoteInput[]): Promise<void> =>
|
|
28
|
-
t.agentWithSkills("review.answer-review-questions", t.buildReviewAnswerQuestionsPrompt(notes), {
|
|
29
|
-
label: "Answering your questions",
|
|
30
|
-
file: REVIEW,
|
|
31
|
-
mode: "review",
|
|
32
|
-
model: planner(),
|
|
33
|
-
system: t.reviewerSystem(),
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
/** Fix every `nit` note in one turn. */
|
|
37
|
-
export const fixNits = (
|
|
38
|
-
notes: readonly t.NoteInput[],
|
|
39
|
-
access?: ScopeAccess,
|
|
40
|
-
rules?: string,
|
|
41
|
-
): Promise<void> => {
|
|
42
|
-
const fixing = (name: string): Promise<void> =>
|
|
43
|
-
t.agentWithSkills(name, t.buildReviewFixNitsPrompt(notes, rules), {
|
|
44
|
-
label: "Fixing your nits",
|
|
45
|
-
file: REVIEW,
|
|
46
|
-
model: planner(),
|
|
47
|
-
system: t.reviewerSystem(),
|
|
48
|
-
})
|
|
49
|
-
return access === undefined
|
|
50
|
-
? fixing("review.fix.nits.fixing")
|
|
51
|
-
: scope({ name: "review.fix.nits", access }, () => fixing("fixing"))
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
|
|
55
|
-
export const collecting = (capture: string, round?: CollectRound): Promise<void> =>
|
|
56
|
-
t.agentWithSkills("review.collecting", t.buildReviewCollectingPrompt(capture, undefined, round), {
|
|
57
|
-
label: "Collecting your feedback",
|
|
58
|
-
file: REQUIREMENTS,
|
|
59
|
-
mode: "qa",
|
|
60
|
-
model: planner(),
|
|
61
|
-
system: t.reviewerSystem(),
|
|
62
|
-
allowEmpty: true,
|
|
63
|
-
})
|
|
64
|
-
|
|
65
29
|
/** Turn the refactor review round `capture` describes into `.gtd/REFACTOR.md`, or change nothing. */
|
|
66
30
|
export const refactorCollecting = (
|
|
67
31
|
capture: string,
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
Feature: fix fixes the notes
|
|
1
|
+
Feature: fix fixes the notes a hand-off leaves it
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
picks both up.
|
|
3
|
+
A hand-off is `.gtd/REVIEW.md` with the human's notes beside the reviewer's
|
|
4
|
+
own `.gtd/REVIEW.orig.md`; `gtd door fix` picks both up.
|
|
6
5
|
|
|
7
6
|
Background:
|
|
8
7
|
Given the shipped workflows, as gtd install scaffolds them
|
|
@@ -13,10 +12,8 @@ Feature: fix fixes the notes review hands it
|
|
|
13
12
|
"""
|
|
14
13
|
And the working tree is committed as "feat: add calculator"
|
|
15
14
|
|
|
16
|
-
Scenario: a note
|
|
17
|
-
Given
|
|
18
|
-
And step "build.health.check" changes nothing
|
|
19
|
-
And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
|
|
15
|
+
Scenario: a handed-off note is fixed, and both review files are removed
|
|
16
|
+
Given a file ".gtd/REVIEW.orig.md" with:
|
|
20
17
|
"""
|
|
21
18
|
# Review: abc1234
|
|
22
19
|
|
|
@@ -27,11 +24,7 @@ Feature: fix fixes the notes review hands it
|
|
|
27
24
|
- [ ] ./src/calc.ts#1-1
|
|
28
25
|
new add function
|
|
29
26
|
"""
|
|
30
|
-
|
|
31
|
-
And the process is driven
|
|
32
|
-
Then the process rests at "build.review.await-review"
|
|
33
|
-
|
|
34
|
-
Given a file ".gtd/REVIEW.md" with:
|
|
27
|
+
And a file ".gtd/REVIEW.md" with:
|
|
35
28
|
"""
|
|
36
29
|
# Review: abc1234
|
|
37
30
|
|
|
@@ -42,21 +35,27 @@ Feature: fix fixes the notes review hands it
|
|
|
42
35
|
- [ ] ./src/calc.ts#1-1
|
|
43
36
|
new add function — rename it to sum
|
|
44
37
|
"""
|
|
45
|
-
And
|
|
46
|
-
|
|
47
|
-
And
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
And the working tree is committed as "chore: hand off a review"
|
|
39
|
+
And step "build.health.check" changes nothing
|
|
40
|
+
And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
|
|
41
|
+
"""
|
|
42
|
+
# Review: abc1234
|
|
43
|
+
|
|
44
|
+
<!-- base: 0000000 -->
|
|
45
|
+
|
|
46
|
+
## calc
|
|
50
47
|
|
|
51
|
-
|
|
48
|
+
- [ ] ./src/calc.ts#1-1
|
|
49
|
+
new add function
|
|
50
|
+
"""
|
|
51
|
+
And step "notes.fixing" writes "src/calc.ts" with:
|
|
52
52
|
"""
|
|
53
53
|
export const sum = (a: number, b: number) => a + b
|
|
54
54
|
"""
|
|
55
55
|
And step "notes.closing" runs its script
|
|
56
56
|
When gtd opens door "fix"
|
|
57
57
|
Then the resting step's content contains "rename it to sum"
|
|
58
|
-
# The fix goes to its own review, which the
|
|
59
|
-
# afresh — the handed-off review itself is gone, as the reviewer's copy shows.
|
|
58
|
+
# The fix goes to its own review, which the scripted reviewer writes afresh.
|
|
60
59
|
When the process is driven
|
|
61
60
|
Then the process rests at "build.review.await-review"
|
|
62
61
|
And the commit subjects end with:
|
package/src/workflows/index.ts
CHANGED
|
@@ -32,7 +32,7 @@ export {
|
|
|
32
32
|
type BuildTailOptions,
|
|
33
33
|
type FeedbackTarget,
|
|
34
34
|
type ReviewOutcome,
|
|
35
|
-
} from "./
|
|
35
|
+
} from "./shared/review.js"
|
|
36
36
|
export * from "./feature/feature.js"
|
|
37
37
|
export { base, review, reviewFlow } from "./review/review.js"
|
|
38
38
|
export { fix, fixFlow, fixReviewNotes } from "./fix/fix.js"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: the review door fixes its notes in the same process
|
|
3
|
+
|
|
4
|
+
`gtd door review` runs the feature's review over everything since its base:
|
|
5
|
+
the triaged quality lap writes `.gtd/REVIEW.md`, the human answers each note,
|
|
6
|
+
and what they ask for is fixed in place under the health loop before the
|
|
7
|
+
review comes back. A sign-off ends the process; nothing is left for `fix`.
|
|
8
|
+
|
|
9
|
+
Background:
|
|
10
|
+
Given the shipped workflows, as gtd install scaffolds them
|
|
11
|
+
And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
|
|
12
|
+
And a file "src/calc.ts" with:
|
|
13
|
+
"""
|
|
14
|
+
export const add = (a: number, b: number) => a + b
|
|
15
|
+
"""
|
|
16
|
+
And the working tree is committed as "feat: add calculator"
|
|
17
|
+
And step "review-gate.check" changes nothing
|
|
18
|
+
And step "build.health.check" changes nothing
|
|
19
|
+
And step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
|
|
20
|
+
"""
|
|
21
|
+
# Quality
|
|
22
|
+
|
|
23
|
+
## add has no test
|
|
24
|
+
|
|
25
|
+
Add a test for add.
|
|
26
|
+
"""
|
|
27
|
+
And step "build.quality.sorting" runs its script
|
|
28
|
+
And judge step "build.quality.triage" answers:
|
|
29
|
+
"""
|
|
30
|
+
[{"id":"Q1","answer":"design","p":0.9}]
|
|
31
|
+
"""
|
|
32
|
+
And step "build.review.composing" runs its script
|
|
33
|
+
And step "build.review.closing" runs its script
|
|
34
|
+
|
|
35
|
+
Scenario: the review is composed from the lap's notes, with answer boxes
|
|
36
|
+
When gtd opens door "review" with "HEAD~1"
|
|
37
|
+
And the process is driven
|
|
38
|
+
Then the process rests at "build.review.await-review"
|
|
39
|
+
And the commit subjects end with:
|
|
40
|
+
"""
|
|
41
|
+
gtd(human): review-gate.check
|
|
42
|
+
gtd(check): review-gate.check → build.quality.correctness.reviewing
|
|
43
|
+
gtd(agent): build.quality.correctness.reviewing → build.quality.triage
|
|
44
|
+
gtd(judge): build.quality.triage → build.quality.sorting
|
|
45
|
+
gtd(check): build.quality.sorting → build.review.composing
|
|
46
|
+
gtd(check): build.review.composing → build.review.await-review
|
|
47
|
+
"""
|
|
48
|
+
And ".gtd/REVIEW.md" contains:
|
|
49
|
+
"""
|
|
50
|
+
## add has no test
|
|
51
|
+
|
|
52
|
+
Add a test for add.
|
|
53
|
+
|
|
54
|
+
Tick exactly one:
|
|
55
|
+
|
|
56
|
+
- [ ] fix as suggested
|
|
57
|
+
- [ ] won't fix
|
|
58
|
+
- [ ] _your answer_
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
Scenario: a won't-fix signs off, and no review is left for fix
|
|
62
|
+
Given judge step "build.review.standards.judge" answers:
|
|
63
|
+
"""
|
|
64
|
+
[{"id":"standard-1","answer":"no","p":0.9}]
|
|
65
|
+
"""
|
|
66
|
+
When gtd opens door "review" with "HEAD~1"
|
|
67
|
+
And the process is driven
|
|
68
|
+
And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
|
|
69
|
+
And gtd lands
|
|
70
|
+
And the process is driven
|
|
71
|
+
Then the process rests at "idle"
|
|
72
|
+
And ".gtd/REVIEW.md" does not exist
|
|
73
|
+
And ".gtd/REVIEW.orig.md" does not exist
|
|
74
|
+
|
|
75
|
+
Scenario: a note ticked fix as suggested is fixed in place, then reviewed again
|
|
76
|
+
Given step "build.review.fix.nits.fixing" writes "src/calc.test.ts" with:
|
|
77
|
+
"""
|
|
78
|
+
it("adds", () => {})
|
|
79
|
+
"""
|
|
80
|
+
When gtd opens door "review" with "HEAD~1"
|
|
81
|
+
And the process is driven
|
|
82
|
+
And the text "- [ ] fix as suggested" in ".gtd/REVIEW.md" is replaced with "- [x] fix as suggested"
|
|
83
|
+
And gtd lands
|
|
84
|
+
And the process is driven
|
|
85
|
+
Then the process rests at "build.review.await-review"
|
|
86
|
+
And the commit subjects end with:
|
|
87
|
+
"""
|
|
88
|
+
gtd(human): build.review.await-review → build.review.fix.nits.fixing
|
|
89
|
+
gtd(agent): build.review.fix.nits.fixing → build.health.check
|
|
90
|
+
gtd(check): build.health.check → build.review.closing
|
|
91
|
+
gtd(check): build.review.closing → build.review.composing
|
|
92
|
+
gtd(check): build.review.composing → build.review.await-review
|
|
93
|
+
"""
|
|
94
|
+
And ".gtd/REVIEW.md" contains "## Fixed from your notes"
|
|
95
|
+
And "src/calc.test.ts" contains "adds"
|
|
96
|
+
When gtd lands
|
|
97
|
+
And the process is driven
|
|
98
|
+
Then the process rests at "idle"
|
|
99
|
+
And ".gtd/REVIEW.md" does not exist
|
|
100
|
+
And ".gtd/REVIEW.orig.md" does not exist
|
|
101
|
+
|
|
102
|
+
Scenario: an own answer asking for a change is fixed in place, then reviewed again
|
|
103
|
+
Given judge step "build.review.standards.judge" answers:
|
|
104
|
+
"""
|
|
105
|
+
[{"id":"standard-1","answer":"no","p":0.9}]
|
|
106
|
+
"""
|
|
107
|
+
And judge step "build.review.triage" answers:
|
|
108
|
+
"""
|
|
109
|
+
[{"id":"answer-1","answer":"edit","p":0.9}]
|
|
110
|
+
"""
|
|
111
|
+
And step "build.review.collecting" writes ".gtd/REQUIREMENTS.md" with:
|
|
112
|
+
"""
|
|
113
|
+
# Requirements
|
|
114
|
+
|
|
115
|
+
## Test add with negatives
|
|
116
|
+
|
|
117
|
+
add(-1, -2) returns -3.
|
|
118
|
+
"""
|
|
119
|
+
And step "feedback.fixing" is prompted with ".gtd/REQUIREMENTS.md"
|
|
120
|
+
And step "feedback.fixing" writes "src/calc.test.ts" with:
|
|
121
|
+
"""
|
|
122
|
+
it("adds negatives", () => {})
|
|
123
|
+
"""
|
|
124
|
+
And step "re-unwind" runs its script
|
|
125
|
+
And step "feedback.closing" runs its script
|
|
126
|
+
When gtd opens door "review" with "HEAD~1"
|
|
127
|
+
And the process is driven
|
|
128
|
+
And the text "- [ ] _your answer_" in ".gtd/REVIEW.md" is replaced with "- [x] test negatives too"
|
|
129
|
+
And gtd lands
|
|
130
|
+
And the process is driven
|
|
131
|
+
Then the process rests at "build.review.await-review"
|
|
132
|
+
And the commit subjects end with:
|
|
133
|
+
"""
|
|
134
|
+
gtd(human): build.review.await-review → build.review.standards.judge
|
|
135
|
+
gtd(judge): build.review.standards.judge → build.review.triage
|
|
136
|
+
gtd(judge): build.review.triage → build.review.closing
|
|
137
|
+
gtd(check): build.review.closing → build.review.collecting
|
|
138
|
+
gtd(agent): build.review.collecting → re-unwind
|
|
139
|
+
gtd(check): re-unwind → feedback.fixing
|
|
140
|
+
gtd(agent): feedback.fixing → feedback.closing
|
|
141
|
+
gtd(check): feedback.closing → build.health.check
|
|
142
|
+
gtd(check): build.health.check → build.quality.correctness.reviewing
|
|
143
|
+
gtd(agent): build.quality.correctness.reviewing → build.quality.triage
|
|
144
|
+
gtd(judge): build.quality.triage → build.quality.sorting
|
|
145
|
+
gtd(check): build.quality.sorting → build.review.composing
|
|
146
|
+
gtd(check): build.review.composing → build.review.await-review
|
|
147
|
+
"""
|
|
148
|
+
And ".gtd/REQUIREMENTS.md" does not exist
|
|
149
|
+
And "src/calc.test.ts" contains "adds negatives"
|
|
150
|
+
And ".gtd/REVIEW.md" does not contain "test negatives too"
|
|
151
|
+
|
|
152
|
+
Scenario: a leftover requirements file is refused before any step
|
|
153
|
+
Given a file ".gtd/REQUIREMENTS.md" with:
|
|
154
|
+
"""
|
|
155
|
+
# Requirements
|
|
156
|
+
"""
|
|
157
|
+
And the working tree is committed as "chore: leftover requirements"
|
|
158
|
+
Then opening door "review" is refused with ".gtd/REQUIREMENTS.md is left over"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import {
|
|
2
|
+
import { testRepo } from "../../testkit/index.js"
|
|
3
3
|
import { CONFIG_SCAFFOLD } from "../../install/index.js"
|
|
4
4
|
import { reviewDoc } from "../shared/config.fixture.js"
|
|
5
5
|
import { base } from "./review.js"
|
|
@@ -7,79 +7,7 @@ import { base } from "./review.js"
|
|
|
7
7
|
const REVIEW = ".gtd/REVIEW.md"
|
|
8
8
|
const ORIG = ".gtd/REVIEW.orig.md"
|
|
9
9
|
|
|
10
|
-
/** A repository with one commit to review, the review run until the human holds `.gtd/REVIEW.md`. */
|
|
11
|
-
const reviewed = async (): Promise<TestRepo> => {
|
|
12
|
-
const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
|
|
13
|
-
repo.write("src/calc.ts", "export const add = (a: number, b: number) => a + b\n")
|
|
14
|
-
repo.commit("feat: add calculator")
|
|
15
|
-
await repo.door("review", "HEAD~1")
|
|
16
|
-
const rest = await repo.drive({
|
|
17
|
-
"review-gate.check": () => undefined,
|
|
18
|
-
"build.health.check": () => undefined,
|
|
19
|
-
"build.review.reviewing": (beat, r) => {
|
|
20
|
-
expect(beat.file).toBe(REVIEW)
|
|
21
|
-
r.write(REVIEW, reviewDoc("new add function"))
|
|
22
|
-
},
|
|
23
|
-
})
|
|
24
|
-
expect(rest).toMatchObject({ state: "build.review.await-review", actor: "human" })
|
|
25
|
-
return repo
|
|
26
|
-
}
|
|
27
|
-
|
|
28
10
|
describe("the review workflow", () => {
|
|
29
|
-
it("checks, reviews and hands the review to the human", async () => {
|
|
30
|
-
const repo = await reviewed()
|
|
31
|
-
expect(repo.subjects().slice(2)).toEqual([
|
|
32
|
-
"gtd(human): review-gate.check",
|
|
33
|
-
"gtd(check): review-gate.check → build.health.check",
|
|
34
|
-
"gtd(check): build.health.check → build.review.reviewing",
|
|
35
|
-
"gtd(agent): build.review.reviewing → build.review.await-review",
|
|
36
|
-
])
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it("closes a review the human signs off", async () => {
|
|
40
|
-
const repo = await reviewed()
|
|
41
|
-
repo.write(REVIEW, reviewDoc("new add function").replace("- [ ]", "- [x]"))
|
|
42
|
-
await repo.land()
|
|
43
|
-
const rest = await repo.drive({ "build.review.closing": runsItsScript })
|
|
44
|
-
expect(rest.state).toBe("idle")
|
|
45
|
-
expect(repo.subjects().slice(-2)).toEqual([
|
|
46
|
-
"gtd(human): build.review.await-review → build.review.closing",
|
|
47
|
-
"gtd(check): build.review.closing → idle",
|
|
48
|
-
])
|
|
49
|
-
expect(repo.read(REVIEW)).toBeUndefined()
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it("never lands a review the review format rejects", async () => {
|
|
53
|
-
const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
|
|
54
|
-
repo.write("src/calc.ts", "export const add = (a: number, b: number) => a + b\n")
|
|
55
|
-
repo.commit("feat: add calculator")
|
|
56
|
-
await repo.door("review", "HEAD~1")
|
|
57
|
-
await expect(
|
|
58
|
-
repo.drive({
|
|
59
|
-
"review-gate.check": () => undefined,
|
|
60
|
-
"build.health.check": () => undefined,
|
|
61
|
-
"build.review.reviewing": (_, r) => r.write(REVIEW, "not a review\n"),
|
|
62
|
-
}),
|
|
63
|
-
).rejects.toThrow(/validating \.gtd\/REVIEW\.md/)
|
|
64
|
-
expect(repo.subjects().at(-1)).toBe("gtd(check): build.health.check → build.review.reviewing")
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it("ends with the review and the reviewer's copy kept when the human leaves a note", async () => {
|
|
68
|
-
const repo = await reviewed()
|
|
69
|
-
repo.write(REVIEW, reviewDoc("new add function — rename it to sum"))
|
|
70
|
-
await repo.land()
|
|
71
|
-
const rest = await repo.drive({
|
|
72
|
-
"build.review.handing-off": runsItsScript,
|
|
73
|
-
})
|
|
74
|
-
expect(rest.state).toBe("idle")
|
|
75
|
-
expect(repo.subjects().slice(-2)).toEqual([
|
|
76
|
-
"gtd(human): build.review.await-review → build.review.handing-off",
|
|
77
|
-
"gtd(check): build.review.handing-off → idle",
|
|
78
|
-
])
|
|
79
|
-
expect(repo.read(REVIEW)).toContain("rename it to sum")
|
|
80
|
-
expect(repo.read(ORIG)).toBe(reviewDoc("new add function"))
|
|
81
|
-
})
|
|
82
|
-
|
|
83
11
|
it("refuses a pending hand-off before requesting any step, the gate's check included", async () => {
|
|
84
12
|
const repo = testRepo({
|
|
85
13
|
workflow: CONFIG_SCAFFOLD,
|
|
@@ -1,18 +1,46 @@
|
|
|
1
|
-
import { start, type WorkflowBase } from "../../flows/index.js"
|
|
1
|
+
import { read, refuse, removeScript, run, start, type WorkflowBase } from "../../flows/index.js"
|
|
2
|
+
import { buildTail, reUnwind } from "../shared/review.js"
|
|
2
3
|
import { gate } from "../shared/health.js"
|
|
3
4
|
import { shipped } from "../shared/options.js"
|
|
4
|
-
import {
|
|
5
|
+
import { coder, REQUIREMENTS } from "../shared/steps.js"
|
|
6
|
+
import { refusePendingHandOff } from "../shared/tail.js"
|
|
5
7
|
import * as t from "../shared/text.js"
|
|
6
8
|
|
|
9
|
+
/** Build the concerns a review round collected into `.gtd/REQUIREMENTS.md`, then remove it. */
|
|
10
|
+
const fixFeedback = async (): Promise<void> => {
|
|
11
|
+
await t.agentWithSkills("feedback.fixing", t.reviewFeedbackFixPrompt(), {
|
|
12
|
+
label: "Fixing your review feedback",
|
|
13
|
+
file: REQUIREMENTS,
|
|
14
|
+
model: coder(),
|
|
15
|
+
system: t.finisherSystem(),
|
|
16
|
+
// An earlier nit fix may already have built the concern.
|
|
17
|
+
allowEmpty: true,
|
|
18
|
+
})
|
|
19
|
+
await run("feedback.closing", removeScript([REQUIREMENTS]), { label: "Closing the feedback" })
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
/**
|
|
8
|
-
*
|
|
9
|
-
* lap, then the human review
|
|
10
|
-
*
|
|
23
|
+
* The feature's review of everything since `reviewBase` — a green gate, the
|
|
24
|
+
* triaged quality lap, then the human review — with no design phase: a
|
|
25
|
+
* review that asks for changes is fixed in place and reviewed again, until
|
|
26
|
+
* sign-off.
|
|
11
27
|
*/
|
|
12
28
|
export const reviewFlow = async (): Promise<void> => {
|
|
13
29
|
refusePendingHandOff()
|
|
30
|
+
// A review's feedback is whatever sits in this file, so a leftover one would be built, then deleted.
|
|
31
|
+
if (read(REQUIREMENTS) !== undefined) {
|
|
32
|
+
refuse(`gtd: ${REQUIREMENTS} is left over — finish or remove it before a review.`)
|
|
33
|
+
}
|
|
14
34
|
await gate("review-gate", t.reviewGateBlockedMessage())
|
|
15
|
-
|
|
35
|
+
const base = start()
|
|
36
|
+
// The gate just proved the tree green.
|
|
37
|
+
let outcome = await buildTail(base, { green: true })
|
|
38
|
+
while (outcome.verdict === "feedback") {
|
|
39
|
+
await reUnwind(outcome)
|
|
40
|
+
await fixFeedback()
|
|
41
|
+
// The human reviews the whole change again; the lenses only what the fix changed.
|
|
42
|
+
outcome = await buildTail(base, { lensBase: outcome.base })
|
|
43
|
+
}
|
|
16
44
|
}
|
|
17
45
|
|
|
18
46
|
export const review = shipped(
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
const state = vi.hoisted(() => ({
|
|
4
|
+
files: {} as Record<string, string | undefined>,
|
|
5
|
+
vars: { standardMinP: "0.8" } as Record<string, string>,
|
|
6
|
+
answers: {} as Record<string, { answer: string; p: number } | undefined>,
|
|
7
|
+
judged: [] as string[][],
|
|
8
|
+
written: {} as Record<string, string>,
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock("../../flows/index.js", async (original) => ({
|
|
12
|
+
...(await original<typeof import("../../flows/index.js")>()),
|
|
13
|
+
read: (path: string) => state.files[path],
|
|
14
|
+
vars: state.vars,
|
|
15
|
+
judge: async (_: string, spec: { questions: { id: string }[] }) => {
|
|
16
|
+
state.judged.push(spec.questions.map((q) => q.id))
|
|
17
|
+
return { answers: state.answers, truncated: [] }
|
|
18
|
+
},
|
|
19
|
+
run: async (_: string, script: string) => {
|
|
20
|
+
const path = /> '([^']+)'/.exec(script)?.[1] ?? ""
|
|
21
|
+
const content = /printf '%s' '((?:[^']|'\\'')*)'/.exec(script)?.[1] ?? ""
|
|
22
|
+
state.written[path] = content.replaceAll("'\\''", "'")
|
|
23
|
+
},
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
import type { ReviewDecision } from "../../steering/index.js"
|
|
27
|
+
import { learnStandards } from "./learn.js"
|
|
28
|
+
|
|
29
|
+
const STANDARDS = ".gtd/STANDARDS.md"
|
|
30
|
+
|
|
31
|
+
const file = (...lines: string[]): string =>
|
|
32
|
+
[
|
|
33
|
+
"# Standards",
|
|
34
|
+
"",
|
|
35
|
+
"Kept from your review answers for every agent turn. One standard per bullet.",
|
|
36
|
+
"",
|
|
37
|
+
...lines,
|
|
38
|
+
"",
|
|
39
|
+
].join("\n")
|
|
40
|
+
|
|
41
|
+
const answer = (anchor: string, text: string): ReviewDecision => ({
|
|
42
|
+
anchor,
|
|
43
|
+
body: "",
|
|
44
|
+
choice: "answer",
|
|
45
|
+
text,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const yes = { answer: "yes", p: 0.9 }
|
|
49
|
+
const no = { answer: "no", p: 0.9 }
|
|
50
|
+
const kept = () => state.written[STANDARDS]
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
state.files = { ".gtd/REVIEW.md": "# Review\n" }
|
|
54
|
+
state.vars.standardMinP = "0.8"
|
|
55
|
+
state.answers = {}
|
|
56
|
+
state.judged = []
|
|
57
|
+
state.written = {}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("learnStandards", () => {
|
|
61
|
+
it("asks a later candidate about each earlier one, and holds a contradiction for the human", async () => {
|
|
62
|
+
state.answers = { "standard-1": yes, "standard-2": yes, "standard-2.contradicts-new-1": yes }
|
|
63
|
+
await learnStandards([answer("A", "Use classes."), answer("B", "Never use classes.")])
|
|
64
|
+
expect(state.judged[0]).toContain("standard-2.contradicts-new-1")
|
|
65
|
+
expect(kept()).toBe(
|
|
66
|
+
file(
|
|
67
|
+
"- Use classes. (re: A)",
|
|
68
|
+
"",
|
|
69
|
+
"## Undecided",
|
|
70
|
+
"",
|
|
71
|
+
"- Never use classes. (re: B)",
|
|
72
|
+
" - contradicts: Use classes. (re: A)",
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("raises a contradiction ruled out below the threshold", async () => {
|
|
78
|
+
state.files[STANDARDS] = file("- Old.")
|
|
79
|
+
state.answers = { "standard-1": yes, "standard-1.contradicts-1": { answer: "no", p: 0.5 } }
|
|
80
|
+
await learnStandards([answer("A", "New.")])
|
|
81
|
+
expect(kept()).toBe(
|
|
82
|
+
file("- Old.", "", "## Undecided", "", "- New. (re: A)", " - contradicts: Old."),
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("keeps one the judge confidently calls compatible", async () => {
|
|
87
|
+
state.files[STANDARDS] = file("- Old.")
|
|
88
|
+
state.answers = { "standard-1": yes, "standard-1.contradicts-1": no }
|
|
89
|
+
await learnStandards([answer("A", "New.")])
|
|
90
|
+
expect(kept()).toBe(file("- Old.", "- New. (re: A)"))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("asks no judge when standardMinP is blank", async () => {
|
|
94
|
+
state.vars.standardMinP = ""
|
|
95
|
+
await learnStandards([answer("A", "New.")])
|
|
96
|
+
expect(state.judged).toEqual([])
|
|
97
|
+
expect(state.written).toEqual({})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("drops a replaced standard from another open conflict", async () => {
|
|
101
|
+
state.files[STANDARDS] = file(
|
|
102
|
+
"- O.",
|
|
103
|
+
"",
|
|
104
|
+
"## Undecided",
|
|
105
|
+
"",
|
|
106
|
+
"- A.",
|
|
107
|
+
" - contradicts: O.",
|
|
108
|
+
"- B.",
|
|
109
|
+
" - contradicts: O.",
|
|
110
|
+
)
|
|
111
|
+
// The conflict note's title hashes its new standard, "A.".
|
|
112
|
+
await learnStandards([
|
|
113
|
+
{ anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
|
|
114
|
+
])
|
|
115
|
+
expect(kept()).toBe(file("- A.", "", "## Undecided", "", "- B."))
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("learnStandards, from a won't-fix", () => {
|
|
120
|
+
it("states the finding, not gtd's banner", async () => {
|
|
121
|
+
state.answers = { "standard-1": yes }
|
|
122
|
+
await learnStandards([
|
|
123
|
+
{
|
|
124
|
+
anchor: "Slow loop",
|
|
125
|
+
// As the review parser hands it over: paragraphs joined by a space.
|
|
126
|
+
body: "_The fixer left this unresolved._ The loop is quadratic.",
|
|
127
|
+
choice: "wontfix",
|
|
128
|
+
text: "",
|
|
129
|
+
},
|
|
130
|
+
])
|
|
131
|
+
expect(kept()).toBe(file("- Won't fix: Slow loop — The loop is quadratic."))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("replaces an old standard hand-edited in case only", async () => {
|
|
135
|
+
state.files[STANDARDS] = file(
|
|
136
|
+
"- use classes.",
|
|
137
|
+
"",
|
|
138
|
+
"## Undecided",
|
|
139
|
+
"",
|
|
140
|
+
"- A.",
|
|
141
|
+
" - contradicts: Use classes.",
|
|
142
|
+
)
|
|
143
|
+
await learnStandards([
|
|
144
|
+
{ anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
|
|
145
|
+
])
|
|
146
|
+
expect(kept()).toBe(file("- A."))
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("does not learn again what a formatter rewrote", async () => {
|
|
150
|
+
state.files[STANDARDS] = file("- Prefer _named_ exports. (re: A)")
|
|
151
|
+
await learnStandards([answer("A", "Prefer *named* exports.")])
|
|
152
|
+
expect(state.judged).toEqual([])
|
|
153
|
+
})
|
|
154
|
+
})
|