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
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