Skip to content
Open
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
123 changes: 123 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,129 @@ The pattern is always the same: vague prompt → Claude guesses → wrong output

---

## Usage Walkthrough

Here's what preflight actually looks like in practice — real tool calls with realistic output.

### 1. Vague prompt → automatic clarification

You type into Claude Code:

```
> update the dashboard
```

Claude calls `preflight_check` with `{ "prompt": "update the dashboard" }`. Triage classifies this as **ambiguous** (< 50 chars, no file refs). You get back:

```
⚠️ AMBIGUOUS — needs clarification before proceeding

📂 Git State:
Branch: feat/analytics
3 uncommitted files: src/components/Dashboard.tsx, src/hooks/useDashboard.ts, src/api/metrics.ts

🔍 Ambiguity Signals:
- "update" is vague — add data? change layout? fix a bug? restyle?
- "dashboard" matches 4 files across the project
- No specific file path or line number referenced

💡 Try something like:
- "add the new revenue chart to src/components/Dashboard.tsx using the metrics API from src/api/metrics.ts"
- "fix the loading spinner that won't dismiss in the analytics dashboard"
- "update dashboard layout to 3-column grid per the Figma mock"
```

### 2. Multi-step task → scoped execution plan

```
> refactor the payments module to support Stripe and add webhook handling
```

Triage: **multi-step** (multiple verbs + cross-concern). `scope_work` and `sequence_tasks` fire automatically:

```
🔶 MULTI-STEP — breaking this down before starting

📋 Execution Plan:
Step 1: Extract payment interface (src/payments/provider.ts)
→ Define PaymentProvider interface with charge(), refund(), webhook()
→ Risk: LOW — new file, no existing consumers yet

Step 2: Implement Stripe adapter (src/payments/stripe.ts)
→ Implement PaymentProvider using stripe SDK
→ Dependencies: stripe npm package
→ Risk: MEDIUM — needs API keys, error handling for network failures

Step 3: Add webhook endpoint (src/api/webhooks/stripe.ts)
→ POST /api/webhooks/stripe with signature verification
→ Risk: HIGH — security-sensitive, needs crypto verification
→ ⚠️ Checkpoint recommended before this step

Step 4: Update consumers (src/checkout/*, src/subscriptions/*)
→ Swap direct calls to new PaymentProvider interface
→ 6 files affected

🧪 Suggested checkpoints: after Step 1, after Step 3
⏱️ Estimated tokens: ~45,000 across all steps
```

### 3. Correction pattern matching

You previously logged a correction with `log_correction`:

```
> log_correction: "When I said 'update types', I meant only the shared types in packages/shared, not the local type files in each service"
```

Next time you say:

```
> update the types
```

Preflight catches it:

```
⚠️ PATTERN MATCH — you've corrected this before

🔄 Previous correction (2 days ago):
"When I said 'update types', I meant only the shared types in packages/shared,
not the local type files in each service"

Did you mean shared types in packages/shared? If so, try:
"update shared types in packages/shared/types.ts"
```

### 4. Cross-service awareness

```
> add a loyalty points field to the user profile
```

Triage: **cross-service** (detected `user` + field change pattern, config has related projects). Contracts are searched:

```
🔗 CROSS-SERVICE — this change affects multiple projects

📂 Current project: web-app
- src/types/user.ts → UserProfile interface (line 12)
- prisma/schema.prisma → User model (line 34)

🔗 Related projects:
- mobile-app: src/api/types.ts → UserProfile (mirrors web-app, line 8)
- rewards-service: src/models/user.ts → UserRecord (includes loyalty_points already?)
- analytics-pipeline: src/schemas/user-events.avro → UserEvent schema

⚠️ Changing UserProfile requires updates in 3 services.
Suggested order:
1. prisma/schema.prisma (source of truth) + migrate
2. web-app src/types/user.ts
3. mobile-app src/api/types.ts (notify mobile team)
4. analytics-pipeline schema (may need backfill)
```

---

## Quick Start

### Option A: npx (fastest — no install)
Expand Down
211 changes: 211 additions & 0 deletions tests/lib/git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as child_process from "child_process";

// Mock child_process
vi.mock("child_process", () => ({
execFileSync: vi.fn(),
}));

// Mock files module to control PROJECT_DIR
vi.mock("../../src/lib/files.js", () => ({
PROJECT_DIR: "/tmp/fake-project",
}));

import {
run,
getBranch,
getStatus,
getRecentCommits,
getLastCommit,
getLastCommitTime,
getDiffFiles,
getStagedFiles,
getDiffStat,
} from "../../src/lib/git.js";

const mockedExecFileSync = vi.mocked(child_process.execFileSync);

describe("git lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe("run", () => {
it("executes git with array args and returns trimmed stdout", () => {
mockedExecFileSync.mockReturnValue(" main \n" as any);
const result = run(["branch", "--show-current"]);
expect(result).toBe("main");
expect(mockedExecFileSync).toHaveBeenCalledWith(
"git",
["branch", "--show-current"],
expect.objectContaining({ cwd: "/tmp/fake-project", encoding: "utf-8" })
);
});

it("splits string args on whitespace", () => {
mockedExecFileSync.mockReturnValue("ok" as any);
run("status --short");
expect(mockedExecFileSync).toHaveBeenCalledWith(
"git",
["status", "--short"],
expect.any(Object)
);
});

it("returns timeout message when process is killed", () => {
const err: any = new Error("timed out");
err.killed = true;
mockedExecFileSync.mockImplementation(() => { throw err; });
expect(run(["log"])).toMatch(/timed out/);
});

it("returns stderr on command failure", () => {
const err: any = new Error("failed");
err.stdout = "";
err.stderr = "fatal: not a git repository";
mockedExecFileSync.mockImplementation(() => { throw err; });
expect(run(["status"])).toBe("fatal: not a git repository");
});

it("returns ENOENT message when git is not found", () => {
const err: any = new Error("not found");
err.code = "ENOENT";
err.stdout = "";
err.stderr = "";
mockedExecFileSync.mockImplementation(() => { throw err; });
expect(run(["status"])).toBe("[git not found]");
});

it("returns generic failure message when no output available", () => {
const err: any = new Error("boom");
err.stdout = "";
err.stderr = "";
err.status = 128;
mockedExecFileSync.mockImplementation(() => { throw err; });
expect(run(["bad-cmd"])).toBe("[command failed: git bad-cmd (exit 128)]");
});

it("respects custom timeout option", () => {
mockedExecFileSync.mockReturnValue("ok" as any);
run(["log"], { timeout: 5000 });
expect(mockedExecFileSync).toHaveBeenCalledWith(
"git",
["log"],
expect.objectContaining({ timeout: 5000 })
);
});
});

describe("convenience functions", () => {
it("getBranch calls git branch --show-current", () => {
mockedExecFileSync.mockReturnValue("feature/test\n" as any);
expect(getBranch()).toBe("feature/test");
});

it("getStatus calls git status --short", () => {
mockedExecFileSync.mockReturnValue("M src/index.ts\n" as any);
expect(getStatus()).toBe("M src/index.ts");
});

it("getRecentCommits defaults to 5", () => {
mockedExecFileSync.mockReturnValue("abc123 first\ndef456 second" as any);
const result = getRecentCommits();
expect(result).toBe("abc123 first\ndef456 second");
expect(mockedExecFileSync).toHaveBeenCalledWith(
"git",
["log", "--oneline", "-5"],
expect.any(Object)
);
});

it("getRecentCommits accepts custom count", () => {
mockedExecFileSync.mockReturnValue("abc123 first" as any);
getRecentCommits(3);
expect(mockedExecFileSync).toHaveBeenCalledWith(
"git",
["log", "--oneline", "-3"],
expect.any(Object)
);
});

it("getLastCommit returns single line", () => {
mockedExecFileSync.mockReturnValue("abc123 fix bug\n" as any);
expect(getLastCommit()).toBe("abc123 fix bug");
});

it("getLastCommitTime returns timestamp", () => {
mockedExecFileSync.mockReturnValue("2026-03-15 14:00:00 -0700\n" as any);
expect(getLastCommitTime()).toBe("2026-03-15 14:00:00 -0700");
});

it("getStagedFiles returns staged file list", () => {
mockedExecFileSync.mockReturnValue("src/index.ts\nsrc/lib/git.ts" as any);
expect(getStagedFiles()).toBe("src/index.ts\nsrc/lib/git.ts");
});
});

describe("getDiffFiles", () => {
it("returns diff output on success", () => {
mockedExecFileSync.mockReturnValue("src/index.ts\nsrc/lib/git.ts" as any);
expect(getDiffFiles("HEAD~3")).toBe("src/index.ts\nsrc/lib/git.ts");
});

it("falls back to HEAD~1 when ref fails", () => {
let callCount = 0;
mockedExecFileSync.mockImplementation((_cmd, args: any) => {
callCount++;
if (callCount === 1) {
// First call fails
const err: any = new Error("bad ref");
err.stdout = "";
err.stderr = "[command failed]";
throw err;
}
return "fallback.ts" as any;
});
expect(getDiffFiles("nonexistent")).toBe("fallback.ts");
});

it("returns 'no commits' when both refs fail", () => {
mockedExecFileSync.mockImplementation(() => {
const err: any = new Error("fail");
err.stdout = "";
err.stderr = "[bad]";
throw err;
});
expect(getDiffFiles()).toBe("no commits");
});
});

describe("getDiffStat", () => {
it("returns stat output on success", () => {
mockedExecFileSync.mockReturnValue(" 3 files changed, 10 insertions(+)" as any);
expect(getDiffStat()).toBe("3 files changed, 10 insertions(+)");
});

it("falls back to HEAD~3 when default ref fails", () => {
let callCount = 0;
mockedExecFileSync.mockImplementation((_cmd, args: any) => {
callCount++;
if (callCount === 1) {
const err: any = new Error("bad");
err.stdout = "";
err.stderr = "[nope]";
throw err;
}
return "1 file changed" as any;
});
expect(getDiffStat()).toBe("1 file changed");
});

it("returns fallback message when both fail", () => {
mockedExecFileSync.mockImplementation(() => {
const err: any = new Error("fail");
err.stdout = "";
err.stderr = "[bad]";
throw err;
});
expect(getDiffStat()).toBe("no diff stats available");
});
});
});
Loading