Skip to content
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A 24-tool MCP server for Claude Code that catches ambiguous instructions before
[![MCP](https://img.shields.io/badge/MCP-Compatible-blueviolet)](https://modelcontextprotocol.io/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![npm](https://img.shields.io/npm/v/preflight-dev)](https://www.npmjs.com/package/preflight-dev)
[![Node 18+](https://img.shields.io/badge/node-18%2B-brightgreen?logo=node.js&logoColor=white)](https://nodejs.org/)
[![Node 20+](https://img.shields.io/badge/node-20%2B-brightgreen?logo=node.js&logoColor=white)](https://nodejs.org/)

[Quick Start](#quick-start) · [How It Works](#how-it-works) · [Tool Reference](#tool-reference) · [Configuration](#configuration) · [Scoring](#the-12-category-scorecard)

Expand Down Expand Up @@ -124,6 +124,16 @@ claude mcp add preflight -- preflight-dev-serve

> **Note:** `preflight-dev` runs the interactive setup wizard. `preflight-dev-serve` starts the MCP server — that's what you want in your Claude Code config.

### Make Claude use preflight automatically

Add preflight rules to your project's `CLAUDE.md` so Claude runs `preflight_check` on every prompt without you asking:

```bash
cp /path/to/preflight/examples/CLAUDE.md your-project/CLAUDE.md
```

See [`examples/CLAUDE.md`](examples/CLAUDE.md) for a ready-to-use template with recommended rules for when to preflight, session hygiene, and skip-lists.

---

## How It Works
Expand Down
30 changes: 30 additions & 0 deletions examples/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# CLAUDE.md — Preflight Integration Example
#
# Drop this into your project's CLAUDE.md (or .claude/CLAUDE.md) to make
# Claude Code automatically use preflight tools during your sessions.
# Customize the rules below to match your workflow.

## Preflight Rules

Before starting any non-trivial task, run `preflight_check` with my prompt. This catches vague instructions before they waste tokens on wrong→fix cycles.

### When to use preflight tools:

- **Every prompt**: `preflight_check` triages automatically — let it decide what's needed
- **Before multi-file changes**: Run `scope_work` to get a phased plan
- **Before sub-agent tasks**: Use `enrich_agent_task` to add context
- **After making a mistake**: Use `log_correction` so preflight learns the pattern
- **Before ending a session**: Run `checkpoint` to save state for next time
- **When I say "fix it" or "do the others"**: Use `sharpen_followup` to resolve what I actually mean

### Session hygiene:

- Run `check_session_health` if we've been going for a while without committing
- If I ask about something we did before, use `search_history` to find it
- Before declaring a task done, run `verify_completion` (type check + tests)

### Don't preflight these:

- Simple git commands (commit, push, status)
- Formatting / linting
- Reading files I explicitly named
16 changes: 16 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ The `.preflight/` directory contains example configuration files you can copy in
└── api.yml # Manual contract definitions for cross-service types
```

## `CLAUDE.md` Integration

The `CLAUDE.md` file tells Claude Code how to behave in your project. Adding preflight rules here makes Claude automatically use preflight tools without you having to ask.

```bash
# Copy the example into your project:
cp /path/to/preflight/examples/CLAUDE.md my-project/CLAUDE.md

# Or append to your existing CLAUDE.md:
cat /path/to/preflight/examples/CLAUDE.md >> my-project/CLAUDE.md
```

This is the **recommended way** to integrate preflight — once it's in your `CLAUDE.md`, every session automatically runs `preflight_check` on your prompts.

---

### Quick setup

```bash
Expand Down
40 changes: 40 additions & 0 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,46 @@ import { join, dirname } from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";

// Handle --help and --version before launching interactive wizard
const args = process.argv.slice(2);

if (args.includes("--help") || args.includes("-h")) {
console.log(`
✈️ preflight-dev — MCP server for Claude Code prompt discipline

Usage:
preflight-dev Interactive setup wizard (creates .mcp.json)
preflight-dev --help Show this help message
preflight-dev --version Show version

The wizard will:
1. Ask you to choose a profile (minimal / standard / full)
2. Optionally create a .preflight/ config directory
3. Write an .mcp.json so Claude Code auto-connects to preflight

After setup, restart Claude Code and preflight tools will appear.

Profiles:
minimal 4 tools — clarify_intent, check_session_health, session_stats, prompt_score
standard 16 tools — all prompt discipline + session_stats + prompt_score
full 20 tools — everything + timeline/vector search (needs LanceDB)

More info: https://github.com/TerminalGravity/preflight
`);
process.exit(0);
}

if (args.includes("--version") || args.includes("-v")) {
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../package.json");
try {
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
console.log(`preflight-dev v${pkg.version}`);
} catch {
console.log("preflight-dev (version unknown)");
}
process.exit(0);
}

const rl = createInterface({ input: process.stdin, output: process.stdout });

function ask(question: string): Promise<string> {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import { registerGenerateScorecard } from "./tools/generate-scorecard.js";
import { registerSearchContracts } from "./tools/search-contracts.js";
import { registerEstimateCost } from "./tools/estimate-cost.js";
import { registerExportTimeline } from "./tools/export-timeline.js";

// Validate related projects from config
function validateRelatedProjects(): void {
Expand All @@ -73,7 +74,7 @@
}

// Load config and validate related projects on startup
const config = getConfig();

Check warning on line 77 in src/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

'config' is assigned a value but never used

Check warning on line 77 in src/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

'config' is assigned a value but never used
validateRelatedProjects();

const profile = getProfile();
Expand Down Expand Up @@ -110,6 +111,7 @@
["generate_scorecard", registerGenerateScorecard],
["estimate_cost", registerEstimateCost],
["search_contracts", registerSearchContracts],
["export_timeline", registerExportTimeline],
];

let registered = 0;
Expand Down
42 changes: 40 additions & 2 deletions src/lib/git.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,52 @@
import { execFileSync } from "child_process";
import { execFileSync, execSync } from "child_process";
import { PROJECT_DIR } from "./files.js";
import type { RunError } from "../types.js";

/**
* Run a shell command string (supports pipes, redirects, etc.).
* Returns stdout on success, descriptive error 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 (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 git command safely using execFileSync (no shell injection).
* Accepts an array of args (preferred) or a string (split on whitespace for backward compat).
* When passed a string, strips a leading "git" if present and removes shell syntax
* (redirections like 2>/dev/null, || clauses) that don't work with execFileSync.
* Returns stdout on success. On failure, returns a descriptive error string.
*/
export function run(argsOrCmd: string | string[], opts: { timeout?: number } = {}): string {
const args = typeof argsOrCmd === "string" ? argsOrCmd.split(/\s+/) : argsOrCmd;
let args: string[];
if (typeof argsOrCmd === "string") {
// Strip shell syntax that doesn't work with execFileSync
let cleaned = argsOrCmd

Check failure on line 39 in src/lib/git.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

'cleaned' is never reassigned. Use 'const' instead

Check failure on line 39 in src/lib/git.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

'cleaned' is never reassigned. Use 'const' instead
.replace(/\s*2>&?\d*\s*>?\s*\/dev\/null/g, "") // 2>/dev/null, 2>&1
.replace(/\s*\|.*$/g, "") // pipe chains
.replace(/\s*\|\|.*$/g, "") // || fallbacks
.trim();
args = cleaned.split(/\s+/).filter(Boolean);
// Strip leading "git" if caller passed it (run already prepends git)
if (args[0] === "git") args = args.slice(1);
} else {
args = argsOrCmd;
}
try {
return execFileSync("git", args, {
cwd: PROJECT_DIR,
Expand Down
95 changes: 95 additions & 0 deletions src/lib/prompt-scoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// =============================================================================
// prompt-scoring — Pure scoring logic extracted from prompt_score tool
// =============================================================================

export interface ScoreResult {
specificity: number;
scope: number;
actionability: number;
doneCondition: number;
total: number;
grade: string;
feedback: string[];
}

export function scorePrompt(text: string): ScoreResult {
const feedback: string[] = [];
let specificity: number;
let scope: number;
let actionability: number;
let doneCondition: number;

// Specificity: file paths, function names, specific identifiers
if (/[/\\][\w.-]+\.\w+/.test(text) || /`[^`]+`/.test(text)) {
specificity = 25;
} else if (/\b(file|component|function|class|module|test|route)\b/i.test(text)) {
specificity = 15;
feedback.push("📁 Name the specific file/function for +10 points");
} else {
specificity = 5;
feedback.push("📁 No specific targets mentioned — which file? which function?");
}

// Scope: bounded task
// Check for bounding keywords first; length alone isn't enough
const hasBoundingKeyword = /\b(only|just|single|one|specific|this)\b/i.test(text);
const hasBroadKeyword = /\b(all|every|entire|whole)\b/i.test(text);

if (hasBoundingKeyword) {
scope = 25;
} else if (hasBroadKeyword) {
scope = 10;
feedback.push("🎯 'All/every' is broad — can you narrow the scope?");
} else if (text.length > 100) {
// Long prompts often imply scope, but not as strong as explicit keywords
scope = 20;
} else {
scope = 10;
feedback.push("🎯 Scope unclear — how much should change?");
}

// Actionability: clear verb
const actionVerbs =
/\b(add|remove|rename|refactor|fix|create|delete|update|change|replace|move|extract|implement|write|test|migrate)\b/i;
if (actionVerbs.test(text)) {
actionability = 25;
} else if (/\b(make|do|handle|work|improve|clean)\b/i.test(text)) {
actionability = 15;
feedback.push("⚡ Vague verb — 'fix' beats 'make work', 'extract' beats 'clean up'");
} else {
actionability = 5;
feedback.push("⚡ What's the action? Use a specific verb (add, remove, rename, etc.)");
}

// Done condition: verifiable outcome
if (
/\b(should|must|expect|assert|return|output|pass|fail|error|log|print|display)\b/i.test(text)
) {
doneCondition = 25;
} else if (/\?$/.test(text.trim())) {
doneCondition = 20; // questions are inherently verifiable
} else {
doneCondition = 5;
feedback.push("✅ No done condition — how will you know it worked?");
}

const total = specificity + scope + actionability + doneCondition;

let grade: string;
if (total >= 90) grade = "A+";
else if (total >= 85) grade = "A";
else if (total >= 80) grade = "A-";
else if (total >= 75) grade = "B+";
else if (total >= 70) grade = "B";
else if (total >= 65) grade = "B-";
else if (total >= 60) grade = "C+";
else if (total >= 55) grade = "C";
else if (total >= 45) grade = "D";
else grade = "F";

if (feedback.length === 0) {
feedback.push("🏆 Excellent prompt! Clear target, scope, action, and done condition.");
}

return { specificity, scope, actionability, doneCondition, total, grade, feedback };
}
6 changes: 3 additions & 3 deletions src/tools/audit-workspace.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -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(Boolean);
const sections: string[] = [];

// Doc freshness
Expand Down Expand Up @@ -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").trim()) || 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
6 changes: 3 additions & 3 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, 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";
Expand Down Expand Up @@ -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'");
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";

Expand Down
6 changes: 3 additions & 3 deletions src/tools/enrich-agent-task.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, 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";
Expand Down Expand Up @@ -42,11 +42,11 @@ 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");
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 */
Expand Down
Loading
Loading