import { tool } from "@opencode-ai/plugin" import { existsSync, readFileSync } from "fs" import { join } from "path" import { loadConfig, readState, writeState, appendLog, resolveWorktree, debugLog } from "../state-io.ts" import type { TriageResult } from "../types.ts" import { EMPTY_STATE } from "../types.ts" function estimateLoc(worktree: string, files: string[]): number { let total = 0 for (const f of files) { const abs = f.startsWith("/") ? f : join(worktree, f) if (!existsSync(abs)) continue try { total += readFileSync(abs, "utf-8").split("\n").length } catch { total += 100 } } return total } function analyzeFiles(worktree: string, files: string[]) { const extensions = new Set() let importCount = 0 let functionCount = 0 let hasTests = false let hasConfig = false for (const f of files) { const ext = f.split(".").pop() ?? "" extensions.add(`.${ext}`) // File type heuristics if (/\.test\.|\.spec\.|__tests__/.test(f)) hasTests = true if (/\.(json|yaml|yml|toml|env|config\.)/.test(f)) hasConfig = true // Count imports and functions in source files const abs = f.startsWith("/") ? f : join(worktree, f) if (existsSync(abs) && /\.(ts|tsx|js|jsx|py|rs)$/.test(f)) { try { const src = readFileSync(abs, "utf-8") // Count import/require statements importCount += (src.match(/^import\s/gm) || []).length importCount += (src.match(/^const\s.*require\(/gm) || []).length // Count function declarations functionCount += (src.match(/^(export\s)?(async\s)?function\s/gm) || []).length functionCount += (src.match(/^(export\s)?(const|let)\s\w+\s*=\s*(async\s)?\(/gm) || []).length } catch {} } } return { extensions: [...extensions], importCount, functionCount, hasTests, hasConfig } } function computeComplexity( files: string[], loc: number, requestLength: number, analysis: ReturnType, config: { triage: { simple_max_files: number } }, ): { score: number; isSimple: boolean } { let score = 0 // File count scoring if (files.length <= 1) score += 0 else if (files.length <= config.triage.simple_max_files) score += 1 else if (files.length <= 5) score += 3 else score += 5 // LOC scoring if (loc < 100) score += 0 else if (loc < 500) score += 1 else if (loc < 1500) score += 3 else score += 5 // Request length (longer requests tend to describe more complex tasks) if (requestLength < 100) score += 0 else if (requestLength < 300) score += 1 else if (requestLength < 600) score += 2 else score += 3 // File type complexity if (analysis.importCount > 20) score += 2 if (analysis.functionCount > 10) score += 2 // Pure config changes are usually simple if (analysis.hasConfig && files.length <= 2) score -= 2 // Test-only changes are usually simple if (analysis.hasTests && files.every(f => /\.test\.|\.spec\./.test(f))) score -= 2 // Multi-type file changes (e.g. ts + css + config) suggest broader impact if (analysis.extensions.length > 3) score += 1 const isSimple = score <= 3 return { score, isSimple } } function detectRisk(request: string, files: string[], riskKeywords: string[]): string[] { const signals = new Set() const lower = request.toLowerCase() for (const kw of riskKeywords) { if (lower.includes(kw)) signals.add(kw) } const highRiskPathPatterns = [ { pattern: /alembic|migration|schema/i, signal: "database" }, { pattern: /models?\//i, signal: "database" }, { pattern: /auth|session|token|password|credential/i, signal: "security" }, { pattern: /docker|compose|deploy|k8s|terraform/i, signal: "infrastructure" }, { pattern: /\.env|secret|config\./i, signal: "security" }, ] for (const { pattern, signal } of highRiskPathPatterns) { for (const f of files) { if (pattern.test(f)) { signals.add(signal) break } } } return [...signals] } export default tool({ description: "Deterministic complexity triage. 0 LLM tokens. " + "Classifies request as simple/complex, low/high risk, cheap/smart tier. " + "Updates project state phase to 'triaged'.", args: { request: tool.schema.string().describe("The user request text"), files: tool.schema.array(tool.schema.string()).describe("Files mentioned or relevant"), }, async execute(args, context) { const wt = resolveWorktree(context.worktree) const config = loadConfig(wt) const existing = args.files.filter((f: string) => { const abs = f.startsWith("/") ? f : join(wt, f) return existsSync(abs) }) const loc = estimateLoc(wt, existing) const analysis = analyzeFiles(wt, existing) const { score, isSimple } = computeComplexity(existing, loc, args.request.length, analysis, config) const riskSignals = detectRisk(args.request, existing, config.triage.risk_keywords) const layers = detectLayers(existing) const isHighRisk = riskSignals.length > 0 || layers >= 3 function detectLayers(files: string[]): number { const detected = new Set() for (const f of files) { if (/model|schema|entity/i.test(f)) detected.add("data") if (/route|router|controller|handler|api/i.test(f)) detected.add("api") if (/service|usecase|logic/i.test(f)) detected.add("service") if (/middleware|interceptor|guard/i.test(f)) detected.add("middleware") if (/config|settings/i.test(f)) detected.add("config") if (/migration|alembic/i.test(f)) detected.add("migration") } return detected.size } const result: TriageResult = { complexity: isSimple ? "simple" : "complex", risk: isHighRisk ? "high" : "low", files: existing, suggested_tier: isSimple && !isHighRisk ? "cheap" : "smart", } const state = readState(wt) const isRestart = state.phase === "done" const base = isRestart ? { ...EMPTY_STATE, log: state.log } : state const updated = appendLog(wt, { ...base, phase: "triaged" as const, triage_result: result }, { task_id: null, event: isRestart ? "triaged.restart" : "triaged", detail: `${result.complexity}/${result.risk}` }, ) writeState(wt, updated) debugLog("tool", "triage_executed", { request_length: args.request.length, files_analyzed: existing.length, loc_estimate: loc, complexity_score: score, complexity: result.complexity, risk: result.risk, suggested_tier: result.suggested_tier, risk_signals: riskSignals, import_count: analysis.importCount, function_count: analysis.functionCount, is_restart: isRestart, }) return JSON.stringify(result) }, })