@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,631 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { createHash } from "node:crypto"
3
+ import {
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readlinkSync,
8
+ renameSync,
9
+ writeFileSync,
10
+ } from "node:fs"
11
+ import { dirname, isAbsolute, join } from "node:path"
12
+ import { Context, Effect, Layer } from "effect"
13
+ import { GitService, type GitOperations } from "./Git.js"
14
+ import { Host } from "./Host.js"
15
+
16
+ const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
17
+
18
+ // A tree listing, or one committed file, can overflow execFileSync's 1 MB default.
19
+ const MAX_BUFFER = 256 * 1024 * 1024
20
+ const HASH_CHUNK = 500
21
+
22
+ /** A full sha1 or sha256 object id — the only refs an immutable, disk-cached read is safe to key on. */
23
+ const isObjectHash = (ref: string): boolean => /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(ref)
24
+
25
+ const GTD_CACHE_SUBDIR = "gtd-cache"
26
+
27
+ const treeCachePath = (commonDir: string, hash: string): string =>
28
+ join(commonDir, GTD_CACHE_SUBDIR, "trees", hash)
29
+
30
+ const blobCachePath = (commonDir: string, id: string): string =>
31
+ join(commonDir, GTD_CACHE_SUBDIR, "blobs", id)
32
+
33
+ const readTextCache = (path: string): string | undefined => {
34
+ try {
35
+ return readFileSync(path, "utf8")
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Atomic write: a temp name in the SAME directory, `rename`d into place —
43
+ * rename is atomic on POSIX, so a concurrent reader sees either the old file
44
+ * or the whole new one, never a half-written one. Any failure (full disk, a
45
+ * read-only git directory, a raced directory removal) is swallowed: the
46
+ * cache is a best-effort speedup, never a correctness dependency, and the
47
+ * alternative — failing a gtd command over a cache write — is worse.
48
+ */
49
+ const writeTextCacheAtomic = (path: string, content: string): void => {
50
+ try {
51
+ mkdirSync(dirname(path), { recursive: true })
52
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
53
+ writeFileSync(tmp, content, "utf8")
54
+ renameSync(tmp, path)
55
+ } catch {
56
+ // A full disk or read-only git dir silently disables the cache.
57
+ }
58
+ }
59
+
60
+ /** A commit's `path → blob id` map, serialized as JSON. `undefined` on ANY read failure — missing, truncated, unparseable — so the caller falls back to asking git. */
61
+ const readTreeCache = (commonDir: string, hash: string): Map<string, string> | undefined => {
62
+ const raw = readTextCache(treeCachePath(commonDir, hash))
63
+ if (raw === undefined) return undefined
64
+ try {
65
+ const parsed: unknown = JSON.parse(raw)
66
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
67
+ const map = new Map<string, string>()
68
+ for (const [path, id] of Object.entries(parsed as Record<string, unknown>)) {
69
+ if (typeof id !== "string") return undefined
70
+ map.set(path, id)
71
+ }
72
+ return map
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
77
+
78
+ const writeTreeCache = (commonDir: string, hash: string, tree: ReadonlyMap<string, string>): void =>
79
+ writeTextCacheAtomic(treeCachePath(commonDir, hash), JSON.stringify(Object.fromEntries(tree)))
80
+
81
+ /**
82
+ * One raw `git log --raw` diff entry, already resolved to what it does to a
83
+ * `path → blob id` map — a rename deletes its old path, a copy does not.
84
+ */
85
+ interface RawDiffEntry {
86
+ readonly status: string
87
+ readonly oldPath?: string
88
+ readonly path: string
89
+ readonly newId: string
90
+ }
91
+
92
+ /** Parses a `:`-prefixed raw-diff header token into its status letter and new blob id. */
93
+ const parseRawDiffHeader = (
94
+ header: string,
95
+ ): { readonly letter: string; readonly newId: string } => {
96
+ const fields = header
97
+ .slice(1)
98
+ .split(" ")
99
+ .filter((f) => f.length > 0)
100
+ const status = fields[4] ?? ""
101
+ const newId = fields[3] ?? ""
102
+ return { letter: status[0] ?? "", newId }
103
+ }
104
+
105
+ /**
106
+ * Consumes the 1 (or, for rename/copy, 2) NUL-separated path tokens that
107
+ * follow a raw-diff header, starting at `tokens[i]`. Returns the resulting
108
+ * entry (`undefined` if a path token is missing) and the next index.
109
+ */
110
+ const consumeRawDiffPaths = (
111
+ tokens: readonly string[],
112
+ i: number,
113
+ letter: string,
114
+ newId: string,
115
+ ): { readonly entry: RawDiffEntry | undefined; readonly next: number } => {
116
+ if (letter === "R" || letter === "C") {
117
+ const oldPath = tokens[i]
118
+ const path = tokens[i + 1]
119
+ const entry =
120
+ oldPath !== undefined && path !== undefined
121
+ ? { status: letter, oldPath, path, newId }
122
+ : undefined
123
+ return { entry, next: i + 2 }
124
+ }
125
+ const path = tokens[i]
126
+ const entry = path !== undefined ? { status: letter, path, newId } : undefined
127
+ return { entry, next: i + 1 }
128
+ }
129
+
130
+ /** Parses the NUL-separated body of ONE commit's `--raw -z` diff (the format string's own separator already stripped). */
131
+ const parseRawDiff = (body: string): readonly RawDiffEntry[] => {
132
+ const tokens = body.split("\0").filter((t) => t.length > 0)
133
+ const entries: RawDiffEntry[] = []
134
+ let i = 0
135
+ while (i < tokens.length) {
136
+ const header = tokens[i]!
137
+ i += 1
138
+ if (!header.startsWith(":")) continue
139
+ const { letter, newId } = parseRawDiffHeader(header)
140
+ const { entry, next } = consumeRawDiffPaths(tokens, i, letter, newId)
141
+ i = next
142
+ if (entry !== undefined) entries.push(entry)
143
+ }
144
+ return entries
145
+ }
146
+
147
+ /** Applies one commit's raw-diff entries to a (mutable, working-copy) tree map. */
148
+ const applyRawDiff = (tree: Map<string, string>, entries: readonly RawDiffEntry[]): void => {
149
+ for (const entry of entries) {
150
+ if (entry.status === "D") tree.delete(entry.path)
151
+ else {
152
+ if (entry.status === "R") tree.delete(entry.oldPath!)
153
+ tree.set(entry.path, entry.newId)
154
+ }
155
+ }
156
+ }
157
+
158
+ /** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → blob id`. */
159
+ const parseLsTree = (out: string): Map<string, string> => {
160
+ const entries = new Map<string, string>()
161
+ for (const record of out.split("\0")) {
162
+ const tab = record.indexOf("\t")
163
+ if (tab === -1) continue
164
+ const id = record.slice(0, tab).split(" ")[2]
165
+ if (id !== undefined) entries.set(record.slice(tab + 1), id)
166
+ }
167
+ return entries
168
+ }
169
+
170
+ /**
171
+ * Runs `git ls-tree` for `ref` and reports whether it actually succeeded —
172
+ * `ok: false` on ANY subprocess failure (a transient `EAGAIN`/`ENOMEM` on
173
+ * fork, a `git gc` race, a signal), never folded into an empty tree here.
174
+ * The disk/memo cache must only ever hold a git-CONFIRMED tree: an immutable
175
+ * key can serve a wrong answer forever if what's stored under it came from a
176
+ * failed read rather than from the key itself, so callers that cache must
177
+ * check `ok` before doing so.
178
+ */
179
+ const fetchTree = (
180
+ root: string,
181
+ ref: string,
182
+ ): { readonly ok: boolean; readonly entries: Map<string, string> } => {
183
+ try {
184
+ return {
185
+ ok: true,
186
+ entries: parseLsTree(
187
+ execFileSync("git", ["ls-tree", "-r", "-z", "--full-tree", ref], {
188
+ cwd: root,
189
+ encoding: "utf8",
190
+ maxBuffer: MAX_BUFFER,
191
+ }),
192
+ ),
193
+ }
194
+ } catch {
195
+ return { ok: false, entries: new Map() }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * The one port onto repo file content, in four REPO-RELATIVE read shapes:
201
+ * `readSync` and `readCommittedSync` (replay reads the tree between two
202
+ * awaits and cannot `yield*` — see the seam rule in `index.ts`), `read` (the
203
+ * Effect shape everything else uses) and `committed` (the async read-at-ref
204
+ * path, `git show <ref>:<path>`). Absence is
205
+ * a VALUE (`undefined`) on every read shape, never an error — a
206
+ * genuine fault (EACCES, EISDIR) still fails/throws for the two working-tree
207
+ * shapes; `committed`/`readCommittedSync` fold EVERY git failure (missing
208
+ * ref, missing path, a real fault) into absence, matching `git show`'s own
209
+ * flat non-zero-exit signal. An ABSOLUTE `path` on any of these four (plus
210
+ * `write`) fails loudly rather than silently escaping the repo — that escape
211
+ * is `atPath`/`writeAtPath`'s job alone, named so it reads as deliberate at
212
+ * each call site: `src/workflow/load.ts`'s cwd→home discovery walk
213
+ * (read-only); and `Program.ts`'s `gtd check`/`gtd uncheck`,
214
+ * whose `<file>` argument is an arbitrary CLI-given path — absolute or
215
+ * relative to `root` — never a repo-scoped one.
216
+ * Keeping the escape on separately-named members is what makes "a
217
+ * repo-relative path reaching `/tmp`" (the failure mode `Host`'s separate
218
+ * scratch port exists to prevent) a loud misuse of the wrong method, not a
219
+ * silent path-shape coincidence.
220
+ */
221
+ export interface WorkspaceOps {
222
+ readonly readSync: (path: string) => string | undefined
223
+ readonly read: (path: string) => Effect.Effect<string | undefined, Error>
224
+ readonly write: (path: string, content: string) => Effect.Effect<void, Error>
225
+ readonly committed: (path: string, ref?: string) => Effect.Effect<string | undefined, Error>
226
+ /** `committed`, synchronous — see the interface doc comment. */
227
+ readonly readCommittedSync: (path: string, ref?: string) => string | undefined
228
+ /**
229
+ * Every path committed at `ref`, with its blob id — sync for the same
230
+ * reason `readCommittedSync` is: flow code reads a commit's tree between
231
+ * two awaits and cannot wait on IO.
232
+ */
233
+ readonly treeSync: (ref: string) => ReadonlyMap<string, string>
234
+ /**
235
+ * Every path `git add -A` would commit from the working tree, with the blob
236
+ * id it would get — `undefined` where no id could be worked out, so a
237
+ * reader compares contents instead.
238
+ */
239
+ readonly worktreeSync: () => ReadonlyMap<string, string | undefined>
240
+ /**
241
+ * `hash → (path → blob id)` for `base` (when given) and every one of
242
+ * `commits`, in ONE call regardless of how many commits that is: a single
243
+ * `git ls-tree` for `base` plus a single `git log --raw` walking
244
+ * `base..last` (or all of `last`'s first-parent history when `base` is
245
+ * `undefined` — the repository-root episode), each commit's delta applied
246
+ * to the previous map in memory. Every resulting map is also cached (in
247
+ * process and on disk, keyed by commit hash — see `treeSync`), so a
248
+ * commit already known from a prior call, or a prior `gtd` invocation,
249
+ * costs no subprocess at all.
250
+ */
251
+ readonly episodeTrees: (
252
+ base: string | undefined,
253
+ commits: readonly string[],
254
+ ) => Effect.Effect<ReadonlyMap<string, ReadonlyMap<string, string>>, Error>
255
+ /**
256
+ * Reads an ARBITRARY path — repo-relative or already-absolute, inside the
257
+ * repo, above it, or anywhere else on disk — with the same absence-is-a-
258
+ * value/fault-still-throws contract as `readSync`. The deliberate escape
259
+ * from the repo-relative contract above.
260
+ */
261
+ readonly atPath: (path: string) => string | undefined
262
+ /** `atPath`'s write counterpart — `gtd uncheck`'s one caller needing it. */
263
+ readonly writeAtPath: (path: string, content: string) => Effect.Effect<void, Error>
264
+ }
265
+
266
+ const assertRepoRelative = (path: string): string => {
267
+ if (isAbsolute(path)) {
268
+ throw new Error(
269
+ `Workspace: "${path}" is an absolute path — this member takes repo-relative paths only (use "atPath"/"writeAtPath" to read/write outside the repo)`,
270
+ )
271
+ }
272
+ return path
273
+ }
274
+
275
+ const readFileOrAbsent = (path: string): string | undefined => {
276
+ try {
277
+ return readFileSync(path, "utf8")
278
+ } catch (e) {
279
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
280
+ throw e
281
+ }
282
+ }
283
+
284
+ const lstatOrAbsent = (path: string): ReturnType<typeof lstatSync> | undefined => {
285
+ try {
286
+ return lstatSync(path)
287
+ } catch (e) {
288
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
289
+ throw e
290
+ }
291
+ }
292
+
293
+ /** One replayed commit: its hash and the (already-`\n`-stripped) `--raw -z` body `parseRawDiff` consumes. */
294
+ interface RawLogEntry {
295
+ readonly hash: string
296
+ readonly tail: string
297
+ }
298
+
299
+ /**
300
+ * Runs `git log --raw` for `base..head` (or all of `head`'s first-parent
301
+ * history when `base` is `undefined`) and splits the output into one
302
+ * `RawLogEntry` per commit, validating the result against `commits` — same
303
+ * count, same hashes, in order — since the two disagreeing means the episode
304
+ * bounds no longer match git's own history.
305
+ */
306
+ const runRawLogReplay = (
307
+ root: string,
308
+ base: string | undefined,
309
+ head: string,
310
+ commits: readonly string[],
311
+ ): readonly RawLogEntry[] => {
312
+ const range = base !== undefined ? `${base}..${head}` : head
313
+ const out = execFileSync(
314
+ "git",
315
+ [
316
+ "log",
317
+ "--first-parent",
318
+ "--reverse",
319
+ "-z",
320
+ "--raw",
321
+ "-r",
322
+ "--no-abbrev",
323
+ "--format=%x01%H",
324
+ range,
325
+ ],
326
+ { cwd: root, encoding: "utf8", maxBuffer: MAX_BUFFER },
327
+ )
328
+ const chunks = out.split("\x01").filter((c) => c.length > 0)
329
+ if (chunks.length !== commits.length) {
330
+ throw new Error(
331
+ `gtd: episode reconstruction expected ${commits.length} commit(s) from git log, got ${chunks.length} — the episode bounds disagree with git's own history`,
332
+ )
333
+ }
334
+ return chunks.map((chunk, i) => {
335
+ const nul = chunk.indexOf("\0")
336
+ const hash = nul === -1 ? chunk : chunk.slice(0, nul)
337
+ if (hash !== commits[i]) {
338
+ throw new Error(
339
+ `gtd: episode reconstruction hash mismatch at position ${i}: expected ${commits[i]}, got ${hash}`,
340
+ )
341
+ }
342
+ const tail = nul === -1 ? "" : chunk.slice(nul + 1)
343
+ return { hash, tail: tail.startsWith("\n") ? tail.slice(1) : tail }
344
+ })
345
+ }
346
+
347
+ /** Applies each `RawLogEntry`'s diff in order onto `baseTree`, caching (and returning) every resulting tree. */
348
+ const foldRawDiffs = (
349
+ baseTree: ReadonlyMap<string, string>,
350
+ replay: readonly RawLogEntry[],
351
+ rememberTree: (hash: string, tree: ReadonlyMap<string, string>) => void,
352
+ ): Map<string, ReadonlyMap<string, string>> => {
353
+ const result = new Map<string, ReadonlyMap<string, string>>()
354
+ let current = new Map(baseTree)
355
+ for (const { hash, tail } of replay) {
356
+ const next = new Map(current)
357
+ applyRawDiff(next, parseRawDiff(tail))
358
+ current = next
359
+ result.set(hash, current)
360
+ rememberTree(hash, current)
361
+ }
362
+ return result
363
+ }
364
+
365
+ const makeWorkspaceOps = (
366
+ root: string,
367
+ git: GitOperations,
368
+ commonDir: string | undefined,
369
+ ): WorkspaceOps => {
370
+ const resolveAny = (path: string): string => (isAbsolute(path) ? path : join(root, path))
371
+
372
+ // Per-PROCESS memo, on the workspace instance rather than any one replay's
373
+ // `TreeView` — an immutable key (a commit hash, a blob id) can never serve
374
+ // a wrong answer, so the two replays one `gtd` command runs (and every
375
+ // `treeSync`/`readCommittedSync` call in between) share it for free.
376
+ const treeMemo = new Map<string, ReadonlyMap<string, string>>()
377
+ const blobMemo = new Map<string, string>()
378
+
379
+ /** A commit's tree, from the in-process memo or the on-disk cache — no subprocess. `undefined` on a cold miss. */
380
+ const peekTree = (hash: string): ReadonlyMap<string, string> | undefined => {
381
+ const memoHit = treeMemo.get(hash)
382
+ if (memoHit !== undefined) return memoHit
383
+ if (commonDir === undefined) return undefined
384
+ const cached = readTreeCache(commonDir, hash)
385
+ if (cached !== undefined) treeMemo.set(hash, cached)
386
+ return cached
387
+ }
388
+
389
+ const rememberTree = (hash: string, tree: ReadonlyMap<string, string>): void => {
390
+ treeMemo.set(hash, tree)
391
+ if (commonDir !== undefined) writeTreeCache(commonDir, hash, tree)
392
+ }
393
+
394
+ const readSync = (path: string): string | undefined =>
395
+ readFileOrAbsent(join(root, assertRepoRelative(path)))
396
+
397
+ /**
398
+ * A blob's content, keyed on its OWN id rather than a (path, ref) pair —
399
+ * the same content read from two different commits costs one read, not
400
+ * two. `git cat-file -p` reads any object by id directly, no pathspec
401
+ * needed. Any failure folds to `undefined`, matching `readCommittedSync`'s
402
+ * own catch-all-as-absence.
403
+ */
404
+ const readBlobSync = (id: string): string | undefined => {
405
+ const memoHit = blobMemo.get(id)
406
+ if (memoHit !== undefined) return memoHit
407
+ if (commonDir !== undefined) {
408
+ const cached = readTextCache(blobCachePath(commonDir, id))
409
+ if (cached !== undefined) {
410
+ blobMemo.set(id, cached)
411
+ return cached
412
+ }
413
+ }
414
+ let content: string
415
+ try {
416
+ content = execFileSync("git", ["cat-file", "-p", id], {
417
+ cwd: root,
418
+ encoding: "utf8",
419
+ maxBuffer: MAX_BUFFER,
420
+ })
421
+ } catch {
422
+ return undefined
423
+ }
424
+ blobMemo.set(id, content)
425
+ if (commonDir !== undefined) writeTextCacheAtomic(blobCachePath(commonDir, id), content)
426
+ return content
427
+ }
428
+
429
+ // A raw, synchronous `git show <ref>:<path>` — the sync counterpart to
430
+ // `committed`, for replay, which can't `yield*` mid-flow. Bypasses the Effect-based `GitOperations`
431
+ // entirely, the same escape `readSync` already takes around the Effect
432
+ // `FileSystem` — both are raw Node calls, not a second git abstraction.
433
+ // ANY failure (missing ref, missing path at that ref, git not on PATH)
434
+ // folds to `undefined`, matching `committed`'s own catch-all-as-absence.
435
+ //
436
+ // A full object-hash `ref` routes through the tree + blob caches (the
437
+ // path's blob id is looked up in the already-cached tree, then its
438
+ // content through `readBlobSync`) — a symbolic ref (`HEAD`, a branch name)
439
+ // is never cached here, since it can move mid-process (a landing commit),
440
+ // and goes straight to `git show` as before.
441
+ const readCommittedSync = (path: string, ref = "HEAD"): string | undefined => {
442
+ const relPath = assertRepoRelative(path)
443
+ if (isObjectHash(ref)) {
444
+ const id = treeSync(ref).get(relPath)
445
+ return id === undefined ? undefined : readBlobSync(id)
446
+ }
447
+ try {
448
+ return execFileSync("git", ["show", `${ref}:${relPath}`], {
449
+ cwd: root,
450
+ encoding: "utf8",
451
+ maxBuffer: MAX_BUFFER,
452
+ })
453
+ } catch {
454
+ return undefined
455
+ }
456
+ }
457
+
458
+ // `entries` is git-confirmed (`ok`) exactly when `fetchTree`'s subprocess
459
+ // actually succeeded — only that result is ever cached; a transient
460
+ // failure folds to an empty tree for THIS call only, never written to the
461
+ // memo or disk (see `fetchTree`'s doc comment).
462
+ const readTree = (ref: string, onFailure: "empty" | "throw"): ReadonlyMap<string, string> => {
463
+ if (isObjectHash(ref)) {
464
+ const cached = peekTree(ref)
465
+ if (cached !== undefined) return cached
466
+ }
467
+ const { ok, entries } = fetchTree(root, ref)
468
+ if (!ok) {
469
+ if (onFailure === "throw") {
470
+ throw new Error(`gtd: failed to read the tree at "${ref}" — git ls-tree did not succeed`)
471
+ }
472
+ return entries
473
+ }
474
+ if (isObjectHash(ref)) rememberTree(ref, entries)
475
+ return entries
476
+ }
477
+
478
+ const treeSync = (ref: string): ReadonlyMap<string, string> => readTree(ref, "empty")
479
+
480
+ /**
481
+ * `treeSync`'s starting point when a failed read must not silently
482
+ * proceed: a cache hit or a git-confirmed `ls-tree` succeeds; any
483
+ * subprocess failure THROWS rather than folding to an empty tree, because
484
+ * `episodeTrees` folds every subsequent commit's diff on top of this one —
485
+ * a wrong empty base would poison every commit's cached tree in the
486
+ * episode, not just this one's.
487
+ */
488
+ const confirmedTree = (ref: string): ReadonlyMap<string, string> => readTree(ref, "throw")
489
+
490
+ /**
491
+ * The delta-stream reconstruction (package 4's Requirement A): one
492
+ * `ls-tree` for `base`, one `--raw` `git log` for every commit, applied in
493
+ * order. Skipped entirely when every requested hash is already known
494
+ * (memo or on-disk cache) — then this issues NO subprocess at all.
495
+ */
496
+ const episodeTrees = (
497
+ base: string | undefined,
498
+ commits: readonly string[],
499
+ ): Effect.Effect<ReadonlyMap<string, ReadonlyMap<string, string>>, Error> =>
500
+ Effect.try({
501
+ try: () => {
502
+ const result = new Map<string, ReadonlyMap<string, string>>()
503
+ const baseTree = base !== undefined ? confirmedTree(base) : new Map<string, string>()
504
+ if (base !== undefined) result.set(base, baseTree)
505
+ if (commits.length === 0) return result
506
+
507
+ if (commits.every((hash) => peekTree(hash) !== undefined)) {
508
+ for (const hash of commits) result.set(hash, treeSync(hash))
509
+ return result
510
+ }
511
+
512
+ const head = commits[commits.length - 1]!
513
+ const replay = runRawLogReplay(root, base, head, commits)
514
+ for (const [hash, tree] of foldRawDiffs(baseTree, replay, rememberTree))
515
+ result.set(hash, tree)
516
+ return result
517
+ },
518
+ catch: toError,
519
+ })
520
+
521
+ const gitSync = (args: readonly string[], input?: string): string =>
522
+ execFileSync("git", args, {
523
+ cwd: root,
524
+ encoding: "utf8",
525
+ maxBuffer: MAX_BUFFER,
526
+ ...(input !== undefined ? { input } : {}),
527
+ })
528
+
529
+ // A path git status reports is re-hashed the way `git add` would store it;
530
+ // every other path keeps HEAD's blob id, so an untouched file is never
531
+ // read, and never compared by working-tree bytes that eol or smudge
532
+ // filters, or a symlink, make differ from the blob.
533
+ const worktreeSync = (): ReadonlyMap<string, string | undefined> => {
534
+ const entries = new Map<string, string | undefined>(treeSync("HEAD"))
535
+ const status = gitSync([
536
+ "status",
537
+ "--porcelain=v1",
538
+ "-z",
539
+ "--untracked-files=all",
540
+ "--no-renames",
541
+ "--ignore-submodules=none",
542
+ ])
543
+ const files: string[] = []
544
+ for (const record of status.split("\0")) {
545
+ if (record.length < 4) continue
546
+ const path = record.slice(3).replace(/\/$/, "")
547
+ const stat = lstatOrAbsent(join(root, path))
548
+ if (stat === undefined) entries.delete(path)
549
+ else if (stat.isSymbolicLink()) {
550
+ entries.set(path, blobId(readlinkSync(join(root, path), "utf8")))
551
+ } else if (stat.isDirectory()) {
552
+ // Only a directory holding its own repository is committed, as a gitlink.
553
+ if (lstatOrAbsent(join(root, path, ".git")) === undefined) entries.delete(path)
554
+ else entries.set(path, gitlinkOf(path))
555
+ } else files.push(path)
556
+ }
557
+ // Paths go as arguments, never newline-separated on stdin: a path may
558
+ // hold a newline. Chunked to stay under the argument-list limit.
559
+ for (let i = 0; i < files.length; i += HASH_CHUNK) {
560
+ const chunk = files.slice(i, i + HASH_CHUNK)
561
+ const ids = gitSync(["hash-object", "--", ...chunk]).split("\n")
562
+ chunk.forEach((path, j) => entries.set(path, ids[j] || undefined))
563
+ }
564
+ return new Map([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
565
+ }
566
+
567
+ /** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
568
+ const gitlinkOf = (path: string): string | undefined => {
569
+ try {
570
+ return execFileSync("git", ["rev-parse", "HEAD"], {
571
+ cwd: join(root, path),
572
+ encoding: "utf8",
573
+ }).trim()
574
+ } catch {
575
+ return undefined
576
+ }
577
+ }
578
+
579
+ let objectFormat: string | undefined
580
+ const blobId = (content: string): string => {
581
+ objectFormat ??= gitSync(["rev-parse", "--show-object-format"]).trim()
582
+ const bytes = Buffer.from(content, "utf8")
583
+ return createHash(objectFormat === "sha256" ? "sha256" : "sha1")
584
+ .update(`blob ${bytes.length}\0`)
585
+ .update(bytes)
586
+ .digest("hex")
587
+ }
588
+
589
+ return {
590
+ treeSync,
591
+ worktreeSync,
592
+ episodeTrees,
593
+ readSync,
594
+ read: (path) => Effect.try({ try: () => readSync(path), catch: toError }),
595
+ write: (path, content) =>
596
+ Effect.try({
597
+ try: () => writeFileSync(join(root, assertRepoRelative(path)), content, "utf8"),
598
+ catch: toError,
599
+ }),
600
+ committed: (path, ref = "HEAD") =>
601
+ Effect.try({ try: () => assertRepoRelative(path), catch: toError }).pipe(
602
+ Effect.flatMap((relPath) =>
603
+ git.readFileAtRef(ref, relPath).pipe(Effect.catchAll(() => Effect.succeed(undefined))),
604
+ ),
605
+ ),
606
+ readCommittedSync,
607
+ atPath: (path) => readFileOrAbsent(resolveAny(path)),
608
+ writeAtPath: (path, content) =>
609
+ Effect.try({ try: () => writeFileSync(resolveAny(path), content, "utf8"), catch: toError }),
610
+ }
611
+ }
612
+
613
+ export class Workspace extends Context.Tag("Workspace")<Workspace, WorkspaceOps>() {
614
+ static Live = Layer.effect(
615
+ Workspace,
616
+ Effect.gen(function* () {
617
+ const { root } = yield* Host
618
+ const git = yield* GitService
619
+ // Resolved once per process, not lazily inside a sync method: the
620
+ // common dir is git's own concern to answer, and every cache read/
621
+ // write below is synchronous. A repo whose common dir can't be
622
+ // resolved (git missing, a corrupted `.git`) still constructs a
623
+ // working `Workspace` — it just runs without the disk cache, exactly
624
+ // like a full-disk failure does.
625
+ const commonDir = yield* git
626
+ .gitCommonDir()
627
+ .pipe(Effect.catchAll(() => Effect.succeed(undefined)))
628
+ return makeWorkspaceOps(root, git, commonDir)
629
+ }),
630
+ )
631
+ }