@vladpazych/dexter 0.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 (68) hide show
  1. package/bin/dexter +6 -0
  2. package/package.json +43 -0
  3. package/src/claude/index.ts +6 -0
  4. package/src/cli.ts +39 -0
  5. package/src/env/define.ts +190 -0
  6. package/src/env/index.ts +10 -0
  7. package/src/env/loader.ts +61 -0
  8. package/src/env/print.ts +98 -0
  9. package/src/env/validate.ts +46 -0
  10. package/src/index.ts +16 -0
  11. package/src/meta/adapters/fs.ts +22 -0
  12. package/src/meta/adapters/git.ts +29 -0
  13. package/src/meta/adapters/glob.ts +14 -0
  14. package/src/meta/adapters/index.ts +24 -0
  15. package/src/meta/adapters/process.ts +40 -0
  16. package/src/meta/cli.ts +340 -0
  17. package/src/meta/domain/bisect.ts +126 -0
  18. package/src/meta/domain/blame.ts +136 -0
  19. package/src/meta/domain/commit.ts +135 -0
  20. package/src/meta/domain/commits.ts +23 -0
  21. package/src/meta/domain/constraints/registry.ts +49 -0
  22. package/src/meta/domain/constraints/types.ts +30 -0
  23. package/src/meta/domain/diff.ts +34 -0
  24. package/src/meta/domain/eval.ts +57 -0
  25. package/src/meta/domain/format.ts +34 -0
  26. package/src/meta/domain/lint.ts +88 -0
  27. package/src/meta/domain/pickaxe.ts +99 -0
  28. package/src/meta/domain/quality.ts +145 -0
  29. package/src/meta/domain/rules.ts +21 -0
  30. package/src/meta/domain/scope-context.ts +63 -0
  31. package/src/meta/domain/service.ts +68 -0
  32. package/src/meta/domain/setup.ts +34 -0
  33. package/src/meta/domain/test.ts +72 -0
  34. package/src/meta/domain/transcripts.ts +88 -0
  35. package/src/meta/domain/typecheck.ts +41 -0
  36. package/src/meta/domain/workspace.ts +78 -0
  37. package/src/meta/errors.ts +19 -0
  38. package/src/meta/hooks/on-post-read.ts +61 -0
  39. package/src/meta/hooks/on-post-write.ts +65 -0
  40. package/src/meta/hooks/on-pre-bash.ts +69 -0
  41. package/src/meta/hooks/stubs.ts +51 -0
  42. package/src/meta/index.ts +36 -0
  43. package/src/meta/lib/actor.ts +53 -0
  44. package/src/meta/lib/eslint.ts +58 -0
  45. package/src/meta/lib/format.ts +55 -0
  46. package/src/meta/lib/paths.ts +36 -0
  47. package/src/meta/lib/present.ts +231 -0
  48. package/src/meta/lib/spec-links.ts +83 -0
  49. package/src/meta/lib/stdin.ts +56 -0
  50. package/src/meta/ports.ts +50 -0
  51. package/src/meta/types.ts +113 -0
  52. package/src/output/build.ts +56 -0
  53. package/src/output/index.ts +24 -0
  54. package/src/output/output.test.ts +374 -0
  55. package/src/output/render-cli.ts +55 -0
  56. package/src/output/render-json.ts +80 -0
  57. package/src/output/render-md.ts +43 -0
  58. package/src/output/render-xml.ts +55 -0
  59. package/src/output/render.ts +23 -0
  60. package/src/output/types.ts +44 -0
  61. package/src/pipe/format.ts +167 -0
  62. package/src/pipe/index.ts +4 -0
  63. package/src/pipe/parse.ts +131 -0
  64. package/src/pipe/spawn.ts +205 -0
  65. package/src/pipe/types.ts +27 -0
  66. package/src/terminal/colors.ts +95 -0
  67. package/src/terminal/index.ts +16 -0
  68. package/src/version.ts +1 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * createCLI — the extension API for consumer repos.
3
+ *
4
+ * Consumer repos import this factory and compose their own CLI
5
+ * with custom commands and hook extensions on top of dexter's core.
6
+ */
7
+
8
+ import { render } from "../output/index.ts"
9
+ import type { OutputMode } from "../output/types.ts"
10
+
11
+ import { isControlError } from "./errors.ts"
12
+ import { findRepoRoot } from "./lib/paths.ts"
13
+ import { parseFormat } from "./lib/format.ts"
14
+ import {
15
+ presentQuery,
16
+ presentGit,
17
+ presentCommit,
18
+ presentEval,
19
+ presentPackages,
20
+ presentSetup,
21
+ presentTranscripts,
22
+ presentError,
23
+ } from "./lib/present.ts"
24
+ import { createControlPorts } from "./adapters/index.ts"
25
+ import { createControlService, type ControlService } from "./domain/service.ts"
26
+
27
+ import { onPreBash } from "./hooks/on-pre-bash.ts"
28
+ import { onPostWrite } from "./hooks/on-post-write.ts"
29
+ import { onPostRead } from "./hooks/on-post-read.ts"
30
+ import {
31
+ onSessionStart,
32
+ onPostBash,
33
+ onStop,
34
+ onPromptSubmit,
35
+ onNotification,
36
+ onPreCompact,
37
+ onToolFailure,
38
+ onSubagentStart,
39
+ onSubagentStop,
40
+ onSessionEnd,
41
+ onPermissionRequest,
42
+ } from "./hooks/stubs.ts"
43
+
44
+ import type { HookInput } from "./lib/stdin.ts"
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Public types
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export type HookContext = {
51
+ root: string
52
+ service: ControlService
53
+ }
54
+
55
+ export type HookOutput = {
56
+ additionalContext?: string
57
+ } | void
58
+
59
+ export type CLIConfig = {
60
+ commands?: Record<string, (args: string[], ctx: HookContext) => Promise<void> | void>
61
+ hooks?: {
62
+ "pre-bash"?: { deny?: Array<{ pattern: RegExp; hint: string }> }
63
+ "post-read"?: (input: HookInput, ctx: HookContext) => HookOutput | Promise<HookOutput>
64
+ "post-write"?: (input: HookInput, ctx: HookContext) => HookOutput | Promise<HookOutput>
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Core hook dispatch
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const CORE_HOOKS: Record<string, () => Promise<void>> = {
73
+ "on-pre-bash": onPreBash,
74
+ "on-post-write": onPostWrite,
75
+ "on-post-read": onPostRead,
76
+ "on-session-start": onSessionStart,
77
+ "on-post-bash": onPostBash,
78
+ "on-stop": onStop,
79
+ "on-prompt-submit": onPromptSubmit,
80
+ "on-notification": onNotification,
81
+ "on-pre-compact": onPreCompact,
82
+ "on-tool-failure": onToolFailure,
83
+ "on-subagent-start": onSubagentStart,
84
+ "on-subagent-stop": onSubagentStop,
85
+ "on-session-end": onSessionEnd,
86
+ "on-permission-request": onPermissionRequest,
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Output helpers
91
+ // ---------------------------------------------------------------------------
92
+
93
+ function output(text: string, mode: OutputMode): void {
94
+ if (mode === "cli") {
95
+ console.log(text)
96
+ } else {
97
+ console.log(text)
98
+ }
99
+ }
100
+
101
+ function outputError(err: unknown, mode: OutputMode): void {
102
+ if (isControlError(err)) {
103
+ output(render(presentError(err), mode), mode)
104
+ } else {
105
+ const message = err instanceof Error ? err.message : String(err)
106
+ console.error(message)
107
+ }
108
+ process.exit(1)
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Factory
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export function createCLI(config: CLIConfig = {}) {
116
+ return {
117
+ async run(): Promise<void> {
118
+ const [, , cmd, ...rawArgs] = process.argv
119
+
120
+ // Hook commands — delegate to core handler
121
+ if (cmd?.startsWith("on-")) {
122
+ const handler = CORE_HOOKS[cmd]
123
+ if (handler) {
124
+ await handler()
125
+ return
126
+ }
127
+ // Unknown hook — silent exit
128
+ process.exit(0)
129
+ }
130
+
131
+ // Everything below needs repo root + service
132
+ let root: string
133
+ try {
134
+ root = findRepoRoot()
135
+ } catch (err) {
136
+ console.error(err instanceof Error ? err.message : String(err))
137
+ process.exit(1)
138
+ }
139
+
140
+ const ports = createControlPorts(root)
141
+ const service = createControlService(ports)
142
+ const ctx: HookContext = { root, service }
143
+
144
+ // Parse format flags
145
+ const { mode, rest: args } = parseFormat(rawArgs)
146
+
147
+ try {
148
+ // Custom commands first (override built-in)
149
+ if (cmd && config.commands?.[cmd]) {
150
+ await config.commands[cmd](args, ctx)
151
+ return
152
+ }
153
+
154
+ // Built-in domain commands
155
+ switch (cmd) {
156
+ case "commit": {
157
+ const [message, ...files] = args
158
+ if (!message) {
159
+ console.error('Usage: commit "message" file1 file2 ...')
160
+ process.exit(1)
161
+ }
162
+ const result = await service.commit({ message, files })
163
+ output(render(presentCommit(result), mode), mode)
164
+ break
165
+ }
166
+
167
+ case "rules": {
168
+ const scopes = args.length > 0 ? args : ["."]
169
+ const result = service.rules(scopes)
170
+ output(render(presentQuery(result), mode), mode)
171
+ break
172
+ }
173
+
174
+ case "diff": {
175
+ const scopes = args.length > 0 ? args : ["."]
176
+ const result = service.diff(scopes)
177
+ output(render(presentQuery(result), mode), mode)
178
+ break
179
+ }
180
+
181
+ case "commits": {
182
+ const scopes = args.length > 0 ? args : ["."]
183
+ const result = service.commits(scopes)
184
+ output(render(presentQuery(result), mode), mode)
185
+ break
186
+ }
187
+
188
+ case "lint": {
189
+ const changed = args.includes("--changed")
190
+ const scopes = args.filter((a) => a !== "--changed")
191
+ const result = await service.lint(scopes, { changed })
192
+ output(render(presentQuery(result), mode), mode)
193
+ if (result.what === "lint" && result.data.errorCount > 0) process.exit(1)
194
+ break
195
+ }
196
+
197
+ case "typecheck": {
198
+ const result = await service.typecheck(args)
199
+ output(render(presentQuery(result), mode), mode)
200
+ if (result.what === "typecheck" && result.data.errorCount > 0) process.exit(1)
201
+ break
202
+ }
203
+
204
+ case "test": {
205
+ const result = await service.test(args)
206
+ output(render(presentQuery(result), mode), mode)
207
+ if (result.what === "test" && result.data.errorCount > 0) process.exit(1)
208
+ break
209
+ }
210
+
211
+ case "blame": {
212
+ const file = args[0]
213
+ if (!file) {
214
+ console.error("Usage: blame <file> [startLine:endLine]")
215
+ process.exit(1)
216
+ }
217
+ let lines: [number, number] | undefined
218
+ if (args[1]) {
219
+ const parts = args[1].split(":")
220
+ if (parts.length === 2) {
221
+ lines = [parseInt(parts[0]!, 10), parseInt(parts[1]!, 10)]
222
+ }
223
+ }
224
+ const result = service.blame(file, lines)
225
+ output(render(presentGit(result), mode), mode)
226
+ break
227
+ }
228
+
229
+ case "pickaxe": {
230
+ const pattern = args[0]
231
+ if (!pattern) {
232
+ console.error("Usage: pickaxe <pattern> [--regex] [scopes...]")
233
+ process.exit(1)
234
+ }
235
+ const regex = args.includes("--regex")
236
+ const scopes = args.slice(1).filter((a) => a !== "--regex")
237
+ const result = service.pickaxe(pattern, { regex, scopes: scopes.length > 0 ? scopes : undefined })
238
+ output(render(presentGit(result), mode), mode)
239
+ break
240
+ }
241
+
242
+ case "bisect": {
243
+ const testCmd = args[0]
244
+ const goodIdx = args.indexOf("--good")
245
+ const badIdx = args.indexOf("--bad")
246
+ const good = goodIdx >= 0 ? args[goodIdx + 1] : undefined
247
+ const bad = badIdx >= 0 ? args[badIdx + 1] : undefined
248
+ if (!testCmd || !good) {
249
+ console.error("Usage: bisect <test-cmd> --good <ref> [--bad <ref>]")
250
+ process.exit(1)
251
+ }
252
+ const result = await service.bisect(testCmd, good, bad)
253
+ output(render(presentGit(result), mode), mode)
254
+ break
255
+ }
256
+
257
+ case "eval": {
258
+ const code = args.join(" ")
259
+ const result = await service.eval({ code })
260
+ output(render(presentEval(result), mode), mode)
261
+ if (!result.ok) process.exit(1)
262
+ break
263
+ }
264
+
265
+ case "setup": {
266
+ const result = service.setup()
267
+ output(render(presentSetup(result), mode), mode)
268
+ break
269
+ }
270
+
271
+ case "transcripts": {
272
+ const skillIdx = args.indexOf("--skill")
273
+ const minutesIdx = args.indexOf("--minutes")
274
+ const skill = skillIdx >= 0 ? args[skillIdx + 1] : undefined
275
+ const minutes = minutesIdx >= 0 ? parseInt(args[minutesIdx + 1]!, 10) : undefined
276
+ const result = service.transcripts({ skill, minutes })
277
+ output(render(presentTranscripts(result), mode), mode)
278
+ break
279
+ }
280
+
281
+ case "packages": {
282
+ const packages = service.discoverPackages()
283
+ output(render(presentPackages(packages), mode), mode)
284
+ break
285
+ }
286
+
287
+ case "format": {
288
+ // Prettier formatting
289
+ const scopes = args.length > 0 ? args : ["."]
290
+ const formatResult = await service.lint(scopes) // HACK: reuses lint, should be separate
291
+ output(render(presentQuery(formatResult), mode), mode)
292
+ break
293
+ }
294
+
295
+ case "--help":
296
+ case "-h":
297
+ case "help":
298
+ case undefined: {
299
+ const commands = [
300
+ "commit — quality-gated atomic commit",
301
+ "rules — CLAUDE.md cascade for scopes",
302
+ "diff — git status + diff for scopes",
303
+ "commits — recent commit history",
304
+ "lint — ESLint across workspace",
305
+ "typecheck — TypeScript checking",
306
+ "test — run tests",
307
+ "blame — structured git blame",
308
+ "pickaxe — find commits by pattern",
309
+ "bisect — binary search for bad commit",
310
+ "eval — sandboxed TypeScript REPL",
311
+ "setup — configure .claude/settings",
312
+ "transcripts — list subagent transcripts",
313
+ "packages — list workspace packages",
314
+ ]
315
+
316
+ if (config.commands) {
317
+ for (const name of Object.keys(config.commands)) {
318
+ commands.push(`${name} — (custom)`)
319
+ }
320
+ }
321
+
322
+ console.log(`dexter meta — agentic development toolkit\n`)
323
+ console.log(`Commands:`)
324
+ for (const c of commands) {
325
+ console.log(` ${c}`)
326
+ }
327
+ console.log(`\nFlags: --format cli|json|xml|md --json --xml --md`)
328
+ break
329
+ }
330
+
331
+ default:
332
+ console.error(`Unknown command: ${cmd}`)
333
+ process.exit(1)
334
+ }
335
+ } catch (err) {
336
+ outputError(err, mode)
337
+ }
338
+ },
339
+ }
340
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Git bisect — automated binary search for the first bad commit.
3
+ */
4
+
5
+ import type { ControlPorts } from "../ports.ts"
6
+ import type { BisectMatch, GitResult } from "../types.ts"
7
+ import { ControlError } from "../errors.ts"
8
+
9
+ const DEFAULT_TIMEOUT = 5 * 60 * 1000
10
+
11
+ /** Parse the first-bad-commit output from git bisect run. */
12
+ function parseFirstBad(output: string, ports: ControlPorts): BisectMatch {
13
+ const match = output.match(/([0-9a-f]{40}) is the first bad commit/)
14
+ if (!match) {
15
+ throw new ControlError("bisect_inconclusive", "bisect did not identify a bad commit", [
16
+ "The test command may not distinguish good from bad correctly",
17
+ "Ensure the command exits 0 for good, non-zero for bad",
18
+ ])
19
+ }
20
+
21
+ const fullHash = match[1]!
22
+ const hash = fullHash.slice(0, 8)
23
+
24
+ const show = ports.git.run(["show", "--format=%an%n%ai%n%s", "--stat", fullHash])
25
+ const showLines = show.success ? show.stdout.split("\n").filter(Boolean) : []
26
+ const author = showLines[0] ?? ""
27
+ const rawDate = showLines[1] ?? ""
28
+ const message = showLines[2] ?? ""
29
+
30
+ let date: string
31
+ try {
32
+ date = new Date(rawDate).toISOString().slice(0, 10)
33
+ } catch {
34
+ date = rawDate
35
+ }
36
+
37
+ const diffResult = ports.git.run(["show", "--format=", "--patch", fullHash])
38
+ const diff = diffResult.success ? diffResult.stdout.trim() : ""
39
+
40
+ return { hash, author, date, message, diff }
41
+ }
42
+
43
+ export async function bisect(
44
+ ports: ControlPorts,
45
+ test: string,
46
+ good: string,
47
+ bad?: string,
48
+ timeout?: number,
49
+ ): Promise<GitResult> {
50
+ if (!test) {
51
+ throw new ControlError("empty_test", "bisect requires a test command", [
52
+ "Usage: bisect <test-cmd> --good <ref>",
53
+ "The command should exit 0 for good commits, non-zero for bad",
54
+ ])
55
+ }
56
+
57
+ if (!good) {
58
+ throw new ControlError("no_good_ref", "bisect requires a good (known-working) ref", [
59
+ "Usage: bisect <test-cmd> --good <ref> [--bad <ref>]",
60
+ 'Example: bisect "bun test test/foo.test.ts" --good HEAD~20',
61
+ ])
62
+ }
63
+
64
+ const badRef = bad ?? "HEAD"
65
+
66
+ const goodCheck = ports.git.run(["rev-parse", "--verify", good])
67
+ if (!goodCheck.success) {
68
+ throw new ControlError("bad_ref", `invalid good ref: ${good}`, [
69
+ `git rev-parse --verify ${good} failed`,
70
+ "Use a commit hash, branch name, tag, or relative ref like HEAD~10",
71
+ ])
72
+ }
73
+
74
+ if (bad) {
75
+ const badCheck = ports.git.run(["rev-parse", "--verify", bad])
76
+ if (!badCheck.success) {
77
+ throw new ControlError("bad_ref", `invalid bad ref: ${bad}`, [`git rev-parse --verify ${bad} failed`])
78
+ }
79
+ }
80
+
81
+ const bisectCheck = ports.git.run(["bisect", "log"])
82
+ if (bisectCheck.success && bisectCheck.stdout.includes("git bisect start")) {
83
+ throw new ControlError("bisect_active", "a bisect session is already in progress", [
84
+ "Run `git bisect reset` to end the current session first",
85
+ ])
86
+ }
87
+
88
+ try {
89
+ const start = ports.git.run(["bisect", "start"])
90
+ if (!start.success) {
91
+ throw new ControlError("bisect_start_failed", `git bisect start failed: ${start.stderr.trim()}`, [])
92
+ }
93
+
94
+ const markBad = ports.git.run(["bisect", "bad", badRef])
95
+ if (!markBad.success) {
96
+ throw new ControlError("bisect_mark_failed", `git bisect bad ${badRef} failed: ${markBad.stderr.trim()}`, [])
97
+ }
98
+
99
+ const markGood = ports.git.run(["bisect", "good", good])
100
+ if (!markGood.success) {
101
+ throw new ControlError("bisect_mark_failed", `git bisect good ${good} failed: ${markGood.stderr.trim()}`, [])
102
+ }
103
+
104
+ const output = await new Promise<string>((resolve) => {
105
+ const lines: string[] = []
106
+ const handle = ports.process.spawn({
107
+ cmd: "git",
108
+ args: ["bisect", "run", "sh", "-c", test],
109
+ cwd: ports.root,
110
+ timeout: timeout ?? DEFAULT_TIMEOUT,
111
+ })
112
+ handle.onLine("stdout", (line) => lines.push(line))
113
+ handle.onLine("stderr", (line) => lines.push(line))
114
+ handle.wait().then((code) => {
115
+ if (code === null) {
116
+ lines.push("bisect timed out")
117
+ }
118
+ resolve(lines.join("\n"))
119
+ })
120
+ })
121
+
122
+ return { what: "bisect", match: parseFirstBad(output, ports) }
123
+ } finally {
124
+ ports.git.run(["bisect", "reset"])
125
+ }
126
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Git blame — structured per-line attribution with range grouping.
3
+ */
4
+
5
+ import type { ControlPorts } from "../ports.ts"
6
+ import type { BlameRange, GitResult } from "../types.ts"
7
+ import { ControlError } from "../errors.ts"
8
+
9
+ type PorcelainEntry = {
10
+ commit: string
11
+ author: string
12
+ date: string
13
+ message: string
14
+ line: number
15
+ content: string
16
+ }
17
+
18
+ /** Parse git blame --line-porcelain output into individual entries. */
19
+ function parsePorcelain(raw: string): PorcelainEntry[] {
20
+ const entries: PorcelainEntry[] = []
21
+ const lines = raw.split("\n")
22
+ let i = 0
23
+
24
+ while (i < lines.length) {
25
+ const header = lines[i]!
26
+ const match = header.match(/^([0-9a-f]{40})\s+\d+\s+(\d+)/)
27
+ if (!match) {
28
+ i++
29
+ continue
30
+ }
31
+
32
+ const commit = match[1]!.slice(0, 8)
33
+ const line = parseInt(match[2]!, 10)
34
+ let author = ""
35
+ let date = ""
36
+ let message = ""
37
+ let content = ""
38
+
39
+ i++
40
+ while (i < lines.length) {
41
+ const cur = lines[i]!
42
+ if (cur.startsWith("\t")) {
43
+ content = cur.slice(1)
44
+ i++
45
+ break
46
+ }
47
+ if (cur.startsWith("author ")) author = cur.slice(7)
48
+ else if (cur.startsWith("author-time ")) {
49
+ const ts = parseInt(cur.slice(12), 10)
50
+ date = new Date(ts * 1000).toISOString().slice(0, 10)
51
+ } else if (cur.startsWith("summary ")) message = cur.slice(8)
52
+ i++
53
+ }
54
+
55
+ entries.push({ commit, author, date, message, line, content })
56
+ }
57
+
58
+ return entries
59
+ }
60
+
61
+ /** Group consecutive entries from the same commit into ranges. */
62
+ function groupRanges(entries: PorcelainEntry[]): BlameRange[] {
63
+ if (entries.length === 0) return []
64
+
65
+ const ranges: BlameRange[] = []
66
+ let current: BlameRange = {
67
+ commit: entries[0]!.commit,
68
+ author: entries[0]!.author,
69
+ date: entries[0]!.date,
70
+ message: entries[0]!.message,
71
+ startLine: entries[0]!.line,
72
+ endLine: entries[0]!.line,
73
+ content: [entries[0]!.content],
74
+ }
75
+
76
+ for (let i = 1; i < entries.length; i++) {
77
+ const entry = entries[i]!
78
+ if (entry.commit === current.commit && entry.line === current.endLine + 1) {
79
+ current.endLine = entry.line
80
+ current.content.push(entry.content)
81
+ } else {
82
+ ranges.push(current)
83
+ current = {
84
+ commit: entry.commit,
85
+ author: entry.author,
86
+ date: entry.date,
87
+ message: entry.message,
88
+ startLine: entry.line,
89
+ endLine: entry.line,
90
+ content: [entry.content],
91
+ }
92
+ }
93
+ }
94
+ ranges.push(current)
95
+ return ranges
96
+ }
97
+
98
+ export function blame(ports: ControlPorts, file: string, lines?: [number, number]): GitResult {
99
+ const fullPath = `${ports.root}/${file}`
100
+ if (!ports.fs.exists(fullPath)) {
101
+ throw new ControlError("file_not_found", `file not found: ${file}`, [
102
+ "Provide a path relative to repo root",
103
+ "Example: blame meta/src/types.ts",
104
+ ])
105
+ }
106
+
107
+ const args = ["blame", "--line-porcelain"]
108
+
109
+ if (lines) {
110
+ args.push(`-L`, `${lines[0]},${lines[1]}`)
111
+ }
112
+
113
+ const ignoreRevsPath = `${ports.root}/.git-blame-ignore-revs`
114
+ if (ports.fs.exists(ignoreRevsPath)) {
115
+ args.push("--ignore-revs-file", ".git-blame-ignore-revs")
116
+ }
117
+
118
+ args.push("--", file)
119
+
120
+ const result = ports.git.run(args)
121
+ if (!result.success) {
122
+ throw new ControlError(
123
+ "blame_failed",
124
+ `git blame failed: ${result.stderr.trim()}`,
125
+ [
126
+ "Check that the file is tracked by git",
127
+ lines ? `Line range ${lines[0]}:${lines[1]} may be out of bounds` : "",
128
+ ].filter(Boolean),
129
+ )
130
+ }
131
+
132
+ const entries = parsePorcelain(result.stdout)
133
+ const ranges = groupRanges(entries)
134
+
135
+ return { what: "blame", file, ranges }
136
+ }