@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,1225 @@
1
+ import { Effect } from "effect"
2
+ import {
3
+ Narrator,
4
+ GitService,
5
+ Host,
6
+ Workspace,
7
+ type GitOperations,
8
+ type WorkspaceOps,
9
+ } from "../platform/index.js"
10
+ import {
11
+ ConfigDiscovery,
12
+ ConfigService,
13
+ changedConfigFiles,
14
+ configFingerprint,
15
+ contentHash,
16
+ multilineSetting,
17
+ resolveVars,
18
+ resolveScopeAccess,
19
+ resolveScopeSkills,
20
+ type ConfigFingerprint,
21
+ type ConfigOperations,
22
+ type ResolvedWorkflow,
23
+ knownModes,
24
+ type ChangeStatus,
25
+ type PendingChange,
26
+ type StateName,
27
+ type StepDef,
28
+ type WorkflowDefinition,
29
+ } from "../workflow/index.js"
30
+ import {
31
+ formatFault,
32
+ formatSubject,
33
+ parseCommitMessage,
34
+ replay,
35
+ treeFromRecord,
36
+ type EpisodeCommit,
37
+ type JudgeVerdict,
38
+ type ReachedStep,
39
+ type ReplayOutcome,
40
+ type TreeView,
41
+ } from "../replay/index.js"
42
+ import type { ScopeAccess } from "../flows/index.js"
43
+ import { steeringFormatFor } from "../steering/index.js"
44
+ import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
45
+ import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
46
+
47
+ export { UNATTRIBUTED_MODEL }
48
+
49
+ // git's empty-tree object — the base when a process starts at the repository's
50
+ // first commit.
51
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
52
+
53
+ const ACTORS: ReadonlySet<string> = new Set(["agent", "human", "check", "judge"])
54
+
55
+ type History = ReadonlyArray<{
56
+ readonly hash: string
57
+ readonly message: string
58
+ readonly touched: ReadonlyArray<string>
59
+ }>
60
+
61
+ // ── Episodes ────────────────────────────────────────────────────────────────
62
+
63
+ interface EpisodeLocation {
64
+ /** The workflow pinned on the opening commit; `undefined` for the default. */
65
+ readonly workflow: string | undefined
66
+ /** An opening commit with no `Gtd-Workflow` trailer: written by the removed `gtd --entry`. */
67
+ readonly legacyOpening: boolean
68
+ /** The commit replay starts reading from, or -1 for the empty tree before history. */
69
+ readonly baseIndex: number
70
+ /** The process's first commit — a `--workflow` process's opening commit, else the one after the base. */
71
+ readonly processStart: number
72
+ }
73
+
74
+ /**
75
+ * Where the episode HEAD belongs to begins, read off subjects alone. Walking
76
+ * back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
77
+ * `gtd --workflow` writes — no landing ever produces one — and opens an
78
+ * episode as its first commit (its `Gtd-Workflow` trailer names the workflow;
79
+ * a legacy `--entry` opening has none); a commit that is not a gtd step commit, or one
80
+ * entering the flow's first step (a finished episode), bounds the episode
81
+ * from below.
82
+ */
83
+ const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocation => {
84
+ for (let i = history.length - 1; i >= 0; i--) {
85
+ const message = parseCommitMessage(history[i]!.message)
86
+ const subject = message.parsed
87
+ const opening =
88
+ subject !== undefined &&
89
+ message.step === undefined &&
90
+ subject.from === undefined &&
91
+ subject.actor === "human"
92
+ if (opening) {
93
+ return {
94
+ workflow: message.workflow,
95
+ legacyOpening: message.workflow === undefined,
96
+ baseIndex: i,
97
+ processStart: i,
98
+ }
99
+ }
100
+ if (subject === undefined || !ACTORS.has(subject.actor) || subject.to === def.initial) {
101
+ return { workflow: undefined, legacyOpening: false, baseIndex: i, processStart: i + 1 }
102
+ }
103
+ }
104
+ return { workflow: undefined, legacyOpening: false, baseIndex: -1, processStart: 0 }
105
+ }
106
+
107
+ // ── The current process run ─────────────────────────────────────────────────
108
+
109
+ export interface CostEntry {
110
+ readonly cost: number
111
+ readonly model: string
112
+ }
113
+
114
+ export interface JudgeVerdictEntry {
115
+ readonly id: string
116
+ readonly answer: string | number | boolean
117
+ readonly p: number
118
+ }
119
+
120
+ /** One process commit: the step it entered (its subject's `<to>`), its hash, and who authored it. */
121
+ export interface TraceEntry {
122
+ readonly state: StateName
123
+ readonly hash: string
124
+ readonly actor: string
125
+ }
126
+
127
+ export interface ProcessRun {
128
+ /** The workflow the process's opening commit pinned; `undefined` replays on the default. */
129
+ readonly workflow: string | undefined
130
+ /** Opened by the removed `gtd --entry`: no workflow to replay, only `gtd abandon` applies. */
131
+ readonly legacyOpening: boolean
132
+ /** The process's first commit, or HEAD when none has landed yet. */
133
+ readonly startHash: string
134
+ /** The parent of the process's first commit — the empty tree when that is the root commit. */
135
+ readonly startParentHash: string
136
+ /** The diff base prompts name: `startParentHash`, unless the opening commit fixed a `Gtd-Review-Base`. */
137
+ readonly diffBase: string
138
+ readonly trace: readonly TraceEntry[]
139
+ readonly costEntries: readonly CostEntry[]
140
+ readonly judgeVerdicts: readonly JudgeVerdictEntry[]
141
+ /** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
142
+ readonly pinnedVars: Record<string, string>
143
+ /** Absent for a process started before recording existed. */
144
+ readonly pinnedConfig: { readonly commit: string; readonly files: ConfigFingerprint } | undefined
145
+ /** HEAD's own gtd commit, when it is one — `empty` is whether it changed nothing. */
146
+ readonly headTurn:
147
+ | {
148
+ readonly state: StateName
149
+ readonly actor: string
150
+ readonly empty: boolean
151
+ readonly step: boolean
152
+ }
153
+ | undefined
154
+ /** Set by `summaryRun` when HEAD is itself the commit that closed the process. */
155
+ readonly closingHash: string | undefined
156
+ /** The episode's step commits, oldest → newest, and the commit replay starts from. */
157
+ readonly episode: {
158
+ readonly base: string | undefined
159
+ readonly commits: readonly { readonly hash: string; readonly message: string }[]
160
+ }
161
+ }
162
+
163
+ const headTurnOf = (head: History[number] | undefined): ProcessRun["headTurn"] => {
164
+ if (head === undefined) return undefined
165
+ const message = parseCommitMessage(head.message)
166
+ const subject = message.parsed
167
+ if (subject === undefined || !ACTORS.has(subject.actor)) return undefined
168
+ return {
169
+ state: subject.to,
170
+ actor: subject.actor,
171
+ empty: head.touched.length === 0,
172
+ step: message.step !== undefined,
173
+ }
174
+ }
175
+
176
+ const traceEntryOf = (
177
+ hash: string,
178
+ message: ReturnType<typeof parseCommitMessage>,
179
+ ): TraceEntry => ({
180
+ state: message.parsed?.to ?? "",
181
+ hash,
182
+ actor: message.parsed?.actor ?? "",
183
+ })
184
+
185
+ const costEntriesOf = (message: ReturnType<typeof parseCommitMessage>): CostEntry[] =>
186
+ message.cost.map((c) => ({ cost: c.cost, model: c.model ?? UNATTRIBUTED_MODEL }))
187
+
188
+ const hashAt = (history: History, index: number): string | undefined => history[index]?.hash
189
+
190
+ // The base is checked too: a foreign subject codec can hide a commit from the
191
+ // episode, leaving it as the base. Only a `gtd`-prefixed base counts, so a
192
+ // commit gtd never wrote cannot block it by quoting the trailer.
193
+ const historyFormatFault = (history: History, location: EpisodeLocation): string | undefined =>
194
+ formatFault(
195
+ history
196
+ .slice(location.baseIndex + 1)
197
+ .concat(
198
+ history[location.baseIndex]?.message.startsWith("gtd")
199
+ ? [history[location.baseIndex]!]
200
+ : [],
201
+ )
202
+ .map((c) => ({ hash: c.hash, format: parseCommitMessage(c.message).format })),
203
+ )
204
+
205
+ /** Fails on a history in a format this gtd does not read, before anything acts on it. */
206
+ const runOf = (
207
+ history: History,
208
+ location: EpisodeLocation,
209
+ closingHash: string | undefined = undefined,
210
+ ): Effect.Effect<ProcessRun, Error> => {
211
+ const fault = historyFormatFault(history, location)
212
+ return fault === undefined
213
+ ? Effect.succeed(buildRun(history, location, closingHash))
214
+ : Effect.fail(new Error(fault))
215
+ }
216
+
217
+ // The newest recording, not the first process commit's: the episode is
218
+ // located by the CURRENT config's first step, so a config that renamed it can
219
+ // move the boundary onto the start commit itself, or behind it.
220
+ const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
221
+ for (let i = history.length - 1; i >= 0; i--) {
222
+ const files = parseCommitMessage(history[i]!.message).config
223
+ if (Object.keys(files).length > 0) return { commit: history[i]!.hash, files }
224
+ }
225
+ return undefined
226
+ }
227
+
228
+ const buildRun = (
229
+ history: History,
230
+ location: EpisodeLocation,
231
+ closingHash: string | undefined,
232
+ ): ProcessRun => {
233
+ const processCommits = history.slice(location.processStart)
234
+ const parsed = processCommits.map((c) => parseCommitMessage(c.message))
235
+ const first = parsed[0]
236
+ const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
237
+ const head = history[history.length - 1]
238
+ return {
239
+ workflow: location.workflow,
240
+ legacyOpening: location.legacyOpening,
241
+ startHash: hashAt(history, location.processStart) ?? head?.hash ?? EMPTY_TREE,
242
+ startParentHash,
243
+ diffBase: first?.reviewBase ?? startParentHash,
244
+ trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
245
+ costEntries: parsed.flatMap(costEntriesOf),
246
+ judgeVerdicts: parsed.flatMap((m) => m.judge),
247
+ pinnedVars: { ...first?.vars },
248
+ pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
249
+ headTurn: headTurnOf(head),
250
+ closingHash,
251
+ episode: {
252
+ base: hashAt(history, location.baseIndex),
253
+ commits: history.slice(location.baseIndex + 1),
254
+ },
255
+ }
256
+ }
257
+
258
+ // The page sizes `findBoundaryBase` doubles through before giving up and
259
+ // reading the whole repository — chosen so an ordinary episode (a handful of
260
+ // commits) resolves at the very first tier.
261
+ const PAGE_TIERS: ReadonlyArray<number> = [32, 128, 512, Infinity]
262
+
263
+ /**
264
+ * `locateEpisode`'s own boundary test, subject-only: a commit that isn't a
265
+ * gtd step commit, or one landing the flow's initial state, bounds an
266
+ * episode from below — EXCEPT a bare `gtd(human): <x>` subject (no `→`),
267
+ * which is NEVER a boundary here, whatever `<x>` is. That bare shape is
268
+ * `locateEpisode`'s own `opening` branch (a started episode's first commit),
269
+ * which takes priority there over the initial-state check no matter what the
270
+ * workflow's first step is named — including one named after `def.initial`
271
+ * itself, where the two branches would otherwise disagree. A bare human commit can
272
+ * also be a self-loop step landing rather than a real opening (this
273
+ * subject-only pass can't read the `Gtd-Step` trailer that would tell them
274
+ * apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
275
+ * commits further back than strictly needed — never stop short of the
276
+ * boundary `locateEpisode` would find once it has full commit bodies in
277
+ * hand. That asymmetry is the only direction this approximation may err in;
278
+ * it must never call a real boundary "not yet found".
279
+ */
280
+ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean => {
281
+ const parsed = parseCommitMessage(subject).parsed
282
+ if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
283
+ if (parsed.actor === "human" && parsed.from === undefined) return false
284
+ return parsed.to === def.initial
285
+ }
286
+
287
+ /** The last page index (walking newest→oldest, same direction as `locateEpisode`) whose subject is a boundary, or `undefined` if none in `page`. */
288
+ const boundaryIndexIn = (
289
+ def: WorkflowDefinition,
290
+ page: ReadonlyArray<{ readonly subject: string }>,
291
+ ): number | undefined => {
292
+ for (let i = page.length - 1; i >= 0; i--) {
293
+ if (subjectIsBoundary(def, page[i]!.subject)) return i
294
+ }
295
+ return undefined
296
+ }
297
+
298
+ /**
299
+ * `findBoundaryBase`'s result: `base` is the hash to pass `commitHistory` so
300
+ * a `git.commitHistory(base, head)` call reads exactly the episode ending at
301
+ * `head`, plus whatever a subject-only read couldn't rule out — never the
302
+ * whole repository. `base` is deliberately the boundary commit's PARENT, not
303
+ * the boundary commit itself: `commitHistory`'s range excludes `base`, and
304
+ * `runOf` needs the boundary commit present in the read history
305
+ * (`episode.base`, `startParentHash`). `base: undefined` means "read from the
306
+ * repository root" — an empty repository, an unresolvable `head`, or an
307
+ * episode that reaches the root commit, none of which cost anything extra
308
+ * since that IS the whole history in those cases.
309
+ *
310
+ * `head` is the CONCRETE hash the literal `head` argument resolved to on this
311
+ * call's first read — `undefined` only when it never resolved (an empty
312
+ * repository, or an unresolvable ref). The caller must hand this hash, not
313
+ * the original `head` argument, to its own `commitHistory` call: resolving a
314
+ * symbolic ref (literal `HEAD`, most callers' default) a second time risks
315
+ * reading a DIFFERENT commit than this function paged through, if something
316
+ * moves HEAD in between — pinning both reads to one resolved hash is what
317
+ * keeps the two-read split from disagreeing with itself.
318
+ */
319
+ const findBoundaryBase = (
320
+ git: GitOperations,
321
+ def: WorkflowDefinition,
322
+ head: string | undefined,
323
+ ): Effect.Effect<{ readonly base: string | undefined; readonly head: string | undefined }, Error> =>
324
+ Effect.gen(function* () {
325
+ let pinnedHead = head
326
+ for (const pageSize of PAGE_TIERS) {
327
+ const page = yield* git.subjectHistory(pageSize, 0, pinnedHead)
328
+ // The newest entry of the very first page IS whatever `head` resolved
329
+ // to — pin it now so every further page in this call, and the caller's
330
+ // own `commitHistory`, all read through this same concrete hash rather
331
+ // than re-resolving a moving literal `HEAD`.
332
+ if (pinnedHead === undefined) pinnedHead = page[page.length - 1]?.hash
333
+ const wholeHistory = !Number.isFinite(pageSize) || page.length < pageSize
334
+ const boundaryIdx = boundaryIndexIn(def, page)
335
+ // No room in this page for the boundary commit's own parent — either
336
+ // no boundary was found at all, or it sits at the page's own oldest
337
+ // slot. Either way, grow the page and look again, unless the page
338
+ // already IS the whole history (nothing earlier to find).
339
+ if (boundaryIdx === undefined || boundaryIdx === 0) {
340
+ if (wholeHistory) return { base: undefined, head: pinnedHead }
341
+ continue
342
+ }
343
+ return { base: page[boundaryIdx - 1]!.hash, head: pinnedHead }
344
+ }
345
+ return { base: undefined, head: pinnedHead }
346
+ })
347
+
348
+ const historyUpTo = (
349
+ git: GitOperations,
350
+ def: WorkflowDefinition,
351
+ head: string | undefined,
352
+ ): Effect.Effect<History, Error> =>
353
+ Effect.flatMap(findBoundaryBase(git, def, head), ({ base, head: resolvedHead }) =>
354
+ git.commitHistory(base, resolvedHead),
355
+ )
356
+
357
+ const computeProcessRun = (
358
+ git: GitOperations,
359
+ def: WorkflowDefinition,
360
+ head?: string,
361
+ ): Effect.Effect<ProcessRun, Error> =>
362
+ Effect.flatMap(historyUpTo(git, def, head), (history) =>
363
+ runOf(history, locateEpisode(def, history)),
364
+ )
365
+
366
+ // A file matching its copy in the recording commit is unchanged: the hashes
367
+ // are taken before the commit's own hooks run, and a hook may reformat it.
368
+ const changedSinceStart = (
369
+ pinned: ProcessRun["pinnedConfig"],
370
+ current: ConfigFingerprint,
371
+ workspace: WorkspaceOps,
372
+ ): readonly string[] => {
373
+ if (pinned === undefined) return []
374
+ return changedConfigFiles(pinned.files, current).filter((path) => {
375
+ const committed = workspace.readCommittedSync(path, pinned.commit)
376
+ return (
377
+ current[path] === undefined ||
378
+ committed === undefined ||
379
+ contentHash(committed) !== current[path]
380
+ )
381
+ })
382
+ }
383
+
384
+ const warnConfigChanges = (
385
+ run: ProcessRun,
386
+ current: ConfigFingerprint,
387
+ workspace: WorkspaceOps,
388
+ ): Effect.Effect<void, never, Narrator> =>
389
+ Effect.gen(function* () {
390
+ const changed = changedSinceStart(run.pinnedConfig, current, workspace)
391
+ if (changed.length === 0) return
392
+ yield* (yield* Narrator).warn(
393
+ `gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`,
394
+ )
395
+ })
396
+
397
+ type ConfigRequirements = GitService | ConfigService | ConfigDiscovery | Narrator | Workspace | Host
398
+
399
+ /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
400
+ export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
401
+ function* () {
402
+ const git = yield* GitService
403
+ const config = yield* (yield* ConfigService).load
404
+ return yield* computeProcessRun(git, config.workflow)
405
+ },
406
+ )
407
+
408
+ /** The process HEAD closes or sits inside — `gtd summary`'s run. */
409
+ export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
410
+ function* () {
411
+ const git = yield* GitService
412
+ const def = (yield* (yield* ConfigService).load).workflow
413
+ // Cheap: just HEAD's own subject, to decide which of the two reads below
414
+ // this run needs — never the commits before it. Its hash is also this
415
+ // run's ONE resolution of literal HEAD: every further read below is
416
+ // pinned to it, rather than re-resolving a `head` that could move.
417
+ const headEntry = (yield* git.subjectHistory(1, 0))[0]
418
+ if (headEntry === undefined) {
419
+ // No commits at all — `historyUpTo` folds an empty repository to an
420
+ // empty run on its own; there is no hash yet to pin anything to.
421
+ const history = yield* historyUpTo(git, def, undefined)
422
+ return yield* runOf(history, locateEpisode(def, history))
423
+ }
424
+ const closes = parseCommitMessage(headEntry.subject).parsed?.to === def.initial
425
+ if (!closes) {
426
+ const history = yield* historyUpTo(git, def, headEntry.hash)
427
+ return yield* runOf(history, locateEpisode(def, history))
428
+ }
429
+ // HEAD itself closes the process: its own subject would otherwise read as
430
+ // a boundary to `findBoundaryBase` (landing the initial state), stopping
431
+ // the page one commit too soon. Search for the PRIOR episode's boundary
432
+ // from HEAD's parent instead, then read the closing commit separately —
433
+ // `commitHistory` falls back to the whole history when that parent ref
434
+ // doesn't resolve (HEAD is the repository's root commit).
435
+ const parentRef = `${headEntry.hash}~1`
436
+ const before = yield* historyUpTo(git, def, parentRef)
437
+ const location = locateEpisode(def, before)
438
+ let closing = yield* git.commitHistory(parentRef, headEntry.hash)
439
+ if (closing.length === 0) closing = yield* git.commitHistory(undefined, headEntry.hash)
440
+ return yield* runOf([...before, ...closing], { ...location }, headEntry.hash)
441
+ },
442
+ )
443
+
444
+ // ── Trees ───────────────────────────────────────────────────────────────────
445
+
446
+ const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
447
+ let entries: ReadonlyMap<string, string> | undefined
448
+ const list = () => (entries ??= workspace.treeSync(hash))
449
+ // Sorted once per commit, not per call: `Workspace.episodeTrees` has
450
+ // already materialized `list()` before this is ever read, so the sort
451
+ // itself is the only remaining per-call cost `paths()` used to pay.
452
+ let sortedPaths: readonly string[] | undefined
453
+ const contents = new Map<string, string | undefined>()
454
+ return {
455
+ paths: () => (sortedPaths ??= [...list().keys()].sort()),
456
+ read: (path) => {
457
+ if (!list().has(path)) return undefined
458
+ if (!contents.has(path)) contents.set(path, workspace.readCommittedSync(path, hash))
459
+ return contents.get(path)
460
+ },
461
+ id: (path) => list().get(path),
462
+ }
463
+ }
464
+
465
+ /** A content rewrite the landing script applies to one path before it commits. */
466
+ interface Rewrite {
467
+ readonly path: string
468
+ readonly apply: (content: string) => string
469
+ }
470
+
471
+ /**
472
+ * The tree a landing would commit: the working tree, with any rewrite the
473
+ * landing script applies first. `entries` is `setup.pendingWorktree()` —
474
+ * already memoized — never a fresh `workspace.worktreeSync()` call; see that
475
+ * field's own comment for why the memo lives there and not on `Workspace`.
476
+ */
477
+ const pendingTree = (
478
+ workspace: WorkspaceOps,
479
+ entries: ReadonlyMap<string, string | undefined>,
480
+ rewrite: Rewrite | undefined,
481
+ ): TreeView => {
482
+ const paths = [...entries.keys()]
483
+ const readWorktree = (path: string): string | undefined => {
484
+ try {
485
+ return workspace.readSync(path)
486
+ } catch {
487
+ // A submodule or another non-file entry has no content to read.
488
+ return undefined
489
+ }
490
+ }
491
+ return {
492
+ paths: () => paths,
493
+ read: (path) => {
494
+ if (!entries.has(path)) return undefined
495
+ const content = readWorktree(path)
496
+ return content === undefined || rewrite?.path !== path ? content : rewrite.apply(content)
497
+ },
498
+ // The rewritten path's blob id is not the working tree's: compare its contents.
499
+ id: (path) => (rewrite?.path === path ? undefined : entries.get(path)),
500
+ }
501
+ }
502
+
503
+ // ── Variables ───────────────────────────────────────────────────────────────
504
+
505
+ /**
506
+ * The one resolver of both setting kinds. Process settings: the recorded
507
+ * (pinned) value wins per name, anything unrecorded resolves live — the
508
+ * fallback for a process started before pinning existed. Environment settings
509
+ * are always live. `startOverrides` are `--var` values, passed only while a
510
+ * process is being started.
511
+ */
512
+ const settingsFor = (
513
+ workflow: Pick<ResolvedWorkflow, "vars" | "env">,
514
+ pinnedVars: Readonly<Record<string, string>>,
515
+ hostEnv: Readonly<Record<string, string | undefined>>,
516
+ startOverrides: Readonly<Record<string, string>> = {},
517
+ ): { readonly vars: Record<string, string>; readonly env: Record<string, string> } => ({
518
+ vars: {
519
+ ...resolveVars(workflow.vars, startOverrides, hostEnv),
520
+ ...pinnedVars,
521
+ },
522
+ env: resolveVars(workflow.env, {}, hostEnv),
523
+ })
524
+
525
+ /** The workflow a run replays on: its pinned one, else the default. */
526
+ const workflowOf = (
527
+ config: ConfigOperations,
528
+ run: ProcessRun,
529
+ ): Effect.Effect<ResolvedWorkflow, Error> => {
530
+ if (run.legacyOpening) {
531
+ return Effect.fail(
532
+ new Error(
533
+ "gtd: this process was opened by `gtd --entry`, which no longer exists — run `gtd abandon` to start over",
534
+ ),
535
+ )
536
+ }
537
+ if (run.workflow === undefined) return Effect.succeed(config.defaultWorkflow)
538
+ const named = config.workflowNamed(run.workflow)
539
+ if (named !== undefined) return Effect.succeed(named)
540
+ return Effect.fail(
541
+ new Error(
542
+ `gtd: this process runs workflow "${run.workflow}", which gtd.config.ts no longer defines — run \`gtd abandon\` to start over`,
543
+ ),
544
+ )
545
+ }
546
+
547
+ /**
548
+ * `judgeBudgetBytes` is the one var that refuses rather than disabling its
549
+ * mechanism when blanked: without a bound the judge payload is rejected
550
+ * anyway. Absent falls back to a conservative default.
551
+ */
552
+ const DEFAULT_JUDGE_BUDGET_BYTES = 32768
553
+ const judgeBudgetBytes = (vars: Record<string, string>): number => {
554
+ const raw = vars.judgeBudgetBytes
555
+ if (raw === undefined) return DEFAULT_JUDGE_BUDGET_BYTES
556
+ const n = Number(raw)
557
+ if (raw.trim() === "" || !Number.isInteger(n) || n <= 0) {
558
+ throw new Error(
559
+ `"judgeBudgetBytes" must be a positive integer — got ${JSON.stringify(raw)} (blanking this var disables the payload bound rather than the mechanism it guards, so it is refused rather than defaulted)`,
560
+ )
561
+ }
562
+ return n
563
+ }
564
+
565
+ // ── Replay ──────────────────────────────────────────────────────────────────
566
+
567
+ interface ReplaySetup {
568
+ readonly def: WorkflowDefinition
569
+ readonly run: ProcessRun
570
+ readonly vars: Record<string, string>
571
+ readonly env: Record<string, string>
572
+ readonly budget: number
573
+ readonly skills: Record<string, readonly string[]>
574
+ readonly access: Record<string, ScopeAccess>
575
+ readonly workspace: WorkspaceOps
576
+ /**
577
+ * The episode's base tree and every commit's, built ONCE — after
578
+ * `Workspace.episodeTrees` has already materialized every one of them —
579
+ * and reused by both replays one `gtd` command runs (`restAt`'s own, then
580
+ * `decideLanding`'s). Rebuilding these per `replayFor` call bought nothing
581
+ * once the underlying `treeSync`/`readCommittedSync` reads are themselves
582
+ * cached, but still allocated a fresh `TreeView` (and its own `contents`
583
+ * memo) per replay for no reason.
584
+ */
585
+ readonly base: { readonly hash: string; readonly tree: TreeView }
586
+ readonly episode: readonly EpisodeCommit[]
587
+ /**
588
+ * The working tree `git add -A` would commit, read at most once — memoized
589
+ * HERE, per `ReplaySetup` (built fresh every `restAt` call), rather than on
590
+ * `Workspace` itself: a long-lived runtime reusing one `Workspace` across
591
+ * requests would otherwise keep serving the FIRST request's working tree
592
+ * forever. A new
593
+ * `ReplaySetup` per `restAt` call is what makes each request see the
594
+ * working tree as it stands right then, while still reading it only once
595
+ * across that one request's `decideLanding`/`startRefusal` replay.
596
+ */
597
+ readonly pendingWorktree: () => ReadonlyMap<string, string | undefined>
598
+ }
599
+
600
+ const replayFor = (
601
+ setup: ReplaySetup,
602
+ pending?: { readonly tree: TreeView; readonly verdicts?: readonly JudgeVerdict[] },
603
+ ): Promise<ReplayOutcome> =>
604
+ replay({
605
+ flow: setup.def.flow,
606
+ episode: { base: setup.base, commits: setup.episode },
607
+ vars: setup.vars,
608
+ env: setup.env,
609
+ start: setup.run.diffBase,
610
+ startTree:
611
+ setup.run.diffBase === EMPTY_TREE
612
+ ? treeFromRecord({})
613
+ : commitTree(setup.workspace, setup.run.diffBase),
614
+ budgetBytes: setup.budget,
615
+ skills: setup.skills,
616
+ configuredSkills: setup.def.configuredSkills,
617
+ access: setup.access,
618
+ configuredAccess: setup.def.configuredAccess,
619
+ ...(pending !== undefined ? { pending } : {}),
620
+ })
621
+
622
+ const replayError = (outcome: ReplayOutcome): Error | undefined => {
623
+ if (outcome.kind === "divergence" || outcome.kind === "failed") return new Error(outcome.message)
624
+ if (outcome.kind === "refused") return new Error(`gtd: ${outcome.message}`)
625
+ if (outcome.kind === "ended") {
626
+ return new Error(
627
+ "gtd: history ends the episode, but HEAD does not enter the default workflow's first step — the workflow changed under this process; run `gtd abandon` to start over",
628
+ )
629
+ }
630
+ return undefined
631
+ }
632
+
633
+ // ── The rest ────────────────────────────────────────────────────────────────
634
+
635
+ const judgeDocument = (step: ReachedStep): string | undefined =>
636
+ step.request.kind === "judge"
637
+ ? judgeJson(step.request.evidence, step.request.questions)
638
+ : undefined
639
+
640
+ /**
641
+ * The fixed sentence appended to a judge gate's message when a bounded read
642
+ * dropped bytes to fit `judgeBudgetBytes` — a constant, so no workflow can
643
+ * reword or drop it.
644
+ */
645
+ export const TRUNCATION_NOTICE =
646
+ "Note: some evidence above was truncated to fit the judge's payload budget."
647
+
648
+ const DEFAULT_JUDGE_MESSAGE =
649
+ "A judgment is pending. Run `gtd judge answer` and pipe a verdict, or land to take the conservative default with no verdict recorded."
650
+
651
+ const optional = <K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } =>
652
+ (value === undefined ? {} : { [key]: value }) as { [P in K]?: V }
653
+
654
+ type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode">
655
+
656
+ const commonOf = (step: ReachedStep): StepCommon => {
657
+ const options = step.request.options
658
+ return {
659
+ actor: step.actor,
660
+ ...optional("label", options.label),
661
+ ...optional("file", options.file),
662
+ ...optional("mode", options.mode),
663
+ }
664
+ }
665
+
666
+ type RequestOf<K extends ReachedStep["request"]["kind"]> = Extract<
667
+ ReachedStep["request"],
668
+ { kind: K }
669
+ >
670
+
671
+ const promptDef = (
672
+ common: StepCommon,
673
+ request: RequestOf<"agent">,
674
+ skills: readonly string[] | undefined,
675
+ access: StepAccess | undefined,
676
+ ): StepDef => ({
677
+ ...common,
678
+ kind: "prompt",
679
+ content: request.prompt,
680
+ ...optional("model", request.options.model),
681
+ ...optional("system", request.options.system),
682
+ ...optional("allowEmpty", request.options.allowEmpty),
683
+ ...optional("skills", skills),
684
+ ...optional("access", access),
685
+ })
686
+
687
+ const scriptDef = (common: StepCommon, request: RequestOf<"run">): StepDef => ({
688
+ ...common,
689
+ kind: "script",
690
+ content: request.body,
691
+ })
692
+
693
+ const judgeDef = (common: StepCommon, request: RequestOf<"judge">, step: ReachedStep): StepDef => {
694
+ const message = request.options.message ?? DEFAULT_JUDGE_MESSAGE
695
+ return {
696
+ ...common,
697
+ kind: "message",
698
+ content: step.truncated ? `${message}\n\n${TRUNCATION_NOTICE}` : message,
699
+ ...optional("judge", judgeDocument(step)),
700
+ }
701
+ }
702
+
703
+ const messageDef = (
704
+ common: StepCommon,
705
+ request: RequestOf<"human">,
706
+ step: ReachedStep,
707
+ ): StepDef => ({
708
+ ...common,
709
+ kind: "message",
710
+ content: request.options.message ?? request.options.label ?? step.name,
711
+ ...optional("acceptClean", request.options.acceptClean),
712
+ })
713
+
714
+ const stepDefOf = (step: ReachedStep): StepDef => {
715
+ const request = step.request
716
+ const common = commonOf(step)
717
+ switch (request.kind) {
718
+ case "agent":
719
+ return promptDef(common, request, step.skills, step.access)
720
+ case "run":
721
+ return scriptDef(common, request)
722
+ case "judge":
723
+ return judgeDef(common, request, step)
724
+ case "human":
725
+ return messageDef(common, request, step)
726
+ }
727
+ }
728
+
729
+ /** Is `name` inside `scope`'s subtree? The root scope `""` holds everything. */
730
+ const inScope = (name: string, scope: string): boolean =>
731
+ scope === "" || name === scope || name.startsWith(`${scope}.`)
732
+
733
+ /**
734
+ * Where the current unbroken run of rests inside `scope`'s subtree began —
735
+ * a dip into a descendant scope does not break it, a sibling or ancestor
736
+ * does, and so does a fresh `scope()` call of the same scope.
737
+ */
738
+ const scopeRunStart = (trace: readonly ReachedStep[], scope: string): number => {
739
+ let start = -1
740
+ for (let k = 0; k < trace.length; k++) {
741
+ if (!inScope(trace[k]!.memoryScope, scope)) continue
742
+ const previous = trace[k - 1]
743
+ const unbroken =
744
+ previous !== undefined &&
745
+ inScope(previous.memoryScope, scope) &&
746
+ scopeCallOf(previous, scope) === scopeCallOf(trace[k]!, scope)
747
+ if (!unbroken) start = k
748
+ }
749
+ return start
750
+ }
751
+
752
+ /** The innermost `scope()` call enclosing `scope` at `step` — a new call is a new conversation. */
753
+ const scopeCallOf = (step: ReachedStep, scope: string): number | undefined => {
754
+ let call: number | undefined
755
+ for (const entry of step.scopeCalls) if (inScope(scope, entry.prefix)) call = entry.call
756
+ return call
757
+ }
758
+
759
+ // Shown for the root scope, which has no name of its own.
760
+ const ROOT_MEMORY_SCOPE_NAME = "root"
761
+
762
+ /**
763
+ * An agent rest's memory key, `<scope>#<hash7>`: the hash is the commit the
764
+ * current unbroken run into the scope started FROM, so the same run always
765
+ * re-derives the same key. `resumed` is whether an agent turn in that run
766
+ * already landed.
767
+ */
768
+ const memoryOf = (
769
+ trace: readonly ReachedStep[],
770
+ run: ProcessRun,
771
+ ): { readonly key: string | undefined; readonly resumed: boolean } => {
772
+ const rest = trace[trace.length - 1]
773
+ if (rest === undefined || rest.kind !== "agent") return { key: undefined, resumed: false }
774
+ const start = scopeRunStart(trace, rest.memoryScope)
775
+ const token = start <= 0 ? run.startParentHash : trace[start - 1]!.enteredAt
776
+ // A child scope's turns keep the run unbroken but are their own conversation.
777
+ const resumed = trace
778
+ .slice(Math.max(start, 0), trace.length - 1)
779
+ .some((s) => s.kind === "agent" && s.memoryScope === rest.memoryScope)
780
+ return { key: `${rest.memoryScope || ROOT_MEMORY_SCOPE_NAME}#${token.slice(0, 7)}`, resumed }
781
+ }
782
+
783
+ /**
784
+ * A rest's hints, as the wire carries them. Optional keys are omitted, never
785
+ * `undefined`. Not strings-only: `skills` carries an array, already split —
786
+ * the wire does no parsing of its own.
787
+ */
788
+ export interface RestHints {
789
+ readonly model?: string
790
+ readonly label?: string
791
+ readonly file?: string
792
+ readonly judge?: string
793
+ readonly system?: string
794
+ readonly mode?: string
795
+ readonly skills?: readonly string[]
796
+ readonly access?: StepAccess
797
+ }
798
+
799
+ const hintsOf = (def: StepDef): RestHints => ({
800
+ ...optional("model", def.model),
801
+ ...optional("label", def.label),
802
+ ...optional("file", def.file),
803
+ ...optional("judge", def.judge),
804
+ ...optional("system", def.system),
805
+ ...optional("mode", def.mode),
806
+ ...optional("skills", def.skills),
807
+ ...optional("access", def.access),
808
+ })
809
+
810
+ const normalizeStatus = (raw: string): ChangeStatus => (raw === "A" ? "A" : raw === "D" ? "D" : "M")
811
+
812
+ /** The currently rested step and its description — enough for the viewer and the LSP. */
813
+ interface ResolvedRest {
814
+ readonly def: WorkflowDefinition
815
+ /** The workflow the process runs: its pinned name, or the default's display name. */
816
+ readonly workflowName: string
817
+ readonly state: StateName
818
+ readonly stepDef: StepDef
819
+ readonly actor: string
820
+ }
821
+
822
+ /**
823
+ * Where the process rests right now, fully resolved. ONE SNAPSHOT, taken
824
+ * before any mutation.
825
+ */
826
+ interface Rest extends ResolvedRest {
827
+ readonly step: ReachedStep
828
+ readonly trace: readonly ReachedStep[]
829
+ readonly run: ProcessRun
830
+ readonly vars: Record<string, string>
831
+ readonly env: Record<string, string>
832
+ /** What a landing that starts the process records. */
833
+ readonly config: ConfigFingerprint
834
+ readonly changes: readonly PendingChange[]
835
+ readonly memory: string | undefined
836
+ readonly memoryResumed: boolean
837
+ readonly hints: RestHints
838
+ /** Total recorded cost of the process, and its per-model breakdown. */
839
+ readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
840
+ readonly setup: ReplaySetup
841
+ }
842
+
843
+ /** Per-model token totals, highest-cost first (ties broken by model name). */
844
+ const costByModel = (entries: readonly CostEntry[]): ModelCost[] => {
845
+ const byModel = new Map<string, number>()
846
+ for (const entry of entries)
847
+ byModel.set(entry.model, (byModel.get(entry.model) ?? 0) + entry.cost)
848
+ return [...byModel.entries()]
849
+ .map(([model, cost]) => ({ model, cost }))
850
+ .sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model))
851
+ }
852
+
853
+ export type RestRequirements = ConfigRequirements
854
+
855
+ /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
856
+ export const restAt = (
857
+ ref: string | undefined,
858
+ startOverrides: Readonly<Record<string, string>> = {},
859
+ ): Effect.Effect<Rest, Error, RestRequirements> =>
860
+ Effect.gen(function* () {
861
+ const git = yield* GitService
862
+ const config = yield* (yield* ConfigService).load
863
+ const workspace = yield* Workspace
864
+ const host = yield* Host
865
+ const run = yield* computeProcessRun(git, config.workflow, ref)
866
+ const resolved = yield* workflowOf(config, run)
867
+ const def = resolved.def
868
+ const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides)
869
+ const skills = yield* resolveScopeSkills(def, vars)
870
+ const access = yield* resolveScopeAccess(def, skills, vars)
871
+ const budget = yield* Effect.try({
872
+ try: () => judgeBudgetBytes(vars),
873
+ catch: (e) => (e instanceof Error ? e : new Error(String(e))),
874
+ })
875
+ // One bulk fetch materializes the base's and every commit's tree — via
876
+ // `Workspace`'s own cache, at most two subprocesses regardless of the
877
+ // episode's length — BEFORE `commitTree` below ever calls `treeSync`/
878
+ // `readCommittedSync` per commit, so those calls always hit the cache
879
+ // this just warmed.
880
+ yield* workspace.episodeTrees(
881
+ run.episode.base,
882
+ run.episode.commits.map((c) => c.hash),
883
+ )
884
+ const base =
885
+ run.episode.base === undefined
886
+ ? { hash: EMPTY_TREE, tree: treeFromRecord({}) }
887
+ : { hash: run.episode.base, tree: commitTree(workspace, run.episode.base) }
888
+ const episode: readonly EpisodeCommit[] = run.episode.commits.map((c) => ({
889
+ hash: c.hash,
890
+ message: c.message,
891
+ tree: commitTree(workspace, c.hash),
892
+ }))
893
+ let pendingMemo: ReadonlyMap<string, string | undefined> | undefined
894
+ const setup: ReplaySetup = {
895
+ def,
896
+ run,
897
+ vars,
898
+ env,
899
+ budget,
900
+ skills,
901
+ access,
902
+ workspace,
903
+ base,
904
+ episode,
905
+ pendingWorktree: () => (pendingMemo ??= workspace.worktreeSync()),
906
+ }
907
+ // No `pending` tree: `restAt` resolves the LANDED process only, never a
908
+ // pending working-tree edit (that's `decideLanding`'s own `replayFor`
909
+ // call, below, deciding what landing right now WOULD do). `gtd lsp` caches
910
+ // `gtd next --json`'s `state`/`file`/`mode` per HEAD and depends on this
911
+ // staying true; passing a pending tree here would make it serve a stale
912
+ // steering file on every cache hit.
913
+ const outcome = yield* Effect.promise(() => replayFor(setup))
914
+ const error = replayError(outcome)
915
+ if (error !== undefined || outcome.kind !== "rest") {
916
+ return yield* Effect.fail(error ?? new Error("gtd: replay found no rest"))
917
+ }
918
+ const step = outcome.rest
919
+ yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`)
920
+ const stepDef = stepDefOf(step)
921
+ if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
922
+ return yield* Effect.fail(
923
+ new Error(
924
+ `gtd config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
925
+ ),
926
+ )
927
+ }
928
+ const changes =
929
+ ref === undefined
930
+ ? (yield* git.changedPaths()).map((e) => ({
931
+ status: normalizeStatus(e.status),
932
+ path: e.path,
933
+ }))
934
+ : []
935
+ const memory = memoryOf(outcome.trace, run)
936
+ const configNow = configFingerprint(config.workflowFiles, host.root)
937
+ // Only the current rest warns, so a command must read it once — a second
938
+ // read warns twice. An earlier ref (restore's tip) would warn about a
939
+ // process the command is not acting on.
940
+ if (ref === undefined) yield* warnConfigChanges(run, configNow, workspace)
941
+ return {
942
+ def,
943
+ workflowName: resolved.name,
944
+ state: step.name,
945
+ stepDef,
946
+ actor: step.actor,
947
+ step,
948
+ trace: outcome.trace,
949
+ run,
950
+ vars,
951
+ env,
952
+ config: configNow,
953
+ changes,
954
+ memory: memory.key,
955
+ memoryResumed: memory.resumed,
956
+ hints: hintsOf(stepDef),
957
+ cost: {
958
+ total: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
959
+ byModel: costByModel(run.costEntries),
960
+ },
961
+ setup,
962
+ }
963
+ })
964
+
965
+ export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
966
+
967
+ /**
968
+ * Whether `gtd --workflow <name>` can start a process here: the workflow must
969
+ * reach a step from the tree the opening commit would capture. That step is
970
+ * what the opening commit's subject names.
971
+ */
972
+ export const startRefusal = (
973
+ rest: Rest,
974
+ resolved: ResolvedWorkflow,
975
+ startVars: Record<string, string>,
976
+ ): Effect.Effect<
977
+ { readonly firstStep: string } | { readonly refusal: string },
978
+ Error,
979
+ ConfigRequirements
980
+ > =>
981
+ Effect.gen(function* () {
982
+ const host = yield* Host
983
+ const { vars, env } = settingsFor(resolved, {}, host.env, startVars)
984
+ const skills = yield* resolveScopeSkills(resolved.def, vars)
985
+ const access = yield* resolveScopeAccess(resolved.def, skills, vars)
986
+ const workspace = rest.setup.workspace
987
+ const outcome = yield* Effect.promise(() =>
988
+ replay({
989
+ flow: resolved.def.flow,
990
+ episode: {
991
+ base: { hash: "", tree: pendingTree(workspace, rest.setup.pendingWorktree(), undefined) },
992
+ commits: [],
993
+ },
994
+ vars,
995
+ env,
996
+ start: "",
997
+ budgetBytes: rest.setup.budget,
998
+ skills,
999
+ configuredSkills: resolved.def.configuredSkills,
1000
+ access,
1001
+ configuredAccess: resolved.def.configuredAccess,
1002
+ }),
1003
+ )
1004
+ if (outcome.kind === "rest") return { firstStep: outcome.rest.name }
1005
+ if (outcome.kind === "refused") return { refusal: outcome.message }
1006
+ const why = outcome.kind === "ended" ? "the flow reaches no step" : outcome.message
1007
+ return { refusal: `workflow "${resolved.name}" cannot start — ${why}` }
1008
+ })
1009
+
1010
+ /** The review window's diff base at the rest. */
1011
+ export const reviewBaseFor = (rest: Rest): string =>
1012
+ rest.step.request.options.base ?? rest.run.diffBase
1013
+
1014
+ // ── Rendering ───────────────────────────────────────────────────────────────
1015
+
1016
+ export interface RenderedRest extends RestHints {
1017
+ readonly state: StateName
1018
+ readonly actor: string
1019
+ readonly kind: StepDef["kind"]
1020
+ readonly content: string
1021
+ readonly memory?: string
1022
+ readonly memoryResumed: boolean
1023
+ }
1024
+
1025
+ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
1026
+ Effect.succeed({
1027
+ state: rest.state,
1028
+ actor: rest.actor,
1029
+ kind: rest.stepDef.kind,
1030
+ content: rest.stepDef.content,
1031
+ ...rest.hints,
1032
+ ...(rest.memory !== undefined ? { memory: rest.memory } : {}),
1033
+ memoryResumed: rest.memoryResumed,
1034
+ })
1035
+
1036
+ /**
1037
+ * Derived, restart-proof stall detection: the tree is clean, HEAD is an empty
1038
+ * attempt at this very agent rest, and another dispatch would repeat it.
1039
+ */
1040
+ export const stalledAt = (rest: Rest): boolean =>
1041
+ rest.changes.length === 0 &&
1042
+ rest.stepDef.kind === "prompt" &&
1043
+ rest.stepDef.allowEmpty !== true &&
1044
+ rest.run.headTurn?.state === rest.state &&
1045
+ rest.run.headTurn.actor === rest.actor &&
1046
+ !rest.run.headTurn.step &&
1047
+ rest.run.headTurn.empty
1048
+
1049
+ /** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
1050
+ export const noProcessUnderway = (rest: Rest): boolean =>
1051
+ rest.run.workflow === undefined &&
1052
+ rest.state === rest.def.initial &&
1053
+ rest.step.id.occurrence === 1
1054
+
1055
+ /** `idle` means exactly one thing: no process underway, clean tree. */
1056
+ export const restIsIdle = (rest: Rest): boolean =>
1057
+ noProcessUnderway(rest) && rest.changes.length === 0
1058
+
1059
+ // ── Landing ─────────────────────────────────────────────────────────────────
1060
+
1061
+ const isHumanReviewGate = (def: StepDef): boolean => def.actor === "human" && def.mode === "review"
1062
+
1063
+ /** The landing a clean tree decides before any replay, if it decides one. */
1064
+ const cleanLanding = (rest: Rest): Landing | undefined => {
1065
+ if (rest.changes.length > 0) return undefined
1066
+ const def = rest.stepDef
1067
+ if (def.kind === "prompt" && def.allowEmpty !== true) {
1068
+ return { kind: "attempt", subject: formatSubject(rest.actor, rest.state) }
1069
+ }
1070
+ if (rest.actor === "human" && def.acceptClean !== true) return { kind: "noop", settled: false }
1071
+ return undefined
1072
+ }
1073
+
1074
+ /** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
1075
+ const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
1076
+ const reviewFormat = steeringFormatFor("review")
1077
+ if (!isHumanReviewGate(def) || def.file === undefined || reviewFormat === undefined) {
1078
+ return undefined
1079
+ }
1080
+ return { path: def.file, apply: (content) => reviewFormat.clearTicks(content) }
1081
+ }
1082
+
1083
+ const multilineRefusal = (vars: Record<string, string>): Landing | undefined => {
1084
+ const multiline = multilineSetting(vars)
1085
+ return multiline === undefined
1086
+ ? undefined
1087
+ : {
1088
+ kind: "refusal",
1089
+ message: `gtd: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`,
1090
+ }
1091
+ }
1092
+
1093
+ const landingTarget = (
1094
+ outcome: Awaited<ReturnType<typeof replayFor>>,
1095
+ rest: Rest,
1096
+ ): Effect.Effect<StateName | Landing, Error> => {
1097
+ if (outcome.kind === "refused")
1098
+ return Effect.succeed({ kind: "refusal", message: outcome.message })
1099
+ if (outcome.kind === "divergence" || outcome.kind === "failed") {
1100
+ return Effect.fail(new Error(outcome.message))
1101
+ }
1102
+ if (outcome.kind !== "rest") return Effect.succeed(rest.def.initial)
1103
+ const reserved = initialStepRefusal(rest.run.workflow, outcome.rest.name, rest.def.initial)
1104
+ return Effect.succeed(reserved === undefined ? outcome.rest.name : reserved)
1105
+ }
1106
+
1107
+ /**
1108
+ * A step commit entering the default's first step is what ends an episode, so
1109
+ * a named workflow landing there would read back as finished. Its opening
1110
+ * commit may name that step: a bare opening is never read as a boundary.
1111
+ */
1112
+ const initialStepRefusal = (
1113
+ workflow: string | undefined,
1114
+ step: StateName,
1115
+ initial: StateName,
1116
+ ): Landing | undefined =>
1117
+ workflow !== undefined && step === initial
1118
+ ? {
1119
+ kind: "refusal",
1120
+ message: `gtd: workflow "${workflow}" reaches step "${step}", the default workflow's first step — that is where a finished process waits, so a started process must never land there; rename the step`,
1121
+ }
1122
+ : undefined
1123
+
1124
+ const landingTo = (rest: Rest, to: StateName): Landing => {
1125
+ if (rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state) {
1126
+ return { kind: "noop", settled: true }
1127
+ }
1128
+ // The landing that leaves the flow's first step starts the process: it
1129
+ // pins the process settings every later invocation reads back.
1130
+ const starts = noProcessUnderway(rest) && to !== rest.def.initial
1131
+ const refused = starts ? multilineRefusal(rest.vars) : undefined
1132
+ if (refused !== undefined) return refused
1133
+ return {
1134
+ kind: "commit",
1135
+ to,
1136
+ spec: {
1137
+ actor: rest.actor,
1138
+ from: rest.state,
1139
+ to,
1140
+ step: rest.step.id,
1141
+ ...(starts ? { vars: rest.vars, config: rest.config } : {}),
1142
+ },
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * What landing the pending turn at `rest` does — a pure decision from a
1148
+ * replay with the working tree as the pending turn. The target step is
1149
+ * computed by replaying, never matched.
1150
+ */
1151
+ const decideLanding = (
1152
+ rest: Rest,
1153
+ verdicts: readonly JudgeVerdict[] | undefined,
1154
+ ): Effect.Effect<Landing, Error> =>
1155
+ Effect.gen(function* () {
1156
+ const early = cleanLanding(rest)
1157
+ if (early !== undefined) return early
1158
+ if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
1159
+ const message = accessRefusal(rest.changes, rest.stepDef.access.write)
1160
+ if (message !== undefined) return { kind: "refusal", message }
1161
+ }
1162
+ const outcome = yield* Effect.promise(() =>
1163
+ replayFor(rest.setup, {
1164
+ tree: pendingTree(
1165
+ rest.setup.workspace,
1166
+ rest.setup.pendingWorktree(),
1167
+ reviewGateRewrite(rest.stepDef),
1168
+ ),
1169
+ ...(verdicts !== undefined ? { verdicts } : {}),
1170
+ }),
1171
+ )
1172
+ const to = yield* landingTarget(outcome, rest)
1173
+ if (typeof to !== "string") return to
1174
+ return landingTo(rest, to)
1175
+ })
1176
+
1177
+ /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
1178
+ export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
1179
+ decideLanding(rest, undefined).pipe(
1180
+ Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
1181
+ Effect.catchAll(() => Effect.succeed(undefined)),
1182
+ )
1183
+
1184
+ /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
1185
+ export const snapshotFromRest = (
1186
+ rest: Rest,
1187
+ verdicts?: readonly JudgeVerdict[],
1188
+ ): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
1189
+ Effect.gen(function* () {
1190
+ const landing = yield* decideLanding(rest, verdicts)
1191
+ return {
1192
+ stepDef: rest.stepDef,
1193
+ state: rest.state,
1194
+ actor: rest.actor,
1195
+ changes: rest.changes,
1196
+ file: rest.hints.file,
1197
+ landing,
1198
+ }
1199
+ })
1200
+
1201
+ // ── Summary ─────────────────────────────────────────────────────────────────
1202
+
1203
+ /** `gtd summary`'s prompt for `run`, or `undefined` when the workflow has none or there is nothing to summarize. */
1204
+ export const summaryFor = (
1205
+ run: ProcessRun,
1206
+ ): Effect.Effect<string | undefined, Error, RestRequirements> =>
1207
+ Effect.gen(function* () {
1208
+ const config = yield* (yield* ConfigService).load
1209
+ const host = yield* Host
1210
+ const resolved = yield* workflowOf(config, run)
1211
+ const summary = resolved.def.summary
1212
+ if (summary === undefined || run.trace.length === 0) return undefined
1213
+ const entryCommit = run.trace[0]!.hash
1214
+ return summary({
1215
+ entryCommit,
1216
+ processBase: run.startParentHash,
1217
+ processTip: run.trace[run.trace.length - 1]!.hash,
1218
+ humanCommits: run.trace
1219
+ .filter((entry) => entry.actor === "human" && entry.hash !== entryCommit)
1220
+ .map((entry) => ({ hash: entry.hash, state: entry.state })),
1221
+ processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
1222
+ processCostByModel: costByModel(run.costEntries),
1223
+ ...settingsFor(resolved, run.pinnedVars, host.env),
1224
+ })
1225
+ })