@pmelab/gtd 59.0.0 → 60.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -38
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
  7. package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +1101 -1025
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +371 -82
  12. package/dist/testkit.mjs +3 -2
  13. package/dist/web/main.js +234 -232
  14. package/dist/wire.mjs +2 -2
  15. package/dist/workflow-kit.mjs +3 -3
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +68 -61
  18. package/src/cli/Cli.test.ts +311 -229
  19. package/src/cli/Cli.ts +486 -585
  20. package/src/cli/Help.test.ts +47 -0
  21. package/src/cli/Help.ts +70 -0
  22. package/src/cli/History.ts +9 -4
  23. package/src/cli/Judges.ts +1 -1
  24. package/src/cli/Output.test.ts +35 -0
  25. package/src/cli/Output.ts +31 -1
  26. package/src/cli/Program.test.ts +131 -82
  27. package/src/cli/Program.ts +197 -111
  28. package/src/edge/Edge.test.ts +64 -56
  29. package/src/edge/Edge.ts +4 -4
  30. package/src/emit/Emit.test.ts +1 -1
  31. package/src/emit/Emit.ts +1 -1
  32. package/src/flows/factory.test.ts +14 -0
  33. package/src/flows/factory.ts +25 -6
  34. package/src/flows/helpers.test.ts +35 -0
  35. package/src/flows/helpers.ts +19 -10
  36. package/src/flows/index.test.ts +66 -0
  37. package/src/flows/index.ts +109 -5
  38. package/src/flows/internal.ts +7 -0
  39. package/src/flows/runtime.test.ts +192 -1
  40. package/src/flows/runtime.ts +188 -54
  41. package/src/gherkin/git-control.feature +6 -6
  42. package/src/gherkin/index.test.ts +127 -0
  43. package/src/gherkin/index.ts +481 -98
  44. package/src/gherkin/messages.test.ts +124 -0
  45. package/src/gherkin/messages.ts +101 -0
  46. package/src/gherkin/more-steps.feature +170 -0
  47. package/src/gherkin/scenario-end.feature +101 -0
  48. package/src/gherkin/testkit.feature +83 -7
  49. package/src/install/Install.test.ts +7 -7
  50. package/src/install/Install.ts +8 -8
  51. package/src/replay/Replay.test.ts +13 -2
  52. package/src/replay/Replay.ts +19 -30
  53. package/src/replay/judgeDiffShape.test.ts +1 -1
  54. package/src/steering/Footnotes.test.ts +1 -1
  55. package/src/steering/SteeringFormats.test.ts +1 -1
  56. package/src/steering/freeform.test.ts +1 -1
  57. package/src/steering/index.test.ts +27 -1
  58. package/src/steering/index.ts +6 -104
  59. package/src/steering/internal.ts +11 -0
  60. package/src/steering/qa.test.ts +7 -2
  61. package/src/steering/reads.test.ts +42 -0
  62. package/src/steering/reads.ts +43 -0
  63. package/src/steering/registry.ts +93 -0
  64. package/src/steering/review.test.ts +2 -2
  65. package/src/step/planStart.test.ts +18 -16
  66. package/src/step/planStart.ts +9 -9
  67. package/src/testkit/TestRepo.test.ts +289 -6
  68. package/src/testkit/TestRepo.ts +317 -41
  69. package/src/testkit/imports.test.ts +58 -0
  70. package/src/testkit/imports.ts +102 -0
  71. package/src/testkit/index.test.ts +28 -0
  72. package/src/testkit/index.ts +14 -2
  73. package/src/twins/EmittedScriptRecognizer.ts +7 -14
  74. package/src/twins/EmittedScripts.test.ts +4 -2
  75. package/src/twins/InMemRepo.ts +1 -0
  76. package/src/wire/Land.ts +1 -1
  77. package/src/wire/Script.ts +11 -0
  78. package/src/wire/WireSchema.test.ts +3 -0
  79. package/src/wire/golden/1/script.json +4 -0
  80. package/src/wire/index.ts +1 -0
  81. package/src/workflow/SteeringFormats.test.ts +1 -1
  82. package/src/workflow/compile.ts +2 -2
  83. package/src/workflow/discovery.test.ts +3 -3
  84. package/src/workflow/load.test.ts +256 -116
  85. package/src/workflow/load.ts +175 -152
  86. package/src/workflow/skills.test.ts +5 -5
  87. package/src/workflow/skills.ts +1 -1
  88. package/src/workflow-kit/index.test.ts +25 -0
  89. package/src/workflow-kit/index.ts +37 -4
  90. package/src/workflow-kit/review.ts +2 -2
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,12 +78,45 @@ 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
  }
85
87
 
88
+ /** `<name>=<value>` occurrences of `flag` → one record, each name a setting name given at most once. */
89
+ const decodeAssignments = (
90
+ flag: string,
91
+ raws: readonly string[],
92
+ ): Either.Either<Readonly<Record<string, string>>, string> => {
93
+ const vars: Record<string, string> = {}
94
+ for (const raw of raws) {
95
+ const eq = raw.indexOf("=")
96
+ if (eq <= 0) {
97
+ return Either.left(`gtd: ${flag} must be <name>=<value> with a non-empty name — got "${raw}"`)
98
+ }
99
+ const name = raw.slice(0, eq)
100
+ const value = raw.slice(eq + 1)
101
+ if (!isSettingName(name)) {
102
+ return Either.left(
103
+ `gtd: ${flag} ${JSON.stringify(name)} is not a valid setting name — ${SETTING_NAME_RULE}`,
104
+ )
105
+ }
106
+ if (/[\r\n]/.test(value)) return Either.left(`gtd: ${flag} ${name} must be a single-line value`)
107
+ if (Object.hasOwn(vars, name))
108
+ return Either.left(`gtd: ${flag} ${name} specified more than once`)
109
+ if (!name.startsWith("flag_")) {
110
+ vars[name] = value
111
+ continue
112
+ }
113
+ const pinned = flagSettingValue(value)
114
+ if (pinned === undefined) return Either.left(`gtd: ${flag} ${name} must be 1, 0, true or false`)
115
+ vars[name] = pinned
116
+ }
117
+ return Either.right(vars)
118
+ }
119
+
86
120
  const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, string> => {
87
121
  const n = Number(raw)
88
122
  if (raw.trim() === "" || !Number.isFinite(n) || n < 0) {
@@ -91,12 +125,23 @@ const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, str
91
125
  return Either.right(n)
92
126
  }
93
127
 
128
+ /** The commands whose only output is a script: `--json` prints `{ schema, script }`. */
129
+ const SCRIPT_KINDS: ReadonlySet<Command["kind"]> = new Set<Command["kind"]>([
130
+ "start",
131
+ "abandon",
132
+ "restore",
133
+ "validate",
134
+ "historyFetch",
135
+ ])
136
+
137
+ const SCRIPT_JSON_HELP = "Print { schema, script }, or with --json=script the script alone"
138
+
94
139
  const JSON_KINDS: ReadonlySet<Command["kind"] | undefined> = new Set<Command["kind"]>([
140
+ ...SCRIPT_KINDS,
95
141
  "next",
96
142
  "land",
97
143
  "judge",
98
144
  "judgeAnswer",
99
- "doors",
100
145
  "historyList",
101
146
  "historyShow",
102
147
  "historyMetrics",
@@ -112,23 +157,15 @@ const FLAGS: readonly FlagRow[] = [
112
157
  scope: (kind) => JSON_KINDS.has(kind),
113
158
  decode: ([raw]) => Either.right(raw ?? ""),
114
159
  scopeError:
115
- "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
- "every other command prints plain text; see `gtd install` for the driver protocol briefing",
160
+ "gtd: --json is only valid for `gtd next`/`gtd land`, the commands that print a script, and the listings whose help names it (e.g. `gtd strays`) — " +
161
+ "every other command prints plain text; see `gtd guide` for the driver protocol briefing",
117
162
  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
- ],
163
+ 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",
164
+ helpFor: {
165
+ strays: "Print one entry of the array (--json=0), nothing past its end",
166
+ ...Object.fromEntries([...SCRIPT_KINDS].map((kind) => [kind, SCRIPT_JSON_HELP])),
167
+ start: `${SCRIPT_JSON_HELP}. Bare, print the list as a JSON array of { kind, name } (a door adds workflow and args)`,
168
+ },
132
169
  },
133
170
  {
134
171
  name: "--port",
@@ -143,11 +180,7 @@ const FLAGS: readonly FlagRow[] = [
143
180
  },
144
181
  scopeError: "gtd: --port is only valid for `gtd ui`",
145
182
  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
- ],
183
+ 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
184
  },
152
185
  {
153
186
  name: "--host",
@@ -160,11 +193,7 @@ const FLAGS: readonly FlagRow[] = [
160
193
  : Either.right(raw),
161
194
  scopeError: "gtd: --host is only valid for `gtd ui`",
162
195
  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
- ],
196
+ help: "Bind this address directly instead of publishing through tailscale serve",
168
197
  },
169
198
  {
170
199
  name: "--self-signed",
@@ -174,10 +203,7 @@ const FLAGS: readonly FlagRow[] = [
174
203
  decode: () => Either.right(true),
175
204
  scopeError: "gtd: --self-signed is only valid for `gtd ui`",
176
205
  valueHint: "",
177
- help: [
178
- "(gtd ui only) generate a throwaway self-signed TLS",
179
- "certificate instead of the configured ui.cert/ui.key",
180
- ],
206
+ help: "Bind directly with a throwaway self-signed certificate instead of the configured ui.cert/ui.key",
181
207
  },
182
208
  {
183
209
  name: "--dev",
@@ -187,7 +213,7 @@ const FLAGS: readonly FlagRow[] = [
187
213
  decode: () => Either.right(true),
188
214
  scopeError: "gtd: --dev is only valid for `gtd ui`",
189
215
  valueHint: "",
190
- help: ["(gtd ui only) run against local development sources", "instead of the packaged build"],
216
+ help: "Serve local development sources instead of the packaged build",
191
217
  },
192
218
  {
193
219
  name: "--provider",
@@ -200,11 +226,7 @@ const FLAGS: readonly FlagRow[] = [
200
226
  : Either.left(`gtd: --provider must be one of fixed, jev, llm — got "${raw ?? ""}"`),
201
227
  scopeError: "gtd: --provider is only valid for `gtd judge run`",
202
228
  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
- ],
229
+ 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
230
  },
209
231
  {
210
232
  name: "--answers",
@@ -217,11 +239,7 @@ const FLAGS: readonly FlagRow[] = [
217
239
  : Either.right(raw),
218
240
  scopeError: "gtd: --answers is only valid for `gtd judge run`",
219
241
  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
- ],
242
+ help: "With --provider fixed: a JSON verdict file [{ id, answer, p }] (default: $GTD_JUDGE_ANSWERS, inline JSON)",
225
243
  },
226
244
  {
227
245
  name: "--cost",
@@ -231,7 +249,7 @@ const FLAGS: readonly FlagRow[] = [
231
249
  decode: ([raw]) => nonNegativeNumber(raw ?? "", "--cost"),
232
250
  scopeError: "gtd: --cost is only valid for `gtd land` — a start is not a metered agent turn",
233
251
  valueHint: "<n>",
234
- help: ["(gtd land only) record the invocation's token cost"],
252
+ help: "Record the token cost of the invocation that just finished on the commit",
235
253
  },
236
254
  {
237
255
  name: "--model",
@@ -242,80 +260,35 @@ const FLAGS: readonly FlagRow[] = [
242
260
  raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
243
261
  ? Either.left("gtd: --model must be a non-empty, single-line value")
244
262
  : Either.right(raw),
245
- scopeError: "gtd: --model is only valid for `gtd land` (with --cost) or `gtd judge run`",
263
+ scopeError:
264
+ "gtd: --model is only valid for `gtd land` (with --cost: the model to record the cost against) or `gtd judge run` (the model that answers)",
246
265
  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
- ],
266
+ help: "",
267
+ helpFor: {
268
+ land: "With --cost: the model that cost was spent on — recorded on the commit, nothing runs on it",
269
+ judgeRun:
270
+ "The claude model the llm answerer uses (default: $GTD_JUDGE_MODEL, then judge.json, then sonnet)",
271
+ },
252
272
  },
253
273
  {
254
- name: "--workflow",
274
+ name: "--threshold",
255
275
  arity: 1,
256
- repeatable: false,
257
- scope: (kind) => kind === "start",
258
- decode: ([raw]) => Either.right(raw ?? ""),
259
- scopeError:
260
- "gtd: --workflow is only valid with no other command — use the bare `gtd --workflow <name>` " +
261
- "form; landing and starting are different verbs",
262
- valueHint: "<name>",
263
- help: [
264
- "(with no command at all) start a brand new process on",
265
- "the named workflow — authenticated as human",
266
- ],
276
+ repeatable: true,
277
+ scope: (kind) => kind === "historyJudges",
278
+ decode: (raws) => decodeAssignments("--threshold", raws),
279
+ scopeError: "gtd: --threshold is only valid for `gtd history judges`",
280
+ valueHint: "<name>=<value>",
281
+ help: "Re-decide every past verdict under this threshold: judgeIdenticalMinP or qualityTriageMinP (repeatable)",
267
282
  },
268
283
  {
269
284
  name: "--var",
270
285
  arity: 1,
271
286
  repeatable: true,
272
- scope: (kind) => kind === "start" || kind === "historyJudges",
273
- decode: (raws) => {
274
- const vars: Record<string, string> = {}
275
- const seen = new Set<string>()
276
- for (const raw of raws) {
277
- const eq = raw.indexOf("=")
278
- if (eq <= 0) {
279
- return Either.left(
280
- `gtd: --var must be <name>=<value> with a non-empty name — got "${raw}"`,
281
- )
282
- }
283
- const name = raw.slice(0, eq)
284
- const value = raw.slice(eq + 1)
285
- if (!isSettingName(name)) {
286
- return Either.left(
287
- `gtd: --var ${JSON.stringify(name)} is not a valid setting name — ${SETTING_NAME_RULE}`,
288
- )
289
- }
290
- if (/[\r\n]/.test(value)) {
291
- return Either.left(`gtd: --var ${name} must be a single-line value`)
292
- }
293
- if (seen.has(name)) {
294
- return Either.left(`gtd: --var ${name} specified more than once`)
295
- }
296
- seen.add(name)
297
- if (name.startsWith("flag_")) {
298
- const pinned = flagSettingValue(value)
299
- if (pinned === undefined) {
300
- return Either.left(`gtd: --var ${name} must be 1, 0, true or false`)
301
- }
302
- vars[name] = pinned
303
- continue
304
- }
305
- vars[name] = value
306
- }
307
- return Either.right(vars)
308
- },
309
- scopeError: "gtd: --var requires --workflow (or `gtd history judges`)",
287
+ scope: (kind) => kind === "start",
288
+ decode: (raws) => decodeAssignments("--var", raws),
289
+ scopeError: "gtd: --var is only valid for `gtd start <name>`",
310
290
  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
- ],
291
+ help: "Pin one of the workflow's declared settings for the new process (repeatable); through a door, on top of the settings its args set",
319
292
  },
320
293
  {
321
294
  name: "--jsonl",
@@ -325,8 +298,8 @@ const FLAGS: readonly FlagRow[] = [
325
298
  decode: () => Either.right(true),
326
299
  scopeError: "gtd: --jsonl is only valid for `gtd history judges`",
327
300
  valueHint: "",
328
- help: ["(gtd history judges only) print one JSON line per verdict", "instead of the report"],
329
- conflicts: ["--json", "--var"],
301
+ help: "Print one JSON line per verdict instead of the report",
302
+ conflicts: ["--json", "--threshold"],
330
303
  },
331
304
  {
332
305
  name: "--open-questions",
@@ -336,12 +309,7 @@ const FLAGS: readonly FlagRow[] = [
336
309
  decode: () => Either.right(true),
337
310
  scopeError: "gtd: --open-questions is only valid for `gtd check`",
338
311
  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
- ],
312
+ help: "Instead of <mode>'s findings, list the unanswered questions in <file>; exits 1 when any remain",
345
313
  },
346
314
  {
347
315
  name: "--open-threads",
@@ -351,15 +319,7 @@ const FLAGS: readonly FlagRow[] = [
351
319
  decode: () => Either.right(true),
352
320
  scopeError: "gtd: --open-threads is only valid for `gtd check`",
353
321
  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
- ],
322
+ 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
323
  },
364
324
  {
365
325
  name: "--verbose",
@@ -369,11 +329,7 @@ const FLAGS: readonly FlagRow[] = [
369
329
  decode: () => Either.right(true),
370
330
  scopeError: "gtd: --verbose is valid for every command — this error is unreachable",
371
331
  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
- ],
332
+ help: "Narrate on stderr what gtd resolved and where its config came from",
377
333
  },
378
334
  ]
379
335
 
@@ -404,451 +360,372 @@ type Arity =
404
360
  | { readonly names: readonly [string, string] }
405
361
  // One required leading positional, then any number the command itself judges.
406
362
  | { readonly name: string; readonly variadic: string }
363
+ // The same, the leading one optional too.
364
+ | { readonly name: string; readonly variadic: string; readonly bare: true }
407
365
  // At most one trailing positional.
408
366
  | { readonly name: string; readonly optional: true }
409
367
  // Any number of positionals, none included.
410
368
  | { readonly name: string; readonly many: true }
411
369
 
370
+ type Group = "Core loop" | "Start and stop" | "Inspect" | "Driver plumbing"
371
+
412
372
  interface CommandRow {
413
373
  readonly token: string
414
374
  readonly kind: Command["kind"]
415
375
  readonly arity: Arity
416
- readonly details: readonly string[]
376
+ readonly group: Group
377
+ /** The top-level help's one line — short enough for its column. */
378
+ readonly summary: string
379
+ /** The command's own help: prose paragraphs, wrapped when rendered (see `wrap`). */
380
+ readonly details: string
417
381
  }
418
382
 
419
383
  const COMMAND_ROWS: readonly CommandRow[] = [
384
+ {
385
+ token: "next",
386
+ kind: "next",
387
+ arity: "none",
388
+ group: "Core loop",
389
+ summary: "Print what should happen next",
390
+ details: `Print what should happen next in the process, changing nothing — safe to poll.
391
+
392
+ 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.
393
+
394
+ --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).`,
395
+ },
420
396
  {
421
397
  token: "land",
422
398
  kind: "land",
423
399
  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
- ],
400
+ group: "Core loop",
401
+ summary: "Record what the tree now shows",
402
+ 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.
403
+
404
+ gtd writes nothing itself. Plain output names the commit it would make; --json=script prints the script that makes it:
405
+
406
+ s=$(gtd land --json=script) && sh -c "$s"
407
+
408
+ --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.`,
409
+ },
410
+ {
411
+ token: "validate",
412
+ kind: "validate",
413
+ arity: "none",
414
+ group: "Core loop",
415
+ summary: "Print the script that checks the steering file",
416
+ 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; --json=script then prints an empty script.`,
417
+ },
418
+ {
419
+ token: "start",
420
+ kind: "start",
421
+ arity: { name: "name", variadic: "args", bare: true },
422
+ group: "Start and stop",
423
+ summary: "Start a workflow or door; bare, list them",
424
+ details: `Start a new process: print the script that makes its first commit, which also commits whatever the working tree holds.
425
+
426
+ <name> is a door — a named shortcut whose positional args set the workflow's settings — or else a workflow the config exports, which takes no args. A door shadows a workflow of the same name. --var pins a setting either way.
427
+
428
+ s=$(gtd start <name> --json=script) && sh -c "$s"
429
+
430
+ With no <name>, list every workflow and door it can start. An unknown <name>, the wrong number of args, or a --var the workflow does not declare is a usage error (exit 2); a process already underway is a refusal (exit 1).`,
441
431
  },
442
432
  {
443
433
  token: "abandon",
444
434
  kind: "abandon",
445
435
  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
- ],
436
+ group: "Start and stop",
437
+ summary: "Stop the process, keeping its changes",
438
+ 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.
439
+
440
+ s=$(gtd abandon --json=script) && sh -c "$s"`,
452
441
  },
453
442
  {
454
443
  token: "restore",
455
444
  kind: "restore",
456
445
  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
- ],
446
+ group: "Start and stop",
447
+ summary: "Bring back the last abandoned process",
448
+ 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.`,
465
449
  },
466
450
  {
467
- token: "next",
468
- kind: "next",
451
+ token: "summary",
452
+ kind: "summary",
469
453
  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
- ],
454
+ group: "Inspect",
455
+ summary: "Print the prompt for the closing message",
456
+ 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.
457
+
458
+ Refuses when the workflow declares no summary template, or when the process has no commits.`,
490
459
  },
491
460
  {
492
- token: "validate",
493
- kind: "validate",
461
+ token: "base",
462
+ kind: "base",
494
463
  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
- ],
464
+ group: "Inspect",
465
+ summary: "Print the commit a review diff starts from",
466
+ 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.`,
506
467
  },
507
468
  {
508
- token: "lsp",
509
- kind: "lsp",
510
- arity: "none",
511
- details: ["Start the LSP server for .gtd/ steering files (stdio)"],
469
+ token: "strays",
470
+ kind: "strays",
471
+ arity: { name: "range", variadic: "glob" },
472
+ group: "Inspect",
473
+ summary: "List changed files outside the globs",
474
+ 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.
475
+
476
+ 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.`,
512
477
  },
513
478
  {
514
- token: "ui",
515
- kind: "ui",
479
+ token: "history list",
480
+ kind: "historyList",
516
481
  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
- ],
482
+ group: "Inspect",
483
+ summary: "List finished processes",
484
+ 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\`.`,
535
485
  },
536
486
  {
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
- ],
487
+ token: "history show",
488
+ kind: "historyShow",
489
+ arity: { name: "ref" },
490
+ group: "Inspect",
491
+ summary: "Replay one finished process",
492
+ 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.`,
554
493
  },
555
494
  {
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
- ],
495
+ token: "history metrics",
496
+ kind: "historyMetrics",
497
+ arity: { name: "ref", many: true },
498
+ group: "Inspect",
499
+ summary: "Report cost and rework of finished processes",
500
+ 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.`,
571
501
  },
572
502
  {
573
- token: "install",
574
- kind: "install",
575
- 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
- ],
503
+ token: "history judges",
504
+ kind: "historyJudges",
505
+ arity: { name: "ref", many: true },
506
+ group: "Inspect",
507
+ summary: "Calibrate the judges of finished processes",
508
+ 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.`,
584
509
  },
585
510
  {
586
- token: "summary",
587
- kind: "summary",
588
- 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
- ],
511
+ token: "history fetch",
512
+ kind: "historyFetch",
513
+ arity: { name: "remote", optional: true },
514
+ group: "Inspect",
515
+ summary: "Print a script that fetches finished processes",
516
+ details: `Print a script that fetches every finished process kept on <remote> (default: origin) and reports how many arrived. gtd runs no git itself:
517
+
518
+ s=$(gtd history fetch --json=script) && sh -c "$s"`,
603
519
  },
604
520
  {
605
- token: "base",
606
- kind: "base",
521
+ token: "ui",
522
+ kind: "ui",
607
523
  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
- ],
524
+ group: "Inspect",
525
+ summary: "Serve the web and phone client",
526
+ 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.`,
617
527
  },
618
528
  {
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",
529
+ token: "guide",
530
+ kind: "guide",
636
531
  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
- ],
532
+ group: "Driver plumbing",
533
+ summary: "Print a briefing for writing a driver",
534
+ 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.`,
643
535
  },
644
536
  {
645
537
  token: "judge",
646
538
  kind: "judge",
647
539
  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
- ],
540
+ group: "Driver plumbing",
541
+ summary: "Print the pending judgment",
542
+ 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.`,
655
543
  },
656
544
  {
657
545
  token: "judge answer",
658
546
  kind: "judgeAnswer",
659
547
  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
- ],
548
+ group: "Driver plumbing",
549
+ summary: "Land a verdict read from stdin",
550
+ 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).`,
669
551
  },
670
552
  {
671
553
  token: "judge run",
672
554
  kind: "judgeRun",
673
555
  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
- ],
689
- },
690
- {
691
- token: "strays",
692
- kind: "strays",
693
- 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
- ],
708
- },
709
- {
710
- token: "history list",
711
- kind: "historyList",
712
- 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
- ],
720
- },
721
- {
722
- token: "history show",
723
- kind: "historyShow",
724
- 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
- ],
556
+ group: "Driver plumbing",
557
+ summary: "Answer a judgment with a model",
558
+ 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.
559
+
560
+ 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. A stdin that is not a judgment document is a usage error (exit 2).`,
733
561
  },
734
562
  {
735
- token: "history metrics",
736
- kind: "historyMetrics",
737
- 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
- ],
563
+ token: "check",
564
+ kind: "check",
565
+ arity: { names: ["mode", "file"] },
566
+ group: "Driver plumbing",
567
+ summary: "Check a steering file's format",
568
+ 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, or when <file> does not exist. An unknown <mode> is a usage error (exit 2). Reads no workflow and runs from any directory — emitted validation scripts call it.`,
750
569
  },
751
570
  {
752
- token: "history judges",
753
- kind: "historyJudges",
754
- 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
- ],
571
+ token: "uncheck",
572
+ kind: "uncheck",
573
+ arity: { name: "file" },
574
+ group: "Driver plumbing",
575
+ summary: "Untick a review file's boxes",
576
+ 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.`,
767
577
  },
768
578
  {
769
- token: "history fetch",
770
- kind: "historyFetch",
771
- 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
- ],
579
+ token: "lsp",
580
+ kind: "lsp",
581
+ arity: "none",
582
+ group: "Driver plumbing",
583
+ summary: "Start the steering-file language server",
584
+ details: `Start the language server for .gtd/ steering files on stdio.`,
777
585
  },
778
586
  ]
779
587
 
780
588
  const commandByToken = (token: string): CommandRow | undefined =>
781
589
  COMMAND_ROWS.find((r) => r.token === token)
782
590
 
591
+ /** Every word that may follow `gtd`, for "did you mean". */
592
+ const COMMAND_WORDS: readonly string[] = [
593
+ ...new Set([...COMMAND_ROWS.map((r) => r.token.split(" ")[0]!), "help", "version"]),
594
+ ]
595
+
783
596
  // ---------------------------------------------------------------------------
784
597
  // Help rendering
785
598
  // ---------------------------------------------------------------------------
786
599
 
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("")
600
+ const TOP_COLUMN = 28
601
+ const OPTION_COLUMN = 22
602
+
603
+ const synopsis = (row: CommandRow): string => {
604
+ const { token, arity } = row
605
+ if (arity === "none") return token
606
+ if ("names" in arity) return `${token} <${arity.names[0]}> <${arity.names[1]}>`
607
+ if ("bare" in arity) return `${token} [${arity.name}] [${arity.variadic}...]`
608
+ if ("variadic" in arity) return `${token} <${arity.name}> [${arity.variadic}...]`
609
+ if ("optional" in arity) return `${token} [${arity.name}]`
610
+ if ("many" in arity) return `${token} [${arity.name}...]`
611
+ return `${token} <${arity.name}>`
797
612
  }
798
613
 
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
- const SPACE_FORM_FLAGS = new Set(["--workflow", "--var"])
614
+ const SPACE_FORM_FLAGS = new Set(["--var", "--threshold"])
808
615
 
809
616
  const flagHeader = (row: FlagRow): string => {
810
617
  if (row.valueHint === "") return row.name
618
+ if (row.arity === "optional") return `${row.name}[=${row.valueHint}]`
811
619
  return SPACE_FORM_FLAGS.has(row.name)
812
620
  ? `${row.name} ${row.valueHint}`
813
621
  : `${row.name}=${row.valueHint}`
814
622
  }
815
623
 
624
+ const GLOBAL_OPTIONS: readonly (readonly [string, string])[] = [
625
+ ["-v, --verbose", "Narrate on stderr what gtd resolved"],
626
+ ["-h, --help", "Print help; gtd <command> --help for one command"],
627
+ ["-V, --version", "Print the version"],
628
+ ["--", "End the options: every word after it is an argument"],
629
+ ]
630
+
631
+ const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
632
+ [
633
+ "help [command]",
634
+ "Print help for gtd or one command",
635
+ "Print gtd's overview, or with <command> that command's own help.",
636
+ ],
637
+ ["version", "Print the version", "Print gtd's version."],
638
+ ]
639
+
640
+ const EXAMPLES = ` $ gtd start list what can start
641
+ $ gtd start <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 extra =
656
+ group === "Driver plumbing"
657
+ ? EXTRA_TOPICS.flatMap(([term, summary]) => definition(term, summary, TOP_COLUMN))
658
+ : []
659
+ return `${group}:\n${lines([...rows, ...extra])}`
831
660
  })
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"])
661
+ return (
662
+ "Usage: gtd <command> [options]\n\n" +
663
+ lines(
664
+ wrap(
665
+ "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.",
666
+ ),
667
+ ) +
668
+ "\n" +
669
+ groups.join("\n") +
670
+ "\nOptions:\n" +
671
+ lines(GLOBAL_OPTIONS.flatMap(([term, text]) => definition(term, text, TOP_COLUMN))) +
672
+ `\nExamples:\n${EXAMPLES}\n\n` +
673
+ lines(
674
+ wrap(
675
+ "Run `gtd help <command>` for a command's details and options, and `gtd guide` to learn how to drive gtd.",
676
+ ),
677
+ )
678
+ )
679
+ }
680
+
681
+ /** Shown on stderr above a bare `gtd`'s usage error — the overview stays one `gtd help` away. */
682
+ const SHORT_USAGE = `Usage: gtd <command> [options]
844
683
 
684
+ gtd start <name> start a process
685
+ gtd next print what should happen next
686
+ gtd land record what the tree now shows
687
+
688
+ `
689
+
690
+ const renderCommandHelp = (row: CommandRow): string => {
691
+ const options = FLAGS.filter((f) => f.name !== "--verbose" && f.scope(row.kind)).flatMap((f) =>
692
+ definition(flagHeader(f), f.helpFor?.[row.kind] ?? f.help, OPTION_COLUMN),
693
+ )
694
+ const global = definition("-v, --verbose", "Narrate on stderr what gtd resolved", OPTION_COLUMN)
845
695
  return (
846
- `Usage: gtd [command] [options]\n\n` +
847
- `Commands:\n${commands}\n` +
848
- `Options:\n${options}${versionHelpOptions}`
696
+ `Usage: gtd ${synopsis(row)}${options.length > 0 ? " [options]" : ""}\n\n` +
697
+ lines(wrap(row.details)) +
698
+ `\nOptions:\n${lines([...options, ...global])}`
849
699
  )
850
700
  }
851
701
 
702
+ /** `gtd help <topic…>`'s text: one command's help, every `history` subcommand's for `history`, or undefined for an unknown topic. */
703
+ const renderTopicHelp = (topic: readonly string[]): string | undefined => {
704
+ const two = commandByToken(topic.slice(0, 2).join(" "))
705
+ if (two !== undefined && topic.length >= 2) return renderCommandHelp(two)
706
+ const [first = ""] = topic
707
+ const one = commandByToken(first)
708
+ if (one !== undefined) return renderCommandHelp(one)
709
+ const extra = EXTRA_TOPICS.find(([term]) => term.split(" ")[0] === first)
710
+ if (extra !== undefined) return `Usage: gtd ${extra[0]}\n\n${lines(wrap(extra[2]))}`
711
+ const family = COMMAND_ROWS.filter((r) => r.token.startsWith(`${first} `))
712
+ if (first !== "" && family.length > 0) return family.map(renderCommandHelp).join("\n")
713
+ return undefined
714
+ }
715
+
716
+ /** A usage error's tail: the nearest candidate when `word` looks like a typo of one, and where the full list is. */
717
+ const didYouMean = (
718
+ word: string,
719
+ candidates: readonly string[],
720
+ prefix = "",
721
+ pointer = "`gtd help` for every command",
722
+ ): string => {
723
+ const guess = closest(word, candidates)
724
+ return guess === undefined
725
+ ? ` — run ${pointer}`
726
+ : ` — did you mean \`${prefix}${guess}\`? Run ${pointer}`
727
+ }
728
+
852
729
  // ---------------------------------------------------------------------------
853
730
  // Tokenizer
854
731
  // ---------------------------------------------------------------------------
@@ -865,6 +742,17 @@ interface TokenizeOk {
865
742
  readonly byFlag: ReadonlyMap<string, readonly string[]>
866
743
  readonly present: ReadonlySet<string>
867
744
  readonly jsonSeen: boolean
745
+ /** The positional right after a bare `--json` — most likely a path meant as `--json=<path>`. */
746
+ readonly afterBareJson?: string
747
+ }
748
+
749
+ const unknownOption = (tok: string, jsonSeen: boolean): TokenizeError => {
750
+ const candidates = tok.startsWith("--") ? FLAGS.map((f) => f.name) : []
751
+ return {
752
+ kind: "error",
753
+ message: `gtd: unknown option '${tok}'${didYouMean(tok.split("=")[0]!, candidates, "", "`gtd help <command>` for its options")}`,
754
+ jsonSeen,
755
+ }
868
756
  }
869
757
 
870
758
  // fallow-ignore-next-line complexity
@@ -873,35 +761,27 @@ const tokenize = (tail: readonly string[]): TokenizeError | TokenizeOk => {
873
761
  const byFlag = new Map<string, string[]>()
874
762
  const present = new Set<string>()
875
763
  let jsonSeen = false
764
+ let afterBareJson: string | undefined
765
+ const end = tail.indexOf("--")
876
766
 
877
767
  for (let i = 0; i < tail.length; i++) {
878
768
  const original = tail[i]!
769
+ if (i === end) continue
879
770
  const tok = SINGLE_DASH_ALIASES.get(original) ?? original
880
- if (!tok.startsWith("--")) {
771
+ if ((end !== -1 && i > end) || !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
  // ---------------------------------------------------------------------------
@@ -1007,7 +894,7 @@ const tooMany = (
1007
894
  const arityError = (cmd: string, rest: readonly string[], arity: Arity): string | undefined => {
1008
895
  if (arity === "none") return tooMany(cmd, rest, "none", 0)
1009
896
  if ("names" in arity) return pairArityError(cmd, rest, arity.names)
1010
- if ("many" in arity) return undefined
897
+ if ("many" in arity || "bare" in arity) return undefined
1011
898
  if ("optional" in arity) return tooMany(cmd, rest, `at most one ${arity.name}`, 1)
1012
899
  if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`
1013
900
  return "variadic" in arity ? undefined : tooMany(cmd, rest, `one ${arity.name}`, 1)
@@ -1044,6 +931,27 @@ const decodeFlags = (
1044
931
  return Either.right(bag)
1045
932
  }
1046
933
 
934
+ /** `gtd history judges`' thresholds: each a known judge threshold with a numeric value. */
935
+ const judgeThresholds = (
936
+ thresholds: Readonly<Record<string, string>>,
937
+ ): Either.Either<Readonly<Record<string, string>>, string> => {
938
+ const unknown = Object.keys(thresholds).find((name) => !Object.hasOwn(THRESHOLDS, name))
939
+ if (unknown !== undefined) {
940
+ return Either.left(
941
+ `gtd history judges: --threshold ${unknown} is no judge threshold — use ${Object.keys(THRESHOLDS).join(" or ")}`,
942
+ )
943
+ }
944
+ const nan = Object.entries(thresholds).find(
945
+ ([, value]) => value.trim() === "" || !Number.isFinite(Number(value)),
946
+ )
947
+ if (nan !== undefined) {
948
+ return Either.left(
949
+ `gtd history judges: --threshold ${nan[0]} must be a number — got "${nan[1]}"`,
950
+ )
951
+ }
952
+ return Either.right(thresholds)
953
+ }
954
+
1047
955
  /** Assembles a `land` `Command` from its already-scope-checked flag bag — split out of `parseArgv` so its two independent, omit-when-absent optional fields don't inflate that function's own branching. */
1048
956
  const buildLandCommand = (bag: {
1049
957
  readonly "--cost"?: number
@@ -1054,24 +962,52 @@ const buildLandCommand = (bag: {
1054
962
  ...(bag["--model"] !== undefined ? { model: bag["--model"] } : {}),
1055
963
  })
1056
964
 
965
+ const HELP_FLAGS = new Set(["--help", "-h"])
966
+
967
+ /** The options part of argv: everything before a `--`, which ends the options. */
968
+ const optionsOf = (argv: readonly string[]): readonly string[] => {
969
+ const end = argv.indexOf("--", 2)
970
+ return end === -1 ? argv : argv.slice(0, end)
971
+ }
972
+
973
+ /** `--help`/`-h` anywhere before `--`: the named command's own help, else the overview — never a usage error, so a typo'd command still gets help. */
974
+ const helpPlan = (argv: readonly string[]): CliPlan => {
975
+ const tokenized = tokenize(
976
+ optionsOf(argv)
977
+ .slice(2)
978
+ .filter((tok) => !HELP_FLAGS.has(tok)),
979
+ )
980
+ const topic = tokenized.kind === "ok" ? tokenized.positionals : []
981
+ return { kind: "output", stdout: renderTopicHelp(topic) ?? renderHelp() }
982
+ }
983
+
984
+ /** `gtd help [topic…]`: an unknown topic is a usage error naming the nearest command. */
985
+ const helpCommandPlan = (topic: readonly string[], json: boolean): CliPlan => {
986
+ if (topic.length === 0) return { kind: "output", stdout: renderHelp() }
987
+ const text = renderTopicHelp(topic)
988
+ if (text !== undefined) return { kind: "output", stdout: text }
989
+ return usagePlan(
990
+ `gtd: help: no command '${topic.join(" ")}'${didYouMean(topic[0]!, COMMAND_WORDS, "gtd help ")}`,
991
+ json,
992
+ )
993
+ }
994
+
1057
995
  // fallow-ignore-next-line complexity
1058
996
  export const parseArgv = (argv: readonly string[]): CliPlan => {
1059
- if (argv.includes("--help") || argv.includes("-h"))
1060
- return { kind: "output", stdout: renderHelp() }
1061
- if (argv.includes("--version") || argv.includes("-V")) {
997
+ const options = optionsOf(argv)
998
+ if (options.some((tok) => HELP_FLAGS.has(tok))) return helpPlan(argv)
999
+ if (options.includes("--version") || options.includes("-V")) {
1062
1000
  return { kind: "output", stdout: `${GTD_VERSION}\n` }
1063
1001
  }
1064
1002
 
1065
1003
  const tokenized = tokenize(argv.slice(2))
1066
1004
  if (tokenized.kind === "error") return usagePlan(tokenized.message, tokenized.jsonSeen)
1067
- const { positionals, byFlag, present, jsonSeen } = tokenized
1005
+ const { positionals, byFlag, present, jsonSeen, afterBareJson } = tokenized
1068
1006
 
1069
1007
  const first = positionals[0]
1070
- const workflowRaw = byFlag.get("--workflow")?.[0]
1071
- const workflowPresent = workflowRaw !== undefined
1072
1008
 
1073
1009
  if (first === "version") return { kind: "output", stdout: `${GTD_VERSION}\n` }
1074
- if (first === "help") return { kind: "output", stdout: renderHelp() }
1010
+ if (first === "help") return helpCommandPlan(positionals.slice(1), jsonSeen)
1075
1011
 
1076
1012
  const isHistory = first === "history"
1077
1013
  const row =
@@ -1080,8 +1016,14 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1080
1016
  : commandByToken(isHistory ? `history ${positionals[1] ?? ""}` : first)
1081
1017
 
1082
1018
  if (isHistory && row === undefined) {
1019
+ const subcommands = COMMAND_ROWS.filter((r) => r.token.startsWith("history ")).map(
1020
+ (r) => r.token.split(" ")[1]!,
1021
+ )
1022
+ const typo = closest(positionals[1] ?? "", subcommands)
1083
1023
  return usagePlan(
1084
- "gtd history: expected a subcommand — list, show <ref>, metrics [ref...], judges [ref...] or fetch [remote]",
1024
+ typo !== undefined
1025
+ ? `gtd history: unknown subcommand '${positionals[1]}' — did you mean \`gtd history ${typo}\`?`
1026
+ : "gtd history: expected a subcommand — list, show <ref>, metrics [ref...], judges [ref...] or fetch [remote]",
1085
1027
  jsonSeen,
1086
1028
  )
1087
1029
  }
@@ -1090,7 +1032,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1090
1032
  // "answer" arriving as `positionals[1]`, a second token after "judge" —
1091
1033
  // detected below via `judgeAnswer`, not through a second dispatchable
1092
1034
  // `COMMAND_ROWS` row: the `"judge answer"`-tokened row above exists for
1093
- // `renderHelp()`'s two-line entry. It CAN also be matched by
1035
+ // its help. It CAN also be matched by
1094
1036
  // `commandByToken` directly, but only if a caller passes the single
1095
1037
  // (space-containing) argv token `"judge answer"` — an unusual shell quoting
1096
1038
  // choice, not the normal two-argv-token invocation `gtd judge answer` — and
@@ -1100,65 +1042,42 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1100
1042
  // `Arity` variant is needed for this one verb.
1101
1043
  const judgeAnswer = row?.kind === "judge" && positionals[1] === "answer"
1102
1044
 
1103
- // The `--workflow` selector: no command at all, with `--workflow` present,
1104
- // resolves to the generic `start` command instead — landing and starting
1105
- // are different verbs, so `gtd land --workflow <name>` is NOT a synonym (it
1106
- // fails the scope check below instead).
1107
- const selectsStart = workflowPresent && first === undefined
1108
1045
  const judgeRun = row?.kind === "judge" && positionals[1] === "run"
1109
- const kind: Command["kind"] | undefined = selectsStart
1110
- ? "start"
1111
- : judgeAnswer
1112
- ? "judgeAnswer"
1113
- : judgeRun
1114
- ? "judgeRun"
1115
- : row?.kind
1116
-
1117
- if (row === undefined && !selectsStart) {
1046
+
1047
+ if (row === undefined || first === undefined) {
1118
1048
  // No dispatchable row resolved (missing/unknown command) — a scoped flag
1119
- // used here (e.g. `--var` with no `--workflow`) is a more specific error
1120
- // than "missing command"/"unknown command", so it takes priority (mirrors
1121
- // the old parser, which validated --workflow/--var/--cost/--model ahead of
1122
- // subcommand resolution).
1049
+ // used here (e.g. a bare `gtd --var x=1`) is a more specific error than
1050
+ // "missing command"/"unknown command", so it takes priority.
1123
1051
  const violation = scopeViolation(undefined, present)
1124
1052
  if (violation !== undefined) return usagePlan(violation, jsonSeen)
1125
1053
  if (first === undefined) {
1126
1054
  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`)",
1055
+ "gtd: missing command — run `gtd help` for every command, or `gtd guide` to learn how to drive gtd",
1132
1056
  jsonSeen,
1133
- renderHelp(),
1057
+ SHORT_USAGE,
1134
1058
  )
1135
1059
  }
1136
- return usagePlan(`unknown command '${first}'`, jsonSeen)
1060
+ return usagePlan(
1061
+ `gtd: unknown command '${first}'${didYouMean(first, COMMAND_WORDS, "gtd ")}`,
1062
+ jsonSeen,
1063
+ )
1137
1064
  }
1138
1065
 
1139
- // From here, `kind` is a genuine `Command["kind"]`. `judgeAnswer` consumes
1140
- // TWO leading positionals ("judge" and "answer"), everything else one.
1141
- const restPositionals =
1142
- first === undefined
1143
- ? positionals
1144
- : positionals.slice(judgeAnswer || judgeRun || isHistory ? 2 : 1)
1145
-
1146
- if (kind === "start" && first === undefined) {
1147
- if (restPositionals.length > 0) {
1148
- return usagePlan(
1149
- `gtd: too many arguments — expected none, got: ${restPositionals.join(", ")}`,
1150
- jsonSeen,
1151
- )
1152
- }
1153
- } else {
1154
- // `kind === "start"` is unreachable here: `selectsStart` only fires when
1155
- // `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
1159
- ? undefined
1160
- : arityError(isHistory ? row!.token : first!, restPositionals, row!.arity)
1161
- if (arityMsg !== undefined) return usagePlan(arityMsg, jsonSeen)
1066
+ const kind: Command["kind"] = judgeAnswer ? "judgeAnswer" : judgeRun ? "judgeRun" : row.kind
1067
+
1068
+ // `judgeAnswer` consumes TWO leading positionals ("judge" and "answer"), everything else one.
1069
+ const restPositionals = positionals.slice(judgeAnswer || judgeRun || isHistory ? 2 : 1)
1070
+
1071
+ const bare = kind === "check" && restPositionals.length === 0 && present.has("--open-threads")
1072
+ const arityMsg = bare
1073
+ ? undefined
1074
+ : arityError(isHistory ? row.token : first, restPositionals, row.arity)
1075
+ if (arityMsg !== undefined) {
1076
+ const hint =
1077
+ afterBareJson !== undefined && restPositionals.includes(afterBareJson)
1078
+ ? ` — to print one field, write --json=${afterBareJson}`
1079
+ : ""
1080
+ return usagePlan(arityMsg + hint, jsonSeen)
1162
1081
  }
1163
1082
 
1164
1083
  const violation = scopeViolation(kind, present)
@@ -1175,6 +1094,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1175
1094
  readonly "--cost"?: number
1176
1095
  readonly "--model"?: string
1177
1096
  readonly "--var"?: Readonly<Record<string, string>>
1097
+ readonly "--threshold"?: Readonly<Record<string, string>>
1178
1098
  readonly "--open-questions"?: boolean
1179
1099
  readonly "--open-threads"?: boolean
1180
1100
  readonly "--host"?: string
@@ -1206,30 +1126,26 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1206
1126
  }
1207
1127
 
1208
1128
  if (kind === "start") {
1209
- const label = `gtd --workflow ${workflowRaw}`
1129
+ const [name, ...args] = restPositionals
1130
+ if (name === undefined && bag["--var"] !== undefined) {
1131
+ return usagePlan(
1132
+ "gtd start: --var needs a <name> — run `gtd start` to list the workflows and doors",
1133
+ jsonSeen,
1134
+ )
1135
+ }
1210
1136
  return {
1211
1137
  kind: "command",
1212
1138
  command: {
1213
1139
  kind: "start",
1214
- actor: "human",
1215
- workflow: workflowRaw!,
1140
+ ...(name !== undefined ? { name } : {}),
1141
+ args,
1216
1142
  vars: bag["--var"] ?? {},
1217
- label,
1218
1143
  },
1219
1144
  json,
1220
1145
  verbose,
1221
1146
  }
1222
1147
  }
1223
1148
 
1224
- if (kind === "door") {
1225
- return {
1226
- kind: "command",
1227
- command: { kind: "door", name: restPositionals[0]!, args: restPositionals.slice(1) },
1228
- json,
1229
- verbose,
1230
- }
1231
- }
1232
-
1233
1149
  if (kind === "ui") {
1234
1150
  return {
1235
1151
  kind: "command",
@@ -1314,23 +1230,9 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1314
1230
  }
1315
1231
 
1316
1232
  if (kind === "historyJudges") {
1317
- const vars = bag["--var"] ?? {}
1318
- const unknown = Object.keys(vars).find((name) => !Object.hasOwn(THRESHOLDS, name))
1319
- if (unknown !== undefined) {
1320
- return usagePlan(
1321
- `gtd history judges: --var ${unknown} is no judge threshold — use ${Object.keys(THRESHOLDS).join(" or ")}`,
1322
- jsonSeen,
1323
- )
1324
- }
1325
- const nan = Object.entries(vars).find(
1326
- ([, value]) => value.trim() === "" || !Number.isFinite(Number(value)),
1327
- )
1328
- if (nan !== undefined) {
1329
- return usagePlan(
1330
- `gtd history judges: --var ${nan[0]} must be a number — got "${nan[1]}"`,
1331
- jsonSeen,
1332
- )
1333
- }
1233
+ const thresholds = judgeThresholds(bag["--threshold"] ?? {})
1234
+ if (Either.isLeft(thresholds)) return usagePlan(thresholds.left, jsonSeen)
1235
+ const vars = thresholds.right
1334
1236
  return {
1335
1237
  kind: "command",
1336
1238
  command: {
@@ -1392,8 +1294,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1392
1294
  | "restore"
1393
1295
  | "next"
1394
1296
  | "validate"
1395
- | "install"
1396
- | "doors"
1297
+ | "guide"
1397
1298
  | "base"
1398
1299
  | "judge"
1399
1300
  | "judgeAnswer"
@@ -1539,7 +1440,7 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
1539
1440
 
1540
1441
  if (plan.kind === "usage") {
1541
1442
  return Effect.sync(() => {
1542
- // The rendered help text (missing-command only) and the `--json`
1443
+ // The short usage (missing-command only) and the `--json`
1543
1444
  // envelope both move to stderr — stdout stays byte-empty on a usage
1544
1445
  // error exactly like every other failure surface.
1545
1446
  if (plan.stdout.length > 0) io.stderr(plan.stdout)