@niroai/niro 0.3.6 → 0.5.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,109 @@
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
+ ### Added
22
+
23
+ - **You can now see what Niro did, without asking.** At the end of a task you get one line:
24
+
25
+ ```
26
+ [NIRO] Mapped 29 callers across 8 files that a grep would have missed · 3 calls instead of ~14 searches
27
+ ```
28
+
29
+ The call count is measured by the server. The number of searches it stands in for, and the
30
+ description, are the assistant's own account of its own work, so that number always carries a `~`
31
+ to mark it as an estimate. We would rather you trusted a smaller number than doubted a bigger one.
32
+
33
+ On a turn where Niro found nothing useful, the description is dropped rather than invented, and
34
+ you see the bare call count.
35
+
36
+ - **It also tells you when Niro went unused.** On a turn where the assistant searched your code
37
+ without asking Niro, you get `[NIRO] Not consulted: this turn used grep and file reads instead`.
38
+ Until now a skipped turn looked exactly like Niro not being installed, which meant every one of
39
+ our failures was invisible. Nothing is printed after a turn that did no code work.
40
+
41
+ - **On by default, including on upgrade.** Turn it off with `niro mcp receipt --off` and back on
42
+ with `--on`; run it with no flags to see the current state. The setting is per machine.
43
+
44
+ Works the same in all twelve supported assistants: the line travels back inside the
45
+ `mark_task_complete` tool result and your assistant prints it, wrapped in backticks so it renders
46
+ as code rather than as more of its own prose. If the assistant never calls `mark_task_complete`,
47
+ no line appears.
48
+
49
+ ### Fixed
50
+
51
+ - **Codex no longer asks permission for every Niro tool call.** Two causes, both fixed by re-running
52
+ `niro mcp install`:
53
+
54
+ The approval mode was set to `"auto"`, which asks Codex to decide for itself — and it decides to
55
+ ask. It is now `"writes"`: prompt only for tools that write, and Niro writes nothing. An existing
56
+ `"auto"` is migrated; a value you chose yourself is left alone.
57
+
58
+ Codex also records a per-tool override whenever you answer a prompt with "approve once" rather than
59
+ "always allow", and a per-tool setting beats the default. Those are now cleared on install.
60
+
61
+ - **A malformed tool response when a reply contained a backslash, tab or quote.** The
62
+ `mark_task_complete` response was assembled by a hand-written JSON builder that escaped only the
63
+ double quote. That was survivable while every value was a fixed keyword, but any free text
64
+ containing a Windows path or a tab produced JSON the client could not parse, losing the whole
65
+ response. All control characters are now escaped.
66
+
67
+ - **The reminder to prefer niro's tools no longer goes quiet mid-session.** Claude Code's
68
+ `Grep|Glob|Read` hook deduplicated to once per session, per repo, per branch — so it fired early
69
+ in a session and then said nothing, which is precisely when an agent drifts back to `grep`. It now
70
+ fires on every native search.
71
+ - **The same reminder was permanently silent in some folders.** It resolved the repo with
72
+ `git rev-parse --show-toplevel` and gave up when that was empty, so a folder tracked by niro but
73
+ not a git checkout never got a reminder at all. It now walks up for the `.niro-project` marker
74
+ directly, and resolves symlinks, so a path reached through a symlinked directory works too.
75
+ - **The reminder's wording let the agent excuse itself.** It offered an "out of scope" escape hatch,
76
+ which is the exit drift actually takes. Removed; the legitimate native-tool cases (config, docs,
77
+ logs, generated files) are now named explicitly instead.
78
+ - **A typed project name is now treated as an account-scoped alias.** It was sent as `project_id` and
79
+ checked with a global lookup, so a name already used by an unrelated account read as taken — or
80
+ returned a 403 — for a name that was genuinely free for you. The server now assigns the opaque id,
81
+ and availability is checked against your own account.
82
+ - **`niro init` no longer records a broken project pin.** If the create response carried no
83
+ `project_id`, the literal text `undefined` was written into `.niro-project` while init reported
84
+ success; every later tool call then failed with `Project 'undefined' not found`. It now fails
85
+ loudly at that boundary and pins nothing.
86
+
87
+ ### Added
88
+
89
+ - The bridge now reports its version and identifies itself (`X-Niro-Cli-Version`,
90
+ `X-Niro-Bridge`) so the server can tell an out-of-date install to upgrade. The notice is off until
91
+ a server is deployed with `niro.mcp.cli.min-version` set.
92
+
93
+ ### Not shipped
94
+
95
+ Two features were built during this cycle and pulled before release. Both are recorded here so the
96
+ reasoning is in the repo rather than only in commit messages.
97
+
98
+ - **A serve-time self-heal** that would have refreshed stale hooks automatically. Three independent
99
+ reviews found it deleting users' own settings, widening file permissions on a file that can hold
100
+ API keys, replacing symlinked configs with regular files, and — with `HOME` unset, the normal case
101
+ on Windows — writing into the current project folder instead of the home directory. Refreshing
102
+ hooks stays a manual `niro mcp install`.
103
+ - **A per-prompt reminder hook for Codex.** Codex does support hooks, and the approach works, but
104
+ installing one means editing `~/.codex/config.toml` — the same file that holds
105
+ `[mcp_servers.niro]`, so a malformed edit makes Codex reject the whole file and takes niro's
106
+ registration down with it. Three review rounds found successive defects in that editing, including
107
+ an uninstall that could delete a hook of the user's own and leave a config Codex would not load.
108
+ Held back rather than shipped on a fourth attempt; the risk sat entirely in the config surgery,
109
+ 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.6",
3
+ "version": "0.5.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
  /**
@@ -37,4 +37,32 @@ function serve() {
37
37
  require("../mcp/server");
38
38
  }
39
39
 
40
- module.exports = { install, uninstall, serve };
40
+ /**
41
+ * `niro mcp receipt` — the per-developer off switch for the end-of-task line.
42
+ *
43
+ * The line itself is printed by the model from the tool result; nothing here displays it. This
44
+ * command only records the preference, which the bridge reads to decide whether to strip the
45
+ * receipt before the model ever sees it.
46
+ *
47
+ * `--pop` and `--hook` are gone. They served the Claude Code Stop hook, which was retired because
48
+ * Claude Code labels hook output "Stop says: …" — a system-warning framing for what is meant to be
49
+ * a quiet note from Niro.
50
+ */
51
+ function receipt(opts = {}) {
52
+ const store = require("../lib/receiptStore");
53
+
54
+ if (opts.off || opts.on) {
55
+ const changed = store.setDisabled(!!opts.off);
56
+ const state = opts.off ? "off" : "on";
57
+ console.log(changed
58
+ ? ` End-of-task Niro line is now ${state}.`
59
+ : ` End-of-task Niro line was already ${state}.`);
60
+ return;
61
+ }
62
+
63
+ console.log(store.isDisabled()
64
+ ? " End-of-task Niro line: off (turn it on with `niro mcp receipt --on`)"
65
+ : " End-of-task Niro line: on (turn it off with `niro mcp receipt --off`)");
66
+ }
67
+
68
+ module.exports = { install, uninstall, serve, receipt };
package/src/index.js CHANGED
@@ -265,6 +265,12 @@ function main() {
265
265
  .command("serve")
266
266
  .description("Run the Niro MCP server over stdio (used by your editor's MCP config).")
267
267
  .action(() => { require("./commands/mcp").serve(); });
268
+ mcp
269
+ .command("receipt")
270
+ .description("Turn the end-of-task Niro line on or off for this machine. No flags shows the current state.")
271
+ .option("--on", "Turn the end-of-task line back on.")
272
+ .option("--off", "Stop showing the end-of-task line.")
273
+ .action((opts) => { require("./commands/mcp").receipt(opts); });
268
274
 
269
275
  program.parseAsync(process.argv).catch((err) => {
270
276
  console.error(err && err.message ? err.message : err);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The per-developer off switch for the end-of-task receipt line.
3
+ *
4
+ * The line itself is composed by the server and travels back inside the mark_task_complete tool
5
+ * result, which the model prints wrapped in backticks. Nothing is stored locally to display it.
6
+ *
7
+ * This file used to be a letterbox: the bridge wrote each receipt to disk and a Claude Code Stop
8
+ * hook printed it. That worked, but Claude Code prefixes anything a Stop hook prints with
9
+ * "Stop says: …", which read as a system warning rather than a note from Niro. Printing from the
10
+ * model reads better and works in all twelve clients instead of one, so the hook, the letterbox and
11
+ * the terminal colouring all went with it.
12
+ *
13
+ * What remains is the switch. It stays local because it is a personal preference: the server serves
14
+ * a whole account and cannot know that one developer wants the line quiet. The bridge reads it and
15
+ * strips the receipt from the tool result, so the model never sees it and cannot print it.
16
+ */
17
+ const fs = require("fs");
18
+ const os = require("os");
19
+ const path = require("path");
20
+
21
+ /** ~/.niro, resolved the same way as the rest of the CLI (Windows has no HOME). */
22
+ function niroHome() {
23
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir() || "";
24
+ return path.join(home, ".niro");
25
+ }
26
+
27
+ /**
28
+ * Presence of this file switches the line off.
29
+ *
30
+ * A file rather than a key inside projects.json so that toggling it can never corrupt, or be
31
+ * corrupted by, the sync state the watch daemon depends on.
32
+ */
33
+ function disabledFlagPath() {
34
+ return path.join(niroHome(), "receipts-off");
35
+ }
36
+
37
+ function isDisabled() {
38
+ try {
39
+ return fs.existsSync(disabledFlagPath());
40
+ } catch {
41
+ // Cannot read the home directory: treat as ON. Failing to a silent state would make the
42
+ // feature look broken with nothing to explain why.
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /** Turn the line on or off. Returns true when the state actually changed. */
48
+ function setDisabled(disabled) {
49
+ const flag = disabledFlagPath();
50
+ try {
51
+ if (disabled) {
52
+ if (fs.existsSync(flag)) return false;
53
+ fs.mkdirSync(path.dirname(flag), { recursive: true, mode: 0o700 });
54
+ fs.writeFileSync(flag, "", { mode: 0o600 });
55
+ return true;
56
+ }
57
+ if (!fs.existsSync(flag)) return false;
58
+ fs.unlinkSync(flag);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Remove the off switch and any leftovers from the retired letterbox.
67
+ *
68
+ * Used by `niro mcp uninstall`, and worth keeping past the letterbox itself: anyone who ran a
69
+ * version that wrote receipts to disk still has that directory, and an uninstall that leaves files
70
+ * behind is not an uninstall.
71
+ */
72
+ function purge() {
73
+ try {
74
+ fs.rmSync(path.join(niroHome(), "receipts"), { recursive: true, force: true });
75
+ } catch {
76
+ /* best effort */
77
+ }
78
+ try {
79
+ fs.rmSync(disabledFlagPath(), { force: true });
80
+ } catch {
81
+ /* best effort */
82
+ }
83
+ }
84
+
85
+ module.exports = { disabledFlagPath, isDisabled, setDisabled, purge };
@@ -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
@@ -42,6 +42,7 @@ const readline = require("readline");
42
42
  const { execSync } = require("child_process");
43
43
  const niroProjectFile = require("../lib/niroProjectFile");
44
44
  const { redactRemoteCreds } = require("../lib/git");
45
+ const receiptStore = require("../lib/receiptStore");
45
46
 
46
47
  /** Absolute path to ~/.niro/projects.json. Uses os.homedir() so it also resolves on Windows, where HOME is
47
48
  * normally unset — `process.env.HOME || ""` there produced a relative path that never existed, silently
@@ -52,6 +53,32 @@ function niroProjectsFilePath() {
52
53
  }
53
54
  const { createPendingTracker, isTrackableRequest, resolveTimeoutMs } = require("../lib/pendingRequests");
54
55
 
56
+ /**
57
+ * This package's version, sent as X-Niro-Cli-Version so the server can tell an out-of-date install to
58
+ * upgrade. That notice is the only channel that reaches a user who installed long ago, because everything
59
+ * else niro writes (hooks, CLAUDE.md/AGENTS.md sections, rules files) is frozen on their disk at install
60
+ * time and nothing prompts them to re-run `niro mcp install`.
61
+ *
62
+ * Paired with the X-Niro-Bridge marker below, which is what actually identifies this bridge. An earlier
63
+ * version had the server treat a MISSING version as out-of-date, reasoning that the header and the check
64
+ * shipped together so its absence proved an older bridge. That was wrong: @niroai/mcp-server is a
65
+ * separate published bridge (a documented install path, and an internal dependency of two packages here)
66
+ * that connects to the same /mcp/sse endpoint and sends no such header. Those users would have been told
67
+ * to upgrade @niroai/niro, which they do not have installed, on every session, with no remedy.
68
+ *
69
+ * So the version is advisory and the marker is the gate. A read failure here must not stop the bridge
70
+ * from starting, hence the empty-string fallback rather than a throw; an empty value simply means the
71
+ * server cannot judge the version and says nothing.
72
+ */
73
+ const CLI_VERSION = (() => {
74
+ try {
75
+ return require("../../package.json").version || "";
76
+ } catch (e) {
77
+ process.stderr.write(`[niro-mcp] WARN: could not read own package version: ${e.message}\n`);
78
+ return "";
79
+ }
80
+ })();
81
+
55
82
  const API_KEY = process.env.NIRO_API_KEY;
56
83
 
57
84
  /**
@@ -410,6 +437,13 @@ function connectSse() {
410
437
  };
411
438
  if (CLI_TOKEN) sseHeaders["X-Niro-Cli-Token"] = CLI_TOKEN;
412
439
  if (API_KEY) sseHeaders["X-Niro-Api-Key"] = API_KEY;
440
+ // Sent on every session so the server can spot an out-of-date install (see CLI_VERSION). The
441
+ // marker is what identifies THIS bridge: @niroai/mcp-server is a separate published bridge that
442
+ // hits the same /mcp/sse endpoint and sends neither header, so the server must not infer "old
443
+ // niro CLI" from a missing version alone — it would tell those users to upgrade a package they do
444
+ // not have installed, on every session, with no way to stop it.
445
+ sseHeaders["X-Niro-Bridge"] = "niro-cli";
446
+ if (CLI_VERSION) sseHeaders["X-Niro-Cli-Version"] = CLI_VERSION;
413
447
  if (PROJECT_ID) {
414
448
  sseHeaders["X-Niro-Project-Id"] = PROJECT_ID;
415
449
  } else {
@@ -474,6 +508,7 @@ function connectSse() {
474
508
  // Swallow the response to an internally-replayed initialize (sentinel id) so the host
475
509
  // never sees a duplicate initialize result after a transparent reconnect.
476
510
  let swallowed = false;
511
+ let rewritten = null;
477
512
  try {
478
513
  const msg = JSON.parse(data);
479
514
  // ADR-018 handshake: only an initialize result carries capabilities. When the server
@@ -495,9 +530,12 @@ function connectSse() {
495
530
  } else {
496
531
  // Cancel the client-side deadline when a delivered result matches an in-flight request.
497
532
  pending.onIncoming(msg);
533
+ rewritten = captureReceipt(msg);
498
534
  }
499
535
  } catch (_) { /* not correlatable JSON — forward unchanged */ }
500
- if (!swallowed) process.stdout.write(data + "\n");
536
+ // captureReceipt returns a rewritten payload when it stripped the receipt field so the
537
+ // Stop hook can print it instead; otherwise the original bytes go through untouched.
538
+ if (!swallowed) process.stdout.write((rewritten || data) + "\n");
501
539
  }
502
540
  }
503
541
  });
@@ -629,6 +667,56 @@ function sendMessage(message) {
629
667
  });
630
668
  }
631
669
 
670
+ /**
671
+ * Stash the end-of-task receipt line, if this incoming message carries one.
672
+ *
673
+ * The server composes the line and returns it inside the mark_task_complete tool result. The model
674
+ * is asked to print it, but a model can forget an instruction; the Claude Code Stop hook prints it
675
+ * whether or not the model cooperates, and this is where it gets the text. See
676
+ * `docs/usecases/task-receipt.md`.
677
+ *
678
+ * Deliberately does NOT filter by tool name: the bridge does not track which request id belonged to
679
+ * which tool, and a `receipt` string in a result is unambiguous enough on its own. Any future tool
680
+ * that wants to say something at end of task gets this for free.
681
+ *
682
+ * Silent on every failure. This sits on the path that hands results back to the developer's
683
+ * assistant, so a malformed message, an unwritable home directory, or an unexpected shape must cost
684
+ * the receipt and nothing else.
685
+ */
686
+ function captureReceipt(msg) {
687
+ // Nothing to do unless the developer has switched the line off. The receipt is displayed by the
688
+ // model printing it from the tool result — there is no local copy and no hook any more.
689
+ if (!receiptStore.isDisabled()) return null;
690
+ try {
691
+ const content = msg && msg.result && msg.result.content;
692
+ if (!Array.isArray(content)) return null;
693
+ for (const item of content) {
694
+ if (!item || typeof item.text !== "string") continue;
695
+ // Cheap reject before parsing: tool results are frequently large, and almost none of them
696
+ // are the one message per task that carries a receipt.
697
+ if (!item.text.includes('"receipt"')) continue;
698
+ let parsed;
699
+ try {
700
+ parsed = JSON.parse(item.text);
701
+ } catch (_) {
702
+ continue; // not JSON — some other tool's prose that happens to contain the word
703
+ }
704
+ if (parsed && typeof parsed.receipt === "string") {
705
+ // Strip it here rather than asking the server not to send it. The switch is per developer
706
+ // and lives on this machine; the server serves a whole account and cannot know that one
707
+ // person wants it quiet.
708
+ delete parsed.receipt;
709
+ item.text = JSON.stringify(parsed);
710
+ return JSON.stringify(msg);
711
+ }
712
+ }
713
+ } catch (_) {
714
+ /* never let this disturb the message path */
715
+ }
716
+ return null;
717
+ }
718
+
719
+
632
720
  /**
633
721
  * Enrich an outgoing tools/call: send the folder's `.niro-project` pin as `_niroPin` (ADR-018 — a
634
722
  * SOURCE binding the server's routing ladder consumes, NOT an explicit project choice), and
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");
@@ -45,11 +46,11 @@ const NIRO_MD_MARKER = "<!-- niro-mcp -->";
45
46
  * Explore-first rule, shared by every client's markdown section. Prose alone is weak, but this is the
46
47
  * one lever some clients (Codex, Copilot CLI, Factory, OpenCode, Windsurf) have — they support no hooks,
47
48
  * so their ONLY always-on nudge is this file. For Claude Code it reinforces the PreToolUse Grep|Glob|Read
48
- * hook (NIRO_EXPLORE_COMMAND), which fires once per session/repo/branch; this stays in force for the rest
49
- * of the session. Wording is imperative and names concrete tools so the agent knows what to call instead
50
- * of native search.
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.
51
52
  */
52
- 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.";
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
54
 
54
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.";
55
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`;
@@ -126,6 +127,17 @@ function isNiroPreToolUseHook(h) {
126
127
  return cmds.some((c) => c.includes("should_create_temp_project") || c.includes("should_sandbox_repo"));
127
128
  }
128
129
 
130
+ /**
131
+ * True if `h` is niro's Stop hook (the end-of-task receipt). Keyed on the exact subcommand it runs,
132
+ * which no other tool would have a reason to invoke. Shared by installer and uninstaller.
133
+ */
134
+ function isNiroStopHook(h) {
135
+ const cmds = [];
136
+ if (h && Array.isArray(h.hooks)) h.hooks.forEach((i) => i && typeof i.command === "string" && cmds.push(i.command));
137
+ if (h && typeof h.command === "string") cmds.push(h.command);
138
+ return cmds.some((c) => c.includes("mcp receipt --hook"));
139
+ }
140
+
129
141
  /**
130
142
  * True if `h` is niro's PreToolUse explore-first reminder entry (matcher Grep|Glob|Read). Keyed on the
131
143
  * unique `niro-explore-` marker-file prefix so a foreign PreToolUse hook on the same matcher is never
@@ -564,6 +576,7 @@ async function setupClaudeCode(apiKey, mcpServerUrl) {
564
576
  addClaudeCodeUserPromptHook();
565
577
  addClaudeCodePreToolUseHook();
566
578
  addClaudeCodeExploreHook();
579
+ removeStaleNiroStopHook();
567
580
  addClaudeCodeMemory();
568
581
  addClaudeCodeSkill();
569
582
  addClaudeCodeCliSkill();
@@ -652,6 +665,31 @@ function addClaudeCodeMemory() {
652
665
  }
653
666
  }
654
667
 
668
+ /**
669
+ * The niro UserPromptSubmit hook command. Module-level so tests can execute the real string under
670
+ * /bin/sh: it is shell, so asserting on its text proves nothing about whether it runs, and this body
671
+ * shipped once with no execution coverage at all.
672
+ *
673
+ * Folder-gated: walks up from the project dir and only emits the reminder when a `.niro-project`
674
+ * marker exists (i.e. this tree is actually indexed). It keeps ONE conditional clause — "if the niro
675
+ * tools are not in your toolset at all" — because a shell hook cannot see which MCP tools Claude has
676
+ * loaded, so the reminder must stay correct when Niro is disabled or an A/B session exposes a subset.
677
+ * The old wording also let the agent excuse itself whenever it judged a query "out of scope", which is
678
+ * the escape hatch drift actually walks through; that clause is gone, and the legitimate native-tool
679
+ * cases are now named explicitly instead.
680
+ *
681
+ * Reads the prompt on stdin so it can add a temporary-project reminder when the message asks to
682
+ * create or switch a branch — the exact moment a repo's local state diverges from what Niro indexed
683
+ * (ADR-017). The branch match is a coarse keyword heuristic (a shell hook cannot call an LLM); a false
684
+ * positive only shows an extra harmless reminder, never gates behaviour.
685
+ */
686
+ const NIRO_USER_PROMPT_COMMAND =
687
+ "IN=\"$(cat)\"; d=\"${CLAUDE_PROJECT_DIR:-$PWD}\"; while [ -n \"$d\" ] && [ \"$d\" != \"/\" ]; do " +
688
+ "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.'; " +
689
+ "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' && " +
690
+ "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; " +
691
+ "d=\"$(dirname \"$d\")\"; done";
692
+
655
693
  /**
656
694
  * Adds a UserPromptSubmit hook to ~/.claude/settings.json so Claude is reminded
657
695
  * about Niro tools + the `niro` skill before every prompt. This is the lightweight,
@@ -660,21 +698,7 @@ function addClaudeCodeMemory() {
660
698
  */
661
699
  function addClaudeCodeUserPromptHook() {
662
700
  const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
663
- // Folder-gated: walks up from the project dir and only emits the reminder when a
664
- // `.niro-project` marker exists (i.e. this tree is actually indexed). The wording is
665
- // conditional ("IF the niro tools are present") because a shell hook cannot see which
666
- // MCP tools Claude has loaded — so it stays correct when Niro is disabled or an A/B
667
- // session exposes only a subset of niro tools.
668
- // Reads the prompt on stdin so it can add a SANDBOX reminder when the message asks to create/switch a
669
- // branch — the exact moment a repo's local state diverges from what Niro indexed (ADR-017). The branch
670
- // match is a coarse keyword heuristic (a shell hook can't call an LLM); a false positive only shows an
671
- // extra harmless reminder, never gates behaviour. Base reminder is unchanged and still `.niro-project`-gated.
672
- const hookCommand =
673
- "IN=\"$(cat)\"; d=\"${CLAUDE_PROJECT_DIR:-$PWD}\"; while [ -n \"$d\" ] && [ \"$d\" != \"/\" ]; do " +
674
- "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.'; " +
675
- "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' && " +
676
- "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; " +
677
- "d=\"$(dirname \"$d\")\"; done";
701
+ const hookCommand = NIRO_USER_PROMPT_COMMAND;
678
702
 
679
703
  let settings = {};
680
704
  try {
@@ -757,6 +781,47 @@ const NIRO_PRETOOL_COMMAND = [
757
781
  `printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"Niro temporary project check: you are editing a Niro-indexed repo on its CURRENT branch. The temporary project answer is per branch, so a check you ran on a different branch (e.g. before switching) does NOT count here. Call should_create_temp_project(git_url, branch=current branch, project_id from its .niro-project) for THIS repo now and run niro new-temp-project if it returns needs_temp_project. Shown once per repo per branch per session."}}'`,
758
782
  ].join('; ');
759
783
 
784
+ /**
785
+ * Removes a Stop hook left behind by an earlier version of this installer.
786
+ *
787
+ * Niro used to print the end-of-task receipt from a Claude Code Stop hook. It worked, but Claude
788
+ * Code labels anything a Stop hook prints with "Stop says: …", which read as a confusing system
789
+ * warning rather than a note from Niro. The receipt is now printed by the model itself, wrapped in
790
+ * backticks, which renders as code in every client rather than just this one.
791
+ *
792
+ * Anyone who installed the hook version still has it on disk, and hooks are frozen there until an
793
+ * install re-runs — so a plain upgrade would leave them seeing "Stop says:" for ever. Hence removing
794
+ * it here rather than only in uninstall. Other Stop hooks, including the developer's own, are
795
+ * matched by our exact subcommand and never touched.
796
+ */
797
+ function removeStaleNiroStopHook() {
798
+ const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
799
+ let settings;
800
+ try {
801
+ if (!fs.existsSync(settingsPath)) return;
802
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
803
+ } catch {
804
+ return; // unreadable or malformed — leave it alone rather than risk rewriting it
805
+ }
806
+ const stop = settings.hooks && settings.hooks.Stop;
807
+ if (!Array.isArray(stop)) return;
808
+
809
+ const kept = stop.filter((h) => !isNiroStopHook(h));
810
+ if (kept.length === stop.length) return;
811
+
812
+ settings.hooks.Stop = kept;
813
+ if (kept.length === 0) delete settings.hooks.Stop;
814
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
815
+
816
+ try {
817
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
818
+ console.log(" Removed the old Stop hook — the end-of-task line now comes from Niro directly,");
819
+ console.log(" without the \"Stop says:\" prefix Claude Code adds to hook output.\n");
820
+ } catch (err) {
821
+ console.warn(` Could not update ~/.claude/settings.json: ${err.message}`);
822
+ }
823
+ }
824
+
760
825
  /**
761
826
  * Adds the PreToolUse (Edit|Write) temporary project-reminder hook to ~/.claude/settings.json. Idempotent:
762
827
  * upgrades a stale niro entry in place, never touches a foreign PreToolUse hook (matched by the
@@ -805,38 +870,57 @@ function addClaudeCodePreToolUseHook() {
805
870
  * UserPromptSubmit hook, the niro skill) tell Claude to prefer niro tools for code exploration, but in
806
871
  * practice that only sticks when a task is phrased as a discovery question — a task phrased as "fix X" or
807
872
  * "add Y" skips straight to native search with no interruption. This hook is the deterministic backstop:
808
- * once per (session, repo, branch), the first native Grep/Glob/Read call in a niro-indexed repo gets an
809
- * advisory reminder to prefer niro's structural tools instead — regardless of how the task was phrased.
873
+ * EVERY native Grep/Glob/Read call in a niro-indexed repo gets an advisory reminder to prefer niro's
874
+ * structural tools instead — regardless of how the task was phrased, and regardless of how far into the
875
+ * session it happens.
876
+ *
877
+ * It used to dedupe to once per (session, repo, branch). That was the bug: the reminder landed on the
878
+ * first native search of a session and then went quiet, so a long session drifted straight back to grep
879
+ * with nothing to correct it. Repeating the nudge on every call costs about 99 tokens per native search
880
+ * (394 characters of additionalContext) and is the only thing that survives a 100-turn session. Note that
881
+ * Claude issues Reads in parallel batches, so one turn can carry several copies of that text.
810
882
  *
811
883
  * Dependency-free (no jq): reads the PreToolUse JSON on stdin. Read supplies `file_path`; Grep/Glob supply
812
- * `path` (often omitted, meaning cwd) — so this tries file_path, then path, then the hook's own `cwd`
813
- * field, then falls back to $PWD. Walks up for a `.niro-project` marker (silent outside indexed repos)
814
- * and dedupes via a marker file keyed by session_id + repo root + branch, same scheme as the Edit|Write
815
- * temp-project hook but under the distinct `niro-explore-` prefix so the two hooks never collide.
884
+ * `path` (often omitted, meaning cwd) — so this tries file_path, then path, then the payload's own `cwd`,
885
+ * then CLAUDE_PROJECT_DIR, then $PWD. Relative targets resolve against the payload `cwd`, symlinks are
886
+ * resolved with `cd -P`, and it then walks up for a `.niro-project` marker (silent outside indexed repos)
887
+ * without requiring a git checkout. Carries `niro-explore-` as an inert identity token so
888
+ * isNiroExploreHook still recognises it for upgrade and uninstall.
816
889
  */
817
890
  const NIRO_EXPLORE_COMMAND = [
818
891
  'IN="$(cat)"',
819
892
  // Read gives file_path; Grep/Glob give path (may be absent); fall back to cwd, then $PWD.
820
893
  `f="$(printf '%s' "$IN" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
821
894
  `[ -z "$f" ] && f="$(printf '%s' "$IN" | grep -oE '"path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
822
- `[ -z "$f" ] && f="$(printf '%s' "$IN" | grep -oE '"cwd"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
823
- '[ -z "$f" ] && f="$PWD"',
895
+ // The session's own directory, from the payload's top-level `cwd`. Serves two jobs: the base for a
896
+ // relative target path, and the fallback when the call names no path at all — which is the COMMON
897
+ // case, since Grep and Glob usually omit `path`. Resolving a relative path against the hook process's
898
+ // own $PWD instead silently misses the marker whenever the two differ; the payload states the right
899
+ // directory, so use it.
900
+ `c="$(printf '%s' "$IN" | grep -oE '"cwd"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
901
+ '[ -z "$c" ] && c="${CLAUDE_PROJECT_DIR:-$PWD}"',
902
+ '[ -z "$f" ] && f="$c"',
903
+ 'case "$f" in /*) ;; *) f="$c/$f" ;; esac',
824
904
  'd0="$f"; [ -d "$d0" ] || d0="$(dirname "$f")"',
825
- 'r="$(git -C "$d0" rev-parse --show-toplevel 2>/dev/null)"',
826
- '[ -z "$r" ] && exit 0',
827
- // only remind inside a niro-indexed tree
828
- 'd="$r"; ok=0; while [ -n "$d" ] && [ "$d" != "/" ]; do if [ -f "$d/.niro-project" ]; then ok=1; break; fi; d="$(dirname "$d")"; done',
905
+ // Resolve symlinks before walking up. The `git rev-parse --show-toplevel` this replaced did that for
906
+ // free, so removing it without this line reintroduced the very failure the change set out to kill,
907
+ // through another door: a Read through a symlinked subdirectory walks lexical parents that never hold
908
+ // the marker. Falls back to the lexical path when the directory does not exist yet (a Write creating
909
+ // a new folder).
910
+ `d0="$(cd -P "$d0" 2>/dev/null && pwd -P || printf '%s' "$d0")"`,
911
+ // Indexed-tree gate, git-independent on purpose: niro indexes plain folders too, so a repo can carry
912
+ // .niro-project with no .git at all. The previous `git rev-parse --show-toplevel` bail made this hook
913
+ // permanently silent in exactly those repos — no nudge, ever. Walk up from the path itself instead.
914
+ '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',
829
915
  '[ "$ok" = 0 ] && exit 0',
830
- `s="$(printf '%s' "$IN" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
831
- // Branch-aware dedup, same rationale as the Edit|Write hook: a stale answer from a different branch must not
832
- // silence the reminder after a branch switch.
833
- 'b="$(git -C "$r" rev-parse --abbrev-ref HEAD 2>/dev/null)"',
834
- `k="$(printf '%s\\n%s\\n%s' "$s" "$r" "$b" | cksum | cut -d' ' -f1)"`,
835
- 'm="${TMPDIR:-/tmp}/niro-explore-$k"',
836
- '[ -f "$m" ] && exit 0',
837
- 'touch "$m"',
916
+ // No once-per-session marker file, deliberately: this fires on EVERY native search. The drift it exists
917
+ // to stop happens late in a session, long after a one-shot reminder at turn 4 has scrolled out of
918
+ // attention — dedup was the reason the hook stopped working, not a cost saving worth keeping.
919
+ // `niro-explore-` stays verbatim as this hook's identity token: isNiroExploreHook and teardown match on
920
+ // it, so dropping the string would orphan the hook on upgrade and on uninstall.
921
+ 'NIRO_HOOK_ID=niro-explore-alwayson',
838
922
  // Advisory context injection ONLY — no permissionDecision, so it never blocks the read/search.
839
- `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."}}'`,
923
+ `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."}}'`,
840
924
  ].join('; ');
841
925
 
842
926
  /**
@@ -873,8 +957,8 @@ function addClaudeCodeExploreHook() {
873
957
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
874
958
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
875
959
  console.log(` PreToolUse explore-first hook ${hadStale ? "upgraded" : "added"} in ~/.claude/settings.json`);
876
- console.log(" → Before the first native Grep/Glob/Read in a niro-indexed repo, Claude is reminded once/session/branch");
877
- console.log(" to prefer niro tools — even mid-task, not just for standalone discovery questions.\n");
960
+ console.log(" → On every native Grep/Glob/Read in a niro-indexed repo, Claude is reminded to prefer");
961
+ console.log(" niro tools — even mid-task and late in a long session, not just for discovery questions.\n");
878
962
  } catch (err) {
879
963
  console.warn(` Could not write ~/.claude/settings.json: ${err.message}`);
880
964
  console.warn(" Add manually to the PreToolUse array in ~/.claude/settings.json:\n");
@@ -1396,53 +1480,222 @@ async function setupCodex(apiKey, mcpServerUrl) {
1396
1480
 
1397
1481
  /**
1398
1482
  * After `codex mcp add` writes [mcp_servers.niro], set
1399
- * `default_tools_approval_mode = "auto"` — niro tools are all read-only, so no per-tool
1400
- * approval prompts (codex mcp add has no flag for this; we insert it directly after the
1401
- * table header, which is valid TOML — bare keys precede any sub-table). Idempotent and a
1402
- * safe no-op if the file/table is missing. Also prints the optional `required = true`
1403
- * recommendation (left off by default: it would make niro a hard Codex startup dependency).
1483
+ * `default_tools_approval_mode = "writes"` — prompt only for tools that write, and niro writes
1484
+ * nothing, so nothing prompts.
1485
+ *
1486
+ * It was "auto" until a developer reported being asked to approve every single call. Codex accepts
1487
+ * four values (`auto`, `prompt`, `writes`, `approve` — confirmed from its own validation error on a
1488
+ * bad one), and "auto" means Codex decides for itself. It decides to ask. "writes" states the fact
1489
+ * about niro that makes asking unnecessary, rather than delegating the judgement.
1490
+ *
1491
+ * Inserted directly after the table header, which is valid TOML — bare keys precede any sub-table.
1492
+ * Idempotent and a safe no-op if the file or table is missing. Also prints the optional
1493
+ * `required = true` recommendation (left off by default: it would make niro a hard Codex startup
1494
+ * dependency).
1404
1495
  */
1405
- function hardenCodexServerConfig() {
1406
- const cfgPath = path.join(process.env.HOME || "", ".codex", "config.toml");
1407
- const header = "[mcp_servers.niro]";
1496
+ /**
1497
+ * Write ~/.codex/config.toml, then prove Codex can still load it — rolling back if it cannot.
1498
+ *
1499
+ * This file holds [mcp_servers.niro] itself, so a malformed edit does not merely fail: it makes
1500
+ * Codex reject the whole file and takes the developer's Niro registration down with it. That is the
1501
+ * failure mode that got a Codex hook built and pulled twice (ae7bdaa, dcc4f7f), and the lesson from
1502
+ * it was that every write here needs a verify-and-rollback rather than care alone.
1503
+ *
1504
+ * Atomic temp-then-rename, with the original file mode preserved. Widening permissions matters:
1505
+ * this file can hold API keys, and an earlier version of the pulled work reset 0600 to 0644 on
1506
+ * rollback.
1507
+ *
1508
+ * Verification runs the real binary. `codex mcp get niro` parses the config and prints the server,
1509
+ * so a non-zero exit means Codex would not load what we just wrote. If the binary is missing we
1510
+ * cannot verify, and the write is skipped entirely — an unverifiable edit to this file is not worth
1511
+ * the risk it carries.
1512
+ */
1513
+ function writeCodexConfigVerified(cfgPath, contents) {
1514
+ const { execSync } = require("child_process");
1515
+
1516
+ try {
1517
+ execSync("command -v codex >/dev/null 2>&1", { stdio: "ignore", shell: "/bin/sh" });
1518
+ } catch {
1519
+ throw new Error("codex is not on PATH, so the edit cannot be verified — leaving the file alone");
1520
+ }
1408
1521
 
1522
+ const previous = fs.readFileSync(cfgPath, "utf-8");
1523
+ let mode = 0o600;
1524
+ try {
1525
+ mode = fs.statSync(cfgPath).mode & 0o777;
1526
+ } catch {
1527
+ /* fall back to the conservative default above */
1528
+ }
1529
+
1530
+ const tmp = `${cfgPath}.${process.pid}.tmp`;
1531
+ fs.writeFileSync(tmp, contents, { mode });
1532
+ fs.renameSync(tmp, cfgPath);
1533
+
1534
+ try {
1535
+ execSync("codex mcp get niro", { stdio: "ignore", timeout: 15000 });
1536
+ } catch (err) {
1537
+ // Put it back exactly as it was, mode included, then report.
1538
+ const rollback = `${cfgPath}.${process.pid}.rollback`;
1539
+ fs.writeFileSync(rollback, previous, { mode });
1540
+ fs.renameSync(rollback, cfgPath);
1541
+ throw new Error("Codex could not load the edited config — rolled back, nothing changed");
1542
+ }
1543
+ }
1544
+
1545
+ /**
1546
+ * Remove [mcp_servers.niro.tools.*] approval overrides from ~/.codex/config.toml.
1547
+ *
1548
+ * Codex writes one of these each time a developer answers a tool prompt with "approve once", and a
1549
+ * per-tool setting overrides default_tools_approval_mode. The result is a config that looks correctly
1550
+ * configured and prompts on every single call.
1551
+ *
1552
+ * Only niro's own sub-tables are touched, matched by their full prefix. This is the file that holds
1553
+ * [mcp_servers.niro] itself, so a careless edit costs the developer their whole Niro registration —
1554
+ * the reason a Codex hook was built and pulled twice (ae7bdaa, dcc4f7f). Hence: read, rewrite in
1555
+ * memory, and only write when the result still parses as something Codex will load.
1556
+ *
1557
+ * Returns how many tables were removed; 0 on any failure, having changed nothing.
1558
+ */
1559
+ function clearCodexPerToolApprovals(cfgPath) {
1409
1560
  let toml;
1410
1561
  try {
1411
1562
  toml = fs.readFileSync(cfgPath, "utf-8");
1412
1563
  } catch {
1413
- console.warn(" Could not read ~/.codex/config.toml to set the approval mode — skipping.\n");
1414
- return;
1564
+ return 0;
1415
1565
  }
1416
1566
 
1417
- const idx = toml.indexOf(header);
1418
- if (idx === -1) {
1419
- console.warn(" [mcp_servers.niro] not found in config.toml — skipping approval-mode hardening.\n");
1420
- return;
1567
+ // A table runs from its header to the next header at any level, or end of file.
1568
+ const pattern = /\n\[mcp_servers\.niro\.tools\.[^\]]+\]\n(?:(?!\n?\[)[^\n]*\n)*/g;
1569
+ const matches = toml.match(pattern);
1570
+ if (!matches || matches.length === 0) return 0;
1571
+
1572
+ const rewritten = toml.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n");
1573
+
1574
+ // Refuse to write anything that lost the niro server itself. Cheap, and the failure it guards
1575
+ // against is the one that matters.
1576
+ if (!rewritten.includes("[mcp_servers.niro]")) {
1577
+ console.warn(" Skipped clearing Codex tool approvals — the edit would have dropped [mcp_servers.niro].\n");
1578
+ return 0;
1421
1579
  }
1422
1580
 
1423
- // Limit the "already set?" check to the niro table body (up to the next table header).
1424
- const after = toml.slice(idx + header.length);
1425
- const nextTableRel = after.search(/\n\s*\[/);
1426
- const tableBody = nextTableRel === -1 ? after : after.slice(0, nextTableRel);
1581
+ try {
1582
+ writeCodexConfigVerified(cfgPath, rewritten);
1583
+ } catch (err) {
1584
+ console.warn(` Could not clear Codex per-tool approvals: ${err.message}\n`);
1585
+ return 0;
1586
+ }
1587
+
1588
+ console.log(` Cleared ${matches.length} per-tool approval override${matches.length === 1 ? "" : "s"} for niro.`);
1589
+ console.log(" → those beat default_tools_approval_mode and made every call prompt.\n");
1590
+ return matches.length;
1591
+ }
1592
+
1593
+ /**
1594
+ * Absolute path to Codex's config.toml, honouring CODEX_HOME.
1595
+ *
1596
+ * `codex mcp add` writes wherever CODEX_HOME points, so reading from a hard-coded ~/.codex would
1597
+ * edit a file Codex never loads while reporting success — the server registered in one place and the
1598
+ * approval mode set in another.
1599
+ *
1600
+ * Returns null rather than a relative path when no home resolves. userHome() can be "", and
1601
+ * path.join("", ".codex", "config.toml") yields ".codex/config.toml" relative to the current working
1602
+ * directory, which on some machines is a git-tracked project folder.
1603
+ */
1604
+ function codexConfigPath() {
1605
+ const codexHome = process.env.CODEX_HOME && process.env.CODEX_HOME.trim();
1606
+ if (codexHome) return path.join(codexHome, "config.toml");
1607
+ const home = userHome();
1608
+ if (!home) return null;
1609
+ return path.join(home, ".codex", "config.toml");
1610
+ }
1611
+
1612
+ function hardenCodexServerConfig() {
1613
+ const cfgPath = codexConfigPath();
1614
+ if (!cfgPath) {
1615
+ console.warn(" Could not resolve ~/.codex/config.toml — skipping approval-mode hardening.\n");
1616
+ return;
1617
+ }
1427
1618
 
1428
1619
  console.log(" Tip: for a hard dependency, add `required = true` under [mcp_servers.niro]");
1429
1620
  console.log(" (Codex then fails startup if niro is unreachable — off by default).\n");
1430
1621
 
1431
- if (/^\s*default_tools_approval_mode\s*=/m.test(tableBody)) {
1432
- console.log(" Codex approval mode already set — skipping.\n");
1622
+ // Clear per-tool overrides FIRST. Codex writes a [mcp_servers.niro.tools.<name>] table with
1623
+ // approval_mode = "approve" whenever the developer answers a prompt with "approve once" rather
1624
+ // than "always allow", and a per-tool setting BEATS default_tools_approval_mode. So the default
1625
+ // below can be correct while every call still prompts — exactly what a developer reported.
1626
+ //
1627
+ // This rewrites the file, so everything after it must work from freshly read contents. An earlier
1628
+ // version computed byte offsets BEFORE this call and spliced with them afterwards, which inserted
1629
+ // text into the middle of an unrelated line whenever the rewrite shortened anything above the niro
1630
+ // header. That produced invalid TOML, and Codex refuses to load a config it cannot parse — taking
1631
+ // [mcp_servers.niro] and every other server's credentials down with it.
1632
+ clearCodexPerToolApprovals(cfgPath);
1633
+
1634
+ const located = locateCodexNiroTable(cfgPath);
1635
+ if (!located) {
1636
+ console.warn(" [mcp_servers.niro] not found in config.toml — skipping approval-mode hardening.\n");
1433
1637
  return;
1434
1638
  }
1639
+ const { toml, bodyStart, bodyEnd, body } = located;
1640
+
1641
+ const existing = body.match(/^[ \t]*default_tools_approval_mode[ \t]*=[ \t]*"([^"]*)"/m);
1642
+
1643
+ if (existing && existing[1] !== "auto") {
1644
+ console.log(` Codex approval mode already set to "${existing[1]}" — leaving your choice alone.\n`);
1645
+ return;
1646
+ }
1647
+
1648
+ // Every edit below is confined to niro's own table slice. Replacing across the whole file with a
1649
+ // single /m regex rewrote the FIRST matching line anywhere in it — which, since `codex mcp add`
1650
+ // appends niro last, was reliably ANOTHER server's line. That silently relaxed a third party's
1651
+ // approval settings and left niro's untouched, while printing that it had succeeded.
1652
+ let newBody;
1653
+ let message;
1654
+ if (existing) {
1655
+ newBody = body.replace(/^([ \t]*default_tools_approval_mode[ \t]*=[ \t]*)"auto"/m, '$1"writes"');
1656
+ message = [' Changed Codex approval mode from "auto" to "writes".',
1657
+ ' -> "auto" let Codex decide, and it decided to ask on every call.'];
1658
+ } else {
1659
+ // A bare key must precede any sub-table, so it goes at the very top of the body.
1660
+ newBody = '\ndefault_tools_approval_mode = "writes"' + body;
1661
+ message = [' Set default_tools_approval_mode = "writes" in ~/.codex/config.toml',
1662
+ " -> niro is read-only, so its tools run without per-call approval prompts."];
1663
+ }
1435
1664
 
1436
- const insertAt = idx + header.length;
1437
- const newToml = toml.slice(0, insertAt) + '\ndefault_tools_approval_mode = "auto"' + toml.slice(insertAt);
1665
+ if (newBody === body) return; // nothing to do
1438
1666
 
1439
1667
  try {
1440
- fs.writeFileSync(cfgPath, newToml);
1441
- console.log(' Set default_tools_approval_mode = "auto" in ~/.codex/config.toml');
1442
- console.log(" → niro is read-only, so its tools run without per-call approval prompts.\n");
1668
+ writeCodexConfigVerified(cfgPath, toml.slice(0, bodyStart) + newBody + toml.slice(bodyEnd));
1669
+ message.forEach((line) => console.log(line));
1670
+ console.log("");
1443
1671
  } catch (err) {
1444
- console.warn(` Could not update ~/.codex/config.toml: ${err.message}\n`);
1672
+ console.warn(` Could not update the Codex approval mode: ${err.message}\n`);
1673
+ }
1674
+ }
1675
+
1676
+ /**
1677
+ * Locate [mcp_servers.niro]'s body in the CURRENT contents of config.toml.
1678
+ *
1679
+ * Returns the whole file plus the byte range of the table body (everything after the header, up to
1680
+ * the next table header at any level). Read fresh on every call: any offset computed before another
1681
+ * function rewrote the file is a splice into the wrong place waiting to happen.
1682
+ */
1683
+ function locateCodexNiroTable(cfgPath) {
1684
+ const header = "[mcp_servers.niro]";
1685
+ let toml;
1686
+ try {
1687
+ toml = fs.readFileSync(cfgPath, "utf-8");
1688
+ } catch {
1689
+ return null;
1445
1690
  }
1691
+ const idx = toml.indexOf(header);
1692
+ if (idx === -1) return null;
1693
+
1694
+ const bodyStart = idx + header.length;
1695
+ const after = toml.slice(bodyStart);
1696
+ const nextTableRel = after.search(/\n[ \t]*\[/);
1697
+ const bodyEnd = nextTableRel === -1 ? toml.length : bodyStart + nextTableRel;
1698
+ return { toml, bodyStart, bodyEnd, body: toml.slice(bodyStart, bodyEnd) };
1446
1699
  }
1447
1700
 
1448
1701
  /**
@@ -2087,7 +2340,6 @@ module.exports = {
2087
2340
  addClaudeCodeCliSkill,
2088
2341
  addAgentsSkill,
2089
2342
  addAgentsCliSkill,
2090
- // Shared with teardown.js so uninstall removes exactly what install wrote.
2091
2343
  CLIENTS,
2092
2344
  NIRO_MD_MARKER,
2093
2345
  NIRO_MD_SECTIONS,
@@ -2095,7 +2347,15 @@ module.exports = {
2095
2347
  isNiroUserPromptHook,
2096
2348
  isNiroPreToolUseHook,
2097
2349
  isNiroExploreHook,
2350
+ isNiroStopHook,
2351
+ clearCodexPerToolApprovals,
2352
+ hardenCodexServerConfig,
2098
2353
  userHome,
2354
+ // Exposed for tests: the hook bodies are shell, so the only way to prove they still fire (and still
2355
+ // carry their identity token) is to execute the real string.
2356
+ NIRO_EXPLORE_COMMAND,
2357
+ NIRO_PRETOOL_COMMAND,
2358
+ NIRO_USER_PROMPT_COMMAND,
2099
2359
  // Exposed for content regression tests (e.g. the cross-project routing guidance).
2100
2360
  NIRO_SKILL,
2101
2361
  NIRO_CLI_SKILL,
@@ -6,7 +6,8 @@
6
6
  * `niro mcp uninstall` removes everything `niro mcp install` wrote, per editor:
7
7
  * - the MCP server registration (claude/codex CLI, or the editor's JSON config)
8
8
  * - the niro hooks in ~/.claude/settings.json (PostCompact + UserPromptSubmit + PreToolUse: both the
9
- * Edit|Write temp-project reminder and the Grep|Glob|Read explore-first reminder)
9
+ * Edit|Write temp-project reminder and the Grep|Glob|Read explore-first reminder + Stop: the
10
+ * end-of-task receipt line), and any receipts still pending on disk
10
11
  * - the niro section in markdown config (CLAUDE.md / AGENTS.md / Windsurf rules)
11
12
  * - the generated skills (~/.claude/skills, the shared ~/.agents/skills)
12
13
  * - Cursor's "niro:*" allowlist entry and project rule
@@ -36,6 +37,7 @@ const {
36
37
  CLIENTS,
37
38
  NIRO_MD_MARKER,
38
39
  NIRO_MD_SECTIONS,
40
+ isNiroStopHook,
39
41
  isNiroPostCompactHook,
40
42
  isNiroUserPromptHook,
41
43
  isNiroPreToolUseHook,
@@ -154,6 +156,12 @@ function filterNiroHooks(settings) {
154
156
  removed += before - hooks.PreToolUse.length;
155
157
  if (hooks.PreToolUse.length === 0) delete hooks.PreToolUse;
156
158
  }
159
+ if (Array.isArray(hooks.Stop)) {
160
+ const before = hooks.Stop.length;
161
+ hooks.Stop = hooks.Stop.filter((h) => !isNiroStopHook(h));
162
+ removed += before - hooks.Stop.length;
163
+ if (hooks.Stop.length === 0) delete hooks.Stop;
164
+ }
157
165
  if (Object.keys(hooks).length === 0) delete settings.hooks;
158
166
 
159
167
  return { settings, removed };
@@ -375,6 +383,14 @@ function uninstallClaudeCode() {
375
383
  const home = userHome();
376
384
  runMcpRemove("claude", ["mcp", "remove", "niro", "--scope", "user"]);
377
385
  stripNiroHooksFromSettings(path.join(home, ".claude", "settings.json"));
386
+ // Claude Code is the only client with the Stop hook, so its pending receipts have nothing left to
387
+ // read them. Leaving them behind would mean a reinstall months later opens with a line describing
388
+ // a task the developer has long forgotten.
389
+ try {
390
+ require("../lib/receiptStore").purge();
391
+ } catch {
392
+ /* best effort — never block an uninstall on cleaning up a cache */
393
+ }
378
394
  stripMarkerFromFile(path.join(home, ".claude", "CLAUDE.md"), "Claude Code memory");
379
395
  deleteFileIfPresent(path.join(home, ".claude", "skills", "niro-skill", "SKILL.md"), "niro skill");
380
396
  deleteFileIfPresent(path.join(home, ".claude", "skills", "niro-cli", "SKILL.md"), "niro-cli skill");