@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -0
- package/dist/chunks/{reserved-zAfoJtR7.mjs → reserved-sfMdy7v7.mjs} +33 -9
- package/dist/chunks/{tail-BRC4DGdV.mjs → tail-Cgca5Gp-.mjs} +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +168 -42
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +198 -48
- package/package.json +1 -1
- package/src/testkit/steps.ts +35 -0
- package/src/workflows/feature/coverage.ts +14 -6
- package/src/workflows/feature/criteria.test.ts +17 -1
- package/src/workflows/feature/criteria.ts +26 -3
- package/src/workflows/feature/feature.ts +64 -15
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/seam.ts +63 -26
- package/src/workflows/feature/slicing-cap.feature +14 -0
- package/src/workflows/feature/slicing.feature +225 -0
- package/src/workflows/feature/slicing.test.ts +49 -0
- package/src/workflows/feature/slicing.ts +105 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/fences.ts +5 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +1 -0
- package/src/workflows/shared/text.ts +50 -15
- package/src/workflows/shared/vars.ts +1 -0
- package/src/workflows/shared/workflows.steps.ts +13 -0
|
@@ -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) }
|
package/src/workflows/index.ts
CHANGED
|
@@ -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+)?(.*)$/
|
|
@@ -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
|
+
)
|