@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
@@ -23,6 +23,7 @@ import {
23
23
  ToolCommands,
24
24
  } from "./index.js"
25
25
  import { EXIT_CODES, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
26
+ import { Host } from "../platform/index.js"
26
27
  import { type CommandRequirements } from "./Cli.js"
27
28
  import { InMemRepo, testLayers } from "../testkit/index.js"
28
29
  import { CONFIG_SCAFFOLD } from "../install/index.js"
@@ -1164,6 +1165,30 @@ describe("runCli — layers() is never invoked for output/usage plans", () => {
1164
1165
  })
1165
1166
  })
1166
1167
 
1168
+ describe("runCli — a garbled GTD_FLAG_* value", () => {
1169
+ it("exits 1 naming the variable", async () => {
1170
+ const { io, captured } = capturingIo(
1171
+ () =>
1172
+ Layer.succeed(Host, {
1173
+ root: "/",
1174
+ home: "/",
1175
+ scratchDir: "/",
1176
+ realPath: (p: string) => Effect.succeed(p),
1177
+ env: { GTD_FLAG_BETA: "ture" },
1178
+ }) as unknown as Layer.Layer<CommandRequirements>,
1179
+ )
1180
+ await Effect.runPromise(
1181
+ runCli(["node", "gtd.js", "next"], {
1182
+ ...io,
1183
+ flags: [{ name: "beta", owner: "pmelab", expiry: "2099-01-01", kind: "ops" }],
1184
+ }),
1185
+ )
1186
+ expect(captured().exitCode).toBe(1)
1187
+ expect(captured().stdout).toBe("")
1188
+ expect(captured().stderr).toContain("GTD_FLAG_BETA")
1189
+ })
1190
+ })
1191
+
1167
1192
  describe("runCli — exit codes", () => {
1168
1193
  it("output plan: exit is never called (success) — --help and --version still exit 0", async () => {
1169
1194
  const { io, captured } = capturingIo(throwingLayers)
@@ -1206,7 +1231,7 @@ describe("runCli — exit codes", () => {
1206
1231
  const repo = new InMemRepo()
1207
1232
  repo.writeFile("NOTE.md", "a note\n")
1208
1233
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1209
- repo.commitAllWithPrefix("gtd: init")
1234
+ repo.seedCommit("gtd: init")
1210
1235
  const { io, captured } = capturingIo(() => testLayers(repo))
1211
1236
  await Effect.runPromise(runCli(["node", "gtd.js", "next", "--json=does.not.exist"], io))
1212
1237
  const { stdout, stderr, exitCode } = captured()
@@ -1291,7 +1316,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
1291
1316
  // produce zero calls, not merely an empty joined string.
1292
1317
  const repo = new InMemRepo()
1293
1318
  repo.writeFile("README.md", "x\n")
1294
- repo.commitAllWithPrefix("chore: init gtd workflow")
1319
+ repo.seedCommit("chore: init gtd workflow")
1295
1320
  repo.writeFile(".gtd/TODO.md", "## Open Questions\n\n###\n\nno question text.\n")
1296
1321
  const stdoutCalls: string[] = []
1297
1322
  const io: CliIo = {
@@ -1371,7 +1396,7 @@ describe("runCli — flush normalizes the artifact's trailing newline", () => {
1371
1396
  const repo = new InMemRepo()
1372
1397
  repo.writeFile(".gitignore", "node_modules\n")
1373
1398
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1374
- repo.commitAllWithPrefix("chore: initial commit")
1399
+ repo.seedCommit("chore: initial commit")
1375
1400
  const { io, captured } = capturingIo(() => testLayers(repo))
1376
1401
  await Effect.runPromise(runCli(["node", "gtd.js", "land"], io))
1377
1402
  const { stdout } = captured()
package/src/cli/Cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { NodeContext } from "@effect/platform-node"
2
2
  import { Cause, Effect, Either, Layer } from "effect"
3
3
  import {
4
+ GtdError,
4
5
  GtdUsageError,
5
6
  Narrator,
6
7
  renderFailure,
@@ -9,6 +10,7 @@ import {
9
10
  Workspace,
10
11
  CommandRunner,
11
12
  } from "../platform/index.js"
13
+ import { resolveFlags, type FlagDeclaration } from "../flags/index.js"
12
14
  import { GTD_VERSION } from "../install/index.js"
13
15
  import {
14
16
  ConfigDiscovery,
@@ -1270,6 +1272,8 @@ export interface CliIo {
1270
1272
  * writer when `false`.
1271
1273
  */
1272
1274
  readonly layers: (verbose: boolean) => Layer.Layer<CommandRequirements>
1275
+ /** Test seam: the shipped registry is empty, so a garbled-value test cannot reach the failure path without injecting one. Drop it when the first real flag ships. */
1276
+ readonly flags?: readonly FlagDeclaration[]
1273
1277
  }
1274
1278
 
1275
1279
  // The one write-to-stderr primitive nodeCliIo uses for both `stderr` (errors)
@@ -1403,7 +1407,14 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
1403
1407
  }
1404
1408
 
1405
1409
  const out = bufferedArtifactOut(io)
1406
- return runCommand(plan.command, plan.json, out).pipe(
1410
+ return Effect.flatMap(Host, ({ env }) =>
1411
+ Effect.try({
1412
+ // Validate only: the result is discarded so a garbled value fails before any command runs.
1413
+ try: () => resolveFlags(env, io.flags),
1414
+ catch: (e) => new GtdError((e as Error).message),
1415
+ }),
1416
+ ).pipe(
1417
+ Effect.flatMap(() => runCommand(plan.command, plan.json, out)),
1407
1418
  // `flush()` fires here, on success, BEFORE `io.exit` — the one point
1408
1419
  // where the whole buffered artifact is known-complete and safe to hand
1409
1420
  // to stdout (a failure discards the buffer instead — see
@@ -36,7 +36,7 @@ const finishedProcess = async (repo: InMemRepo, file: string) => {
36
36
  const repoWithConfig = (): InMemRepo => {
37
37
  const repo = new InMemRepo()
38
38
  repo.writeFile("gtd.config.ts", CONFIG)
39
- repo.commitAllWithPrefix("chore: add workflow")
39
+ repo.seedCommit("chore: add workflow")
40
40
  return repo
41
41
  }
42
42
 
@@ -90,7 +90,7 @@ describe("historyEntries", () => {
90
90
  const repo = repoWithConfig()
91
91
  const { entry: hash } = await finishedProcess(repo, "a.txt")
92
92
  repo.writeFile("x.txt", "x\n")
93
- repo.commitAllWithPrefix("chore: bare tip")
93
+ repo.seedCommit("chore: bare tip")
94
94
  repo.updateRef(`refs/gtd/history/${hash}`, "HEAD")
95
95
  expect((await entries(repo))[0]?.branch).toBeNull()
96
96
  })
@@ -60,7 +60,7 @@ const seed = (source: string, extra: Record<string, string> = {}): InMemRepo =>
60
60
  const repo = new InMemRepo()
61
61
  repo.writeFile("gtd.config.ts", source)
62
62
  for (const [path, content] of Object.entries(extra)) repo.writeFile(path, content)
63
- repo.commitAllWithPrefix("chore: add custom workflow")
63
+ repo.seedCommit("chore: add custom workflow")
64
64
  return repo
65
65
  }
66
66
 
@@ -69,7 +69,7 @@ const seedBundled = (): InMemRepo => {
69
69
  const repo = new InMemRepo()
70
70
  repo.writeFile("README.md", "# test project\n")
71
71
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
72
- repo.commitAllWithPrefix("chore: init")
72
+ repo.seedCommit("chore: init")
73
73
  return repo
74
74
  }
75
75
 
@@ -199,7 +199,7 @@ describe("gtd --workflow <name> — the shipped workflows", () => {
199
199
  const repo = seedBundled()
200
200
  const base = repo.commitHistory().at(-1)!.hash
201
201
  repo.writeFile("scratch.txt", "more work\n")
202
- repo.commitAllWithPrefix("chore: more work")
202
+ repo.seedCommit("chore: more work")
203
203
  const before = repo.commitHistory().length
204
204
  const { stdout, exitCode } = await run(
205
205
  repo,
@@ -288,7 +288,7 @@ describe("gtd next --json — log path emission", () => {
288
288
  const repo = new InMemRepo()
289
289
  repo.writeFile("NOTE.md", "a note\n")
290
290
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
291
- repo.commitAllWithPrefix("gtd: init")
291
+ repo.seedCommit("gtd: init")
292
292
  return repo
293
293
  }
294
294
 
@@ -703,7 +703,7 @@ describe("gtd next — refuses when history diverges from what replay reaches",
703
703
  const diverged = async (): Promise<InMemRepo> => {
704
704
  const repo = await atWorking()
705
705
  repo.writeFile("WORK.md", "done\n")
706
- repo.commitAllWithPrefix("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1")
706
+ repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1")
707
707
  return repo
708
708
  }
709
709
 
@@ -752,9 +752,7 @@ describe("a history written in a format this gtd does not read", () => {
752
752
  const repo = seed(IDLE_THEN_WORKING)
753
753
  await landTurn(repo, { "NOTE.md": "a note\n" })
754
754
  repo.writeFile("WORK.md", "done\n")
755
- repo.commitAllWithPrefix(
756
- "gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2",
757
- )
755
+ repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2")
758
756
  return repo
759
757
  }
760
758
 
@@ -779,7 +777,7 @@ describe("a history written in a format this gtd does not read", () => {
779
777
  it("refuses even when a foreign subject hides the commit from the episode", async () => {
780
778
  const repo = seed(IDLE_THEN_WORKING)
781
779
  repo.writeFile("NOTE.md", "a note\n")
782
- repo.commitAllWithPrefix("gtd2[human] idle -> working\n\nGtd-Format: 2")
780
+ repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 2")
783
781
  for (const command of ["next", "abandon"]) {
784
782
  const { exitCode, stderr } = await run(repo, command)
785
783
  expect(exitCode).toBe(1)
@@ -790,7 +788,7 @@ describe("a history written in a format this gtd does not read", () => {
790
788
  it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
791
789
  const repo = seed(IDLE_THEN_WORKING)
792
790
  repo.writeFile("NOTE.md", "a note\n")
793
- repo.commitAllWithPrefix("feat: x\n\nGtd-Format: 2")
791
+ repo.seedCommit("feat: x\n\nGtd-Format: 2")
794
792
  expect((await run(repo, "next")).exitCode).toBe(0)
795
793
  })
796
794
 
@@ -798,7 +796,7 @@ describe("a history written in a format this gtd does not read", () => {
798
796
  const repo = seed(IDLE_THEN_WORKING)
799
797
  const base = repo.resolveRef("HEAD")!
800
798
  repo.writeFile("NOTE.md", "a note\n")
801
- repo.commitAllWithPrefix("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
799
+ repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
802
800
  repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
803
801
  repo.hardResetTo(base)
804
802
  const { exitCode, stderr } = await run(repo, "restore")
@@ -1949,7 +1947,7 @@ describe("gtd next/land --json=<path> — the select branch", () => {
1949
1947
  const repo = new InMemRepo()
1950
1948
  repo.writeFile("NOTE.md", "a note\n")
1951
1949
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1952
- repo.commitAllWithPrefix("gtd: init")
1950
+ repo.seedCommit("gtd: init")
1953
1951
  return repo
1954
1952
  }
1955
1953
 
@@ -19,7 +19,7 @@ import {
19
19
  type RestRequirements,
20
20
  } from "./Edge.js"
21
21
  import { GTD_VERSION } from "../install/index.js"
22
- import { processHistoryRef } from "../platform/index.js"
22
+ import { keptRef, processHistoryRef } from "../platform/index.js"
23
23
  import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
24
24
  import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
25
25
  import { contentHash } from "../workflow/index.js"
@@ -78,7 +78,7 @@ const headOf = (repo: InMemRepo): string => repo.resolveRef("HEAD")!
78
78
  const repoWith = (config: string, files: Files = {}): InMemRepo => {
79
79
  const repo = new InMemRepo()
80
80
  write(repo, { "gtd.config.ts": config, ...files })
81
- repo.commitAllWithPrefix("chore: add workflow")
81
+ repo.seedCommit("chore: add workflow")
82
82
  return repo
83
83
  }
84
84
 
@@ -112,7 +112,7 @@ const enter = async (repo: InMemRepo, workflow: string, ...vars: string[]): Prom
112
112
  /** Commits an exact message — for trailers no landing writes on its own. */
113
113
  const commit = (repo: InMemRepo, spec: CommitSpec, files: Files = {}): string => {
114
114
  write(repo, files)
115
- repo.commitAllWithPrefix(formatCommitMessage(spec))
115
+ repo.seedCommit(formatCommitMessage(spec))
116
116
  return headOf(repo)
117
117
  }
118
118
 
@@ -179,7 +179,7 @@ describe("currentRun", () => {
179
179
  const repo = repoWith(LINEAR)
180
180
  await land(repo, { "a.txt": "a\n" })
181
181
  repo.writeFile("b.txt", "b\n")
182
- repo.commitAllWithPrefix("feat: unrelated")
182
+ repo.seedCommit("feat: unrelated")
183
183
  const run = await provide(currentRun, repo)
184
184
  expect(run.trace).toEqual([])
185
185
  expect(run.startParentHash).toBe(headOf(repo))
@@ -296,7 +296,7 @@ export const again = async () => {
296
296
  { "a.txt": "a\n" },
297
297
  )
298
298
  repo.writeFile("b.txt", "b\n")
299
- repo.commitAllWithPrefix(
299
+ repo.seedCommit(
300
300
  [
301
301
  "gtd(agent): building → checking",
302
302
  "",
@@ -316,7 +316,7 @@ export const again = async () => {
316
316
  const repo = repoWith(LINEAR)
317
317
  const base = headOf(repo)
318
318
  repo.writeFile("later.txt", "later\n")
319
- repo.commitAllWithPrefix("feat: later")
319
+ repo.seedCommit("feat: later")
320
320
  const before = headOf(repo)
321
321
  await enter(repo, "review", `base=${base}`)
322
322
  const run = await provide(currentRun, repo)
@@ -398,13 +398,13 @@ describe("history reads stay bounded by the episode, not the repository", () =>
398
398
  const repoWithHistory = (count: number): InMemRepo => {
399
399
  const repo = new InMemRepo()
400
400
  repo.writeFile("seed.txt", "0")
401
- repo.commitAllWithPrefix("chore: seed 0")
401
+ repo.seedCommit("chore: seed 0")
402
402
  for (let i = 1; i < count; i++) {
403
403
  repo.writeFile("seed.txt", String(i))
404
- repo.commitAllWithPrefix(`chore: seed ${i}`)
404
+ repo.seedCommit(`chore: seed ${i}`)
405
405
  }
406
406
  write(repo, { "gtd.config.ts": LINEAR })
407
- repo.commitAllWithPrefix("chore: add workflow")
407
+ repo.seedCommit("chore: add workflow")
408
408
  return repo
409
409
  }
410
410
 
@@ -529,7 +529,7 @@ describe("summaryRun", () => {
529
529
  await land(repo, { "b.txt": "b\n" })
530
530
  await land(repo, { "c.txt": "c\n" })
531
531
  repo.writeFile("d.txt", "d\n")
532
- repo.commitAllWithPrefix("chore: unrelated")
532
+ repo.seedCommit("chore: unrelated")
533
533
  const summary = await provide(summaryRun, repo)
534
534
  expect(summary.closingHash).toBeUndefined()
535
535
  expect(summary.trace).toEqual([])
@@ -638,7 +638,7 @@ export const base = (workflow, vars) => (workflow === "review" ? (vars.base ?? "
638
638
  const repo = repoWith(LOOP)
639
639
  const base = headOf(repo)
640
640
  repo.writeFile("later.txt", "later\n")
641
- repo.commitAllWithPrefix("feat: later")
641
+ repo.seedCommit("feat: later")
642
642
  await enter(repo, "review", `base=${base}`)
643
643
  expect(reviewBaseFor(await provide(currentRest, repo))).toBe(base)
644
644
  })
@@ -850,7 +850,7 @@ describe("stalledAt", () => {
850
850
  const repo = repoWith(LINEAR)
851
851
  await enter(repo, "side")
852
852
  await land(repo, { "fix.txt": "x\n" })
853
- repo.commitAllWithPrefix("gtd(agent): tidying")
853
+ repo.seedCommit("gtd(agent): tidying")
854
854
  const rest = await provide(currentRest, repo)
855
855
  expect(rest.state).toBe("tidying")
856
856
  expect(stalledAt(rest)).toBe(false)
@@ -1038,7 +1038,7 @@ describe("closedRunAt", () => {
1038
1038
  const tip = await land(repo, { "c.txt": "c\n" })
1039
1039
  const closed = await provide(closedRunAt(tip), repo)
1040
1040
  repo.writeFile("d.txt", "d\n")
1041
- repo.commitAllWithPrefix("chore: unrelated")
1041
+ repo.seedCommit("chore: unrelated")
1042
1042
  const later = await provide(closedRunAt(tip), repo)
1043
1043
  expect(later).toEqual(closed)
1044
1044
  expect(later.closingHash).toBe(tip)
@@ -1146,6 +1146,103 @@ export const side = async () => {
1146
1146
  })
1147
1147
  })
1148
1148
 
1149
+ describe("keep steps", () => {
1150
+ const KEEP = `import { agent, human } from "@pmelab/gtd/flows"
1151
+
1152
+ export default async () => {
1153
+ await human("idle")
1154
+ await agent("building", "build-prompt", { keep: true })
1155
+ await agent("checking", "check-prompt")
1156
+ }
1157
+ `
1158
+
1159
+ const snapshotOf = async (repo: InMemRepo) =>
1160
+ (await provide(snapshotFromRest(await provide(currentRest, repo)), repo)).landing
1161
+
1162
+ it("landing at a keep step carries keepTurn under refs/gtd/kept/<startHash>/<step>", async () => {
1163
+ const repo = repoWith(KEEP)
1164
+ const building = await land(repo, { "a.txt": "a\n" })
1165
+ repo.writeFile("naive.txt", "naive\n")
1166
+ const landing = await snapshotOf(repo)
1167
+ if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
1168
+ expect(landing.to).toBe("checking")
1169
+ expect(landing.keepTurn).toBe(keptRef(building, "building"))
1170
+ expect(landing.keepTurn).toBe(`refs/gtd/kept/${building}/building`)
1171
+ })
1172
+
1173
+ it("a landing at a step that does not keep carries no keepTurn", async () => {
1174
+ const repo = repoWith(KEEP)
1175
+ repo.writeFile("a.txt", "a\n")
1176
+ const landing = await snapshotOf(repo)
1177
+ if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
1178
+ expect(landing.keepTurn).toBeUndefined()
1179
+ })
1180
+
1181
+ it("a clean tree at a keep step is still an attempt, never a keep", async () => {
1182
+ const repo = repoWith(KEEP)
1183
+ await land(repo, { "a.txt": "a\n" })
1184
+ const landing = await snapshotOf(repo)
1185
+ expect(landing.kind).toBe("attempt")
1186
+ })
1187
+
1188
+ it("a marker at HEAD is not a stall: the process rests at the step after the keep", async () => {
1189
+ const repo = repoWith(KEEP)
1190
+ await land(repo, { "a.txt": "a\n" })
1191
+ await land(repo, { "naive.txt": "naive\n" })
1192
+ expect(repo.readFile("naive.txt")).toBeUndefined()
1193
+ expect(repo.lastCommitMessage()).toMatch(/\nGtd-Kept: [0-9a-f]{40}$/)
1194
+ const rest = await provide(currentRest, repo)
1195
+ expect(rest.state).toBe("checking")
1196
+ expect(stalledAt(rest)).toBe(false)
1197
+ })
1198
+ })
1199
+
1200
+ describe("kept episode commits", () => {
1201
+ const KEEP = `import { agent, human } from "@pmelab/gtd/flows"
1202
+
1203
+ export default async () => {
1204
+ await human("idle")
1205
+ await agent("building", "build-prompt", { keep: true })
1206
+ await agent("checking", "check-prompt")
1207
+ }
1208
+ `
1209
+
1210
+ const keptRepo = async () => {
1211
+ const repo = repoWith(KEEP)
1212
+ await land(repo, { "a.txt": "a\n" })
1213
+ await land(repo, { "naive.txt": "naive\n" })
1214
+ return repo
1215
+ }
1216
+
1217
+ it("builds kept: { hash, tree } for a marker whose kept object exists", async () => {
1218
+ const repo = await keptRepo()
1219
+ const rest = await provide(currentRest, repo)
1220
+ const marker = rest.setup.episode.at(-1)!
1221
+ const hash = /Gtd-Kept: ([0-9a-f]{40})/.exec(repo.lastCommitMessage()!)![1]!
1222
+ expect(marker.kept?.hash).toBe(hash)
1223
+ expect(marker.kept?.tree.read("naive.txt")).toBe("naive\n")
1224
+ expect(marker.tree.read("naive.txt")).toBeUndefined()
1225
+ })
1226
+
1227
+ it("builds no kept for a marker whose kept object is missing", async () => {
1228
+ const repo = await keptRepo()
1229
+ const message = repo
1230
+ .lastCommitMessage()!
1231
+ .replace(/Gtd-Kept: [0-9a-f]{40}/, `Gtd-Kept: ${"ab".repeat(20)}`)
1232
+ repo.hardResetTo("HEAD~1")
1233
+ repo.commitAsIs(message)
1234
+ const rest = await provide(currentRest, repo)
1235
+ expect(rest.state).toBe("checking")
1236
+ expect(rest.setup.episode.at(-1)!.kept).toBeUndefined()
1237
+ })
1238
+
1239
+ it("builds no kept for a commit without the trailer", async () => {
1240
+ const repo = await keptRepo()
1241
+ const rest = await provide(currentRest, repo)
1242
+ expect(rest.setup.episode.slice(0, -1).every((c) => c.kept === undefined)).toBe(true)
1243
+ })
1244
+ })
1245
+
1149
1246
  describe("historyTrail", () => {
1150
1247
  const finished = async (config = LINEAR) => {
1151
1248
  const repo = repoWith(config)
@@ -1158,7 +1255,7 @@ describe("historyTrail", () => {
1158
1255
  it("replays every commit of the closed run, with HEAD elsewhere", async () => {
1159
1256
  const { repo, building, checking, tip } = await finished()
1160
1257
  repo.writeFile("d.txt", "d\n")
1161
- repo.commitAllWithPrefix("chore: unrelated")
1258
+ repo.seedCommit("chore: unrelated")
1162
1259
  const trail = await provide(historyTrail(tip), repo)
1163
1260
  expect(trail.tip).toBe(tip)
1164
1261
  expect(trail.ended).toBe("return")
@@ -1256,7 +1353,7 @@ export default async () => {
1256
1353
  it("refuses naming the config file that differs at the tip", async () => {
1257
1354
  const { repo, tip } = await finished()
1258
1355
  repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
1259
- repo.commitAllWithPrefix("chore: edit config")
1356
+ repo.seedCommit("chore: edit config")
1260
1357
  const exit = await provideExit(historyTrail(tip), repo)
1261
1358
  expect(Exit.isFailure(exit)).toBe(true)
1262
1359
  expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
@@ -1266,7 +1363,7 @@ export default async () => {
1266
1363
  const { repo, tip } = await finished()
1267
1364
  repo.writeFile("gtd.config.ts", `import "./extra.ts"\n${LINEAR}`)
1268
1365
  repo.writeFile("extra.ts", "export {}\n")
1269
- repo.commitAllWithPrefix("chore: split config")
1366
+ repo.seedCommit("chore: split config")
1270
1367
  const exit = await provideExit(historyTrail(tip), repo)
1271
1368
  expect(Exit.isFailure(exit)).toBe(true)
1272
1369
  expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
package/src/edge/Edge.ts CHANGED
@@ -43,7 +43,7 @@ import type { ScopeAccess } from "../flows/index.js"
43
43
  import { steeringFormatFor } from "../steering/index.js"
44
44
  import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
45
45
  import { GTD_VERSION } from "../install/index.js"
46
- import { processHistoryRef } from "../platform/index.js"
46
+ import { keptRef, processHistoryRef } from "../platform/index.js"
47
47
  import { isAbsolute, relative, resolve, sep } from "node:path"
48
48
  import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
49
49
 
@@ -700,6 +700,7 @@ const promptDef = (
700
700
  ...optional("model", request.options.model),
701
701
  ...optional("system", request.options.system),
702
702
  ...optional("allowEmpty", request.options.allowEmpty),
703
+ ...optional("keep", request.options.keep),
703
704
  ...optional("skills", skills),
704
705
  ...optional("access", access),
705
706
  })
@@ -902,11 +903,19 @@ const setupFor = (
902
903
  run.episode.base === undefined
903
904
  ? { hash: EMPTY_TREE, tree: treeFromRecord({}) }
904
905
  : { hash: run.episode.base, tree: commitTree(workspace, run.episode.base) }
905
- const episode: readonly EpisodeCommit[] = run.episode.commits.map((c) => ({
906
- hash: c.hash,
907
- message: c.message,
908
- tree: commitTree(workspace, c.hash),
909
- }))
906
+ const git = yield* GitService
907
+ const episode: EpisodeCommit[] = []
908
+ for (const c of run.episode.commits) {
909
+ const tip = parseCommitMessage(c.message).kept
910
+ // A plain `rev-parse --verify` of a missing 40-hex object succeeds; `^{commit}` does not.
911
+ const exists = tip !== undefined && Option.isSome(yield* git.readRefOption(`${tip}^{commit}`))
912
+ episode.push({
913
+ hash: c.hash,
914
+ message: c.message,
915
+ tree: commitTree(workspace, c.hash),
916
+ ...(exists ? { kept: { hash: tip, tree: commitTree(workspace, tip) } } : {}),
917
+ })
918
+ }
910
919
  let pendingMemo: ReadonlyMap<string, string | undefined> | undefined
911
920
  const setup: ReplaySetup = {
912
921
  def,
@@ -1167,6 +1176,12 @@ const closingTrailers = (branch: Option.Option<string>) => ({
1167
1176
  const isSettledNoop = (rest: Rest, to: StateName): boolean =>
1168
1177
  rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state
1169
1178
 
1179
+ // A clean tree has nothing to set aside (an allowEmpty keep step lands as an ordinary empty commit).
1180
+ const keptTurn = (rest: Rest) =>
1181
+ rest.stepDef.keep === true && rest.changes.length > 0
1182
+ ? { keepTurn: keptRef(rest.run.startHash, rest.state) }
1183
+ : {}
1184
+
1170
1185
  const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): Landing => {
1171
1186
  if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
1172
1187
  // The landing that leaves the flow's first step starts the process: it
@@ -1191,6 +1206,7 @@ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): La
1191
1206
  ...(finishes ? closingTrailers(branch) : {}),
1192
1207
  },
1193
1208
  ...(finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}),
1209
+ ...keptTurn(rest),
1194
1210
  }
1195
1211
  }
1196
1212
 
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process"
2
- import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"
2
+ import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
3
3
  import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
5
  import { describe, expect, it } from "vitest"
@@ -11,6 +11,7 @@ import {
11
11
  discardPending,
12
12
  fetchHistoryRefs,
13
13
  hardResetTo,
14
+ keepTurn,
14
15
  mixedResetTo,
15
16
  pathspec,
16
17
  ScriptSurface,
@@ -61,7 +62,7 @@ const withFakeGit = (
61
62
  }
62
63
 
63
64
  const runWithFakeGit = (script: string, binDir: string): { status: number | null } =>
64
- spawnSync("bash", ["-c", script], {
65
+ spawnSync("sh", ["-c", script], {
65
66
  env: { ...process.env, PATH: `${binDir}:${process.env["PATH"]}` },
66
67
  encoding: "utf8",
67
68
  })
@@ -74,6 +75,7 @@ const allBuilders: ReadonlyArray<[string, string]> = [
74
75
  ["hardResetTo", hardResetTo("HEAD~1")],
75
76
  ["discardPending", discardPending()],
76
77
  ["updateRef", updateRef("refs/worktree/gtd/review-head", "abc123")],
78
+ ["keepTurn", keepTurn("refs/gtd/kept/abc/build.naive", "gtd(agent): it's done\n\nGtd-Format: 1")],
77
79
  ["deleteRef", deleteRef("refs/worktree/gtd/review-head")],
78
80
  ]
79
81
 
@@ -285,3 +287,64 @@ describe("fetchHistoryRefs", () => {
285
287
  expect(runBashCheckSyntax(script)).toBe(0)
286
288
  })
287
289
  })
290
+
291
+ describe("keepTurn", () => {
292
+ const block = keepTurn("refs/gtd/kept/abc/build.naive", "gtd(agent): done")
293
+
294
+ it("a branch update refused by the guard exits 1, deleting the kept ref after reset -q", () => {
295
+ const binDir = mkdtempSync(join(tmpdir(), "gitscript-fakebin-"))
296
+ const log = join(binDir, "log")
297
+ writeFileSync(
298
+ join(binDir, "git"),
299
+ [
300
+ "#!/bin/sh",
301
+ `echo "$*" >> '${log}'`,
302
+ `case "$1" in`,
303
+ ` rev-parse) echo head0 ;;`,
304
+ ` write-tree) echo tree0 ;;`,
305
+ ` commit-tree) echo commit0 ;;`,
306
+ ` config) echo false ;;`,
307
+ ` update-ref) [ "$2" = HEAD ] && exit 1; exit 0 ;;`,
308
+ ` *) exit 0 ;;`,
309
+ `esac`,
310
+ ].join("\n"),
311
+ { mode: 0o755 },
312
+ )
313
+ const result = runWithFakeGit(`set -eu\n${block}`, binDir)
314
+ expect(result.status).toBe(1)
315
+ const calls = readFileSync(log, "utf8").trim().split("\n")
316
+ expect(calls).toContain("update-ref HEAD commit0 head0")
317
+ const resetAt = calls.indexOf("reset -q")
318
+ expect(resetAt).toBeGreaterThan(-1)
319
+ expect(calls.slice(resetAt + 1)).toEqual([
320
+ "update-ref -d refs/gtd/kept/abc/build.naive/commit0",
321
+ ])
322
+ expect(calls).not.toContain("reset --hard HEAD")
323
+ })
324
+
325
+ it("a failed reset -q exits 1 and keeps the kept ref", () => {
326
+ const binDir = mkdtempSync(join(tmpdir(), "gitscript-fakebin-"))
327
+ const log = join(binDir, "log")
328
+ writeFileSync(
329
+ join(binDir, "git"),
330
+ [
331
+ "#!/bin/sh",
332
+ `echo "$*" >> '${log}'`,
333
+ `case "$1" in`,
334
+ ` rev-parse) echo head0 ;;`,
335
+ ` write-tree) echo tree0 ;;`,
336
+ ` commit-tree) echo commit0 ;;`,
337
+ ` config) echo false ;;`,
338
+ ` update-ref) [ "$2" = HEAD ] && exit 1; exit 0 ;;`,
339
+ ` reset) exit 1 ;;`,
340
+ ` *) exit 0 ;;`,
341
+ `esac`,
342
+ ].join("\n"),
343
+ { mode: 0o755 },
344
+ )
345
+ const result = runWithFakeGit(`set -eu\n${block}`, binDir)
346
+ expect(result.status).toBe(1)
347
+ const calls = readFileSync(log, "utf8").trim().split("\n")
348
+ expect(calls).not.toContain("update-ref -d refs/gtd/kept/abc/build.naive/commit0")
349
+ })
350
+ })
@@ -17,7 +17,7 @@ const commitAllowEmpty = (message: string): string => {
17
17
  return [
18
18
  `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
19
19
  ` case "$out" in`,
20
- ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
20
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
21
21
  ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
22
22
  ` esac`,
23
23
  `fi`,
@@ -47,6 +47,37 @@ export const hardResetTo = (ref: string): string => `git reset --hard ${shellQuo
47
47
  */
48
48
  export const discardPending = (): string => `git add -A && git reset --hard HEAD`
49
49
 
50
+ /**
51
+ * POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
52
+ * tree, so no hook runs on it and its tree is the pre-turn tree by
53
+ * construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
54
+ * `-S`, read (and failing closed) before any object or ref is written; the
55
+ * index is already staged by then, so an early exit leaves the turn staged.
56
+ * The kept ref is deleted only after `reset -q` succeeds: it is then the
57
+ * turn's only handle.
58
+ * The branch moves before the final hard reset, so if that reset fails the
59
+ * marker has landed and the turn is still in the working tree (kept ref
60
+ * present); every other failure leaves the branch untouched.
61
+ */
62
+ export const keepTurn = (refPrefix: string, message: string): string => {
63
+ const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`
64
+ return [
65
+ `git add -A || exit 1`,
66
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
67
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
68
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
69
+ `gtd_tree=$(git write-tree) || exit 1`,
70
+ `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
71
+ `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
72
+ `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
73
+ `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref HEAD "$gtd_tip" "$gtd_head"; then`,
74
+ ` git reset -q && git update-ref -d ${keptRef}`,
75
+ ` exit 1`,
76
+ `fi`,
77
+ `git reset --hard HEAD`,
78
+ ].join("\n")
79
+ }
80
+
50
81
  export const updateRef = (ref: string, hash: string): string =>
51
82
  `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`
52
83
 
@@ -100,6 +131,8 @@ const renderGitWrite = (write: GitWrite): string => {
100
131
  return commitAll(write.message)
101
132
  case "updateRef":
102
133
  return updateRef(write.ref, write.target)
134
+ case "keepTurn":
135
+ return keepTurn(write.ref, write.message)
103
136
  }
104
137
  }
105
138
 
package/src/emit/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  hardResetTo,
21
21
  discardPending,
22
22
  updateRef,
23
+ keepTurn,
23
24
  deleteRef,
24
25
  fetchHistoryRefs,
25
26
  type RunnableScript,