@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
|
@@ -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 {
|
|
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 {
|
|
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 →
|
|
26
|
-
// lands as the implementation, or (refactoring
|
|
27
|
-
// built fresh from → the health loop → the
|
|
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
|
-
/**
|
|
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
|
|
44
|
-
|
|
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
|
|
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
|
-
?
|
|
104
|
+
? sliceBase
|
|
59
105
|
: seam.base
|
|
60
|
-
|
|
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")) {
|
|
@@ -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 {
|
|
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
|
-
(
|
|
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 = (
|
|
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
|
|
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 (
|
|
344
|
-
|
|
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
|
|
414
|
-
|
|
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)
|
|
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
|
|
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
|
+
})
|