@pmelab/gtd 60.0.0 → 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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -2
  3. package/dist/chunks/testkit-BhTey1Do.mjs +30517 -0
  4. package/dist/gtd.bundle.mjs +10545 -19230
  5. package/dist/gtd.core.mjs +593 -0
  6. package/dist/testkit/gherkin.mjs +2 -2
  7. package/dist/testkit.mjs +1 -1
  8. package/package.json +7 -7
  9. package/src/cli/Cli.test.ts +2 -2
  10. package/src/cli/Cli.ts +68 -51
  11. package/src/cli/Output.test.ts +1 -1
  12. package/src/cli/Output.ts +3 -2
  13. package/src/cli/Program.test.ts +14 -6
  14. package/src/cli/Program.ts +35 -10
  15. package/src/cli/ToolCommands.ts +2 -2
  16. package/src/edge/Edge.test.ts +8 -6
  17. package/src/edge/Edge.ts +3 -3
  18. package/src/flows/runtime.ts +1 -1
  19. package/src/install/Version.ts +9 -3
  20. package/src/install/index.ts +1 -1
  21. package/src/platform/CommandRunner.ts +13 -11
  22. package/src/platform/Commentary.ts +2 -2
  23. package/src/platform/Git.test.ts +4 -5
  24. package/src/platform/Git.ts +42 -38
  25. package/src/platform/Host.ts +1 -1
  26. package/src/platform/NodeServices.ts +11 -0
  27. package/src/platform/Workspace.test.ts +4 -5
  28. package/src/platform/Workspace.ts +3 -3
  29. package/src/platform/WorktreeState.test.ts +2 -3
  30. package/src/platform/WorktreeState.ts +1 -1
  31. package/src/platform/index.ts +2 -1
  32. package/src/step/planStart.ts +3 -3
  33. package/src/twins/EmittedScripts.test.ts +1 -1
  34. package/src/twins/GitTiers.ts +6 -7
  35. package/src/twins/Layers.test.ts +1 -1
  36. package/src/twins/Layers.ts +10 -3
  37. package/src/twins/cliIo.ts +9 -4
  38. package/src/workflow/discovery.test.ts +24 -7
  39. package/src/workflow/discovery.ts +5 -6
  40. package/src/workflow/index.ts +1 -0
  41. package/src/workflow/load.test.ts +60 -3
  42. package/src/workflow/load.ts +316 -166
  43. package/src/workflow/pin.test.ts +159 -0
  44. package/src/workflow/pin.ts +72 -0
  45. package/src/workflow/vars.ts +3 -3
  46. package/dist/chunks/testkit-BvecVjQ0.mjs +0 -32355
@@ -1,8 +1,8 @@
1
- import { n as runsItsScript, r as testRepo } from "../chunks/testkit-BvecVjQ0.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-BhTey1Do.mjs";
2
2
  import { resolve } from "node:path";
3
- import assert from "node:assert";
4
3
  import { pathToFileURL } from "node:url";
5
4
  import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
5
+ import assert from "node:assert";
6
6
  //#region src/gherkin/messages.ts
7
7
  const CONTENT_LINES = 5;
8
8
  const reason = (error) => error instanceof Error ? error.message : String(error);
package/dist/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { H as installContext } from "./chunks/flows-DENt0uWV.mjs";
2
- import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-BvecVjQ0.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-BhTey1Do.mjs";
3
3
  export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "60.0.0",
3
+ "version": "60.1.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -76,14 +76,14 @@
76
76
  "url": "https://github.com/pmelab/gtd.git"
77
77
  },
78
78
  "engines": {
79
- "node": ">=22"
79
+ "node": ">=22.19.0"
80
80
  },
81
81
  "type": "module",
82
82
  "packageManager": "npm@11.17.0",
83
83
  "scripts": {
84
84
  "prepare": "husky",
85
85
  "dev": "node dev/run.mjs",
86
- "build": "tsdown --filter web && npx @tailwindcss/cli -i src/web/styles.css -o dist/web/main.css --minify && node scripts/inline-web-client.mjs && tsdown --filter gtd && tsdown --filter entrypoints",
86
+ "build": "tsdown --filter web && npx @tailwindcss/cli -i src/web/styles.css -o dist/web/main.css --minify && node scripts/inline-web-client.mjs && tsdown --filter gtd --filter core && tsdown --filter entrypoints",
87
87
  "postbuild": "node scripts/assert-no-test-doubles.mjs",
88
88
  "watch": "tsdown --watch",
89
89
  "bench:replay": "node scripts/bench-replay.mjs",
@@ -141,6 +141,7 @@
141
141
  "fallow": "3.25.0",
142
142
  "fast-check": "^4.8.0",
143
143
  "husky": "^9.1.7",
144
+ "jiti": "^2.7.0",
144
145
  "lint-staged": "^17.0.8",
145
146
  "oxfmt": "^0.59.0",
146
147
  "oxlint": "^1.74.0",
@@ -173,12 +174,10 @@
173
174
  }
174
175
  },
175
176
  "dependencies": {
176
- "@effect/platform": "^0.94.2",
177
- "@effect/platform-node": "^0.104.1",
177
+ "@effect/platform-node-shared": "^4.1.0",
178
178
  "@standard-schema/spec": "^1.1.0",
179
179
  "@trpc/server": "^11.18.0",
180
- "effect": "^3.19.15",
181
- "jiti": "^2.7.0",
180
+ "effect": "^4.1.0",
182
181
  "mdast-util-from-markdown": "^2.0.3",
183
182
  "mdast-util-gfm-autolink-literal": "^2.0.1",
184
183
  "mdast-util-gfm-footnote": "^2.1.0",
@@ -188,6 +187,7 @@
188
187
  "micromark-extension-gfm-footnote": "^2.1.0",
189
188
  "micromark-extension-gfm-strikethrough": "^2.1.0",
190
189
  "micromark-extension-gfm-task-list-item": "^2.1.0",
190
+ "rolldown": "^1.1.5",
191
191
  "vscode-languageserver": "^10.1.0",
192
192
  "vscode-languageserver-textdocument": "^1.0.13"
193
193
  }
@@ -1537,7 +1537,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
1537
1537
  repo.writeFile(".gtd/TODO.md", "## Open Questions\n\n###\n\nno question text.\n")
1538
1538
  const stdoutCalls: string[] = []
1539
1539
  const io: CliIo = {
1540
- stdout: (chunk) => stdoutCalls.push(chunk),
1540
+ stdout: (chunk) => stdoutCalls.push(String(chunk)),
1541
1541
  stderr: () => {},
1542
1542
  exit: () => {},
1543
1543
  layers: () => testLayers(repo),
@@ -1548,7 +1548,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
1548
1548
  })
1549
1549
 
1550
1550
  describe("nodeCliIo", () => {
1551
- const io = nodeCliIo(Layer.succeed(ToolCommands, {} as ToolCommands["Type"]))
1551
+ const io = nodeCliIo(Layer.succeed(ToolCommands, {} as ToolCommands["Service"]))
1552
1552
 
1553
1553
  it("exposes stdout/stderr/exit/layers", () => {
1554
1554
  expect(typeof io.stdout).toBe("function")
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. */
@@ -89,40 +90,40 @@ interface FlagRow {
89
90
  const decodeAssignments = (
90
91
  flag: string,
91
92
  raws: readonly string[],
92
- ): Either.Either<Readonly<Record<string, string>>, string> => {
93
+ ): Result.Result<Readonly<Record<string, string>>, string> => {
93
94
  const vars: Record<string, string> = {}
94
95
  for (const raw of raws) {
95
96
  const eq = raw.indexOf("=")
96
97
  if (eq <= 0) {
97
- return Either.left(`gtd: ${flag} must be <name>=<value> with a non-empty name — got "${raw}"`)
98
+ return Result.fail(`gtd: ${flag} must be <name>=<value> with a non-empty name — got "${raw}"`)
98
99
  }
99
100
  const name = raw.slice(0, eq)
100
101
  const value = raw.slice(eq + 1)
101
102
  if (!isSettingName(name)) {
102
- return Either.left(
103
+ return Result.fail(
103
104
  `gtd: ${flag} ${JSON.stringify(name)} is not a valid setting name — ${SETTING_NAME_RULE}`,
104
105
  )
105
106
  }
106
- if (/[\r\n]/.test(value)) return Either.left(`gtd: ${flag} ${name} must be a single-line value`)
107
+ if (/[\r\n]/.test(value)) return Result.fail(`gtd: ${flag} ${name} must be a single-line value`)
107
108
  if (Object.hasOwn(vars, name))
108
- return Either.left(`gtd: ${flag} ${name} specified more than once`)
109
+ return Result.fail(`gtd: ${flag} ${name} specified more than once`)
109
110
  if (!name.startsWith("flag_")) {
110
111
  vars[name] = value
111
112
  continue
112
113
  }
113
114
  const pinned = flagSettingValue(value)
114
- if (pinned === undefined) return Either.left(`gtd: ${flag} ${name} must be 1, 0, true or false`)
115
+ if (pinned === undefined) return Result.fail(`gtd: ${flag} ${name} must be 1, 0, true or false`)
115
116
  vars[name] = pinned
116
117
  }
117
- return Either.right(vars)
118
+ return Result.succeed(vars)
118
119
  }
119
120
 
120
- const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, string> => {
121
+ const nonNegativeNumber = (raw: string, flag: string): Result.Result<number, string> => {
121
122
  const n = Number(raw)
122
123
  if (raw.trim() === "" || !Number.isFinite(n) || n < 0) {
123
- 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}"`)
124
125
  }
125
- return Either.right(n)
126
+ return Result.succeed(n)
126
127
  }
127
128
 
128
129
  /** The commands whose only output is a script: `--json` prints `{ schema, script }`. */
@@ -155,7 +156,7 @@ const FLAGS: readonly FlagRow[] = [
155
156
  arity: "optional",
156
157
  repeatable: false,
157
158
  scope: (kind) => JSON_KINDS.has(kind),
158
- decode: ([raw]) => Either.right(raw ?? ""),
159
+ decode: ([raw]) => Result.succeed(raw ?? ""),
159
160
  scopeError:
160
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`) — " +
161
162
  "every other command prints plain text; see `gtd guide` for the driver protocol briefing",
@@ -175,8 +176,8 @@ const FLAGS: readonly FlagRow[] = [
175
176
  decode: ([raw]) => {
176
177
  const n = Number(raw)
177
178
  return raw !== undefined && Number.isInteger(n) && n >= 0 && n <= 65535
178
- ? Either.right(n)
179
- : 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 ?? ""}')`)
180
181
  },
181
182
  scopeError: "gtd: --port is only valid for `gtd ui`",
182
183
  valueHint: "<n>",
@@ -189,8 +190,8 @@ const FLAGS: readonly FlagRow[] = [
189
190
  scope: (kind) => kind === "ui",
190
191
  decode: ([raw]) =>
191
192
  raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
192
- ? Either.left("gtd: --host must be a non-empty, single-line value")
193
- : Either.right(raw),
193
+ ? Result.fail("gtd: --host must be a non-empty, single-line value")
194
+ : Result.succeed(raw),
194
195
  scopeError: "gtd: --host is only valid for `gtd ui`",
195
196
  valueHint: "<addr>",
196
197
  help: "Bind this address directly instead of publishing through tailscale serve",
@@ -200,7 +201,7 @@ const FLAGS: readonly FlagRow[] = [
200
201
  arity: 0,
201
202
  repeatable: false,
202
203
  scope: (kind) => kind === "ui",
203
- decode: () => Either.right(true),
204
+ decode: () => Result.succeed(true),
204
205
  scopeError: "gtd: --self-signed is only valid for `gtd ui`",
205
206
  valueHint: "",
206
207
  help: "Bind directly with a throwaway self-signed certificate instead of the configured ui.cert/ui.key",
@@ -210,7 +211,7 @@ const FLAGS: readonly FlagRow[] = [
210
211
  arity: 0,
211
212
  repeatable: false,
212
213
  scope: (kind) => kind === "ui",
213
- decode: () => Either.right(true),
214
+ decode: () => Result.succeed(true),
214
215
  scopeError: "gtd: --dev is only valid for `gtd ui`",
215
216
  valueHint: "",
216
217
  help: "Serve local development sources instead of the packaged build",
@@ -222,8 +223,8 @@ const FLAGS: readonly FlagRow[] = [
222
223
  scope: (kind) => kind === "judgeRun",
223
224
  decode: ([raw]) =>
224
225
  raw === "fixed" || raw === "jev" || raw === "llm"
225
- ? Either.right(raw)
226
- : 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 ?? ""}"`),
227
228
  scopeError: "gtd: --provider is only valid for `gtd judge run`",
228
229
  valueHint: "<name>",
229
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)",
@@ -235,8 +236,8 @@ const FLAGS: readonly FlagRow[] = [
235
236
  scope: (kind) => kind === "judgeRun",
236
237
  decode: ([raw]) =>
237
238
  raw === undefined || raw === ""
238
- ? Either.left("gtd: --answers must be a non-empty path")
239
- : Either.right(raw),
239
+ ? Result.fail("gtd: --answers must be a non-empty path")
240
+ : Result.succeed(raw),
240
241
  scopeError: "gtd: --answers is only valid for `gtd judge run`",
241
242
  valueHint: "<path>",
242
243
  help: "With --provider fixed: a JSON verdict file [{ id, answer, p }] (default: $GTD_JUDGE_ANSWERS, inline JSON)",
@@ -258,8 +259,8 @@ const FLAGS: readonly FlagRow[] = [
258
259
  scope: (kind) => kind === "land" || kind === "judgeRun",
259
260
  decode: ([raw]) =>
260
261
  raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
261
- ? Either.left("gtd: --model must be a non-empty, single-line value")
262
- : Either.right(raw),
262
+ ? Result.fail("gtd: --model must be a non-empty, single-line value")
263
+ : Result.succeed(raw),
263
264
  scopeError:
264
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)",
265
266
  valueHint: "<name>",
@@ -295,7 +296,7 @@ const FLAGS: readonly FlagRow[] = [
295
296
  arity: 0,
296
297
  repeatable: false,
297
298
  scope: (kind) => kind === "historyJudges",
298
- decode: () => Either.right(true),
299
+ decode: () => Result.succeed(true),
299
300
  scopeError: "gtd: --jsonl is only valid for `gtd history judges`",
300
301
  valueHint: "",
301
302
  help: "Print one JSON line per verdict instead of the report",
@@ -306,7 +307,7 @@ const FLAGS: readonly FlagRow[] = [
306
307
  arity: 0,
307
308
  repeatable: false,
308
309
  scope: (kind) => kind === "check",
309
- decode: () => Either.right(true),
310
+ decode: () => Result.succeed(true),
310
311
  scopeError: "gtd: --open-questions is only valid for `gtd check`",
311
312
  valueHint: "",
312
313
  help: "Instead of <mode>'s findings, list the unanswered questions in <file>; exits 1 when any remain",
@@ -316,7 +317,7 @@ const FLAGS: readonly FlagRow[] = [
316
317
  arity: 0,
317
318
  repeatable: false,
318
319
  scope: (kind) => kind === "check",
319
- decode: () => Either.right(true),
320
+ decode: () => Result.succeed(true),
320
321
  scopeError: "gtd: --open-threads is only valid for `gtd check`",
321
322
  valueHint: "",
322
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",
@@ -326,7 +327,7 @@ const FLAGS: readonly FlagRow[] = [
326
327
  arity: 0,
327
328
  repeatable: false,
328
329
  scope: () => true,
329
- decode: () => Either.right(true),
330
+ decode: () => Result.succeed(true),
330
331
  scopeError: "gtd: --verbose is valid for every command — this error is unreachable",
331
332
  valueHint: "",
332
333
  help: "Narrate on stderr what gtd resolved and where its config came from",
@@ -533,6 +534,14 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
533
534
  summary: "Print a briefing for writing a driver",
534
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.`,
535
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
+ },
536
545
  {
537
546
  token: "judge",
538
547
  kind: "judge",
@@ -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,19 +934,19 @@ 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)
932
941
  }
933
942
 
934
943
  /** `gtd history judges`' thresholds: each a known judge threshold with a numeric value. */
935
944
  const judgeThresholds = (
936
945
  thresholds: Readonly<Record<string, string>>,
937
- ): Either.Either<Readonly<Record<string, string>>, string> => {
946
+ ): Result.Result<Readonly<Record<string, string>>, string> => {
938
947
  const unknown = Object.keys(thresholds).find((name) => !Object.hasOwn(THRESHOLDS, name))
939
948
  if (unknown !== undefined) {
940
- return Either.left(
949
+ return Result.fail(
941
950
  `gtd history judges: --threshold ${unknown} is no judge threshold — use ${Object.keys(THRESHOLDS).join(" or ")}`,
942
951
  )
943
952
  }
@@ -945,11 +954,11 @@ const judgeThresholds = (
945
954
  ([, value]) => value.trim() === "" || !Number.isFinite(Number(value)),
946
955
  )
947
956
  if (nan !== undefined) {
948
- return Either.left(
957
+ return Result.fail(
949
958
  `gtd history judges: --threshold ${nan[0]} must be a number — got "${nan[1]}"`,
950
959
  )
951
960
  }
952
- return Either.right(thresholds)
961
+ return Result.succeed(thresholds)
953
962
  }
954
963
 
955
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. */
@@ -1087,8 +1096,8 @@ 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
@@ -1231,8 +1240,8 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1231
1240
 
1232
1241
  if (kind === "historyJudges") {
1233
1242
  const thresholds = judgeThresholds(bag["--threshold"] ?? {})
1234
- if (Either.isLeft(thresholds)) return usagePlan(thresholds.left, jsonSeen)
1235
- const vars = thresholds.right
1243
+ if (Result.isFailure(thresholds)) return usagePlan(thresholds.failure, jsonSeen)
1244
+ const vars = thresholds.success
1236
1245
  return {
1237
1246
  kind: "command",
1238
1247
  command: {
@@ -1295,6 +1304,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1295
1304
  | "next"
1296
1305
  | "validate"
1297
1306
  | "guide"
1307
+ | "bundle"
1298
1308
  | "base"
1299
1309
  | "judge"
1300
1310
  | "judgeAnswer"
@@ -1308,7 +1318,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1308
1318
  // ---------------------------------------------------------------------------
1309
1319
 
1310
1320
  export interface CliIo {
1311
- readonly stdout: (chunk: string) => void
1321
+ readonly stdout: (chunk: string | Uint8Array) => void
1312
1322
  readonly stderr: (chunk: string) => void
1313
1323
  readonly exit: (code: number) => void
1314
1324
  /**
@@ -1333,7 +1343,7 @@ const writeStderr = (chunk: string): void => {
1333
1343
  export type LiveToolCommands = Layer.Layer<
1334
1344
  ToolCommands,
1335
1345
  never,
1336
- Exclude<CommandRequirements, ToolCommands> | NodeContext.NodeContext
1346
+ Exclude<CommandRequirements, ToolCommands> | NodeServices.NodeServices
1337
1347
  >
1338
1348
 
1339
1349
  export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
@@ -1358,20 +1368,21 @@ export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
1358
1368
  // "command"`, so --version/--help/a usage error never provoke it.
1359
1369
  layers: (verbose) =>
1360
1370
  // Dependency order matters: Workspace.Live needs GitService, both need
1361
- // Host, and GitService needs NodeContext's CommandExecutor. Each
1371
+ // Host, and GitService needs NodeServices' ChildProcessSpawner. Each
1362
1372
  // `provideMerge` satisfies the layers above it AND stays in the output.
1363
1373
  tools.pipe(
1364
1374
  Layer.provideMerge(
1365
1375
  Layer.mergeAll(
1366
1376
  ConfigService.Live,
1367
1377
  ConfigDiscovery.Live,
1378
+ EngineCore.Live,
1368
1379
  Workspace.Live,
1369
1380
  Narrator.layer(writeStderr, verbose),
1370
1381
  CommandRunner.Live,
1371
1382
  ).pipe(
1372
1383
  Layer.provideMerge(GitService.Live),
1373
1384
  Layer.provideMerge(Host.Live),
1374
- Layer.provideMerge(NodeContext.layer),
1385
+ Layer.provideMerge(NodeServices.layer),
1375
1386
  ),
1376
1387
  ),
1377
1388
  ),
@@ -1386,14 +1397,20 @@ export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
1386
1397
  export const normalizeTrailingNewline = (artifact: string): string =>
1387
1398
  artifact.length === 0 ? artifact : `${artifact.replace(/\n+$/, "")}\n`
1388
1399
 
1400
+ // Bytes are a binary artifact, written as they are.
1389
1401
  const bufferedArtifactOut = (io: CliIo): ArtifactOut => {
1390
- let buffer = ""
1402
+ let buffer: string | Uint8Array = ""
1391
1403
  return {
1392
1404
  write: (chunk) => {
1393
- buffer += chunk
1405
+ buffer =
1406
+ typeof buffer === "string" && typeof chunk === "string"
1407
+ ? buffer + chunk
1408
+ : Buffer.concat([Buffer.from(buffer), Buffer.from(chunk)])
1394
1409
  },
1395
1410
  flush: () => {
1396
- if (buffer.length > 0) io.stdout(normalizeTrailingNewline(buffer))
1411
+ if (buffer.length > 0) {
1412
+ io.stdout(typeof buffer === "string" ? normalizeTrailingNewline(buffer) : buffer)
1413
+ }
1397
1414
  buffer = ""
1398
1415
  },
1399
1416
  }
@@ -1479,8 +1496,8 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
1479
1496
  // (Ctrl-C on `ui`/`lsp`'s blocking `Effect.never`) is passed
1480
1497
  // through untouched via `failCause` so `NodeRuntime.runMain` still sees a
1481
1498
  // real interruption; everything else gets the envelope.
1482
- Effect.catchAll((cause) =>
1483
- Cause.isInterruptedOnly(cause)
1499
+ Effect.catch((cause) =>
1500
+ Cause.hasInterruptsOnly(cause)
1484
1501
  ? Effect.failCause(cause)
1485
1502
  : report(io, plan.json.kind !== "off")(cause),
1486
1503
  ),
@@ -5,7 +5,7 @@ import { writeScript, type JsonMode } from "./Output.js"
5
5
 
6
6
  const run = (json: JsonMode, script: string, plain?: string) => {
7
7
  let written = ""
8
- const out = { write: (chunk: string) => (written += chunk), flush: () => {} }
8
+ const out = { write: (chunk: string | Uint8Array) => (written += String(chunk)), flush: () => {} }
9
9
  const exit = Effect.runSyncExit(writeScript(json, out, script, plain))
10
10
  return { exit, written }
11
11
  }
package/src/cli/Output.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  type EXIT_OK,
8
8
  type EXIT_STRAYS_FOUND,
9
9
  } from "../wire/index.js"
10
- import { ConfigDiscovery, ConfigService } from "../workflow/index.js"
10
+ import { ConfigDiscovery, ConfigService, EngineCore } from "../workflow/index.js"
11
11
  import type { ToolCommands } from "./ToolCommands.js"
12
12
 
13
13
  /**
@@ -59,7 +59,7 @@ export type JsonMode =
59
59
  * reach stdout.
60
60
  */
61
61
  export interface ArtifactOut {
62
- readonly write: (chunk: string) => void
62
+ readonly write: (chunk: string | Uint8Array) => void
63
63
  readonly flush: () => void
64
64
  }
65
65
 
@@ -67,6 +67,7 @@ export type CommandRequirements =
67
67
  | GitService
68
68
  | ConfigService
69
69
  | ConfigDiscovery
70
+ | EngineCore
70
71
  | Workspace
71
72
  | Host
72
73
  | Narrator
@@ -1712,6 +1712,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1712
1712
  check: { kind: "check", mode: "qa", file: ".gtd/TODO.md" },
1713
1713
  uncheck: { kind: "uncheck", file: ".gtd/REVIEW.md" },
1714
1714
  guide: { kind: "guide" },
1715
+ bundle: { kind: "bundle" },
1715
1716
  summary: { kind: "summary" },
1716
1717
  base: { kind: "base" },
1717
1718
  judge: { kind: "judge" },
@@ -1732,11 +1733,12 @@ describe("runCommand — refuses in a repository with no commits", () => {
1732
1733
  const NO_COMMITS_MESSAGE =
1733
1734
  "gtd: the repository has no commits yet — make an initial commit, then run gtd again"
1734
1735
 
1735
- it("derives exactly the sixteen non-standalone kinds — a canary for the table-driven cases below", () => {
1736
+ it("derives exactly the seventeen non-standalone kinds — a canary for the table-driven cases below", () => {
1736
1737
  expect(stateKinds.sort()).toEqual(
1737
1738
  [
1738
1739
  "abandon",
1739
1740
  "base",
1741
+ "bundle",
1740
1742
  "start",
1741
1743
  "land",
1742
1744
  "next",
@@ -1761,7 +1763,10 @@ describe("runCommand — refuses in a repository with no commits", () => {
1761
1763
  const repo = new InMemRepo()
1762
1764
  const before = repo.commitHistory().length
1763
1765
  const written: string[] = []
1764
- const out = { write: (chunk: string) => written.push(chunk), flush: () => {} }
1766
+ const out = {
1767
+ write: (chunk: string | Uint8Array) => written.push(String(chunk)),
1768
+ flush: () => {},
1769
+ }
1765
1770
 
1766
1771
  const exit = await Effect.runPromiseExit(
1767
1772
  runCommand(commandFor[kind], { kind: "off" }, out).pipe(Effect.provide(testLayers(repo))),
@@ -1780,7 +1785,10 @@ describe("runCommand — refuses in a repository with no commits", () => {
1780
1785
  it("a repository with a commit passes the guard and dispatches normally", async () => {
1781
1786
  const repo = seedIdle()
1782
1787
  const written: string[] = []
1783
- const out = { write: (chunk: string) => written.push(chunk), flush: () => {} }
1788
+ const out = {
1789
+ write: (chunk: string | Uint8Array) => written.push(String(chunk)),
1790
+ flush: () => {},
1791
+ }
1784
1792
 
1785
1793
  const exit = await Effect.runPromiseExit(
1786
1794
  runCommand({ kind: "next" }, { kind: "off" }, out).pipe(Effect.provide(testLayers(repo))),
@@ -2090,7 +2098,7 @@ describe("gtd next/land --json=<path> — the select branch", () => {
2090
2098
  )
2091
2099
  expect(Exit.isFailure(exit)).toBe(true)
2092
2100
  if (Exit.isFailure(exit)) {
2093
- const failure = Cause.failureOption(exit.cause)
2101
+ const failure = Cause.findErrorOption(exit.cause)
2094
2102
  expect(failure._tag).toBe("Some")
2095
2103
  if (failure._tag === "Some") {
2096
2104
  expect(failure.value).toBeInstanceOf(SelectorUsageError)
@@ -2297,8 +2305,8 @@ export default workflow(
2297
2305
  describe("runCommand — the repository-root guard's messages", () => {
2298
2306
  const guarded = async (
2299
2307
  override: (
2300
- git: Context.Tag.Service<GitService>,
2301
- host: Context.Tag.Service<Host>,
2308
+ git: Context.Service.Shape<typeof GitService>,
2309
+ host: Context.Service.Shape<typeof Host>,
2302
2310
  ) => Effect.Effect<unknown, Error, CommandRequirements>,
2303
2311
  ): Promise<string> => {
2304
2312
  const exit = await Effect.runPromiseExit(