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.

7 changes: 5 additions & 2 deletions src/tools/audit-workspace.ts
Original file line number Diff line number Diff line change
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,10 @@ 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 = run(["ls-files", "tests/"])
.split("\n")
.filter(f => /\.(spec|test)\.(ts|tsx|js|jsx)$/.test(f))
.length;
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
34 changes: 20 additions & 14 deletions src/tools/enrich-agent-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@ import { execFileSync } from "child_process";
import { join, basename } from "path";
import { createHash } from "crypto";

/** Sanitize user input for safe use in shell commands */
function shellEscape(s: string): string {
return s.replace(/[^a-zA-Z0-9_\-./]/g, "");
}

/** Detect package manager from lockfiles */
function detectPackageManager(): string {
if (existsSync(join(PROJECT_DIR, "pnpm-lock.yaml"))) return "pnpm";
Expand All @@ -25,35 +20,46 @@ function detectPackageManager(): string {
function findAreaFiles(area: string): string {
if (!area) return getDiffFiles("HEAD~3");

const safeArea = shellEscape(area);
const allFiles = run(["ls-files"]).split("\n").filter(Boolean);

// If area looks like a path, search directly
if (area.includes("/")) {
return run(`git ls-files -- '${safeArea}*' 2>/dev/null | head -20`);
const matches = allFiles.filter(f => f.startsWith(area)).slice(0, 20);
if (matches.length > 0) return matches.join("\n");
}

// Search for area keyword in git-tracked file paths
const files = run(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`);
if (files && !files.startsWith("[command failed")) return files;
const pattern = new RegExp(area.replace(/[^a-zA-Z0-9_\-./]/g, ""), "i");
const matches = allFiles.filter(f => pattern.test(f)).slice(0, 20);
if (matches.length > 0) return matches.join("\n");

// Fallback to recently changed files
return getDiffFiles("HEAD~3");
}

/** 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");
const allFiles = run(["ls-files"]).split("\n").filter(Boolean);
const testFiles = allFiles.filter(f => /\.(spec|test)\.(ts|tsx|js|jsx)$/.test(f));

if (!area) return testFiles.slice(0, 10).join("\n");

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 keyword = area.split(/\s+/)[0].replace(/[^a-zA-Z0-9_\-./]/g, "");
const pattern = new RegExp(keyword, "i");
const matched = testFiles.filter(f => pattern.test(f)).slice(0, 10);
return matched.length > 0 ? matched.join("\n") : testFiles.slice(0, 10).join("\n");
}

/** 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'`);
try {
const content = readFileSync(join(PROJECT_DIR, firstFile), "utf-8");
return content.split("\n").slice(0, 30).join("\n");
} catch {
return "could not read file";
}
}

// ---------------------------------------------------------------------------
Expand Down
15 changes: 6 additions & 9 deletions src/tools/scope-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ const STOP_WORDS = new Set([
"like", "some", "each", "only", "need", "want", "please", "update", "change",
]);

/** Shell-escape a string for use inside single quotes */
function shellEscape(s: string): string {
return s.replace(/'/g, "'\\''");
}

/** Safely parse git porcelain status lines */
function parsePortelainFiles(porcelain: string): string[] {
if (!porcelain.trim()) return [];
Expand Down Expand Up @@ -93,9 +88,9 @@ export function registerScopeWork(server: McpServer): void {
const timestamp = now();
const currentBranch = branch ?? getBranch();
const recentCommits = getRecentCommits(10);
const porcelain = run("git status --porcelain");
const porcelain = run(["status", "--porcelain"]);
const dirtyFiles = parsePortelainFiles(porcelain);
const diffStat = dirtyFiles.length > 0 ? run("git diff --stat") : "(clean working tree)";
const diffStat = dirtyFiles.length > 0 ? run(["diff", "--stat"]) : "(clean working tree)";

// Scan for relevant files based on task keywords
const keywords = task.toLowerCase().split(/\s+/);
Expand Down Expand Up @@ -127,8 +122,10 @@ export function registerScopeWork(server: McpServer): void {
.filter((k) => k.length > 2)
.slice(0, 5);
if (grepTerms.length > 0) {
const pattern = shellEscape(grepTerms.join("|"));
matchedFiles = run(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`);
const allFiles = run(["ls-files"]);
const fileLines = allFiles.split("\n").filter(Boolean).slice(0, 500);
const pattern = new RegExp(grepTerms.join("|"), "i");
matchedFiles = fileLines.filter(f => pattern.test(f)).slice(0, 30).join("\n");
}

// Check which relevant dirs actually exist (with path traversal protection)
Expand Down
2 changes: 1 addition & 1 deletion src/tools/sequence-tasks.ts
Original file line number Diff line number Diff line change
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 = run(["ls-files"]).split("\n").slice(0, 1000).join("\n");
const knownDirs = new Set<string>();
for (const f of gitFiles.split("\n").filter(Boolean)) {
const parts = f.split("/");
Expand Down
19 changes: 16 additions & 3 deletions src/tools/session-handoff.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { existsSync, readFileSync } from "fs";
import { execFileSync } from "child_process";
import { join } from "path";
import { run, 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`);
return !!result && !result.startsWith("[command failed");
try {
execFileSync("which", [cmd], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
return true;
} catch {
return false;
}
}

export function registerSessionHandoff(server: McpServer): void {
Expand Down Expand Up @@ -44,7 +49,15 @@ 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 '[]'");
let openPRs = "[]";
try {
openPRs = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,title,headRefName"], {
cwd: process.cwd(),
encoding: "utf-8",
timeout: 10000,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch { /* gh not available or not in a repo */ }
if (openPRs && openPRs !== "[]") {
sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``);
}
Expand Down
10 changes: 5 additions & 5 deletions src/tools/sharpen-followup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ 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",
const commands: string[][] = [
["diff", "--name-only", "HEAD~1", "HEAD"],
["diff", "--name-only", "--cached"],
["diff", "--name-only"],
];
const results = new Set<string>();
for (const cmd of commands) {
Expand Down Expand Up @@ -87,7 +87,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
32 changes: 18 additions & 14 deletions src/tools/token-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,9 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } 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";
import { readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "fs";
import { join } from "path";

/** Shell-escape a filename for safe interpolation */
function shellEscape(s: string): string {
return s.replace(/'/g, "'\\''");
}

/**
* Grade thresholds rationale:
* - A (0-10): Minimal waste — small diffs, targeted reads, lean context
Expand Down Expand Up @@ -39,8 +34,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 @@ -62,9 +57,12 @@ export function registerTokenAudit(server: McpServer): void {
const largeFiles: string[] = [];

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 lines = parseInt(wc) || 0;
// Count lines using Node.js fs instead of shell wc
let lines = 0;
try {
const content = readFileSync(join(PROJECT_DIR, f), "utf-8");
lines = content.split("\n").length;
} catch { /* skip unreadable files */ }
estimatedContextTokens += lines * AVG_LINE_BYTES * AVG_TOKENS_PER_BYTE;
if (lines > 500) {
largeFiles.push(`${f} (${lines} lines)`);
Expand All @@ -80,8 +78,8 @@ 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 bytes = parseInt(stat) || 0;
let bytes = 0;
try { bytes = statSync(join(PROJECT_DIR, "CLAUDE.md")).size; } catch { /* ignore */ }
if (bytes > 5120) {
patterns.push(`CLAUDE.md is ${(bytes / 1024).toFixed(1)}KB — injected every session, burns tokens on paste`);
recommendations.push("Trim CLAUDE.md to essentials (<5KB). Move reference docs to files read on-demand");
Expand Down Expand Up @@ -139,7 +137,13 @@ 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)}'`);
: (() => {
const fd = openSync(toolLogPath, "r");
const buf = Buffer.alloc(MAX_TOOL_LOG_BYTES);
const bytesRead = readSync(fd, buf, 0, MAX_TOOL_LOG_BYTES, stat.size - MAX_TOOL_LOG_BYTES);
closeSync(fd);
return buf.subarray(0, bytesRead).toString("utf-8");
})();

const lines = raw.trim().split("\n").filter(Boolean);
totalToolCalls = lines.length;
Expand Down
60 changes: 42 additions & 18 deletions src/tools/verify-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,25 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getStatus } from "../lib/git.js";
import { PROJECT_DIR } from "../lib/files.js";
import { existsSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { execFileSync } from "child_process";
import { join } from "path";

/** Run a non-git command safely, returning stdout or empty string on failure */
function execSafe(cmd: string, args: string[], opts?: { timeout?: number }): string {
try {
return execFileSync(cmd, args, {
cwd: PROJECT_DIR,
encoding: "utf-8",
timeout: opts?.timeout || 30000,
maxBuffer: 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch (e: any) {
return e.stdout?.trim?.() || "";
}
}

/** Detect package manager from lockfiles */
function detectPM(): string {
if (existsSync(join(PROJECT_DIR, "pnpm-lock.yaml"))) return "pnpm";
Expand Down Expand Up @@ -34,7 +50,8 @@
/** 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 +72,9 @@
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 tscCmd = pm === "npx" ? "npx" : pm;
const tscArgs = pm === "npx" ? ["tsc", "--noEmit"] : ["exec", "tsc", "--", "--noEmit"];
const tscOutput = execSafe(tscCmd, tscArgs, { timeout: 60000 });
const errorLines = tscOutput.split("\n").filter(l => /error TS\d+/.test(l));
const typePassed = errorLines.length === 0;
checks.push({
Expand All @@ -80,39 +99,42 @@
// 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 = "";

Check failure on line 103 in src/tools/verify-completion.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

'testCmd' is never reassigned. Use 'const' instead

Check failure on line 103 in src/tools/verify-completion.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

'testCmd' is never reassigned. Use 'const' instead

// Build test command as [cmd, ...args] for execSafe
let testArgs: { cmd: string; args: string[] } | null = null;

if (runner === "playwright") {
const runnerCmd = `${pm === "npx" ? "npx" : `${pm} exec`} playwright test`;
const baseCmd = pm === "npx" ? "npx" : pm;
const baseArgs = pm === "npx" ? ["playwright", "test"] : ["exec", "playwright", "test"];
if (test_scope && test_scope !== "all") {
testCmd = test_scope.endsWith(".spec.ts") || test_scope.endsWith(".test.ts")
? `${runnerCmd} ${test_scope} --reporter=line 2>&1 | tail -20`
: `${runnerCmd} --grep "${test_scope}" --reporter=line 2>&1 | tail -20`;
testArgs = test_scope.endsWith(".spec.ts") || test_scope.endsWith(".test.ts")
? { cmd: baseCmd, args: [...baseArgs, test_scope, "--reporter=line"] }
: { cmd: baseCmd, args: [...baseArgs, "--grep", test_scope, "--reporter=line"] };
} else {
// Auto-detect from changed files
const changedTests = changedFiles.filter(f => /\.(spec|test)\.(ts|tsx|js)$/.test(f)).slice(0, 5);
if (changedTests.length > 0) {
testCmd = `${runnerCmd} ${changedTests.join(" ")} --reporter=line 2>&1 | tail -20`;
testArgs = { cmd: baseCmd, args: [...baseArgs, ...changedTests, "--reporter=line"] };
}
}
} else if (runner === "vitest" || runner === "jest") {
const runnerCmd = `${pm === "npx" ? "npx" : `${pm} exec`} ${runner}`;
const baseCmd = pm === "npx" ? "npx" : pm;
const baseArgs = pm === "npx" ? [runner] : ["exec", runner, "--"];
if (test_scope && test_scope !== "all") {
testCmd = `${runnerCmd} --run ${test_scope} 2>&1 | tail -20`;
testArgs = { cmd: baseCmd, args: [...baseArgs, "--run", test_scope] };
} else {
const changedTests = changedFiles.filter(f => /\.(spec|test)\.(ts|tsx|js)$/.test(f)).slice(0, 5);
if (changedTests.length > 0) {
testCmd = `${runnerCmd} --run ${changedTests.join(" ")} 2>&1 | tail -20`;
testArgs = { cmd: baseCmd, args: [...baseArgs, "--run", ...changedTests] };
}
}
} else if (test_scope) {
// No recognized runner but scope given — try npm test
testCmd = `${pm} test 2>&1 | tail -20`;
testArgs = { cmd: pm, args: ["test"] };
}

if (testCmd) {
const testResult = run(testCmd, { timeout: 120000 });
if (testArgs) {
const testResult = execSafe(testArgs.cmd, testArgs.args, { timeout: 120000 });
const testPassed = /pass/i.test(testResult) && !/fail/i.test(testResult);
checks.push({
name: "Tests",
Expand All @@ -130,7 +152,9 @@

// 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 buildCmd = pm === "npx" ? "npm" : pm;
const buildArgs = pm === "npx" ? ["run", "build"] : ["run", "build"];
const buildCheck = execSafe(buildCmd, buildArgs, { timeout: 60000 });
const buildPassed = !/\b[Ee]rror\b/.test(buildCheck) || /Successfully compiled/.test(buildCheck);
checks.push({
name: "Build",
Expand Down
Loading