@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.
@@ -0,0 +1,105 @@
1
+ import { read, refuse, scope, vars } from "../../flows/index.js"
2
+ import { HEADING, ITEM, markFenced } from "../shared/fences.js"
3
+ import { planner, REQUIREMENTS, SLICES } from "../shared/steps.js"
4
+ import * as t from "../shared/text.js"
5
+ import { defaults } from "../shared/vars.js"
6
+ import { criterionIds, type CriteriaSlice } from "./criteria.js"
7
+
8
+ /** One slice of the change: the criterion IDs it builds, in the order `.gtd/SLICES.md` lists them. */
9
+ export interface Slice {
10
+ readonly title: string
11
+ readonly ids: readonly string[]
12
+ }
13
+
14
+ const ID = /^(AC-\d+)(?![\w-])/
15
+
16
+ /** The `sliceMaxCriteria` setting. Anything but a positive integer fails rather than guess a cap. */
17
+ export const sliceCap = (): number => {
18
+ const value = vars.sliceMaxCriteria ?? defaults.sliceMaxCriteria!
19
+ if (!/^[1-9]\d*$/.test(value))
20
+ refuse(`gtd: sliceMaxCriteria must be a positive integer, got ${JSON.stringify(value)}`)
21
+ return Number(value)
22
+ }
23
+
24
+ /** Each `## ` heading of `text` with the list items under it; an item not led by an ID keeps an empty one. */
25
+ export const slicesOf = (text: string): readonly Slice[] => {
26
+ const slices: { title: string; ids: string[] }[] = []
27
+ // Only items right under a `##` heading: another heading ends the slice.
28
+ let open = false
29
+ for (const { line, fenced } of markFenced(text)) {
30
+ if (fenced) continue
31
+ const heading = HEADING.exec(line)
32
+ if (heading !== null) {
33
+ open = heading[1] === "##"
34
+ if (open) slices.push({ title: heading[2]!, ids: [] })
35
+ continue
36
+ }
37
+ const item = ITEM.exec(line)
38
+ if (item !== null && open) slices.at(-1)!.ids.push(ID.exec(item[1]!)?.[1] ?? "")
39
+ }
40
+ return slices
41
+ }
42
+
43
+ const twice = (list: readonly string[]): string[] => [
44
+ ...new Set(list.filter((id, i) => list.indexOf(id) !== i)),
45
+ ]
46
+
47
+ /** Why `slices` fail to put each of `ids` in exactly one slice of at most `cap`, or `undefined`. */
48
+ const sliceFault = (
49
+ slices: readonly Slice[],
50
+ ids: readonly string[],
51
+ cap: number,
52
+ ): string | undefined => {
53
+ const listed = slices.flatMap((s) => s.ids)
54
+ if (slices.length === 0) return `no "## " slice`
55
+ const empty = slices.filter((s) => s.ids.length === 0).map((s) => s.title)
56
+ if (empty.length > 0) return `a slice with no criterion: ${empty.join(", ")}`
57
+ if (listed.includes("")) return "a list item not led by a criterion ID"
58
+ const over = slices.filter((s) => s.ids.length > cap).map((s) => s.title)
59
+ if (over.length > 0) return `more than ${cap} criteria in: ${over.join(", ")}`
60
+ const repeated = twice(listed)
61
+ if (repeated.length > 0) return `an ID in more than one slice: ${repeated.join(", ")}`
62
+ const unknown = listed.filter((id) => !ids.includes(id))
63
+ if (unknown.length > 0) return `an ID ${REQUIREMENTS} does not list: ${unknown.join(", ")}`
64
+ const missing = ids.filter((id) => !listed.includes(id))
65
+ if (missing.length > 0) return `no slice for: ${missing.join(", ")}`
66
+ return undefined
67
+ }
68
+
69
+ /** Refuse a turn whose `.gtd/SLICES.md` does not put each of `ids` in exactly one slice of at most `cap`. */
70
+ export const requireSlices = (ids: readonly string[], cap: number): readonly Slice[] => {
71
+ const slices = slicesOf(read(SLICES) ?? "")
72
+ const why = sliceFault(slices, ids, cap)
73
+ if (why !== undefined)
74
+ refuse(
75
+ `gtd land: slices: ${SLICES} has ${why} — list each slice as a "## <title>" heading over "- AC-<n>" items, every criterion of ${REQUIREMENTS} in exactly one slice, at most ${cap} per slice`,
76
+ )
77
+ return slices
78
+ }
79
+
80
+ /**
81
+ * The slices to build, in order. Criteria within `sliceMaxCriteria` are one
82
+ * slice and no turn; above it `design.slicing` groups them.
83
+ */
84
+ export const slicing = async (): Promise<readonly Slice[]> => {
85
+ const all = criterionIds(read(REQUIREMENTS) ?? "")
86
+ const cap = sliceCap()
87
+ if (all.length <= cap) return [{ title: "", ids: all }]
88
+ return scope("design", async () => {
89
+ await t.agentWithSkills("slicing", t.slicingPrompt(cap), {
90
+ label: "Slicing the change",
91
+ file: SLICES,
92
+ model: planner(),
93
+ system: t.designSystem(),
94
+ })
95
+ return requireSlices(all, cap)
96
+ })
97
+ }
98
+
99
+ /** Slice `i`'s criteria; `undefined` when `slices` is the one slice of an unsliced change. */
100
+ export const sliceCriteria = (slices: readonly Slice[], i: number): CriteriaSlice | undefined =>
101
+ slices.length === 1
102
+ ? undefined
103
+ : i < slices.length - 1
104
+ ? { only: slices[i]!.ids }
105
+ : { except: slices.slice(0, -1).flatMap((s) => s.ids) }
@@ -25,6 +25,7 @@ export * from "./feature/criteria.js"
25
25
  export * from "./feature/planning.js"
26
26
  export * from "./feature/refactor.js"
27
27
  export * from "./feature/seam.js"
28
+ export * from "./feature/slicing.js"
28
29
  export {
29
30
  buildTail,
30
31
  type BuildTailOptions,
@@ -17,3 +17,8 @@ export const markFenced = (text: string): readonly { line: string; fenced: boole
17
17
  return { line, fenced }
18
18
  })
19
19
  }
20
+
21
+ /** A markdown heading: its `#` run, then its text. */
22
+ export const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
23
+ /** A list item, its checkbox dropped: its text. */
24
+ export const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/
@@ -21,6 +21,7 @@ export const ShippedOptions = Schema.Struct({
21
21
  reviewSeverityMinP: setting,
22
22
  refactorFlagPattern: setting,
23
23
  seamAllow: setting,
24
+ sliceMaxCriteria: setting,
24
25
  qualityReviews: setting,
25
26
  refactorQualityReviews: setting,
26
27
  testCommand: setting,
@@ -14,6 +14,7 @@ export const REVIEW = ".gtd/REVIEW.md"
14
14
  export const REFACTOR = ".gtd/REFACTOR.md"
15
15
  export const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md"
16
16
  export const QUALITY = ".gtd/QUALITY.md"
17
+ export const SLICES = ".gtd/SLICES.md"
17
18
 
18
19
  export const planner = (): string => env.plannerModel ?? ""
19
20
  export const coder = (): string => env.coderModel ?? ""
@@ -290,6 +290,30 @@ printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(".gtd/REQUIREMENTS.md")}
290
290
  `
291
291
  }
292
292
 
293
+ export const slicingPrompt = (cap: number): string =>
294
+ `${stateFileRules}
295
+ - Read \`.gtd/REQUIREMENTS.md\`: its \`## Acceptance Criteria\` are settled
296
+ and more than ${cap}, too many to build in one slice
297
+ - Group them into slices in \`.gtd/SLICES.md\`, one \`## <title>\` heading per
298
+ slice over a \`- AC-<n>\` list item per criterion it builds. Put criteria of
299
+ the same flow or subsystem together; every criterion in exactly one slice,
300
+ at most ${cap} per slice
301
+ - Keep it coarse: as few slices as the cap allows, never one per criterion
302
+ - Order the slices so each builds on the ones before: they run in that order,
303
+ each from where the previous one landed
304
+ - Change nothing but \`.gtd/SLICES.md\` — leave the criteria as they are
305
+ - Leave everything uncommitted and finish your turn — do not commit
306
+ `
307
+
308
+ /** The line telling a slice's turn which criteria are its own; empty for a single slice. */
309
+ const sliceLine = (ids: readonly string[] | undefined): string =>
310
+ ids === undefined
311
+ ? ""
312
+ : `- This slice builds ${ids.join(", ")} only: every rule here about the criteria
313
+ means these. The others are for other slices, before or after it — leave
314
+ them unbuilt and untested
315
+ `
316
+
293
317
  export const designSystem = (): string =>
294
318
  `${designPersona}
295
319
 
@@ -1203,10 +1227,10 @@ ${listed(retry.strays)}
1203
1227
  Start over and choose a different approach; do not rebuild the same diff.
1204
1228
  `
1205
1229
 
1206
- export const naivePassPrompt = (retry?: NaiveRetry): string =>
1230
+ export const naivePassPrompt = (retry?: NaiveRetry, slice?: readonly string[]): string =>
1207
1231
  `${stateFileRules}
1208
1232
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1209
- ${criteriaBinding(false)}
1233
+ ${sliceLine(slice)}${criteriaBinding(false)}
1210
1234
  - Implement them the quickest way that works — the naive version. Do not
1211
1235
  restructure, abstract or refactor around the change; it may land exactly as
1212
1236
  you leave it, and a reviewer makes it fit
@@ -1289,6 +1313,25 @@ const afterRefactorLine = (rebuild: Rebuild): string =>
1289
1313
  rule below; if nothing is, ${carries(rebuild) ? "carry over the acceptance tests below and change nothing else" : "change nothing and finish your turn"}
1290
1314
  `
1291
1315
 
1316
+ const flagLine = (
1317
+ flagMode: boolean,
1318
+ tier: "low" | "high" | undefined,
1319
+ amend: boolean | undefined,
1320
+ sliced: boolean,
1321
+ ): string =>
1322
+ !flagMode
1323
+ ? "- Add no release flag — this project ships without release flags"
1324
+ : tier === "low"
1325
+ ? "- Add no release flag — this change is low risk tier"
1326
+ : amend === true
1327
+ ? "- Keep the release flag the earlier lap added, if it added one; otherwise put\n the behaviour behind a new release flag"
1328
+ : sliced
1329
+ ? `- Put the new behaviour behind the release flag an earlier slice of this
1330
+ change added; only when none did, behind a new one, following the
1331
+ repository's flag conventions`
1332
+ : `- Put the new behaviour behind a new release flag, following the
1333
+ repository's flag conventions`
1334
+
1292
1335
  export const implementBuildingPrompt = (
1293
1336
  reference: string | undefined,
1294
1337
  tier?: "low" | "high",
@@ -1296,10 +1339,11 @@ export const implementBuildingPrompt = (
1296
1339
  afterRefactor?: boolean,
1297
1340
  flagMode = true,
1298
1341
  rebuild: Rebuild = {},
1342
+ slice?: readonly string[],
1299
1343
  ): string =>
1300
1344
  `${stateFileRules}
1301
1345
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1302
- ${criteriaBinding(true)}
1346
+ ${sliceLine(slice)}${criteriaBinding(true)}
1303
1347
  ${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${
1304
1348
  amend === true
1305
1349
  ? `- The change already exists in the tree from an earlier lap; amend it to meet
@@ -1318,16 +1362,7 @@ ${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${
1318
1362
  Read it for ideas; never apply, cherry-pick or check it out — write
1319
1363
  the change fresh${carries(rebuild) ? ", its acceptance tests excepted (below)" : ""}
1320
1364
  `
1321
- }${rebuildLines(rebuild)}${
1322
- !flagMode
1323
- ? "- Add no release flag — this project ships without release flags"
1324
- : tier === "low"
1325
- ? "- Add no release flag — this change is low risk tier"
1326
- : amend === true
1327
- ? "- Keep the release flag the earlier lap added, if it added one; otherwise put\n the behaviour behind a new release flag"
1328
- : `- Put the new behaviour behind a new release flag, following the
1329
- repository's flag conventions`
1330
- }
1365
+ }${rebuildLines(rebuild)}${flagLine(flagMode, tier, amend, slice !== undefined)}
1331
1366
  - Write the tests first, then the code that makes them pass. Name each
1332
1367
  criterion's ID in the test that covers it (\`[AC-3] …\`): once the suite is
1333
1368
  green a judge checks every ID has a passing test, and sends uncovered ones back
@@ -1437,10 +1472,10 @@ export const coverageMessage = (): string =>
1437
1472
  counts every criterion as uncovered.
1438
1473
  `
1439
1474
 
1440
- export const coveringPrompt = (uncovered: readonly string[]): string =>
1475
+ export const coveringPrompt = (uncovered: readonly string[], slice?: readonly string[]): string =>
1441
1476
  `${stateFileRules}
1442
1477
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1443
- ${criteriaBinding(true)}
1478
+ ${sliceLine(slice)}${criteriaBinding(true)}
1444
1479
  - A judge found no passing test in this change that covers ${uncovered.join(", ")}.
1445
1480
  Write a behaviour-level test for each, naming its ID in the test or scenario
1446
1481
  name (\`[${uncovered[0]}] …\`), and fix the code if the test shows it falls short
@@ -26,6 +26,7 @@ export const defaults: Readonly<Record<string, string>> = {
26
26
  refactorFlagPattern: "\\bGTD_FLAG_\\w+",
27
27
  seamAllow: [...testFileGlobs, ".gtd/**"].join(", "),
28
28
  flagMode: "true",
29
+ sliceMaxCriteria: "8",
29
30
  // GTD_FLAG_RISK_TIER, recorded at process start; only "1" is on.
30
31
  flag_risk_tier: "0",
31
32
  // Decides the turn count: one `build.quality.<lens>` scope per entry, each
@@ -52,3 +52,16 @@ Then("no commit body contains {string}", (world: TestkitWorld, text: string) =>
52
52
  .find((m) => m.includes(text))
53
53
  assert.ok(hit === undefined, `a commit body contains "${text}":\n${hit}`)
54
54
  })
55
+
56
+ Then(
57
+ "slice {string} begins where slice {string} ends",
58
+ async (world: TestkitWorld, later: string, earlier: string) => {
59
+ const { stdout } = await world.repo().gtd("slices")
60
+ const range = (name: string): string[] => {
61
+ const line = stdout.split("\n").find((l) => l.startsWith(`${name} `))
62
+ assert.ok(line !== undefined, stdout)
63
+ return line.slice(name.length + 1).split("..")
64
+ }
65
+ assert.strictEqual(range(later)[0], range(earlier)[1], stdout)
66
+ },
67
+ )