@pmelab/gtd 37.0.0 → 38.1.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 +15 -0
- package/dist/chunks/{reserved-zAfoJtR7.mjs → reserved-CfQ8eurM.mjs} +85 -17
- package/dist/chunks/{tail-BRC4DGdV.mjs → tail-C7yaWvaS.mjs} +1 -1
- package/dist/chunks/{wire-CSsa3Wxd.mjs → wire-BJuZWR3C.mjs} +1 -1
- package/dist/gtd.bundle.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +318 -76
- 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 +419 -109
- package/package.json +1 -1
- package/src/testkit/steps.ts +39 -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/coverage.ts +14 -6
- package/src/workflows/feature/criteria.test.ts +17 -1
- package/src/workflows/feature/criteria.ts +37 -3
- package/src/workflows/feature/feature.ts +64 -15
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/refactor.ts +64 -30
- package/src/workflows/feature/seam.ts +157 -48
- package/src/workflows/feature/slicing-cap.feature +14 -0
- package/src/workflows/feature/slicing.feature +225 -0
- package/src/workflows/feature/slicing.test.ts +49 -0
- package/src/workflows/feature/slicing.ts +105 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/continuation.ts +92 -0
- package/src/workflows/shared/fences.ts +5 -0
- package/src/workflows/shared/options.ts +3 -0
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/text.test.ts +6 -2
- package/src/workflows/shared/text.ts +153 -22
- package/src/workflows/shared/vars.ts +6 -0
- package/src/workflows/shared/workflows.steps.ts +13 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: a change with more criteria than the cap is built in coarse slices
|
|
3
|
+
|
|
4
|
+
After design, criteria within sliceMaxCriteria are one slice, built as
|
|
5
|
+
before. Above it design.slicing groups them in .gtd/SLICES.md, at most the
|
|
6
|
+
cap per slice, and each slice runs in order — naive pass, implement, quality
|
|
7
|
+
lap — from where the previous one landed, as slices feature-1, feature-2, …
|
|
8
|
+
|
|
9
|
+
Background:
|
|
10
|
+
Given the shipped workflows, as gtd install scaffolds them
|
|
11
|
+
And an environment variable "GTD_QUALITYREVIEWS" set to ""
|
|
12
|
+
And an environment variable "GTD_FLAGMODE" set to "false"
|
|
13
|
+
And an environment variable "GTD_SLICEMAXCRITERIA" set to "2"
|
|
14
|
+
And a file "src/calc.ts" with:
|
|
15
|
+
"""
|
|
16
|
+
export const add = (a: number, b: number) => a + b
|
|
17
|
+
"""
|
|
18
|
+
And the working tree is committed as "feat: add calculator"
|
|
19
|
+
And a file "TODO.md" with:
|
|
20
|
+
"""
|
|
21
|
+
# More arithmetic
|
|
22
|
+
|
|
23
|
+
subtract, multiply and divide.
|
|
24
|
+
"""
|
|
25
|
+
And step "unwind" changes nothing
|
|
26
|
+
And step "start-gate.check" changes nothing
|
|
27
|
+
And step "design.gate.answer" changes nothing
|
|
28
|
+
And step "seam.check" changes nothing
|
|
29
|
+
And step "implement.applying" runs its script
|
|
30
|
+
And step "implement.health.check" changes nothing
|
|
31
|
+
And step "implement.closing" runs its script
|
|
32
|
+
|
|
33
|
+
Scenario: criteria within the cap are one unnumbered slice with no slicing turn
|
|
34
|
+
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
35
|
+
"""
|
|
36
|
+
# Requirements
|
|
37
|
+
|
|
38
|
+
## Acceptance Criteria
|
|
39
|
+
|
|
40
|
+
- AC-1: subtract(a, b) returns a - b.
|
|
41
|
+
- AC-2: multiply(a, b) returns a * b.
|
|
42
|
+
"""
|
|
43
|
+
And step "seam.naive" writes "src/calc.test.ts" with:
|
|
44
|
+
"""
|
|
45
|
+
it("[AC-1] subtracts", () => {})
|
|
46
|
+
it("[AC-2] multiplies", () => {})
|
|
47
|
+
"""
|
|
48
|
+
And step "seam.naive" is prompted with "Read `.gtd/REQUIREMENTS.md`"
|
|
49
|
+
And judge step "implement.coverage" answers:
|
|
50
|
+
"""
|
|
51
|
+
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
|
|
52
|
+
"""
|
|
53
|
+
When the process is driven
|
|
54
|
+
Then the process rests at "idle"
|
|
55
|
+
And the commit subjects end with:
|
|
56
|
+
"""
|
|
57
|
+
gtd(human): design.gate.answer → seam.naive
|
|
58
|
+
gtd(agent): seam.naive → seam.check
|
|
59
|
+
gtd(check): seam.check → implement.applying
|
|
60
|
+
gtd(check): implement.applying → implement.health.check
|
|
61
|
+
gtd(check): implement.health.check → implement.coverage
|
|
62
|
+
gtd(judge): implement.coverage → implement.closing
|
|
63
|
+
gtd(check): implement.closing → idle
|
|
64
|
+
"""
|
|
65
|
+
And the output lines of gtd "slices" start with:
|
|
66
|
+
"""
|
|
67
|
+
feature
|
|
68
|
+
"""
|
|
69
|
+
And the output of gtd "slices" does not contain "feature-"
|
|
70
|
+
And no commit body contains "This slice builds"
|
|
71
|
+
|
|
72
|
+
Scenario: criteria above the cap are sliced and built slice by slice
|
|
73
|
+
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
74
|
+
"""
|
|
75
|
+
# Requirements
|
|
76
|
+
|
|
77
|
+
## Acceptance Criteria
|
|
78
|
+
|
|
79
|
+
- AC-1: subtract(a, b) returns a - b.
|
|
80
|
+
- AC-2: multiply(a, b) returns a * b.
|
|
81
|
+
- AC-3: divide(a, b) returns a / b.
|
|
82
|
+
"""
|
|
83
|
+
And step "design.slicing" is prompted with "at most 2 per slice"
|
|
84
|
+
And step "design.slicing" writes ".gtd/SLICES.md" with:
|
|
85
|
+
"""
|
|
86
|
+
# Slices
|
|
87
|
+
|
|
88
|
+
## Subtract and divide
|
|
89
|
+
|
|
90
|
+
- AC-1
|
|
91
|
+
- AC-3
|
|
92
|
+
|
|
93
|
+
## Multiply
|
|
94
|
+
|
|
95
|
+
- AC-2
|
|
96
|
+
"""
|
|
97
|
+
And step "seam.naive", when prompted with "This slice builds AC-1, AC-3 only", writes "src/sub.test.ts" with:
|
|
98
|
+
"""
|
|
99
|
+
it("[AC-1] subtracts", () => {})
|
|
100
|
+
it("[AC-3] divides", () => {})
|
|
101
|
+
"""
|
|
102
|
+
And step "seam.naive", when prompted with "This slice builds AC-2 only", writes "src/mul.test.ts" with:
|
|
103
|
+
"""
|
|
104
|
+
it("[AC-2] multiplies", () => {})
|
|
105
|
+
"""
|
|
106
|
+
And judge step "implement.coverage", when its questions contain "AC-3", answers:
|
|
107
|
+
"""
|
|
108
|
+
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-3","answer":"yes","p":0.9}]
|
|
109
|
+
"""
|
|
110
|
+
And judge step "implement.coverage", when its questions contain "AC-2", answers:
|
|
111
|
+
"""
|
|
112
|
+
[{"id":"AC-2","answer":"yes","p":0.9}]
|
|
113
|
+
"""
|
|
114
|
+
When the process is driven
|
|
115
|
+
Then the process rests at "idle"
|
|
116
|
+
And the commit subjects end with:
|
|
117
|
+
"""
|
|
118
|
+
gtd(agent): design.triage → design.gate.answer
|
|
119
|
+
gtd(human): design.gate.answer → design.slicing
|
|
120
|
+
gtd(agent): design.slicing → seam.naive
|
|
121
|
+
gtd(agent): seam.naive → seam.check
|
|
122
|
+
gtd(check): seam.check → implement.applying
|
|
123
|
+
gtd(check): implement.applying → implement.health.check
|
|
124
|
+
gtd(check): implement.health.check → implement.coverage
|
|
125
|
+
gtd(judge): implement.coverage → implement.closing
|
|
126
|
+
gtd(check): implement.closing → seam.naive
|
|
127
|
+
gtd(agent): seam.naive → seam.check
|
|
128
|
+
gtd(check): seam.check → implement.applying
|
|
129
|
+
gtd(check): implement.applying → implement.health.check
|
|
130
|
+
gtd(check): implement.health.check → implement.coverage
|
|
131
|
+
gtd(judge): implement.coverage → implement.closing
|
|
132
|
+
gtd(check): implement.closing → idle
|
|
133
|
+
"""
|
|
134
|
+
And the output lines of gtd "slices" start with:
|
|
135
|
+
"""
|
|
136
|
+
feature-1
|
|
137
|
+
feature-2
|
|
138
|
+
"""
|
|
139
|
+
And slice "feature-2" begins where slice "feature-1" ends
|
|
140
|
+
And "src/sub.test.ts" contains "[AC-3] divides"
|
|
141
|
+
And "src/mul.test.ts" contains "[AC-2] multiplies"
|
|
142
|
+
And ".gtd/REQUIREMENTS.md" does not exist
|
|
143
|
+
And ".gtd/SLICES.md" does not exist
|
|
144
|
+
|
|
145
|
+
Scenario: a slice over the cap is refused
|
|
146
|
+
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
147
|
+
"""
|
|
148
|
+
# Requirements
|
|
149
|
+
|
|
150
|
+
## Acceptance Criteria
|
|
151
|
+
|
|
152
|
+
- AC-1: subtract(a, b) returns a - b.
|
|
153
|
+
- AC-2: multiply(a, b) returns a * b.
|
|
154
|
+
- AC-3: divide(a, b) returns a / b.
|
|
155
|
+
"""
|
|
156
|
+
And step "design.slicing" writes ".gtd/SLICES.md" with:
|
|
157
|
+
"""
|
|
158
|
+
# Slices
|
|
159
|
+
|
|
160
|
+
## Everything
|
|
161
|
+
|
|
162
|
+
- AC-1
|
|
163
|
+
- AC-2
|
|
164
|
+
- AC-3
|
|
165
|
+
"""
|
|
166
|
+
Then driving the process is refused with "more than 2 criteria in: Everything"
|
|
167
|
+
|
|
168
|
+
Scenario: a criterion in no slice is refused
|
|
169
|
+
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
170
|
+
"""
|
|
171
|
+
# Requirements
|
|
172
|
+
|
|
173
|
+
## Acceptance Criteria
|
|
174
|
+
|
|
175
|
+
- AC-1: subtract(a, b) returns a - b.
|
|
176
|
+
- AC-2: multiply(a, b) returns a * b.
|
|
177
|
+
- AC-3: divide(a, b) returns a / b.
|
|
178
|
+
"""
|
|
179
|
+
And step "design.slicing" writes ".gtd/SLICES.md" with:
|
|
180
|
+
"""
|
|
181
|
+
# Slices
|
|
182
|
+
|
|
183
|
+
## Subtract
|
|
184
|
+
|
|
185
|
+
- AC-1
|
|
186
|
+
|
|
187
|
+
## Multiply
|
|
188
|
+
|
|
189
|
+
- AC-2
|
|
190
|
+
"""
|
|
191
|
+
Then driving the process is refused with "no slice for: AC-3"
|
|
192
|
+
|
|
193
|
+
Scenario: a slicing turn may not change the acceptance criteria
|
|
194
|
+
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
195
|
+
"""
|
|
196
|
+
# Requirements
|
|
197
|
+
|
|
198
|
+
## Acceptance Criteria
|
|
199
|
+
|
|
200
|
+
- AC-1: subtract(a, b) returns a - b.
|
|
201
|
+
- AC-2: multiply(a, b) returns a * b.
|
|
202
|
+
- AC-3: divide(a, b) returns a / b.
|
|
203
|
+
"""
|
|
204
|
+
And step "design.slicing" writes ".gtd/REQUIREMENTS.md" with:
|
|
205
|
+
"""
|
|
206
|
+
# Requirements
|
|
207
|
+
|
|
208
|
+
## Acceptance Criteria
|
|
209
|
+
|
|
210
|
+
- AC-1: subtract(a, b) returns a - b.
|
|
211
|
+
- AC-2: multiply(a, b) returns a * b.
|
|
212
|
+
"""
|
|
213
|
+
And step "design.slicing" writes ".gtd/SLICES.md" with:
|
|
214
|
+
"""
|
|
215
|
+
# Slices
|
|
216
|
+
|
|
217
|
+
## Subtract
|
|
218
|
+
|
|
219
|
+
- AC-1
|
|
220
|
+
|
|
221
|
+
## Multiply
|
|
222
|
+
|
|
223
|
+
- AC-2
|
|
224
|
+
"""
|
|
225
|
+
Then driving the process is refused with "wrote outside its write access"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { sliceCriteria, slicesOf } from "./slicing.js"
|
|
3
|
+
|
|
4
|
+
describe("slicesOf", () => {
|
|
5
|
+
it("reads each level-2 heading's ID'd items, in order", () => {
|
|
6
|
+
expect(
|
|
7
|
+
slicesOf("# Slices\n\n## Parse\n\n- AC-1: parses\n- AC-3\n\n## Print\n\n- AC-2 prints\n"),
|
|
8
|
+
).toEqual([
|
|
9
|
+
{ title: "Parse", ids: ["AC-1", "AC-3"] },
|
|
10
|
+
{ title: "Print", ids: ["AC-2"] },
|
|
11
|
+
])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("keeps an item not led by an ID as an empty one, and skips fenced lines", () => {
|
|
15
|
+
expect(slicesOf("## One\n\n```md\n## Two\n- AC-9\n```\n- AC-10\n- see AC-1\n")).toEqual([
|
|
16
|
+
{ title: "One", ids: ["AC-10", ""] },
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("ends a slice at any other heading", () => {
|
|
21
|
+
expect(
|
|
22
|
+
slicesOf("## One\n\n- AC-1\n\n### Why\n\n- shares the lexer\n\n# Later\n\n- AC-5\n"),
|
|
23
|
+
).toEqual([{ title: "One", ids: ["AC-1"] }])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("reads no slice from a file without level-2 headings", () => {
|
|
27
|
+
expect(slicesOf("# Slices\n\n- AC-1\n")).toEqual([])
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe("sliceCriteria", () => {
|
|
32
|
+
const slices = [
|
|
33
|
+
{ title: "A", ids: ["AC-1", "AC-3"] },
|
|
34
|
+
{ title: "B", ids: ["AC-2"] },
|
|
35
|
+
{ title: "C", ids: ["AC-4"] },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
it("keeps an earlier slice to its own IDs", () => {
|
|
39
|
+
expect(sliceCriteria(slices, 1)).toEqual({ only: ["AC-2"] })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("gives the last slice every ID no earlier slice builds", () => {
|
|
43
|
+
expect(sliceCriteria(slices, 2)).toEqual({ except: ["AC-1", "AC-3", "AC-2"] })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("leaves a single slice unsliced", () => {
|
|
47
|
+
expect(sliceCriteria([slices[0]!], 0)).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { read, refuse, scope, vars } from "../../flows/index.js"
|
|
2
|
+
import { HEADING, ITEM, markFenced } from "../shared/fences.js"
|
|
3
|
+
import { planner, REQUIREMENTS, SLICES } from "../shared/steps.js"
|
|
4
|
+
import * as t from "../shared/text.js"
|
|
5
|
+
import { defaults } from "../shared/vars.js"
|
|
6
|
+
import { criterionIds, type CriteriaSlice } from "./criteria.js"
|
|
7
|
+
|
|
8
|
+
/** One slice of the change: the criterion IDs it builds, in the order `.gtd/SLICES.md` lists them. */
|
|
9
|
+
export interface Slice {
|
|
10
|
+
readonly title: string
|
|
11
|
+
readonly ids: readonly string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ID = /^(AC-\d+)(?![\w-])/
|
|
15
|
+
|
|
16
|
+
/** The `sliceMaxCriteria` setting. Anything but a positive integer fails rather than guess a cap. */
|
|
17
|
+
export const sliceCap = (): number => {
|
|
18
|
+
const value = vars.sliceMaxCriteria ?? defaults.sliceMaxCriteria!
|
|
19
|
+
if (!/^[1-9]\d*$/.test(value))
|
|
20
|
+
refuse(`gtd: sliceMaxCriteria must be a positive integer, got ${JSON.stringify(value)}`)
|
|
21
|
+
return Number(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Each `## ` heading of `text` with the list items under it; an item not led by an ID keeps an empty one. */
|
|
25
|
+
export const slicesOf = (text: string): readonly Slice[] => {
|
|
26
|
+
const slices: { title: string; ids: string[] }[] = []
|
|
27
|
+
// Only items right under a `##` heading: another heading ends the slice.
|
|
28
|
+
let open = false
|
|
29
|
+
for (const { line, fenced } of markFenced(text)) {
|
|
30
|
+
if (fenced) continue
|
|
31
|
+
const heading = HEADING.exec(line)
|
|
32
|
+
if (heading !== null) {
|
|
33
|
+
open = heading[1] === "##"
|
|
34
|
+
if (open) slices.push({ title: heading[2]!, ids: [] })
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
const item = ITEM.exec(line)
|
|
38
|
+
if (item !== null && open) slices.at(-1)!.ids.push(ID.exec(item[1]!)?.[1] ?? "")
|
|
39
|
+
}
|
|
40
|
+
return slices
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const twice = (list: readonly string[]): string[] => [
|
|
44
|
+
...new Set(list.filter((id, i) => list.indexOf(id) !== i)),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
/** Why `slices` fail to put each of `ids` in exactly one slice of at most `cap`, or `undefined`. */
|
|
48
|
+
const sliceFault = (
|
|
49
|
+
slices: readonly Slice[],
|
|
50
|
+
ids: readonly string[],
|
|
51
|
+
cap: number,
|
|
52
|
+
): string | undefined => {
|
|
53
|
+
const listed = slices.flatMap((s) => s.ids)
|
|
54
|
+
if (slices.length === 0) return `no "## " slice`
|
|
55
|
+
const empty = slices.filter((s) => s.ids.length === 0).map((s) => s.title)
|
|
56
|
+
if (empty.length > 0) return `a slice with no criterion: ${empty.join(", ")}`
|
|
57
|
+
if (listed.includes("")) return "a list item not led by a criterion ID"
|
|
58
|
+
const over = slices.filter((s) => s.ids.length > cap).map((s) => s.title)
|
|
59
|
+
if (over.length > 0) return `more than ${cap} criteria in: ${over.join(", ")}`
|
|
60
|
+
const repeated = twice(listed)
|
|
61
|
+
if (repeated.length > 0) return `an ID in more than one slice: ${repeated.join(", ")}`
|
|
62
|
+
const unknown = listed.filter((id) => !ids.includes(id))
|
|
63
|
+
if (unknown.length > 0) return `an ID ${REQUIREMENTS} does not list: ${unknown.join(", ")}`
|
|
64
|
+
const missing = ids.filter((id) => !listed.includes(id))
|
|
65
|
+
if (missing.length > 0) return `no slice for: ${missing.join(", ")}`
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Refuse a turn whose `.gtd/SLICES.md` does not put each of `ids` in exactly one slice of at most `cap`. */
|
|
70
|
+
export const requireSlices = (ids: readonly string[], cap: number): readonly Slice[] => {
|
|
71
|
+
const slices = slicesOf(read(SLICES) ?? "")
|
|
72
|
+
const why = sliceFault(slices, ids, cap)
|
|
73
|
+
if (why !== undefined)
|
|
74
|
+
refuse(
|
|
75
|
+
`gtd land: slices: ${SLICES} has ${why} — list each slice as a "## <title>" heading over "- AC-<n>" items, every criterion of ${REQUIREMENTS} in exactly one slice, at most ${cap} per slice`,
|
|
76
|
+
)
|
|
77
|
+
return slices
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The slices to build, in order. Criteria within `sliceMaxCriteria` are one
|
|
82
|
+
* slice and no turn; above it `design.slicing` groups them.
|
|
83
|
+
*/
|
|
84
|
+
export const slicing = async (): Promise<readonly Slice[]> => {
|
|
85
|
+
const all = criterionIds(read(REQUIREMENTS) ?? "")
|
|
86
|
+
const cap = sliceCap()
|
|
87
|
+
if (all.length <= cap) return [{ title: "", ids: all }]
|
|
88
|
+
return scope("design", async () => {
|
|
89
|
+
await t.agentWithSkills("slicing", t.slicingPrompt(cap), {
|
|
90
|
+
label: "Slicing the change",
|
|
91
|
+
file: SLICES,
|
|
92
|
+
model: planner(),
|
|
93
|
+
system: t.designSystem(),
|
|
94
|
+
})
|
|
95
|
+
return requireSlices(all, cap)
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Slice `i`'s criteria; `undefined` when `slices` is the one slice of an unsliced change. */
|
|
100
|
+
export const sliceCriteria = (slices: readonly Slice[], i: number): CriteriaSlice | undefined =>
|
|
101
|
+
slices.length === 1
|
|
102
|
+
? undefined
|
|
103
|
+
: i < slices.length - 1
|
|
104
|
+
? { only: slices[i]!.ids }
|
|
105
|
+
: { except: slices.slice(0, -1).flatMap((s) => s.ids) }
|
package/src/workflows/index.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -17,3 +17,8 @@ export const markFenced = (text: string): readonly { line: string; fenced: boole
|
|
|
17
17
|
return { line, fenced }
|
|
18
18
|
})
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/** A markdown heading: its `#` run, then its text. */
|
|
22
|
+
export const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
|
|
23
|
+
/** A list item, its checkbox dropped: its text. */
|
|
24
|
+
export const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/
|
|
@@ -21,8 +21,11 @@ export const ShippedOptions = Schema.Struct({
|
|
|
21
21
|
reviewSeverityMinP: setting,
|
|
22
22
|
refactorFlagPattern: setting,
|
|
23
23
|
seamAllow: setting,
|
|
24
|
+
sliceMaxCriteria: setting,
|
|
24
25
|
qualityReviews: setting,
|
|
25
26
|
refactorQualityReviews: setting,
|
|
27
|
+
continuations: setting,
|
|
28
|
+
continuationStall: setting,
|
|
26
29
|
testCommand: setting,
|
|
27
30
|
plannerModel: setting,
|
|
28
31
|
coderModel: setting,
|
|
@@ -14,6 +14,8 @@ export const REVIEW = ".gtd/REVIEW.md"
|
|
|
14
14
|
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
|
+
export const SLICES = ".gtd/SLICES.md"
|
|
18
|
+
export const HANDOFF = ".gtd/HANDOFF.md"
|
|
17
19
|
|
|
18
20
|
export const planner = (): string => env.plannerModel ?? ""
|
|
19
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")
|