@pmelab/gtd 36.0.0 → 38.0.0

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