@pmelab/gtd 26.0.0 → 26.1.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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -0
  3. package/dist/flows.mjs +15 -1
  4. package/dist/gtd.bundle.mjs +134 -18
  5. package/dist/testkit.mjs +284 -55
  6. package/package.json +1 -1
  7. package/src/cli/Cli.test.ts +3 -3
  8. package/src/cli/History.test.ts +2 -2
  9. package/src/cli/Program.test.ts +10 -12
  10. package/src/edge/Edge.test.ts +113 -16
  11. package/src/edge/Edge.ts +22 -6
  12. package/src/emit/GitScript.test.ts +65 -2
  13. package/src/emit/GitScript.ts +34 -1
  14. package/src/emit/index.ts +1 -0
  15. package/src/flags/index.ts +0 -1
  16. package/src/flows/runtime.test.ts +33 -0
  17. package/src/flows/runtime.ts +31 -0
  18. package/src/platform/RetainedHistory.test.ts +9 -0
  19. package/src/platform/RetainedHistory.ts +3 -0
  20. package/src/platform/Workspace.test.ts +1 -1
  21. package/src/platform/index.ts +1 -0
  22. package/src/replay/RefName.test.ts +40 -0
  23. package/src/replay/RefName.ts +19 -0
  24. package/src/replay/Replay.test.ts +193 -2
  25. package/src/replay/Replay.ts +51 -6
  26. package/src/replay/Trailers.test.ts +26 -0
  27. package/src/replay/Trailers.ts +8 -0
  28. package/src/replay/index.ts +1 -0
  29. package/src/step/LandStep.ts +1 -0
  30. package/src/step/RepoSnapshot.ts +2 -0
  31. package/src/step/planStart.test.ts +5 -5
  32. package/src/step/planStep.test.ts +53 -0
  33. package/src/step/planStep.ts +7 -1
  34. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  35. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  36. package/src/testkit/EmittedScripts.test.ts +182 -0
  37. package/src/testkit/FakeGitOperations.ts +8 -2
  38. package/src/testkit/GitTiers.ts +103 -24
  39. package/src/testkit/InMemRepo.ts +57 -12
  40. package/src/testkit/TestRepo.ts +2 -2
  41. package/src/workflow/Workflow.ts +1 -0
  42. package/src/workflow/discovery.test.ts +1 -1
  43. package/src/workflows/shared/text.fixture.ts +1 -0
@@ -9,6 +9,9 @@ export const PROCESS_HISTORY_PREFIX = "refs/gtd/history/"
9
9
 
10
10
  export const processHistoryRef = (hash: string): string => `${PROCESS_HISTORY_PREFIX}${hash}`
11
11
 
12
+ /** Directory of a step's turns set aside by `keep: true`, each at `<this>/<kept hash>`; own namespace, so history list/show/fetch never see it. */
13
+ export const keptRef = (entry: string, step: string): string => `refs/gtd/kept/${entry}/${step}`
14
+
12
15
  export const readRetainedHistory = (
13
16
  git: GitOperations,
14
17
  ): Effect.Effect<Option.Option<string>, Error> => git.readRefOption(HISTORY_REF)
@@ -83,7 +83,7 @@ const makeInMemTier = (): WorkspaceTier => {
83
83
  writeWorking: (path, content) => repo.writeFile(path, content),
84
84
  commit: (path, content) => {
85
85
  repo.writeFile(path, content)
86
- repo.commitAllWithPrefix(`commit ${path}`)
86
+ repo.seedCommit(`commit ${path}`)
87
87
  },
88
88
  provide: (eff) =>
89
89
  Effect.runPromise(eff.pipe(Effect.provide(Layer.succeed(Workspace, workspaceOps)))),
@@ -21,6 +21,7 @@ export { CommandRunner } from "./CommandRunner.js"
21
21
  export { loopLogPath } from "./WorktreeState.js"
22
22
  export {
23
23
  HISTORY_REF,
24
+ keptRef,
24
25
  processHistoryRef,
25
26
  PROCESS_HISTORY_PREFIX,
26
27
  readRetainedHistory,
@@ -0,0 +1,40 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { describe, expect, it } from "vitest"
3
+ import { refNameFault } from "./RefName.js"
4
+
5
+ // The predicate's owner: it must agree with git, or an invalid keep name only fails at landing.
6
+ describe("refNameFault", () => {
7
+ it.each([
8
+ "a",
9
+ "a.b",
10
+ "build.naive",
11
+ "-a",
12
+ "ünï",
13
+ "a@b",
14
+ "a/b",
15
+ "a.",
16
+ "a//b",
17
+ "/a",
18
+ "a/",
19
+ "@",
20
+ "a@{b",
21
+ "a..b",
22
+ "a b",
23
+ "a~b",
24
+ "a^b",
25
+ "a:b",
26
+ "a?b",
27
+ "a*b",
28
+ "a[b",
29
+ "a\\b",
30
+ "a\tb",
31
+ ".a",
32
+ "a.lock",
33
+ "a/.b",
34
+ "a.lock/b",
35
+ "",
36
+ ])("agrees with git check-ref-format for %j", (name) => {
37
+ const git = spawnSync("git", ["check-ref-format", `refs/gtd/kept/e/${name}`])
38
+ expect(refNameFault(name) === undefined).toBe(git.status === 0)
39
+ })
40
+ })
@@ -0,0 +1,19 @@
1
+ // A kept ref is `refs/gtd/kept/<entry>/<step>`: the step's full name must survive `git check-ref-format`.
2
+
3
+ // eslint-disable-next-line no-control-regex -- the control range IS the point
4
+ const FORBIDDEN = /[\u0000- \u007f~^:?*[\\]/
5
+
6
+ /** Why `name` cannot be (part of) a git ref name, or `undefined` when it can. */
7
+ export const refNameFault = (name: string): string | undefined => {
8
+ if (name === "") return "it is empty"
9
+ if (FORBIDDEN.test(name)) return "it contains a space, control character or one of ~^:?*[\\"
10
+ if (name.includes("..")) return 'it contains ".."'
11
+ if (name.includes("@{")) return 'it contains "@{"'
12
+ if (name.endsWith(".")) return 'it ends with "."'
13
+ for (const part of name.split("/")) {
14
+ if (part === "") return 'it has an empty "/" component'
15
+ if (part.startsWith(".")) return 'a component starts with "."'
16
+ if (part.endsWith(".lock")) return 'a component ends with ".lock"'
17
+ }
18
+ return undefined
19
+ }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest"
1
+ import { beforeEach, describe, expect, it } from "vitest"
2
2
  import fc from "fast-check"
3
3
  import {
4
4
  FLOWS_PROTOCOL,
@@ -8,6 +8,7 @@ import {
8
8
  codeThreads,
9
9
  human,
10
10
  judge,
11
+ kept,
11
12
  read,
12
13
  refuse,
13
14
  restart,
@@ -32,6 +33,7 @@ interface Commit {
32
33
  readonly hash: string
33
34
  readonly message: string
34
35
  readonly tree: TreeView
36
+ readonly kept?: { readonly hash: string; readonly tree: TreeView }
35
37
  }
36
38
 
37
39
  const hashOf = (n: number): string => n.toString(16).padStart(40, "0")
@@ -64,12 +66,19 @@ class History {
64
66
  return this
65
67
  }
66
68
 
67
- raw(message: string, edit: Record<string, string | null> = {}): this {
69
+ raw(
70
+ message: string,
71
+ edit: Record<string, string | null> = {},
72
+ keptTip?: { hash: string; files: Record<string, string> },
73
+ ): this {
68
74
  this.apply(edit)
69
75
  this.commits.push({
70
76
  hash: hashOf(this.counter++),
71
77
  message,
72
78
  tree: treeFromRecord({ ...this.files }),
79
+ ...(keptTip !== undefined
80
+ ? { kept: { hash: keptTip.hash, tree: treeFromRecord(keptTip.files) } }
81
+ : {}),
73
82
  })
74
83
  return this
75
84
  }
@@ -600,6 +609,188 @@ describe("replay", () => {
600
609
  })
601
610
  })
602
611
 
612
+ describe("replay: keep", () => {
613
+ let seen: { read?: string | undefined; changes?: number }
614
+ beforeEach(() => {
615
+ seen = {}
616
+ })
617
+ const keepFlow =
618
+ (name = "naive"): Flow =>
619
+ async () => {
620
+ await human("idle")
621
+ await agent(name, "p", { keep: true })
622
+ seen.read = read("naive.txt")
623
+ seen.changes = changes().length
624
+ await human("after")
625
+ }
626
+
627
+ it("accepts keep as an agent option", async () => {
628
+ const h = new History().land("human", "idle", 1, "naive", {})
629
+ const outcome = await replayOf(keepFlow(), h)
630
+ expect(outcome.kind).toBe("rest")
631
+ expect(restName(outcome)).toBe("naive#1")
632
+ })
633
+
634
+ it("a pending turn completes the keep step but replay stays on the pre-turn tree", async () => {
635
+ const h = new History().land("human", "idle", 1, "naive", {})
636
+ const outcome = await replayOf(keepFlow(), h, { pending: { "naive.txt": "naive\n" } })
637
+ expect(restName(outcome)).toBe("after#1")
638
+ expect(outcome.kind === "rest" && outcome.landed?.name).toBe("naive")
639
+ expect(seen.read).toBeUndefined()
640
+ expect(seen.changes).toBe(0)
641
+ })
642
+
643
+ it("a marker commit answers its step and the next step is reached", async () => {
644
+ const marker = formatCommitMessage({
645
+ actor: "agent",
646
+ from: "naive",
647
+ to: "after",
648
+ step: { name: "naive", occurrence: 1 },
649
+ })
650
+ const h = new History()
651
+ .land("human", "idle", 1, "naive", {})
652
+ .raw(`${marker}\nGtd-Kept: ${hashOf(77)}`)
653
+ const outcome = await replayOf(keepFlow(), h)
654
+ expect(restName(outcome)).toBe("after#1")
655
+ expect(seen.read).toBeUndefined()
656
+ expect(seen.changes).toBe(0)
657
+ })
658
+
659
+ it("rejects keep together with allowEmpty", async () => {
660
+ const flow: Flow = async () => {
661
+ await human("idle")
662
+ await agent("naive", "p", { keep: true, allowEmpty: true })
663
+ }
664
+ const h = new History().land("human", "idle", 1, "naive", {})
665
+ const outcome = await replayOf(flow, h)
666
+ expect(outcome.kind === "failed" && outcome.message).toContain("allowEmpty")
667
+ })
668
+
669
+ it.each(["a b", "a..b", "a~b", "a.lock", "a:b", "a/b"])(
670
+ "fails naming the step when %j is not a valid ref component",
671
+ async (name) => {
672
+ const h = new History().land("human", "idle", 1, name, {})
673
+ const outcome = await replayOf(keepFlow(name), h)
674
+ expect(outcome.kind).toBe("failed")
675
+ expect(outcome.kind === "failed" && outcome.message).toContain(`step "${name}"`)
676
+ },
677
+ )
678
+
679
+ it("does not check the name of a step that does not keep", async () => {
680
+ const flow: Flow = async () => {
681
+ await human("idle")
682
+ await agent("a b", "p")
683
+ }
684
+ const h = new History().land("human", "idle", 1, "a b", {})
685
+ expect(restName(await replayOf(flow, h))).toBe("a b#1")
686
+ })
687
+ })
688
+
689
+ describe("replay: kept()", () => {
690
+ const markerFor = (name: string, to: string, occurrence = 1, tip = hashOf(77)): string =>
691
+ `${formatCommitMessage({
692
+ actor: "agent",
693
+ from: name,
694
+ to,
695
+ step: { name, occurrence },
696
+ })}\nGtd-Kept: ${tip}`
697
+
698
+ type Tip = ReturnType<typeof kept>
699
+ let seen: { before?: Tip; after?: Tip }
700
+ beforeEach(() => {
701
+ seen = {}
702
+ })
703
+ const flow =
704
+ (name = "naive"): Flow =>
705
+ async () => {
706
+ await human("idle")
707
+ seen.before = kept(name)
708
+ await agent("naive", "p", { keep: true })
709
+ seen.after = kept(name)
710
+ await human("after")
711
+ }
712
+
713
+ it("returns the newest consumed marker's hash and its diff against the marker's tree", async () => {
714
+ const h = new History()
715
+ .land("human", "idle", 1, "naive", { "base.txt": "b\n" })
716
+ .raw(
717
+ markerFor("naive", "after"),
718
+ {},
719
+ { hash: hashOf(77), files: { "base.txt": "b\n", "naive.ts": "export const n = 1\n" } },
720
+ )
721
+ const outcome = await replayOf(flow(), h)
722
+ expect(restName(outcome)).toBe("after#1")
723
+ expect(seen.after?.hash).toBe(hashOf(77))
724
+ expect(seen.after?.changes.map((c) => [c.path, c.status, c.after])).toEqual([
725
+ ["naive.ts", "added", "export const n = 1\n"],
726
+ ])
727
+ })
728
+
729
+ it("the newest of two passes wins", async () => {
730
+ const h = new History()
731
+ .land("human", "idle", 1, "naive", {})
732
+ .raw(markerFor("naive", "naive", 1, hashOf(70)), {}, { hash: hashOf(70), files: { a: "1" } })
733
+ .raw(markerFor("naive", "after", 2, hashOf(71)), {}, { hash: hashOf(71), files: { a: "2" } })
734
+ const twice: Flow = async () => {
735
+ await human("idle")
736
+ await agent("naive", "p", { keep: true })
737
+ await agent("naive", "p", { keep: true })
738
+ seen.after = kept("naive")
739
+ await human("after")
740
+ }
741
+ await replayOf(twice, h)
742
+ expect(seen.after?.hash).toBe(hashOf(71))
743
+ expect(seen.after?.changes.get("a")?.after).toBe("2")
744
+ })
745
+
746
+ it("is undefined for a step that never kept", async () => {
747
+ const h = new History().land("human", "idle", 1, "naive", {}).land("agent", "naive", 1, "after")
748
+ expect(restName(await replayOf(flow(), h))).toBe("after#1")
749
+ expect(seen.after).toBeUndefined()
750
+ })
751
+
752
+ it("is undefined when the marker's kept object is gone", async () => {
753
+ const h = new History().land("human", "idle", 1, "naive", {}).raw(markerFor("naive", "after"))
754
+ expect(restName(await replayOf(flow(), h))).toBe("after#1")
755
+ expect(seen.after).toBeUndefined()
756
+ })
757
+
758
+ it("resolves the name through scope() like agent()", async () => {
759
+ const scoped: Flow = async () => {
760
+ await human("idle")
761
+ await scope("build", async () => {
762
+ await agent("naive", "p", { keep: true })
763
+ seen.after = kept("naive")
764
+ })
765
+ seen.before = kept("naive")
766
+ await human("after")
767
+ }
768
+ const h = new History()
769
+ .land("human", "idle", 1, "build.naive", {})
770
+ .raw(markerFor("build.naive", "after"), {}, { hash: hashOf(77), files: { "x.ts": "x" } })
771
+ await replayOf(scoped, h)
772
+ expect(seen.after?.hash).toBe(hashOf(77))
773
+ expect(seen.before).toBeUndefined()
774
+ })
775
+
776
+ it('shows the pending tip with hash "" during the landing decision', async () => {
777
+ const h = new History().land("human", "idle", 1, "naive", {})
778
+ const outcome = await replayOf(flow(), h, { pending: { "naive.ts": "n\n" } })
779
+ expect(restName(outcome)).toBe("after#1")
780
+ expect(seen.after?.hash).toBe("")
781
+ expect(seen.after?.changes.map((c) => [c.path, c.status])).toEqual([["naive.ts", "added"]])
782
+ })
783
+
784
+ it("does not show a marker the flow has not consumed yet", async () => {
785
+ const h = new History()
786
+ .land("human", "idle", 1, "naive", {})
787
+ .raw(markerFor("naive", "after"), {}, { hash: hashOf(77), files: { "x.ts": "x" } })
788
+ await replayOf(flow(), h)
789
+ expect(seen.before).toBeUndefined()
790
+ expect(seen.after?.hash).toBe(hashOf(77))
791
+ })
792
+ })
793
+
603
794
  describe("replay: skills resolution", () => {
604
795
  interface Tiers {
605
796
  skills?: Record<string, readonly string[]>
@@ -21,6 +21,7 @@ import {
21
21
  } from "../steering/index.js"
22
22
  import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
23
23
  import { globMatches } from "../wire/index.js"
24
+ import { refNameFault } from "./RefName.js"
24
25
  import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
25
26
  import {
26
27
  formatStepId,
@@ -34,6 +35,8 @@ export interface EpisodeCommit {
34
35
  readonly hash: string
35
36
  readonly message: string
36
37
  readonly tree: TreeView
38
+ /** Present when the commit carries Gtd-Kept and that object still exists. */
39
+ readonly kept?: { readonly hash: string; readonly tree: TreeView }
37
40
  }
38
41
 
39
42
  /**
@@ -232,7 +235,7 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
232
235
  // The option keys each step accepts. A gtd.config.ts is evaluated without a
233
236
  // type check, so a misspelt key would otherwise be silently ignored.
234
237
  const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
235
- agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty"]),
238
+ agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
236
239
  human: new Set([...STEERING_OPTIONS, "message", "acceptClean"]),
237
240
  run: new Set(STEERING_OPTIONS),
238
241
  judge: new Set([...STEERING_OPTIONS, "message"]),
@@ -275,6 +278,11 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
275
278
  const scopeCallIds: number[] = []
276
279
  let scopeCallCount = 0
277
280
  const trace: ReachedStep[] = []
281
+ // Newest consumed keep per scoped step name; `before` is the tree the kept turn diverged from.
282
+ const keptTips = new Map<
283
+ string,
284
+ { readonly hash: string; readonly before: TreeView; readonly after: TreeView }
285
+ >()
278
286
  let landed: ReachedStep | undefined
279
287
  let outcome: ReplayOutcome | undefined
280
288
  let signal: (() => void) | undefined
@@ -494,11 +502,32 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
494
502
  }
495
503
  cursor++
496
504
  advance(commit)
505
+ if (commit.parsed.kept !== undefined) {
506
+ if (commit.kept === undefined) keptTips.delete(step.name)
507
+ else
508
+ keptTips.set(step.name, {
509
+ hash: commit.kept.hash,
510
+ before: commit.tree,
511
+ after: commit.kept.tree,
512
+ })
513
+ }
497
514
  const to = commit.parsed.parsed?.to
498
515
  if (to !== undefined) expectedNext = { name: to, hash: commit.hash }
499
516
  return replyFor(step.request, commit.parsed.judge)
500
517
  }
501
518
 
519
+ const landPending = (step: ReachedStep, pending: NonNullable<typeof input.pending>): unknown => {
520
+ pendingUsed = true
521
+ landed = step
522
+ // A kept turn is set aside: replay stays on the pre-turn tree, as it will once the marker lands.
523
+ const keeping = step.request.kind === "agent" && step.request.options.keep === true
524
+ if (keeping) {
525
+ keptTips.set(step.name, { hash: "", before: position.tree, after: pending.tree })
526
+ }
527
+ advance({ hash: "", tree: keeping ? position.tree : pending.tree })
528
+ return replyFor(step.request, pending.verdicts ?? [])
529
+ }
530
+
502
531
  const answer = (step: ReachedStep): unknown => {
503
532
  while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) cursor++
504
533
  const commit = commits[cursor]
@@ -506,10 +535,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
506
535
  if (input.pending === undefined || pendingUsed) {
507
536
  return finish({ kind: "rest", rest: step, trace, landed })
508
537
  }
509
- pendingUsed = true
510
- landed = step
511
- advance({ hash: "", tree: input.pending.tree })
512
- return replyFor(step.request, input.pending.verdicts ?? [])
538
+ return landPending(step, input.pending)
513
539
  }
514
540
 
515
541
  // gtd.config.ts is loaded without a type check.
@@ -517,7 +543,20 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
517
543
  if (request.kind === "run" && typeof request.body !== "string") {
518
544
  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)`
519
545
  }
520
- return request.kind === "agent" ? scopeAccessFault() : undefined
546
+ if (request.kind !== "agent") return undefined
547
+ const name = scoped(request.name)
548
+ if (request.options.keep === true) {
549
+ // A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
550
+ if (request.options.allowEmpty === true) {
551
+ return `gtd: step "${name}": keep: true cannot be combined with allowEmpty — a turn that changes nothing has nothing to keep`
552
+ }
553
+ // "/" would let one step's ref directory collide with another step's ref (git refuses `a` and `a/b` together).
554
+ const refFault = name.includes("/") ? 'it contains "/"' : refNameFault(name)
555
+ if (refFault !== undefined) {
556
+ return `gtd: step "${name}": keep: true files the turn under a git ref named after the step, but ${refFault}`
557
+ }
558
+ }
559
+ return scopeAccessFault()
521
560
  }
522
561
 
523
562
  const handleStep = async (request: StepRequest): Promise<unknown> => {
@@ -611,6 +650,12 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
611
650
  .map((f) => f.message),
612
651
  }))
613
652
  }),
653
+ kept: (name) => {
654
+ const tip = keptTips.get(scoped(name))
655
+ return tip === undefined
656
+ ? undefined
657
+ : { hash: tip.hash, changes: changesBetween(tip.before, tip.after) }
658
+ },
614
659
  vars: input.vars,
615
660
  env: input.env,
616
661
  start: () => input.start,
@@ -147,3 +147,29 @@ describe("the commit-message codec", () => {
147
147
  expect(parseSubject("gtd(agent): fix")).toEqual({ actor: "agent", to: "fix" })
148
148
  })
149
149
  })
150
+
151
+ describe("Gtd-Kept", () => {
152
+ const hash = "0123456789abcdef0123456789abcdef01234567"
153
+
154
+ it("parses a 40-hex hash", () => {
155
+ expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Step: a#1\nGtd-Kept: ${hash}`).kept).toBe(
156
+ hash,
157
+ )
158
+ })
159
+
160
+ it("parses a 64-hex (sha256) hash", () => {
161
+ const sha256 = hash.repeat(2).slice(0, 64)
162
+ expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${sha256}`).kept).toBe(sha256)
163
+ })
164
+
165
+ it("skips a non-hash value", () => {
166
+ expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Kept: not-a-hash").kept).toBeUndefined()
167
+ expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${hash}0`).kept).toBeUndefined()
168
+ })
169
+
170
+ it("reads undefined when absent, and leaves Gtd-Format at 1", () => {
171
+ const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
172
+ expect(parsed.kept).toBeUndefined()
173
+ expect(parsed.format).toBe(1)
174
+ })
175
+ })
@@ -75,6 +75,8 @@ export interface CommitMessage {
75
75
  /** Written only on a process's finishing landing. */
76
76
  readonly branch: string | undefined
77
77
  readonly version: string | undefined
78
+ /** The off-branch tip a keep step set aside; a non-hash value is skipped. */
79
+ readonly kept: string | undefined
78
80
  readonly format: number
79
81
  }
80
82
 
@@ -114,6 +116,7 @@ const decodePath = (raw: string): string | undefined => {
114
116
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
115
117
  }
116
118
  const FORMAT_RE = /^[1-9][0-9]*$/
119
+ const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
117
120
 
118
121
  interface Trailers {
119
122
  step: StepId | undefined
@@ -121,6 +124,7 @@ interface Trailers {
121
124
  reviewBase: string | undefined
122
125
  branch: string | undefined
123
126
  version: string | undefined
127
+ kept: string | undefined
124
128
  readonly judge: JudgeVerdict[]
125
129
  readonly vars: Record<string, string>
126
130
  readonly config: Record<string, string>
@@ -157,6 +161,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
157
161
  "Gtd-Version": (value, into) => {
158
162
  if (value !== "") into.version ??= value
159
163
  },
164
+ "Gtd-Kept": (value, into) => {
165
+ if (HASH_RE.test(value)) into.kept ??= value
166
+ },
160
167
  "Gtd-Format": (value, into) => {
161
168
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
162
169
  },
@@ -175,6 +182,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
175
182
  reviewBase: undefined,
176
183
  branch: undefined,
177
184
  version: undefined,
185
+ kept: undefined,
178
186
  judge: [],
179
187
  vars: {},
180
188
  config: {},
@@ -9,3 +9,4 @@ export {
9
9
  } from "./Trailers.js"
10
10
  export { accessShapeFault, foldAccess } from "./Access.js"
11
11
  export { formatFault } from "./Format.js"
12
+ export { refNameFault } from "./RefName.js"
@@ -9,6 +9,7 @@ import type { StateName } from "../workflow/index.js"
9
9
  export type GitWrite =
10
10
  | { readonly kind: "commitAll"; readonly message: string }
11
11
  | { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
12
+ | { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
12
13
 
13
14
  /** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
14
15
  export type Outcome =
@@ -17,6 +17,8 @@ export type Landing =
17
17
  readonly spec: CommitSpec
18
18
  /** The full history ref a finishing landing points at its own commit. */
19
19
  readonly keepHistory?: string
20
+ /** The kept ref this landing's turn goes to; the branch gets a marker commit instead. */
21
+ readonly keepTurn?: string
20
22
  }
21
23
 
22
24
  /**
@@ -27,7 +27,7 @@ export const base = (workflow, vars) => (workflow === "reviewcheck" ? (vars.base
27
27
  const repoAt = (): InMemRepo => {
28
28
  const repo = new InMemRepo()
29
29
  repo.writeFile("gtd.config.ts", WORKFLOW)
30
- repo.commitAllWithPrefix("chore: add custom workflow")
30
+ repo.seedCommit("chore: add custom workflow")
31
31
  return repo
32
32
  }
33
33
 
@@ -104,7 +104,7 @@ describe("planStart", () => {
104
104
  const fork = repo.resolveRef("HEAD")!
105
105
  repo.updateRef("main", fork)
106
106
  repo.writeFile("a.txt", "a")
107
- repo.commitAllWithPrefix("feat: ahead of main")
107
+ repo.seedCommit("feat: ahead of main")
108
108
  const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
109
109
  if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
110
110
  const write = plan.steps.find((s) => s.kind === "gitWrite")
@@ -117,11 +117,11 @@ describe("planStart", () => {
117
117
  const repo = repoAt()
118
118
  const fork = repo.resolveRef("HEAD")!
119
119
  repo.writeFile("other.txt", "o")
120
- repo.commitAllWithPrefix("feat: other branch")
120
+ repo.seedCommit("feat: other branch")
121
121
  repo.updateRef("other", repo.resolveRef("HEAD")!)
122
122
  repo.hardResetTo(fork)
123
123
  repo.writeFile("mine.txt", "m")
124
- repo.commitAllWithPrefix("feat: my branch")
124
+ repo.seedCommit("feat: my branch")
125
125
  const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
126
126
  if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
127
127
  const write = plan.steps.find((s) => s.kind === "gitWrite")
@@ -143,7 +143,7 @@ describe("planStart", () => {
143
143
  repo.updateRef("old-root", repo.resolveRef("HEAD")!)
144
144
  repo.mixedResetTo("4b825dc642cb6eb9a060e54bf8d69288fbee4904")
145
145
  repo.writeFile("gtd.config.ts", WORKFLOW)
146
- repo.commitAllWithPrefix("chore: a second root")
146
+ repo.seedCommit("chore: a second root")
147
147
  const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "old-root" } })
148
148
  expect(plan).toEqual({
149
149
  kind: "refusal",
@@ -130,3 +130,56 @@ describe("planStep — keepHistory", () => {
130
130
  ).toBe(false)
131
131
  })
132
132
  })
133
+
134
+ describe("planStep — keepTurn", () => {
135
+ const kept = "refs/gtd/kept/abc/building"
136
+
137
+ it("emits keepTurn in place of commitAll, carrying the step's own commit message", () => {
138
+ const s = snapshot({
139
+ state: "building",
140
+ stepDef: { actor: "agent", kind: "prompt" },
141
+ actor: "agent",
142
+ changes: [{ status: "A", path: "naive.txt" }],
143
+ landing: { ...commitTo("building", "checking", "agent"), keepTurn: kept },
144
+ })
145
+ const outcome = planStep(s)
146
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
147
+ expect(outcome.steps.map((step) => step.kind)).toEqual(["gitWrite", "outcome"])
148
+ expect(outcome.steps[0]).toEqual({
149
+ kind: "gitWrite",
150
+ write: {
151
+ kind: "keepTurn",
152
+ ref: kept,
153
+ message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format: 1",
154
+ },
155
+ })
156
+ })
157
+
158
+ it("a finishing keep landing points the history ref after the marker", () => {
159
+ const s = snapshot({
160
+ state: "building",
161
+ stepDef: { actor: "agent", kind: "prompt" },
162
+ actor: "agent",
163
+ landing: {
164
+ ...commitTo("building", "idle", "agent"),
165
+ keepTurn: kept,
166
+ keepHistory: "refs/gtd/history/abc",
167
+ },
168
+ })
169
+ const outcome = planStep(s)
170
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
171
+ expect(outcome.steps.map((step) => step.kind)).toEqual(["gitWrite", "gitWrite", "outcome"])
172
+ expect(outcome.steps[0]).toMatchObject({ write: { kind: "keepTurn" } })
173
+ expect(outcome.steps[1]).toEqual({
174
+ kind: "gitWrite",
175
+ write: { kind: "updateRef", ref: "refs/gtd/history/abc", target: "HEAD" },
176
+ })
177
+ })
178
+
179
+ it("without keepTurn the landing stays a commitAll", () => {
180
+ const s = snapshot({ state: "building", stepDef: {}, landing: commitTo("building", "done") })
181
+ const outcome = planStep(s)
182
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
183
+ expect(outcome.steps[0]).toMatchObject({ write: { kind: "commitAll" } })
184
+ })
185
+ })
@@ -82,7 +82,13 @@ export const planStep = (
82
82
  subject,
83
83
  steps: [
84
84
  ...uncheckStep(snapshot),
85
- { kind: "gitWrite", write: { kind: "commitAll", message } },
85
+ {
86
+ kind: "gitWrite",
87
+ write:
88
+ landing.keepTurn !== undefined
89
+ ? { kind: "keepTurn", ref: landing.keepTurn, message }
90
+ : { kind: "commitAll", message },
91
+ },
86
92
  ...(landing.keepHistory !== undefined
87
93
  ? [
88
94
  {