@pmelab/gtd 60.0.0 → 61.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -2
  3. package/dist/chunks/testkit-C9sq0x07.mjs +30758 -0
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +12238 -20706
  6. package/dist/gtd.core.mjs +593 -0
  7. package/dist/testkit/gherkin.mjs +2 -2
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +2 -2
  10. package/package.json +7 -7
  11. package/src/cli/Cli.test.ts +2 -2
  12. package/src/cli/Cli.ts +68 -51
  13. package/src/cli/History.test.ts +10 -1
  14. package/src/cli/Metrics.test.ts +10 -1
  15. package/src/cli/Output.test.ts +1 -1
  16. package/src/cli/Output.ts +3 -2
  17. package/src/cli/Program.test.ts +44 -25
  18. package/src/cli/Program.ts +39 -11
  19. package/src/cli/Strays.ts +3 -2
  20. package/src/cli/ToolCommands.ts +2 -2
  21. package/src/edge/Edge.test.ts +86 -31
  22. package/src/edge/Edge.ts +164 -31
  23. package/src/emit/GitScript.ts +21 -3
  24. package/src/emit/index.ts +3 -0
  25. package/src/flows/runtime.ts +1 -1
  26. package/src/gherkin/messages.test.ts +1 -0
  27. package/src/gherkin/module-config.feature +1 -0
  28. package/src/gherkin/more-steps.feature +1 -0
  29. package/src/gherkin/testkit.feature +2 -1
  30. package/src/install/Install.ts +9 -2
  31. package/src/install/Version.ts +9 -3
  32. package/src/install/index.ts +1 -1
  33. package/src/platform/CommandRunner.ts +13 -11
  34. package/src/platform/Commentary.ts +2 -2
  35. package/src/platform/Git.test.ts +4 -5
  36. package/src/platform/Git.ts +42 -38
  37. package/src/platform/Host.ts +1 -1
  38. package/src/platform/NodeServices.ts +11 -0
  39. package/src/platform/Workspace.test.ts +4 -5
  40. package/src/platform/Workspace.ts +3 -3
  41. package/src/platform/WorktreeState.test.ts +2 -3
  42. package/src/platform/WorktreeState.ts +1 -1
  43. package/src/platform/index.ts +2 -1
  44. package/src/replay/Format.test.ts +35 -8
  45. package/src/replay/Format.ts +7 -4
  46. package/src/replay/Replay.test.ts +54 -0
  47. package/src/replay/Replay.ts +61 -17
  48. package/src/replay/Trailers.test.ts +12 -12
  49. package/src/replay/Trailers.ts +35 -20
  50. package/src/replay/Tree.ts +11 -0
  51. package/src/replay/index.ts +3 -0
  52. package/src/step/LandStep.ts +1 -0
  53. package/src/step/RepoSnapshot.ts +2 -0
  54. package/src/step/planStart.test.ts +2 -2
  55. package/src/step/planStart.ts +3 -3
  56. package/src/step/planStep.test.ts +4 -4
  57. package/src/step/planStep.ts +10 -8
  58. package/src/testkit/TestRepo.test.ts +4 -1
  59. package/src/testkit/TestRepo.ts +3 -1
  60. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  61. package/src/twins/EmittedScripts.test.ts +1 -1
  62. package/src/twins/GitTiers.ts +29 -7
  63. package/src/twins/InMemRepo.ts +14 -0
  64. package/src/twins/Layers.test.ts +1 -1
  65. package/src/twins/Layers.ts +10 -3
  66. package/src/twins/PinBeats.ts +33 -0
  67. package/src/twins/cliIo.ts +9 -4
  68. package/src/twins/index.ts +1 -0
  69. package/src/wire/BeatDocument.ts +26 -5
  70. package/src/wire/Demand.ts +16 -6
  71. package/src/wire/constants.ts +3 -0
  72. package/src/wire/types.ts +4 -0
  73. package/src/workflow/discovery.test.ts +24 -7
  74. package/src/workflow/discovery.ts +5 -6
  75. package/src/workflow/index.ts +1 -0
  76. package/src/workflow/load.test.ts +60 -3
  77. package/src/workflow/load.ts +316 -166
  78. package/src/workflow/pin.test.ts +159 -0
  79. package/src/workflow/pin.ts +72 -0
  80. package/src/workflow/vars.ts +3 -3
  81. package/dist/chunks/testkit-BvecVjQ0.mjs +0 -32355
@@ -12,12 +12,13 @@ export const pathspec = (paths: ReadonlyArray<string>): string => paths.map(shel
12
12
  * output rather than the exit code, so a genuinely rejected commit (a lint
13
13
  * error, a commit-msg hook) still fails.
14
14
  */
15
- const commitAllowEmpty = (message: string): string => {
15
+ const commitAllowEmpty = (message: string, only?: string): string => {
16
16
  const m = shellQuote(message)
17
+ const paths = only === undefined ? "" : ` -- ${shellQuote(only)}`
17
18
  return [
18
- `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
19
+ `if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
19
20
  ` case "$out" in`,
20
- ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
21
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m}${paths} || exit 1 ;;`,
21
22
  ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
22
23
  ` esac`,
23
24
  `fi`,
@@ -31,6 +32,21 @@ export const CHANGED_SINCE_LAND =
31
32
  export const commitAll = (message: string): string =>
32
33
  `git add -A || exit 1\n${commitAllowEmpty(message)}`
33
34
 
35
+ /** Commits `path` alone, as the worktree has it; every other pending change stays pending. */
36
+ export const commitPath = (path: string, message: string): string =>
37
+ `git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`
38
+
39
+ /** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
40
+ export const pinScript = (path: string): string => {
41
+ const p = shellQuote(path)
42
+ const slash = path.lastIndexOf("/")
43
+ const mkdir = slash === -1 ? "" : `mkdir -p ${shellQuote(path.slice(0, slash))}\n`
44
+ return `${mkdir}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`
45
+ }
46
+
47
+ /** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
48
+ export const unpinScript = (path: string): string => `rm -f -- ${shellQuote(path)}`
49
+
34
50
  /**
35
51
  * An approval records exactly the judged tree: HEAD is pinned before the
36
52
  * clean check and the commit is built over its tree with `commit-tree`, so no
@@ -151,6 +167,8 @@ const renderGitWrite = (write: GitWrite): string => {
151
167
  switch (write.kind) {
152
168
  case "commitAll":
153
169
  return commitAll(write.message)
170
+ case "commitPath":
171
+ return commitPath(write.path, write.message)
154
172
  case "commitClean":
155
173
  return commitClean(write.message)
156
174
  case "updateRef":
package/src/emit/index.ts CHANGED
@@ -16,6 +16,9 @@ export {
16
16
  commitAll,
17
17
  commitAsIs,
18
18
  commitClean,
19
+ commitPath,
20
+ pinScript,
21
+ unpinScript,
19
22
  CHANGED_SINCE_LAND,
20
23
  softResetTo,
21
24
  mixedResetTo,
@@ -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
 
@@ -45,6 +45,7 @@ describe("restDescription", () => {
45
45
  " last commits:",
46
46
  " chore: initial commit",
47
47
  " gtd(agent): plan → sign-off",
48
+ " gtd(check): pin → sign-off",
48
49
  ' actors never used: "repair" (scenario), "ready" (Background)',
49
50
  ].join("\n"),
50
51
  )
@@ -20,5 +20,6 @@ Feature: a scenario runs a workflow module from disk
20
20
  And the commit subjects end with:
21
21
  """
22
22
  gtd(agent): plan → sign-off
23
+ gtd(check): pin → sign-off
23
24
  """
24
25
 
@@ -23,6 +23,7 @@ Feature: steps for checks, stalls, beat fields, commit bodies and the environmen
23
23
 
24
24
  Scenario: the resting step's fields are read by name
25
25
  When gtd starts workflow "tested"
26
+ And the process is driven
26
27
  Then the process rests at "build.plan"
27
28
  And the resting step's model is "smart"
28
29
  And the resting step's idle is "false"
@@ -43,6 +43,7 @@ Feature: testkit drives any workflow against the real engine
43
43
  And the commit subjects end with:
44
44
  """
45
45
  gtd(human): plan
46
+ gtd(check): pin → plan
46
47
  gtd(agent): plan → check
47
48
  gtd(check): check → ready
48
49
  gtd(judge): ready → ship
@@ -106,7 +107,7 @@ Feature: testkit drives any workflow against the real engine
106
107
  When gtd starts workflow "tiny"
107
108
  And the process is driven
108
109
  Then the process rests at "plan"
109
- And the last commit subject is "gtd(human): plan"
110
+ And the last commit subject is "gtd(check): pin → plan"
110
111
 
111
112
  Scenario: a prompt asserted beside a script is checked at every visit, before the step acts
112
113
  Given step "check" is prompted with "npm test"
@@ -240,7 +240,13 @@ The \`kind\` field (\`--json=kind\`) selects what to do:
240
240
  Land it immediately, no display needed.
241
241
  - \`script\` — run \`--json=content\` with \`sh -c\`, appending its output to
242
242
  \`--json=log\`, and ignore its exit code — the outcome lives in the tree,
243
- not in the exit code.
243
+ not in the exit code. Two script beats carry a \`pin\` mark: the **pin
244
+ beat** right after a process starts (\`pin: "add"\`, its script writes
245
+ \`gtd bundle\`'s output to \`.gtd/process.gtdpin\`) and the **unpin beat**
246
+ once the flow has ended (\`pin: "remove"\`, its script deletes that file).
247
+ Both run unattended, like any script beat. A driver may write or delete the
248
+ file its own way instead of running the script; either way it then lands,
249
+ and the unpin's landing is the commit that closes the process.
244
250
  - \`prompt\` — send plain \`gtd next\`'s own output to your agent CLI over
245
251
  stdin (it's the bare content at this kind, so no separate \`--json=content\`
246
252
  read is needed), using the accompanying \`session.id\`/\`session.resume\`/
@@ -284,7 +290,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
284
290
  \`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
285
291
  document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
286
292
  \`cost\`, \`costByModel\`. Always \`approvals\` (an array of
287
- \`{step, name, email, commit}\`, \`[]\` when none).
293
+ \`{step, name, email, commit}\`, \`[]\` when none). Only at a pin or unpin
294
+ beat: \`pin\` (\`"add"\` or \`"remove"\`).
288
295
 
289
296
  \`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
290
297
  (true for a no-op at a \`script\` rest — stop immediately, nothing more to
@@ -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
  )
@@ -12,10 +12,9 @@ import { mkdtempSync, realpathSync } from "node:fs"
12
12
  import { execSync } from "node:child_process"
13
13
  import { tmpdir } from "node:os"
14
14
  import { Effect, Layer } from "effect"
15
- import { NodeContext } from "@effect/platform-node"
16
15
  import { describe, expect, it } from "vitest"
17
16
  import { loopLogPath, worktreeGitDir } from "./WorktreeState.js"
18
- import { GitService, Host, Workspace } from "./index.js"
17
+ import { GitService, Host, Workspace, NodeServices } from "./index.js"
19
18
  import { InMemRepo, testLayers } from "../twins/index.js"
20
19
 
21
20
  const provide = <A>(
@@ -112,7 +111,7 @@ describe("worktreeGitDir [real git]", () => {
112
111
 
113
112
  const runIn = (root: string) => {
114
113
  const hostLayer = Host.layer({ root, home: root, env: {} })
115
- const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeContext.layer)))
114
+ const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
116
115
  const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
117
116
  return Effect.runPromise(
118
117
  worktreeGitDir.pipe(Effect.provide(Layer.mergeAll(hostLayer, workspaceLayer))),
@@ -17,7 +17,7 @@ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Ef
17
17
  const workspace = yield* Workspace
18
18
  const content = yield* workspace
19
19
  .read(".git")
20
- .pipe(Effect.catchAll(() => Effect.succeed(undefined)))
20
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
21
21
  const pointer = content === undefined ? null : GITDIR_POINTER.exec(content)
22
22
  if (pointer === null) return ".git"
23
23
  const gitdir = pointer[1]!.trim()
@@ -4,7 +4,7 @@
4
4
  * home, env, scratch). `GitService` moved in alongside `Workspace` since
5
5
  * `committed` IS `git.readFileAtRef` — one module owns every read.
6
6
  *
7
- * Seam rule, stated once: a seam is a `Context.Tag` unless the consuming
7
+ * Seam rule, stated once: a seam is a `Context.Service` unless the consuming
8
8
  * code is synchronous, non-Effect code that cannot `yield*` — replay's flow
9
9
  * reads are that exception, and use the `*Sync` members.
10
10
  */
@@ -19,6 +19,7 @@ export {
19
19
  } from "./Git.js"
20
20
  export { Narrator, GtdError, GtdUsageError, renderFailure } from "./Commentary.js"
21
21
  export { CommandRunner } from "./CommandRunner.js"
22
+ export * as NodeServices from "./NodeServices.js"
22
23
  export { loopLogPath } from "./WorktreeState.js"
23
24
  export {
24
25
  HISTORY_REF,