@pmelab/gtd 23.2.0 → 24.1.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 (236) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +44 -11
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43830 -56044
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +1953 -1724
  19. package/package.json +70 -8
  20. package/skills/authoring/SKILL.md +5 -4
  21. package/src/cli/Cli.test.ts +1329 -0
  22. package/src/cli/Cli.ts +1348 -0
  23. package/src/cli/Program.test.ts +2262 -0
  24. package/src/cli/Program.ts +1331 -0
  25. package/src/cli/ToolCommands.ts +43 -0
  26. package/src/cli/index.ts +14 -0
  27. package/src/edge/Edge.test.ts +1016 -0
  28. package/src/edge/Edge.ts +1225 -0
  29. package/src/edge/index.ts +16 -0
  30. package/src/emit/Emit.test.ts +425 -0
  31. package/src/emit/Emit.ts +139 -0
  32. package/src/emit/GitScript.test.ts +260 -0
  33. package/src/emit/GitScript.ts +131 -0
  34. package/src/emit/ModeContradiction.test.ts +120 -0
  35. package/src/emit/ModeContradiction.ts +66 -0
  36. package/src/emit/OutcomeScript.test.ts +238 -0
  37. package/src/emit/OutcomeScript.ts +73 -0
  38. package/src/emit/SteeringMode.test.ts +274 -0
  39. package/src/emit/SteeringMode.ts +256 -0
  40. package/src/emit/index.ts +40 -0
  41. package/src/flows/factory.ts +5 -1
  42. package/src/flows/runtime.ts +3 -0
  43. package/src/install/ConfigScaffold.ts +19 -0
  44. package/src/install/Install.test.ts +426 -0
  45. package/src/install/Install.ts +634 -0
  46. package/src/install/Version.ts +27 -0
  47. package/src/install/index.ts +3 -0
  48. package/src/platform/CommandRunner.ts +74 -0
  49. package/src/platform/Commentary.test.ts +126 -0
  50. package/src/platform/Commentary.ts +92 -0
  51. package/src/platform/Git.test.ts +50 -0
  52. package/src/platform/Git.ts +552 -0
  53. package/src/platform/Host.ts +42 -0
  54. package/src/platform/RetainedHistory.test.ts +65 -0
  55. package/src/platform/RetainedHistory.ts +34 -0
  56. package/src/platform/Workspace.test.ts +669 -0
  57. package/src/platform/Workspace.ts +631 -0
  58. package/src/platform/WorktreeState.test.ts +162 -0
  59. package/src/platform/WorktreeState.ts +50 -0
  60. package/src/platform/index.ts +22 -0
  61. package/src/replay/Access.test.ts +53 -0
  62. package/src/replay/Access.ts +46 -0
  63. package/src/replay/Format.test.ts +20 -0
  64. package/src/replay/Format.ts +14 -0
  65. package/src/replay/Replay.test.ts +887 -0
  66. package/src/replay/Replay.ts +669 -0
  67. package/src/replay/Trailers.test.ts +117 -0
  68. package/src/replay/Trailers.ts +213 -0
  69. package/src/replay/Tree.test.ts +25 -0
  70. package/src/replay/Tree.ts +45 -0
  71. package/src/replay/index.ts +11 -0
  72. package/src/steering/Blocks.fixture.ts +6 -0
  73. package/src/steering/Blocks.test.ts +167 -0
  74. package/src/steering/Blocks.ts +321 -0
  75. package/src/steering/CodeThreads.test.ts +127 -0
  76. package/src/steering/CodeThreads.ts +197 -0
  77. package/src/steering/Edits.test.ts +19 -0
  78. package/src/steering/Edits.ts +20 -0
  79. package/src/steering/Eol.test.ts +16 -0
  80. package/src/steering/Eol.ts +2 -0
  81. package/src/steering/FORMATS.md +58 -0
  82. package/src/steering/Footnotes.test.ts +977 -0
  83. package/src/steering/Footnotes.ts +661 -0
  84. package/src/steering/Inline.fixture.ts +7 -0
  85. package/src/steering/Inline.test.ts +227 -0
  86. package/src/steering/Inline.ts +195 -0
  87. package/src/steering/MarkdownTree.test.ts +247 -0
  88. package/src/steering/MarkdownTree.ts +260 -0
  89. package/src/steering/Outline.ts +19 -0
  90. package/src/steering/SteeringFormat.test.ts +91 -0
  91. package/src/steering/SteeringFormat.ts +267 -0
  92. package/src/steering/SteeringFormats.test.ts +163 -0
  93. package/src/steering/Threads.ts +163 -0
  94. package/src/steering/contract.test.ts +264 -0
  95. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  96. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  97. package/src/steering/fixtures/free-form/notes.md +12 -0
  98. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  99. package/src/steering/fixtures/qa/answered.md +23 -0
  100. package/src/steering/fixtures/qa/answered.read.json +119 -0
  101. package/src/steering/fixtures/qa/requirements.md +160 -0
  102. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  103. package/src/steering/fixtures/review/review.md +76 -0
  104. package/src/steering/fixtures/review/review.read.json +237 -0
  105. package/src/steering/fixtures/review/ticked.md +23 -0
  106. package/src/steering/fixtures/review/ticked.read.json +99 -0
  107. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  108. package/src/steering/fixtures/review-notes/noted.md +19 -0
  109. package/src/steering/freeform.test.ts +311 -0
  110. package/src/steering/freeform.ts +148 -0
  111. package/src/steering/index.test.ts +62 -0
  112. package/src/steering/index.ts +140 -0
  113. package/src/steering/qa.test.ts +2160 -0
  114. package/src/steering/qa.ts +1104 -0
  115. package/src/steering/review.test.ts +1829 -0
  116. package/src/steering/review.ts +1180 -0
  117. package/src/step/LandStep.ts +31 -0
  118. package/src/step/RepoSnapshot.ts +29 -0
  119. package/src/step/accessRefusal.test.ts +58 -0
  120. package/src/step/accessRefusal.ts +26 -0
  121. package/src/step/index.ts +5 -0
  122. package/src/step/planStart.test.ts +202 -0
  123. package/src/step/planStart.ts +167 -0
  124. package/src/step/planStep.test.ts +104 -0
  125. package/src/step/planStep.ts +89 -0
  126. package/src/step/snapshot.fixture.ts +28 -0
  127. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  128. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  129. package/src/testkit/FakeGitOperations.ts +95 -0
  130. package/src/testkit/GitTiers.test.ts +11 -0
  131. package/src/testkit/GitTiers.ts +1052 -0
  132. package/src/testkit/InMemRepo.ts +467 -0
  133. package/src/testkit/Layers.test.ts +58 -0
  134. package/src/testkit/Layers.ts +241 -0
  135. package/src/testkit/StrictGitOperations.ts +31 -0
  136. package/src/testkit/TestRepo.test.ts +119 -0
  137. package/src/testkit/TestRepo.ts +274 -0
  138. package/src/testkit/cliIo.ts +36 -0
  139. package/src/testkit/index.ts +9 -0
  140. package/src/testkit/steps.ts +177 -0
  141. package/src/testkit/testkit.feature +65 -0
  142. package/src/wire/BeatDocument.test.ts +419 -0
  143. package/src/wire/BeatDocument.ts +114 -0
  144. package/src/wire/BeatPlain.test.ts +113 -0
  145. package/src/wire/BeatPlain.ts +127 -0
  146. package/src/wire/BeatStatus.test.ts +59 -0
  147. package/src/wire/BeatStatus.ts +81 -0
  148. package/src/wire/Demand.test.ts +142 -0
  149. package/src/wire/Demand.ts +108 -0
  150. package/src/wire/ExitCodes.test.ts +21 -0
  151. package/src/wire/ExitCodes.ts +16 -0
  152. package/src/wire/Glob.ts +29 -0
  153. package/src/wire/Judge.ts +22 -0
  154. package/src/wire/Land.test.ts +120 -0
  155. package/src/wire/Land.ts +75 -0
  156. package/src/wire/Select.test.ts +148 -0
  157. package/src/wire/Select.ts +78 -0
  158. package/src/wire/Sessions.test.ts +59 -0
  159. package/src/wire/Sessions.ts +39 -0
  160. package/src/wire/WireSchema.test.ts +162 -0
  161. package/src/wire/constants.ts +22 -0
  162. package/src/wire/golden/1/beat-capture.json +34 -0
  163. package/src/wire/golden/1/beat-message.json +34 -0
  164. package/src/wire/golden/1/beat-prompt.json +44 -0
  165. package/src/wire/golden/1/beat-script.json +34 -0
  166. package/src/wire/golden/1/beat-stalled.json +34 -0
  167. package/src/wire/golden/1/judge.json +14 -0
  168. package/src/wire/golden/1/land-noop.json +10 -0
  169. package/src/wire/golden/1/land.json +10 -0
  170. package/src/wire/index.ts +12 -0
  171. package/src/wire/types.ts +46 -0
  172. package/src/workflow/Diagnostic.test.ts +93 -0
  173. package/src/workflow/Diagnostic.ts +54 -0
  174. package/src/workflow/SteeringFormats.test.ts +27 -0
  175. package/src/workflow/SteeringFormats.ts +6 -0
  176. package/src/workflow/Workflow.ts +77 -0
  177. package/src/workflow/compile.test.ts +53 -0
  178. package/src/workflow/compile.ts +56 -0
  179. package/src/workflow/discovery.ts +122 -0
  180. package/src/workflow/fingerprint.test.ts +52 -0
  181. package/src/workflow/fingerprint.ts +53 -0
  182. package/src/workflow/index.ts +33 -0
  183. package/src/workflow/load.test.ts +1017 -0
  184. package/src/workflow/load.ts +836 -0
  185. package/src/workflow/skills.test.ts +109 -0
  186. package/src/workflow/skills.ts +90 -0
  187. package/src/workflow/vars.ts +33 -0
  188. package/src/workflow-kit/health.test.ts +30 -0
  189. package/src/workflow-kit/health.ts +86 -0
  190. package/src/workflow-kit/index.ts +7 -0
  191. package/src/workflow-kit/quality.test.ts +17 -0
  192. package/src/workflow-kit/quality.ts +45 -0
  193. package/src/workflow-kit/review.ts +324 -0
  194. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  195. package/src/workflows/feature/feature.test.ts +32 -0
  196. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  197. package/src/workflows/feature/index.ts +10 -0
  198. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  199. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  200. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  201. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  202. package/src/workflows/feature/review.ts +106 -0
  203. package/src/workflows/feature/steps.test.ts +82 -0
  204. package/src/workflows/feature/steps.ts +92 -0
  205. package/src/workflows/fix/fix.feature +80 -0
  206. package/src/workflows/fix/fix.test.ts +74 -0
  207. package/src/workflows/fix/fix.ts +52 -0
  208. package/src/workflows/fix/index.ts +6 -0
  209. package/src/workflows/index.ts +36 -2
  210. package/src/workflows/review/index.ts +6 -0
  211. package/src/workflows/review/review.test.ts +99 -0
  212. package/src/workflows/review/review.ts +33 -0
  213. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  214. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  215. package/src/workflows/shared/config.fixture.ts +12 -0
  216. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  217. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  218. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  219. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  220. package/src/workflows/shared/reserved.ts +11 -0
  221. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  222. package/src/workflows/shared/scenarios.ts +35 -0
  223. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  224. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  225. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  226. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  227. package/src/workflows/shared/tail.test.ts +205 -0
  228. package/src/workflows/shared/tail.ts +135 -0
  229. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  230. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  231. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  232. package/src/workflows/shared/workflows.steps.ts +7 -0
  233. package/src/workflows/review.ts +0 -362
  234. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  235. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  236. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
@@ -0,0 +1,2262 @@
1
+ /**
2
+ * Behavioural coverage of `src/cli/Program.ts`'s commands, each run through the
3
+ * real `runCli` shell over an in-memory repo — the same shape the `@inmem`
4
+ * e2e tier observes. Argv parsing is pinned in `src/cli/Cli.test.ts`.
5
+ */
6
+
7
+ import { Cause, Effect, Exit } from "effect"
8
+ import { PassThrough } from "node:stream"
9
+ import { afterEach, describe, expect, it, vi } from "vitest"
10
+
11
+ // `gtd ui`'s bind-host lookup reads the real `os.networkInterfaces()` — a host
12
+ // on a tailnet would make the "no Tailscale interface" refusal below fail.
13
+ vi.mock("node:os", async (importOriginal) => ({
14
+ ...(await importOriginal<typeof import("node:os")>()),
15
+ networkInterfaces: () => ({}),
16
+ }))
17
+
18
+ import { runCli, type Command } from "./index.js"
19
+ import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
20
+ import { formatFinding, needsOf, runCommand, SelectorUsageError } from "./Program.js"
21
+ import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../testkit/index.js"
22
+ import {
23
+ commitAll,
24
+ DID_NOT_RUN_COMMENT,
25
+ abandonNoopOutcome,
26
+ noteOutcome,
27
+ restoredOutcome,
28
+ } from "../emit/index.js"
29
+ import { HISTORY_REF } from "../platform/index.js"
30
+ import { CONFIG_SCAFFOLD } from "../install/index.js"
31
+
32
+ const run = async (
33
+ repo: InMemRepo,
34
+ ...args: string[]
35
+ ): Promise<{ readonly stdout: string; readonly stderr: string; readonly exitCode: number }> => {
36
+ const { io, result } = makeCapturingCliIo(repo)
37
+ await Effect.runPromise(runCli(["node", "gtd.js", ...args], io))
38
+ return result()
39
+ }
40
+
41
+ /** Lands via `gtd land --json` and applies the emitted script, as a driver would. */
42
+ const landAndApply = async (
43
+ repo: InMemRepo,
44
+ ): Promise<{ readonly exitCode: number; readonly script: string }> => {
45
+ const { stdout, exitCode } = await run(repo, "land", "--json")
46
+ const script = exitCode === 0 ? (JSON.parse(stdout) as { readonly script: string }).script : ""
47
+ if (exitCode === 0) expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
48
+ return { exitCode, script }
49
+ }
50
+
51
+ /** Writes `files` into the tree, then lands them as one turn. */
52
+ const landTurn = async (repo: InMemRepo, files: Record<string, string> = {}): Promise<void> => {
53
+ for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
54
+ expect((await landAndApply(repo)).exitCode).toBe(0)
55
+ }
56
+
57
+ /** A repo whose only commit adds `gtd.config.ts` (and any `extra` files). */
58
+ const seed = (source: string, extra: Record<string, string> = {}): InMemRepo => {
59
+ const repo = new InMemRepo()
60
+ repo.writeFile("gtd.config.ts", source)
61
+ for (const [path, content] of Object.entries(extra)) repo.writeFile(path, content)
62
+ repo.commitAllWithPrefix("chore: add custom workflow")
63
+ return repo
64
+ }
65
+
66
+ /** A repo `gtd install` set up: its config runs the shipped workflows. */
67
+ const seedBundled = (): InMemRepo => {
68
+ const repo = new InMemRepo()
69
+ repo.writeFile("README.md", "# test project\n")
70
+ repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
71
+ repo.commitAllWithPrefix("chore: init")
72
+ return repo
73
+ }
74
+
75
+ const IDLE_THEN_WORKING = `import { agent, human } from "@pmelab/gtd/flows"
76
+
77
+ export default async () => {
78
+ await human("idle", { message: "write NOTE.md to start a process" })
79
+ await agent("working", "do the work described in NOTE.md")
80
+ }
81
+ `
82
+
83
+ describe("gtd --workflow <name> — a custom workflow's named workflow", () => {
84
+ const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
85
+
86
+ export default async () => {
87
+ await human("idle", { message: "hi" })
88
+ await agent("working", "go")
89
+ }
90
+
91
+ export const hotfix = async () => {
92
+ await agent("working", "go")
93
+ }
94
+
95
+ export const defaults = { greeting: "hello" }
96
+ `
97
+
98
+ it("the happy path emits the opening commit's script — gtd itself writes nothing", async () => {
99
+ const repo = seed(WORKFLOW)
100
+ const before = repo.commitHistory().length
101
+ const { stdout, exitCode } = await run(repo, "--workflow", "hotfix")
102
+ expect(exitCode).toBe(0)
103
+ expect(stdout).toContain(
104
+ commitAll(
105
+ "gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 1",
106
+ ),
107
+ )
108
+ expect(repo.commitHistory()).toHaveLength(before)
109
+ })
110
+
111
+ it("an unknown workflow is a usage error (exit 2) listing every startable name", async () => {
112
+ const repo = seed(WORKFLOW)
113
+ const before = repo.commitHistory().length
114
+ const { exitCode, stderr } = await run(repo, "--workflow", "bogus")
115
+ expect(exitCode).toBe(2)
116
+ expect(repo.commitHistory()).toHaveLength(before)
117
+ expect(stderr).toContain('unknown workflow "bogus"')
118
+ expect(stderr).toContain("startable: hotfix")
119
+ })
120
+
121
+ it("`default` is not startable by name", async () => {
122
+ const repo = seed(WORKFLOW)
123
+ const { exitCode, stderr } = await run(repo, "--workflow", "default")
124
+ expect(exitCode).toBe(2)
125
+ expect(stderr).toContain('unknown workflow "default"')
126
+ })
127
+
128
+ it("--entry is an ordinary unknown flag", async () => {
129
+ const repo = seed(WORKFLOW)
130
+ const { exitCode } = await run(repo, "--entry", "hotfix")
131
+ expect(exitCode).toBe(2)
132
+ })
133
+
134
+ it("a process already underway refuses", async () => {
135
+ const repo = seed(WORKFLOW)
136
+ await landTurn(repo, { ".gtd/TODO.md": "sketch\n" })
137
+ const before = repo.commitHistory().length
138
+ const { exitCode, stderr } = await run(repo, "--workflow", "hotfix")
139
+ expect(exitCode).toBe(1)
140
+ expect(repo.commitHistory()).toHaveLength(before)
141
+ expect(stderr).toContain("already underway")
142
+ })
143
+
144
+ it("an undeclared --var name refuses, listing the declared names", async () => {
145
+ const repo = seed(WORKFLOW)
146
+ const before = repo.commitHistory().length
147
+ const { exitCode, stderr } = await run(repo, "--workflow", "hotfix", "--var", "bogus=1")
148
+ expect(exitCode).toBe(1)
149
+ expect(repo.commitHistory()).toHaveLength(before)
150
+ expect(stderr).toContain("bogus")
151
+ expect(stderr).toContain("greeting")
152
+ })
153
+
154
+ it("a declared --var override renders as a Gtd-Var trailer in the emitted commit script", async () => {
155
+ const repo = seed(WORKFLOW)
156
+ const before = repo.commitHistory().length
157
+ const { exitCode, stdout } = await run(repo, "--workflow", "hotfix", "--var", "greeting=world")
158
+ expect(exitCode).toBe(0)
159
+ expect(stdout).toContain("Gtd-Var: greeting=world")
160
+ expect(repo.commitHistory()).toHaveLength(before)
161
+ })
162
+
163
+ it("a dirty working tree does not refuse the start — the emitted script captures it", async () => {
164
+ const repo = seed(WORKFLOW)
165
+ repo.writeFile("scratch.txt", "uncommitted\n")
166
+ const before = repo.commitHistory().length
167
+ const { exitCode, stdout } = await run(repo, "--workflow", "hotfix")
168
+ expect(exitCode).toBe(0)
169
+ expect(stdout.length).toBeGreaterThan(0)
170
+ expect(repo.commitHistory()).toHaveLength(before)
171
+ })
172
+
173
+ it("applying the start script opens the workflow's episode at its first step", async () => {
174
+ const repo = seed(WORKFLOW)
175
+ const { stdout } = await run(repo, "--workflow", "hotfix")
176
+ expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
177
+ expect(repo.lastCommitSubject()).toBe("gtd(human): working")
178
+ const next = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
179
+ expect(next.kind).toBe("prompt")
180
+ expect(next.state).toBe("working")
181
+ expect(next.workflow).toBe("hotfix")
182
+ })
183
+
184
+ it("a pinned workflow the config no longer defines is refused with the abandon advice", async () => {
185
+ const repo = seed(WORKFLOW)
186
+ const { stdout } = await run(repo, "--workflow", "hotfix")
187
+ applyEmittedScript(repo, new Map(), stdout)
188
+ repo.writeFile("gtd.config.ts", WORKFLOW.replace("export const hotfix", "const hotfix"))
189
+ const { exitCode, stderr } = await run(repo, "next")
190
+ expect(exitCode).not.toBe(0)
191
+ expect(stderr).toContain('runs workflow "hotfix", which gtd.config.ts no longer defines')
192
+ expect(stderr).toContain("gtd abandon")
193
+ })
194
+ })
195
+
196
+ describe("gtd --workflow <name> — the shipped workflows", () => {
197
+ it("--workflow review --var reviewBase=<base> emits a script anchoring the process to that base", async () => {
198
+ const repo = seedBundled()
199
+ const base = repo.commitHistory().at(-1)!.hash
200
+ repo.writeFile("scratch.txt", "more work\n")
201
+ repo.commitAllWithPrefix("chore: more work")
202
+ const before = repo.commitHistory().length
203
+ const { stdout, exitCode } = await run(
204
+ repo,
205
+ "--workflow",
206
+ "review",
207
+ "--var",
208
+ `reviewBase=${base}`,
209
+ )
210
+ expect(exitCode).toBe(0)
211
+ expect(stdout).toContain("gtd(human): review-gate.check")
212
+ expect(stdout).toContain("Gtd-Workflow: review")
213
+ expect(stdout).toContain(`Gtd-Review-Base: ${base}`)
214
+ expect(repo.commitHistory()).toHaveLength(before)
215
+ })
216
+
217
+ it("--workflow fix emits a script that would start a fix process", async () => {
218
+ const repo = seedBundled()
219
+ const before = repo.commitHistory().length
220
+ const { stdout, exitCode } = await run(repo, "--workflow", "fix")
221
+ expect(exitCode).toBe(0)
222
+ expect(stdout).toContain("gtd(human): fix-precheck")
223
+ expect(stdout).toContain("Gtd-Workflow: fix")
224
+ expect(repo.commitHistory()).toHaveLength(before)
225
+ })
226
+ })
227
+
228
+ describe("config loading", () => {
229
+ it('gtd next --verbose narrates "config: module" exactly once — the extra load that surfaces warnings is silent', async () => {
230
+ const repo = seedBundled()
231
+ const { stderr, exitCode } = await run(repo, "next", "--verbose")
232
+ expect(exitCode).toBe(0)
233
+ expect(stderr.match(/config: module /g)).toHaveLength(1)
234
+ })
235
+ })
236
+
237
+ describe("gtd next --json — label emission", () => {
238
+ const workflowWith = (
239
+ options: string,
240
+ ): string => `import { agent, human } from "@pmelab/gtd/flows"
241
+
242
+ export default async () => {
243
+ await human("idle", { message: "write NOTE.md to start a process" })
244
+ await agent("working", "do the work described in NOTE.md"${options})
245
+ }
246
+ `
247
+
248
+ // Plain `gtd next` prints no header at a `prompt` rest, so the plain
249
+ // `Label:` line needs a message rest.
250
+ it("gtd next shows the step's declared label as a plain-text Label: line at a non-prompt rest", async () => {
251
+ const repo = seed(`import { human } from "@pmelab/gtd/flows"
252
+
253
+ export default async () => {
254
+ await human("idle", { message: "write NOTE.md to start a process" })
255
+ await human("reviewing", { label: "planning", message: "check the note" })
256
+ }
257
+ `)
258
+ await landTurn(repo, { "NOTE.md": "a note\n" })
259
+ const { stdout, exitCode } = await run(repo, "next")
260
+ expect(exitCode).toBe(0)
261
+ expect(stdout).toContain("State: reviewing")
262
+ expect(stdout).toContain("Label: planning")
263
+ })
264
+
265
+ it("gtd next --json carries the step's declared label", async () => {
266
+ const repo = seed(workflowWith(', { label: "planning" }'))
267
+ await landTurn(repo, { "NOTE.md": "a note\n" })
268
+ const { stdout, exitCode } = await run(repo, "next", "--json")
269
+ expect(exitCode).toBe(0)
270
+ expect((JSON.parse(stdout) as Record<string, unknown>).label).toBe("planning")
271
+ })
272
+
273
+ it("gtd next --json omits label entirely when the step declares none", async () => {
274
+ const repo = seed(workflowWith(""))
275
+ await landTurn(repo, { "NOTE.md": "a note\n" })
276
+ const { stdout, exitCode } = await run(repo, "next", "--json")
277
+ expect(exitCode).toBe(0)
278
+ expect(JSON.parse(stdout) as Record<string, unknown>).not.toHaveProperty("label")
279
+ })
280
+ })
281
+
282
+ describe("gtd next --json — log path emission", () => {
283
+ // `log` is the per-worktree loop log path (src/platform/WorktreeState.ts's
284
+ // `loopLogPath`) — always present, unlike the omit-when-unset keys above.
285
+
286
+ const seededRepo = (): InMemRepo => {
287
+ const repo = new InMemRepo()
288
+ repo.writeFile("NOTE.md", "a note\n")
289
+ repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
290
+ repo.commitAllWithPrefix("gtd: init")
291
+ return repo
292
+ }
293
+
294
+ it("defaults to .git/gtd-loop.log", async () => {
295
+ const repo = seededRepo()
296
+ const { stdout, exitCode } = await run(repo, "next", "--json")
297
+ expect(exitCode).toBe(0)
298
+ const parsed = JSON.parse(stdout) as Record<string, unknown>
299
+ expect(parsed.log).toBe(".git/gtd-loop.log")
300
+ })
301
+
302
+ it("GTD_LOOP_LOG overrides it verbatim", async () => {
303
+ const repo = seededRepo()
304
+ const { io, result } = makeCapturingCliIo(repo, { GTD_LOOP_LOG: "/elsewhere/run.log" })
305
+ await Effect.runPromise(runCli(["node", "gtd.js", "next", "--json"], io))
306
+ const { stdout, exitCode } = result()
307
+ expect(exitCode).toBe(0)
308
+ const parsed = JSON.parse(stdout) as Record<string, unknown>
309
+ expect(parsed.log).toBe("/elsewhere/run.log")
310
+ })
311
+ })
312
+
313
+ describe("gtd next --json — memory key emission", () => {
314
+ const CHECKING = `import { human, run } from "@pmelab/gtd/flows"
315
+
316
+ export default async () => {
317
+ await human("idle", { message: "write NOTE.md to start a process" })
318
+ await run("checking", "echo hi")
319
+ }
320
+ `
321
+
322
+ // Loops through a script step without touching the initial step, so the
323
+ // agent's scope-run is never broken by a new process.
324
+ const PROMPT_THEN_CHECK = `import { agent, human, run } from "@pmelab/gtd/flows"
325
+
326
+ export default async () => {
327
+ await human("idle", { message: "write NOTE.md to start a process" })
328
+ for (;;) {
329
+ await agent("working", "do the work described in NOTE.md")
330
+ await run("check", "echo hi")
331
+ }
332
+ }
333
+ `
334
+
335
+ const atWorking = async (source = IDLE_THEN_WORKING): Promise<InMemRepo> => {
336
+ const repo = seed(source)
337
+ await landTurn(repo, { "NOTE.md": "a note\n" })
338
+ return repo
339
+ }
340
+
341
+ const MEMORY_KEY = /^root#[0-9a-f]{7}$/
342
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
343
+
344
+ type WithSession = { readonly session?: { readonly id: string; readonly resume: boolean } }
345
+
346
+ it("gtd next --json computes a <scope>#<hash7> memory key for a prompt rest", async () => {
347
+ const repo = await atWorking()
348
+ const { stdout, exitCode } = await run(repo, "next", "--json")
349
+ expect(exitCode).toBe(0)
350
+ expect((JSON.parse(stdout) as Record<string, unknown>).memory).toMatch(MEMORY_KEY)
351
+ })
352
+
353
+ it("gtd next --json omits memory entirely for a non-prompt rest", async () => {
354
+ const repo = await atWorking(CHECKING)
355
+ const { stdout, exitCode } = await run(repo, "next", "--json")
356
+ expect(exitCode).toBe(0)
357
+ expect(JSON.parse(stdout) as Record<string, unknown>).not.toHaveProperty("memory")
358
+ })
359
+
360
+ it("gtd next --json derives a session for a prompt rest, resume: false on the fresh scope-run", async () => {
361
+ const repo = await atWorking()
362
+ const { stdout, exitCode } = await run(repo, "next", "--json")
363
+ expect(exitCode).toBe(0)
364
+ const parsed = JSON.parse(stdout) as WithSession
365
+ expect(parsed.session?.id).toMatch(UUID)
366
+ expect(parsed.session?.resume).toBe(false)
367
+ })
368
+
369
+ it("two next --json calls back-to-back yield the SAME id and resume — a peek writes nothing", async () => {
370
+ const repo = await atWorking()
371
+ const first = JSON.parse((await run(repo, "next", "--json")).stdout) as WithSession
372
+ const second = JSON.parse((await run(repo, "next", "--json")).stdout) as WithSession
373
+ expect(second.session?.id).toBe(first.session?.id)
374
+ expect(first.session?.resume).toBe(false)
375
+ expect(second.session?.resume).toBe(false)
376
+ })
377
+
378
+ it("three consecutive next --json calls at a prompt rest are byte-identical and leave the commit count unchanged", async () => {
379
+ const repo = await atWorking()
380
+ const before = repo.commitHistory().length
381
+ const first = (await run(repo, "next", "--json")).stdout
382
+ const second = (await run(repo, "next", "--json")).stdout
383
+ const third = (await run(repo, "next", "--json")).stdout
384
+ expect(second).toBe(first)
385
+ expect(third).toBe(first)
386
+ expect(repo.commitHistory().length).toBe(before)
387
+ })
388
+
389
+ it("resume flips false → true once a turn lands back at the same prompt step, id unchanged", async () => {
390
+ const repo = await atWorking(PROMPT_THEN_CHECK)
391
+ const first = JSON.parse((await run(repo, "next", "--json")).stdout) as WithSession
392
+ expect(first.session?.resume).toBe(false)
393
+
394
+ await landTurn(repo, { "NOTE.md": "the agent did the work\n" })
395
+ await landTurn(repo, { "FEEDBACK.md": "check ran\n" })
396
+
397
+ const second = JSON.parse((await run(repo, "next", "--json")).stdout) as WithSession
398
+ expect(second.session?.id).toBe(first.session?.id)
399
+ expect(second.session?.resume).toBe(true)
400
+ })
401
+
402
+ it("gtd next --json omits session for a non-prompt rest", async () => {
403
+ const repo = await atWorking(CHECKING)
404
+ const { stdout, exitCode } = await run(repo, "next", "--json")
405
+ expect(exitCode).toBe(0)
406
+ expect(JSON.parse(stdout) as Record<string, unknown>).not.toHaveProperty("session")
407
+ })
408
+ })
409
+
410
+ describe("gtd next --json — stall detection (attempt commits)", () => {
411
+ const WORKFLOW = `import { agent, human, run } from "@pmelab/gtd/flows"
412
+
413
+ const work = async () => {
414
+ await agent("working", "do the work described in NOTE.md")
415
+ await run("checking", "echo hi")
416
+ }
417
+
418
+ export default async () => {
419
+ await human("idle", { message: "write NOTE.md to start a process" })
420
+ await work()
421
+ }
422
+
423
+ export const resume = work
424
+ `
425
+
426
+ const atWorking = async (): Promise<InMemRepo> => {
427
+ const repo = seed(WORKFLOW)
428
+ await landTurn(repo, { "NOTE.md": "a note\n" })
429
+ return repo
430
+ }
431
+
432
+ const attempted = async (): Promise<InMemRepo> => {
433
+ const repo = await atWorking()
434
+ await landTurn(repo)
435
+ return repo
436
+ }
437
+
438
+ it("is not kind stalled before the attempt lands", async () => {
439
+ const repo = await atWorking()
440
+ const { stdout, exitCode } = await run(repo, "next", "--json")
441
+ expect(exitCode).toBe(0)
442
+ expect(JSON.parse(stdout).kind).not.toBe("stalled")
443
+ })
444
+
445
+ it("is kind stalled once `gtd land` has landed an empty attempt, with the diagnosis as content and no session/validate key", async () => {
446
+ const repo = await attempted()
447
+ expect(repo.lastCommitSubject()).toBe("gtd(agent): working")
448
+ expect(repo.lastCommitMessage()).not.toContain("Gtd-Step:")
449
+
450
+ const { stdout } = await run(repo, "next", "--json")
451
+ const parsed = JSON.parse(stdout) as Record<string, unknown>
452
+ expect(parsed.kind).toBe("stalled")
453
+ expect(parsed.content).toBe(stallDiagnosis("working", "agent"))
454
+ expect(parsed).not.toHaveProperty("session")
455
+ expect(parsed).not.toHaveProperty("validate")
456
+ })
457
+
458
+ it("a clean-tree entry commit resting at a prompt step is NOT a stall — the actor differs (human entered, agent acts)", async () => {
459
+ const repo = seed(WORKFLOW)
460
+ const { stdout: entry } = await run(repo, "--workflow", "resume")
461
+ expect(applyEmittedScript(repo, new Map(), entry).ok).toBe(true)
462
+ const { stdout, exitCode } = await run(repo, "next", "--json")
463
+ expect(exitCode).toBe(0)
464
+ expect(JSON.parse(stdout).kind).toBe("prompt")
465
+ })
466
+
467
+ it("plain gtd next renders the stall diagnosis too — a human peek must not see the prompt that went nowhere", async () => {
468
+ const repo = await attempted()
469
+ const { stdout, exitCode } = await run(repo, "next")
470
+ expect(exitCode).toBe(0)
471
+ expect(stdout.startsWith("State: working\nAwaits: agent\nWorkflow: default\n")).toBe(true)
472
+ expect(stdout).toContain(`\n\n${stallDiagnosis("working", "agent")}`)
473
+ })
474
+
475
+ it("stays stalled on a repeat", async () => {
476
+ const repo = await attempted()
477
+ expect(JSON.parse((await run(repo, "next", "--json")).stdout).kind).toBe("stalled")
478
+ expect(JSON.parse((await run(repo, "next", "--json")).stdout).kind).toBe("stalled")
479
+ })
480
+
481
+ it("is not kind stalled with a dirty tree", async () => {
482
+ const repo = await attempted()
483
+ repo.writeFile("scratch.txt", "x\n")
484
+ const { stdout } = await run(repo, "next", "--json")
485
+ expect(JSON.parse(stdout).kind).not.toBe("stalled")
486
+ })
487
+
488
+ it("is never stalled at a script rest", async () => {
489
+ const repo = await atWorking()
490
+ await landTurn(repo, { "WORK.md": "done\n" })
491
+ const { stdout } = await run(repo, "next", "--json")
492
+ expect(JSON.parse(stdout).kind).toBe("script")
493
+ })
494
+
495
+ it("is never stalled at a message rest", async () => {
496
+ const repo = seed(WORKFLOW)
497
+ const { stdout } = await run(repo, "next", "--json")
498
+ expect(JSON.parse(stdout).kind).toBe("message")
499
+ })
500
+ })
501
+
502
+ describe("gtd next --json — capture/message kinds at a human gate", () => {
503
+ it("is kind message at a clean message rest", async () => {
504
+ const repo = seed(IDLE_THEN_WORKING)
505
+ const { stdout, exitCode } = await run(repo, "next", "--json")
506
+ expect(exitCode).toBe(0)
507
+ expect(JSON.parse(stdout).kind).toBe("message")
508
+ })
509
+
510
+ it("is kind capture at a dirty message rest — the human already acted", async () => {
511
+ const repo = seed(IDLE_THEN_WORKING)
512
+ repo.writeFile("NOTE.md", "a note\n")
513
+ const { stdout, exitCode } = await run(repo, "next", "--json")
514
+ expect(exitCode).toBe(0)
515
+ expect(JSON.parse(stdout).kind).toBe("capture")
516
+ })
517
+ })
518
+
519
+ describe("gtd next — exit code is uniformly 0 across every rest shape", () => {
520
+ const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
521
+
522
+ export default async () => {
523
+ await human("idle", { message: "write NOTE.md to start a process" })
524
+ await agent("working", "do the work described in NOTE.md")
525
+ await human("waiting", { message: "confirm before continuing" })
526
+ }
527
+ `
528
+
529
+ it("a clean tree at the initial step is 0 (idle)", async () => {
530
+ const repo = seed(WORKFLOW)
531
+ expect((await run(repo, "next")).exitCode).toBe(0)
532
+ expect((await run(repo, "next", "--json")).exitCode).toBe(0)
533
+ })
534
+
535
+ it("a dirty tree at the initial step (kind capture) is still 0", async () => {
536
+ const repo = seed(WORKFLOW)
537
+ repo.writeFile("NOTE.md", "a note\n")
538
+ expect((await run(repo, "next")).exitCode).toBe(0)
539
+ expect((await run(repo, "next", "--json")).exitCode).toBe(0)
540
+ })
541
+
542
+ it("a clean, NON-initial message gate (kind message) is still 0", async () => {
543
+ const repo = seed(WORKFLOW)
544
+ await landTurn(repo, { "NOTE.md": "a note\n" })
545
+ await landTurn(repo, { "DONE.md": "done\n" })
546
+ const { stdout, exitCode } = await run(repo, "next", "--json")
547
+ expect(JSON.parse(stdout).kind).toBe("message")
548
+ expect(exitCode).toBe(0)
549
+ expect((await run(repo, "next")).exitCode).toBe(0)
550
+ })
551
+
552
+ it("a non-initial prompt rest (kind prompt) is still 0", async () => {
553
+ const repo = seed(WORKFLOW)
554
+ await landTurn(repo, { "NOTE.md": "a note\n" })
555
+ const { stdout, exitCode } = await run(repo, "next", "--json")
556
+ expect(JSON.parse(stdout).kind).toBe("prompt")
557
+ expect(exitCode).toBe(0)
558
+ expect((await run(repo, "next")).exitCode).toBe(0)
559
+ })
560
+ })
561
+
562
+ const WITH_STEERING_FILE = (
563
+ mode: string,
564
+ ): string => `import { agent, human } from "@pmelab/gtd/flows"
565
+
566
+ export default async () => {
567
+ await human("idle", { message: "write NOTE.md to start a process" })
568
+ await agent("working", "do the work", { file: ".gtd/PLAN.md", mode: "${mode}" })
569
+ }
570
+ `
571
+
572
+ describe("gtd next --json — embedded validate script", () => {
573
+ const atWorking = async (): Promise<InMemRepo> => {
574
+ const repo = seed(WITH_STEERING_FILE("qa"))
575
+ await landTurn(repo, { "NOTE.md": "a note\n" })
576
+ return repo
577
+ }
578
+
579
+ it("embeds the same script gtd validate itself prints, when the declared file is present", async () => {
580
+ const repo = await atWorking()
581
+ repo.writeFile(".gtd/PLAN.md", "- [ ] a question\n")
582
+
583
+ const { stdout: nextStdout, exitCode: nextExit } = await run(repo, "next", "--json")
584
+ expect(nextExit).toBe(0)
585
+ const next = JSON.parse(nextStdout) as { validate?: string }
586
+
587
+ const { stdout: validateStdout, exitCode: validateExit } = await run(repo, "validate")
588
+ expect(validateExit).toBe(0)
589
+
590
+ expect(next.validate).toBeDefined()
591
+ expect(next.validate).toBe(validateStdout.replace(/\n$/, ""))
592
+ })
593
+
594
+ it("still embeds a validate script when the declared file is absent — the existence check lives inside the script", async () => {
595
+ const repo = await atWorking()
596
+ const { stdout, exitCode } = await run(repo, "next", "--json")
597
+ expect(exitCode).toBe(0)
598
+ const parsed = JSON.parse(stdout) as Record<string, unknown>
599
+ expect(parsed).toHaveProperty("validate")
600
+ expect(parsed.validate).toContain(`[ -f '.gtd/PLAN.md' ] || exit 0`)
601
+ expect(parsed.validate).toContain(`export GTD_FILE='.gtd/PLAN.md'\ngtd check qa "$GTD_FILE"`)
602
+ })
603
+ })
604
+
605
+ describe("gtd validate — the mode-contradiction round-trip", () => {
606
+ const runEnv = async (
607
+ repo: InMemRepo,
608
+ env: Readonly<Record<string, string | undefined>>,
609
+ ...args: string[]
610
+ ): Promise<{ readonly stdout: string; readonly stderr: string; readonly exitCode: number }> => {
611
+ const { io, result } = makeCapturingCliIo(repo, env)
612
+ await Effect.runPromise(runCli(["node", "gtd.js", ...args], io))
613
+ return result()
614
+ }
615
+
616
+ const atWorking = async (modes: string, stateMode = "qa"): Promise<InMemRepo> => {
617
+ const repo = seed(
618
+ `${WITH_STEERING_FILE(stateMode)}\nexport const config = { modes: ${modes} }\n`,
619
+ )
620
+ await landTurn(repo, { "NOTE.md": "a note\n" })
621
+ return repo
622
+ }
623
+
624
+ it("a live built-in validator (qa, with a declared format:) emits the round-trip BEFORE the existence guard, using the scratch path under TMPDIR", async () => {
625
+ const repo = await atWorking(`{ qa: { format: "my-formatter $GTD_FILE" } }`)
626
+ const { stdout, exitCode } = await runEnv(repo, { TMPDIR: "/fixture-scratch" }, "validate")
627
+ expect(exitCode).toBe(0)
628
+
629
+ const samplePath = `/fixture-scratch/gtd-mode-sample-qa-${process.pid}.md`
630
+ const roundTripIndex = stdout.indexOf(`printf '%s' `)
631
+ const guardIndex = stdout.indexOf(`[ -f '.gtd/PLAN.md' ] || exit 0`)
632
+ // The real format command, distinct from the round-trip's own copy
633
+ // (rendered against the scratch sample path) earlier in the script.
634
+ const formatIndex = stdout.indexOf(`export GTD_FILE='.gtd/PLAN.md'\nmy-formatter $GTD_FILE`)
635
+ const validateIndex = stdout.indexOf(`gtd_validate_out=`)
636
+
637
+ expect(roundTripIndex).toBeGreaterThan(-1)
638
+ expect(guardIndex).toBeGreaterThan(-1)
639
+ expect(roundTripIndex).toBeLessThan(guardIndex)
640
+ expect(guardIndex).toBeLessThan(formatIndex)
641
+ expect(formatIndex).toBeLessThan(validateIndex)
642
+
643
+ expect(stdout).toContain(samplePath)
644
+ expect(stdout).toContain(`export GTD_FILE='${samplePath}'\nmy-formatter $GTD_FILE`)
645
+ expect(stdout).toContain(`gtd check qa '${samplePath}' >/dev/null 2>&1 || {`)
646
+ expect(stdout).toContain("CONFIGURATION BUG")
647
+ expect(stdout).toContain("Do NOT edit the steering file")
648
+ })
649
+
650
+ it("an external validate: command prints a one-line skip notice instead of the round-trip", async () => {
651
+ const repo = await atWorking(`{ qa: { format: "my-formatter $GTD_FILE", validate: "true" } }`)
652
+ const { stdout, exitCode } = await runEnv(repo, { TMPDIR: "/fixture-scratch" }, "validate")
653
+ expect(exitCode).toBe(0)
654
+ expect(stdout).toContain('mode "qa" has an external validate: command')
655
+ expect(stdout).toContain("skipping")
656
+ expect(stdout).not.toContain("printf '%s' ")
657
+ expect(stdout).not.toContain("CONFIGURATION BUG")
658
+ })
659
+
660
+ it("a format-only mode (no validate at all) emits neither the round-trip nor the skip notice — just the guard and the format command", async () => {
661
+ const repo = await atWorking(`{ prose: { format: "my-formatter $GTD_FILE" } }`, "prose")
662
+ const { stdout, exitCode } = await runEnv(repo, { TMPDIR: "/fixture-scratch" }, "validate")
663
+ expect(exitCode).toBe(0)
664
+ expect(stdout).toContain(`[ -f '.gtd/PLAN.md' ] || exit 0`)
665
+ expect(stdout).toContain("export GTD_FILE='.gtd/PLAN.md'\nmy-formatter $GTD_FILE")
666
+ expect(stdout).not.toContain("printf '%s' ")
667
+ expect(stdout).not.toContain("CONFIGURATION BUG")
668
+ expect(stdout).not.toContain("skipping")
669
+ })
670
+
671
+ it("no format: command at all emits neither", async () => {
672
+ const repo = await atWorking(`{}`)
673
+ const { stdout, exitCode } = await runEnv(repo, { TMPDIR: "/fixture-scratch" }, "validate")
674
+ expect(exitCode).toBe(0)
675
+ expect(stdout).toContain(`[ -f '.gtd/PLAN.md' ] || exit 0`)
676
+ expect(stdout).not.toContain("printf '%s' ")
677
+ expect(stdout).not.toContain("skipping")
678
+ })
679
+
680
+ it("resolves the scratch dir from node:os's tmpdir() when TMPDIR is unset or empty", async () => {
681
+ const repo = await atWorking(`{ qa: { format: "my-formatter $GTD_FILE" } }`)
682
+ const { stdout, exitCode } = await runEnv(repo, {}, "validate")
683
+ expect(exitCode).toBe(0)
684
+ expect(stdout).toContain(`gtd-mode-sample-qa-${process.pid}.md`)
685
+ })
686
+
687
+ it("gtd validate the COMMAND exits 0 even when the emitted SCRIPT would fail if run — gtd only prints it", async () => {
688
+ const repo = await atWorking(`{ qa: { format: "my-formatter $GTD_FILE" } }`)
689
+ const { exitCode } = await runEnv(repo, { TMPDIR: "/fixture-scratch" }, "validate")
690
+ expect(exitCode).toBe(0)
691
+ })
692
+ })
693
+
694
+ describe("gtd next — refuses when history diverges from what replay reaches", () => {
695
+ const atWorking = async (): Promise<InMemRepo> => {
696
+ const repo = seed(IDLE_THEN_WORKING)
697
+ await landTurn(repo, { "NOTE.md": "a note\n" })
698
+ return repo
699
+ }
700
+
701
+ /** A step commit recording a step the workflow never reaches there. */
702
+ const diverged = async (): Promise<InMemRepo> => {
703
+ const repo = await atWorking()
704
+ repo.writeFile("WORK.md", "done\n")
705
+ repo.commitAllWithPrefix("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1")
706
+ return repo
707
+ }
708
+
709
+ it("refuses, saying the workflow changed and pointing at `gtd abandon`", async () => {
710
+ const repo = await diverged()
711
+ const { exitCode, stderr } = await run(repo, "next")
712
+ expect(exitCode).toBe(1)
713
+ expect(stderr).toContain("the workflow changed under this process")
714
+ expect(stderr).toContain("gtd abandon")
715
+ })
716
+
717
+ it("a workflow edited under an in-flight process refuses the same way", async () => {
718
+ const repo = await atWorking()
719
+ repo.writeFile("gtd.config.ts", IDLE_THEN_WORKING.replace('"working"', '"building"'))
720
+ const { exitCode, stderr } = await run(repo, "next")
721
+ expect(exitCode).toBe(1)
722
+ expect(stderr).toContain("the workflow changed under this process")
723
+ })
724
+
725
+ it("a process whose history replays cleanly is unaffected", async () => {
726
+ const repo = await atWorking()
727
+ expect((await run(repo, "next")).exitCode).toBe(0)
728
+ })
729
+
730
+ it("`gtd abandon` still works on a diverged process — the escape hatch must not refuse alongside everything else", async () => {
731
+ const repo = await diverged()
732
+ const before = repo.commitHistory().length
733
+
734
+ const { stdout, exitCode } = await run(repo, "abandon")
735
+ expect(exitCode).toBe(0)
736
+ expect(stdout).toContain('abandoned the process resting at "%s"')
737
+ expect(stdout).toContain("'review'")
738
+ expect(repo.commitHistory()).toHaveLength(before)
739
+
740
+ expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
741
+ expect(repo.commitHistory()).toHaveLength(before - 2)
742
+ })
743
+ })
744
+
745
+ describe("a history written in a format this gtd does not read", () => {
746
+ // The exact wording is pinned in Format.test.ts; this is the part that proves the format gate fired.
747
+ const FOREIGN = "is written in history format 2, but this gtd reads format 1"
748
+
749
+ /** A step commit that would also diverge, so only the format gate can explain the refusal. */
750
+ const foreignStep = async (): Promise<InMemRepo> => {
751
+ const repo = seed(IDLE_THEN_WORKING)
752
+ await landTurn(repo, { "NOTE.md": "a note\n" })
753
+ repo.writeFile("WORK.md", "done\n")
754
+ repo.commitAllWithPrefix(
755
+ "gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2",
756
+ )
757
+ return repo
758
+ }
759
+
760
+ it("`gtd next` exits 1 naming both formats, never reporting divergence", async () => {
761
+ const { exitCode, stderr } = await run(await foreignStep(), "next")
762
+ expect(exitCode).toBe(1)
763
+ expect(stderr).toContain(FOREIGN)
764
+ expect(stderr).not.toContain("the workflow changed under this process")
765
+ })
766
+
767
+ it("`gtd abandon` and `gtd summary` refuse it too, rewinding nothing", async () => {
768
+ const repo = await foreignStep()
769
+ const before = repo.commitHistory().length
770
+ for (const command of ["abandon", "summary"]) {
771
+ const { exitCode, stderr } = await run(repo, command)
772
+ expect(exitCode).toBe(1)
773
+ expect(stderr).toContain(FOREIGN)
774
+ }
775
+ expect(repo.commitHistory()).toHaveLength(before)
776
+ })
777
+
778
+ it("refuses even when a foreign subject hides the commit from the episode", async () => {
779
+ const repo = seed(IDLE_THEN_WORKING)
780
+ repo.writeFile("NOTE.md", "a note\n")
781
+ repo.commitAllWithPrefix("gtd2[human] idle -> working\n\nGtd-Format: 2")
782
+ for (const command of ["next", "abandon"]) {
783
+ const { exitCode, stderr } = await run(repo, command)
784
+ expect(exitCode).toBe(1)
785
+ expect(stderr).toContain(FOREIGN)
786
+ }
787
+ })
788
+
789
+ it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
790
+ const repo = seed(IDLE_THEN_WORKING)
791
+ repo.writeFile("NOTE.md", "a note\n")
792
+ repo.commitAllWithPrefix("feat: x\n\nGtd-Format: 2")
793
+ expect((await run(repo, "next")).exitCode).toBe(0)
794
+ })
795
+
796
+ it("`gtd restore` refuses a retained tip in a foreign format", async () => {
797
+ const repo = seed(IDLE_THEN_WORKING)
798
+ const base = repo.resolveRef("HEAD")!
799
+ repo.writeFile("NOTE.md", "a note\n")
800
+ repo.commitAllWithPrefix("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
801
+ repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
802
+ repo.hardResetTo(base)
803
+ const { exitCode, stderr } = await run(repo, "restore")
804
+ expect(exitCode).toBe(1)
805
+ expect(stderr).toContain(FOREIGN)
806
+ })
807
+ })
808
+
809
+ describe("outcome scripts — step no-op / abandon no-op / restore", () => {
810
+ it("a clean-tree step is a no-op whose required script is print-only, naming the resting step", async () => {
811
+ const repo = seed(IDLE_THEN_WORKING)
812
+ const before = repo.commitHistory().length
813
+
814
+ const { stdout, exitCode } = await run(repo, "land", "--json")
815
+ expect(exitCode).toBe(0)
816
+ const script = (JSON.parse(stdout) as { readonly script: string }).script
817
+ expect(script).toContain(noteOutcome(noopText("idle")))
818
+
819
+ expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
820
+ expect(repo.commitHistory()).toHaveLength(before)
821
+ })
822
+
823
+ it("plain gtd land at the same clean-tree no-op prints the prose no-op line, not the script", async () => {
824
+ const repo = seed(IDLE_THEN_WORKING)
825
+ const { stdout, exitCode } = await run(repo, "land")
826
+ expect(exitCode).toBe(0)
827
+ expect(stdout).toBe(noopText("idle"))
828
+ })
829
+
830
+ it("gtd abandon with nothing underway emits a print-only required script carrying the same wording", async () => {
831
+ const repo = seed(IDLE_THEN_WORKING)
832
+ const before = repo.commitHistory().length
833
+
834
+ const { stdout, exitCode } = await run(repo, "abandon")
835
+ expect(exitCode).toBe(0)
836
+ expect(stdout).toContain(abandonNoopOutcome("idle"))
837
+
838
+ expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
839
+ expect(repo.commitHistory()).toHaveLength(before)
840
+ })
841
+
842
+ it("gtd restore's script resolves the post-hoc short hash/subject in-script", async () => {
843
+ const repo = seed(IDLE_THEN_WORKING)
844
+ await landTurn(repo, { "NOTE.md": "a note\n" })
845
+ const tip = repo.resolveRef("HEAD")!
846
+ repo.updateRef(HISTORY_REF, tip)
847
+ // HEAD an ancestor of the retained tip, as after an abandon.
848
+ repo.hardResetTo(repo.resolveRef("HEAD~1")!)
849
+
850
+ const { stdout, exitCode } = await run(repo, "restore")
851
+ expect(exitCode).toBe(0)
852
+ expect(stdout).toContain(restoredOutcome(tip, "working"))
853
+
854
+ expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
855
+ expect(repo.resolveRef("HEAD")).toBe(tip)
856
+ })
857
+ })
858
+
859
+ describe("gtd check <mode> <file>", () => {
860
+ // Fully standalone (needsOf("check") === "none") — no config, no commit, no
861
+ // git state at all is required; the file just needs to exist in the
862
+ // in-memory worktree. Sample valid/invalid content mirrors
863
+ // src/steering/index.test.ts's own qa/review fixtures.
864
+
865
+ const validQaDoc = [
866
+ "# Plan",
867
+ "",
868
+ "## Open Questions",
869
+ "",
870
+ "### Which operations?",
871
+ "",
872
+ "add and subtract.",
873
+ "",
874
+ "## Answered Questions",
875
+ "",
876
+ "### What is the target platform?",
877
+ "",
878
+ "web only.",
879
+ "",
880
+ ].join("\n")
881
+
882
+ const invalidQaDoc = ["## Open Questions", "", "###", "", "no question text.", ""].join("\n")
883
+
884
+ const validReviewDoc = [
885
+ "# Review: abc1234",
886
+ "<!-- base: abc1234def5678901234567890123456789abcd -->",
887
+ "",
888
+ "## Add calculator",
889
+ "",
890
+ "- [x] ./src/calc.ts#1-1",
891
+ "",
892
+ ].join("\n")
893
+
894
+ const invalidReviewDoc = "Just some text\n"
895
+
896
+ const bareRepo = (): InMemRepo => new InMemRepo()
897
+
898
+ it("exits 0 with no output for valid qa content", async () => {
899
+ const repo = bareRepo()
900
+ repo.writeFile(".gtd/TODO.md", validQaDoc)
901
+ const { stdout, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md")
902
+ expect(exitCode).toBe(0)
903
+ expect(stdout).toBe("")
904
+ })
905
+
906
+ it("exits 0 with no output for valid review content", async () => {
907
+ const repo = bareRepo()
908
+ repo.writeFile("REVIEW.md", validReviewDoc)
909
+ const { stdout, exitCode } = await run(repo, "check", "review", "REVIEW.md")
910
+ expect(exitCode).toBe(0)
911
+ expect(stdout).toBe("")
912
+ })
913
+
914
+ it("invalid qa content prints each finding one per line on stderr, leaves stdout empty, and exits non-zero", async () => {
915
+ const repo = bareRepo()
916
+ repo.writeFile(".gtd/TODO.md", invalidQaDoc)
917
+ const { stdout, stderr, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md")
918
+ expect(exitCode).toBe(1)
919
+ expect(stdout).toBe("")
920
+ expect(stderr).toContain(
921
+ "An '### ' question heading under '## Open Questions' or '## Answered Questions' has no question text",
922
+ )
923
+ })
924
+
925
+ it("invalid review content prints each finding one per line on stderr, leaves stdout empty, and exits non-zero", async () => {
926
+ const repo = bareRepo()
927
+ repo.writeFile("REVIEW.md", invalidReviewDoc)
928
+ const { stdout, stderr, exitCode } = await run(repo, "check", "review", "REVIEW.md")
929
+ expect(exitCode).toBe(1)
930
+ expect(stdout).toBe("")
931
+ expect(stderr).toContain(
932
+ "Missing or malformed '# Review: <hash>' header as the document's first line",
933
+ )
934
+ expect(stderr).toContain("Missing '<!-- base: <hash> -->' comment")
935
+ expect(stderr).toContain("REVIEW.md has no '##' chunks")
936
+ })
937
+
938
+ it("a positioned finding prints '<file>:<line>:<col>: <message>', 1-based, on stderr", async () => {
939
+ const repo = bareRepo()
940
+ const secondPointerDoc = [
941
+ "# Review: abc1234",
942
+ "<!-- base: abc1234def5678901234567890123456789abcd -->",
943
+ "",
944
+ "## Add calculator",
945
+ "",
946
+ "- [ ] ./src/calc.ts#1 — ./src/other.ts#2",
947
+ "",
948
+ ].join("\n")
949
+ repo.writeFile("REVIEW.md", secondPointerDoc)
950
+ const { stdout, stderr, exitCode } = await run(repo, "check", "review", "REVIEW.md")
951
+ expect(exitCode).toBe(1)
952
+ expect(stdout).toBe("")
953
+ // The offending pointer is the 0-based 6th line (index 5) — printed
954
+ // 1-based as 6 — and the second pointer token itself starts at the
955
+ // 0-based column 24, printed 1-based as 25.
956
+ expect(stderr).toContain(
957
+ 'REVIEW.md:6:25: Chunk "Add calculator" hunk ./src/calc.ts#1\'s note starts with a second pointer (./src/other.ts#2) — give it its own "- [ ]" line',
958
+ )
959
+ })
960
+
961
+ it("a line-carrying finding with no range prints '<file>:<line>: <message>' — the flat-shape fallback no built-in format reaches", async () => {
962
+ expect(formatFinding("FAKE.md", { message: "positioned but rangeless", line: 4 })).toBe(
963
+ "FAKE.md:5: positioned but rangeless",
964
+ )
965
+ })
966
+
967
+ it("an absent file exits 0 with no output", async () => {
968
+ const repo = bareRepo()
969
+ const { stdout, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md")
970
+ expect(exitCode).toBe(0)
971
+ expect(stdout).toBe("")
972
+ })
973
+
974
+ it("an unknown mode is a usage-style error naming the known modes", async () => {
975
+ const repo = bareRepo()
976
+ repo.writeFile("TODO.md", validQaDoc)
977
+ const { stderr, exitCode } = await run(repo, "check", "bogus", "TODO.md")
978
+ expect(exitCode).toBe(1)
979
+ expect(stderr).toContain('unknown mode "bogus"')
980
+ expect(stderr).toContain("qa")
981
+ expect(stderr).toContain("review")
982
+ })
983
+
984
+ it("--json is out of scope for check — a usage error, never a JSON envelope (next/land are the only structured surfaces)", async () => {
985
+ const repo = bareRepo()
986
+ repo.writeFile(".gtd/TODO.md", validQaDoc)
987
+ const { stdout, stderr, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md", "--json")
988
+ expect(exitCode).toBe(2)
989
+ expect(stdout).toBe("")
990
+ expect(stderr).toContain("only valid for `gtd next`")
991
+ })
992
+
993
+ // Regression pin: `ModeContradiction.ts`'s round-trip renders `gtd check
994
+ // <mode> <path>` against an ABSOLUTE scratch path under `Host.scratchDir`
995
+ // (never repo-relative) — `Workspace`'s repo-relative `read` rejects an
996
+ // absolute argument outright, so this command must read through `atPath`
997
+ // instead, not `read`.
998
+ it("reads an ABSOLUTE file path too — atPath, not the repo-relative read", async () => {
999
+ const repo = bareRepo()
1000
+ repo.writeFile("outside.md", validQaDoc)
1001
+ const { stdout, exitCode } = await run(repo, "check", "qa", "/repo/outside.md")
1002
+ expect(exitCode).toBe(0)
1003
+ expect(stdout).toBe("")
1004
+ })
1005
+ })
1006
+
1007
+ describe("gtd uncheck <file>", () => {
1008
+ const bareRepo = (): InMemRepo => new InMemRepo()
1009
+
1010
+ it("rewrites [x]/[X] pointer boxes to [ ], exit 0, no output", async () => {
1011
+ const repo = bareRepo()
1012
+ repo.writeFile(
1013
+ "REVIEW.md",
1014
+ ["# Review: abc1234", "", "## Chunk", "", "- [x] ./src/calc.ts#1", ""].join("\n"),
1015
+ )
1016
+ const { stdout, stderr, exitCode } = await run(repo, "uncheck", "REVIEW.md")
1017
+ expect(exitCode).toBe(0)
1018
+ expect(stdout).toBe("")
1019
+ expect(stderr).toBe("")
1020
+ expect(repo.readFile("REVIEW.md")).toBe(
1021
+ ["# Review: abc1234", "", "## Chunk", "", "- [ ] ./src/calc.ts#1", ""].join("\n"),
1022
+ )
1023
+ })
1024
+
1025
+ it("does not write back when the bytes are unchanged (no ticks to clear)", async () => {
1026
+ const repo = bareRepo()
1027
+ const content = ["# Review: abc1234", "", "## Chunk", "", "- [ ] ./src/calc.ts#1", ""].join(
1028
+ "\n",
1029
+ )
1030
+ repo.writeFile("REVIEW.md", content)
1031
+ const writeSpy = vi.spyOn(repo, "writeFile")
1032
+ const { exitCode } = await run(repo, "uncheck", "REVIEW.md")
1033
+ expect(exitCode).toBe(0)
1034
+ expect(writeSpy).not.toHaveBeenCalled()
1035
+ writeSpy.mockRestore()
1036
+ })
1037
+
1038
+ it("a missing file writes nothing and exits 0", async () => {
1039
+ const repo = bareRepo()
1040
+ const { stdout, exitCode } = await run(repo, "uncheck", "REVIEW.md")
1041
+ expect(exitCode).toBe(0)
1042
+ expect(stdout).toBe("")
1043
+ expect(repo.hasPath("REVIEW.md")).toBe(false)
1044
+ })
1045
+
1046
+ it("bad arity (no file argument) exits 2", async () => {
1047
+ const repo = bareRepo()
1048
+ const { exitCode, stderr } = await run(repo, "uncheck")
1049
+ expect(exitCode).toBe(2)
1050
+ expect(stderr).toContain("missing file argument")
1051
+ })
1052
+
1053
+ // Regression pin: `gtd uncheck` shares its file-reading shape with `gtd
1054
+ // check`, both of which take an arbitrary CLI-given `<file>` — not
1055
+ // necessarily repo-relative (see the sibling pin below, at `gtd check`'s
1056
+ // own describe block, for the case this actually matters in production:
1057
+ // `ModeContradiction.ts`'s round-trip invokes `gtd check qa <absolute
1058
+ // scratch path>`). `Workspace`'s repo-relative `read`/`write` reject an
1059
+ // absolute path outright — only `atPath`/`writeAtPath` may see one.
1060
+ it("rewrites ticks at an ABSOLUTE file path too — atPath/writeAtPath, not the repo-relative read/write", async () => {
1061
+ const repo = bareRepo()
1062
+ // Seeded/read back by the STRIPPED key: `atPath`'s in-memory adapter
1063
+ // (`testing/Layers.ts`) maps an absolute path rooted under `/repo` (the
1064
+ // default fake root) to its relative key, same as the live adapter maps
1065
+ // it to `join(root, …)` — the CLI argument is absolute, the storage key
1066
+ // isn't.
1067
+ repo.writeFile(
1068
+ "REVIEW.md",
1069
+ ["# Review: abc1234", "", "## Chunk", "", "- [x] ./src/calc.ts#1", ""].join("\n"),
1070
+ )
1071
+ const { stdout, stderr, exitCode } = await run(repo, "uncheck", "/repo/REVIEW.md")
1072
+ expect(exitCode).toBe(0)
1073
+ expect(stdout).toBe("")
1074
+ expect(stderr).toBe("")
1075
+ expect(repo.readFile("REVIEW.md")).toBe(
1076
+ ["# Review: abc1234", "", "## Chunk", "", "- [ ] ./src/calc.ts#1", ""].join("\n"),
1077
+ )
1078
+ })
1079
+ })
1080
+
1081
+ describe("gtd check <mode> <file> --open-questions", () => {
1082
+ // Shares the exact `unansweredQuestions` predicate a flow's `openQuestions()`
1083
+ // reads — this is the leaf command a workflow's own gate script calls to
1084
+ // answer the same question ahead of time.
1085
+
1086
+ const docWithUnanswered = [
1087
+ "# Plan",
1088
+ "",
1089
+ "## Open Questions",
1090
+ "",
1091
+ "### Which operations?",
1092
+ "",
1093
+ "- [ ] add and subtract",
1094
+ "- [ ] _your answer_",
1095
+ "",
1096
+ "### What is the target platform?",
1097
+ "",
1098
+ "- [x] web only",
1099
+ "- [ ] _your answer_",
1100
+ "",
1101
+ ].join("\n")
1102
+
1103
+ const docFullyAnswered = [
1104
+ "# Plan",
1105
+ "",
1106
+ "## Open Questions",
1107
+ "",
1108
+ "### What is the target platform?",
1109
+ "",
1110
+ "- [x] web only",
1111
+ "- [ ] _your answer_",
1112
+ "",
1113
+ ].join("\n")
1114
+
1115
+ const docNoOpenQuestionsSection = [
1116
+ "# Plan",
1117
+ "",
1118
+ "## Answered Questions",
1119
+ "",
1120
+ "### What is the target platform?",
1121
+ "",
1122
+ "web only.",
1123
+ "",
1124
+ ].join("\n")
1125
+
1126
+ const docNeitherSection = ["# Plan", "", "Just some prose, no questions at all.", ""].join("\n")
1127
+
1128
+ const docFreeTextAnswered = [
1129
+ "# Plan",
1130
+ "",
1131
+ "## Open Questions",
1132
+ "",
1133
+ "### Which operations?",
1134
+ "",
1135
+ "- [ ] add and subtract",
1136
+ "- [x] multiply only",
1137
+ "",
1138
+ ].join("\n")
1139
+
1140
+ const bareRepo = (): InMemRepo => new InMemRepo()
1141
+
1142
+ it("lists each unanswered question on stderr, leaves stdout empty, and exits non-zero", async () => {
1143
+ const repo = bareRepo()
1144
+ repo.writeFile(".gtd/TODO.md", docWithUnanswered)
1145
+ const { stdout, stderr, exitCode } = await run(
1146
+ repo,
1147
+ "check",
1148
+ "qa",
1149
+ "--open-questions",
1150
+ ".gtd/TODO.md",
1151
+ )
1152
+ expect(exitCode).not.toBe(0)
1153
+ expect(stdout).toBe("")
1154
+ expect(stderr).toContain(".gtd/TODO.md:5: Which operations?")
1155
+ })
1156
+
1157
+ it("exits 0 with no output when every open question has a ticked box", async () => {
1158
+ const repo = bareRepo()
1159
+ repo.writeFile(".gtd/TODO.md", docFullyAnswered)
1160
+ const { stdout, exitCode } = await run(repo, "check", "qa", "--open-questions", ".gtd/TODO.md")
1161
+ expect(exitCode).toBe(0)
1162
+ expect(stdout).toBe("")
1163
+ })
1164
+
1165
+ it("exits 0 with no output when the '## Open Questions' section is absent (an '## Answered Questions' section only)", async () => {
1166
+ const repo = bareRepo()
1167
+ repo.writeFile(".gtd/TODO.md", docNoOpenQuestionsSection)
1168
+ const { stdout, exitCode } = await run(repo, "check", "qa", "--open-questions", ".gtd/TODO.md")
1169
+ expect(exitCode).toBe(0)
1170
+ expect(stdout).toBe("")
1171
+ })
1172
+
1173
+ it("exits 0 with no output for a document with neither section at all", async () => {
1174
+ const repo = bareRepo()
1175
+ repo.writeFile(".gtd/TODO.md", docNeitherSection)
1176
+ const { stdout, exitCode } = await run(repo, "check", "qa", "--open-questions", ".gtd/TODO.md")
1177
+ expect(exitCode).toBe(0)
1178
+ expect(stdout).toBe("")
1179
+ })
1180
+
1181
+ it("a question answered via the free-text slot with the human's own text counts as answered", async () => {
1182
+ const repo = bareRepo()
1183
+ repo.writeFile(".gtd/TODO.md", docFreeTextAnswered)
1184
+ const { stdout, exitCode } = await run(repo, "check", "qa", "--open-questions", ".gtd/TODO.md")
1185
+ expect(exitCode).toBe(0)
1186
+ expect(stdout).toBe("")
1187
+ })
1188
+
1189
+ it("a missing file exits non-zero with a message, not a silent zero", async () => {
1190
+ const repo = bareRepo()
1191
+ const { stderr, exitCode } = await run(repo, "check", "qa", "--open-questions", ".gtd/TODO.md")
1192
+ expect(exitCode).not.toBe(0)
1193
+ expect(stderr).toContain(".gtd/TODO.md")
1194
+ })
1195
+
1196
+ it("an existing-but-unreadable path (a directory at that name) exits non-zero with a message", async () => {
1197
+ // The in-memory fake has no real file descriptors/permissions, so
1198
+ // "unreadable" is simulated the one way it can be: a path that EXISTS
1199
+ // (`hasPath` sees the nested file) but has no content of its own
1200
+ // (`readFile` returns undefined for a directory), exactly like `gtd
1201
+ // check`'s existing `steeringFormatFor` path already treats a directory —
1202
+ // `readFileString` fails with ENOENT rather than returning a string.
1203
+ const repo = bareRepo()
1204
+ repo.writeFile(".gtd/TODO.md/nested.txt", "not actually the file")
1205
+ const { stderr, exitCode } = await run(repo, "check", "qa", "--open-questions", ".gtd/TODO.md")
1206
+ expect(exitCode).not.toBe(0)
1207
+ expect(stderr).toContain(".gtd/TODO.md")
1208
+ })
1209
+
1210
+ it("check without the flag is unchanged: an absent file still exits 0 with no output", async () => {
1211
+ const repo = bareRepo()
1212
+ const { stdout, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md")
1213
+ expect(exitCode).toBe(0)
1214
+ expect(stdout).toBe("")
1215
+ })
1216
+
1217
+ it("--json is out of scope for check --open-questions too — a usage error, never a JSON envelope", async () => {
1218
+ const repo = bareRepo()
1219
+ repo.writeFile(".gtd/TODO.md", docWithUnanswered)
1220
+ const { stdout, stderr, exitCode } = await run(
1221
+ repo,
1222
+ "check",
1223
+ "qa",
1224
+ "--open-questions",
1225
+ ".gtd/TODO.md",
1226
+ "--json",
1227
+ )
1228
+ expect(exitCode).toBe(2)
1229
+ expect(stdout).toBe("")
1230
+ expect(stderr).toContain("only valid for `gtd next`")
1231
+ })
1232
+
1233
+ it("an unknown mode still fails with the usual unknown-mode usage error, even with --open-questions", async () => {
1234
+ const repo = bareRepo()
1235
+ repo.writeFile(".gtd/TODO.md", docFullyAnswered)
1236
+ const { stderr, exitCode } = await run(
1237
+ repo,
1238
+ "check",
1239
+ "bogus",
1240
+ "--open-questions",
1241
+ ".gtd/TODO.md",
1242
+ )
1243
+ expect(exitCode).not.toBe(0)
1244
+ expect(stderr).toContain('unknown mode "bogus"')
1245
+ expect(stderr).toContain("qa")
1246
+ expect(stderr).toContain("review")
1247
+ })
1248
+
1249
+ it("a known but non-qa mode (e.g. review) refuses --open-questions instead of silently running the qa predicate", async () => {
1250
+ const repo = bareRepo()
1251
+ repo.writeFile("REVIEW.md", docWithUnanswered)
1252
+ const { stderr, exitCode } = await run(repo, "check", "review", "--open-questions", "REVIEW.md")
1253
+ expect(exitCode).not.toBe(0)
1254
+ expect(stderr).toContain("--open-questions")
1255
+ expect(stderr).toContain('"review"')
1256
+ })
1257
+ })
1258
+
1259
+ describe("gtd check <mode> <file> --open-threads", () => {
1260
+ const open = [
1261
+ "We cache.[^cache]",
1262
+ "",
1263
+ "[^cache]:",
1264
+ " - H: why this way?",
1265
+ " - A: because",
1266
+ "",
1267
+ ].join("\n")
1268
+ const bareRepo = (): InMemRepo => new InMemRepo()
1269
+ const waitingOnAgent = open.replace(" - A: because\n", "")
1270
+
1271
+ it("exits non-zero listing file:line: [^name]: first H entry per open thread", async () => {
1272
+ const repo = bareRepo()
1273
+ repo.writeFile("NOTES.md", open)
1274
+ const { stderr, exitCode } = await run(repo, "check", "qa", "--open-threads", "NOTES.md")
1275
+ expect(exitCode).not.toBe(0)
1276
+ expect(stderr).toContain("NOTES.md:3: [^cache]: why this way?")
1277
+ })
1278
+
1279
+ it("exits 0 when every thread waits on the agent", async () => {
1280
+ const repo = bareRepo()
1281
+ repo.writeFile("NOTES.md", waitingOnAgent)
1282
+ const { exitCode } = await run(repo, "check", "review", "--open-threads", "NOTES.md")
1283
+ expect(exitCode).toBe(0)
1284
+ })
1285
+
1286
+ it("exits 0 when there is no thread", async () => {
1287
+ const repo = bareRepo()
1288
+ repo.writeFile("NOTES.md", "plain\n")
1289
+ const { exitCode } = await run(repo, "check", "qa", "--open-threads", "NOTES.md")
1290
+ expect(exitCode).toBe(0)
1291
+ })
1292
+
1293
+ it("fails on a missing file", async () => {
1294
+ const repo = bareRepo()
1295
+ const { stderr, exitCode } = await run(repo, "check", "qa", "--open-threads", "MISSING.md")
1296
+ expect(exitCode).not.toBe(0)
1297
+ expect(stderr).toContain("MISSING.md")
1298
+ })
1299
+ })
1300
+
1301
+ describe("gtd check --open-threads (no mode, no file) — code threads", () => {
1302
+ const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
1303
+
1304
+ export default async () => {
1305
+ await human("idle", { message: "hi" })
1306
+ await agent("working", "go")
1307
+ }
1308
+ `
1309
+ const withThread = ["const a = 1", "// H: why this?", "// A: because", "const b = 2", ""].join(
1310
+ "\n",
1311
+ )
1312
+
1313
+ it("no process underway: prints nothing, exits 0", async () => {
1314
+ const repo = seed(WORKFLOW)
1315
+ repo.writeFile("src/a.ts", withThread)
1316
+ const { stderr, exitCode } = await run(repo, "check", "--open-threads")
1317
+ expect(exitCode).toBe(0)
1318
+ expect(stderr).toBe("")
1319
+ })
1320
+
1321
+ it("process underway: lists open threads (committed and untracked) as path:line: first entry", async () => {
1322
+ const repo = seed(WORKFLOW)
1323
+ await landTurn(repo, { "src/a.ts": withThread })
1324
+ repo.writeFile("src/b.ts", ["x", "// H: and this?", "// A: so"].join("\n"))
1325
+ repo.writeFile("src/asked.ts", "// H: still waiting on the agent")
1326
+ const { stderr, exitCode } = await run(repo, "check", "--open-threads")
1327
+ expect(stderr).toContain("src/a.ts:2: why this?")
1328
+ expect(stderr).toContain("src/b.ts:2: and this?")
1329
+ expect(stderr).not.toContain("asked.ts")
1330
+ expect(exitCode).not.toBe(0)
1331
+ })
1332
+ })
1333
+
1334
+ describe("gtd next — Next: preview of where landing the pending turn would go", () => {
1335
+ // A human rest past the initial step: plain `gtd next` prints no header at a
1336
+ // `prompt` rest, so the plain `Next:` line needs a non-prompt one.
1337
+ const WORKFLOW = `import { changes, human, refuse } from "@pmelab/gtd/flows"
1338
+
1339
+ export default async () => {
1340
+ await human("idle", { message: "write NOTE.md to start a process" })
1341
+ await human("working", { message: "do the work described in NOTE.md" })
1342
+ if (changes("PLAN.md").some((c) => c.status === "added")) {
1343
+ await human("accepted", { message: "plan accepted" })
1344
+ } else if (!changes("REVIEW.md").some((c) => c.status === "modified")) {
1345
+ refuse("expected a new PLAN.md or an edited REVIEW.md")
1346
+ }
1347
+ }
1348
+ `
1349
+
1350
+ const atWorking = async (): Promise<InMemRepo> => {
1351
+ const repo = seed(WORKFLOW, { "REVIEW.md": "old review\n" })
1352
+ await landTurn(repo, { "NOTE.md": "a note\n" })
1353
+ return repo
1354
+ }
1355
+
1356
+ const nextOf = async (repo: InMemRepo): Promise<unknown> => {
1357
+ const { stdout, exitCode } = await run(repo, "next", "--json")
1358
+ expect(exitCode).toBe(0)
1359
+ const parsed = JSON.parse(stdout) as Record<string, unknown>
1360
+ expect(parsed).toHaveProperty("next")
1361
+ return parsed.next
1362
+ }
1363
+
1364
+ it("--json's `next` names the step replay reaches with the pending turn", async () => {
1365
+ const repo = await atWorking()
1366
+ repo.writeFile("PLAN.md", "the plan\n")
1367
+ expect(await nextOf(repo)).toEqual({ target: "accepted" })
1368
+ })
1369
+
1370
+ it("a turn that finishes the flow targets the initial step", async () => {
1371
+ const repo = await atWorking()
1372
+ repo.writeFile("REVIEW.md", "an updated review\n")
1373
+ expect(await nextOf(repo)).toMatchObject({ target: "idle" })
1374
+ })
1375
+
1376
+ it("plain gtd next shows the same preview as a `Next:` line", async () => {
1377
+ const repo = await atWorking()
1378
+ repo.writeFile("PLAN.md", "the plan\n")
1379
+ const { stdout, exitCode } = await run(repo, "next")
1380
+ expect(exitCode).toBe(0)
1381
+ expect(stdout).toContain("Next: → accepted")
1382
+ })
1383
+
1384
+ it("a pending turn the flow would refuse previews as present-but-null, and plain says nothing would land", async () => {
1385
+ const repo = await atWorking()
1386
+ repo.writeFile("OTHER.md", "unrelated\n")
1387
+ expect(await nextOf(repo)).toBeNull()
1388
+ expect((await run(repo, "next")).stdout).toContain("Next: (nothing would land)")
1389
+ })
1390
+
1391
+ it("a clean tree previews nothing", async () => {
1392
+ const repo = await atWorking()
1393
+ expect(await nextOf(repo)).toBeNull()
1394
+ })
1395
+ })
1396
+
1397
+ describe("gtd land — the settled signal (exit code, script content, and the --json settled field)", () => {
1398
+ // `checking` re-runs until a run leaves OUT.txt behind: a clean run there
1399
+ // replays to the same step, which is the settled no-op.
1400
+ const SETTLED_WORKFLOW = `import { agent, changes, human, run } from "@pmelab/gtd/flows"
1401
+
1402
+ export default async () => {
1403
+ await human("idle", { message: "hi" })
1404
+ await agent("working", "go")
1405
+ do {
1406
+ await run("checking", "run-checks")
1407
+ } while (!changes("OUT.txt").some((c) => c.status === "added"))
1408
+ }
1409
+ `
1410
+
1411
+ // `checking` runs once, so a clean run finishes the flow.
1412
+ const REENTRY_WORKFLOW = `import { agent, human, run } from "@pmelab/gtd/flows"
1413
+
1414
+ export default async () => {
1415
+ await human("idle", { message: "hi" })
1416
+ await agent("working", "go")
1417
+ await run("checking", "run-checks")
1418
+ }
1419
+ `
1420
+
1421
+ const atWorking = async (source: string): Promise<InMemRepo> => {
1422
+ const repo = seed(source)
1423
+ await landTurn(repo, { "NOTE.md": "a note\n" })
1424
+ return repo
1425
+ }
1426
+
1427
+ const atChecking = async (source = SETTLED_WORKFLOW): Promise<InMemRepo> => {
1428
+ const repo = await atWorking(source)
1429
+ await landTurn(repo, { "WORK.md": "done\n" })
1430
+ return repo
1431
+ }
1432
+
1433
+ it("a clean tree at the script rest is settled, with a print-only required script", async () => {
1434
+ const repo = await atChecking()
1435
+ const { stdout, exitCode } = await run(repo, "land")
1436
+ expect(exitCode).toBe(0)
1437
+ expect(stdout).toContain("nothing to do")
1438
+ expect(stdout).not.toContain("git commit")
1439
+ })
1440
+
1441
+ it("a clean tree at a prompt rest is not settled — that's a stall, not a terminal state", async () => {
1442
+ const repo = await atWorking(SETTLED_WORKFLOW)
1443
+ const { exitCode, stdout } = await run(repo, "land", "--json")
1444
+ expect(exitCode).toBe(0)
1445
+ expect((JSON.parse(stdout) as { readonly settled: boolean }).settled).toBe(false)
1446
+ })
1447
+
1448
+ it("a dirty tree at the script rest is not settled — it's the no-op that settles, not the step", async () => {
1449
+ const repo = await atChecking()
1450
+ repo.writeFile("OUT.txt", "all green\n")
1451
+ const { stdout, exitCode } = await run(repo, "land", "--json")
1452
+ expect(exitCode).toBe(0)
1453
+ expect((JSON.parse(stdout) as { readonly script: string }).script).toContain("git commit")
1454
+ })
1455
+
1456
+ it("a clean run that finishes the flow lands an ordinary commit re-entering the initial step", async () => {
1457
+ const repo = await atChecking(REENTRY_WORKFLOW)
1458
+ const { stdout, exitCode } = await run(repo, "land", "--json")
1459
+ expect(exitCode).toBe(0)
1460
+ const script = (JSON.parse(stdout) as { readonly script: string }).script
1461
+ expect(script).toContain("git commit")
1462
+ expect(script).not.toContain("git reset --mixed")
1463
+ expect(script).not.toContain("nothing to retain")
1464
+ expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
1465
+ expect(repo.lastCommitSubject()).toBe("gtd(check): checking → idle")
1466
+ })
1467
+
1468
+ it("the same rest with a pending change lands an ordinary commit too", async () => {
1469
+ const repo = await atChecking(REENTRY_WORKFLOW)
1470
+ repo.writeFile("OUT.txt", "all green\n")
1471
+ const { stdout, exitCode } = await run(repo, "land", "--json")
1472
+ expect(exitCode).toBe(0)
1473
+ const script = (JSON.parse(stdout) as { readonly script: string }).script
1474
+ expect(script).toContain("git commit")
1475
+ expect(script).not.toContain("nothing to retain")
1476
+ })
1477
+
1478
+ it("plain gtd land prints the prose sentence and points at --json=script, never the script itself", async () => {
1479
+ const repo = await atChecking(REENTRY_WORKFLOW)
1480
+ const { stdout, exitCode } = await run(repo, "land")
1481
+ expect(exitCode).toBe(0)
1482
+ expect(stdout).not.toContain("git commit")
1483
+ expect(stdout).toMatch(/^commit everything with this message: /)
1484
+ expect(stdout).toContain("--json=script")
1485
+ })
1486
+
1487
+ it("gtd land --json a no-op at a script rest reports settled:true, idle:false", async () => {
1488
+ const repo = await atChecking()
1489
+ const { stdout, exitCode } = await run(repo, "land", "--json")
1490
+ expect(exitCode).toBe(0)
1491
+ const parsed = JSON.parse(stdout) as {
1492
+ readonly settled: boolean
1493
+ readonly idle: boolean
1494
+ readonly state: string
1495
+ readonly script: string
1496
+ }
1497
+ expect(parsed.settled).toBe(true)
1498
+ expect(parsed.idle).toBe(false)
1499
+ expect(parsed.state).toBe("checking")
1500
+ expect(parsed.script).toContain("nothing to do")
1501
+ })
1502
+
1503
+ it("gtd land --json a decision that re-enters the initial step reports settled:false, idle:true, state:idle", async () => {
1504
+ const repo = await atChecking(REENTRY_WORKFLOW)
1505
+ const { stdout, exitCode } = await run(repo, "land", "--json")
1506
+ expect(exitCode).toBe(0)
1507
+ const parsed = JSON.parse(stdout) as {
1508
+ readonly settled: boolean
1509
+ readonly idle: boolean
1510
+ readonly state: string
1511
+ }
1512
+ expect(parsed.settled).toBe(false)
1513
+ expect(parsed.idle).toBe(true)
1514
+ expect(parsed.state).toBe("idle")
1515
+ })
1516
+
1517
+ it("gtd land --json an ordinary commit reports settled:false", async () => {
1518
+ const repo = await atWorking(SETTLED_WORKFLOW)
1519
+ repo.writeFile("OUT.txt", "all green\n")
1520
+ const { stdout } = await run(repo, "land", "--json")
1521
+ const parsed = JSON.parse(stdout) as { readonly settled: boolean; readonly idle: boolean }
1522
+ expect(parsed.settled).toBe(false)
1523
+ expect(parsed.idle).toBe(false)
1524
+ })
1525
+
1526
+ it("gtd land --json --cost=<n> --model=<name> carries both verbatim, and a genuine no-op reports both null", async () => {
1527
+ const withCost = await atWorking(SETTLED_WORKFLOW)
1528
+ withCost.writeFile("OUT.txt", "all green\n")
1529
+ const { stdout: withCostStdout } = await run(
1530
+ withCost,
1531
+ "land",
1532
+ "--json",
1533
+ "--cost=0.5",
1534
+ "--model=opus",
1535
+ )
1536
+ const parsedWithCost = JSON.parse(withCostStdout) as {
1537
+ readonly cost: number | null
1538
+ readonly model: string | null
1539
+ }
1540
+ expect(parsedWithCost.cost).toBe(0.5)
1541
+ expect(parsedWithCost.model).toBe("opus")
1542
+
1543
+ const noop = await atChecking()
1544
+ const { stdout: noopStdout } = await run(noop, "land", "--json")
1545
+ const parsedNoop = JSON.parse(noopStdout) as {
1546
+ readonly cost: number | null
1547
+ readonly model: string | null
1548
+ readonly subject: string | null
1549
+ }
1550
+ expect(parsedNoop.cost).toBeNull()
1551
+ expect(parsedNoop.model).toBeNull()
1552
+ expect(parsedNoop.subject).toBeNull()
1553
+ })
1554
+ })
1555
+
1556
+ describe("gtd land — exit code does not name the post-land rest's owner", () => {
1557
+ const WORKFLOW = `import { agent, changes, human, refuse, run } from "@pmelab/gtd/flows"
1558
+
1559
+ export default async () => {
1560
+ await human("idle", { message: "write NOTE.md to start a process" })
1561
+ await agent("working", "do the work described in NOTE.md")
1562
+ if (changes("DONE.md").some((c) => c.status === "added")) {
1563
+ await human("waiting", { message: "confirm before continuing" })
1564
+ } else {
1565
+ await run("checking", "run-checks")
1566
+ refuse("checking accepts no turn")
1567
+ }
1568
+ }
1569
+ `
1570
+
1571
+ const atWorking = async (): Promise<InMemRepo> => {
1572
+ const repo = seed(WORKFLOW)
1573
+ await landTurn(repo, { "NOTE.md": "a note\n" })
1574
+ return repo
1575
+ }
1576
+
1577
+ const atChecking = async (): Promise<InMemRepo> => {
1578
+ const repo = await atWorking()
1579
+ await landTurn(repo, { "OUT.txt": "all green\n" })
1580
+ return repo
1581
+ }
1582
+
1583
+ it("a landing whose next rest is a prompt step exits 0", async () => {
1584
+ const repo = seed(WORKFLOW)
1585
+ repo.writeFile("NOTE.md", "a note\n")
1586
+ expect((await run(repo, "land")).exitCode).toBe(0)
1587
+ })
1588
+
1589
+ it("a landing whose next rest is a script step exits 0", async () => {
1590
+ const repo = await atWorking()
1591
+ repo.writeFile("OUT.txt", "all green\n")
1592
+ expect((await run(repo, "land")).exitCode).toBe(0)
1593
+ })
1594
+
1595
+ it("a landing whose next rest is a message step exits 0", async () => {
1596
+ const repo = await atWorking()
1597
+ repo.writeFile("DONE.md", "done\n")
1598
+ const { stdout, exitCode } = await run(repo, "land")
1599
+ expect(exitCode).toBe(0)
1600
+ expect(stdout).toContain("gtd(agent): working → waiting")
1601
+ })
1602
+
1603
+ it("a refusal exits 1 and emits nothing", async () => {
1604
+ const repo = await atChecking()
1605
+ repo.writeFile("scratch.txt", "an unrelated pending change\n")
1606
+ const before = repo.commitHistory().length
1607
+ const { stdout, stderr, exitCode } = await run(repo, "land")
1608
+ expect(exitCode).toBe(1)
1609
+ expect(stdout).toBe("")
1610
+ expect(stderr).toContain("checking accepts no turn")
1611
+ expect(repo.commitHistory()).toHaveLength(before)
1612
+ })
1613
+
1614
+ it("a refusal under --json stays stdout byte-empty (the error envelope is on stderr)", async () => {
1615
+ const repo = await atChecking()
1616
+ repo.writeFile("scratch.txt", "an unrelated pending change\n")
1617
+ const { stdout, stderr, exitCode } = await run(repo, "land", "--json")
1618
+ expect(exitCode).toBe(1)
1619
+ expect(stdout).toBe("")
1620
+ expect(stderr).toContain('"state":"error"')
1621
+ })
1622
+ })
1623
+
1624
+ describe("gtd land — the landing script is only the commit", () => {
1625
+ // A step's mode `format:`/`validate:` pair runs in the driver, off
1626
+ // `gtd next --json`'s `validate` field — never inside the landing script.
1627
+ const NOTES_WORKFLOW = `import { agent, changes, human } from "@pmelab/gtd/flows"
1628
+
1629
+ export default async () => {
1630
+ await human("idle", { message: "hi" })
1631
+ do {
1632
+ await agent("drafting", "write the notes", { file: ".gtd/NOTES.md", mode: "notes" })
1633
+ } while (!changes(".gtd/NOTES.md").some((c) => c.status === "deleted"))
1634
+ }
1635
+ `
1636
+
1637
+ const atDrafting = async (): Promise<InMemRepo> => {
1638
+ const repo = seed(
1639
+ `${NOTES_WORKFLOW}export const config = { modes: { notes: { format: "fmt-notes $GTD_FILE" } } }\n`,
1640
+ )
1641
+ await landTurn(repo, { ".gtd/NOTES.md": "# notes\n\nfirst draft\n" })
1642
+ return repo
1643
+ }
1644
+
1645
+ it("at a dirty steering file (file + mode), the emitted script carries no format/validate command", async () => {
1646
+ const repo = await atDrafting()
1647
+ repo.writeFile(".gtd/NOTES.md", "# notes\n\nsecond draft\n")
1648
+ const { exitCode, script } = await landAndApply(repo)
1649
+ expect(exitCode).toBe(0)
1650
+ expect(script).not.toContain("fmt-notes")
1651
+ expect(script).not.toContain("oxfmt")
1652
+ expect(script).not.toContain("prettier")
1653
+ expect(script).not.toContain("gtd check")
1654
+ expect(repo.lastCommitSubject()).toBe("gtd(agent): drafting")
1655
+ })
1656
+
1657
+ it("still lands a step that deletes that file, with no format command either", async () => {
1658
+ const repo = await atDrafting()
1659
+ repo.deleteFile(".gtd/NOTES.md")
1660
+ const { exitCode, script } = await landAndApply(repo)
1661
+ expect(exitCode).toBe(0)
1662
+ expect(script).not.toContain("fmt-notes")
1663
+ expect(repo.lastCommitSubject()).toBe("gtd(agent): drafting → idle")
1664
+ })
1665
+
1666
+ it("`gtd next --json`'s `validate` field carries the mode's format command", async () => {
1667
+ const repo = await atDrafting()
1668
+ repo.writeFile(".gtd/NOTES.md", "# notes\n\nsecond draft\n")
1669
+ const { stdout, exitCode } = await run(repo, "next", "--json")
1670
+ expect(exitCode).toBe(0)
1671
+ expect((JSON.parse(stdout) as { validate?: string }).validate).toContain(
1672
+ "export GTD_FILE='.gtd/NOTES.md'\nfmt-notes $GTD_FILE",
1673
+ )
1674
+ })
1675
+ })
1676
+
1677
+ describe("runCommand — refuses in a repository with no commits", () => {
1678
+ // Typed as a total record, so a new `Command` kind fails typecheck until it
1679
+ // gets an entry here and `stateKinds` picks it up.
1680
+ const commandFor: Record<Command["kind"], Command> = {
1681
+ lsp: { kind: "lsp" },
1682
+ ui: { kind: "ui", selfSigned: false, dev: false },
1683
+ land: { kind: "land" },
1684
+ start: { kind: "start", actor: "human", workflow: "feature", vars: {}, label: "" },
1685
+ door: { kind: "door", name: "fix", args: [] },
1686
+ doors: { kind: "doors" },
1687
+ abandon: { kind: "abandon" },
1688
+ restore: { kind: "restore" },
1689
+ next: { kind: "next" },
1690
+ validate: { kind: "validate" },
1691
+ check: { kind: "check", mode: "qa", file: ".gtd/TODO.md" },
1692
+ uncheck: { kind: "uncheck", file: ".gtd/REVIEW.md" },
1693
+ install: { kind: "install" },
1694
+ loop: { kind: "loop" },
1695
+ summary: { kind: "summary" },
1696
+ base: { kind: "base" },
1697
+ judge: { kind: "judge" },
1698
+ judgeAnswer: { kind: "judgeAnswer" },
1699
+ judgeRun: { kind: "judgeRun", provider: "fixed" },
1700
+ }
1701
+
1702
+ const stateKinds = (Object.keys(commandFor) as Command["kind"][]).filter(
1703
+ (kind) => needsOf(kind) === "state",
1704
+ )
1705
+
1706
+ const NO_COMMITS_MESSAGE =
1707
+ "gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
1708
+
1709
+ it("derives exactly the thirteen non-standalone kinds — a canary for the table-driven cases below", () => {
1710
+ expect(stateKinds.sort()).toEqual(
1711
+ [
1712
+ "abandon",
1713
+ "base",
1714
+ "door",
1715
+ "doors",
1716
+ "start",
1717
+ "land",
1718
+ "next",
1719
+ "restore",
1720
+ "summary",
1721
+ "validate",
1722
+ "ui",
1723
+ "judge",
1724
+ "judgeAnswer",
1725
+ ].sort(),
1726
+ )
1727
+ })
1728
+
1729
+ it.each(stateKinds)(
1730
+ "%s refuses with the pinned message, creates no commit, and writes no script",
1731
+ async (kind) => {
1732
+ const repo = new InMemRepo()
1733
+ const before = repo.commitHistory().length
1734
+ const written: string[] = []
1735
+ const out = { write: (chunk: string) => written.push(chunk), flush: () => {} }
1736
+
1737
+ const exit = await Effect.runPromiseExit(
1738
+ runCommand(commandFor[kind], { kind: "off" }, out).pipe(Effect.provide(testLayers(repo))),
1739
+ )
1740
+
1741
+ expect(Exit.isFailure(exit)).toBe(true)
1742
+ if (Exit.isFailure(exit)) {
1743
+ expect(String(exit.cause)).toContain(NO_COMMITS_MESSAGE)
1744
+ }
1745
+ expect(repo.hasCommits()).toBe(false)
1746
+ expect(repo.commitHistory()).toHaveLength(before)
1747
+ expect(written).toEqual([])
1748
+ },
1749
+ )
1750
+
1751
+ it("a repository with a commit passes the guard and dispatches normally", async () => {
1752
+ const repo = seedBundled()
1753
+ const written: string[] = []
1754
+ const out = { write: (chunk: string) => written.push(chunk), flush: () => {} }
1755
+
1756
+ const exit = await Effect.runPromiseExit(
1757
+ runCommand({ kind: "next" }, { kind: "off" }, out).pipe(Effect.provide(testLayers(repo))),
1758
+ )
1759
+
1760
+ expect(Exit.isSuccess(exit)).toBe(true)
1761
+ expect(written.length).toBeGreaterThan(0)
1762
+ })
1763
+
1764
+ it("gtd ui in a repository WITH commits reaches its own dispatch, past the guard", async () => {
1765
+ const repo = seedBundled()
1766
+ const out = { write: () => {}, flush: () => {} }
1767
+ const requests: unknown[] = []
1768
+ const ui = (request: unknown) => Effect.sync(() => void requests.push(request))
1769
+
1770
+ const exit = await Effect.runPromiseExit(
1771
+ runCommand(
1772
+ { kind: "ui", port: 3000, selfSigned: false, dev: false },
1773
+ { kind: "off" },
1774
+ out,
1775
+ ).pipe(Effect.provide(testLayers(repo, { tools: { ui } }))),
1776
+ )
1777
+
1778
+ expect(Exit.isSuccess(exit)).toBe(true)
1779
+ expect(requests).toEqual([
1780
+ { flags: { port: 3000, selfSigned: false, dev: false }, config: undefined, root: "/repo" },
1781
+ ])
1782
+ })
1783
+ })
1784
+
1785
+ describe("gtd summary", () => {
1786
+ const SUMMARY_WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
1787
+
1788
+ export default async () => {
1789
+ await human("idle", { message: "hi" })
1790
+ await agent("working", "go")
1791
+ await human("reviewing", { message: "check it" })
1792
+ await agent("polishing", "polish")
1793
+ }
1794
+
1795
+ export const summary = (c) => \`entry=\${c.entryCommit} tip=\${c.processTip} humans=\${c.humanCommits.length}\`
1796
+ `
1797
+
1798
+ const reviewed = async (): Promise<InMemRepo> => {
1799
+ const repo = seed(SUMMARY_WORKFLOW)
1800
+ await landTurn(repo, { "NOTE.md": "a note\n" })
1801
+ await landTurn(repo, { "WORK.md": "done\n" })
1802
+ await landTurn(repo, { "REVIEW.md": "looks fine\n" })
1803
+ return repo
1804
+ }
1805
+
1806
+ it("refuses when the workflow declares no summary, even with an in-flight process", async () => {
1807
+ const repo = seed(IDLE_THEN_WORKING)
1808
+ await landTurn(repo, { "NOTE.md": "a note\n" })
1809
+ const before = repo.commitHistory().length
1810
+ const { exitCode, stdout, stderr } = await run(repo, "summary")
1811
+ expect(exitCode).toBe(1)
1812
+ expect(stderr).toContain("gtd summary: refused —")
1813
+ expect(stdout).toBe("")
1814
+ expect(repo.commitHistory()).toHaveLength(before)
1815
+ })
1816
+
1817
+ it("refuses when no process is underway at HEAD — an empty trace", async () => {
1818
+ const repo = seed(SUMMARY_WORKFLOW)
1819
+ const before = repo.commitHistory().length
1820
+ const { exitCode, stdout, stderr } = await run(repo, "summary")
1821
+ expect(exitCode).toBe(1)
1822
+ expect(stderr).toContain("gtd summary: refused —")
1823
+ expect(stdout).toBe("")
1824
+ expect(repo.commitHistory()).toHaveLength(before)
1825
+ })
1826
+
1827
+ it("writes the rendered summary, with a trailing newline, counting the human turns after the entry commit", async () => {
1828
+ const repo = await reviewed()
1829
+ const history = repo.commitHistory()
1830
+ const entryHash = history[history.length - 3]!.hash
1831
+ const tipHash = history[history.length - 1]!.hash
1832
+
1833
+ const { exitCode, stdout } = await run(repo, "summary")
1834
+ expect(exitCode).toBe(0)
1835
+ expect(stdout).toBe(`entry=${entryHash} tip=${tipHash} humans=1\n`)
1836
+ })
1837
+
1838
+ it("writes nothing to git and emits no script — the output is the prompt only", async () => {
1839
+ const repo = await reviewed()
1840
+ const before = repo.commitHistory()
1841
+
1842
+ const { exitCode, stdout } = await run(repo, "summary")
1843
+ expect(exitCode).toBe(0)
1844
+ expect(stdout).not.toContain("git reset --mixed")
1845
+ expect(stdout).not.toContain("git commit")
1846
+
1847
+ const after = repo.commitHistory()
1848
+ expect(after).toHaveLength(before.length)
1849
+ expect(after.at(-1)!.hash).toBe(before.at(-1)!.hash)
1850
+ })
1851
+ })
1852
+
1853
+ describe("gtd base — prints the review anchor hash", () => {
1854
+ // Each round's review window starts at the commit that entered `deciding`;
1855
+ // FEEDBACK.md sends the process round again, a clean accept finishes it.
1856
+ const BASE_WORKFLOW = `import { agent, changes, head, human, start } from "@pmelab/gtd/flows"
1857
+
1858
+ export default async () => {
1859
+ await human("idle", { message: "hi" })
1860
+ let base = start()
1861
+ for (;;) {
1862
+ await agent("working", "go", { base })
1863
+ base = head()
1864
+ await human("deciding", { message: "decide", base, acceptClean: true })
1865
+ if (!changes("FEEDBACK.md").some((c) => c.status === "added")) return
1866
+ }
1867
+ }
1868
+ `
1869
+
1870
+ const atWorking = async (): Promise<InMemRepo> => {
1871
+ const repo = seed(BASE_WORKFLOW)
1872
+ await landTurn(repo, { "NOTE.md": "a note\n" })
1873
+ return repo
1874
+ }
1875
+
1876
+ const atDeciding = async (): Promise<InMemRepo> => {
1877
+ const repo = await atWorking()
1878
+ await landTurn(repo, { "WORK.md": "done\n" })
1879
+ return repo
1880
+ }
1881
+
1882
+ it("refuses when no process is underway at HEAD", async () => {
1883
+ const repo = seed(BASE_WORKFLOW)
1884
+ const before = repo.commitHistory().length
1885
+ const { exitCode, stdout, stderr } = await run(repo, "base")
1886
+ expect(exitCode).toBe(1)
1887
+ expect(stderr).toContain("gtd base: refused —")
1888
+ expect(stdout).toBe("")
1889
+ expect(repo.commitHistory()).toHaveLength(before)
1890
+ })
1891
+
1892
+ it("prints the process's diff base before the first review round lands", async () => {
1893
+ const repo = seed(BASE_WORKFLOW)
1894
+ const boundaryHash = repo.commitHistory().at(-1)!.hash
1895
+ await landTurn(repo, { "NOTE.md": "a note\n" })
1896
+
1897
+ const { exitCode, stdout } = await run(repo, "base")
1898
+ expect(exitCode).toBe(0)
1899
+ expect(stdout).toBe(`${boundaryHash}\n`)
1900
+ })
1901
+
1902
+ it("prints the base the resting step names once one has landed this process", async () => {
1903
+ const repo = await atDeciding()
1904
+ const decidingHash = repo.commitHistory().at(-1)!.hash
1905
+
1906
+ const { exitCode, stdout } = await run(repo, "base")
1907
+ expect(exitCode).toBe(0)
1908
+ expect(stdout).toBe(`${decidingHash}\n`)
1909
+ })
1910
+
1911
+ it("on a second, incremental round prints the previous round's boundary, not the process start", async () => {
1912
+ const repo = await atDeciding()
1913
+ const firstDecidingHash = repo.commitHistory().at(-1)!.hash
1914
+ await landTurn(repo, { "FEEDBACK.md": "please change this\n" })
1915
+
1916
+ const { exitCode, stdout } = await run(repo, "base")
1917
+ expect(exitCode).toBe(0)
1918
+ expect(stdout).toBe(`${firstDecidingHash}\n`)
1919
+ })
1920
+
1921
+ it("refuses once the process has closed and HEAD rests at the initial step again", async () => {
1922
+ const repo = await atDeciding()
1923
+ await landTurn(repo)
1924
+ expect(repo.lastCommitSubject()).toBe("gtd(human): deciding → idle")
1925
+
1926
+ const { exitCode, stdout, stderr } = await run(repo, "base")
1927
+ expect(exitCode).toBe(1)
1928
+ expect(stderr).toContain("gtd base: refused —")
1929
+ expect(stdout).toBe("")
1930
+ })
1931
+
1932
+ it("writes nothing to git, and prints a bare hash plus a trailing newline", async () => {
1933
+ const repo = await atWorking()
1934
+ const before = repo.commitHistory()
1935
+
1936
+ const { exitCode, stdout } = await run(repo, "base")
1937
+ expect(exitCode).toBe(0)
1938
+ expect(stdout).toMatch(/^[0-9a-f-]+\n$/)
1939
+
1940
+ const after = repo.commitHistory()
1941
+ expect(after).toHaveLength(before.length)
1942
+ expect(after.at(-1)!.hash).toBe(before.at(-1)!.hash)
1943
+ })
1944
+ })
1945
+
1946
+ describe("gtd next/land --json=<path> — the select branch", () => {
1947
+ const seededRepo = (): InMemRepo => {
1948
+ const repo = new InMemRepo()
1949
+ repo.writeFile("NOTE.md", "a note\n")
1950
+ repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1951
+ repo.commitAllWithPrefix("gtd: init")
1952
+ return repo
1953
+ }
1954
+
1955
+ it("gtd next --json=kind reduces the SAME fields object gtd next --json renders — matches the document's own kind", async () => {
1956
+ const repo = seededRepo()
1957
+ const { stdout: documentOut, exitCode: documentExit } = await run(repo, "next", "--json")
1958
+ expect(documentExit).toBe(0)
1959
+ const parsedKind = (JSON.parse(documentOut) as { readonly kind: string }).kind
1960
+
1961
+ const { stdout: selectOut, exitCode: selectExit } = await run(repo, "next", "--json=kind")
1962
+ expect(selectExit).toBe(0)
1963
+ expect(selectOut).toBe(`${parsedKind}\n`)
1964
+ })
1965
+
1966
+ it("gtd land --json=state reduces the SAME fields object gtd land --json renders — matches the document's own state", async () => {
1967
+ const repo = seededRepo()
1968
+ const { stdout: documentOut, exitCode: documentExit } = await run(repo, "land", "--json")
1969
+ expect(documentExit).toBe(0)
1970
+ const parsedState = (JSON.parse(documentOut) as { readonly state: string }).state
1971
+
1972
+ const { stdout: selectOut, exitCode: selectExit } = await run(repo, "land", "--json=state")
1973
+ expect(selectExit).toBe(0)
1974
+ expect(selectOut).toBe(`${parsedState}\n`)
1975
+ })
1976
+
1977
+ it("a value selection is written with exactly one trailing newline, nothing else", async () => {
1978
+ const repo = seededRepo()
1979
+ const { stdout, exitCode } = await run(repo, "next", "--json=kind")
1980
+ expect(exitCode).toBe(0)
1981
+ expect(stdout.endsWith("\n")).toBe(true)
1982
+ expect(stdout.endsWith("\n\n")).toBe(false)
1983
+ })
1984
+
1985
+ it("an absent selector writes zero bytes to stdout and exits EXIT_OK", async () => {
1986
+ const repo = seededRepo()
1987
+ // The built-in workflow's initial `idle` state declares no `mode:` —
1988
+ // `mode` is a real, present-but-`undefined` field on the beat document
1989
+ // there (see `src/wire/BeatStatus.ts`'s `statusOf`), not merely an
1990
+ // unknown path.
1991
+ const { stdout, exitCode } = await run(repo, "next", "--json=mode")
1992
+ expect(exitCode).toBe(0)
1993
+ expect(stdout).toBe("")
1994
+ })
1995
+
1996
+ it('a null-valued leaf (the beat document\'s next, on a clean tree) reads as absent — zero bytes, exit 0, never the string "null"', async () => {
1997
+ const repo = seededRepo()
1998
+ const { stdout, exitCode } = await run(repo, "next", "--json=next")
1999
+ expect(exitCode).toBe(0)
2000
+ expect(stdout).toBe("")
2001
+ })
2002
+
2003
+ it("--json=next.target descends through a null next without going fatal — absent, not unknown, per the absent-parent rule", async () => {
2004
+ const repo = seededRepo()
2005
+ const { stdout, exitCode } = await run(repo, "next", "--json=next.target")
2006
+ expect(exitCode).toBe(0)
2007
+ expect(stdout).toBe("")
2008
+ })
2009
+
2010
+ it('gtd land\'s | null LandFields (subject/cost/model) at a no-op landing read as absent — never the string "null"', async () => {
2011
+ const repo = seededRepo()
2012
+ const { stdout: subjectOut, exitCode: subjectExit } = await run(repo, "land", "--json=subject")
2013
+ expect(subjectExit).toBe(0)
2014
+ expect(subjectOut).toBe("")
2015
+
2016
+ const { stdout: costOut, exitCode: costExit } = await run(repo, "land", "--json=cost")
2017
+ expect(costExit).toBe(0)
2018
+ expect(costOut).toBe("")
2019
+
2020
+ const { stdout: modelOut, exitCode: modelExit } = await run(repo, "land", "--json=model")
2021
+ expect(modelExit).toBe(0)
2022
+ expect(modelOut).toBe("")
2023
+ })
2024
+
2025
+ it("an unknown selector fails with exit code EXIT_USAGE_ERROR, not a generic runtime error", async () => {
2026
+ const repo = seededRepo()
2027
+ const { exitCode } = await run(repo, "next", "--json=does.not.exist")
2028
+ expect(exitCode).toBe(EXIT_USAGE_ERROR)
2029
+ })
2030
+
2031
+ it("an unknown selector writes its message to stderr and nothing to stdout", async () => {
2032
+ const repo = seededRepo()
2033
+ const { stdout, stderr, exitCode } = await run(repo, "next", "--json=does.not.exist")
2034
+ expect(exitCode).toBe(EXIT_USAGE_ERROR)
2035
+ expect(stdout).toBe("")
2036
+ expect(stderr).toContain("does.not.exist")
2037
+ })
2038
+
2039
+ it("an unknown selector on gtd land also exits EXIT_USAGE_ERROR", async () => {
2040
+ const repo = seededRepo()
2041
+ const { stdout, stderr, exitCode } = await run(repo, "land", "--json=does.not.exist")
2042
+ expect(exitCode).toBe(EXIT_USAGE_ERROR)
2043
+ expect(stdout).toBe("")
2044
+ expect(stderr).toContain("does.not.exist")
2045
+ })
2046
+
2047
+ it("the underlying failure Program.ts raises for an unknown selector is a SelectorUsageError", async () => {
2048
+ const repo = seededRepo()
2049
+ const exit = await Effect.runPromiseExit(
2050
+ runCommand(
2051
+ { kind: "next" },
2052
+ { kind: "select", path: "does.not.exist" },
2053
+ {
2054
+ write: () => {},
2055
+ flush: () => {},
2056
+ },
2057
+ ).pipe(Effect.provide(testLayers(repo))),
2058
+ )
2059
+ expect(Exit.isFailure(exit)).toBe(true)
2060
+ if (Exit.isFailure(exit)) {
2061
+ const failure = Cause.failureOption(exit.cause)
2062
+ expect(failure._tag).toBe("Some")
2063
+ if (failure._tag === "Some") {
2064
+ expect(failure.value).toBeInstanceOf(SelectorUsageError)
2065
+ }
2066
+ }
2067
+ })
2068
+
2069
+ it("the self-validation-command resolution still runs only on the plain branch under JsonMode", async () => {
2070
+ // A degrade-on-error smoke check: a plain gtd next still succeeds (and
2071
+ // includes the resolved rest's rendered content) even though nothing here
2072
+ // declares a mode:/file: pair for the self-validate instruction to
2073
+ // resolve against.
2074
+ const repo = seededRepo()
2075
+ const { stdout, exitCode } = await run(repo, "next")
2076
+ expect(exitCode).toBe(0)
2077
+ expect(stdout.length).toBeGreaterThan(0)
2078
+ })
2079
+ })
2080
+
2081
+ describe("gtd judge / gtd judge answer", () => {
2082
+ const JUDGE_DOCUMENT = `{"schema":${WIRE_SCHEMA},"state":{"note":"idle"},"questions":[{"id":"q1","primitive":"noul","instructions":"i","criteria":"c"}]}`
2083
+
2084
+ const QUESTION = `{ id: "q1", primitive: "noul", instructions: "i", criteria: "c" }`
2085
+
2086
+ const judgeWorkflow = (evidence: string, then: string, vars = "{}"): string =>
2087
+ `import { agent, human, judge, read } from "@pmelab/gtd/flows"
2088
+
2089
+ export default async () => {
2090
+ await judge("idle", { questions: [${QUESTION}], evidence: ${evidence} })
2091
+ await ${then}
2092
+ }
2093
+
2094
+ export const defaults = ${vars}
2095
+ `
2096
+
2097
+ const seededRepo = (): InMemRepo =>
2098
+ seed(judgeWorkflow('{ note: "idle" }', 'agent("working", "go")'))
2099
+
2100
+ const seededRepoWithoutJudge = (): InMemRepo => seed(IDLE_THEN_WORKING)
2101
+
2102
+ const seededLandingRepo = (): InMemRepo =>
2103
+ seed(judgeWorkflow('{ note: "idle" }', 'human("landed", { message: "done" })'))
2104
+
2105
+ it("gtd judge prints the judge document verbatim, plus exactly one trailing newline", async () => {
2106
+ const repo = seededRepo()
2107
+ const { stdout, exitCode } = await run(repo, "judge")
2108
+ expect(exitCode).toBe(0)
2109
+ expect(stdout).toBe(`${JUDGE_DOCUMENT}\n`)
2110
+ })
2111
+
2112
+ it("gtd judge --json prints the same document — a read-only peek, no mutation", async () => {
2113
+ const repo = seededRepo()
2114
+ const before = repo.commitHistory().length
2115
+ const { stdout, exitCode } = await run(repo, "judge", "--json")
2116
+ expect(exitCode).toBe(0)
2117
+ expect(stdout).toBe(`${JUDGE_DOCUMENT}\n`)
2118
+ expect(repo.commitHistory()).toHaveLength(before)
2119
+ })
2120
+
2121
+ it("gtd judge refuses through the ordinary error envelope when the rest is no judge step", async () => {
2122
+ const repo = seededRepoWithoutJudge()
2123
+ const { exitCode, stderr, stdout } = await run(repo, "judge")
2124
+ expect(exitCode).toBe(1)
2125
+ expect(stdout).toBe("")
2126
+ expect(stderr).toContain('declares no "judge:"')
2127
+ })
2128
+
2129
+ it("polling gtd judge twice reads byte-identical output — a peek, not a dispatch", async () => {
2130
+ const repo = seededRepo()
2131
+ const first = await run(repo, "judge")
2132
+ const second = await run(repo, "judge")
2133
+ expect(first.stdout).toBe(second.stdout)
2134
+ })
2135
+
2136
+ let savedStdin: PropertyDescriptor | undefined
2137
+ afterEach(() => {
2138
+ if (savedStdin) {
2139
+ Object.defineProperty(process, "stdin", savedStdin)
2140
+ savedStdin = undefined
2141
+ }
2142
+ })
2143
+
2144
+ const withStdin = async <T>(content: string, fn: () => Promise<T>): Promise<T> => {
2145
+ savedStdin = Object.getOwnPropertyDescriptor(process, "stdin")
2146
+ const stdin = new PassThrough()
2147
+ Object.defineProperty(process, "stdin", { value: stdin, configurable: true })
2148
+ stdin.end(content)
2149
+ try {
2150
+ return await fn()
2151
+ } finally {
2152
+ if (savedStdin) Object.defineProperty(process, "stdin", savedStdin)
2153
+ savedStdin = undefined
2154
+ }
2155
+ }
2156
+
2157
+ const VERDICT = JSON.stringify([{ id: "q1", answer: true, p: 0.97 }])
2158
+
2159
+ it("gtd judge answer decodes a verdict on stdin against the pending question ids and succeeds", async () => {
2160
+ const repo = seededRepo()
2161
+ const { exitCode, stdout } = await withStdin(VERDICT, () => run(repo, "judge", "answer"))
2162
+ expect(exitCode).toBe(0)
2163
+ expect(stdout.length).toBeGreaterThan(0)
2164
+ })
2165
+
2166
+ it("gtd judge answer refuses a verdict naming a question id the pending judgment never declared, with the usage-error exit code", async () => {
2167
+ const repo = seededRepo()
2168
+ const { exitCode, stderr } = await withStdin(
2169
+ JSON.stringify([{ id: "not-a-real-question", answer: true, p: 0.97 }]),
2170
+ () => run(repo, "judge", "answer"),
2171
+ )
2172
+ expect(exitCode).toBe(2)
2173
+ expect(stderr).toContain("does not match the pending questions")
2174
+ })
2175
+
2176
+ it("gtd judge answer refuses when stdin is not valid JSON at all, with the usage-error exit code", async () => {
2177
+ const repo = seededRepo()
2178
+ const { exitCode, stderr } = await withStdin("not json", () => run(repo, "judge", "answer"))
2179
+ expect(exitCode).toBe(2)
2180
+ expect(stderr).toContain("stdin is not valid JSON")
2181
+ })
2182
+
2183
+ it("gtd judge answer refuses a verdict whose p is above 1 — an unvalidated p would clear every minP", async () => {
2184
+ const repo = seededRepo()
2185
+ const { exitCode, stderr } = await withStdin(
2186
+ JSON.stringify([{ id: "q1", answer: true, p: 5 }]),
2187
+ () => run(repo, "judge", "answer"),
2188
+ )
2189
+ expect(exitCode).toBe(2)
2190
+ expect(stderr).toContain("does not match the pending questions")
2191
+ })
2192
+
2193
+ it("gtd judge answer refuses a verdict whose p is negative", async () => {
2194
+ const repo = seededRepo()
2195
+ const { exitCode, stderr } = await withStdin(
2196
+ JSON.stringify([{ id: "q1", answer: true, p: -0.1 }]),
2197
+ () => run(repo, "judge", "answer"),
2198
+ )
2199
+ expect(exitCode).toBe(2)
2200
+ expect(stderr).toContain("does not match the pending questions")
2201
+ })
2202
+
2203
+ it("gtd judge answer refuses through the ordinary error envelope when the rest is no judge step", async () => {
2204
+ const repo = seededRepoWithoutJudge()
2205
+ const { exitCode, stderr } = await withStdin("[]", () => run(repo, "judge", "answer"))
2206
+ expect(exitCode).toBe(1)
2207
+ expect(stderr).toContain('declares no "judge:"')
2208
+ })
2209
+
2210
+ it("gtd judge answer --json=script emits a POSIX sh script carrying a Gtd-Judge: trailer on the step commit", async () => {
2211
+ const repo = seededLandingRepo()
2212
+ const { stdout, exitCode } = await withStdin(VERDICT, () =>
2213
+ run(repo, "judge", "answer", "--json=script"),
2214
+ )
2215
+ expect(exitCode).toBe(0)
2216
+ expect(stdout).toContain(DID_NOT_RUN_COMMENT)
2217
+ expect(stdout).toContain('Gtd-Judge: {"id":"q1","answer":true,"p":0.97}')
2218
+
2219
+ expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
2220
+ expect(repo.lastCommitSubject()).toBe("gtd(judge): idle → landed")
2221
+ expect(repo.lastCommitMessage()).toContain('Gtd-Judge: {"id":"q1","answer":true,"p":0.97}')
2222
+ })
2223
+
2224
+ it("gtd judge answer --json=script opens with the same leading comment as land's script", async () => {
2225
+ const repo = seededLandingRepo()
2226
+ const { stdout: script } = await withStdin(VERDICT, () =>
2227
+ run(repo, "judge", "answer", "--json=script"),
2228
+ )
2229
+ const { stdout: landScript } = await run(repo, "land", "--json=script")
2230
+ expect(script.split("\n")[0]).toBe(landScript.split("\n")[0])
2231
+ })
2232
+
2233
+ it("plain gtd judge answer (no --json) names the commit and points at --json=script, never the script itself", async () => {
2234
+ const repo = seededLandingRepo()
2235
+ const { stdout, exitCode } = await withStdin(VERDICT, () => run(repo, "judge", "answer"))
2236
+ expect(exitCode).toBe(0)
2237
+ expect(stdout).toContain("--json=script")
2238
+ expect(stdout).not.toContain(DID_NOT_RUN_COMMENT)
2239
+ })
2240
+
2241
+ // The judged render's own truncation flag must reach the landing commit —
2242
+ // not a fresh re-resolve of the rest.
2243
+ const seededTruncatingLandingRepo = (judgeBudgetBytes: string, bigContent: string): InMemRepo =>
2244
+ seed(
2245
+ judgeWorkflow(
2246
+ '{ big: read(".gtd/BIG.md") ?? "" }',
2247
+ 'human("landed", { message: "done" })',
2248
+ `{ judgeBudgetBytes: "${judgeBudgetBytes}" }`,
2249
+ ),
2250
+ { ".gtd/BIG.md": bigContent },
2251
+ )
2252
+
2253
+ it("gtd judge answer records no truncation on the landing commit — replay recomputes the cut from the budget", async () => {
2254
+ const repo = seededTruncatingLandingRepo("20", "aaaaaaaaaa\nbbbbbbbbbb\ncccccccccc\n")
2255
+ const { stdout, exitCode } = await withStdin(VERDICT, () =>
2256
+ run(repo, "judge", "answer", "--json=script"),
2257
+ )
2258
+ expect(exitCode).toBe(0)
2259
+ expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
2260
+ expect(repo.lastCommitMessage()).not.toContain("Gtd-Payload:")
2261
+ })
2262
+ })