@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
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
changes,
|
|
3
3
|
changesSince,
|
|
4
4
|
head,
|
|
5
|
+
matches,
|
|
5
6
|
quote,
|
|
6
7
|
read,
|
|
7
8
|
refuse,
|
|
@@ -11,12 +12,16 @@ import {
|
|
|
11
12
|
vars,
|
|
12
13
|
type DiffShapeResult,
|
|
13
14
|
} from "../../flows/index.js"
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
15
|
+
import { lensesOf } from "../../workflow-kit/index.js"
|
|
16
|
+
import { markFenced } from "../shared/fences.js"
|
|
17
|
+
import { healthy, type Guard } from "../shared/health.js"
|
|
18
|
+
import { coder, FEEDBACK, REFACTOR } from "../shared/steps.js"
|
|
16
19
|
import * as t from "../shared/text.js"
|
|
17
|
-
import { defaults } from "../shared/vars.js"
|
|
20
|
+
import { defaults, testFileGlobs } from "../shared/vars.js"
|
|
21
|
+
import { buildTail, reUnwind, type FeedbackTarget } from "./review.js"
|
|
22
|
+
import { refactorCollecting } from "./steps.js"
|
|
18
23
|
|
|
19
|
-
export const REFACTOR_PACKAGE =
|
|
24
|
+
export const REFACTOR_PACKAGE = REFACTOR
|
|
20
25
|
|
|
21
26
|
export interface RefactorOptions {
|
|
22
27
|
/** Blank counts as absent: `kept()` reports `""` while the keeping turn is pending. */
|
|
@@ -35,6 +40,19 @@ export const requireWithin = (allowed: readonly string[]): void => {
|
|
|
35
40
|
)
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
// A fixed list, not `seamAllow`: narrowing that setting must not let a test file through.
|
|
44
|
+
const isTestFile = (path: string): boolean => testFileGlobs.some((glob) => matches(path, glob))
|
|
45
|
+
|
|
46
|
+
export const requireNoTestFiles = (): void => {
|
|
47
|
+
const tests = changes()
|
|
48
|
+
.map((c) => c.path)
|
|
49
|
+
.filter((path) => isCode(path) && isTestFile(path))
|
|
50
|
+
if (tests.length === 0) return
|
|
51
|
+
refuse(
|
|
52
|
+
`gtd land: refactor-tests: the refactor may not change a test file — revert these:\n${tests.map((path) => ` - ${path}`).join("\n")}`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
const flagExpression = (pattern: string): RegExp => {
|
|
39
57
|
if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank")
|
|
40
58
|
let expression: RegExp
|
|
@@ -95,6 +113,10 @@ export const requireNoNewFlags = (pattern: string, since: string): void => {
|
|
|
95
113
|
)
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
/** A test-file stray is never refactored: the implement turn still reads it in the naive tip. */
|
|
117
|
+
export const refactorStrays = (shape: DiffShapeResult): readonly string[] =>
|
|
118
|
+
shape.strays.filter((path) => !isTestFile(path))
|
|
119
|
+
|
|
98
120
|
const packageLines = (
|
|
99
121
|
strays: readonly string[],
|
|
100
122
|
pattern: string,
|
|
@@ -111,7 +133,7 @@ const packageLines = (
|
|
|
111
133
|
"## Rules",
|
|
112
134
|
"",
|
|
113
135
|
"- The suite's outcome must not change",
|
|
114
|
-
"- Change only the paths above
|
|
136
|
+
"- Change only the paths above, and never a test file",
|
|
115
137
|
`- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
|
|
116
138
|
"",
|
|
117
139
|
"## Reference",
|
|
@@ -122,57 +144,153 @@ const packageLines = (
|
|
|
122
144
|
"",
|
|
123
145
|
]
|
|
124
146
|
|
|
147
|
+
// Nested under `## Review feedback`, so every heading of the collected text drops a level.
|
|
148
|
+
const demoteHeadings = (text: string): string[] => {
|
|
149
|
+
const marked = markFenced(text.trimEnd())
|
|
150
|
+
const lines = marked.map((m) => m.line)
|
|
151
|
+
const headings = marked.map((m) =>
|
|
152
|
+
m.fenced ? undefined : /^(#{1,6}) /.exec(m.line)?.[1]?.length,
|
|
153
|
+
)
|
|
154
|
+
const shallowest = Math.min(...headings.filter((n): n is number => n !== undefined))
|
|
155
|
+
// Collected headings nest under `## Review feedback`, so the shallowest lands at `###`.
|
|
156
|
+
const shift = 3 - shallowest
|
|
157
|
+
return lines.map((line, i) => {
|
|
158
|
+
const depth = headings[i]
|
|
159
|
+
if (depth === undefined) return line
|
|
160
|
+
// Markdown has no heading past `######`.
|
|
161
|
+
return "#".repeat(Math.min(6, depth + shift)) + line.slice(depth)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
125
165
|
const deriveScript = (lines: readonly string[]): string =>
|
|
126
166
|
`mkdir -p .gtd
|
|
127
167
|
printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(REFACTOR_PACKAGE)}
|
|
128
168
|
`
|
|
129
169
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
170
|
+
interface Plan {
|
|
171
|
+
readonly pattern: string
|
|
172
|
+
readonly strays: readonly string[]
|
|
173
|
+
readonly reference: string | undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Undefined for every shape that is not a readable `refactor-first`; throws on a bad flag pattern. */
|
|
177
|
+
const plan = (shape: DiffShapeResult, options: RefactorOptions): Plan | undefined => {
|
|
178
|
+
if (shape.choice !== "refactor-first" || shape.unreadable.length > 0) return undefined
|
|
135
179
|
const pattern = vars.refactorFlagPattern ?? defaults.refactorFlagPattern!
|
|
136
180
|
const expression = flagExpression(pattern)
|
|
137
|
-
const
|
|
181
|
+
const strays = refactorStrays(shape)
|
|
182
|
+
if (strays.length === 0) return undefined
|
|
138
183
|
const zeroLength = strays.filter((path) => countFlags(read(path) ?? "", expression, new Map()))
|
|
139
184
|
if (zeroLength.length > 0)
|
|
140
185
|
throw new Error(
|
|
141
186
|
`gtd: refactorFlagPattern (${pattern}) produces a zero-length match in:\n${listPaths(zeroLength)}`,
|
|
142
187
|
)
|
|
143
188
|
const reference = options.reference?.trim() ? options.reference : undefined
|
|
144
|
-
return
|
|
145
|
-
|
|
146
|
-
|
|
189
|
+
return { pattern, strays, reference }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The in-memory `strays`, never the package file: a hand edit of its list changes nothing.
|
|
193
|
+
// Scope and tests look at the turn's own changes: a person's edit at an escalation rest must
|
|
194
|
+
// not wedge every later fix turn. Flags look at the whole range since `entry`.
|
|
195
|
+
const guardOf =
|
|
196
|
+
({ pattern, strays }: Plan, entry: string): Guard =>
|
|
197
|
+
() => {
|
|
198
|
+
// Tests first: a test file is never a stray here, so the scope refusal would mask it.
|
|
199
|
+
requireNoTestFiles()
|
|
200
|
+
requireWithin(strays)
|
|
201
|
+
requireNoNewFlags(pattern, entry)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
|
|
205
|
+
const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<void> => {
|
|
206
|
+
const { pattern, strays, reference } = p
|
|
207
|
+
await run(
|
|
208
|
+
"deriving",
|
|
209
|
+
deriveScript([
|
|
210
|
+
...packageLines(strays, pattern, reference),
|
|
211
|
+
...(feedback === undefined
|
|
212
|
+
? []
|
|
213
|
+
: ["## Review feedback", "", ...demoteHeadings(feedback), ""]),
|
|
214
|
+
]),
|
|
215
|
+
{ label: "Deriving the refactor package", file: REFACTOR_PACKAGE },
|
|
216
|
+
)
|
|
217
|
+
await t.agentWithSkills(
|
|
218
|
+
"building",
|
|
219
|
+
t.refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference, feedback !== undefined),
|
|
220
|
+
{ label: "Refactoring", model: coder(), system: t.builderSystem() },
|
|
221
|
+
)
|
|
222
|
+
guard()
|
|
223
|
+
await healthy(
|
|
224
|
+
async () => {
|
|
225
|
+
await t.agentWithSkills("fix.suite.fixing", t.refactorFixPrompt(strays, pattern), {
|
|
226
|
+
label: "Fixing the check",
|
|
227
|
+
file: FEEDBACK,
|
|
228
|
+
model: coder(),
|
|
229
|
+
system: t.builderSystem(),
|
|
230
|
+
})
|
|
231
|
+
guard()
|
|
232
|
+
},
|
|
233
|
+
{ describeGuard: guard, rules: t.refactorRules(strays, pattern) },
|
|
234
|
+
)
|
|
235
|
+
await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const strayAccess = (strays: readonly string[]) => ({ write: [...strays, ".gtd/**"] })
|
|
239
|
+
|
|
240
|
+
export const refactorFirst = async (
|
|
241
|
+
shape: DiffShapeResult,
|
|
242
|
+
options: RefactorOptions = {},
|
|
243
|
+
): Promise<boolean> => {
|
|
244
|
+
const p = plan(shape, options)
|
|
245
|
+
if (p === undefined) return false
|
|
246
|
+
return scope({ name: "refactor", access: strayAccess(p.strays) }, async () => {
|
|
247
|
+
await refactorLap(p, guardOf(p, head()))
|
|
248
|
+
return true
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `refactorFirst` plus its review, in the one `refactor` scope so the tail's
|
|
254
|
+
* steps are `refactor.build.*`. A review edit re-runs the refactor, never a
|
|
255
|
+
* re-plan: the collected notes ride the re-derived package. Every round
|
|
256
|
+
* reviews the whole refactor since `entry`, since each lap rebuilds over it.
|
|
257
|
+
*/
|
|
258
|
+
export const reviewedRefactor = async (
|
|
259
|
+
shape: DiffShapeResult,
|
|
260
|
+
options: RefactorOptions = {},
|
|
261
|
+
): Promise<boolean> => {
|
|
262
|
+
const p = plan(shape, options)
|
|
263
|
+
if (p === undefined) return false
|
|
264
|
+
return scope({ name: "refactor", access: strayAccess(p.strays) }, async () => {
|
|
265
|
+
const entry = head()
|
|
266
|
+
const guard = guardOf(p, entry)
|
|
267
|
+
const feedback: FeedbackTarget = {
|
|
147
268
|
file: REFACTOR_PACKAGE,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
269
|
+
collecting: (capture, round) => refactorCollecting(capture, round, p.strays),
|
|
270
|
+
unmatched:
|
|
271
|
+
"gtd land: refactor-feedback: the review feedback changed files but wrote no .gtd/REFACTOR.md — a refactor cannot act on it; revert the change, or `gtd abandon` and restart",
|
|
272
|
+
dropped: t.refactorDroppedRefusal,
|
|
273
|
+
}
|
|
274
|
+
let collected: string | undefined
|
|
275
|
+
for (;;) {
|
|
276
|
+
await refactorLap(p, guard, collected)
|
|
277
|
+
// A person's edit at an escalation rest is never refused, as in the lap.
|
|
278
|
+
const outcome = await buildTail(false, entry, true, {
|
|
279
|
+
guard,
|
|
280
|
+
guardRests: false,
|
|
281
|
+
lenses: lensesOf(vars.refactorQualityReviews ?? ""),
|
|
282
|
+
feedback,
|
|
283
|
+
fixAccess: strayAccess(p.strays),
|
|
284
|
+
lensBase: entry,
|
|
285
|
+
rules: t.refactorRules(p.strays, p.pattern),
|
|
286
|
+
lap: "refactor",
|
|
287
|
+
})
|
|
288
|
+
if (outcome.verdict === "signoff") return true
|
|
289
|
+
await reUnwind(outcome)
|
|
290
|
+
// The kit returns `feedback` only once the collecting turn wrote the package.
|
|
291
|
+
collected = read(REFACTOR_PACKAGE)
|
|
292
|
+
if (collected === undefined)
|
|
293
|
+
throw new Error(`gtd: review feedback left no ${REFACTOR_PACKAGE}`)
|
|
156
294
|
}
|
|
157
|
-
await t.agentWithSkills(
|
|
158
|
-
"building",
|
|
159
|
-
t.refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference),
|
|
160
|
-
{ label: "Refactoring", model: coder(), system: t.builderSystem() },
|
|
161
|
-
)
|
|
162
|
-
guard()
|
|
163
|
-
await healthy(
|
|
164
|
-
async () => {
|
|
165
|
-
await t.agentWithSkills("fix.suite.fixing", t.refactorFixPrompt(strays, pattern), {
|
|
166
|
-
label: "Fixing the check",
|
|
167
|
-
file: FEEDBACK,
|
|
168
|
-
model: coder(),
|
|
169
|
-
system: t.builderSystem(),
|
|
170
|
-
})
|
|
171
|
-
guard()
|
|
172
|
-
},
|
|
173
|
-
{ describeGuard: guard },
|
|
174
|
-
)
|
|
175
|
-
await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" })
|
|
176
|
-
return true
|
|
177
295
|
})
|
|
178
296
|
}
|
|
@@ -3,9 +3,17 @@ import {
|
|
|
3
3
|
installContext,
|
|
4
4
|
type Change,
|
|
5
5
|
type JudgeAnswer,
|
|
6
|
+
type ScopeOptions,
|
|
6
7
|
type StepRequest,
|
|
7
8
|
} from "../../flows/index.js"
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildTail,
|
|
11
|
+
review,
|
|
12
|
+
reUnwind,
|
|
13
|
+
severityOf,
|
|
14
|
+
type BuildTailOptions,
|
|
15
|
+
type ReviewOutcome,
|
|
16
|
+
} from "./review.js"
|
|
9
17
|
import type { ReviewNote } from "../../steering/index.js"
|
|
10
18
|
import * as bundled from "../index.js"
|
|
11
19
|
import { fixtureContext } from "../shared/text.fixture.js"
|
|
@@ -41,6 +49,14 @@ interface Drive {
|
|
|
41
49
|
readonly reviewDocs?: readonly (string | undefined)[]
|
|
42
50
|
/** Let the run pass the second gate, stopping at the first range read after it. */
|
|
43
51
|
readonly passSecondGate?: boolean
|
|
52
|
+
/** Options as `buildTail` passes them; `guard` is logged as `guard` when it runs. */
|
|
53
|
+
readonly options?: Omit<BuildTailOptions, "guard">
|
|
54
|
+
/** Install a guard that logs `guard` into the step log. */
|
|
55
|
+
readonly guarded?: boolean
|
|
56
|
+
/** The pending changes `changes()` reports at the collecting turn. */
|
|
57
|
+
readonly pending?: readonly Change[]
|
|
58
|
+
/** Files the harness holds from the start. */
|
|
59
|
+
readonly files?: Readonly<Record<string, string>>
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
interface Run {
|
|
@@ -53,6 +69,8 @@ interface Run {
|
|
|
53
69
|
readonly gateMessages: readonly string[]
|
|
54
70
|
/** Every `changesSince` argument, in order. */
|
|
55
71
|
readonly since: readonly string[]
|
|
72
|
+
/** Every scope opened, in order. */
|
|
73
|
+
readonly scopes: readonly ScopeOptions[]
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
/** Run `review` against a scripted context; a second pass at the gate or a fresh review stops the run. */
|
|
@@ -63,7 +81,11 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
63
81
|
const gates: boolean[] = []
|
|
64
82
|
const gateMessages: string[] = []
|
|
65
83
|
const since: string[] = []
|
|
66
|
-
const
|
|
84
|
+
const scopes: ScopeOptions[] = []
|
|
85
|
+
const files = new Map<string, string>([
|
|
86
|
+
[REVIEW, d.after ?? doc(d.notes)],
|
|
87
|
+
...Object.entries(d.files ?? {}),
|
|
88
|
+
])
|
|
67
89
|
let steps = 0
|
|
68
90
|
let nitsFixed = false
|
|
69
91
|
let awaits = 0
|
|
@@ -107,9 +129,10 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
107
129
|
refuse: (message: string): never => {
|
|
108
130
|
throw new Error(message)
|
|
109
131
|
},
|
|
110
|
-
pushScope: () =>
|
|
132
|
+
pushScope: (scope) => void scopes.push(scope),
|
|
111
133
|
popScope: () => undefined,
|
|
112
134
|
read: (path) => files.get(path),
|
|
135
|
+
changes: (): readonly Change[] => d.pending ?? [],
|
|
113
136
|
changesSince: (from: string): readonly Change[] => {
|
|
114
137
|
since.push(from)
|
|
115
138
|
if (d.passSecondGate && awaits === 2) throw new Stop()
|
|
@@ -127,12 +150,15 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
127
150
|
installContext(context)
|
|
128
151
|
let result: ReviewOutcome | "stopped"
|
|
129
152
|
try {
|
|
130
|
-
result = await review("base"
|
|
153
|
+
result = await review("base", undefined, {
|
|
154
|
+
...d.options,
|
|
155
|
+
...(d.guarded ? { guard: () => void log.push("guard") } : {}),
|
|
156
|
+
})
|
|
131
157
|
} catch (error) {
|
|
132
158
|
if (!(error instanceof Stop)) throw error
|
|
133
159
|
result = "stopped"
|
|
134
160
|
}
|
|
135
|
-
return { log, prompts, result, judged, gates, gateMessages, since }
|
|
161
|
+
return { log, prompts, result, judged, gates, gateMessages, since, scopes }
|
|
136
162
|
}
|
|
137
163
|
|
|
138
164
|
const verdict = (answer: string, p = 0.9): JudgeAnswer => ({ answer, p })
|
|
@@ -167,6 +193,28 @@ describe("the risk-fix pass", () => {
|
|
|
167
193
|
])
|
|
168
194
|
})
|
|
169
195
|
|
|
196
|
+
it("fixAccess wraps both fix turns in their own scope, the step names unchanged", async () => {
|
|
197
|
+
const fixAccess = { write: ["src/a.ts", ".gtd/**"] }
|
|
198
|
+
const run = await drive({
|
|
199
|
+
notes: ["add — typo", "sub", "mul"],
|
|
200
|
+
answers: { "note-1": verdict("nit") },
|
|
201
|
+
reviewDocs: [risky, clean],
|
|
202
|
+
options: { fixAccess },
|
|
203
|
+
})
|
|
204
|
+
expect(run.scopes).toEqual([
|
|
205
|
+
{ name: "review.fix.risks", access: fixAccess },
|
|
206
|
+
{ name: "review.fix.nits", access: fixAccess },
|
|
207
|
+
])
|
|
208
|
+
// The harness pushes no scope names, so the step is relative to its own fix scope.
|
|
209
|
+
expect(run.log.filter((n) => n === "fixing")).toHaveLength(2)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("without fixAccess the fix turns open no scope", async () => {
|
|
213
|
+
const run = await drive({ notes: [], reviewDocs: [risky, clean] })
|
|
214
|
+
expect(run.log).toContain("review.fix.risks.fixing")
|
|
215
|
+
expect(run.scopes).toEqual([])
|
|
216
|
+
})
|
|
217
|
+
|
|
170
218
|
it("no marker means no fix-risks step", async () => {
|
|
171
219
|
const run = await drive({ notes: [], reviewDocs: [clean] })
|
|
172
220
|
expect(run.log).not.toContain("review.fix.risks.fixing")
|
|
@@ -196,6 +244,159 @@ describe("the risk-fix pass", () => {
|
|
|
196
244
|
})
|
|
197
245
|
})
|
|
198
246
|
|
|
247
|
+
describe("the guard", () => {
|
|
248
|
+
const risky = doc(["Risk: drops the carry", "sub"])
|
|
249
|
+
const clean = doc(["add — fine", "sub"])
|
|
250
|
+
|
|
251
|
+
it("runs right after the risk-fix turn", async () => {
|
|
252
|
+
const run = await drive({ notes: [], reviewDocs: [risky, clean], guarded: true })
|
|
253
|
+
expect(run.log.slice(0, 4)).toEqual([
|
|
254
|
+
"review.reviewing",
|
|
255
|
+
"review.fix.risks.fixing",
|
|
256
|
+
"guard",
|
|
257
|
+
"health.check",
|
|
258
|
+
])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it("runs right after the nit-fix turn", async () => {
|
|
262
|
+
const run = await drive({
|
|
263
|
+
notes: ["add — typo", "sub", "mul"],
|
|
264
|
+
answers: { "note-1": verdict("nit") },
|
|
265
|
+
guarded: true,
|
|
266
|
+
})
|
|
267
|
+
expect(run.log.slice(2, 6)).toEqual([
|
|
268
|
+
"review.triage",
|
|
269
|
+
"review.fix.nits.fixing",
|
|
270
|
+
"guard",
|
|
271
|
+
"health.check",
|
|
272
|
+
])
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe("the feedback target", () => {
|
|
277
|
+
const edit = ["add — rename it", "sub", "mul"]
|
|
278
|
+
const REFACTOR = ".gtd/REFACTOR.md"
|
|
279
|
+
|
|
280
|
+
it("defaults to .gtd/REQUIREMENTS.md via the review.collecting step", async () => {
|
|
281
|
+
const run = await drive({ notes: edit })
|
|
282
|
+
expect(run.log.at(-1)).toBe("review.collecting")
|
|
283
|
+
expect(run.result).toMatchObject({ verdict: "feedback" })
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it("file replaces .gtd/REQUIREMENTS.md as the feedback test", async () => {
|
|
287
|
+
let collected = ""
|
|
288
|
+
const run = await drive({
|
|
289
|
+
notes: edit,
|
|
290
|
+
options: {
|
|
291
|
+
feedback: {
|
|
292
|
+
file: REFACTOR,
|
|
293
|
+
collecting: (capture) => {
|
|
294
|
+
collected = capture
|
|
295
|
+
return Promise.resolve()
|
|
296
|
+
},
|
|
297
|
+
unmatched: "unmatched: refactor",
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
files: { [REFACTOR]: "## Concern" },
|
|
301
|
+
})
|
|
302
|
+
expect(collected).toContain("rename it")
|
|
303
|
+
expect(run.log).not.toContain("review.collecting")
|
|
304
|
+
expect(run.result).toMatchObject({ verdict: "feedback" })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it("a collecting turn that wrote REQUIREMENTS but not file signs off unrefused with nothing changed", async () => {
|
|
308
|
+
const run = await drive({
|
|
309
|
+
notes: edit,
|
|
310
|
+
options: {
|
|
311
|
+
feedback: {
|
|
312
|
+
file: REFACTOR,
|
|
313
|
+
collecting: () => Promise.resolve(),
|
|
314
|
+
unmatched: "unmatched: refactor",
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
})
|
|
318
|
+
expect(run.result).toEqual({ verdict: "signoff" })
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("a feature review edit note collected to nothing signs off", async () => {
|
|
322
|
+
const run = await drive({
|
|
323
|
+
notes: edit,
|
|
324
|
+
options: {
|
|
325
|
+
feedback: {
|
|
326
|
+
file: REQUIREMENTS,
|
|
327
|
+
collecting: () => Promise.resolve(),
|
|
328
|
+
unmatched: "unmatched",
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
expect(run.result).toEqual({ verdict: "signoff" })
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it("dropped is passed through as the refusal for a round collected to nothing", async () => {
|
|
336
|
+
await expect(
|
|
337
|
+
drive({
|
|
338
|
+
notes: edit,
|
|
339
|
+
options: {
|
|
340
|
+
feedback: {
|
|
341
|
+
file: REFACTOR,
|
|
342
|
+
collecting: () => Promise.resolve(),
|
|
343
|
+
unmatched: "unmatched: refactor",
|
|
344
|
+
dropped: (r) => `dropped: ${r.notes.map((n) => n.id).join(",")}`,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
).rejects.toThrow("dropped: note-1")
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("refuses with unmatched when the collecting turn changed something but not file", async () => {
|
|
352
|
+
await expect(
|
|
353
|
+
drive({
|
|
354
|
+
notes: edit,
|
|
355
|
+
options: {
|
|
356
|
+
feedback: {
|
|
357
|
+
file: REFACTOR,
|
|
358
|
+
collecting: () => Promise.resolve(),
|
|
359
|
+
unmatched: "unmatched: refactor",
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
pending: [{ path: "src/x.ts", status: "modified", before: "a", after: "b" }],
|
|
363
|
+
}),
|
|
364
|
+
).rejects.toThrow("unmatched: refactor")
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
describe("reUnwind", () => {
|
|
369
|
+
it("restores edited paths from restoreFrom and removes added ones, in the caller's scope", async () => {
|
|
370
|
+
const steps: StepRequest[] = []
|
|
371
|
+
installContext(
|
|
372
|
+
fixtureContext(
|
|
373
|
+
{},
|
|
374
|
+
{
|
|
375
|
+
step: (request) => {
|
|
376
|
+
steps.push(request)
|
|
377
|
+
return Promise.resolve()
|
|
378
|
+
},
|
|
379
|
+
read: (path) => (path === "src/a.ts" ? "old" : undefined),
|
|
380
|
+
},
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
await reUnwind({
|
|
384
|
+
verdict: "feedback",
|
|
385
|
+
base: "b1",
|
|
386
|
+
restoreFrom: "r1",
|
|
387
|
+
edited: [
|
|
388
|
+
{ path: "src/a.ts", status: "modified", before: "old", after: "new" },
|
|
389
|
+
{ path: "src/new.ts", status: "added", before: undefined, after: "x" },
|
|
390
|
+
],
|
|
391
|
+
})
|
|
392
|
+
const [step] = steps
|
|
393
|
+
expect(step).toMatchObject({ kind: "run", name: "re-unwind" })
|
|
394
|
+
const body = step?.kind === "run" ? step.body : ""
|
|
395
|
+
expect(body).toContain("git restore --source='r1' --worktree -- 'src/a.ts'")
|
|
396
|
+
expect(body).toContain("rm -f -- 'src/new.ts'")
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
|
|
199
400
|
describe("review verdict routing", () => {
|
|
200
401
|
const notes = ["add — typo", "sub", "mul"]
|
|
201
402
|
|
|
@@ -568,3 +769,119 @@ describe("severity routing", () => {
|
|
|
568
769
|
expect(run.log).not.toContain("review.defer")
|
|
569
770
|
})
|
|
570
771
|
})
|
|
772
|
+
|
|
773
|
+
describe("BuildTailOptions.lensBase", () => {
|
|
774
|
+
const lensPrompts = async (options: BuildTailOptions): Promise<string[]> => {
|
|
775
|
+
const prompts: string[] = []
|
|
776
|
+
installContext(
|
|
777
|
+
fixtureContext(
|
|
778
|
+
{ vars: { qualityReviews: "conventions, correctness" } },
|
|
779
|
+
{
|
|
780
|
+
step: (request: StepRequest) => {
|
|
781
|
+
if (request.kind === "restart") return Promise.resolve()
|
|
782
|
+
if (request.kind === "agent" && request.name.startsWith("quality."))
|
|
783
|
+
prompts.push(request.prompt)
|
|
784
|
+
if (request.name === "review.reviewing") throw new Stop()
|
|
785
|
+
return Promise.resolve()
|
|
786
|
+
},
|
|
787
|
+
pushScope: () => undefined,
|
|
788
|
+
popScope: () => undefined,
|
|
789
|
+
changesSince: () => {
|
|
790
|
+
throw new Stop()
|
|
791
|
+
},
|
|
792
|
+
start: () => "START-SHA",
|
|
793
|
+
},
|
|
794
|
+
),
|
|
795
|
+
)
|
|
796
|
+
await buildTail(false, "base", true, options).catch((error: unknown) => {
|
|
797
|
+
if (!(error instanceof Stop)) throw error
|
|
798
|
+
})
|
|
799
|
+
return prompts
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
it("reaches every quality-lens prompt", async () => {
|
|
803
|
+
const prompts = await lensPrompts({ lensBase: "SLICE-BASE" })
|
|
804
|
+
expect(prompts).toHaveLength(2)
|
|
805
|
+
for (const prompt of prompts) expect(prompt).toContain("from `SLICE-BASE`")
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
it("absent, the lens prompts name start()", async () => {
|
|
809
|
+
const prompts = await lensPrompts({})
|
|
810
|
+
expect(prompts).toHaveLength(2)
|
|
811
|
+
for (const prompt of prompts) expect(prompt).toContain("from `START-SHA`")
|
|
812
|
+
})
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
describe("buildTail without rules", () => {
|
|
816
|
+
const RULES = "Change only these paths"
|
|
817
|
+
|
|
818
|
+
const tailPrompts = async (reds: number): Promise<Map<string, string>> => {
|
|
819
|
+
const prompts = new Map<string, string>()
|
|
820
|
+
let checks = 0
|
|
821
|
+
const files = new Map<string, string>([[".gtd/QUALITY.md", "- finding"]])
|
|
822
|
+
installContext(
|
|
823
|
+
fixtureContext(
|
|
824
|
+
{ vars: { qualityReviews: "conventions" } },
|
|
825
|
+
{
|
|
826
|
+
step: (request: StepRequest) => {
|
|
827
|
+
if (request.kind === "restart") return Promise.resolve()
|
|
828
|
+
if (request.kind === "agent") prompts.set(request.name, request.prompt)
|
|
829
|
+
if (request.name === "health.check") checks++
|
|
830
|
+
if (request.name === "fix.quality.fixing") files.delete(".gtd/QUALITY.md")
|
|
831
|
+
if (request.name === "review.reviewing" || request.name === "health.stop")
|
|
832
|
+
throw new Stop()
|
|
833
|
+
return Promise.resolve(
|
|
834
|
+
request.kind === "judge" ? { answers: {}, truncated: [] } : undefined,
|
|
835
|
+
)
|
|
836
|
+
},
|
|
837
|
+
pushScope: () => undefined,
|
|
838
|
+
popScope: () => undefined,
|
|
839
|
+
read: (path) =>
|
|
840
|
+
path === ".gtd/FEEDBACK.md" ? (checks <= reds ? "red" : undefined) : files.get(path),
|
|
841
|
+
changes: () =>
|
|
842
|
+
checks <= reds
|
|
843
|
+
? [{ path: ".gtd/FEEDBACK.md", status: "added", before: undefined, after: "red" }]
|
|
844
|
+
: [],
|
|
845
|
+
matches: (path, pattern) => path === pattern,
|
|
846
|
+
changesSince: () => {
|
|
847
|
+
throw new Stop()
|
|
848
|
+
},
|
|
849
|
+
start: () => "c0",
|
|
850
|
+
},
|
|
851
|
+
),
|
|
852
|
+
)
|
|
853
|
+
await buildTail(true, "base", false, {}).catch((error: unknown) => {
|
|
854
|
+
if (!(error instanceof Stop)) throw error
|
|
855
|
+
})
|
|
856
|
+
return prompts
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
it("renders the feature tail's fix and describe prompts unchanged", async () => {
|
|
860
|
+
const prompts = await tailPrompts(6)
|
|
861
|
+
expect(prompts.get("fix")).toMatch(/- Leave everything uncommitted — do not commit\n$/)
|
|
862
|
+
expect(prompts.get("health.describe")).toMatch(/this turn only writes the document\n$/)
|
|
863
|
+
for (const prompt of prompts.values()) expect(prompt).not.toContain(RULES)
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it("renders the feature tail's quality-fix prompt unchanged", async () => {
|
|
867
|
+
const prompts = await tailPrompts(0)
|
|
868
|
+
expect(prompts.get("fix.quality.fixing")).toMatch(
|
|
869
|
+
/- Leave everything else uncommitted and finish your turn\n$/,
|
|
870
|
+
)
|
|
871
|
+
expect(prompts.get("fix.quality.fixing")).not.toContain(RULES)
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it("renders the risk-fix and nit-fix prompts unchanged", async () => {
|
|
875
|
+
const run = await drive({
|
|
876
|
+
notes: [],
|
|
877
|
+
reviewDocs: [doc(["Risk: x", "sub"]), doc(["add", "sub"])],
|
|
878
|
+
})
|
|
879
|
+
expect(run.prompts.get("review.fix.risks.fixing")).toContain("add a test that fails")
|
|
880
|
+
expect(run.prompts.get("review.fix.risks.fixing")).not.toContain(RULES)
|
|
881
|
+
const nits = await drive({
|
|
882
|
+
notes: ["add — typo", "sub", "mul"],
|
|
883
|
+
answers: { "note-1": verdict("nit") },
|
|
884
|
+
})
|
|
885
|
+
expect(nits.prompts.get("review.fix.nits.fixing")).not.toContain(RULES)
|
|
886
|
+
})
|
|
887
|
+
})
|