@niroai/niro 0.11.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 0.12.0 — 2026-09-06
8
+
9
+ ### Before an edit to indexed source, Claude is asked to map the method and to verify those flows afterwards
10
+
11
+ Every instruction file niro writes tells the assistant to map who depends on a method before changing
12
+ it. In practice the assistant finds the file, reads it, and edits; the rule is read early in a session
13
+ and forgotten by the middle of it. And the map was always meant for afterwards: the point is to go back
14
+ to the callers and flows once the edit is made and check they still hold.
15
+
16
+ Claude Code now gets a third niro hook, on Edit and Write. Before an edit to source in an indexed repo
17
+ it puts a short reminder in front of the assistant: decide whether the change can alter behaviour (a log
18
+ line or a comment cannot, say so and go ahead); if it can, map who depends on the method now with
19
+ get_blast_radius or get_impact_analysis and keep that list as the checklist for after the edit; and do
20
+ not re-query the graph to verify, because it updates only after the edit is synced and rebuilt. If an
21
+ impact call already named the file this session, the reminder instead says to go back to that list
22
+ after the edit. Each reminder appears once per file per session. It is advisory: it never approves or
23
+ refuses an edit. The evidence comes from the bridge, which writes one line per answered impact call
24
+ under `~/.niro/impact/`. New files, tests, config and docs are never mentioned. Off switch:
25
+ `touch ~/.niro/impact-gate-off`. `niro mcp uninstall` removes the hook and the evidence files.
26
+
27
+ The same three points were added to every instruction surface niro writes: the rule sentence in each
28
+ client's always-on file, the niro skill, the Cursor, Cline and Kiro rules, and the PostCompact and
29
+ prompt hooks. The clients without hooks read them there. Details: `docs/usecases/impact-gate.md`.
30
+
31
+ ### `niro mcp install` now refreshes Claude Code hooks on a machine where niro was already set up
32
+
33
+ Re-running the install on a machine with an existing registration failed at the first step, because
34
+ `claude mcp add` refuses a name that already exists, and the installer read that refusal as "Claude
35
+ Code is not installed" and stopped before writing any hook or the CLAUDE.md section. So the step the
36
+ out-of-date notice asks for did nothing for anyone who had installed once. The installer now reads the
37
+ existing registration first: same URL, it keeps it; a different `--url`, it replaces it; none, it adds
38
+ it. Hooks and sections are written in every case.
39
+
40
+ ### CommonJS and ES-module files (.cjs, .mjs, .cts, .mts) are now uploaded for indexing
41
+
42
+ When niro decided which files to upload for indexing, it skipped `.cjs`, `.mjs`, `.cts` and `.mts`,
43
+ so a `build.cjs` or an `index.mjs` was never indexed and never appeared in the graph. Those four
44
+ extensions are now included, matching the server's own file filter.
45
+
46
+ ### The niro rules now name get_exception_flow
47
+
48
+ The explore-first rule niro writes into each client now mentions `get_exception_flow`, the tool that
49
+ reports which exceptions can escape a method (and who must handle one). It is served by the niro
50
+ backend; this only makes the assistants aware it exists.
51
+
5
52
  ## 0.11.0 — 2026-09-03
6
53
 
7
54
  ### The watcher stops pausing and resuming your uploads over and over
package/README.md CHANGED
@@ -45,7 +45,9 @@ Then restart your assistant and ask it something:
45
45
  ```bash
46
46
  niro status
47
47
  ```
48
- Is my index up to date?
48
+ Is my index up to date? It answers about the project this folder is synced to **on the branch you
49
+ are on**, so once you have run `niro new-temp-project` it reports that copy rather than the shared
50
+ project. Name one explicitly to ask about a different project: `niro status <project-id>`.
49
51
 
50
52
  ```bash
51
53
  niro start
@@ -65,7 +67,9 @@ Every project in your account.
65
67
  ```bash
66
68
  niro build
67
69
  ```
68
- Rebuild the index by hand and watch it finish.
70
+ Rebuild the index by hand and watch it finish. Like `niro status`, it picks the project for the
71
+ branch you are on, so on a branch with a temporary project it rebuilds that one. Name a project to
72
+ rebuild a different one.
69
73
 
70
74
  ```bash
71
75
  niro doctor
@@ -224,7 +228,10 @@ Detach a repo from a project. Nothing is deleted.
224
228
  ```bash
225
229
  niro remove --project --confirm-name <name>
226
230
  ```
227
- Delete a whole project. Destructive, hence the confirmation.
231
+ Delete a whole project. Destructive, hence the confirmation. It refuses on a temporary project and
232
+ sends you to `niro discard-temp-project` instead, because that removes the copy **and** points this
233
+ folder back at the shared project; deleting it here would leave the folder aimed at a project that
234
+ no longer exists. `niro delete` refuses for the same reason.
228
235
 
229
236
  ---
230
237
 
@@ -46,6 +46,14 @@ It creates your own private copy of the project inside Niro.
46
46
 
47
47
  From then on, answers come from YOUR code.
48
48
 
49
+ The commands follow too. `niro status` and `niro build` act on the copy for the branch you are on,
50
+ not on the shared project, so "is my index up to date" and "rebuild it" mean what you expect while
51
+ you are working on a branch. Name a project explicitly if you want a different one.
52
+
53
+ `niro delete` and `niro remove --project` will not delete a private copy. They send you to
54
+ `niro discard-temp-project`, which removes the copy AND points your folder back at the shared
55
+ project. Deleting it any other way leaves your folder aimed at something that is gone.
56
+
49
57
  ## Switching branches
50
58
 
51
59
  Nothing to do. Niro checks which branch you are on for every question.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Niro — one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
5
5
  "keywords": [
6
6
  "niro",
@@ -7,11 +7,16 @@
7
7
  * - an alias or git URL -> resolved via GET /api/projects/resolve.
8
8
  * If an alias/URL maps to MULTIPLE projects, a clear ambiguity error is
9
9
  * thrown listing the candidates (we never silently pick the first).
10
- * 2. Walk up from cwd looking for .niro-project (same logic the MCP bridge uses).
10
+ * 2. The project THIS FOLDER IS CURRENTLY SYNCED TO, when it was recorded for the
11
+ * branch that is checked out. See resolveFromSyncedFolder for why this comes
12
+ * before the pin.
13
+ * 3. Walk up from cwd looking for .niro-project (same logic the MCP bridge uses).
11
14
  */
12
15
  const api = require("../lib/api");
13
16
  const prompt = require("../lib/prompt");
14
17
  const niroProjectFile = require("../lib/niroProjectFile");
18
+ const git = require("../lib/git");
19
+ const watchState = require("../lib/watchState");
15
20
  const { normalizeProjects, resolveContext } = require("../lib/resolveContext");
16
21
 
17
22
  // A canonical project_id is a UUID. Anything that doesn't look like one and
@@ -119,6 +124,36 @@ async function resolveProjectFromGit(opts = {}) {
119
124
  throw e;
120
125
  }
121
126
 
127
+ /**
128
+ * The project this folder is actually synced to right now, when the recorded branch is the
129
+ * one checked out. Null when nothing is recorded, when the branch has moved on, or on any error.
130
+ *
131
+ * <p>Why this has to come BEFORE the .niro-project pin. The pin holds the SHARED project's id and
132
+ * never changes. When a developer runs `niro new-temp-project`, the folder starts syncing to a
133
+ * private temporary project that indexes their branch, and only ~/.niro/projects.json records it.
134
+ * Reading the pin therefore answers about a project the developer is not working in. Measured on
135
+ * 2026-09-08: in a repo on branch feat/shipping-wrap-rethrow, `niro status` reported the shared
136
+ * project with "Updated 2026-09-06T09:28", two days stale, while the branch's own project had
137
+ * finished a build four minutes earlier. `niro build` had the same fault and would have rebuilt the
138
+ * shared project.
139
+ *
140
+ * <p>The branch comparison is the safety catch. An entry recorded on another branch must not
141
+ * redirect the command, so a mismatch falls through to the pin, which is the old behaviour.
142
+ */
143
+ function resolveFromSyncedFolder(opts = {}) {
144
+ try {
145
+ const ctx = git.inspect(process.cwd());
146
+ if (!ctx || !ctx.repoRoot) return null;
147
+ const entry = watchState.get(ctx.repoRoot, opts.apiUrl || api.currentBaseUrl());
148
+ if (!entry || !entry.projectId) return null;
149
+ if (entry.branch && ctx.branch && entry.branch !== ctx.branch) return null;
150
+ return entry.projectId;
151
+ } catch (_) {
152
+ // Never block a command on this: falling through to the pin is the previous behaviour.
153
+ return null;
154
+ }
155
+ }
156
+
122
157
  async function resolveProjectId(explicit, opts = {}) {
123
158
  if (explicit) {
124
159
  // A UUID is used directly — unchanged behavior. Anything else is an
@@ -126,7 +161,10 @@ async function resolveProjectId(explicit, opts = {}) {
126
161
  if (looksLikeProjectId(explicit)) return explicit;
127
162
  return resolveAliasOrUrl(explicit, opts);
128
163
  }
129
- // No explicit arg: prefer a .niro-project pin (walk up from cwd).
164
+ // No explicit arg: the project this folder is synced to on THIS branch wins over the pin.
165
+ const fromFolder = resolveFromSyncedFolder(opts);
166
+ if (fromFolder) return fromFolder;
167
+ // Then a .niro-project pin (walk up from cwd).
130
168
  const fromFile = readNiroProjectFile(process.cwd());
131
169
  if (fromFile) {
132
170
  // A pin that is already a project_id is used directly. A pin holding an
@@ -139,4 +177,22 @@ async function resolveProjectId(explicit, opts = {}) {
139
177
  return resolveProjectFromGit(opts);
140
178
  }
141
179
 
142
- module.exports = { resolveProjectId, readNiroProjectFile, resolveAliasOrUrl, looksLikeProjectId };
180
+ /**
181
+ * The temporary project this id names, or null when it is an ordinary shared project.
182
+ *
183
+ * <p>Used by the destructive commands. Deleting a temporary project through `niro delete` leaves
184
+ * the folder pinned to a project that is gone and skips the local cleanup that
185
+ * `niro discard-temp-project` does, so those commands send the developer there instead.
186
+ * Never throws: if the project cannot be fetched, the caller carries on as before.
187
+ */
188
+ async function tempProjectSourceId(projectId, opts = {}) {
189
+ try {
190
+ const project = await api.get(`/api/projects/${encodeURIComponent(projectId)}`, { apiUrl: opts.apiUrl });
191
+ return (project && (project.cloneOfProjectId || project.clone_of_project_id)) || null;
192
+ } catch (_) {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ module.exports = { resolveProjectId, readNiroProjectFile, resolveAliasOrUrl, looksLikeProjectId,
198
+ resolveFromSyncedFolder, tempProjectSourceId };
@@ -14,12 +14,27 @@ const api = require("../lib/api");
14
14
  const credentials = require("../lib/credentials");
15
15
  const prompt = require("../lib/prompt");
16
16
  const color = require("../lib/color");
17
- const { resolveProjectId, readNiroProjectFile } = require("./_resolveProject");
17
+ const { resolveProjectId, readNiroProjectFile, tempProjectSourceId } = require("./_resolveProject");
18
18
 
19
19
  async function deleteProject(projectIdArg, opts = {}) {
20
20
  credentials.requireCreds();
21
21
  const projectId = await resolveProjectId(projectIdArg, { apiUrl: opts.apiUrl });
22
22
 
23
+ // A temporary project is not deleted through here. It belongs to a folder and a branch, and
24
+ // `discard-temp-project` is what unwinds both: it removes the project AND points the folder back
25
+ // at the shared one, repairing the local pin. Deleting it server-side does neither, so the folder
26
+ // is left aimed at a project that no longer exists. Refusing costs one command; the alternative is
27
+ // a broken checkout the developer then has to work out how to repair.
28
+ const cloneOf = await tempProjectSourceId(projectId, opts);
29
+ if (cloneOf) {
30
+ console.log(`${color.yellow("⚠")} ${color.bold(projectId)} is a temporary project for your branch, not a shared one.`);
31
+ console.log(color.dim(` It is a private copy of ${cloneOf}.`));
32
+ console.log(` Use ${color.bold("niro discard-temp-project")} in this folder instead — that removes it AND`);
33
+ console.log(` points this folder back at the shared project. ${color.dim("Nothing has been deleted.")}`);
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+
23
38
  if (!opts.yes) {
24
39
  console.log(`${color.yellow("⚠")} About to delete project ${color.bold(projectId)}.`);
25
40
  console.log(color.dim(" This removes the project and its graph from the server. Registered repos stay."));
@@ -57,14 +57,27 @@ async function remove(opts = {}) {
57
57
  // --project: destructive. Fetch the project so we can match --confirm-name
58
58
  // against either the alias or the project_id.
59
59
  let alias = null;
60
+ let cloneOf = null;
60
61
  try {
61
62
  const project = await api.get(`/api/projects/${encodeURIComponent(projectId)}`, { apiUrl: opts.apiUrl });
62
63
  alias = (project && (project.alias)) || null;
64
+ cloneOf = (project && (project.cloneOfProjectId || project.clone_of_project_id)) || null;
63
65
  } catch (err) {
64
66
  // If we can't read the project, fall back to matching the id only.
65
67
  alias = null;
66
68
  }
67
69
 
70
+ // Same rule as `niro delete`: a temporary project is unwound by discard-temp-project, which also
71
+ // points this folder back at the shared project. Removing it here would leave the folder aimed at
72
+ // a project that is gone. Read from the fetch above rather than a second request.
73
+ if (cloneOf) {
74
+ console.log(`${color.yellow("⚠")} ${color.bold(projectId)} is a temporary project for your branch, not a shared one.`);
75
+ console.log(color.dim(` It is a private copy of ${cloneOf}.`));
76
+ console.log(` Use ${color.bold("niro discard-temp-project")} in this folder instead. ${color.dim("Nothing has been removed.")}`);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
68
81
  const confirm = opts.confirmName == null ? null : String(opts.confirmName).trim();
69
82
  const matches = confirm != null && (confirm === projectId || (alias != null && confirm === alias));
70
83
  if (!matches) {
@@ -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
+ };
@@ -82,4 +82,4 @@ function purge() {
82
82
  }
83
83
  }
84
84
 
85
- module.exports = { disabledFlagPath, isDisabled, setDisabled, purge };
85
+ module.exports = { niroHome, disabledFlagPath, isDisabled, setDisabled, purge };
package/src/mcp/server.js CHANGED
@@ -52,6 +52,12 @@ function niroProjectsFilePath() {
52
52
  return path.join(home, ".niro", "projects.json");
53
53
  }
54
54
  const { createPendingTracker, isTrackableRequest, resolveTimeoutMs } = require("../lib/pendingRequests");
55
+ const { createImpactRecorder } = require("../lib/impactLog");
56
+
57
+ // Evidence for the Edit|Write impact gate (docs/usecases/impact-gate.md): which impact tools this
58
+ // session called, and that the server is answering. Keyed by this process's working directory, which
59
+ // is the session folder the hook sees as `cwd` (checked on 8 live bridges, 2026-09-04).
60
+ const impact = createImpactRecorder({ cwd: process.cwd() });
55
61
 
56
62
  /**
57
63
  * This package's version, sent as X-Niro-Cli-Version so the server can tell an out-of-date install to
@@ -150,17 +156,26 @@ function resolveProjectIdFromNiroProjects() {
150
156
  }
151
157
 
152
158
  /**
153
- * Step 3: Detect the git remote URL and current branch for server-side project resolution.
154
- * Used when no project_id could be resolved locally. The server matches the remote URL
155
- * against stored project git mappings.
156
- * Returns { remote, branch } or null if not in a git repo or git is unavailable.
159
+ * Step 3: Detect the current git branch, HEAD and dirty state — plus the origin remote when there is
160
+ * one — for the freshness/takeover notices, server-side project resolution, and per-branch temp routing.
161
+ * The gate is HEAD (is this a git repo with a commit?), NOT origin: a local repo with commits but no
162
+ * origin remote (a `local://` niro project, or a repo not yet pushed) still has a branch that drives
163
+ * routing, so its git state must be reported with a null remote rather than dropped entirely.
164
+ * Returns { remote (nullable), branch, commit, dirty } or null if not a git repo / no commit / git unavailable.
157
165
  */
158
166
  function detectGitContext(dir) {
159
167
  try {
160
168
  const opts = { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] };
161
169
  if (dir) opts.cwd = dir;
162
- const remote = execSync("git remote get-url origin 2>/dev/null", opts).trim();
163
- if (!remote) return null;
170
+ // HEAD first: this is the "real git repo with a commit?" test. The old `if (!remote) return null`
171
+ // bailed on a MISSING ORIGIN and so discarded the branch + commit of every no-origin repo — those
172
+ // repos then sent no _niroGit and never routed to their temp project. The owned-temp match needs
173
+ // only the branch, not the remote, so keep going and let the remote be null.
174
+ let commit = null;
175
+ try { commit = execSync("git rev-parse HEAD 2>/dev/null", opts).trim(); } catch (_) {}
176
+ if (!commit) return null; // not a git repo, or a repo with no commit yet — nothing to report
177
+ let remote = null;
178
+ try { remote = execSync("git remote get-url origin 2>/dev/null", opts).trim() || null; } catch (_) {}
164
179
  // normalizeBranch, not a bare trim: on a detached HEAD this command PRINTS "HEAD" instead of
165
180
  // failing, and sending that verbatim told the server the checkout was on a branch called "HEAD".
166
181
  // Nothing indexes such a branch, so the routing ladder read every bisect / tag checkout as
@@ -168,10 +183,6 @@ function detectGitContext(dir) {
168
183
  // a null branch. `git.inspect` has always collapsed "HEAD" to null; this shares that one rule.
169
184
  let branch = null;
170
185
  try { branch = normalizeBranch(execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts)); } catch (_) {}
171
- // The freshness/takeover notices compare the local HEAD + dirty state against what Niro indexed, so
172
- // the bridge must report them — without git_commit the server-side check no-ops entirely.
173
- let commit = null;
174
- try { commit = execSync("git rev-parse HEAD 2>/dev/null", opts).trim(); } catch (_) {}
175
186
  let dirty = false;
176
187
  try { dirty = execSync("git status --porcelain 2>/dev/null", opts).trim().length > 0; } catch (_) {}
177
188
  return { remote, branch, commit, dirty };
@@ -535,6 +546,8 @@ function connectSse() {
535
546
  } else {
536
547
  // Cancel the client-side deadline when a delivered result matches an in-flight request.
537
548
  pending.onIncoming(msg);
549
+ impact.noteResult(msg);
550
+ impact.touchAlive();
538
551
  rewritten = captureReceipt(msg);
539
552
  }
540
553
  } catch (_) { /* not correlatable JSON — forward unchanged */ }
@@ -827,6 +840,7 @@ async function main() {
827
840
  // Remember the host's initialize so we can replay it verbatim to re-authenticate a fresh
828
841
  // session after a transparent reconnect.
829
842
  if (message.method === "initialize") initializeRaw = line;
843
+ impact.noteRequest(message);
830
844
 
831
845
  // Arm the deadline for a real request before sending; a delivered result (or the
832
846
  // send-failure path below) cancels it.
@@ -836,6 +850,9 @@ async function main() {
836
850
  await sendMessage(message);
837
851
  } catch (err) {
838
852
  process.stderr.write(`[niro-mcp] Failed to send message: ${err.message}\n`);
853
+ // The attempt still counts for the impact gate: a server that cannot be reached must not leave
854
+ // the file locked until the alive marker ages out (review finding, 2026-09-05).
855
+ impact.noteResult({ jsonrpc: "2.0", id: message.id, error: { code: -32001, message: err.message } });
839
856
  // The POST never reached the server, so no result will arrive: fail this request fast
840
857
  // instead of waiting out the deadline.
841
858
  if (trackable) {
@@ -852,6 +869,9 @@ async function main() {
852
869
  rl.on("close", () => {
853
870
  process.exit(0);
854
871
  });
872
+ // A bridge that is gone must not keep the impact gate denying edits for up to 30 minutes on the
873
+ // strength of a stale alive marker. A hard kill still leaves it; the hook ages it out.
874
+ process.on("exit", () => impact.clearAlive());
855
875
  }
856
876
 
857
877
  main();
package/src/mcp/setup.js CHANGED
@@ -50,7 +50,7 @@ const NIRO_MD_MARKER = "<!-- niro-mcp -->";
50
50
  * read once per session, which is why it now states its own non-expiry explicitly. Wording is imperative
51
51
  * and names concrete tools so the agent knows what to call instead of native search.
52
52
  */
53
- const NIRO_EXPLORE_FIRST_SENTENCE = "MUST FOLLOW: before you run grep, find, glob, or open a file to hunt for something, first check whether a Niro tool answers it (code_grep for content search, resolve_fqn and get_source_code for a known symbol, find_handler and find_api_endpoints for entry points). Use the connection tools (get_call_chain, get_reverse_call_chain, get_blast_radius, get_impact_analysis, get_service_topology) to understand how code connects BEFORE deciding which file to read or search next, rather than reading files to rebuild that picture by hand. Native search is the fallback for what Niro does not cover (config files, docs, logs, generated output, non-code text) or when the tools are unavailable. This rule does not expire: it holds on the first turn and on the fiftieth, mid-task as much as at the start of a session, and a long conversation is not a reason to drift back to native search.";
53
+ const NIRO_EXPLORE_FIRST_SENTENCE = "MUST FOLLOW: before you run grep, find, glob, or open a file to hunt for something, first check whether a Niro tool answers it (code_grep for content search, resolve_fqn and get_source_code for a known symbol, find_handler and find_api_endpoints for entry points). Use the connection tools (get_call_chain, get_reverse_call_chain, get_blast_radius, get_impact_analysis, get_service_topology, get_exception_flow for which exceptions can escape a method) to understand how code connects BEFORE deciding which file to read or search next, rather than reading files to rebuild that picture by hand. Native search is the fallback for what Niro does not cover (config files, docs, logs, generated output, non-code text) or when the tools are unavailable. This rule does not expire: it holds on the first turn and on the fiftieth, mid-task as much as at the start of a session, and a long conversation is not a reason to drift back to native search.";
54
54
 
55
55
  const NIRO_MULTI_REPO_SENTENCE = "The temporary project answer is per (repo, BRANCH): after you create/switch a branch in a repo — and before your first edit on that branch — call should_create_temp_project with the repo's git url, its CURRENT branch, and its `.niro-project` id if present, then run `niro new-temp-project` in its folder if needs_temp_project is true. Re-check after every branch change (a `false` from another branch does NOT carry over); this applies to every repo you touch, including when the session started in a parent folder that is not itself a git repo.";
56
56
  /**
@@ -63,7 +63,7 @@ const NIRO_MULTI_REPO_SENTENCE = "The temporary project answer is per (repo, BRA
63
63
  * is agreed the wrong basis is already baked in; and it says the size of the diff does not excuse it,
64
64
  * because a one-line change to shared code reaches further than a rewrite of a standalone tool.
65
65
  */
66
- const NIRO_IMPACT_BOTH_WAYS_SENTENCE = "MUST FOLLOW: before you plan, propose, or start any change to existing code, establish BOTH sides of it. Upstream: what depends on the code you are about to touch, including callers in other services over HTTP or events, and the tests and config that name it. Downstream: what that code itself calls, and the data and events it relies on. Use get_blast_radius, which returns callers, callees, cross-service callers and impacted files in one call, or get_impact_analysis for the lighter upstream-only view. Put the result in the plan you show the user: name the callers you found, say which ones you will update, and say which ones you are deliberately leaving alone. This applies to a one-line change too, because how far a change reaches is decided by what depends on it, not by the size of the diff.";
66
+ const NIRO_IMPACT_BOTH_WAYS_SENTENCE = "MUST FOLLOW: before you plan, propose, or start any change to existing code, establish BOTH sides of it. Upstream: what depends on the code you are about to touch, including callers in other services over HTTP or events, and the tests and config that name it. Downstream: what that code itself calls, and the data and events it relies on. Use get_blast_radius, which returns callers, callees, cross-service callers and impacted files in one call, or get_impact_analysis for the lighter upstream-only view. Put the result in the plan you show the user: name the callers you found, say which ones you will update, and say which ones you are deliberately leaving alone. This applies to a one-line change too, because how far a change reaches is decided by what depends on it, not by the size of the diff. Keep that list: after the edit, verify those flows still hold (run their tests, trace them, read the callers) and say what you verified. The graph updates only after your edit is synced and rebuilt, so do not re-query it to verify; use the list you captured before. A change that cannot alter behaviour at all (a log line, a comment, formatting) needs no map: say so in one sentence.";
67
67
  const NIRO_CLAUDE_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP tools are available for code intelligence. Use them (call chains, blast radius, impact analysis, source lookup) before falling back to native grep/read for code questions.\nBefore adding/writing any new code, you MUST use the find_reusable_code tool from niro to figure out if there is anything reusable.\n${NIRO_EXPLORE_FIRST_SENTENCE}\n${NIRO_IMPACT_BOTH_WAYS_SENTENCE}\n${NIRO_MULTI_REPO_SENTENCE}\n`;
68
68
  const NIRO_CODEX_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools and the \`niro-skill\` are available (call chains, blast radius, impact analysis, source lookup). Prefer them over grep/read for questions about this codebase; the niro-skill has the full tool-routing playbook. Before adding or writing any new code, use find_reusable_code to check for reusable code. If the niro tools are not present or the repo is not indexed, use native tools.\n${NIRO_EXPLORE_FIRST_SENTENCE}\n${NIRO_IMPACT_BOTH_WAYS_SENTENCE}\n${NIRO_MULTI_REPO_SENTENCE}\n`;
69
69
  const NIRO_WINDSURF_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools and the niro-skill are available. Prefer them over grep/read for questions about this codebase (structure, call chains, blast radius, impact, data flows, reuse); the niro-skill has the full routing playbook. Before writing any new code, use find_reusable_code. If the niro tools are not present or the repo is not indexed, use native tools.\n${NIRO_EXPLORE_FIRST_SENTENCE}\n${NIRO_IMPACT_BOTH_WAYS_SENTENCE}\n${NIRO_MULTI_REPO_SENTENCE}\n`;
@@ -161,6 +161,19 @@ function isNiroExploreHook(h) {
161
161
  return cmds.some((c) => c.includes("niro-explore-"));
162
162
  }
163
163
 
164
+ /**
165
+ * True if `h` is niro's PreToolUse impact-gate entry (matcher Edit|Write). Keyed on the unique
166
+ * `niro-impact-` identity token so a foreign PreToolUse hook on the same matcher, and niro's own
167
+ * temp-project hook on the same matcher, are never matched or removed by mistake. Shared by installer
168
+ * (upgrade-in-place) and uninstaller (removal).
169
+ */
170
+ function isNiroImpactHook(h) {
171
+ const cmds = [];
172
+ if (h && Array.isArray(h.hooks)) h.hooks.forEach((i) => i && typeof i.command === "string" && cmds.push(i.command));
173
+ if (h && typeof h.command === "string") cmds.push(h.command);
174
+ return cmds.some((c) => c.includes("niro-impact-"));
175
+ }
176
+
164
177
  /**
165
178
  * Writes a niro-managed skill/rule file, refreshing it when the generated content has
166
179
  * changed. These files are GENERATED (not hand-edited), so refresh-on-change is correct:
@@ -589,8 +602,49 @@ function printClients({ all = false } = {}) {
589
602
  console.log("");
590
603
  }
591
604
 
592
- async function setupClaudeCode(apiKey, mcpServerUrl) {
605
+ /** The URL an existing Claude Code registration points at, read off `claude mcp get niro`; null if none. */
606
+ function registeredServerUrl(getOutput) {
607
+ const m = /NIRO_MCP_SERVER_URL=(\S+)/.exec(String(getOutput || ""));
608
+ return m ? m[1] : null;
609
+ }
610
+
611
+ /**
612
+ * What to do about the Claude Code registration before the hooks are written. Pure, so it is tested
613
+ * with the real `claude mcp get` output as a fixture.
614
+ * "add" nothing is registered (`claude mcp get niro` failed)
615
+ * "keep" registered and already pointing at the wanted URL
616
+ * "replace" registered, but at another URL or in a shape this cannot read: remove, then add
617
+ */
618
+ function planClaudeRegistration({ existingOutput, wantedUrl }) {
619
+ if (existingOutput == null) return "add";
620
+ const url = registeredServerUrl(existingOutput);
621
+ return url && url === wantedUrl ? "keep" : "replace";
622
+ }
623
+
624
+ /**
625
+ * Register the niro server with Claude Code, or keep the registration already there, then write the
626
+ * hooks, the memory section and the skills.
627
+ *
628
+ * `claude mcp add` refuses when a server of that name exists ("MCP server niro already exists in
629
+ * user config", exit 1). This function treated every failure of that command as "Claude Code is not
630
+ * installed" and returned before writing a single hook, so on any machine where niro had been set up
631
+ * once, `niro mcp install` refreshed nothing: not the hooks, not the CLAUDE.md section. That is the
632
+ * exact step the out-of-date notice tells users to run. Found 2026-09-05 when the new Edit|Write
633
+ * hook could not be installed on the machine it was written on.
634
+ *
635
+ * `deps` is for tests only: `get` returns the `claude mcp get niro` output or null, `run` runs a
636
+ * client command; both default to the real thing.
637
+ */
638
+ async function setupClaudeCode(apiKey, mcpServerUrl, deps = {}) {
593
639
  console.log("\n Configuring Claude Code...\n");
640
+ const run = deps.run || runClientCommand;
641
+ const get = deps.get || (() => {
642
+ try {
643
+ return execSync("claude mcp get niro", { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
644
+ } catch (_) {
645
+ return null; // not registered, or no `claude` on the PATH: `add` below tells the two apart
646
+ }
647
+ });
594
648
 
595
649
  const cmd = [
596
650
  "claude", "mcp", "add",
@@ -604,20 +658,34 @@ async function setupClaudeCode(apiKey, mcpServerUrl) {
604
658
  cmd.push("-e", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
605
659
  cmd.push("--", "niro", "mcp", "serve");
606
660
 
607
- try {
608
- runClientCommand(cmd.join(" "));
609
- console.log(" MCP server registered globally in Claude Code.\n");
610
- } catch (err) {
611
- console.error(" Failed to register MCP server. Is Claude Code installed?");
612
- console.error(" Install: https://claude.ai/code\n");
613
- console.error(` Manual command:\n ${cmd.join(" ")}\n`);
614
- return false;
661
+ const plan = planClaudeRegistration({ existingOutput: get(), wantedUrl: mcpServerUrl });
662
+ if (plan === "keep") {
663
+ console.log(` MCP server already registered in Claude Code at ${mcpServerUrl}. Keeping it.\n`);
664
+ } else {
665
+ if (plan === "replace") {
666
+ try {
667
+ run("claude mcp remove --scope user niro");
668
+ } catch (_) {
669
+ /* the add below reports what is wrong */
670
+ }
671
+ }
672
+ try {
673
+ run(cmd.join(" "));
674
+ console.log(" MCP server registered globally in Claude Code.\n");
675
+ } catch (err) {
676
+ const detail = String((err && (err.stderr || err.message)) || "").trim().split("\n")[0];
677
+ console.error(` Failed to register MCP server${detail ? `: ${detail}` : ""}.`);
678
+ console.error(" If Claude Code is not installed: https://claude.ai/code\n");
679
+ console.error(` Manual command:\n ${cmd.join(" ")}\n`);
680
+ return false;
681
+ }
615
682
  }
616
683
 
617
684
  addClaudeCodePostCompactHook();
618
685
  addClaudeCodeUserPromptHook();
619
686
  addClaudeCodePreToolUseHook();
620
687
  addClaudeCodeExploreHook();
688
+ addClaudeCodeImpactHook();
621
689
  removeStaleNiroStopHook();
622
690
  addClaudeCodeMemory();
623
691
  addClaudeCodeSkill();
@@ -670,7 +738,9 @@ function isNiroGeneratedPostCompactHook(h) {
670
738
  const NIRO_POST_COMPACT_COMMAND =
671
739
  "echo 'Niro MCP tools are available for code intelligence (call chains, blast radius, impact analysis, source lookup). "
672
740
  + "Use niro tools before native grep/read for code questions. Before you plan or propose a change to existing code, call "
673
- + "get_blast_radius (or get_impact_analysis) to see who calls it and what it calls, and put that impact in the plan.'";
741
+ + "get_blast_radius (or get_impact_analysis) to see who calls it and what it calls, and put that impact in the plan. "
742
+ + "Keep that list and verify those flows after the edit; the graph lags your edit, so do not re-query it to verify. "
743
+ + "A log line or comment needs no map.'";
674
744
 
675
745
  function addClaudeCodePostCompactHook() {
676
746
  // Guard the invariant that keeps upgrade-in-place working: if the reminder is ever reworded so it
@@ -781,7 +851,7 @@ function addClaudeCodeMemory() {
781
851
  */
782
852
  const NIRO_USER_PROMPT_COMMAND =
783
853
  "IN=\"$(cat)\"; d=\"${CLAUDE_PROJECT_DIR:-$PWD}\"; while [ -n \"$d\" ] && [ \"$d\" != \"/\" ]; do " +
784
- "if [ -f \"$d/.niro-project\" ]; then echo 'NIRO ACTIVE. Standing rule for this whole session, at the start and mid-task alike: before Grep/Glob/Read or any native file hunt, use niro (code_grep, get_source_code, resolve_fqn, get_call_chain, get_blast_radius, get_impact_analysis). Before writing new code, call find_reusable_code. Before you plan or propose a change to existing code, call get_blast_radius (or get_impact_analysis) to see who calls it and what it calls, and put that impact in the plan. Native tools only for config, docs, logs, generated or non-code text. If the niro tools are not in your toolset at all, use native tools and do not mention Niro.'; " +
854
+ "if [ -f \"$d/.niro-project\" ]; then echo 'NIRO ACTIVE. Standing rule for this whole session, at the start and mid-task alike: before Grep/Glob/Read or any native file hunt, use niro (code_grep, get_source_code, resolve_fqn, get_call_chain, get_blast_radius, get_impact_analysis). Before writing new code, call find_reusable_code. Before you plan or propose a change to existing code, call get_blast_radius (or get_impact_analysis) to see who calls it and what it calls, and put that impact in the plan. Keep that list and verify those flows after the edit; the graph lags your edit, so do not re-query it to verify. A log line or comment needs no map. Native tools only for config, docs, logs, generated or non-code text. If the niro tools are not in your toolset at all, use native tools and do not mention Niro.'; " +
785
855
  "printf '%s' \"$IN\" | grep -qiE 'new branch|create[^.]{0,24}branch|switch[^.]{0,24}branch|checkout -b|change[^.]{0,24}branch|different branch|another branch' && " +
786
856
  "echo 'You appear to be about to create or switch a branch. The temporary project check is per (repo, branch), so do it AFTER you are on the new branch (not before) and pass that CURRENT branch: call should_create_temp_project(git_url, branch, project_id from its .niro-project) and run niro new-temp-project if needs_temp_project is true. A check you ran on the old branch does not count.'; break; fi; " +
787
857
  "d=\"$(dirname \"$d\")\"; done";
@@ -1102,6 +1172,165 @@ function addClaudeCodeExploreHook() {
1102
1172
  }
1103
1173
  }
1104
1174
 
1175
+ /**
1176
+ * The three answers the impact hook can give. Built with JSON.stringify so the text is valid JSON by
1177
+ * construction. All three are wrapped in single quotes inside the shell command, so no message may
1178
+ * contain an apostrophe or a backtick; the test suite executes the command, which would break loudly
1179
+ * if one crept in. None carries a permissionDecision: the hook advises, it never approves or refuses.
1180
+ *
1181
+ * Why advisory and not a refusal, in Niraj's words (2026-09-05): "The reason I said before editing
1182
+ * any method, the impact of editing it should be measured is because I want the coding agent to verify
1183
+ * that after the change as well, the flows connected with the current method are not breaking. [...]
1184
+ * graph updating takes time after an edit is performed. The change does not take effect instantly.
1185
+ * [...] it is not to enforce use of niro [...] now if someone is just adding a log message, that's not
1186
+ * gonna affect anything so if the verification should happen or not is also subjective."
1187
+ */
1188
+ const NIRO_IMPACT_UNMAPPED_JSON = JSON.stringify({
1189
+ hookSpecificOutput: {
1190
+ hookEventName: "PreToolUse",
1191
+ additionalContext:
1192
+ "Niro impact check, before you edit this file: decide whether this change can alter behaviour. " +
1193
+ "A log line, a comment, formatting or a message string cannot, so say so in one sentence and go " +
1194
+ "ahead. Anything else can, so map who depends on the method NOW with get_blast_radius or " +
1195
+ "get_impact_analysis (a partial name such as ClassName.method resolves when it is unique; " +
1196
+ "resolve_fqn on one that does not) and keep the list of callers and flows: it is your checklist " +
1197
+ "for AFTER the edit. The graph updates only after your edit is synced and rebuilt, so do not " +
1198
+ "re-query it to verify; verify the listed flows by running their tests, tracing them, or reading " +
1199
+ "the callers, and say what you verified. Shown once per file per session.",
1200
+ },
1201
+ });
1202
+ const NIRO_IMPACT_MAPPED_JSON = JSON.stringify({
1203
+ hookSpecificOutput: {
1204
+ hookEventName: "PreToolUse",
1205
+ additionalContext:
1206
+ "Niro impact check: an impact map for this file was recorded in the last hour. After this edit, " +
1207
+ "go back to the callers and flows it listed and verify they still hold: run their tests, trace " +
1208
+ "them, or read the callers, and say what you verified. The graph updates only after your edit is " +
1209
+ "synced and rebuilt, so a fresh query would still show the old code; use the list you already " +
1210
+ "have. Shown once per file per session.",
1211
+ },
1212
+ });
1213
+ const NIRO_IMPACT_NO_BRIDGE_JSON = JSON.stringify({
1214
+ hookSpecificOutput: {
1215
+ hookEventName: "PreToolUse",
1216
+ additionalContext:
1217
+ "Niro impact check: you are editing indexed source with no impact map recorded for this file, " +
1218
+ "and the Niro bridge has not answered in the last 30 minutes. If this change can alter behaviour " +
1219
+ "(anything beyond a log line, a comment, formatting or a message string), map who depends on it " +
1220
+ "as soon as Niro answers and verify those flows after the edit; the graph will lag your edit, so " +
1221
+ "use the list you capture, not a fresh query. Shown once per file per session.",
1222
+ },
1223
+ });
1224
+
1225
+ /**
1226
+ * The niro PreToolUse hook command (matcher Edit|Write): before an edit to indexed source, make the
1227
+ * agent decide out loud whether the change can alter behaviour and, if so, map who depends on the
1228
+ * method while the graph is still current, then carry that list across the edit as the checklist for
1229
+ * afterwards. The prose rule (NIRO_IMPACT_BOTH_WAYS_SENTENCE) is on every instruction surface and
1230
+ * still gets read past; a reminder at the moment of the edit is what survives a long session.
1231
+ * Full design, the reasoning in Niraj's words, and the scenario table: docs/usecases/impact-gate.md.
1232
+ *
1233
+ * Advisory, never a refusal. The first cut denied the edit until an impact call named the file. That
1234
+ * enforced a tool call, which was never the goal; it could not tell a log line from a signature change;
1235
+ * and review found two ways it failed closed. Now every answer is additionalContext, and it never emits
1236
+ * permissionDecision at all ("allow" would auto-approve every edit and bypass the permission prompt).
1237
+ *
1238
+ * The evidence comes from the bridge: src/lib/impactLog.js writes one JSON line per answered
1239
+ * get_blast_radius / get_impact_analysis / get_reverse_call_chain call to ~/.niro/impact/<key>.jsonl,
1240
+ * and touches <key>.alive whenever the server answers. <key> is the session folder with every
1241
+ * non-alphanumeric byte replaced by "-": here `cd -P` + `tr -c`, there fs.realpathSync + a byte loop,
1242
+ * and test/impactGateHook.test.js proves the two agree.
1243
+ *
1244
+ * Decision, top to bottom, first match wins: no file_path, the off switch, a file that does not exist
1245
+ * yet, a non-source extension, a test path, or no `.niro-project` above the file -> silent. A log line
1246
+ * from the last hour that names the file (a files entry ending "/Foo.java", or a name containing
1247
+ * ".Foo.", "\"Foo.", ".Foo\"", "\"Foo\"," or "\"Foo\"]": always a VALUE, so a file called ts.js or
1248
+ * tool.js is not matched by the record's own "ts"/"tool" keys) -> the MAPPED reminder: verify the
1249
+ * listed flows after the edit, do not re-query the lagging graph. Otherwise, alive file touched in the
1250
+ * last 30 minutes -> the UNMAPPED reminder: decide, and map now if the change can alter behaviour; no
1251
+ * live bridge -> the short NO_BRIDGE form. Each reminder is shown once per (session, file), keyed like
1252
+ * the temp-project hook's marker, so ten edits to one file do not produce ten copies.
1253
+ *
1254
+ * Dependency-free like its siblings (grep -oE, sed -E, tr, find -mmin, cksum: GNU + BSD).
1255
+ * `niro-impact-` is the identity token isNiroImpactHook and teardown match on.
1256
+ */
1257
+ const NIRO_IMPACT_COMMAND = [
1258
+ 'IN="$(cat)"',
1259
+ `f="$(printf '%s' "$IN" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
1260
+ '[ -z "$f" ] && exit 0',
1261
+ `c="$(printf '%s' "$IN" | grep -oE '"cwd"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
1262
+ '[ -z "$c" ] && c="${CLAUDE_PROJECT_DIR:-$PWD}"',
1263
+ 'case "$f" in /*) ;; *) f="$c/$f" ;; esac',
1264
+ 'H="${HOME:-/tmp}"',
1265
+ '[ -f "$H/.niro/impact-gate-off" ] && exit 0',
1266
+ // A file that does not exist yet has no callers; a Write creating it is never gated.
1267
+ '[ -f "$f" ] || exit 0',
1268
+ 'case "$f" in *.java|*.js|*.mjs|*.cjs|*.ts|*.tsx|*.jsx|*.py|*.go|*.rb|*.kt|*.kts|*.cs|*.php|*.rs|*.scala|*.swift|*.c|*.cc|*.cpp|*.h|*.hpp) ;; *) exit 0 ;; esac',
1269
+ 'case "$f" in */test/*|*/tests/*|*/__tests__/*|*/spec/*|*.test.*|*.spec.*|*Test.*|*Tests.*|*IT.java|*_test.*|*/test_*|*_spec.*|*/conftest.py) exit 0 ;; esac',
1270
+ // Symlink-resolved walk-up for the .niro-project marker, same as the explore hook.
1271
+ `d0="$(dirname "$f")"; d0="$(cd -P "$d0" 2>/dev/null && pwd -P || printf '%s' "$d0")"`,
1272
+ 'd="$d0"; ok=0; while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "." ]; do if [ -f "$d/.niro-project" ]; then ok=1; break; fi; p="$(dirname "$d")"; [ "$p" = "$d" ] && break; d="$p"; done',
1273
+ '[ "$ok" = 0 ] && exit 0',
1274
+ // The log key, byte for byte what impactLog.keyForCwd computes from the bridge's process.cwd().
1275
+ `cr="$(cd -P "$c" 2>/dev/null && pwd -P || printf '%s' "$c")"`,
1276
+ `k="$(printf '%s' "$cr" | tr -c 'A-Za-z0-9' '-')"`,
1277
+ 'L="$H/.niro/impact/$k.jsonl"; A="$H/.niro/impact/$k.alive"',
1278
+ 'b="$(basename "$f")"; n="${b%.*}"',
1279
+ // The session + file key for the once-per-file markers, hashed the way the temp-project hook does.
1280
+ `s="$(printf '%s' "$IN" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
1281
+ `j="$(printf '%s\\n%s' "$s" "$f" | cksum | cut -d' ' -f1)"`,
1282
+ 'NIRO_HOOK_ID=niro-impact-gate',
1283
+ // Mapped: the log was written in the last hour (the bridge prunes on write, so an older file holds
1284
+ // only stale lines) and a line names this file or its class -> remind once to verify AFTER the edit.
1285
+ `if [ -f "$L" ] && [ -n "$(find "$L" -mmin -60 2>/dev/null)" ] && grep -qF -e "/$b\\"" -e ".$n." -e "\\"$n." -e ".$n\\"" -e "\\"$n\\"," -e "\\"$n\\"]" "$L" 2>/dev/null; then m="\${TMPDIR:-/tmp}/niro-impact-after-$j"; [ -f "$m" ] && exit 0; touch "$m"; printf '%s' '${NIRO_IMPACT_MAPPED_JSON}'; exit 0; fi`,
1286
+ // Unmapped: ask once per (session, file) for the decision; with no live bridge, the short form.
1287
+ 'm="${TMPDIR:-/tmp}/niro-impact-before-$j"; [ -f "$m" ] && exit 0; touch "$m"',
1288
+ `if [ -f "$A" ] && [ -n "$(find "$A" -mmin -30 2>/dev/null)" ]; then printf '%s' '${NIRO_IMPACT_UNMAPPED_JSON}'; else printf '%s' '${NIRO_IMPACT_NO_BRIDGE_JSON}'; fi`,
1289
+ ].join('; ');
1290
+
1291
+ /**
1292
+ * Adds the PreToolUse (Edit|Write) impact-gate hook to ~/.claude/settings.json. Idempotent: upgrades a
1293
+ * stale niro entry in place, never touches a foreign PreToolUse hook on the same matcher nor niro's own
1294
+ * temp-project hook (matched by the niro-impact- token via isNiroImpactHook).
1295
+ */
1296
+ function addClaudeCodeImpactHook() {
1297
+ const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
1298
+ let settings = {};
1299
+ try {
1300
+ if (fs.existsSync(settingsPath)) settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
1301
+ } catch {
1302
+ // Corrupt or missing — start fresh
1303
+ }
1304
+
1305
+ if (!settings.hooks) settings.hooks = {};
1306
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
1307
+
1308
+ const entry = { matcher: "Edit|Write", hooks: [{ type: "command", command: NIRO_IMPACT_COMMAND }] };
1309
+ const alreadyCurrent = settings.hooks.PreToolUse.some(
1310
+ (h) => Array.isArray(h.hooks) && h.hooks.some((i) => i && i.command === NIRO_IMPACT_COMMAND),
1311
+ );
1312
+ if (alreadyCurrent) {
1313
+ console.log(" PreToolUse impact-gate hook already up to date. Skipping.\n");
1314
+ return;
1315
+ }
1316
+
1317
+ const hadStale = settings.hooks.PreToolUse.some(isNiroImpactHook);
1318
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((h) => !isNiroImpactHook(h));
1319
+ settings.hooks.PreToolUse.push(entry);
1320
+
1321
+ try {
1322
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
1323
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1324
+ console.log(` PreToolUse impact-gate hook ${hadStale ? "upgraded" : "added"} in ~/.claude/settings.json`);
1325
+ console.log(" Before an edit to indexed source, Claude is asked to map who depends on the method and to");
1326
+ console.log(" verify those flows after the edit. Advisory only. Off switch: touch ~/.niro/impact-gate-off\n");
1327
+ } catch (err) {
1328
+ console.warn(` Could not write ~/.claude/settings.json: ${err.message}`);
1329
+ console.warn(" Add manually to the PreToolUse array in ~/.claude/settings.json:\n");
1330
+ console.warn(JSON.stringify(entry, null, 2) + "\n");
1331
+ }
1332
+ }
1333
+
1105
1334
  /** Body of the `niro` skill (long-form tool-routing playbook). */
1106
1335
  const NIRO_SKILL = `---
1107
1336
  name: niro-skill
@@ -1143,6 +1372,12 @@ plan, propose, or start any change to existing code, establish both sides:
1143
1372
  impacted files). \`get_impact_analysis\` is the lighter upstream-only view. Pass
1144
1373
  \`include_source_code=true\` when you need to read what you found.
1145
1374
 
1375
+ Keep the list. After the edit, go back to the callers and flows it named and verify they still
1376
+ hold: run their tests, trace them, read the callers, and say what you verified. The graph updates
1377
+ only after your edit is synced and rebuilt, so do not re-query it to verify; use the list you
1378
+ captured before. A change that cannot alter behaviour at all (a log line, a comment, formatting)
1379
+ needs no map: say so in one sentence.
1380
+
1146
1381
  Put the answer in the plan you show the user, not just in your head: name the callers you found,
1147
1382
  say which ones you will update, and say which ones you are deliberately leaving alone. A one-line
1148
1383
  change is not exempt, because reach is decided by what depends on the code, not by the size of the
@@ -1301,7 +1536,8 @@ Also available (catalog / utility, use when the above don't fit): \`list_project
1301
1536
  - **Before adding or writing ANY new code, call \`find_reusable_code\` first.** Mandatory.
1302
1537
  - **Before you plan or propose a change to existing code, call \`get_blast_radius\` (or
1303
1538
  \`get_impact_analysis\`) and put the upstream and downstream impact in the plan.** Mandatory,
1304
- whatever the size of the change.
1539
+ whatever the size of the change. Keep the list and verify those flows after the edit; the graph
1540
+ lags your edit, so do not re-query it to verify. A log line or comment needs no map.
1305
1541
  - For comprehension / debugging, set \`purpose\` to COMPREHEND or DEBUG and, on
1306
1542
  \`get_blast_radius\` / \`get_impact_analysis\`, pass \`include_source_code=true\`.
1307
1543
  - If a result contains **⚠️ ACTION REQUIRED**, the graph is stale — read the listed files
@@ -1559,7 +1795,11 @@ any change to existing code, establish who depends on it (upstream callers, incl
1559
1795
  services over HTTP or events) and what it depends on (downstream callees, data, events). Use
1560
1796
  get_blast_radius, which returns both sides plus impacted files in one call, or
1561
1797
  get_impact_analysis for the upstream-only view. Name the callers you found in the plan and say
1562
- which ones you will update. A one-line change is not exempt.
1798
+ which ones you will update. A one-line change is not exempt. Keep that list: after the edit, verify
1799
+ those flows still hold (run their tests, trace them, read the callers) and say what you verified.
1800
+ The graph updates only after your edit is synced and rebuilt, so do not re-query it to verify. A
1801
+ change that cannot alter behaviour at all (a log line, a comment, formatting) needs no map: say so
1802
+ in one sentence.
1563
1803
 
1564
1804
  Availability self-check FIRST — if the niro tools are not present in this session, or the
1565
1805
  repo is not indexed (no project resolves / a tool reports "not indexed"), fall back to
@@ -2029,7 +2269,11 @@ any change to existing code, establish who depends on it (upstream callers, incl
2029
2269
  other services over HTTP or events) and what it depends on (downstream callees, data, events). Use
2030
2270
  \`get_blast_radius\`, which returns both sides plus impacted files in one call, or
2031
2271
  \`get_impact_analysis\` for the upstream-only view. Name the callers you found in the plan and say
2032
- which ones you will update. A one-line change is not exempt.
2272
+ which ones you will update. A one-line change is not exempt. Keep that list: after the edit, verify
2273
+ those flows still hold (run their tests, trace them, read the callers) and say what you verified.
2274
+ The graph updates only after your edit is synced and rebuilt, so do not re-query it to verify. A
2275
+ change that cannot alter behaviour at all (a log line, a comment, formatting) needs no map: say so
2276
+ in one sentence.
2033
2277
 
2034
2278
  If the niro tools are not present in this session, or the repo is not indexed (no project
2035
2279
  resolves), fall back to native tools silently — do not mention Niro's absence.
@@ -2081,7 +2325,11 @@ any change to existing code, establish who depends on it (upstream callers, incl
2081
2325
  other services over HTTP or events) and what it depends on (downstream callees, data, events). Use
2082
2326
  \`get_blast_radius\`, which returns both sides plus impacted files in one call, or
2083
2327
  \`get_impact_analysis\` for the upstream-only view. Name the callers you found in the plan and say
2084
- which ones you will update. A one-line change is not exempt.
2328
+ which ones you will update. A one-line change is not exempt. Keep that list: after the edit, verify
2329
+ those flows still hold (run their tests, trace them, read the callers) and say what you verified.
2330
+ The graph updates only after your edit is synced and rebuilt, so do not re-query it to verify. A
2331
+ change that cannot alter behaviour at all (a log line, a comment, formatting) needs no map: say so
2332
+ in one sentence.
2085
2333
 
2086
2334
  If the niro tools are not present in this session, or the repo is not indexed (no project
2087
2335
  resolves), fall back to native tools silently — do not mention Niro's absence.
@@ -2541,6 +2789,11 @@ module.exports = {
2541
2789
  isNiroUserPromptHook,
2542
2790
  isNiroPreToolUseHook,
2543
2791
  isNiroExploreHook,
2792
+ isNiroImpactHook,
2793
+ NIRO_IMPACT_COMMAND,
2794
+ setupClaudeCode,
2795
+ planClaudeRegistration,
2796
+ registeredServerUrl,
2544
2797
  isNiroStopHook,
2545
2798
  clearCodexPerToolApprovals,
2546
2799
  hardenCodexServerConfig,
@@ -42,6 +42,7 @@ const {
42
42
  isNiroUserPromptHook,
43
43
  isNiroPreToolUseHook,
44
44
  isNiroExploreHook,
45
+ isNiroImpactHook,
45
46
  userHome,
46
47
  } = require("./setup");
47
48
 
@@ -126,7 +127,7 @@ function removeMarkerSectionBounded(content, marker) {
126
127
  }
127
128
 
128
129
  /**
129
- * Strip niro's PostCompact, UserPromptSubmit, and PreToolUse (temp-project + explore-first) hook
130
+ * Strip niro's PostCompact, UserPromptSubmit, and PreToolUse (temp-project + explore-first + impact-gate) hook
130
131
  * entries from a parsed Claude Code settings object (mutates and returns it). Uses the same matchers
131
132
  * install uses. Empty arrays / an empty `hooks` object are pruned so the file stays tidy. Other hooks
132
133
  * are untouched. Returns { settings, removed } where `removed` is the count deleted.
@@ -152,7 +153,9 @@ function filterNiroHooks(settings) {
152
153
  }
153
154
  if (Array.isArray(hooks.PreToolUse)) {
154
155
  const before = hooks.PreToolUse.length;
155
- hooks.PreToolUse = hooks.PreToolUse.filter((h) => !isNiroPreToolUseHook(h) && !isNiroExploreHook(h));
156
+ hooks.PreToolUse = hooks.PreToolUse.filter(
157
+ (h) => !isNiroPreToolUseHook(h) && !isNiroExploreHook(h) && !isNiroImpactHook(h),
158
+ );
156
159
  removed += before - hooks.PreToolUse.length;
157
160
  if (hooks.PreToolUse.length === 0) delete hooks.PreToolUse;
158
161
  }
@@ -388,6 +391,8 @@ function uninstallClaudeCode() {
388
391
  // a task the developer has long forgotten.
389
392
  try {
390
393
  require("../lib/receiptStore").purge();
394
+ // The impact gate's evidence files and off switch go with the hook that read them.
395
+ require("../lib/impactLog").purge();
391
396
  } catch {
392
397
  /* best effort — never block an uninstall on cleaning up a cache */
393
398
  }