@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.
Files changed (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +45 -29
  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-C8Fm3s72.mjs → reserved-zAfoJtR7.mjs} +62 -24
  7. package/dist/chunks/{tail-oaYewBN_.mjs → tail-BRC4DGdV.mjs} +1 -1
  8. package/dist/chunks/{wire-D3Rc5r22.mjs → wire-CSsa3Wxd.mjs} +117 -2
  9. package/dist/flows.mjs +15 -10
  10. package/dist/gtd.bundle.mjs +511 -64
  11. package/dist/testkit.mjs +158 -26
  12. package/dist/wire.mjs +2 -2
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +138 -38
  15. package/dist/workflows/fix.mjs +2 -2
  16. package/dist/workflows/review.mjs +2 -2
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +208 -66
  19. package/package.json +1 -1
  20. package/skills/authoring/SKILL.md +3 -3
  21. package/src/cli/Cli.test.ts +10 -3
  22. package/src/cli/Cli.ts +76 -1
  23. package/src/cli/Program.test.ts +1 -0
  24. package/src/cli/Program.ts +28 -0
  25. package/src/cli/ToolCommands.ts +3 -0
  26. package/src/edge/Edge.ts +11 -0
  27. package/src/flows/runtime.ts +11 -0
  28. package/src/flows/scripts.ts +31 -9
  29. package/src/replay/Replay.ts +51 -2
  30. package/src/testkit/FlowScripts.test.ts +58 -0
  31. package/src/testkit/FlowScripts.ts +61 -24
  32. package/src/testkit/Layers.ts +1 -0
  33. package/src/wire/BeatDocument.ts +12 -2
  34. package/src/wire/BeatStatus.ts +3 -0
  35. package/src/wire/ContextLimit.fixture.ts +30 -0
  36. package/src/wire/ContextLimit.test.ts +96 -0
  37. package/src/wire/ContextLimit.ts +169 -0
  38. package/src/wire/index.ts +1 -0
  39. package/src/wire/types.ts +7 -0
  40. package/src/workflow/Workflow.ts +9 -1
  41. package/src/workflows/feature/acceptance-criteria.feature +5 -1
  42. package/src/workflows/feature/context-limit.feature +37 -0
  43. package/src/workflows/feature/coverage.feature +6 -3
  44. package/src/workflows/feature/coverage.ts +8 -2
  45. package/src/workflows/feature/fast-path.feature +11 -19
  46. package/src/workflows/feature/feature.test.ts +24 -4
  47. package/src/workflows/feature/feature.ts +14 -10
  48. package/src/workflows/feature/flag-mode.feature +17 -9
  49. package/src/workflows/feature/lens-models.feature +7 -1
  50. package/src/workflows/feature/naive-acceptance.feature +2 -2
  51. package/src/workflows/feature/one-step-land.feature +10 -12
  52. package/src/workflows/feature/quality-unresolved.feature +7 -1
  53. package/src/workflows/feature/refactor-absorbs.feature +12 -5
  54. package/src/workflows/feature/refactor-first.feature +11 -2
  55. package/src/workflows/feature/refactor-lens-models.feature +4 -0
  56. package/src/workflows/feature/refactor-rebuild.feature +269 -0
  57. package/src/workflows/feature/refactor-review.feature +45 -1
  58. package/src/workflows/feature/refactor.ts +9 -3
  59. package/src/workflows/feature/risk-tier.feature +12 -4
  60. package/src/workflows/feature/seam-rejection.feature +4 -9
  61. package/src/workflows/feature/seam.feature +29 -26
  62. package/src/workflows/feature/seam.test.ts +117 -6
  63. package/src/workflows/feature/seam.ts +158 -39
  64. package/src/workflows/review/review-door.feature +12 -4
  65. package/src/workflows/shared/options.test.ts +11 -2
  66. package/src/workflows/shared/options.ts +18 -4
  67. package/src/workflows/shared/steps.ts +1 -0
  68. package/src/workflows/shared/text.test.ts +8 -0
  69. 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 ids = (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "")
108
- const tests = tip.changes.filter(
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
- await t.agentWithSkills(
190
- "building",
191
- t.implementBuildingPrompt(
192
- options.reference,
193
- options.tier,
194
- options.amend,
195
- options.afterRefactor,
196
- flagModeOn(),
197
- ),
198
- {
199
- label: "Implementing",
200
- model: coder(),
201
- system: t.builderSystem(),
202
- ...(options.afterRefactor === true ? { allowEmpty: true } : {}),
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
- 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
+ )
207
287
  await requireCoverage()
208
- 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
+ })
209
291
  })
210
292
  }
211
293
 
212
294
  interface Decision {
213
295
  readonly reference: string
214
- /** Present only on `refactor-first`: the refactor runs before the implementation. */
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 = (await vetted(tip, vetting)) ?? (await ruled(tip, vetting.again))
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 "implement.building" writes "src/calc.ts" with:
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 → implement.building
60
- gtd(agent): implement.building → implement.health.check
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~9"
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
- 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 ?? ""
@@ -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
- feedback
361
- ? `- The package carries a \`## Review feedback\` section: a human's review of the
362
- 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
363
368
  `
364
369
  : ""
365
- }${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)}
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; a later step judges
1201
- the shape of your diff and a reviewer makes it fit
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
- export const seamSteerMessage = (retry: NaiveRetry): string =>
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's approach:
1325
- the implementation is built against it as reference, with no seam gate
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