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/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); + }); + }); +});