@pmelab/gtd 27.8.0 → 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.
package/src/edge/Edge.ts CHANGED
@@ -43,7 +43,6 @@ import {
43
43
  type JudgeVerdict,
44
44
  type ReachedStep,
45
45
  type ReplayOutcome,
46
- type SliceRange,
47
46
  type TreeView,
48
47
  } from "../replay/index.js"
49
48
  import type { Mark, ScopeAccess } from "../flows/index.js"
@@ -53,6 +52,7 @@ import {
53
52
  UNATTRIBUTED_MODEL,
54
53
  type ApprovalEntry,
55
54
  type ModelCost,
55
+ type SliceEntry,
56
56
  type StepAccess,
57
57
  } from "../wire/index.js"
58
58
 
@@ -476,26 +476,21 @@ const runAtTip = (
476
476
  return yield* runOf([...before, ...closing], { ...location }, tip.hash)
477
477
  })
478
478
 
479
+ const runAtHead = (git: GitOperations, def: WorkflowDefinition) =>
480
+ Effect.gen(function* () {
481
+ // Cheap: just HEAD's own subject, never the commits before it.
482
+ return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
483
+ })
484
+
479
485
  /** The process HEAD closes or sits inside — `gtd summary`'s run. */
480
486
  export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
481
487
  function* () {
482
488
  const git = yield* GitService
483
489
  const def = (yield* (yield* ConfigService).load).workflow
484
- // Cheap: just HEAD's own subject, never the commits before it.
485
- return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
490
+ return yield* runAtHead(git, def)
486
491
  },
487
492
  )
488
493
 
489
- /** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
490
- export const closedRunAt = (tip: string): Effect.Effect<ProcessRun, Error, ConfigRequirements> =>
491
- Effect.gen(function* () {
492
- const git = yield* GitService
493
- const def = (yield* (yield* ConfigService).load).workflow
494
- const hash = yield* git.resolveRef(tip)
495
- const subject = yield* git.lastCommitSubject(hash)
496
- return yield* runAtTip(git, def, { hash, subject })
497
- })
498
-
499
494
  // ── Trees ───────────────────────────────────────────────────────────────────
500
495
 
501
496
  const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
@@ -897,7 +892,6 @@ interface Rest extends ResolvedRest {
897
892
  /** Total recorded cost of the process, and its per-model breakdown. */
898
893
  readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
899
894
  readonly setup: ReplaySetup
900
- readonly slices: readonly SliceRange[]
901
895
  }
902
896
 
903
897
  /** Per-model token totals, highest-cost first (ties broken by model name). */
@@ -1072,12 +1066,74 @@ export const restAt = (
1072
1066
  byModel: costByModel(run.costEntries),
1073
1067
  },
1074
1068
  setup,
1075
- slices: sliceRangesOf(outcome.slices, run),
1076
1069
  }
1077
1070
  })
1078
1071
 
1079
1072
  export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
1080
1073
 
1074
+ export interface ProcessSlices {
1075
+ readonly slices: readonly SliceEntry[]
1076
+ readonly versionWarning?: string
1077
+ }
1078
+
1079
+ /**
1080
+ * The slices of the process HEAD closes or sits inside, current episode only,
1081
+ * stack order. Reads like `historyTrail`, never `restAt`: it narrates and
1082
+ * writes nothing, and returns a version warning for a closed run.
1083
+ */
1084
+ export const processSlices: Effect.Effect<ProcessSlices, Error, RestRequirements> = Effect.gen(
1085
+ function* () {
1086
+ const git = yield* GitService
1087
+ const config = yield* (yield* ConfigService).load
1088
+ const workspace = yield* Workspace
1089
+ const host = yield* Host
1090
+ const run = yield* runAtHead(git, config.workflow)
1091
+ if (run.trace.length === 0) {
1092
+ return yield* Effect.fail(
1093
+ new Error("gtd slices: refused — HEAD neither closes nor sits inside a process"),
1094
+ )
1095
+ }
1096
+ const closing = run.closingHash
1097
+ let outcome: ReplayOutcome
1098
+ let versionWarning: string | undefined
1099
+ if (closing !== undefined) {
1100
+ const replayed = yield* replayFinishedProcess(config, run, closing, {
1101
+ command: "gtd slices",
1102
+ configReason:
1103
+ "HEAD's process replays under today's config; revert or stash the edit to list its slices",
1104
+ notEnded: (o) => slicesDivergence(run, o),
1105
+ })
1106
+ outcome = replayed.outcome
1107
+ versionWarning = replayed.versionWarning
1108
+ } else {
1109
+ const { setup } = yield* setupFor(config, run, workspace, host)
1110
+ outcome = yield* Effect.promise(() => replayFor(setup))
1111
+ if (outcome.kind !== "rest") return yield* Effect.fail(slicesDivergence(run, outcome))
1112
+ }
1113
+ return {
1114
+ slices: sliceRangesOf(outcome.slices, run).map(({ name, base, tip, open }) => ({
1115
+ name,
1116
+ base,
1117
+ tip,
1118
+ closed: closing !== undefined || !open,
1119
+ })),
1120
+ ...(versionWarning !== undefined ? { versionWarning } : {}),
1121
+ }
1122
+ },
1123
+ )
1124
+
1125
+ type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
1126
+ type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
1127
+
1128
+ /** Every `gtd slices` refusal reads `gtd slices: refused — <reason>`; replay's own `gtd: ` lead is dropped. */
1129
+ const slicesDivergence = (run: ProcessRun, outcome: ReplayOutcome): Error => {
1130
+ const reason =
1131
+ outcome.kind === "rest" && run.closingHash !== undefined
1132
+ ? `${run.closingHash.slice(0, 7)} closes a process that does not end under today's config`
1133
+ : (replayError(outcome)?.message.replace(/^gtd: /, "") ?? "replay failed")
1134
+ return new Error(`gtd slices: refused — ${reason}`)
1135
+ }
1136
+
1081
1137
  /**
1082
1138
  * Whether `gtd --workflow <name>` can start a process here: the workflow must
1083
1139
  * reach a step from the tree the opening commit would capture. That step is
@@ -1231,7 +1287,7 @@ const multilineRefusal = (vars: Record<string, string>): Landing | undefined =>
1231
1287
  }
1232
1288
 
1233
1289
  const landingTarget = (
1234
- outcome: Awaited<ReturnType<typeof replayFor>>,
1290
+ outcome: ReplayOutcome,
1235
1291
  rest: Rest,
1236
1292
  ): Effect.Effect<StateName | Landing, Error> => {
1237
1293
  if (outcome.kind === "refused")
@@ -1442,10 +1498,14 @@ const inRepoConfigPaths = (
1442
1498
  return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
1443
1499
  }
1444
1500
 
1445
- const configRefusal = (tip: string, paths: readonly string[]): Error =>
1501
+ const configRefusal = (
1502
+ command: string,
1503
+ tip: string,
1504
+ paths: readonly string[],
1505
+ reason = "A history ref replays under today's config, so it is unreadable after the config changes",
1506
+ ): Error =>
1446
1507
  new Error(
1447
- `gtd history: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ` +
1448
- `A history ref replays under today's config, so it is unreadable after the config changes`,
1508
+ `${command}: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ${reason}`,
1449
1509
  )
1450
1510
 
1451
1511
  const differingConfigPaths = (
@@ -1469,10 +1529,7 @@ const differingConfigPaths = (
1469
1529
  return differing
1470
1530
  })
1471
1531
 
1472
- const notClosedError = (
1473
- hash: string,
1474
- outcome: Exclude<Awaited<ReturnType<typeof replayFor>>, { kind: "ended" }>,
1475
- ): Error =>
1532
+ const notClosedError = (hash: string, outcome: UnendedOutcome): Error =>
1476
1533
  outcome.kind === "rest"
1477
1534
  ? new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`)
1478
1535
  : (replayError(outcome) ?? new Error("gtd history: replay failed"))
@@ -1507,30 +1564,52 @@ export const historyTrail = (tip: string): Effect.Effect<HistoryTrail, Error, Re
1507
1564
  Effect.gen(function* () {
1508
1565
  const git = yield* GitService
1509
1566
  const config = yield* (yield* ConfigService).load
1510
- const workspace = yield* Workspace
1511
- const host = yield* Host
1512
1567
  const hash = yield* git.resolveRef(tip)
1513
- const run = yield* closedRunAt(hash)
1514
-
1515
- const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
1516
- if (differing.length > 0) return yield* Effect.fail(configRefusal(hash, differing))
1517
-
1518
- const { setup } = yield* setupFor(config, run, workspace, host)
1519
- yield* workspace.episodeTrees(
1520
- run.episode.base,
1521
- run.episode.commits.map((c) => c.hash),
1522
- )
1523
- const outcome = yield* Effect.promise(() => replayFor(setup))
1524
- if (outcome.kind !== "ended") return yield* Effect.fail(notClosedError(hash, outcome))
1525
-
1526
- const steps = historySteps(run)
1527
- const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version
1528
- const versionWarning = versionWarningFor(recorded)
1568
+ const subject = yield* git.lastCommitSubject(hash)
1569
+ const run = yield* runAtTip(git, config.workflow, { hash, subject })
1570
+ const { outcome, recorded, versionWarning } = yield* replayFinishedProcess(config, run, hash, {
1571
+ command: "gtd history",
1572
+ notEnded: (o) => notClosedError(hash, o),
1573
+ })
1529
1574
  return {
1530
1575
  tip: hash,
1531
- steps,
1576
+ steps: historySteps(run),
1532
1577
  ended: outcome.via,
1533
1578
  version: { recorded, running: GTD_VERSION },
1534
1579
  ...(versionWarning !== undefined ? { versionWarning } : {}),
1535
1580
  }
1536
1581
  })
1582
+
1583
+ interface FinishedReplay {
1584
+ readonly outcome: EndedOutcome
1585
+ readonly recorded: string | undefined
1586
+ readonly versionWarning?: string
1587
+ }
1588
+
1589
+ /** Refuses on config drift before replaying, so a stale config never runs the flow. */
1590
+ const replayFinishedProcess = (
1591
+ config: ConfigOperations,
1592
+ run: ProcessRun,
1593
+ hash: string,
1594
+ how: {
1595
+ readonly command: string
1596
+ readonly configReason?: string
1597
+ readonly notEnded: (outcome: UnendedOutcome) => Error
1598
+ },
1599
+ ): Effect.Effect<FinishedReplay, Error, RestRequirements> =>
1600
+ Effect.gen(function* () {
1601
+ const git = yield* GitService
1602
+ const workspace = yield* Workspace
1603
+ const host = yield* Host
1604
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
1605
+ if (differing.length > 0)
1606
+ return yield* Effect.fail(configRefusal(how.command, hash, differing, how.configReason))
1607
+ const { setup } = yield* setupFor(config, run, workspace, host)
1608
+ const outcome = yield* Effect.promise(() => replayFor(setup))
1609
+ if (outcome.kind !== "ended") return yield* Effect.fail(how.notEnded(outcome))
1610
+ const recorded = parseCommitMessage(
1611
+ run.episode.commits.find((c) => c.hash === hash)?.message ?? "",
1612
+ ).version
1613
+ const versionWarning = versionWarningFor(recorded)
1614
+ return { outcome, recorded, ...(versionWarning !== undefined ? { versionWarning } : {}) }
1615
+ })
package/src/edge/index.ts CHANGED
@@ -14,4 +14,5 @@ export {
14
14
  snapshotFromRest,
15
15
  summaryFor,
16
16
  historyTrail,
17
+ processSlices,
17
18
  } from "./Edge.js"
@@ -1231,6 +1231,38 @@ describe("slice()", () => {
1231
1231
  ])
1232
1232
  })
1233
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
+
1234
1266
  it("keeps leftAt undefined when the process rests inside the slice", async () => {
1235
1267
  const wf = async () => {
1236
1268
  await slice("refactor", async () => {
@@ -126,6 +126,8 @@ export type ReplayOutcome =
126
126
  readonly trace: readonly ReachedStep[]
127
127
  readonly landed: ReachedStep | undefined
128
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[]
129
131
  }
130
132
  | { readonly kind: "divergence"; readonly message: string }
131
133
  | { readonly kind: "refused"; readonly message: string }
@@ -613,7 +615,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
613
615
  kind: "divergence",
614
616
  message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
615
617
  }
616
- : { kind: "ended", via, trace, landed, marks }
618
+ : { kind: "ended", via, trace, landed, marks, slices: slices.map((s) => ({ ...s })) }
617
619
 
618
620
  const changesSince = (hash: string): readonly Change[] => {
619
621
  const tree = treeAt(hash)
@@ -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,4 +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"
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"