@pmelab/gtd 27.2.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.
@@ -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
+ })
@@ -0,0 +1,178 @@
1
+ import {
2
+ changes,
3
+ changesSince,
4
+ head,
5
+ quote,
6
+ read,
7
+ refuse,
8
+ removeScript,
9
+ run,
10
+ scope,
11
+ vars,
12
+ type DiffShapeResult,
13
+ } from "../../flows/index.js"
14
+ import { healthy } from "../shared/health.js"
15
+ import { coder, FEEDBACK } from "../shared/steps.js"
16
+ import * as t from "../shared/text.js"
17
+ import { defaults } from "../shared/vars.js"
18
+
19
+ export const REFACTOR_PACKAGE = ".gtd/REFACTOR.md"
20
+
21
+ export interface RefactorOptions {
22
+ /** Blank counts as absent: `kept()` reports `""` while the keeping turn is pending. */
23
+ readonly reference?: string | undefined
24
+ }
25
+
26
+ const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
27
+
28
+ export const requireWithin = (allowed: readonly string[]): void => {
29
+ const outside = changes()
30
+ .map((c) => c.path)
31
+ .filter((path) => isCode(path) && !allowed.includes(path))
32
+ if (outside.length === 0) return
33
+ refuse(
34
+ `gtd land: refactor-scope: the refactor may change only its stray paths — revert these:\n${outside.map((path) => ` - ${path}`).join("\n")}`,
35
+ )
36
+ }
37
+
38
+ const flagExpression = (pattern: string): RegExp => {
39
+ if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank")
40
+ let expression: RegExp
41
+ try {
42
+ expression = new RegExp(pattern, "g")
43
+ } catch (error) {
44
+ throw new Error(`gtd: refactorFlagPattern is not a valid regular expression: ${String(error)}`)
45
+ }
46
+ // An empty match would count the name "" at every character.
47
+ if (expression.test(""))
48
+ throw new Error("gtd: refactorFlagPattern must not match the empty string")
49
+ return expression
50
+ }
51
+
52
+ // A zero-length match is never counted: a lookahead-only pattern would count every read
53
+ // under "" and let a flag swap pass. Checked on the strays up front and again at landing.
54
+ const countFlags = (text: string, expression: RegExp, into: Map<string, number>): boolean => {
55
+ let zeroLength = false
56
+ for (const [name] of text.matchAll(expression)) {
57
+ if (name === "") zeroLength = true
58
+ else into.set(name, (into.get(name) ?? 0) + 1)
59
+ }
60
+ return zeroLength
61
+ }
62
+
63
+ const listPaths = (paths: readonly string[]): string =>
64
+ paths.map((path) => ` - ${path}`).join("\n")
65
+
66
+ // Counts per name, so a read moved between files passes.
67
+ export const requireNoNewFlags = (pattern: string, since: string): void => {
68
+ const expression = flagExpression(pattern)
69
+ const before = new Map<string, number>()
70
+ const after = new Map<string, number>()
71
+ const unreadable: string[] = []
72
+ const zeroLength: string[] = []
73
+ for (const { path, status, before: was, after: now } of changesSince(since)) {
74
+ if (!isCode(path)) continue
75
+ // A side absent by status is empty; one absent otherwise could not be read.
76
+ const oldSide = status === "added" ? "" : was
77
+ const newSide = status === "deleted" ? "" : now
78
+ if (oldSide === undefined || newSide === undefined) {
79
+ unreadable.push(path)
80
+ continue
81
+ }
82
+ countFlags(oldSide, expression, before)
83
+ if (countFlags(newSide, expression, after)) zeroLength.push(path)
84
+ }
85
+ if (unreadable.length > 0)
86
+ refuse(`gtd land: refactor-flags: cannot compare flag reads in:\n${listPaths(unreadable)}`)
87
+ if (zeroLength.length > 0)
88
+ refuse(
89
+ `gtd land: refactor-flags: refactorFlagPattern (${pattern}) produced a zero-length match in:\n${listPaths(zeroLength)}`,
90
+ )
91
+ const raised = [...after].filter(([name, count]) => count > (before.get(name) ?? 0))
92
+ if (raised.length === 0) return
93
+ refuse(
94
+ `gtd land: refactor-flags: the refactor may not introduce or add reads of a flag (${pattern}):\n${raised.map(([name, count]) => ` - ${name}: ${before.get(name) ?? 0} → ${count}`).join("\n")}`,
95
+ )
96
+ }
97
+
98
+ const packageLines = (
99
+ strays: readonly string[],
100
+ pattern: string,
101
+ reference: string | undefined,
102
+ ): string[] => [
103
+ "# Behaviour-neutral refactor",
104
+ "",
105
+ "## Scope (read-only)",
106
+ "",
107
+ ...strays.map((path) => `- \`${path}\``),
108
+ "",
109
+ "This list is fixed when the package is derived: editing it here widens or narrows nothing.",
110
+ "",
111
+ "## Rules",
112
+ "",
113
+ "- The suite's outcome must not change",
114
+ "- Change only the paths above; a test file only when it is one of them",
115
+ `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
116
+ "",
117
+ "## Reference",
118
+ "",
119
+ reference === undefined
120
+ ? "None."
121
+ : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
122
+ "",
123
+ ]
124
+
125
+ const deriveScript = (lines: readonly string[]): string =>
126
+ `mkdir -p .gtd
127
+ printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(REFACTOR_PACKAGE)}
128
+ `
129
+
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
135
+ const pattern = vars.refactorFlagPattern ?? defaults.refactorFlagPattern!
136
+ const expression = flagExpression(pattern)
137
+ const { strays } = shape
138
+ const zeroLength = strays.filter((path) => countFlags(read(path) ?? "", expression, new Map()))
139
+ if (zeroLength.length > 0)
140
+ throw new Error(
141
+ `gtd: refactorFlagPattern (${pattern}) produces a zero-length match in:\n${listPaths(zeroLength)}`,
142
+ )
143
+ 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",
147
+ 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)
156
+ }
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
+ })
178
+ }
@@ -22,6 +22,7 @@ export { lensesOf } from "../workflow-kit/index.js"
22
22
  export * from "./feature/steps.js"
23
23
  export * from "./feature/planning.js"
24
24
  export * from "./feature/packages.js"
25
+ export * from "./feature/refactor.js"
25
26
  export { buildTail, type ReviewOutcome } from "./feature/review.js"
26
27
  export * from "./feature/feature.js"
27
28
  export { base, review, reviewFlow } from "./review/review.js"
@@ -3,7 +3,8 @@ import { lensesOf, lensScopes } from "../../workflow-kit/index.js"
3
3
 
4
4
  // The bundled workflow's default file access, keyed by scope full name — the
5
5
  // name a `config.access` entry addresses. Only planning and review scopes
6
- // are restricted, and only on writes; no scope restricts reads. A step's own
6
+ // are restricted, and only on writes; no scope restricts reads. `refactor`'s
7
+ // write restriction lives in its own `scope()` option, not here. A step's own
7
8
  // steering file is folded in per step, so it is not listed here. Build and fix
8
9
  // scopes carry no entry: no project layout is assumed.
9
10
  export const access = (