@pmelab/gtd 27.7.1 → 27.8.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "27.7.1",
3
+ "version": "27.8.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -649,7 +649,7 @@ gtd is one package, `@pmelab/gtd`, and every entrypoint shares its version and
649
649
 
650
650
  | Import | What it is |
651
651
  | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
652
- | `@pmelab/gtd/flows` | The flow API a workflow is written in: the steps (`agent`, `human`, `run`, …), workflow factories, `defineConfig` and `FLOWS_PROTOCOL`. |
652
+ | `@pmelab/gtd/flows` | The flow API a workflow is written in: the steps (`agent`, `human`, `run`, …), `scope` and `slice` (named spans of a process), workflow factories, `defineConfig` and `FLOWS_PROTOCOL`. |
653
653
  | `@pmelab/gtd/wire` | The driver protocol: `WIRE_SCHEMA`, the exit codes and the types of every `--json` document. |
654
654
  | `@pmelab/gtd/steering` | The steering-file formats (`qa`, `review`) and the review-note readers. |
655
655
  | `@pmelab/gtd/workflow-kit` | Policy-free building blocks to compose a workflow from: health checks, quality laps, scenario freezing, review tails. |
package/dist/flows.mjs CHANGED
@@ -86,6 +86,20 @@ const scope = async (scope, fn) => {
86
86
  context.popScope();
87
87
  }
88
88
  };
89
+ /**
90
+ * Mark the span `fn` covers as one named slice of the process. Lands no
91
+ * commit, prefixes no step name, opens no memory scope.
92
+ */
93
+ const slice = async (name, fn) => {
94
+ const context = ctx();
95
+ if (typeof context.enterSlice !== "function") return fn();
96
+ context.enterSlice(name);
97
+ try {
98
+ return await fn();
99
+ } finally {
100
+ context.leaveSlice?.();
101
+ }
102
+ };
89
103
  const read = (path) => ctx().read(path);
90
104
  /** Every path in the tree matching `pattern` (`*` stays within a segment, `**` crosses them). */
91
105
  const glob = (pattern) => ctx().glob(pattern);
@@ -652,10 +666,11 @@ var flows_exports = /* @__PURE__ */ __exportAll({
652
666
  sections: () => sections,
653
667
  showScript: () => showScript,
654
668
  skillsFor: () => skillsFor,
669
+ slice: () => slice,
655
670
  start: () => start,
656
671
  threads: () => threads,
657
672
  vars: () => vars,
658
673
  wrote: () => wrote
659
674
  });
660
675
  //#endregion
661
- export { FLOWS_PROTOCOL, FlowsProtocolError, accessFor, agent, answered, changes, changesSince, check, checkScript, codeChanges, codeThreads, configProblems, configure, defineConfig, defineWorkflow, displayPath, env, glob, hasThreadFor, head, human, installContext, isFactoryWorkflow, judge, judgeDiffShape, kept, mark, matches, moveScript, numeric, openQuestions, protocolMismatch, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restart, restoreScript, revertScript, run, scope, seamGate, sectionBodies, sections, showScript, skillsFor, start, flows_exports as t, threads, vars, wrote };
676
+ export { FLOWS_PROTOCOL, FlowsProtocolError, accessFor, agent, answered, changes, changesSince, check, checkScript, codeChanges, codeThreads, configProblems, configure, defineConfig, defineWorkflow, displayPath, env, glob, hasThreadFor, head, human, installContext, isFactoryWorkflow, judge, judgeDiffShape, kept, mark, matches, moveScript, numeric, openQuestions, protocolMismatch, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restart, restoreScript, revertScript, run, scope, seamGate, sectionBodies, sections, showScript, skillsFor, slice, start, flows_exports as t, threads, vars, wrote };
@@ -21724,6 +21724,20 @@ const scope = async (scope, fn) => {
21724
21724
  context.popScope();
21725
21725
  }
21726
21726
  };
21727
+ /**
21728
+ * Mark the span `fn` covers as one named slice of the process. Lands no
21729
+ * commit, prefixes no step name, opens no memory scope.
21730
+ */
21731
+ const slice = async (name, fn) => {
21732
+ const context = ctx();
21733
+ if (typeof context.enterSlice !== "function") return fn();
21734
+ context.enterSlice(name);
21735
+ try {
21736
+ return await fn();
21737
+ } finally {
21738
+ context.leaveSlice?.();
21739
+ }
21740
+ };
21727
21741
  const read = (path) => ctx().read(path);
21728
21742
  /** Every path in the tree matching `pattern` (`*` stays within a segment, `**` crosses them). */
21729
21743
  const glob = (pattern) => ctx().glob(pattern);
@@ -22290,6 +22304,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
22290
22304
  sections: () => sections,
22291
22305
  showScript: () => showScript,
22292
22306
  skillsFor: () => skillsFor,
22307
+ slice: () => slice,
22293
22308
  start: () => start,
22294
22309
  threads: () => threads,
22295
22310
  vars: () => vars,
@@ -37670,6 +37685,32 @@ const refNameFault = (name) => {
37670
37685
  }
37671
37686
  };
37672
37687
  //#endregion
37688
+ //#region src/replay/Slices.ts
37689
+ const IMPLICIT_SLICE = "main";
37690
+ /** Same shape as mark()'s name rule; kept local so @pmelab/gtd/flows publishes no regex. */
37691
+ const SLICE_NAME = /^[a-z][a-z0-9-]*$/;
37692
+ /**
37693
+ * Folds boundaries into ranges: each kept span's base is the previous kept
37694
+ * span's `leftAt`, so a gap before a span belongs to it, and the last span
37695
+ * runs to the episode tip. A closed span that landed nothing is dropped.
37696
+ */
37697
+ const sliceRanges = (spans, episode) => {
37698
+ if (spans.some((s) => s.enteredAt === "" || s.leftAt === "")) throw new Error("sliceRanges: a pending turn's \"\" hash is not a commit");
37699
+ const kept = spans.filter((s) => s.leftAt === void 0 || s.enteredAt !== s.leftAt);
37700
+ if (kept.length === 0) return [{
37701
+ name: IMPLICIT_SLICE,
37702
+ base: episode.base,
37703
+ tip: episode.tip,
37704
+ open: true
37705
+ }];
37706
+ return kept.map((s, i) => ({
37707
+ name: s.name,
37708
+ base: i === 0 ? episode.base : kept[i - 1].leftAt ?? episode.base,
37709
+ tip: i === kept.length - 1 ? episode.tip : s.leftAt ?? episode.tip,
37710
+ open: i === kept.length - 1
37711
+ }));
37712
+ };
37713
+ //#endregion
37673
37714
  //#region src/replay/Tree.ts
37674
37715
  const treeFromRecord = (files) => ({
37675
37716
  paths: () => Object.keys(files).sort(),
@@ -38017,6 +38058,7 @@ const replay = async (input) => {
38017
38058
  const keptTips = /* @__PURE__ */ new Map();
38018
38059
  let landed;
38019
38060
  const marks = [];
38061
+ const slices = [];
38020
38062
  let outcome;
38021
38063
  let signal;
38022
38064
  const settled = new Promise((resolve) => {
@@ -38223,7 +38265,8 @@ const replay = async (input) => {
38223
38265
  rest: step,
38224
38266
  trace,
38225
38267
  landed,
38226
- marks
38268
+ marks,
38269
+ slices: slices.map((s) => ({ ...s }))
38227
38270
  });
38228
38271
  return landPending(step, input.pending);
38229
38272
  };
@@ -38287,6 +38330,31 @@ const replay = async (input) => {
38287
38330
  scopes.pop();
38288
38331
  scopeCallIds.pop();
38289
38332
  },
38333
+ enterSlice: (name) => {
38334
+ if (outcome !== void 0) return;
38335
+ const fail = (message) => {
38336
+ finish({
38337
+ kind: "failed",
38338
+ message
38339
+ });
38340
+ throw new Stop();
38341
+ };
38342
+ if (typeof name !== "string" || !SLICE_NAME.test(name)) fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`);
38343
+ if (name === "main") fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`);
38344
+ const open = slices.find((s) => s.leftAt === void 0);
38345
+ if (open !== void 0) fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`);
38346
+ if (slices.some((s) => s.name === name)) fail(`gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`);
38347
+ slices.push({
38348
+ name,
38349
+ enteredAt: position.hash,
38350
+ leftAt: void 0
38351
+ });
38352
+ },
38353
+ leaveSlice: () => {
38354
+ if (outcome !== void 0) return;
38355
+ const open = slices.find((s) => s.leftAt === void 0);
38356
+ if (open !== void 0) open.leftAt = position.hash;
38357
+ },
38290
38358
  read: (path) => position.tree.read(path),
38291
38359
  glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
38292
38360
  changes: () => changesBetween(previousPosition.tree, position.tree),
@@ -39976,6 +40044,10 @@ const pendingChanges = (git, withIndex) => gen(function* () {
39976
40044
  const seen = new Set(worktree.map((e) => e.path));
39977
40045
  return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
39978
40046
  });
40047
+ const sliceRangesOf = (slices, run) => sliceRanges(slices, {
40048
+ base: run.startParentHash,
40049
+ tip: run.trace.at(-1)?.hash ?? run.startParentHash
40050
+ });
39979
40051
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
39980
40052
  const restAt = (ref, startOverrides = {}) => gen(function* () {
39981
40053
  const git = yield* GitService;
@@ -40018,7 +40090,8 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
40018
40090
  total: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
40019
40091
  byModel: costByModel(run.costEntries)
40020
40092
  },
40021
- setup
40093
+ setup,
40094
+ slices: sliceRangesOf(outcome.slices, run)
40022
40095
  };
40023
40096
  });
40024
40097
  const currentRest = restAt(void 0);
package/dist/testkit.mjs CHANGED
@@ -6817,6 +6817,32 @@ const refNameFault = (name) => {
6817
6817
  }
6818
6818
  };
6819
6819
  //#endregion
6820
+ //#region src/replay/Slices.ts
6821
+ const IMPLICIT_SLICE = "main";
6822
+ /** Same shape as mark()'s name rule; kept local so @pmelab/gtd/flows publishes no regex. */
6823
+ const SLICE_NAME = /^[a-z][a-z0-9-]*$/;
6824
+ /**
6825
+ * Folds boundaries into ranges: each kept span's base is the previous kept
6826
+ * span's `leftAt`, so a gap before a span belongs to it, and the last span
6827
+ * runs to the episode tip. A closed span that landed nothing is dropped.
6828
+ */
6829
+ const sliceRanges = (spans, episode) => {
6830
+ if (spans.some((s) => s.enteredAt === "" || s.leftAt === "")) throw new Error("sliceRanges: a pending turn's \"\" hash is not a commit");
6831
+ const kept = spans.filter((s) => s.leftAt === void 0 || s.enteredAt !== s.leftAt);
6832
+ if (kept.length === 0) return [{
6833
+ name: IMPLICIT_SLICE,
6834
+ base: episode.base,
6835
+ tip: episode.tip,
6836
+ open: true
6837
+ }];
6838
+ return kept.map((s, i) => ({
6839
+ name: s.name,
6840
+ base: i === 0 ? episode.base : kept[i - 1].leftAt ?? episode.base,
6841
+ tip: i === kept.length - 1 ? episode.tip : s.leftAt ?? episode.tip,
6842
+ open: i === kept.length - 1
6843
+ }));
6844
+ };
6845
+ //#endregion
6820
6846
  //#region src/replay/Tree.ts
6821
6847
  const treeFromRecord = (files) => ({
6822
6848
  paths: () => Object.keys(files).sort(),
@@ -7164,6 +7190,7 @@ const replay = async (input) => {
7164
7190
  const keptTips = /* @__PURE__ */ new Map();
7165
7191
  let landed;
7166
7192
  const marks = [];
7193
+ const slices = [];
7167
7194
  let outcome;
7168
7195
  let signal;
7169
7196
  const settled = new Promise((resolve) => {
@@ -7370,7 +7397,8 @@ const replay = async (input) => {
7370
7397
  rest: step,
7371
7398
  trace,
7372
7399
  landed,
7373
- marks
7400
+ marks,
7401
+ slices: slices.map((s) => ({ ...s }))
7374
7402
  });
7375
7403
  return landPending(step, input.pending);
7376
7404
  };
@@ -7434,6 +7462,31 @@ const replay = async (input) => {
7434
7462
  scopes.pop();
7435
7463
  scopeCallIds.pop();
7436
7464
  },
7465
+ enterSlice: (name) => {
7466
+ if (outcome !== void 0) return;
7467
+ const fail = (message) => {
7468
+ finish({
7469
+ kind: "failed",
7470
+ message
7471
+ });
7472
+ throw new Stop();
7473
+ };
7474
+ if (typeof name !== "string" || !SLICE_NAME.test(name)) fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`);
7475
+ if (name === "main") fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`);
7476
+ const open = slices.find((s) => s.leftAt === void 0);
7477
+ if (open !== void 0) fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`);
7478
+ if (slices.some((s) => s.name === name)) fail(`gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`);
7479
+ slices.push({
7480
+ name,
7481
+ enteredAt: position.hash,
7482
+ leftAt: void 0
7483
+ });
7484
+ },
7485
+ leaveSlice: () => {
7486
+ if (outcome !== void 0) return;
7487
+ const open = slices.find((s) => s.leftAt === void 0);
7488
+ if (open !== void 0) open.leftAt = position.hash;
7489
+ },
7437
7490
  read: (path) => position.tree.read(path),
7438
7491
  glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
7439
7492
  changes: () => changesBetween(previousPosition.tree, position.tree),
@@ -9112,6 +9165,10 @@ const pendingChanges = (git, withIndex) => gen(function* () {
9112
9165
  const seen = new Set(worktree.map((e) => e.path));
9113
9166
  return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
9114
9167
  });
9168
+ const sliceRangesOf = (slices, run) => sliceRanges(slices, {
9169
+ base: run.startParentHash,
9170
+ tip: run.trace.at(-1)?.hash ?? run.startParentHash
9171
+ });
9115
9172
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
9116
9173
  const restAt = (ref, startOverrides = {}) => gen(function* () {
9117
9174
  const git = yield* GitService;
@@ -9154,7 +9211,8 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
9154
9211
  total: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
9155
9212
  byModel: costByModel(run.costEntries)
9156
9213
  },
9157
- setup
9214
+ setup,
9215
+ slices: sliceRangesOf(outcome.slices, run)
9158
9216
  };
9159
9217
  });
9160
9218
  const currentRest = restAt(void 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "27.7.1",
3
+ "version": "27.8.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -596,6 +596,34 @@ 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"
601
+
602
+ export default async () => {
603
+ await human("idle", { message: "idle-message" })
604
+ await slice("build", async () => {
605
+ await agent("building", "build-prompt")
606
+ })
607
+ await slice("verify", async () => {
608
+ await agent("checking", "check-prompt")
609
+ })
610
+ }
611
+ `
612
+
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)
616
+ 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
+ ])
624
+ })
625
+ })
626
+
599
627
  describe("restAt", () => {
600
628
  it("restAt(ref) resolves where the process rested at that commit", async () => {
601
629
  const repo = repoWith(LINEAR)
package/src/edge/Edge.ts CHANGED
@@ -37,11 +37,13 @@ import {
37
37
  isRecordableApproval,
38
38
  parseCommitMessage,
39
39
  replay,
40
+ sliceRanges,
40
41
  treeFromRecord,
41
42
  type EpisodeCommit,
42
43
  type JudgeVerdict,
43
44
  type ReachedStep,
44
45
  type ReplayOutcome,
46
+ type SliceRange,
45
47
  type TreeView,
46
48
  } from "../replay/index.js"
47
49
  import type { Mark, ScopeAccess } from "../flows/index.js"
@@ -895,6 +897,7 @@ interface Rest extends ResolvedRest {
895
897
  /** Total recorded cost of the process, and its per-model breakdown. */
896
898
  readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
897
899
  readonly setup: ReplaySetup
900
+ readonly slices: readonly SliceRange[]
898
901
  }
899
902
 
900
903
  /** Per-model token totals, highest-cost first (ties broken by model name). */
@@ -987,6 +990,15 @@ const pendingChanges = (git: GitOperations, withIndex: boolean) =>
987
990
  return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))]
988
991
  })
989
992
 
993
+ const sliceRangesOf = (
994
+ slices: Parameters<typeof sliceRanges>[0],
995
+ run: { readonly startParentHash: string; readonly trace: readonly { readonly hash: string }[] },
996
+ ) =>
997
+ sliceRanges(slices, {
998
+ base: run.startParentHash,
999
+ tip: run.trace.at(-1)?.hash ?? run.startParentHash,
1000
+ })
1001
+
990
1002
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
991
1003
  export const restAt = (
992
1004
  ref: string | undefined,
@@ -1060,6 +1072,7 @@ export const restAt = (
1060
1072
  byModel: costByModel(run.costEntries),
1061
1073
  },
1062
1074
  setup,
1075
+ slices: sliceRangesOf(outcome.slices, run),
1063
1076
  }
1064
1077
  })
1065
1078
 
@@ -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)
@@ -15,6 +15,7 @@ import {
15
15
  restart,
16
16
  run,
17
17
  scope,
18
+ slice,
18
19
  threads,
19
20
  type Flow,
20
21
  type ScopeAccess,
@@ -1210,3 +1211,139 @@ describe("human({ approve })", () => {
1210
1211
  expect(outcome.kind).toBe("rest")
1211
1212
  })
1212
1213
  })
1214
+
1215
+ describe("slice()", () => {
1216
+ const spansOf = (o: ReplayOutcome) => (o as { slices: unknown }).slices
1217
+
1218
+ it("records enteredAt and leftAt as the commit replay stood on", async () => {
1219
+ const wf = async () => {
1220
+ await human("a")
1221
+ await slice("refactor", async () => {
1222
+ await human("b")
1223
+ })
1224
+ await human("c")
1225
+ }
1226
+ const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "c")
1227
+ const outcome = await replayOf(wf, h)
1228
+ expect(outcome).toMatchObject({ kind: "rest" })
1229
+ expect(spansOf(outcome)).toEqual([
1230
+ { name: "refactor", enteredAt: hashOf(1), leftAt: hashOf(2) },
1231
+ ])
1232
+ })
1233
+
1234
+ it("keeps leftAt undefined when the process rests inside the slice", async () => {
1235
+ const wf = async () => {
1236
+ await slice("refactor", async () => {
1237
+ await human("a")
1238
+ })
1239
+ }
1240
+ const outcome = await replayOf(wf, new History())
1241
+ expect(spansOf(outcome)).toEqual([
1242
+ { name: "refactor", enteredAt: hashOf(0), leftAt: undefined },
1243
+ ])
1244
+ })
1245
+
1246
+ it("records enteredAt === leftAt for a slice that landed no commit", async () => {
1247
+ const wf = async () => {
1248
+ await human("a")
1249
+ await slice("empty", async () => undefined)
1250
+ await human("b")
1251
+ }
1252
+ const h = new History().land("human", "a", 1, "b")
1253
+ expect(spansOf(await replayOf(wf, h))).toEqual([
1254
+ { name: "empty", enteredAt: hashOf(1), leftAt: hashOf(1) },
1255
+ ])
1256
+ })
1257
+
1258
+ it("prefixes no step name and ignores an enclosing scope", async () => {
1259
+ const wf = async () => {
1260
+ await scope("build", async () => {
1261
+ await slice("refactor", async () => {
1262
+ await human("gate")
1263
+ })
1264
+ })
1265
+ }
1266
+ const outcome = await replayOf(wf, new History())
1267
+ expect(outcome).toMatchObject({ kind: "rest", rest: { name: "build.gate" } })
1268
+ expect((spansOf(outcome) as { name: string }[])[0]!.name).toBe("refactor")
1269
+ })
1270
+
1271
+ const failed = async (wf: Flow) => {
1272
+ const outcome = await replayOf(wf, new History())
1273
+ expect(outcome.kind).toBe("failed")
1274
+ return (outcome as { message: string }).message
1275
+ }
1276
+
1277
+ it.each(["Refactor", "step_1", "v2.api", ""])("refuses the name %j", async (name) => {
1278
+ expect(await failed(async () => slice(name, async () => human("a")))).toBe(
1279
+ `gtd: slice "${name}": name must match /^[a-z][a-z0-9-]*$/`,
1280
+ )
1281
+ })
1282
+
1283
+ it("accepts refactor-2", async () => {
1284
+ const outcome = await replayOf(
1285
+ async () => slice("refactor-2", async () => human("a")),
1286
+ new History(),
1287
+ )
1288
+ expect(outcome.kind).toBe("rest")
1289
+ })
1290
+
1291
+ it("refuses the reserved name main", async () => {
1292
+ expect(await failed(async () => slice("main", async () => human("a")))).toBe(
1293
+ 'gtd: slice "main" is reserved for the implicit slice',
1294
+ )
1295
+ })
1296
+
1297
+ it("refuses nesting, also through scope()", async () => {
1298
+ const message = 'gtd: slice "inner" is entered inside slice "outer" — slices do not nest'
1299
+ expect(
1300
+ await failed(async () => slice("outer", async () => slice("inner", async () => human("a")))),
1301
+ ).toBe(message)
1302
+ expect(
1303
+ await failed(async () =>
1304
+ slice("outer", async () => scope("s", async () => slice("inner", async () => human("a")))),
1305
+ ),
1306
+ ).toBe(message)
1307
+ })
1308
+
1309
+ it("checks the name rule before main and nesting", async () => {
1310
+ expect(
1311
+ await failed(async () => slice("outer", async () => slice("Main", async () => human("a")))),
1312
+ ).toBe('gtd: slice "Main": name must match /^[a-z][a-z0-9-]*$/')
1313
+ })
1314
+
1315
+ it("lets a flow that caught an error from a slice enter another", async () => {
1316
+ const wf = async () => {
1317
+ try {
1318
+ await slice("a", async () => {
1319
+ throw new Error("boom")
1320
+ })
1321
+ } catch {}
1322
+ await slice("b", async () => human("x"))
1323
+ }
1324
+ expect(await replayOf(wf, new History())).toMatchObject({ kind: "rest" })
1325
+ })
1326
+
1327
+ it("refuses a non-string name", async () => {
1328
+ expect(
1329
+ await failed(async () => slice(undefined as unknown as string, async () => human("a"))),
1330
+ ).toContain("name must match")
1331
+ })
1332
+
1333
+ it("refuses a name entered twice, sequentially and from a loop", async () => {
1334
+ const message =
1335
+ 'gtd: slice "again" is entered twice in this episode — a loop must sit inside its slice'
1336
+ expect(
1337
+ await failed(async () => {
1338
+ await slice("again", async () => undefined)
1339
+ await slice("again", async () => human("a"))
1340
+ }),
1341
+ ).toBe(message)
1342
+ expect(
1343
+ await failed(async () => {
1344
+ for (let i = 0; i < 2; i++) await slice("again", async () => undefined)
1345
+ await human("a")
1346
+ }),
1347
+ ).toBe(message)
1348
+ })
1349
+ })
@@ -23,6 +23,7 @@ import {
23
23
  import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
24
24
  import { globMatches } from "../wire/index.js"
25
25
  import { refNameFault } from "./RefName.js"
26
+ import { IMPLICIT_SLICE, SLICE_NAME, type SliceSpan } from "./Slices.js"
26
27
  import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
27
28
  import {
28
29
  formatStepId,
@@ -117,6 +118,7 @@ export type ReplayOutcome =
117
118
  readonly landed: ReachedStep | undefined
118
119
  /** Marks the flow made after the pending turn's step resolved; [] without a pending turn. */
119
120
  readonly marks: readonly Mark[]
121
+ readonly slices: readonly SliceSpan[]
120
122
  }
121
123
  | {
122
124
  readonly kind: "ended"
@@ -297,6 +299,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
297
299
  >()
298
300
  let landed: ReachedStep | undefined
299
301
  const marks: Mark[] = []
302
+ const slices: { name: string; enteredAt: string; leftAt: string | undefined }[] = []
300
303
  let outcome: ReplayOutcome | undefined
301
304
  let signal: (() => void) | undefined
302
305
  const settled = new Promise<void>((resolve) => {
@@ -558,7 +561,14 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
558
561
  const commit = commits[cursor]
559
562
  if (commit !== undefined) return consumeCommit(step, commit)
560
563
  if (input.pending === undefined || pendingUsed) {
561
- return finish({ kind: "rest", rest: step, trace, landed, marks })
564
+ return finish({
565
+ kind: "rest",
566
+ rest: step,
567
+ trace,
568
+ landed,
569
+ marks,
570
+ slices: slices.map((s) => ({ ...s })),
571
+ })
562
572
  }
563
573
  return landPending(step, input.pending)
564
574
  }
@@ -630,6 +640,34 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
630
640
  scopes.pop()
631
641
  scopeCallIds.pop()
632
642
  },
643
+ enterSlice: (name) => {
644
+ if (outcome !== undefined) return
645
+ const fail = (message: string): never => {
646
+ void finish({ kind: "failed", message })
647
+ throw new Stop()
648
+ }
649
+ if (typeof name !== "string" || !SLICE_NAME.test(name)) {
650
+ fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`)
651
+ }
652
+ if (name === IMPLICIT_SLICE) {
653
+ fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`)
654
+ }
655
+ const open = slices.find((s) => s.leftAt === undefined)
656
+ if (open !== undefined) {
657
+ fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`)
658
+ }
659
+ if (slices.some((s) => s.name === name)) {
660
+ fail(
661
+ `gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`,
662
+ )
663
+ }
664
+ slices.push({ name, enteredAt: position.hash, leftAt: undefined })
665
+ },
666
+ leaveSlice: () => {
667
+ if (outcome !== undefined) return
668
+ const open = slices.find((s) => s.leftAt === undefined)
669
+ if (open !== undefined) open.leftAt = position.hash
670
+ },
633
671
  read: (path) => position.tree.read(path),
634
672
  glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
635
673
  changes: () => changesBetween(previousPosition.tree, position.tree),
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { sliceRanges, type SliceSpan } from "./Slices.js"
3
+
4
+ const span = (name: string, enteredAt: string, leftAt?: string): SliceSpan => ({
5
+ name,
6
+ enteredAt,
7
+ leftAt,
8
+ })
9
+ const running = { base: "B", tip: "T" }
10
+
11
+ describe("sliceRanges", () => {
12
+ it("two adjacent spans: first from episode.base to its leftAt, second from there to episode.tip", () => {
13
+ expect(sliceRanges([span("a", "B", "c2"), span("b", "c2", "T")], running)).toEqual([
14
+ { name: "a", base: "B", tip: "c2", open: false },
15
+ { name: "b", base: "c2", tip: "T", open: true },
16
+ ])
17
+ })
18
+
19
+ it("a leading gap folds into the first range via episode.base", () => {
20
+ expect(sliceRanges([span("a", "c1", "c2")], running)).toEqual([
21
+ { name: "a", base: "B", tip: "T", open: true },
22
+ ])
23
+ })
24
+
25
+ it("an inner gap folds into the next range: its base is the previous leftAt", () => {
26
+ expect(sliceRanges([span("a", "B", "c1"), span("b", "c3", "T")], running)).toEqual([
27
+ { name: "a", base: "B", tip: "c1", open: false },
28
+ { name: "b", base: "c1", tip: "T", open: true },
29
+ ])
30
+ })
31
+
32
+ it("a trailing gap folds back: the last range's tip is the episode tip, and it stays open", () => {
33
+ expect(sliceRanges([span("a", "B", "c1")], running)).toEqual([
34
+ { name: "a", base: "B", tip: "T", open: true },
35
+ ])
36
+ })
37
+
38
+ it("drops a closed empty span, first, middle and last, its neighbours absorbing the gap", () => {
39
+ expect(sliceRanges([span("e", "B", "B"), span("a", "B", "c1")], running)).toEqual([
40
+ { name: "a", base: "B", tip: "T", open: true },
41
+ ])
42
+ expect(
43
+ sliceRanges([span("a", "B", "c1"), span("e", "c2", "c2"), span("b", "c2", "c4")], running),
44
+ ).toEqual([
45
+ { name: "a", base: "B", tip: "c1", open: false },
46
+ { name: "b", base: "c1", tip: "T", open: true },
47
+ ])
48
+ expect(sliceRanges([span("a", "B", "c1"), span("e", "c1", "c1")], running)).toEqual([
49
+ { name: "a", base: "B", tip: "T", open: true },
50
+ ])
51
+ })
52
+
53
+ it("no spans, and only empty closed spans, each yield one open main range over the whole episode", () => {
54
+ const main = [{ name: "main", base: "B", tip: "T", open: true }]
55
+ expect(sliceRanges([], running)).toEqual(main)
56
+ expect(sliceRanges([span("e", "c1", "c1")], running)).toEqual(main)
57
+ })
58
+
59
+ it("an unleft empty span is kept and open", () => {
60
+ expect(sliceRanges([span("a", "B", "c1"), span("b", "c1")], running)).toEqual([
61
+ { name: "a", base: "B", tip: "c1", open: false },
62
+ { name: "b", base: "c1", tip: "T", open: true },
63
+ ])
64
+ expect(sliceRanges([span("b", "T")], running)).toEqual([
65
+ { name: "b", base: "B", tip: "T", open: true },
66
+ ])
67
+ })
68
+
69
+ it("rejects a pending turn's empty hash", () => {
70
+ expect(() => sliceRanges([span("a", "", "c1")], running)).toThrow("not a commit")
71
+ expect(() => sliceRanges([span("a", "c1", "")], running)).toThrow("not a commit")
72
+ })
73
+ })
@@ -0,0 +1,45 @@
1
+ export const IMPLICIT_SLICE = "main"
2
+
3
+ /** Same shape as mark()'s name rule; kept local so @pmelab/gtd/flows publishes no regex. */
4
+ export const SLICE_NAME = /^[a-z][a-z0-9-]*$/
5
+
6
+ /** One slice() call as replay saw it. Hashes are the commit replay stood on; "" is a pending turn. */
7
+ export interface SliceSpan {
8
+ readonly name: string
9
+ readonly enteredAt: string
10
+ /** undefined: the flow never left it — the process rests inside, or restart() fired inside. */
11
+ readonly leftAt: string | undefined
12
+ }
13
+
14
+ export interface SliceRange {
15
+ readonly name: string
16
+ /** Exclusive: the range is base..tip in git's sense. */
17
+ readonly base: string
18
+ readonly tip: string
19
+ /** The tip can still move: only the last range, since a rest is never past the episode's end. */
20
+ readonly open: boolean
21
+ }
22
+
23
+ /**
24
+ * Folds boundaries into ranges: each kept span's base is the previous kept
25
+ * span's `leftAt`, so a gap before a span belongs to it, and the last span
26
+ * runs to the episode tip. A closed span that landed nothing is dropped.
27
+ */
28
+ export const sliceRanges = (
29
+ spans: readonly SliceSpan[],
30
+ episode: { readonly base: string; readonly tip: string },
31
+ ): readonly SliceRange[] => {
32
+ if (spans.some((s) => s.enteredAt === "" || s.leftAt === "")) {
33
+ throw new Error('sliceRanges: a pending turn\'s "" hash is not a commit')
34
+ }
35
+ const kept = spans.filter((s) => s.leftAt === undefined || s.enteredAt !== s.leftAt)
36
+ if (kept.length === 0) {
37
+ return [{ name: IMPLICIT_SLICE, base: episode.base, tip: episode.tip, open: true }]
38
+ }
39
+ return kept.map((s, i) => ({
40
+ name: s.name,
41
+ base: i === 0 ? episode.base : (kept[i - 1]!.leftAt ?? episode.base),
42
+ tip: i === kept.length - 1 ? episode.tip : (s.leftAt ?? episode.tip),
43
+ open: i === kept.length - 1,
44
+ }))
45
+ }
@@ -12,3 +12,4 @@ export {
12
12
  export { accessShapeFault, foldAccess } from "./Access.js"
13
13
  export { formatFault } from "./Format.js"
14
14
  export { refNameFault } from "./RefName.js"
15
+ export { sliceRanges, type SliceRange } from "./Slices.js"