@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
package/src/cli/Cli.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { NodeContext } from "@effect/platform-node"
2
- import { Cause, Effect, Either, Layer } from "effect"
1
+ import { Cause, Effect, Layer, Result } from "effect"
3
2
  import {
4
3
  GtdError,
5
4
  GtdUsageError,
@@ -9,6 +8,7 @@ import {
9
8
  Host,
10
9
  Workspace,
11
10
  CommandRunner,
11
+ NodeServices,
12
12
  } from "../platform/index.js"
13
13
  import {
14
14
  flagSettings,
@@ -20,6 +20,7 @@ import { GTD_VERSION } from "../install/index.js"
20
20
  import {
21
21
  ConfigDiscovery,
22
22
  ConfigService,
23
+ EngineCore,
23
24
  isSettingName,
24
25
  SETTING_NAME_RULE,
25
26
  StartFlags,
@@ -75,7 +76,7 @@ interface FlagRow {
75
76
  readonly repeatable: boolean
76
77
  readonly scope: FlagScope
77
78
  /** All raw occurrences (already de-`=`-ed) → the decoded value, or an error message. */
78
- readonly decode: (raws: readonly string[]) => Either.Either<unknown, string>
79
+ readonly decode: (raws: readonly string[]) => Result.Result<unknown, string>
79
80
  readonly scopeError: string
80
81
  readonly valueHint: string
81
82
  /** One sentence for a command's help, wrapped when rendered; `helpFor` overrides it for a command the flag means something else on. */
@@ -85,20 +86,63 @@ interface FlagRow {
85
86
  readonly conflicts?: readonly string[]
86
87
  }
87
88
 
88
- const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, string> => {
89
+ /** `<name>=<value>` occurrences of `flag` → one record, each name a setting name given at most once. */
90
+ const decodeAssignments = (
91
+ flag: string,
92
+ raws: readonly string[],
93
+ ): Result.Result<Readonly<Record<string, string>>, string> => {
94
+ const vars: Record<string, string> = {}
95
+ for (const raw of raws) {
96
+ const eq = raw.indexOf("=")
97
+ if (eq <= 0) {
98
+ return Result.fail(`gtd: ${flag} must be <name>=<value> with a non-empty name — got "${raw}"`)
99
+ }
100
+ const name = raw.slice(0, eq)
101
+ const value = raw.slice(eq + 1)
102
+ if (!isSettingName(name)) {
103
+ return Result.fail(
104
+ `gtd: ${flag} ${JSON.stringify(name)} is not a valid setting name — ${SETTING_NAME_RULE}`,
105
+ )
106
+ }
107
+ if (/[\r\n]/.test(value)) return Result.fail(`gtd: ${flag} ${name} must be a single-line value`)
108
+ if (Object.hasOwn(vars, name))
109
+ return Result.fail(`gtd: ${flag} ${name} specified more than once`)
110
+ if (!name.startsWith("flag_")) {
111
+ vars[name] = value
112
+ continue
113
+ }
114
+ const pinned = flagSettingValue(value)
115
+ if (pinned === undefined) return Result.fail(`gtd: ${flag} ${name} must be 1, 0, true or false`)
116
+ vars[name] = pinned
117
+ }
118
+ return Result.succeed(vars)
119
+ }
120
+
121
+ const nonNegativeNumber = (raw: string, flag: string): Result.Result<number, string> => {
89
122
  const n = Number(raw)
90
123
  if (raw.trim() === "" || !Number.isFinite(n) || n < 0) {
91
- return Either.left(`gtd: ${flag} must be a non-negative number — got "${raw}"`)
124
+ return Result.fail(`gtd: ${flag} must be a non-negative number — got "${raw}"`)
92
125
  }
93
- return Either.right(n)
126
+ return Result.succeed(n)
94
127
  }
95
128
 
129
+ /** The commands whose only output is a script: `--json` prints `{ schema, script }`. */
130
+ const SCRIPT_KINDS: ReadonlySet<Command["kind"]> = new Set<Command["kind"]>([
131
+ "start",
132
+ "abandon",
133
+ "restore",
134
+ "validate",
135
+ "historyFetch",
136
+ ])
137
+
138
+ const SCRIPT_JSON_HELP = "Print { schema, script }, or with --json=script the script alone"
139
+
96
140
  const JSON_KINDS: ReadonlySet<Command["kind"] | undefined> = new Set<Command["kind"]>([
141
+ ...SCRIPT_KINDS,
97
142
  "next",
98
143
  "land",
99
144
  "judge",
100
145
  "judgeAnswer",
101
- "doors",
102
146
  "historyList",
103
147
  "historyShow",
104
148
  "historyMetrics",
@@ -112,14 +156,16 @@ const FLAGS: readonly FlagRow[] = [
112
156
  arity: "optional",
113
157
  repeatable: false,
114
158
  scope: (kind) => JSON_KINDS.has(kind),
115
- decode: ([raw]) => Either.right(raw ?? ""),
159
+ decode: ([raw]) => Result.succeed(raw ?? ""),
116
160
  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",
161
+ "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`) — " +
162
+ "every other command prints plain text; see `gtd guide` for the driver protocol briefing",
119
163
  valueHint: "<path>",
120
164
  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
165
  helpFor: {
122
166
  strays: "Print one entry of the array (--json=0), nothing past its end",
167
+ ...Object.fromEntries([...SCRIPT_KINDS].map((kind) => [kind, SCRIPT_JSON_HELP])),
168
+ start: `${SCRIPT_JSON_HELP}. Bare, print the list as a JSON array of { kind, name } (a door adds workflow and args)`,
123
169
  },
124
170
  },
125
171
  {
@@ -130,8 +176,8 @@ const FLAGS: readonly FlagRow[] = [
130
176
  decode: ([raw]) => {
131
177
  const n = Number(raw)
132
178
  return raw !== undefined && Number.isInteger(n) && n >= 0 && n <= 65535
133
- ? Either.right(n)
134
- : Either.left(`gtd: --port must be an integer 0–65535 (got '${raw ?? ""}')`)
179
+ ? Result.succeed(n)
180
+ : Result.fail(`gtd: --port must be an integer 0–65535 (got '${raw ?? ""}')`)
135
181
  },
136
182
  scopeError: "gtd: --port is only valid for `gtd ui`",
137
183
  valueHint: "<n>",
@@ -144,8 +190,8 @@ const FLAGS: readonly FlagRow[] = [
144
190
  scope: (kind) => kind === "ui",
145
191
  decode: ([raw]) =>
146
192
  raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
147
- ? Either.left("gtd: --host must be a non-empty, single-line value")
148
- : Either.right(raw),
193
+ ? Result.fail("gtd: --host must be a non-empty, single-line value")
194
+ : Result.succeed(raw),
149
195
  scopeError: "gtd: --host is only valid for `gtd ui`",
150
196
  valueHint: "<addr>",
151
197
  help: "Bind this address directly instead of publishing through tailscale serve",
@@ -155,7 +201,7 @@ const FLAGS: readonly FlagRow[] = [
155
201
  arity: 0,
156
202
  repeatable: false,
157
203
  scope: (kind) => kind === "ui",
158
- decode: () => Either.right(true),
204
+ decode: () => Result.succeed(true),
159
205
  scopeError: "gtd: --self-signed is only valid for `gtd ui`",
160
206
  valueHint: "",
161
207
  help: "Bind directly with a throwaway self-signed certificate instead of the configured ui.cert/ui.key",
@@ -165,7 +211,7 @@ const FLAGS: readonly FlagRow[] = [
165
211
  arity: 0,
166
212
  repeatable: false,
167
213
  scope: (kind) => kind === "ui",
168
- decode: () => Either.right(true),
214
+ decode: () => Result.succeed(true),
169
215
  scopeError: "gtd: --dev is only valid for `gtd ui`",
170
216
  valueHint: "",
171
217
  help: "Serve local development sources instead of the packaged build",
@@ -177,8 +223,8 @@ const FLAGS: readonly FlagRow[] = [
177
223
  scope: (kind) => kind === "judgeRun",
178
224
  decode: ([raw]) =>
179
225
  raw === "fixed" || raw === "jev" || raw === "llm"
180
- ? Either.right(raw)
181
- : Either.left(`gtd: --provider must be one of fixed, jev, llm — got "${raw ?? ""}"`),
226
+ ? Result.succeed(raw)
227
+ : Result.fail(`gtd: --provider must be one of fixed, jev, llm — got "${raw ?? ""}"`),
182
228
  scopeError: "gtd: --provider is only valid for `gtd judge run`",
183
229
  valueHint: "<name>",
184
230
  help: "The answerer: fixed, jev or llm (default: $GTD_JUDGE_PROVIDER, then ~/.config/gtd/judge.json, then jev when $TYPESAFE_API_KEY is set, else llm)",
@@ -190,8 +236,8 @@ const FLAGS: readonly FlagRow[] = [
190
236
  scope: (kind) => kind === "judgeRun",
191
237
  decode: ([raw]) =>
192
238
  raw === undefined || raw === ""
193
- ? Either.left("gtd: --answers must be a non-empty path")
194
- : Either.right(raw),
239
+ ? Result.fail("gtd: --answers must be a non-empty path")
240
+ : Result.succeed(raw),
195
241
  scopeError: "gtd: --answers is only valid for `gtd judge run`",
196
242
  valueHint: "<path>",
197
243
  help: "With --provider fixed: a JSON verdict file [{ id, answer, p }] (default: $GTD_JUDGE_ANSWERS, inline JSON)",
@@ -213,96 +259,55 @@ const FLAGS: readonly FlagRow[] = [
213
259
  scope: (kind) => kind === "land" || kind === "judgeRun",
214
260
  decode: ([raw]) =>
215
261
  raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
216
- ? Either.left("gtd: --model must be a non-empty, single-line value")
217
- : Either.right(raw),
218
- scopeError: "gtd: --model is only valid for `gtd land` (with --cost) or `gtd judge run`",
262
+ ? Result.fail("gtd: --model must be a non-empty, single-line value")
263
+ : Result.succeed(raw),
264
+ scopeError:
265
+ "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
266
  valueHint: "<name>",
220
267
  help: "",
221
268
  helpFor: {
222
- land: "With --cost: the model that cost was spent on",
269
+ land: "With --cost: the model that cost was spent on — recorded on the commit, nothing runs on it",
223
270
  judgeRun:
224
271
  "The claude model the llm answerer uses (default: $GTD_JUDGE_MODEL, then judge.json, then sonnet)",
225
272
  },
226
273
  },
227
274
  {
228
- name: "--workflow",
275
+ name: "--threshold",
229
276
  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",
277
+ repeatable: true,
278
+ scope: (kind) => kind === "historyJudges",
279
+ decode: (raws) => decodeAssignments("--threshold", raws),
280
+ scopeError: "gtd: --threshold is only valid for `gtd history judges`",
281
+ valueHint: "<name>=<value>",
282
+ help: "Re-decide every past verdict under this threshold: judgeIdenticalMinP or qualityTriageMinP (repeatable)",
238
283
  },
239
284
  {
240
285
  name: "--var",
241
286
  arity: 1,
242
287
  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`)",
288
+ scope: (kind) => kind === "start",
289
+ decode: (raws) => decodeAssignments("--var", raws),
290
+ scopeError: "gtd: --var is only valid for `gtd start <name>`",
281
291
  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
- },
292
+ 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
293
  },
289
294
  {
290
295
  name: "--jsonl",
291
296
  arity: 0,
292
297
  repeatable: false,
293
298
  scope: (kind) => kind === "historyJudges",
294
- decode: () => Either.right(true),
299
+ decode: () => Result.succeed(true),
295
300
  scopeError: "gtd: --jsonl is only valid for `gtd history judges`",
296
301
  valueHint: "",
297
302
  help: "Print one JSON line per verdict instead of the report",
298
- conflicts: ["--json", "--var"],
303
+ conflicts: ["--json", "--threshold"],
299
304
  },
300
305
  {
301
306
  name: "--open-questions",
302
307
  arity: 0,
303
308
  repeatable: false,
304
309
  scope: (kind) => kind === "check",
305
- decode: () => Either.right(true),
310
+ decode: () => Result.succeed(true),
306
311
  scopeError: "gtd: --open-questions is only valid for `gtd check`",
307
312
  valueHint: "",
308
313
  help: "Instead of <mode>'s findings, list the unanswered questions in <file>; exits 1 when any remain",
@@ -312,7 +317,7 @@ const FLAGS: readonly FlagRow[] = [
312
317
  arity: 0,
313
318
  repeatable: false,
314
319
  scope: (kind) => kind === "check",
315
- decode: () => Either.right(true),
320
+ decode: () => Result.succeed(true),
316
321
  scopeError: "gtd: --open-threads is only valid for `gtd check`",
317
322
  valueHint: "",
318
323
  help: "Instead of <mode>'s findings, list the open threads in <file>; exits 1 when any are open. Alone, lists the open code-comment threads in the files the current process changed",
@@ -322,7 +327,7 @@ const FLAGS: readonly FlagRow[] = [
322
327
  arity: 0,
323
328
  repeatable: false,
324
329
  scope: () => true,
325
- decode: () => Either.right(true),
330
+ decode: () => Result.succeed(true),
326
331
  scopeError: "gtd: --verbose is valid for every command — this error is unreachable",
327
332
  valueHint: "",
328
333
  help: "Narrate on stderr what gtd resolved and where its config came from",
@@ -356,6 +361,8 @@ type Arity =
356
361
  | { readonly names: readonly [string, string] }
357
362
  // One required leading positional, then any number the command itself judges.
358
363
  | { readonly name: string; readonly variadic: string }
364
+ // The same, the leading one optional too.
365
+ | { readonly name: string; readonly variadic: string; readonly bare: true }
359
366
  // At most one trailing positional.
360
367
  | { readonly name: string; readonly optional: true }
361
368
  // Any number of positionals, none included.
@@ -407,25 +414,21 @@ gtd writes nothing itself. Plain output names the commit it would make; --json=s
407
414
  arity: "none",
408
415
  group: "Core loop",
409
416
  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.`,
417
+ 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
418
  },
412
419
  {
413
- token: "door",
414
- kind: "door",
415
- arity: { name: "name", variadic: "args" },
420
+ token: "start",
421
+ kind: "start",
422
+ arity: { name: "name", variadic: "args", bare: true },
416
423
  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.
424
+ summary: "Start a workflow or door; bare, list them",
425
+ details: `Start a new process: print the script that makes its first commit, which also commits whatever the working tree holds.
419
426
 
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.`,
427
+ <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.
428
+
429
+ s=$(gtd start <name> --json=script) && sh -c "$s"
430
+
431
+ 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
432
  },
430
433
  {
431
434
  token: "abandon",
@@ -435,7 +438,7 @@ With no <name>, an unknown one, or the wrong number of args, it lists the doors
435
438
  summary: "Stop the process, keeping its changes",
436
439
  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
440
 
438
- s=$(gtd abandon) && sh -c "$s"`,
441
+ s=$(gtd abandon --json=script) && sh -c "$s"`,
439
442
  },
440
443
  {
441
444
  token: "restore",
@@ -513,7 +516,7 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
513
516
  summary: "Print a script that fetches finished processes",
514
517
  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
518
 
516
- s=$(gtd history fetch) && sh -c "$s"`,
519
+ s=$(gtd history fetch --json=script) && sh -c "$s"`,
517
520
  },
518
521
  {
519
522
  token: "ui",
@@ -524,13 +527,21 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
524
527
  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
528
  },
526
529
  {
527
- token: "install",
528
- kind: "install",
530
+ token: "guide",
531
+ kind: "guide",
529
532
  arity: "none",
530
533
  group: "Driver plumbing",
531
534
  summary: "Print a briefing for writing a driver",
532
535
  details: `Print a self-contained briefing that teaches an agent or a person to build a gtd driver in any shell or runtime, and where to get a workflow — gtd ships none. Writes nothing.`,
533
536
  },
537
+ {
538
+ token: "bundle",
539
+ kind: "bundle",
540
+ arity: "none",
541
+ group: "Driver plumbing",
542
+ summary: "Print the pin of the current workflow",
543
+ details: `Print a pin to stdout: gtd's engine and the current workflow — gtd.config.ts, every module it imports and every npm package they use — as one gzipped ES module, after a plain-text header line \`gtdpin <format> gtd <version>\`. Git diffs it as binary. Refuses a workflow gtd would refuse, and a package that loads a native addon (.node), naming it. Writes nothing.`,
544
+ },
534
545
  {
535
546
  token: "judge",
536
547
  kind: "judge",
@@ -555,7 +566,7 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
555
566
  summary: "Answer a judgment with a model",
556
567
  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
568
 
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.`,
569
+ 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
570
  },
560
571
  {
561
572
  token: "check",
@@ -563,7 +574,7 @@ The fixed answerer reads --answers and leaves out the questions it does not cove
563
574
  arity: { names: ["mode", "file"] },
564
575
  group: "Driver plumbing",
565
576
  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.`,
577
+ 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
578
  },
568
579
  {
569
580
  token: "uncheck",
@@ -602,13 +613,14 @@ const synopsis = (row: CommandRow): string => {
602
613
  const { token, arity } = row
603
614
  if (arity === "none") return token
604
615
  if ("names" in arity) return `${token} <${arity.names[0]}> <${arity.names[1]}>`
616
+ if ("bare" in arity) return `${token} [${arity.name}] [${arity.variadic}...]`
605
617
  if ("variadic" in arity) return `${token} <${arity.name}> [${arity.variadic}...]`
606
618
  if ("optional" in arity) return `${token} [${arity.name}]`
607
619
  if ("many" in arity) return `${token} [${arity.name}...]`
608
620
  return `${token} <${arity.name}>`
609
621
  }
610
622
 
611
- const SPACE_FORM_FLAGS = new Set(["--workflow", "--var"])
623
+ const SPACE_FORM_FLAGS = new Set(["--var", "--threshold"])
612
624
 
613
625
  const flagHeader = (row: FlagRow): string => {
614
626
  if (row.valueHint === "") return row.name
@@ -618,15 +630,11 @@ const flagHeader = (row: FlagRow): string => {
618
630
  : `${row.name}=${row.valueHint}`
619
631
  }
620
632
 
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
633
  const GLOBAL_OPTIONS: readonly (readonly [string, string])[] = [
627
634
  ["-v, --verbose", "Narrate on stderr what gtd resolved"],
628
635
  ["-h, --help", "Print help; gtd <command> --help for one command"],
629
636
  ["-V, --version", "Print the version"],
637
+ ["--", "End the options: every word after it is an argument"],
630
638
  ]
631
639
 
632
640
  const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
@@ -638,7 +646,8 @@ const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
638
646
  ["version", "Print the version", "Print gtd's version."],
639
647
  ]
640
648
 
641
- const EXAMPLES = ` $ gtd --workflow <name> start a process
649
+ const EXAMPLES = ` $ gtd start list what can start
650
+ $ gtd start <name> start a process
642
651
  $ gtd next see what happens next
643
652
  $ s=$(gtd land --json=script) && sh -c "$s" land the turn
644
653
  $ gtd help land one command's details`
@@ -652,13 +661,11 @@ export const renderHelp = (): string => {
652
661
  const rows = COMMAND_ROWS.filter((r) => r.group === group).flatMap((r) =>
653
662
  definition(synopsis(r), r.summary, TOP_COLUMN),
654
663
  )
655
- const start =
656
- group === "Start and stop" ? definition(START_ROW.term, START_ROW.summary, TOP_COLUMN) : []
657
664
  const extra =
658
665
  group === "Driver plumbing"
659
666
  ? EXTRA_TOPICS.flatMap(([term, summary]) => definition(term, summary, TOP_COLUMN))
660
667
  : []
661
- return `${group}:\n${lines([...start, ...rows, ...extra])}`
668
+ return `${group}:\n${lines([...rows, ...extra])}`
662
669
  })
663
670
  return (
664
671
  "Usage: gtd <command> [options]\n\n" +
@@ -674,7 +681,7 @@ export const renderHelp = (): string => {
674
681
  `\nExamples:\n${EXAMPLES}\n\n` +
675
682
  lines(
676
683
  wrap(
677
- "Run `gtd help <command>` for a command's details and options, and `gtd install` to learn how to drive gtd.",
684
+ "Run `gtd help <command>` for a command's details and options, and `gtd guide` to learn how to drive gtd.",
678
685
  ),
679
686
  )
680
687
  )
@@ -683,7 +690,7 @@ export const renderHelp = (): string => {
683
690
  /** Shown on stderr above a bare `gtd`'s usage error — the overview stays one `gtd help` away. */
684
691
  const SHORT_USAGE = `Usage: gtd <command> [options]
685
692
 
686
- gtd --workflow <name> start a process
693
+ gtd start <name> start a process
687
694
  gtd next print what should happen next
688
695
  gtd land record what the tree now shows
689
696
 
@@ -764,11 +771,13 @@ const tokenize = (tail: readonly string[]): TokenizeError | TokenizeOk => {
764
771
  const present = new Set<string>()
765
772
  let jsonSeen = false
766
773
  let afterBareJson: string | undefined
774
+ const end = tail.indexOf("--")
767
775
 
768
776
  for (let i = 0; i < tail.length; i++) {
769
777
  const original = tail[i]!
778
+ if (i === end) continue
770
779
  const tok = SINGLE_DASH_ALIASES.get(original) ?? original
771
- if (!tok.startsWith("-") || tok === "-") {
780
+ if ((end !== -1 && i > end) || !tok.startsWith("-") || tok === "-") {
772
781
  if (tail[i - 1] === "--json") afterBareJson ??= original
773
782
  positionals.push(original)
774
783
  continue
@@ -894,7 +903,7 @@ const tooMany = (
894
903
  const arityError = (cmd: string, rest: readonly string[], arity: Arity): string | undefined => {
895
904
  if (arity === "none") return tooMany(cmd, rest, "none", 0)
896
905
  if ("names" in arity) return pairArityError(cmd, rest, arity.names)
897
- if ("many" in arity) return undefined
906
+ if ("many" in arity || "bare" in arity) return undefined
898
907
  if ("optional" in arity) return tooMany(cmd, rest, `at most one ${arity.name}`, 1)
899
908
  if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`
900
909
  return "variadic" in arity ? undefined : tooMany(cmd, rest, `one ${arity.name}`, 1)
@@ -916,7 +925,7 @@ const pairArityError = (
916
925
  /** Decode every present flag into a bag of values, or the first decode error encountered. */
917
926
  const decodeFlags = (
918
927
  byFlag: ReadonlyMap<string, readonly string[]>,
919
- ): Either.Either<Readonly<Record<string, unknown>>, string> => {
928
+ ): Result.Result<Readonly<Record<string, unknown>>, string> => {
920
929
  const bag: Record<string, unknown> = {}
921
930
  for (const row of FLAGS) {
922
931
  const raws = byFlag.get(row.name)
@@ -925,10 +934,31 @@ const decodeFlags = (
925
934
  // to decode — presence alone is tracked via `present`, not the bag.
926
935
  if (row.arity === "optional" && raws.length === 0) continue
927
936
  const decoded = row.decode(raws)
928
- if (Either.isLeft(decoded)) return Either.left(decoded.left)
929
- bag[row.name] = decoded.right
937
+ if (Result.isFailure(decoded)) return Result.fail(decoded.failure)
938
+ bag[row.name] = decoded.success
930
939
  }
931
- return Either.right(bag)
940
+ return Result.succeed(bag)
941
+ }
942
+
943
+ /** `gtd history judges`' thresholds: each a known judge threshold with a numeric value. */
944
+ const judgeThresholds = (
945
+ thresholds: Readonly<Record<string, string>>,
946
+ ): Result.Result<Readonly<Record<string, string>>, string> => {
947
+ const unknown = Object.keys(thresholds).find((name) => !Object.hasOwn(THRESHOLDS, name))
948
+ if (unknown !== undefined) {
949
+ return Result.fail(
950
+ `gtd history judges: --threshold ${unknown} is no judge threshold — use ${Object.keys(THRESHOLDS).join(" or ")}`,
951
+ )
952
+ }
953
+ const nan = Object.entries(thresholds).find(
954
+ ([, value]) => value.trim() === "" || !Number.isFinite(Number(value)),
955
+ )
956
+ if (nan !== undefined) {
957
+ return Result.fail(
958
+ `gtd history judges: --threshold ${nan[0]} must be a number — got "${nan[1]}"`,
959
+ )
960
+ }
961
+ return Result.succeed(thresholds)
932
962
  }
933
963
 
934
964
  /** 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. */
@@ -943,9 +973,19 @@ const buildLandCommand = (bag: {
943
973
 
944
974
  const HELP_FLAGS = new Set(["--help", "-h"])
945
975
 
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. */
976
+ /** The options part of argv: everything before a `--`, which ends the options. */
977
+ const optionsOf = (argv: readonly string[]): readonly string[] => {
978
+ const end = argv.indexOf("--", 2)
979
+ return end === -1 ? argv : argv.slice(0, end)
980
+ }
981
+
982
+ /** `--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
983
  const helpPlan = (argv: readonly string[]): CliPlan => {
948
- const tokenized = tokenize(argv.slice(2).filter((tok) => !HELP_FLAGS.has(tok)))
984
+ const tokenized = tokenize(
985
+ optionsOf(argv)
986
+ .slice(2)
987
+ .filter((tok) => !HELP_FLAGS.has(tok)),
988
+ )
949
989
  const topic = tokenized.kind === "ok" ? tokenized.positionals : []
950
990
  return { kind: "output", stdout: renderTopicHelp(topic) ?? renderHelp() }
951
991
  }
@@ -963,8 +1003,9 @@ const helpCommandPlan = (topic: readonly string[], json: boolean): CliPlan => {
963
1003
 
964
1004
  // fallow-ignore-next-line complexity
965
1005
  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")) {
1006
+ const options = optionsOf(argv)
1007
+ if (options.some((tok) => HELP_FLAGS.has(tok))) return helpPlan(argv)
1008
+ if (options.includes("--version") || options.includes("-V")) {
968
1009
  return { kind: "output", stdout: `${GTD_VERSION}\n` }
969
1010
  }
970
1011
 
@@ -973,8 +1014,6 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
973
1014
  const { positionals, byFlag, present, jsonSeen, afterBareJson } = tokenized
974
1015
 
975
1016
  const first = positionals[0]
976
- const workflowRaw = byFlag.get("--workflow")?.[0]
977
- const workflowPresent = workflowRaw !== undefined
978
1017
 
979
1018
  if (first === "version") return { kind: "output", stdout: `${GTD_VERSION}\n` }
980
1019
  if (first === "help") return helpCommandPlan(positionals.slice(1), jsonSeen)
@@ -1012,31 +1051,17 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1012
1051
  // `Arity` variant is needed for this one verb.
1013
1052
  const judgeAnswer = row?.kind === "judge" && positionals[1] === "answer"
1014
1053
 
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
1054
  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) {
1055
+
1056
+ if (row === undefined || first === undefined) {
1030
1057
  // 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).
1058
+ // used here (e.g. a bare `gtd --var x=1`) is a more specific error than
1059
+ // "missing command"/"unknown command", so it takes priority.
1035
1060
  const violation = scopeViolation(undefined, present)
1036
1061
  if (violation !== undefined) return usagePlan(violation, jsonSeen)
1037
1062
  if (first === undefined) {
1038
1063
  return usagePlan(
1039
- "gtd: missing command — run `gtd help` for every command, or `gtd install` to learn how to drive gtd",
1064
+ "gtd: missing command — run `gtd help` for every command, or `gtd guide` to learn how to drive gtd",
1040
1065
  jsonSeen,
1041
1066
  SHORT_USAGE,
1042
1067
  )
@@ -1047,37 +1072,21 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1047
1072
  )
1048
1073
  }
1049
1074
 
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
- }
1075
+ const kind: Command["kind"] = judgeAnswer ? "judgeAnswer" : judgeRun ? "judgeRun" : row.kind
1076
+
1077
+ // `judgeAnswer` consumes TWO leading positionals ("judge" and "answer"), everything else one.
1078
+ const restPositionals = positionals.slice(judgeAnswer || judgeRun || isHistory ? 2 : 1)
1079
+
1080
+ const bare = kind === "check" && restPositionals.length === 0 && present.has("--open-threads")
1081
+ const arityMsg = bare
1082
+ ? undefined
1083
+ : arityError(isHistory ? row.token : first, restPositionals, row.arity)
1084
+ if (arityMsg !== undefined) {
1085
+ const hint =
1086
+ afterBareJson !== undefined && restPositionals.includes(afterBareJson)
1087
+ ? ` — to print one field, write --json=${afterBareJson}`
1088
+ : ""
1089
+ return usagePlan(arityMsg + hint, jsonSeen)
1081
1090
  }
1082
1091
 
1083
1092
  const violation = scopeViolation(kind, present)
@@ -1087,13 +1096,14 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1087
1096
  if (conflict !== undefined) return usagePlan(conflict, jsonSeen)
1088
1097
 
1089
1098
  const decoded = decodeFlags(byFlag)
1090
- if (Either.isLeft(decoded)) return usagePlan(decoded.left, jsonSeen)
1091
- const bag = decoded.right as {
1099
+ if (Result.isFailure(decoded)) return usagePlan(decoded.failure, jsonSeen)
1100
+ const bag = decoded.success as {
1092
1101
  readonly "--json"?: string
1093
1102
  readonly "--port"?: number
1094
1103
  readonly "--cost"?: number
1095
1104
  readonly "--model"?: string
1096
1105
  readonly "--var"?: Readonly<Record<string, string>>
1106
+ readonly "--threshold"?: Readonly<Record<string, string>>
1097
1107
  readonly "--open-questions"?: boolean
1098
1108
  readonly "--open-threads"?: boolean
1099
1109
  readonly "--host"?: string
@@ -1125,28 +1135,20 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1125
1135
  }
1126
1136
 
1127
1137
  if (kind === "start") {
1128
- const label = `gtd --workflow ${workflowRaw}`
1138
+ const [name, ...args] = restPositionals
1139
+ if (name === undefined && bag["--var"] !== undefined) {
1140
+ return usagePlan(
1141
+ "gtd start: --var needs a <name> — run `gtd start` to list the workflows and doors",
1142
+ jsonSeen,
1143
+ )
1144
+ }
1129
1145
  return {
1130
1146
  kind: "command",
1131
1147
  command: {
1132
1148
  kind: "start",
1133
- actor: "human",
1134
- workflow: workflowRaw!,
1149
+ ...(name !== undefined ? { name } : {}),
1150
+ args,
1135
1151
  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
1152
  },
1151
1153
  json,
1152
1154
  verbose,
@@ -1237,23 +1239,9 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1237
1239
  }
1238
1240
 
1239
1241
  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
- }
1242
+ const thresholds = judgeThresholds(bag["--threshold"] ?? {})
1243
+ if (Result.isFailure(thresholds)) return usagePlan(thresholds.failure, jsonSeen)
1244
+ const vars = thresholds.success
1257
1245
  return {
1258
1246
  kind: "command",
1259
1247
  command: {
@@ -1315,8 +1303,8 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1315
1303
  | "restore"
1316
1304
  | "next"
1317
1305
  | "validate"
1318
- | "install"
1319
- | "doors"
1306
+ | "guide"
1307
+ | "bundle"
1320
1308
  | "base"
1321
1309
  | "judge"
1322
1310
  | "judgeAnswer"
@@ -1330,7 +1318,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1330
1318
  // ---------------------------------------------------------------------------
1331
1319
 
1332
1320
  export interface CliIo {
1333
- readonly stdout: (chunk: string) => void
1321
+ readonly stdout: (chunk: string | Uint8Array) => void
1334
1322
  readonly stderr: (chunk: string) => void
1335
1323
  readonly exit: (code: number) => void
1336
1324
  /**
@@ -1355,7 +1343,7 @@ const writeStderr = (chunk: string): void => {
1355
1343
  export type LiveToolCommands = Layer.Layer<
1356
1344
  ToolCommands,
1357
1345
  never,
1358
- Exclude<CommandRequirements, ToolCommands> | NodeContext.NodeContext
1346
+ Exclude<CommandRequirements, ToolCommands> | NodeServices.NodeServices
1359
1347
  >
1360
1348
 
1361
1349
  export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
@@ -1380,20 +1368,21 @@ export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
1380
1368
  // "command"`, so --version/--help/a usage error never provoke it.
1381
1369
  layers: (verbose) =>
1382
1370
  // Dependency order matters: Workspace.Live needs GitService, both need
1383
- // Host, and GitService needs NodeContext's CommandExecutor. Each
1371
+ // Host, and GitService needs NodeServices' ChildProcessSpawner. Each
1384
1372
  // `provideMerge` satisfies the layers above it AND stays in the output.
1385
1373
  tools.pipe(
1386
1374
  Layer.provideMerge(
1387
1375
  Layer.mergeAll(
1388
1376
  ConfigService.Live,
1389
1377
  ConfigDiscovery.Live,
1378
+ EngineCore.Live,
1390
1379
  Workspace.Live,
1391
1380
  Narrator.layer(writeStderr, verbose),
1392
1381
  CommandRunner.Live,
1393
1382
  ).pipe(
1394
1383
  Layer.provideMerge(GitService.Live),
1395
1384
  Layer.provideMerge(Host.Live),
1396
- Layer.provideMerge(NodeContext.layer),
1385
+ Layer.provideMerge(NodeServices.layer),
1397
1386
  ),
1398
1387
  ),
1399
1388
  ),
@@ -1408,14 +1397,20 @@ export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
1408
1397
  export const normalizeTrailingNewline = (artifact: string): string =>
1409
1398
  artifact.length === 0 ? artifact : `${artifact.replace(/\n+$/, "")}\n`
1410
1399
 
1400
+ // Bytes are a binary artifact, written as they are.
1411
1401
  const bufferedArtifactOut = (io: CliIo): ArtifactOut => {
1412
- let buffer = ""
1402
+ let buffer: string | Uint8Array = ""
1413
1403
  return {
1414
1404
  write: (chunk) => {
1415
- buffer += chunk
1405
+ buffer =
1406
+ typeof buffer === "string" && typeof chunk === "string"
1407
+ ? buffer + chunk
1408
+ : Buffer.concat([Buffer.from(buffer), Buffer.from(chunk)])
1416
1409
  },
1417
1410
  flush: () => {
1418
- if (buffer.length > 0) io.stdout(normalizeTrailingNewline(buffer))
1411
+ if (buffer.length > 0) {
1412
+ io.stdout(typeof buffer === "string" ? normalizeTrailingNewline(buffer) : buffer)
1413
+ }
1419
1414
  buffer = ""
1420
1415
  },
1421
1416
  }
@@ -1501,8 +1496,8 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
1501
1496
  // (Ctrl-C on `ui`/`lsp`'s blocking `Effect.never`) is passed
1502
1497
  // through untouched via `failCause` so `NodeRuntime.runMain` still sees a
1503
1498
  // real interruption; everything else gets the envelope.
1504
- Effect.catchAll((cause) =>
1505
- Cause.isInterruptedOnly(cause)
1499
+ Effect.catch((cause) =>
1500
+ Cause.hasInterruptsOnly(cause)
1506
1501
  ? Effect.failCause(cause)
1507
1502
  : report(io, plan.json.kind !== "off")(cause),
1508
1503
  ),