@pmelab/gtd 64.3.0 → 66.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.
Files changed (91) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +35 -25
  3. package/claude/hooks/assist.ts +9 -35
  4. package/claude/hooks/doors.ts +4 -4
  5. package/claude/hooks/drive.ts +10 -1
  6. package/claude/hooks/git.ts +56 -0
  7. package/claude/hooks/handoff.ts +11 -11
  8. package/claude/hooks/jobs.ts +8 -8
  9. package/claude/hooks/pr.ts +86 -26
  10. package/claude/hooks/register.tsx +35 -82
  11. package/claude/hooks/tools.ts +7 -20
  12. package/claude/hooks/wording.ts +3 -3
  13. package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
  14. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  15. package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
  16. package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
  17. package/dist/flows.mjs +1 -1
  18. package/dist/gtd.bundle.mjs +965 -163
  19. package/dist/gtd.core.mjs +150 -134
  20. package/dist/steering.mjs +1 -1
  21. package/dist/testkit/gherkin.mjs +1 -1
  22. package/dist/testkit.mjs +2 -2
  23. package/dist/web/main.css +1 -1
  24. package/dist/web/main.js +185 -18
  25. package/dist/wire.mjs +1 -1
  26. package/dist/workflow-kit.mjs +9 -3
  27. package/package.json +1 -1
  28. package/src/cli/Cli.ts +5 -5
  29. package/src/cli/History.test.ts +10 -8
  30. package/src/cli/Metrics.test.ts +2 -1
  31. package/src/cli/Program.test.ts +4 -2
  32. package/src/cli/Program.ts +105 -7
  33. package/src/edge/Edge.test.ts +37 -16
  34. package/src/edge/Edge.ts +380 -33
  35. package/src/emit/GitScript.ts +116 -15
  36. package/src/emit/index.ts +7 -0
  37. package/src/flows/config.ts +7 -1
  38. package/src/flows/helpers.ts +36 -18
  39. package/src/flows/index.test.ts +4 -0
  40. package/src/flows/index.ts +7 -0
  41. package/src/flows/runtime.ts +153 -15
  42. package/src/install/Install.ts +9 -2
  43. package/src/platform/Git.test.ts +7 -11
  44. package/src/platform/NodeSync.ts +2 -0
  45. package/src/platform/RetainedHistory.ts +3 -0
  46. package/src/platform/SyncIo.ts +2 -0
  47. package/src/platform/Workspace.test.ts +37 -0
  48. package/src/platform/Workspace.ts +36 -0
  49. package/src/platform/index.ts +1 -0
  50. package/src/replay/Replay.test.ts +9 -0
  51. package/src/replay/Replay.ts +92 -16
  52. package/src/replay/Trailers.ts +17 -0
  53. package/src/replay/Tree.ts +5 -0
  54. package/src/replay/index.ts +1 -1
  55. package/src/steering/FORMATS.md +3 -1
  56. package/src/steering/SteeringFormat.ts +13 -0
  57. package/src/steering/fixtures/review/sectioned.md +1 -1
  58. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  59. package/src/steering/index.ts +1 -0
  60. package/src/steering/review.test.ts +181 -5
  61. package/src/steering/review.ts +160 -18
  62. package/src/step/LandStep.ts +12 -0
  63. package/src/step/RepoSnapshot.ts +15 -0
  64. package/src/step/index.ts +1 -0
  65. package/src/step/planStart.test.ts +4 -3
  66. package/src/step/planStart.ts +7 -0
  67. package/src/step/planStep.ts +22 -17
  68. package/src/step/startBranch.ts +54 -0
  69. package/src/twins/EmittedScriptRecognizer.ts +118 -1
  70. package/src/twins/EmittedScripts.test.ts +147 -1
  71. package/src/twins/InMemRepo.ts +140 -3
  72. package/src/twins/Layers.ts +1 -0
  73. package/src/twins/SyncIo.test.ts +19 -4
  74. package/src/twins/SyncIo.ts +13 -1
  75. package/src/wire/BeatDocument.test.ts +15 -0
  76. package/src/wire/BeatDocument.ts +8 -2
  77. package/src/wire/Demand.ts +26 -12
  78. package/src/wire/constants.ts +3 -0
  79. package/src/wire/golden/1/beat-capture.json +1 -0
  80. package/src/wire/golden/1/beat-message.json +1 -0
  81. package/src/wire/golden/1/beat-prompt.json +1 -0
  82. package/src/wire/golden/1/beat-script.json +1 -0
  83. package/src/wire/golden/1/beat-stalled.json +1 -0
  84. package/src/wire/types.ts +4 -0
  85. package/src/workflow/Workflow.ts +3 -0
  86. package/src/workflow/compile.ts +2 -0
  87. package/src/workflow/load.ts +2 -0
  88. package/src/workflow-kit/index.test.ts +1 -0
  89. package/src/workflow-kit/index.ts +1 -0
  90. package/src/workflow-kit/scenarios.ts +8 -0
  91. package/claude/hooks/ship.ts +0 -730
@@ -10,6 +10,9 @@ import {
10
10
  import { runCli } from "./index.js"
11
11
  import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
12
12
 
13
+ // A process runs on `gtd/<b>`; the recorded branch is the user's `<b>`.
14
+ const USER_BRANCH = "main"
15
+
13
16
  const CONFIG = `import { agent, human } from "@pmelab/gtd/flows"
14
17
 
15
18
  export default async () => {
@@ -32,8 +35,9 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
32
35
  (JSON.parse(stdout) as { script: string }).script,
33
36
  )
34
37
  if (!outcome.ok) throw new Error(outcome.error)
38
+ const branch = repo.branchName()
35
39
  await takePinBeat(repo, "remove")
36
- return repo.resolveRef("HEAD")!
40
+ return repo.resolveRef(`refs/heads/${branch}`)!
37
41
  }
38
42
 
39
43
  const finishedProcess = async (repo: InMemRepo, file: string) => {
@@ -82,7 +86,7 @@ describe("historyEntries", () => {
82
86
  expect(list.map((e) => e.entry)).toEqual([second.entry, first.entry])
83
87
  expect(list[0]).toMatchObject({
84
88
  ref: `refs/gtd/history/${second.entry}`,
85
- branch: repo.branchName(),
89
+ branch: USER_BRANCH,
86
90
  tip: second.tip,
87
91
  subject: "gtd(human): idle → building",
88
92
  })
@@ -169,16 +173,14 @@ describe("gtd history list", () => {
169
173
  const text = await gtd(repo, "history", "list")
170
174
  const line = text.stdout.trimEnd().split("\n")
171
175
  expect(line).toHaveLength(1)
172
- expect(
173
- line[0]!.startsWith(`${hash.slice(0, 7)} ${repo.branchName()} ${tip.slice(0, 7)} `),
174
- ).toBe(true)
176
+ expect(line[0]!.startsWith(`${hash.slice(0, 7)} ${USER_BRANCH} ${tip.slice(0, 7)} `)).toBe(true)
175
177
  expect(line[0]!.endsWith(" gtd(human): idle → building")).toBe(true)
176
178
  const json = await gtd(repo, "history", "list", "--json")
177
179
  expect(JSON.parse(json.stdout)).toEqual([
178
180
  expect.objectContaining({
179
181
  ref: `refs/gtd/history/${hash}`,
180
182
  entry: hash,
181
- branch: repo.branchName(),
183
+ branch: USER_BRANCH,
182
184
  tip,
183
185
  subject: "gtd(human): idle → building",
184
186
  }),
@@ -195,7 +197,7 @@ describe("gtd history show", () => {
195
197
  expect(out.exitCode).toBe(0)
196
198
  const lines = out.stdout.trimEnd().split("\n")
197
199
  expect(lines[0]).toBe(
198
- `refs/gtd/history/${hash} (branch ${repo.branchName()}, tip ${tip.slice(0, 7)})`,
200
+ `refs/gtd/history/${hash} (branch ${USER_BRANCH}, tip ${tip.slice(0, 7)})`,
199
201
  )
200
202
  expect(out.stdout).toContain("gtd(human): idle → building")
201
203
  expect(lines[lines.length - 1]).toBe("ended (return)")
@@ -206,7 +208,7 @@ describe("gtd history show", () => {
206
208
  const { entry: hash, tip } = await finishedProcess(repo, "a.txt")
207
209
  const out = await gtd(repo, "history", "show", hash, "--json")
208
210
  const doc = JSON.parse(out.stdout)
209
- expect(doc).toMatchObject({ entry: hash, tip, ended: "return", branch: repo.branchName() })
211
+ expect(doc).toMatchObject({ entry: hash, tip, ended: "return", branch: USER_BRANCH })
210
212
  expect(doc.steps.length).toBeGreaterThan(0)
211
213
  expect(doc.version).toHaveProperty("running")
212
214
  })
@@ -233,8 +233,9 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
233
233
  (JSON.parse(stdout) as { script: string }).script,
234
234
  )
235
235
  if (!outcome.ok) throw new Error(outcome.error)
236
+ const branch = repo.branchName()
236
237
  await takePinBeat(repo, "remove")
237
- return repo.resolveRef("HEAD")!
238
+ return repo.resolveRef(`refs/heads/${branch}`)!
238
239
  }
239
240
 
240
241
  describe("readProcessMetrics", () => {
@@ -1694,7 +1694,9 @@ export default async () => {
1694
1694
  const { exitCode, script } = await landAndApply(repo)
1695
1695
  expect(exitCode).toBe(0)
1696
1696
  expect(script).not.toContain("fmt-notes")
1697
- expect(repo.lastCommitSubject("HEAD~1")).toBe("gtd(agent): drafting → gtd:unpin")
1697
+ expect(repo.lastCommitSubject(`${repo.resolveRef("gtd/main")}~1`)).toBe(
1698
+ "gtd(agent): drafting → gtd:unpin",
1699
+ )
1698
1700
  })
1699
1701
 
1700
1702
  it("`gtd next --json`'s `validate` field carries the mode's format command", async () => {
@@ -1974,7 +1976,7 @@ export default async () => {
1974
1976
  it("refuses once the process has closed and HEAD rests at the initial step again", async () => {
1975
1977
  const repo = await atDeciding()
1976
1978
  await landTurn(repo)
1977
- expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:unpin → idle")
1979
+ expect(repo.lastCommitSubject("gtd/main")).toBe("gtd(check): gtd:unpin → idle")
1978
1980
 
1979
1981
  const { exitCode, stdout, stderr } = await run(repo, "base")
1980
1982
  expect(exitCode).toBe(1)
@@ -9,6 +9,8 @@ import {
9
9
  type GitOperations,
10
10
  type HostOps,
11
11
  HISTORY_REF,
12
+ RESUME_BRANCH_REF,
13
+ processHistoryRef,
12
14
  readRetainedHistory,
13
15
  restorability,
14
16
  loopLogPath,
@@ -67,8 +69,14 @@ import {
67
69
  summaryRun,
68
70
  type RenderedRest,
69
71
  } from "../edge/index.js"
70
- import { formatCommitMessage, PIN_STATES } from "../replay/index.js"
71
- import { planStart, planStep as planStepPure, type JudgeVerdict } from "../step/index.js"
72
+ import { formatCommitMessage, parseCommitMessage, PIN_STATES } from "../replay/index.js"
73
+ import {
74
+ GTD_BRANCH_PREFIX,
75
+ userBranchOf,
76
+ planStart,
77
+ planStep as planStepPure,
78
+ type JudgeVerdict,
79
+ } from "../step/index.js"
72
80
  import {
73
81
  BUILT_IN_MODE_NAMES,
74
82
  formatFromSpec,
@@ -85,6 +93,10 @@ import {
85
93
  deleteRef,
86
94
  hardResetTo,
87
95
  mixedResetTo,
96
+ switchBranchTo,
97
+ switchTo,
98
+ deleteBranch,
99
+ pushRef,
88
100
  unpinScript,
89
101
  repinScript,
90
102
  commitPath,
@@ -749,6 +761,29 @@ const runStartListCommand = (
749
761
  )
750
762
  })
751
763
 
764
+ /**
765
+ * Back on `<b>` when the process ran on `gtd/<b>` and `<b>` moved only by its
766
+ * ships (whose `Gtd-Tip` the tip descends from): the rewind stops at the last
767
+ * ship, so the uncommitted changes are what it left unshipped.
768
+ */
769
+ const abandonTarget = (current: Option.Option<string>, tip: string, startParent: string) =>
770
+ Effect.gen(function* () {
771
+ const git = yield* GitService
772
+ const userBranch = Option.isSome(current) ? userBranchOf(current.value) : undefined
773
+ const userTip =
774
+ userBranch === undefined
775
+ ? Option.none<string>()
776
+ : yield* git.readRefOption(`refs/heads/${userBranch}`)
777
+ if (userBranch === undefined || Option.isNone(userTip)) {
778
+ return { leaves: undefined, rewindTo: startParent }
779
+ }
780
+ if (userTip.value === startParent) return { leaves: userBranch, rewindTo: startParent }
781
+ const shipped = parseCommitMessage(yield* git.lastCommitMessage(userTip.value)).tip
782
+ return shipped !== undefined && (yield* git.isAncestor(shipped, tip))
783
+ ? { leaves: userBranch, rewindTo: userTip.value }
784
+ : { leaves: undefined, rewindTo: startParent }
785
+ })
786
+
752
787
  /**
753
788
  * Ends the process underway without completing it. Nothing is discarded: a
754
789
  * `git reset --mixed` to the process's start commit leaves every turn
@@ -766,6 +801,7 @@ const runAbandonCommand = (
766
801
  json: JsonMode,
767
802
  out: ArtifactOut,
768
803
  ): Effect.Effect<void, Error, CommandRequirements> =>
804
+ // fallow-ignore-next-line complexity
769
805
  Effect.gen(function* () {
770
806
  const git = yield* GitService
771
807
  const config = yield* (yield* ConfigService).load
@@ -790,17 +826,58 @@ const runAbandonCommand = (
790
826
  }
791
827
 
792
828
  const tip = yield* git.resolveRef("HEAD")
829
+ const historyRef = processHistoryRef(run.startHash)
830
+
831
+ const current = yield* git.currentBranch()
832
+ const { leaves, rewindTo } = yield* abandonTarget(current, tip, run.startParentHash)
833
+ const remote = config.historyRemote
793
834
 
794
835
  const steps: EmitStep[] = [
795
836
  { kind: "gitWrite", command: updateRef(HISTORY_REF, tip) },
796
- { kind: "gitWrite", command: mixedResetTo(run.startParentHash) },
837
+ { kind: "gitWrite", command: updateRef(historyRef, tip) },
838
+ { kind: "gitWrite", command: mixedResetTo(rewindTo) },
797
839
  { kind: "gitWrite", command: unpinScript(PIN_PATH) },
798
- { kind: "outcome", command: abandonedOutcome(restState, run.startParentHash, initial) },
840
+ ...(leaves !== undefined
841
+ ? [
842
+ { kind: "gitWrite", command: switchTo(leaves) } as const,
843
+ {
844
+ kind: "gitWrite",
845
+ command: deleteBranch(Option.getOrThrow(current)),
846
+ } as const,
847
+ { kind: "gitWrite", command: updateRef(RESUME_BRANCH_REF, tip) } as const,
848
+ ]
849
+ : [{ kind: "gitWrite", command: deleteRef(RESUME_BRANCH_REF) } as const]),
850
+ { kind: "outcome", command: abandonedOutcome(restState, rewindTo, initial) },
851
+ ...(remote !== undefined
852
+ ? [{ kind: "gitWrite", command: pushRef(remote, historyRef) } as const]
853
+ : []),
799
854
  ]
800
855
  const required = emitScripts(steps).required
801
856
  yield* writeScript(json, out, combinedScript(required, ""))
802
857
  })
803
858
 
859
+ /**
860
+ * `gtd/<b>` when the abandon left it for `<b>` and HEAD is still on `<b>`. A
861
+ * `gtd/<b>` made since is reset only when the retained tip contains it — the
862
+ * `-C` switch would orphan anything else.
863
+ */
864
+ const resumeBranch = (git: GitOperations, tip: string): Effect.Effect<string | undefined, Error> =>
865
+ Effect.gen(function* () {
866
+ if (Option.isNone(yield* git.readRefOption(RESUME_BRANCH_REF))) return undefined
867
+ const current = yield* git.currentBranch()
868
+ if (Option.isNone(current) || current.value.startsWith(GTD_BRANCH_PREFIX)) return undefined
869
+ const branch = `${GTD_BRANCH_PREFIX}${current.value}`
870
+ const existing = yield* git.readRefOption(`refs/heads/${branch}`)
871
+ if (Option.isSome(existing) && !(yield* git.isAncestor(existing.value, tip))) {
872
+ return yield* Effect.fail(
873
+ new Error(
874
+ `gtd restore: branch ${branch} holds commits the retained tip ${tip.slice(0, 7)} does not — merge or delete it first`,
875
+ ),
876
+ )
877
+ }
878
+ return branch
879
+ })
880
+
804
881
  /**
805
882
  * `gtd restore`: hard-reset HEAD back to the tip `gtd abandon` retained.
806
883
  *
@@ -834,7 +911,10 @@ const runRestoreCommand = (
834
911
  const tip = retained.value
835
912
 
836
913
  const headHash = yield* git.resolveRef("HEAD")
837
- const check = yield* restorability(git, headHash, tip)
914
+ // Abandon after a ship leaves HEAD on `<b>` at the bare ship commit: its `Gtd-Tip` is on the retained tip.
915
+ const shippedTip = parseCommitMessage(yield* git.lastCommitMessage(headHash)).tip
916
+ const atShip = shippedTip !== undefined && (yield* git.isAncestor(shippedTip, tip))
917
+ const check = atShip ? { ok: true as const } : yield* restorability(git, headHash, tip)
838
918
  if (!check.ok) {
839
919
  return yield* Effect.fail(
840
920
  new Error(
@@ -847,9 +927,14 @@ const runRestoreCommand = (
847
927
  // A preview of the resulting state, resolved at tip directly since no reset has happened yet.
848
928
  const after = yield* restAt(tip)
849
929
 
930
+ const resumeOn = yield* resumeBranch(git, tip)
850
931
  const steps: EmitStep[] = [
851
- { kind: "gitWrite", command: hardResetTo(tip) },
932
+ {
933
+ kind: "gitWrite",
934
+ command: resumeOn === undefined ? hardResetTo(tip) : switchBranchTo(resumeOn, tip),
935
+ },
852
936
  { kind: "gitWrite", command: deleteRef(HISTORY_REF) },
937
+ { kind: "gitWrite", command: deleteRef(RESUME_BRANCH_REF) },
853
938
  { kind: "outcome", command: restoredOutcome(tip, after.state) },
854
939
  ]
855
940
  const required = emitScripts(steps).required
@@ -1204,6 +1289,17 @@ const computeNextMatch = (rest: Rest): Effect.Effect<NextMatch | null> =>
1204
1289
  (target) => (target === undefined ? null : { target }),
1205
1290
  )
1206
1291
 
1292
+ /** A validator retry's content is the correction; a session that cannot resume needs the original prompt too, and one that can hands a driver that loses it `fresh`. */
1293
+ const retryContent = (
1294
+ kind: BeatKind,
1295
+ rendered: RenderedRest,
1296
+ resumes: boolean,
1297
+ ): { readonly rendered: RenderedRest; readonly fresh?: string } => {
1298
+ if (kind !== "prompt" || rendered.original === undefined) return { rendered }
1299
+ const joined = `${rendered.original}\n\n${rendered.content}`
1300
+ return resumes ? { rendered, fresh: joined } : { rendered: { ...rendered, content: joined } }
1301
+ }
1302
+
1207
1303
  /** Everything one beat needs beyond the resolved rest itself, gathered once so plain/`--json` can never describe different rests for the same beat — built as `wire`'s `Demand` (what to do) and `BeatStatus` (what no driver branches on), then flattened by `beatDocument` into the one wire-shaped object plain/`--json` both render from. */
1208
1304
  const gatherBeatDocument = (
1209
1305
  rest: Rest,
@@ -1222,9 +1318,11 @@ const gatherBeatDocument = (
1222
1318
  ? resolvedValidate.script
1223
1319
  : undefined
1224
1320
  const log = yield* loopLogPath
1321
+ const retry = retryContent(kind, rendered, session?.resume === true)
1225
1322
  const demand = demandOf({
1226
- rendered,
1323
+ rendered: retry.rendered,
1227
1324
  kind,
1325
+ ...(retry.fresh !== undefined ? { fresh: retry.fresh } : {}),
1228
1326
  ...(session !== undefined
1229
1327
  ? { session: { id: session.sessionId, resume: session.resume } }
1230
1328
  : {}),
@@ -100,7 +100,8 @@ const applied = (repo: InMemRepo, script: string): string => {
100
100
  /**
101
101
  * Lands the pending turn the way a driver does — `gtd land --json`, then its
102
102
  * script, then the pin or unpin beat it leads to — and returns the turn's
103
- * commit, or the closing commit when the turn led to the unpin.
103
+ * commit, or the closing commit when the turn led to the unpin (which leaves
104
+ * HEAD back on the user's branch).
104
105
  */
105
106
  const land = async (repo: InMemRepo, files: Files = {}): Promise<string> => {
106
107
  write(repo, files)
@@ -108,7 +109,8 @@ const land = async (repo: InMemRepo, files: Files = {}): Promise<string> => {
108
109
  if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
109
110
  const turn = applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
110
111
  await takePinBeat(repo, "add")
111
- return (await takePinBeat(repo, "remove")) ? headOf(repo) : turn
112
+ const branch = repo.branchName()
113
+ return (await takePinBeat(repo, "remove")) ? headOf(repo, `refs/heads/${branch}`) : turn
112
114
  }
113
115
 
114
116
  const enter = async (repo: InMemRepo, workflow: string, ...vars: string[]): Promise<string> => {
@@ -206,7 +208,8 @@ describe("currentRun", () => {
206
208
  await land(repo, { "a.txt": "a\n" })
207
209
  await land(repo, { "b.txt": "b\n" })
208
210
  const closing = await land(repo, { "c.txt": "c\n" })
209
- expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:unpin → idle")
211
+ expect(repo.lastCommitSubject(closing)).toBe("gtd(check): gtd:unpin → idle")
212
+ repo.checkoutBranch("gtd/main")
210
213
  const next = await land(repo, { "d.txt": "d\n" })
211
214
  const run = await provide(currentRun, repo)
212
215
  expect(run.startParentHash).toBe(closing)
@@ -413,7 +416,12 @@ export const again = workflow(async () => {
413
416
  await land(repo, { "a.txt": "a\n" })
414
417
  await land(repo, { "b.txt": "b\n" })
415
418
  const run = await provide(currentRun, repo)
416
- expect(run.headTurn).toEqual({ state: "checking", actor: "agent", empty: false, step: true })
419
+ expect(run.headTurn).toEqual({
420
+ state: "checking",
421
+ actor: "agent",
422
+ empty: false,
423
+ step: true,
424
+ })
417
425
  })
418
426
 
419
427
  it("is undefined at a pin commit, which is no turn", async () => {
@@ -429,7 +437,12 @@ export const again = workflow(async () => {
429
437
  await land(repo)
430
438
  expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 4")
431
439
  const run = await provide(currentRun, repo)
432
- expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
440
+ expect(run.headTurn).toEqual({
441
+ state: "building",
442
+ actor: "agent",
443
+ empty: true,
444
+ step: false,
445
+ })
433
446
  })
434
447
 
435
448
  it("is undefined at a non-gtd HEAD", async () => {
@@ -560,7 +573,7 @@ describe("summaryRun", () => {
560
573
 
561
574
  const ordinary = await provide(currentRun, repo)
562
575
  expect(ordinary.trace).toEqual([])
563
- expect(ordinary.startParentHash).toBe(closing)
576
+ expect(ordinary.startParentHash).toBe(boundary)
564
577
 
565
578
  const summary = await provide(summaryRun, repo)
566
579
  expect(summary.closingHash).toBe(closing)
@@ -645,14 +658,17 @@ describe("process marks", () => {
645
658
  })
646
659
  })
647
660
 
648
- const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
661
+ const withTipMessage = (
662
+ repo: InMemRepo,
663
+ edit: (message: string) => string,
664
+ edited = repo.resolveRef("HEAD"),
665
+ ): GitOperations => {
649
666
  const real = fakeGitOperations(repo)
650
- const head = repo.resolveRef("HEAD")
651
667
  return {
652
668
  ...real,
653
669
  commitHistory: (base, tip) =>
654
670
  Effect.map(real.commitHistory(base, tip), (commits) =>
655
- commits.map((c) => (c.hash === head ? { ...c, message: edit(c.message) } : c)),
671
+ commits.map((c) => (c.hash === edited ? { ...c, message: edit(c.message) } : c)),
656
672
  ),
657
673
  }
658
674
  }
@@ -683,7 +699,8 @@ export default workflow(async () => {
683
699
  if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
684
700
  const turn = applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
685
701
  await takePinBeat(repo, "add")
686
- return (await takePinBeat(repo, "remove")) ? headOf(repo) : turn
702
+ const branch = repo.branchName()
703
+ return (await takePinBeat(repo, "remove")) ? headOf(repo, `refs/heads/${branch}`) : turn
687
704
  }
688
705
  type Rendered = {
689
706
  entryCommit: string
@@ -1366,7 +1383,7 @@ describe("finishing landings keep history", () => {
1366
1383
  expect(landing.spec.pin).toBe("remove")
1367
1384
  expect(landing.keepHistory).toBe(processHistoryRef(building))
1368
1385
  expect(landing.spec.version).toBe(GTD_VERSION)
1369
- expect(landing.spec.branch).toBe(repo.branchName())
1386
+ expect(landing.spec.branch).toBe("main")
1370
1387
  })
1371
1388
 
1372
1389
  it("restart() is a finish too", async () => {
@@ -1647,8 +1664,10 @@ export default async () => {
1647
1664
 
1648
1665
  it("warns, never refuses, when the recorded version differs", async () => {
1649
1666
  const { repo, tip } = await finished()
1650
- const git = withTipMessage(repo, (m) =>
1651
- m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
1667
+ const git = withTipMessage(
1668
+ repo,
1669
+ (m) => m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
1670
+ tip,
1652
1671
  )
1653
1672
  const trail = await Effect.runPromise(
1654
1673
  historyTrail(tip).pipe(
@@ -1663,7 +1682,7 @@ export default async () => {
1663
1682
 
1664
1683
  it("a missing recorded version counts as different", async () => {
1665
1684
  const { repo, tip } = await finished()
1666
- const git = withTipMessage(repo, (m) => m.replace(/Gtd-Version: .*\n?/, ""))
1685
+ const git = withTipMessage(repo, (m) => m.replace(/Gtd-Version: .*\n?/, ""), tip)
1667
1686
  const trail = await Effect.runPromise(
1668
1687
  historyTrail(tip).pipe(
1669
1688
  Effect.provideService(GitService, git),
@@ -1677,8 +1696,10 @@ export default async () => {
1677
1696
  it("reads a recorded version carrying control bytes as unknown", async () => {
1678
1697
  const { repo, tip } = await finished()
1679
1698
  const raw = "1.0\u001b]0;x\u0007"
1680
- const git = withTipMessage(repo, (m) =>
1681
- m.replace(`Gtd-Version: ${GTD_VERSION}`, `Gtd-Version: ${raw}`),
1699
+ const git = withTipMessage(
1700
+ repo,
1701
+ (m) => m.replace(`Gtd-Version: ${GTD_VERSION}`, `Gtd-Version: ${raw}`),
1702
+ tip,
1682
1703
  )
1683
1704
  const trail = await Effect.runPromise(
1684
1705
  historyTrail(tip).pipe(