@niroai/niro 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Undoing a takeover locally: point a folder's registration back at the source project.
3
+ *
4
+ * This is the LOCAL half of discarding a temporary project. The server half (deleting the copy) is
5
+ * `niro discard-temp-project`; the sweep that finds copies already gone from the server is
6
+ * `niro cleanup-temp-projects`. Both end in exactly this loop, so it lives here rather than in
7
+ * either command. Copying it would be the worst option available: the two comments below record
8
+ * incidents that cost silent data loss, and a second copy is a second place for that reasoning to
9
+ * rot out of step with the first.
10
+ *
11
+ * It never contacts the server and it never deletes a registration. A folder whose temporary project
12
+ * is gone still needs to be synced to SOMETHING, so it is moved back to the source project rather
13
+ * than unregistered.
14
+ */
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+
18
+ const niroProjectFile = require("./niroProjectFile");
19
+ const watchState = require("./watchState");
20
+
21
+ /**
22
+ * Order a group so that folders carrying a takeover backup are written LAST.
23
+ *
24
+ * The backup is the only thing on this machine that says "this project id is a temporary project".
25
+ * Restoring a folder spends its backup, so if the one folder holding the group's backup is written
26
+ * first and the run then stops (Ctrl-C, a closed lid, a full disk), the rest of the group becomes
27
+ * invisible: `niro cleanup-temp-projects` no longer recognises the id as a temporary project and
28
+ * reports "nothing to clean up", while `niro discard-temp-project` refuses because no folder records
29
+ * what the copy was made from. The only repair left is editing the JSON by hand.
30
+ *
31
+ * Writing the witnesses last means an interrupted run always leaves at least one behind, so the next
32
+ * run finds the group and finishes the job. Order within each half is untouched.
33
+ */
34
+ function witnessesLast(folders) {
35
+ const withoutBackup = folders.filter((e) => !(e.takeover && e.takeover.sourceProjectId));
36
+ const withBackup = folders.filter((e) => e.takeover && e.takeover.sourceProjectId);
37
+ return [...withoutBackup, ...withBackup];
38
+ }
39
+
40
+ /**
41
+ * Move every folder in `folders` off `tempProjectId` and back onto its source project.
42
+ *
43
+ * @param {Array<object>} folders watchState entries (each with `folderPath`), all registered
44
+ * against the same temporary project.
45
+ * @param {object} opts
46
+ * @param {string} opts.tempProjectId the temporary project being undone.
47
+ * @param {string} opts.cloneOf the source project id, used for any folder whose own
48
+ * takeover backup does not name one.
49
+ * @param {string} opts.baseUrl backend to tag an entry with when it carries no apiUrl.
50
+ * @param {boolean} opts.healPins write/delete an old-style swapped `.niro-project`. TRUE for
51
+ * an interactive discard, FALSE for the unattended sweep — it
52
+ * touches a git-tracked file in the developer's repo.
53
+ * @returns {{ repinned: string[], healed: Array<{folder: string, to: string}>,
54
+ * failed: Array<{folder: string, error: string}>,
55
+ * skipped: Array<{folder: string, nowGitId: string|null}>,
56
+ * untracked: string[] }}
57
+ */
58
+ function restoreFolders(folders, { tempProjectId, cloneOf, baseUrl, healPins = true } = {}) {
59
+ // ADR-018: the .niro-project file is left ALONE unless it still carries the OLD-STYLE swapped pin
60
+ // (pre-ADR-018 takeover) — then it points at a now-deleted project and must be healed: previousPin
61
+ // verbatim when recorded; explicit "no pin existed" → delete the file; otherwise the source project
62
+ // id. New-style folders (pin untouched) skip all of this.
63
+ const repinned = [];
64
+ const healed = [];
65
+ const failed = [];
66
+ const skipped = [];
67
+ const untracked = [];
68
+ for (const e of witnessesLast(folders)) {
69
+ try {
70
+ const backup = e.takeover || {};
71
+ const sourceProjectId = backup.sourceProjectId || cloneOf;
72
+ const entryDir = path.resolve(e.folderPath);
73
+
74
+ // Compare-and-write, exactly as watchState.remove does its compare-and-delete, and for the
75
+ // reason its own comment gives: a folder can be re-pointed by ANOTHER PROCESS between the read
76
+ // that produced `e` and this write. The sweep makes that window wide, because it reads the whole
77
+ // registry once and then does a server round-trip per temporary project before writing anything.
78
+ //
79
+ // What it costs to get wrong is not a stale row. A `niro new-temp-project` landing mid-sweep
80
+ // would be overwritten: its brand new copy is then named by no entry at all, so discard cannot
81
+ // find it and a later sweep cannot see it, AND the folder is left pointing at the SHARED source
82
+ // project's repo with an empty baseline — so the next daemon pass uploads the private
83
+ // feature-branch tree into the project the whole team reads.
84
+ //
85
+ // A missing entry is deliberately NOT a mismatch. `niro discard-temp-project --project <id>` can
86
+ // be run in a folder that has no registration yet, and creating one there is existing, intended
87
+ // behaviour. Only a DIFFERENT gitId means somebody re-pointed this folder under us.
88
+ const current = watchState.peek(e.folderPath);
89
+ if (current && current.gitId !== e.gitId) {
90
+ skipped.push({ folder: e.folderPath, nowGitId: current.gitId || null });
91
+ continue;
92
+ }
93
+
94
+ // healPins is FALSE for the unattended sweep, and that is the whole point of the option. This
95
+ // block writes and DELETES `.niro-project`, a file tracked in the developer's git repository. It
96
+ // is right for `niro discard-temp-project`, which a person just ran in that folder; it is not
97
+ // something a six-hourly background job should do. A fourth review found the sweep reaching it —
98
+ // through this module — while a test and four documents claimed pins were never touched.
99
+ if (healPins && fs.existsSync(entryDir)) {
100
+ const pin = niroProjectFile.read(entryDir);
101
+ const pinIsSwapped = pin && pin.id === tempProjectId
102
+ && path.dirname(pin.source) === entryDir; // only heal THIS folder's own pin, never a parent's
103
+ if (pinIsSwapped) {
104
+ const recordedNoPin = ("previousPin" in backup) && backup.previousPin === null;
105
+ if (backup.previousPin) {
106
+ niroProjectFile.write(entryDir, backup.previousPin, { overwrite: true });
107
+ healed.push({ folder: e.folderPath, to: backup.previousPin });
108
+ } else if (recordedNoPin) {
109
+ niroProjectFile.remove(entryDir);
110
+ healed.push({ folder: e.folderPath, to: "(no pin — removed)" });
111
+ } else {
112
+ niroProjectFile.write(entryDir, sourceProjectId, { overwrite: true });
113
+ healed.push({ folder: e.folderPath, to: sourceProjectId });
114
+ }
115
+ }
116
+ }
117
+
118
+ // Watch/daemon registration goes back to the source project regardless of pin style.
119
+ watchState.set(e.folderPath, {
120
+ ...e,
121
+ gitId: backup.previousGitId || null,
122
+ projectId: sourceProjectId,
123
+ // The baseline describes what the TEMPORARY project holds, so it MUST NOT survive the move
124
+ // back to the source repo. Carrying it across told the daemon the source already had every
125
+ // file at its sandbox-era fingerprint — which it did not, because those edits only ever went
126
+ // to the temporary copy. The next pass then compared disk against that baseline, found no
127
+ // difference, and uploaded nothing. Every edit made during the temporary project was silently
128
+ // lost to the source project, with no error and nothing to prompt a re-sync.
129
+ //
130
+ // Clearing it makes the next pass treat every file as new and upload it. That is a full
131
+ // re-upload once, which is the cost of being sure: the client cannot know which files the
132
+ // source repo is missing, so the only safe assumption is all of them.
133
+ //
134
+ // The opposite direction already gets this right — takeover.js seeds fileHashes with what it
135
+ // actually uploaded to the new gitId. A baseline belongs to ONE gitId; when that changes, so
136
+ // must it.
137
+ fileHashes: {},
138
+ lastSyncedAt: null,
139
+ takeover: undefined,
140
+ }, e.apiUrl || baseUrl);
141
+ repinned.push(e.folderPath);
142
+ // No repo id to go back to means this folder had no local registration BEFORE it was taken over
143
+ // (takeover.js records `previousGitId: existing ? existing.gitId : null`). Restoring null is
144
+ // therefore correct — it is the state the folder was in before — but the entry is now inert:
145
+ // watchState.allLocal filters on gitId, so the daemon stops listing it. Callers report these
146
+ // separately rather than counting them as "repaired and syncing", because the honest sentence is
147
+ // "back where it was, and not locally tracked", not "fixed".
148
+ if (!backup.previousGitId) untracked.push(e.folderPath);
149
+ } catch (err) {
150
+ failed.push({ folder: e.folderPath, error: err.message });
151
+ }
152
+ }
153
+ return { repinned, healed, failed, skipped, untracked };
154
+ }
155
+
156
+ module.exports = { restoreFolders, witnessesLast };
package/src/mcp/setup.js CHANGED
@@ -53,16 +53,27 @@ const NIRO_MD_MARKER = "<!-- niro-mcp -->";
53
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
54
 
55
55
  const NIRO_MULTI_REPO_SENTENCE = "The temporary project answer is per (repo, BRANCH): after you create/switch a branch in a repo — and before your first edit on that branch — call should_create_temp_project with the repo's git url, its CURRENT branch, and its `.niro-project` id if present, then run `niro new-temp-project` in its folder if needs_temp_project is true. Re-check after every branch change (a `false` from another branch does NOT carry over); this applies to every repo you touch, including when the session started in a parent folder that is not itself a git repo.";
56
- 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`;
56
+ /**
57
+ * Impact-both-ways rule, shared by every client's markdown section. The explore-first sentence above
58
+ * governs READING code; this one governs CHANGING it. The failure it exists to stop is an agent that
59
+ * reads the one file it was pointed at, proposes an edit, and never learns which callers that edit
60
+ * breaks or which dependency constrains it. get_blast_radius is named first because it answers BOTH
61
+ * sides in a single call, so the rule costs one tool call and skipping it costs a broken caller nobody
62
+ * looked for. It is worded "before you plan or propose", not "before you edit", because once the plan
63
+ * is agreed the wrong basis is already baked in; and it says the size of the diff does not excuse it,
64
+ * because a one-line change to shared code reaches further than a rewrite of a standalone tool.
65
+ */
66
+ const NIRO_IMPACT_BOTH_WAYS_SENTENCE = "MUST FOLLOW: before you plan, propose, or start any change to existing code, establish BOTH sides of it. Upstream: what depends on the code you are about to touch, including callers in other services over HTTP or events, and the tests and config that name it. Downstream: what that code itself calls, and the data and events it relies on. Use get_blast_radius, which returns callers, callees, cross-service callers and impacted files in one call, or get_impact_analysis for the lighter upstream-only view. Put the result in the plan you show the user: name the callers you found, say which ones you will update, and say which ones you are deliberately leaving alone. This applies to a one-line change too, because how far a change reaches is decided by what depends on it, not by the size of the diff.";
67
+ const NIRO_CLAUDE_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP tools are available for code intelligence. Use them (call chains, blast radius, impact analysis, source lookup) before falling back to native grep/read for code questions.\nBefore adding/writing any new code, you MUST use the find_reusable_code tool from niro to figure out if there is anything reusable.\n${NIRO_EXPLORE_FIRST_SENTENCE}\n${NIRO_IMPACT_BOTH_WAYS_SENTENCE}\n${NIRO_MULTI_REPO_SENTENCE}\n`;
68
+ const NIRO_CODEX_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools and the \`niro-skill\` are available (call chains, blast radius, impact analysis, source lookup). Prefer them over grep/read for questions about this codebase; the niro-skill has the full tool-routing playbook. Before adding or writing any new code, use find_reusable_code to check for reusable code. If the niro tools are not present or the repo is not indexed, use native tools.\n${NIRO_EXPLORE_FIRST_SENTENCE}\n${NIRO_IMPACT_BOTH_WAYS_SENTENCE}\n${NIRO_MULTI_REPO_SENTENCE}\n`;
69
+ const NIRO_WINDSURF_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools and the niro-skill are available. Prefer them over grep/read for questions about this codebase (structure, call chains, blast radius, impact, data flows, reuse); the niro-skill has the full routing playbook. Before writing any new code, use find_reusable_code. If the niro tools are not present or the repo is not indexed, use native tools.\n${NIRO_EXPLORE_FIRST_SENTENCE}\n${NIRO_IMPACT_BOTH_WAYS_SENTENCE}\n${NIRO_MULTI_REPO_SENTENCE}\n`;
59
70
  /**
60
71
  * Shared generic section for clients whose "always-on instructions" file has no companion
61
72
  * skill mechanism we've verified (Gemini CLI's GEMINI.md, GitHub Copilot CLI's
62
73
  * copilot-instructions.md, Factory's AGENTS.md, OpenCode's AGENTS.md) — one wording, reused
63
74
  * across those destination files (teardown matches it per-file, so reuse is safe).
64
75
  */
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`;
76
+ 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_IMPACT_BOTH_WAYS_SENTENCE}\n${NIRO_MULTI_REPO_SENTENCE}\n`;
66
77
 
67
78
  /** All niro markdown sections — the uninstaller tries each for an exact removal. */
68
79
  const NIRO_MD_SECTIONS = [NIRO_CLAUDE_MD_SECTION, NIRO_CODEX_MD_SECTION, NIRO_WINDSURF_MD_SECTION, NIRO_GENERIC_MD_SECTION];
@@ -620,9 +631,57 @@ async function setupClaudeCode(apiKey, mcpServerUrl) {
620
631
  * about Niro tools after context compaction (the point where tool awareness
621
632
  * most commonly degrades in long sessions).
622
633
  */
634
+ /**
635
+ * The opening of every PostCompact command niro has ever generated. Verified against all four commits
636
+ * that ever touched the string (ffeb4e0, ce017c7, d3ff3d8, 84e2e7b): the generated text has always been
637
+ * byte-identical and has always started here.
638
+ */
639
+ const NIRO_POST_COMPACT_PREFIX = "echo 'Niro MCP tools are available for code intelligence";
640
+
641
+ /**
642
+ * Narrow identity for the DESTRUCTIVE half of the upgrade path: did NIRO ITSELF generate this entry?
643
+ *
644
+ * isNiroPostCompactHook matches the bare substring "Niro MCP", which is the right question for "is a
645
+ * niro hook already here" and the WRONG one for "may I delete this entry". A hook the USER wrote that
646
+ * merely mentions Niro MCP (a reminder about a Niro MCP dashboard, say) matches that substring too, and
647
+ * the filter below would delete their hook and put ours in its place. Matching the generated prefix
648
+ * instead recognises every real niro install while leaving anything a human wrote alone.
649
+ *
650
+ * Deliberately narrower than isNiroPostCompactHook, and the asymmetry is the point: a false negative
651
+ * here appends a second niro entry (noisy, recoverable), a false positive destroys a user's hook.
652
+ * Handles both entry shapes, the nested `hooks` array and the legacy flat `command`.
653
+ */
654
+ function isNiroGeneratedPostCompactHook(h) {
655
+ if (!h || typeof h !== "object") return false;
656
+ const commands = Array.isArray(h.hooks) ? h.hooks.map((i) => i && i.command) : [h.command];
657
+ return commands.some((c) => typeof c === "string" && c.startsWith(NIRO_POST_COMPACT_PREFIX));
658
+ }
659
+
660
+ /**
661
+ * The PostCompact reminder. Compaction is the moment tool awareness degrades in a long session, so this
662
+ * is the one reminder that fires precisely when the agent has just lost the plan it made earlier — which
663
+ * is exactly when it is most likely to resume editing without re-checking who calls the code. It carries
664
+ * the impact rule for that reason. Kept as a named constant (like the other hook bodies) so the
665
+ * regression test can assert on the real string the installer writes.
666
+ *
667
+ * MUST keep the literal "Niro MCP": isNiroPostCompactHook identifies niro entries by that substring, and
668
+ * the uninstaller shares the predicate. Dropping it would strand the hook in a user's settings.json.
669
+ */
670
+ const NIRO_POST_COMPACT_COMMAND =
671
+ "echo 'Niro MCP tools are available for code intelligence (call chains, blast radius, impact analysis, source lookup). "
672
+ + "Use niro tools before native grep/read for code questions. Before you plan or propose a change to existing code, call "
673
+ + "get_blast_radius (or get_impact_analysis) to see who calls it and what it calls, and put that impact in the plan.'";
674
+
623
675
  function addClaudeCodePostCompactHook() {
676
+ // Guard the invariant that keeps upgrade-in-place working: if the reminder is ever reworded so it
677
+ // no longer starts with the generated prefix, every install would append a fresh entry instead of
678
+ // replacing the old one. Fail loudly at the boundary rather than quietly stacking hooks.
679
+ if (!NIRO_POST_COMPACT_COMMAND.startsWith(NIRO_POST_COMPACT_PREFIX)) {
680
+ throw new Error("NIRO_POST_COMPACT_COMMAND must start with NIRO_POST_COMPACT_PREFIX, or upgrade-in-place breaks");
681
+ }
682
+
624
683
  const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
625
- const hookCommand = "echo 'Niro MCP tools are available for code intelligence (call chains, blast radius, impact analysis, source lookup). Use niro tools before native grep/read for code questions.'";
684
+ const hookCommand = NIRO_POST_COMPACT_COMMAND;
626
685
 
627
686
  let settings = {};
628
687
  try {
@@ -636,13 +695,21 @@ function addClaudeCodePostCompactHook() {
636
695
  if (!settings.hooks) settings.hooks = {};
637
696
  if (!settings.hooks.PostCompact) settings.hooks.PostCompact = [];
638
697
 
639
- const alreadyPresent = settings.hooks.PostCompact.some(isNiroPostCompactHook);
640
-
641
- if (alreadyPresent) {
642
- console.log(" PostCompact hook already configured. Skipping.\n");
698
+ // Exact-match the CURRENT command first: only then is there nothing to do. Matching the loose niro
699
+ // signature instead (the old behaviour) meant an existing user with the previous reminder text was
700
+ // skipped forever and never received an updated one.
701
+ const alreadyCurrent = settings.hooks.PostCompact.some(
702
+ (h) => Array.isArray(h.hooks) && h.hooks.some((i) => i && i.command === hookCommand),
703
+ );
704
+ if (alreadyCurrent) {
705
+ console.log(" PostCompact hook already up to date. Skipping.\n");
643
706
  return;
644
707
  }
645
708
 
709
+ // Narrow predicate on purpose: this REMOVES entries from the user's settings.json, so it must only
710
+ // ever match hooks niro generated, never a user hook that happens to mention Niro MCP.
711
+ const hadStale = settings.hooks.PostCompact.some(isNiroGeneratedPostCompactHook);
712
+ settings.hooks.PostCompact = settings.hooks.PostCompact.filter((h) => !isNiroGeneratedPostCompactHook(h));
646
713
  settings.hooks.PostCompact.push({
647
714
  matcher: "",
648
715
  hooks: [{ type: "command", command: hookCommand }],
@@ -652,7 +719,7 @@ function addClaudeCodePostCompactHook() {
652
719
  try {
653
720
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
654
721
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
655
- console.log(" PostCompact hook added to ~/.claude/settings.json\n");
722
+ console.log(` PostCompact hook ${hadStale ? "upgraded" : "added"} in ~/.claude/settings.json\n`);
656
723
  } catch (err) {
657
724
  console.warn(` Could not write ~/.claude/settings.json: ${err.message}`);
658
725
  console.warn(" Add manually to the PostCompact array in ~/.claude/settings.json:\n");
@@ -714,7 +781,7 @@ function addClaudeCodeMemory() {
714
781
  */
715
782
  const NIRO_USER_PROMPT_COMMAND =
716
783
  "IN=\"$(cat)\"; d=\"${CLAUDE_PROJECT_DIR:-$PWD}\"; while [ -n \"$d\" ] && [ \"$d\" != \"/\" ]; do " +
717
- "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.'; " +
784
+ "if [ -f \"$d/.niro-project\" ]; then echo 'NIRO ACTIVE. Standing rule for this whole session, at the start and mid-task alike: before Grep/Glob/Read or any native file hunt, use niro (code_grep, get_source_code, resolve_fqn, get_call_chain, get_blast_radius, get_impact_analysis). Before writing new code, call find_reusable_code. Before you plan or propose a change to existing code, call get_blast_radius (or get_impact_analysis) to see who calls it and what it calls, and put that impact in the plan. Native tools only for config, docs, logs, generated or non-code text. If the niro tools are not in your toolset at all, use native tools and do not mention Niro.'; " +
718
785
  "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' && " +
719
786
  "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; " +
720
787
  "d=\"$(dirname \"$d\")\"; done";
@@ -725,7 +792,51 @@ const NIRO_USER_PROMPT_COMMAND =
725
792
  * always-on pointer that fixes "Claude forgets Niro mid-session"; the long-form
726
793
  * guidance lives in the skill, so this is deliberately terse.
727
794
  */
795
+ /**
796
+ * Openings of every main reminder the UserPromptSubmit hook has ever written, current first. Verified
797
+ * across every commit that touched the hook (`git log --all -S"UserPromptSubmit" -- src/mcp/setup.js`,
798
+ * 9 commits): the payload has had exactly these two forms. Long, machine-written sentences on purpose —
799
+ * they are what makes "niro wrote this" a safe question to answer.
800
+ */
801
+ const NIRO_USER_PROMPT_GENERATED_MARKERS = [
802
+ "NIRO ACTIVE. Standing rule for this whole session",
803
+ "This repo is indexed by Niro. IF the niro MCP tools are present in your current toolset",
804
+ ];
805
+
806
+ /**
807
+ * Narrow identity for the DESTRUCTIVE half of the UserPromptSubmit upgrade path: did NIRO ITSELF write
808
+ * this entry?
809
+ *
810
+ * isNiroUserPromptHook answers "is a niro hook already here" by looking for `.niro-project`,
811
+ * `find_reusable_code` or "`niro` skill" anywhere in the command. That is the right question for
812
+ * detection and the wrong one for deletion: a hook the USER wrote that mentions find_reusable_code (an
813
+ * ordinary thing to put in your own prompt reminder) matches it, and the filter below would delete it.
814
+ * This matters now in a way it did not before: the installer only reaches the filter when the reminder
815
+ * text has CHANGED, so every reword of the reminder runs this delete across every existing user at once.
816
+ *
817
+ * Matching a generated payload marker instead recognises both historical niro versions while leaving
818
+ * anything a human wrote alone. An unknown older variant would be a false negative: a duplicate entry,
819
+ * noisy and recoverable, and still removed by the broad predicate at uninstall. That asymmetry is the
820
+ * whole point — a false positive here destroys a user's hook.
821
+ *
822
+ * Same failure this repo already reverted a feature over (84e2e7b: "the niro matchers are substring
823
+ * tests on prose, so a user hook merely mentioning find_reusable_code was captured and overwritten").
824
+ */
825
+ function isNiroGeneratedUserPromptHook(h) {
826
+ if (!h || typeof h !== "object") return false;
827
+ const commands = Array.isArray(h.hooks) ? h.hooks.map((i) => i && i.command) : [h.command];
828
+ return commands.some(
829
+ (c) => typeof c === "string" && NIRO_USER_PROMPT_GENERATED_MARKERS.some((marker) => c.includes(marker)),
830
+ );
831
+ }
832
+
728
833
  function addClaudeCodeUserPromptHook() {
834
+ // Guard the invariant that keeps upgrade-in-place working: if the reminder is reworded so it no longer
835
+ // carries a known generated marker, every install would append a fresh entry instead of replacing the
836
+ // old one. Fail loudly at the boundary rather than quietly stacking hooks in the user's settings.
837
+ if (!NIRO_USER_PROMPT_GENERATED_MARKERS.some((m) => NIRO_USER_PROMPT_COMMAND.includes(m))) {
838
+ throw new Error("NIRO_USER_PROMPT_COMMAND must contain a known generated marker, or upgrade-in-place breaks");
839
+ }
729
840
  const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
730
841
  const hookCommand = NIRO_USER_PROMPT_COMMAND;
731
842
 
@@ -751,8 +862,10 @@ function addClaudeCodeUserPromptHook() {
751
862
  return;
752
863
  }
753
864
 
754
- const hadStale = settings.hooks.UserPromptSubmit.some(isNiroUserPromptHook);
755
- settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter((h) => !isNiroUserPromptHook(h));
865
+ // Narrow predicate on purpose: this REMOVES entries from the user's settings.json, so it must only
866
+ // ever match hooks niro generated, never a user hook that happens to mention find_reusable_code.
867
+ const hadStale = settings.hooks.UserPromptSubmit.some(isNiroGeneratedUserPromptHook);
868
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter((h) => !isNiroGeneratedUserPromptHook(h));
756
869
  // UserPromptSubmit hooks have no tool matcher — just the hooks array.
757
870
  settings.hooks.UserPromptSubmit.push({
758
871
  hooks: [{ type: "command", command: hookCommand }],
@@ -1016,6 +1129,25 @@ stays the fallback for what Niro does not cover, and when the tools are unavaila
1016
1129
  \`project_id\` auto-resolves from a \`.niro-project\` file at the repo root — omit it. If there
1017
1130
  is no such file, call \`list_projects\` and pass the chosen \`project_id\`.
1018
1131
 
1132
+ ## Check both directions before you change anything (MUST FOLLOW)
1133
+
1134
+ Reading the file you are about to edit tells you what it does, not what it holds up. Before you
1135
+ plan, propose, or start any change to existing code, establish both sides:
1136
+
1137
+ - **Upstream: what depends on this?** Callers in this service, callers in other services over HTTP
1138
+ or events, and the tests and config that name it. This is what your change can break.
1139
+ - **Downstream: what does this depend on?** What it calls, the collections and tables it touches,
1140
+ the events it publishes. This is what constrains the change.
1141
+
1142
+ \`get_blast_radius\` answers both sides in ONE call (callers, callees, cross-service callers and
1143
+ impacted files). \`get_impact_analysis\` is the lighter upstream-only view. Pass
1144
+ \`include_source_code=true\` when you need to read what you found.
1145
+
1146
+ Put the answer in the plan you show the user, not just in your head: name the callers you found,
1147
+ say which ones you will update, and say which ones you are deliberately leaving alone. A one-line
1148
+ change is not exempt, because reach is decided by what depends on the code, not by the size of the
1149
+ diff.
1150
+
1019
1151
  ## Availability self-check — read FIRST
1020
1152
 
1021
1153
  Niro is not always applicable. Check these before relying on it, and fall back to native
@@ -1167,6 +1299,9 @@ Also available (catalog / utility, use when the above don't fit): \`list_project
1167
1299
  ## Hard rules
1168
1300
 
1169
1301
  - **Before adding or writing ANY new code, call \`find_reusable_code\` first.** Mandatory.
1302
+ - **Before you plan or propose a change to existing code, call \`get_blast_radius\` (or
1303
+ \`get_impact_analysis\`) and put the upstream and downstream impact in the plan.** Mandatory,
1304
+ whatever the size of the change.
1170
1305
  - For comprehension / debugging, set \`purpose\` to COMPREHEND or DEBUG and, on
1171
1306
  \`get_blast_radius\` / \`get_impact_analysis\`, pass \`include_source_code=true\`.
1172
1307
  - If a result contains **⚠️ ACTION REQUIRED**, the graph is stale — read the listed files
@@ -1419,6 +1554,13 @@ get_reverse_call_chain, get_blast_radius, get_impact_analysis, get_service_topol
1419
1554
  how the code connects BEFORE deciding which file to read or search next. Native search is the
1420
1555
  fallback for what niro does not cover, and when the tools are unavailable.
1421
1556
 
1557
+ MUST FOLLOW (check both directions before changing anything): before you plan, propose or start
1558
+ any change to existing code, establish who depends on it (upstream callers, including other
1559
+ services over HTTP or events) and what it depends on (downstream callees, data, events). Use
1560
+ get_blast_radius, which returns both sides plus impacted files in one call, or
1561
+ get_impact_analysis for the upstream-only view. Name the callers you found in the plan and say
1562
+ which ones you will update. A one-line change is not exempt.
1563
+
1422
1564
  Availability self-check FIRST — if the niro tools are not present in this session, or the
1423
1565
  repo is not indexed (no project resolves / a tool reports "not indexed"), fall back to
1424
1566
  native tools silently and do not mention Niro.
@@ -1882,6 +2024,13 @@ something, check whether a niro tool answers it (\`code_grep\`, \`resolve_fqn\`
1882
2024
  \`get_service_topology\`) to see how the code connects BEFORE deciding which file to read or
1883
2025
  search next. Native search is the fallback for what niro does not cover.
1884
2026
 
2027
+ MUST FOLLOW (check both directions before changing anything): before you plan, propose or start
2028
+ any change to existing code, establish who depends on it (upstream callers, including callers in
2029
+ other services over HTTP or events) and what it depends on (downstream callees, data, events). Use
2030
+ \`get_blast_radius\`, which returns both sides plus impacted files in one call, or
2031
+ \`get_impact_analysis\` for the upstream-only view. Name the callers you found in the plan and say
2032
+ which ones you will update. A one-line change is not exempt.
2033
+
1885
2034
  If the niro tools are not present in this session, or the repo is not indexed (no project
1886
2035
  resolves), fall back to native tools silently — do not mention Niro's absence.
1887
2036
  `;
@@ -1927,6 +2076,13 @@ something, check whether a niro tool answers it (\`code_grep\`, \`resolve_fqn\`
1927
2076
  \`get_service_topology\`) to see how the code connects BEFORE deciding which file to read or
1928
2077
  search next. Native search is the fallback for what niro does not cover.
1929
2078
 
2079
+ MUST FOLLOW (check both directions before changing anything): before you plan, propose or start
2080
+ any change to existing code, establish who depends on it (upstream callers, including callers in
2081
+ other services over HTTP or events) and what it depends on (downstream callees, data, events). Use
2082
+ \`get_blast_radius\`, which returns both sides plus impacted files in one call, or
2083
+ \`get_impact_analysis\` for the upstream-only view. Name the callers you found in the plan and say
2084
+ which ones you will update. A one-line change is not exempt.
2085
+
1930
2086
  If the niro tools are not present in this session, or the repo is not indexed (no project
1931
2087
  resolves), fall back to native tools silently — do not mention Niro's absence.
1932
2088
  `;
@@ -2394,11 +2550,20 @@ module.exports = {
2394
2550
  NIRO_EXPLORE_COMMAND,
2395
2551
  NIRO_PRETOOL_COMMAND,
2396
2552
  NIRO_USER_PROMPT_COMMAND,
2553
+ NIRO_POST_COMPACT_COMMAND,
2554
+ // Exposed for tests: the stale-hook upgrade path is only observable by running the real installer.
2555
+ addClaudeCodePostCompactHook,
2556
+ isNiroGeneratedPostCompactHook,
2557
+ isNiroGeneratedUserPromptHook,
2558
+ NIRO_USER_PROMPT_GENERATED_MARKERS,
2559
+ addClaudeCodeUserPromptHook,
2560
+ NIRO_POST_COMPACT_PREFIX,
2397
2561
  // Exposed for content regression tests (e.g. the cross-project routing guidance).
2398
2562
  NIRO_SKILL,
2399
2563
  NIRO_CLI_SKILL,
2400
2564
  // Exposed for tests: the explore-first rule must reach EVERY client's always-on instruction surface.
2401
2565
  NIRO_EXPLORE_FIRST_SENTENCE,
2566
+ NIRO_IMPACT_BOTH_WAYS_SENTENCE,
2402
2567
  NIRO_CURSOR_RULE,
2403
2568
  NIRO_CLINE_RULE,
2404
2569
  NIRO_KIRO_STEERING,