@pmelab/gtd 15.8.0 → 15.10.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/README.md +31 -14
- package/dist/gtd.bundle.mjs +613 -274
- package/package.json +1 -1
- package/src/workflows/prose.ts +5 -4
- package/src/workflows/review.test.ts +247 -0
- package/src/workflows/review.ts +125 -47
- package/src/workflows/steps.test.ts +39 -0
- package/src/workflows/steps.ts +38 -6
- package/src/workflows/text.fixture.ts +40 -42
- package/src/workflows/text.ts +91 -21
package/package.json
CHANGED
package/src/workflows/prose.ts
CHANGED
|
@@ -65,10 +65,11 @@ judgement call: the grouping is already decided.`
|
|
|
65
65
|
|
|
66
66
|
export const reviewerPersona = `You are the independent reviewing mind in gtd's build pipeline —
|
|
67
67
|
deliberately separate from whoever wrote the code, with no attachment
|
|
68
|
-
to it.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
and
|
|
68
|
+
to it. Write a structured review document grouping a diff into
|
|
69
|
+
chunks; classify a round of the human's feedback as actionable or
|
|
70
|
+
just approving; answer the human's questions inline in the review;
|
|
71
|
+
and, when asked, fix the small nits the human flagged in one batch.
|
|
72
|
+
Beyond that you never fix or build anything yourself.`
|
|
72
73
|
|
|
73
74
|
export const specReviewerPersona = `You are the adversarial spec-conformance checker in gtd's build
|
|
74
75
|
pipeline, checking one freshly-built package against its spec. Verify
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { installContext, type Change, type JudgeAnswer, type StepRequest } from "../flows/index.js"
|
|
3
|
+
import { review, type ReviewOutcome } from "./review.js"
|
|
4
|
+
import { fixtureContext } from "./text.fixture.js"
|
|
5
|
+
|
|
6
|
+
afterEach(() => installContext(undefined))
|
|
7
|
+
|
|
8
|
+
const REVIEW = ".gtd/REVIEW.md"
|
|
9
|
+
const REQUIREMENTS = ".gtd/REQUIREMENTS.md"
|
|
10
|
+
|
|
11
|
+
class Stop extends Error {}
|
|
12
|
+
|
|
13
|
+
const doc = (notes: readonly string[]): string =>
|
|
14
|
+
[
|
|
15
|
+
"# Review: abc1234",
|
|
16
|
+
"",
|
|
17
|
+
"<!-- base: 0000000000000000000000000000000000000000 -->",
|
|
18
|
+
"",
|
|
19
|
+
"## calc",
|
|
20
|
+
...notes.flatMap((note, i) => [`- [ ] ./src/calc.ts#${i + 1}-${i + 1}`, note]),
|
|
21
|
+
"",
|
|
22
|
+
].join("\n")
|
|
23
|
+
|
|
24
|
+
const BEFORE = doc(["add", "sub", "mul"])
|
|
25
|
+
|
|
26
|
+
interface Drive {
|
|
27
|
+
readonly notes: readonly string[]
|
|
28
|
+
readonly answers?: Readonly<Record<string, JudgeAnswer>>
|
|
29
|
+
readonly truncated?: readonly string[]
|
|
30
|
+
readonly vars?: Readonly<Record<string, string>>
|
|
31
|
+
/** The REVIEW.md the human leaves; defaults to `notes` laid over the baseline. */
|
|
32
|
+
readonly after?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Run {
|
|
36
|
+
readonly log: string[]
|
|
37
|
+
readonly prompts: Map<string, string>
|
|
38
|
+
readonly result: ReviewOutcome | "stopped"
|
|
39
|
+
readonly judged: readonly string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Run `review` against a scripted context; a second pass at the gate or a fresh review stops the run. */
|
|
43
|
+
const drive = async (d: Drive): Promise<Run> => {
|
|
44
|
+
const log: string[] = []
|
|
45
|
+
const prompts = new Map<string, string>()
|
|
46
|
+
const judged: string[] = []
|
|
47
|
+
const files = new Map<string, string>([[REVIEW, d.after ?? doc(d.notes)]])
|
|
48
|
+
let steps = 0
|
|
49
|
+
let nitsFixed = false
|
|
50
|
+
let awaits = 0
|
|
51
|
+
let reviews = 0
|
|
52
|
+
const effects: Record<string, () => void> = {
|
|
53
|
+
"review.reviewing": () => {
|
|
54
|
+
if (++reviews > 1) throw new Stop()
|
|
55
|
+
},
|
|
56
|
+
"review.await-review": () => {
|
|
57
|
+
if (++awaits > 1) throw new Stop()
|
|
58
|
+
},
|
|
59
|
+
"review.closing": () => void files.delete(REVIEW),
|
|
60
|
+
"review.collecting": () => void files.set(REQUIREMENTS, "## Concern"),
|
|
61
|
+
"review.fix-nits": () => {
|
|
62
|
+
nitsFixed = true
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
const context = fixtureContext(
|
|
66
|
+
{ vars: d.vars ?? {} },
|
|
67
|
+
{
|
|
68
|
+
step: (request: StepRequest) => {
|
|
69
|
+
steps++
|
|
70
|
+
if (request.kind === "restart") return Promise.resolve()
|
|
71
|
+
log.push(request.name)
|
|
72
|
+
if (request.kind === "agent") prompts.set(request.name, request.prompt)
|
|
73
|
+
effects[request.name]?.()
|
|
74
|
+
if (request.kind !== "judge") return Promise.resolve()
|
|
75
|
+
judged.push(...request.questions.map((q) => q.id))
|
|
76
|
+
return Promise.resolve({ answers: d.answers ?? {}, truncated: d.truncated ?? [] })
|
|
77
|
+
},
|
|
78
|
+
refuse: (message: string): never => {
|
|
79
|
+
throw new Error(message)
|
|
80
|
+
},
|
|
81
|
+
pushScope: () => undefined,
|
|
82
|
+
popScope: () => undefined,
|
|
83
|
+
read: (path) => files.get(path),
|
|
84
|
+
changesSince: (): readonly Change[] => [
|
|
85
|
+
{ path: REVIEW, status: "modified", before: BEFORE, after: files.get(REVIEW) },
|
|
86
|
+
...(nitsFixed
|
|
87
|
+
? [{ path: "src/calc.ts", status: "modified" as const, before: "a", after: "b" }]
|
|
88
|
+
: []),
|
|
89
|
+
],
|
|
90
|
+
head: () => `c${steps}`,
|
|
91
|
+
start: () => "c0",
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
installContext(context)
|
|
95
|
+
let result: ReviewOutcome | "stopped"
|
|
96
|
+
try {
|
|
97
|
+
result = await review("base")
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof Stop)) throw error
|
|
100
|
+
result = "stopped"
|
|
101
|
+
}
|
|
102
|
+
return { log, prompts, result, judged }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const verdict = (answer: string, p = 0.9): JudgeAnswer => ({ answer, p })
|
|
106
|
+
|
|
107
|
+
describe("review verdict routing", () => {
|
|
108
|
+
const notes = ["add — typo", "sub", "mul"]
|
|
109
|
+
|
|
110
|
+
it("judges one note per added note, as one judge rest", async () => {
|
|
111
|
+
const run = await drive({ notes: ["add — a", "sub — b", "mul"] })
|
|
112
|
+
expect(run.judged).toEqual(["note-1", "note-2"])
|
|
113
|
+
expect(run.log.filter((n) => n === "review.triage")).toHaveLength(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("an unanswered note counts as edit", async () => {
|
|
117
|
+
const run = await drive({ notes: ["add — typo", "sub", "mul"], answers: {} })
|
|
118
|
+
expect(run.log).toEqual([
|
|
119
|
+
"review.reviewing",
|
|
120
|
+
"review.await-review",
|
|
121
|
+
"review.triage",
|
|
122
|
+
"review.closing",
|
|
123
|
+
"review.collecting",
|
|
124
|
+
])
|
|
125
|
+
expect(run.result).toMatchObject({ verdict: "feedback" })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("a cut note counts as edit despite a confident praise", async () => {
|
|
129
|
+
const run = await drive({
|
|
130
|
+
notes,
|
|
131
|
+
answers: { "note-1": verdict("praise", 0.99) },
|
|
132
|
+
truncated: ["note-1"],
|
|
133
|
+
})
|
|
134
|
+
expect(run.log).toContain("review.collecting")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("a verdict below the reviewNoteActionable floor counts as edit", async () => {
|
|
138
|
+
const run = await drive({ notes, answers: { "note-1": verdict("praise", 0.5) } })
|
|
139
|
+
expect(run.log).toContain("review.collecting")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("a blank floor makes every note an edit", async () => {
|
|
143
|
+
const run = await drive({
|
|
144
|
+
notes,
|
|
145
|
+
answers: { "note-1": verdict("praise", 1) },
|
|
146
|
+
vars: { reviewNoteActionable: "" },
|
|
147
|
+
})
|
|
148
|
+
expect(run.log).toContain("review.collecting")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("only praise closes and signs off with no agent turn", async () => {
|
|
152
|
+
const run = await drive({ notes, answers: { "note-1": verdict("praise") } })
|
|
153
|
+
expect(run.log).toEqual([
|
|
154
|
+
"review.reviewing",
|
|
155
|
+
"review.await-review",
|
|
156
|
+
"review.triage",
|
|
157
|
+
"review.closing",
|
|
158
|
+
])
|
|
159
|
+
expect(run.result).toEqual({ verdict: "signoff" })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("questions only are answered, then the gate again, never closed", async () => {
|
|
163
|
+
const run = await drive({
|
|
164
|
+
notes: ["add — why?", "sub — and here?", "mul"],
|
|
165
|
+
answers: { "note-1": verdict("question"), "note-2": verdict("question") },
|
|
166
|
+
})
|
|
167
|
+
expect(run.log).toEqual([
|
|
168
|
+
"review.reviewing",
|
|
169
|
+
"review.await-review",
|
|
170
|
+
"review.triage",
|
|
171
|
+
"review.answer-review-questions",
|
|
172
|
+
"review.await-review",
|
|
173
|
+
])
|
|
174
|
+
expect(run.prompts.get("review.answer-review-questions")).toContain("note-2")
|
|
175
|
+
expect(run.result).toBe("stopped")
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("nits without edits are fixed, kept green, closed, then re-reviewed with the answers carried", async () => {
|
|
179
|
+
const run = await drive({
|
|
180
|
+
notes: ["add — why?", "sub — typo", "mul"],
|
|
181
|
+
answers: { "note-1": verdict("question"), "note-2": verdict("nit") },
|
|
182
|
+
})
|
|
183
|
+
expect(run.log).toEqual([
|
|
184
|
+
"review.reviewing",
|
|
185
|
+
"review.await-review",
|
|
186
|
+
"review.triage",
|
|
187
|
+
"review.answer-review-questions",
|
|
188
|
+
"review.fix-nits",
|
|
189
|
+
"health.check",
|
|
190
|
+
"review.closing",
|
|
191
|
+
"review.reviewing",
|
|
192
|
+
])
|
|
193
|
+
expect(run.prompts.get("review.fix-nits")).not.toContain("why?")
|
|
194
|
+
expect(run.prompts.get("review.reviewing")).toContain("Carry-over: commit `c4`")
|
|
195
|
+
expect(run.result).toBe("stopped")
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("nits alone carry nothing over", async () => {
|
|
199
|
+
const run = await drive({
|
|
200
|
+
notes: ["add — typo", "sub", "mul"],
|
|
201
|
+
answers: { "note-1": verdict("nit") },
|
|
202
|
+
})
|
|
203
|
+
expect(run.prompts.get("review.reviewing")).not.toContain("Carry-over")
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("edits run after questions and nits, and the capture names only the edits and the answering commit", async () => {
|
|
207
|
+
const run = await drive({
|
|
208
|
+
notes: ["add — rename it", "sub — why?", "mul — typo"],
|
|
209
|
+
answers: { "note-2": verdict("question"), "note-3": verdict("nit") },
|
|
210
|
+
})
|
|
211
|
+
expect(run.log).toEqual([
|
|
212
|
+
"review.reviewing",
|
|
213
|
+
"review.await-review",
|
|
214
|
+
"review.triage",
|
|
215
|
+
"review.answer-review-questions",
|
|
216
|
+
"review.fix-nits",
|
|
217
|
+
"health.check",
|
|
218
|
+
"review.closing",
|
|
219
|
+
"review.collecting",
|
|
220
|
+
])
|
|
221
|
+
const capture = run.prompts.get("review.collecting") ?? ""
|
|
222
|
+
expect(capture).toContain("note-1")
|
|
223
|
+
expect(capture).toContain("rename it")
|
|
224
|
+
expect(capture).not.toContain("note-2 —")
|
|
225
|
+
expect(capture).not.toContain("note-3 —")
|
|
226
|
+
expect(capture).toContain("commit c4 answered")
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("a noted round with no extractable note collects as one edit, without judging", async () => {
|
|
230
|
+
const run = await drive({ notes: [], after: BEFORE + "\nSome stray prose.\n" })
|
|
231
|
+
expect(run.log).toEqual([
|
|
232
|
+
"review.reviewing",
|
|
233
|
+
"review.await-review",
|
|
234
|
+
"review.closing",
|
|
235
|
+
"review.collecting",
|
|
236
|
+
])
|
|
237
|
+
expect(run.prompts.get("review.collecting")).toContain("The human's notes are in")
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("never counts a nit fix as a hand-edit", async () => {
|
|
241
|
+
const run = await drive({
|
|
242
|
+
notes: ["add — rename it", "sub — typo", "mul"],
|
|
243
|
+
answers: { "note-2": verdict("nit") },
|
|
244
|
+
})
|
|
245
|
+
expect(run.result).toMatchObject({ verdict: "feedback", edited: [] })
|
|
246
|
+
})
|
|
247
|
+
})
|
package/src/workflows/review.ts
CHANGED
|
@@ -13,17 +13,18 @@ import {
|
|
|
13
13
|
requireThreadsClosed,
|
|
14
14
|
run,
|
|
15
15
|
scope,
|
|
16
|
-
sectionBodies,
|
|
17
16
|
vars,
|
|
18
17
|
type Change,
|
|
19
18
|
type JudgeQuestion,
|
|
20
19
|
} from "../flows/index.js"
|
|
21
|
-
import { stripCodeThreads } from "../steering/index.js"
|
|
20
|
+
import { reviewNotes, stripCodeThreads, type ReviewNote } from "../steering/index.js"
|
|
22
21
|
import { escalation, FIX_CAP, healthy, type EscalationCount } from "./health.js"
|
|
23
22
|
import {
|
|
23
|
+
answerReviewQuestions,
|
|
24
24
|
awaitReview,
|
|
25
25
|
collecting,
|
|
26
26
|
fix,
|
|
27
|
+
fixNits,
|
|
27
28
|
fixQuality,
|
|
28
29
|
QUALITY,
|
|
29
30
|
REQUIREMENTS,
|
|
@@ -70,36 +71,43 @@ export type ReviewOutcome =
|
|
|
70
71
|
readonly edited: readonly Change[]
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
74
|
+
type Verdict = "edit" | "question" | "nit" | "praise"
|
|
75
|
+
|
|
76
|
+
const VERDICTS: readonly Verdict[] = ["edit", "question", "nit", "praise"]
|
|
77
|
+
|
|
78
|
+
/** One four-way choice per note the human added to `.gtd/REVIEW.md`. */
|
|
79
|
+
const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
|
|
80
|
+
id: note.id,
|
|
81
|
+
primitive: "choice",
|
|
82
|
+
instructions: `Classify the human's note on review item "${note.anchor}" (in .gtd/REVIEW.md). Its evidence holds the anchor, the reviewer's text and the human's text.`,
|
|
83
|
+
criteria:
|
|
84
|
+
"edit: a request to change behaviour, design or scope — anything needing a plan, or any note you are unsure about. question: asks something and wants an answer, with no change requested. nit: a small, local, unambiguous fix (naming, typo, formatting, comment wording) needing no re-plan. praise: an approving remark with nothing to do.",
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One judge rest, one verdict per note. Dismissing a note is the risky
|
|
89
|
+
* direction, so only a confident non-`edit` verdict on uncut evidence counts;
|
|
90
|
+
* a cut, unanswered or below-floor note is an `edit`, and a blank floor makes
|
|
91
|
+
* every note one.
|
|
92
|
+
*/
|
|
93
|
+
const triage = async (notes: readonly ReviewNote[]): Promise<ReadonlyMap<string, Verdict>> => {
|
|
88
94
|
const { answers, truncated } = await judge("review.triage", {
|
|
89
|
-
questions:
|
|
90
|
-
evidence
|
|
95
|
+
questions: notes.map(verdictQuestion),
|
|
96
|
+
evidence: Object.fromEntries(
|
|
97
|
+
notes.map((n) => [
|
|
98
|
+
n.id,
|
|
99
|
+
`Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`,
|
|
100
|
+
]),
|
|
101
|
+
),
|
|
91
102
|
message: t.buildReviewTriageMessage(),
|
|
92
|
-
label: "Judging
|
|
103
|
+
label: "Judging each note",
|
|
93
104
|
})
|
|
94
|
-
// Dismissing a note is the risky direction, so only a confident "no" on
|
|
95
|
-
// uncut evidence dismisses a chunk; a blank floor dismisses nothing.
|
|
96
105
|
const minP = numeric(vars.reviewNoteActionable, Infinity)
|
|
97
|
-
return (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
})
|
|
106
|
+
return new Map(
|
|
107
|
+
notes.map((n): [string, Verdict] => {
|
|
108
|
+
const verdict = VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
|
|
109
|
+
return [n.id, truncated.includes(n.id) || verdict === undefined ? "edit" : verdict]
|
|
110
|
+
}),
|
|
103
111
|
)
|
|
104
112
|
}
|
|
105
113
|
|
|
@@ -140,8 +148,8 @@ const notedSince = (commit: string): boolean =>
|
|
|
140
148
|
* commit of the last `review.collecting` turn, `"missing"` when the round
|
|
141
149
|
* left no review, or `undefined` when no such turn ran.
|
|
142
150
|
*/
|
|
143
|
-
const converse = async (base: string): Promise<string | undefined> => {
|
|
144
|
-
let collectedAt
|
|
151
|
+
const converse = async (base: string, collectedBefore?: string): Promise<string | undefined> => {
|
|
152
|
+
let collectedAt = collectedBefore
|
|
145
153
|
for (;;) {
|
|
146
154
|
await awaitReview(base)
|
|
147
155
|
if (changes(REVIEW).some((c) => c.status === "deleted")) {
|
|
@@ -180,41 +188,111 @@ const settledAfter = (collectedAt: string | undefined): { folded: boolean; settl
|
|
|
180
188
|
!notedSince(collectedAt),
|
|
181
189
|
})
|
|
182
190
|
|
|
191
|
+
/** What `finish` hands back to `review`: an outcome, or another lap at the gate. */
|
|
192
|
+
type Finish =
|
|
193
|
+
| ReviewOutcome
|
|
194
|
+
| { readonly next: "await"; readonly reviewed: string }
|
|
195
|
+
| { readonly next: "rereview"; readonly carry: string | undefined }
|
|
196
|
+
|
|
197
|
+
/** What a round's routing needs from `finish`: its closing step and the ways it ends. */
|
|
198
|
+
interface Round {
|
|
199
|
+
readonly round: string
|
|
200
|
+
readonly escalations: EscalationCount
|
|
201
|
+
readonly close: () => Promise<void>
|
|
202
|
+
readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome
|
|
203
|
+
readonly unfolded: () => Promise<Finish>
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
|
|
207
|
+
const routeNotes = async (notes: readonly ReviewNote[], r: Round): Promise<Finish> => {
|
|
208
|
+
const verdicts = await triage(notes)
|
|
209
|
+
const of = (v: Verdict): ReviewNote[] => notes.filter((n) => verdicts.get(n.id) === v)
|
|
210
|
+
const [edits, questions, nits] = [of("edit"), of("question"), of("nit")]
|
|
211
|
+
if (edits.length + questions.length + nits.length === 0) return r.unfolded()
|
|
212
|
+
|
|
213
|
+
let answeredAt: string | undefined
|
|
214
|
+
if (questions.length > 0) {
|
|
215
|
+
await answerReviewQuestions(questions)
|
|
216
|
+
answeredAt = head()
|
|
217
|
+
}
|
|
218
|
+
if (nits.length > 0) {
|
|
219
|
+
await fixNits(nits)
|
|
220
|
+
await healthy(fix, { escalations: r.escalations })
|
|
221
|
+
}
|
|
222
|
+
if (edits.length > 0) {
|
|
223
|
+
await r.close()
|
|
224
|
+
return r.outcome(await collect(t.reviewEditNotesCapture(r.round, edits, answeredAt)))
|
|
225
|
+
}
|
|
226
|
+
if (nits.length > 0) {
|
|
227
|
+
await r.close()
|
|
228
|
+
return { next: "rereview", carry: answeredAt }
|
|
229
|
+
}
|
|
230
|
+
return { next: "await", reviewed: answeredAt! }
|
|
231
|
+
}
|
|
232
|
+
|
|
183
233
|
const finish = async (
|
|
184
234
|
reviewed: string,
|
|
185
235
|
collectedAt: string | undefined,
|
|
186
|
-
|
|
236
|
+
escalations: EscalationCount,
|
|
237
|
+
): Promise<Finish> => {
|
|
238
|
+
// Everything the human did is read before any agent turn runs, so a nit fix
|
|
239
|
+
// is never counted as a hand-edit.
|
|
187
240
|
const round = head()
|
|
188
241
|
const since = changesSince(reviewed)
|
|
189
242
|
const edited = since.filter(isCodeEdit)
|
|
243
|
+
const baselineText = since.get(REVIEW)?.before ?? ""
|
|
190
244
|
const record = read(REVIEW) ?? ""
|
|
191
245
|
const { folded, settled } = afterCollect(collectedAt)
|
|
192
246
|
// The review gate clears every tick before its commit, so a changed
|
|
193
247
|
// REVIEW.md is a note, never a tick.
|
|
194
248
|
const noted = since.some((c) => c.path === REVIEW)
|
|
249
|
+
const close = (): Promise<void> =>
|
|
250
|
+
run("review.closing", removeScript([REVIEW]), { label: "Closing the review", base: round })
|
|
195
251
|
const outcome = (verdict: "signoff" | "feedback"): ReviewOutcome =>
|
|
196
252
|
verdict === "signoff" ? { verdict } : { verdict, base: round, restoreFrom: reviewed, edited }
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
253
|
+
const unfolded = async (): Promise<Finish> => {
|
|
254
|
+
await close()
|
|
255
|
+
return outcome(folded ? "feedback" : "signoff")
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const collectWith = async (capture: string): Promise<Finish> => {
|
|
259
|
+
await close()
|
|
260
|
+
return outcome(await collect(capture))
|
|
261
|
+
}
|
|
262
|
+
if (settled || (edited.length === 0 && !noted)) return unfolded()
|
|
263
|
+
if (edited.length > 0) return collectWith(t.reviewEditsCapture(round))
|
|
264
|
+
const notes = reviewNotes(baselineText, record)
|
|
265
|
+
if (notes.length === 0) return collectWith(t.reviewNotesCapture(round))
|
|
266
|
+
return routeNotes(notes, { round, escalations, close, outcome, unfolded })
|
|
205
267
|
}
|
|
206
268
|
|
|
207
269
|
/**
|
|
208
270
|
* A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
|
|
209
|
-
* reviews and signs off or comments, and
|
|
210
|
-
*
|
|
271
|
+
* reviews and signs off or comments, and each note is judged: edits go to
|
|
272
|
+
* another planning lap, questions are answered in place, nits fixed in one
|
|
273
|
+
* batch, praise dropped.
|
|
211
274
|
*/
|
|
212
|
-
export const review = async (
|
|
275
|
+
export const review = async (
|
|
276
|
+
base: string,
|
|
277
|
+
escalations: EscalationCount = { rounds: 0 },
|
|
278
|
+
): Promise<ReviewOutcome> => {
|
|
279
|
+
let carry: string | undefined
|
|
213
280
|
for (;;) {
|
|
214
|
-
await reviewing(base)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
281
|
+
await reviewing(base, carry)
|
|
282
|
+
carry = undefined
|
|
283
|
+
let reviewed = head()
|
|
284
|
+
let collectedAt: string | undefined
|
|
285
|
+
for (;;) {
|
|
286
|
+
collectedAt = await converse(base, collectedAt)
|
|
287
|
+
if (collectedAt === "missing") break
|
|
288
|
+
const result = await finish(reviewed, collectedAt, escalations)
|
|
289
|
+
if (!("next" in result)) return result
|
|
290
|
+
if (result.next === "rereview") {
|
|
291
|
+
carry = result.carry
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
reviewed = result.reviewed
|
|
295
|
+
}
|
|
218
296
|
}
|
|
219
297
|
}
|
|
220
298
|
|
|
@@ -233,7 +311,7 @@ export const buildTail = (fixFirst: boolean, base: string): Promise<ReviewOutcom
|
|
|
233
311
|
}
|
|
234
312
|
const lap = lapped ? "clean" : await qualityLap()
|
|
235
313
|
lapped = true
|
|
236
|
-
if (lap === "clean") return review(base)
|
|
314
|
+
if (lap === "clean") return review(base, escalations)
|
|
237
315
|
if (await fixQualityFindings(escalations)) await healthy(fix, { escalations })
|
|
238
316
|
else redFirst = true
|
|
239
317
|
}
|
|
@@ -104,4 +104,43 @@ describe("the bundled workflow's steps declare skills", () => {
|
|
|
104
104
|
expect(request.options.skills).toEqual([])
|
|
105
105
|
expect(request.prompt).not.toContain("Load whatever's listed here")
|
|
106
106
|
})
|
|
107
|
+
|
|
108
|
+
it("answerReviewQuestions answers inline in .gtd/REVIEW.md as the reviewer, with reviewSkills", async () => {
|
|
109
|
+
const request = await capture(() =>
|
|
110
|
+
steps.answerReviewQuestions([{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "why?" }]),
|
|
111
|
+
)
|
|
112
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
113
|
+
expect(request.name).toBe("review.answer-review-questions")
|
|
114
|
+
expect(request.options).toMatchObject({
|
|
115
|
+
file: ".gtd/REVIEW.md",
|
|
116
|
+
mode: "review",
|
|
117
|
+
skills: ["code-review-and-quality"],
|
|
118
|
+
})
|
|
119
|
+
expect(request.prompt).toContain("note-1 — calc ./a.ts#1-1")
|
|
120
|
+
expect(request.prompt).toContain("`A: ` line")
|
|
121
|
+
expect(request.prompt).toContain("gtd check review .gtd/REVIEW.md")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("fixNits fixes every nit in one turn and leaves .gtd/REVIEW.md alone", async () => {
|
|
125
|
+
const request = await capture(() =>
|
|
126
|
+
steps.fixNits([
|
|
127
|
+
{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "typo" },
|
|
128
|
+
{ id: "note-2", anchor: "calc ./a.ts#2-2", text: "semicolon" },
|
|
129
|
+
]),
|
|
130
|
+
)
|
|
131
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
132
|
+
expect(request.name).toBe("review.fix-nits")
|
|
133
|
+
expect(request.options.skills).toEqual(["incremental-implementation", "code-simplification"])
|
|
134
|
+
expect(request.prompt).toContain("typo")
|
|
135
|
+
expect(request.prompt).toContain("semicolon")
|
|
136
|
+
expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("reviewing names the carry-over commit only when given one", async () => {
|
|
140
|
+
const bare = await capture(() => steps.reviewing("base"))
|
|
141
|
+
const carried = await capture(() => steps.reviewing("base", "abc1234"))
|
|
142
|
+
if (bare.kind !== "agent" || carried.kind !== "agent") throw new Error("unreachable")
|
|
143
|
+
expect(bare.prompt).not.toContain("Carry-over")
|
|
144
|
+
expect(carried.prompt).toContain("Carry-over: commit `abc1234`")
|
|
145
|
+
})
|
|
107
146
|
})
|
package/src/workflows/steps.ts
CHANGED
|
@@ -158,15 +158,47 @@ export const fixQuality = (): Promise<void> =>
|
|
|
158
158
|
allowEmpty: true,
|
|
159
159
|
})
|
|
160
160
|
|
|
161
|
-
/** Write `.gtd/REVIEW.md` over everything since `base
|
|
162
|
-
export const reviewing = (base: string): Promise<void> =>
|
|
163
|
-
t.agentWithSkills(
|
|
164
|
-
|
|
161
|
+
/** Write `.gtd/REVIEW.md` over everything since `base`, carrying the answers of commit `carry` when given. */
|
|
162
|
+
export const reviewing = (base: string, carry?: string): Promise<void> =>
|
|
163
|
+
t.agentWithSkills(
|
|
164
|
+
"review.reviewing",
|
|
165
|
+
vars.reviewSkills,
|
|
166
|
+
t.buildReviewReviewingPrompt(base, carry),
|
|
167
|
+
{
|
|
168
|
+
label: "Reviewing",
|
|
169
|
+
file: REVIEW,
|
|
170
|
+
mode: "review",
|
|
171
|
+
model: planner(),
|
|
172
|
+
system: t.reviewerSystem(),
|
|
173
|
+
base,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
/** Answer every `question` note inline in `.gtd/REVIEW.md`. */
|
|
178
|
+
export const answerReviewQuestions = (notes: readonly t.NoteInput[]): Promise<void> =>
|
|
179
|
+
t.agentWithSkills(
|
|
180
|
+
"review.answer-review-questions",
|
|
181
|
+
vars.reviewSkills,
|
|
182
|
+
t.buildReviewAnswerQuestionsPrompt(notes),
|
|
183
|
+
{
|
|
184
|
+
label: "Answering your questions",
|
|
185
|
+
file: REVIEW,
|
|
186
|
+
mode: "review",
|
|
187
|
+
model: planner(),
|
|
188
|
+
system: t.reviewerSystem(),
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Fix every `nit` note in one turn. Its name puts it in the `build.review`
|
|
194
|
+
* conversation, which has one identity — so it runs as the reviewer, not the coder.
|
|
195
|
+
*/
|
|
196
|
+
export const fixNits = (notes: readonly t.NoteInput[]): Promise<void> =>
|
|
197
|
+
t.agentWithSkills("review.fix-nits", vars.reviewFixSkills, t.buildReviewFixNitsPrompt(notes), {
|
|
198
|
+
label: "Fixing your nits",
|
|
165
199
|
file: REVIEW,
|
|
166
|
-
mode: "review",
|
|
167
200
|
model: planner(),
|
|
168
201
|
system: t.reviewerSystem(),
|
|
169
|
-
base,
|
|
170
202
|
})
|
|
171
203
|
|
|
172
204
|
export const awaitReview = (base: string): Promise<void> =>
|