@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
@@ -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
  ]
@@ -24,11 +24,11 @@ export type StepOutcome =
24
24
  }
25
25
 
26
26
  /**
27
- * At the human review gate an unconditional `gtd uncheck '<file>'` runs
28
- * ahead of the commit, resetting every checkbox before `git add -A` picks it
29
- * up — a tick is read-progress, never sign-off.
27
+ * Clear ticks at the human review gate, a tick is read-progress. Keep them when
28
+ * the next step is an agent turn: the agent reads them.
30
29
  */
31
- const uncheckStep = (snapshot: RepoSnapshot): readonly LandStep[] =>
30
+ const uncheckStep = (snapshot: RepoSnapshot, keepTicks: boolean): readonly LandStep[] =>
31
+ !keepTicks &&
32
32
  snapshot.stepDef.actor === "human" &&
33
33
  snapshot.stepDef.mode === "review" &&
34
34
  snapshot.file !== undefined
@@ -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 ?? landing.recordedHistory
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: [
91
- ...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
- : []),
96
+ ...writeIf(landing.startBranch, (branch) => ({ kind: "switchBranch", branch })),
97
+ ...uncheckStep(snapshot, landing.keepTicks === true),
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
+ })
@@ -13,6 +13,12 @@ import {
13
13
  hardResetTo,
14
14
  keepTurn,
15
15
  mixedResetTo,
16
+ switchBranchTo,
17
+ switchBack,
18
+ switchTo,
19
+ deleteBranch,
20
+ pushRef,
21
+ shipCommit,
16
22
  shellQuote,
17
23
  softResetTo,
18
24
  updateRef,
@@ -26,6 +32,7 @@ import {
26
32
  modeContradictionSkipNotice,
27
33
  OUTCOME_MARKER,
28
34
  pinScript,
35
+ failRestScript,
29
36
  repinScript,
30
37
  unpinScript,
31
38
  } from "../emit/index.js"
@@ -161,6 +168,19 @@ const recognizeKeepTurn = (repo: InMemRepo, block: string): BlockOutcome | undef
161
168
  return failure === undefined ? { kind: "applied" } : { kind: "failed", error: failure, status: 1 }
162
169
  }
163
170
 
171
+ const attempt = (apply: () => void): BlockOutcome => {
172
+ try {
173
+ apply()
174
+ return { kind: "applied" }
175
+ } catch (e) {
176
+ return {
177
+ kind: "failed",
178
+ error: `fatal: ${e instanceof Error ? e.message : String(e)}`,
179
+ status: 128,
180
+ }
181
+ }
182
+ }
183
+
164
184
  // fallow-ignore-next-line complexity
165
185
  const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
166
186
  if (block === discardPending()) {
@@ -250,6 +270,73 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
250
270
  return undefined
251
271
  }
252
272
 
273
+ if (block.startsWith("git switch -q -C ")) {
274
+ const [branch, start] = extractQuotedTokens(block)
275
+ if (branch !== undefined && switchBranchTo(branch, start) === block) {
276
+ return attempt(() => repo.switchBranchTo(branch, start))
277
+ }
278
+ return undefined
279
+ }
280
+
281
+ if (block.startsWith("if ! git switch -q ")) {
282
+ const [branch, , note] = extractQuotedTokens(block)
283
+ if (branch !== undefined && switchBack(branch) === block) {
284
+ try {
285
+ repo.switchTo(branch)
286
+ return { kind: "applied" }
287
+ } catch {
288
+ return { kind: "noop", output: `${note}\n` }
289
+ }
290
+ }
291
+ return undefined
292
+ }
293
+
294
+ if (block.startsWith("git switch -q ")) {
295
+ const [branch] = extractQuotedTokens(block)
296
+ if (branch !== undefined && switchTo(branch) === block) {
297
+ return attempt(() => repo.switchTo(branch))
298
+ }
299
+ return undefined
300
+ }
301
+
302
+ if (block.startsWith("git branch -q -D ")) {
303
+ const [branch] = extractQuotedTokens(block)
304
+ if (branch !== undefined && deleteBranch(branch) === block) {
305
+ return attempt(() => repo.deleteBranch(branch))
306
+ }
307
+ return undefined
308
+ }
309
+
310
+ if (block.startsWith("if ! git push --force ")) {
311
+ const [remote, refspec] = extractQuotedTokens(block)
312
+ const ref = refspec?.split(":")[0]
313
+ if (remote !== undefined && ref !== undefined && pushRef(remote, ref) === block) {
314
+ const failure = repo.pushRef(remote, ref)
315
+ if (failure === undefined) return { kind: "applied" }
316
+ const retry = extractQuotedTokens(block)[3]!
317
+ return { kind: "failed", error: `${failure}\n${retry}`, status: 1 }
318
+ }
319
+ return undefined
320
+ }
321
+
322
+ if (block.startsWith("gtd_ship_parent=")) {
323
+ const [parent, branch, history, shipMessage, message] = extractQuotedTokens(block)
324
+ if (
325
+ parent === undefined ||
326
+ branch === undefined ||
327
+ history === undefined ||
328
+ shipMessage === undefined ||
329
+ message === undefined ||
330
+ shipCommit(branch, parent, shipMessage, history, message) !== block
331
+ ) {
332
+ return undefined
333
+ }
334
+ const failure = repo.shipCommit(branch, parent, shipMessage, history, message)
335
+ return failure === undefined
336
+ ? { kind: "applied" }
337
+ : { kind: "failed", error: failure, status: 1 }
338
+ }
339
+
253
340
  if (block.startsWith("git update-ref -d ")) {
254
341
  const [ref] = extractQuotedTokens(block)
255
342
  if (ref !== undefined && deleteRef(ref) === block) {
@@ -375,6 +462,35 @@ const recognizeModeCommand = (
375
462
  return recognizeScriptedCommand(repo, new Map([[command, bound]]), command)
376
463
  }
377
464
 
465
+ const FAIL_REST_MIDDLE = '\n} 2>&1 )" || gtd_run_status=$?\n'
466
+
467
+ /** A `run(…, { onFail: "rest" })` body: the inner script's failure is recorded in the tree and the block itself succeeds. */
468
+ // fallow-ignore-next-line complexity
469
+ const recognizeFailRest = (
470
+ repo: InMemRepo,
471
+ commands: CommandTable,
472
+ block: string,
473
+ ): BlockOutcome | undefined => {
474
+ if (!block.startsWith("rm -f -- '") || !block.includes("gtd_run_out=")) return undefined
475
+ const [path] = extractQuotedTokens(block)
476
+ const headerEnd = block.indexOf('gtd_run_out="$( {\n')
477
+ const bodyEnd = block.indexOf(FAIL_REST_MIDDLE)
478
+ if (path === undefined || headerEnd === -1 || bodyEnd === -1) return undefined
479
+ const body = block.slice(headerEnd + 'gtd_run_out="$( {\n'.length, bodyEnd)
480
+ if (failRestScript(body, path) !== block) return undefined
481
+ if (repo.readFile(path) !== undefined) repo.deleteFile(path)
482
+ const result = applyEmittedScript(repo, commands, body)
483
+ if (!result.ok && result.error?.startsWith(UNRECOGNIZED_PREFIX)) {
484
+ return { kind: "failed", error: `${result.output}${result.error}` }
485
+ }
486
+ if (!result.ok) {
487
+ // Mirrors `$(…)` (strips trailing newlines) and the script's `tail -n 50`.
488
+ const lines = result.output.replace(/\n+$/, "").split("\n")
489
+ repo.writeFile(path, `exit ${result.status}\n${lines.slice(-50).join("\n")}\n`)
490
+ }
491
+ return { kind: "noop", output: result.output }
492
+ }
493
+
378
494
  const FAILURE_PROMPT_HEADER = 'gtd_validate_status=0\ngtd_validate_out="$( {\n'
379
495
  const FAILURE_PROMPT_MIDDLE =
380
496
  '\n} 2>&1 )" || gtd_validate_status=$?\n' +
@@ -565,7 +681,7 @@ const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undef
565
681
 
566
682
  const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/
567
683
 
568
- /** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, so no tick ever reaches it. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
684
+ /** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, absent when the next step is an agent turn. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
569
685
  const recognizeGtdUncheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
570
686
  const match = GTD_UNCHECK_RE.exec(block)
571
687
  if (!match) return undefined
@@ -728,6 +844,7 @@ const recognizersFor = (
728
844
  (block) => (git ? recognizeGitBuilders(repo, block) : undefined),
729
845
  (block) => recognizeWriteLines(repo, block),
730
846
  (block) => recognizePinScripts(repo, block),
847
+ (block) => recognizeFailRest(repo, commands, block),
731
848
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
732
849
  (block) => recognizePresentationSubshell(repo, commands, block, git),
733
850
  (block) => recognizeModeContradictionSkipNotice(block),
@@ -5,15 +5,20 @@ import {
5
5
  combinedScript,
6
6
  commitAll,
7
7
  commitAsIs,
8
+ deleteBranch,
8
9
  emitScripts,
10
+ failRestScript,
9
11
  failurePromptWrapper,
10
12
  fileExistsGuard,
11
13
  modeContradictionSkipNotice,
14
+ switchBranchTo,
15
+ switchBack,
16
+ switchTo,
12
17
  transitionOutcome,
13
18
  withFileVar,
14
19
  type EmitStep,
15
20
  } from "../emit/index.js"
16
- import { Effect } from "effect"
21
+ import { Effect, Option } from "effect"
17
22
  import { ScriptSurface } from "../emit/index.js"
18
23
  import { GitService } from "../platform/index.js"
19
24
  import { listFormat } from "../steering/index.js"
@@ -305,6 +310,147 @@ printf '%s\n' 'a' '' 'it'\''s' '100%' '-x' 'a\b' '.gtd/packages/00-x.md' > '.gtd
305
310
  )
306
311
  })
307
312
 
313
+ describe("branch scripts", () => {
314
+ it("switchBranchTo creates the branch at HEAD and moves onto it", () => {
315
+ const head = t.observe.resolveRef("HEAD")
316
+ expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
317
+ expect(t.observe.resolveRef("gtd/x")).toBe(head)
318
+ t.seed.writeFile("a.txt", "a\n")
319
+ expect(run(commitAll("gtd(agent): building")).ok).toBe(true)
320
+ expect(t.observe.resolveRef("gtd/x")).toBe(t.observe.resolveRef("HEAD"))
321
+ expect(t.observe.resolveRef("gtd/x")).not.toBe(head)
322
+ })
323
+
324
+ it("switchTo moves onto an existing branch and fails for a missing one", () => {
325
+ expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
326
+ expect(run(switchTo("nope")).ok).toBe(false)
327
+ expect(run(switchBranchTo("gtd/y")).ok).toBe(true)
328
+ expect(run(switchTo("gtd/x")).ok).toBe(true)
329
+ })
330
+
331
+ const branch = () =>
332
+ t.provide(Effect.flatMap(GitService, (g) => g.currentBranch())).then(Option.getOrUndefined)
333
+
334
+ it("switchBack carries a change to a path both branches agree on", async () => {
335
+ expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
336
+ t.seed.commit("feat: on gtd/x", { "b.txt": "b\n" })
337
+ expect(run(switchBranchTo("x", "HEAD~1")).ok).toBe(true)
338
+ expect(run(switchTo("gtd/x")).ok).toBe(true)
339
+ t.seed.writeFile("a.txt", "changed\n")
340
+ const result = run(switchBack("x"))
341
+ expect(result.ok).toBe(true)
342
+ expect(result.output).toBe("")
343
+ expect(await branch()).toBe("x")
344
+ expect(read("a.txt")).toBe("changed\n")
345
+ expect(read("b.txt")).toBeUndefined()
346
+ })
347
+
348
+ it("switchBack stays put, saying so, when a change would be overwritten", async () => {
349
+ expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
350
+ t.seed.commit("feat: on gtd/x", { "b.txt": "b\n" })
351
+ expect(run(switchBranchTo("x", "HEAD~1")).ok).toBe(true)
352
+ expect(run(switchTo("gtd/x")).ok).toBe(true)
353
+ t.seed.writeFile("b.txt", "changed\n")
354
+ const result = run(switchBack("x"))
355
+ expect(result.ok).toBe(true)
356
+ expect(result.output).toContain("pending changes keep HEAD off x")
357
+ expect(await branch()).toBe("gtd/x")
358
+ expect(read("b.txt")).toBe("changed\n")
359
+ })
360
+
361
+ it("deleteBranch removes another branch and refuses the checked-out one", () => {
362
+ expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
363
+ expect(run(deleteBranch("gtd/x")).ok).toBe(false)
364
+ expect(run(switchBranchTo("gtd/y")).ok).toBe(true)
365
+ expect(run(deleteBranch("gtd/x")).ok).toBe(true)
366
+ expect(t.observe.refExists("refs/heads/gtd/x")).toBe(false)
367
+ })
368
+ })
369
+
370
+ describe("shipCommit", () => {
371
+ const HISTORY = "refs/gtd/history/0123456789012345678901234567890123456789"
372
+ const LANDING = "gtd(check): shipping → after\n\nGtd-Format: 4"
373
+ const git = <A>(f: (g: GitService["Service"]) => Effect.Effect<A, Error>) =>
374
+ t.provide(Effect.flatMap(GitService, f))
375
+ // As a landing renders it: under `set -eu`.
376
+ const ship = (branch: string, parent: string, shipMessage: string) =>
377
+ ScriptSurface.render([
378
+ {
379
+ kind: "gitWrite",
380
+ write: {
381
+ kind: "ship",
382
+ branch,
383
+ parent,
384
+ shipMessage,
385
+ historyRef: HISTORY,
386
+ message: LANDING,
387
+ },
388
+ },
389
+ ])
390
+
391
+ it("squashes the staged tree minus .gtd/ onto the branch, then lands naming it", async () => {
392
+ const branch = await git((g) => g.currentBranch())
393
+ const user = Option.getOrElse(branch, () => "main")
394
+ const parent = t.observe.resolveRef("HEAD")
395
+ expect(run(switchBranchTo(`gtd/${user}`)).ok).toBe(true)
396
+ t.seed.writeFile("a.txt", "shipped\n")
397
+ t.seed.writeFile(".gtd/notes.md", "kept back\n")
398
+ const result = run(ship(user, parent, "feat: it's shipped"))
399
+ expect(result.ok).toBe(true)
400
+ const shipped = t.observe.resolveRef(user)
401
+ expect(shipped).not.toBe(parent)
402
+ expect(t.observe.resolveRef(`${shipped}~1`)).toBe(parent)
403
+ expect(await git((g) => g.readFileAtRef(shipped, "a.txt"))).toBe("shipped\n")
404
+ expect(await git((g) => g.diffPaths(parent, shipped))).toEqual(["a.txt"])
405
+ const message = await git((g) => g.lastCommitMessage(shipped))
406
+ expect(message).toContain("feat: it's shipped")
407
+ expect(message).toContain(`\n\nGtd-History: ${HISTORY}\nGtd-Tip: ${parent}`)
408
+ expect(await git((g) => g.lastCommitMessage("HEAD"))).toContain(`Gtd-Shipped: ${shipped}`)
409
+ expect(t.observe.statusPorcelain()).toBe("")
410
+ })
411
+
412
+ it("keeps the .gtd/ files the branch tracks as it has them", async () => {
413
+ const user = Option.getOrElse(await git((g) => g.currentBranch()), () => "main")
414
+ t.seed.commit("chore: track", { ".gtd/keep.md": "tracked\n" })
415
+ const parent = t.observe.resolveRef("HEAD")
416
+ expect(run(switchBranchTo(`gtd/${user}`)).ok).toBe(true)
417
+ t.seed.writeFile("a.txt", "shipped\n")
418
+ t.seed.writeFile(".gtd/keep.md", "process\n")
419
+ expect(run(ship(user, parent, "feat: x")).ok).toBe(true)
420
+ const shipped = t.observe.resolveRef(user)
421
+ expect(await git((g) => g.readFileAtRef(shipped, ".gtd/keep.md"))).toBe("tracked\n")
422
+ expect(await git((g) => g.diffPaths(parent, shipped))).toEqual(["a.txt"])
423
+ })
424
+
425
+ it("fails before landing when the branch moved since the decision", () => {
426
+ const head = t.observe.resolveRef("HEAD")
427
+ expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
428
+ expect(run(switchBranchTo("x")).ok).toBe(true)
429
+ t.seed.commit("feat: moved", { "b.txt": "b\n" })
430
+ expect(run(switchTo("gtd/x")).ok).toBe(true)
431
+ t.seed.writeFile("a.txt", "a\n")
432
+ expect(run(ship("x", head, "feat: x")).ok).toBe(false)
433
+ expect(t.observe.resolveRef("HEAD")).toBe(head)
434
+ })
435
+ })
436
+
437
+ describe("failRestScript", () => {
438
+ const FAIL_PATH = ".gtd/run-failed.txt"
439
+ const wrapped = (body: string) => failRestScript(body, FAIL_PATH)
440
+
441
+ it("records the status and output, without the output's trailing newline, and succeeds", () => {
442
+ expect(run(wrapped(withFileVar(FAILING, FILE))).ok).toBe(true)
443
+ expect(read(FAIL_PATH)).toBe(`exit 2\nbad: ${FILE}\n`)
444
+ })
445
+
446
+ it("leaves no record when the body passes, and clears an earlier one", () => {
447
+ t.seed.writeFile(FAIL_PATH, "exit 1\nold\n")
448
+ t.seed.writeFile(FILE, "a\n")
449
+ expect(run(wrapped(withFileVar(COPY, FILE))).ok).toBe(true)
450
+ expect(read(FAIL_PATH)).toBeUndefined()
451
+ })
452
+ })
453
+
308
454
  describe("commitAll", () => {
309
455
  it("a staging hook's edit lands in the commit", () => {
310
456
  t.seed.stagingHook("readme.txt", "hooked")