@pmelab/gtd 38.0.0 → 38.2.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 +36 -3
- package/dist/chunks/{Schema-DO5ttJaI.mjs → Schema-C6fLlQzx.mjs} +1 -1
- package/dist/chunks/{reserved-sfMdy7v7.mjs → reserved-C3PgP7XB.mjs} +56 -12
- package/dist/chunks/{tail-Cgca5Gp-.mjs → tail-Bkxki2nK.mjs} +1 -1
- package/dist/chunks/{wire-CSsa3Wxd.mjs → wire-BJuZWR3C.mjs} +1 -1
- package/dist/gtd.bundle.mjs +292 -53
- package/dist/testkit.mjs +257 -21
- package/dist/wire.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +158 -42
- 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 +230 -70
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +15 -3
- package/src/cli/Cli.ts +61 -28
- package/src/cli/History.ts +1 -19
- package/src/cli/Metrics.test.ts +224 -0
- package/src/cli/Metrics.ts +310 -0
- package/src/cli/Output.ts +13 -0
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +4 -0
- package/src/testkit/steps.ts +4 -0
- package/src/wire/ContextLimit.ts +1 -1
- package/src/workflows/feature/continuation-cap.feature +65 -0
- package/src/workflows/feature/continuation-rebuild.feature +88 -0
- package/src/workflows/feature/continuation-slices.feature +64 -0
- package/src/workflows/feature/continuation.feature +205 -0
- package/src/workflows/feature/criteria.ts +11 -0
- package/src/workflows/feature/refactor.ts +64 -30
- package/src/workflows/feature/seam.ts +102 -30
- package/src/workflows/shared/continuation.ts +92 -0
- package/src/workflows/shared/options.ts +2 -0
- package/src/workflows/shared/steps.ts +1 -0
- package/src/workflows/shared/text.test.ts +6 -2
- package/src/workflows/shared/text.ts +104 -8
- package/src/workflows/shared/vars.ts +5 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: a turn stopped at its context limit is snapshotted and continued
|
|
3
|
+
|
|
4
|
+
A naive pass, refactor or build turn told to stop at its context limit
|
|
5
|
+
writes its handoff note to `.gtd/HANDOFF.md`. That turn is the snapshot —
|
|
6
|
+
the naive pass's kept tip, or the refactor's or build's own commit — and a
|
|
7
|
+
continuation in a fresh conversation (`<phase>.continue.<step>`) picks up
|
|
8
|
+
from it with the note and what is still open. After `continuations` (3) of
|
|
9
|
+
them, or `continuationStall` (2) in a row that close nothing still open, the
|
|
10
|
+
phase rests at `<phase>.oversized`.
|
|
11
|
+
|
|
12
|
+
Background:
|
|
13
|
+
Given the shipped workflows, as gtd install scaffolds them
|
|
14
|
+
And an environment variable "GTD_QUALITYREVIEWS" set to ""
|
|
15
|
+
And an environment variable "GTD_REFACTORQUALITYREVIEWS" set to ""
|
|
16
|
+
And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
|
|
17
|
+
And a file "src/calc.ts" with:
|
|
18
|
+
"""
|
|
19
|
+
export const add = (a: number, b: number) => a + b
|
|
20
|
+
"""
|
|
21
|
+
And the working tree is committed as "feat: add calculator"
|
|
22
|
+
And a file "TODO.md" with:
|
|
23
|
+
"""
|
|
24
|
+
# Add subtract and multiply
|
|
25
|
+
"""
|
|
26
|
+
And step "unwind" changes nothing
|
|
27
|
+
And step "start-gate.check" changes nothing
|
|
28
|
+
And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
29
|
+
"""
|
|
30
|
+
# Requirements
|
|
31
|
+
|
|
32
|
+
## Acceptance Criteria
|
|
33
|
+
|
|
34
|
+
- AC-1: `subtract(5, 3)` returns 2.
|
|
35
|
+
- AC-2: `multiply(2, 3)` returns 6.
|
|
36
|
+
"""
|
|
37
|
+
And step "design.gate.answer" changes nothing
|
|
38
|
+
And step "seam.check" changes nothing
|
|
39
|
+
|
|
40
|
+
Scenario: a naive pass stopped at its context limit is continued from its kept tip with the open IDs
|
|
41
|
+
Given step "seam.naive" writes "tests/calc.test.ts" with:
|
|
42
|
+
"""
|
|
43
|
+
it("[AC-1] subtracts", () => {})
|
|
44
|
+
"""
|
|
45
|
+
And step "seam.naive" writes ".gtd/HANDOFF.md" with:
|
|
46
|
+
"""
|
|
47
|
+
Wrote subtract and its test; multiply was next.
|
|
48
|
+
"""
|
|
49
|
+
When the process is driven
|
|
50
|
+
Then the process rests at "seam.continue.naive"
|
|
51
|
+
And the resting step's content contains "Your previous turn stopped at its context limit"
|
|
52
|
+
And the resting step's content contains "Still open: AC-2."
|
|
53
|
+
And the resting step's content contains "Wrote subtract and its test; multiply was next."
|
|
54
|
+
And the resting step's content contains "':(top)' ':(top,exclude).gtd/HANDOFF.md' | git apply"
|
|
55
|
+
And ".gtd/HANDOFF.md" does not exist
|
|
56
|
+
And the output of gtd "next --json" contains "\"resume\":false"
|
|
57
|
+
And the output of gtd "next --json" contains "\"memory\":\"seam.continue#"
|
|
58
|
+
|
|
59
|
+
Given step "seam.continue.naive" writes "tests/calc.test.ts" with:
|
|
60
|
+
"""
|
|
61
|
+
it("[AC-1] subtracts", () => {})
|
|
62
|
+
it("[AC-2] multiplies", () => {})
|
|
63
|
+
"""
|
|
64
|
+
When the process is driven
|
|
65
|
+
Then the process rests at "implement.applying"
|
|
66
|
+
And the commit subjects end with:
|
|
67
|
+
"""
|
|
68
|
+
gtd(human): design.gate.answer → seam.naive
|
|
69
|
+
gtd(agent): seam.naive → seam.continue.naive
|
|
70
|
+
gtd(agent): seam.continue.naive → seam.check
|
|
71
|
+
gtd(check): seam.check → implement.applying
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
Scenario: two continuations in a row that close nothing rest for a person
|
|
75
|
+
Given step "seam.naive" writes "tests/calc.test.ts" with:
|
|
76
|
+
"""
|
|
77
|
+
it("[AC-1] subtracts", () => {})
|
|
78
|
+
"""
|
|
79
|
+
And step "seam.naive" writes ".gtd/HANDOFF.md" with:
|
|
80
|
+
"""
|
|
81
|
+
Subtract is tested; multiply was next.
|
|
82
|
+
"""
|
|
83
|
+
And step "seam.continue.naive" writes ".gtd/HANDOFF.md" with:
|
|
84
|
+
"""
|
|
85
|
+
Still on multiply.
|
|
86
|
+
"""
|
|
87
|
+
When the process is driven
|
|
88
|
+
Then the process rests at "seam.oversized"
|
|
89
|
+
And the resting step's content contains "the last 2 continuations closed nothing still open"
|
|
90
|
+
And the commit subjects end with:
|
|
91
|
+
"""
|
|
92
|
+
gtd(human): design.gate.answer → seam.naive
|
|
93
|
+
gtd(agent): seam.naive → seam.continue.naive
|
|
94
|
+
gtd(agent): seam.continue.naive
|
|
95
|
+
gtd(agent): seam.continue.naive → seam.oversized
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
Scenario: a refactor stopped at its context limit is run green before its continuation
|
|
99
|
+
Given step "seam.naive" writes "tests/calc.test.ts" with:
|
|
100
|
+
"""
|
|
101
|
+
it("[AC-1] subtracts", () => {})
|
|
102
|
+
it("[AC-2] multiplies", () => {})
|
|
103
|
+
"""
|
|
104
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
105
|
+
"""
|
|
106
|
+
export const add = (a: number, b: number) => a + b
|
|
107
|
+
export const subtract = (a: number, b: number) => a - b
|
|
108
|
+
export const multiply = (a: number, b: number) => a * b
|
|
109
|
+
"""
|
|
110
|
+
And judge step "seam.judge" answers:
|
|
111
|
+
"""
|
|
112
|
+
[{"id":"shape","answer":"refactor-first","p":0.9}]
|
|
113
|
+
"""
|
|
114
|
+
And step "seam.note" writes ".gtd/REFACTOR-NOTE.md" with:
|
|
115
|
+
"""
|
|
116
|
+
Seam: a shared `op` in src/calc.ts.
|
|
117
|
+
"""
|
|
118
|
+
And step "refactor.deriving" runs its script
|
|
119
|
+
And step "refactor.building" writes ".gtd/HANDOFF.md" with:
|
|
120
|
+
"""
|
|
121
|
+
Started on op; nothing changed yet.
|
|
122
|
+
"""
|
|
123
|
+
And step "refactor.health.check" changes nothing
|
|
124
|
+
When the process is driven
|
|
125
|
+
Then the process rests at "refactor.continue.building"
|
|
126
|
+
And the resting step's content contains "Still open: src/calc.ts."
|
|
127
|
+
And the resting step's content contains "Started on op; nothing changed yet."
|
|
128
|
+
And the commit subjects end with:
|
|
129
|
+
"""
|
|
130
|
+
gtd(agent): refactor.building → refactor.health.check
|
|
131
|
+
gtd(check): refactor.health.check → refactor.continue.building
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
Scenario: a refactor continuation that leaves the note in place stopped again
|
|
135
|
+
Given step "seam.naive" writes "tests/calc.test.ts" with:
|
|
136
|
+
"""
|
|
137
|
+
it("[AC-1] subtracts", () => {})
|
|
138
|
+
it("[AC-2] multiplies", () => {})
|
|
139
|
+
"""
|
|
140
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
141
|
+
"""
|
|
142
|
+
export const add = (a: number, b: number) => a + b
|
|
143
|
+
export const subtract = (a: number, b: number) => a - b
|
|
144
|
+
"""
|
|
145
|
+
And judge step "seam.judge" answers:
|
|
146
|
+
"""
|
|
147
|
+
[{"id":"shape","answer":"refactor-first","p":0.9}]
|
|
148
|
+
"""
|
|
149
|
+
And step "seam.note" writes ".gtd/REFACTOR-NOTE.md" with:
|
|
150
|
+
"""
|
|
151
|
+
Seam: a shared `op` in src/calc.ts.
|
|
152
|
+
"""
|
|
153
|
+
And step "refactor.deriving" runs its script
|
|
154
|
+
And step "refactor.building" writes "src/calc.ts" with:
|
|
155
|
+
"""
|
|
156
|
+
export const op = (a: number, b: number, sign: number) => a + sign * b
|
|
157
|
+
export const add = (a: number, b: number) => a + b
|
|
158
|
+
"""
|
|
159
|
+
And step "refactor.building" writes ".gtd/HANDOFF.md" with:
|
|
160
|
+
"""
|
|
161
|
+
op is in; routing add through it was next.
|
|
162
|
+
"""
|
|
163
|
+
And step "refactor.health.check" changes nothing
|
|
164
|
+
And step "refactor.continue.building" writes "src/calc.ts" with:
|
|
165
|
+
"""
|
|
166
|
+
export const op = (a: number, b: number, sign: number) => a + sign * b
|
|
167
|
+
export const add = (a: number, b: number) => op(a, b, 1)
|
|
168
|
+
"""
|
|
169
|
+
When the process is driven
|
|
170
|
+
Then the process rests at "refactor.continue.building"
|
|
171
|
+
And the commit subjects end with:
|
|
172
|
+
"""
|
|
173
|
+
gtd(agent): refactor.building → refactor.health.check
|
|
174
|
+
gtd(check): refactor.health.check → refactor.continue.building
|
|
175
|
+
gtd(agent): refactor.continue.building → refactor.health.check
|
|
176
|
+
gtd(check): refactor.health.check → refactor.continue.building
|
|
177
|
+
gtd(agent): refactor.continue.building
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
Scenario: once a naive pass has continued, its retry runs in a fresh conversation, not as a continuation
|
|
181
|
+
Given step "seam.naive" writes "tests/calc.test.ts" with:
|
|
182
|
+
"""
|
|
183
|
+
it("[AC-1] subtracts", () => {})
|
|
184
|
+
"""
|
|
185
|
+
And step "seam.naive" writes ".gtd/HANDOFF.md" with:
|
|
186
|
+
"""
|
|
187
|
+
Multiply was next.
|
|
188
|
+
"""
|
|
189
|
+
And step "seam.continue.naive" writes "tests/calc.test.ts" with:
|
|
190
|
+
"""
|
|
191
|
+
it("[AC-1] subtracts", () => {})
|
|
192
|
+
it("[AC-2] multiplies", () => {})
|
|
193
|
+
"""
|
|
194
|
+
And step "seam.continue.naive" writes "src/calc.ts" with:
|
|
195
|
+
"""
|
|
196
|
+
export const add = (a: number, b: number) => a - b
|
|
197
|
+
"""
|
|
198
|
+
And judge step "seam.judge" answers:
|
|
199
|
+
"""
|
|
200
|
+
[{"id":"shape","answer":"reject","p":0.9}]
|
|
201
|
+
"""
|
|
202
|
+
When the process is driven
|
|
203
|
+
Then the process rests at "seam.fresh.naive"
|
|
204
|
+
And the resting step's content contains "rejected"
|
|
205
|
+
And the output of gtd "next --json" contains "\"resume\":false"
|
|
@@ -189,3 +189,14 @@ export const skipDesign = (stated: StatedCriteria | undefined): Promise<boolean>
|
|
|
189
189
|
})
|
|
190
190
|
return true
|
|
191
191
|
})
|
|
192
|
+
|
|
193
|
+
/** After a gate that invites narrowing the requirements: an edit must leave valid criteria. */
|
|
194
|
+
export const requireCriteriaIfEdited = (): void => {
|
|
195
|
+
const change = changes().get(REQUIREMENTS)
|
|
196
|
+
if (change === undefined) return
|
|
197
|
+
if (change.status === "deleted")
|
|
198
|
+
refuse(
|
|
199
|
+
`gtd land: criteria-missing: ${REQUIREMENTS} must stay — narrow its criteria, never delete it`,
|
|
200
|
+
)
|
|
201
|
+
requireCriteria(REQUIREMENTS)
|
|
202
|
+
}
|
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
} from "../../flows/index.js"
|
|
16
16
|
import { lensEntriesOf } from "../../workflow-kit/index.js"
|
|
17
17
|
import { markFenced } from "../shared/fences.js"
|
|
18
|
+
import { continuing, phase, type Phase } from "../shared/continuation.js"
|
|
18
19
|
import { healthy, type Guard } from "../shared/health.js"
|
|
19
|
-
import { coder, FEEDBACK, REFACTOR } from "../shared/steps.js"
|
|
20
|
+
import { coder, FEEDBACK, HANDOFF, REFACTOR } from "../shared/steps.js"
|
|
20
21
|
import * as t from "../shared/text.js"
|
|
21
22
|
import { defaults, testFileGlobs } from "../shared/vars.js"
|
|
22
23
|
import { buildTail, reUnwind, type FeedbackTarget } from "./review.js"
|
|
@@ -263,7 +264,12 @@ const guardOf =
|
|
|
263
264
|
}
|
|
264
265
|
|
|
265
266
|
/** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
|
|
266
|
-
const refactorLap = async (
|
|
267
|
+
const refactorLap = async (
|
|
268
|
+
p: Plan,
|
|
269
|
+
guard: Guard,
|
|
270
|
+
refactor: Phase,
|
|
271
|
+
feedback?: string,
|
|
272
|
+
): Promise<void> => {
|
|
267
273
|
const { pattern, strays, reference, note } = p
|
|
268
274
|
await run(
|
|
269
275
|
"deriving",
|
|
@@ -276,32 +282,59 @@ const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<vo
|
|
|
276
282
|
]),
|
|
277
283
|
{ label: "Deriving the refactor package", file: REFACTOR_PACKAGE },
|
|
278
284
|
)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
285
|
+
const reached = new Set<string>()
|
|
286
|
+
// Each snapshot is greened before its continuation: every sub-step keeps the old suite green.
|
|
287
|
+
await continuing(refactor, async (from, fresh) => {
|
|
288
|
+
await fresh(() =>
|
|
289
|
+
t.agentWithSkills(
|
|
290
|
+
"building",
|
|
291
|
+
t.refactorBuildingPrompt({
|
|
292
|
+
pkg: REFACTOR_PACKAGE,
|
|
293
|
+
strays,
|
|
294
|
+
pattern,
|
|
295
|
+
reference,
|
|
296
|
+
feedback: feedback !== undefined,
|
|
297
|
+
note: note !== undefined,
|
|
298
|
+
from,
|
|
299
|
+
}),
|
|
300
|
+
{
|
|
301
|
+
label: from === undefined ? "Refactoring" : "Continuing the refactor",
|
|
302
|
+
model: coder(),
|
|
303
|
+
system: t.builderSystem(),
|
|
304
|
+
},
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
// Committed by the snapshot, so a turn stopped again may leave it as it was: present means stopped.
|
|
308
|
+
const left = read(HANDOFF)
|
|
309
|
+
guard()
|
|
310
|
+
const snapshot = head()
|
|
311
|
+
// The building turns' own changes only: a fix turn reaching a path is not the refactor reaching it.
|
|
312
|
+
for (const c of changes()) reached.add(c.path)
|
|
313
|
+
await healthy(
|
|
314
|
+
async () => {
|
|
315
|
+
await t.agentWithSkills("fix.suite.fixing", t.refactorFixPrompt(strays, pattern), {
|
|
316
|
+
label: "Fixing the check",
|
|
317
|
+
file: FEEDBACK,
|
|
318
|
+
model: coder(),
|
|
319
|
+
system: t.builderSystem(),
|
|
320
|
+
})
|
|
321
|
+
guard()
|
|
322
|
+
},
|
|
323
|
+
{ describeGuard: guard, rules: t.refactorRules(strays, pattern) },
|
|
324
|
+
)
|
|
325
|
+
if (left === undefined) return { result: undefined }
|
|
326
|
+
return {
|
|
327
|
+
result: undefined,
|
|
328
|
+
handoff: {
|
|
329
|
+
snapshot,
|
|
330
|
+
note: left.trim(),
|
|
331
|
+
open: strays.filter((path) => !reached.has(path)),
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
await run("closing", removeScript([REFACTOR_PACKAGE, HANDOFF]), {
|
|
336
|
+
label: "Closing out the refactor",
|
|
337
|
+
})
|
|
305
338
|
}
|
|
306
339
|
|
|
307
340
|
const strayAccess = (strays: readonly string[]) => ({
|
|
@@ -315,7 +348,7 @@ export const refactorFirst = async (
|
|
|
315
348
|
const p = plan(shape, options)
|
|
316
349
|
if (p === undefined) return false
|
|
317
350
|
return scope({ name: "refactor", access: strayAccess(p.strays) }, async () => {
|
|
318
|
-
await refactorLap(p, guardOf(p, head()))
|
|
351
|
+
await refactorLap(p, guardOf(p, head()), phase("refactor"))
|
|
319
352
|
return true
|
|
320
353
|
})
|
|
321
354
|
}
|
|
@@ -342,9 +375,10 @@ export const reviewedRefactor = async (
|
|
|
342
375
|
"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",
|
|
343
376
|
dropped: t.refactorDroppedRefusal,
|
|
344
377
|
}
|
|
378
|
+
const refactor = phase("refactor")
|
|
345
379
|
let collected: string | undefined
|
|
346
380
|
for (;;) {
|
|
347
|
-
await refactorLap(p, guard, collected)
|
|
381
|
+
await refactorLap(p, guard, refactor, collected)
|
|
348
382
|
// A person's edit at an escalation rest is never refused, as in the lap.
|
|
349
383
|
const outcome = await buildTail(entry, {
|
|
350
384
|
green: true,
|
|
@@ -18,13 +18,15 @@ import {
|
|
|
18
18
|
run,
|
|
19
19
|
scope,
|
|
20
20
|
slice as sliced,
|
|
21
|
+
start,
|
|
21
22
|
vars,
|
|
22
23
|
wrote,
|
|
23
24
|
type Change,
|
|
24
25
|
type DiffShapeResult,
|
|
25
26
|
type KeptTip,
|
|
26
27
|
} from "../../flows/index.js"
|
|
27
|
-
import {
|
|
28
|
+
import { continuing, phase, type Handoff, type Phase } from "../shared/continuation.js"
|
|
29
|
+
import { coder, FEEDBACK, HANDOFF, REFACTOR_NOTE, REQUIREMENTS, SLICES } from "../shared/steps.js"
|
|
28
30
|
import * as t from "../shared/text.js"
|
|
29
31
|
import { defaults } from "../shared/vars.js"
|
|
30
32
|
import { green, requireCoverage } from "./coverage.js"
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
criterionIds,
|
|
33
35
|
requireCriteria,
|
|
34
36
|
requireCriteriaFrozen,
|
|
37
|
+
requireCriteriaIfEdited,
|
|
35
38
|
sliceIds,
|
|
36
39
|
type CriteriaSlice,
|
|
37
40
|
} from "./criteria.js"
|
|
@@ -60,14 +63,47 @@ export const judgeSeam = (tip: KeptTip): Promise<DiffShapeResult> =>
|
|
|
60
63
|
exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path),
|
|
61
64
|
})
|
|
62
65
|
|
|
63
|
-
export const naivePass = (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
export const naivePass = (
|
|
67
|
+
retry?: t.NaiveRetry,
|
|
68
|
+
slice?: CriteriaSlice,
|
|
69
|
+
from?: Handoff,
|
|
70
|
+
): Promise<void> =>
|
|
71
|
+
t.agentWithSkills("naive", t.naivePassPrompt(retry, sliceIds(slice), from), {
|
|
72
|
+
label:
|
|
73
|
+
from !== undefined
|
|
74
|
+
? "Continuing the naive pass"
|
|
75
|
+
: retry === undefined
|
|
76
|
+
? "Writing the naive pass"
|
|
77
|
+
: "Rewriting the naive pass",
|
|
66
78
|
keep: true,
|
|
67
79
|
model: coder(),
|
|
68
80
|
system: t.builderSystem(),
|
|
69
81
|
})
|
|
70
82
|
|
|
83
|
+
const handoffOf = (tip: KeptTip, slice: CriteriaSlice | undefined): Handoff | undefined => {
|
|
84
|
+
const note = tip.changes.find((c) => c.path === HANDOFF && c.after !== undefined)
|
|
85
|
+
if (note === undefined) return undefined
|
|
86
|
+
return {
|
|
87
|
+
snapshot: tip.hash,
|
|
88
|
+
note: note.after!.trim(),
|
|
89
|
+
open: unnamed(idsOf(REQUIREMENTS, slice), acceptanceTests(tip.changes)),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A naive pass, continued until a pass ends without a handoff note: the snapshot is its kept tip. */
|
|
94
|
+
const naiveContinued = (
|
|
95
|
+
retry: t.NaiveRetry | undefined,
|
|
96
|
+
slice: CriteriaSlice | undefined,
|
|
97
|
+
naive: Phase,
|
|
98
|
+
) =>
|
|
99
|
+
continuing(naive, async (from, fresh) => {
|
|
100
|
+
const tip = await fresh(async () => {
|
|
101
|
+
await naivePass(retry, slice, from)
|
|
102
|
+
return kept("naive")!
|
|
103
|
+
})
|
|
104
|
+
return { result: tip, handoff: handoffOf(tip, slice) }
|
|
105
|
+
})
|
|
106
|
+
|
|
71
107
|
// Only a string an import, `require` or `mock` call takes: a URL route or path-like data is not one.
|
|
72
108
|
const IMPORTED = /(?:\bfrom|\bimport|\brequire\s*\(|\bmock\s*\()\s*\(?\s*(["'`])([^"'`\n]+)\1/g
|
|
73
109
|
const EXTENSION = /\.[\w-]+$/
|
|
@@ -258,6 +294,56 @@ const applied = async (tip: string): Promise<boolean> => {
|
|
|
258
294
|
return !wrote(FEEDBACK) && codeChanges().length > 0
|
|
259
295
|
}
|
|
260
296
|
|
|
297
|
+
/** `carried` limited to the criteria `.gtd/REQUIREMENTS.md` lists now; a file listing none narrows nothing. */
|
|
298
|
+
const settled = (
|
|
299
|
+
carried: readonly string[] | undefined,
|
|
300
|
+
slice: CriteriaSlice | undefined,
|
|
301
|
+
): readonly string[] => {
|
|
302
|
+
const listed = idsOf(REQUIREMENTS, slice)
|
|
303
|
+
if (carried === undefined) return listed
|
|
304
|
+
return listed.length === 0 ? carried : carried.filter((id) => listed.includes(id))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// A snapshot lands on the branch as it is; the health loop greens the whole build after.
|
|
308
|
+
// Open IDs count every test line since the process start, so an amend lap's earlier tests close theirs.
|
|
309
|
+
const build = (options: ImplementOptions): Promise<void> =>
|
|
310
|
+
continuing(phase("build", requireCriteriaIfEdited), async (from, fresh) => {
|
|
311
|
+
await fresh(() =>
|
|
312
|
+
t.agentWithSkills(
|
|
313
|
+
"building",
|
|
314
|
+
t.implementBuildingPrompt(
|
|
315
|
+
options.reference,
|
|
316
|
+
options.tier,
|
|
317
|
+
options.amend,
|
|
318
|
+
options.afterRefactor,
|
|
319
|
+
flagModeOn(),
|
|
320
|
+
options.rebuild,
|
|
321
|
+
sliceIds(options.slice),
|
|
322
|
+
from,
|
|
323
|
+
),
|
|
324
|
+
{
|
|
325
|
+
label: from === undefined ? "Implementing" : "Continuing the implementation",
|
|
326
|
+
model: coder(),
|
|
327
|
+
system: t.builderSystem(),
|
|
328
|
+
...(options.afterRefactor === true ? { allowEmpty: true } : {}),
|
|
329
|
+
},
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
requireCriteriaFrozen(REQUIREMENTS)
|
|
333
|
+
// Committed by the snapshot, so a turn stopped again may leave it as it was: present means stopped.
|
|
334
|
+
const note = read(HANDOFF)
|
|
335
|
+
if (note === undefined) return { result: undefined }
|
|
336
|
+
const ids = settled(options.rebuild?.acceptance?.ids, options.slice)
|
|
337
|
+
return {
|
|
338
|
+
result: undefined,
|
|
339
|
+
handoff: {
|
|
340
|
+
snapshot: head(),
|
|
341
|
+
note: note.trim(),
|
|
342
|
+
open: unnamed(ids, acceptanceTests(changesSince(start()))),
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
261
347
|
export function implement(options?: ImplementOptions): Promise<void>
|
|
262
348
|
/** @deprecated Pass `implement({ reference, tier, amend })`. */
|
|
263
349
|
export function implement(
|
|
@@ -276,27 +362,11 @@ export function implement(
|
|
|
276
362
|
const since = head()
|
|
277
363
|
const acceptance = options.rebuild?.acceptance
|
|
278
364
|
const built = options.naive === undefined || !(await applied(options.naive))
|
|
279
|
-
if (built)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
options.tier,
|
|
285
|
-
options.amend,
|
|
286
|
-
options.afterRefactor,
|
|
287
|
-
flagModeOn(),
|
|
288
|
-
options.rebuild,
|
|
289
|
-
sliceIds(options.slice),
|
|
290
|
-
),
|
|
291
|
-
{
|
|
292
|
-
label: "Implementing",
|
|
293
|
-
model: coder(),
|
|
294
|
-
system: t.builderSystem(),
|
|
295
|
-
...(options.afterRefactor === true ? { allowEmpty: true } : {}),
|
|
296
|
-
},
|
|
297
|
-
)
|
|
298
|
-
requireCriteriaFrozen(REQUIREMENTS)
|
|
299
|
-
if (built && acceptance !== undefined) requireAcceptance(acceptance, since)
|
|
365
|
+
if (built) await build(options)
|
|
366
|
+
else requireCriteriaFrozen(REQUIREMENTS)
|
|
367
|
+
// Only the criteria still listed: a person may narrow them at `implement.oversized`.
|
|
368
|
+
if (built && acceptance !== undefined)
|
|
369
|
+
requireAcceptance({ ...acceptance, ids: settled(acceptance.ids, options.slice) }, since)
|
|
300
370
|
// Fix turns are held to the carried tests too: deleting a red one must not buy a green suite.
|
|
301
371
|
await green(
|
|
302
372
|
built && acceptance !== undefined ? () => requireAcceptanceKept(acceptance) : undefined,
|
|
@@ -311,9 +381,11 @@ export const closeOut = (): Promise<void> => scope("implement", () => closing(fa
|
|
|
311
381
|
|
|
312
382
|
/** A slice with `more` after it keeps the criteria and the slicing for the next one. */
|
|
313
383
|
const closing = (more: boolean): Promise<void> =>
|
|
314
|
-
run(
|
|
315
|
-
|
|
316
|
-
|
|
384
|
+
run(
|
|
385
|
+
"closing",
|
|
386
|
+
removeScript(more ? [REFACTOR_NOTE, HANDOFF] : [REQUIREMENTS, REFACTOR_NOTE, SLICES, HANDOFF]),
|
|
387
|
+
{ label: "Closing out the implementation" },
|
|
388
|
+
)
|
|
317
389
|
|
|
318
390
|
interface Decision {
|
|
319
391
|
readonly reference: string
|
|
@@ -392,10 +464,10 @@ const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision |
|
|
|
392
464
|
const decide = (slice: CriteriaSlice | undefined): Promise<Decision> =>
|
|
393
465
|
scope("seam", async () => {
|
|
394
466
|
const vetting: Vetting = { again: budget(), waived: false }
|
|
467
|
+
const naive = phase("naive pass", requireCriteriaIfEdited)
|
|
395
468
|
let retry: t.NaiveRetry | undefined
|
|
396
469
|
for (;;) {
|
|
397
|
-
await
|
|
398
|
-
const tip = kept("naive")!
|
|
470
|
+
const tip = await naiveContinued(retry, slice, naive)
|
|
399
471
|
const outcome =
|
|
400
472
|
(await vetted(tip, vetting, slice)) ??
|
|
401
473
|
(flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash, tip })
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { human, refuse, scope, vars } from "../../flows/index.js"
|
|
2
|
+
import * as t from "./text.js"
|
|
3
|
+
import type { Handoff } from "./text.js"
|
|
4
|
+
import { defaults } from "./vars.js"
|
|
5
|
+
|
|
6
|
+
export type { Handoff }
|
|
7
|
+
|
|
8
|
+
export interface Turned<T> {
|
|
9
|
+
readonly result: T
|
|
10
|
+
/** Set when the turn wrote a handoff note, i.e. stopped at its context limit. */
|
|
11
|
+
readonly handoff?: Handoff | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Runs a turn's agent step in a conversation of its own, or in the caller's. */
|
|
15
|
+
export type Fresh = <T>(fn: () => Promise<T>) => Promise<T>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One phase's continuations, shared by every `continuing()` call in it (a naive
|
|
19
|
+
* retry, a refactor lap) so the caps hold per phase. `spent`: once a turn hit
|
|
20
|
+
* its context limit, resuming that conversation would only hit it again.
|
|
21
|
+
*/
|
|
22
|
+
export interface Phase {
|
|
23
|
+
readonly name: string
|
|
24
|
+
spent: boolean
|
|
25
|
+
count: number
|
|
26
|
+
stalled: number
|
|
27
|
+
/** Run after a landing at `oversized`, where a person may narrow the requirements; absent, the gate does not suggest it. */
|
|
28
|
+
readonly steered?: (() => void) | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const phase = (name: string, steered?: () => void): Phase => ({
|
|
32
|
+
name,
|
|
33
|
+
spent: false,
|
|
34
|
+
count: 0,
|
|
35
|
+
stalled: 0,
|
|
36
|
+
steered,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const own: Fresh = (fn) => fn()
|
|
40
|
+
// Every `scope()` call is a new conversation; the names keep continuations
|
|
41
|
+
// apart in history from later turns of a phase that merely start afresh.
|
|
42
|
+
const continued: Fresh = (fn) => scope("continue", fn)
|
|
43
|
+
const afresh: Fresh = (fn) => scope("fresh", fn)
|
|
44
|
+
|
|
45
|
+
const setting = (name: "continuations" | "continuationStall", least: number): number => {
|
|
46
|
+
const raw = vars[name] ?? defaults[name]!
|
|
47
|
+
const n = Number(raw)
|
|
48
|
+
if (raw.trim() === "" || !Number.isInteger(n) || n < least)
|
|
49
|
+
refuse(`gtd: ${name} must be a whole number of at least ${least}, got ${JSON.stringify(raw)}`)
|
|
50
|
+
return n
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run `turn` until one ends without a handoff note: each stopped turn is picked
|
|
55
|
+
* up by a continuation in a fresh conversation. Past `continuations` of them in
|
|
56
|
+
* the phase, or after `continuationStall` in a row that close nothing still
|
|
57
|
+
* open, the phase rests at `oversized` for a person; landing there grants a new round.
|
|
58
|
+
*/
|
|
59
|
+
export const continuing = async <T>(
|
|
60
|
+
phase: Phase,
|
|
61
|
+
turn: (from: Handoff | undefined, fresh: Fresh) => Promise<Turned<T>>,
|
|
62
|
+
): Promise<T> => {
|
|
63
|
+
const cap = setting("continuations", 0)
|
|
64
|
+
const stall = setting("continuationStall", 1)
|
|
65
|
+
let last = await turn(undefined, phase.spent ? afresh : own)
|
|
66
|
+
while (last.handoff !== undefined) {
|
|
67
|
+
phase.spent = true
|
|
68
|
+
const from = last.handoff
|
|
69
|
+
if (phase.count >= cap || phase.stalled >= stall) {
|
|
70
|
+
await human("oversized", {
|
|
71
|
+
message: t.oversizedMessage(
|
|
72
|
+
phase.name,
|
|
73
|
+
from,
|
|
74
|
+
phase.count >= cap ? { cap } : { stall },
|
|
75
|
+
phase.steered !== undefined,
|
|
76
|
+
),
|
|
77
|
+
label: "Too big for its context",
|
|
78
|
+
acceptClean: true,
|
|
79
|
+
})
|
|
80
|
+
phase.steered?.()
|
|
81
|
+
phase.count = 0
|
|
82
|
+
phase.stalled = 0
|
|
83
|
+
}
|
|
84
|
+
last = await turn(from, continued)
|
|
85
|
+
phase.count++
|
|
86
|
+
// Nothing countable left open is no stall: only the cap stops such a phase.
|
|
87
|
+
const closed =
|
|
88
|
+
from.open.length === 0 || from.open.some((item) => !(last.handoff?.open ?? []).includes(item))
|
|
89
|
+
phase.stalled = closed ? 0 : phase.stalled + 1
|
|
90
|
+
}
|
|
91
|
+
return last.result
|
|
92
|
+
}
|
|
@@ -24,6 +24,8 @@ export const ShippedOptions = Schema.Struct({
|
|
|
24
24
|
sliceMaxCriteria: setting,
|
|
25
25
|
qualityReviews: setting,
|
|
26
26
|
refactorQualityReviews: setting,
|
|
27
|
+
continuations: setting,
|
|
28
|
+
continuationStall: setting,
|
|
27
29
|
testCommand: setting,
|
|
28
30
|
plannerModel: setting,
|
|
29
31
|
coderModel: setting,
|
|
@@ -15,6 +15,7 @@ export const REFACTOR = ".gtd/REFACTOR.md"
|
|
|
15
15
|
export const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md"
|
|
16
16
|
export const QUALITY = ".gtd/QUALITY.md"
|
|
17
17
|
export const SLICES = ".gtd/SLICES.md"
|
|
18
|
+
export const HANDOFF = ".gtd/HANDOFF.md"
|
|
18
19
|
|
|
19
20
|
export const planner = (): string => env.plannerModel ?? ""
|
|
20
21
|
export const coder = (): string => env.coderModel ?? ""
|
|
@@ -976,7 +976,9 @@ describe("the seam phase's texts", () => {
|
|
|
976
976
|
it("has a red retry pick up the kept attempt instead of starting over", () => {
|
|
977
977
|
const prompt = renderText(() => naivePassPrompt({ red: "abc123" }))
|
|
978
978
|
expect(prompt).toContain("left the suite red")
|
|
979
|
-
expect(prompt).toContain(
|
|
979
|
+
expect(prompt).toContain(
|
|
980
|
+
"git diff --binary --no-color --no-ext-diff --no-textconv abc123^ abc123 | git apply",
|
|
981
|
+
)
|
|
980
982
|
expect(prompt).toContain(".gtd/FEEDBACK.md")
|
|
981
983
|
expect(prompt).not.toContain("rejected")
|
|
982
984
|
})
|
|
@@ -1042,7 +1044,9 @@ describe("the seam phase's texts", () => {
|
|
|
1042
1044
|
}
|
|
1043
1045
|
const prompt = renderText(() => naivePassPrompt(fault))
|
|
1044
1046
|
expect(prompt).toContain("names AC-2")
|
|
1045
|
-
expect(prompt).toContain(
|
|
1047
|
+
expect(prompt).toContain(
|
|
1048
|
+
"git diff --binary --no-color --no-ext-diff --no-textconv abc123^ abc123 | git apply",
|
|
1049
|
+
)
|
|
1046
1050
|
const message = renderText(() => seamSteerMessage(fault))
|
|
1047
1051
|
expect(message).toContain("names AC-2")
|
|
1048
1052
|
expect(message).toContain("`seam.check` runs its suite")
|