@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,231 @@
1
+ /**
2
+ * Presenter layer — domain results → Node trees.
3
+ *
4
+ * Pure functions. Each converts a typed domain result into a document tree
5
+ * that renders polymorphically via dexter's output module.
6
+ */
7
+
8
+ import { block, field, heading, list, text } from "../../output/index.ts"
9
+ import type { Node } from "../../output/index.ts"
10
+
11
+ import type {
12
+ CommitResult,
13
+ EvalResult,
14
+ GitResult,
15
+ Package,
16
+ QueryResult,
17
+ SetupResult,
18
+ TranscriptsResult,
19
+ } from "../types.ts"
20
+ import type { ControlError } from "../errors.ts"
21
+
22
+ // --- Query ---
23
+
24
+ function presentRules(result: Extract<QueryResult, { what: "rules" }>): Node {
25
+ return block(
26
+ "rules",
27
+ ...result.data.map((scope) =>
28
+ block(
29
+ "scope",
30
+ { path: scope.path },
31
+ heading(scope.path),
32
+ field("cascade", list("ref", ...scope.cascade.map((c) => text(c)))),
33
+ ),
34
+ ),
35
+ )
36
+ }
37
+
38
+ function presentDiff(result: Extract<QueryResult, { what: "diff" }>): Node {
39
+ return block(
40
+ "diff",
41
+ ...result.data.map((scope) => {
42
+ const parts: Node[] = [heading(scope.path)]
43
+ if (scope.status) parts.push(field("status", scope.status))
44
+ if (scope.diff) parts.push(field("diff", scope.diff))
45
+ return block("scope", { path: scope.path }, ...parts)
46
+ }),
47
+ )
48
+ }
49
+
50
+ function presentCommits(result: Extract<QueryResult, { what: "commits" }>): Node {
51
+ const children: Node[] = result.data.map((scope) =>
52
+ block(
53
+ "scope",
54
+ { path: scope.path },
55
+ heading(scope.path),
56
+ field("log", list("entry", ...scope.log.map((l) => text(l)))),
57
+ ),
58
+ )
59
+ if (result.recent.length > 0) {
60
+ children.push(
61
+ block(
62
+ "recent-commits",
63
+ heading("Recent Commits"),
64
+ field("entries", list("entry", ...result.recent.map((l) => text(l)))),
65
+ ),
66
+ )
67
+ }
68
+ return block("commits", ...children)
69
+ }
70
+
71
+ function presentCheck(result: Extract<QueryResult, { what: "lint" | "typecheck" | "test" }>): Node {
72
+ const { what, data } = result
73
+ const children: Node[] = [field("errorCount", data.errorCount)]
74
+ if (data.errors.length > 0) {
75
+ children.push(field("errors", list("error", ...data.errors.map((e) => text(e.summary)))))
76
+ }
77
+ if (data.raw) {
78
+ children.push(field("raw", data.raw))
79
+ }
80
+ return block(what, ...children)
81
+ }
82
+
83
+ export function presentQuery(result: QueryResult): Node {
84
+ switch (result.what) {
85
+ case "rules":
86
+ return presentRules(result)
87
+ case "diff":
88
+ return presentDiff(result)
89
+ case "commits":
90
+ return presentCommits(result)
91
+ case "lint":
92
+ case "typecheck":
93
+ case "test":
94
+ return presentCheck(result)
95
+ }
96
+ }
97
+
98
+ // --- Git ---
99
+
100
+ function presentBlame(result: Extract<GitResult, { what: "blame" }>): Node {
101
+ return block(
102
+ "blame",
103
+ { file: result.file },
104
+ heading(result.file),
105
+ ...result.ranges.map((r) =>
106
+ block(
107
+ "range",
108
+ { commit: r.commit, lines: `${r.startLine}-${r.endLine}` },
109
+ field("commit", r.commit),
110
+ field("author", r.author),
111
+ field("date", r.date),
112
+ field("message", r.message),
113
+ field("lines", `${r.startLine}-${r.endLine}`),
114
+ field("content", r.content.join("\n")),
115
+ ),
116
+ ),
117
+ )
118
+ }
119
+
120
+ function presentPickaxe(result: Extract<GitResult, { what: "pickaxe" }>): Node {
121
+ if (result.matches.length === 0) {
122
+ return block("pickaxe", { pattern: result.pattern }, field("matches", "none"))
123
+ }
124
+ return block(
125
+ "pickaxe",
126
+ { pattern: result.pattern },
127
+ ...result.matches.map((m) =>
128
+ block(
129
+ "match",
130
+ { hash: m.hash },
131
+ field("hash", m.hash),
132
+ field("author", m.author),
133
+ field("date", m.date),
134
+ field("message", m.message),
135
+ field("diff", m.diff),
136
+ ),
137
+ ),
138
+ )
139
+ }
140
+
141
+ function presentBisect(result: Extract<GitResult, { what: "bisect" }>): Node {
142
+ const m = result.match
143
+ return block(
144
+ "bisect",
145
+ { hash: m.hash },
146
+ field("hash", m.hash),
147
+ field("author", m.author),
148
+ field("date", m.date),
149
+ field("message", m.message),
150
+ field("diff", m.diff),
151
+ )
152
+ }
153
+
154
+ export function presentGit(result: GitResult): Node {
155
+ switch (result.what) {
156
+ case "blame":
157
+ return presentBlame(result)
158
+ case "pickaxe":
159
+ return presentPickaxe(result)
160
+ case "bisect":
161
+ return presentBisect(result)
162
+ }
163
+ }
164
+
165
+ // --- Commit ---
166
+
167
+ export function presentCommit(result: CommitResult): Node {
168
+ return block(
169
+ "commit",
170
+ field("hash", result.hash),
171
+ field("message", result.message),
172
+ field("files", list("file", ...result.files.map((f) => text(f)))),
173
+ )
174
+ }
175
+
176
+ // --- Eval ---
177
+
178
+ export function presentEval(result: EvalResult): Node {
179
+ const children: Node[] = []
180
+ if (result.stdout) children.push(field("stdout", result.stdout))
181
+ if (result.stderr) children.push(field("stderr", result.stderr))
182
+ return block("eval", { ok: String(result.ok) }, ...children)
183
+ }
184
+
185
+ // --- Packages ---
186
+
187
+ export function presentPackages(packages: Package[]): Node {
188
+ return list(
189
+ "package",
190
+ ...packages.map((pkg) => block("package", field("name", pkg.shortName), field("dir", pkg.relDir))),
191
+ )
192
+ }
193
+
194
+ // --- Setup ---
195
+
196
+ export function presentSetup(result: SetupResult): Node {
197
+ return block("setup", field("settings", result.settingsPath), field("bin", result.binPath))
198
+ }
199
+
200
+ // --- Transcripts ---
201
+
202
+ export function presentTranscripts(result: TranscriptsResult): Node {
203
+ if (result.entries.length === 0) {
204
+ return block("transcripts", { project: result.projectSlug }, field("entries", "none"))
205
+ }
206
+ return block(
207
+ "transcripts",
208
+ { project: result.projectSlug },
209
+ ...result.entries.map((entry) =>
210
+ block(
211
+ "transcript",
212
+ { agentId: entry.agentId },
213
+ field("path", entry.path),
214
+ field("skill", entry.skill),
215
+ field("session", entry.sessionId),
216
+ field("timestamp", entry.timestamp),
217
+ field("size", entry.size),
218
+ ),
219
+ ),
220
+ )
221
+ }
222
+
223
+ // --- Error ---
224
+
225
+ export function presentError(err: ControlError): Node {
226
+ const children: Node[] = [field("code", err.code), field("message", text(err.message, "red"))]
227
+ if (err.hints.length > 0) {
228
+ children.push(field("hints", list("hint", ...err.hints.map((h) => text(h)))))
229
+ }
230
+ return block("error", ...children)
231
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Spec link validation.
3
+ *
4
+ * Validates markdown links [text](path.md) in CLAUDE.md and .md files.
5
+ *
6
+ * Paths resolve as:
7
+ * - /path/file.md Root-absolute (from repo root)
8
+ * - ./path/file.md Relative (from file's directory)
9
+ * - path/file.md Relative (from file's directory)
10
+ */
11
+
12
+ import { existsSync } from "node:fs"
13
+ import { basename, dirname, join, resolve } from "node:path"
14
+
15
+ export type SpecLink = {
16
+ type: "link"
17
+ path: string
18
+ line: number
19
+ }
20
+
21
+ export type BrokenLink = SpecLink & {
22
+ resolved: string
23
+ }
24
+
25
+ /** Check if a file participates in the spec system */
26
+ export function isSpecFile(filePath: string): boolean {
27
+ if (basename(filePath) === "CLAUDE.md") return true
28
+ if (filePath.endsWith(".md")) return true
29
+ return false
30
+ }
31
+
32
+ /** Extract markdown links to .md files */
33
+ export function extractSpecLinks(content: string): SpecLink[] {
34
+ const links: SpecLink[] = []
35
+ const lines = content.split("\n")
36
+
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const line = lines[i]!
39
+ const lineNum = i + 1
40
+
41
+ const linkRegex = /\[[^\]]*\]\(([^)]+\.md)\)/g
42
+ let match
43
+ while ((match = linkRegex.exec(line)) !== null) {
44
+ const path = match[1]!
45
+ if (path.startsWith("http://") || path.startsWith("https://")) continue
46
+ links.push({ type: "link", path, line: lineNum })
47
+ }
48
+ }
49
+
50
+ return links
51
+ }
52
+
53
+ /** Resolve a spec link path to absolute filesystem path */
54
+ export function resolveSpecPath(linkPath: string, fromFile: string, repoRoot: string): string {
55
+ if (linkPath.startsWith("/")) {
56
+ return join(repoRoot, linkPath)
57
+ }
58
+ return resolve(dirname(fromFile), linkPath)
59
+ }
60
+
61
+ /** Find all broken links in a spec file */
62
+ export function findBrokenLinks(filePath: string, content: string, repoRoot: string): BrokenLink[] {
63
+ const links = extractSpecLinks(content)
64
+ const broken: BrokenLink[] = []
65
+
66
+ for (const link of links) {
67
+ const resolved = resolveSpecPath(link.path, filePath, repoRoot)
68
+ if (!existsSync(resolved)) {
69
+ broken.push({ ...link, resolved })
70
+ }
71
+ }
72
+
73
+ return broken
74
+ }
75
+
76
+ /** Format broken links for hook context output */
77
+ export function formatBrokenLinks(broken: BrokenLink[]): string {
78
+ const lines = ["=== Broken Spec Links ==="]
79
+ for (const link of broken) {
80
+ lines.push(`Warning: [...](${link.path}) -> not found (line ${link.line})`)
81
+ }
82
+ return lines.join("\n")
83
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Utilities for reading hook input from stdin
3
+ */
4
+
5
+ /**
6
+ * Read all input from stdin
7
+ */
8
+ export async function readStdin(): Promise<string> {
9
+ return Bun.stdin.text()
10
+ }
11
+
12
+ /**
13
+ * Parse JSON from stdin, returning null on parse error
14
+ */
15
+ export async function readJsonStdin<T>(): Promise<T | null> {
16
+ const input = await readStdin()
17
+ if (!input.trim()) return null
18
+
19
+ try {
20
+ return JSON.parse(input) as T
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Hook input structure from Claude Code
28
+ */
29
+ export type HookInput = {
30
+ /** Session identifier for correlation */
31
+ session_id?: string
32
+ /** The tool name that triggered the hook */
33
+ tool_name?: string
34
+ /** Tool input parameters */
35
+ tool_input?: Record<string, unknown>
36
+ /** Tool output (for PostToolUse) */
37
+ tool_output?: unknown
38
+ }
39
+
40
+ /**
41
+ * Extract file path from hook input
42
+ */
43
+ export function getFilePath(input: HookInput | null): string | null {
44
+ if (!input?.tool_input) return null
45
+ const filePath = input.tool_input.file_path
46
+ return typeof filePath === "string" ? filePath : null
47
+ }
48
+
49
+ /**
50
+ * Extract command from Bash hook input
51
+ */
52
+ export function getCommand(input: HookInput | null): string | null {
53
+ if (!input?.tool_input) return null
54
+ const command = input.tool_input.command
55
+ return typeof command === "string" ? command : null
56
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Port interfaces — what control domain needs from the outside world.
3
+ *
4
+ * Adapters implement these. Domain functions accept ControlPorts.
5
+ */
6
+
7
+ export type SpawnResult = { success: boolean; stdout: string; stderr: string }
8
+
9
+ export type GitPort = {
10
+ run(args: string[], env?: Record<string, string>): SpawnResult
11
+ checkIgnore(file: string): boolean
12
+ }
13
+
14
+ export type FsPort = {
15
+ exists(path: string): boolean
16
+ readFile(path: string): string
17
+ writeFile(path: string, content: string): void
18
+ readDir(path: string): Array<{ name: string; isDirectory: boolean }>
19
+ unlink(path: string): void
20
+ mkdir(path: string): void
21
+ }
22
+
23
+ export type ProcessHandle = {
24
+ onLine(stream: "stdout" | "stderr", cb: (line: string) => void): void
25
+ wait(): Promise<number | null>
26
+ }
27
+
28
+ export type ProcessPort = {
29
+ spawn(params: {
30
+ cmd: string
31
+ args: string[]
32
+ cwd: string
33
+ env?: Record<string, string>
34
+ timeout?: number
35
+ }): ProcessHandle
36
+ }
37
+
38
+ export type GlobPort = {
39
+ match(pattern: string, candidates: string[]): string[]
40
+ }
41
+
42
+ export type ControlPorts = {
43
+ git: GitPort
44
+ fs: FsPort
45
+ process: ProcessPort
46
+ glob: GlobPort
47
+ tmpdir: () => string
48
+ homedir: () => string
49
+ root: string
50
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Domain types — command parameters, results, and shared contracts.
3
+ */
4
+
5
+ export type CommitParams = {
6
+ message: string
7
+ files: string[]
8
+ }
9
+
10
+ export type CommitResult = {
11
+ hash: string
12
+ message: string
13
+ files: string[]
14
+ }
15
+
16
+ export type QualityCheck = {
17
+ name: string
18
+ status: "pass" | "fail"
19
+ output: string
20
+ }
21
+
22
+ export type QualityResult = {
23
+ passed: boolean
24
+ checks: QualityCheck[]
25
+ }
26
+
27
+ export type SetupResult = {
28
+ settingsPath: string
29
+ binPath: string
30
+ }
31
+
32
+ // --- eval ---
33
+
34
+ export type EvalParams = { code: string; timeout?: number }
35
+ export type EvalResult = { ok: boolean; stdout: string; stderr: string }
36
+
37
+ // --- transcripts ---
38
+
39
+ export type TranscriptsParams = { skill?: string; minutes?: number }
40
+
41
+ export type TranscriptEntry = {
42
+ path: string
43
+ agentId: string
44
+ sessionId: string
45
+ skill: string
46
+ timestamp: string
47
+ size: number
48
+ }
49
+
50
+ export type TranscriptsResult = {
51
+ projectSlug: string
52
+ entries: TranscriptEntry[]
53
+ }
54
+
55
+ // --- packages ---
56
+
57
+ export type Package = {
58
+ name: string
59
+ shortName: string
60
+ dir: string
61
+ relDir: string
62
+ scripts: Record<string, string>
63
+ }
64
+
65
+ // --- blame / pickaxe / bisect ---
66
+
67
+ export type BlameRange = {
68
+ commit: string
69
+ author: string
70
+ date: string
71
+ message: string
72
+ startLine: number
73
+ endLine: number
74
+ content: string[]
75
+ }
76
+
77
+ export type PickaxeMatch = {
78
+ hash: string
79
+ author: string
80
+ date: string
81
+ message: string
82
+ diff: string
83
+ }
84
+
85
+ export type BisectMatch = {
86
+ hash: string
87
+ author: string
88
+ date: string
89
+ message: string
90
+ diff: string
91
+ }
92
+
93
+ export type GitResult =
94
+ | { what: "blame"; file: string; ranges: BlameRange[] }
95
+ | { what: "pickaxe"; pattern: string; matches: PickaxeMatch[] }
96
+ | { what: "bisect"; match: BisectMatch }
97
+
98
+ // --- rules / diff / commits / lint / typecheck / test ---
99
+
100
+ export type RulesScope = { path: string; cascade: string[] }
101
+ export type DiffScope = { path: string; status: string; diff: string }
102
+ export type CommitsScope = { path: string; log: string[] }
103
+
104
+ export type CheckError = { line: number; summary: string }
105
+ export type CheckData = { errorCount: number; errors: CheckError[]; raw: string }
106
+
107
+ export type QueryResult =
108
+ | { what: "rules"; scopes: string[]; data: RulesScope[] }
109
+ | { what: "diff"; scopes: string[]; data: DiffScope[] }
110
+ | { what: "commits"; scopes: string[]; data: CommitsScope[]; recent: string[] }
111
+ | { what: "lint"; scopes: string[]; data: CheckData }
112
+ | { what: "typecheck"; scopes: string[]; data: CheckData }
113
+ | { what: "test"; scopes: string[]; data: CheckData }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Builder functions — construct document trees from domain data.
3
+ *
4
+ * Pure functions returning immutable nodes.
5
+ */
6
+
7
+ import type { BlockNode, FieldNode, HeadingNode, ListNode, Node, Primitive, Style, TextNode } from "./types.ts"
8
+
9
+ /** Styled text content. */
10
+ export function text(value: string, style?: Style): TextNode {
11
+ return style ? { kind: "text", value, style } : { kind: "text", value }
12
+ }
13
+
14
+ /** Key-value pair. Primitives preserved for JSON type fidelity. */
15
+ export function field(label: string, value: Primitive | Node): FieldNode {
16
+ return { kind: "field", label, value }
17
+ }
18
+
19
+ function isNode(x: unknown): x is Node {
20
+ return typeof x === "object" && x !== null && "kind" in x
21
+ }
22
+
23
+ /**
24
+ * Semantic container with tag and optional attributes.
25
+ *
26
+ * ```ts
27
+ * block("commit", field("hash", "abc"))
28
+ * block("scope", { path: "meta/src" }, field("status", "clean"))
29
+ * ```
30
+ */
31
+ export function block(tag: string, ...args: (Node | Record<string, string>)[]): BlockNode {
32
+ if (args.length > 0 && args[0] !== undefined && !isNode(args[0])) {
33
+ return { kind: "block", tag, attrs: args[0] as Record<string, string>, children: args.slice(1) as Node[] }
34
+ }
35
+ return { kind: "block", tag, children: args as Node[] }
36
+ }
37
+
38
+ /**
39
+ * Ordered collection. Optional tag names individual items in XML.
40
+ *
41
+ * ```ts
42
+ * list(text("a.ts"), text("b.ts"))
43
+ * list("file", text("a.ts"), text("b.ts")) // XML: <file>a.ts</file><file>b.ts</file>
44
+ * ```
45
+ */
46
+ export function list(...args: (string | Node)[]): ListNode {
47
+ if (typeof args[0] === "string") {
48
+ return { kind: "list", tag: args[0], items: args.slice(1) as Node[] }
49
+ }
50
+ return { kind: "list", items: args as Node[] }
51
+ }
52
+
53
+ /** Display heading — renders in CLI and Markdown, omitted in JSON and XML. */
54
+ export function heading(value: string): HeadingNode {
55
+ return { kind: "heading", text: value }
56
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Polymorphic output — build once, render to any format.
3
+ */
4
+
5
+ // Types
6
+ export type {
7
+ BlockNode,
8
+ FieldNode,
9
+ HeadingNode,
10
+ ListNode,
11
+ Node,
12
+ OutputMode,
13
+ Primitive,
14
+ Style,
15
+ TextNode,
16
+ } from "./types.ts"
17
+
18
+ // Builders
19
+ export { block, field, heading, list, text } from "./build.ts"
20
+
21
+ // Renderers
22
+ export { render } from "./render.ts"
23
+ export { toValue } from "./render-json.ts"
24
+ export { escapeXml } from "./render-xml.ts"