@pmelab/gtd 34.0.0 → 36.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 +35 -26
- package/dist/chunks/{reserved-BcJgt5_R.mjs → reserved-BAv71I4i.mjs} +43 -10
- package/dist/chunks/{tail-CM_fa93v.mjs → tail-kar1zhOb.mjs} +1 -1
- package/dist/flows.mjs +15 -10
- package/dist/gtd.bundle.mjs +14 -9
- package/dist/testkit.mjs +43 -23
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +97 -20
- 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 +152 -38
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +3 -3
- package/src/flows/scripts.ts +31 -9
- package/src/testkit/FlowScripts.test.ts +58 -0
- package/src/testkit/FlowScripts.ts +61 -24
- package/src/workflows/feature/acceptance-criteria.feature +5 -1
- package/src/workflows/feature/coverage.feature +164 -0
- package/src/workflows/feature/coverage.ts +106 -0
- package/src/workflows/feature/fast-path.feature +17 -20
- package/src/workflows/feature/feature.test.ts +25 -4
- package/src/workflows/feature/feature.ts +13 -9
- package/src/workflows/feature/flag-mode.feature +23 -10
- package/src/workflows/feature/lens-models.feature +11 -1
- package/src/workflows/feature/naive-acceptance.feature +2 -2
- package/src/workflows/feature/one-step-land.feature +16 -13
- package/src/workflows/feature/quality-unresolved.feature +11 -1
- package/src/workflows/feature/refactor-absorbs.feature +6 -1
- package/src/workflows/feature/refactor-first.feature +6 -1
- package/src/workflows/feature/refactor-review.feature +24 -0
- package/src/workflows/feature/risk-tier.feature +12 -4
- package/src/workflows/feature/seam-rejection.feature +10 -10
- package/src/workflows/feature/seam.feature +35 -27
- package/src/workflows/feature/seam.test.ts +12 -4
- package/src/workflows/feature/seam.ts +42 -32
- package/src/workflows/review/review-door.feature +18 -5
- package/src/workflows/shared/text.test.ts +8 -0
- package/src/workflows/shared/text.ts +54 -9
package/src/flows/scripts.ts
CHANGED
|
@@ -55,25 +55,31 @@ export const checkScript = (command: string, options: CheckScriptOptions): strin
|
|
|
55
55
|
].join("\n")
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
const patchScript = (
|
|
59
|
+
commit: string,
|
|
60
|
+
report: string,
|
|
61
|
+
failure: string,
|
|
62
|
+
reverse: boolean,
|
|
63
|
+
exclude: readonly string[] = [],
|
|
64
|
+
): string => {
|
|
65
|
+
const paths =
|
|
66
|
+
exclude.length === 0
|
|
67
|
+
? ""
|
|
68
|
+
: ` . ${exclude.map((p) => quote(`:(exclude,literal)${p}`)).join(" ")}`
|
|
64
69
|
const range = `${quote(`${commit}^`)} ${quote(commit)}`
|
|
65
70
|
const error = quote(`${report}.output`)
|
|
66
71
|
return [
|
|
67
72
|
"#!/usr/bin/env sh",
|
|
68
73
|
"set +e",
|
|
69
|
-
`if ! git diff --quiet ${range}
|
|
74
|
+
`if ! git diff --quiet ${range} --${paths}; then`,
|
|
70
75
|
` mkdir -p "$(dirname ${quote(report)})"`,
|
|
71
|
-
|
|
76
|
+
// Pinned flags: a user's diff.noprefix, color.ui, diff.external, textconv or apply.whitespace would change the patch.
|
|
77
|
+
` git diff --binary --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ ${range} --${paths} | git apply --whitespace=nowarn${reverse ? " -R" : ""} 2> ${error}`,
|
|
72
78
|
" code=$?",
|
|
73
79
|
' if [ "$code" -ne 0 ]; then',
|
|
74
80
|
" {",
|
|
75
81
|
` printf '%s\\n\\n' ${quote(failure)}`,
|
|
76
|
-
` if [ -s ${error} ]; then cat ${error}; else printf 'Reverting it exited %s and produced no output.\\n' "$code"; fi`,
|
|
82
|
+
` if [ -s ${error} ]; then cat ${error}; else printf '${reverse ? "Reverting" : "Applying"} it exited %s and produced no output.\\n' "$code"; fi`,
|
|
77
83
|
` } > ${quote(report)}`,
|
|
78
84
|
" fi",
|
|
79
85
|
` rm -f ${error}`,
|
|
@@ -82,6 +88,22 @@ export const revertScript = (commit: string, report: string, failure: string): s
|
|
|
82
88
|
].join("\n")
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Reverse-apply `commit`'s changes to the working tree. A failure — the
|
|
93
|
+
* patch no longer applies — writes `failure` and git's error to `report`,
|
|
94
|
+
* since a failed revert and one with nothing to undo both leave the tree clean.
|
|
95
|
+
*/
|
|
96
|
+
export const revertScript = (commit: string, report: string, failure: string): string =>
|
|
97
|
+
patchScript(commit, report, failure, true)
|
|
98
|
+
|
|
99
|
+
/** Apply `commit`'s changes outside `exclude` to the working tree; a failure is reported as `revertScript`'s is. */
|
|
100
|
+
export const applyScript = (
|
|
101
|
+
commit: string,
|
|
102
|
+
report: string,
|
|
103
|
+
failure: string,
|
|
104
|
+
exclude: readonly string[] = [],
|
|
105
|
+
): string => patchScript(commit, report, failure, false, exclude)
|
|
106
|
+
|
|
85
107
|
/**
|
|
86
108
|
* Undo the edits one commit made to `paths`: each goes back to its content
|
|
87
109
|
* before `commit` — or away, when `commit` added it — but only while it is
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
2
2
|
import {
|
|
3
|
+
applyScript,
|
|
3
4
|
checkScript,
|
|
4
5
|
moveScript,
|
|
5
6
|
removeScript,
|
|
@@ -178,6 +179,63 @@ for (const makeTier of gitTiers) {
|
|
|
178
179
|
})
|
|
179
180
|
})
|
|
180
181
|
|
|
182
|
+
describe("applyScript", () => {
|
|
183
|
+
it("replays a commit's modifications, additions and deletions onto the working tree", () => {
|
|
184
|
+
t.seed.commit("feat: base", { "mod.txt": "before", "gone.txt": "g" })
|
|
185
|
+
t.seed.writeFile("mod.txt", "after")
|
|
186
|
+
t.seed.writeFile("added.txt", "new")
|
|
187
|
+
t.seed.deleteFile("gone.txt")
|
|
188
|
+
t.seed.stageAll()
|
|
189
|
+
t.seed.commit("feat: change", {})
|
|
190
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
191
|
+
t.seed.writeFile("gone.txt", "g")
|
|
192
|
+
t.seed.deleteFile("added.txt")
|
|
193
|
+
t.seed.stageAll()
|
|
194
|
+
t.seed.commit("revert: change", { "mod.txt": "before" })
|
|
195
|
+
const head = t.observe.resolveRef("HEAD")
|
|
196
|
+
expect(run(applyScript(commit, REPORT, "could not apply")).ok).toBe(true)
|
|
197
|
+
expect(read("mod.txt")).toBe("after")
|
|
198
|
+
expect(read("added.txt")).toBe("new")
|
|
199
|
+
expect(read("gone.txt")).toBeUndefined()
|
|
200
|
+
expect(read(REPORT)).toBeUndefined()
|
|
201
|
+
expect(t.observe.resolveRef("HEAD")).toBe(head)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("leaves excluded paths as they are", () => {
|
|
205
|
+
t.seed.commit("feat: base", { "a.txt": "a0", ".gtd/R.md": "r0" })
|
|
206
|
+
t.seed.commit("feat: change", { "a.txt": "a1", ".gtd/R.md": "r1", ".gtd/new.md": "n" })
|
|
207
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
208
|
+
t.seed.commit("revert: change", { "a.txt": "a0", ".gtd/R.md": "r2" })
|
|
209
|
+
t.seed.commitDeletion(".gtd/new.md", "revert: change")
|
|
210
|
+
expect(run(applyScript(commit, REPORT, "could not apply", [".gtd"])).ok).toBe(true)
|
|
211
|
+
expect(read(REPORT)).toBeUndefined()
|
|
212
|
+
expect(read("a.txt")).toBe("a1")
|
|
213
|
+
expect(read(".gtd/R.md")).toBe("r2")
|
|
214
|
+
expect(read(".gtd/new.md")).toBeUndefined()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("does nothing for a commit with nothing to apply", () => {
|
|
218
|
+
t.seed.commit("feat: empty", {})
|
|
219
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
220
|
+
expect(run(applyScript(commit, REPORT, "could not apply")).ok).toBe(true)
|
|
221
|
+
expect(read(REPORT)).toBeUndefined()
|
|
222
|
+
expect(t.observe.statusPorcelain()).toBe("")
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("reports a commit that cannot apply, leaving the tree untouched", () => {
|
|
226
|
+
t.seed.commit("feat: base", { "a.txt": "a0" })
|
|
227
|
+
t.seed.commit("feat: change", { "a.txt": "a1", "b.txt": "b1" })
|
|
228
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
229
|
+
t.seed.commitDeletion("b.txt", "revert: change")
|
|
230
|
+
t.seed.commit("revert: change", { "a.txt": "a0" })
|
|
231
|
+
t.seed.writeFile("b.txt", "other")
|
|
232
|
+
expect(run(applyScript(commit, REPORT, "could not apply")).ok).toBe(true)
|
|
233
|
+
expect(read(REPORT)).toMatch(/^could not apply\n\n\S/)
|
|
234
|
+
expect(read("a.txt")).toBe("a0")
|
|
235
|
+
expect(read("b.txt")).toBe("other")
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
181
239
|
describe("checkScript", () => {
|
|
182
240
|
const options = { report: REPORT, stamp: "abc1234" }
|
|
183
241
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// `sh` and this twin, so the two cannot drift apart silently.
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
applyScript,
|
|
8
9
|
checkScript,
|
|
9
10
|
moveScript,
|
|
10
11
|
removeScript,
|
|
@@ -129,44 +130,80 @@ const recognizeRestore = (repo: InMemRepo, script: string): FlowScriptResult | u
|
|
|
129
130
|
|
|
130
131
|
const PATCH_FAILED = "error: patch does not apply"
|
|
131
132
|
|
|
133
|
+
const outside = (path: string, exclude: readonly string[]): boolean =>
|
|
134
|
+
!exclude.some((e) => path === e || path.startsWith(`${e}/`))
|
|
135
|
+
|
|
136
|
+
/** Why `path` blocks the patch, `undefined` when it is exactly as `from` has it. */
|
|
137
|
+
const blocking = (repo: InMemRepo, path: string, from: string, to: string): string | undefined => {
|
|
138
|
+
const expected = repo.fileAtRef(from, path)
|
|
139
|
+
const current = repo.readFile(path) ?? null
|
|
140
|
+
if (current === expected) return undefined
|
|
141
|
+
if (current === null || expected === null || repo.fileAtRef(to, path) === null) {
|
|
142
|
+
return `${PATCH_FAILED}: ${path}`
|
|
143
|
+
}
|
|
144
|
+
throw new Error(
|
|
145
|
+
`testkit: cannot tell in memory whether the patch of ${from}..${to} applies to the hand-edited ${path} — run this @live`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
/**
|
|
133
|
-
* `git diff C^ C | git apply -R`, which is all or nothing. A file
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
150
|
+
* `git diff C^ C | git apply [-R]`, which is all or nothing. A file exactly
|
|
151
|
+
* as the side being undone (`from`) has it moves to the other side (`to`); a
|
|
152
|
+
* file missing where `from` has one, or present where `from` has none, fails
|
|
153
|
+
* the whole patch. A file both sides have that differs from `from` may still
|
|
154
|
+
* apply through its context — that needs real git, so `blocking` throws
|
|
155
|
+
* rather than guess.
|
|
138
156
|
*/
|
|
139
|
-
const
|
|
157
|
+
const patch = (
|
|
158
|
+
repo: InMemRepo,
|
|
159
|
+
commit: string,
|
|
160
|
+
reverse: boolean,
|
|
161
|
+
exclude: readonly string[],
|
|
162
|
+
): string | undefined => {
|
|
140
163
|
const parent = `${commit}^`
|
|
164
|
+
const [from, to] = reverse ? [commit, parent] : [parent, commit]
|
|
141
165
|
const paths = new Set([...repo.pathsAtRef(parent), ...repo.pathsAtRef(commit)])
|
|
142
|
-
const changed = [...paths].filter(
|
|
166
|
+
const changed = [...paths].filter(
|
|
167
|
+
(p) => repo.fileAtRef(parent, p) !== repo.fileAtRef(commit, p) && outside(p, exclude),
|
|
168
|
+
)
|
|
143
169
|
for (const path of changed) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
if (current === after) continue
|
|
147
|
-
if (current === null || after === null || repo.fileAtRef(parent, path) === null) {
|
|
148
|
-
return `${PATCH_FAILED}: ${path}`
|
|
149
|
-
}
|
|
150
|
-
throw new Error(
|
|
151
|
-
`testkit: cannot tell in memory whether reverting ${commit} applies to the hand-edited ${path} — run this @live`,
|
|
152
|
-
)
|
|
170
|
+
const error = blocking(repo, path, from, to)
|
|
171
|
+
if (error !== undefined) return error
|
|
153
172
|
}
|
|
154
173
|
for (const path of changed) {
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
else repo.writeFile(path,
|
|
174
|
+
const content = repo.fileAtRef(to, path)
|
|
175
|
+
if (content === null) repo.deleteFile(path)
|
|
176
|
+
else repo.writeFile(path, content)
|
|
158
177
|
}
|
|
159
178
|
return undefined
|
|
160
179
|
}
|
|
161
180
|
|
|
162
|
-
|
|
181
|
+
interface ParsedPatch {
|
|
182
|
+
readonly commit: string
|
|
183
|
+
readonly failure: string
|
|
184
|
+
readonly report: string
|
|
185
|
+
readonly exclude: readonly string[]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const parsePatch = (script: string): ParsedPatch | undefined => {
|
|
163
189
|
const lines = script.split("\n")
|
|
164
|
-
const [, commit] = words(
|
|
190
|
+
const [, commit, ...pathspecs] = words(
|
|
191
|
+
lines.find((l) => l.startsWith("if ! git diff --quiet ")) ?? "",
|
|
192
|
+
)
|
|
165
193
|
const [, failure] = words(lines.find((l) => l.includes("printf '%s\\n\\n' ")) ?? "")
|
|
166
194
|
const [report] = words(lines.find((l) => l.startsWith(" } > ")) ?? "")
|
|
167
195
|
if (commit === undefined || failure === undefined || report === undefined) return undefined
|
|
168
|
-
|
|
169
|
-
|
|
196
|
+
const exclude = pathspecs.map((p) => p.replace(/^:\(exclude,literal\)/, ""))
|
|
197
|
+
return { commit, failure, report, exclude }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const recognizePatch = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
|
|
201
|
+
const parsed = parsePatch(script)
|
|
202
|
+
if (parsed === undefined) return undefined
|
|
203
|
+
const { commit, failure, report, exclude } = parsed
|
|
204
|
+
const reverse = revertScript(commit, report, failure) === script
|
|
205
|
+
if (!reverse && applyScript(commit, report, failure, exclude) !== script) return undefined
|
|
206
|
+
const error = patch(repo, commit, reverse, reverse ? [] : exclude)
|
|
170
207
|
if (error !== undefined) repo.writeFile(report, `${failure}\n\n${error}\n`)
|
|
171
208
|
return ok()
|
|
172
209
|
}
|
|
@@ -319,7 +356,7 @@ export const applyFlowScript = (
|
|
|
319
356
|
recognizeRemove(repo, script) ??
|
|
320
357
|
recognizeMove(repo, script) ??
|
|
321
358
|
recognizeShow(repo, script) ??
|
|
322
|
-
|
|
359
|
+
recognizePatch(repo, script) ??
|
|
323
360
|
recognizeRestore(repo, script) ??
|
|
324
361
|
recognizeCheck(repo, commands, script)
|
|
325
362
|
)
|
|
@@ -86,7 +86,6 @@ Feature: acceptance criteria carry stable IDs, and a sketch that states clear on
|
|
|
86
86
|
- AC-1: `greeting` reads "Hello, world".
|
|
87
87
|
"""
|
|
88
88
|
And step "design.gate.answer" changes nothing
|
|
89
|
-
And step "seam.check" changes nothing
|
|
90
89
|
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
91
90
|
"""
|
|
92
91
|
it("[AC-1] holds", () => {})
|
|
@@ -98,10 +97,15 @@ Feature: acceptance criteria carry stable IDs, and a sketch that states clear on
|
|
|
98
97
|
"""
|
|
99
98
|
export const greeting = "Hello, world"
|
|
100
99
|
"""
|
|
100
|
+
And step "seam.check" writes "src/acceptance.test.ts" with:
|
|
101
|
+
"""
|
|
102
|
+
it("[AC-1] holds elsewhere", () => {})
|
|
103
|
+
"""
|
|
101
104
|
And judge step "seam.judge" answers:
|
|
102
105
|
"""
|
|
103
106
|
[{"id":"shape","answer":"land","p":0.9}]
|
|
104
107
|
"""
|
|
108
|
+
And step "implement.applying" runs its script
|
|
105
109
|
And step "implement.building" writes ".gtd/REQUIREMENTS.md" with:
|
|
106
110
|
"""
|
|
107
111
|
# Requirements
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: a feature is done only when every acceptance criterion has a passing test
|
|
3
|
+
|
|
4
|
+
Once the implementation's suite is green, implement.coverage asks a judge,
|
|
5
|
+
per criterion ID of .gtd/REQUIREMENTS.md, whether a test the change added or
|
|
6
|
+
changed covers it. Every ID covered lets the process close out, the judge's
|
|
7
|
+
landing marking each as `Gtd-Mark: covers=<id>`. An uncovered ID sends the
|
|
8
|
+
process back to a covering turn instead; after two, it rests for a person.
|
|
9
|
+
|
|
10
|
+
Background:
|
|
11
|
+
Given the shipped workflows, as gtd install scaffolds them
|
|
12
|
+
And an environment variable "GTD_QUALITYREVIEWS" set to ""
|
|
13
|
+
And an environment variable "GTD_FLAGMODE" set to "false"
|
|
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
|
+
# Add subtract
|
|
22
|
+
|
|
23
|
+
subtract(a, b) returns the difference.
|
|
24
|
+
"""
|
|
25
|
+
And step "unwind" changes nothing
|
|
26
|
+
And step "start-gate.check" changes nothing
|
|
27
|
+
And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
28
|
+
"""
|
|
29
|
+
# Requirements
|
|
30
|
+
|
|
31
|
+
## Acceptance Criteria
|
|
32
|
+
|
|
33
|
+
- AC-1: subtract(a, b) returns a - b.
|
|
34
|
+
- AC-2: add is unchanged.
|
|
35
|
+
"""
|
|
36
|
+
And step "design.gate.answer" changes nothing
|
|
37
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
38
|
+
"""
|
|
39
|
+
export const add = (a: number, b: number) => a + b
|
|
40
|
+
export const subtract = (a: number, b: number) => a - b
|
|
41
|
+
"""
|
|
42
|
+
And step "seam.naive" writes "src/calc.test.ts" with:
|
|
43
|
+
"""
|
|
44
|
+
it("[AC-1] subtracts", () => expect(subtract(3, 1)).toBe(2))
|
|
45
|
+
// AC-2: add is untouched
|
|
46
|
+
"""
|
|
47
|
+
And step "seam.check" changes nothing
|
|
48
|
+
And step "implement.applying" runs its script
|
|
49
|
+
And step "implement.health.check" changes nothing
|
|
50
|
+
And step "implement.closing" runs its script
|
|
51
|
+
|
|
52
|
+
Scenario: the judge asks per criterion over the change's test files
|
|
53
|
+
When the process is driven
|
|
54
|
+
Then the process rests at "implement.coverage"
|
|
55
|
+
And the output of gtd "judge" contains "add is unchanged."
|
|
56
|
+
And the output of gtd "judge" contains "[AC-1] subtracts"
|
|
57
|
+
|
|
58
|
+
Scenario: every ID covered ends the feature and marks the IDs it covers
|
|
59
|
+
Given judge step "implement.coverage" answers:
|
|
60
|
+
"""
|
|
61
|
+
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
|
|
62
|
+
"""
|
|
63
|
+
When the process is driven
|
|
64
|
+
Then the process rests at "idle"
|
|
65
|
+
And the commit subjects end with:
|
|
66
|
+
"""
|
|
67
|
+
gtd(check): implement.applying → implement.health.check
|
|
68
|
+
gtd(check): implement.health.check → implement.coverage
|
|
69
|
+
gtd(judge): implement.coverage → implement.closing
|
|
70
|
+
gtd(check): implement.closing → idle
|
|
71
|
+
"""
|
|
72
|
+
And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
|
|
73
|
+
And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-2"
|
|
74
|
+
And no commit body contains "Gtd-Mark: uncovered="
|
|
75
|
+
|
|
76
|
+
Scenario: one uncovered ID sends the process back instead of ending it
|
|
77
|
+
Given judge step "implement.coverage" answers:
|
|
78
|
+
"""
|
|
79
|
+
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"no","p":0.9}]
|
|
80
|
+
"""
|
|
81
|
+
When the process is driven
|
|
82
|
+
Then the process rests at "implement.covering"
|
|
83
|
+
And the last commit subject is "gtd(judge): implement.coverage → implement.covering"
|
|
84
|
+
And the resting step's content contains "no passing test in this change that covers AC-2."
|
|
85
|
+
And no commit body contains "Gtd-Mark: covers="
|
|
86
|
+
|
|
87
|
+
Scenario: a yes below the confidence floor does not cover
|
|
88
|
+
Given judge step "implement.coverage" answers:
|
|
89
|
+
"""
|
|
90
|
+
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.5}]
|
|
91
|
+
"""
|
|
92
|
+
When the process is driven
|
|
93
|
+
Then the process rests at "implement.covering"
|
|
94
|
+
And the resting step's content contains "covers AC-2."
|
|
95
|
+
|
|
96
|
+
Scenario: a covering turn runs the suite before the judge rules again
|
|
97
|
+
Given judge step "implement.coverage" answers:
|
|
98
|
+
"""
|
|
99
|
+
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"no","p":0.9}]
|
|
100
|
+
"""
|
|
101
|
+
And step "implement.covering" writes "src/calc.test.ts" with:
|
|
102
|
+
"""
|
|
103
|
+
it("[AC-1] subtracts", () => expect(subtract(3, 1)).toBe(2))
|
|
104
|
+
it("[AC-2] adds", () => expect(add(1, 1)).toBe(2))
|
|
105
|
+
"""
|
|
106
|
+
When the process is driven
|
|
107
|
+
Then the process rests at "implement.uncovered"
|
|
108
|
+
And the commit subjects end with:
|
|
109
|
+
"""
|
|
110
|
+
gtd(judge): implement.coverage → implement.covering
|
|
111
|
+
gtd(agent): implement.covering → implement.health.check
|
|
112
|
+
gtd(check): implement.health.check → implement.coverage
|
|
113
|
+
gtd(judge): implement.coverage → implement.covering
|
|
114
|
+
gtd(agent): implement.covering → implement.health.check
|
|
115
|
+
gtd(check): implement.health.check → implement.coverage
|
|
116
|
+
gtd(judge): implement.coverage → implement.uncovered
|
|
117
|
+
"""
|
|
118
|
+
And the resting step's content contains "still finds no passing test for AC-2"
|
|
119
|
+
|
|
120
|
+
Scenario: a covering turn may not change the acceptance criteria
|
|
121
|
+
Given judge step "implement.coverage" answers:
|
|
122
|
+
"""
|
|
123
|
+
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"no","p":0.9}]
|
|
124
|
+
"""
|
|
125
|
+
And step "implement.covering" writes ".gtd/REQUIREMENTS.md" with:
|
|
126
|
+
"""
|
|
127
|
+
# Requirements
|
|
128
|
+
|
|
129
|
+
## Acceptance Criteria
|
|
130
|
+
|
|
131
|
+
- AC-1: subtract(a, b) returns a - b.
|
|
132
|
+
"""
|
|
133
|
+
Then driving the process is refused with "criteria-frozen"
|
|
134
|
+
|
|
135
|
+
Scenario: overruling at the gate ends the feature, recording the uncovered IDs
|
|
136
|
+
Given judge step "implement.coverage" answers:
|
|
137
|
+
"""
|
|
138
|
+
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"no","p":0.9}]
|
|
139
|
+
"""
|
|
140
|
+
And step "implement.covering" writes "src/calc.test.ts" with:
|
|
141
|
+
"""
|
|
142
|
+
it("[AC-1] subtracts", () => expect(subtract(3, 1)).toBe(2))
|
|
143
|
+
it("[AC-2] adds", () => expect(add(1, 1)).toBe(2))
|
|
144
|
+
"""
|
|
145
|
+
And step "implement.uncovered" changes nothing
|
|
146
|
+
When the process is driven
|
|
147
|
+
Then the process rests at "idle"
|
|
148
|
+
And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
|
|
149
|
+
And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: uncovered=AC-2"
|
|
150
|
+
|
|
151
|
+
Scenario: an empty covering turn asks the judge again rather than stalling
|
|
152
|
+
Given judge step "implement.coverage" answers:
|
|
153
|
+
"""
|
|
154
|
+
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"no","p":0.9}]
|
|
155
|
+
"""
|
|
156
|
+
And step "implement.covering" changes nothing
|
|
157
|
+
When the process is driven
|
|
158
|
+
Then the process rests at "implement.uncovered"
|
|
159
|
+
And the commit subjects end with:
|
|
160
|
+
"""
|
|
161
|
+
gtd(agent): implement.covering → implement.health.check
|
|
162
|
+
gtd(check): implement.health.check → implement.coverage
|
|
163
|
+
gtd(judge): implement.coverage → implement.uncovered
|
|
164
|
+
"""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
answered,
|
|
3
|
+
changes,
|
|
4
|
+
changesSince,
|
|
5
|
+
human,
|
|
6
|
+
judge,
|
|
7
|
+
mark,
|
|
8
|
+
read,
|
|
9
|
+
start,
|
|
10
|
+
} from "../../flows/index.js"
|
|
11
|
+
import { healthy } from "../shared/health.js"
|
|
12
|
+
import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
|
|
13
|
+
import * as t from "../shared/text.js"
|
|
14
|
+
import { criteriaOf, requireCriteriaFrozen } from "./criteria.js"
|
|
15
|
+
import { isTestFile } from "./refactor.js"
|
|
16
|
+
import { isCode } from "./review.js"
|
|
17
|
+
|
|
18
|
+
/** Below this the judge's `yes` is not trusted: a wrongly covered ID ends the feature without its test. */
|
|
19
|
+
const COVERED_MIN_P = 0.7
|
|
20
|
+
|
|
21
|
+
/** Covering turns before the process rests at `uncovered`; a human edit there refills them. */
|
|
22
|
+
const COVERING_RETRIES = 2
|
|
23
|
+
|
|
24
|
+
const frozen = (): void => requireCriteriaFrozen(REQUIREMENTS)
|
|
25
|
+
|
|
26
|
+
export const green = (): Promise<void> =>
|
|
27
|
+
healthy(
|
|
28
|
+
() =>
|
|
29
|
+
t.agentWithSkills("fix.suite.fixing", t.implementFixPrompt(), {
|
|
30
|
+
label: "Fixing the check",
|
|
31
|
+
file: FEEDBACK,
|
|
32
|
+
model: coder(),
|
|
33
|
+
system: t.builderSystem(),
|
|
34
|
+
}),
|
|
35
|
+
{ guard: frozen, guardRests: false },
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// A budget cut keeps the end, so the files naming a criterion ID go last.
|
|
39
|
+
const testsSince = (base: string): string =>
|
|
40
|
+
changesSince(base)
|
|
41
|
+
.filter((c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path))
|
|
42
|
+
.sort((a, b) => Number(/\bAC-\d/.test(a.after!)) - Number(/\bAC-\d/.test(b.after!)))
|
|
43
|
+
.map((c) => `=== ${c.path} (${c.status})\n${c.after}`)
|
|
44
|
+
.join("\n")
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The criterion IDs of `.gtd/REQUIREMENTS.md` — read afresh, never carried
|
|
48
|
+
* over — no passing test since `base` covers, by the judge.
|
|
49
|
+
*/
|
|
50
|
+
const uncoveredSince = async (
|
|
51
|
+
base: string,
|
|
52
|
+
): Promise<{ readonly covered: string[]; readonly uncovered: string[] }> => {
|
|
53
|
+
const requirements = read(REQUIREMENTS) ?? ""
|
|
54
|
+
const criteria = (criteriaOf(requirements) ?? []).filter((c) => c.id !== "")
|
|
55
|
+
if (criteria.length === 0) return { covered: [], uncovered: [] }
|
|
56
|
+
const { answers } = await judge("coverage", {
|
|
57
|
+
questions: criteria.map(t.coverageQuestion),
|
|
58
|
+
evidence: { tests: testsSince(base) },
|
|
59
|
+
message: t.coverageMessage(),
|
|
60
|
+
label: "Judging acceptance-criteria coverage",
|
|
61
|
+
})
|
|
62
|
+
// A budget cut keeps the evidence's end: a `no` may miss tests the judge never saw, a `yes` names one it did.
|
|
63
|
+
const covers = (id: string): boolean => answered(answers[id], "yes", COVERED_MIN_P)
|
|
64
|
+
const ids = criteria.map((c) => c.id)
|
|
65
|
+
return { covered: ids.filter(covers), uncovered: ids.filter((id) => !covers(id)) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const record = (covered: readonly string[], uncovered: readonly string[] = []): void => {
|
|
69
|
+
for (const id of covered) mark("covers", id)
|
|
70
|
+
for (const id of uncovered) mark("uncovered", id)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Hold a green change until every acceptance criterion has a passing test,
|
|
75
|
+
* sending uncovered IDs back to a covering turn; marks the IDs covered on the
|
|
76
|
+
* landing that ends it. A human may overrule once the retries are spent.
|
|
77
|
+
*/
|
|
78
|
+
export const requireCoverage = async (base = start()): Promise<void> => {
|
|
79
|
+
let retries = 0
|
|
80
|
+
for (;;) {
|
|
81
|
+
const { covered, uncovered } = await uncoveredSince(base)
|
|
82
|
+
if (uncovered.length === 0) return record(covered)
|
|
83
|
+
if (retries < COVERING_RETRIES) {
|
|
84
|
+
retries++
|
|
85
|
+
await t.agentWithSkills("covering", t.coveringPrompt(uncovered), {
|
|
86
|
+
label: "Covering the acceptance criteria",
|
|
87
|
+
model: coder(),
|
|
88
|
+
system: t.builderSystem(),
|
|
89
|
+
// The judge may have missed a test that exists: an empty turn asks it again.
|
|
90
|
+
allowEmpty: true,
|
|
91
|
+
})
|
|
92
|
+
frozen()
|
|
93
|
+
} else {
|
|
94
|
+
await human("uncovered", {
|
|
95
|
+
message: t.uncoveredMessage(uncovered),
|
|
96
|
+
label: "Acceptance criteria without a test",
|
|
97
|
+
file: REQUIREMENTS,
|
|
98
|
+
acceptClean: true,
|
|
99
|
+
})
|
|
100
|
+
if (!changes().some((c) => isCode(c.path))) return record(covered, uncovered)
|
|
101
|
+
frozen()
|
|
102
|
+
retries = 0
|
|
103
|
+
}
|
|
104
|
+
await green()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
@inmem
|
|
2
|
-
Feature: a trivial change takes the same few beats at either risk tier
|
|
2
|
+
Feature: a trivial change takes the same few beats at either risk tier
|
|
3
3
|
|
|
4
4
|
With GTD_FLAG_RISK_TIER on, a one-line copy fix goes
|
|
5
|
-
from idle through the naive pass, the seam judge's land,
|
|
6
|
-
quality lap back to idle. Low tier walks exactly the steps high does; only
|
|
7
|
-
|
|
5
|
+
from idle through the naive pass, the seam judge's land, the applied pass and
|
|
6
|
+
the quality lap back to idle. Low tier walks exactly the steps high does; only
|
|
7
|
+
the summary differs.
|
|
8
8
|
|
|
9
9
|
Background:
|
|
10
10
|
Given the shipped workflows, as gtd install scaffolds them
|
|
@@ -26,6 +26,10 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
|
|
|
26
26
|
And step "design.gate.answer" changes nothing
|
|
27
27
|
And step "implement.health.check" changes nothing
|
|
28
28
|
And step "implement.closing" runs its script
|
|
29
|
+
And judge step "implement.coverage" answers:
|
|
30
|
+
"""
|
|
31
|
+
[{"id":"AC-1","answer":"yes","p":0.9}]
|
|
32
|
+
"""
|
|
29
33
|
And step "build.quality.correctness.reviewing" changes nothing
|
|
30
34
|
|
|
31
35
|
Scenario Outline: a one-line fix runs idle to idle with the same beats at either tier
|
|
@@ -58,18 +62,10 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
|
|
|
58
62
|
"""
|
|
59
63
|
[{"id":"shape","answer":"land","p":0.9}]
|
|
60
64
|
"""
|
|
61
|
-
|
|
62
|
-
Then the process rests at "implement.building"
|
|
63
|
-
And the resting step's content contains "<demand>"
|
|
64
|
-
And the resting step's content does not contain "<absent>"
|
|
65
|
-
|
|
66
|
-
Given step "implement.building" writes "src/greeting.ts" with:
|
|
67
|
-
"""
|
|
68
|
-
export const greeting = "Hello, world!"
|
|
69
|
-
"""
|
|
65
|
+
And step "implement.applying" runs its script
|
|
70
66
|
When the process is driven
|
|
71
67
|
Then the process rests at "idle"
|
|
72
|
-
And "src/greeting.ts" contains "Hello, world
|
|
68
|
+
And "src/greeting.ts" contains "Hello, world"
|
|
73
69
|
And the commit subjects end with:
|
|
74
70
|
"""
|
|
75
71
|
gtd(human): idle → unwind
|
|
@@ -79,15 +75,16 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
|
|
|
79
75
|
gtd(human): design.gate.answer → seam.naive
|
|
80
76
|
gtd(agent): seam.naive → seam.check
|
|
81
77
|
gtd(check): seam.check → seam.judge
|
|
82
|
-
gtd(judge): seam.judge → implement.
|
|
83
|
-
gtd(
|
|
84
|
-
gtd(check): implement.health.check → implement.
|
|
78
|
+
gtd(judge): seam.judge → implement.applying
|
|
79
|
+
gtd(check): implement.applying → implement.health.check
|
|
80
|
+
gtd(check): implement.health.check → implement.coverage
|
|
81
|
+
gtd(judge): implement.coverage → implement.closing
|
|
85
82
|
gtd(check): implement.closing → build.quality.correctness.reviewing
|
|
86
83
|
gtd(agent): build.quality.correctness.reviewing → idle
|
|
87
84
|
"""
|
|
88
85
|
And the output of gtd "summary" <summary> "needs a human reviewer"
|
|
89
86
|
|
|
90
87
|
Examples:
|
|
91
|
-
| tier |
|
|
92
|
-
| low |
|
|
93
|
-
| high |
|
|
88
|
+
| tier | summary |
|
|
89
|
+
| low | does not contain |
|
|
90
|
+
| high | contains |
|
|
@@ -154,6 +154,7 @@ describe("afterTail feedback lap", () => {
|
|
|
154
154
|
state === "build.review.await-review" || state === "idle"
|
|
155
155
|
? undefined
|
|
156
156
|
: (beat, r) => {
|
|
157
|
+
if (state === "implement.coverage") return '[{"id":"AC-1","answer":"yes","p":0.9}]'
|
|
157
158
|
if (state === "build.review.reviewing") {
|
|
158
159
|
prompts.push(beat.content)
|
|
159
160
|
r.write(".gtd/REVIEW.md", review())
|
|
@@ -364,10 +365,30 @@ describe("flagMode", () => {
|
|
|
364
365
|
expect((await designed(config("feature({ flagMode: true })"))).state).toBe("seam.naive")
|
|
365
366
|
})
|
|
366
367
|
|
|
367
|
-
it("off through GTD_FLAGMODE=false,
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
368
|
+
it("off through GTD_FLAGMODE=false, lands the naive pass with no seam judge", async () => {
|
|
369
|
+
const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_FLAGMODE: "false" } })
|
|
370
|
+
repo.write("src/calc.ts", CALC)
|
|
371
|
+
repo.commit("feat: add calculator")
|
|
372
|
+
repo.write("TODO.md", "# Add subtract\n")
|
|
373
|
+
const rest = await repo.drive({
|
|
374
|
+
unwind: () => undefined,
|
|
375
|
+
"start-gate.check": () => undefined,
|
|
376
|
+
"design.triage": (_, r) =>
|
|
377
|
+
r.write(
|
|
378
|
+
".gtd/REQUIREMENTS.md",
|
|
379
|
+
"# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n\n## Acceptance Criteria\n\n- AC-1: done\n",
|
|
380
|
+
),
|
|
381
|
+
"design.gate.answer": () => undefined,
|
|
382
|
+
"seam.naive": (_, r) => {
|
|
383
|
+
r.write("src/calc.ts", `${CALC}// naive\n`)
|
|
384
|
+
r.write("src/calc.test.ts", ACCEPTANCE)
|
|
385
|
+
},
|
|
386
|
+
"seam.check": () => undefined,
|
|
387
|
+
"implement.applying": runsItsScript,
|
|
388
|
+
})
|
|
389
|
+
expect(rest.state).toBe("implement.health.check")
|
|
390
|
+
expect(repo.read("src/calc.ts")).toBe(`${CALC}// naive\n`)
|
|
391
|
+
expect(repo.subjects().some((subject) => subject.includes("seam.judge"))).toBe(false)
|
|
371
392
|
})
|
|
372
393
|
|
|
373
394
|
it("refuses a value other than true or false before the process starts", async () => {
|