diff --git a/README.md b/README.md index f60fefa..cb437d8 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,12 @@ This prevents the common failure mode: changing a shared type in one service and ## Configuration Reference +> **Want a ready-to-use starting point?** Copy the example configs: +> ```bash +> cp -r examples/.preflight /path/to/your/project/ +> ``` +> See [`examples/.preflight/README.md`](examples/.preflight/README.md) for details. + ### `.preflight/config.yml` Drop this in your project root. Every field is optional — defaults are sensible. diff --git a/examples/.preflight/README.md b/examples/.preflight/README.md new file mode 100644 index 0000000..b8aaf8c --- /dev/null +++ b/examples/.preflight/README.md @@ -0,0 +1,25 @@ +# `.preflight/` Example Config + +Copy this directory into your project root to configure preflight: + +```bash +cp -r examples/.preflight /path/to/your/project/ +``` + +## Files + +| File | Purpose | +|------|---------| +| `config.yml` | Main config — profile, related projects, thresholds, embeddings | +| `triage.yml` | Triage rules — which keywords trigger which classification level | +| `contracts/*.yml` | Manual contract definitions — supplement auto-extraction | + +## Quick Setup + +1. Copy the directory: `cp -r examples/.preflight ./` +2. Edit `config.yml` — set your `related_projects` paths +3. Edit `triage.yml` — add your domain-specific keywords to `always_check` +4. Optionally add contracts in `contracts/` for planned or external APIs +5. Commit `.preflight/` to your repo so your team shares the same config + +All fields are optional. Defaults work well out of the box — only customize what you need. diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml new file mode 100644 index 0000000..0ad12e8 --- /dev/null +++ b/examples/.preflight/config.yml @@ -0,0 +1,29 @@ +# .preflight/config.yml — drop this in your project root +# All fields are optional. Defaults are sensible. +# See: https://github.com/TerminalGravity/preflight#configuration-reference + +# Profile controls overall verbosity +# "minimal" — only flag ambiguous+, skip clarification detail +# "standard" — default behavior +# "full" — maximum detail on every non-trivial prompt +profile: standard + +# Related projects for cross-service awareness +# Preflight will search these projects' indexes when your prompt +# touches shared contracts (types, routes, schemas). +related_projects: + # - path: /absolute/path/to/auth-service + # alias: auth-service + # - path: /absolute/path/to/shared-types + # alias: shared-types + +# Behavioral thresholds +thresholds: + session_stale_minutes: 30 # warn if no activity for this long + max_tool_calls_before_checkpoint: 100 # suggest checkpoint after N tool calls + correction_pattern_threshold: 3 # min corrections before forming a pattern + +# Embedding configuration +embeddings: + provider: local # "local" (Xenova, zero config) or "openai" + # openai_api_key: sk-... # only needed if provider is "openai" diff --git a/examples/.preflight/contracts/api.yml b/examples/.preflight/contracts/api.yml new file mode 100644 index 0000000..754c5da --- /dev/null +++ b/examples/.preflight/contracts/api.yml @@ -0,0 +1,47 @@ +# .preflight/contracts/api.yml — manual contract definitions +# These supplement auto-extracted contracts from your codebase. +# Manual definitions win on name conflicts with auto-extracted ones. +# +# Use this when: +# - You have contracts that aren't in code yet (planned APIs) +# - Auto-extraction misses something important +# - You want to document cross-service agreements explicitly + +- name: User + kind: interface + description: Core user object shared across services + fields: + - name: id + type: string + required: true + - name: email + type: string + required: true + - name: role + type: "'admin' | 'member' | 'viewer'" + required: true + - name: createdAt + type: Date + required: true + +- name: "POST /api/users" + kind: route + description: Create a new user account + fields: + - name: body + type: "{ email: string, role: string }" + required: true + - name: response + type: "{ user: User, token: string }" + required: true + +- name: "GET /api/users/:id" + kind: route + description: Fetch user by ID + fields: + - name: params + type: "{ id: string }" + required: true + - name: response + type: User + required: true diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml new file mode 100644 index 0000000..22b05d3 --- /dev/null +++ b/examples/.preflight/triage.yml @@ -0,0 +1,38 @@ +# .preflight/triage.yml — controls the triage classification engine +# Customize which prompts get flagged, skipped, or escalated. + +rules: + # Prompts containing these words → always at least AMBIGUOUS + # Add domain terms that are too vague without context + always_check: + - rewards + - permissions + - migration + - schema + # - billing # uncomment for your domain + # - onboarding + + # Prompts containing these words → TRIVIAL (pass through immediately) + # Common low-risk commands that don't need analysis + skip: + - commit + - format + - lint + - "git status" + - "git log" + + # Prompts containing these words → CROSS-SERVICE + # Triggers search across related_projects defined in config.yml + cross_service_keywords: + - auth + - notification + - event + - webhook + # - payment + # - analytics + +# How aggressively to classify +# "relaxed" — more prompts pass as clear (faster, less interruption) +# "standard" — balanced (recommended) +# "strict" — more prompts flagged as ambiguous (thorough, more interruptions) +strictness: standard diff --git a/src/lib/config.ts b/src/lib/config.ts index fc9d8f2..1ea416b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -151,6 +151,11 @@ export function getRelatedProjects(): string[] { return getConfig().related_projects.map(p => p.path); } +/** Reset cached config (useful for tests and config reload) */ +export function resetConfig(): void { + _config = null; +} + /** Check if .preflight/ directory exists */ export function hasPreflightConfig(): boolean { return existsSync(join(PROJECT_DIR, ".preflight")); diff --git a/src/lib/preflight.ts b/src/lib/preflight.ts new file mode 100644 index 0000000..c7d85a0 --- /dev/null +++ b/src/lib/preflight.ts @@ -0,0 +1,53 @@ +/** + * Pure helper functions for the preflight_check tool. + * Extracted for testability — no side effects in pure functions. + */ + +/** Extract file paths from prompt text */ +export function extractFilePaths(prompt: string): string[] { + // Match standard paths (src/foo.ts) and dotfiles (.env, .gitignore) + const standard = prompt.match(/[\w\-./\\]+\.\w{1,6}/g) || []; + const dotfiles = prompt.match(/(?:^|\s)(\.[\w\-.]+)/g) || []; + const cleaned = dotfiles.map(s => s.trim()); + return [...new Set([...standard, ...cleaned])]; +} + +/** Detect ambiguity signals in a prompt */ +export function detectAmbiguity(prompt: string): string[] { + const issues: string[] = []; + const filePaths = extractFilePaths(prompt); + + if (/\b(it|them|the thing|that|those|this|these)\b/i.test(prompt)) + issues.push("Contains vague pronouns — clarify what 'it'/'them' refers to"); + if (/\b(fix|update|change|refactor|improve)\b/i.test(prompt) && !filePaths.length) + issues.push("Vague verb without specific file targets"); + if (prompt.trim().length < 40) + issues.push("Very short prompt — likely missing context"); + + return issues; +} + +/** Estimate scope complexity from file paths */ +export function estimateComplexity(filePaths: string[]): "SMALL" | "MEDIUM" | "LARGE" { + const hasMultipleFiles = filePaths.length > 3; + const hasMultipleDirs = new Set(filePaths.map(f => f.split("/")[0])).size > 2; + return hasMultipleFiles && hasMultipleDirs ? "LARGE" : filePaths.length > 1 ? "MEDIUM" : "SMALL"; +} + +/** Split a prompt into sequenced sub-tasks */ +export function splitSubtasks(prompt: string): { task: string; risk: string }[] { + const parts = prompt + .split(/\b(?:then|after that|next|finally)\b|(?:,\s*and\s+)|(?:\band\b(?=\s+(?:update|add|remove|create|fix|change|refactor|implement|deploy)))/i) + .map(s => s.trim()) + .filter(s => s.length > 5); + + if (parts.length <= 1) { + return [{ task: prompt.slice(0, 100), risk: "🟡 MEDIUM" }]; + } + + return parts.map(part => { + const risk = /schema|migrat|database|config|env|deploy/i.test(part) ? "🔴 HIGH" : + /api|route|endpoint/i.test(part) ? "🟡 MEDIUM" : "🟢 LOW"; + return { task: part.charAt(0).toUpperCase() + part.slice(1), risk }; + }); +} diff --git a/src/tools/preflight-check.ts b/src/tools/preflight-check.ts index 8c9121a..0981f37 100644 --- a/src/tools/preflight-check.ts +++ b/src/tools/preflight-check.ts @@ -13,16 +13,12 @@ import { searchSemantic } from "../lib/timeline-db.js"; import { basename, join } from "path"; import { loadPatterns, matchPatterns, formatPatternMatches } from "../lib/patterns.js"; +import { extractFilePaths, detectAmbiguity, estimateComplexity, splitSubtasks } from "../lib/preflight.js"; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** Extract file paths from prompt text */ -function extractFilePaths(prompt: string): string[] { - const matches = prompt.match(/[\w\-./\\]+\.\w{1,6}/g) || []; - return [...new Set(matches)]; -} - /** Verify files exist and return stats */ function verifyFiles(paths: string[]): string[] { const lines: string[] = []; @@ -104,10 +100,7 @@ function buildClarifySection(prompt: string): string[] { } // Ambiguity signals - const issues: string[] = []; - if (/\b(it|them|the thing|that|those|this|these)\b/i.test(prompt)) issues.push("Contains vague pronouns — clarify what 'it'/'them' refers to"); - if (/\b(fix|update|change|refactor|improve)\b/i.test(prompt) && !extractFilePaths(prompt).length) issues.push("Vague verb without specific file targets"); - if (prompt.trim().length < 40) issues.push("Very short prompt — likely missing context"); + const issues = detectAmbiguity(prompt); if (issues.length > 0) { sections.push(`### ⚠️ Clarification Needed\n${issues.map(i => `- ${i}`).join("\n")}`); @@ -127,9 +120,7 @@ function buildScopeSection(prompt: string): string[] { } // Estimate complexity - const hasMultipleFiles = filePaths.length > 3; - const hasMultipleDirs = new Set(filePaths.map(f => f.split("/")[0])).size > 2; - const complexity = hasMultipleFiles && hasMultipleDirs ? "LARGE" : filePaths.length > 1 ? "MEDIUM" : "SMALL"; + const complexity = estimateComplexity(filePaths); sections.push(`### Scope: ${complexity}`); return sections; @@ -137,24 +128,8 @@ function buildScopeSection(prompt: string): string[] { /** Build sequence section for multi-step */ function buildSequenceSection(prompt: string): string[] { - // Split prompt into sub-tasks - const subtasks: string[] = []; - - // Split on "and", "then", numbered lists, bullet points - const parts = prompt - .split(/\b(?:then|after that|next|finally)\b|(?:,\s*and\s+)|(?:\band\b(?=\s+(?:update|add|remove|create|fix|change|refactor|implement|deploy)))/i) - .map(s => s.trim()) - .filter(s => s.length > 5); - - if (parts.length > 1) { - for (let i = 0; i < parts.length; i++) { - const risk = /schema|migrat|database|config|env|deploy/i.test(parts[i]) ? "🔴 HIGH" : - /api|route|endpoint/i.test(parts[i]) ? "🟡 MEDIUM" : "🟢 LOW"; - subtasks.push(`${i + 1}. ${parts[i].charAt(0).toUpperCase() + parts[i].slice(1)} — Risk: ${risk}`); - } - } else { - subtasks.push(`1. ${prompt.slice(0, 100)} — Risk: 🟡 MEDIUM`); - } + const tasks = splitSubtasks(prompt); + const subtasks = tasks.map((t, i) => `${i + 1}. ${t.task} — Risk: ${t.risk}`); return [ `### Execution Plan`, diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts new file mode 100644 index 0000000..2e958fa --- /dev/null +++ b/tests/lib/config.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { getConfig, getRelatedProjects, hasPreflightConfig, resetConfig } from "../../src/lib/config.js"; +import * as fs from "fs"; +import * as path from "path"; + +// Mock fs and files module to control config loading +vi.mock("fs", async () => { + const actual = await vi.importActual("fs"); + return { + ...actual, + existsSync: vi.fn(actual.existsSync), + readFileSync: vi.fn(actual.readFileSync), + }; +}); + +const mockExistsSync = vi.mocked(fs.existsSync); +const mockReadFileSync = vi.mocked(fs.readFileSync); + +describe("config", () => { + const origEnv = { ...process.env }; + + beforeEach(() => { + resetConfig(); + vi.clearAllMocks(); + // Default: no .preflight/ dir + mockExistsSync.mockReturnValue(false); + }); + + afterEach(() => { + process.env = { ...origEnv }; + resetConfig(); + }); + + describe("getConfig defaults", () => { + it("returns default config when no .preflight/ and no env vars", () => { + delete process.env.PROMPT_DISCIPLINE_PROFILE; + delete process.env.PREFLIGHT_RELATED; + delete process.env.EMBEDDING_PROVIDER; + delete process.env.OPENAI_API_KEY; + + const config = getConfig(); + expect(config.profile).toBe("standard"); + expect(config.related_projects).toEqual([]); + expect(config.thresholds.session_stale_minutes).toBe(30); + expect(config.thresholds.max_tool_calls_before_checkpoint).toBe(100); + expect(config.thresholds.correction_pattern_threshold).toBe(3); + expect(config.embeddings.provider).toBe("local"); + expect(config.triage.strictness).toBe("standard"); + expect(config.triage.rules.always_check).toContain("rewards"); + expect(config.triage.rules.skip).toContain("commit"); + }); + + it("caches config on repeated calls", () => { + delete process.env.PROMPT_DISCIPLINE_PROFILE; + const c1 = getConfig(); + const c2 = getConfig(); + expect(c1).toBe(c2); // same reference + }); + + it("resetConfig clears the cache", () => { + delete process.env.PROMPT_DISCIPLINE_PROFILE; + const c1 = getConfig(); + resetConfig(); + const c2 = getConfig(); + expect(c1).not.toBe(c2); // different reference + expect(c1).toEqual(c2); // same values + }); + }); + + describe("env var overrides (no .preflight/)", () => { + it("reads PROMPT_DISCIPLINE_PROFILE", () => { + process.env.PROMPT_DISCIPLINE_PROFILE = "minimal"; + const config = getConfig(); + expect(config.profile).toBe("minimal"); + }); + + it("reads PROMPT_DISCIPLINE_PROFILE=full", () => { + process.env.PROMPT_DISCIPLINE_PROFILE = "full"; + const config = getConfig(); + expect(config.profile).toBe("full"); + }); + + it("ignores invalid PROMPT_DISCIPLINE_PROFILE", () => { + process.env.PROMPT_DISCIPLINE_PROFILE = "turbo"; + const config = getConfig(); + expect(config.profile).toBe("standard"); + }); + + it("reads PREFLIGHT_RELATED", () => { + process.env.PREFLIGHT_RELATED = "/tmp/project-a, /tmp/project-b"; + const config = getConfig(); + expect(config.related_projects).toHaveLength(2); + expect(config.related_projects[0]).toEqual({ path: "/tmp/project-a", alias: "project-a" }); + expect(config.related_projects[1]).toEqual({ path: "/tmp/project-b", alias: "project-b" }); + }); + + it("reads EMBEDDING_PROVIDER", () => { + process.env.EMBEDDING_PROVIDER = "openai"; + const config = getConfig(); + expect(config.embeddings.provider).toBe("openai"); + }); + + it("reads OPENAI_API_KEY", () => { + process.env.OPENAI_API_KEY = "sk-test-123"; + const config = getConfig(); + expect(config.embeddings.openai_api_key).toBe("sk-test-123"); + }); + }); + + describe(".preflight/ config loading", () => { + it("loads config.yml when .preflight/ exists", () => { + const configYaml = ` +profile: full +related_projects: + - path: /tmp/svc-a + alias: svc-a +thresholds: + session_stale_minutes: 60 +embeddings: + provider: openai +`; + mockExistsSync.mockImplementation((p: any) => { + const s = String(p); + if (s.endsWith(".preflight")) return true; + if (s.endsWith("config.yml")) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: any, _enc?: any) => { + if (String(p).endsWith("config.yml")) return configYaml; + throw new Error("not found"); + }); + + const config = getConfig(); + expect(config.profile).toBe("full"); + expect(config.related_projects).toEqual([{ path: "/tmp/svc-a", alias: "svc-a" }]); + expect(config.thresholds.session_stale_minutes).toBe(60); + // Other thresholds keep defaults + expect(config.thresholds.max_tool_calls_before_checkpoint).toBe(100); + expect(config.embeddings.provider).toBe("openai"); + }); + + it("loads triage.yml when present", () => { + const triageYaml = ` +rules: + always_check: + - payments + - billing + skip: + - deploy +strictness: strict +`; + mockExistsSync.mockImplementation((p: any) => { + const s = String(p); + if (s.endsWith(".preflight")) return true; + if (s.endsWith("triage.yml")) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: any, _enc?: any) => { + if (String(p).endsWith("triage.yml")) return triageYaml; + throw new Error("not found"); + }); + + const config = getConfig(); + expect(config.triage.strictness).toBe("strict"); + expect(config.triage.rules.always_check).toEqual(["payments", "billing"]); + expect(config.triage.rules.skip).toEqual(["deploy"]); + }); + + it("ignores env vars when .preflight/ exists", () => { + process.env.PROMPT_DISCIPLINE_PROFILE = "minimal"; + mockExistsSync.mockImplementation((p: any) => { + const s = String(p); + if (s.endsWith(".preflight")) return true; + return false; + }); + + const config = getConfig(); + // Should use default "standard", not env "minimal" + expect(config.profile).toBe("standard"); + }); + + it("handles malformed config.yml gracefully", () => { + mockExistsSync.mockImplementation((p: any) => { + const s = String(p); + if (s.endsWith(".preflight")) return true; + if (s.endsWith("config.yml")) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: any, _enc?: any) => { + if (String(p).endsWith("config.yml")) return "{{invalid yaml: ["; + throw new Error("not found"); + }); + + // Should not throw, falls back to defaults + const config = getConfig(); + expect(config.profile).toBe("standard"); + }); + }); + + describe("getRelatedProjects", () => { + it("returns paths from config", () => { + process.env.PREFLIGHT_RELATED = "/tmp/a, /tmp/b"; + const projects = getRelatedProjects(); + expect(projects).toEqual(["/tmp/a", "/tmp/b"]); + }); + + it("returns empty array by default", () => { + delete process.env.PREFLIGHT_RELATED; + expect(getRelatedProjects()).toEqual([]); + }); + }); + + describe("hasPreflightConfig", () => { + it("returns true when .preflight/ exists", () => { + mockExistsSync.mockImplementation((p: any) => String(p).endsWith(".preflight")); + expect(hasPreflightConfig()).toBe(true); + }); + + it("returns false when .preflight/ missing", () => { + mockExistsSync.mockReturnValue(false); + expect(hasPreflightConfig()).toBe(false); + }); + }); +}); diff --git a/tests/lib/preflight.test.ts b/tests/lib/preflight.test.ts new file mode 100644 index 0000000..466928e --- /dev/null +++ b/tests/lib/preflight.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { extractFilePaths, detectAmbiguity, estimateComplexity, splitSubtasks } from "../../src/lib/preflight.js"; + +describe("extractFilePaths", () => { + it("extracts paths with extensions", () => { + expect(extractFilePaths("fix src/auth/jwt.ts and update README.md")).toEqual([ + "src/auth/jwt.ts", + "README.md", + ]); + }); + + it("deduplicates repeated paths", () => { + expect(extractFilePaths("check foo.ts then foo.ts again")).toEqual(["foo.ts"]); + }); + + it("returns empty for prompts without file paths", () => { + expect(extractFilePaths("fix the auth bug")).toEqual([]); + }); + + it("handles nested paths", () => { + const result = extractFilePaths("edit src/lib/config.ts and utils/helpers.js"); + expect(result).toContain("src/lib/config.ts"); + expect(result).toContain("utils/helpers.js"); + }); + + it("matches dotfiles like .env and .gitignore", () => { + const result = extractFilePaths("check .env and .gitignore"); + expect(result).toContain(".env"); + expect(result).toContain(".gitignore"); + }); +}); + +describe("detectAmbiguity", () => { + it("flags vague pronouns", () => { + const issues = detectAmbiguity("fix it and make sure those work correctly with the new system"); + expect(issues.some(i => i.includes("vague pronouns"))).toBe(true); + }); + + it("flags vague verbs without file targets", () => { + const issues = detectAmbiguity("fix the auth bug and update the tests to match"); + expect(issues.some(i => i.includes("Vague verb"))).toBe(true); + }); + + it("does not flag vague verbs when file targets present", () => { + const issues = detectAmbiguity("fix the null check in src/auth/jwt.ts line 42 and make sure it handles edge cases"); + expect(issues.some(i => i.includes("Vague verb"))).toBe(false); + }); + + it("flags very short prompts", () => { + const issues = detectAmbiguity("fix auth"); + expect(issues.some(i => i.includes("Very short"))).toBe(true); + }); + + it("returns empty for clear, specific prompts", () => { + const issues = detectAmbiguity("Add a null check on line 42 of src/auth/jwt.ts to guard against undefined user tokens"); + expect(issues).toEqual([]); + }); +}); + +describe("estimateComplexity", () => { + it("returns SMALL for 0-1 files", () => { + expect(estimateComplexity([])).toBe("SMALL"); + expect(estimateComplexity(["src/foo.ts"])).toBe("SMALL"); + }); + + it("returns MEDIUM for 2-3 files", () => { + expect(estimateComplexity(["src/a.ts", "src/b.ts"])).toBe("MEDIUM"); + }); + + it("returns LARGE for 4+ files across 3+ dirs", () => { + expect(estimateComplexity([ + "src/a.ts", "lib/b.ts", "tests/c.ts", "config/d.ts" + ])).toBe("LARGE"); + }); + + it("returns MEDIUM for many files in few dirs", () => { + expect(estimateComplexity([ + "src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts" + ])).toBe("MEDIUM"); + }); +}); + +describe("splitSubtasks", () => { + it("returns single task for simple prompts", () => { + const tasks = splitSubtasks("add a health check endpoint"); + expect(tasks).toHaveLength(1); + expect(tasks[0].risk).toBe("🟡 MEDIUM"); + }); + + it("splits on 'then'", () => { + const tasks = splitSubtasks("update the schema then deploy to staging"); + expect(tasks.length).toBeGreaterThan(1); + }); + + it("assigns HIGH risk to schema/migration tasks", () => { + const tasks = splitSubtasks("update the database schema then fix the UI"); + const schemaTask = tasks.find(t => /schema/i.test(t.task)); + expect(schemaTask?.risk).toBe("🔴 HIGH"); + }); + + it("assigns MEDIUM risk to API tasks", () => { + const tasks = splitSubtasks("create the model then add an API endpoint"); + const apiTask = tasks.find(t => /endpoint/i.test(t.task)); + expect(apiTask?.risk).toBe("🟡 MEDIUM"); + }); + + it("assigns LOW risk to generic tasks", () => { + const tasks = splitSubtasks("write the tests then update the docs"); + const docsTask = tasks.find(t => /docs/i.test(t.task)); + expect(docsTask?.risk).toBe("🟢 LOW"); + }); + + it("splits on 'after that' and 'finally'", () => { + const tasks = splitSubtasks("refactor the module after that write tests finally update the changelog"); + expect(tasks.length).toBe(3); + }); +});