@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
@@ -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' +
@@ -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")
@@ -180,6 +180,89 @@ export class InMemRepo {
180
180
  this.index = new Map(tree)
181
181
  }
182
182
 
183
+ /** `git switch -C <name> [<start>]`: pending changes ride along only when the tip's tree is unchanged. */
184
+ switchBranchTo(name: string, start?: string): void {
185
+ const hash = start === undefined ? this.head : this.resolveRef(start)
186
+ if (hash === null) throw new Error(`invalid reference: ${start ?? "HEAD"}`)
187
+ this.moveToBranch(name, hash, true)
188
+ }
189
+
190
+ /** `git switch <name>`: a pending change rides along when its path is the same in both trees. */
191
+ switchTo(name: string): void {
192
+ const hash = this.branches.get(name)
193
+ if (hash === undefined) throw new Error(`invalid reference: ${name}`)
194
+ if (hash === this.head) return this.moveToBranch(name, hash, false)
195
+ const from = this.headTree()
196
+ const to = this.getCommit(hash)?.files ?? new Map<string, string>()
197
+ const pending = [
198
+ ...new Set([...from.keys(), ...this.index.keys(), ...this.worktree.keys()]),
199
+ ].filter(
200
+ (path) =>
201
+ this.index.get(path) !== from.get(path) || this.worktree.get(path) !== from.get(path),
202
+ )
203
+ if (pending.some((path) => to.get(path) !== from.get(path))) {
204
+ throw new Error(`cannot switch to '${name}': local changes would be overwritten`)
205
+ }
206
+ const carry = (side: Map<string, string>) => {
207
+ const out = new Map(to)
208
+ for (const path of pending) {
209
+ const content = side.get(path)
210
+ if (content === undefined) out.delete(path)
211
+ else out.set(path, content)
212
+ }
213
+ return out
214
+ }
215
+ this.worktree = carry(this.worktree)
216
+ this.index = carry(this.index)
217
+ this.head = hash
218
+ this.currentBranch = name
219
+ }
220
+
221
+ private moveToBranch(name: string, hash: string, reset: boolean): void {
222
+ if (hash !== this.head) {
223
+ if (this.statusPorcelain().trim() !== "") {
224
+ throw new Error(`cannot switch to '${name}': the working tree has uncommitted changes`)
225
+ }
226
+ const tree = this.getCommit(hash)?.files ?? new Map<string, string>()
227
+ this.head = hash
228
+ this.worktree = new Map(tree)
229
+ this.index = new Map(tree)
230
+ }
231
+ if (reset || !this.branches.has(name)) this.branches.set(name, hash)
232
+ this.currentBranch = name
233
+ }
234
+
235
+ deleteBranch(name: string): void {
236
+ if (name === this.currentBranch) throw new Error(`cannot delete branch '${name}' checked out`)
237
+ if (!this.branches.delete(name)) throw new Error(`branch '${name}' not found`)
238
+ }
239
+
240
+ private remotes: Map<string, Map<string, string>> = new Map()
241
+
242
+ /** Test setup: a remote that accepts pushes. */
243
+ // fallow-ignore-next-line unused-class-member
244
+ addRemote(name: string): void {
245
+ this.remotes.set(name, new Map())
246
+ }
247
+
248
+ /** What a remote holds after pushes: ref → hash. */
249
+ // fallow-ignore-next-line unused-class-member
250
+ remoteRefs(name: string): ReadonlyMap<string, string> {
251
+ return this.remotes.get(name) ?? new Map()
252
+ }
253
+
254
+ /** `git push --force <remote> <ref>:<ref>`; returns git's complaint, or `undefined`. */
255
+ pushRef(remote: string, ref: string): string | undefined {
256
+ const there = this.remotes.get(remote)
257
+ if (there === undefined) {
258
+ return `fatal: '${remote}' does not appear to be a git repository`
259
+ }
260
+ const hash = this.resolveRef(ref)
261
+ if (hash === null) return `error: src refspec ${ref} does not match any`
262
+ there.set(ref, hash)
263
+ return undefined
264
+ }
265
+
183
266
  /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
184
267
  listRefs(prefix: string): Array<{ ref: string; hash: string; date: string }> {
185
268
  return [...this.refs.entries()]
@@ -473,6 +556,39 @@ export class InMemRepo {
473
556
  return undefined
474
557
  }
475
558
 
559
+ /** Twin of `GitScript.shipCommit`: the staged tree, with `.gtd/` as `branch` has it, onto `branch`, then the landing commit naming it. */
560
+ shipCommit(
561
+ branch: string,
562
+ parent: string,
563
+ shipMessage: string,
564
+ historyRef: string,
565
+ message: string,
566
+ ): string | undefined {
567
+ this.stageAll()
568
+ if (this.head === null) return "fatal: Needed a single revision"
569
+ if (this.signingFails) return "error: gpg failed to sign the data"
570
+ const kept = [...(this.commits.get(parent)?.files ?? [])]
571
+ const tree = new Map([
572
+ ...[...this.index].filter(([path]) => !path.startsWith(".gtd/")),
573
+ ...kept.filter(([path]) => path.startsWith(".gtd/")),
574
+ ])
575
+ const shipped = `${shipMessage}\n\nGtd-History: ${historyRef}\nGtd-Tip: ${this.head}\n`
576
+ const hash = makeHash(shipped, parent, tree)
577
+ if (this.branches.get(branch) !== parent) {
578
+ return `fatal: update_ref failed for ref 'refs/heads/${branch}': cannot lock ref 'refs/heads/${branch}': is at ${this.branches.get(branch) ?? "nothing"} but expected ${parent}`
579
+ }
580
+ this.commits.set(hash, {
581
+ hash,
582
+ message: shipped,
583
+ files: tree,
584
+ parent,
585
+ seq: this.nextSeq++,
586
+ signed: this.signingEnabled,
587
+ })
588
+ this.branches.set(branch, hash)
589
+ return this.commitAsIs(`${message}\nGtd-Shipped: ${hash}`)
590
+ }
591
+
476
592
  /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
477
593
  discardPending(): void {
478
594
  this.index = new Map(this.worktree)
@@ -54,14 +54,24 @@ export const beatKindOf = (input: {
54
54
  * `stalled at "<state>"` stays a stable substring (see
55
55
  * `tests/integration/features/driver-doc.feature`).
56
56
  */
57
- export const stallDiagnosis = (state: StateName, actor: Actor): string =>
58
- `stalled at "${state}": the last gtd(${actor}): ${state} turn landed an empty ` +
59
- `attempt, the tree is clean, and another dispatch would repeat it.\n\n` +
60
- `Two ways out:\n` +
61
- ` - sharpen the step's prompt so the turn has something concrete to author\n` +
62
- ` - pass allowEmpty: true to the step, so an empty turn completes it and the\n` +
63
- ` flow decides what follows — finishing, or an escalation step after N\n` +
64
- ` empty turns\n`
57
+ export const stallDiagnosis = (
58
+ state: StateName,
59
+ actor: Actor,
60
+ failure?: string,
61
+ blocked?: string,
62
+ ): string =>
63
+ blocked !== undefined
64
+ ? `stalled at "${state}": landing this ship step is refused.\n\n${blocked}\n`
65
+ : failure !== undefined
66
+ ? `stalled at "${state}": the script exited non-zero and its failure is recorded in the tree. ` +
67
+ `Change the tree to run the script again; the step advances only on a zero exit.\n\n${failure}`
68
+ : `stalled at "${state}": the last gtd(${actor}): ${state} turn landed an empty ` +
69
+ `attempt, the tree is clean, and another dispatch would repeat it.\n\n` +
70
+ `Two ways out:\n` +
71
+ ` - sharpen the step's prompt so the turn has something concrete to author\n` +
72
+ ` - pass allowEmpty: true to the step, so an empty turn completes it and the\n` +
73
+ ` flow decides what follows — finishing, or an escalation step after N\n` +
74
+ ` empty turns\n`
65
75
 
66
76
  /** One dispatch session — `resume`'s own meaning lives with `src/wire/Sessions.ts`'s `resolveSession`, which produces it. */
67
77
  export interface DemandSession {
@@ -100,7 +110,7 @@ export type Demand =
100
110
  export const demandOf = (input: {
101
111
  readonly rendered: Pick<
102
112
  RenderedDemandSource,
103
- "actor" | "content" | "state" | "skills" | "access" | "pin"
113
+ "actor" | "content" | "state" | "skills" | "access" | "pin" | "failure" | "blocked"
104
114
  >
105
115
  readonly kind: BeatKind
106
116
  readonly session?: DemandSession
@@ -108,7 +118,9 @@ export const demandOf = (input: {
108
118
  }): Demand => {
109
119
  const { rendered, kind, session, validate } = input
110
120
  const content =
111
- kind === "stalled" ? stallDiagnosis(rendered.state, rendered.actor) : rendered.content
121
+ kind === "stalled"
122
+ ? stallDiagnosis(rendered.state, rendered.actor, rendered.failure, rendered.blocked)
123
+ : rendered.content
112
124
  const skills =
113
125
  rendered.skills !== undefined && rendered.skills.length > 0 ? rendered.skills : undefined
114
126
  const access = rendered.access ?? { read: null, write: null }
@@ -23,3 +23,6 @@ export const ACCESS_REFUSAL =
23
23
 
24
24
  /** Where a process keeps its pin: the engine and workflow it runs on, as `gtd bundle` prints them. */
25
25
  export const PIN_PATH = ".gtd/process.gtdpin"
26
+
27
+ /** Where a `run(…, { onFail: "rest" })` body's failure is recorded in the tree: the exit status and the output. */
28
+ export const RUN_FAILED_PATH = ".gtd/run-failed.txt"
package/src/wire/types.ts CHANGED
@@ -55,4 +55,8 @@ export interface RenderedDemandSource {
55
55
  readonly skills?: readonly string[]
56
56
  readonly access?: StepAccess
57
57
  readonly pin?: PinMark
58
+ /** The recorded output of a failed `onFail: "rest"` run, shown at the stalled beat. */
59
+ readonly failure?: string
60
+ /** Why landing a ship step is refused, shown at the stalled beat. */
61
+ readonly blocked?: string
58
62
  }
@@ -42,6 +42,7 @@ export interface StepDef {
42
42
  readonly requireRevert?: boolean
43
43
  readonly allowEmpty?: boolean
44
44
  readonly keep?: boolean
45
+ readonly onFail?: "rest"
45
46
  readonly acceptClean?: boolean
46
47
  readonly approve?: boolean
47
48
  readonly skills?: readonly string[]
@@ -8,6 +8,7 @@ export interface CompiledConfig {
8
8
  /** The built-in modes (qa/review, with gtd's own validators) merged with `config.modes`, per half. */
9
9
  readonly modes: Record<string, ModeDef>
10
10
  readonly ui?: UiSettings
11
+ readonly historyRemote?: string
11
12
  readonly skills: Readonly<Record<string, readonly string[]>>
12
13
  readonly access: GtdConfig["access"] & {}
13
14
  readonly diagnostics: readonly Diagnostic[]
@@ -48,6 +49,7 @@ export const compileConfig = (value: unknown, origin: string): CompiledConfig =>
48
49
  return {
49
50
  modes,
50
51
  ...(config.ui !== undefined ? { ui: config.ui } : {}),
52
+ ...(config.historyRemote !== undefined ? { historyRemote: config.historyRemote } : {}),
51
53
  // Null prototype: a `__proto__` key must stay an ordinary (reportable) entry.
52
54
  skills: Object.assign(Object.create(null), config.skills),
53
55
  access: Object.assign(Object.create(null), config.access),
@@ -40,6 +40,7 @@ export interface ConfigOperations {
40
40
  readonly doors: ReadonlyMap<string, Door>
41
41
  /** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
42
42
  readonly ui?: flows.UiSettings
43
+ readonly historyRemote?: string
43
44
  /** Non-fatal findings, formatted through `formatDiagnostic` like an error. */
44
45
  readonly warnings: readonly Diagnostic[]
45
46
  /** The content hash of every file `gtd.config.ts`'s evaluation touched — itself plus any relative import — by path relative to the root. */
@@ -197,6 +198,7 @@ export const load: Effect.Effect<
197
198
  return entry === undefined ? undefined : resolve(name, entry)
198
199
  },
199
200
  ...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
201
+ ...(compiled.historyRemote !== undefined ? { historyRemote: compiled.historyRemote } : {}),
200
202
  warnings: diagnostics.filter((d) => d.severity === "warning"),
201
203
  fingerprint:
202
204
  pinned?.fingerprint ??