@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 +47 -0
- package/README.md +10 -3
- package/docs/working-on-branches.md +8 -0
- package/package.json +1 -1
- package/src/commands/_resolveProject.js +59 -3
- package/src/commands/delete.js +16 -1
- package/src/commands/remove.js +13 -0
- package/src/lib/folderSync.js +1 -0
- package/src/lib/impactLog.js +251 -0
- package/src/lib/receiptStore.js +1 -1
- package/src/mcp/server.js +30 -10
- package/src/mcp/setup.js +270 -17
- package/src/mcp/teardown.js +7 -2
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
|
@@ -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.
|
|
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:
|
|
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
|
-
|
|
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 };
|
package/src/commands/delete.js
CHANGED
|
@@ -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."));
|
package/src/commands/remove.js
CHANGED
|
@@ -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) {
|
package/src/lib/folderSync.js
CHANGED
|
@@ -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
|
+
};
|
package/src/lib/receiptStore.js
CHANGED
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
console.log(
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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,
|
package/src/mcp/teardown.js
CHANGED
|
@@ -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(
|
|
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
|
}
|