@pmelab/gtd 59.0.1 → 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 (83) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -33
  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-07Z_ENch.mjs → testkit-BvecVjQ0.mjs} +570 -390
  7. package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +751 -645
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +245 -36
  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 +66 -60
  18. package/src/cli/Cli.test.ts +183 -182
  19. package/src/cli/Cli.ts +164 -186
  20. package/src/cli/History.ts +9 -4
  21. package/src/cli/Judges.ts +1 -1
  22. package/src/cli/Output.test.ts +35 -0
  23. package/src/cli/Output.ts +31 -1
  24. package/src/cli/Program.test.ts +65 -79
  25. package/src/cli/Program.ts +157 -118
  26. package/src/edge/Edge.test.ts +64 -56
  27. package/src/edge/Edge.ts +3 -3
  28. package/src/flows/factory.test.ts +14 -0
  29. package/src/flows/factory.ts +22 -5
  30. package/src/flows/helpers.test.ts +35 -0
  31. package/src/flows/helpers.ts +19 -10
  32. package/src/flows/index.test.ts +66 -0
  33. package/src/flows/index.ts +109 -5
  34. package/src/flows/internal.ts +7 -0
  35. package/src/flows/runtime.test.ts +192 -1
  36. package/src/flows/runtime.ts +188 -54
  37. package/src/gherkin/git-control.feature +6 -6
  38. package/src/gherkin/index.test.ts +127 -0
  39. package/src/gherkin/index.ts +370 -51
  40. package/src/gherkin/messages.test.ts +38 -4
  41. package/src/gherkin/messages.ts +21 -4
  42. package/src/gherkin/more-steps.feature +170 -0
  43. package/src/gherkin/scenario-end.feature +101 -0
  44. package/src/gherkin/testkit.feature +83 -7
  45. package/src/install/Install.test.ts +7 -7
  46. package/src/install/Install.ts +8 -8
  47. package/src/replay/Replay.test.ts +13 -2
  48. package/src/replay/Replay.ts +19 -30
  49. package/src/replay/judgeDiffShape.test.ts +1 -1
  50. package/src/steering/Footnotes.test.ts +1 -1
  51. package/src/steering/SteeringFormats.test.ts +1 -1
  52. package/src/steering/freeform.test.ts +1 -1
  53. package/src/steering/index.test.ts +27 -1
  54. package/src/steering/index.ts +6 -104
  55. package/src/steering/internal.ts +11 -0
  56. package/src/steering/qa.test.ts +7 -2
  57. package/src/steering/reads.test.ts +42 -0
  58. package/src/steering/reads.ts +43 -0
  59. package/src/steering/registry.ts +93 -0
  60. package/src/steering/review.test.ts +2 -2
  61. package/src/step/planStart.test.ts +18 -16
  62. package/src/step/planStart.ts +9 -9
  63. package/src/testkit/TestRepo.test.ts +245 -7
  64. package/src/testkit/TestRepo.ts +271 -40
  65. package/src/testkit/imports.test.ts +58 -0
  66. package/src/testkit/imports.ts +102 -0
  67. package/src/testkit/index.test.ts +28 -0
  68. package/src/testkit/index.ts +14 -2
  69. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  70. package/src/twins/EmittedScripts.test.ts +4 -2
  71. package/src/twins/InMemRepo.ts +1 -0
  72. package/src/wire/Script.ts +11 -0
  73. package/src/wire/WireSchema.test.ts +3 -0
  74. package/src/wire/golden/1/script.json +4 -0
  75. package/src/wire/index.ts +1 -0
  76. package/src/workflow/SteeringFormats.test.ts +1 -1
  77. package/src/workflow/compile.ts +2 -2
  78. package/src/workflow/discovery.test.ts +3 -3
  79. package/src/workflow/load.test.ts +256 -116
  80. package/src/workflow/load.ts +169 -146
  81. package/src/workflow-kit/index.test.ts +25 -0
  82. package/src/workflow-kit/index.ts +37 -4
  83. package/src/workflow-kit/review.ts +2 -2
package/src/cli/Cli.ts CHANGED
@@ -85,6 +85,38 @@ interface FlagRow {
85
85
  readonly conflicts?: readonly string[]
86
86
  }
87
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
+
88
120
  const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, string> => {
89
121
  const n = Number(raw)
90
122
  if (raw.trim() === "" || !Number.isFinite(n) || n < 0) {
@@ -93,12 +125,23 @@ const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, str
93
125
  return Either.right(n)
94
126
  }
95
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
+
96
139
  const JSON_KINDS: ReadonlySet<Command["kind"] | undefined> = new Set<Command["kind"]>([
140
+ ...SCRIPT_KINDS,
97
141
  "next",
98
142
  "land",
99
143
  "judge",
100
144
  "judgeAnswer",
101
- "doors",
102
145
  "historyList",
103
146
  "historyShow",
104
147
  "historyMetrics",
@@ -114,12 +157,14 @@ const FLAGS: readonly FlagRow[] = [
114
157
  scope: (kind) => JSON_KINDS.has(kind),
115
158
  decode: ([raw]) => Either.right(raw ?? ""),
116
159
  scopeError:
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` — " +
118
- "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",
119
162
  valueHint: "<path>",
120
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",
121
164
  helpFor: {
122
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)`,
123
168
  },
124
169
  },
125
170
  {
@@ -215,76 +260,35 @@ const FLAGS: readonly FlagRow[] = [
215
260
  raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
216
261
  ? Either.left("gtd: --model must be a non-empty, single-line value")
217
262
  : Either.right(raw),
218
- 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)",
219
265
  valueHint: "<name>",
220
266
  help: "",
221
267
  helpFor: {
222
- land: "With --cost: the model that cost was spent on",
268
+ land: "With --cost: the model that cost was spent on — recorded on the commit, nothing runs on it",
223
269
  judgeRun:
224
270
  "The claude model the llm answerer uses (default: $GTD_JUDGE_MODEL, then judge.json, then sonnet)",
225
271
  },
226
272
  },
227
273
  {
228
- name: "--workflow",
274
+ name: "--threshold",
229
275
  arity: 1,
230
- repeatable: false,
231
- scope: (kind) => kind === "start",
232
- decode: ([raw]) => Either.right(raw ?? ""),
233
- scopeError:
234
- "gtd: --workflow is only valid with no other command — use the bare `gtd --workflow <name>` " +
235
- "form; landing and starting are different verbs",
236
- valueHint: "<name>",
237
- help: "Start a new process on the named workflow",
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)",
238
282
  },
239
283
  {
240
284
  name: "--var",
241
285
  arity: 1,
242
286
  repeatable: true,
243
- scope: (kind) => kind === "start" || kind === "historyJudges",
244
- decode: (raws) => {
245
- const vars: Record<string, string> = {}
246
- const seen = new Set<string>()
247
- for (const raw of raws) {
248
- const eq = raw.indexOf("=")
249
- if (eq <= 0) {
250
- return Either.left(
251
- `gtd: --var must be <name>=<value> with a non-empty name — got "${raw}"`,
252
- )
253
- }
254
- const name = raw.slice(0, eq)
255
- const value = raw.slice(eq + 1)
256
- if (!isSettingName(name)) {
257
- return Either.left(
258
- `gtd: --var ${JSON.stringify(name)} is not a valid setting name — ${SETTING_NAME_RULE}`,
259
- )
260
- }
261
- if (/[\r\n]/.test(value)) {
262
- return Either.left(`gtd: --var ${name} must be a single-line value`)
263
- }
264
- if (seen.has(name)) {
265
- return Either.left(`gtd: --var ${name} specified more than once`)
266
- }
267
- seen.add(name)
268
- if (name.startsWith("flag_")) {
269
- const pinned = flagSettingValue(value)
270
- if (pinned === undefined) {
271
- return Either.left(`gtd: --var ${name} must be 1, 0, true or false`)
272
- }
273
- vars[name] = pinned
274
- continue
275
- }
276
- vars[name] = value
277
- }
278
- return Either.right(vars)
279
- },
280
- 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>`",
281
290
  valueHint: "<name>=<value>",
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
- },
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",
288
292
  },
289
293
  {
290
294
  name: "--jsonl",
@@ -295,7 +299,7 @@ const FLAGS: readonly FlagRow[] = [
295
299
  scopeError: "gtd: --jsonl is only valid for `gtd history judges`",
296
300
  valueHint: "",
297
301
  help: "Print one JSON line per verdict instead of the report",
298
- conflicts: ["--json", "--var"],
302
+ conflicts: ["--json", "--threshold"],
299
303
  },
300
304
  {
301
305
  name: "--open-questions",
@@ -356,6 +360,8 @@ type Arity =
356
360
  | { readonly names: readonly [string, string] }
357
361
  // One required leading positional, then any number the command itself judges.
358
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 }
359
365
  // At most one trailing positional.
360
366
  | { readonly name: string; readonly optional: true }
361
367
  // Any number of positionals, none included.
@@ -407,25 +413,21 @@ gtd writes nothing itself. Plain output names the commit it would make; --json=s
407
413
  arity: "none",
408
414
  group: "Core loop",
409
415
  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.`,
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.`,
411
417
  },
412
418
  {
413
- token: "door",
414
- kind: "door",
415
- arity: { name: "name", variadic: "args" },
419
+ token: "start",
420
+ kind: "start",
421
+ arity: { name: "name", variadic: "args", bare: true },
416
422
  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.
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.
419
425
 
420
- With no <name>, an unknown one, or the wrong number of args, it lists the doors and exits 2.`,
421
- },
422
- {
423
- token: "doors",
424
- kind: "doors",
425
- arity: "none",
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.`,
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).`,
429
431
  },
430
432
  {
431
433
  token: "abandon",
@@ -435,7 +437,7 @@ With no <name>, an unknown one, or the wrong number of args, it lists the doors
435
437
  summary: "Stop the process, keeping its changes",
436
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.
437
439
 
438
- s=$(gtd abandon) && sh -c "$s"`,
440
+ s=$(gtd abandon --json=script) && sh -c "$s"`,
439
441
  },
440
442
  {
441
443
  token: "restore",
@@ -513,7 +515,7 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
513
515
  summary: "Print a script that fetches finished processes",
514
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:
515
517
 
516
- s=$(gtd history fetch) && sh -c "$s"`,
518
+ s=$(gtd history fetch --json=script) && sh -c "$s"`,
517
519
  },
518
520
  {
519
521
  token: "ui",
@@ -524,8 +526,8 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
524
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.`,
525
527
  },
526
528
  {
527
- token: "install",
528
- kind: "install",
529
+ token: "guide",
530
+ kind: "guide",
529
531
  arity: "none",
530
532
  group: "Driver plumbing",
531
533
  summary: "Print a briefing for writing a driver",
@@ -555,7 +557,7 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
555
557
  summary: "Answer a judgment with a model",
556
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.
557
559
 
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.`,
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).`,
559
561
  },
560
562
  {
561
563
  token: "check",
@@ -563,7 +565,7 @@ The fixed answerer reads --answers and leaves out the questions it does not cove
563
565
  arity: { names: ["mode", "file"] },
564
566
  group: "Driver plumbing",
565
567
  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.`,
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.`,
567
569
  },
568
570
  {
569
571
  token: "uncheck",
@@ -602,13 +604,14 @@ const synopsis = (row: CommandRow): string => {
602
604
  const { token, arity } = row
603
605
  if (arity === "none") return token
604
606
  if ("names" in arity) return `${token} <${arity.names[0]}> <${arity.names[1]}>`
607
+ if ("bare" in arity) return `${token} [${arity.name}] [${arity.variadic}...]`
605
608
  if ("variadic" in arity) return `${token} <${arity.name}> [${arity.variadic}...]`
606
609
  if ("optional" in arity) return `${token} [${arity.name}]`
607
610
  if ("many" in arity) return `${token} [${arity.name}...]`
608
611
  return `${token} <${arity.name}>`
609
612
  }
610
613
 
611
- const SPACE_FORM_FLAGS = new Set(["--workflow", "--var"])
614
+ const SPACE_FORM_FLAGS = new Set(["--var", "--threshold"])
612
615
 
613
616
  const flagHeader = (row: FlagRow): string => {
614
617
  if (row.valueHint === "") return row.name
@@ -618,15 +621,11 @@ const flagHeader = (row: FlagRow): string => {
618
621
  : `${row.name}=${row.valueHint}`
619
622
  }
620
623
 
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
624
  const GLOBAL_OPTIONS: readonly (readonly [string, string])[] = [
627
625
  ["-v, --verbose", "Narrate on stderr what gtd resolved"],
628
626
  ["-h, --help", "Print help; gtd <command> --help for one command"],
629
627
  ["-V, --version", "Print the version"],
628
+ ["--", "End the options: every word after it is an argument"],
630
629
  ]
631
630
 
632
631
  const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
@@ -638,7 +637,8 @@ const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
638
637
  ["version", "Print the version", "Print gtd's version."],
639
638
  ]
640
639
 
641
- const EXAMPLES = ` $ gtd --workflow <name> start a process
640
+ const EXAMPLES = ` $ gtd start list what can start
641
+ $ gtd start <name> start a process
642
642
  $ gtd next see what happens next
643
643
  $ s=$(gtd land --json=script) && sh -c "$s" land the turn
644
644
  $ gtd help land one command's details`
@@ -652,13 +652,11 @@ export const renderHelp = (): string => {
652
652
  const rows = COMMAND_ROWS.filter((r) => r.group === group).flatMap((r) =>
653
653
  definition(synopsis(r), r.summary, TOP_COLUMN),
654
654
  )
655
- const start =
656
- group === "Start and stop" ? definition(START_ROW.term, START_ROW.summary, TOP_COLUMN) : []
657
655
  const extra =
658
656
  group === "Driver plumbing"
659
657
  ? EXTRA_TOPICS.flatMap(([term, summary]) => definition(term, summary, TOP_COLUMN))
660
658
  : []
661
- return `${group}:\n${lines([...start, ...rows, ...extra])}`
659
+ return `${group}:\n${lines([...rows, ...extra])}`
662
660
  })
663
661
  return (
664
662
  "Usage: gtd <command> [options]\n\n" +
@@ -674,7 +672,7 @@ export const renderHelp = (): string => {
674
672
  `\nExamples:\n${EXAMPLES}\n\n` +
675
673
  lines(
676
674
  wrap(
677
- "Run `gtd help <command>` for a command's details and options, and `gtd install` to learn how to drive gtd.",
675
+ "Run `gtd help <command>` for a command's details and options, and `gtd guide` to learn how to drive gtd.",
678
676
  ),
679
677
  )
680
678
  )
@@ -683,7 +681,7 @@ export const renderHelp = (): string => {
683
681
  /** Shown on stderr above a bare `gtd`'s usage error — the overview stays one `gtd help` away. */
684
682
  const SHORT_USAGE = `Usage: gtd <command> [options]
685
683
 
686
- gtd --workflow <name> start a process
684
+ gtd start <name> start a process
687
685
  gtd next print what should happen next
688
686
  gtd land record what the tree now shows
689
687
 
@@ -764,11 +762,13 @@ const tokenize = (tail: readonly string[]): TokenizeError | TokenizeOk => {
764
762
  const present = new Set<string>()
765
763
  let jsonSeen = false
766
764
  let afterBareJson: string | undefined
765
+ const end = tail.indexOf("--")
767
766
 
768
767
  for (let i = 0; i < tail.length; i++) {
769
768
  const original = tail[i]!
769
+ if (i === end) continue
770
770
  const tok = SINGLE_DASH_ALIASES.get(original) ?? original
771
- if (!tok.startsWith("-") || tok === "-") {
771
+ if ((end !== -1 && i > end) || !tok.startsWith("-") || tok === "-") {
772
772
  if (tail[i - 1] === "--json") afterBareJson ??= original
773
773
  positionals.push(original)
774
774
  continue
@@ -894,7 +894,7 @@ const tooMany = (
894
894
  const arityError = (cmd: string, rest: readonly string[], arity: Arity): string | undefined => {
895
895
  if (arity === "none") return tooMany(cmd, rest, "none", 0)
896
896
  if ("names" in arity) return pairArityError(cmd, rest, arity.names)
897
- if ("many" in arity) return undefined
897
+ if ("many" in arity || "bare" in arity) return undefined
898
898
  if ("optional" in arity) return tooMany(cmd, rest, `at most one ${arity.name}`, 1)
899
899
  if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`
900
900
  return "variadic" in arity ? undefined : tooMany(cmd, rest, `one ${arity.name}`, 1)
@@ -931,6 +931,27 @@ const decodeFlags = (
931
931
  return Either.right(bag)
932
932
  }
933
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
+
934
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. */
935
956
  const buildLandCommand = (bag: {
936
957
  readonly "--cost"?: number
@@ -943,9 +964,19 @@ const buildLandCommand = (bag: {
943
964
 
944
965
  const HELP_FLAGS = new Set(["--help", "-h"])
945
966
 
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. */
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. */
947
974
  const helpPlan = (argv: readonly string[]): CliPlan => {
948
- const tokenized = tokenize(argv.slice(2).filter((tok) => !HELP_FLAGS.has(tok)))
975
+ const tokenized = tokenize(
976
+ optionsOf(argv)
977
+ .slice(2)
978
+ .filter((tok) => !HELP_FLAGS.has(tok)),
979
+ )
949
980
  const topic = tokenized.kind === "ok" ? tokenized.positionals : []
950
981
  return { kind: "output", stdout: renderTopicHelp(topic) ?? renderHelp() }
951
982
  }
@@ -963,8 +994,9 @@ const helpCommandPlan = (topic: readonly string[], json: boolean): CliPlan => {
963
994
 
964
995
  // fallow-ignore-next-line complexity
965
996
  export const parseArgv = (argv: readonly string[]): CliPlan => {
966
- if (argv.some((tok) => HELP_FLAGS.has(tok))) return helpPlan(argv)
967
- 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")) {
968
1000
  return { kind: "output", stdout: `${GTD_VERSION}\n` }
969
1001
  }
970
1002
 
@@ -973,8 +1005,6 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
973
1005
  const { positionals, byFlag, present, jsonSeen, afterBareJson } = tokenized
974
1006
 
975
1007
  const first = positionals[0]
976
- const workflowRaw = byFlag.get("--workflow")?.[0]
977
- const workflowPresent = workflowRaw !== undefined
978
1008
 
979
1009
  if (first === "version") return { kind: "output", stdout: `${GTD_VERSION}\n` }
980
1010
  if (first === "help") return helpCommandPlan(positionals.slice(1), jsonSeen)
@@ -1012,31 +1042,17 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1012
1042
  // `Arity` variant is needed for this one verb.
1013
1043
  const judgeAnswer = row?.kind === "judge" && positionals[1] === "answer"
1014
1044
 
1015
- // The `--workflow` selector: no command at all, with `--workflow` present,
1016
- // resolves to the generic `start` command instead — landing and starting
1017
- // are different verbs, so `gtd land --workflow <name>` is NOT a synonym (it
1018
- // fails the scope check below instead).
1019
- const selectsStart = workflowPresent && first === undefined
1020
1045
  const judgeRun = row?.kind === "judge" && positionals[1] === "run"
1021
- const kind: Command["kind"] | undefined = selectsStart
1022
- ? "start"
1023
- : judgeAnswer
1024
- ? "judgeAnswer"
1025
- : judgeRun
1026
- ? "judgeRun"
1027
- : row?.kind
1028
-
1029
- if (row === undefined && !selectsStart) {
1046
+
1047
+ if (row === undefined || first === undefined) {
1030
1048
  // No dispatchable row resolved (missing/unknown command) — a scoped flag
1031
- // used here (e.g. `--var` with no `--workflow`) is a more specific error
1032
- // than "missing command"/"unknown command", so it takes priority (mirrors
1033
- // the old parser, which validated --workflow/--var/--cost/--model ahead of
1034
- // 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.
1035
1051
  const violation = scopeViolation(undefined, present)
1036
1052
  if (violation !== undefined) return usagePlan(violation, jsonSeen)
1037
1053
  if (first === undefined) {
1038
1054
  return usagePlan(
1039
- "gtd: missing command — run `gtd help` for every command, or `gtd install` to learn how to drive gtd",
1055
+ "gtd: missing command — run `gtd help` for every command, or `gtd guide` to learn how to drive gtd",
1040
1056
  jsonSeen,
1041
1057
  SHORT_USAGE,
1042
1058
  )
@@ -1047,37 +1063,21 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1047
1063
  )
1048
1064
  }
1049
1065
 
1050
- // From here, `kind` is a genuine `Command["kind"]`. `judgeAnswer` consumes
1051
- // TWO leading positionals ("judge" and "answer"), everything else one.
1052
- const restPositionals =
1053
- first === undefined
1054
- ? positionals
1055
- : positionals.slice(judgeAnswer || judgeRun || isHistory ? 2 : 1)
1056
-
1057
- if (kind === "start" && first === undefined) {
1058
- if (restPositionals.length > 0) {
1059
- return usagePlan(
1060
- `gtd: too many arguments — expected none, got: ${restPositionals.join(", ")}`,
1061
- jsonSeen,
1062
- )
1063
- }
1064
- } else {
1065
- // `kind === "start"` is unreachable here: `selectsStart` only fires when
1066
- // `first === undefined`, which the `if` branch above already handled.
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
1072
- ? undefined
1073
- : arityError(isHistory ? row!.token : first!, restPositionals, row!.arity)
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
- }
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)
1081
1081
  }
1082
1082
 
1083
1083
  const violation = scopeViolation(kind, present)
@@ -1094,6 +1094,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1094
1094
  readonly "--cost"?: number
1095
1095
  readonly "--model"?: string
1096
1096
  readonly "--var"?: Readonly<Record<string, string>>
1097
+ readonly "--threshold"?: Readonly<Record<string, string>>
1097
1098
  readonly "--open-questions"?: boolean
1098
1099
  readonly "--open-threads"?: boolean
1099
1100
  readonly "--host"?: string
@@ -1125,28 +1126,20 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1125
1126
  }
1126
1127
 
1127
1128
  if (kind === "start") {
1128
- 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
+ }
1129
1136
  return {
1130
1137
  kind: "command",
1131
1138
  command: {
1132
1139
  kind: "start",
1133
- actor: "human",
1134
- workflow: workflowRaw!,
1140
+ ...(name !== undefined ? { name } : {}),
1141
+ args,
1135
1142
  vars: bag["--var"] ?? {},
1136
- label,
1137
- },
1138
- json,
1139
- verbose,
1140
- }
1141
- }
1142
-
1143
- if (kind === "door") {
1144
- return {
1145
- kind: "command",
1146
- command: {
1147
- kind: "door",
1148
- ...(restPositionals[0] !== undefined ? { name: restPositionals[0] } : {}),
1149
- args: restPositionals.slice(1),
1150
1143
  },
1151
1144
  json,
1152
1145
  verbose,
@@ -1237,23 +1230,9 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1237
1230
  }
1238
1231
 
1239
1232
  if (kind === "historyJudges") {
1240
- const vars = bag["--var"] ?? {}
1241
- const unknown = Object.keys(vars).find((name) => !Object.hasOwn(THRESHOLDS, name))
1242
- if (unknown !== undefined) {
1243
- return usagePlan(
1244
- `gtd history judges: --var ${unknown} is no judge threshold — use ${Object.keys(THRESHOLDS).join(" or ")}`,
1245
- jsonSeen,
1246
- )
1247
- }
1248
- const nan = Object.entries(vars).find(
1249
- ([, value]) => value.trim() === "" || !Number.isFinite(Number(value)),
1250
- )
1251
- if (nan !== undefined) {
1252
- return usagePlan(
1253
- `gtd history judges: --var ${nan[0]} must be a number — got "${nan[1]}"`,
1254
- jsonSeen,
1255
- )
1256
- }
1233
+ const thresholds = judgeThresholds(bag["--threshold"] ?? {})
1234
+ if (Either.isLeft(thresholds)) return usagePlan(thresholds.left, jsonSeen)
1235
+ const vars = thresholds.right
1257
1236
  return {
1258
1237
  kind: "command",
1259
1238
  command: {
@@ -1315,8 +1294,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1315
1294
  | "restore"
1316
1295
  | "next"
1317
1296
  | "validate"
1318
- | "install"
1319
- | "doors"
1297
+ | "guide"
1320
1298
  | "base"
1321
1299
  | "judge"
1322
1300
  | "judgeAnswer"
@@ -3,7 +3,13 @@ import { historyTrail } from "../edge/index.js"
3
3
  import { combinedScript, fetchHistoryRefs } from "../emit/index.js"
4
4
  import { GitService, Narrator, PROCESS_HISTORY_PREFIX } from "../platform/index.js"
5
5
  import { parseCommitMessage } from "../replay/index.js"
6
- import { writeJson, type ArtifactOut, type CommandRequirements, type JsonMode } from "./Output.js"
6
+ import {
7
+ writeJson,
8
+ writeScript,
9
+ type ArtifactOut,
10
+ type CommandRequirements,
11
+ type JsonMode,
12
+ } from "./Output.js"
7
13
 
8
14
  export interface HistoryEntry {
9
15
  readonly ref: string
@@ -138,8 +144,7 @@ export const runHistoryShowCommand = (
138
144
  /** Prints the script and never runs git: gtd's promise that it only decides and prints. */
139
145
  export const runHistoryFetchCommand = (
140
146
  remote: string,
147
+ json: JsonMode,
141
148
  out: ArtifactOut,
142
149
  ): Effect.Effect<void, Error, CommandRequirements> =>
143
- Effect.sync(() => {
144
- out.write(`${combinedScript(fetchHistoryRefs(remote), "")}\n`)
145
- })
150
+ writeScript(json, out, `${combinedScript(fetchHistoryRefs(remote), "")}\n`)
package/src/cli/Judges.ts CHANGED
@@ -2,7 +2,7 @@ import { Effect, Option } from "effect"
2
2
  import { answered, numeric } from "../flows/index.js"
3
3
  import { GitService } from "../platform/index.js"
4
4
  import { parseCommitMessage, type JudgeVerdict } from "../replay/index.js"
5
- import { headingSectionBodies } from "../steering/index.js"
5
+ import { headingSectionBodies } from "../steering/internal.js"
6
6
  import { selectHistoryEntries, type HistoryEntry } from "./History.js"
7
7
  import { printable } from "./Metrics.js"
8
8
  import { writeJson, type ArtifactOut, type CommandRequirements, type JsonMode } from "./Output.js"