diff --git a/README.md b/README.md index f60fefa..7b4b747 100644 --- a/README.md +++ b/README.md @@ -500,6 +500,8 @@ Manual contract definitions that supplement auto-extraction: Environment variables are **fallbacks** — `.preflight/` config takes precedence when present. +> **💡 Ready-to-use example configs:** Copy [`examples/.preflight/`](examples/.preflight/) into your project root to get started quickly. See [`examples/.preflight/README.md`](examples/.preflight/README.md) for details. + --- ## Embedding Providers diff --git a/examples/.preflight/README.md b/examples/.preflight/README.md new file mode 100644 index 0000000..c3ca3b6 --- /dev/null +++ b/examples/.preflight/README.md @@ -0,0 +1,24 @@ +# Example `.preflight/` Config + +Copy this directory into your project root to get started: + +```bash +cp -r examples/.preflight /path/to/your/project/ +``` + +Then edit the files to match your project: + +| File | Purpose | +|------|---------| +| `config.yml` | Profile, related projects, thresholds, embedding provider | +| `triage.yml` | Keyword rules and strictness for prompt classification | +| `contracts/*.yml` | Manual type/interface definitions for cross-service awareness | + +All files are optional — preflight works without any config. These let you tune it for your team and codebase. + +## Tips + +- **Commit `.preflight/` to your repo** so the whole team shares the same rules +- **Start with `strictness: standard`**, then relax or tighten based on your experience +- **Add domain terms to `always_check`** that are frequently ambiguous in your codebase (e.g., "billing", "permissions") +- **Use contracts** for types that live in a separate repo or aren't auto-detected diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml new file mode 100644 index 0000000..df4f864 --- /dev/null +++ b/examples/.preflight/config.yml @@ -0,0 +1,31 @@ +# .preflight/config.yml +# Drop this in your project root. Every field is optional. +# See: https://github.com/TerminalGravity/preflight#configuration-reference + +# Profile controls how much detail preflight returns. +# "minimal" — only flags ambiguous+, skips clarification detail +# "standard" — default behavior +# "full" — maximum detail on every non-trivial prompt +profile: standard + +# Related projects for cross-service contract awareness. +# When your prompt mentions a keyword from a related project, +# triage escalates to cross-service and searches those projects. +related_projects: + - path: /Users/you/projects/auth-service + alias: auth + - path: /Users/you/projects/shared-types + alias: shared + +# 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 provider for timeline search +# "local" uses Xenova (zero config, runs on-device) +# "openai" uses text-embedding-3-small (faster, needs API key) +embeddings: + provider: local + # 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..154ad5b --- /dev/null +++ b/examples/.preflight/contracts/api.yml @@ -0,0 +1,35 @@ +# .preflight/contracts/api.yml +# Manual contract definitions that supplement auto-extraction. +# Use these when preflight can't auto-detect your shared types, +# or when you want to be explicit about cross-service boundaries. + +- name: User + kind: interface + description: Core user object shared across services + fields: + - name: id + type: string + required: true + - name: email + type: string + required: true + - name: role + type: "'admin' | 'member' | 'viewer'" + required: true + - name: teamId + type: string + required: false + +- name: ApiResponse + kind: interface + description: Standard API response wrapper + fields: + - name: data + type: T + required: true + - name: error + type: string + required: false + - name: meta + type: "{ page: number, total: number }" + required: false diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml new file mode 100644 index 0000000..d418ace --- /dev/null +++ b/examples/.preflight/triage.yml @@ -0,0 +1,36 @@ +# .preflight/triage.yml +# Controls the triage classification engine. +# Customize which prompts get flagged, skipped, or escalated. + +rules: + # Prompts containing these words → always at least AMBIGUOUS. + # Add domain terms that are frequently underspecified in your codebase. + always_check: + - rewards + - permissions + - migration + - schema + - billing + + # Prompts containing these words → TRIVIAL (pass through without checks). + # Safe, low-risk operations that don't need guardrails. + skip: + - commit + - format + - lint + - prettier + + # Prompts containing these words → CROSS-SERVICE. + # Triggers a search across related_projects defined in config.yml. + cross_service_keywords: + - auth + - notification + - event + - webhook + - queue + +# How aggressively to classify prompts. +# "relaxed" — more prompts pass as clear (experienced users) +# "standard" — balanced (recommended) +# "strict" — more prompts flagged as ambiguous (teams, onboarding) +strictness: standard diff --git a/src/tools/audit-workspace.ts b/src/tools/audit-workspace.ts index d4306bd..1ac3d5f 100644 --- a/src/tools/audit-workspace.ts +++ b/src/tools/audit-workspace.ts @@ -1,6 +1,8 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run } from "../lib/git.js"; -import { readIfExists, findWorkspaceDocs } from "../lib/files.js"; +import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js"; +import { readdirSync, statSync } from "fs"; +import { join } from "path"; /** Extract top-level work areas from file paths generically */ function detectWorkAreas(files: string[]): Set { @@ -36,7 +38,8 @@ export function registerAuditWorkspace(server: McpServer): void { {}, async () => { const docs = findWorkspaceDocs(); - const recentFiles = run("git diff --name-only HEAD~10 2>/dev/null || echo ''").split("\n").filter(Boolean); + const recentFilesRaw = run(["diff", "--name-only", "HEAD~10"]); + const recentFiles = (recentFilesRaw.startsWith("[") ? "" : recentFilesRaw).split("\n").filter(Boolean); const sections: string[] = []; // Doc freshness @@ -75,7 +78,20 @@ export function registerAuditWorkspace(server: McpServer): void { // Check for gap trackers or similar tracking docs const trackingDocs = Object.entries(docs).filter(([n]) => /gap|track|progress/i.test(n)); if (trackingDocs.length > 0) { - const testFilesCount = parseInt(run("find tests -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | wc -l").trim()) || 0; + const testFilesCount = (() => { + try { + const testsDir = join(PROJECT_DIR, "tests"); + let count = 0; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) walk(join(dir, entry.name)); + else if (/\.(spec|test)\.(ts|tsx|js|jsx)$/.test(entry.name)) count++; + } + }; + walk(testsDir); + return count; + } catch { return 0; } + })(); sections.push(`## Tracking Docs\n${trackingDocs.map(([n]) => { const age = docStatus.find(d => d.name === n)?.ageHours ?? "?"; return `- .claude/${n} — last updated ${age}h ago`; diff --git a/src/tools/enrich-agent-task.ts b/src/tools/enrich-agent-task.ts index 236edfa..4a7e171 100644 --- a/src/tools/enrich-agent-task.ts +++ b/src/tools/enrich-agent-task.ts @@ -8,11 +8,6 @@ import { execFileSync } from "child_process"; import { join, basename } from "path"; import { createHash } from "crypto"; -/** Sanitize user input for safe use in shell commands */ -function shellEscape(s: string): string { - return s.replace(/[^a-zA-Z0-9_\-./]/g, ""); -} - /** Detect package manager from lockfiles */ function detectPackageManager(): string { if (existsSync(join(PROJECT_DIR, "pnpm-lock.yaml"))) return "pnpm"; @@ -25,16 +20,22 @@ function detectPackageManager(): string { function findAreaFiles(area: string): string { if (!area) return getDiffFiles("HEAD~3"); - const safeArea = shellEscape(area); - // If area looks like a path, search directly if (area.includes("/")) { - return run(`git ls-files -- '${safeArea}*' 2>/dev/null | head -20`); + const result = run(["ls-files", "--", `${area}*`]); + if (result && !result.startsWith("[")) { + return result.split("\n").filter(Boolean).slice(0, 20).join("\n"); + } + return getDiffFiles("HEAD~3"); } // Search for area keyword in git-tracked file paths - const files = run(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`); - if (files && !files.startsWith("[command failed")) return files; + const allFiles = run(["ls-files"]); + if (allFiles && !allFiles.startsWith("[")) { + const areaLower = area.toLowerCase(); + const matched = allFiles.split("\n").filter(Boolean).filter((f) => f.toLowerCase().includes(areaLower)).slice(0, 20); + if (matched.length > 0) return matched.join("\n"); + } // Fallback to recently changed files return getDiffFiles("HEAD~3"); @@ -42,18 +43,28 @@ 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"); + const allFiles = run(["ls-files"]); + if (!allFiles || allFiles.startsWith("[")) return ""; + const testFiles = allFiles.split("\n").filter((f) => /\.(spec|test)\.(ts|tsx|js|jsx)$/.test(f)); - 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"); + if (!area) return testFiles.slice(0, 10).join("\n"); + + const areaLower = area.split(/\s+/)[0].toLowerCase(); + const areaTests = testFiles.filter((f) => f.toLowerCase().includes(areaLower)).slice(0, 10); + return areaTests.length > 0 ? areaTests.join("\n") : testFiles.slice(0, 10).join("\n"); } /** 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'`); + try { + const abs = join(PROJECT_DIR, firstFile); + const content = readFileSync(abs, "utf-8"); + return content.split("\n").slice(0, 30).join("\n"); + } catch { + return "could not read file"; + } } // --------------------------------------------------------------------------- diff --git a/src/tools/scope-work.ts b/src/tools/scope-work.ts index 9b5d971..db06a7a 100644 --- a/src/tools/scope-work.ts +++ b/src/tools/scope-work.ts @@ -17,11 +17,6 @@ const STOP_WORDS = new Set([ "like", "some", "each", "only", "need", "want", "please", "update", "change", ]); -/** Shell-escape a string for use inside single quotes */ -function shellEscape(s: string): string { - return s.replace(/'/g, "'\\''"); -} - /** Safely parse git porcelain status lines */ function parsePortelainFiles(porcelain: string): string[] { if (!porcelain.trim()) return []; @@ -93,7 +88,7 @@ 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)"; @@ -127,8 +122,15 @@ export function registerScopeWork(server: McpServer): void { .filter((k) => k.length > 2) .slice(0, 5); if (grepTerms.length > 0) { - const pattern = shellEscape(grepTerms.join("|")); - matchedFiles = run(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`); + const allFiles = run(["ls-files"]); + const re = new RegExp(grepTerms.join("|"), "i"); + matchedFiles = allFiles + .split("\n") + .filter(Boolean) + .slice(0, 500) + .filter((f) => re.test(f)) + .slice(0, 30) + .join("\n"); } // Check which relevant dirs actually exist (with path traversal protection) diff --git a/src/tools/sequence-tasks.ts b/src/tools/sequence-tasks.ts index 22dea23..b6fbdc0 100644 --- a/src/tools/sequence-tasks.ts +++ b/src/tools/sequence-tasks.ts @@ -90,7 +90,8 @@ export function registerSequenceTasks(server: McpServer): void { // For locality: infer directories from path-like tokens in task text if (strategy === "locality") { // Use git ls-files with a depth limit instead of find for performance - const gitFiles = run("git ls-files 2>/dev/null | head -1000"); + const gitFilesRaw = run(["ls-files"]); + const gitFiles = gitFilesRaw.split("\n").slice(0, 1000).join("\n"); const knownDirs = new Set(); for (const f of gitFiles.split("\n").filter(Boolean)) { const parts = f.split("/"); diff --git a/src/tools/session-handoff.ts b/src/tools/session-handoff.ts index d199462..eb275de 100644 --- a/src/tools/session-handoff.ts +++ b/src/tools/session-handoff.ts @@ -2,14 +2,17 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; +import { execFileSync } from "child_process"; import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js"; import { readIfExists, findWorkspaceDocs } from "../lib/files.js"; import { STATE_DIR, now } from "../lib/state.js"; -/** Check if a CLI tool is available */ +/** Check if a CLI tool is available on PATH */ function hasCommand(cmd: string): boolean { - const result = run(`command -v ${cmd} 2>/dev/null`); - return !!result && !result.startsWith("[command failed"); + try { + execFileSync("which", [cmd], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); + return true; + } catch { return false; } } export function registerSessionHandoff(server: McpServer): void { @@ -44,7 +47,12 @@ 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 '[]'"); + let openPRs = "[]"; + try { + openPRs = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,title,headRefName"], { + encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { /* gh not authed or no remote — skip */ } if (openPRs && openPRs !== "[]") { sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``); } diff --git a/src/tools/sharpen-followup.ts b/src/tools/sharpen-followup.ts index db5acaa..e95bdaf 100644 --- a/src/tools/sharpen-followup.ts +++ b/src/tools/sharpen-followup.ts @@ -27,10 +27,10 @@ function parsePortelainFiles(output: string): string[] { /** Get recently changed files, safe for first commit / shallow clones */ function getRecentChangedFiles(): string[] { // Try HEAD~1..HEAD, fall back to just staged, then unstaged - const commands = [ - "git diff --name-only HEAD~1 HEAD 2>/dev/null", - "git diff --name-only --cached 2>/dev/null", - "git diff --name-only 2>/dev/null", + const commands: string[][] = [ + ["diff", "--name-only", "HEAD~1", "HEAD"], + ["diff", "--name-only", "--cached"], + ["diff", "--name-only"], ]; const results = new Set(); for (const cmd of commands) { @@ -87,7 +87,7 @@ export function registerSharpenFollowup(server: McpServer): void { // Gather context to resolve ambiguity const contextFiles: string[] = [...(previous_files ?? [])]; const recentChanged = getRecentChangedFiles(); - const porcelainOutput = run("git status --porcelain 2>/dev/null"); + const porcelainOutput = run(["status", "--porcelain"]); const untrackedOrModified = parsePortelainFiles(porcelainOutput); const allKnownFiles = [...new Set([...contextFiles, ...recentChanged, ...untrackedOrModified])].filter(Boolean); diff --git a/src/tools/token-audit.ts b/src/tools/token-audit.ts index b7aad2c..a2ff47f 100644 --- a/src/tools/token-audit.ts +++ b/src/tools/token-audit.ts @@ -4,12 +4,37 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run } from "../lib/git.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"; +import { readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "fs"; import { join } from "path"; -/** Shell-escape a filename for safe interpolation */ -function shellEscape(s: string): string { - return s.replace(/'/g, "'\\''"); +/** Count lines in a file using Node.js fs (no shell needed). Returns 0 on error. */ +function countLines(filePath: string): number { + try { + const abs = join(PROJECT_DIR, filePath); + const content = readFileSync(abs, "utf-8"); + return content.split("\n").length; + } catch { return 0; } +} + +/** Get file size in bytes using Node.js fs. Returns 0 on error. */ +function fileSize(filePath: string): number { + try { + const abs = join(PROJECT_DIR, filePath); + return statSync(abs).size; + } catch { return 0; } +} + +/** Read tail bytes of a file. Returns empty string on error. */ +function readTail(filePath: string, maxBytes: number): string { + try { + const stat = statSync(filePath); + const fd = openSync(filePath, "r"); + const start = Math.max(0, stat.size - maxBytes); + const buf = Buffer.alloc(Math.min(maxBytes, stat.size)); + readSync(fd, buf, 0, buf.length, start); + closeSync(fd); + return buf.toString("utf-8"); + } catch { return ""; } } /** @@ -39,8 +64,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; @@ -62,9 +87,7 @@ export function registerTokenAudit(server: McpServer): void { const largeFiles: string[] = []; 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; + const lines = countLines(f); estimatedContextTokens += lines * AVG_LINE_BYTES * AVG_TOKENS_PER_BYTE; if (lines > 500) { largeFiles.push(`${f} (${lines} lines)`); @@ -80,8 +103,7 @@ 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; + const bytes = fileSize("CLAUDE.md"); 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 +161,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)}'`); + : readTail(toolLogPath, MAX_TOOL_LOG_BYTES); 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..bc04a79 100644 --- a/src/tools/verify-completion.ts +++ b/src/tools/verify-completion.ts @@ -2,9 +2,27 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run, getStatus } from "../lib/git.js"; import { PROJECT_DIR } from "../lib/files.js"; -import { existsSync } from "fs"; +import { existsSync, readFileSync } from "fs"; +import { execSync } from "child_process"; import { join } from "path"; +/** Run a shell command safely, returning stdout. Returns error string on failure. */ +function shell(cmd: string, opts: { timeout?: number } = {}): string { + try { + return execSync(cmd, { + cwd: PROJECT_DIR, + encoding: "utf-8", + timeout: opts.timeout || 30000, + maxBuffer: 2 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch (e: any) { + // Combine stdout+stderr for visibility + const out = [e.stdout?.trim(), e.stderr?.trim()].filter(Boolean).join("\n"); + return out || `[command failed: ${cmd}]`; + } +} + /** Detect package manager from lockfiles */ function detectPM(): string { if (existsSync(join(PROJECT_DIR, "pnpm-lock.yaml"))) return "pnpm"; @@ -34,7 +52,7 @@ 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 pkg = JSON.parse(readFileSync(join(PROJECT_DIR, "package.json"), "utf-8")); return !!pkg?.scripts?.build; } catch { return false; } } @@ -55,7 +73,8 @@ 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 tscRaw = shell(`${pm === "npx" ? "npx" : pm} tsc --noEmit 2>&1`, { timeout: 60000 }); + const tscOutput = tscRaw.split("\n").slice(-20).join("\n"); const errorLines = tscOutput.split("\n").filter(l => /error TS\d+/.test(l)); const typePassed = errorLines.length === 0; checks.push({ @@ -80,39 +99,39 @@ 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 changedFilesRaw = run(["diff", "--name-only", "HEAD~1"]); + const changedFiles = (changedFilesRaw.startsWith("[") ? "" : changedFilesRaw).split("\n").filter(Boolean); let testCmd = ""; if (runner === "playwright") { const runnerCmd = `${pm === "npx" ? "npx" : `${pm} exec`} playwright test`; if (test_scope && test_scope !== "all") { testCmd = test_scope.endsWith(".spec.ts") || test_scope.endsWith(".test.ts") - ? `${runnerCmd} ${test_scope} --reporter=line 2>&1 | tail -20` - : `${runnerCmd} --grep "${test_scope}" --reporter=line 2>&1 | tail -20`; + ? `${runnerCmd} ${test_scope} --reporter=line 2>&1` + : `${runnerCmd} --grep "${test_scope}" --reporter=line 2>&1`; } else { - // Auto-detect from changed files const changedTests = changedFiles.filter(f => /\.(spec|test)\.(ts|tsx|js)$/.test(f)).slice(0, 5); if (changedTests.length > 0) { - testCmd = `${runnerCmd} ${changedTests.join(" ")} --reporter=line 2>&1 | tail -20`; + testCmd = `${runnerCmd} ${changedTests.join(" ")} --reporter=line 2>&1`; } } } else if (runner === "vitest" || runner === "jest") { const runnerCmd = `${pm === "npx" ? "npx" : `${pm} exec`} ${runner}`; if (test_scope && test_scope !== "all") { - testCmd = `${runnerCmd} --run ${test_scope} 2>&1 | tail -20`; + testCmd = `${runnerCmd} --run ${test_scope} 2>&1`; } else { const changedTests = changedFiles.filter(f => /\.(spec|test)\.(ts|tsx|js)$/.test(f)).slice(0, 5); if (changedTests.length > 0) { - testCmd = `${runnerCmd} --run ${changedTests.join(" ")} 2>&1 | tail -20`; + testCmd = `${runnerCmd} --run ${changedTests.join(" ")} 2>&1`; } } } else if (test_scope) { - // No recognized runner but scope given — try npm test - testCmd = `${pm} test 2>&1 | tail -20`; + testCmd = `${pm} test 2>&1`; } if (testCmd) { - const testResult = run(testCmd, { timeout: 120000 }); + const testRaw = shell(testCmd, { timeout: 120000 }); + const testResult = testRaw.split("\n").slice(-20).join("\n"); const testPassed = /pass/i.test(testResult) && !/fail/i.test(testResult); checks.push({ name: "Tests", @@ -130,7 +149,8 @@ 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 buildRaw = shell(`${pm === "npx" ? "npm run" : pm} build 2>&1`, { timeout: 60000 }); + const buildCheck = buildRaw.split("\n").slice(-10).join("\n"); const buildPassed = !/\b[Ee]rror\b/.test(buildCheck) || /Successfully compiled/.test(buildCheck); checks.push({ name: "Build",