@pmelab/gtd 28.0.0 → 28.2.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.
@@ -162,7 +162,7 @@ export type Command =
162
162
  | { readonly kind: "uncheck"; readonly file: string }
163
163
  | { readonly kind: "install" }
164
164
  | { readonly kind: "loop" }
165
- | { readonly kind: "summary" }
165
+ | { readonly kind: "summary"; readonly slice?: string }
166
166
  | { readonly kind: "base" }
167
167
  | { readonly kind: "judge" }
168
168
  | { readonly kind: "judgeAnswer" }
@@ -326,13 +326,20 @@ const planLanding = (
326
326
  * closes or sits inside its own closing message. Writes nothing — no git, no
327
327
  * state transition, no file. Refuses (throws, mapped to the runtime-error
328
328
  * exit code) when the workflow declares no `summary` prompt, or the resolved
329
- * run has no commits to name.
329
+ * run has no commits to name. The edge never narrates: the CLI prints the
330
+ * version warning, before the unknown-slice refusal; a replay refusal carries
331
+ * it in its message.
330
332
  */
331
- const runSummaryCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
333
+ const runSummaryCommand = (
334
+ out: ArtifactOut,
335
+ sliceName: string | undefined,
336
+ ): Effect.Effect<void, Error, CommandRequirements> =>
332
337
  Effect.gen(function* () {
333
338
  const run = yield* summaryRun
334
- const rendered = yield* summaryFor(run)
335
- if (rendered === undefined) {
339
+ const { prompt, versionWarning, unknownSlice } = yield* summaryFor(run, sliceName)
340
+ if (versionWarning !== undefined) yield* (yield* Narrator).warn(versionWarning)
341
+ if (unknownSlice !== undefined) return yield* Effect.fail(new Error(unknownSlice))
342
+ if (prompt === undefined) {
336
343
  return yield* Effect.fail(
337
344
  new Error(
338
345
  `gtd summary: refused — either this workflow declares no "summary:" template, or ` +
@@ -340,7 +347,7 @@ const runSummaryCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
340
347
  ),
341
348
  )
342
349
  }
343
- out.write(rendered.endsWith("\n") ? rendered : rendered + "\n")
350
+ out.write(prompt.endsWith("\n") ? prompt : prompt + "\n")
344
351
  })
345
352
 
346
353
  /** Prints the review anchor hash bare, so an external tool can be pointed at the range under review. Nothing is written. Refuses when the run has no commits, where the hash would name a range with no review. */
@@ -1270,7 +1277,7 @@ const dispatchVoidCommand = (
1270
1277
  case "loop":
1271
1278
  return runLoopCommand()
1272
1279
  case "summary":
1273
- return runSummaryCommand(out)
1280
+ return runSummaryCommand(out, command.slice)
1274
1281
  case "base":
1275
1282
  return runBaseCommand(out)
1276
1283
  case "judge":
@@ -13,6 +13,7 @@ import {
13
13
  reviewBaseFor,
14
14
  snapshotFromRest,
15
15
  stalledAt,
16
+ summaryFor,
16
17
  summaryRun,
17
18
  TRUNCATION_NOTICE,
18
19
  UNATTRIBUTED_MODEL,
@@ -278,9 +279,9 @@ export const again = async () => {
278
279
  )
279
280
  const run = await provide(currentRun, repo)
280
281
  expect(run.costEntries).toEqual([
281
- { cost: 120, model: "opus" },
282
- { cost: 50, model: UNATTRIBUTED_MODEL },
283
- { cost: 300, model: "haiku" },
282
+ { cost: 120, model: "opus", hash: run.trace[0]!.hash },
283
+ { cost: 50, model: UNATTRIBUTED_MODEL, hash: run.trace[1]!.hash },
284
+ { cost: 300, model: "haiku", hash: run.trace[2]!.hash },
284
285
  ])
285
286
  })
286
287
 
@@ -761,7 +762,262 @@ describe("processSlices", () => {
761
762
  })
762
763
  })
763
764
 
765
+ describe("summaryFor", () => {
766
+ const SLICED = `import { agent, human, slice, mark } from "@pmelab/gtd/flows"
767
+
768
+ export default async () => {
769
+ await human("idle", { message: "idle-message" })
770
+ mark("risk-tier", "high")
771
+ await slice("build", async () => {
772
+ await agent("building", "build-prompt")
773
+ })
774
+ await human("gate", { message: "gate-message" })
775
+ await slice("verify", async () => {
776
+ await agent("checking", "check-prompt")
777
+ })
778
+ }
779
+
780
+ export const summary = (it) => JSON.stringify(it)
781
+ `
782
+ const landCost = async (repo: InMemRepo, files: Files, cost: number, model: string) => {
783
+ write(repo, files)
784
+ const { stdout, stderr, exitCode } = await cli(
785
+ repo,
786
+ "land",
787
+ "--json",
788
+ `--cost=${cost}`,
789
+ `--model=${model}`,
790
+ )
791
+ if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
792
+ return applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
793
+ }
794
+ type Rendered = {
795
+ entryCommit: string
796
+ processBase: string
797
+ processTip: string
798
+ humanCommits: { hash: string; state: string }[]
799
+ processCost: number
800
+ processCostByModel: { model: string; cost: number }[]
801
+ marks: Mark[]
802
+ slice?: unknown
803
+ }
804
+
805
+ /** Entry (human, idle→building), build agent, human gate, verify agent closing. */
806
+ const finished = async () => {
807
+ const repo = repoWith(SLICED)
808
+ const boundary = headOf(repo)
809
+ const entry = await landCost(repo, { "NOTE.md": "n\n" }, 1, "opus")
810
+ const built = await landCost(repo, { "a.txt": "a\n" }, 10, "opus")
811
+ const gate = await landCost(repo, { "b.txt": "b\n" }, 100, "haiku")
812
+ const closing = await landCost(repo, { "c.txt": "c\n" }, 1000, "haiku")
813
+ return { repo, boundary, entry, built, gate, closing }
814
+ }
815
+ const render = async (repo: InMemRepo, sliceName?: string) => {
816
+ const run = await provide(summaryRun, repo)
817
+ const { prompt } = await provide(summaryFor(run, sliceName), repo)
818
+ return JSON.parse(prompt!) as Rendered
819
+ }
820
+
821
+ it("resolves each slice with its range, 1-based index and count", async () => {
822
+ const { repo, boundary, built, closing } = await finished()
823
+ expect((await render(repo, "build")).slice).toEqual({
824
+ name: "build",
825
+ base: boundary,
826
+ tip: built,
827
+ index: 1,
828
+ count: 2,
829
+ })
830
+ expect((await render(repo, "verify")).slice).toEqual({
831
+ name: "verify",
832
+ base: built,
833
+ tip: closing,
834
+ index: 2,
835
+ count: 2,
836
+ })
837
+ })
838
+
839
+ it("renders no slice key for the plain call", async () => {
840
+ const { repo } = await finished()
841
+ expect("slice" in (await render(repo))).toBe(false)
842
+ })
843
+
844
+ it("narrows range and human commits to the slice's window, base exclusive and tip inclusive", async () => {
845
+ const { repo, boundary, built, gate, closing } = await finished()
846
+ const whole = await render(repo)
847
+ expect(whole.humanCommits.map((c) => c.hash)).toEqual([gate])
848
+ const build = await render(repo, "build")
849
+ const verify = await render(repo, "verify")
850
+ // the gate commit falls in the gap between the slices and folds into the later one
851
+ expect([build.processBase, build.processTip]).toEqual([boundary, built])
852
+ expect([verify.processBase, verify.processTip]).toEqual([built, closing])
853
+ expect(build.humanCommits).toEqual([])
854
+ expect(verify.humanCommits.map((c) => c.hash)).toEqual([gate])
855
+ })
856
+
857
+ it("splits cost by slice so the parts sum to the whole, gap commits included", async () => {
858
+ const { repo } = await finished()
859
+ const whole = await render(repo)
860
+ const build = await render(repo, "build")
861
+ const verify = await render(repo, "verify")
862
+ expect(whole.processCost).toBe(1111)
863
+ expect(build.processCost + verify.processCost).toBe(whole.processCost)
864
+ expect(build.processCost).toBe(11)
865
+ expect(verify.processCost).toBe(1100)
866
+ const byModel = new Map<string, number>()
867
+ for (const part of [build, verify])
868
+ for (const m of part.processCostByModel)
869
+ byModel.set(m.model, (byModel.get(m.model) ?? 0) + m.cost)
870
+ expect([...byModel.entries()].sort()).toEqual(
871
+ whole.processCostByModel.map((m) => [m.model, m.cost] as const).sort(),
872
+ )
873
+ })
874
+
875
+ it("keeps entryCommit and marks whole-process under a slice", async () => {
876
+ const { repo, entry } = await finished()
877
+ const whole = await render(repo)
878
+ const verify = await render(repo, "verify")
879
+ expect(verify.entryCommit).toBe(entry)
880
+ expect(verify.entryCommit).toBe(whole.entryCommit)
881
+ expect(verify.marks).toEqual(whole.marks)
882
+ expect(verify.marks.length).toBeGreaterThan(0)
883
+ })
884
+
885
+ it("refuses a named slice on an empty trace, while the plain call has nothing to render", async () => {
886
+ const repo = repoWith(SLICED)
887
+ const run = await provide(summaryRun, repo)
888
+ const exit = await provideExit(summaryFor(run, "build"), repo)
889
+ expect(Exit.isFailure(exit) ? String(exit.cause) : "").toContain(
890
+ "gtd summary: refused — HEAD neither closes nor sits inside a process",
891
+ )
892
+ expect(await provide(summaryFor(run), repo)).toEqual({ prompt: undefined })
893
+ })
894
+
895
+ it("refuses config drift at a closing HEAD with the summary prefix", async () => {
896
+ const { repo } = await finished()
897
+ const run = await provide(summaryRun, repo)
898
+ repo.writeFile("gtd.config.ts", `${SLICED}\n// edited\n`)
899
+ const exit = await provideExit(summaryFor(run, "build"), repo)
900
+ const message = Exit.isFailure(exit) ? String(exit.cause) : ""
901
+ expect(message).toContain("gtd summary: refused — config differs at")
902
+ expect(message).toContain("revert or stash")
903
+ })
904
+
905
+ it.each([
906
+ { recorded: GTD_VERSION, warned: false },
907
+ { recorded: "0.0.1", warned: true },
908
+ ])(
909
+ "refuses a closed process that does not end under today's config (recorded $recorded)",
910
+ async ({ recorded, warned }) => {
911
+ const { repo } = await finished()
912
+ const tip = withTipMessage(repo, (m) =>
913
+ m.replace(`Gtd-Version: ${GTD_VERSION}`, `Gtd-Version: ${recorded}`),
914
+ )
915
+ const run = await Effect.runPromise(
916
+ summaryRun.pipe(
917
+ Effect.provideService(GitService, tip),
918
+ Effect.provide(testLayers(repo, {})),
919
+ ),
920
+ )
921
+ repo.writeFile(
922
+ "gtd.config.ts",
923
+ SLICED.replace(
924
+ 'await agent("checking", "check-prompt")',
925
+ 'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
926
+ ),
927
+ )
928
+ // Every ref reads as the working tree: only replay divergence remains.
929
+ const git: GitOperations = {
930
+ ...tip,
931
+ readFileAtRef: (_ref, path) => Effect.succeed(repo.readFile(path) ?? ""),
932
+ }
933
+ const exit = await Effect.runPromiseExit(
934
+ summaryFor(run, "build").pipe(
935
+ Effect.provideService(GitService, git),
936
+ Effect.provide(testLayers(repo, {})),
937
+ ),
938
+ )
939
+ const message = Exit.isFailure(exit) ? String(exit.cause) : ""
940
+ expect(message).toContain(
941
+ `gtd summary: refused — ${repo.resolveRef("HEAD")!.slice(0, 7)} closes a process that does not end under today's config`,
942
+ )
943
+ if (warned) expect(message).toContain(`recorded by gtd ${recorded}`)
944
+ else expect(message).not.toContain("recorded by gtd")
945
+ },
946
+ )
947
+
948
+ it("returns no prompt, not a failure, when an edited config no longer exports a summary", async () => {
949
+ const { repo } = await finished()
950
+ const run = await provide(summaryRun, repo)
951
+ repo.writeFile("gtd.config.ts", SLICED.replace(/export const summary.*\n/, ""))
952
+ expect(await provide(summaryFor(run, "build"), repo)).toEqual({ prompt: undefined })
953
+ })
954
+
955
+ it("plain summaryFor still returns a prompt under a config edited since the close, never replaying", async () => {
956
+ const { repo } = await finished()
957
+ const run = await provide(summaryRun, repo)
958
+ repo.writeFile("gtd.config.ts", `${SLICED}\n// edited\n`)
959
+ expect((await provide(summaryFor(run), repo)).prompt).toBeDefined()
960
+ })
961
+
962
+ it("returns a version warning for a closing commit by another gtd version, not a failure", async () => {
963
+ const { repo } = await finished()
964
+ const git = withTipMessage(repo, (m) =>
965
+ m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
966
+ )
967
+ const result = await Effect.runPromise(
968
+ Effect.flatMap(summaryRun, (run) => summaryFor(run, "build")).pipe(
969
+ Effect.provideService(GitService, git),
970
+ Effect.provide(testLayers(repo, {})),
971
+ ),
972
+ )
973
+ expect(result.versionWarning).toContain("0.0.1")
974
+ expect(result.prompt).toBeDefined()
975
+ })
976
+
977
+ it("an unknown slice name returns unknownSlice naming every slice, and keeps the version warning", async () => {
978
+ const { repo } = await finished()
979
+ const git = withTipMessage(repo, (m) =>
980
+ m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
981
+ )
982
+ const result = await Effect.runPromise(
983
+ Effect.flatMap(summaryRun, (run) => summaryFor(run, "nope")).pipe(
984
+ Effect.provideService(GitService, git),
985
+ Effect.provide(testLayers(repo, {})),
986
+ ),
987
+ )
988
+ expect(result.prompt).toBeUndefined()
989
+ expect(result.unknownSlice).toContain('no slice named "nope"')
990
+ expect(result.unknownSlice).toContain("build, verify")
991
+ expect(result.versionWarning).toContain("0.0.1")
992
+ })
993
+ })
994
+
764
995
  describe("restAt", () => {
996
+ it("totals recorded cost and breaks it down by model, highest first", async () => {
997
+ const repo = repoWith(LINEAR)
998
+ for (const [file, cost, model] of [
999
+ ["a.txt", 1, "opus"],
1000
+ ["b.txt", 10, "haiku"],
1001
+ ] as const) {
1002
+ write(repo, { [file]: "x\n" })
1003
+ const { stdout, exitCode } = await cli(
1004
+ repo,
1005
+ "land",
1006
+ "--json",
1007
+ `--cost=${cost}`,
1008
+ `--model=${model}`,
1009
+ )
1010
+ expect(exitCode).toBe(0)
1011
+ await applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
1012
+ }
1013
+ const { cost } = await provide(currentRest, repo)
1014
+ expect(cost.total).toBe(11)
1015
+ expect(cost.byModel).toEqual([
1016
+ { model: "haiku", cost: 10 },
1017
+ { model: "opus", cost: 1 },
1018
+ ])
1019
+ })
1020
+
765
1021
  it("restAt(ref) resolves where the process rested at that commit", async () => {
766
1022
  const repo = repoWith(LINEAR)
767
1023
  const boundary = headOf(repo)
package/src/edge/Edge.ts CHANGED
@@ -45,7 +45,7 @@ import {
45
45
  type ReplayOutcome,
46
46
  type TreeView,
47
47
  } from "../replay/index.js"
48
- import type { Mark, ScopeAccess } from "../flows/index.js"
48
+ import type { Mark, ScopeAccess, SummaryContext, SummarySlice } from "../flows/index.js"
49
49
  import { steeringFormatFor } from "../steering/index.js"
50
50
  import {
51
51
  judgeJson,
@@ -126,6 +126,8 @@ const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocati
126
126
  export interface CostEntry {
127
127
  readonly cost: number
128
128
  readonly model: string
129
+ /** The process commit whose `Gtd-Cost` trailer recorded it. */
130
+ readonly hash: string
129
131
  }
130
132
 
131
133
  export interface JudgeVerdictEntry {
@@ -203,8 +205,8 @@ const traceEntryOf = (
203
205
  actor: message.parsed?.actor ?? "",
204
206
  })
205
207
 
206
- const costEntriesOf = (message: ReturnType<typeof parseCommitMessage>): CostEntry[] =>
207
- message.cost.map((c) => ({ cost: c.cost, model: c.model ?? UNATTRIBUTED_MODEL }))
208
+ const costEntriesOf = (hash: string, message: ReturnType<typeof parseCommitMessage>): CostEntry[] =>
209
+ message.cost.map((c) => ({ cost: c.cost, model: c.model ?? UNATTRIBUTED_MODEL, hash }))
208
210
 
209
211
  const hashAt = (history: History, index: number): string | undefined => history[index]?.hash
210
212
 
@@ -278,7 +280,7 @@ const buildRun = (
278
280
  startParentHash,
279
281
  diffBase: first?.reviewBase ?? startParentHash,
280
282
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
281
- costEntries: parsed.flatMap(costEntriesOf),
283
+ costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
282
284
  judgeVerdicts: parsed.flatMap((m) => m.judge),
283
285
  marks: parsed.flatMap((m) => m.marks),
284
286
  approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
@@ -904,6 +906,11 @@ const costByModel = (entries: readonly CostEntry[]): ModelCost[] => {
904
906
  .sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model))
905
907
  }
906
908
 
909
+ const costTotals = (entries: readonly CostEntry[]): Rest["cost"] => ({
910
+ total: entries.reduce((sum, entry) => sum + entry.cost, 0),
911
+ byModel: costByModel(entries),
912
+ })
913
+
907
914
  export type RestRequirements = ConfigRequirements
908
915
 
909
916
  const setupFor = (
@@ -1061,10 +1068,7 @@ export const restAt = (
1061
1068
  memory: memory.key,
1062
1069
  memoryResumed: memory.resumed,
1063
1070
  hints: hintsOf(stepDef),
1064
- cost: {
1065
- total: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
1066
- byModel: costByModel(run.costEntries),
1067
- },
1071
+ cost: costTotals(run.costEntries),
1068
1072
  setup,
1069
1073
  }
1070
1074
  })
@@ -1076,21 +1080,42 @@ export interface ProcessSlices {
1076
1080
  readonly versionWarning?: string
1077
1081
  }
1078
1082
 
1083
+ type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
1084
+ type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
1085
+
1086
+ /** Every slice refusal reads `<command>: refused — <reason>`; replay's own `gtd: ` lead is dropped. */
1087
+ const slicesDivergence = (command: string, run: ProcessRun, outcome: ReplayOutcome): Error => {
1088
+ const reason =
1089
+ outcome.kind === "rest" && run.closingHash !== undefined
1090
+ ? `${run.closingHash.slice(0, 7)} closes a process that does not end under today's config`
1091
+ : (replayError(outcome)?.message.replace(/^gtd: /, "") ?? "replay failed")
1092
+ return new Error(`${command}: refused — ${reason}`)
1093
+ }
1094
+
1079
1095
  /**
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.
1096
+ * The replay `gtd slices` and `gtd summary --slice` share: the slice ranges of
1097
+ * `run`, plus whether HEAD closes it. Reads like `historyTrail`, never
1098
+ * `restAt`: it narrates and writes nothing.
1083
1099
  */
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
1100
+ const replayedSlices = (
1101
+ config: ConfigOperations,
1102
+ run: ProcessRun,
1103
+ how: { readonly command: string; readonly configReason: string },
1104
+ ): Effect.Effect<
1105
+ {
1106
+ readonly ranges: ReturnType<typeof sliceRangesOf>
1107
+ readonly closed: boolean
1108
+ readonly versionWarning?: string
1109
+ },
1110
+ Error,
1111
+ RestRequirements
1112
+ > =>
1113
+ Effect.gen(function* () {
1088
1114
  const workspace = yield* Workspace
1089
1115
  const host = yield* Host
1090
- const run = yield* runAtHead(git, config.workflow)
1091
1116
  if (run.trace.length === 0) {
1092
1117
  return yield* Effect.fail(
1093
- new Error("gtd slices: refused — HEAD neither closes nor sits inside a process"),
1118
+ new Error(`${how.command}: refused — HEAD neither closes nor sits inside a process`),
1094
1119
  )
1095
1120
  }
1096
1121
  const closing = run.closingHash
@@ -1098,42 +1123,51 @@ export const processSlices: Effect.Effect<ProcessSlices, Error, RestRequirements
1098
1123
  let versionWarning: string | undefined
1099
1124
  if (closing !== undefined) {
1100
1125
  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),
1126
+ command: how.command,
1127
+ configReason: how.configReason,
1128
+ notEnded: (o) => slicesDivergence(how.command, run, o),
1105
1129
  })
1106
1130
  outcome = replayed.outcome
1107
1131
  versionWarning = replayed.versionWarning
1108
1132
  } else {
1109
1133
  const { setup } = yield* setupFor(config, run, workspace, host)
1110
1134
  outcome = yield* Effect.promise(() => replayFor(setup))
1111
- if (outcome.kind !== "rest") return yield* Effect.fail(slicesDivergence(run, outcome))
1135
+ if (outcome.kind !== "rest")
1136
+ return yield* Effect.fail(slicesDivergence(how.command, run, outcome))
1112
1137
  }
1113
1138
  return {
1114
- slices: sliceRangesOf(outcome.slices, run).map(({ name, base, tip, open }) => ({
1139
+ ranges: sliceRangesOf(outcome.slices, run),
1140
+ closed: closing !== undefined,
1141
+ ...(versionWarning !== undefined ? { versionWarning } : {}),
1142
+ }
1143
+ })
1144
+
1145
+ /**
1146
+ * The slices of the process HEAD closes or sits inside, current episode only,
1147
+ * stack order. Returns a version warning for a closed run.
1148
+ */
1149
+ export const processSlices: Effect.Effect<ProcessSlices, Error, RestRequirements> = Effect.gen(
1150
+ function* () {
1151
+ const git = yield* GitService
1152
+ const config = yield* (yield* ConfigService).load
1153
+ const run = yield* runAtHead(git, config.workflow)
1154
+ const { ranges, closed, versionWarning } = yield* replayedSlices(config, run, {
1155
+ command: "gtd slices",
1156
+ configReason:
1157
+ "HEAD's process replays under today's config; revert or stash the edit to list its slices",
1158
+ })
1159
+ return {
1160
+ slices: ranges.map(({ name, base, tip, open }) => ({
1115
1161
  name,
1116
1162
  base,
1117
1163
  tip,
1118
- closed: closing !== undefined || !open,
1164
+ closed: closed || !open,
1119
1165
  })),
1120
1166
  ...(versionWarning !== undefined ? { versionWarning } : {}),
1121
1167
  }
1122
1168
  },
1123
1169
  )
1124
1170
 
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
-
1137
1171
  /**
1138
1172
  * Whether `gtd --workflow <name>` can start a process here: the workflow must
1139
1173
  * reach a step from the tree the opening commit would capture. That step is
@@ -1440,29 +1474,107 @@ export const snapshotFromRest = (
1440
1474
 
1441
1475
  // ── Summary ─────────────────────────────────────────────────────────────────
1442
1476
 
1443
- /** `gtd summary`'s prompt for `run`, or `undefined` when the workflow has none or there is nothing to summarize. */
1477
+ const narrowToSlice = (
1478
+ run: ProcessRun,
1479
+ slice: SummarySlice | undefined,
1480
+ ): Pick<ProcessRun, "trace" | "costEntries"> => {
1481
+ if (slice === undefined) return { trace: run.trace, costEntries: run.costEntries }
1482
+ // `base` is exclusive: -1 when it is the episode's start parent, before the first entry.
1483
+ const from =
1484
+ slice.base === run.startParentHash ? -1 : run.trace.findIndex((e) => e.hash === slice.base)
1485
+ const to = run.trace.findIndex((e) => e.hash === slice.tip)
1486
+ const trace = run.trace.slice(from + 1, to + 1)
1487
+ const hashes = new Set(trace.map((e) => e.hash))
1488
+ return { trace, costEntries: run.costEntries.filter((c) => hashes.has(c.hash)) }
1489
+ }
1490
+
1491
+ interface SummaryResult {
1492
+ readonly prompt: string | undefined
1493
+ readonly versionWarning?: string
1494
+ readonly unknownSlice?: string
1495
+ }
1496
+
1497
+ const humanCommitsOf = (
1498
+ trace: ProcessRun["trace"],
1499
+ entryCommit: string,
1500
+ ): { hash: string; state: StateName }[] =>
1501
+ trace
1502
+ .filter((entry) => entry.actor === "human" && entry.hash !== entryCommit)
1503
+ .map((entry) => ({ hash: entry.hash, state: entry.state }))
1504
+
1505
+ const summaryInput = (
1506
+ run: ProcessRun,
1507
+ slice: SummarySlice | undefined,
1508
+ settings: Pick<SummaryContext, "vars" | "env">,
1509
+ ) => {
1510
+ const entryCommit = run.trace[0]!.hash
1511
+ const { trace, costEntries } = narrowToSlice(run, slice)
1512
+ const { total, byModel } = costTotals(costEntries)
1513
+ return {
1514
+ entryCommit,
1515
+ processBase: slice?.base ?? run.startParentHash,
1516
+ processTip: slice?.tip ?? run.trace[run.trace.length - 1]!.hash,
1517
+ humanCommits: humanCommitsOf(trace, entryCommit),
1518
+ processCost: total,
1519
+ processCostByModel: byModel,
1520
+ marks: run.marks,
1521
+ ...settings,
1522
+ ...(slice !== undefined ? { slice } : {}),
1523
+ }
1524
+ }
1525
+
1526
+ const locateSlice = (
1527
+ config: ConfigOperations,
1528
+ run: ProcessRun,
1529
+ sliceName: string | undefined,
1530
+ ): Effect.Effect<
1531
+ | { readonly slice?: SummarySlice; readonly versionWarning?: string }
1532
+ | { readonly unknownSlice: string; readonly versionWarning?: string },
1533
+ Error,
1534
+ RestRequirements
1535
+ > =>
1536
+ Effect.gen(function* () {
1537
+ if (sliceName === undefined) return {}
1538
+ const replayed = yield* replayedSlices(config, run, {
1539
+ command: "gtd summary",
1540
+ configReason:
1541
+ "HEAD's process replays under today's config; revert or stash the edit to summarize its slices",
1542
+ })
1543
+ const { ranges, versionWarning } = replayed
1544
+ const warn = versionWarning !== undefined ? { versionWarning } : {}
1545
+ const at = ranges.findIndex((r) => r.name === sliceName)
1546
+ if (at < 0) {
1547
+ return {
1548
+ unknownSlice: `gtd summary: refused — no slice named "${sliceName}"; the slices, in stack order: ${ranges.map((r) => r.name).join(", ")}`,
1549
+ ...warn,
1550
+ }
1551
+ }
1552
+ const { base, tip } = ranges[at]!
1553
+ return { slice: { name: sliceName, base, tip, index: at + 1, count: ranges.length }, ...warn }
1554
+ })
1555
+
1556
+ /**
1557
+ * A plain call never replays; under a slice, `entryCommit` and `marks` stay
1558
+ * whole-process.
1559
+ */
1444
1560
  export const summaryFor = (
1445
1561
  run: ProcessRun,
1446
- ): Effect.Effect<string | undefined, Error, RestRequirements> =>
1562
+ sliceName?: string,
1563
+ ): Effect.Effect<SummaryResult, Error, RestRequirements> =>
1447
1564
  Effect.gen(function* () {
1448
1565
  const config = yield* (yield* ConfigService).load
1449
1566
  const host = yield* Host
1450
1567
  const resolved = yield* workflowOf(config, run)
1451
1568
  const summary = resolved.def.summary
1452
- if (summary === undefined || run.trace.length === 0) return undefined
1453
- const entryCommit = run.trace[0]!.hash
1454
- return summary({
1455
- entryCommit,
1456
- processBase: run.startParentHash,
1457
- processTip: run.trace[run.trace.length - 1]!.hash,
1458
- humanCommits: run.trace
1459
- .filter((entry) => entry.actor === "human" && entry.hash !== entryCommit)
1460
- .map((entry) => ({ hash: entry.hash, state: entry.state })),
1461
- processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
1462
- processCostByModel: costByModel(run.costEntries),
1463
- marks: run.marks,
1464
- ...settingsFor(resolved, run.pinnedVars, host.env, {}, {}),
1465
- })
1569
+ if (summary === undefined) return { prompt: undefined }
1570
+ if (sliceName === undefined && run.trace.length === 0) return { prompt: undefined }
1571
+ const located = yield* locateSlice(config, run, sliceName)
1572
+ if ("unknownSlice" in located) return { prompt: undefined, ...located }
1573
+ const { slice, versionWarning } = located
1574
+ const prompt = summary(
1575
+ summaryInput(run, slice, settingsFor(resolved, run.pinnedVars, host.env, {}, {})),
1576
+ )
1577
+ return { prompt, ...(versionWarning !== undefined ? { versionWarning } : {}) }
1466
1578
  })
1467
1579
 
1468
1580
  export interface HistoryStep {
@@ -1601,15 +1713,20 @@ const replayFinishedProcess = (
1601
1713
  const git = yield* GitService
1602
1714
  const workspace = yield* Workspace
1603
1715
  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
1716
  const recorded = parseCommitMessage(
1611
1717
  run.episode.commits.find((c) => c.hash === hash)?.message ?? "",
1612
1718
  ).version
1613
1719
  const versionWarning = versionWarningFor(recorded)
1720
+ // A refusal is often caused by the version change, so it carries the warning.
1721
+ const withWarning = (err: Error): Error =>
1722
+ versionWarning === undefined ? err : new Error(`${err.message}\n${versionWarning}`)
1723
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
1724
+ if (differing.length > 0)
1725
+ return yield* Effect.fail(
1726
+ withWarning(configRefusal(how.command, hash, differing, how.configReason)),
1727
+ )
1728
+ const { setup } = yield* setupFor(config, run, workspace, host)
1729
+ const outcome = yield* Effect.promise(() => replayFor(setup))
1730
+ if (outcome.kind !== "ended") return yield* Effect.fail(withWarning(how.notEnded(outcome)))
1614
1731
  return { outcome, recorded, ...(versionWarning !== undefined ? { versionWarning } : {}) }
1615
1732
  })