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
4 changes: 3 additions & 1 deletion packages/cli/bin/tps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ async function main() {
" tps agent logs --id <agent-id> [--lines <N>] [--follow]\n" +
" tps agent healthcheck <agent-id>\n" +
" tps agent decommission --id <agent-id> [--force]\n" +
" tps agent commit --repo <path> --branch <name> --message <msg> --author <name> <email> [--path <f>] [--push] [--pr-title <t>]",
" tps agent commit --repo <path> --branch <name> --message <msg> --author <name> <email> [--path <f>] [--push] [--pr-title <t>] [--ack-scope-expansion] [--scope-warn-threshold <factor>]",
);
process.exit(1);
}
Expand Down Expand Up @@ -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");
Expand Down
89 changes: 87 additions & 2 deletions packages/cli/src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,6 +51,8 @@ export interface AgentArgs {
sandboxed?: boolean;
lines?: number;
follow?: boolean;
ackScopeExpansion?: boolean;
scopeWarnThreshold?: number;
}

interface AgentHealthcheckResult {
Expand Down Expand Up @@ -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<void> {
const { repo, branchName, commitMessage, authorName, authorEmail, paths, push: doPush, prTitle } = args;

if (!repo || !branchName || !commitMessage || !authorName || !authorEmail) {
failWith("Usage: tps agent commit --repo <path> --branch <name> --message <msg> --author <name> <email> [--path <file>] [--push] [--pr-title <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}`);
Expand All @@ -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) {
Expand Down
56 changes: 56 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading