@pmelab/gtd 53.0.0 → 55.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +56 -50
  3. package/dist/chunks/{reserved-CQ7amcKG.mjs → reserved-DHafcW7t.mjs} +47 -27
  4. package/dist/chunks/{review-CNgN_V8T.mjs → review-BJGKwlYv.mjs} +12 -5
  5. package/dist/chunks/{tail-JZ9PAOwp.mjs → tail-B9Hw2AFB.mjs} +12 -8
  6. package/dist/workflows/cleanup.mjs +2 -2
  7. package/dist/workflows/feature.mjs +41 -94
  8. package/dist/workflows/fix.mjs +2 -2
  9. package/dist/workflows/review.mjs +3 -3
  10. package/dist/workflows/rollout.mjs +1 -1
  11. package/dist/workflows.mjs +107 -129
  12. package/package.json +1 -1
  13. package/src/testkit/steps.ts +23 -0
  14. package/src/workflows/feature/criteria-coverage.feature +109 -0
  15. package/src/workflows/feature/feature.ts +9 -4
  16. package/src/workflows/feature/green.ts +2 -5
  17. package/src/workflows/feature/lens-review.feature +50 -0
  18. package/src/workflows/feature/naive-acceptance.feature +31 -32
  19. package/src/workflows/feature/prototype.ts +5 -8
  20. package/src/workflows/feature/refactor-rebuild.feature +16 -12
  21. package/src/workflows/feature/refactor-review.feature +38 -0
  22. package/src/workflows/feature/reviewer-walkthrough.feature +0 -31
  23. package/src/workflows/feature/seam.test.ts +19 -101
  24. package/src/workflows/feature/seam.ts +13 -89
  25. package/src/workflows/fix/fix-quality.feature +4 -2
  26. package/src/workflows/shared/access.test.ts +1 -0
  27. package/src/workflows/shared/review.test.ts +3 -1
  28. package/src/workflows/shared/review.ts +12 -2
  29. package/src/workflows/shared/skills.test.ts +3 -1
  30. package/src/workflows/shared/steps.test.ts +9 -6
  31. package/src/workflows/shared/steps.ts +12 -3
  32. package/src/workflows/shared/tail.test.ts +20 -2
  33. package/src/workflows/shared/tail.ts +20 -7
  34. package/src/workflows/shared/text.test.ts +19 -6
  35. package/src/workflows/shared/text.ts +50 -29
  36. package/src/workflows/shared/vars.ts +1 -1
@@ -3,7 +3,6 @@ import {
3
3
  agent,
4
4
  applyScript,
5
5
  changes,
6
- changesSince,
7
6
  check,
8
7
  codeChanges,
9
8
  env,
@@ -92,43 +91,19 @@ const reaches = (imports: ReturnType<typeof importsOf>, added: string): boolean
92
91
  imports.some(({ path, tail }) => name === path || (tail && name.endsWith(`/${path}`))),
93
92
  )
94
93
 
95
- const named = (id: string, text: string): boolean =>
96
- new RegExp(`(?<![\\w-])${id}(?!\\d)`).test(text)
97
-
98
- /** The lines `change` added: an ID an earlier change left in the file does not count. */
99
- const addedLines = (change: Change): string => {
100
- const before = new Set((change.before ?? "").split("\n"))
101
- return change
102
- .after!.split("\n")
103
- .filter((line) => !before.has(line))
104
- .join("\n")
105
- }
106
-
107
94
  const acceptanceTests = (changes: readonly Change[]): readonly Change[] =>
108
95
  changes.filter((c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path))
109
96
 
110
- const idsOf = (file: string): string[] => criterionIds(read(file) ?? "")
111
-
112
- // One rule for both gates, so a naive test carried over verbatim always satisfies the rebuild's.
113
- const unnamed = (ids: readonly string[], tests: readonly Change[]): string[] => {
114
- const lines = tests.map(addedLines)
115
- return ids.filter((id) => !lines.some((text) => named(id, text)))
116
- }
117
-
118
97
  /**
119
- * Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
120
- * test line of the pass names, or a test importing a file the pass added —
121
- * naive structure a reset and refactor replaces, which the tests must survive.
98
+ * Why `tip`'s tests fall short, or `undefined`: a test importing a file the
99
+ * pass added — naive structure a reset and refactor replaces, which the tests
100
+ * must survive.
122
101
  */
123
- export const acceptanceFault = (file: string, tip: KeptTip): string | undefined => {
124
- const tests = acceptanceTests(tip.changes)
125
- const untested = unnamed(idsOf(file), tests)
126
- if (untested.length > 0)
127
- 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]}] …"`
102
+ export const acceptanceFault = (tip: KeptTip): string | undefined => {
128
103
  const added = tip.changes
129
104
  .filter((c) => c.status === "added" && isCode(c.path) && !isTestFile(c.path))
130
105
  .map((c) => c.path)
131
- const reaching = tests
106
+ const reaching = acceptanceTests(tip.changes)
132
107
  .filter((test) => holdsCallSites(test.path))
133
108
  .flatMap((test) => {
134
109
  const imports = importsOf(test)
@@ -141,48 +116,11 @@ export const acceptanceFault = (file: string, tip: KeptTip): string | undefined
141
116
  return undefined
142
117
  }
143
118
 
144
- /** What a rebuild carries over from the naive pass `tip`: the IDs its tests name, and those test files. */
145
- export const naiveAcceptance = (tip: KeptTip): t.Acceptance => {
146
- const ids = idsOf(REQUIREMENTS)
147
- const perTest = acceptanceTests(tip.changes).map((c) => {
148
- const lines = addedLines(c)
149
- return { path: c.path, ids: ids.filter((id) => named(id, lines)) }
150
- })
151
- return {
152
- hash: tip.hash,
153
- tests: perTest.filter((c) => c.ids.length > 0).map((c) => c.path),
154
- ids: ids.filter((id) => perTest.some((c) => c.ids.includes(id))),
155
- }
156
- }
157
-
158
- /**
159
- * Refuse a rebuild whose test changes since `since` leave out an ID the naive
160
- * pass's tests named. A skipped test still counts: the gate reads text, never runs it.
161
- */
162
- export const requireAcceptance = (acceptance: t.Acceptance, since: string): void => {
163
- const missing = unnamed(acceptance.ids, acceptanceTests(changesSince(since)))
164
- if (missing.length === 0) return
165
- refuse(
166
- `gtd land: rebuild-acceptance: the rebuild's tests leave out ${missing.join(", ")} — carry over the naive pass's acceptance tests (\`git show ${acceptance.hash} -- ${acceptance.tests.map(quote).join(" ")}\`), each with its ID in the test or scenario name`,
167
- )
168
- }
169
-
170
- /**
171
- * Refuse a fix turn that, on its own, drops an ID the naive pass's tests named
172
- * from a test file. Turn-local, so a person's edit at an escalation rest never
173
- * wedges later fix turns.
174
- */
175
- export const requireAcceptanceKept = (acceptance: t.Acceptance): void => {
176
- const dropped = acceptance.ids.filter((id) =>
177
- changes()
178
- .filter((c) => isCode(c.path) && isTestFile(c.path))
179
- .some((c) => named(id, c.before ?? "") && !named(id, c.after ?? "")),
180
- )
181
- if (dropped.length === 0) return
182
- refuse(
183
- `gtd land: rebuild-acceptance: this turn drops ${dropped.join(", ")} from the tests — the naive pass's acceptance tests stay; fix the code, not them`,
184
- )
185
- }
119
+ /** What a rebuild carries over from the naive pass `tip`: every test file it added or changed. */
120
+ export const naiveAcceptance = (tip: KeptTip): t.Acceptance => ({
121
+ hash: tip.hash,
122
+ tests: acceptanceTests(tip.changes).map((c) => c.path),
123
+ })
186
124
 
187
125
  const restore = (path: string): string =>
188
126
  `if git cat-file -e ${quote(`HEAD:${path}`)} 2>/dev/null; then git checkout -q HEAD -- ${quote(path)}; else rm -f -- ${quote(path)}; fi`
@@ -243,12 +181,6 @@ const applied = async (tip: string): Promise<boolean> => {
243
181
  return !wrote(FEEDBACK) && codeChanges().length > 0
244
182
  }
245
183
 
246
- /** `carried` limited to the criteria `.gtd/REQUIREMENTS.md` lists now; a file listing none narrows nothing. */
247
- const settled = (carried: readonly string[]): readonly string[] => {
248
- const listed = idsOf(REQUIREMENTS)
249
- return listed.length === 0 ? carried : carried.filter((id) => listed.includes(id))
250
- }
251
-
252
184
  const build = async (options: ImplementOptions): Promise<void> => {
253
185
  await t.agentWithSkills(
254
186
  "building",
@@ -290,19 +222,11 @@ export async function implement(
290
222
  /** `implement`, resolving with the criterion IDs the requirements listed when it closed. */
291
223
  export const implementing = (options: ImplementOptions): Promise<readonly string[]> =>
292
224
  scope("implement", async () => {
293
- const since = head()
294
- const acceptance = options.rebuild?.acceptance
295
225
  const built = options.naive === undefined || !(await applied(options.naive))
296
226
  if (built) await build(options)
297
227
  else requireCriteriaFrozen(REQUIREMENTS)
298
- // Only the criteria the requirements still list.
299
- if (built && acceptance !== undefined)
300
- requireAcceptance({ ...acceptance, ids: settled(acceptance.ids) }, since)
301
- // Fix turns are held to the carried tests too: deleting a red one must not buy a green suite.
302
- const keepsTests =
303
- built && acceptance !== undefined ? () => requireAcceptanceKept(acceptance) : undefined
304
- await green(keepsTests)
305
- await prototypeTest(keepsTests)
228
+ await green()
229
+ await prototypeTest()
306
230
  const ids = criterionIds(read(REQUIREMENTS) ?? "")
307
231
  await run("closing", removeScript([REQUIREMENTS, REFACTOR_NOTE, PROTOTYPE]), {
308
232
  label: "Closing out the implementation",
@@ -360,7 +284,7 @@ interface Vetting {
360
284
 
361
285
  /** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
362
286
  const vetted = async (tip: KeptTip, vetting: Vetting): Promise<Verdict | undefined> => {
363
- const fault = vetting.waived ? undefined : acceptanceFault(REQUIREMENTS, tip)
287
+ const fault = vetting.waived ? undefined : acceptanceFault(tip)
364
288
  if (fault !== undefined) {
365
289
  const verdict = await vetting.again({ fault, hash: tip.hash })
366
290
  if (verdict.kind !== "overruled") return verdict
@@ -4,11 +4,12 @@ Feature: fix's quality lap is triaged, and its leftovers go to the hand-off revi
4
4
  A judge sorts the lap's findings: mechanical ones are fixed for up to three
5
5
  turns, noise is dropped, and design ones — with whatever the fix turns left —
6
6
  are added to the reviewer's `.gtd/REVIEW.md` by gtd, never to a person's
7
- gate before the review.
7
+ gate before the review. With no requirements to check against, the
8
+ `criteria-coverage` lens runs no turn.
8
9
 
9
10
  Background:
10
11
  Given the shipped workflows, as gtd install scaffolds them
11
- And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
12
+ And an environment variable "GTD_QUALITYREVIEWS" set to "criteria-coverage, correctness"
12
13
  And a file "src/calc.ts" with:
13
14
  """
14
15
  export const add = (a: number, b: number) => a + b
@@ -81,6 +82,7 @@ Feature: fix's quality lap is triaged, and its leftovers go to the hand-off revi
81
82
  Then the process rests at "build.review.await-review"
82
83
  And ".gtd/QUALITY.md" does not exist
83
84
  And no commit body contains "build.health.describe"
85
+ And no commit body contains "build.quality.criteria-coverage"
84
86
  And ".gtd/REVIEW.md" contains:
85
87
  """
86
88
  ## sum has no negative test
@@ -19,6 +19,7 @@ describe("the bundled workflow's access export", () => {
19
19
  "build.quality.test-audit": { write: [] },
20
20
  "build.quality.conventions": { write: [] },
21
21
  "build.quality.spec-challenge": { write: [] },
22
+ "build.quality.criteria-coverage": { write: [] },
22
23
  "refactor.build.review": { write: [".gtd/REVIEW.md"] },
23
24
  "refactor.build.quality.correctness": { write: [] },
24
25
  "refactor.build.quality.ponytail-review": { write: [] },
@@ -575,7 +575,7 @@ describe("review verdict routing", () => {
575
575
  })
576
576
 
577
577
  describe("the default quality lenses", () => {
578
- it("are the six lenses in the settled order", () => {
578
+ it("are the seven lenses in the settled order", () => {
579
579
  expect(bundled.defaults.qualityReviews!.split(",").map((l) => l.trim())).toEqual([
580
580
  "correctness",
581
581
  "owasp-security",
@@ -583,6 +583,7 @@ describe("the default quality lenses", () => {
583
583
  "test-audit",
584
584
  "conventions",
585
585
  "spec-challenge",
586
+ "criteria-coverage",
586
587
  ])
587
588
  })
588
589
 
@@ -590,6 +591,7 @@ describe("the default quality lenses", () => {
590
591
  expect(Object.keys(bundled.builtInLenses).sort()).toEqual([
591
592
  "conventions",
592
593
  "correctness",
594
+ "criteria-coverage",
593
595
  "spec-challenge",
594
596
  ])
595
597
  })
@@ -380,6 +380,7 @@ const walkthrough = async (
380
380
  report: t.ComposedReview,
381
381
  lap: t.ReviewRange,
382
382
  carry: string | undefined,
383
+ afterRiskFix: boolean,
383
384
  ): Promise<void> => {
384
385
  const notes = t.titledNotes(report)
385
386
  await reviewing(base, carry, lap, t.walkthroughBlock(notes, report.nitFix))
@@ -393,7 +394,8 @@ const walkthrough = async (
393
394
  notes,
394
395
  changesSince(base).some((c) => isCode(c.path)),
395
396
  )
396
- // Ranked even when it marks risks: the review written after their fix reaches the human with its own.
397
+ // Its risks are fixed and the review written again, so the re-reviewer edits this draft, never gtd's layout.
398
+ if (!afterRiskFix && reviewRisks(written).length > 0) return
397
399
  const full = sectionedReview(
398
400
  await ranked(review, t.withWalkthrough(review, report), new Set(notes.map((n) => n.title))),
399
401
  new Set(reviewChunks(review).map((c) => c.title)),
@@ -432,12 +434,16 @@ export const humanReview = (
432
434
  let nitFix: string | undefined
433
435
  // Every note the human answered in this review, left out of a fresh one.
434
436
  const answered = new Set<string>()
437
+ // Set by a risk fix, so the review written next reaches the human whatever it marks.
438
+ let riskFixed = false
435
439
  return kit.review(base, {
436
440
  file: REVIEW,
437
441
  feedback: feedback.file,
438
442
  isCode,
439
443
  reviewing: (b, carry) => {
440
444
  const fixedAt = nitFix
445
+ const afterRiskFix = riskFixed
446
+ riskFixed = false
441
447
  const report = options.report ?? NO_FINDINGS
442
448
  return options.walkthrough === true
443
449
  ? guarded(
@@ -447,12 +453,16 @@ export const humanReview = (
447
453
  composedOf(b, report, fixedAt, answered),
448
454
  options.lap ?? "first",
449
455
  carry,
456
+ afterRiskFix,
450
457
  ),
451
458
  guard,
452
459
  )()
453
460
  : composing(b, report, carry, fixedAt, answered)
454
461
  },
455
- fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
462
+ fixRisks: guarded((notes) => {
463
+ riskFixed = true
464
+ return fixRisks(notes, fixAccess, rules)
465
+ }, guard),
456
466
  keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
457
467
  // Kept until the human sees a review, so a re-review after a risk fix still names it.
458
468
  awaitReview: (b) => {
@@ -39,6 +39,7 @@ describe("the bundled workflow's skills export", () => {
39
39
  "build.quality.test-audit",
40
40
  "build.quality.conventions",
41
41
  "build.quality.spec-challenge",
42
+ "build.quality.criteria-coverage",
42
43
  "refactor.build",
43
44
  "refactor.build.health",
44
45
  "refactor.build.fix.quality",
@@ -98,10 +99,11 @@ describe("the bundled workflow's skills export", () => {
98
99
  expect(Object.keys(none).filter((k) => k.startsWith("refactor.build.quality."))).toEqual([])
99
100
  })
100
101
 
101
- it("loads one lens skill per quality lens: correctness its review skill, conventions and spec-challenge none, any other itself", () => {
102
+ it("loads one lens skill per quality lens: correctness its review skill, conventions, spec-challenge and criteria-coverage none, any other itself", () => {
102
103
  expect(bundled["build.quality.correctness"]).toEqual(["code-review-and-quality"])
103
104
  expect(bundled["build.quality.conventions"]).toEqual([])
104
105
  expect(bundled["build.quality.spec-challenge"]).toEqual([])
106
+ expect(bundled["build.quality.criteria-coverage"]).toEqual([])
105
107
  expect(bundled["build.quality.owasp-security"]).toEqual(["owasp-security"])
106
108
  })
107
109
 
@@ -57,12 +57,15 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
57
57
  expect(body).toContain(point)
58
58
  })
59
59
 
60
- it.each(["conventions", "spec-challenge"])("%s carries its brief", async (lens) => {
61
- const request = await capture(() => steps.reviewQuality(lens), "build")
62
- expect(JSON.stringify(request)).toContain(
63
- steps.builtInLenses[lens]!.brief.split("\n")[0]!.slice(0, 40),
64
- )
65
- })
60
+ it.each(["conventions", "spec-challenge", "criteria-coverage"])(
61
+ "%s carries its brief",
62
+ async (lens) => {
63
+ const request = await capture(() => steps.reviewQuality(lens), "build")
64
+ expect(JSON.stringify(request)).toContain(
65
+ steps.builtInLenses[lens]!.brief.split("\n")[0]!.slice(0, 40),
66
+ )
67
+ },
68
+ )
66
69
 
67
70
  it("an unknown lens has no brief", async () => {
68
71
  const request = await capture(() => steps.reviewQuality("my-lens"), "build")
@@ -66,15 +66,24 @@ export const builtInLenses: Readonly<Record<string, BuiltInLens>> = {
66
66
  correctness: { skills: ["code-review-and-quality"], brief: t.correctnessBrief },
67
67
  conventions: { skills: [], brief: t.conventionsBrief },
68
68
  "spec-challenge": { skills: [], brief: t.specChallengeBrief },
69
+ "criteria-coverage": { skills: [], brief: t.criteriaCoverageBrief },
69
70
  }
70
71
 
71
72
  const coderLenses: ReadonlySet<string> = new Set(["conventions", "ponytail-review", "test-audit"])
72
73
 
73
- /** One quality review through `lens`; each lens is its own scope, so its own conversation and skills. */
74
- export const reviewQuality = (lens: string, base?: string, model?: string): Promise<void> =>
74
+ /**
75
+ * One quality review through `lens`; each lens is its own scope, so its own
76
+ * conversation and skills. `requirements`, when given, are quoted in the prompt.
77
+ */
78
+ export const reviewQuality = (
79
+ lens: string,
80
+ base?: string,
81
+ model?: string,
82
+ requirements?: string,
83
+ ): Promise<void> =>
75
84
  t.agentWithSkills(
76
85
  `quality.${lens}.reviewing`,
77
- t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base),
86
+ t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base, requirements),
78
87
  {
79
88
  label: "Reviewing (one quality lens)",
80
89
  file: QUALITY,
@@ -211,13 +211,17 @@ const prompts: string[] = []
211
211
  const models: (string | undefined)[] = []
212
212
 
213
213
  describe("the quality lap's lenses", () => {
214
- const lap = async (run: () => Promise<unknown>, vars: Record<string, string> = {}) => {
214
+ const lap = async (
215
+ run: () => Promise<unknown>,
216
+ vars: Record<string, string> = {},
217
+ files: Record<string, string> = {},
218
+ ) => {
215
219
  const log: string[] = []
216
220
  prompts.length = 0
217
221
  models.length = 0
218
222
  installContext(
219
223
  fixtureContext(
220
- { vars },
224
+ { vars, read: (path) => files[path] },
221
225
  {
222
226
  step: (request: StepRequest) => {
223
227
  if (request.kind !== "restart") log.push(request.name)
@@ -309,6 +313,20 @@ describe("the quality lap's lenses", () => {
309
313
  expect(models).toEqual(["smart", "opus"])
310
314
  })
311
315
 
316
+ it("criteria-coverage runs only with requirements, passed or in .gtd/REQUIREMENTS.md, and quotes them", async () => {
317
+ const lenses = ["criteria-coverage", "conventions"]
318
+ expect(await lap(() => qualityLap(undefined, lenses))).toEqual([
319
+ "quality.conventions.reviewing",
320
+ ])
321
+ await lap(() => qualityLap(undefined, lenses), {}, { ".gtd/REQUIREMENTS.md": "- AC-1: file" })
322
+ expect(prompts[0]).toContain(" > - AC-1: file")
323
+ expect(prompts[1]).not.toContain("AC-1")
324
+ await lap(() =>
325
+ tail(() => Promise.resolve(), { green: true, lenses, requirements: "- AC-1: passed" }),
326
+ )
327
+ expect(prompts[0]).toContain(" > - AC-1: passed")
328
+ })
329
+
312
330
  it("tail passes lenses to its lap and a guard after each lens turn", async () => {
313
331
  let guards = 0
314
332
  const log = await lap(() =>
@@ -15,6 +15,7 @@ import {
15
15
  fixCheck,
16
16
  fixRisks,
17
17
  QUALITY,
18
+ REQUIREMENTS,
18
19
  REVIEW,
19
20
  reviewing,
20
21
  reviewMissing,
@@ -35,24 +36,31 @@ export const qualityLap = (
35
36
  guard?: Guard,
36
37
  lenses?: readonly string[],
37
38
  base?: string,
38
- ): Promise<"clean" | "findings"> => lensLap(guard, lenses, base, () => {})
39
+ ): Promise<"clean" | "findings"> => lensLap(guard, lenses, base, undefined, () => {})
40
+
41
+ const COVERAGE = "criteria-coverage"
39
42
 
40
43
  // `seen` gets each lens and every finding in the file once its turn is done.
41
44
  const lensLap = (
42
45
  guard: Guard | undefined,
43
46
  lenses: readonly string[] | undefined,
44
47
  base: string | undefined,
48
+ given: string | undefined,
45
49
  seen: (lens: string, findings: readonly Finding[]) => void,
46
- ): Promise<"clean" | "findings"> =>
47
- kit.qualityLap(
48
- lenses ?? kit.lensEntriesOf(vars.qualityReviews ?? ""),
50
+ ): Promise<"clean" | "findings"> => {
51
+ const requirements = given ?? read(REQUIREMENTS)
52
+ return kit.qualityLap(
53
+ (lenses ?? kit.lensEntriesOf(vars.qualityReviews ?? "")).filter(
54
+ (entry) => requirements !== undefined || kit.lensOf(entry).lens !== COVERAGE,
55
+ ),
49
56
  guarded(async (entry: string) => {
50
57
  const { lens, model } = kit.lensOf(entry)
51
- await reviewQuality(lens, base, model)
58
+ await reviewQuality(lens, base, model, lens === COVERAGE ? requirements : undefined)
52
59
  seen(lens, findingsOf(read(QUALITY) ?? ""))
53
60
  }, guard),
54
61
  QUALITY,
55
62
  )
63
+ }
56
64
 
57
65
  // What the fixer left is in the lap's report by now; the next lap must not append to it.
58
66
  const closeQuality = async (): Promise<void> => {
@@ -91,6 +99,11 @@ export interface TailOptions {
91
99
  readonly lensBase?: string | undefined
92
100
  /** Appended to every fix and describe prompt of the tail. */
93
101
  readonly rules?: string | undefined
102
+ /**
103
+ * The requirements the change was built to, for the `criteria-coverage`
104
+ * lens; absent: `.gtd/REQUIREMENTS.md`, and with neither the lens is skipped.
105
+ */
106
+ readonly requirements?: string | undefined
94
107
  }
95
108
 
96
109
  /**
@@ -156,7 +169,7 @@ const tailForm = <T>(
156
169
 
157
170
  const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
158
171
  scope("build", async () => {
159
- const { guard, lenses, lensBase, rules } = options
172
+ const { guard, lenses, lensBase, rules, requirements } = options
160
173
  // Only undefined defaults: a null reaches every escalation as falsy, as it reaches review's.
161
174
  const guardRests = options.guardRests === undefined ? true : options.guardRests
162
175
  const escalations: EscalationCount = { rounds: 0 }
@@ -170,7 +183,7 @@ const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
170
183
  // The lap runs once a tail: after its findings are fixed, `finish` follows.
171
184
  // Lenses append, so a title the file did not hold before a lens's turn is that lens's.
172
185
  const byTitle: { title: string; lens: string }[] = []
173
- const lap = await lensLap(guard, lenses, lensBase, (lens, findings) => {
186
+ const lap = await lensLap(guard, lenses, lensBase, requirements, (lens, findings) => {
174
187
  const before = byTitle.map((f) => f.title)
175
188
  for (const { title } of findings) {
176
189
  const at = before.indexOf(title)
@@ -898,14 +898,27 @@ describe("the seam phase's texts", () => {
898
898
  expect(prompt).toContain("solved the task the wrong way")
899
899
  })
900
900
 
901
- it("asks for a breadth-first pass with ID-tagged behaviour tests, green before it ends", () => {
901
+ it("asks for a breadth-first pass tested through existing code, green before it ends", () => {
902
902
  const prompt = renderText(() => naivePassPrompt())
903
903
  expect(prompt).toContain("breadth-first")
904
- expect(prompt).toContain("`[AC-3] …`")
905
904
  expect(prompt).toContain("never a file this pass adds")
906
905
  expect(prompt).toContain("green")
907
906
  })
908
907
 
908
+ it.each([
909
+ ["naive", () => naivePassPrompt()],
910
+ ["building", () => implementBuildingPrompt("abc123")],
911
+ ["prototype fixing", () => prototypeFixingPrompt()],
912
+ ])(
913
+ "the %s prompt asks for a test per behaviour, never a criterion ID in its name",
914
+ (_n, prompt) => {
915
+ const text = renderText(prompt)
916
+ expect(text).toContain("one test per behaviour, not per\n criterion")
917
+ expect(text).toContain("never put criterion IDs in test or scenario names")
918
+ expect(text).not.toContain("[AC-3]")
919
+ },
920
+ )
921
+
909
922
  it("has a red retry pick up the kept attempt instead of starting over", () => {
910
923
  const prompt = renderText(() => naivePassPrompt({ red: "abc123" }))
911
924
  expect(prompt).toContain("left the suite red")
@@ -972,16 +985,16 @@ describe("the seam phase's texts", () => {
972
985
 
973
986
  it("hands a faulty pass back with the fault and the kept attempt to pick up", () => {
974
987
  const fault = {
975
- fault: "acceptance-tests: no test of the naive pass names AC-2",
988
+ fault: "acceptance-internals: these tests import files the naive pass added",
976
989
  hash: "abc123",
977
990
  }
978
991
  const prompt = renderText(() => naivePassPrompt(fault))
979
- expect(prompt).toContain("names AC-2")
992
+ expect(prompt).toContain("import files the naive pass added")
980
993
  expect(prompt).toContain(
981
994
  "git diff --binary --no-color --no-ext-diff --no-textconv abc123^ abc123 | git apply",
982
995
  )
983
996
  const message = renderText(() => seamSteerMessage(fault))
984
- expect(message).toContain("names AC-2")
997
+ expect(message).toContain("import files the naive pass added")
985
998
  expect(message).toContain("`seam.check` runs its suite")
986
999
  expect(message).toContain("Overrule")
987
1000
  })
@@ -997,7 +1010,7 @@ describe("the seam phase's texts", () => {
997
1010
  })
998
1011
 
999
1012
  it("promises no judge on an overrule with flagMode off", () => {
1000
- for (const retry of [{ red: "abc123" }, { fault: "acceptance-tests: x", hash: "abc123" }]) {
1013
+ for (const retry of [{ red: "abc123" }, { fault: "acceptance-internals: x", hash: "abc123" }]) {
1001
1014
  const message = renderText(() => seamSteerMessage(retry, false))
1002
1015
  expect(message).not.toContain("seam judge")
1003
1016
  expect(message).toContain("it lands as the implementation")