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
5 changes: 3 additions & 2 deletions package-lock.json

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

29 changes: 28 additions & 1 deletion src/lib/git.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFileSync } from "child_process";
import { execFileSync, execSync } from "child_process";
import { PROJECT_DIR } from "./files.js";
import type { RunError } from "../types.js";

Expand Down Expand Up @@ -35,6 +35,33 @@ function gitCmd(cmdStr: string, opts?: { timeout?: number }): string {
return run(cmdStr.split(/\s+/), opts);
}

/**
* Run a shell command string (supports pipes, redirects, ||, etc.).
* Use for non-git commands or complex pipelines. Prefer `run()` for simple git operations.
* Returns stdout on success, descriptive error string on failure.
*
* ⚠️ Only use with trusted, hardcoded command strings — never with user input.
*/
export function shell(cmd: string, opts: { timeout?: number } = {}): string {
try {
return execSync(cmd, {
cwd: PROJECT_DIR,
encoding: "utf-8",
timeout: opts.timeout || 10000,
maxBuffer: 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch (e: any) {
const timedOut = e.killed === true || e.signal === "SIGTERM";
if (timedOut) {
return `[timed out after ${opts.timeout || 10000}ms]`;
}
const output = e.stdout?.trim() || e.stderr?.trim();
if (output) return output;
return `[command failed: ${cmd} (exit ${e.status ?? "?"})]`;
}
}

/** Get the current branch name. */
export function getBranch(): string {
return run(["branch", "--show-current"]);
Expand Down
6 changes: 3 additions & 3 deletions src/tools/audit-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } from "../lib/git.js";
import { run, shell } from "../lib/git.js";
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";

/** Extract top-level work areas from file paths generically */
Expand Down Expand Up @@ -36,7 +36,7 @@ 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 recentFiles = run(["diff", "--name-only", "HEAD~10"]).split("\n").filter(Boolean);
const sections: string[] = [];

// Doc freshness
Expand Down Expand Up @@ -75,7 +75,7 @@ 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 = parseInt(shell("find tests -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | wc -l").trim()) || 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
8 changes: 4 additions & 4 deletions src/tools/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { writeFileSync, existsSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { run, getBranch, getStatus, getLastCommit, getStagedFiles } from "../lib/git.js";
import { run, shell, getBranch, getStatus, getLastCommit, getStagedFiles } from "../lib/git.js";
import { PROJECT_DIR } from "../lib/files.js";
import { appendLog, now } from "../lib/state.js";

Expand Down Expand Up @@ -84,11 +84,11 @@ ${dirty || "clean"}

if (commitResult === "no uncommitted changes") {
// Stage the checkpoint file too
run(`git add "${checkpointFile}"`);
const result = run(`${addCmd} && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>&1`);
run(["add", checkpointFile]);
const result = shell(`${addCmd} && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>&1`);
if (result.includes("commit failed") || result.includes("nothing to commit")) {
// Rollback: unstage if commit failed
run("git reset HEAD 2>/dev/null");
run(["reset", "HEAD"]);
commitResult = `commit failed: ${result}`;
} else {
commitResult = result;
Expand Down
6 changes: 3 additions & 3 deletions src/tools/clarify-intent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js";
import { run, shell, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js";
import { findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js";
import { searchSemantic } from "../lib/timeline-db.js";
import { getRelatedProjects } from "../lib/config.js";
Expand Down Expand Up @@ -152,10 +152,10 @@ export function registerClarifyIntent(server: McpServer): void {
let hasTestFailures = false;

if (!area || area.includes("test") || area.includes("fix") || area.includes("ui") || area.includes("api")) {
const typeErrors = run("pnpm tsc --noEmit 2>&1 | grep -c 'error TS' || echo '0'");
const typeErrors = shell("pnpm tsc --noEmit 2>&1 | grep -c 'error TS' || echo '0'");
hasTypeErrors = parseInt(typeErrors, 10) > 0;

const testFiles = run("find tests -name '*.spec.ts' -maxdepth 4 2>/dev/null | head -20");
const testFiles = shell("find tests -name '*.spec.ts' -maxdepth 4 2>/dev/null | head -20");
const failingTests = getTestFailures();
hasTestFailures = failingTests !== "all passing" && failingTests !== "no test report found";

Expand Down
14 changes: 7 additions & 7 deletions src/tools/enrich-agent-task.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getDiffFiles } from "../lib/git.js";
import { run, shell, getDiffFiles } from "../lib/git.js";
import { PROJECT_DIR } from "../lib/files.js";
import { getConfig, type RelatedProject } from "../lib/config.js";
import { existsSync, readFileSync } from "fs";
Expand Down Expand Up @@ -29,11 +29,11 @@ function findAreaFiles(area: string): string {

// If area looks like a path, search directly
if (area.includes("/")) {
return run(`git ls-files -- '${safeArea}*' 2>/dev/null | head -20`);
return shell(`git ls-files -- '${safeArea}*' 2>/dev/null | head -20`);
}

// Search for area keyword in git-tracked file paths
const files = run(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`);
const files = shell(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`);
if (files && !files.startsWith("[command failed")) return files;

// Fallback to recently changed files
Expand All @@ -42,18 +42,18 @@ function findAreaFiles(area: string): string {

/** 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");
if (!area) return shell("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10");

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");
const tests = shell(`git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | grep -i '${safeArea}' | head -10`);
return tests || shell("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10");
}

/** 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'`);
return shell(`head -30 '${shellEscape(firstFile)}' 2>/dev/null || echo 'could not read file'`);
}

// ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions src/tools/sequence-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// CATEGORY 6: sequence_tasks — Sequencing
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } from "../lib/git.js";
import { shell } from "../lib/git.js";
import { now } from "../lib/state.js";
import { PROJECT_DIR } from "../lib/files.js";
import { existsSync } from "fs";
Expand Down Expand Up @@ -90,7 +90,7 @@ 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 gitFiles = shell("git ls-files 2>/dev/null | head -1000");
const knownDirs = new Set<string>();
for (const f of gitFiles.split("\n").filter(Boolean)) {
const parts = f.split("/");
Expand Down
6 changes: 3 additions & 3 deletions src/tools/session-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js";
import { run, shell, 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 */
function hasCommand(cmd: string): boolean {
const result = run(`command -v ${cmd} 2>/dev/null`);
const result = shell(`command -v ${cmd} 2>/dev/null`);
return !!result && !result.startsWith("[command failed");
}

Expand Down Expand Up @@ -44,7 +44,7 @@ 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 '[]'");
const openPRs = shell("gh pr list --state open --json number,title,headRefName 2>/dev/null || echo '[]'");
if (openPRs && openPRs !== "[]") {
sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``);
}
Expand Down
19 changes: 10 additions & 9 deletions src/tools/sharpen-followup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// CATEGORY 4: sharpen_followup — Follow-up Specificity
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } from "../lib/git.js";
import { run, getStatus } from "../lib/git.js";
import { now } from "../lib/state.js";

/** Parse git porcelain output into deduplicated file paths, handling renames (R/C) */
Expand All @@ -27,15 +27,16 @@ 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",
// Try HEAD~1..HEAD, fall back to just staged, then unstaged
const argSets: string[][] = [
["diff", "--name-only", "HEAD~1", "HEAD"],
["diff", "--name-only", "--cached"],
["diff", "--name-only"],
];
const results = new Set<string>();
for (const cmd of commands) {
const out = run(cmd);
if (out) out.split("\n").filter(Boolean).forEach((f) => results.add(f));
for (const args of argSets) {
const out = run(args);
if (out && !out.startsWith("[")) out.split("\n").filter(Boolean).forEach((f) => results.add(f));
if (results.size > 0) break; // first successful source is enough
}
return [...results];
Expand Down Expand Up @@ -87,7 +88,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
12 changes: 6 additions & 6 deletions src/tools/token-audit.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// CATEGORY 5: token_audit — Token Efficiency
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } from "../lib/git.js";
import { run, shell } from "../lib/git.js";
import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js";
import { loadState, saveState, now, STATE_DIR } from "../lib/state.js";
import { readFileSync, existsSync, statSync } from "fs";
Expand Down Expand Up @@ -39,8 +39,8 @@ export function registerTokenAudit(server: McpServer): void {
let wasteScore = 0;

// 1. Git diff size & dirty file count
const diffStat = run("git diff --stat --no-color 2>/dev/null");
const dirtyFiles = run("git diff --name-only 2>/dev/null");
const diffStat = run(["diff", "--stat", "--no-color"]);
const dirtyFiles = run(["diff", "--name-only"]);
const dirtyList = dirtyFiles.split("\n").filter(Boolean);
const dirtyCount = dirtyList.length;

Expand All @@ -63,7 +63,7 @@ export function registerTokenAudit(server: McpServer): void {

for (const f of dirtyList.slice(0, 30)) {
// Use shell-safe quoting instead of interpolation
const wc = run(`wc -l < '${shellEscape(f)}' 2>/dev/null`);
const wc = shell(`wc -l < '${shellEscape(f)}' 2>/dev/null`);
const lines = parseInt(wc) || 0;
estimatedContextTokens += lines * AVG_LINE_BYTES * AVG_TOKENS_PER_BYTE;
if (lines > 500) {
Expand All @@ -80,7 +80,7 @@ export function registerTokenAudit(server: McpServer): void {
// 3. CLAUDE.md bloat check
const claudeMd = readIfExists("CLAUDE.md", 1);
if (claudeMd !== null) {
const stat = run(`wc -c < '${shellEscape("CLAUDE.md")}' 2>/dev/null`);
const stat = shell(`wc -c < '${shellEscape("CLAUDE.md")}' 2>/dev/null`);
const bytes = parseInt(stat) || 0;
if (bytes > 5120) {
patterns.push(`CLAUDE.md is ${(bytes / 1024).toFixed(1)}KB — injected every session, burns tokens on paste`);
Expand Down Expand Up @@ -139,7 +139,7 @@ export function registerTokenAudit(server: McpServer): void {
// Read with size cap: take the tail if too large
const raw = stat.size <= MAX_TOOL_LOG_BYTES
? readFileSync(toolLogPath, "utf-8")
: run(`tail -c ${MAX_TOOL_LOG_BYTES} '${shellEscape(toolLogPath)}'`);
: shell(`tail -c ${MAX_TOOL_LOG_BYTES} '${shellEscape(toolLogPath)}'`);

const lines = raw.trim().split("\n").filter(Boolean);
totalToolCalls = lines.length;
Expand Down
15 changes: 8 additions & 7 deletions src/tools/verify-completion.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getStatus } from "../lib/git.js";
import { run, shell, getStatus } from "../lib/git.js";
import { PROJECT_DIR } from "../lib/files.js";
import { existsSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { join } from "path";

/** Detect package manager from lockfiles */
Expand Down Expand Up @@ -34,7 +34,8 @@ function detectTestRunner(): string | null {
/** Check if a build script exists in package.json */
function hasBuildScript(): boolean {
try {
const pkg = JSON.parse(run("cat package.json 2>/dev/null"));
const raw = readFileSync(join(PROJECT_DIR, "package.json"), "utf-8");
const pkg = JSON.parse(raw);
return !!pkg?.scripts?.build;
} catch { return false; }
}
Expand All @@ -55,7 +56,7 @@ export function registerVerifyCompletion(server: McpServer): void {
const checks: { name: string; passed: boolean; detail: string }[] = [];

// 1. Type check (single invocation, extract both result and count)
const tscOutput = run(`${pm === "npx" ? "npx" : pm} tsc --noEmit 2>&1 | tail -20`);
const tscOutput = shell(`${pm === "npx" ? "npx" : pm} tsc --noEmit 2>&1 | tail -20`);
const errorLines = tscOutput.split("\n").filter(l => /error TS\d+/.test(l));
const typePassed = errorLines.length === 0;
checks.push({
Expand All @@ -80,7 +81,7 @@ export function registerVerifyCompletion(server: McpServer): void {
// 3. Tests
if (!skip_tests) {
const runner = detectTestRunner();
const changedFiles = run("git diff --name-only HEAD~1 2>/dev/null").split("\n").filter(Boolean);
const changedFiles = run(["diff", "--name-only", "HEAD~1"]).split("\n").filter(Boolean);
let testCmd = "";

if (runner === "playwright") {
Expand Down Expand Up @@ -112,7 +113,7 @@ export function registerVerifyCompletion(server: McpServer): void {
}

if (testCmd) {
const testResult = run(testCmd, { timeout: 120000 });
const testResult = shell(testCmd, { timeout: 120000 });
const testPassed = /pass/i.test(testResult) && !/fail/i.test(testResult);
checks.push({
name: "Tests",
Expand All @@ -130,7 +131,7 @@ export function registerVerifyCompletion(server: McpServer): void {

// 4. Build check (only if build script exists and not skipped)
if (!skip_build && hasBuildScript()) {
const buildCheck = run(`${pm === "npx" ? "npm run" : pm} build 2>&1 | tail -10`, { timeout: 60000 });
const buildCheck = shell(`${pm === "npx" ? "npm run" : pm} build 2>&1 | tail -10`, { timeout: 60000 });
const buildPassed = !/\b[Ee]rror\b/.test(buildCheck) || /Successfully compiled/.test(buildCheck);
checks.push({
name: "Build",
Expand Down
6 changes: 3 additions & 3 deletions src/tools/what-changed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getBranch, getDiffStat } from "../lib/git.js";
import { run, getBranch, getDiffStat, getDiffFiles } from "../lib/git.js";

export function registerWhatChanged(server: McpServer): void {
server.tool(
Expand All @@ -12,8 +12,8 @@ export function registerWhatChanged(server: McpServer): void {
async ({ since }) => {
const ref = since || "HEAD~5";
const diffStat = getDiffStat(ref);
const diffFiles = run(`git diff ${ref} --name-only 2>/dev/null || git diff HEAD~3 --name-only`);
const log = run(`git log ${ref}..HEAD --oneline 2>/dev/null || git log -5 --oneline`);
const diffFiles = getDiffFiles(ref);
const log = run(["log", `${ref}..HEAD`, "--oneline"]);
const branch = getBranch();

const fileList = diffFiles.split("\n").filter(Boolean);
Expand Down
Loading