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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ This prevents the common failure mode: changing a shared type in one service and

### `.preflight/config.yml`

> **💡 Tip:** Copy [`examples/.preflight/`](examples/.preflight/) into your project root for ready-to-use config files with comments explaining every option.

Drop this in your project root. Every field is optional — defaults are sensible.

```yaml
Expand Down
25 changes: 25 additions & 0 deletions examples/.preflight/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Example `.preflight/` Configuration

Copy this directory into your project root:

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

Then edit the files to match your setup:

- **`config.yml`** — Profile, related projects, thresholds, embedding provider
- **`triage.yml`** — Which keywords trigger which triage levels
- **`contracts/api.yml`** — Manual API contract definitions (supplements auto-extraction)

All files are optional. Preflight works with sensible defaults out of the box.

## Quick Customization

**Want stricter triage?** Set `strictness: strict` in `triage.yml`.

**Have microservices?** Uncomment `related_projects` in `config.yml` and add your service paths.

**Using OpenAI embeddings?** Set `embeddings.provider: openai` and add your key in `config.yml`.

**Custom contracts?** Add more YAML files to `contracts/` — they'll be merged automatically.
27 changes: 27 additions & 0 deletions examples/.preflight/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# .preflight/config.yml — Drop this in your project root
# All fields are optional. Defaults are sensible.

# Profile controls overall verbosity:
# "minimal" — only flag ambiguous+, skip clarification detail
# "standard" — default behavior
# "full" — maximum detail on every non-trivial prompt
profile: standard

# Related projects for cross-service awareness.
# Preflight will search these when your prompt touches shared boundaries.
# related_projects:
# - path: /Users/you/code/auth-service
# alias: auth
# - path: /Users/you/code/shared-types
# alias: shared-types

# Behavioral thresholds
thresholds:
session_stale_minutes: 30 # warn if no activity for this long
max_tool_calls_before_checkpoint: 100 # suggest checkpoint after N tool calls
correction_pattern_threshold: 3 # min corrections before forming a pattern

# Embedding configuration
embeddings:
provider: local # "local" (Xenova, zero config) or "openai"
# openai_api_key: sk-... # only needed if provider is "openai"
34 changes: 34 additions & 0 deletions examples/.preflight/contracts/api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# .preflight/contracts/api.yml — Manual contract definitions
# These supplement auto-extracted contracts from your source code.
# Useful for documenting APIs that aren't easily inferred from code.

- name: User
kind: interface
description: Core user record
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: CreateUserRequest
kind: interface
description: POST /api/users request body
fields:
- name: email
type: string
required: true
- name: name
type: string
required: true
- name: tier
type: "'free' | 'pro' | 'enterprise'"
required: false
32 changes: 32 additions & 0 deletions examples/.preflight/triage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# .preflight/triage.yml — Controls the triage classification engine
# Customize which prompts get flagged, skipped, or escalated.

rules:
# Prompts containing these are always at least AMBIGUOUS
always_check:
- rewards
- permissions
- migration
- schema
- billing

# Prompts containing these pass through as TRIVIAL
skip:
- commit
- format
- lint
- prettier

# Prompts containing these escalate to CROSS-SERVICE
cross_service_keywords:
- auth
- notification
- event
- webhook
- shared

# How aggressively to classify:
# "relaxed" — more prompts pass as clear
# "standard" — balanced
# "strict" — more prompts flagged as ambiguous
strictness: standard
62 changes: 62 additions & 0 deletions src/lib/shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { execSync, execFileSync } from "child_process";
import { PROJECT_DIR } from "./files.js";

/**
* Run an arbitrary shell command (with shell: true).
* Use this for non-git commands that need pipes, redirects, or shell builtins.
* Returns stdout on success, descriptive error string on failure.
*/
export function shell(cmd: string, opts: { timeout?: number; cwd?: string } = {}): string {
try {
return execSync(cmd, {
cwd: opts.cwd || PROJECT_DIR,
encoding: "utf-8",
timeout: opts.timeout || 10000,
maxBuffer: 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch (e: any) {
if (e.killed === true || e.signal === "SIGTERM") {
return `[timed out after ${opts.timeout || 10000}ms]`;
}
const output = e.stdout?.trim() || e.stderr?.trim();
if (output) return output;
return `[command failed: ${cmd} (exit ${e.status ?? "?"})]`;
}
}

/**
* Run a non-git executable safely (no shell). Pass command and args separately.
* Returns stdout on success, descriptive error string on failure.
*/
export function exec(cmd: string, args: string[], opts: { timeout?: number; cwd?: string } = {}): string {
try {
return execFileSync(cmd, args, {
cwd: opts.cwd || PROJECT_DIR,
encoding: "utf-8",
timeout: opts.timeout || 10000,
maxBuffer: 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch (e: any) {
if (e.killed === true || e.signal === "SIGTERM") {
return `[timed out after ${opts.timeout || 10000}ms]`;
}
const output = e.stdout?.trim() || e.stderr?.trim();
if (output) return output;
if (e.code === "ENOENT") return `[${cmd} not found]`;
return `[command failed: ${cmd} ${args.join(" ")} (exit ${e.status ?? "?"})]`;
}
}

/**
* Check if a CLI tool is available on PATH.
*/
export function hasCommand(cmd: string): boolean {
try {
execFileSync("which", [cmd], { stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
return true;
} catch {
return false;
}
}
11 changes: 5 additions & 6 deletions src/tools/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,25 +70,24 @@ ${dirty || "clean"}
if (!staged) {
commitResult = "nothing staged — skipped commit (use 'tracked' or 'all' mode, or stage files first)";
}
addCmd = "true"; // noop, already staged
break;
}
case "all":
addCmd = "git add -A";
run(["add", "-A"]);
break;
case "tracked":
default:
addCmd = "git add -u";
run(["add", "-u"]);
break;
}

if (commitResult === "no uncommitted changes") {
// Stage the checkpoint file too
run(`git add "${checkpointFile}"`);
const result = run(`${addCmd} && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>&1`);
run(["add", checkpointFile]);
const result = run(["commit", "-m", commitMsg]);
if (result.includes("commit failed") || result.includes("nothing to commit")) {
// Rollback: unstage if commit failed
run("git reset HEAD 2>/dev/null");
run(["reset", "HEAD"]);
commitResult = `commit failed: ${result}`;
} else {
commitResult = result;
Expand Down
13 changes: 7 additions & 6 deletions src/tools/enrich-agent-task.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getDiffFiles } from "../lib/git.js";
import { shell } from "../lib/shell.js";
import { PROJECT_DIR } from "../lib/files.js";
import { getConfig, type RelatedProject } from "../lib/config.js";
import { existsSync, readFileSync } from "fs";
Expand Down Expand Up @@ -29,11 +30,11 @@ function findAreaFiles(area: string): string {

// If area looks like a path, search directly
if (area.includes("/")) {
return run(`git ls-files -- '${safeArea}*' 2>/dev/null | head -20`);
return shell(`git ls-files -- '${safeArea}'* 2>/dev/null | head -20`);
}

// Search for area keyword in git-tracked file paths
const files = run(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`);
const files = shell(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`);
if (files && !files.startsWith("[command failed")) return files;

// Fallback to recently changed files
Expand All @@ -42,18 +43,18 @@ function findAreaFiles(area: string): string {

/** Find related test files for an area */
function findRelatedTests(area: string): string {
if (!area) return run("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10");
if (!area) return shell("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10");

const safeArea = shellEscape(area.split(/\s+/)[0]);
const tests = run(`git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | grep -i '${safeArea}' | head -10`);
return tests || run("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10");
const tests = shell(`git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | grep -i '${safeArea}' | head -10`);
return tests || shell("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10");
}

/** Get an example pattern from the first matching file */
function getExamplePattern(files: string): string {
const firstFile = files.split("\n").filter(Boolean)[0];
if (!firstFile) return "no pattern available";
return run(`head -30 '${shellEscape(firstFile)}' 2>/dev/null || echo 'could not read file'`);
return shell(`head -30 '${shellEscape(firstFile)}' 2>/dev/null || echo 'could not read file'`);
}

// ---------------------------------------------------------------------------
Expand Down
7 changes: 4 additions & 3 deletions src/tools/scope-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js";
import { shell } from "../lib/shell.js";
import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js";
import { searchSemantic } from "../lib/timeline-db.js";
import { getRelatedProjects } from "../lib/config.js";
Expand Down Expand Up @@ -93,9 +94,9 @@ export function registerScopeWork(server: McpServer): void {
const timestamp = now();
const currentBranch = branch ?? getBranch();
const recentCommits = getRecentCommits(10);
const porcelain = run("git status --porcelain");
const porcelain = run(["status", "--porcelain"]);
const dirtyFiles = parsePortelainFiles(porcelain);
const diffStat = dirtyFiles.length > 0 ? run("git diff --stat") : "(clean working tree)";
const diffStat = dirtyFiles.length > 0 ? run(["diff", "--stat"]) : "(clean working tree)";

// Scan for relevant files based on task keywords
const keywords = task.toLowerCase().split(/\s+/);
Expand Down Expand Up @@ -128,7 +129,7 @@ export function registerScopeWork(server: McpServer): void {
.slice(0, 5);
if (grepTerms.length > 0) {
const pattern = shellEscape(grepTerms.join("|"));
matchedFiles = run(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`);
matchedFiles = shell(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`);
}

// Check which relevant dirs actually exist (with path traversal protection)
Expand Down
9 changes: 2 additions & 7 deletions src/tools/session-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js";
import { shell, hasCommand } from "../lib/shell.js";
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";
import { STATE_DIR, now } from "../lib/state.js";

/** Check if a CLI tool is available */
function hasCommand(cmd: string): boolean {
const result = run(`command -v ${cmd} 2>/dev/null`);
return !!result && !result.startsWith("[command failed");
}

export function registerSessionHandoff(server: McpServer): void {
server.tool(
"session_handoff",
Expand Down Expand Up @@ -44,7 +39,7 @@ export function registerSessionHandoff(server: McpServer): void {

// Only try gh if it exists
if (hasCommand("gh")) {
const openPRs = run("gh pr list --state open --json number,title,headRefName 2>/dev/null || echo '[]'");
const openPRs = shell("gh pr list --state open --json number,title,headRefName 2>/dev/null || echo '[]'");
if (openPRs && openPRs !== "[]") {
sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``);
}
Expand Down
20 changes: 13 additions & 7 deletions src/tools/token-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } from "../lib/git.js";
import { shell } from "../lib/shell.js";
import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js";
import { loadState, saveState, now, STATE_DIR } from "../lib/state.js";
import { readFileSync, existsSync, statSync } from "fs";
Expand Down Expand Up @@ -39,8 +40,8 @@ export function registerTokenAudit(server: McpServer): void {
let wasteScore = 0;

// 1. Git diff size & dirty file count
const diffStat = run("git diff --stat --no-color 2>/dev/null");
const dirtyFiles = run("git diff --name-only 2>/dev/null");
const diffStat = run(["diff", "--stat", "--no-color"]);
const dirtyFiles = run(["diff", "--name-only"]);
const dirtyList = dirtyFiles.split("\n").filter(Boolean);
const dirtyCount = dirtyList.length;

Expand All @@ -63,8 +64,11 @@ export function registerTokenAudit(server: McpServer): void {

for (const f of dirtyList.slice(0, 30)) {
// Use shell-safe quoting instead of interpolation
const wc = run(`wc -l < '${shellEscape(f)}' 2>/dev/null`);
const lines = parseInt(wc) || 0;
let lines = 0;
try {
const content = readFileSync(join(PROJECT_DIR, f), "utf-8");
lines = content.split("\n").length;
} catch { /* file may not exist or be unreadable */ }
estimatedContextTokens += lines * AVG_LINE_BYTES * AVG_TOKENS_PER_BYTE;
if (lines > 500) {
largeFiles.push(`${f} (${lines} lines)`);
Expand All @@ -80,8 +84,10 @@ export function registerTokenAudit(server: McpServer): void {
// 3. CLAUDE.md bloat check
const claudeMd = readIfExists("CLAUDE.md", 1);
if (claudeMd !== null) {
const stat = run(`wc -c < '${shellEscape("CLAUDE.md")}' 2>/dev/null`);
const bytes = parseInt(stat) || 0;
let bytes = 0;
try {
bytes = statSync(join(PROJECT_DIR, "CLAUDE.md")).size;
} catch { /* ignore */ }
if (bytes > 5120) {
patterns.push(`CLAUDE.md is ${(bytes / 1024).toFixed(1)}KB — injected every session, burns tokens on paste`);
recommendations.push("Trim CLAUDE.md to essentials (<5KB). Move reference docs to files read on-demand");
Expand Down Expand Up @@ -139,7 +145,7 @@ export function registerTokenAudit(server: McpServer): void {
// Read with size cap: take the tail if too large
const raw = stat.size <= MAX_TOOL_LOG_BYTES
? readFileSync(toolLogPath, "utf-8")
: run(`tail -c ${MAX_TOOL_LOG_BYTES} '${shellEscape(toolLogPath)}'`);
: shell(`tail -c ${MAX_TOOL_LOG_BYTES} '${shellEscape(toolLogPath)}'`);

const lines = raw.trim().split("\n").filter(Boolean);
totalToolCalls = lines.length;
Expand Down
Loading
Loading