@pmelab/gtd 18.1.0 → 19.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.
@@ -77,8 +77,7 @@ export const architectPersona = `You are the technical planning voice in gtd's b
77
77
  once product concerns are settled, reading them cold, no carried
78
78
  conversation. Work out the *how* per concern — structure, data models,
79
79
  tech-stack choices, error handling — raise the technical open
80
- questions, then write one package spec per concern with no further
81
- judgement call: the grouping is already decided.`
80
+ questions, then pin interfaces and tests in the architecture document.`
82
81
 
83
82
  export const reviewerPersona = `You are the independent reviewing mind in gtd's build pipeline —
84
83
  deliberately separate from whoever wrote the code, with no attachment
@@ -89,13 +88,6 @@ and, when asked, fix the small nits the human flagged and the risks
89
88
  you marked yourself, each in one batch.
90
89
  Beyond that you never fix or build anything yourself.`
91
90
 
92
- export const specReviewerPersona = `You are the adversarial spec-conformance checker in gtd's build
93
- pipeline, checking one freshly-built package against its spec. Verify
94
- only: tasks done, criteria met, code sound and consistent with the
95
- codebase. Write feedback only when something is genuinely wrong —
96
- otherwise write nothing; a clean turn IS the approval. Never fix what
97
- you find — naming it precisely enough for a fix turn is the whole job.`
98
-
99
91
  export const builderPersona = `You are the TDD implementer in gtd's build pipeline: build one
100
92
  package's declared scope end to end, tests first, and come back to fix
101
93
  things when redirected — a failing check, or reviewer feedback. Stay
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
2
  import { installContext, type Change, type JudgeAnswer, type StepRequest } from "../flows/index.js"
3
- import { review, type ReviewOutcome } from "./review.js"
3
+ import { fixQualityFindings, review, type ReviewOutcome } from "./review.js"
4
4
  import { unified } from "./index.js"
5
5
  import { fixtureContext } from "./text.fixture.js"
6
6
 
@@ -331,3 +331,90 @@ describe("the default quality lenses", () => {
331
331
  ])
332
332
  })
333
333
  })
334
+
335
+ describe("scenario wording in review's fix turns", () => {
336
+ const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
337
+ const risky = doc(["Risk: drops the carry", "sub"])
338
+
339
+ it("a green-keeping fix after a risk fix that rewrites a scenario stops at review.scenario-wording", async () => {
340
+ const log: string[] = []
341
+ let red = false
342
+ let drifted = false
343
+ installContext(
344
+ fixtureContext(
345
+ {},
346
+ {
347
+ step: (request: StepRequest) => {
348
+ if (request.kind === "restart") return Promise.resolve()
349
+ log.push(request.name)
350
+ if (request.name === "health.check") red = !red && !drifted
351
+ if (request.name === "fix") drifted = true
352
+ if (request.name === "scenario-wording") throw new Stop()
353
+ return Promise.resolve()
354
+ },
355
+ pushScope: () => undefined,
356
+ popScope: () => undefined,
357
+ read: (path) =>
358
+ path === REVIEW
359
+ ? risky
360
+ : path === "e2e/a.feature"
361
+ ? drifted
362
+ ? "Given b"
363
+ : "Given a"
364
+ : undefined,
365
+ changes: (): readonly Change[] =>
366
+ red
367
+ ? [{ path: ".gtd/FEEDBACK.md", status: "added", before: undefined, after: "red" }]
368
+ : [],
369
+ matches: (path, pattern) => path === pattern,
370
+ head: () => "h2",
371
+ start: () => "h0",
372
+ },
373
+ ),
374
+ )
375
+ await review("base", undefined, frozen).catch((e: unknown) => {
376
+ if (!(e instanceof Stop)) throw e
377
+ })
378
+ expect(log.slice(-2)).toEqual(["fix", "scenario-wording"])
379
+ })
380
+ })
381
+
382
+ describe("the quality fix loop", () => {
383
+ it("a fix that resolves QUALITY.md and rewrites a scenario ends after the wording gate", async () => {
384
+ const QUALITY = ".gtd/QUALITY.md"
385
+ const log: string[] = []
386
+ const files = new Map([
387
+ [QUALITY, "- finding"],
388
+ ["e2e/a.feature", "Given a"],
389
+ ])
390
+ let last: readonly Change[] = []
391
+ installContext(
392
+ fixtureContext(
393
+ {},
394
+ {
395
+ step: (request: StepRequest) => {
396
+ if (request.kind === "restart") return Promise.resolve()
397
+ log.push(request.name)
398
+ last = []
399
+ if (request.name === "fix.quality.fixing") {
400
+ files.delete(QUALITY)
401
+ files.set("e2e/a.feature", "Given b")
402
+ last = [{ path: QUALITY, status: "deleted", before: "- finding", after: undefined }]
403
+ }
404
+ return Promise.resolve()
405
+ },
406
+ pushScope: () => undefined,
407
+ popScope: () => undefined,
408
+ read: (path) => files.get(path),
409
+ changes: () => last,
410
+ matches: (path, pattern) => path === pattern,
411
+ head: () => "h2",
412
+ start: () => "h0",
413
+ },
414
+ ),
415
+ )
416
+ const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
417
+ expect(await fixQualityFindings({ rounds: 0 }, frozen)).toBe(true)
418
+ expect(log).toEqual(["fix.quality.fixing", "scenario-wording"])
419
+ })
420
+ })
@@ -19,8 +19,11 @@ import {
19
19
  } from "../flows/index.js"
20
20
  import { reviewNotes, reviewRisks, stripCodeThreads, type ReviewNote } from "../steering/index.js"
21
21
  import { escalation, FIX_CAP, healthy, type EscalationCount } from "./health.js"
22
+ import type { BuiltPlan } from "./packages.js"
23
+ import { guarded, type FrozenScenarios } from "./scenarios.js"
22
24
  import {
23
25
  answerReviewQuestions,
26
+ ARCHITECTURE,
24
27
  awaitReview,
25
28
  collecting,
26
29
  fix,
@@ -54,10 +57,14 @@ export const qualityLap = async (): Promise<"clean" | "findings"> => {
54
57
  }
55
58
 
56
59
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
57
- export const fixQualityFindings = async (escalations: EscalationCount): Promise<boolean> => {
60
+ export const fixQualityFindings = async (
61
+ escalations: EscalationCount,
62
+ frozen?: FrozenScenarios,
63
+ ): Promise<boolean> => {
58
64
  for (let turns = 0; turns < FIX_CAP; turns++) {
59
- await fixQuality()
60
- if (changes(QUALITY).some((c) => c.status === "deleted")) return true
65
+ await guarded(frozen, fixQuality)()
66
+ // Not `changes()`: a wording gate after the fix would be the last step.
67
+ if (read(QUALITY) === undefined) return true
61
68
  }
62
69
  await escalation(escalations)
63
70
  return false
@@ -203,6 +210,7 @@ type Finish =
203
210
  interface Round {
204
211
  readonly round: string
205
212
  readonly escalations: EscalationCount
213
+ readonly frozen: FrozenScenarios | undefined
206
214
  readonly close: () => Promise<void>
207
215
  readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome
208
216
  readonly unfolded: () => Promise<Finish>
@@ -221,8 +229,8 @@ const routeNotes = async (notes: readonly ReviewNote[], r: Round): Promise<Finis
221
229
  answeredAt = head()
222
230
  }
223
231
  if (nits.length > 0) {
224
- await fixNits(nits)
225
- await healthy(fix, { escalations: r.escalations })
232
+ await guarded(r.frozen, () => fixNits(nits), "review")()
233
+ await healthy(guarded(r.frozen, fix, "review"), { escalations: r.escalations })
226
234
  }
227
235
  if (edits.length > 0) {
228
236
  await r.close()
@@ -239,6 +247,7 @@ const finish = async (
239
247
  reviewed: string,
240
248
  collectedAt: string | undefined,
241
249
  escalations: EscalationCount,
250
+ frozen: FrozenScenarios | undefined,
242
251
  ): Promise<Finish> => {
243
252
  // Everything the human did is read before any agent turn runs, so a nit fix
244
253
  // is never counted as a hand-edit.
@@ -268,7 +277,7 @@ const finish = async (
268
277
  if (edited.length > 0) return collectWith(t.reviewEditsCapture(round))
269
278
  const notes = reviewNotes(baselineText, record)
270
279
  if (notes.length === 0) return collectWith(t.reviewNotesCapture(round))
271
- return routeNotes(notes, { round, escalations, close, outcome, unfolded })
280
+ return routeNotes(notes, { round, escalations, frozen, close, outcome, unfolded })
272
281
  }
273
282
 
274
283
  /** Write the review; if it marks risks, fix them, keep green, and write it again — once, so the re-review's own risks reach the human unfixed. */
@@ -276,12 +285,13 @@ const reviewOnce = async (
276
285
  base: string,
277
286
  carry: string | undefined,
278
287
  escalations: EscalationCount,
288
+ frozen: FrozenScenarios | undefined,
279
289
  ): Promise<void> => {
280
290
  await reviewing(base, carry)
281
291
  const risks = reviewRisks(read(REVIEW) ?? "")
282
292
  if (risks.length === 0) return
283
- await fixRisks(risks)
284
- await healthy(fix, { escalations })
293
+ await guarded(frozen, () => fixRisks(risks), "review")()
294
+ await healthy(guarded(frozen, fix, "review"), { escalations })
285
295
  await reviewing(base, carry)
286
296
  }
287
297
 
@@ -294,17 +304,18 @@ const reviewOnce = async (
294
304
  export const review = async (
295
305
  base: string,
296
306
  escalations: EscalationCount = { rounds: 0 },
307
+ frozen?: FrozenScenarios,
297
308
  ): Promise<ReviewOutcome> => {
298
309
  let carry: string | undefined
299
310
  for (;;) {
300
- await reviewOnce(base, carry, escalations)
311
+ await reviewOnce(base, carry, escalations, frozen)
301
312
  carry = undefined
302
313
  let reviewed = head()
303
314
  let collectedAt: string | undefined
304
315
  for (;;) {
305
316
  collectedAt = await converse(base, collectedAt)
306
317
  if (collectedAt === "missing") break
307
- const result = await finish(reviewed, collectedAt, escalations)
318
+ const result = await finish(reviewed, collectedAt, escalations, frozen)
308
319
  if (!("next" in result)) return result
309
320
  if (result.next === "rereview") {
310
321
  carry = result.carry
@@ -316,22 +327,36 @@ export const review = async (
316
327
  }
317
328
 
318
329
  /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
319
- export const buildTail = (fixFirst: boolean, base: string): Promise<ReviewOutcome> =>
330
+ export const buildTail = (
331
+ fixFirst: boolean,
332
+ base: string,
333
+ built?: BuiltPlan,
334
+ ): Promise<ReviewOutcome> =>
320
335
  scope("build", async () => {
336
+ const frozen = built?.frozen
321
337
  const escalations: EscalationCount = { rounds: 0 }
338
+ const guardedFix = guarded(frozen, () => fix())
322
339
  let redFirst = fixFirst
340
+ // fixFirst's own fix + healthy below already is the full run.
341
+ if (!fixFirst) {
342
+ await healthy(
343
+ guarded(frozen, () => fix(built)),
344
+ { escalations, sweepOnGreen: [ARCHITECTURE] },
345
+ )
346
+ }
323
347
  // The lap runs once a tail: after its findings are fixed, review follows.
324
348
  let lapped = false
325
349
  for (;;) {
326
350
  if (redFirst) {
327
- await fix()
328
- await healthy(fix, { fixesSoFar: 1, escalations })
351
+ await guardedFix()
352
+ await healthy(guardedFix, { fixesSoFar: 1, escalations })
329
353
  redFirst = false
330
354
  }
331
355
  const lap = lapped ? "clean" : await qualityLap()
332
356
  lapped = true
333
- if (lap === "clean") return review(base, escalations)
334
- if (await fixQualityFindings(escalations)) await healthy(fix, { escalations })
335
- else redFirst = true
357
+ if (lap === "clean") return review(base, escalations, frozen)
358
+ if (await fixQualityFindings(escalations, frozen)) {
359
+ await healthy(guardedFix, { escalations })
360
+ } else redFirst = true
336
361
  }
337
362
  })
@@ -0,0 +1,256 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext, type StepRequest } from "../flows/index.js"
3
+ import {
4
+ driftedScenarios,
5
+ freezeScenarios,
6
+ guarded,
7
+ holdWording,
8
+ scenarioText,
9
+ } from "./scenarios.js"
10
+ import { fixtureContext, renderText } from "./text.fixture.js"
11
+
12
+ afterEach(() => installContext(undefined))
13
+
14
+ const tree = (files: Record<string, string>) => ({
15
+ read: (path: string) => files[path],
16
+ })
17
+
18
+ describe("scenarioText", () => {
19
+ it("ignores comments, tags, blank lines and indentation", () => {
20
+ const loose = [
21
+ "@wip @slow",
22
+ "Feature: x",
23
+ "",
24
+ " # a comment",
25
+ " @tag",
26
+ " Scenario: y",
27
+ " Given a thing",
28
+ ].join("\n")
29
+ const tight = "Feature: x\nScenario: y\nGiven a thing"
30
+ expect(scenarioText(loose)).toBe(scenarioText(tight))
31
+ })
32
+
33
+ it("keeps changed wording", () => {
34
+ expect(scenarioText("Given a")).not.toBe(scenarioText("Given b"))
35
+ })
36
+
37
+ it.each([
38
+ ["a # line", "# Review: abc"],
39
+ ["an @ line", "@mention"],
40
+ ["a blank line", ""],
41
+ ])("keeps %s inside a doc string", (_, line) => {
42
+ const doc = (body: string[]) =>
43
+ ["Given a file with:", ' """', ...body.map((l) => (l === "" ? "" : ` ${l}`)), ' """'].join(
44
+ "\n",
45
+ )
46
+ expect(scenarioText(doc(["top", line, "end"]))).not.toBe(scenarioText(doc(["top", "end"])))
47
+ })
48
+
49
+ it("keeps indentation inside a doc string", () => {
50
+ const doc = (line: string) => ["Given a file with:", ' """', ` ${line}`, ' """'].join("\n")
51
+ expect(scenarioText(doc(" nested"))).not.toBe(scenarioText(doc("nested")))
52
+ })
53
+
54
+ it("ignores a doc string re-indented together with its delimiters", () => {
55
+ const at = (indent: string) =>
56
+ [
57
+ "Given a file with:",
58
+ `${indent}\`\`\``,
59
+ `${indent}# Review`,
60
+ `${indent} - x`,
61
+ `${indent}\`\`\``,
62
+ ].join("\n")
63
+ expect(scenarioText(at(" "))).toBe(scenarioText(at(" ")))
64
+ })
65
+ })
66
+
67
+ describe("freezeScenarios", () => {
68
+ it("snapshots wording at head, nothing frozen is undefined", () => {
69
+ const files = { "a.feature": "@t\nGiven a" }
70
+ renderText(() => {
71
+ expect(freezeScenarios([])).toBeUndefined()
72
+ })
73
+ installContext(fixtureContext({ head: "h1" }, tree(files)))
74
+ expect(freezeScenarios(["a.feature"])).toEqual({
75
+ at: "h1",
76
+ texts: { "a.feature": scenarioText("Given a") },
77
+ })
78
+ })
79
+
80
+ it("merges over previous, a new path overriding an old one", () => {
81
+ installContext(
82
+ fixtureContext({ head: "h2" }, tree({ "b.feature": "Given b", "a.feature": "Given a2" })),
83
+ )
84
+ const merged = freezeScenarios(["a.feature", "b.feature"], {
85
+ at: "h1",
86
+ texts: { "a.feature": "Given a", "c.feature": "Given c" },
87
+ })
88
+ expect(merged).toEqual({
89
+ at: "h2",
90
+ texts: {
91
+ "a.feature": "Given a2",
92
+ "b.feature": "Given b",
93
+ "c.feature": "Given c",
94
+ },
95
+ })
96
+ })
97
+
98
+ it("keeps previous untouched when no paths are given", () => {
99
+ const previous = { at: "h1", texts: { "a.feature": "Given a" } }
100
+ installContext(fixtureContext({ head: "h2" }, tree({})))
101
+ expect(freezeScenarios([], previous)).toBe(previous)
102
+ })
103
+ })
104
+
105
+ describe("driftedScenarios", () => {
106
+ const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
107
+
108
+ it("flags edited and deleted files, ignores comment-only edits and other paths", () => {
109
+ installContext(
110
+ fixtureContext({}, tree({ "a.feature": "# note\nGiven a2", "other.feature": "Given z" })),
111
+ )
112
+ expect(driftedScenarios(frozen)).toEqual(["a.feature", "b.feature"])
113
+ })
114
+
115
+ it("is empty while wording is unchanged", () => {
116
+ installContext(
117
+ fixtureContext({}, tree({ "a.feature": " @x\n Given a", "b.feature": "Given b" })),
118
+ )
119
+ expect(driftedScenarios(frozen)).toEqual([])
120
+ })
121
+ })
122
+
123
+ describe("holdWording", () => {
124
+ const run = async (
125
+ files: () => Record<string, string>,
126
+ frozen: Parameters<typeof holdWording>[0],
127
+ head = "h2",
128
+ ): Promise<StepRequest[]> => {
129
+ const steps: StepRequest[] = []
130
+ installContext(
131
+ fixtureContext(
132
+ { head },
133
+ {
134
+ read: (path) => files()[path],
135
+ step: (request) => {
136
+ steps.push(request)
137
+ return Promise.resolve()
138
+ },
139
+ },
140
+ ),
141
+ )
142
+ await holdWording(frozen)
143
+ return steps
144
+ }
145
+
146
+ it("does nothing without a freeze or without drift", async () => {
147
+ expect(await run(() => ({}), undefined)).toEqual([])
148
+ const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
149
+ expect(await run(() => ({ "a.feature": "Given a" }), frozen)).toEqual([])
150
+ })
151
+
152
+ it("rests at scenario-wording with the removed and added lines and the restore command", async () => {
153
+ const frozen = { at: "h1", texts: { "my dir/a.feature": "Given a\nThen ok" } }
154
+ const steps = await run(() => ({ "my dir/a.feature": "Given a\nThen better" }), frozen)
155
+ const [step] = steps
156
+ if (step?.kind !== "human") throw new Error("expected a human step")
157
+ expect(step.name).toBe("scenario-wording")
158
+ expect(step.options.acceptClean).toBe(true)
159
+ expect(step.options.base).toBe("h1")
160
+ const message = step.options.message ?? ""
161
+ expect(message).toContain("- Then ok")
162
+ expect(message).toContain("+ Then better")
163
+ expect(message).toContain("git checkout h1 -- 'my dir/a.feature'")
164
+ expect(message).toContain("partial accept")
165
+ })
166
+
167
+ const shownDiff = async (before: string, after: string): Promise<string[]> => {
168
+ const frozen = { at: "h1", texts: { "a.feature": before } }
169
+ const [step] = await run(() => ({ "a.feature": after }), frozen)
170
+ if (step?.kind !== "human") throw new Error("expected a human step")
171
+ return (step.options.message ?? "").split("\n").filter((l) => /^[-+] /.test(l))
172
+ }
173
+
174
+ it("shows a moved step as one removal and one addition of it, in place", async () => {
175
+ expect(await shownDiff("Given a\nWhen b\nThen c", "When b\nGiven a\nThen c")).toEqual([
176
+ "- Given a",
177
+ "+ Given a",
178
+ ])
179
+ })
180
+
181
+ it("shows a blank line added inside a doc string", async () => {
182
+ expect(await shownDiff('"""\na\nb\n"""', '"""\na\n\nb\n"""')).toEqual(["+ "])
183
+ })
184
+
185
+ it("shows a repeated step as its added copy", async () => {
186
+ expect(await shownDiff("Given a\nThen c", "Given a\nGiven a\nThen c")).toEqual(["+ Given a"])
187
+ })
188
+
189
+ it("accepts still-differing files by moving the snapshot to head", async () => {
190
+ const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
191
+ await run(() => ({ "a.feature": "Given a2", "b.feature": "Given b2" }), frozen)
192
+ expect(frozen).toEqual({
193
+ at: "h2",
194
+ texts: { "a.feature": "Given a2", "b.feature": "Given b2" },
195
+ })
196
+ })
197
+
198
+ it("rejects restored files, keeping the original wording frozen", async () => {
199
+ const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
200
+ let files: Record<string, string> = { "a.feature": "Given a2", "b.feature": "Given b2" }
201
+ installContext(
202
+ fixtureContext(
203
+ { head: "h2" },
204
+ {
205
+ read: (path) => files[path],
206
+ step: () => {
207
+ files = { "a.feature": "Given a", "b.feature": "Given b2" }
208
+ return Promise.resolve()
209
+ },
210
+ },
211
+ ),
212
+ )
213
+ await holdWording(frozen)
214
+ expect(frozen.texts).toEqual({ "a.feature": "Given a", "b.feature": "Given b2" })
215
+ })
216
+
217
+ it("drops an accepted deletion from the snapshot", async () => {
218
+ const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
219
+ await run(() => ({}), frozen)
220
+ expect(frozen.texts).toEqual({})
221
+ })
222
+ })
223
+
224
+ describe("guarded", () => {
225
+ it("runs the turn, then holds wording", async () => {
226
+ const order: string[] = []
227
+ const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
228
+ installContext(
229
+ fixtureContext(
230
+ { head: "h2" },
231
+ {
232
+ read: () => "Given changed",
233
+ step: (request) => {
234
+ order.push(request.kind)
235
+ return Promise.resolve()
236
+ },
237
+ },
238
+ ),
239
+ )
240
+ await guarded(frozen, () => {
241
+ order.push("turn")
242
+ return Promise.resolve()
243
+ })()
244
+ expect(order).toEqual(["turn", "human"])
245
+ })
246
+
247
+ it("is the bare turn without a freeze", async () => {
248
+ const order: string[] = []
249
+ installContext(fixtureContext())
250
+ await guarded(undefined, () => {
251
+ order.push("turn")
252
+ return Promise.resolve()
253
+ })()
254
+ expect(order).toEqual(["turn"])
255
+ })
256
+ })
@@ -0,0 +1,137 @@
1
+ import { head, human, read, scope } from "../flows/index.js"
2
+ import * as t from "./text.js"
3
+
4
+ /** Mutable: an accepted change moves the snapshot forward. */
5
+ export interface FrozenScenarios {
6
+ at: string
7
+ texts: Record<string, string>
8
+ }
9
+
10
+ const DOC_STRING = /^(\s*)("""|```)/
11
+
12
+ /**
13
+ * Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
14
+ * dropped — except inside a doc string, whose content is test input: kept
15
+ * whole, indented relative to its opening delimiter (as Gherkin reads it).
16
+ */
17
+ export const scenarioText = (source: string): string => {
18
+ const out: string[] = []
19
+ let doc: { readonly indent: number; readonly fence: string } | undefined
20
+ for (const line of source.split("\n")) {
21
+ const delimiter = DOC_STRING.exec(line)
22
+ if (doc === undefined && delimiter !== null) {
23
+ doc = { indent: delimiter[1]!.length, fence: delimiter[2]! }
24
+ out.push(line.trim())
25
+ } else if (doc !== undefined && line.trim().startsWith(doc.fence)) {
26
+ doc = undefined
27
+ out.push(line.trim())
28
+ } else if (doc !== undefined) {
29
+ const lead = line.length - line.trimStart().length
30
+ out.push(line.slice(Math.min(lead, doc.indent)).trimEnd())
31
+ } else {
32
+ const trimmed = line.trim()
33
+ if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed)
34
+ }
35
+ }
36
+ return out.join("\n")
37
+ }
38
+
39
+ const textOf = (path: string): string | undefined => {
40
+ const source = read(path)
41
+ return source === undefined ? undefined : scenarioText(source)
42
+ }
43
+
44
+ /** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
45
+ export const freezeScenarios = (
46
+ paths: readonly string[],
47
+ previous?: FrozenScenarios,
48
+ ): FrozenScenarios | undefined => {
49
+ if (paths.length === 0) return previous
50
+ const texts: Record<string, string> = { ...previous?.texts }
51
+ for (const path of paths) {
52
+ const text = textOf(path)
53
+ if (text !== undefined) texts[path] = text
54
+ }
55
+ return Object.keys(texts).length === 0 ? previous : { at: head(), texts }
56
+ }
57
+
58
+ /** Paths whose wording differs from the snapshot (a deleted file counts). */
59
+ export const driftedScenarios = (frozen: FrozenScenarios): readonly string[] =>
60
+ Object.entries(frozen.texts)
61
+ .filter(([path, text]) => textOf(path) !== text)
62
+ .map(([path]) => path)
63
+
64
+ // Blank lines survive only inside doc strings, where they are wording.
65
+ const lines = (text: string | undefined): string[] =>
66
+ text === undefined || text === "" ? [] : text.split("\n")
67
+
68
+ /** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
69
+ const lcsTable = (a: readonly string[], b: readonly string[]): number[][] => {
70
+ const table = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0))
71
+ for (let i = a.length - 1; i >= 0; i--) {
72
+ for (let j = b.length - 1; j >= 0; j--) {
73
+ table[i]![j] =
74
+ a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
75
+ }
76
+ }
77
+ return table
78
+ }
79
+
80
+ /** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
81
+ const lineDiff = (a: readonly string[], b: readonly string[]): string[] => {
82
+ const table = lcsTable(a, b)
83
+ const out: string[] = []
84
+ let i = 0
85
+ let j = 0
86
+ while (i < a.length && j < b.length) {
87
+ if (a[i] === b[j]) {
88
+ i++
89
+ j++
90
+ } else if (table[i + 1]![j]! >= table[i]![j + 1]!) out.push(`- ${a[i++]}`)
91
+ else out.push(`+ ${b[j++]}`)
92
+ }
93
+ return [...out, ...a.slice(i).map((l) => `- ${l}`), ...b.slice(j).map((l) => `+ ${l}`)]
94
+ }
95
+
96
+ export const wordingDrift = (frozen: FrozenScenarios, path: string): t.WordingDrift => ({
97
+ path,
98
+ lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path))),
99
+ })
100
+
101
+ /** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
102
+ export const holdWording = async (frozen: FrozenScenarios | undefined): Promise<void> => {
103
+ if (frozen === undefined) return
104
+ const drifted = driftedScenarios(frozen)
105
+ if (drifted.length === 0) return
106
+ await human("scenario-wording", {
107
+ message: t.scenarioWordingMessage(
108
+ frozen.at,
109
+ drifted.map((path) => wordingDrift(frozen, path)),
110
+ ),
111
+ label: "Scenario wording changed",
112
+ acceptClean: true,
113
+ base: frozen.at,
114
+ })
115
+ // Still differing is accepted; restored is rejected and stays frozen as it was.
116
+ const accepted = driftedScenarios(frozen)
117
+ if (accepted.length === 0) return
118
+ for (const path of accepted) {
119
+ const text = textOf(path)
120
+ if (text === undefined) delete frozen.texts[path]
121
+ else frozen.texts[path] = text
122
+ }
123
+ frozen.at = head()
124
+ }
125
+
126
+ /** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
127
+ export const guarded =
128
+ (
129
+ frozen: FrozenScenarios | undefined,
130
+ turn: () => Promise<void>,
131
+ within?: string,
132
+ ): (() => Promise<void>) =>
133
+ async () => {
134
+ await turn()
135
+ if (within === undefined) await holdWording(frozen)
136
+ else await scope(within, () => holdWording(frozen))
137
+ }
@@ -18,8 +18,6 @@ describe("the bundled workflow's skills export", () => {
18
18
  "architecture.decompose",
19
19
  "packages.item",
20
20
  "packages.item.fix.suite",
21
- "packages.item.fix.spec",
22
- "packages.item.spec",
23
21
  "packages.item.health",
24
22
  "build",
25
23
  "build.health",
@@ -50,8 +48,6 @@ describe("the bundled workflow's skills export", () => {
50
48
  "architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
51
49
  "packages.item": ["test-driven-development", "incremental-implementation"],
52
50
  "packages.item.fix.suite": ["debugging-and-error-recovery"],
53
- "packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
54
- "packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
55
51
  "packages.item.health": ["debugging-and-error-recovery"],
56
52
  build: ["debugging-and-error-recovery"],
57
53
  "build.health": ["debugging-and-error-recovery"],