@pmelab/gtd 58.2.0 → 59.0.1

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 (150) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +120 -395
  3. package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-07Z_ENch.mjs} +13503 -3460
  4. package/dist/chunks/{wire-C12dahrH.mjs → wire-BTUhRGXj.mjs} +1 -1
  5. package/dist/flows.mjs +1 -1
  6. package/dist/gtd.bundle.mjs +484 -497
  7. package/dist/testkit/gherkin.mjs +145 -65
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +1 -1
  10. package/package.json +3 -42
  11. package/skills/authoring/SKILL.md +34 -27
  12. package/src/cli/Cli.test.ts +148 -71
  13. package/src/cli/Cli.ts +384 -463
  14. package/src/cli/Help.test.ts +47 -0
  15. package/src/cli/Help.ts +70 -0
  16. package/src/cli/Program.test.ts +127 -33
  17. package/src/cli/Program.ts +70 -25
  18. package/src/edge/Edge.ts +4 -4
  19. package/src/emit/Emit.test.ts +1 -1
  20. package/src/emit/Emit.ts +1 -1
  21. package/src/flags/index.test.ts +0 -13
  22. package/src/flags/index.ts +1 -3
  23. package/src/flows/factory.ts +3 -1
  24. package/src/gherkin/index.ts +135 -71
  25. package/src/gherkin/messages.test.ts +90 -0
  26. package/src/gherkin/messages.ts +84 -0
  27. package/src/install/Install.test.ts +14 -11
  28. package/src/install/Install.ts +38 -15
  29. package/src/install/index.ts +1 -2
  30. package/src/replay/Replay.test.ts +1 -1
  31. package/src/replay/index.ts +1 -1
  32. package/src/step/planStart.ts +6 -1
  33. package/src/testkit/TestRepo.test.ts +47 -2
  34. package/src/testkit/TestRepo.ts +51 -6
  35. package/src/twins/EmittedScriptRecognizer.ts +5 -8
  36. package/src/twins/FlowScripts.ts +1 -1
  37. package/src/wire/Land.ts +1 -1
  38. package/src/workflow/index.ts +1 -0
  39. package/src/workflow/load.test.ts +138 -113
  40. package/src/workflow/load.ts +7 -7
  41. package/src/workflow/skills.test.ts +5 -5
  42. package/src/workflow/skills.ts +1 -1
  43. package/src/workflow/vars.test.ts +30 -3
  44. package/src/workflow/vars.ts +34 -2
  45. package/src/workflow-kit/index.ts +1 -1
  46. package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
  47. package/dist/chunks/review-Bfaror38.mjs +0 -427
  48. package/dist/chunks/tail-Bd7L940p.mjs +0 -355
  49. package/dist/chunks/testkit-JDtpNKyi.mjs +0 -10410
  50. package/dist/workflows/cleanup.mjs +0 -36
  51. package/dist/workflows/feature.mjs +0 -1018
  52. package/dist/workflows/fix.mjs +0 -41
  53. package/dist/workflows/review.mjs +0 -45
  54. package/dist/workflows/rollout.mjs +0 -48
  55. package/dist/workflows.mjs +0 -40651
  56. package/src/install/ConfigScaffold.ts +0 -22
  57. package/src/workflows/cleanup/cleanup-path.feature +0 -94
  58. package/src/workflows/cleanup/cleanup.feature +0 -315
  59. package/src/workflows/cleanup/cleanup.test.ts +0 -276
  60. package/src/workflows/cleanup/cleanup.ts +0 -52
  61. package/src/workflows/cleanup/index.ts +0 -6
  62. package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
  63. package/src/workflows/feature/acceptance-criteria.feature +0 -389
  64. package/src/workflows/feature/criteria-coverage.feature +0 -102
  65. package/src/workflows/feature/criteria.test.ts +0 -147
  66. package/src/workflows/feature/criteria.ts +0 -267
  67. package/src/workflows/feature/fast-path.feature +0 -93
  68. package/src/workflows/feature/feature.test.ts +0 -338
  69. package/src/workflows/feature/feature.ts +0 -156
  70. package/src/workflows/feature/green.ts +0 -19
  71. package/src/workflows/feature/index.ts +0 -18
  72. package/src/workflows/feature/lens-models.feature +0 -69
  73. package/src/workflows/feature/lens-review.feature +0 -566
  74. package/src/workflows/feature/naive-acceptance.feature +0 -147
  75. package/src/workflows/feature/naive-test.feature +0 -263
  76. package/src/workflows/feature/one-step-land.feature +0 -93
  77. package/src/workflows/feature/planning.ts +0 -41
  78. package/src/workflows/feature/refactor-absorbs.feature +0 -131
  79. package/src/workflows/feature/refactor-first.feature +0 -158
  80. package/src/workflows/feature/refactor-lens-models.feature +0 -79
  81. package/src/workflows/feature/refactor-rebuild.feature +0 -273
  82. package/src/workflows/feature/refactor-review.feature +0 -1528
  83. package/src/workflows/feature/refactor.test.ts +0 -1220
  84. package/src/workflows/feature/refactor.ts +0 -368
  85. package/src/workflows/feature/release-flag.feature +0 -167
  86. package/src/workflows/feature/release-flag.test.ts +0 -49
  87. package/src/workflows/feature/release-flag.ts +0 -46
  88. package/src/workflows/feature/review-gate.feature +0 -241
  89. package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
  90. package/src/workflows/feature/risk-tier.feature +0 -351
  91. package/src/workflows/feature/seam-rejection.feature +0 -146
  92. package/src/workflows/feature/seam.feature +0 -297
  93. package/src/workflows/feature/seam.test.ts +0 -527
  94. package/src/workflows/feature/seam.ts +0 -426
  95. package/src/workflows/feature/standards.feature +0 -354
  96. package/src/workflows/feature/steps.test.ts +0 -65
  97. package/src/workflows/feature/steps.ts +0 -45
  98. package/src/workflows/feature/tier-pin.feature +0 -66
  99. package/src/workflows/feature/tier.test.ts +0 -166
  100. package/src/workflows/feature/tier.ts +0 -56
  101. package/src/workflows/fix/fix-lens-origin.feature +0 -114
  102. package/src/workflows/fix/fix-quality.feature +0 -212
  103. package/src/workflows/fix/fix.feature +0 -79
  104. package/src/workflows/fix/fix.test.ts +0 -74
  105. package/src/workflows/fix/fix.ts +0 -52
  106. package/src/workflows/fix/index.ts +0 -6
  107. package/src/workflows/index.ts +0 -47
  108. package/src/workflows/review/index.ts +0 -6
  109. package/src/workflows/review/review-door.feature +0 -199
  110. package/src/workflows/review/review.test.ts +0 -27
  111. package/src/workflows/review/review.ts +0 -60
  112. package/src/workflows/rollout/index.ts +0 -6
  113. package/src/workflows/rollout/rollout.feature +0 -510
  114. package/src/workflows/rollout/rollout.test.ts +0 -263
  115. package/src/workflows/rollout/rollout.ts +0 -82
  116. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
  117. package/src/workflows/shared/access.test.ts +0 -62
  118. package/src/workflows/shared/access.ts +0 -24
  119. package/src/workflows/shared/config.fixture.ts +0 -12
  120. package/src/workflows/shared/fences.ts +0 -24
  121. package/src/workflows/shared/health.test.ts +0 -148
  122. package/src/workflows/shared/health.ts +0 -191
  123. package/src/workflows/shared/learn.test.ts +0 -140
  124. package/src/workflows/shared/learn.ts +0 -201
  125. package/src/workflows/shared/lenses.test.ts +0 -39
  126. package/src/workflows/shared/lenses.ts +0 -294
  127. package/src/workflows/shared/options.test.ts +0 -157
  128. package/src/workflows/shared/options.ts +0 -92
  129. package/src/workflows/shared/progress.feature +0 -124
  130. package/src/workflows/shared/prose.ts +0 -263
  131. package/src/workflows/shared/recheck.feature +0 -60
  132. package/src/workflows/shared/registry.test.ts +0 -345
  133. package/src/workflows/shared/registry.ts +0 -183
  134. package/src/workflows/shared/reserved.ts +0 -11
  135. package/src/workflows/shared/review.test.ts +0 -1062
  136. package/src/workflows/shared/review.ts +0 -446
  137. package/src/workflows/shared/skills.test.ts +0 -121
  138. package/src/workflows/shared/skills.ts +0 -49
  139. package/src/workflows/shared/standards.test.ts +0 -90
  140. package/src/workflows/shared/standards.ts +0 -156
  141. package/src/workflows/shared/steps.test.ts +0 -219
  142. package/src/workflows/shared/steps.ts +0 -201
  143. package/src/workflows/shared/tail.test.ts +0 -454
  144. package/src/workflows/shared/tail.ts +0 -264
  145. package/src/workflows/shared/text.fixture.ts +0 -128
  146. package/src/workflows/shared/text.test.ts +0 -1430
  147. package/src/workflows/shared/text.ts +0 -1700
  148. package/src/workflows/shared/threads.test.ts +0 -129
  149. package/src/workflows/shared/vars.ts +0 -47
  150. package/src/workflows/shared/workflows.steps.ts +0 -54
package/src/cli/Cli.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  } from "./Program.js"
37
37
  import type { ToolCommands } from "./ToolCommands.js"
38
38
  import { THRESHOLDS } from "./Judges.js"
39
+ import { closest, definition, wrap } from "./Help.js"
39
40
  import { EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
40
41
 
41
42
  export type { Command, CommandRequirements } from "./Program.js"
@@ -77,8 +78,9 @@ interface FlagRow {
77
78
  readonly decode: (raws: readonly string[]) => Either.Either<unknown, string>
78
79
  readonly scopeError: string
79
80
  readonly valueHint: string
80
- /** Pre-wrapped help lines (data, not generated prose) — see `renderHelp`. */
81
- readonly help: readonly string[]
81
+ /** One sentence for a command's help, wrapped when rendered; `helpFor` overrides it for a command the flag means something else on. */
82
+ readonly help: string
83
+ readonly helpFor?: Partial<Record<Command["kind"], string>>
82
84
  /** Flag names this one may never appear alongside — checked generically after parsing (see `conflictViolation`), never as a bespoke `if`. Defaults to none. */
83
85
  readonly conflicts?: readonly string[]
84
86
  }
@@ -115,20 +117,10 @@ const FLAGS: readonly FlagRow[] = [
115
117
  "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show`/`gtd history metrics`/`gtd history judges`/`gtd strays` — " +
116
118
  "every other command prints plain text; see `gtd install` for the driver protocol briefing",
117
119
  valueHint: "<path>",
118
- help: [
119
- "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
120
- "list/gtd history show/gtd history metrics/gtd history judges/gtd strays only) output structured JSON. Bare --json prints the whole document;",
121
- "--json=<path> (a dotted key path into that document, e.g.",
122
- "kind, content, session.id) prints just that value: a",
123
- "scalar raw and unquoted, a boolean as true/false, a list",
124
- "one JSON entry per line — a numeric segment (e.g.",
125
- "skills.0) indexes into one entry of a list field instead.",
126
- "An absent optional field prints nothing and exits 0 —",
127
- "including when an earlier segment of <path> is itself",
128
- "absent/null (e.g. session.id at a non-prompt rest), which",
129
- "never counts as unknown; an unknown path is a usage error",
130
- "(exit 2).",
131
- ],
120
+ help: "Print the JSON document, or with --json=<path> one field of it (e.g. --json=kind): a scalar bare, a list one entry per line, nothing for an absent field",
121
+ helpFor: {
122
+ strays: "Print one entry of the array (--json=0), nothing past its end",
123
+ },
132
124
  },
133
125
  {
134
126
  name: "--port",
@@ -143,11 +135,7 @@ const FLAGS: readonly FlagRow[] = [
143
135
  },
144
136
  scopeError: "gtd: --port is only valid for `gtd ui`",
145
137
  valueHint: "<n>",
146
- help: [
147
- "(gtd ui only) the tailscale serve port (default: the first",
148
- "free of 8443, 10000, 443), or the bind port when --host",
149
- "opts out of serve (default: a free port)",
150
- ],
138
+ help: "The tailscale serve port (default: the first free of 8443, 10000, 443), or with --host the port to bind (default: any free one)",
151
139
  },
152
140
  {
153
141
  name: "--host",
@@ -160,11 +148,7 @@ const FLAGS: readonly FlagRow[] = [
160
148
  : Either.right(raw),
161
149
  scopeError: "gtd: --host is only valid for `gtd ui`",
162
150
  valueHint: "<addr>",
163
- help: [
164
- "(gtd ui only) opt out of the default tailscale serve front",
165
- "door and bind this address directly instead, showing it in",
166
- "the printed URL",
167
- ],
151
+ help: "Bind this address directly instead of publishing through tailscale serve",
168
152
  },
169
153
  {
170
154
  name: "--self-signed",
@@ -174,10 +158,7 @@ const FLAGS: readonly FlagRow[] = [
174
158
  decode: () => Either.right(true),
175
159
  scopeError: "gtd: --self-signed is only valid for `gtd ui`",
176
160
  valueHint: "",
177
- help: [
178
- "(gtd ui only) generate a throwaway self-signed TLS",
179
- "certificate instead of the configured ui.cert/ui.key",
180
- ],
161
+ help: "Bind directly with a throwaway self-signed certificate instead of the configured ui.cert/ui.key",
181
162
  },
182
163
  {
183
164
  name: "--dev",
@@ -187,7 +168,7 @@ const FLAGS: readonly FlagRow[] = [
187
168
  decode: () => Either.right(true),
188
169
  scopeError: "gtd: --dev is only valid for `gtd ui`",
189
170
  valueHint: "",
190
- help: ["(gtd ui only) run against local development sources", "instead of the packaged build"],
171
+ help: "Serve local development sources instead of the packaged build",
191
172
  },
192
173
  {
193
174
  name: "--provider",
@@ -200,11 +181,7 @@ const FLAGS: readonly FlagRow[] = [
200
181
  : Either.left(`gtd: --provider must be one of fixed, jev, llm — got "${raw ?? ""}"`),
201
182
  scopeError: "gtd: --provider is only valid for `gtd judge run`",
202
183
  valueHint: "<name>",
203
- help: [
204
- "(gtd judge run only) the answerer: fixed | jev | llm;",
205
- "omitted: GTD_JUDGE_PROVIDER, then judge.json, then jev",
206
- "when TYPESAFE_API_KEY is set, else llm",
207
- ],
184
+ help: "The answerer: fixed, jev or llm (default: $GTD_JUDGE_PROVIDER, then ~/.config/gtd/judge.json, then jev when $TYPESAFE_API_KEY is set, else llm)",
208
185
  },
209
186
  {
210
187
  name: "--answers",
@@ -217,11 +194,7 @@ const FLAGS: readonly FlagRow[] = [
217
194
  : Either.right(raw),
218
195
  scopeError: "gtd: --answers is only valid for `gtd judge run`",
219
196
  valueHint: "<path>",
220
- help: [
221
- "(gtd judge run --provider fixed only) a JSON verdict array",
222
- "[{ id, answer, p }]; else the GTD_JUDGE_ANSWERS env var",
223
- "(inline JSON) is read",
224
- ],
197
+ help: "With --provider fixed: a JSON verdict file [{ id, answer, p }] (default: $GTD_JUDGE_ANSWERS, inline JSON)",
225
198
  },
226
199
  {
227
200
  name: "--cost",
@@ -231,7 +204,7 @@ const FLAGS: readonly FlagRow[] = [
231
204
  decode: ([raw]) => nonNegativeNumber(raw ?? "", "--cost"),
232
205
  scopeError: "gtd: --cost is only valid for `gtd land` — a start is not a metered agent turn",
233
206
  valueHint: "<n>",
234
- help: ["(gtd land only) record the invocation's token cost"],
207
+ help: "Record the token cost of the invocation that just finished on the commit",
235
208
  },
236
209
  {
237
210
  name: "--model",
@@ -244,11 +217,12 @@ const FLAGS: readonly FlagRow[] = [
244
217
  : Either.right(raw),
245
218
  scopeError: "gtd: --model is only valid for `gtd land` (with --cost) or `gtd judge run`",
246
219
  valueHint: "<name>",
247
- help: [
248
- "(gtd land, with --cost) tag that cost's model",
249
- "(gtd judge run, llm answerer) the claude model; omitted:",
250
- "GTD_JUDGE_MODEL, then judge.json, then sonnet",
251
- ],
220
+ help: "",
221
+ helpFor: {
222
+ land: "With --cost: the model that cost was spent on",
223
+ judgeRun:
224
+ "The claude model the llm answerer uses (default: $GTD_JUDGE_MODEL, then judge.json, then sonnet)",
225
+ },
252
226
  },
253
227
  {
254
228
  name: "--workflow",
@@ -260,10 +234,7 @@ const FLAGS: readonly FlagRow[] = [
260
234
  "gtd: --workflow is only valid with no other command — use the bare `gtd --workflow <name>` " +
261
235
  "form; landing and starting are different verbs",
262
236
  valueHint: "<name>",
263
- help: [
264
- "(with no command at all) start a brand new process on",
265
- "the named workflow — authenticated as human",
266
- ],
237
+ help: "Start a new process on the named workflow",
267
238
  },
268
239
  {
269
240
  name: "--var",
@@ -308,14 +279,12 @@ const FLAGS: readonly FlagRow[] = [
308
279
  },
309
280
  scopeError: "gtd: --var requires --workflow (or `gtd history judges`)",
310
281
  valueHint: "<name>=<value>",
311
- help: [
312
- "(with --workflow; repeatable) pin a process setting for the",
313
- "new process; the name must already be declared by the",
314
- "workflow's defaults (environment settings are not",
315
- "pinnable). (gtd history judges) re-decide past verdicts",
316
- "under this threshold: judgeIdenticalMinP or",
317
- "qualityTriageMinP",
318
- ],
282
+ help: "",
283
+ helpFor: {
284
+ start: "Pin one of the workflow's declared settings for the new process (repeatable)",
285
+ historyJudges:
286
+ "Re-decide every past verdict under this threshold: judgeIdenticalMinP or qualityTriageMinP (repeatable)",
287
+ },
319
288
  },
320
289
  {
321
290
  name: "--jsonl",
@@ -325,7 +294,7 @@ const FLAGS: readonly FlagRow[] = [
325
294
  decode: () => Either.right(true),
326
295
  scopeError: "gtd: --jsonl is only valid for `gtd history judges`",
327
296
  valueHint: "",
328
- help: ["(gtd history judges only) print one JSON line per verdict", "instead of the report"],
297
+ help: "Print one JSON line per verdict instead of the report",
329
298
  conflicts: ["--json", "--var"],
330
299
  },
331
300
  {
@@ -336,12 +305,7 @@ const FLAGS: readonly FlagRow[] = [
336
305
  decode: () => Either.right(true),
337
306
  scopeError: "gtd: --open-questions is only valid for `gtd check`",
338
307
  valueHint: "",
339
- help: [
340
- "(gtd check only) ignore <mode>'s structural findings and",
341
- "instead run the qa open-questions predicate over <file>,",
342
- "printing each unanswered question one per line and exiting",
343
- "non-zero when any remain",
344
- ],
308
+ help: "Instead of <mode>'s findings, list the unanswered questions in <file>; exits 1 when any remain",
345
309
  },
346
310
  {
347
311
  name: "--open-threads",
@@ -351,15 +315,7 @@ const FLAGS: readonly FlagRow[] = [
351
315
  decode: () => Either.right(true),
352
316
  scopeError: "gtd: --open-threads is only valid for `gtd check`",
353
317
  valueHint: "",
354
- help: [
355
- "(gtd check only) ignore <mode>'s structural findings and",
356
- "instead list every open thread (a footnote whose last",
357
- "entry is the agent's) over <file>, one per line with its",
358
- "first H entry, exiting non-zero when any are open. With no",
359
- "<mode>/<file>, lists code-comment threads in the files the",
360
- "current process changed (read from its diff base, unlike",
361
- "the rest of `gtd check`)",
362
- ],
318
+ help: "Instead of <mode>'s findings, list the open threads in <file>; exits 1 when any are open. Alone, lists the open code-comment threads in the files the current process changed",
363
319
  },
364
320
  {
365
321
  name: "--verbose",
@@ -369,11 +325,7 @@ const FLAGS: readonly FlagRow[] = [
369
325
  decode: () => Either.right(true),
370
326
  scopeError: "gtd: --verbose is valid for every command — this error is unreachable",
371
327
  valueHint: "",
372
- help: [
373
- "enable stderr narration for this invocation: which rest",
374
- "resolved and how config resolved across layers. Aliased",
375
- "to -v",
376
- ],
328
+ help: "Narrate on stderr what gtd resolved and where its config came from",
377
329
  },
378
330
  ]
379
331
 
@@ -409,448 +361,373 @@ type Arity =
409
361
  // Any number of positionals, none included.
410
362
  | { readonly name: string; readonly many: true }
411
363
 
364
+ type Group = "Core loop" | "Start and stop" | "Inspect" | "Driver plumbing"
365
+
412
366
  interface CommandRow {
413
367
  readonly token: string
414
368
  readonly kind: Command["kind"]
415
369
  readonly arity: Arity
416
- readonly details: readonly string[]
370
+ readonly group: Group
371
+ /** The top-level help's one line — short enough for its column. */
372
+ readonly summary: string
373
+ /** The command's own help: prose paragraphs, wrapped when rendered (see `wrap`). */
374
+ readonly details: string
417
375
  }
418
376
 
419
377
  const COMMAND_ROWS: readonly CommandRow[] = [
420
378
  {
421
- token: "land",
422
- kind: "land",
423
- arity: "none",
424
- details: [
425
- "Land whatever the tree now shows at the currently resolved",
426
- "rest — a human capture, an agent/check turn, or an empty",
427
- "attempt (a fruitless prompt turn). Pass",
428
- "--cost=<n> (optionally --model=<name>) to record the",
429
- "just-finished invocation's token cost and model on the",
430
- "turn commit (summed into it.processCost/",
431
- "processCostByModel). Plain (the default) prints one",
432
- "human-readable sentence naming the commit subject (or a",
433
- "no-op note) plus a pointer to `gtd land --json=script | sh`",
434
- "to get the landing script — never the script itself.",
435
- "--json emits script (the actual POSIX sh",
436
- "a driver runs) alongside settled, idle, state (the",
437
- "post-land target), subject, cost and model. Exits 0 on",
438
- "success, 1 on any refusal — see the Exit codes section",
439
- "below",
440
- ],
441
- },
442
- {
443
- token: "abandon",
444
- kind: "abandon",
445
- arity: "none",
446
- details: [
447
- "End the process currently underway without completing it:",
448
- "rewind HEAD to the commit the process started from,",
449
- "keeping everything it produced as uncommitted changes. A",
450
- "no-op when no process is underway",
451
- ],
452
- },
453
- {
454
- token: "restore",
455
- kind: "restore",
379
+ token: "next",
380
+ kind: "next",
456
381
  arity: "none",
457
- details: [
458
- "Hard-reset HEAD back to the tip retained by the last",
459
- "abandon (refs/worktree/gtd/history), bringing back an",
460
- "abandoned process's turns. Refuses on a dirty working",
461
- "tree, when there is no retained history, or when HEAD has",
462
- "advanced past the retained tip with commits that would be",
463
- "lost",
464
- ],
382
+ group: "Core loop",
383
+ summary: "Print what should happen next",
384
+ details: `Print what should happen next in the process, changing nothing — safe to poll.
385
+
386
+ Plain output is for a person: a status header (the step, who acts, the pending changes, where landing them would go), then the step's content. At a prompt the header goes to stderr, so stdout is exactly the agent's input.
387
+
388
+ --json prints one JSON document for a driver: its kind (capture, message, script, prompt or stalled) says what to do, its content what to run or show. --json=<path> prints one field of it; an unknown path is a usage error (exit 2).`,
465
389
  },
466
390
  {
467
- token: "next",
468
- kind: "next",
391
+ token: "land",
392
+ kind: "land",
469
393
  arity: "none",
470
- details: [
471
- "Print the resolved rest's beat (no mutation, safe to",
472
- "poll), in one of three encodings. Plain (the default): a",
473
- "status summary, a blank line, then the step verbatim — at a",
474
- "script/capture rest an instruction line ('Run this",
475
- "script:' / 'The edit is already made — run `gtd land` to",
476
- "land it.') precedes the status summary; at a prompt rest",
477
- "it's the bare step (plus the self-validation instruction",
478
- "when applicable) with no header at all, since those bytes",
479
- "are the agent's own input. --json emits the one structured",
480
- "surface gtd has: kind (capture|message|script|prompt|",
481
- "stalled) selects what a driver does, content is what it",
482
- "runs or shows, idle marks the workflow's initial state with",
483
- "a clean tree, plus the prompt session, model, validate",
484
- "script, log path, changes, next and the resting state's own",
485
- "fields. --json=<path> (a dotted key path into that same",
486
- "document, e.g. kind, content, session.id) prints just that",
487
- "value instead of the whole document — see --json's own help",
488
- "above. Exits 0 — see the Exit codes section below",
489
- ],
394
+ group: "Core loop",
395
+ summary: "Record what the tree now shows",
396
+ details: `Record what the working tree now shows at the current step: a person's edit, an agent's or a check's turn, or an empty attempt when an agent changed nothing.
397
+
398
+ gtd writes nothing itself. Plain output names the commit it would make; --json=script prints the script that makes it:
399
+
400
+ s=$(gtd land --json=script) && sh -c "$s"
401
+
402
+ --json prints the whole document: the script, the step the process lands at, the commit subject, and the recorded cost and model. A refusal exits 1.`,
490
403
  },
491
404
  {
492
405
  token: "validate",
493
406
  kind: "validate",
494
407
  arity: "none",
495
- details: [
496
- "Print the script that formats (when declared) then",
497
- "validates the resolved rest's steering file, using its",
498
- "mode's commands (its file:/mode:), instead of running it —",
499
- "a driver runs the script and reads the findings from its",
500
- 'own exit code/output. Always exits 0; prints "nothing to',
501
- 'validate" when the resolved rest declares nothing to run.',
502
- "On a non-zero validate exit the emitted script prints a",
503
- "ready-to-send fix prompt (instruction + findings) and",
504
- "exits with the validator's own code",
505
- ],
408
+ group: "Core loop",
409
+ summary: "Print the script that checks the steering file",
410
+ details: `Print the script that formats, then validates, the current step's steering file with its mode's commands. gtd runs nothing: the script's exit code is the verdict, and on a failure it prints a ready-to-send fix prompt. Prints "nothing to validate" when the step declares no file to check.`,
506
411
  },
507
412
  {
508
- token: "lsp",
509
- kind: "lsp",
510
- arity: "none",
511
- details: ["Start the LSP server for .gtd/ steering files (stdio)"],
413
+ token: "door",
414
+ kind: "door",
415
+ arity: { name: "name", variadic: "args" },
416
+ group: "Start and stop",
417
+ summary: "Start a process through a named shortcut",
418
+ details: `Start a process through a door: a named shortcut to a workflow that maps its positional args to the workflow's settings. It prints the same start script \`gtd --workflow\` does. Each workflow the config exports brings its own doors, plus the config's \`doors\` export.
419
+
420
+ With no <name>, an unknown one, or the wrong number of args, it lists the doors and exits 2.`,
512
421
  },
513
422
  {
514
- token: "ui",
515
- kind: "ui",
423
+ token: "doors",
424
+ kind: "doors",
516
425
  arity: "none",
517
- details: [
518
- "Expose gtd's web/phone client for THIS worktree — the",
519
- "invoking directory, never a configured list of roots — and",
520
- "refuses outside a repository like every other state command.",
521
- "By default, publishes through `tailscale serve` (reachable",
522
- "from anywhere on the tailnet, including over a DERP relay)",
523
- "with tailscaled terminating TLS, walking 8443, then 10000,",
524
- "then 443 until one publishes; falls back to binding a local",
525
- "HTTPS server directly, never refusing, when none of the",
526
- "three works. --host <addr> opts out of serve and binds that",
527
- "address directly instead; --port <n> overrides the serve",
528
- "port (default: the first free of 8443, 10000, 443), or the",
529
- "bind port when --host is given (default: a free port);",
530
- "--self-signed also opts out of serve, generating a",
531
- "throwaway TLS certificate instead of the configured",
532
- "ui.cert/ui.key; --dev runs against local development sources",
533
- "instead of the packaged build",
534
- ],
426
+ group: "Start and stop",
427
+ summary: "List the doors",
428
+ details: `List every door, sorted: one \`name [args] → workflow\` line each. --json prints a JSON array of { name, workflow, args: [{ name, optional }] }. Writes nothing.`,
535
429
  },
536
430
  {
537
- token: "check",
538
- kind: "check",
539
- arity: { names: ["mode", "file"] },
540
- details: [
541
- "Read <file> and run the built-in steering format named",
542
- "<mode> (see `gtd validate`'s modes: qa, review, findings)",
543
- "over its contents, printing each finding one per line and",
544
- "exiting non-zero when there are any. Resolves no workflow",
545
- "state and reads no config — standalone, runnable from any",
546
- "directory with <mode>/<file> given explicitly. This is what a",
547
- "workflow's emitted validation script invokes as a leaf step.",
548
- "--open-questions runs the qa unanswered-questions predicate",
549
- "instead (see --help); --open-threads lists open threads.",
550
- "`gtd check --open-threads` alone lists open code-comment",
551
- "threads (`// H: ...`) in the files the process changed;",
552
- "it prints nothing when no process is underway.",
553
- ],
554
- },
555
- {
556
- token: "uncheck",
557
- kind: "uncheck",
558
- arity: { name: "file" },
559
- details: [
560
- "Read <file> and reset every review-mode `- [x]`/`- [X]`",
561
- "file-pointer box back to `- [ ]`, writing the result back",
562
- "only when the bytes actually changed. Resolves no workflow",
563
- "state and reads no config — standalone, runnable from any",
564
- "directory with <file> given explicitly. This is what the",
565
- "landing script runs ahead of the commit at the human review",
566
- "gate, so no tick ever reaches a commit. Takes no <mode> —",
567
- "it means review-mode file pointers and nothing else, never",
568
- "qa-mode's answered-question boxes. A missing file writes",
569
- "nothing and exits 0",
570
- ],
431
+ token: "abandon",
432
+ kind: "abandon",
433
+ arity: "none",
434
+ group: "Start and stop",
435
+ summary: "Stop the process, keeping its changes",
436
+ details: `Stop the process underway without finishing it. The printed script moves HEAD back to the commit the process started from and keeps everything it produced as uncommitted changes; \`gtd restore\` undoes it. With no process underway the script does nothing.
437
+
438
+ s=$(gtd abandon) && sh -c "$s"`,
571
439
  },
572
440
  {
573
- token: "install",
574
- kind: "install",
441
+ token: "restore",
442
+ kind: "restore",
575
443
  arity: "none",
576
- details: [
577
- "Print a complete, self-contained briefing that teaches an",
578
- "agent (or a human) to build a gtd driver in any shell or",
579
- "runtime — the self-serve version of",
580
- "https://github.com/pmelab/gtd/blob/main/docs/driver.md's",
581
- "'Writing your own driver'. At a repository root no",
582
- "gtd.config.ts governs yet, it also writes one importing each",
583
- "shipped workflow from @pmelab/gtd/workflows/<name>; anywhere",
584
- "else it writes nothing.",
585
- ],
444
+ group: "Start and stop",
445
+ summary: "Bring back the last abandoned process",
446
+ details: `Bring back the process the last \`gtd abandon\` stopped: the printed script resets HEAD to that process's last commit. Refuses on a dirty working tree, when nothing was abandoned, or when commits made since would be lost.`,
586
447
  },
587
448
  {
588
449
  token: "summary",
589
450
  kind: "summary",
590
451
  arity: "none",
591
- details: [
592
- "Print the prompt for an agent to write the process HEAD",
593
- "closes or sits inside its own closing message — the entry",
594
- "commit, each human-authored commit (a review round, an",
595
- "answered question gate), the diff range to inspect, and",
596
- "it.processCost/processCostByModel. Writes nothing: no git,",
597
- "no state transition, no file, no session identity — the",
598
- "driver pipes the output to a cold agent and does what it",
599
- "wants with the result (a squash, an amend, a PR body).",
600
- "Refuses (exit 1) when the workflow declares no summary:",
601
- "template, or when the resolved run has no commits to name —",
602
- "it is runnable any time before the next thing lands on the",
603
- "branch",
604
- ],
452
+ group: "Inspect",
453
+ summary: "Print the prompt for the closing message",
454
+ details: `Print the prompt for an agent to write the closing message of the process HEAD closes or sits inside: its first commit, each commit a person made, the diff range to read, and its cost. Writes nothing — what to do with the answer (a squash message, a PR body) is the driver's call.
455
+
456
+ Refuses when the workflow declares no summary template, or when the process has no commits.`,
605
457
  },
606
458
  {
607
459
  token: "base",
608
460
  kind: "base",
609
461
  arity: "none",
610
- details: [
611
- "Print the review anchor hash — the diff base an external",
612
- "tool (a diff, a PR tool, another agent) should point at —",
613
- "bare, newline-terminated, and nothing else. Writes nothing:",
614
- "no git, no state transition, no session identity. Before",
615
- "the first review round it's the process's diff base;",
616
- "afterward it's the most-recent review round's boundary.",
617
- "Refuses (exit 1) when no process is underway.",
618
- ],
619
- },
620
- {
621
- token: "door",
622
- kind: "door",
623
- arity: { name: "name", variadic: "args" },
624
- details: [
625
- "Start a process through a named shortcut, mapping the",
626
- "positional args to that workflow's process settings — the",
627
- "same start `gtd --workflow` makes, printed the same way.",
628
- "Each workflow the config exports brings its own doors (gtd's",
629
- "`fix`, `review [base]`, `rollout <flag>` and",
630
- "`cleanup <flag>`), plus the config's `doors` export. An",
631
- "unknown door, or too few or too many args, is a usage error",
632
- "(exit 2) naming the doors or the door's synopsis",
633
- ],
634
- },
635
- {
636
- token: "doors",
637
- kind: "doors",
638
- arity: "none",
639
- details: [
640
- "List every door, sorted: one `name [args] → workflow` line",
641
- "each. --json prints one JSON array of { name, workflow,",
642
- "args: [{ name, optional }] } instead; --json=<path> reads one",
643
- "value off it. Writes nothing",
644
- ],
645
- },
646
- {
647
- token: "judge",
648
- kind: "judge",
649
- arity: "none",
650
- details: [
651
- "Print the resolved rest's pending judgment — the prepared",
652
- "state, its typed questions, and their criteria — the same",
653
- "judge field `gtd next --json` already carries. Read-only:",
654
- "resolves no session, writes nothing. Refuses (exit 1) when",
655
- "the resolved rest declares no judge:",
656
- ],
657
- },
658
- {
659
- token: "judge answer",
660
- kind: "judgeAnswer",
661
- arity: "none",
662
- details: [
663
- "Read a verdict off stdin — one { id, answer, p } entry per",
664
- "question the pending judgment declared — and decode it",
665
- "against an Effect Schema built from those same question",
666
- "ids. Refuses (exit 1) when the resolved rest declares no",
667
- "judge:. Exits 2 (a usage error, like an unknown --json",
668
- "selector) when stdin isn't valid JSON or the verdict",
669
- "doesn't decode against the pending question ids.",
670
- ],
671
- },
672
- {
673
- token: "judge run",
674
- kind: "judgeRun",
675
- arity: "none",
676
- details: [
677
- "Answer a judgment: read the `gtd judge --json` document off",
678
- "stdin, write a verdict [{ id, answer, p }] on stdout — what",
679
- "`gtd judge answer` decodes. --provider fixed answers from",
680
- "--answers <path> or GTD_JUDGE_ANSWERS; questions the file",
681
- "does not cover are left out. --provider jev asks TypeSafe",
682
- "(TYPESAFE_API_KEY). --provider llm asks the `claude` CLI",
683
- "on PATH (Claude Code only), model sonnet unless --model.",
684
- "No --provider: GTD_JUDGE_PROVIDER, then ~/.config/gtd/",
685
- "judge.json, else jev when TYPESAFE_API_KEY is set and no",
686
- "model is given, else llm; --model likewise falls back to",
687
- "GTD_JUDGE_MODEL, then the file's model. Needs no",
688
- "repository. jev and llm exit 1 when they cannot answer",
689
- "every question, with nothing on stdout",
690
- ],
462
+ group: "Inspect",
463
+ summary: "Print the commit a review diff starts from",
464
+ details: `Print the commit a review diff should start from, bare: the process's diff base before the first review round, the latest round's boundary after it. Writes nothing; refuses when no process is underway.`,
691
465
  },
692
466
  {
693
467
  token: "strays",
694
468
  kind: "strays",
695
469
  arity: { name: "range", variadic: "glob" },
696
- details: [
697
- "List the files <range> (<base>..<tip>, an empty side is HEAD)",
698
- "touched in its committed net diff that match none of the",
699
- "globs. Anchored at the repo root: * stays within one path",
700
- "segment, ** crosses them, everything else is literal — quote",
701
- "globs in the shell. stdout: a sorted JSON array, [] when",
702
- "clean; stderr: a summary, plus one stray: line per path with",
703
- "--verbose. --json=<n> picks one entry, nothing past the end;",
704
- "a symlink is a path like any other, never followed; a",
705
- "submodule counts as its path, whatever diff.ignoreSubmodules",
706
- "or .gitmodules ignore say. Runs in any git repository, no",
707
- "process needed. Exits 10 when",
708
- "strays were found, 1 on an unknown revision, 2 on a bad range",
709
- ],
470
+ group: "Inspect",
471
+ summary: "List changed files outside the globs",
472
+ details: `List the files changed in <range> (<base>..<tip>, an empty side is HEAD) that match none of the globs. Globs are anchored at the repository root: * stays within one path segment, ** crosses them, everything else is literal — quote them in the shell.
473
+
474
+ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose adds one stray: line per path. Runs in any git repository, no process needed. Exits 10 when strays were found, 1 on an unknown revision, 2 on a bad range.`,
710
475
  },
711
476
  {
712
477
  token: "history list",
713
478
  kind: "historyList",
714
479
  arity: "none",
715
- details: [
716
- "List the finished processes kept behind refs/gtd/history/*,",
717
- "newest first: entry, branch, tip, date and subject per line.",
718
- "--json prints one array of { ref, entry, branch, tip, date,",
719
- "subject }. Writes nothing and never replays; with no refs it",
720
- "hints at `gtd history fetch | sh` and still exits 0",
721
- ],
480
+ group: "Inspect",
481
+ summary: "List finished processes",
482
+ details: `List the finished processes this clone keeps, newest first: entry, branch, tip, date and subject. --json prints an array of { ref, entry, branch, tip, date, subject }. Writes nothing; with none kept it suggests \`gtd history fetch\`.`,
722
483
  },
723
484
  {
724
485
  token: "history show",
725
486
  kind: "historyShow",
726
487
  arity: { name: "ref" },
727
- details: [
728
- "Replay one kept process under today's config and print its",
729
- "steps, judge verdicts and approvals. <ref> is a full ref, an",
730
- "entry hash (or unique prefix) or a branch name. Refuses",
731
- "(exit 1) when a config file differs from its content at the",
732
- "ref's tip. A version mismatch only warns on stderr. --json",
733
- "prints the trail as one document; writes nothing",
734
- ],
488
+ group: "Inspect",
489
+ summary: "Replay one finished process",
490
+ details: `Replay one finished process under today's config and print its steps, judge verdicts and approvals. <ref> is a full ref, an entry hash or a unique prefix of one, or a branch name. Refuses when a config file changed since; a different gtd version only warns. --json prints the trail as one document. Writes nothing.`,
735
491
  },
736
492
  {
737
493
  token: "history metrics",
738
494
  kind: "historyMetrics",
739
495
  arity: { name: "ref", many: true },
740
- details: [
741
- "Report how the kept processes went: builds whose naive pass",
742
- "landed or was rebuilt (the refactor-first rate), Gtd-Cost by",
743
- "model and phase with the double work of a rebuild, quality-lap",
744
- "findings per lens, and red checks that passed on their",
745
- "re-check — per process and in total. Each <ref>",
746
- "is read as by history show, but a branch name means every",
747
- "process kept from it; none means every ref.",
748
- "Reads commit subjects and trailers, never replays, so a",
749
- "changed config is no refusal. --json prints { processes, total",
750
- "} as one document; writes nothing",
751
- ],
496
+ group: "Inspect",
497
+ summary: "Report cost and rework of finished processes",
498
+ details: `Report how finished processes went: builds that landed first time or were rebuilt, cost by model and phase, review findings per lens, and red checks that passed when re-run — per process and in total. <ref> is read as by \`gtd history show\`, but a branch name means every process kept from it; no <ref> means all of them. Never replays, so a changed config is no reason to refuse. --json prints { processes, total }. Writes nothing.`,
752
499
  },
753
500
  {
754
501
  token: "history judges",
755
502
  kind: "historyJudges",
756
503
  arity: { name: "ref", many: true },
757
- details: [
758
- "Calibrate the judges of the kept processes from their",
759
- "recorded Gtd-Judge verdicts: per judge (retry, triage, clear)",
760
- "and p bucket, how many verdicts later events overturned.",
761
- "--var <name>=<value> re-decides every past verdict under",
762
- "another threshold and prints what would have changed;",
763
- "--jsonl prints one { process, judge, commit, step, question,",
764
- "state, answer, p, outcome } line per verdict instead. <ref>",
765
- "is read as by history metrics. Never replays and asks no",
766
- "model. --json prints { processes, total, whatIf }; writes",
767
- "nothing",
768
- ],
504
+ group: "Inspect",
505
+ summary: "Calibrate the judges of finished processes",
506
+ details: `Calibrate the judges of finished processes from their recorded verdicts: per judge and confidence bucket, how many verdicts later events overturned. <ref> is read as by \`gtd history metrics\`. Never replays and asks no model. --json prints { processes, total, whatIf }. Writes nothing.`,
769
507
  },
770
508
  {
771
509
  token: "history fetch",
772
510
  kind: "historyFetch",
773
511
  arity: { name: "remote", optional: true },
774
- details: [
775
- "Print a script that fetches every refs/gtd/history/* from",
776
- "<remote> (default origin) and reports how many arrived — run",
777
- "it with `gtd history fetch | sh`. Never runs git itself",
778
- ],
512
+ group: "Inspect",
513
+ summary: "Print a script that fetches finished processes",
514
+ details: `Print a script that fetches every finished process kept on <remote> (default: origin) and reports how many arrived. gtd runs no git itself:
515
+
516
+ s=$(gtd history fetch) && sh -c "$s"`,
517
+ },
518
+ {
519
+ token: "ui",
520
+ kind: "ui",
521
+ arity: "none",
522
+ group: "Inspect",
523
+ summary: "Serve the web and phone client",
524
+ details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing.`,
525
+ },
526
+ {
527
+ token: "install",
528
+ kind: "install",
529
+ arity: "none",
530
+ group: "Driver plumbing",
531
+ summary: "Print a briefing for writing a driver",
532
+ details: `Print a self-contained briefing that teaches an agent or a person to build a gtd driver in any shell or runtime, and where to get a workflow — gtd ships none. Writes nothing.`,
533
+ },
534
+ {
535
+ token: "judge",
536
+ kind: "judge",
537
+ arity: "none",
538
+ group: "Driver plumbing",
539
+ summary: "Print the pending judgment",
540
+ details: `Print the current step's pending judgment: the prepared state, its questions and their criteria — the judge field \`gtd next --json\` carries. Writes nothing; refuses when the step declares no judge.`,
541
+ },
542
+ {
543
+ token: "judge answer",
544
+ kind: "judgeAnswer",
545
+ arity: "none",
546
+ group: "Driver plumbing",
547
+ summary: "Land a verdict read from stdin",
548
+ details: `Read a verdict from stdin — one { id, answer, p } entry per pending question — and land it the way \`gtd land\` does: plain output names the commit, --json=script prints the script. Refuses when the step declares no judge; a verdict that is not JSON, or names a question that was not asked, is a usage error (exit 2).`,
549
+ },
550
+ {
551
+ token: "judge run",
552
+ kind: "judgeRun",
553
+ arity: "none",
554
+ group: "Driver plumbing",
555
+ summary: "Answer a judgment with a model",
556
+ details: `Answer a judgment: read the \`gtd judge --json\` document from stdin and print the verdict [{ id, answer, p }] \`gtd judge answer\` reads. Needs no repository.
557
+
558
+ The fixed answerer reads --answers and leaves out the questions it does not cover; jev asks TypeSafe ($TYPESAFE_API_KEY); llm asks the \`claude\` CLI on PATH. jev and llm exit 1, printing nothing, when they cannot answer every question.`,
559
+ },
560
+ {
561
+ token: "check",
562
+ kind: "check",
563
+ arity: { names: ["mode", "file"] },
564
+ group: "Driver plumbing",
565
+ summary: "Check a steering file's format",
566
+ details: `Run the steering format <mode> (qa, review or findings) over <file> and print each finding on a line of its own; exits 1 when there are any. Reads no workflow and runs from any directory — emitted validation scripts call it.`,
567
+ },
568
+ {
569
+ token: "uncheck",
570
+ kind: "uncheck",
571
+ arity: { name: "file" },
572
+ group: "Driver plumbing",
573
+ summary: "Untick a review file's boxes",
574
+ details: `Reset every ticked review box (- [x]) in <file> to - [ ], writing the file only when it changes; a missing file writes nothing. Reads no workflow and runs from any directory — the landing script at a review gate calls it, so no tick is ever committed.`,
575
+ },
576
+ {
577
+ token: "lsp",
578
+ kind: "lsp",
579
+ arity: "none",
580
+ group: "Driver plumbing",
581
+ summary: "Start the steering-file language server",
582
+ details: `Start the language server for .gtd/ steering files on stdio.`,
779
583
  },
780
584
  ]
781
585
 
782
586
  const commandByToken = (token: string): CommandRow | undefined =>
783
587
  COMMAND_ROWS.find((r) => r.token === token)
784
588
 
589
+ /** Every word that may follow `gtd`, for "did you mean". */
590
+ const COMMAND_WORDS: readonly string[] = [
591
+ ...new Set([...COMMAND_ROWS.map((r) => r.token.split(" ")[0]!), "help", "version"]),
592
+ ]
593
+
785
594
  // ---------------------------------------------------------------------------
786
595
  // Help rendering
787
596
  // ---------------------------------------------------------------------------
788
597
 
789
- const COLUMN = 19
790
-
791
- const renderBlock = (header: string, lines: readonly string[]): string => {
792
- const [first, ...rest] = lines
793
- const headerCell = ` ${header}`
794
- const headerLine =
795
- headerCell.length < COLUMN
796
- ? `${headerCell.padEnd(COLUMN)}${first ?? ""}\n`
797
- : `${headerCell}\n${" ".repeat(COLUMN)}${first ?? ""}\n`
798
- return headerLine + rest.map((l) => `${" ".repeat(COLUMN)}${l}\n`).join("")
598
+ const TOP_COLUMN = 28
599
+ const OPTION_COLUMN = 22
600
+
601
+ const synopsis = (row: CommandRow): string => {
602
+ const { token, arity } = row
603
+ if (arity === "none") return token
604
+ if ("names" in arity) return `${token} <${arity.names[0]}> <${arity.names[1]}>`
605
+ if ("variadic" in arity) return `${token} <${arity.name}> [${arity.variadic}...]`
606
+ if ("optional" in arity) return `${token} [${arity.name}]`
607
+ if ("many" in arity) return `${token} [${arity.name}...]`
608
+ return `${token} <${arity.name}>`
799
609
  }
800
610
 
801
- const START_SHORT_FORM = renderBlock("(no command) --workflow <name>", [
802
- "Starts a new process authenticated as human, e.g.",
803
- "'gtd --workflow <name>'",
804
- ])
805
-
806
- const VERSION_BLOCK = renderBlock("version", ["Print version and exit"])
807
- const HELP_BLOCK = renderBlock("help", ["Print this help and exit"])
808
-
809
611
  const SPACE_FORM_FLAGS = new Set(["--workflow", "--var"])
810
612
 
811
613
  const flagHeader = (row: FlagRow): string => {
812
614
  if (row.valueHint === "") return row.name
615
+ if (row.arity === "optional") return `${row.name}[=${row.valueHint}]`
813
616
  return SPACE_FORM_FLAGS.has(row.name)
814
617
  ? `${row.name} ${row.valueHint}`
815
618
  : `${row.name}=${row.valueHint}`
816
619
  }
817
620
 
621
+ const START_ROW = {
622
+ term: "--workflow <name>",
623
+ summary: "Start a process on a workflow; --var <name>=<value> pins one of its settings",
624
+ }
625
+
626
+ const GLOBAL_OPTIONS: readonly (readonly [string, string])[] = [
627
+ ["-v, --verbose", "Narrate on stderr what gtd resolved"],
628
+ ["-h, --help", "Print help; gtd <command> --help for one command"],
629
+ ["-V, --version", "Print the version"],
630
+ ]
631
+
632
+ const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
633
+ [
634
+ "help [command]",
635
+ "Print help for gtd or one command",
636
+ "Print gtd's overview, or with <command> that command's own help.",
637
+ ],
638
+ ["version", "Print the version", "Print gtd's version."],
639
+ ]
640
+
641
+ const EXAMPLES = ` $ gtd --workflow <name> start a process
642
+ $ gtd next see what happens next
643
+ $ s=$(gtd land --json=script) && sh -c "$s" land the turn
644
+ $ gtd help land one command's details`
645
+
646
+ const lines = (block: readonly string[]): string => block.map((l) => `${l}\n`).join("")
647
+
648
+ const GROUPS: readonly Group[] = ["Core loop", "Start and stop", "Inspect", "Driver plumbing"]
649
+
818
650
  export const renderHelp = (): string => {
819
- const commandBlocks = COMMAND_ROWS.map((row) => {
820
- const header =
821
- row.arity === "none"
822
- ? row.token
823
- : "names" in row.arity
824
- ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>`
825
- : "variadic" in row.arity
826
- ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]`
827
- : "optional" in row.arity
828
- ? `${row.token} [${row.arity.name}]`
829
- : "many" in row.arity
830
- ? `${row.token} [${row.arity.name}...]`
831
- : `${row.token} <${row.arity.name}>`
832
- return renderBlock(header, row.details)
651
+ const groups = GROUPS.map((group) => {
652
+ const rows = COMMAND_ROWS.filter((r) => r.group === group).flatMap((r) =>
653
+ definition(synopsis(r), r.summary, TOP_COLUMN),
654
+ )
655
+ const start =
656
+ group === "Start and stop" ? definition(START_ROW.term, START_ROW.summary, TOP_COLUMN) : []
657
+ const extra =
658
+ group === "Driver plumbing"
659
+ ? EXTRA_TOPICS.flatMap(([term, summary]) => definition(term, summary, TOP_COLUMN))
660
+ : []
661
+ return `${group}:\n${lines([...start, ...rows, ...extra])}`
833
662
  })
834
- const commands = [
835
- commandBlocks[0], // land
836
- START_SHORT_FORM,
837
- ...commandBlocks.slice(1), // abandon..base
838
- VERSION_BLOCK,
839
- HELP_BLOCK,
840
- ].join("")
841
-
842
- const options = FLAGS.map((row) => renderBlock(flagHeader(row), row.help)).join("")
843
- const versionHelpOptions =
844
- renderBlock("--version, -V", ["Print version and exit"]) +
845
- renderBlock("--help, -h", ["Print this help and exit"])
663
+ return (
664
+ "Usage: gtd <command> [options]\n\n" +
665
+ lines(
666
+ wrap(
667
+ "gtd reads a repository's git state and prints what should happen next in its workflow. It never acts on it: a driver, or you, runs what it prints.",
668
+ ),
669
+ ) +
670
+ "\n" +
671
+ groups.join("\n") +
672
+ "\nOptions:\n" +
673
+ lines(GLOBAL_OPTIONS.flatMap(([term, text]) => definition(term, text, TOP_COLUMN))) +
674
+ `\nExamples:\n${EXAMPLES}\n\n` +
675
+ lines(
676
+ wrap(
677
+ "Run `gtd help <command>` for a command's details and options, and `gtd install` to learn how to drive gtd.",
678
+ ),
679
+ )
680
+ )
681
+ }
682
+
683
+ /** Shown on stderr above a bare `gtd`'s usage error — the overview stays one `gtd help` away. */
684
+ const SHORT_USAGE = `Usage: gtd <command> [options]
685
+
686
+ gtd --workflow <name> start a process
687
+ gtd next print what should happen next
688
+ gtd land record what the tree now shows
689
+
690
+ `
846
691
 
692
+ const renderCommandHelp = (row: CommandRow): string => {
693
+ const options = FLAGS.filter((f) => f.name !== "--verbose" && f.scope(row.kind)).flatMap((f) =>
694
+ definition(flagHeader(f), f.helpFor?.[row.kind] ?? f.help, OPTION_COLUMN),
695
+ )
696
+ const global = definition("-v, --verbose", "Narrate on stderr what gtd resolved", OPTION_COLUMN)
847
697
  return (
848
- `Usage: gtd [command] [options]\n\n` +
849
- `Commands:\n${commands}\n` +
850
- `Options:\n${options}${versionHelpOptions}`
698
+ `Usage: gtd ${synopsis(row)}${options.length > 0 ? " [options]" : ""}\n\n` +
699
+ lines(wrap(row.details)) +
700
+ `\nOptions:\n${lines([...options, ...global])}`
851
701
  )
852
702
  }
853
703
 
704
+ /** `gtd help <topic…>`'s text: one command's help, every `history` subcommand's for `history`, or undefined for an unknown topic. */
705
+ const renderTopicHelp = (topic: readonly string[]): string | undefined => {
706
+ const two = commandByToken(topic.slice(0, 2).join(" "))
707
+ if (two !== undefined && topic.length >= 2) return renderCommandHelp(two)
708
+ const [first = ""] = topic
709
+ const one = commandByToken(first)
710
+ if (one !== undefined) return renderCommandHelp(one)
711
+ const extra = EXTRA_TOPICS.find(([term]) => term.split(" ")[0] === first)
712
+ if (extra !== undefined) return `Usage: gtd ${extra[0]}\n\n${lines(wrap(extra[2]))}`
713
+ const family = COMMAND_ROWS.filter((r) => r.token.startsWith(`${first} `))
714
+ if (first !== "" && family.length > 0) return family.map(renderCommandHelp).join("\n")
715
+ return undefined
716
+ }
717
+
718
+ /** A usage error's tail: the nearest candidate when `word` looks like a typo of one, and where the full list is. */
719
+ const didYouMean = (
720
+ word: string,
721
+ candidates: readonly string[],
722
+ prefix = "",
723
+ pointer = "`gtd help` for every command",
724
+ ): string => {
725
+ const guess = closest(word, candidates)
726
+ return guess === undefined
727
+ ? ` — run ${pointer}`
728
+ : ` — did you mean \`${prefix}${guess}\`? Run ${pointer}`
729
+ }
730
+
854
731
  // ---------------------------------------------------------------------------
855
732
  // Tokenizer
856
733
  // ---------------------------------------------------------------------------
@@ -867,6 +744,17 @@ interface TokenizeOk {
867
744
  readonly byFlag: ReadonlyMap<string, readonly string[]>
868
745
  readonly present: ReadonlySet<string>
869
746
  readonly jsonSeen: boolean
747
+ /** The positional right after a bare `--json` — most likely a path meant as `--json=<path>`. */
748
+ readonly afterBareJson?: string
749
+ }
750
+
751
+ const unknownOption = (tok: string, jsonSeen: boolean): TokenizeError => {
752
+ const candidates = tok.startsWith("--") ? FLAGS.map((f) => f.name) : []
753
+ return {
754
+ kind: "error",
755
+ message: `gtd: unknown option '${tok}'${didYouMean(tok.split("=")[0]!, candidates, "", "`gtd help <command>` for its options")}`,
756
+ jsonSeen,
757
+ }
870
758
  }
871
759
 
872
760
  // fallow-ignore-next-line complexity
@@ -875,35 +763,25 @@ const tokenize = (tail: readonly string[]): TokenizeError | TokenizeOk => {
875
763
  const byFlag = new Map<string, string[]>()
876
764
  const present = new Set<string>()
877
765
  let jsonSeen = false
766
+ let afterBareJson: string | undefined
878
767
 
879
768
  for (let i = 0; i < tail.length; i++) {
880
769
  const original = tail[i]!
881
770
  const tok = SINGLE_DASH_ALIASES.get(original) ?? original
882
- if (!tok.startsWith("--")) {
771
+ if (!tok.startsWith("-") || tok === "-") {
772
+ if (tail[i - 1] === "--json") afterBareJson ??= original
883
773
  positionals.push(original)
884
774
  continue
885
775
  }
886
- const row = flagByToken(tok)
887
- if (row === undefined) {
888
- return {
889
- kind: "error",
890
- message: `gtd: unknown option '${tok}' — see \`gtd --help\``,
891
- jsonSeen,
892
- }
893
- }
776
+ const row = tok.startsWith("--") ? flagByToken(tok) : undefined
777
+ if (row === undefined) return unknownOption(tok, jsonSeen)
894
778
  const eq = tok.indexOf("=")
895
779
  const alreadyPresent = present.has(row.name)
896
780
  present.add(row.name)
897
781
  if (row.name === "--json") jsonSeen = true
898
782
 
899
783
  if (row.arity === 0) {
900
- if (eq !== -1) {
901
- return {
902
- kind: "error",
903
- message: `gtd: unknown option '${tok}' — see \`gtd --help\``,
904
- jsonSeen,
905
- }
906
- }
784
+ if (eq !== -1) return unknownOption(tok, jsonSeen)
907
785
  if (!byFlag.has(row.name)) byFlag.set(row.name, [])
908
786
  continue
909
787
  }
@@ -961,7 +839,14 @@ const tokenize = (tail: readonly string[]): TokenizeError | TokenizeOk => {
961
839
  byFlag.set(row.name, [...existing, raw])
962
840
  }
963
841
 
964
- return { kind: "ok", positionals, byFlag, present, jsonSeen }
842
+ return {
843
+ kind: "ok",
844
+ positionals,
845
+ byFlag,
846
+ present,
847
+ jsonSeen,
848
+ ...(afterBareJson !== undefined ? { afterBareJson } : {}),
849
+ }
965
850
  }
966
851
 
967
852
  // ---------------------------------------------------------------------------
@@ -1056,24 +941,43 @@ const buildLandCommand = (bag: {
1056
941
  ...(bag["--model"] !== undefined ? { model: bag["--model"] } : {}),
1057
942
  })
1058
943
 
944
+ const HELP_FLAGS = new Set(["--help", "-h"])
945
+
946
+ /** `--help`/`-h` anywhere: the named command's own help, else the overview — never a usage error, so a typo'd command still gets help. */
947
+ const helpPlan = (argv: readonly string[]): CliPlan => {
948
+ const tokenized = tokenize(argv.slice(2).filter((tok) => !HELP_FLAGS.has(tok)))
949
+ const topic = tokenized.kind === "ok" ? tokenized.positionals : []
950
+ return { kind: "output", stdout: renderTopicHelp(topic) ?? renderHelp() }
951
+ }
952
+
953
+ /** `gtd help [topic…]`: an unknown topic is a usage error naming the nearest command. */
954
+ const helpCommandPlan = (topic: readonly string[], json: boolean): CliPlan => {
955
+ if (topic.length === 0) return { kind: "output", stdout: renderHelp() }
956
+ const text = renderTopicHelp(topic)
957
+ if (text !== undefined) return { kind: "output", stdout: text }
958
+ return usagePlan(
959
+ `gtd: help: no command '${topic.join(" ")}'${didYouMean(topic[0]!, COMMAND_WORDS, "gtd help ")}`,
960
+ json,
961
+ )
962
+ }
963
+
1059
964
  // fallow-ignore-next-line complexity
1060
965
  export const parseArgv = (argv: readonly string[]): CliPlan => {
1061
- if (argv.includes("--help") || argv.includes("-h"))
1062
- return { kind: "output", stdout: renderHelp() }
966
+ if (argv.some((tok) => HELP_FLAGS.has(tok))) return helpPlan(argv)
1063
967
  if (argv.includes("--version") || argv.includes("-V")) {
1064
968
  return { kind: "output", stdout: `${GTD_VERSION}\n` }
1065
969
  }
1066
970
 
1067
971
  const tokenized = tokenize(argv.slice(2))
1068
972
  if (tokenized.kind === "error") return usagePlan(tokenized.message, tokenized.jsonSeen)
1069
- const { positionals, byFlag, present, jsonSeen } = tokenized
973
+ const { positionals, byFlag, present, jsonSeen, afterBareJson } = tokenized
1070
974
 
1071
975
  const first = positionals[0]
1072
976
  const workflowRaw = byFlag.get("--workflow")?.[0]
1073
977
  const workflowPresent = workflowRaw !== undefined
1074
978
 
1075
979
  if (first === "version") return { kind: "output", stdout: `${GTD_VERSION}\n` }
1076
- if (first === "help") return { kind: "output", stdout: renderHelp() }
980
+ if (first === "help") return helpCommandPlan(positionals.slice(1), jsonSeen)
1077
981
 
1078
982
  const isHistory = first === "history"
1079
983
  const row =
@@ -1082,8 +986,14 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1082
986
  : commandByToken(isHistory ? `history ${positionals[1] ?? ""}` : first)
1083
987
 
1084
988
  if (isHistory && row === undefined) {
989
+ const subcommands = COMMAND_ROWS.filter((r) => r.token.startsWith("history ")).map(
990
+ (r) => r.token.split(" ")[1]!,
991
+ )
992
+ const typo = closest(positionals[1] ?? "", subcommands)
1085
993
  return usagePlan(
1086
- "gtd history: expected a subcommand — list, show <ref>, metrics [ref...], judges [ref...] or fetch [remote]",
994
+ typo !== undefined
995
+ ? `gtd history: unknown subcommand '${positionals[1]}' — did you mean \`gtd history ${typo}\`?`
996
+ : "gtd history: expected a subcommand — list, show <ref>, metrics [ref...], judges [ref...] or fetch [remote]",
1087
997
  jsonSeen,
1088
998
  )
1089
999
  }
@@ -1092,7 +1002,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1092
1002
  // "answer" arriving as `positionals[1]`, a second token after "judge" —
1093
1003
  // detected below via `judgeAnswer`, not through a second dispatchable
1094
1004
  // `COMMAND_ROWS` row: the `"judge answer"`-tokened row above exists for
1095
- // `renderHelp()`'s two-line entry. It CAN also be matched by
1005
+ // its help. It CAN also be matched by
1096
1006
  // `commandByToken` directly, but only if a caller passes the single
1097
1007
  // (space-containing) argv token `"judge answer"` — an unusual shell quoting
1098
1008
  // choice, not the normal two-argv-token invocation `gtd judge answer` — and
@@ -1126,16 +1036,15 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1126
1036
  if (violation !== undefined) return usagePlan(violation, jsonSeen)
1127
1037
  if (first === undefined) {
1128
1038
  return usagePlan(
1129
- "gtd: missing command — gtd decides and prints, a driver executes; " +
1130
- "run `gtd install`, or copy one from " +
1131
- "https://github.com/pmelab/gtd/blob/main/docs/driver.md's " +
1132
- '"A complete minimal driver" section and run that, or see usage ' +
1133
- "above (`gtd --help`)",
1039
+ "gtd: missing command — run `gtd help` for every command, or `gtd install` to learn how to drive gtd",
1134
1040
  jsonSeen,
1135
- renderHelp(),
1041
+ SHORT_USAGE,
1136
1042
  )
1137
1043
  }
1138
- return usagePlan(`unknown command '${first}'`, jsonSeen)
1044
+ return usagePlan(
1045
+ `gtd: unknown command '${first}'${didYouMean(first, COMMAND_WORDS, "gtd ")}`,
1046
+ jsonSeen,
1047
+ )
1139
1048
  }
1140
1049
 
1141
1050
  // From here, `kind` is a genuine `Command["kind"]`. `judgeAnswer` consumes
@@ -1155,12 +1064,20 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1155
1064
  } else {
1156
1065
  // `kind === "start"` is unreachable here: `selectsStart` only fires when
1157
1066
  // `first === undefined`, which the `if` branch above already handled.
1158
- const bareCheck =
1159
- kind === "check" && restPositionals.length === 0 && present.has("--open-threads")
1160
- const arityMsg = bareCheck
1067
+ // A bare `gtd door` lists the doors — which only the loaded config knows.
1068
+ const bare =
1069
+ (kind === "check" && restPositionals.length === 0 && present.has("--open-threads")) ||
1070
+ (kind === "door" && restPositionals.length === 0)
1071
+ const arityMsg = bare
1161
1072
  ? undefined
1162
1073
  : arityError(isHistory ? row!.token : first!, restPositionals, row!.arity)
1163
- if (arityMsg !== undefined) return usagePlan(arityMsg, jsonSeen)
1074
+ if (arityMsg !== undefined) {
1075
+ const hint =
1076
+ afterBareJson !== undefined && restPositionals.includes(afterBareJson)
1077
+ ? ` — to print one field, write --json=${afterBareJson}`
1078
+ : ""
1079
+ return usagePlan(arityMsg + hint, jsonSeen)
1080
+ }
1164
1081
  }
1165
1082
 
1166
1083
  const violation = scopeViolation(kind, present)
@@ -1226,7 +1143,11 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1226
1143
  if (kind === "door") {
1227
1144
  return {
1228
1145
  kind: "command",
1229
- command: { kind: "door", name: restPositionals[0]!, args: restPositionals.slice(1) },
1146
+ command: {
1147
+ kind: "door",
1148
+ ...(restPositionals[0] !== undefined ? { name: restPositionals[0] } : {}),
1149
+ args: restPositionals.slice(1),
1150
+ },
1230
1151
  json,
1231
1152
  verbose,
1232
1153
  }
@@ -1541,7 +1462,7 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
1541
1462
 
1542
1463
  if (plan.kind === "usage") {
1543
1464
  return Effect.sync(() => {
1544
- // The rendered help text (missing-command only) and the `--json`
1465
+ // The short usage (missing-command only) and the `--json`
1545
1466
  // envelope both move to stderr — stdout stays byte-empty on a usage
1546
1467
  // error exactly like every other failure surface.
1547
1468
  if (plan.stdout.length > 0) io.stderr(plan.stdout)