@pmelab/gtd 25.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 (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -0
  3. package/dist/flows.mjs +15 -1
  4. package/dist/gtd.bundle.mjs +161 -20
  5. package/dist/testkit.mjs +311 -57
  6. package/package.json +2 -1
  7. package/src/cli/Cli.test.ts +28 -3
  8. package/src/cli/Cli.ts +12 -1
  9. package/src/cli/History.test.ts +2 -2
  10. package/src/cli/Program.test.ts +10 -12
  11. package/src/edge/Edge.test.ts +113 -16
  12. package/src/edge/Edge.ts +22 -6
  13. package/src/emit/GitScript.test.ts +65 -2
  14. package/src/emit/GitScript.ts +34 -1
  15. package/src/emit/index.ts +1 -0
  16. package/src/flags/index.test.ts +67 -0
  17. package/src/flags/index.ts +38 -0
  18. package/src/flows/runtime.test.ts +33 -0
  19. package/src/flows/runtime.ts +31 -0
  20. package/src/platform/RetainedHistory.test.ts +9 -0
  21. package/src/platform/RetainedHistory.ts +3 -0
  22. package/src/platform/Workspace.test.ts +1 -1
  23. package/src/platform/index.ts +1 -0
  24. package/src/replay/RefName.test.ts +40 -0
  25. package/src/replay/RefName.ts +19 -0
  26. package/src/replay/Replay.test.ts +193 -2
  27. package/src/replay/Replay.ts +51 -6
  28. package/src/replay/Trailers.test.ts +26 -0
  29. package/src/replay/Trailers.ts +8 -0
  30. package/src/replay/index.ts +1 -0
  31. package/src/step/LandStep.ts +1 -0
  32. package/src/step/RepoSnapshot.ts +2 -0
  33. package/src/step/planStart.test.ts +5 -5
  34. package/src/step/planStep.test.ts +53 -0
  35. package/src/step/planStep.ts +7 -1
  36. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  37. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  38. package/src/testkit/EmittedScripts.test.ts +182 -0
  39. package/src/testkit/FakeGitOperations.ts +8 -2
  40. package/src/testkit/GitTiers.ts +103 -24
  41. package/src/testkit/InMemRepo.ts +57 -12
  42. package/src/testkit/TestRepo.ts +2 -2
  43. package/src/workflow/Workflow.ts +1 -0
  44. package/src/workflow/discovery.test.ts +1 -1
  45. package/src/workflow/vars.test.ts +16 -0
  46. package/src/workflow/vars.ts +4 -1
  47. package/src/workflows/shared/text.fixture.ts +1 -0
@@ -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
  {
@@ -5,6 +5,7 @@ import {
5
5
  deleteRef,
6
6
  discardPending,
7
7
  hardResetTo,
8
+ keepTurn,
8
9
  mixedResetTo,
9
10
  softResetTo,
10
11
  updateRef,
@@ -40,7 +41,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
40
41
  twin.writeFile("a.txt", "1")
41
42
 
42
43
  const result = applyEmittedScript(repo, NO_COMMANDS, commitAll("gtd(agent): building"))
43
- twin.commitAllWithPrefix("gtd(agent): building")
44
+ twin.seedCommit("gtd(agent): building")
44
45
 
45
46
  expect(result).toMatchObject({ ok: true })
46
47
  expect(snapshot(repo)).toEqual(snapshot(twin))
@@ -84,15 +85,15 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
84
85
  it("softResetTo: moves HEAD only, leaving index and worktree untouched", () => {
85
86
  const repo = new InMemRepo()
86
87
  repo.writeFile("a.txt", "1")
87
- repo.commitAllWithPrefix("chore: first")
88
+ repo.seedCommit("chore: first")
88
89
  const first = repo.resolveRef("HEAD")!
89
90
  repo.writeFile("b.txt", "2")
90
- repo.commitAllWithPrefix("chore: second")
91
+ repo.seedCommit("chore: second")
91
92
  const twin = new InMemRepo()
92
93
  twin.writeFile("a.txt", "1")
93
- twin.commitAllWithPrefix("chore: first")
94
+ twin.seedCommit("chore: first")
94
95
  twin.writeFile("b.txt", "2")
95
- twin.commitAllWithPrefix("chore: second")
96
+ twin.seedCommit("chore: second")
96
97
 
97
98
  const result = applyEmittedScript(repo, NO_COMMANDS, softResetTo(first))
98
99
  twin.softResetTo(first)
@@ -105,15 +106,15 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
105
106
  it("mixedResetTo: moves HEAD and index, leaving the worktree untouched", () => {
106
107
  const repo = new InMemRepo()
107
108
  repo.writeFile("a.txt", "1")
108
- repo.commitAllWithPrefix("chore: first")
109
+ repo.seedCommit("chore: first")
109
110
  const first = repo.resolveRef("HEAD")!
110
111
  repo.writeFile("b.txt", "2")
111
- repo.commitAllWithPrefix("chore: second")
112
+ repo.seedCommit("chore: second")
112
113
  const twin = new InMemRepo()
113
114
  twin.writeFile("a.txt", "1")
114
- twin.commitAllWithPrefix("chore: first")
115
+ twin.seedCommit("chore: first")
115
116
  twin.writeFile("b.txt", "2")
116
- twin.commitAllWithPrefix("chore: second")
117
+ twin.seedCommit("chore: second")
117
118
 
118
119
  const result = applyEmittedScript(repo, NO_COMMANDS, mixedResetTo(first))
119
120
  twin.mixedResetTo(first)
@@ -125,15 +126,15 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
125
126
  it("hardResetTo: moves HEAD, index, AND worktree", () => {
126
127
  const repo = new InMemRepo()
127
128
  repo.writeFile("a.txt", "1")
128
- repo.commitAllWithPrefix("chore: first")
129
+ repo.seedCommit("chore: first")
129
130
  const first = repo.resolveRef("HEAD")!
130
131
  repo.writeFile("b.txt", "2")
131
- repo.commitAllWithPrefix("chore: second")
132
+ repo.seedCommit("chore: second")
132
133
  const twin = new InMemRepo()
133
134
  twin.writeFile("a.txt", "1")
134
- twin.commitAllWithPrefix("chore: first")
135
+ twin.seedCommit("chore: first")
135
136
  twin.writeFile("b.txt", "2")
136
- twin.commitAllWithPrefix("chore: second")
137
+ twin.seedCommit("chore: second")
137
138
 
138
139
  const result = applyEmittedScript(repo, NO_COMMANDS, hardResetTo(first))
139
140
  twin.hardResetTo(first)
@@ -146,12 +147,12 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
146
147
  it("discardPending: drops every pending change, tracked or untracked", () => {
147
148
  const repo = new InMemRepo()
148
149
  repo.writeFile("a.txt", "1")
149
- repo.commitAllWithPrefix("chore: first")
150
+ repo.seedCommit("chore: first")
150
151
  repo.writeFile("a.txt", "changed")
151
152
  repo.writeFile("untracked.txt", "new")
152
153
  const twin = new InMemRepo()
153
154
  twin.writeFile("a.txt", "1")
154
- twin.commitAllWithPrefix("chore: first")
155
+ twin.seedCommit("chore: first")
155
156
  twin.writeFile("a.txt", "changed")
156
157
  twin.writeFile("untracked.txt", "new")
157
158
 
@@ -167,11 +168,11 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
167
168
  it("updateRef: points a repo-local ref at a commit hash", () => {
168
169
  const repo = new InMemRepo()
169
170
  repo.writeFile("a.txt", "1")
170
- repo.commitAllWithPrefix("chore: first")
171
+ repo.seedCommit("chore: first")
171
172
  const hash = repo.resolveRef("HEAD")!
172
173
  const twin = new InMemRepo()
173
174
  twin.writeFile("a.txt", "1")
174
- twin.commitAllWithPrefix("chore: first")
175
+ twin.seedCommit("chore: first")
175
176
 
176
177
  const result = applyEmittedScript(repo, NO_COMMANDS, updateRef("refs/gtd/base", hash))
177
178
  twin.updateRef("refs/gtd/base", hash)
@@ -184,11 +185,11 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
184
185
  it("deleteRef: removes a repo-local ref (idempotently)", () => {
185
186
  const repo = new InMemRepo()
186
187
  repo.writeFile("a.txt", "1")
187
- repo.commitAllWithPrefix("chore: first")
188
+ repo.seedCommit("chore: first")
188
189
  repo.updateRef("refs/gtd/base", repo.resolveRef("HEAD")!)
189
190
  const twin = new InMemRepo()
190
191
  twin.writeFile("a.txt", "1")
191
- twin.commitAllWithPrefix("chore: first")
192
+ twin.seedCommit("chore: first")
192
193
  twin.updateRef("refs/gtd/base", twin.resolveRef("HEAD")!)
193
194
 
194
195
  const result = applyEmittedScript(repo, NO_COMMANDS, deleteRef("refs/gtd/base"))
@@ -405,7 +406,7 @@ describe("applyEmittedScript — a tripped precondition stops the script", () =>
405
406
  it("fails at the precondition and never applies a subsequent recognizable block", () => {
406
407
  const repo = new InMemRepo()
407
408
  repo.writeFile("a.txt", "1")
408
- repo.commitAllWithPrefix("chore: first")
409
+ repo.seedCommit("chore: first")
409
410
  const actualHead = repo.resolveRef("HEAD")!
410
411
  const wrongHash = "0".repeat(40)
411
412
  expect(wrongHash).not.toBe(actualHead)
@@ -426,7 +427,7 @@ describe("applyEmittedScript — a tripped precondition stops the script", () =>
426
427
  it("a satisfied precondition is a no-op and lets the rest of the script run", () => {
427
428
  const repo = new InMemRepo()
428
429
  repo.writeFile("a.txt", "1")
429
- repo.commitAllWithPrefix("chore: first")
430
+ repo.seedCommit("chore: first")
430
431
  const actualHead = repo.resolveRef("HEAD")!
431
432
 
432
433
  const script = [preconditionHeadEquals(actualHead), commitAll("gtd(agent): lands")].join("\n\n")
@@ -442,7 +443,7 @@ describe("applyEmittedScript — a realistic assembled multi-block script", () =
442
443
  it("parses set -eu and a satisfied precondition alongside real builder blocks", () => {
443
444
  const repo = new InMemRepo()
444
445
  repo.writeFile("a.txt", "1")
445
- repo.commitAllWithPrefix("chore: first")
446
+ repo.seedCommit("chore: first")
446
447
  const first = repo.resolveRef("HEAD")!
447
448
 
448
449
  const script = [
@@ -464,11 +465,11 @@ describe("applyEmittedScript — a realistic src/emit/Emit.ts-assembled script",
464
465
  it("an assembled commitAll applies exactly like commitAllWithPrefix", () => {
465
466
  const repo = new InMemRepo()
466
467
  repo.writeFile("base.txt", "0")
467
- repo.commitAllWithPrefix("chore: first")
468
+ repo.seedCommit("chore: first")
468
469
  repo.writeFile("a.txt", "1")
469
470
  const twin = new InMemRepo()
470
471
  twin.writeFile("base.txt", "0")
471
- twin.commitAllWithPrefix("chore: first")
472
+ twin.seedCommit("chore: first")
472
473
  twin.writeFile("a.txt", "1")
473
474
 
474
475
  const steps: readonly EmitStep[] = [
@@ -476,7 +477,7 @@ describe("applyEmittedScript — a realistic src/emit/Emit.ts-assembled script",
476
477
  ]
477
478
  const { required } = emitScripts(steps)
478
479
  const result = applyEmittedScript(repo, NO_COMMANDS, required)
479
- twin.commitAllWithPrefix("gtd(agent): building")
480
+ twin.seedCommit("gtd(agent): building")
480
481
 
481
482
  expect(result).toMatchObject({ ok: true })
482
483
  expect(snapshot(repo)).toEqual(snapshot(twin))
@@ -485,7 +486,7 @@ describe("applyEmittedScript — a realistic src/emit/Emit.ts-assembled script",
485
486
  it("a squash's four gitWrite steps (retain, soft-reset, commit-as-is, discard) apply in order", () => {
486
487
  const repo = new InMemRepo()
487
488
  repo.writeFile("a.txt", "1")
488
- repo.commitAllWithPrefix("gtd(agent): building")
489
+ repo.seedCommit("gtd(agent): building")
489
490
  const tip = repo.resolveRef("HEAD")!
490
491
  // Untracked, never staged — the index still matches the tip's own tree
491
492
  // exactly, mirroring the real flow where nothing `git add`s between the
@@ -537,11 +538,11 @@ describe("applyEmittedScript — outcome blocks are inert (no git effect to miss
537
538
  it("a realistic assembled script — commitAll's gitWrite plus a trailing outcome step — applies exactly like commitAllWithPrefix, the outcome step contributing nothing", () => {
538
539
  const repo = new InMemRepo()
539
540
  repo.writeFile("base.txt", "0")
540
- repo.commitAllWithPrefix("chore: first")
541
+ repo.seedCommit("chore: first")
541
542
  repo.writeFile("a.txt", "1")
542
543
  const twin = new InMemRepo()
543
544
  twin.writeFile("base.txt", "0")
544
- twin.commitAllWithPrefix("chore: first")
545
+ twin.seedCommit("chore: first")
545
546
  twin.writeFile("a.txt", "1")
546
547
 
547
548
  const steps: readonly EmitStep[] = [
@@ -550,7 +551,7 @@ describe("applyEmittedScript — outcome blocks are inert (no git effect to miss
550
551
  ]
551
552
  const { required } = emitScripts(steps)
552
553
  const result = applyEmittedScript(repo, NO_COMMANDS, required)
553
- twin.commitAllWithPrefix("gtd(agent): building")
554
+ twin.seedCommit("gtd(agent): building")
554
555
 
555
556
  expect(result).toMatchObject({ ok: true })
556
557
  expect(snapshot(repo)).toEqual(snapshot(twin))
@@ -636,3 +637,30 @@ describe("applyEmittedScript — Emit.ts's combinedScript (the whole stdout of a
636
637
  expect(result.error).toContain("totally-unrecognized-required-command")
637
638
  })
638
639
  })
640
+
641
+ describe("applyEmittedScript — keepTurn", () => {
642
+ const MESSAGE = "gtd(agent): a → b\n\nGtd-Step: a#1\nGtd-Format: 1"
643
+ const REF = "refs/gtd/kept/entry/a"
644
+
645
+ const seeded = (): InMemRepo => {
646
+ const repo = new InMemRepo()
647
+ repo.writeFile("base.txt", "0")
648
+ repo.seedCommit("chore: first")
649
+ repo.writeFile("turn.txt", "1")
650
+ return repo
651
+ }
652
+
653
+ it("fails loudly on a near-miss and applies nothing", () => {
654
+ const repo = seeded()
655
+ const before = snapshot(repo)
656
+ const nearMiss = keepTurn(REF, MESSAGE).replace(
657
+ "git reset --hard HEAD",
658
+ "git reset --soft HEAD",
659
+ )
660
+ const result = applyEmittedScript(repo, NO_COMMANDS, nearMiss)
661
+ expect(result.ok).toBe(false)
662
+ expect(result.error).toContain("unrecognized script block")
663
+ expect(snapshot(repo)).toEqual(before)
664
+ expect(repo.resolveRef(REF)).toBeNull()
665
+ })
666
+ })
@@ -9,6 +9,7 @@ import {
9
9
  deleteRef,
10
10
  discardPending,
11
11
  hardResetTo,
12
+ keepTurn,
12
13
  mixedResetTo,
13
14
  shellQuote,
14
15
  softResetTo,
@@ -30,6 +31,7 @@ import {
30
31
  steeringFormatFor,
31
32
  type SteeringFormat,
32
33
  } from "../steering/index.js"
34
+ import { refNameFault } from "../replay/index.js"
33
35
  import { renderFormat } from "../wire/index.js"
34
36
  import { applyFlowScript, rewritten } from "./FlowScripts.js"
35
37
  import type { InMemRepo } from "./InMemRepo.js"
@@ -140,6 +142,20 @@ const conditionStatement = (block: string): string => {
140
142
  return end === -1 ? block.slice(start) : block.slice(start, end)
141
143
  }
142
144
 
145
+ /** `keepTurn`: an invalid ref name fails before anything moves, as `git update-ref` does. */
146
+ const recognizeKeepTurn = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
147
+ const [message, refDir] = extractQuotedTokens(block)
148
+ const ref = refDir?.slice(0, -1)
149
+ if (message === undefined || ref === undefined || keepTurn(ref, message) !== block) {
150
+ return undefined
151
+ }
152
+ if (refNameFault(ref) !== undefined) {
153
+ return { kind: "failed", error: `fatal: invalid ref format: ${ref}`, status: 128 }
154
+ }
155
+ const failure = repo.keepTurn(ref, message)
156
+ return failure === undefined ? { kind: "applied" } : { kind: "failed", error: failure, status: 1 }
157
+ }
158
+
143
159
  // fallow-ignore-next-line complexity
144
160
  const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
145
161
  if (block === discardPending()) {
@@ -156,11 +172,15 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
156
172
  // quoted argument.
157
173
  const conditionLine = conditionStatement(block)
158
174
 
175
+ if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block)
176
+
159
177
  if (block.startsWith("git add -A || exit 1\n")) {
160
178
  const [message] = extractQuotedTokens(conditionLine)
161
179
  if (message !== undefined && commitAll(message) === block) {
162
- repo.commitAllWithPrefix(message)
163
- return { kind: "applied" }
180
+ const failure = repo.commitAllWithPrefix(message)
181
+ return failure === undefined
182
+ ? { kind: "applied" }
183
+ : { kind: "failed", error: failure, status: 1 }
164
184
  }
165
185
  return undefined
166
186
  }
@@ -168,8 +188,10 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
168
188
  if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
169
189
  const [message] = extractQuotedTokens(conditionLine)
170
190
  if (message !== undefined && commitAsIs(message) === block) {
171
- repo.commitAsIs(message)
172
- return { kind: "applied" }
191
+ const failure = repo.commitAsIs(message)
192
+ return failure === undefined
193
+ ? { kind: "applied" }
194
+ : { kind: "failed", error: failure, status: 1 }
173
195
  }
174
196
  return undefined
175
197
  }