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.

2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function validateRelatedProjects(): void {
}

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

const profile = getProfile();
Expand Down
2 changes: 1 addition & 1 deletion src/lib/files.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync, existsSync, readdirSync, statSync } from "fs";
import { join } from "path";
import type { DocInfo, DocMeta } from "../types.js";
import type { DocInfo } from "../types.js";

/** Single source of truth for the project directory. */
export const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
Expand Down
55 changes: 49 additions & 6 deletions src/lib/git.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,39 @@
import { execFileSync } from "child_process";
import { execFileSync, execSync } from "child_process";
import { PROJECT_DIR } from "./files.js";
import type { RunError } from "../types.js";

/**
* Strip shell-isms from a string command that will be passed to execFileSync.
* Removes trailing `2>/dev/null`, `2>&1`, and shell fallback chains (`|| ...`).
*/
function cleanShellArgs(parts: string[]): string[] {
const cleaned: string[] = [];
for (const p of parts) {
// Stop at shell operators
if (p === "||" || p === "&&" || p === "|") break;
// Skip stderr redirections
if (p === "2>/dev/null" || p === "2>&1") continue;
cleaned.push(p);
}
return cleaned;
}

/**
* Run a git command safely using execFileSync (no shell injection).
* Accepts an array of args (preferred) or a string (split on whitespace for backward compat).
* When given a string starting with "git ", the leading "git" is stripped automatically.
* Shell operators (2>/dev/null, ||, |) in strings are stripped — use shell() if you need them.
* Returns stdout on success. On failure, returns a descriptive error string.
*/
export function run(argsOrCmd: string | string[], opts: { timeout?: number } = {}): string {
const args = typeof argsOrCmd === "string" ? argsOrCmd.split(/\s+/) : argsOrCmd;
let args: string[];
if (typeof argsOrCmd === "string") {
const parts = argsOrCmd.split(/\s+/);
// Strip leading "git" if present (callers often pass "git diff ...")
if (parts[0] === "git") parts.shift();
args = cleanShellArgs(parts);
} else {
args = argsOrCmd;
}
try {
return execFileSync("git", args, {
cwd: PROJECT_DIR,
Expand All @@ -17,7 +42,7 @@
maxBuffer: 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch (e: any) {

Check warning on line 45 in src/lib/git.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

Unexpected any. Specify a different type

Check warning on line 45 in src/lib/git.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

Unexpected any. Specify a different type
const timedOut = e.killed === true || e.signal === "SIGTERM";
if (timedOut) {
return `[timed out after ${opts.timeout || 10000}ms]`;
Expand All @@ -30,9 +55,27 @@
}
}

/** Convenience: run a raw command string (split on spaces). Only for simple, known-safe commands. */
function gitCmd(cmdStr: string, opts?: { timeout?: number }): string {
return run(cmdStr.split(/\s+/), opts);
/**
* Run an arbitrary shell command string (with pipes, redirections, etc.).
* Use only for commands that genuinely need shell features.
* Returns stdout on success, or an error string on failure.
*/
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. */
Expand Down
1 change: 0 additions & 1 deletion src/lib/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ export function matchPatterns(
patterns: CorrectionPattern[],
): CorrectionPattern[] {
if (patterns.length === 0) return [];
const promptKeywords = extractKeywords(prompt);
const promptLower = prompt.toLowerCase();

return patterns.filter((p) => {
Expand Down
8 changes: 4 additions & 4 deletions src/lib/timeline-db.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as lancedb from "@lancedb/lancedb";
import { randomUUID } from "node:crypto";
import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { join, basename, resolve } from "node:path";
import { createEmbeddingProvider, type EmbeddingProvider, type EmbeddingConfig } from "./embeddings.js";
import type { ProjectMeta, ProjectRegistry, SearchScope } from "../types.js";
import { createEmbeddingProvider, type EmbeddingProvider } from "./embeddings.js";
import type { ProjectMeta, ProjectRegistry } from "../types.js";

// --- Types ---

Expand Down Expand Up @@ -342,7 +342,7 @@ export async function searchSemantic(
_score: 1 - (result._distance || 0),
});
}
} catch (error) {
} catch {
// Skip projects that don't exist or have issues
continue;
}
Expand Down
8 changes: 4 additions & 4 deletions src/tools/audit-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } from "../lib/git.js";
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";
import { run, shell } from "../lib/git.js";
import { findWorkspaceDocs } from "../lib/files.js";

/** Extract top-level work areas from file paths generically */
function detectWorkAreas(files: string[]): Set<string> {
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
6 changes: 3 additions & 3 deletions src/tools/checkpoint.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,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 { join } from "path";
import { run, 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}"`);
run(["add", checkpointFile]);
const result = run(`${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
36 changes: 1 addition & 35 deletions src/tools/generate-scorecard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function clamp(v: number): number {

const PATH_RE = /(?:\/[\w./-]+\.\w{1,6}|\b\w+\.\w{2,6}\b)/;
const FILE_EXT_RE = /\.\b(?:ts|tsx|js|jsx|py|rs|go|rb|java|c|cpp|h|css|scss|html|json|yaml|yml|toml|md|sql|sh)\b/;
const CORRECTION_PATTERNS = [/\bno\b/i, /\bwrong\b/i, /\bnot that\b/i, /\bi meant\b/i, /\bactually\b/i, /\binstead\b/i, /\bundo\b/i, /\brevert\b/i];


interface ParsedSession {
id: string;
Expand Down Expand Up @@ -419,40 +419,6 @@ function computeScorecard(
};
}

// ── Markdown Output ────────────────────────────────────────────────────────

function toMarkdown(sc: Scorecard): string {
const lines: string[] = [];
lines.push(`# 📊 Prompt Discipline Scorecard`);
lines.push(`**Project:** ${sc.project} | **Period:** ${sc.period} (${sc.date}) | **Overall: ${sc.overallGrade} (${sc.overall}/100)**\n`);

lines.push(`## Category Scores`);
lines.push(`| # | Category | Score | Grade |`);
lines.push(`|---|----------|-------|-------|`);
sc.categories.forEach((c, i) => {
lines.push(`| ${i + 1} | ${c.name} | ${c.score} | ${c.grade} |`);
});

lines.push(`\n## Highlights`);
lines.push(`- 🏆 **Best:** ${sc.highlights.best.name} (${sc.highlights.best.grade}) — ${sc.highlights.best.evidence}`);
lines.push(`- ⚠️ **Worst:** ${sc.highlights.worst.name} (${sc.highlights.worst.grade}) — ${sc.highlights.worst.evidence}`);

lines.push(`\n## Detailed Breakdown`);
sc.categories.forEach((c, i) => {
lines.push(`\n### ${i + 1}. ${c.name} — ${c.grade} (${c.score}/100)`);
lines.push(`Evidence: ${c.evidence}`);
if (c.examples?.bad?.length) {
lines.push(`\nExamples of vague follow-ups:`);
c.examples.bad.forEach((e) => lines.push(`- ❌ "${e}"`));
}
if (c.examples?.good?.length) {
lines.push(`\nExamples of specific follow-ups:`);
c.examples.good.forEach((e) => lines.push(`- ✅ "${e}"`));
}
});

return lines.join("\n");
}

// ── HTML / PDF Output ──────────────────────────────────────────────────────

Expand Down
2 changes: 0 additions & 2 deletions src/tools/onboard-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import * as path from "path";
import {
insertEvents,
getLastIndexedTimestamp,
listIndexedProjects,
getEventsTable,
registerProject,
loadProjectMeta,
saveProjectMeta
} from "../lib/timeline-db.js";
Expand Down
8 changes: 4 additions & 4 deletions src/tools/preflight-check.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
// Unified preflight_check — single entry point that triages and chains tools
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { triagePrompt, type TriageLevel, type TriageResult } from "../lib/triage.js";
import { triagePrompt, type TriageLevel } from "../lib/triage.js";
import { existsSync, statSync } from "fs";
import { resolve } from "path";
import { PROJECT_DIR } from "../lib/files.js";
import { run, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js";
import { getBranch, getStatus, getRecentCommits } from "../lib/git.js";
import { now } from "../lib/state.js";
import { findWorkspaceDocs } from "../lib/files.js";
import { getConfig } from "../lib/config.js";
import { searchSemantic } from "../lib/timeline-db.js";
import { basename, join } from "path";
import { loadPatterns, matchPatterns, formatPatternMatches } from "../lib/patterns.js";
import { basename } from "path";
import { loadPatterns, matchPatterns } from "../lib/patterns.js";

// ---------------------------------------------------------------------------
// Helpers
Expand Down
1 change: 0 additions & 1 deletion src/tools/scan-sessions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as fs from "fs";
import * as path from "path";
import { findSessionDirs, findSessionFiles } from "../lib/session-parser.js";

interface SessionInfo {
Expand Down
10 changes: 5 additions & 5 deletions src/tools/scope-work.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// CATEGORY 1: scope_work — Plans
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js";
import { run, shell, getBranch, getRecentCommits } from "../lib/git.js";
import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js";
import { searchSemantic } from "../lib/timeline-db.js";
import { getRelatedProjects } from "../lib/config.js";
import { now } from "../lib/state.js";
import { existsSync } from "fs";
import { join, normalize, resolve, basename } from "path";
import { join, resolve, basename } from "path";
import { loadAllContracts, searchContracts, formatContracts } from "../lib/contracts.js";

const STOP_WORDS = new Set([
Expand Down Expand Up @@ -93,9 +93,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 @@ -128,7 +128,7 @@ export function registerScopeWork(server: McpServer): void {
.slice(0, 5);
if (grepTerms.length > 0) {
const pattern = shellEscape(grepTerms.join("|"));
matchedFiles = run(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`);
matchedFiles = shell(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`);
}

// Check which relevant dirs actually exist (with path traversal protection)
Expand Down
Loading
Loading