@pmelab/gtd 28.2.0 → 29.1.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 (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -14
  3. package/claude/hooks/handoff.ts +2 -3
  4. package/claude/hooks/ship.ts +762 -62
  5. package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
  6. package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
  7. package/dist/testkit.mjs +2 -1
  8. package/dist/workflow-kit.mjs +51 -21
  9. package/dist/workflows/cleanup.mjs +2 -2
  10. package/dist/workflows/feature.mjs +247 -96
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +2 -2
  13. package/dist/workflows/rollout.mjs +1 -1
  14. package/dist/workflows.mjs +528 -262
  15. package/package.json +1 -1
  16. package/src/testkit/TestRepo.ts +3 -0
  17. package/src/testkit/index.ts +1 -1
  18. package/src/testkit/steps.ts +44 -0
  19. package/src/workflow-kit/review.test.ts +366 -8
  20. package/src/workflow-kit/review.ts +109 -26
  21. package/src/workflows/feature/feature.test.ts +313 -1
  22. package/src/workflows/feature/feature.ts +48 -43
  23. package/src/workflows/feature/one-step-land.feature +4 -0
  24. package/src/workflows/feature/refactor-first.feature +54 -24
  25. package/src/workflows/feature/refactor-review.feature +1706 -0
  26. package/src/workflows/feature/refactor.test.ts +592 -2
  27. package/src/workflows/feature/refactor.ts +160 -42
  28. package/src/workflows/feature/review.test.ts +322 -5
  29. package/src/workflows/feature/review.ts +77 -10
  30. package/src/workflows/feature/seam-rejection.feature +4 -0
  31. package/src/workflows/feature/seam.feature +3 -4
  32. package/src/workflows/feature/seam.test.ts +196 -1
  33. package/src/workflows/feature/seam.ts +23 -9
  34. package/src/workflows/feature/steps.test.ts +54 -2
  35. package/src/workflows/feature/steps.ts +45 -9
  36. package/src/workflows/feature/tier.test.ts +4 -0
  37. package/src/workflows/feature/tier.ts +2 -20
  38. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
  39. package/src/workflows/shared/access.test.ts +31 -1
  40. package/src/workflows/shared/access.ts +7 -1
  41. package/src/workflows/shared/fences.ts +19 -0
  42. package/src/workflows/shared/health.ts +32 -12
  43. package/src/workflows/shared/options.test.ts +18 -0
  44. package/src/workflows/shared/options.ts +1 -0
  45. package/src/workflows/shared/prose.ts +4 -2
  46. package/src/workflows/shared/skills.test.ts +20 -1
  47. package/src/workflows/shared/skills.ts +43 -32
  48. package/src/workflows/shared/steps.ts +33 -19
  49. package/src/workflows/shared/tail.test.ts +84 -1
  50. package/src/workflows/shared/tail.ts +30 -12
  51. package/src/workflows/shared/text.test.ts +266 -3
  52. package/src/workflows/shared/text.ts +183 -69
  53. package/src/workflows/shared/vars.ts +12 -2
@@ -2,6 +2,7 @@ import {
2
2
  changes,
3
3
  changesSince,
4
4
  head,
5
+ matches,
5
6
  quote,
6
7
  read,
7
8
  refuse,
@@ -11,12 +12,16 @@ import {
11
12
  vars,
12
13
  type DiffShapeResult,
13
14
  } from "../../flows/index.js"
14
- import { healthy } from "../shared/health.js"
15
- import { coder, FEEDBACK } from "../shared/steps.js"
15
+ import { lensesOf } from "../../workflow-kit/index.js"
16
+ import { markFenced } from "../shared/fences.js"
17
+ import { healthy, type Guard } from "../shared/health.js"
18
+ import { coder, FEEDBACK, REFACTOR } from "../shared/steps.js"
16
19
  import * as t from "../shared/text.js"
17
- import { defaults } from "../shared/vars.js"
20
+ import { defaults, testFileGlobs } from "../shared/vars.js"
21
+ import { buildTail, reUnwind, type FeedbackTarget } from "./review.js"
22
+ import { refactorCollecting } from "./steps.js"
18
23
 
19
- export const REFACTOR_PACKAGE = ".gtd/REFACTOR.md"
24
+ export const REFACTOR_PACKAGE = REFACTOR
20
25
 
21
26
  export interface RefactorOptions {
22
27
  /** Blank counts as absent: `kept()` reports `""` while the keeping turn is pending. */
@@ -35,6 +40,19 @@ export const requireWithin = (allowed: readonly string[]): void => {
35
40
  )
36
41
  }
37
42
 
43
+ // A fixed list, not `seamAllow`: narrowing that setting must not let a test file through.
44
+ const isTestFile = (path: string): boolean => testFileGlobs.some((glob) => matches(path, glob))
45
+
46
+ export const requireNoTestFiles = (): void => {
47
+ const tests = changes()
48
+ .map((c) => c.path)
49
+ .filter((path) => isCode(path) && isTestFile(path))
50
+ if (tests.length === 0) return
51
+ refuse(
52
+ `gtd land: refactor-tests: the refactor may not change a test file — revert these:\n${tests.map((path) => ` - ${path}`).join("\n")}`,
53
+ )
54
+ }
55
+
38
56
  const flagExpression = (pattern: string): RegExp => {
39
57
  if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank")
40
58
  let expression: RegExp
@@ -95,6 +113,10 @@ export const requireNoNewFlags = (pattern: string, since: string): void => {
95
113
  )
96
114
  }
97
115
 
116
+ /** A test-file stray is never refactored: the implement turn still reads it in the naive tip. */
117
+ export const refactorStrays = (shape: DiffShapeResult): readonly string[] =>
118
+ shape.strays.filter((path) => !isTestFile(path))
119
+
98
120
  const packageLines = (
99
121
  strays: readonly string[],
100
122
  pattern: string,
@@ -111,7 +133,7 @@ const packageLines = (
111
133
  "## Rules",
112
134
  "",
113
135
  "- The suite's outcome must not change",
114
- "- Change only the paths above; a test file only when it is one of them",
136
+ "- Change only the paths above, and never a test file",
115
137
  `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
116
138
  "",
117
139
  "## Reference",
@@ -122,57 +144,153 @@ const packageLines = (
122
144
  "",
123
145
  ]
124
146
 
147
+ // Nested under `## Review feedback`, so every heading of the collected text drops a level.
148
+ const demoteHeadings = (text: string): string[] => {
149
+ const marked = markFenced(text.trimEnd())
150
+ const lines = marked.map((m) => m.line)
151
+ const headings = marked.map((m) =>
152
+ m.fenced ? undefined : /^(#{1,6}) /.exec(m.line)?.[1]?.length,
153
+ )
154
+ const shallowest = Math.min(...headings.filter((n): n is number => n !== undefined))
155
+ // Collected headings nest under `## Review feedback`, so the shallowest lands at `###`.
156
+ const shift = 3 - shallowest
157
+ return lines.map((line, i) => {
158
+ const depth = headings[i]
159
+ if (depth === undefined) return line
160
+ // Markdown has no heading past `######`.
161
+ return "#".repeat(Math.min(6, depth + shift)) + line.slice(depth)
162
+ })
163
+ }
164
+
125
165
  const deriveScript = (lines: readonly string[]): string =>
126
166
  `mkdir -p .gtd
127
167
  printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(REFACTOR_PACKAGE)}
128
168
  `
129
169
 
130
- export const refactorFirst = async (
131
- shape: DiffShapeResult,
132
- options: RefactorOptions = {},
133
- ): Promise<boolean> => {
134
- if (shape.choice !== "refactor-first" || shape.unreadable.length > 0) return false
170
+ interface Plan {
171
+ readonly pattern: string
172
+ readonly strays: readonly string[]
173
+ readonly reference: string | undefined
174
+ }
175
+
176
+ /** Undefined for every shape that is not a readable `refactor-first`; throws on a bad flag pattern. */
177
+ const plan = (shape: DiffShapeResult, options: RefactorOptions): Plan | undefined => {
178
+ if (shape.choice !== "refactor-first" || shape.unreadable.length > 0) return undefined
135
179
  const pattern = vars.refactorFlagPattern ?? defaults.refactorFlagPattern!
136
180
  const expression = flagExpression(pattern)
137
- const { strays } = shape
181
+ const strays = refactorStrays(shape)
182
+ if (strays.length === 0) return undefined
138
183
  const zeroLength = strays.filter((path) => countFlags(read(path) ?? "", expression, new Map()))
139
184
  if (zeroLength.length > 0)
140
185
  throw new Error(
141
186
  `gtd: refactorFlagPattern (${pattern}) produces a zero-length match in:\n${listPaths(zeroLength)}`,
142
187
  )
143
188
  const reference = options.reference?.trim() ? options.reference : undefined
144
- return scope({ name: "refactor", access: { write: [...strays, ".gtd/**"] } }, async () => {
145
- await run("deriving", deriveScript(packageLines(strays, pattern, reference)), {
146
- label: "Deriving the refactor package",
189
+ return { pattern, strays, reference }
190
+ }
191
+
192
+ // The in-memory `strays`, never the package file: a hand edit of its list changes nothing.
193
+ // Scope and tests look at the turn's own changes: a person's edit at an escalation rest must
194
+ // not wedge every later fix turn. Flags look at the whole range since `entry`.
195
+ const guardOf =
196
+ ({ pattern, strays }: Plan, entry: string): Guard =>
197
+ () => {
198
+ // Tests first: a test file is never a stray here, so the scope refusal would mask it.
199
+ requireNoTestFiles()
200
+ requireWithin(strays)
201
+ requireNoNewFlags(pattern, entry)
202
+ }
203
+
204
+ /** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
205
+ const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<void> => {
206
+ const { pattern, strays, reference } = p
207
+ await run(
208
+ "deriving",
209
+ deriveScript([
210
+ ...packageLines(strays, pattern, reference),
211
+ ...(feedback === undefined
212
+ ? []
213
+ : ["## Review feedback", "", ...demoteHeadings(feedback), ""]),
214
+ ]),
215
+ { label: "Deriving the refactor package", file: REFACTOR_PACKAGE },
216
+ )
217
+ await t.agentWithSkills(
218
+ "building",
219
+ t.refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference, feedback !== undefined),
220
+ { label: "Refactoring", model: coder(), system: t.builderSystem() },
221
+ )
222
+ guard()
223
+ await healthy(
224
+ async () => {
225
+ await t.agentWithSkills("fix.suite.fixing", t.refactorFixPrompt(strays, pattern), {
226
+ label: "Fixing the check",
227
+ file: FEEDBACK,
228
+ model: coder(),
229
+ system: t.builderSystem(),
230
+ })
231
+ guard()
232
+ },
233
+ { describeGuard: guard, rules: t.refactorRules(strays, pattern) },
234
+ )
235
+ await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" })
236
+ }
237
+
238
+ const strayAccess = (strays: readonly string[]) => ({ write: [...strays, ".gtd/**"] })
239
+
240
+ export const refactorFirst = async (
241
+ shape: DiffShapeResult,
242
+ options: RefactorOptions = {},
243
+ ): Promise<boolean> => {
244
+ const p = plan(shape, options)
245
+ if (p === undefined) return false
246
+ return scope({ name: "refactor", access: strayAccess(p.strays) }, async () => {
247
+ await refactorLap(p, guardOf(p, head()))
248
+ return true
249
+ })
250
+ }
251
+
252
+ /**
253
+ * `refactorFirst` plus its review, in the one `refactor` scope so the tail's
254
+ * steps are `refactor.build.*`. A review edit re-runs the refactor, never a
255
+ * re-plan: the collected notes ride the re-derived package. Every round
256
+ * reviews the whole refactor since `entry`, since each lap rebuilds over it.
257
+ */
258
+ export const reviewedRefactor = async (
259
+ shape: DiffShapeResult,
260
+ options: RefactorOptions = {},
261
+ ): Promise<boolean> => {
262
+ const p = plan(shape, options)
263
+ if (p === undefined) return false
264
+ return scope({ name: "refactor", access: strayAccess(p.strays) }, async () => {
265
+ const entry = head()
266
+ const guard = guardOf(p, entry)
267
+ const feedback: FeedbackTarget = {
147
268
  file: REFACTOR_PACKAGE,
148
- })
149
- const from = head()
150
- // The in-memory `strays`, never the package file: a hand edit of its list changes nothing.
151
- // Scope looks at the turn's own changes: a person's edit at an escalation rest must not
152
- // wedge every later fix turn. Flags look at the whole range.
153
- const guard = (): void => {
154
- requireWithin(strays)
155
- requireNoNewFlags(pattern, from)
269
+ collecting: (capture, round) => refactorCollecting(capture, round, p.strays),
270
+ unmatched:
271
+ "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",
272
+ dropped: t.refactorDroppedRefusal,
273
+ }
274
+ let collected: string | undefined
275
+ for (;;) {
276
+ await refactorLap(p, guard, collected)
277
+ // A person's edit at an escalation rest is never refused, as in the lap.
278
+ const outcome = await buildTail(false, entry, true, {
279
+ guard,
280
+ guardRests: false,
281
+ lenses: lensesOf(vars.refactorQualityReviews ?? ""),
282
+ feedback,
283
+ fixAccess: strayAccess(p.strays),
284
+ lensBase: entry,
285
+ rules: t.refactorRules(p.strays, p.pattern),
286
+ lap: "refactor",
287
+ })
288
+ if (outcome.verdict === "signoff") return true
289
+ await reUnwind(outcome)
290
+ // The kit returns `feedback` only once the collecting turn wrote the package.
291
+ collected = read(REFACTOR_PACKAGE)
292
+ if (collected === undefined)
293
+ throw new Error(`gtd: review feedback left no ${REFACTOR_PACKAGE}`)
156
294
  }
157
- await t.agentWithSkills(
158
- "building",
159
- t.refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference),
160
- { label: "Refactoring", model: coder(), system: t.builderSystem() },
161
- )
162
- guard()
163
- await healthy(
164
- async () => {
165
- await t.agentWithSkills("fix.suite.fixing", t.refactorFixPrompt(strays, pattern), {
166
- label: "Fixing the check",
167
- file: FEEDBACK,
168
- model: coder(),
169
- system: t.builderSystem(),
170
- })
171
- guard()
172
- },
173
- { describeGuard: guard },
174
- )
175
- await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" })
176
- return true
177
295
  })
178
296
  }
@@ -3,9 +3,17 @@ import {
3
3
  installContext,
4
4
  type Change,
5
5
  type JudgeAnswer,
6
+ type ScopeOptions,
6
7
  type StepRequest,
7
8
  } from "../../flows/index.js"
8
- import { review, severityOf, type ReviewOutcome } from "./review.js"
9
+ import {
10
+ buildTail,
11
+ review,
12
+ reUnwind,
13
+ severityOf,
14
+ type BuildTailOptions,
15
+ type ReviewOutcome,
16
+ } from "./review.js"
9
17
  import type { ReviewNote } from "../../steering/index.js"
10
18
  import * as bundled from "../index.js"
11
19
  import { fixtureContext } from "../shared/text.fixture.js"
@@ -41,6 +49,14 @@ interface Drive {
41
49
  readonly reviewDocs?: readonly (string | undefined)[]
42
50
  /** Let the run pass the second gate, stopping at the first range read after it. */
43
51
  readonly passSecondGate?: boolean
52
+ /** Options as `buildTail` passes them; `guard` is logged as `guard` when it runs. */
53
+ readonly options?: Omit<BuildTailOptions, "guard">
54
+ /** Install a guard that logs `guard` into the step log. */
55
+ readonly guarded?: boolean
56
+ /** The pending changes `changes()` reports at the collecting turn. */
57
+ readonly pending?: readonly Change[]
58
+ /** Files the harness holds from the start. */
59
+ readonly files?: Readonly<Record<string, string>>
44
60
  }
45
61
 
46
62
  interface Run {
@@ -53,6 +69,8 @@ interface Run {
53
69
  readonly gateMessages: readonly string[]
54
70
  /** Every `changesSince` argument, in order. */
55
71
  readonly since: readonly string[]
72
+ /** Every scope opened, in order. */
73
+ readonly scopes: readonly ScopeOptions[]
56
74
  }
57
75
 
58
76
  /** Run `review` against a scripted context; a second pass at the gate or a fresh review stops the run. */
@@ -63,7 +81,11 @@ const drive = async (d: Drive): Promise<Run> => {
63
81
  const gates: boolean[] = []
64
82
  const gateMessages: string[] = []
65
83
  const since: string[] = []
66
- const files = new Map<string, string>([[REVIEW, d.after ?? doc(d.notes)]])
84
+ const scopes: ScopeOptions[] = []
85
+ const files = new Map<string, string>([
86
+ [REVIEW, d.after ?? doc(d.notes)],
87
+ ...Object.entries(d.files ?? {}),
88
+ ])
67
89
  let steps = 0
68
90
  let nitsFixed = false
69
91
  let awaits = 0
@@ -107,9 +129,10 @@ const drive = async (d: Drive): Promise<Run> => {
107
129
  refuse: (message: string): never => {
108
130
  throw new Error(message)
109
131
  },
110
- pushScope: () => undefined,
132
+ pushScope: (scope) => void scopes.push(scope),
111
133
  popScope: () => undefined,
112
134
  read: (path) => files.get(path),
135
+ changes: (): readonly Change[] => d.pending ?? [],
113
136
  changesSince: (from: string): readonly Change[] => {
114
137
  since.push(from)
115
138
  if (d.passSecondGate && awaits === 2) throw new Stop()
@@ -127,12 +150,15 @@ const drive = async (d: Drive): Promise<Run> => {
127
150
  installContext(context)
128
151
  let result: ReviewOutcome | "stopped"
129
152
  try {
130
- result = await review("base")
153
+ result = await review("base", undefined, {
154
+ ...d.options,
155
+ ...(d.guarded ? { guard: () => void log.push("guard") } : {}),
156
+ })
131
157
  } catch (error) {
132
158
  if (!(error instanceof Stop)) throw error
133
159
  result = "stopped"
134
160
  }
135
- return { log, prompts, result, judged, gates, gateMessages, since }
161
+ return { log, prompts, result, judged, gates, gateMessages, since, scopes }
136
162
  }
137
163
 
138
164
  const verdict = (answer: string, p = 0.9): JudgeAnswer => ({ answer, p })
@@ -167,6 +193,28 @@ describe("the risk-fix pass", () => {
167
193
  ])
168
194
  })
169
195
 
196
+ it("fixAccess wraps both fix turns in their own scope, the step names unchanged", async () => {
197
+ const fixAccess = { write: ["src/a.ts", ".gtd/**"] }
198
+ const run = await drive({
199
+ notes: ["add — typo", "sub", "mul"],
200
+ answers: { "note-1": verdict("nit") },
201
+ reviewDocs: [risky, clean],
202
+ options: { fixAccess },
203
+ })
204
+ expect(run.scopes).toEqual([
205
+ { name: "review.fix.risks", access: fixAccess },
206
+ { name: "review.fix.nits", access: fixAccess },
207
+ ])
208
+ // The harness pushes no scope names, so the step is relative to its own fix scope.
209
+ expect(run.log.filter((n) => n === "fixing")).toHaveLength(2)
210
+ })
211
+
212
+ it("without fixAccess the fix turns open no scope", async () => {
213
+ const run = await drive({ notes: [], reviewDocs: [risky, clean] })
214
+ expect(run.log).toContain("review.fix.risks.fixing")
215
+ expect(run.scopes).toEqual([])
216
+ })
217
+
170
218
  it("no marker means no fix-risks step", async () => {
171
219
  const run = await drive({ notes: [], reviewDocs: [clean] })
172
220
  expect(run.log).not.toContain("review.fix.risks.fixing")
@@ -196,6 +244,159 @@ describe("the risk-fix pass", () => {
196
244
  })
197
245
  })
198
246
 
247
+ describe("the guard", () => {
248
+ const risky = doc(["Risk: drops the carry", "sub"])
249
+ const clean = doc(["add — fine", "sub"])
250
+
251
+ it("runs right after the risk-fix turn", async () => {
252
+ const run = await drive({ notes: [], reviewDocs: [risky, clean], guarded: true })
253
+ expect(run.log.slice(0, 4)).toEqual([
254
+ "review.reviewing",
255
+ "review.fix.risks.fixing",
256
+ "guard",
257
+ "health.check",
258
+ ])
259
+ })
260
+
261
+ it("runs right after the nit-fix turn", async () => {
262
+ const run = await drive({
263
+ notes: ["add — typo", "sub", "mul"],
264
+ answers: { "note-1": verdict("nit") },
265
+ guarded: true,
266
+ })
267
+ expect(run.log.slice(2, 6)).toEqual([
268
+ "review.triage",
269
+ "review.fix.nits.fixing",
270
+ "guard",
271
+ "health.check",
272
+ ])
273
+ })
274
+ })
275
+
276
+ describe("the feedback target", () => {
277
+ const edit = ["add — rename it", "sub", "mul"]
278
+ const REFACTOR = ".gtd/REFACTOR.md"
279
+
280
+ it("defaults to .gtd/REQUIREMENTS.md via the review.collecting step", async () => {
281
+ const run = await drive({ notes: edit })
282
+ expect(run.log.at(-1)).toBe("review.collecting")
283
+ expect(run.result).toMatchObject({ verdict: "feedback" })
284
+ })
285
+
286
+ it("file replaces .gtd/REQUIREMENTS.md as the feedback test", async () => {
287
+ let collected = ""
288
+ const run = await drive({
289
+ notes: edit,
290
+ options: {
291
+ feedback: {
292
+ file: REFACTOR,
293
+ collecting: (capture) => {
294
+ collected = capture
295
+ return Promise.resolve()
296
+ },
297
+ unmatched: "unmatched: refactor",
298
+ },
299
+ },
300
+ files: { [REFACTOR]: "## Concern" },
301
+ })
302
+ expect(collected).toContain("rename it")
303
+ expect(run.log).not.toContain("review.collecting")
304
+ expect(run.result).toMatchObject({ verdict: "feedback" })
305
+ })
306
+
307
+ it("a collecting turn that wrote REQUIREMENTS but not file signs off unrefused with nothing changed", async () => {
308
+ const run = await drive({
309
+ notes: edit,
310
+ options: {
311
+ feedback: {
312
+ file: REFACTOR,
313
+ collecting: () => Promise.resolve(),
314
+ unmatched: "unmatched: refactor",
315
+ },
316
+ },
317
+ })
318
+ expect(run.result).toEqual({ verdict: "signoff" })
319
+ })
320
+
321
+ it("a feature review edit note collected to nothing signs off", async () => {
322
+ const run = await drive({
323
+ notes: edit,
324
+ options: {
325
+ feedback: {
326
+ file: REQUIREMENTS,
327
+ collecting: () => Promise.resolve(),
328
+ unmatched: "unmatched",
329
+ },
330
+ },
331
+ })
332
+ expect(run.result).toEqual({ verdict: "signoff" })
333
+ })
334
+
335
+ it("dropped is passed through as the refusal for a round collected to nothing", async () => {
336
+ await expect(
337
+ drive({
338
+ notes: edit,
339
+ options: {
340
+ feedback: {
341
+ file: REFACTOR,
342
+ collecting: () => Promise.resolve(),
343
+ unmatched: "unmatched: refactor",
344
+ dropped: (r) => `dropped: ${r.notes.map((n) => n.id).join(",")}`,
345
+ },
346
+ },
347
+ }),
348
+ ).rejects.toThrow("dropped: note-1")
349
+ })
350
+
351
+ it("refuses with unmatched when the collecting turn changed something but not file", async () => {
352
+ await expect(
353
+ drive({
354
+ notes: edit,
355
+ options: {
356
+ feedback: {
357
+ file: REFACTOR,
358
+ collecting: () => Promise.resolve(),
359
+ unmatched: "unmatched: refactor",
360
+ },
361
+ },
362
+ pending: [{ path: "src/x.ts", status: "modified", before: "a", after: "b" }],
363
+ }),
364
+ ).rejects.toThrow("unmatched: refactor")
365
+ })
366
+ })
367
+
368
+ describe("reUnwind", () => {
369
+ it("restores edited paths from restoreFrom and removes added ones, in the caller's scope", async () => {
370
+ const steps: StepRequest[] = []
371
+ installContext(
372
+ fixtureContext(
373
+ {},
374
+ {
375
+ step: (request) => {
376
+ steps.push(request)
377
+ return Promise.resolve()
378
+ },
379
+ read: (path) => (path === "src/a.ts" ? "old" : undefined),
380
+ },
381
+ ),
382
+ )
383
+ await reUnwind({
384
+ verdict: "feedback",
385
+ base: "b1",
386
+ restoreFrom: "r1",
387
+ edited: [
388
+ { path: "src/a.ts", status: "modified", before: "old", after: "new" },
389
+ { path: "src/new.ts", status: "added", before: undefined, after: "x" },
390
+ ],
391
+ })
392
+ const [step] = steps
393
+ expect(step).toMatchObject({ kind: "run", name: "re-unwind" })
394
+ const body = step?.kind === "run" ? step.body : ""
395
+ expect(body).toContain("git restore --source='r1' --worktree -- 'src/a.ts'")
396
+ expect(body).toContain("rm -f -- 'src/new.ts'")
397
+ })
398
+ })
399
+
199
400
  describe("review verdict routing", () => {
200
401
  const notes = ["add — typo", "sub", "mul"]
201
402
 
@@ -568,3 +769,119 @@ describe("severity routing", () => {
568
769
  expect(run.log).not.toContain("review.defer")
569
770
  })
570
771
  })
772
+
773
+ describe("BuildTailOptions.lensBase", () => {
774
+ const lensPrompts = async (options: BuildTailOptions): Promise<string[]> => {
775
+ const prompts: string[] = []
776
+ installContext(
777
+ fixtureContext(
778
+ { vars: { qualityReviews: "conventions, correctness" } },
779
+ {
780
+ step: (request: StepRequest) => {
781
+ if (request.kind === "restart") return Promise.resolve()
782
+ if (request.kind === "agent" && request.name.startsWith("quality."))
783
+ prompts.push(request.prompt)
784
+ if (request.name === "review.reviewing") throw new Stop()
785
+ return Promise.resolve()
786
+ },
787
+ pushScope: () => undefined,
788
+ popScope: () => undefined,
789
+ changesSince: () => {
790
+ throw new Stop()
791
+ },
792
+ start: () => "START-SHA",
793
+ },
794
+ ),
795
+ )
796
+ await buildTail(false, "base", true, options).catch((error: unknown) => {
797
+ if (!(error instanceof Stop)) throw error
798
+ })
799
+ return prompts
800
+ }
801
+
802
+ it("reaches every quality-lens prompt", async () => {
803
+ const prompts = await lensPrompts({ lensBase: "SLICE-BASE" })
804
+ expect(prompts).toHaveLength(2)
805
+ for (const prompt of prompts) expect(prompt).toContain("from `SLICE-BASE`")
806
+ })
807
+
808
+ it("absent, the lens prompts name start()", async () => {
809
+ const prompts = await lensPrompts({})
810
+ expect(prompts).toHaveLength(2)
811
+ for (const prompt of prompts) expect(prompt).toContain("from `START-SHA`")
812
+ })
813
+ })
814
+
815
+ describe("buildTail without rules", () => {
816
+ const RULES = "Change only these paths"
817
+
818
+ const tailPrompts = async (reds: number): Promise<Map<string, string>> => {
819
+ const prompts = new Map<string, string>()
820
+ let checks = 0
821
+ const files = new Map<string, string>([[".gtd/QUALITY.md", "- finding"]])
822
+ installContext(
823
+ fixtureContext(
824
+ { vars: { qualityReviews: "conventions" } },
825
+ {
826
+ step: (request: StepRequest) => {
827
+ if (request.kind === "restart") return Promise.resolve()
828
+ if (request.kind === "agent") prompts.set(request.name, request.prompt)
829
+ if (request.name === "health.check") checks++
830
+ if (request.name === "fix.quality.fixing") files.delete(".gtd/QUALITY.md")
831
+ if (request.name === "review.reviewing" || request.name === "health.stop")
832
+ throw new Stop()
833
+ return Promise.resolve(
834
+ request.kind === "judge" ? { answers: {}, truncated: [] } : undefined,
835
+ )
836
+ },
837
+ pushScope: () => undefined,
838
+ popScope: () => undefined,
839
+ read: (path) =>
840
+ path === ".gtd/FEEDBACK.md" ? (checks <= reds ? "red" : undefined) : files.get(path),
841
+ changes: () =>
842
+ checks <= reds
843
+ ? [{ path: ".gtd/FEEDBACK.md", status: "added", before: undefined, after: "red" }]
844
+ : [],
845
+ matches: (path, pattern) => path === pattern,
846
+ changesSince: () => {
847
+ throw new Stop()
848
+ },
849
+ start: () => "c0",
850
+ },
851
+ ),
852
+ )
853
+ await buildTail(true, "base", false, {}).catch((error: unknown) => {
854
+ if (!(error instanceof Stop)) throw error
855
+ })
856
+ return prompts
857
+ }
858
+
859
+ it("renders the feature tail's fix and describe prompts unchanged", async () => {
860
+ const prompts = await tailPrompts(6)
861
+ expect(prompts.get("fix")).toMatch(/- Leave everything uncommitted — do not commit\n$/)
862
+ expect(prompts.get("health.describe")).toMatch(/this turn only writes the document\n$/)
863
+ for (const prompt of prompts.values()) expect(prompt).not.toContain(RULES)
864
+ })
865
+
866
+ it("renders the feature tail's quality-fix prompt unchanged", async () => {
867
+ const prompts = await tailPrompts(0)
868
+ expect(prompts.get("fix.quality.fixing")).toMatch(
869
+ /- Leave everything else uncommitted and finish your turn\n$/,
870
+ )
871
+ expect(prompts.get("fix.quality.fixing")).not.toContain(RULES)
872
+ })
873
+
874
+ it("renders the risk-fix and nit-fix prompts unchanged", async () => {
875
+ const run = await drive({
876
+ notes: [],
877
+ reviewDocs: [doc(["Risk: x", "sub"]), doc(["add", "sub"])],
878
+ })
879
+ expect(run.prompts.get("review.fix.risks.fixing")).toContain("add a test that fails")
880
+ expect(run.prompts.get("review.fix.risks.fixing")).not.toContain(RULES)
881
+ const nits = await drive({
882
+ notes: ["add — typo", "sub", "mul"],
883
+ answers: { "note-1": verdict("nit") },
884
+ })
885
+ expect(nits.prompts.get("review.fix.nits.fixing")).not.toContain(RULES)
886
+ })
887
+ })