@niroai/niro 0.4.0 → 0.6.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,258 @@
1
+ /**
2
+ * Is this folder a sane thing to onboard? The check that runs BEFORE
3
+ * `niro init` / `niro add repo` register anything on the server or upload a byte.
4
+ *
5
+ * Two independent rules:
6
+ *
7
+ * 1. PROTECTED ROOT — never bypassable. $HOME, the filesystem root, any ancestor
8
+ * of $HOME (/home, /Users) and the well-known system dirs are refused outright.
9
+ * Onboarding one of these is never intended, and the damage is not the kind
10
+ * --force should be able to cause by accident: source from every project on the
11
+ * machine uploaded as one blob, a .niro-project pin dropped at $HOME, and a
12
+ * watch daemon streaming the whole home tree. Worse, .gitignore is only honoured
13
+ * when the onboarded folder is itself a git work tree, so onboarding $HOME
14
+ * silently uploads files that each nested repo deliberately ignores.
15
+ *
16
+ * 2. PROJECT INDICATOR — bypassable with --force. The folder must LOOK like a
17
+ * project: a build descriptor at the folder itself, or in a directory just below
18
+ * it. This mirrors ai-orchestrator
19
+ * ProjectStructureResolverService.detectProjects(), which marks any directory
20
+ * holding a descriptor from the ConfigFileParser SUPPORTED_FILES union as a
21
+ * project root. folderSync already carries that union as STRUCTURAL_FILENAMES /
22
+ * STRUCTURAL_EXTENSIONS (documented there as byte-identical to the server), so
23
+ * this REUSES those sets. There is deliberately no second list to drift.
24
+ *
25
+ * Two deliberate differences from the server:
26
+ *
27
+ * - Depth-bounded (INDICATOR_MAX_DEPTH) instead of a full-tree walk. The question
28
+ * here is "is THIS folder a project", not "which projects live somewhere inside
29
+ * it". A descriptor buried deep is precisely the accident being caught: $HOME
30
+ * contains thousands of them.
31
+ * - No parser veto. The server asks the owning parser isProjectIndicator(file) to
32
+ * reject a false match (a Python module literally named setup.py). The CLI cannot
33
+ * parse, so a name match is accepted. That errs toward ALLOWING onboarding, which
34
+ * is the safe direction for a guard: the worst case is the behaviour we had before.
35
+ */
36
+ const fs = require("fs");
37
+ const os = require("os");
38
+ const path = require("path");
39
+ const color = require("./color");
40
+ const folderSync = require("./folderSync");
41
+
42
+ // How far below the target we look for a build descriptor. 0 = the folder itself.
43
+ // 2 covers a monorepo root whose deployables sit under a grouping dir
44
+ // (services/foo/pom.xml, packages/foo/package.json) and still refuses a folder whose
45
+ // only descriptors are buried deeper than any real project root would be.
46
+ const INDICATOR_MAX_DEPTH = 2;
47
+
48
+ // Hard bound on the walk so a pathological tree cannot stall the command.
49
+ const MAX_DIRS_SCANNED = 2000;
50
+
51
+ // Refused outright, by exact path match. Ancestors of $HOME (/home, /Users) are
52
+ // caught by the ancestor rule below as well; listed here for the case where $HOME
53
+ // lives somewhere unusual.
54
+ const SYSTEM_DIRS = new Set([
55
+ "/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/media",
56
+ "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp",
57
+ "/usr", "/var", "/Applications", "/Library", "/System", "/Users", "/Volumes",
58
+ ]);
59
+
60
+ // Windows paths are case-insensitive; POSIX paths are not.
61
+ function samePath(a, b) {
62
+ if (process.platform === "win32") return a.toLowerCase() === b.toLowerCase();
63
+ return a === b;
64
+ }
65
+
66
+ // Resolve symlinks before comparing, so a symlinked route to a protected path (macOS
67
+ // /tmp -> /private/tmp, an $HOME reached through /mnt/home/<user>) cannot walk past the
68
+ // refusal on a string mismatch. Falls back to the lexical path when the target does not
69
+ // exist or is unreadable — the comparison is then no worse than before.
70
+ function realPath(p) {
71
+ try {
72
+ return fs.realpathSync(p);
73
+ } catch {
74
+ return p;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Why this folder must never be onboarded, or null when it is fine.
80
+ * Returns a human phrase that slots into "Refusing to onboard <phrase>".
81
+ */
82
+ function protectedRootReason(dir) {
83
+ const target = realPath(path.resolve(dir));
84
+
85
+ if (samePath(target, path.parse(target).root)) return "the filesystem root";
86
+
87
+ let home = null;
88
+ try {
89
+ home = os.homedir() ? realPath(path.resolve(os.homedir())) : null;
90
+ } catch {
91
+ home = null; // no home resolvable -> only the root/system rules apply
92
+ }
93
+ if (home) {
94
+ if (samePath(target, home)) return "your home folder";
95
+ // home lives INSIDE target => target is an ancestor of home (/home, /Users, ...).
96
+ const rel = path.relative(target, home);
97
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
98
+ return "a parent of your home folder";
99
+ }
100
+ }
101
+
102
+ if (SYSTEM_DIRS.has(target)) return "a system folder";
103
+ // Also refuse the lexical path when it differs from the resolved one, so a symlink
104
+ // POINTING AT an ordinary folder cannot be used to name a protected path either way.
105
+ if (SYSTEM_DIRS.has(path.resolve(dir))) return "a system folder";
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * The indicator kind this filename represents, or null. Name match wins over
111
+ * extension match, mirroring the server's parserRegistry-then-extensionRegistry
112
+ * lookup order. Returned as a display label ("pom.xml", "*.csproj").
113
+ */
114
+ function indicatorName(fileName) {
115
+ const lower = path.basename(fileName).toLowerCase();
116
+ if (folderSync.STRUCTURAL_FILENAMES.has(lower)) return lower;
117
+ const ext = path.extname(lower);
118
+ if (ext && folderSync.STRUCTURAL_EXTENSIONS.has(ext)) return `*${ext}`;
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Look for project roots at `targetDir` and just below it.
124
+ *
125
+ * Stops descending as soon as a directory IS a project root: once a descriptor is
126
+ * found there is nothing further to prove, and it keeps the common case (a normal
127
+ * repo with a descriptor at its top) down to a single readdir.
128
+ *
129
+ * Returns { roots, indicators: Map(label -> count), truncated }.
130
+ */
131
+ function findProjectIndicators(targetDir, opts = {}) {
132
+ const maxDepth = Number.isInteger(opts.maxDepth) ? opts.maxDepth : INDICATOR_MAX_DEPTH;
133
+ // Injectable so the breadth-first guarantee can be tested without a 2000-directory fixture.
134
+ const maxDirs = Number.isInteger(opts.maxDirs) ? opts.maxDirs : MAX_DIRS_SCANNED;
135
+ const indicators = new Map();
136
+ let roots = 0;
137
+ let dirsScanned = 0;
138
+ let truncated = false;
139
+
140
+ // Breadth-first, one depth level at a time. Depth-first would let a single wide or
141
+ // deep subtree spend the whole MAX_DIRS_SCANNED budget before the walk ever looked
142
+ // at the target's own siblings, so a real project root could go unseen while junk
143
+ // was scanned. Breadth-first means level 0 is always examined, then all of level 1,
144
+ // and only the deepest level can ever be cut short.
145
+ let frontier = [path.resolve(targetDir)];
146
+ for (let depth = 0; depth <= maxDepth && frontier.length > 0; depth++) {
147
+ const next = [];
148
+ for (const dir of frontier) {
149
+ if (dirsScanned >= maxDirs) { truncated = true; break; }
150
+ dirsScanned++;
151
+
152
+ let entries;
153
+ try {
154
+ entries = fs.readdirSync(dir, { withFileTypes: true });
155
+ } catch {
156
+ continue; // unreadable (permissions, vanished) -> nothing to prove here
157
+ }
158
+
159
+ let isRoot = false;
160
+ for (const entry of entries) {
161
+ if (!entry.isFile()) continue;
162
+ const label = indicatorName(entry.name);
163
+ if (!label) continue;
164
+ isRoot = true;
165
+ indicators.set(label, (indicators.get(label) || 0) + 1);
166
+ }
167
+ // A project root proves what we came to prove; there is no reason to look inside it.
168
+ if (isRoot) { roots++; continue; }
169
+ if (depth >= maxDepth) continue;
170
+
171
+ // Sorted so the walk is deterministic: readdir order is filesystem-dependent, and
172
+ // which directories fall inside the budget must not vary between machines.
173
+ const children = entries
174
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !folderSync.IGNORED_DIRS.has(e.name))
175
+ .map((e) => e.name)
176
+ .sort();
177
+ for (const name of children) next.push(path.join(dir, name));
178
+ }
179
+ if (truncated) break;
180
+ frontier = next;
181
+ }
182
+
183
+ return { roots, indicators, truncated };
184
+ }
185
+
186
+ // "pom.xml x10, package.json x1" — the three commonest kinds, then a tail count.
187
+ function describeIndicators(indicators) {
188
+ const sorted = [...indicators.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
189
+ const shown = sorted.slice(0, 3).map(([label, n]) => (n > 1 ? `${label} x${n}` : label));
190
+ const rest = sorted.length - shown.length;
191
+ return rest > 0 ? `${shown.join(", ")}, and ${rest} more` : shown.join(", ");
192
+ }
193
+
194
+ /**
195
+ * The gate itself. Prints the refusal and returns false when this folder must not be
196
+ * onboarded; returns true when the caller may proceed.
197
+ *
198
+ * opts.force bypasses the INDICATOR rule only. It deliberately does not bypass a
199
+ * protected root: there is no legitimate reason to upload $HOME, and a scripted
200
+ * --force must not be able to do it by accident.
201
+ *
202
+ * opts.alreadyRegistered likewise bypasses the INDICATOR rule only, for a folder this
203
+ * backend already holds a repo for. Callers pass it from watchState so that adding the
204
+ * gate never breaks a sync that was already working.
205
+ */
206
+ function ensureOnboardable(targetDir, opts = {}) {
207
+ const resolved = path.resolve(targetDir);
208
+
209
+ const reason = protectedRootReason(resolved);
210
+ if (reason) {
211
+ console.error(color.red(`\n ✗ Refusing to onboard ${reason} (${resolved}).`));
212
+ console.error(" That would upload source from every project underneath it, pin this folder");
213
+ console.error(" with a .niro-project file, and watch the whole tree for changes.");
214
+ console.error(` Change into the project folder you meant, then run the command there.`);
215
+ return false;
216
+ }
217
+
218
+ const { roots, indicators, truncated } = findProjectIndicators(resolved, opts);
219
+ if (indicators.size === 0) {
220
+ // A folder this backend ALREADY has a repo for is exempt from the descriptor rule.
221
+ // That rule gates NEW onboarding; it is not a reason to start failing a re-sync of a
222
+ // folder onboarded by an earlier CLI, which would strand the user with no way back in.
223
+ // The protected-root rule above still applied, and still refuses.
224
+ if (opts.alreadyRegistered) {
225
+ console.log(color.dim(
226
+ ` No build file in ${resolved}, but it is already registered here — continuing.`
227
+ ));
228
+ return true;
229
+ }
230
+ if (opts.force) {
231
+ console.log(color.yellow(
232
+ `\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
233
+ ));
234
+ return true;
235
+ }
236
+ console.error(color.red(`\n ✗ No project found in ${resolved}.`));
237
+ console.error(" Niro looks for a build file (package.json, pom.xml, go.mod, ...) in this");
238
+ console.error(` folder or just below it${truncated ? " (the scan stopped early — the tree is very large)" : ""}. None found.`);
239
+ console.error(" Pass --force if you really mean to upload this folder.");
240
+ return false;
241
+ }
242
+
243
+ console.log(
244
+ ` ${color.green("✓")} Found ${roots} project${roots === 1 ? "" : "s"} ` +
245
+ `(${describeIndicators(indicators)}).`
246
+ );
247
+ return true;
248
+ }
249
+
250
+ module.exports = {
251
+ ensureOnboardable,
252
+ protectedRootReason,
253
+ findProjectIndicators,
254
+ indicatorName,
255
+ describeIndicators,
256
+ INDICATOR_MAX_DEPTH,
257
+ MAX_DIRS_SCANNED,
258
+ };
@@ -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 };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reading the state of a temporary project.
3
+ *
4
+ * A developer gets ONE temporary project per project, and `niro new-temp-project` MOVES it rather than
5
+ * adding a second. So "which folder does Niro currently reflect?" has exactly one answer, and it lives
6
+ * on the server: the branch recorded against the temporary project's repo.
7
+ *
8
+ * That matters because a folder cannot tell locally whether it still holds the slot. Every folder taken
9
+ * over for one project shares the SAME repo id, so the server cannot tell them apart either — if two
10
+ * folders upload, their branches blend into one copy and every answer drawn from it is a mixture. The
11
+ * only way to know is to ask which branch the copy is on and compare it with your own.
12
+ */
13
+ const api = require("./api");
14
+
15
+ /**
16
+ * The branch a temporary project is currently bound to, or null if it cannot be determined.
17
+ *
18
+ * Returns null rather than throwing on a server or network problem: this is used to decide whether to
19
+ * hold back an upload, and "I could not reach the server" must never be read as "you have lost the
20
+ * slot" — that would stop a perfectly healthy folder syncing every time the network hiccuped.
21
+ */
22
+ async function currentBranch(tempProjectId, gitId, opts = {}) {
23
+ if (!tempProjectId) return null;
24
+ let project;
25
+ try {
26
+ project = await api.get(`/api/projects/${encodeURIComponent(tempProjectId)}`, opts);
27
+ } catch {
28
+ return null;
29
+ }
30
+ const repos = project.git_details || project.gitDetails || [];
31
+ // Match the repo by id when we can. Falling back to the first is safe for the single-repo case and
32
+ // still right for multi-repo: every repo in one temporary project is taken over onto one branch.
33
+ const match = repos.find((r) => r.id === gitId) || repos[0];
34
+ return match && match.branch ? match.branch : null;
35
+ }
36
+
37
+ module.exports = { currentBranch };
@@ -127,6 +127,34 @@ function update(folderPath, patch) {
127
127
  save(state);
128
128
  }
129
129
 
130
+ /**
131
+ * Merge per-path hash changes into an entry instead of replacing the whole map.
132
+ *
133
+ * `update({ fileHashes })` replaces the map wholesale, so whichever writer saves last wins and every
134
+ * entry the other writer added in the meantime is silently gone. The delta sync computes a map from
135
+ * its own walk and writes it at the end, which can take minutes on a large upload; any live per-file
136
+ * event that landed during that window is erased. Losing a baseline entry is not cosmetic — the file
137
+ * stops being tracked, so it is never re-sent and the divergence is permanent. That is the difference
138
+ * between the 27 July incident, which healed itself in 35 minutes, and 31 July, which never did.
139
+ *
140
+ * Removals must be explicit rather than implied by absence, so nothing disappears by accident.
141
+ *
142
+ * load/save are synchronous and save renames atomically, so this is safe against other writers in
143
+ * THIS process. The watch daemon is a separate process, so a cross-process interleave is still
144
+ * possible; this shrinks the window from minutes to microseconds rather than closing it entirely.
145
+ */
146
+ function mergeFileHashes(folderPath, { set = {}, remove = [], patch = {} } = {}) {
147
+ const state = load();
148
+ const key = path.resolve(folderPath);
149
+ if (!state[key]) return;
150
+ const merged = { ...(state[key].fileHashes || {}) };
151
+ for (const rp of remove) delete merged[rp];
152
+ // Applied after the removals, so a file deleted and re-created within one run keeps its new hash.
153
+ Object.assign(merged, set);
154
+ state[key] = { ...state[key], ...patch, fileHashes: merged };
155
+ save(state);
156
+ }
157
+
130
158
  /**
131
159
  * Remove a folder's entry. When `expectedGitId` is given, only remove if the CURRENT entry still has that
132
160
  * gitId (compare-and-delete): the self-heal path removes a poison entry after an async repoExists round-trip,
@@ -214,4 +242,5 @@ function hashFile(filePath) {
214
242
  }
215
243
  }
216
244
 
217
- module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile, FILE };
245
+ module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile,
246
+ mergeFileHashes, FILE };
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
@@ -507,6 +508,7 @@ function connectSse() {
507
508
  // Swallow the response to an internally-replayed initialize (sentinel id) so the host
508
509
  // never sees a duplicate initialize result after a transparent reconnect.
509
510
  let swallowed = false;
511
+ let rewritten = null;
510
512
  try {
511
513
  const msg = JSON.parse(data);
512
514
  // ADR-018 handshake: only an initialize result carries capabilities. When the server
@@ -528,9 +530,12 @@ function connectSse() {
528
530
  } else {
529
531
  // Cancel the client-side deadline when a delivered result matches an in-flight request.
530
532
  pending.onIncoming(msg);
533
+ rewritten = captureReceipt(msg);
531
534
  }
532
535
  } catch (_) { /* not correlatable JSON — forward unchanged */ }
533
- 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");
534
539
  }
535
540
  }
536
541
  });
@@ -662,6 +667,56 @@ function sendMessage(message) {
662
667
  });
663
668
  }
664
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
+
665
720
  /**
666
721
  * Enrich an outgoing tools/call: send the folder's `.niro-project` pin as `_niroPin` (ADR-018 — a
667
722
  * SOURCE binding the server's routing ladder consumes, NOT an explicit project choice), and