@pmelab/gtd 59.0.1 → 60.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +48 -32
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/testkit-BhTey1Do.mjs +30517 -0
  7. package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +11285 -19864
  10. package/dist/gtd.core.mjs +593 -0
  11. package/dist/steering.mjs +2 -2
  12. package/dist/testkit/gherkin.mjs +246 -37
  13. package/dist/testkit.mjs +3 -2
  14. package/dist/web/main.js +234 -232
  15. package/dist/wire.mjs +2 -2
  16. package/dist/workflow-kit.mjs +3 -3
  17. package/package.json +7 -7
  18. package/skills/authoring/SKILL.md +66 -60
  19. package/src/cli/Cli.test.ts +185 -184
  20. package/src/cli/Cli.ts +219 -224
  21. package/src/cli/History.ts +9 -4
  22. package/src/cli/Judges.ts +1 -1
  23. package/src/cli/Output.test.ts +35 -0
  24. package/src/cli/Output.ts +34 -3
  25. package/src/cli/Program.test.ts +78 -84
  26. package/src/cli/Program.ts +192 -128
  27. package/src/cli/ToolCommands.ts +2 -2
  28. package/src/edge/Edge.test.ts +72 -62
  29. package/src/edge/Edge.ts +6 -6
  30. package/src/flows/factory.test.ts +14 -0
  31. package/src/flows/factory.ts +22 -5
  32. package/src/flows/helpers.test.ts +35 -0
  33. package/src/flows/helpers.ts +19 -10
  34. package/src/flows/index.test.ts +66 -0
  35. package/src/flows/index.ts +109 -5
  36. package/src/flows/internal.ts +7 -0
  37. package/src/flows/runtime.test.ts +192 -1
  38. package/src/flows/runtime.ts +189 -55
  39. package/src/gherkin/git-control.feature +6 -6
  40. package/src/gherkin/index.test.ts +127 -0
  41. package/src/gherkin/index.ts +370 -51
  42. package/src/gherkin/messages.test.ts +38 -4
  43. package/src/gherkin/messages.ts +21 -4
  44. package/src/gherkin/more-steps.feature +170 -0
  45. package/src/gherkin/scenario-end.feature +101 -0
  46. package/src/gherkin/testkit.feature +83 -7
  47. package/src/install/Install.test.ts +7 -7
  48. package/src/install/Install.ts +8 -8
  49. package/src/install/Version.ts +9 -3
  50. package/src/install/index.ts +1 -1
  51. package/src/platform/CommandRunner.ts +13 -11
  52. package/src/platform/Commentary.ts +2 -2
  53. package/src/platform/Git.test.ts +4 -5
  54. package/src/platform/Git.ts +42 -38
  55. package/src/platform/Host.ts +1 -1
  56. package/src/platform/NodeServices.ts +11 -0
  57. package/src/platform/Workspace.test.ts +4 -5
  58. package/src/platform/Workspace.ts +3 -3
  59. package/src/platform/WorktreeState.test.ts +2 -3
  60. package/src/platform/WorktreeState.ts +1 -1
  61. package/src/platform/index.ts +2 -1
  62. package/src/replay/Replay.test.ts +13 -2
  63. package/src/replay/Replay.ts +19 -30
  64. package/src/replay/judgeDiffShape.test.ts +1 -1
  65. package/src/steering/Footnotes.test.ts +1 -1
  66. package/src/steering/SteeringFormats.test.ts +1 -1
  67. package/src/steering/freeform.test.ts +1 -1
  68. package/src/steering/index.test.ts +27 -1
  69. package/src/steering/index.ts +6 -104
  70. package/src/steering/internal.ts +11 -0
  71. package/src/steering/qa.test.ts +7 -2
  72. package/src/steering/reads.test.ts +42 -0
  73. package/src/steering/reads.ts +43 -0
  74. package/src/steering/registry.ts +93 -0
  75. package/src/steering/review.test.ts +2 -2
  76. package/src/step/planStart.test.ts +18 -16
  77. package/src/step/planStart.ts +12 -12
  78. package/src/testkit/TestRepo.test.ts +245 -7
  79. package/src/testkit/TestRepo.ts +271 -40
  80. package/src/testkit/imports.test.ts +58 -0
  81. package/src/testkit/imports.ts +102 -0
  82. package/src/testkit/index.test.ts +28 -0
  83. package/src/testkit/index.ts +14 -2
  84. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  85. package/src/twins/EmittedScripts.test.ts +5 -3
  86. package/src/twins/GitTiers.ts +6 -7
  87. package/src/twins/InMemRepo.ts +1 -0
  88. package/src/twins/Layers.test.ts +1 -1
  89. package/src/twins/Layers.ts +10 -3
  90. package/src/twins/cliIo.ts +9 -4
  91. package/src/wire/Script.ts +11 -0
  92. package/src/wire/WireSchema.test.ts +3 -0
  93. package/src/wire/golden/1/script.json +4 -0
  94. package/src/wire/index.ts +1 -0
  95. package/src/workflow/SteeringFormats.test.ts +1 -1
  96. package/src/workflow/compile.ts +2 -2
  97. package/src/workflow/discovery.test.ts +27 -10
  98. package/src/workflow/discovery.ts +5 -6
  99. package/src/workflow/index.ts +1 -0
  100. package/src/workflow/load.test.ts +316 -119
  101. package/src/workflow/load.ts +478 -305
  102. package/src/workflow/pin.test.ts +159 -0
  103. package/src/workflow/pin.ts +72 -0
  104. package/src/workflow/vars.ts +3 -3
  105. package/src/workflow-kit/index.test.ts +25 -0
  106. package/src/workflow-kit/index.ts +37 -4
  107. package/src/workflow-kit/review.ts +2 -2
  108. package/dist/chunks/testkit-07Z_ENch.mjs +0 -32175
@@ -1,5 +1,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,
@@ -702,7 +702,7 @@ describe("replay: keep", () => {
702
702
  it("rejects keep together with allowEmpty", async () => {
703
703
  const flow: Flow = async () => {
704
704
  await human("idle")
705
- await agent("naive", "p", { keep: true, allowEmpty: true })
705
+ await agent("naive", "p", { keep: true, allowEmpty: true } as never)
706
706
  }
707
707
  const h = new History().land("human", "idle", 1, "naive", {})
708
708
  const outcome = await replayOf(flow, h)
@@ -1166,6 +1166,17 @@ describe("human({ approve })", () => {
1166
1166
  }
1167
1167
  })
1168
1168
 
1169
+ it.each([
1170
+ ["human", async () => human("gate", { mode: "qa" } as never)],
1171
+ ["agent", async () => agent("gate", "p", { mode: "qa" } as never)],
1172
+ ["run", async () => run("gate", "true", { mode: "qa" } as never)],
1173
+ ] as const)("refuses a %s step's mode without a file", async (kind, flow) => {
1174
+ expect(await replayOf(flow, new History())).toEqual({
1175
+ kind: "failed",
1176
+ message: `gtd: step "gate": mode requires file in ${kind}() options — name the steering file the mode checks`,
1177
+ })
1178
+ })
1179
+
1169
1180
  describe("refuses mode and file", () => {
1170
1181
  const tail =
1171
1182
  "in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing"
@@ -1189,7 +1200,7 @@ describe("human({ approve })", () => {
1189
1200
  for (const options of [
1190
1201
  { approve: true, label: "x" },
1191
1202
  { approve: true, base: "start" },
1192
- { approve: false, mode: "review" },
1203
+ { approve: false, mode: "review", file: ".gtd/REVIEW.md" },
1193
1204
  { approve: true, mode: undefined },
1194
1205
  ]) {
1195
1206
  expect((await outcomeOf(options)).kind).toBe("rest")
@@ -11,14 +11,13 @@ import {
11
11
  type StepRequest,
12
12
  type Flow,
13
13
  type Mark,
14
- } from "../flows/index.js"
14
+ } from "../flows/internal.js"
15
15
  import {
16
- headingSectionBodies,
17
- headingSections,
18
- steeringFormatFor,
16
+ openQuestions,
19
17
  parseCodeThreads,
20
- parseThreadsWithFindings,
21
- unansweredQuestions,
18
+ sectionBodies,
19
+ sections,
20
+ threads,
22
21
  } from "../steering/index.js"
23
22
  import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
24
23
  import { globMatches } from "../wire/index.js"
@@ -291,6 +290,13 @@ const approveConflict = (step: ReachedStep): string | undefined => {
291
290
  return `gtd: step "${step.name}": approve cannot be combined with ${keys.join(", ")} in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing`
292
291
  }
293
292
 
293
+ // A mode names how a steering file is checked; with no file there is nothing to check.
294
+ const modeWithoutFile = (step: ReachedStep): string | undefined => {
295
+ const options = step.request.options
296
+ if (options.mode === undefined || options.file !== undefined) return undefined
297
+ return `gtd: step "${step.name}": mode requires file in ${step.kind}() options — name the steering file the mode checks`
298
+ }
299
+
294
300
  export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
295
301
  const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
296
302
  ...c,
@@ -524,7 +530,8 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
524
530
  message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`,
525
531
  }
526
532
  }
527
- const error = unknownOptions(step) ?? approveConflict(step) ?? checkIdentity(step)
533
+ const error =
534
+ unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step)
528
535
  return error === undefined ? undefined : { kind: "failed", message: error }
529
536
  }
530
537
 
@@ -604,7 +611,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
604
611
  const name = scoped(request.name)
605
612
  if (request.options.keep === true) {
606
613
  // A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
607
- if (request.options.allowEmpty === true) {
614
+ if ((request.options as { readonly allowEmpty?: unknown }).allowEmpty === true) {
608
615
  return `gtd: step "${name}": keep: true cannot be combined with allowEmpty — a turn that changes nothing has nothing to keep`
609
616
  }
610
617
  // "/" would let one step's ref directory collide with another step's ref (git refuses `a` and `a/b` together).
@@ -667,28 +674,10 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
667
674
  changes: () => changesBetween(previousPosition.tree, position.tree),
668
675
  changesSince,
669
676
  matches: globMatches,
670
- sections: (text) => headingSections(text),
671
- sectionBodies: (text) => headingSectionBodies(text),
672
- openQuestions: (text) => {
673
- const qa = steeringFormatFor("qa")
674
- return qa === undefined
675
- ? []
676
- : unansweredQuestions(qa, text).map((q) => ({
677
- question: q.question,
678
- line: q.headingLine + 1,
679
- }))
680
- },
681
- threads: (text) => {
682
- const { threads, findings } = parseThreadsWithFindings(text)
683
- return threads.map((t) => ({
684
- name: t.name,
685
- line: t.line + 1,
686
- waitingOn: t.waitingOn,
687
- faults: findings
688
- .filter((f) => f.message.startsWith(`Footnote thread "[^${t.name}]": `))
689
- .map((f) => f.message),
690
- }))
691
- },
677
+ sections,
678
+ sectionBodies,
679
+ openQuestions,
680
+ threads,
692
681
  codeThreads: () =>
693
682
  changesBetween(
694
683
  treeAt(input.start) ?? input.startTree ?? input.episode.base.tree,
@@ -6,7 +6,7 @@ import {
6
6
  type FlowContext,
7
7
  type JudgeAnswer,
8
8
  type StepRequest,
9
- } from "../flows/index.js"
9
+ } from "../flows/internal.js"
10
10
  import { toRequest } from "../judges/index.js"
11
11
  import { globMatches } from "../wire/index.js"
12
12
 
@@ -697,7 +697,7 @@ describe("case-insensitive definition matching", () => {
697
697
  })
698
698
  })
699
699
 
700
- import { freeFormFormat, getParseCount, steeringFormatFor } from "./index.js"
700
+ import { freeFormFormat, getParseCount, steeringFormatFor } from "./internal.js"
701
701
 
702
702
  const qaDescriptor = steeringFormatFor("qa")!
703
703
  const reviewDescriptor = steeringFormatFor("review")!
@@ -5,7 +5,7 @@ import {
5
5
  parseFootnotes,
6
6
  parseThreads,
7
7
  steeringFormatFor,
8
- } from "./index.js"
8
+ } from "./internal.js"
9
9
 
10
10
  const QA_FORMAT = steeringFormatFor("qa")!
11
11
  const REVIEW_FORMAT = steeringFormatFor("review")!
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { freeFormFormat } from "./freeform.js"
3
- import { BUILT_IN_MODE_NAMES, steeringFormatFor } from "./index.js"
3
+ import { BUILT_IN_MODE_NAMES, steeringFormatFor } from "./internal.js"
4
4
 
5
5
  /** Applies edits back-to-front — a local copy of the file's own splice pattern, mirroring `qa.test.ts`/`review.test.ts`'s identical helper. */
6
6
  const applyEdits = (
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { BUILT_IN_MODE_NAMES, steeringFormatFor, unansweredQuestions, viewOf } from "./index.js"
2
+ import * as published from "./index.js"
3
+ import { BUILT_IN_MODE_NAMES, steeringFormatFor, unansweredQuestions, viewOf } from "./internal.js"
3
4
 
4
5
  const qa = steeringFormatFor("qa")!
5
6
  const review = steeringFormatFor("review")!
@@ -60,3 +61,28 @@ describe("viewOf", () => {
60
61
  expect(view.documentLinks[0]!.line).toBe(0)
61
62
  })
62
63
  })
64
+
65
+ describe("@pmelab/gtd/steering", () => {
66
+ it("publishes no engine-only name, nor a duplicate of the pure text reads", () => {
67
+ for (const name of [
68
+ "FOOTNOTE_ACTION_TITLE",
69
+ "THREAD_REPLY_ACTION_TITLE",
70
+ "getParseCount",
71
+ "BUILT_IN_MODE_NAMES",
72
+ "steeringFormatOrFreeForm",
73
+ "viewOf",
74
+ "headingSections",
75
+ "headingSectionBodies",
76
+ "unansweredQuestions",
77
+ ]) {
78
+ expect(published, name).not.toHaveProperty(name)
79
+ }
80
+ })
81
+
82
+ it("publishes the pure text reads a flow also has", () => {
83
+ expect(published.sections("## A\n\n## B\n")).toEqual(["A", "B"])
84
+ expect(typeof published.threads).toBe("function")
85
+ expect(typeof published.openQuestions).toBe("function")
86
+ expect(typeof published.sectionBodies).toBe("function")
87
+ })
88
+ })