@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,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,
@@ -88,7 +91,7 @@ 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 {
94
97
  normalizeScriptNewline,
@@ -119,7 +122,7 @@ import { runHistoryMetricsCommand } from "./Metrics.js"
119
122
  * SAME type structurally off `currentRest`'s own success type rather than
120
123
  * importing the name.
121
124
  */
122
- type Rest = Effect.Effect.Success<typeof currentRest>
125
+ type Rest = Effect.Success<typeof currentRest>
123
126
 
124
127
  export type Command =
125
128
  | { readonly kind: "lsp" }
@@ -161,6 +164,7 @@ export type Command =
161
164
  }
162
165
  | { readonly kind: "uncheck"; readonly file: string }
163
166
  | { readonly kind: "guide" }
167
+ | { readonly kind: "bundle" }
164
168
  | { readonly kind: "summary" }
165
169
  | { readonly kind: "base" }
166
170
  | { readonly kind: "judge" }
@@ -219,15 +223,34 @@ const runGuideCommand = (
219
223
  const briefing = renderBriefing()
220
224
  out.write(briefing.endsWith("\n") ? briefing : briefing + "\n")
221
225
  const host = yield* Host
222
- const topLevel = yield* Effect.either((yield* GitService).topLevel())
223
- if (topLevel._tag === "Left") return
224
- 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
225
229
  if ((yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)) !== undefined) return
226
230
  yield* (yield* Narrator).warn(
227
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)`,
228
232
  )
229
233
  })
230
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
+
231
254
  /**
232
255
  * `planLanding`'s result — a preview of what `gtd land` would do, plus the
233
256
  * combined `script` the driver runs to do it. `script` is
@@ -457,7 +480,7 @@ const runJudgeRunCommand = (
457
480
  })
458
481
 
459
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. */
460
- const VerdictAnswer = Schema.Union(Schema.String, Schema.Number, Schema.Boolean)
483
+ const VerdictAnswer = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
461
484
 
462
485
  /**
463
486
  * One `gtd judge answer` verdict entry per pending question, `{ id, answer,
@@ -470,14 +493,14 @@ const VerdictAnswer = Schema.Union(Schema.String, Schema.Number, Schema.Boolean)
470
493
  const verdictSchemaFor = (ids: readonly string[]) =>
471
494
  Schema.Array(
472
495
  Schema.Struct({
473
- id: ids.length > 0 ? Schema.Literal(...(ids as [string, ...string[]])) : Schema.Never,
496
+ id: ids.length > 0 ? Schema.Literals(ids) : Schema.Never,
474
497
  answer: VerdictAnswer,
475
498
  // Bounded [0, 1] — every OTHER bound in the routes: matching path fails
476
499
  // CLOSED (a non-finite rendered minP/maxP never matches); an
477
500
  // unvalidated p wouldn't: a driver bug or a miscalibrated model
478
501
  // sending p > 1 would clear every minP a workflow declares,
479
502
  // unconditionally forcing the judged route on a live gate.
480
- p: Schema.Number.pipe(Schema.between(0, 1)),
503
+ p: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 })),
481
504
  }),
482
505
  )
483
506
 
@@ -509,7 +532,7 @@ const runJudgeAnswerCommand = (
509
532
  try: () => JSON.parse(raw) as unknown,
510
533
  catch: () => new SelectorUsageError("gtd judge answer: stdin is not valid JSON"),
511
534
  })
512
- const verdict = yield* Schema.decodeUnknown(verdictSchemaFor(ids))(parsed).pipe(
535
+ const verdict = yield* Schema.decodeUnknownEffect(verdictSchemaFor(ids))(parsed).pipe(
513
536
  Effect.mapError(
514
537
  (e) =>
515
538
  new SelectorUsageError(
@@ -1137,7 +1160,7 @@ const gatherBeatDocument = (
1137
1160
  kind === "prompt" ? resolveSession(rendered.memory, rendered.memoryResumed) : undefined
1138
1161
  const resolvedValidate =
1139
1162
  kind === "prompt"
1140
- ? yield* resolveValidateScript(rest).pipe(Effect.catchAll(() => Effect.succeed(undefined)))
1163
+ ? yield* resolveValidateScript(rest).pipe(Effect.catch(() => Effect.succeed(undefined)))
1141
1164
  : undefined
1142
1165
  const validate =
1143
1166
  resolvedValidate !== undefined && resolvedValidate.script.length > 0
@@ -1332,6 +1355,8 @@ const dispatchVoidCommand = (
1332
1355
  return runUncheckCommand(command.file)
1333
1356
  case "guide":
1334
1357
  return runGuideCommand(out)
1358
+ case "bundle":
1359
+ return runBundleCommand(out)
1335
1360
  case "summary":
1336
1361
  return runSummaryCommand(out)
1337
1362
  case "base":
@@ -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") {}
@@ -469,12 +469,14 @@ describe("history reads stay bounded by the episode, not the repository", () =>
469
469
  const raceyGit: GitOperations = {
470
470
  ...base,
471
471
  subjectHistory: (pageSize, skip, head) =>
472
- Effect.tap(base.subjectHistory(pageSize, skip, head), () => {
473
- if (!moved) {
474
- moved = true
475
- repo.hardResetTo(boundary)
476
- }
477
- }),
472
+ Effect.tap(base.subjectHistory(pageSize, skip, head), () =>
473
+ Effect.sync(() => {
474
+ if (!moved) {
475
+ moved = true
476
+ repo.hardResetTo(boundary)
477
+ }
478
+ }),
479
+ ),
478
480
  }
479
481
 
480
482
  const run = await provideWithGit(currentRun, raceyGit, repo)
package/src/edge/Edge.ts CHANGED
@@ -1319,7 +1319,7 @@ const decideLanding = (
1319
1319
  export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
1320
1320
  decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
1321
1321
  Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
1322
- Effect.catchAll(() => Effect.succeed(undefined)),
1322
+ Effect.catch(() => Effect.succeed(undefined)),
1323
1323
  )
1324
1324
 
1325
1325
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
@@ -1432,8 +1432,8 @@ const configRefusal = (
1432
1432
  )
1433
1433
 
1434
1434
  const differingConfigPaths = (
1435
- git: GitService["Type"],
1436
- workspace: Workspace["Type"],
1435
+ git: GitService["Service"],
1436
+ workspace: Workspace["Service"],
1437
1437
  root: string,
1438
1438
  config: { readonly fingerprint: ConfigFingerprint },
1439
1439
  run: ProcessRun,
@@ -1,7 +1,7 @@
1
1
  // The authoring surface of a gtd workflow. Every function here is a thin
2
2
  // facade over the replay context the engine installs on `globalThis` — keyed by
3
3
  // a registered symbol, not a module-local variable, so a copy of this file
4
- // loaded through a user's `gtd.config.ts` (jiti) and the copy bundled into the
4
+ // a user's `gtd.config.ts` brings along and the copy bundled into the
5
5
  // gtd binary drive the same replay.
6
6
  import type { WorkflowBuild } from "./factory.js"
7
7
 
@@ -22,6 +22,12 @@ const findPackageJson = (fromUrl: string): string => {
22
22
  throw new Error(`package.json not found by walking up from ${fromUrl}`)
23
23
  }
24
24
 
25
- export const GTD_VERSION: string = (
26
- _require(findPackageJson(import.meta.url)) as { version: string }
27
- ).version
25
+ /** The global a pin's workflow registers itself under, ahead of the engine in the same file. */
26
+ export const PIN_KEY = Symbol.for("@pmelab/gtd/pin")
27
+
28
+ // Inside a pin, `import.meta.url` is the pin's own file, in the user's
29
+ // repository: the pin registered the version it was built by before this runs.
30
+ const pinned = (globalThis as { [PIN_KEY]?: { readonly version: string } })[PIN_KEY]?.version
31
+
32
+ export const GTD_VERSION: string =
33
+ pinned ?? (_require(findPackageJson(import.meta.url)) as { version: string }).version
@@ -1,2 +1,2 @@
1
1
  export { renderBriefing, WORKFLOWS_REPO } from "./Install.js"
2
- export { GTD_VERSION } from "./Version.js"
2
+ export { GTD_VERSION, PIN_KEY } from "./Version.js"
@@ -1,5 +1,5 @@
1
- import { Command, CommandExecutor } from "@effect/platform"
2
1
  import { Context, Effect, Layer, Stream } from "effect"
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/process"
3
3
  import { Host } from "./Host.js"
4
4
 
5
5
  /** One command run's outcome: its exit status (a signal death reported as `null`, matching `spawnSync`) and its combined output (stdout then stderr). */
@@ -23,13 +23,13 @@ interface CommandOutcome {
23
23
  * stdout/stderr, none of which this port's single-shot "run to completion"
24
24
  * shape supports.
25
25
  */
26
- export class CommandRunner extends Context.Tag("CommandRunner")<
26
+ export class CommandRunner extends Context.Service<
27
27
  CommandRunner,
28
28
  {
29
29
  /** A SPAWN failure (no `bash`, unreadable cwd) fails the Effect; a non-zero EXIT is a value — how a mode command reports "invalid"/"broken" for the caller to interpret. */
30
30
  readonly bash: (command: string) => Effect.Effect<CommandOutcome, Error>
31
31
  }
32
- >() {
32
+ >()("CommandRunner") {
33
33
  /** A test layer over a canned `bash` implementation — no subprocess. */
34
34
  static readonly layer = (
35
35
  bash: (command: string) => Effect.Effect<CommandOutcome, Error>,
@@ -39,17 +39,19 @@ export class CommandRunner extends Context.Tag("CommandRunner")<
39
39
  CommandRunner,
40
40
  Effect.gen(function* () {
41
41
  const { root } = yield* Host
42
- const executor = yield* CommandExecutor.CommandExecutor
42
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
43
43
  return {
44
44
  bash: (command: string) =>
45
45
  Effect.scoped(
46
46
  Effect.gen(function* () {
47
- const process = yield* executor.start(
48
- Command.make("bash", "-c", command).pipe(
49
- Command.workingDirectory(root),
50
- Command.stdout("pipe"),
51
- Command.stderr("pipe"),
52
- ),
47
+ const process = yield* spawner.spawn(
48
+ ChildProcess.make("bash", ["-c", command], {
49
+ cwd: root,
50
+ stdout: "pipe",
51
+ stderr: "pipe",
52
+ // Not detached, like `Git.ts`'s `run`: a background job the command starts survives it.
53
+ detached: false,
54
+ }),
53
55
  )
54
56
  const collect = (stream: typeof process.stdout) =>
55
57
  stream.pipe(Stream.decodeText(), Stream.mkString)
@@ -60,7 +62,7 @@ export class CommandRunner extends Context.Tag("CommandRunner")<
60
62
  // A signal death fails process.exitCode; map to null to match spawnSync's contract.
61
63
  process.exitCode.pipe(
62
64
  Effect.map((code): number | null => code),
63
- Effect.catchAll(() => Effect.succeed(null)),
65
+ Effect.catch(() => Effect.succeed(null)),
64
66
  ),
65
67
  ],
66
68
  { concurrency: "unbounded" },
@@ -5,13 +5,13 @@ import { Context, Effect, Layer } from "effect"
5
5
  * layer was built, never by the call site; `warn` is its ungated
6
6
  * counterpart, always written regardless of verbosity.
7
7
  */
8
- export class Narrator extends Context.Tag("Narrator")<
8
+ export class Narrator extends Context.Service<
9
9
  Narrator,
10
10
  {
11
11
  readonly narrate: (line: string) => Effect.Effect<void>
12
12
  readonly warn: (line: string) => Effect.Effect<void>
13
13
  }
14
- >() {
14
+ >()("Narrator") {
15
15
  /**
16
16
  * `write` is the raw stderr sink (`CliIo.stderr` in production, so the
17
17
  * in-memory `CliIo` used by `@inmem` e2e scenarios captures narration into
@@ -2,11 +2,10 @@ import { execFileSync } from "node:child_process"
2
2
  import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
3
3
  import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
- import { NodeContext } from "@effect/platform-node"
6
5
  import { Cause, Effect, Exit, Option } from "effect"
7
6
  import { describe, expect, it } from "vitest"
8
7
  import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
9
- import { Host } from "./index.js"
8
+ import { Host, NodeServices } from "./index.js"
10
9
  import { gitTiers, runGitServiceContract, runGitScriptContract } from "../twins/index.js"
11
10
 
12
11
  /**
@@ -78,7 +77,7 @@ describe("diffPaths [Live]", () => {
78
77
  Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
79
78
  Effect.provide(GitService.Live),
80
79
  Effect.provide(Host.layer({ root: join(root, "sub"), home: root, env: {} })),
81
- Effect.provide(NodeContext.layer),
80
+ Effect.provide(NodeServices.layer),
82
81
  ),
83
82
  )
84
83
  expect([...paths].sort()).toEqual(["sub/a.txt", "top.txt"])
@@ -131,7 +130,7 @@ describe("diffPaths [Live]", () => {
131
130
  Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
132
131
  Effect.provide(GitService.Live),
133
132
  Effect.provide(Host.layer({ root: repo, home: root, env: {} })),
134
- Effect.provide(NodeContext.layer),
133
+ Effect.provide(NodeServices.layer),
135
134
  ),
136
135
  )
137
136
  expect([...paths]).toEqual(["sub"])
@@ -155,7 +154,7 @@ describe("authorIdent [Live]", () => {
155
154
  Effect.flatMap(GitService, (g) => g.authorIdent()).pipe(
156
155
  Effect.provide(GitService.Live),
157
156
  Effect.provide(Host.layer({ root, home: root, env: {} })),
158
- Effect.provide(NodeContext.layer),
157
+ Effect.provide(NodeServices.layer),
159
158
  ),
160
159
  )
161
160
  expect(Option.isNone(ident)).toBe(true)
@@ -1,5 +1,5 @@
1
- import { Command, CommandExecutor } from "@effect/platform"
2
1
  import { Context, Effect, Layer, Option, Stream } from "effect"
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/process"
3
3
  import { Host } from "./Host.js"
4
4
 
5
5
  export interface GitReaderOperations {
@@ -80,7 +80,7 @@ export interface GitReaderOperations {
80
80
  skip: number,
81
81
  head?: string,
82
82
  ) => Effect.Effect<ReadonlyArray<{ readonly hash: string; readonly subject: string }>, Error>
83
- /** Fails when the path doesn't exist at `ref` — a caller expecting that (e.g. the review sign-off gate) handles it with an explicit `catchAll`. */
83
+ /** Fails when the path doesn't exist at `ref` — a caller expecting that (e.g. the review sign-off gate) handles it with an explicit `catch`. */
84
84
  readonly readFileAtRef: (ref: string, path: string) => Effect.Effect<string, Error>
85
85
  /**
86
86
  * Pending working-tree changes vs `base` (default `HEAD`), as
@@ -216,7 +216,7 @@ const blobsAtRef = (
216
216
  paths: ReadonlyArray<string>,
217
217
  ): Effect.Effect<Map<string, string>, Error> =>
218
218
  exec("git", "ls-tree", "-r", "-z", ref, "--", ...paths).pipe(
219
- Effect.catchAll(() => Effect.succeed("")),
219
+ Effect.catch(() => Effect.succeed("")),
220
220
  Effect.map((out) => {
221
221
  const blobs = new Map<string, string>()
222
222
  for (const entry of splitNul(out)) {
@@ -256,7 +256,7 @@ const hashObjects = (
256
256
  .map((oid, i) => [paths[i] ?? "", oid.trim()] as const),
257
257
  ),
258
258
  ),
259
- Effect.catchAll(() => Effect.succeed(new Map<string, string>())),
259
+ Effect.catch(() => Effect.succeed(new Map<string, string>())),
260
260
  )
261
261
 
262
262
  /**
@@ -287,25 +287,28 @@ const classifyUntracked = (
287
287
 
288
288
  /**
289
289
  * Run a command and return its stdout — FAILING on a non-zero exit code with
290
- * the command line and stderr in the error message. `Command.string` alone
291
- * silently ignores exit codes, which used to make gtd report success on
292
- * rejected commits and lose files whose quoted paths broke a swallowed
293
- * `git add`. Callers that expect a probe to fail handle it with an explicit
294
- * `catchAll`.
290
+ * the command line and stderr in the error message. Ignoring exit codes
291
+ * used to make gtd report success on rejected commits and lose files whose
292
+ * quoted paths broke a swallowed `git add`. Callers that expect a probe to
293
+ * fail handle it with an explicit `catch`.
295
294
  */
296
295
  const run = (
297
296
  root: string,
298
297
  ...args: [string, ...Array<string>]
299
- ): Effect.Effect<string, Error, CommandExecutor.CommandExecutor> =>
298
+ ): Effect.Effect<string, Error, ChildProcessSpawner.ChildProcessSpawner> =>
300
299
  Effect.scoped(
301
300
  Effect.gen(function* () {
302
- const executor = yield* CommandExecutor.CommandExecutor
303
- const process = yield* executor.start(
304
- Command.make(...args).pipe(
305
- Command.workingDirectory(root),
306
- Command.stdout("pipe"),
307
- Command.stderr("pipe"),
308
- ),
301
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
302
+ const [command, ...rest] = args
303
+ const process = yield* spawner.spawn(
304
+ // Not detached: the spawner SIGTERMs a detached child's whole group once it
305
+ // exits, which would kill a git hook's background job.
306
+ ChildProcess.make(command, rest, {
307
+ cwd: root,
308
+ stdout: "pipe",
309
+ stderr: "pipe",
310
+ detached: false,
311
+ }),
309
312
  )
310
313
  const collect = (stream: typeof process.stdout) =>
311
314
  stream.pipe(Stream.decodeText(), Stream.mkString)
@@ -353,11 +356,12 @@ export const resolvedRefOrCorrupted = (
353
356
  ? Effect.succeed(hash)
354
357
  : Effect.fail(new InvalidRefError(`Invalid ref: ${ref}`, [`ref: ${ref}`]))
355
358
 
356
- const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): GitOperations => {
359
+ const makeGitImpl = (
360
+ spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
361
+ root: string,
362
+ ): GitOperations => {
357
363
  const exec = (...args: [string, ...Array<string>]) =>
358
- run(root, ...args).pipe(
359
- Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, executor)),
360
- )
364
+ run(root, ...args).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner))
361
365
 
362
366
  return {
363
367
  lastCommitSubject: (ref = "HEAD") =>
@@ -374,7 +378,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
374
378
  exec("git", "symbolic-ref", "--quiet", "--short", "HEAD").pipe(
375
379
  Effect.map((s) => s.trim()),
376
380
  Effect.map((name) => Option.some(name)),
377
- Effect.catchAll(() => Effect.succeed(Option.none<string>())),
381
+ Effect.catch(() => Effect.succeed(Option.none<string>())),
378
382
  ),
379
383
 
380
384
  listRefs: (prefix: string) =>
@@ -399,7 +403,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
399
403
  hasCommits: () =>
400
404
  exec("git", "rev-parse", "--verify", "HEAD").pipe(
401
405
  Effect.map(() => true),
402
- Effect.catchAll(() => Effect.succeed(false)),
406
+ Effect.catch(() => Effect.succeed(false)),
403
407
  ),
404
408
 
405
409
  readFileAtRef: (ref: string, path: string) => exec("git", "show", `${ref}:${path}`),
@@ -427,7 +431,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
427
431
  ? Option.none()
428
432
  : Option.some({ name: match[1]!, email: match[2]! })
429
433
  }),
430
- Effect.catchAll(() => Effect.succeed(Option.none())),
434
+ Effect.catch(() => Effect.succeed(Option.none())),
431
435
  ),
432
436
 
433
437
  changedPaths: (base?: string) =>
@@ -436,7 +440,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
436
440
  // Tolerates the empty-repo case (no HEAD), where the diff has no ref
437
441
  // to compare against and reports no tracked changes.
438
442
  const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(
439
- Effect.catchAll(() => Effect.succeed("")),
443
+ Effect.catch(() => Effect.succeed("")),
440
444
  )
441
445
  const trackedPaths = parseNameStatus(splitNul(nameStatusOut))
442
446
 
@@ -469,7 +473,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
469
473
 
470
474
  stagedPaths: () =>
471
475
  exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(
472
- Effect.catchAll(() => Effect.succeed("")),
476
+ Effect.catch(() => Effect.succeed("")),
473
477
  Effect.map((out) => parseNameStatus(splitNul(out))),
474
478
  ),
475
479
 
@@ -482,26 +486,26 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
482
486
  readRefOption: (ref: string) =>
483
487
  exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(
484
488
  Effect.map((s) => Option.some(s.trim())),
485
- Effect.catchAll(() => Effect.succeed(Option.none<string>())),
489
+ Effect.catch(() => Effect.succeed(Option.none<string>())),
486
490
  ),
487
491
 
488
492
  isAncestor: (a: string, b: string) =>
489
493
  run(root, "git", "merge-base", "--is-ancestor", a, b).pipe(
490
- Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, executor)),
494
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
491
495
  Effect.map(() => true),
492
- Effect.catchAll(() => Effect.succeed(false)),
496
+ Effect.catch(() => Effect.succeed(false)),
493
497
  ),
494
498
 
495
499
  mergeBase: (a: string, b: string) =>
496
500
  exec("git", "merge-base", a, b).pipe(
497
501
  Effect.map((s) => Option.some(s.trim())),
498
- Effect.catchAll(() => Effect.succeed(Option.none<string>())),
502
+ Effect.catch(() => Effect.succeed(Option.none<string>())),
499
503
  ),
500
504
 
501
505
  defaultBranch: () =>
502
506
  exec("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").pipe(
503
507
  Effect.map((s) => s.trim()),
504
- Effect.catchAll(() => Effect.succeed("")),
508
+ Effect.catch(() => Effect.succeed("")),
505
509
  Effect.map((name) => (name === "" ? "main" : name)),
506
510
  ),
507
511
 
@@ -546,7 +550,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
546
550
  }),
547
551
  ),
548
552
  // Empty repo (no HEAD) makes `git log` fail; treat as no commits.
549
- Effect.catchAll(() =>
553
+ Effect.catch(() =>
550
554
  Effect.succeed(
551
555
  [] as ReadonlyArray<{
552
556
  readonly hash: string
@@ -582,7 +586,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
582
586
  ),
583
587
  // Empty repo (no HEAD), or an unresolvable `head`, makes `git log`
584
588
  // fail; treat as no commits — the same fold `commitHistory` applies.
585
- Effect.catchAll(() =>
589
+ Effect.catch(() =>
586
590
  Effect.succeed([] as ReadonlyArray<{ readonly hash: string; readonly subject: string }>),
587
591
  ),
588
592
  )
@@ -592,7 +596,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
592
596
  Effect.gen(function* () {
593
597
  yield* exec("git", "add", "-A")
594
598
  yield* exec("git", "commit", "--allow-empty", "-m", prefix).pipe(
595
- Effect.catchAll((error) =>
599
+ Effect.catch((error) =>
596
600
  // Hooks like lint-staged block empty commits even with --allow-empty.
597
601
  // gtd's workflow commits have nothing for code-quality hooks to validate,
598
602
  // so retry without the pre-commit hook when that guard fires.
@@ -608,7 +612,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
608
612
  commitAsIs: (message: string) =>
609
613
  exec("git", "commit", "--allow-empty", "-m", message)
610
614
  .pipe(
611
- Effect.catchAll((error) =>
615
+ Effect.catch((error) =>
612
616
  error.message.includes("empty git commit")
613
617
  ? exec("git", "commit", "--allow-empty", "--no-verify", "-m", message)
614
618
  : Effect.fail(error),
@@ -634,11 +638,11 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
634
638
  }
635
639
 
636
640
  const makeLiveEffect = Effect.gen(function* () {
637
- const executor = yield* CommandExecutor.CommandExecutor
641
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
638
642
  const { root } = yield* Host
639
- return makeGitImpl(executor, root)
643
+ return makeGitImpl(spawner, root)
640
644
  })
641
645
 
642
- export class GitService extends Context.Tag("GitService")<GitService, GitOperations>() {
646
+ export class GitService extends Context.Service<GitService, GitOperations>()("GitService") {
643
647
  static Live = Layer.effect(GitService, makeLiveEffect)
644
648
  }
@@ -36,7 +36,7 @@ const makeHostOps = (opts: HostOpsInput): HostOps => ({
36
36
  ((path: string) => Effect.try({ try: () => realpathSync(path), catch: toError })),
37
37
  })
38
38
 
39
- export class Host extends Context.Tag("Host")<Host, HostOps>() {
39
+ export class Host extends Context.Service<Host, HostOps>()("Host") {
40
40
  static layer = (opts: HostOpsInput): Layer.Layer<Host> => Layer.succeed(Host, makeHostOps(opts))
41
41
  static Live = Host.layer({ root: process.cwd(), home: homedir(), env: process.env })
42
42
  }
@@ -0,0 +1,11 @@
1
+ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node-shared"
2
+ import { Layer } from "effect"
3
+
4
+ // Not `@effect/platform-node`'s own: that package requires a redis peer, which
5
+ // every install of gtd would then pull in.
6
+ export const layer = Layer.provideMerge(
7
+ NodeChildProcessSpawner.layer,
8
+ Layer.mergeAll(NodeFileSystem.layer, NodePath.layer),
9
+ )
10
+
11
+ export type NodeServices = Layer.Success<typeof layer>
@@ -13,8 +13,7 @@ import { tmpdir } from "node:os"
13
13
  import { join } from "node:path"
14
14
  import { afterEach, describe, expect, it, vi } from "vitest"
15
15
  import { Effect, Layer } from "effect"
16
- import { NodeContext } from "@effect/platform-node"
17
- import { GitService, Host, Workspace, type WorkspaceOps } from "./index.js"
16
+ import { GitService, Host, Workspace, type WorkspaceOps, NodeServices } from "./index.js"
18
17
  import { InMemRepo, makeInMemoryWorkspaceOps } from "../twins/index.js"
19
18
 
20
19
  // `execFileSync` is a named export of the built-in `node:child_process` ESM
@@ -54,7 +53,7 @@ const makeLiveTier = (): WorkspaceTier => {
54
53
  gitExecIn(root, "config", "commit.gpgsign", "false")
55
54
 
56
55
  const hostLayer = Host.layer({ root, home: root, env: {} })
57
- const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeContext.layer)))
56
+ const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
58
57
  const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
59
58
 
60
59
  return {
@@ -275,7 +274,7 @@ describe("Workspace [Live] worktreeSync", () => {
275
274
  Host.layer({ root, home: root, env: {} }),
276
275
  GitService.Live.pipe(
277
276
  Layer.provide(
278
- Layer.merge(Host.layer({ root, home: root, env: {} }), NodeContext.layer),
277
+ Layer.merge(Host.layer({ root, home: root, env: {} }), NodeServices.layer),
279
278
  ),
280
279
  ),
281
280
  ),
@@ -361,7 +360,7 @@ const makeLiveEpisodeTier = () => {
361
360
  git("config", "commit.gpgsign", "false")
362
361
 
363
362
  const hostLayer = Host.layer({ root, home: root, env: {} })
364
- const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeContext.layer)))
363
+ const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
365
364
  const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
366
365
  const ops = (): Promise<WorkspaceOps> =>
367
366
  Effect.runPromise(
@@ -600,7 +600,7 @@ const makeWorkspaceOps = (
600
600
  committed: (path, ref = "HEAD") =>
601
601
  Effect.try({ try: () => assertRepoRelative(path), catch: toError }).pipe(
602
602
  Effect.flatMap((relPath) =>
603
- git.readFileAtRef(ref, relPath).pipe(Effect.catchAll(() => Effect.succeed(undefined))),
603
+ git.readFileAtRef(ref, relPath).pipe(Effect.catch(() => Effect.succeed(undefined))),
604
604
  ),
605
605
  ),
606
606
  readCommittedSync,
@@ -610,7 +610,7 @@ const makeWorkspaceOps = (
610
610
  }
611
611
  }
612
612
 
613
- export class Workspace extends Context.Tag("Workspace")<Workspace, WorkspaceOps>() {
613
+ export class Workspace extends Context.Service<Workspace, WorkspaceOps>()("Workspace") {
614
614
  static Live = Layer.effect(
615
615
  Workspace,
616
616
  Effect.gen(function* () {
@@ -624,7 +624,7 @@ export class Workspace extends Context.Tag("Workspace")<Workspace, WorkspaceOps>
624
624
  // like a full-disk failure does.
625
625
  const commonDir = yield* git
626
626
  .gitCommonDir()
627
- .pipe(Effect.catchAll(() => Effect.succeed(undefined)))
627
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
628
628
  return makeWorkspaceOps(root, git, commonDir)
629
629
  }),
630
630
  )