From a65d67679dceccbaba1ef3b314ec9ce70fe80ed4 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Tue, 10 Mar 2026 12:23:43 -0700 Subject: [PATCH 1/2] add .preflight/ example config directory with commented starter files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - examples/.preflight/config.yml — profile, related projects, thresholds, embeddings - examples/.preflight/triage.yml — keyword rules and strictness tuning - examples/.preflight/contracts/api.yml — manual cross-service contract definitions - examples/README.md — quick setup instructions - README.md — link to examples from config reference section --- README.md | 2 + examples/.preflight/config.yml | 35 ++++++++++++++++ examples/.preflight/contracts/api.yml | 58 +++++++++++++++++++++++++++ examples/.preflight/triage.yml | 45 +++++++++++++++++++++ examples/README.md | 35 ++++++++++++++++ 5 files changed, 175 insertions(+) create mode 100644 examples/.preflight/config.yml create mode 100644 examples/.preflight/contracts/api.yml create mode 100644 examples/.preflight/triage.yml create mode 100644 examples/README.md diff --git a/README.md b/README.md index f60fefa..15b50f7 100644 --- a/README.md +++ b/README.md @@ -500,6 +500,8 @@ Manual contract definitions that supplement auto-extraction: Environment variables are **fallbacks** — `.preflight/` config takes precedence when present. +> 💡 **Ready-to-use examples:** Copy [`examples/.preflight/`](examples/.preflight/) into your project root for a working starter config with detailed comments. + --- ## Embedding Providers diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml new file mode 100644 index 0000000..f59170f --- /dev/null +++ b/examples/.preflight/config.yml @@ -0,0 +1,35 @@ +# .preflight/config.yml — Drop this in your project root +# +# 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 +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: + - 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 +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 + +# 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). +embeddings: + provider: local + # openai_api_key: sk-... # Uncomment if using openai provider diff --git a/examples/.preflight/contracts/api.yml b/examples/.preflight/contracts/api.yml new file mode 100644 index 0000000..512543f --- /dev/null +++ b/examples/.preflight/contracts/api.yml @@ -0,0 +1,58 @@ +# .preflight/contracts/api.yml — Manual contract definitions +# +# 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) + +- name: User + kind: interface + description: Core user model shared across all services + fields: + - name: id + type: string + required: true + - name: email + type: string + required: true + - name: tier + type: "'free' | 'pro' | 'enterprise'" + required: true + - name: createdAt + type: Date + required: true + +- name: AuthToken + kind: interface + description: JWT payload structure from auth-service + fields: + - name: userId + type: string + required: true + - name: permissions + type: string[] + required: true + - name: expiresAt + type: number + required: true + +- name: WebhookPayload + kind: interface + description: Standard webhook envelope for inter-service events + fields: + - name: event + type: string + required: true + - name: timestamp + type: string + required: true + - name: data + type: Record + required: true + - name: source + type: string + required: true diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml new file mode 100644 index 0000000..b3d394e --- /dev/null +++ b/examples/.preflight/triage.yml @@ -0,0 +1,45 @@ +# .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. + +rules: + # Prompts containing these words are always flagged as AMBIGUOUS. + # Add domain-specific terms that tend to produce vague prompts. + always_check: + - rewards + - permissions + - migration + - schema + - pricing # example: your billing domain + - onboarding # example: multi-step user flows + + # Prompts containing these words skip checks entirely (TRIVIAL). + # These are safe, mechanical tasks that don't need guardrails. + skip: + - commit + - format + - lint + - prettier + - "git push" + + # Prompts containing these words trigger CROSS-SERVICE classification. + # Preflight will search related_projects for relevant types and routes. + cross_service_keywords: + - auth + - notification + - event + - webhook + - billing # matches the related_project alias + +# 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) +strictness: standard diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..778f15d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,35 @@ +# Examples + +## `.preflight/` Config Directory + +The `.preflight/` directory contains example configuration files you can copy into your project root: + +``` +.preflight/ +├── config.yml # Main config — profile, related projects, thresholds +├── triage.yml # Triage rules — keywords, strictness +└── contracts/ + └── api.yml # Manual contract definitions for cross-service types +``` + +### Quick setup + +```bash +# From your project root: +cp -r /path/to/preflight/examples/.preflight .preflight + +# Edit paths in config.yml to match your setup: +$EDITOR .preflight/config.yml +``` + +Then commit `.preflight/` to your repo — your whole team gets the same preflight behavior. + +### What each file does + +| File | Purpose | Required? | +|------|---------|-----------| +| `config.yml` | Profile, related projects, thresholds, embedding config | No — sensible defaults | +| `triage.yml` | Keyword rules for prompt classification | No — sensible defaults | +| `contracts/*.yml` | Manual type/interface definitions for cross-service awareness | No — auto-extraction works without it | + +All files are optional. Preflight works out of the box with zero config — these files let you tune it to your codebase. From 7af560a999deaa3599c5a16678551eb9022c061c Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Tue, 10 Mar 2026 16:46:19 -0700 Subject: [PATCH 2/2] test: add 11 unit tests for lib/state.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers loadState, saveState, appendLog, readLog (including lastN, corrupt line handling, and 5MB rotation), and now(). Brings test count from 43 → 54. --- tests/lib/state.test.ts | 118 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 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..34a8589 --- /dev/null +++ b/tests/lib/state.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "fs"; +import { join } from "path"; + +// Point PROJECT_DIR to a temp directory before importing state +const tmpDir = join(__dirname, "__state_test_tmp__"); +process.env.CLAUDE_PROJECT_DIR = tmpDir; + +// Dynamic import so env var is set first +const { loadState, saveState, appendLog, readLog, now, STATE_DIR } = await import( + "../../src/lib/state.js" +); + +describe("state module", () => { + beforeEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("loadState", () => { + it("returns empty object when file does not exist", () => { + expect(loadState("nonexistent")).toEqual({}); + }); + + it("loads valid JSON state", () => { + mkdirSync(STATE_DIR, { recursive: true }); + writeFileSync(join(STATE_DIR, "test.json"), '{"foo":"bar","n":42}'); + expect(loadState("test")).toEqual({ foo: "bar", n: 42 }); + }); + + it("returns empty object for corrupt JSON", () => { + mkdirSync(STATE_DIR, { recursive: true }); + writeFileSync(join(STATE_DIR, "bad.json"), "{not valid json"); + expect(loadState("bad")).toEqual({}); + }); + }); + + describe("saveState", () => { + it("creates state dir and writes JSON", () => { + saveState("mystate", { key: "value", count: 1 }); + const raw = readFileSync(join(STATE_DIR, "mystate.json"), "utf-8"); + expect(JSON.parse(raw)).toEqual({ key: "value", count: 1 }); + }); + + it("overwrites existing state", () => { + saveState("x", { a: 1 }); + saveState("x", { b: 2 }); + expect(loadState("x")).toEqual({ b: 2 }); + }); + }); + + describe("appendLog / readLog", () => { + it("appends JSONL entries and reads them back", () => { + appendLog("test.jsonl", { event: "start", ts: 1 }); + appendLog("test.jsonl", { event: "end", ts: 2 }); + const entries = readLog("test.jsonl"); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual({ event: "start", ts: 1 }); + expect(entries[1]).toEqual({ event: "end", ts: 2 }); + }); + + it("readLog returns empty array for missing file", () => { + expect(readLog("nope.jsonl")).toEqual([]); + }); + + it("readLog with lastN returns only last N entries", () => { + for (let i = 0; i < 10; i++) { + appendLog("many.jsonl", { i }); + } + const last3 = readLog("many.jsonl", 3); + expect(last3).toHaveLength(3); + expect(last3[0]).toEqual({ i: 7 }); + expect(last3[2]).toEqual({ i: 9 }); + }); + + it("readLog skips corrupt lines gracefully", () => { + mkdirSync(STATE_DIR, { recursive: true }); + writeFileSync( + join(STATE_DIR, "mixed.jsonl"), + '{"ok":true}\nNOT JSON\n{"also":"ok"}\n' + ); + const entries = readLog("mixed.jsonl"); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual({ ok: true }); + expect(entries[1]).toEqual({ also: "ok" }); + }); + + it("rotates log when exceeding 5MB", () => { + mkdirSync(STATE_DIR, { recursive: true }); + const logPath = join(STATE_DIR, "big.jsonl"); + // Write a 5.1MB file + const bigLine = JSON.stringify({ data: "x".repeat(1000) }) + "\n"; + const count = Math.ceil((5.1 * 1024 * 1024) / bigLine.length); + writeFileSync(logPath, bigLine.repeat(count)); + + // Append should trigger rotation + appendLog("big.jsonl", { after: "rotation" }); + + // Old file should exist as backup + expect(existsSync(logPath + ".old")).toBe(true); + // New file should only have the new entry + const entries = readLog("big.jsonl"); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual({ after: "rotation" }); + }); + }); + + describe("now", () => { + it("returns a valid ISO 8601 string", () => { + const ts = now(); + expect(new Date(ts).toISOString()).toBe(ts); + }); + }); +});