diff --git a/.gitignore b/.gitignore index c49e8d7..59f96b3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ *.js.map .worktrees/ +memory/ diff --git a/README.md b/README.md index f60fefa..3c375d7 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. @@ -600,6 +602,86 @@ src/ └── ... # One file per tool ``` +## Troubleshooting + +### "CLAUDE_PROJECT_DIR is required" / tools return empty results + +Preflight needs to know which project to monitor. Set it when adding the MCP server: + +```bash +claude mcp add preflight \ + -e CLAUDE_PROJECT_DIR=/absolute/path/to/your/project \ + -- npx tsx /path/to/preflight/src/index.ts +``` + +Or in `.mcp.json`: + +```json +"env": { "CLAUDE_PROJECT_DIR": "/absolute/path/to/your/project" } +``` + +**Must be an absolute path.** Relative paths like `./` or `../myproject` won't resolve correctly. + +### First run is slow / "Downloading model..." hangs + +The default local embedding provider downloads [Xenova/all-MiniLM-L6-v2](https://huggingface.co/Xenova/all-MiniLM-L6-v2) (~90MB) on first use. This is a one-time download — subsequent runs use the cached model. If you're behind a corporate proxy, set `HTTPS_PROXY` before running. + +To skip the wait entirely, use OpenAI embeddings instead: + +```bash +claude mcp add preflight \ + -e CLAUDE_PROJECT_DIR=/path/to/project \ + -e EMBEDDING_PROVIDER=openai \ + -e OPENAI_API_KEY=sk-... \ + -- npx tsx /path/to/preflight/src/index.ts +``` + +### LanceDB errors / "Cannot open database" / timeline search fails + +LanceDB stores vector data in `~/.preflight/projects//timeline.lance/`. Common fixes: + +1. **Corrupted database** — delete the project's data directory and re-onboard: + ```bash + # Find your project hash + cat ~/.preflight/projects/index.json + # Delete and re-index + rm -rf ~/.preflight/projects/ + ``` + Then run `onboard_project` again from Claude Code. + +2. **Permissions** — ensure `~/.preflight/` is writable by your user. + +3. **Disk space** — LanceDB databases grow with session history. A project with 10K events uses ~50MB. + +### Node version errors / "SyntaxError: Unexpected token" + +Preflight requires **Node.js 20+**. Check with `node -v`. If you're on Node 18, upgrade — the project uses modern ES features that aren't available in older versions. + +### "No session files found" when onboarding + +Preflight reads Claude Code session history from `~/.claude/projects/`. If this directory is empty: + +- You haven't used Claude Code on this project yet (use it first, then onboard) +- Your Claude Code installation uses a non-standard config path — check `CLAUDE_CONFIG_DIR` + +### Tools show up but never fire / preflight seems inactive + +Make sure you're invoking tools through Claude Code's MCP interface, not calling them directly. Preflight runs as a **server** — Claude Code connects to it and calls tools automatically based on your prompts. + +If tools are registered but not firing, try: +```bash +claude mcp remove preflight +claude mcp add preflight -- npx tsx /path/to/preflight/src/index.ts +``` + +### `.preflight/` config not loading + +- Config files must be in your **project root** (the `CLAUDE_PROJECT_DIR`), not the preflight installation directory +- Files must be named exactly: `config.yml`, `triage.yml`, or placed in `contracts/` +- YAML syntax errors fail silently — validate with `npx yaml-lint .preflight/config.yml` + +--- + ## License MIT — do whatever you want with it. 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 diff --git a/src/lib/timeline-db.ts b/src/lib/timeline-db.ts index 49b4f78..9bf95ce 100644 --- a/src/lib/timeline-db.ts +++ b/src/lib/timeline-db.ts @@ -289,13 +289,29 @@ export async function insertEvents(events: TimelineEvent[], projectDir?: string) } } +/** Escape a string value for use in LanceDB SQL WHERE clauses. */ +export function escapeSqlString(value: string): string { + // Escape single quotes by doubling them and strip null bytes + return value.replace(/\0/g, "").replace(/'/g, "''"); +} + +/** Validate that an EventType value is one of the known types (prevents injection via type field). */ +function isValidEventType(value: string): value is EventType { + return (EVENT_TYPES as readonly string[]).includes(value); +} + function buildWhereFilter(opts: SearchOptions): string | undefined { const clauses: string[] = []; - if (opts.project) clauses.push(`project = '${opts.project}'`); - if (opts.branch) clauses.push(`branch = '${opts.branch}'`); - if (opts.type) clauses.push(`type = '${opts.type}'`); - if (opts.since) clauses.push(`timestamp >= '${opts.since}'`); - if (opts.until) clauses.push(`timestamp <= '${opts.until}'`); + if (opts.project) clauses.push(`project = '${escapeSqlString(opts.project)}'`); + if (opts.branch) clauses.push(`branch = '${escapeSqlString(opts.branch)}'`); + if (opts.type) { + if (!isValidEventType(opts.type)) { + throw new Error(`Invalid event type: ${opts.type}`); + } + clauses.push(`type = '${opts.type}'`); + } + if (opts.since) clauses.push(`timestamp >= '${escapeSqlString(opts.since)}'`); + if (opts.until) clauses.push(`timestamp <= '${escapeSqlString(opts.until)}'`); return clauses.length > 0 ? clauses.join(" AND ") : undefined; } @@ -358,7 +374,7 @@ export async function searchExact( opts: SearchOptions = {}, ): Promise { const limit = opts.limit || 50; - const likeClauses = [`content LIKE '%${query.replace(/'/g, "''")}%'`]; + const likeClauses = [`content LIKE '%${escapeSqlString(query)}%'`]; const where = buildWhereFilter(opts); const fullWhere = where ? `${likeClauses[0]} AND ${where}` : likeClauses[0]; diff --git a/tests/lib/timeline-db.test.ts b/tests/lib/timeline-db.test.ts new file mode 100644 index 0000000..ad2bb8b --- /dev/null +++ b/tests/lib/timeline-db.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { escapeSqlString } from "../../src/lib/timeline-db.js"; + +describe("escapeSqlString", () => { + it("returns plain strings unchanged", () => { + expect(escapeSqlString("hello")).toBe("hello"); + }); + + it("doubles single quotes", () => { + expect(escapeSqlString("it's")).toBe("it''s"); + expect(escapeSqlString("a'b'c")).toBe("a''b''c"); + }); + + it("strips null bytes", () => { + expect(escapeSqlString("ab\0cd")).toBe("abcd"); + }); + + it("handles both quotes and null bytes", () => { + expect(escapeSqlString("it\0's")).toBe("it''s"); + }); + + it("handles empty string", () => { + expect(escapeSqlString("")).toBe(""); + }); + + it("handles strings with SQL-like content", () => { + const malicious = "'; DROP TABLE events; --"; + expect(escapeSqlString(malicious)).toBe("''; DROP TABLE events; --"); + }); +});