@pmelab/gtd 23.2.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 (236) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +44 -11
  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 +43830 -56044
  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 +1953 -1724
  19. package/package.json +70 -8
  20. package/skills/authoring/SKILL.md +5 -4
  21. package/src/cli/Cli.test.ts +1329 -0
  22. package/src/cli/Cli.ts +1348 -0
  23. package/src/cli/Program.test.ts +2262 -0
  24. package/src/cli/Program.ts +1331 -0
  25. package/src/cli/ToolCommands.ts +43 -0
  26. package/src/cli/index.ts +14 -0
  27. package/src/edge/Edge.test.ts +1016 -0
  28. package/src/edge/Edge.ts +1225 -0
  29. package/src/edge/index.ts +16 -0
  30. package/src/emit/Emit.test.ts +425 -0
  31. package/src/emit/Emit.ts +139 -0
  32. package/src/emit/GitScript.test.ts +260 -0
  33. package/src/emit/GitScript.ts +131 -0
  34. package/src/emit/ModeContradiction.test.ts +120 -0
  35. package/src/emit/ModeContradiction.ts +66 -0
  36. package/src/emit/OutcomeScript.test.ts +238 -0
  37. package/src/emit/OutcomeScript.ts +73 -0
  38. package/src/emit/SteeringMode.test.ts +274 -0
  39. package/src/emit/SteeringMode.ts +256 -0
  40. package/src/emit/index.ts +40 -0
  41. package/src/flows/factory.ts +5 -1
  42. package/src/flows/runtime.ts +3 -0
  43. package/src/install/ConfigScaffold.ts +19 -0
  44. package/src/install/Install.test.ts +426 -0
  45. package/src/install/Install.ts +634 -0
  46. package/src/install/Version.ts +27 -0
  47. package/src/install/index.ts +3 -0
  48. package/src/platform/CommandRunner.ts +74 -0
  49. package/src/platform/Commentary.test.ts +126 -0
  50. package/src/platform/Commentary.ts +92 -0
  51. package/src/platform/Git.test.ts +50 -0
  52. package/src/platform/Git.ts +552 -0
  53. package/src/platform/Host.ts +42 -0
  54. package/src/platform/RetainedHistory.test.ts +65 -0
  55. package/src/platform/RetainedHistory.ts +34 -0
  56. package/src/platform/Workspace.test.ts +669 -0
  57. package/src/platform/Workspace.ts +631 -0
  58. package/src/platform/WorktreeState.test.ts +162 -0
  59. package/src/platform/WorktreeState.ts +50 -0
  60. package/src/platform/index.ts +22 -0
  61. package/src/replay/Access.test.ts +53 -0
  62. package/src/replay/Access.ts +46 -0
  63. package/src/replay/Format.test.ts +20 -0
  64. package/src/replay/Format.ts +14 -0
  65. package/src/replay/Replay.test.ts +887 -0
  66. package/src/replay/Replay.ts +669 -0
  67. package/src/replay/Trailers.test.ts +117 -0
  68. package/src/replay/Trailers.ts +213 -0
  69. package/src/replay/Tree.test.ts +25 -0
  70. package/src/replay/Tree.ts +45 -0
  71. package/src/replay/index.ts +11 -0
  72. package/src/steering/Blocks.fixture.ts +6 -0
  73. package/src/steering/Blocks.test.ts +167 -0
  74. package/src/steering/Blocks.ts +321 -0
  75. package/src/steering/CodeThreads.test.ts +127 -0
  76. package/src/steering/CodeThreads.ts +197 -0
  77. package/src/steering/Edits.test.ts +19 -0
  78. package/src/steering/Edits.ts +20 -0
  79. package/src/steering/Eol.test.ts +16 -0
  80. package/src/steering/Eol.ts +2 -0
  81. package/src/steering/FORMATS.md +58 -0
  82. package/src/steering/Footnotes.test.ts +977 -0
  83. package/src/steering/Footnotes.ts +661 -0
  84. package/src/steering/Inline.fixture.ts +7 -0
  85. package/src/steering/Inline.test.ts +227 -0
  86. package/src/steering/Inline.ts +195 -0
  87. package/src/steering/MarkdownTree.test.ts +247 -0
  88. package/src/steering/MarkdownTree.ts +260 -0
  89. package/src/steering/Outline.ts +19 -0
  90. package/src/steering/SteeringFormat.test.ts +91 -0
  91. package/src/steering/SteeringFormat.ts +267 -0
  92. package/src/steering/SteeringFormats.test.ts +163 -0
  93. package/src/steering/Threads.ts +163 -0
  94. package/src/steering/contract.test.ts +264 -0
  95. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  96. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  97. package/src/steering/fixtures/free-form/notes.md +12 -0
  98. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  99. package/src/steering/fixtures/qa/answered.md +23 -0
  100. package/src/steering/fixtures/qa/answered.read.json +119 -0
  101. package/src/steering/fixtures/qa/requirements.md +160 -0
  102. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  103. package/src/steering/fixtures/review/review.md +76 -0
  104. package/src/steering/fixtures/review/review.read.json +237 -0
  105. package/src/steering/fixtures/review/ticked.md +23 -0
  106. package/src/steering/fixtures/review/ticked.read.json +99 -0
  107. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  108. package/src/steering/fixtures/review-notes/noted.md +19 -0
  109. package/src/steering/freeform.test.ts +311 -0
  110. package/src/steering/freeform.ts +148 -0
  111. package/src/steering/index.test.ts +62 -0
  112. package/src/steering/index.ts +140 -0
  113. package/src/steering/qa.test.ts +2160 -0
  114. package/src/steering/qa.ts +1104 -0
  115. package/src/steering/review.test.ts +1829 -0
  116. package/src/steering/review.ts +1180 -0
  117. package/src/step/LandStep.ts +31 -0
  118. package/src/step/RepoSnapshot.ts +29 -0
  119. package/src/step/accessRefusal.test.ts +58 -0
  120. package/src/step/accessRefusal.ts +26 -0
  121. package/src/step/index.ts +5 -0
  122. package/src/step/planStart.test.ts +202 -0
  123. package/src/step/planStart.ts +167 -0
  124. package/src/step/planStep.test.ts +104 -0
  125. package/src/step/planStep.ts +89 -0
  126. package/src/step/snapshot.fixture.ts +28 -0
  127. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  128. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  129. package/src/testkit/FakeGitOperations.ts +95 -0
  130. package/src/testkit/GitTiers.test.ts +11 -0
  131. package/src/testkit/GitTiers.ts +1052 -0
  132. package/src/testkit/InMemRepo.ts +467 -0
  133. package/src/testkit/Layers.test.ts +58 -0
  134. package/src/testkit/Layers.ts +241 -0
  135. package/src/testkit/StrictGitOperations.ts +31 -0
  136. package/src/testkit/TestRepo.test.ts +119 -0
  137. package/src/testkit/TestRepo.ts +274 -0
  138. package/src/testkit/cliIo.ts +36 -0
  139. package/src/testkit/index.ts +9 -0
  140. package/src/testkit/steps.ts +177 -0
  141. package/src/testkit/testkit.feature +65 -0
  142. package/src/wire/BeatDocument.test.ts +419 -0
  143. package/src/wire/BeatDocument.ts +114 -0
  144. package/src/wire/BeatPlain.test.ts +113 -0
  145. package/src/wire/BeatPlain.ts +127 -0
  146. package/src/wire/BeatStatus.test.ts +59 -0
  147. package/src/wire/BeatStatus.ts +81 -0
  148. package/src/wire/Demand.test.ts +142 -0
  149. package/src/wire/Demand.ts +108 -0
  150. package/src/wire/ExitCodes.test.ts +21 -0
  151. package/src/wire/ExitCodes.ts +16 -0
  152. package/src/wire/Glob.ts +29 -0
  153. package/src/wire/Judge.ts +22 -0
  154. package/src/wire/Land.test.ts +120 -0
  155. package/src/wire/Land.ts +75 -0
  156. package/src/wire/Select.test.ts +148 -0
  157. package/src/wire/Select.ts +78 -0
  158. package/src/wire/Sessions.test.ts +59 -0
  159. package/src/wire/Sessions.ts +39 -0
  160. package/src/wire/WireSchema.test.ts +162 -0
  161. package/src/wire/constants.ts +22 -0
  162. package/src/wire/golden/1/beat-capture.json +34 -0
  163. package/src/wire/golden/1/beat-message.json +34 -0
  164. package/src/wire/golden/1/beat-prompt.json +44 -0
  165. package/src/wire/golden/1/beat-script.json +34 -0
  166. package/src/wire/golden/1/beat-stalled.json +34 -0
  167. package/src/wire/golden/1/judge.json +14 -0
  168. package/src/wire/golden/1/land-noop.json +10 -0
  169. package/src/wire/golden/1/land.json +10 -0
  170. package/src/wire/index.ts +12 -0
  171. package/src/wire/types.ts +46 -0
  172. package/src/workflow/Diagnostic.test.ts +93 -0
  173. package/src/workflow/Diagnostic.ts +54 -0
  174. package/src/workflow/SteeringFormats.test.ts +27 -0
  175. package/src/workflow/SteeringFormats.ts +6 -0
  176. package/src/workflow/Workflow.ts +77 -0
  177. package/src/workflow/compile.test.ts +53 -0
  178. package/src/workflow/compile.ts +56 -0
  179. package/src/workflow/discovery.ts +122 -0
  180. package/src/workflow/fingerprint.test.ts +52 -0
  181. package/src/workflow/fingerprint.ts +53 -0
  182. package/src/workflow/index.ts +33 -0
  183. package/src/workflow/load.test.ts +1017 -0
  184. package/src/workflow/load.ts +836 -0
  185. package/src/workflow/skills.test.ts +109 -0
  186. package/src/workflow/skills.ts +90 -0
  187. package/src/workflow/vars.ts +33 -0
  188. package/src/workflow-kit/health.test.ts +30 -0
  189. package/src/workflow-kit/health.ts +86 -0
  190. package/src/workflow-kit/index.ts +7 -0
  191. package/src/workflow-kit/quality.test.ts +17 -0
  192. package/src/workflow-kit/quality.ts +45 -0
  193. package/src/workflow-kit/review.ts +324 -0
  194. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  195. package/src/workflows/feature/feature.test.ts +32 -0
  196. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  197. package/src/workflows/feature/index.ts +10 -0
  198. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  199. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  200. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  201. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  202. package/src/workflows/feature/review.ts +106 -0
  203. package/src/workflows/feature/steps.test.ts +82 -0
  204. package/src/workflows/feature/steps.ts +92 -0
  205. package/src/workflows/fix/fix.feature +80 -0
  206. package/src/workflows/fix/fix.test.ts +74 -0
  207. package/src/workflows/fix/fix.ts +52 -0
  208. package/src/workflows/fix/index.ts +6 -0
  209. package/src/workflows/index.ts +36 -2
  210. package/src/workflows/review/index.ts +6 -0
  211. package/src/workflows/review/review.test.ts +99 -0
  212. package/src/workflows/review/review.ts +33 -0
  213. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  214. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  215. package/src/workflows/shared/config.fixture.ts +12 -0
  216. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  217. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  218. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  219. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  220. package/src/workflows/shared/reserved.ts +11 -0
  221. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  222. package/src/workflows/shared/scenarios.ts +35 -0
  223. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  224. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  225. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  226. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  227. package/src/workflows/shared/tail.test.ts +205 -0
  228. package/src/workflows/shared/tail.ts +135 -0
  229. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  230. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  231. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  232. package/src/workflows/shared/workflows.steps.ts +7 -0
  233. package/src/workflows/review.ts +0 -362
  234. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  235. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  236. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Coverage for `src/platform/WorktreeState.ts`'s `worktreeGitDir`/`loopLogPath`: an
3
+ * in-memory group pinning the `.git` resolution table (D1's own words in
4
+ * gtd#169), and a real-git group proving the resolver is byte-identical to
5
+ * `git rev-parse --git-dir` — including under a `GIT_DIR`/`GIT_WORK_TREE`
6
+ * pointed at an unrelated repo, the env-immunity property a driver's own
7
+ * `env -u` scrub is expected to provide.
8
+ */
9
+
10
+ import { join } from "node:path"
11
+ import { mkdtempSync, realpathSync } from "node:fs"
12
+ import { execSync } from "node:child_process"
13
+ import { tmpdir } from "node:os"
14
+ import { Effect, Layer } from "effect"
15
+ import { NodeContext } from "@effect/platform-node"
16
+ import { describe, expect, it } from "vitest"
17
+ import { loopLogPath, worktreeGitDir } from "./WorktreeState.js"
18
+ import { GitService, Host, Workspace } from "./index.js"
19
+ import { InMemRepo, testLayers } from "../testkit/index.js"
20
+
21
+ const provide = <A>(
22
+ eff: Effect.Effect<A, never, Host | Workspace>,
23
+ repo: InMemRepo,
24
+ opts: {
25
+ readonly root?: string
26
+ readonly env?: Readonly<Record<string, string | undefined>>
27
+ } = {},
28
+ ): Promise<A> => Effect.runPromise(eff.pipe(Effect.provide(testLayers(repo, opts))))
29
+
30
+ describe("worktreeGitDir / loopLogPath [in-memory]", () => {
31
+ it("falls back to .git when the worktree has no .git entry at all", async () => {
32
+ const repo = new InMemRepo()
33
+ expect(await provide(worktreeGitDir, repo)).toBe(".git")
34
+ expect(await provide(loopLogPath, repo)).toBe(".git/gtd-loop.log")
35
+ })
36
+
37
+ it("follows an absolute gitdir: pointer (a linked worktree)", async () => {
38
+ const repo = new InMemRepo()
39
+ repo.writeFile(".git", "gitdir: /abs/path\n")
40
+ expect(await provide(worktreeGitDir, repo)).toBe("/abs/path")
41
+ expect(await provide(loopLogPath, repo)).toBe(join("/abs/path", "gtd-loop.log"))
42
+ })
43
+
44
+ it("resolves a relative gitdir: pointer against the worktree root", async () => {
45
+ const repo = new InMemRepo()
46
+ repo.writeFile(".git", "gitdir: ../shared/worktrees/x\n")
47
+ expect(await provide(worktreeGitDir, repo)).toBe(join("/repo", "../shared/worktrees/x"))
48
+ })
49
+
50
+ it("falls back to .git when the .git file has no gitdir: line", async () => {
51
+ const repo = new InMemRepo()
52
+ repo.writeFile(".git", "not a gitdir pointer\n")
53
+ expect(await provide(worktreeGitDir, repo)).toBe(".git")
54
+ })
55
+
56
+ it("GTD_LOOP_LOG wins verbatim, even over a gitdir: pointer", async () => {
57
+ const repo = new InMemRepo()
58
+ repo.writeFile(".git", "gitdir: /abs/path\n")
59
+ expect(
60
+ await provide(loopLogPath, repo, { env: { GTD_LOOP_LOG: "/elsewhere/custom.log" } }),
61
+ ).toBe("/elsewhere/custom.log")
62
+ })
63
+
64
+ it("treats an empty GTD_LOOP_LOG as unset", async () => {
65
+ const repo = new InMemRepo()
66
+ expect(await provide(loopLogPath, repo, { env: { GTD_LOOP_LOG: "" } })).toBe(
67
+ ".git/gtd-loop.log",
68
+ )
69
+ })
70
+
71
+ it("an absolute GIT_DIR names the git dir for the log path, even over a gitdir: pointer", async () => {
72
+ const repo = new InMemRepo()
73
+ repo.writeFile(".git", "gitdir: /abs/pointer-path\n")
74
+ expect(await provide(loopLogPath, repo, { env: { GIT_DIR: "/elsewhere/gitdir" } })).toBe(
75
+ join("/elsewhere/gitdir", "gtd-loop.log"),
76
+ )
77
+ })
78
+
79
+ it("a relative GIT_DIR is joined against the repo root", async () => {
80
+ const repo = new InMemRepo()
81
+ expect(
82
+ await provide(loopLogPath, repo, { root: "/repo", env: { GIT_DIR: "../shared/gitdir" } }),
83
+ ).toBe(join("/repo", "../shared/gitdir", "gtd-loop.log"))
84
+ })
85
+
86
+ it("treats an empty GIT_DIR as unset, falling through to the pointer read", async () => {
87
+ const repo = new InMemRepo()
88
+ repo.writeFile(".git", "gitdir: /abs/pointer-path\n")
89
+ expect(await provide(loopLogPath, repo, { env: { GIT_DIR: "" } })).toBe(
90
+ join("/abs/pointer-path", "gtd-loop.log"),
91
+ )
92
+ })
93
+
94
+ it("GTD_LOOP_LOG still wins verbatim over GIT_DIR", async () => {
95
+ const repo = new InMemRepo()
96
+ expect(
97
+ await provide(loopLogPath, repo, {
98
+ env: { GTD_LOOP_LOG: "/elsewhere/custom.log", GIT_DIR: "/elsewhere/gitdir" },
99
+ }),
100
+ ).toBe("/elsewhere/custom.log")
101
+ })
102
+
103
+ it("falls back to the literal .git when GIT_DIR is unset and the fake filesystem has no .git entry", async () => {
104
+ const repo = new InMemRepo()
105
+ expect(await provide(loopLogPath, repo)).toBe(".git/gtd-loop.log")
106
+ })
107
+ })
108
+
109
+ describe("worktreeGitDir [real git]", () => {
110
+ const gitExecIn = (dir: string, ...args: string[]): string =>
111
+ execSync(`git ${args.join(" ")}`, { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim()
112
+
113
+ const runIn = (root: string) => {
114
+ const hostLayer = Host.layer({ root, home: root, env: {} })
115
+ const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeContext.layer)))
116
+ const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
117
+ return Effect.runPromise(
118
+ worktreeGitDir.pipe(Effect.provide(Layer.mergeAll(hostLayer, workspaceLayer))),
119
+ )
120
+ }
121
+
122
+ it("matches git rev-parse --git-dir in the main repo and in a linked worktree", async () => {
123
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-worktree-state-")))
124
+ gitExecIn(root, "init", "-q")
125
+ gitExecIn(root, "config", "user.email", "test@test.com")
126
+ gitExecIn(root, "config", "user.name", "Test")
127
+ gitExecIn(root, "config", "commit.gpgsign", "false")
128
+ gitExecIn(root, "commit", "--allow-empty", "-q", "-m", "init")
129
+
130
+ expect(await runIn(root)).toBe(gitExecIn(root, "rev-parse", "--git-dir"))
131
+
132
+ const siblingDir = `${root}-sibling`
133
+ gitExecIn(root, "worktree", "add", "-q", "-b", "sibling", siblingDir, "HEAD")
134
+ expect(await runIn(siblingDir)).toBe(gitExecIn(siblingDir, "rev-parse", "--git-dir"))
135
+ })
136
+
137
+ it("is immune to a GIT_DIR/GIT_WORK_TREE pointed at a third, unrelated repo", async () => {
138
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-worktree-state-")))
139
+ gitExecIn(root, "init", "-q")
140
+ gitExecIn(root, "config", "user.email", "test@test.com")
141
+ gitExecIn(root, "config", "user.name", "Test")
142
+ gitExecIn(root, "config", "commit.gpgsign", "false")
143
+ gitExecIn(root, "commit", "--allow-empty", "-q", "-m", "init")
144
+ const expected = await runIn(root)
145
+
146
+ const other = realpathSync(mkdtempSync(join(tmpdir(), "gtd-worktree-state-other-")))
147
+ gitExecIn(other, "init", "-q", "--bare")
148
+
149
+ const savedGitDir = process.env.GIT_DIR
150
+ const savedWorkTree = process.env.GIT_WORK_TREE
151
+ try {
152
+ process.env.GIT_DIR = other
153
+ process.env.GIT_WORK_TREE = root
154
+ expect(await runIn(root)).toBe(expected)
155
+ } finally {
156
+ if (savedGitDir === undefined) delete process.env.GIT_DIR
157
+ else process.env.GIT_DIR = savedGitDir
158
+ if (savedWorkTree === undefined) delete process.env.GIT_WORK_TREE
159
+ else process.env.GIT_WORK_TREE = savedWorkTree
160
+ }
161
+ })
162
+ })
@@ -0,0 +1,50 @@
1
+ import { isAbsolute, join } from "node:path"
2
+ import { Effect } from "effect"
3
+ import { Host } from "./Host.js"
4
+ import { Workspace } from "./Workspace.js"
5
+
6
+ const GITDIR_POINTER = /^gitdir:\s*(.+)$/m
7
+
8
+ /**
9
+ * The worktree's own git dir, resolved via the filesystem rather than a `git`
10
+ * subprocess — byte-identical to `git rev-parse --git-dir`. Never fails: a
11
+ * read failure or a `.git` file with no `gitdir:` line both fall back to
12
+ * `".git"`, which is also correct for the in-memory test tier.
13
+ */
14
+ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Effect.gen(
15
+ function* () {
16
+ const { root } = yield* Host
17
+ const workspace = yield* Workspace
18
+ const content = yield* workspace
19
+ .read(".git")
20
+ .pipe(Effect.catchAll(() => Effect.succeed(undefined)))
21
+ const pointer = content === undefined ? null : GITDIR_POINTER.exec(content)
22
+ if (pointer === null) return ".git"
23
+ const gitdir = pointer[1]!.trim()
24
+ return isAbsolute(gitdir) ? gitdir : join(root, gitdir)
25
+ },
26
+ )
27
+
28
+ /**
29
+ * The per-worktree loop log path `gtd next --json` reports as `log`.
30
+ * Precedence: `$GTD_LOOP_LOG` verbatim, then `$GIT_DIR` (so the log follows
31
+ * wherever git subprocesses actually write), else `worktreeGitDir`. `$GIT_DIR`
32
+ * is honored rather than guarded against on purpose — an inherited (not
33
+ * deliberately set) `GIT_DIR` will move the log path too, so a caller that
34
+ * doesn't want that (e.g. the e2e suite) must scrub it before invoking gtd.
35
+ * Advisory only — gtd neither creates nor truncates the file.
36
+ */
37
+ export const loopLogPath: Effect.Effect<string, never, Host | Workspace> = Effect.gen(function* () {
38
+ const { root, env } = yield* Host
39
+ const override = env.GTD_LOOP_LOG
40
+ if (override !== undefined && override !== "") return override
41
+
42
+ const gitDirEnv = env.GIT_DIR
43
+ if (gitDirEnv !== undefined && gitDirEnv !== "") {
44
+ const gitDir = isAbsolute(gitDirEnv) ? gitDirEnv : join(root, gitDirEnv)
45
+ return join(gitDir, "gtd-loop.log")
46
+ }
47
+
48
+ const gitDir = yield* worktreeGitDir
49
+ return join(gitDir, "gtd-loop.log")
50
+ })
@@ -0,0 +1,22 @@
1
+ /**
2
+ * gtd's two ports onto its environment: `Workspace` (repo file content —
3
+ * working-tree reads/writes plus the read-at-ref path) and `Host` (root,
4
+ * home, env, scratch). `GitService` moved in alongside `Workspace` since
5
+ * `committed` IS `git.readFileAtRef` — one module owns every read.
6
+ *
7
+ * Seam rule, stated once: a seam is a `Context.Tag` unless the consuming
8
+ * code is synchronous, non-Effect code that cannot `yield*` — replay's flow
9
+ * reads are that exception, and use the `*Sync` members.
10
+ */
11
+ export { Workspace, type WorkspaceOps } from "./Workspace.js"
12
+ export { Host, type HostOps } from "./Host.js"
13
+ export {
14
+ GitService,
15
+ type GitOperations,
16
+ type GitReaderOperations,
17
+ type GitWriterOperations,
18
+ } from "./Git.js"
19
+ export { Narrator, GtdError, GtdUsageError, renderFailure } from "./Commentary.js"
20
+ export { CommandRunner } from "./CommandRunner.js"
21
+ export { loopLogPath } from "./WorktreeState.js"
22
+ export { HISTORY_REF, readRetainedHistory, restorability } from "./RetainedHistory.js"
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { accessShapeFault, foldAccess } from "./Access.js"
3
+
4
+ describe("foldAccess", () => {
5
+ it("adds the steering file to each restricted side, after the scope globs", () => {
6
+ expect(foldAccess({ read: ["docs/**"], write: ["src/**"] }, ".gtd/X.md", [])).toEqual({
7
+ read: ["docs/**", ".gtd/X.md"],
8
+ write: ["src/**", ".gtd/X.md"],
9
+ })
10
+ })
11
+
12
+ it("adds the paths of waiting code threads after the steering file", () => {
13
+ expect(foldAccess({ write: [] }, ".gtd/X.md", ["src/a.ts"])).toEqual({
14
+ read: null,
15
+ write: [".gtd/X.md", "src/a.ts"],
16
+ })
17
+ })
18
+
19
+ it("dedupes against scope globs and across additions", () => {
20
+ expect(foldAccess({ write: ["src/a.ts", ".gtd/X.md"] }, ".gtd/X.md", ["src/a.ts"])).toEqual({
21
+ read: null,
22
+ write: ["src/a.ts", ".gtd/X.md"],
23
+ })
24
+ })
25
+
26
+ it("leaves an unrestricted side null, whatever is added", () => {
27
+ expect(foldAccess({}, ".gtd/X.md", ["src/a.ts"])).toEqual({ read: null, write: null })
28
+ expect(foldAccess(undefined, ".gtd/X.md", [])).toEqual({ read: null, write: null })
29
+ })
30
+
31
+ it("adds nothing for a missing file", () => {
32
+ expect(foldAccess({ read: ["a"], write: ["b"] }, undefined, [])).toEqual({
33
+ read: ["a"],
34
+ write: ["b"],
35
+ })
36
+ })
37
+ })
38
+
39
+ describe("accessShapeFault", () => {
40
+ it("accepts {} and either side alone", () => {
41
+ expect(accessShapeFault({})).toBeUndefined()
42
+ expect(accessShapeFault({ read: [] })).toBeUndefined()
43
+ expect(accessShapeFault({ write: ["a/**"] })).toBeUndefined()
44
+ })
45
+
46
+ it("names what is wrong", () => {
47
+ expect(accessShapeFault("x")).toContain("must be an object")
48
+ expect(accessShapeFault(["a"])).toContain("must be an object")
49
+ expect(accessShapeFault({ write: "a" })).toContain("access.write must be an array")
50
+ expect(accessShapeFault({ read: [1] })).toContain("access.read must be an array")
51
+ expect(accessShapeFault({ exec: [] })).toContain('unknown access key "exec"')
52
+ })
53
+ })
@@ -0,0 +1,46 @@
1
+ import type { AccessDef, ScopeAccess } from "../flows/index.js"
2
+
3
+ const UNRESTRICTED: ScopeAccess = {}
4
+
5
+ const withExtras = (
6
+ globs: readonly string[] | undefined,
7
+ extras: readonly string[],
8
+ ): readonly string[] | null => (globs === undefined ? null : [...new Set([...globs, ...extras])])
9
+
10
+ /**
11
+ * Fold a step's own paths into a scope's access: each restricted side gets the
12
+ * steering `file`, then the code threads waiting on the agent. An unrestricted
13
+ * side stays `null`.
14
+ */
15
+ export const foldAccess = (
16
+ scopeAccess: ScopeAccess | undefined,
17
+ file: string | undefined,
18
+ threadPaths: readonly string[],
19
+ ): AccessDef => {
20
+ const access = scopeAccess ?? UNRESTRICTED
21
+ const extras = [...(file === undefined ? [] : [file]), ...threadPaths]
22
+ return { read: withExtras(access.read, extras), write: withExtras(access.write, extras) }
23
+ }
24
+
25
+ /** The shape fault of a `scope()` `access` value, or `undefined` when well-formed. */
26
+ export const accessShapeFault = (value: unknown): string | undefined => {
27
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
28
+ return "access must be an object of { read?, write? } glob arrays"
29
+ }
30
+ for (const [side, globs] of Object.entries(value)) {
31
+ if (side !== "read" && side !== "write") return `unknown access key "${side}"`
32
+ if (globs === undefined) continue
33
+ if (!Array.isArray(globs) || globs.some((g) => typeof g !== "string")) {
34
+ return `access.${side} must be an array of glob strings`
35
+ }
36
+ }
37
+ return undefined
38
+ }
39
+
40
+ const sameGlobs = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean =>
41
+ a === undefined || b === undefined
42
+ ? a === b
43
+ : a.length === b.length && a.every((g, i) => g === b[i])
44
+
45
+ export const sameAccess = (a: ScopeAccess | undefined, b: ScopeAccess | undefined): boolean =>
46
+ sameGlobs(a?.read, b?.read) && sameGlobs(a?.write, b?.write)
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { formatFault } from "./Format.js"
3
+
4
+ describe("the history format", () => {
5
+ it("accepts a history entirely in the current format", () => {
6
+ expect(formatFault([{ hash: "a".repeat(40), format: 1 }])).toBeUndefined()
7
+ expect(formatFault([])).toBeUndefined()
8
+ })
9
+
10
+ it("names the first foreign commit, the found and readable formats, and what to do", () => {
11
+ const fault = formatFault([
12
+ { hash: "a".repeat(40), format: 1 },
13
+ { hash: "b".repeat(40), format: 2 },
14
+ { hash: "c".repeat(40), format: 3 },
15
+ ])
16
+ expect(fault).toBe(
17
+ "gtd: commit bbbbbbb is written in history format 2, but this gtd reads format 1 — run a gtd release that reads format 2 to continue this process",
18
+ )
19
+ })
20
+ })
@@ -0,0 +1,14 @@
1
+ // Bump only when what replay reads back out of git changes incompatibly — the
2
+ // package version must not stand in for it, since most majors leave history
3
+ // untouched. A bump that keeps reading the previous format widens the check below.
4
+ export const HISTORY_FORMAT = 1
5
+
6
+ const short = (hash: string): string => hash.slice(0, 7)
7
+
8
+ export const formatFault = (
9
+ commits: readonly { readonly hash: string; readonly format: number }[],
10
+ ): string | undefined => {
11
+ const foreign = commits.find((c) => c.format !== HISTORY_FORMAT)
12
+ if (foreign === undefined) return undefined
13
+ return `gtd: commit ${short(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads format ${HISTORY_FORMAT} — run a gtd release that reads format ${foreign.format} to continue this process`
14
+ }