@pmelab/gtd 38.0.0 → 38.2.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 (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +36 -3
  3. package/dist/chunks/{Schema-DO5ttJaI.mjs → Schema-C6fLlQzx.mjs} +1 -1
  4. package/dist/chunks/{reserved-sfMdy7v7.mjs → reserved-C3PgP7XB.mjs} +56 -12
  5. package/dist/chunks/{tail-Cgca5Gp-.mjs → tail-Bkxki2nK.mjs} +1 -1
  6. package/dist/chunks/{wire-CSsa3Wxd.mjs → wire-BJuZWR3C.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +292 -53
  8. package/dist/testkit.mjs +257 -21
  9. package/dist/wire.mjs +1 -1
  10. package/dist/workflows/cleanup.mjs +2 -2
  11. package/dist/workflows/feature.mjs +158 -42
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +2 -2
  14. package/dist/workflows/rollout.mjs +1 -1
  15. package/dist/workflows.mjs +230 -70
  16. package/package.json +1 -1
  17. package/src/cli/Cli.test.ts +15 -3
  18. package/src/cli/Cli.ts +61 -28
  19. package/src/cli/History.ts +1 -19
  20. package/src/cli/Metrics.test.ts +224 -0
  21. package/src/cli/Metrics.ts +310 -0
  22. package/src/cli/Output.ts +13 -0
  23. package/src/cli/Program.test.ts +3 -1
  24. package/src/cli/Program.ts +4 -0
  25. package/src/testkit/steps.ts +4 -0
  26. package/src/wire/ContextLimit.ts +1 -1
  27. package/src/workflows/feature/continuation-cap.feature +65 -0
  28. package/src/workflows/feature/continuation-rebuild.feature +88 -0
  29. package/src/workflows/feature/continuation-slices.feature +64 -0
  30. package/src/workflows/feature/continuation.feature +205 -0
  31. package/src/workflows/feature/criteria.ts +11 -0
  32. package/src/workflows/feature/refactor.ts +64 -30
  33. package/src/workflows/feature/seam.ts +102 -30
  34. package/src/workflows/shared/continuation.ts +92 -0
  35. package/src/workflows/shared/options.ts +2 -0
  36. package/src/workflows/shared/steps.ts +1 -0
  37. package/src/workflows/shared/text.test.ts +6 -2
  38. package/src/workflows/shared/text.ts +104 -8
  39. package/src/workflows/shared/vars.ts +5 -0
@@ -0,0 +1,205 @@
1
+ @inmem
2
+ Feature: a turn stopped at its context limit is snapshotted and continued
3
+
4
+ A naive pass, refactor or build turn told to stop at its context limit
5
+ writes its handoff note to `.gtd/HANDOFF.md`. That turn is the snapshot —
6
+ the naive pass's kept tip, or the refactor's or build's own commit — and a
7
+ continuation in a fresh conversation (`<phase>.continue.<step>`) picks up
8
+ from it with the note and what is still open. After `continuations` (3) of
9
+ them, or `continuationStall` (2) in a row that close nothing still open, the
10
+ phase rests at `<phase>.oversized`.
11
+
12
+ Background:
13
+ Given the shipped workflows, as gtd install scaffolds them
14
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
15
+ And an environment variable "GTD_REFACTORQUALITYREVIEWS" set to ""
16
+ And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
17
+ And a file "src/calc.ts" with:
18
+ """
19
+ export const add = (a: number, b: number) => a + b
20
+ """
21
+ And the working tree is committed as "feat: add calculator"
22
+ And a file "TODO.md" with:
23
+ """
24
+ # Add subtract and multiply
25
+ """
26
+ And step "unwind" changes nothing
27
+ And step "start-gate.check" changes nothing
28
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
29
+ """
30
+ # Requirements
31
+
32
+ ## Acceptance Criteria
33
+
34
+ - AC-1: `subtract(5, 3)` returns 2.
35
+ - AC-2: `multiply(2, 3)` returns 6.
36
+ """
37
+ And step "design.gate.answer" changes nothing
38
+ And step "seam.check" changes nothing
39
+
40
+ Scenario: a naive pass stopped at its context limit is continued from its kept tip with the open IDs
41
+ Given step "seam.naive" writes "tests/calc.test.ts" with:
42
+ """
43
+ it("[AC-1] subtracts", () => {})
44
+ """
45
+ And step "seam.naive" writes ".gtd/HANDOFF.md" with:
46
+ """
47
+ Wrote subtract and its test; multiply was next.
48
+ """
49
+ When the process is driven
50
+ Then the process rests at "seam.continue.naive"
51
+ And the resting step's content contains "Your previous turn stopped at its context limit"
52
+ And the resting step's content contains "Still open: AC-2."
53
+ And the resting step's content contains "Wrote subtract and its test; multiply was next."
54
+ And the resting step's content contains "':(top)' ':(top,exclude).gtd/HANDOFF.md' | git apply"
55
+ And ".gtd/HANDOFF.md" does not exist
56
+ And the output of gtd "next --json" contains "\"resume\":false"
57
+ And the output of gtd "next --json" contains "\"memory\":\"seam.continue#"
58
+
59
+ Given step "seam.continue.naive" writes "tests/calc.test.ts" with:
60
+ """
61
+ it("[AC-1] subtracts", () => {})
62
+ it("[AC-2] multiplies", () => {})
63
+ """
64
+ When the process is driven
65
+ Then the process rests at "implement.applying"
66
+ And the commit subjects end with:
67
+ """
68
+ gtd(human): design.gate.answer → seam.naive
69
+ gtd(agent): seam.naive → seam.continue.naive
70
+ gtd(agent): seam.continue.naive → seam.check
71
+ gtd(check): seam.check → implement.applying
72
+ """
73
+
74
+ Scenario: two continuations in a row that close nothing rest for a person
75
+ Given step "seam.naive" writes "tests/calc.test.ts" with:
76
+ """
77
+ it("[AC-1] subtracts", () => {})
78
+ """
79
+ And step "seam.naive" writes ".gtd/HANDOFF.md" with:
80
+ """
81
+ Subtract is tested; multiply was next.
82
+ """
83
+ And step "seam.continue.naive" writes ".gtd/HANDOFF.md" with:
84
+ """
85
+ Still on multiply.
86
+ """
87
+ When the process is driven
88
+ Then the process rests at "seam.oversized"
89
+ And the resting step's content contains "the last 2 continuations closed nothing still open"
90
+ And the commit subjects end with:
91
+ """
92
+ gtd(human): design.gate.answer → seam.naive
93
+ gtd(agent): seam.naive → seam.continue.naive
94
+ gtd(agent): seam.continue.naive
95
+ gtd(agent): seam.continue.naive → seam.oversized
96
+ """
97
+
98
+ Scenario: a refactor stopped at its context limit is run green before its continuation
99
+ Given step "seam.naive" writes "tests/calc.test.ts" with:
100
+ """
101
+ it("[AC-1] subtracts", () => {})
102
+ it("[AC-2] multiplies", () => {})
103
+ """
104
+ And step "seam.naive" writes "src/calc.ts" with:
105
+ """
106
+ export const add = (a: number, b: number) => a + b
107
+ export const subtract = (a: number, b: number) => a - b
108
+ export const multiply = (a: number, b: number) => a * b
109
+ """
110
+ And judge step "seam.judge" answers:
111
+ """
112
+ [{"id":"shape","answer":"refactor-first","p":0.9}]
113
+ """
114
+ And step "seam.note" writes ".gtd/REFACTOR-NOTE.md" with:
115
+ """
116
+ Seam: a shared `op` in src/calc.ts.
117
+ """
118
+ And step "refactor.deriving" runs its script
119
+ And step "refactor.building" writes ".gtd/HANDOFF.md" with:
120
+ """
121
+ Started on op; nothing changed yet.
122
+ """
123
+ And step "refactor.health.check" changes nothing
124
+ When the process is driven
125
+ Then the process rests at "refactor.continue.building"
126
+ And the resting step's content contains "Still open: src/calc.ts."
127
+ And the resting step's content contains "Started on op; nothing changed yet."
128
+ And the commit subjects end with:
129
+ """
130
+ gtd(agent): refactor.building → refactor.health.check
131
+ gtd(check): refactor.health.check → refactor.continue.building
132
+ """
133
+
134
+ Scenario: a refactor continuation that leaves the note in place stopped again
135
+ Given step "seam.naive" writes "tests/calc.test.ts" with:
136
+ """
137
+ it("[AC-1] subtracts", () => {})
138
+ it("[AC-2] multiplies", () => {})
139
+ """
140
+ And step "seam.naive" writes "src/calc.ts" with:
141
+ """
142
+ export const add = (a: number, b: number) => a + b
143
+ export const subtract = (a: number, b: number) => a - b
144
+ """
145
+ And judge step "seam.judge" answers:
146
+ """
147
+ [{"id":"shape","answer":"refactor-first","p":0.9}]
148
+ """
149
+ And step "seam.note" writes ".gtd/REFACTOR-NOTE.md" with:
150
+ """
151
+ Seam: a shared `op` in src/calc.ts.
152
+ """
153
+ And step "refactor.deriving" runs its script
154
+ And step "refactor.building" writes "src/calc.ts" with:
155
+ """
156
+ export const op = (a: number, b: number, sign: number) => a + sign * b
157
+ export const add = (a: number, b: number) => a + b
158
+ """
159
+ And step "refactor.building" writes ".gtd/HANDOFF.md" with:
160
+ """
161
+ op is in; routing add through it was next.
162
+ """
163
+ And step "refactor.health.check" changes nothing
164
+ And step "refactor.continue.building" writes "src/calc.ts" with:
165
+ """
166
+ export const op = (a: number, b: number, sign: number) => a + sign * b
167
+ export const add = (a: number, b: number) => op(a, b, 1)
168
+ """
169
+ When the process is driven
170
+ Then the process rests at "refactor.continue.building"
171
+ And the commit subjects end with:
172
+ """
173
+ gtd(agent): refactor.building → refactor.health.check
174
+ gtd(check): refactor.health.check → refactor.continue.building
175
+ gtd(agent): refactor.continue.building → refactor.health.check
176
+ gtd(check): refactor.health.check → refactor.continue.building
177
+ gtd(agent): refactor.continue.building
178
+ """
179
+
180
+ Scenario: once a naive pass has continued, its retry runs in a fresh conversation, not as a continuation
181
+ Given step "seam.naive" writes "tests/calc.test.ts" with:
182
+ """
183
+ it("[AC-1] subtracts", () => {})
184
+ """
185
+ And step "seam.naive" writes ".gtd/HANDOFF.md" with:
186
+ """
187
+ Multiply was next.
188
+ """
189
+ And step "seam.continue.naive" writes "tests/calc.test.ts" with:
190
+ """
191
+ it("[AC-1] subtracts", () => {})
192
+ it("[AC-2] multiplies", () => {})
193
+ """
194
+ And step "seam.continue.naive" writes "src/calc.ts" with:
195
+ """
196
+ export const add = (a: number, b: number) => a - b
197
+ """
198
+ And judge step "seam.judge" answers:
199
+ """
200
+ [{"id":"shape","answer":"reject","p":0.9}]
201
+ """
202
+ When the process is driven
203
+ Then the process rests at "seam.fresh.naive"
204
+ And the resting step's content contains "rejected"
205
+ And the output of gtd "next --json" contains "\"resume\":false"
@@ -189,3 +189,14 @@ export const skipDesign = (stated: StatedCriteria | undefined): Promise<boolean>
189
189
  })
190
190
  return true
191
191
  })
192
+
193
+ /** After a gate that invites narrowing the requirements: an edit must leave valid criteria. */
194
+ export const requireCriteriaIfEdited = (): void => {
195
+ const change = changes().get(REQUIREMENTS)
196
+ if (change === undefined) return
197
+ if (change.status === "deleted")
198
+ refuse(
199
+ `gtd land: criteria-missing: ${REQUIREMENTS} must stay — narrow its criteria, never delete it`,
200
+ )
201
+ requireCriteria(REQUIREMENTS)
202
+ }
@@ -15,8 +15,9 @@ import {
15
15
  } from "../../flows/index.js"
16
16
  import { lensEntriesOf } from "../../workflow-kit/index.js"
17
17
  import { markFenced } from "../shared/fences.js"
18
+ import { continuing, phase, type Phase } from "../shared/continuation.js"
18
19
  import { healthy, type Guard } from "../shared/health.js"
19
- import { coder, FEEDBACK, REFACTOR } from "../shared/steps.js"
20
+ import { coder, FEEDBACK, HANDOFF, REFACTOR } from "../shared/steps.js"
20
21
  import * as t from "../shared/text.js"
21
22
  import { defaults, testFileGlobs } from "../shared/vars.js"
22
23
  import { buildTail, reUnwind, type FeedbackTarget } from "./review.js"
@@ -263,7 +264,12 @@ const guardOf =
263
264
  }
264
265
 
265
266
  /** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
266
- const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<void> => {
267
+ const refactorLap = async (
268
+ p: Plan,
269
+ guard: Guard,
270
+ refactor: Phase,
271
+ feedback?: string,
272
+ ): Promise<void> => {
267
273
  const { pattern, strays, reference, note } = p
268
274
  await run(
269
275
  "deriving",
@@ -276,32 +282,59 @@ const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<vo
276
282
  ]),
277
283
  { label: "Deriving the refactor package", file: REFACTOR_PACKAGE },
278
284
  )
279
- await t.agentWithSkills(
280
- "building",
281
- t.refactorBuildingPrompt({
282
- pkg: REFACTOR_PACKAGE,
283
- strays,
284
- pattern,
285
- reference,
286
- feedback: feedback !== undefined,
287
- note: note !== undefined,
288
- }),
289
- { label: "Refactoring", model: coder(), system: t.builderSystem() },
290
- )
291
- guard()
292
- await healthy(
293
- async () => {
294
- await t.agentWithSkills("fix.suite.fixing", t.refactorFixPrompt(strays, pattern), {
295
- label: "Fixing the check",
296
- file: FEEDBACK,
297
- model: coder(),
298
- system: t.builderSystem(),
299
- })
300
- guard()
301
- },
302
- { describeGuard: guard, rules: t.refactorRules(strays, pattern) },
303
- )
304
- await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" })
285
+ const reached = new Set<string>()
286
+ // Each snapshot is greened before its continuation: every sub-step keeps the old suite green.
287
+ await continuing(refactor, async (from, fresh) => {
288
+ await fresh(() =>
289
+ t.agentWithSkills(
290
+ "building",
291
+ t.refactorBuildingPrompt({
292
+ pkg: REFACTOR_PACKAGE,
293
+ strays,
294
+ pattern,
295
+ reference,
296
+ feedback: feedback !== undefined,
297
+ note: note !== undefined,
298
+ from,
299
+ }),
300
+ {
301
+ label: from === undefined ? "Refactoring" : "Continuing the refactor",
302
+ model: coder(),
303
+ system: t.builderSystem(),
304
+ },
305
+ ),
306
+ )
307
+ // Committed by the snapshot, so a turn stopped again may leave it as it was: present means stopped.
308
+ const left = read(HANDOFF)
309
+ guard()
310
+ const snapshot = head()
311
+ // The building turns' own changes only: a fix turn reaching a path is not the refactor reaching it.
312
+ for (const c of changes()) reached.add(c.path)
313
+ await healthy(
314
+ async () => {
315
+ await t.agentWithSkills("fix.suite.fixing", t.refactorFixPrompt(strays, pattern), {
316
+ label: "Fixing the check",
317
+ file: FEEDBACK,
318
+ model: coder(),
319
+ system: t.builderSystem(),
320
+ })
321
+ guard()
322
+ },
323
+ { describeGuard: guard, rules: t.refactorRules(strays, pattern) },
324
+ )
325
+ if (left === undefined) return { result: undefined }
326
+ return {
327
+ result: undefined,
328
+ handoff: {
329
+ snapshot,
330
+ note: left.trim(),
331
+ open: strays.filter((path) => !reached.has(path)),
332
+ },
333
+ }
334
+ })
335
+ await run("closing", removeScript([REFACTOR_PACKAGE, HANDOFF]), {
336
+ label: "Closing out the refactor",
337
+ })
305
338
  }
306
339
 
307
340
  const strayAccess = (strays: readonly string[]) => ({
@@ -315,7 +348,7 @@ export const refactorFirst = async (
315
348
  const p = plan(shape, options)
316
349
  if (p === undefined) return false
317
350
  return scope({ name: "refactor", access: strayAccess(p.strays) }, async () => {
318
- await refactorLap(p, guardOf(p, head()))
351
+ await refactorLap(p, guardOf(p, head()), phase("refactor"))
319
352
  return true
320
353
  })
321
354
  }
@@ -342,9 +375,10 @@ export const reviewedRefactor = async (
342
375
  "gtd land: refactor-feedback: the review feedback changed files but wrote no .gtd/REFACTOR.md — a refactor cannot act on it; revert the change, or `gtd abandon` and restart",
343
376
  dropped: t.refactorDroppedRefusal,
344
377
  }
378
+ const refactor = phase("refactor")
345
379
  let collected: string | undefined
346
380
  for (;;) {
347
- await refactorLap(p, guard, collected)
381
+ await refactorLap(p, guard, refactor, collected)
348
382
  // A person's edit at an escalation rest is never refused, as in the lap.
349
383
  const outcome = await buildTail(entry, {
350
384
  green: true,
@@ -18,13 +18,15 @@ import {
18
18
  run,
19
19
  scope,
20
20
  slice as sliced,
21
+ start,
21
22
  vars,
22
23
  wrote,
23
24
  type Change,
24
25
  type DiffShapeResult,
25
26
  type KeptTip,
26
27
  } from "../../flows/index.js"
27
- import { coder, FEEDBACK, REFACTOR_NOTE, REQUIREMENTS, SLICES } from "../shared/steps.js"
28
+ import { continuing, phase, type Handoff, type Phase } from "../shared/continuation.js"
29
+ import { coder, FEEDBACK, HANDOFF, REFACTOR_NOTE, REQUIREMENTS, SLICES } from "../shared/steps.js"
28
30
  import * as t from "../shared/text.js"
29
31
  import { defaults } from "../shared/vars.js"
30
32
  import { green, requireCoverage } from "./coverage.js"
@@ -32,6 +34,7 @@ import {
32
34
  criterionIds,
33
35
  requireCriteria,
34
36
  requireCriteriaFrozen,
37
+ requireCriteriaIfEdited,
35
38
  sliceIds,
36
39
  type CriteriaSlice,
37
40
  } from "./criteria.js"
@@ -60,14 +63,47 @@ export const judgeSeam = (tip: KeptTip): Promise<DiffShapeResult> =>
60
63
  exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path),
61
64
  })
62
65
 
63
- export const naivePass = (retry?: t.NaiveRetry, slice?: CriteriaSlice): Promise<void> =>
64
- t.agentWithSkills("naive", t.naivePassPrompt(retry, sliceIds(slice)), {
65
- label: retry === undefined ? "Writing the naive pass" : "Rewriting the naive pass",
66
+ export const naivePass = (
67
+ retry?: t.NaiveRetry,
68
+ slice?: CriteriaSlice,
69
+ from?: Handoff,
70
+ ): Promise<void> =>
71
+ t.agentWithSkills("naive", t.naivePassPrompt(retry, sliceIds(slice), from), {
72
+ label:
73
+ from !== undefined
74
+ ? "Continuing the naive pass"
75
+ : retry === undefined
76
+ ? "Writing the naive pass"
77
+ : "Rewriting the naive pass",
66
78
  keep: true,
67
79
  model: coder(),
68
80
  system: t.builderSystem(),
69
81
  })
70
82
 
83
+ const handoffOf = (tip: KeptTip, slice: CriteriaSlice | undefined): Handoff | undefined => {
84
+ const note = tip.changes.find((c) => c.path === HANDOFF && c.after !== undefined)
85
+ if (note === undefined) return undefined
86
+ return {
87
+ snapshot: tip.hash,
88
+ note: note.after!.trim(),
89
+ open: unnamed(idsOf(REQUIREMENTS, slice), acceptanceTests(tip.changes)),
90
+ }
91
+ }
92
+
93
+ /** A naive pass, continued until a pass ends without a handoff note: the snapshot is its kept tip. */
94
+ const naiveContinued = (
95
+ retry: t.NaiveRetry | undefined,
96
+ slice: CriteriaSlice | undefined,
97
+ naive: Phase,
98
+ ) =>
99
+ continuing(naive, async (from, fresh) => {
100
+ const tip = await fresh(async () => {
101
+ await naivePass(retry, slice, from)
102
+ return kept("naive")!
103
+ })
104
+ return { result: tip, handoff: handoffOf(tip, slice) }
105
+ })
106
+
71
107
  // Only a string an import, `require` or `mock` call takes: a URL route or path-like data is not one.
72
108
  const IMPORTED = /(?:\bfrom|\bimport|\brequire\s*\(|\bmock\s*\()\s*\(?\s*(["'`])([^"'`\n]+)\1/g
73
109
  const EXTENSION = /\.[\w-]+$/
@@ -258,6 +294,56 @@ const applied = async (tip: string): Promise<boolean> => {
258
294
  return !wrote(FEEDBACK) && codeChanges().length > 0
259
295
  }
260
296
 
297
+ /** `carried` limited to the criteria `.gtd/REQUIREMENTS.md` lists now; a file listing none narrows nothing. */
298
+ const settled = (
299
+ carried: readonly string[] | undefined,
300
+ slice: CriteriaSlice | undefined,
301
+ ): readonly string[] => {
302
+ const listed = idsOf(REQUIREMENTS, slice)
303
+ if (carried === undefined) return listed
304
+ return listed.length === 0 ? carried : carried.filter((id) => listed.includes(id))
305
+ }
306
+
307
+ // A snapshot lands on the branch as it is; the health loop greens the whole build after.
308
+ // Open IDs count every test line since the process start, so an amend lap's earlier tests close theirs.
309
+ const build = (options: ImplementOptions): Promise<void> =>
310
+ continuing(phase("build", requireCriteriaIfEdited), async (from, fresh) => {
311
+ await fresh(() =>
312
+ t.agentWithSkills(
313
+ "building",
314
+ t.implementBuildingPrompt(
315
+ options.reference,
316
+ options.tier,
317
+ options.amend,
318
+ options.afterRefactor,
319
+ flagModeOn(),
320
+ options.rebuild,
321
+ sliceIds(options.slice),
322
+ from,
323
+ ),
324
+ {
325
+ label: from === undefined ? "Implementing" : "Continuing the implementation",
326
+ model: coder(),
327
+ system: t.builderSystem(),
328
+ ...(options.afterRefactor === true ? { allowEmpty: true } : {}),
329
+ },
330
+ ),
331
+ )
332
+ requireCriteriaFrozen(REQUIREMENTS)
333
+ // Committed by the snapshot, so a turn stopped again may leave it as it was: present means stopped.
334
+ const note = read(HANDOFF)
335
+ if (note === undefined) return { result: undefined }
336
+ const ids = settled(options.rebuild?.acceptance?.ids, options.slice)
337
+ return {
338
+ result: undefined,
339
+ handoff: {
340
+ snapshot: head(),
341
+ note: note.trim(),
342
+ open: unnamed(ids, acceptanceTests(changesSince(start()))),
343
+ },
344
+ }
345
+ })
346
+
261
347
  export function implement(options?: ImplementOptions): Promise<void>
262
348
  /** @deprecated Pass `implement({ reference, tier, amend })`. */
263
349
  export function implement(
@@ -276,27 +362,11 @@ export function implement(
276
362
  const since = head()
277
363
  const acceptance = options.rebuild?.acceptance
278
364
  const built = options.naive === undefined || !(await applied(options.naive))
279
- if (built)
280
- await t.agentWithSkills(
281
- "building",
282
- t.implementBuildingPrompt(
283
- options.reference,
284
- options.tier,
285
- options.amend,
286
- options.afterRefactor,
287
- flagModeOn(),
288
- options.rebuild,
289
- sliceIds(options.slice),
290
- ),
291
- {
292
- label: "Implementing",
293
- model: coder(),
294
- system: t.builderSystem(),
295
- ...(options.afterRefactor === true ? { allowEmpty: true } : {}),
296
- },
297
- )
298
- requireCriteriaFrozen(REQUIREMENTS)
299
- if (built && acceptance !== undefined) requireAcceptance(acceptance, since)
365
+ if (built) await build(options)
366
+ else requireCriteriaFrozen(REQUIREMENTS)
367
+ // Only the criteria still listed: a person may narrow them at `implement.oversized`.
368
+ if (built && acceptance !== undefined)
369
+ requireAcceptance({ ...acceptance, ids: settled(acceptance.ids, options.slice) }, since)
300
370
  // Fix turns are held to the carried tests too: deleting a red one must not buy a green suite.
301
371
  await green(
302
372
  built && acceptance !== undefined ? () => requireAcceptanceKept(acceptance) : undefined,
@@ -311,9 +381,11 @@ export const closeOut = (): Promise<void> => scope("implement", () => closing(fa
311
381
 
312
382
  /** A slice with `more` after it keeps the criteria and the slicing for the next one. */
313
383
  const closing = (more: boolean): Promise<void> =>
314
- run("closing", removeScript(more ? [REFACTOR_NOTE] : [REQUIREMENTS, REFACTOR_NOTE, SLICES]), {
315
- label: "Closing out the implementation",
316
- })
384
+ run(
385
+ "closing",
386
+ removeScript(more ? [REFACTOR_NOTE, HANDOFF] : [REQUIREMENTS, REFACTOR_NOTE, SLICES, HANDOFF]),
387
+ { label: "Closing out the implementation" },
388
+ )
317
389
 
318
390
  interface Decision {
319
391
  readonly reference: string
@@ -392,10 +464,10 @@ const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision |
392
464
  const decide = (slice: CriteriaSlice | undefined): Promise<Decision> =>
393
465
  scope("seam", async () => {
394
466
  const vetting: Vetting = { again: budget(), waived: false }
467
+ const naive = phase("naive pass", requireCriteriaIfEdited)
395
468
  let retry: t.NaiveRetry | undefined
396
469
  for (;;) {
397
- await naivePass(retry, slice)
398
- const tip = kept("naive")!
470
+ const tip = await naiveContinued(retry, slice, naive)
399
471
  const outcome =
400
472
  (await vetted(tip, vetting, slice)) ??
401
473
  (flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash, tip })
@@ -0,0 +1,92 @@
1
+ import { human, refuse, scope, vars } from "../../flows/index.js"
2
+ import * as t from "./text.js"
3
+ import type { Handoff } from "./text.js"
4
+ import { defaults } from "./vars.js"
5
+
6
+ export type { Handoff }
7
+
8
+ export interface Turned<T> {
9
+ readonly result: T
10
+ /** Set when the turn wrote a handoff note, i.e. stopped at its context limit. */
11
+ readonly handoff?: Handoff | undefined
12
+ }
13
+
14
+ /** Runs a turn's agent step in a conversation of its own, or in the caller's. */
15
+ export type Fresh = <T>(fn: () => Promise<T>) => Promise<T>
16
+
17
+ /**
18
+ * One phase's continuations, shared by every `continuing()` call in it (a naive
19
+ * retry, a refactor lap) so the caps hold per phase. `spent`: once a turn hit
20
+ * its context limit, resuming that conversation would only hit it again.
21
+ */
22
+ export interface Phase {
23
+ readonly name: string
24
+ spent: boolean
25
+ count: number
26
+ stalled: number
27
+ /** Run after a landing at `oversized`, where a person may narrow the requirements; absent, the gate does not suggest it. */
28
+ readonly steered?: (() => void) | undefined
29
+ }
30
+
31
+ export const phase = (name: string, steered?: () => void): Phase => ({
32
+ name,
33
+ spent: false,
34
+ count: 0,
35
+ stalled: 0,
36
+ steered,
37
+ })
38
+
39
+ const own: Fresh = (fn) => fn()
40
+ // Every `scope()` call is a new conversation; the names keep continuations
41
+ // apart in history from later turns of a phase that merely start afresh.
42
+ const continued: Fresh = (fn) => scope("continue", fn)
43
+ const afresh: Fresh = (fn) => scope("fresh", fn)
44
+
45
+ const setting = (name: "continuations" | "continuationStall", least: number): number => {
46
+ const raw = vars[name] ?? defaults[name]!
47
+ const n = Number(raw)
48
+ if (raw.trim() === "" || !Number.isInteger(n) || n < least)
49
+ refuse(`gtd: ${name} must be a whole number of at least ${least}, got ${JSON.stringify(raw)}`)
50
+ return n
51
+ }
52
+
53
+ /**
54
+ * Run `turn` until one ends without a handoff note: each stopped turn is picked
55
+ * up by a continuation in a fresh conversation. Past `continuations` of them in
56
+ * the phase, or after `continuationStall` in a row that close nothing still
57
+ * open, the phase rests at `oversized` for a person; landing there grants a new round.
58
+ */
59
+ export const continuing = async <T>(
60
+ phase: Phase,
61
+ turn: (from: Handoff | undefined, fresh: Fresh) => Promise<Turned<T>>,
62
+ ): Promise<T> => {
63
+ const cap = setting("continuations", 0)
64
+ const stall = setting("continuationStall", 1)
65
+ let last = await turn(undefined, phase.spent ? afresh : own)
66
+ while (last.handoff !== undefined) {
67
+ phase.spent = true
68
+ const from = last.handoff
69
+ if (phase.count >= cap || phase.stalled >= stall) {
70
+ await human("oversized", {
71
+ message: t.oversizedMessage(
72
+ phase.name,
73
+ from,
74
+ phase.count >= cap ? { cap } : { stall },
75
+ phase.steered !== undefined,
76
+ ),
77
+ label: "Too big for its context",
78
+ acceptClean: true,
79
+ })
80
+ phase.steered?.()
81
+ phase.count = 0
82
+ phase.stalled = 0
83
+ }
84
+ last = await turn(from, continued)
85
+ phase.count++
86
+ // Nothing countable left open is no stall: only the cap stops such a phase.
87
+ const closed =
88
+ from.open.length === 0 || from.open.some((item) => !(last.handoff?.open ?? []).includes(item))
89
+ phase.stalled = closed ? 0 : phase.stalled + 1
90
+ }
91
+ return last.result
92
+ }
@@ -24,6 +24,8 @@ export const ShippedOptions = Schema.Struct({
24
24
  sliceMaxCriteria: setting,
25
25
  qualityReviews: setting,
26
26
  refactorQualityReviews: setting,
27
+ continuations: setting,
28
+ continuationStall: setting,
27
29
  testCommand: setting,
28
30
  plannerModel: setting,
29
31
  coderModel: setting,
@@ -15,6 +15,7 @@ export const REFACTOR = ".gtd/REFACTOR.md"
15
15
  export const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md"
16
16
  export const QUALITY = ".gtd/QUALITY.md"
17
17
  export const SLICES = ".gtd/SLICES.md"
18
+ export const HANDOFF = ".gtd/HANDOFF.md"
18
19
 
19
20
  export const planner = (): string => env.plannerModel ?? ""
20
21
  export const coder = (): string => env.coderModel ?? ""
@@ -976,7 +976,9 @@ describe("the seam phase's texts", () => {
976
976
  it("has a red retry pick up the kept attempt instead of starting over", () => {
977
977
  const prompt = renderText(() => naivePassPrompt({ red: "abc123" }))
978
978
  expect(prompt).toContain("left the suite red")
979
- expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
979
+ expect(prompt).toContain(
980
+ "git diff --binary --no-color --no-ext-diff --no-textconv abc123^ abc123 | git apply",
981
+ )
980
982
  expect(prompt).toContain(".gtd/FEEDBACK.md")
981
983
  expect(prompt).not.toContain("rejected")
982
984
  })
@@ -1042,7 +1044,9 @@ describe("the seam phase's texts", () => {
1042
1044
  }
1043
1045
  const prompt = renderText(() => naivePassPrompt(fault))
1044
1046
  expect(prompt).toContain("names AC-2")
1045
- expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
1047
+ expect(prompt).toContain(
1048
+ "git diff --binary --no-color --no-ext-diff --no-textconv abc123^ abc123 | git apply",
1049
+ )
1046
1050
  const message = renderText(() => seamSteerMessage(fault))
1047
1051
  expect(message).toContain("names AC-2")
1048
1052
  expect(message).toContain("`seam.check` runs its suite")