diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index 5c900ce..a03f9d7 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -277,7 +277,7 @@ async function main() { " tps agent logs --id [--lines ] [--follow]\n" + " tps agent healthcheck \n" + " tps agent decommission --id [--force]\n" + - " tps agent commit --repo --branch --message --author [--path ] [--push] [--pr-title ]", + " tps agent commit --repo --branch --message --author [--path ] [--push] [--pr-title ] [--ack-scope-expansion] [--scope-warn-threshold ]", ); process.exit(1); } @@ -350,6 +350,8 @@ async function main() { paths: pathValues, push: process.argv.includes("--push"), prTitle: getFlag("pr-title"), + ackScopeExpansion: process.argv.includes("--ack-scope-expansion"), + scopeWarnThreshold: getFlag("scope-warn-threshold") ? parseInt(getFlag("scope-warn-threshold")!, 10) : undefined, }); } else if (action === "isolate") { const portArg = getFlag("port"); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 6b53150..42f046a 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -18,7 +18,7 @@ import { applyRole, loadPlugins } from "../plugins/index.js"; import { createInterface as createPromptInterface } from "node:readline/promises"; import { accessSync, constants, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, watch, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, resolve as resolvePathMod } from "node:path"; import { findNono, runCommandUnderNono, isNonoStrict } from "../utils/nono.js"; export interface AgentArgs { @@ -51,6 +51,8 @@ export interface AgentArgs { sandboxed?: boolean; lines?: number; follow?: boolean; + ackScopeExpansion?: boolean; + scopeWarnThreshold?: number; } interface AgentHealthcheckResult { @@ -866,11 +868,59 @@ function runGitOrFail(args: string[], cwd: string, label: string): string { return r.stdout; } + +function getMostRecentTaskMailBody(agentId: string) { + const mailDir = join(homedir(), ".tps", "mail", agentId, "cur"); + if (!existsSync(mailDir)) return null; + const files = readdirSync(mailDir); + if (files.length === 0) return null; + const sorted = files + .map(f => ({ f, mtime: statSync(join(mailDir, f)).mtime })) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + const latestFile = join(mailDir, sorted[0].f); + const raw = readFileSync(latestFile, "utf-8"); + try { + const mail = JSON.parse(raw); + return mail.body || null; + } catch { + return raw; + } +} + +function countFilesInText(text: string, repoRoot: string) { + const repoRootNorm = repoRoot.endsWith("/") ? repoRoot : repoRoot + "/"; + const lines = text.split("\n"); + const paths = new Set(); + const extRe = /\.[a-zA-Z0-9]+$/; + for (const line of lines) { + const tokens = line.trim().split(/\\s+/); + for (let token of tokens) { + token = token.replace(/^[.,:;!?"`']+|[.,:;!?"`']+$/g, ""); + if (!token) continue; + if (token.startsWith(repoRootNorm)) { paths.add(token); continue; } + if (token.startsWith("./") || token.startsWith("../")) { + const resolved = resolvePathMod(repoRoot, token); + if (resolved.startsWith(repoRoot)) paths.add(resolved); + continue; + } + if (extRe.test(token) && !token.includes("/")) { + const resolved = resolvePathMod(repoRoot, token); + if (resolved.startsWith(repoRoot)) paths.add(resolved); + continue; + } + if (token.includes("/") && token.includes(".") && !token.startsWith("http://") && !token.startsWith("https://")) { + const resolved = resolvePathMod(repoRoot, token); + if (resolved.startsWith(repoRoot)) paths.add(resolved); + } + } + } + return paths.size; +} async function commitAgentChanges(args: AgentArgs): Promise { const { repo, branchName, commitMessage, authorName, authorEmail, paths, push: doPush, prTitle } = args; if (!repo || !branchName || !commitMessage || !authorName || !authorEmail) { - failWith("Usage: tps agent commit --repo --branch --message --author [--path ] [--push] [--pr-title ]"); + failWith("Usage: tps agent commit --repo <path> --branch <name> --message <msg> --author <name> <email> [--path <file>] [--push] [--pr-title <title>] [--ack-scope-expansion] [--scope-warn-threshold <factor>]"); } if (!SIMPLE_EMAIL_RE.test(authorEmail!)) failWith(`Invalid author email: ${authorEmail}`); if (branchName!.startsWith("-") || !SAFE_GIT_REF_RE.test(branchName!)) failWith(`Invalid branch name: ${branchName}`); @@ -883,6 +933,41 @@ async function commitAgentChanges(args: AgentArgs): Promise<void> { const gitCheck = runGit(["rev-parse", "--is-inside-work-tree"], repoPath); if (!gitCheck.ok || gitCheck.stdout !== "true") failWith(`Not a git repository: ${repoPath}`); + // Scope expansion guardrail (ops-43zd) + const scopeAgentId = process.env.TPS_AGENT_ID ?? "anvil"; + const taskMailBody = getMostRecentTaskMailBody(scopeAgentId); + if (taskMailBody !== null) { + const hintFileCount = countFilesInText(taskMailBody, repoPath); + const scopeThresholdFactor = args.scopeWarnThreshold + ?? (process.env.TPS_SCOPE_WARN_THRESHOLD ? parseInt(process.env.TPS_SCOPE_WARN_THRESHOLD, 10) : undefined) + ?? 3; + let filesToStageCount = 0; + if (args.paths && args.paths.length > 0) { + const pathSet = new Set(); + for (const p of args.paths) { + const abs = resolvePath(repoPath, p); + if (isWithinDir(repoPath, abs)) pathSet.add(abs); + } + filesToStageCount = pathSet.size; + } else { + const diff = runGit(["diff", "--name-only"], repoPath); + const diffFiles = diff.status === 0 && diff.stdout ? diff.stdout.split("\n").filter(Boolean) : []; + const untracked = runGit(["ls-files", "--others", "--exclude-standard"], repoPath); + const untrackedFiles = untracked.status === 0 && untracked.stdout ? untracked.stdout.split("\n").filter(Boolean) : []; + filesToStageCount = new Set([...diffFiles, ...untrackedFiles]).size; + } + if (hintFileCount > 0 && filesToStageCount > hintFileCount * scopeThresholdFactor) { + const warning = `SCOPE EXPANSION DETECTED — original task hinted at ${hintFileCount} files; diff touches ${filesToStageCount} files. Continue with --ack-scope-expansion or revise.`; + if (args.ackScopeExpansion) { + // Write scope-warning to commit message footer + commitMessage = `${commitMessage}\n\nScope-warning: ${filesToStageCount} files vs spec hint of ${hintFileCount}.`; + } else { + // Emit visible warning (do not block) + console.error(warning); + } + } + } + // Create or checkout branch const branchExists = runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], repoPath).ok; if (branchExists) { diff --git a/src/main.ts b/src/main.ts new file mode 100755 index 0000000..975b8c5 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import { checkScopeExpansion } from "./utils/helper"; + +const args = process.argv.slice(2); +let thresholdMultiplier = 3; +let ackScopeExpansion = false; +let checkOnly = false; + +for (const arg of args) { + if (arg === "--ack-scope-expansion") { + ackScopeExpansion = true; + } else if (arg === "--check-only") { + checkOnly = true; + } else if (arg.startsWith("--threshold=")) { + const value = parseFloat(arg.split("=")[1]); + if (!isNaN(value) && value >= 0) { + thresholdMultiplier = value; + } + } +} + +const result = checkScopeExpansion("anvil", thresholdMultiplier); + +if (result.withinThreshold) { + // Within threshold - exit successfully + process.exit(0); +} + +// Scope expansion detected +if (ackScopeExpansion) { + // User has acknowledged the scope expansion + if (checkOnly) { + // In check-only mode, just output the info + console.log(`Scope check: ${result.diffCount} files vs spec hint of ${result.hintCount} (threshold: ${result.threshold})`); + process.exit(0); + } + + // Otherwise, exit successfully (don't block) + process.exit(0); +} + +if (checkOnly) { + // In check-only mode, output the warning and exit + console.error(result.warningMessage || "Scope expansion detected"); + process.exit(0); +} + +// Output the warning to stderr (does not block) +console.error(""); +console.error("╔══════════════════════════════════════════════════════════════════╗"); +console.error(`║ ⚠️ ${result.warningMessage}`); +console.error("╚══════════════════════════════════════════════════════════════════╝"); +console.error(""); + +// Always exit 0 - never blocks commit +process.exit(0); \ No newline at end of file diff --git a/src/utils/helper.ts b/src/utils/helper.ts new file mode 100644 index 0000000..71b3034 --- /dev/null +++ b/src/utils/helper.ts @@ -0,0 +1,220 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { homedir } from "node:os"; +import { spawnSync } from "node:child_process"; + +/** + * Interface for a TPS mail message + */ +interface TpsMailMessage { + id: string; + from: string; + to: string; + body: string; + timestamp: string; + read: boolean; +} + +/** + * Finds the most recent task mail in the agent's mail cur directory + * @returns The most recent mail message or null if none found + */ +export function findMostRecentTaskMail(agentId: string = "anvil"): TpsMailMessage | null { + const home = homedir(); + const mailDir = join(home, ".tps", "mail", agentId, "cur"); + + if (!existsSync(mailDir)) { + return null; + } + + try { + const files = readdirSync(mailDir) + .filter(file => file.endsWith(".json")) + .map(file => ({ + file, + path: join(mailDir, file), + mtime: statSync(join(mailDir, file)).mtime + })) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + if (files.length === 0) { + return null; + } + + const latestFile = files[0]; + const content = require("fs").readFileSync(latestFile.path, "utf8"); + return JSON.parse(content) as TpsMailMessage; + } catch (error) { + console.error(`Error reading mail: ${error}`); + return null; + } +} + +/** + * Extracts file path hints from a mail body using multiple heuristics + * @param mailBody The body text of the mail message + * @returns Array of unique file paths mentioned in the mail + */ +export function extractFilePathHints(mailBody: string): string[] { + if (!mailBody) return []; + + const hints = new Set<string>(); + + // Heuristic 1: Backtick-wrapped paths (`src/main.ts`, `packages/cli/bin/tps.ts`) + const backtickMatches = mailBody.match(/`[^`]+`/g) || []; + for (const match of backtickMatches) { + const trimmed = match.slice(1, -1); // Remove backticks + const filePath = extractTsJsPath(trimmed); + if (filePath) hints.add(filePath); + } + + // Heuristic 2: Inline paths like ~/agents/anvil/bin, ~/.tps/mail/anvil/cur + const inlineMatches = mailBody.match(/(?:~(?:\/|$|\/))|\/(?:[^\s\"'<>\[\]{}|\\^%&*+=;:,.]+(?:\/[^\s\"'<>\[\]{}|\\^%&*+=;:,.]+)*)/g) || []; + for (const match of inlineMatches) { + const filePath = extractTsJsPath(match); + if (filePath) hints.add(filePath); + } + + // Heuristic 3: Loose file mentions like "scripts/foo.sh" without backticks + const looseMatches = mailBody.match(/\b[a-zA-Z0-9_\/.-]+\/(?:[a-zA-Z0-9_\/.-]+\.)?(?:ts|js|sh|mjs|cjs|json|yml|yaml|md|toml|env)\b/g) || []; + for (const match of looseMatches) { + const filePath = extractTsJsPath(match); + if (filePath) hints.add(filePath); + } + + return Array.from(hints); +} + +/** + * Extracts a TypeScript/JavaScript file path from a string, returning null if not found + * @param input String that may contain a file path + * @returns File path if it looks like a TS/JS file, otherwise null + */ +function extractTsJsPath(input: string): string | null { + if (!input) return null; + + // Look for paths ending with common file extensions + const match = input.match(/([^\s\"'<>\[\]{}|\\^%&*+=;:,.]+\.(?:ts|js|sh|mjs|cjs|json|yml|yaml|md|toml|env))/); + if (match) { + return match[1]; + } + + return null; +} + +/** + * Gets the working-tree diff file count (staged + unstaged changes) + * @returns Number of files changed in the working tree + */ +export function getWorkingTreeDiffCount(repoPath: string = "."): number { + try { + // Get unstaged changes + const diffResult = spawnSync("git", ["diff", "--name-only"], { + cwd: repoPath, + encoding: "utf8" + }); + + // Get staged changes + const cachedResult = spawnSync("git", ["diff", "--cached", "--name-only"], { + cwd: repoPath, + encoding: "utf8" + }); + + let allFiles = ""; + + if (diffResult.status === 0 && diffResult.stdout) { + allFiles += diffResult.stdout; + } + + if (cachedResult.status === 0 && cachedResult.stdout) { + allFiles += cachedResult.stdout; + } + + // Split by newline, filter out empty lines, and get unique files + const files = allFiles + .split(/\r?\n/) + .filter(line => line.trim().length > 0) + .filter((value, index, self) => self.indexOf(value) === index); + + return files.length; + } catch (error) { + console.error(`Error getting diff count: ${error}`); + return 0; + } +} + +/** + * Gets the lines of code changes in the working tree + * @returns Number of lines changed (insertions + deletions) + */ +export function getWorkingTreeDiffLoc(repoPath: string = "."): number { + try { + const result = spawnSync("git", ["diff", "--stat"], { + cwd: repoPath, + encoding: "utf8" + }); + + if (result.status !== 0 || !result.stdout) { + return 0; + } + + // Parse output like " 5 files changed, 32 insertions(+), 12 deletions(-)" + const match = result.stdout.match(/(\d+)\s+insertions?\+/); + const insertions = match ? parseInt(match[1], 10) : 0; + + const match2 = result.stdout.match(/(\d+)\s+deletions?-/); + const deletions = match2 ? parseInt(match2[1], 10) : 0; + + return insertions + deletions; + } catch (error) { + console.error(`Error getting diff LOC: ${error}`); + return 0; + } +} + +/** + * Checks if scope expansion has occurred based on mail hints and working tree diff + * @param agentId The agent ID to check mail for + * @param thresholdMultiplier The multiplier for the threshold (default: 3) + * @returns Object with check results + */ +export function checkScopeExpansion( + agentId: string = "anvil", + thresholdMultiplier: number = 3 +): { + withinThreshold: boolean; + hintCount: number; + diffCount: number; + diffLoc: number; + threshold: number; + warningMessage?: string; +} { + const mail = findMostRecentTaskMail(agentId); + let hintCount = 0; + + if (mail && mail.body) { + const hints = extractFilePathHints(mail.body); + hintCount = hints.length; + } + + const diffCount = getWorkingTreeDiffCount(); + const diffLoc = getWorkingTreeDiffLoc(); + + const threshold = hintCount * thresholdMultiplier; + const withinThreshold = diffCount <= threshold; + + let warningMessage: string | undefined; + + if (!withinThreshold && hintCount > 0) { + warningMessage = `SCOPE EXPANSION DETECTED — original task hinted at ${hintCount} files; diff touches ${diffCount} files (${diffLoc} LOC). Continue with --ack-scope-expansion or revise.`; + } + + return { + withinThreshold, + hintCount, + diffCount, + diffLoc, + threshold, + warningMessage + }; +} \ No newline at end of file