@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
package/src/cli/Cli.ts ADDED
@@ -0,0 +1,1348 @@
1
+ import { NodeContext } from "@effect/platform-node"
2
+ import { Cause, Effect, Either, Layer } from "effect"
3
+ import {
4
+ GtdUsageError,
5
+ Narrator,
6
+ renderFailure,
7
+ GitService,
8
+ Host,
9
+ Workspace,
10
+ CommandRunner,
11
+ } from "../platform/index.js"
12
+ import { GTD_VERSION } from "../install/index.js"
13
+ import {
14
+ ConfigDiscovery,
15
+ ConfigService,
16
+ isSettingName,
17
+ SETTING_NAME_RULE,
18
+ } from "../workflow/index.js"
19
+ // The shared command types live in `Program.ts`, which imports nothing from
20
+ // here, so this dependency stays one-directional.
21
+ import {
22
+ runCommand,
23
+ SelectorUsageError,
24
+ type ArtifactOut,
25
+ type Command,
26
+ type CommandRequirements,
27
+ type JsonMode,
28
+ } from "./Program.js"
29
+ import type { ToolCommands } from "./ToolCommands.js"
30
+ import { EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
31
+
32
+ export type { Command, CommandRequirements } from "./Program.js"
33
+ export { needsOf, standaloneKinds } from "./Program.js"
34
+
35
+ export type CliPlan =
36
+ | { readonly kind: "output"; readonly stdout: string }
37
+ | {
38
+ readonly kind: "usage"
39
+ readonly stdout: string
40
+ readonly message: string
41
+ readonly json: boolean
42
+ }
43
+ | {
44
+ readonly kind: "command"
45
+ readonly command: Command
46
+ readonly json: JsonMode
47
+ /** Whether `--verbose`/`-v` was present — gates the `Narrator` `Cli.ts` builds for this dispatch (see `runCli`). */
48
+ readonly verbose: boolean
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Flag table
53
+ // ---------------------------------------------------------------------------
54
+
55
+ type FlagScope = (kind: Command["kind"] | undefined) => boolean
56
+
57
+ interface FlagRow {
58
+ readonly name: string
59
+ /**
60
+ * `"optional"` is a third generic mode: a value only from the `=` form
61
+ * (`--json=<path>`), never consuming a following token — bare `--json`
62
+ * records presence with no value. See `tokenize`'s single generic branch.
63
+ */
64
+ readonly arity: 0 | 1 | "optional"
65
+ readonly repeatable: boolean
66
+ readonly scope: FlagScope
67
+ /** All raw occurrences (already de-`=`-ed) → the decoded value, or an error message. */
68
+ readonly decode: (raws: readonly string[]) => Either.Either<unknown, string>
69
+ readonly scopeError: string
70
+ readonly valueHint: string
71
+ /** Pre-wrapped help lines (data, not generated prose) — see `renderHelp`. */
72
+ readonly help: readonly string[]
73
+ /** Flag names this one may never appear alongside — checked generically after parsing (see `conflictViolation`), never as a bespoke `if`. Defaults to none. */
74
+ readonly conflicts?: readonly string[]
75
+ }
76
+
77
+ const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, string> => {
78
+ const n = Number(raw)
79
+ if (raw.trim() === "" || !Number.isFinite(n) || n < 0) {
80
+ return Either.left(`gtd: ${flag} must be a non-negative number — got "${raw}"`)
81
+ }
82
+ return Either.right(n)
83
+ }
84
+
85
+ const FLAGS: readonly FlagRow[] = [
86
+ {
87
+ name: "--json",
88
+ arity: "optional",
89
+ repeatable: false,
90
+ scope: (kind) =>
91
+ kind === "next" ||
92
+ kind === "land" ||
93
+ kind === "judge" ||
94
+ kind === "judgeAnswer" ||
95
+ kind === "doors",
96
+ decode: ([raw]) => Either.right(raw ?? ""),
97
+ scopeError:
98
+ "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors` — " +
99
+ "every other command prints plain text; see `gtd install` for the driver protocol briefing",
100
+ valueHint: "<path>",
101
+ help: [
102
+ "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors only) output",
103
+ "structured JSON. Bare --json prints the whole document;",
104
+ "--json=<path> (a dotted key path into that document, e.g.",
105
+ "kind, content, session.id) prints just that value: a",
106
+ "scalar raw and unquoted, a boolean as true/false, a list",
107
+ "one JSON entry per line — a numeric segment (e.g.",
108
+ "skills.0) indexes into one entry of a list field instead.",
109
+ "An absent optional field prints nothing and exits 0 —",
110
+ "including when an earlier segment of <path> is itself",
111
+ "absent/null (e.g. session.id at a non-prompt rest), which",
112
+ "never counts as unknown; an unknown path is a usage error",
113
+ "(exit 2).",
114
+ ],
115
+ },
116
+ {
117
+ name: "--port",
118
+ arity: 1,
119
+ repeatable: false,
120
+ scope: (kind) => kind === "ui",
121
+ decode: ([raw]) => {
122
+ const n = Number(raw)
123
+ return raw !== undefined && Number.isInteger(n) && n >= 0 && n <= 65535
124
+ ? Either.right(n)
125
+ : Either.left(`gtd: --port must be an integer 0–65535 (got '${raw ?? ""}')`)
126
+ },
127
+ scopeError: "gtd: --port is only valid for `gtd ui`",
128
+ valueHint: "<n>",
129
+ help: [
130
+ "(gtd ui only) the tailscale serve port (default: the first",
131
+ "free of 8443, 10000, 443), or the bind port when --host",
132
+ "opts out of serve (default: a free port)",
133
+ ],
134
+ },
135
+ {
136
+ name: "--host",
137
+ arity: 1,
138
+ repeatable: false,
139
+ scope: (kind) => kind === "ui",
140
+ decode: ([raw]) =>
141
+ raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
142
+ ? Either.left("gtd: --host must be a non-empty, single-line value")
143
+ : Either.right(raw),
144
+ scopeError: "gtd: --host is only valid for `gtd ui`",
145
+ valueHint: "<addr>",
146
+ help: [
147
+ "(gtd ui only) opt out of the default tailscale serve front",
148
+ "door and bind this address directly instead, showing it in",
149
+ "the printed URL",
150
+ ],
151
+ },
152
+ {
153
+ name: "--self-signed",
154
+ arity: 0,
155
+ repeatable: false,
156
+ scope: (kind) => kind === "ui",
157
+ decode: () => Either.right(true),
158
+ scopeError: "gtd: --self-signed is only valid for `gtd ui`",
159
+ valueHint: "",
160
+ help: [
161
+ "(gtd ui only) generate a throwaway self-signed TLS",
162
+ "certificate instead of the configured ui.cert/ui.key",
163
+ ],
164
+ },
165
+ {
166
+ name: "--dev",
167
+ arity: 0,
168
+ repeatable: false,
169
+ scope: (kind) => kind === "ui",
170
+ decode: () => Either.right(true),
171
+ scopeError: "gtd: --dev is only valid for `gtd ui`",
172
+ valueHint: "",
173
+ help: ["(gtd ui only) run against local development sources", "instead of the packaged build"],
174
+ },
175
+ {
176
+ name: "--provider",
177
+ arity: 1,
178
+ repeatable: false,
179
+ scope: (kind) => kind === "judgeRun",
180
+ decode: ([raw]) =>
181
+ raw === "fixed" || raw === "jev" || raw === "llm"
182
+ ? Either.right(raw)
183
+ : Either.left(`gtd: --provider must be one of fixed, jev, llm — got "${raw ?? ""}"`),
184
+ scopeError: "gtd: --provider is only valid for `gtd judge run`",
185
+ valueHint: "<name>",
186
+ help: [
187
+ "(gtd judge run only) the answerer: fixed | jev | llm;",
188
+ "omitted: GTD_JUDGE_PROVIDER, then judge.json, then jev",
189
+ "when TYPESAFE_API_KEY is set, else llm",
190
+ ],
191
+ },
192
+ {
193
+ name: "--answers",
194
+ arity: 1,
195
+ repeatable: false,
196
+ scope: (kind) => kind === "judgeRun",
197
+ decode: ([raw]) =>
198
+ raw === undefined || raw === ""
199
+ ? Either.left("gtd: --answers must be a non-empty path")
200
+ : Either.right(raw),
201
+ scopeError: "gtd: --answers is only valid for `gtd judge run`",
202
+ valueHint: "<path>",
203
+ help: [
204
+ "(gtd judge run --provider fixed only) a JSON verdict array",
205
+ "[{ id, answer, p }]; else the GTD_JUDGE_ANSWERS env var",
206
+ "(inline JSON) is read",
207
+ ],
208
+ },
209
+ {
210
+ name: "--cost",
211
+ arity: 1,
212
+ repeatable: false,
213
+ scope: (kind) => kind === "land",
214
+ decode: ([raw]) => nonNegativeNumber(raw ?? "", "--cost"),
215
+ scopeError: "gtd: --cost is only valid for `gtd land` — a start is not a metered agent turn",
216
+ valueHint: "<n>",
217
+ help: ["(gtd land only) record the invocation's token cost"],
218
+ },
219
+ {
220
+ name: "--model",
221
+ arity: 1,
222
+ repeatable: false,
223
+ scope: (kind) => kind === "land" || kind === "judgeRun",
224
+ decode: ([raw]) =>
225
+ raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
226
+ ? Either.left("gtd: --model must be a non-empty, single-line value")
227
+ : Either.right(raw),
228
+ scopeError: "gtd: --model is only valid for `gtd land` (with --cost) or `gtd judge run`",
229
+ valueHint: "<name>",
230
+ help: [
231
+ "(gtd land, with --cost) tag that cost's model",
232
+ "(gtd judge run, llm answerer) the claude model; omitted:",
233
+ "GTD_JUDGE_MODEL, then judge.json, then haiku",
234
+ ],
235
+ },
236
+ {
237
+ name: "--workflow",
238
+ arity: 1,
239
+ repeatable: false,
240
+ scope: (kind) => kind === "start",
241
+ decode: ([raw]) => Either.right(raw ?? ""),
242
+ scopeError:
243
+ "gtd: --workflow is only valid with no other command — use the bare `gtd --workflow <name>` " +
244
+ "form; landing and starting are different verbs",
245
+ valueHint: "<name>",
246
+ help: [
247
+ "(with no command at all) start a brand new process on",
248
+ "the named workflow — authenticated as human",
249
+ ],
250
+ },
251
+ {
252
+ name: "--var",
253
+ arity: 1,
254
+ repeatable: true,
255
+ scope: (kind) => kind === "start",
256
+ decode: (raws) => {
257
+ const vars: Record<string, string> = {}
258
+ const seen = new Set<string>()
259
+ for (const raw of raws) {
260
+ const eq = raw.indexOf("=")
261
+ if (eq <= 0) {
262
+ return Either.left(
263
+ `gtd: --var must be <name>=<value> with a non-empty name — got "${raw}"`,
264
+ )
265
+ }
266
+ const name = raw.slice(0, eq)
267
+ const value = raw.slice(eq + 1)
268
+ if (!isSettingName(name)) {
269
+ return Either.left(
270
+ `gtd: --var ${JSON.stringify(name)} is not a valid setting name — ${SETTING_NAME_RULE}`,
271
+ )
272
+ }
273
+ if (/[\r\n]/.test(value)) {
274
+ return Either.left(`gtd: --var ${name} must be a single-line value`)
275
+ }
276
+ if (seen.has(name)) {
277
+ return Either.left(`gtd: --var ${name} specified more than once`)
278
+ }
279
+ seen.add(name)
280
+ vars[name] = value
281
+ }
282
+ return Either.right(vars)
283
+ },
284
+ scopeError: "gtd: --var requires --workflow",
285
+ valueHint: "<name>=<value>",
286
+ help: [
287
+ "(with --workflow; repeatable) pin a process setting for the",
288
+ "new process; the name must already be declared by the",
289
+ "workflow's defaults (environment settings are not",
290
+ "pinnable)",
291
+ ],
292
+ },
293
+ {
294
+ name: "--open-questions",
295
+ arity: 0,
296
+ repeatable: false,
297
+ scope: (kind) => kind === "check",
298
+ decode: () => Either.right(true),
299
+ scopeError: "gtd: --open-questions is only valid for `gtd check`",
300
+ valueHint: "",
301
+ help: [
302
+ "(gtd check only) ignore <mode>'s structural findings and",
303
+ "instead run the qa open-questions predicate over <file>,",
304
+ "printing each unanswered question one per line and exiting",
305
+ "non-zero when any remain",
306
+ ],
307
+ },
308
+ {
309
+ name: "--open-threads",
310
+ arity: 0,
311
+ repeatable: false,
312
+ scope: (kind) => kind === "check",
313
+ decode: () => Either.right(true),
314
+ scopeError: "gtd: --open-threads is only valid for `gtd check`",
315
+ valueHint: "",
316
+ help: [
317
+ "(gtd check only) ignore <mode>'s structural findings and",
318
+ "instead list every open thread (a footnote whose last",
319
+ "entry is the agent's) over <file>, one per line with its",
320
+ "first H entry, exiting non-zero when any are open. With no",
321
+ "<mode>/<file>, lists code-comment threads in the files the",
322
+ "current process changed (read from its diff base, unlike",
323
+ "the rest of `gtd check`)",
324
+ ],
325
+ },
326
+ {
327
+ name: "--verbose",
328
+ arity: 0,
329
+ repeatable: false,
330
+ scope: () => true,
331
+ decode: () => Either.right(true),
332
+ scopeError: "gtd: --verbose is valid for every command — this error is unreachable",
333
+ valueHint: "",
334
+ help: [
335
+ "enable stderr narration for this invocation: which rest",
336
+ "resolved and how config resolved across layers. Aliased",
337
+ "to -v",
338
+ ],
339
+ },
340
+ ]
341
+
342
+ /**
343
+ * `-v` is `--verbose`'s single-dash alias (the only one today) — resolved to
344
+ * its canonical `--` name before flag-row lookup, so the rest of the
345
+ * tokenizer never distinguishes how a flag was spelled. A `Map`, not a plain
346
+ * object: an object literal inherits `Object.prototype`, so a positional
347
+ * argument that happens to spell an inherited property name (`"valueOf"`,
348
+ * `"toString"`, …) would otherwise resolve to that inherited FUNCTION instead
349
+ * of `undefined`.
350
+ */
351
+ const SINGLE_DASH_ALIASES: ReadonlyMap<string, string> = new Map([["-v", "--verbose"]])
352
+
353
+ const flagByToken = (token: string): FlagRow | undefined => {
354
+ const eq = token.indexOf("=")
355
+ const bareName = eq === -1 ? token : token.slice(0, eq)
356
+ return FLAGS.find((f) => f.name === bareName)
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Command table
361
+ // ---------------------------------------------------------------------------
362
+
363
+ type Arity =
364
+ | "none"
365
+ | { readonly name: string }
366
+ | { readonly names: readonly [string, string] }
367
+ // One required leading positional, then any number the command itself judges.
368
+ | { readonly name: string; readonly variadic: string }
369
+
370
+ interface CommandRow {
371
+ readonly token: string
372
+ readonly kind: Command["kind"]
373
+ readonly arity: Arity
374
+ readonly details: readonly string[]
375
+ }
376
+
377
+ const COMMAND_ROWS: readonly CommandRow[] = [
378
+ {
379
+ token: "land",
380
+ kind: "land",
381
+ arity: "none",
382
+ details: [
383
+ "Land whatever the tree now shows at the currently resolved",
384
+ "rest — a human capture, an agent/check turn, or an empty",
385
+ "attempt (a fruitless prompt turn). Pass",
386
+ "--cost=<n> (optionally --model=<name>) to record the",
387
+ "just-finished invocation's token cost and model on the",
388
+ "turn commit (summed into it.processCost/",
389
+ "processCostByModel). Plain (the default) prints one",
390
+ "human-readable sentence naming the commit subject (or a",
391
+ "no-op note) plus a pointer to `gtd land --json=script | sh`",
392
+ "to get the landing script — never the script itself.",
393
+ "--json emits script (the actual POSIX sh",
394
+ "a driver runs) alongside settled, idle, state (the",
395
+ "post-land target), subject, cost and model. Exits 0 on",
396
+ "success, 1 on any refusal — see the Exit codes section",
397
+ "below",
398
+ ],
399
+ },
400
+ {
401
+ token: "abandon",
402
+ kind: "abandon",
403
+ arity: "none",
404
+ details: [
405
+ "End the process currently underway without completing it:",
406
+ "rewind HEAD to the commit the process started from,",
407
+ "keeping everything it produced as uncommitted changes. A",
408
+ "no-op when no process is underway",
409
+ ],
410
+ },
411
+ {
412
+ token: "restore",
413
+ kind: "restore",
414
+ arity: "none",
415
+ details: [
416
+ "Hard-reset HEAD back to the tip retained by the last",
417
+ "abandon (refs/worktree/gtd/history), bringing back an",
418
+ "abandoned process's turns. Refuses on a dirty working",
419
+ "tree, when there is no retained history, or when HEAD has",
420
+ "advanced past the retained tip with commits that would be",
421
+ "lost",
422
+ ],
423
+ },
424
+ {
425
+ token: "next",
426
+ kind: "next",
427
+ arity: "none",
428
+ details: [
429
+ "Print the resolved rest's beat (no mutation, safe to",
430
+ "poll), in one of three encodings. Plain (the default): a",
431
+ "status summary, a blank line, then the step verbatim — at a",
432
+ "script/capture rest an instruction line ('Run this",
433
+ "script:' / 'The edit is already made — run `gtd land` to",
434
+ "land it.') precedes the status summary; at a prompt rest",
435
+ "it's the bare step (plus the self-validation instruction",
436
+ "when applicable) with no header at all, since those bytes",
437
+ "are the agent's own input. --json emits the one structured",
438
+ "surface gtd has: kind (capture|message|script|prompt|",
439
+ "stalled) selects what a driver does, content is what it",
440
+ "runs or shows, idle marks the workflow's initial state with",
441
+ "a clean tree, plus the prompt session, model, validate",
442
+ "script, log path, changes, next and the resting state's own",
443
+ "fields. --json=<path> (a dotted key path into that same",
444
+ "document, e.g. kind, content, session.id) prints just that",
445
+ "value instead of the whole document — see --json's own help",
446
+ "above. Exits 0 — see the Exit codes section below",
447
+ ],
448
+ },
449
+ {
450
+ token: "validate",
451
+ kind: "validate",
452
+ arity: "none",
453
+ details: [
454
+ "Print the script that formats (when declared) then",
455
+ "validates the resolved rest's steering file, using its",
456
+ "mode's commands (its file:/mode:), instead of running it —",
457
+ "a driver runs the script and reads the findings from its",
458
+ 'own exit code/output. Always exits 0; prints "nothing to',
459
+ 'validate" when the resolved rest declares nothing to run.',
460
+ "On a non-zero validate exit the emitted script prints a",
461
+ "ready-to-send fix prompt (instruction + findings) and",
462
+ "exits with the validator's own code",
463
+ ],
464
+ },
465
+ {
466
+ token: "lsp",
467
+ kind: "lsp",
468
+ arity: "none",
469
+ details: ["Start the LSP server for .gtd/ steering files (stdio)"],
470
+ },
471
+ {
472
+ token: "ui",
473
+ kind: "ui",
474
+ arity: "none",
475
+ details: [
476
+ "Expose gtd's web/phone client for THIS worktree — the",
477
+ "invoking directory, never a configured list of roots — and",
478
+ "refuses outside a repository like every other state command.",
479
+ "By default, publishes through `tailscale serve` (reachable",
480
+ "from anywhere on the tailnet, including over a DERP relay)",
481
+ "with tailscaled terminating TLS, walking 8443, then 10000,",
482
+ "then 443 until one publishes; falls back to binding a local",
483
+ "HTTPS server directly, never refusing, when none of the",
484
+ "three works. --host <addr> opts out of serve and binds that",
485
+ "address directly instead; --port <n> overrides the serve",
486
+ "port (default: the first free of 8443, 10000, 443), or the",
487
+ "bind port when --host is given (default: a free port);",
488
+ "--self-signed also opts out of serve, generating a",
489
+ "throwaway TLS certificate instead of the configured",
490
+ "ui.cert/ui.key; --dev runs against local development sources",
491
+ "instead of the packaged build",
492
+ ],
493
+ },
494
+ {
495
+ token: "check",
496
+ kind: "check",
497
+ arity: { names: ["mode", "file"] },
498
+ details: [
499
+ "Read <file> and run the built-in steering format named",
500
+ "<mode> (see `gtd validate`'s modes: qa, review) over its",
501
+ "contents, printing each finding one per line and exiting",
502
+ "non-zero when there are any. Resolves no workflow state and",
503
+ "reads no config — standalone, runnable from any directory",
504
+ "with <mode>/<file> given explicitly. This is what a",
505
+ "workflow's emitted validation script invokes as a leaf step.",
506
+ "--open-questions runs the qa unanswered-questions predicate",
507
+ "instead (see --help); --open-threads lists open threads.",
508
+ "`gtd check --open-threads` alone lists open code-comment",
509
+ "threads (`// H: ...`) in the files the process changed;",
510
+ "it prints nothing when no process is underway.",
511
+ ],
512
+ },
513
+ {
514
+ token: "uncheck",
515
+ kind: "uncheck",
516
+ arity: { name: "file" },
517
+ details: [
518
+ "Read <file> and reset every review-mode `- [x]`/`- [X]`",
519
+ "file-pointer box back to `- [ ]`, writing the result back",
520
+ "only when the bytes actually changed. Resolves no workflow",
521
+ "state and reads no config — standalone, runnable from any",
522
+ "directory with <file> given explicitly. This is what the",
523
+ "landing script runs ahead of the commit at the human review",
524
+ "gate, so no tick ever reaches a commit. Takes no <mode> —",
525
+ "it means review-mode file pointers and nothing else, never",
526
+ "qa-mode's answered-question boxes. A missing file writes",
527
+ "nothing and exits 0",
528
+ ],
529
+ },
530
+ {
531
+ token: "install",
532
+ kind: "install",
533
+ arity: "none",
534
+ details: [
535
+ "Print a complete, self-contained briefing that teaches an",
536
+ "agent (or a human) to build a gtd driver in any shell or",
537
+ "runtime — the self-serve version of",
538
+ "https://github.com/pmelab/gtd/blob/main/docs/driver.md's",
539
+ "'Writing your own driver'. At a repository root no",
540
+ "gtd.config.ts governs yet, it also writes one importing each",
541
+ "shipped workflow from @pmelab/gtd/workflows/<name>; anywhere",
542
+ "else it writes nothing.",
543
+ ],
544
+ },
545
+ {
546
+ token: "loop",
547
+ kind: "loop",
548
+ arity: "none",
549
+ details: [
550
+ "Drive this worktree's process until it rests on you: read",
551
+ "each beat, run the agent turn or script it demands, and",
552
+ "land the result. Exits 0 at a human gate, at idle and when",
553
+ "a landing settles; exits 1 on a stall or a failed turn or",
554
+ "landing. The agent CLI and the model each workflow hint",
555
+ "maps to are per-machine settings (GTD_LOOP_* variables, or",
556
+ "~/.config/gtd/loop.json); see docs/driver.md",
557
+ ],
558
+ },
559
+ {
560
+ token: "summary",
561
+ kind: "summary",
562
+ arity: "none",
563
+ details: [
564
+ "Print the prompt for an agent to write the process HEAD",
565
+ "closes or sits inside its own closing message — the entry",
566
+ "commit, each human-authored commit (a review round, an",
567
+ "answered question gate), the diff range to inspect, and",
568
+ "it.processCost/processCostByModel. Writes nothing: no git,",
569
+ "no state transition, no file, no session identity — the",
570
+ "driver pipes the output to a cold agent and does what it",
571
+ "wants with the result (a squash, an amend, a PR body).",
572
+ "Refuses (exit 1) when the workflow declares no summary:",
573
+ "template, or when the resolved run has no commits to name",
574
+ "— runnable any time before the next thing lands on the",
575
+ "branch",
576
+ ],
577
+ },
578
+ {
579
+ token: "base",
580
+ kind: "base",
581
+ arity: "none",
582
+ details: [
583
+ "Print the review anchor hash — the diff base an external",
584
+ "tool (a diff, a PR tool, another agent) should point at —",
585
+ "bare, newline-terminated, and nothing else. Writes nothing:",
586
+ "no git, no state transition, no session identity. Before",
587
+ "the first review round it's the process's diff base;",
588
+ "afterward it's the most-recent review round's boundary.",
589
+ "Refuses (exit 1) when no process is underway.",
590
+ ],
591
+ },
592
+ {
593
+ token: "door",
594
+ kind: "door",
595
+ arity: { name: "name", variadic: "args" },
596
+ details: [
597
+ "Start a process through a named shortcut, mapping the",
598
+ "positional args to that workflow's process settings — the",
599
+ "same start `gtd --workflow` makes, printed the same way.",
600
+ "Each workflow the config exports brings its own doors (gtd's",
601
+ "`fix` brings `fix`, `review` brings `review [base]`), plus",
602
+ "the config's `doors` export. An unknown door, or too few or",
603
+ "too many args, is a usage error (exit 2) naming the doors or",
604
+ "the door's synopsis",
605
+ ],
606
+ },
607
+ {
608
+ token: "doors",
609
+ kind: "doors",
610
+ arity: "none",
611
+ details: [
612
+ "List every door, sorted: one `name [args] → workflow` line",
613
+ "each. --json prints one JSON array of { name, workflow,",
614
+ "args: [{ name, optional }] } instead; --json=<path> reads one",
615
+ "value off it. Writes nothing",
616
+ ],
617
+ },
618
+ {
619
+ token: "judge",
620
+ kind: "judge",
621
+ arity: "none",
622
+ details: [
623
+ "Print the resolved rest's pending judgment — the prepared",
624
+ "state, its typed questions, and their criteria — the same",
625
+ "judge field `gtd next --json` already carries. Read-only:",
626
+ "resolves no session, writes nothing. Refuses (exit 1) when",
627
+ "the resolved rest declares no judge:",
628
+ ],
629
+ },
630
+ {
631
+ token: "judge answer",
632
+ kind: "judgeAnswer",
633
+ arity: "none",
634
+ details: [
635
+ "Read a verdict off stdin — one { id, answer, p } entry per",
636
+ "question the pending judgment declared — and decode it",
637
+ "against an Effect Schema built from those same question",
638
+ "ids. Refuses (exit 1) when the resolved rest declares no",
639
+ "judge:. Exits 2 (a usage error, like an unknown --json",
640
+ "selector) when stdin isn't valid JSON or the verdict",
641
+ "doesn't decode against the pending question ids.",
642
+ ],
643
+ },
644
+ {
645
+ token: "judge run",
646
+ kind: "judgeRun",
647
+ arity: "none",
648
+ details: [
649
+ "Answer a judgment: read the `gtd judge --json` document off",
650
+ "stdin, write a verdict [{ id, answer, p }] on stdout — what",
651
+ "`gtd judge answer` decodes. --provider fixed answers from",
652
+ "--answers <path> or GTD_JUDGE_ANSWERS; questions the file",
653
+ "does not cover are left out. --provider jev asks TypeSafe",
654
+ "(TYPESAFE_API_KEY). --provider llm asks the `claude` CLI",
655
+ "on PATH (Claude Code only), model haiku unless --model.",
656
+ "No --provider: GTD_JUDGE_PROVIDER, then ~/.config/gtd/",
657
+ "judge.json, else jev when TYPESAFE_API_KEY is set and no",
658
+ "model is given, else llm; --model likewise falls back to",
659
+ "GTD_JUDGE_MODEL, then the file's model. Needs no",
660
+ "repository. jev and llm exit 1 when they cannot answer",
661
+ "every question, with nothing on stdout",
662
+ ],
663
+ },
664
+ ]
665
+
666
+ const commandByToken = (token: string): CommandRow | undefined =>
667
+ COMMAND_ROWS.find((r) => r.token === token)
668
+
669
+ // ---------------------------------------------------------------------------
670
+ // Help rendering
671
+ // ---------------------------------------------------------------------------
672
+
673
+ const COLUMN = 19
674
+
675
+ const renderBlock = (header: string, lines: readonly string[]): string => {
676
+ const [first, ...rest] = lines
677
+ const headerCell = ` ${header}`
678
+ const headerLine =
679
+ headerCell.length < COLUMN
680
+ ? `${headerCell.padEnd(COLUMN)}${first ?? ""}\n`
681
+ : `${headerCell}\n${" ".repeat(COLUMN)}${first ?? ""}\n`
682
+ return headerLine + rest.map((l) => `${" ".repeat(COLUMN)}${l}\n`).join("")
683
+ }
684
+
685
+ const START_SHORT_FORM = renderBlock("(no command) --workflow <name>", [
686
+ "Starts a new process authenticated as human, e.g.",
687
+ "'gtd --workflow <name>'",
688
+ ])
689
+
690
+ const VERSION_BLOCK = renderBlock("version", ["Print version and exit"])
691
+ const HELP_BLOCK = renderBlock("help", ["Print this help and exit"])
692
+
693
+ const SPACE_FORM_FLAGS = new Set(["--workflow", "--var"])
694
+
695
+ const flagHeader = (row: FlagRow): string => {
696
+ if (row.valueHint === "") return row.name
697
+ return SPACE_FORM_FLAGS.has(row.name)
698
+ ? `${row.name} ${row.valueHint}`
699
+ : `${row.name}=${row.valueHint}`
700
+ }
701
+
702
+ export const renderHelp = (): string => {
703
+ const commandBlocks = COMMAND_ROWS.map((row) => {
704
+ const header =
705
+ row.arity === "none"
706
+ ? row.token
707
+ : "names" in row.arity
708
+ ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>`
709
+ : "variadic" in row.arity
710
+ ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]`
711
+ : `${row.token} <${row.arity.name}>`
712
+ return renderBlock(header, row.details)
713
+ })
714
+ const commands = [
715
+ commandBlocks[0], // land
716
+ START_SHORT_FORM,
717
+ ...commandBlocks.slice(1), // abandon..base
718
+ VERSION_BLOCK,
719
+ HELP_BLOCK,
720
+ ].join("")
721
+
722
+ const options = FLAGS.map((row) => renderBlock(flagHeader(row), row.help)).join("")
723
+ const versionHelpOptions =
724
+ renderBlock("--version, -V", ["Print version and exit"]) +
725
+ renderBlock("--help, -h", ["Print this help and exit"])
726
+
727
+ return (
728
+ `Usage: gtd [command] [options]\n\n` +
729
+ `Commands:\n${commands}\n` +
730
+ `Options:\n${options}${versionHelpOptions}`
731
+ )
732
+ }
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // Tokenizer
736
+ // ---------------------------------------------------------------------------
737
+
738
+ interface TokenizeError {
739
+ readonly kind: "error"
740
+ readonly message: string
741
+ readonly jsonSeen: boolean
742
+ }
743
+
744
+ interface TokenizeOk {
745
+ readonly kind: "ok"
746
+ readonly positionals: readonly string[]
747
+ readonly byFlag: ReadonlyMap<string, readonly string[]>
748
+ readonly present: ReadonlySet<string>
749
+ readonly jsonSeen: boolean
750
+ }
751
+
752
+ // fallow-ignore-next-line complexity
753
+ const tokenize = (tail: readonly string[]): TokenizeError | TokenizeOk => {
754
+ const positionals: string[] = []
755
+ const byFlag = new Map<string, string[]>()
756
+ const present = new Set<string>()
757
+ let jsonSeen = false
758
+
759
+ for (let i = 0; i < tail.length; i++) {
760
+ const original = tail[i]!
761
+ const tok = SINGLE_DASH_ALIASES.get(original) ?? original
762
+ if (!tok.startsWith("--")) {
763
+ positionals.push(original)
764
+ continue
765
+ }
766
+ const row = flagByToken(tok)
767
+ if (row === undefined) {
768
+ return {
769
+ kind: "error",
770
+ message: `gtd: unknown option '${tok}' — see \`gtd --help\``,
771
+ jsonSeen,
772
+ }
773
+ }
774
+ const eq = tok.indexOf("=")
775
+ const alreadyPresent = present.has(row.name)
776
+ present.add(row.name)
777
+ if (row.name === "--json") jsonSeen = true
778
+
779
+ if (row.arity === 0) {
780
+ if (eq !== -1) {
781
+ return {
782
+ kind: "error",
783
+ message: `gtd: unknown option '${tok}' — see \`gtd --help\``,
784
+ jsonSeen,
785
+ }
786
+ }
787
+ if (!byFlag.has(row.name)) byFlag.set(row.name, [])
788
+ continue
789
+ }
790
+
791
+ if (row.arity === "optional") {
792
+ if (!row.repeatable && alreadyPresent) {
793
+ return { kind: "error", message: `gtd: ${row.name} may be given at most once`, jsonSeen }
794
+ }
795
+ if (eq !== -1) {
796
+ const value = tok.slice(eq + 1)
797
+ if (value === "") {
798
+ // Unlike arity-1, an "optional" flag's bare/space form is a
799
+ // deliberate, DIFFERENT meaning (presence with no value) — never a
800
+ // rejected value-missing form — so this message names only the
801
+ // one legal way to supply a value, `=`, not the arity-1 template's
802
+ // "or <name> <value>" (which would recommend the very space form
803
+ // the settled decisions make a usage error on purpose).
804
+ return {
805
+ kind: "error",
806
+ message: `gtd: ${row.name} requires a value — use ${row.name}=${row.valueHint}`,
807
+ jsonSeen,
808
+ }
809
+ }
810
+ byFlag.set(row.name, [value])
811
+ continue
812
+ }
813
+ // Bare presence — never consumes the following token, unlike arity 1.
814
+ if (!byFlag.has(row.name)) byFlag.set(row.name, [])
815
+ continue
816
+ }
817
+
818
+ let raw: string
819
+ if (eq !== -1) {
820
+ raw = tok.slice(eq + 1)
821
+ } else {
822
+ const next = tail[i + 1]
823
+ if (next === undefined || next.startsWith("--")) {
824
+ raw = ""
825
+ } else {
826
+ raw = next
827
+ i++
828
+ }
829
+ }
830
+ if (raw === "") {
831
+ return {
832
+ kind: "error",
833
+ message: `gtd: ${row.name} requires a value — use ${row.name}=${row.valueHint} or ${row.name} ${row.valueHint}`,
834
+ jsonSeen,
835
+ }
836
+ }
837
+ const existing = byFlag.get(row.name) ?? []
838
+ if (!row.repeatable && existing.length >= 1) {
839
+ return { kind: "error", message: `gtd: ${row.name} may be given at most once`, jsonSeen }
840
+ }
841
+ byFlag.set(row.name, [...existing, raw])
842
+ }
843
+
844
+ return { kind: "ok", positionals, byFlag, present, jsonSeen }
845
+ }
846
+
847
+ // ---------------------------------------------------------------------------
848
+ // parseArgv
849
+ // ---------------------------------------------------------------------------
850
+
851
+ const usagePlan = (message: string, json: boolean, stdout = ""): CliPlan => ({
852
+ kind: "usage",
853
+ stdout,
854
+ message,
855
+ json,
856
+ })
857
+
858
+ const scopeViolation = (
859
+ kind: Command["kind"] | undefined,
860
+ present: ReadonlySet<string>,
861
+ ): string | undefined => {
862
+ for (const row of FLAGS) {
863
+ if (present.has(row.name) && !row.scope(kind)) return row.scopeError
864
+ }
865
+ return undefined
866
+ }
867
+
868
+ /** A table field, not an `if`: any row present alongside a flag it names in `conflicts` is a usage error — checked generically over every row, so a new conflicting pair is one table edit. */
869
+ const conflictViolation = (present: ReadonlySet<string>): string | undefined => {
870
+ for (const row of FLAGS) {
871
+ if (!present.has(row.name)) continue
872
+ for (const other of row.conflicts ?? []) {
873
+ if (present.has(other)) return `gtd: ${row.name} and ${other} are mutually exclusive`
874
+ }
875
+ }
876
+ return undefined
877
+ }
878
+
879
+ const arityError = (cmd: string, rest: readonly string[], arity: Arity): string | undefined => {
880
+ if (arity === "none") {
881
+ return rest.length > 0
882
+ ? `gtd ${cmd}: too many arguments — expected none, got: ${rest.join(", ")}`
883
+ : undefined
884
+ }
885
+ if ("names" in arity) return pairArityError(cmd, rest, arity.names)
886
+ if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`
887
+ if (!("variadic" in arity) && rest.length > 1) {
888
+ return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`
889
+ }
890
+ return undefined
891
+ }
892
+
893
+ const pairArityError = (
894
+ cmd: string,
895
+ rest: readonly string[],
896
+ [first, second]: readonly [string, string],
897
+ ): string | undefined => {
898
+ if (rest.length === 0) return `gtd ${cmd}: missing ${first} and ${second} arguments`
899
+ if (rest.length === 1) return `gtd ${cmd}: missing ${second} argument`
900
+ if (rest.length > 2) {
901
+ return `gtd ${cmd}: too many arguments — expected ${first} and ${second}, got: ${rest.join(", ")}`
902
+ }
903
+ return undefined
904
+ }
905
+
906
+ /** Decode every present flag into a bag of values, or the first decode error encountered. */
907
+ const decodeFlags = (
908
+ byFlag: ReadonlyMap<string, readonly string[]>,
909
+ ): Either.Either<Readonly<Record<string, unknown>>, string> => {
910
+ const bag: Record<string, unknown> = {}
911
+ for (const row of FLAGS) {
912
+ const raws = byFlag.get(row.name)
913
+ if (raws === undefined) continue
914
+ // A bare `"optional"`-arity flag (e.g. `--json` with no `=`) has nothing
915
+ // to decode — presence alone is tracked via `present`, not the bag.
916
+ if (row.arity === "optional" && raws.length === 0) continue
917
+ const decoded = row.decode(raws)
918
+ if (Either.isLeft(decoded)) return Either.left(decoded.left)
919
+ bag[row.name] = decoded.right
920
+ }
921
+ return Either.right(bag)
922
+ }
923
+
924
+ /** Assembles a `land` `Command` from its already-scope-checked flag bag — split out of `parseArgv` so its two independent, omit-when-absent optional fields don't inflate that function's own branching. */
925
+ const buildLandCommand = (bag: {
926
+ readonly "--cost"?: number
927
+ readonly "--model"?: string
928
+ }): Command => ({
929
+ kind: "land",
930
+ ...(bag["--cost"] !== undefined ? { cost: bag["--cost"] } : {}),
931
+ ...(bag["--model"] !== undefined ? { model: bag["--model"] } : {}),
932
+ })
933
+
934
+ // fallow-ignore-next-line complexity
935
+ export const parseArgv = (argv: readonly string[]): CliPlan => {
936
+ if (argv.includes("--help") || argv.includes("-h"))
937
+ return { kind: "output", stdout: renderHelp() }
938
+ if (argv.includes("--version") || argv.includes("-V")) {
939
+ return { kind: "output", stdout: `${GTD_VERSION}\n` }
940
+ }
941
+
942
+ const tokenized = tokenize(argv.slice(2))
943
+ if (tokenized.kind === "error") return usagePlan(tokenized.message, tokenized.jsonSeen)
944
+ const { positionals, byFlag, present, jsonSeen } = tokenized
945
+
946
+ const first = positionals[0]
947
+ const workflowRaw = byFlag.get("--workflow")?.[0]
948
+ const workflowPresent = workflowRaw !== undefined
949
+
950
+ if (first === "version") return { kind: "output", stdout: `${GTD_VERSION}\n` }
951
+ if (first === "help") return { kind: "output", stdout: renderHelp() }
952
+
953
+ const row = first === undefined ? undefined : commandByToken(first)
954
+
955
+ // `gtd judge answer` — the CLI's one two-level verb. The NORMAL path is
956
+ // "answer" arriving as `positionals[1]`, a second token after "judge" —
957
+ // detected below via `judgeAnswer`, not through a second dispatchable
958
+ // `COMMAND_ROWS` row: the `"judge answer"`-tokened row above exists for
959
+ // `renderHelp()`'s two-line entry. It CAN also be matched by
960
+ // `commandByToken` directly, but only if a caller passes the single
961
+ // (space-containing) argv token `"judge answer"` — an unusual shell quoting
962
+ // choice, not the normal two-argv-token invocation `gtd judge answer` — and
963
+ // `row.kind` is `"judgeAnswer"` either way, so nothing downstream branches
964
+ // differently between the two paths. `row`'s own arity (still "none",
965
+ // judge's own) applies to whatever follows "answer" below, so no bespoke
966
+ // `Arity` variant is needed for this one verb.
967
+ const judgeAnswer = row?.kind === "judge" && positionals[1] === "answer"
968
+
969
+ // The `--workflow` selector: no command at all, with `--workflow` present,
970
+ // resolves to the generic `start` command instead — landing and starting
971
+ // are different verbs, so `gtd land --workflow <name>` is NOT a synonym (it
972
+ // fails the scope check below instead).
973
+ const selectsStart = workflowPresent && first === undefined
974
+ const judgeRun = row?.kind === "judge" && positionals[1] === "run"
975
+ const kind: Command["kind"] | undefined = selectsStart
976
+ ? "start"
977
+ : judgeAnswer
978
+ ? "judgeAnswer"
979
+ : judgeRun
980
+ ? "judgeRun"
981
+ : row?.kind
982
+
983
+ if (row === undefined && !selectsStart) {
984
+ // No dispatchable row resolved (missing/unknown command) — a scoped flag
985
+ // used here (e.g. `--var` with no `--workflow`) is a more specific error
986
+ // than "missing command"/"unknown command", so it takes priority (mirrors
987
+ // the old parser, which validated --workflow/--var/--cost/--model ahead of
988
+ // subcommand resolution).
989
+ const violation = scopeViolation(undefined, present)
990
+ if (violation !== undefined) return usagePlan(violation, jsonSeen)
991
+ if (first === undefined) {
992
+ return usagePlan(
993
+ "gtd: missing command — gtd decides and prints, a driver executes; " +
994
+ "run `gtd install`, or copy one from " +
995
+ "https://github.com/pmelab/gtd/blob/main/docs/driver.md's " +
996
+ '"A complete minimal driver" section and run that, or see usage ' +
997
+ "above (`gtd --help`)",
998
+ jsonSeen,
999
+ renderHelp(),
1000
+ )
1001
+ }
1002
+ return usagePlan(`unknown command '${first}'`, jsonSeen)
1003
+ }
1004
+
1005
+ // From here, `kind` is a genuine `Command["kind"]`. `judgeAnswer` consumes
1006
+ // TWO leading positionals ("judge" and "answer"), everything else one.
1007
+ const restPositionals =
1008
+ first === undefined ? positionals : positionals.slice(judgeAnswer || judgeRun ? 2 : 1)
1009
+
1010
+ if (kind === "start" && first === undefined) {
1011
+ if (restPositionals.length > 0) {
1012
+ return usagePlan(
1013
+ `gtd: too many arguments — expected none, got: ${restPositionals.join(", ")}`,
1014
+ jsonSeen,
1015
+ )
1016
+ }
1017
+ } else {
1018
+ // `kind === "start"` is unreachable here: `selectsStart` only fires when
1019
+ // `first === undefined`, which the `if` branch above already handled.
1020
+ const bareCheck =
1021
+ kind === "check" && restPositionals.length === 0 && present.has("--open-threads")
1022
+ const arityMsg = bareCheck ? undefined : arityError(first!, restPositionals, row!.arity)
1023
+ if (arityMsg !== undefined) return usagePlan(arityMsg, jsonSeen)
1024
+ }
1025
+
1026
+ const violation = scopeViolation(kind, present)
1027
+ if (violation !== undefined) return usagePlan(violation, jsonSeen)
1028
+
1029
+ const conflict = conflictViolation(present)
1030
+ if (conflict !== undefined) return usagePlan(conflict, jsonSeen)
1031
+
1032
+ const decoded = decodeFlags(byFlag)
1033
+ if (Either.isLeft(decoded)) return usagePlan(decoded.left, jsonSeen)
1034
+ const bag = decoded.right as {
1035
+ readonly "--json"?: string
1036
+ readonly "--port"?: number
1037
+ readonly "--cost"?: number
1038
+ readonly "--model"?: string
1039
+ readonly "--var"?: Readonly<Record<string, string>>
1040
+ readonly "--open-questions"?: boolean
1041
+ readonly "--open-threads"?: boolean
1042
+ readonly "--host"?: string
1043
+ readonly "--self-signed"?: boolean
1044
+ readonly "--dev"?: boolean
1045
+ readonly "--provider"?: "fixed" | "jev" | "llm"
1046
+ readonly "--answers"?: string
1047
+ }
1048
+
1049
+ // `present.has("--json")` alone can't distinguish bare `--json` (no value
1050
+ // decoded) from `--json=<path>` (a value in the bag) — see `decodeFlags`'s
1051
+ // optional-arity skip above.
1052
+ const json: JsonMode = !present.has("--json")
1053
+ ? { kind: "off" }
1054
+ : bag["--json"] !== undefined
1055
+ ? { kind: "select", path: bag["--json"] }
1056
+ : { kind: "document" }
1057
+ const verbose = present.has("--verbose")
1058
+
1059
+ if (kind === "land") {
1060
+ if (bag["--model"] !== undefined && bag["--cost"] === undefined) {
1061
+ return usagePlan(
1062
+ "gtd: --model requires --cost — it tags the recorded cost with the model that ran",
1063
+ jsonSeen,
1064
+ )
1065
+ }
1066
+ return { kind: "command", command: buildLandCommand(bag), json, verbose }
1067
+ }
1068
+
1069
+ if (kind === "start") {
1070
+ const label = `gtd --workflow ${workflowRaw}`
1071
+ return {
1072
+ kind: "command",
1073
+ command: {
1074
+ kind: "start",
1075
+ actor: "human",
1076
+ workflow: workflowRaw!,
1077
+ vars: bag["--var"] ?? {},
1078
+ label,
1079
+ },
1080
+ json,
1081
+ verbose,
1082
+ }
1083
+ }
1084
+
1085
+ if (kind === "door") {
1086
+ return {
1087
+ kind: "command",
1088
+ command: { kind: "door", name: restPositionals[0]!, args: restPositionals.slice(1) },
1089
+ json,
1090
+ verbose,
1091
+ }
1092
+ }
1093
+
1094
+ if (kind === "ui") {
1095
+ return {
1096
+ kind: "command",
1097
+ command: {
1098
+ kind: "ui",
1099
+ ...(bag["--host"] !== undefined ? { host: bag["--host"] } : {}),
1100
+ ...(bag["--port"] !== undefined ? { port: bag["--port"] } : {}),
1101
+ selfSigned: bag["--self-signed"] ?? false,
1102
+ dev: bag["--dev"] ?? false,
1103
+ },
1104
+ json,
1105
+ verbose,
1106
+ }
1107
+ }
1108
+
1109
+ if (kind === "check") {
1110
+ return {
1111
+ kind: "command",
1112
+ command: {
1113
+ kind: "check",
1114
+ ...(restPositionals[0] !== undefined ? { mode: restPositionals[0] } : {}),
1115
+ ...(restPositionals[1] !== undefined ? { file: restPositionals[1] } : {}),
1116
+ ...(bag["--open-questions"] !== undefined
1117
+ ? { openQuestions: bag["--open-questions"] }
1118
+ : {}),
1119
+ ...(bag["--open-threads"] !== undefined ? { openThreads: bag["--open-threads"] } : {}),
1120
+ },
1121
+ json,
1122
+ verbose,
1123
+ }
1124
+ }
1125
+
1126
+ if (kind === "judgeRun") {
1127
+ const provider = bag["--provider"]
1128
+ if (provider !== "fixed" && bag["--answers"] !== undefined) {
1129
+ return usagePlan("gtd judge run: --answers is only valid with --provider fixed", jsonSeen)
1130
+ }
1131
+ if ((provider === "fixed" || provider === "jev") && bag["--model"] !== undefined) {
1132
+ return usagePlan(
1133
+ "gtd judge run: --model is only valid with --provider llm (or when it is auto-selected)",
1134
+ jsonSeen,
1135
+ )
1136
+ }
1137
+ return {
1138
+ kind: "command",
1139
+ command: {
1140
+ kind: "judgeRun",
1141
+ ...(provider !== undefined ? { provider } : {}),
1142
+ ...(bag["--answers"] !== undefined ? { answers: bag["--answers"] } : {}),
1143
+ ...(bag["--model"] !== undefined ? { model: bag["--model"] } : {}),
1144
+ },
1145
+ json,
1146
+ verbose,
1147
+ }
1148
+ }
1149
+
1150
+ if (kind === "uncheck") {
1151
+ return {
1152
+ kind: "command",
1153
+ command: { kind: "uncheck", file: restPositionals[0]! },
1154
+ json,
1155
+ verbose,
1156
+ }
1157
+ }
1158
+
1159
+ // Every other kind carries no extra fields.
1160
+ const command: Command = {
1161
+ kind: kind as
1162
+ | "lsp"
1163
+ | "abandon"
1164
+ | "restore"
1165
+ | "next"
1166
+ | "validate"
1167
+ | "install"
1168
+ | "loop"
1169
+ | "doors"
1170
+ | "summary"
1171
+ | "base"
1172
+ | "judge"
1173
+ | "judgeAnswer",
1174
+ }
1175
+ return { kind: "command", command, json, verbose }
1176
+ }
1177
+
1178
+ // ---------------------------------------------------------------------------
1179
+ // runCli
1180
+ // ---------------------------------------------------------------------------
1181
+
1182
+ export interface CliIo {
1183
+ readonly stdout: (chunk: string) => void
1184
+ readonly stderr: (chunk: string) => void
1185
+ readonly exit: (code: number) => void
1186
+ /**
1187
+ * Deferred thunk — a layer is built only for a resolved `Command`, never
1188
+ * for `--version`/`--help`/a usage error. `verbose` (`--verbose`/`-v`,
1189
+ * decoded by `parseArgv`) gates the `Narrator` this builds — a no-op
1190
+ * writer when `false`.
1191
+ */
1192
+ readonly layers: (verbose: boolean) => Layer.Layer<CommandRequirements>
1193
+ }
1194
+
1195
+ // The one write-to-stderr primitive nodeCliIo uses for both `stderr` (errors)
1196
+ // and `layers`'s `Narrator` (narration) — same sink, so a real invocation's
1197
+ // narration and its remediation interleave on the same fd exactly as
1198
+ // written.
1199
+ const writeStderr = (chunk: string): void => {
1200
+ process.stderr.write(chunk)
1201
+ }
1202
+
1203
+ export type LiveToolCommands = Layer.Layer<
1204
+ ToolCommands,
1205
+ never,
1206
+ Exclude<CommandRequirements, ToolCommands> | NodeContext.NodeContext
1207
+ >
1208
+
1209
+ export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
1210
+ // `process.stdout.write` is asynchronous whenever stdout is a pipe or
1211
+ // socket — passing a completion callback (and never force-closing, see
1212
+ // `exit` below) keeps a queued remainder from being discarded, so a large
1213
+ // artifact still reaches a slow reader even after a non-zero exit.
1214
+ stdout: (chunk) => {
1215
+ process.stdout.write(chunk, () => {})
1216
+ },
1217
+ stderr: writeStderr,
1218
+ exit: (code) => {
1219
+ // Never `process.exit(code)`: that tears the process down out from under
1220
+ // an undrained `stdout.write` above and truncates the artifact mid-pipe.
1221
+ // Setting `exitCode` lets the event loop run to completion — draining
1222
+ // any queued write — before Node exits gracefully with this code.
1223
+ process.exitCode = code
1224
+ },
1225
+ // A Layer is a lazy description, not a running service — building this
1226
+ // object touches no filesystem/git; only `Effect.provide` running the
1227
+ // result does. `runCli` calls this thunk exactly when `plan.kind ===
1228
+ // "command"`, so --version/--help/a usage error never provoke it.
1229
+ layers: (verbose) =>
1230
+ // Dependency order matters: Workspace.Live needs GitService, both need
1231
+ // Host, and GitService needs NodeContext's CommandExecutor. Each
1232
+ // `provideMerge` satisfies the layers above it AND stays in the output.
1233
+ tools.pipe(
1234
+ Layer.provideMerge(
1235
+ Layer.mergeAll(
1236
+ ConfigService.Live,
1237
+ ConfigDiscovery.Live,
1238
+ Workspace.Live,
1239
+ Narrator.layer(writeStderr, verbose),
1240
+ CommandRunner.Live,
1241
+ ).pipe(
1242
+ Layer.provideMerge(GitService.Live),
1243
+ Layer.provideMerge(Host.Live),
1244
+ Layer.provideMerge(NodeContext.layer),
1245
+ ),
1246
+ ),
1247
+ ),
1248
+ })
1249
+
1250
+ /**
1251
+ * Normalizes an artifact's trailing newlines to exactly one — an emitted
1252
+ * script ends with none, plain text already ends with one, so this is the
1253
+ * single place both become uniform. A byte-empty artifact stays byte-empty
1254
+ * (never becomes a lone `\n`); several trailing newlines collapse to one.
1255
+ */
1256
+ export const normalizeTrailingNewline = (artifact: string): string =>
1257
+ artifact.length === 0 ? artifact : `${artifact.replace(/\n+$/, "")}\n`
1258
+
1259
+ const bufferedArtifactOut = (io: CliIo): ArtifactOut => {
1260
+ let buffer = ""
1261
+ return {
1262
+ write: (chunk) => {
1263
+ buffer += chunk
1264
+ },
1265
+ flush: () => {
1266
+ if (buffer.length > 0) io.stdout(normalizeTrailingNewline(buffer))
1267
+ buffer = ""
1268
+ },
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * The single envelope writer: `{state:"error",prompt}` on **stderr** under
1274
+ * `--json`, `renderFailure` on stderr always, exit `EXIT_RUNTIME_ERROR`.
1275
+ * stdout is never touched here — the command's `ArtifactOut` buffer was never
1276
+ * flushed, so a `--json` driver reading stdout on a failed run reads nothing
1277
+ * — it must read stderr or the exit code instead. `Effect.sandbox` means this also
1278
+ * fires for a DEFECT, not just a typed error. Unreached by an ordinary usage
1279
+ * error (an unknown flag, bad arity, a scope violation) — those never build a
1280
+ * layer at all. Two exceptions map to `EXIT_USAGE_ERROR` instead, below:
1281
+ * `SelectorUsageError` (an unknown `--json=<path>` selector can only be
1282
+ * judged after the layer is built and the fields object it's reduced
1283
+ * against is fully resolved, so it fails HERE rather than in `parseArgv`)
1284
+ * and `GtdUsageError` (`gtd ui` refusing to start on a step it cannot
1285
+ * render — see `Commentary.ts`).
1286
+ */
1287
+ const report =
1288
+ (io: CliIo, json: boolean) =>
1289
+ (cause: Cause.Cause<Error>): Effect.Effect<void> =>
1290
+ Effect.sync(() => {
1291
+ const error = Cause.squash(cause)
1292
+ if (json) {
1293
+ const message = error instanceof Error ? error.message : String(error)
1294
+ io.stderr(`${JSON.stringify({ state: "error", prompt: message })}\n`)
1295
+ }
1296
+ io.stderr(`${renderFailure(error)}\n`)
1297
+ io.exit(
1298
+ error instanceof SelectorUsageError || error instanceof GtdUsageError
1299
+ ? EXIT_USAGE_ERROR
1300
+ : EXIT_RUNTIME_ERROR,
1301
+ )
1302
+ })
1303
+
1304
+ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void, Error> => {
1305
+ const plan = parseArgv(argv)
1306
+
1307
+ if (plan.kind === "output") {
1308
+ return Effect.sync(() => io.stdout(plan.stdout))
1309
+ }
1310
+
1311
+ if (plan.kind === "usage") {
1312
+ return Effect.sync(() => {
1313
+ // The rendered help text (missing-command only) and the `--json`
1314
+ // envelope both move to stderr — stdout stays byte-empty on a usage
1315
+ // error exactly like every other failure surface.
1316
+ if (plan.stdout.length > 0) io.stderr(plan.stdout)
1317
+ if (plan.json) {
1318
+ io.stderr(`${JSON.stringify({ state: "error", prompt: plan.message })}\n`)
1319
+ }
1320
+ io.stderr(`${renderFailure(plan.message)}\n`)
1321
+ io.exit(EXIT_USAGE_ERROR)
1322
+ })
1323
+ }
1324
+
1325
+ const out = bufferedArtifactOut(io)
1326
+ return runCommand(plan.command, plan.json, out).pipe(
1327
+ // `flush()` fires here, on success, BEFORE `io.exit` — the one point
1328
+ // where the whole buffered artifact is known-complete and safe to hand
1329
+ // to stdout (a failure discards the buffer instead — see
1330
+ // `bufferedArtifactOut`).
1331
+ Effect.map(() => {
1332
+ out.flush()
1333
+ io.exit(EXIT_OK)
1334
+ }),
1335
+ Effect.provide(io.layers(plan.verbose)),
1336
+ Effect.sandbox,
1337
+ // `sandbox` moves EVERY failure mode — a typed error, a defect, an
1338
+ // interruption — into this handler's `cause`. An interrupt-only cause
1339
+ // (Ctrl-C on `ui`/`lsp`'s blocking `Effect.never`) is passed
1340
+ // through untouched via `failCause` so `NodeRuntime.runMain` still sees a
1341
+ // real interruption; everything else gets the envelope.
1342
+ Effect.catchAll((cause) =>
1343
+ Cause.isInterruptedOnly(cause)
1344
+ ? Effect.failCause(cause)
1345
+ : report(io, plan.json.kind !== "off")(cause),
1346
+ ),
1347
+ )
1348
+ }