@pmelab/gtd 32.1.0 → 34.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 (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +66 -53
  3. package/dist/chunks/{reserved-2fV0mj14.mjs → reserved-BcJgt5_R.mjs} +79 -12
  4. package/dist/chunks/{tail-BynhCfuv.mjs → tail-CM_fa93v.mjs} +1 -1
  5. package/dist/testkit.mjs +2 -2
  6. package/dist/workflows/cleanup.mjs +2 -2
  7. package/dist/workflows/feature.mjs +174 -40
  8. package/dist/workflows/fix.mjs +2 -2
  9. package/dist/workflows/review.mjs +2 -2
  10. package/dist/workflows/rollout.mjs +1 -1
  11. package/dist/workflows.mjs +249 -48
  12. package/package.json +1 -1
  13. package/skills/authoring/SKILL.md +2 -2
  14. package/src/testkit/steps.ts +4 -0
  15. package/src/workflows/feature/acceptance-criteria.feature +8 -0
  16. package/src/workflows/feature/fast-path.feature +11 -35
  17. package/src/workflows/feature/feature.test.ts +9 -100
  18. package/src/workflows/feature/feature.ts +29 -16
  19. package/src/workflows/feature/flag-mode.feature +2 -91
  20. package/src/workflows/feature/naive-acceptance.feature +140 -0
  21. package/src/workflows/feature/one-step-land.feature +11 -34
  22. package/src/workflows/feature/quality-unresolved.feature +96 -0
  23. package/src/workflows/feature/refactor-absorbs.feature +17 -25
  24. package/src/workflows/feature/refactor-first.feature +11 -35
  25. package/src/workflows/feature/refactor-lens-models.feature +6 -1
  26. package/src/workflows/feature/refactor-review.feature +67 -360
  27. package/src/workflows/feature/refactor.ts +4 -3
  28. package/src/workflows/feature/risk-tier.feature +10 -0
  29. package/src/workflows/feature/seam-rejection.feature +20 -37
  30. package/src/workflows/feature/seam.feature +66 -113
  31. package/src/workflows/feature/seam.test.ts +173 -3
  32. package/src/workflows/feature/seam.ts +196 -31
  33. package/src/workflows/review/review-door.feature +108 -0
  34. package/src/workflows/shared/tail.ts +2 -2
  35. package/src/workflows/shared/text.test.ts +40 -0
  36. package/src/workflows/shared/text.ts +110 -16
@@ -1,14 +1,20 @@
1
+ import { posix } from "node:path"
1
2
  import {
2
3
  changes,
4
+ check,
5
+ env,
3
6
  head,
4
7
  human,
5
8
  judgeDiffShape,
6
9
  kept,
10
+ quote,
11
+ read,
7
12
  removeScript,
8
13
  run,
9
14
  scope,
10
15
  slice,
11
16
  vars,
17
+ type Change,
12
18
  type DiffShapeResult,
13
19
  type KeptTip,
14
20
  } from "../../flows/index.js"
@@ -16,12 +22,19 @@ import { healthy } from "../shared/health.js"
16
22
  import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
17
23
  import * as t from "../shared/text.js"
18
24
  import { defaults } from "../shared/vars.js"
19
- import { requireCriteria, requireCriteriaFrozen } from "./criteria.js"
20
- import { refactorStrays, reviewedRefactor } from "./refactor.js"
25
+ import { criteriaOf, requireCriteria, requireCriteriaFrozen } from "./criteria.js"
26
+ import {
27
+ COMMENT,
28
+ holdsCallSites,
29
+ isTestFile,
30
+ refactorStrays,
31
+ reviewedRefactor,
32
+ } from "./refactor.js"
33
+ import { isCode } from "./review.js"
21
34
  import { flagModeOn } from "../shared/options.js"
22
35
  import type { RiskTier } from "./tier.js"
23
36
 
24
- /** Naive-pass retries after a `reject`; the next `reject` rests at `seam.steer`. */
37
+ /** Naive-pass retries after a `reject` or a red suite; the next one rests at `seam.steer`. */
25
38
  const NAIVE_RETRIES = 2
26
39
 
27
40
  /** A change to an existing file outside the allowlist is a stray even when the task needs it: the judge decides. */
@@ -35,14 +48,121 @@ export const judgeSeam = (tip: KeptTip): Promise<DiffShapeResult> =>
35
48
  exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path),
36
49
  })
37
50
 
38
- export const naivePass = (rejection?: t.SeamRejection): Promise<void> =>
39
- t.agentWithSkills("naive", t.naivePassPrompt(rejection), {
40
- label: rejection === undefined ? "Writing the naive pass" : "Rewriting the naive pass",
51
+ export const naivePass = (retry?: t.NaiveRetry): Promise<void> =>
52
+ t.agentWithSkills("naive", t.naivePassPrompt(retry), {
53
+ label: retry === undefined ? "Writing the naive pass" : "Rewriting the naive pass",
41
54
  keep: true,
42
55
  model: coder(),
43
56
  system: t.builderSystem(),
44
57
  })
45
58
 
59
+ // Only a string an import, `require` or `mock` call takes: a URL route or path-like data is not one.
60
+ const IMPORTED = /(?:\bfrom|\bimport|\brequire\s*\(|\bmock\s*\()\s*\(?\s*(["'`])([^"'`\n]+)\1/g
61
+ const EXTENSION = /\.[\w-]+$/
62
+ const ALIAS = /^(?:[@~]\/|#)/
63
+
64
+ /** The specifiers an added `path` answers to: itself, without its extension, and its directory for an `index`. */
65
+ const namesOf = (path: string): string[] => {
66
+ const bare = path.replace(EXTENSION, "")
67
+ return posix.basename(bare) === "index" ? [path, bare, posix.dirname(bare)] : [path, bare]
68
+ }
69
+
70
+ /**
71
+ * The repository paths `test` imports, each with and without an extension: a
72
+ * relative specifier resolved from its directory, an aliased one (`@/`, `~/`,
73
+ * `#`) as a path tail, anything else as written. Comments are skipped; a
74
+ * computed import goes unseen.
75
+ */
76
+ const importsOf = (test: Change): { readonly path: string; readonly tail: boolean }[] =>
77
+ [...(test.after ?? "").replace(COMMENT, "").matchAll(IMPORTED)].flatMap(([, , spec]) => {
78
+ const relative = /^\.\.?(\/|$)/.test(spec!)
79
+ const tail = ALIAS.test(spec!)
80
+ const path = relative ? posix.join(posix.dirname(test.path), spec!) : spec!.replace(ALIAS, "")
81
+ return [path, path.replace(EXTENSION, "")].map((p) => ({ path: p, tail }))
82
+ })
83
+
84
+ const reaches = (imports: ReturnType<typeof importsOf>, added: string): boolean =>
85
+ namesOf(added).some((name) =>
86
+ imports.some(({ path, tail }) => name === path || (tail && name.endsWith(`/${path}`))),
87
+ )
88
+
89
+ const named = (id: string, text: string): boolean =>
90
+ new RegExp(`(?<![\\w-])${id}(?!\\d)`).test(text)
91
+
92
+ /** The lines `change` added: an ID an earlier change left in the file does not count. */
93
+ const addedLines = (change: Change): string => {
94
+ const before = new Set((change.before ?? "").split("\n"))
95
+ return change
96
+ .after!.split("\n")
97
+ .filter((line) => !before.has(line))
98
+ .join("\n")
99
+ }
100
+
101
+ /**
102
+ * Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
103
+ * test line of the pass names, or a test importing a file the pass added —
104
+ * naive structure a reset and refactor replaces, which the tests must survive.
105
+ */
106
+ 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)))
113
+ if (untested.length > 0)
114
+ 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
+ const added = tip.changes
116
+ .filter((c) => c.status === "added" && isCode(c.path) && !isTestFile(c.path))
117
+ .map((c) => c.path)
118
+ const reaching = tests
119
+ .filter((test) => holdsCallSites(test.path))
120
+ .flatMap((test) => {
121
+ const imports = importsOf(test)
122
+ return added
123
+ .filter((path) => reaches(imports, path))
124
+ .map((path) => ` - ${test.path} → ${path}`)
125
+ })
126
+ if (reaching.length > 0)
127
+ return `acceptance-internals: these tests import files the naive pass added — test through code that existed before it (a public entry point, an existing module's exports):\n${reaching.join("\n")}`
128
+ return undefined
129
+ }
130
+
131
+ const restore = (path: string): string =>
132
+ `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
+
134
+ /**
135
+ * Run the suite with `tip`'s diff applied, then take it out again; resolves
136
+ * `true` when green. The pass's paths go back to HEAD before (an interrupted
137
+ * run left them applied) and after; afterwards so does anything else the run
138
+ * changed, judged against what was already dirty, which is left alone.
139
+ */
140
+ const naiveGreen = (tip: KeptTip): Promise<boolean> => {
141
+ const paths = tip.changes.map((c) => restore(c.path))
142
+ return check(
143
+ "check",
144
+ [
145
+ ...paths,
146
+ "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`,
148
+ " echo 'gtd: the kept naive pass no longer applies to HEAD; no test ran'",
149
+ " exit 1",
150
+ "fi",
151
+ "(",
152
+ env.testCommand ?? "",
153
+ ")",
154
+ "code=$?",
155
+ ...paths,
156
+ "git -c core.quotePath=false ls-files -m -d -o --exclude-standard | while IFS= read -r path; do",
157
+ ' printf \'%s\\n\' "$dirty" | grep -qxF -- "$path" && continue',
158
+ ' if git cat-file -e "HEAD:$path" 2>/dev/null; then git checkout -q HEAD -- "$path"; else rm -f -- "$path"; fi',
159
+ "done",
160
+ 'exit "$code"',
161
+ ].join("\n"),
162
+ { report: FEEDBACK, label: "Running the naive pass's tests" },
163
+ )
164
+ }
165
+
46
166
  export interface ImplementOptions {
47
167
  readonly reference?: string | undefined
48
168
  readonly tier?: RiskTier | undefined
@@ -103,41 +223,86 @@ interface Decision {
103
223
  readonly shape?: DiffShapeResult
104
224
  }
105
225
 
226
+ /** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
227
+ const steer = async (retry: t.NaiveRetry): Promise<boolean> => {
228
+ await human("steer", {
229
+ message: t.seamSteerMessage(retry),
230
+ label: "Steer or overrule the naive pass",
231
+ file: REQUIREMENTS,
232
+ acceptClean: true,
233
+ })
234
+ if (changes().length === 0) return false
235
+ requireCriteria(REQUIREMENTS)
236
+ return true
237
+ }
238
+
239
+ type Verdict =
240
+ | { readonly kind: "retry"; readonly retry: t.NaiveRetry }
241
+ | { readonly kind: "steered" }
242
+ | { readonly kind: "overruled" }
243
+
244
+ /** Rejects, test faults and red checks share one budget; a steer or an overrule refills it. */
245
+ const budget = (): ((next: t.NaiveRetry) => Promise<Verdict>) => {
246
+ let retries = 0
247
+ return async (next) => {
248
+ if (retries < NAIVE_RETRIES) {
249
+ retries++
250
+ return { kind: "retry", retry: next }
251
+ }
252
+ const steered = await steer(next)
253
+ retries = 0
254
+ // A steered or overruled red pass leaves its report behind: nothing reads it any more.
255
+ if (read(FEEDBACK) !== undefined)
256
+ await run("clear", removeScript([FEEDBACK]), { label: "Clearing the red check" })
257
+ return { kind: steered ? "steered" : "overruled" }
258
+ }
259
+ }
260
+
261
+ interface Vetting {
262
+ readonly again: ReturnType<typeof budget>
263
+ /** Set once a human overrules a test fault: the rule does not fit this change, so later passes skip it. */
264
+ waived: boolean
265
+ }
266
+
267
+ /** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
268
+ const vetted = async (tip: KeptTip, vetting: Vetting): Promise<Verdict | undefined> => {
269
+ const fault = vetting.waived ? undefined : acceptanceFault(REQUIREMENTS, tip)
270
+ if (fault !== undefined) {
271
+ const verdict = await vetting.again({ fault, hash: tip.hash })
272
+ if (verdict.kind !== "overruled") return verdict
273
+ vetting.waived = true
274
+ }
275
+ if (await naiveGreen(tip)) return undefined
276
+ const verdict = await vetting.again({ red: tip.hash })
277
+ return verdict.kind === "overruled" ? undefined : verdict
278
+ }
279
+
280
+ /** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
281
+ const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision | Verdict> => {
282
+ const shape = await judgeSeam(tip)
283
+ if (shape.choice === "land") return { reference: tip.hash }
284
+ if (shape.choice === "refactor-first") return { reference: tip.hash, shape }
285
+ const next = await again({ strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 })
286
+ return next.kind === "overruled" ? { reference: tip.hash } : next
287
+ }
288
+
106
289
  const decide = (): Promise<Decision> =>
107
290
  scope("seam", async () => {
108
- let rejection: t.SeamRejection | undefined
109
- let retries = 0
291
+ const vetting: Vetting = { again: budget(), waived: false }
292
+ let retry: t.NaiveRetry | undefined
110
293
  for (;;) {
111
- await naivePass(rejection)
294
+ await naivePass(retry)
112
295
  const tip = kept("naive")!
113
- const shape = await judgeSeam(tip)
114
- if (shape.choice === "land") return { reference: tip.hash }
115
- if (shape.choice === "refactor-first") {
116
- return { reference: tip.hash, shape }
117
- }
118
- rejection = { strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 }
119
- if (retries < NAIVE_RETRIES) {
120
- retries++
121
- continue
122
- }
123
- await human("steer", {
124
- message: t.seamSteerMessage(rejection),
125
- label: "Steer or overrule the rejected naive pass",
126
- file: REQUIREMENTS,
127
- acceptClean: true,
128
- })
129
- // Any edit steers, with a fresh budget and no stale rejection; clean overrules.
130
- if (changes().length === 0) return { reference: tip.hash }
131
- requireCriteria(REQUIREMENTS)
132
- rejection = undefined
133
- retries = 0
296
+ const outcome = (await vetted(tip, vetting)) ?? (await ruled(tip, vetting.again))
297
+ if (!("kind" in outcome)) return outcome
298
+ retry = outcome.kind === "retry" ? outcome.retry : undefined
134
299
  }
135
300
  })
136
301
 
137
302
  export interface SeamOutcome {
138
303
  /** The naive pass's kept tip: the first implement turn's reading material. */
139
304
  readonly reference: string
140
- /** The feature review's base: HEAD after the refactor on refactor-first, else the caller's base. */
305
+ /** The feature slice's base: HEAD after the refactor on refactor-first, else the caller's base. */
141
306
  readonly base: string
142
307
  readonly refactored?: boolean
143
308
  }
@@ -0,0 +1,108 @@
1
+ @inmem
2
+ Feature: human review is the review door's, never the feature workflow's
3
+
4
+ A feature process ends once its last slice lands: the quality lap is its
5
+ last look at the change, and no human review rest follows it. Human review
6
+ runs only when someone opens the `review` door.
7
+
8
+ Background:
9
+ Given the shipped workflows, as gtd install scaffolds them
10
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
11
+ And an environment variable "GTD_FLAGMODE" set to "false"
12
+ And a file "src/calc.ts" with:
13
+ """
14
+ export const add = (a: number, b: number) => a + b
15
+ """
16
+ And the working tree is committed as "feat: add calculator"
17
+ And a file "TODO.md" with:
18
+ """
19
+ # Add subtract
20
+
21
+ subtract(a, b) returns the difference.
22
+ """
23
+ And step "unwind" changes nothing
24
+ And step "start-gate.check" changes nothing
25
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
26
+ """
27
+ # Requirements
28
+
29
+ ## Concerns
30
+
31
+ ### Subtract
32
+
33
+ - `subtract(a, b)` returns `a - b`.
34
+
35
+ ## Acceptance Criteria
36
+
37
+ - AC-1: subtract(a, b) returns a - b.
38
+ """
39
+ And step "design.gate.answer" changes nothing
40
+ And step "implement.building" writes "src/calc.ts" with:
41
+ """
42
+ export const add = (a: number, b: number) => a + b
43
+ export const subtract = (a: number, b: number) => a - b
44
+ """
45
+ And step "implement.health.check" changes nothing
46
+ And step "implement.closing" runs its script
47
+
48
+ Scenario: a feature process ends at idle with no review rest
49
+ When the process is driven
50
+ Then the process rests at "idle"
51
+ And "src/calc.ts" contains "subtract"
52
+ And ".gtd/REVIEW.md" does not exist
53
+ And the commit subjects end with:
54
+ """
55
+ gtd(human): design.gate.answer → implement.building
56
+ gtd(agent): implement.building → implement.health.check
57
+ gtd(check): implement.health.check → implement.closing
58
+ gtd(check): implement.closing → idle
59
+ """
60
+
61
+ Scenario: the review door still runs human review over the finished feature
62
+ When the process is driven
63
+ Then the process rests at "idle"
64
+
65
+ Given step "review-gate.check" changes nothing
66
+ And step "build.health.check" changes nothing
67
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
68
+ """
69
+ # Review: abc1234
70
+
71
+ <!-- base: 0000000 -->
72
+
73
+ ## calc
74
+
75
+ - [ ] ./src/calc.ts#2-2
76
+ new subtract function
77
+ """
78
+ When gtd opens door "review" with "HEAD~8"
79
+ And the process is driven
80
+ Then the process rests at "build.review.await-review"
81
+ And the resting step's content contains the hash of the commit "feat: add calculator"
82
+
83
+ Given a file ".gtd/REVIEW.md" with:
84
+ """
85
+ # Review: abc1234
86
+
87
+ <!-- base: 0000000 -->
88
+
89
+ ## calc
90
+
91
+ - [x] ./src/calc.ts#2-2
92
+ new subtract function
93
+ """
94
+ And step "build.review.closing" runs its script
95
+ When gtd lands
96
+ And the process is driven
97
+ Then the process rests at "idle"
98
+ And ".gtd/REVIEW.md" does not exist
99
+ And the commit subjects end with:
100
+ """
101
+ gtd(check): implement.closing → idle
102
+ gtd(human): review-gate.check
103
+ gtd(check): review-gate.check → build.health.check
104
+ gtd(check): build.health.check → build.review.reviewing
105
+ gtd(agent): build.review.reviewing → build.review.await-review
106
+ gtd(human): build.review.await-review → build.review.closing
107
+ gtd(check): build.review.closing → idle
108
+ """
@@ -92,7 +92,7 @@ export interface TailOptions {
92
92
 
93
93
  /**
94
94
  * The build tail: fix (when entered red), keep green, the quality lap, then
95
- * `finish` — the human review, as the calling workflow runs it.
95
+ * `finish` — whatever the calling workflow runs last, a human review or nothing.
96
96
  */
97
97
  export function tail<T>(
98
98
  finish: (escalations: EscalationCount) => Promise<T>,
@@ -165,7 +165,7 @@ const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
165
165
  // Truthy, not `true`: an untyped config's flags read as they always did.
166
166
  if (options.fixFirst) await repair()
167
167
  else if (!options.green) await healthy(fix, { escalations, guard, guardRests, rules })
168
- // The lap runs once a tail: after its findings are fixed, review follows.
168
+ // The lap runs once a tail: after its findings are fixed, `finish` follows.
169
169
  if ((await qualityLap(guard, lenses, lensBase)) === "findings") {
170
170
  if (await fixQualityFindings(escalations, guard, guardRests, rules)) {
171
171
  await healthy(fix, { escalations, guard, guardRests, rules })
@@ -965,6 +965,22 @@ describe("the seam phase's texts", () => {
965
965
  expect(prompt).toContain("solved the task the wrong way")
966
966
  })
967
967
 
968
+ it("asks for a breadth-first pass with ID-tagged behaviour tests, green before it ends", () => {
969
+ const prompt = renderText(() => naivePassPrompt())
970
+ expect(prompt).toContain("breadth-first")
971
+ expect(prompt).toContain("`[AC-3] …`")
972
+ expect(prompt).toContain("never a file this pass adds")
973
+ expect(prompt).toContain("green")
974
+ })
975
+
976
+ it("has a red retry pick up the kept attempt instead of starting over", () => {
977
+ const prompt = renderText(() => naivePassPrompt({ red: "abc123" }))
978
+ expect(prompt).toContain("left the suite red")
979
+ expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
980
+ expect(prompt).toContain(".gtd/FEEDBACK.md")
981
+ expect(prompt).not.toContain("rejected")
982
+ })
983
+
968
984
  it("cites the reference as reading material only and asks for a new release flag", () => {
969
985
  const prompt = renderText(() => implementBuildingPrompt("abc123"))
970
986
  expect(prompt).toContain("git show abc123")
@@ -1019,6 +1035,30 @@ describe("the seam phase's texts", () => {
1019
1035
  expect(message).toContain("src/flows/helpers.ts")
1020
1036
  })
1021
1037
 
1038
+ it("hands a faulty pass back with the fault and the kept attempt to pick up", () => {
1039
+ const fault = {
1040
+ fault: "acceptance-tests: no test of the naive pass names AC-2",
1041
+ hash: "abc123",
1042
+ }
1043
+ const prompt = renderText(() => naivePassPrompt(fault))
1044
+ expect(prompt).toContain("names AC-2")
1045
+ expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
1046
+ const message = renderText(() => seamSteerMessage(fault))
1047
+ expect(message).toContain("names AC-2")
1048
+ expect(message).toContain("`seam.check` runs its suite")
1049
+ expect(message).toContain("Overrule")
1050
+ })
1051
+
1052
+ it("names the red suite, its report and what overruling it means", () => {
1053
+ const message = renderText(() => seamSteerMessage({ red: "abc123" }))
1054
+ expect(message).toContain("The last\nattempt left the suite red")
1055
+ expect(message).toContain(".gtd/FEEDBACK.md")
1056
+ expect(message).toContain("edit `.gtd/REQUIREMENTS.md`")
1057
+ expect(message).toContain("Overrule")
1058
+ expect(message).toContain("seam judge")
1059
+ expect(message).not.toContain("outside the expected set")
1060
+ })
1061
+
1022
1062
  it("names every unreadable path for a by-hand landing", () => {
1023
1063
  const message = renderText(() =>
1024
1064
  seamByHandMessage(["vendor/a.bin", "src/flows/helpers.ts"], ["vendor/a.bin"], "abc123"),
@@ -87,6 +87,19 @@ What each change does next (then run \`gtd land\`):
87
87
  - **Continue** — having undone the sketch by hand, check the test baseline is green and start triage (**start-gate.check**).
88
88
  `
89
89
 
90
+ export const qualityUnresolvedMessage = (): string =>
91
+ `The quality lap's fix turns hit their cap and \`.gtd/QUALITY.md\` still
92
+ holds findings. Nothing reviews the change after this, so the process
93
+ will not end with them open.
94
+
95
+ Fix what matters by hand, then delete or empty \`.gtd/QUALITY.md\` —
96
+ dropping a finding is your call to make.
97
+
98
+ What each change does next (then run \`gtd land\`):
99
+ - **Delete or empty \`.gtd/QUALITY.md\`** — the suite runs on your edits, then the process ends (**idle**).
100
+ - **Anything else** — rests here again.
101
+ `
102
+
90
103
  export const startGateBlockedMessage = (): string =>
91
104
  `The test baseline is red — gtd will not start new work on a broken suite.
92
105
  \`.gtd/FEEDBACK.md\` holds the failing output.
@@ -1134,31 +1147,72 @@ export interface SeamRejection {
1134
1147
  readonly p: number
1135
1148
  }
1136
1149
 
1150
+ /** A naive pass whose suite `seam.check` found red; `red` is its kept tip. */
1151
+ export interface NaiveRed {
1152
+ readonly red: string
1153
+ }
1154
+
1155
+ /** A naive pass whose tests break the acceptance-test rules; `hash` is its kept tip. */
1156
+ export interface NaiveFault {
1157
+ readonly fault: string
1158
+ readonly hash: string
1159
+ }
1160
+
1161
+ export type NaiveRetry = SeamRejection | NaiveRed | NaiveFault
1162
+
1163
+ const isRed = (retry: NaiveRetry): retry is NaiveRed => "red" in retry
1164
+ const isFault = (retry: NaiveRetry): retry is NaiveFault => "fault" in retry
1165
+
1166
+ const pickUp = (hash: string): string =>
1167
+ `Pick it up — \`git diff --binary ${hash}^ ${hash} | git apply\``
1168
+
1137
1169
  const criteriaBinding = (refused: boolean): string =>
1138
1170
  `- Its \`## Acceptance Criteria\` (\`AC-<n>\`) bind this turn: re-read them from
1139
1171
  the file, never from memory, and stop only when each one holds. They are
1140
1172
  settled — ${refused ? "a turn that changes them is refused" : "leave them as they are"}`
1141
1173
 
1142
- export const naivePassPrompt = (rejection?: SeamRejection): string =>
1174
+ const naiveRetry = (retry: NaiveRetry): string =>
1175
+ isFault(retry)
1176
+ ? `
1177
+ Your previous attempt's (\`${retry.hash}\`) tests fall short:
1178
+ ${retry.fault}
1179
+ ${pickUp(retry.hash)} — and fix its tests; do not start over.
1180
+ `
1181
+ : isRed(retry)
1182
+ ? `
1183
+ Your previous attempt (\`${retry.red}\`) left the suite red; \`.gtd/FEEDBACK.md\`
1184
+ holds the output. ${pickUp(retry.red)}
1185
+ — and fix it until the suite is green; do not start over.
1186
+ `
1187
+ : `
1188
+ Your previous attempt was rejected: the judge answered \`reject\` (p=${retry.p}),
1189
+ meaning the naive pass solved the task the wrong way — discard it and
1190
+ re-implement. It changed these files outside the expected set:
1191
+ ${listed(retry.strays)}
1192
+ Start over and choose a different approach; do not rebuild the same diff.
1193
+ `
1194
+
1195
+ export const naivePassPrompt = (retry?: NaiveRetry): string =>
1143
1196
  `${stateFileRules}
1144
1197
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1145
1198
  ${criteriaBinding(false)}
1146
1199
  - Implement them the quickest way that works — the naive version. Do not
1147
1200
  restructure, abstract or refactor around the change; a later step judges
1148
1201
  the shape of your diff and a reviewer makes it fit
1149
- - Add tests as you go, and leave \`.gtd/\` untouched
1202
+ - Work breadth-first: rough out every part of the change before finishing any
1203
+ one, so a run cut off midway still shows the whole shape
1204
+ - Write behaviour-level acceptance tests, at least one per criterion, each
1205
+ naming its ID in the test or scenario name (\`[AC-3] …\`). Test through code
1206
+ that existed before this pass — a public entry point, the CLI, an existing
1207
+ module's exports — never a file this pass adds: that is naive structure a
1208
+ later reset and refactor replaces. A pass that leaves a criterion's ID out
1209
+ of its tests, or whose test imports a file it added, is sent back
1210
+ - Run \`${env.testCommand}\` and finish only once it is green with every
1211
+ criterion's test passing: the next step runs it on your diff and sends a red
1212
+ pass back
1213
+ - Leave \`.gtd/\` untouched
1150
1214
  - Leave everything uncommitted and finish your turn — do not commit
1151
- ${
1152
- rejection === undefined
1153
- ? ""
1154
- : `
1155
- Your previous attempt was rejected: the judge answered \`reject\` (p=${rejection.p}),
1156
- meaning the naive pass solved the task the wrong way — discard it and
1157
- re-implement. It changed these files outside the expected set:
1158
- ${listed(rejection.strays)}
1159
- Start over and choose a different approach; do not rebuild the same diff.
1160
- `
1161
- }`
1215
+ ${retry === undefined ? "" : naiveRetry(retry)}`
1162
1216
 
1163
1217
  export const implementBuildingPrompt = (
1164
1218
  reference: string | undefined,
@@ -1216,9 +1270,49 @@ ${fixFeedbackPrompt}
1216
1270
  - Leave everything uncommitted and finish your turn — do not commit
1217
1271
  `
1218
1272
 
1219
- export const seamSteerMessage = (rejection: SeamRejection): string =>
1220
- `The naive pass was rejected three times and the retries are spent.
1221
- The last attempt changed these files outside the expected set:
1273
+ const steerChoices = (overrule: string): string => `Two ways forward (then run \`gtd land\`):
1274
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
1275
+ pass runs again with a fresh retry budget
1276
+ - **Overrule** — ${overrule}
1277
+
1278
+ An edit together with an approval counts as steer. Only
1279
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
1280
+ branch unjudged.
1281
+ `
1282
+
1283
+ export const seamSteerMessage = (retry: NaiveRetry): string =>
1284
+ isFault(retry)
1285
+ ? `The naive pass failed three times and the retries are spent. The last
1286
+ attempt's tests (\`git show ${retry.hash}\`) fall short:
1287
+ ${retry.fault}
1288
+
1289
+ ${steerChoices(`land a clean tree to go on with the last attempt as it is:
1290
+ \`seam.check\` runs its suite and the seam judge rules on its shape. Use it
1291
+ when the rule cannot fit this change (tests the globs do not see, or no
1292
+ entry point that existed before). Risk: a criterion may have no test that
1293
+ survives a later refactor — the choice is recorded in history.`)}`
1294
+ : isRed(retry)
1295
+ ? `The naive pass failed three times and the retries are spent. The last
1296
+ attempt left the suite red: \`.gtd/FEEDBACK.md\` holds the last run's output; \`git show ${retry.red}\` shows
1297
+ the last attempt.
1298
+
1299
+ Two ways forward (then run \`gtd land\`):
1300
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
1301
+ pass runs again with a fresh retry budget
1302
+ - **Overrule** — land a clean tree to go on with the last attempt as it is:
1303
+ the seam judge rules on its shape, and the implementation's health loop
1304
+ must turn the suite green. Risk: the criteria's tests never passed on the
1305
+ naive pass — the choice is recorded in history.
1306
+
1307
+ An edit together with an approval counts as steer. Only
1308
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
1309
+ branch unjudged.
1310
+ `
1311
+ : seamRejectedMessage(retry)
1312
+
1313
+ const seamRejectedMessage = (rejection: SeamRejection): string =>
1314
+ `The naive pass failed three times and the retries are spent. The judge
1315
+ rejected the last attempt: it changed these files outside the expected set:
1222
1316
  ${listed(rejection.strays)}
1223
1317
 
1224
1318
  Two ways forward (then run \`gtd land\`):