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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,12 @@ 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 example configs:
> ```bash
> cp -r examples/.preflight /path/to/your/project/
> ```
> See [`examples/.preflight/README.md`](examples/.preflight/README.md) for details.

### `.preflight/config.yml`

Drop this in your project root. Every field is optional — defaults are sensible.
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 @@
# `.preflight/` Example Config

Copy this directory into your project root to configure preflight:

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

## Files

| File | Purpose |
|------|---------|
| `config.yml` | Main config — profile, related projects, thresholds, embeddings |
| `triage.yml` | Triage rules — which keywords trigger which classification level |
| `contracts/*.yml` | Manual contract definitions — supplement auto-extraction |

## Quick Setup

1. Copy the directory: `cp -r examples/.preflight ./`
2. Edit `config.yml` — set your `related_projects` paths
3. Edit `triage.yml` — add your domain-specific keywords to `always_check`
4. Optionally add contracts in `contracts/` for planned or external APIs
5. Commit `.preflight/` to your repo so your team shares the same config

All fields are optional. Defaults work well out of the box — only customize what you need.
29 changes: 29 additions & 0 deletions examples/.preflight/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# .preflight/config.yml — drop this in your project root
# All fields are optional. Defaults are sensible.
# See: https://github.com/TerminalGravity/preflight#configuration-reference

# 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 projects' indexes when your prompt
# touches shared contracts (types, routes, schemas).
related_projects:
# - path: /absolute/path/to/auth-service
# alias: auth-service
# - path: /absolute/path/to/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"
47 changes: 47 additions & 0 deletions examples/.preflight/contracts/api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# .preflight/contracts/api.yml — manual contract definitions
# These supplement auto-extracted contracts from your codebase.
# Manual definitions win on name conflicts with auto-extracted ones.
#
# Use this when:
# - You have contracts that aren't in code yet (planned APIs)
# - Auto-extraction misses something important
# - You want to document cross-service agreements explicitly

- 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: createdAt
type: Date
required: true

- name: "POST /api/users"
kind: route
description: Create a new user account
fields:
- name: body
type: "{ email: string, role: string }"
required: true
- name: response
type: "{ user: User, token: string }"
required: true

- name: "GET /api/users/:id"
kind: route
description: Fetch user by ID
fields:
- name: params
type: "{ id: string }"
required: true
- name: response
type: User
required: true
38 changes: 38 additions & 0 deletions examples/.preflight/triage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# .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 too vague without context
always_check:
- rewards
- permissions
- migration
- schema
# - billing # uncomment for your domain
# - onboarding

# Prompts containing these words → TRIVIAL (pass through immediately)
# Common low-risk commands that don't need analysis
skip:
- commit
- format
- lint
- "git status"
- "git log"

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

# How aggressively to classify
# "relaxed" — more prompts pass as clear (faster, less interruption)
# "standard" — balanced (recommended)
# "strict" — more prompts flagged as ambiguous (thorough, more interruptions)
strictness: standard
5 changes: 5 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
if (existsSync(configPath)) {
try {
const configYaml = readFileSync(configPath, "utf-8");
const configData = yamlLoad(configYaml) as any;

Check warning on line 81 in src/lib/config.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

Unexpected any. Specify a different type

if (configData) {
// Merge config data with defaults
Expand All @@ -96,7 +96,7 @@
if (existsSync(triagePath)) {
try {
const triageYaml = readFileSync(triagePath, "utf-8");
const triageData = yamlLoad(triageYaml) as any;

Check warning on line 99 in src/lib/config.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

Unexpected any. Specify a different type

if (triageData) {
if (triageData.rules) config.triage.rules = { ...config.triage.rules, ...triageData.rules };
Expand Down Expand Up @@ -151,6 +151,11 @@
return getConfig().related_projects.map(p => p.path);
}

/** Reset cached config (useful for tests and config reload) */
export function resetConfig(): void {
_config = null;
}

/** Check if .preflight/ directory exists */
export function hasPreflightConfig(): boolean {
return existsSync(join(PROJECT_DIR, ".preflight"));
Expand Down
53 changes: 53 additions & 0 deletions src/lib/preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Pure helper functions for the preflight_check tool.
* Extracted for testability — no side effects in pure functions.
*/

/** Extract file paths from prompt text */
export function extractFilePaths(prompt: string): string[] {
// Match standard paths (src/foo.ts) and dotfiles (.env, .gitignore)
const standard = prompt.match(/[\w\-./\\]+\.\w{1,6}/g) || [];
const dotfiles = prompt.match(/(?:^|\s)(\.[\w\-.]+)/g) || [];
const cleaned = dotfiles.map(s => s.trim());
return [...new Set([...standard, ...cleaned])];
}

/** Detect ambiguity signals in a prompt */
export function detectAmbiguity(prompt: string): string[] {
const issues: string[] = [];
const filePaths = extractFilePaths(prompt);

if (/\b(it|them|the thing|that|those|this|these)\b/i.test(prompt))
issues.push("Contains vague pronouns — clarify what 'it'/'them' refers to");
if (/\b(fix|update|change|refactor|improve)\b/i.test(prompt) && !filePaths.length)
issues.push("Vague verb without specific file targets");
if (prompt.trim().length < 40)
issues.push("Very short prompt — likely missing context");

return issues;
}

/** Estimate scope complexity from file paths */
export function estimateComplexity(filePaths: string[]): "SMALL" | "MEDIUM" | "LARGE" {
const hasMultipleFiles = filePaths.length > 3;
const hasMultipleDirs = new Set(filePaths.map(f => f.split("/")[0])).size > 2;
return hasMultipleFiles && hasMultipleDirs ? "LARGE" : filePaths.length > 1 ? "MEDIUM" : "SMALL";
}

/** Split a prompt into sequenced sub-tasks */
export function splitSubtasks(prompt: string): { task: string; risk: string }[] {
const parts = prompt
.split(/\b(?:then|after that|next|finally)\b|(?:,\s*and\s+)|(?:\band\b(?=\s+(?:update|add|remove|create|fix|change|refactor|implement|deploy)))/i)
.map(s => s.trim())
.filter(s => s.length > 5);

if (parts.length <= 1) {
return [{ task: prompt.slice(0, 100), risk: "🟡 MEDIUM" }];
}

return parts.map(part => {
const risk = /schema|migrat|database|config|env|deploy/i.test(part) ? "🔴 HIGH" :
/api|route|endpoint/i.test(part) ? "🟡 MEDIUM" : "🟢 LOW";
return { task: part.charAt(0).toUpperCase() + part.slice(1), risk };
});
}
37 changes: 6 additions & 31 deletions src/tools/preflight-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,12 @@ import { searchSemantic } from "../lib/timeline-db.js";
import { basename, join } from "path";
import { loadPatterns, matchPatterns, formatPatternMatches } from "../lib/patterns.js";

import { extractFilePaths, detectAmbiguity, estimateComplexity, splitSubtasks } from "../lib/preflight.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Extract file paths from prompt text */
function extractFilePaths(prompt: string): string[] {
const matches = prompt.match(/[\w\-./\\]+\.\w{1,6}/g) || [];
return [...new Set(matches)];
}

/** Verify files exist and return stats */
function verifyFiles(paths: string[]): string[] {
const lines: string[] = [];
Expand Down Expand Up @@ -104,10 +100,7 @@ function buildClarifySection(prompt: string): string[] {
}

// Ambiguity signals
const issues: string[] = [];
if (/\b(it|them|the thing|that|those|this|these)\b/i.test(prompt)) issues.push("Contains vague pronouns — clarify what 'it'/'them' refers to");
if (/\b(fix|update|change|refactor|improve)\b/i.test(prompt) && !extractFilePaths(prompt).length) issues.push("Vague verb without specific file targets");
if (prompt.trim().length < 40) issues.push("Very short prompt — likely missing context");
const issues = detectAmbiguity(prompt);

if (issues.length > 0) {
sections.push(`### ⚠️ Clarification Needed\n${issues.map(i => `- ${i}`).join("\n")}`);
Expand All @@ -127,34 +120,16 @@ function buildScopeSection(prompt: string): string[] {
}

// Estimate complexity
const hasMultipleFiles = filePaths.length > 3;
const hasMultipleDirs = new Set(filePaths.map(f => f.split("/")[0])).size > 2;
const complexity = hasMultipleFiles && hasMultipleDirs ? "LARGE" : filePaths.length > 1 ? "MEDIUM" : "SMALL";
const complexity = estimateComplexity(filePaths);
sections.push(`### Scope: ${complexity}`);

return sections;
}

/** Build sequence section for multi-step */
function buildSequenceSection(prompt: string): string[] {
// Split prompt into sub-tasks
const subtasks: string[] = [];

// Split on "and", "then", numbered lists, bullet points
const parts = prompt
.split(/\b(?:then|after that|next|finally)\b|(?:,\s*and\s+)|(?:\band\b(?=\s+(?:update|add|remove|create|fix|change|refactor|implement|deploy)))/i)
.map(s => s.trim())
.filter(s => s.length > 5);

if (parts.length > 1) {
for (let i = 0; i < parts.length; i++) {
const risk = /schema|migrat|database|config|env|deploy/i.test(parts[i]) ? "🔴 HIGH" :
/api|route|endpoint/i.test(parts[i]) ? "🟡 MEDIUM" : "🟢 LOW";
subtasks.push(`${i + 1}. ${parts[i].charAt(0).toUpperCase() + parts[i].slice(1)} — Risk: ${risk}`);
}
} else {
subtasks.push(`1. ${prompt.slice(0, 100)} — Risk: 🟡 MEDIUM`);
}
const tasks = splitSubtasks(prompt);
const subtasks = tasks.map((t, i) => `${i + 1}. ${t.task} — Risk: ${t.risk}`);

return [
`### Execution Plan`,
Expand Down
Loading
Loading