@pmelab/gtd 24.0.0 → 24.2.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 (203) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -13
  3. package/claude/hooks/access.ts +26 -1
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  12. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  13. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  14. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  15. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  16. package/dist/flows.mjs +525 -0
  17. package/dist/gtd.bundle.mjs +43339 -55575
  18. package/dist/steering.mjs +2 -0
  19. package/dist/testkit.mjs +13006 -0
  20. package/dist/wire.mjs +2 -0
  21. package/dist/workflow-kit.mjs +352 -0
  22. package/dist/workflows/feature.mjs +315 -0
  23. package/dist/workflows/fix.mjs +40 -0
  24. package/dist/workflows/review.mjs +25 -0
  25. package/dist/workflows.mjs +8 -6
  26. package/package.json +69 -8
  27. package/src/cli/Cli.test.ts +1329 -0
  28. package/src/cli/Cli.ts +1348 -0
  29. package/src/cli/Program.test.ts +2262 -0
  30. package/src/cli/Program.ts +1331 -0
  31. package/src/cli/ToolCommands.ts +43 -0
  32. package/src/cli/index.ts +14 -0
  33. package/src/edge/Edge.test.ts +1016 -0
  34. package/src/edge/Edge.ts +1225 -0
  35. package/src/edge/index.ts +16 -0
  36. package/src/emit/Emit.test.ts +425 -0
  37. package/src/emit/Emit.ts +139 -0
  38. package/src/emit/GitScript.test.ts +260 -0
  39. package/src/emit/GitScript.ts +131 -0
  40. package/src/emit/ModeContradiction.test.ts +120 -0
  41. package/src/emit/ModeContradiction.ts +66 -0
  42. package/src/emit/OutcomeScript.test.ts +238 -0
  43. package/src/emit/OutcomeScript.ts +73 -0
  44. package/src/emit/SteeringMode.test.ts +274 -0
  45. package/src/emit/SteeringMode.ts +256 -0
  46. package/src/emit/index.ts +40 -0
  47. package/src/install/ConfigScaffold.ts +19 -0
  48. package/src/install/Install.test.ts +426 -0
  49. package/src/install/Install.ts +634 -0
  50. package/src/install/Version.ts +27 -0
  51. package/src/install/index.ts +3 -0
  52. package/src/platform/CommandRunner.ts +74 -0
  53. package/src/platform/Commentary.test.ts +126 -0
  54. package/src/platform/Commentary.ts +92 -0
  55. package/src/platform/Git.test.ts +50 -0
  56. package/src/platform/Git.ts +552 -0
  57. package/src/platform/Host.ts +42 -0
  58. package/src/platform/RetainedHistory.test.ts +65 -0
  59. package/src/platform/RetainedHistory.ts +34 -0
  60. package/src/platform/Workspace.test.ts +669 -0
  61. package/src/platform/Workspace.ts +631 -0
  62. package/src/platform/WorktreeState.test.ts +162 -0
  63. package/src/platform/WorktreeState.ts +50 -0
  64. package/src/platform/index.ts +22 -0
  65. package/src/replay/Access.test.ts +53 -0
  66. package/src/replay/Access.ts +46 -0
  67. package/src/replay/Format.test.ts +20 -0
  68. package/src/replay/Format.ts +14 -0
  69. package/src/replay/Replay.test.ts +887 -0
  70. package/src/replay/Replay.ts +669 -0
  71. package/src/replay/Trailers.test.ts +117 -0
  72. package/src/replay/Trailers.ts +213 -0
  73. package/src/replay/Tree.test.ts +25 -0
  74. package/src/replay/Tree.ts +45 -0
  75. package/src/replay/index.ts +11 -0
  76. package/src/steering/Blocks.fixture.ts +6 -0
  77. package/src/steering/Blocks.test.ts +167 -0
  78. package/src/steering/Blocks.ts +321 -0
  79. package/src/steering/CodeThreads.test.ts +127 -0
  80. package/src/steering/CodeThreads.ts +197 -0
  81. package/src/steering/Edits.test.ts +19 -0
  82. package/src/steering/Edits.ts +20 -0
  83. package/src/steering/Eol.test.ts +16 -0
  84. package/src/steering/Eol.ts +2 -0
  85. package/src/steering/FORMATS.md +58 -0
  86. package/src/steering/Footnotes.test.ts +977 -0
  87. package/src/steering/Footnotes.ts +661 -0
  88. package/src/steering/Inline.fixture.ts +7 -0
  89. package/src/steering/Inline.test.ts +227 -0
  90. package/src/steering/Inline.ts +195 -0
  91. package/src/steering/MarkdownTree.test.ts +247 -0
  92. package/src/steering/MarkdownTree.ts +260 -0
  93. package/src/steering/Outline.ts +19 -0
  94. package/src/steering/SteeringFormat.test.ts +91 -0
  95. package/src/steering/SteeringFormat.ts +267 -0
  96. package/src/steering/SteeringFormats.test.ts +163 -0
  97. package/src/steering/Threads.ts +163 -0
  98. package/src/steering/contract.test.ts +264 -0
  99. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  100. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  101. package/src/steering/fixtures/free-form/notes.md +12 -0
  102. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  103. package/src/steering/fixtures/qa/answered.md +23 -0
  104. package/src/steering/fixtures/qa/answered.read.json +119 -0
  105. package/src/steering/fixtures/qa/requirements.md +160 -0
  106. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  107. package/src/steering/fixtures/review/review.md +76 -0
  108. package/src/steering/fixtures/review/review.read.json +237 -0
  109. package/src/steering/fixtures/review/ticked.md +23 -0
  110. package/src/steering/fixtures/review/ticked.read.json +99 -0
  111. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  112. package/src/steering/fixtures/review-notes/noted.md +19 -0
  113. package/src/steering/freeform.test.ts +311 -0
  114. package/src/steering/freeform.ts +148 -0
  115. package/src/steering/index.test.ts +62 -0
  116. package/src/steering/index.ts +140 -0
  117. package/src/steering/qa.test.ts +2160 -0
  118. package/src/steering/qa.ts +1104 -0
  119. package/src/steering/review.test.ts +1829 -0
  120. package/src/steering/review.ts +1180 -0
  121. package/src/step/LandStep.ts +31 -0
  122. package/src/step/RepoSnapshot.ts +29 -0
  123. package/src/step/accessRefusal.test.ts +58 -0
  124. package/src/step/accessRefusal.ts +26 -0
  125. package/src/step/index.ts +5 -0
  126. package/src/step/planStart.test.ts +202 -0
  127. package/src/step/planStart.ts +167 -0
  128. package/src/step/planStep.test.ts +104 -0
  129. package/src/step/planStep.ts +89 -0
  130. package/src/step/snapshot.fixture.ts +28 -0
  131. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  132. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  133. package/src/testkit/FakeGitOperations.ts +95 -0
  134. package/src/testkit/GitTiers.test.ts +11 -0
  135. package/src/testkit/GitTiers.ts +1052 -0
  136. package/src/testkit/InMemRepo.ts +467 -0
  137. package/src/testkit/Layers.test.ts +58 -0
  138. package/src/testkit/Layers.ts +241 -0
  139. package/src/testkit/StrictGitOperations.ts +31 -0
  140. package/src/testkit/TestRepo.test.ts +119 -0
  141. package/src/testkit/TestRepo.ts +274 -0
  142. package/src/testkit/cliIo.ts +36 -0
  143. package/src/testkit/index.ts +9 -0
  144. package/src/testkit/steps.ts +177 -0
  145. package/src/testkit/testkit.feature +65 -0
  146. package/src/wire/BeatDocument.test.ts +419 -0
  147. package/src/wire/BeatDocument.ts +114 -0
  148. package/src/wire/BeatPlain.test.ts +113 -0
  149. package/src/wire/BeatPlain.ts +127 -0
  150. package/src/wire/BeatStatus.test.ts +59 -0
  151. package/src/wire/BeatStatus.ts +81 -0
  152. package/src/wire/Demand.test.ts +142 -0
  153. package/src/wire/Demand.ts +108 -0
  154. package/src/wire/ExitCodes.test.ts +21 -0
  155. package/src/wire/ExitCodes.ts +16 -0
  156. package/src/wire/Glob.ts +29 -0
  157. package/src/wire/Judge.ts +22 -0
  158. package/src/wire/Land.test.ts +120 -0
  159. package/src/wire/Land.ts +75 -0
  160. package/src/wire/Select.test.ts +148 -0
  161. package/src/wire/Select.ts +78 -0
  162. package/src/wire/Sessions.test.ts +59 -0
  163. package/src/wire/Sessions.ts +39 -0
  164. package/src/wire/WireSchema.test.ts +162 -0
  165. package/src/wire/constants.ts +22 -0
  166. package/src/wire/golden/1/beat-capture.json +34 -0
  167. package/src/wire/golden/1/beat-message.json +34 -0
  168. package/src/wire/golden/1/beat-prompt.json +44 -0
  169. package/src/wire/golden/1/beat-script.json +34 -0
  170. package/src/wire/golden/1/beat-stalled.json +34 -0
  171. package/src/wire/golden/1/judge.json +14 -0
  172. package/src/wire/golden/1/land-noop.json +10 -0
  173. package/src/wire/golden/1/land.json +10 -0
  174. package/src/wire/index.ts +12 -0
  175. package/src/wire/types.ts +46 -0
  176. package/src/workflow/Diagnostic.test.ts +93 -0
  177. package/src/workflow/Diagnostic.ts +54 -0
  178. package/src/workflow/SteeringFormats.test.ts +27 -0
  179. package/src/workflow/SteeringFormats.ts +6 -0
  180. package/src/workflow/Workflow.ts +77 -0
  181. package/src/workflow/compile.test.ts +53 -0
  182. package/src/workflow/compile.ts +56 -0
  183. package/src/workflow/discovery.ts +122 -0
  184. package/src/workflow/fingerprint.test.ts +52 -0
  185. package/src/workflow/fingerprint.ts +53 -0
  186. package/src/workflow/index.ts +33 -0
  187. package/src/workflow/load.test.ts +1017 -0
  188. package/src/workflow/load.ts +836 -0
  189. package/src/workflow/skills.test.ts +109 -0
  190. package/src/workflow/skills.ts +90 -0
  191. package/src/workflow/vars.ts +33 -0
  192. package/src/workflows/feature/feature.test.ts +27 -33
  193. package/src/workflows/feature/index.ts +10 -0
  194. package/src/workflows/fix/fix.feature +80 -0
  195. package/src/workflows/fix/fix.test.ts +58 -78
  196. package/src/workflows/fix/index.ts +6 -0
  197. package/src/workflows/index.ts +2 -10
  198. package/src/workflows/review/index.ts +6 -0
  199. package/src/workflows/review/review.test.ts +85 -29
  200. package/src/workflows/review/review.ts +3 -1
  201. package/src/workflows/shared/config.fixture.ts +12 -0
  202. package/src/workflows/shared/reserved.ts +11 -0
  203. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,669 @@
1
+ import {
2
+ installContext,
3
+ protocolMismatch,
4
+ type FlowContext,
5
+ type Change,
6
+ type JudgeAnswer,
7
+ type JudgeQuestion,
8
+ type ScopeAccess,
9
+ type AccessDef,
10
+ type ScopeOptions,
11
+ type StepRequest,
12
+ type Flow,
13
+ } from "../flows/index.js"
14
+ import {
15
+ headingSectionBodies,
16
+ headingSections,
17
+ steeringFormatFor,
18
+ parseCodeThreads,
19
+ parseThreadsWithFindings,
20
+ unansweredQuestions,
21
+ } from "../steering/index.js"
22
+ import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
23
+ import { globMatches } from "../wire/index.js"
24
+ import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
25
+ import {
26
+ formatStepId,
27
+ parseCommitMessage,
28
+ type CommitMessage,
29
+ type JudgeVerdict,
30
+ type StepId,
31
+ } from "./Trailers.js"
32
+
33
+ export interface EpisodeCommit {
34
+ readonly hash: string
35
+ readonly message: string
36
+ readonly tree: TreeView
37
+ }
38
+
39
+ /**
40
+ * The commits one episode consists of. `base` is where the first step starts
41
+ * reading from: the commit before the episode, or an entered process's opening
42
+ * commit — which completes no step and so is never in `commits`.
43
+ */
44
+ export interface Episode {
45
+ readonly base: { readonly hash: string; readonly tree: TreeView }
46
+ readonly commits: readonly EpisodeCommit[]
47
+ }
48
+
49
+ /** The turn a landing is about to commit, completing the step the episode rests at. */
50
+ export interface PendingTurn {
51
+ readonly tree: TreeView
52
+ readonly verdicts?: readonly JudgeVerdict[]
53
+ }
54
+
55
+ export interface ReplayInput {
56
+ readonly flow: Flow
57
+ readonly episode: Episode
58
+ /** Process settings: flow code may branch on these. */
59
+ readonly vars: Readonly<Record<string, string>>
60
+ /** Environment settings: read live, so flow code must not let them change the next step. */
61
+ readonly env: Readonly<Record<string, string>>
62
+ /** The process's diff base — what `start()` returns. */
63
+ readonly start: string
64
+ /** `start`'s tree, for a diff base outside the episode (an entered process's review base predates its opening commit). */
65
+ readonly startTree?: TreeView
66
+ readonly budgetBytes: number
67
+ readonly pending?: PendingTurn
68
+ /** Bundled skill lists keyed by scope full name — `WorkflowDefinition.skills` applied to the process's vars. The fallback beneath a `scope()` option and `configuredSkills`. */
69
+ readonly skills?: Readonly<Record<string, readonly string[]>>
70
+ /** `config.skills` entries by scope full name; kept apart from `skills` so they outrank a `scope()` option while the bundled export does not. */
71
+ readonly configuredSkills?: Readonly<Record<string, readonly string[]>>
72
+ /** Bundled access keyed by scope full name — the fallback beneath a `scope()` option and `configuredAccess`. */
73
+ readonly access?: Readonly<Record<string, ScopeAccess>>
74
+ /** `config.access` entries by scope full name; they outrank a `scope()` option. */
75
+ readonly configuredAccess?: Readonly<Record<string, ScopeAccess>>
76
+ }
77
+
78
+ export type StepKind = Exclude<StepRequest["kind"], "restart">
79
+
80
+ type Actor = "agent" | "human" | "check" | "judge"
81
+
82
+ const actorOfKind = (kind: StepKind): Actor =>
83
+ kind === "run" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge"
84
+
85
+ /** One step replay reached, completed or not. */
86
+ export interface ReachedStep {
87
+ readonly id: StepId
88
+ /** The scoped name — the step's node id and subject name. */
89
+ readonly name: string
90
+ readonly kind: StepKind
91
+ readonly actor: Actor
92
+ readonly request: Exclude<StepRequest, { kind: "restart" }>
93
+ /** Everything before the name's last `.` — `""` at the root. */
94
+ readonly memoryScope: string
95
+ /** The scope's resolved skill list, for an agent step; absent when none. */
96
+ readonly skills?: readonly string[] | undefined
97
+ /** The scope's resolved access with the step's steering file and waiting code threads folded in, for an agent step. */
98
+ readonly access?: AccessDef | undefined
99
+ /** The commit replay stood on when it reached this step. */
100
+ readonly enteredAt: string
101
+ /** Whether the judge budget cut this step's evidence. */
102
+ readonly truncated: boolean
103
+ /** The named `scope()` calls enclosing the step, outermost first: each call's prefix and its own id. */
104
+ readonly scopeCalls: readonly { readonly prefix: string; readonly call: number }[]
105
+ }
106
+
107
+ export type ReplayOutcome =
108
+ | {
109
+ readonly kind: "rest"
110
+ readonly rest: ReachedStep
111
+ readonly trace: readonly ReachedStep[]
112
+ /** The step the pending turn completed, when one was supplied. */
113
+ readonly landed: ReachedStep | undefined
114
+ }
115
+ | {
116
+ readonly kind: "ended"
117
+ readonly via: "return" | "restart"
118
+ readonly trace: readonly ReachedStep[]
119
+ readonly landed: ReachedStep | undefined
120
+ }
121
+ | { readonly kind: "divergence"; readonly message: string }
122
+ | { readonly kind: "refused"; readonly message: string }
123
+ | { readonly kind: "failed"; readonly message: string }
124
+
125
+ const memoryScopeOf = (name: string): string => {
126
+ const dot = name.lastIndexOf(".")
127
+ return dot === -1 ? "" : name.slice(0, dot)
128
+ }
129
+
130
+ class Stop extends Error {}
131
+
132
+ const short = (hash: string): string => hash.slice(0, 7)
133
+
134
+ /** One step's changes; content is read only when a flow asks for it. */
135
+ const changesBetween = (before: TreeView, after: TreeView): readonly Change[] => {
136
+ const diff = diffTrees(before, after)
137
+ const change = (path: string, status: Change["status"]): Change => ({
138
+ path,
139
+ status,
140
+ get before() {
141
+ return status === "added" ? undefined : before.read(path)
142
+ },
143
+ get after() {
144
+ return status === "deleted" ? undefined : after.read(path)
145
+ },
146
+ })
147
+ return [
148
+ ...diff.added.map((path) => change(path, "added")),
149
+ ...diff.modified.map((path) => change(path, "modified")),
150
+ ...diff.deleted.map((path) => change(path, "deleted")),
151
+ ].sort((a, b) => a.path.localeCompare(b.path))
152
+ }
153
+
154
+ const normalizeAnswer = (answer: string | number | boolean): string =>
155
+ typeof answer === "boolean" ? (answer ? "yes" : "no") : String(answer)
156
+
157
+ const answersFrom = (
158
+ verdicts: readonly JudgeVerdict[],
159
+ questions: readonly JudgeQuestion[],
160
+ ): Readonly<Record<string, JudgeAnswer | undefined>> => {
161
+ const answers: Record<string, JudgeAnswer | undefined> = {}
162
+ for (const question of questions) answers[question.id] = undefined
163
+ for (const verdict of verdicts) {
164
+ if (!(verdict.id in answers)) continue
165
+ answers[verdict.id] = { answer: normalizeAnswer(verdict.answer), p: verdict.p }
166
+ }
167
+ return answers
168
+ }
169
+
170
+ /** The last `allowedBytes` bytes of `content`, from the first whole line on. */
171
+ const tailOf = (content: string, allowedBytes: number): string => {
172
+ const buf = Buffer.from(content, "utf8")
173
+ if (buf.length <= allowedBytes) return content
174
+ const tail = buf.subarray(buf.length - allowedBytes).toString("utf8")
175
+ const newline = tail.indexOf("\n")
176
+ return newline === -1 ? "" : tail.slice(newline + 1)
177
+ }
178
+
179
+ /** Cut each evidence value to an even share of the judge budget; which keys were cut. */
180
+ const budgeted = (
181
+ evidence: Readonly<Record<string, string>>,
182
+ budgetBytes: number,
183
+ ): {
184
+ readonly evidence: Readonly<Record<string, string>>
185
+ readonly truncated: readonly string[]
186
+ } => {
187
+ const keys = Object.keys(evidence)
188
+ const share = Math.floor(budgetBytes / Math.max(keys.length, 1))
189
+ const bounded: Record<string, string> = {}
190
+ const truncated: string[] = []
191
+ for (const key of keys) {
192
+ const value = evidence[key]!
193
+ bounded[key] = tailOf(value, share)
194
+ if (bounded[key] !== value) truncated.push(key)
195
+ }
196
+ return { evidence: bounded, truncated }
197
+ }
198
+
199
+ interface Identity {
200
+ readonly model: string | undefined
201
+ readonly system: string | undefined
202
+ readonly skills: readonly string[] | undefined
203
+ /** Scope-level and unfolded: two steps differing only in steering file share a conversation. */
204
+ readonly access: ScopeAccess | undefined
205
+ }
206
+
207
+ const sameSkills = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean =>
208
+ (a?.length ?? 0) === (b?.length ?? 0) && (a ?? []).every((skill, i) => skill === b?.[i])
209
+
210
+ const samePersona = (a: Identity, b: Identity): boolean =>
211
+ a.model === b.model &&
212
+ a.system === b.system &&
213
+ sameSkills(a.skills, b.skills) &&
214
+ sameAccess(a.access, b.access)
215
+
216
+ interface Position {
217
+ readonly hash: string
218
+ readonly tree: TreeView
219
+ }
220
+
221
+ interface ParsedCommit extends EpisodeCommit {
222
+ readonly parsed: CommitMessage
223
+ }
224
+
225
+ /**
226
+ * Replay one episode: run its entry flow, answering each step from the
227
+ * episode's commits in order, until a step has no commit left — the rest.
228
+ * Pure over its input: the same episode always yields the same outcome, and
229
+ * the only reads are through `TreeView`s.
230
+ */
231
+ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
232
+ // The option keys each step accepts. A gtd.config.ts is evaluated without a
233
+ // type check, so a misspelt key would otherwise be silently ignored.
234
+ const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
235
+ agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty"]),
236
+ human: new Set([...STEERING_OPTIONS, "message", "acceptClean"]),
237
+ run: new Set(STEERING_OPTIONS),
238
+ judge: new Set([...STEERING_OPTIONS, "message"]),
239
+ }
240
+
241
+ const unknownOptions = (step: ReachedStep): string | undefined => {
242
+ const unknown = Object.keys(step.request.options).filter(
243
+ (key) => !KNOWN_OPTIONS[step.kind].has(key),
244
+ )
245
+ if (unknown.length === 0) return undefined
246
+ return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`
247
+ }
248
+
249
+ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
250
+ const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
251
+ ...c,
252
+ parsed: parseCommitMessage(c.message),
253
+ }))
254
+
255
+ // Every replay re-derives base, commits and head() from whatever history
256
+ // exists right now, so a hash a flow reads this same run (head()/start())
257
+ // always names a commit `treeAt` can find here. Only a hash a flow gets from
258
+ // somewhere else — stored state, another branch, a fabricated value — can
259
+ // miss.
260
+ const treeAt = (hash: string): TreeView | undefined =>
261
+ hash === input.episode.base.hash
262
+ ? input.episode.base.tree
263
+ : commits.find((c) => c.hash === hash)?.tree
264
+
265
+ let cursor = 0
266
+ let pendingUsed = false
267
+ let position: Position = input.episode.base
268
+ let previousPosition: Position = input.episode.base
269
+ let expectedNext: { readonly name: string; readonly hash: string } | undefined
270
+ const occurrences = new Map<string, number>()
271
+ const personas = new Map<string, Identity>()
272
+ const scopes: ScopeOptions[] = []
273
+ // Numbers each scope() call in replay order, so two calls in a row are two conversations.
274
+ const scopeCallIds: number[] = []
275
+ let scopeCallCount = 0
276
+ const trace: ReachedStep[] = []
277
+ let landed: ReachedStep | undefined
278
+ let outcome: ReplayOutcome | undefined
279
+ let signal: (() => void) | undefined
280
+ const settled = new Promise<void>((resolve) => {
281
+ signal = resolve
282
+ })
283
+
284
+ const finish = (result: ReplayOutcome): Promise<never> => {
285
+ outcome ??= result
286
+ signal?.()
287
+ throw new Stop()
288
+ }
289
+
290
+ const namedScopeCalls = (): { prefix: string; call: number }[] => {
291
+ const calls: { prefix: string; call: number }[] = []
292
+ const names: string[] = []
293
+ scopes.forEach((s, i) => {
294
+ if (s.name === undefined) return
295
+ names.push(s.name)
296
+ calls.push({ prefix: names.join("."), call: scopeCallIds[i]! })
297
+ })
298
+ return calls
299
+ }
300
+
301
+ const scoped = (name: string): string =>
302
+ [...scopes.flatMap((s) => (s.name === undefined ? [] : [s.name])), name].join(".")
303
+
304
+ const isAttemptAt = (commit: ParsedCommit, name: string): boolean =>
305
+ commit.parsed.step === undefined &&
306
+ commit.parsed.parsed !== undefined &&
307
+ commit.parsed.parsed.from === undefined &&
308
+ commit.parsed.parsed.to === name &&
309
+ isEmptyDiff(diffTrees(position.tree, commit.tree))
310
+
311
+ const advance = (next: Position): void => {
312
+ previousPosition = position
313
+ position = next
314
+ }
315
+
316
+ const replyFor = (
317
+ request: Exclude<StepRequest, { kind: "restart" }>,
318
+ verdicts: readonly JudgeVerdict[],
319
+ ): unknown =>
320
+ request.kind === "judge"
321
+ ? {
322
+ answers: answersFrom(verdicts, request.questions),
323
+ truncated: judgeCuts.get(request) ?? [],
324
+ }
325
+ : undefined
326
+
327
+ const judgeCuts = new WeakMap<object, readonly string[]>()
328
+
329
+ // Resolved per memory scope `memory`: for each prefix of it, innermost
330
+ // first, a `config` entry, then the innermost `scope()` call at that prefix
331
+ // (an unnamed scope counts at its parent's prefix), then the bundled export.
332
+ const resolveScoped = <T>(
333
+ memory: string,
334
+ pick: (s: (typeof scopes)[number]) => T | undefined,
335
+ configured: Readonly<Record<string, T>> | undefined,
336
+ bundled: Readonly<Record<string, T>> | undefined,
337
+ ): T | undefined => {
338
+ const own = new Map<string, T>()
339
+ const names: string[] = []
340
+ for (const s of scopes) {
341
+ if (s.name !== undefined) names.push(s.name)
342
+ const v = pick(s)
343
+ if (v !== undefined) own.set(names.join("."), v)
344
+ }
345
+ const parts = memory === "" ? [] : memory.split(".")
346
+ const prefixes = parts.map((_, i) => parts.slice(0, parts.length - i).join(".")).concat("")
347
+ return prefixes
348
+ .map((p) => configured?.[p] ?? own.get(p) ?? bundled?.[p])
349
+ .find((r) => r !== undefined)
350
+ }
351
+
352
+ // An empty result collapses to `undefined` so it is absent on the wire.
353
+ const resolveSkills = (memory: string): readonly string[] | undefined => {
354
+ const hit = resolveScoped(memory, (s) => s.skills, input.configuredSkills, input.skills)
355
+ return hit === undefined || hit.length === 0 ? undefined : hit
356
+ }
357
+
358
+ // `{}` is a hit meaning unrestricted.
359
+ const resolveAccess = (memory: string): ScopeAccess | undefined =>
360
+ resolveScoped(memory, (s) => s.access, input.configuredAccess, input.access)
361
+
362
+ const foldedAccessFor = (
363
+ scopeAccess: ScopeAccess | undefined,
364
+ file: string | undefined,
365
+ ): AccessDef => {
366
+ // Reading code threads parses every changed file: only a restricted side needs them.
367
+ const restricted = scopeAccess?.read !== undefined || scopeAccess?.write !== undefined
368
+ const threadPaths = restricted
369
+ ? context
370
+ .codeThreads()
371
+ .filter((t) => t.waitingOn === "agent")
372
+ .map((t) => t.path)
373
+ : []
374
+ return foldAccess(scopeAccess, file, threadPaths)
375
+ }
376
+
377
+ const scopeAccessFault = (): string | undefined => {
378
+ for (const [i, s] of scopes.entries()) {
379
+ if (s.access === undefined) continue
380
+ const fault = accessShapeFault(s.access)
381
+ if (fault === undefined) continue
382
+ const name = scopes
383
+ .slice(0, i + 1)
384
+ .flatMap((o) => (o.name === undefined ? [] : [o.name]))
385
+ .join(".")
386
+ return `gtd: scope "${name || "root"}": ${fault}`
387
+ }
388
+ return undefined
389
+ }
390
+
391
+ const unfoldedAccess = new WeakMap<ReachedStep, ScopeAccess | undefined>()
392
+
393
+ const resolve = (
394
+ request: Exclude<StepRequest, { kind: "restart" }>,
395
+ ): Exclude<StepRequest, { kind: "restart" }> => {
396
+ if (request.kind === "agent") {
397
+ const persona: { model?: string; system?: string } = {}
398
+ for (const s of scopes) {
399
+ if (s.model !== undefined) persona.model = s.model
400
+ if (s.system !== undefined) persona.system = s.system
401
+ }
402
+ return {
403
+ ...request,
404
+ options: {
405
+ ...persona,
406
+ ...request.options,
407
+ },
408
+ }
409
+ }
410
+ if (request.kind === "judge") {
411
+ const { evidence, truncated } = budgeted(request.evidence, input.budgetBytes)
412
+ const bounded = { ...request, evidence }
413
+ judgeCuts.set(bounded, truncated)
414
+ return bounded
415
+ }
416
+ return request
417
+ }
418
+
419
+ const reach = (request: Exclude<StepRequest, { kind: "restart" }>): ReachedStep => {
420
+ const name = scoped(request.name)
421
+ const occurrence = (occurrences.get(name) ?? 0) + 1
422
+ occurrences.set(name, occurrence)
423
+ const resolved = resolve(request)
424
+ const memory = memoryScopeOf(name)
425
+ const scopeAccess = resolved.kind === "agent" ? resolveAccess(memory) : undefined
426
+ const reached: ReachedStep = {
427
+ id: { name, occurrence },
428
+ name,
429
+ kind: resolved.kind,
430
+ actor: actorOfKind(resolved.kind),
431
+ request: resolved,
432
+ memoryScope: memoryScopeOf(name),
433
+ ...(resolved.kind === "agent"
434
+ ? {
435
+ skills: resolveSkills(memory),
436
+ access: foldedAccessFor(scopeAccess, resolved.options.file),
437
+ }
438
+ : {}),
439
+ enteredAt: position.hash,
440
+ truncated: (judgeCuts.get(resolved) ?? []).length > 0,
441
+ scopeCalls: namedScopeCalls(),
442
+ }
443
+ if (resolved.kind === "agent") unfoldedAccess.set(reached, scopeAccess)
444
+ trace.push(reached)
445
+ return reached
446
+ }
447
+
448
+ const checkIdentity = (step: ReachedStep): string | undefined => {
449
+ if (step.request.kind !== "agent") return undefined
450
+ const identity: Identity = {
451
+ model: step.request.options.model,
452
+ system: step.request.options.system,
453
+ skills: step.skills,
454
+ access: unfoldedAccess.get(step),
455
+ }
456
+ const seen = personas.get(step.memoryScope)
457
+ if (seen === undefined) {
458
+ personas.set(step.memoryScope, identity)
459
+ return undefined
460
+ }
461
+ return samePersona(seen, identity)
462
+ ? undefined
463
+ : `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in memory scope "${step.memoryScope || "root"}" — one scope is one conversation, and a conversation has one identity`
464
+ }
465
+
466
+ const DIVERGED = "the workflow changed under this process; run `gtd abandon` to start over"
467
+
468
+ // Checks a reached step against what the previous commit's subject promised
469
+ // and against the persona of its scope — the reasons replay cannot go on.
470
+ const blockerAt = (step: ReachedStep): ReplayOutcome | undefined => {
471
+ const expected = expectedNext
472
+ expectedNext = undefined
473
+ if (expected !== undefined && expected.name !== step.name) {
474
+ return {
475
+ kind: "divergence",
476
+ message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`,
477
+ }
478
+ }
479
+ const error = unknownOptions(step) ?? checkIdentity(step)
480
+ return error === undefined ? undefined : { kind: "failed", message: error }
481
+ }
482
+
483
+ const consumeCommit = (step: ReachedStep, commit: ParsedCommit): unknown => {
484
+ const recorded = commit.parsed.step
485
+ const expected = formatStepId(step.id)
486
+ if (recorded === undefined || formatStepId(recorded) !== expected) {
487
+ const what =
488
+ recorded === undefined ? "records no step" : `records step "${formatStepId(recorded)}"`
489
+ return finish({
490
+ kind: "divergence",
491
+ message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`,
492
+ })
493
+ }
494
+ cursor++
495
+ advance(commit)
496
+ const to = commit.parsed.parsed?.to
497
+ if (to !== undefined) expectedNext = { name: to, hash: commit.hash }
498
+ return replyFor(step.request, commit.parsed.judge)
499
+ }
500
+
501
+ const answer = (step: ReachedStep): unknown => {
502
+ while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) cursor++
503
+ const commit = commits[cursor]
504
+ if (commit !== undefined) return consumeCommit(step, commit)
505
+ if (input.pending === undefined || pendingUsed) {
506
+ return finish({ kind: "rest", rest: step, trace, landed })
507
+ }
508
+ pendingUsed = true
509
+ landed = step
510
+ advance({ hash: "", tree: input.pending.tree })
511
+ return replyFor(step.request, input.pending.verdicts ?? [])
512
+ }
513
+
514
+ // gtd.config.ts is loaded without a type check.
515
+ const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
516
+ if (request.kind === "run" && typeof request.body !== "string") {
517
+ 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)`
518
+ }
519
+ return request.kind === "agent" ? scopeAccessFault() : undefined
520
+ }
521
+
522
+ const handleStep = async (request: StepRequest): Promise<unknown> => {
523
+ if (outcome !== undefined) throw new Stop()
524
+ if (request.kind === "restart") return finish(endOfFlow("restart"))
525
+ if (typeof request.name !== "string" || request.name === "") {
526
+ return finish({ kind: "failed", message: "gtd: a step was called without a name" })
527
+ }
528
+ const fault = requestFault(request)
529
+ if (fault !== undefined) return finish({ kind: "failed", message: fault })
530
+ const step = reach(request)
531
+ const blocker = blockerAt(step)
532
+ return blocker === undefined ? answer(step) : finish(blocker)
533
+ }
534
+
535
+ const endOfFlow = (via: "return" | "restart"): ReplayOutcome =>
536
+ cursor < commits.length
537
+ ? {
538
+ kind: "divergence",
539
+ message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
540
+ }
541
+ : { kind: "ended", via, trace, landed }
542
+
543
+ const changesSince = (hash: string): readonly Change[] => {
544
+ const tree = treeAt(hash)
545
+ if (tree === undefined) {
546
+ throw new Error(
547
+ `gtd: changesSince(${hash}): ${hash} is not the episode base or one of its commits — pass a hash this run read from head() or start(), not one captured earlier, read from state, or from another branch`,
548
+ )
549
+ }
550
+ return changesBetween(tree, position.tree)
551
+ }
552
+
553
+ const context: FlowContext = {
554
+ step: handleStep,
555
+ refuse: (message) => {
556
+ outcome ??= { kind: "refused", message }
557
+ signal?.()
558
+ throw new Stop()
559
+ },
560
+ pushScope: (scope) => {
561
+ scopes.push(scope)
562
+ scopeCallIds.push(scopeCallCount++)
563
+ },
564
+ popScope: () => {
565
+ scopes.pop()
566
+ scopeCallIds.pop()
567
+ },
568
+ read: (path) => position.tree.read(path),
569
+ glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
570
+ changes: () => changesBetween(previousPosition.tree, position.tree),
571
+ changesSince,
572
+ matches: globMatches,
573
+ sections: (text) => headingSections(text),
574
+ sectionBodies: (text) => headingSectionBodies(text),
575
+ openQuestions: (text) => {
576
+ const qa = steeringFormatFor("qa")
577
+ return qa === undefined
578
+ ? []
579
+ : unansweredQuestions(qa, text).map((q) => ({
580
+ question: q.question,
581
+ line: q.headingLine + 1,
582
+ }))
583
+ },
584
+ threads: (text) => {
585
+ const { threads, findings } = parseThreadsWithFindings(text)
586
+ return threads.map((t) => ({
587
+ name: t.name,
588
+ line: t.line + 1,
589
+ waitingOn: t.waitingOn,
590
+ faults: findings
591
+ .filter((f) => f.message.startsWith(`Footnote thread "[^${t.name}]": `))
592
+ .map((f) => f.message),
593
+ }))
594
+ },
595
+ codeThreads: () =>
596
+ changesBetween(
597
+ treeAt(input.start) ?? input.startTree ?? input.episode.base.tree,
598
+ position.tree,
599
+ )
600
+ .filter((c) => c.status !== "deleted")
601
+ .flatMap((c) => {
602
+ const { threads, findings } = parseCodeThreads(c.path, position.tree.read(c.path) ?? "")
603
+ return threads.map((t) => ({
604
+ path: t.path,
605
+ line: t.line + 1,
606
+ waitingOn: t.waitingOn,
607
+ first: t.entries[0]?.text ?? "",
608
+ faults: findings
609
+ .filter((f) => f.message.startsWith(`Code thread at ${c.path}:${t.line + 1}: `))
610
+ .map((f) => f.message),
611
+ }))
612
+ }),
613
+ vars: input.vars,
614
+ env: input.env,
615
+ start: () => input.start,
616
+ skillsFor: (localName) => resolveSkills(memoryScopeOf(scoped(localName))) ?? [],
617
+ accessFor: (localName, file) =>
618
+ foldedAccessFor(resolveAccess(memoryScopeOf(scoped(localName))), file),
619
+ // At the rest, trailing attempts sit above the last step commit: the head a
620
+ // prompt names is the commit the process actually stands on.
621
+ head: () => {
622
+ const rest = commits.slice(cursor)
623
+ return rest.length > 0 && rest.every((c) => c.parsed.step === undefined)
624
+ ? rest[rest.length - 1]!.hash
625
+ : position.hash
626
+ },
627
+ }
628
+
629
+ installContext(context)
630
+ try {
631
+ // A pre-upgrade flow destructuring `{ entry }` reads `undefined` (an
632
+ // ordinary start) instead of crashing.
633
+ const flowDone = (input.flow as (a: object) => Promise<void>)({}).then(
634
+ () => {
635
+ if (outcome === undefined && trace.length === 0) {
636
+ outcome = { kind: "failed", message: "gtd: the flow returned without reaching any step" }
637
+ }
638
+ outcome ??= endOfFlow("return")
639
+ },
640
+ (error: unknown) => {
641
+ if (error instanceof Stop) return
642
+ outcome ??= {
643
+ kind: "failed",
644
+ // By name, not instanceof: a workflow loading its own copy of the
645
+ // flows facade throws that copy's class.
646
+ message:
647
+ error instanceof Error && error.name === "FlowsProtocolError"
648
+ ? error.message
649
+ : `gtd: the workflow threw: ${error instanceof Error ? error.message : String(error)}`,
650
+ }
651
+ },
652
+ )
653
+ // Replay runs on microtasks alone: a flow still unsettled once a macrotask
654
+ // fires awaited something other than a step.
655
+ const stuck = new Promise<void>((resolve) => setImmediate(resolve))
656
+ await Promise.race([flowDone, settled, stuck])
657
+ const mismatch = protocolMismatch()
658
+ if (mismatch !== undefined) return { kind: "failed", message: mismatch.message }
659
+ } finally {
660
+ installContext(undefined)
661
+ }
662
+ return (
663
+ outcome ?? {
664
+ kind: "failed",
665
+ message:
666
+ "gtd: the flow awaited something that is not a step — flow code may suspend only at steps",
667
+ }
668
+ )
669
+ }