@pmelab/gtd 35.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 +45 -29
- 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-C8Fm3s72.mjs → reserved-zAfoJtR7.mjs} +62 -24
- package/dist/chunks/{tail-oaYewBN_.mjs → tail-BRC4DGdV.mjs} +1 -1
- package/dist/chunks/{wire-D3Rc5r22.mjs → wire-CSsa3Wxd.mjs} +117 -2
- package/dist/flows.mjs +15 -10
- package/dist/gtd.bundle.mjs +511 -64
- package/dist/testkit.mjs +158 -26
- package/dist/wire.mjs +2 -2
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +138 -38
- 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 +208 -66
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +3 -3
- 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/flows/scripts.ts +31 -9
- package/src/replay/Replay.ts +51 -2
- package/src/testkit/FlowScripts.test.ts +58 -0
- package/src/testkit/FlowScripts.ts +61 -24
- 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/acceptance-criteria.feature +5 -1
- package/src/workflows/feature/context-limit.feature +37 -0
- package/src/workflows/feature/coverage.feature +6 -3
- package/src/workflows/feature/coverage.ts +8 -2
- package/src/workflows/feature/fast-path.feature +11 -19
- package/src/workflows/feature/feature.test.ts +24 -4
- package/src/workflows/feature/feature.ts +14 -10
- package/src/workflows/feature/flag-mode.feature +17 -9
- package/src/workflows/feature/lens-models.feature +7 -1
- package/src/workflows/feature/naive-acceptance.feature +2 -2
- package/src/workflows/feature/one-step-land.feature +10 -12
- package/src/workflows/feature/quality-unresolved.feature +7 -1
- 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/risk-tier.feature +12 -4
- package/src/workflows/feature/seam-rejection.feature +4 -9
- package/src/workflows/feature/seam.feature +29 -26
- package/src/workflows/feature/seam.test.ts +117 -6
- package/src/workflows/feature/seam.ts +158 -39
- package/src/workflows/review/review-door.feature +12 -4
- 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.test.ts +8 -0
- package/src/workflows/shared/text.ts +91 -22
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { posix } from "node:path"
|
|
2
2
|
import {
|
|
3
|
+
agent,
|
|
4
|
+
applyScript,
|
|
3
5
|
changes,
|
|
6
|
+
changesSince,
|
|
4
7
|
check,
|
|
8
|
+
codeChanges,
|
|
5
9
|
env,
|
|
6
10
|
head,
|
|
7
11
|
human,
|
|
@@ -9,16 +13,18 @@ import {
|
|
|
9
13
|
kept,
|
|
10
14
|
quote,
|
|
11
15
|
read,
|
|
16
|
+
refuse,
|
|
12
17
|
removeScript,
|
|
13
18
|
run,
|
|
14
19
|
scope,
|
|
15
20
|
slice,
|
|
16
21
|
vars,
|
|
22
|
+
wrote,
|
|
17
23
|
type Change,
|
|
18
24
|
type DiffShapeResult,
|
|
19
25
|
type KeptTip,
|
|
20
26
|
} from "../../flows/index.js"
|
|
21
|
-
import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
|
|
27
|
+
import { coder, FEEDBACK, REFACTOR_NOTE, REQUIREMENTS } from "../shared/steps.js"
|
|
22
28
|
import * as t from "../shared/text.js"
|
|
23
29
|
import { defaults } from "../shared/vars.js"
|
|
24
30
|
import { green, requireCoverage } from "./coverage.js"
|
|
@@ -98,18 +104,26 @@ const addedLines = (change: Change): string => {
|
|
|
98
104
|
.join("\n")
|
|
99
105
|
}
|
|
100
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
|
+
|
|
101
119
|
/**
|
|
102
120
|
* Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
|
|
103
121
|
* test line of the pass names, or a test importing a file the pass added —
|
|
104
122
|
* naive structure a reset and refactor replaces, which the tests must survive.
|
|
105
123
|
*/
|
|
106
124
|
export const acceptanceFault = (file: string, tip: KeptTip): string | undefined => {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
(c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path),
|
|
110
|
-
)
|
|
111
|
-
const lines = tests.map(addedLines)
|
|
112
|
-
const untested = ids.filter((id) => !lines.some((text) => named(id, text)))
|
|
125
|
+
const tests = acceptanceTests(tip.changes)
|
|
126
|
+
const untested = unnamed(idsOf(file), tests)
|
|
113
127
|
if (untested.length > 0)
|
|
114
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]}] …"`
|
|
115
129
|
const added = tip.changes
|
|
@@ -128,6 +142,49 @@ export const acceptanceFault = (file: string, tip: KeptTip): string | undefined
|
|
|
128
142
|
return undefined
|
|
129
143
|
}
|
|
130
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
|
+
|
|
131
188
|
const restore = (path: string): string =>
|
|
132
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`
|
|
133
190
|
|
|
@@ -144,7 +201,7 @@ const naiveGreen = (tip: KeptTip): Promise<boolean> => {
|
|
|
144
201
|
[
|
|
145
202
|
...paths,
|
|
146
203
|
"dirty=$(git -c core.quotePath=false ls-files -m -d -o --exclude-standard)",
|
|
147
|
-
`if ! git diff --binary ${quote(`${tip.hash}^`)} ${quote(tip.hash)} -- | git apply; then`,
|
|
204
|
+
`if ! git diff --binary --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ ${quote(`${tip.hash}^`)} ${quote(tip.hash)} -- | git apply --whitespace=nowarn; then`,
|
|
148
205
|
" echo 'gtd: the kept naive pass no longer applies to HEAD; no test ran'",
|
|
149
206
|
" exit 1",
|
|
150
207
|
"fi",
|
|
@@ -163,12 +220,26 @@ const naiveGreen = (tip: KeptTip): Promise<boolean> => {
|
|
|
163
220
|
)
|
|
164
221
|
}
|
|
165
222
|
|
|
223
|
+
export type { Acceptance, Rebuild } from "../shared/text.js"
|
|
224
|
+
|
|
166
225
|
export interface ImplementOptions {
|
|
167
226
|
readonly reference?: string | undefined
|
|
168
227
|
readonly tier?: RiskTier | undefined
|
|
169
228
|
readonly amend?: boolean | undefined
|
|
170
229
|
/** After a refactor slice, which may already carry the feature: an empty turn completes the step. */
|
|
171
230
|
readonly afterRefactor?: boolean | undefined
|
|
231
|
+
/** A kept naive tip to apply as the implementation; built fresh only when it no longer applies. */
|
|
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
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Resolves `false` when the tip no longer applies (the failure is in `.gtd/FEEDBACK.md`) or has nothing outside `.gtd/` to apply. */
|
|
238
|
+
const applied = async (tip: string): Promise<boolean> => {
|
|
239
|
+
await run("applying", applyScript(tip, FEEDBACK, t.naiveApplyFailure(tip), [".gtd"]), {
|
|
240
|
+
label: "Applying the naive pass",
|
|
241
|
+
})
|
|
242
|
+
return !wrote(FEEDBACK) && codeChanges().length > 0
|
|
172
243
|
}
|
|
173
244
|
|
|
174
245
|
export function implement(options?: ImplementOptions): Promise<void>
|
|
@@ -186,39 +257,51 @@ export function implement(
|
|
|
186
257
|
const options: ImplementOptions =
|
|
187
258
|
typeof first === "object" && first !== null ? first : { reference: first, tier, amend }
|
|
188
259
|
return scope("implement", async () => {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
260
|
+
const since = head()
|
|
261
|
+
const acceptance = options.rebuild?.acceptance
|
|
262
|
+
const built = options.naive === undefined || !(await applied(options.naive))
|
|
263
|
+
if (built)
|
|
264
|
+
await t.agentWithSkills(
|
|
265
|
+
"building",
|
|
266
|
+
t.implementBuildingPrompt(
|
|
267
|
+
options.reference,
|
|
268
|
+
options.tier,
|
|
269
|
+
options.amend,
|
|
270
|
+
options.afterRefactor,
|
|
271
|
+
flagModeOn(),
|
|
272
|
+
options.rebuild,
|
|
273
|
+
),
|
|
274
|
+
{
|
|
275
|
+
label: "Implementing",
|
|
276
|
+
model: coder(),
|
|
277
|
+
system: t.builderSystem(),
|
|
278
|
+
...(options.afterRefactor === true ? { allowEmpty: true } : {}),
|
|
279
|
+
},
|
|
280
|
+
)
|
|
205
281
|
requireCriteriaFrozen(REQUIREMENTS)
|
|
206
|
-
|
|
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
|
+
)
|
|
207
287
|
await requireCoverage()
|
|
208
|
-
await run("closing", removeScript([REQUIREMENTS]), {
|
|
288
|
+
await run("closing", removeScript([REQUIREMENTS, REFACTOR_NOTE]), {
|
|
289
|
+
label: "Closing out the implementation",
|
|
290
|
+
})
|
|
209
291
|
})
|
|
210
292
|
}
|
|
211
293
|
|
|
212
294
|
interface Decision {
|
|
213
295
|
readonly reference: string
|
|
214
|
-
|
|
296
|
+
readonly tip: KeptTip
|
|
297
|
+
/** Absent on `land` (or with `flagMode` off, unjudged): the pass itself is the implementation. */
|
|
215
298
|
readonly shape?: DiffShapeResult
|
|
216
299
|
}
|
|
217
300
|
|
|
218
301
|
/** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
|
|
219
302
|
const steer = async (retry: t.NaiveRetry): Promise<boolean> => {
|
|
220
303
|
await human("steer", {
|
|
221
|
-
message: t.seamSteerMessage(retry),
|
|
304
|
+
message: t.seamSteerMessage(retry, flagModeOn()),
|
|
222
305
|
label: "Steer or overrule the naive pass",
|
|
223
306
|
file: REQUIREMENTS,
|
|
224
307
|
acceptClean: true,
|
|
@@ -272,10 +355,10 @@ const vetted = async (tip: KeptTip, vetting: Vetting): Promise<Verdict | undefin
|
|
|
272
355
|
/** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
|
|
273
356
|
const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision | Verdict> => {
|
|
274
357
|
const shape = await judgeSeam(tip)
|
|
275
|
-
if (shape.choice === "land") return { reference: tip.hash }
|
|
276
|
-
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 }
|
|
277
360
|
const next = await again({ strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 })
|
|
278
|
-
return next.kind === "overruled" ? { reference: tip.hash } : next
|
|
361
|
+
return next.kind === "overruled" ? { reference: tip.hash, tip } : next
|
|
279
362
|
}
|
|
280
363
|
|
|
281
364
|
const decide = (): Promise<Decision> =>
|
|
@@ -285,23 +368,51 @@ const decide = (): Promise<Decision> =>
|
|
|
285
368
|
for (;;) {
|
|
286
369
|
await naivePass(retry)
|
|
287
370
|
const tip = kept("naive")!
|
|
288
|
-
const outcome =
|
|
371
|
+
const outcome =
|
|
372
|
+
(await vetted(tip, vetting)) ??
|
|
373
|
+
(flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash, tip })
|
|
289
374
|
if (!("kind" in outcome)) return outcome
|
|
290
375
|
retry = outcome.kind === "retry" ? outcome.retry : undefined
|
|
291
376
|
}
|
|
292
377
|
})
|
|
293
378
|
|
|
294
379
|
export interface SeamOutcome {
|
|
295
|
-
/** The naive pass's kept tip: the first implement turn's reading material. */
|
|
380
|
+
/** The naive pass's kept tip: applied as the implementation when `land`, else the first implement turn's reading material. */
|
|
296
381
|
readonly reference: string
|
|
382
|
+
/** The naive pass lands as it is: no build turn. */
|
|
383
|
+
readonly land?: boolean
|
|
297
384
|
/** The feature slice's base: HEAD after the refactor on refactor-first, else the caller's base. */
|
|
298
385
|
readonly base: string
|
|
299
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
|
|
300
411
|
}
|
|
301
412
|
|
|
302
413
|
export const seamBuild = async (base: string): Promise<SeamOutcome> => {
|
|
303
|
-
const { reference, shape } = await decide()
|
|
304
|
-
if (shape === undefined) return { reference, base }
|
|
414
|
+
const { reference, tip, shape } = await decide()
|
|
415
|
+
if (shape === undefined) return { reference, base, land: true }
|
|
305
416
|
if (shape.unreadable.length > 0) {
|
|
306
417
|
await scope("seam", () =>
|
|
307
418
|
human("by-hand", {
|
|
@@ -310,9 +421,17 @@ export const seamBuild = async (base: string): Promise<SeamOutcome> => {
|
|
|
310
421
|
acceptClean: true,
|
|
311
422
|
}),
|
|
312
423
|
)
|
|
313
|
-
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) },
|
|
314
436
|
}
|
|
315
|
-
if (refactorStrays(shape).length === 0) return { reference, base }
|
|
316
|
-
const refactored = await slice("refactor", () => reviewedRefactor(shape, { reference }))
|
|
317
|
-
return { reference, base: head(), refactored }
|
|
318
437
|
}
|
|
@@ -37,11 +37,17 @@ Feature: human review is the review door's, never the feature workflow's
|
|
|
37
37
|
- AC-1: subtract(a, b) returns a - b.
|
|
38
38
|
"""
|
|
39
39
|
And step "design.gate.answer" changes nothing
|
|
40
|
-
And step "
|
|
40
|
+
And step "seam.naive" writes "src/calc.test.ts" with:
|
|
41
|
+
"""
|
|
42
|
+
it("[AC-1] subtracts", () => {})
|
|
43
|
+
"""
|
|
44
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
41
45
|
"""
|
|
42
46
|
export const add = (a: number, b: number) => a + b
|
|
43
47
|
export const subtract = (a: number, b: number) => a - b
|
|
44
48
|
"""
|
|
49
|
+
And step "seam.check" changes nothing
|
|
50
|
+
And step "implement.applying" runs its script
|
|
45
51
|
And step "implement.health.check" changes nothing
|
|
46
52
|
And step "implement.closing" runs its script
|
|
47
53
|
And judge step "implement.coverage" answers:
|
|
@@ -56,8 +62,10 @@ Feature: human review is the review door's, never the feature workflow's
|
|
|
56
62
|
And ".gtd/REVIEW.md" does not exist
|
|
57
63
|
And the commit subjects end with:
|
|
58
64
|
"""
|
|
59
|
-
gtd(human): design.gate.answer →
|
|
60
|
-
gtd(agent):
|
|
65
|
+
gtd(human): design.gate.answer → seam.naive
|
|
66
|
+
gtd(agent): seam.naive → seam.check
|
|
67
|
+
gtd(check): seam.check → implement.applying
|
|
68
|
+
gtd(check): implement.applying → implement.health.check
|
|
61
69
|
gtd(check): implement.health.check → implement.coverage
|
|
62
70
|
gtd(judge): implement.coverage → implement.closing
|
|
63
71
|
gtd(check): implement.closing → idle
|
|
@@ -80,7 +88,7 @@ Feature: human review is the review door's, never the feature workflow's
|
|
|
80
88
|
- [ ] ./src/calc.ts#2-2
|
|
81
89
|
new subtract function
|
|
82
90
|
"""
|
|
83
|
-
When gtd opens door "review" with "HEAD~
|
|
91
|
+
When gtd opens door "review" with "HEAD~11"
|
|
84
92
|
And the process is driven
|
|
85
93
|
Then the process rests at "build.review.await-review"
|
|
86
94
|
And the resting step's content contains the hash of the commit "feat: add calculator"
|
|
@@ -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 ?? ""
|
|
@@ -1059,6 +1059,14 @@ describe("the seam phase's texts", () => {
|
|
|
1059
1059
|
expect(message).not.toContain("outside the expected set")
|
|
1060
1060
|
})
|
|
1061
1061
|
|
|
1062
|
+
it("promises no judge on an overrule with flagMode off", () => {
|
|
1063
|
+
for (const retry of [{ red: "abc123" }, { fault: "acceptance-tests: x", hash: "abc123" }]) {
|
|
1064
|
+
const message = renderText(() => seamSteerMessage(retry, false))
|
|
1065
|
+
expect(message).not.toContain("seam judge")
|
|
1066
|
+
expect(message).toContain("it lands as the implementation")
|
|
1067
|
+
}
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1062
1070
|
it("names every unreadable path for a by-hand landing", () => {
|
|
1063
1071
|
const message = renderText(() =>
|
|
1064
1072
|
seamByHandMessage(["vendor/a.bin", "src/flows/helpers.ts"], ["vendor/a.bin"], "abc123"),
|
|
@@ -58,6 +58,9 @@ export const agentWithSkills = (
|
|
|
58
58
|
export const unwindFailure = (commit: string): string =>
|
|
59
59
|
`gtd could not unwind ${commit} out of your working tree.`
|
|
60
60
|
|
|
61
|
+
export const naiveApplyFailure = (commit: string): string =>
|
|
62
|
+
`gtd could not apply the naive pass ${commit} to the working tree; it is built fresh instead.`
|
|
63
|
+
|
|
61
64
|
export const idleMessage = (): string =>
|
|
62
65
|
`No active gtd process.
|
|
63
66
|
|
|
@@ -347,22 +350,30 @@ export const refactorBuildingPrompt = ({
|
|
|
347
350
|
pattern,
|
|
348
351
|
reference,
|
|
349
352
|
feedback = false,
|
|
353
|
+
note = false,
|
|
350
354
|
}: {
|
|
351
355
|
pkg: string
|
|
352
356
|
strays: readonly string[]
|
|
353
357
|
pattern: string
|
|
354
358
|
reference?: string | undefined
|
|
355
359
|
feedback?: boolean | undefined
|
|
360
|
+
note?: boolean | undefined
|
|
356
361
|
}): string =>
|
|
357
362
|
`${stateFileRules}
|
|
358
363
|
- The package to implement is \`${pkg}\`; never delete it
|
|
359
364
|
${
|
|
360
|
-
|
|
361
|
-
? `- The package
|
|
362
|
-
|
|
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
|
|
363
368
|
`
|
|
364
369
|
: ""
|
|
365
|
-
}${
|
|
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)}
|
|
366
377
|
${
|
|
367
378
|
reference === undefined
|
|
368
379
|
? ""
|
|
@@ -1197,8 +1208,8 @@ export const naivePassPrompt = (retry?: NaiveRetry): string =>
|
|
|
1197
1208
|
- Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
|
|
1198
1209
|
${criteriaBinding(false)}
|
|
1199
1210
|
- Implement them the quickest way that works — the naive version. Do not
|
|
1200
|
-
restructure, abstract or refactor around the change;
|
|
1201
|
-
|
|
1211
|
+
restructure, abstract or refactor around the change; it may land exactly as
|
|
1212
|
+
you leave it, and a reviewer makes it fit
|
|
1202
1213
|
- Work breadth-first: rough out every part of the change before finishing any
|
|
1203
1214
|
one, so a run cut off midway still shows the whole shape
|
|
1204
1215
|
- Write behaviour-level acceptance tests, at least one per criterion, each
|
|
@@ -1214,24 +1225,82 @@ ${criteriaBinding(false)}
|
|
|
1214
1225
|
- Leave everything uncommitted and finish your turn — do not commit
|
|
1215
1226
|
${retry === undefined ? "" : naiveRetry(retry)}`
|
|
1216
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
|
+
|
|
1217
1292
|
export const implementBuildingPrompt = (
|
|
1218
1293
|
reference: string | undefined,
|
|
1219
1294
|
tier?: "low" | "high",
|
|
1220
1295
|
amend?: boolean,
|
|
1221
1296
|
afterRefactor?: boolean,
|
|
1222
1297
|
flagMode = true,
|
|
1298
|
+
rebuild: Rebuild = {},
|
|
1223
1299
|
): string =>
|
|
1224
1300
|
`${stateFileRules}
|
|
1225
1301
|
- Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
|
|
1226
1302
|
${criteriaBinding(true)}
|
|
1227
|
-
${
|
|
1228
|
-
afterRefactor === true
|
|
1229
|
-
? `- A reviewed refactor has just landed and may already carry part of the change,
|
|
1230
|
-
though never a release flag. Build only what is still missing, held to every
|
|
1231
|
-
rule below; if nothing is, change nothing and finish your turn
|
|
1232
|
-
`
|
|
1233
|
-
: ""
|
|
1234
|
-
}${
|
|
1303
|
+
${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${
|
|
1235
1304
|
amend === true
|
|
1236
1305
|
? `- The change already exists in the tree from an earlier lap; amend it to meet
|
|
1237
1306
|
the re-derived requirements instead of rewriting it
|
|
@@ -1247,9 +1316,9 @@ ${
|
|
|
1247
1316
|
? ""
|
|
1248
1317
|
: `- Reference only: \`git show ${reference}\` shows an earlier naive attempt.
|
|
1249
1318
|
Read it for ideas; never apply, cherry-pick or check it out — write
|
|
1250
|
-
the change fresh
|
|
1319
|
+
the change fresh${carries(rebuild) ? ", its acceptance tests excepted (below)" : ""}
|
|
1251
1320
|
`
|
|
1252
|
-
}${
|
|
1321
|
+
}${rebuildLines(rebuild)}${
|
|
1253
1322
|
!flagMode
|
|
1254
1323
|
? "- Add no release flag — this project ships without release flags"
|
|
1255
1324
|
: tier === "low"
|
|
@@ -1282,14 +1351,15 @@ An edit together with an approval counts as steer. Only
|
|
|
1282
1351
|
branch unjudged.
|
|
1283
1352
|
`
|
|
1284
1353
|
|
|
1285
|
-
|
|
1354
|
+
/** `judged`: whether an overruled pass still meets the seam judge (`flagMode` on). */
|
|
1355
|
+
export const seamSteerMessage = (retry: NaiveRetry, judged = true): string =>
|
|
1286
1356
|
isFault(retry)
|
|
1287
1357
|
? `The naive pass failed three times and the retries are spent. The last
|
|
1288
1358
|
attempt's tests (\`git show ${retry.hash}\`) fall short:
|
|
1289
1359
|
${retry.fault}
|
|
1290
1360
|
|
|
1291
1361
|
${steerChoices(`land a clean tree to go on with the last attempt as it is:
|
|
1292
|
-
\`seam.check\` runs its suite and the seam judge rules on its shape. Use it
|
|
1362
|
+
\`seam.check\` runs its suite and ${judged ? "the seam judge rules on its shape" : "it lands as the implementation"}. Use it
|
|
1293
1363
|
when the rule cannot fit this change (tests the globs do not see, or no
|
|
1294
1364
|
entry point that existed before). Risk: a criterion may have no test that
|
|
1295
1365
|
survives a later refactor — the choice is recorded in history.`)}`
|
|
@@ -1302,7 +1372,7 @@ Two ways forward (then run \`gtd land\`):
|
|
|
1302
1372
|
- **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
|
|
1303
1373
|
pass runs again with a fresh retry budget
|
|
1304
1374
|
- **Overrule** — land a clean tree to go on with the last attempt as it is:
|
|
1305
|
-
the seam judge rules on its shape, and the implementation's health loop
|
|
1375
|
+
${judged ? "the seam judge rules on its shape" : "it lands as the implementation"}, and the implementation's health loop
|
|
1306
1376
|
must turn the suite green. Risk: the criteria's tests never passed on the
|
|
1307
1377
|
naive pass — the choice is recorded in history.
|
|
1308
1378
|
|
|
@@ -1321,9 +1391,8 @@ Two ways forward (then run \`gtd land\`):
|
|
|
1321
1391
|
- **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
|
|
1322
1392
|
pass runs again with a fresh retry budget (the risk tier stays as the
|
|
1323
1393
|
design gate settled it; a tier edit here is not applied)
|
|
1324
|
-
- **Overrule** — land a clean tree to accept the last attempt
|
|
1325
|
-
the implementation
|
|
1326
|
-
and no refactor. Risk: this ships the seam the judge objected to,
|
|
1394
|
+
- **Overrule** — land a clean tree to accept the last attempt as it is: it
|
|
1395
|
+
lands as the implementation, with no seam gate and no refactor. Risk: this ships the seam the judge objected to,
|
|
1327
1396
|
unaddressed — the choice is recorded in history.
|
|
1328
1397
|
|
|
1329
1398
|
An edit together with an approval counts as steer. Only
|