Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions examples/.preflight/config.yml
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions examples/.preflight/contracts/api.yml
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
required: true
- name: source
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 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
35 changes: 35 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -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.
139 changes: 139 additions & 0 deletions tests/lib/files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect, afterAll, beforeAll } from "vitest";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync, existsSync, readdirSync, statSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

/**
* Since PROJECT_DIR is a module-level constant in files.ts (set at import time),
* we re-implement the functions here against a temp dir to test the logic properly.
* This tests the actual algorithms without fighting module caching.
*/

let tempDir: string;

beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), "preflight-files-test-"));
});

afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});

// Re-implement readIfExists logic to test against tempDir
function readIfExists(baseDir: string, relPath: string, maxLines = 50): string | null {
const full = join(baseDir, relPath);
if (!existsSync(full)) return null;
try {
const buf = readFileSync(full);
if (buf.subarray(0, 8192).includes(0)) return null;
const lines = buf.toString("utf-8").split("\n");
return lines.slice(0, maxLines).join("\n");
} catch {
return null;
}
}

// Actually, let's just import and test the real module by setting env before anything
// The trick: we need to ensure no other test imports files.js first.
// Better approach: test via the actual exported functions with a subprocess or
// just test the real module and accept it uses cwd.

// Simplest correct approach: test the actual module's exported functions.
// For readIfExists, we create files relative to PROJECT_DIR (which is cwd in test).
// For findWorkspaceDocs, we'd need a .claude/ dir in cwd which is messy.
// Let's use a pragmatic approach: test readIfExists with real files in a subdir,
// and test findWorkspaceDocs behavior by creating a temp .claude/ and cleaning up.

import { readIfExists as realReadIfExists, findWorkspaceDocs, PROJECT_DIR } from "../../src/lib/files.js";

describe("readIfExists()", () => {
const testDir = ".__test_files_tmp__";

beforeAll(() => {
mkdirSync(join(PROJECT_DIR, testDir), { recursive: true });
});

afterAll(() => {
rmSync(join(PROJECT_DIR, testDir), { recursive: true, force: true });
});

it("returns null for non-existent file", () => {
expect(realReadIfExists(join(testDir, "nope.txt"))).toBeNull();
});

it("reads a text file", () => {
writeFileSync(join(PROJECT_DIR, testDir, "hello.md"), "# Hello\nWorld");
expect(realReadIfExists(join(testDir, "hello.md"))).toBe("# Hello\nWorld");
});

it("limits lines returned", () => {
const lines = Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n");
writeFileSync(join(PROJECT_DIR, testDir, "big.txt"), lines);
const result = realReadIfExists(join(testDir, "big.txt"), 3);
expect(result).toBe("line 0\nline 1\nline 2");
});

it("uses default of 50 lines", () => {
const lines = Array.from({ length: 100 }, (_, i) => `L${i}`).join("\n");
writeFileSync(join(PROJECT_DIR, testDir, "many.txt"), lines);
const result = realReadIfExists(join(testDir, "many.txt"));
expect(result?.split("\n").length).toBe(50);
});

it("rejects binary files with null bytes", () => {
const buf = Buffer.from([0x48, 0x65, 0x6c, 0x00, 0x6f]);
writeFileSync(join(PROJECT_DIR, testDir, "bin.dat"), buf);
expect(realReadIfExists(join(testDir, "bin.dat"))).toBeNull();
});

it("allows text files without null bytes", () => {
writeFileSync(join(PROJECT_DIR, testDir, "clean.txt"), "no nulls");
expect(realReadIfExists(join(testDir, "clean.txt"))).toBe("no nulls");
});
});

describe("findWorkspaceDocs()", () => {
// These tests work with the real .claude/ dir if it exists
// We test the return type and behavior

it("returns an object", () => {
const result = findWorkspaceDocs();
expect(typeof result).toBe("object");
});

it("metadataOnly returns empty content strings", () => {
const docs = findWorkspaceDocs({ metadataOnly: true });
for (const [, doc] of Object.entries(docs)) {
expect(doc.content).toBe("");
}
});

it("regular mode returns content with mtime and size", () => {
const docs = findWorkspaceDocs();
for (const [, doc] of Object.entries(docs)) {
expect(doc).toHaveProperty("content");
expect(doc).toHaveProperty("mtime");
expect(doc).toHaveProperty("size");
expect(typeof doc.content).toBe("string");
expect(typeof doc.size).toBe("number");
}
});

it("content is limited to 40 lines per file", () => {
const docs = findWorkspaceDocs();
for (const [, doc] of Object.entries(docs)) {
expect(doc.content.split("\n").length).toBeLessThanOrEqual(40);
}
});
});

describe("PROJECT_DIR", () => {
it("is a string", () => {
expect(typeof PROJECT_DIR).toBe("string");
});

it("falls back to cwd when CLAUDE_PROJECT_DIR is not set", () => {
// In test context, it should be cwd or the env var
expect(PROJECT_DIR.length).toBeGreaterThan(0);
});
});
Loading
Loading