@pmelab/gtd 37.0.0 → 38.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.
@@ -8,7 +8,7 @@ import {
8
8
  scope,
9
9
  type Change,
10
10
  } from "../../flows/index.js"
11
- import { markFenced } from "../shared/fences.js"
11
+ import { HEADING, ITEM, markFenced } from "../shared/fences.js"
12
12
  import { REQUIREMENTS } from "../shared/steps.js"
13
13
  import * as t from "../shared/text.js"
14
14
 
@@ -29,8 +29,6 @@ export interface StatedCriteria {
29
29
  /** Below this the judge's `yes` is not trusted: a wrong skip builds against criteria nobody can check. */
30
30
  const CLEAR_MIN_P = 0.8
31
31
 
32
- const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
33
- const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/
34
32
  const ID = /^(AC-\d+):\s*(.*)$/
35
33
 
36
34
  /** The list items under the first heading `title` matches (at `level` when given), up to the next heading; wrapped lines joined, fenced lines ignored. */
@@ -71,6 +69,31 @@ export const criteriaOf = (text: string): readonly Criterion[] | undefined =>
71
69
  return id === null ? { id: "", text: item } : { id: id[1]!, text: id[2]! }
72
70
  })
73
71
 
72
+ /**
73
+ * Which criteria one slice builds, resolved against the file whenever read:
74
+ * `only` drops an ID a steer deleted, `except` (the last slice) takes one a
75
+ * steer added.
76
+ */
77
+ export interface CriteriaSlice {
78
+ readonly only?: readonly string[]
79
+ readonly except?: readonly string[]
80
+ }
81
+
82
+ /** The ID'd criteria of `text`, only `slice`'s when given. */
83
+ export const criterionIds = (text: string, slice?: CriteriaSlice): string[] =>
84
+ (criteriaOf(text) ?? [])
85
+ .map((c) => c.id)
86
+ .filter(
87
+ (id) =>
88
+ id !== "" &&
89
+ (slice?.only === undefined || slice.only.includes(id)) &&
90
+ !(slice?.except ?? []).includes(id),
91
+ )
92
+
93
+ /** `slice`'s IDs in `.gtd/REQUIREMENTS.md` as it is now; `undefined` for an unsliced change. */
94
+ export const sliceIds = (slice: CriteriaSlice | undefined): string[] | undefined =>
95
+ slice === undefined ? undefined : criterionIds(read(REQUIREMENTS) ?? "", slice)
96
+
74
97
  /** Items numbered `AC-1`…, unless every one already carries its own distinct ID. */
75
98
  const numbered = (items: readonly string[]): Criterion[] => {
76
99
  const ids = items.map((item) => ID.exec(item))
@@ -11,21 +11,29 @@ import {
11
11
  } from "../../flows/index.js"
12
12
  import { gate, healthy } from "../shared/health.js"
13
13
  import { flagModeOn, shipped } from "../shared/options.js"
14
- import { FEEDBACK, fixCheck, QUALITY } from "../shared/steps.js"
14
+ import { FEEDBACK, fixCheck, QUALITY, REQUIREMENTS } from "../shared/steps.js"
15
15
  import { refusePendingHandOff, tail } from "../shared/tail.js"
16
16
  import * as t from "../shared/text.js"
17
- import { skipDesign, statedCriteria, type StatedCriteria } from "./criteria.js"
17
+ import {
18
+ requireCriteriaFrozen,
19
+ skipDesign,
20
+ sliceIds,
21
+ statedCriteria,
22
+ type CriteriaSlice,
23
+ type StatedCriteria,
24
+ } from "./criteria.js"
18
25
  import { design } from "./planning.js"
19
26
  import { buildTail, isCode, reUnwind, type ReviewOutcome } from "./review.js"
20
- import { implement, seamBuild } from "./seam.js"
21
- import { recordRisk, riskTierOn } from "./tier.js"
27
+ import { closeOut, implement, seamBuild } from "./seam.js"
28
+ import { sliceCap, sliceCriteria, slicing } from "./slicing.js"
29
+ import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
22
30
 
23
31
  // The ordinary start. Any change to the tree starts a process: `idle` →
24
32
  // `unwind` reverts the sketch (its intent survives in history) → a
25
- // green-baseline gate → design → a naive pass, judged with `flagMode` on: it
26
- // lands as the implementation, or (refactoring first when the judge asks) is
27
- // built fresh from → the health loop → the quality lap, and the episode ends
28
- // back at `idle`. Human review is the `review` door's.
33
+ // green-baseline gate → design → per slice of the criteria, a naive pass,
34
+ // judged with `flagMode` on: it lands as the implementation, or (refactoring
35
+ // first when the judge asks) is built fresh from → the health loop → the
36
+ // quality lap, and the episode ends back at `idle`. Human review is the `review` door's.
29
37
 
30
38
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
31
39
  export const unwind = (): Promise<void> => {
@@ -37,27 +45,67 @@ export const unwind = (): Promise<void> => {
37
45
 
38
46
  export { reUnwind }
39
47
 
40
- /** A naive pass (judged only with `flagMode` on), then implement and the quality lap; design must have run since `firstBase`. */
48
+ /**
49
+ * The slices `design.slicing` grouped the criteria into, each built in turn
50
+ * from where the previous one landed; design must have run since `firstBase`.
51
+ */
41
52
  export const buildFeature = async (firstBase: string): Promise<void> => {
42
53
  const tier = riskTierOn() ? recordRisk() : undefined
43
- const seam = await seamBuild(firstBase)
44
- await slice("feature", async () => {
54
+ const slices = await slicing()
55
+ // A single slice keeps the unnumbered names and builds against every criterion.
56
+ const one = slices.length === 1
57
+ for (const i of slices.keys()) {
58
+ const more = i < slices.length - 1
59
+ const criteria = sliceCriteria(slices, i)
60
+ // A steer may have deleted every criterion of a later slice: nothing to build.
61
+ if (sliceIds(criteria)?.length === 0) {
62
+ if (!more) await closeOut()
63
+ continue
64
+ }
65
+ const n = one ? "" : `-${i + 1}`
66
+ await buildSlice(i === 0 ? firstBase : head(), {
67
+ tier,
68
+ criteria,
69
+ refactor: `refactor${n}`,
70
+ feature: `feature${n}`,
71
+ more,
72
+ })
73
+ }
74
+ }
75
+
76
+ interface SliceBuild {
77
+ readonly tier: RiskTier | undefined
78
+ readonly criteria: CriteriaSlice | undefined
79
+ readonly refactor: string
80
+ readonly feature: string
81
+ readonly more: boolean
82
+ }
83
+
84
+ /** A naive pass (judged only with `flagMode` on), then implement and the quality lap. */
85
+ const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> => {
86
+ const seam = await seamBuild(sliceBase, { criteria: build.criteria, refactor: build.refactor })
87
+ await slice(build.feature, async () => {
45
88
  await implement({
46
89
  reference: seam.reference,
47
- tier,
90
+ tier: build.tier,
48
91
  afterRefactor: seam.refactored,
49
92
  naive: seam.land === true ? seam.reference : undefined,
50
93
  rebuild: seam.rebuild,
94
+ slice: build.criteria,
95
+ base: build.criteria === undefined ? undefined : sliceBase,
96
+ more: build.more,
51
97
  })
52
98
  // A feature slice with no code beyond the carried-over tests: the refactor carried the
53
- // feature, so the lap covers the whole change.
99
+ // feature, so the lap covers the whole slice.
54
100
  const carried = seam.rebuild?.acceptance?.tests ?? []
55
101
  const base =
56
102
  seam.refactored === true &&
57
103
  !changesSince(seam.base).some((c) => isCode(c.path) && !carried.includes(c.path))
58
- ? firstBase
104
+ ? sliceBase
59
105
  : seam.base
60
- await tail(settleQuality, { green: true, lensBase: base })
106
+ // The criteria stay in the tree for the next slice; no quality fix may move them.
107
+ const guard = build.more ? () => requireCriteriaFrozen(REQUIREMENTS) : undefined
108
+ await tail(settleQuality, { green: true, lensBase: base, guard, guardRests: false })
61
109
  })
62
110
  }
63
111
 
@@ -103,6 +151,7 @@ export const ordinaryStart = async (): Promise<void> => {
103
151
  refusePendingHandOff()
104
152
  // Read right after idle, so a bad value refuses the landing that would pin it.
105
153
  flagModeOn()
154
+ sliceCap()
106
155
  const stated = statedCriteria(changes(), head())
107
156
  await unwind()
108
157
  if (changes(FEEDBACK).some((c) => c.status !== "deleted")) {
@@ -5,6 +5,7 @@ export * from "./criteria.js"
5
5
  export * from "./planning.js"
6
6
  export * from "./refactor.js"
7
7
  export * from "./seam.js"
8
+ export * from "./slicing.js"
8
9
  export {
9
10
  buildTail,
10
11
  type BuildTailOptions,
@@ -17,18 +17,24 @@ import {
17
17
  removeScript,
18
18
  run,
19
19
  scope,
20
- slice,
20
+ slice as sliced,
21
21
  vars,
22
22
  wrote,
23
23
  type Change,
24
24
  type DiffShapeResult,
25
25
  type KeptTip,
26
26
  } from "../../flows/index.js"
27
- import { coder, FEEDBACK, REFACTOR_NOTE, REQUIREMENTS } from "../shared/steps.js"
27
+ import { coder, FEEDBACK, REFACTOR_NOTE, REQUIREMENTS, SLICES } from "../shared/steps.js"
28
28
  import * as t from "../shared/text.js"
29
29
  import { defaults } from "../shared/vars.js"
30
30
  import { green, requireCoverage } from "./coverage.js"
31
- import { criteriaOf, requireCriteria, requireCriteriaFrozen } from "./criteria.js"
31
+ import {
32
+ criterionIds,
33
+ requireCriteria,
34
+ requireCriteriaFrozen,
35
+ sliceIds,
36
+ type CriteriaSlice,
37
+ } from "./criteria.js"
32
38
  import {
33
39
  COMMENT,
34
40
  holdsCallSites,
@@ -54,8 +60,8 @@ export const judgeSeam = (tip: KeptTip): Promise<DiffShapeResult> =>
54
60
  exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path),
55
61
  })
56
62
 
57
- export const naivePass = (retry?: t.NaiveRetry): Promise<void> =>
58
- t.agentWithSkills("naive", t.naivePassPrompt(retry), {
63
+ export const naivePass = (retry?: t.NaiveRetry, slice?: CriteriaSlice): Promise<void> =>
64
+ t.agentWithSkills("naive", t.naivePassPrompt(retry, sliceIds(slice)), {
59
65
  label: retry === undefined ? "Writing the naive pass" : "Rewriting the naive pass",
60
66
  keep: true,
61
67
  model: coder(),
@@ -107,8 +113,8 @@ const addedLines = (change: Change): string => {
107
113
  const acceptanceTests = (changes: readonly Change[]): readonly Change[] =>
108
114
  changes.filter((c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path))
109
115
 
110
- const idsOf = (file: string): string[] =>
111
- (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "")
116
+ const idsOf = (file: string, slice?: CriteriaSlice): string[] =>
117
+ criterionIds(read(file) ?? "", slice)
112
118
 
113
119
  // One rule for both gates, so a naive test carried over verbatim always satisfies the rebuild's.
114
120
  const unnamed = (ids: readonly string[], tests: readonly Change[]): string[] => {
@@ -121,9 +127,13 @@ const unnamed = (ids: readonly string[], tests: readonly Change[]): string[] =>
121
127
  * test line of the pass names, or a test importing a file the pass added —
122
128
  * naive structure a reset and refactor replaces, which the tests must survive.
123
129
  */
124
- export const acceptanceFault = (file: string, tip: KeptTip): string | undefined => {
130
+ export const acceptanceFault = (
131
+ file: string,
132
+ tip: KeptTip,
133
+ slice?: CriteriaSlice,
134
+ ): string | undefined => {
125
135
  const tests = acceptanceTests(tip.changes)
126
- const untested = unnamed(idsOf(file), tests)
136
+ const untested = unnamed(idsOf(file, slice), tests)
127
137
  if (untested.length > 0)
128
138
  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]}] …"`
129
139
  const added = tip.changes
@@ -143,8 +153,8 @@ export const acceptanceFault = (file: string, tip: KeptTip): string | undefined
143
153
  }
144
154
 
145
155
  /** What a rebuild carries over from the naive pass `tip`: the IDs its tests name, and those test files. */
146
- export const naiveAcceptance = (tip: KeptTip): t.Acceptance => {
147
- const ids = idsOf(REQUIREMENTS)
156
+ export const naiveAcceptance = (tip: KeptTip, slice?: CriteriaSlice): t.Acceptance => {
157
+ const ids = idsOf(REQUIREMENTS, slice)
148
158
  const perTest = acceptanceTests(tip.changes).map((c) => {
149
159
  const lines = addedLines(c)
150
160
  return { path: c.path, ids: ids.filter((id) => named(id, lines)) }
@@ -232,6 +242,12 @@ export interface ImplementOptions {
232
242
  readonly naive?: string | undefined
233
243
  /** A rebuild after a refactor-first judgment: the note it plugs into, the acceptance tests it must carry. */
234
244
  readonly rebuild?: t.Rebuild | undefined
245
+ /** The criteria this slice builds, when the change has more than one slice. */
246
+ readonly slice?: CriteriaSlice | undefined
247
+ /** Where the slice began: the coverage judge reads only the tests changed since. */
248
+ readonly base?: string | undefined
249
+ /** Not the last slice: `.gtd/REQUIREMENTS.md` and `.gtd/SLICES.md` stay for the next one. */
250
+ readonly more?: boolean | undefined
235
251
  }
236
252
 
237
253
  /** Resolves `false` when the tip no longer applies (the failure is in `.gtd/FEEDBACK.md`) or has nothing outside `.gtd/` to apply. */
@@ -270,6 +286,7 @@ export function implement(
270
286
  options.afterRefactor,
271
287
  flagModeOn(),
272
288
  options.rebuild,
289
+ sliceIds(options.slice),
273
290
  ),
274
291
  {
275
292
  label: "Implementing",
@@ -284,13 +301,20 @@ export function implement(
284
301
  await green(
285
302
  built && acceptance !== undefined ? () => requireAcceptanceKept(acceptance) : undefined,
286
303
  )
287
- await requireCoverage()
288
- await run("closing", removeScript([REQUIREMENTS, REFACTOR_NOTE]), {
289
- label: "Closing out the implementation",
290
- })
304
+ await requireCoverage(options.base, options.slice)
305
+ await closing(options.more === true)
291
306
  })
292
307
  }
293
308
 
309
+ /** The last slice's closing, for a last slice a steer left with no criterion to build. */
310
+ export const closeOut = (): Promise<void> => scope("implement", () => closing(false))
311
+
312
+ /** A slice with `more` after it keeps the criteria and the slicing for the next one. */
313
+ const closing = (more: boolean): Promise<void> =>
314
+ run("closing", removeScript(more ? [REFACTOR_NOTE] : [REQUIREMENTS, REFACTOR_NOTE, SLICES]), {
315
+ label: "Closing out the implementation",
316
+ })
317
+
294
318
  interface Decision {
295
319
  readonly reference: string
296
320
  readonly tip: KeptTip
@@ -340,8 +364,12 @@ interface Vetting {
340
364
  }
341
365
 
342
366
  /** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
343
- const vetted = async (tip: KeptTip, vetting: Vetting): Promise<Verdict | undefined> => {
344
- const fault = vetting.waived ? undefined : acceptanceFault(REQUIREMENTS, tip)
367
+ const vetted = async (
368
+ tip: KeptTip,
369
+ vetting: Vetting,
370
+ slice: CriteriaSlice | undefined,
371
+ ): Promise<Verdict | undefined> => {
372
+ const fault = vetting.waived ? undefined : acceptanceFault(REQUIREMENTS, tip, slice)
345
373
  if (fault !== undefined) {
346
374
  const verdict = await vetting.again({ fault, hash: tip.hash })
347
375
  if (verdict.kind !== "overruled") return verdict
@@ -361,15 +389,15 @@ const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision |
361
389
  return next.kind === "overruled" ? { reference: tip.hash, tip } : next
362
390
  }
363
391
 
364
- const decide = (): Promise<Decision> =>
392
+ const decide = (slice: CriteriaSlice | undefined): Promise<Decision> =>
365
393
  scope("seam", async () => {
366
394
  const vetting: Vetting = { again: budget(), waived: false }
367
395
  let retry: t.NaiveRetry | undefined
368
396
  for (;;) {
369
- await naivePass(retry)
397
+ await naivePass(retry, slice)
370
398
  const tip = kept("naive")!
371
399
  const outcome =
372
- (await vetted(tip, vetting)) ??
400
+ (await vetted(tip, vetting, slice)) ??
373
401
  (flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash, tip })
374
402
  if (!("kind" in outcome)) return outcome
375
403
  retry = outcome.kind === "retry" ? outcome.retry : undefined
@@ -410,8 +438,14 @@ export const refactorNote = async (
410
438
  return note
411
439
  }
412
440
 
413
- export const seamBuild = async (base: string): Promise<SeamOutcome> => {
414
- const { reference, tip, shape } = await decide()
441
+ export interface SeamSlice {
442
+ /** Absent for a change built in one slice. */
443
+ readonly criteria?: CriteriaSlice | undefined
444
+ readonly refactor?: string | undefined
445
+ }
446
+
447
+ export const seamBuild = async (base: string, slice: SeamSlice = {}): Promise<SeamOutcome> => {
448
+ const { reference, tip, shape } = await decide(slice.criteria)
415
449
  if (shape === undefined) return { reference, base, land: true }
416
450
  if (shape.unreadable.length > 0) {
417
451
  await scope("seam", () =>
@@ -421,17 +455,20 @@ export const seamBuild = async (base: string): Promise<SeamOutcome> => {
421
455
  acceptClean: true,
422
456
  }),
423
457
  )
424
- return { reference, base, rebuild: { acceptance: naiveAcceptance(tip) } }
458
+ return { reference, base, rebuild: { acceptance: naiveAcceptance(tip, slice.criteria) } }
425
459
  }
426
460
  const strays = refactorStrays(shape)
427
- if (strays.length === 0) return { reference, base, rebuild: { acceptance: naiveAcceptance(tip) } }
461
+ if (strays.length === 0)
462
+ return { reference, base, rebuild: { acceptance: naiveAcceptance(tip, slice.criteria) } }
428
463
  const note = await scope("seam", () => refactorNote(reference, strays))
429
- const refactored = await slice("refactor", () => reviewedRefactor(shape, { reference, note }))
464
+ const refactored = await sliced(slice.refactor ?? "refactor", () =>
465
+ reviewedRefactor(shape, { reference, note }),
466
+ )
430
467
  return {
431
468
  reference,
432
469
  base: head(),
433
470
  refactored,
434
471
  // Fixed at `seam.note`, like the package's scope: a later edit of the file reaches neither turn.
435
- rebuild: { note, acceptance: naiveAcceptance(tip) },
472
+ rebuild: { note, acceptance: naiveAcceptance(tip, slice.criteria) },
436
473
  }
437
474
  }
@@ -0,0 +1,14 @@
1
+ @inmem
2
+ Feature: a bad sliceMaxCriteria refuses the landing that would pin it
3
+
4
+ The cap is read right after idle, so a value that is not a positive integer
5
+ never gets pinned into a process that would refuse every later landing.
6
+
7
+ Scenario: a zero cap refuses the idle landing
8
+ Given the shipped workflows, as gtd install scaffolds them
9
+ And an environment variable "GTD_SLICEMAXCRITERIA" set to "0"
10
+ And a file "TODO.md" with:
11
+ """
12
+ # Something
13
+ """
14
+ Then driving the process is refused with "sliceMaxCriteria must be a positive integer, got \"0\""
@@ -0,0 +1,225 @@
1
+ @inmem
2
+ Feature: a change with more criteria than the cap is built in coarse slices
3
+
4
+ After design, criteria within sliceMaxCriteria are one slice, built as
5
+ before. Above it design.slicing groups them in .gtd/SLICES.md, at most the
6
+ cap per slice, and each slice runs in order — naive pass, implement, quality
7
+ lap — from where the previous one landed, as slices feature-1, feature-2, …
8
+
9
+ Background:
10
+ Given the shipped workflows, as gtd install scaffolds them
11
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
12
+ And an environment variable "GTD_FLAGMODE" set to "false"
13
+ And an environment variable "GTD_SLICEMAXCRITERIA" set to "2"
14
+ And a file "src/calc.ts" with:
15
+ """
16
+ export const add = (a: number, b: number) => a + b
17
+ """
18
+ And the working tree is committed as "feat: add calculator"
19
+ And a file "TODO.md" with:
20
+ """
21
+ # More arithmetic
22
+
23
+ subtract, multiply and divide.
24
+ """
25
+ And step "unwind" changes nothing
26
+ And step "start-gate.check" changes nothing
27
+ And step "design.gate.answer" changes nothing
28
+ And step "seam.check" changes nothing
29
+ And step "implement.applying" runs its script
30
+ And step "implement.health.check" changes nothing
31
+ And step "implement.closing" runs its script
32
+
33
+ Scenario: criteria within the cap are one unnumbered slice with no slicing turn
34
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
35
+ """
36
+ # Requirements
37
+
38
+ ## Acceptance Criteria
39
+
40
+ - AC-1: subtract(a, b) returns a - b.
41
+ - AC-2: multiply(a, b) returns a * b.
42
+ """
43
+ And step "seam.naive" writes "src/calc.test.ts" with:
44
+ """
45
+ it("[AC-1] subtracts", () => {})
46
+ it("[AC-2] multiplies", () => {})
47
+ """
48
+ And step "seam.naive" is prompted with "Read `.gtd/REQUIREMENTS.md`"
49
+ And judge step "implement.coverage" answers:
50
+ """
51
+ [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
52
+ """
53
+ When the process is driven
54
+ Then the process rests at "idle"
55
+ And the commit subjects end with:
56
+ """
57
+ gtd(human): design.gate.answer → seam.naive
58
+ gtd(agent): seam.naive → seam.check
59
+ gtd(check): seam.check → implement.applying
60
+ gtd(check): implement.applying → implement.health.check
61
+ gtd(check): implement.health.check → implement.coverage
62
+ gtd(judge): implement.coverage → implement.closing
63
+ gtd(check): implement.closing → idle
64
+ """
65
+ And the output lines of gtd "slices" start with:
66
+ """
67
+ feature
68
+ """
69
+ And the output of gtd "slices" does not contain "feature-"
70
+ And no commit body contains "This slice builds"
71
+
72
+ Scenario: criteria above the cap are sliced and built slice by slice
73
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
74
+ """
75
+ # Requirements
76
+
77
+ ## Acceptance Criteria
78
+
79
+ - AC-1: subtract(a, b) returns a - b.
80
+ - AC-2: multiply(a, b) returns a * b.
81
+ - AC-3: divide(a, b) returns a / b.
82
+ """
83
+ And step "design.slicing" is prompted with "at most 2 per slice"
84
+ And step "design.slicing" writes ".gtd/SLICES.md" with:
85
+ """
86
+ # Slices
87
+
88
+ ## Subtract and divide
89
+
90
+ - AC-1
91
+ - AC-3
92
+
93
+ ## Multiply
94
+
95
+ - AC-2
96
+ """
97
+ And step "seam.naive", when prompted with "This slice builds AC-1, AC-3 only", writes "src/sub.test.ts" with:
98
+ """
99
+ it("[AC-1] subtracts", () => {})
100
+ it("[AC-3] divides", () => {})
101
+ """
102
+ And step "seam.naive", when prompted with "This slice builds AC-2 only", writes "src/mul.test.ts" with:
103
+ """
104
+ it("[AC-2] multiplies", () => {})
105
+ """
106
+ And judge step "implement.coverage", when its questions contain "AC-3", answers:
107
+ """
108
+ [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-3","answer":"yes","p":0.9}]
109
+ """
110
+ And judge step "implement.coverage", when its questions contain "AC-2", answers:
111
+ """
112
+ [{"id":"AC-2","answer":"yes","p":0.9}]
113
+ """
114
+ When the process is driven
115
+ Then the process rests at "idle"
116
+ And the commit subjects end with:
117
+ """
118
+ gtd(agent): design.triage → design.gate.answer
119
+ gtd(human): design.gate.answer → design.slicing
120
+ gtd(agent): design.slicing → seam.naive
121
+ gtd(agent): seam.naive → seam.check
122
+ gtd(check): seam.check → implement.applying
123
+ gtd(check): implement.applying → implement.health.check
124
+ gtd(check): implement.health.check → implement.coverage
125
+ gtd(judge): implement.coverage → implement.closing
126
+ gtd(check): implement.closing → seam.naive
127
+ gtd(agent): seam.naive → seam.check
128
+ gtd(check): seam.check → implement.applying
129
+ gtd(check): implement.applying → implement.health.check
130
+ gtd(check): implement.health.check → implement.coverage
131
+ gtd(judge): implement.coverage → implement.closing
132
+ gtd(check): implement.closing → idle
133
+ """
134
+ And the output lines of gtd "slices" start with:
135
+ """
136
+ feature-1
137
+ feature-2
138
+ """
139
+ And slice "feature-2" begins where slice "feature-1" ends
140
+ And "src/sub.test.ts" contains "[AC-3] divides"
141
+ And "src/mul.test.ts" contains "[AC-2] multiplies"
142
+ And ".gtd/REQUIREMENTS.md" does not exist
143
+ And ".gtd/SLICES.md" does not exist
144
+
145
+ Scenario: a slice over the cap is refused
146
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
147
+ """
148
+ # Requirements
149
+
150
+ ## Acceptance Criteria
151
+
152
+ - AC-1: subtract(a, b) returns a - b.
153
+ - AC-2: multiply(a, b) returns a * b.
154
+ - AC-3: divide(a, b) returns a / b.
155
+ """
156
+ And step "design.slicing" writes ".gtd/SLICES.md" with:
157
+ """
158
+ # Slices
159
+
160
+ ## Everything
161
+
162
+ - AC-1
163
+ - AC-2
164
+ - AC-3
165
+ """
166
+ Then driving the process is refused with "more than 2 criteria in: Everything"
167
+
168
+ Scenario: a criterion in no slice is refused
169
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
170
+ """
171
+ # Requirements
172
+
173
+ ## Acceptance Criteria
174
+
175
+ - AC-1: subtract(a, b) returns a - b.
176
+ - AC-2: multiply(a, b) returns a * b.
177
+ - AC-3: divide(a, b) returns a / b.
178
+ """
179
+ And step "design.slicing" writes ".gtd/SLICES.md" with:
180
+ """
181
+ # Slices
182
+
183
+ ## Subtract
184
+
185
+ - AC-1
186
+
187
+ ## Multiply
188
+
189
+ - AC-2
190
+ """
191
+ Then driving the process is refused with "no slice for: AC-3"
192
+
193
+ Scenario: a slicing turn may not change the acceptance criteria
194
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
195
+ """
196
+ # Requirements
197
+
198
+ ## Acceptance Criteria
199
+
200
+ - AC-1: subtract(a, b) returns a - b.
201
+ - AC-2: multiply(a, b) returns a * b.
202
+ - AC-3: divide(a, b) returns a / b.
203
+ """
204
+ And step "design.slicing" writes ".gtd/REQUIREMENTS.md" with:
205
+ """
206
+ # Requirements
207
+
208
+ ## Acceptance Criteria
209
+
210
+ - AC-1: subtract(a, b) returns a - b.
211
+ - AC-2: multiply(a, b) returns a * b.
212
+ """
213
+ And step "design.slicing" writes ".gtd/SLICES.md" with:
214
+ """
215
+ # Slices
216
+
217
+ ## Subtract
218
+
219
+ - AC-1
220
+
221
+ ## Multiply
222
+
223
+ - AC-2
224
+ """
225
+ Then driving the process is refused with "wrote outside its write access"
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { sliceCriteria, slicesOf } from "./slicing.js"
3
+
4
+ describe("slicesOf", () => {
5
+ it("reads each level-2 heading's ID'd items, in order", () => {
6
+ expect(
7
+ slicesOf("# Slices\n\n## Parse\n\n- AC-1: parses\n- AC-3\n\n## Print\n\n- AC-2 prints\n"),
8
+ ).toEqual([
9
+ { title: "Parse", ids: ["AC-1", "AC-3"] },
10
+ { title: "Print", ids: ["AC-2"] },
11
+ ])
12
+ })
13
+
14
+ it("keeps an item not led by an ID as an empty one, and skips fenced lines", () => {
15
+ expect(slicesOf("## One\n\n```md\n## Two\n- AC-9\n```\n- AC-10\n- see AC-1\n")).toEqual([
16
+ { title: "One", ids: ["AC-10", ""] },
17
+ ])
18
+ })
19
+
20
+ it("ends a slice at any other heading", () => {
21
+ expect(
22
+ slicesOf("## One\n\n- AC-1\n\n### Why\n\n- shares the lexer\n\n# Later\n\n- AC-5\n"),
23
+ ).toEqual([{ title: "One", ids: ["AC-1"] }])
24
+ })
25
+
26
+ it("reads no slice from a file without level-2 headings", () => {
27
+ expect(slicesOf("# Slices\n\n- AC-1\n")).toEqual([])
28
+ })
29
+ })
30
+
31
+ describe("sliceCriteria", () => {
32
+ const slices = [
33
+ { title: "A", ids: ["AC-1", "AC-3"] },
34
+ { title: "B", ids: ["AC-2"] },
35
+ { title: "C", ids: ["AC-4"] },
36
+ ]
37
+
38
+ it("keeps an earlier slice to its own IDs", () => {
39
+ expect(sliceCriteria(slices, 1)).toEqual({ only: ["AC-2"] })
40
+ })
41
+
42
+ it("gives the last slice every ID no earlier slice builds", () => {
43
+ expect(sliceCriteria(slices, 2)).toEqual({ except: ["AC-1", "AC-3", "AC-2"] })
44
+ })
45
+
46
+ it("leaves a single slice unsliced", () => {
47
+ expect(sliceCriteria([slices[0]!], 0)).toBeUndefined()
48
+ })
49
+ })