diff --git a/README.md b/README.md index f60fefa..09d5aca 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,8 @@ This prevents the common failure mode: changing a shared type in one service and ### `.preflight/config.yml` +> **💡 Tip:** Copy [`examples/.preflight/`](examples/.preflight/) into your project root for ready-to-use config files with comments explaining every option. + Drop this in your project root. Every field is optional — defaults are sensible. ```yaml diff --git a/examples/.preflight/README.md b/examples/.preflight/README.md new file mode 100644 index 0000000..bfa5c43 --- /dev/null +++ b/examples/.preflight/README.md @@ -0,0 +1,25 @@ +# Example `.preflight/` Configuration + +Copy this directory into your project root: + +```bash +cp -r examples/.preflight /path/to/your/project/ +``` + +Then edit the files to match your setup: + +- **`config.yml`** — Profile, related projects, thresholds, embedding provider +- **`triage.yml`** — Which keywords trigger which triage levels +- **`contracts/api.yml`** — Manual API contract definitions (supplements auto-extraction) + +All files are optional. Preflight works with sensible defaults out of the box. + +## Quick Customization + +**Want stricter triage?** Set `strictness: strict` in `triage.yml`. + +**Have microservices?** Uncomment `related_projects` in `config.yml` and add your service paths. + +**Using OpenAI embeddings?** Set `embeddings.provider: openai` and add your key in `config.yml`. + +**Custom contracts?** Add more YAML files to `contracts/` — they'll be merged automatically. diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml new file mode 100644 index 0000000..65303d6 --- /dev/null +++ b/examples/.preflight/config.yml @@ -0,0 +1,27 @@ +# .preflight/config.yml — Drop this in your project root +# All fields are optional. Defaults are sensible. + +# Profile controls overall verbosity: +# "minimal" — only flag ambiguous+, skip clarification detail +# "standard" — default behavior +# "full" — maximum detail on every non-trivial prompt +profile: standard + +# Related projects for cross-service awareness. +# Preflight will search these when your prompt touches shared boundaries. +# related_projects: +# - path: /Users/you/code/auth-service +# alias: auth +# - path: /Users/you/code/shared-types +# alias: shared-types + +# Behavioral thresholds +thresholds: + session_stale_minutes: 30 # warn if no activity for this long + max_tool_calls_before_checkpoint: 100 # suggest checkpoint after N tool calls + correction_pattern_threshold: 3 # min corrections before forming a pattern + +# Embedding configuration +embeddings: + provider: local # "local" (Xenova, zero config) or "openai" + # openai_api_key: sk-... # only needed if provider is "openai" diff --git a/examples/.preflight/contracts/api.yml b/examples/.preflight/contracts/api.yml new file mode 100644 index 0000000..f73bff9 --- /dev/null +++ b/examples/.preflight/contracts/api.yml @@ -0,0 +1,34 @@ +# .preflight/contracts/api.yml — Manual contract definitions +# These supplement auto-extracted contracts from your source code. +# Useful for documenting APIs that aren't easily inferred from code. + +- name: User + kind: interface + description: Core user record + fields: + - name: id + type: string + required: true + - name: email + type: string + required: true + - name: tier + type: "'free' | 'pro' | 'enterprise'" + required: true + - name: createdAt + type: Date + required: true + +- name: CreateUserRequest + kind: interface + description: POST /api/users request body + fields: + - name: email + type: string + required: true + - name: name + type: string + required: true + - name: tier + type: "'free' | 'pro' | 'enterprise'" + required: false diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml new file mode 100644 index 0000000..8c50d6a --- /dev/null +++ b/examples/.preflight/triage.yml @@ -0,0 +1,32 @@ +# .preflight/triage.yml — Controls the triage classification engine +# Customize which prompts get flagged, skipped, or escalated. + +rules: + # Prompts containing these are always at least AMBIGUOUS + always_check: + - rewards + - permissions + - migration + - schema + - billing + + # Prompts containing these pass through as TRIVIAL + skip: + - commit + - format + - lint + - prettier + + # Prompts containing these escalate to CROSS-SERVICE + cross_service_keywords: + - auth + - notification + - event + - webhook + - shared + +# How aggressively to classify: +# "relaxed" — more prompts pass as clear +# "standard" — balanced +# "strict" — more prompts flagged as ambiguous +strictness: standard diff --git a/src/lib/shell.ts b/src/lib/shell.ts new file mode 100644 index 0000000..72803a4 --- /dev/null +++ b/src/lib/shell.ts @@ -0,0 +1,62 @@ +import { execSync, execFileSync } from "child_process"; +import { PROJECT_DIR } from "./files.js"; + +/** + * Run an arbitrary shell command (with shell: true). + * Use this for non-git commands that need pipes, redirects, or shell builtins. + * Returns stdout on success, descriptive error string on failure. + */ +export function shell(cmd: string, opts: { timeout?: number; cwd?: string } = {}): string { + try { + return execSync(cmd, { + cwd: opts.cwd || PROJECT_DIR, + encoding: "utf-8", + timeout: opts.timeout || 10000, + maxBuffer: 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch (e: any) { + if (e.killed === true || e.signal === "SIGTERM") { + return `[timed out after ${opts.timeout || 10000}ms]`; + } + const output = e.stdout?.trim() || e.stderr?.trim(); + if (output) return output; + return `[command failed: ${cmd} (exit ${e.status ?? "?"})]`; + } +} + +/** + * Run a non-git executable safely (no shell). Pass command and args separately. + * Returns stdout on success, descriptive error string on failure. + */ +export function exec(cmd: string, args: string[], opts: { timeout?: number; cwd?: string } = {}): string { + try { + return execFileSync(cmd, args, { + cwd: opts.cwd || PROJECT_DIR, + encoding: "utf-8", + timeout: opts.timeout || 10000, + maxBuffer: 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch (e: any) { + if (e.killed === true || e.signal === "SIGTERM") { + return `[timed out after ${opts.timeout || 10000}ms]`; + } + const output = e.stdout?.trim() || e.stderr?.trim(); + if (output) return output; + if (e.code === "ENOENT") return `[${cmd} not found]`; + return `[command failed: ${cmd} ${args.join(" ")} (exit ${e.status ?? "?"})]`; + } +} + +/** + * Check if a CLI tool is available on PATH. + */ +export function hasCommand(cmd: string): boolean { + try { + execFileSync("which", [cmd], { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 }); + return true; + } catch { + return false; + } +} diff --git a/src/tools/checkpoint.ts b/src/tools/checkpoint.ts index e086f01..f9aa492 100644 --- a/src/tools/checkpoint.ts +++ b/src/tools/checkpoint.ts @@ -70,25 +70,24 @@ ${dirty || "clean"} if (!staged) { commitResult = "nothing staged — skipped commit (use 'tracked' or 'all' mode, or stage files first)"; } - addCmd = "true"; // noop, already staged break; } case "all": - addCmd = "git add -A"; + run(["add", "-A"]); break; case "tracked": default: - addCmd = "git add -u"; + run(["add", "-u"]); break; } if (commitResult === "no uncommitted changes") { // Stage the checkpoint file too - run(`git add "${checkpointFile}"`); - const result = run(`${addCmd} && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>&1`); + run(["add", checkpointFile]); + const result = run(["commit", "-m", commitMsg]); if (result.includes("commit failed") || result.includes("nothing to commit")) { // Rollback: unstage if commit failed - run("git reset HEAD 2>/dev/null"); + run(["reset", "HEAD"]); commitResult = `commit failed: ${result}`; } else { commitResult = result; diff --git a/src/tools/enrich-agent-task.ts b/src/tools/enrich-agent-task.ts index 236edfa..f4d5a75 100644 --- a/src/tools/enrich-agent-task.ts +++ b/src/tools/enrich-agent-task.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run, getDiffFiles } from "../lib/git.js"; +import { shell } from "../lib/shell.js"; import { PROJECT_DIR } from "../lib/files.js"; import { getConfig, type RelatedProject } from "../lib/config.js"; import { existsSync, readFileSync } from "fs"; @@ -29,11 +30,11 @@ function findAreaFiles(area: string): string { // If area looks like a path, search directly if (area.includes("/")) { - return run(`git ls-files -- '${safeArea}*' 2>/dev/null | head -20`); + return shell(`git ls-files -- '${safeArea}'* 2>/dev/null | head -20`); } // Search for area keyword in git-tracked file paths - const files = run(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`); + const files = shell(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`); if (files && !files.startsWith("[command failed")) return files; // Fallback to recently changed files @@ -42,18 +43,18 @@ function findAreaFiles(area: string): string { /** Find related test files for an area */ function findRelatedTests(area: string): string { - if (!area) return run("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10"); + if (!area) return shell("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10"); const safeArea = shellEscape(area.split(/\s+/)[0]); - const tests = run(`git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | grep -i '${safeArea}' | head -10`); - return tests || run("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10"); + const tests = shell(`git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | grep -i '${safeArea}' | head -10`); + return tests || shell("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10"); } /** Get an example pattern from the first matching file */ function getExamplePattern(files: string): string { const firstFile = files.split("\n").filter(Boolean)[0]; if (!firstFile) return "no pattern available"; - return run(`head -30 '${shellEscape(firstFile)}' 2>/dev/null || echo 'could not read file'`); + return shell(`head -30 '${shellEscape(firstFile)}' 2>/dev/null || echo 'could not read file'`); } // --------------------------------------------------------------------------- diff --git a/src/tools/scope-work.ts b/src/tools/scope-work.ts index 9b5d971..d98f593 100644 --- a/src/tools/scope-work.ts +++ b/src/tools/scope-work.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js"; +import { shell } from "../lib/shell.js"; import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js"; import { searchSemantic } from "../lib/timeline-db.js"; import { getRelatedProjects } from "../lib/config.js"; @@ -93,9 +94,9 @@ export function registerScopeWork(server: McpServer): void { const timestamp = now(); const currentBranch = branch ?? getBranch(); const recentCommits = getRecentCommits(10); - const porcelain = run("git status --porcelain"); + const porcelain = run(["status", "--porcelain"]); const dirtyFiles = parsePortelainFiles(porcelain); - const diffStat = dirtyFiles.length > 0 ? run("git diff --stat") : "(clean working tree)"; + const diffStat = dirtyFiles.length > 0 ? run(["diff", "--stat"]) : "(clean working tree)"; // Scan for relevant files based on task keywords const keywords = task.toLowerCase().split(/\s+/); @@ -128,7 +129,7 @@ export function registerScopeWork(server: McpServer): void { .slice(0, 5); if (grepTerms.length > 0) { const pattern = shellEscape(grepTerms.join("|")); - matchedFiles = run(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`); + matchedFiles = shell(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`); } // Check which relevant dirs actually exist (with path traversal protection) diff --git a/src/tools/session-handoff.ts b/src/tools/session-handoff.ts index d199462..fab0b80 100644 --- a/src/tools/session-handoff.ts +++ b/src/tools/session-handoff.ts @@ -3,15 +3,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js"; +import { shell, hasCommand } from "../lib/shell.js"; import { readIfExists, findWorkspaceDocs } from "../lib/files.js"; import { STATE_DIR, now } from "../lib/state.js"; -/** Check if a CLI tool is available */ -function hasCommand(cmd: string): boolean { - const result = run(`command -v ${cmd} 2>/dev/null`); - return !!result && !result.startsWith("[command failed"); -} - export function registerSessionHandoff(server: McpServer): void { server.tool( "session_handoff", @@ -44,7 +39,7 @@ export function registerSessionHandoff(server: McpServer): void { // Only try gh if it exists if (hasCommand("gh")) { - const openPRs = run("gh pr list --state open --json number,title,headRefName 2>/dev/null || echo '[]'"); + const openPRs = shell("gh pr list --state open --json number,title,headRefName 2>/dev/null || echo '[]'"); if (openPRs && openPRs !== "[]") { sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``); } diff --git a/src/tools/token-audit.ts b/src/tools/token-audit.ts index b7aad2c..ac45457 100644 --- a/src/tools/token-audit.ts +++ b/src/tools/token-audit.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run } from "../lib/git.js"; +import { shell } from "../lib/shell.js"; import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js"; import { loadState, saveState, now, STATE_DIR } from "../lib/state.js"; import { readFileSync, existsSync, statSync } from "fs"; @@ -39,8 +40,8 @@ export function registerTokenAudit(server: McpServer): void { let wasteScore = 0; // 1. Git diff size & dirty file count - const diffStat = run("git diff --stat --no-color 2>/dev/null"); - const dirtyFiles = run("git diff --name-only 2>/dev/null"); + const diffStat = run(["diff", "--stat", "--no-color"]); + const dirtyFiles = run(["diff", "--name-only"]); const dirtyList = dirtyFiles.split("\n").filter(Boolean); const dirtyCount = dirtyList.length; @@ -63,8 +64,11 @@ export function registerTokenAudit(server: McpServer): void { for (const f of dirtyList.slice(0, 30)) { // Use shell-safe quoting instead of interpolation - const wc = run(`wc -l < '${shellEscape(f)}' 2>/dev/null`); - const lines = parseInt(wc) || 0; + let lines = 0; + try { + const content = readFileSync(join(PROJECT_DIR, f), "utf-8"); + lines = content.split("\n").length; + } catch { /* file may not exist or be unreadable */ } estimatedContextTokens += lines * AVG_LINE_BYTES * AVG_TOKENS_PER_BYTE; if (lines > 500) { largeFiles.push(`${f} (${lines} lines)`); @@ -80,8 +84,10 @@ export function registerTokenAudit(server: McpServer): void { // 3. CLAUDE.md bloat check const claudeMd = readIfExists("CLAUDE.md", 1); if (claudeMd !== null) { - const stat = run(`wc -c < '${shellEscape("CLAUDE.md")}' 2>/dev/null`); - const bytes = parseInt(stat) || 0; + let bytes = 0; + try { + bytes = statSync(join(PROJECT_DIR, "CLAUDE.md")).size; + } catch { /* ignore */ } if (bytes > 5120) { patterns.push(`CLAUDE.md is ${(bytes / 1024).toFixed(1)}KB — injected every session, burns tokens on paste`); recommendations.push("Trim CLAUDE.md to essentials (<5KB). Move reference docs to files read on-demand"); @@ -139,7 +145,7 @@ export function registerTokenAudit(server: McpServer): void { // Read with size cap: take the tail if too large const raw = stat.size <= MAX_TOOL_LOG_BYTES ? readFileSync(toolLogPath, "utf-8") - : run(`tail -c ${MAX_TOOL_LOG_BYTES} '${shellEscape(toolLogPath)}'`); + : shell(`tail -c ${MAX_TOOL_LOG_BYTES} '${shellEscape(toolLogPath)}'`); const lines = raw.trim().split("\n").filter(Boolean); totalToolCalls = lines.length; diff --git a/src/tools/verify-completion.ts b/src/tools/verify-completion.ts index 732532f..b799ad5 100644 --- a/src/tools/verify-completion.ts +++ b/src/tools/verify-completion.ts @@ -1,8 +1,9 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run, getStatus } from "../lib/git.js"; +import { shell } from "../lib/shell.js"; import { PROJECT_DIR } from "../lib/files.js"; -import { existsSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import { join } from "path"; /** Detect package manager from lockfiles */ @@ -34,7 +35,8 @@ function detectTestRunner(): string | null { /** Check if a build script exists in package.json */ function hasBuildScript(): boolean { try { - const pkg = JSON.parse(run("cat package.json 2>/dev/null")); + const raw = readFileSync(join(PROJECT_DIR, "package.json"), "utf-8"); + const pkg = JSON.parse(raw); return !!pkg?.scripts?.build; } catch { return false; } } @@ -55,7 +57,7 @@ export function registerVerifyCompletion(server: McpServer): void { const checks: { name: string; passed: boolean; detail: string }[] = []; // 1. Type check (single invocation, extract both result and count) - const tscOutput = run(`${pm === "npx" ? "npx" : pm} tsc --noEmit 2>&1 | tail -20`); + const tscOutput = shell(`${pm === "npx" ? "npx" : pm} tsc --noEmit 2>&1 | tail -20`); const errorLines = tscOutput.split("\n").filter(l => /error TS\d+/.test(l)); const typePassed = errorLines.length === 0; checks.push({ @@ -80,7 +82,7 @@ export function registerVerifyCompletion(server: McpServer): void { // 3. Tests if (!skip_tests) { const runner = detectTestRunner(); - const changedFiles = run("git diff --name-only HEAD~1 2>/dev/null").split("\n").filter(Boolean); + const changedFiles = run(["diff", "--name-only", "HEAD~1"]).split("\n").filter(Boolean); let testCmd = ""; if (runner === "playwright") { @@ -112,7 +114,7 @@ export function registerVerifyCompletion(server: McpServer): void { } if (testCmd) { - const testResult = run(testCmd, { timeout: 120000 }); + const testResult = shell(testCmd, { timeout: 120000 }); const testPassed = /pass/i.test(testResult) && !/fail/i.test(testResult); checks.push({ name: "Tests", @@ -130,7 +132,7 @@ export function registerVerifyCompletion(server: McpServer): void { // 4. Build check (only if build script exists and not skipped) if (!skip_build && hasBuildScript()) { - const buildCheck = run(`${pm === "npx" ? "npm run" : pm} build 2>&1 | tail -10`, { timeout: 60000 }); + const buildCheck = shell(`${pm === "npx" ? "npm run" : pm} build 2>&1 | tail -10`, { timeout: 60000 }); const buildPassed = !/\b[Ee]rror\b/.test(buildCheck) || /Successfully compiled/.test(buildCheck); checks.push({ name: "Build", diff --git a/src/tools/what-changed.ts b/src/tools/what-changed.ts index 913dfa2..6e8efcc 100644 --- a/src/tools/what-changed.ts +++ b/src/tools/what-changed.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run, getBranch, getDiffStat } from "../lib/git.js"; +import { run, getBranch, getDiffStat, getDiffFiles } from "../lib/git.js"; export function registerWhatChanged(server: McpServer): void { server.tool( @@ -12,8 +12,8 @@ export function registerWhatChanged(server: McpServer): void { async ({ since }) => { const ref = since || "HEAD~5"; const diffStat = getDiffStat(ref); - const diffFiles = run(`git diff ${ref} --name-only 2>/dev/null || git diff HEAD~3 --name-only`); - const log = run(`git log ${ref}..HEAD --oneline 2>/dev/null || git log -5 --oneline`); + const diffFiles = getDiffFiles(ref); + const log = run(["log", `${ref}..HEAD`, "--oneline"]); const branch = getBranch(); const fileList = diffFiles.split("\n").filter(Boolean);