@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,61 @@
1
+ import { basename } from "node:path"
2
+
3
+ import { block, text, render } from "../../output/index.ts"
4
+ import { readJsonStdin, getFilePath, type HookInput } from "../lib/stdin.ts"
5
+ import { findRepoRoot } from "../lib/paths.ts"
6
+ import { isSpecFile, findBrokenLinks, formatBrokenLinks } from "../lib/spec-links.ts"
7
+
8
+ function xml(name: string, content: string): string {
9
+ return render(block(name, text(content)), "xml")
10
+ }
11
+
12
+ export async function onPostRead(): Promise<void> {
13
+ const input = await readJsonStdin<HookInput>()
14
+ const filePath = getFilePath(input)
15
+
16
+ if (!filePath) return
17
+
18
+ let root: string
19
+ try {
20
+ root = findRepoRoot()
21
+ } catch {
22
+ return
23
+ }
24
+
25
+ const sections: string[] = []
26
+
27
+ if (isSpecFile(filePath)) {
28
+ try {
29
+ const content = await Bun.file(filePath).text()
30
+ const broken = findBrokenLinks(filePath, content, root)
31
+
32
+ if (broken.length > 0) {
33
+ sections.push(xml("spec-links", formatBrokenLinks(broken)))
34
+ }
35
+ } catch {
36
+ // File might have been deleted or unreadable
37
+ }
38
+ }
39
+
40
+ const name = basename(filePath)
41
+ if (name === "CLAUDE.md") {
42
+ sections.push(xml("before-writing", "load /markdown skill · llm audience · claude-md document"))
43
+ } else if (name === "README.md") {
44
+ sections.push(xml("before-writing", "load /markdown skill · human audience · readme document"))
45
+ } else if (name === "SKILL.md") {
46
+ sections.push(xml("before-writing", "load /markdown skill · llm audience · skill document"))
47
+ } else if (name === "OOUX.md") {
48
+ sections.push(xml("before-writing", "load /ooux skill before modifying OOUX spec"))
49
+ }
50
+
51
+ if (sections.length > 0) {
52
+ console.log(
53
+ JSON.stringify({
54
+ hookSpecificOutput: {
55
+ hookEventName: "PostToolUse",
56
+ additionalContext: sections.join("\n") + "\n",
57
+ },
58
+ }),
59
+ )
60
+ }
61
+ }
@@ -0,0 +1,65 @@
1
+ import { block, text, render } from "../../output/index.ts"
2
+ import { readJsonStdin, getFilePath, type HookInput } from "../lib/stdin.ts"
3
+ import { shouldLint, runESLint } from "../lib/eslint.ts"
4
+ import { findRepoRoot, isInsideRepo } from "../lib/paths.ts"
5
+ import { isSpecFile, findBrokenLinks, formatBrokenLinks } from "../lib/spec-links.ts"
6
+
7
+ function xml(name: string, content: string): string {
8
+ return render(block(name, text(content)), "xml")
9
+ }
10
+
11
+ export async function onPostWrite(): Promise<void> {
12
+ const input = await readJsonStdin<HookInput>()
13
+ const filePath = getFilePath(input)
14
+ if (!filePath) return
15
+
16
+ let root: string
17
+ try {
18
+ root = findRepoRoot()
19
+ } catch {
20
+ return
21
+ }
22
+
23
+ if (!isInsideRepo(filePath, root)) return
24
+
25
+ const sections: string[] = []
26
+
27
+ if (shouldLint(filePath)) {
28
+ try {
29
+ const remaining = runESLint(filePath)
30
+ if (remaining) {
31
+ sections.push(xml("lint", remaining))
32
+ }
33
+ } catch {
34
+ // ESLint might not be available or fail
35
+ }
36
+ }
37
+
38
+ if (isSpecFile(filePath)) {
39
+ try {
40
+ const content = await Bun.file(filePath).text()
41
+ const broken = findBrokenLinks(filePath, content, root)
42
+
43
+ if (broken.length > 0) {
44
+ sections.push(xml("spec-links", formatBrokenLinks(broken)))
45
+ }
46
+ } catch {
47
+ // File might have been deleted or unreadable
48
+ }
49
+ }
50
+
51
+ sections.push(xml("commit", `commit in meaningful chunks · ./meta/run commit "<reason>" <files>`))
52
+
53
+ if (sections.length > 0) {
54
+ console.log(
55
+ JSON.stringify({
56
+ hookSpecificOutput: {
57
+ hookEventName: "PostToolUse",
58
+ additionalContext: sections.join("\n"),
59
+ },
60
+ }),
61
+ )
62
+ }
63
+
64
+ process.exit(0)
65
+ }
@@ -0,0 +1,69 @@
1
+ import { readJsonStdin, getCommand, type HookInput } from "../lib/stdin.ts"
2
+
3
+ /** Patterns to deny with hints */
4
+ const DENY_PATTERNS = [
5
+ { pattern: /^git\s+(add)(\s|$)/, hint: "Use: /commit skill" },
6
+ { pattern: /^git\s+commit(?!\s+--no-verify)(\s|$)/, hint: "Use: /commit skill" },
7
+ { pattern: /^git\s+push\s+.*--force/, hint: "Force push not allowed. Use revert instead." },
8
+ { pattern: /^git\s+reset\s+--hard/, hint: "Hard reset not allowed. Use revert instead." },
9
+ { pattern: /^git\s+clean\s+-f/, hint: "git clean -f not allowed. Remove files explicitly." },
10
+ { pattern: /^git\s+checkout\s+\.$/, hint: "Discard all changes not allowed. Be explicit about files." },
11
+ ]
12
+
13
+ async function isInConflictResolution(): Promise<boolean> {
14
+ try {
15
+ const result = Bun.spawnSync(["git", "rev-parse", "--git-dir"], {
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ })
19
+ if (!result.success) return false
20
+ const gitDir = result.stdout.toString().trim()
21
+
22
+ const conflictIndicators = [
23
+ `${gitDir}/rebase-merge`,
24
+ `${gitDir}/rebase-apply`,
25
+ `${gitDir}/MERGE_HEAD`,
26
+ `${gitDir}/CHERRY_PICK_HEAD`,
27
+ ]
28
+
29
+ for (const indicator of conflictIndicators) {
30
+ if (await Bun.file(indicator).exists()) {
31
+ return true
32
+ }
33
+ }
34
+
35
+ return false
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ export async function onPreBash(): Promise<void> {
42
+ const input = await readJsonStdin<HookInput>()
43
+ const command = getCommand(input)
44
+
45
+ if (!command) {
46
+ process.exit(0)
47
+ }
48
+
49
+ for (const { pattern, hint } of DENY_PATTERNS) {
50
+ if (pattern.test(command)) {
51
+ if (await isInConflictResolution()) {
52
+ process.exit(0)
53
+ }
54
+
55
+ console.log(
56
+ JSON.stringify({
57
+ hookSpecificOutput: {
58
+ hookEventName: "PreToolUse",
59
+ permissionDecision: "deny",
60
+ permissionDecisionReason: `Raw git command blocked: ${command}. ${hint}`,
61
+ },
62
+ }),
63
+ )
64
+ process.exit(0)
65
+ }
66
+ }
67
+
68
+ process.exit(0)
69
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Stub hook handlers — silent exit 0.
3
+ *
4
+ * Extension points for consumer repos to override via createCLI hooks config.
5
+ */
6
+
7
+ import { readJsonStdin, type HookInput } from "../lib/stdin.ts"
8
+
9
+ export async function onSessionStart(): Promise<void> {}
10
+
11
+ export async function onPostBash(): Promise<void> {
12
+ await readJsonStdin<HookInput>()
13
+ process.exit(0)
14
+ }
15
+
16
+ export async function onStop(): Promise<void> {}
17
+
18
+ export async function onPromptSubmit(): Promise<void> {
19
+ await readJsonStdin<HookInput>()
20
+ process.exit(0)
21
+ }
22
+
23
+ export async function onNotification(): Promise<void> {
24
+ await readJsonStdin<HookInput>()
25
+ }
26
+
27
+ export async function onPreCompact(): Promise<void> {
28
+ await readJsonStdin<HookInput>()
29
+ }
30
+
31
+ export async function onToolFailure(): Promise<void> {
32
+ await readJsonStdin<HookInput>()
33
+ }
34
+
35
+ export async function onSubagentStart(): Promise<void> {
36
+ await readJsonStdin<HookInput>()
37
+ }
38
+
39
+ export async function onSubagentStop(): Promise<void> {
40
+ await readJsonStdin<HookInput>()
41
+ process.exit(0)
42
+ }
43
+
44
+ export async function onSessionEnd(): Promise<void> {
45
+ await readJsonStdin<HookInput>()
46
+ }
47
+
48
+ export async function onPermissionRequest(): Promise<void> {
49
+ await readJsonStdin<HookInput>()
50
+ process.exit(0)
51
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Meta framework — createCLI factory, hook protocol, domain commands.
3
+ *
4
+ * Consumer repos use this to wire project-specific commands and hook extensions
5
+ * on top of the universal agentic development toolkit.
6
+ */
7
+
8
+ // Factory
9
+ export { createCLI } from "./cli.ts"
10
+ export type { CLIConfig, HookContext, HookOutput } from "./cli.ts"
11
+
12
+ // Error handling
13
+ export { ControlError, isControlError } from "./errors.ts"
14
+
15
+ // Utilities for custom commands
16
+ export { parseFormat } from "./lib/format.ts"
17
+ export type { ParsedFormat } from "./lib/format.ts"
18
+ export { findRepoRoot } from "./lib/paths.ts"
19
+ export { getActor, isLLM, isHuman } from "./lib/actor.ts"
20
+
21
+ // Types
22
+ export type { HookInput } from "./lib/stdin.ts"
23
+ export type {
24
+ CommitParams,
25
+ CommitResult,
26
+ QueryResult,
27
+ GitResult,
28
+ Package,
29
+ EvalParams,
30
+ EvalResult,
31
+ SetupResult,
32
+ TranscriptsParams,
33
+ TranscriptsResult,
34
+ } from "./types.ts"
35
+ export type { ControlPorts, GitPort, FsPort, ProcessPort, GlobPort } from "./ports.ts"
36
+ export type { ControlService } from "./domain/service.ts"
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Actor detection and mode constraints.
3
+ *
4
+ * CLAUDECODE=1 indicates LLM agent context.
5
+ * Absence indicates human context.
6
+ */
7
+
8
+ export type Actor = "llm" | "human"
9
+
10
+ /** Detect current actor from environment */
11
+ export function getActor(): Actor {
12
+ return process.env.CLAUDECODE === "1" ? "llm" : "human"
13
+ }
14
+
15
+ /** True if running in LLM agent context */
16
+ export function isLLM(): boolean {
17
+ return getActor() === "llm"
18
+ }
19
+
20
+ /** True if running in human context */
21
+ export function isHuman(): boolean {
22
+ return getActor() === "human"
23
+ }
24
+
25
+ /**
26
+ * Output mode constraints by actor.
27
+ *
28
+ * LLM: minimal output, inline only, never TUI/interactive
29
+ * Human: supports inline and interactive
30
+ */
31
+ export interface ActorOutputMode {
32
+ /** Use minimal output (no decorations, progress bars, etc.) */
33
+ minimal: boolean
34
+ /** Allow interactive prompts */
35
+ interactive: boolean
36
+ /** Allow TUI (full-screen terminal UI) */
37
+ tui: boolean
38
+ }
39
+
40
+ export function getOutputMode(): ActorOutputMode {
41
+ if (isLLM()) {
42
+ return {
43
+ minimal: true,
44
+ interactive: false,
45
+ tui: false,
46
+ }
47
+ }
48
+ return {
49
+ minimal: false,
50
+ interactive: true,
51
+ tui: true,
52
+ }
53
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ESLint integration for post-write hooks.
3
+ *
4
+ * Auto-fixes a file, returns remaining (unfixable) violations as text.
5
+ */
6
+
7
+ import { dirname, join } from "node:path"
8
+ import { existsSync } from "node:fs"
9
+
10
+ /** Walk up from filePath to find the nearest directory containing package.json */
11
+ function findPackageRoot(filePath: string): string | undefined {
12
+ let dir = dirname(filePath)
13
+ while (dir !== dirname(dir)) {
14
+ if (existsSync(join(dir, "package.json"))) return dir
15
+ dir = dirname(dir)
16
+ }
17
+ return undefined
18
+ }
19
+
20
+ /** Auto-fix a file, return remaining violations as text (empty string = clean) */
21
+ export function runESLint(filePath: string): string {
22
+ const cwd = findPackageRoot(filePath)
23
+ const result = Bun.spawnSync(["bunx", "eslint", "--fix", "--no-warn-ignored", filePath], {
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ ...(cwd && { cwd }),
27
+ })
28
+
29
+ // eslint prints remaining (unfixable) violations to stdout
30
+ return result.stdout.toString().trim()
31
+ }
32
+
33
+ /** Check if a file should be linted */
34
+ export function shouldLint(filePath: string): boolean {
35
+ if (!filePath.endsWith(".ts") && !filePath.endsWith(".tsx")) {
36
+ return false
37
+ }
38
+
39
+ const skipPatterns = [
40
+ "/node_modules/",
41
+ "/dist/",
42
+ "/.next/",
43
+ "/coverage/",
44
+ ".d.ts",
45
+ "/routeTree.gen.ts",
46
+ "/storybook-static/",
47
+ "/.storybook/",
48
+ "/.vite/",
49
+ ]
50
+
51
+ for (const pattern of skipPatterns) {
52
+ if (filePath.includes(pattern)) {
53
+ return false
54
+ }
55
+ }
56
+
57
+ return true
58
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Format flag parsing — extracts output mode from CLI args.
3
+ *
4
+ * Supports `--format cli|json|xml|md` and `--json` shorthand.
5
+ * Format flags must appear before `--` separator.
6
+ */
7
+
8
+ import type { OutputMode } from "../../output/types.ts"
9
+
10
+ const VALID_MODES = new Set<OutputMode>(["cli", "json", "xml", "md"])
11
+
12
+ export type ParsedFormat = {
13
+ mode: OutputMode
14
+ rest: string[]
15
+ }
16
+
17
+ /**
18
+ * Extract format flag from args, return mode + remaining args.
19
+ *
20
+ * Scans only args before `--` separator. Default mode: `cli`.
21
+ */
22
+ export function parseFormat(args: string[]): ParsedFormat {
23
+ let mode: OutputMode = "cli"
24
+ const rest: string[] = []
25
+ const ddIndex = args.indexOf("--")
26
+ const flagRegion = ddIndex >= 0 ? ddIndex : args.length
27
+
28
+ for (let i = 0; i < args.length; i++) {
29
+ if (i < flagRegion) {
30
+ if (args[i] === "--json") {
31
+ mode = "json"
32
+ continue
33
+ }
34
+ if (args[i] === "--xml") {
35
+ mode = "xml"
36
+ continue
37
+ }
38
+ if (args[i] === "--md") {
39
+ mode = "md"
40
+ continue
41
+ }
42
+ if (args[i] === "--format" && i + 1 < flagRegion) {
43
+ const candidate = args[i + 1] as OutputMode
44
+ if (VALID_MODES.has(candidate)) {
45
+ mode = candidate
46
+ i++ // skip value
47
+ continue
48
+ }
49
+ }
50
+ }
51
+ rest.push(args[i]!)
52
+ }
53
+
54
+ return { mode, rest }
55
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Path utilities for finding repo root.
3
+ */
4
+
5
+ import { existsSync, realpathSync } from "node:fs"
6
+ import { dirname, join } from "node:path"
7
+
8
+ /**
9
+ * Find the repo root by looking for CLAUDE.md, starting from CWD.
10
+ * Returns absolute path or throws if not found.
11
+ */
12
+ export function findRepoRoot(): string {
13
+ let dir = process.cwd()
14
+
15
+ while (dir !== dirname(dir)) {
16
+ if (existsSync(join(dir, "CLAUDE.md"))) {
17
+ return dir
18
+ }
19
+ dir = dirname(dir)
20
+ }
21
+
22
+ throw new Error("error: not in a repo (no CLAUDE.md found)")
23
+ }
24
+
25
+ /**
26
+ * Check if a file path is inside the repo root.
27
+ * Resolves symlinks — files behind symlinks pointing outside are excluded.
28
+ */
29
+ export function isInsideRepo(filePath: string, root: string): boolean {
30
+ try {
31
+ const real = realpathSync(filePath)
32
+ return real.startsWith(root + "/") || real === root
33
+ } catch {
34
+ return false
35
+ }
36
+ }