@pmelab/gtd 24.0.0 → 24.2.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 (203) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -13
  3. package/claude/hooks/access.ts +26 -1
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  12. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  13. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  14. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  15. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  16. package/dist/flows.mjs +525 -0
  17. package/dist/gtd.bundle.mjs +43339 -55575
  18. package/dist/steering.mjs +2 -0
  19. package/dist/testkit.mjs +13006 -0
  20. package/dist/wire.mjs +2 -0
  21. package/dist/workflow-kit.mjs +352 -0
  22. package/dist/workflows/feature.mjs +315 -0
  23. package/dist/workflows/fix.mjs +40 -0
  24. package/dist/workflows/review.mjs +25 -0
  25. package/dist/workflows.mjs +8 -6
  26. package/package.json +69 -8
  27. package/src/cli/Cli.test.ts +1329 -0
  28. package/src/cli/Cli.ts +1348 -0
  29. package/src/cli/Program.test.ts +2262 -0
  30. package/src/cli/Program.ts +1331 -0
  31. package/src/cli/ToolCommands.ts +43 -0
  32. package/src/cli/index.ts +14 -0
  33. package/src/edge/Edge.test.ts +1016 -0
  34. package/src/edge/Edge.ts +1225 -0
  35. package/src/edge/index.ts +16 -0
  36. package/src/emit/Emit.test.ts +425 -0
  37. package/src/emit/Emit.ts +139 -0
  38. package/src/emit/GitScript.test.ts +260 -0
  39. package/src/emit/GitScript.ts +131 -0
  40. package/src/emit/ModeContradiction.test.ts +120 -0
  41. package/src/emit/ModeContradiction.ts +66 -0
  42. package/src/emit/OutcomeScript.test.ts +238 -0
  43. package/src/emit/OutcomeScript.ts +73 -0
  44. package/src/emit/SteeringMode.test.ts +274 -0
  45. package/src/emit/SteeringMode.ts +256 -0
  46. package/src/emit/index.ts +40 -0
  47. package/src/install/ConfigScaffold.ts +19 -0
  48. package/src/install/Install.test.ts +426 -0
  49. package/src/install/Install.ts +634 -0
  50. package/src/install/Version.ts +27 -0
  51. package/src/install/index.ts +3 -0
  52. package/src/platform/CommandRunner.ts +74 -0
  53. package/src/platform/Commentary.test.ts +126 -0
  54. package/src/platform/Commentary.ts +92 -0
  55. package/src/platform/Git.test.ts +50 -0
  56. package/src/platform/Git.ts +552 -0
  57. package/src/platform/Host.ts +42 -0
  58. package/src/platform/RetainedHistory.test.ts +65 -0
  59. package/src/platform/RetainedHistory.ts +34 -0
  60. package/src/platform/Workspace.test.ts +669 -0
  61. package/src/platform/Workspace.ts +631 -0
  62. package/src/platform/WorktreeState.test.ts +162 -0
  63. package/src/platform/WorktreeState.ts +50 -0
  64. package/src/platform/index.ts +22 -0
  65. package/src/replay/Access.test.ts +53 -0
  66. package/src/replay/Access.ts +46 -0
  67. package/src/replay/Format.test.ts +20 -0
  68. package/src/replay/Format.ts +14 -0
  69. package/src/replay/Replay.test.ts +887 -0
  70. package/src/replay/Replay.ts +669 -0
  71. package/src/replay/Trailers.test.ts +117 -0
  72. package/src/replay/Trailers.ts +213 -0
  73. package/src/replay/Tree.test.ts +25 -0
  74. package/src/replay/Tree.ts +45 -0
  75. package/src/replay/index.ts +11 -0
  76. package/src/steering/Blocks.fixture.ts +6 -0
  77. package/src/steering/Blocks.test.ts +167 -0
  78. package/src/steering/Blocks.ts +321 -0
  79. package/src/steering/CodeThreads.test.ts +127 -0
  80. package/src/steering/CodeThreads.ts +197 -0
  81. package/src/steering/Edits.test.ts +19 -0
  82. package/src/steering/Edits.ts +20 -0
  83. package/src/steering/Eol.test.ts +16 -0
  84. package/src/steering/Eol.ts +2 -0
  85. package/src/steering/FORMATS.md +58 -0
  86. package/src/steering/Footnotes.test.ts +977 -0
  87. package/src/steering/Footnotes.ts +661 -0
  88. package/src/steering/Inline.fixture.ts +7 -0
  89. package/src/steering/Inline.test.ts +227 -0
  90. package/src/steering/Inline.ts +195 -0
  91. package/src/steering/MarkdownTree.test.ts +247 -0
  92. package/src/steering/MarkdownTree.ts +260 -0
  93. package/src/steering/Outline.ts +19 -0
  94. package/src/steering/SteeringFormat.test.ts +91 -0
  95. package/src/steering/SteeringFormat.ts +267 -0
  96. package/src/steering/SteeringFormats.test.ts +163 -0
  97. package/src/steering/Threads.ts +163 -0
  98. package/src/steering/contract.test.ts +264 -0
  99. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  100. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  101. package/src/steering/fixtures/free-form/notes.md +12 -0
  102. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  103. package/src/steering/fixtures/qa/answered.md +23 -0
  104. package/src/steering/fixtures/qa/answered.read.json +119 -0
  105. package/src/steering/fixtures/qa/requirements.md +160 -0
  106. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  107. package/src/steering/fixtures/review/review.md +76 -0
  108. package/src/steering/fixtures/review/review.read.json +237 -0
  109. package/src/steering/fixtures/review/ticked.md +23 -0
  110. package/src/steering/fixtures/review/ticked.read.json +99 -0
  111. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  112. package/src/steering/fixtures/review-notes/noted.md +19 -0
  113. package/src/steering/freeform.test.ts +311 -0
  114. package/src/steering/freeform.ts +148 -0
  115. package/src/steering/index.test.ts +62 -0
  116. package/src/steering/index.ts +140 -0
  117. package/src/steering/qa.test.ts +2160 -0
  118. package/src/steering/qa.ts +1104 -0
  119. package/src/steering/review.test.ts +1829 -0
  120. package/src/steering/review.ts +1180 -0
  121. package/src/step/LandStep.ts +31 -0
  122. package/src/step/RepoSnapshot.ts +29 -0
  123. package/src/step/accessRefusal.test.ts +58 -0
  124. package/src/step/accessRefusal.ts +26 -0
  125. package/src/step/index.ts +5 -0
  126. package/src/step/planStart.test.ts +202 -0
  127. package/src/step/planStart.ts +167 -0
  128. package/src/step/planStep.test.ts +104 -0
  129. package/src/step/planStep.ts +89 -0
  130. package/src/step/snapshot.fixture.ts +28 -0
  131. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  132. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  133. package/src/testkit/FakeGitOperations.ts +95 -0
  134. package/src/testkit/GitTiers.test.ts +11 -0
  135. package/src/testkit/GitTiers.ts +1052 -0
  136. package/src/testkit/InMemRepo.ts +467 -0
  137. package/src/testkit/Layers.test.ts +58 -0
  138. package/src/testkit/Layers.ts +241 -0
  139. package/src/testkit/StrictGitOperations.ts +31 -0
  140. package/src/testkit/TestRepo.test.ts +119 -0
  141. package/src/testkit/TestRepo.ts +274 -0
  142. package/src/testkit/cliIo.ts +36 -0
  143. package/src/testkit/index.ts +9 -0
  144. package/src/testkit/steps.ts +177 -0
  145. package/src/testkit/testkit.feature +65 -0
  146. package/src/wire/BeatDocument.test.ts +419 -0
  147. package/src/wire/BeatDocument.ts +114 -0
  148. package/src/wire/BeatPlain.test.ts +113 -0
  149. package/src/wire/BeatPlain.ts +127 -0
  150. package/src/wire/BeatStatus.test.ts +59 -0
  151. package/src/wire/BeatStatus.ts +81 -0
  152. package/src/wire/Demand.test.ts +142 -0
  153. package/src/wire/Demand.ts +108 -0
  154. package/src/wire/ExitCodes.test.ts +21 -0
  155. package/src/wire/ExitCodes.ts +16 -0
  156. package/src/wire/Glob.ts +29 -0
  157. package/src/wire/Judge.ts +22 -0
  158. package/src/wire/Land.test.ts +120 -0
  159. package/src/wire/Land.ts +75 -0
  160. package/src/wire/Select.test.ts +148 -0
  161. package/src/wire/Select.ts +78 -0
  162. package/src/wire/Sessions.test.ts +59 -0
  163. package/src/wire/Sessions.ts +39 -0
  164. package/src/wire/WireSchema.test.ts +162 -0
  165. package/src/wire/constants.ts +22 -0
  166. package/src/wire/golden/1/beat-capture.json +34 -0
  167. package/src/wire/golden/1/beat-message.json +34 -0
  168. package/src/wire/golden/1/beat-prompt.json +44 -0
  169. package/src/wire/golden/1/beat-script.json +34 -0
  170. package/src/wire/golden/1/beat-stalled.json +34 -0
  171. package/src/wire/golden/1/judge.json +14 -0
  172. package/src/wire/golden/1/land-noop.json +10 -0
  173. package/src/wire/golden/1/land.json +10 -0
  174. package/src/wire/index.ts +12 -0
  175. package/src/wire/types.ts +46 -0
  176. package/src/workflow/Diagnostic.test.ts +93 -0
  177. package/src/workflow/Diagnostic.ts +54 -0
  178. package/src/workflow/SteeringFormats.test.ts +27 -0
  179. package/src/workflow/SteeringFormats.ts +6 -0
  180. package/src/workflow/Workflow.ts +77 -0
  181. package/src/workflow/compile.test.ts +53 -0
  182. package/src/workflow/compile.ts +56 -0
  183. package/src/workflow/discovery.ts +122 -0
  184. package/src/workflow/fingerprint.test.ts +52 -0
  185. package/src/workflow/fingerprint.ts +53 -0
  186. package/src/workflow/index.ts +33 -0
  187. package/src/workflow/load.test.ts +1017 -0
  188. package/src/workflow/load.ts +836 -0
  189. package/src/workflow/skills.test.ts +109 -0
  190. package/src/workflow/skills.ts +90 -0
  191. package/src/workflow/vars.ts +33 -0
  192. package/src/workflows/feature/feature.test.ts +27 -33
  193. package/src/workflows/feature/index.ts +10 -0
  194. package/src/workflows/fix/fix.feature +80 -0
  195. package/src/workflows/fix/fix.test.ts +58 -78
  196. package/src/workflows/fix/index.ts +6 -0
  197. package/src/workflows/index.ts +2 -10
  198. package/src/workflows/review/index.ts +6 -0
  199. package/src/workflows/review/review.test.ts +85 -29
  200. package/src/workflows/review/review.ts +3 -1
  201. package/src/workflows/shared/config.fixture.ts +12 -0
  202. package/src/workflows/shared/reserved.ts +11 -0
  203. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,639 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ commitAll,
4
+ commitAsIs,
5
+ deleteRef,
6
+ discardPending,
7
+ hardResetTo,
8
+ mixedResetTo,
9
+ softResetTo,
10
+ updateRef,
11
+ combinedScript,
12
+ emitScripts,
13
+ fileExistsGuard,
14
+ type EmitStep,
15
+ commitOutcome,
16
+ noteOutcome,
17
+ transitionOutcome,
18
+ buildModeContradictionCheck,
19
+ modeContradictionSkipNotice,
20
+ } from "../emit/index.js"
21
+ import { applyEmittedScript, preconditionHeadEquals } from "./EmittedScriptRecognizer.js"
22
+ import { InMemRepo, type ScriptedCommand } from "./index.js"
23
+
24
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
25
+
26
+ const snapshot = (repo: InMemRepo) => ({
27
+ head: repo.resolveRef("HEAD"),
28
+ message: repo.lastCommitMessage(),
29
+ status: repo.statusPorcelain(),
30
+ files: repo.pathsUnder("").map((path) => [path, repo.readFile(path)]),
31
+ })
32
+
33
+ const NO_COMMANDS: ReadonlyMap<string, ScriptedCommand> = new Map()
34
+
35
+ describe("applyEmittedScript — the 8 GitScript builders", () => {
36
+ it("commitAll: stages the worktree and commits, like commitAllWithPrefix", () => {
37
+ const repo = new InMemRepo()
38
+ repo.writeFile("a.txt", "1")
39
+ const twin = new InMemRepo()
40
+ twin.writeFile("a.txt", "1")
41
+
42
+ const result = applyEmittedScript(repo, NO_COMMANDS, commitAll("gtd(agent): building"))
43
+ twin.commitAllWithPrefix("gtd(agent): building")
44
+
45
+ expect(result).toEqual({ ok: true })
46
+ expect(snapshot(repo)).toEqual(snapshot(twin))
47
+ })
48
+
49
+ it("commitAll: a multi-line message carrying Gtd-Judge: trailers (gtd judge answer --json=script) round-trips like any other trailer-carrying message", () => {
50
+ // No dedicated recognizer for `gtd judge answer`'s script exists (or is
51
+ // needed) — its required half is built from the SAME `commitAll` builder
52
+ // `gtd land` uses, so this generic recognizer already handles it,
53
+ // trailer content and all. See `.gtd/packages/01-judgment-surface.md`
54
+ // Task 5.
55
+ const repo = new InMemRepo()
56
+ repo.writeFile("a.txt", "1")
57
+ const message =
58
+ 'gtd(human): idle\n\nGtd-Judge: {"id":"q1","answer":true,"p":0.97}\n' +
59
+ 'Gtd-Judge: {"id":"q2","answer":"escalate","p":0.6}'
60
+
61
+ const result = applyEmittedScript(repo, NO_COMMANDS, commitAll(message))
62
+
63
+ expect(result).toEqual({ ok: true })
64
+ expect(repo.lastCommitMessage()).toBe(message)
65
+ })
66
+
67
+ it("commitAsIs: commits whatever is already staged, without an implicit add", () => {
68
+ const repo = new InMemRepo()
69
+ repo.writeFile("a.txt", "1")
70
+ repo.stageAll()
71
+ repo.writeFile("a.txt", "2") // pending worktree change, left OUT of the index on purpose
72
+ const twin = new InMemRepo()
73
+ twin.writeFile("a.txt", "1")
74
+ twin.stageAll()
75
+ twin.writeFile("a.txt", "2")
76
+
77
+ const result = applyEmittedScript(repo, NO_COMMANDS, commitAsIs("gtd(agent): as-is"))
78
+ twin.commitAsIs("gtd(agent): as-is")
79
+
80
+ expect(result).toEqual({ ok: true })
81
+ expect(snapshot(repo)).toEqual(snapshot(twin))
82
+ })
83
+
84
+ it("softResetTo: moves HEAD only, leaving index and worktree untouched", () => {
85
+ const repo = new InMemRepo()
86
+ repo.writeFile("a.txt", "1")
87
+ repo.commitAllWithPrefix("chore: first")
88
+ const first = repo.resolveRef("HEAD")!
89
+ repo.writeFile("b.txt", "2")
90
+ repo.commitAllWithPrefix("chore: second")
91
+ const twin = new InMemRepo()
92
+ twin.writeFile("a.txt", "1")
93
+ twin.commitAllWithPrefix("chore: first")
94
+ twin.writeFile("b.txt", "2")
95
+ twin.commitAllWithPrefix("chore: second")
96
+
97
+ const result = applyEmittedScript(repo, NO_COMMANDS, softResetTo(first))
98
+ twin.softResetTo(first)
99
+
100
+ expect(result).toEqual({ ok: true })
101
+ expect(snapshot(repo)).toEqual(snapshot(twin))
102
+ expect(repo.resolveRef("HEAD")).toBe(first)
103
+ })
104
+
105
+ it("mixedResetTo: moves HEAD and index, leaving the worktree untouched", () => {
106
+ const repo = new InMemRepo()
107
+ repo.writeFile("a.txt", "1")
108
+ repo.commitAllWithPrefix("chore: first")
109
+ const first = repo.resolveRef("HEAD")!
110
+ repo.writeFile("b.txt", "2")
111
+ repo.commitAllWithPrefix("chore: second")
112
+ const twin = new InMemRepo()
113
+ twin.writeFile("a.txt", "1")
114
+ twin.commitAllWithPrefix("chore: first")
115
+ twin.writeFile("b.txt", "2")
116
+ twin.commitAllWithPrefix("chore: second")
117
+
118
+ const result = applyEmittedScript(repo, NO_COMMANDS, mixedResetTo(first))
119
+ twin.mixedResetTo(first)
120
+
121
+ expect(result).toEqual({ ok: true })
122
+ expect(snapshot(repo)).toEqual(snapshot(twin))
123
+ })
124
+
125
+ it("hardResetTo: moves HEAD, index, AND worktree", () => {
126
+ const repo = new InMemRepo()
127
+ repo.writeFile("a.txt", "1")
128
+ repo.commitAllWithPrefix("chore: first")
129
+ const first = repo.resolveRef("HEAD")!
130
+ repo.writeFile("b.txt", "2")
131
+ repo.commitAllWithPrefix("chore: second")
132
+ const twin = new InMemRepo()
133
+ twin.writeFile("a.txt", "1")
134
+ twin.commitAllWithPrefix("chore: first")
135
+ twin.writeFile("b.txt", "2")
136
+ twin.commitAllWithPrefix("chore: second")
137
+
138
+ const result = applyEmittedScript(repo, NO_COMMANDS, hardResetTo(first))
139
+ twin.hardResetTo(first)
140
+
141
+ expect(result).toEqual({ ok: true })
142
+ expect(snapshot(repo)).toEqual(snapshot(twin))
143
+ expect(repo.hasPath("b.txt")).toBe(false)
144
+ })
145
+
146
+ it("discardPending: drops every pending change, tracked or untracked", () => {
147
+ const repo = new InMemRepo()
148
+ repo.writeFile("a.txt", "1")
149
+ repo.commitAllWithPrefix("chore: first")
150
+ repo.writeFile("a.txt", "changed")
151
+ repo.writeFile("untracked.txt", "new")
152
+ const twin = new InMemRepo()
153
+ twin.writeFile("a.txt", "1")
154
+ twin.commitAllWithPrefix("chore: first")
155
+ twin.writeFile("a.txt", "changed")
156
+ twin.writeFile("untracked.txt", "new")
157
+
158
+ const result = applyEmittedScript(repo, NO_COMMANDS, discardPending())
159
+ twin.discardPending()
160
+
161
+ expect(result).toEqual({ ok: true })
162
+ expect(snapshot(repo)).toEqual(snapshot(twin))
163
+ expect(repo.readFile("a.txt")).toBe("1")
164
+ expect(repo.hasPath("untracked.txt")).toBe(false)
165
+ })
166
+
167
+ it("updateRef: points a repo-local ref at a commit hash", () => {
168
+ const repo = new InMemRepo()
169
+ repo.writeFile("a.txt", "1")
170
+ repo.commitAllWithPrefix("chore: first")
171
+ const hash = repo.resolveRef("HEAD")!
172
+ const twin = new InMemRepo()
173
+ twin.writeFile("a.txt", "1")
174
+ twin.commitAllWithPrefix("chore: first")
175
+
176
+ const result = applyEmittedScript(repo, NO_COMMANDS, updateRef("refs/gtd/base", hash))
177
+ twin.updateRef("refs/gtd/base", hash)
178
+
179
+ expect(result).toEqual({ ok: true })
180
+ expect(repo.resolveRef("refs/gtd/base")).toBe(hash)
181
+ expect(snapshot(repo)).toEqual(snapshot(twin))
182
+ })
183
+
184
+ it("deleteRef: removes a repo-local ref (idempotently)", () => {
185
+ const repo = new InMemRepo()
186
+ repo.writeFile("a.txt", "1")
187
+ repo.commitAllWithPrefix("chore: first")
188
+ repo.updateRef("refs/gtd/base", repo.resolveRef("HEAD")!)
189
+ const twin = new InMemRepo()
190
+ twin.writeFile("a.txt", "1")
191
+ twin.commitAllWithPrefix("chore: first")
192
+ twin.updateRef("refs/gtd/base", twin.resolveRef("HEAD")!)
193
+
194
+ const result = applyEmittedScript(repo, NO_COMMANDS, deleteRef("refs/gtd/base"))
195
+ twin.deleteRef("refs/gtd/base")
196
+
197
+ expect(result).toEqual({ ok: true })
198
+ expect(repo.resolveRef("refs/gtd/base")).toBeNull()
199
+ expect(snapshot(repo)).toEqual(snapshot(twin))
200
+ })
201
+ })
202
+
203
+ describe("applyEmittedScript — gtd check <mode> <file>", () => {
204
+ it("passes on valid qa-mode content", () => {
205
+ const repo = new InMemRepo()
206
+ repo.writeFile(
207
+ ".gtd/TODO.md",
208
+ ["## Open Questions", "", "### What color?", "", "- [ ] Red", "- [ ] Blue"].join("\n"),
209
+ )
210
+
211
+ const result = applyEmittedScript(repo, NO_COMMANDS, "gtd check qa '.gtd/TODO.md'")
212
+
213
+ expect(result).toEqual({ ok: true })
214
+ })
215
+
216
+ it("fails on invalid qa-mode content (a bare ### heading with no question text)", () => {
217
+ const repo = new InMemRepo()
218
+ repo.writeFile(".gtd/TODO.md", ["## Open Questions", "", "### "].join("\n"))
219
+
220
+ const result = applyEmittedScript(repo, NO_COMMANDS, "gtd check qa '.gtd/TODO.md'")
221
+
222
+ expect(result.ok).toBe(false)
223
+ expect(result.error).toContain("gtd check qa")
224
+ })
225
+ })
226
+
227
+ describe("applyEmittedScript — Emit.ts's fileExistsGuard", () => {
228
+ it("is a no-op continuation when the file is present", () => {
229
+ const repo = new InMemRepo()
230
+ repo.writeFile(".gtd/PLAN.md", "content")
231
+
232
+ const result = applyEmittedScript(repo, NO_COMMANDS, fileExistsGuard(".gtd/PLAN.md"))
233
+
234
+ expect(result).toEqual({ ok: true })
235
+ })
236
+
237
+ it("stops the whole script (successfully) when the file is absent — unlike every other guard, it does not fail", () => {
238
+ const repo = new InMemRepo()
239
+
240
+ const script = [fileExistsGuard(".gtd/PLAN.md"), "gtd check qa '.gtd/PLAN.md'"].join("\n\n")
241
+ const result = applyEmittedScript(repo, NO_COMMANDS, script)
242
+
243
+ expect(result).toEqual({ ok: true })
244
+ })
245
+
246
+ it("a subsequent block after the guard trips is never applied", () => {
247
+ const repo = new InMemRepo()
248
+ const commands: ReadonlyMap<string, ScriptedCommand> = new Map([
249
+ ["./should-not-run.sh", { kind: "rewrite", file: "MARKER.md", content: "ran" }],
250
+ ])
251
+
252
+ const script = [fileExistsGuard(".gtd/PLAN.md"), "./should-not-run.sh"].join("\n\n")
253
+ applyEmittedScript(repo, commands, script)
254
+
255
+ expect(repo.readFile("MARKER.md")).toBeUndefined()
256
+ })
257
+ })
258
+
259
+ describe("applyEmittedScript — ModeContradiction.ts's skip notice", () => {
260
+ it("is an inert no-op — a mode with an external validator prints a notice and continues", () => {
261
+ const repo = new InMemRepo()
262
+
263
+ const result = applyEmittedScript(repo, NO_COMMANDS, modeContradictionSkipNotice("adr"))
264
+
265
+ expect(result).toEqual({ ok: true })
266
+ })
267
+ })
268
+
269
+ describe("applyEmittedScript — ModeContradiction.ts's contradiction round-trip", () => {
270
+ const samplePath = "/fixture-scratch/gtd-mode-sample-qa-1234.md"
271
+ const sample = "Sample plan.\n\n## Open Questions\n\n### Which?\n\n- [ ] A\n- [ ] B\n"
272
+
273
+ it("passes (and cleans up the scratch file) when the formatter leaves the sample valid", () => {
274
+ const repo = new InMemRepo()
275
+ const commands: ReadonlyMap<string, ScriptedCommand> = new Map([
276
+ ["my-formatter " + samplePath, { kind: "exit", status: 0, output: "" }],
277
+ ])
278
+ const block = buildModeContradictionCheck({
279
+ mode: "qa",
280
+ samplePath,
281
+ sample,
282
+ formatCommand: `my-formatter ${samplePath}`,
283
+ })
284
+
285
+ const result = applyEmittedScript(repo, commands, block)
286
+
287
+ expect(result).toEqual({ ok: true })
288
+ expect(repo.readFile(samplePath)).toBeUndefined()
289
+ })
290
+
291
+ it("fails (and cleans up the scratch file) when the formatter breaks the sample under its own parser", () => {
292
+ const repo = new InMemRepo()
293
+ const commands: ReadonlyMap<string, ScriptedCommand> = new Map([
294
+ [
295
+ "my-formatter " + samplePath,
296
+ {
297
+ kind: "rewrite",
298
+ file: samplePath,
299
+ content: "## Open Questions\n\n### \n\nblank heading, invalid",
300
+ },
301
+ ],
302
+ ])
303
+ const block = buildModeContradictionCheck({
304
+ mode: "qa",
305
+ samplePath,
306
+ sample,
307
+ formatCommand: `my-formatter ${samplePath}`,
308
+ })
309
+
310
+ const result = applyEmittedScript(repo, commands, block)
311
+
312
+ expect(result.ok).toBe(false)
313
+ expect(result.error).toContain("CONFIGURATION BUG")
314
+ expect(result.error).toContain("### ")
315
+ expect(repo.readFile(samplePath)).toBeUndefined()
316
+ })
317
+
318
+ it("fails loudly on an unscripted format: command rather than silently passing", () => {
319
+ const repo = new InMemRepo()
320
+ const block = buildModeContradictionCheck({
321
+ mode: "qa",
322
+ samplePath,
323
+ sample,
324
+ formatCommand: "unregistered-formatter " + samplePath,
325
+ })
326
+
327
+ const result = applyEmittedScript(repo, NO_COMMANDS, block)
328
+
329
+ expect(result.ok).toBe(false)
330
+ expect(result.error).toContain("unscripted command")
331
+ expect(repo.readFile(samplePath)).toBeUndefined()
332
+ })
333
+
334
+ it("leaves nothing in the repo either way — no untracked path for changedPaths to report", () => {
335
+ const repo = new InMemRepo()
336
+ const commands: ReadonlyMap<string, ScriptedCommand> = new Map([
337
+ ["my-formatter " + samplePath, { kind: "exit", status: 0, output: "" }],
338
+ ])
339
+ const block = buildModeContradictionCheck({
340
+ mode: "qa",
341
+ samplePath,
342
+ sample,
343
+ formatCommand: `my-formatter ${samplePath}`,
344
+ })
345
+
346
+ applyEmittedScript(repo, commands, block)
347
+
348
+ expect(repo.pathsUnder("").includes(samplePath)).toBe(false)
349
+ })
350
+ })
351
+
352
+ describe("applyEmittedScript — the scripted-command table", () => {
353
+ it("an 'exit' hit with status 0 continues", () => {
354
+ const repo = new InMemRepo()
355
+ repo.writeFile("a.txt", "1")
356
+ const commands: ReadonlyMap<string, ScriptedCommand> = new Map([
357
+ ["./run-check.sh", { kind: "exit", status: 0, output: "ok" }],
358
+ ])
359
+
360
+ const result = applyEmittedScript(repo, commands, "./run-check.sh")
361
+
362
+ expect(result).toEqual({ ok: true })
363
+ })
364
+
365
+ it("an 'exit' hit with a non-zero status fails the script", () => {
366
+ const repo = new InMemRepo()
367
+ const commands: ReadonlyMap<string, ScriptedCommand> = new Map([
368
+ ["./run-check.sh", { kind: "exit", status: 1, output: "boom" }],
369
+ ])
370
+
371
+ const result = applyEmittedScript(repo, commands, "./run-check.sh")
372
+
373
+ expect(result.ok).toBe(false)
374
+ expect(result.error).toContain("./run-check.sh")
375
+ })
376
+
377
+ it("a 'rewrite' hit mutates the repo's file", () => {
378
+ const repo = new InMemRepo()
379
+ repo.writeFile("FEEDBACK.md", "old")
380
+ const commands: ReadonlyMap<string, ScriptedCommand> = new Map([
381
+ ["./format-feedback.sh", { kind: "rewrite", file: "FEEDBACK.md", content: "new" }],
382
+ ])
383
+
384
+ const result = applyEmittedScript(repo, commands, "./format-feedback.sh")
385
+
386
+ expect(result).toEqual({ ok: true })
387
+ expect(repo.readFile("FEEDBACK.md")).toBe("new")
388
+ })
389
+ })
390
+
391
+ describe("applyEmittedScript — unrecognized blocks fail loudly", () => {
392
+ it("names the offending line in the error and applies nothing", () => {
393
+ const repo = new InMemRepo()
394
+ repo.writeFile("a.txt", "1")
395
+ const before = snapshot(repo)
396
+
397
+ const result = applyEmittedScript(repo, NO_COMMANDS, "curl -X POST https://example.com/hook")
398
+
399
+ expect(result.ok).toBe(false)
400
+ expect(result.error).toContain("curl -X POST https://example.com/hook")
401
+ expect(snapshot(repo)).toEqual(before)
402
+ })
403
+ })
404
+
405
+ describe("applyEmittedScript — a tripped precondition stops the script", () => {
406
+ it("fails at the precondition and never applies a subsequent recognizable block", () => {
407
+ const repo = new InMemRepo()
408
+ repo.writeFile("a.txt", "1")
409
+ repo.commitAllWithPrefix("chore: first")
410
+ const actualHead = repo.resolveRef("HEAD")!
411
+ const wrongHash = "0".repeat(40)
412
+ expect(wrongHash).not.toBe(actualHead)
413
+
414
+ const script = [
415
+ preconditionHeadEquals(wrongHash),
416
+ commitAll("gtd(agent): should never land"),
417
+ ].join("\n\n")
418
+
419
+ const result = applyEmittedScript(repo, NO_COMMANDS, script)
420
+
421
+ expect(result.ok).toBe(false)
422
+ expect(result.error).toContain("precondition failed")
423
+ expect(repo.resolveRef("HEAD")).toBe(actualHead)
424
+ expect(repo.lastCommitMessage()).toBe("chore: first")
425
+ })
426
+
427
+ it("a satisfied precondition is a no-op and lets the rest of the script run", () => {
428
+ const repo = new InMemRepo()
429
+ repo.writeFile("a.txt", "1")
430
+ repo.commitAllWithPrefix("chore: first")
431
+ const actualHead = repo.resolveRef("HEAD")!
432
+
433
+ const script = [preconditionHeadEquals(actualHead), commitAll("gtd(agent): lands")].join("\n\n")
434
+
435
+ const result = applyEmittedScript(repo, NO_COMMANDS, script)
436
+
437
+ expect(result).toEqual({ ok: true })
438
+ expect(repo.lastCommitMessage()).toBe("gtd(agent): lands")
439
+ })
440
+ })
441
+
442
+ describe("applyEmittedScript — a realistic assembled multi-block script", () => {
443
+ it("parses set -eu and a satisfied precondition alongside real builder blocks", () => {
444
+ const repo = new InMemRepo()
445
+ repo.writeFile("a.txt", "1")
446
+ repo.commitAllWithPrefix("chore: first")
447
+ const first = repo.resolveRef("HEAD")!
448
+
449
+ const script = [
450
+ "set -eu",
451
+ preconditionHeadEquals(first),
452
+ commitAll("gtd(agent): second"),
453
+ updateRef("refs/gtd/base", first),
454
+ ].join("\n\n")
455
+
456
+ const result = applyEmittedScript(repo, NO_COMMANDS, script)
457
+
458
+ expect(result).toEqual({ ok: true })
459
+ expect(repo.lastCommitMessage()).toBe("gtd(agent): second")
460
+ expect(repo.resolveRef("refs/gtd/base")).toBe(first)
461
+ })
462
+ })
463
+
464
+ describe("applyEmittedScript — a realistic src/emit/Emit.ts-assembled script", () => {
465
+ it("an assembled commitAll applies exactly like commitAllWithPrefix", () => {
466
+ const repo = new InMemRepo()
467
+ repo.writeFile("base.txt", "0")
468
+ repo.commitAllWithPrefix("chore: first")
469
+ repo.writeFile("a.txt", "1")
470
+ const twin = new InMemRepo()
471
+ twin.writeFile("base.txt", "0")
472
+ twin.commitAllWithPrefix("chore: first")
473
+ twin.writeFile("a.txt", "1")
474
+
475
+ const steps: readonly EmitStep[] = [
476
+ { kind: "gitWrite", command: commitAll("gtd(agent): building") },
477
+ ]
478
+ const { required } = emitScripts(steps)
479
+ const result = applyEmittedScript(repo, NO_COMMANDS, required)
480
+ twin.commitAllWithPrefix("gtd(agent): building")
481
+
482
+ expect(result).toEqual({ ok: true })
483
+ expect(snapshot(repo)).toEqual(snapshot(twin))
484
+ })
485
+
486
+ it("a squash's four gitWrite steps (retain, soft-reset, commit-as-is, discard) apply in order", () => {
487
+ const repo = new InMemRepo()
488
+ repo.writeFile("a.txt", "1")
489
+ repo.commitAllWithPrefix("gtd(agent): building")
490
+ const tip = repo.resolveRef("HEAD")!
491
+ // Untracked, never staged — the index still matches the tip's own tree
492
+ // exactly, mirroring the real flow where nothing `git add`s between the
493
+ // last turn commit and a squash decision. `commitAsIs` commits the INDEX
494
+ // as-is (no implicit staging), so this file is absent from the squashed
495
+ // commit's tree; `discardPending`'s `git add -A && git reset --hard HEAD`
496
+ // then drops it from the working tree too (staged-but-not-in-HEAD).
497
+ repo.writeFile(".gtd/TODO.md", "leftover plumbing")
498
+
499
+ const steps: readonly EmitStep[] = [
500
+ { kind: "gitWrite", command: updateRef("refs/worktree/gtd/history", tip) },
501
+ { kind: "gitWrite", command: softResetTo(EMPTY_TREE) },
502
+ { kind: "gitWrite", command: commitAsIs("chore: squashed") },
503
+ { kind: "gitWrite", command: discardPending() },
504
+ ]
505
+ const { required } = emitScripts(steps)
506
+ const result = applyEmittedScript(repo, NO_COMMANDS, required)
507
+
508
+ expect(result).toEqual({ ok: true })
509
+ expect(repo.resolveRef("refs/worktree/gtd/history")).toBe(tip)
510
+ expect(repo.lastCommitMessage()).toBe("chore: squashed")
511
+ expect(repo.hasPath(".gtd/TODO.md")).toBe(false)
512
+ })
513
+ })
514
+
515
+ describe("applyEmittedScript — outcome blocks are inert (no git effect to miss)", () => {
516
+ it("a transition outcome — printf plus the changed-file read — applies as a no-op", () => {
517
+ const repo = new InMemRepo()
518
+ repo.writeFile("a.txt", "1")
519
+ const before = snapshot(repo)
520
+
521
+ const result = applyEmittedScript(repo, NO_COMMANDS, transitionOutcome("idle", "working"))
522
+
523
+ expect(result).toEqual({ ok: true })
524
+ expect(snapshot(repo)).toEqual(before)
525
+ })
526
+
527
+ it("a bare commit outcome applies as a no-op", () => {
528
+ const repo = new InMemRepo()
529
+ repo.writeFile("a.txt", "1")
530
+ const before = snapshot(repo)
531
+
532
+ const result = applyEmittedScript(repo, NO_COMMANDS, commitOutcome("gtd(agent): x"))
533
+
534
+ expect(result).toEqual({ ok: true })
535
+ expect(snapshot(repo)).toEqual(before)
536
+ })
537
+
538
+ it("a realistic assembled script — commitAll's gitWrite plus a trailing outcome step — applies exactly like commitAllWithPrefix, the outcome step contributing nothing", () => {
539
+ const repo = new InMemRepo()
540
+ repo.writeFile("base.txt", "0")
541
+ repo.commitAllWithPrefix("chore: first")
542
+ repo.writeFile("a.txt", "1")
543
+ const twin = new InMemRepo()
544
+ twin.writeFile("base.txt", "0")
545
+ twin.commitAllWithPrefix("chore: first")
546
+ twin.writeFile("a.txt", "1")
547
+
548
+ const steps: readonly EmitStep[] = [
549
+ { kind: "gitWrite", command: commitAll("gtd(agent): building") },
550
+ { kind: "outcome", command: commitOutcome("gtd(agent): building") },
551
+ ]
552
+ const { required } = emitScripts(steps)
553
+ const result = applyEmittedScript(repo, NO_COMMANDS, required)
554
+ twin.commitAllWithPrefix("gtd(agent): building")
555
+
556
+ expect(result).toEqual({ ok: true })
557
+ expect(snapshot(repo)).toEqual(snapshot(twin))
558
+ })
559
+
560
+ it("a print-only no-op script (a single outcome step, no gitWrite) applies as a no-op", () => {
561
+ const repo = new InMemRepo()
562
+ repo.writeFile("a.txt", "1")
563
+ const before = snapshot(repo)
564
+
565
+ const { required } = emitScripts([
566
+ { kind: "outcome", command: noteOutcome('nothing to do at "idle"') },
567
+ ])
568
+ const result = applyEmittedScript(repo, NO_COMMANDS, required)
569
+
570
+ expect(result).toEqual({ ok: true })
571
+ expect(snapshot(repo)).toEqual(before)
572
+ })
573
+ })
574
+
575
+ describe("applyEmittedScript — Emit.ts's combinedScript (the whole stdout of a plain-text write command)", () => {
576
+ it("recognizes the leading 'did not run it' comment and applies the required half that follows it", () => {
577
+ const repo = new InMemRepo()
578
+ repo.writeFile("a.txt", "1")
579
+
580
+ const required = emitScripts([
581
+ { kind: "gitWrite", command: commitAll("gtd(human): idle") },
582
+ ]).required
583
+ const script = combinedScript(required, "")
584
+ const result = applyEmittedScript(repo, NO_COMMANDS, script)
585
+
586
+ expect(result).toEqual({ ok: true })
587
+ expect(repo.lastCommitMessage()).toBe("gtd(human): idle")
588
+ })
589
+
590
+ it("applies a non-empty optional half wrapped in the presentation-only subshell, even though the optional script itself carries a blank line between its own sections", () => {
591
+ const repo = new InMemRepo()
592
+ repo.writeFile("a.txt", "1")
593
+
594
+ const required = emitScripts([
595
+ { kind: "gitWrite", command: commitAll("gtd(human): idle") },
596
+ ]).required
597
+ // A gitWrite optional half is itself multi-section (assembleScript joins
598
+ // "set -eu" and the command with a blank line) — the realistic shape this
599
+ // recognizer must keep intact as ONE block.
600
+ const optional = emitScripts([
601
+ { kind: "gitWrite", command: updateRef("refs/gtd/marker", "HEAD") },
602
+ ]).required
603
+ expect(optional).toContain("\n\n")
604
+ const script = combinedScript(required, optional)
605
+
606
+ const result = applyEmittedScript(repo, NO_COMMANDS, script)
607
+
608
+ expect(result).toEqual({ ok: true })
609
+ expect(repo.lastCommitMessage()).toBe("gtd(human): idle")
610
+ expect(repo.resolveRef("refs/gtd/marker")).toBe(repo.resolveRef("HEAD"))
611
+ })
612
+
613
+ it("a failing optional half is swallowed — the combined script as a whole still applies ok", () => {
614
+ const repo = new InMemRepo()
615
+ repo.writeFile("a.txt", "1")
616
+
617
+ const required = emitScripts([
618
+ { kind: "gitWrite", command: commitAll("gtd(human): idle") },
619
+ ]).required
620
+ // An optional half whose own block is unrecognizable — a stand-in for a
621
+ // real optional script failing at run time — must not fail the OUTER
622
+ // script: `combinedScript` wraps the optional half in a subshell whose
623
+ // failure it deliberately swallows.
624
+ const script = combinedScript(required, "totally-unrecognized-command")
625
+
626
+ const result = applyEmittedScript(repo, NO_COMMANDS, script)
627
+
628
+ expect(result).toEqual({ ok: true })
629
+ expect(repo.lastCommitMessage()).toBe("gtd(human): idle")
630
+ })
631
+
632
+ it("still fails loudly on an unrecognized block outside the presentation-only subshell", () => {
633
+ const repo = new InMemRepo()
634
+ const script = `${combinedScript("totally-unrecognized-required-command", "")}`
635
+ const result = applyEmittedScript(repo, NO_COMMANDS, script)
636
+ expect(result.ok).toBe(false)
637
+ expect(result.error).toContain("totally-unrecognized-required-command")
638
+ })
639
+ })