diff --git a/README.md b/README.md index 6d03f5d..7e8b3f5 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/tests/lib/git.test.ts b/tests/lib/git.test.ts new file mode 100644 index 0000000..24c4c67 --- /dev/null +++ b/tests/lib/git.test.ts @@ -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"); + }); + }); +});