@pmelab/gtd 24.0.0 → 24.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -13
  3. package/claude/hooks/access.ts +26 -1
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  12. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  13. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  14. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  15. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  16. package/dist/flows.mjs +525 -0
  17. package/dist/gtd.bundle.mjs +43339 -55575
  18. package/dist/steering.mjs +2 -0
  19. package/dist/testkit.mjs +13006 -0
  20. package/dist/wire.mjs +2 -0
  21. package/dist/workflow-kit.mjs +352 -0
  22. package/dist/workflows/feature.mjs +315 -0
  23. package/dist/workflows/fix.mjs +40 -0
  24. package/dist/workflows/review.mjs +25 -0
  25. package/dist/workflows.mjs +8 -6
  26. package/package.json +69 -8
  27. package/src/cli/Cli.test.ts +1329 -0
  28. package/src/cli/Cli.ts +1348 -0
  29. package/src/cli/Program.test.ts +2262 -0
  30. package/src/cli/Program.ts +1331 -0
  31. package/src/cli/ToolCommands.ts +43 -0
  32. package/src/cli/index.ts +14 -0
  33. package/src/edge/Edge.test.ts +1016 -0
  34. package/src/edge/Edge.ts +1225 -0
  35. package/src/edge/index.ts +16 -0
  36. package/src/emit/Emit.test.ts +425 -0
  37. package/src/emit/Emit.ts +139 -0
  38. package/src/emit/GitScript.test.ts +260 -0
  39. package/src/emit/GitScript.ts +131 -0
  40. package/src/emit/ModeContradiction.test.ts +120 -0
  41. package/src/emit/ModeContradiction.ts +66 -0
  42. package/src/emit/OutcomeScript.test.ts +238 -0
  43. package/src/emit/OutcomeScript.ts +73 -0
  44. package/src/emit/SteeringMode.test.ts +274 -0
  45. package/src/emit/SteeringMode.ts +256 -0
  46. package/src/emit/index.ts +40 -0
  47. package/src/install/ConfigScaffold.ts +19 -0
  48. package/src/install/Install.test.ts +426 -0
  49. package/src/install/Install.ts +634 -0
  50. package/src/install/Version.ts +27 -0
  51. package/src/install/index.ts +3 -0
  52. package/src/platform/CommandRunner.ts +74 -0
  53. package/src/platform/Commentary.test.ts +126 -0
  54. package/src/platform/Commentary.ts +92 -0
  55. package/src/platform/Git.test.ts +50 -0
  56. package/src/platform/Git.ts +552 -0
  57. package/src/platform/Host.ts +42 -0
  58. package/src/platform/RetainedHistory.test.ts +65 -0
  59. package/src/platform/RetainedHistory.ts +34 -0
  60. package/src/platform/Workspace.test.ts +669 -0
  61. package/src/platform/Workspace.ts +631 -0
  62. package/src/platform/WorktreeState.test.ts +162 -0
  63. package/src/platform/WorktreeState.ts +50 -0
  64. package/src/platform/index.ts +22 -0
  65. package/src/replay/Access.test.ts +53 -0
  66. package/src/replay/Access.ts +46 -0
  67. package/src/replay/Format.test.ts +20 -0
  68. package/src/replay/Format.ts +14 -0
  69. package/src/replay/Replay.test.ts +887 -0
  70. package/src/replay/Replay.ts +669 -0
  71. package/src/replay/Trailers.test.ts +117 -0
  72. package/src/replay/Trailers.ts +213 -0
  73. package/src/replay/Tree.test.ts +25 -0
  74. package/src/replay/Tree.ts +45 -0
  75. package/src/replay/index.ts +11 -0
  76. package/src/steering/Blocks.fixture.ts +6 -0
  77. package/src/steering/Blocks.test.ts +167 -0
  78. package/src/steering/Blocks.ts +321 -0
  79. package/src/steering/CodeThreads.test.ts +127 -0
  80. package/src/steering/CodeThreads.ts +197 -0
  81. package/src/steering/Edits.test.ts +19 -0
  82. package/src/steering/Edits.ts +20 -0
  83. package/src/steering/Eol.test.ts +16 -0
  84. package/src/steering/Eol.ts +2 -0
  85. package/src/steering/FORMATS.md +58 -0
  86. package/src/steering/Footnotes.test.ts +977 -0
  87. package/src/steering/Footnotes.ts +661 -0
  88. package/src/steering/Inline.fixture.ts +7 -0
  89. package/src/steering/Inline.test.ts +227 -0
  90. package/src/steering/Inline.ts +195 -0
  91. package/src/steering/MarkdownTree.test.ts +247 -0
  92. package/src/steering/MarkdownTree.ts +260 -0
  93. package/src/steering/Outline.ts +19 -0
  94. package/src/steering/SteeringFormat.test.ts +91 -0
  95. package/src/steering/SteeringFormat.ts +267 -0
  96. package/src/steering/SteeringFormats.test.ts +163 -0
  97. package/src/steering/Threads.ts +163 -0
  98. package/src/steering/contract.test.ts +264 -0
  99. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  100. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  101. package/src/steering/fixtures/free-form/notes.md +12 -0
  102. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  103. package/src/steering/fixtures/qa/answered.md +23 -0
  104. package/src/steering/fixtures/qa/answered.read.json +119 -0
  105. package/src/steering/fixtures/qa/requirements.md +160 -0
  106. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  107. package/src/steering/fixtures/review/review.md +76 -0
  108. package/src/steering/fixtures/review/review.read.json +237 -0
  109. package/src/steering/fixtures/review/ticked.md +23 -0
  110. package/src/steering/fixtures/review/ticked.read.json +99 -0
  111. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  112. package/src/steering/fixtures/review-notes/noted.md +19 -0
  113. package/src/steering/freeform.test.ts +311 -0
  114. package/src/steering/freeform.ts +148 -0
  115. package/src/steering/index.test.ts +62 -0
  116. package/src/steering/index.ts +140 -0
  117. package/src/steering/qa.test.ts +2160 -0
  118. package/src/steering/qa.ts +1104 -0
  119. package/src/steering/review.test.ts +1829 -0
  120. package/src/steering/review.ts +1180 -0
  121. package/src/step/LandStep.ts +31 -0
  122. package/src/step/RepoSnapshot.ts +29 -0
  123. package/src/step/accessRefusal.test.ts +58 -0
  124. package/src/step/accessRefusal.ts +26 -0
  125. package/src/step/index.ts +5 -0
  126. package/src/step/planStart.test.ts +202 -0
  127. package/src/step/planStart.ts +167 -0
  128. package/src/step/planStep.test.ts +104 -0
  129. package/src/step/planStep.ts +89 -0
  130. package/src/step/snapshot.fixture.ts +28 -0
  131. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  132. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  133. package/src/testkit/FakeGitOperations.ts +95 -0
  134. package/src/testkit/GitTiers.test.ts +11 -0
  135. package/src/testkit/GitTiers.ts +1052 -0
  136. package/src/testkit/InMemRepo.ts +467 -0
  137. package/src/testkit/Layers.test.ts +58 -0
  138. package/src/testkit/Layers.ts +241 -0
  139. package/src/testkit/StrictGitOperations.ts +31 -0
  140. package/src/testkit/TestRepo.test.ts +119 -0
  141. package/src/testkit/TestRepo.ts +274 -0
  142. package/src/testkit/cliIo.ts +36 -0
  143. package/src/testkit/index.ts +9 -0
  144. package/src/testkit/steps.ts +177 -0
  145. package/src/testkit/testkit.feature +65 -0
  146. package/src/wire/BeatDocument.test.ts +419 -0
  147. package/src/wire/BeatDocument.ts +114 -0
  148. package/src/wire/BeatPlain.test.ts +113 -0
  149. package/src/wire/BeatPlain.ts +127 -0
  150. package/src/wire/BeatStatus.test.ts +59 -0
  151. package/src/wire/BeatStatus.ts +81 -0
  152. package/src/wire/Demand.test.ts +142 -0
  153. package/src/wire/Demand.ts +108 -0
  154. package/src/wire/ExitCodes.test.ts +21 -0
  155. package/src/wire/ExitCodes.ts +16 -0
  156. package/src/wire/Glob.ts +29 -0
  157. package/src/wire/Judge.ts +22 -0
  158. package/src/wire/Land.test.ts +120 -0
  159. package/src/wire/Land.ts +75 -0
  160. package/src/wire/Select.test.ts +148 -0
  161. package/src/wire/Select.ts +78 -0
  162. package/src/wire/Sessions.test.ts +59 -0
  163. package/src/wire/Sessions.ts +39 -0
  164. package/src/wire/WireSchema.test.ts +162 -0
  165. package/src/wire/constants.ts +22 -0
  166. package/src/wire/golden/1/beat-capture.json +34 -0
  167. package/src/wire/golden/1/beat-message.json +34 -0
  168. package/src/wire/golden/1/beat-prompt.json +44 -0
  169. package/src/wire/golden/1/beat-script.json +34 -0
  170. package/src/wire/golden/1/beat-stalled.json +34 -0
  171. package/src/wire/golden/1/judge.json +14 -0
  172. package/src/wire/golden/1/land-noop.json +10 -0
  173. package/src/wire/golden/1/land.json +10 -0
  174. package/src/wire/index.ts +12 -0
  175. package/src/wire/types.ts +46 -0
  176. package/src/workflow/Diagnostic.test.ts +93 -0
  177. package/src/workflow/Diagnostic.ts +54 -0
  178. package/src/workflow/SteeringFormats.test.ts +27 -0
  179. package/src/workflow/SteeringFormats.ts +6 -0
  180. package/src/workflow/Workflow.ts +77 -0
  181. package/src/workflow/compile.test.ts +53 -0
  182. package/src/workflow/compile.ts +56 -0
  183. package/src/workflow/discovery.ts +122 -0
  184. package/src/workflow/fingerprint.test.ts +52 -0
  185. package/src/workflow/fingerprint.ts +53 -0
  186. package/src/workflow/index.ts +33 -0
  187. package/src/workflow/load.test.ts +1017 -0
  188. package/src/workflow/load.ts +836 -0
  189. package/src/workflow/skills.test.ts +109 -0
  190. package/src/workflow/skills.ts +90 -0
  191. package/src/workflow/vars.ts +33 -0
  192. package/src/workflows/feature/feature.test.ts +27 -33
  193. package/src/workflows/feature/index.ts +10 -0
  194. package/src/workflows/fix/fix.feature +80 -0
  195. package/src/workflows/fix/fix.test.ts +58 -78
  196. package/src/workflows/fix/index.ts +6 -0
  197. package/src/workflows/index.ts +2 -10
  198. package/src/workflows/review/index.ts +6 -0
  199. package/src/workflows/review/review.test.ts +85 -29
  200. package/src/workflows/review/review.ts +3 -1
  201. package/src/workflows/shared/config.fixture.ts +12 -0
  202. package/src/workflows/shared/reserved.ts +11 -0
  203. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,74 @@
1
+ import { Command, CommandExecutor } from "@effect/platform"
2
+ import { Context, Effect, Layer, Stream } from "effect"
3
+ import { Host } from "./Host.js"
4
+
5
+ /** One command run's outcome: its exit status (a signal death reported as `null`, matching `spawnSync`) and its combined output (stdout then stderr). */
6
+ interface CommandOutcome {
7
+ readonly status: number | null
8
+ readonly output: string
9
+ /** `stdout`/`stderr` captured separately, alongside the merged `output` — additive, for callers (the tRPC router) that need a refusal's three fields distinct rather than flattened into one string. Optional so existing test doubles built from `{ status, output }` alone still satisfy this interface. */
10
+ readonly stdout?: string
11
+ readonly stderr?: string
12
+ }
13
+
14
+ /**
15
+ * The subprocess port: run one shell command in the repo root, wait for it to
16
+ * finish, and get its combined output back as one string (a workflow
17
+ * `script:` is run by the DRIVER, never by gtd — and a mode's
18
+ * `format:`/`validate:` command is RENDERED into the driver's script rather
19
+ * than spawned here). Its remaining callers are `gtd ui`'s own helpers
20
+ * (openssl, tailscale). `gtd ui`'s loop command is spawned by `Loop.ts`'s own
21
+ * port instead — it needs a per-worktree cwd, a shim-prepended `PATH`, real
22
+ * OS signal control while the child is still running, and never-combined
23
+ * stdout/stderr, none of which this port's single-shot "run to completion"
24
+ * shape supports.
25
+ */
26
+ export class CommandRunner extends Context.Tag("CommandRunner")<
27
+ CommandRunner,
28
+ {
29
+ /** A SPAWN failure (no `bash`, unreadable cwd) fails the Effect; a non-zero EXIT is a value — how a mode command reports "invalid"/"broken" for the caller to interpret. */
30
+ readonly bash: (command: string) => Effect.Effect<CommandOutcome, Error>
31
+ }
32
+ >() {
33
+ /** A test layer over a canned `bash` implementation — no subprocess. */
34
+ static readonly layer = (
35
+ bash: (command: string) => Effect.Effect<CommandOutcome, Error>,
36
+ ): Layer.Layer<CommandRunner> => Layer.succeed(CommandRunner, { bash })
37
+
38
+ static Live = Layer.effect(
39
+ CommandRunner,
40
+ Effect.gen(function* () {
41
+ const { root } = yield* Host
42
+ const executor = yield* CommandExecutor.CommandExecutor
43
+ return {
44
+ bash: (command: string) =>
45
+ Effect.scoped(
46
+ Effect.gen(function* () {
47
+ const process = yield* executor.start(
48
+ Command.make("bash", "-c", command).pipe(
49
+ Command.workingDirectory(root),
50
+ Command.stdout("pipe"),
51
+ Command.stderr("pipe"),
52
+ ),
53
+ )
54
+ const collect = (stream: typeof process.stdout) =>
55
+ stream.pipe(Stream.decodeText(), Stream.mkString)
56
+ const [stdout, stderr, status] = yield* Effect.all(
57
+ [
58
+ collect(process.stdout),
59
+ collect(process.stderr),
60
+ // A signal death fails process.exitCode; map to null to match spawnSync's contract.
61
+ process.exitCode.pipe(
62
+ Effect.map((code): number | null => code),
63
+ Effect.catchAll(() => Effect.succeed(null)),
64
+ ),
65
+ ],
66
+ { concurrency: "unbounded" },
67
+ )
68
+ return { status, output: `${stdout}${stderr}`, stdout, stderr }
69
+ }),
70
+ ).pipe(Effect.mapError((e) => (e instanceof Error ? e : new Error(String(e))))),
71
+ }
72
+ }),
73
+ )
74
+ }
@@ -0,0 +1,126 @@
1
+ import { Effect } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { GtdError, Narrator, renderFailure } from "./Commentary.js"
4
+ import { PRESENTATION_FAILURE_WARNING } from "../emit/index.js"
5
+
6
+ describe("Narrator", () => {
7
+ it("is a no-op writer when built with verbose: false", async () => {
8
+ const lines: string[] = []
9
+ await Effect.runPromise(
10
+ Effect.gen(function* () {
11
+ const narrator = yield* Narrator
12
+ yield* narrator.narrate("hello")
13
+ }).pipe(Effect.provide(Narrator.layer((chunk) => lines.push(chunk), false))),
14
+ )
15
+ expect(lines).toEqual([])
16
+ })
17
+
18
+ it("writes each narrated line, newline-terminated, when verbose: true", async () => {
19
+ const lines: string[] = []
20
+ await Effect.runPromise(
21
+ Effect.gen(function* () {
22
+ const narrator = yield* Narrator
23
+ yield* narrator.narrate("rest resolved: build.review.closing")
24
+ yield* narrator.narrate("config: module /repo/gtd.config.ts")
25
+ }).pipe(Effect.provide(Narrator.layer((chunk) => lines.push(chunk), true))),
26
+ )
27
+ expect(lines).toEqual([
28
+ "rest resolved: build.review.closing\n",
29
+ "config: module /repo/gtd.config.ts\n",
30
+ ])
31
+ })
32
+
33
+ it("warn writes to the sink even when verbose: false", async () => {
34
+ const lines: string[] = []
35
+ await Effect.runPromise(
36
+ Effect.gen(function* () {
37
+ const narrator = yield* Narrator
38
+ yield* narrator.warn("careful: something odd")
39
+ }).pipe(Effect.provide(Narrator.layer((chunk) => lines.push(chunk), false))),
40
+ )
41
+ expect(lines).toEqual(["careful: something odd\n"])
42
+ })
43
+
44
+ it("warn writes to the sink when verbose: true too", async () => {
45
+ const lines: string[] = []
46
+ await Effect.runPromise(
47
+ Effect.gen(function* () {
48
+ const narrator = yield* Narrator
49
+ yield* narrator.warn("careful: something odd")
50
+ }).pipe(Effect.provide(Narrator.layer((chunk) => lines.push(chunk), true))),
51
+ )
52
+ expect(lines).toEqual(["careful: something odd\n"])
53
+ })
54
+ })
55
+
56
+ describe("GtdError", () => {
57
+ it("carries a message and a detail list, defaulting detail to empty", () => {
58
+ const bare = new GtdError("boom")
59
+ expect(bare.message).toBe("boom")
60
+ expect(bare.detail).toEqual([])
61
+
62
+ const detailed = new GtdError("boom", ["because X", "because Y"])
63
+ expect(detailed.message).toBe("boom")
64
+ expect(detailed.detail).toEqual(["because X", "because Y"])
65
+ })
66
+
67
+ it("is a genuine Error — every `error instanceof Error` normalization idiom still catches it", () => {
68
+ expect(new GtdError("boom")).toBeInstanceOf(Error)
69
+ })
70
+ })
71
+
72
+ describe("renderFailure", () => {
73
+ it("prefixes a bare message with 'gtd: '", () => {
74
+ expect(renderFailure(new Error("boom"))).toBe("gtd: boom")
75
+ })
76
+
77
+ it("does not double-prefix a message that already starts with 'gtd:'", () => {
78
+ expect(renderFailure(new Error("gtd: already prefixed"))).toBe("gtd: already prefixed")
79
+ })
80
+
81
+ it("does not double-prefix a message that already starts with 'gtd '", () => {
82
+ expect(renderFailure(new Error("gtd land: out of turn"))).toBe("gtd land: out of turn")
83
+ })
84
+
85
+ it("stringifies a non-Error thrown value", () => {
86
+ expect(renderFailure("just a string")).toBe("gtd: just a string")
87
+ })
88
+
89
+ it("prints a GtdError's detail lines, each indented two spaces, after the prefixed message", () => {
90
+ const error = new GtdError("gtd: bad config", ["testCommand: gtd.config.ts"])
91
+ expect(renderFailure(error)).toBe("gtd: bad config\n testCommand: gtd.config.ts")
92
+ })
93
+
94
+ it("prints every detail line, in order, for a multi-detail GtdError", () => {
95
+ const error = new GtdError("boom", ["first", "second"])
96
+ expect(renderFailure(error)).toBe("gtd: boom\n first\n second")
97
+ })
98
+
99
+ it("a plain Error (the ~100 unmigrated sites) still renders as exactly one line", () => {
100
+ expect(renderFailure(new Error("plain failure"))).not.toContain("\n")
101
+ })
102
+
103
+ it("prints detail lines for an error shaped like GtdError but not an instance of it — duck-typed, not instanceof-checked, so src/platform/Git.ts's own equivalently-shaped error (which may not import GtdError) still renders its remediation", () => {
104
+ class LookalikeError extends Error {
105
+ readonly detail: readonly string[]
106
+ constructor(message: string, detail: readonly string[]) {
107
+ super(message)
108
+ this.detail = detail
109
+ }
110
+ }
111
+ const error = new LookalikeError("gtd: invalid ref", ["ref: some-ref"])
112
+ expect(error).not.toBeInstanceOf(GtdError)
113
+ expect(renderFailure(error)).toBe("gtd: invalid ref\n ref: some-ref")
114
+ })
115
+ })
116
+
117
+ describe("the emitted script's own stderr shape stays cross-referenced here", () => {
118
+ // `Emit.ts`'s discarded-optional-failure warning is written by a SCRIPT gtd
119
+ // emits, not by gtd's own process — so it never goes through `renderFailure`
120
+ // — but it lands on the same commentary channel this module owns, so its
121
+ // wording is pinned alongside every other stderr shape rather than only in
122
+ // `Emit.test.ts`.
123
+ it("is gtd:-prefixed, matching this channel's own convention", () => {
124
+ expect(PRESENTATION_FAILURE_WARNING).toContain("gtd: presentation-only follow-up failed")
125
+ })
126
+ })
@@ -0,0 +1,92 @@
1
+ import { Context, Effect, Layer } from "effect"
2
+
3
+ /**
4
+ * One line of stderr commentary — `narrate` is gated entirely by how the
5
+ * layer was built, never by the call site; `warn` is its ungated
6
+ * counterpart, always written regardless of verbosity.
7
+ */
8
+ export class Narrator extends Context.Tag("Narrator")<
9
+ Narrator,
10
+ {
11
+ readonly narrate: (line: string) => Effect.Effect<void>
12
+ readonly warn: (line: string) => Effect.Effect<void>
13
+ }
14
+ >() {
15
+ /**
16
+ * `write` is the raw stderr sink (`CliIo.stderr` in production, so the
17
+ * in-memory `CliIo` used by `@inmem` e2e scenarios captures narration into
18
+ * the exact same buffer it already captures errors into). `verbose` gates
19
+ * every `narrate` line at construction time — the resulting service is a
20
+ * no-op writer when `false`, never a per-call check a narrating call site
21
+ * has to remember to make. `warn` bypasses that gate unconditionally.
22
+ */
23
+ static readonly layer = (
24
+ write: (chunk: string) => void,
25
+ verbose: boolean,
26
+ ): Layer.Layer<Narrator> =>
27
+ Layer.succeed(Narrator, {
28
+ narrate: (line) =>
29
+ Effect.sync(() => {
30
+ if (verbose) write(`${line}\n`)
31
+ }),
32
+ warn: (line) => Effect.sync(() => write(`${line}\n`)),
33
+ })
34
+ }
35
+
36
+ /**
37
+ * An error carrying REMEDIATION alongside its message — the offending config
38
+ * key and the layer it came from, a corrupted ref's name, a missing binary's
39
+ * resolved `$PATH`. Only two families construct one (see
40
+ * `src/workflow/load.ts` and `src/ui/Tls.ts`) — every other `Error` site
41
+ * stays a plain `Error` and renders as the single `gtd: `-prefixed line it
42
+ * always has.
43
+ */
44
+ export class GtdError extends Error {
45
+ readonly detail: readonly string[]
46
+
47
+ constructor(message: string, detail: readonly string[] = []) {
48
+ super(message)
49
+ this.name = "GtdError"
50
+ this.detail = detail
51
+ }
52
+ }
53
+
54
+ /**
55
+ * A `GtdError` that names how gtd was INVOKED, not what it did — `Cli.ts`'s
56
+ * `report` maps this to `EXIT_USAGE_ERROR` alongside `SelectorUsageError`,
57
+ * rather than the `EXIT_RUNTIME_ERROR` every plain `GtdError` gets. `gtd ui`
58
+ * refusing to start on a step it cannot render is the one constructor today
59
+ * (see `ui/Server.ts`).
60
+ */
61
+ export class GtdUsageError extends GtdError {
62
+ constructor(message: string, detail: readonly string[] = []) {
63
+ super(message, detail)
64
+ this.name = "GtdUsageError"
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Structurally, not nominally: an error carrying a `detail: readonly
70
+ * string[]` — `GtdError`'s shape, matched WITHOUT an `instanceof GtdError`
71
+ * check, because `src/platform/Git.ts` reports a corrupted ref's remediation
72
+ * lines through its own equivalently-shaped local error class.
73
+ */
74
+ const hasDetail = (error: unknown): error is { readonly detail: readonly string[] } =>
75
+ typeof error === "object" &&
76
+ error !== null &&
77
+ "detail" in error &&
78
+ Array.isArray((error as { detail: unknown }).detail)
79
+
80
+ /**
81
+ * The stderr text for a CLI failure: a `gtd: ` prefix UNLESS the message
82
+ * already carries one, then one two-space-indented line per `GtdError`
83
+ * detail (none, for a plain `Error`) — unconditional, at every verbosity.
84
+ * Most gtd errors are authored with a `gtd:`/`gtd <cmd>:` prefix of their own,
85
+ * so a blind prepend would produce a doubled `gtd: gtd: …`.
86
+ */
87
+ export const renderFailure = (error: unknown): string => {
88
+ const message = error instanceof Error ? error.message : String(error)
89
+ const prefixed = /^gtd[: ]/.test(message) ? message : `gtd: ${message}`
90
+ const detail = hasDetail(error) ? error.detail : []
91
+ return [prefixed, ...detail.map((line) => ` ${line}`)].join("\n")
92
+ }
@@ -0,0 +1,50 @@
1
+ import { Cause, Effect, Exit } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
4
+ import { gitTiers, runGitServiceContract, runGitScriptContract } from "../testkit/index.js"
5
+
6
+ /**
7
+ * The `GitOperations` contract, run identically against both the Live and
8
+ * InMemory tiers (`src/testkit/GitTiers.ts`) — 20 operations × 2 tiers.
9
+ */
10
+ for (const makeTier of gitTiers) {
11
+ const name = makeTier().name
12
+ describe(`GitService [${name}]`, () => {
13
+ runGitServiceContract(makeTier)
14
+ })
15
+ }
16
+
17
+ /**
18
+ * `resolveRef`'s own validation, unit-tested directly rather than through a
19
+ * real git repo — this is the rarer, defensive branch where the command
20
+ * SUCCEEDS but its output isn't a 40-hex-char hash: a corrupted ref, this
21
+ * module's own `InvalidRefError`.
22
+ */
23
+ describe("resolvedRefOrCorrupted", () => {
24
+ it("succeeds with a genuine 40-hex-char hash", async () => {
25
+ const hash = "a".repeat(40)
26
+ const result = await Effect.runPromise(resolvedRefOrCorrupted("HEAD", hash))
27
+ expect(result).toBe(hash)
28
+ })
29
+
30
+ it("fails with an InvalidRefError naming the ref, for anything else", async () => {
31
+ const exit = await Effect.runPromiseExit(resolvedRefOrCorrupted("some-ref", "not-a-hash"))
32
+ expect(Exit.isFailure(exit)).toBe(true)
33
+ if (Exit.isFailure(exit)) {
34
+ const error = Cause.squash(exit.cause)
35
+ expect(error).toBeInstanceOf(InvalidRefError)
36
+ expect(error).toHaveProperty("message", "Invalid ref: some-ref")
37
+ if (error instanceof InvalidRefError) expect(error.detail).toEqual(["ref: some-ref"])
38
+ }
39
+ })
40
+ })
41
+
42
+ /**
43
+ * `GitScript.ts`'s bash builders, run through real `bash` against a real git
44
+ * repo — the Live tier only, since the in-memory fake's root has no shell to
45
+ * execute against. Asserts the same 9 writer postconditions as the contract
46
+ * above, driven via the emitted script instead of the `GitOperations` port.
47
+ */
48
+ describe("GitScript", () => {
49
+ runGitScriptContract()
50
+ })