@pmelab/gtd 36.0.0 → 37.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 +21 -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-zAfoJtR7.mjs} +53 -16
- package/dist/chunks/{tail-kar1zhOb.mjs → tail-BRC4DGdV.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 +126 -33
- 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 +174 -44
- 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/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 +8 -2
- package/src/workflows/feature/feature.ts +6 -2
- 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 +120 -19
- package/src/workflows/shared/options.test.ts +11 -2
- package/src/workflows/shared/options.ts +18 -4
- package/src/workflows/shared/steps.ts +1 -0
- package/src/workflows/shared/text.ts +80 -14
|
@@ -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,6 +13,7 @@ import {
|
|
|
11
13
|
kept,
|
|
12
14
|
quote,
|
|
13
15
|
read,
|
|
16
|
+
refuse,
|
|
14
17
|
removeScript,
|
|
15
18
|
run,
|
|
16
19
|
scope,
|
|
@@ -21,7 +24,7 @@ import {
|
|
|
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 } 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"
|
|
@@ -101,18 +104,26 @@ const addedLines = (change: Change): string => {
|
|
|
101
104
|
.join("\n")
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
const acceptanceTests = (changes: readonly Change[]): readonly Change[] =>
|
|
108
|
+
changes.filter((c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path))
|
|
109
|
+
|
|
110
|
+
const idsOf = (file: string): string[] =>
|
|
111
|
+
(criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "")
|
|
112
|
+
|
|
113
|
+
// One rule for both gates, so a naive test carried over verbatim always satisfies the rebuild's.
|
|
114
|
+
const unnamed = (ids: readonly string[], tests: readonly Change[]): string[] => {
|
|
115
|
+
const lines = tests.map(addedLines)
|
|
116
|
+
return ids.filter((id) => !lines.some((text) => named(id, text)))
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
/**
|
|
105
120
|
* Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
|
|
106
121
|
* test line of the pass names, or a test importing a file the pass added —
|
|
107
122
|
* naive structure a reset and refactor replaces, which the tests must survive.
|
|
108
123
|
*/
|
|
109
124
|
export const acceptanceFault = (file: string, tip: KeptTip): string | undefined => {
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
(c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path),
|
|
113
|
-
)
|
|
114
|
-
const lines = tests.map(addedLines)
|
|
115
|
-
const untested = ids.filter((id) => !lines.some((text) => named(id, text)))
|
|
125
|
+
const tests = acceptanceTests(tip.changes)
|
|
126
|
+
const untested = unnamed(idsOf(file), tests)
|
|
116
127
|
if (untested.length > 0)
|
|
117
128
|
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
129
|
const added = tip.changes
|
|
@@ -131,6 +142,49 @@ export const acceptanceFault = (file: string, tip: KeptTip): string | undefined
|
|
|
131
142
|
return undefined
|
|
132
143
|
}
|
|
133
144
|
|
|
145
|
+
/** What a rebuild carries over from the naive pass `tip`: the IDs its tests name, and those test files. */
|
|
146
|
+
export const naiveAcceptance = (tip: KeptTip): t.Acceptance => {
|
|
147
|
+
const ids = idsOf(REQUIREMENTS)
|
|
148
|
+
const perTest = acceptanceTests(tip.changes).map((c) => {
|
|
149
|
+
const lines = addedLines(c)
|
|
150
|
+
return { path: c.path, ids: ids.filter((id) => named(id, lines)) }
|
|
151
|
+
})
|
|
152
|
+
return {
|
|
153
|
+
hash: tip.hash,
|
|
154
|
+
tests: perTest.filter((c) => c.ids.length > 0).map((c) => c.path),
|
|
155
|
+
ids: ids.filter((id) => perTest.some((c) => c.ids.includes(id))),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Refuse a rebuild whose test changes since `since` leave out an ID the naive
|
|
161
|
+
* pass's tests named. A skipped test still counts: the gate reads text, never runs it.
|
|
162
|
+
*/
|
|
163
|
+
export const requireAcceptance = (acceptance: t.Acceptance, since: string): void => {
|
|
164
|
+
const missing = unnamed(acceptance.ids, acceptanceTests(changesSince(since)))
|
|
165
|
+
if (missing.length === 0) return
|
|
166
|
+
refuse(
|
|
167
|
+
`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`,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Refuse a fix turn that, on its own, drops an ID the naive pass's tests named
|
|
173
|
+
* from a test file. Turn-local, so a person's edit at an escalation rest never
|
|
174
|
+
* wedges later fix turns.
|
|
175
|
+
*/
|
|
176
|
+
export const requireAcceptanceKept = (acceptance: t.Acceptance): void => {
|
|
177
|
+
const dropped = acceptance.ids.filter((id) =>
|
|
178
|
+
changes()
|
|
179
|
+
.filter((c) => isCode(c.path) && isTestFile(c.path))
|
|
180
|
+
.some((c) => named(id, c.before ?? "") && !named(id, c.after ?? "")),
|
|
181
|
+
)
|
|
182
|
+
if (dropped.length === 0) return
|
|
183
|
+
refuse(
|
|
184
|
+
`gtd land: rebuild-acceptance: this turn drops ${dropped.join(", ")} from the tests — the naive pass's acceptance tests stay; fix the code, not them`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
134
188
|
const restore = (path: string): string =>
|
|
135
189
|
`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
190
|
|
|
@@ -166,6 +220,8 @@ const naiveGreen = (tip: KeptTip): Promise<boolean> => {
|
|
|
166
220
|
)
|
|
167
221
|
}
|
|
168
222
|
|
|
223
|
+
export type { Acceptance, Rebuild } from "../shared/text.js"
|
|
224
|
+
|
|
169
225
|
export interface ImplementOptions {
|
|
170
226
|
readonly reference?: string | undefined
|
|
171
227
|
readonly tier?: RiskTier | undefined
|
|
@@ -174,6 +230,8 @@ export interface ImplementOptions {
|
|
|
174
230
|
readonly afterRefactor?: boolean | undefined
|
|
175
231
|
/** A kept naive tip to apply as the implementation; built fresh only when it no longer applies. */
|
|
176
232
|
readonly naive?: string | undefined
|
|
233
|
+
/** A rebuild after a refactor-first judgment: the note it plugs into, the acceptance tests it must carry. */
|
|
234
|
+
readonly rebuild?: t.Rebuild | undefined
|
|
177
235
|
}
|
|
178
236
|
|
|
179
237
|
/** Resolves `false` when the tip no longer applies (the failure is in `.gtd/FEEDBACK.md`) or has nothing outside `.gtd/` to apply. */
|
|
@@ -199,7 +257,10 @@ export function implement(
|
|
|
199
257
|
const options: ImplementOptions =
|
|
200
258
|
typeof first === "object" && first !== null ? first : { reference: first, tier, amend }
|
|
201
259
|
return scope("implement", async () => {
|
|
202
|
-
|
|
260
|
+
const since = head()
|
|
261
|
+
const acceptance = options.rebuild?.acceptance
|
|
262
|
+
const built = options.naive === undefined || !(await applied(options.naive))
|
|
263
|
+
if (built)
|
|
203
264
|
await t.agentWithSkills(
|
|
204
265
|
"building",
|
|
205
266
|
t.implementBuildingPrompt(
|
|
@@ -208,6 +269,7 @@ export function implement(
|
|
|
208
269
|
options.amend,
|
|
209
270
|
options.afterRefactor,
|
|
210
271
|
flagModeOn(),
|
|
272
|
+
options.rebuild,
|
|
211
273
|
),
|
|
212
274
|
{
|
|
213
275
|
label: "Implementing",
|
|
@@ -217,14 +279,21 @@ export function implement(
|
|
|
217
279
|
},
|
|
218
280
|
)
|
|
219
281
|
requireCriteriaFrozen(REQUIREMENTS)
|
|
220
|
-
|
|
282
|
+
if (built && acceptance !== undefined) requireAcceptance(acceptance, since)
|
|
283
|
+
// Fix turns are held to the carried tests too: deleting a red one must not buy a green suite.
|
|
284
|
+
await green(
|
|
285
|
+
built && acceptance !== undefined ? () => requireAcceptanceKept(acceptance) : undefined,
|
|
286
|
+
)
|
|
221
287
|
await requireCoverage()
|
|
222
|
-
await run("closing", removeScript([REQUIREMENTS]), {
|
|
288
|
+
await run("closing", removeScript([REQUIREMENTS, REFACTOR_NOTE]), {
|
|
289
|
+
label: "Closing out the implementation",
|
|
290
|
+
})
|
|
223
291
|
})
|
|
224
292
|
}
|
|
225
293
|
|
|
226
294
|
interface Decision {
|
|
227
295
|
readonly reference: string
|
|
296
|
+
readonly tip: KeptTip
|
|
228
297
|
/** Absent on `land` (or with `flagMode` off, unjudged): the pass itself is the implementation. */
|
|
229
298
|
readonly shape?: DiffShapeResult
|
|
230
299
|
}
|
|
@@ -286,10 +355,10 @@ const vetted = async (tip: KeptTip, vetting: Vetting): Promise<Verdict | undefin
|
|
|
286
355
|
/** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
|
|
287
356
|
const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision | Verdict> => {
|
|
288
357
|
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 }
|
|
358
|
+
if (shape.choice === "land") return { reference: tip.hash, tip }
|
|
359
|
+
if (shape.choice === "refactor-first") return { reference: tip.hash, tip, shape }
|
|
291
360
|
const next = await again({ strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 })
|
|
292
|
-
return next.kind === "overruled" ? { reference: tip.hash } : next
|
|
361
|
+
return next.kind === "overruled" ? { reference: tip.hash, tip } : next
|
|
293
362
|
}
|
|
294
363
|
|
|
295
364
|
const decide = (): Promise<Decision> =>
|
|
@@ -301,7 +370,7 @@ const decide = (): Promise<Decision> =>
|
|
|
301
370
|
const tip = kept("naive")!
|
|
302
371
|
const outcome =
|
|
303
372
|
(await vetted(tip, vetting)) ??
|
|
304
|
-
(flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash })
|
|
373
|
+
(flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash, tip })
|
|
305
374
|
if (!("kind" in outcome)) return outcome
|
|
306
375
|
retry = outcome.kind === "retry" ? outcome.retry : undefined
|
|
307
376
|
}
|
|
@@ -315,10 +384,34 @@ export interface SeamOutcome {
|
|
|
315
384
|
/** The feature slice's base: HEAD after the refactor on refactor-first, else the caller's base. */
|
|
316
385
|
readonly base: string
|
|
317
386
|
readonly refactored?: boolean
|
|
387
|
+
/** Set on refactor-first: what the rebuild carries over from the naive pass (the note only after a refactor). */
|
|
388
|
+
readonly rebuild?: t.Rebuild
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Write the refactor note from the whole naive pass; a turn that changes anything else is refused. */
|
|
392
|
+
export const refactorNote = async (
|
|
393
|
+
reference: string,
|
|
394
|
+
strays: readonly string[],
|
|
395
|
+
): Promise<string> => {
|
|
396
|
+
await agent("note", t.refactorNotePrompt(reference, strays), {
|
|
397
|
+
label: "Writing the refactor note",
|
|
398
|
+
file: REFACTOR_NOTE,
|
|
399
|
+
model: coder(),
|
|
400
|
+
system: t.builderSystem(),
|
|
401
|
+
})
|
|
402
|
+
const others = changes()
|
|
403
|
+
.map((c) => c.path)
|
|
404
|
+
.filter((path) => path !== REFACTOR_NOTE)
|
|
405
|
+
const note = read(REFACTOR_NOTE) ?? ""
|
|
406
|
+
if (others.length > 0 || note.trim() === "")
|
|
407
|
+
refuse(
|
|
408
|
+
`gtd land: refactor-note: write ${REFACTOR_NOTE} and change nothing else${others.length > 0 ? ` — revert these:\n${others.map((path) => ` - ${path}`).join("\n")}` : ""}`,
|
|
409
|
+
)
|
|
410
|
+
return note
|
|
318
411
|
}
|
|
319
412
|
|
|
320
413
|
export const seamBuild = async (base: string): Promise<SeamOutcome> => {
|
|
321
|
-
const { reference, shape } = await decide()
|
|
414
|
+
const { reference, tip, shape } = await decide()
|
|
322
415
|
if (shape === undefined) return { reference, base, land: true }
|
|
323
416
|
if (shape.unreadable.length > 0) {
|
|
324
417
|
await scope("seam", () =>
|
|
@@ -328,9 +421,17 @@ export const seamBuild = async (base: string): Promise<SeamOutcome> => {
|
|
|
328
421
|
acceptClean: true,
|
|
329
422
|
}),
|
|
330
423
|
)
|
|
331
|
-
return { reference, base }
|
|
424
|
+
return { reference, base, rebuild: { acceptance: naiveAcceptance(tip) } }
|
|
425
|
+
}
|
|
426
|
+
const strays = refactorStrays(shape)
|
|
427
|
+
if (strays.length === 0) return { reference, base, rebuild: { acceptance: naiveAcceptance(tip) } }
|
|
428
|
+
const note = await scope("seam", () => refactorNote(reference, strays))
|
|
429
|
+
const refactored = await slice("refactor", () => reviewedRefactor(shape, { reference, note }))
|
|
430
|
+
return {
|
|
431
|
+
reference,
|
|
432
|
+
base: head(),
|
|
433
|
+
refactored,
|
|
434
|
+
// Fixed at `seam.note`, like the package's scope: a later edit of the file reaches neither turn.
|
|
435
|
+
rebuild: { note, acceptance: naiveAcceptance(tip) },
|
|
332
436
|
}
|
|
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
437
|
}
|
|
@@ -8,8 +8,9 @@ const { defaults, envDefaults, feature, fix, review, ShippedOptions } = workflow
|
|
|
8
8
|
const { flagShipped } = options
|
|
9
9
|
|
|
10
10
|
describe("the shipped workflow factories", () => {
|
|
11
|
-
it("take exactly the settings the shipped workflows declare as options", () => {
|
|
12
|
-
|
|
11
|
+
it("take exactly the settings the shipped workflows declare as options, and contextLimit", () => {
|
|
12
|
+
const settings = Object.keys(ShippedOptions.fields).filter((name) => name !== "contextLimit")
|
|
13
|
+
expect(settings.sort()).toEqual(
|
|
13
14
|
[...Object.keys(defaults), ...Object.keys(envDefaults)]
|
|
14
15
|
.filter((name) => !name.startsWith("flag_"))
|
|
15
16
|
.sort(),
|
|
@@ -23,6 +24,14 @@ describe("the shipped workflow factories", () => {
|
|
|
23
24
|
expect(defaults).toHaveProperty(`flag_${name}`, defaultOn ? "1" : "0")
|
|
24
25
|
})
|
|
25
26
|
|
|
27
|
+
it("keep contextLimit out of the settings", () => {
|
|
28
|
+
const built = configure(feature({ contextLimit: { hard: 0.8 } }))
|
|
29
|
+
expect(built).toMatchObject({ ok: true })
|
|
30
|
+
const build = built?.ok === true ? built.build : undefined
|
|
31
|
+
expect(build?.defaults).not.toHaveProperty("contextLimit")
|
|
32
|
+
expect(build?.envDefaults).not.toHaveProperty("contextLimit")
|
|
33
|
+
})
|
|
34
|
+
|
|
26
35
|
it("declare a seamAllow default a factory option overrides", () => {
|
|
27
36
|
expect(configure(feature({ seamAllow: "docs/**" }))).toMatchObject({
|
|
28
37
|
ok: true,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type WorkflowDoors,
|
|
7
7
|
type WorkflowFactory,
|
|
8
8
|
refuse,
|
|
9
|
+
scope,
|
|
9
10
|
vars,
|
|
10
11
|
} from "../../flows/index.js"
|
|
11
12
|
import { defaultFlagRegistry, defaults, envDefaults } from "./vars.js"
|
|
@@ -26,6 +27,13 @@ export const ShippedOptions = Schema.Struct({
|
|
|
26
27
|
plannerModel: setting,
|
|
27
28
|
coderModel: setting,
|
|
28
29
|
flagMode: Schema.optionalWith(Schema.Boolean, { exact: true }),
|
|
30
|
+
contextLimit: Schema.optionalWith(
|
|
31
|
+
Schema.Struct({
|
|
32
|
+
soft: Schema.optionalWith(Schema.Number, { exact: true }),
|
|
33
|
+
hard: Schema.optionalWith(Schema.Number, { exact: true }),
|
|
34
|
+
}),
|
|
35
|
+
{ exact: true },
|
|
36
|
+
),
|
|
29
37
|
})
|
|
30
38
|
|
|
31
39
|
export type ShippedOptions = Schema.Schema.Encoded<typeof ShippedOptions>
|
|
@@ -50,6 +58,12 @@ export const flagModeOn = (): boolean => {
|
|
|
50
58
|
return value === "true"
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
// Not a setting: a factory option is known at load, so the scope wraps the
|
|
62
|
+
// whole flow without reading a setting before its first step. The engine
|
|
63
|
+
// refuses limits it cannot use when it reaches an agent step.
|
|
64
|
+
const limited = (flow: Flow, context: ShippedOptions["contextLimit"]): Flow =>
|
|
65
|
+
context === undefined ? flow : () => scope({ context }, flow)
|
|
66
|
+
|
|
53
67
|
/** A shipped flow as a factory: its options override the settings it declares, each in its own kind. */
|
|
54
68
|
export const shipped = (
|
|
55
69
|
flow: Flow,
|
|
@@ -58,8 +72,8 @@ export const shipped = (
|
|
|
58
72
|
): WorkflowFactory<ShippedOptions> =>
|
|
59
73
|
defineWorkflow(
|
|
60
74
|
Schema.standardSchemaV1(ShippedOptions, { onExcessProperty: "error", errors: "all" }),
|
|
61
|
-
(options) => ({
|
|
62
|
-
flow,
|
|
75
|
+
({ contextLimit, ...options }) => ({
|
|
76
|
+
flow: limited(flow, contextLimit),
|
|
63
77
|
defaults: over(defaults, options),
|
|
64
78
|
envDefaults: over(envDefaults, options),
|
|
65
79
|
doors,
|
|
@@ -77,10 +91,10 @@ export const flagShipped = (
|
|
|
77
91
|
): WorkflowFactory<FlagOptions> =>
|
|
78
92
|
defineWorkflow(
|
|
79
93
|
Schema.standardSchemaV1(FlagOptions, { onExcessProperty: "error", errors: "all" }),
|
|
80
|
-
(options) => {
|
|
94
|
+
({ contextLimit, ...options }) => {
|
|
81
95
|
const flagDefaults = { ...defaults, flag: "", flagRegistry: defaultFlagRegistry }
|
|
82
96
|
return {
|
|
83
|
-
flow,
|
|
97
|
+
flow: limited(flow, contextLimit),
|
|
84
98
|
defaults: over(flagDefaults, options),
|
|
85
99
|
envDefaults: over(envDefaults, options),
|
|
86
100
|
doors: { [name]: { args: [{ name: "flag" }], vars: ({ flag }) => ({ flag: flag ?? "" }) } },
|
|
@@ -12,6 +12,7 @@ export const ESCALATION = ".gtd/ESCALATION.md"
|
|
|
12
12
|
export const REQUIREMENTS = ".gtd/REQUIREMENTS.md"
|
|
13
13
|
export const REVIEW = ".gtd/REVIEW.md"
|
|
14
14
|
export const REFACTOR = ".gtd/REFACTOR.md"
|
|
15
|
+
export const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md"
|
|
15
16
|
export const QUALITY = ".gtd/QUALITY.md"
|
|
16
17
|
|
|
17
18
|
export const planner = (): string => env.plannerModel ?? ""
|
|
@@ -350,22 +350,30 @@ export const refactorBuildingPrompt = ({
|
|
|
350
350
|
pattern,
|
|
351
351
|
reference,
|
|
352
352
|
feedback = false,
|
|
353
|
+
note = false,
|
|
353
354
|
}: {
|
|
354
355
|
pkg: string
|
|
355
356
|
strays: readonly string[]
|
|
356
357
|
pattern: string
|
|
357
358
|
reference?: string | undefined
|
|
358
359
|
feedback?: boolean | undefined
|
|
360
|
+
note?: boolean | undefined
|
|
359
361
|
}): string =>
|
|
360
362
|
`${stateFileRules}
|
|
361
363
|
- The package to implement is \`${pkg}\`; never delete it
|
|
362
364
|
${
|
|
363
|
-
|
|
364
|
-
? `- The package
|
|
365
|
-
|
|
365
|
+
note
|
|
366
|
+
? `- The package's \`## Refactor note\` section names the seams to open and the
|
|
367
|
+
shape each takes, written from the whole naive pass: build exactly those
|
|
366
368
|
`
|
|
367
369
|
: ""
|
|
368
|
-
}${
|
|
370
|
+
}${
|
|
371
|
+
feedback
|
|
372
|
+
? `- The package carries a \`## Review feedback\` section: a human's review of the
|
|
373
|
+
last refactor. Act on it, within the rules below
|
|
374
|
+
`
|
|
375
|
+
: ""
|
|
376
|
+
}${refactorRules(strays, pattern)}
|
|
369
377
|
${
|
|
370
378
|
reference === undefined
|
|
371
379
|
? ""
|
|
@@ -1217,24 +1225,82 @@ ${criteriaBinding(false)}
|
|
|
1217
1225
|
- Leave everything uncommitted and finish your turn — do not commit
|
|
1218
1226
|
${retry === undefined ? "" : naiveRetry(retry)}`
|
|
1219
1227
|
|
|
1228
|
+
/** The naive pass's kept tip, its acceptance test files and the criterion IDs they name. */
|
|
1229
|
+
export interface Acceptance {
|
|
1230
|
+
readonly hash: string
|
|
1231
|
+
readonly tests: readonly string[]
|
|
1232
|
+
readonly ids: readonly string[]
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** What a rebuild after a refactor-first judgment carries over from the naive pass. */
|
|
1236
|
+
export interface Rebuild {
|
|
1237
|
+
readonly note?: string | undefined
|
|
1238
|
+
readonly acceptance?: Acceptance | undefined
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const carries = (rebuild: Rebuild): boolean => (rebuild.acceptance?.ids.length ?? 0) > 0
|
|
1242
|
+
|
|
1243
|
+
const rebuildLines = ({ note, acceptance }: Rebuild): string =>
|
|
1244
|
+
`${
|
|
1245
|
+
note === undefined
|
|
1246
|
+
? ""
|
|
1247
|
+
: `- The refactor note (\`.gtd/REFACTOR-NOTE.md\`) named the seams the refactor
|
|
1248
|
+
opened; plug the change into them:
|
|
1249
|
+
|
|
1250
|
+
${note
|
|
1251
|
+
.trimEnd()
|
|
1252
|
+
.split("\n")
|
|
1253
|
+
.map((line) => (line === "" ? "" : ` > ${line}`))
|
|
1254
|
+
.join("\n")}
|
|
1255
|
+
|
|
1256
|
+
`
|
|
1257
|
+
}${
|
|
1258
|
+
acceptance === undefined || !carries({ acceptance })
|
|
1259
|
+
? ""
|
|
1260
|
+
: `- Carry over the naive pass's acceptance tests for ${acceptance.ids.join(", ")}, the
|
|
1261
|
+
one part of it you take as it is: \`git show ${acceptance.hash} -- ${acceptance.tests.map(quote).join(" ")}\`.
|
|
1262
|
+
Copy each test with its \`[AC-<n>]\` name into the tree as the refactor left
|
|
1263
|
+
it — check a file out of the tip whole only where the refactor did not touch
|
|
1264
|
+
it — changing only what reaches the release flag. A turn whose changed tests
|
|
1265
|
+
leave an ID out is refused, and the suite must be green with them
|
|
1266
|
+
`
|
|
1267
|
+
}`
|
|
1268
|
+
|
|
1269
|
+
export const refactorNotePrompt = (reference: string, strays: readonly string[]): string =>
|
|
1270
|
+
`${stateFileRules}
|
|
1271
|
+
- The seam judge ruled the naive pass refactor-first: it is never applied.
|
|
1272
|
+
A behaviour-neutral refactor lands first, then the change is rebuilt on
|
|
1273
|
+
the refactored base. Both turns read the note you write
|
|
1274
|
+
- Read the whole naive pass: \`git show ${reference}\`. It changed these
|
|
1275
|
+
existing files outside the allowlist:
|
|
1276
|
+
${listed(strays)}
|
|
1277
|
+
- Write \`.gtd/REFACTOR-NOTE.md\`: which seams the refactor opens (the existing
|
|
1278
|
+
code to restructure, by path and symbol) and the shape each takes — the
|
|
1279
|
+
signatures and where the new behaviour will plug in. Name only the files
|
|
1280
|
+
listed above: the refactor may change no other path. Never the feature's own
|
|
1281
|
+
behaviour, and no flag read: the refactor adds neither
|
|
1282
|
+
- Change nothing else — a turn that does is refused
|
|
1283
|
+
- Leave everything uncommitted and finish your turn — do not commit
|
|
1284
|
+
`
|
|
1285
|
+
|
|
1286
|
+
const afterRefactorLine = (rebuild: Rebuild): string =>
|
|
1287
|
+
`- A reviewed refactor has just landed and may already carry part of the change,
|
|
1288
|
+
though never a release flag. Build only what is still missing, held to every
|
|
1289
|
+
rule below; if nothing is, ${carries(rebuild) ? "carry over the acceptance tests below and change nothing else" : "change nothing and finish your turn"}
|
|
1290
|
+
`
|
|
1291
|
+
|
|
1220
1292
|
export const implementBuildingPrompt = (
|
|
1221
1293
|
reference: string | undefined,
|
|
1222
1294
|
tier?: "low" | "high",
|
|
1223
1295
|
amend?: boolean,
|
|
1224
1296
|
afterRefactor?: boolean,
|
|
1225
1297
|
flagMode = true,
|
|
1298
|
+
rebuild: Rebuild = {},
|
|
1226
1299
|
): string =>
|
|
1227
1300
|
`${stateFileRules}
|
|
1228
1301
|
- Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
|
|
1229
1302
|
${criteriaBinding(true)}
|
|
1230
|
-
${
|
|
1231
|
-
afterRefactor === true
|
|
1232
|
-
? `- A reviewed refactor has just landed and may already carry part of the change,
|
|
1233
|
-
though never a release flag. Build only what is still missing, held to every
|
|
1234
|
-
rule below; if nothing is, change nothing and finish your turn
|
|
1235
|
-
`
|
|
1236
|
-
: ""
|
|
1237
|
-
}${
|
|
1303
|
+
${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${
|
|
1238
1304
|
amend === true
|
|
1239
1305
|
? `- The change already exists in the tree from an earlier lap; amend it to meet
|
|
1240
1306
|
the re-derived requirements instead of rewriting it
|
|
@@ -1250,9 +1316,9 @@ ${
|
|
|
1250
1316
|
? ""
|
|
1251
1317
|
: `- Reference only: \`git show ${reference}\` shows an earlier naive attempt.
|
|
1252
1318
|
Read it for ideas; never apply, cherry-pick or check it out — write
|
|
1253
|
-
the change fresh
|
|
1319
|
+
the change fresh${carries(rebuild) ? ", its acceptance tests excepted (below)" : ""}
|
|
1254
1320
|
`
|
|
1255
|
-
}${
|
|
1321
|
+
}${rebuildLines(rebuild)}${
|
|
1256
1322
|
!flagMode
|
|
1257
1323
|
? "- Add no release flag — this project ships without release flags"
|
|
1258
1324
|
: tier === "low"
|