@pmelab/gtd 59.0.1 → 60.1.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 (108) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +48 -32
  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-BhTey1Do.mjs +30517 -0
  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 +11285 -19864
  10. package/dist/gtd.core.mjs +593 -0
  11. package/dist/steering.mjs +2 -2
  12. package/dist/testkit/gherkin.mjs +246 -37
  13. package/dist/testkit.mjs +3 -2
  14. package/dist/web/main.js +234 -232
  15. package/dist/wire.mjs +2 -2
  16. package/dist/workflow-kit.mjs +3 -3
  17. package/package.json +7 -7
  18. package/skills/authoring/SKILL.md +66 -60
  19. package/src/cli/Cli.test.ts +185 -184
  20. package/src/cli/Cli.ts +219 -224
  21. package/src/cli/History.ts +9 -4
  22. package/src/cli/Judges.ts +1 -1
  23. package/src/cli/Output.test.ts +35 -0
  24. package/src/cli/Output.ts +34 -3
  25. package/src/cli/Program.test.ts +78 -84
  26. package/src/cli/Program.ts +192 -128
  27. package/src/cli/ToolCommands.ts +2 -2
  28. package/src/edge/Edge.test.ts +72 -62
  29. package/src/edge/Edge.ts +6 -6
  30. package/src/flows/factory.test.ts +14 -0
  31. package/src/flows/factory.ts +22 -5
  32. package/src/flows/helpers.test.ts +35 -0
  33. package/src/flows/helpers.ts +19 -10
  34. package/src/flows/index.test.ts +66 -0
  35. package/src/flows/index.ts +109 -5
  36. package/src/flows/internal.ts +7 -0
  37. package/src/flows/runtime.test.ts +192 -1
  38. package/src/flows/runtime.ts +189 -55
  39. package/src/gherkin/git-control.feature +6 -6
  40. package/src/gherkin/index.test.ts +127 -0
  41. package/src/gherkin/index.ts +370 -51
  42. package/src/gherkin/messages.test.ts +38 -4
  43. package/src/gherkin/messages.ts +21 -4
  44. package/src/gherkin/more-steps.feature +170 -0
  45. package/src/gherkin/scenario-end.feature +101 -0
  46. package/src/gherkin/testkit.feature +83 -7
  47. package/src/install/Install.test.ts +7 -7
  48. package/src/install/Install.ts +8 -8
  49. package/src/install/Version.ts +9 -3
  50. package/src/install/index.ts +1 -1
  51. package/src/platform/CommandRunner.ts +13 -11
  52. package/src/platform/Commentary.ts +2 -2
  53. package/src/platform/Git.test.ts +4 -5
  54. package/src/platform/Git.ts +42 -38
  55. package/src/platform/Host.ts +1 -1
  56. package/src/platform/NodeServices.ts +11 -0
  57. package/src/platform/Workspace.test.ts +4 -5
  58. package/src/platform/Workspace.ts +3 -3
  59. package/src/platform/WorktreeState.test.ts +2 -3
  60. package/src/platform/WorktreeState.ts +1 -1
  61. package/src/platform/index.ts +2 -1
  62. package/src/replay/Replay.test.ts +13 -2
  63. package/src/replay/Replay.ts +19 -30
  64. package/src/replay/judgeDiffShape.test.ts +1 -1
  65. package/src/steering/Footnotes.test.ts +1 -1
  66. package/src/steering/SteeringFormats.test.ts +1 -1
  67. package/src/steering/freeform.test.ts +1 -1
  68. package/src/steering/index.test.ts +27 -1
  69. package/src/steering/index.ts +6 -104
  70. package/src/steering/internal.ts +11 -0
  71. package/src/steering/qa.test.ts +7 -2
  72. package/src/steering/reads.test.ts +42 -0
  73. package/src/steering/reads.ts +43 -0
  74. package/src/steering/registry.ts +93 -0
  75. package/src/steering/review.test.ts +2 -2
  76. package/src/step/planStart.test.ts +18 -16
  77. package/src/step/planStart.ts +12 -12
  78. package/src/testkit/TestRepo.test.ts +245 -7
  79. package/src/testkit/TestRepo.ts +271 -40
  80. package/src/testkit/imports.test.ts +58 -0
  81. package/src/testkit/imports.ts +102 -0
  82. package/src/testkit/index.test.ts +28 -0
  83. package/src/testkit/index.ts +14 -2
  84. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  85. package/src/twins/EmittedScripts.test.ts +5 -3
  86. package/src/twins/GitTiers.ts +6 -7
  87. package/src/twins/InMemRepo.ts +1 -0
  88. package/src/twins/Layers.test.ts +1 -1
  89. package/src/twins/Layers.ts +10 -3
  90. package/src/twins/cliIo.ts +9 -4
  91. package/src/wire/Script.ts +11 -0
  92. package/src/wire/WireSchema.test.ts +3 -0
  93. package/src/wire/golden/1/script.json +4 -0
  94. package/src/wire/index.ts +1 -0
  95. package/src/workflow/SteeringFormats.test.ts +1 -1
  96. package/src/workflow/compile.ts +2 -2
  97. package/src/workflow/discovery.test.ts +27 -10
  98. package/src/workflow/discovery.ts +5 -6
  99. package/src/workflow/index.ts +1 -0
  100. package/src/workflow/load.test.ts +316 -119
  101. package/src/workflow/load.ts +478 -305
  102. package/src/workflow/pin.test.ts +159 -0
  103. package/src/workflow/pin.ts +72 -0
  104. package/src/workflow/vars.ts +3 -3
  105. package/src/workflow-kit/index.test.ts +25 -0
  106. package/src/workflow-kit/index.ts +37 -4
  107. package/src/workflow-kit/review.ts +2 -2
  108. package/dist/chunks/testkit-07Z_ENch.mjs +0 -32175
@@ -1,5 +1,6 @@
1
1
  import { Effect, Option, Schema } from "effect"
2
2
  import {
3
+ GtdError,
3
4
  GtdUsageError,
4
5
  Narrator,
5
6
  GitService,
@@ -15,6 +16,8 @@ import {
15
16
  import {
16
17
  WORKFLOW_MODULE,
17
18
  ConfigDiscovery,
19
+ EngineCore,
20
+ pin,
18
21
  refuseLegacyRc,
19
22
  ConfigService,
20
23
  formatDiagnostic,
@@ -70,7 +73,7 @@ import {
70
73
  unansweredQuestions,
71
74
  formatFinding,
72
75
  type SteeringFormat,
73
- } from "../steering/index.js"
76
+ } from "../steering/internal.js"
74
77
  import {
75
78
  resolveMode,
76
79
  validateScriptFor,
@@ -88,10 +91,13 @@ import {
88
91
  abandonNoopOutcome,
89
92
  restoredOutcome,
90
93
  } from "../emit/index.js"
91
- import { renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
94
+ import { GTD_VERSION, renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
92
95
  import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
93
96
  import {
97
+ normalizeScriptNewline,
94
98
  SelectorUsageError,
99
+ writeJson,
100
+ writeScript,
95
101
  writeSelection,
96
102
  type ArtifactOut,
97
103
  type CommandRequirements,
@@ -116,7 +122,7 @@ import { runHistoryMetricsCommand } from "./Metrics.js"
116
122
  * SAME type structurally off `currentRest`'s own success type rather than
117
123
  * importing the name.
118
124
  */
119
- type Rest = Effect.Effect.Success<typeof currentRest>
125
+ type Rest = Effect.Success<typeof currentRest>
120
126
 
121
127
  export type Command =
122
128
  | { readonly kind: "lsp" }
@@ -139,13 +145,11 @@ export type Command =
139
145
  }
140
146
  | {
141
147
  readonly kind: "start"
142
- readonly actor: string
143
- readonly workflow: string
148
+ /** A door or a workflow; absent lists both. */
149
+ readonly name?: string
150
+ readonly args: readonly string[]
144
151
  readonly vars: Readonly<Record<string, string>>
145
- readonly label: string
146
152
  }
147
- | { readonly kind: "door"; readonly name?: string; readonly args: readonly string[] }
148
- | { readonly kind: "doors" }
149
153
  | { readonly kind: "abandon" }
150
154
  | { readonly kind: "restore" }
151
155
  | { readonly kind: "next" }
@@ -159,7 +163,8 @@ export type Command =
159
163
  readonly openThreads?: boolean
160
164
  }
161
165
  | { readonly kind: "uncheck"; readonly file: string }
162
- | { readonly kind: "install" }
166
+ | { readonly kind: "guide" }
167
+ | { readonly kind: "bundle" }
163
168
  | { readonly kind: "summary" }
164
169
  | { readonly kind: "base" }
165
170
  | { readonly kind: "judge" }
@@ -211,22 +216,41 @@ const runLspCommand = (): Effect.Effect<void, Error, ToolCommands> =>
211
216
  * gtd ships no workflow, so where no `gtd.config.ts` governs it says where to
212
217
  * get one; it writes nothing.
213
218
  */
214
- const runInstallCommand = (
219
+ const runGuideCommand = (
215
220
  out: ArtifactOut,
216
221
  ): Effect.Effect<void, Error, GitService | Host | ConfigDiscovery | Narrator> =>
217
222
  Effect.gen(function* () {
218
223
  const briefing = renderBriefing()
219
224
  out.write(briefing.endsWith("\n") ? briefing : briefing + "\n")
220
225
  const host = yield* Host
221
- const topLevel = yield* Effect.either((yield* GitService).topLevel())
222
- if (topLevel._tag === "Left") return
223
- if ((yield* host.realPath(topLevel.right)) !== (yield* host.realPath(host.root))) return
226
+ const topLevel = yield* Effect.result((yield* GitService).topLevel())
227
+ if (topLevel._tag === "Failure") return
228
+ if ((yield* host.realPath(topLevel.success)) !== (yield* host.realPath(host.root))) return
224
229
  if ((yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)) !== undefined) return
225
230
  yield* (yield* Narrator).warn(
226
- `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)`,
231
+ `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)`,
227
232
  )
228
233
  })
229
234
 
235
+ /** Loads the workflow first, so a pin is never made of one gtd would refuse. */
236
+ const runBundleCommand = (
237
+ out: ArtifactOut,
238
+ ): Effect.Effect<
239
+ void,
240
+ Error,
241
+ ConfigService | ConfigDiscovery | EngineCore | Host | Narrator | Workspace
242
+ > =>
243
+ Effect.gen(function* () {
244
+ yield* (yield* ConfigService).load
245
+ const host = yield* Host
246
+ const module = yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)
247
+ // Inside a pin, load read the pinned workflow, not a config on disk.
248
+ if (module === undefined) {
249
+ return yield* Effect.fail(new GtdError(`gtd bundle: no ${WORKFLOW_MODULE} here or above`))
250
+ }
251
+ out.write(yield* pin(module, host.root, GTD_VERSION))
252
+ })
253
+
230
254
  /**
231
255
  * `planLanding`'s result — a preview of what `gtd land` would do, plus the
232
256
  * combined `script` the driver runs to do it. `script` is
@@ -244,20 +268,10 @@ interface LandResult {
244
268
  readonly idle: boolean
245
269
  }
246
270
 
247
- /**
248
- * Exactly one trailing newline — duplicated from `Cli.ts`'s own
249
- * `normalizeTrailingNewline` rather than imported, to avoid a circular value
250
- * dependency (`Cli.ts` already depends on this module). `LandResult.script`
251
- * must carry its own trailing newline because it's embedded verbatim inside
252
- * the `--json` document, never re-normalized at its tail.
253
- */
254
- const normalizeScriptNewline = (script: string): string =>
255
- script.length === 0 ? script : `${script.replace(/\n+$/, "")}\n`
256
-
257
271
  /**
258
272
  * `combinedScript`, narrowed to accept only an already-guarded
259
273
  * `RunnableScript` for `required` — the one boundary `ScriptSurface`'s brand
260
- * exists to protect: `gtd land`/`gtd --workflow`'s emitted script. Every OTHER
274
+ * exists to protect: `gtd land`/`gtd start`'s emitted script. Every OTHER
261
275
  * `combinedScript` call (`abandon`/`restore`/steering validate, below) builds
262
276
  * from `emitScripts` directly and keeps the unbranded signature — those
263
277
  * paths land no guard-gated git write for a brand to protect.
@@ -425,6 +439,20 @@ const readStdin = (): Effect.Effect<string, Error> =>
425
439
  catch: (e) => (e instanceof Error ? e : new Error(String(e))),
426
440
  })
427
441
 
442
+ /** A stdin that is no judgment is the caller's mistake, a usage error like `gtd judge answer`'s. */
443
+ const judgmentShape = (input: string): Effect.Effect<void, GtdUsageError> => {
444
+ let doc: unknown
445
+ try {
446
+ doc = JSON.parse(input)
447
+ } catch {
448
+ return Effect.fail(new GtdUsageError("gtd judge run: stdin is not valid JSON"))
449
+ }
450
+ const questions = (doc as { questions?: unknown } | null)?.questions
451
+ return Array.isArray(questions)
452
+ ? Effect.void
453
+ : Effect.fail(new GtdUsageError('gtd judge run: stdin has no "questions" array'))
454
+ }
455
+
428
456
  /** Stdout is written once, after the answerer resolves — a rejection leaves it empty. */
429
457
  const runJudgeRunCommand = (
430
458
  command: Extract<Command, { kind: "judgeRun" }>,
@@ -435,6 +463,7 @@ const runJudgeRunCommand = (
435
463
  const tools = yield* ToolCommands
436
464
  yield* refuseLegacyRc
437
465
  const input = yield* readStdin()
466
+ yield* judgmentShape(input)
438
467
  const verdict = yield* Effect.tryPromise({
439
468
  try: () =>
440
469
  tools.judge({
@@ -451,7 +480,7 @@ const runJudgeRunCommand = (
451
480
  })
452
481
 
453
482
  /** A verdict's own `answer` shape covers every `judge:` primitive (`noul` → boolean, `choice` → string, `score` → number) without gtd itself interpreting which one applies — that's a driver/judge-model concern, not this decode's. */
454
- const VerdictAnswer = Schema.Union(Schema.String, Schema.Number, Schema.Boolean)
483
+ const VerdictAnswer = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
455
484
 
456
485
  /**
457
486
  * One `gtd judge answer` verdict entry per pending question, `{ id, answer,
@@ -464,14 +493,14 @@ const VerdictAnswer = Schema.Union(Schema.String, Schema.Number, Schema.Boolean)
464
493
  const verdictSchemaFor = (ids: readonly string[]) =>
465
494
  Schema.Array(
466
495
  Schema.Struct({
467
- id: ids.length > 0 ? Schema.Literal(...(ids as [string, ...string[]])) : Schema.Never,
496
+ id: ids.length > 0 ? Schema.Literals(ids) : Schema.Never,
468
497
  answer: VerdictAnswer,
469
498
  // Bounded [0, 1] — every OTHER bound in the routes: matching path fails
470
499
  // CLOSED (a non-finite rendered minP/maxP never matches); an
471
500
  // unvalidated p wouldn't: a driver bug or a miscalibrated model
472
501
  // sending p > 1 would clear every minP a workflow declares,
473
502
  // unconditionally forcing the judged route on a live gate.
474
- p: Schema.Number.pipe(Schema.between(0, 1)),
503
+ p: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 })),
475
504
  }),
476
505
  )
477
506
 
@@ -503,7 +532,7 @@ const runJudgeAnswerCommand = (
503
532
  try: () => JSON.parse(raw) as unknown,
504
533
  catch: () => new SelectorUsageError("gtd judge answer: stdin is not valid JSON"),
505
534
  })
506
- const verdict = yield* Schema.decodeUnknown(verdictSchemaFor(ids))(parsed).pipe(
535
+ const verdict = yield* Schema.decodeUnknownEffect(verdictSchemaFor(ids))(parsed).pipe(
507
536
  Effect.mapError(
508
537
  (e) =>
509
538
  new SelectorUsageError(
@@ -556,10 +585,10 @@ const runLandCommand = (
556
585
  * `base` renders to something that is not an ancestor of (and differs from)
557
586
  * HEAD.
558
587
  */
559
- const runStartCommand = (
560
- actor: string,
588
+ const startWorkflow = (
561
589
  workflow: string,
562
590
  varOverrides: Record<string, string>,
591
+ json: JsonMode,
563
592
  out: ArtifactOut,
564
593
  commandLabel: string,
565
594
  ): Effect.Effect<void, Error, CommandRequirements> =>
@@ -572,7 +601,7 @@ const runStartCommand = (
572
601
  ? `startable: ${config.workflowNames.join(", ")}`
573
602
  : "this config exports no workflow besides its default, which an ordinary start runs"
574
603
  return yield* Effect.fail(
575
- new GtdUsageError(`gtd --workflow: unknown workflow "${workflow}" — ${startable}`),
604
+ new GtdUsageError(`${commandLabel}: unknown workflow "${workflow}" — ${startable}`),
576
605
  )
577
606
  }
578
607
  const rest = yield* restAt(undefined, varOverrides)
@@ -582,7 +611,7 @@ const runStartCommand = (
582
611
  state: rest.state,
583
612
  idle: noProcessUnderway(rest),
584
613
  },
585
- actor,
614
+ "human",
586
615
  {
587
616
  workflow: resolved,
588
617
  first: started,
@@ -594,7 +623,11 @@ const runStartCommand = (
594
623
  if (plan.kind === "refusal") {
595
624
  return yield* Effect.fail(new Error(plan.message))
596
625
  }
597
- out.write(listingCommitted(landingScript(ScriptSurface.render(plan.steps)), rest.changes))
626
+ yield* writeScript(
627
+ json,
628
+ out,
629
+ listingCommitted(landingScript(ScriptSurface.render(plan.steps)), rest.changes),
630
+ )
598
631
  })
599
632
 
600
633
  /**
@@ -618,40 +651,23 @@ const doorSynopsis = (name: string, door: Door): string =>
618
651
  ...(door.args ?? []).map((arg) => (arg.optional === true ? `[${arg.name}]` : `<${arg.name}>`)),
619
652
  ].join(" ")
620
653
 
621
- const noSuchDoor = (name: string | undefined, doors: ReadonlyMap<string, Door>): GtdUsageError => {
622
- const listing =
623
- doors.size > 0
624
- ? `doors: ${[...doors].map(([n, d]) => doorSynopsis(n, d)).join(", ")}`
625
- : "this config has no doors"
626
- return new GtdUsageError(
627
- name === undefined
628
- ? `gtd door: missing door name — ${listing}`
629
- : `gtd door: unknown door "${name}" — ${listing}`,
630
- )
631
- }
632
-
633
- /**
634
- * `gtd door <name> [args…]`: `gtd --workflow` with the door's workflow and the
635
- * process settings its `vars` derive from the positional args. A wrong name or
636
- * arity is a usage error naming what would have worked.
637
- */
638
- const runDoorCommand = (
639
- name: string | undefined,
654
+ /** A door's start: its args mapped to process settings, `extra` (`--var`) pinned on top. */
655
+ const startThroughDoor = (
656
+ name: string,
657
+ door: Door,
640
658
  args: readonly string[],
659
+ extra: Readonly<Record<string, string>>,
660
+ json: JsonMode,
641
661
  out: ArtifactOut,
642
662
  ): Effect.Effect<void, Error, CommandRequirements> =>
643
663
  Effect.gen(function* () {
644
- const config = yield* (yield* ConfigService).load
645
- const door = name === undefined ? undefined : config.doors.get(name)
646
- if (name === undefined || door === undefined) {
647
- return yield* Effect.fail(noSuchDoor(name, config.doors))
648
- }
664
+ const label = `gtd start ${name}`
649
665
  const declared = door.args ?? []
650
666
  const required = declared.filter((arg) => arg.optional !== true).length
651
667
  if (args.length < required || args.length > declared.length) {
652
668
  return yield* Effect.fail(
653
669
  new GtdUsageError(
654
- `gtd door ${name}: ${args.length < required ? "missing" : "too many"} arguments — usage: gtd door ${doorSynopsis(name, door)}`,
670
+ `${label}: ${args.length < required ? "missing" : "too many"} arguments — usage: gtd start ${doorSynopsis(name, door)}`,
655
671
  ),
656
672
  )
657
673
  }
@@ -661,35 +677,81 @@ const runDoorCommand = (
661
677
  })
662
678
  const vars = yield* Effect.try({
663
679
  try: () => door.vars?.(named) ?? {},
664
- catch: (e) => new Error(`gtd door ${name}: ${e instanceof Error ? e.message : String(e)}`),
680
+ catch: (e) => new Error(`${label}: ${e instanceof Error ? e.message : String(e)}`),
665
681
  })
666
- yield* runStartCommand("human", door.workflow, { ...vars }, out, `gtd door ${name}`)
682
+ yield* startWorkflow(door.workflow, { ...vars, ...extra }, json, out, label)
683
+ })
684
+
685
+ /**
686
+ * `gtd start <name> [args…]`: a door when `<name>` is one — so a door shadows
687
+ * the workflow it shares a name with — else a workflow, which takes no args.
688
+ * No name lists both.
689
+ */
690
+ const runStartCommand = (
691
+ command: Extract<Command, { kind: "start" }>,
692
+ json: JsonMode,
693
+ out: ArtifactOut,
694
+ ): Effect.Effect<void, Error, CommandRequirements> =>
695
+ Effect.gen(function* () {
696
+ const { name, args, vars } = command
697
+ if (name === undefined) return yield* runStartListCommand(json, out)
698
+ const label = `gtd start ${name}`
699
+ const config = yield* (yield* ConfigService).load
700
+ const door = config.doors.get(name)
701
+ if (door !== undefined) {
702
+ return yield* startThroughDoor(name, door, args, vars, json, out)
703
+ }
704
+ if (config.workflowNamed(name) === undefined) {
705
+ return yield* Effect.fail(
706
+ new GtdUsageError(
707
+ `${label}: no workflow or door is named "${name}" — run \`gtd start\` to list them`,
708
+ ),
709
+ )
710
+ }
711
+ if (args.length > 0) {
712
+ return yield* Effect.fail(
713
+ new GtdUsageError(
714
+ `${label}: too many arguments — a workflow takes none, only --var <name>=<value>; got: ${args.join(", ")}`,
715
+ ),
716
+ )
717
+ }
718
+ yield* startWorkflow(name, { ...vars }, json, out, label)
667
719
  })
668
720
 
669
- /** `gtd doors`: every door and its args, sorted by name — plain lines, or one JSON array. */
670
- const runDoorsCommand = (
721
+ /**
722
+ * Bare `gtd start`: every workflow startable by name, then every door — plain
723
+ * lines, or one JSON array. A workflow a door shadows is left out: its name
724
+ * opens the door.
725
+ */
726
+ const runStartListCommand = (
671
727
  json: JsonMode,
672
728
  out: ArtifactOut,
673
729
  ): Effect.Effect<void, Error, CommandRequirements> =>
674
730
  Effect.gen(function* () {
675
731
  const config = yield* (yield* ConfigService).load
732
+ const workflows = [...config.workflowNames].filter((name) => !config.doors.has(name)).sort()
676
733
  const doors = [...config.doors]
677
- if (json.kind === "off") {
678
- if (doors.length === 0) {
679
- yield* (yield* Narrator).warn(
680
- "gtd doors: this config has no doors — a workflow factory's `doors` option or the config's `doors` export adds one",
681
- )
682
- }
683
- out.write(doors.map(([n, d]) => `${doorSynopsis(n, d)} → ${d.workflow}\n`).join(""))
684
- return
734
+ const document = [
735
+ ...workflows.map((name) => ({ kind: "workflow", name })),
736
+ ...doors.map(([name, door]) => ({
737
+ kind: "door",
738
+ name,
739
+ workflow: door.workflow,
740
+ args: (door.args ?? []).map((arg) => ({ name: arg.name, optional: arg.optional === true })),
741
+ })),
742
+ ]
743
+ if (yield* writeJson(json, out, document)) return
744
+ if (document.length === 0) {
745
+ yield* (yield* Narrator).warn(
746
+ "gtd start: this config exports no workflow besides its default, which an ordinary start runs, and no door",
747
+ )
685
748
  }
686
- const document = doors.map(([n, d]) => ({
687
- name: n,
688
- workflow: d.workflow,
689
- args: (d.args ?? []).map((arg) => ({ name: arg.name, optional: arg.optional === true })),
690
- }))
691
- if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`)
692
- else yield* writeSelection(out, document, json.path)
749
+ out.write(
750
+ [
751
+ ...workflows.map((name) => `workflow ${name}\n`),
752
+ ...doors.map(([n, d]) => `door ${doorSynopsis(n, d)} → ${d.workflow}\n`),
753
+ ].join(""),
754
+ )
693
755
  })
694
756
 
695
757
  /**
@@ -705,7 +767,10 @@ const runDoorsCommand = (
705
767
  * Idempotent at the initial state. The one refusal is a process whose first
706
768
  * commit is the repository's root commit: nothing earlier to rewind to.
707
769
  */
708
- const runAbandonCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
770
+ const runAbandonCommand = (
771
+ json: JsonMode,
772
+ out: ArtifactOut,
773
+ ): Effect.Effect<void, Error, CommandRequirements> =>
709
774
  Effect.gen(function* () {
710
775
  const git = yield* GitService
711
776
  const config = yield* (yield* ConfigService).load
@@ -716,8 +781,7 @@ const runAbandonCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
716
781
  const required = emitScripts([
717
782
  { kind: "outcome", command: abandonNoopOutcome(initial) },
718
783
  ]).required
719
- out.write(combinedScript(required, ""))
720
- return
784
+ return yield* writeScript(json, out, combinedScript(required, ""))
721
785
  }
722
786
  const restState = run.trace[run.trace.length - 1]!.state
723
787
 
@@ -738,7 +802,7 @@ const runAbandonCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
738
802
  { kind: "outcome", command: abandonedOutcome(restState, run.startParentHash, initial) },
739
803
  ]
740
804
  const required = emitScripts(steps).required
741
- out.write(combinedScript(required, ""))
805
+ yield* writeScript(json, out, combinedScript(required, ""))
742
806
  })
743
807
 
744
808
  /**
@@ -748,7 +812,10 @@ const runAbandonCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
748
812
  * refuses on a dirty working tree, no retained history, or HEAD having
749
813
  * advanced past the retained tip with commits that would be lost.
750
814
  */
751
- const runRestoreCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
815
+ const runRestoreCommand = (
816
+ json: JsonMode,
817
+ out: ArtifactOut,
818
+ ): Effect.Effect<void, Error, CommandRequirements> =>
752
819
  Effect.gen(function* () {
753
820
  const git = yield* GitService
754
821
 
@@ -790,7 +857,7 @@ const runRestoreCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
790
857
  { kind: "outcome", command: restoredOutcome(tip, after.state) },
791
858
  ]
792
859
  const required = emitScripts(steps).required
793
- out.write(combinedScript(required, ""))
860
+ yield* writeScript(json, out, combinedScript(required, ""))
794
861
  })
795
862
 
796
863
  /**
@@ -890,14 +957,18 @@ const runNextCommand = (
890
957
  })
891
958
 
892
959
  /** 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. */
893
- const runValidateCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
960
+ const runValidateCommand = (
961
+ json: JsonMode,
962
+ out: ArtifactOut,
963
+ ): Effect.Effect<void, Error, CommandRequirements> =>
894
964
  Effect.gen(function* () {
895
965
  const rest = yield* currentRest
896
- const resolved = yield* resolveValidateScript(rest)
897
- out.write(
898
- resolved !== undefined && resolved.script.length > 0
899
- ? `${resolved.script}\n`
900
- : `nothing to validate at "${rest.state}"\n`,
966
+ const script = (yield* resolveValidateScript(rest))?.script ?? ""
967
+ yield* writeScript(
968
+ json,
969
+ out,
970
+ script,
971
+ script.length > 0 ? `${script}\n` : `nothing to validate at "${rest.state}"\n`,
901
972
  )
902
973
  })
903
974
 
@@ -906,8 +977,10 @@ const runValidateCommand = (out: ArtifactOut): Effect.Effect<void, Error, Comman
906
977
  * Resolves no workflow state and reads no config — this is what a seeded
907
978
  * `validate:` command invokes as a leaf step, and it formats nothing.
908
979
  *
909
- * An absent file exits 0, like `gtd validate`. Findings ride the failing
910
- * Effect's message, so the shared refusal path reports them on stderr.
980
+ * An unknown mode is a usage error; an absent file is a refusal (the emitted
981
+ * validate script guards on the file's presence before it calls this).
982
+ * Findings ride the failing Effect's message, so the shared refusal path
983
+ * reports them on stderr.
911
984
  */
912
985
  const runCheckCommand = (
913
986
  mode: string,
@@ -919,7 +992,7 @@ const runCheckCommand = (
919
992
  const format = steeringFormatFor(mode)
920
993
  if (format === undefined) {
921
994
  return yield* Effect.fail(
922
- new Error(
995
+ new GtdUsageError(
923
996
  `gtd check: unknown mode "${mode}" — known modes: ${BUILT_IN_MODE_NAMES.join(", ")}`,
924
997
  ),
925
998
  )
@@ -953,12 +1026,8 @@ const runCheckCommand = (
953
1026
  return yield* runOpenQuestionsCheckCommand(format, file)
954
1027
  }
955
1028
 
956
- const workspace = yield* Workspace
957
- const content = workspace.atPath(file)
958
- const errors =
959
- content !== undefined
960
- ? format.validate(content).map((finding) => formatFinding(file, finding))
961
- : []
1029
+ const content = yield* existingFile(file)
1030
+ const errors = format.validate(content).map((finding) => formatFinding(file, finding))
962
1031
 
963
1032
  if (errors.length === 0) return
964
1033
 
@@ -970,6 +1039,14 @@ const runCheckCommand = (
970
1039
  )
971
1040
  })
972
1041
 
1042
+ const existingFile = (file: string): Effect.Effect<string, Error, Workspace> =>
1043
+ Effect.flatMap(Workspace, (workspace) => {
1044
+ const content = workspace.atPath(file)
1045
+ return content === undefined
1046
+ ? Effect.fail(new Error(`gtd check: no such file: ${file}`))
1047
+ : Effect.succeed(content)
1048
+ })
1049
+
973
1050
  /**
974
1051
  * The bare `gtd check --open-threads`: code-comment threads in the files the
975
1052
  * current process changed. The one `check` form that reads process state; no
@@ -997,18 +1074,13 @@ const runCodeThreadsCheckCommand = (): Effect.Effect<void, Error, CommandRequire
997
1074
  return yield* Effect.fail(new Error(lines.join("\n")))
998
1075
  })
999
1076
 
1000
- /** Prints one `file:line: [^name]: <first H entry>` line per open thread; a missing file fails, like `--open-questions`. */
1077
+ /** Prints one `file:line: [^name]: <first H entry>` line per open thread. */
1001
1078
  const runOpenThreadsCheckCommand = (
1002
1079
  format: SteeringFormat,
1003
1080
  file: string,
1004
1081
  ): Effect.Effect<void, Error, Workspace> =>
1005
1082
  Effect.gen(function* () {
1006
- const workspace = yield* Workspace
1007
- const content = workspace.atPath(file)
1008
- if (content === undefined) {
1009
- return yield* Effect.fail(new Error(`gtd check: ${file} does not exist`))
1010
- }
1011
-
1083
+ const content = yield* existingFile(file)
1012
1084
  const errors = openThreads(format, content).map(
1013
1085
  (t) => `${file}:${t.line + 1}: [^${t.name}]: ${t.firstMe}`,
1014
1086
  )
@@ -1021,20 +1093,14 @@ const runOpenThreadsCheckCommand = (
1021
1093
 
1022
1094
  /**
1023
1095
  * Runs the SAME `unansweredQuestions` predicate a flow's `openQuestions()`
1024
- * reads, so the gate script and the flow's own check stay in sync. A missing
1025
- * file is a non-zero exit here, unlike the structural path above.
1096
+ * reads, so the gate script and the flow's own check stay in sync.
1026
1097
  */
1027
1098
  const runOpenQuestionsCheckCommand = (
1028
1099
  format: SteeringFormat,
1029
1100
  file: string,
1030
1101
  ): Effect.Effect<void, Error, Workspace> =>
1031
1102
  Effect.gen(function* () {
1032
- const workspace = yield* Workspace
1033
- const content = workspace.atPath(file)
1034
- if (content === undefined) {
1035
- return yield* Effect.fail(new Error(`gtd check: ${file} does not exist`))
1036
- }
1037
-
1103
+ const content = yield* existingFile(file)
1038
1104
  const errors = unansweredQuestions(format, content).map(
1039
1105
  (q) => `${file}:${q.headingLine + 1}: ${q.question}`,
1040
1106
  )
@@ -1094,7 +1160,7 @@ const gatherBeatDocument = (
1094
1160
  kind === "prompt" ? resolveSession(rendered.memory, rendered.memoryResumed) : undefined
1095
1161
  const resolvedValidate =
1096
1162
  kind === "prompt"
1097
- ? yield* resolveValidateScript(rest).pipe(Effect.catchAll(() => Effect.succeed(undefined)))
1163
+ ? yield* resolveValidateScript(rest).pipe(Effect.catch(() => Effect.succeed(undefined)))
1098
1164
  : undefined
1099
1165
  const validate =
1100
1166
  resolvedValidate !== undefined && resolvedValidate.script.length > 0
@@ -1208,7 +1274,7 @@ const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Err
1208
1274
  export const needsOf = (kind: Command["kind"]): Needs => {
1209
1275
  switch (kind) {
1210
1276
  case "lsp":
1211
- case "install":
1277
+ case "guide":
1212
1278
  case "judgeRun":
1213
1279
  case "strays":
1214
1280
  return "none"
@@ -1225,7 +1291,7 @@ export const standaloneKinds = (): readonly Command["kind"][] => [
1225
1291
  "lsp",
1226
1292
  "check",
1227
1293
  "uncheck",
1228
- "install",
1294
+ "guide",
1229
1295
  "judgeRun",
1230
1296
  "strays",
1231
1297
  ]
@@ -1266,19 +1332,15 @@ const dispatchVoidCommand = (
1266
1332
  out,
1267
1333
  )
1268
1334
  case "start":
1269
- return runStartCommand(command.actor, command.workflow, command.vars, out, command.label)
1270
- case "door":
1271
- return runDoorCommand(command.name, command.args, out)
1272
- case "doors":
1273
- return runDoorsCommand(json, out)
1335
+ return runStartCommand(command, json, out)
1274
1336
  case "abandon":
1275
- return runAbandonCommand(out)
1337
+ return runAbandonCommand(json, out)
1276
1338
  case "restore":
1277
- return runRestoreCommand(out)
1339
+ return runRestoreCommand(json, out)
1278
1340
  case "next":
1279
1341
  return runNextCommand(json, out)
1280
1342
  case "validate":
1281
- return runValidateCommand(out)
1343
+ return runValidateCommand(json, out)
1282
1344
  case "check":
1283
1345
  if (command.mode === undefined || command.file === undefined) {
1284
1346
  return runCodeThreadsCheckCommand()
@@ -1291,8 +1353,10 @@ const dispatchVoidCommand = (
1291
1353
  )
1292
1354
  case "uncheck":
1293
1355
  return runUncheckCommand(command.file)
1294
- case "install":
1295
- return runInstallCommand(out)
1356
+ case "guide":
1357
+ return runGuideCommand(out)
1358
+ case "bundle":
1359
+ return runBundleCommand(out)
1296
1360
  case "summary":
1297
1361
  return runSummaryCommand(out)
1298
1362
  case "base":
@@ -1312,7 +1376,7 @@ const dispatchVoidCommand = (
1312
1376
  case "historyJudges":
1313
1377
  return runHistoryJudgesCommand(command.refs, command.vars, command.jsonl, json, out)
1314
1378
  case "historyFetch":
1315
- return runHistoryFetchCommand(command.remote, out)
1379
+ return runHistoryFetchCommand(command.remote, json, out)
1316
1380
  }
1317
1381
  }
1318
1382
 
@@ -29,7 +29,7 @@ interface JudgeRequest {
29
29
  * The subcommands backed by a driver or tool. Core may not import those
30
30
  * units, so the composition root provides them here.
31
31
  */
32
- export class ToolCommands extends Context.Tag("ToolCommands")<
32
+ export class ToolCommands extends Context.Service<
33
33
  ToolCommands,
34
34
  {
35
35
  readonly ui: (
@@ -39,4 +39,4 @@ export class ToolCommands extends Context.Tag("ToolCommands")<
39
39
  readonly lsp: (gtd: SelfGtd) => Effect.Effect<void, Error>
40
40
  readonly judge: (request: JudgeRequest) => Promise<string>
41
41
  }
42
- >() {}
42
+ >()("ToolCommands") {}