Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,16 @@ This prevents the common failure mode: changing a shared type in one service and

## Configuration Reference

### Quick Setup

Copy the example config directory into your project:

```bash
cp -r examples/.preflight /path/to/your/project/
```

Then edit the files to match your project. See [`examples/.preflight/`](examples/.preflight/) for annotated examples of all three config files.

### `.preflight/config.yml`

Drop this in your project root. Every field is optional — defaults are sensible.
Expand Down
41 changes: 41 additions & 0 deletions examples/.preflight/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# .preflight/config.yml — Drop this in your project root
#
# Copy this entire .preflight/ directory into your project:
# cp -r examples/.preflight /path/to/your/project/
#
# Every field is optional. Defaults are sensible — only override what you need.

# Profile controls how much detail preflight adds to responses.
# "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.
# When your prompt mentions something from a related service,
# preflight searches that project's indexed history and contracts.
related_projects:
- path: /absolute/path/to/api-service
alias: api
- path: /absolute/path/to/auth-service
alias: auth
# - path: /absolute/path/to/shared-types
# alias: shared-types

# Behavioral thresholds — tune these to your workflow
thresholds:
# Warn if no 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 forming a learnable pattern
correction_pattern_threshold: 3

# Embedding configuration
embeddings:
# "local" uses Xenova/all-MiniLM-L6-v2 (~90MB download, runs offline)
# "openai" uses text-embedding-ada-002 (faster, requires API key)
provider: local
# openai_api_key: sk-... # Only needed if provider is "openai"
52 changes: 52 additions & 0 deletions examples/.preflight/contracts/api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# .preflight/contracts/api.yml — Manual contract definitions
#
# Use this to define API contracts that auto-extraction might miss,
# or to document contracts from external services your project consumes.
#
# Manual definitions take precedence over auto-extracted ones when
# names conflict.

- name: User
kind: interface
description: Core user object returned by the API
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: CreateUserRequest
kind: interface
description: Payload for POST /api/users
fields:
- name: email
type: string
required: true
- name: role
type: "'admin' | 'member' | 'viewer'"
required: false
description: Defaults to 'member'

# Example: document an external webhook your app receives
- name: StripeWebhookEvent
kind: interface
description: Incoming Stripe webhook payload (subset we care about)
fields:
- name: type
type: string
required: true
description: "e.g. 'checkout.session.completed'"
- name: data.object.id
type: string
required: true
- name: data.object.customer
type: string
required: true
45 changes: 45 additions & 0 deletions examples/.preflight/triage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# .preflight/triage.yml — Controls the triage classification engine
#
# This is where you teach preflight about YOUR project's domain.
# Add keywords that matter to your codebase so triage routes correctly.

rules:
# Prompts containing these are ALWAYS flagged as ambiguous (at minimum).
# Add domain terms that are too important to let vague prompts slip through.
always_check:
- rewards
- permissions
- migration
- schema
- billing
# - payments
# - onboarding

# Prompts containing these pass through as TRIVIAL (no checks).
# These are safe, mechanical commands that don't need disambiguation.
skip:
- commit
- format
- lint
- "save this"
- "push it"

# Prompts containing these escalate to CROSS-SERVICE triage.
# Add keywords for services your project talks to.
cross_service_keywords:
- auth
- notification
- event
- webhook
# - analytics
# - stripe
# - sendgrid

# How aggressively to classify prompts.
# "relaxed" — more prompts pass as clear (fewer interruptions)
# "standard" — balanced (default)
# "strict" — more prompts flagged as ambiguous (catches more mistakes)
#
# Start with "standard". Move to "strict" if you find yourself correcting
# Claude often. Move to "relaxed" once your prompt habits improve.
strictness: standard
158 changes: 158 additions & 0 deletions tests/lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { join } from "path";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";

// We need to control PROJECT_DIR and reset the config singleton between tests.
// The config module reads from PROJECT_DIR at load time, so we mock files.ts.

let tempDir: string;

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "preflight-config-test-"));
vi.stubEnv("CLAUDE_PROJECT_DIR", tempDir);
// Reset the cached config singleton by re-importing
vi.resetModules();
});

afterEach(() => {
vi.unstubAllEnvs();
rmSync(tempDir, { recursive: true, force: true });
});

async function loadConfig() {
const mod = await import("../../src/lib/config.js");
return mod;
}

describe("config", () => {
it("returns default config when no .preflight/ exists and no env vars", async () => {
const { getConfig } = await loadConfig();
const config = getConfig();
expect(config.profile).toBe("standard");
expect(config.related_projects).toEqual([]);
expect(config.thresholds.session_stale_minutes).toBe(30);
expect(config.embeddings.provider).toBe("local");
expect(config.triage.strictness).toBe("standard");
expect(config.triage.rules.always_check).toContain("migration");
});

it("reads profile from env when no .preflight/ dir", async () => {
vi.stubEnv("PROMPT_DISCIPLINE_PROFILE", "minimal");
const { getConfig } = await loadConfig();
expect(getConfig().profile).toBe("minimal");
});

it("reads related projects from env when no .preflight/ dir", async () => {
vi.stubEnv("PREFLIGHT_RELATED", "/tmp/foo, /tmp/bar");
const { getConfig } = await loadConfig();
const projects = getConfig().related_projects;
expect(projects).toHaveLength(2);
expect(projects[0]).toEqual({ path: "/tmp/foo", alias: "foo" });
expect(projects[1]).toEqual({ path: "/tmp/bar", alias: "bar" });
});

it("reads embedding provider from env", async () => {
vi.stubEnv("EMBEDDING_PROVIDER", "openai");
const { getConfig } = await loadConfig();
expect(getConfig().embeddings.provider).toBe("openai");
});

it("ignores invalid profile env values", async () => {
vi.stubEnv("PROMPT_DISCIPLINE_PROFILE", "turbo");
const { getConfig } = await loadConfig();
expect(getConfig().profile).toBe("standard"); // default
});

it("loads config from .preflight/config.yml", async () => {
const preflightDir = join(tempDir, ".preflight");
mkdirSync(preflightDir);
writeFileSync(
join(preflightDir, "config.yml"),
`profile: full
thresholds:
session_stale_minutes: 60
related_projects:
- path: /opt/api
alias: api-service
embeddings:
provider: openai
`
);
const { getConfig } = await loadConfig();
const config = getConfig();
expect(config.profile).toBe("full");
expect(config.thresholds.session_stale_minutes).toBe(60);
// Other thresholds should keep defaults
expect(config.thresholds.max_tool_calls_before_checkpoint).toBe(100);
expect(config.related_projects).toEqual([{ path: "/opt/api", alias: "api-service" }]);
expect(config.embeddings.provider).toBe("openai");
});

it("loads triage rules from .preflight/triage.yml", async () => {
const preflightDir = join(tempDir, ".preflight");
mkdirSync(preflightDir);
writeFileSync(
join(preflightDir, "triage.yml"),
`strictness: strict
rules:
always_check:
- payments
- auth
skip:
- format
`
);
const { getConfig } = await loadConfig();
const config = getConfig();
expect(config.triage.strictness).toBe("strict");
expect(config.triage.rules.always_check).toEqual(["payments", "auth"]);
expect(config.triage.rules.skip).toEqual(["format"]);
});

it("ignores env vars when .preflight/ directory exists", async () => {
const preflightDir = join(tempDir, ".preflight");
mkdirSync(preflightDir);
writeFileSync(join(preflightDir, "config.yml"), "profile: minimal\n");
vi.stubEnv("PROMPT_DISCIPLINE_PROFILE", "full");
const { getConfig } = await loadConfig();
// .preflight/ takes precedence, env var ignored
expect(getConfig().profile).toBe("minimal");
});

it("handles malformed YAML gracefully", async () => {
const preflightDir = join(tempDir, ".preflight");
mkdirSync(preflightDir);
writeFileSync(join(preflightDir, "config.yml"), "{{invalid yaml");
const { getConfig } = await loadConfig();
// Should fall back to defaults without crashing
expect(getConfig().profile).toBe("standard");
});

it("hasPreflightConfig returns true when .preflight/ exists", async () => {
mkdirSync(join(tempDir, ".preflight"));
const { hasPreflightConfig } = await loadConfig();
expect(hasPreflightConfig()).toBe(true);
});

it("hasPreflightConfig returns false when .preflight/ does not exist", async () => {
const { hasPreflightConfig } = await loadConfig();
expect(hasPreflightConfig()).toBe(false);
});

it("getRelatedProjects returns path array for backward compat", async () => {
const preflightDir = join(tempDir, ".preflight");
mkdirSync(preflightDir);
writeFileSync(
join(preflightDir, "config.yml"),
`related_projects:
- path: /a
alias: a
- path: /b
alias: b
`
);
const { getRelatedProjects } = await loadConfig();
expect(getRelatedProjects()).toEqual(["/a", "/b"]);
});
});
Loading