agent-roast 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/{chunk-FA47T742.js → chunk-W6JP5YWF.js} +288 -17
- package/dist/cli.js +10 -1
- package/dist/index.d.ts +127 -4
- package/dist/index.js +7 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -40,7 +40,8 @@ npx agent-roast # review what you have not committed yet (on a clean tr
|
|
|
40
40
|
npx agent-roast hook install # Claude Code, Codex and Cursor
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
For Claude Code there is also a plugin: `/plugin install agent-roast --marketplace Nari-Ab/agent-roast`.
|
|
43
|
+
For Claude Code there is also a plugin: `/plugin install agent-roast --marketplace Nari-Ab/agent-roast`. Any agent that speaks
|
|
44
|
+
MCP can also call agent-roast as a read-only tool, `agent-roast mcp` ([setup](docs/integrations.md#mcp-server)).
|
|
44
45
|
|
|
45
46
|
When the agent is about to finish, agent-roast reviews everything it changed. If a check got weaker, the agent is **sent back
|
|
46
47
|
once**, with the file, the line and the before and after, and told to fix the code instead. If it tries again, you get a
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import
|
|
4
|
+
import path12 from "path";
|
|
5
5
|
|
|
6
6
|
// src/git.ts
|
|
7
7
|
import { spawn, spawnSync } from "child_process";
|
|
@@ -3711,10 +3711,10 @@ var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
|
3711
3711
|
function clipEvidence(text, limit = 200) {
|
|
3712
3712
|
if (text.length <= limit) return text;
|
|
3713
3713
|
const arrow = text.indexOf(" \u2192 ");
|
|
3714
|
-
const
|
|
3715
|
-
if (arrow < 0) return
|
|
3714
|
+
const clip3 = (part, size) => part.length > size ? `${part.slice(0, size - 1)}\u2026` : part;
|
|
3715
|
+
if (arrow < 0) return clip3(text, limit);
|
|
3716
3716
|
const side = Math.floor((limit - 3) / 2);
|
|
3717
|
-
return `${
|
|
3717
|
+
return `${clip3(text.slice(0, arrow), side)} \u2192 ${clip3(text.slice(arrow + 3), side)}`;
|
|
3718
3718
|
}
|
|
3719
3719
|
async function replayHistory(options = {}) {
|
|
3720
3720
|
assertNotAborted(options.signal);
|
|
@@ -3848,11 +3848,11 @@ function describe(finding2, index) {
|
|
|
3848
3848
|
const evidence = clip(finding2.lineSnippet || finding2.reason, 220);
|
|
3849
3849
|
return [`${index}. ${where} [${finding2.type}]`, ` ${evidence}`];
|
|
3850
3850
|
}
|
|
3851
|
-
function formatAgentFeedback(result) {
|
|
3851
|
+
function formatAgentFeedback(result, subject = "the uncommitted changes") {
|
|
3852
3852
|
const ranked = mostImportantFirst(result.infractions, result.failedRules);
|
|
3853
3853
|
const blocking = ranked.filter((finding2) => result.failedRules.includes(finding2.type));
|
|
3854
3854
|
const notes = ranked.filter((finding2) => !result.failedRules.includes(finding2.type) && isCheckRule(finding2.type));
|
|
3855
|
-
const lines = [
|
|
3855
|
+
const lines = [`agent-roast: ${subject} make the checks easier to pass instead of making the code correct.`, ""];
|
|
3856
3856
|
blocking.slice(0, LISTED).forEach((finding2, index) => lines.push(...describe(finding2, index + 1)));
|
|
3857
3857
|
if (blocking.length > LISTED) lines.push(` \u2026 and ${blocking.length - LISTED} more.`);
|
|
3858
3858
|
if (notes.length) {
|
|
@@ -4116,10 +4116,278 @@ function installInstructions(options = {}) {
|
|
|
4116
4116
|
});
|
|
4117
4117
|
}
|
|
4118
4118
|
|
|
4119
|
+
// src/mcp.ts
|
|
4120
|
+
import * as fs9 from "fs/promises";
|
|
4121
|
+
import path10 from "path";
|
|
4122
|
+
import readline from "readline";
|
|
4123
|
+
var MODERN_VERSIONS = ["2026-07-28"];
|
|
4124
|
+
var LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
4125
|
+
var VERSION_KEY = "io.modelcontextprotocol/protocolVersion";
|
|
4126
|
+
var TOOL_TIMEOUT_MS = 25e3;
|
|
4127
|
+
var CACHE_MS = 36e5;
|
|
4128
|
+
var MAX_TEXT = 6e3;
|
|
4129
|
+
var LISTED_NOTES = 3;
|
|
4130
|
+
var INSTRUCTIONS = "Call review_worktree before you tell the user a task is done. It compares your uncommitted changes with HEAD and reports any test, assertion, coverage threshold, lint rule or CI step that was made weaker instead of fixing the code. If it reports findings, fix the code and leave the checks as they were; if a change to a check is really intended, ask the user first.";
|
|
4131
|
+
var READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
4132
|
+
var PATH_SCHEMA = { type: "string", description: "A directory inside the project. Defaults to the project the server was started in." };
|
|
4133
|
+
var MCP_TOOLS = [
|
|
4134
|
+
{
|
|
4135
|
+
name: "review_worktree",
|
|
4136
|
+
title: "Review uncommitted changes",
|
|
4137
|
+
description: "Compare everything not committed yet (staged, unstaged and untracked files) with HEAD and report changes that make tests, assertions, coverage thresholds, lint rules or CI easier to pass instead of fixing the code. Read-only and offline: nothing is run, written or sent.",
|
|
4138
|
+
inputSchema: { type: "object", properties: { path: PATH_SCHEMA }, additionalProperties: false },
|
|
4139
|
+
annotations: { title: "Review uncommitted changes", ...READ_ONLY }
|
|
4140
|
+
},
|
|
4141
|
+
{
|
|
4142
|
+
name: "review_changes",
|
|
4143
|
+
title: "Review a range of commits",
|
|
4144
|
+
description: "Compare two committed revisions, for example a branch against main, and report changes that weaken tests, assertions, coverage thresholds, lint rules or CI. The rules come from the base revision, so the change under review cannot switch them off. Read-only and offline.",
|
|
4145
|
+
inputSchema: {
|
|
4146
|
+
type: "object",
|
|
4147
|
+
properties: {
|
|
4148
|
+
base: { type: "string", description: "The revision to compare against, for example main or origin/main." },
|
|
4149
|
+
head: { type: "string", description: "The revision under review. Defaults to HEAD." },
|
|
4150
|
+
path: PATH_SCHEMA
|
|
4151
|
+
},
|
|
4152
|
+
required: ["base"],
|
|
4153
|
+
additionalProperties: false
|
|
4154
|
+
},
|
|
4155
|
+
annotations: { title: "Review a range of commits", ...READ_ONLY }
|
|
4156
|
+
},
|
|
4157
|
+
{
|
|
4158
|
+
name: "stop_hook",
|
|
4159
|
+
title: "Stop hook adapter",
|
|
4160
|
+
description: "Runs the same check as `agent-roast hook run` for plugin hooks of type mcp_tool and answers with the hook's JSON: it sends an agent back once when its changes weaken checks. Meant to be called by a hook, not by hand.",
|
|
4161
|
+
inputSchema: {
|
|
4162
|
+
type: "object",
|
|
4163
|
+
properties: {
|
|
4164
|
+
cwd: PATH_SCHEMA,
|
|
4165
|
+
stop_hook_active: { type: "string", description: '"true" when the agent is already continuing because of a Stop hook.' },
|
|
4166
|
+
loop_count: { type: "string", description: "How many times the agent was already sent back, for agents that count." }
|
|
4167
|
+
},
|
|
4168
|
+
additionalProperties: true
|
|
4169
|
+
},
|
|
4170
|
+
annotations: { title: "Stop hook adapter", ...READ_ONLY }
|
|
4171
|
+
}
|
|
4172
|
+
];
|
|
4173
|
+
var ToolInputError = class extends Error {
|
|
4174
|
+
};
|
|
4175
|
+
var ok = (id, result) => ({ jsonrpc: "2.0", id, result });
|
|
4176
|
+
var fail = (id, code, message, data) => ({ jsonrpc: "2.0", id, error: data === void 0 ? { code, message } : { code, message, data } });
|
|
4177
|
+
var clip2 = (text) => text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 1)}\u2026` : text;
|
|
4178
|
+
function optionalText(value, name) {
|
|
4179
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
4180
|
+
if (typeof value !== "string" || value.length > 300 || value.includes("\0")) throw new ToolInputError(`${name} must be a revision name of at most 300 characters.`);
|
|
4181
|
+
return value;
|
|
4182
|
+
}
|
|
4183
|
+
function summarize(result, subject) {
|
|
4184
|
+
const blocking = result.infractions.filter((finding2) => result.failedRules.includes(finding2.type));
|
|
4185
|
+
const lines = [];
|
|
4186
|
+
if (blocking.length) lines.push(formatAgentFeedback(result, subject));
|
|
4187
|
+
else {
|
|
4188
|
+
lines.push(result.filesChanged ? `agent-roast: no weakened checks in ${subject} (${result.filesInspected} of ${result.filesChanged} changed files compared).` : `agent-roast: nothing changed in ${subject}, so there is nothing to review.`);
|
|
4189
|
+
const notes = result.infractions.filter((finding2) => isCheckRule(finding2.type));
|
|
4190
|
+
if (notes.length) {
|
|
4191
|
+
lines.push("Worth a second look (these do not block):");
|
|
4192
|
+
for (const note of notes.slice(0, LISTED_NOTES)) {
|
|
4193
|
+
const where = `${terminalText(note.file ?? "unknown location")}${note.lineNumber ? `:${note.lineNumber}` : ""}`;
|
|
4194
|
+
lines.push(`- ${where} [${note.type}] ${terminalText(note.lineSnippet || note.reason).replace(/\s+/g, " ").trim().slice(0, 120)}`);
|
|
4195
|
+
}
|
|
4196
|
+
if (notes.length > LISTED_NOTES) lines.push(`- \u2026 and ${notes.length - LISTED_NOTES} more.`);
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
if (!result.complete) {
|
|
4200
|
+
const first = result.skippedFiles[0];
|
|
4201
|
+
lines.push(`agent-roast could not compare ${result.skippedFiles.length} changed file${result.skippedFiles.length === 1 ? "" : "s"}${first ? ` (for example ${terminalText(first.file)}: ${terminalText(first.reason).slice(0, 120)})` : ""}, so this result may be incomplete.`);
|
|
4202
|
+
}
|
|
4203
|
+
return lines.join("\n");
|
|
4204
|
+
}
|
|
4205
|
+
function createMcpServer(options) {
|
|
4206
|
+
const env = options.env ?? process.env;
|
|
4207
|
+
const timeoutMs = options.timeoutMs ?? TOOL_TIMEOUT_MS;
|
|
4208
|
+
const serverInfo = { name: "agent-roast", title: "Agent Roast", version: options.version };
|
|
4209
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
4210
|
+
const cancelled = /* @__PURE__ */ new Set();
|
|
4211
|
+
let allowed;
|
|
4212
|
+
const startDirectory = () => path10.resolve(options.cwd ?? (env.CLAUDE_PROJECT_DIR || env.CURSOR_PROJECT_DIR || process.cwd()));
|
|
4213
|
+
const roots = () => allowed ??= Promise.all(
|
|
4214
|
+
(options.roots?.length ? options.roots : [startDirectory()]).map((root) => fs9.realpath(path10.resolve(root)).catch(() => path10.resolve(root)))
|
|
4215
|
+
);
|
|
4216
|
+
async function gitCommonDirectory(directory) {
|
|
4217
|
+
try {
|
|
4218
|
+
const output = gitOutputPath((await readGit(directory, ["rev-parse", "--git-common-dir"], void 0, 8192)).toString());
|
|
4219
|
+
return await fs9.realpath(path10.resolve(directory, output));
|
|
4220
|
+
} catch {
|
|
4221
|
+
return null;
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
async function permitted(real, list) {
|
|
4225
|
+
for (const root of list) {
|
|
4226
|
+
const relative2 = path10.relative(root, real);
|
|
4227
|
+
if (relative2 === "" || relative2 !== ".." && !relative2.startsWith(".." + path10.sep) && !path10.isAbsolute(relative2)) return true;
|
|
4228
|
+
}
|
|
4229
|
+
const common = await gitCommonDirectory(real);
|
|
4230
|
+
if (!common) return false;
|
|
4231
|
+
for (const root of list) if (common === await gitCommonDirectory(root)) return true;
|
|
4232
|
+
return false;
|
|
4233
|
+
}
|
|
4234
|
+
async function project(requested) {
|
|
4235
|
+
if (requested !== void 0 && requested !== null && requested !== "" && (typeof requested !== "string" || requested.includes("\0"))) throw new ToolInputError("path must be a directory path.");
|
|
4236
|
+
const list = await roots();
|
|
4237
|
+
if (!options.roots?.length && list.every((root) => path10.parse(root).root === root)) {
|
|
4238
|
+
throw new ToolInputError("The server was started outside a project (its working directory is the filesystem root). Add --root <directory> to the server's command line.");
|
|
4239
|
+
}
|
|
4240
|
+
const target = typeof requested === "string" && requested ? path10.resolve(list[0], requested) : list[0];
|
|
4241
|
+
let real;
|
|
4242
|
+
try {
|
|
4243
|
+
real = await fs9.realpath(target);
|
|
4244
|
+
} catch {
|
|
4245
|
+
throw new ToolInputError(`${terminalText(typeof requested === "string" && requested ? requested : target)} does not exist.`);
|
|
4246
|
+
}
|
|
4247
|
+
if (!await permitted(real, list)) throw new ToolInputError("That path is outside the project this server was started in. Add --root <directory> to the server's command line to allow another location.");
|
|
4248
|
+
return real;
|
|
4249
|
+
}
|
|
4250
|
+
async function callTool(name, args, signal) {
|
|
4251
|
+
switch (name) {
|
|
4252
|
+
case "review_worktree":
|
|
4253
|
+
return summarize(await reviewWorktreeChanges({ cwd: await project(args.path), signal }), "the uncommitted changes");
|
|
4254
|
+
case "review_changes": {
|
|
4255
|
+
const base = optionalText(args.base, "base");
|
|
4256
|
+
if (!base) throw new ToolInputError("base is required, for example main or origin/main.");
|
|
4257
|
+
const head = optionalText(args.head, "head");
|
|
4258
|
+
const cwd = await project(args.path);
|
|
4259
|
+
return summarize(await reviewChanges({ cwd, base, head, signal }), `the changes between ${terminalText(base)} and ${terminalText(head ?? "HEAD")}`);
|
|
4260
|
+
}
|
|
4261
|
+
case "stop_hook": {
|
|
4262
|
+
const cwd = await project(args.cwd);
|
|
4263
|
+
const input = { hook_event_name: "Stop", cwd, stop_hook_active: args.stop_hook_active === true || args.stop_hook_active === "true" };
|
|
4264
|
+
const loops = Number(args.loop_count);
|
|
4265
|
+
if (args.loop_count !== void 0 && args.loop_count !== "" && Number.isFinite(loops)) input.loop_count = loops;
|
|
4266
|
+
const outcome2 = await runHook(JSON.stringify(input), { agent: "claude", cwd, env, timeoutMs });
|
|
4267
|
+
if (outcome2.stderr) process.stderr.write(outcome2.stderr);
|
|
4268
|
+
return outcome2.stdout.trim() || "{}";
|
|
4269
|
+
}
|
|
4270
|
+
default:
|
|
4271
|
+
throw new ToolInputError(`Unknown tool: ${terminalText(name).slice(0, 100)}`);
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
async function toolResult(id, name, rawArguments) {
|
|
4275
|
+
const controller = new AbortController();
|
|
4276
|
+
inflight.set(id, controller);
|
|
4277
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
4278
|
+
try {
|
|
4279
|
+
const args = rawArguments ?? {};
|
|
4280
|
+
if (typeof args !== "object" || Array.isArray(args)) throw new ToolInputError("arguments must be an object.");
|
|
4281
|
+
return { text: clip2(await callTool(name, args, controller.signal)), isError: false };
|
|
4282
|
+
} catch (error) {
|
|
4283
|
+
if (cancelled.has(id)) return null;
|
|
4284
|
+
if (controller.signal.aborted) {
|
|
4285
|
+
const limit = timeoutMs >= 1e3 ? `${Math.round(timeoutMs / 1e3)} seconds` : `${timeoutMs} ms`;
|
|
4286
|
+
return { text: `agent-roast: the check took longer than ${limit} and was stopped. Nothing was changed.`, isError: true };
|
|
4287
|
+
}
|
|
4288
|
+
const message = terminalText(error instanceof Error ? error.message : String(error));
|
|
4289
|
+
return { text: clip2(error instanceof ToolInputError ? message : `agent-roast: ${message}`), isError: true };
|
|
4290
|
+
} finally {
|
|
4291
|
+
clearTimeout(timer);
|
|
4292
|
+
inflight.delete(id);
|
|
4293
|
+
cancelled.delete(id);
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
async function dispatch(id, method, params) {
|
|
4297
|
+
const meta = params._meta && typeof params._meta === "object" ? params._meta : {};
|
|
4298
|
+
const requested = meta[VERSION_KEY];
|
|
4299
|
+
const modern = typeof requested === "string";
|
|
4300
|
+
if (modern && !MODERN_VERSIONS.includes(requested)) {
|
|
4301
|
+
return fail(id, -32022, "Unsupported protocol version", { supported: [...MODERN_VERSIONS, ...LEGACY_VERSIONS], requested });
|
|
4302
|
+
}
|
|
4303
|
+
const capabilities = { tools: { listChanged: false } };
|
|
4304
|
+
switch (method) {
|
|
4305
|
+
case "initialize": {
|
|
4306
|
+
const asked = params.protocolVersion;
|
|
4307
|
+
const protocolVersion = typeof asked === "string" && LEGACY_VERSIONS.includes(asked) ? asked : LEGACY_VERSIONS[0];
|
|
4308
|
+
return ok(id, { protocolVersion, capabilities, serverInfo, instructions: INSTRUCTIONS });
|
|
4309
|
+
}
|
|
4310
|
+
case "ping":
|
|
4311
|
+
return ok(id, {});
|
|
4312
|
+
case "server/discover":
|
|
4313
|
+
return ok(id, { resultType: "complete", supportedVersions: [...MODERN_VERSIONS, ...LEGACY_VERSIONS], capabilities, serverInfo, instructions: INSTRUCTIONS, ttlMs: CACHE_MS, cacheScope: "public" });
|
|
4314
|
+
case "tools/list":
|
|
4315
|
+
return ok(id, modern ? { resultType: "complete", tools: MCP_TOOLS, ttlMs: CACHE_MS, cacheScope: "public" } : { tools: MCP_TOOLS });
|
|
4316
|
+
case "tools/call": {
|
|
4317
|
+
const name = params.name;
|
|
4318
|
+
if (typeof name !== "string" || !MCP_TOOLS.some((tool) => tool.name === name)) {
|
|
4319
|
+
return fail(id, -32602, `Unknown tool: ${typeof name === "string" ? terminalText(name).slice(0, 100) : "(no name)"}`);
|
|
4320
|
+
}
|
|
4321
|
+
const outcome2 = await toolResult(id, name, params.arguments);
|
|
4322
|
+
if (!outcome2) return void 0;
|
|
4323
|
+
const content = [{ type: "text", text: outcome2.text }];
|
|
4324
|
+
return ok(id, modern ? { resultType: "complete", content, isError: outcome2.isError } : { content, isError: outcome2.isError });
|
|
4325
|
+
}
|
|
4326
|
+
default:
|
|
4327
|
+
return fail(id, -32601, `Method not found: ${terminalText(method).slice(0, 100)}`);
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
async function handleOne(message) {
|
|
4331
|
+
if (!message || typeof message !== "object" || Array.isArray(message) || message.jsonrpc !== "2.0") return fail(null, -32600, "Invalid Request");
|
|
4332
|
+
const request = message;
|
|
4333
|
+
if (typeof request.method !== "string") return void 0;
|
|
4334
|
+
const params = request.params && typeof request.params === "object" && !Array.isArray(request.params) ? request.params : {};
|
|
4335
|
+
if (typeof request.id !== "string" && typeof request.id !== "number") {
|
|
4336
|
+
const target = params.requestId;
|
|
4337
|
+
if (request.method === "notifications/cancelled" && (typeof target === "string" || typeof target === "number") && inflight.has(target)) {
|
|
4338
|
+
cancelled.add(target);
|
|
4339
|
+
inflight.get(target)?.abort();
|
|
4340
|
+
}
|
|
4341
|
+
return void 0;
|
|
4342
|
+
}
|
|
4343
|
+
return dispatch(request.id, request.method, params);
|
|
4344
|
+
}
|
|
4345
|
+
return {
|
|
4346
|
+
async handle(message) {
|
|
4347
|
+
if (!Array.isArray(message)) return handleOne(message);
|
|
4348
|
+
if (!message.length) return fail(null, -32600, "Invalid Request");
|
|
4349
|
+
const responses = (await Promise.all(message.map(handleOne))).filter((response) => response !== void 0);
|
|
4350
|
+
return responses.length ? responses : void 0;
|
|
4351
|
+
}
|
|
4352
|
+
};
|
|
4353
|
+
}
|
|
4354
|
+
async function serveMcp(options) {
|
|
4355
|
+
const server = createMcpServer(options);
|
|
4356
|
+
const output = options.output ?? process.stdout;
|
|
4357
|
+
output.on("error", () => {
|
|
4358
|
+
});
|
|
4359
|
+
const write = (message) => {
|
|
4360
|
+
output.write(JSON.stringify(message) + "\n");
|
|
4361
|
+
};
|
|
4362
|
+
const lines = readline.createInterface({ input: options.input ?? process.stdin, crlfDelay: Infinity });
|
|
4363
|
+
const running = /* @__PURE__ */ new Set();
|
|
4364
|
+
lines.on("line", (line) => {
|
|
4365
|
+
if (!line.trim()) return;
|
|
4366
|
+
let message;
|
|
4367
|
+
try {
|
|
4368
|
+
message = JSON.parse(line);
|
|
4369
|
+
} catch {
|
|
4370
|
+
write(fail(null, -32700, "Parse error"));
|
|
4371
|
+
return;
|
|
4372
|
+
}
|
|
4373
|
+
const task = server.handle(message).then((response) => {
|
|
4374
|
+
if (response !== void 0) write(response);
|
|
4375
|
+
}).catch((error) => {
|
|
4376
|
+
process.stderr.write(`agent-roast mcp: ${terminalText(error instanceof Error ? error.message : String(error))}
|
|
4377
|
+
`);
|
|
4378
|
+
}).finally(() => {
|
|
4379
|
+
running.delete(task);
|
|
4380
|
+
});
|
|
4381
|
+
running.add(task);
|
|
4382
|
+
});
|
|
4383
|
+
await new Promise((resolve2) => lines.once("close", () => resolve2()));
|
|
4384
|
+
await Promise.allSettled([...running]);
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4119
4387
|
// src/demo.ts
|
|
4120
|
-
import * as
|
|
4388
|
+
import * as fs10 from "fs";
|
|
4121
4389
|
import os2 from "os";
|
|
4122
|
-
import
|
|
4390
|
+
import path11 from "path";
|
|
4123
4391
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
4124
4392
|
import pc2 from "picocolors";
|
|
4125
4393
|
var CART = `export function total(prices: number[], discount = 0): number {
|
|
@@ -4161,7 +4429,7 @@ jobs:
|
|
|
4161
4429
|
${lenient ? " continue-on-error: true\n" : ""} run: npm test
|
|
4162
4430
|
`;
|
|
4163
4431
|
async function runDemo(options = {}) {
|
|
4164
|
-
const dir =
|
|
4432
|
+
const dir = fs10.mkdtempSync(path11.join(os2.tmpdir(), "agent-roast-demo-"));
|
|
4165
4433
|
try {
|
|
4166
4434
|
const git2 = (...args) => {
|
|
4167
4435
|
const run = spawnSync4(
|
|
@@ -4175,7 +4443,7 @@ async function runDemo(options = {}) {
|
|
|
4175
4443
|
"-c",
|
|
4176
4444
|
"commit.gpgsign=false",
|
|
4177
4445
|
"-c",
|
|
4178
|
-
`core.hooksPath=${
|
|
4446
|
+
`core.hooksPath=${path11.join(dir, ".no-hooks")}`,
|
|
4179
4447
|
...args
|
|
4180
4448
|
],
|
|
4181
4449
|
{ cwd: dir, env: getHermeticGitEnv(), encoding: "utf8", timeout: 3e4, windowsHide: true }
|
|
@@ -4183,9 +4451,9 @@ async function runDemo(options = {}) {
|
|
|
4183
4451
|
if (run.error || run.status !== 0) throw new ConfigError(`The demo needs Git to create a temporary repository: ${run.error?.message ?? run.stderr.trim()}`);
|
|
4184
4452
|
};
|
|
4185
4453
|
const write = (file, text) => {
|
|
4186
|
-
const destination =
|
|
4187
|
-
|
|
4188
|
-
|
|
4454
|
+
const destination = path11.join(dir, file);
|
|
4455
|
+
fs10.mkdirSync(path11.dirname(destination), { recursive: true });
|
|
4456
|
+
fs10.writeFileSync(destination, text);
|
|
4189
4457
|
};
|
|
4190
4458
|
git2("init", "-q", "-b", "main");
|
|
4191
4459
|
write("src/cart.ts", CART);
|
|
@@ -4219,7 +4487,7 @@ async function runDemo(options = {}) {
|
|
|
4219
4487
|
].join("\n");
|
|
4220
4488
|
return { exitCode: result.failedRules.length ? 0 : 1, output, result };
|
|
4221
4489
|
} finally {
|
|
4222
|
-
|
|
4490
|
+
fs10.rmSync(dir, { recursive: true, force: true });
|
|
4223
4491
|
}
|
|
4224
4492
|
}
|
|
4225
4493
|
|
|
@@ -4231,9 +4499,9 @@ var InvalidDetectorError = class extends Error {
|
|
|
4231
4499
|
}
|
|
4232
4500
|
};
|
|
4233
4501
|
async function roastRepository(options = {}) {
|
|
4234
|
-
const cwd =
|
|
4502
|
+
const cwd = path12.resolve(options.cwd || process.cwd());
|
|
4235
4503
|
const since = options.since || "90 days ago";
|
|
4236
|
-
const repoName =
|
|
4504
|
+
const repoName = path12.basename(cwd);
|
|
4237
4505
|
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
|
|
4238
4506
|
const gitStream = streamGitLog(cwd, since);
|
|
4239
4507
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
@@ -4248,7 +4516,7 @@ async function roastRepository(options = {}) {
|
|
|
4248
4516
|
return { summary, output };
|
|
4249
4517
|
}
|
|
4250
4518
|
async function auditPullRequest(options) {
|
|
4251
|
-
const cwd =
|
|
4519
|
+
const cwd = path12.resolve(options.cwd || process.cwd());
|
|
4252
4520
|
const format = options.format || "terminal";
|
|
4253
4521
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
4254
4522
|
for (const det of failOn) {
|
|
@@ -4416,6 +4684,9 @@ export {
|
|
|
4416
4684
|
INSTRUCTIONS_END,
|
|
4417
4685
|
INSTRUCTIONS_BLOCK,
|
|
4418
4686
|
installInstructions,
|
|
4687
|
+
MCP_TOOLS,
|
|
4688
|
+
createMcpServer,
|
|
4689
|
+
serveMcp,
|
|
4419
4690
|
runDemo,
|
|
4420
4691
|
InvalidDetectorError,
|
|
4421
4692
|
roastRepository,
|
package/dist/cli.js
CHANGED
|
@@ -34,10 +34,11 @@ import {
|
|
|
34
34
|
roastRepository,
|
|
35
35
|
runDemo,
|
|
36
36
|
runHook,
|
|
37
|
+
serveMcp,
|
|
37
38
|
terminalText,
|
|
38
39
|
uninstallHooks,
|
|
39
40
|
writeBaseline
|
|
40
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-W6JP5YWF.js";
|
|
41
42
|
|
|
42
43
|
// src/cli.ts
|
|
43
44
|
import { Command } from "commander";
|
|
@@ -590,4 +591,12 @@ hook.command("resume").description("End a pause").action(async () => {
|
|
|
590
591
|
process.exitCode = 2;
|
|
591
592
|
}
|
|
592
593
|
});
|
|
594
|
+
program.command("mcp").description("Run as an MCP server over stdio, so any MCP-capable coding agent can review its own changes (read-only, offline)").option("--root <directory...>", "a directory the tools may review (repeatable); defaults to the project the server was started in").action(async (opts) => {
|
|
595
|
+
try {
|
|
596
|
+
await serveMcp({ version: pkg.version, roots: opts.root });
|
|
597
|
+
} catch (error) {
|
|
598
|
+
console.error(`agent-roast mcp: ${errorMessage(error)}`);
|
|
599
|
+
process.exitCode = 1;
|
|
600
|
+
}
|
|
601
|
+
});
|
|
593
602
|
program.parse(process.argv);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Readable } from 'stream';
|
|
1
|
+
import { Readable, Writable } from 'stream';
|
|
2
2
|
|
|
3
3
|
/** A reviewed allowance for a precise pattern and number of occurrences.
|
|
4
4
|
* No wildcard, inline bypass, source snippet or executable policy is needed. */
|
|
@@ -493,9 +493,9 @@ interface HookOutcome {
|
|
|
493
493
|
}
|
|
494
494
|
/**
|
|
495
495
|
* What the agent reads when it is sent back: the offending changes, then the instruction to fix the code rather than the
|
|
496
|
-
* checks. Short on purpose, since it is added to the agent's context.
|
|
496
|
+
* checks. Short on purpose, since it is added to the agent's context. `subject` names what was reviewed.
|
|
497
497
|
*/
|
|
498
|
-
declare function formatAgentFeedback(result: ReviewResult): string;
|
|
498
|
+
declare function formatAgentFeedback(result: ReviewResult, subject?: string): string;
|
|
499
499
|
/** The time a pause lasts until, or null when the hook is active. Expired pauses are cleaned up. */
|
|
500
500
|
declare function hookPausedUntil(cwd: string, now?: Date): Promise<Date | null>;
|
|
501
501
|
declare function pauseHook(cwd: string, until: Date): Promise<string>;
|
|
@@ -558,6 +558,129 @@ declare function installInstructions(options?: {
|
|
|
558
558
|
action: "created" | "updated" | "unchanged";
|
|
559
559
|
}[];
|
|
560
560
|
|
|
561
|
+
/**
|
|
562
|
+
* A small MCP server over stdio, so any agent that speaks MCP can ask "did my changes weaken a check?" before it says it is
|
|
563
|
+
* done. It is read-only and offline like the rest of agent-roast, and written without a dependency on purpose: the built files
|
|
564
|
+
* contain no network code, which a test checks. It answers both protocol eras: the initialize handshake of 2025-11-25 and
|
|
565
|
+
* earlier, and the per-request metadata of 2026-07-28 (modelcontextprotocol.io/specification/2026-07-28/basic/versioning).
|
|
566
|
+
*/
|
|
567
|
+
type RpcId = string | number;
|
|
568
|
+
type RpcResponse = {
|
|
569
|
+
jsonrpc: "2.0";
|
|
570
|
+
id: RpcId | null;
|
|
571
|
+
} & ({
|
|
572
|
+
result: unknown;
|
|
573
|
+
} | {
|
|
574
|
+
error: {
|
|
575
|
+
code: number;
|
|
576
|
+
message: string;
|
|
577
|
+
data?: unknown;
|
|
578
|
+
};
|
|
579
|
+
});
|
|
580
|
+
declare const MCP_TOOLS: readonly [{
|
|
581
|
+
readonly name: "review_worktree";
|
|
582
|
+
readonly title: "Review uncommitted changes";
|
|
583
|
+
readonly description: string;
|
|
584
|
+
readonly inputSchema: {
|
|
585
|
+
readonly type: "object";
|
|
586
|
+
readonly properties: {
|
|
587
|
+
readonly path: {
|
|
588
|
+
readonly type: "string";
|
|
589
|
+
readonly description: "A directory inside the project. Defaults to the project the server was started in.";
|
|
590
|
+
};
|
|
591
|
+
};
|
|
592
|
+
readonly additionalProperties: false;
|
|
593
|
+
};
|
|
594
|
+
readonly annotations: {
|
|
595
|
+
readonly readOnlyHint: true;
|
|
596
|
+
readonly destructiveHint: false;
|
|
597
|
+
readonly idempotentHint: true;
|
|
598
|
+
readonly openWorldHint: false;
|
|
599
|
+
readonly title: "Review uncommitted changes";
|
|
600
|
+
};
|
|
601
|
+
}, {
|
|
602
|
+
readonly name: "review_changes";
|
|
603
|
+
readonly title: "Review a range of commits";
|
|
604
|
+
readonly description: string;
|
|
605
|
+
readonly inputSchema: {
|
|
606
|
+
readonly type: "object";
|
|
607
|
+
readonly properties: {
|
|
608
|
+
readonly base: {
|
|
609
|
+
readonly type: "string";
|
|
610
|
+
readonly description: "The revision to compare against, for example main or origin/main.";
|
|
611
|
+
};
|
|
612
|
+
readonly head: {
|
|
613
|
+
readonly type: "string";
|
|
614
|
+
readonly description: "The revision under review. Defaults to HEAD.";
|
|
615
|
+
};
|
|
616
|
+
readonly path: {
|
|
617
|
+
readonly type: "string";
|
|
618
|
+
readonly description: "A directory inside the project. Defaults to the project the server was started in.";
|
|
619
|
+
};
|
|
620
|
+
};
|
|
621
|
+
readonly required: readonly ["base"];
|
|
622
|
+
readonly additionalProperties: false;
|
|
623
|
+
};
|
|
624
|
+
readonly annotations: {
|
|
625
|
+
readonly readOnlyHint: true;
|
|
626
|
+
readonly destructiveHint: false;
|
|
627
|
+
readonly idempotentHint: true;
|
|
628
|
+
readonly openWorldHint: false;
|
|
629
|
+
readonly title: "Review a range of commits";
|
|
630
|
+
};
|
|
631
|
+
}, {
|
|
632
|
+
readonly name: "stop_hook";
|
|
633
|
+
readonly title: "Stop hook adapter";
|
|
634
|
+
readonly description: string;
|
|
635
|
+
readonly inputSchema: {
|
|
636
|
+
readonly type: "object";
|
|
637
|
+
readonly properties: {
|
|
638
|
+
readonly cwd: {
|
|
639
|
+
readonly type: "string";
|
|
640
|
+
readonly description: "A directory inside the project. Defaults to the project the server was started in.";
|
|
641
|
+
};
|
|
642
|
+
readonly stop_hook_active: {
|
|
643
|
+
readonly type: "string";
|
|
644
|
+
readonly description: "\"true\" when the agent is already continuing because of a Stop hook.";
|
|
645
|
+
};
|
|
646
|
+
readonly loop_count: {
|
|
647
|
+
readonly type: "string";
|
|
648
|
+
readonly description: "How many times the agent was already sent back, for agents that count.";
|
|
649
|
+
};
|
|
650
|
+
};
|
|
651
|
+
readonly additionalProperties: true;
|
|
652
|
+
};
|
|
653
|
+
readonly annotations: {
|
|
654
|
+
readonly readOnlyHint: true;
|
|
655
|
+
readonly destructiveHint: false;
|
|
656
|
+
readonly idempotentHint: true;
|
|
657
|
+
readonly openWorldHint: false;
|
|
658
|
+
readonly title: "Stop hook adapter";
|
|
659
|
+
};
|
|
660
|
+
}];
|
|
661
|
+
interface McpServerOptions {
|
|
662
|
+
/** The version reported to clients. */
|
|
663
|
+
version: string;
|
|
664
|
+
/** Directories the tools may read. Defaults to the project the server was started in. */
|
|
665
|
+
roots?: string[];
|
|
666
|
+
/** The project directory when the server was started; defaults to CLAUDE_PROJECT_DIR, CURSOR_PROJECT_DIR, then the working directory. */
|
|
667
|
+
cwd?: string;
|
|
668
|
+
env?: NodeJS.ProcessEnv;
|
|
669
|
+
/** How long one tool call may take before it is stopped. */
|
|
670
|
+
timeoutMs?: number;
|
|
671
|
+
}
|
|
672
|
+
interface McpServer {
|
|
673
|
+
/** Answers one JSON-RPC message (or a batch), or nothing for a notification. */
|
|
674
|
+
handle(message: unknown): Promise<RpcResponse | RpcResponse[] | undefined>;
|
|
675
|
+
}
|
|
676
|
+
declare function createMcpServer(options: McpServerOptions): McpServer;
|
|
677
|
+
interface ServeMcpOptions extends McpServerOptions {
|
|
678
|
+
input?: Readable;
|
|
679
|
+
output?: Writable;
|
|
680
|
+
}
|
|
681
|
+
/** Reads newline-delimited JSON-RPC from the input and writes responses to the output until the input closes. */
|
|
682
|
+
declare function serveMcp(options: ServeMcpOptions): Promise<void>;
|
|
683
|
+
|
|
561
684
|
interface DemoResult {
|
|
562
685
|
exitCode: number;
|
|
563
686
|
output: string;
|
|
@@ -613,4 +736,4 @@ declare function roastRepository(options?: RoastOptions): Promise<{
|
|
|
613
736
|
}>;
|
|
614
737
|
declare function auditPullRequest(options: PrAuditOptions): Promise<PrAuditResult>;
|
|
615
738
|
|
|
616
|
-
export { AGENT_LABELS, type AgentRoastConfig, type AiAttributionResult, CONFIG_FILENAMES, type CheckOptions, type CheckResult, type CommitInfo, ConfigError, DEFAULT_CONFIG, DEFAULT_CONFIG_TEMPLATE, type DemoResult, type DetectorType, FIELD_SEPARATOR, type FindingBaseline, type FindingException, GITHUB_WORKFLOW_TEMPLATE, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, HOOK_AGENTS, type HookAgent, type HookInstallChange, type HookInstallOptions, type HookOptions, type HookOutcome, type HookScope, INSTRUCTIONS_BLOCK, INSTRUCTIONS_END, INSTRUCTIONS_START, type Infraction, type InitOptions, type InitResult, InvalidDetectorError, MAX_CONFIG_BYTES, type MetricSummary, MetricsCollector, PRE_COMMIT_HOOK_TEMPLATE, type ParseDiffResult, type ParsedCommit, type ParsedDiffLine, type ParsedFileDiff, type PrAuditOptions, type PrAuditResult, RECORD_SEPARATOR, RULE_GUIDES, type ReplayCommit, type ReplayOptions, type ReplayResult, type ReplayStatus, type ReviewFormat, type ReviewOptions, type ReviewResult, type RoastOptions, type RuleGuide, type ShieldsEndpointResponse, type StagedReviewOptions, type SuppressedFinding, VALID_DETECTORS, applyBaseline, applyExceptions, auditFileDiffs, auditPullRequest, checkProject, compareFindings, configFromText, createBaseline, detectAiAttribution, findMergeBase, findingFingerprint, formatAgentFeedback, formatCheckMarkdown, formatCheckTerminal, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatReplayTerminal, formatReviewMarkdown, formatReviewTerminal, formatSarif, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, gitOutputPath, hookCommand, hookConfigPath, hookPausedUntil, hookStatus, initProject, installHooks, installInstructions, isCheckRule, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, mostImportantFirst, packageVersion, parseDiff, parseDuration, parseGitLogStream, pauseHook, replayHistory, repositoryRoot, resolveCommitSha, resumeHook, reviewChanges, reviewStagedChanges, reviewWorktreeChanges, roastRepository, runDemo, runHook, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, terminalText, uninstallHooks, unquoteGitPath, validateExceptions, writeBaseline };
|
|
739
|
+
export { AGENT_LABELS, type AgentRoastConfig, type AiAttributionResult, CONFIG_FILENAMES, type CheckOptions, type CheckResult, type CommitInfo, ConfigError, DEFAULT_CONFIG, DEFAULT_CONFIG_TEMPLATE, type DemoResult, type DetectorType, FIELD_SEPARATOR, type FindingBaseline, type FindingException, GITHUB_WORKFLOW_TEMPLATE, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, HOOK_AGENTS, type HookAgent, type HookInstallChange, type HookInstallOptions, type HookOptions, type HookOutcome, type HookScope, INSTRUCTIONS_BLOCK, INSTRUCTIONS_END, INSTRUCTIONS_START, type Infraction, type InitOptions, type InitResult, InvalidDetectorError, MAX_CONFIG_BYTES, MCP_TOOLS, type McpServer, type McpServerOptions, type MetricSummary, MetricsCollector, PRE_COMMIT_HOOK_TEMPLATE, type ParseDiffResult, type ParsedCommit, type ParsedDiffLine, type ParsedFileDiff, type PrAuditOptions, type PrAuditResult, RECORD_SEPARATOR, RULE_GUIDES, type ReplayCommit, type ReplayOptions, type ReplayResult, type ReplayStatus, type ReviewFormat, type ReviewOptions, type ReviewResult, type RoastOptions, type RuleGuide, type ServeMcpOptions, type ShieldsEndpointResponse, type StagedReviewOptions, type SuppressedFinding, VALID_DETECTORS, applyBaseline, applyExceptions, auditFileDiffs, auditPullRequest, checkProject, compareFindings, configFromText, createBaseline, createMcpServer, detectAiAttribution, findMergeBase, findingFingerprint, formatAgentFeedback, formatCheckMarkdown, formatCheckTerminal, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatReplayTerminal, formatReviewMarkdown, formatReviewTerminal, formatSarif, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, gitOutputPath, hookCommand, hookConfigPath, hookPausedUntil, hookStatus, initProject, installHooks, installInstructions, isCheckRule, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, mostImportantFirst, packageVersion, parseDiff, parseDuration, parseGitLogStream, pauseHook, replayHistory, repositoryRoot, resolveCommitSha, resumeHook, reviewChanges, reviewStagedChanges, reviewWorktreeChanges, roastRepository, runDemo, runHook, serveMcp, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, terminalText, uninstallHooks, unquoteGitPath, validateExceptions, writeBaseline };
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
INSTRUCTIONS_START,
|
|
17
17
|
InvalidDetectorError,
|
|
18
18
|
MAX_CONFIG_BYTES,
|
|
19
|
+
MCP_TOOLS,
|
|
19
20
|
MetricsCollector,
|
|
20
21
|
PRE_COMMIT_HOOK_TEMPLATE,
|
|
21
22
|
RECORD_SEPARATOR,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
compareFindings,
|
|
30
31
|
configFromText,
|
|
31
32
|
createBaseline,
|
|
33
|
+
createMcpServer,
|
|
32
34
|
detectAiAttribution,
|
|
33
35
|
findMergeBase,
|
|
34
36
|
findingFingerprint,
|
|
@@ -80,6 +82,7 @@ import {
|
|
|
80
82
|
roastRepository,
|
|
81
83
|
runDemo,
|
|
82
84
|
runHook,
|
|
85
|
+
serveMcp,
|
|
83
86
|
streamGitDiff,
|
|
84
87
|
streamGitLog,
|
|
85
88
|
stripStringAndRegexLiterals,
|
|
@@ -88,7 +91,7 @@ import {
|
|
|
88
91
|
unquoteGitPath,
|
|
89
92
|
validateExceptions,
|
|
90
93
|
writeBaseline
|
|
91
|
-
} from "./chunk-
|
|
94
|
+
} from "./chunk-W6JP5YWF.js";
|
|
92
95
|
export {
|
|
93
96
|
AGENT_LABELS,
|
|
94
97
|
CONFIG_FILENAMES,
|
|
@@ -106,6 +109,7 @@ export {
|
|
|
106
109
|
INSTRUCTIONS_START,
|
|
107
110
|
InvalidDetectorError,
|
|
108
111
|
MAX_CONFIG_BYTES,
|
|
112
|
+
MCP_TOOLS,
|
|
109
113
|
MetricsCollector,
|
|
110
114
|
PRE_COMMIT_HOOK_TEMPLATE,
|
|
111
115
|
RECORD_SEPARATOR,
|
|
@@ -119,6 +123,7 @@ export {
|
|
|
119
123
|
compareFindings,
|
|
120
124
|
configFromText,
|
|
121
125
|
createBaseline,
|
|
126
|
+
createMcpServer,
|
|
122
127
|
detectAiAttribution,
|
|
123
128
|
findMergeBase,
|
|
124
129
|
findingFingerprint,
|
|
@@ -170,6 +175,7 @@ export {
|
|
|
170
175
|
roastRepository,
|
|
171
176
|
runDemo,
|
|
172
177
|
runHook,
|
|
178
|
+
serveMcp,
|
|
173
179
|
streamGitDiff,
|
|
174
180
|
streamGitLog,
|
|
175
181
|
stripStringAndRegexLiterals,
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-roast",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Stops AI coding agents (and rushed humans) from turning a build green by weakening tests, coverage gates or CI. Hook for Claude Code, Codex and Cursor, pre-commit, GitHub Action. Offline.",
|
|
5
|
+
"mcpName": "io.github.Nari-Ab/agent-roast",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "./dist/index.js",
|
|
7
8
|
"types": "./dist/index.d.ts",
|