@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.
- package/CHANGELOG.md +117 -0
- package/README.md +71 -1
- package/docs/working-on-branches.md +55 -0
- package/package.json +1 -1
- package/src/commands/cleanup.js +689 -0
- package/src/commands/info.js +51 -2
- package/src/commands/release.js +13 -60
- package/src/commands/takeover.js +52 -2
- package/src/commands/watch.js +93 -25
- package/src/index.js +23 -3
- package/src/lib/folderSync.js +6 -0
- package/src/lib/periodicClaim.js +100 -0
- package/src/lib/printError.js +46 -0
- package/src/lib/takeoverRestore.js +156 -0
- package/src/mcp/setup.js +178 -13
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 =
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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(
|
|
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
|
-
|
|
755
|
-
|
|
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,
|