@pmelab/gtd 32.0.0 → 33.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +28 -22
- package/dist/chunks/{reserved-CHTJTzpA.mjs → reserved-BU5Zi-nX.mjs} +74 -13
- package/dist/chunks/{tail-CCsDBbZe.mjs → tail-DuXanjIY.mjs} +6 -3
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +15 -3
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +158 -34
- 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 +246 -46
- package/package.json +1 -1
- package/src/workflow-kit/quality.test.ts +25 -1
- package/src/workflow-kit/quality.ts +22 -4
- package/src/workflows/feature/acceptance-criteria.feature +8 -0
- package/src/workflows/feature/fast-path.feature +8 -2
- package/src/workflows/feature/feature.test.ts +19 -9
- package/src/workflows/feature/lens-models.feature +60 -0
- package/src/workflows/feature/naive-acceptance.feature +140 -0
- package/src/workflows/feature/one-step-land.feature +8 -2
- package/src/workflows/feature/refactor-absorbs.feature +6 -1
- package/src/workflows/feature/refactor-first.feature +9 -3
- package/src/workflows/feature/refactor-lens-models.feature +67 -0
- package/src/workflows/feature/refactor-review.feature +50 -5
- package/src/workflows/feature/refactor.ts +6 -5
- package/src/workflows/feature/risk-tier.feature +10 -0
- package/src/workflows/feature/seam-rejection.feature +19 -6
- package/src/workflows/feature/seam.feature +70 -16
- package/src/workflows/feature/seam.test.ts +189 -3
- package/src/workflows/feature/seam.ts +195 -30
- package/src/workflows/feature/steps.test.ts +13 -0
- package/src/workflows/index.ts +1 -1
- package/src/workflows/shared/skills.test.ts +7 -0
- package/src/workflows/shared/steps.test.ts +45 -0
- package/src/workflows/shared/steps.ts +4 -2
- package/src/workflows/shared/tail.test.ts +31 -1
- package/src/workflows/shared/tail.ts +6 -3
- package/src/workflows/shared/text.test.ts +40 -0
- package/src/workflows/shared/text.ts +97 -16
|
@@ -51,7 +51,12 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
51
51
|
And step "design.gate.answer" changes nothing
|
|
52
52
|
|
|
53
53
|
Scenario: a land judgment goes straight to the implementation with no seam gate or refactor
|
|
54
|
-
Given step "seam.
|
|
54
|
+
Given step "seam.check" changes nothing
|
|
55
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
56
|
+
"""
|
|
57
|
+
it("[AC-1] holds", () => {})
|
|
58
|
+
"""
|
|
59
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
55
60
|
"""
|
|
56
61
|
export const add = (a: number, b: number) => a + b
|
|
57
62
|
export const subtract = (a: number, b: number) => a - b
|
|
@@ -72,12 +77,18 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
72
77
|
gtd(check): start-gate.check → design.triage
|
|
73
78
|
gtd(agent): design.triage → design.gate.answer
|
|
74
79
|
gtd(human): design.gate.answer → seam.naive
|
|
75
|
-
gtd(agent): seam.naive → seam.
|
|
80
|
+
gtd(agent): seam.naive → seam.check
|
|
81
|
+
gtd(check): seam.check → seam.judge
|
|
76
82
|
gtd(judge): seam.judge → implement.building
|
|
77
83
|
"""
|
|
78
84
|
|
|
79
85
|
Scenario: config skills for the implement scope reach the implement step
|
|
80
|
-
Given step "seam.
|
|
86
|
+
Given step "seam.check" changes nothing
|
|
87
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
88
|
+
"""
|
|
89
|
+
it("[AC-1] holds", () => {})
|
|
90
|
+
"""
|
|
91
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
81
92
|
"""
|
|
82
93
|
export const add = (a: number, b: number) => a + b
|
|
83
94
|
export const subtract = (a: number, b: number) => a - b
|
|
@@ -91,19 +102,33 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
91
102
|
And the resting step's content contains "my-tdd, my-runbook"
|
|
92
103
|
|
|
93
104
|
Scenario: the naive pass lands as a marker commit and its turn stays off the branch
|
|
94
|
-
Given step "seam.
|
|
105
|
+
Given step "seam.check" changes nothing
|
|
106
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
107
|
+
"""
|
|
108
|
+
it("[AC-1] holds", () => {})
|
|
109
|
+
"""
|
|
110
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
95
111
|
"""
|
|
96
112
|
export const add = (a: number, b: number) => a + b
|
|
97
113
|
export const subtract = (a: number, b: number) => a - b
|
|
98
114
|
"""
|
|
99
115
|
When the process is driven
|
|
100
116
|
Then the process rests at "seam.judge"
|
|
101
|
-
And the
|
|
102
|
-
|
|
117
|
+
And the commit subjects end with:
|
|
118
|
+
"""
|
|
119
|
+
gtd(agent): seam.naive → seam.check
|
|
120
|
+
gtd(check): seam.check → seam.judge
|
|
121
|
+
"""
|
|
122
|
+
And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
|
|
103
123
|
And "src/calc.ts" does not contain "subtract"
|
|
104
124
|
|
|
105
125
|
Scenario: the implementation is one build turn, a green check, then the requirements are closed out
|
|
106
|
-
Given step "seam.
|
|
126
|
+
Given step "seam.check" changes nothing
|
|
127
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
128
|
+
"""
|
|
129
|
+
it("[AC-1] holds", () => {})
|
|
130
|
+
"""
|
|
131
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
107
132
|
"""
|
|
108
133
|
export const add = (a: number, b: number) => a + b
|
|
109
134
|
export const subtract = (a: number, b: number) => a - b
|
|
@@ -132,7 +157,12 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
132
157
|
And ".gtd/REQUIREMENTS.md" does not exist
|
|
133
158
|
|
|
134
159
|
Scenario: a naive pass modifying a test file and adding a new one skips the judge
|
|
135
|
-
Given step "seam.
|
|
160
|
+
Given step "seam.check" changes nothing
|
|
161
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
162
|
+
"""
|
|
163
|
+
it("[AC-1] holds", () => {})
|
|
164
|
+
"""
|
|
165
|
+
And step "seam.naive" writes "src/subtract.ts" with:
|
|
136
166
|
"""
|
|
137
167
|
export const subtract = (a: number, b: number) => a - b
|
|
138
168
|
"""
|
|
@@ -146,11 +176,17 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
146
176
|
And the commit subjects end with:
|
|
147
177
|
"""
|
|
148
178
|
gtd(human): design.gate.answer → seam.naive
|
|
149
|
-
gtd(agent): seam.naive →
|
|
179
|
+
gtd(agent): seam.naive → seam.check
|
|
180
|
+
gtd(check): seam.check → implement.building
|
|
150
181
|
"""
|
|
151
182
|
|
|
152
183
|
Scenario: a naive pass adding a glob-named file does not hide a modified sibling
|
|
153
|
-
Given step "seam.
|
|
184
|
+
Given step "seam.check" changes nothing
|
|
185
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
186
|
+
"""
|
|
187
|
+
it("[AC-1] holds", () => {})
|
|
188
|
+
"""
|
|
189
|
+
And step "seam.naive" writes "src/calc*.ts" with:
|
|
154
190
|
"""
|
|
155
191
|
export const subtract = (a: number, b: number) => a - b
|
|
156
192
|
"""
|
|
@@ -169,7 +205,12 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
169
205
|
And the resting step's content does not contain "src/calc*.ts"
|
|
170
206
|
|
|
171
207
|
Scenario: an edit at the steer gate runs the naive pass again with a fresh retry budget
|
|
172
|
-
Given step "seam.
|
|
208
|
+
Given step "seam.check" changes nothing
|
|
209
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
210
|
+
"""
|
|
211
|
+
it("[AC-1] holds", () => {})
|
|
212
|
+
"""
|
|
213
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
173
214
|
"""
|
|
174
215
|
export const add = (a: number, b: number) => b + a
|
|
175
216
|
"""
|
|
@@ -201,16 +242,24 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
201
242
|
And the commit subjects end with:
|
|
202
243
|
"""
|
|
203
244
|
gtd(human): seam.steer → seam.naive
|
|
204
|
-
gtd(agent): seam.naive → seam.
|
|
245
|
+
gtd(agent): seam.naive → seam.check
|
|
246
|
+
gtd(check): seam.check → seam.judge
|
|
205
247
|
gtd(judge): seam.judge → seam.naive
|
|
206
|
-
gtd(agent): seam.naive → seam.
|
|
248
|
+
gtd(agent): seam.naive → seam.check
|
|
249
|
+
gtd(check): seam.check → seam.judge
|
|
207
250
|
gtd(judge): seam.judge → seam.naive
|
|
208
|
-
gtd(agent): seam.naive → seam.
|
|
251
|
+
gtd(agent): seam.naive → seam.check
|
|
252
|
+
gtd(check): seam.check → seam.judge
|
|
209
253
|
gtd(judge): seam.judge → seam.steer
|
|
210
254
|
"""
|
|
211
255
|
|
|
212
256
|
Scenario: a clean landing at the steer gate overrules, with no seam gate and no refactor
|
|
213
|
-
Given step "seam.
|
|
257
|
+
Given step "seam.check" changes nothing
|
|
258
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
259
|
+
"""
|
|
260
|
+
it("[AC-1] holds", () => {})
|
|
261
|
+
"""
|
|
262
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
214
263
|
"""
|
|
215
264
|
export const add = (a: number, b: number) => b + a
|
|
216
265
|
"""
|
|
@@ -229,7 +278,12 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
229
278
|
"""
|
|
230
279
|
|
|
231
280
|
Scenario: review feedback after land re-runs design and implement, never the seam phase
|
|
232
|
-
Given step "seam.
|
|
281
|
+
Given step "seam.check" changes nothing
|
|
282
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
283
|
+
"""
|
|
284
|
+
it("[AC-1] holds", () => {})
|
|
285
|
+
"""
|
|
286
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
233
287
|
"""
|
|
234
288
|
export const add = (a: number, b: number) => b + a
|
|
235
289
|
"""
|
|
@@ -8,7 +8,14 @@ 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 {
|
|
11
|
+
import {
|
|
12
|
+
implement,
|
|
13
|
+
judgeSeam,
|
|
14
|
+
naivePass,
|
|
15
|
+
acceptanceFault,
|
|
16
|
+
seamBuild,
|
|
17
|
+
type SeamOutcome,
|
|
18
|
+
} from "./seam.js"
|
|
12
19
|
import { captureStep } from "../shared/text.fixture.js"
|
|
13
20
|
|
|
14
21
|
afterEach(() => installContext(undefined))
|
|
@@ -79,6 +86,17 @@ describe("naivePass", () => {
|
|
|
79
86
|
expect(request.options).toMatchObject({ keep: true })
|
|
80
87
|
})
|
|
81
88
|
|
|
89
|
+
it("runs on the coder model, base by default, the coderModel setting otherwise", async () => {
|
|
90
|
+
const plain = await capture()
|
|
91
|
+
const set = await captureStep(() => naivePass(), {
|
|
92
|
+
scope: "seam",
|
|
93
|
+
env: { coderModel: "sonnet" },
|
|
94
|
+
})
|
|
95
|
+
if (plain.kind !== "agent" || set.kind !== "agent") throw new Error("unreachable")
|
|
96
|
+
expect(plain.options.model).toBe("base")
|
|
97
|
+
expect(set.options.model).toBe("sonnet")
|
|
98
|
+
})
|
|
99
|
+
|
|
82
100
|
it("carries the rejection into the retry prompt", async () => {
|
|
83
101
|
const request = await capture({ strays: ["src/core.ts"], p: 0.8 })
|
|
84
102
|
if (request.kind !== "agent") throw new Error("unreachable")
|
|
@@ -87,6 +105,130 @@ describe("naivePass", () => {
|
|
|
87
105
|
})
|
|
88
106
|
})
|
|
89
107
|
|
|
108
|
+
describe("acceptanceFault", () => {
|
|
109
|
+
const requirements = "# R\n\n## Acceptance Criteria\n\n- AC-1: adds.\n- AC-2: subtracts.\n"
|
|
110
|
+
const file = (path: string, status: Change["status"], after: string): Change => ({
|
|
111
|
+
path,
|
|
112
|
+
status,
|
|
113
|
+
before: status === "added" ? undefined : "",
|
|
114
|
+
after,
|
|
115
|
+
})
|
|
116
|
+
const refusal = (...changes: Change[]): string | undefined => {
|
|
117
|
+
installContext({
|
|
118
|
+
...fixtureContext({}),
|
|
119
|
+
matches: globMatches,
|
|
120
|
+
read: (path: string) => (path === ".gtd/REQUIREMENTS.md" ? requirements : undefined),
|
|
121
|
+
} as unknown as FlowContext)
|
|
122
|
+
return acceptanceFault(".gtd/REQUIREMENTS.md", tip(...changes))
|
|
123
|
+
}
|
|
124
|
+
const tagged = 'import { add } from "../src/calc.js"\nit("[AC-1] adds")\nit("[AC-2] subtracts")'
|
|
125
|
+
|
|
126
|
+
it("passes when every ID is named in a test the pass wrote", () => {
|
|
127
|
+
expect(
|
|
128
|
+
refusal(file("src/calc.ts", "modified", "x"), file("tests/calc.test.ts", "added", tagged)),
|
|
129
|
+
).toBe(undefined)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("names every ID no test of the pass mentions", () => {
|
|
133
|
+
const message = refusal(file("tests/calc.test.ts", "added", 'it("[AC-12] x")'))
|
|
134
|
+
expect(message).toMatch(/^acceptance-tests: /)
|
|
135
|
+
expect(message).toContain("AC-1, AC-2")
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("does not count an ID in a non-test file", () => {
|
|
139
|
+
expect(refusal(file("src/calc.ts", "modified", "AC-1 AC-2"))).toContain("AC-1, AC-2")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("refuses a test importing a file the pass added, resolved from the test's directory", () => {
|
|
143
|
+
const message = refusal(
|
|
144
|
+
file("src/naive/helper.ts", "added", "export const h = 1"),
|
|
145
|
+
file("src/naive/helper.test.ts", "added", `import { h } from "./helper.js"\n${tagged}`),
|
|
146
|
+
)
|
|
147
|
+
expect(message).toMatch(/^acceptance-internals: /)
|
|
148
|
+
expect(message).toContain("src/naive/helper.test.ts → src/naive/helper.ts")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("refuses an aliased or directory import of an added module", () => {
|
|
152
|
+
const added = file("src/naive/index.ts", "added", "")
|
|
153
|
+
expect(
|
|
154
|
+
refusal(added, file("tests/a.test.ts", "added", `import "@/naive"\n${tagged}`)),
|
|
155
|
+
).toContain("acceptance-internals")
|
|
156
|
+
expect(
|
|
157
|
+
refusal(added, file("tests/a.test.ts", "added", `import "../src/naive/index.js"\n${tagged}`)),
|
|
158
|
+
).toContain("acceptance-internals")
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("counts only the lines the pass added: an ID an earlier feature left does not", () => {
|
|
162
|
+
const old = 'it("[AC-1] old")\nit("[AC-2] old")'
|
|
163
|
+
const message = refusal({
|
|
164
|
+
path: "tests/calc.test.ts",
|
|
165
|
+
status: "modified",
|
|
166
|
+
before: old,
|
|
167
|
+
after: `${old}\nit("[AC-2] new")`,
|
|
168
|
+
})
|
|
169
|
+
expect(message).toContain("names AC-1 —")
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("refuses a bare . or .. import of an added index", () => {
|
|
173
|
+
expect(
|
|
174
|
+
refusal(
|
|
175
|
+
file("src/naive/index.ts", "added", ""),
|
|
176
|
+
file("src/naive/__tests__/a.test.ts", "added", `import { x } from ".."\n${tagged}`),
|
|
177
|
+
),
|
|
178
|
+
).toContain("acceptance-internals")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("reads a URL route or path-like data as no import", () => {
|
|
182
|
+
expect(
|
|
183
|
+
refusal(
|
|
184
|
+
file("src/routes/users.ts", "added", ""),
|
|
185
|
+
file(
|
|
186
|
+
"tests/a.test.ts",
|
|
187
|
+
"added",
|
|
188
|
+
`request(app).get("/users")\nrun("src/routes/users.ts")\n${tagged}`,
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
).toBe(undefined)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("reads no import out of a .feature file", () => {
|
|
195
|
+
expect(
|
|
196
|
+
refusal(
|
|
197
|
+
file("src/subtract.ts", "added", ""),
|
|
198
|
+
file("features/calc.feature", "added", `Given a file "src/subtract.ts"\n${tagged}`),
|
|
199
|
+
),
|
|
200
|
+
).toBe(undefined)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("skips commented-out imports and takes a package subpath as no repository path", () => {
|
|
204
|
+
expect(
|
|
205
|
+
refusal(
|
|
206
|
+
file("src/naive/helper.ts", "added", ""),
|
|
207
|
+
file("src/date-fns/format.ts", "added", ""),
|
|
208
|
+
file(
|
|
209
|
+
"src/naive/a.test.ts",
|
|
210
|
+
"added",
|
|
211
|
+
`// import { h } from "./helper.js"\nimport { format } from "date-fns/format"\n${tagged}`,
|
|
212
|
+
),
|
|
213
|
+
),
|
|
214
|
+
).toBe(undefined)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("lets a test reach an existing module, a same-named file elsewhere, or a URL", () => {
|
|
218
|
+
expect(
|
|
219
|
+
refusal(
|
|
220
|
+
file("src/naive/api.ts", "added", ""),
|
|
221
|
+
file("src/calc.ts", "modified", ""),
|
|
222
|
+
file(
|
|
223
|
+
"tests/a.test.ts",
|
|
224
|
+
"added",
|
|
225
|
+
`import "../src/calc.js"\nimport "./api.js"\nfetch("https://x/api")\n${tagged}`,
|
|
226
|
+
),
|
|
227
|
+
),
|
|
228
|
+
).toBe(undefined)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
90
232
|
const REVIEW = ".gtd/REVIEW.md"
|
|
91
233
|
const REFACTOR = ".gtd/REFACTOR.md"
|
|
92
234
|
const reviewDoc = [
|
|
@@ -113,7 +255,10 @@ const driveSeam = async (
|
|
|
113
255
|
answer: string,
|
|
114
256
|
stray: Change = change("src/a.ts", "modified"),
|
|
115
257
|
vars: Record<string, string> = {},
|
|
258
|
+
reds = 0,
|
|
116
259
|
): Promise<Seamed> => {
|
|
260
|
+
let red = false
|
|
261
|
+
let redsLeft = reds
|
|
117
262
|
const names: string[] = []
|
|
118
263
|
const slices: string[] = []
|
|
119
264
|
const inside: Record<string, string[]> = {}
|
|
@@ -147,11 +292,17 @@ const driveSeam = async (
|
|
|
147
292
|
read: (path: string) => files.get(path),
|
|
148
293
|
head: () => current,
|
|
149
294
|
kept: () => tip(stray),
|
|
150
|
-
changes: () => [],
|
|
295
|
+
changes: () => (red ? [change(".gtd/FEEDBACK.md", "added")] : []),
|
|
151
296
|
changesSince: () => [],
|
|
152
297
|
step: (request: StepRequest) => {
|
|
153
298
|
if (request.kind === "restart") return Promise.resolve()
|
|
154
299
|
const full = [...stack, request.name].join(".")
|
|
300
|
+
red = full === "seam.check" && redsLeft-- > 0
|
|
301
|
+
if (full === "seam.check") {
|
|
302
|
+
if (red) files.set(".gtd/FEEDBACK.md", "FAIL")
|
|
303
|
+
else files.delete(".gtd/FEEDBACK.md")
|
|
304
|
+
}
|
|
305
|
+
if (full === "seam.clear") files.delete(".gtd/FEEDBACK.md")
|
|
155
306
|
names.push(full)
|
|
156
307
|
for (const name of open) inside[name]!.push(full)
|
|
157
308
|
effects[request.name]?.()
|
|
@@ -174,7 +325,37 @@ describe("seamBuild", () => {
|
|
|
174
325
|
const h = await driveSeam("land")
|
|
175
326
|
expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
|
|
176
327
|
expect(h.slices).toEqual([])
|
|
177
|
-
expect(h.names).toEqual(["seam.naive", "seam.judge"])
|
|
328
|
+
expect(h.names).toEqual(["seam.naive", "seam.check", "seam.judge"])
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it("a red check retries the pass, and the judge sees it only once green", async () => {
|
|
332
|
+
const h = await driveSeam("land", undefined, {}, 1)
|
|
333
|
+
expect(h.names).toEqual(["seam.naive", "seam.check", "seam.naive", "seam.check", "seam.judge"])
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it("an overruled red pass gets a fresh budget for the judge's rejects", async () => {
|
|
337
|
+
const h = await driveSeam("reject", undefined, {}, 3)
|
|
338
|
+
const steers = h.names.flatMap((name, i) => (name === "seam.steer" ? [i] : []))
|
|
339
|
+
expect(steers).toHaveLength(2)
|
|
340
|
+
expect(h.names.slice(steers[0]! + 1, steers[1]).filter((n) => n === "seam.judge")).toHaveLength(
|
|
341
|
+
3,
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it("a third red rests at seam.steer, and a clean landing there takes the red pass to the judge", async () => {
|
|
346
|
+
const h = await driveSeam("land", undefined, {}, 3)
|
|
347
|
+
expect(h.names).toEqual([
|
|
348
|
+
"seam.naive",
|
|
349
|
+
"seam.check",
|
|
350
|
+
"seam.naive",
|
|
351
|
+
"seam.check",
|
|
352
|
+
"seam.naive",
|
|
353
|
+
"seam.check",
|
|
354
|
+
"seam.steer",
|
|
355
|
+
"seam.clear",
|
|
356
|
+
"seam.judge",
|
|
357
|
+
])
|
|
358
|
+
expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
|
|
178
359
|
})
|
|
179
360
|
|
|
180
361
|
it("refactor-first runs the reviewed refactor in slice refactor and resolves HEAD after it", async () => {
|
|
@@ -251,6 +432,11 @@ describe("implement", () => {
|
|
|
251
432
|
return request.prompt
|
|
252
433
|
}
|
|
253
434
|
|
|
435
|
+
it("builds on the coder model", async () => {
|
|
436
|
+
await building("abc")
|
|
437
|
+
expect(lastOptions?.model).toBe("base")
|
|
438
|
+
})
|
|
439
|
+
|
|
254
440
|
it("after a refactor, lets an empty turn complete the step and says so", async () => {
|
|
255
441
|
const prompt = await building("abc", undefined, undefined, () =>
|
|
256
442
|
implement({ reference: "abc", afterRefactor: true }),
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
+
import { posix } from "node:path"
|
|
1
2
|
import {
|
|
2
3
|
changes,
|
|
4
|
+
check,
|
|
5
|
+
env,
|
|
3
6
|
head,
|
|
4
7
|
human,
|
|
5
8
|
judgeDiffShape,
|
|
6
9
|
kept,
|
|
10
|
+
quote,
|
|
11
|
+
read,
|
|
7
12
|
removeScript,
|
|
8
13
|
run,
|
|
9
14
|
scope,
|
|
10
15
|
slice,
|
|
11
16
|
vars,
|
|
17
|
+
type Change,
|
|
12
18
|
type DiffShapeResult,
|
|
13
19
|
type KeptTip,
|
|
14
20
|
} from "../../flows/index.js"
|
|
@@ -16,12 +22,19 @@ import { healthy } from "../shared/health.js"
|
|
|
16
22
|
import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
|
|
17
23
|
import * as t from "../shared/text.js"
|
|
18
24
|
import { defaults } from "../shared/vars.js"
|
|
19
|
-
import { requireCriteria, requireCriteriaFrozen } from "./criteria.js"
|
|
20
|
-
import {
|
|
25
|
+
import { criteriaOf, requireCriteria, requireCriteriaFrozen } from "./criteria.js"
|
|
26
|
+
import {
|
|
27
|
+
COMMENT,
|
|
28
|
+
holdsCallSites,
|
|
29
|
+
isTestFile,
|
|
30
|
+
refactorStrays,
|
|
31
|
+
reviewedRefactor,
|
|
32
|
+
} from "./refactor.js"
|
|
33
|
+
import { isCode } from "./review.js"
|
|
21
34
|
import { flagModeOn } from "../shared/options.js"
|
|
22
35
|
import type { RiskTier } from "./tier.js"
|
|
23
36
|
|
|
24
|
-
/** Naive-pass retries after a `reject
|
|
37
|
+
/** Naive-pass retries after a `reject` or a red suite; the next one rests at `seam.steer`. */
|
|
25
38
|
const NAIVE_RETRIES = 2
|
|
26
39
|
|
|
27
40
|
/** A change to an existing file outside the allowlist is a stray even when the task needs it: the judge decides. */
|
|
@@ -35,14 +48,121 @@ export const judgeSeam = (tip: KeptTip): Promise<DiffShapeResult> =>
|
|
|
35
48
|
exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path),
|
|
36
49
|
})
|
|
37
50
|
|
|
38
|
-
export const naivePass = (
|
|
39
|
-
t.agentWithSkills("naive", t.naivePassPrompt(
|
|
40
|
-
label:
|
|
51
|
+
export const naivePass = (retry?: t.NaiveRetry): Promise<void> =>
|
|
52
|
+
t.agentWithSkills("naive", t.naivePassPrompt(retry), {
|
|
53
|
+
label: retry === undefined ? "Writing the naive pass" : "Rewriting the naive pass",
|
|
41
54
|
keep: true,
|
|
42
55
|
model: coder(),
|
|
43
56
|
system: t.builderSystem(),
|
|
44
57
|
})
|
|
45
58
|
|
|
59
|
+
// Only a string an import, `require` or `mock` call takes: a URL route or path-like data is not one.
|
|
60
|
+
const IMPORTED = /(?:\bfrom|\bimport|\brequire\s*\(|\bmock\s*\()\s*\(?\s*(["'`])([^"'`\n]+)\1/g
|
|
61
|
+
const EXTENSION = /\.[\w-]+$/
|
|
62
|
+
const ALIAS = /^(?:[@~]\/|#)/
|
|
63
|
+
|
|
64
|
+
/** The specifiers an added `path` answers to: itself, without its extension, and its directory for an `index`. */
|
|
65
|
+
const namesOf = (path: string): string[] => {
|
|
66
|
+
const bare = path.replace(EXTENSION, "")
|
|
67
|
+
return posix.basename(bare) === "index" ? [path, bare, posix.dirname(bare)] : [path, bare]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The repository paths `test` imports, each with and without an extension: a
|
|
72
|
+
* relative specifier resolved from its directory, an aliased one (`@/`, `~/`,
|
|
73
|
+
* `#`) as a path tail, anything else as written. Comments are skipped; a
|
|
74
|
+
* computed import goes unseen.
|
|
75
|
+
*/
|
|
76
|
+
const importsOf = (test: Change): { readonly path: string; readonly tail: boolean }[] =>
|
|
77
|
+
[...(test.after ?? "").replace(COMMENT, "").matchAll(IMPORTED)].flatMap(([, , spec]) => {
|
|
78
|
+
const relative = /^\.\.?(\/|$)/.test(spec!)
|
|
79
|
+
const tail = ALIAS.test(spec!)
|
|
80
|
+
const path = relative ? posix.join(posix.dirname(test.path), spec!) : spec!.replace(ALIAS, "")
|
|
81
|
+
return [path, path.replace(EXTENSION, "")].map((p) => ({ path: p, tail }))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const reaches = (imports: ReturnType<typeof importsOf>, added: string): boolean =>
|
|
85
|
+
namesOf(added).some((name) =>
|
|
86
|
+
imports.some(({ path, tail }) => name === path || (tail && name.endsWith(`/${path}`))),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const named = (id: string, text: string): boolean =>
|
|
90
|
+
new RegExp(`(?<![\\w-])${id}(?!\\d)`).test(text)
|
|
91
|
+
|
|
92
|
+
/** The lines `change` added: an ID an earlier change left in the file does not count. */
|
|
93
|
+
const addedLines = (change: Change): string => {
|
|
94
|
+
const before = new Set((change.before ?? "").split("\n"))
|
|
95
|
+
return change
|
|
96
|
+
.after!.split("\n")
|
|
97
|
+
.filter((line) => !before.has(line))
|
|
98
|
+
.join("\n")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
|
|
103
|
+
* test line of the pass names, or a test importing a file the pass added —
|
|
104
|
+
* naive structure a reset and refactor replaces, which the tests must survive.
|
|
105
|
+
*/
|
|
106
|
+
export const acceptanceFault = (file: string, tip: KeptTip): string | undefined => {
|
|
107
|
+
const ids = (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "")
|
|
108
|
+
const tests = tip.changes.filter(
|
|
109
|
+
(c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path),
|
|
110
|
+
)
|
|
111
|
+
const lines = tests.map(addedLines)
|
|
112
|
+
const untested = ids.filter((id) => !lines.some((text) => named(id, text)))
|
|
113
|
+
if (untested.length > 0)
|
|
114
|
+
return `acceptance-tests: no test of the naive pass names ${untested.join(", ")} — write a behaviour-level test for each criterion with its ID in the test or scenario name, e.g. "[${untested[0]}] …"`
|
|
115
|
+
const added = tip.changes
|
|
116
|
+
.filter((c) => c.status === "added" && isCode(c.path) && !isTestFile(c.path))
|
|
117
|
+
.map((c) => c.path)
|
|
118
|
+
const reaching = tests
|
|
119
|
+
.filter((test) => holdsCallSites(test.path))
|
|
120
|
+
.flatMap((test) => {
|
|
121
|
+
const imports = importsOf(test)
|
|
122
|
+
return added
|
|
123
|
+
.filter((path) => reaches(imports, path))
|
|
124
|
+
.map((path) => ` - ${test.path} → ${path}`)
|
|
125
|
+
})
|
|
126
|
+
if (reaching.length > 0)
|
|
127
|
+
return `acceptance-internals: these tests import files the naive pass added — test through code that existed before it (a public entry point, an existing module's exports):\n${reaching.join("\n")}`
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const restore = (path: string): string =>
|
|
132
|
+
`if git cat-file -e ${quote(`HEAD:${path}`)} 2>/dev/null; then git checkout -q HEAD -- ${quote(path)}; else rm -f -- ${quote(path)}; fi`
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Run the suite with `tip`'s diff applied, then take it out again; resolves
|
|
136
|
+
* `true` when green. The pass's paths go back to HEAD before (an interrupted
|
|
137
|
+
* run left them applied) and after; afterwards so does anything else the run
|
|
138
|
+
* changed, judged against what was already dirty, which is left alone.
|
|
139
|
+
*/
|
|
140
|
+
const naiveGreen = (tip: KeptTip): Promise<boolean> => {
|
|
141
|
+
const paths = tip.changes.map((c) => restore(c.path))
|
|
142
|
+
return check(
|
|
143
|
+
"check",
|
|
144
|
+
[
|
|
145
|
+
...paths,
|
|
146
|
+
"dirty=$(git -c core.quotePath=false ls-files -m -d -o --exclude-standard)",
|
|
147
|
+
`if ! git diff --binary ${quote(`${tip.hash}^`)} ${quote(tip.hash)} -- | git apply; then`,
|
|
148
|
+
" echo 'gtd: the kept naive pass no longer applies to HEAD; no test ran'",
|
|
149
|
+
" exit 1",
|
|
150
|
+
"fi",
|
|
151
|
+
"(",
|
|
152
|
+
env.testCommand ?? "",
|
|
153
|
+
")",
|
|
154
|
+
"code=$?",
|
|
155
|
+
...paths,
|
|
156
|
+
"git -c core.quotePath=false ls-files -m -d -o --exclude-standard | while IFS= read -r path; do",
|
|
157
|
+
' printf \'%s\\n\' "$dirty" | grep -qxF -- "$path" && continue',
|
|
158
|
+
' if git cat-file -e "HEAD:$path" 2>/dev/null; then git checkout -q HEAD -- "$path"; else rm -f -- "$path"; fi',
|
|
159
|
+
"done",
|
|
160
|
+
'exit "$code"',
|
|
161
|
+
].join("\n"),
|
|
162
|
+
{ report: FEEDBACK, label: "Running the naive pass's tests" },
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
46
166
|
export interface ImplementOptions {
|
|
47
167
|
readonly reference?: string | undefined
|
|
48
168
|
readonly tier?: RiskTier | undefined
|
|
@@ -103,34 +223,79 @@ interface Decision {
|
|
|
103
223
|
readonly shape?: DiffShapeResult
|
|
104
224
|
}
|
|
105
225
|
|
|
226
|
+
/** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
|
|
227
|
+
const steer = async (retry: t.NaiveRetry): Promise<boolean> => {
|
|
228
|
+
await human("steer", {
|
|
229
|
+
message: t.seamSteerMessage(retry),
|
|
230
|
+
label: "Steer or overrule the naive pass",
|
|
231
|
+
file: REQUIREMENTS,
|
|
232
|
+
acceptClean: true,
|
|
233
|
+
})
|
|
234
|
+
if (changes().length === 0) return false
|
|
235
|
+
requireCriteria(REQUIREMENTS)
|
|
236
|
+
return true
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
type Verdict =
|
|
240
|
+
| { readonly kind: "retry"; readonly retry: t.NaiveRetry }
|
|
241
|
+
| { readonly kind: "steered" }
|
|
242
|
+
| { readonly kind: "overruled" }
|
|
243
|
+
|
|
244
|
+
/** Rejects, test faults and red checks share one budget; a steer or an overrule refills it. */
|
|
245
|
+
const budget = (): ((next: t.NaiveRetry) => Promise<Verdict>) => {
|
|
246
|
+
let retries = 0
|
|
247
|
+
return async (next) => {
|
|
248
|
+
if (retries < NAIVE_RETRIES) {
|
|
249
|
+
retries++
|
|
250
|
+
return { kind: "retry", retry: next }
|
|
251
|
+
}
|
|
252
|
+
const steered = await steer(next)
|
|
253
|
+
retries = 0
|
|
254
|
+
// A steered or overruled red pass leaves its report behind: nothing reads it any more.
|
|
255
|
+
if (read(FEEDBACK) !== undefined)
|
|
256
|
+
await run("clear", removeScript([FEEDBACK]), { label: "Clearing the red check" })
|
|
257
|
+
return { kind: steered ? "steered" : "overruled" }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface Vetting {
|
|
262
|
+
readonly again: ReturnType<typeof budget>
|
|
263
|
+
/** Set once a human overrules a test fault: the rule does not fit this change, so later passes skip it. */
|
|
264
|
+
waived: boolean
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
|
|
268
|
+
const vetted = async (tip: KeptTip, vetting: Vetting): Promise<Verdict | undefined> => {
|
|
269
|
+
const fault = vetting.waived ? undefined : acceptanceFault(REQUIREMENTS, tip)
|
|
270
|
+
if (fault !== undefined) {
|
|
271
|
+
const verdict = await vetting.again({ fault, hash: tip.hash })
|
|
272
|
+
if (verdict.kind !== "overruled") return verdict
|
|
273
|
+
vetting.waived = true
|
|
274
|
+
}
|
|
275
|
+
if (await naiveGreen(tip)) return undefined
|
|
276
|
+
const verdict = await vetting.again({ red: tip.hash })
|
|
277
|
+
return verdict.kind === "overruled" ? undefined : verdict
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
|
|
281
|
+
const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision | Verdict> => {
|
|
282
|
+
const shape = await judgeSeam(tip)
|
|
283
|
+
if (shape.choice === "land") return { reference: tip.hash }
|
|
284
|
+
if (shape.choice === "refactor-first") return { reference: tip.hash, shape }
|
|
285
|
+
const next = await again({ strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 })
|
|
286
|
+
return next.kind === "overruled" ? { reference: tip.hash } : next
|
|
287
|
+
}
|
|
288
|
+
|
|
106
289
|
const decide = (): Promise<Decision> =>
|
|
107
290
|
scope("seam", async () => {
|
|
108
|
-
|
|
109
|
-
let
|
|
291
|
+
const vetting: Vetting = { again: budget(), waived: false }
|
|
292
|
+
let retry: t.NaiveRetry | undefined
|
|
110
293
|
for (;;) {
|
|
111
|
-
await naivePass(
|
|
294
|
+
await naivePass(retry)
|
|
112
295
|
const tip = kept("naive")!
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
return { reference: tip.hash, shape }
|
|
117
|
-
}
|
|
118
|
-
rejection = { strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 }
|
|
119
|
-
if (retries < NAIVE_RETRIES) {
|
|
120
|
-
retries++
|
|
121
|
-
continue
|
|
122
|
-
}
|
|
123
|
-
await human("steer", {
|
|
124
|
-
message: t.seamSteerMessage(rejection),
|
|
125
|
-
label: "Steer or overrule the rejected naive pass",
|
|
126
|
-
file: REQUIREMENTS,
|
|
127
|
-
acceptClean: true,
|
|
128
|
-
})
|
|
129
|
-
// Any edit steers, with a fresh budget and no stale rejection; clean overrules.
|
|
130
|
-
if (changes().length === 0) return { reference: tip.hash }
|
|
131
|
-
requireCriteria(REQUIREMENTS)
|
|
132
|
-
rejection = undefined
|
|
133
|
-
retries = 0
|
|
296
|
+
const outcome = (await vetted(tip, vetting)) ?? (await ruled(tip, vetting.again))
|
|
297
|
+
if (!("kind" in outcome)) return outcome
|
|
298
|
+
retry = outcome.kind === "retry" ? outcome.retry : undefined
|
|
134
299
|
}
|
|
135
300
|
})
|
|
136
301
|
|