@pmelab/gtd 36.0.0 → 38.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 +30 -9
- package/claude/hooks/context.ts +66 -0
- package/claude/hooks/drive.ts +3 -0
- package/claude/hooks/register.tsx +69 -6
- package/dist/chunks/{reserved-BAv71I4i.mjs → reserved-sfMdy7v7.mjs} +83 -22
- package/dist/chunks/{tail-kar1zhOb.mjs → tail-Cgca5Gp-.mjs} +1 -1
- package/dist/chunks/{wire-D3Rc5r22.mjs → wire-CSsa3Wxd.mjs} +117 -2
- package/dist/gtd.bundle.mjs +498 -56
- package/dist/testkit.mjs +115 -3
- package/dist/wire.mjs +2 -2
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +277 -58
- 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 +355 -75
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +10 -3
- package/src/cli/Cli.ts +76 -1
- package/src/cli/Program.test.ts +1 -0
- package/src/cli/Program.ts +28 -0
- package/src/cli/ToolCommands.ts +3 -0
- package/src/edge/Edge.ts +11 -0
- package/src/flows/runtime.ts +11 -0
- package/src/replay/Replay.ts +51 -2
- package/src/testkit/Layers.ts +1 -0
- package/src/testkit/steps.ts +35 -0
- package/src/wire/BeatDocument.ts +12 -2
- package/src/wire/BeatStatus.ts +3 -0
- package/src/wire/ContextLimit.fixture.ts +30 -0
- package/src/wire/ContextLimit.test.ts +96 -0
- package/src/wire/ContextLimit.ts +169 -0
- package/src/wire/index.ts +1 -0
- package/src/wire/types.ts +7 -0
- package/src/workflow/Workflow.ts +9 -1
- package/src/workflows/feature/context-limit.feature +37 -0
- package/src/workflows/feature/coverage.ts +22 -8
- package/src/workflows/feature/criteria.test.ts +17 -1
- package/src/workflows/feature/criteria.ts +26 -3
- package/src/workflows/feature/feature.ts +69 -16
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/refactor-absorbs.feature +12 -5
- package/src/workflows/feature/refactor-first.feature +11 -2
- package/src/workflows/feature/refactor-lens-models.feature +4 -0
- package/src/workflows/feature/refactor-rebuild.feature +269 -0
- package/src/workflows/feature/refactor-review.feature +45 -1
- package/src/workflows/feature/refactor.ts +9 -3
- package/src/workflows/feature/seam.test.ts +108 -3
- package/src/workflows/feature/seam.ts +169 -31
- 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/fences.ts +5 -0
- package/src/workflows/shared/options.test.ts +11 -2
- package/src/workflows/shared/options.ts +19 -4
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/text.ts +129 -28
- package/src/workflows/shared/vars.ts +1 -0
- package/src/workflows/shared/workflows.steps.ts +13 -0
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
judgeSeam,
|
|
14
14
|
naivePass,
|
|
15
15
|
acceptanceFault,
|
|
16
|
+
naiveAcceptance,
|
|
17
|
+
requireAcceptance,
|
|
18
|
+
requireAcceptanceKept,
|
|
16
19
|
seamBuild,
|
|
17
20
|
type SeamOutcome,
|
|
18
21
|
} from "./seam.js"
|
|
@@ -105,6 +108,67 @@ describe("naivePass", () => {
|
|
|
105
108
|
})
|
|
106
109
|
})
|
|
107
110
|
|
|
111
|
+
describe("naiveAcceptance and requireAcceptance", () => {
|
|
112
|
+
const requirements = "# R\n\n## Acceptance Criteria\n\n- AC-1: adds.\n- AC-2: subtracts.\n"
|
|
113
|
+
const file = (path: string, before: string | undefined, after: string): Change => ({
|
|
114
|
+
path,
|
|
115
|
+
status: before === undefined ? "added" : "modified",
|
|
116
|
+
before,
|
|
117
|
+
after,
|
|
118
|
+
})
|
|
119
|
+
const install = (since: Change[] = []) =>
|
|
120
|
+
installContext({
|
|
121
|
+
...fixtureContext({}),
|
|
122
|
+
matches: globMatches,
|
|
123
|
+
read: (path: string) => (path === ".gtd/REQUIREMENTS.md" ? requirements : undefined),
|
|
124
|
+
changesSince: () => since,
|
|
125
|
+
changes: () => since,
|
|
126
|
+
refuse: (message: string) => {
|
|
127
|
+
throw new Error(message)
|
|
128
|
+
},
|
|
129
|
+
} as unknown as FlowContext)
|
|
130
|
+
|
|
131
|
+
it("takes the IDs the pass's tests name on an added line, and only those test files", () => {
|
|
132
|
+
install()
|
|
133
|
+
expect(
|
|
134
|
+
naiveAcceptance(
|
|
135
|
+
tip(
|
|
136
|
+
file("src/a.test.ts", 'it("[AC-2] old")', 'it("[AC-2] old")\nit("[AC-1] adds")'),
|
|
137
|
+
file("src/b.test.ts", undefined, 'it("helper")'),
|
|
138
|
+
file("src/calc.ts", "x", "AC-2"),
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
).toEqual({ hash: "abc", tests: ["src/a.test.ts"], ids: ["AC-1"] })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("refuses a rebuild whose tests leave out an ID, naming the naive tests to carry over", () => {
|
|
145
|
+
install([file("src/calc.test.ts", undefined, 'it("[AC-1] adds")')])
|
|
146
|
+
expect(() =>
|
|
147
|
+
requireAcceptance({ hash: "abc", tests: ["src/a.test.ts"], ids: ["AC-1", "AC-2"] }, "c0"),
|
|
148
|
+
).toThrow(
|
|
149
|
+
/^gtd land: rebuild-acceptance: .*leave out AC-2 — .*git show abc -- 'src\/a\.test\.ts'/,
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("refuses a fix turn that itself drops a carried ID, never one that leaves it alone", () => {
|
|
154
|
+
const acceptance = { hash: "abc", tests: [], ids: ["AC-1"] }
|
|
155
|
+
install([file("src/calc.test.ts", 'it("[AC-1] adds")', 'it("adds")')])
|
|
156
|
+
expect(() => requireAcceptanceKept(acceptance)).toThrow(/drops AC-1/)
|
|
157
|
+
install([file("src/calc.ts", "a", "b")])
|
|
158
|
+
expect(() => requireAcceptanceKept(acceptance)).not.toThrow()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("passes a rebuild whose tests name every ID, wherever they live", () => {
|
|
162
|
+
install([
|
|
163
|
+
file("tests/x.test.ts", undefined, 'it("[AC-1] adds")'),
|
|
164
|
+
file("tests/y.feature", "", "Scenario: [AC-2] subtracts"),
|
|
165
|
+
])
|
|
166
|
+
expect(() =>
|
|
167
|
+
requireAcceptance({ hash: "abc", tests: ["src/a.test.ts"], ids: ["AC-1", "AC-2"] }, "c0"),
|
|
168
|
+
).not.toThrow()
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
108
172
|
describe("acceptanceFault", () => {
|
|
109
173
|
const requirements = "# R\n\n## Acceptance Criteria\n\n- AC-1: adds.\n- AC-2: subtracts.\n"
|
|
110
174
|
const file = (path: string, status: Change["status"], after: string): Change => ({
|
|
@@ -231,6 +295,7 @@ describe("acceptanceFault", () => {
|
|
|
231
295
|
|
|
232
296
|
const REVIEW = ".gtd/REVIEW.md"
|
|
233
297
|
const REFACTOR = ".gtd/REFACTOR.md"
|
|
298
|
+
const NOTE = ".gtd/REFACTOR-NOTE.md"
|
|
234
299
|
const reviewDoc = [
|
|
235
300
|
"# Review: abc1234",
|
|
236
301
|
"",
|
|
@@ -267,6 +332,7 @@ const driveSeam = async (
|
|
|
267
332
|
const files = new Map<string, string>([["src/a.ts", "a"]])
|
|
268
333
|
let current = "c0"
|
|
269
334
|
const effects: Record<string, () => void> = {
|
|
335
|
+
note: () => void files.set(NOTE, "Seam: a shared op"),
|
|
270
336
|
deriving: () => void files.set(REFACTOR, "package"),
|
|
271
337
|
closing: () => void files.delete(REFACTOR),
|
|
272
338
|
"review.reviewing": () => void files.set(REVIEW, reviewDoc),
|
|
@@ -366,8 +432,15 @@ describe("seamBuild", () => {
|
|
|
366
432
|
|
|
367
433
|
it("refactor-first runs the reviewed refactor in slice refactor and resolves HEAD after it", async () => {
|
|
368
434
|
const h = await driveSeam("refactor-first")
|
|
369
|
-
expect(h.outcome).toEqual({
|
|
435
|
+
expect(h.outcome).toEqual({
|
|
436
|
+
reference: "abc",
|
|
437
|
+
base: "after-refactor",
|
|
438
|
+
refactored: true,
|
|
439
|
+
rebuild: { note: "Seam: a shared op", acceptance: { hash: "abc", tests: [], ids: [] } },
|
|
440
|
+
})
|
|
370
441
|
expect(h.slices).toEqual(["refactor"])
|
|
442
|
+
expect(h.names.indexOf("seam.note")).toBe(h.names.indexOf("seam.judge") + 1)
|
|
443
|
+
expect(h.inside.refactor).not.toContain("seam.note")
|
|
371
444
|
expect(h.inside.refactor![0]).toBe("refactor.deriving")
|
|
372
445
|
expect(h.inside.refactor).toContain("refactor.build.review.closing")
|
|
373
446
|
expect(h.names.some((n) => n.includes("approve"))).toBe(false)
|
|
@@ -378,7 +451,11 @@ describe("seamBuild", () => {
|
|
|
378
451
|
const h = await driveSeam("refactor-first", change("src/a.test.ts", "modified"), {
|
|
379
452
|
seamAllow: ".gtd/**",
|
|
380
453
|
})
|
|
381
|
-
expect(h.outcome).toEqual({
|
|
454
|
+
expect(h.outcome).toEqual({
|
|
455
|
+
reference: "abc",
|
|
456
|
+
base: "c-base",
|
|
457
|
+
rebuild: { acceptance: { hash: "abc", tests: [], ids: [] } },
|
|
458
|
+
})
|
|
382
459
|
expect(h.slices).toEqual([])
|
|
383
460
|
expect(h.names).not.toContain("seam.by-hand")
|
|
384
461
|
expect(h.names.some((n) => n.startsWith("refactor"))).toBe(false)
|
|
@@ -403,7 +480,11 @@ describe("seamBuild", () => {
|
|
|
403
480
|
})
|
|
404
481
|
expect(h.slices).toEqual([])
|
|
405
482
|
expect(h.names).toContain("seam.by-hand")
|
|
406
|
-
expect(h.outcome).toEqual({
|
|
483
|
+
expect(h.outcome).toEqual({
|
|
484
|
+
reference: "abc",
|
|
485
|
+
base: "c-base",
|
|
486
|
+
rebuild: { acceptance: { hash: "abc", tests: [], ids: [] } },
|
|
487
|
+
})
|
|
407
488
|
})
|
|
408
489
|
})
|
|
409
490
|
|
|
@@ -472,6 +553,30 @@ describe("implement", () => {
|
|
|
472
553
|
expect(bare).toBe(await building(undefined))
|
|
473
554
|
})
|
|
474
555
|
|
|
556
|
+
it("a rebuild quotes the refactor note and names the naive acceptance tests to carry over", async () => {
|
|
557
|
+
const prompt = await building("abc", undefined, undefined, () =>
|
|
558
|
+
implement({
|
|
559
|
+
reference: "abc",
|
|
560
|
+
rebuild: {
|
|
561
|
+
note: "# Note\n\nSeam: a shared op",
|
|
562
|
+
acceptance: { hash: "abc", tests: ["src/a.test.ts"], ids: ["AC-1"] },
|
|
563
|
+
},
|
|
564
|
+
// The fixture's turn carries no test, so the acceptance guard refuses it after the prompt.
|
|
565
|
+
}).catch(() => undefined),
|
|
566
|
+
)
|
|
567
|
+
expect(prompt).toContain(" > Seam: a shared op")
|
|
568
|
+
expect(prompt).toContain("acceptance tests for AC-1")
|
|
569
|
+
expect(prompt).toContain("git show abc -- 'src/a.test.ts'")
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it("a rebuild whose naive tests named no ID asks to carry nothing over", async () => {
|
|
573
|
+
const prompt = await building("abc", undefined, undefined, () =>
|
|
574
|
+
implement({ reference: "abc", rebuild: { acceptance: { hash: "abc", tests: [], ids: [] } } }),
|
|
575
|
+
)
|
|
576
|
+
expect(prompt).not.toContain("Carry over")
|
|
577
|
+
expect(prompt).not.toContain("refactor note")
|
|
578
|
+
})
|
|
579
|
+
|
|
475
580
|
it("names the reference with a git show line", async () => {
|
|
476
581
|
expect(await building("abc")).toContain("git show abc")
|
|
477
582
|
})
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { posix } from "node:path"
|
|
2
2
|
import {
|
|
3
|
+
agent,
|
|
3
4
|
applyScript,
|
|
4
5
|
changes,
|
|
6
|
+
changesSince,
|
|
5
7
|
check,
|
|
6
8
|
codeChanges,
|
|
7
9
|
env,
|
|
@@ -11,21 +13,28 @@ import {
|
|
|
11
13
|
kept,
|
|
12
14
|
quote,
|
|
13
15
|
read,
|
|
16
|
+
refuse,
|
|
14
17
|
removeScript,
|
|
15
18
|
run,
|
|
16
19
|
scope,
|
|
17
|
-
slice,
|
|
20
|
+
slice as sliced,
|
|
18
21
|
vars,
|
|
19
22
|
wrote,
|
|
20
23
|
type Change,
|
|
21
24
|
type DiffShapeResult,
|
|
22
25
|
type KeptTip,
|
|
23
26
|
} from "../../flows/index.js"
|
|
24
|
-
import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
|
|
27
|
+
import { coder, FEEDBACK, REFACTOR_NOTE, REQUIREMENTS, SLICES } from "../shared/steps.js"
|
|
25
28
|
import * as t from "../shared/text.js"
|
|
26
29
|
import { defaults } from "../shared/vars.js"
|
|
27
30
|
import { green, requireCoverage } from "./coverage.js"
|
|
28
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
criterionIds,
|
|
33
|
+
requireCriteria,
|
|
34
|
+
requireCriteriaFrozen,
|
|
35
|
+
sliceIds,
|
|
36
|
+
type CriteriaSlice,
|
|
37
|
+
} from "./criteria.js"
|
|
29
38
|
import {
|
|
30
39
|
COMMENT,
|
|
31
40
|
holdsCallSites,
|
|
@@ -51,8 +60,8 @@ export const judgeSeam = (tip: KeptTip): Promise<DiffShapeResult> =>
|
|
|
51
60
|
exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path),
|
|
52
61
|
})
|
|
53
62
|
|
|
54
|
-
export const naivePass = (retry?: t.NaiveRetry): Promise<void> =>
|
|
55
|
-
t.agentWithSkills("naive", t.naivePassPrompt(retry), {
|
|
63
|
+
export const naivePass = (retry?: t.NaiveRetry, slice?: CriteriaSlice): Promise<void> =>
|
|
64
|
+
t.agentWithSkills("naive", t.naivePassPrompt(retry, sliceIds(slice)), {
|
|
56
65
|
label: retry === undefined ? "Writing the naive pass" : "Rewriting the naive pass",
|
|
57
66
|
keep: true,
|
|
58
67
|
model: coder(),
|
|
@@ -101,18 +110,30 @@ const addedLines = (change: Change): string => {
|
|
|
101
110
|
.join("\n")
|
|
102
111
|
}
|
|
103
112
|
|
|
113
|
+
const acceptanceTests = (changes: readonly Change[]): readonly Change[] =>
|
|
114
|
+
changes.filter((c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path))
|
|
115
|
+
|
|
116
|
+
const idsOf = (file: string, slice?: CriteriaSlice): string[] =>
|
|
117
|
+
criterionIds(read(file) ?? "", slice)
|
|
118
|
+
|
|
119
|
+
// One rule for both gates, so a naive test carried over verbatim always satisfies the rebuild's.
|
|
120
|
+
const unnamed = (ids: readonly string[], tests: readonly Change[]): string[] => {
|
|
121
|
+
const lines = tests.map(addedLines)
|
|
122
|
+
return ids.filter((id) => !lines.some((text) => named(id, text)))
|
|
123
|
+
}
|
|
124
|
+
|
|
104
125
|
/**
|
|
105
126
|
* Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
|
|
106
127
|
* test line of the pass names, or a test importing a file the pass added —
|
|
107
128
|
* naive structure a reset and refactor replaces, which the tests must survive.
|
|
108
129
|
*/
|
|
109
|
-
export const acceptanceFault = (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const untested =
|
|
130
|
+
export const acceptanceFault = (
|
|
131
|
+
file: string,
|
|
132
|
+
tip: KeptTip,
|
|
133
|
+
slice?: CriteriaSlice,
|
|
134
|
+
): string | undefined => {
|
|
135
|
+
const tests = acceptanceTests(tip.changes)
|
|
136
|
+
const untested = unnamed(idsOf(file, slice), tests)
|
|
116
137
|
if (untested.length > 0)
|
|
117
138
|
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]}] …"`
|
|
118
139
|
const added = tip.changes
|
|
@@ -131,6 +152,49 @@ export const acceptanceFault = (file: string, tip: KeptTip): string | undefined
|
|
|
131
152
|
return undefined
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
/** What a rebuild carries over from the naive pass `tip`: the IDs its tests name, and those test files. */
|
|
156
|
+
export const naiveAcceptance = (tip: KeptTip, slice?: CriteriaSlice): t.Acceptance => {
|
|
157
|
+
const ids = idsOf(REQUIREMENTS, slice)
|
|
158
|
+
const perTest = acceptanceTests(tip.changes).map((c) => {
|
|
159
|
+
const lines = addedLines(c)
|
|
160
|
+
return { path: c.path, ids: ids.filter((id) => named(id, lines)) }
|
|
161
|
+
})
|
|
162
|
+
return {
|
|
163
|
+
hash: tip.hash,
|
|
164
|
+
tests: perTest.filter((c) => c.ids.length > 0).map((c) => c.path),
|
|
165
|
+
ids: ids.filter((id) => perTest.some((c) => c.ids.includes(id))),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Refuse a rebuild whose test changes since `since` leave out an ID the naive
|
|
171
|
+
* pass's tests named. A skipped test still counts: the gate reads text, never runs it.
|
|
172
|
+
*/
|
|
173
|
+
export const requireAcceptance = (acceptance: t.Acceptance, since: string): void => {
|
|
174
|
+
const missing = unnamed(acceptance.ids, acceptanceTests(changesSince(since)))
|
|
175
|
+
if (missing.length === 0) return
|
|
176
|
+
refuse(
|
|
177
|
+
`gtd land: rebuild-acceptance: the rebuild's tests leave out ${missing.join(", ")} — carry over the naive pass's acceptance tests (\`git show ${acceptance.hash} -- ${acceptance.tests.map(quote).join(" ")}\`), each with its ID in the test or scenario name`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Refuse a fix turn that, on its own, drops an ID the naive pass's tests named
|
|
183
|
+
* from a test file. Turn-local, so a person's edit at an escalation rest never
|
|
184
|
+
* wedges later fix turns.
|
|
185
|
+
*/
|
|
186
|
+
export const requireAcceptanceKept = (acceptance: t.Acceptance): void => {
|
|
187
|
+
const dropped = acceptance.ids.filter((id) =>
|
|
188
|
+
changes()
|
|
189
|
+
.filter((c) => isCode(c.path) && isTestFile(c.path))
|
|
190
|
+
.some((c) => named(id, c.before ?? "") && !named(id, c.after ?? "")),
|
|
191
|
+
)
|
|
192
|
+
if (dropped.length === 0) return
|
|
193
|
+
refuse(
|
|
194
|
+
`gtd land: rebuild-acceptance: this turn drops ${dropped.join(", ")} from the tests — the naive pass's acceptance tests stay; fix the code, not them`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
134
198
|
const restore = (path: string): string =>
|
|
135
199
|
`if git cat-file -e ${quote(`HEAD:${path}`)} 2>/dev/null; then git checkout -q HEAD -- ${quote(path)}; else rm -f -- ${quote(path)}; fi`
|
|
136
200
|
|
|
@@ -166,6 +230,8 @@ const naiveGreen = (tip: KeptTip): Promise<boolean> => {
|
|
|
166
230
|
)
|
|
167
231
|
}
|
|
168
232
|
|
|
233
|
+
export type { Acceptance, Rebuild } from "../shared/text.js"
|
|
234
|
+
|
|
169
235
|
export interface ImplementOptions {
|
|
170
236
|
readonly reference?: string | undefined
|
|
171
237
|
readonly tier?: RiskTier | undefined
|
|
@@ -174,6 +240,14 @@ export interface ImplementOptions {
|
|
|
174
240
|
readonly afterRefactor?: boolean | undefined
|
|
175
241
|
/** A kept naive tip to apply as the implementation; built fresh only when it no longer applies. */
|
|
176
242
|
readonly naive?: string | undefined
|
|
243
|
+
/** A rebuild after a refactor-first judgment: the note it plugs into, the acceptance tests it must carry. */
|
|
244
|
+
readonly rebuild?: t.Rebuild | undefined
|
|
245
|
+
/** The criteria this slice builds, when the change has more than one slice. */
|
|
246
|
+
readonly slice?: CriteriaSlice | undefined
|
|
247
|
+
/** Where the slice began: the coverage judge reads only the tests changed since. */
|
|
248
|
+
readonly base?: string | undefined
|
|
249
|
+
/** Not the last slice: `.gtd/REQUIREMENTS.md` and `.gtd/SLICES.md` stay for the next one. */
|
|
250
|
+
readonly more?: boolean | undefined
|
|
177
251
|
}
|
|
178
252
|
|
|
179
253
|
/** Resolves `false` when the tip no longer applies (the failure is in `.gtd/FEEDBACK.md`) or has nothing outside `.gtd/` to apply. */
|
|
@@ -199,7 +273,10 @@ export function implement(
|
|
|
199
273
|
const options: ImplementOptions =
|
|
200
274
|
typeof first === "object" && first !== null ? first : { reference: first, tier, amend }
|
|
201
275
|
return scope("implement", async () => {
|
|
202
|
-
|
|
276
|
+
const since = head()
|
|
277
|
+
const acceptance = options.rebuild?.acceptance
|
|
278
|
+
const built = options.naive === undefined || !(await applied(options.naive))
|
|
279
|
+
if (built)
|
|
203
280
|
await t.agentWithSkills(
|
|
204
281
|
"building",
|
|
205
282
|
t.implementBuildingPrompt(
|
|
@@ -208,6 +285,8 @@ export function implement(
|
|
|
208
285
|
options.amend,
|
|
209
286
|
options.afterRefactor,
|
|
210
287
|
flagModeOn(),
|
|
288
|
+
options.rebuild,
|
|
289
|
+
sliceIds(options.slice),
|
|
211
290
|
),
|
|
212
291
|
{
|
|
213
292
|
label: "Implementing",
|
|
@@ -217,14 +296,28 @@ export function implement(
|
|
|
217
296
|
},
|
|
218
297
|
)
|
|
219
298
|
requireCriteriaFrozen(REQUIREMENTS)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
await
|
|
299
|
+
if (built && acceptance !== undefined) requireAcceptance(acceptance, since)
|
|
300
|
+
// Fix turns are held to the carried tests too: deleting a red one must not buy a green suite.
|
|
301
|
+
await green(
|
|
302
|
+
built && acceptance !== undefined ? () => requireAcceptanceKept(acceptance) : undefined,
|
|
303
|
+
)
|
|
304
|
+
await requireCoverage(options.base, options.slice)
|
|
305
|
+
await closing(options.more === true)
|
|
223
306
|
})
|
|
224
307
|
}
|
|
225
308
|
|
|
309
|
+
/** The last slice's closing, for a last slice a steer left with no criterion to build. */
|
|
310
|
+
export const closeOut = (): Promise<void> => scope("implement", () => closing(false))
|
|
311
|
+
|
|
312
|
+
/** A slice with `more` after it keeps the criteria and the slicing for the next one. */
|
|
313
|
+
const closing = (more: boolean): Promise<void> =>
|
|
314
|
+
run("closing", removeScript(more ? [REFACTOR_NOTE] : [REQUIREMENTS, REFACTOR_NOTE, SLICES]), {
|
|
315
|
+
label: "Closing out the implementation",
|
|
316
|
+
})
|
|
317
|
+
|
|
226
318
|
interface Decision {
|
|
227
319
|
readonly reference: string
|
|
320
|
+
readonly tip: KeptTip
|
|
228
321
|
/** Absent on `land` (or with `flagMode` off, unjudged): the pass itself is the implementation. */
|
|
229
322
|
readonly shape?: DiffShapeResult
|
|
230
323
|
}
|
|
@@ -271,8 +364,12 @@ interface Vetting {
|
|
|
271
364
|
}
|
|
272
365
|
|
|
273
366
|
/** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
|
|
274
|
-
const vetted = async (
|
|
275
|
-
|
|
367
|
+
const vetted = async (
|
|
368
|
+
tip: KeptTip,
|
|
369
|
+
vetting: Vetting,
|
|
370
|
+
slice: CriteriaSlice | undefined,
|
|
371
|
+
): Promise<Verdict | undefined> => {
|
|
372
|
+
const fault = vetting.waived ? undefined : acceptanceFault(REQUIREMENTS, tip, slice)
|
|
276
373
|
if (fault !== undefined) {
|
|
277
374
|
const verdict = await vetting.again({ fault, hash: tip.hash })
|
|
278
375
|
if (verdict.kind !== "overruled") return verdict
|
|
@@ -286,22 +383,22 @@ const vetted = async (tip: KeptTip, vetting: Vetting): Promise<Verdict | undefin
|
|
|
286
383
|
/** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
|
|
287
384
|
const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision | Verdict> => {
|
|
288
385
|
const shape = await judgeSeam(tip)
|
|
289
|
-
if (shape.choice === "land") return { reference: tip.hash }
|
|
290
|
-
if (shape.choice === "refactor-first") return { reference: tip.hash, shape }
|
|
386
|
+
if (shape.choice === "land") return { reference: tip.hash, tip }
|
|
387
|
+
if (shape.choice === "refactor-first") return { reference: tip.hash, tip, shape }
|
|
291
388
|
const next = await again({ strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 })
|
|
292
|
-
return next.kind === "overruled" ? { reference: tip.hash } : next
|
|
389
|
+
return next.kind === "overruled" ? { reference: tip.hash, tip } : next
|
|
293
390
|
}
|
|
294
391
|
|
|
295
|
-
const decide = (): Promise<Decision> =>
|
|
392
|
+
const decide = (slice: CriteriaSlice | undefined): Promise<Decision> =>
|
|
296
393
|
scope("seam", async () => {
|
|
297
394
|
const vetting: Vetting = { again: budget(), waived: false }
|
|
298
395
|
let retry: t.NaiveRetry | undefined
|
|
299
396
|
for (;;) {
|
|
300
|
-
await naivePass(retry)
|
|
397
|
+
await naivePass(retry, slice)
|
|
301
398
|
const tip = kept("naive")!
|
|
302
399
|
const outcome =
|
|
303
|
-
(await vetted(tip, vetting)) ??
|
|
304
|
-
(flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash })
|
|
400
|
+
(await vetted(tip, vetting, slice)) ??
|
|
401
|
+
(flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash, tip })
|
|
305
402
|
if (!("kind" in outcome)) return outcome
|
|
306
403
|
retry = outcome.kind === "retry" ? outcome.retry : undefined
|
|
307
404
|
}
|
|
@@ -315,10 +412,40 @@ export interface SeamOutcome {
|
|
|
315
412
|
/** The feature slice's base: HEAD after the refactor on refactor-first, else the caller's base. */
|
|
316
413
|
readonly base: string
|
|
317
414
|
readonly refactored?: boolean
|
|
415
|
+
/** Set on refactor-first: what the rebuild carries over from the naive pass (the note only after a refactor). */
|
|
416
|
+
readonly rebuild?: t.Rebuild
|
|
318
417
|
}
|
|
319
418
|
|
|
320
|
-
|
|
321
|
-
|
|
419
|
+
/** Write the refactor note from the whole naive pass; a turn that changes anything else is refused. */
|
|
420
|
+
export const refactorNote = async (
|
|
421
|
+
reference: string,
|
|
422
|
+
strays: readonly string[],
|
|
423
|
+
): Promise<string> => {
|
|
424
|
+
await agent("note", t.refactorNotePrompt(reference, strays), {
|
|
425
|
+
label: "Writing the refactor note",
|
|
426
|
+
file: REFACTOR_NOTE,
|
|
427
|
+
model: coder(),
|
|
428
|
+
system: t.builderSystem(),
|
|
429
|
+
})
|
|
430
|
+
const others = changes()
|
|
431
|
+
.map((c) => c.path)
|
|
432
|
+
.filter((path) => path !== REFACTOR_NOTE)
|
|
433
|
+
const note = read(REFACTOR_NOTE) ?? ""
|
|
434
|
+
if (others.length > 0 || note.trim() === "")
|
|
435
|
+
refuse(
|
|
436
|
+
`gtd land: refactor-note: write ${REFACTOR_NOTE} and change nothing else${others.length > 0 ? ` — revert these:\n${others.map((path) => ` - ${path}`).join("\n")}` : ""}`,
|
|
437
|
+
)
|
|
438
|
+
return note
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export interface SeamSlice {
|
|
442
|
+
/** Absent for a change built in one slice. */
|
|
443
|
+
readonly criteria?: CriteriaSlice | undefined
|
|
444
|
+
readonly refactor?: string | undefined
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export const seamBuild = async (base: string, slice: SeamSlice = {}): Promise<SeamOutcome> => {
|
|
448
|
+
const { reference, tip, shape } = await decide(slice.criteria)
|
|
322
449
|
if (shape === undefined) return { reference, base, land: true }
|
|
323
450
|
if (shape.unreadable.length > 0) {
|
|
324
451
|
await scope("seam", () =>
|
|
@@ -328,9 +455,20 @@ export const seamBuild = async (base: string): Promise<SeamOutcome> => {
|
|
|
328
455
|
acceptClean: true,
|
|
329
456
|
}),
|
|
330
457
|
)
|
|
331
|
-
return { reference, base }
|
|
458
|
+
return { reference, base, rebuild: { acceptance: naiveAcceptance(tip, slice.criteria) } }
|
|
459
|
+
}
|
|
460
|
+
const strays = refactorStrays(shape)
|
|
461
|
+
if (strays.length === 0)
|
|
462
|
+
return { reference, base, rebuild: { acceptance: naiveAcceptance(tip, slice.criteria) } }
|
|
463
|
+
const note = await scope("seam", () => refactorNote(reference, strays))
|
|
464
|
+
const refactored = await sliced(slice.refactor ?? "refactor", () =>
|
|
465
|
+
reviewedRefactor(shape, { reference, note }),
|
|
466
|
+
)
|
|
467
|
+
return {
|
|
468
|
+
reference,
|
|
469
|
+
base: head(),
|
|
470
|
+
refactored,
|
|
471
|
+
// Fixed at `seam.note`, like the package's scope: a later edit of the file reaches neither turn.
|
|
472
|
+
rebuild: { note, acceptance: naiveAcceptance(tip, slice.criteria) },
|
|
332
473
|
}
|
|
333
|
-
if (refactorStrays(shape).length === 0) return { reference, base }
|
|
334
|
-
const refactored = await slice("refactor", () => reviewedRefactor(shape, { reference }))
|
|
335
|
-
return { reference, base: head(), refactored }
|
|
336
474
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: a bad sliceMaxCriteria refuses the landing that would pin it
|
|
3
|
+
|
|
4
|
+
The cap is read right after idle, so a value that is not a positive integer
|
|
5
|
+
never gets pinned into a process that would refuse every later landing.
|
|
6
|
+
|
|
7
|
+
Scenario: a zero cap refuses the idle landing
|
|
8
|
+
Given the shipped workflows, as gtd install scaffolds them
|
|
9
|
+
And an environment variable "GTD_SLICEMAXCRITERIA" set to "0"
|
|
10
|
+
And a file "TODO.md" with:
|
|
11
|
+
"""
|
|
12
|
+
# Something
|
|
13
|
+
"""
|
|
14
|
+
Then driving the process is refused with "sliceMaxCriteria must be a positive integer, got \"0\""
|