@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
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { FLAGS, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
3
+
4
+ const registry = [
5
+ { name: "alpha", owner: "pmelab", expiry: "2020-01-01", kind: "release" },
6
+ { name: "new_review", owner: "pmelab", expiry: "2099-01-01", kind: "ops" },
7
+ ] as const satisfies readonly FlagDeclaration[]
8
+
9
+ describe("resolveFlags", () => {
10
+ it.each(["1", "true", "TRUE", "True"])("%s turns a flag on", (value) => {
11
+ expect(resolveFlags({ GTD_FLAG_ALPHA: value }, registry).alpha).toBe(true)
12
+ })
13
+
14
+ it.each(["0", "false", "FALSE"])("%s turns a flag off", (value) => {
15
+ expect(resolveFlags({ GTD_FLAG_ALPHA: value }, registry).alpha).toBe(false)
16
+ })
17
+
18
+ it("reads unset and empty as off, with every declared flag a key", () => {
19
+ expect(resolveFlags({ GTD_FLAG_NEW_REVIEW: "" }, registry)).toEqual({
20
+ alpha: false,
21
+ new_review: false,
22
+ })
23
+ })
24
+
25
+ it.each(["ture", "constructor", "__proto__", "toString"])(
26
+ "throws naming the variable, not the value %s",
27
+ (value) => {
28
+ expect(() => resolveFlags({ GTD_FLAG_NEW_REVIEW: value }, registry)).toThrow(
29
+ /^GTD_FLAG_NEW_REVIEW must be 1, 0, true or false$/,
30
+ )
31
+ },
32
+ )
33
+
34
+ it("reports the first garbled flag in registry order", () => {
35
+ expect(() => resolveFlags({ GTD_FLAG_NEW_REVIEW: "x", GTD_FLAG_ALPHA: "y" }, registry)).toThrow(
36
+ /^GTD_FLAG_ALPHA /,
37
+ )
38
+ })
39
+
40
+ it("ignores an undeclared GTD_FLAG_* key with a garbled value", () => {
41
+ expect(() => resolveFlags({ GTD_FLAG_NOPE: "ture" }, registry)).not.toThrow()
42
+ })
43
+
44
+ it("resolves a flag past its expiry like any other", () => {
45
+ expect(registry[0].expiry < new Date().toISOString().slice(0, 10)).toBe(true)
46
+ expect(resolveFlags({ GTD_FLAG_ALPHA: "1" }, registry).alpha).toBe(true)
47
+ expect(resolveFlags({ GTD_FLAG_ALPHA: "0" }, registry).alpha).toBe(false)
48
+ expect(resolveFlags({}, registry).alpha).toBe(false)
49
+ })
50
+
51
+ it("Flags<R> rejects a misspelt name", () => {
52
+ const flags: Flags<typeof registry> = resolveFlags({}, registry)
53
+ // @ts-expect-error misspelt flag name
54
+ void flags.alpah
55
+ })
56
+ })
57
+
58
+ describe("FLAGS", () => {
59
+ it("declares settable, unique variable names", () => {
60
+ // Vacuous while FLAGS is empty; guards the first declared flag.
61
+ const names = (FLAGS as readonly FlagDeclaration[]).map(({ name }) => {
62
+ expect(name).toMatch(/^[a-z][a-z0-9_]*$/)
63
+ return name
64
+ })
65
+ expect(new Set(names).size).toBe(names.length)
66
+ })
67
+ })
@@ -0,0 +1,38 @@
1
+ // A flag may change how a step runs, never which step comes next: flags are not recorded in
2
+ // history, so replay must not read them. No lint rule enforces this.
3
+
4
+ export type FlagKind = "release" | "ops" | "experiment"
5
+
6
+ export interface FlagDeclaration {
7
+ readonly name: string
8
+ readonly owner: string
9
+ /** ISO `YYYY-MM-DD`; never enforced at runtime. */
10
+ readonly expiry: string
11
+ readonly kind: FlagKind
12
+ }
13
+
14
+ export const FLAGS = [] as const satisfies readonly FlagDeclaration[]
15
+
16
+ /** A misspelt name is a compile error. */
17
+ export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
18
+ readonly [N in R[number]["name"]]: boolean
19
+ }
20
+
21
+ const BOOL: Record<string, boolean> = { "": false, "0": false, false: false, "1": true, true: true }
22
+
23
+ export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof FLAGS>(
24
+ env: Readonly<Record<string, string | undefined>>,
25
+ registry: R = FLAGS as unknown as R,
26
+ ): Flags<R> => {
27
+ const out: Record<string, boolean> = {}
28
+ for (const { name } of registry) {
29
+ const variable = `GTD_FLAG_${name.toUpperCase()}`
30
+ const raw = env[variable] ?? ""
31
+ const key = raw.toLowerCase()
32
+ if (!Object.hasOwn(BOOL, key)) {
33
+ throw new Error(`${variable} must be 1, 0, true or false`)
34
+ }
35
+ out[name] = BOOL[key]!
36
+ }
37
+ return out as Flags<R>
38
+ }
@@ -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
 
@@ -4,6 +4,7 @@ import { strictGitOperations as stubGit } from "../testkit/index.js"
4
4
  import {
5
5
  clearRetainedHistory,
6
6
  HISTORY_REF,
7
+ keptRef,
7
8
  PROCESS_HISTORY_PREFIX,
8
9
  processHistoryRef,
9
10
  readRetainedHistory,
@@ -77,3 +78,11 @@ describe("processHistoryRef", () => {
77
78
  expect(HISTORY_REF.startsWith(PROCESS_HISTORY_PREFIX)).toBe(false)
78
79
  })
79
80
  })
81
+
82
+ describe("keptRef", () => {
83
+ it("keys a kept tip by entry and step, outside the finished-process namespace", () => {
84
+ const entry = "a".repeat(40)
85
+ expect(keptRef(entry, "build.naive")).toBe(`refs/gtd/kept/${entry}/build.naive`)
86
+ expect(keptRef(entry, "build.naive").startsWith(PROCESS_HISTORY_PREFIX)).toBe(false)
87
+ })
88
+ })
@@ -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,