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 @@ -500,6 +500,8 @@ Manual contract definitions that supplement auto-extraction:

Environment variables are **fallbacks** — `.preflight/` config takes precedence when present.

> **💡 Ready-to-use example configs:** Copy [`examples/.preflight/`](examples/.preflight/) into your project root to get started quickly. See [`examples/.preflight/README.md`](examples/.preflight/README.md) for details.

---

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

Copy this directory into your project root to get started:

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

Then edit the files to match your project:

| File | Purpose |
|------|---------|
| `config.yml` | Profile, related projects, thresholds, embedding provider |
| `triage.yml` | Keyword rules and strictness for prompt classification |
| `contracts/*.yml` | Manual type/interface definitions for cross-service awareness |

All files are optional — preflight works without any config. These let you tune it for your team and codebase.

## Tips

- **Commit `.preflight/` to your repo** so the whole team shares the same rules
- **Start with `strictness: standard`**, then relax or tighten based on your experience
- **Add domain terms to `always_check`** that are frequently ambiguous in your codebase (e.g., "billing", "permissions")
- **Use contracts** for types that live in a separate repo or aren't auto-detected
31 changes: 31 additions & 0 deletions examples/.preflight/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# .preflight/config.yml
# Drop this in your project root. Every field is optional.
# See: https://github.com/TerminalGravity/preflight#configuration-reference

# Profile controls how much detail preflight returns.
# "minimal" — only flags ambiguous+, skips clarification detail
# "standard" — default behavior
# "full" — maximum detail on every non-trivial prompt
profile: standard

# Related projects for cross-service contract awareness.
# When your prompt mentions a keyword from a related project,
# triage escalates to cross-service and searches those projects.
related_projects:
- path: /Users/you/projects/auth-service
alias: auth
- path: /Users/you/projects/shared-types
alias: shared

# 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 provider for timeline search
# "local" uses Xenova (zero config, runs on-device)
# "openai" uses text-embedding-3-small (faster, needs API key)
embeddings:
provider: local
# openai_api_key: sk-... # only needed if provider is "openai"
35 changes: 35 additions & 0 deletions examples/.preflight/contracts/api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# .preflight/contracts/api.yml
# Manual contract definitions that supplement auto-extraction.
# Use these when preflight can't auto-detect your shared types,
# or when you want to be explicit about cross-service boundaries.

- name: User
kind: interface
description: Core user object 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: teamId
type: string
required: false

- name: ApiResponse
kind: interface
description: Standard API response wrapper
fields:
- name: data
type: T
required: true
- name: error
type: string
required: false
- name: meta
type: "{ page: number, total: number }"
required: false
36 changes: 36 additions & 0 deletions examples/.preflight/triage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# .preflight/triage.yml
# Controls the triage classification engine.
# Customize which prompts get flagged, skipped, or escalated.

rules:
# Prompts containing these words → always at least AMBIGUOUS.
# Add domain terms that are frequently underspecified in your codebase.
always_check:
- rewards
- permissions
- migration
- schema
- billing

# Prompts containing these words → TRIVIAL (pass through without checks).
# Safe, low-risk operations that don't need guardrails.
skip:
- commit
- format
- lint
- prettier

# Prompts containing these words → CROSS-SERVICE.
# Triggers a search across related_projects defined in config.yml.
cross_service_keywords:
- auth
- notification
- event
- webhook
- queue

# How aggressively to classify prompts.
# "relaxed" — more prompts pass as clear (experienced users)
# "standard" — balanced (recommended)
# "strict" — more prompts flagged as ambiguous (teams, onboarding)
strictness: standard
22 changes: 19 additions & 3 deletions src/tools/audit-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } from "../lib/git.js";
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";
import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js";
import { readdirSync, statSync } from "fs";
import { join } from "path";

/** Extract top-level work areas from file paths generically */
function detectWorkAreas(files: string[]): Set<string> {
Expand Down Expand Up @@ -36,7 +38,8 @@ export function registerAuditWorkspace(server: McpServer): void {
{},
async () => {
const docs = findWorkspaceDocs();
const recentFiles = run("git diff --name-only HEAD~10 2>/dev/null || echo ''").split("\n").filter(Boolean);
const recentFilesRaw = run(["diff", "--name-only", "HEAD~10"]);
const recentFiles = (recentFilesRaw.startsWith("[") ? "" : recentFilesRaw).split("\n").filter(Boolean);
const sections: string[] = [];

// Doc freshness
Expand Down Expand Up @@ -75,7 +78,20 @@ export function registerAuditWorkspace(server: McpServer): void {
// Check for gap trackers or similar tracking docs
const trackingDocs = Object.entries(docs).filter(([n]) => /gap|track|progress/i.test(n));
if (trackingDocs.length > 0) {
const testFilesCount = parseInt(run("find tests -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | wc -l").trim()) || 0;
const testFilesCount = (() => {
try {
const testsDir = join(PROJECT_DIR, "tests");
let count = 0;
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) walk(join(dir, entry.name));
else if (/\.(spec|test)\.(ts|tsx|js|jsx)$/.test(entry.name)) count++;
}
};
walk(testsDir);
return count;
} catch { return 0; }
})();
sections.push(`## Tracking Docs\n${trackingDocs.map(([n]) => {
const age = docStatus.find(d => d.name === n)?.ageHours ?? "?";
return `- .claude/${n} — last updated ${age}h ago`;
Expand Down
41 changes: 26 additions & 15 deletions src/tools/enrich-agent-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@ import { execFileSync } from "child_process";
import { join, basename } from "path";
import { createHash } from "crypto";

/** Sanitize user input for safe use in shell commands */
function shellEscape(s: string): string {
return s.replace(/[^a-zA-Z0-9_\-./]/g, "");
}

/** Detect package manager from lockfiles */
function detectPackageManager(): string {
if (existsSync(join(PROJECT_DIR, "pnpm-lock.yaml"))) return "pnpm";
Expand All @@ -25,35 +20,51 @@ function detectPackageManager(): string {
function findAreaFiles(area: string): string {
if (!area) return getDiffFiles("HEAD~3");

const safeArea = shellEscape(area);

// If area looks like a path, search directly
if (area.includes("/")) {
return run(`git ls-files -- '${safeArea}*' 2>/dev/null | head -20`);
const result = run(["ls-files", "--", `${area}*`]);
if (result && !result.startsWith("[")) {
return result.split("\n").filter(Boolean).slice(0, 20).join("\n");
}
return getDiffFiles("HEAD~3");
}

// Search for area keyword in git-tracked file paths
const files = run(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`);
if (files && !files.startsWith("[command failed")) return files;
const allFiles = run(["ls-files"]);
if (allFiles && !allFiles.startsWith("[")) {
const areaLower = area.toLowerCase();
const matched = allFiles.split("\n").filter(Boolean).filter((f) => f.toLowerCase().includes(areaLower)).slice(0, 20);
if (matched.length > 0) return matched.join("\n");
}

// Fallback to recently changed files
return getDiffFiles("HEAD~3");
}

/** 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");
const allFiles = run(["ls-files"]);
if (!allFiles || allFiles.startsWith("[")) return "";
const testFiles = allFiles.split("\n").filter((f) => /\.(spec|test)\.(ts|tsx|js|jsx)$/.test(f));

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");
if (!area) return testFiles.slice(0, 10).join("\n");

const areaLower = area.split(/\s+/)[0].toLowerCase();
const areaTests = testFiles.filter((f) => f.toLowerCase().includes(areaLower)).slice(0, 10);
return areaTests.length > 0 ? areaTests.join("\n") : testFiles.slice(0, 10).join("\n");
}

/** 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'`);
try {
const abs = join(PROJECT_DIR, firstFile);
const content = readFileSync(abs, "utf-8");
return content.split("\n").slice(0, 30).join("\n");
} catch {
return "could not read file";
}
}

// ---------------------------------------------------------------------------
Expand Down
18 changes: 10 additions & 8 deletions src/tools/scope-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ const STOP_WORDS = new Set([
"like", "some", "each", "only", "need", "want", "please", "update", "change",
]);

/** Shell-escape a string for use inside single quotes */
function shellEscape(s: string): string {
return s.replace(/'/g, "'\\''");
}

/** Safely parse git porcelain status lines */
function parsePortelainFiles(porcelain: string): string[] {
if (!porcelain.trim()) return [];
Expand Down Expand Up @@ -93,7 +88,7 @@ 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)";

Expand Down Expand Up @@ -127,8 +122,15 @@ export function registerScopeWork(server: McpServer): void {
.filter((k) => k.length > 2)
.slice(0, 5);
if (grepTerms.length > 0) {
const pattern = shellEscape(grepTerms.join("|"));
matchedFiles = run(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`);
const allFiles = run(["ls-files"]);
const re = new RegExp(grepTerms.join("|"), "i");
matchedFiles = allFiles
.split("\n")
.filter(Boolean)
.slice(0, 500)
.filter((f) => re.test(f))
.slice(0, 30)
.join("\n");
}

// Check which relevant dirs actually exist (with path traversal protection)
Expand Down
3 changes: 2 additions & 1 deletion src/tools/sequence-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ export function registerSequenceTasks(server: McpServer): void {
// For locality: infer directories from path-like tokens in task text
if (strategy === "locality") {
// Use git ls-files with a depth limit instead of find for performance
const gitFiles = run("git ls-files 2>/dev/null | head -1000");
const gitFilesRaw = run(["ls-files"]);
const gitFiles = gitFilesRaw.split("\n").slice(0, 1000).join("\n");
const knownDirs = new Set<string>();
for (const f of gitFiles.split("\n").filter(Boolean)) {
const parts = f.split("/");
Expand Down
16 changes: 12 additions & 4 deletions src/tools/session-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { execFileSync } from "child_process";
import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js";
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";
import { STATE_DIR, now } from "../lib/state.js";

/** Check if a CLI tool is available */
/** Check if a CLI tool is available on PATH */
function hasCommand(cmd: string): boolean {
const result = run(`command -v ${cmd} 2>/dev/null`);
return !!result && !result.startsWith("[command failed");
try {
execFileSync("which", [cmd], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
return true;
} catch { return false; }
}

export function registerSessionHandoff(server: McpServer): void {
Expand Down Expand Up @@ -44,7 +47,12 @@ 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 '[]'");
let openPRs = "[]";
try {
openPRs = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,title,headRefName"], {
encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch { /* gh not authed or no remote — skip */ }
if (openPRs && openPRs !== "[]") {
sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``);
}
Expand Down
10 changes: 5 additions & 5 deletions src/tools/sharpen-followup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ function parsePortelainFiles(output: string): string[] {
/** Get recently changed files, safe for first commit / shallow clones */
function getRecentChangedFiles(): string[] {
// Try HEAD~1..HEAD, fall back to just staged, then unstaged
const commands = [
"git diff --name-only HEAD~1 HEAD 2>/dev/null",
"git diff --name-only --cached 2>/dev/null",
"git diff --name-only 2>/dev/null",
const commands: string[][] = [
["diff", "--name-only", "HEAD~1", "HEAD"],
["diff", "--name-only", "--cached"],
["diff", "--name-only"],
];
const results = new Set<string>();
for (const cmd of commands) {
Expand Down Expand Up @@ -87,7 +87,7 @@ export function registerSharpenFollowup(server: McpServer): void {
// Gather context to resolve ambiguity
const contextFiles: string[] = [...(previous_files ?? [])];
const recentChanged = getRecentChangedFiles();
const porcelainOutput = run("git status --porcelain 2>/dev/null");
const porcelainOutput = run(["status", "--porcelain"]);
const untrackedOrModified = parsePortelainFiles(porcelainOutput);

const allKnownFiles = [...new Set([...contextFiles, ...recentChanged, ...untrackedOrModified])].filter(Boolean);
Expand Down
Loading
Loading