@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/flows.mjs +16 -1
- package/dist/gtd.bundle.mjs +75 -2
- package/dist/testkit.mjs +60 -2
- package/package.json +1 -1
- package/src/edge/Edge.test.ts +28 -0
- package/src/edge/Edge.ts +13 -0
- package/src/flows/runtime.test.ts +27 -0
- package/src/flows/runtime.ts +18 -0
- package/src/replay/Replay.test.ts +137 -0
- package/src/replay/Replay.ts +39 -1
- package/src/replay/Slices.test.ts +73 -0
- package/src/replay/Slices.ts +45 -0
- package/src/replay/index.ts +1 -0
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 };
|
package/dist/gtd.bundle.mjs
CHANGED
|
@@ -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
package/src/edge/Edge.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/flows/runtime.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/replay/Replay.ts
CHANGED
|
@@ -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({
|
|
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
|
+
}
|
package/src/replay/index.ts
CHANGED