@pmelab/gtd 62.1.0 → 63.0.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,4 +1,4 @@
1
- import { n as runsItsScript, r as testRepo } from "../chunks/testkit-CPzWnl3P.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-DwypA7cH.mjs";
2
2
  import { resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
package/dist/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { H as installContext } from "./chunks/flows-DENt0uWV.mjs";
2
- import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-CPzWnl3P.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-DwypA7cH.mjs";
3
3
  export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "62.1.0",
3
+ "version": "63.0.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": {
package/src/cli/Cli.ts CHANGED
@@ -455,7 +455,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
455
455
  arity: "none",
456
456
  group: "Start and stop",
457
457
  summary: "Pin the process to the live gtd and workflow",
458
- details: `Switch the process underway to the live engine and workflow, the way out when the workflow needs a fix mid-process: the printed script runs the pin beat again, writing \`gtd bundle\`'s output to .gtd/process.gtdpin and committing that file alone. Always runs on the gtd on PATH, never on the pin. Refuses when no process is underway, when the next beat is a pin or unpin beat already, or when the live workflow does not replay the process.
458
+ details: `Switch the process underway to the live engine and workflow, the way out when the workflow needs a fix mid-process: the printed script runs the pin beat again, writing \`gtd bundle\`'s output to .gtd/process.gtdpin and committing that file alone. Always runs on the gtd on PATH, never on the pin. When the live workflow no longer replays the process, the commit continues from the divergence: a Gtd-Anchor trailer names the last commit it replays, the later commits stay as files only, and the process rests at the step the live workflow reached there. Refuses when no process is underway, when the next beat is a pin or unpin beat already, when an unpinned process diverges, or when the live workflow would continue at the default workflow's first step.
459
459
 
460
460
  s=$(gtd repin --json=script) && sh -c "$s"`,
461
461
  },
@@ -110,7 +110,7 @@ export const hotfix = workflow(
110
110
  expect(exitCode).toBe(0)
111
111
  expect(stdout).toContain(
112
112
  commitAll(
113
- `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 2`,
113
+ `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 3`,
114
114
  ),
115
115
  )
116
116
  expect(repo.commitHistory()).toHaveLength(before)
@@ -791,14 +791,14 @@ describe("gtd next — refuses when history diverges from what replay reaches",
791
791
 
792
792
  describe("a history written in a format this gtd does not read", () => {
793
793
  // The exact wording is pinned in Format.test.ts; this is the part that proves the format gate fired.
794
- const FOREIGN = "is written in history format 3, but this gtd reads formats 1 to 2"
794
+ const FOREIGN = "is written in history format 4, but this gtd reads formats 1 to 3"
795
795
 
796
796
  /** A step commit that would also diverge, so only the format gate can explain the refusal. */
797
797
  const foreignStep = async (): Promise<InMemRepo> => {
798
798
  const repo = seed(IDLE_THEN_WORKING)
799
799
  await landTurn(repo, { "NOTE.md": "a note\n" })
800
800
  repo.writeFile("WORK.md", "done\n")
801
- repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 3")
801
+ repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 4")
802
802
  return repo
803
803
  }
804
804
 
@@ -823,7 +823,7 @@ describe("a history written in a format this gtd does not read", () => {
823
823
  it("refuses even when a foreign subject hides the commit from the episode", async () => {
824
824
  const repo = seed(IDLE_THEN_WORKING)
825
825
  repo.writeFile("NOTE.md", "a note\n")
826
- repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 3")
826
+ repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 4")
827
827
  for (const command of ["next", "abandon"]) {
828
828
  const { exitCode, stderr } = await run(repo, command)
829
829
  expect(exitCode).toBe(1)
@@ -834,7 +834,7 @@ describe("a history written in a format this gtd does not read", () => {
834
834
  it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
835
835
  const repo = seed(IDLE_THEN_WORKING)
836
836
  repo.writeFile("NOTE.md", "a note\n")
837
- repo.seedCommit("feat: x\n\nGtd-Format: 3")
837
+ repo.seedCommit("feat: x\n\nGtd-Format: 4")
838
838
  expect((await run(repo, "next")).exitCode).toBe(0)
839
839
  })
840
840
 
@@ -842,7 +842,7 @@ describe("a history written in a format this gtd does not read", () => {
842
842
  const repo = seed(IDLE_THEN_WORKING)
843
843
  const base = repo.resolveRef("HEAD")!
844
844
  repo.writeFile("NOTE.md", "a note\n")
845
- repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 3")
845
+ repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 4")
846
846
  repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
847
847
  repo.hardResetTo(base)
848
848
  const { exitCode, stderr } = await run(repo, "restore")
@@ -51,6 +51,7 @@ import {
51
51
  } from "../wire/index.js"
52
52
  import {
53
53
  currentRest,
54
+ repinAnchor,
54
55
  startRefusal,
55
56
  currentRun,
56
57
  renderRest,
@@ -867,36 +868,45 @@ const runRestoreCommand = (
867
868
  yield* writeScript(json, out, combinedScript(required, ""))
868
869
  })
869
870
 
871
+ /** The step an ordinary repin rests at — the one the process rests at now. */
872
+ const repinTarget = Effect.gen(function* () {
873
+ const rest = yield* currentRest
874
+ const refusal =
875
+ rest.pin === "add"
876
+ ? "the next beat pins the process already — run it (`gtd next --json=content`), then land"
877
+ : rest.pin === "remove"
878
+ ? "the flow has ended — its unpin beat is next, so there is nothing to repin"
879
+ : noProcessUnderway(rest)
880
+ ? "no gtd process is underway — nothing to repin"
881
+ : undefined
882
+ if (refusal !== undefined) return yield* Effect.fail(new Error(`gtd repin: ${refusal}`))
883
+ return rest.step.name
884
+ })
885
+
870
886
  /**
871
- * `gtd repin`: the pin beat's script and landing, on demand. It runs live,
872
- * so reading the rest first refuses a live workflow that cannot replay the
873
- * process before any pin is written.
887
+ * `gtd repin`: the pin beat's script and landing, on demand. It runs live: a
888
+ * pinned process the live workflow no longer replays is anchored where it
889
+ * diverged, and any other failure to read the rest refuses before any pin is
890
+ * written.
874
891
  */
875
892
  const runRepinCommand = (
876
893
  json: JsonMode,
877
894
  out: ArtifactOut,
878
895
  ): Effect.Effect<void, Error, CommandRequirements> =>
879
896
  Effect.gen(function* () {
880
- const rest = yield* currentRest
881
- const refusal =
882
- rest.pin === "add"
883
- ? "the next beat pins the process already — run it (`gtd next --json=content`), then land"
884
- : rest.pin === "remove"
885
- ? "the flow has ended — its unpin beat is next, so there is nothing to repin"
886
- : noProcessUnderway(rest)
887
- ? "no gtd process is underway — nothing to repin"
888
- : undefined
889
- if (refusal !== undefined) return yield* Effect.fail(new Error(`gtd repin: ${refusal}`))
897
+ const anchor = yield* repinAnchor
898
+ const to = anchor?.to ?? (yield* repinTarget)
890
899
  const message = formatCommitMessage({
891
900
  actor: "check",
892
901
  from: PIN_STATES.add,
893
- to: rest.step.name,
902
+ to,
894
903
  pin: "add",
904
+ ...(anchor !== undefined ? { anchor: anchor.at } : {}),
895
905
  })
896
906
  const steps: EmitStep[] = [
897
907
  { kind: "gitWrite", command: repinScript(PIN_PATH) },
898
908
  { kind: "gitWrite", command: commitPath(PIN_PATH, message) },
899
- { kind: "outcome", command: transitionOutcome(PIN_STATES.add, rest.step.name) },
909
+ { kind: "outcome", command: transitionOutcome(PIN_STATES.add, to) },
900
910
  ]
901
911
  yield* writeScript(json, out, combinedScript(emitScripts(steps).required, ""))
902
912
  })
@@ -217,7 +217,7 @@ describe("currentRun", () => {
217
217
  const before = headOf(repo)
218
218
  const opening = await enter(repo, "side")
219
219
  expect(repo.lastCommitMessage(opening)).toBe(
220
- `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 2`,
220
+ `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 3`,
221
221
  )
222
222
  const fixed = await land(repo, { "fix.txt": "x\n" })
223
223
  const run = await provide(currentRun, repo)
@@ -250,7 +250,7 @@ export const again = workflow(async () => {
250
250
  const before = headOf(repo)
251
251
  const opening = await enter(repo, "again")
252
252
  expect(repo.lastCommitMessage(opening)).toBe(
253
- `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 2`,
253
+ `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 3`,
254
254
  )
255
255
  const fixed = await land(repo, { "fix.txt": "x\n" })
256
256
  const run = await provide(currentRun, repo)
@@ -403,7 +403,7 @@ export const again = workflow(async () => {
403
403
  const repo = repoWith(LINEAR)
404
404
  await land(repo, { "a.txt": "a\n" })
405
405
  await land(repo)
406
- expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 2")
406
+ expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 3")
407
407
  const run = await provide(currentRun, repo)
408
408
  expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
409
409
  })
package/src/edge/Edge.ts CHANGED
@@ -36,7 +36,9 @@ import {
36
36
  parseCommitMessage,
37
37
  replay,
38
38
  type CommitMessage,
39
+ formatCommitMessage,
39
40
  scopeRunStart,
41
+ setAsideBy,
40
42
  treeFromRecord,
41
43
  type EpisodeCommit,
42
44
  type JudgeVerdict,
@@ -259,6 +261,18 @@ const approvalEntryOf = (
259
261
  },
260
262
  ]
261
263
 
264
+ // What an anchor set aside answers no step: it records no mark, approval or
265
+ // verdict. An anchor naming no commit is replay's to refuse.
266
+ const setAsideIn = (history: History, location: EpisodeLocation): ReadonlySet<string> => {
267
+ const anchored = setAsideBy(
268
+ hashAt(history, location.baseIndex) ?? EMPTY_TREE,
269
+ history
270
+ .slice(location.baseIndex + 1)
271
+ .map((c) => ({ hash: c.hash, parsed: parseCommitMessage(c.message) })),
272
+ )
273
+ return "setAside" in anchored ? anchored.setAside : new Set()
274
+ }
275
+
262
276
  const buildRun = (
263
277
  history: History,
264
278
  location: EpisodeLocation,
@@ -269,6 +283,9 @@ const buildRun = (
269
283
  // A pin commit is no turn; the unpin commit stays, as the one that closes the process.
270
284
  const processCommits = allProcessCommits.filter((_, i) => allParsed[i]!.pin !== "add")
271
285
  const parsed = allParsed.filter((m) => m.pin !== "add")
286
+ const setAside = setAsideIn(history, location)
287
+ const answering = parsed.filter((_, i) => !setAside.has(processCommits[i]!.hash))
288
+ const answeringCommits = processCommits.filter((c) => !setAside.has(c.hash))
272
289
  const first = parsed[0]
273
290
  const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
274
291
  const head = history[history.length - 1]
@@ -280,10 +297,10 @@ const buildRun = (
280
297
  diffBase: first?.reviewBase ?? startParentHash,
281
298
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
282
299
  costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
283
- judgeVerdicts: parsed.flatMap((m) => m.judge),
300
+ judgeVerdicts: answering.flatMap((m) => m.judge),
284
301
  pinCommitted: allParsed.some((m) => m.pin === "add"),
285
- marks: parsed.flatMap((m) => m.marks),
286
- approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
302
+ marks: answering.flatMap((m) => m.marks),
303
+ approvals: answeringCommits.flatMap((c, i) => approvalEntryOf(c.hash, answering[i]!)),
287
304
  pinnedVars: { ...first?.vars },
288
305
  headTurn: headTurnOf(head),
289
306
  closingHash,
@@ -1012,6 +1029,54 @@ export const restAt = (
1012
1029
 
1013
1030
  export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
1014
1031
 
1032
+ /**
1033
+ * Where `gtd repin` anchors a pinned process the live workflow no longer
1034
+ * replays: the commit replay last stood on, and the step the live workflow
1035
+ * reaches from there on HEAD's tree — the unpin when the flow ends instead.
1036
+ * `undefined` when there is nothing to anchor: no divergence, or no pin.
1037
+ */
1038
+ export const repinAnchor: Effect.Effect<
1039
+ { readonly at: string; readonly to: string } | undefined,
1040
+ Error,
1041
+ RestRequirements
1042
+ > = Effect.gen(function* () {
1043
+ const git = yield* GitService
1044
+ const config = yield* (yield* ConfigService).load
1045
+ const workspace = yield* Workspace
1046
+ const run = yield* computeProcessRun(git, config.workflow)
1047
+ const { def, setup } = yield* setupFor(config, run, workspace, yield* Host)
1048
+ const diverged = yield* Effect.promise(() => replayFor(setup))
1049
+ if (diverged.kind !== "divergence" || !headHasPin(workspace, run)) return undefined
1050
+ // A trial anchor naming the reserved pin state diverges at whatever step the
1051
+ // flow reaches once it stands on HEAD's tree.
1052
+ const trial: EpisodeCommit = {
1053
+ hash: "",
1054
+ message: formatCommitMessage({
1055
+ actor: "check",
1056
+ from: PIN_STATES.add,
1057
+ to: PIN_STATES.add,
1058
+ pin: "add",
1059
+ anchor: diverged.at,
1060
+ }),
1061
+ tree: setup.episode.at(-1)?.tree ?? setup.base.tree,
1062
+ }
1063
+ const outcome = yield* Effect.promise(() =>
1064
+ replayFor({ ...setup, episode: [...setup.episode, trial] }),
1065
+ )
1066
+ if (outcome.kind !== "divergence") {
1067
+ return yield* Effect.fail(replayError(outcome) ?? new Error("gtd repin: replay found no step"))
1068
+ }
1069
+ const to = outcome.reached ?? PIN_STATES.remove
1070
+ if (run.workflow === undefined && to === def.initial) {
1071
+ return yield* Effect.fail(
1072
+ new Error(
1073
+ `gtd repin: the live workflow continues this process at "${to}", the default workflow's first step, where a finished process waits — run \`gtd abandon\` to start over`,
1074
+ ),
1075
+ )
1076
+ }
1077
+ return { at: diverged.at, to }
1078
+ })
1079
+
1015
1080
  /** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
1016
1081
  const restingOn = (outcome: ReplayOutcome, pin: PinChange | undefined) => {
1017
1082
  if (outcome.kind === "rest") return { reached: outcome, step: outcome.rest }
package/src/edge/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  summaryRun,
4
4
  restAt,
5
5
  currentRest,
6
+ repinAnchor,
6
7
  startRefusal,
7
8
  reviewBaseFor,
8
9
  type RenderedRest,
@@ -3,11 +3,12 @@ import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js
3
3
 
4
4
  describe("the history format", () => {
5
5
  it("accepts a history in the current format and in the one before it", () => {
6
- expect(formatFault([{ hash: "a".repeat(40), format: 2 }])).toBeUndefined()
6
+ expect(formatFault([{ hash: "a".repeat(40), format: 3 }])).toBeUndefined()
7
7
  expect(
8
8
  formatFault([
9
9
  { hash: "a".repeat(40), format: 1 },
10
10
  { hash: "b".repeat(40), format: 2 },
11
+ { hash: "c".repeat(40), format: 3 },
11
12
  ]),
12
13
  ).toBeUndefined()
13
14
  expect(formatFault([])).toBeUndefined()
@@ -16,11 +17,11 @@ describe("the history format", () => {
16
17
  it("names the first foreign commit, the found and readable formats, and what to do", () => {
17
18
  const fault = formatFault([
18
19
  { hash: "a".repeat(40), format: 1 },
19
- { hash: "b".repeat(40), format: 3 },
20
- { hash: "c".repeat(40), format: 4 },
20
+ { hash: "b".repeat(40), format: 4 },
21
+ { hash: "c".repeat(40), format: 5 },
21
22
  ])
22
23
  expect(fault).toBe(
23
- "gtd: commit bbbbbbb is written in history format 3, but this gtd reads formats 1 to 2 — run a gtd release that reads format 3 to continue this process",
24
+ "gtd: commit bbbbbbb is written in history format 4, but this gtd reads formats 1 to 3 — run a gtd release that reads format 4 to continue this process",
24
25
  )
25
26
  })
26
27
  })
@@ -35,8 +36,8 @@ describe("Gtd-Approved and the history format", () => {
35
36
  expect(message).toContain(
36
37
  'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
37
38
  )
38
- expect(message).toContain("Gtd-Format: 2")
39
- expect(parseCommitMessage(message).format).toBe(2)
39
+ expect(message).toContain("Gtd-Format: 3")
40
+ expect(parseCommitMessage(message).format).toBe(3)
40
41
  })
41
42
  })
42
43
 
@@ -50,7 +51,7 @@ describe("Gtd-Pin", () => {
50
51
  branch: "main",
51
52
  })
52
53
  expect(message).toBe(
53
- "gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 2",
54
+ "gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 3",
54
55
  )
55
56
  expect(parseCommitMessage(message).pin).toBe("remove")
56
57
  expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: add").pin).toBe("add")
@@ -60,3 +61,21 @@ describe("Gtd-Pin", () => {
60
61
  expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
61
62
  })
62
63
  })
64
+
65
+ describe("Gtd-Anchor", () => {
66
+ it("round-trips after Gtd-Pin, and reads a non-hash value as no anchor", () => {
67
+ const anchor = "a".repeat(40)
68
+ const message = formatCommitMessage({
69
+ actor: "check",
70
+ from: "pin",
71
+ to: "b",
72
+ pin: "add",
73
+ anchor,
74
+ })
75
+ expect(message).toBe(
76
+ `gtd(check): pin → b\n\nGtd-Pin: add\nGtd-Anchor: ${anchor}\nGtd-Format: 3`,
77
+ )
78
+ expect(parseCommitMessage(message).anchor).toBe(anchor)
79
+ expect(parseCommitMessage("gtd(check): pin → b\n\nGtd-Anchor: HEAD~2").anchor).toBeUndefined()
80
+ })
81
+ })
@@ -1,7 +1,8 @@
1
1
  // Bump only when what replay reads back out of git changes incompatibly — the
2
2
  // package version must not stand in for it, since most majors leave history
3
- // untouched. Format 2 adds pin commits, which format 1 would replay as turns.
4
- export const HISTORY_FORMAT = 2
3
+ // untouched. Format 2 adds pin commits, which format 1 would replay as turns;
4
+ // format 3 adds the anchor, whose set-aside commits format 2 would replay.
5
+ export const HISTORY_FORMAT = 3
5
6
  const OLDEST_READABLE_FORMAT = 1
6
7
 
7
8
  const short = (hash: string): string => hash.slice(0, 7)
@@ -1154,6 +1154,8 @@ describe("human({ approve })", () => {
1154
1154
  expect(outcome).toEqual({
1155
1155
  kind: "divergence",
1156
1156
  message: `gtd: commit ${hashOf(1).slice(0, 7)} completes approval gate "gate#1" but records no Gtd-Approved for it — the workflow changed under this process; run \`gtd abandon\` to start over`,
1157
+ at: hashOf(0),
1158
+ reached: "gate",
1157
1159
  })
1158
1160
  })
1159
1161
 
@@ -1271,3 +1273,88 @@ describe("pin commits", () => {
1271
1273
  expect((await replayOf(flow, h)).kind).toBe("ended")
1272
1274
  })
1273
1275
  })
1276
+
1277
+ describe("replay: a repin's anchor", () => {
1278
+ const anchorTo = (to: string, at: number): string =>
1279
+ formatCommitMessage({ actor: "check", from: "pin", to, pin: "add", anchor: hashOf(at) })
1280
+
1281
+ // The old flow went idle → build → old; this one goes idle → build → check.
1282
+ const anchoredHistory = (): History =>
1283
+ new History()
1284
+ .land("human", "idle", 1, "build", { "a.txt": "a" })
1285
+ .land("agent", "build", 1, "old", { "b.txt": "b" })
1286
+ .raw(anchorTo("build", 1))
1287
+
1288
+ it("records where replay diverged and the step it reached there", async () => {
1289
+ const h = new History().land("human", "idle", 1, "fix", { "TODO.md": "go" })
1290
+ const outcome = await replayOf(fixLoop, h)
1291
+ expect(outcome).toMatchObject({ kind: "divergence", at: hashOf(1), reached: "check" })
1292
+ })
1293
+
1294
+ it("rests at the anchored step, redone on the anchor's tree", async () => {
1295
+ let afterBuild: readonly string[] = []
1296
+ const flow: Flow = async () => {
1297
+ await human("idle")
1298
+ await agent("build", "p")
1299
+ afterBuild = changes().map((c) => c.path)
1300
+ await agent("check", "p")
1301
+ }
1302
+ expect(restName(await replayOf(flow, anchoredHistory()))).toBe("build#1")
1303
+ const outcome = await replayOf(flow, anchoredHistory(), { pending: { "c.txt": "c" } })
1304
+ expect(restName(outcome)).toBe("check#1")
1305
+ expect(afterBuild).toEqual(["c.txt"])
1306
+ })
1307
+
1308
+ it("fails changesSince() on a commit it set aside", async () => {
1309
+ const flow: Flow = async () => {
1310
+ await human("idle")
1311
+ changesSince(hashOf(2))
1312
+ await agent("build", "p")
1313
+ }
1314
+ const outcome = await replayOf(flow, anchoredHistory())
1315
+ expect(outcome.kind === "failed" && outcome.message).toContain("a repin set 0000000 aside")
1316
+ })
1317
+
1318
+ it("diverges again when the flow reaches another step than the anchor names", async () => {
1319
+ const flow: Flow = async () => {
1320
+ await human("idle")
1321
+ await agent("make", "p")
1322
+ }
1323
+ expect(await replayOf(flow, anchoredHistory())).toMatchObject({
1324
+ kind: "divergence",
1325
+ at: hashOf(3),
1326
+ reached: "make",
1327
+ })
1328
+ })
1329
+
1330
+ it("lets the flow code leading to the step read HEAD's tree", async () => {
1331
+ const flow: Flow = async () => {
1332
+ await human("idle")
1333
+ await agent("build", `seen: ${read("b.txt") ?? "nothing"}`)
1334
+ }
1335
+ const outcome = await replayOf(flow, anchoredHistory())
1336
+ expect(outcome.kind === "rest" && outcome.rest.request).toMatchObject({ prompt: "seen: b" })
1337
+ })
1338
+
1339
+ it("diverges when the flow ends where it names a step", async () => {
1340
+ const outcome = await replayOf(async () => void (await human("idle")), anchoredHistory())
1341
+ expect(outcome).toMatchObject({ kind: "divergence", at: hashOf(3), reached: undefined })
1342
+ })
1343
+
1344
+ it("closes the process when it names the unpin and the flow ends", async () => {
1345
+ const h = new History()
1346
+ .land("human", "idle", 1, "build", { "a.txt": "a" })
1347
+ .land("agent", "build", 1, "old")
1348
+ .raw(anchorTo("unpin", 1))
1349
+ const outcome = await replayOf(async () => void (await human("idle")), h)
1350
+ expect(outcome.kind).toBe("ended")
1351
+ })
1352
+
1353
+ it("fails when it names no commit replay reads", async () => {
1354
+ const h = anchoredHistory().raw(anchorTo("build", 2))
1355
+ const outcome = await replayOf(async () => void (await human("idle")), h)
1356
+ expect(outcome.kind === "failed" && outcome.message).toContain(
1357
+ "which is no commit replay reads",
1358
+ )
1359
+ })
1360
+ })