diff --git a/README.md b/README.md index 6d03f5d..9472e34 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml index f59170f..92631cc 100644 --- a/examples/.preflight/config.yml +++ b/examples/.preflight/config.yml @@ -1,35 +1,49 @@ -# .preflight/config.yml — Drop this in your project root +# ============================================================================= +# Preflight Configuration — config.yml +# ============================================================================= +# Copy this file to: /.preflight/config.yml +# All settings are optional — preflight works out of the box without config. # -# This is an example config for a typical Next.js + microservices setup. -# Every field is optional — preflight works with sensible defaults out of the box. -# Commit this to your repo so the whole team gets the same preflight behavior. - -# Profile controls how much detail preflight returns. -# "minimal" — only flags ambiguous+ prompts, skips clarification detail -# "standard" — balanced (default) -# "full" — maximum detail on every non-trivial prompt +# Docs: https://github.com/TerminalGravity/preflight#configuration-reference +# ============================================================================= + +# Profile controls which tools are active. +# minimal → just preflight_check (the unified entry point) +# standard → core tools + session search + scorecards (recommended) +# full → everything, including contracts and correction patterns profile: standard -# Related projects for cross-service awareness. -# Preflight will search these for shared types, routes, and contracts -# so it can warn you when a change might break a consumer. +# Related projects for cross-service context. +# When you reference an API type or route that lives in another repo, +# preflight will search these projects for matching contracts/types. related_projects: - - path: /Users/you/code/auth-service - alias: auth - - path: /Users/you/code/billing-api - alias: billing - - path: /Users/you/code/shared-types - alias: types - -# Behavioral thresholds — tune these to your workflow + # Uncomment and edit to match your setup: + # - path: /Users/you/projects/auth-service + # alias: auth + # - path: /Users/you/projects/api-gateway + # alias: gateway + +# Thresholds — tune these to match your workflow. thresholds: - session_stale_minutes: 30 # Warn if no activity for this long - max_tool_calls_before_checkpoint: 100 # Suggest a checkpoint after N tool calls - correction_pattern_threshold: 3 # Min corrections before flagging a pattern + # Minutes before a session is flagged as "stale" by session_health + session_stale_minutes: 30 + + # Tool calls before checkpoint suggests saving context + max_tool_calls_before_checkpoint: 100 + + # How many times a correction pattern repeats before triggering a warning + correction_pattern_threshold: 3 # Embedding provider for semantic search over session history. -# "local" uses Xenova transformers (no API key needed, runs on CPU). -# "openai" uses text-embedding-3-small (faster, needs OPENAI_API_KEY). +# local → runs Xenova/all-MiniLM-L6-v2 on your machine (free, private, ~90MB first download) +# openai → uses text-embedding-3-small via API (faster, requires OPENAI_API_KEY) +# ollama → uses a local Ollama model (requires ollama serve + ollama pull all-minilm) embeddings: provider: local - # openai_api_key: sk-... # Uncomment if using openai provider + + # Only needed if provider is openai: + # openai_api_key: sk-... (or set OPENAI_API_KEY env var — preferred) + + # Only needed if provider is ollama: + # ollama_model: all-minilm + # ollama_url: http://localhost:11434 diff --git a/examples/.preflight/contracts/api.yml b/examples/.preflight/contracts/api.yml index 512543f..0bf41e8 100644 --- a/examples/.preflight/contracts/api.yml +++ b/examples/.preflight/contracts/api.yml @@ -1,17 +1,17 @@ -# .preflight/contracts/api.yml — Manual contract definitions +# ============================================================================= +# Manual Contract Definitions — contracts/api.yml +# ============================================================================= +# Define types, interfaces, and routes that preflight should know about +# for cross-service awareness. These supplement auto-extraction. # -# Define shared types and interfaces that preflight should know about. -# These supplement auto-extracted contracts from your codebase. -# Manual definitions win on name conflicts with auto-extracted ones. -# -# Why manual contracts? -# - Document cross-service interfaces that live in docs, not code -# - Define contracts for external APIs your services consume -# - Pin down types that are implicit (e.g., event payloads) +# Copy to: /.preflight/contracts/api.yml +# You can split into multiple files: contracts/auth.yml, contracts/billing.yml, etc. +# ============================================================================= +# Example: a shared interface used across frontend and backend - name: User kind: interface - description: Core user model shared across all services + description: Core user object returned by /api/users endpoints fields: - name: id type: string @@ -19,40 +19,36 @@ - name: email type: string required: true - - name: tier - type: "'free' | 'pro' | 'enterprise'" - required: true - - name: createdAt - type: Date + - name: role + type: "'admin' | 'member' | 'viewer'" required: true + - name: teamId + type: string + required: false -- name: AuthToken - kind: interface - description: JWT payload structure from auth-service +# Example: an API route contract +- name: POST /api/users/invite + kind: route + description: Sends a team invitation email fields: - - name: userId + - name: email type: string required: true - - name: permissions - type: string[] + - name: role + type: "'member' | 'viewer'" required: true - - name: expiresAt - type: number + - name: teamId + type: string required: true -- name: WebhookPayload - kind: interface - description: Standard webhook envelope for inter-service events +# Example: a shared enum used in multiple services +- name: SubscriptionTier + kind: enum + description: Billing tiers — must stay in sync between billing-service and frontend fields: - - name: event + - name: FREE type: string - required: true - - name: timestamp + - name: PRO type: string - required: true - - name: data - type: Record - required: true - - name: source + - name: ENTERPRISE type: string - required: true diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml index b3d394e..0c8a0e8 100644 --- a/examples/.preflight/triage.yml +++ b/examples/.preflight/triage.yml @@ -1,45 +1,46 @@ -# .preflight/triage.yml — Controls how preflight classifies your prompts -# -# The triage engine routes prompts into categories: -# TRIVIAL → pass through (commit, format, lint) -# CLEAR → well-specified, no intervention needed -# AMBIGUOUS → needs clarification before proceeding -# MULTI-STEP → complex task, preflight suggests a plan -# CROSS-SERVICE → touches multiple projects, pulls in contracts -# -# Customize the keywords below to match your domain. +# ============================================================================= +# Preflight Triage Rules — triage.yml +# ============================================================================= +# Controls how preflight_check classifies prompts before routing them. +# Copy to: /.preflight/triage.yml +# ============================================================================= rules: - # Prompts containing these words are always flagged as AMBIGUOUS. - # Add domain-specific terms that tend to produce vague prompts. + # Prompts containing these keywords always get a full preflight check, + # even if they look simple. Use for high-risk areas of your codebase. always_check: - - rewards - - permissions - - migration - - schema - - pricing # example: your billing domain - - onboarding # example: multi-step user flows + - migration # DB migrations are easy to get wrong + - permissions # Auth/access control — never wing it + - billing # Money-touching code needs extra care + - schema # Data model changes ripple everywhere + # Add your own: + # - deployment + # - encryption - # Prompts containing these words skip checks entirely (TRIVIAL). - # These are safe, mechanical tasks that don't need guardrails. + # These prompts skip preflight entirely (pass-through). + # Use for quick, unambiguous commands that don't need guardrails. skip: - commit - format - lint - - prettier - - "git push" + - "git status" + # Add your own: + # - "run tests" - # Prompts containing these words trigger CROSS-SERVICE classification. - # Preflight will search related_projects for relevant types and routes. + # Keywords that trigger cross-service contract search. + # When a prompt mentions these, preflight looks in related_projects + # (from config.yml) for matching types, routes, and interfaces. cross_service_keywords: - auth - notification - - event - webhook - - billing # matches the related_project alias + - event + # Add your own: + # - payment + # - analytics -# How aggressively to classify prompts. -# "relaxed" — more prompts pass as clear (experienced users) -# "standard" — balanced (default) -# "strict" — more prompts flagged as ambiguous (new teams, complex codebases) +# Overall strictness for triage classification: +# relaxed → more prompts pass through without checks +# standard → balanced (recommended) +# strict → most prompts get checked; good for teams or unfamiliar codebases strictness: standard diff --git a/src/tools/checkpoint.ts b/src/tools/checkpoint.ts index e086f01..8e603b8 100644 --- a/src/tools/checkpoint.ts +++ b/src/tools/checkpoint.ts @@ -63,32 +63,37 @@ ${dirty || "clean"} const shortSummary = summary.split("\n")[0].slice(0, 72); const commitMsg = `checkpoint: ${shortSummary}`; - let addCmd: string; + let skipAdd = false; + let addArgs: 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 + skipAdd = true; // already staged + addArgs = []; break; } case "all": - addCmd = "git add -A"; + addArgs = ["add", "-A"]; break; case "tracked": default: - addCmd = "git add -u"; + addArgs = ["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`); + run(["add", checkpointFile]); + if (!skipAdd && addArgs!.length > 0) { + run(addArgs!); + } + const result = run(["commit", "-m", commitMsg]); 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/sequence-tasks.ts b/src/tools/sequence-tasks.ts index 22dea23..ba0119a 100644 --- a/src/tools/sequence-tasks.ts +++ b/src/tools/sequence-tasks.ts @@ -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 = run(["ls-files"]); 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..e79c9b5 100644 --- a/src/tools/session-handoff.ts +++ b/src/tools/session-handoff.ts @@ -2,14 +2,35 @@ 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 { PROJECT_DIR } 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"); + try { + execFileSync("which", [cmd], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); + return true; + } catch { + return false; + } +} + +/** Run an external (non-git) command safely, returning stdout or empty string on failure */ +function runExternal(cmd: string, args: string[]): string { + try { + return execFileSync(cmd, args, { + cwd: PROJECT_DIR, + encoding: "utf-8", + timeout: 15000, + maxBuffer: 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + return ""; + } } export function registerSessionHandoff(server: McpServer): void { @@ -44,7 +65,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 = runExternal("gh", ["pr", "list", "--state", "open", "--json", "number,title,headRefName"]) || "[]"; 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..a65f114 100644 --- a/src/tools/sharpen-followup.ts +++ b/src/tools/sharpen-followup.ts @@ -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(); - 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]; @@ -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);