@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
package/src/edge/Edge.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  formatCommitMessage,
40
40
  scopeRunStart,
41
41
  setAsideBy,
42
+ diffTrees,
42
43
  treeFromRecord,
43
44
  type EpisodeCommit,
44
45
  type JudgeVerdict,
@@ -51,10 +52,11 @@ import {
51
52
  } from "../replay/index.js"
52
53
  import type { Mark, ScopeAccess, SummaryContext } from "../flows/index.js"
53
54
  import { steeringFormatFor } from "../steering/index.js"
54
- import { pinScript, unpinScript } from "../emit/index.js"
55
+ import { failRestScript, noteOutcome, pinScript, unpinScript } from "../emit/index.js"
55
56
  import {
56
57
  judgeJson,
57
58
  PIN_PATH,
59
+ RUN_FAILED_PATH,
58
60
  UNATTRIBUTED_MODEL,
59
61
  type ApprovalEntry,
60
62
  type ModelCost,
@@ -63,6 +65,7 @@ import {
63
65
 
64
66
  import { GTD_VERSION } from "../install/index.js"
65
67
  import { keptRef, processHistoryRef } from "../platform/index.js"
68
+ import { GTD_BRANCH_PREFIX, startBranchFor, userBranchOf } from "../step/index.js"
66
69
  import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
67
70
 
68
71
  export { UNATTRIBUTED_MODEL }
@@ -478,21 +481,38 @@ const runAtTip = (
478
481
  return yield* runOf([...before, ...closing], { ...location }, tip.hash)
479
482
  })
480
483
 
481
- const runAtHead = (git: GitOperations, def: WorkflowDefinition) =>
482
- Effect.gen(function* () {
483
- // Cheap: just HEAD's own subject, never the commits before it.
484
- return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
485
- })
486
-
487
- /** The process HEAD closes or sits inside — `gtd summary`'s run. */
484
+ /**
485
+ * The process HEAD closes or sits inside — `gtd summary`'s run. On `<b>`, where
486
+ * a finished process leaves HEAD, it is the one `gtd/<b>`'s tip closes, while
487
+ * `<b>` holds nothing since: its start, or its last ship.
488
+ */
488
489
  export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
489
490
  function* () {
490
491
  const git = yield* GitService
491
492
  const def = (yield* (yield* ConfigService).load).workflow
492
- return yield* runAtHead(git, def)
493
+ const head = (yield* git.subjectHistory(1, 0))[0]
494
+ const left = head === undefined ? undefined : yield* leftProcessTip(git, head)
495
+ return yield* runAtTip(git, def, left ?? head)
493
496
  },
494
497
  )
495
498
 
499
+ const leftProcessTip = (
500
+ git: GitOperations,
501
+ head: { readonly hash: string; readonly subject: string },
502
+ ) =>
503
+ Effect.gen(function* () {
504
+ if (parseCommitMessage(head.subject).parsed !== undefined) return undefined
505
+ const current = yield* git.currentBranch()
506
+ if (Option.isNone(current) || userBranchOf(current.value) !== undefined) return undefined
507
+ const tip = yield* git.readRefOption(`refs/heads/${GTD_BRANCH_PREFIX}${current.value}`)
508
+ if (Option.isNone(tip)) return undefined
509
+ const message = yield* git.lastCommitMessage(tip.value)
510
+ if (parseCommitMessage(message).parsed?.to === undefined) return undefined
511
+ const since = parseCommitMessage(yield* git.lastCommitMessage(head.hash)).tip ?? head.hash
512
+ if (!(yield* git.isAncestor(since, tip.value))) return undefined
513
+ return { hash: tip.value, subject: message.split("\n")[0]! }
514
+ })
515
+
496
516
  // ── Trees ───────────────────────────────────────────────────────────────────
497
517
 
498
518
  const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
@@ -653,7 +673,11 @@ interface ReplaySetup {
653
673
 
654
674
  const replayFor = (
655
675
  setup: ReplaySetup,
656
- pending?: { readonly tree: TreeView; readonly verdicts?: readonly JudgeVerdict[] },
676
+ pending?: {
677
+ readonly tree: TreeView
678
+ readonly verdicts?: readonly JudgeVerdict[]
679
+ readonly shipped?: string
680
+ },
657
681
  ): Promise<ReplayOutcome> =>
658
682
  replay({
659
683
  flow: setup.def.flow,
@@ -737,6 +761,7 @@ const promptDef = (
737
761
  ...optional("system", request.options.system),
738
762
  ...optional("allowEmpty", request.options.allowEmpty),
739
763
  ...optional("keep", request.options.keep),
764
+ ...optional("original", request.original),
740
765
  ...optional("skills", skills),
741
766
  ...optional("access", access),
742
767
  })
@@ -744,7 +769,19 @@ const promptDef = (
744
769
  const scriptDef = (common: StepCommon, request: RequestOf<"run">): StepDef => ({
745
770
  ...common,
746
771
  kind: "script",
747
- content: request.body,
772
+ content:
773
+ request.options.onFail === "rest"
774
+ ? failRestScript(request.body, RUN_FAILED_PATH)
775
+ : request.body,
776
+ ...optional("onFail", request.options.onFail),
777
+ })
778
+
779
+ const SHIP_CONTENT = noteOutcome("gtd: landing this step ships the process onto your branch")
780
+
781
+ const shipDef = (common: StepCommon): StepDef => ({
782
+ ...common,
783
+ kind: "script",
784
+ content: SHIP_CONTENT,
748
785
  })
749
786
 
750
787
  const judgeDef = (common: StepCommon, request: RequestOf<"judge">, step: ReachedStep): StepDef => {
@@ -777,6 +814,8 @@ const stepDefOf = (step: ReachedStep): StepDef => {
777
814
  return promptDef(common, request, step.skills, step.access)
778
815
  case "run":
779
816
  return scriptDef(common, request)
817
+ case "ship":
818
+ return shipDef(common)
780
819
  case "judge":
781
820
  return judgeDef(common, request, step)
782
821
  case "human":
@@ -885,6 +924,8 @@ interface Rest extends ResolvedRest {
885
924
  /** Total recorded cost of the process, and its per-model breakdown. */
886
925
  readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
887
926
  readonly setup: ReplaySetup
927
+ /** At a ship step on a clean tree: why landing it would be refused. */
928
+ readonly shipBlocked?: string
888
929
  }
889
930
 
890
931
  /** Per-model token totals, highest-cost first (ties broken by model name). */
@@ -1054,6 +1095,8 @@ export const restAt = (
1054
1095
  yield* (yield* Narrator).warn(
1055
1096
  `gtd: notice: the live config differs from the pinned one (${config.drift.join(", ")}); \`gtd repin\` to switch`,
1056
1097
  )
1098
+ const shipBlocked =
1099
+ ref === undefined ? yield* shipBlockedAt(setup, pin, step, changes) : undefined
1057
1100
  return {
1058
1101
  def,
1059
1102
  workflowName: resolved.name,
@@ -1073,6 +1116,7 @@ export const restAt = (
1073
1116
  hints: hintsOf(stepDef),
1074
1117
  cost: costTotals(run.costEntries),
1075
1118
  setup,
1119
+ ...(shipBlocked !== undefined ? { shipBlocked } : {}),
1076
1120
  }
1077
1121
  })
1078
1122
 
@@ -1245,7 +1289,10 @@ export interface RenderedRest extends RestHints {
1245
1289
  readonly content: string
1246
1290
  readonly memory?: string
1247
1291
  readonly memoryResumed: boolean
1292
+ readonly original?: string
1248
1293
  readonly pin?: PinChange
1294
+ readonly failure?: string
1295
+ readonly blocked?: string
1249
1296
  }
1250
1297
 
1251
1298
  export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
@@ -1255,8 +1302,13 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
1255
1302
  kind: rest.stepDef.kind,
1256
1303
  content: rest.stepDef.content,
1257
1304
  ...rest.hints,
1305
+ ...(failedRunStalled(rest)
1306
+ ? { failure: rest.setup.workspace.readSync(RUN_FAILED_PATH) ?? "" }
1307
+ : {}),
1308
+ ...(rest.shipBlocked !== undefined ? { blocked: rest.shipBlocked } : {}),
1258
1309
  ...(rest.memory !== undefined ? { memory: rest.memory } : {}),
1259
1310
  memoryResumed: rest.memoryResumed,
1311
+ ...(rest.stepDef.original !== undefined ? { original: rest.stepDef.original } : {}),
1260
1312
  ...(rest.pin !== undefined ? { pin: rest.pin } : {}),
1261
1313
  })
1262
1314
 
@@ -1265,13 +1317,25 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
1265
1317
  * attempt at this very agent rest, and another dispatch would repeat it.
1266
1318
  */
1267
1319
  export const stalledAt = (rest: Rest): boolean =>
1320
+ failedRunStalled(rest) ||
1321
+ rest.shipBlocked !== undefined ||
1322
+ (rest.changes.length === 0 &&
1323
+ rest.stepDef.kind === "prompt" &&
1324
+ rest.stepDef.allowEmpty !== true &&
1325
+ rest.run.headTurn?.state === rest.state &&
1326
+ rest.run.headTurn.actor === rest.actor &&
1327
+ !rest.run.headTurn.step &&
1328
+ rest.run.headTurn.empty)
1329
+
1330
+ /** A `run(…, { onFail: "rest" })` step whose last turn recorded a failure, on a clean tree. */
1331
+ const failedRunStalled = (rest: Rest): boolean =>
1268
1332
  rest.changes.length === 0 &&
1269
- rest.stepDef.kind === "prompt" &&
1270
- rest.stepDef.allowEmpty !== true &&
1333
+ rest.stepDef.onFail === "rest" &&
1271
1334
  rest.run.headTurn?.state === rest.state &&
1272
- rest.run.headTurn.actor === rest.actor &&
1273
1335
  !rest.run.headTurn.step &&
1274
- rest.run.headTurn.empty
1336
+ // Read from the (clean) tree, not the commit's touched paths: a retry that
1337
+ // fails with identical output touches nothing yet must still stall.
1338
+ rest.setup.workspace.readSync(RUN_FAILED_PATH) !== undefined
1275
1339
 
1276
1340
  /** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
1277
1341
  export const noProcessUnderway = (rest: Rest): boolean =>
@@ -1322,7 +1386,7 @@ const cleanLanding = (rest: Rest): Landing | undefined => {
1322
1386
  return undefined
1323
1387
  }
1324
1388
 
1325
- /** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
1389
+ /** At the human review gate the pending tree lands with every tick cleared; an agent-bound landing keeps them only if the unrewritten tree replays to the same step. */
1326
1390
  const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
1327
1391
  const reviewFormat = steeringFormatFor("review")
1328
1392
  if (!isHumanReviewGate(def) || def.file === undefined || reviewFormat === undefined) {
@@ -1331,6 +1395,16 @@ const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
1331
1395
  return { path: def.file, apply: (content) => reviewFormat.clearTicks(content) }
1332
1396
  }
1333
1397
 
1398
+ /** Whether the pending review file differs from HEAD's once both have their ticks cleared. */
1399
+ const reviewEditedBeyondTicks = (rest: Rest): boolean => {
1400
+ const rewrite = reviewGateRewrite(rest.stepDef)
1401
+ if (rewrite === undefined) return false
1402
+ const pending = rest.setup.workspace.readSync(rewrite.path)
1403
+ const committed = rest.setup.workspace.readCommittedSync(rewrite.path)
1404
+ if (pending === undefined || committed === undefined) return true
1405
+ return rewrite.apply(pending) !== rewrite.apply(committed)
1406
+ }
1407
+
1334
1408
  const multilineRefusal = (vars: Record<string, string>): Landing | undefined => {
1335
1409
  const multiline = multilineSetting(vars)
1336
1410
  return multiline === undefined
@@ -1487,7 +1561,9 @@ const decideLanding = (
1487
1561
  verdicts: readonly JudgeVerdict[] | undefined,
1488
1562
  branch: Option.Option<string>,
1489
1563
  approver: Option.Option<AuthorIdent>,
1564
+ shipped?: string,
1490
1565
  ): Effect.Effect<Landing, Error> =>
1566
+ // fallow-ignore-next-line complexity
1491
1567
  Effect.gen(function* () {
1492
1568
  if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
1493
1569
  const unapproved = approvalRefusal(rest, approver)
@@ -1498,46 +1574,317 @@ const decideLanding = (
1498
1574
  const message = accessRefusal(rest.changes, rest.stepDef.access.write)
1499
1575
  if (message !== undefined) return { kind: "refusal", message }
1500
1576
  }
1577
+ const landing = pendingTree(
1578
+ rest.setup.workspace,
1579
+ rest.setup.pendingWorktree(),
1580
+ reviewGateRewrite(rest.stepDef),
1581
+ )
1582
+ // A recorded failure lands as an attempt: the process stays at the step.
1583
+ if (rest.stepDef.onFail === "rest" && landing.read(RUN_FAILED_PATH) !== undefined) {
1584
+ return { kind: "attempt", subject: formatSubject(rest.actor, rest.state) }
1585
+ }
1501
1586
  const outcome = yield* Effect.promise(() =>
1502
1587
  replayFor(rest.setup, {
1503
- tree: pendingTree(
1504
- rest.setup.workspace,
1505
- rest.setup.pendingWorktree(),
1506
- reviewGateRewrite(rest.stepDef),
1507
- ),
1588
+ tree: landing,
1508
1589
  ...(verdicts !== undefined ? { verdicts } : {}),
1590
+ ...(shipped !== undefined ? { shipped } : {}),
1509
1591
  }),
1510
1592
  )
1511
1593
  const to = yield* landingTarget(outcome, rest)
1512
1594
  if (typeof to !== "string") return to
1513
- return landingTo(rest, to, branch, approver, outcomeMarks(outcome))
1595
+ const landed = landingTo(rest, to, branch, approver, outcomeMarks(outcome))
1596
+ // Ticks survive an agent-bound landing only when the review file changed beyond ticks
1597
+ // (a tick-only sign-off lands clean) and the unrewritten tree replays to the same step.
1598
+ if (
1599
+ landed.kind !== "commit" ||
1600
+ !isHumanReviewGate(rest.stepDef) ||
1601
+ outcome.kind !== "rest" ||
1602
+ outcome.rest.request.kind !== "agent" ||
1603
+ !reviewEditedBeyondTicks(rest)
1604
+ ) {
1605
+ return landed
1606
+ }
1607
+ const ticked = yield* Effect.promise(() =>
1608
+ replayFor(rest.setup, {
1609
+ tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined),
1610
+ ...(verdicts !== undefined ? { verdicts } : {}),
1611
+ ...(shipped !== undefined ? { shipped } : {}),
1612
+ }),
1613
+ )
1614
+ return ticked.kind === "rest" && ticked.rest.name === outcome.rest.name
1615
+ ? { ...landed, keepTicks: true }
1616
+ : landed
1617
+ })
1618
+
1619
+ /** The user's branch: `<b>` for a process on `gtd/<b>` while `<b>` still exists; any other branch as is. */
1620
+ const userBranch = (git: GitOperations): Effect.Effect<Option.Option<string>, Error> =>
1621
+ Effect.gen(function* () {
1622
+ const current = yield* git.currentBranch()
1623
+ const name = Option.isSome(current) ? userBranchOf(current.value) : undefined
1624
+ if (name === undefined) return current
1625
+ const there = yield* git.readRefOption(`refs/heads/${name}`)
1626
+ return Option.isSome(there) ? Option.some(name) : current
1627
+ })
1628
+
1629
+ /** `<b>` for a process on `gtd/<b>` while `<b>` exists: where a finishing landing leaves HEAD. */
1630
+ const returnBranch = Effect.gen(function* () {
1631
+ const git = yield* GitService
1632
+ const current = yield* git.currentBranch()
1633
+ const name = Option.isSome(current) ? userBranchOf(current.value) : undefined
1634
+ if (name === undefined) return undefined
1635
+ return Option.isSome(yield* git.readRefOption(`refs/heads/${name}`)) ? name : undefined
1636
+ })
1637
+
1638
+ /** A starting landing moves onto `gtd/<b>` first. */
1639
+ const withBranchAndPush = (landing: Landing) =>
1640
+ Effect.gen(function* () {
1641
+ if (landing.kind !== "commit") return landing
1642
+ let out = landing
1643
+ if (landing.spec.vars !== undefined) {
1644
+ const start = yield* startBranchFor("gtd land")
1645
+ if (start.kind === "refusal") return { kind: "refusal", message: start.message } as Landing
1646
+ if (start.kind === "switch") out = { ...out, startBranch: start.branch }
1647
+ }
1648
+ return yield* withHistory(out)
1649
+ })
1650
+
1651
+ /** A closing landing switches back to `<b>`; it and a shipping one push their history ref. */
1652
+ const withHistory = (landing: Landing & { kind: "commit" }) =>
1653
+ Effect.gen(function* () {
1654
+ let out = landing
1655
+ const back = landing.keepHistory === undefined ? undefined : yield* returnBranch
1656
+ if (back !== undefined) out = { ...out, leaveBranch: back }
1657
+ if (
1658
+ landing.keepHistory === undefined &&
1659
+ landing.ship === undefined &&
1660
+ landing.recordedHistory === undefined
1661
+ ) {
1662
+ return out
1663
+ }
1664
+ const remote = (yield* (yield* ConfigService).load).historyRemote
1665
+ return remote === undefined ? out : { ...out, pushHistory: remote }
1514
1666
  })
1515
1667
 
1516
1668
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
1517
1669
  export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
1518
- decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
1670
+ decideLanding(
1671
+ rest,
1672
+ undefined,
1673
+ Option.none(),
1674
+ Option.none(),
1675
+ // Assumes the ship ships: deciding that needs git, and a preview reads none.
1676
+ rest.step.request.kind === "ship" ? SHIPPED_STAND_IN : undefined,
1677
+ ).pipe(
1519
1678
  Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
1520
1679
  Effect.catch(() => Effect.succeed(undefined)),
1521
1680
  )
1522
1681
 
1682
+ const snapshotOf = (rest: Rest, landing: Landing): RepoSnapshot => ({
1683
+ stepDef: rest.stepDef,
1684
+ state: rest.state,
1685
+ actor: rest.actor,
1686
+ changes: rest.changes,
1687
+ file: rest.hints.file,
1688
+ landing,
1689
+ })
1690
+
1691
+ const refusedSnapshot = (rest: Rest, message: string): RepoSnapshot =>
1692
+ snapshotOf(rest, { kind: "refusal", message })
1693
+
1694
+ // The squash's hash exists only once the landing script ran; the flow deciding
1695
+ // the landing's target sees this instead, and every later replay the real one.
1696
+ const SHIPPED_STAND_IN = "0".repeat(40)
1697
+
1698
+ const SHIP_EXCLUDED = ".gtd/"
1699
+
1700
+ type ShipDecision =
1701
+ | { readonly kind: "ship"; readonly branch: string; readonly parent: string }
1702
+ | { readonly kind: "recorded"; readonly hash: string }
1703
+ | { readonly kind: "skip"; readonly note: string }
1704
+ | { readonly kind: "blocked"; readonly message: string }
1705
+
1706
+ /** `tree` with file modes read from `ref` (the working tree when `undefined`), for the ship decision only: reading them costs a git call. */
1707
+ const withModes = (workspace: WorkspaceOps, tree: TreeView, ref: string | undefined): TreeView => {
1708
+ let modes: ReadonlyMap<string, string> | undefined
1709
+ return { ...tree, mode: (path) => (modes ??= workspace.modeSync(ref)).get(path) }
1710
+ }
1711
+
1712
+ const shippedPaths = (before: TreeView, after: TreeView): readonly string[] => {
1713
+ const diff = diffTrees(before, after)
1714
+ return [...diff.added, ...diff.modified, ...diff.deleted].filter(
1715
+ (path) => !path.startsWith(SHIP_EXCLUDED),
1716
+ )
1717
+ }
1718
+
1719
+ const noTarget = (why: string): ShipDecision => ({
1720
+ kind: "skip",
1721
+ note: `gtd: no branch to ship to: ${why}`,
1722
+ })
1723
+
1724
+ /** `<b>` and its tip, for a process on `gtd/<b>`; why there is none otherwise. */
1725
+ const shipTarget = (git: GitOperations) =>
1726
+ Effect.gen(function* () {
1727
+ const current = yield* git.currentBranch()
1728
+ if (Option.isNone(current)) return noTarget("HEAD is detached")
1729
+ const branch = userBranchOf(current.value)
1730
+ if (branch === undefined) return noTarget(`${current.value} is no gtd/ branch`)
1731
+ if (userBranchOf(branch) !== undefined) {
1732
+ return noTarget(`${current.value} records no user branch`)
1733
+ }
1734
+ const parent = yield* git.readRefOption(`refs/heads/${branch}`)
1735
+ if (Option.isNone(parent)) return noTarget(`${branch} does not exist`)
1736
+ return { kind: "ship", branch, parent: parent.value } as const
1737
+ })
1738
+
1739
+ /**
1740
+ * The paths `<b>` changed outside this process that shipping `tree` would
1741
+ * revert. A path the working tree changed since HEAD counts as brought in:
1742
+ * whether it carries `<b>`'s change is the person's call, not a merge gtd redoes.
1743
+ */
1744
+ const revertedPaths = (
1745
+ git: GitOperations,
1746
+ workspace: WorkspaceOps,
1747
+ parent: string,
1748
+ tree: TreeView,
1749
+ changed: ReadonlySet<string>,
1750
+ treeRef: string | undefined,
1751
+ ) =>
1752
+ Effect.gen(function* () {
1753
+ const head = yield* git.resolveRef("HEAD")
1754
+ const shipped = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip
1755
+ if (shipped !== undefined && (yield* git.isAncestor(shipped, head))) return []
1756
+ const fork = yield* git.mergeBase(parent, head)
1757
+ const foreign = Option.isNone(fork)
1758
+ ? [...changed]
1759
+ : shippedPaths(
1760
+ withModes(workspace, commitTree(workspace, fork.value), fork.value),
1761
+ withModes(workspace, commitTree(workspace, parent), parent),
1762
+ )
1763
+ const committed = withModes(workspace, commitTree(workspace, head), head)
1764
+ const moded = withModes(workspace, tree, treeRef)
1765
+ return foreign.filter(
1766
+ (path) =>
1767
+ changed.has(path) &&
1768
+ moded.read(path) === committed.read(path) &&
1769
+ moded.mode!(path) === committed.mode!(path),
1770
+ )
1771
+ })
1772
+
1773
+ /**
1774
+ * Whether landing a ship step with `tree` would ship, and onto what. The squash
1775
+ * replaces the user's branch's tree with `tree`, so it is blocked while that
1776
+ * branch carries a change `tree` would revert: one made since the last ship
1777
+ * (whose `Gtd-Tip` HEAD descends from), or since it forked from HEAD.
1778
+ */
1779
+ const decideShip = (
1780
+ workspace: WorkspaceOps,
1781
+ tree: TreeView,
1782
+ /** The commit `tree` is of; `undefined` for the working tree. */
1783
+ treeRef: string | undefined,
1784
+ ): Effect.Effect<ShipDecision, Error, GitService> =>
1785
+ Effect.gen(function* () {
1786
+ const git = yield* GitService
1787
+ const target = yield* shipTarget(git)
1788
+ if (target.kind !== "ship") return target
1789
+ const { branch, parent } = target
1790
+ const changed = new Set(
1791
+ shippedPaths(
1792
+ withModes(workspace, commitTree(workspace, parent), parent),
1793
+ withModes(workspace, tree, treeRef),
1794
+ ),
1795
+ )
1796
+ // A landing whose hook rejected after the ship moved `<b>`: its tip is a ship of this very HEAD.
1797
+ // An edit since (say, to satisfy that hook) ships again rather than reusing the stale ship.
1798
+ const pending = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip
1799
+ if (
1800
+ changed.size === 0 &&
1801
+ pending !== undefined &&
1802
+ pending === (yield* git.resolveRef("HEAD"))
1803
+ ) {
1804
+ return { kind: "recorded", hash: parent }
1805
+ }
1806
+ const reverted = yield* revertedPaths(git, workspace, parent, tree, changed, treeRef)
1807
+ if (reverted.length > 0) {
1808
+ return {
1809
+ kind: "blocked",
1810
+ message: `gtd: ${branch} changed outside this process, and shipping would revert ${reverted.join(", ")} — bring those changes into the working tree (\`git merge --squash --no-commit ${branch}\`, or by hand) and land`,
1811
+ }
1812
+ }
1813
+ return changed.size === 0
1814
+ ? { kind: "skip", note: `gtd: nothing to ship: ${branch} already has this tree` }
1815
+ : target
1816
+ })
1817
+
1818
+ const shipBlockedAt = (
1819
+ setup: ReplaySetup,
1820
+ pin: PinChange | undefined,
1821
+ step: ReachedStep,
1822
+ changes: readonly PendingChange[],
1823
+ ) =>
1824
+ Effect.gen(function* () {
1825
+ if (pin !== undefined || step.request.kind !== "ship" || changes.length > 0) return undefined
1826
+ const head = yield* (yield* GitService).resolveRef("HEAD")
1827
+ const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head), head)
1828
+ return decision.kind === "blocked" ? decision.message : undefined
1829
+ })
1830
+
1831
+ const shipSnapshot = (
1832
+ rest: Rest,
1833
+ message: string,
1834
+ verdicts: readonly JudgeVerdict[] | undefined,
1835
+ branch: Option.Option<string>,
1836
+ approver: Option.Option<AuthorIdent>,
1837
+ ) =>
1838
+ Effect.gen(function* () {
1839
+ const tree = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined)
1840
+ const decision = yield* decideShip(rest.setup.workspace, tree, undefined)
1841
+ if (decision.kind === "blocked") return refusedSnapshot(rest, decision.message)
1842
+ const shipping = decision.kind === "ship"
1843
+ const decided = yield* decideLanding(
1844
+ rest,
1845
+ verdicts,
1846
+ branch,
1847
+ approver,
1848
+ decision.kind === "recorded" ? decision.hash : shipping ? SHIPPED_STAND_IN : undefined,
1849
+ )
1850
+ if (decided.kind !== "commit") return snapshotOf(rest, decided)
1851
+ if (decision.kind === "recorded") {
1852
+ const recorded = {
1853
+ ...decided,
1854
+ spec: { ...decided.spec, shipped: decision.hash },
1855
+ recordedHistory: processHistoryRef(rest.run.startHash),
1856
+ }
1857
+ return snapshotOf(rest, yield* withBranchAndPush(recorded))
1858
+ }
1859
+ const landing: Landing = shipping
1860
+ ? {
1861
+ ...decided,
1862
+ ship: {
1863
+ branch: decision.branch,
1864
+ parent: decision.parent,
1865
+ shipMessage: message,
1866
+ historyRef: processHistoryRef(rest.run.startHash),
1867
+ },
1868
+ }
1869
+ : { ...decided, note: decision.note }
1870
+ return snapshotOf(rest, yield* withBranchAndPush(landing))
1871
+ })
1872
+
1523
1873
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
1524
1874
  export const snapshotFromRest = (
1525
1875
  rest: Rest,
1526
1876
  verdicts?: readonly JudgeVerdict[],
1527
- ): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
1877
+ ): Effect.Effect<RepoSnapshot, Error, Workspace | GitService | RestRequirements> =>
1528
1878
  Effect.gen(function* () {
1529
1879
  const git = yield* GitService
1530
- const branch = yield* git.currentBranch()
1880
+ const branch = yield* userBranch(git)
1531
1881
  const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : Option.none()
1532
- const landing = yield* decideLanding(rest, verdicts, branch, approver)
1533
- return {
1534
- stepDef: rest.stepDef,
1535
- state: rest.state,
1536
- actor: rest.actor,
1537
- changes: rest.changes,
1538
- file: rest.hints.file,
1539
- landing,
1882
+ const request = rest.step.request
1883
+ if (rest.pin === undefined && request.kind === "ship") {
1884
+ return yield* shipSnapshot(rest, request.message, verdicts, branch, approver)
1540
1885
  }
1886
+ const decided = yield* decideLanding(rest, verdicts, branch, approver)
1887
+ return snapshotOf(rest, yield* withBranchAndPush(decided))
1541
1888
  })
1542
1889
 
1543
1890
  // ── Summary ─────────────────────────────────────────────────────────────────