@pmelab/gtd 26.0.0 → 27.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 (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -3
  3. package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
  4. package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
  5. package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
  6. package/dist/flows.mjs +15 -1
  7. package/dist/gtd.bundle.mjs +219 -34
  8. package/dist/testkit.mjs +447 -72
  9. package/dist/wire.mjs +2 -2
  10. package/dist/workflows/feature.mjs +1 -1
  11. package/dist/workflows/fix.mjs +1 -1
  12. package/dist/workflows/review.mjs +1 -1
  13. package/package.json +2 -2
  14. package/src/cli/Cli.test.ts +56 -4
  15. package/src/cli/Cli.ts +48 -6
  16. package/src/cli/History.test.ts +2 -2
  17. package/src/cli/Output.ts +3 -1
  18. package/src/cli/Program.test.ts +11 -12
  19. package/src/cli/Program.ts +28 -7
  20. package/src/cli/Strays.test.ts +88 -0
  21. package/src/cli/Strays.ts +56 -0
  22. package/src/edge/Edge.test.ts +113 -16
  23. package/src/edge/Edge.ts +22 -6
  24. package/src/emit/GitScript.test.ts +65 -2
  25. package/src/emit/GitScript.ts +34 -1
  26. package/src/emit/index.ts +1 -0
  27. package/src/flags/index.ts +0 -1
  28. package/src/flows/runtime.test.ts +33 -0
  29. package/src/flows/runtime.ts +31 -0
  30. package/src/platform/Git.test.ts +96 -1
  31. package/src/platform/Git.ts +24 -0
  32. package/src/platform/RetainedHistory.test.ts +9 -0
  33. package/src/platform/RetainedHistory.ts +3 -0
  34. package/src/platform/Workspace.test.ts +1 -1
  35. package/src/platform/index.ts +1 -0
  36. package/src/replay/RefName.test.ts +40 -0
  37. package/src/replay/RefName.ts +19 -0
  38. package/src/replay/Replay.test.ts +193 -2
  39. package/src/replay/Replay.ts +51 -6
  40. package/src/replay/Trailers.test.ts +26 -0
  41. package/src/replay/Trailers.ts +8 -0
  42. package/src/replay/index.ts +1 -0
  43. package/src/step/LandStep.ts +1 -0
  44. package/src/step/RepoSnapshot.ts +2 -0
  45. package/src/step/index.ts +1 -0
  46. package/src/step/planStart.test.ts +5 -5
  47. package/src/step/planStep.test.ts +53 -0
  48. package/src/step/planStep.ts +7 -1
  49. package/src/step/strayPaths.test.ts +17 -0
  50. package/src/step/strayPaths.ts +8 -0
  51. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  52. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  53. package/src/testkit/EmittedScripts.test.ts +182 -0
  54. package/src/testkit/FakeGitOperations.ts +10 -2
  55. package/src/testkit/GitTiers.ts +170 -25
  56. package/src/testkit/InMemRepo.ts +64 -12
  57. package/src/testkit/Layers.test.ts +34 -1
  58. package/src/testkit/Layers.ts +21 -1
  59. package/src/testkit/TestRepo.ts +3 -3
  60. package/src/wire/ExitCodes.test.ts +11 -3
  61. package/src/wire/ExitCodes.ts +3 -0
  62. package/src/wire/Glob.test.ts +35 -0
  63. package/src/wire/Glob.ts +1 -1
  64. package/src/workflow/Workflow.ts +1 -0
  65. package/src/workflow/discovery.test.ts +1 -1
  66. package/src/workflows/shared/text.fixture.ts +1 -0
@@ -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,
@@ -11,7 +11,6 @@ export interface FlagDeclaration {
11
11
  readonly kind: FlagKind
12
12
  }
13
13
 
14
- // fallow-ignore-next-line unused-export
15
14
  export const FLAGS = [] as const satisfies readonly FlagDeclaration[]
16
15
 
17
16
  /** A misspelt name is a compile error. */
@@ -4,6 +4,7 @@ import {
4
4
  FlowsProtocolError,
5
5
  head,
6
6
  installContext,
7
+ kept,
7
8
  protocolMismatch,
8
9
  type FlowContext,
9
10
  } from "./runtime.js"
@@ -52,3 +53,35 @@ describe("the flows protocol handshake", () => {
52
53
  )
53
54
  })
54
55
  })
56
+
57
+ describe("kept()", () => {
58
+ const change = { path: "a.ts", status: "added", before: undefined, after: "a" } as const
59
+ const withKept = (tip: unknown) =>
60
+ installContext({
61
+ kept: (name: string) => (name === "naive" ? tip : undefined),
62
+ } as unknown as FlowContext)
63
+
64
+ it("returns undefined as given", () => {
65
+ withKept(undefined)
66
+ expect(kept("naive")).toBeUndefined()
67
+ })
68
+
69
+ it("wraps a tip's changes with paths and get", () => {
70
+ withKept({ hash: "abc", changes: [change] })
71
+ const tip = kept("naive")
72
+ expect(tip?.hash).toBe("abc")
73
+ expect(tip?.changes.paths).toEqual(["a.ts"])
74
+ expect(tip?.changes.get("a.ts")?.after).toBe("a")
75
+ expect(tip?.changes.get("b.ts")).toBeUndefined()
76
+ expect(tip?.changes).toHaveLength(1)
77
+ expect(kept("other")).toBeUndefined()
78
+ })
79
+
80
+ it("throws the upgrade message on a context without kept, protocol still 1", () => {
81
+ installContext(context)
82
+ expect(() => kept("naive")).toThrow(
83
+ "gtd: kept() needs a newer gtd — upgrade the gtd that runs it",
84
+ )
85
+ expect(FLOWS_PROTOCOL).toBe(1)
86
+ })
87
+ })
@@ -24,6 +24,12 @@ export interface AgentOptions extends SteeringOptions {
24
24
  * is an attempt: recorded, but the process stays at the step (a stall).
25
25
  */
26
26
  readonly allowEmpty?: boolean | undefined
27
+ /**
28
+ * Set the turn aside: its tip goes to `refs/gtd/kept/<entry>/<step>/<hash>` and the
29
+ * branch stays where it was, a marker commit recording that the step ran.
30
+ * The step's full name must be usable as a git ref component.
31
+ */
32
+ readonly keep?: boolean | undefined
27
33
  }
28
34
 
29
35
  export interface HumanOptions extends SteeringOptions {
@@ -157,6 +163,10 @@ export interface FlowContext {
157
163
  readonly skillsFor: (localName: string) => readonly string[]
158
164
  /** The folded access the memory scope `localName` lands in resolves to; `file` is the step's own steering file. */
159
165
  readonly accessFor: (localName: string, file?: string) => AccessDef
166
+ /** Absent on an engine older than `kept`. */
167
+ readonly kept?: (
168
+ name: string,
169
+ ) => { readonly hash: string; readonly changes: readonly Change[] } | undefined
160
170
  }
161
171
 
162
172
  /**
@@ -302,6 +312,27 @@ export const changesSince = (hash: string, pattern?: string): Changes => {
302
312
  )
303
313
  }
304
314
 
315
+ export interface KeptTip {
316
+ /** The kept commit. `""` while the keeping turn is still pending (a landing decision): never branch on it, the landed replay sees the real hash. */
317
+ readonly hash: string
318
+ /** The kept tip against the commit it diverged from. */
319
+ readonly changes: Changes
320
+ }
321
+
322
+ /**
323
+ * The latest kept tip of step `name` (scoped from here, same as `agent()`) in
324
+ * this episode, or `undefined` when none.
325
+ */
326
+ export const kept = (name: string): KeptTip | undefined => {
327
+ const context = ctx()
328
+ // FLOWS_PROTOCOL stays 1, so an older engine's context simply lacks `kept`.
329
+ if (typeof context.kept !== "function") {
330
+ throw new Error("gtd: kept() needs a newer gtd — upgrade the gtd that runs it")
331
+ }
332
+ const tip = context.kept(name)
333
+ return tip === undefined ? undefined : { hash: tip.hash, changes: toChanges(tip.changes) }
334
+ }
335
+
305
336
  /** The commit the process stands on at this point of the flow. */
306
337
  export const head = (): string => ctx().head()
307
338
 
@@ -1,6 +1,12 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { NodeContext } from "@effect/platform-node"
1
6
  import { Cause, Effect, Exit } from "effect"
2
7
  import { describe, expect, it } from "vitest"
3
- import { InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
8
+ import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
9
+ import { Host } from "./index.js"
4
10
  import { gitTiers, runGitServiceContract, runGitScriptContract } from "../testkit/index.js"
5
11
 
6
12
  /**
@@ -45,3 +51,92 @@ for (const makeTier of gitTiers) {
45
51
  runGitScriptContract(makeTier)
46
52
  })
47
53
  }
54
+
55
+ describe("diffPaths [Live]", () => {
56
+ it("stays repo-root-relative with diff.relative=true and a subdirectory cwd", async () => {
57
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-diffpaths-")))
58
+ try {
59
+ const git = (...args: string[]) =>
60
+ execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim()
61
+ git("init")
62
+ git("config", "user.email", "test@test.com")
63
+ git("config", "user.name", "Test")
64
+ git("config", "commit.gpgsign", "false")
65
+ git("config", "diff.relative", "true")
66
+ mkdirSync(join(root, "sub"))
67
+ writeFileSync(join(root, "sub", "a.txt"), "a")
68
+ writeFileSync(join(root, "top.txt"), "t")
69
+ git("add", "-A")
70
+ git("commit", "-m", "one")
71
+ const base = git("rev-parse", "HEAD")
72
+ writeFileSync(join(root, "sub", "a.txt"), "b")
73
+ writeFileSync(join(root, "top.txt"), "u")
74
+ git("add", "-A")
75
+ git("commit", "-m", "two")
76
+ const tip = git("rev-parse", "HEAD")
77
+ const paths = await Effect.runPromise(
78
+ Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
79
+ Effect.provide(GitService.Live),
80
+ Effect.provide(Host.layer({ root: join(root, "sub"), home: root, env: {} })),
81
+ Effect.provide(NodeContext.layer),
82
+ ),
83
+ )
84
+ expect([...paths].sort()).toEqual(["sub/a.txt", "top.txt"])
85
+ } finally {
86
+ rmSync(root, { recursive: true, force: true })
87
+ }
88
+ })
89
+
90
+ // An exported GIT_DIR/GIT_WORK_TREE would send this test's commits to the real repo;
91
+ // the pinned config keeps a global commit.gpgsign or missing identity from failing them.
92
+ const cleanEnv = {
93
+ ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))),
94
+ GIT_CONFIG_COUNT: "3",
95
+ GIT_CONFIG_KEY_0: "commit.gpgsign",
96
+ GIT_CONFIG_VALUE_0: "false",
97
+ GIT_CONFIG_KEY_1: "user.email",
98
+ GIT_CONFIG_VALUE_1: "test@test.com",
99
+ GIT_CONFIG_KEY_2: "user.name",
100
+ GIT_CONFIG_VALUE_2: "Test",
101
+ }
102
+
103
+ it("reports a submodule pointer bump whatever diff.ignoreSubmodules and .gitmodules ignore say", async () => {
104
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-diffpaths-sm-")))
105
+ try {
106
+ const run = (cwd: string, ...args: string[]) =>
107
+ execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe", env: cleanEnv }).trim()
108
+ const source = join(root, "source")
109
+ const repo = join(root, "super")
110
+ mkdirSync(source)
111
+ mkdirSync(repo)
112
+ run(source, "init")
113
+ writeFileSync(join(source, "f.txt"), "1")
114
+ run(source, "add", "-A")
115
+ run(source, "commit", "-m", "s1")
116
+ run(repo, "init")
117
+ run(repo, "config", "diff.ignoreSubmodules", "all")
118
+ run(repo, "-c", "protocol.file.allow=always", "submodule", "add", source, "sub")
119
+ run(repo, "config", "-f", ".gitmodules", "submodule.sub.ignore", "all")
120
+ run(repo, "add", "-A")
121
+ run(repo, "commit", "-m", "one")
122
+ const base = run(repo, "rev-parse", "HEAD")
123
+ const clone = join(repo, "sub")
124
+ writeFileSync(join(clone, "f.txt"), "2")
125
+ run(clone, "add", "-A")
126
+ run(clone, "commit", "-m", "s2")
127
+ run(repo, "add", "--force", "sub")
128
+ run(repo, "commit", "-m", "two")
129
+ const tip = run(repo, "rev-parse", "HEAD")
130
+ const paths = await Effect.runPromise(
131
+ Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
132
+ Effect.provide(GitService.Live),
133
+ Effect.provide(Host.layer({ root: repo, home: root, env: {} })),
134
+ Effect.provide(NodeContext.layer),
135
+ ),
136
+ )
137
+ expect([...paths]).toEqual(["sub"])
138
+ } finally {
139
+ rmSync(root, { recursive: true, force: true })
140
+ }
141
+ })
142
+ })