@pmelab/gtd 64.3.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 (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +34 -25
  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-BUX-47TD.mjs → testkit-CgrLmpdJ.mjs} +740 -79
  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 +553 -81
  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/wire.mjs +1 -1
  21. package/dist/workflow-kit.mjs +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/Cli.ts +4 -4
  24. package/src/cli/History.test.ts +10 -8
  25. package/src/cli/Metrics.test.ts +2 -1
  26. package/src/cli/Program.test.ts +4 -2
  27. package/src/cli/Program.ts +87 -5
  28. package/src/edge/Edge.test.ts +37 -16
  29. package/src/edge/Edge.ts +296 -31
  30. package/src/emit/GitScript.ts +116 -15
  31. package/src/emit/index.ts +7 -0
  32. package/src/flows/config.ts +7 -1
  33. package/src/flows/index.test.ts +1 -0
  34. package/src/flows/index.ts +2 -0
  35. package/src/flows/runtime.ts +32 -1
  36. package/src/platform/RetainedHistory.ts +3 -0
  37. package/src/platform/index.ts +1 -0
  38. package/src/replay/Replay.test.ts +9 -0
  39. package/src/replay/Replay.ts +59 -15
  40. package/src/replay/Trailers.ts +14 -0
  41. package/src/replay/index.ts +1 -1
  42. package/src/step/LandStep.ts +12 -0
  43. package/src/step/RepoSnapshot.ts +11 -0
  44. package/src/step/index.ts +1 -0
  45. package/src/step/planStart.test.ts +4 -3
  46. package/src/step/planStart.ts +7 -0
  47. package/src/step/planStep.ts +17 -12
  48. package/src/step/startBranch.ts +54 -0
  49. package/src/twins/EmittedScriptRecognizer.ts +117 -0
  50. package/src/twins/EmittedScripts.test.ts +147 -1
  51. package/src/twins/InMemRepo.ts +116 -0
  52. package/src/wire/Demand.ts +22 -10
  53. package/src/wire/constants.ts +3 -0
  54. package/src/wire/types.ts +4 -0
  55. package/src/workflow/Workflow.ts +1 -0
  56. package/src/workflow/compile.ts +2 -0
  57. package/src/workflow/load.ts +2 -0
  58. 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,
@@ -744,7 +768,19 @@ const promptDef = (
744
768
  const scriptDef = (common: StepCommon, request: RequestOf<"run">): StepDef => ({
745
769
  ...common,
746
770
  kind: "script",
747
- content: request.body,
771
+ content:
772
+ request.options.onFail === "rest"
773
+ ? failRestScript(request.body, RUN_FAILED_PATH)
774
+ : request.body,
775
+ ...optional("onFail", request.options.onFail),
776
+ })
777
+
778
+ const SHIP_CONTENT = noteOutcome("gtd: landing this step ships the process onto your branch")
779
+
780
+ const shipDef = (common: StepCommon): StepDef => ({
781
+ ...common,
782
+ kind: "script",
783
+ content: SHIP_CONTENT,
748
784
  })
749
785
 
750
786
  const judgeDef = (common: StepCommon, request: RequestOf<"judge">, step: ReachedStep): StepDef => {
@@ -777,6 +813,8 @@ const stepDefOf = (step: ReachedStep): StepDef => {
777
813
  return promptDef(common, request, step.skills, step.access)
778
814
  case "run":
779
815
  return scriptDef(common, request)
816
+ case "ship":
817
+ return shipDef(common)
780
818
  case "judge":
781
819
  return judgeDef(common, request, step)
782
820
  case "human":
@@ -885,6 +923,8 @@ interface Rest extends ResolvedRest {
885
923
  /** Total recorded cost of the process, and its per-model breakdown. */
886
924
  readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
887
925
  readonly setup: ReplaySetup
926
+ /** At a ship step on a clean tree: why landing it would be refused. */
927
+ readonly shipBlocked?: string
888
928
  }
889
929
 
890
930
  /** Per-model token totals, highest-cost first (ties broken by model name). */
@@ -1054,6 +1094,8 @@ export const restAt = (
1054
1094
  yield* (yield* Narrator).warn(
1055
1095
  `gtd: notice: the live config differs from the pinned one (${config.drift.join(", ")}); \`gtd repin\` to switch`,
1056
1096
  )
1097
+ const shipBlocked =
1098
+ ref === undefined ? yield* shipBlockedAt(setup, pin, step, changes) : undefined
1057
1099
  return {
1058
1100
  def,
1059
1101
  workflowName: resolved.name,
@@ -1073,6 +1115,7 @@ export const restAt = (
1073
1115
  hints: hintsOf(stepDef),
1074
1116
  cost: costTotals(run.costEntries),
1075
1117
  setup,
1118
+ ...(shipBlocked !== undefined ? { shipBlocked } : {}),
1076
1119
  }
1077
1120
  })
1078
1121
 
@@ -1246,6 +1289,8 @@ export interface RenderedRest extends RestHints {
1246
1289
  readonly memory?: string
1247
1290
  readonly memoryResumed: boolean
1248
1291
  readonly pin?: PinChange
1292
+ readonly failure?: string
1293
+ readonly blocked?: string
1249
1294
  }
1250
1295
 
1251
1296
  export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
@@ -1255,6 +1300,10 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
1255
1300
  kind: rest.stepDef.kind,
1256
1301
  content: rest.stepDef.content,
1257
1302
  ...rest.hints,
1303
+ ...(failedRunStalled(rest)
1304
+ ? { failure: rest.setup.workspace.readSync(RUN_FAILED_PATH) ?? "" }
1305
+ : {}),
1306
+ ...(rest.shipBlocked !== undefined ? { blocked: rest.shipBlocked } : {}),
1258
1307
  ...(rest.memory !== undefined ? { memory: rest.memory } : {}),
1259
1308
  memoryResumed: rest.memoryResumed,
1260
1309
  ...(rest.pin !== undefined ? { pin: rest.pin } : {}),
@@ -1265,13 +1314,25 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
1265
1314
  * attempt at this very agent rest, and another dispatch would repeat it.
1266
1315
  */
1267
1316
  export const stalledAt = (rest: Rest): boolean =>
1317
+ failedRunStalled(rest) ||
1318
+ rest.shipBlocked !== undefined ||
1319
+ (rest.changes.length === 0 &&
1320
+ rest.stepDef.kind === "prompt" &&
1321
+ rest.stepDef.allowEmpty !== true &&
1322
+ rest.run.headTurn?.state === rest.state &&
1323
+ rest.run.headTurn.actor === rest.actor &&
1324
+ !rest.run.headTurn.step &&
1325
+ rest.run.headTurn.empty)
1326
+
1327
+ /** A `run(…, { onFail: "rest" })` step whose last turn recorded a failure, on a clean tree. */
1328
+ const failedRunStalled = (rest: Rest): boolean =>
1268
1329
  rest.changes.length === 0 &&
1269
- rest.stepDef.kind === "prompt" &&
1270
- rest.stepDef.allowEmpty !== true &&
1330
+ rest.stepDef.onFail === "rest" &&
1271
1331
  rest.run.headTurn?.state === rest.state &&
1272
- rest.run.headTurn.actor === rest.actor &&
1273
1332
  !rest.run.headTurn.step &&
1274
- rest.run.headTurn.empty
1333
+ // Read from the (clean) tree, not the commit's touched paths: a retry that
1334
+ // fails with identical output touches nothing yet must still stall.
1335
+ rest.setup.workspace.readSync(RUN_FAILED_PATH) !== undefined
1275
1336
 
1276
1337
  /** 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
1338
  export const noProcessUnderway = (rest: Rest): boolean =>
@@ -1487,7 +1548,9 @@ const decideLanding = (
1487
1548
  verdicts: readonly JudgeVerdict[] | undefined,
1488
1549
  branch: Option.Option<string>,
1489
1550
  approver: Option.Option<AuthorIdent>,
1551
+ shipped?: string,
1490
1552
  ): Effect.Effect<Landing, Error> =>
1553
+ // fallow-ignore-next-line complexity
1491
1554
  Effect.gen(function* () {
1492
1555
  if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
1493
1556
  const unapproved = approvalRefusal(rest, approver)
@@ -1498,14 +1561,20 @@ const decideLanding = (
1498
1561
  const message = accessRefusal(rest.changes, rest.stepDef.access.write)
1499
1562
  if (message !== undefined) return { kind: "refusal", message }
1500
1563
  }
1564
+ const landing = pendingTree(
1565
+ rest.setup.workspace,
1566
+ rest.setup.pendingWorktree(),
1567
+ reviewGateRewrite(rest.stepDef),
1568
+ )
1569
+ // A recorded failure lands as an attempt: the process stays at the step.
1570
+ if (rest.stepDef.onFail === "rest" && landing.read(RUN_FAILED_PATH) !== undefined) {
1571
+ return { kind: "attempt", subject: formatSubject(rest.actor, rest.state) }
1572
+ }
1501
1573
  const outcome = yield* Effect.promise(() =>
1502
1574
  replayFor(rest.setup, {
1503
- tree: pendingTree(
1504
- rest.setup.workspace,
1505
- rest.setup.pendingWorktree(),
1506
- reviewGateRewrite(rest.stepDef),
1507
- ),
1575
+ tree: landing,
1508
1576
  ...(verdicts !== undefined ? { verdicts } : {}),
1577
+ ...(shipped !== undefined ? { shipped } : {}),
1509
1578
  }),
1510
1579
  )
1511
1580
  const to = yield* landingTarget(outcome, rest)
@@ -1513,31 +1582,227 @@ const decideLanding = (
1513
1582
  return landingTo(rest, to, branch, approver, outcomeMarks(outcome))
1514
1583
  })
1515
1584
 
1585
+ /** The user's branch: `<b>` for a process on `gtd/<b>` while `<b>` still exists; any other branch as is. */
1586
+ const userBranch = (git: GitOperations): Effect.Effect<Option.Option<string>, Error> =>
1587
+ Effect.gen(function* () {
1588
+ const current = yield* git.currentBranch()
1589
+ const name = Option.isSome(current) ? userBranchOf(current.value) : undefined
1590
+ if (name === undefined) return current
1591
+ const there = yield* git.readRefOption(`refs/heads/${name}`)
1592
+ return Option.isSome(there) ? Option.some(name) : current
1593
+ })
1594
+
1595
+ /** `<b>` for a process on `gtd/<b>` while `<b>` exists: where a finishing landing leaves HEAD. */
1596
+ const returnBranch = Effect.gen(function* () {
1597
+ const git = yield* GitService
1598
+ const current = yield* git.currentBranch()
1599
+ const name = Option.isSome(current) ? userBranchOf(current.value) : undefined
1600
+ if (name === undefined) return undefined
1601
+ return Option.isSome(yield* git.readRefOption(`refs/heads/${name}`)) ? name : undefined
1602
+ })
1603
+
1604
+ /** A starting landing moves onto `gtd/<b>` first. */
1605
+ const withBranchAndPush = (landing: Landing) =>
1606
+ Effect.gen(function* () {
1607
+ if (landing.kind !== "commit") return landing
1608
+ let out = landing
1609
+ if (landing.spec.vars !== undefined) {
1610
+ const start = yield* startBranchFor("gtd land")
1611
+ if (start.kind === "refusal") return { kind: "refusal", message: start.message } as Landing
1612
+ if (start.kind === "switch") out = { ...out, startBranch: start.branch }
1613
+ }
1614
+ return yield* withHistory(out)
1615
+ })
1616
+
1617
+ /** A closing landing switches back to `<b>`; it and a shipping one push their history ref. */
1618
+ const withHistory = (landing: Landing & { kind: "commit" }) =>
1619
+ Effect.gen(function* () {
1620
+ let out = landing
1621
+ const back = landing.keepHistory === undefined ? undefined : yield* returnBranch
1622
+ if (back !== undefined) out = { ...out, leaveBranch: back }
1623
+ if (landing.keepHistory === undefined && landing.ship === undefined) return out
1624
+ const remote = (yield* (yield* ConfigService).load).historyRemote
1625
+ return remote === undefined ? out : { ...out, pushHistory: remote }
1626
+ })
1627
+
1516
1628
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
1517
1629
  export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
1518
- decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
1630
+ decideLanding(
1631
+ rest,
1632
+ undefined,
1633
+ Option.none(),
1634
+ Option.none(),
1635
+ // Assumes the ship ships: deciding that needs git, and a preview reads none.
1636
+ rest.step.request.kind === "ship" ? SHIPPED_STAND_IN : undefined,
1637
+ ).pipe(
1519
1638
  Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
1520
1639
  Effect.catch(() => Effect.succeed(undefined)),
1521
1640
  )
1522
1641
 
1642
+ const snapshotOf = (rest: Rest, landing: Landing): RepoSnapshot => ({
1643
+ stepDef: rest.stepDef,
1644
+ state: rest.state,
1645
+ actor: rest.actor,
1646
+ changes: rest.changes,
1647
+ file: rest.hints.file,
1648
+ landing,
1649
+ })
1650
+
1651
+ const refusedSnapshot = (rest: Rest, message: string): RepoSnapshot =>
1652
+ snapshotOf(rest, { kind: "refusal", message })
1653
+
1654
+ // The squash's hash exists only once the landing script ran; the flow deciding
1655
+ // the landing's target sees this instead, and every later replay the real one.
1656
+ const SHIPPED_STAND_IN = "0".repeat(40)
1657
+
1658
+ const SHIP_EXCLUDED = ".gtd/"
1659
+
1660
+ type ShipDecision =
1661
+ | { readonly kind: "ship"; readonly branch: string; readonly parent: string }
1662
+ | { readonly kind: "skip"; readonly note: string }
1663
+ | { readonly kind: "blocked"; readonly message: string }
1664
+
1665
+ const shippedPaths = (before: TreeView, after: TreeView): readonly string[] => {
1666
+ const diff = diffTrees(before, after)
1667
+ return [...diff.added, ...diff.modified, ...diff.deleted].filter(
1668
+ (path) => !path.startsWith(SHIP_EXCLUDED),
1669
+ )
1670
+ }
1671
+
1672
+ const noTarget = (why: string): ShipDecision => ({
1673
+ kind: "skip",
1674
+ note: `gtd: no branch to ship to: ${why}`,
1675
+ })
1676
+
1677
+ /** `<b>` and its tip, for a process on `gtd/<b>`; why there is none otherwise. */
1678
+ const shipTarget = (git: GitOperations) =>
1679
+ Effect.gen(function* () {
1680
+ const current = yield* git.currentBranch()
1681
+ if (Option.isNone(current)) return noTarget("HEAD is detached")
1682
+ const branch = userBranchOf(current.value)
1683
+ if (branch === undefined) return noTarget(`${current.value} is no gtd/ branch`)
1684
+ if (userBranchOf(branch) !== undefined) {
1685
+ return noTarget(`${current.value} records no user branch`)
1686
+ }
1687
+ const parent = yield* git.readRefOption(`refs/heads/${branch}`)
1688
+ if (Option.isNone(parent)) return noTarget(`${branch} does not exist`)
1689
+ return { kind: "ship", branch, parent: parent.value } as const
1690
+ })
1691
+
1692
+ /**
1693
+ * The paths `<b>` changed outside this process that shipping `tree` would
1694
+ * revert. A path the working tree changed since HEAD counts as brought in:
1695
+ * whether it carries `<b>`'s change is the person's call, not a merge gtd redoes.
1696
+ */
1697
+ const revertedPaths = (
1698
+ git: GitOperations,
1699
+ workspace: WorkspaceOps,
1700
+ parent: string,
1701
+ tree: TreeView,
1702
+ changed: ReadonlySet<string>,
1703
+ ) =>
1704
+ Effect.gen(function* () {
1705
+ const head = yield* git.resolveRef("HEAD")
1706
+ const shipped = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip
1707
+ if (shipped !== undefined && (yield* git.isAncestor(shipped, head))) return []
1708
+ const fork = yield* git.mergeBase(parent, head)
1709
+ const foreign = Option.isNone(fork)
1710
+ ? [...changed]
1711
+ : shippedPaths(commitTree(workspace, fork.value), commitTree(workspace, parent))
1712
+ const committed = commitTree(workspace, head)
1713
+ return foreign.filter((path) => changed.has(path) && tree.read(path) === committed.read(path))
1714
+ })
1715
+
1716
+ /**
1717
+ * Whether landing a ship step with `tree` would ship, and onto what. The squash
1718
+ * replaces the user's branch's tree with `tree`, so it is blocked while that
1719
+ * branch carries a change `tree` would revert: one made since the last ship
1720
+ * (whose `Gtd-Tip` HEAD descends from), or since it forked from HEAD.
1721
+ */
1722
+ const decideShip = (
1723
+ workspace: WorkspaceOps,
1724
+ tree: TreeView,
1725
+ ): Effect.Effect<ShipDecision, Error, GitService> =>
1726
+ Effect.gen(function* () {
1727
+ const git = yield* GitService
1728
+ const target = yield* shipTarget(git)
1729
+ if (target.kind !== "ship") return target
1730
+ const { branch, parent } = target
1731
+ const changed = new Set(shippedPaths(commitTree(workspace, parent), tree))
1732
+ const reverted = yield* revertedPaths(git, workspace, parent, tree, changed)
1733
+ if (reverted.length > 0) {
1734
+ return {
1735
+ kind: "blocked",
1736
+ 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`,
1737
+ }
1738
+ }
1739
+ return changed.size === 0
1740
+ ? { kind: "skip", note: `gtd: nothing to ship: ${branch} already has this tree` }
1741
+ : target
1742
+ })
1743
+
1744
+ const shipBlockedAt = (
1745
+ setup: ReplaySetup,
1746
+ pin: PinChange | undefined,
1747
+ step: ReachedStep,
1748
+ changes: readonly PendingChange[],
1749
+ ) =>
1750
+ Effect.gen(function* () {
1751
+ if (pin !== undefined || step.request.kind !== "ship" || changes.length > 0) return undefined
1752
+ const head = yield* (yield* GitService).resolveRef("HEAD")
1753
+ const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head))
1754
+ return decision.kind === "blocked" ? decision.message : undefined
1755
+ })
1756
+
1757
+ const shipSnapshot = (
1758
+ rest: Rest,
1759
+ message: string,
1760
+ verdicts: readonly JudgeVerdict[] | undefined,
1761
+ branch: Option.Option<string>,
1762
+ approver: Option.Option<AuthorIdent>,
1763
+ ) =>
1764
+ Effect.gen(function* () {
1765
+ const tree = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined)
1766
+ const decision = yield* decideShip(rest.setup.workspace, tree)
1767
+ if (decision.kind === "blocked") return refusedSnapshot(rest, decision.message)
1768
+ const shipping = decision.kind === "ship"
1769
+ const decided = yield* decideLanding(
1770
+ rest,
1771
+ verdicts,
1772
+ branch,
1773
+ approver,
1774
+ shipping ? SHIPPED_STAND_IN : undefined,
1775
+ )
1776
+ if (decided.kind !== "commit") return snapshotOf(rest, decided)
1777
+ const landing: Landing = shipping
1778
+ ? {
1779
+ ...decided,
1780
+ ship: {
1781
+ branch: decision.branch,
1782
+ parent: decision.parent,
1783
+ shipMessage: message,
1784
+ historyRef: processHistoryRef(rest.run.startHash),
1785
+ },
1786
+ }
1787
+ : { ...decided, note: decision.note }
1788
+ return snapshotOf(rest, yield* withBranchAndPush(landing))
1789
+ })
1790
+
1523
1791
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
1524
1792
  export const snapshotFromRest = (
1525
1793
  rest: Rest,
1526
1794
  verdicts?: readonly JudgeVerdict[],
1527
- ): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
1795
+ ): Effect.Effect<RepoSnapshot, Error, Workspace | GitService | RestRequirements> =>
1528
1796
  Effect.gen(function* () {
1529
1797
  const git = yield* GitService
1530
- const branch = yield* git.currentBranch()
1798
+ const branch = yield* userBranch(git)
1531
1799
  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,
1800
+ const request = rest.step.request
1801
+ if (rest.pin === undefined && request.kind === "ship") {
1802
+ return yield* shipSnapshot(rest, request.message, verdicts, branch, approver)
1540
1803
  }
1804
+ const decided = yield* decideLanding(rest, verdicts, branch, approver)
1805
+ return snapshotOf(rest, yield* withBranchAndPush(decided))
1541
1806
  })
1542
1807
 
1543
1808
  // ── Summary ─────────────────────────────────────────────────────────────────
@@ -12,8 +12,11 @@ export const pathspec = (paths: ReadonlyArray<string>): string => paths.map(shel
12
12
  * output rather than the exit code, so a genuinely rejected commit (a lint
13
13
  * error, a commit-msg hook) still fails.
14
14
  */
15
- const commitAllowEmpty = (message: string, only?: string): string => {
16
- const m = shellQuote(message)
15
+ const commitAllowEmpty = (message: string, only?: string): string =>
16
+ commitWordAllowEmpty(shellQuote(message), only)
17
+
18
+ /** `commitAllowEmpty` over a message that is already a shell word. */
19
+ const commitWordAllowEmpty = (m: string, only?: string): string => {
17
20
  const paths = only === undefined ? "" : ` -- ${shellQuote(only)}`
18
21
  return [
19
22
  `if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
@@ -36,6 +39,29 @@ export const commitAll = (message: string): string =>
36
39
  export const commitPath = (path: string, message: string): string =>
37
40
  `git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`
38
41
 
42
+ /**
43
+ * A `run()` body that records its own failure instead of failing the beat: the
44
+ * status and the last 50 output lines go to `path`, and the script exits zero
45
+ * so the turn lands. The file is COMMITTED (and force-pushed with a history
46
+ * remote), hence the cap.
47
+ */
48
+ export const failRestScript = (body: string, path: string): string => {
49
+ const p = shellQuote(path)
50
+ const slash = path.lastIndexOf("/")
51
+ return [
52
+ `rm -f -- ${p}`,
53
+ `gtd_run_status=0`,
54
+ `gtd_run_out="$( {`,
55
+ body,
56
+ `} 2>&1 )" || gtd_run_status=$?`,
57
+ `printf '%s\\n' "$gtd_run_out"`,
58
+ `if [ "$gtd_run_status" -ne 0 ]; then`,
59
+ ` mkdir -p ${shellQuote(path.slice(0, slash))}`,
60
+ ` printf 'exit %s\\n%s\\n' "$gtd_run_status" "$(printf '%s\\n' "$gtd_run_out" | tail -n 50)" >${p}`,
61
+ `fi`,
62
+ ].join("\n")
63
+ }
64
+
39
65
  /** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
40
66
  export const pinScript = (path: string): string => {
41
67
  const p = shellQuote(path)
@@ -123,6 +149,77 @@ export const keepTurn = (refPrefix: string, message: string): string => {
123
149
  ].join("\n")
124
150
  }
125
151
 
152
+ /** `git switch -C <branch> [<start>]`: create or reset the branch, carrying pending changes over. */
153
+ export const switchBranchTo = (branch: string, start?: string): string =>
154
+ `git switch -q -C ${shellQuote(branch)}${start === undefined ? "" : ` ${shellQuote(start)}`}`
155
+
156
+ /** `git switch <branch>`: an existing branch, carrying pending changes over. */
157
+ export const switchTo = (branch: string): string => `git switch -q ${shellQuote(branch)}`
158
+
159
+ /** `switchTo` that, when pending changes block it, stays put and says so: the landing already happened. */
160
+ export const switchBack = (branch: string): string =>
161
+ [
162
+ `if ! git switch -q ${shellQuote(branch)} 2>/dev/null; then`,
163
+ ` printf '%s\\n' ${shellQuote(`gtd: pending changes keep HEAD off ${branch}; switch to it once they are committed or stashed`)}`,
164
+ `fi`,
165
+ ].join("\n")
166
+
167
+ export const deleteBranch = (branch: string): string => `git branch -q -D ${shellQuote(branch)}`
168
+
169
+ /**
170
+ * Force-pushes one ref to a remote. A failed push exits non-zero with the retry
171
+ * on stderr: it runs after the closing commit has landed, so the commit stands.
172
+ */
173
+ export const pushRef = (remote: string, ref: string): string => {
174
+ const retry = `git push --force ${shellQuote(remote)} ${shellQuote(`${ref}:${ref}`)}`
175
+ return [
176
+ `if ! ${retry}; then`,
177
+ ` printf '%s\\n' ${shellQuote(`gtd: pushing ${ref} failed — retry: ${retry}`)} >&2`,
178
+ ` exit 1`,
179
+ `fi`,
180
+ ].join("\n")
181
+ }
182
+
183
+ /**
184
+ * A ship step's landing, in one block because the landing commit's
185
+ * `Gtd-Shipped` trailer names the squash it makes first. The squash carries
186
+ * the staged tree with `.gtd/` as `<branch>` has it, built in a scratch index; `<branch>` moves by
187
+ * compare-and-swap against `parent`, so a branch moved since the decision
188
+ * fails the script before the landing commits. `commit-tree` ignores
189
+ * `commit.gpgSign`, so it is read here.
190
+ */
191
+ export const shipCommit = (
192
+ branch: string,
193
+ parent: string,
194
+ shipMessage: string,
195
+ historyRef: string,
196
+ message: string,
197
+ ): string =>
198
+ [
199
+ `gtd_ship_parent=${shellQuote(parent)}`,
200
+ `gtd_ship_branch=${shellQuote(branch)}`,
201
+ `gtd_ship_history=${shellQuote(historyRef)}`,
202
+ `gtd_ship_message=${shellQuote(shipMessage)}`,
203
+ `gtd_land_message=${shellQuote(message)}`,
204
+ `git add -A || exit 1`,
205
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
206
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
207
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
208
+ `gtd_tree=$(git write-tree) || exit 1`,
209
+ `gtd_index=$(git rev-parse --git-path gtd-ship-index) || exit 1`,
210
+ `rm -f -- "$gtd_index"`,
211
+ `GIT_INDEX_FILE="$gtd_index" git read-tree "$gtd_tree" || exit 1`,
212
+ `GIT_INDEX_FILE="$gtd_index" git rm -r -q --cached --ignore-unmatch -- .gtd || exit 1`,
213
+ `git ls-tree -r "$gtd_ship_parent" -- .gtd | GIT_INDEX_FILE="$gtd_index" git update-index --index-info || exit 1`,
214
+ `gtd_ship_tree=$(GIT_INDEX_FILE="$gtd_index" git write-tree) || exit 1`,
215
+ `rm -f -- "$gtd_index"`,
216
+ `gtd_ship_trailers=$(printf "Gtd-History: %s\\nGtd-Tip: %s" "$gtd_ship_history" "$gtd_head")`,
217
+ `gtd_shipped=$(git commit-tree "$gtd_sign" "$gtd_ship_tree" -p "$gtd_ship_parent" -m "$gtd_ship_message" -m "$gtd_ship_trailers") || exit 1`,
218
+ `git update-ref -m "gtd: ship" "refs/heads/$gtd_ship_branch" "$gtd_shipped" "$gtd_ship_parent" || exit 1`,
219
+ `gtd_message=$(printf "%s\\nGtd-Shipped: %s" "$gtd_land_message" "$gtd_shipped")`,
220
+ commitWordAllowEmpty(`"$gtd_message"`),
221
+ ].join("\n")
222
+
126
223
  export const updateRef = (ref: string, hash: string): string =>
127
224
  `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`
128
225
 
@@ -170,21 +267,25 @@ const renderOutcome = (outcome: Outcome): string => {
170
267
  }
171
268
  }
172
269
 
173
- const renderGitWrite = (write: GitWrite): string => {
174
- switch (write.kind) {
175
- case "commitAll":
176
- return commitAll(write.message)
177
- case "commitPath":
178
- return commitPath(write.path, write.message)
179
- case "commitClean":
180
- return commitClean(write.message)
181
- case "updateRef":
182
- return updateRef(write.ref, write.target)
183
- case "keepTurn":
184
- return keepTurn(write.ref, write.message)
185
- }
270
+ type Renderers = {
271
+ readonly [K in GitWrite["kind"]]: (write: Extract<GitWrite, { kind: K }>) => string
272
+ }
273
+
274
+ const GIT_WRITES: Renderers = {
275
+ commitAll: (w) => commitAll(w.message),
276
+ commitPath: (w) => commitPath(w.path, w.message),
277
+ commitClean: (w) => commitClean(w.message),
278
+ updateRef: (w) => updateRef(w.ref, w.target),
279
+ keepTurn: (w) => keepTurn(w.ref, w.message),
280
+ switchBranch: (w) => switchBranchTo(w.branch),
281
+ switchTo: (w) => switchBack(w.branch),
282
+ pushRef: (w) => pushRef(w.remote, w.ref),
283
+ ship: (w) => shipCommit(w.branch, w.parent, w.shipMessage, w.historyRef, w.message),
186
284
  }
187
285
 
286
+ const renderGitWrite = (write: GitWrite): string =>
287
+ (GIT_WRITES[write.kind] as (write: GitWrite) => string)(write)
288
+
188
289
  /**
189
290
  * Wraps `command` so a non-zero exit prints `prompt` plus the command's
190
291
  * captured output before propagating that exit code — mirrors `Emit.ts`'s
package/src/emit/index.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  commitClean,
19
19
  commitPath,
20
20
  pinScript,
21
+ failRestScript,
21
22
  repinScript,
22
23
  unpinScript,
23
24
  CHANGED_SINCE_LAND,
@@ -28,6 +29,12 @@ export {
28
29
  updateRef,
29
30
  keepTurn,
30
31
  deleteRef,
32
+ switchBranchTo,
33
+ switchBack,
34
+ switchTo,
35
+ deleteBranch,
36
+ pushRef,
37
+ shipCommit,
31
38
  fetchHistoryRefs,
32
39
  type RunnableScript,
33
40
  ScriptSurface,
@@ -21,6 +21,8 @@ export interface GtdConfig {
21
21
  readonly ui?: UiSettings
22
22
  readonly skills?: Readonly<Record<string, readonly string[]>>
23
23
  readonly access?: Readonly<Record<string, ScopeAccess>>
24
+ /** A remote name: every finished or abandoned process force-pushes its history ref there. */
25
+ readonly historyRemote?: string
24
26
  }
25
27
 
26
28
  export interface ConfigProblem {
@@ -28,7 +30,7 @@ export interface ConfigProblem {
28
30
  readonly message: string
29
31
  }
30
32
 
31
- const CONFIG_KEYS = ["modes", "ui", "skills", "access"] as const
33
+ const CONFIG_KEYS = ["modes", "ui", "skills", "access", "historyRemote"] as const
32
34
  const MODE_KEYS = ["format", "validate"] as const
33
35
  const UI_KEYS = ["port", "host", "cert", "key", "format"] as const
34
36
 
@@ -113,6 +115,10 @@ export const configProblems = (value: unknown): readonly ConfigProblem[] => {
113
115
  ...unknownKeys(value, CONFIG_KEYS, []),
114
116
  ...(value.modes === undefined ? [] : recordOf(value.modes, ["modes"], "modes", modeProblems)),
115
117
  ...(value.ui === undefined ? [] : uiProblems(value.ui, ["ui"])),
118
+ ...(value.historyRemote === undefined ||
119
+ (typeof value.historyRemote === "string" && value.historyRemote.trim() !== "")
120
+ ? []
121
+ : [{ path: ["historyRemote"], message: "must be a non-empty remote name" }]),
116
122
  ...(value.skills === undefined
117
123
  ? []
118
124
  : recordOf(value.skills, ["skills"], "skill-name arrays", (v, path) =>
@@ -53,6 +53,7 @@ describe("@pmelab/gtd/flows", () => {
53
53
  "sections",
54
54
  "settings",
55
55
  "settingsOf",
56
+ "ship",
56
57
  "showScript",
57
58
  "skillsFor",
58
59
  "start",