@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.
Files changed (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -9
  3. package/claude/hooks/context.ts +66 -0
  4. package/claude/hooks/drive.ts +3 -0
  5. package/claude/hooks/register.tsx +69 -6
  6. package/dist/chunks/{reserved-BAv71I4i.mjs → reserved-zAfoJtR7.mjs} +53 -16
  7. package/dist/chunks/{tail-kar1zhOb.mjs → tail-BRC4DGdV.mjs} +1 -1
  8. package/dist/chunks/{wire-D3Rc5r22.mjs → wire-CSsa3Wxd.mjs} +117 -2
  9. package/dist/gtd.bundle.mjs +498 -56
  10. package/dist/testkit.mjs +115 -3
  11. package/dist/wire.mjs +2 -2
  12. package/dist/workflows/cleanup.mjs +2 -2
  13. package/dist/workflows/feature.mjs +126 -33
  14. package/dist/workflows/fix.mjs +2 -2
  15. package/dist/workflows/review.mjs +2 -2
  16. package/dist/workflows/rollout.mjs +1 -1
  17. package/dist/workflows.mjs +174 -44
  18. package/package.json +1 -1
  19. package/src/cli/Cli.test.ts +10 -3
  20. package/src/cli/Cli.ts +76 -1
  21. package/src/cli/Program.test.ts +1 -0
  22. package/src/cli/Program.ts +28 -0
  23. package/src/cli/ToolCommands.ts +3 -0
  24. package/src/edge/Edge.ts +11 -0
  25. package/src/flows/runtime.ts +11 -0
  26. package/src/replay/Replay.ts +51 -2
  27. package/src/testkit/Layers.ts +1 -0
  28. package/src/wire/BeatDocument.ts +12 -2
  29. package/src/wire/BeatStatus.ts +3 -0
  30. package/src/wire/ContextLimit.fixture.ts +30 -0
  31. package/src/wire/ContextLimit.test.ts +96 -0
  32. package/src/wire/ContextLimit.ts +169 -0
  33. package/src/wire/index.ts +1 -0
  34. package/src/wire/types.ts +7 -0
  35. package/src/workflow/Workflow.ts +9 -1
  36. package/src/workflows/feature/context-limit.feature +37 -0
  37. package/src/workflows/feature/coverage.ts +8 -2
  38. package/src/workflows/feature/feature.ts +6 -2
  39. package/src/workflows/feature/refactor-absorbs.feature +12 -5
  40. package/src/workflows/feature/refactor-first.feature +11 -2
  41. package/src/workflows/feature/refactor-lens-models.feature +4 -0
  42. package/src/workflows/feature/refactor-rebuild.feature +269 -0
  43. package/src/workflows/feature/refactor-review.feature +45 -1
  44. package/src/workflows/feature/refactor.ts +9 -3
  45. package/src/workflows/feature/seam.test.ts +108 -3
  46. package/src/workflows/feature/seam.ts +120 -19
  47. package/src/workflows/shared/options.test.ts +11 -2
  48. package/src/workflows/shared/options.ts +18 -4
  49. package/src/workflows/shared/steps.ts +1 -0
  50. 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({ reference: "abc", base: "after-refactor", refactored: true })
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({ reference: "abc", base: "c-base" })
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({ reference: "abc", base: "c-base" })
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 ids = (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "")
111
- const tests = tip.changes.filter(
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
- if (options.naive === undefined || !(await applied(options.naive)))
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
- await green()
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]), { label: "Closing out the implementation" })
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
- expect(Object.keys(ShippedOptions.fields).sort()).toEqual(
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
- feedback
364
- ? `- The package carries a \`## Review feedback\` section: a human's review of the
365
- last refactor. Act on it, within the rules below
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
- }${refactorRules(strays, pattern)}
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"