From 44df79a9a366115241c1bfbbb21e0dc5d7fbcadf Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Fri, 6 Mar 2026 15:22:24 -0700 Subject: [PATCH 1/2] add examples/.preflight/ config directory with annotated config, triage, and contracts examples --- README.md | 2 + examples/.preflight/README.md | 33 +++++++++++++++ examples/.preflight/config.yml | 44 +++++++++++++++++++ examples/.preflight/contracts/api.yml | 61 +++++++++++++++++++++++++++ examples/.preflight/triage.yml | 48 +++++++++++++++++++++ 5 files changed, 188 insertions(+) create mode 100644 examples/.preflight/README.md create mode 100644 examples/.preflight/config.yml create mode 100644 examples/.preflight/contracts/api.yml create mode 100644 examples/.preflight/triage.yml diff --git a/README.md b/README.md index f60fefa..0767a34 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,8 @@ 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 [`examples/.preflight/`](examples/.preflight/) directory to your project root and edit to taste. + ### `.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..38bf7da --- /dev/null +++ b/examples/.preflight/README.md @@ -0,0 +1,33 @@ +# Example `.preflight/` Configuration + +Copy this directory to your project root to get started: + +```bash +cp -r examples/.preflight /path/to/your/project/ +``` + +Then edit the files for your project: + +1. **`config.yml`** — Set your related projects and thresholds +2. **`triage.yml`** — Add domain-specific keywords that should trigger checks +3. **`contracts/*.yml`** — Define shared types and API contracts + +All fields are optional. Preflight uses sensible defaults for anything you leave out. + +## File Overview + +``` +.preflight/ +├── config.yml # Main config: related projects, thresholds, embeddings +├── triage.yml # Triage rules: which prompts get checked and how +├── contracts/ +│ └── api.yml # Manual contract definitions (types, interfaces, routes) +└── README.md # This file (you can delete it) +``` + +## Tips + +- **Commit `.preflight/` to your repo** so your whole team gets the same behavior +- **Start with defaults** and add `always_check` keywords as you discover pain points +- **Split contracts** into multiple files (`api.yml`, `events.yml`, etc.) for organization +- **Use `profile: minimal`** if preflight feels too chatty during rapid iteration diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml new file mode 100644 index 0000000..53df712 --- /dev/null +++ b/examples/.preflight/config.yml @@ -0,0 +1,44 @@ +# .preflight/config.yml — Drop this in your project root +# +# This is the main preflight configuration file. Every field is optional; +# defaults are sensible for most projects. Commit this to your repo so +# your whole team gets the same preflight behavior. +# +# Docs: https://github.com/TerminalGravity/preflight#configuration-reference + +# Profile controls how verbose preflight is: +# "minimal" — only flag ambiguous+, skip clarification detail +# "standard" — default behavior (recommended) +# "full" — maximum detail on every non-trivial prompt +profile: standard + +# Related projects for cross-service awareness. +# Preflight will search these projects' LanceDB indexes and contract +# registries when it detects cross-service prompts. This prevents the +# common failure: changing a shared type in one service and forgetting +# the consumers. +related_projects: + - path: /Users/you/projects/auth-service + alias: auth + - path: /Users/you/projects/shared-types + alias: shared-types + # Add more as needed: + # - path: /Users/you/projects/notifications + # alias: notifications + +# Behavioral thresholds — tune these to your workflow +thresholds: + # Warn if no session activity for this many minutes + session_stale_minutes: 30 + # Suggest a checkpoint after this many tool calls + max_tool_calls_before_checkpoint: 100 + # Minimum corrections before preflight learns a pattern + correction_pattern_threshold: 3 + +# Embedding configuration for semantic search +embeddings: + # "local" uses Xenova (no API key needed, runs on-device) + # "openai" uses OpenAI embeddings (faster, requires key) + provider: local + # Only needed if provider is "openai": + # openai_api_key: sk-... diff --git a/examples/.preflight/contracts/api.yml b/examples/.preflight/contracts/api.yml new file mode 100644 index 0000000..53f6838 --- /dev/null +++ b/examples/.preflight/contracts/api.yml @@ -0,0 +1,61 @@ +# .preflight/contracts/api.yml — Manual contract definitions +# +# Define contracts that preflight should know about. These supplement +# auto-extracted contracts from your codebase. If a manual contract has +# the same name as an auto-extracted one, the manual definition wins. +# +# Use cases: +# - Document API contracts that aren't easily extracted from code +# - Define expected shapes for external services +# - Add contracts for planned-but-not-yet-built features +# +# You can split contracts across multiple files in this directory: +# contracts/api.yml, contracts/events.yml, contracts/external.yml, etc. + +- name: User + kind: interface + description: Core user model 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: RewardsTier + kind: interface + description: Reward tier levels and their perks + fields: + - name: tier + type: "'bronze' | 'silver' | 'gold' | 'platinum'" + required: true + - name: multiplier + type: number + required: true + - name: perks + type: string[] + required: false + +- name: AuthToken + kind: interface + description: JWT payload structure from auth-service + fields: + - name: userId + type: string + required: true + - name: tier + type: string + required: true + - name: permissions + type: string[] + required: true + - name: exp + type: number + required: true diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml new file mode 100644 index 0000000..e3e095f --- /dev/null +++ b/examples/.preflight/triage.yml @@ -0,0 +1,48 @@ +# .preflight/triage.yml — Controls the triage classification engine +# +# Triage decides how preflight routes your prompts: +# TRIVIAL → pass through (no preflight check) +# CLEAR → quick validation, no blocking questions +# AMBIGUOUS → preflight asks clarifying questions before proceeding +# MULTI-STEP → preflight breaks down the task and checks each step +# CROSS-SERVICE → preflight searches related projects for contracts/types +# +# Customize these keywords for your domain. For example, if your app has +# a "billing" module that's error-prone, add "billing" to always_check. + +rules: + # Prompts containing these words → always at least AMBIGUOUS. + # Add domain-specific terms that commonly lead to wrong guesses. + always_check: + - rewards + - permissions + - migration + - schema + - billing # example: add your own risky domains + # - payments + # - deployment + + # Prompts containing these words → TRIVIAL (pass through immediately). + # These are safe, well-understood operations that don't need checking. + skip: + - commit + - format + - lint + - prettier + # - typecheck + + # Prompts containing these words → CROSS-SERVICE. + # Triggers a search across related_projects defined in config.yml. + cross_service_keywords: + - auth + - notification + - event + - webhook + # - queue + # - pubsub + +# How aggressively to classify prompts: +# "relaxed" — more prompts pass as clear (fewer interruptions) +# "standard" — balanced (recommended) +# "strict" — more prompts flagged as ambiguous (maximum safety) +strictness: standard From 4c4a1f9e9f0684ee9b4ec238f78b1678c4293b7b Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Fri, 6 Mar 2026 17:09:52 -0700 Subject: [PATCH 2/2] test: add 14 unit tests for state lib (load/save/appendLog/readLog/rotation) --- tests/lib/state.test.ts | 137 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/lib/state.test.ts diff --git a/tests/lib/state.test.ts b/tests/lib/state.test.ts new file mode 100644 index 0000000..bd4826e --- /dev/null +++ b/tests/lib/state.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +const TEST_DIR = join(tmpdir(), `preflight-state-test-${process.pid}`); + +// Set PROJECT_DIR before importing state module (it reads env at import time) +process.env.CLAUDE_PROJECT_DIR = TEST_DIR; + +// Now import — STATE_DIR will be TEST_DIR/.claude/preflight-state +const { loadState, saveState, appendLog, readLog, now, STATE_DIR } = await import("../../src/lib/state.js"); + +const stateDir = join(TEST_DIR, ".claude", "preflight-state"); + +beforeEach(() => { + mkdirSync(stateDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe("now()", () => { + it("returns a valid ISO timestamp", () => { + const ts = now(); + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(() => new Date(ts)).not.toThrow(); + }); + + it("returns a timestamp close to current time", () => { + const before = Date.now(); + const ts = now(); + const after = Date.now(); + const parsed = new Date(ts).getTime(); + expect(parsed).toBeGreaterThanOrEqual(before); + expect(parsed).toBeLessThanOrEqual(after); + }); +}); + +describe("loadState / saveState", () => { + it("returns empty object for missing state file", () => { + expect(loadState("nonexistent")).toEqual({}); + }); + + it("returns empty object for corrupt JSON", () => { + writeFileSync(join(stateDir, "corrupt.json"), "not json{{{"); + expect(loadState("corrupt")).toEqual({}); + }); + + it("round-trips a state object", () => { + saveState("test", { foo: "bar", count: 42 }); + const result = loadState("test"); + expect(result).toEqual({ foo: "bar", count: 42 }); + }); + + it("overwrites existing state", () => { + saveState("overwrite", { version: 1 }); + saveState("overwrite", { version: 2 }); + expect(loadState("overwrite")).toEqual({ version: 2 }); + }); + + it("creates state dir if it does not exist", () => { + rmSync(stateDir, { recursive: true, force: true }); + saveState("autocreate", { ok: true }); + expect(existsSync(join(stateDir, "autocreate.json"))).toBe(true); + expect(loadState("autocreate")).toEqual({ ok: true }); + }); +}); + +describe("appendLog / readLog", () => { + it("returns empty array for missing log file", () => { + expect(readLog("missing.jsonl")).toEqual([]); + }); + + it("returns empty array for empty log file", () => { + writeFileSync(join(stateDir, "empty.jsonl"), ""); + expect(readLog("empty.jsonl")).toEqual([]); + }); + + it("appends and reads JSONL entries", () => { + appendLog("append.jsonl", { a: 1 }); + appendLog("append.jsonl", { b: 2 }); + appendLog("append.jsonl", { c: 3 }); + const result = readLog("append.jsonl"); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ a: 1 }); + expect(result[2]).toEqual({ c: 3 }); + }); + + it("respects lastN parameter", () => { + for (let i = 0; i < 10; i++) { + appendLog("many.jsonl", { i }); + } + const result = readLog("many.jsonl", 3); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ i: 7 }); + expect(result[2]).toEqual({ i: 9 }); + }); + + it("skips corrupt lines gracefully", () => { + const lines = [ + JSON.stringify({ good: 1 }), + "not json", + JSON.stringify({ good: 2 }), + ].join("\n"); + writeFileSync(join(stateDir, "partial.jsonl"), lines); + const result = readLog("partial.jsonl"); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ good: 1 }); + expect(result[1]).toEqual({ good: 2 }); + }); + + it("rotates log file when it exceeds 5MB", () => { + // Write a 5MB+ entry + const bigEntry = { data: "x".repeat(5 * 1024 * 1024) }; + appendLog("big.jsonl", bigEntry); + // File should exist and be large + expect(existsSync(join(stateDir, "big.jsonl"))).toBe(true); + + // Append another entry — should trigger rotation + appendLog("big.jsonl", { after: true }); + + // The .old backup should exist + expect(existsSync(join(stateDir, "big.jsonl.old"))).toBe(true); + // The current file should only have the new entry + const result = readLog("big.jsonl"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ after: true }); + }); + + it("creates state dir if needed", () => { + rmSync(stateDir, { recursive: true, force: true }); + appendLog("autocreate.jsonl", { ok: true }); + expect(readLog("autocreate.jsonl")).toEqual([{ ok: true }]); + }); +});