From be8c525bfb829d5449bb6715d8aa0c62a3f66eea Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Tue, 3 Mar 2026 10:46:14 -0700 Subject: [PATCH] feat: add preflight_init tool + .preflight/ config directory support (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New preflight_init tool scaffolds .preflight/ directory with config.yml, triage.yml, rules.md, patterns.json, and contracts/ directory - loadPatterns() now merges version-controlled .preflight/patterns.json with auto-generated patterns (manual patterns take precedence by id) - loadRules() reads .preflight/rules.md and surfaces it in preflight_check output for non-trivial prompts - preflight_check now shows project rules section when rules.md exists - Added test suite for .preflight/ config directory structure - Tool count: 24 → 25 --- package-lock.json | 2 +- src/index.ts | 2 + src/lib/config.ts | 11 ++ src/lib/patterns.ts | 33 +++++- src/tools/preflight-check.ts | 8 +- src/tools/preflight-init.ts | 161 +++++++++++++++++++++++++++++ tests/lib/preflight-config.test.ts | 78 ++++++++++++++ 7 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 src/tools/preflight-init.ts create mode 100644 tests/lib/preflight-config.test.ts diff --git a/package-lock.json b/package-lock.json index 89ef280..4e5a169 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/src/index.ts b/src/index.ts index e7e9d00..1b49a8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { existsSync } from "fs"; // Main entry point import { registerPreflightCheck } from "./tools/preflight-check.js"; +import { registerPreflightInit } from "./tools/preflight-init.js"; // Category 1: Plans import { registerScopeWork } from "./tools/scope-work.js"; // Category 2: Clarification @@ -87,6 +88,7 @@ type RegisterFn = (server: McpServer) => void; const toolRegistry: Array<[string, RegisterFn]> = [ ["preflight_check", registerPreflightCheck], + ["preflight_init", registerPreflightInit], ["scope_work", registerScopeWork], ["clarify_intent", registerClarifyIntent], ["enrich_agent_task", registerEnrichAgentTask], diff --git a/src/lib/config.ts b/src/lib/config.ts index fc9d8f2..ac435ed 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -154,4 +154,15 @@ export function getRelatedProjects(): string[] { /** Check if .preflight/ directory exists */ export function hasPreflightConfig(): boolean { return existsSync(join(PROJECT_DIR, ".preflight")); +} + +/** Load .preflight/rules.md content, or null if not present. */ +export function loadRules(): string | null { + const rulesPath = join(PROJECT_DIR, ".preflight", "rules.md"); + if (!existsSync(rulesPath)) return null; + try { + return readFileSync(rulesPath, "utf-8"); + } catch { + return null; + } } \ No newline at end of file diff --git a/src/lib/patterns.ts b/src/lib/patterns.ts index 350e048..d0b2184 100644 --- a/src/lib/patterns.ts +++ b/src/lib/patterns.ts @@ -5,6 +5,9 @@ */ import { readLog, saveState, loadState } from "./state.js"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { PROJECT_DIR } from "./files.js"; // ── Types ────────────────────────────────────────────────────────────────── @@ -163,10 +166,34 @@ export function savePatterns(patterns: CorrectionPattern[]): void { saveState("patterns", { patterns, updated: new Date().toISOString() }); } -/** Load patterns from state. */ +/** Load patterns from state, merged with .preflight/patterns.json if present. */ export function loadPatterns(): CorrectionPattern[] { - const state = loadState("patterns"); - return (state.patterns as CorrectionPattern[]) || []; + const autoPatterns = (loadState("patterns").patterns as CorrectionPattern[]) || []; + + // Merge version-controlled patterns from .preflight/patterns.json + const preflightPath = join(PROJECT_DIR, ".preflight", "patterns.json"); + if (existsSync(preflightPath)) { + try { + const raw = JSON.parse(readFileSync(preflightPath, "utf-8")); + const manual: CorrectionPattern[] = Array.isArray(raw) ? raw : (raw.patterns || []); + // Merge: manual patterns take precedence by id + const byId = new Map(autoPatterns.map(p => [p.id, p])); + for (const mp of manual) { + if (byId.has(mp.id)) { + // Manual overrides auto — merge frequency (keep higher) + const existing = byId.get(mp.id)!; + byId.set(mp.id, { ...existing, ...mp, frequency: Math.max(existing.frequency, mp.frequency) }); + } else { + byId.set(mp.id, mp); + } + } + return [...byId.values()]; + } catch { + // Silently fall back to auto patterns on parse error + } + } + + return autoPatterns; } /** diff --git a/src/tools/preflight-check.ts b/src/tools/preflight-check.ts index 8c9121a..bd4fa38 100644 --- a/src/tools/preflight-check.ts +++ b/src/tools/preflight-check.ts @@ -8,7 +8,7 @@ import { PROJECT_DIR } from "../lib/files.js"; import { run, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js"; import { now } from "../lib/state.js"; import { findWorkspaceDocs } from "../lib/files.js"; -import { getConfig } from "../lib/config.js"; +import { getConfig, loadRules } from "../lib/config.js"; import { searchSemantic } from "../lib/timeline-db.js"; import { basename, join } from "path"; import { loadPatterns, matchPatterns, formatPatternMatches } from "../lib/patterns.js"; @@ -223,6 +223,12 @@ export function registerPreflightCheck(server: McpServer): void { `_Reasons: ${triage.reasons.join("; ")}_`, ]; + // --- Project rules --- + const rules = loadRules(); + if (rules) { + sections.push("", "## Project Rules (.preflight/rules.md)", rules.trim()); + } + // --- Pattern warnings --- if (patternMatches.length > 0) { sections.push(""); diff --git a/src/tools/preflight-init.ts b/src/tools/preflight-init.ts new file mode 100644 index 0000000..a1662c0 --- /dev/null +++ b/src/tools/preflight-init.ts @@ -0,0 +1,161 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { join } from "path"; +import { PROJECT_DIR } from "../lib/files.js"; + +const CONFIG_YML = `# .preflight/config.yml — Preflight configuration +# See: https://github.com/TerminalGravity/preflight + +# Profile controls check depth: minimal | standard | full +profile: standard + +# Related projects for cross-service awareness +# related_projects: +# - path: ../auth-service +# alias: auth +# - path: ../payments +# alias: payments + +# Thresholds +thresholds: + session_stale_minutes: 30 + max_tool_calls_before_checkpoint: 100 + correction_pattern_threshold: 3 + +# Embedding provider: local | openai +embeddings: + provider: local +`; + +const TRIAGE_YML = `# .preflight/triage.yml — Triage classification rules +# Controls how prompts are classified before deciding what checks to run. + +strictness: standard # relaxed | standard | strict + +rules: + # Always run full preflight for prompts matching these keywords + always_check: + - migration + - schema + - permissions + - rewards + + # Skip preflight entirely for these (fast-pass) + skip: + - commit + - format + - lint + + # Keywords that trigger cross-service checks + cross_service_keywords: + - auth + - notification + - event + - webhook +`; + +const RULES_MD = `# Project Rules + + + + +## Examples (delete these and add your own) + +- Always run \`npm test\` before committing +- Never modify the migrations table directly +- Use the \`logger\` module, not \`console.log\` +`; + +const PATTERNS_JSON = `[ + { + "id": "example-wrong-import", + "pattern": "Used default import instead of named import", + "keywords": ["import", "default", "named"], + "frequency": 1, + "lastSeen": "", + "context": "Example pattern — delete or replace with real patterns", + "examples": ["import Foo from './foo' should be import { Foo } from './foo'"] + } +] +`; + +const GITIGNORE = `# Auto-generated patterns go in preflight-state, not here +# This directory is for version-controlled config only +`; + +export function registerPreflightInit(server: McpServer): void { + server.tool( + "preflight_init", + "Scaffold a .preflight/ config directory in the current project. Creates config.yml, triage.yml, rules.md, contracts/, and patterns.json with sensible defaults.", + { + project_dir: z.string().optional().describe("Project directory (defaults to current working directory)"), + force: z.boolean().default(false).describe("Overwrite existing files"), + }, + async ({ project_dir, force }) => { + const dir = project_dir || PROJECT_DIR; + const preflightDir = join(dir, ".preflight"); + const contractsDir = join(preflightDir, "contracts"); + + const created: string[] = []; + const skipped: string[] = []; + + // Create directories + if (!existsSync(preflightDir)) { + mkdirSync(preflightDir, { recursive: true }); + created.push(".preflight/"); + } + if (!existsSync(contractsDir)) { + mkdirSync(contractsDir, { recursive: true }); + created.push(".preflight/contracts/"); + } + + // Write files + const files: [string, string][] = [ + ["config.yml", CONFIG_YML], + ["triage.yml", TRIAGE_YML], + ["rules.md", RULES_MD], + ["patterns.json", PATTERNS_JSON], + [".gitignore", GITIGNORE], + ]; + + for (const [name, content] of files) { + const filePath = join(preflightDir, name); + if (existsSync(filePath) && !force) { + skipped.push(name); + } else { + writeFileSync(filePath, content, "utf-8"); + created.push(name); + } + } + + const lines: string[] = [ + `# 🛫 Preflight Config Initialized`, + "", + `Directory: \`${preflightDir}\``, + "", + ]; + + if (created.length > 0) { + lines.push(`**Created:** ${created.map(f => `\`${f}\``).join(", ")}`); + } + if (skipped.length > 0) { + lines.push(`**Skipped (already exist):** ${skipped.map(f => `\`${f}\``).join(", ")} — use \`force: true\` to overwrite`); + } + + lines.push( + "", + "## Next Steps", + "1. Edit `config.yml` to set your profile and related projects", + "2. Customize `triage.yml` with project-specific keywords", + "3. Add team conventions to `rules.md`", + "4. Drop API contracts/schemas into `contracts/`", + "5. Commit `.preflight/` to version control", + "", + "Auto-generated correction patterns (from `log_correction`) merge with `patterns.json` at runtime.", + ); + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } + ); +} diff --git a/tests/lib/preflight-config.test.ts b/tests/lib/preflight-config.test.ts new file mode 100644 index 0000000..cf1b39c --- /dev/null +++ b/tests/lib/preflight-config.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +// We test the config and patterns loading with .preflight/ directory + +describe(".preflight/ config directory", () => { + const testDir = join(tmpdir(), "preflight-config-test-" + Date.now()); + const preflightDir = join(testDir, ".preflight"); + + beforeEach(() => { + mkdirSync(preflightDir, { recursive: true }); + mkdirSync(join(preflightDir, "contracts"), { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("should create the .preflight directory structure", () => { + expect(existsSync(preflightDir)).toBe(true); + expect(existsSync(join(preflightDir, "contracts"))).toBe(true); + }); + + it("should parse config.yml correctly", () => { + writeFileSync( + join(preflightDir, "config.yml"), + `profile: full\nthresholds:\n session_stale_minutes: 60\n`, + "utf-8" + ); + const content = require("fs").readFileSync(join(preflightDir, "config.yml"), "utf-8"); + expect(content).toContain("profile: full"); + expect(content).toContain("session_stale_minutes: 60"); + }); + + it("should parse triage.yml correctly", () => { + writeFileSync( + join(preflightDir, "triage.yml"), + `strictness: strict\nrules:\n always_check:\n - deploy\n`, + "utf-8" + ); + const content = require("fs").readFileSync(join(preflightDir, "triage.yml"), "utf-8"); + expect(content).toContain("strictness: strict"); + expect(content).toContain("deploy"); + }); + + it("should parse patterns.json correctly", () => { + const patterns = [ + { + id: "test-pattern", + pattern: "Forgot to add return type", + keywords: ["return", "type"], + frequency: 3, + lastSeen: "2026-01-01", + context: "TypeScript function definitions", + examples: ["function foo() should be function foo(): string"], + }, + ]; + writeFileSync(join(preflightDir, "patterns.json"), JSON.stringify(patterns), "utf-8"); + const parsed = JSON.parse( + require("fs").readFileSync(join(preflightDir, "patterns.json"), "utf-8") + ); + expect(parsed).toHaveLength(1); + expect(parsed[0].id).toBe("test-pattern"); + expect(parsed[0].frequency).toBe(3); + }); + + it("should read rules.md content", () => { + writeFileSync( + join(preflightDir, "rules.md"), + "# Rules\n- Always run tests before committing\n", + "utf-8" + ); + const content = require("fs").readFileSync(join(preflightDir, "rules.md"), "utf-8"); + expect(content).toContain("Always run tests"); + }); +});