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: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

// Main entry point
import { registerPreflightCheck } from "./tools/preflight-check.js";
import { registerPreflightInit } from "./tools/preflight-init.js";
// Category 1: Plans
import { registerScopeWork } from "./tools/scope-work.js";
// Category 2: Clarification
Expand Down Expand Up @@ -73,7 +74,7 @@
}

// Load config and validate related projects on startup
const config = getConfig();

Check warning on line 77 in src/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

'config' is assigned a value but never used

Check warning on line 77 in src/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

'config' is assigned a value but never used
validateRelatedProjects();

const profile = getProfile();
Expand All @@ -87,6 +88,7 @@

const toolRegistry: Array<[string, RegisterFn]> = [
["preflight_check", registerPreflightCheck],
["preflight_init", registerPreflightInit],
["scope_work", registerScopeWork],
["clarify_intent", registerClarifyIntent],
["enrich_agent_task", registerEnrichAgentTask],
Expand Down
11 changes: 11 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

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

View workflow job for this annotation

GitHub Actions / build-and-test (22)

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

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

View workflow job for this annotation

GitHub Actions / build-and-test (22)

Unexpected any. Specify a different type

if (triageData) {
if (triageData.rules) config.triage.rules = { ...config.triage.rules, ...triageData.rules };
Expand Down Expand Up @@ -154,4 +154,15 @@
/** Check if .preflight/ directory exists */
export function hasPreflightConfig(): boolean {
return existsSync(join(PROJECT_DIR, ".preflight"));
}

/** Load .preflight/rules.md content, or null if not present. */
export function loadRules(): string | null {
const rulesPath = join(PROJECT_DIR, ".preflight", "rules.md");
if (!existsSync(rulesPath)) return null;
try {
return readFileSync(rulesPath, "utf-8");
} catch {
return null;
}
}
33 changes: 30 additions & 3 deletions src/lib/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
*/

import { readLog, saveState, loadState } from "./state.js";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { PROJECT_DIR } from "./files.js";

// ── Types ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -163,10 +166,34 @@ export function savePatterns(patterns: CorrectionPattern[]): void {
saveState("patterns", { patterns, updated: new Date().toISOString() });
}

/** Load patterns from state. */
/** Load patterns from state, merged with .preflight/patterns.json if present. */
export function loadPatterns(): CorrectionPattern[] {
const state = loadState("patterns");
return (state.patterns as CorrectionPattern[]) || [];
const autoPatterns = (loadState("patterns").patterns as CorrectionPattern[]) || [];

// Merge version-controlled patterns from .preflight/patterns.json
const preflightPath = join(PROJECT_DIR, ".preflight", "patterns.json");
if (existsSync(preflightPath)) {
try {
const raw = JSON.parse(readFileSync(preflightPath, "utf-8"));
const manual: CorrectionPattern[] = Array.isArray(raw) ? raw : (raw.patterns || []);
// Merge: manual patterns take precedence by id
const byId = new Map(autoPatterns.map(p => [p.id, p]));
for (const mp of manual) {
if (byId.has(mp.id)) {
// Manual overrides auto — merge frequency (keep higher)
const existing = byId.get(mp.id)!;
byId.set(mp.id, { ...existing, ...mp, frequency: Math.max(existing.frequency, mp.frequency) });
} else {
byId.set(mp.id, mp);
}
}
return [...byId.values()];
} catch {
// Silently fall back to auto patterns on parse error
}
}

return autoPatterns;
}

/**
Expand Down
8 changes: 7 additions & 1 deletion src/tools/preflight-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { PROJECT_DIR } from "../lib/files.js";
import { run, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js";
import { now } from "../lib/state.js";
import { findWorkspaceDocs } from "../lib/files.js";
import { getConfig } from "../lib/config.js";
import { getConfig, loadRules } from "../lib/config.js";
import { searchSemantic } from "../lib/timeline-db.js";
import { basename, join } from "path";
import { loadPatterns, matchPatterns, formatPatternMatches } from "../lib/patterns.js";
Expand Down Expand Up @@ -223,6 +223,12 @@ export function registerPreflightCheck(server: McpServer): void {
`_Reasons: ${triage.reasons.join("; ")}_`,
];

// --- Project rules ---
const rules = loadRules();
if (rules) {
sections.push("", "## Project Rules (.preflight/rules.md)", rules.trim());
}

// --- Pattern warnings ---
if (patternMatches.length > 0) {
sections.push("");
Expand Down
161 changes: 161 additions & 0 deletions src/tools/preflight-init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join } from "path";
import { PROJECT_DIR } from "../lib/files.js";

const CONFIG_YML = `# .preflight/config.yml — Preflight configuration
# See: https://github.com/TerminalGravity/preflight

# Profile controls check depth: minimal | standard | full
profile: standard

# Related projects for cross-service awareness
# related_projects:
# - path: ../auth-service
# alias: auth
# - path: ../payments
# alias: payments

# Thresholds
thresholds:
session_stale_minutes: 30
max_tool_calls_before_checkpoint: 100
correction_pattern_threshold: 3

# Embedding provider: local | openai
embeddings:
provider: local
`;

const TRIAGE_YML = `# .preflight/triage.yml — Triage classification rules
# Controls how prompts are classified before deciding what checks to run.

strictness: standard # relaxed | standard | strict

rules:
# Always run full preflight for prompts matching these keywords
always_check:
- migration
- schema
- permissions
- rewards

# Skip preflight entirely for these (fast-pass)
skip:
- commit
- format
- lint

# Keywords that trigger cross-service checks
cross_service_keywords:
- auth
- notification
- event
- webhook
`;

const RULES_MD = `# Project Rules

<!-- Add project-specific rules that preflight should surface on every non-trivial check. -->
<!-- These appear in preflight_check output to remind the agent of conventions. -->

## Examples (delete these and add your own)

- Always run \`npm test\` before committing
- Never modify the migrations table directly
- Use the \`logger\` module, not \`console.log\`
`;

const PATTERNS_JSON = `[
{
"id": "example-wrong-import",
"pattern": "Used default import instead of named import",
"keywords": ["import", "default", "named"],
"frequency": 1,
"lastSeen": "",
"context": "Example pattern — delete or replace with real patterns",
"examples": ["import Foo from './foo' should be import { Foo } from './foo'"]
}
]
`;

const GITIGNORE = `# Auto-generated patterns go in preflight-state, not here
# This directory is for version-controlled config only
`;

export function registerPreflightInit(server: McpServer): void {
server.tool(
"preflight_init",
"Scaffold a .preflight/ config directory in the current project. Creates config.yml, triage.yml, rules.md, contracts/, and patterns.json with sensible defaults.",
{
project_dir: z.string().optional().describe("Project directory (defaults to current working directory)"),
force: z.boolean().default(false).describe("Overwrite existing files"),
},
async ({ project_dir, force }) => {
const dir = project_dir || PROJECT_DIR;
const preflightDir = join(dir, ".preflight");
const contractsDir = join(preflightDir, "contracts");

const created: string[] = [];
const skipped: string[] = [];

// Create directories
if (!existsSync(preflightDir)) {
mkdirSync(preflightDir, { recursive: true });
created.push(".preflight/");
}
if (!existsSync(contractsDir)) {
mkdirSync(contractsDir, { recursive: true });
created.push(".preflight/contracts/");
}

// Write files
const files: [string, string][] = [
["config.yml", CONFIG_YML],
["triage.yml", TRIAGE_YML],
["rules.md", RULES_MD],
["patterns.json", PATTERNS_JSON],
[".gitignore", GITIGNORE],
];

for (const [name, content] of files) {
const filePath = join(preflightDir, name);
if (existsSync(filePath) && !force) {
skipped.push(name);
} else {
writeFileSync(filePath, content, "utf-8");
created.push(name);
}
}

const lines: string[] = [
`# 🛫 Preflight Config Initialized`,
"",
`Directory: \`${preflightDir}\``,
"",
];

if (created.length > 0) {
lines.push(`**Created:** ${created.map(f => `\`${f}\``).join(", ")}`);
}
if (skipped.length > 0) {
lines.push(`**Skipped (already exist):** ${skipped.map(f => `\`${f}\``).join(", ")} — use \`force: true\` to overwrite`);
}

lines.push(
"",
"## Next Steps",
"1. Edit `config.yml` to set your profile and related projects",
"2. Customize `triage.yml` with project-specific keywords",
"3. Add team conventions to `rules.md`",
"4. Drop API contracts/schemas into `contracts/`",
"5. Commit `.preflight/` to version control",
"",
"Auto-generated correction patterns (from `log_correction`) merge with `patterns.json` at runtime.",
);

return { content: [{ type: "text" as const, text: lines.join("\n") }] };
}
);
}
78 changes: 78 additions & 0 deletions tests/lib/preflight-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

// We test the config and patterns loading with .preflight/ directory

describe(".preflight/ config directory", () => {
const testDir = join(tmpdir(), "preflight-config-test-" + Date.now());
const preflightDir = join(testDir, ".preflight");

beforeEach(() => {
mkdirSync(preflightDir, { recursive: true });
mkdirSync(join(preflightDir, "contracts"), { recursive: true });
});

afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});

it("should create the .preflight directory structure", () => {
expect(existsSync(preflightDir)).toBe(true);
expect(existsSync(join(preflightDir, "contracts"))).toBe(true);
});

it("should parse config.yml correctly", () => {
writeFileSync(
join(preflightDir, "config.yml"),
`profile: full\nthresholds:\n session_stale_minutes: 60\n`,
"utf-8"
);
const content = require("fs").readFileSync(join(preflightDir, "config.yml"), "utf-8");
expect(content).toContain("profile: full");
expect(content).toContain("session_stale_minutes: 60");
});

it("should parse triage.yml correctly", () => {
writeFileSync(
join(preflightDir, "triage.yml"),
`strictness: strict\nrules:\n always_check:\n - deploy\n`,
"utf-8"
);
const content = require("fs").readFileSync(join(preflightDir, "triage.yml"), "utf-8");
expect(content).toContain("strictness: strict");
expect(content).toContain("deploy");
});

it("should parse patterns.json correctly", () => {
const patterns = [
{
id: "test-pattern",
pattern: "Forgot to add return type",
keywords: ["return", "type"],
frequency: 3,
lastSeen: "2026-01-01",
context: "TypeScript function definitions",
examples: ["function foo() should be function foo(): string"],
},
];
writeFileSync(join(preflightDir, "patterns.json"), JSON.stringify(patterns), "utf-8");
const parsed = JSON.parse(
require("fs").readFileSync(join(preflightDir, "patterns.json"), "utf-8")
);
expect(parsed).toHaveLength(1);
expect(parsed[0].id).toBe("test-pattern");
expect(parsed[0].frequency).toBe(3);
});

it("should read rules.md content", () => {
writeFileSync(
join(preflightDir, "rules.md"),
"# Rules\n- Always run tests before committing\n",
"utf-8"
);
const content = require("fs").readFileSync(join(preflightDir, "rules.md"), "utf-8");
expect(content).toContain("Always run tests");
});
});
Loading