@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
@@ -33,6 +33,7 @@ import {
33
33
  landFields,
34
34
  noopText,
35
35
  renderBeatJson,
36
+ DEMAND_BRIEFING,
36
37
  renderBeatPlain,
37
38
  renderLandJson,
38
39
  renderLandPlain,
@@ -69,7 +70,7 @@ import {
69
70
  unansweredQuestions,
70
71
  formatFinding,
71
72
  type SteeringFormat,
72
- } from "../steering/index.js"
73
+ } from "../steering/internal.js"
73
74
  import {
74
75
  resolveMode,
75
76
  validateScriptFor,
@@ -90,7 +91,10 @@ import {
90
91
  import { renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
91
92
  import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
92
93
  import {
94
+ normalizeScriptNewline,
93
95
  SelectorUsageError,
96
+ writeJson,
97
+ writeScript,
94
98
  writeSelection,
95
99
  type ArtifactOut,
96
100
  type CommandRequirements,
@@ -138,13 +142,11 @@ export type Command =
138
142
  }
139
143
  | {
140
144
  readonly kind: "start"
141
- readonly actor: string
142
- readonly workflow: string
145
+ /** A door or a workflow; absent lists both. */
146
+ readonly name?: string
147
+ readonly args: readonly string[]
143
148
  readonly vars: Readonly<Record<string, string>>
144
- readonly label: string
145
149
  }
146
- | { readonly kind: "door"; readonly name: string; readonly args: readonly string[] }
147
- | { readonly kind: "doors" }
148
150
  | { readonly kind: "abandon" }
149
151
  | { readonly kind: "restore" }
150
152
  | { readonly kind: "next" }
@@ -158,7 +160,7 @@ export type Command =
158
160
  readonly openThreads?: boolean
159
161
  }
160
162
  | { readonly kind: "uncheck"; readonly file: string }
161
- | { readonly kind: "install" }
163
+ | { readonly kind: "guide" }
162
164
  | { readonly kind: "summary" }
163
165
  | { readonly kind: "base" }
164
166
  | { readonly kind: "judge" }
@@ -210,7 +212,7 @@ const runLspCommand = (): Effect.Effect<void, Error, ToolCommands> =>
210
212
  * gtd ships no workflow, so where no `gtd.config.ts` governs it says where to
211
213
  * get one; it writes nothing.
212
214
  */
213
- const runInstallCommand = (
215
+ const runGuideCommand = (
214
216
  out: ArtifactOut,
215
217
  ): Effect.Effect<void, Error, GitService | Host | ConfigDiscovery | Narrator> =>
216
218
  Effect.gen(function* () {
@@ -222,7 +224,7 @@ const runInstallCommand = (
222
224
  if ((yield* host.realPath(topLevel.right)) !== (yield* host.realPath(host.root))) return
223
225
  if ((yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)) !== undefined) return
224
226
  yield* (yield* Narrator).warn(
225
- `gtd install: no ${WORKFLOW_MODULE} governs here, and gtd ships no workflow — get one from ${WORKFLOWS_REPO} or write your own (step 2 of the briefing above)`,
227
+ `gtd guide: no ${WORKFLOW_MODULE} governs here, and gtd ships no workflow — get one from ${WORKFLOWS_REPO} or write your own (step 2 of the briefing above)`,
226
228
  )
227
229
  })
228
230
 
@@ -243,20 +245,10 @@ interface LandResult {
243
245
  readonly idle: boolean
244
246
  }
245
247
 
246
- /**
247
- * Exactly one trailing newline — duplicated from `Cli.ts`'s own
248
- * `normalizeTrailingNewline` rather than imported, to avoid a circular value
249
- * dependency (`Cli.ts` already depends on this module). `LandResult.script`
250
- * must carry its own trailing newline because it's embedded verbatim inside
251
- * the `--json` document, never re-normalized at its tail.
252
- */
253
- const normalizeScriptNewline = (script: string): string =>
254
- script.length === 0 ? script : `${script.replace(/\n+$/, "")}\n`
255
-
256
248
  /**
257
249
  * `combinedScript`, narrowed to accept only an already-guarded
258
250
  * `RunnableScript` for `required` — the one boundary `ScriptSurface`'s brand
259
- * exists to protect: `gtd land`/`gtd --workflow`'s emitted script. Every OTHER
251
+ * exists to protect: `gtd land`/`gtd start`'s emitted script. Every OTHER
260
252
  * `combinedScript` call (`abandon`/`restore`/steering validate, below) builds
261
253
  * from `emitScripts` directly and keeps the unbranded signature — those
262
254
  * paths land no guard-gated git write for a brand to protect.
@@ -424,6 +416,20 @@ const readStdin = (): Effect.Effect<string, Error> =>
424
416
  catch: (e) => (e instanceof Error ? e : new Error(String(e))),
425
417
  })
426
418
 
419
+ /** A stdin that is no judgment is the caller's mistake, a usage error like `gtd judge answer`'s. */
420
+ const judgmentShape = (input: string): Effect.Effect<void, GtdUsageError> => {
421
+ let doc: unknown
422
+ try {
423
+ doc = JSON.parse(input)
424
+ } catch {
425
+ return Effect.fail(new GtdUsageError("gtd judge run: stdin is not valid JSON"))
426
+ }
427
+ const questions = (doc as { questions?: unknown } | null)?.questions
428
+ return Array.isArray(questions)
429
+ ? Effect.void
430
+ : Effect.fail(new GtdUsageError('gtd judge run: stdin has no "questions" array'))
431
+ }
432
+
427
433
  /** Stdout is written once, after the answerer resolves — a rejection leaves it empty. */
428
434
  const runJudgeRunCommand = (
429
435
  command: Extract<Command, { kind: "judgeRun" }>,
@@ -434,6 +440,7 @@ const runJudgeRunCommand = (
434
440
  const tools = yield* ToolCommands
435
441
  yield* refuseLegacyRc
436
442
  const input = yield* readStdin()
443
+ yield* judgmentShape(input)
437
444
  const verdict = yield* Effect.tryPromise({
438
445
  try: () =>
439
446
  tools.judge({
@@ -555,10 +562,10 @@ const runLandCommand = (
555
562
  * `base` renders to something that is not an ancestor of (and differs from)
556
563
  * HEAD.
557
564
  */
558
- const runStartCommand = (
559
- actor: string,
565
+ const startWorkflow = (
560
566
  workflow: string,
561
567
  varOverrides: Record<string, string>,
568
+ json: JsonMode,
562
569
  out: ArtifactOut,
563
570
  commandLabel: string,
564
571
  ): Effect.Effect<void, Error, CommandRequirements> =>
@@ -566,10 +573,12 @@ const runStartCommand = (
566
573
  const config = yield* (yield* ConfigService).load
567
574
  const resolved = config.workflowNamed(workflow)
568
575
  if (resolved === undefined) {
576
+ const startable =
577
+ config.workflowNames.length > 0
578
+ ? `startable: ${config.workflowNames.join(", ")}`
579
+ : "this config exports no workflow besides its default, which an ordinary start runs"
569
580
  return yield* Effect.fail(
570
- new GtdUsageError(
571
- `gtd --workflow: unknown workflow "${workflow}" — startable: ${config.workflowNames.join(", ")}`,
572
- ),
581
+ new GtdUsageError(`${commandLabel}: unknown workflow "${workflow}" — ${startable}`),
573
582
  )
574
583
  }
575
584
  const rest = yield* restAt(undefined, varOverrides)
@@ -579,7 +588,7 @@ const runStartCommand = (
579
588
  state: rest.state,
580
589
  idle: noProcessUnderway(rest),
581
590
  },
582
- actor,
591
+ "human",
583
592
  {
584
593
  workflow: resolved,
585
594
  first: started,
@@ -591,42 +600,51 @@ const runStartCommand = (
591
600
  if (plan.kind === "refusal") {
592
601
  return yield* Effect.fail(new Error(plan.message))
593
602
  }
594
- out.write(landingScript(ScriptSurface.render(plan.steps)))
603
+ yield* writeScript(
604
+ json,
605
+ out,
606
+ listingCommitted(landingScript(ScriptSurface.render(plan.steps)), rest.changes),
607
+ )
595
608
  })
596
609
 
610
+ /**
611
+ * A start commits the whole tree (`git add -A`), so its script says up front
612
+ * which paths that is. Comment lines only: control characters are masked so a
613
+ * path can never end the comment and become a command.
614
+ */
615
+ const listingCommitted = (script: string, changes: readonly PendingChange[]): string => {
616
+ if (changes.length === 0) return script
617
+ const listing = [
618
+ "# commits every change in the working tree (git add -A):",
619
+ ...changes.map((change) => `# ${change.status} ${change.path.replace(/\p{Cc}/gu, "?")}`),
620
+ ].join("\n")
621
+ const at = script.indexOf("\n\n")
622
+ return at === -1 ? script : `${script.slice(0, at)}\n\n${listing}${script.slice(at)}`
623
+ }
624
+
597
625
  const doorSynopsis = (name: string, door: Door): string =>
598
626
  [
599
627
  name,
600
628
  ...(door.args ?? []).map((arg) => (arg.optional === true ? `[${arg.name}]` : `<${arg.name}>`)),
601
629
  ].join(" ")
602
630
 
603
- /**
604
- * `gtd door <name> [args…]`: `gtd --workflow` with the door's workflow and the
605
- * process settings its `vars` derive from the positional args. A wrong name or
606
- * arity is a usage error naming what would have worked.
607
- */
608
- const runDoorCommand = (
631
+ /** A door's start: its args mapped to process settings, `extra` (`--var`) pinned on top. */
632
+ const startThroughDoor = (
609
633
  name: string,
634
+ door: Door,
610
635
  args: readonly string[],
636
+ extra: Readonly<Record<string, string>>,
637
+ json: JsonMode,
611
638
  out: ArtifactOut,
612
639
  ): Effect.Effect<void, Error, CommandRequirements> =>
613
640
  Effect.gen(function* () {
614
- const config = yield* (yield* ConfigService).load
615
- const door = config.doors.get(name)
616
- if (door === undefined) {
617
- const known = [...config.doors]
618
- return yield* Effect.fail(
619
- new GtdUsageError(
620
- `gtd door: unknown door "${name}" — doors: ${known.map(([n, d]) => doorSynopsis(n, d)).join(", ")}`,
621
- ),
622
- )
623
- }
641
+ const label = `gtd start ${name}`
624
642
  const declared = door.args ?? []
625
643
  const required = declared.filter((arg) => arg.optional !== true).length
626
644
  if (args.length < required || args.length > declared.length) {
627
645
  return yield* Effect.fail(
628
646
  new GtdUsageError(
629
- `gtd door ${name}: ${args.length < required ? "missing" : "too many"} arguments — usage: gtd door ${doorSynopsis(name, door)}`,
647
+ `${label}: ${args.length < required ? "missing" : "too many"} arguments — usage: gtd start ${doorSynopsis(name, door)}`,
630
648
  ),
631
649
  )
632
650
  }
@@ -636,30 +654,81 @@ const runDoorCommand = (
636
654
  })
637
655
  const vars = yield* Effect.try({
638
656
  try: () => door.vars?.(named) ?? {},
639
- catch: (e) => new Error(`gtd door ${name}: ${e instanceof Error ? e.message : String(e)}`),
657
+ catch: (e) => new Error(`${label}: ${e instanceof Error ? e.message : String(e)}`),
640
658
  })
641
- yield* runStartCommand("human", door.workflow, { ...vars }, out, `gtd door ${name}`)
659
+ yield* startWorkflow(door.workflow, { ...vars, ...extra }, json, out, label)
660
+ })
661
+
662
+ /**
663
+ * `gtd start <name> [args…]`: a door when `<name>` is one — so a door shadows
664
+ * the workflow it shares a name with — else a workflow, which takes no args.
665
+ * No name lists both.
666
+ */
667
+ const runStartCommand = (
668
+ command: Extract<Command, { kind: "start" }>,
669
+ json: JsonMode,
670
+ out: ArtifactOut,
671
+ ): Effect.Effect<void, Error, CommandRequirements> =>
672
+ Effect.gen(function* () {
673
+ const { name, args, vars } = command
674
+ if (name === undefined) return yield* runStartListCommand(json, out)
675
+ const label = `gtd start ${name}`
676
+ const config = yield* (yield* ConfigService).load
677
+ const door = config.doors.get(name)
678
+ if (door !== undefined) {
679
+ return yield* startThroughDoor(name, door, args, vars, json, out)
680
+ }
681
+ if (config.workflowNamed(name) === undefined) {
682
+ return yield* Effect.fail(
683
+ new GtdUsageError(
684
+ `${label}: no workflow or door is named "${name}" — run \`gtd start\` to list them`,
685
+ ),
686
+ )
687
+ }
688
+ if (args.length > 0) {
689
+ return yield* Effect.fail(
690
+ new GtdUsageError(
691
+ `${label}: too many arguments — a workflow takes none, only --var <name>=<value>; got: ${args.join(", ")}`,
692
+ ),
693
+ )
694
+ }
695
+ yield* startWorkflow(name, { ...vars }, json, out, label)
642
696
  })
643
697
 
644
- /** `gtd doors`: every door and its args, sorted by name — plain lines, or one JSON array. */
645
- const runDoorsCommand = (
698
+ /**
699
+ * Bare `gtd start`: every workflow startable by name, then every door — plain
700
+ * lines, or one JSON array. A workflow a door shadows is left out: its name
701
+ * opens the door.
702
+ */
703
+ const runStartListCommand = (
646
704
  json: JsonMode,
647
705
  out: ArtifactOut,
648
706
  ): Effect.Effect<void, Error, CommandRequirements> =>
649
707
  Effect.gen(function* () {
650
708
  const config = yield* (yield* ConfigService).load
709
+ const workflows = [...config.workflowNames].filter((name) => !config.doors.has(name)).sort()
651
710
  const doors = [...config.doors]
652
- if (json.kind === "off") {
653
- out.write(doors.map(([n, d]) => `${doorSynopsis(n, d)} → ${d.workflow}\n`).join(""))
654
- return
711
+ const document = [
712
+ ...workflows.map((name) => ({ kind: "workflow", name })),
713
+ ...doors.map(([name, door]) => ({
714
+ kind: "door",
715
+ name,
716
+ workflow: door.workflow,
717
+ args: (door.args ?? []).map((arg) => ({ name: arg.name, optional: arg.optional === true })),
718
+ })),
719
+ ]
720
+ if (yield* writeJson(json, out, document)) return
721
+ if (document.length === 0) {
722
+ yield* (yield* Narrator).warn(
723
+ "gtd start: this config exports no workflow besides its default, which an ordinary start runs, and no door",
724
+ )
655
725
  }
656
- const document = doors.map(([n, d]) => ({
657
- name: n,
658
- workflow: d.workflow,
659
- args: (d.args ?? []).map((arg) => ({ name: arg.name, optional: arg.optional === true })),
660
- }))
661
- if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`)
662
- else yield* writeSelection(out, document, json.path)
726
+ out.write(
727
+ [
728
+ ...workflows.map((name) => `workflow ${name}\n`),
729
+ ...doors.map(([n, d]) => `door ${doorSynopsis(n, d)} → ${d.workflow}\n`),
730
+ ].join(""),
731
+ )
663
732
  })
664
733
 
665
734
  /**
@@ -675,7 +744,10 @@ const runDoorsCommand = (
675
744
  * Idempotent at the initial state. The one refusal is a process whose first
676
745
  * commit is the repository's root commit: nothing earlier to rewind to.
677
746
  */
678
- const runAbandonCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
747
+ const runAbandonCommand = (
748
+ json: JsonMode,
749
+ out: ArtifactOut,
750
+ ): Effect.Effect<void, Error, CommandRequirements> =>
679
751
  Effect.gen(function* () {
680
752
  const git = yield* GitService
681
753
  const config = yield* (yield* ConfigService).load
@@ -686,8 +758,7 @@ const runAbandonCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
686
758
  const required = emitScripts([
687
759
  { kind: "outcome", command: abandonNoopOutcome(initial) },
688
760
  ]).required
689
- out.write(combinedScript(required, ""))
690
- return
761
+ return yield* writeScript(json, out, combinedScript(required, ""))
691
762
  }
692
763
  const restState = run.trace[run.trace.length - 1]!.state
693
764
 
@@ -708,7 +779,7 @@ const runAbandonCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
708
779
  { kind: "outcome", command: abandonedOutcome(restState, run.startParentHash, initial) },
709
780
  ]
710
781
  const required = emitScripts(steps).required
711
- out.write(combinedScript(required, ""))
782
+ yield* writeScript(json, out, combinedScript(required, ""))
712
783
  })
713
784
 
714
785
  /**
@@ -718,7 +789,10 @@ const runAbandonCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
718
789
  * refuses on a dirty working tree, no retained history, or HEAD having
719
790
  * advanced past the retained tip with commits that would be lost.
720
791
  */
721
- const runRestoreCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
792
+ const runRestoreCommand = (
793
+ json: JsonMode,
794
+ out: ArtifactOut,
795
+ ): Effect.Effect<void, Error, CommandRequirements> =>
722
796
  Effect.gen(function* () {
723
797
  const git = yield* GitService
724
798
 
@@ -760,7 +834,7 @@ const runRestoreCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
760
834
  { kind: "outcome", command: restoredOutcome(tip, after.state) },
761
835
  ]
762
836
  const required = emitScripts(steps).required
763
- out.write(combinedScript(required, ""))
837
+ yield* writeScript(json, out, combinedScript(required, ""))
764
838
  })
765
839
 
766
840
  /**
@@ -849,19 +923,29 @@ const runNextCommand = (
849
923
  const validateCommand = emitsValidatablePrompt(rendered)
850
924
  ? selfValidateCommand(rest.def, rest.state, rendered.mode!, rendered.file!)
851
925
  : undefined
926
+ if (DEMAND_BRIEFING[fields.kind].suppressHeader) {
927
+ // A prompt's stdout is the agent's input, so its header goes to stderr;
928
+ // rendered as a header-showing kind to reuse the one header layout.
929
+ const shown = renderBeatPlain({ ...fields, kind: "message" })
930
+ yield* narrator.warn(shown.slice(0, shown.indexOf("\n\n")))
931
+ }
852
932
  out.write(renderBeatPlain(fields, validateCommand))
853
933
  }
854
934
  })
855
935
 
856
936
  /** Emits the format-then-validate script for the driver to run; reads no file and executes nothing itself. The verdict lives in that script's future exit code, which prints the complete fix prompt rather than bare findings. */
857
- const runValidateCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
937
+ const runValidateCommand = (
938
+ json: JsonMode,
939
+ out: ArtifactOut,
940
+ ): Effect.Effect<void, Error, CommandRequirements> =>
858
941
  Effect.gen(function* () {
859
942
  const rest = yield* currentRest
860
- const resolved = yield* resolveValidateScript(rest)
861
- out.write(
862
- resolved !== undefined && resolved.script.length > 0
863
- ? `${resolved.script}\n`
864
- : `nothing to validate at "${rest.state}"\n`,
943
+ const script = (yield* resolveValidateScript(rest))?.script ?? ""
944
+ yield* writeScript(
945
+ json,
946
+ out,
947
+ script,
948
+ script.length > 0 ? `${script}\n` : `nothing to validate at "${rest.state}"\n`,
865
949
  )
866
950
  })
867
951
 
@@ -870,8 +954,10 @@ const runValidateCommand = (out: ArtifactOut): Effect.Effect<void, Error, Comman
870
954
  * Resolves no workflow state and reads no config — this is what a seeded
871
955
  * `validate:` command invokes as a leaf step, and it formats nothing.
872
956
  *
873
- * An absent file exits 0, like `gtd validate`. Findings ride the failing
874
- * Effect's message, so the shared refusal path reports them on stderr.
957
+ * An unknown mode is a usage error; an absent file is a refusal (the emitted
958
+ * validate script guards on the file's presence before it calls this).
959
+ * Findings ride the failing Effect's message, so the shared refusal path
960
+ * reports them on stderr.
875
961
  */
876
962
  const runCheckCommand = (
877
963
  mode: string,
@@ -883,7 +969,7 @@ const runCheckCommand = (
883
969
  const format = steeringFormatFor(mode)
884
970
  if (format === undefined) {
885
971
  return yield* Effect.fail(
886
- new Error(
972
+ new GtdUsageError(
887
973
  `gtd check: unknown mode "${mode}" — known modes: ${BUILT_IN_MODE_NAMES.join(", ")}`,
888
974
  ),
889
975
  )
@@ -917,12 +1003,8 @@ const runCheckCommand = (
917
1003
  return yield* runOpenQuestionsCheckCommand(format, file)
918
1004
  }
919
1005
 
920
- const workspace = yield* Workspace
921
- const content = workspace.atPath(file)
922
- const errors =
923
- content !== undefined
924
- ? format.validate(content).map((finding) => formatFinding(file, finding))
925
- : []
1006
+ const content = yield* existingFile(file)
1007
+ const errors = format.validate(content).map((finding) => formatFinding(file, finding))
926
1008
 
927
1009
  if (errors.length === 0) return
928
1010
 
@@ -934,6 +1016,14 @@ const runCheckCommand = (
934
1016
  )
935
1017
  })
936
1018
 
1019
+ const existingFile = (file: string): Effect.Effect<string, Error, Workspace> =>
1020
+ Effect.flatMap(Workspace, (workspace) => {
1021
+ const content = workspace.atPath(file)
1022
+ return content === undefined
1023
+ ? Effect.fail(new Error(`gtd check: no such file: ${file}`))
1024
+ : Effect.succeed(content)
1025
+ })
1026
+
937
1027
  /**
938
1028
  * The bare `gtd check --open-threads`: code-comment threads in the files the
939
1029
  * current process changed. The one `check` form that reads process state; no
@@ -961,18 +1051,13 @@ const runCodeThreadsCheckCommand = (): Effect.Effect<void, Error, CommandRequire
961
1051
  return yield* Effect.fail(new Error(lines.join("\n")))
962
1052
  })
963
1053
 
964
- /** Prints one `file:line: [^name]: <first H entry>` line per open thread; a missing file fails, like `--open-questions`. */
1054
+ /** Prints one `file:line: [^name]: <first H entry>` line per open thread. */
965
1055
  const runOpenThreadsCheckCommand = (
966
1056
  format: SteeringFormat,
967
1057
  file: string,
968
1058
  ): Effect.Effect<void, Error, Workspace> =>
969
1059
  Effect.gen(function* () {
970
- const workspace = yield* Workspace
971
- const content = workspace.atPath(file)
972
- if (content === undefined) {
973
- return yield* Effect.fail(new Error(`gtd check: ${file} does not exist`))
974
- }
975
-
1060
+ const content = yield* existingFile(file)
976
1061
  const errors = openThreads(format, content).map(
977
1062
  (t) => `${file}:${t.line + 1}: [^${t.name}]: ${t.firstMe}`,
978
1063
  )
@@ -985,20 +1070,14 @@ const runOpenThreadsCheckCommand = (
985
1070
 
986
1071
  /**
987
1072
  * Runs the SAME `unansweredQuestions` predicate a flow's `openQuestions()`
988
- * reads, so the gate script and the flow's own check stay in sync. A missing
989
- * file is a non-zero exit here, unlike the structural path above.
1073
+ * reads, so the gate script and the flow's own check stay in sync.
990
1074
  */
991
1075
  const runOpenQuestionsCheckCommand = (
992
1076
  format: SteeringFormat,
993
1077
  file: string,
994
1078
  ): Effect.Effect<void, Error, Workspace> =>
995
1079
  Effect.gen(function* () {
996
- const workspace = yield* Workspace
997
- const content = workspace.atPath(file)
998
- if (content === undefined) {
999
- return yield* Effect.fail(new Error(`gtd check: ${file} does not exist`))
1000
- }
1001
-
1080
+ const content = yield* existingFile(file)
1002
1081
  const errors = unansweredQuestions(format, content).map(
1003
1082
  (q) => `${file}:${q.headingLine + 1}: ${q.question}`,
1004
1083
  )
@@ -1125,19 +1204,30 @@ const runUiCliCommand = (
1125
1204
  */
1126
1205
  const assertRunningFromRepoRoot = (git: GitOperations, host: HostOps): Effect.Effect<void, Error> =>
1127
1206
  Effect.gen(function* () {
1128
- const topLevel = yield* git.topLevel()
1207
+ const topLevel = yield* git
1208
+ .topLevel()
1209
+ .pipe(
1210
+ Effect.mapError((e) =>
1211
+ /not a git repository/.test(e.message)
1212
+ ? new Error("gtd: not a git repository — run gtd inside one (`git init` makes one)")
1213
+ : e,
1214
+ ),
1215
+ )
1129
1216
  const topReal = yield* host.realPath(topLevel)
1130
1217
  const cwdReal = yield* host.realPath(host.root)
1131
1218
  if (topReal !== cwdReal) {
1132
1219
  return yield* Effect.fail(
1133
1220
  new Error(
1134
- `gtd must be run from the repository root (${topLevel}); ` +
1135
- `the current directory is ${host.root}`,
1221
+ `gtd: run gtd from the repository root — cd ${shellWord(topLevel)} ` +
1222
+ `(the current directory is ${host.root})`,
1136
1223
  ),
1137
1224
  )
1138
1225
  }
1139
1226
  })
1140
1227
 
1228
+ const shellWord = (path: string): string =>
1229
+ /^[\w@%+=:,./-]+$/.test(path) ? path : `'${path.replace(/'/g, "'\\''")}'`
1230
+
1141
1231
  /** A command that derives workflow state needs at least one commit too — a commitless repository has no HEAD to resolve a `Rest` at. */
1142
1232
  const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Error> =>
1143
1233
  Effect.gen(function* () {
@@ -1145,7 +1235,7 @@ const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Err
1145
1235
  if (!hasCommits) {
1146
1236
  return yield* Effect.fail(
1147
1237
  new Error(
1148
- "gtd requires a repository with at least one commit — make an initial commit, then run gtd again",
1238
+ "gtd: the repository has no commits yet — make an initial commit, then run gtd again",
1149
1239
  ),
1150
1240
  )
1151
1241
  }
@@ -1161,7 +1251,7 @@ const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Err
1161
1251
  export const needsOf = (kind: Command["kind"]): Needs => {
1162
1252
  switch (kind) {
1163
1253
  case "lsp":
1164
- case "install":
1254
+ case "guide":
1165
1255
  case "judgeRun":
1166
1256
  case "strays":
1167
1257
  return "none"
@@ -1178,7 +1268,7 @@ export const standaloneKinds = (): readonly Command["kind"][] => [
1178
1268
  "lsp",
1179
1269
  "check",
1180
1270
  "uncheck",
1181
- "install",
1271
+ "guide",
1182
1272
  "judgeRun",
1183
1273
  "strays",
1184
1274
  ]
@@ -1219,19 +1309,15 @@ const dispatchVoidCommand = (
1219
1309
  out,
1220
1310
  )
1221
1311
  case "start":
1222
- return runStartCommand(command.actor, command.workflow, command.vars, out, command.label)
1223
- case "door":
1224
- return runDoorCommand(command.name, command.args, out)
1225
- case "doors":
1226
- return runDoorsCommand(json, out)
1312
+ return runStartCommand(command, json, out)
1227
1313
  case "abandon":
1228
- return runAbandonCommand(out)
1314
+ return runAbandonCommand(json, out)
1229
1315
  case "restore":
1230
- return runRestoreCommand(out)
1316
+ return runRestoreCommand(json, out)
1231
1317
  case "next":
1232
1318
  return runNextCommand(json, out)
1233
1319
  case "validate":
1234
- return runValidateCommand(out)
1320
+ return runValidateCommand(json, out)
1235
1321
  case "check":
1236
1322
  if (command.mode === undefined || command.file === undefined) {
1237
1323
  return runCodeThreadsCheckCommand()
@@ -1244,8 +1330,8 @@ const dispatchVoidCommand = (
1244
1330
  )
1245
1331
  case "uncheck":
1246
1332
  return runUncheckCommand(command.file)
1247
- case "install":
1248
- return runInstallCommand(out)
1333
+ case "guide":
1334
+ return runGuideCommand(out)
1249
1335
  case "summary":
1250
1336
  return runSummaryCommand(out)
1251
1337
  case "base":
@@ -1265,7 +1351,7 @@ const dispatchVoidCommand = (
1265
1351
  case "historyJudges":
1266
1352
  return runHistoryJudgesCommand(command.refs, command.vars, command.jsonl, json, out)
1267
1353
  case "historyFetch":
1268
- return runHistoryFetchCommand(command.remote, out)
1354
+ return runHistoryFetchCommand(command.remote, json, out)
1269
1355
  }
1270
1356
  }
1271
1357