@niroai/niro 0.11.0 → 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.
@@ -0,0 +1,400 @@
1
+ /**
2
+ * `niro upgrade` — replace this install with the latest published package, then put back the
3
+ * things replacing the package cannot put back for itself.
4
+ *
5
+ * Why this runs as two processes.
6
+ *
7
+ * Every command in this CLI is loaded lazily, inside its own action handler (src/index.js), and
8
+ * `npm install -g` replaces the folder those files are loaded from. A single process that ran npm
9
+ * and then carried on would be running the modules it loaded before the swap alongside modules
10
+ * loaded after it. Nothing tests that mixture. start.js has always avoided the same trap the same
11
+ * way: bootstrapIfNpx installs globally only when it is running from the npx cache, so it never
12
+ * overwrites the tree it is executing, and then it hands over.
13
+ *
14
+ * phase one, this copy run npm, then exec the NEW global binary with --finish and stop.
15
+ * phase two, the new copy a fresh process reading the new tree: restart the watcher, refresh
16
+ * each configured client's hooks and skills, ask for an assistant
17
+ * restart.
18
+ *
19
+ * Phase one requires nothing between the npm call and the hand-over, and it also catches its own
20
+ * errors rather than letting them reach src/index.js `run()`, whose catch does
21
+ * `require("./lib/printError")` — a lazy load out of the folder npm has just replaced. That is the
22
+ * one path where the swap could still bite, so it is closed here rather than described.
23
+ *
24
+ * What it deliberately does not do is write a settings file itself. Refreshing hooks means
25
+ * spawning the existing installer, once per client. A second write path into
26
+ * ~/.claude/settings.json was built once, reviewed three times and reverted on 2026-07-30
27
+ * (d3ff3d8) after it was found deleting users' own hooks, changing that file from mode 0600 to
28
+ * 0644 while it can hold API keys, and, with HOME unset, writing into a project's git-tracked
29
+ * .claude/settings.json. The installer already gets those cases right, and here it runs because
30
+ * the user asked for it.
31
+ */
32
+ const childProcess = require("child_process");
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+
36
+ const color = require("../lib/color");
37
+ const service = require("../lib/service");
38
+ const mcpClients = require("../lib/mcpClients");
39
+ const mcpCmd = require("./mcp");
40
+ const startCmd = require("./start");
41
+
42
+ const PACKAGE_NAME = "@niroai/niro";
43
+ const VERSION = require("../../package.json").version;
44
+
45
+ /**
46
+ * Set on the process phase one hands over to. If phase one ever sees it already set, it is itself
47
+ * a hand-over child that failed to reach phase two, which means the newly installed version does
48
+ * not understand `--finish`. Without this, that version would run npm and hand over again, without
49
+ * bound. start.js guards its own hand-over with BOOTSTRAP_ENV for the same reason.
50
+ */
51
+ const HANDOVER_ENV = "NIRO_UPGRADE_HANDOVER";
52
+
53
+ /** Exit codes, so a script can tell the failures apart. */
54
+ const EXIT_NPM_FAILED = 1;
55
+ const EXIT_NO_BINARY = 2;
56
+ const EXIT_HANDOVER_FAILED = 3;
57
+ const EXIT_REFRESH_INCOMPLETE = 4;
58
+ const EXIT_WRONG_COPY = 5;
59
+
60
+ /** `niro upgrade` (phase one) and `niro upgrade --finish` (phase two). */
61
+ async function upgrade(opts = {}) {
62
+ if (opts.finish) return finish(opts.from);
63
+ return replaceAndHandOver(Boolean(opts.force));
64
+ }
65
+
66
+ /**
67
+ * Phase one. Replace the package, then hand over to the copy that replaced it.
68
+ *
69
+ * Everything this function touches is already loaded by the requires at the top of this file, and
70
+ * every failure is reported here rather than thrown, because a throw would reach a catch that
71
+ * lazily loads a module out of the folder npm has just swapped.
72
+ */
73
+ function replaceAndHandOver(force) {
74
+ if (process.env[HANDOVER_ENV]) {
75
+ console.error(color.red("\n The version just installed does not support `niro upgrade --finish`."));
76
+ console.error(" The package itself is upgraded. Your hooks, skills and watcher are not.");
77
+ console.error(` Finish by hand: ${color.cyan("niro mcp install")} for each assistant you use,`);
78
+ console.error(` then ${color.cyan("niro service install")} if you run the background watcher.\n`);
79
+ process.exitCode = EXIT_HANDOVER_FAILED;
80
+ return;
81
+ }
82
+
83
+ const running = describeRunningCopy();
84
+ if (running.kind === "linked" && !force) {
85
+ console.error(color.yellow("\n Not upgrading: niro is npm-linked to a working copy."));
86
+ console.error(` ${color.cyan("npm prefix -g")} says the global package folder is a link:`);
87
+ console.error(` ${running.pkgDir}`);
88
+ console.error(` -> ${running.linkTarget}`);
89
+ console.error(" `npm install -g` would replace that link with the published package, and");
90
+ console.error(" `niro` would stop running your checkout. Your files would be untouched, but");
91
+ console.error(` you would have to run ${color.cyan("npm link")} in the checkout to get it back.`);
92
+ console.error(` Upgrade anyway with ${color.cyan("niro upgrade --force")}.\n`);
93
+ process.exitCode = EXIT_WRONG_COPY;
94
+ return;
95
+ }
96
+ if (running.kind === "elsewhere" && !force) {
97
+ console.error(color.yellow("\n Not upgrading: you are not running the globally installed niro."));
98
+ console.error(` running: ${running.self || "unknown"}`);
99
+ console.error(` would upgrade: ${running.pkgDir || "the npm global folder"}`);
100
+ console.error(" Those are different copies, so this would replace one you are not using and");
101
+ console.error(" leave the one you are.");
102
+ console.error(` Upgrade the global copy anyway with ${color.cyan("niro upgrade --force")}.\n`);
103
+ process.exitCode = EXIT_WRONG_COPY;
104
+ return;
105
+ }
106
+
107
+ console.log(`\n Upgrading niro. Installed now: ${VERSION}\n`);
108
+
109
+ const installed = childProcess.spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
110
+ shell: true,
111
+ stdio: "inherit",
112
+ });
113
+
114
+ if (installed.signal) {
115
+ // Ctrl+C reaches the whole process group, so npm dies with this process. Whether it left the
116
+ // global folder half-written is npm's business and not something this can know, so it is not
117
+ // claimed either way.
118
+ console.error(color.yellow(`\n Interrupted (${installed.signal}) while npm was running.`));
119
+ console.error(" Whether the package was replaced is unknown. Check with `niro --version`,");
120
+ console.error(` then run ${color.cyan("niro upgrade")} again.\n`);
121
+ process.exitCode = EXIT_NPM_FAILED;
122
+ return;
123
+ }
124
+
125
+ if (installed.status !== 0) {
126
+ console.error(color.red("\n npm could not replace the package."));
127
+ console.error(" The usual cause is that your npm global folder needs root. Find it with");
128
+ console.error(` ${color.cyan("npm prefix -g")}, then either fix its ownership or re-run the install`);
129
+ console.error(" with the privileges it needs:");
130
+ console.error(` ${color.cyan(`npm install -g ${PACKAGE_NAME}@latest`)}`);
131
+ console.error(` Then run ${color.cyan("niro upgrade --finish")} to refresh your assistant files.\n`);
132
+ process.exitCode = EXIT_NPM_FAILED;
133
+ return;
134
+ }
135
+
136
+ const bin = startCmd.globalNiroPath();
137
+ if (!bin) {
138
+ console.error(color.red("\n npm reported success, but there is no niro in your npm global folder."));
139
+ console.error(` Check ${color.cyan("npm prefix -g")} and that its bin folder is on your PATH.`);
140
+ console.error(" The package is upgraded; only the refresh step was skipped. Run it with");
141
+ console.error(` ${color.cyan("niro upgrade --finish")} once niro is back on PATH.\n`);
142
+ process.exitCode = EXIT_NO_BINARY;
143
+ return;
144
+ }
145
+
146
+ if (!supportsFinish(bin)) {
147
+ // The version just installed has no `upgrade` command, which is what happens when the newest
148
+ // published version predates this one. Saying "re-run niro upgrade --finish" here would send
149
+ // the user to a command that cannot exist.
150
+ console.error(color.yellow("\n The package is upgraded, but the version now installed has no"));
151
+ console.error(" `niro upgrade` command, so it cannot refresh your assistant files itself.");
152
+ console.error(" Finish by hand:");
153
+ console.error(` ${color.cyan("niro mcp install")} once per assistant you use`);
154
+ console.error(` ${color.cyan("niro service install")} if you run the background watcher`);
155
+ console.error(" Then restart your assistant.\n");
156
+ process.exitCode = EXIT_HANDOVER_FAILED;
157
+ return;
158
+ }
159
+
160
+ // The hand-over. A fresh process reads the new tree cleanly; this one cannot. VERSION comes from
161
+ // package.json, so it is a semver string and safe on a Windows command line. The Windows branch
162
+ // is untested: spawning a .cmd shim needs a shell on current Node, and a global prefix
163
+ // containing spaces is why the path is quoted.
164
+ const env = { ...process.env, [HANDOVER_ENV]: "1" };
165
+ const handover = process.platform === "win32"
166
+ ? childProcess.spawnSync(`"${bin}" upgrade --finish --from ${VERSION}`, { stdio: "inherit", shell: true, env })
167
+ : childProcess.spawnSync(bin, ["upgrade", "--finish", "--from", VERSION], { stdio: "inherit", env });
168
+
169
+ if (handover.error) {
170
+ // spawnSync could not start the child at all: a non-executable bin link, a directory where the
171
+ // binary should be, a broken shim. Without this the command exits silently.
172
+ console.error(color.red(`\n Could not run the upgraded niro at ${bin}:`));
173
+ console.error(` ${handover.error.message}`);
174
+ console.error(" The package is upgraded; your hooks, skills and watcher are not.");
175
+ console.error(` Try ${color.cyan("niro upgrade --finish")}, or reinstall with`);
176
+ console.error(` ${color.cyan(`npm install -g ${PACKAGE_NAME}@latest`)}.\n`);
177
+ process.exitCode = EXIT_HANDOVER_FAILED;
178
+ return;
179
+ }
180
+
181
+ if (handover.signal) {
182
+ console.error(color.yellow(`\n Interrupted (${handover.signal}) while refreshing.`));
183
+ console.error(` The package is upgraded. Finish with ${color.cyan("niro upgrade --finish")}.\n`);
184
+ process.exitCode = EXIT_HANDOVER_FAILED;
185
+ return;
186
+ }
187
+
188
+ if (handover.status !== 0) {
189
+ console.error(`\n The upgraded niro exited with ${handover.status} while refreshing.`);
190
+ console.error(` The package is upgraded. Re-run ${color.cyan("niro upgrade --finish")} to try again.\n`);
191
+ }
192
+ process.exitCode = handover.status === null ? EXIT_HANDOVER_FAILED : handover.status;
193
+ }
194
+
195
+ /** Where npm would install this package globally. */
196
+ function globalPackageDir() {
197
+ const r = childProcess.spawnSync("npm", ["prefix", "-g"], { shell: true, encoding: "utf8" });
198
+ if (r.status !== 0) return null;
199
+ const prefix = String(r.stdout || "").trim();
200
+ if (!prefix) return null;
201
+ return process.platform === "win32"
202
+ ? path.join(prefix, "node_modules", PACKAGE_NAME.replace("/", path.sep))
203
+ : path.join(prefix, "lib", "node_modules", PACKAGE_NAME.replace("/", path.sep));
204
+ }
205
+
206
+ /**
207
+ * Which copy of niro is running this, checked BEFORE npm is allowed to touch anything.
208
+ *
209
+ * "global" this process is the globally installed package, so replacing it is the job
210
+ * "linked" the global package folder is a symlink, which is what `npm link` leaves behind.
211
+ * `npm install -g` deletes that link and puts the published package in its place,
212
+ * so a developer working on niro loses the wiring between `niro` and their checkout.
213
+ * "elsewhere" running from somewhere else entirely: a checkout invoked directly, or the npx
214
+ * cache. Upgrading then replaces a copy the user is not running.
215
+ *
216
+ * This exists because the first real run of `niro upgrade` was from a linked checkout and silently
217
+ * replaced it with the published package.
218
+ */
219
+ function describeRunningCopy(pkgDirOverride) {
220
+ // The directory is a parameter so a unit test can exercise every branch without shelling out to
221
+ // npm at all. An end-to-end rehearsal takes the other route and sets npm_config_prefix, which
222
+ // npm does honour (scripts/rehearse-upgrade.sh).
223
+ const pkgDir = pkgDirOverride === undefined ? globalPackageDir() : pkgDirOverride;
224
+
225
+ let self = null;
226
+ try {
227
+ self = fs.realpathSync(process.argv[1]);
228
+ } catch (err) {
229
+ self = null;
230
+ }
231
+
232
+ if (pkgDir) {
233
+ try {
234
+ if (fs.lstatSync(pkgDir).isSymbolicLink()) {
235
+ return { kind: "linked", pkgDir, linkTarget: fs.realpathSync(pkgDir), self };
236
+ }
237
+ } catch (err) {
238
+ // Not installed globally at all. Falls through to "elsewhere".
239
+ }
240
+ }
241
+
242
+ let pkgReal = null;
243
+ try {
244
+ pkgReal = pkgDir ? fs.realpathSync(pkgDir) : null;
245
+ } catch (err) {
246
+ pkgReal = null;
247
+ }
248
+ if (pkgReal && self && (self === pkgReal || self.startsWith(pkgReal + path.sep))) {
249
+ return { kind: "global", pkgDir, linkTarget: null, self };
250
+ }
251
+ return { kind: "elsewhere", pkgDir, linkTarget: null, self };
252
+ }
253
+
254
+ /**
255
+ * Does the version now installed actually have `niro upgrade --finish`?
256
+ *
257
+ * Asked rather than assumed, because the newest published version can predate this one: a machine
258
+ * running an unreleased build upgrades "up" to a package with no `upgrade` command at all. Without
259
+ * this, the hand-over fails with commander's bare `error: unknown command 'upgrade'` and the user
260
+ * is told to re-run a command that cannot exist.
261
+ */
262
+ function supportsFinish(bin) {
263
+ const probe = process.platform === "win32"
264
+ ? childProcess.spawnSync(`"${bin}" upgrade --help`, { shell: true, encoding: "utf8", stdio: "pipe" })
265
+ : childProcess.spawnSync(bin, ["upgrade", "--help"], { encoding: "utf8", stdio: "pipe" });
266
+ if (probe.error || probe.status !== 0) return false;
267
+ return String(probe.stdout || "").includes("--finish");
268
+ }
269
+
270
+ /** Phase two, running from the new package: put back what npm could not. */
271
+ async function finish(fromVersion) {
272
+ console.log(fromVersion && fromVersion !== VERSION
273
+ ? `\n niro ${fromVersion} to ${VERSION}\n`
274
+ : `\n niro ${VERSION}\n`);
275
+
276
+ // The watcher first. It is one command and cannot block, whereas a client refresh can sit on a
277
+ // prompt for as long as the user is away, and until the restart happens the watcher is running
278
+ // code from a package that no longer exists on disk.
279
+ restartWatcher();
280
+
281
+ const complete = await refreshAssistantFiles();
282
+ if (!complete) process.exitCode = EXIT_REFRESH_INCOMPLETE;
283
+
284
+ console.log(` ${color.yellow("One thing left:")} restart your AI assistant.`);
285
+ console.log(" Hooks and skills are read when it starts, so an assistant that is already open");
286
+ console.log(" is still using the old ones.\n");
287
+ }
288
+
289
+ /**
290
+ * Re-run the installer once per configured client, so their hooks, skills and instruction files
291
+ * match the package that is now installed. Returns false when anything was skipped or failed, so
292
+ * the caller can set a non-zero exit code: a script driving this needs to know that half of it
293
+ * did nothing.
294
+ *
295
+ * One client failing does not stop the others. They are separate files owned by separate tools,
296
+ * and a missing editor CLI is an ordinary reason for one to fail.
297
+ */
298
+ async function refreshAssistantFiles() {
299
+ const { home, configured } = mcpClients.configuredClients();
300
+
301
+ if (!home) {
302
+ console.log(` ${color.yellow("Skipped the assistant files:")} there is no usable home folder.`);
303
+ console.log(" HOME is unset or empty, so every path would have been read relative to the");
304
+ console.log(" folder you are standing in, which is not yours to read or write.");
305
+ console.log(` Set HOME, then run ${color.cyan("niro upgrade --finish")}.\n`);
306
+ return false;
307
+ }
308
+
309
+ if (configured.length === 0) {
310
+ console.log(" No configured assistant found, so there were no hooks or skills to refresh.");
311
+ console.log(` Connect one with ${color.cyan("niro mcp install")}.\n`);
312
+ return true;
313
+ }
314
+
315
+ if (!startCmd.hasCredentials()) {
316
+ console.log(` ${color.yellow("Heads up:")} you are not signed in, so each client's installer will ask`);
317
+ console.log(` for an API key. ${color.cyan("niro login")} first if you would rather it did not.\n`);
318
+ }
319
+
320
+ // Cursor's installer writes a project-scoped rule to <cwd>/.cursor/rules/niro.mdc, so refreshing
321
+ // it leaves a file in whatever folder the upgrade was run from. That is the installer's design,
322
+ // not something to change here, but it is a surprise when you only asked to upgrade.
323
+ if (configured.some((c) => c.id === "cursor")) {
324
+ console.log(` ${color.yellow("Note:")} refreshing Cursor also writes a project rule to`);
325
+ console.log(` ${path.join(process.cwd(), ".cursor", "rules", "niro.mdc")}`);
326
+ console.log(" because Cursor rules are per-folder. Delete it if you did not want it here.\n");
327
+ }
328
+
329
+ const total = configured.length;
330
+ console.log(` Refreshing hooks and skills for ${total} client${total === 1 ? "" : "s"}:`);
331
+ for (const client of configured) {
332
+ console.log(` ${client.name} (${client.where})`);
333
+ }
334
+ console.log("");
335
+
336
+ let done = 0;
337
+ let failed = 0;
338
+ for (const client of configured) {
339
+ console.log(color.dim(` [${done + failed + 1} of ${total}] ${client.name}`));
340
+ try {
341
+ await mcpCmd.install(["--client", client.id]);
342
+ done += 1;
343
+ } catch (err) {
344
+ failed += 1;
345
+ console.error(` ${color.yellow("!")} ${client.name} was not refreshed: ${err.message}`);
346
+ console.error(` Run ${color.cyan(`niro mcp install --client ${client.id}`)} to see why.\n`);
347
+ }
348
+ }
349
+
350
+ if (failed > 0) {
351
+ console.log(` ${done} of ${total} refreshed, ${failed} failed.`);
352
+ console.log(` Re-run ${color.cyan("niro upgrade --finish")} once the failures above are sorted.\n`);
353
+ return false;
354
+ }
355
+ console.log(` ${color.green("Done.")} ${done} of ${total} refreshed.\n`);
356
+ return true;
357
+ }
358
+
359
+ /**
360
+ * Restart the background watcher, which is a long-running process still holding the code it loaded
361
+ * when it started. Replacing files on disk cannot change a process that is already running.
362
+ */
363
+ function restartWatcher() {
364
+ if (!service.pm2Available()) {
365
+ console.log(" Background watcher: PM2 is not installed, so there is nothing to restart.\n");
366
+ return;
367
+ }
368
+
369
+ const state = service.processState();
370
+ if (state !== "online") {
371
+ const what = state === "stopped" ? "registered but not running" : "not installed";
372
+ console.log(` Background watcher: ${what}, so it was left alone.\n`);
373
+ return;
374
+ }
375
+
376
+ console.log(" Restarting the background watcher, which is still running the old code...");
377
+ if (service.restart()) {
378
+ // Deliberately not "now on <version>": pm2 re-execs the script path recorded when the daemon
379
+ // was installed (service.js records process.argv[1]), which is not necessarily the copy that
380
+ // was just upgraded. All that is known here is that pm2 restarted it.
381
+ console.log(` ${color.green("Done.")} niro-watch restarted. Check it with ${color.cyan("niro service status")}.\n`);
382
+ } else {
383
+ console.log(` ${color.yellow("!")} Could not restart it. Run ${color.cyan("niro service install")}.\n`);
384
+ }
385
+ }
386
+
387
+ module.exports = {
388
+ upgrade,
389
+ // exported for tests
390
+ refreshAssistantFiles,
391
+ restartWatcher,
392
+ describeRunningCopy,
393
+ supportsFinish,
394
+ HANDOVER_ENV,
395
+ EXIT_NPM_FAILED,
396
+ EXIT_NO_BINARY,
397
+ EXIT_HANDOVER_FAILED,
398
+ EXIT_REFRESH_INCOMPLETE,
399
+ EXIT_WRONG_COPY,
400
+ };
package/src/index.js CHANGED
@@ -309,6 +309,17 @@ function main() {
309
309
  await deleteCmd.deleteProject(projectId, { ...opts });
310
310
  }));
311
311
 
312
+ program
313
+ .command("upgrade")
314
+ .description("Upgrade niro to the latest published version, then refresh the hooks, skills and background watcher that replacing the package leaves untouched.")
315
+ .option("--finish", "Only refresh hooks, skills and the watcher; do not run npm. `niro upgrade` runs this for you in the new version once the package is replaced.")
316
+ .option("--from <version>", "The version upgraded from, for the summary line. Set automatically by `niro upgrade`.")
317
+ .option("--force", "Upgrade the global copy even when you are running a linked or local checkout, which `npm install -g` would replace.")
318
+ .action((opts) => run(async () => {
319
+ const upgradeCmd = require("./commands/upgrade");
320
+ await upgradeCmd.upgrade(opts);
321
+ }));
322
+
312
323
  const svc = program
313
324
  .command("service")
314
325
  .description("Manage the niro-watch background daemon (auto-sync on login).");
@@ -36,6 +36,7 @@ const color = require("./color");
36
36
  const BATCH_SIZE = 50;
37
37
  const SOURCE_EXTENSIONS = new Set([
38
38
  ".java", ".py", ".go", ".js", ".ts", ".tsx", ".jsx",
39
+ ".cjs", ".mjs", ".cts", ".mts",
39
40
  ".kt", ".scala", ".rs", ".rb", ".php", ".cs", ".swift",
40
41
  ".c", ".cpp", ".h", ".hpp", ".dart",
41
42
  ]);
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Record of the impact checks an agent made through the bridge, read by the Edit|Write impact gate.
3
+ *
4
+ * Why this exists: CLAUDE.md, the niro skill and the UserPromptSubmit hook all tell the agent to map
5
+ * who depends on code before changing it (get_blast_radius, get_impact_analysis). Prose decays over a
6
+ * session: the agent obeys early, then reads a method with sed and edits it. Niraj, 2026-09-04: "I
7
+ * always felt that before you make any code changes in any method you would want to check what's
8
+ * the impact of that but you hardly ever care to check." The only thing that survives a long session
9
+ * is a check at the moment of the edit. The PreToolUse hook in setup.js (NIRO_IMPACT_COMMAND) is that
10
+ * check, and it needs evidence of what the agent actually called. The bridge sees every tools/call and
11
+ * every answer, so it keeps that evidence here.
12
+ *
13
+ * What is written: one JSON line per answered impact call under ~/.niro/impact/<key>.jsonl, where
14
+ * <key> is the bridge's working directory, symlinks resolved, with every byte that is not an ASCII
15
+ * letter or digit replaced by "-". The hook derives the same key from the payload's cwd with
16
+ * `cd -P` and `tr -c 'A-Za-z0-9' '-'`, so the two sides agree without sharing code (tested in
17
+ * test/impactGateHook.test.js). A line carries the tool name, the names the agent asked about
18
+ * (method_fqn, class_fqn and friends) and every source path the answer mentioned. Lines older than
19
+ * WINDOW_MS are pruned on every write, so the file cannot grow past one hour of checks.
20
+ *
21
+ * Liveness: <key>.alive is touched whenever the server answers. The hook denies an unchecked edit only
22
+ * while that file is fresh; with the bridge gone it can only advise, so Niro being unreachable never
23
+ * blocks an edit. Fail-open by design.
24
+ *
25
+ * Every function here swallows filesystem errors: this sits on the message path between the host and
26
+ * the server, and a full disk or an unwritable home must cost the gate and nothing else.
27
+ */
28
+ const fs = require("fs");
29
+ const path = require("path");
30
+ const { niroHome } = require("./receiptStore");
31
+
32
+ /** The calls that count as "checked who depends on it". Downstream-only walks (get_call_chain) do not. */
33
+ const IMPACT_TOOLS = new Set(["get_blast_radius", "get_impact_analysis", "get_reverse_call_chain"]);
34
+ /** How long a recorded check keeps a file unlocked. */
35
+ const WINDOW_MS = 60 * 60 * 1000;
36
+ const MAX_LINES = 500;
37
+ const MAX_NAMES = 50;
38
+ const MAX_FILES = 200;
39
+ const MAX_IN_FLIGHT = 100;
40
+ /** The alive file is rewritten at most this often; answers arrive far more frequently than that. */
41
+ const ALIVE_TOUCH_MIN_MS = 10 * 1000;
42
+ /** Argument names across the impact tools that carry what the agent asked about. */
43
+ const NAME_ARGS = [
44
+ "method_fqn", "method_fqns", "class_fqn", "entity_fqn", "entity_fqns", "fqn", "fqns", "targets",
45
+ "http_path", "event_fqn",
46
+ ];
47
+ const SOURCE_EXT = "java|js|mjs|cjs|ts|tsx|jsx|py|go|rb|kt|kts|cs|php|rs|scala|swift|c|cc|cpp|h|hpp";
48
+ /**
49
+ * A source path inside an answer: optional leading "/", slash-separated segments, a known extension,
50
+ * bounded by whitespace, quotes, brackets, ":" or ",". The ":" lets `repo::src/a/B.java` yield the
51
+ * repo-relative path, which is what the hook compares against the edited file's path.
52
+ */
53
+ const PATH_RE = new RegExp(
54
+ "(?:^|[\\s\"'(:,])(/?(?:[A-Za-z0-9_.\\-]+/)*[A-Za-z0-9_.\\-]+\\.(?:" + SOURCE_EXT + "))(?=$|[\\s\"'),:])",
55
+ "g",
56
+ );
57
+
58
+ function impactDir() {
59
+ return path.join(niroHome(), "impact");
60
+ }
61
+
62
+ /** Presence of this file switches the gate off for every session on this machine. */
63
+ function offSwitchPath() {
64
+ return path.join(niroHome(), "impact-gate-off");
65
+ }
66
+
67
+ /**
68
+ * The per-folder key, byte for byte what `printf '%s' "$(cd -P dir && pwd -P)" | tr -c 'A-Za-z0-9' '-'`
69
+ * produces. Byte-based on purpose: tr works on bytes, so a non-ASCII character becomes several dashes
70
+ * there and must here too, or the hook looks for a file the bridge never wrote.
71
+ */
72
+ function keyForCwd(cwd) {
73
+ let real = String(cwd || "");
74
+ try {
75
+ real = fs.realpathSync(real);
76
+ } catch (_) {
77
+ /* a folder that does not exist keeps its lexical path, as the hook's `cd -P || printf` fallback does */
78
+ }
79
+ let out = "";
80
+ for (const b of Buffer.from(real, "utf8")) {
81
+ const alnum = (b >= 48 && b <= 57) || (b >= 65 && b <= 90) || (b >= 97 && b <= 122);
82
+ out += alnum ? String.fromCharCode(b) : "-";
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function logPathFor(cwd) {
88
+ return path.join(impactDir(), keyForCwd(cwd) + ".jsonl");
89
+ }
90
+
91
+ function alivePathFor(cwd) {
92
+ return path.join(impactDir(), keyForCwd(cwd) + ".alive");
93
+ }
94
+
95
+ function toolNameOf(message) {
96
+ if (!message || message.method !== "tools/call" || !message.params) return null;
97
+ return typeof message.params.name === "string" ? message.params.name : null;
98
+ }
99
+
100
+ function isImpactCall(message) {
101
+ const name = toolNameOf(message);
102
+ return !!name && IMPACT_TOOLS.has(name);
103
+ }
104
+
105
+ /** The strings the agent asked about, deduplicated and bounded; routing extras like _niroPin are ignored. */
106
+ function namesFromArgs(args) {
107
+ const out = [];
108
+ const push = (v) => {
109
+ if (typeof v !== "string") return;
110
+ const s = v.trim().slice(0, 300);
111
+ if (s && !out.includes(s)) out.push(s);
112
+ };
113
+ if (args && typeof args === "object") {
114
+ for (const key of NAME_ARGS) {
115
+ const v = args[key];
116
+ if (Array.isArray(v)) v.forEach(push);
117
+ else push(v);
118
+ }
119
+ }
120
+ return out.slice(0, MAX_NAMES);
121
+ }
122
+
123
+ /** All text parts of a tool result, joined; "" for anything that is not a text result. */
124
+ function resultText(msg) {
125
+ const content = msg && msg.result && msg.result.content;
126
+ if (!Array.isArray(content)) return "";
127
+ return content.map((c) => (c && typeof c.text === "string" ? c.text : "")).join("\n");
128
+ }
129
+
130
+ /** Every distinct source path an answer mentions, in order of appearance, bounded. */
131
+ function filesFromResultText(text) {
132
+ const out = [];
133
+ if (typeof text !== "string" || !text) return out;
134
+ PATH_RE.lastIndex = 0;
135
+ let m;
136
+ while ((m = PATH_RE.exec(text)) && out.length < MAX_FILES) {
137
+ if (!out.includes(m[1])) out.push(m[1]);
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /** Drop lines older than the window, then cap the count; the newest lines win. */
143
+ function pruneLines(lines, now) {
144
+ const cutoff = now - WINDOW_MS;
145
+ const kept = lines.filter((line) => {
146
+ const m = /"ts":(\d+)/.exec(line);
147
+ return !!m && Number(m[1]) >= cutoff;
148
+ });
149
+ return kept.length > MAX_LINES ? kept.slice(kept.length - MAX_LINES) : kept;
150
+ }
151
+
152
+ /** Append one record, pruning first. Returns false, never throws, when the file cannot be written. */
153
+ function appendRecord(file, record, now) {
154
+ try {
155
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
156
+ let lines = [];
157
+ try {
158
+ lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
159
+ } catch (_) {
160
+ /* first record for this folder */
161
+ }
162
+ lines = pruneLines(lines, now);
163
+ lines.push(JSON.stringify(record));
164
+ fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
165
+ return true;
166
+ } catch (_) {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * One recorder per bridge process. `noteRequest` on every outgoing message, `noteResult` and
173
+ * `touchAlive` on every incoming one; each returns true only when it wrote something, which the
174
+ * tests use and the bridge ignores.
175
+ */
176
+ function createImpactRecorder({ cwd = process.cwd(), now = Date.now } = {}) {
177
+ const inFlight = new Map();
178
+ let lastAlive = 0;
179
+ return {
180
+ noteRequest(message) {
181
+ if (!isImpactCall(message) || message.id === undefined || message.id === null) return false;
182
+ const args = message.params.arguments || {};
183
+ inFlight.set(message.id, { tool: message.params.name, names: namesFromArgs(args), ts: now() });
184
+ if (inFlight.size > MAX_IN_FLIGHT) inFlight.delete(inFlight.keys().next().value);
185
+ return true;
186
+ },
187
+ noteResult(msg) {
188
+ if (!msg || msg.id === undefined || msg.id === null) return false;
189
+ const meta = inFlight.get(msg.id);
190
+ if (!meta) return false;
191
+ inFlight.delete(msg.id);
192
+ // An error answer is still recorded: the agent asked, which is the behaviour the gate exists to
193
+ // produce, and a name the server could not resolve must not leave the file locked.
194
+ const files = filesFromResultText(resultText(msg));
195
+ return appendRecord(logPathFor(cwd), { ts: meta.ts, tool: meta.tool, names: meta.names, files }, now());
196
+ },
197
+ clearAlive() {
198
+ try {
199
+ fs.rmSync(alivePathFor(cwd), { force: true });
200
+ return true;
201
+ } catch (_) {
202
+ return false;
203
+ }
204
+ },
205
+ touchAlive() {
206
+ const t = now();
207
+ if (t - lastAlive < ALIVE_TOUCH_MIN_MS) return false;
208
+ lastAlive = t;
209
+ try {
210
+ fs.mkdirSync(impactDir(), { recursive: true, mode: 0o700 });
211
+ fs.writeFileSync(alivePathFor(cwd), String(t), { mode: 0o600 });
212
+ return true;
213
+ } catch (_) {
214
+ return false;
215
+ }
216
+ },
217
+ };
218
+ }
219
+
220
+ /** Remove the evidence files and the off switch. Used by `niro mcp uninstall`. Best effort. */
221
+ function purge() {
222
+ try {
223
+ fs.rmSync(impactDir(), { recursive: true, force: true });
224
+ } catch (_) {
225
+ /* best effort */
226
+ }
227
+ try {
228
+ fs.rmSync(offSwitchPath(), { force: true });
229
+ } catch (_) {
230
+ /* best effort */
231
+ }
232
+ }
233
+
234
+ module.exports = {
235
+ purge,
236
+ IMPACT_TOOLS,
237
+ WINDOW_MS,
238
+ MAX_LINES,
239
+ keyForCwd,
240
+ impactDir,
241
+ offSwitchPath,
242
+ logPathFor,
243
+ alivePathFor,
244
+ isImpactCall,
245
+ namesFromArgs,
246
+ resultText,
247
+ filesFromResultText,
248
+ pruneLines,
249
+ appendRecord,
250
+ createImpactRecorder,
251
+ };