Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,18 @@ The pattern is always the same: vague prompt → Claude guesses → wrong output

## Quick Start

### Option A: npx (fastest — no install)
### Option A: Interactive setup (recommended)

The init wizard creates your `.mcp.json` and `.preflight/` config in one step:

```bash
cd /path/to/your/project
npx preflight-dev init
```

It walks you through profile selection (minimal/standard/full), embedding provider, and config directory setup — then writes everything for you. Just restart Claude Code when it's done.

### Option B: One-liner (fastest — no wizard)

```bash
claude mcp add preflight -- npx -y preflight-dev-serve
Expand All @@ -90,7 +101,7 @@ claude mcp add preflight \
-- npx -y preflight-dev-serve
```

### Option B: Clone & configure manually
### Option C: Clone & configure manually

```bash
git clone https://github.com/TerminalGravity/preflight.git
Expand All @@ -115,7 +126,7 @@ Add to your project's `.mcp.json`:

Restart Claude Code. The tools activate automatically.

### Option C: npm (global)
### Option D: npm (global)

```bash
npm install -g preflight-dev
Expand Down
60 changes: 60 additions & 0 deletions src/lib/git.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,67 @@
import { execFileSync } from "child_process";
import { readFileSync } from "fs";
import { PROJECT_DIR } from "./files.js";
import type { RunError } from "../types.js";

/**
* Run an arbitrary command safely using execFileSync (no shell).
* Returns stdout on success, 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 ?? "?"})]`;
}
}

/**
* Count lines in a file using Node.js (replaces `wc -l < file`).
*/
export function countLines(filePath: string): number {
try {
const content = readFileSync(filePath, "utf-8");
return content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
} catch {
return 0;
}
}

/**
* Count bytes in a file using Node.js (replaces `wc -c < file`).
*/
export function countBytes(filePath: string): number {
try {
return readFileSync(filePath).length;
} catch {
return 0;
}
}

/**
* Read first N lines of a file (replaces `head -N file`).
*/
export function headLines(filePath: string, n: number): string {
try {
const content = readFileSync(filePath, "utf-8");
return content.split("\n").slice(0, n).join("\n");
} catch {
return "could not read file";
}
}

/**
* Run a git command safely using execFileSync (no shell injection).
* Accepts an array of args (preferred) or a string (split on whitespace for backward compat).
Expand Down
8 changes: 6 additions & 2 deletions src/tools/audit-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,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
Expand Down Expand Up @@ -75,7 +76,10 @@ 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 allTracked = run(["ls-files"]);
const testFilesCount = allTracked && !allTracked.startsWith("[")
? allTracked.split("\n").filter(f => /\.(spec|test)\.(ts|tsx)$/.test(f)).length
: 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`;
Expand Down
35 changes: 15 additions & 20 deletions src/tools/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,32 +63,27 @@ ${dirty || "clean"}
const shortSummary = summary.split("\n")[0].slice(0, 72);
const commitMsg = `checkpoint: ${shortSummary}`;

let addCmd: string;
switch (mode) {
case "staged": {
const staged = getStagedFiles();
if (!staged) {
commitResult = "nothing staged — skipped commit (use 'tracked' or 'all' mode, or stage files first)";
}
addCmd = "true"; // noop, already staged
break;
if (mode === "staged") {
const staged = getStagedFiles();
if (!staged) {
commitResult = "nothing staged — skipped commit (use 'tracked' or 'all' mode, or stage files first)";
}
case "all":
addCmd = "git add -A";
break;
case "tracked":
default:
addCmd = "git 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`);
if (result.includes("commit failed") || result.includes("nothing to commit")) {
run(["add", checkpointFile]);
// Stage files based on mode
if (mode === "all") {
run(["add", "-A"]);
} else if (mode === "tracked") {
run(["add", "-u"]);
}
// mode === "staged": already staged, noop
const result = run(["commit", "-m", commitMsg]);
if (result.includes("nothing to commit") || result.startsWith("[command failed")) {
// Rollback: unstage if commit failed
run("git reset HEAD 2>/dev/null");
run(["reset", "HEAD"]);
commitResult = `commit failed: ${result}`;
} else {
commitResult = result;
Expand Down
15 changes: 10 additions & 5 deletions src/tools/clarify-intent.ts
Original file line number Diff line number Diff line change
@@ -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, exec, 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";
Expand Down Expand Up @@ -152,10 +152,15 @@ 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'");
hasTypeErrors = parseInt(typeErrors, 10) > 0;

const testFiles = run("find tests -name '*.spec.ts' -maxdepth 4 2>/dev/null | head -20");
const typeErrorOutput = exec("pnpm", ["tsc", "--noEmit"], { timeout: 30000 });
const typeErrorCount = (typeErrorOutput.match(/error TS/g) || []).length;
const typeErrors = String(typeErrorCount);
hasTypeErrors = typeErrorCount > 0;

const testFilesRaw = run(["ls-files"]);
const testFiles = testFilesRaw && !testFilesRaw.startsWith("[")
? testFilesRaw.split("\n").filter(f => /^tests?\//.test(f) && f.endsWith(".spec.ts")).slice(0, 20).join("\n")
: "";
const failingTests = getTestFailures();
hasTestFailures = failingTests !== "all passing" && failingTests !== "no test report found";

Expand Down
31 changes: 22 additions & 9 deletions src/tools/enrich-agent-task.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getDiffFiles } from "../lib/git.js";
import { run, getDiffFiles, headLines } from "../lib/git.js";
import { PROJECT_DIR } from "../lib/files.js";
import { getConfig, type RelatedProject } from "../lib/config.js";
import { existsSync, readFileSync } from "fs";
import { execFileSync } from "child_process";
import { join, basename } from "path";
import { resolve } from "path";
import { createHash } from "crypto";

/** Sanitize user input for safe use in shell commands */
Expand All @@ -29,31 +30,43 @@ 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`);
const allFiles = run(["ls-files", "--", `${safeArea}*`]);
if (allFiles && !allFiles.startsWith("[")) {
return allFiles.split("\n").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 matched = allFiles.split("\n").filter(f => f.toLowerCase().includes(safeArea.toLowerCase())).slice(0, 20);
if (matched.length > 0) return matched.join("\n");
}

// Fallback to recently changed files
return getDiffFiles("HEAD~3");
}

/** 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 testPattern = /\.(spec|test)\.(ts|tsx|js|jsx)$/;
const testFiles = allFiles.split("\n").filter(f => testPattern.test(f));

if (!area) return testFiles.slice(0, 10).join("\n");

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 safeArea = shellEscape(area.split(/\s+/)[0]).toLowerCase();
const areaTests = testFiles.filter(f => f.toLowerCase().includes(safeArea)).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'`);
return headLines(resolve(PROJECT_DIR, firstFile), 30);
}

// ---------------------------------------------------------------------------
Expand Down
5 changes: 4 additions & 1 deletion src/tools/sequence-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ 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 && !gitFilesRaw.startsWith("[")
? gitFilesRaw.split("\n").slice(0, 1000).join("\n")
: "";
const knownDirs = new Set<string>();
for (const f of gitFiles.split("\n").filter(Boolean)) {
const parts = f.split("/");
Expand Down
8 changes: 4 additions & 4 deletions src/tools/session-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, exec, 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 = exec("which", [cmd]);
return !!result && !result.startsWith("[");
}

export function registerSessionHandoff(server: McpServer): void {
Expand Down Expand Up @@ -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 = exec("gh", ["pr", "list", "--state", "open", "--json", "number,title,headRefName"]);
if (openPRs && openPRs !== "[]") {
sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``);
}
Expand Down
16 changes: 8 additions & 8 deletions src/tools/sharpen-followup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 commands: string[][] = [
["diff", "--name-only", "HEAD~1", "HEAD"],
["diff", "--name-only", "--cached"],
["diff", "--name-only"],
];
const results = new Set<string>();
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 commands) {
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];
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 6 additions & 13 deletions src/tools/token-audit.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
// CATEGORY 5: token_audit — Token Efficiency
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } from "../lib/git.js";
import { run, countLines, countBytes } 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 { join } from "path";

/** Shell-escape a filename for safe interpolation */
function shellEscape(s: string): string {
return s.replace(/'/g, "'\\''");
}

/**
* Grade thresholds rationale:
* - A (0-10): Minimal waste — small diffs, targeted reads, lean context
Expand Down Expand Up @@ -39,8 +34,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;

Expand All @@ -63,8 +58,7 @@ 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;
const lines = countLines(join(PROJECT_DIR, f));
estimatedContextTokens += lines * AVG_LINE_BYTES * AVG_TOKENS_PER_BYTE;
if (lines > 500) {
largeFiles.push(`${f} (${lines} lines)`);
Expand All @@ -80,8 +74,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 = countBytes(join(PROJECT_DIR, "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");
Expand Down Expand Up @@ -139,7 +132,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)}'`);
: (() => { try { const buf = readFileSync(toolLogPath, "utf-8"); return buf.slice(-MAX_TOOL_LOG_BYTES); } catch { return ""; } })();

const lines = raw.trim().split("\n").filter(Boolean);
totalToolCalls = lines.length;
Expand Down
Loading