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/tests/lib/session-parser.test.ts b/tests/lib/session-parser.test.ts new file mode 100644 index 0000000..f730a88 --- /dev/null +++ b/tests/lib/session-parser.test.ts @@ -0,0 +1,351 @@ +/** + * Tests for src/lib/session-parser.ts + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, statSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { + findSessionDirs, + findSessionFiles, + parseSession, + parseSessionAsync, + parseAllSessions, +} from "../../src/lib/session-parser.js"; + +// Helper to create temp dirs with JSONL content +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "sp-test-")); +} + +function writeJsonl(dir: string, filename: string, records: any[]): string { + const p = join(dir, filename); + writeFileSync(p, records.map((r) => JSON.stringify(r)).join("\n") + "\n"); + return p; +} + +describe("session-parser", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ── findSessionFiles ─────────────────────────────────────────────────── + + describe("findSessionFiles", () => { + it("returns empty for nonexistent dir", () => { + expect(findSessionFiles("/no/such/dir")).toEqual([]); + }); + + it("finds .jsonl files at top level", () => { + writeJsonl(tmpDir, "abc.jsonl", [{ type: "user" }]); + writeJsonl(tmpDir, "def.jsonl", [{ type: "user" }]); + writeFileSync(join(tmpDir, "readme.txt"), "not a session"); + + const files = findSessionFiles(tmpDir); + expect(files).toHaveLength(2); + expect(files.map((f) => f.sessionId).sort()).toEqual(["abc", "def"]); + expect(files[0].mtime).toBeInstanceOf(Date); + }); + + it("finds subagent session files", () => { + // Create parent session dir with subagents subdir + const parentDir = join(tmpDir, "parent-uuid"); + const subDir = join(parentDir, "subagents"); + mkdirSync(subDir, { recursive: true }); + writeJsonl(subDir, "sub-uuid.jsonl", [{ type: "user" }]); + + const files = findSessionFiles(tmpDir); + expect(files).toHaveLength(1); + expect(files[0].sessionId).toBe("sub-uuid"); + }); + }); + + // ── parseSession ─────────────────────────────────────────────────────── + + describe("parseSession", () => { + it("parses user prompts", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "Hello world" } }, + ]); + const events = parseSession(fp, "/test", "test"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("prompt"); + expect(events[0].content).toBe("Hello world"); + expect(events[0].project).toBe("/test"); + expect(events[0].project_name).toBe("test"); + }); + + it("parses array content blocks", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { + type: "user", + timestamp: "2025-01-01T00:00:00Z", + message: { content: [{ type: "text", text: "part1" }, { type: "text", text: "part2" }] }, + }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events[0].content).toBe("part1\npart2"); + }); + + it("detects corrections after assistant messages", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "assistant", timestamp: "2025-01-01T00:00:00Z", message: { content: "Here's the code" } }, + { type: "user", timestamp: "2025-01-01T00:00:01Z", message: { content: "No, that's wrong" } }, + ]); + const events = parseSession(fp, "/p", "p"); + const correction = events.find((e) => e.type === "correction"); + expect(correction).toBeDefined(); + expect(correction!.content).toBe("No, that's wrong"); + }); + + it("does not flag as correction when no prior assistant", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "No, that's wrong" } }, + ]); + const events = parseSession(fp, "/p", "p"); + // First message can't be correction (no prior assistant) + expect(events[0].type).toBe("prompt"); + }); + + it("parses assistant text and tool_use blocks", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { + type: "assistant", + timestamp: "2025-01-01T00:00:00Z", + model: "claude-3", + message: { + content: [ + { type: "text", text: "Let me check" }, + { type: "tool_use", name: "Read", input: { path: "/foo" } }, + ], + }, + }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events).toHaveLength(2); + expect(events[0].type).toBe("assistant"); + expect(events[0].content).toBe("Let me check"); + expect(JSON.parse(events[0].metadata).model).toBe("claude-3"); + expect(events[1].type).toBe("tool_call"); + expect(events[1].content).toContain("Read:"); + }); + + it("detects sub_agent_spawn for Task tool", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { + type: "assistant", + timestamp: "2025-01-01T00:00:00Z", + message: { + content: [{ type: "tool_use", name: "Task", input: { task: "do stuff" } }], + }, + }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events[0].type).toBe("sub_agent_spawn"); + }); + + it("detects sub_agent_spawn for dispatch_agent tool", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { + type: "assistant", + timestamp: "2025-01-01T00:00:00Z", + message: { + content: [{ type: "tool_use", name: "dispatch_agent", input: {} }], + }, + }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events[0].type).toBe("sub_agent_spawn"); + }); + + it("parses tool_result errors", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "tool_result", timestamp: "2025-01-01T00:00:00Z", is_error: true, content: "ENOENT: file not found", tool_use_id: "tu_123" }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + expect(events[0].content).toContain("ENOENT"); + }); + + it("detects stderr in tool_result as error", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "tool_result", timestamp: "2025-01-01T00:00:00Z", content: "stderr: something failed" }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + }); + + it("parses compaction events from system type", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "system", timestamp: "2025-01-01T00:00:00Z", subtype: "compaction", content: "compacted" }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("compaction"); + }); + + it("parses compaction via text match", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "system", timestamp: "2025-01-01T00:00:00Z", message: { content: "Context was compacted to save tokens" } }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("compaction"); + }); + + it("extracts branch and sessionId from summary records", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "summary", gitBranch: "feat/cool", sessionId: "custom-id" }, + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "hi" } }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events[0].branch).toBe("feat/cool"); + expect(events[0].session_id).toBe("custom-id"); + }); + + it("handles malformed JSON lines gracefully", () => { + const fp = join(tmpDir, "bad.jsonl"); + writeFileSync(fp, '{"type":"user","message":{"content":"ok"}}\nnot json\n{"type":"user","message":{"content":"two"}}\n'); + + // Suppress stderr + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const events = parseSession(fp, "/p", "p"); + stderrSpy.mockRestore(); + + expect(events).toHaveLength(2); + }); + + it("skips user messages with empty content", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "" } }, + { type: "user", timestamp: "2025-01-01T00:00:01Z", message: { content: [] } }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events).toHaveLength(0); + }); + + it("normalizes epoch timestamps", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "user", timestamp: 1704067200, message: { content: "epoch seconds" } }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events[0].timestamp).toBe("2024-01-01T00:00:00.000Z"); + }); + + it("normalizes epoch ms timestamps", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "user", timestamp: 1704067200000, message: { content: "epoch ms" } }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events[0].timestamp).toBe("2024-01-01T00:00:00.000Z"); + }); + + it("falls back to file mtime for missing timestamps", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "user", message: { content: "no ts" } }, + ]); + const events = parseSession(fp, "/p", "p"); + const mtime = statSync(fp).mtime.toISOString(); + expect(events[0].timestamp).toBe(mtime); + }); + + it("truncates long content_preview", () => { + const longText = "x".repeat(200); + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: longText } }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events[0].content_preview.length).toBeLessThanOrEqual(121); // 120 + "…" + expect(events[0].content_preview.endsWith("…")).toBe(true); + }); + + it("generates unique IDs for each event", () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "a" } }, + { type: "user", timestamp: "2025-01-01T00:00:01Z", message: { content: "b" } }, + ]); + const events = parseSession(fp, "/p", "p"); + expect(events[0].id).not.toBe(events[1].id); + }); + + it("handles all correction patterns", () => { + const patterns = ["no", "wrong", "not that", "i meant", "actually", "instead", "undo"]; + for (const word of patterns) { + const fp = writeJsonl(tmpDir, `corr-${word.replace(/\s/g, "")}.jsonl`, [ + { type: "assistant", timestamp: "2025-01-01T00:00:00Z", message: { content: "response" } }, + { type: "user", timestamp: "2025-01-01T00:00:01Z", message: { content: `${word} do it differently` } }, + ]); + const events = parseSession(fp, "/p", "p"); + const corr = events.find((e) => e.type === "correction"); + expect(corr, `pattern "${word}" should be detected as correction`).toBeDefined(); + } + }); + }); + + // ── parseSessionAsync ────────────────────────────────────────────────── + + describe("parseSessionAsync", () => { + it("produces same results as sync parser", async () => { + const fp = writeJsonl(tmpDir, "s1.jsonl", [ + { type: "summary", gitBranch: "main", sessionId: "sess-1" }, + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "hello" } }, + { type: "assistant", timestamp: "2025-01-01T00:00:01Z", message: { content: "hi there" } }, + { type: "user", timestamp: "2025-01-01T00:00:02Z", message: { content: "no that's wrong" } }, + ]); + + const syncEvents = parseSession(fp, "/p", "p"); + const asyncEvents = await parseSessionAsync(fp, "/p", "p"); + + // Same number of events, same types + expect(asyncEvents.length).toBe(syncEvents.length); + for (let i = 0; i < syncEvents.length; i++) { + expect(asyncEvents[i].type).toBe(syncEvents[i].type); + expect(asyncEvents[i].content).toBe(syncEvents[i].content); + expect(asyncEvents[i].branch).toBe(syncEvents[i].branch); + } + }); + }); + + // ── parseAllSessions ─────────────────────────────────────────────────── + + describe("parseAllSessions", () => { + it("parses all jsonl files and sorts by timestamp", () => { + writeJsonl(tmpDir, "a.jsonl", [ + { type: "user", timestamp: "2025-01-01T02:00:00Z", message: { content: "second" } }, + ]); + writeJsonl(tmpDir, "b.jsonl", [ + { type: "user", timestamp: "2025-01-01T01:00:00Z", message: { content: "first" } }, + ]); + + const events = parseAllSessions(tmpDir); + expect(events).toHaveLength(2); + expect(events[0].content).toBe("first"); + expect(events[1].content).toBe("second"); + }); + + it("respects since filter", () => { + const oldFile = writeJsonl(tmpDir, "old.jsonl", [ + { type: "user", timestamp: "2020-01-01T00:00:00Z", message: { content: "old" } }, + ]); + // Backdate the file + const { utimesSync } = require("fs"); + utimesSync(oldFile, new Date("2020-01-01"), new Date("2020-01-01")); + + writeJsonl(tmpDir, "new.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "new" } }, + ]); + + const events = parseAllSessions(tmpDir, { since: new Date("2024-01-01") }); + expect(events).toHaveLength(1); + expect(events[0].content).toBe("new"); + }); + }); +});