@pmelab/gtd 27.7.1 → 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.
@@ -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,6 +596,171 @@ export const summary = (it) => it.marks.map((m) => m.name + "=" + m.value).join(
596
596
  })
597
597
  })
598
598
 
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
+ }
610
+
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
+ )
648
+ })
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:")
663
+ })
664
+
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)
674
+ await land(repo, { "a.txt": "a\n" })
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)
761
+ })
762
+ })
763
+
599
764
  describe("restAt", () => {
600
765
  it("restAt(ref) resolves where the process rested at that commit", async () => {
601
766
  const repo = repoWith(LINEAR)
@@ -1126,30 +1291,6 @@ describe("the pending working tree is read at most once per restAt, and never st
1126
1291
  })
1127
1292
  })
1128
1293
 
1129
- describe("closedRunAt", () => {
1130
- it("is the run summaryRun reads when HEAD is the tip", async () => {
1131
- const repo = repoWith(LINEAR)
1132
- await land(repo, { "a.txt": "a\n" })
1133
- await land(repo, { "b.txt": "b\n" })
1134
- const tip = await land(repo, { "c.txt": "c\n" })
1135
- expect(await provide(closedRunAt(tip), repo)).toEqual(await provide(summaryRun, repo))
1136
- })
1137
-
1138
- it("resolves the closed run with HEAD elsewhere", async () => {
1139
- const repo = repoWith(LINEAR)
1140
- const building = await land(repo, { "a.txt": "a\n" })
1141
- await land(repo, { "b.txt": "b\n" })
1142
- const tip = await land(repo, { "c.txt": "c\n" })
1143
- const closed = await provide(closedRunAt(tip), repo)
1144
- repo.writeFile("d.txt", "d\n")
1145
- repo.seedCommit("chore: unrelated")
1146
- const later = await provide(closedRunAt(tip), repo)
1147
- expect(later).toEqual(closed)
1148
- expect(later.closingHash).toBe(tip)
1149
- expect(later.startHash).toBe(building)
1150
- })
1151
- })
1152
-
1153
1294
  describe("finishing landings keep history", () => {
1154
1295
  const landingOf = async (repo: InMemRepo, git?: GitOperations) => {
1155
1296
  const rest = await provide(currentRest, repo)
@@ -1439,14 +1580,6 @@ export default async () => {
1439
1580
  expect(trail.versionWarning).toBeUndefined()
1440
1581
  })
1441
1582
 
1442
- const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
1443
- const real = fakeGitOperations(repo)
1444
- return {
1445
- ...real,
1446
- lastCommitMessage: (ref) => Effect.map(real.lastCommitMessage(ref), edit),
1447
- }
1448
- }
1449
-
1450
1583
  it("warns, never refuses, when the recorded version differs", async () => {
1451
1584
  const { repo, tip } = await finished()
1452
1585
  const git = withTipMessage(repo, (m) =>
@@ -1476,13 +1609,63 @@ export default async () => {
1476
1609
  expect(trail.versionWarning).toContain(GTD_VERSION)
1477
1610
  })
1478
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
+
1479
1630
  it("refuses naming the config file that differs at the tip", async () => {
1480
1631
  const { repo, tip } = await finished()
1481
1632
  repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
1482
1633
  repo.seedCommit("chore: edit config")
1483
1634
  const exit = await provideExit(historyTrail(tip), repo)
1484
1635
  expect(Exit.isFailure(exit)).toBe(true)
1485
- 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)
1486
1669
  })
1487
1670
 
1488
1671
  it("refuses naming a config file missing from the tip", async () => {
package/src/edge/Edge.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  isRecordableApproval,
38
38
  parseCommitMessage,
39
39
  replay,
40
+ sliceRanges,
40
41
  treeFromRecord,
41
42
  type EpisodeCommit,
42
43
  type JudgeVerdict,
@@ -51,6 +52,7 @@ import {
51
52
  UNATTRIBUTED_MODEL,
52
53
  type ApprovalEntry,
53
54
  type ModelCost,
55
+ type SliceEntry,
54
56
  type StepAccess,
55
57
  } from "../wire/index.js"
56
58
 
@@ -474,26 +476,21 @@ const runAtTip = (
474
476
  return yield* runOf([...before, ...closing], { ...location }, tip.hash)
475
477
  })
476
478
 
479
+ const runAtHead = (git: GitOperations, def: WorkflowDefinition) =>
480
+ Effect.gen(function* () {
481
+ // Cheap: just HEAD's own subject, never the commits before it.
482
+ return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
483
+ })
484
+
477
485
  /** The process HEAD closes or sits inside — `gtd summary`'s run. */
478
486
  export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
479
487
  function* () {
480
488
  const git = yield* GitService
481
489
  const def = (yield* (yield* ConfigService).load).workflow
482
- // Cheap: just HEAD's own subject, never the commits before it.
483
- return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
490
+ return yield* runAtHead(git, def)
484
491
  },
485
492
  )
486
493
 
487
- /** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
488
- export const closedRunAt = (tip: string): Effect.Effect<ProcessRun, Error, ConfigRequirements> =>
489
- Effect.gen(function* () {
490
- const git = yield* GitService
491
- const def = (yield* (yield* ConfigService).load).workflow
492
- const hash = yield* git.resolveRef(tip)
493
- const subject = yield* git.lastCommitSubject(hash)
494
- return yield* runAtTip(git, def, { hash, subject })
495
- })
496
-
497
494
  // ── Trees ───────────────────────────────────────────────────────────────────
498
495
 
499
496
  const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
@@ -987,6 +984,15 @@ const pendingChanges = (git: GitOperations, withIndex: boolean) =>
987
984
  return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))]
988
985
  })
989
986
 
987
+ const sliceRangesOf = (
988
+ slices: Parameters<typeof sliceRanges>[0],
989
+ run: { readonly startParentHash: string; readonly trace: readonly { readonly hash: string }[] },
990
+ ) =>
991
+ sliceRanges(slices, {
992
+ base: run.startParentHash,
993
+ tip: run.trace.at(-1)?.hash ?? run.startParentHash,
994
+ })
995
+
990
996
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
991
997
  export const restAt = (
992
998
  ref: string | undefined,
@@ -1065,6 +1071,69 @@ export const restAt = (
1065
1071
 
1066
1072
  export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
1067
1073
 
1074
+ export interface ProcessSlices {
1075
+ readonly slices: readonly SliceEntry[]
1076
+ readonly versionWarning?: string
1077
+ }
1078
+
1079
+ /**
1080
+ * The slices of the process HEAD closes or sits inside, current episode only,
1081
+ * stack order. Reads like `historyTrail`, never `restAt`: it narrates and
1082
+ * writes nothing, and returns a version warning for a closed run.
1083
+ */
1084
+ export const processSlices: Effect.Effect<ProcessSlices, Error, RestRequirements> = Effect.gen(
1085
+ function* () {
1086
+ const git = yield* GitService
1087
+ const config = yield* (yield* ConfigService).load
1088
+ const workspace = yield* Workspace
1089
+ const host = yield* Host
1090
+ const run = yield* runAtHead(git, config.workflow)
1091
+ if (run.trace.length === 0) {
1092
+ return yield* Effect.fail(
1093
+ new Error("gtd slices: refused — HEAD neither closes nor sits inside a process"),
1094
+ )
1095
+ }
1096
+ const closing = run.closingHash
1097
+ let outcome: ReplayOutcome
1098
+ let versionWarning: string | undefined
1099
+ if (closing !== undefined) {
1100
+ const replayed = yield* replayFinishedProcess(config, run, closing, {
1101
+ command: "gtd slices",
1102
+ configReason:
1103
+ "HEAD's process replays under today's config; revert or stash the edit to list its slices",
1104
+ notEnded: (o) => slicesDivergence(run, o),
1105
+ })
1106
+ outcome = replayed.outcome
1107
+ versionWarning = replayed.versionWarning
1108
+ } else {
1109
+ const { setup } = yield* setupFor(config, run, workspace, host)
1110
+ outcome = yield* Effect.promise(() => replayFor(setup))
1111
+ if (outcome.kind !== "rest") return yield* Effect.fail(slicesDivergence(run, outcome))
1112
+ }
1113
+ return {
1114
+ slices: sliceRangesOf(outcome.slices, run).map(({ name, base, tip, open }) => ({
1115
+ name,
1116
+ base,
1117
+ tip,
1118
+ closed: closing !== undefined || !open,
1119
+ })),
1120
+ ...(versionWarning !== undefined ? { versionWarning } : {}),
1121
+ }
1122
+ },
1123
+ )
1124
+
1125
+ type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
1126
+ type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
1127
+
1128
+ /** Every `gtd slices` refusal reads `gtd slices: refused — <reason>`; replay's own `gtd: ` lead is dropped. */
1129
+ const slicesDivergence = (run: ProcessRun, outcome: ReplayOutcome): Error => {
1130
+ const reason =
1131
+ outcome.kind === "rest" && run.closingHash !== undefined
1132
+ ? `${run.closingHash.slice(0, 7)} closes a process that does not end under today's config`
1133
+ : (replayError(outcome)?.message.replace(/^gtd: /, "") ?? "replay failed")
1134
+ return new Error(`gtd slices: refused — ${reason}`)
1135
+ }
1136
+
1068
1137
  /**
1069
1138
  * Whether `gtd --workflow <name>` can start a process here: the workflow must
1070
1139
  * reach a step from the tree the opening commit would capture. That step is
@@ -1218,7 +1287,7 @@ const multilineRefusal = (vars: Record<string, string>): Landing | undefined =>
1218
1287
  }
1219
1288
 
1220
1289
  const landingTarget = (
1221
- outcome: Awaited<ReturnType<typeof replayFor>>,
1290
+ outcome: ReplayOutcome,
1222
1291
  rest: Rest,
1223
1292
  ): Effect.Effect<StateName | Landing, Error> => {
1224
1293
  if (outcome.kind === "refused")
@@ -1429,10 +1498,14 @@ const inRepoConfigPaths = (
1429
1498
  return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
1430
1499
  }
1431
1500
 
1432
- const configRefusal = (tip: string, paths: readonly string[]): Error =>
1501
+ const configRefusal = (
1502
+ command: string,
1503
+ tip: string,
1504
+ paths: readonly string[],
1505
+ reason = "A history ref replays under today's config, so it is unreadable after the config changes",
1506
+ ): Error =>
1433
1507
  new Error(
1434
- `gtd history: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ` +
1435
- `A history ref replays under today's config, so it is unreadable after the config changes`,
1508
+ `${command}: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ${reason}`,
1436
1509
  )
1437
1510
 
1438
1511
  const differingConfigPaths = (
@@ -1456,10 +1529,7 @@ const differingConfigPaths = (
1456
1529
  return differing
1457
1530
  })
1458
1531
 
1459
- const notClosedError = (
1460
- hash: string,
1461
- outcome: Exclude<Awaited<ReturnType<typeof replayFor>>, { kind: "ended" }>,
1462
- ): Error =>
1532
+ const notClosedError = (hash: string, outcome: UnendedOutcome): Error =>
1463
1533
  outcome.kind === "rest"
1464
1534
  ? new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`)
1465
1535
  : (replayError(outcome) ?? new Error("gtd history: replay failed"))
@@ -1494,30 +1564,52 @@ export const historyTrail = (tip: string): Effect.Effect<HistoryTrail, Error, Re
1494
1564
  Effect.gen(function* () {
1495
1565
  const git = yield* GitService
1496
1566
  const config = yield* (yield* ConfigService).load
1497
- const workspace = yield* Workspace
1498
- const host = yield* Host
1499
1567
  const hash = yield* git.resolveRef(tip)
1500
- const run = yield* closedRunAt(hash)
1501
-
1502
- const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
1503
- if (differing.length > 0) return yield* Effect.fail(configRefusal(hash, differing))
1504
-
1505
- const { setup } = yield* setupFor(config, run, workspace, host)
1506
- yield* workspace.episodeTrees(
1507
- run.episode.base,
1508
- run.episode.commits.map((c) => c.hash),
1509
- )
1510
- const outcome = yield* Effect.promise(() => replayFor(setup))
1511
- if (outcome.kind !== "ended") return yield* Effect.fail(notClosedError(hash, outcome))
1512
-
1513
- const steps = historySteps(run)
1514
- const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version
1515
- const versionWarning = versionWarningFor(recorded)
1568
+ const subject = yield* git.lastCommitSubject(hash)
1569
+ const run = yield* runAtTip(git, config.workflow, { hash, subject })
1570
+ const { outcome, recorded, versionWarning } = yield* replayFinishedProcess(config, run, hash, {
1571
+ command: "gtd history",
1572
+ notEnded: (o) => notClosedError(hash, o),
1573
+ })
1516
1574
  return {
1517
1575
  tip: hash,
1518
- steps,
1576
+ steps: historySteps(run),
1519
1577
  ended: outcome.via,
1520
1578
  version: { recorded, running: GTD_VERSION },
1521
1579
  ...(versionWarning !== undefined ? { versionWarning } : {}),
1522
1580
  }
1523
1581
  })
1582
+
1583
+ interface FinishedReplay {
1584
+ readonly outcome: EndedOutcome
1585
+ readonly recorded: string | undefined
1586
+ readonly versionWarning?: string
1587
+ }
1588
+
1589
+ /** Refuses on config drift before replaying, so a stale config never runs the flow. */
1590
+ const replayFinishedProcess = (
1591
+ config: ConfigOperations,
1592
+ run: ProcessRun,
1593
+ hash: string,
1594
+ how: {
1595
+ readonly command: string
1596
+ readonly configReason?: string
1597
+ readonly notEnded: (outcome: UnendedOutcome) => Error
1598
+ },
1599
+ ): Effect.Effect<FinishedReplay, Error, RestRequirements> =>
1600
+ Effect.gen(function* () {
1601
+ const git = yield* GitService
1602
+ const workspace = yield* Workspace
1603
+ const host = yield* Host
1604
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
1605
+ if (differing.length > 0)
1606
+ return yield* Effect.fail(configRefusal(how.command, hash, differing, how.configReason))
1607
+ const { setup } = yield* setupFor(config, run, workspace, host)
1608
+ const outcome = yield* Effect.promise(() => replayFor(setup))
1609
+ if (outcome.kind !== "ended") return yield* Effect.fail(how.notEnded(outcome))
1610
+ const recorded = parseCommitMessage(
1611
+ run.episode.commits.find((c) => c.hash === hash)?.message ?? "",
1612
+ ).version
1613
+ const versionWarning = versionWarningFor(recorded)
1614
+ return { outcome, recorded, ...(versionWarning !== undefined ? { versionWarning } : {}) }
1615
+ })
package/src/edge/index.ts CHANGED
@@ -14,4 +14,5 @@ export {
14
14
  snapshotFromRest,
15
15
  summaryFor,
16
16
  historyTrail,
17
+ processSlices,
17
18
  } from "./Edge.js"
@@ -7,6 +7,7 @@ import {
7
7
  kept,
8
8
  mark,
9
9
  protocolMismatch,
10
+ slice,
10
11
  type FlowContext,
11
12
  } from "./runtime.js"
12
13
 
@@ -127,3 +128,29 @@ describe("kept()", () => {
127
128
  expect(FLOWS_PROTOCOL).toBe(1)
128
129
  })
129
130
  })
131
+
132
+ describe("slice()", () => {
133
+ const withSlices = () => {
134
+ const calls: string[] = []
135
+ installContext({
136
+ enterSlice: (n: string) => calls.push(`enter ${n}`),
137
+ leaveSlice: () => calls.push("leave"),
138
+ } as unknown as FlowContext)
139
+ return calls
140
+ }
141
+
142
+ it("brackets fn with enter and leave and resolves to its value", async () => {
143
+ const calls = withSlices()
144
+ const value = await slice("refactor", async () => {
145
+ calls.push("fn")
146
+ return 42
147
+ })
148
+ expect(value).toBe(42)
149
+ expect(calls).toEqual(["enter refactor", "fn", "leave"])
150
+ })
151
+
152
+ it("runs fn unsliced on a context without enterSlice", async () => {
153
+ installContext(context)
154
+ expect(await slice("refactor", async () => "ran")).toBe("ran")
155
+ })
156
+ })
@@ -150,6 +150,9 @@ export interface FlowContext {
150
150
  readonly refuse: (message: string) => never
151
151
  readonly pushScope: (scope: ScopeOptions) => void
152
152
  readonly popScope: () => void
153
+ /** Absent on an engine older than `slice`; FLOWS_PROTOCOL stays 1. */
154
+ readonly enterSlice?: (name: string) => void
155
+ readonly leaveSlice?: () => void
153
156
  readonly read: (path: string) => string | undefined
154
157
  readonly glob: (pattern: string) => readonly string[]
155
158
  /** The last step's changes, content read on demand. */
@@ -284,6 +287,21 @@ export const scope = async <T>(scope: string | ScopeOptions, fn: () => Promise<T
284
287
  }
285
288
  }
286
289
 
290
+ /**
291
+ * Mark the span `fn` covers as one named slice of the process. Lands no
292
+ * commit, prefixes no step name, opens no memory scope.
293
+ */
294
+ export const slice = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
295
+ const context = ctx()
296
+ if (typeof context.enterSlice !== "function") return fn()
297
+ context.enterSlice(name)
298
+ try {
299
+ return await fn()
300
+ } finally {
301
+ context.leaveSlice?.()
302
+ }
303
+ }
304
+
287
305
  // ── Reads of the commit replay stands on ────────────────────────────────────
288
306
 
289
307
  export const read = (path: string): string | undefined => ctx().read(path)