@pmelab/gtd 28.2.0 → 29.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -4
- 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 -3
- package/src/workflows/shared/text.ts +183 -69
- package/src/workflows/shared/vars.ts +12 -2
|
@@ -3,13 +3,17 @@ import {
|
|
|
3
3
|
head,
|
|
4
4
|
judge,
|
|
5
5
|
numeric,
|
|
6
|
+
requireRevert,
|
|
7
|
+
restoreScript,
|
|
8
|
+
run,
|
|
6
9
|
vars,
|
|
7
10
|
type JudgeQuestion,
|
|
8
11
|
type Judgment,
|
|
12
|
+
type ScopeAccess,
|
|
9
13
|
} from "../../flows/index.js"
|
|
10
14
|
import type { ReviewNote } from "../../steering/index.js"
|
|
11
15
|
import * as kit from "../../workflow-kit/index.js"
|
|
12
|
-
import { healthy, type EscalationCount } from "../shared/health.js"
|
|
16
|
+
import { guarded, healthy, type EscalationCount, type Guard } from "../shared/health.js"
|
|
13
17
|
import {
|
|
14
18
|
awaitReview,
|
|
15
19
|
fixCheck,
|
|
@@ -165,21 +169,29 @@ const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".
|
|
|
165
169
|
export const review = (
|
|
166
170
|
base: string,
|
|
167
171
|
escalations: EscalationCount = { rounds: 0 },
|
|
172
|
+
options: BuildTailOptions = {},
|
|
168
173
|
): Promise<kit.ReviewOutcome> => {
|
|
174
|
+
const { guard, guardRests, fixAccess, rules } = options
|
|
175
|
+
const feedback: FeedbackTarget = options.feedback ?? {
|
|
176
|
+
file: REQUIREMENTS,
|
|
177
|
+
collecting,
|
|
178
|
+
unmatched:
|
|
179
|
+
"gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
|
|
180
|
+
}
|
|
169
181
|
const severities = new Map<string, Severity>()
|
|
170
182
|
// Armed by a defer-only round to pick the next gate's message; a fresh
|
|
171
183
|
// review (`reviewing`) disarms it.
|
|
172
184
|
let deferred: readonly t.NoteInput[] = []
|
|
173
185
|
return kit.review(base, {
|
|
174
186
|
file: REVIEW,
|
|
175
|
-
feedback:
|
|
187
|
+
feedback: feedback.file,
|
|
176
188
|
isCode,
|
|
177
189
|
reviewing: (b, carry) => {
|
|
178
190
|
deferred = []
|
|
179
|
-
return reviewing(b, carry)
|
|
191
|
+
return reviewing(b, carry, options.lap)
|
|
180
192
|
},
|
|
181
|
-
fixRisks,
|
|
182
|
-
keepGreen: () => healthy(fixCheck, { escalations }),
|
|
193
|
+
fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
|
|
194
|
+
keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
|
|
183
195
|
awaitReview: (b) => {
|
|
184
196
|
const notes = deferred
|
|
185
197
|
deferred = []
|
|
@@ -188,7 +200,7 @@ export const review = (
|
|
|
188
200
|
: awaitReview(b)
|
|
189
201
|
},
|
|
190
202
|
reviewMissing,
|
|
191
|
-
collecting,
|
|
203
|
+
collecting: feedback.collecting,
|
|
192
204
|
triage: triage(severities),
|
|
193
205
|
defer: (notes) => {
|
|
194
206
|
deferred = notes.map((n) => ({
|
|
@@ -200,7 +212,7 @@ export const review = (
|
|
|
200
212
|
return deferNotes(deferred, head())
|
|
201
213
|
},
|
|
202
214
|
answerQuestions: answerReviewQuestions,
|
|
203
|
-
fixNits,
|
|
215
|
+
fixNits: guarded((notes) => fixNits(notes, fixAccess, rules), guard),
|
|
204
216
|
close: closeReview,
|
|
205
217
|
captures: {
|
|
206
218
|
edits: t.reviewEditsCapture,
|
|
@@ -229,15 +241,70 @@ export const review = (
|
|
|
229
241
|
refusals: {
|
|
230
242
|
deleted:
|
|
231
243
|
"gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
|
|
232
|
-
unmatched:
|
|
233
|
-
|
|
244
|
+
unmatched: feedback.unmatched,
|
|
245
|
+
...(feedback.dropped === undefined ? {} : { dropped: feedback.dropped }),
|
|
234
246
|
},
|
|
235
247
|
})
|
|
236
248
|
}
|
|
237
249
|
|
|
250
|
+
export interface FeedbackTarget {
|
|
251
|
+
readonly file: string
|
|
252
|
+
readonly collecting: (capture: string, round: kit.CollectRound) => Promise<void>
|
|
253
|
+
/** Refusal when the collecting turn changed something but not `file`. */
|
|
254
|
+
readonly unmatched: string
|
|
255
|
+
readonly dropped?: (requested: kit.Requested) => string
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface BuildTailOptions {
|
|
259
|
+
/** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
|
|
260
|
+
readonly guard?: Guard
|
|
261
|
+
/** `false`: `guard` skips the escalation's human rests. */
|
|
262
|
+
readonly guardRests?: boolean
|
|
263
|
+
/** Quality-lap lenses; default `lensesOf(vars.qualityReviews)`. */
|
|
264
|
+
readonly lenses?: readonly string[]
|
|
265
|
+
readonly feedback?: FeedbackTarget
|
|
266
|
+
/** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
|
|
267
|
+
readonly fixAccess?: ScopeAccess
|
|
268
|
+
/** The commit the quality lap reviews from; absent: `start()`. */
|
|
269
|
+
readonly lensBase?: string
|
|
270
|
+
/** Appended to every fix and describe prompt of the tail. */
|
|
271
|
+
readonly rules?: string
|
|
272
|
+
/** Absent: "first". */
|
|
273
|
+
readonly lap?: t.ReviewRange
|
|
274
|
+
}
|
|
275
|
+
|
|
238
276
|
/** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
|
|
239
277
|
export const buildTail = (
|
|
240
278
|
fixFirst: boolean,
|
|
241
279
|
base: string,
|
|
242
280
|
green = false,
|
|
243
|
-
|
|
281
|
+
options: BuildTailOptions = {},
|
|
282
|
+
): Promise<kit.ReviewOutcome> =>
|
|
283
|
+
tail(
|
|
284
|
+
fixFirst,
|
|
285
|
+
(escalations) => review(base, escalations, options),
|
|
286
|
+
green,
|
|
287
|
+
options.guard,
|
|
288
|
+
options.lenses,
|
|
289
|
+
options.guardRests,
|
|
290
|
+
options.lensBase,
|
|
291
|
+
options.rules,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Undo the human's review-round code edits. A path changed since the human
|
|
296
|
+
* left it is not overwritten; requireRevert names it instead.
|
|
297
|
+
*/
|
|
298
|
+
export const reUnwind = async (
|
|
299
|
+
feedback: Extract<kit.ReviewOutcome, { verdict: "feedback" }>,
|
|
300
|
+
): Promise<void> => {
|
|
301
|
+
const { base, restoreFrom, edited } = feedback
|
|
302
|
+
const restore = edited.filter((c) => c.status !== "added").map((c) => c.path)
|
|
303
|
+
const remove = edited.filter((c) => c.status === "added").map((c) => c.path)
|
|
304
|
+
await run("re-unwind", restoreScript(base, { restore, remove }, restoreFrom), {
|
|
305
|
+
label: "Re-unwinding your review edit",
|
|
306
|
+
file: REVIEW,
|
|
307
|
+
base,
|
|
308
|
+
})
|
|
309
|
+
requireRevert(edited, base)
|
|
310
|
+
}
|
|
@@ -143,4 +143,8 @@ Feature: rejected naive passes walk one fixed path through the steer gate to idl
|
|
|
143
143
|
And the commit "gtd(judge): seam.judge → seam.steer" has a body containing "\"answer\":\"reject\""
|
|
144
144
|
And the commit "gtd(judge): seam.judge → implement.building" has a body containing "\"answer\":\"land\""
|
|
145
145
|
And no commit body contains "Gtd-Approved:"
|
|
146
|
+
And the output lines of gtd "slices" start with:
|
|
147
|
+
"""
|
|
148
|
+
feature
|
|
149
|
+
"""
|
|
146
150
|
And no commit body contains "Gtd-Workflow:"
|
|
@@ -222,7 +222,7 @@ Feature: the naive pass and seam judge replace architecture behind GTD_FLAG_NAIV
|
|
|
222
222
|
gtd(human): seam.steer → implement.building
|
|
223
223
|
"""
|
|
224
224
|
|
|
225
|
-
Scenario: review feedback re-runs design and the
|
|
225
|
+
Scenario: review feedback after land re-runs design and implement, never the seam phase
|
|
226
226
|
Given step "seam.naive" writes "src/calc.ts" with:
|
|
227
227
|
"""
|
|
228
228
|
export const add = (a: number, b: number) => b + a
|
|
@@ -311,10 +311,9 @@ Feature: the naive pass and seam judge replace architecture behind GTD_FLAG_NAIV
|
|
|
311
311
|
Then the process rests at "implement.building"
|
|
312
312
|
And the commit subjects end with:
|
|
313
313
|
"""
|
|
314
|
+
gtd(check): build.review.closing → re-unwind
|
|
314
315
|
gtd(check): re-unwind → design.triage
|
|
315
316
|
gtd(agent): design.triage → design.gate.answer
|
|
316
|
-
gtd(human): design.gate.answer →
|
|
317
|
-
gtd(agent): seam.naive → seam.judge
|
|
318
|
-
gtd(judge): seam.judge → implement.building
|
|
317
|
+
gtd(human): design.gate.answer → implement.building
|
|
319
318
|
gtd(agent): implement.building
|
|
320
319
|
"""
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../../flows/index.js"
|
|
9
9
|
import { globMatches } from "../../wire/index.js"
|
|
10
10
|
import { fixtureContext } from "../shared/text.fixture.js"
|
|
11
|
-
import { judgeSeam, naivePass } from "./seam.js"
|
|
11
|
+
import { implement, judgeSeam, naivePass, seamBuild, type SeamOutcome } from "./seam.js"
|
|
12
12
|
import { captureStep } from "../shared/text.fixture.js"
|
|
13
13
|
|
|
14
14
|
afterEach(() => installContext(undefined))
|
|
@@ -86,3 +86,198 @@ describe("naivePass", () => {
|
|
|
86
86
|
expect(request.prompt).toContain("0.8")
|
|
87
87
|
})
|
|
88
88
|
})
|
|
89
|
+
|
|
90
|
+
const REVIEW = ".gtd/REVIEW.md"
|
|
91
|
+
const REFACTOR = ".gtd/REFACTOR.md"
|
|
92
|
+
const reviewDoc = [
|
|
93
|
+
"# Review: abc1234",
|
|
94
|
+
"",
|
|
95
|
+
"<!-- base: 0000000000000000000000000000000000000000 -->",
|
|
96
|
+
"",
|
|
97
|
+
"## calc",
|
|
98
|
+
"- [ ] ./src/a.ts#1-1",
|
|
99
|
+
"add",
|
|
100
|
+
"",
|
|
101
|
+
].join("\n")
|
|
102
|
+
|
|
103
|
+
interface Seamed {
|
|
104
|
+
readonly names: string[]
|
|
105
|
+
readonly slices: string[]
|
|
106
|
+
/** Slice names entered, with the steps seen while inside each. */
|
|
107
|
+
readonly inside: Record<string, string[]>
|
|
108
|
+
readonly outcome: SeamOutcome | Error
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Runs `seamBuild` against a scripted context: the judge answers `answer`, every review signs off. */
|
|
112
|
+
const driveSeam = async (
|
|
113
|
+
answer: string,
|
|
114
|
+
stray: Change = change("src/a.ts", "modified"),
|
|
115
|
+
vars: Record<string, string> = {},
|
|
116
|
+
): Promise<Seamed> => {
|
|
117
|
+
const names: string[] = []
|
|
118
|
+
const slices: string[] = []
|
|
119
|
+
const inside: Record<string, string[]> = {}
|
|
120
|
+
const stack: string[] = []
|
|
121
|
+
const open: string[] = []
|
|
122
|
+
const files = new Map<string, string>([["src/a.ts", "a"]])
|
|
123
|
+
let current = "c0"
|
|
124
|
+
const effects: Record<string, () => void> = {
|
|
125
|
+
deriving: () => void files.set(REFACTOR, "package"),
|
|
126
|
+
closing: () => void files.delete(REFACTOR),
|
|
127
|
+
"review.reviewing": () => void files.set(REVIEW, reviewDoc),
|
|
128
|
+
"review.closing": () => {
|
|
129
|
+
files.delete(REVIEW)
|
|
130
|
+
current = "after-refactor"
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
installContext({
|
|
134
|
+
...fixtureContext({ vars: { refactorQualityReviews: "", ...vars } }),
|
|
135
|
+
matches: globMatches,
|
|
136
|
+
refuse: (message: string) => {
|
|
137
|
+
throw new Error(message)
|
|
138
|
+
},
|
|
139
|
+
pushScope: (scope: { name?: string }) => void stack.push(scope.name ?? ""),
|
|
140
|
+
popScope: () => void stack.pop(),
|
|
141
|
+
enterSlice: (name: string) => {
|
|
142
|
+
slices.push(name)
|
|
143
|
+
open.push(name)
|
|
144
|
+
inside[name] = []
|
|
145
|
+
},
|
|
146
|
+
leaveSlice: () => void open.pop(),
|
|
147
|
+
read: (path: string) => files.get(path),
|
|
148
|
+
head: () => current,
|
|
149
|
+
kept: () => tip(stray),
|
|
150
|
+
changes: () => [],
|
|
151
|
+
changesSince: () => [],
|
|
152
|
+
step: (request: StepRequest) => {
|
|
153
|
+
if (request.kind === "restart") return Promise.resolve()
|
|
154
|
+
const full = [...stack, request.name].join(".")
|
|
155
|
+
names.push(full)
|
|
156
|
+
for (const name of open) inside[name]!.push(full)
|
|
157
|
+
effects[request.name]?.()
|
|
158
|
+
if (request.kind === "judge")
|
|
159
|
+
return Promise.resolve({ answers: { shape: { answer, p: 0.9 } }, truncated: [] })
|
|
160
|
+
return Promise.resolve(undefined)
|
|
161
|
+
},
|
|
162
|
+
} as unknown as FlowContext)
|
|
163
|
+
let outcome: SeamOutcome | Error
|
|
164
|
+
try {
|
|
165
|
+
outcome = await seamBuild("c-base")
|
|
166
|
+
} catch (error) {
|
|
167
|
+
outcome = error as Error
|
|
168
|
+
}
|
|
169
|
+
return { names, slices, inside, outcome }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
describe("seamBuild", () => {
|
|
173
|
+
it("land resolves the kept tip with the caller's base and enters no slice", async () => {
|
|
174
|
+
const h = await driveSeam("land")
|
|
175
|
+
expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
|
|
176
|
+
expect(h.slices).toEqual([])
|
|
177
|
+
expect(h.names).toEqual(["seam.naive", "seam.judge"])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it("refactor-first runs the reviewed refactor in slice refactor and resolves HEAD after it", async () => {
|
|
181
|
+
const h = await driveSeam("refactor-first")
|
|
182
|
+
expect(h.outcome).toEqual({ reference: "abc", base: "after-refactor" })
|
|
183
|
+
expect(h.slices).toEqual(["refactor"])
|
|
184
|
+
expect(h.inside.refactor![0]).toBe("refactor.deriving")
|
|
185
|
+
expect(h.inside.refactor).toContain("refactor.build.review.closing")
|
|
186
|
+
expect(h.names.some((n) => n.includes("approve"))).toBe(false)
|
|
187
|
+
expect(h.names.some((n) => n.startsWith("implement"))).toBe(false)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("refactor-first with only test-file strays resolves the caller's base with no slice and no by-hand", async () => {
|
|
191
|
+
const h = await driveSeam("refactor-first", change("src/a.test.ts", "modified"), {
|
|
192
|
+
seamAllow: ".gtd/**",
|
|
193
|
+
})
|
|
194
|
+
expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
|
|
195
|
+
expect(h.slices).toEqual([])
|
|
196
|
+
expect(h.names).not.toContain("seam.by-hand")
|
|
197
|
+
expect(h.names.some((n) => n.startsWith("refactor"))).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("rests an unreadable test-file stray at seam.by-hand", async () => {
|
|
201
|
+
const h = await driveSeam(
|
|
202
|
+
"refactor-first",
|
|
203
|
+
{ path: "src/a.test.ts", status: "modified", before: undefined, after: "b" },
|
|
204
|
+
{ seamAllow: ".gtd/**" },
|
|
205
|
+
)
|
|
206
|
+
expect(h.slices).toEqual([])
|
|
207
|
+
expect(h.names).toContain("seam.by-hand")
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("rests unreadable strays at seam.by-hand and enters no refactor slice", async () => {
|
|
211
|
+
const h = await driveSeam("refactor-first", {
|
|
212
|
+
path: "src/a.ts",
|
|
213
|
+
status: "modified",
|
|
214
|
+
before: undefined,
|
|
215
|
+
after: "b",
|
|
216
|
+
})
|
|
217
|
+
expect(h.slices).toEqual([])
|
|
218
|
+
expect(h.names).toContain("seam.by-hand")
|
|
219
|
+
expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe("implement", () => {
|
|
224
|
+
const building = async (
|
|
225
|
+
reference: string | undefined,
|
|
226
|
+
tier?: "low" | "high",
|
|
227
|
+
amend?: boolean,
|
|
228
|
+
) => {
|
|
229
|
+
const requests: StepRequest[] = []
|
|
230
|
+
installContext(
|
|
231
|
+
fixtureContext(
|
|
232
|
+
{},
|
|
233
|
+
{
|
|
234
|
+
step: (request: StepRequest) => {
|
|
235
|
+
requests.push(request)
|
|
236
|
+
return Promise.resolve()
|
|
237
|
+
},
|
|
238
|
+
pushScope: () => undefined,
|
|
239
|
+
popScope: () => undefined,
|
|
240
|
+
changesSince: () => [],
|
|
241
|
+
},
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
await implement(reference, tier, amend)
|
|
245
|
+
const request = requests.find((r) => r.kind === "agent" && r.name === "building")
|
|
246
|
+
if (request?.kind !== "agent") throw new Error("no building turn")
|
|
247
|
+
return request.prompt
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
it("names the reference with a git show line", async () => {
|
|
251
|
+
expect(await building("abc")).toContain("git show abc")
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("carries no git show line without a reference", async () => {
|
|
255
|
+
expect(await building(undefined)).not.toContain("git show")
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it("first lap renders the reference line and the fresh-write wording", async () => {
|
|
259
|
+
const prompt = await building("abc")
|
|
260
|
+
expect(prompt).toContain("git show abc")
|
|
261
|
+
expect(prompt).toContain("Implement them properly")
|
|
262
|
+
expect(prompt).toContain("write\n the change fresh")
|
|
263
|
+
expect(prompt).toContain("new release flag")
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it("amend says the change exists, keeps the earlier flag and asks for no new one", async () => {
|
|
267
|
+
const prompt = await building(undefined, undefined, true)
|
|
268
|
+
expect(prompt).toContain("already exists")
|
|
269
|
+
expect(prompt).toContain("amend")
|
|
270
|
+
expect(prompt).toContain("Keep the release flag")
|
|
271
|
+
expect(prompt).toContain("if it added one")
|
|
272
|
+
expect(prompt).not.toContain("implement them properly")
|
|
273
|
+
expect(prompt).not.toContain("Implement them properly")
|
|
274
|
+
expect(prompt).not.toContain("write the change fresh")
|
|
275
|
+
expect(prompt).toContain("otherwise put")
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("amend on tier low says to add no release flag", async () => {
|
|
279
|
+
const prompt = await building(undefined, "low", true)
|
|
280
|
+
expect(prompt).toContain("Add no release flag")
|
|
281
|
+
expect(prompt).toContain("amend")
|
|
282
|
+
})
|
|
283
|
+
})
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
changes,
|
|
3
|
+
head,
|
|
3
4
|
human,
|
|
4
5
|
judgeDiffShape,
|
|
5
6
|
kept,
|
|
6
7
|
removeScript,
|
|
7
8
|
run,
|
|
8
9
|
scope,
|
|
9
|
-
|
|
10
|
+
slice,
|
|
10
11
|
vars,
|
|
11
12
|
type DiffShapeResult,
|
|
12
13
|
type KeptTip,
|
|
@@ -15,7 +16,7 @@ import { healthy } from "../shared/health.js"
|
|
|
15
16
|
import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
|
|
16
17
|
import * as t from "../shared/text.js"
|
|
17
18
|
import { defaults } from "../shared/vars.js"
|
|
18
|
-
import {
|
|
19
|
+
import { refactorStrays, reviewedRefactor } from "./refactor.js"
|
|
19
20
|
import type { RiskTier } from "./tier.js"
|
|
20
21
|
|
|
21
22
|
/** Naive-pass retries after a `reject`; the next `reject` rests at `seam.steer`. */
|
|
@@ -40,9 +41,13 @@ export const naivePass = (rejection?: t.SeamRejection): Promise<void> =>
|
|
|
40
41
|
system: t.builderSystem(),
|
|
41
42
|
})
|
|
42
43
|
|
|
43
|
-
export const implement = (
|
|
44
|
+
export const implement = (
|
|
45
|
+
reference: string | undefined,
|
|
46
|
+
tier?: RiskTier,
|
|
47
|
+
amend?: boolean,
|
|
48
|
+
): Promise<void> =>
|
|
44
49
|
scope("implement", async () => {
|
|
45
|
-
await t.agentWithSkills("building", t.implementBuildingPrompt(reference, tier), {
|
|
50
|
+
await t.agentWithSkills("building", t.implementBuildingPrompt(reference, tier, amend), {
|
|
46
51
|
label: "Implementing",
|
|
47
52
|
model: coder(),
|
|
48
53
|
system: t.builderSystem(),
|
|
@@ -74,7 +79,6 @@ const decide = (): Promise<Decision> =>
|
|
|
74
79
|
const shape = await judgeSeam(tip)
|
|
75
80
|
if (shape.choice === "land") return { reference: tip.hash }
|
|
76
81
|
if (shape.choice === "refactor-first") {
|
|
77
|
-
await seamGate("approve", shape)
|
|
78
82
|
return { reference: tip.hash, shape }
|
|
79
83
|
}
|
|
80
84
|
rejection = { strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 }
|
|
@@ -95,10 +99,17 @@ const decide = (): Promise<Decision> =>
|
|
|
95
99
|
}
|
|
96
100
|
})
|
|
97
101
|
|
|
98
|
-
export
|
|
102
|
+
export interface SeamOutcome {
|
|
103
|
+
/** The naive pass's kept tip: the first implement turn's reading material. */
|
|
104
|
+
readonly reference: string
|
|
105
|
+
/** The feature review's base: HEAD after the refactor on refactor-first, else the caller's base. */
|
|
106
|
+
readonly base: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const seamBuild = async (base: string): Promise<SeamOutcome> => {
|
|
99
110
|
const { reference, shape } = await decide()
|
|
100
|
-
if (shape
|
|
101
|
-
|
|
111
|
+
if (shape === undefined) return { reference, base }
|
|
112
|
+
if (shape.unreadable.length > 0) {
|
|
102
113
|
await scope("seam", () =>
|
|
103
114
|
human("by-hand", {
|
|
104
115
|
message: t.seamByHandMessage(shape.strays, shape.unreadable, reference),
|
|
@@ -106,6 +117,9 @@ export const seamBuild = async (tier?: RiskTier): Promise<void> => {
|
|
|
106
117
|
acceptClean: true,
|
|
107
118
|
}),
|
|
108
119
|
)
|
|
120
|
+
return { reference, base }
|
|
109
121
|
}
|
|
110
|
-
|
|
122
|
+
if (refactorStrays(shape).length === 0) return { reference, base }
|
|
123
|
+
await slice("refactor", () => reviewedRefactor(shape, { reference }))
|
|
124
|
+
return { reference, base: head() }
|
|
111
125
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
1
2
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import type
|
|
3
|
-
import { captureStep } from "../shared/text.fixture.js"
|
|
3
|
+
import { installContext, type ScopeOptions, type StepRequest } from "../../flows/index.js"
|
|
4
|
+
import { captureStep, fixtureContext } from "../shared/text.fixture.js"
|
|
4
5
|
import * as steps from "./steps.js"
|
|
5
6
|
|
|
6
7
|
const capture = (
|
|
@@ -45,6 +46,30 @@ describe("the feature workflow's own steps declare skills — a step's rendered
|
|
|
45
46
|
expect(prompt).toContain("code-review-and-quality")
|
|
46
47
|
})
|
|
47
48
|
|
|
49
|
+
it("collecting's prompt is the pinned feature fixture", async () => {
|
|
50
|
+
const prompt = agentPrompt(await capture(() => steps.collecting("capture-text"), "build"))
|
|
51
|
+
expect(prompt).toContain(
|
|
52
|
+
readFileSync(
|
|
53
|
+
new URL("../shared/__fixtures__/feature-collecting-prompt.txt", import.meta.url),
|
|
54
|
+
"utf8",
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("refactorCollecting renders the open variant for the reply round, the closed one otherwise, whatever .gtd/REVIEW.md holds", async () => {
|
|
60
|
+
const at = (round: "reply" | "edits", present: boolean): Promise<StepRequest> =>
|
|
61
|
+
captureStep(() => steps.refactorCollecting("capture", round, ["src/a.ts"]), {
|
|
62
|
+
scope: "refactor.build",
|
|
63
|
+
read: (path) => (present && path === ".gtd/REVIEW.md" ? "# Review" : undefined),
|
|
64
|
+
})
|
|
65
|
+
const open = agentPrompt(await at("reply", false))
|
|
66
|
+
const closed = agentPrompt(await at("edits", true))
|
|
67
|
+
expect(open).toContain("exactly one `- A:` reply")
|
|
68
|
+
expect(open).not.toContain("already closed")
|
|
69
|
+
expect(closed).toContain("already closed")
|
|
70
|
+
expect(closed).not.toContain("exactly one `- A:` reply")
|
|
71
|
+
})
|
|
72
|
+
|
|
48
73
|
it("answerReviewQuestions answers inline in .gtd/REVIEW.md as the reviewer, with its bundled skills", async () => {
|
|
49
74
|
const request = await capture(
|
|
50
75
|
() =>
|
|
@@ -80,6 +105,33 @@ describe("the feature workflow's own steps declare skills — a step's rendered
|
|
|
80
105
|
expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
|
|
81
106
|
})
|
|
82
107
|
|
|
108
|
+
it("fixNits with access runs in a review.fix.nits scope, the step named fixing inside it", async () => {
|
|
109
|
+
const scopes: ScopeOptions[] = []
|
|
110
|
+
const steps_: StepRequest[] = []
|
|
111
|
+
const access = { write: ["src/a.ts", ".gtd/**"] }
|
|
112
|
+
installContext(
|
|
113
|
+
fixtureContext(
|
|
114
|
+
{ scope: "refactor.build" },
|
|
115
|
+
{
|
|
116
|
+
step: (request) => {
|
|
117
|
+
steps_.push(request)
|
|
118
|
+
return Promise.resolve()
|
|
119
|
+
},
|
|
120
|
+
pushScope: (s) => void scopes.push(s),
|
|
121
|
+
popScope: () => undefined,
|
|
122
|
+
changesSince: () => [],
|
|
123
|
+
},
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
try {
|
|
127
|
+
await steps.fixNits([], access)
|
|
128
|
+
} finally {
|
|
129
|
+
installContext(undefined)
|
|
130
|
+
}
|
|
131
|
+
expect(scopes).toEqual([{ name: "review.fix.nits", access }])
|
|
132
|
+
expect(steps_.map((r) => ("name" in r ? r.name : ""))).toEqual(["fixing"])
|
|
133
|
+
})
|
|
134
|
+
|
|
83
135
|
it("deferNotes rests at build.review.defer as a script writing .gtd/TODO.md", async () => {
|
|
84
136
|
const request = await capture(
|
|
85
137
|
() =>
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import { human, run } from "../../flows/index.js"
|
|
1
|
+
import { human, run, scope, type ScopeAccess } from "../../flows/index.js"
|
|
2
|
+
import type { CollectRound } from "../../workflow-kit/index.js"
|
|
2
3
|
import * as t from "../shared/text.js"
|
|
3
4
|
import { riskTierOn } from "./tier.js"
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ARCHITECTURE,
|
|
7
|
+
coder,
|
|
8
|
+
FEEDBACK,
|
|
9
|
+
planner,
|
|
10
|
+
REFACTOR,
|
|
11
|
+
REQUIREMENTS,
|
|
12
|
+
REVIEW,
|
|
13
|
+
} from "../shared/steps.js"
|
|
5
14
|
|
|
6
15
|
/** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
|
|
7
16
|
export const triage = (base: string): Promise<void> =>
|
|
@@ -73,13 +82,22 @@ export const answerReviewQuestions = (notes: readonly t.NoteInput[]): Promise<vo
|
|
|
73
82
|
})
|
|
74
83
|
|
|
75
84
|
/** Fix every `nit` note in one turn. */
|
|
76
|
-
export const fixNits = (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
export const fixNits = (
|
|
86
|
+
notes: readonly t.NoteInput[],
|
|
87
|
+
access?: ScopeAccess,
|
|
88
|
+
rules?: string,
|
|
89
|
+
): Promise<void> => {
|
|
90
|
+
const fixing = (name: string): Promise<void> =>
|
|
91
|
+
t.agentWithSkills(name, t.buildReviewFixNitsPrompt(notes, rules), {
|
|
92
|
+
label: "Fixing your nits",
|
|
93
|
+
file: REVIEW,
|
|
94
|
+
model: planner(),
|
|
95
|
+
system: t.reviewerSystem(),
|
|
96
|
+
})
|
|
97
|
+
return access === undefined
|
|
98
|
+
? fixing("review.fix.nits.fixing")
|
|
99
|
+
: scope({ name: "review.fix.nits", access }, () => fixing("fixing"))
|
|
100
|
+
}
|
|
83
101
|
|
|
84
102
|
/** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
|
|
85
103
|
export const collecting = (capture: string): Promise<void> =>
|
|
@@ -92,6 +110,24 @@ export const collecting = (capture: string): Promise<void> =>
|
|
|
92
110
|
allowEmpty: true,
|
|
93
111
|
})
|
|
94
112
|
|
|
113
|
+
/** Turn the refactor review round `capture` describes into `.gtd/REFACTOR.md`, or change nothing. */
|
|
114
|
+
export const refactorCollecting = (
|
|
115
|
+
capture: string,
|
|
116
|
+
round: CollectRound,
|
|
117
|
+
strays: readonly string[],
|
|
118
|
+
): Promise<void> =>
|
|
119
|
+
t.agentWithSkills(
|
|
120
|
+
"review.collecting",
|
|
121
|
+
t.buildReviewCollectingPrompt(capture, { strays, round }),
|
|
122
|
+
{
|
|
123
|
+
label: "Collecting your refactor feedback",
|
|
124
|
+
file: REFACTOR,
|
|
125
|
+
model: planner(),
|
|
126
|
+
system: t.reviewerSystem(),
|
|
127
|
+
allowEmpty: true,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
95
131
|
/** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
|
|
96
132
|
export const deferNotes = (notes: readonly t.NoteInput[], round: string): Promise<void> =>
|
|
97
133
|
run("review.defer", t.deferNotesScript(notes, round), {
|
|
@@ -77,6 +77,10 @@ describe("readRiskTier fences", () => {
|
|
|
77
77
|
const text = "````\n```\n## Risk Tier\n\nlow\n````\n"
|
|
78
78
|
expect(readRiskTier(text)).toBe("high")
|
|
79
79
|
})
|
|
80
|
+
|
|
81
|
+
it("does not open a fence on a backtick line indented 4 spaces", () => {
|
|
82
|
+
expect(readRiskTier(" ```\n## Risk Tier\n\nlow\n")).toBe("low")
|
|
83
|
+
})
|
|
80
84
|
})
|
|
81
85
|
|
|
82
86
|
describe("readRiskTier fence closers", () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mark, read, vars } from "../../flows/index.js"
|
|
2
|
+
import { markFenced } from "../shared/fences.js"
|
|
2
3
|
import { REQUIREMENTS } from "../shared/steps.js"
|
|
3
4
|
|
|
4
5
|
export type RiskTier = "low" | "high"
|
|
@@ -6,32 +7,13 @@ export type RiskTier = "low" | "high"
|
|
|
6
7
|
/** GTD_FLAG_NAIVE_PASS, GTD_FLAG_RISK_TIER: both pinned at process start, so a mid-process flip never moves a process. */
|
|
7
8
|
export const riskTierOn = (): boolean => vars.flag_naive_pass === "1" && vars.flag_risk_tier === "1"
|
|
8
9
|
|
|
9
|
-
// An unclosed fence runs to end of file, so the tier reads `high` (fail-closed). A backtick
|
|
10
|
-
// opener with a backtick in its info string still counts as a fence: deviates from CommonMark,
|
|
11
|
-
// worst case is `high`.
|
|
12
|
-
const markFenced = (text: string): readonly { line: string; fenced: boolean }[] => {
|
|
13
|
-
let fence: string | undefined // the opener run, e.g. "````"
|
|
14
|
-
return text.split(/\r?\n/).map((line) => {
|
|
15
|
-
const run = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]
|
|
16
|
-
const fenced = fence !== undefined || run !== undefined
|
|
17
|
-
if (fence === undefined) fence = run
|
|
18
|
-
else if (
|
|
19
|
-
run !== undefined &&
|
|
20
|
-
run[0] === fence[0] &&
|
|
21
|
-
run.length >= fence.length &&
|
|
22
|
-
/^ {0,3}(`+|~+)[ \t]*$/.test(line)
|
|
23
|
-
)
|
|
24
|
-
fence = undefined
|
|
25
|
-
return { line, fenced }
|
|
26
|
-
})
|
|
27
|
-
}
|
|
28
|
-
|
|
29
10
|
const sectionLines = (
|
|
30
11
|
text: string | undefined,
|
|
31
12
|
title: string,
|
|
32
13
|
): readonly { line: string; fenced: boolean }[] => {
|
|
33
14
|
if (text === undefined) return []
|
|
34
15
|
const heading = new RegExp(`^##[ \\t]+${title}[ \\t]*$`, "i")
|
|
16
|
+
// An unclosed fence hides the rest, so the tier reads `high` (fail-closed).
|
|
35
17
|
const lines = markFenced(text)
|
|
36
18
|
const start = lines.findIndex((l) => !l.fenced && heading.test(l.line))
|
|
37
19
|
if (start < 0) return []
|