@pmelab/gtd 27.1.0 → 27.3.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 (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -9
  3. package/dist/chunks/registry-CP3YuV9j.mjs +101 -0
  4. package/dist/chunks/{reserved-BL9krPao.mjs → reserved-BPdVZkgv.mjs} +66 -73
  5. package/dist/chunks/tail-iLAMoigl.mjs +81 -0
  6. package/dist/gtd.bundle.mjs +5 -2
  7. package/dist/testkit.mjs +17 -2
  8. package/dist/workflows/cleanup.mjs +37 -0
  9. package/dist/workflows/feature.mjs +119 -5
  10. package/dist/workflows/fix.mjs +2 -1
  11. package/dist/workflows/review.mjs +2 -1
  12. package/dist/workflows/rollout.mjs +33 -0
  13. package/dist/workflows.mjs +362 -25
  14. package/package.json +11 -1
  15. package/src/flags/index.test.ts +28 -0
  16. package/src/flags/index.ts +9 -4
  17. package/src/testkit/EmittedScriptRecognizer.ts +21 -0
  18. package/src/testkit/EmittedScripts.test.ts +9 -0
  19. package/src/testkit/steps.ts +26 -0
  20. package/src/testkit/testkit.feature +8 -1
  21. package/src/workflows/cleanup/cleanup.feature +323 -0
  22. package/src/workflows/cleanup/cleanup.test.ts +272 -0
  23. package/src/workflows/cleanup/cleanup.ts +52 -0
  24. package/src/workflows/cleanup/index.ts +6 -0
  25. package/src/workflows/feature/index.ts +1 -0
  26. package/src/workflows/feature/refactor.test.ts +466 -0
  27. package/src/workflows/feature/refactor.ts +178 -0
  28. package/src/workflows/index.ts +4 -1
  29. package/src/workflows/rollout/index.ts +6 -0
  30. package/src/workflows/rollout/rollout.feature +214 -0
  31. package/src/workflows/rollout/rollout.test.ts +167 -0
  32. package/src/workflows/rollout/rollout.ts +44 -0
  33. package/src/workflows/shared/access.ts +2 -1
  34. package/src/workflows/shared/health.test.ts +103 -0
  35. package/src/workflows/shared/health.ts +32 -7
  36. package/src/workflows/shared/options.test.ts +30 -0
  37. package/src/workflows/shared/options.ts +20 -0
  38. package/src/workflows/shared/registry.test.ts +305 -0
  39. package/src/workflows/shared/registry.ts +158 -0
  40. package/src/workflows/shared/skills.test.ts +6 -0
  41. package/src/workflows/shared/skills.ts +5 -1
  42. package/src/workflows/shared/tail.test.ts +36 -2
  43. package/src/workflows/shared/tail.ts +30 -18
  44. package/src/workflows/shared/text.ts +35 -0
  45. package/src/workflows/shared/vars.ts +1 -0
@@ -0,0 +1,52 @@
1
+ import { agent, changes, glob, read, refuse, scope, start, vars } from "../../flows/index.js"
2
+ import { flagShipped } from "../shared/options.js"
3
+ import { flagReferences, registryEntry, requireFlag } from "../shared/registry.js"
4
+ import { coder } from "../shared/steps.js"
5
+ import { handOffTail, refusePendingHandOff } from "../shared/tail.js"
6
+
7
+ const removingPrompt = (name: string, registry: string): string =>
8
+ `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`
9
+
10
+ export const cleanupFlow = async (): Promise<void> => {
11
+ const registry = vars.flagRegistry ?? ""
12
+ const name = vars.flag ?? ""
13
+ // Before the turn, never after: replay re-runs every pre-turn check.
14
+ refusePendingHandOff()
15
+ requireFlag("cleanup", registry, name, true)
16
+ // After `removing` both checks are per-turn: only a turn that touched the registry or a source is refused, so the human's unguarded sign-off edits are never blamed on a later agent turn.
17
+ let scanned = false
18
+ const guard = (): void => {
19
+ const turn = scanned ? changes() : undefined
20
+ if (
21
+ (turn === undefined || turn.some((c) => c.path === registry)) &&
22
+ registryEntry(read(registry) ?? "", name) !== undefined
23
+ ) {
24
+ refuse(`gtd land: cleanup: ${registry} still declares flag "${name}"`)
25
+ }
26
+ const paths =
27
+ turn === undefined
28
+ ? [...glob("src/**/*.ts"), ...glob("src/**/*.tsx")]
29
+ : turn
30
+ .filter((c) => c.status !== "deleted")
31
+ .map((c) => c.path)
32
+ .filter((path) => path.startsWith("src/") && /\.tsx?$/.test(path))
33
+ const hits = flagReferences(
34
+ paths.map((path) => ({ path, text: read(path) ?? "" })),
35
+ name,
36
+ )
37
+ if (hits.length > 0) {
38
+ refuse(`gtd land: cleanup: ${hits.join(", ")} still names GTD_FLAG_${name.toUpperCase()}`)
39
+ }
40
+ }
41
+ await scope("cleanup", async () => {
42
+ await agent("removing", removingPrompt(name, registry), {
43
+ label: `Removing ${name}`,
44
+ model: coder(),
45
+ })
46
+ guard()
47
+ scanned = true
48
+ })
49
+ await handOffTail(false, start(), guard)
50
+ }
51
+
52
+ export const cleanup = flagShipped(cleanupFlow)
@@ -0,0 +1,6 @@
1
+ import { cleanup } from "./cleanup.js"
2
+
3
+ export { cleanup, cleanupFlow } from "./cleanup.js"
4
+ export * from "../shared/reserved.js"
5
+
6
+ export default cleanup
@@ -3,6 +3,7 @@ import { feature } from "./feature.js"
3
3
  export * from "./steps.js"
4
4
  export * from "./planning.js"
5
5
  export * from "./packages.js"
6
+ export * from "./refactor.js"
6
7
  export { buildTail, type ReviewOutcome } from "./review.js"
7
8
  export * from "./feature.js"
8
9
  export * from "../shared/reserved.js"
@@ -0,0 +1,466 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import {
3
+ installContext,
4
+ type Change,
5
+ type DiffShapeResult,
6
+ type ScopeOptions,
7
+ type StepRequest,
8
+ } from "../../flows/index.js"
9
+ import { fixtureContext, type TextContext } from "../shared/text.fixture.js"
10
+ import { refactorFirst, requireNoNewFlags, requireWithin, REFACTOR_PACKAGE } from "./refactor.js"
11
+
12
+ afterEach(() => installContext(undefined))
13
+
14
+ const change = (
15
+ path: string,
16
+ before: string | undefined,
17
+ after: string | undefined,
18
+ status: Change["status"] = "modified",
19
+ ): Change => ({ path, status, before, after })
20
+
21
+ const refusal = (fn: () => void): string | undefined => {
22
+ try {
23
+ fn()
24
+ } catch (error) {
25
+ return (error as Error).message
26
+ }
27
+ return undefined
28
+ }
29
+
30
+ const withChanges = (list: readonly Change[], fn: () => void): string | undefined => {
31
+ installContext(
32
+ fixtureContext(
33
+ {},
34
+ {
35
+ changes: () => list,
36
+ changesSince: () => list,
37
+ refuse: (message) => {
38
+ throw new Error(message)
39
+ },
40
+ },
41
+ ),
42
+ )
43
+ return refusal(fn)
44
+ }
45
+
46
+ describe("requireWithin", () => {
47
+ it("passes for strays and .gtd/ paths", () => {
48
+ const list = [change("src/a.ts", "a", "b"), change(".gtd/FEEDBACK.md", "", "x", "added")]
49
+ expect(withChanges(list, () => requireWithin(["src/a.ts"]))).toBeUndefined()
50
+ })
51
+
52
+ it("refuses naming each other path", () => {
53
+ const list = [
54
+ change("src/a.ts", "a", "b"),
55
+ change("lib/x.ts", "a", "b"),
56
+ change("lib/y.ts", "a", "b"),
57
+ ]
58
+ const message = withChanges(list, () => requireWithin(["src/a.ts"]))
59
+ expect(message).toContain("lib/x.ts")
60
+ expect(message).toContain("lib/y.ts")
61
+ expect(message).not.toContain("src/a.ts")
62
+ })
63
+ })
64
+
65
+ describe("requireNoNewFlags", () => {
66
+ const pattern = "\\bGTD_FLAG_\\w+"
67
+
68
+ it("refuses a flag name absent before and present after", () => {
69
+ const message = withChanges([change("src/a.ts", "x", "x GTD_FLAG_NEW")], () =>
70
+ requireNoNewFlags(pattern, "h"),
71
+ )
72
+ expect(message).toContain("GTD_FLAG_NEW")
73
+ })
74
+
75
+ it("refuses a second read of an existing flag", () => {
76
+ const message = withChanges([change("src/a.ts", "GTD_FLAG_A", "GTD_FLAG_A GTD_FLAG_A")], () =>
77
+ requireNoNewFlags(pattern, "h"),
78
+ )
79
+ expect(message).toContain("GTD_FLAG_A")
80
+ })
81
+
82
+ it("counts an added file's flags against zero", () => {
83
+ const message = withChanges([change("src/n.ts", undefined, "GTD_FLAG_Z", "added")], () =>
84
+ requireNoNewFlags(pattern, "h"),
85
+ )
86
+ expect(message).toContain("GTD_FLAG_Z")
87
+ })
88
+
89
+ it("passes a read moved between files", () => {
90
+ const list = [change("src/a.ts", "GTD_FLAG_A", ""), change("src/b.ts", "", "GTD_FLAG_A")]
91
+ expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
92
+ })
93
+
94
+ it("passes a deleted file's flags", () => {
95
+ const list = [change("src/gone.ts", "GTD_FLAG_A", undefined, "deleted")]
96
+ expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
97
+ })
98
+
99
+ it("passes a swap that keeps every per-name count flat", () => {
100
+ const list = [
101
+ change("src/a.ts", "GTD_FLAG_A", "GTD_FLAG_B"),
102
+ change("src/b.ts", "GTD_FLAG_B", "GTD_FLAG_A"),
103
+ ]
104
+ expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
105
+ })
106
+
107
+ it("ignores flag names in .gtd/ files", () => {
108
+ const list = [change(".gtd/FEEDBACK.md", "", "GTD_FLAG_X failed", "added")]
109
+ expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
110
+ })
111
+
112
+ describe("a pattern with a zero-length match", () => {
113
+ const lookahead = "(?=GTD_FLAG_\\w+)"
114
+
115
+ it("refuses a changed file holding a flag read, naming the setting and the path", () => {
116
+ const message = withChanges([change("src/a.ts", "x", "x GTD_FLAG_A")], () =>
117
+ requireNoNewFlags(lookahead, "h"),
118
+ )
119
+ expect(message).toContain("refactorFlagPattern")
120
+ expect(message).toContain("zero-length match")
121
+ expect(message).toContain(" - src/a.ts")
122
+ })
123
+
124
+ it("refuses a flag swap across two files that the default pattern passes", () => {
125
+ const list = [
126
+ change("src/a.ts", "GTD_FLAG_A", "GTD_FLAG_B"),
127
+ change("src/b.ts", "GTD_FLAG_B", "GTD_FLAG_A"),
128
+ ]
129
+ expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
130
+ const message = withChanges(list, () => requireNoNewFlags(lookahead, "h"))
131
+ expect(message).toContain(" - src/a.ts")
132
+ expect(message).toContain(" - src/b.ts")
133
+ })
134
+
135
+ it("lists a path once when both sides match", () => {
136
+ const message = withChanges([change("src/a.ts", "GTD_FLAG_A", "GTD_FLAG_B")], () =>
137
+ requireNoNewFlags(lookahead, "h"),
138
+ )
139
+ expect(message?.match(/src\/a\.ts/g)).toHaveLength(1)
140
+ })
141
+
142
+ it("passes a file with no match on either side", () => {
143
+ expect(
144
+ withChanges([change("src/a.ts", "x", "y")], () => requireNoNewFlags(lookahead, "h")),
145
+ ).toBeUndefined()
146
+ })
147
+ })
148
+
149
+ it("refuses an unreadable side of a modified file", () => {
150
+ expect(
151
+ withChanges([change("src/a.ts", undefined, "x")], () => requireNoNewFlags(pattern, "h")),
152
+ ).toContain("src/a.ts")
153
+ expect(
154
+ withChanges([change("src/a.ts", "x", undefined)], () => requireNoNewFlags(pattern, "h")),
155
+ ).toContain("src/a.ts")
156
+ })
157
+ })
158
+
159
+ const shape = (overrides: Partial<DiffShapeResult> = {}): DiffShapeResult => ({
160
+ choice: "refactor-first",
161
+ strays: ["src/a.ts", "src/b.ts"],
162
+ unreadable: [],
163
+ judgment: undefined,
164
+ ...overrides,
165
+ })
166
+
167
+ interface Harness {
168
+ readonly steps: StepRequest[]
169
+ readonly scopes: ScopeOptions[]
170
+ }
171
+
172
+ /** The phase a step name enters; `fix.suite.fixing` is an agent step. */
173
+ const PHASE_AT: Record<string, string | undefined> = {
174
+ "fix.suite.fixing": "fix",
175
+ "health.describe": "describe",
176
+ "health.stop": "stop",
177
+ "health.exhausted": "stop",
178
+ }
179
+
180
+ /** Runs `refactorFirst` against a recording context; `reds` red checks come first. */
181
+ const drive = async (
182
+ given: DiffShapeResult,
183
+ options: {
184
+ readonly text?: TextContext
185
+ readonly reference?: string
186
+ readonly build?: readonly Change[]
187
+ readonly fix?: readonly Change[]
188
+ /** The fix turn's own changes, when they differ from the cumulative `fix`. */
189
+ readonly fixTurn?: readonly Change[]
190
+ /** Red checks before the first green one. */
191
+ readonly reds?: number
192
+ /** The escalation describe turn's own changes. */
193
+ readonly describe?: readonly Change[]
194
+ /** A person's own changes at the `health.stop` rest. */
195
+ readonly stop?: readonly Change[]
196
+ } = {},
197
+ ): Promise<Harness & { result: boolean | Error }> => {
198
+ const steps: StepRequest[] = []
199
+ const scopes: ScopeOptions[] = []
200
+ let phase = "build"
201
+ let checks = 0
202
+ const reached = new Set<string>()
203
+ const reds = options.reds ?? 0
204
+ installContext(
205
+ fixtureContext(options.text ?? { scope: "refactor" }, {
206
+ step: (request: StepRequest) => {
207
+ steps.push(request)
208
+ const name = "name" in request ? request.name : ""
209
+ phase = PHASE_AT[name] ?? phase
210
+ reached.add(phase)
211
+ if (request.kind === "run" && name === "health.check") {
212
+ checks++
213
+ phase = "check"
214
+ }
215
+ return Promise.resolve(
216
+ request.kind === "judge" ? { answers: {}, truncated: [] } : undefined,
217
+ )
218
+ },
219
+ refuse: (message) => {
220
+ throw new Error(message)
221
+ },
222
+ pushScope: (scope) => void scopes.push(scope),
223
+ popScope: () => undefined,
224
+ // Cumulative like the live range: every turn reached so far, later phases included.
225
+ changesSince: () => [
226
+ ...(options.build ?? []),
227
+ ...(reached.has("fix") ? (options.fix ?? []) : []),
228
+ ...(reached.has("describe") ? (options.describe ?? []) : []),
229
+ ...(reached.has("stop") ? (options.stop ?? []) : []),
230
+ ],
231
+ changes: () => {
232
+ const turn: Record<string, readonly Change[] | undefined> = {
233
+ build: options.build,
234
+ fix: options.fixTurn ?? options.fix,
235
+ describe: options.describe,
236
+ stop: options.stop,
237
+ }
238
+ if (phase in turn) return turn[phase] ?? []
239
+ return checks <= reds ? [change(".gtd/FEEDBACK.md", undefined, "red", "added")] : []
240
+ },
241
+ matches: (path, pattern) => path === pattern,
242
+ head: () => "c0ffee0",
243
+ }),
244
+ )
245
+ try {
246
+ const result = await refactorFirst(given, { reference: options.reference })
247
+ return { steps, scopes, result }
248
+ } catch (error) {
249
+ return { steps, scopes, result: error as Error }
250
+ }
251
+ }
252
+
253
+ const names = (h: Harness): string[] => h.steps.map((s) => ("name" in s ? s.name : s.kind))
254
+
255
+ describe("refactorFirst", () => {
256
+ it.each([
257
+ ["land", shape({ choice: "land" })],
258
+ ["reject", shape({ choice: "reject" })],
259
+ ["refactor-first with unreadable strays", shape({ unreadable: ["src/a.ts"] })],
260
+ ])("resolves false with no step for %s", async (_label, given) => {
261
+ const h = await drive(given)
262
+ expect(h.result).toBe(false)
263
+ expect(h.steps).toEqual([])
264
+ })
265
+
266
+ it("throws before any step when a stray holds a flag read the pattern matches zero-length", async () => {
267
+ const h = await drive(shape(), {
268
+ text: {
269
+ scope: "refactor",
270
+ read: (path) => (path === "src/a.ts" ? "GTD_FLAG_X" : ""),
271
+ vars: { refactorFlagPattern: "(?=GTD_FLAG_\\w+)" },
272
+ },
273
+ })
274
+ expect(h.result).toBeInstanceOf(Error)
275
+ expect((h.result as Error).message).toContain("refactorFlagPattern")
276
+ expect((h.result as Error).message).toContain(" - src/a.ts")
277
+ expect(h.steps).toEqual([])
278
+ })
279
+
280
+ it("derives, builds, checks and closes on a green run, resolving true", async () => {
281
+ const h = await drive(shape())
282
+ expect(h.result).toBe(true)
283
+ expect(names(h)).toEqual(["deriving", "building", "health.check", "closing"])
284
+ const closing = h.steps.at(-1)
285
+ expect(closing).toMatchObject({ kind: "run" })
286
+ const body = closing?.kind === "run" ? closing.body : ""
287
+ expect(body).toContain(REFACTOR_PACKAGE)
288
+ })
289
+
290
+ it("writes a package whose scope lines equal the strays, in order, and marks them read-only", async () => {
291
+ const h = await drive(shape({ strays: ["src/z.ts", "src/a.ts", "it's.ts"] }))
292
+ const deriving = h.steps[0]
293
+ const body = deriving?.kind === "run" ? deriving.body : ""
294
+ expect(body).toContain(REFACTOR_PACKAGE)
295
+ expect(body).not.toContain("<<")
296
+ expect(body).toContain("## Scope (read-only)")
297
+ expect(body).toContain("## Rules")
298
+ expect(body).toContain("## Reference")
299
+ // shell-unquote enough to read the printf arguments back
300
+ const words = [...body.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) =>
301
+ m[1]!.replaceAll("'\\''", "'"),
302
+ )
303
+ const lines = words.filter((w) => w.startsWith("- `"))
304
+ expect(lines).toEqual(["- `src/z.ts`", "- `src/a.ts`", "- `it's.ts`"]) // gtd-path-exempt: fixture paths
305
+ })
306
+
307
+ it("runs inside a refactor scope whose write access is the strays plus .gtd/**", async () => {
308
+ const h = await drive(shape())
309
+ expect(h.scopes).toEqual([
310
+ { name: "refactor", access: { write: ["src/a.ts", "src/b.ts", ".gtd/**"] } },
311
+ ])
312
+ })
313
+
314
+ const bodyOf = (h: Harness, index: number): string => {
315
+ const step = h.steps[index]
316
+ return step?.kind === "run" ? step.body : ""
317
+ }
318
+ const buildingPrompt = (h: Harness): string => {
319
+ const step = h.steps.find((s) => s.kind === "agent")
320
+ return step?.kind === "agent" ? step.prompt : ""
321
+ }
322
+
323
+ it("names the reference hash as reference only, in prompt and package file", async () => {
324
+ const h = await drive(shape(), { reference: "abc1234" })
325
+ expect(buildingPrompt(h)).toContain("git show abc1234 -- ")
326
+ expect(buildingPrompt(h)).toMatch(/never apply/i)
327
+ expect(bodyOf(h, 0)).toContain("git show abc1234 -- '\\''src/a.ts'\\'' '\\''src/b.ts'\\''")
328
+ })
329
+
330
+ it.each([undefined, "", " "])("omits the reference for %j", async (reference) => {
331
+ const h = await drive(shape(), reference === undefined ? {} : { reference })
332
+ expect(buildingPrompt(h)).not.toContain("git show")
333
+ expect(bodyOf(h, 0)).toContain("None.")
334
+ })
335
+
336
+ it("refuses a build turn that touches a path outside the strays", async () => {
337
+ const h = await drive(shape(), { build: [change("lib/other.ts", "a", "b")] })
338
+ expect(h.result).toBeInstanceOf(Error)
339
+ expect((h.result as Error).message).toContain("lib/other.ts")
340
+ expect(names(h)).not.toContain("health.check")
341
+ })
342
+
343
+ it("refuses a build turn adding a flag read", async () => {
344
+ const h = await drive(shape(), { build: [change("src/a.ts", "x", "GTD_FLAG_NEW")] })
345
+ expect((h.result as Error).message).toContain("GTD_FLAG_NEW")
346
+ })
347
+
348
+ it("refuses a fix turn outside the strays", async () => {
349
+ const h = await drive(shape(), { reds: 1, fix: [change("lib/other.ts", "a", "b")] })
350
+ expect(names(h)).toContain("fix.suite.fixing")
351
+ expect((h.result as Error).message).toContain("lib/other.ts")
352
+ })
353
+
354
+ it("refuses a fix turn adding a flag read", async () => {
355
+ const h = await drive(shape(), {
356
+ reds: 1,
357
+ fix: [change("src/a.ts", "x", "GTD_FLAG_NEW")],
358
+ })
359
+ expect(names(h)).toContain("fix.suite.fixing")
360
+ expect((h.result as Error).message).toContain("GTD_FLAG_NEW")
361
+ })
362
+
363
+ it("judges a fix turn on its own changes: an earlier non-stray edit does not wedge it", async () => {
364
+ const h = await drive(shape(), {
365
+ reds: 1,
366
+ fix: [change("lib/human.ts", "a", "b")],
367
+ fixTurn: [change("src/a.ts", "a", "b")],
368
+ })
369
+ expect(h.result).toBe(true)
370
+ })
371
+
372
+ it("loads ponytail on the building and fix turns, and a config.skills entry replaces it", async () => {
373
+ const h = await drive(shape(), { reds: 1 })
374
+ const agents = h.steps.filter((s) => s.kind === "agent")
375
+ expect(agents.map((s) => s.name)).toEqual(["building", "fix.suite.fixing"])
376
+ for (const a of agents) expect(a.kind === "agent" ? a.prompt : "").toContain("ponytail")
377
+
378
+ const custom = await drive(shape(), {
379
+ reds: 1,
380
+ text: {
381
+ scope: "refactor",
382
+ skills: { refactor: ["my-skill"], "refactor.fix.suite": ["my-fix"] },
383
+ },
384
+ })
385
+ const prompts = custom.steps.flatMap((s) => (s.kind === "agent" ? [s.prompt] : []))
386
+ expect(prompts[0]).toContain("my-skill")
387
+ expect(prompts[0]).not.toContain("ponytail")
388
+ expect(prompts[1]).toContain("my-fix")
389
+ })
390
+
391
+ it.each(["", " ", "(", "[a-", "x?", "(GTD_FLAG_\\w+)?"])(
392
+ "throws naming refactorFlagPattern for %j, before any step",
393
+ async (bad) => {
394
+ const h = await drive(shape(), {
395
+ text: { scope: "refactor", vars: { refactorFlagPattern: bad } },
396
+ })
397
+ expect(h.result).toBeInstanceOf(Error)
398
+ expect((h.result as Error).message).toContain("refactorFlagPattern")
399
+ expect(h.steps).toEqual([])
400
+ },
401
+ )
402
+
403
+ it("falls back to the default pattern without the setting", async () => {
404
+ const h = await drive(shape(), {
405
+ text: { scope: "refactor", vars: {} },
406
+ build: [change("src/a.ts", "", "GTD_FLAG_N")],
407
+ })
408
+ expect((h.result as Error).message).toContain("GTD_FLAG_N")
409
+ })
410
+
411
+ it("lets a custom pattern replace the default", async () => {
412
+ const text = { scope: "refactor", vars: { refactorFlagPattern: "\\bFEATURE_\\w+" } }
413
+ const ignored = await drive(shape(), { text, build: [change("src/a.ts", "", "GTD_FLAG_N")] })
414
+ expect(ignored.result).toBe(true)
415
+ const caught = await drive(shape(), { text, build: [change("src/a.ts", "", "FEATURE_N")] })
416
+ expect((caught.result as Error).message).toContain("FEATURE_N")
417
+ })
418
+
419
+ describe("escalation", () => {
420
+ // Four reds put the run at `health.describe` (the kit owns the count).
421
+ const escalated = { reds: 4 }
422
+ // Live, the write access refuses a non-stray first; this pins the guard itself.
423
+ const exhausted = { reds: 12 }
424
+
425
+ it("reaches the describe turn", async () => {
426
+ const h = await drive(shape(), escalated)
427
+ expect(names(h)).toContain("health.describe")
428
+ expect(h.result).toBe(true)
429
+ })
430
+
431
+ it("refuses a describe turn outside the strays, naming the path", async () => {
432
+ const h = await drive(shape(), { ...escalated, describe: [change("lib/other.ts", "a", "b")] })
433
+ expect((h.result as Error).message).toContain("lib/other.ts")
434
+ expect(names(h)).not.toContain("health.stop")
435
+ })
436
+
437
+ it("refuses a describe turn adding a flag read, naming the flag", async () => {
438
+ const h = await drive(shape(), {
439
+ ...escalated,
440
+ describe: [change("src/a.ts", "x", "GTD_FLAG_NEW")],
441
+ })
442
+ expect((h.result as Error).message).toContain("GTD_FLAG_NEW")
443
+ expect(names(h)).not.toContain("health.stop")
444
+ })
445
+
446
+ it("never refuses a human edit at health.stop", async () => {
447
+ const h = await drive(shape(), { ...escalated, stop: [change("lib/human.ts", "a", "b")] })
448
+ expect(names(h)).toContain("health.stop")
449
+ expect(h.result).toBe(true)
450
+ })
451
+
452
+ it("never refuses a human edit at health.exhausted", async () => {
453
+ const h = await drive(shape(), { ...exhausted, stop: [change("lib/human.ts", "a", "b")] })
454
+ expect(names(h)).toContain("health.exhausted")
455
+ expect(h.result).toBe(true)
456
+ })
457
+
458
+ it("refuses the next fix turn for a flag read a person added at the rest", async () => {
459
+ const h = await drive(shape(), {
460
+ ...escalated,
461
+ stop: [change("lib/human.ts", "a", "a GTD_FLAG_NEW")],
462
+ })
463
+ expect((h.result as Error).message).toContain("GTD_FLAG_NEW")
464
+ })
465
+ })
466
+ })