@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.
- package/bin/dexter +6 -0
- package/package.json +43 -0
- package/src/claude/index.ts +6 -0
- package/src/cli.ts +39 -0
- package/src/env/define.ts +190 -0
- package/src/env/index.ts +10 -0
- package/src/env/loader.ts +61 -0
- package/src/env/print.ts +98 -0
- package/src/env/validate.ts +46 -0
- package/src/index.ts +16 -0
- package/src/meta/adapters/fs.ts +22 -0
- package/src/meta/adapters/git.ts +29 -0
- package/src/meta/adapters/glob.ts +14 -0
- package/src/meta/adapters/index.ts +24 -0
- package/src/meta/adapters/process.ts +40 -0
- package/src/meta/cli.ts +340 -0
- package/src/meta/domain/bisect.ts +126 -0
- package/src/meta/domain/blame.ts +136 -0
- package/src/meta/domain/commit.ts +135 -0
- package/src/meta/domain/commits.ts +23 -0
- package/src/meta/domain/constraints/registry.ts +49 -0
- package/src/meta/domain/constraints/types.ts +30 -0
- package/src/meta/domain/diff.ts +34 -0
- package/src/meta/domain/eval.ts +57 -0
- package/src/meta/domain/format.ts +34 -0
- package/src/meta/domain/lint.ts +88 -0
- package/src/meta/domain/pickaxe.ts +99 -0
- package/src/meta/domain/quality.ts +145 -0
- package/src/meta/domain/rules.ts +21 -0
- package/src/meta/domain/scope-context.ts +63 -0
- package/src/meta/domain/service.ts +68 -0
- package/src/meta/domain/setup.ts +34 -0
- package/src/meta/domain/test.ts +72 -0
- package/src/meta/domain/transcripts.ts +88 -0
- package/src/meta/domain/typecheck.ts +41 -0
- package/src/meta/domain/workspace.ts +78 -0
- package/src/meta/errors.ts +19 -0
- package/src/meta/hooks/on-post-read.ts +61 -0
- package/src/meta/hooks/on-post-write.ts +65 -0
- package/src/meta/hooks/on-pre-bash.ts +69 -0
- package/src/meta/hooks/stubs.ts +51 -0
- package/src/meta/index.ts +36 -0
- package/src/meta/lib/actor.ts +53 -0
- package/src/meta/lib/eslint.ts +58 -0
- package/src/meta/lib/format.ts +55 -0
- package/src/meta/lib/paths.ts +36 -0
- package/src/meta/lib/present.ts +231 -0
- package/src/meta/lib/spec-links.ts +83 -0
- package/src/meta/lib/stdin.ts +56 -0
- package/src/meta/ports.ts +50 -0
- package/src/meta/types.ts +113 -0
- package/src/output/build.ts +56 -0
- package/src/output/index.ts +24 -0
- package/src/output/output.test.ts +374 -0
- package/src/output/render-cli.ts +55 -0
- package/src/output/render-json.ts +80 -0
- package/src/output/render-md.ts +43 -0
- package/src/output/render-xml.ts +55 -0
- package/src/output/render.ts +23 -0
- package/src/output/types.ts +44 -0
- package/src/pipe/format.ts +167 -0
- package/src/pipe/index.ts +4 -0
- package/src/pipe/parse.ts +131 -0
- package/src/pipe/spawn.ts +205 -0
- package/src/pipe/types.ts +27 -0
- package/src/terminal/colors.ts +95 -0
- package/src/terminal/index.ts +16 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quality gates — auto-fix then gate before commit.
|
|
3
|
+
*
|
|
4
|
+
* Prettier and ESLint run in fix mode (deterministic, always safe).
|
|
5
|
+
* Typecheck is gate-only (no auto-fix possible).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join, relative } from "node:path"
|
|
9
|
+
|
|
10
|
+
import type { ControlPorts } from "../ports.ts"
|
|
11
|
+
import type { Package, QualityCheck, QualityResult } from "../types.ts"
|
|
12
|
+
|
|
13
|
+
import { discoverPackages, filterByFiles } from "./workspace.ts"
|
|
14
|
+
|
|
15
|
+
const SKIP_PATTERNS = ["node_modules/", "dist/", ".next/", "coverage/", ".d.ts", "routeTree.gen.ts"]
|
|
16
|
+
|
|
17
|
+
const LINT_EXTS = [".ts", ".tsx"]
|
|
18
|
+
const FORMAT_EXTS = [".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".css", ".html", ".yaml", ".yml"]
|
|
19
|
+
const TYPECHECK_EXTS = [".ts", ".tsx"]
|
|
20
|
+
|
|
21
|
+
function hasExt(file: string, exts: string[]): boolean {
|
|
22
|
+
return exts.some((ext) => file.endsWith(ext))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isSkipped(file: string): boolean {
|
|
26
|
+
return SKIP_PATTERNS.some((p) => file.includes(p))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isLintable(file: string): boolean {
|
|
30
|
+
return hasExt(file, LINT_EXTS) && !isSkipped(file)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isFormattable(file: string): boolean {
|
|
34
|
+
return hasExt(file, FORMAT_EXTS) && !isSkipped(file)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isTypecheckable(file: string): boolean {
|
|
38
|
+
return hasExt(file, TYPECHECK_EXTS) && !isSkipped(file)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectOutput(ports: ControlPorts, cmd: string, args: string[], cwd: string): Promise<QualityCheck> {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const lines: string[] = []
|
|
44
|
+
const handle = ports.process.spawn({ cmd, args, cwd })
|
|
45
|
+
|
|
46
|
+
handle.onLine("stdout", (line) => lines.push(line))
|
|
47
|
+
handle.onLine("stderr", (line) => lines.push(line))
|
|
48
|
+
|
|
49
|
+
handle.wait().then((code) => {
|
|
50
|
+
resolve({
|
|
51
|
+
name: `${cmd} ${args[0] ?? ""}`.trim(),
|
|
52
|
+
status: code === 0 ? "pass" : "fail",
|
|
53
|
+
output: lines.join("\n"),
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scopedTypecheck(ports: ControlPorts, pkg: Package, committedFiles: string[]): Promise<QualityCheck> {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const lines: string[] = []
|
|
62
|
+
const handle = ports.process.spawn({ cmd: "bun", args: ["run", "typecheck"], cwd: pkg.dir })
|
|
63
|
+
|
|
64
|
+
handle.onLine("stdout", (line) => lines.push(line))
|
|
65
|
+
handle.onLine("stderr", (line) => lines.push(line))
|
|
66
|
+
|
|
67
|
+
handle.wait().then((code) => {
|
|
68
|
+
const name = `typecheck (${pkg.shortName})`
|
|
69
|
+
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
resolve({ name, status: "pass", output: "" })
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Filter to errors in committed files only
|
|
76
|
+
const relFiles = committedFiles
|
|
77
|
+
.filter((f) => f.startsWith(pkg.relDir + "/"))
|
|
78
|
+
.map((f) => f.slice(pkg.relDir.length + 1))
|
|
79
|
+
|
|
80
|
+
const relevant = lines.filter((line) => relFiles.some((f) => line.startsWith(f + "(")))
|
|
81
|
+
|
|
82
|
+
resolve({
|
|
83
|
+
name,
|
|
84
|
+
status: relevant.length > 0 ? "fail" : "pass",
|
|
85
|
+
output: relevant.join("\n"),
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function checkQuality(ports: ControlPorts, files: string[]): Promise<QualityResult> {
|
|
92
|
+
const checks: Promise<QualityCheck>[] = []
|
|
93
|
+
|
|
94
|
+
const existing = files.filter((f) => ports.fs.exists(join(ports.root, f)))
|
|
95
|
+
const lintFiles = existing.filter(isLintable)
|
|
96
|
+
const formatFiles = existing.filter(isFormattable)
|
|
97
|
+
|
|
98
|
+
const packages = discoverPackages(ports)
|
|
99
|
+
|
|
100
|
+
// ESLint on lintable files — package-aware routing
|
|
101
|
+
if (lintFiles.length > 0) {
|
|
102
|
+
const lintAffected = filterByFiles(packages, lintFiles, ports.root)
|
|
103
|
+
const pkgWithConfig = lintAffected.filter((pkg) => ports.fs.exists(join(pkg.dir, "eslint.config.js")))
|
|
104
|
+
const pkgFiles = new Set<string>()
|
|
105
|
+
|
|
106
|
+
for (const pkg of pkgWithConfig) {
|
|
107
|
+
const pkgSpecific = lintFiles.filter((f) => f.startsWith(pkg.relDir + "/"))
|
|
108
|
+
for (const f of pkgSpecific) pkgFiles.add(f)
|
|
109
|
+
if (pkgSpecific.length > 0) {
|
|
110
|
+
const relFiles = pkgSpecific.map((f) => relative(pkg.relDir, f))
|
|
111
|
+
checks.push(collectOutput(ports, "bunx", ["eslint", "--fix", "--no-warn-ignored", ...relFiles], pkg.dir))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const rootLintFiles = lintFiles.filter((f) => !pkgFiles.has(f))
|
|
116
|
+
if (rootLintFiles.length > 0) {
|
|
117
|
+
checks.push(collectOutput(ports, "bunx", ["eslint", "--fix", "--no-warn-ignored", ...rootLintFiles], ports.root))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Prettier on all formattable files
|
|
122
|
+
if (formatFiles.length > 0) {
|
|
123
|
+
checks.push(collectOutput(ports, "bunx", ["prettier", "--write", ...formatFiles], ports.root))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Typecheck per affected package — scoped to committed files only
|
|
127
|
+
const tsFiles = existing.filter(isTypecheckable)
|
|
128
|
+
if (tsFiles.length > 0) {
|
|
129
|
+
const affected = filterByFiles(packages, tsFiles, ports.root)
|
|
130
|
+
for (const pkg of affected) {
|
|
131
|
+
if ("typecheck" in pkg.scripts) {
|
|
132
|
+
checks.push(scopedTypecheck(ports, pkg, tsFiles))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (checks.length === 0) {
|
|
138
|
+
return { passed: true, checks: [] }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const results = await Promise.all(checks)
|
|
142
|
+
const passed = results.every((c) => c.status === "pass")
|
|
143
|
+
|
|
144
|
+
return { passed, checks: results }
|
|
145
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query: rules — governing CLAUDE.md cascade for given scopes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ControlPorts } from "../ports.ts"
|
|
6
|
+
import type { QueryResult, RulesScope } from "../types.ts"
|
|
7
|
+
import { resolveCascade, scopeToDir } from "./scope-context.ts"
|
|
8
|
+
|
|
9
|
+
export function rules(ports: ControlPorts, scopes: string[]): QueryResult {
|
|
10
|
+
const data: RulesScope[] = []
|
|
11
|
+
|
|
12
|
+
for (const scope of scopes) {
|
|
13
|
+
const dir = scopeToDir(scope)
|
|
14
|
+
const cascade = resolveCascade(ports, dir)
|
|
15
|
+
if (cascade.length > 0) {
|
|
16
|
+
data.push({ path: scope, cascade })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return { what: "rules", scopes, data }
|
|
21
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope utilities — resolve CLAUDE.md cascade from scope strings.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
import type { ControlPorts } from "../ports.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract the directory prefix from a scope string.
|
|
11
|
+
*
|
|
12
|
+
* - File path: strip filename → directory
|
|
13
|
+
* - Glob: extract non-glob prefix
|
|
14
|
+
* - Directory: as-is (strip trailing slash)
|
|
15
|
+
*/
|
|
16
|
+
export function scopeToDir(scope: string): string {
|
|
17
|
+
// Strip trailing slash for consistency
|
|
18
|
+
const clean = scope.replace(/\/+$/, "")
|
|
19
|
+
|
|
20
|
+
// Glob: take directory prefix before first glob character
|
|
21
|
+
if (/[*?{]/.test(clean)) {
|
|
22
|
+
const idx = clean.search(/[*?{]/)
|
|
23
|
+
const prefix = clean.slice(0, idx).replace(/\/+$/, "")
|
|
24
|
+
return prefix || "."
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// File: if last segment has a dot, treat as file → strip to parent
|
|
28
|
+
const lastSlash = clean.lastIndexOf("/")
|
|
29
|
+
const lastSegment = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean
|
|
30
|
+
if (lastSegment.includes(".")) {
|
|
31
|
+
return lastSlash >= 0 ? clean.slice(0, lastSlash) : "."
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return clean || "."
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Walk from root to target directory, collecting CLAUDE.md paths that exist.
|
|
39
|
+
* Returns @-prefixed references in cascade order: root first, most specific last.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveCascade(ports: ControlPorts, dir: string): string[] {
|
|
42
|
+
const refs: string[] = []
|
|
43
|
+
|
|
44
|
+
// Always check root CLAUDE.md
|
|
45
|
+
if (ports.fs.exists(join(ports.root, "CLAUDE.md"))) {
|
|
46
|
+
refs.push("@CLAUDE.md")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Walk each segment of the directory path
|
|
50
|
+
if (dir !== ".") {
|
|
51
|
+
const segments = dir.split("/").filter(Boolean)
|
|
52
|
+
let current = ""
|
|
53
|
+
|
|
54
|
+
for (const segment of segments) {
|
|
55
|
+
current = current ? `${current}/${segment}` : segment
|
|
56
|
+
if (ports.fs.exists(join(ports.root, current, "CLAUDE.md"))) {
|
|
57
|
+
refs.push(`@${current}/CLAUDE.md`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return refs
|
|
63
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control service factory — wires domain functions to ports.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ControlPorts } from "../ports.ts"
|
|
6
|
+
import type {
|
|
7
|
+
CommitParams,
|
|
8
|
+
CommitResult,
|
|
9
|
+
QueryResult,
|
|
10
|
+
GitResult,
|
|
11
|
+
SetupResult,
|
|
12
|
+
EvalParams,
|
|
13
|
+
EvalResult,
|
|
14
|
+
Package,
|
|
15
|
+
TranscriptsParams,
|
|
16
|
+
TranscriptsResult,
|
|
17
|
+
} from "../types.ts"
|
|
18
|
+
|
|
19
|
+
import { commit } from "./commit.ts"
|
|
20
|
+
import { rules } from "./rules.ts"
|
|
21
|
+
import { diff } from "./diff.ts"
|
|
22
|
+
import { commits } from "./commits.ts"
|
|
23
|
+
import { lint } from "./lint.ts"
|
|
24
|
+
import { typecheck } from "./typecheck.ts"
|
|
25
|
+
import { test } from "./test.ts"
|
|
26
|
+
import { blame } from "./blame.ts"
|
|
27
|
+
import { pickaxe } from "./pickaxe.ts"
|
|
28
|
+
import { bisect } from "./bisect.ts"
|
|
29
|
+
import { setup } from "./setup.ts"
|
|
30
|
+
import { evaluate } from "./eval.ts"
|
|
31
|
+
import { transcripts } from "./transcripts.ts"
|
|
32
|
+
import { discoverPackages } from "./workspace.ts"
|
|
33
|
+
|
|
34
|
+
export type ControlService = {
|
|
35
|
+
commit(params: CommitParams): Promise<CommitResult>
|
|
36
|
+
rules(scopes: string[]): QueryResult
|
|
37
|
+
diff(scopes: string[]): QueryResult
|
|
38
|
+
commits(scopes: string[]): QueryResult
|
|
39
|
+
lint(scopes: string[], opts?: { changed?: boolean }): Promise<QueryResult>
|
|
40
|
+
typecheck(scopes: string[]): Promise<QueryResult>
|
|
41
|
+
test(scopes: string[]): Promise<QueryResult>
|
|
42
|
+
blame(file: string, lines?: [number, number]): GitResult
|
|
43
|
+
pickaxe(pattern: string, opts?: { regex?: boolean; scopes?: string[] }): GitResult
|
|
44
|
+
bisect(test: string, good: string, bad?: string, timeout?: number): Promise<GitResult>
|
|
45
|
+
setup(): SetupResult
|
|
46
|
+
eval(params: EvalParams): Promise<EvalResult>
|
|
47
|
+
transcripts(params: TranscriptsParams): TranscriptsResult
|
|
48
|
+
discoverPackages(): Package[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createControlService(ports: ControlPorts): ControlService {
|
|
52
|
+
return {
|
|
53
|
+
commit: (params) => commit(ports, params),
|
|
54
|
+
rules: (scopes) => rules(ports, scopes),
|
|
55
|
+
diff: (scopes) => diff(ports, scopes),
|
|
56
|
+
commits: (scopes) => commits(ports, scopes),
|
|
57
|
+
lint: (scopes, opts) => lint(ports, scopes, opts),
|
|
58
|
+
typecheck: (scopes) => typecheck(ports, scopes),
|
|
59
|
+
test: (scopes) => test(ports, scopes),
|
|
60
|
+
blame: (file, lines) => blame(ports, file, lines),
|
|
61
|
+
pickaxe: (pattern, opts) => pickaxe(ports, pattern, opts),
|
|
62
|
+
bisect: (testCmd, good, bad, timeout) => bisect(ports, testCmd, good, bad, timeout),
|
|
63
|
+
setup: () => setup(ports),
|
|
64
|
+
eval: (params) => evaluate(ports, params),
|
|
65
|
+
transcripts: (params) => transcripts(ports, params),
|
|
66
|
+
discoverPackages: () => discoverPackages(ports),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup domain logic — configures Claude Code local settings.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
import type { ControlPorts } from "../ports.ts"
|
|
8
|
+
import type { SetupResult } from "../types.ts"
|
|
9
|
+
|
|
10
|
+
type LocalSettings = {
|
|
11
|
+
env?: Record<string, string>
|
|
12
|
+
[key: string]: unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setup(ports: ControlPorts): SetupResult {
|
|
16
|
+
const settingsPath = join(ports.root, ".claude/settings.local.json")
|
|
17
|
+
const binPath = join(ports.root, ".claude/bin")
|
|
18
|
+
|
|
19
|
+
let settings: LocalSettings = {}
|
|
20
|
+
if (ports.fs.exists(settingsPath)) {
|
|
21
|
+
try {
|
|
22
|
+
settings = JSON.parse(ports.fs.readFile(settingsPath)) as LocalSettings
|
|
23
|
+
} catch {
|
|
24
|
+
// Invalid JSON, start fresh
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
settings.env = settings.env ?? {}
|
|
29
|
+
settings.env.PATH = `${binPath}:$PATH`
|
|
30
|
+
|
|
31
|
+
ports.fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n")
|
|
32
|
+
|
|
33
|
+
return { settingsPath, binPath }
|
|
34
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query: test — run bun test with explicit test file paths.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ControlPorts } from "../ports.ts"
|
|
6
|
+
import type { QueryResult, Package } from "../types.ts"
|
|
7
|
+
import { discoverPackages, filterByFiles, filterByScript } from "./workspace.ts"
|
|
8
|
+
import { extractErrors } from "./format.ts"
|
|
9
|
+
|
|
10
|
+
export async function test(ports: ControlPorts, scopes: string[]): Promise<QueryResult> {
|
|
11
|
+
const packages = discoverPackages(ports)
|
|
12
|
+
|
|
13
|
+
const { FORCE_COLOR: _, ...parentEnv } = process.env as Record<string, string>
|
|
14
|
+
const env: Record<string, string> = { ...parentEnv, NO_COLOR: "1" }
|
|
15
|
+
|
|
16
|
+
if (scopes.length === 0) {
|
|
17
|
+
const withTest = filterByScript(packages, "test")
|
|
18
|
+
if (withTest.length === 0) {
|
|
19
|
+
return { what: "test", scopes, data: { errorCount: 0, errors: [], raw: "" } }
|
|
20
|
+
}
|
|
21
|
+
const results = await Promise.all(withTest.map((pkg) => runTestScript(ports, pkg, env)))
|
|
22
|
+
const raw = results.filter(Boolean).join("\n")
|
|
23
|
+
return { what: "test", scopes, data: extractErrors(raw) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const affected = filterByFiles(packages, scopes, ports.root)
|
|
27
|
+
|
|
28
|
+
if (affected.length === 0) {
|
|
29
|
+
return { what: "test", scopes, data: { errorCount: 0, errors: [], raw: "" } }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const results = await Promise.all(
|
|
33
|
+
affected.map((pkg) => {
|
|
34
|
+
const testFiles = scopes.filter((s) => s.startsWith(pkg.relDir + "/"))
|
|
35
|
+
return runTests(ports, pkg, testFiles, env)
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const raw = results.filter(Boolean).join("\n")
|
|
40
|
+
return { what: "test", scopes, data: extractErrors(raw) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function runTestScript(ports: ControlPorts, pkg: Package, env: Record<string, string>): Promise<string> {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const lines: string[] = []
|
|
46
|
+
const handle = ports.process.spawn({ cmd: "bun", args: ["run", "test"], cwd: pkg.dir, env })
|
|
47
|
+
handle.onLine("stdout", (line) => lines.push(line))
|
|
48
|
+
handle.onLine("stderr", (line) => lines.push(line))
|
|
49
|
+
handle.wait().then(() => resolve(lines.join("\n")))
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runTests(
|
|
54
|
+
ports: ControlPorts,
|
|
55
|
+
pkg: Package,
|
|
56
|
+
testFiles: string[],
|
|
57
|
+
env: Record<string, string>,
|
|
58
|
+
): Promise<string> {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const lines: string[] = []
|
|
61
|
+
const relFiles = testFiles.map((f) => f.slice(pkg.relDir.length + 1))
|
|
62
|
+
const handle = ports.process.spawn({
|
|
63
|
+
cmd: "bun",
|
|
64
|
+
args: ["test", ...relFiles],
|
|
65
|
+
cwd: pkg.dir,
|
|
66
|
+
env,
|
|
67
|
+
})
|
|
68
|
+
handle.onLine("stdout", (line) => lines.push(line))
|
|
69
|
+
handle.onLine("stderr", (line) => lines.push(line))
|
|
70
|
+
handle.wait().then(() => resolve(lines.join("\n")))
|
|
71
|
+
})
|
|
72
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcripts — find and list Claude Code subagent transcripts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
import type { ControlPorts } from "../ports.ts"
|
|
8
|
+
import type { TranscriptEntry, TranscriptsParams, TranscriptsResult } from "../types.ts"
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MINUTES = 120
|
|
11
|
+
const SKILL_DIR_PATTERN = /Base directory for this skill:.*\/\.claude\/skills\/([^/\n]+)/
|
|
12
|
+
|
|
13
|
+
/** Derive Claude Code project slug from absolute repo root path. */
|
|
14
|
+
function projectSlug(root: string): string {
|
|
15
|
+
return "-" + root.replace(/\//g, "-").replace(/^-/, "")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Extract skill name from first JSONL line's message content. */
|
|
19
|
+
function extractSkill(firstLine: string): string {
|
|
20
|
+
try {
|
|
21
|
+
const data = JSON.parse(firstLine)
|
|
22
|
+
const content = data?.message?.content
|
|
23
|
+
const text = typeof content === "string" ? content : Array.isArray(content) ? (content[0]?.text ?? "") : ""
|
|
24
|
+
const match = SKILL_DIR_PATTERN.exec(text)
|
|
25
|
+
return match?.[1] ?? "task"
|
|
26
|
+
} catch {
|
|
27
|
+
return "unknown"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function transcripts(ports: ControlPorts, params: TranscriptsParams): TranscriptsResult {
|
|
32
|
+
const { skill, minutes = DEFAULT_MINUTES } = params
|
|
33
|
+
const slug = projectSlug(ports.root)
|
|
34
|
+
const basePath = join(ports.homedir(), ".claude", "projects", slug)
|
|
35
|
+
|
|
36
|
+
if (!ports.fs.exists(basePath)) {
|
|
37
|
+
return { projectSlug: slug, entries: [] }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cutoff = new Date(Date.now() - minutes * 60 * 1000)
|
|
41
|
+
const entries: TranscriptEntry[] = []
|
|
42
|
+
|
|
43
|
+
const sessions = ports.fs.readDir(basePath)
|
|
44
|
+
for (const session of sessions) {
|
|
45
|
+
if (!session.isDirectory) continue
|
|
46
|
+
|
|
47
|
+
const subagentsPath = join(basePath, session.name, "subagents")
|
|
48
|
+
if (!ports.fs.exists(subagentsPath)) continue
|
|
49
|
+
|
|
50
|
+
const files = ports.fs.readDir(subagentsPath)
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
if (file.isDirectory || !file.name.endsWith(".jsonl")) continue
|
|
53
|
+
|
|
54
|
+
const filePath = join(subagentsPath, file.name)
|
|
55
|
+
const content = ports.fs.readFile(filePath)
|
|
56
|
+
const firstLine = content.slice(0, content.indexOf("\n"))
|
|
57
|
+
if (!firstLine) continue
|
|
58
|
+
|
|
59
|
+
let timestamp: string
|
|
60
|
+
let agentId: string
|
|
61
|
+
try {
|
|
62
|
+
const meta = JSON.parse(firstLine)
|
|
63
|
+
timestamp = meta.timestamp ?? ""
|
|
64
|
+
agentId = meta.agentId ?? file.name.replace("agent-", "").replace(".jsonl", "")
|
|
65
|
+
} catch {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (timestamp && new Date(timestamp) < cutoff) continue
|
|
70
|
+
|
|
71
|
+
const skillName = extractSkill(firstLine)
|
|
72
|
+
if (skill && skillName !== skill) continue
|
|
73
|
+
|
|
74
|
+
entries.push({
|
|
75
|
+
path: filePath,
|
|
76
|
+
agentId,
|
|
77
|
+
sessionId: session.name,
|
|
78
|
+
skill: skillName,
|
|
79
|
+
timestamp,
|
|
80
|
+
size: content.length,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
entries.sort((a, b) => (b.timestamp > a.timestamp ? 1 : b.timestamp < a.timestamp ? -1 : 0))
|
|
86
|
+
|
|
87
|
+
return { projectSlug: slug, entries }
|
|
88
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query: typecheck — run tsc across workspace packages matching scope.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ControlPorts } from "../ports.ts"
|
|
6
|
+
import type { QueryResult } from "../types.ts"
|
|
7
|
+
import { discoverPackages, filterByScript } from "./workspace.ts"
|
|
8
|
+
import { extractErrors } from "./format.ts"
|
|
9
|
+
|
|
10
|
+
export async function typecheck(ports: ControlPorts, scopes: string[]): Promise<QueryResult> {
|
|
11
|
+
const packages = discoverPackages(ports)
|
|
12
|
+
|
|
13
|
+
const matching =
|
|
14
|
+
scopes.length === 0
|
|
15
|
+
? packages
|
|
16
|
+
: packages.filter((pkg) => scopes.some((s) => pkg.relDir.startsWith(s) || s.startsWith(pkg.relDir + "/")))
|
|
17
|
+
const withScript = filterByScript(matching, "typecheck")
|
|
18
|
+
|
|
19
|
+
if (withScript.length === 0) {
|
|
20
|
+
return { what: "typecheck", scopes, data: { errorCount: 0, errors: [], raw: "" } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { FORCE_COLOR: _, ...parentEnv } = process.env as Record<string, string>
|
|
24
|
+
const env: Record<string, string> = { ...parentEnv, NO_COLOR: "1" }
|
|
25
|
+
|
|
26
|
+
const results = await Promise.all(
|
|
27
|
+
withScript.map(
|
|
28
|
+
(pkg) =>
|
|
29
|
+
new Promise<string>((resolve) => {
|
|
30
|
+
const lines: string[] = []
|
|
31
|
+
const handle = ports.process.spawn({ cmd: "bun", args: ["run", "typecheck"], cwd: pkg.dir, env })
|
|
32
|
+
handle.onLine("stdout", (line) => lines.push(line))
|
|
33
|
+
handle.onLine("stderr", (line) => lines.push(line))
|
|
34
|
+
handle.wait().then(() => resolve(lines.join("\n")))
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const raw = results.filter(Boolean).join("\n")
|
|
40
|
+
return { what: "typecheck", scopes, data: extractErrors(raw) }
|
|
41
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace discovery — reads package.json workspaces globs and builds Package list.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { basename, join, relative } from "node:path"
|
|
6
|
+
|
|
7
|
+
import type { ControlPorts } from "../ports.ts"
|
|
8
|
+
import type { Package } from "../types.ts"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Discover all workspace packages from root package.json.
|
|
12
|
+
* Supports `prefix/*` globs (e.g. "apps/*", "lib/*") and bare directories (e.g. "meta").
|
|
13
|
+
*/
|
|
14
|
+
export function discoverPackages(ports: ControlPorts): Package[] {
|
|
15
|
+
const rootPkg = JSON.parse(ports.fs.readFile(join(ports.root, "package.json"))) as {
|
|
16
|
+
workspaces?: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const globs = rootPkg.workspaces ?? []
|
|
20
|
+
const packages: Package[] = []
|
|
21
|
+
|
|
22
|
+
for (const glob of globs) {
|
|
23
|
+
if (glob.endsWith("/*")) {
|
|
24
|
+
// Glob: enumerate subdirectories
|
|
25
|
+
const prefix = glob.slice(0, -2)
|
|
26
|
+
const prefixDir = join(ports.root, prefix)
|
|
27
|
+
|
|
28
|
+
let entries: Array<{ name: string; isDirectory: boolean }>
|
|
29
|
+
try {
|
|
30
|
+
entries = ports.fs.readDir(prefixDir).filter((d) => d.isDirectory)
|
|
31
|
+
} catch {
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const entry of entries.map((d) => d.name).sort()) {
|
|
36
|
+
const pkg = readPackage(ports, join(prefixDir, entry))
|
|
37
|
+
if (pkg) packages.push(pkg)
|
|
38
|
+
}
|
|
39
|
+
} else if (!glob.includes("*")) {
|
|
40
|
+
// Bare directory: single package (skip unsupported globs like "packages/**")
|
|
41
|
+
const pkg = readPackage(ports, join(ports.root, glob))
|
|
42
|
+
if (pkg) packages.push(pkg)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return packages.sort((a, b) => a.relDir.localeCompare(b.relDir))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readPackage(ports: ControlPorts, dir: string): Package | null {
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(ports.fs.readFile(join(dir, "package.json"))) as {
|
|
52
|
+
name?: string
|
|
53
|
+
scripts?: Record<string, string>
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
name: pkg.name ?? basename(dir),
|
|
57
|
+
shortName: basename(dir),
|
|
58
|
+
dir,
|
|
59
|
+
relDir: relative(ports.root, dir),
|
|
60
|
+
scripts: pkg.scripts ?? {},
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function filterByScope(packages: Package[], scope: string): Package[] {
|
|
68
|
+
return packages.filter((p) => p.relDir.startsWith(scope))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function filterByScript(packages: Package[], script: string): Package[] {
|
|
72
|
+
return packages.filter((p) => script in p.scripts)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function filterByFiles(packages: Package[], files: string[], root: string): Package[] {
|
|
76
|
+
const relFiles = files.map((f) => (f.startsWith("/") ? relative(root, f) : f))
|
|
77
|
+
return packages.filter((pkg) => relFiles.some((f) => f.startsWith(pkg.relDir + "/")))
|
|
78
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain error — typed error with machine-readable code and human-readable hints.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class ControlError extends Error {
|
|
6
|
+
readonly code: string
|
|
7
|
+
readonly hints: string[]
|
|
8
|
+
|
|
9
|
+
constructor(code: string, message: string, hints: string[] = []) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = "ControlError"
|
|
12
|
+
this.code = code
|
|
13
|
+
this.hints = hints
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isControlError(err: unknown): err is ControlError {
|
|
18
|
+
return err instanceof ControlError
|
|
19
|
+
}
|