@pmelab/gtd 59.0.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.
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,446 +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', and how to get a workflow: gtd",
582
- "ships none. It writes nothing.",
583
- ],
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.`,
584
447
  },
585
448
  {
586
449
  token: "summary",
587
450
  kind: "summary",
588
451
  arity: "none",
589
- details: [
590
- "Print the prompt for an agent to write the process HEAD",
591
- "closes or sits inside its own closing message — the entry",
592
- "commit, each human-authored commit (a review round, an",
593
- "answered question gate), the diff range to inspect, and",
594
- "it.processCost/processCostByModel. Writes nothing: no git,",
595
- "no state transition, no file, no session identity — the",
596
- "driver pipes the output to a cold agent and does what it",
597
- "wants with the result (a squash, an amend, a PR body).",
598
- "Refuses (exit 1) when the workflow declares no summary:",
599
- "template, or when the resolved run has no commits to name —",
600
- "it is runnable any time before the next thing lands on the",
601
- "branch",
602
- ],
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.`,
603
457
  },
604
458
  {
605
459
  token: "base",
606
460
  kind: "base",
607
461
  arity: "none",
608
- details: [
609
- "Print the review anchor hash — the diff base an external",
610
- "tool (a diff, a PR tool, another agent) should point at —",
611
- "bare, newline-terminated, and nothing else. Writes nothing:",
612
- "no git, no state transition, no session identity. Before",
613
- "the first review round it's the process's diff base;",
614
- "afterward it's the most-recent review round's boundary.",
615
- "Refuses (exit 1) when no process is underway.",
616
- ],
617
- },
618
- {
619
- token: "door",
620
- kind: "door",
621
- arity: { name: "name", variadic: "args" },
622
- details: [
623
- "Start a process through a named shortcut, mapping the",
624
- "positional args to that workflow's process settings — the",
625
- "same start `gtd --workflow` makes, printed the same way.",
626
- "Each workflow the config exports brings its own doors (gtd's",
627
- "`fix`, `review [base]`, `rollout <flag>` and",
628
- "`cleanup <flag>`), plus the config's `doors` export. An",
629
- "unknown door, or too few or too many args, is a usage error",
630
- "(exit 2) naming the doors or the door's synopsis",
631
- ],
632
- },
633
- {
634
- token: "doors",
635
- kind: "doors",
636
- arity: "none",
637
- details: [
638
- "List every door, sorted: one `name [args] → workflow` line",
639
- "each. --json prints one JSON array of { name, workflow,",
640
- "args: [{ name, optional }] } instead; --json=<path> reads one",
641
- "value off it. Writes nothing",
642
- ],
643
- },
644
- {
645
- token: "judge",
646
- kind: "judge",
647
- arity: "none",
648
- details: [
649
- "Print the resolved rest's pending judgment — the prepared",
650
- "state, its typed questions, and their criteria — the same",
651
- "judge field `gtd next --json` already carries. Read-only:",
652
- "resolves no session, writes nothing. Refuses (exit 1) when",
653
- "the resolved rest declares no judge:",
654
- ],
655
- },
656
- {
657
- token: "judge answer",
658
- kind: "judgeAnswer",
659
- arity: "none",
660
- details: [
661
- "Read a verdict off stdin — one { id, answer, p } entry per",
662
- "question the pending judgment declared — and decode it",
663
- "against an Effect Schema built from those same question",
664
- "ids. Refuses (exit 1) when the resolved rest declares no",
665
- "judge:. Exits 2 (a usage error, like an unknown --json",
666
- "selector) when stdin isn't valid JSON or the verdict",
667
- "doesn't decode against the pending question ids.",
668
- ],
669
- },
670
- {
671
- token: "judge run",
672
- kind: "judgeRun",
673
- arity: "none",
674
- details: [
675
- "Answer a judgment: read the `gtd judge --json` document off",
676
- "stdin, write a verdict [{ id, answer, p }] on stdout — what",
677
- "`gtd judge answer` decodes. --provider fixed answers from",
678
- "--answers <path> or GTD_JUDGE_ANSWERS; questions the file",
679
- "does not cover are left out. --provider jev asks TypeSafe",
680
- "(TYPESAFE_API_KEY). --provider llm asks the `claude` CLI",
681
- "on PATH (Claude Code only), model sonnet unless --model.",
682
- "No --provider: GTD_JUDGE_PROVIDER, then ~/.config/gtd/",
683
- "judge.json, else jev when TYPESAFE_API_KEY is set and no",
684
- "model is given, else llm; --model likewise falls back to",
685
- "GTD_JUDGE_MODEL, then the file's model. Needs no",
686
- "repository. jev and llm exit 1 when they cannot answer",
687
- "every question, with nothing on stdout",
688
- ],
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.`,
689
465
  },
690
466
  {
691
467
  token: "strays",
692
468
  kind: "strays",
693
469
  arity: { name: "range", variadic: "glob" },
694
- details: [
695
- "List the files <range> (<base>..<tip>, an empty side is HEAD)",
696
- "touched in its committed net diff that match none of the",
697
- "globs. Anchored at the repo root: * stays within one path",
698
- "segment, ** crosses them, everything else is literal — quote",
699
- "globs in the shell. stdout: a sorted JSON array, [] when",
700
- "clean; stderr: a summary, plus one stray: line per path with",
701
- "--verbose. --json=<n> picks one entry, nothing past the end;",
702
- "a symlink is a path like any other, never followed; a",
703
- "submodule counts as its path, whatever diff.ignoreSubmodules",
704
- "or .gitmodules ignore say. Runs in any git repository, no",
705
- "process needed. Exits 10 when",
706
- "strays were found, 1 on an unknown revision, 2 on a bad range",
707
- ],
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.`,
708
475
  },
709
476
  {
710
477
  token: "history list",
711
478
  kind: "historyList",
712
479
  arity: "none",
713
- details: [
714
- "List the finished processes kept behind refs/gtd/history/*,",
715
- "newest first: entry, branch, tip, date and subject per line.",
716
- "--json prints one array of { ref, entry, branch, tip, date,",
717
- "subject }. Writes nothing and never replays; with no refs it",
718
- "hints at `gtd history fetch | sh` and still exits 0",
719
- ],
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\`.`,
720
483
  },
721
484
  {
722
485
  token: "history show",
723
486
  kind: "historyShow",
724
487
  arity: { name: "ref" },
725
- details: [
726
- "Replay one kept process under today's config and print its",
727
- "steps, judge verdicts and approvals. <ref> is a full ref, an",
728
- "entry hash (or unique prefix) or a branch name. Refuses",
729
- "(exit 1) when a config file differs from its content at the",
730
- "ref's tip. A version mismatch only warns on stderr. --json",
731
- "prints the trail as one document; writes nothing",
732
- ],
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.`,
733
491
  },
734
492
  {
735
493
  token: "history metrics",
736
494
  kind: "historyMetrics",
737
495
  arity: { name: "ref", many: true },
738
- details: [
739
- "Report how the kept processes went: builds whose naive pass",
740
- "landed or was rebuilt (the refactor-first rate), Gtd-Cost by",
741
- "model and phase with the double work of a rebuild, quality-lap",
742
- "findings per lens, and red checks that passed on their",
743
- "re-check — per process and in total. Each <ref>",
744
- "is read as by history show, but a branch name means every",
745
- "process kept from it; none means every ref.",
746
- "Reads commit subjects and trailers, never replays, so a",
747
- "changed config is no refusal. --json prints { processes, total",
748
- "} as one document; writes nothing",
749
- ],
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.`,
750
499
  },
751
500
  {
752
501
  token: "history judges",
753
502
  kind: "historyJudges",
754
503
  arity: { name: "ref", many: true },
755
- details: [
756
- "Calibrate the judges of the kept processes from their",
757
- "recorded Gtd-Judge verdicts: per judge (retry, triage, clear)",
758
- "and p bucket, how many verdicts later events overturned.",
759
- "--var <name>=<value> re-decides every past verdict under",
760
- "another threshold and prints what would have changed;",
761
- "--jsonl prints one { process, judge, commit, step, question,",
762
- "state, answer, p, outcome } line per verdict instead. <ref>",
763
- "is read as by history metrics. Never replays and asks no",
764
- "model. --json prints { processes, total, whatIf }; writes",
765
- "nothing",
766
- ],
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.`,
767
507
  },
768
508
  {
769
509
  token: "history fetch",
770
510
  kind: "historyFetch",
771
511
  arity: { name: "remote", optional: true },
772
- details: [
773
- "Print a script that fetches every refs/gtd/history/* from",
774
- "<remote> (default origin) and reports how many arrived — run",
775
- "it with `gtd history fetch | sh`. Never runs git itself",
776
- ],
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.`,
777
583
  },
778
584
  ]
779
585
 
780
586
  const commandByToken = (token: string): CommandRow | undefined =>
781
587
  COMMAND_ROWS.find((r) => r.token === token)
782
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
+
783
594
  // ---------------------------------------------------------------------------
784
595
  // Help rendering
785
596
  // ---------------------------------------------------------------------------
786
597
 
787
- const COLUMN = 19
788
-
789
- const renderBlock = (header: string, lines: readonly string[]): string => {
790
- const [first, ...rest] = lines
791
- const headerCell = ` ${header}`
792
- const headerLine =
793
- headerCell.length < COLUMN
794
- ? `${headerCell.padEnd(COLUMN)}${first ?? ""}\n`
795
- : `${headerCell}\n${" ".repeat(COLUMN)}${first ?? ""}\n`
796
- 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}>`
797
609
  }
798
610
 
799
- const START_SHORT_FORM = renderBlock("(no command) --workflow <name>", [
800
- "Starts a new process authenticated as human, e.g.",
801
- "'gtd --workflow <name>'",
802
- ])
803
-
804
- const VERSION_BLOCK = renderBlock("version", ["Print version and exit"])
805
- const HELP_BLOCK = renderBlock("help", ["Print this help and exit"])
806
-
807
611
  const SPACE_FORM_FLAGS = new Set(["--workflow", "--var"])
808
612
 
809
613
  const flagHeader = (row: FlagRow): string => {
810
614
  if (row.valueHint === "") return row.name
615
+ if (row.arity === "optional") return `${row.name}[=${row.valueHint}]`
811
616
  return SPACE_FORM_FLAGS.has(row.name)
812
617
  ? `${row.name} ${row.valueHint}`
813
618
  : `${row.name}=${row.valueHint}`
814
619
  }
815
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
+
816
650
  export const renderHelp = (): string => {
817
- const commandBlocks = COMMAND_ROWS.map((row) => {
818
- const header =
819
- row.arity === "none"
820
- ? row.token
821
- : "names" in row.arity
822
- ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>`
823
- : "variadic" in row.arity
824
- ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]`
825
- : "optional" in row.arity
826
- ? `${row.token} [${row.arity.name}]`
827
- : "many" in row.arity
828
- ? `${row.token} [${row.arity.name}...]`
829
- : `${row.token} <${row.arity.name}>`
830
- 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])}`
831
662
  })
832
- const commands = [
833
- commandBlocks[0], // land
834
- START_SHORT_FORM,
835
- ...commandBlocks.slice(1), // abandon..base
836
- VERSION_BLOCK,
837
- HELP_BLOCK,
838
- ].join("")
839
-
840
- const options = FLAGS.map((row) => renderBlock(flagHeader(row), row.help)).join("")
841
- const versionHelpOptions =
842
- renderBlock("--version, -V", ["Print version and exit"]) +
843
- 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
+ `
844
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)
845
697
  return (
846
- `Usage: gtd [command] [options]\n\n` +
847
- `Commands:\n${commands}\n` +
848
- `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])}`
849
701
  )
850
702
  }
851
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
+
852
731
  // ---------------------------------------------------------------------------
853
732
  // Tokenizer
854
733
  // ---------------------------------------------------------------------------
@@ -865,6 +744,17 @@ interface TokenizeOk {
865
744
  readonly byFlag: ReadonlyMap<string, readonly string[]>
866
745
  readonly present: ReadonlySet<string>
867
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
+ }
868
758
  }
869
759
 
870
760
  // fallow-ignore-next-line complexity
@@ -873,35 +763,25 @@ const tokenize = (tail: readonly string[]): TokenizeError | TokenizeOk => {
873
763
  const byFlag = new Map<string, string[]>()
874
764
  const present = new Set<string>()
875
765
  let jsonSeen = false
766
+ let afterBareJson: string | undefined
876
767
 
877
768
  for (let i = 0; i < tail.length; i++) {
878
769
  const original = tail[i]!
879
770
  const tok = SINGLE_DASH_ALIASES.get(original) ?? original
880
- if (!tok.startsWith("--")) {
771
+ if (!tok.startsWith("-") || tok === "-") {
772
+ if (tail[i - 1] === "--json") afterBareJson ??= original
881
773
  positionals.push(original)
882
774
  continue
883
775
  }
884
- const row = flagByToken(tok)
885
- if (row === undefined) {
886
- return {
887
- kind: "error",
888
- message: `gtd: unknown option '${tok}' — see \`gtd --help\``,
889
- jsonSeen,
890
- }
891
- }
776
+ const row = tok.startsWith("--") ? flagByToken(tok) : undefined
777
+ if (row === undefined) return unknownOption(tok, jsonSeen)
892
778
  const eq = tok.indexOf("=")
893
779
  const alreadyPresent = present.has(row.name)
894
780
  present.add(row.name)
895
781
  if (row.name === "--json") jsonSeen = true
896
782
 
897
783
  if (row.arity === 0) {
898
- if (eq !== -1) {
899
- return {
900
- kind: "error",
901
- message: `gtd: unknown option '${tok}' — see \`gtd --help\``,
902
- jsonSeen,
903
- }
904
- }
784
+ if (eq !== -1) return unknownOption(tok, jsonSeen)
905
785
  if (!byFlag.has(row.name)) byFlag.set(row.name, [])
906
786
  continue
907
787
  }
@@ -959,7 +839,14 @@ const tokenize = (tail: readonly string[]): TokenizeError | TokenizeOk => {
959
839
  byFlag.set(row.name, [...existing, raw])
960
840
  }
961
841
 
962
- 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
+ }
963
850
  }
964
851
 
965
852
  // ---------------------------------------------------------------------------
@@ -1054,24 +941,43 @@ const buildLandCommand = (bag: {
1054
941
  ...(bag["--model"] !== undefined ? { model: bag["--model"] } : {}),
1055
942
  })
1056
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
+
1057
964
  // fallow-ignore-next-line complexity
1058
965
  export const parseArgv = (argv: readonly string[]): CliPlan => {
1059
- if (argv.includes("--help") || argv.includes("-h"))
1060
- return { kind: "output", stdout: renderHelp() }
966
+ if (argv.some((tok) => HELP_FLAGS.has(tok))) return helpPlan(argv)
1061
967
  if (argv.includes("--version") || argv.includes("-V")) {
1062
968
  return { kind: "output", stdout: `${GTD_VERSION}\n` }
1063
969
  }
1064
970
 
1065
971
  const tokenized = tokenize(argv.slice(2))
1066
972
  if (tokenized.kind === "error") return usagePlan(tokenized.message, tokenized.jsonSeen)
1067
- const { positionals, byFlag, present, jsonSeen } = tokenized
973
+ const { positionals, byFlag, present, jsonSeen, afterBareJson } = tokenized
1068
974
 
1069
975
  const first = positionals[0]
1070
976
  const workflowRaw = byFlag.get("--workflow")?.[0]
1071
977
  const workflowPresent = workflowRaw !== undefined
1072
978
 
1073
979
  if (first === "version") return { kind: "output", stdout: `${GTD_VERSION}\n` }
1074
- if (first === "help") return { kind: "output", stdout: renderHelp() }
980
+ if (first === "help") return helpCommandPlan(positionals.slice(1), jsonSeen)
1075
981
 
1076
982
  const isHistory = first === "history"
1077
983
  const row =
@@ -1080,8 +986,14 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1080
986
  : commandByToken(isHistory ? `history ${positionals[1] ?? ""}` : first)
1081
987
 
1082
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)
1083
993
  return usagePlan(
1084
- "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]",
1085
997
  jsonSeen,
1086
998
  )
1087
999
  }
@@ -1090,7 +1002,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1090
1002
  // "answer" arriving as `positionals[1]`, a second token after "judge" —
1091
1003
  // detected below via `judgeAnswer`, not through a second dispatchable
1092
1004
  // `COMMAND_ROWS` row: the `"judge answer"`-tokened row above exists for
1093
- // `renderHelp()`'s two-line entry. It CAN also be matched by
1005
+ // its help. It CAN also be matched by
1094
1006
  // `commandByToken` directly, but only if a caller passes the single
1095
1007
  // (space-containing) argv token `"judge answer"` — an unusual shell quoting
1096
1008
  // choice, not the normal two-argv-token invocation `gtd judge answer` — and
@@ -1124,16 +1036,15 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1124
1036
  if (violation !== undefined) return usagePlan(violation, jsonSeen)
1125
1037
  if (first === undefined) {
1126
1038
  return usagePlan(
1127
- "gtd: missing command — gtd decides and prints, a driver executes; " +
1128
- "run `gtd install`, or copy one from " +
1129
- "https://github.com/pmelab/gtd/blob/main/docs/driver.md's " +
1130
- '"A complete minimal driver" section and run that, or see usage ' +
1131
- "above (`gtd --help`)",
1039
+ "gtd: missing command — run `gtd help` for every command, or `gtd install` to learn how to drive gtd",
1132
1040
  jsonSeen,
1133
- renderHelp(),
1041
+ SHORT_USAGE,
1134
1042
  )
1135
1043
  }
1136
- return usagePlan(`unknown command '${first}'`, jsonSeen)
1044
+ return usagePlan(
1045
+ `gtd: unknown command '${first}'${didYouMean(first, COMMAND_WORDS, "gtd ")}`,
1046
+ jsonSeen,
1047
+ )
1137
1048
  }
1138
1049
 
1139
1050
  // From here, `kind` is a genuine `Command["kind"]`. `judgeAnswer` consumes
@@ -1153,12 +1064,20 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1153
1064
  } else {
1154
1065
  // `kind === "start"` is unreachable here: `selectsStart` only fires when
1155
1066
  // `first === undefined`, which the `if` branch above already handled.
1156
- const bareCheck =
1157
- kind === "check" && restPositionals.length === 0 && present.has("--open-threads")
1158
- 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
1159
1072
  ? undefined
1160
1073
  : arityError(isHistory ? row!.token : first!, restPositionals, row!.arity)
1161
- 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
+ }
1162
1081
  }
1163
1082
 
1164
1083
  const violation = scopeViolation(kind, present)
@@ -1224,7 +1143,11 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1224
1143
  if (kind === "door") {
1225
1144
  return {
1226
1145
  kind: "command",
1227
- 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
+ },
1228
1151
  json,
1229
1152
  verbose,
1230
1153
  }
@@ -1539,7 +1462,7 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
1539
1462
 
1540
1463
  if (plan.kind === "usage") {
1541
1464
  return Effect.sync(() => {
1542
- // The rendered help text (missing-command only) and the `--json`
1465
+ // The short usage (missing-command only) and the `--json`
1543
1466
  // envelope both move to stderr — stdout stays byte-empty on a usage
1544
1467
  // error exactly like every other failure surface.
1545
1468
  if (plan.stdout.length > 0) io.stderr(plan.stdout)