agent-guardrails 0.3.1 → 0.3.4

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 CHANGED
@@ -523,6 +523,58 @@ The first recommended MCP flow is:
523
523
 
524
524
  `suggest_task_contract` and `run_guardrail_check` still exist as lower-level MCP tools, but they are not the preferred first-run chat flow.
525
525
 
526
+ ## Daemon Mode / 守护进程模式
527
+
528
+ Run guardrails automatically in the background while you code:
529
+
530
+ ```bash
531
+ # Start the daemon (background mode)
532
+ agent-guardrails start
533
+
534
+ # Check daemon status
535
+ agent-guardrails status
536
+
537
+ # Stop the daemon
538
+ agent-guardrails stop
539
+
540
+ # Run in foreground (useful for debugging or Docker)
541
+ agent-guardrails start --foreground
542
+ ```
543
+
544
+ ### How It Works
545
+
546
+ The daemon monitors file changes and automatically runs guardrail checks:
547
+ - Watches `src/`, `lib/`, `tests/` by default
548
+ - Debounces checks (5 second interval)
549
+ - Logs to `.agent-guardrails/daemon.log`
550
+
551
+ ### Configuration (`.agent-guardrails/daemon.json`)
552
+
553
+ | Option | Default | Description |
554
+ |--------|---------|-------------|
555
+ | `watchPaths` | `["src/", "lib/", "tests/"]` | Paths to monitor |
556
+ | `ignorePatterns` | `["node_modules", ".git", ...]` | Patterns to ignore |
557
+ | `checkInterval` | `5000` | Debounce interval (ms) |
558
+ | `blockOnHighRisk` | `true` | Block on high-risk findings |
559
+ | `autoFix` | `false` | Auto-fix issues when possible |
560
+
561
+ ### Use Cases
562
+
563
+ - **Local development**: Get instant feedback while coding
564
+ - **CI/CD integration**: Run in containers with `--foreground`
565
+ - **Team guardrails**: Shared daemon config in repo
566
+
567
+ ### Daemon vs Manual Check
568
+
569
+ | Daemon Mode | Manual Check |
570
+ |-------------|--------------|
571
+ | Continuous monitoring | One-time check |
572
+ | Automatic on file change | Run when you want |
573
+ | Background process | Foreground process |
574
+ | Best for active development | Best for pre-commit/CI |
575
+
576
+ ---
577
+
526
578
  ## CLI Fallback Quick Start
527
579
 
528
580
  If you want the shortest manual path, copy this:
@@ -1,5 +1,6 @@
1
1
  import { createFinding } from "../finding.js";
2
2
  import { normalizeChangeType } from "../../utils.js";
3
+ import { execFileSync } from "node:child_process";
3
4
 
4
5
  function toBoolean(value) {
5
6
  if (value === true || value === false) {
@@ -461,5 +462,63 @@ export const ossDetectors = [
461
462
  }));
462
463
  }
463
464
  }
465
+ },
466
+ {
467
+ name: "secrets-safety",
468
+ run({ context, addFinding, t }) {
469
+ const { repoRoot, changedFiles } = context;
470
+ if (!changedFiles || changedFiles.length === 0) return;
471
+
472
+ const patterns = [
473
+ { regex: /(?:api[_-]?key|apikey)\s*[=:]\s*["'][^"']{8,}["']/i, label: "API key" },
474
+ { regex: /(?:password|passwd|pwd)\s*[=:]\s*["'][^"']{6,}["']/i, label: "Password" },
475
+ { regex: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/i, label: "Bearer token" },
476
+ { regex: /-----BEGIN(?:\s+(?:RSA|EC|DSA|OPENSSH|PRIVATE))?[\s-]*PRIVATE KEY-----/, label: "Private key" },
477
+ { regex: /(?:secret|token)\s*[=:]\s*["'][A-Za-z0-9\-._~+/]{16,}["']/i, label: "Secret/token" }
478
+ ];
479
+
480
+ const testLike = /\.(test|spec)\.(js|ts|mjs|cjs|jsx|tsx)$|__tests__|fixtures|mock/i;
481
+ let matched = false;
482
+
483
+ for (const filePath of changedFiles) {
484
+ if (testLike.test(filePath)) continue;
485
+ let content;
486
+ try {
487
+ content = execFileSync(
488
+ "git",
489
+ ["diff", "--cached", "--", filePath],
490
+ { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
491
+ );
492
+ if (!content.trim()) {
493
+ content = execFileSync(
494
+ "git",
495
+ ["diff", "--", filePath],
496
+ { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
497
+ );
498
+ }
499
+ } catch {
500
+ continue;
501
+ }
502
+ const addedLines = content.split("\n").filter((line) => /^\+/.test(line) && !/^\+\+\+/.test(line));
503
+ for (const { regex, label } of patterns) {
504
+ for (const line of addedLines) {
505
+ if (regex.test(line)) {
506
+ matched = true;
507
+ addFinding(createFinding({
508
+ severity: "warning",
509
+ category: "risk",
510
+ code: "secrets-safety",
511
+ message: t("findings.secretsSafetyDetected", { file: filePath, type: label }),
512
+ action: t("actions.moveSecretToEnv"),
513
+ files: [filePath]
514
+ }));
515
+ break;
516
+ }
517
+ }
518
+ if (matched) break;
519
+ }
520
+ if (matched) break;
521
+ }
522
+ }
464
523
  }
465
524
  ];
package/lib/cli.js CHANGED
@@ -5,7 +5,8 @@ import { runSetup } from "./commands/setup.js";
5
5
  import { createTranslator, supportedLocales } from "./i18n.js";
6
6
  import { runPlan } from "./commands/plan.js";
7
7
  import { runGenerateAgentsMd } from "./commands/agents-md.js";
8
- import { supportedAdapters, supportedPresets } from "./utils.js";
8
+ import { startDaemon, stopDaemon, showDaemonStatus } from "./commands/daemon.js";
9
+ import { supportedAdapters, supportedPresets, readOwnPackageJson } from "./utils.js";
9
10
 
10
11
  function parseArgs(argv) {
11
12
  const positional = [];
@@ -43,14 +44,26 @@ ${t("cli.usage")}
43
44
  agent-guardrails setup [targetDir] --agent <name> [--preset <name>] [--lang <locale>] [--json] [--write-repo-config]
44
45
  agent-guardrails plan --task "<task description>" [--intended-files "src/service.js,tests/service.test.js"] [--allowed-change-types "implementation-only"] [--allow-paths "src/,tests/"] [--required-commands "npm test"] [--evidence ".agent-guardrails/evidence/current-task.md"] [--risk-level high] [--lang <locale>] [--print-only]
45
46
  agent-guardrails check [--contract-path <path>] [--base-ref <ref>] [--commands-run "npm test"] [--review] [--lang <locale>] [--json]
47
+ agent-guardrails start [--foreground] - ${t("cli.startSummary")}
48
+ agent-guardrails stop - ${t("cli.stopSummary")}
49
+ agent-guardrails status - ${t("cli.statusSummary")}
46
50
  agent-guardrails generate-agents [targetDir] [--preset <name>] [--lang <locale>]
47
51
  agent-guardrails mcp
52
+ agent-guardrails --version
53
+
54
+ ${t("cli.globalOptions")}
55
+ --version, -v ${t("cli.versionSummary") ?? "Show version number"}
56
+ --help, -h ${t("cli.helpSummary") ?? "Show this help"}
57
+ --lang <locale> ${t("cli.langSummary") ?? "Language for output (en, zh-CN)"}
48
58
 
49
59
  ${t("cli.commands")}
50
60
  init ${t("cli.initSummary")}
51
61
  setup ${t("cli.setupSummary")}
52
62
  plan ${t("cli.planSummary")}
53
63
  check ${t("cli.checkSummary")}
64
+ start ${t("cli.startSummary")}
65
+ stop ${t("cli.stopSummary")}
66
+ status ${t("cli.statusSummary")}
54
67
  generate-agents Generate AGENTS.md for Harness Engineering
55
68
  mcp ${t("cli.mcpSummary")}
56
69
 
@@ -70,6 +83,12 @@ export async function runCli(argv) {
70
83
  const [command, ...rest] = positional;
71
84
  const locale = flags.lang ?? null;
72
85
 
86
+ if (flags.version || flags.v) {
87
+ const pkg = readOwnPackageJson();
88
+ console.log(`agent-guardrails v${pkg.version}`);
89
+ return;
90
+ }
91
+
73
92
  if (!command || command === "help" || flags.help) {
74
93
  printHelp(locale);
75
94
  return;
@@ -100,6 +119,21 @@ export async function runCli(argv) {
100
119
  return;
101
120
  }
102
121
 
122
+ if (command === "start") {
123
+ await startDaemon(process.cwd(), { locale, foreground: flags.foreground || false });
124
+ return;
125
+ }
126
+
127
+ if (command === "stop") {
128
+ stopDaemon(process.cwd(), { locale });
129
+ return;
130
+ }
131
+
132
+ if (command === "status") {
133
+ showDaemonStatus(process.cwd(), { locale });
134
+ return;
135
+ }
136
+
103
137
  if (command === "generate-agents" || command === "gen-agents") {
104
138
  await runGenerateAgentsMd({ positional: rest, flags, locale });
105
139
  return;
@@ -475,6 +475,11 @@ function printTextResult(result, t, { reviewMode = false } = {}) {
475
475
  if (result.failures.length === 0) {
476
476
  console.log(`\n${t("check.allPassed")}`);
477
477
  }
478
+
479
+ if (result.runtime?.costHints?.entries?.length > 0) {
480
+ console.log(`\n${t("check.costAwareness")}`);
481
+ console.log(result.runtime.costHints.entries.map((entry) => `- ${t(entry.key, entry.vars)}`).join("\n"));
482
+ }
478
483
  }
479
484
 
480
485
  function printJsonResult(result) {
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Agent Guardrails Daemon Mode
3
+ *
4
+ * Event-driven file watcher — zero resource usage when idle.
5
+ *
6
+ * Usage:
7
+ * agent-guardrails start [--foreground] - Start daemon (--foreground for terminal)
8
+ * agent-guardrails stop - Stop daemon
9
+ * agent-guardrails status - Show status
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { createTranslator } from "../i18n.js";
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+
21
+ const DAEMON_PID_FILE = ".agent-guardrails/daemon.pid";
22
+ const DAEMON_LOG_FILE = ".agent-guardrails/daemon.log";
23
+ const DAEMON_CONFIG_FILE = ".agent-guardrails/daemon.json";
24
+ const DAEMON_INFO_FILE = ".agent-guardrails/daemon-info.json";
25
+
26
+ /**
27
+ * 默认守护配置
28
+ */
29
+ const DEFAULT_DAEMON_CONFIG = {
30
+ enabled: true,
31
+ watchPaths: ["src/", "lib/", "tests/"],
32
+ ignorePatterns: ["node_modules", ".git", "dist", "coverage"],
33
+ checkInterval: 5000,
34
+ notifications: {
35
+ sound: false,
36
+ desktop: false
37
+ },
38
+ autoFix: false,
39
+ blockOnHighRisk: true
40
+ };
41
+
42
+ /**
43
+ * 获取守护配置
44
+ */
45
+ export function getDaemonConfig(repoRoot) {
46
+ const configPath = path.join(repoRoot, DAEMON_CONFIG_FILE);
47
+
48
+ if (fs.existsSync(configPath)) {
49
+ try {
50
+ const content = fs.readFileSync(configPath, "utf8");
51
+ return { ...DEFAULT_DAEMON_CONFIG, ...JSON.parse(content) };
52
+ } catch {
53
+ // 配置解析失败,使用默认
54
+ }
55
+ }
56
+
57
+ return DEFAULT_DAEMON_CONFIG;
58
+ }
59
+
60
+ /**
61
+ * 写入守护配置
62
+ */
63
+ export function writeDaemonConfig(repoRoot, config) {
64
+ const configPath = path.join(repoRoot, DAEMON_CONFIG_FILE);
65
+ const dir = path.dirname(configPath);
66
+
67
+ if (!fs.existsSync(dir)) {
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ }
70
+
71
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
72
+ }
73
+
74
+ /**
75
+ * 检查守护进程是否运行
76
+ */
77
+ export function isDaemonRunning(repoRoot) {
78
+ const pidFile = path.join(repoRoot, DAEMON_PID_FILE);
79
+
80
+ if (!fs.existsSync(pidFile)) {
81
+ return { running: false };
82
+ }
83
+
84
+ try {
85
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
86
+
87
+ if (Number.isNaN(pid)) {
88
+ return { running: false };
89
+ }
90
+
91
+ // 跨平台检查进程
92
+ let running = false;
93
+ try {
94
+ if (process.platform === "win32") {
95
+ const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`], {
96
+ encoding: "utf8",
97
+ timeout: 5000
98
+ });
99
+ // 精确匹配 PID,避免 "123" 匹配 "1234"
100
+ running = new RegExp(`\\b${pid}\\b`).test(result.stdout);
101
+ } else {
102
+ process.kill(pid, 0);
103
+ running = true;
104
+ }
105
+ } catch {
106
+ running = false;
107
+ }
108
+
109
+ if (!running) {
110
+ fs.unlinkSync(pidFile);
111
+ return { running: false };
112
+ }
113
+
114
+ // 读取守护进程信息
115
+ const infoFile = path.join(repoRoot, DAEMON_INFO_FILE);
116
+ let info = {};
117
+ if (fs.existsSync(infoFile)) {
118
+ try {
119
+ info = JSON.parse(fs.readFileSync(infoFile, "utf8"));
120
+ } catch {
121
+ // ignore
122
+ }
123
+ }
124
+
125
+ return {
126
+ running: true,
127
+ pid,
128
+ startTime: info.startTime,
129
+ checksRun: info.checksRun || 0,
130
+ lastCheck: info.lastCheck
131
+ };
132
+ } catch {
133
+ return { running: false };
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 事件驱动等待 PID 文件出现(替代 busy-wait 轮询)
139
+ */
140
+ async function waitForPidFile(pidFile, timeoutMs = 5000) {
141
+ return new Promise((resolve) => {
142
+ if (fs.existsSync(pidFile)) {
143
+ try { resolve(fs.readFileSync(pidFile, "utf8").trim()); } catch { resolve(null); }
144
+ return;
145
+ }
146
+
147
+ const dir = path.dirname(pidFile);
148
+ let watcher;
149
+ const timer = setTimeout(() => {
150
+ if (watcher) watcher.close();
151
+ // 最终检查一次
152
+ if (fs.existsSync(pidFile)) {
153
+ try { resolve(fs.readFileSync(pidFile, "utf8").trim()); } catch { resolve(null); }
154
+ } else {
155
+ resolve(null);
156
+ }
157
+ }, timeoutMs);
158
+
159
+ try {
160
+ watcher = fs.watch(dir, (eventType) => {
161
+ if (eventType === "rename" && fs.existsSync(pidFile)) {
162
+ clearTimeout(timer);
163
+ watcher.close();
164
+ try { resolve(fs.readFileSync(pidFile, "utf8").trim()); } catch { resolve(null); }
165
+ }
166
+ });
167
+ } catch {
168
+ clearTimeout(timer);
169
+ resolve(null);
170
+ }
171
+ });
172
+ }
173
+
174
+ /**
175
+ * 启动守护进程
176
+ */
177
+ export async function startDaemon(repoRoot, options = {}) {
178
+ const locale = options.locale || null;
179
+ const foreground = options.foreground || false;
180
+ const { t } = createTranslator(locale);
181
+
182
+ // 检查是否已运行
183
+ const status = isDaemonRunning(repoRoot);
184
+ if (status.running && !foreground) {
185
+ console.log(`\n${t("daemon.alreadyRunning")}`);
186
+ console.log(` PID: ${status.pid}`);
187
+ console.log(` ${t("daemon.startTime")}: ${status.startTime || "unknown"}`);
188
+ console.log(`\n ${t("daemon.useStop")}`);
189
+ return { success: false, reason: "already_running", status };
190
+ }
191
+
192
+ const config = getDaemonConfig(repoRoot);
193
+
194
+ // 前台模式 — 直接调用 worker.run()
195
+ if (foreground) {
196
+ const { run } = await import("../daemon/worker.js");
197
+ return run({ repoRoot, config, foreground: true, locale });
198
+ }
199
+
200
+ // 后台模式 — spawn worker.js 子进程
201
+ console.log(`\n${t("daemon.starting")}\n`);
202
+
203
+ const workerPath = path.resolve(__dirname, "..", "daemon", "worker.js");
204
+ const pidFile = path.join(repoRoot, DAEMON_PID_FILE);
205
+
206
+ const child = spawn(process.execPath, [
207
+ workerPath,
208
+ "--repo-root", repoRoot,
209
+ "--config", JSON.stringify(config)
210
+ ], {
211
+ detached: true,
212
+ stdio: "ignore",
213
+ cwd: repoRoot
214
+ });
215
+
216
+ child.unref();
217
+
218
+ // 事件驱动等待 PID 文件(替代 busy-wait)
219
+ const pid = await waitForPidFile(pidFile, 5000);
220
+
221
+ if (pid) {
222
+ console.log(`${t("daemon.started")}`);
223
+ console.log(` PID: ${pid}`);
224
+ console.log(`\n${t("daemon.logFile")}: ${DAEMON_LOG_FILE}`);
225
+ console.log(`${t("daemon.configFile")}: ${DAEMON_CONFIG_FILE}`);
226
+ console.log(`\n${t("daemon.useStop")}`);
227
+ console.log(`${t("daemon.useStatus")}`);
228
+ return { success: true, pid: parseInt(pid, 10) };
229
+ } else {
230
+ console.log(`${t("daemon.startFailed")}`);
231
+ return { success: false, reason: "timeout" };
232
+ }
233
+ }
234
+
235
+ /**
236
+ * 停止守护进程
237
+ */
238
+ export async function stopDaemon(repoRoot, options = {}) {
239
+ const locale = options.locale || null;
240
+ const { t } = createTranslator(locale);
241
+
242
+ const status = isDaemonRunning(repoRoot);
243
+
244
+ if (!status.running) {
245
+ console.log(`\n${t("daemon.notRunning")}`);
246
+ return { success: false, reason: "not_running" };
247
+ }
248
+
249
+ console.log(`\n${t("daemon.stopping")}`);
250
+ console.log(` PID: ${status.pid}`);
251
+
252
+ try {
253
+ if (process.platform === "win32") {
254
+ // 先尝试 taskkill /T(终止进程树,给 SIGTERM handler 机会)
255
+ spawnSync("taskkill", ["/PID", status.pid.toString(), "/T"], {
256
+ encoding: "utf8",
257
+ timeout: 5000
258
+ });
259
+ // 等待进程退出
260
+ const { setTimeout: delay } = await import("node:timers/promises");
261
+ await delay(1000);
262
+ // 如果还活着,强制终止
263
+ if (isDaemonRunning(repoRoot).running) {
264
+ spawnSync("taskkill", ["/PID", status.pid.toString(), "/F", "/T"], {
265
+ encoding: "utf8",
266
+ timeout: 5000
267
+ });
268
+ }
269
+ } else {
270
+ process.kill(status.pid, "SIGTERM");
271
+ }
272
+
273
+ // 清理文件
274
+ const pidFile = path.join(repoRoot, DAEMON_PID_FILE);
275
+ if (fs.existsSync(pidFile)) {
276
+ fs.unlinkSync(pidFile);
277
+ }
278
+
279
+ console.log(`\n${t("daemon.stopped")}`);
280
+ return { success: true };
281
+ } catch (error) {
282
+ console.log(`\n${t("daemon.stopFailed")}: ${error.message}`);
283
+ return { success: false, reason: error.message };
284
+ }
285
+ }
286
+
287
+ /**
288
+ * 显示守护进程状态
289
+ */
290
+ export function showDaemonStatus(repoRoot, options = {}) {
291
+ const locale = options.locale || null;
292
+ const { t } = createTranslator(locale);
293
+
294
+ const status = isDaemonRunning(repoRoot);
295
+ const config = getDaemonConfig(repoRoot);
296
+
297
+ console.log(`\n${t("daemon.status")}\n`);
298
+
299
+ if (status.running) {
300
+ console.log(` ${t("daemon.state")}: ${t("daemon.running")}`);
301
+ console.log(` PID: ${status.pid}`);
302
+ console.log(` ${t("daemon.startTime")}: ${status.startTime || "unknown"}`);
303
+ console.log(` ${t("daemon.checksRun")}: ${status.checksRun}`);
304
+ console.log(` ${t("daemon.lastCheck")}: ${status.lastCheck || "never"}`);
305
+ } else {
306
+ console.log(` ${t("daemon.state")}: ${t("daemon.stopped")}`);
307
+ }
308
+
309
+ console.log(`\n ${t("daemon.config")}:`);
310
+ console.log(` ${t("daemon.watchPaths")}: ${config.watchPaths.join(", ")}`);
311
+ console.log(` ${t("daemon.checkInterval")}: ${config.checkInterval}ms`);
312
+ console.log(` ${t("daemon.blockOnHighRisk")}: ${config.blockOnHighRisk ? "yes" : "no"}`);
313
+
314
+ console.log(`\n ${t("daemon.commands")}:`);
315
+ if (status.running) {
316
+ console.log(` agent-guardrails stop - ${t("daemon.stopDesc")}`);
317
+ } else {
318
+ console.log(` agent-guardrails start - ${t("daemon.startDesc")}`);
319
+ }
320
+ console.log(` agent-guardrails status - ${t("daemon.statusDesc")}`);
321
+
322
+ return status;
323
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Agent Guardrails Daemon Worker
3
+ *
4
+ * Core daemon logic shared by foreground and background modes.
5
+ * Event-driven — no polling when idle.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Args
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export function parseArgs(argv = process.argv) {
16
+ const args = {};
17
+ for (let i = 2; i < argv.length; i++) {
18
+ const flag = argv[i];
19
+ if (flag === "--foreground") {
20
+ args.foreground = true;
21
+ } else if (flag === "--repo-root" && argv[i + 1]) {
22
+ args.repoRoot = argv[++i];
23
+ } else if (flag === "--config" && argv[i + 1]) {
24
+ try { args.config = JSON.parse(argv[++i]); } catch { /* ignore */ }
25
+ }
26
+ }
27
+ return args;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Logger
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export function createLogger(logFile, foreground = false) {
35
+ function ensureDir() {
36
+ const dir = path.dirname(logFile);
37
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
38
+ }
39
+
40
+ function log(message) {
41
+ const ts = new Date().toISOString();
42
+ const line = `[${ts}] ${message}`;
43
+ if (foreground) {
44
+ console.log(line);
45
+ } else {
46
+ try {
47
+ ensureDir();
48
+ fs.appendFileSync(logFile, line + "\n");
49
+ } catch { /* ignore write errors */ }
50
+ }
51
+ }
52
+
53
+ return { log };
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Info store — tracks daemon state on disk
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export function createInfoStore(infoFile, startTime) {
61
+ const state = {
62
+ pid: process.pid,
63
+ startTime,
64
+ checksRun: 0,
65
+ lastCheck: null
66
+ };
67
+
68
+ function save() {
69
+ const dir = path.dirname(infoFile);
70
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
71
+ fs.writeFileSync(infoFile, JSON.stringify(state, null, 2));
72
+ }
73
+
74
+ return {
75
+ incrementChecks() { state.checksRun++; },
76
+ updateLastCheck() { state.lastCheck = new Date().toISOString(); },
77
+ save
78
+ };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // File watcher — chokidar with fs.watch fallback
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function createFallbackWatcher(repoRoot, config, onChange, log) {
86
+ const watchers = new Map();
87
+ let closed = false;
88
+
89
+ function watchDir(dir) {
90
+ if (closed) return;
91
+ try {
92
+ const w = fs.watch(dir, { recursive: false }, (_eventType, filename) => {
93
+ if (!filename) return;
94
+ onChange(path.join(dir, filename));
95
+ });
96
+ watchers.set(dir, w);
97
+ } catch { /* skip directories we can't watch */ }
98
+ }
99
+
100
+ function scanDirs(baseDir, depth) {
101
+ if (closed || depth > 10) return;
102
+ try {
103
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
104
+ for (const entry of entries) {
105
+ if (config.ignorePatterns.some((p) => entry.name.includes(p))) continue;
106
+ const fullPath = path.join(baseDir, entry.name);
107
+ if (entry.isDirectory()) {
108
+ watchDir(fullPath);
109
+ scanDirs(fullPath, depth + 1);
110
+ }
111
+ }
112
+ } catch { /* skip */ }
113
+ }
114
+
115
+ for (const watchPath of config.watchPaths) {
116
+ const absolute = path.resolve(repoRoot, watchPath);
117
+ if (fs.existsSync(absolute)) {
118
+ watchDir(absolute);
119
+ scanDirs(absolute, 0);
120
+ }
121
+ }
122
+
123
+ return {
124
+ close() {
125
+ closed = true;
126
+ for (const [, w] of watchers) w.close();
127
+ watchers.clear();
128
+ },
129
+ ready: Promise.resolve()
130
+ };
131
+ }
132
+
133
+ async function createChokidarWatcher(repoRoot, config, onChange, log) {
134
+ const chokidar = await import("chokidar");
135
+ const watcher = chokidar.watch(config.watchPaths, {
136
+ cwd: repoRoot,
137
+ ignored: config.ignorePatterns,
138
+ persistent: true,
139
+ ignoreInitial: true
140
+ });
141
+
142
+ watcher.on("change", (filePath) => onChange(filePath));
143
+ watcher.on("add", (filePath) => onChange(filePath));
144
+
145
+ return new Promise((resolve) => {
146
+ watcher.on("ready", () => {
147
+ resolve({
148
+ close: () => watcher.close(),
149
+ ready: Promise.resolve()
150
+ });
151
+ });
152
+ });
153
+ }
154
+
155
+ export async function createWatcher(repoRoot, config, onChange, log) {
156
+ try {
157
+ log("Attempting to use chokidar for file watching...");
158
+ const watcher = await createChokidarWatcher(repoRoot, config, onChange, log);
159
+ log("chokidar initialized successfully");
160
+ return watcher;
161
+ } catch {
162
+ log("chokidar unavailable, falling back to fs.watch");
163
+ return createFallbackWatcher(repoRoot, config, onChange, log);
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Check runner — direct import, no npx
169
+ // ---------------------------------------------------------------------------
170
+
171
+ export async function createCheckRunner(repoRoot, config, log, t) {
172
+ let isCheckRunning = false;
173
+ let checkTimeout = null;
174
+ let runCount = 0;
175
+
176
+ const { executeCheck } = await import("../commands/check.js");
177
+
178
+ function scheduleCheck() {
179
+ if (checkTimeout) clearTimeout(checkTimeout);
180
+ checkTimeout = setTimeout(runCheck, config.checkInterval);
181
+ }
182
+
183
+ function runCheck() {
184
+ if (isCheckRunning) {
185
+ log(t("daemon.checkSkipped"));
186
+ return;
187
+ }
188
+ isCheckRunning = true;
189
+ runCount++;
190
+ log(`Running guardrail check (#${runCount})...`);
191
+
192
+ executeCheck({
193
+ repoRoot,
194
+ flags: { json: false },
195
+ suppressExitCode: true
196
+ })
197
+ .then((result) => {
198
+ const ok = result?.ok;
199
+ log(ok ? "Check passed" : "Check completed with issues");
200
+
201
+ // 保存结构化结果供 MCP 读取
202
+ try {
203
+ const resultFile = path.join(repoRoot, ".agent-guardrails", "daemon-result.json");
204
+ const dir = path.dirname(resultFile);
205
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
206
+ fs.writeFileSync(resultFile, JSON.stringify({
207
+ timestamp: new Date().toISOString(),
208
+ ok: !!ok,
209
+ result
210
+ }, null, 2));
211
+ } catch { /* ignore */ }
212
+ })
213
+ .catch((err) => {
214
+ log(`Check error: ${err.message}`);
215
+ })
216
+ .finally(() => {
217
+ isCheckRunning = false;
218
+ });
219
+ }
220
+
221
+ return { scheduleCheck };
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Main worker entry
226
+ // ---------------------------------------------------------------------------
227
+
228
+ export async function run({ repoRoot, config, foreground = false, locale = null }) {
229
+ const { createTranslator } = await import("../i18n.js");
230
+ const { t } = createTranslator(locale);
231
+
232
+ const startTime = new Date().toISOString();
233
+ const logFile = path.join(repoRoot, ".agent-guardrails", "daemon.log");
234
+ const infoFile = path.join(repoRoot, ".agent-guardrails", "daemon-info.json");
235
+ const pidFile = path.join(repoRoot, ".agent-guardrails", "daemon.pid");
236
+
237
+ const { log } = createLogger(logFile, foreground);
238
+ const info = createInfoStore(infoFile, startTime);
239
+ const { scheduleCheck } = await createCheckRunner(repoRoot, config, log, t);
240
+
241
+ // Cleanup handler
242
+ let watcher = null;
243
+ function cleanup() {
244
+ log("Shutting down...");
245
+ if (watcher) watcher.close();
246
+ try { if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); } catch { /* ignore */ }
247
+ try { if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile); } catch { /* ignore */ }
248
+ }
249
+
250
+ process.on("SIGTERM", () => { cleanup(); process.exit(0); });
251
+ process.on("SIGINT", () => { console.log(""); cleanup(); process.exit(0); });
252
+
253
+ // Write PID file
254
+ const dir = path.dirname(pidFile);
255
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
256
+ fs.writeFileSync(pidFile, process.pid.toString());
257
+
258
+ log(`Daemon started with PID ${process.pid}`);
259
+ log(`Working directory: ${repoRoot}`);
260
+ log(`Watch paths: ${config.watchPaths.join(", ")}`);
261
+ log(`Check interval: ${config.checkInterval}ms (debounce, not polling)`);
262
+ if (!foreground) log("Press Ctrl+C or run 'agent-guardrails stop' to stop\n");
263
+ info.save();
264
+
265
+ // Start file watcher
266
+ watcher = await createWatcher(repoRoot, config, (filePath) => {
267
+ log(`File changed: ${filePath}`);
268
+ info.incrementChecks();
269
+ info.updateLastCheck();
270
+ info.save();
271
+ scheduleCheck();
272
+ }, log);
273
+
274
+ log("File watcher ready");
275
+
276
+ // Keep process alive
277
+ return new Promise(() => { /* never resolve */ });
278
+ }
package/lib/i18n.js CHANGED
@@ -3,14 +3,21 @@ const resources = {
3
3
  cli: {
4
4
  usage: "Usage:",
5
5
  commands: "Commands:",
6
+ globalOptions: "Global Options:",
6
7
  supportedPresets: "Supported presets:",
7
8
  supportedAdapters: "Supported adapters:",
8
9
  supportedLocales: "Supported locales:",
10
+ versionSummary: "Show version number",
11
+ helpSummary: "Show this help message",
12
+ langSummary: "Language for output (en, zh-CN)",
9
13
  initSummary: "Seed guardrail files into a repository",
10
14
  setupSummary: "Auto-initialize a repo, generate MCP config, and point an agent at the runtime",
11
15
  planSummary: "Print a bounded implementation brief and write a richer task contract by default",
12
16
  checkSummary: "Validate scope, consistency, correctness, risk, and production-profile rules for the current change",
13
17
  mcpSummary: "Expose the runtime through a stdio MCP server",
18
+ startSummary: "Start daemon mode for automatic background checking",
19
+ stopSummary: "Stop the daemon process",
20
+ statusSummary: "Show daemon status and configuration",
14
21
  unknownCommand: "Unknown command \"{command}\""
15
22
  },
16
23
  errors: {
@@ -247,7 +254,8 @@ const resources = {
247
254
  mentionObservability: "Mention metrics, logging, tracing, or monitoring changes in the evidence note.",
248
255
  mentionPerfValidation: "Call out how concurrency, load, performance, or reliability was validated.",
249
256
  extendExistingTarget: "Extend the existing target first: {value}",
250
- preserveExistingStructure: "Preserve the current structure or explicitly widen the task contract before restructuring it."
257
+ preserveExistingStructure: "Preserve the current structure or explicitly widen the task contract before restructuring it.",
258
+ moveSecretToEnv: "Move the secret to an environment variable or a secrets manager, and add the variable name to a .env.example file."
251
259
  },
252
260
  check: {
253
261
  title: "Agent Guardrails Check",
@@ -305,7 +313,15 @@ const resources = {
305
313
  nextActions: "Next actions:",
306
314
  finishCommand: "Finish-time command: {value}",
307
315
  action: "Action: {value}",
308
- findingSeverity: "[{severity}] {message}"
316
+ findingSeverity: "[{severity}] {message}",
317
+ costAwareness: "Cost awareness:",
318
+ costSizeSmall: "Change size: Small ({totalFiles} files, {sourceFiles} source)",
319
+ costSizeMedium: "Change size: Medium ({totalFiles} files, {sourceFiles} source)",
320
+ costSizeLarge: "Change size: Large ({totalFiles} files, {sourceFiles} source)",
321
+ costSizeVeryLarge: "Change size: Very large ({totalFiles} files, {sourceFiles} source)",
322
+ costTokenEstimate: "Estimated token cost: {low}-{high}",
323
+ costLargeChangeWarning: "Large change detected — consider splitting into smaller tasks to reduce token usage and review burden.",
324
+ costFixCostHint: "Fix cost: {errorCount} error(s), {warningCount} warning(s) need to be resolved."
309
325
  },
310
326
  findings: {
311
327
  missingRequiredFile: "Required file is missing: {path}",
@@ -336,7 +352,17 @@ const resources = {
336
352
  criticalPathTouchedWithoutRollback: "Critical path changed without rollback notes: {files}",
337
353
  performanceSensitiveAreaTouched: "Performance-sensitive area changed: {files}",
338
354
  observabilityRequirementsUnaddressed: "Observability requirements were declared, but the evidence note does not mention them.",
339
- concurrencyRequirementsUnaddressed: "Concurrency or performance requirements were declared, but the evidence note does not mention validation for them."
355
+ concurrencyRequirementsUnaddressed: "Concurrency or performance requirements were declared, but the evidence note does not mention validation for them.",
356
+ secretsSafetyDetected: "Potential {type} detected in added lines of {file}. Move secrets to environment variables or a secrets manager."
357
+ },
358
+ recovery: {
359
+ revertOrNarrowScope: "Recovery: revert out-of-scope changes or update the task contract to include them.",
360
+ runOrSkipCommands: "Recovery: run the missing required commands, or explicitly skip them with justification in evidence.",
361
+ writeEvidenceFile: "Recovery: create the required evidence file and document what was done and any residual risk.",
362
+ revertOrAddReviewNotes: "Recovery: revert the protected-area changes or add detailed review notes explaining the rationale.",
363
+ moveSecretsToEnv: "Recovery: move detected secrets to environment variables or a secrets manager immediately.",
364
+ revertOrWidenContract: "Recovery: revert the unexpected change-type changes or update the task contract accordingly.",
365
+ addTestsOrAcknowledge: "Recovery: add tests for the changed source files, or document why tests are deferred."
340
366
  },
341
367
  roughIntent: {
342
368
  analyzing: "Analyzing your intent...",
@@ -364,19 +390,63 @@ const resources = {
364
390
  actionConfirm: "✅ Confirm",
365
391
  actionModify: "✏️ Modify scope",
366
392
  actionCancel: "❌ Cancel"
393
+ },
394
+ daemon: {
395
+ starting: "Starting daemon...",
396
+ foregroundMode: "foreground mode",
397
+ started: "✅ Daemon started successfully!",
398
+ startFailed: "❌ Failed to start daemon",
399
+ alreadyRunning: "Daemon is already running!",
400
+ stopping: "Stopping daemon...",
401
+ stopped: "✅ Daemon stopped.",
402
+ stopFailed: "❌ Failed to stop daemon",
403
+ notRunning: "Daemon is not running.",
404
+ status: "Daemon Status",
405
+ state: "State",
406
+ running: "Running",
407
+ config: "Configuration",
408
+ watchPaths: "Watch paths",
409
+ checkInterval: "Check interval",
410
+ blockOnHighRisk: "Block on high risk",
411
+ commands: "Commands",
412
+ startTime: "Started at",
413
+ checksRun: "Checks run",
414
+ lastCheck: "Last check",
415
+ logFile: "Log file",
416
+ configFile: "Config file",
417
+ useStop: "Use 'agent-guardrails stop' to stop.",
418
+ useStatus: "Use 'agent-guardrails status' to check status.",
419
+ startDesc: "Start the daemon",
420
+ stopDesc: "Stop the daemon",
421
+ statusDesc: "Show daemon status",
422
+ configUpdated: "✅ Daemon configuration updated!",
423
+ startDescLong: "Start the daemon in background mode",
424
+ stopDesc: "Stop the daemon",
425
+ stopDescLong: "Stop the daemon",
426
+ statusDesc: "Show daemon status",
427
+ statusDescLong: "Show daemon status and configuration",
428
+ chokidarFallback: "chokidar unavailable, falling back to fs.watch (less reliable on some platforms)",
429
+ checkSkipped: "Check already in progress, skipped"
367
430
  }
368
431
  },
369
432
  "zh-CN": {
370
433
  cli: {
371
434
  usage: "用法:",
372
435
  commands: "命令:",
436
+ globalOptions: "全局选项:",
373
437
  supportedPresets: "支持的 preset:",
374
438
  supportedAdapters: "支持的 adapter:",
375
439
  supportedLocales: "支持的语言:",
440
+ versionSummary: "显示版本号",
441
+ helpSummary: "显示帮助信息",
442
+ langSummary: "输出语言 (en, zh-CN)",
376
443
  initSummary: "向仓库写入 guardrail 文件",
377
444
  planSummary: "输出受约束的实现简报,并默认写入更丰富的任务契约",
378
445
  checkSummary: "校验当前改动的范围、一致性、正确性、风险和生产画像规则",
379
446
  mcpSummary: "通过 stdio MCP server 暴露运行时能力",
447
+ startSummary: "启动守护进程,自动后台检查",
448
+ stopSummary: "停止守护进程",
449
+ statusSummary: "查看守护进程状态和配置",
380
450
  unknownCommand: "未知命令 \"{command}\""
381
451
  },
382
452
  errors: {
@@ -538,7 +608,8 @@ const resources = {
538
608
  mentionObservability: "在证据说明里说明 metrics、logging、tracing 或 monitoring 的变化。",
539
609
  mentionPerfValidation: "说明并发、负载、性能或可靠性是如何验证的。",
540
610
  extendExistingTarget: "优先扩展现有目标:{value}",
541
- preserveExistingStructure: "尽量保持当前结构,除非先显式放宽任务契约。"
611
+ preserveExistingStructure: "尽量保持当前结构,除非先显式放宽任务契约。",
612
+ moveSecretToEnv: "将密钥移至环境变量或密钥管理器,并在 .env.example 中记录变量名。"
542
613
  },
543
614
  check: {
544
615
  title: "Agent Guardrails 检查结果",
@@ -589,7 +660,15 @@ const resources = {
589
660
  nextActions: "下一步:",
590
661
  finishCommand: "收尾命令:{value}",
591
662
  action: "处理建议:{value}",
592
- findingSeverity: "[{severity}] {message}"
663
+ findingSeverity: "[{severity}] {message}",
664
+ costAwareness: "成本感知:",
665
+ costSizeSmall: "变更规模:小({totalFiles} 个文件,{sourceFiles} 个源码)",
666
+ costSizeMedium: "变更规模:中({totalFiles} 个文件,{sourceFiles} 个源码)",
667
+ costSizeLarge: "变更规模:大({totalFiles} 个文件,{sourceFiles} 个源码)",
668
+ costSizeVeryLarge: "变更规模:非常大({totalFiles} 个文件,{sourceFiles} 个源码)",
669
+ costTokenEstimate: "预估 token 成本:{low}-{high}",
670
+ costLargeChangeWarning: "检测到大范围变更 — 建议拆分为更小的任务以降低 token 消耗和审查负担。",
671
+ costFixCostHint: "修复成本:{errorCount} 个错误、{warningCount} 个警告需要解决。"
593
672
  },
594
673
  findings: {
595
674
  missingRequiredFile: "缺少必需文件:{path}",
@@ -621,7 +700,17 @@ const resources = {
621
700
  performanceSensitiveAreaTouched: "触及性能敏感区域:{files}",
622
701
  observabilityRequirementsUnaddressed: "任务声明了可观测性要求,但证据中没有体现。",
623
702
  concurrencyRequirementsUnaddressed: "任务声明了并发或性能要求,但证据中没有体现相关验证。",
624
- protectedAreaDefaultAction: "收窄任务契约,或为 {path} 补充更明确的 review 说明。"
703
+ protectedAreaDefaultAction: "收窄任务契约,或为 {path} 补充更明确的 review 说明。",
704
+ secretsSafetyDetected: "在 {file} 的新增行中检测到可能的 {type}。请将密钥移至环境变量或密钥管理器。"
705
+ },
706
+ recovery: {
707
+ revertOrNarrowScope: "恢复建议:撤回超出范围的变更,或更新任务契约以包含这些文件。",
708
+ runOrSkipCommands: "恢复建议:执行缺失的必需命令,或在证据中说明跳过原因。",
709
+ writeEvidenceFile: "恢复建议:创建必需的证据文件,记录已执行的操作和残余风险。",
710
+ revertOrAddReviewNotes: "恢复建议:撤回保护区域的变更,或添加详细的 review 说明。",
711
+ moveSecretsToEnv: "恢复建议:立即将检测到的密钥移至环境变量或密钥管理器。",
712
+ revertOrWidenContract: "恢复建议:撤回意外的改动类型变更,或更新任务契约。",
713
+ addTestsOrAcknowledge: "恢复建议:为变更的源码添加测试,或说明推迟测试的原因。"
625
714
  },
626
715
  roughIntent: {
627
716
  analyzing: "正在分析你的意图...",
@@ -649,6 +738,41 @@ const resources = {
649
738
  actionConfirm: "✅ 确认,继续",
650
739
  actionModify: "✏️ 修改范围",
651
740
  actionCancel: "❌ 取消"
741
+ },
742
+ daemon: {
743
+ starting: "正在启动守护进程...",
744
+ foregroundMode: "前台模式",
745
+ started: "✅ 守护进程启动成功!",
746
+ startFailed: "❌ 守护进程启动失败",
747
+ alreadyRunning: "守护进程已在运行中!",
748
+ stopping: "正在停止守护进程...",
749
+ stopped: "✅ 守护进程已停止。",
750
+ stopFailed: "❌ 停止守护进程失败",
751
+ notRunning: "守护进程未运行。",
752
+ status: "守护进程状态",
753
+ state: "状态",
754
+ running: "运行中",
755
+ config: "配置",
756
+ watchPaths: "监控路径",
757
+ checkInterval: "检查间隔",
758
+ blockOnHighRisk: "高风险时阻断",
759
+ commands: "命令",
760
+ startTime: "启动时间",
761
+ checksRun: "已运行检查",
762
+ lastCheck: "上次检查",
763
+ logFile: "日志文件",
764
+ configFile: "配置文件",
765
+ useStop: "使用 'agent-guardrails stop' 停止。",
766
+ useStatus: "使用 'agent-guardrails status' 查看状态。",
767
+ startDesc: "启动守护进程",
768
+ stopDesc: "停止守护进程",
769
+ statusDesc: "查看守护进程状态",
770
+ configUpdated: "✅ 守护进程配置已更新!",
771
+ startDescLong: "在后台启动守护进程",
772
+ stopDescLong: "停止守护进程",
773
+ statusDescLong: "显示守护进程状态和配置",
774
+ chokidarFallback: "chokidar 不可用,降级使用 fs.watch(部分平台可靠性较低)",
775
+ checkSkipped: "检查正在进行中,已跳过"
652
776
  }
653
777
  }
654
778
  };
package/lib/mcp/server.js CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { executeCheck } from "../commands/check.js";
2
4
  import {
3
5
  finishAgentNativeLoop,
@@ -181,18 +183,32 @@ const TOOL_DEFINITIONS = [
181
183
  },
182
184
  mode: {
183
185
  type: "string",
184
- enum": ["suggest", "auto", "strict"],
185
- "description": "suggest = return suggestion for confirmation, auto = auto-accept if confidence >= 0.6, strict = always require confirmation"
186
+ enum: ["suggest", "auto", "strict"],
187
+ description: "suggest = return suggestion for confirmation, auto = auto-accept if confidence >= 0.6, strict = always require confirmation"
186
188
  },
187
189
  locale: {
188
190
  type: "string",
189
- enum": ["en", "zh-CN"],
190
- "description": "Language for the response text."
191
+ enum: ["en", "zh-CN"],
192
+ description: "Language for the response text."
191
193
  }
192
194
  },
193
195
  required: ["task"],
194
196
  additionalProperties: false
195
197
  }
198
+ },
199
+ {
200
+ name: "read_daemon_status",
201
+ description: "Read the latest daemon check result and status. Returns daemon running state, check count, and the structured result of the most recent guardrail check. Call this after code changes to check if the daemon detected any issues.",
202
+ inputSchema: {
203
+ type: "object",
204
+ properties: {
205
+ repoRoot: {
206
+ type: "string",
207
+ description: "Absolute path to the repository root."
208
+ }
209
+ },
210
+ additionalProperties: false
211
+ }
196
212
  }
197
213
  ];
198
214
 
@@ -428,6 +444,35 @@ async function callTool(name, args, defaultRepoRoot) {
428
444
  });
429
445
  }
430
446
 
447
+ if (name === "read_daemon_status") {
448
+ const repoRoot = args.repoRoot || defaultRepoRoot;
449
+ const { isDaemonRunning, getDaemonConfig } = await import("../commands/daemon.js");
450
+ const status = isDaemonRunning(repoRoot);
451
+ const config = getDaemonConfig(repoRoot);
452
+
453
+ const resultPath = path.join(repoRoot, ".agent-guardrails", "daemon-result.json");
454
+ let lastResult = null;
455
+ try {
456
+ if (fs.existsSync(resultPath)) {
457
+ lastResult = JSON.parse(fs.readFileSync(resultPath, "utf8"));
458
+ }
459
+ } catch { /* ignore */ }
460
+
461
+ return createJsonResult({
462
+ running: status.running,
463
+ pid: status.pid || null,
464
+ startTime: status.startTime || null,
465
+ checksRun: status.checksRun || 0,
466
+ lastCheck: status.lastCheck || null,
467
+ config: {
468
+ watchPaths: config.watchPaths,
469
+ checkInterval: config.checkInterval,
470
+ blockOnHighRisk: config.blockOnHighRisk
471
+ },
472
+ lastResult
473
+ });
474
+ }
475
+
431
476
  throw createError(-32601, `Unknown tool "${name}".`);
432
477
  }
433
478
 
@@ -748,10 +748,103 @@ export function prepareFinishCheck({ repoRoot, session = null, commandsRun = [],
748
748
  };
749
749
  }
750
750
 
751
+ function buildCostHints(result) {
752
+ const totalFiles = (result.changedFiles || []).length;
753
+ const sourceFiles = (result.sourceFiles || []).length;
754
+ const hasErrors = result.findings.some((f) => f.severity === "error");
755
+ const hasWarnings = result.findings.some((f) => f.severity === "warning");
756
+
757
+ const lowEstimate = totalFiles * 50;
758
+ const highEstimate = sourceFiles * 1500 + (totalFiles - sourceFiles) * 200;
759
+
760
+ let sizeLevel;
761
+ if (totalFiles <= 3) {
762
+ sizeLevel = "Small";
763
+ } else if (totalFiles <= 8) {
764
+ sizeLevel = "Medium";
765
+ } else if (totalFiles <= 15) {
766
+ sizeLevel = "Large";
767
+ } else {
768
+ sizeLevel = "VeryLarge";
769
+ }
770
+
771
+ const entries = [];
772
+
773
+ entries.push({
774
+ key: `check.costSize${sizeLevel}`,
775
+ vars: { totalFiles, sourceFiles }
776
+ });
777
+
778
+ entries.push({
779
+ key: "check.costTokenEstimate",
780
+ vars: { low: formatTokens(lowEstimate), high: formatTokens(highEstimate) }
781
+ });
782
+
783
+ if (totalFiles > 8) {
784
+ entries.push({ key: "check.costLargeChangeWarning", vars: {} });
785
+ }
786
+
787
+ if (hasErrors || hasWarnings) {
788
+ const errorCount = result.findings.filter((f) => f.severity === "error").length;
789
+ const warningCount = result.findings.filter((f) => f.severity === "warning").length;
790
+ entries.push({
791
+ key: "check.costFixCostHint",
792
+ vars: { errorCount, warningCount }
793
+ });
794
+ }
795
+
796
+ return {
797
+ sizeLevel,
798
+ tokenEstimate: { low: lowEstimate, high: highEstimate },
799
+ entries
800
+ };
801
+ }
802
+
803
+ function formatTokens(count) {
804
+ if (count >= 1000) return `${Math.round(count / 1000)}k`;
805
+ return String(count);
806
+ }
807
+
808
+ const RECOVERY_MAP = {
809
+ "path-scope-violation": "recovery.revertOrNarrowScope",
810
+ "task-scope-violation": "recovery.revertOrNarrowScope",
811
+ "intended-file-violation": "recovery.revertOrNarrowScope",
812
+ "missing-required-commands": "recovery.runOrSkipCommands",
813
+ "missing-evidence": "recovery.writeEvidenceFile",
814
+ "protected-area-touched": "recovery.revertOrAddReviewNotes",
815
+ "secrets-safety": "recovery.moveSecretsToEnv",
816
+ "change-type-violation": "recovery.revertOrWidenContract",
817
+ "test-coverage-missing": "recovery.addTestsOrAcknowledge"
818
+ };
819
+
820
+ function buildRecoveryGuidance(result, t) {
821
+ const findings = result.findings || [];
822
+ const errorFindings = findings.filter((f) => f.severity === "error");
823
+ if (errorFindings.length === 0) return [];
824
+
825
+ const guidance = [];
826
+ const seen = new Set();
827
+
828
+ for (const finding of errorFindings) {
829
+ const key = RECOVERY_MAP[finding.code];
830
+ if (key && !seen.has(key)) {
831
+ seen.add(key);
832
+ guidance.push(t(key));
833
+ }
834
+ }
835
+
836
+ return guidance;
837
+ }
838
+
751
839
  export function summarizeReviewRisks(result, locale = null) {
752
840
  const { t, locale: resolvedLocale } = createTranslator(locale ?? "en");
753
841
  const nextActions = [];
754
842
 
843
+ const recoveryGuidance = buildRecoveryGuidance(result, t);
844
+ if (recoveryGuidance.length > 0) {
845
+ nextActions.push(...recoveryGuidance);
846
+ }
847
+
755
848
  if (result.missingRequiredCommands.length > 0) {
756
849
  nextActions.push(t("runtime.reviewRunMissingCommands", { value: result.missingRequiredCommands.join(", ") }));
757
850
  }
@@ -935,6 +1028,7 @@ export function summarizeReviewRisks(result, locale = null) {
935
1028
  verdict,
936
1029
  deployReadiness,
937
1030
  postDeployMaintenance,
1031
+ costHints: buildCostHints(result),
938
1032
  topRisks: [
939
1033
  ...result.review.scopeIssues,
940
1034
  ...result.review.validationIssues,
package/lib/utils.js CHANGED
@@ -362,3 +362,16 @@ export function normalizeChangeType(value) {
362
362
 
363
363
  return normalized;
364
364
  }
365
+
366
+ /**
367
+ * Read the agent-guardrails package.json version
368
+ */
369
+ export function readOwnPackageJson() {
370
+ const packageJsonPath = path.join(import.meta.dirname, "..", "package.json");
371
+ try {
372
+ const content = fs.readFileSync(packageJsonPath, "utf8");
373
+ return JSON.parse(content);
374
+ } catch {
375
+ return { version: "unknown", name: "agent-guardrails" };
376
+ }
377
+ }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "agent-guardrails",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
+ "mcpName": "io.github.logi-cmd/agent-guardrails",
4
5
  "description": "Production guardrails for AI coding agents",
5
6
  "type": "module",
6
7
  "files": [
@@ -46,12 +47,23 @@
46
47
  "ai",
47
48
  "agent",
48
49
  "guardrails",
50
+ "mcp",
51
+ "mcp-server",
52
+ "model-context-protocol",
53
+ "code-review",
54
+ "merge-gate",
49
55
  "cli",
50
56
  "developer-tools",
51
57
  "codex",
52
58
  "cursor",
53
59
  "claude-code",
54
- "openhands"
60
+ "windsurf",
61
+ "openhands",
62
+ "ai-safety",
63
+ "llm-guardrails"
55
64
  ],
56
- "license": "MIT"
65
+ "license": "MIT",
66
+ "optionalDependencies": {
67
+ "chokidar": "^3.6.0"
68
+ }
57
69
  }