@pmelab/gtd 32.0.0 → 33.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 (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -22
  3. package/dist/chunks/{reserved-CHTJTzpA.mjs → reserved-BU5Zi-nX.mjs} +74 -13
  4. package/dist/chunks/{tail-CCsDBbZe.mjs → tail-DuXanjIY.mjs} +6 -3
  5. package/dist/testkit.mjs +2 -2
  6. package/dist/workflow-kit.mjs +15 -3
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +158 -34
  9. package/dist/workflows/fix.mjs +2 -2
  10. package/dist/workflows/review.mjs +2 -2
  11. package/dist/workflows/rollout.mjs +1 -1
  12. package/dist/workflows.mjs +246 -46
  13. package/package.json +1 -1
  14. package/src/workflow-kit/quality.test.ts +25 -1
  15. package/src/workflow-kit/quality.ts +22 -4
  16. package/src/workflows/feature/acceptance-criteria.feature +8 -0
  17. package/src/workflows/feature/fast-path.feature +8 -2
  18. package/src/workflows/feature/feature.test.ts +19 -9
  19. package/src/workflows/feature/lens-models.feature +60 -0
  20. package/src/workflows/feature/naive-acceptance.feature +140 -0
  21. package/src/workflows/feature/one-step-land.feature +8 -2
  22. package/src/workflows/feature/refactor-absorbs.feature +6 -1
  23. package/src/workflows/feature/refactor-first.feature +9 -3
  24. package/src/workflows/feature/refactor-lens-models.feature +67 -0
  25. package/src/workflows/feature/refactor-review.feature +50 -5
  26. package/src/workflows/feature/refactor.ts +6 -5
  27. package/src/workflows/feature/risk-tier.feature +10 -0
  28. package/src/workflows/feature/seam-rejection.feature +19 -6
  29. package/src/workflows/feature/seam.feature +70 -16
  30. package/src/workflows/feature/seam.test.ts +189 -3
  31. package/src/workflows/feature/seam.ts +195 -30
  32. package/src/workflows/feature/steps.test.ts +13 -0
  33. package/src/workflows/index.ts +1 -1
  34. package/src/workflows/shared/skills.test.ts +7 -0
  35. package/src/workflows/shared/steps.test.ts +45 -0
  36. package/src/workflows/shared/steps.ts +4 -2
  37. package/src/workflows/shared/tail.test.ts +31 -1
  38. package/src/workflows/shared/tail.ts +6 -3
  39. package/src/workflows/shared/text.test.ts +40 -0
  40. package/src/workflows/shared/text.ts +97 -16
@@ -51,7 +51,12 @@ Feature: the naive pass and seam judge decide how a feature is built
51
51
  And step "design.gate.answer" changes nothing
52
52
 
53
53
  Scenario: a land judgment goes straight to the implementation with no seam gate or refactor
54
- Given step "seam.naive" writes "src/calc.ts" with:
54
+ Given step "seam.check" changes nothing
55
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
56
+ """
57
+ it("[AC-1] holds", () => {})
58
+ """
59
+ And step "seam.naive" writes "src/calc.ts" with:
55
60
  """
56
61
  export const add = (a: number, b: number) => a + b
57
62
  export const subtract = (a: number, b: number) => a - b
@@ -72,12 +77,18 @@ Feature: the naive pass and seam judge decide how a feature is built
72
77
  gtd(check): start-gate.check → design.triage
73
78
  gtd(agent): design.triage → design.gate.answer
74
79
  gtd(human): design.gate.answer → seam.naive
75
- gtd(agent): seam.naive → seam.judge
80
+ gtd(agent): seam.naive → seam.check
81
+ gtd(check): seam.check → seam.judge
76
82
  gtd(judge): seam.judge → implement.building
77
83
  """
78
84
 
79
85
  Scenario: config skills for the implement scope reach the implement step
80
- Given step "seam.naive" writes "src/calc.ts" with:
86
+ Given step "seam.check" changes nothing
87
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
88
+ """
89
+ it("[AC-1] holds", () => {})
90
+ """
91
+ And step "seam.naive" writes "src/calc.ts" with:
81
92
  """
82
93
  export const add = (a: number, b: number) => a + b
83
94
  export const subtract = (a: number, b: number) => a - b
@@ -91,19 +102,33 @@ Feature: the naive pass and seam judge decide how a feature is built
91
102
  And the resting step's content contains "my-tdd, my-runbook"
92
103
 
93
104
  Scenario: the naive pass lands as a marker commit and its turn stays off the branch
94
- Given step "seam.naive" writes "src/calc.ts" with:
105
+ Given step "seam.check" changes nothing
106
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
107
+ """
108
+ it("[AC-1] holds", () => {})
109
+ """
110
+ And step "seam.naive" writes "src/calc.ts" with:
95
111
  """
96
112
  export const add = (a: number, b: number) => a + b
97
113
  export const subtract = (a: number, b: number) => a - b
98
114
  """
99
115
  When the process is driven
100
116
  Then the process rests at "seam.judge"
101
- And the last commit subject is "gtd(agent): seam.naive → seam.judge"
102
- And the last commit body contains "Gtd-Kept:"
117
+ And the commit subjects end with:
118
+ """
119
+ gtd(agent): seam.naive → seam.check
120
+ gtd(check): seam.check → seam.judge
121
+ """
122
+ And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
103
123
  And "src/calc.ts" does not contain "subtract"
104
124
 
105
125
  Scenario: the implementation is one build turn, a green check, then the requirements are closed out
106
- Given step "seam.naive" writes "src/calc.ts" with:
126
+ Given step "seam.check" changes nothing
127
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
128
+ """
129
+ it("[AC-1] holds", () => {})
130
+ """
131
+ And step "seam.naive" writes "src/calc.ts" with:
107
132
  """
108
133
  export const add = (a: number, b: number) => a + b
109
134
  export const subtract = (a: number, b: number) => a - b
@@ -132,7 +157,12 @@ Feature: the naive pass and seam judge decide how a feature is built
132
157
  And ".gtd/REQUIREMENTS.md" does not exist
133
158
 
134
159
  Scenario: a naive pass modifying a test file and adding a new one skips the judge
135
- Given step "seam.naive" writes "src/subtract.ts" with:
160
+ Given step "seam.check" changes nothing
161
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
162
+ """
163
+ it("[AC-1] holds", () => {})
164
+ """
165
+ And step "seam.naive" writes "src/subtract.ts" with:
136
166
  """
137
167
  export const subtract = (a: number, b: number) => a - b
138
168
  """
@@ -146,11 +176,17 @@ Feature: the naive pass and seam judge decide how a feature is built
146
176
  And the commit subjects end with:
147
177
  """
148
178
  gtd(human): design.gate.answer → seam.naive
149
- gtd(agent): seam.naive → implement.building
179
+ gtd(agent): seam.naive → seam.check
180
+ gtd(check): seam.check → implement.building
150
181
  """
151
182
 
152
183
  Scenario: a naive pass adding a glob-named file does not hide a modified sibling
153
- Given step "seam.naive" writes "src/calc*.ts" with:
184
+ Given step "seam.check" changes nothing
185
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
186
+ """
187
+ it("[AC-1] holds", () => {})
188
+ """
189
+ And step "seam.naive" writes "src/calc*.ts" with:
154
190
  """
155
191
  export const subtract = (a: number, b: number) => a - b
156
192
  """
@@ -169,7 +205,12 @@ Feature: the naive pass and seam judge decide how a feature is built
169
205
  And the resting step's content does not contain "src/calc*.ts"
170
206
 
171
207
  Scenario: an edit at the steer gate runs the naive pass again with a fresh retry budget
172
- Given step "seam.naive" writes "src/calc.ts" with:
208
+ Given step "seam.check" changes nothing
209
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
210
+ """
211
+ it("[AC-1] holds", () => {})
212
+ """
213
+ And step "seam.naive" writes "src/calc.ts" with:
173
214
  """
174
215
  export const add = (a: number, b: number) => b + a
175
216
  """
@@ -201,16 +242,24 @@ Feature: the naive pass and seam judge decide how a feature is built
201
242
  And the commit subjects end with:
202
243
  """
203
244
  gtd(human): seam.steer → seam.naive
204
- gtd(agent): seam.naive → seam.judge
245
+ gtd(agent): seam.naive → seam.check
246
+ gtd(check): seam.check → seam.judge
205
247
  gtd(judge): seam.judge → seam.naive
206
- gtd(agent): seam.naive → seam.judge
248
+ gtd(agent): seam.naive → seam.check
249
+ gtd(check): seam.check → seam.judge
207
250
  gtd(judge): seam.judge → seam.naive
208
- gtd(agent): seam.naive → seam.judge
251
+ gtd(agent): seam.naive → seam.check
252
+ gtd(check): seam.check → seam.judge
209
253
  gtd(judge): seam.judge → seam.steer
210
254
  """
211
255
 
212
256
  Scenario: a clean landing at the steer gate overrules, with no seam gate and no refactor
213
- Given step "seam.naive" writes "src/calc.ts" with:
257
+ Given step "seam.check" changes nothing
258
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
259
+ """
260
+ it("[AC-1] holds", () => {})
261
+ """
262
+ And step "seam.naive" writes "src/calc.ts" with:
214
263
  """
215
264
  export const add = (a: number, b: number) => b + a
216
265
  """
@@ -229,7 +278,12 @@ Feature: the naive pass and seam judge decide how a feature is built
229
278
  """
230
279
 
231
280
  Scenario: review feedback after land re-runs design and implement, never the seam phase
232
- Given step "seam.naive" writes "src/calc.ts" with:
281
+ Given step "seam.check" changes nothing
282
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
283
+ """
284
+ it("[AC-1] holds", () => {})
285
+ """
286
+ And step "seam.naive" writes "src/calc.ts" with:
233
287
  """
234
288
  export const add = (a: number, b: number) => b + a
235
289
  """
@@ -8,7 +8,14 @@ import {
8
8
  } from "../../flows/index.js"
9
9
  import { globMatches } from "../../wire/index.js"
10
10
  import { fixtureContext } from "../shared/text.fixture.js"
11
- import { implement, judgeSeam, naivePass, seamBuild, type SeamOutcome } from "./seam.js"
11
+ import {
12
+ implement,
13
+ judgeSeam,
14
+ naivePass,
15
+ acceptanceFault,
16
+ seamBuild,
17
+ type SeamOutcome,
18
+ } from "./seam.js"
12
19
  import { captureStep } from "../shared/text.fixture.js"
13
20
 
14
21
  afterEach(() => installContext(undefined))
@@ -79,6 +86,17 @@ describe("naivePass", () => {
79
86
  expect(request.options).toMatchObject({ keep: true })
80
87
  })
81
88
 
89
+ it("runs on the coder model, base by default, the coderModel setting otherwise", async () => {
90
+ const plain = await capture()
91
+ const set = await captureStep(() => naivePass(), {
92
+ scope: "seam",
93
+ env: { coderModel: "sonnet" },
94
+ })
95
+ if (plain.kind !== "agent" || set.kind !== "agent") throw new Error("unreachable")
96
+ expect(plain.options.model).toBe("base")
97
+ expect(set.options.model).toBe("sonnet")
98
+ })
99
+
82
100
  it("carries the rejection into the retry prompt", async () => {
83
101
  const request = await capture({ strays: ["src/core.ts"], p: 0.8 })
84
102
  if (request.kind !== "agent") throw new Error("unreachable")
@@ -87,6 +105,130 @@ describe("naivePass", () => {
87
105
  })
88
106
  })
89
107
 
108
+ describe("acceptanceFault", () => {
109
+ const requirements = "# R\n\n## Acceptance Criteria\n\n- AC-1: adds.\n- AC-2: subtracts.\n"
110
+ const file = (path: string, status: Change["status"], after: string): Change => ({
111
+ path,
112
+ status,
113
+ before: status === "added" ? undefined : "",
114
+ after,
115
+ })
116
+ const refusal = (...changes: Change[]): string | undefined => {
117
+ installContext({
118
+ ...fixtureContext({}),
119
+ matches: globMatches,
120
+ read: (path: string) => (path === ".gtd/REQUIREMENTS.md" ? requirements : undefined),
121
+ } as unknown as FlowContext)
122
+ return acceptanceFault(".gtd/REQUIREMENTS.md", tip(...changes))
123
+ }
124
+ const tagged = 'import { add } from "../src/calc.js"\nit("[AC-1] adds")\nit("[AC-2] subtracts")'
125
+
126
+ it("passes when every ID is named in a test the pass wrote", () => {
127
+ expect(
128
+ refusal(file("src/calc.ts", "modified", "x"), file("tests/calc.test.ts", "added", tagged)),
129
+ ).toBe(undefined)
130
+ })
131
+
132
+ it("names every ID no test of the pass mentions", () => {
133
+ const message = refusal(file("tests/calc.test.ts", "added", 'it("[AC-12] x")'))
134
+ expect(message).toMatch(/^acceptance-tests: /)
135
+ expect(message).toContain("AC-1, AC-2")
136
+ })
137
+
138
+ it("does not count an ID in a non-test file", () => {
139
+ expect(refusal(file("src/calc.ts", "modified", "AC-1 AC-2"))).toContain("AC-1, AC-2")
140
+ })
141
+
142
+ it("refuses a test importing a file the pass added, resolved from the test's directory", () => {
143
+ const message = refusal(
144
+ file("src/naive/helper.ts", "added", "export const h = 1"),
145
+ file("src/naive/helper.test.ts", "added", `import { h } from "./helper.js"\n${tagged}`),
146
+ )
147
+ expect(message).toMatch(/^acceptance-internals: /)
148
+ expect(message).toContain("src/naive/helper.test.ts → src/naive/helper.ts")
149
+ })
150
+
151
+ it("refuses an aliased or directory import of an added module", () => {
152
+ const added = file("src/naive/index.ts", "added", "")
153
+ expect(
154
+ refusal(added, file("tests/a.test.ts", "added", `import "@/naive"\n${tagged}`)),
155
+ ).toContain("acceptance-internals")
156
+ expect(
157
+ refusal(added, file("tests/a.test.ts", "added", `import "../src/naive/index.js"\n${tagged}`)),
158
+ ).toContain("acceptance-internals")
159
+ })
160
+
161
+ it("counts only the lines the pass added: an ID an earlier feature left does not", () => {
162
+ const old = 'it("[AC-1] old")\nit("[AC-2] old")'
163
+ const message = refusal({
164
+ path: "tests/calc.test.ts",
165
+ status: "modified",
166
+ before: old,
167
+ after: `${old}\nit("[AC-2] new")`,
168
+ })
169
+ expect(message).toContain("names AC-1 —")
170
+ })
171
+
172
+ it("refuses a bare . or .. import of an added index", () => {
173
+ expect(
174
+ refusal(
175
+ file("src/naive/index.ts", "added", ""),
176
+ file("src/naive/__tests__/a.test.ts", "added", `import { x } from ".."\n${tagged}`),
177
+ ),
178
+ ).toContain("acceptance-internals")
179
+ })
180
+
181
+ it("reads a URL route or path-like data as no import", () => {
182
+ expect(
183
+ refusal(
184
+ file("src/routes/users.ts", "added", ""),
185
+ file(
186
+ "tests/a.test.ts",
187
+ "added",
188
+ `request(app).get("/users")\nrun("src/routes/users.ts")\n${tagged}`,
189
+ ),
190
+ ),
191
+ ).toBe(undefined)
192
+ })
193
+
194
+ it("reads no import out of a .feature file", () => {
195
+ expect(
196
+ refusal(
197
+ file("src/subtract.ts", "added", ""),
198
+ file("features/calc.feature", "added", `Given a file "src/subtract.ts"\n${tagged}`),
199
+ ),
200
+ ).toBe(undefined)
201
+ })
202
+
203
+ it("skips commented-out imports and takes a package subpath as no repository path", () => {
204
+ expect(
205
+ refusal(
206
+ file("src/naive/helper.ts", "added", ""),
207
+ file("src/date-fns/format.ts", "added", ""),
208
+ file(
209
+ "src/naive/a.test.ts",
210
+ "added",
211
+ `// import { h } from "./helper.js"\nimport { format } from "date-fns/format"\n${tagged}`,
212
+ ),
213
+ ),
214
+ ).toBe(undefined)
215
+ })
216
+
217
+ it("lets a test reach an existing module, a same-named file elsewhere, or a URL", () => {
218
+ expect(
219
+ refusal(
220
+ file("src/naive/api.ts", "added", ""),
221
+ file("src/calc.ts", "modified", ""),
222
+ file(
223
+ "tests/a.test.ts",
224
+ "added",
225
+ `import "../src/calc.js"\nimport "./api.js"\nfetch("https://x/api")\n${tagged}`,
226
+ ),
227
+ ),
228
+ ).toBe(undefined)
229
+ })
230
+ })
231
+
90
232
  const REVIEW = ".gtd/REVIEW.md"
91
233
  const REFACTOR = ".gtd/REFACTOR.md"
92
234
  const reviewDoc = [
@@ -113,7 +255,10 @@ const driveSeam = async (
113
255
  answer: string,
114
256
  stray: Change = change("src/a.ts", "modified"),
115
257
  vars: Record<string, string> = {},
258
+ reds = 0,
116
259
  ): Promise<Seamed> => {
260
+ let red = false
261
+ let redsLeft = reds
117
262
  const names: string[] = []
118
263
  const slices: string[] = []
119
264
  const inside: Record<string, string[]> = {}
@@ -147,11 +292,17 @@ const driveSeam = async (
147
292
  read: (path: string) => files.get(path),
148
293
  head: () => current,
149
294
  kept: () => tip(stray),
150
- changes: () => [],
295
+ changes: () => (red ? [change(".gtd/FEEDBACK.md", "added")] : []),
151
296
  changesSince: () => [],
152
297
  step: (request: StepRequest) => {
153
298
  if (request.kind === "restart") return Promise.resolve()
154
299
  const full = [...stack, request.name].join(".")
300
+ red = full === "seam.check" && redsLeft-- > 0
301
+ if (full === "seam.check") {
302
+ if (red) files.set(".gtd/FEEDBACK.md", "FAIL")
303
+ else files.delete(".gtd/FEEDBACK.md")
304
+ }
305
+ if (full === "seam.clear") files.delete(".gtd/FEEDBACK.md")
155
306
  names.push(full)
156
307
  for (const name of open) inside[name]!.push(full)
157
308
  effects[request.name]?.()
@@ -174,7 +325,37 @@ describe("seamBuild", () => {
174
325
  const h = await driveSeam("land")
175
326
  expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
176
327
  expect(h.slices).toEqual([])
177
- expect(h.names).toEqual(["seam.naive", "seam.judge"])
328
+ expect(h.names).toEqual(["seam.naive", "seam.check", "seam.judge"])
329
+ })
330
+
331
+ it("a red check retries the pass, and the judge sees it only once green", async () => {
332
+ const h = await driveSeam("land", undefined, {}, 1)
333
+ expect(h.names).toEqual(["seam.naive", "seam.check", "seam.naive", "seam.check", "seam.judge"])
334
+ })
335
+
336
+ it("an overruled red pass gets a fresh budget for the judge's rejects", async () => {
337
+ const h = await driveSeam("reject", undefined, {}, 3)
338
+ const steers = h.names.flatMap((name, i) => (name === "seam.steer" ? [i] : []))
339
+ expect(steers).toHaveLength(2)
340
+ expect(h.names.slice(steers[0]! + 1, steers[1]).filter((n) => n === "seam.judge")).toHaveLength(
341
+ 3,
342
+ )
343
+ })
344
+
345
+ it("a third red rests at seam.steer, and a clean landing there takes the red pass to the judge", async () => {
346
+ const h = await driveSeam("land", undefined, {}, 3)
347
+ expect(h.names).toEqual([
348
+ "seam.naive",
349
+ "seam.check",
350
+ "seam.naive",
351
+ "seam.check",
352
+ "seam.naive",
353
+ "seam.check",
354
+ "seam.steer",
355
+ "seam.clear",
356
+ "seam.judge",
357
+ ])
358
+ expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
178
359
  })
179
360
 
180
361
  it("refactor-first runs the reviewed refactor in slice refactor and resolves HEAD after it", async () => {
@@ -251,6 +432,11 @@ describe("implement", () => {
251
432
  return request.prompt
252
433
  }
253
434
 
435
+ it("builds on the coder model", async () => {
436
+ await building("abc")
437
+ expect(lastOptions?.model).toBe("base")
438
+ })
439
+
254
440
  it("after a refactor, lets an empty turn complete the step and says so", async () => {
255
441
  const prompt = await building("abc", undefined, undefined, () =>
256
442
  implement({ reference: "abc", afterRefactor: true }),
@@ -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,34 +223,79 @@ 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