@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.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -27
  3. package/dist/chunks/{reserved-C8Fm3s72.mjs → reserved-BAv71I4i.mjs} +10 -9
  4. package/dist/chunks/{tail-oaYewBN_.mjs → tail-kar1zhOb.mjs} +1 -1
  5. package/dist/flows.mjs +15 -10
  6. package/dist/gtd.bundle.mjs +14 -9
  7. package/dist/testkit.mjs +43 -23
  8. package/dist/workflows/cleanup.mjs +2 -2
  9. package/dist/workflows/feature.mjs +18 -11
  10. package/dist/workflows/fix.mjs +2 -2
  11. package/dist/workflows/review.mjs +2 -2
  12. package/dist/workflows/rollout.mjs +1 -1
  13. package/dist/workflows.mjs +37 -25
  14. package/package.json +1 -1
  15. package/skills/authoring/SKILL.md +3 -3
  16. package/src/flows/scripts.ts +31 -9
  17. package/src/testkit/FlowScripts.test.ts +58 -0
  18. package/src/testkit/FlowScripts.ts +61 -24
  19. package/src/workflows/feature/acceptance-criteria.feature +5 -1
  20. package/src/workflows/feature/coverage.feature +6 -3
  21. package/src/workflows/feature/fast-path.feature +11 -19
  22. package/src/workflows/feature/feature.test.ts +24 -4
  23. package/src/workflows/feature/feature.ts +8 -8
  24. package/src/workflows/feature/flag-mode.feature +17 -9
  25. package/src/workflows/feature/lens-models.feature +7 -1
  26. package/src/workflows/feature/naive-acceptance.feature +2 -2
  27. package/src/workflows/feature/one-step-land.feature +10 -12
  28. package/src/workflows/feature/quality-unresolved.feature +7 -1
  29. package/src/workflows/feature/risk-tier.feature +12 -4
  30. package/src/workflows/feature/seam-rejection.feature +4 -9
  31. package/src/workflows/feature/seam.feature +29 -26
  32. package/src/workflows/feature/seam.test.ts +9 -3
  33. package/src/workflows/feature/seam.ts +40 -22
  34. package/src/workflows/review/review-door.feature +12 -4
  35. package/src/workflows/shared/text.test.ts +8 -0
  36. package/src/workflows/shared/text.ts +11 -8
@@ -214,25 +214,21 @@ const checkScript = (command, options) => {
214
214
  ""
215
215
  ].join("\n");
216
216
  };
217
- /**
218
- * Reverse-apply `commit`'s changes to the working tree. A failure — the
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} --; then`,
224
+ `if ! git diff --quiet ${range} --${paths}; then`,
229
225
  ` mkdir -p "$(dirname ${quote(report)})"`,
230
- ` git diff --binary ${range} -- | git apply -R 2> ${error}`,
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; a later step judges
18040
- the shape of your diff and a reviewer makes it fit
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
- const seamSteerMessage = (retry) => isFault(retry) ? `The naive pass failed three times and the retries are spent. The last
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's approach:
18123
- the implementation is built against it as reference, with no seam gate
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 judged naive pass (none with `flagMode` off), then implement and the quality lap; design must have run since `firstBase`. */
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 = flagModeOn() ? await seamBuild(firstBase) : { base: firstBase };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "35.0.0",
3
+ "version": "36.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -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 and
227
- the quality lap, ending with no human review. Add a human sign-off before the
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> => {
@@ -55,25 +55,31 @@ export const checkScript = (command: string, options: CheckScriptOptions): strin
55
55
  ].join("\n")
56
56
  }
57
57
 
58
- /**
59
- * Reverse-apply `commit`'s changes to the working tree. A failure — the
60
- * patch no longer applies — writes `failure` and git's error to `report`,
61
- * since a failed revert and one with nothing to undo both leave the tree clean.
62
- */
63
- export const revertScript = (commit: string, report: string, failure: string): string => {
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} --; then`,
74
+ `if ! git diff --quiet ${range} --${paths}; then`,
70
75
  ` mkdir -p "$(dirname ${quote(report)})"`,
71
- ` git diff --binary ${range} -- | git apply -R 2> ${error}`,
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 still
134
- * exactly as `C` left it reverts; a file gone, or one `C` added and that
135
- * changed since, fails the whole patch. A file `C` modified and that changed
136
- * since may still apply through its context — that needs real git, so it
137
- * throws rather than guess.
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 reverseApply = (repo: InMemRepo, commit: string): string | undefined => {
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((p) => repo.fileAtRef(parent, p) !== repo.fileAtRef(commit, p))
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 after = repo.fileAtRef(commit, path)
145
- const current = repo.readFile(path) ?? null
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 before = repo.fileAtRef(parent, path)
156
- if (before === null) repo.deleteFile(path)
157
- else repo.writeFile(path, before)
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
- const recognizeRevert = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
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(lines.find((l) => l.startsWith("if ! git diff --quiet ")) ?? "")
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
- if (revertScript(commit, report, failure) !== script) return undefined
169
- const error = reverseApply(repo, commit)
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
- recognizeRevert(repo, script) ??
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 "implement.building" writes "src/calc.ts" with:
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 "implement.building" writes "src/calc.test.ts" with:
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(agent): implement.building → implement.health.check
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, flag-free at low
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, implement and the
6
- quality lap back to idle. Low tier walks exactly the steps high does; only the
7
- implementation prompt and the summary differ.
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
- When the process is driven
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.building
87
- gtd(agent): implement.building → implement.health.check
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 | demand | absent | summary |
97
- | low | Add no release flag | new release flag | does not contain |
98
- | high | Put the new behaviour behind a new release flag | Add no release flag | contains |
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, skips the naive pass and the seam judge", async () => {
369
- const rest = await designed(CONFIG_SCAFFOLD, { GTD_FLAGMODE: "false" })
370
- expect(rest.state).toBe("implement.building")
371
- expect(rest.content).toContain("this project ships without release flags")
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, type SeamOutcome } from "./seam.js"
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 judged naive pass (refactoring first when
26
- // the judge asks; with `flagMode` off, neither) → implement → the quality lap,
27
- // and the episode ends back at `idle`. Human review is the `review` door's.
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 judged naive pass (none with `flagMode` off), then implement and the quality lap; design must have run since `firstBase`. */
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: Omit<SeamOutcome, "reference"> & { reference?: string } = flagModeOn()
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 =