@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
@@ -28,6 +28,7 @@ export {
28
28
  sections,
29
29
  settings,
30
30
  settingsOf,
31
+ ship,
31
32
  skillsFor,
32
33
  start,
33
34
  threads,
@@ -58,6 +59,7 @@ export {
58
59
  type ScopeOptions,
59
60
  type Section,
60
61
  type Settings,
62
+ type ShipOptions,
61
63
  type SkillsExport,
62
64
  type Steering,
63
65
  type SteeringOptions,
@@ -89,7 +89,20 @@ export type HumanOptions = HumanCommon &
89
89
  })
90
90
  )
91
91
 
92
- export type RunOptions = SteeringOptions
92
+ export type RunOptions = SteeringOptions & {
93
+ /**
94
+ * `"rest"`: a non-zero exit is recorded in the tree and the process rests at
95
+ * the step as `stalled`; any tree change runs the script again, and only a
96
+ * zero exit advances. Without it a failing body lands and the flow carries on.
97
+ */
98
+ readonly onFail?: "rest" | undefined
99
+ }
100
+
101
+ /** A ship step rests on no steering file. */
102
+ export type ShipOptions = SteeringCommon & {
103
+ readonly file?: undefined
104
+ readonly mode?: undefined
105
+ }
93
106
 
94
107
  export type JudgePrimitive = "noul" | "choice" | "score"
95
108
 
@@ -204,6 +217,12 @@ export type StepRequest =
204
217
  readonly body: string
205
218
  readonly options: RunOptions
206
219
  }
220
+ | {
221
+ readonly kind: "ship"
222
+ readonly name: string
223
+ readonly message: string
224
+ readonly options: ShipOptions
225
+ }
207
226
  | {
208
227
  readonly kind: "judge"
209
228
  readonly name: string
@@ -344,6 +363,18 @@ export const human: Human = (
344
363
  export const run = (name: string, body: string, options: RunOptions = {}): Promise<void> =>
345
364
  ctx().step({ kind: "run", name, body, options }) as Promise<void>
346
365
 
366
+ /**
367
+ * Squashes HEAD's tree, with `.gtd/` as the user's branch has it, into one commit on the user's branch
368
+ * with `message`, and moves that branch there; HEAD stays on `gtd/<b>`.
369
+ * Resolves to the new commit's hash, or `undefined` when nothing shipped.
370
+ */
371
+ export const ship = (
372
+ name: string,
373
+ message: string,
374
+ options: ShipOptions = {},
375
+ ): Promise<string | undefined> =>
376
+ ctx().step({ kind: "ship", name, message, options }) as Promise<string | undefined>
377
+
347
378
  /** A judge gate: resolves to what the judge answered, for the flow to decide on. */
348
379
  export const judge = <const Q extends readonly JudgeQuestionInput[] = readonly JudgeQuestion[]>(
349
380
  name: string,
@@ -4,6 +4,9 @@ import type { GitOperations } from "./Git.js"
4
4
  /** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
5
5
  export const HISTORY_REF = "refs/worktree/gtd/history"
6
6
 
7
+ /** Set by an abandon that left `gtd/<b>` for `<b>`: only then does restore recreate `gtd/<b>`. */
8
+ export const RESUME_BRANCH_REF = "refs/worktree/gtd/history-branch"
9
+
7
10
  /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
8
11
  export const PROCESS_HISTORY_PREFIX = "refs/gtd/history/"
9
12
 
@@ -31,6 +31,7 @@ export * as NodeServices from "./NodeServices.js"
31
31
  export { loopLogPath } from "./WorktreeState.js"
32
32
  export {
33
33
  HISTORY_REF,
34
+ RESUME_BRANCH_REF,
34
35
  keptRef,
35
36
  processHistoryRef,
36
37
  PROCESS_HISTORY_PREFIX,
@@ -15,6 +15,7 @@ import {
15
15
  refuse,
16
16
  restart,
17
17
  run,
18
+ ship,
18
19
  scope,
19
20
  threads,
20
21
  type Flow,
@@ -647,6 +648,14 @@ describe("replay", () => {
647
648
  expect(outcome.kind === "failed" && outcome.message).toContain("shell script string")
648
649
  })
649
650
 
651
+ it("fails a ship step with an empty message", async () => {
652
+ const wf = async () => {
653
+ await ship("shipping", " ")
654
+ }
655
+ const outcome = await replayOf(wf, new History())
656
+ expect(outcome.kind === "failed" && outcome.message).toContain("non-empty commit message")
657
+ })
658
+
650
659
  it("fails a flow that awaits something other than a step", async () => {
651
660
  const waiting = async () => {
652
661
  await new Promise((resolve) => setTimeout(resolve, 50))
@@ -20,7 +20,7 @@ import {
20
20
  threads,
21
21
  } from "../steering/index.js"
22
22
  import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
23
- import { globMatches, PIN_PATH } from "../wire/index.js"
23
+ import { globMatches, PIN_PATH, RUN_FAILED_PATH } from "../wire/index.js"
24
24
  import { refNameFault } from "./RefName.js"
25
25
  import { diffTrees, isEmptyDiff, withoutPath, type TreeView } from "./Tree.js"
26
26
  import {
@@ -60,6 +60,8 @@ export interface Episode {
60
60
  export interface PendingTurn {
61
61
  readonly tree: TreeView
62
62
  readonly verdicts?: readonly JudgeVerdict[]
63
+ /** What a pending ship step resolves to: a stand-in hash when it will ship, the real one exists only once it landed. */
64
+ readonly shipped?: string
63
65
  }
64
66
 
65
67
  export interface ReplayInput {
@@ -90,7 +92,13 @@ export type StepKind = Exclude<StepRequest["kind"], "restart">
90
92
  type Actor = "agent" | "human" | "check" | "judge"
91
93
 
92
94
  const actorOfKind = (kind: StepKind): Actor =>
93
- kind === "run" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge"
95
+ kind === "run" || kind === "ship"
96
+ ? "check"
97
+ : kind === "agent"
98
+ ? "agent"
99
+ : kind === "human"
100
+ ? "human"
101
+ : "judge"
94
102
 
95
103
  /** One step replay reached, completed or not. */
96
104
  export interface ReachedStep {
@@ -316,10 +324,21 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
316
324
  const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
317
325
  agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
318
326
  human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
319
- run: new Set(STEERING_OPTIONS),
327
+ run: new Set([...STEERING_OPTIONS, "onFail"]),
328
+ ship: new Set(["label", "base"]),
320
329
  judge: new Set([...STEERING_OPTIONS, "message"]),
321
330
  }
322
331
 
332
+ const restsOnFail = (step: ReachedStep): boolean =>
333
+ step.request.kind === "run" && step.request.options.onFail === "rest"
334
+
335
+ const onFailFault = (step: ReachedStep): string | undefined =>
336
+ step.request.kind === "run" &&
337
+ step.request.options.onFail !== undefined &&
338
+ step.request.options.onFail !== "rest"
339
+ ? `gtd: step "${step.name}": onFail must be "rest" in run() options`
340
+ : undefined
341
+
323
342
  const unknownOptions = (step: ReachedStep): string | undefined => {
324
343
  const unknown = Object.keys(step.request.options).filter(
325
344
  (key) => !KNOWN_OPTIONS[step.kind].has(key),
@@ -454,12 +473,13 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
454
473
  const scoped = (name: string): string =>
455
474
  [...scopes.flatMap((s) => (s.name === undefined ? [] : [s.name])), name].join(".")
456
475
 
457
- const isAttemptAt = (commit: ParsedCommit, name: string): boolean =>
476
+ const isAttemptAt = (commit: ParsedCommit, step: ReachedStep): boolean =>
458
477
  commit.parsed.step === undefined &&
459
478
  commit.parsed.parsed !== undefined &&
460
479
  commit.parsed.parsed.from === undefined &&
461
- commit.parsed.parsed.to === name &&
462
- isEmptyDiff(diffTrees(position.tree, commit.tree))
480
+ commit.parsed.parsed.to === step.name &&
481
+ (isEmptyDiff(diffTrees(position.tree, commit.tree)) ||
482
+ (restsOnFail(step) && commit.tree.read(RUN_FAILED_PATH) !== undefined))
463
483
 
464
484
  const advance = (next: Position): void => {
465
485
  previousPosition = position
@@ -469,13 +489,16 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
469
489
  const replyFor = (
470
490
  request: Exclude<StepRequest, { kind: "restart" }>,
471
491
  verdicts: readonly JudgeVerdict[],
492
+ shipped: string | undefined,
472
493
  ): unknown =>
473
494
  request.kind === "judge"
474
495
  ? {
475
496
  answers: answersFrom(verdicts, request.questions),
476
497
  truncated: judgeCuts.get(request) ?? [],
477
498
  }
478
- : undefined
499
+ : request.kind === "ship"
500
+ ? shipped
501
+ : undefined
479
502
 
480
503
  const judgeCuts = new WeakMap<object, readonly string[]>()
481
504
 
@@ -636,7 +659,11 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
636
659
  )
637
660
  }
638
661
  const error =
639
- unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step)
662
+ unknownOptions(step) ??
663
+ onFailFault(step) ??
664
+ approveConflict(step) ??
665
+ modeWithoutFile(step) ??
666
+ checkIdentity(step)
640
667
  return error === undefined ? undefined : { kind: "failed", message: error }
641
668
  }
642
669
 
@@ -670,7 +697,7 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
670
697
  expectNextOf(commit)
671
698
  skipPins()
672
699
  noteKept(step, commit)
673
- return replyFor(step.request, commit.parsed.judge)
700
+ return replyFor(step.request, commit.parsed.judge, commit.parsed.shipped)
674
701
  }
675
702
 
676
703
  // A turn that ends a pinned process names the unpin, which is no step.
@@ -699,11 +726,15 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
699
726
  keptTips.set(step.name, { hash: "", before: position.tree, after: pending.tree })
700
727
  }
701
728
  advance({ hash: "", tree: keeping ? position.tree : pending.tree })
702
- return replyFor(step.request, pending.verdicts ?? [])
729
+ return replyFor(step.request, pending.verdicts ?? [], pending.shipped)
703
730
  }
704
731
 
705
732
  const answer = (step: ReachedStep): unknown => {
706
- while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) {
733
+ while (cursor < commits.length && isAttemptAt(commits[cursor]!, step)) {
734
+ // An attempt that recorded a failure carries its tree; an empty one changes nothing.
735
+ if (!isEmptyDiff(diffTrees(position.tree, commits[cursor]!.tree))) {
736
+ advance({ hash: commits[cursor]!.hash, tree: commits[cursor]!.tree })
737
+ }
707
738
  cursor++
708
739
  skipPins()
709
740
  }
@@ -718,18 +749,31 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
718
749
  marks,
719
750
  })
720
751
  }
752
+ if (restsOnFail(step) && input.pending.tree.read(RUN_FAILED_PATH) !== undefined) {
753
+ return finish({ kind: "rest", rest: step, trace, landed, marks })
754
+ }
721
755
  return landPending(step, input.pending)
722
756
  }
723
757
 
758
+ const bodyFault = (request: Exclude<StepRequest, { kind: "restart" | "agent" }>) => {
759
+ if (
760
+ request.kind === "ship" &&
761
+ (typeof request.message !== "string" || request.message.trim() === "")
762
+ ) {
763
+ return `gtd: step "${scoped(request.name)}": a ship() message is a non-empty commit message`
764
+ }
765
+ if (request.kind === "run" && typeof request.body !== "string") {
766
+ return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
767
+ }
768
+ return undefined
769
+ }
770
+
724
771
  // gtd.config.ts is loaded without a type check.
725
772
  const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
726
773
  if (scoped(request.name).startsWith(ENGINE_PREFIX)) {
727
774
  return `gtd: step "${scoped(request.name)}": the "${ENGINE_PREFIX}" prefix is reserved for gtd's own beats — rename the step`
728
775
  }
729
- if (request.kind === "run" && typeof request.body !== "string") {
730
- return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
731
- }
732
- if (request.kind !== "agent") return undefined
776
+ if (request.kind !== "agent") return bodyFault(request)
733
777
  const name = scoped(request.name)
734
778
  if (request.options.keep === true) {
735
779
  // A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
@@ -118,6 +118,10 @@ export interface CommitMessage {
118
118
  readonly pin: PinChange | undefined
119
119
  /** `Gtd-Anchor`, on a repin's commit: replay sets aside every commit after this one; a non-hash value is skipped. */
120
120
  readonly anchor: string | undefined
121
+ /** `Gtd-Shipped`, on a ship step's landing: the commit it put on the user's branch. */
122
+ readonly shipped: string | undefined
123
+ /** `Gtd-Tip`, on a shipped commit: the process commit whose tree it carries. */
124
+ readonly tip: string | undefined
121
125
  readonly format: number
122
126
  }
123
127
 
@@ -178,6 +182,8 @@ interface Trailers {
178
182
  approved: Approval | undefined
179
183
  pin: PinChange | undefined
180
184
  anchor: string | undefined
185
+ shipped: string | undefined
186
+ tip: string | undefined
181
187
  readonly judge: JudgeVerdict[]
182
188
  readonly marks: Mark[]
183
189
  readonly vars: Record<string, string>
@@ -228,6 +234,12 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
228
234
  "Gtd-Anchor": (value, into) => {
229
235
  if (HASH_RE.test(value)) into.anchor ??= value
230
236
  },
237
+ "Gtd-Shipped": (value, into) => {
238
+ if (HASH_RE.test(value)) into.shipped ??= value
239
+ },
240
+ "Gtd-Tip": (value, into) => {
241
+ if (HASH_RE.test(value)) into.tip ??= value
242
+ },
231
243
  "Gtd-Format": (value, into) => {
232
244
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
233
245
  },
@@ -250,6 +262,8 @@ export const parseCommitMessage = (message: string): CommitMessage => {
250
262
  approved: undefined,
251
263
  pin: undefined,
252
264
  anchor: undefined,
265
+ shipped: undefined,
266
+ tip: undefined,
253
267
  judge: [],
254
268
  marks: [],
255
269
  vars: {},
@@ -6,7 +6,7 @@ export {
6
6
  type ReachedStep,
7
7
  type ReplayOutcome,
8
8
  } from "./Replay.js"
9
- export { treeFromRecord, type TreeView } from "./Tree.js"
9
+ export { diffTrees, treeFromRecord, type TreeView } from "./Tree.js"
10
10
  export {
11
11
  formatCommitMessage,
12
12
  formatStepId,
@@ -12,6 +12,18 @@ export type GitWrite =
12
12
  | { readonly kind: "commitClean"; readonly message: string }
13
13
  | { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
14
14
  | { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
15
+ | { readonly kind: "switchBranch"; readonly branch: string }
16
+ | { readonly kind: "switchTo"; readonly branch: string }
17
+ | { readonly kind: "pushRef"; readonly remote: string; readonly ref: string }
18
+ | ({ readonly kind: "ship"; readonly message: string } & ShipSpec)
19
+
20
+ /** A ship step's squash: `shipMessage` on `branch`, CAS against `parent`, its `Gtd-History` naming `historyRef`. */
21
+ export interface ShipSpec {
22
+ readonly branch: string
23
+ readonly parent: string
24
+ readonly shipMessage: string
25
+ readonly historyRef: string
26
+ }
15
27
 
16
28
  /** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
17
29
  export type Outcome =
@@ -1,5 +1,6 @@
1
1
  import type { CommitSpec } from "../replay/index.js"
2
2
  import type { PendingChange, StateName, StepDef } from "../workflow/index.js"
3
+ import type { ShipSpec } from "./LandStep.js"
3
4
 
4
5
  /**
5
6
  * What landing the pending turn does, decided at the edge by replaying the
@@ -19,6 +20,16 @@ export type Landing =
19
20
  readonly keepHistory?: string
20
21
  /** The kept ref this landing's turn goes to; the branch gets a marker commit instead. */
21
22
  readonly keepTurn?: string
23
+ /** The `gtd/<b>` branch a starting landing switches to before it commits. */
24
+ readonly startBranch?: string
25
+ /** The user's branch `<b>` a finishing landing switches back to, after its history ref. */
26
+ readonly leaveBranch?: string
27
+ /** A remote the closing or shipping landing force-pushes its history ref to, after the commit. */
28
+ readonly pushHistory?: string
29
+ /** A ship step's squash onto the user's branch, made ahead of the landing commit. */
30
+ readonly ship?: ShipSpec
31
+ /** Printed after the landing: why a ship step shipped nothing. */
32
+ readonly note?: string
22
33
  /** The one path this landing commits; every other pending change stays pending. */
23
34
  readonly only?: string
24
35
  }
package/src/step/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { planStep, type JudgeVerdict } from "./planStep.js"
2
2
  export { accessRefusal } from "./accessRefusal.js"
3
3
  export { planStart } from "./planStart.js"
4
+ export { startBranchFor, GTD_BRANCH_PREFIX, userBranchOf } from "./startBranch.js"
4
5
  export type { GitWrite, LandStep, Outcome } from "./LandStep.js"
5
6
  export type { Landing, RepoSnapshot } from "./RepoSnapshot.js"
6
7
  export { strayPaths } from "./strayPaths.js"
@@ -108,7 +108,7 @@ describe("planStart", () => {
108
108
  repo.seedCommit("feat: ahead of main")
109
109
  const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
110
110
  if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
111
- const write = plan.steps.find((s) => s.kind === "gitWrite")
111
+ const write = plan.steps.find((s) => s.kind === "gitWrite" && s.write.kind === "commitAll")
112
112
  if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
113
113
  throw new Error("expected a gitWrite step")
114
114
  expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
@@ -125,7 +125,7 @@ describe("planStart", () => {
125
125
  repo.seedCommit("feat: my branch")
126
126
  const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
127
127
  if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
128
- const write = plan.steps.find((s) => s.kind === "gitWrite")
128
+ const write = plan.steps.find((s) => s.kind === "gitWrite" && s.write.kind === "commitAll")
129
129
  if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
130
130
  throw new Error("expected a gitWrite step")
131
131
  expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
@@ -172,6 +172,7 @@ describe("planStart", () => {
172
172
  if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
173
173
  expect(plan.subject).toBe("gtd(human): work")
174
174
  expect(plan.steps).toEqual([
175
+ { kind: "gitWrite", write: { kind: "switchBranch", branch: "gtd/main" } },
175
176
  {
176
177
  kind: "gitWrite",
177
178
  write: {
@@ -191,7 +192,7 @@ describe("planStart", () => {
191
192
  vars: { base: "main" },
192
193
  })
193
194
  if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
194
- const write = plan.steps.find((s) => s.kind === "gitWrite")
195
+ const write = plan.steps.find((s) => s.kind === "gitWrite" && s.write.kind === "commitAll")
195
196
  if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
196
197
  throw new Error("expected a gitWrite step")
197
198
  expect(write.write.message).toBe(
@@ -11,6 +11,7 @@ import {
11
11
  } from "../workflow/index.js"
12
12
  import { formatCommitMessage, formatSubject } from "../replay/index.js"
13
13
  import type { LandStep } from "./LandStep.js"
14
+ import { startBranchFor } from "./startBranch.js"
14
15
 
15
16
  /** Just enough of the current rest for `planStart`'s checks. */
16
17
  export interface StartCurrent {
@@ -87,6 +88,7 @@ export const planStart = (
87
88
  Error,
88
89
  GitService | ConfigService | ConfigDiscovery | Workspace | Host | Narrator
89
90
  > =>
91
+ // fallow-ignore-next-line complexity
90
92
  Effect.gen(function* () {
91
93
  const { workflow, first, commandLabel, vars: varOverrides } = start
92
94
  const name = workflow.name
@@ -134,7 +136,12 @@ export const planStart = (
134
136
  ...(base !== undefined ? { reviewBase: base } : {}),
135
137
  vars,
136
138
  })
139
+ const onBranch = yield* startBranchFor(commandLabel)
140
+ if (onBranch.kind === "refusal") return refusal(onBranch.message)
137
141
  const steps: readonly LandStep[] = [
142
+ ...(onBranch.kind === "switch"
143
+ ? [{ kind: "gitWrite", write: { kind: "switchBranch", branch: onBranch.branch } } as const]
144
+ : []),
138
145
  { kind: "gitWrite", write: { kind: "commitAll", message } },
139
146
  { kind: "outcome", outcome: { kind: "commit", subject } },
140
147
  ]
@@ -36,12 +36,16 @@ const uncheckStep = (snapshot: RepoSnapshot): readonly LandStep[] =>
36
36
  : []
37
37
 
38
38
  const gitWriteOf = (landing: Extract<Landing, { kind: "commit" }>, message: string): GitWrite => {
39
+ if (landing.ship !== undefined) return { kind: "ship", message, ...landing.ship }
39
40
  if (landing.only !== undefined) return { kind: "commitPath", path: landing.only, message }
40
41
  if (landing.keepTurn !== undefined) return { kind: "keepTurn", ref: landing.keepTurn, message }
41
42
  if (landing.spec.approved !== undefined) return { kind: "commitClean", message }
42
43
  return { kind: "commitAll", message }
43
44
  }
44
45
 
46
+ const writeIf = (value: string | undefined, write: (value: string) => GitWrite): LandStep[] =>
47
+ value === undefined ? [] : [{ kind: "gitWrite", write: write(value) }]
48
+
45
49
  export const planStep = (
46
50
  snapshot: RepoSnapshot,
47
51
  opts: {
@@ -78,6 +82,7 @@ export const planStep = (
78
82
  ...(opts.judge !== undefined && opts.judge.length > 0 ? { judge: opts.judge } : {}),
79
83
  })
80
84
  const subject = message.split("\n")[0]!
85
+ const historyRef = landing.keepHistory ?? landing.ship?.historyRef
81
86
  const outcome: LandStep =
82
87
  landing.to === snapshot.state
83
88
  ? { kind: "outcome", outcome: { kind: "commit", subject } }
@@ -88,20 +93,20 @@ export const planStep = (
88
93
  to: landing.to,
89
94
  subject,
90
95
  steps: [
96
+ ...writeIf(landing.startBranch, (branch) => ({ kind: "switchBranch", branch })),
91
97
  ...uncheckStep(snapshot),
92
- {
93
- kind: "gitWrite",
94
- write: gitWriteOf(landing, message),
95
- },
96
- ...(landing.keepHistory !== undefined
97
- ? [
98
- {
99
- kind: "gitWrite",
100
- write: { kind: "updateRef", ref: landing.keepHistory, target: "HEAD" },
101
- } as const,
102
- ]
103
- : []),
98
+ { kind: "gitWrite", write: gitWriteOf(landing, message) },
99
+ ...writeIf(historyRef, (ref) => ({ kind: "updateRef", ref, target: "HEAD" })),
100
+ ...writeIf(landing.leaveBranch, (branch) => ({ kind: "switchTo", branch })),
104
101
  outcome,
102
+ ...(landing.note !== undefined
103
+ ? [{ kind: "outcome", outcome: { kind: "note", text: landing.note } } as const]
104
+ : []),
105
+ ...writeIf(historyRef !== undefined ? landing.pushHistory : undefined, (remote) => ({
106
+ kind: "pushRef",
107
+ remote,
108
+ ref: historyRef!,
109
+ })),
105
110
  ],
106
111
  }
107
112
  }
@@ -0,0 +1,54 @@
1
+ import { Effect, Option } from "effect"
2
+ import { GitService } from "../platform/index.js"
3
+ import { PROCESS_HISTORY_PREFIX } from "../platform/index.js"
4
+
5
+ export const GTD_BRANCH_PREFIX = "gtd/"
6
+
7
+ /** `<b>` for a `gtd/<b>` branch name; `undefined` for any other. */
8
+ export const userBranchOf = (branch: string): string | undefined =>
9
+ branch.startsWith(GTD_BRANCH_PREFIX) ? branch.slice(GTD_BRANCH_PREFIX.length) : undefined
10
+
11
+ /** A branch already named `gtd/…` and a detached HEAD start in place. */
12
+ export type StartBranch =
13
+ | { readonly kind: "inPlace" }
14
+ | { readonly kind: "switch"; readonly branch: string }
15
+ | { readonly kind: "refusal"; readonly message: string }
16
+
17
+ /**
18
+ * Where a new process starts: on `gtd/<b>`, so `<b>` never carries its commits.
19
+ * An existing `gtd/<b>` is reused only when its tip is contained in `<b>` or in
20
+ * a kept history ref (a squash onto `<b>` never makes it an ancestor).
21
+ */
22
+ export const startBranchFor = (
23
+ commandLabel: string,
24
+ ): Effect.Effect<StartBranch, Error, GitService> =>
25
+ Effect.gen(function* () {
26
+ const git = yield* GitService
27
+ const current = yield* git.currentBranch()
28
+ if (Option.isNone(current) || current.value.startsWith(GTD_BRANCH_PREFIX)) {
29
+ return { kind: "inPlace" } as const
30
+ }
31
+ if (!(yield* git.hasCommits())) return { kind: "inPlace" } as const
32
+ const branch = `${GTD_BRANCH_PREFIX}${current.value}`
33
+ const existing = yield* git.readRefOption(`refs/heads/${branch}`)
34
+ if (Option.isSome(existing)) {
35
+ const head = yield* git.resolveRef("HEAD")
36
+ let contained = yield* git.isAncestor(existing.value, head)
37
+ if (!contained) {
38
+ const kept = yield* git.listRefs(PROCESS_HISTORY_PREFIX)
39
+ for (const k of kept) {
40
+ if (yield* git.isAncestor(existing.value, k.hash)) {
41
+ contained = true
42
+ break
43
+ }
44
+ }
45
+ }
46
+ if (!contained) {
47
+ return {
48
+ kind: "refusal",
49
+ message: `${commandLabel}: branch ${branch} holds commits that are neither on ${current.value} nor in a kept history ref — merge or delete it first`,
50
+ } as const
51
+ }
52
+ }
53
+ return { kind: "switch", branch } as const
54
+ })