@niroai/niro 0.3.5 → 0.4.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 ADDED
@@ -0,0 +1,65 @@
1
+ # Changelog
2
+
3
+ Notable changes to `@niroai/niro`. Dates are release dates.
4
+
5
+ ## Unreleased
6
+
7
+ ### Upgrading from 0.3.x — one manual step
8
+
9
+ ```bash
10
+ npm install -g @niroai/niro@latest
11
+ niro mcp install # once per assistant you use
12
+ ```
13
+
14
+ **The second command is required.** niro's hooks and instruction files are written to your disk at
15
+ install time, so upgrading the package alone leaves the old versions in place — including the
16
+ once-per-session reminder this release fixes. Restart your assistant afterwards; hooks are read at
17
+ startup.
18
+
19
+ Nothing else is required.
20
+
21
+ ### Fixed
22
+
23
+ - **The reminder to prefer niro's tools no longer goes quiet mid-session.** Claude Code's
24
+ `Grep|Glob|Read` hook deduplicated to once per session, per repo, per branch — so it fired early
25
+ in a session and then said nothing, which is precisely when an agent drifts back to `grep`. It now
26
+ fires on every native search.
27
+ - **The same reminder was permanently silent in some folders.** It resolved the repo with
28
+ `git rev-parse --show-toplevel` and gave up when that was empty, so a folder tracked by niro but
29
+ not a git checkout never got a reminder at all. It now walks up for the `.niro-project` marker
30
+ directly, and resolves symlinks, so a path reached through a symlinked directory works too.
31
+ - **The reminder's wording let the agent excuse itself.** It offered an "out of scope" escape hatch,
32
+ which is the exit drift actually takes. Removed; the legitimate native-tool cases (config, docs,
33
+ logs, generated files) are now named explicitly instead.
34
+ - **A typed project name is now treated as an account-scoped alias.** It was sent as `project_id` and
35
+ checked with a global lookup, so a name already used by an unrelated account read as taken — or
36
+ returned a 403 — for a name that was genuinely free for you. The server now assigns the opaque id,
37
+ and availability is checked against your own account.
38
+ - **`niro init` no longer records a broken project pin.** If the create response carried no
39
+ `project_id`, the literal text `undefined` was written into `.niro-project` while init reported
40
+ success; every later tool call then failed with `Project 'undefined' not found`. It now fails
41
+ loudly at that boundary and pins nothing.
42
+
43
+ ### Added
44
+
45
+ - The bridge now reports its version and identifies itself (`X-Niro-Cli-Version`,
46
+ `X-Niro-Bridge`) so the server can tell an out-of-date install to upgrade. The notice is off until
47
+ a server is deployed with `niro.mcp.cli.min-version` set.
48
+
49
+ ### Not shipped
50
+
51
+ Two features were built during this cycle and pulled before release. Both are recorded here so the
52
+ reasoning is in the repo rather than only in commit messages.
53
+
54
+ - **A serve-time self-heal** that would have refreshed stale hooks automatically. Three independent
55
+ reviews found it deleting users' own settings, widening file permissions on a file that can hold
56
+ API keys, replacing symlinked configs with regular files, and — with `HOME` unset, the normal case
57
+ on Windows — writing into the current project folder instead of the home directory. Refreshing
58
+ hooks stays a manual `niro mcp install`.
59
+ - **A per-prompt reminder hook for Codex.** Codex does support hooks, and the approach works, but
60
+ installing one means editing `~/.codex/config.toml` — the same file that holds
61
+ `[mcp_servers.niro]`, so a malformed edit makes Codex reject the whole file and takes niro's
62
+ registration down with it. Three review rounds found successive defects in that editing, including
63
+ an uninstall that could delete a hook of the user's own and leave a config Codex would not load.
64
+ Held back rather than shipped on a fourth attempt; the risk sat entirely in the config surgery,
65
+ not in the idea.
@@ -54,6 +54,10 @@ the shared `niro-skill` + `niro-cli` skill to `~/.agents/skills/` (also read by
54
54
 
55
55
  Verify: `/mcp` inside the Codex TUI.
56
56
 
57
+ Note that `AGENTS.md` is read once at the start of a session, so on a long task the reminder to
58
+ prefer niro's tools carries less weight than it does in Claude Code, where a per-tool-call hook
59
+ reinforces it. A per-prompt hook for Codex is in progress.
60
+
57
61
  ## Cursor
58
62
 
59
63
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.3.5",
3
+ "version": "0.4.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",
@@ -27,7 +27,8 @@
27
27
  "bin",
28
28
  "src",
29
29
  "docs/*.md",
30
- "README.md"
30
+ "README.md",
31
+ "CHANGELOG.md"
31
32
  ],
32
33
  "engines": {
33
34
  "node": ">=18"
@@ -11,6 +11,7 @@ const prompt = require("../lib/prompt");
11
11
  const color = require("../lib/color");
12
12
  const watchState = require("../lib/watchState");
13
13
  const niroProjectFile = require("../lib/niroProjectFile");
14
+ const { normalizeProjects, projectIdFromCreateResponse } = require("../lib/resolveContext");
14
15
  const { login } = require("./login");
15
16
  const { watchStatus } = require("./build");
16
17
 
@@ -108,14 +109,17 @@ async function addProject(opts = {}) {
108
109
  deriveNameFromUrl(selectedRepos[0].repository_url || selectedRepos[0].repositoryUrl))
109
110
  : "my-project";
110
111
 
111
- let projectId;
112
+ // The typed name is an ALIAS (account-scoped; duplicates across accounts are
113
+ // allowed by design) — never the opaque project_id, which the server assigns
114
+ // on create below.
115
+ let alias;
112
116
  while (true) {
113
117
  const candidate = await prompt.ask(" Project name", defaultName);
114
118
  if (!candidate) { console.error(color.red("Project name is required.")); return; }
115
- if (await projectNameTaken(candidate)) {
119
+ if (await aliasTaken(candidate, opts)) {
116
120
  console.log(color.yellow(` "${candidate}" already exists — pick another.`));
117
121
  } else {
118
- projectId = candidate;
122
+ alias = candidate;
119
123
  break;
120
124
  }
121
125
  }
@@ -126,11 +130,14 @@ async function addProject(opts = {}) {
126
130
  gitIdToMapping[repo.id || repo.gitId] = {};
127
131
  }
128
132
 
129
- console.log(`\n Creating project "${color.bold(projectId)}"...`);
133
+ console.log(`\n Creating project "${color.bold(alias)}"...`);
130
134
  const project = await api.post("/api/projects", {
131
- project_id: projectId,
135
+ alias,
132
136
  git_id_to_mapping: gitIdToMapping,
133
137
  });
138
+ // Throws rather than proceeding with a falsy id — see projectIdFromCreateResponse. Below, this is
139
+ // written into watchState and the .niro-project pin for every selected repo.
140
+ const projectId = projectIdFromCreateResponse(project);
134
141
 
135
142
  // Update local state with projectId for each local repo
136
143
  for (const repo of selectedRepos) {
@@ -141,7 +148,7 @@ async function addProject(opts = {}) {
141
148
  }
142
149
  }
143
150
 
144
- console.log(` ${color.green("✓")} Project created: ${color.bold(projectId)}`);
151
+ console.log(` ${color.green("✓")} Project created: ${color.bold(alias)}`);
145
152
 
146
153
  const shouldBuild = await prompt.confirm(" Trigger a full index build now?", true);
147
154
  if (shouldBuild) {
@@ -161,16 +168,17 @@ async function addProject(opts = {}) {
161
168
  console.log(`\n${color.green("✓ Done.")} Run ${color.cyan("niro mcp install")} to configure Claude Code / Cursor / Windsurf.`);
162
169
  }
163
170
 
164
- async function projectNameTaken(projectId) {
165
- try {
166
- await api.get(`/api/projects/${encodeURIComponent(projectId)}`);
167
- return true;
168
- } catch (err) {
169
- const body = err && err.body;
170
- const msg = (body && (body.error || body.message)) || err.message || "";
171
- if (/not found/i.test(String(msg))) return false;
172
- throw err;
173
- }
171
+ /**
172
+ * True when the alias is already used on this account. Checked BEFORE anything is created, so a
173
+ * collision does not leave a half-registered repo behind.
174
+ *
175
+ * `apiUrl` is threaded through to match init.js's copy. It is largely inert today (this command reaches
176
+ * the API through credentials written by login), but the two had diverged, which invites the next edit to
177
+ * fix one and miss the other.
178
+ */
179
+ async function aliasTaken(alias, opts = {}) {
180
+ const data = await api.get("/api/projects/resolve", { query: { alias }, apiUrl: opts.apiUrl });
181
+ return normalizeProjects(data).length > 0;
174
182
  }
175
183
 
176
184
  function deriveNameFromUrl(url) {
@@ -30,7 +30,7 @@ const fs = require("fs");
30
30
  const api = require("../lib/api");
31
31
  const agentApi = require("../lib/agentApi");
32
32
  const watchState = require("../lib/watchState");
33
- const { resolveContext } = require("../lib/resolveContext");
33
+ const { resolveContext, normalizeProjects, projectIdFromCreateResponse } = require("../lib/resolveContext");
34
34
  const folderSync = require("../lib/folderSync");
35
35
  const credentials = require("../lib/credentials");
36
36
  const prompt = require("../lib/prompt");
@@ -234,8 +234,13 @@ async function init(pathArg, opts = {}) {
234
234
  // EXISTING mode attaches the new repo's gitId to target.projectId; NEW mode
235
235
  // POSTs /api/projects to create it. The repo register/upload steps below are
236
236
  // identical for both — they don't depend on new-vs-existing.
237
+ //
238
+ // In NEW mode, target.projectId is the user-chosen ALIAS, not a real
239
+ // project_id yet — the server assigns the opaque project_id on create (see
240
+ // the POST below), and `projectId` is reassigned to that real value so
241
+ // every downstream call (pin file, env-vars, build, status) targets it.
237
242
  const isExisting = target.isExisting;
238
- const projectId = target.projectId;
243
+ let projectId = target.projectId;
239
244
 
240
245
  const cliModuleExcludes = Array.isArray(opts.moduleExclude) ? opts.moduleExclude : [];
241
246
  const cliFileExcludes = Array.isArray(opts.fileExclude) ? opts.fileExclude : [];
@@ -439,10 +444,18 @@ async function init(pathArg, opts = {}) {
439
444
  );
440
445
  } else {
441
446
  console.log(` ${step("2/3")} Creating project "${color.bold(projectId)}" and scanning for environment variables...`);
447
+ // Send the chosen name as `alias` (account-scoped, duplicates across
448
+ // accounts allowed by design) — never as `project_id`. Letting the server
449
+ // assign the opaque project_id avoids colliding with an unrelated
450
+ // account's project whose project_id happens to equal this alias.
442
451
  project = await api.post("/api/projects", {
443
- project_id: projectId,
452
+ alias: projectId,
444
453
  git_id_to_mapping: { [gitId]: {} },
445
454
  });
455
+ // Throws rather than pinning a falsy id — see projectIdFromCreateResponse. The pin write and
456
+ // watchState.update below both consume this, so a missing key would otherwise be recorded as the
457
+ // literal string "undefined" and every later tool call would fail with no clue why.
458
+ projectId = projectIdFromCreateResponse(project);
446
459
  }
447
460
 
448
461
  // If this folder was already onboarded to a different project, note the re-pin so
@@ -783,11 +796,18 @@ const GLOB_INCLUDE_EXAMPLES = "e.g. src/**/*.py, lib/**/*.ts";
783
796
  const GLOB_EXCLUDE_EXAMPLES = "e.g. **/test/**, **/*.generated.*, vendor/**";
784
797
 
785
798
  /**
786
- * Resolve a project name that is guaranteed to be free on this account.
799
+ * Resolve a project ALIAS that is guaranteed to be free on this account.
800
+ *
801
+ * The typed name is an alias, not the project_id — aliases are only unique
802
+ * per account (the server explicitly allows the same alias across different
803
+ * accounts), so this checks GET /api/projects/resolve?alias= (account-scoped)
804
+ * rather than GET /api/projects/{id} (a global project_id lookup, which used
805
+ * to 403 whenever some OTHER account already owned a project whose opaque
806
+ * project_id happened to equal the typed name).
787
807
  *
788
- * We probe GET /api/projects/{id} before registering anything — previously
789
- * the CLI only discovered a collision after the POST, by which point the
790
- * repo had already been registered in step 1 and we left an orphan.
808
+ * We probe before registering anything — previously the CLI only discovered
809
+ * a collision after the POST, by which point the repo had already been
810
+ * registered in step 1 and we left an orphan.
791
811
  *
792
812
  * Two modes:
793
813
  * - --project-name supplied: check once; if taken, fail fast with guidance.
@@ -798,7 +818,7 @@ const GLOB_EXCLUDE_EXAMPLES = "e.g. **/test/**, **/*.generated.*, vendor/**";
798
818
  */
799
819
  async function resolveAvailableProjectName({ defaultName, opts }) {
800
820
  if (opts.projectName) {
801
- if (await projectNameTaken(opts.projectName)) {
821
+ if (await aliasTaken(opts.projectName, opts)) {
802
822
  console.error(
803
823
  fail(`Project "${opts.projectName}" already exists on this account.`) +
804
824
  ` Pick a different name or delete it first (niro delete ${opts.projectName}).`
@@ -812,7 +832,7 @@ async function resolveAvailableProjectName({ defaultName, opts }) {
812
832
  // for collisions below). Non-interactive without --defaults/--project-name:
813
833
  // fail loudly rather than hang on a prompt.
814
834
  if (opts.defaults) {
815
- if (await projectNameTaken(defaultName)) {
835
+ if (await aliasTaken(defaultName, opts)) {
816
836
  console.error(
817
837
  fail(`Project "${defaultName}" already exists on this account.`) +
818
838
  ` Pass --project-name <name> or delete it first (niro delete ${defaultName}).`
@@ -836,24 +856,15 @@ async function resolveAvailableProjectName({ defaultName, opts }) {
836
856
  console.error(fail("Project name is required."));
837
857
  return null;
838
858
  }
839
- if (!(await projectNameTaken(answer))) return answer;
859
+ if (!(await aliasTaken(answer, opts))) return answer;
840
860
  console.log(` ${warn("⚠")} "${color.bold(answer)}" already exists on this account — pick another.`);
841
861
  candidate = answer;
842
862
  }
843
863
  }
844
864
 
845
- async function projectNameTaken(projectId) {
846
- try {
847
- await api.get(`/api/projects/${encodeURIComponent(projectId)}`);
848
- return true;
849
- } catch (err) {
850
- const body = err && err.body;
851
- const msg = (body && (body.error || body.message)) || err.message || "";
852
- if (/not found/i.test(String(msg))) return false;
853
- // Any other error (network, 401, 500) we surface — don't silently treat
854
- // as "available" or we'll create a project we can't manage.
855
- throw err;
856
- }
865
+ async function aliasTaken(alias, opts = {}) {
866
+ const data = await api.get("/api/projects/resolve", { query: { alias }, apiUrl: opts.apiUrl });
867
+ return normalizeProjects(data).length > 0;
857
868
  }
858
869
 
859
870
  /**
@@ -105,4 +105,28 @@ function normalizeProjects(data) {
105
105
  .filter(Boolean);
106
106
  }
107
107
 
108
- module.exports = { resolveContext, normalizeProjects };
108
+ /**
109
+ * The project_id from a `POST /api/projects` response, or throws.
110
+ *
111
+ * Accepts either casing for the same reason normalizeProjects does — the server response shape varies
112
+ * across versions and deployments — and refuses to return a falsy id. That refusal is the point: callers
113
+ * assign this straight into the `.niro-project` pin file and watchState, so a missing key used to write
114
+ * the literal string "undefined" into the pin while `niro init` still printed success and exited 0. Every
115
+ * later MCP tool call then failed with `Project 'undefined' not found` and `niro build` targeted
116
+ * /api/graph/undefined/build, with nothing pointing at the cause.
117
+ *
118
+ * A create response with no id is a protocol violation, so it fails loudly at the boundary instead of
119
+ * degrading into a broken pin.
120
+ */
121
+ function projectIdFromCreateResponse(project) {
122
+ const id = project && (project.project_id || project.projectId || project.id);
123
+ if (!id || typeof id !== "string" || !id.trim()) {
124
+ throw new Error(
125
+ "The server created the project but returned no project_id. Nothing was pinned. " +
126
+ "This usually means the backend predates account-scoped aliases — upgrade it, or report this."
127
+ );
128
+ }
129
+ return id.trim();
130
+ }
131
+
132
+ module.exports = { resolveContext, normalizeProjects, projectIdFromCreateResponse };
package/src/mcp/server.js CHANGED
@@ -52,6 +52,32 @@ function niroProjectsFilePath() {
52
52
  }
53
53
  const { createPendingTracker, isTrackableRequest, resolveTimeoutMs } = require("../lib/pendingRequests");
54
54
 
55
+ /**
56
+ * This package's version, sent as X-Niro-Cli-Version so the server can tell an out-of-date install to
57
+ * upgrade. That notice is the only channel that reaches a user who installed long ago, because everything
58
+ * else niro writes (hooks, CLAUDE.md/AGENTS.md sections, rules files) is frozen on their disk at install
59
+ * time and nothing prompts them to re-run `niro mcp install`.
60
+ *
61
+ * Paired with the X-Niro-Bridge marker below, which is what actually identifies this bridge. An earlier
62
+ * version had the server treat a MISSING version as out-of-date, reasoning that the header and the check
63
+ * shipped together so its absence proved an older bridge. That was wrong: @niroai/mcp-server is a
64
+ * separate published bridge (a documented install path, and an internal dependency of two packages here)
65
+ * that connects to the same /mcp/sse endpoint and sends no such header. Those users would have been told
66
+ * to upgrade @niroai/niro, which they do not have installed, on every session, with no remedy.
67
+ *
68
+ * So the version is advisory and the marker is the gate. A read failure here must not stop the bridge
69
+ * from starting, hence the empty-string fallback rather than a throw; an empty value simply means the
70
+ * server cannot judge the version and says nothing.
71
+ */
72
+ const CLI_VERSION = (() => {
73
+ try {
74
+ return require("../../package.json").version || "";
75
+ } catch (e) {
76
+ process.stderr.write(`[niro-mcp] WARN: could not read own package version: ${e.message}\n`);
77
+ return "";
78
+ }
79
+ })();
80
+
55
81
  const API_KEY = process.env.NIRO_API_KEY;
56
82
 
57
83
  /**
@@ -410,6 +436,13 @@ function connectSse() {
410
436
  };
411
437
  if (CLI_TOKEN) sseHeaders["X-Niro-Cli-Token"] = CLI_TOKEN;
412
438
  if (API_KEY) sseHeaders["X-Niro-Api-Key"] = API_KEY;
439
+ // Sent on every session so the server can spot an out-of-date install (see CLI_VERSION). The
440
+ // marker is what identifies THIS bridge: @niroai/mcp-server is a separate published bridge that
441
+ // hits the same /mcp/sse endpoint and sends neither header, so the server must not infer "old
442
+ // niro CLI" from a missing version alone — it would tell those users to upgrade a package they do
443
+ // not have installed, on every session, with no way to stop it.
444
+ sseHeaders["X-Niro-Bridge"] = "niro-cli";
445
+ if (CLI_VERSION) sseHeaders["X-Niro-Cli-Version"] = CLI_VERSION;
413
446
  if (PROJECT_ID) {
414
447
  sseHeaders["X-Niro-Project-Id"] = PROJECT_ID;
415
448
  } else {
package/src/mcp/setup.js CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  const fs = require("fs");
19
+ const os = require("os");
19
20
  const path = require("path");
20
21
  const readline = require("readline");
21
22
  const { execSync } = require("child_process");
@@ -41,17 +42,27 @@ const NIRO_MD_MARKER = "<!-- niro-mcp -->";
41
42
  * own notes added below the block survive uninstall — see teardown.js removeMarkerSection).
42
43
  * Each begins with a leading "\n" because install does `existing + section`.
43
44
  */
45
+ /**
46
+ * Explore-first rule, shared by every client's markdown section. Prose alone is weak, but this is the
47
+ * one lever some clients (Codex, Copilot CLI, Factory, OpenCode, Windsurf) have — they support no hooks,
48
+ * so their ONLY always-on nudge is this file. For Claude Code it reinforces the PreToolUse Grep|Glob|Read
49
+ * hook (NIRO_EXPLORE_COMMAND), which fires on every native search. On the hookless clients this prose is
50
+ * read once per session, which is why it now states its own non-expiry explicitly. Wording is imperative
51
+ * and names concrete tools so the agent knows what to call instead of native search.
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.";
54
+
44
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.";
45
- 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_MULTI_REPO_SENTENCE}\n`;
46
- 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_MULTI_REPO_SENTENCE}\n`;
47
- 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_MULTI_REPO_SENTENCE}\n`;
56
+ 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_MULTI_REPO_SENTENCE}\n`;
57
+ 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_MULTI_REPO_SENTENCE}\n`;
58
+ 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_MULTI_REPO_SENTENCE}\n`;
48
59
  /**
49
60
  * Shared generic section for clients whose "always-on instructions" file has no companion
50
61
  * skill mechanism we've verified (Gemini CLI's GEMINI.md, GitHub Copilot CLI's
51
62
  * copilot-instructions.md, Factory's AGENTS.md, OpenCode's AGENTS.md) — one wording, reused
52
63
  * across those destination files (teardown matches it per-file, so reuse is safe).
53
64
  */
54
- const NIRO_GENERIC_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools are available (call chains, blast radius, impact analysis, source lookup). Prefer them over grep/read for questions about this codebase. 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_MULTI_REPO_SENTENCE}\n`;
65
+ const NIRO_GENERIC_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools are available (call chains, blast radius, impact analysis, source lookup). Prefer them over grep/read for questions about this codebase. 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_MULTI_REPO_SENTENCE}\n`;
55
66
 
56
67
  /** All niro markdown sections — the uninstaller tries each for an exact removal. */
57
68
  const NIRO_MD_SECTIONS = [NIRO_CLAUDE_MD_SECTION, NIRO_CODEX_MD_SECTION, NIRO_WINDSURF_MD_SECTION, NIRO_GENERIC_MD_SECTION];
@@ -642,6 +653,31 @@ function addClaudeCodeMemory() {
642
653
  }
643
654
  }
644
655
 
656
+ /**
657
+ * The niro UserPromptSubmit hook command. Module-level so tests can execute the real string under
658
+ * /bin/sh: it is shell, so asserting on its text proves nothing about whether it runs, and this body
659
+ * shipped once with no execution coverage at all.
660
+ *
661
+ * Folder-gated: walks up from the project dir and only emits the reminder when a `.niro-project`
662
+ * marker exists (i.e. this tree is actually indexed). It keeps ONE conditional clause — "if the niro
663
+ * tools are not in your toolset at all" — because a shell hook cannot see which MCP tools Claude has
664
+ * loaded, so the reminder must stay correct when Niro is disabled or an A/B session exposes a subset.
665
+ * The old wording also let the agent excuse itself whenever it judged a query "out of scope", which is
666
+ * the escape hatch drift actually walks through; that clause is gone, and the legitimate native-tool
667
+ * cases are now named explicitly instead.
668
+ *
669
+ * Reads the prompt on stdin so it can add a temporary-project reminder when the message asks to
670
+ * create or switch a branch — the exact moment a repo's local state diverges from what Niro indexed
671
+ * (ADR-017). The branch match is a coarse keyword heuristic (a shell hook cannot call an LLM); a false
672
+ * positive only shows an extra harmless reminder, never gates behaviour.
673
+ */
674
+ const NIRO_USER_PROMPT_COMMAND =
675
+ "IN=\"$(cat)\"; d=\"${CLAUDE_PROJECT_DIR:-$PWD}\"; while [ -n \"$d\" ] && [ \"$d\" != \"/\" ]; do " +
676
+ "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. 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.'; " +
677
+ "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' && " +
678
+ "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; " +
679
+ "d=\"$(dirname \"$d\")\"; done";
680
+
645
681
  /**
646
682
  * Adds a UserPromptSubmit hook to ~/.claude/settings.json so Claude is reminded
647
683
  * about Niro tools + the `niro` skill before every prompt. This is the lightweight,
@@ -650,21 +686,7 @@ function addClaudeCodeMemory() {
650
686
  */
651
687
  function addClaudeCodeUserPromptHook() {
652
688
  const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
653
- // Folder-gated: walks up from the project dir and only emits the reminder when a
654
- // `.niro-project` marker exists (i.e. this tree is actually indexed). The wording is
655
- // conditional ("IF the niro tools are present") because a shell hook cannot see which
656
- // MCP tools Claude has loaded — so it stays correct when Niro is disabled or an A/B
657
- // session exposes only a subset of niro tools.
658
- // Reads the prompt on stdin so it can add a SANDBOX reminder when the message asks to create/switch a
659
- // branch — the exact moment a repo's local state diverges from what Niro indexed (ADR-017). The branch
660
- // match is a coarse keyword heuristic (a shell hook can't call an LLM); a false positive only shows an
661
- // extra harmless reminder, never gates behaviour. Base reminder is unchanged and still `.niro-project`-gated.
662
- const hookCommand =
663
- "IN=\"$(cat)\"; d=\"${CLAUDE_PROJECT_DIR:-$PWD}\"; while [ -n \"$d\" ] && [ \"$d\" != \"/\" ]; do " +
664
- "if [ -f \"$d/.niro-project\" ]; then echo 'This repo is indexed by Niro. IF the niro MCP tools are present in your current toolset, prefer them over grep/read for codebase questions (structure, call chains, blast radius, impact, data flows, reuse) and call find_reusable_code before writing new code. If the niro tools are absent (disabled or A/B-limited) or a query is out of scope, use native tools and do not mention Niro.'; " +
665
- "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' && " +
666
- "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; " +
667
- "d=\"$(dirname \"$d\")\"; done";
689
+ const hookCommand = NIRO_USER_PROMPT_COMMAND;
668
690
 
669
691
  let settings = {};
670
692
  try {
@@ -795,38 +817,57 @@ function addClaudeCodePreToolUseHook() {
795
817
  * UserPromptSubmit hook, the niro skill) tell Claude to prefer niro tools for code exploration, but in
796
818
  * practice that only sticks when a task is phrased as a discovery question — a task phrased as "fix X" or
797
819
  * "add Y" skips straight to native search with no interruption. This hook is the deterministic backstop:
798
- * once per (session, repo, branch), the first native Grep/Glob/Read call in a niro-indexed repo gets an
799
- * advisory reminder to prefer niro's structural tools instead — regardless of how the task was phrased.
820
+ * EVERY native Grep/Glob/Read call in a niro-indexed repo gets an advisory reminder to prefer niro's
821
+ * structural tools instead — regardless of how the task was phrased, and regardless of how far into the
822
+ * session it happens.
823
+ *
824
+ * It used to dedupe to once per (session, repo, branch). That was the bug: the reminder landed on the
825
+ * first native search of a session and then went quiet, so a long session drifted straight back to grep
826
+ * with nothing to correct it. Repeating the nudge on every call costs about 99 tokens per native search
827
+ * (394 characters of additionalContext) and is the only thing that survives a 100-turn session. Note that
828
+ * Claude issues Reads in parallel batches, so one turn can carry several copies of that text.
800
829
  *
801
830
  * Dependency-free (no jq): reads the PreToolUse JSON on stdin. Read supplies `file_path`; Grep/Glob supply
802
- * `path` (often omitted, meaning cwd) — so this tries file_path, then path, then the hook's own `cwd`
803
- * field, then falls back to $PWD. Walks up for a `.niro-project` marker (silent outside indexed repos)
804
- * and dedupes via a marker file keyed by session_id + repo root + branch, same scheme as the Edit|Write
805
- * temp-project hook but under the distinct `niro-explore-` prefix so the two hooks never collide.
831
+ * `path` (often omitted, meaning cwd) — so this tries file_path, then path, then the payload's own `cwd`,
832
+ * then CLAUDE_PROJECT_DIR, then $PWD. Relative targets resolve against the payload `cwd`, symlinks are
833
+ * resolved with `cd -P`, and it then walks up for a `.niro-project` marker (silent outside indexed repos)
834
+ * without requiring a git checkout. Carries `niro-explore-` as an inert identity token so
835
+ * isNiroExploreHook still recognises it for upgrade and uninstall.
806
836
  */
807
837
  const NIRO_EXPLORE_COMMAND = [
808
838
  'IN="$(cat)"',
809
839
  // Read gives file_path; Grep/Glob give path (may be absent); fall back to cwd, then $PWD.
810
840
  `f="$(printf '%s' "$IN" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
811
841
  `[ -z "$f" ] && f="$(printf '%s' "$IN" | grep -oE '"path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
812
- `[ -z "$f" ] && f="$(printf '%s' "$IN" | grep -oE '"cwd"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
813
- '[ -z "$f" ] && f="$PWD"',
842
+ // The session's own directory, from the payload's top-level `cwd`. Serves two jobs: the base for a
843
+ // relative target path, and the fallback when the call names no path at all — which is the COMMON
844
+ // case, since Grep and Glob usually omit `path`. Resolving a relative path against the hook process's
845
+ // own $PWD instead silently misses the marker whenever the two differ; the payload states the right
846
+ // directory, so use it.
847
+ `c="$(printf '%s' "$IN" | grep -oE '"cwd"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
848
+ '[ -z "$c" ] && c="${CLAUDE_PROJECT_DIR:-$PWD}"',
849
+ '[ -z "$f" ] && f="$c"',
850
+ 'case "$f" in /*) ;; *) f="$c/$f" ;; esac',
814
851
  'd0="$f"; [ -d "$d0" ] || d0="$(dirname "$f")"',
815
- 'r="$(git -C "$d0" rev-parse --show-toplevel 2>/dev/null)"',
816
- '[ -z "$r" ] && exit 0',
817
- // only remind inside a niro-indexed tree
818
- 'd="$r"; ok=0; while [ -n "$d" ] && [ "$d" != "/" ]; do if [ -f "$d/.niro-project" ]; then ok=1; break; fi; d="$(dirname "$d")"; done',
852
+ // Resolve symlinks before walking up. The `git rev-parse --show-toplevel` this replaced did that for
853
+ // free, so removing it without this line reintroduced the very failure the change set out to kill,
854
+ // through another door: a Read through a symlinked subdirectory walks lexical parents that never hold
855
+ // the marker. Falls back to the lexical path when the directory does not exist yet (a Write creating
856
+ // a new folder).
857
+ `d0="$(cd -P "$d0" 2>/dev/null && pwd -P || printf '%s' "$d0")"`,
858
+ // Indexed-tree gate, git-independent on purpose: niro indexes plain folders too, so a repo can carry
859
+ // .niro-project with no .git at all. The previous `git rev-parse --show-toplevel` bail made this hook
860
+ // permanently silent in exactly those repos — no nudge, ever. Walk up from the path itself instead.
861
+ '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',
819
862
  '[ "$ok" = 0 ] && exit 0',
820
- `s="$(printf '%s' "$IN" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
821
- // Branch-aware dedup, same rationale as the Edit|Write hook: a stale answer from a different branch must not
822
- // silence the reminder after a branch switch.
823
- 'b="$(git -C "$r" rev-parse --abbrev-ref HEAD 2>/dev/null)"',
824
- `k="$(printf '%s\\n%s\\n%s' "$s" "$r" "$b" | cksum | cut -d' ' -f1)"`,
825
- 'm="${TMPDIR:-/tmp}/niro-explore-$k"',
826
- '[ -f "$m" ] && exit 0',
827
- 'touch "$m"',
863
+ // No once-per-session marker file, deliberately: this fires on EVERY native search. The drift it exists
864
+ // to stop happens late in a session, long after a one-shot reminder at turn 4 has scrolled out of
865
+ // attention — dedup was the reason the hook stopped working, not a cost saving worth keeping.
866
+ // `niro-explore-` stays verbatim as this hook's identity token: isNiroExploreHook and teardown match on
867
+ // it, so dropping the string would orphan the hook on upgrade and on uninstall.
868
+ 'NIRO_HOOK_ID=niro-explore-alwayson',
828
869
  // Advisory context injection ONLY — no permissionDecision, so it never blocks the read/search.
829
- `printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"This is a niro-indexed repo. For code structure/behavior/call-chain/impact/reuse questions - including ones that come up mid-task while implementing or fixing something, not just standalone discovery questions - prefer niro MCP tools (get_source_code, code_grep, get_call_chain, get_blast_radius, get_impact_analysis, find_reusable_code, get_class_structure, etc.) over native Grep/Glob/Read. Fall back to native tools silently for out-of-scope questions (config files, docs, runtime state, non-code content). Shown once per repo per branch per session."}}'`,
870
+ `printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"NIRO ACTIVE. Native search on a niro-indexed repo. Prefer niro tools, every time and mid-task too: code_grep (content), get_source_code or resolve_fqn (known symbol), find_handler or find_api_endpoints (entry points), get_call_chain, get_blast_radius, get_impact_analysis (connections), find_reusable_code (before new code). Native tools only for config, docs, logs, generated or non-code text."}}'`,
830
871
  ].join('; ');
831
872
 
832
873
  /**
@@ -863,8 +904,8 @@ function addClaudeCodeExploreHook() {
863
904
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
864
905
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
865
906
  console.log(` PreToolUse explore-first hook ${hadStale ? "upgraded" : "added"} in ~/.claude/settings.json`);
866
- console.log(" → Before the first native Grep/Glob/Read in a niro-indexed repo, Claude is reminded once/session/branch");
867
- console.log(" to prefer niro tools — even mid-task, not just for standalone discovery questions.\n");
907
+ console.log(" → On every native Grep/Glob/Read in a niro-indexed repo, Claude is reminded to prefer");
908
+ console.log(" niro tools — even mid-task and late in a long session, not just for discovery questions.\n");
868
909
  } catch (err) {
869
910
  console.warn(` Could not write ~/.claude/settings.json: ${err.message}`);
870
911
  console.warn(" Add manually to the PreToolUse array in ~/.claude/settings.json:\n");
@@ -886,6 +927,16 @@ code — structure, behavior, impact, data/event flows, semantics — prefer the
886
927
  native grep/read. For anything outside the indexed code (config files, docs, runtime state),
887
928
  use your native tools.
888
929
 
930
+ ## Explore first — MUST FOLLOW
931
+
932
+ Before you run grep, find, glob, or open a file to hunt for something, check whether a Niro
933
+ tool answers it: \`code_grep\` for content search, \`resolve_fqn\` + \`get_source_code\` for a known
934
+ symbol, \`find_handler\` / \`find_api_endpoints\` for entry points. Use the connection tools
935
+ (\`get_call_chain\`, \`get_reverse_call_chain\`, \`get_blast_radius\`, \`get_impact_analysis\`,
936
+ \`get_service_topology\`) to understand how the code connects BEFORE you decide which file to
937
+ read or search next, instead of reading files to rebuild that picture by hand. Native search
938
+ stays the fallback for what Niro does not cover, and when the tools are unavailable.
939
+
889
940
  \`project_id\` auto-resolves from a \`.niro-project\` file at the repo root — omit it. If there
890
941
  is no such file, call \`list_projects\` and pass the chosen \`project_id\`.
891
942
 
@@ -1287,6 +1338,13 @@ alwaysApply: false
1287
1338
  The niro MCP server exposes code-intelligence tools for this codebase. Prefer them over
1288
1339
  grep/read for any question answerable from the indexed code.
1289
1340
 
1341
+ MUST FOLLOW — explore first: before you run grep, find, glob, or open a file to hunt for
1342
+ something, check whether a niro tool answers it (code_grep, resolve_fqn + get_source_code,
1343
+ find_handler, find_api_endpoints). Use the connection tools (get_call_chain,
1344
+ get_reverse_call_chain, get_blast_radius, get_impact_analysis, get_service_topology) to see
1345
+ how the code connects BEFORE deciding which file to read or search next. Native search is the
1346
+ fallback for what niro does not cover, and when the tools are unavailable.
1347
+
1290
1348
  Availability self-check FIRST — if the niro tools are not present in this session, or the
1291
1349
  repo is not indexed (no project resolves / a tool reports "not indexed"), fall back to
1292
1350
  native tools silently and do not mention Niro.
@@ -1574,6 +1632,13 @@ Niro code-intelligence MCP tools are available for the current codebase. Prefer
1574
1632
  grep/read for questions about structure, call chains, blast radius, impact, data flows, and
1575
1633
  reuse. Before writing or adding any new code, use \`find_reusable_code\` first.
1576
1634
 
1635
+ MUST FOLLOW — explore first: before you run grep, find, glob, or open a file to hunt for
1636
+ something, check whether a niro tool answers it (\`code_grep\`, \`resolve_fqn\` +
1637
+ \`get_source_code\`, \`find_handler\`, \`find_api_endpoints\`). Use the connection tools
1638
+ (\`get_call_chain\`, \`get_reverse_call_chain\`, \`get_blast_radius\`, \`get_impact_analysis\`,
1639
+ \`get_service_topology\`) to see how the code connects BEFORE deciding which file to read or
1640
+ search next. Native search is the fallback for what niro does not cover.
1641
+
1577
1642
  If the niro tools are not present in this session, or the repo is not indexed (no project
1578
1643
  resolves), fall back to native tools silently — do not mention Niro's absence.
1579
1644
  `;
@@ -1612,6 +1677,13 @@ Niro code-intelligence MCP tools are available for the current codebase. Prefer
1612
1677
  grep/read for questions about structure, call chains, blast radius, impact, data flows, and
1613
1678
  reuse. Before writing or adding any new code, use \`find_reusable_code\` first.
1614
1679
 
1680
+ MUST FOLLOW — explore first: before you run grep, find, glob, or open a file to hunt for
1681
+ something, check whether a niro tool answers it (\`code_grep\`, \`resolve_fqn\` +
1682
+ \`get_source_code\`, \`find_handler\`, \`find_api_endpoints\`). Use the connection tools
1683
+ (\`get_call_chain\`, \`get_reverse_call_chain\`, \`get_blast_radius\`, \`get_impact_analysis\`,
1684
+ \`get_service_topology\`) to see how the code connects BEFORE deciding which file to read or
1685
+ search next. Native search is the fallback for what niro does not cover.
1686
+
1615
1687
  If the niro tools are not present in this session, or the repo is not indexed (no project
1616
1688
  resolves), fall back to native tools silently — do not mention Niro's absence.
1617
1689
  `;
@@ -2046,7 +2118,6 @@ module.exports = {
2046
2118
  addClaudeCodeCliSkill,
2047
2119
  addAgentsSkill,
2048
2120
  addAgentsCliSkill,
2049
- // Shared with teardown.js so uninstall removes exactly what install wrote.
2050
2121
  CLIENTS,
2051
2122
  NIRO_MD_MARKER,
2052
2123
  NIRO_MD_SECTIONS,
@@ -2055,9 +2126,20 @@ module.exports = {
2055
2126
  isNiroPreToolUseHook,
2056
2127
  isNiroExploreHook,
2057
2128
  userHome,
2129
+ // Exposed for tests: the hook bodies are shell, so the only way to prove they still fire (and still
2130
+ // carry their identity token) is to execute the real string.
2131
+ NIRO_EXPLORE_COMMAND,
2132
+ NIRO_PRETOOL_COMMAND,
2133
+ NIRO_USER_PROMPT_COMMAND,
2058
2134
  // Exposed for content regression tests (e.g. the cross-project routing guidance).
2059
2135
  NIRO_SKILL,
2060
2136
  NIRO_CLI_SKILL,
2137
+ // Exposed for tests: the explore-first rule must reach EVERY client's always-on instruction surface.
2138
+ NIRO_EXPLORE_FIRST_SENTENCE,
2139
+ NIRO_CURSOR_RULE,
2140
+ NIRO_CLINE_RULE,
2141
+ NIRO_KIRO_STEERING,
2142
+ upsertNiroMarkerSection,
2061
2143
  // Exposed for tests: URL resolution replaced the install-time prompt.
2062
2144
  resolveMcpServerUrl,
2063
2145
  SAAS_URL,