@pmelab/gtd 35.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 +31 -27
- package/dist/chunks/{reserved-C8Fm3s72.mjs → reserved-BAv71I4i.mjs} +10 -9
- package/dist/chunks/{tail-oaYewBN_.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 +18 -11
- 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 +37 -25
- 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 +6 -3
- package/src/workflows/feature/fast-path.feature +11 -19
- package/src/workflows/feature/feature.test.ts +24 -4
- package/src/workflows/feature/feature.ts +8 -8
- package/src/workflows/feature/flag-mode.feature +17 -9
- package/src/workflows/feature/lens-models.feature +7 -1
- package/src/workflows/feature/naive-acceptance.feature +2 -2
- package/src/workflows/feature/one-step-land.feature +10 -12
- package/src/workflows/feature/quality-unresolved.feature +7 -1
- package/src/workflows/feature/risk-tier.feature +12 -4
- package/src/workflows/feature/seam-rejection.feature +4 -9
- package/src/workflows/feature/seam.feature +29 -26
- package/src/workflows/feature/seam.test.ts +9 -3
- package/src/workflows/feature/seam.ts +40 -22
- package/src/workflows/review/review-door.feature +12 -4
- package/src/workflows/shared/text.test.ts +8 -0
- package/src/workflows/shared/text.ts +11 -8
package/dist/workflows.mjs
CHANGED
|
@@ -214,25 +214,21 @@ const checkScript = (command, options) => {
|
|
|
214
214
|
""
|
|
215
215
|
].join("\n");
|
|
216
216
|
};
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
* patch no longer applies — writes `failure` and git's error to `report`,
|
|
220
|
-
* since a failed revert and one with nothing to undo both leave the tree clean.
|
|
221
|
-
*/
|
|
222
|
-
const revertScript = (commit, report, failure) => {
|
|
217
|
+
const patchScript = (commit, report, failure, reverse, exclude = []) => {
|
|
218
|
+
const paths = exclude.length === 0 ? "" : ` . ${exclude.map((p) => quote(`:(exclude,literal)${p}`)).join(" ")}`;
|
|
223
219
|
const range = `${quote(`${commit}^`)} ${quote(commit)}`;
|
|
224
220
|
const error = quote(`${report}.output`);
|
|
225
221
|
return [
|
|
226
222
|
"#!/usr/bin/env sh",
|
|
227
223
|
"set +e",
|
|
228
|
-
`if ! git diff --quiet ${range}
|
|
224
|
+
`if ! git diff --quiet ${range} --${paths}; then`,
|
|
229
225
|
` mkdir -p "$(dirname ${quote(report)})"`,
|
|
230
|
-
` git diff --binary ${range}
|
|
226
|
+
` 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}`,
|
|
231
227
|
" code=$?",
|
|
232
228
|
" if [ \"$code\" -ne 0 ]; then",
|
|
233
229
|
" {",
|
|
234
230
|
` printf '%s\\n\\n' ${quote(failure)}`,
|
|
235
|
-
` if [ -s ${error} ]; then cat ${error}; else printf 'Reverting it exited %s and produced no output.\\n' "$code"; fi`,
|
|
231
|
+
` if [ -s ${error} ]; then cat ${error}; else printf '${reverse ? "Reverting" : "Applying"} it exited %s and produced no output.\\n' "$code"; fi`,
|
|
236
232
|
` } > ${quote(report)}`,
|
|
237
233
|
" fi",
|
|
238
234
|
` rm -f ${error}`,
|
|
@@ -241,6 +237,14 @@ const revertScript = (commit, report, failure) => {
|
|
|
241
237
|
].join("\n");
|
|
242
238
|
};
|
|
243
239
|
/**
|
|
240
|
+
* Reverse-apply `commit`'s changes to the working tree. A failure — the
|
|
241
|
+
* patch no longer applies — writes `failure` and git's error to `report`,
|
|
242
|
+
* since a failed revert and one with nothing to undo both leave the tree clean.
|
|
243
|
+
*/
|
|
244
|
+
const revertScript = (commit, report, failure) => patchScript(commit, report, failure, true);
|
|
245
|
+
/** Apply `commit`'s changes outside `exclude` to the working tree; a failure is reported as `revertScript`'s is. */
|
|
246
|
+
const applyScript = (commit, report, failure, exclude = []) => patchScript(commit, report, failure, false, exclude);
|
|
247
|
+
/**
|
|
244
248
|
* Undo the edits one commit made to `paths`: each goes back to its content
|
|
245
249
|
* before `commit` — or away, when `commit` added it — but only while it is
|
|
246
250
|
* still exactly as `commit` left it. A path changed since is left alone.
|
|
@@ -17188,6 +17192,7 @@ const agentWithSkills = (name, prompt, options = {}) => {
|
|
|
17188
17192
|
return agent(name, withSkills(skillsFor(name).join(", "), body), options);
|
|
17189
17193
|
};
|
|
17190
17194
|
const unwindFailure = (commit) => `gtd could not unwind ${commit} out of your working tree.`;
|
|
17195
|
+
const naiveApplyFailure = (commit) => `gtd could not apply the naive pass ${commit} to the working tree; it is built fresh instead.`;
|
|
17191
17196
|
const idleMessage = () => `No active gtd process.
|
|
17192
17197
|
|
|
17193
17198
|
To start one, make ANY change — a hand-edit to real code, a scratch
|
|
@@ -18036,8 +18041,8 @@ const naivePassPrompt = (retry) => `${stateFileRules}
|
|
|
18036
18041
|
- Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
|
|
18037
18042
|
${criteriaBinding(false)}
|
|
18038
18043
|
- Implement them the quickest way that works — the naive version. Do not
|
|
18039
|
-
restructure, abstract or refactor around the change;
|
|
18040
|
-
|
|
18044
|
+
restructure, abstract or refactor around the change; it may land exactly as
|
|
18045
|
+
you leave it, and a reviewer makes it fit
|
|
18041
18046
|
- Work breadth-first: rough out every part of the change before finishing any
|
|
18042
18047
|
one, so a run cut off midway still shows the whole shape
|
|
18043
18048
|
- Write behaviour-level acceptance tests, at least one per criterion, each
|
|
@@ -18087,12 +18092,13 @@ An edit together with an approval counts as steer. Only
|
|
|
18087
18092
|
\`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
|
|
18088
18093
|
branch unjudged.
|
|
18089
18094
|
`;
|
|
18090
|
-
|
|
18095
|
+
/** `judged`: whether an overruled pass still meets the seam judge (`flagMode` on). */
|
|
18096
|
+
const seamSteerMessage = (retry, judged = true) => isFault(retry) ? `The naive pass failed three times and the retries are spent. The last
|
|
18091
18097
|
attempt's tests (\`git show ${retry.hash}\`) fall short:
|
|
18092
18098
|
${retry.fault}
|
|
18093
18099
|
|
|
18094
18100
|
${steerChoices(`land a clean tree to go on with the last attempt as it is:
|
|
18095
|
-
\`seam.check\` runs its suite and the seam judge rules on its shape. Use it
|
|
18101
|
+
\`seam.check\` runs its suite and ${judged ? "the seam judge rules on its shape" : "it lands as the implementation"}. Use it
|
|
18096
18102
|
when the rule cannot fit this change (tests the globs do not see, or no
|
|
18097
18103
|
entry point that existed before). Risk: a criterion may have no test that
|
|
18098
18104
|
survives a later refactor — the choice is recorded in history.`)}` : isRed(retry) ? `The naive pass failed three times and the retries are spent. The last
|
|
@@ -18103,7 +18109,7 @@ Two ways forward (then run \`gtd land\`):
|
|
|
18103
18109
|
- **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
|
|
18104
18110
|
pass runs again with a fresh retry budget
|
|
18105
18111
|
- **Overrule** — land a clean tree to go on with the last attempt as it is:
|
|
18106
|
-
the seam judge rules on its shape, and the implementation's health loop
|
|
18112
|
+
${judged ? "the seam judge rules on its shape" : "it lands as the implementation"}, and the implementation's health loop
|
|
18107
18113
|
must turn the suite green. Risk: the criteria's tests never passed on the
|
|
18108
18114
|
naive pass — the choice is recorded in history.
|
|
18109
18115
|
|
|
@@ -18119,9 +18125,8 @@ Two ways forward (then run \`gtd land\`):
|
|
|
18119
18125
|
- **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
|
|
18120
18126
|
pass runs again with a fresh retry budget (the risk tier stays as the
|
|
18121
18127
|
design gate settled it; a tier edit here is not applied)
|
|
18122
|
-
- **Overrule** — land a clean tree to accept the last attempt
|
|
18123
|
-
the implementation
|
|
18124
|
-
and no refactor. Risk: this ships the seam the judge objected to,
|
|
18128
|
+
- **Overrule** — land a clean tree to accept the last attempt as it is: it
|
|
18129
|
+
lands as the implementation, with no seam gate and no refactor. Risk: this ships the seam the judge objected to,
|
|
18125
18130
|
unaddressed — the choice is recorded in history.
|
|
18126
18131
|
|
|
18127
18132
|
An edit together with an approval counts as steer. Only
|
|
@@ -38728,7 +38733,7 @@ const naiveGreen = (tip) => {
|
|
|
38728
38733
|
return check("check", [
|
|
38729
38734
|
...paths,
|
|
38730
38735
|
"dirty=$(git -c core.quotePath=false ls-files -m -d -o --exclude-standard)",
|
|
38731
|
-
`if ! git diff --binary ${quote(`${tip.hash}^`)} ${quote(tip.hash)} -- | git apply; then`,
|
|
38736
|
+
`if ! git diff --binary --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ ${quote(`${tip.hash}^`)} ${quote(tip.hash)} -- | git apply --whitespace=nowarn; then`,
|
|
38732
38737
|
" echo 'gtd: the kept naive pass no longer applies to HEAD; no test ran'",
|
|
38733
38738
|
" exit 1",
|
|
38734
38739
|
"fi",
|
|
@@ -38747,6 +38752,11 @@ const naiveGreen = (tip) => {
|
|
|
38747
38752
|
label: "Running the naive pass's tests"
|
|
38748
38753
|
});
|
|
38749
38754
|
};
|
|
38755
|
+
/** Resolves `false` when the tip no longer applies (the failure is in `.gtd/FEEDBACK.md`) or has nothing outside `.gtd/` to apply. */
|
|
38756
|
+
const applied = async (tip) => {
|
|
38757
|
+
await run("applying", applyScript(tip, FEEDBACK, naiveApplyFailure(tip), [".gtd"]), { label: "Applying the naive pass" });
|
|
38758
|
+
return !wrote(".gtd/FEEDBACK.md") && codeChanges().length > 0;
|
|
38759
|
+
};
|
|
38750
38760
|
function implement(first, tier, amend) {
|
|
38751
38761
|
const options = typeof first === "object" && first !== null ? first : {
|
|
38752
38762
|
reference: first,
|
|
@@ -38754,7 +38764,7 @@ function implement(first, tier, amend) {
|
|
|
38754
38764
|
amend
|
|
38755
38765
|
};
|
|
38756
38766
|
return scope("implement", async () => {
|
|
38757
|
-
await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend, options.afterRefactor, flagModeOn()), {
|
|
38767
|
+
if (options.naive === void 0 || !await applied(options.naive)) await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend, options.afterRefactor, flagModeOn()), {
|
|
38758
38768
|
label: "Implementing",
|
|
38759
38769
|
model: coder(),
|
|
38760
38770
|
system: builderSystem(),
|
|
@@ -38769,7 +38779,7 @@ function implement(first, tier, amend) {
|
|
|
38769
38779
|
/** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
|
|
38770
38780
|
const steer = async (retry) => {
|
|
38771
38781
|
await human("steer", {
|
|
38772
|
-
message: seamSteerMessage(retry),
|
|
38782
|
+
message: seamSteerMessage(retry, flagModeOn()),
|
|
38773
38783
|
label: "Steer or overrule the naive pass",
|
|
38774
38784
|
file: REQUIREMENTS,
|
|
38775
38785
|
acceptClean: true
|
|
@@ -38833,7 +38843,7 @@ const decide = () => scope("seam", async () => {
|
|
|
38833
38843
|
for (;;) {
|
|
38834
38844
|
await naivePass(retry);
|
|
38835
38845
|
const tip = kept("naive");
|
|
38836
|
-
const outcome = await vetted(tip, vetting) ?? await ruled(tip, vetting.again);
|
|
38846
|
+
const outcome = await vetted(tip, vetting) ?? (flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash });
|
|
38837
38847
|
if (!("kind" in outcome)) return outcome;
|
|
38838
38848
|
retry = outcome.kind === "retry" ? outcome.retry : void 0;
|
|
38839
38849
|
}
|
|
@@ -38842,7 +38852,8 @@ const seamBuild = async (base) => {
|
|
|
38842
38852
|
const { reference, shape } = await decide();
|
|
38843
38853
|
if (shape === void 0) return {
|
|
38844
38854
|
reference,
|
|
38845
|
-
base
|
|
38855
|
+
base,
|
|
38856
|
+
land: true
|
|
38846
38857
|
};
|
|
38847
38858
|
if (shape.unreadable.length > 0) {
|
|
38848
38859
|
await scope("seam", () => human("by-hand", {
|
|
@@ -38873,15 +38884,16 @@ const unwind = () => {
|
|
|
38873
38884
|
const commit = head$1();
|
|
38874
38885
|
return run("unwind", revertScript(commit, FEEDBACK, unwindFailure(commit)), { label: "Unwinding your input" });
|
|
38875
38886
|
};
|
|
38876
|
-
/** A
|
|
38887
|
+
/** A naive pass (judged only with `flagMode` on), then implement and the quality lap; design must have run since `firstBase`. */
|
|
38877
38888
|
const buildFeature = async (firstBase) => {
|
|
38878
38889
|
const tier = riskTierOn() ? recordRisk() : void 0;
|
|
38879
|
-
const seam =
|
|
38890
|
+
const seam = await seamBuild(firstBase);
|
|
38880
38891
|
await slice("feature", async () => {
|
|
38881
38892
|
await implement({
|
|
38882
38893
|
reference: seam.reference,
|
|
38883
38894
|
tier,
|
|
38884
|
-
afterRefactor: seam.refactored
|
|
38895
|
+
afterRefactor: seam.refactored,
|
|
38896
|
+
naive: seam.land === true ? seam.reference : void 0
|
|
38885
38897
|
});
|
|
38886
38898
|
const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path)) ? firstBase : seam.base;
|
|
38887
38899
|
await tail(settleQuality, {
|
package/package.json
CHANGED
|
@@ -223,9 +223,9 @@ workflow.
|
|
|
223
223
|
## Worked example: add an approval gate before building
|
|
224
224
|
|
|
225
225
|
In the bundled default, `planAndBuild` runs the design phase, then
|
|
226
|
-
`buildFeature`: the naive pass and its seam judge, then the implementation
|
|
227
|
-
the quality lap, ending with no human review. Add a
|
|
228
|
-
build:
|
|
226
|
+
`buildFeature`: the naive pass and its seam judge, then the implementation (the
|
|
227
|
+
pass itself on `land`) and the quality lap, ending with no human review. Add a
|
|
228
|
+
human sign-off before the build:
|
|
229
229
|
|
|
230
230
|
```ts
|
|
231
231
|
const planAndBuild = async (firstBase: string): Promise<void> => {
|
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
|
|
@@ -34,15 +34,18 @@ Feature: a feature is done only when every acceptance criterion has a passing te
|
|
|
34
34
|
- AC-2: add is unchanged.
|
|
35
35
|
"""
|
|
36
36
|
And step "design.gate.answer" changes nothing
|
|
37
|
-
And step "
|
|
37
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
38
38
|
"""
|
|
39
39
|
export const add = (a: number, b: number) => a + b
|
|
40
40
|
export const subtract = (a: number, b: number) => a - b
|
|
41
41
|
"""
|
|
42
|
-
And step "
|
|
42
|
+
And step "seam.naive" writes "src/calc.test.ts" with:
|
|
43
43
|
"""
|
|
44
44
|
it("[AC-1] subtracts", () => expect(subtract(3, 1)).toBe(2))
|
|
45
|
+
// AC-2: add is untouched
|
|
45
46
|
"""
|
|
47
|
+
And step "seam.check" changes nothing
|
|
48
|
+
And step "implement.applying" runs its script
|
|
46
49
|
And step "implement.health.check" changes nothing
|
|
47
50
|
And step "implement.closing" runs its script
|
|
48
51
|
|
|
@@ -61,7 +64,7 @@ Feature: a feature is done only when every acceptance criterion has a passing te
|
|
|
61
64
|
Then the process rests at "idle"
|
|
62
65
|
And the commit subjects end with:
|
|
63
66
|
"""
|
|
64
|
-
gtd(
|
|
67
|
+
gtd(check): implement.applying → implement.health.check
|
|
65
68
|
gtd(check): implement.health.check → implement.coverage
|
|
66
69
|
gtd(judge): implement.coverage → implement.closing
|
|
67
70
|
gtd(check): implement.closing → idle
|
|
@@ -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
|
|
@@ -62,18 +62,10 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
|
|
|
62
62
|
"""
|
|
63
63
|
[{"id":"shape","answer":"land","p":0.9}]
|
|
64
64
|
"""
|
|
65
|
-
|
|
66
|
-
Then the process rests at "implement.building"
|
|
67
|
-
And the resting step's content contains "<demand>"
|
|
68
|
-
And the resting step's content does not contain "<absent>"
|
|
69
|
-
|
|
70
|
-
Given step "implement.building" writes "src/greeting.ts" with:
|
|
71
|
-
"""
|
|
72
|
-
export const greeting = "Hello, world!"
|
|
73
|
-
"""
|
|
65
|
+
And step "implement.applying" runs its script
|
|
74
66
|
When the process is driven
|
|
75
67
|
Then the process rests at "idle"
|
|
76
|
-
And "src/greeting.ts" contains "Hello, world
|
|
68
|
+
And "src/greeting.ts" contains "Hello, world"
|
|
77
69
|
And the commit subjects end with:
|
|
78
70
|
"""
|
|
79
71
|
gtd(human): idle → unwind
|
|
@@ -83,8 +75,8 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
|
|
|
83
75
|
gtd(human): design.gate.answer → seam.naive
|
|
84
76
|
gtd(agent): seam.naive → seam.check
|
|
85
77
|
gtd(check): seam.check → seam.judge
|
|
86
|
-
gtd(judge): seam.judge → implement.
|
|
87
|
-
gtd(
|
|
78
|
+
gtd(judge): seam.judge → implement.applying
|
|
79
|
+
gtd(check): implement.applying → implement.health.check
|
|
88
80
|
gtd(check): implement.health.check → implement.coverage
|
|
89
81
|
gtd(judge): implement.coverage → implement.closing
|
|
90
82
|
gtd(check): implement.closing → build.quality.correctness.reviewing
|
|
@@ -93,6 +85,6 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
|
|
|
93
85
|
And the output of gtd "summary" <summary> "needs a human reviewer"
|
|
94
86
|
|
|
95
87
|
Examples:
|
|
96
|
-
| tier |
|
|
97
|
-
| low |
|
|
98
|
-
| high |
|
|
88
|
+
| tier | summary |
|
|
89
|
+
| low | does not contain |
|
|
90
|
+
| high | contains |
|
|
@@ -365,10 +365,30 @@ describe("flagMode", () => {
|
|
|
365
365
|
expect((await designed(config("feature({ flagMode: true })"))).state).toBe("seam.naive")
|
|
366
366
|
})
|
|
367
367
|
|
|
368
|
-
it("off through GTD_FLAGMODE=false,
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
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)
|
|
372
392
|
})
|
|
373
393
|
|
|
374
394
|
it("refuses a value other than true or false before the process starts", async () => {
|
|
@@ -17,14 +17,15 @@ import * as t from "../shared/text.js"
|
|
|
17
17
|
import { skipDesign, statedCriteria, type StatedCriteria } from "./criteria.js"
|
|
18
18
|
import { design } from "./planning.js"
|
|
19
19
|
import { buildTail, isCode, reUnwind, type ReviewOutcome } from "./review.js"
|
|
20
|
-
import { implement, seamBuild
|
|
20
|
+
import { implement, seamBuild } from "./seam.js"
|
|
21
21
|
import { recordRisk, riskTierOn } from "./tier.js"
|
|
22
22
|
|
|
23
23
|
// The ordinary start. Any change to the tree starts a process: `idle` →
|
|
24
24
|
// `unwind` reverts the sketch (its intent survives in history) → a
|
|
25
|
-
// green-baseline gate → design → a
|
|
26
|
-
//
|
|
27
|
-
//
|
|
25
|
+
// green-baseline gate → design → a naive pass, judged with `flagMode` on: it
|
|
26
|
+
// lands as the implementation, or (refactoring first when the judge asks) is
|
|
27
|
+
// built fresh from → the health loop → the quality lap, and the episode ends
|
|
28
|
+
// back at `idle`. Human review is the `review` door's.
|
|
28
29
|
|
|
29
30
|
/** Revert the sketch that started the process out of the working tree; its intent survives in history. */
|
|
30
31
|
export const unwind = (): Promise<void> => {
|
|
@@ -36,17 +37,16 @@ export const unwind = (): Promise<void> => {
|
|
|
36
37
|
|
|
37
38
|
export { reUnwind }
|
|
38
39
|
|
|
39
|
-
/** A
|
|
40
|
+
/** A naive pass (judged only with `flagMode` on), then implement and the quality lap; design must have run since `firstBase`. */
|
|
40
41
|
export const buildFeature = async (firstBase: string): Promise<void> => {
|
|
41
42
|
const tier = riskTierOn() ? recordRisk() : undefined
|
|
42
|
-
const seam
|
|
43
|
-
? await seamBuild(firstBase)
|
|
44
|
-
: { base: firstBase }
|
|
43
|
+
const seam = await seamBuild(firstBase)
|
|
45
44
|
await slice("feature", async () => {
|
|
46
45
|
await implement({
|
|
47
46
|
reference: seam.reference,
|
|
48
47
|
tier,
|
|
49
48
|
afterRefactor: seam.refactored,
|
|
49
|
+
naive: seam.land === true ? seam.reference : undefined,
|
|
50
50
|
})
|
|
51
51
|
// An empty feature slice: the refactor carried the feature, so the lap covers the whole change.
|
|
52
52
|
const base =
|