@pmelab/gtd 27.7.1 → 27.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,171 @@ 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("an ended outcome carries every span of the episode that just ended", async () => {
1235
+ const wf = async () => {
1236
+ await slice("one", async () => {
1237
+ await human("a")
1238
+ })
1239
+ await slice("two", async () => {
1240
+ await human("b")
1241
+ })
1242
+ }
1243
+ const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
1244
+ const outcome = await replayOf(wf, h)
1245
+ expect(outcome).toMatchObject({ kind: "ended", via: "return" })
1246
+ expect(spansOf(outcome)).toEqual([
1247
+ { name: "one", enteredAt: hashOf(0), leftAt: hashOf(1) },
1248
+ { name: "two", enteredAt: hashOf(1), leftAt: hashOf(2) },
1249
+ ])
1250
+ })
1251
+
1252
+ it("restart() inside a slice ends via restart with that span's leftAt undefined", async () => {
1253
+ const wf = async () => {
1254
+ await human("a")
1255
+ await slice("risky", async () => {
1256
+ await human("b")
1257
+ await restart()
1258
+ })
1259
+ }
1260
+ const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
1261
+ const outcome = await replayOf(wf, h)
1262
+ expect(outcome).toMatchObject({ kind: "ended", via: "restart" })
1263
+ expect(spansOf(outcome)).toEqual([{ name: "risky", enteredAt: hashOf(1), leftAt: undefined }])
1264
+ })
1265
+
1266
+ it("keeps leftAt undefined when the process rests inside the slice", async () => {
1267
+ const wf = async () => {
1268
+ await slice("refactor", async () => {
1269
+ await human("a")
1270
+ })
1271
+ }
1272
+ const outcome = await replayOf(wf, new History())
1273
+ expect(spansOf(outcome)).toEqual([
1274
+ { name: "refactor", enteredAt: hashOf(0), leftAt: undefined },
1275
+ ])
1276
+ })
1277
+
1278
+ it("records enteredAt === leftAt for a slice that landed no commit", async () => {
1279
+ const wf = async () => {
1280
+ await human("a")
1281
+ await slice("empty", async () => undefined)
1282
+ await human("b")
1283
+ }
1284
+ const h = new History().land("human", "a", 1, "b")
1285
+ expect(spansOf(await replayOf(wf, h))).toEqual([
1286
+ { name: "empty", enteredAt: hashOf(1), leftAt: hashOf(1) },
1287
+ ])
1288
+ })
1289
+
1290
+ it("prefixes no step name and ignores an enclosing scope", async () => {
1291
+ const wf = async () => {
1292
+ await scope("build", async () => {
1293
+ await slice("refactor", async () => {
1294
+ await human("gate")
1295
+ })
1296
+ })
1297
+ }
1298
+ const outcome = await replayOf(wf, new History())
1299
+ expect(outcome).toMatchObject({ kind: "rest", rest: { name: "build.gate" } })
1300
+ expect((spansOf(outcome) as { name: string }[])[0]!.name).toBe("refactor")
1301
+ })
1302
+
1303
+ const failed = async (wf: Flow) => {
1304
+ const outcome = await replayOf(wf, new History())
1305
+ expect(outcome.kind).toBe("failed")
1306
+ return (outcome as { message: string }).message
1307
+ }
1308
+
1309
+ it.each(["Refactor", "step_1", "v2.api", ""])("refuses the name %j", async (name) => {
1310
+ expect(await failed(async () => slice(name, async () => human("a")))).toBe(
1311
+ `gtd: slice "${name}": name must match /^[a-z][a-z0-9-]*$/`,
1312
+ )
1313
+ })
1314
+
1315
+ it("accepts refactor-2", async () => {
1316
+ const outcome = await replayOf(
1317
+ async () => slice("refactor-2", async () => human("a")),
1318
+ new History(),
1319
+ )
1320
+ expect(outcome.kind).toBe("rest")
1321
+ })
1322
+
1323
+ it("refuses the reserved name main", async () => {
1324
+ expect(await failed(async () => slice("main", async () => human("a")))).toBe(
1325
+ 'gtd: slice "main" is reserved for the implicit slice',
1326
+ )
1327
+ })
1328
+
1329
+ it("refuses nesting, also through scope()", async () => {
1330
+ const message = 'gtd: slice "inner" is entered inside slice "outer" — slices do not nest'
1331
+ expect(
1332
+ await failed(async () => slice("outer", async () => slice("inner", async () => human("a")))),
1333
+ ).toBe(message)
1334
+ expect(
1335
+ await failed(async () =>
1336
+ slice("outer", async () => scope("s", async () => slice("inner", async () => human("a")))),
1337
+ ),
1338
+ ).toBe(message)
1339
+ })
1340
+
1341
+ it("checks the name rule before main and nesting", async () => {
1342
+ expect(
1343
+ await failed(async () => slice("outer", async () => slice("Main", async () => human("a")))),
1344
+ ).toBe('gtd: slice "Main": name must match /^[a-z][a-z0-9-]*$/')
1345
+ })
1346
+
1347
+ it("lets a flow that caught an error from a slice enter another", async () => {
1348
+ const wf = async () => {
1349
+ try {
1350
+ await slice("a", async () => {
1351
+ throw new Error("boom")
1352
+ })
1353
+ } catch {}
1354
+ await slice("b", async () => human("x"))
1355
+ }
1356
+ expect(await replayOf(wf, new History())).toMatchObject({ kind: "rest" })
1357
+ })
1358
+
1359
+ it("refuses a non-string name", async () => {
1360
+ expect(
1361
+ await failed(async () => slice(undefined as unknown as string, async () => human("a"))),
1362
+ ).toContain("name must match")
1363
+ })
1364
+
1365
+ it("refuses a name entered twice, sequentially and from a loop", async () => {
1366
+ const message =
1367
+ 'gtd: slice "again" is entered twice in this episode — a loop must sit inside its slice'
1368
+ expect(
1369
+ await failed(async () => {
1370
+ await slice("again", async () => undefined)
1371
+ await slice("again", async () => human("a"))
1372
+ }),
1373
+ ).toBe(message)
1374
+ expect(
1375
+ await failed(async () => {
1376
+ for (let i = 0; i < 2; i++) await slice("again", async () => undefined)
1377
+ await human("a")
1378
+ }),
1379
+ ).toBe(message)
1380
+ })
1381
+ })
@@ -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"
@@ -124,6 +126,8 @@ export type ReplayOutcome =
124
126
  readonly trace: readonly ReachedStep[]
125
127
  readonly landed: ReachedStep | undefined
126
128
  readonly marks: readonly Mark[]
129
+ /** Spans of the episode that just ended; a restart() inside a slice leaves its leftAt undefined. */
130
+ readonly slices: readonly SliceSpan[]
127
131
  }
128
132
  | { readonly kind: "divergence"; readonly message: string }
129
133
  | { readonly kind: "refused"; readonly message: string }
@@ -297,6 +301,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
297
301
  >()
298
302
  let landed: ReachedStep | undefined
299
303
  const marks: Mark[] = []
304
+ const slices: { name: string; enteredAt: string; leftAt: string | undefined }[] = []
300
305
  let outcome: ReplayOutcome | undefined
301
306
  let signal: (() => void) | undefined
302
307
  const settled = new Promise<void>((resolve) => {
@@ -558,7 +563,14 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
558
563
  const commit = commits[cursor]
559
564
  if (commit !== undefined) return consumeCommit(step, commit)
560
565
  if (input.pending === undefined || pendingUsed) {
561
- return finish({ kind: "rest", rest: step, trace, landed, marks })
566
+ return finish({
567
+ kind: "rest",
568
+ rest: step,
569
+ trace,
570
+ landed,
571
+ marks,
572
+ slices: slices.map((s) => ({ ...s })),
573
+ })
562
574
  }
563
575
  return landPending(step, input.pending)
564
576
  }
@@ -603,7 +615,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
603
615
  kind: "divergence",
604
616
  message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
605
617
  }
606
- : { kind: "ended", via, trace, landed, marks }
618
+ : { kind: "ended", via, trace, landed, marks, slices: slices.map((s) => ({ ...s })) }
607
619
 
608
620
  const changesSince = (hash: string): readonly Change[] => {
609
621
  const tree = treeAt(hash)
@@ -630,6 +642,34 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
630
642
  scopes.pop()
631
643
  scopeCallIds.pop()
632
644
  },
645
+ enterSlice: (name) => {
646
+ if (outcome !== undefined) return
647
+ const fail = (message: string): never => {
648
+ void finish({ kind: "failed", message })
649
+ throw new Stop()
650
+ }
651
+ if (typeof name !== "string" || !SLICE_NAME.test(name)) {
652
+ fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`)
653
+ }
654
+ if (name === IMPLICIT_SLICE) {
655
+ fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`)
656
+ }
657
+ const open = slices.find((s) => s.leftAt === undefined)
658
+ if (open !== undefined) {
659
+ fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`)
660
+ }
661
+ if (slices.some((s) => s.name === name)) {
662
+ fail(
663
+ `gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`,
664
+ )
665
+ }
666
+ slices.push({ name, enteredAt: position.hash, leftAt: undefined })
667
+ },
668
+ leaveSlice: () => {
669
+ if (outcome !== undefined) return
670
+ const open = slices.find((s) => s.leftAt === undefined)
671
+ if (open !== undefined) open.leftAt = position.hash
672
+ },
633
673
  read: (path) => position.tree.read(path),
634
674
  glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
635
675
  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
+ }
@@ -115,6 +115,17 @@ describe("the commit-message codec", () => {
115
115
  expect(parsed.version).toBe("1.2.3")
116
116
  })
117
117
 
118
+ it("reads a Gtd-Version carrying control bytes as unknown, and keeps spaces and parens", () => {
119
+ const withVersion = (value: string) =>
120
+ formatCommitMessage({ actor: "a", to: "b" }).replace(
121
+ "Gtd-Format: 1",
122
+ `Gtd-Version: ${value}\nGtd-Format: 1`,
123
+ )
124
+ expect(parseCommitMessage(withVersion("1.0\u001b]0;x\u0007")).version).toBeUndefined()
125
+ expect(parseCommitMessage(withVersion("1.0\u009b")).version).toBeUndefined()
126
+ expect(parseCommitMessage(withVersion("0.0.1 (dev)")).version).toBe("0.0.1 (dev)")
127
+ })
128
+
118
129
  it("omits Gtd-Branch and Gtd-Version when unset, and reads them as undefined", () => {
119
130
  const message = formatCommitMessage({ actor: "agent", to: "idle" })
120
131
  expect(message).not.toContain("Gtd-Branch")
@@ -196,7 +196,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
196
196
  if (value !== "") into.branch ??= value
197
197
  },
198
198
  "Gtd-Version": (value, into) => {
199
- if (value !== "") into.version ??= value
199
+ // Untrusted (fetched history): control bytes would reach the terminal; a rejected value reads as unknown.
200
+ // eslint-disable-next-line no-control-regex
201
+ if (value !== "" && !/[\u0000-\u001f\u007f-\u009f]/.test(value)) into.version ??= value
200
202
  },
201
203
  "Gtd-Kept": (value, into) => {
202
204
  if (HASH_RE.test(value)) into.kept ??= value
@@ -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 } from "./Slices.js"
@@ -0,0 +1,25 @@
1
+ import { WIRE_SCHEMA } from "./constants.js"
2
+
3
+ export interface SliceEntry {
4
+ readonly name: string
5
+ /** Full hash, exclusive: git's base..tip. */
6
+ readonly base: string
7
+ /** Full hash, inclusive; HEAD's hash for the open slice. */
8
+ readonly tip: string
9
+ /** false only for the last slice of an underway process. */
10
+ readonly closed: boolean
11
+ }
12
+
13
+ export interface SlicesDocument {
14
+ readonly schema: number
15
+ readonly slices: readonly SliceEntry[]
16
+ }
17
+
18
+ /** The ONLY place `gtd slices --json`'s shape is built; copies exactly the four fields, in order. */
19
+ export const slicesDocument = (slices: readonly SliceEntry[]): SlicesDocument => ({
20
+ schema: WIRE_SCHEMA,
21
+ slices: slices.map(({ name, base, tip, closed }) => ({ name, base, tip, closed })),
22
+ })
23
+
24
+ export const renderSlicesJson = (document: SlicesDocument): string =>
25
+ JSON.stringify(document) + "\n"
@@ -9,6 +9,8 @@ import {
9
9
  landFields,
10
10
  renderBeatJson,
11
11
  renderLandJson,
12
+ renderSlicesJson,
13
+ slicesDocument,
12
14
  statusOf,
13
15
  WIRE_SCHEMA,
14
16
  type BeatKind,
@@ -116,6 +118,9 @@ const documents: Readonly<Record<string, string>> = {
116
118
  }),
117
119
  ),
118
120
  "judge.json": JUDGE,
121
+ "slices.json": renderSlicesJson(
122
+ slicesDocument([{ name: "build", base: "a".repeat(40), tip: "b".repeat(40), closed: true }]),
123
+ ),
119
124
  }
120
125
 
121
126
  describe(`wire schema ${WIRE_SCHEMA}`, () => {
@@ -139,6 +144,19 @@ describe(`wire schema ${WIRE_SCHEMA}`, () => {
139
144
  }
140
145
  })
141
146
 
147
+ describe("slicesDocument", () => {
148
+ it("stamps WIRE_SCHEMA and drops any field beyond the four", () => {
149
+ const doc = slicesDocument([
150
+ { name: "n", base: "b", tip: "t", closed: false, extra: 1 } as never,
151
+ ])
152
+ expect(doc).toEqual({
153
+ schema: WIRE_SCHEMA,
154
+ slices: [{ name: "n", base: "b", tip: "t", closed: false }],
155
+ })
156
+ expect(Object.keys(doc.slices[0]!)).toEqual(["name", "base", "tip", "closed"])
157
+ })
158
+ })
159
+
142
160
  describe("the shape check", () => {
143
161
  const land = JSON.parse(documents["land.json"]!) as Record<string, unknown>
144
162
  const { subject, ...rest } = land
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema": 1,
3
+ "slices": [
4
+ {
5
+ "name": "build",
6
+ "base": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
7
+ "tip": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
8
+ "closed": true
9
+ }
10
+ ]
11
+ }
package/src/wire/index.ts CHANGED
@@ -10,3 +10,4 @@ export * from "./Judge.js"
10
10
  export { selectPath } from "./Select.js"
11
11
  export { resolveSession } from "./Sessions.js"
12
12
  export { globMatches } from "./Glob.js"
13
+ export * from "./Slices.js"