@niroai/niro 0.17.0 → 0.18.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.
Files changed (177) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +123 -302
  3. package/bin/niro.js +12 -1
  4. package/docs/agent-antigravity.md +37 -0
  5. package/docs/agent-claude-code.md +48 -0
  6. package/docs/agent-claude-desktop.md +47 -0
  7. package/docs/agent-cline.md +42 -0
  8. package/docs/agent-codex.md +45 -0
  9. package/docs/agent-copilot-cli.md +42 -0
  10. package/docs/agent-cursor.md +44 -0
  11. package/docs/agent-factory.md +42 -0
  12. package/docs/agent-gemini-cli.md +42 -0
  13. package/docs/agent-goose.md +42 -0
  14. package/docs/agent-kiro.md +37 -0
  15. package/docs/agent-opencode.md +37 -0
  16. package/docs/agent-vscode.md +45 -0
  17. package/docs/agent-windsurf.md +38 -0
  18. package/docs/answer-shapes.md +69 -0
  19. package/docs/builds-and-updates.md +68 -0
  20. package/docs/cloud-README.md +372 -0
  21. package/docs/configuring-a-folder.md +51 -0
  22. package/docs/connect-ci.md +24 -0
  23. package/docs/connect-overview.md +20 -0
  24. package/docs/connect-review-bots.md +17 -0
  25. package/docs/connect-team-server.md +12 -0
  26. package/docs/connect-terminal.md +49 -0
  27. package/docs/connect-web-agents.md +9 -0
  28. package/docs/connect-your-ai-assistant.md +59 -175
  29. package/docs/coverage-audit-and-consent.md +51 -0
  30. package/docs/evaluate-what-niro-did.md +271 -0
  31. package/docs/faq-assistants-and-mcp.md +122 -0
  32. package/docs/faq-feedback-and-receipts.md +33 -0
  33. package/docs/faq-indexing.md +127 -0
  34. package/docs/faq-install-and-setup.md +103 -0
  35. package/docs/faq-licence-and-plans.md +86 -0
  36. package/docs/faq-login-and-account.md +69 -0
  37. package/docs/faq-offline.md +34 -0
  38. package/docs/faq-performance.md +33 -0
  39. package/docs/faq-privacy-and-security.md +113 -0
  40. package/docs/faq-uninstall.md +47 -0
  41. package/docs/faq-updates.md +59 -0
  42. package/docs/faq-worktrees-and-branches.md +59 -0
  43. package/docs/faq-wrong-or-missing-answers.md +275 -0
  44. package/docs/hosted-only.md +33 -0
  45. package/docs/how-the-graph-works.md +47 -0
  46. package/docs/index.md +157 -0
  47. package/docs/licence-and-plans.md +72 -0
  48. package/docs/lifecycle-debug.md +48 -0
  49. package/docs/lifecycle-onboarding.md +32 -0
  50. package/docs/lifecycle-overview.md +77 -0
  51. package/docs/lifecycle-plan-and-design.md +44 -0
  52. package/docs/lifecycle-review.md +30 -0
  53. package/docs/lifecycle-security-and-privacy.md +35 -0
  54. package/docs/lifecycle-test.md +28 -0
  55. package/docs/lifecycle-write-code.md +35 -0
  56. package/docs/llms-full.txt +8015 -0
  57. package/docs/llms.txt +126 -0
  58. package/docs/playbook-add-a-feature.md +108 -0
  59. package/docs/playbook-change-safely.md +93 -0
  60. package/docs/playbook-data-and-events.md +107 -0
  61. package/docs/playbook-debug-after-a-change.md +96 -0
  62. package/docs/playbook-debug-config.md +95 -0
  63. package/docs/playbook-debug-exception.md +124 -0
  64. package/docs/playbook-debug-failing-service-call.md +119 -0
  65. package/docs/playbook-debug-slow-path.md +80 -0
  66. package/docs/playbook-debug-wrong-value.md +137 -0
  67. package/docs/playbook-onboard.md +95 -0
  68. package/docs/playbook-review-a-change.md +72 -0
  69. package/docs/playbook-test-a-change.md +73 -0
  70. package/docs/reference-cli.md +113 -0
  71. package/docs/reference-config-files.md +42 -0
  72. package/docs/reference-engine-commands.md +56 -0
  73. package/docs/reference-environment-variables.md +89 -0
  74. package/docs/reference-exit-codes.md +67 -0
  75. package/docs/reference-limits.md +75 -0
  76. package/docs/reference-mcp-tools.md +326 -0
  77. package/docs/reference-messages.md +180 -0
  78. package/docs/secrets-and-masking.md +48 -0
  79. package/docs/supported-c.md +130 -0
  80. package/docs/supported-cpp.md +157 -0
  81. package/docs/supported-csharp.md +211 -0
  82. package/docs/supported-go.md +258 -0
  83. package/docs/supported-java.md +226 -0
  84. package/docs/supported-javascript.md +223 -0
  85. package/docs/supported-kotlin.md +124 -0
  86. package/docs/supported-overview.md +89 -0
  87. package/docs/supported-php.md +157 -0
  88. package/docs/supported-python.md +224 -0
  89. package/docs/supported-ruby.md +162 -0
  90. package/docs/supported-rust.md +150 -0
  91. package/docs/supported-swift.md +133 -0
  92. package/docs/supported-typescript.md +220 -0
  93. package/docs/tokens-speed-and-accuracy.md +99 -0
  94. package/docs/troubleshooting.md +42 -0
  95. package/docs/uninstall.md +74 -0
  96. package/docs/what-leaves-your-machine.md +71 -0
  97. package/docs/where-things-are-stored.md +59 -0
  98. package/docs/worktrees-and-branches.md +55 -0
  99. package/package.json +5 -3
  100. package/src/commands/add-repo.js +5 -0
  101. package/src/commands/auditSend.js +67 -0
  102. package/src/commands/build.js +6 -0
  103. package/src/commands/docsSend.js +73 -0
  104. package/src/commands/engine.js +120 -0
  105. package/src/commands/engineRoutes.js +105 -0
  106. package/src/commands/guide.js +254 -0
  107. package/src/commands/init.js +6 -2
  108. package/src/commands/initLocal.js +96 -0
  109. package/src/commands/journalSend.js +47 -0
  110. package/src/commands/login.js +89 -12
  111. package/src/commands/mcp.js +22 -6
  112. package/src/commands/plan.js +99 -0
  113. package/src/commands/repoPush.js +33 -0
  114. package/src/commands/requestIntegration.js +38 -0
  115. package/src/commands/share.js +56 -0
  116. package/src/commands/start.js +451 -371
  117. package/src/commands/status.js +57 -0
  118. package/src/commands/takeover.js +6 -0
  119. package/src/commands/uninstall.js +180 -0
  120. package/src/commands/upgrade.js +38 -8
  121. package/src/commands/watch.js +420 -6
  122. package/src/index.js +174 -3
  123. package/src/lib/agentApi.js +32 -1
  124. package/src/lib/api.js +20 -6
  125. package/src/lib/auditUpload.js +293 -0
  126. package/src/lib/branchDrift.js +149 -0
  127. package/src/lib/choose.js +116 -0
  128. package/src/lib/cloudGuard.js +74 -0
  129. package/src/lib/credentials.js +2 -2
  130. package/src/lib/docsMarked.js +55 -0
  131. package/src/lib/docsState.js +213 -0
  132. package/src/lib/docsUpload.js +481 -0
  133. package/src/lib/engine.js +619 -0
  134. package/src/lib/folderSync.js +2 -1
  135. package/src/lib/graphCheck.js +40 -0
  136. package/src/lib/guide.js +293 -0
  137. package/src/lib/guideSearch.js +252 -0
  138. package/src/lib/integrationRequest.js +89 -0
  139. package/src/lib/journalUpload.js +147 -0
  140. package/src/lib/languages.js +129 -0
  141. package/src/lib/lease.js +375 -0
  142. package/src/lib/localBuild.js +285 -0
  143. package/src/lib/localEngine.js +210 -0
  144. package/src/lib/localOnboard.js +124 -0
  145. package/src/lib/localWorktrees.js +243 -0
  146. package/src/lib/machineCheck.js +58 -0
  147. package/src/lib/mcpClients.js +10 -1
  148. package/src/lib/migrationNotice.js +118 -0
  149. package/src/lib/modelFetch.js +153 -0
  150. package/src/lib/nestedOutput.js +23 -9
  151. package/src/lib/onboardingLog.js +34 -0
  152. package/src/lib/planUsage.js +165 -0
  153. package/src/lib/progressBar.js +46 -0
  154. package/src/lib/prompt.js +1 -1
  155. package/src/lib/redact.js +155 -0
  156. package/src/lib/release.js +33 -0
  157. package/src/lib/repoPush.js +495 -0
  158. package/src/lib/secret.js +1 -1
  159. package/src/lib/service.js +372 -63
  160. package/src/lib/shareCode.js +367 -0
  161. package/src/lib/shareExclusions.js +114 -0
  162. package/src/lib/shareFiles.js +129 -0
  163. package/src/lib/spinner.js +9 -5
  164. package/src/lib/watchDaemon.js +33 -10
  165. package/src/lib/watchState.js +12 -1
  166. package/src/lib/windowsPaths.js +67 -0
  167. package/src/mcp/lineCheck.js +382 -0
  168. package/src/mcp/lineName.js +92 -0
  169. package/src/mcp/localBackend.js +751 -0
  170. package/src/mcp/localMode.js +383 -0
  171. package/src/mcp/localSkill.js +189 -0
  172. package/src/mcp/server.js +610 -79
  173. package/src/mcp/setup.js +519 -131
  174. package/src/mcp/shellSearchHook.js +509 -0
  175. package/src/mcp/teardown.js +173 -91
  176. package/src/mcp/usingNiro.js +39 -0
  177. package/docs/working-on-branches.md +0 -162
@@ -31,8 +31,24 @@ const color = require("./color");
31
31
  * "pm2_install_failed" — `npm install -g pm2` exited non-zero
32
32
  * "daemon_install_failed" — registering niro-watch under PM2 failed
33
33
  */
34
- async function ensureWatchDaemon({ skipPrompts = false } = {}) {
35
- if (!service.pm2Available()) {
34
+ async function ensureWatchDaemon({ skipPrompts = false, quiet = false, heading = null } = {}) {
35
+ // `heading`: the line niro start printed just above. Kept only while nothing is printed under it.
36
+ let sameLineAs = heading && process.stdout.isTTY ? heading : null;
37
+ if (!service.pm2Available() && quiet && !skipPrompts && !prompt.isNonInteractive()) {
38
+ // `niro start`: one question, and npm's output only if it fails. macOS and Linux then need
39
+ // sudo once for start-on-login (pm2 startup); Windows does not (a per-user Run key).
40
+ const what = process.platform === "win32" ? "installs PM2" : "installs PM2 and asks for a password";
41
+ sameLineAs = null;
42
+ if (!(await prompt.confirm(` Keep the graph in sync? (${what})`, true))) return { ok: false, reason: "declined" };
43
+ console.log(color.dim(" Installing PM2…"));
44
+ if (!service.installPm2({ quiet: true })) {
45
+ // Windows installs global packages into the user's own folder, so sudo is no help there.
46
+ const retry = process.platform === "win32" ? "npm install -g pm2" : "sudo npm install -g pm2";
47
+ const cause = process.platform === "win32" ? "" : " (a permission error is the usual cause)";
48
+ console.log(` ${color.yellow("⚠")} PM2 install failed${cause}. Try ${color.cyan(retry)} then ${color.cyan("niro service install")}`);
49
+ return { ok: false, reason: "pm2_install_failed" };
50
+ }
51
+ } else if (!service.pm2Available()) {
36
52
  console.log(`\n ${color.yellow("⚠")} PM2 is not installed. Niro uses PM2 to keep your synced folders`);
37
53
  console.log(` up to date in the background, including after a reboot.`);
38
54
 
@@ -56,22 +72,29 @@ async function ensureWatchDaemon({ skipPrompts = false } = {}) {
56
72
  console.log(` ${color.green("✓")} PM2 installed.`);
57
73
  }
58
74
 
59
- console.log(`\n ${color.cyan("●")} Installing niro-watch daemon (keeps your synced folders up to date, auto-starts on login)...`);
75
+ if (!quiet) console.log(`\n ${color.cyan("●")} Installing niro-watch daemon (keeps your synced folders up to date, auto-starts on login)...`);
60
76
  try {
61
77
  // install() returns false when pm2 turns out not to be runnable after
62
78
  // all — e.g. npm installed it into a global bin dir that isn't on PATH
63
79
  // (npm exits 0 for that). Treat it as the failure it is; the old code's
64
80
  // unchecked call here is how "✓ Done." lied to customers.
65
- if (!service.install()) {
66
- console.log(` ${color.yellow("⚠")} PM2 was installed but isn't runnable from this shell (its global bin`);
67
- console.log(` directory may not be on your PATH). Open a NEW terminal and run ${color.cyan("niro service install")}.`);
81
+ const installed = service.install({ sameLineAs });
82
+ if (!installed) {
83
+ console.log(` ${color.yellow("⚠")} PM2 was installed but isn't runnable from this shell (its global bin`);
84
+ console.log(` directory may not be on your PATH). Open a NEW terminal and run ${color.cyan("niro service install")}.`);
68
85
  return { ok: false, reason: "daemon_install_failed" };
69
86
  }
70
- console.log(` ${color.green("✓")} niro-watch is running and will start automatically on login.`);
71
- return { ok: true };
87
+ const autostart = installed !== "no_autostart";
88
+ // quiet: the caller (niro start) prints its own closing line from the result.
89
+ if (!quiet) {
90
+ console.log(autostart
91
+ ? ` ${color.green("✓")} niro-watch is running and will start automatically on login.`
92
+ : ` ${color.green("✓")} niro-watch is running, but will not restart after you log out.`);
93
+ }
94
+ return { ok: true, autostart };
72
95
  } catch (err) {
73
- console.log(` ${color.yellow("⚠")} Could not install the watch daemon: ${err.message}`);
74
- console.log(` Run ${color.cyan("niro service install")} to try again, or ${color.cyan("niro watch")} to sync manually.`);
96
+ console.log(` ${color.yellow("⚠")} Could not install the watch daemon: ${err.message}`);
97
+ console.log(` Run ${color.cyan("niro service install")} to try again, or ${color.cyan("niro watch")} to sync manually.`);
75
98
  return { ok: false, reason: "daemon_install_failed" };
76
99
  }
77
100
  }
@@ -67,6 +67,17 @@ function load() {
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * A release without the cloud keeps the Niro web folder list, renamed, so nothing reads it as
72
+ * folders to sync any more and the note about them is shown once. Never overwrites an earlier copy.
73
+ */
74
+ const WEB_FILE = path.join(DIR, "projects.web.json");
75
+ function retireToWeb(from = FILE, to = WEB_FILE) {
76
+ if (!fs.existsSync(from) || fs.existsSync(to)) return false;
77
+ fs.renameSync(from, to);
78
+ return true;
79
+ }
80
+
70
81
  function save(state) {
71
82
  fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
72
83
  // Atomic write: a concurrent reader (the niro-watch daemon runs in a SEPARATE
@@ -255,4 +266,4 @@ function hashFile(filePath) {
255
266
  }
256
267
 
257
268
  module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile,
258
- mergeFileHashes, FILE };
269
+ mergeFileHashes, FILE, WEB_FILE, retireToWeb };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Two Windows-only safeguards for the programs niro starts: they start hidden (hideWindows, below), and they are
3
+ * never taken from the current folder. Windows looks for a bare program name in the current folder before the PATH,
4
+ * so a reg.exe, git.exe or pm2.cmd sitting in a cloned repository (or wherever niro is run from) would run
5
+ * instead of the real one. Two runtimes do that lookup, and they need different fixes (checked on
6
+ * Windows Server 2022, 2026-11-11, with planted files):
7
+ *
8
+ * - cmd.exe, which starts pm2 and npm (`shell: true`), skips the current folder when
9
+ * NoDefaultCurrentDirectoryInExePath is set. `harden` sets it once for this process and
10
+ * everything it starts.
11
+ * - Node's own spawn without a shell (libuv) ignores that variable, so a program started that way
12
+ * is named by its full path: `system32` for Windows' own tools, `gitProgram` for git.
13
+ */
14
+ const path = require("path");
15
+
16
+ function harden(env = process.env, platform = process.platform) {
17
+ if (platform !== "win32") return;
18
+ env.NoDefaultCurrentDirectoryInExePath = "1";
19
+ hideWindows(require("child_process"), platform);
20
+ }
21
+
22
+ const STARTERS = ["spawn", "spawnSync", "execFile", "execFileSync", "exec", "execSync", "fork"];
23
+
24
+ /**
25
+ * On Windows, every program niro starts starts hidden unless the caller asks for a window (`windowsHide: false`).
26
+ * Under PM2 the watcher has no console of its own, so each console program it started (the engine every 30 s, git)
27
+ * opened a console window of its own: on a desktop, a terminal flashed open and shut every few seconds (2026-11-11;
28
+ * on Windows Server 2022, 6 console windows in 90 s from one folder). One switch here rather than an option at each of
29
+ * the 60-odd places that start a program, so a new one cannot forget it. Elsewhere nothing changes.
30
+ */
31
+ function hideWindows(childProcess, platform = process.platform) {
32
+ if (platform !== "win32") return;
33
+ for (const name of STARTERS) {
34
+ const original = childProcess[name];
35
+ if (typeof original !== "function" || original.niroHidden) continue;
36
+ const hidden = function (...args) {
37
+ return original.apply(this, withWindowHidden(args));
38
+ };
39
+ hidden.niroHidden = true;
40
+ childProcess[name] = hidden;
41
+ }
42
+ }
43
+
44
+ /** The same arguments, with `windowsHide: true` in the options object (added where none was given). */
45
+ function withWindowHidden(args) {
46
+ const out = [...args];
47
+ const at = out.findIndex((arg, i) => i > 0 && arg && typeof arg === "object" && !Array.isArray(arg));
48
+ if (at !== -1) {
49
+ if (out[at].windowsHide === undefined) out[at] = { ...out[at], windowsHide: true };
50
+ return out;
51
+ }
52
+ // No options: they go after the argument list when there is one, and before a trailing callback.
53
+ out.splice(Array.isArray(out[1]) ? 2 : 1, 0, { windowsHide: true });
54
+ return out;
55
+ }
56
+
57
+ /** A tool Windows ships, by full path (reg.exe, tar.exe). */
58
+ function system32(exe, env = process.env) {
59
+ return path.join(env.SystemRoot || "C:\\Windows", "System32", exe);
60
+ }
61
+
62
+ /** "git" elsewhere; on Windows git.exe's full path from the PATH, or null when git is not installed. */
63
+ function gitProgram(platform = process.platform) {
64
+ return platform === "win32" ? require("./engine").onThePath("git.exe") : "git";
65
+ }
66
+
67
+ module.exports = { harden, hideWindows, system32, gitProgram };
@@ -0,0 +1,382 @@
1
+ /**
2
+ * The Claude Code Stop hook that makes sure the end-of-task line is not forgotten.
3
+ *
4
+ * The line reaches the developer only if the assistant calls mark_task_complete and then copies
5
+ * what comes back. Assistants forget one or the other. This hook runs when the assistant is about
6
+ * to stop, looks at the turn that just ended, and when Niro tools answered and the reply has no
7
+ * line, it asks the assistant, once, to finish the job.
8
+ *
9
+ * **It prints nothing to the person.** An earlier Stop hook printed the line itself and Claude Code
10
+ * labelled that "Stop says: ...", which read as a system warning, so it was removed
11
+ * (docs/usecases/task-receipt.md). This one only returns a decision; the line is still written by
12
+ * the assistant, in backticks, the way every other client shows it.
13
+ *
14
+ * **It always lets the assistant stop when anything goes wrong.** A missing transcript, unreadable
15
+ * JSON, a turn too long to find inside the tail it reads, a transcript that does not catch up with
16
+ * the reply, or running out of its time budget all end in silence. A forgotten line costs one line; a hook that traps an assistant costs the session.
17
+ *
18
+ * **At most once per turn.** Claude Code sets `stop_hook_active` on the stop that follows a block,
19
+ * and on that stop this hook only records what happened.
20
+ *
21
+ * **Which turns.** Only tools from Niro's own servers (`mcp__niro__`, `mcp__niro-local__`), never
22
+ * `mcp__niro-memory__`, which is a different product with a line of its own. Only calls that
23
+ * answered: a call that failed gives no line (the engine says "Nothing to report"), and neither do
24
+ * the calls that manage the session rather than answer about code. Only the main conversation:
25
+ * Claude Code keeps a subagent's calls in its own file (`subagents/agent-<id>.jsonl`) and hands the
26
+ * parent a report, so a turn where only a subagent used Niro has no call for this hook to see, and
27
+ * asking the parent to report work it did not do would produce a line about nothing.
28
+ */
29
+ const fs = require("fs");
30
+
31
+ const lineName = require("./lineName");
32
+
33
+ /** The MCP server names Niro registers under. Exact, because `niro-memory` shares the prefix. */
34
+ const NIRO_SERVERS = new Set(["niro", "niro-local"]);
35
+
36
+ /**
37
+ * Calls that manage the session rather than answer about the developer's code. Upstream's
38
+ * `SessionPlumbingTools` list, which is what the server itself leaves out of the line's count.
39
+ */
40
+ const PLUMBING = new Set([
41
+ "mark_task_complete",
42
+ "should_create_temp_project",
43
+ "select_project",
44
+ "get_index_status",
45
+ "list_projects",
46
+ "get_niro_usage_patterns_for_intent",
47
+ ]);
48
+
49
+ const COMPLETE_TOOL = "mark_task_complete";
50
+
51
+ /**
52
+ * A line of its own starting with the prefix, backticks allowed. `[NIRO-MEMORY]` does not match. The
53
+ * prefix is `[NIRO-CLI]`, `[NIRO-LOCAL]` from an engine older than 2026-11-09, or the cloud's `[NIRO]`
54
+ * (`lineName.js`).
55
+ */
56
+ const LINE_IN_REPLY = new RegExp(`^[ \\t]*\`?(${lineName.PREFIX_SOURCE} [^\`\\n]*)\`?[ \\t]*$`, "m");
57
+ /** The same line inside a tool answer, with the rest of it captured so it can be quoted back. */
58
+ const LINE_IN_ANSWER = new RegExp(`^[ \\t]*(${lineName.PREFIX_SOURCE} .*)$`, "m");
59
+
60
+ const GENUINE = "line";
61
+ const ABSENT = "no-line";
62
+ const FORGED = "forged";
63
+
64
+ /** How much of the transcript's end is read, growing only when the turn starts further back. */
65
+ const TAIL_STEPS = [256 * 1024, 2 * 1024 * 1024, 8 * 1024 * 1024];
66
+ /** Past this the hook gives up and lets the assistant stop. It is a courtesy, not a gate. */
67
+ const TIME_BUDGET_MS = 1500;
68
+ /** How long to wait before reading a transcript again that has not caught up with the reply. */
69
+ const SETTLE_POLL_MS = 40;
70
+
71
+ /**
72
+ * How every request opens. A person sees this sentence on screen under Claude Code's own label, so
73
+ * it says what is happening; and the next stop recognises it in the transcript as already asked.
74
+ */
75
+ const ASK_OPENING = "Niro tools answered this turn";
76
+
77
+ /** The tool's own name when it belongs to a Niro server, otherwise null. */
78
+ function niroToolName(name) {
79
+ if (typeof name !== "string" || !name.startsWith("mcp__")) return null;
80
+ const rest = name.slice("mcp__".length);
81
+ const split = rest.indexOf("__");
82
+ if (split < 0) return null;
83
+ return NIRO_SERVERS.has(rest.slice(0, split)) ? rest.slice(split + 2) : null;
84
+ }
85
+
86
+ /** A person's message, rather than a tool result or something Claude Code added by itself. */
87
+ function startsTurn(entry) {
88
+ if (!entry || entry.type !== "user" || entry.isMeta || entry.isSidechain) return false;
89
+ if (entry.toolUseResult !== undefined) return false;
90
+ const content = entry.message && entry.message.content;
91
+ if (typeof content === "string") return !isStopHookFeedback(content);
92
+ if (!Array.isArray(content)) return false;
93
+ return content.some(
94
+ (block) => block && block.type !== "tool_result" && !isStopHookFeedback(block.text || "")
95
+ );
96
+ }
97
+
98
+ /** What Claude Code writes into the conversation when a Stop hook blocks: part of the same turn. */
99
+ function isStopHookFeedback(text) {
100
+ return /^\s*Stop hook feedback:/i.test(text);
101
+ }
102
+
103
+ function textOf(content) {
104
+ if (typeof content === "string") return content;
105
+ if (!Array.isArray(content)) return "";
106
+ return content.map((block) => (block && typeof block.text === "string" ? block.text : "")).join("\n");
107
+ }
108
+
109
+ /** The line a mark_task_complete answer carried: plain text locally, a JSON field from the cloud. */
110
+ function lineInAnswer(text) {
111
+ const plain = LINE_IN_ANSWER.exec(text);
112
+ if (plain) return plain[1].trim();
113
+ try {
114
+ const parsed = JSON.parse(text);
115
+ if (parsed && typeof parsed.receipt === "string" && LINE_IN_ANSWER.test(parsed.receipt)) {
116
+ return parsed.receipt.trim();
117
+ }
118
+ } catch {
119
+ // Not JSON: the answer carried no line.
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * What the turn did, from the entries after the last person's message. Null when the turn's start
126
+ * is not among the lines given, so a caller can read further back or give up.
127
+ */
128
+ function readTurn(lines, deadline = Infinity) {
129
+ const entries = [];
130
+ let found = false;
131
+ for (let i = lines.length - 1; i >= 0; i--) {
132
+ if (Date.now() > deadline) return null;
133
+ const line = lines[i];
134
+ if (!line) continue;
135
+ let entry;
136
+ try {
137
+ entry = JSON.parse(line);
138
+ } catch {
139
+ continue; // The first line of a tail is usually cut in half.
140
+ }
141
+ if (startsTurn(entry)) {
142
+ found = true;
143
+ break;
144
+ }
145
+ entries.push(entry);
146
+ }
147
+ if (!found) return null;
148
+ entries.reverse();
149
+
150
+ const uses = new Map();
151
+ const results = new Map();
152
+ let lastText = "";
153
+ let asked = false;
154
+ for (const entry of entries) {
155
+ if (entry.isSidechain) continue;
156
+ if (entry.type === "attachment" && JSON.stringify(entry.attachment || {}).includes(ASK_OPENING)) {
157
+ asked = true;
158
+ }
159
+ const content = entry.message && entry.message.content;
160
+ if (!Array.isArray(content)) continue;
161
+ if (entry.type === "assistant") {
162
+ const text = textOf(content.filter((b) => b && b.type === "text"));
163
+ if (text.trim()) lastText = text;
164
+ for (const block of content) {
165
+ if (block && block.type === "tool_use") uses.set(block.id, niroToolName(block.name));
166
+ }
167
+ } else if (entry.type === "user") {
168
+ for (const block of content) {
169
+ if (block && block.type === "tool_result") results.set(block.tool_use_id, block);
170
+ }
171
+ }
172
+ }
173
+
174
+ let answered = 0;
175
+ let completed = false;
176
+ let lineFromTool = null;
177
+ for (const [id, tool] of uses) {
178
+ if (tool === null) continue;
179
+ const result = results.get(id);
180
+ if (!result) continue;
181
+ if (tool === COMPLETE_TOOL) {
182
+ completed = true;
183
+ lineFromTool = lineInAnswer(textOf(result.content)) || lineFromTool;
184
+ } else if (!PLUMBING.has(tool) && !result.is_error) {
185
+ answered += 1;
186
+ }
187
+ }
188
+ return { answered, completed, lineFromTool, lastText, asked };
189
+ }
190
+
191
+ /** Read backwards in growing steps until the turn's start is in view. Null when it never is. */
192
+ function turnFromTranscript(transcriptPath, deadline) {
193
+ let handle;
194
+ try {
195
+ // Only a regular file. Opening a FIFO waits for a writer, before any time budget is looked at,
196
+ // and held the stop until Claude Code's 5 s timeout (independent check of fb3d8cb).
197
+ if (!fs.statSync(transcriptPath).isFile()) return null;
198
+ handle = fs.openSync(transcriptPath, "r");
199
+ const size = fs.fstatSync(handle).size;
200
+ for (const step of TAIL_STEPS) {
201
+ const length = Math.min(step, size);
202
+ const buffer = Buffer.alloc(length);
203
+ fs.readSync(handle, buffer, 0, length, size - length);
204
+ const turn = readTurn(buffer.toString("utf8").split("\n"), deadline);
205
+ if (turn) return turn;
206
+ if (length === size || Date.now() > deadline) return null;
207
+ }
208
+ return null;
209
+ } finally {
210
+ if (handle !== undefined) fs.closeSync(handle);
211
+ }
212
+ }
213
+
214
+ function sleep(ms) {
215
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
216
+ }
217
+
218
+ /**
219
+ * The turn, once the transcript has caught up with the reply; null when it does not in time.
220
+ *
221
+ * Claude Code writes the transcript behind what it hands the hook. Seen in real sessions on 2026-11-05:
222
+ * the reply on stdin carried the true line while mark_task_complete's result was not yet in the file,
223
+ * so the line read as made up, the assistant was asked for it, called the tool again, and printed
224
+ * "Nothing to print" under a reply that had been right. The reply is the last thing written, so once
225
+ * its text is in the file everything before it is too. Without a reply on stdin, as from an older
226
+ * version, the file is all there is.
227
+ */
228
+ function settledTurn(transcriptPath, reply, deadline) {
229
+ for (;;) {
230
+ const turn = turnFromTranscript(transcriptPath, deadline);
231
+ if (typeof reply !== "string") return turn;
232
+ if (turn && turn.lastText.trim() && reply.trim().endsWith(turn.lastText.trim())) return turn;
233
+ if (Date.now() + SETTLE_POLL_MS >= deadline) return null;
234
+ sleep(SETTLE_POLL_MS);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * What the assistant prints above the real line when its reply carried a made-up one. A hook cannot
240
+ * take back what is already on screen, so without this the person sees two lines with different
241
+ * numbers and nothing saying which to trust. Wording approved by Niraj on 2026-11-06.
242
+ */
243
+ const CORRECTION_LABEL = "Correction: the Niro line above was not measured. The measured line is:";
244
+
245
+ /** What the assistant is asked to do. Quotes the line when it already exists. */
246
+ function reasonFor(turn, verdict = ABSENT) {
247
+ if (verdict === FORGED) {
248
+ // The label goes on a line of its own in the request, so it cannot be read as part of a sentence.
249
+ const said = "and the Niro line in your reply did not come from the tool";
250
+ if (turn.lineFromTool) {
251
+ return (
252
+ `${ASK_OPENING} ${said}: print only these two lines, the second in single backticks:\n` +
253
+ `${CORRECTION_LABEL}\n${turn.lineFromTool}`
254
+ );
255
+ }
256
+ return (
257
+ `${ASK_OPENING} ${said}: call mark_task_complete, then print only two lines, and do not repeat ` +
258
+ `your answer. The first is exactly:\n${CORRECTION_LABEL}\nThe second is the line the tool ` +
259
+ "returns, in single backticks. If the tool returns no line, print nothing."
260
+ );
261
+ }
262
+ const said = "and your reply has no Niro line";
263
+ if (turn.lineFromTool) {
264
+ return `${ASK_OPENING} ${said}: print only this, in single backticks: ${turn.lineFromTool}`;
265
+ }
266
+ return (
267
+ `${ASK_OPENING} ${said}: call mark_task_complete, then print only the line it returns, in ` +
268
+ "single backticks. Do not repeat your answer."
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Whether the reply's line is the one the tool returned.
274
+ *
275
+ * Seeing a line is not enough. In real sessions on 2026-11-05 an assistant skipped the tool and wrote
276
+ * the line itself, invented numbers included, in 14 of 62 turns with answers: "[NIRO-LOCAL] 1 call
277
+ * instead of ~2 searches · ~120 tokens instead of ~900". The instructions already forbade it. A figure
278
+ * nobody measured, in the place a measured one goes, is worse than no line, so it counts as a miss.
279
+ */
280
+ function verdictOf(reply, turn) {
281
+ const found = LINE_IN_REPLY.exec(reply || "");
282
+ if (!found) return ABSENT;
283
+ return turn.lineFromTool && comparable(found[1]) === comparable(turn.lineFromTool) ? GENUINE : FORGED;
284
+ }
285
+
286
+ /**
287
+ * The line without what copying it can harmlessly change. Seen in real replies: a stray "]" at the
288
+ * end, and a doubled space. Asking again over those would be noise. A word or a number that differs
289
+ * is not forgiven.
290
+ */
291
+ function comparable(line) {
292
+ // The old and the new name of the local line are one line: an assistant may copy `[NIRO-LOCAL]` from an
293
+ // older engine's answer the bridge did not rename, and that is not a made-up line (`lineName.js`).
294
+ return lineName.renamed(line).replace(/\s+/g, "").replace(/[\]`.]+$/, "");
295
+ }
296
+
297
+ /**
298
+ * What to do about this stop. Pure, so every case is a plain test.
299
+ *
300
+ * @returns {{block: string|null, note: object|null}} `block` is the reason to continue, `note` what
301
+ * to record about a turn where Niro tools answered.
302
+ */
303
+ function decide(input, turn) {
304
+ if (!turn || turn.answered <= 0) return { block: null, note: null };
305
+ const reply =
306
+ typeof input.last_assistant_message === "string" ? input.last_assistant_message : turn.lastText;
307
+ const verdict = verdictOf(reply, turn);
308
+ const note = {
309
+ client_session: typeof input.session_id === "string" ? input.session_id : "",
310
+ niro_calls: turn.answered,
311
+ line: verdict,
312
+ after_block: Boolean(input.stop_hook_active || turn.asked),
313
+ };
314
+ if (verdict === GENUINE || note.after_block) return { block: null, note };
315
+ // The tool ran and said there was nothing to report, so there is no line to ask for.
316
+ if (turn.completed && !turn.lineFromTool) return { block: null, note: null };
317
+ return { block: reasonFor(turn, verdict), note };
318
+ }
319
+
320
+ /**
321
+ * Whether the person or the setup has switched the line off, or nobody is reading.
322
+ *
323
+ * `claude -p` and the Agent SDK hand a script the LAST message as the result. Measured on 2026-11-05:
324
+ * asking for the line there replaced the answer in `result` with the line, so a script got
325
+ * "[NIRO-LOCAL] 1 call" and lost what it asked for. Claude Code names its entry point in the
326
+ * environment: `cli` for a person at a terminal, `sdk-cli`, `sdk-ts` and `sdk-py` for a script.
327
+ */
328
+ function switchedOff(env = process.env) {
329
+ if (String(env.CLAUDE_CODE_ENTRYPOINT || "").startsWith("sdk-")) return true;
330
+ const flag = String(env.NIRO_LOCAL_TASK_COMPLETE || "1").trim().toLowerCase();
331
+ if (["0", "false", "no"].includes(flag)) return true;
332
+ try {
333
+ return require("../lib/receiptStore").isDisabled();
334
+ } catch {
335
+ return false;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Record the outcome in the engine's journal, without waiting for it: the assistant is not held up
341
+ * by bookkeeping, and an engine that is missing (a cloud install) simply records nothing.
342
+ */
343
+ function noteReply(note) {
344
+ try {
345
+ const { spawn } = require("child_process");
346
+ const { enginePath } = require("../lib/localEngine");
347
+ const child = spawn(
348
+ enginePath(),
349
+ [
350
+ "journal",
351
+ "--note-reply",
352
+ note.client_session,
353
+ String(note.niro_calls),
354
+ note.line,
355
+ note.after_block ? "after-block" : "first",
356
+ ],
357
+ { detached: true, stdio: "ignore" }
358
+ );
359
+ child.on("error", () => {});
360
+ child.unref();
361
+ } catch {
362
+ // Bookkeeping only.
363
+ }
364
+ }
365
+
366
+ /** The whole hook: reads stdin, prints a decision or nothing. Never throws, never exits non-zero. */
367
+ function run(raw, { out = process.stdout, record = noteReply, env = process.env } = {}) {
368
+ try {
369
+ const deadline = Date.now() + TIME_BUDGET_MS;
370
+ if (switchedOff(env)) return;
371
+ const input = JSON.parse(raw);
372
+ if (!input || typeof input.transcript_path !== "string") return;
373
+ const turn = settledTurn(input.transcript_path, input.last_assistant_message, deadline);
374
+ const { block, note } = decide(input, turn);
375
+ if (note) record(note);
376
+ if (block) out.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "Stop", additionalContext: block } }));
377
+ } catch {
378
+ // Let the assistant stop.
379
+ }
380
+ }
381
+
382
+ module.exports = { CORRECTION_LABEL, decide, lineInAnswer, niroToolName, readTurn, reasonFor, run, settledTurn, turnFromTranscript, verdictOf };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The name the end-of-task line opens with.
3
+ *
4
+ * The local engine's line opened with `[NIRO-LOCAL]` until 2026-11-09 and opens with `[NIRO-CLI]`
5
+ * from then (Niraj: "all caps"). An engine installed before that still writes the old name, and a
6
+ * machine can hold a newer niro-cli with an older engine for a while, so the bridge reads both
7
+ * names and shows only the new one: the developer sees `[NIRO-CLI]` whichever engine answered.
8
+ * The cloud's `[NIRO]` line is a different product's line and is never renamed.
9
+ *
10
+ * Only what is the line, or is about the line, is renamed: the `mark_task_complete` answer, the
11
+ * engine's end-of-turn reminder, its server instructions and the tool's description. Any other
12
+ * answer is passed on as it came, because a code search can return source that contains the old
13
+ * name as text, and changing that would show the developer a file that is not on their disk.
14
+ */
15
+
16
+ const SHOWN = "[NIRO-CLI]";
17
+ const OLD = "[NIRO-LOCAL]";
18
+
19
+ /** The prefixes a line may open with, as a regular-expression group: `[NIRO]`, `[NIRO-LOCAL]`, `[NIRO-CLI]`. */
20
+ const PREFIX_SOURCE = "\\[NIRO(?:-LOCAL|-CLI)?\\]";
21
+
22
+ /** The old engine's reminder under answers, exactly as niro-local wrote it (receipt.py TURN_REMINDER). */
23
+ const OLD_REMINDER = `(End of turn: call mark_task_complete, print its ${OLD} line last.)`;
24
+ const NEW_REMINDER = `(End of turn: call mark_task_complete, print its ${SHOWN} line last.)`;
25
+
26
+ /** `text` with every `[NIRO-LOCAL]` written as `[NIRO-CLI]`. For text that is about the line only. */
27
+ function renamed(text) {
28
+ return typeof text === "string" ? text.split(OLD).join(SHOWN) : text;
29
+ }
30
+
31
+ /** `text` with only the old engine's end-of-turn reminder renamed, for any other answer. */
32
+ function reminderRenamed(text) {
33
+ return typeof text === "string" ? text.split(OLD_REMINDER).join(NEW_REMINDER) : text;
34
+ }
35
+
36
+ /**
37
+ * An MCP tool answer with `rename` applied to each text block and to `structuredContent.result`, the
38
+ * second copy of the same text niro-local sends for a tool with an output schema. Anything else is
39
+ * left as it is. The same object comes back when nothing changed.
40
+ */
41
+ function answerRenamed(answer, rename = renamed) {
42
+ if (!answer || !Array.isArray(answer.content)) return answer;
43
+ let changed = false;
44
+ const content = answer.content.map((block) => {
45
+ if (!block || block.type !== "text" || typeof block.text !== "string") return block;
46
+ const text = rename(block.text);
47
+ if (text === block.text) return block;
48
+ changed = true;
49
+ return { ...block, text };
50
+ });
51
+ const structured = answer.structuredContent;
52
+ let structuredContent = structured;
53
+ if (structured && typeof structured === "object" && typeof structured.result === "string") {
54
+ const result = rename(structured.result);
55
+ if (result !== structured.result) {
56
+ changed = true;
57
+ structuredContent = { ...structured, result };
58
+ }
59
+ }
60
+ if (!changed) return answer;
61
+ const out = { ...answer, content };
62
+ if (structuredContent !== undefined) out.structuredContent = structuredContent;
63
+ return out;
64
+ }
65
+
66
+ /** The engine's initialize answer with the line's name renamed in its instructions. */
67
+ function initializeRenamed(result) {
68
+ if (!result || typeof result.instructions !== "string") return result;
69
+ const instructions = renamed(result.instructions);
70
+ return instructions === result.instructions ? result : { ...result, instructions };
71
+ }
72
+
73
+ /** The engine's tool list with the line's name renamed in `mark_task_complete`'s description. */
74
+ function toolsRenamed(tools) {
75
+ if (!Array.isArray(tools)) return tools;
76
+ return tools.map((tool) =>
77
+ tool && tool.name === "mark_task_complete" && typeof tool.description === "string"
78
+ ? { ...tool, description: renamed(tool.description) }
79
+ : tool
80
+ );
81
+ }
82
+
83
+ module.exports = {
84
+ SHOWN,
85
+ OLD,
86
+ PREFIX_SOURCE,
87
+ renamed,
88
+ reminderRenamed,
89
+ answerRenamed,
90
+ initializeRenamed,
91
+ toolsRenamed,
92
+ };