dictum-cli 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.
@@ -0,0 +1,363 @@
1
+ /**
2
+ * mcp/server.ts — expose Dictum's engine over MCP.
3
+ *
4
+ * Any MCP client (Claude Code, Cursor, Windsurf, Claude Desktop, Codex) can
5
+ * use Dictum without installing the CLI. Text-only — voice has no place in
6
+ * the MCP protocol (no microphone access).
7
+ *
8
+ * Prompts (the flagship, host-brain: the HOST model rewrites, using the
9
+ * session's project context — see prompts.ts):
10
+ * - polish(draft?) — draft → clear, structured prompt
11
+ * - spec(draft?) — draft → task spec with acceptance criteria
12
+ * - decompose(draft?) — draft → ordered, dependency-tracked subtasks
13
+ *
14
+ * Tools:
15
+ * - polish_brief(text, mode?) — the same host-brain brief as a tool result,
16
+ * for hosts without MCP Prompts (Codex CLI)
17
+ * - polish_prompt(text, mode?) — server-side polish with Dictum's own model
18
+ * - analyze_prompt(text) — deterministic 0–100 score + weak spots (offline)
19
+ * - build_spec(text) — server-side spec via the "spec" template
20
+ *
21
+ * This is an assembly-layer entry point (like cli.ts): it reuses createPolisher
22
+ * + resolveTemplate and never touches recorder/stt/sink. Started via `dictum mcp`.
23
+ */
24
+
25
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
26
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
27
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
28
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
29
+ import { ErrorCode, GetPromptRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js"
30
+ import { z } from "zod"
31
+ import pkg from "../../package.json" with { type: "json" }
32
+ import { type Config, loadConfig, templatesDir } from "../config.ts"
33
+ import { createPolisher } from "../polisher/factory.ts"
34
+ import { analyzePrompt } from "../polisher/rules.ts"
35
+ import { availableTemplateNames, resolveTemplate } from "../polisher/templates.ts"
36
+ import {
37
+ HOST_PROMPT_KINDS,
38
+ HOST_PROMPT_META,
39
+ PROVIDER_FAMILIES,
40
+ type ProviderFamily,
41
+ buildHostBrief,
42
+ buildHostPrompt,
43
+ detectProviderFamily,
44
+ isHostPromptKind,
45
+ isProviderFamily,
46
+ } from "./prompts.ts"
47
+
48
+ /** Refines `text` with the given template `mode`; resolves to the polished text. */
49
+ export type PolishFn = (text: string, mode?: string) => Promise<string>
50
+
51
+ export type McpDeps = {
52
+ /** Polishing backend (real one from config, or a stub in tests). */
53
+ polish: PolishFn
54
+ /** Template names advertised in the tool description. */
55
+ modes: string[]
56
+ }
57
+
58
+ /** Build a text CallToolResult. */
59
+ function textResult(text: string, isError = false): CallToolResult {
60
+ return { content: [{ type: "text", text }], isError }
61
+ }
62
+
63
+ // ── In-flight request tracking ──────────────────────────────────────────────
64
+ // stdin EOF must not kill responses still being computed (an async tool like
65
+ // polish_prompt awaits a subprocess). Handlers are wrapped in track(); the
66
+ // EOF path awaits waitForIdle() before letting the process exit.
67
+
68
+ let inflight = 0
69
+
70
+ /** Wrap a handler so the in-flight counter reflects it. */
71
+ export function track<A extends unknown[], R>(
72
+ fn: (...args: A) => R | Promise<R>,
73
+ ): (...args: A) => Promise<R> {
74
+ return async (...args: A): Promise<R> => {
75
+ inflight++
76
+ try {
77
+ return await fn(...args)
78
+ } finally {
79
+ inflight--
80
+ }
81
+ }
82
+ }
83
+
84
+ // The SDK hands a parsed request to its handler on a later microtask; on
85
+ // Bun 1.2 stdin 'end' lands inside that gap, when no handler has started yet
86
+ // and inflight is still 0. Idle is therefore also judged at the wire level:
87
+ // every request the transport parsed must get its response before exit.
88
+ const pendingWire = new Set<string | number>()
89
+
90
+ /** Record a client→server wire message; requests (id + method) become pending. */
91
+ export function noteWireMessage(message: unknown): void {
92
+ const m = message as { id?: unknown; method?: unknown } | null
93
+ if (m && typeof m.method === "string" && (typeof m.id === "string" || typeof m.id === "number")) {
94
+ pendingWire.add(m.id)
95
+ }
96
+ }
97
+
98
+ /** Record a server→client wire message; responses (id, no method) settle requests. */
99
+ export function noteWireReply(message: unknown): void {
100
+ const m = message as { id?: unknown; method?: unknown } | null
101
+ if (m && m.method === undefined && (typeof m.id === "string" || typeof m.id === "number")) {
102
+ pendingWire.delete(m.id)
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Wrap a connected transport so pendingWire mirrors the actual traffic.
108
+ * Must run after connect() — that is when the protocol assigns onmessage.
109
+ */
110
+ export function instrumentWireTracking(transport: Transport): void {
111
+ const dispatch = transport.onmessage
112
+ transport.onmessage = (message, extra) => {
113
+ noteWireMessage(message)
114
+ dispatch?.(message, extra)
115
+ }
116
+ const sendRaw = transport.send.bind(transport)
117
+ transport.send = (message, options) => {
118
+ noteWireReply(message)
119
+ return sendRaw(message, options)
120
+ }
121
+ }
122
+
123
+ /** Resolve once no request is in flight or awaiting dispatch (or the drain timeout elapses). */
124
+ export async function waitForIdle(timeoutMs = 15000): Promise<void> {
125
+ const deadline = Date.now() + timeoutMs
126
+ while ((inflight > 0 || pendingWire.size > 0) && Date.now() < deadline) {
127
+ await new Promise((r) => setTimeout(r, 25))
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Core polish_prompt logic, decoupled from the transport for direct testing.
133
+ * Validates input and maps provider failures to a human-readable error result
134
+ * (isError) rather than throwing across the protocol boundary.
135
+ */
136
+ export async function handlePolishPrompt(
137
+ args: { text?: unknown; mode?: unknown },
138
+ deps: McpDeps,
139
+ ): Promise<CallToolResult> {
140
+ const text = typeof args.text === "string" ? args.text.trim() : ""
141
+ if (!text) {
142
+ return textResult("Error: 'text' must be a non-empty string.", true)
143
+ }
144
+ const mode = typeof args.mode === "string" && args.mode.length > 0 ? args.mode : undefined
145
+ // Defense in depth on top of resolveTemplate's name validation: MCP callers
146
+ // may only pick from the advertised template list.
147
+ if (mode !== undefined && !deps.modes.includes(mode)) {
148
+ return textResult(`Error: unknown mode '${mode}'. Valid: ${deps.modes.join(", ")}.`, true)
149
+ }
150
+ try {
151
+ const polished = await deps.polish(text, mode)
152
+ return textResult(polished)
153
+ } catch (err) {
154
+ const reason = err instanceof Error ? err.message : String(err)
155
+ return textResult(`Polishing failed: ${reason}`, true)
156
+ }
157
+ }
158
+
159
+ /**
160
+ * analyze_prompt logic: deterministic offline scoring — no LLM call, so it
161
+ * never fails on provider problems. Returns the analysis as a JSON string
162
+ * ({score, dimensions, issues}) for the client to reason over.
163
+ */
164
+ export function handleAnalyzePrompt(args: { text?: unknown }): CallToolResult {
165
+ const text = typeof args.text === "string" ? args.text.trim() : ""
166
+ if (!text) {
167
+ return textResult("Error: 'text' must be a non-empty string.", true)
168
+ }
169
+ return textResult(JSON.stringify(analyzePrompt(text)))
170
+ }
171
+
172
+ /** build_spec logic: polish_prompt fixed to the "spec" template. */
173
+ export async function handleBuildSpec(
174
+ args: { text?: unknown },
175
+ deps: McpDeps,
176
+ ): Promise<CallToolResult> {
177
+ return handlePolishPrompt({ text: args.text, mode: "spec" }, deps)
178
+ }
179
+
180
+ /**
181
+ * polish_brief logic: return the host-brain brief as a tool result, so hosts
182
+ * without MCP Prompts (Codex CLI) still get the flagship flow — the HOST model
183
+ * executes the brief with its own session context. Offline, no LLM call.
184
+ */
185
+ export function handlePolishBrief(
186
+ args: { text?: unknown; mode?: unknown; provider?: unknown },
187
+ detected: ProviderFamily = "generic",
188
+ ): CallToolResult {
189
+ const text = typeof args.text === "string" ? args.text.trim() : ""
190
+ if (!text) {
191
+ return textResult("Error: 'text' must be a non-empty string.", true)
192
+ }
193
+ const mode = typeof args.mode === "string" && args.mode.length > 0 ? args.mode : "polish"
194
+ if (!isHostPromptKind(mode)) {
195
+ return textResult(
196
+ `Error: unknown brief mode '${mode}'. Valid: ${HOST_PROMPT_KINDS.join(", ")}.`,
197
+ true,
198
+ )
199
+ }
200
+ const provider =
201
+ typeof args.provider === "string" && args.provider.length > 0 ? args.provider : detected
202
+ if (!isProviderFamily(provider)) {
203
+ return textResult(
204
+ `Error: unknown provider '${provider}'. Valid: ${PROVIDER_FAMILIES.join(", ")}.`,
205
+ true,
206
+ )
207
+ }
208
+ return textResult(buildHostBrief(mode, text, provider))
209
+ }
210
+
211
+ /**
212
+ * Build a PolishFn from configuration. Constructs the polisher once and resolves
213
+ * the template per call (so different `mode` values work). Reuses the exact same
214
+ * createPolisher + resolveTemplate the CLI uses — no duplicated logic.
215
+ */
216
+ export function polishFromConfig(config: Config): PolishFn {
217
+ const polisher = createPolisher(config.polisher)
218
+ return async (text, mode) => {
219
+ const template = await resolveTemplate(mode ?? config.polisher.template, templatesDir())
220
+ return polisher.polish(text, template)
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Construct the MCP server: host-brain prompts (polish / spec / decompose,
226
+ * the flagship surface) plus the polish_brief / polish_prompt / analyze_prompt
227
+ * / build_spec tools.
228
+ */
229
+ export function createMcpServer(deps: McpDeps): McpServer {
230
+ const server = new McpServer({ name: "dictum", version: pkg.version })
231
+ const modeList = deps.modes.join(", ")
232
+ const modeHelp = `Polishing template (one of: ${modeList}). Defaults to the configured template.`
233
+
234
+ // Model family of the connected host (known after `initialize`), so briefs
235
+ // can carry that vendor's own prompting advice — the polished prompt will
236
+ // be executed by the very model the client runs on.
237
+ const clientFamily = () => detectProviderFamily(server.server.getClientVersion()?.name)
238
+
239
+ // Host-brain prompts: the host model executes the brief with its own
240
+ // context. Surfaced as e.g. /mcp__dictum__polish in Claude Code.
241
+ for (const kind of HOST_PROMPT_KINDS) {
242
+ server.registerPrompt(
243
+ kind,
244
+ {
245
+ title: HOST_PROMPT_META[kind].title,
246
+ description: HOST_PROMPT_META[kind].description,
247
+ argsSchema: {
248
+ draft: z
249
+ .string()
250
+ .optional()
251
+ .describe("The rough draft to refine. If omitted, the host asks the user for it."),
252
+ },
253
+ },
254
+ ({ draft }) => buildHostPrompt(kind, draft ?? "", clientFamily()),
255
+ )
256
+ }
257
+
258
+ // The MCP spec makes `params.arguments` optional, but the SDK's generated
259
+ // prompts/get handler rejects a request that omits it (-32602) — which
260
+ // would kill our "no draft → ask the user" flow on spec-compliant clients.
261
+ // Override just the get handler; registerPrompt above still owns
262
+ // prompts/list (names, titles, argument advertising).
263
+ server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
264
+ const { name } = request.params
265
+ if (!isHostPromptKind(name)) {
266
+ throw new McpError(ErrorCode.InvalidParams, `Prompt ${name} not found`)
267
+ }
268
+ const draft = request.params.arguments?.draft
269
+ return buildHostPrompt(name, typeof draft === "string" ? draft : "", clientFamily())
270
+ })
271
+
272
+ server.registerTool(
273
+ "polish_brief",
274
+ {
275
+ title: "Get a polishing brief (host-executed)",
276
+ description:
277
+ "Return Dictum's rewriting brief for YOU to execute: canon rules plus a deterministic pre-analysis of the user's rough draft. Follow the brief yourself, using your session's project context — do not expect polished text back from this tool. Prefer this over polish_prompt when the user wants their draft refined and you have the context. Offline, no LLM call. Modes: polish (default), spec, decompose.",
278
+ inputSchema: {
279
+ text: z.string().describe("The rough draft to build the brief for."),
280
+ mode: z.string().optional().describe("Brief kind: polish (default), spec, or decompose."),
281
+ provider: z
282
+ .string()
283
+ .optional()
284
+ .describe(
285
+ "Model family for vendor prompting tips: anthropic, openai, or generic. Auto-detected from the connected client when omitted.",
286
+ ),
287
+ },
288
+ },
289
+ track((args) => handlePolishBrief(args, clientFamily())),
290
+ )
291
+
292
+ server.registerTool(
293
+ "polish_prompt",
294
+ {
295
+ title: "Polish a prompt",
296
+ description: `Refine a rough thought or draft into a clear, well-structured prompt using Dictum's OWN model, server-side (it does not see this session's context — prefer polish_brief when you have context). Text in, polished text out (no voice). Available modes: ${modeList}.`,
297
+ inputSchema: {
298
+ text: z.string().describe("The rough thought or draft prompt to refine."),
299
+ mode: z.string().optional().describe(modeHelp),
300
+ },
301
+ },
302
+ track((args) => handlePolishPrompt(args, deps)),
303
+ )
304
+
305
+ server.registerTool(
306
+ "analyze_prompt",
307
+ {
308
+ title: "Analyze a prompt",
309
+ description:
310
+ "Score a prompt draft 0–100 across five dimensions (clarity, specificity, structure, actionability, context) and list its weak spots. Deterministic and offline — no LLM call. Returns JSON: {score, dimensions, issues}. Use it to decide whether a draft needs polishing.",
311
+ inputSchema: {
312
+ text: z.string().describe("The prompt draft to analyze."),
313
+ },
314
+ },
315
+ track((args) => handleAnalyzePrompt(args)),
316
+ )
317
+
318
+ server.registerTool(
319
+ "build_spec",
320
+ {
321
+ title: "Build a task spec",
322
+ description:
323
+ "Expand a rough thought into a compact task spec with requirements and acceptance criteria (Dictum's 'spec' template). Text in, Markdown spec out.",
324
+ inputSchema: {
325
+ text: z.string().describe("The rough task description to expand into a spec."),
326
+ },
327
+ },
328
+ track((args) => handleBuildSpec(args, deps)),
329
+ )
330
+
331
+ return server
332
+ }
333
+
334
+ /**
335
+ * Load config, wire the real polisher, and serve over stdio. Stays alive until
336
+ * the client disconnects (stdin EOF) or the process is signalled. stdout is the
337
+ * protocol channel, so all logging goes to stderr.
338
+ */
339
+ export async function startMcpServer(): Promise<void> {
340
+ const config = await loadConfig()
341
+ const modes = await availableTemplateNames(templatesDir())
342
+ const server = createMcpServer({ polish: polishFromConfig(config), modes })
343
+ const transport = new StdioServerTransport()
344
+ await server.connect(transport)
345
+ instrumentWireTracking(transport)
346
+ process.stderr.write("dictum mcp: host-brain prompts + tools ready on stdio\n")
347
+
348
+ await new Promise<void>((resolve) => {
349
+ const done = () => resolve()
350
+ transport.onclose = done
351
+ // The SDK transport does not surface stdin EOF as onclose — watch the
352
+ // stream directly so `printf … | dictum mcp` exits when the pipe closes.
353
+ // Drain in-flight requests first: killing a response mid-computation
354
+ // would return exit 0 with no output.
355
+ const drainThenDone = () => {
356
+ waitForIdle().then(done)
357
+ }
358
+ process.stdin.once("end", drainThenDone)
359
+ process.stdin.once("close", drainThenDone)
360
+ process.once("SIGINT", done)
361
+ process.once("SIGTERM", done)
362
+ })
363
+ }
@@ -0,0 +1,7 @@
1
+ // Ambient declarations for non-code imports embedded by Bun.
2
+
3
+ // Markdown templates imported as text: `import x from "./t.md" with { type: "text" }`
4
+ declare module "*.md" {
5
+ const content: string
6
+ export default content
7
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * polisher/anthropic.ts — Polisher backed by the Claude Messages API.
3
+ *
4
+ * Raw HTTP (fetch) to keep dependencies minimal, mirroring stt/openai_compat.ts.
5
+ * POST {base}/v1/messages
6
+ * headers: x-api-key, anthropic-version: 2023-06-01, content-type
7
+ * body: { model, max_tokens, system, messages: [{role:"user", content}] }
8
+ * response: { content: [{type:"text", text}], stop_reason, ... }
9
+ */
10
+
11
+ import type { Polisher, Template } from "../core/types.ts"
12
+
13
+ const ANTHROPIC_VERSION = "2023-06-01"
14
+
15
+ export type AnthropicOptions = {
16
+ apiKey: string
17
+ model: string
18
+ baseUrl: string
19
+ /** Max output tokens; polished prompts are short. */
20
+ maxTokens?: number
21
+ /** Request timeout in milliseconds. */
22
+ timeoutMs?: number
23
+ }
24
+
25
+ type AnthropicResponse = {
26
+ content?: Array<{ type?: string; text?: string }>
27
+ stop_reason?: string
28
+ error?: { message?: string }
29
+ }
30
+
31
+ export class AnthropicPolisher implements Polisher {
32
+ private readonly apiKey: string
33
+ private readonly model: string
34
+ private readonly baseUrl: string
35
+ private readonly maxTokens: number
36
+ private readonly timeoutMs: number
37
+
38
+ constructor(opts: AnthropicOptions) {
39
+ this.apiKey = opts.apiKey
40
+ this.model = opts.model
41
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "")
42
+ this.maxTokens = opts.maxTokens ?? 2048
43
+ this.timeoutMs = opts.timeoutMs ?? 60000
44
+ }
45
+
46
+ async polish(text: string, template: Template): Promise<string> {
47
+ if (!this.apiKey) {
48
+ throw new Error("Anthropic polisher needs an API key — set ANTHROPIC_API_KEY")
49
+ }
50
+
51
+ let res: Response
52
+ try {
53
+ res = await fetch(`${this.baseUrl}/v1/messages`, {
54
+ method: "POST",
55
+ headers: {
56
+ "content-type": "application/json",
57
+ "x-api-key": this.apiKey,
58
+ "anthropic-version": ANTHROPIC_VERSION,
59
+ },
60
+ body: JSON.stringify({
61
+ model: this.model,
62
+ max_tokens: this.maxTokens,
63
+ system: template.instruction,
64
+ messages: [{ role: "user", content: text }],
65
+ }),
66
+ signal: AbortSignal.timeout(this.timeoutMs),
67
+ })
68
+ } catch (err) {
69
+ const reason = err instanceof Error ? err.message : String(err)
70
+ throw new Error(`Anthropic request failed: ${reason}`)
71
+ }
72
+
73
+ const body = (await res.json().catch(() => ({}))) as AnthropicResponse
74
+ if (!res.ok) {
75
+ const detail = body.error?.message ?? (await res.text().catch(() => "")).trim()
76
+ throw new Error(`Anthropic API returned ${res.status}${detail ? `: ${detail}` : ""}`)
77
+ }
78
+ if (body.stop_reason === "refusal") {
79
+ throw new Error("Anthropic declined to polish this transcript (safety refusal)")
80
+ }
81
+
82
+ const out = (body.content ?? [])
83
+ .filter((b) => b.type === "text" && typeof b.text === "string")
84
+ .map((b) => b.text)
85
+ .join("")
86
+ .trim()
87
+ if (!out) throw new Error("Anthropic returned an empty response")
88
+ return out
89
+ }
90
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * polisher/claude_cli.ts — Polisher that shells out to the `claude` CLI.
3
+ *
4
+ * Runs `claude -p --output-format text` with the instruction + transcript
5
+ * piped via stdin (never argv: transcripts must not leak into the process
6
+ * list — /proc/<pid>/cmdline is world-readable on the same host). Uses the
7
+ * user's existing Claude Code auth (subscription), so no API key is needed.
8
+ * The instruction comes from the selected Template.
9
+ *
10
+ * Least privilege: the polisher is a pure text-in/text-out call, so the CLI
11
+ * runs with --safe-mode (no CLAUDE.md, hooks, plugins, MCP servers, skills),
12
+ * --no-session-persistence (no transcript saved to disk) and all standard
13
+ * tools denied. Project-aware polishing is the host-brain MCP path's job,
14
+ * not this subprocess's.
15
+ */
16
+
17
+ import type { Polisher, Template } from "../core/types.ts"
18
+
19
+ /**
20
+ * Oldest Claude Code that understands every safety flag below (--safe-mode
21
+ * shipped in 2.1.169). doctor checks the installed CLI against this.
22
+ */
23
+ export const CLAUDE_CLI_MIN_VERSION = "2.1.169"
24
+
25
+ /** Compare dotted numeric versions; true when `a >= b`. Pure, for testing. */
26
+ export function versionAtLeast(a: string, b: string): boolean {
27
+ const pa = a.split(".").map(Number)
28
+ const pb = b.split(".").map(Number)
29
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
30
+ const x = pa[i] ?? 0
31
+ const y = pb[i] ?? 0
32
+ if (x !== y) return x > y
33
+ }
34
+ return true
35
+ }
36
+
37
+ /**
38
+ * Hardening flags every polisher invocation carries (pinned by tests).
39
+ * `--tools ""` disables ALL tools (future-proof); the denylist stays as a
40
+ * second belt for CLI versions where the empty allowlist is advisory.
41
+ */
42
+ export const CLAUDE_CLI_SAFETY_ARGS = [
43
+ "--safe-mode",
44
+ "--no-session-persistence",
45
+ "--tools",
46
+ "",
47
+ "--disallowedTools",
48
+ "Bash,Edit,Write,Read,Glob,Grep,WebFetch,WebSearch,Task,NotebookEdit",
49
+ ] as const
50
+
51
+ export type ClaudeCliOptions = {
52
+ /** Binary name/path; defaults to "claude". */
53
+ bin?: string
54
+ /** Optional model override (empty = CLI default). */
55
+ model?: string
56
+ /** Timeout in milliseconds. */
57
+ timeoutMs?: number
58
+ }
59
+
60
+ export class ClaudeCliPolisher implements Polisher {
61
+ private readonly bin: string
62
+ private readonly model: string
63
+ private readonly timeoutMs: number
64
+
65
+ constructor(opts: ClaudeCliOptions = {}) {
66
+ this.bin = opts.bin ?? "claude"
67
+ this.model = opts.model ?? ""
68
+ this.timeoutMs = opts.timeoutMs ?? 60000
69
+ }
70
+
71
+ async polish(text: string, template: Template): Promise<string> {
72
+ const prompt = `${template.instruction}\n${text}`
73
+ const args = ["-p", "--output-format", "text", ...CLAUDE_CLI_SAFETY_ARGS]
74
+ if (this.model) args.push("--model", this.model)
75
+
76
+ const spawn = () => {
77
+ try {
78
+ return Bun.spawn([this.bin, ...args], {
79
+ stdin: new TextEncoder().encode(prompt),
80
+ stdout: "pipe",
81
+ stderr: "pipe",
82
+ })
83
+ } catch (err) {
84
+ const reason = err instanceof Error ? err.message : String(err)
85
+ throw new Error(
86
+ `Cannot launch '${this.bin}': ${reason}. Is the Claude CLI installed and on PATH?`,
87
+ )
88
+ }
89
+ }
90
+ const proc = spawn()
91
+
92
+ // Manual timeout: SIGTERM at the deadline, SIGKILL after a grace, so a
93
+ // child that holds the stdout/stderr pipes open can't make us hang. We
94
+ // never block on pipe EOF past the process exit (bounded reads below).
95
+ let timedOut = false
96
+ const sigtermTimer = setTimeout(() => {
97
+ timedOut = true
98
+ proc.kill("SIGTERM")
99
+ }, this.timeoutMs)
100
+ const sigkillTimer = setTimeout(() => proc.kill("SIGKILL"), this.timeoutMs + 2000)
101
+
102
+ const stdoutText = new Response(proc.stdout).text().catch(() => "")
103
+ const stderrText = new Response(proc.stderr).text().catch(() => "")
104
+
105
+ const code = await proc.exited
106
+ clearTimeout(sigtermTimer)
107
+ clearTimeout(sigkillTimer)
108
+
109
+ // Don't let a lingering grandchild's open pipe hang us: cap the reads.
110
+ const bounded = (p: Promise<string>): Promise<string> =>
111
+ Promise.race([p, new Promise<string>((resolve) => setTimeout(() => resolve(""), 1000))])
112
+ const stdout = await bounded(stdoutText)
113
+ const stderr = await bounded(stderrText)
114
+
115
+ if (timedOut) {
116
+ throw new Error(`claude polishing timed out after ${this.timeoutMs / 1000}s`)
117
+ }
118
+ if (code !== 0) {
119
+ throw new Error(
120
+ `claude exited with code ${code}: ${stderr.trim() || stdout.trim() || "no output"}`,
121
+ )
122
+ }
123
+
124
+ const result = stdout.trim()
125
+ if (!result) throw new Error("claude returned an empty response")
126
+ return result
127
+ }
128
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * polisher/factory.ts — build a Polisher from configuration.
3
+ *
4
+ * Validates the provider name (human-readable error on a bad config/env value)
5
+ * and constructs the matching backend. Imports only core/types.ts and sibling
6
+ * polisher files (own module).
7
+ */
8
+
9
+ import type { Polisher } from "../core/types.ts"
10
+ import { AnthropicPolisher } from "./anthropic.ts"
11
+ import { ClaudeCliPolisher } from "./claude_cli.ts"
12
+ import { LayeredPolisher, RulesPolisher } from "./layered.ts"
13
+ import { OpenAiCompatPolisher } from "./openai_compat.ts"
14
+
15
+ export const VALID_POLISHERS = ["claude_cli", "anthropic", "openai_compat"] as const
16
+ export type PolisherName = (typeof VALID_POLISHERS)[number]
17
+
18
+ export const VALID_POLISHER_MODES = ["llm", "rules", "layered"] as const
19
+ export type PolisherMode = (typeof VALID_POLISHER_MODES)[number]
20
+
21
+ /** Layered mode: skip the LLM when the draft scores ≥ this (0–100). */
22
+ const DEFAULT_SCORE_THRESHOLD = 80
23
+
24
+ /** Structural config the factory needs (mirrors Config["polisher"], no import). */
25
+ export type PolisherFactoryConfig = {
26
+ provider: string
27
+ /** Polishing mode; optional for back-compat, defaults to "llm". */
28
+ mode?: string
29
+ /** Layered-mode gate (0–100); defaults to 80. */
30
+ scoreThreshold?: number
31
+ claude_cli: { bin: string; model: string; timeout: number }
32
+ anthropic: { apiKey: string; model: string; baseUrl: string }
33
+ openai_compat: { apiKey: string; model: string; baseUrl: string }
34
+ }
35
+
36
+ function isValidName(name: string): name is PolisherName {
37
+ return (VALID_POLISHERS as readonly string[]).includes(name)
38
+ }
39
+
40
+ function isValidMode(mode: string): mode is PolisherMode {
41
+ return (VALID_POLISHER_MODES as readonly string[]).includes(mode)
42
+ }
43
+
44
+ /**
45
+ * Construct the configured Polisher; throws on an unknown provider or mode.
46
+ * Mode "rules" is fully offline and needs no provider at all; "layered" wraps
47
+ * the configured provider behind the rule-based score gate.
48
+ */
49
+ export function createPolisher(cfg: PolisherFactoryConfig): Polisher {
50
+ const mode = cfg.mode ?? "llm"
51
+ if (!isValidMode(mode)) {
52
+ throw new Error(`Unknown polisher mode '${mode}'. Valid: ${VALID_POLISHER_MODES.join(", ")}`)
53
+ }
54
+ if (mode === "rules") return new RulesPolisher()
55
+
56
+ if (!isValidName(cfg.provider)) {
57
+ throw new Error(`Unknown polisher '${cfg.provider}'. Valid: ${VALID_POLISHERS.join(", ")}`)
58
+ }
59
+ const base = createProvider(cfg, cfg.provider)
60
+ if (mode === "layered") {
61
+ return new LayeredPolisher(base, {
62
+ scoreThreshold: cfg.scoreThreshold ?? DEFAULT_SCORE_THRESHOLD,
63
+ })
64
+ }
65
+ return base
66
+ }
67
+
68
+ function createProvider(cfg: PolisherFactoryConfig, provider: PolisherName): Polisher {
69
+ switch (provider) {
70
+ case "claude_cli":
71
+ return new ClaudeCliPolisher({
72
+ bin: cfg.claude_cli.bin,
73
+ model: cfg.claude_cli.model,
74
+ timeoutMs: cfg.claude_cli.timeout * 1000,
75
+ })
76
+ case "anthropic":
77
+ return new AnthropicPolisher({
78
+ apiKey: cfg.anthropic.apiKey,
79
+ model: cfg.anthropic.model,
80
+ baseUrl: cfg.anthropic.baseUrl,
81
+ })
82
+ case "openai_compat":
83
+ return new OpenAiCompatPolisher({
84
+ apiKey: cfg.openai_compat.apiKey,
85
+ model: cfg.openai_compat.model,
86
+ baseUrl: cfg.openai_compat.baseUrl,
87
+ })
88
+ }
89
+ }