@pmelab/gtd 64.2.0 → 65.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 (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +36 -26
  3. package/claude/hooks/assist.ts +9 -35
  4. package/claude/hooks/doors.ts +4 -4
  5. package/claude/hooks/git.ts +56 -0
  6. package/claude/hooks/handoff.ts +11 -11
  7. package/claude/hooks/jobs.ts +8 -8
  8. package/claude/hooks/pr.ts +8 -8
  9. package/claude/hooks/register.tsx +16 -78
  10. package/claude/hooks/tools.ts +7 -20
  11. package/claude/hooks/wording.ts +3 -3
  12. package/dist/chunks/{flows-dnIE_qbe.mjs → flows-DGs0W3HI.mjs} +19 -2
  13. package/dist/chunks/{testkit-BwCH8ky6.mjs → testkit-CgrLmpdJ.mjs} +741 -80
  14. package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-BNS8ciNu.mjs} +5 -3
  15. package/dist/flows.mjs +1 -1
  16. package/dist/gtd.bundle.mjs +565 -114
  17. package/dist/gtd.core.mjs +132 -129
  18. package/dist/testkit/gherkin.mjs +1 -1
  19. package/dist/testkit.mjs +2 -2
  20. package/dist/web/main.js +2 -2
  21. package/dist/wire.mjs +1 -1
  22. package/dist/workflow-kit.mjs +1 -1
  23. package/package.json +1 -1
  24. package/src/cli/Cli.ts +5 -5
  25. package/src/cli/History.test.ts +10 -8
  26. package/src/cli/Metrics.test.ts +2 -1
  27. package/src/cli/Program.test.ts +4 -2
  28. package/src/cli/Program.ts +87 -5
  29. package/src/edge/Edge.test.ts +37 -16
  30. package/src/edge/Edge.ts +296 -31
  31. package/src/emit/GitScript.ts +116 -15
  32. package/src/emit/index.ts +7 -0
  33. package/src/flows/config.ts +7 -1
  34. package/src/flows/index.test.ts +1 -0
  35. package/src/flows/index.ts +2 -0
  36. package/src/flows/runtime.ts +32 -1
  37. package/src/platform/RetainedHistory.ts +3 -0
  38. package/src/platform/index.ts +1 -0
  39. package/src/replay/Replay.test.ts +9 -0
  40. package/src/replay/Replay.ts +59 -15
  41. package/src/replay/Trailers.ts +14 -0
  42. package/src/replay/index.ts +1 -1
  43. package/src/step/LandStep.ts +12 -0
  44. package/src/step/RepoSnapshot.ts +11 -0
  45. package/src/step/index.ts +1 -0
  46. package/src/step/planStart.test.ts +4 -3
  47. package/src/step/planStart.ts +7 -0
  48. package/src/step/planStep.ts +17 -12
  49. package/src/step/startBranch.ts +54 -0
  50. package/src/twins/EmittedScriptRecognizer.ts +117 -0
  51. package/src/twins/EmittedScripts.test.ts +147 -1
  52. package/src/twins/InMemRepo.ts +116 -0
  53. package/src/wire/Demand.ts +22 -10
  54. package/src/wire/constants.ts +3 -0
  55. package/src/wire/types.ts +4 -0
  56. package/src/workflow/Workflow.ts +1 -0
  57. package/src/workflow/compile.ts +2 -0
  58. package/src/workflow/load.ts +2 -0
  59. package/claude/hooks/ship.ts +0 -730
@@ -1,4 +1,4 @@
1
- import { n as runsItsScript, r as testRepo } from "../chunks/testkit-BwCH8ky6.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-CgrLmpdJ.mjs";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { resolve } from "node:path";
4
4
  import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
package/dist/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { H as installContext } from "./chunks/flows-dnIE_qbe.mjs";
2
- import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-BwCH8ky6.mjs";
1
+ import { H as installContext } from "./chunks/flows-DGs0W3HI.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-CgrLmpdJ.mjs";
3
3
  export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
package/dist/web/main.js CHANGED
@@ -35949,8 +35949,8 @@ const openable = (step) => step?.status === "ok" && step.file !== void 0;
35949
35949
  * `"qa"` means `Plan`; `"findings"` means `Findings`; anything else (absent, or an unregistered mode name)
35950
35950
  * falls through to `FreeForm`, which reads the file as plain markdown and
35951
35951
  * offers only a note per block, plus a textfield when it's empty. Neither
35952
- * screen offers a way back to a list — `done` is the only
35953
- * thing that ends the turn, and it exits the process.
35952
+ * screen offers a way back to a list — `done` hands the turn back (the
35953
+ * server keeps running until the outer loop moves on).
35954
35954
  *
35955
35955
  * Never returns `null`: a blank white screen was package 03's own regression
35956
35956
  * (a phone over a tailnet with nothing on-screen to explain why) — every
package/dist/wire.mjs CHANGED
@@ -1 +1 @@
1
- export { O as ACCESS_REFUSAL, _ as BEAT_KINDS, f as DEMAND_BRIEFING, x as EXIT_CODES, S as EXIT_OK, C as EXIT_RUNTIME_ERROR, w as EXIT_SIGINT, T as EXIT_SIGTERM, E as EXIT_STRAYS_FOUND, D as EXIT_USAGE_ERROR, k as PIN_PATH, A as UNATTRIBUTED_MODEL, j as WIRE_SCHEMA, m as beatDocument, v as beatKindOf, y as demandOf, t as globMatches, i as judgeJson, s as landFields, c as landProseText, l as noopText, h as renderBeatJson, p as renderBeatPlain, M as renderFormat, u as renderLandJson, d as renderLandPlain, a as renderScriptJson, n as resolveSession, o as scriptFields, r as selectPath, b as stallDiagnosis, g as statusOf } from "./chunks/wire-CaPJ9Tme.mjs";
1
+ export { O as ACCESS_REFUSAL, _ as BEAT_KINDS, f as DEMAND_BRIEFING, x as EXIT_CODES, S as EXIT_OK, C as EXIT_RUNTIME_ERROR, w as EXIT_SIGINT, T as EXIT_SIGTERM, E as EXIT_STRAYS_FOUND, D as EXIT_USAGE_ERROR, k as PIN_PATH, A as RUN_FAILED_PATH, j as UNATTRIBUTED_MODEL, M as WIRE_SCHEMA, m as beatDocument, v as beatKindOf, y as demandOf, t as globMatches, i as judgeJson, s as landFields, c as landProseText, l as noopText, h as renderBeatJson, p as renderBeatPlain, N as renderFormat, u as renderLandJson, d as renderLandPlain, a as renderScriptJson, n as resolveSession, o as scriptFields, r as selectPath, b as stallDiagnosis, g as statusOf } from "./chunks/wire-BNS8ciNu.mjs";
@@ -1,4 +1,4 @@
1
- import { B as head, F as changesSince, G as mark, P as changes, X as read, Z as refuse, d as hasThreadFor, et as scope, g as requireReplies, v as requireThreadsClosed } from "./chunks/flows-dnIE_qbe.mjs";
1
+ import { B as head, F as changesSince, G as mark, P as changes, X as read, Z as refuse, d as hasThreadFor, et as scope, g as requireReplies, v as requireThreadsClosed } from "./chunks/flows-DGs0W3HI.mjs";
2
2
  import { C as reviewRisks, N as parseThreads, S as reviewNotes, T as unansweredNotes, n as stripCodeThreads, x as reviewDecisions } from "./chunks/steering-DEh_MS40.mjs";
3
3
  // src/workflow-kit/health.ts
4
4
  /** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "64.2.0",
3
+ "version": "65.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
@@ -445,7 +445,7 @@ gtd writes nothing itself. Plain output names the commit it would make; --json=s
445
445
  arity: { name: "name", variadic: "args", bare: true },
446
446
  group: "Start and stop",
447
447
  summary: "Start a workflow or door; bare, list them",
448
- details: `Start a new process: print the script that makes its first commit, which also commits whatever the working tree holds.
448
+ details: `Start a new process: print the script that makes its first commit, which also commits whatever the working tree holds. The process runs on the branch \`gtd/<b>\` (created, or reused when nothing on it would be lost), never on \`<b>\`; a branch already named \`gtd/…\` and a detached HEAD start in place.
449
449
 
450
450
  <name> is a door — a named shortcut whose positional args set the workflow's settings — or else a workflow the config exports, which takes no args. A door shadows a workflow of the same name. --var pins a setting either way.
451
451
 
@@ -459,7 +459,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
459
459
  arity: "none",
460
460
  group: "Start and stop",
461
461
  summary: "Stop the process, keeping its changes",
462
- details: `Stop the process underway without finishing it. The printed script moves HEAD back to the commit the process started from and keeps everything it produced as uncommitted changes; \`gtd restore\` undoes it. With no process underway the script does nothing.
462
+ details: `Stop the process underway without finishing it. The printed script moves HEAD back to the commit the process started from, or to its last ship, and keeps everything it produced as uncommitted changes, switches back to \`<b>\` and deletes \`gtd/<b>\`; it keeps the abandoned tip at \`refs/gtd/history/<entry>\` (pushed to \`historyRemote\` when set). \`gtd restore\` undoes it. With no process underway the script does nothing.
463
463
 
464
464
  s=$(gtd abandon --json=script) && sh -c "$s"`,
465
465
  },
@@ -469,7 +469,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
469
469
  arity: "none",
470
470
  group: "Start and stop",
471
471
  summary: "Bring back the last abandoned process",
472
- details: `Bring back the process the last \`gtd abandon\` stopped: the printed script resets HEAD to that process's last commit. Refuses on a dirty working tree, when nothing was abandoned, or when commits made since would be lost.`,
472
+ details: `Bring back the process the last \`gtd abandon\` stopped: the printed script puts that process's last commit back, on a recreated \`gtd/<b>\` when the process ran on one. Refuses on a dirty working tree, when nothing was abandoned, or when commits made since would be lost.`,
473
473
  },
474
474
  {
475
475
  token: "repin",
@@ -487,7 +487,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
487
487
  arity: "none",
488
488
  group: "Inspect",
489
489
  summary: "Print the prompt for the closing message",
490
- details: `Print the prompt for an agent to write the closing message of the process HEAD closes or sits inside: its first commit, each commit a person made, the diff range to read, and its cost. Writes nothing — what to do with the answer (a squash message, a PR body) is the driver's call.
490
+ details: `Print the prompt for an agent to write the closing message of the process HEAD closes or sits inside (back on a user branch, the one its gtd/ branch closes): its first commit, each commit a person made, the diff range to read, and its cost. Writes nothing — what to do with the answer (a squash message, a PR body) is the driver's call.
491
491
 
492
492
  Refuses when the workflow declares no summary template, or when the process has no commits.`,
493
493
  },
@@ -557,7 +557,7 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
557
557
  arity: "none",
558
558
  group: "Inspect",
559
559
  summary: "Serve the web and phone client",
560
- details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing.`,
560
+ details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing. Done saves the steering file and keeps serving; the server exits when the worktree moves on to another step, or on a signal.`,
561
561
  },
562
562
  {
563
563
  token: "guide",
@@ -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
  *
@@ -847,9 +924,14 @@ const runRestoreCommand = (
847
924
  // A preview of the resulting state, resolved at tip directly since no reset has happened yet.
848
925
  const after = yield* restAt(tip)
849
926
 
927
+ const resumeOn = yield* resumeBranch(git, tip)
850
928
  const steps: EmitStep[] = [
851
- { kind: "gitWrite", command: hardResetTo(tip) },
929
+ {
930
+ kind: "gitWrite",
931
+ command: resumeOn === undefined ? hardResetTo(tip) : switchBranchTo(resumeOn, tip),
932
+ },
852
933
  { kind: "gitWrite", command: deleteRef(HISTORY_REF) },
934
+ { kind: "gitWrite", command: deleteRef(RESUME_BRANCH_REF) },
853
935
  { kind: "outcome", command: restoredOutcome(tip, after.state) },
854
936
  ]
855
937
  const required = emitScripts(steps).required
@@ -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(