@pmelab/gtd 24.0.0 → 24.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 (196) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -5
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43339 -55575
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +8 -6
  19. package/package.json +69 -8
  20. package/src/cli/Cli.test.ts +1329 -0
  21. package/src/cli/Cli.ts +1348 -0
  22. package/src/cli/Program.test.ts +2262 -0
  23. package/src/cli/Program.ts +1331 -0
  24. package/src/cli/ToolCommands.ts +43 -0
  25. package/src/cli/index.ts +14 -0
  26. package/src/edge/Edge.test.ts +1016 -0
  27. package/src/edge/Edge.ts +1225 -0
  28. package/src/edge/index.ts +16 -0
  29. package/src/emit/Emit.test.ts +425 -0
  30. package/src/emit/Emit.ts +139 -0
  31. package/src/emit/GitScript.test.ts +260 -0
  32. package/src/emit/GitScript.ts +131 -0
  33. package/src/emit/ModeContradiction.test.ts +120 -0
  34. package/src/emit/ModeContradiction.ts +66 -0
  35. package/src/emit/OutcomeScript.test.ts +238 -0
  36. package/src/emit/OutcomeScript.ts +73 -0
  37. package/src/emit/SteeringMode.test.ts +274 -0
  38. package/src/emit/SteeringMode.ts +256 -0
  39. package/src/emit/index.ts +40 -0
  40. package/src/install/ConfigScaffold.ts +19 -0
  41. package/src/install/Install.test.ts +426 -0
  42. package/src/install/Install.ts +634 -0
  43. package/src/install/Version.ts +27 -0
  44. package/src/install/index.ts +3 -0
  45. package/src/platform/CommandRunner.ts +74 -0
  46. package/src/platform/Commentary.test.ts +126 -0
  47. package/src/platform/Commentary.ts +92 -0
  48. package/src/platform/Git.test.ts +50 -0
  49. package/src/platform/Git.ts +552 -0
  50. package/src/platform/Host.ts +42 -0
  51. package/src/platform/RetainedHistory.test.ts +65 -0
  52. package/src/platform/RetainedHistory.ts +34 -0
  53. package/src/platform/Workspace.test.ts +669 -0
  54. package/src/platform/Workspace.ts +631 -0
  55. package/src/platform/WorktreeState.test.ts +162 -0
  56. package/src/platform/WorktreeState.ts +50 -0
  57. package/src/platform/index.ts +22 -0
  58. package/src/replay/Access.test.ts +53 -0
  59. package/src/replay/Access.ts +46 -0
  60. package/src/replay/Format.test.ts +20 -0
  61. package/src/replay/Format.ts +14 -0
  62. package/src/replay/Replay.test.ts +887 -0
  63. package/src/replay/Replay.ts +669 -0
  64. package/src/replay/Trailers.test.ts +117 -0
  65. package/src/replay/Trailers.ts +213 -0
  66. package/src/replay/Tree.test.ts +25 -0
  67. package/src/replay/Tree.ts +45 -0
  68. package/src/replay/index.ts +11 -0
  69. package/src/steering/Blocks.fixture.ts +6 -0
  70. package/src/steering/Blocks.test.ts +167 -0
  71. package/src/steering/Blocks.ts +321 -0
  72. package/src/steering/CodeThreads.test.ts +127 -0
  73. package/src/steering/CodeThreads.ts +197 -0
  74. package/src/steering/Edits.test.ts +19 -0
  75. package/src/steering/Edits.ts +20 -0
  76. package/src/steering/Eol.test.ts +16 -0
  77. package/src/steering/Eol.ts +2 -0
  78. package/src/steering/FORMATS.md +58 -0
  79. package/src/steering/Footnotes.test.ts +977 -0
  80. package/src/steering/Footnotes.ts +661 -0
  81. package/src/steering/Inline.fixture.ts +7 -0
  82. package/src/steering/Inline.test.ts +227 -0
  83. package/src/steering/Inline.ts +195 -0
  84. package/src/steering/MarkdownTree.test.ts +247 -0
  85. package/src/steering/MarkdownTree.ts +260 -0
  86. package/src/steering/Outline.ts +19 -0
  87. package/src/steering/SteeringFormat.test.ts +91 -0
  88. package/src/steering/SteeringFormat.ts +267 -0
  89. package/src/steering/SteeringFormats.test.ts +163 -0
  90. package/src/steering/Threads.ts +163 -0
  91. package/src/steering/contract.test.ts +264 -0
  92. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  93. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  94. package/src/steering/fixtures/free-form/notes.md +12 -0
  95. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  96. package/src/steering/fixtures/qa/answered.md +23 -0
  97. package/src/steering/fixtures/qa/answered.read.json +119 -0
  98. package/src/steering/fixtures/qa/requirements.md +160 -0
  99. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  100. package/src/steering/fixtures/review/review.md +76 -0
  101. package/src/steering/fixtures/review/review.read.json +237 -0
  102. package/src/steering/fixtures/review/ticked.md +23 -0
  103. package/src/steering/fixtures/review/ticked.read.json +99 -0
  104. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  105. package/src/steering/fixtures/review-notes/noted.md +19 -0
  106. package/src/steering/freeform.test.ts +311 -0
  107. package/src/steering/freeform.ts +148 -0
  108. package/src/steering/index.test.ts +62 -0
  109. package/src/steering/index.ts +140 -0
  110. package/src/steering/qa.test.ts +2160 -0
  111. package/src/steering/qa.ts +1104 -0
  112. package/src/steering/review.test.ts +1829 -0
  113. package/src/steering/review.ts +1180 -0
  114. package/src/step/LandStep.ts +31 -0
  115. package/src/step/RepoSnapshot.ts +29 -0
  116. package/src/step/accessRefusal.test.ts +58 -0
  117. package/src/step/accessRefusal.ts +26 -0
  118. package/src/step/index.ts +5 -0
  119. package/src/step/planStart.test.ts +202 -0
  120. package/src/step/planStart.ts +167 -0
  121. package/src/step/planStep.test.ts +104 -0
  122. package/src/step/planStep.ts +89 -0
  123. package/src/step/snapshot.fixture.ts +28 -0
  124. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  125. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  126. package/src/testkit/FakeGitOperations.ts +95 -0
  127. package/src/testkit/GitTiers.test.ts +11 -0
  128. package/src/testkit/GitTiers.ts +1052 -0
  129. package/src/testkit/InMemRepo.ts +467 -0
  130. package/src/testkit/Layers.test.ts +58 -0
  131. package/src/testkit/Layers.ts +241 -0
  132. package/src/testkit/StrictGitOperations.ts +31 -0
  133. package/src/testkit/TestRepo.test.ts +119 -0
  134. package/src/testkit/TestRepo.ts +274 -0
  135. package/src/testkit/cliIo.ts +36 -0
  136. package/src/testkit/index.ts +9 -0
  137. package/src/testkit/steps.ts +177 -0
  138. package/src/testkit/testkit.feature +65 -0
  139. package/src/wire/BeatDocument.test.ts +419 -0
  140. package/src/wire/BeatDocument.ts +114 -0
  141. package/src/wire/BeatPlain.test.ts +113 -0
  142. package/src/wire/BeatPlain.ts +127 -0
  143. package/src/wire/BeatStatus.test.ts +59 -0
  144. package/src/wire/BeatStatus.ts +81 -0
  145. package/src/wire/Demand.test.ts +142 -0
  146. package/src/wire/Demand.ts +108 -0
  147. package/src/wire/ExitCodes.test.ts +21 -0
  148. package/src/wire/ExitCodes.ts +16 -0
  149. package/src/wire/Glob.ts +29 -0
  150. package/src/wire/Judge.ts +22 -0
  151. package/src/wire/Land.test.ts +120 -0
  152. package/src/wire/Land.ts +75 -0
  153. package/src/wire/Select.test.ts +148 -0
  154. package/src/wire/Select.ts +78 -0
  155. package/src/wire/Sessions.test.ts +59 -0
  156. package/src/wire/Sessions.ts +39 -0
  157. package/src/wire/WireSchema.test.ts +162 -0
  158. package/src/wire/constants.ts +22 -0
  159. package/src/wire/golden/1/beat-capture.json +34 -0
  160. package/src/wire/golden/1/beat-message.json +34 -0
  161. package/src/wire/golden/1/beat-prompt.json +44 -0
  162. package/src/wire/golden/1/beat-script.json +34 -0
  163. package/src/wire/golden/1/beat-stalled.json +34 -0
  164. package/src/wire/golden/1/judge.json +14 -0
  165. package/src/wire/golden/1/land-noop.json +10 -0
  166. package/src/wire/golden/1/land.json +10 -0
  167. package/src/wire/index.ts +12 -0
  168. package/src/wire/types.ts +46 -0
  169. package/src/workflow/Diagnostic.test.ts +93 -0
  170. package/src/workflow/Diagnostic.ts +54 -0
  171. package/src/workflow/SteeringFormats.test.ts +27 -0
  172. package/src/workflow/SteeringFormats.ts +6 -0
  173. package/src/workflow/Workflow.ts +77 -0
  174. package/src/workflow/compile.test.ts +53 -0
  175. package/src/workflow/compile.ts +56 -0
  176. package/src/workflow/discovery.ts +122 -0
  177. package/src/workflow/fingerprint.test.ts +52 -0
  178. package/src/workflow/fingerprint.ts +53 -0
  179. package/src/workflow/index.ts +33 -0
  180. package/src/workflow/load.test.ts +1017 -0
  181. package/src/workflow/load.ts +836 -0
  182. package/src/workflow/skills.test.ts +109 -0
  183. package/src/workflow/skills.ts +90 -0
  184. package/src/workflow/vars.ts +33 -0
  185. package/src/workflows/feature/feature.test.ts +27 -33
  186. package/src/workflows/feature/index.ts +10 -0
  187. package/src/workflows/fix/fix.feature +80 -0
  188. package/src/workflows/fix/fix.test.ts +58 -78
  189. package/src/workflows/fix/index.ts +6 -0
  190. package/src/workflows/index.ts +2 -10
  191. package/src/workflows/review/index.ts +6 -0
  192. package/src/workflows/review/review.test.ts +85 -29
  193. package/src/workflows/review/review.ts +3 -1
  194. package/src/workflows/shared/config.fixture.ts +12 -0
  195. package/src/workflows/shared/reserved.ts +11 -0
  196. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,887 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import fc from "fast-check"
3
+ import {
4
+ FLOWS_PROTOCOL,
5
+ agent,
6
+ changes,
7
+ changesSince,
8
+ codeThreads,
9
+ human,
10
+ judge,
11
+ read,
12
+ refuse,
13
+ restart,
14
+ run,
15
+ scope,
16
+ threads,
17
+ type Flow,
18
+ type ScopeAccess,
19
+ } from "../flows/index.js"
20
+ import type { Episode, ReplayOutcome } from "./Replay.js"
21
+ import {
22
+ formatCommitMessage,
23
+ replay,
24
+ treeFromRecord,
25
+ type JudgeVerdict,
26
+ type TreeView,
27
+ } from "./index.js"
28
+
29
+ // A tiny in-memory history: each `land` commits a tree and a message, the way
30
+ // a landing would, so tests read like the process they model.
31
+ interface Commit {
32
+ readonly hash: string
33
+ readonly message: string
34
+ readonly tree: TreeView
35
+ }
36
+
37
+ const hashOf = (n: number): string => n.toString(16).padStart(40, "0")
38
+
39
+ class History {
40
+ readonly commits: Commit[] = []
41
+ files: Record<string, string> = {}
42
+ private counter = 1
43
+
44
+ land(
45
+ actor: string,
46
+ from: string,
47
+ occurrence: number,
48
+ to: string,
49
+ edit: Record<string, string | null> = {},
50
+ judgeVerdicts?: readonly JudgeVerdict[],
51
+ ): this {
52
+ this.apply(edit)
53
+ this.commits.push({
54
+ hash: hashOf(this.counter++),
55
+ message: formatCommitMessage({
56
+ actor,
57
+ from,
58
+ to,
59
+ step: { name: from, occurrence },
60
+ ...(judgeVerdicts !== undefined ? { judge: judgeVerdicts } : {}),
61
+ }),
62
+ tree: treeFromRecord({ ...this.files }),
63
+ })
64
+ return this
65
+ }
66
+
67
+ raw(message: string, edit: Record<string, string | null> = {}): this {
68
+ this.apply(edit)
69
+ this.commits.push({
70
+ hash: hashOf(this.counter++),
71
+ message,
72
+ tree: treeFromRecord({ ...this.files }),
73
+ })
74
+ return this
75
+ }
76
+
77
+ private apply(edit: Record<string, string | null>): void {
78
+ const next = { ...this.files }
79
+ for (const [path, content] of Object.entries(edit)) {
80
+ if (content === null) delete next[path]
81
+ else next[path] = content
82
+ }
83
+ this.files = next
84
+ }
85
+
86
+ episode(): Episode {
87
+ return {
88
+ base: { hash: hashOf(0), tree: treeFromRecord({ "README.md": "# x\n" }) },
89
+ commits: this.commits,
90
+ }
91
+ }
92
+ }
93
+
94
+ const replayOf = (
95
+ wf: Flow,
96
+ h: History,
97
+ extra: { pending?: Record<string, string>; verdicts?: JudgeVerdict[] } = {},
98
+ ): Promise<ReplayOutcome> =>
99
+ replay({
100
+ flow: wf,
101
+ episode: h.episode(),
102
+ vars: { cap: "2" },
103
+ env: {},
104
+ start: hashOf(0),
105
+ budgetBytes: 1024,
106
+ ...(extra.pending !== undefined
107
+ ? {
108
+ pending: {
109
+ tree: treeFromRecord({ ...h.files, ...extra.pending }),
110
+ ...(extra.verdicts !== undefined ? { verdicts: extra.verdicts } : {}),
111
+ },
112
+ }
113
+ : {}),
114
+ })
115
+
116
+ const PROTOCOL_KEY = Symbol.for("@pmelab/gtd/flow-protocol")
117
+
118
+ describe("the flows protocol handshake", () => {
119
+ it("installs the protocol it implements under the registered key", async () => {
120
+ let seen: unknown
121
+ await replayOf(async () => {
122
+ seen = (globalThis as Record<symbol, unknown>)[PROTOCOL_KEY]
123
+ await human("idle")
124
+ }, new History())
125
+ expect(seen).toBe(FLOWS_PROTOCOL)
126
+ })
127
+
128
+ it("refuses a mismatch the workflow caught and swallowed", async () => {
129
+ const outcome = await replayOf(async () => {
130
+ ;(globalThis as Record<symbol, unknown>)[PROTOCOL_KEY] = FLOWS_PROTOCOL + 1
131
+ await human("idle").catch(() => undefined)
132
+ }, new History())
133
+ expect(outcome).toEqual({
134
+ kind: "failed",
135
+ message: `gtd: the workflow speaks flows protocol ${FLOWS_PROTOCOL}, but the engine installed protocol ${FLOWS_PROTOCOL + 1} — upgrade the @pmelab/gtd the workflow imports`,
136
+ })
137
+ })
138
+
139
+ it("frames an ordinary workflow error as the workflow throwing", async () => {
140
+ const outcome = await replayOf(async () => {
141
+ throw new Error("boom")
142
+ }, new History())
143
+ expect(outcome).toEqual({ kind: "failed", message: "gtd: the workflow threw: boom" })
144
+ })
145
+
146
+ it("unwraps a mismatch thrown by a second copy of the flows facade", async () => {
147
+ // A workflow package resolving its own @pmelab/gtd throws that copy's
148
+ // class, which `instanceof` against the engine's copy never matches.
149
+ class FlowsProtocolError extends Error {
150
+ override name = "FlowsProtocolError"
151
+ }
152
+ const outcome = await replayOf(async () => {
153
+ throw new FlowsProtocolError("gtd: the workflow speaks flows protocol 0")
154
+ }, new History())
155
+ expect(outcome).toEqual({
156
+ kind: "failed",
157
+ message: "gtd: the workflow speaks flows protocol 0",
158
+ })
159
+ })
160
+ })
161
+
162
+ const restName = (outcome: ReplayOutcome): string => {
163
+ if (outcome.kind !== "rest") throw new Error(`expected a rest, got ${JSON.stringify(outcome)}`)
164
+ return `${outcome.rest.name}#${outcome.rest.id.occurrence}`
165
+ }
166
+
167
+ // Plain loops, a counter cap, and helper-driven branches: the shape the
168
+ // bundled workflow has.
169
+ const fixLoop = async () => {
170
+ await human("idle", { file: "TODO.md" })
171
+ for (let attempt = 1; ; attempt++) {
172
+ await run("check", "npm test")
173
+ if (read(".gtd/FEEDBACK.md") === undefined) break
174
+ if (attempt > 2) {
175
+ await human("stuck")
176
+ continue
177
+ }
178
+ await agent("fix", "fix it")
179
+ }
180
+ await human("done")
181
+ }
182
+
183
+ describe("replay", () => {
184
+ it("rests at the first step of an empty episode", async () => {
185
+ expect(restName(await replayOf(fixLoop, new History()))).toBe("idle#1")
186
+ })
187
+
188
+ it("answers each step from its commit and rests at the first step without one", async () => {
189
+ const h = new History()
190
+ .land("human", "idle", 1, "check", { "TODO.md": "go" })
191
+ .land("check", "check", 1, "fix", { ".gtd/FEEDBACK.md": "red" })
192
+ expect(restName(await replayOf(fixLoop, h))).toBe("fix#1")
193
+ })
194
+
195
+ it("branches on the tree the step committed", async () => {
196
+ const h = new History()
197
+ .land("human", "idle", 1, "check", { "TODO.md": "go" })
198
+ .land("check", "check", 1, "done", {})
199
+ expect(restName(await replayOf(fixLoop, h))).toBe("done#1")
200
+ })
201
+
202
+ it("counts loop rounds with an ordinary counter", async () => {
203
+ const h = new History().land("human", "idle", 1, "check", { "TODO.md": "go" })
204
+ for (let round = 1; round <= 2; round++) {
205
+ h.land("check", "check", round, "fix", { ".gtd/FEEDBACK.md": `red ${round}` })
206
+ h.land("agent", "fix", round, "check", { "src/a.ts": `${round}` })
207
+ }
208
+ h.land("check", "check", 3, "stuck", { ".gtd/FEEDBACK.md": "red 3" })
209
+ expect(restName(await replayOf(fixLoop, h))).toBe("stuck#1")
210
+ })
211
+
212
+ it("is deterministic: the same history always yields the same rest", async () => {
213
+ await fc.assert(
214
+ fc.asyncProperty(fc.array(fc.boolean(), { maxLength: 6 }), async (reds) => {
215
+ const h = new History().land("human", "idle", 1, "check", { "TODO.md": "go" })
216
+ let checks = 0
217
+ let fixes = 0
218
+ for (const red of reds) {
219
+ checks++
220
+ if (!red) {
221
+ h.land("check", "check", checks, "done", { ".gtd/FEEDBACK.md": null })
222
+ break
223
+ }
224
+ const to = checks > 2 ? "stuck" : "fix"
225
+ h.land("check", "check", checks, to, { ".gtd/FEEDBACK.md": `red ${checks}` })
226
+ if (to === "stuck") {
227
+ h.land("human", "stuck", checks - 2, "check")
228
+ } else {
229
+ fixes++
230
+ h.land("agent", "fix", fixes, "check", { "src/a.ts": `${fixes}` })
231
+ }
232
+ }
233
+ const first = await replayOf(fixLoop, h)
234
+ const second = await replayOf(fixLoop, h)
235
+ expect(first.kind).not.toBe("divergence")
236
+ expect(JSON.stringify(second)).toBe(JSON.stringify(first))
237
+ }),
238
+ { numRuns: 40 },
239
+ )
240
+ })
241
+
242
+ it("threads() gives a flow each thread's name, 1-based line and waitingOn", async () => {
243
+ const doc = "a.[^q]\n\n[^q]:\n - H: why?\n - A: because\n"
244
+ const flow = async () => {
245
+ await human("idle")
246
+ const open = threads(read("NOTES.md") ?? "").filter((t) => t.waitingOn === "human")
247
+ await human(
248
+ open.length === 1 && open[0]!.name === "q" && open[0]!.line === 3 ? "open" : "none",
249
+ )
250
+ }
251
+ const h = new History().land("human", "idle", 1, "open", { "NOTES.md": doc })
252
+ expect(restName(await replayOf(flow, h))).toBe("open#1")
253
+ })
254
+
255
+ it("threads() carries each thread's own syntax faults, prefixed with its name", async () => {
256
+ const doc = "a.[^q] b.[^r]\n\n[^q]:\n - H: why?\n - H: again\n\n[^r]:\n - H: ok\n"
257
+ const flow = async () => {
258
+ await human("idle")
259
+ const [q, r] = threads(read("NOTES.md") ?? "")
260
+ const ok =
261
+ q!.faults.length === 1 &&
262
+ q!.faults[0]!.startsWith('Footnote thread "[^q]": two consecutive') &&
263
+ r!.faults.length === 0
264
+ await human(ok ? "open" : "none")
265
+ }
266
+ const h = new History().land("human", "idle", 1, "open", { "NOTES.md": doc })
267
+ expect(restName(await replayOf(flow, h))).toBe("open#1")
268
+ })
269
+
270
+ it("codeThreads() returns threads of files changed since the process base only", async () => {
271
+ const thread = ["// H: why?", "// A: because"].join("\n")
272
+ const open = ["x", "// H: ask"].join("\n")
273
+ const flow = async () => {
274
+ await human("idle")
275
+ const found = codeThreads()
276
+ const ok =
277
+ found.length === 1 &&
278
+ found[0]!.path === "src/new.ts" &&
279
+ found[0]!.line === 2 &&
280
+ found[0]!.first === "ask" &&
281
+ found[0]!.waitingOn === "agent"
282
+ await human(ok ? "open" : "none")
283
+ }
284
+ const h = new History().land("human", "idle", 1, "open", { "src/new.ts": open })
285
+ h.files = { ...h.files, "src/old.ts": thread }
286
+ const episode = h.episode()
287
+ const withBase: Episode = {
288
+ ...episode,
289
+ base: {
290
+ ...episode.base,
291
+ tree: treeFromRecord({ "src/old.ts": thread, "src/gone.ts": open }),
292
+ },
293
+ commits: [
294
+ {
295
+ ...episode.commits[0]!,
296
+ tree: treeFromRecord({ "src/old.ts": thread, "src/new.ts": open }),
297
+ },
298
+ ],
299
+ }
300
+ const outcome = await replay({
301
+ flow,
302
+ episode: withBase,
303
+ vars: {},
304
+ env: {},
305
+ start: hashOf(0),
306
+ budgetBytes: 1024,
307
+ })
308
+ expect(restName(outcome)).toBe("open#1")
309
+ })
310
+
311
+ it("codeThreads() reaches back to a diff base older than the episode base", async () => {
312
+ // An entered process: its review base predates the opening commit, which
313
+ // is the episode base — so the opening commit's own edits still count.
314
+ const open = ["x", "// H: ask"].join("\n")
315
+ const flow = async () => {
316
+ await human("idle")
317
+ await human(codeThreads().length === 1 ? "open" : "none")
318
+ }
319
+ const h = new History()
320
+ h.files = { "src/a.ts": open }
321
+ h.land("human", "idle", 1, "open", { "TODO.md": "go" })
322
+ const episode = h.episode()
323
+ const outcome = await replay({
324
+ flow,
325
+ episode: {
326
+ ...episode,
327
+ base: { hash: hashOf(99), tree: treeFromRecord({ "src/a.ts": open }) },
328
+ },
329
+ vars: {},
330
+ env: {},
331
+ start: hashOf(98),
332
+ startTree: treeFromRecord({}),
333
+ budgetBytes: 1024,
334
+ })
335
+ expect(restName(outcome)).toBe("open#1")
336
+ })
337
+
338
+ describe("divergence", () => {
339
+ it("is reported when a commit records a different step than replay reaches", async () => {
340
+ const h = new History().land("human", "idle", 1, "check", { "TODO.md": "go" })
341
+ h.land("check", "fix", 1, "check", { ".gtd/FEEDBACK.md": "red" })
342
+ const outcome = await replayOf(fixLoop, h)
343
+ expect(outcome.kind).toBe("divergence")
344
+ expect(outcome.kind === "divergence" && outcome.message).toContain('expected "check#1"')
345
+ })
346
+
347
+ it("is reported when a step commit carries no Gtd-Step trailer", async () => {
348
+ const h = new History().raw("gtd(human): idle → check", { "TODO.md": "go" })
349
+ const outcome = await replayOf(fixLoop, h)
350
+ expect(outcome.kind).toBe("divergence")
351
+ expect(outcome.kind === "divergence" && outcome.message).toContain("records no step")
352
+ })
353
+
354
+ it("is reported when the subject names a different next step than replay reaches", async () => {
355
+ const h = new History().land("human", "idle", 1, "fix", { "TODO.md": "go" })
356
+ const outcome = await replayOf(fixLoop, h)
357
+ expect(outcome.kind).toBe("divergence")
358
+ expect(outcome.kind === "divergence" && outcome.message).toContain('reached "check"')
359
+ })
360
+
361
+ it("is reported when history continues past the end of the flow", async () => {
362
+ const short = async () => void (await human("only"))
363
+ const h = new History().land("human", "only", 1, "only").land("human", "only", 2, "only")
364
+ expect((await replayOf(short, h)).kind).toBe("divergence")
365
+ })
366
+ })
367
+
368
+ it("skips an attempt — an empty, trailer-less self-loop at the resting step", async () => {
369
+ const h = new History()
370
+ .land("human", "idle", 1, "check", { "TODO.md": "go" })
371
+ .land("check", "check", 1, "fix", { ".gtd/FEEDBACK.md": "red" })
372
+ .raw("gtd(agent): fix")
373
+ expect(restName(await replayOf(fixLoop, h))).toBe("fix#1")
374
+ })
375
+
376
+ it("lands a pending turn on the resting step and rests at the next one", async () => {
377
+ const h = new History().land("human", "idle", 1, "check", { "TODO.md": "go" })
378
+ const outcome = await replayOf(fixLoop, h, { pending: { ".gtd/FEEDBACK.md": "red" } })
379
+ expect(outcome.kind === "rest" && outcome.landed?.name).toBe("check")
380
+ expect(restName(outcome)).toBe("fix#1")
381
+ })
382
+
383
+ it("reads helpers against the replay position, not the working tree", async () => {
384
+ const wf = async () => {
385
+ await human("write")
386
+ if (changes(".gtd/*.md").some((c) => c.status === "added"))
387
+ await agent("saw", read(".gtd/NOTE.md") ?? "")
388
+ else await agent("missed", "nothing")
389
+ }
390
+ const h = new History().land("human", "write", 1, "saw", { ".gtd/NOTE.md": "hello" })
391
+ const outcome = await replayOf(wf, h)
392
+ expect(restName(outcome)).toBe("saw#1")
393
+ expect(
394
+ outcome.kind === "rest" &&
395
+ outcome.rest.request.kind === "agent" &&
396
+ outcome.rest.request.prompt,
397
+ ).toBe("hello")
398
+ })
399
+
400
+ it("reports what the last step changed, with content on both sides", async () => {
401
+ let seen: unknown
402
+ const wf = async () => {
403
+ for (;;) {
404
+ await human("write")
405
+ seen = changes().map(({ path, status, before, after }) => ({ path, status, before, after }))
406
+ }
407
+ }
408
+ const h = new History()
409
+ .land("human", "write", 1, "write", { keep: "old", gone: "bye" })
410
+ .land("human", "write", 2, "write", { keep: "new", gone: null, fresh: "hi" })
411
+ await replayOf(wf, h)
412
+ expect(seen).toEqual([
413
+ { path: "fresh", status: "added", before: undefined, after: "hi" },
414
+ { path: "gone", status: "deleted", before: "bye", after: undefined },
415
+ { path: "keep", status: "modified", before: "old", after: "new" },
416
+ ])
417
+ })
418
+
419
+ it("changesSince(hash) diffs the tree at hash to the tree replay stands on", async () => {
420
+ let seen: unknown
421
+ const wf = async () => {
422
+ const since = hashOf(1)
423
+ for (;;) {
424
+ await human("write")
425
+ seen = changesSince(since).map(({ path, status }) => ({ path, status }))
426
+ }
427
+ }
428
+ const h = new History()
429
+ .land("human", "write", 1, "write", { a: "1" })
430
+ .land("human", "write", 2, "write", { b: "1" })
431
+ .land("human", "write", 3, "write", { c: "1" })
432
+ await replayOf(wf, h)
433
+ expect(seen).toEqual([
434
+ { path: "b", status: "added" },
435
+ { path: "c", status: "added" },
436
+ ])
437
+ })
438
+
439
+ it("changesSince(hash) resolves against the episode base, and is empty over a zero-length range", async () => {
440
+ let seenFromBase: unknown
441
+ let seenFromLast: unknown
442
+ const wf = async () => {
443
+ for (;;) {
444
+ await human("write")
445
+ seenFromBase = changesSince(hashOf(0)).paths
446
+ seenFromLast = changesSince(hashOf(2)).paths
447
+ }
448
+ }
449
+ const h = new History()
450
+ .land("human", "write", 1, "write", { a: "1" })
451
+ .land("human", "write", 2, "write", { b: "1" })
452
+ await replayOf(wf, h)
453
+ expect(seenFromBase).toEqual(["a", "b", "README.md"])
454
+ expect(seenFromLast).toEqual([])
455
+ })
456
+
457
+ // A squash or rebase never strands a captured hash: `since` is never
458
+ // persisted, and every replay re-derives its own commit list from whatever
459
+ // history exists at that moment (see the comment at `treeAt`). What DOES
460
+ // reach the guard is a hash from outside that run entirely — read out of
461
+ // state, from another branch, or otherwise fabricated. This case models
462
+ // that: a hash real in one episode, handed to a second, unrelated episode
463
+ // that never held it.
464
+ it("fails the step when changesSince is given a hash that resolved in an earlier episode but not this one", async () => {
465
+ const hash = hashOf(2)
466
+ const wf = async () => {
467
+ for (;;) {
468
+ await human("write")
469
+ changesSince(hash)
470
+ }
471
+ }
472
+ // First: prove `hash` really does resolve — a two-commit episode where it
473
+ // names the second commit.
474
+ const resolvable = new History()
475
+ .land("human", "write", 1, "write", { a: "1" })
476
+ .land("human", "write", 2, "write", { b: "1" })
477
+ const resolved = await replayOf(wf, resolvable)
478
+ expect(resolved.kind).not.toBe("failed")
479
+
480
+ // Then: a squash collapses those two commits into one, so the same hash
481
+ // no longer names anything in this episode's commit list.
482
+ const squashed = new History().land("human", "write", 1, "write", { a: "1", b: "1" })
483
+ const outcome = await replayOf(wf, squashed)
484
+ expect(outcome.kind).toBe("failed")
485
+ expect(outcome.kind === "failed" && outcome.message).toContain(
486
+ `changesSince(${hash}): ${hash} is not the episode base or one of its commits`,
487
+ )
488
+ expect(outcome.kind === "failed" && outcome.message).toContain("pass a hash this run read from")
489
+ })
490
+
491
+ it("keeps a local variable across replayed steps", async () => {
492
+ let seen: string | undefined = "unset"
493
+ const wf = async () => {
494
+ let previous: string | undefined
495
+ for (;;) {
496
+ await run("check", "true")
497
+ seen = previous
498
+ previous = read("OUT")
499
+ await human("look")
500
+ }
501
+ }
502
+ const h = new History()
503
+ .land("check", "check", 1, "look", { OUT: "first" })
504
+ .land("human", "look", 1, "check")
505
+ .land("check", "check", 2, "look", { OUT: "second" })
506
+ await replayOf(wf, h)
507
+ expect(seen).toBe("first")
508
+ })
509
+
510
+ it("answers a judge step from its Gtd-Judge trailer", async () => {
511
+ const question = { id: "verdict", primitive: "choice", instructions: "", criteria: "" } as const
512
+ const wf = async () => {
513
+ const { answers } = await judge("same", { questions: [question], evidence: {} })
514
+ const verdict = answers.verdict
515
+ if (verdict?.answer === "identical" && verdict.p >= 0.7) await human("escalate")
516
+ else await human("retry")
517
+ }
518
+ const confident = new History().land("judge", "same", 1, "escalate", {}, [
519
+ { id: "verdict", answer: "identical", p: 0.9 },
520
+ ])
521
+ expect(restName(await replayOf(wf, confident))).toBe("escalate#1")
522
+ const unsure = new History().land("judge", "same", 1, "retry", {}, [
523
+ { id: "verdict", answer: "identical", p: 0.5 },
524
+ ])
525
+ expect(restName(await replayOf(wf, unsure))).toBe("retry#1")
526
+ const skipped = new History().land("judge", "same", 1, "retry")
527
+ expect(restName(await replayOf(wf, skipped))).toBe("retry#1")
528
+ })
529
+
530
+ it("prefixes names inside scope() and derives the memory scope from the name", async () => {
531
+ const wf = () => scope("build", () => scope("health", () => agent("fix", "x")))
532
+ const outcome = await replayOf(wf, new History())
533
+ expect(restName(outcome)).toBe("build.health.fix#1")
534
+ expect(outcome.kind === "rest" && outcome.rest.memoryScope).toBe("build.health")
535
+ })
536
+
537
+ it("gives a scope's model to the agent steps inside, and refuses two identities in one scope", async () => {
538
+ const wf = () =>
539
+ scope({ model: "smart" }, async () => {
540
+ await agent("one", "a")
541
+ await scope({ name: "plan", model: "base" }, () => agent("two", "b"))
542
+ await agent("three", "c", { model: "base" })
543
+ })
544
+ const modelAt = (outcome: ReplayOutcome) =>
545
+ outcome.kind === "rest" &&
546
+ outcome.rest.request.kind === "agent" &&
547
+ outcome.rest.request.options.model
548
+ expect(modelAt(await replayOf(wf, new History()))).toBe("smart")
549
+ const h = new History().land("agent", "one", 1, "plan.two", { a: "1" })
550
+ expect(modelAt(await replayOf(wf, h))).toBe("base")
551
+ h.land("agent", "plan.two", 1, "three", { b: "1" })
552
+ expect((await replayOf(wf, h)).kind).toBe("failed")
553
+ })
554
+
555
+ it("ends the episode on return and on restart", async () => {
556
+ const returning = async () => void (await human("only"))
557
+ const h = new History().land("human", "only", 1, "only")
558
+ expect(await replayOf(returning, h)).toMatchObject({ kind: "ended", via: "return" })
559
+
560
+ const restarting = async () => {
561
+ await human("first")
562
+ await restart()
563
+ await human("never")
564
+ }
565
+ const r = new History().land("human", "first", 1, "first")
566
+ expect(await replayOf(restarting, r)).toMatchObject({ kind: "ended", via: "restart" })
567
+ })
568
+
569
+ it("reports refuse() as a refusal and a throw as a failure", async () => {
570
+ const refusing = async () => {
571
+ await human("gate")
572
+ if (read("bad") !== undefined) refuse("no bad files")
573
+ await human("next")
574
+ }
575
+ const outcome = await replayOf(refusing, new History(), { pending: { bad: "x" } })
576
+ expect(outcome).toEqual({ kind: "refused", message: "no bad files" })
577
+
578
+ const throwing = async () => {
579
+ throw new Error("boom")
580
+ }
581
+ expect(await replayOf(throwing, new History())).toMatchObject({ kind: "failed" })
582
+ })
583
+
584
+ it("fails a run step whose body is not a shell script", async () => {
585
+ const wf = async () => {
586
+ await run("check", (() => undefined) as unknown as string)
587
+ }
588
+ const outcome = await replayOf(wf, new History())
589
+ expect(outcome).toMatchObject({ kind: "failed" })
590
+ expect(outcome.kind === "failed" && outcome.message).toContain("shell script string")
591
+ })
592
+
593
+ it("fails a flow that awaits something other than a step", async () => {
594
+ const waiting = async () => {
595
+ await new Promise((resolve) => setTimeout(resolve, 50))
596
+ await human("late")
597
+ }
598
+ const outcome = await replayOf(waiting, new History())
599
+ expect(outcome.kind === "failed" && outcome.message).toContain("not a step")
600
+ })
601
+ })
602
+
603
+ describe("replay: skills resolution", () => {
604
+ interface Tiers {
605
+ skills?: Record<string, readonly string[]>
606
+ configuredSkills?: Record<string, readonly string[]>
607
+ }
608
+
609
+ const restOf = async (flow: Flow, tiers: Tiers = {}) => {
610
+ const outcome = await replay({
611
+ flow,
612
+ episode: new History().episode(),
613
+ vars: {},
614
+ env: {},
615
+ start: hashOf(0),
616
+ budgetBytes: 1024,
617
+ ...tiers,
618
+ })
619
+ return outcome
620
+ }
621
+
622
+ const wireSkills = async (flow: Flow, tiers: Tiers = {}) => {
623
+ const outcome = await restOf(flow, tiers)
624
+ if (outcome.kind !== "rest") throw new Error(`expected a rest, got ${JSON.stringify(outcome)}`)
625
+ return outcome.rest.skills
626
+ }
627
+
628
+ type Body = () => Promise<void>
629
+ const inScope =
630
+ (options: { name: string; skills?: readonly string[] }, ...rest: Body[]): Flow =>
631
+ async () => {
632
+ await scope(options, async () => {
633
+ for (const f of rest) await f()
634
+ })
635
+ }
636
+ const step: Body = async () => {
637
+ await agent("step", "prompt")
638
+ }
639
+
640
+ it("rejects a skills option on agent()", async () => {
641
+ const outcome = await restOf(async () => {
642
+ await agent("step", "prompt", { skills: ["own"] } as never)
643
+ })
644
+ expect(outcome).toMatchObject({
645
+ kind: "failed",
646
+ message: 'gtd: step "step": unknown key(s) skills in agent() options',
647
+ })
648
+ })
649
+
650
+ it("reads the list a scope() option declares", async () => {
651
+ expect(await wireSkills(inScope({ name: "a", skills: ["own"] }, step))).toEqual(["own"])
652
+ })
653
+
654
+ it("inherits a parent scope's list in a nested scope", async () => {
655
+ const flow = inScope({ name: "a", skills: ["parent"] }, async () => {
656
+ await scope("b", step)
657
+ })
658
+ expect(await wireSkills(flow)).toEqual(["parent"])
659
+ })
660
+
661
+ it("replaces the parent's list wholesale when a nested scope sets its own", async () => {
662
+ const flow = inScope({ name: "a", skills: ["parent"] }, async () => {
663
+ await scope({ name: "b", skills: ["child"] }, step)
664
+ })
665
+ expect(await wireSkills(flow)).toEqual(["child"])
666
+ })
667
+
668
+ it("falls back to the bundled export keyed by scope, walking up prefixes", async () => {
669
+ const flow = inScope({ name: "a" }, async () => {
670
+ await scope("b", step)
671
+ })
672
+ expect(await wireSkills(flow, { skills: { a: ["bundled"] } })).toEqual(["bundled"])
673
+ expect(await wireSkills(flow, { skills: { a: ["x"], "a.b": ["inner"] } })).toEqual(["inner"])
674
+ })
675
+
676
+ it("resolves an implicit dotted scope from its own export entry", async () => {
677
+ const flow = inScope({ name: "a" }, async () => {
678
+ await agent("review.reviewing", "prompt")
679
+ })
680
+ expect(await wireSkills(flow, { skills: { a: ["x"], "a.review": ["own"] } })).toEqual(["own"])
681
+ })
682
+
683
+ it("ranks the config export above the scope() option above the export", async () => {
684
+ const flow = inScope({ name: "a", skills: ["option"] }, step)
685
+ expect(await wireSkills(flow, { skills: { a: ["bundled"] } })).toEqual(["option"])
686
+ expect(
687
+ await wireSkills(flow, { skills: { a: ["bundled"] }, configuredSkills: { a: ["rc"] } }),
688
+ ).toEqual(["rc"])
689
+ })
690
+
691
+ it("lets a parent config entry reach a nested scope with no list, but not one with its own", async () => {
692
+ const bare = inScope({ name: "a" }, async () => {
693
+ await scope("b", step)
694
+ })
695
+ const own = inScope({ name: "a" }, async () => {
696
+ await scope({ name: "b", skills: ["child"] }, step)
697
+ })
698
+ expect(await wireSkills(bare, { configuredSkills: { a: ["rc"] } })).toEqual(["rc"])
699
+ expect(await wireSkills(own, { configuredSkills: { a: ["rc"] } })).toEqual(["child"])
700
+ })
701
+
702
+ it("leaves an empty resolved list absent, never []", async () => {
703
+ const flow = inScope({ name: "a", skills: ["option"] }, step)
704
+ expect(await wireSkills(flow, { configuredSkills: { a: [] } })).toBeUndefined()
705
+ expect(await wireSkills(inScope({ name: "a" }, step), { skills: { a: [] } })).toBeUndefined()
706
+ })
707
+
708
+ it("fails two agent steps in one memory scope with different lists", async () => {
709
+ const flow: Flow = async () => {
710
+ await scope({ name: "a", skills: ["one"] }, async () => {
711
+ await agent("first", "prompt")
712
+ })
713
+ await scope({ name: "a", skills: ["two"] }, async () => {
714
+ await agent("second", "prompt")
715
+ })
716
+ }
717
+ const history = new History()
718
+ history.land("agent", "a.first", 1, "a.second", { "x.txt": "x" })
719
+ const outcome = await replay({
720
+ flow,
721
+ episode: history.episode(),
722
+ vars: {},
723
+ env: {},
724
+ start: hashOf(0),
725
+ budgetBytes: 1024,
726
+ })
727
+ expect(outcome.kind === "failed" && outcome.message).toContain(
728
+ "different model, system prompt, skills or file access",
729
+ )
730
+ })
731
+ })
732
+
733
+ describe("replay: access resolution", () => {
734
+ interface Tiers {
735
+ access?: Record<string, ScopeAccess>
736
+ configuredAccess?: Record<string, ScopeAccess>
737
+ }
738
+
739
+ const restOf = (flow: Flow, tiers: Tiers = {}, history = new History()) =>
740
+ replay({
741
+ flow,
742
+ episode: history.episode(),
743
+ vars: {},
744
+ env: {},
745
+ start: hashOf(0),
746
+ budgetBytes: 1024,
747
+ ...tiers,
748
+ })
749
+
750
+ const wireAccess = async (flow: Flow, tiers: Tiers = {}) => {
751
+ const outcome = await restOf(flow, tiers)
752
+ if (outcome.kind !== "rest") throw new Error(`expected a rest, got ${JSON.stringify(outcome)}`)
753
+ return outcome.rest.access
754
+ }
755
+
756
+ const step = async () => {
757
+ await agent("step", "prompt")
758
+ }
759
+
760
+ it("is unrestricted when nothing declares access", async () => {
761
+ expect(await wireAccess(async () => step())).toEqual({ read: null, write: null })
762
+ })
763
+
764
+ it("rejects an access option on agent()", async () => {
765
+ const outcome = await restOf(async () => {
766
+ await agent("step", "prompt", { access: { write: [] } } as never)
767
+ })
768
+ expect(outcome).toMatchObject({
769
+ kind: "failed",
770
+ message: 'gtd: step "step": unknown key(s) access in agent() options',
771
+ })
772
+ })
773
+
774
+ it("reads a scope() access, adding the step's steering file to each restricted side", async () => {
775
+ const flow: Flow = async () => {
776
+ await scope({ name: "a", access: { read: ["docs/**"] } }, async () => {
777
+ await agent("step", "prompt", { file: ".gtd/NOTES.md" })
778
+ })
779
+ }
780
+ expect(await wireAccess(flow)).toEqual({
781
+ read: ["docs/**", ".gtd/NOTES.md"],
782
+ write: null,
783
+ })
784
+ })
785
+
786
+ it("keeps read and write independent: write grants no read", async () => {
787
+ const flow: Flow = async () => {
788
+ await scope({ name: "a", access: { write: ["src/**"] } }, step)
789
+ }
790
+ expect(await wireAccess(flow)).toEqual({ read: null, write: ["src/**"] })
791
+ const readOnly: Flow = async () => {
792
+ await scope({ name: "a", access: { read: ["docs/**"], write: ["src/**"] } }, step)
793
+ }
794
+ expect((await wireAccess(readOnly))?.read).toEqual(["docs/**"])
795
+ })
796
+
797
+ it("inherits a parent's access, replaces it wholesale, and reopens with {}", async () => {
798
+ const inherit: Flow = async () => {
799
+ await scope({ name: "a", access: { write: ["p/**"] } }, () => scope("b", step))
800
+ }
801
+ expect(await wireAccess(inherit)).toEqual({ read: null, write: ["p/**"] })
802
+ const replace: Flow = async () => {
803
+ await scope({ name: "a", access: { write: ["p/**"] } }, () =>
804
+ scope({ name: "b", access: { read: ["c/**"] } }, step),
805
+ )
806
+ }
807
+ expect(await wireAccess(replace)).toEqual({ read: ["c/**"], write: null })
808
+ const reopen: Flow = async () => {
809
+ await scope({ name: "a", access: { write: ["p/**"] } }, () =>
810
+ scope({ name: "b", access: {} }, step),
811
+ )
812
+ }
813
+ expect(await wireAccess(reopen)).toEqual({ read: null, write: null })
814
+ })
815
+
816
+ it("ranks the config export above the scope() option above the export", async () => {
817
+ const flow: Flow = async () => {
818
+ await scope({ name: "a", access: { write: ["option"] } }, step)
819
+ }
820
+ expect((await wireAccess(flow, { access: { a: { write: ["bundled"] } } }))?.write).toEqual([
821
+ "option",
822
+ ])
823
+ expect(
824
+ (
825
+ await wireAccess(flow, {
826
+ access: { a: { write: ["bundled"] } },
827
+ configuredAccess: { a: { write: ["rc"] } },
828
+ })
829
+ )?.write,
830
+ ).toEqual(["rc"])
831
+ const bare: Flow = async () => {
832
+ await scope("a", () => scope("b", step))
833
+ }
834
+ expect((await wireAccess(bare, { configuredAccess: { a: { write: ["rc"] } } }))?.write).toEqual(
835
+ ["rc"],
836
+ )
837
+ })
838
+
839
+ it("adds a code thread waiting on the agent to the restricted sides", async () => {
840
+ const history = new History().land("human", "idle", 1, "a.step", {
841
+ "src/a.ts": "const a = 1\n// H: fix this\n",
842
+ })
843
+ const flow: Flow = async () => {
844
+ await human("idle")
845
+ await scope({ name: "a", access: { write: [] } }, step)
846
+ }
847
+ const outcome = await restOf(flow, {}, history)
848
+ if (outcome.kind !== "rest") throw new Error("expected a rest")
849
+ expect(outcome.rest.access).toEqual({ read: null, write: ["src/a.ts"] })
850
+ })
851
+
852
+ it("fails two agent steps in one memory scope with different scope access", async () => {
853
+ const flow: Flow = async () => {
854
+ await scope({ name: "a", access: { write: ["one"] } }, () => agent("first", "prompt"))
855
+ await scope({ name: "a", access: { write: ["two"] } }, () => agent("second", "prompt"))
856
+ }
857
+ const history = new History()
858
+ history.land("agent", "a.first", 1, "a.second", { "x.txt": "x" })
859
+ const outcome = await restOf(flow, {}, history)
860
+ expect(outcome.kind === "failed" && outcome.message).toContain(
861
+ "different model, system prompt, skills or file access",
862
+ )
863
+ })
864
+
865
+ it("does not conflict two steps that differ only in steering file", async () => {
866
+ const flow: Flow = async () => {
867
+ await scope({ name: "a", access: { write: [] } }, async () => {
868
+ await agent("first", "prompt", { file: ".gtd/ONE.md" })
869
+ await agent("second", "prompt", { file: ".gtd/TWO.md" })
870
+ })
871
+ }
872
+ const history = new History()
873
+ history.land("agent", "a.first", 1, "a.second", { "x.txt": "x" })
874
+ const outcome = await restOf(flow, {}, history)
875
+ expect(outcome.kind).toBe("rest")
876
+ if (outcome.kind === "rest") expect(outcome.rest.access?.write).toEqual([".gtd/TWO.md"])
877
+ })
878
+
879
+ it("fails a malformed scope() access, naming the scope", async () => {
880
+ const flow: Flow = async () => {
881
+ await scope({ name: "a", access: { write: "src/**" } as never }, () => scope("b", step))
882
+ }
883
+ const outcome = await restOf(flow)
884
+ expect(outcome).toMatchObject({ kind: "failed" })
885
+ expect(outcome.kind === "failed" && outcome.message).toContain('scope "a"')
886
+ })
887
+ })