From 8b69f96878717be93cfd1d3b7cddf7a0fb2c496d Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Thu, 12 Mar 2026 10:16:47 -0700 Subject: [PATCH 1/3] fix: replace run() misuse with shell() or proper array args across 9 tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the remaining tools listed in #215 where run() was being called with shell syntax (pipes, redirects, non-git commands). run() uses execFileSync('git', args) — no shell, always prepends 'git'. Changes: - Add shell() helper to src/lib/git.ts (execSync with shell support) - verify-completion: use shell() for tsc/build, readFileSync for package.json, array args for git diff - clarify-intent: use shell() for tsc and find commands - session-handoff: use shell() for command -v and gh pr list - audit-workspace: use run() with array args for git diff, shell() for find - sharpen-followup: use run() with array args for git diff/status - enrich-agent-task: use shell() for piped git ls-files, readFileSync for head - sequence-tasks: use shell() for piped git ls-files - checkpoint: use run() with array args for git add/reset, shell() for compound command - scope-work: use run() with array args for git status/diff, shell() for piped ls-files Closes #215 --- src/lib/git.ts | 21 ++++++++++++++++++++- src/tools/audit-workspace.ts | 6 +++--- src/tools/checkpoint.ts | 8 ++++---- src/tools/clarify-intent.ts | 6 +++--- src/tools/enrich-agent-task.ts | 21 +++++++++++++-------- src/tools/scope-work.ts | 8 ++++---- src/tools/sequence-tasks.ts | 4 ++-- src/tools/session-handoff.ts | 8 ++++---- src/tools/sharpen-followup.ts | 18 +++++++++--------- src/tools/verify-completion.ts | 15 ++++++++------- 10 files changed, 70 insertions(+), 45 deletions(-) diff --git a/src/lib/git.ts b/src/lib/git.ts index a32ee3c..aa369d4 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { PROJECT_DIR } from "./files.js"; import type { RunError } from "../types.js"; @@ -87,3 +87,22 @@ export function getDiffStat(ref = "HEAD~5"): string { if (!fallback.startsWith("[")) return fallback; return "no diff stats available"; } + +/** + * Run an arbitrary shell command string (with pipes, redirects, etc.). + * Use sparingly — prefer `run()` with explicit args for git commands. + * Returns stdout trimmed, or empty string on failure. + */ +export function shell(cmd: string, opts: { timeout?: number } = {}): string { + try { + return execSync(cmd, { + cwd: PROJECT_DIR, + encoding: "utf-8", + timeout: opts.timeout || 10000, + maxBuffer: 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + return ""; + } +} diff --git a/src/tools/audit-workspace.ts b/src/tools/audit-workspace.ts index d4306bd..2252410 100644 --- a/src/tools/audit-workspace.ts +++ b/src/tools/audit-workspace.ts @@ -1,5 +1,5 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run } from "../lib/git.js"; +import { run, shell } from "../lib/git.js"; import { readIfExists, findWorkspaceDocs } from "../lib/files.js"; /** Extract top-level work areas from file paths generically */ @@ -36,7 +36,7 @@ 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 recentFiles = run(["diff", "--name-only", "HEAD~10"]).split("\n").filter(l => l && !l.startsWith("[")); const sections: string[] = []; // Doc freshness @@ -75,7 +75,7 @@ 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 = parseInt(shell("find tests -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | wc -l")) || 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/checkpoint.ts b/src/tools/checkpoint.ts index e086f01..432a03e 100644 --- a/src/tools/checkpoint.ts +++ b/src/tools/checkpoint.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { writeFileSync, existsSync, mkdirSync } from "fs"; import { join, dirname } from "path"; -import { run, getBranch, getStatus, getLastCommit, getStagedFiles } from "../lib/git.js"; +import { run, shell, getBranch, getStatus, getLastCommit, getStagedFiles } from "../lib/git.js"; import { PROJECT_DIR } from "../lib/files.js"; import { appendLog, now } from "../lib/state.js"; @@ -84,11 +84,11 @@ ${dirty || "clean"} 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 = shell(`${addCmd} && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>&1`); 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/clarify-intent.ts b/src/tools/clarify-intent.ts index 32efa3a..db71ccb 100644 --- a/src/tools/clarify-intent.ts +++ b/src/tools/clarify-intent.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js"; +import { run, shell, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js"; import { findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js"; import { searchSemantic } from "../lib/timeline-db.js"; import { getRelatedProjects } from "../lib/config.js"; @@ -152,10 +152,10 @@ export function registerClarifyIntent(server: McpServer): void { let hasTestFailures = false; if (!area || area.includes("test") || area.includes("fix") || area.includes("ui") || area.includes("api")) { - const typeErrors = run("pnpm tsc --noEmit 2>&1 | grep -c 'error TS' || echo '0'"); + const typeErrors = shell("pnpm tsc --noEmit 2>&1 | grep -c 'error TS' || echo '0'") || "0"; hasTypeErrors = parseInt(typeErrors, 10) > 0; - const testFiles = run("find tests -name '*.spec.ts' -maxdepth 4 2>/dev/null | head -20"); + const testFiles = shell("find tests -name '*.spec.ts' -maxdepth 4 2>/dev/null | head -20"); const failingTests = getTestFailures(); hasTestFailures = failingTests !== "all passing" && failingTests !== "no test report found"; diff --git a/src/tools/enrich-agent-task.ts b/src/tools/enrich-agent-task.ts index 236edfa..6bfddde 100644 --- a/src/tools/enrich-agent-task.ts +++ b/src/tools/enrich-agent-task.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run, getDiffFiles } from "../lib/git.js"; +import { run, shell, getDiffFiles } from "../lib/git.js"; import { PROJECT_DIR } from "../lib/files.js"; import { getConfig, type RelatedProject } from "../lib/config.js"; import { existsSync, readFileSync } from "fs"; @@ -29,12 +29,12 @@ 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`); - if (files && !files.startsWith("[command failed")) return files; + const files = shell(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`); + if (files) return files; // Fallback to recently changed files return getDiffFiles("HEAD~3"); @@ -42,18 +42,23 @@ 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'`); + try { + const content = readFileSync(join(PROJECT_DIR, firstFile), "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..96b7708 100644 --- a/src/tools/scope-work.ts +++ b/src/tools/scope-work.ts @@ -1,7 +1,7 @@ // CATEGORY 1: scope_work — Plans import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js"; +import { run, shell, getBranch, getRecentCommits, getStatus } from "../lib/git.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 +93,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 +128,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/sequence-tasks.ts b/src/tools/sequence-tasks.ts index 22dea23..749a0d9 100644 --- a/src/tools/sequence-tasks.ts +++ b/src/tools/sequence-tasks.ts @@ -1,7 +1,7 @@ // CATEGORY 6: sequence_tasks — Sequencing import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run } from "../lib/git.js"; +import { run, shell } from "../lib/git.js"; import { now } from "../lib/state.js"; import { PROJECT_DIR } from "../lib/files.js"; import { existsSync } from "fs"; @@ -90,7 +90,7 @@ 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 gitFiles = shell("git ls-files 2>/dev/null | head -1000"); 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..c1e1981 100644 --- a/src/tools/session-handoff.ts +++ b/src/tools/session-handoff.ts @@ -2,14 +2,14 @@ import { z } from "zod"; 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 { run, shell, 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 */ function hasCommand(cmd: string): boolean { - const result = run(`command -v ${cmd} 2>/dev/null`); - return !!result && !result.startsWith("[command failed"); + const result = shell(`command -v ${cmd} 2>/dev/null`); + return !!result; } export function registerSessionHandoff(server: McpServer): void { @@ -44,7 +44,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") || "[]"; 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..d702a4d 100644 --- a/src/tools/sharpen-followup.ts +++ b/src/tools/sharpen-followup.ts @@ -1,7 +1,7 @@ // CATEGORY 4: sharpen_followup — Follow-up Specificity import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run } from "../lib/git.js"; +import { run, shell } from "../lib/git.js"; import { now } from "../lib/state.js"; /** Parse git porcelain output into deduplicated file paths, handling renames (R/C) */ @@ -27,15 +27,15 @@ 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 argSets: string[][] = [ + ["diff", "--name-only", "HEAD~1", "HEAD"], + ["diff", "--name-only", "--cached"], + ["diff", "--name-only"], ]; const results = new Set(); - for (const cmd of commands) { - const out = run(cmd); - if (out) out.split("\n").filter(Boolean).forEach((f) => results.add(f)); + for (const args of argSets) { + const out = run(args); + if (out && !out.startsWith("[")) out.split("\n").filter(Boolean).forEach((f) => results.add(f)); if (results.size > 0) break; // first successful source is enough } return [...results]; @@ -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/verify-completion.ts b/src/tools/verify-completion.ts index 732532f..254d747 100644 --- a/src/tools/verify-completion.ts +++ b/src/tools/verify-completion.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run, getStatus } from "../lib/git.js"; +import { run, shell, getStatus } from "../lib/git.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 +34,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 +56,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 +81,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 +113,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 +131,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", From 7912e6e9c2813c117efb7bc596cdce1adcd64571 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Thu, 12 Mar 2026 10:39:52 -0700 Subject: [PATCH 2/3] docs: add concrete usage examples for key tools in examples/README.md Shows real input/output examples for preflight_check, prompt_score, scope_work, estimate_cost, log_correction, and search_history. Includes workflow tip for automatic preflight via CLAUDE.md. --- examples/README.md | 151 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/examples/README.md b/examples/README.md index 778f15d..34a6035 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,3 +33,154 @@ Then commit `.preflight/` to your repo — your whole team gets the same preflig | `contracts/*.yml` | Manual type/interface definitions for cross-service awareness | No — auto-extraction works without it | All files are optional. Preflight works out of the box with zero config — these files let you tune it to your codebase. + +--- + +## Usage Examples + +Once preflight is registered as an MCP server, these tools are available inside Claude Code. Here's what real usage looks like for the most common tools. + +### `preflight_check` — The unified entry point + +This is the tool you'll use most. It triages your prompt and automatically chains the right checks. + +**Vague prompt → caught and clarified:** + +``` +You: "fix the tests" + +preflight_check fires → triage level: AMBIGUOUS + +🚨 Ambiguous prompt detected — clarification needed: + + Which tests? I found: + • 14 test files in tests/ + • 3 currently failing: + - tests/triage.test.ts (assertion error line 42) + - tests/config.test.ts (timeout) + - tests/patterns.test.ts (missing fixture) + + Also: 2 correction patterns matched — last time "fix tests" led to + editing the wrong test file. Be specific about which test and what + the expected behavior should be. +``` + +**Specific prompt → green light with context:** + +``` +You: "refactor src/lib/triage.ts to extract the keyword matching into its own function" + +preflight_check fires → triage level: CLEAR + +✅ Prompt is clear. Context gathered: + • Branch: feat/triage-refactor (3 commits ahead of main) + • src/lib/triage.ts — 287 lines, last modified 2h ago + • 2 files import from triage.ts: preflight-check.ts, clarify-intent.ts + • No related correction patterns found +``` + +### `prompt_score` — Gamified prompt quality + +Scores your prompt on specificity, context, and actionability. Tracks your session average. + +``` +You: "add error handling" + +prompt_score result: + Score: 3/10 ⚠️ + - Missing: which file? what errors? what should happen on failure? + - Session average: 6.2 (↓ from 7.1) + - Tip: try "add try/catch to src/lib/embeddings.ts embedText() — + catch fetch failures and return a fallback zero vector" +``` + +### `scope_work` — Plan before you build + +Generates a scoped work plan with file targets, risk areas, and estimated complexity. + +``` +You: "add OAuth login with Google" + +scope_work result: + 📋 Work Plan: Google OAuth Integration + + Files to create: + • src/auth/google.ts — OAuth flow handler + • src/auth/callback.ts — Token exchange endpoint + + Files to modify: + • src/middleware/session.ts — add OAuth session type + • src/types.ts — add GoogleUser interface + + Dependencies to add: + • googleapis or passport-google-oauth20 + + Risk areas: + • Session cookie config (SameSite issues in dev) + • Redirect URI mismatch between dev/prod + + Estimated scope: Medium (4-6 files, ~200 lines) + Related contracts: UserSession interface in api.yml +``` + +### `estimate_cost` — Know before you go + +Estimates token spend for a task based on historical patterns. + +``` +You: "rewrite the entire scoring engine" + +estimate_cost result: + 💰 Estimated cost for this task: + • Estimated tokens: ~45,000 (input) + ~12,000 (output) + • Estimated cost: ~$0.85 + • Based on: 3 similar refactoring tasks in history + • Warning: Large refactors average 2.1 correction cycles — + consider breaking into smaller pieces +``` + +### `log_correction` — Teach preflight your patterns + +When Claude goes wrong, log it so preflight warns you next time. + +``` +You: log_correction("said 'update the config' and Claude edited + package.json instead of .preflight/config.yml") + +✅ Correction logged. + Pattern: "update config" → wrong file target + Next time you say "update config", preflight will ask which config file. +``` + +### `search_history` — Find anything from past sessions + +Semantic search across all your Claude Code session history. + +``` +You: "how did I set up the database migrations last month?" + +search_history result: + Found 3 relevant sessions: + + 1. [Feb 14] "Set up Prisma migrations for user table" + → Created prisma/migrations/001_users.sql + → Used `prisma migrate dev --name init` + + 2. [Feb 16] "Fix migration conflict after schema change" + → Resolved by resetting dev DB: `prisma migrate reset` + + 3. [Feb 20] "Add index to sessions table" + → prisma/migrations/003_session_index.sql +``` + +### Workflow tip: Let `preflight_check` run automatically + +Add this to your Claude Code custom instructions (CLAUDE.md): + +```markdown +Before executing any task, run preflight_check with the user's prompt. +If the triage level is AMBIGUOUS or higher, present the clarification +before proceeding. Never skip preflight on multi-file changes. +``` + +This makes preflight automatic — you don't have to remember to call it. From c3e0b53203f2d38f7ee5c7ad940c9ff4f6c278c4 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Thu, 12 Mar 2026 10:45:45 -0700 Subject: [PATCH 3/3] test: add comprehensive unit tests for lib/git.ts (22 tests) Covers run(), shell(), and all convenience functions including error handling, timeout behavior, fallback logic in getDiffFiles and getDiffStat. --- tests/lib/git.test.ts | 173 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/lib/git.test.ts diff --git a/tests/lib/git.test.ts b/tests/lib/git.test.ts new file mode 100644 index 0000000..254bcda --- /dev/null +++ b/tests/lib/git.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as cp from "child_process"; + +// Mock child_process +vi.mock("child_process", () => ({ + execFileSync: vi.fn(), + execSync: vi.fn(), +})); + +// Mock files module to provide PROJECT_DIR +vi.mock("../../src/lib/files.js", () => ({ + PROJECT_DIR: "/mock/project", +})); + +import { run, shell, getBranch, getStatus, getRecentCommits, getLastCommit, getLastCommitTime, getDiffFiles, getStagedFiles, getDiffStat } from "../../src/lib/git.js"; + +const mockedExecFileSync = vi.mocked(cp.execFileSync); +const mockedExecSync = vi.mocked(cp.execSync); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("run()", () => { + it("accepts an array of args and calls execFileSync with git", () => { + mockedExecFileSync.mockReturnValue("output\n" as any); + const result = run(["status", "--short"]); + expect(mockedExecFileSync).toHaveBeenCalledWith("git", ["status", "--short"], expect.objectContaining({ cwd: "/mock/project" })); + expect(result).toBe("output"); + }); + + it("accepts a string and splits on whitespace", () => { + mockedExecFileSync.mockReturnValue("ok\n" as any); + run("log --oneline -5"); + expect(mockedExecFileSync).toHaveBeenCalledWith("git", ["log", "--oneline", "-5"], expect.any(Object)); + }); + + it("returns timeout message when process is killed", () => { + const err = new Error("timed out") as any; + err.killed = true; + mockedExecFileSync.mockImplementation(() => { throw err; }); + expect(run(["status"])).toMatch(/timed out/); + }); + + it("returns stderr on failure", () => { + const err = new Error("fail") as any; + err.stderr = "fatal: not a git repo\n"; + err.stdout = ""; + err.status = 128; + mockedExecFileSync.mockImplementation(() => { throw err; }); + expect(run(["status"])).toBe("fatal: not a git repo"); + }); + + it("returns ENOENT message when git is not found", () => { + const err = new Error("fail") as any; + err.code = "ENOENT"; + err.stdout = ""; + err.stderr = ""; + mockedExecFileSync.mockImplementation(() => { throw err; }); + expect(run(["status"])).toBe("[git not found]"); + }); + + it("returns generic failure message when no output", () => { + const err = new Error("fail") as any; + err.status = 1; + err.stdout = ""; + err.stderr = ""; + mockedExecFileSync.mockImplementation(() => { throw err; }); + expect(run(["bad-cmd"])).toMatch(/command failed/); + }); +}); + +describe("shell()", () => { + it("runs arbitrary command via execSync", () => { + mockedExecSync.mockReturnValue("hello\n" as any); + const result = shell("echo hello"); + expect(mockedExecSync).toHaveBeenCalledWith("echo hello", expect.objectContaining({ cwd: "/mock/project" })); + expect(result).toBe("hello"); + }); + + it("returns empty string on failure", () => { + mockedExecSync.mockImplementation(() => { throw new Error("boom"); }); + expect(shell("bad-cmd")).toBe(""); + }); + + it("respects custom timeout", () => { + mockedExecSync.mockReturnValue("" as any); + shell("slow-cmd", { timeout: 30000 }); + expect(mockedExecSync).toHaveBeenCalledWith("slow-cmd", expect.objectContaining({ timeout: 30000 })); + }); +}); + +describe("convenience functions", () => { + it("getBranch calls git branch --show-current", () => { + mockedExecFileSync.mockReturnValue("main\n" as any); + expect(getBranch()).toBe("main"); + expect(mockedExecFileSync).toHaveBeenCalledWith("git", ["branch", "--show-current"], expect.any(Object)); + }); + + it("getStatus calls git status --short", () => { + mockedExecFileSync.mockReturnValue("M file.ts\n" as any); + expect(getStatus()).toBe("M file.ts"); + }); + + it("getRecentCommits defaults to 5", () => { + mockedExecFileSync.mockReturnValue("abc123 msg\n" as any); + getRecentCommits(); + expect(mockedExecFileSync).toHaveBeenCalledWith("git", ["log", "--oneline", "-5"], expect.any(Object)); + }); + + it("getRecentCommits accepts custom count", () => { + mockedExecFileSync.mockReturnValue("" as any); + getRecentCommits(10); + expect(mockedExecFileSync).toHaveBeenCalledWith("git", ["log", "--oneline", "-10"], expect.any(Object)); + }); + + it("getLastCommit returns single line", () => { + mockedExecFileSync.mockReturnValue("abc123 fix stuff\n" as any); + expect(getLastCommit()).toBe("abc123 fix stuff"); + }); + + it("getLastCommitTime returns timestamp", () => { + mockedExecFileSync.mockReturnValue("2026-03-12 10:00:00 -0700\n" as any); + expect(getLastCommitTime()).toBe("2026-03-12 10:00:00 -0700"); + }); + + it("getStagedFiles returns staged file list", () => { + mockedExecFileSync.mockReturnValue("src/index.ts\nsrc/lib/git.ts\n" as any); + expect(getStagedFiles()).toBe("src/index.ts\nsrc/lib/git.ts"); + }); +}); + +describe("getDiffFiles()", () => { + it("returns diff output on success", () => { + mockedExecFileSync.mockReturnValue("file1.ts\nfile2.ts\n" as any); + expect(getDiffFiles()).toBe("file1.ts\nfile2.ts"); + }); + + it("falls back to HEAD~1 when default ref fails", () => { + mockedExecFileSync + .mockReturnValueOnce("[command failed: ...]" as any) + .mockReturnValueOnce("fallback.ts\n" as any); + expect(getDiffFiles()).toBe("fallback.ts"); + }); + + it("returns 'no commits' when both refs fail", () => { + mockedExecFileSync + .mockReturnValueOnce("[command failed]" as any) + .mockReturnValueOnce("[command failed]" as any); + expect(getDiffFiles()).toBe("no commits"); + }); +}); + +describe("getDiffStat()", () => { + it("returns diff stat on success", () => { + mockedExecFileSync.mockReturnValue(" 2 files changed, 10 insertions(+)\n" as any); + expect(getDiffStat()).toBe("2 files changed, 10 insertions(+)"); + }); + + it("falls back to HEAD~3 when default ref fails", () => { + mockedExecFileSync + .mockReturnValueOnce("[command failed]" as any) + .mockReturnValueOnce("1 file changed\n" as any); + expect(getDiffStat()).toBe("1 file changed"); + }); + + it("returns fallback message when both fail", () => { + mockedExecFileSync + .mockReturnValueOnce("[command failed]" as any) + .mockReturnValueOnce("[command failed]" as any); + expect(getDiffStat()).toBe("no diff stats available"); + }); +});