@pmelab/gtd 27.8.0 → 27.9.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.
@@ -112,6 +112,7 @@ describe("parseArgv — scope", () => {
112
112
  ["judge", "--json"],
113
113
  ["judge", "answer", "--json"],
114
114
  ["doors", "--json"],
115
+ ["slices", "--json"],
115
116
  ]) {
116
117
  const ok = parseArgv(["node", "gtd.js", ...args])
117
118
  expect(ok.kind).toBe("command")
@@ -927,6 +928,40 @@ describe("parseArgv — doors", () => {
927
928
  })
928
929
  })
929
930
 
931
+ describe("parseArgv — slices", () => {
932
+ const parse = (...args: string[]): CliPlan => parseArgv(["node", "gtd.js", ...args])
933
+
934
+ it("`gtd slices` parses with every --json shape", () => {
935
+ expect(parse("slices")).toMatchObject({
936
+ kind: "command",
937
+ command: { kind: "slices" },
938
+ json: { kind: "off" },
939
+ })
940
+ expect(parse("slices", "--json")).toMatchObject({ json: { kind: "document" } })
941
+ expect(parse("slices", "--json=slices.0.name")).toMatchObject({
942
+ json: { kind: "select", path: "slices.0.name" },
943
+ })
944
+ })
945
+
946
+ it("`gtd slices` takes no argument", () => {
947
+ const plan = parse("slices", "extra")
948
+ expect(plan.kind).toBe("usage")
949
+ if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
950
+ })
951
+
952
+ it("help lists the command, and --json names it", () => {
953
+ const help = renderHelp()
954
+ expect(help).toMatch(/^ {2}slices\b/m)
955
+ expect(help).toContain("gtd slices")
956
+ })
957
+
958
+ it("--json's scope error names gtd slices", () => {
959
+ const plan = parse("lsp", "--json")
960
+ expect(plan.kind).toBe("usage")
961
+ if (plan.kind === "usage") expect(plan.message).toContain("`gtd slices`")
962
+ })
963
+ })
964
+
930
965
  describe("parseArgv — gtd history", () => {
931
966
  const parse = (...args: string[]) => parseArgv(["node", "gtd.js", ...args])
932
967
  const usage = (plan: CliPlan): string => (plan.kind === "usage" ? plan.message : "")
@@ -1057,6 +1092,7 @@ describe("standaloneKinds / needsOf", () => {
1057
1092
  "judgeAnswer",
1058
1093
  "door",
1059
1094
  "doors",
1095
+ "slices",
1060
1096
  "historyList",
1061
1097
  "historyShow",
1062
1098
  "historyFetch",
@@ -1136,9 +1172,9 @@ describe("renderHelp", () => {
1136
1172
  expect(match, file).not.toBeNull()
1137
1173
  return new Set([...match![1]!.matchAll(/`([a-z]+(?: [a-z]+)?)`/g)].map(([, name]) => name!))
1138
1174
  }
1139
- expect(listed("../../README.md", /The `gtd` bin's commands — ([\s\S]*?) — are listed/)).toEqual(
1140
- commands,
1141
- )
1175
+ expect(
1176
+ listed("../../README.md", /The `gtd` bin's commands — ([\s\S]*?) — are\s+listed/),
1177
+ ).toEqual(commands)
1142
1178
  expect(listed("../../COMPATIBILITY.md", /every command \(([\s\S]*?)\), option/)).toEqual(
1143
1179
  commands,
1144
1180
  )
package/src/cli/Cli.ts CHANGED
@@ -101,16 +101,17 @@ const FLAGS: readonly FlagRow[] = [
101
101
  kind === "judge" ||
102
102
  kind === "judgeAnswer" ||
103
103
  kind === "doors" ||
104
+ kind === "slices" ||
104
105
  kind === "historyList" ||
105
106
  kind === "historyShow" ||
106
107
  kind === "strays",
107
108
  decode: ([raw]) => Either.right(raw ?? ""),
108
109
  scopeError:
109
- "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show`/`gtd strays` — " +
110
+ "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd slices`/`gtd history list`/`gtd history show`/`gtd strays` — " +
110
111
  "every other command prints plain text; see `gtd install` for the driver protocol briefing",
111
112
  valueHint: "<path>",
112
113
  help: [
113
- "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
114
+ "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd slices/gtd history",
114
115
  "list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
115
116
  "--json=<path> (a dotted key path into that document, e.g.",
116
117
  "kind, content, session.id) prints just that value: a",
@@ -636,6 +637,25 @@ const COMMAND_ROWS: readonly CommandRow[] = [
636
637
  "value off it. Writes nothing",
637
638
  ],
638
639
  },
640
+ {
641
+ token: "slices",
642
+ kind: "slices",
643
+ arity: "none",
644
+ details: [
645
+ "List the slices of the process HEAD closes or sits inside,",
646
+ "first slice first: one `name base..tip` line each (full",
647
+ "hashes; plain output carries no promise). --json prints",
648
+ "{ schema: 1, slices: [{ name, base, tip, closed }] };",
649
+ "--json=<path> reads one value off it. Writes nothing: no",
650
+ "git, no state transition, no session identity. When HEAD",
651
+ "closes a process, every slice is listed as closed; a gtd",
652
+ "version mismatch only warns on stderr. Refuses (exit 1)",
653
+ "when HEAD neither closes nor sits inside a process, or when",
654
+ "today's config disagrees with history about the process:",
655
+ "HEAD closes it and its config files differ from the working",
656
+ "tree, or the replay diverged",
657
+ ],
658
+ },
639
659
  {
640
660
  token: "judge",
641
661
  kind: "judge",
@@ -1304,6 +1324,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1304
1324
  | "install"
1305
1325
  | "loop"
1306
1326
  | "doors"
1327
+ | "slices"
1307
1328
  | "summary"
1308
1329
  | "base"
1309
1330
  | "judge"
@@ -1677,6 +1677,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1677
1677
  start: { kind: "start", actor: "human", workflow: "feature", vars: {}, label: "" },
1678
1678
  door: { kind: "door", name: "fix", args: [] },
1679
1679
  doors: { kind: "doors" },
1680
+ slices: { kind: "slices" },
1680
1681
  abandon: { kind: "abandon" },
1681
1682
  restore: { kind: "restore" },
1682
1683
  next: { kind: "next" },
@@ -1703,13 +1704,14 @@ describe("runCommand — refuses in a repository with no commits", () => {
1703
1704
  const NO_COMMITS_MESSAGE =
1704
1705
  "gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
1705
1706
 
1706
- it("derives exactly the sixteen non-standalone kinds — a canary for the table-driven cases below", () => {
1707
+ it("derives exactly the seventeen non-standalone kinds — a canary for the table-driven cases below", () => {
1707
1708
  expect(stateKinds.sort()).toEqual(
1708
1709
  [
1709
1710
  "abandon",
1710
1711
  "base",
1711
1712
  "door",
1712
1713
  "doors",
1714
+ "slices",
1713
1715
  "start",
1714
1716
  "land",
1715
1717
  "next",
@@ -43,6 +43,8 @@ import {
43
43
  type StatusChange,
44
44
  selectPath,
45
45
  EXIT_OK,
46
+ renderSlicesJson,
47
+ slicesDocument,
46
48
  } from "../wire/index.js"
47
49
  import {
48
50
  currentRest,
@@ -51,6 +53,7 @@ import {
51
53
  renderRest,
52
54
  restAt,
53
55
  previewLanding,
56
+ processSlices,
54
57
  noProcessUnderway,
55
58
  restIsIdle,
56
59
  reviewBaseFor,
@@ -143,6 +146,7 @@ export type Command =
143
146
  }
144
147
  | { readonly kind: "door"; readonly name: string; readonly args: readonly string[] }
145
148
  | { readonly kind: "doors" }
149
+ | { readonly kind: "slices" }
146
150
  | { readonly kind: "abandon" }
147
151
  | { readonly kind: "restore" }
148
152
  | { readonly kind: "next" }
@@ -659,6 +663,22 @@ const runDoorsCommand = (
659
663
  else yield* writeSelection(out, document, json.path)
660
664
  })
661
665
 
666
+ const runSlicesCommand = (
667
+ json: JsonMode,
668
+ out: ArtifactOut,
669
+ ): Effect.Effect<void, Error, CommandRequirements> =>
670
+ Effect.gen(function* () {
671
+ const { slices, versionWarning } = yield* processSlices
672
+ if (versionWarning !== undefined) yield* (yield* Narrator).warn(versionWarning)
673
+ if (json.kind === "off") {
674
+ out.write(slices.map((s) => `${s.name} ${s.base}..${s.tip}\n`).join(""))
675
+ return
676
+ }
677
+ const document = slicesDocument(slices)
678
+ if (json.kind === "document") out.write(renderSlicesJson(document))
679
+ else yield* writeSelection(out, document, json.path)
680
+ })
681
+
662
682
  /**
663
683
  * Ends the process underway without completing it. Nothing is discarded: a
664
684
  * `git reset --mixed` to the process's start commit leaves every turn
@@ -1223,6 +1243,8 @@ const dispatchVoidCommand = (
1223
1243
  return runDoorCommand(command.name, command.args, out)
1224
1244
  case "doors":
1225
1245
  return runDoorsCommand(json, out)
1246
+ case "slices":
1247
+ return runSlicesCommand(json, out)
1226
1248
  case "abandon":
1227
1249
  return runAbandonCommand(out)
1228
1250
  case "restore":
@@ -3,10 +3,10 @@ import { describe, expect, it } from "vitest"
3
3
  import { runCli } from "../cli/index.js"
4
4
  import {
5
5
  currentRest,
6
- closedRunAt,
7
6
  currentRun,
8
7
  historyTrail,
9
8
  noProcessUnderway,
9
+ processSlices,
10
10
  renderRest,
11
11
  restAt,
12
12
  restIsIdle,
@@ -596,31 +596,168 @@ export const summary = (it) => it.marks.map((m) => m.name + "=" + m.value).join(
596
596
  })
597
597
  })
598
598
 
599
- describe("Rest.slices", () => {
600
- const SLICED = `import { agent, human, slice } from "@pmelab/gtd/flows"
599
+ const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
600
+ const real = fakeGitOperations(repo)
601
+ const head = repo.resolveRef("HEAD")
602
+ return {
603
+ ...real,
604
+ commitHistory: (base, tip) =>
605
+ Effect.map(real.commitHistory(base, tip), (commits) =>
606
+ commits.map((c) => (c.hash === head ? { ...c, message: edit(c.message) } : c)),
607
+ ),
608
+ }
609
+ }
601
610
 
602
- export default async () => {
603
- await human("idle", { message: "idle-message" })
604
- await slice("build", async () => {
605
- await agent("building", "build-prompt")
611
+ describe("processSlices", () => {
612
+ const failure = async (repo: InMemRepo): Promise<string> => {
613
+ const exit = await provideExit(processSlices, repo)
614
+ expect(Exit.isFailure(exit)).toBe(true)
615
+ return Exit.isFailure(exit) ? String(exit.cause) : ""
616
+ }
617
+
618
+ it("never reports an open slice for a closed run whose replay no longer ends", async () => {
619
+ const repo = repoWith(LINEAR)
620
+ await land(repo, { "a.txt": "a\n" })
621
+ await land(repo, { "b.txt": "b\n" })
622
+ await land(repo, { "c.txt": "c\n" })
623
+ repo.writeFile(
624
+ "gtd.config.ts",
625
+ LINEAR.replace(
626
+ 'await agent("checking", "check-prompt")',
627
+ 'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
628
+ ),
629
+ )
630
+ // Stands for a flow change with no in-repo config edit (an upgraded
631
+ // out-of-repo import): every ref reads as the working tree, so the
632
+ // config-differs refusal stays quiet and only replay drift remains.
633
+ const git: GitOperations = {
634
+ ...fakeGitOperations(repo),
635
+ readFileAtRef: (_ref, path) => Effect.succeed(repo.readFile(path) ?? ""),
636
+ }
637
+ const exit = await Effect.runPromiseExit(
638
+ processSlices.pipe(
639
+ Effect.provideService(GitService, git),
640
+ Effect.provide(testLayers(repo, {})),
641
+ ),
642
+ )
643
+ expect(Exit.isFailure(exit)).toBe(true)
644
+ const message = Exit.isFailure(exit) ? String(exit.cause) : ""
645
+ expect(message).toContain(
646
+ `gtd slices: refused — ${repo.resolveRef("HEAD")!.slice(0, 7)} closes a process that does not end under today's config`,
647
+ )
606
648
  })
607
- await slice("verify", async () => {
608
- await agent("checking", "check-prompt")
649
+
650
+ it("never reports a closed slice for an underway run whose replay now ends", async () => {
651
+ const repo = repoWith(LINEAR)
652
+ await land(repo, { "a.txt": "a\n" })
653
+ await land(repo, { "b.txt": "b\n" })
654
+ repo.writeFile(
655
+ "gtd.config.ts",
656
+ LINEAR.replace(' await agent("checking", "check-prompt")\n', ""),
657
+ )
658
+ const message = await failure(repo)
659
+ expect(message).toContain("gtd slices: refused")
660
+ expect(message).toContain("the workflow changed under this process")
661
+ expect(message).toContain("gtd abandon")
662
+ expect(message).not.toContain("gtd slices: gtd:")
609
663
  })
610
- }
611
- `
612
664
 
613
- it("folds the replay's spans over the run's base and its last commit", async () => {
614
- const repo = repoWith(SLICED)
615
- const boundary = headOf(repo)
665
+ it("refuses with no process at HEAD, naming that HEAD neither closes nor sits inside one", async () => {
666
+ const repo = repoWith(LINEAR)
667
+ expect(await failure(repo)).toContain(
668
+ "gtd slices: refused — HEAD neither closes nor sits inside a process",
669
+ )
670
+ })
671
+
672
+ it("refuses a renamed step with replay's message, prefixed and without a gtd: lead", async () => {
673
+ const repo = repoWith(LINEAR)
616
674
  await land(repo, { "a.txt": "a\n" })
617
- const checking = await land(repo, { "b.txt": "b\n" })
618
- const rest = await provide(currentRest, repo)
619
- expect(rest.state).toBe("checking")
620
- expect(rest.slices).toEqual([
621
- { name: "build", base: boundary, tip: checking, open: false },
622
- { name: "verify", base: checking, tip: checking, open: true },
623
- ])
675
+ repo.writeFile("gtd.config.ts", LINEAR.replace('agent("building"', 'agent("constructing"'))
676
+ const message = await failure(repo)
677
+ expect(message).toContain("gtd slices: refused — ")
678
+ expect(message).not.toContain("HEAD neither closes nor sits inside a process")
679
+ expect(message).not.toContain("refused — gtd: ")
680
+ expect(message).not.toContain("gtd slices: gtd:")
681
+ })
682
+
683
+ it("refuses a finished process whose config file was edited since", async () => {
684
+ const repo = repoWith(LINEAR)
685
+ await land(repo, { "a.txt": "a\n" })
686
+ await land(repo, { "b.txt": "b\n" })
687
+ await land(repo, { "c.txt": "c\n" })
688
+ repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
689
+ const message = await failure(repo)
690
+ expect(message).toContain(
691
+ `gtd slices: refused — config differs at ${repo.resolveRef("HEAD")!.slice(0, 7)}: gtd.config.ts. HEAD's process replays under today's config; revert or stash the edit to list its slices`,
692
+ )
693
+ })
694
+
695
+ const finishedProcess = async () => {
696
+ const repo = repoWith(LINEAR)
697
+ await land(repo, { "a.txt": "a\n" })
698
+ await land(repo, { "b.txt": "b\n" })
699
+ await land(repo, { "c.txt": "c\n" })
700
+ return repo
701
+ }
702
+ const slicesWith = (repo: InMemRepo, git: GitOperations) =>
703
+ Effect.runPromise(
704
+ processSlices.pipe(
705
+ Effect.provideService(GitService, git),
706
+ Effect.provide(testLayers(repo, {})),
707
+ ),
708
+ )
709
+
710
+ it("warns about a closed run recorded by another version, slices unchanged", async () => {
711
+ const repo = await finishedProcess()
712
+ const plain = await provide(processSlices, repo)
713
+ const git = withTipMessage(repo, (m) =>
714
+ m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
715
+ )
716
+ const result = await slicesWith(repo, git)
717
+ expect(result.versionWarning).toContain("0.0.1")
718
+ expect(result.versionWarning).toContain(GTD_VERSION)
719
+ expect(result.slices).toEqual(plain.slices)
720
+ })
721
+
722
+ it("warns about an unknown version when the closing commit records none", async () => {
723
+ const repo = await finishedProcess()
724
+ const git = withTipMessage(repo, (m) => m.replace(/Gtd-Version: .*\n?/, ""))
725
+ expect((await slicesWith(repo, git)).versionWarning).toContain("an unknown gtd version")
726
+ })
727
+
728
+ it("does not warn for a closed run landed at the running version", async () => {
729
+ const repo = await finishedProcess()
730
+ expect((await provide(processSlices, repo)).versionWarning).toBeUndefined()
731
+ })
732
+
733
+ it("does not warn for an underway process", async () => {
734
+ const repo = repoWith(LINEAR)
735
+ await land(repo, { "a.txt": "a\n" })
736
+ const git = withTipMessage(repo, (m) =>
737
+ m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
738
+ )
739
+ expect((await slicesWith(repo, git)).versionWarning).toBeUndefined()
740
+ })
741
+
742
+ it("refuses on config drift before replay-ends drift", async () => {
743
+ const repo = await finishedProcess()
744
+ repo.writeFile(
745
+ "gtd.config.ts",
746
+ LINEAR.replace(
747
+ 'await agent("checking", "check-prompt")',
748
+ 'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
749
+ ),
750
+ )
751
+ const message = await failure(repo)
752
+ expect(message).toContain("config differs at")
753
+ expect(message).not.toContain("does not end")
754
+ })
755
+
756
+ it("still lists an underway process's slices after the same config edit", async () => {
757
+ const repo = repoWith(LINEAR)
758
+ await land(repo, { "a.txt": "a\n" })
759
+ repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
760
+ expect((await provide(processSlices, repo)).slices).toHaveLength(1)
624
761
  })
625
762
  })
626
763
 
@@ -1154,30 +1291,6 @@ describe("the pending working tree is read at most once per restAt, and never st
1154
1291
  })
1155
1292
  })
1156
1293
 
1157
- describe("closedRunAt", () => {
1158
- it("is the run summaryRun reads when HEAD is the tip", async () => {
1159
- const repo = repoWith(LINEAR)
1160
- await land(repo, { "a.txt": "a\n" })
1161
- await land(repo, { "b.txt": "b\n" })
1162
- const tip = await land(repo, { "c.txt": "c\n" })
1163
- expect(await provide(closedRunAt(tip), repo)).toEqual(await provide(summaryRun, repo))
1164
- })
1165
-
1166
- it("resolves the closed run with HEAD elsewhere", async () => {
1167
- const repo = repoWith(LINEAR)
1168
- const building = await land(repo, { "a.txt": "a\n" })
1169
- await land(repo, { "b.txt": "b\n" })
1170
- const tip = await land(repo, { "c.txt": "c\n" })
1171
- const closed = await provide(closedRunAt(tip), repo)
1172
- repo.writeFile("d.txt", "d\n")
1173
- repo.seedCommit("chore: unrelated")
1174
- const later = await provide(closedRunAt(tip), repo)
1175
- expect(later).toEqual(closed)
1176
- expect(later.closingHash).toBe(tip)
1177
- expect(later.startHash).toBe(building)
1178
- })
1179
- })
1180
-
1181
1294
  describe("finishing landings keep history", () => {
1182
1295
  const landingOf = async (repo: InMemRepo, git?: GitOperations) => {
1183
1296
  const rest = await provide(currentRest, repo)
@@ -1467,14 +1580,6 @@ export default async () => {
1467
1580
  expect(trail.versionWarning).toBeUndefined()
1468
1581
  })
1469
1582
 
1470
- const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
1471
- const real = fakeGitOperations(repo)
1472
- return {
1473
- ...real,
1474
- lastCommitMessage: (ref) => Effect.map(real.lastCommitMessage(ref), edit),
1475
- }
1476
- }
1477
-
1478
1583
  it("warns, never refuses, when the recorded version differs", async () => {
1479
1584
  const { repo, tip } = await finished()
1480
1585
  const git = withTipMessage(repo, (m) =>
@@ -1504,13 +1609,63 @@ export default async () => {
1504
1609
  expect(trail.versionWarning).toContain(GTD_VERSION)
1505
1610
  })
1506
1611
 
1612
+ it("reads a recorded version carrying control bytes as unknown", async () => {
1613
+ const { repo, tip } = await finished()
1614
+ const raw = "1.0\u001b]0;x\u0007"
1615
+ const git = withTipMessage(repo, (m) =>
1616
+ m.replace(`Gtd-Version: ${GTD_VERSION}`, `Gtd-Version: ${raw}`),
1617
+ )
1618
+ const trail = await Effect.runPromise(
1619
+ historyTrail(tip).pipe(
1620
+ Effect.provideService(GitService, git),
1621
+ Effect.provide(testLayers(repo, {})),
1622
+ ),
1623
+ )
1624
+ expect(trail.version.recorded).toBeUndefined()
1625
+ expect(trail.versionWarning).toContain("an unknown gtd version")
1626
+ // eslint-disable-next-line no-control-regex
1627
+ expect(trail.versionWarning).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/)
1628
+ })
1629
+
1507
1630
  it("refuses naming the config file that differs at the tip", async () => {
1508
1631
  const { repo, tip } = await finished()
1509
1632
  repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
1510
1633
  repo.seedCommit("chore: edit config")
1511
1634
  const exit = await provideExit(historyTrail(tip), repo)
1512
1635
  expect(Exit.isFailure(exit)).toBe(true)
1513
- expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
1636
+ expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
1637
+ `gtd history: refused — config differs at ${tip.slice(0, 7)}: gtd.config.ts. A history ref replays under today's config, so it is unreadable after the config changes`,
1638
+ )
1639
+ })
1640
+
1641
+ it("refuses a resting tip with the exact message", async () => {
1642
+ const repo = repoWith(LINEAR)
1643
+ const tip = await land(repo, { "a.txt": "a\n" })
1644
+ const exit = await provideExit(historyTrail(tip), repo)
1645
+ expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
1646
+ `gtd history: ${tip.slice(0, 7)} does not close a process under today's config`,
1647
+ )
1648
+ })
1649
+
1650
+ it("reads the episode's trees exactly once", async () => {
1651
+ const { repo, tip } = await finished()
1652
+ let calls = 0
1653
+ const trail = await Effect.runPromise(
1654
+ Effect.gen(function* () {
1655
+ const real = yield* Workspace
1656
+ return yield* historyTrail(tip).pipe(
1657
+ Effect.provideService(Workspace, {
1658
+ ...real,
1659
+ episodeTrees: (...args: Parameters<typeof real.episodeTrees>) => {
1660
+ calls++
1661
+ return real.episodeTrees(...args)
1662
+ },
1663
+ }),
1664
+ )
1665
+ }).pipe(Effect.provide(testLayers(repo, {}))),
1666
+ )
1667
+ expect(trail.steps).toHaveLength(3)
1668
+ expect(calls).toBe(1)
1514
1669
  })
1515
1670
 
1516
1671
  it("refuses naming a config file missing from the tip", async () => {