@pmelab/gtd 24.0.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 (196) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -5
  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 +43339 -55575
  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 +8 -6
  19. package/package.json +69 -8
  20. package/src/cli/Cli.test.ts +1329 -0
  21. package/src/cli/Cli.ts +1348 -0
  22. package/src/cli/Program.test.ts +2262 -0
  23. package/src/cli/Program.ts +1331 -0
  24. package/src/cli/ToolCommands.ts +43 -0
  25. package/src/cli/index.ts +14 -0
  26. package/src/edge/Edge.test.ts +1016 -0
  27. package/src/edge/Edge.ts +1225 -0
  28. package/src/edge/index.ts +16 -0
  29. package/src/emit/Emit.test.ts +425 -0
  30. package/src/emit/Emit.ts +139 -0
  31. package/src/emit/GitScript.test.ts +260 -0
  32. package/src/emit/GitScript.ts +131 -0
  33. package/src/emit/ModeContradiction.test.ts +120 -0
  34. package/src/emit/ModeContradiction.ts +66 -0
  35. package/src/emit/OutcomeScript.test.ts +238 -0
  36. package/src/emit/OutcomeScript.ts +73 -0
  37. package/src/emit/SteeringMode.test.ts +274 -0
  38. package/src/emit/SteeringMode.ts +256 -0
  39. package/src/emit/index.ts +40 -0
  40. package/src/install/ConfigScaffold.ts +19 -0
  41. package/src/install/Install.test.ts +426 -0
  42. package/src/install/Install.ts +634 -0
  43. package/src/install/Version.ts +27 -0
  44. package/src/install/index.ts +3 -0
  45. package/src/platform/CommandRunner.ts +74 -0
  46. package/src/platform/Commentary.test.ts +126 -0
  47. package/src/platform/Commentary.ts +92 -0
  48. package/src/platform/Git.test.ts +50 -0
  49. package/src/platform/Git.ts +552 -0
  50. package/src/platform/Host.ts +42 -0
  51. package/src/platform/RetainedHistory.test.ts +65 -0
  52. package/src/platform/RetainedHistory.ts +34 -0
  53. package/src/platform/Workspace.test.ts +669 -0
  54. package/src/platform/Workspace.ts +631 -0
  55. package/src/platform/WorktreeState.test.ts +162 -0
  56. package/src/platform/WorktreeState.ts +50 -0
  57. package/src/platform/index.ts +22 -0
  58. package/src/replay/Access.test.ts +53 -0
  59. package/src/replay/Access.ts +46 -0
  60. package/src/replay/Format.test.ts +20 -0
  61. package/src/replay/Format.ts +14 -0
  62. package/src/replay/Replay.test.ts +887 -0
  63. package/src/replay/Replay.ts +669 -0
  64. package/src/replay/Trailers.test.ts +117 -0
  65. package/src/replay/Trailers.ts +213 -0
  66. package/src/replay/Tree.test.ts +25 -0
  67. package/src/replay/Tree.ts +45 -0
  68. package/src/replay/index.ts +11 -0
  69. package/src/steering/Blocks.fixture.ts +6 -0
  70. package/src/steering/Blocks.test.ts +167 -0
  71. package/src/steering/Blocks.ts +321 -0
  72. package/src/steering/CodeThreads.test.ts +127 -0
  73. package/src/steering/CodeThreads.ts +197 -0
  74. package/src/steering/Edits.test.ts +19 -0
  75. package/src/steering/Edits.ts +20 -0
  76. package/src/steering/Eol.test.ts +16 -0
  77. package/src/steering/Eol.ts +2 -0
  78. package/src/steering/FORMATS.md +58 -0
  79. package/src/steering/Footnotes.test.ts +977 -0
  80. package/src/steering/Footnotes.ts +661 -0
  81. package/src/steering/Inline.fixture.ts +7 -0
  82. package/src/steering/Inline.test.ts +227 -0
  83. package/src/steering/Inline.ts +195 -0
  84. package/src/steering/MarkdownTree.test.ts +247 -0
  85. package/src/steering/MarkdownTree.ts +260 -0
  86. package/src/steering/Outline.ts +19 -0
  87. package/src/steering/SteeringFormat.test.ts +91 -0
  88. package/src/steering/SteeringFormat.ts +267 -0
  89. package/src/steering/SteeringFormats.test.ts +163 -0
  90. package/src/steering/Threads.ts +163 -0
  91. package/src/steering/contract.test.ts +264 -0
  92. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  93. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  94. package/src/steering/fixtures/free-form/notes.md +12 -0
  95. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  96. package/src/steering/fixtures/qa/answered.md +23 -0
  97. package/src/steering/fixtures/qa/answered.read.json +119 -0
  98. package/src/steering/fixtures/qa/requirements.md +160 -0
  99. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  100. package/src/steering/fixtures/review/review.md +76 -0
  101. package/src/steering/fixtures/review/review.read.json +237 -0
  102. package/src/steering/fixtures/review/ticked.md +23 -0
  103. package/src/steering/fixtures/review/ticked.read.json +99 -0
  104. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  105. package/src/steering/fixtures/review-notes/noted.md +19 -0
  106. package/src/steering/freeform.test.ts +311 -0
  107. package/src/steering/freeform.ts +148 -0
  108. package/src/steering/index.test.ts +62 -0
  109. package/src/steering/index.ts +140 -0
  110. package/src/steering/qa.test.ts +2160 -0
  111. package/src/steering/qa.ts +1104 -0
  112. package/src/steering/review.test.ts +1829 -0
  113. package/src/steering/review.ts +1180 -0
  114. package/src/step/LandStep.ts +31 -0
  115. package/src/step/RepoSnapshot.ts +29 -0
  116. package/src/step/accessRefusal.test.ts +58 -0
  117. package/src/step/accessRefusal.ts +26 -0
  118. package/src/step/index.ts +5 -0
  119. package/src/step/planStart.test.ts +202 -0
  120. package/src/step/planStart.ts +167 -0
  121. package/src/step/planStep.test.ts +104 -0
  122. package/src/step/planStep.ts +89 -0
  123. package/src/step/snapshot.fixture.ts +28 -0
  124. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  125. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  126. package/src/testkit/FakeGitOperations.ts +95 -0
  127. package/src/testkit/GitTiers.test.ts +11 -0
  128. package/src/testkit/GitTiers.ts +1052 -0
  129. package/src/testkit/InMemRepo.ts +467 -0
  130. package/src/testkit/Layers.test.ts +58 -0
  131. package/src/testkit/Layers.ts +241 -0
  132. package/src/testkit/StrictGitOperations.ts +31 -0
  133. package/src/testkit/TestRepo.test.ts +119 -0
  134. package/src/testkit/TestRepo.ts +274 -0
  135. package/src/testkit/cliIo.ts +36 -0
  136. package/src/testkit/index.ts +9 -0
  137. package/src/testkit/steps.ts +177 -0
  138. package/src/testkit/testkit.feature +65 -0
  139. package/src/wire/BeatDocument.test.ts +419 -0
  140. package/src/wire/BeatDocument.ts +114 -0
  141. package/src/wire/BeatPlain.test.ts +113 -0
  142. package/src/wire/BeatPlain.ts +127 -0
  143. package/src/wire/BeatStatus.test.ts +59 -0
  144. package/src/wire/BeatStatus.ts +81 -0
  145. package/src/wire/Demand.test.ts +142 -0
  146. package/src/wire/Demand.ts +108 -0
  147. package/src/wire/ExitCodes.test.ts +21 -0
  148. package/src/wire/ExitCodes.ts +16 -0
  149. package/src/wire/Glob.ts +29 -0
  150. package/src/wire/Judge.ts +22 -0
  151. package/src/wire/Land.test.ts +120 -0
  152. package/src/wire/Land.ts +75 -0
  153. package/src/wire/Select.test.ts +148 -0
  154. package/src/wire/Select.ts +78 -0
  155. package/src/wire/Sessions.test.ts +59 -0
  156. package/src/wire/Sessions.ts +39 -0
  157. package/src/wire/WireSchema.test.ts +162 -0
  158. package/src/wire/constants.ts +22 -0
  159. package/src/wire/golden/1/beat-capture.json +34 -0
  160. package/src/wire/golden/1/beat-message.json +34 -0
  161. package/src/wire/golden/1/beat-prompt.json +44 -0
  162. package/src/wire/golden/1/beat-script.json +34 -0
  163. package/src/wire/golden/1/beat-stalled.json +34 -0
  164. package/src/wire/golden/1/judge.json +14 -0
  165. package/src/wire/golden/1/land-noop.json +10 -0
  166. package/src/wire/golden/1/land.json +10 -0
  167. package/src/wire/index.ts +12 -0
  168. package/src/wire/types.ts +46 -0
  169. package/src/workflow/Diagnostic.test.ts +93 -0
  170. package/src/workflow/Diagnostic.ts +54 -0
  171. package/src/workflow/SteeringFormats.test.ts +27 -0
  172. package/src/workflow/SteeringFormats.ts +6 -0
  173. package/src/workflow/Workflow.ts +77 -0
  174. package/src/workflow/compile.test.ts +53 -0
  175. package/src/workflow/compile.ts +56 -0
  176. package/src/workflow/discovery.ts +122 -0
  177. package/src/workflow/fingerprint.test.ts +52 -0
  178. package/src/workflow/fingerprint.ts +53 -0
  179. package/src/workflow/index.ts +33 -0
  180. package/src/workflow/load.test.ts +1017 -0
  181. package/src/workflow/load.ts +836 -0
  182. package/src/workflow/skills.test.ts +109 -0
  183. package/src/workflow/skills.ts +90 -0
  184. package/src/workflow/vars.ts +33 -0
  185. package/src/workflows/feature/feature.test.ts +27 -33
  186. package/src/workflows/feature/index.ts +10 -0
  187. package/src/workflows/fix/fix.feature +80 -0
  188. package/src/workflows/fix/fix.test.ts +58 -78
  189. package/src/workflows/fix/index.ts +6 -0
  190. package/src/workflows/index.ts +2 -10
  191. package/src/workflows/review/index.ts +6 -0
  192. package/src/workflows/review/review.test.ts +85 -29
  193. package/src/workflows/review/review.ts +3 -1
  194. package/src/workflows/shared/config.fixture.ts +12 -0
  195. package/src/workflows/shared/reserved.ts +11 -0
  196. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,238 @@
1
+ import { execFileSync, execSync } from "node:child_process"
2
+ import { mkdtempSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join, resolve } from "node:path"
5
+ import { describe, expect, it } from "vitest"
6
+ import fc from "fast-check"
7
+ import {
8
+ abandonedOutcome,
9
+ abandonNoopOutcome,
10
+ abandonNoopText,
11
+ commitOutcome,
12
+ noteOutcome,
13
+ OUTCOME_MARKER,
14
+ renderFormat,
15
+ restoredOutcome,
16
+ transitionOutcome,
17
+ } from "./OutcomeScript.js"
18
+
19
+ const runShCheckSyntax = (script: string): number => {
20
+ try {
21
+ execFileSync("sh", ["-n"], { input: script })
22
+ return 0
23
+ } catch (error) {
24
+ return (error as { status?: number }).status ?? 1
25
+ }
26
+ }
27
+
28
+ describe("transitionOutcome", () => {
29
+ it("emits one printf naming both states", () => {
30
+ expect(transitionOutcome("plan.planning", "plan.await-plan")).toBe(
31
+ `${OUTCOME_MARKER}\nprintf '%s %s → %s\\n' '->' 'plan.planning' 'plan.await-plan'`,
32
+ )
33
+ })
34
+
35
+ it("is syntactically valid sh on its own — no preamble to define first", () => {
36
+ expect(runShCheckSyntax(transitionOutcome("a", "b"))).toBe(0)
37
+ })
38
+ })
39
+
40
+ describe("commitOutcome", () => {
41
+ it("emits one printf naming the subject", () => {
42
+ expect(commitOutcome("gtd(agent): x")).toBe(
43
+ `${OUTCOME_MARKER}\nprintf '%s %s\\n' '[commit]' 'gtd(agent): x'`,
44
+ )
45
+ })
46
+
47
+ it("is syntactically valid sh on its own", () => {
48
+ expect(runShCheckSyntax(commitOutcome("gtd(agent): x"))).toBe(0)
49
+ })
50
+ })
51
+
52
+ describe("noteOutcome", () => {
53
+ it("emits a single printf of the text", () => {
54
+ expect(noteOutcome('nothing to do at "idle"')).toBe(
55
+ `${OUTCOME_MARKER}\nprintf '%s\\n' 'nothing to do at "idle"'`,
56
+ )
57
+ })
58
+ })
59
+
60
+ describe("abandonedOutcome / restoredOutcome", () => {
61
+ it("resolves the short hash and subject in-script, since the reset has not happened yet when gtd emits this", () => {
62
+ expect(abandonedOutcome("build.fix", "abc123", "idle")).toContain(
63
+ "\"$(git rev-parse --short 'abc123')\"",
64
+ )
65
+ expect(abandonedOutcome("build.fix", "abc123", "idle")).toContain(
66
+ "\"$(git log -1 --format=%s 'abc123')\"",
67
+ )
68
+ })
69
+
70
+ it("both are syntactically valid sh on their own", () => {
71
+ expect(runShCheckSyntax(abandonedOutcome("build.fix", "abc123", "idle"))).toBe(0)
72
+ expect(runShCheckSyntax(restoredOutcome("abc123", "idle"))).toBe(0)
73
+ })
74
+ })
75
+
76
+ describe("abandonNoopOutcome", () => {
77
+ it("carries the same wording as abandonNoopText", () => {
78
+ expect(abandonNoopOutcome("idle")).toBe(noteOutcome(abandonNoopText("idle")))
79
+ })
80
+ })
81
+
82
+ describe("plain-text twins", () => {
83
+ it("abandonNoopText names the initial state", () => {
84
+ expect(abandonNoopText("idle")).toBe(
85
+ 'no gtd process is underway (resting at "idle") — nothing to abandon\n',
86
+ )
87
+ })
88
+ })
89
+
90
+ describe("renderFormat", () => {
91
+ it("substitutes %s placeholders in order", () => {
92
+ expect(renderFormat("%s and %s", "a", "b")).toBe("a and b")
93
+ })
94
+
95
+ it("leaves a literal % that isn't followed by s untouched", () => {
96
+ expect(renderFormat("100%% done, %s", "ok")).toBe("100%% done, ok")
97
+ })
98
+ })
99
+
100
+ /**
101
+ * The emitted statements carry no ANSI at all now, so there is no tty /
102
+ * `NO_COLOR` / `TERM=dumb` branch left to gate: output is byte-identical on a
103
+ * pipe and on a real terminal. `run-in-pty.py` is what proves that rather than
104
+ * assumes it — it allocates a throwaway pty pair, the one context where a
105
+ * colour branch (if one came back) would actually fire.
106
+ */
107
+ describe("no ANSI, on a pipe or a real tty", () => {
108
+ const PTY_RUNNER = resolve(import.meta.dirname, "../../tests/tooling/support/run-in-pty.py")
109
+ const ESC = "\x1b"
110
+
111
+ const initRepo = (dir: string): void => {
112
+ execFileSync("git", ["init", "-q"], { cwd: dir })
113
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir })
114
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: dir })
115
+ execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: dir })
116
+ execFileSync("git", ["commit", "-q", "--allow-empty", "-m", "gtd(human): idle"], { cwd: dir })
117
+ }
118
+
119
+ it("emits no escape byte under a real tty with a colour-capable TERM", () => {
120
+ const dir = mkdtempSync(join(tmpdir(), "outcome-pty-"))
121
+ initRepo(dir)
122
+ const script = commitOutcome("gtd(human): idle")
123
+ const runOnce = (): string =>
124
+ execFileSync("python3", [PTY_RUNNER, `cd ${JSON.stringify(dir)} && ${script}`], {
125
+ encoding: "utf8",
126
+ env: { PATH: process.env.PATH ?? "", TERM: "xterm" },
127
+ })
128
+ // A real pty allocation under severe host-wide CPU contention (many
129
+ // concurrent `npm test` runs across sibling worktrees) can rarely come
130
+ // back with zero captured bytes despite the child exiting cleanly — an
131
+ // OS/scheduler-level hiccup in the fork-exec-pty path itself, not a bug
132
+ // in `run-in-pty.py`'s own read loop (which blocks to real EOF, see its
133
+ // own comment) or in this repo's code. Retrying absorbs that one-off
134
+ // without weakening the assertion: a genuine regression still reproduces
135
+ // every attempt.
136
+ let out = runOnce()
137
+ for (let attempt = 0; attempt < 2 && out.length === 0; attempt++) out = runOnce()
138
+ expect(out).not.toContain(ESC)
139
+ expect(out).toContain("[commit] gtd(human): idle")
140
+ })
141
+
142
+ it("contains no ANSI source text either — nothing for a driver's stdout to leak", () => {
143
+ for (const statement of [
144
+ transitionOutcome("a", "b"),
145
+ commitOutcome("gtd(human): x"),
146
+ noteOutcome("ok"),
147
+ abandonedOutcome("build.fix", "abc123", "idle"),
148
+ restoredOutcome("abc123", "idle"),
149
+ ]) {
150
+ expect(statement).not.toContain("\\033[")
151
+ expect(statement).not.toContain("$'")
152
+ }
153
+ })
154
+
155
+ it("contains no bash-only local declarations or process substitution", () => {
156
+ const statement = commitOutcome("gtd(human): x")
157
+ expect(statement).not.toContain(" local ")
158
+ expect(statement).not.toContain("<(")
159
+ })
160
+ })
161
+
162
+ describe("%-safety and quoting", () => {
163
+ it("a subject containing %s survives commitOutcome round-tripped through printf", () => {
164
+ const tricky = "gtd(agent): 50%s off deal's price"
165
+ const dir = mkdtempSync(join(tmpdir(), "outcome-safety-"))
166
+ execFileSync("git", ["init", "-q"], { cwd: dir })
167
+ const out = execSync("sh", { input: commitOutcome(tricky), cwd: dir, encoding: "utf8" })
168
+ expect(out).toContain(tricky)
169
+ })
170
+
171
+ // 120s, not the default 30s: each of the 200 `fc.assert` runs below spawns
172
+ // a REAL `sh` subprocess via `execSync` — shell startup overhead alone
173
+ // pushes 200 sequential spawns past 30s on a loaded machine, with nothing
174
+ // about the test itself hanging (it reliably completes, just slowly).
175
+ it("round-trips arbitrary subjects (including % and quotes) through noteOutcome", () => {
176
+ fc.assert(
177
+ fc.property(
178
+ fc.string({ unit: "binary" }).filter((s) => !s.includes("\0") && !s.includes("\n")),
179
+ (subject) => {
180
+ const out = execSync("sh", { input: noteOutcome(subject), encoding: "utf8" })
181
+ expect(out).toBe(`${subject}\n`)
182
+ },
183
+ ),
184
+ { numRuns: 200 },
185
+ )
186
+ }, 120_000)
187
+ })
188
+
189
+ describe("abandonedOutcome / restoredOutcome — real repo", () => {
190
+ const initRepo = (): string => {
191
+ const dir = mkdtempSync(join(tmpdir(), "outcome-abandon-"))
192
+ execFileSync("git", ["init", "-q"], { cwd: dir })
193
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir })
194
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: dir })
195
+ execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: dir })
196
+ execFileSync("git", ["commit", "-q", "--allow-empty", "-m", "gtd(human): idle"], { cwd: dir })
197
+ return dir
198
+ }
199
+
200
+ it("resolves the short hash/subject post-hoc from the given commitish", () => {
201
+ const dir = initRepo()
202
+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim()
203
+ const short = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
204
+ cwd: dir,
205
+ encoding: "utf8",
206
+ }).trim()
207
+ const out = execSync("sh", {
208
+ input: abandonedOutcome("build.fix", head, "idle"),
209
+ cwd: dir,
210
+ encoding: "utf8",
211
+ })
212
+ expect(out).toBe(
213
+ `abandoned the process resting at "build.fix" — HEAD is back at ${short} ` +
214
+ '("gtd(human): idle"), resting at "idle".\n' +
215
+ "Everything the process produced is kept as uncommitted changes (`git status`); " +
216
+ "discard them with `git checkout -- . && git clean -fd .gtd` for a clean tree.\n",
217
+ )
218
+ })
219
+
220
+ it("restoredOutcome resolves the short hash/subject post-hoc from the given commitish", () => {
221
+ const dir = initRepo()
222
+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim()
223
+ const short = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
224
+ cwd: dir,
225
+ encoding: "utf8",
226
+ }).trim()
227
+ const out = execSync("sh", {
228
+ input: restoredOutcome(head, "await-review"),
229
+ cwd: dir,
230
+ encoding: "utf8",
231
+ })
232
+ expect(out).toBe(
233
+ `restored the retained history — HEAD is back at ${short} ("gtd(human): idle"), ` +
234
+ 'resting at "await-review". Resume with the loop, or `git reset` to any earlier ' +
235
+ "turn to restart from there.\n",
236
+ )
237
+ })
238
+ })
@@ -0,0 +1,73 @@
1
+ import { shellQuote } from "./GitScript.js"
2
+ import { renderFormat } from "../wire/index.js"
3
+
4
+ export { renderFormat }
5
+
6
+ /**
7
+ * A `printf '<fmt>' <args...>` bash statement — `args` are already-valid bash
8
+ * tokens (a `shellQuote`d literal, a `"$(git …)"` substitution), never
9
+ * re-quoted here: a value reaches the script as a printf ARGUMENT, never
10
+ * interpolated into the format itself, so a subject or state name containing
11
+ * `%` can never be read as a conversion spec. Markers like `->` are arguments
12
+ * too, keeping every format string free of a leading `-` that a shell's
13
+ * `printf` could mistake for an option. A real newline in `fmt` is emitted as
14
+ * the two-character `\n` escape `printf` itself expands, keeping every emitted
15
+ * statement on one line.
16
+ */
17
+ const printfLine = (fmt: string, args: readonly string[]): string =>
18
+ `${OUTCOME_MARKER}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`
19
+
20
+ /**
21
+ * Marks an emitted block as print-only. Load-bearing beyond documentation:
22
+ * `src/testkit/EmittedScriptRecognizer.ts` recognizes an outcome block by
23
+ * this line, and a bare `printf` would be ambiguous with a workflow
24
+ * `script:` command that happens to start the same way.
25
+ */
26
+ export const OUTCOME_MARKER = "# gtd: outcome (print-only)"
27
+
28
+ const FMT_ABANDON_NOOP = 'no gtd process is underway (resting at "%s") — nothing to abandon\n'
29
+ const FMT_ABANDONED =
30
+ 'abandoned the process resting at "%s" — HEAD is back at %s ("%s"), resting at "%s".\n' +
31
+ "Everything the process produced is kept as uncommitted changes (`git status`); " +
32
+ "discard them with `git checkout -- . && git clean -fd .gtd` for a clean tree.\n"
33
+ const FMT_RESTORED =
34
+ 'restored the retained history — HEAD is back at %s ("%s"), resting at "%s". Resume ' +
35
+ "with the loop, or `git reset` to any earlier turn to restart from there.\n"
36
+
37
+ /** `no gtd process is underway (resting at "<initial>") — nothing to abandon` — `gtd abandon`'s no-op plain-text line. */
38
+ export const abandonNoopText = (initial: string): string => renderFormat(FMT_ABANDON_NOOP, initial)
39
+
40
+ /** A landed self-loop/target-change transition. */
41
+ export const transitionOutcome = (from: string, to: string): string =>
42
+ printfLine("%s %s → %s\n", ["'->'", shellQuote(from), shellQuote(to)])
43
+
44
+ /** A bare capture (a self-loop commit). */
45
+ export const commitOutcome = (subject: string): string =>
46
+ printfLine("%s %s\n", ["'[commit]'", shellQuote(subject)])
47
+
48
+ /** One already-rendered plain line. */
49
+ export const noteOutcome = (text: string): string => printfLine("%s\n", [shellQuote(text)])
50
+
51
+ /**
52
+ * Resolves the post-hoc short hash/subject from `head` (a commitish) in-script
53
+ * — this line runs AFTER the reset that made `head` the new HEAD, so neither
54
+ * value is knowable when gtd generates it.
55
+ */
56
+ export const abandonedOutcome = (from: string, head: string, state: string): string =>
57
+ printfLine(FMT_ABANDONED, [
58
+ shellQuote(from),
59
+ `"$(git rev-parse --short ${shellQuote(head)})"`,
60
+ `"$(git log -1 --format=%s ${shellQuote(head)})"`,
61
+ shellQuote(state),
62
+ ])
63
+
64
+ /** `gtd abandon`'s no-op outcome — the same wording `abandonNoopText` renders. */
65
+ export const abandonNoopOutcome = (initial: string): string => noteOutcome(abandonNoopText(initial))
66
+
67
+ /** Resolves the post-hoc short hash/subject from `to` (a commitish) in-script, for the same reason `abandonedOutcome` does. */
68
+ export const restoredOutcome = (to: string, state: string): string =>
69
+ printfLine(FMT_RESTORED, [
70
+ `"$(git rev-parse --short ${shellQuote(to)})"`,
71
+ `"$(git log -1 --format=%s ${shellQuote(to)})"`,
72
+ shellQuote(state),
73
+ ])
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { Effect } from "effect"
3
+ import { resolveMode, validateScriptFor, type ModeResolution } from "./SteeringMode.js"
4
+ import { Host } from "../platform/index.js"
5
+ import { steeringFormatFor } from "../steering/index.js"
6
+ import { seededValidateCommand, type WorkflowDefinition } from "../workflow/index.js"
7
+
8
+ const QA_FORMAT = steeringFormatFor("qa")!
9
+ const REVIEW_FORMAT = steeringFormatFor("review")!
10
+
11
+ type ModesDef = Pick<WorkflowDefinition, "modes">
12
+
13
+ const commandsDef = (modes: WorkflowDefinition["modes"]): ModesDef => ({ modes })
14
+
15
+ const envVarsLayer = Host.layer({ root: "/repo", home: "/repo", env: { PATH: "/usr/bin:/bin" } })
16
+
17
+ const runScript = (resolved: ModeResolution, file: string) => {
18
+ if (resolved.kind !== "resolved") throw new Error("expected a resolved mode")
19
+ return Effect.runPromise(validateScriptFor(resolved, file).pipe(Effect.provide(envVarsLayer)))
20
+ }
21
+
22
+ describe("resolveMode", () => {
23
+ it("resolves the two built-in names to their in-process validator, their format, and to no formatter", () => {
24
+ const def: ModesDef = { modes: {} }
25
+ expect(resolveMode(def, "drafting", "qa")).toEqual({
26
+ kind: "resolved",
27
+ mode: "qa",
28
+ format: QA_FORMAT,
29
+ validate: { kind: "builtin", format: QA_FORMAT },
30
+ capabilities: { format: QA_FORMAT, liveValidate: QA_FORMAT.validate },
31
+ })
32
+ expect(resolveMode(def, "drafting", "review")).toEqual({
33
+ kind: "resolved",
34
+ mode: "review",
35
+ format: REVIEW_FORMAT,
36
+ validate: { kind: "builtin", format: REVIEW_FORMAT },
37
+ capabilities: { format: REVIEW_FORMAT, liveValidate: REVIEW_FORMAT.validate },
38
+ })
39
+ })
40
+
41
+ it("resolves a declared mode to its commands", () => {
42
+ const def = commandsDef({ adr: { validate: "adr-lint $GTD_FILE" } })
43
+ expect(resolveMode(def, "drafting", "adr")).toEqual({
44
+ kind: "resolved",
45
+ mode: "adr",
46
+ validate: { kind: "command", command: "adr-lint $GTD_FILE" },
47
+ capabilities: { externalValidate: true },
48
+ })
49
+ })
50
+
51
+ it("adds a formatter to a built-in WITHOUT displacing its validation", () => {
52
+ const def = commandsDef({ qa: { format: "npx prettier --write $GTD_FILE" } })
53
+ expect(resolveMode(def, "drafting", "qa")).toEqual({
54
+ kind: "resolved",
55
+ mode: "qa",
56
+ format: QA_FORMAT,
57
+ formatCommand: "npx prettier --write $GTD_FILE",
58
+ validate: { kind: "builtin", format: QA_FORMAT },
59
+ capabilities: { format: QA_FORMAT, liveValidate: QA_FORMAT.validate },
60
+ })
61
+ })
62
+
63
+ it("lets a declared `validate:` override a built-in's parser, WITHOUT losing the format identity", () => {
64
+ const def = commandsDef({ qa: { validate: "my-qa-linter $GTD_FILE" } })
65
+ expect(resolveMode(def, "drafting", "qa")).toEqual({
66
+ kind: "resolved",
67
+ mode: "qa",
68
+ format: QA_FORMAT,
69
+ validate: { kind: "command", command: "my-qa-linter $GTD_FILE" },
70
+ capabilities: { format: QA_FORMAT, externalValidate: true },
71
+ })
72
+ })
73
+
74
+ it("resolves a non-built-in mode with only a `format:` to no validator at all", () => {
75
+ const def = commandsDef({ adr: { format: "fmt $GTD_FILE" } })
76
+ expect(resolveMode(def, "drafting", "adr")).toEqual({
77
+ kind: "resolved",
78
+ mode: "adr",
79
+ formatCommand: "fmt $GTD_FILE",
80
+ capabilities: {},
81
+ })
82
+ })
83
+
84
+ it("resolves nothing for an undefined mode, and names what IS known", () => {
85
+ const def = commandsDef({ adr: { validate: "adr-lint" } })
86
+ expect(resolveMode(def, "drafting", "nope")).toEqual({
87
+ kind: "unknown",
88
+ message:
89
+ 'state "drafting": mode "nope" is not defined by the active workflow (known modes: adr)',
90
+ })
91
+ })
92
+
93
+ it("resolves nothing for `prose` with no declared entry — it is not in the built-in registry", () => {
94
+ const def: ModesDef = { modes: {} }
95
+ expect(resolveMode(def, "drafting", "prose").kind).toBe("unknown")
96
+ })
97
+
98
+ it("resolves a declared `prose` entry with only a `format:` to a formatter-only, validator-less mode", () => {
99
+ const def = commandsDef({ prose: { format: "npx prettier --write $GTD_FILE" } })
100
+ expect(resolveMode(def, "drafting", "prose")).toEqual({
101
+ kind: "resolved",
102
+ mode: "prose",
103
+ formatCommand: "npx prettier --write $GTD_FILE",
104
+ capabilities: {},
105
+ })
106
+ })
107
+
108
+ it("resolves the two built-in names against the registry alone, with no definition (`def: undefined`) — the LSP's basename fallback", () => {
109
+ expect(resolveMode(undefined, "", "qa")).toEqual({
110
+ kind: "resolved",
111
+ mode: "qa",
112
+ format: QA_FORMAT,
113
+ validate: { kind: "builtin", format: QA_FORMAT },
114
+ capabilities: { format: QA_FORMAT, liveValidate: QA_FORMAT.validate },
115
+ })
116
+ expect(resolveMode(undefined, "", "review")).toEqual({
117
+ kind: "resolved",
118
+ mode: "review",
119
+ format: REVIEW_FORMAT,
120
+ validate: { kind: "builtin", format: REVIEW_FORMAT },
121
+ capabilities: { format: REVIEW_FORMAT, liveValidate: REVIEW_FORMAT.validate },
122
+ })
123
+ })
124
+
125
+ it("resolves nothing for a name the registry doesn't know, with no definition", () => {
126
+ expect(resolveMode(undefined, "", "prose").kind).toBe("unknown")
127
+ expect(resolveMode(undefined, "", "adr").kind).toBe("unknown")
128
+ })
129
+
130
+ it("keeps liveValidate (using format.validate) and drops externalValidate when a built-in mode's declared `validate:` IS its own seeded command", () => {
131
+ const def = commandsDef({ qa: { validate: seededValidateCommand("qa") } })
132
+ const resolved = resolveMode(def, "drafting", "qa")
133
+ expect(resolved).toEqual({
134
+ kind: "resolved",
135
+ mode: "qa",
136
+ format: QA_FORMAT,
137
+ validate: { kind: "command", command: seededValidateCommand("qa") },
138
+ capabilities: { format: QA_FORMAT, liveValidate: QA_FORMAT.validate },
139
+ })
140
+ })
141
+
142
+ it("recognizes the seeded command for `review` too, keyed to its own mode name", () => {
143
+ const def = commandsDef({ review: { validate: seededValidateCommand("review") } })
144
+ const resolved = resolveMode(def, "drafting", "review")
145
+ if (resolved.kind !== "resolved") throw new Error("expected resolved")
146
+ expect(resolved.capabilities.format).toBe(REVIEW_FORMAT)
147
+ expect(resolved.capabilities.liveValidate).toBe(REVIEW_FORMAT.validate)
148
+ expect(resolved.capabilities.externalValidate).toBeUndefined()
149
+ })
150
+
151
+ it("still reports externalValidate for a genuine user override even when the command text merely resembles the seeded one", () => {
152
+ const def = commandsDef({ qa: { validate: `${seededValidateCommand("qa")} --extra` } })
153
+ const resolved = resolveMode(def, "drafting", "qa")
154
+ if (resolved.kind !== "resolved") throw new Error("expected resolved")
155
+ expect(resolved.capabilities.format).toBe(QA_FORMAT)
156
+ expect(resolved.capabilities.liveValidate).toBeUndefined()
157
+ expect(resolved.capabilities.externalValidate).toBe(true)
158
+ })
159
+ })
160
+
161
+ describe("validateScriptFor — built-in modes", () => {
162
+ it("`qa` embeds a `gtd check` invocation, never a bare execution", async () => {
163
+ const resolved = resolveMode(undefined, "", "qa")
164
+ const { script } = await runScript(resolved, ".gtd/TODO.md")
165
+ expect(script).toContain(".gtd/TODO.md")
166
+ expect(script).not.toMatch(/^\s*$/)
167
+ })
168
+
169
+ it("a format-only mode with no validator emits just the file-existence guard", async () => {
170
+ const def = commandsDef({ adr: { format: "true" } })
171
+ const resolved = resolveMode(def, "drafting", "adr")
172
+ if (resolved.kind !== "resolved") throw new Error("expected resolved")
173
+ const { script } = await runScript(resolved, "docs/adr.md")
174
+ expect(script).toContain("docs/adr.md")
175
+ })
176
+ })
177
+
178
+ describe("validateScriptFor — a workflow-declared command", () => {
179
+ it("runs the validate command with $GTD_FILE set to the file, wrapped with the fix-prompt instruction", async () => {
180
+ const def = commandsDef({ adr: { validate: 'echo "saw $GTD_FILE"; exit 1' } })
181
+ const resolved = resolveMode(def, "drafting", "adr")
182
+ const { script } = await runScript(resolved, "docs/adr.md")
183
+ expect(script).toContain("export GTD_FILE='docs/adr.md'\necho \"saw $GTD_FILE\"; exit 1")
184
+ expect(script).toContain("Fix these format violations in docs/adr.md")
185
+ })
186
+
187
+ it("runs format THEN validate, in that order", async () => {
188
+ const def = commandsDef({
189
+ adr: {
190
+ format: "fmt $GTD_FILE",
191
+ validate: "adr-lint $GTD_FILE",
192
+ },
193
+ })
194
+ const resolved = resolveMode(def, "drafting", "adr")
195
+ const { script } = await runScript(resolved, "docs/adr.md")
196
+ expect(script.indexOf("fmt $GTD_FILE")).toBeGreaterThanOrEqual(0)
197
+ expect(script.indexOf("fmt $GTD_FILE")).toBeLessThan(script.indexOf("adr-lint $GTD_FILE"))
198
+ })
199
+ })
200
+
201
+ describe("validateScriptFor — the binary-presence guard", () => {
202
+ it("emits a binaryGuard immediately ahead of a plain format: command, naming ITS OWN leading word", async () => {
203
+ const def = commandsDef({ adr: { format: "adr-fmt $GTD_FILE" } })
204
+ const resolved = resolveMode(def, "drafting", "adr")
205
+ const { script } = await runScript(resolved, "docs/adr.md")
206
+ const guardIndex = script.indexOf(`command -v 'adr-fmt'`)
207
+ const commandIndex = script.indexOf("adr-fmt $GTD_FILE")
208
+ expect(guardIndex).toBeGreaterThanOrEqual(0)
209
+ expect(guardIndex).toBeLessThan(commandIndex)
210
+ expect(script).toContain('mode "adr": "format" command not found: adr-fmt')
211
+ })
212
+
213
+ it("emits a binaryGuard immediately ahead of a plain validate: command, naming ITS OWN leading word", async () => {
214
+ const def = commandsDef({ adr: { validate: "adr-lint $GTD_FILE" } })
215
+ const resolved = resolveMode(def, "drafting", "adr")
216
+ const { script } = await runScript(resolved, "docs/adr.md")
217
+ const guardIndex = script.indexOf(`command -v 'adr-lint'`)
218
+ const commandIndex = script.indexOf("adr-lint $GTD_FILE")
219
+ expect(guardIndex).toBeGreaterThanOrEqual(0)
220
+ expect(guardIndex).toBeLessThan(commandIndex)
221
+ expect(script).toContain('mode "adr": "validate" command not found: adr-lint')
222
+ })
223
+
224
+ it("guards format AND validate independently, each naming its OWN binary", async () => {
225
+ const def = commandsDef({
226
+ adr: { format: "adr-fmt $GTD_FILE", validate: "adr-lint $GTD_FILE" },
227
+ })
228
+ const resolved = resolveMode(def, "drafting", "adr")
229
+ const { script } = await runScript(resolved, "docs/adr.md")
230
+ expect(script).toContain('mode "adr": "format" command not found: adr-fmt')
231
+ expect(script).toContain('mode "adr": "validate" command not found: adr-lint')
232
+ })
233
+
234
+ it("emits no guard at all for a pipeline command", async () => {
235
+ const def = commandsDef({ adr: { validate: "adr-lint $GTD_FILE | tee /dev/null" } })
236
+ const resolved = resolveMode(def, "drafting", "adr")
237
+ const { script } = await runScript(resolved, "docs/adr.md")
238
+ expect(script).not.toContain("command -v")
239
+ })
240
+
241
+ it("emits no guard at all for a VAR=x-prefixed command", async () => {
242
+ const def = commandsDef({ adr: { validate: "FOO=1 adr-lint $GTD_FILE" } })
243
+ const resolved = resolveMode(def, "drafting", "adr")
244
+ const { script } = await runScript(resolved, "docs/adr.md")
245
+ expect(script).not.toContain("command -v")
246
+ })
247
+ })
248
+
249
+ describe("validateScriptFor — the mode/format contradiction round-trip", () => {
250
+ it("embeds a contradiction round-trip block when a built-in mode gets its own formatter", async () => {
251
+ const def = commandsDef({ qa: { format: "npx prettier --write $GTD_FILE" } })
252
+ const resolved = resolveMode(def, "drafting", "qa")
253
+ const { script } = await runScript(resolved, ".gtd/TODO.md")
254
+ expect(script).toContain("gtd check qa")
255
+ expect(script).toContain("CONFIGURATION BUG")
256
+ })
257
+
258
+ it("prints a skip notice instead of a round-trip when the validator is external", async () => {
259
+ const def = commandsDef({
260
+ adr: { format: "fmt $GTD_FILE", validate: "adr-lint $GTD_FILE" },
261
+ })
262
+ const resolved = resolveMode(def, "drafting", "adr")
263
+ const { script } = await runScript(resolved, "docs/adr.md")
264
+ expect(script).toContain("skipping the format/validate contradiction check")
265
+ })
266
+
267
+ it("has no round-trip at all when there is no `format:` command", async () => {
268
+ const def = commandsDef({ adr: { validate: "adr-lint $GTD_FILE" } })
269
+ const resolved = resolveMode(def, "drafting", "adr")
270
+ const { script } = await runScript(resolved, "docs/adr.md")
271
+ expect(script).not.toContain("CONFIGURATION BUG")
272
+ expect(script).not.toContain("skipping the format/validate contradiction check")
273
+ })
274
+ })