@misaka-net/misakanet-setup 0.5.6 → 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.
package/README.md CHANGED
@@ -1,8 +1,15 @@
1
1
  # @misaka-net/misakanet-setup
2
2
 
3
- One command to teach your **Claude Code**, **Codex**, **Hermes**, **OpenClaw** or **codewhale** to
4
- check MisakaNet's failure lessons before repeating a mistake — and to distil the session's reusable
5
- lessons at a checkpoint.
3
+ One command to teach your **Claude Code**, **Codex**, **Hermes**, **OpenClaw**, **codewhale**,
4
+ **Cursor**, **Gemini CLI**, **Copilot CLI**, **OpenCode** or **Kiro** to
5
+ check MisakaNet's failure lessons before repeating a mistake — and to write up the reusable lessons
6
+ this session produced, at a checkpoint.
7
+
8
+ **What it writes:** an MCP endpoint into each agent config, rules blocks for the clients that take
9
+ one, and a local hook for Claude Code. **What leaves this machine:** at most one anonymous request
10
+ while the installer runs; afterwards, only what the agent itself sends — with one opt-in exception:
11
+ `MISAKANET_HOOK_FETCH=1` (off by default) makes the hook itself send the failed call's error text
12
+ (or the command, when the tool gave no error text) as a search query.
6
13
 
7
14
  ```bash
8
15
  npx @misaka-net/misakanet-setup
@@ -16,19 +23,54 @@ knowledge base on its own instead of guessing.
16
23
 
17
24
  | # | Goal | Mechanism |
18
25
  |---|---|---|
19
- | 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json`, `~/.codex/config.toml` and `~/.openclaw/openclaw.json` (`mcp.servers.misakanet`), with the Bearer token when registration succeeds — reads need no token (anonymous reads are unmetered since 2026-09-18); the token is what opens the **write** path. Every entry also carries the self-declared context hints `X-MisakaNet-Client` / `-Agent` / `-Os` / `-Version`, which the endpoint records on the read's analytics row and never treats as identity (`codewhale` is the one exception: its server entry has no custom-header field). Hermes is the same thing in its own files: `mcp_servers.misakanet` in `~/.hermes/config.yaml` plus the token in `~/.hermes/.env` under `MCP_MISAKANET_API_KEY`. All four are config files written file-to-file — no subprocess is spawned, so **no token ever appears in a command line**. For Claude Code only, the five **read-only** tools are also added to `permissions.allow` — "can call it" otherwise still means a permission prompt on the very first search, so `0.5.3` pre-allows them (`--verify` reports it, `--report` prints `permissions:`); `write_lesson` is deliberately **not** pre-allowed, because a tool that writes should ask |
20
- | 2 | the agent **knows when** | rules block appended to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.hermes/SOUL.md` / `~/.openclaw/workspace/AGENTS.md` (issue, retry, risky-operation triggers; desensitisation rules). Wording is **imperative first** ("必须先调 misakanet_search") rather than descriptive, because a field test showed a weaker model reads a descriptive block as background and never calls the tool |
26
+ | 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json`, `~/.codex/config.toml` and `~/.openclaw/openclaw.json` (`mcp.servers.misakanet`), with the Bearer token when registration succeeds — reads need no token (anonymous reads are unmetered since 2026-09-18); the token is what opens the **write** path. Every entry also carries the self-declared context hints `X-MisakaNet-Client` / `-Agent` / `-Os` / `-Version`, which the endpoint records on the read's analytics row and never treats as identity (`codewhale` is the one exception: its server entry has no custom-header field). Hermes is the same thing in its own files: `mcp_servers.misakanet` in `~/.hermes/config.yaml` plus the token in `~/.hermes/.env` under `MCP_MISAKANET_API_KEY`. Five clients take the same idea in one file each, and **each file uses its own key path and URL field** — they are not interchangeable, and a copied entry with the wrong key fails silently (no error, the server simply never appears):
27
+
28
+ | client | file | container | URL field |
29
+ |---|---|---|---|
30
+ | Cursor | `~/.cursor/mcp.json` | `mcpServers` | `url` |
31
+ | Gemini CLI | `~/.gemini/settings.json` | `mcpServers` | **`httpUrl`** (its `url` means SSE) |
32
+ | Copilot CLI | `~/.copilot/mcp-config.json` | `mcpServers` | `url`, with `type: "http"` |
33
+ | OpenCode | `~/.config/opencode/opencode.json` | **`mcp`** | `url`, with `type: "remote"` |
34
+ | Kiro | `~/.kiro/settings/mcp.json` | `mcpServers` | `url` |
35
+
36
+ All of them are config files written file-to-file — no subprocess is spawned, so **no token ever appears in a command line**. For Claude Code only, the five **read-only** tools are also added to `permissions.allow` — "can call it" otherwise still means a permission prompt on the very first search, so `0.5.3` pre-allows them (`--verify` reports it, `--report` prints `permissions:`); `write_lesson` is deliberately **not** pre-allowed, because a tool that writes should ask |
37
+ | 2 | the agent **knows when** | rules block appended to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.hermes/SOUL.md` / `~/.openclaw/workspace/AGENTS.md`. **The five JSON-file clients are the exception: they get none**, because their rules are project-scoped (`.cursor/rules/*.mdc`, `GEMINI.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `.kiro/steering/*.md`) and the installer cannot know where your projects live — the install output says so, with the project-side action for each client, instead of implying behaviour changed (issue, retry, risky-operation triggers; desensitisation rules). Wording is **imperative first** ("必须先调 misakanet_search") rather than descriptive, because a field test showed a weaker model reads a descriptive block as background and never calls the tool |
21
38
  | 4 | *(opt-in)* you **hear** it | `--voice` adds a `PostToolUse` hook that plays the cue the server asked for (`lesson-found` / `failure-warning` / `connect-success` / `pair-success`). Off by default; mute with `MISAKANET_VOICE=0`; see `docs/integrations/mcp-voice-hooks.md` |
22
- | 3 | the checkpoint **fires** | a hook that counts user turns: turn 1 announces the install to the user, turn 20 (and every 10 after) injects the "distil and submit" reminder; a failed tool call injects a "search before you retry" reminder built from the error text. Claude Code only today — Codex's user-level hook shape is unconfirmed and OpenClaw's events are unverified, so those two work from the rules block, and `--verify` says so per target instead of implying otherwise |
39
+ | 3 | the checkpoint **fires** | a hook that counts user turns: turn 1 announces the install to the user, turn 20 (and every 10 after) injects the "write up the reusable lessons and submit them" reminder — the agent does that writing and that submitting, by hand, and the hook itself sends nothing by default; a failed tool call injects a "search before you retry" reminder built from the error text, and only with `MISAKANET_HOOK_FETCH=1` (off by default) does the hook itself send that error text as a search query. Claude Code only today — Codex's user-level hook shape is unconfirmed and OpenClaw's events are unverified, so those two work from the rules block, and `--verify` says so per target instead of implying otherwise |
23
40
 
24
41
  Without (3) a rule saying "summarise every 20 turns" never fires — agents do not keep
25
42
  counters. Without (1)/(2) the hook has nothing to call.
26
43
 
44
+ ### dsh is not a file target — it is a plugin
45
+
46
+ dsh takes its MCP servers from a **plugin bundle**, not from a JSON file. Its instruction files are a
47
+ chain: **`$DSH_HOME/AGENTS.md`** (user-global, default `~/.dsh/AGENTS.md`, applies to every session)
48
+ first, then the project's `AGENTS.md`/`CLAUDE.md` from the project root down to the working directory
49
+ (`@deepseek-ai/dsh-agent-instructions`, byte-budgeted at 65,536 by default). It has no Claude-Code-style
50
+ hooks. So the two commands are:
51
+
52
+ ```bash
53
+ dsh plugin --profile web add misakanet # ① the tools (mcp__misakanet__*)
54
+ # ② put the rules block in ~/.dsh/AGENTS.md (global) or the project's AGENTS.md
55
+ ```
56
+
57
+ Measured 2026-10-17: writing `~/.dsh/AGENTS.md` is picked up as `user-global` instructions on the next
58
+ message, with no restart.
59
+
60
+ Measured 2026-10-17: installing the current `misakanet` takes ~1s into `~/.dsh/profiles/web`; the plugin's
61
+ `index.js` mounts the MCP client and `lib/client.js` is the web panel — **neither injects a rule**,
62
+ which is why ② is a separate, manual step. Full guide: `docs/integrations/dsh.md`.
63
+
27
64
  ## Flags
28
65
 
29
66
  ```bash
30
67
  npx @misaka-net/misakanet-setup --dry-run # show what would change, write nothing
31
68
  npx @misaka-net/misakanet-setup --verify # READY / NOT READY, installed version, and the fix for each gap
69
+ # two sections, two questions: 端点/可用性 (can you use it — the
70
+ # endpoint answers) and 安装完整性 (are the files we prefer
71
+ # written); one failing does not condemn the other, and a file
72
+ # we cannot write says 装不了 with its cause — not 没装 with
73
+ # the one fix that cannot work ("re-run the installer")
32
74
  npx @misaka-net/misakanet-setup --only claude # one agent only
33
75
  npx @misaka-net/misakanet-setup --no-register # read-only, no anonymous token
34
76
  npx @misaka-net/misakanet-setup --upgrade # same as installing the latest (the command is idempotent)
@@ -98,6 +140,10 @@ GPO / Intune / Jamf / Ansible snippets, and the section an IT security review ac
98
140
  `--no-register` avoids registration entirely):
99
141
  [`docs/maintainer/enterprise-deployment.md`](https://github.com/Ikalus1988/MisakaNet/blob/main/docs/maintainer/enterprise-deployment.md).
100
142
 
143
+ A reviewer-facing landing page — the same boundary questions, plus an explicit list of what does
144
+ **not** exist yet (no compliance certifications, no organisation-level audit export, no self-hosted
145
+ form of the hosted endpoint): <https://misakanet.org/enterprise/>.
146
+
101
147
  The short version for a deployment script — two commands, both silent:
102
148
 
103
149
  ```bash
@@ -153,9 +199,30 @@ npx @misaka-net/misakanet-setup@latest
153
199
  ## Offline / restricted networks
154
200
 
155
201
  The hook ships inside the npm tarball, so installing needs no download beyond npm itself.
156
- Registration is best-effort: without it you keep the anonymous read path (5 reads/day/IP)
202
+ Registration is best-effort: without it you keep the anonymous read path (unmetered since 2026-09-18; only a per-address burst window applies)
157
203
  and the installer says so in plain words.
158
204
 
205
+ **Behind a corporate proxy, read the probe line before believing it.** `--verify` probes the
206
+ endpoint with Node's `fetch`, and Node ignores `HTTP_PROXY` / `HTTPS_PROXY` unless the runtime is
207
+ told to use them — `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, with `fetch` support in Node
208
+ ≥ 22.21.0 / 24.0.0 ([Node docs](https://nodejs.org/learn/http/enterprise-network-configuration)).
209
+ On a proxy machine the probe can therefore fail while your agent reaches the endpoint fine. The
210
+ report now says which of the two it is:
211
+
212
+ ```bash
213
+ NODE_USE_ENV_PROXY=1 npx @misaka-net/misakanet-setup --verify # let Node use the proxy
214
+ curl -sS https://misakanet.org/mcp -H 'Accept: application/json' \
215
+ -H 'MCP-Protocol-Version: 2025-06-18' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
216
+ ```
217
+
218
+ `endpoint-probe: inconclusive-proxy` in `--report` means exactly this: a proxy is configured, this
219
+ process could not use it, so the `endpoint-reachable` boolean above it says nothing. `--strict` /
220
+ `--ci` still fail in that case — a gate that cannot tell must not pass silently. If your proxy also
221
+ intercepts TLS, add `NODE_USE_SYSTEM_CA=1`.
222
+
223
+ The Python installer (`integrations/agent-autostart/install_misakanet_agent.py`) needs none of this:
224
+ it uses `urllib`, which honours the proxy environment variables by default.
225
+
159
226
  ## Requires
160
227
 
161
228
  Node 18+ (the same runtime your assistant already uses). No dependencies, no Python.
@@ -113,7 +113,8 @@ const SILENT = has('--silent');
113
113
  /** `--report-json`: the same report as JSON, and nothing else on stdout (#1784). */
114
114
  const REPORT_JSON = has('--report-json');
115
115
  const HOME = resolve(valueOf('--home', homedir()));
116
- const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale'];
116
+ const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
117
+ 'cursor', 'gemini', 'copilot', 'opencode', 'kiro'];
117
118
 
118
119
  const done = [];
119
120
  const manual = [];
@@ -146,8 +147,17 @@ function readText(path) {
146
147
  * (the value goes into the User-Agent header): js/file-access-to-http #268. The drift it was
147
148
  * meant to prevent is now caught in CI instead, by a test that binds this literal to the
148
149
  * manifest — a failing test is a better place for that than a request header.
150
+ *
151
+ * **Who moves it:** the release tooling, not a person. The `x-release-please-version` marker on
152
+ * this line is what release-please's generic updater looks for, and the file is listed in the
153
+ * setup package's `extra-files`, so the release pull request bumps this literal together with
154
+ * the manifest. That marker is not decoration: release-please replaces a version **only on a
155
+ * line that carries the marker** — an unmarked line is copied verbatim, so a release would ship
156
+ * a stale `--version` and a stale `X-MisakaNet-Version` header while every gate stayed green
157
+ * except the two that name the version (measured 2026-10-22: the 0.6.0 release pull request
158
+ * failed 9 e2e legs on exactly that).
149
159
  */
150
- const VERSION = '0.5.6'
160
+ const VERSION = '0.6.0' // x-release-please-version
151
161
 
152
162
  /**
153
163
  * The shape of a client id, in one place.
@@ -159,6 +169,9 @@ const VERSION = '0.5.6'
159
169
  * this cannot contain a quote, a newline or a `:`, so it cannot break any of the three.
160
170
  */
161
171
  const CLIENT_ID_SHAPE = /^[A-Za-z0-9._-]{8,64}$/
172
+ // The server's rule for a referral code (`misakanet_register` → `referral_code`), kept identical on
173
+ // both sides: a value the client is willing to send and the server is willing to count.
174
+ const REFERRAL_SHAPE = /^[A-Za-z0-9]{4,16}$/
162
175
 
163
176
  // ── argument surface ─────────────────────────────────────────────────
164
177
  // Until 2026-09-17 no flag was validated at all: `--help` fell through to a *real install*
@@ -206,9 +219,6 @@ if (has('--version')) {
206
219
  process.exit(0);
207
220
  }
208
221
 
209
- /** What we would write, and how to take it back — printed by --list-writes, and used by the report. */
210
- const WRITE_MANIFEST = [];
211
-
212
222
  const KNOWN = new Set(FLAGS.flatMap(([flag]) => flag.split(/[,\s]/).filter((f) => f.startsWith('-'))));
213
223
  {
214
224
  const unknown = args.filter((a) => a.startsWith('-') && !KNOWN.has(a));
@@ -318,6 +328,91 @@ function writeText(path, text, { mode } = {}) {
318
328
  }
319
329
  }
320
330
 
331
+ /**
332
+ * The targets whose whole install is one MCP entry in one JSON file.
333
+ *
334
+ * Each entry shape is the one that target's own documentation shows, and they are **not**
335
+ * interchangeable: Gemini CLI's remote field is `httpUrl` (its `url` means SSE), OpenCode nests under
336
+ * `mcp` rather than `mcpServers` and wants `type: "remote"`, Copilot CLI wants `type: "http"`, and
337
+ * Cursor and Kiro use a bare `url`. A copied entry with the wrong key fails **silently** — no error,
338
+ * the server simply never appears — which is why each shape is pinned by tests instead of assumed.
339
+ *
340
+ * None of these five has a rules block or a hook *this installer can write*: their rules live in
341
+ * project-scoped files (`.cursor/rules/*.mdc`, `GEMINI.md`, `AGENTS.md`, `.github/copilot-instructions.md`,
342
+ * `.kiro/steering/*.md`) and we do not know where a user's projects are. So tier ① is the whole
343
+ * install for them, `--mcp-only` is an identity, and the output says "MCP only" out loud instead of
344
+ * implying behaviour changed. That is also why `--report` says `install-scope: mcp-only` on a machine
345
+ * whose only target is one of these.
346
+ */
347
+ const MCP_ONLY_TARGETS = {
348
+ cursor: {
349
+ label: 'Cursor',
350
+ detect: ['.cursor'],
351
+ config: (home) => join(home, '.cursor', 'mcp.json'),
352
+ container: 'mcpServers',
353
+ urlField: 'url',
354
+ entry: (bearer) => ({ url: ENDPOINT, headers: mcpHeaders(bearer, 'cursor') }),
355
+ rules: '.cursor/rules/*.mdc 是项目级的,安装器不知道你的项目在哪',
356
+ ruleHint: '想让 Cursor 主动去查,把 .cursor/rules/misakanet-failure-memory.mdc 放进项目',
357
+ manual: '重启 Cursor 后在 Settings → MCP 里确认能看见 misakanet(本安装器只写用户级 '
358
+ + '~/.cursor/mcp.json,项目级 .cursor/mcp.json 归你自己管)',
359
+ verifyHint: '在 Settings → MCP 里复核',
360
+ },
361
+ gemini: {
362
+ label: 'Gemini CLI',
363
+ detect: ['.gemini'],
364
+ config: (home) => join(home, '.gemini', 'settings.json'),
365
+ container: 'mcpServers',
366
+ // `httpUrl` is the HTTP-streaming endpoint; `url` would mean SSE. Documented property list:
367
+ // geminicli.com/docs/tools/mcp-server ("headers: Custom HTTP headers when using url or httpUrl").
368
+ urlField: 'httpUrl',
369
+ entry: (bearer) => ({ httpUrl: ENDPOINT, headers: mcpHeaders(bearer, 'gemini') }),
370
+ rules: '它读 GEMINI.md(全局 ~/.gemini/GEMINI.md 与项目树),而安装器不写别人的规则文件',
371
+ ruleHint: '想让 Gemini CLI 主动去查,把规则块加进项目的 GEMINI.md',
372
+ manual: '重启会话后用 `/mcp` 复核 misakanet 是否列出',
373
+ verifyHint: '用 /mcp 复核',
374
+ },
375
+ copilot: {
376
+ label: 'Copilot CLI',
377
+ detect: ['.copilot'],
378
+ config: (home) => join(home, '.copilot', 'mcp-config.json'),
379
+ container: 'mcpServers',
380
+ urlField: 'url',
381
+ entry: (bearer) => ({ type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'copilot') }),
382
+ rules: '它读 .github/copilot-instructions.md 与 AGENTS.md(项目级),安装器不写项目文件',
383
+ ruleHint: '想让 Copilot CLI 主动去查,把规则写进项目的 .github/copilot-instructions.md',
384
+ manual: '跑 `copilot mcp list` 复核(VS Code 里的 Copilot 是另一份配置,键名是 `servers`)',
385
+ verifyHint: '用 `copilot mcp list` 复核',
386
+ },
387
+ opencode: {
388
+ label: 'OpenCode',
389
+ detect: ['.config/opencode'],
390
+ config: (home) => join(home, '.config', 'opencode', 'opencode.json'),
391
+ container: 'mcp',
392
+ urlField: 'url',
393
+ entry: (bearer) => ({ type: 'remote', url: ENDPOINT, enabled: true,
394
+ headers: mcpHeaders(bearer, 'opencode') }),
395
+ rules: '它读 AGENTS.md(项目根与 ~/.config/opencode/AGENTS.md),安装器不写别人的规则文件',
396
+ ruleHint: '想让 OpenCode 主动去查,把规则块加进项目的 AGENTS.md',
397
+ manual: '重启 OpenCode 后看它的 MCP 列表;若你设了 XDG_CONFIG_HOME,它读的是 '
398
+ + '$XDG_CONFIG_HOME/opencode/opencode.json,把同样的条目贴过去即可',
399
+ verifyHint: '看 OpenCode 的 MCP 列表复核',
400
+ parseNote: 'opencode.jsonc 允许注释,若你用的正是它,本安装器解析不了',
401
+ },
402
+ kiro: {
403
+ label: 'Kiro',
404
+ detect: ['.kiro'],
405
+ config: (home) => join(home, '.kiro', 'settings', 'mcp.json'),
406
+ container: 'mcpServers',
407
+ urlField: 'url',
408
+ entry: (bearer) => ({ url: ENDPOINT, headers: mcpHeaders(bearer, 'kiro') }),
409
+ rules: '它读 .kiro/steering/*.md(steering 文件),安装器不写别人的规则文件',
410
+ ruleHint: '想让 Kiro 主动去查,把规则加进 .kiro/steering/',
411
+ manual: '重启 Kiro 后用它的 MCP 面板复核 misakanet(Kiro 还支持 disabled / autoApprove 字段)',
412
+ verifyHint: '用 Kiro 的 MCP 面板复核',
413
+ },
414
+ };
415
+
321
416
  /**
322
417
  * The files each assistant's install writes, so a permission problem can be reported *before*
323
418
  * anything is touched (and so `--dry-run` can say "I cannot change that" as well as "I would change
@@ -330,9 +425,111 @@ const AGENT_WRITE_PATHS = {
330
425
  codex: (home) => [join(home, '.codex', 'config.toml'), join(home, '.codex', 'AGENTS.md')],
331
426
  hermes: (home) => [join(home, '.hermes', 'config.yaml'), join(home, '.hermes', 'SOUL.md')],
332
427
  codewhale: (home) => [join(home, '.codewhale', 'mcp.json')],
428
+ // Every MCP-only target writes exactly one file, so the table above is the single source of truth
429
+ // for the write manifest, the detection paths and the report's scope line.
430
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
431
+ .map(([id, spec]) => [id, (home) => [spec.config(home)]])),
333
432
  openclaw: () => openclawWorkspaces().map((workspace) => join(workspace, 'AGENTS.md')),
334
433
  };
335
434
 
435
+ /**
436
+ * The write manifest: every file this run would touch, with the kind of write and the tier it
437
+ * belongs to.
438
+ *
439
+ * One builder, two readers: `--list-writes` prints it for an operator who has to approve it, and
440
+ * the install preview prints it for the user who is about to say yes (#3690). They cannot drift,
441
+ * because there is only one list — a preview that disagrees with the manifest is worse than no
442
+ * preview at all.
443
+ *
444
+ * The tiers are the permission tiers from the top of this file: ① registers an MCP endpoint,
445
+ * ② writes a rules block (that changes how the agent behaves), ③ installs hooks and state (that
446
+ * run on every turn). Under `--mcp-only` only ① is in scope, so the other two are not listed —
447
+ * the same rule the install itself follows.
448
+ */
449
+ function buildWriteManifest(agents) {
450
+ // Compare base names, not separators: a `/settings.json$` regex silently classifies nothing on
451
+ // Windows (all three windows legs went red on the first CI run of this change), and a manifest
452
+ // that hides a tier is worse than no manifest.
453
+ const classify = (path) => {
454
+ const base = basename(path);
455
+ if (base === 'CLAUDE.md' || base === 'AGENTS.md' || base === 'SOUL.md') return { tier: 2, kind: 'rules' };
456
+ if (path.includes('.misakanet-agent')) return { tier: 3, kind: 'state' };
457
+ // `settings.json` carries both the read-only grants (①) and the hooks (③); the hook row is
458
+ // added separately below, and only when the hooks are in scope.
459
+ if (base === 'settings.json' && path.includes('.claude')) return { tier: 1, kind: 'grants' };
460
+ return { tier: 1, kind: 'mcp' };
461
+ };
462
+ const rows = new Map();
463
+ const add = (path, action, tier, kind) => {
464
+ const key = `${tier}|${path}`;
465
+ if (!rows.has(key)) rows.set(key, { path: redact(path), action, tier, kind });
466
+ };
467
+ for (const agent of agents) {
468
+ if (!detect(agent)) continue;
469
+ for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
470
+ const { tier, kind } = classify(path);
471
+ add(path, existsSync(path) ? 'append-or-update' : 'create', tier, kind);
472
+ if (!MCP_ONLY && basename(path) === 'settings.json' && path.includes('.claude')) {
473
+ add(path, 'append-or-update(钩子)', 3, 'hook');
474
+ }
475
+ }
476
+ }
477
+ // The state dir is ours, but it is still written to disk — and only when there is something to
478
+ // install: with no target detected the run writes nothing at all.
479
+ if (agents.length) {
480
+ // `--no-register` registers no anonymous node, so no token is written; listing it anyway would
481
+ // make the manifest — and the preview's file count — promise a file that stays absent.
482
+ if (!has('--no-register')) add(join(stateDir(), 'token'), 'create (0600)', 1, 'token');
483
+ if (!MCP_ONLY) {
484
+ add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
485
+ add(join(stateDir(), STATE_VERSION_FILE), 'create', 3, 'version');
486
+ }
487
+ }
488
+ return [...rows.values()]
489
+ .filter((row) => (MCP_ONLY ? row.tier === 1 : true))
490
+ .sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
491
+ }
492
+
493
+ /**
494
+ * The preview: what this run is about to write, said *before* it writes it (#3690).
495
+ *
496
+ * A user who never opens a checkout has no way to know that an installer also edits the agent's
497
+ * rules file — and that file is the one that changes how the agent behaves. So the summary names
498
+ * the files, the kind of change, and the three ways out: look first (`--list-writes`), ask for the
499
+ * endpoint only (`--mcp-only`), or take it back (`--uninstall`).
500
+ *
501
+ * One line per file, one line of ways out, and every path redacted to `~` — the person reading
502
+ * this may never see a checkout, so nothing here may assume one.
503
+ */
504
+ function writePreview(rows) {
505
+ const files = new Map();
506
+ for (const row of rows) {
507
+ const file = files.get(row.path) || { path: row.path, tier: row.tier, kinds: [] };
508
+ file.tier = Math.min(file.tier, row.tier);
509
+ file.kinds.push(row.kind);
510
+ files.set(row.path, file);
511
+ }
512
+ const list = [...files.values()].sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
513
+ if (!list.length) return '';
514
+ const labels = {
515
+ mcp: 'MCP 端点',
516
+ grants: '只读工具放行',
517
+ rules: '规则块(会改变 agent 的行为)',
518
+ hook: '钩子',
519
+ token: 'MCP 凭据',
520
+ hookScript: '钩子脚本',
521
+ version: '版本戳',
522
+ state: '本机状态',
523
+ };
524
+ const width = Math.min(Math.max(...list.map((f) => f.path.length)) + 2, 32);
525
+ const pad = (text) => (text.length >= width ? `${text} ` : text.padEnd(width));
526
+ return [
527
+ `将修改 ${list.length} 个文件${DRY ? '(--dry-run:只是预览,不写任何东西)' : ''}:`,
528
+ ...list.map((f) => ` ${pad(f.path)}+ ${f.kinds.map((k) => labels[k]).join(' + ')}`),
529
+ '先看:--list-writes 不改行为:--mcp-only 撤销:--uninstall',
530
+ ].join('\n');
531
+ }
532
+
336
533
  /**
337
534
  * Which of these paths this process may not write.
338
535
  *
@@ -413,6 +610,19 @@ function ourHookEntries(hooks) {
413
610
  }
414
611
 
415
612
  /** Insert or refresh a marker-delimited block. Uses a function replacement (no $-escapes). */
613
+ /**
614
+ * Is tier ③ (the behaviour layer) actually *wired*, or merely present on disk?
615
+ *
616
+ * `hook.mjs` living in our own state directory proves nothing about the user's agents: the hook only
617
+ * runs where a config file points at it, and today that is Claude Code alone. `--report` used to
618
+ * answer `install-scope: full` from the file's existence, so a Cursor-only machine claimed the
619
+ * behaviour layer was installed. It is `full` only when Claude Code's settings carry our entries.
620
+ */
621
+ function hookWired() {
622
+ const settings = readJson(join(HOME, '.claude', 'settings.json'), null);
623
+ return ourHookEntries(settings?.hooks).length > 0;
624
+ }
625
+
416
626
  function injectBlock(path, block) {
417
627
  const existing = readText(path);
418
628
  const pattern = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
@@ -483,6 +693,19 @@ function mcpHeaders(bearer, agent) {
483
693
  * syntaxes, so it is checked against `CLIENT_ID_SHAPE` first. A `client_id` that is not id-shaped is
484
694
  * not a hint worth writing; it is noise that could close a quote or a mapping.
485
695
  */
696
+ /**
697
+ * The referral code of the node that invited this one, or `''`.
698
+ *
699
+ * `python3 scripts/referral.py --apply=CODE` writes it to `<stateDir>/referral_code` — the same
700
+ * directory `client_id` and `token` live in, so every client on the machine can read it. Shape-checked
701
+ * for the same reason `client_id` is: it is a value from a file on its way into an API request, and the
702
+ * server will ignore anything that is not code-shaped, which would be a silent no-op (#1996).
703
+ */
704
+ function declaredReferral() {
705
+ const found = readText(join(stateDir(), 'referral_code')).trim();
706
+ return REFERRAL_SHAPE.test(found) ? found : '';
707
+ }
708
+
486
709
  function declaredClientId() {
487
710
  if (knownClientId) return knownClientId;
488
711
  const found = readText(join(stateDir(), 'client_id')).trim();
@@ -503,6 +726,38 @@ function probeEndpoint() {
503
726
  return (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
504
727
  }
505
728
 
729
+ /**
730
+ * The proxy environment, and whether *this* Node will actually use it.
731
+ *
732
+ * Node's `fetch` ignores `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` unless the runtime is told to
733
+ * honour them — `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, with fetch support in v22.21.0 /
734
+ * v24.0.0+ (nodejs.org/learn/http/enterprise-network-configuration). So in an enterprise-proxy
735
+ * environment a failed probe is **not** evidence that the endpoint is down: `curl` and the user's
736
+ * agent may both be reaching it through a proxy this process never saw. Reporting that as
737
+ * "端点不可达(网络受限?)" sends a user whose install works off to debug a network that is fine —
738
+ * and the audience for this installer includes exactly the people whose proxies cause this.
739
+ *
740
+ * Returns `null` when no proxy is configured (the ordinary path), otherwise the variables in play
741
+ * and whether the runtime was told to use them.
742
+ */
743
+ function proxyEnvironment() {
744
+ const vars = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']
745
+ .filter((name) => (process.env[name] || '').trim());
746
+ if (!vars.length) return null;
747
+ const enabled = Boolean((process.env.NODE_USE_ENV_PROXY || '').trim())
748
+ || process.execArgv.some((arg) => arg.includes('use-env-proxy'))
749
+ || (process.env.NODE_OPTIONS || '').includes('use-env-proxy');
750
+ return { vars, enabled, noProxy: (process.env.NO_PROXY || process.env.no_proxy || '').trim() };
751
+ }
752
+
753
+ /** The one line that turns an inconclusive probe into a next action. */
754
+ function proxyProbeAdvice(proxy) {
755
+ return `本机配了代理(${proxy.vars.join('、')}),而 Node 的 fetch 默认不读这些变量 → 探测结果不可信,`
756
+ + '不代表端点不可达。确认方式:先 `curl -sS https://misakanet.org/mcp` 手测,'
757
+ + '或让 Node 走代理重跑:`NODE_USE_ENV_PROXY=1 npx @misaka-net/misakanet-setup --verify`'
758
+ + '(fetch 需要 Node ≥ 22.21.0 / 24.0.0;公司代理做 TLS 拦截时再加 `NODE_USE_SYSTEM_CA=1`)';
759
+ }
760
+
506
761
  /**
507
762
  * One unauthenticated MCP call.
508
763
  *
@@ -621,7 +876,7 @@ const PROMPT_BLOCK = `调用规则(按顺序照做):
621
876
  5) 查不到就 misakanet_submit_intake(kind="question")。
622
877
  6) 约 20 轮后、或刚解决一个非平凡问题时:把本次「失败→根因→修复→验证」里可泛化、有判据、且搜过没有重复的部分,
623
878
  脱敏后 misakanet_submit_intake(kind="missing_lesson") 提交;不够价值就不提交。
624
- 脱敏:密钥/凭据→<REDACTED>,人名/邮箱/真实域名/绝对家目录→泛化。全程不要打断用户任务。`;
879
+ 脱敏:已由代码自动完成——本地钩子发出的查询在离开本机前、服务端入库前,都会把密钥/绝对路径/邮箱换成占位符;你不必手动改写,但仍不要粘整段会话转录或与故障无关的业务机密。全程不要打断用户任务。`;
625
880
 
626
881
  // ── the hook file: shipped in the package, downloaded only if missing ─
627
882
  /**
@@ -771,7 +1026,10 @@ async function ensureIdentity() {
771
1026
  // export — an instruction a non-technical user cannot follow, and on Windows a different one.
772
1027
  const clientId = usable ? explicit : `setup-${randomUUID()}`;
773
1028
  knownClientId = clientId;
774
- const result = await mcpCall('misakanet_register', { agent_type: 'setup', client_id: clientId });
1029
+ const referral = declaredReferral();
1030
+ const result = await mcpCall('misakanet_register', {
1031
+ agent_type: 'setup', client_id: clientId, ...(referral ? { referral_code: referral } : {}),
1032
+ });
775
1033
  // Validate before persisting: a response body is not something to write to disk unchecked
776
1034
  // (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
777
1035
  // ours, but "trust the shape" is the correct habit and it makes the value failing to match
@@ -781,8 +1039,22 @@ async function ensureIdentity() {
781
1039
  // Say what it costs, not what went wrong internally: "凭据形状不对" is jargon. Reading is not
782
1040
  // what is lost here (it needs no token and is not metered) — the write path is, and saying so
783
1041
  // plainly is the difference between a user retrying and a user concluding the tool is broken.
784
- need('匿名注册没成功(多半是网络)→ 读课程不受影响,但写入类工具(write_lesson)现在用不了;'
785
- + '网络恢复后重跑本命令就能拿到 token');
1042
+ //
1043
+ // And say *whose* problem it is. This branch used to say "多半是网络" unconditionally, which on
1044
+ // 2026-10-18 was measurably wrong: the server answered
1045
+ // `{"code":"storage_unavailable","storage":{"token":true},"hint":"Retry shortly."}` because D1's
1046
+ // daily quota was spent, and the user was sent to inspect a network that was working. The
1047
+ // server already names the cause and the wait; the fix is to read it instead of guessing.
1048
+ const serverCode = typeof result?.code === 'string' ? result.code : '';
1049
+ const serverHint = typeof result?.hint === 'string' ? result.hint.trim() : '';
1050
+ const cause = serverCode === 'storage_unavailable'
1051
+ ? '服务端暂时存不了 token(**不是你的网络**)'
1052
+ : serverCode
1053
+ ? `服务端拒绝了这次注册(${serverCode})`
1054
+ : `多半是网络(${lastRequestError || '没有收到响应'})`;
1055
+ need(`匿名注册没成功:${cause} → 读课程不受影响,但写入类工具(write_lesson)现在用不了;`
1056
+ + '稍后重跑本命令就能拿到 token'
1057
+ + (serverHint ? `(服务端提示:${serverHint})` : ''));
786
1058
  return '';
787
1059
  }
788
1060
  // Created with mode 0600 rather than written and then chmodded: the old order left a window in
@@ -809,6 +1081,8 @@ function mcpEntryPresent(agent) {
809
1081
  hermes: () => readText(join(HOME, '.hermes', 'config.yaml')),
810
1082
  openclaw: () => readText(join(HOME, '.openclaw', 'openclaw.json')),
811
1083
  codewhale: () => readText(join(HOME, '.codewhale', 'mcp.json')),
1084
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
1085
+ .map(([id, spec]) => [id, () => readText(spec.config(HOME))])),
812
1086
  }[agent]?.() || '';
813
1087
  return text.includes('misakanet') && text !== 'null';
814
1088
  }
@@ -820,6 +1094,7 @@ function detect(agent) {
820
1094
  hermes: ['.hermes'],
821
1095
  openclaw: ['.openclaw'],
822
1096
  codewhale: ['.codewhale'],
1097
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS).map(([id, spec]) => [id, spec.detect])),
823
1098
  }[agent] || [];
824
1099
  return paths.some((p) => existsSync(join(HOME, p)));
825
1100
  }
@@ -1340,11 +1615,77 @@ async function installOpenclaw(bearer) {
1340
1615
  ok(`OpenClaw:注册 MCP(streamable-http)→ ${cfg}`);
1341
1616
  }
1342
1617
 
1618
+ /**
1619
+ * Install one MCP-only target: read its JSON, merge our entry under the vendor's own key path, write.
1620
+ *
1621
+ * File-to-file, never a subprocess, so no token ever reaches a command line. An existing file is
1622
+ * preserved (other servers, other keys) and backed up first; a file we cannot parse stops at a manual
1623
+ * step that names exactly what to paste, because guessing at a half-written config is how a user
1624
+ * loses the servers they already had.
1625
+ */
1626
+ async function installMcpOnly(agent, bearer) {
1627
+ const spec = MCP_ONLY_TARGETS[agent];
1628
+ const cfg = spec.config(HOME);
1629
+ const data = readJson(cfg, null);
1630
+ if (data === null && readText(cfg)) {
1631
+ need(`${spec.label}:${cfg} 不是合法 JSON${spec.parseNote ? `(${spec.parseNote})` : ''}`
1632
+ + ` → 请手动加入 ${spec.container}.misakanet(形状见 https://misakanet.org/mcp 的客户端文档)`);
1633
+ return;
1634
+ }
1635
+ const doc = data || {};
1636
+ doc[spec.container] = doc[spec.container] || {};
1637
+ const entry = spec.entry(bearer);
1638
+ if (sameJson(doc[spec.container].misakanet, entry)) {
1639
+ ok(`${spec.label}:MCP 已注册(无改动)`);
1640
+ } else {
1641
+ doc[spec.container].misakanet = entry;
1642
+ backup(cfg);
1643
+ writeText(cfg, `${JSON.stringify(doc, null, 2)}\n`);
1644
+ ok(`${spec.label}:注册 MCP → ${cfg}`);
1645
+ }
1646
+ skip(`${spec.label}:没有规则块与钩子(${spec.rules})→ ${spec.ruleHint}`);
1647
+ need(`${spec.label}:${spec.manual}`);
1648
+ }
1649
+
1343
1650
  // The last endpoint probe, recorded so `--report` can print it without probing twice.
1344
- let lastProbe = { reachable: false, tools: 0 };
1651
+ let lastProbe = { reachable: false, tools: 0, note: '' };
1652
+
1653
+ /**
1654
+ * The advice for a file we could not write.
1655
+ *
1656
+ * It has to be an advice that can work — and "re-run the install command" is not one: the file is
1657
+ * read-only or the permissions do not allow it, so a re-run writes exactly as much as the last one
1658
+ * did. Measured 2026-10-22 on an OpenClaw machine whose workspace was root-owned and read-only:
1659
+ * `--verify` said "规则块没装 → 重跑安装命令", and the trialer went after the network, the
1660
+ * permissions and the gateway while the installer had already named the real blocker.
1661
+ *
1662
+ * The question is asked the way the installer asks it before writing (`canCreateHere`): by
1663
+ * attempting the access, never by `existsSync` first.
1664
+ */
1665
+ function unwritableAdvice(manual = '') {
1666
+ const fix = '改权限(或换到可写的目录)后再重跑';
1667
+ return manual
1668
+ ? `只读或权限不足,重跑也写不进去 → ${manual}(或${fix})`
1669
+ : `只读或权限不足,重跑也写不进去 → ${fix}`;
1670
+ }
1345
1671
 
1346
1672
  async function verify() {
1347
1673
  let allOk = true;
1674
+ // The report answers two different questions, and one of them failing must not condemn the
1675
+ // other (issue #3696): "can you use it" (the endpoint answers) and "are the files we prefer
1676
+ // written" (our own footprint). A machine whose endpoint answers and whose workspace is
1677
+ // read-only is **usable** — and the old verdict called it NOT READY.
1678
+ let usable = true;
1679
+ // The probe has three answers, not two: a probe that could not run is not a probe that failed.
1680
+ // "unknown" is reported as unknown, and only `--strict` refuses to pass on it.
1681
+ let probeState = 'ready';
1682
+ // The install segment is judged on its own: a file we cannot write is a fact about our
1683
+ // footprint, and it says nothing about the endpoint.
1684
+ let installOk = true;
1685
+ const installFailed = () => { installOk = false; allOk = false; };
1686
+ // Where each segment starts, in the three line lists. The output prints the two segments as
1687
+ // two sections, and this boundary is the only thing that has to be right about them.
1688
+ const segmentStart = { done: done.length, manual: manual.length, skipped: skipped.length };
1348
1689
  // Handshake, not a search. `tools/list` proves the endpoint speaks MCP and answers, and it
1349
1690
  // consumes no anonymous read quota — so `--verify` can be run as often as the user likes. The
1350
1691
  // search used to be the probe, which spent one of the five free reads per run and, worse,
@@ -1364,18 +1705,46 @@ async function verify() {
1364
1705
  const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
1365
1706
  if (detect('claude') && missing.length) {
1366
1707
  allOk = false;
1708
+ // Not an install fact: the endpoint answers, and the search still fails. It belongs to
1709
+ // the usability segment, so it belongs there in the output too.
1710
+ usable = false;
1367
1711
  need(`Claude Code:只读 MCP 工具没有放行(${missing.length} 个)→ 第一次检索会被权限拦下;`
1368
1712
  + '重跑安装命令即可放行');
1369
1713
  }
1370
1714
  } catch { /* a settings file we cannot read is reported by the Claude checks below */ }
1371
1715
  } else if (probe && (probe.error || probe.protocolVersion || probe.serverInfo)) {
1372
1716
  allOk = false;
1717
+ usable = false;
1718
+ probeState = 'not-ready';
1373
1719
  need(`端点可达但握手异常:${JSON.stringify(probe).slice(0, 120)}`);
1374
1720
  } else {
1375
- allOk = false;
1376
- need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
1377
- + (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
1721
+ const proxy = proxyEnvironment();
1722
+ if (proxy && !proxy.enabled) {
1723
+ // Inconclusive rather than unreachable. Interactive runs warn (a visible `!` that does not
1724
+ // flip the verdict), because the install itself is fine and the user's agent may well be
1725
+ // reaching the endpoint through that proxy. `--strict` / `--ci` keep a hard verdict: a gate
1726
+ // that cannot tell must not pass silently, and CI machines are the ones actually configured
1727
+ // to make the probe trustworthy.
1728
+ lastProbe = { reachable: false, tools: 0, note: 'inconclusive-proxy' };
1729
+ probeState = 'unknown';
1730
+ if (STRICT) {
1731
+ allOk = false;
1732
+ usable = false;
1733
+ need(`端点探测不可信,且 --strict 不接受"说不清":${proxyProbeAdvice(proxy)}`);
1734
+ } else {
1735
+ need(`端点探测不可信(不是"不可达"):${proxyProbeAdvice(proxy)}`);
1736
+ }
1737
+ } else {
1738
+ allOk = false;
1739
+ usable = false;
1740
+ probeState = 'not-ready';
1741
+ need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
1742
+ + (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
1743
+ }
1378
1744
  }
1745
+ // The endpoint segment ends here. Everything below is about our own footprint: the files we
1746
+ // write, the entries we register, the token we keep.
1747
+ const segmentEnd = { done: done.length, manual: manual.length, skipped: skipped.length };
1379
1748
  const tokenFile = join(stateDir(), 'token');
1380
1749
  let tokenPresent = '';
1381
1750
  try {
@@ -1386,43 +1755,61 @@ async function verify() {
1386
1755
  if (tokenPresent) {
1387
1756
  ok('写入通道:token 已就绪(write_lesson / preflight 可用)');
1388
1757
  } else {
1389
- skip('写入通道:无 token(读不限次数、只读完全可用,写入类工具不可用)');
1390
- }
1391
- const hookPath = join(stateDir(), 'hook.mjs');
1392
- let hookPresent = false;
1393
- try {
1394
- hookPresent = readFileSync(hookPath, 'utf8').length > 0;
1395
- } catch {
1396
- hookPresent = false;
1397
- }
1398
- if (!hookPresent) {
1399
- allOk = false;
1400
- need('自动沉淀钩子:缺失 → 重跑安装命令');
1401
- } else {
1402
- const settingsPath = join(HOME, '.claude', 'settings.json');
1403
- const settings = readJson(settingsPath, {}) || {};
1404
- // Only our own entries: a foreign hook that merely mentions "hook.mjs" is not evidence
1405
- // that this machine is installed (and its interpreter may not even be ours).
1406
- const commands = ourHookEntries(settings.hooks)
1407
- .flatMap((entry) => (entry.hooks || []).map((h) => h.command))
1408
- .filter((c) => typeof c === 'string');
1409
- if (!commands.length) {
1410
- allOk = false;
1411
- need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
1758
+ // Which token this is looking for, said out loud: it reads **our own** state directory, so a
1759
+ // machine that got its token through another channel (mcporter, a gateway) shows "no token"
1760
+ // here even though writes work. The trialer read that as "nothing is set up".
1761
+ skip('写入通道:无 token(这一项只检查本安装器状态目录里的 token;如果 token 来自别的通道,'
1762
+ + '读路径完全不受影响——读不限次数、只读完全可用,写入类工具不可用)');
1763
+ }
1764
+ // The hook is Claude Code's alone: it is the one target whose config carries it (Codex's
1765
+ // user-level hook shape is unconfirmed, OpenClaw's events are unverified), so a machine without
1766
+ // Claude Code must not be told its hook is missing. Until 2026-09-20 it was: a Codex-only or
1767
+ // Cursor-only install reported NOT READY with two Claude Code complaints about a target that was
1768
+ // never selected — the same lie the OpenClaw and Hermes checks below already refuse to tell
1769
+ // (found while adding Cursor, which is mcp-only by construction).
1770
+ if (detect('claude')) {
1771
+ const hookPath = join(stateDir(), 'hook.mjs');
1772
+ let hookPresent = false;
1773
+ try {
1774
+ hookPresent = readFileSync(hookPath, 'utf8').length > 0;
1775
+ } catch {
1776
+ hookPresent = false;
1777
+ }
1778
+ if (!hookPresent) {
1779
+ installFailed();
1780
+ need(canCreateHere(hookPath)
1781
+ ? '自动沉淀钩子:缺失 → 重跑安装命令'
1782
+ : `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
1412
1783
  } else {
1413
- const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
1414
- if (!existsSync(exe)) {
1415
- allOk = false;
1416
- need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`);
1784
+ const settingsPath = join(HOME, '.claude', 'settings.json');
1785
+ const settings = readJson(settingsPath, {}) || {};
1786
+ // Only our own entries: a foreign hook that merely mentions "hook.mjs" is not evidence
1787
+ // that this machine is installed (and its interpreter may not even be ours).
1788
+ const commands = ourHookEntries(settings.hooks)
1789
+ .flatMap((entry) => (entry.hooks || []).map((h) => h.command))
1790
+ .filter((c) => typeof c === 'string');
1791
+ if (!commands.length) {
1792
+ installFailed();
1793
+ need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
1417
1794
  } else {
1418
- ok('Claude Code:钩子已装且解释器存在');
1795
+ const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
1796
+ if (!existsSync(exe)) {
1797
+ installFailed();
1798
+ need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`);
1799
+ } else {
1800
+ ok('Claude Code:钩子已装且解释器存在');
1801
+ }
1419
1802
  }
1803
+ const cfg = join(HOME, '.claude.json');
1804
+ const data = readJson(cfg, {}) || {};
1805
+ const entry = data.mcpServers?.misakanet;
1806
+ if (!entry) {
1807
+ installFailed();
1808
+ need(canCreateHere(cfg)
1809
+ ? `Claude Code:MCP 未注册(${cfg})`
1810
+ : `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
1811
+ } else ok(`Claude Code:MCP 已注册(${entry.url})`);
1420
1812
  }
1421
- const cfg = join(HOME, '.claude.json');
1422
- const data = readJson(cfg, {}) || {};
1423
- const entry = data.mcpServers?.misakanet;
1424
- if (!entry) { allOk = false; need(`Claude Code:MCP 未注册(${cfg})`); }
1425
- else ok(`Claude Code:MCP 已注册(${entry.url})`);
1426
1813
  }
1427
1814
  // OpenClaw is only reported when the user actually has it: telling a machine without
1428
1815
  // OpenClaw that it is "not ready" would be a lie about a target that was never selected.
@@ -1431,17 +1818,23 @@ async function verify() {
1431
1818
  if (detect('openclaw')) {
1432
1819
  const rules = join(openclawWorkspaces()[0], 'AGENTS.md');
1433
1820
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
1434
- allOk = false;
1435
- need(`OpenClaw:规则块没装(${rules})→ 重跑安装命令`);
1821
+ installFailed();
1822
+ // "没装" and "装不了" are different facts with different fixes, and the difference is the
1823
+ // whole point (issue #3696): a read-only workspace is not something a re-run installs.
1824
+ need(canCreateHere(rules)
1825
+ ? `OpenClaw:规则块没装(${rules})→ 重跑安装命令`
1826
+ : `OpenClaw:规则块装不了(${rules})→ ${unwritableAdvice()}`);
1436
1827
  } else {
1437
1828
  ok('OpenClaw:规则块已装');
1438
1829
  }
1439
1830
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
1440
1831
  const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
1441
1832
  if (!entry) {
1442
- allOk = false;
1443
- need(`OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 `
1444
- + `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`);
1833
+ installFailed();
1834
+ const manual = `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`;
1835
+ need(canCreateHere(cfg)
1836
+ ? `OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 ${manual}`
1837
+ : `OpenClaw:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
1445
1838
  } else {
1446
1839
  ok(`OpenClaw:MCP 已注册(${entry.url || '?'})`);
1447
1840
  }
@@ -1452,17 +1845,21 @@ async function verify() {
1452
1845
  if (readText(join(HOME, '.hermes', 'config.yaml'))) {
1453
1846
  const rules = join(HOME, '.hermes', 'SOUL.md');
1454
1847
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
1455
- allOk = false;
1456
- need(`Hermes:规则块没装(${rules})→ 重跑安装命令`);
1848
+ installFailed();
1849
+ need(canCreateHere(rules)
1850
+ ? `Hermes:规则块没装(${rules})→ 重跑安装命令`
1851
+ : `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
1457
1852
  } else {
1458
1853
  ok('Hermes:规则块已装');
1459
1854
  }
1460
1855
  const cfg = join(HOME, '.hermes', 'config.yaml');
1461
1856
  const text = readText(cfg);
1462
1857
  if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
1463
- allOk = false;
1464
- need(`Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 `
1465
- + `hermes mcp add misakanet --url ${ENDPOINT} --auth header`);
1858
+ installFailed();
1859
+ const manual = `hermes mcp add misakanet --url ${ENDPOINT} --auth header`;
1860
+ need(canCreateHere(cfg)
1861
+ ? `Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 ${manual}`
1862
+ : `Hermes:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
1466
1863
  } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
1467
1864
  ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
1468
1865
  skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
@@ -1471,6 +1868,27 @@ async function verify() {
1471
1868
  skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
1472
1869
  }
1473
1870
  }
1871
+ // The MCP-only targets are reported only when the user actually has them, like OpenClaw and
1872
+ // Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
1873
+ // the client has *loaded* that file cannot be confirmed from here, and the line says so.
1874
+ for (const [agent, spec] of Object.entries(MCP_ONLY_TARGETS)) {
1875
+ if (!detect(agent)) continue;
1876
+ const cfg = spec.config(HOME);
1877
+ const entry = (readJson(cfg, null) || {})[spec.container]?.misakanet;
1878
+ if (!entry) {
1879
+ installFailed();
1880
+ need(canCreateHere(cfg)
1881
+ ? `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`
1882
+ : `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
1883
+ } else if (!entry[spec.urlField]) {
1884
+ installFailed();
1885
+ need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
1886
+ + `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`);
1887
+ } else {
1888
+ ok(`${spec.label}:MCP 已注册(${entry[spec.urlField]})`);
1889
+ skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`);
1890
+ }
1891
+ }
1474
1892
  // Version: what is installed, and whether the registry has moved on. Read-only here — the
1475
1893
  // hook owns the periodic nudge (it cannot be a network call in this process's hot path), and
1476
1894
  // `--verify` is an explicit user action, so one lookup is both cheap and expected.
@@ -1493,7 +1911,72 @@ async function verify() {
1493
1911
  + 'npx @misaka-net/misakanet-setup@latest');
1494
1912
  }
1495
1913
  }
1496
- return allOk;
1914
+ return {
1915
+ allOk,
1916
+ usable,
1917
+ probeState,
1918
+ install: installOk ? 'complete' : 'incomplete',
1919
+ // The install segment's open items, counted on their own: the report's `open-items` is every
1920
+ // manual item, this is the ones that belong to our footprint.
1921
+ openItems: manual.length - segmentEnd.manual,
1922
+ segments: { start: segmentStart, end: segmentEnd },
1923
+ };
1924
+ }
1925
+
1926
+ /**
1927
+ * The two verdicts, in words.
1928
+ *
1929
+ * They are two answers to two questions, and the point of #3696 is that they are printed as two:
1930
+ * a machine whose endpoint answers perfectly and whose workspace is read-only is **usable**, and
1931
+ * the old single verdict said NOT READY about it — the one word that sends the user to debug the
1932
+ * wrong thing.
1933
+ */
1934
+ function usabilityText(verdict) {
1935
+ if (verdict.probeState === 'not-ready') return '不就绪 —— 端点不可达或握手异常';
1936
+ if (verdict.probeState === 'unknown') {
1937
+ return STRICT
1938
+ ? '不就绪 —— 探测不可信,--strict 不接受"说不清"'
1939
+ : '说不清 —— 探测不可信(不等于不可达)';
1940
+ }
1941
+ if (!verdict.usable) return '不就绪 —— 端点可达,但只读工具没放行,第一次检索会被拦下';
1942
+ return '就绪 —— 端点可达,检索与读取可用';
1943
+ }
1944
+
1945
+ function installText(verdict) {
1946
+ if (verdict.install === 'complete') return '完整 —— 我们偏好的文件都写好了';
1947
+ return `不完整 —— ${verdict.openItems} 项需要手动处理(上面每条 ! 都给了成因和做法)`;
1948
+ }
1949
+
1950
+ /**
1951
+ * The verify output: two sections, because the two questions have two answers.
1952
+ *
1953
+ * The old output was one list, and one list cannot say "the endpoint answered, the files did not
1954
+ * get written" — it can only say NOT READY, which is the sentence the OpenClaw trialer read as
1955
+ * "MisakaNet does not work here" (issue #3696).
1956
+ */
1957
+ function renderVerify(verdict) {
1958
+ const { start, end } = verdict.segments;
1959
+ const tail = { done: done.length, manual: manual.length, skipped: skipped.length };
1960
+ const section = (label, from, to) => {
1961
+ const out = [];
1962
+ const finished = done.slice(from.done, to.done);
1963
+ const open = manual.slice(from.manual, to.manual);
1964
+ const ignored = skipped.slice(from.skipped, to.skipped);
1965
+ // `--silent` drops the narration, never the errors (the same contract `render()` keeps): the
1966
+ // `!` lines are what a human has to act on, the `✓` and `·` lines are progress.
1967
+ if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l}`));
1968
+ if (open.length) out.push(...open.map((l) => ` ! ${l}`));
1969
+ if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l}`));
1970
+ if (!out.length) return out;
1971
+ return [`\n── ${label} ──`, ...out];
1972
+ };
1973
+ return [
1974
+ ...section('端点/可用性', start, end),
1975
+ ...section('安装完整性', end, tail),
1976
+ '',
1977
+ `可用性:${usabilityText(verdict)}`,
1978
+ `安装完整性:${installText(verdict)}`,
1979
+ ].join('\n');
1497
1980
  }
1498
1981
 
1499
1982
  /**
@@ -1550,7 +2033,8 @@ function restoreEmptiedFiles() {
1550
2033
  // JSON configs: prune containers that our own removal emptied. The file itself is never deleted —
1551
2034
  // it may have existed before us (an empty `~/.claude.json` is a legitimate user state).
1552
2035
  const jsonFiles = [join(HOME, '.claude.json'), join(HOME, '.claude', 'settings.json'),
1553
- join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json')];
2036
+ join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json'),
2037
+ ...Object.values(MCP_ONLY_TARGETS).map((spec) => spec.config(HOME))];
1554
2038
  for (const file of jsonFiles) {
1555
2039
  const data = readJson(file, null);
1556
2040
  if (!data || typeof data !== 'object') continue;
@@ -1617,6 +2101,16 @@ function uninstall() {
1617
2101
  writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
1618
2102
  ok(`移除 MCP 注册 → ${cfg}`);
1619
2103
  }
2104
+ for (const spec of Object.values(MCP_ONLY_TARGETS)) {
2105
+ const cfg = spec.config(HOME);
2106
+ const data = readJson(cfg, null);
2107
+ if (data?.[spec.container]?.misakanet) {
2108
+ delete data[spec.container].misakanet;
2109
+ backup(cfg);
2110
+ writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
2111
+ ok(`移除 MCP 注册 → ${cfg}`);
2112
+ }
2113
+ }
1620
2114
  const voiceDir = join(stateDir(), 'voice');
1621
2115
  if (existsSync(voiceDir)) {
1622
2116
  try {
@@ -1772,7 +2266,8 @@ function distroName() {
1772
2266
  * scalars (`true`, `[claude, codex]`, `""`); the JSON keeps their real types (boolean, array,
1773
2267
  * string), which is the whole point of a machine-readable form.
1774
2268
  */
1775
- function reportValues(allOk) {
2269
+ function reportValues(verdict) {
2270
+ const allOk = verdict.allOk;
1776
2271
  const agents = AGENTS.filter((agent) => detect(agent));
1777
2272
  const token = existsSync(join(stateDir(), 'token'));
1778
2273
  const permissions = (() => {
@@ -1792,14 +2287,25 @@ function reportValues(allOk) {
1792
2287
  ['node', process.version],
1793
2288
  ['detected-agents', agents],
1794
2289
  ['verify', allOk ? 'READY' : 'NOT READY'],
2290
+ // The verdict has two halves, and a machine that collects this report needs to be able to
2291
+ // tell them apart: `verify: NOT READY` because our files could not be written is a
2292
+ // different machine from `verify: NOT READY` because the endpoint does not answer
2293
+ // (issue #3696). `endpoint-reachable` and `endpoint-probe` below already say the endpoint's
2294
+ // half; this is ours.
2295
+ ['install-complete', verdict.install],
2296
+ ['endpoint-usable', verdict.usable ? 'yes' : 'no'],
1795
2297
  ['endpoint-reachable', lastProbe.reachable],
1796
2298
  ['endpoint-tools', lastProbe.tools],
2299
+ // `ok` = the probe ran and its answer is meaningful; `inconclusive-proxy` = the machine has a
2300
+ // proxy this process cannot use, so the boolean above says nothing. Without this field the YAML
2301
+ // a user pastes into a public issue cannot tell a real outage from a false negative.
2302
+ ['endpoint-probe', lastProbe.note || 'ok'],
1797
2303
  ['token', token ? 'present' : 'absent'],
1798
2304
  ['permissions', permissions],
1799
2305
  ['hook', existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'],
1800
2306
  // `mcp-only` = the endpoint is registered but the hooks are not, which is a different machine
1801
2307
  // from "nothing installed" and must not be counted as one (#1753's T1-lite rows).
1802
- ['install-scope', existsSync(join(stateDir(), 'hook.mjs'))
2308
+ ['install-scope', hookWired()
1803
2309
  ? 'full'
1804
2310
  : (AGENTS.some((agent) => detect(agent) && mcpEntryPresent(agent)) ? 'mcp-only' : 'none')],
1805
2311
  ['voice', voiceStatus()],
@@ -1876,10 +2382,10 @@ function reportJson(values) {
1876
2382
  * encoding never changes the verdict.
1877
2383
  */
1878
2384
  async function report(asJson) {
1879
- const allOk = await verify();
1880
- const values = reportValues(allOk);
2385
+ const verdict = await verify();
2386
+ const values = reportValues(verdict);
1881
2387
  console.log(asJson ? reportJson(values) : reportYaml(values));
1882
- return { allOk, openItems: manual.length };
2388
+ return { ...verdict, openItems: manual.length };
1883
2389
  }
1884
2390
 
1885
2391
  // ── main ─────────────────────────────────────────────────────────────
@@ -1979,13 +2485,13 @@ if (mode === 'report') {
1979
2485
  }
1980
2486
 
1981
2487
  if (mode === 'verify') {
1982
- const allOk = await verify();
1983
- say(render());
2488
+ const verdict = await verify();
2489
+ say(renderVerify(verdict));
1984
2490
  // Kept under `--silent`: this line is the verdict, not progress. (It is also the only thing a
1985
2491
  // silent `--verify` prints when the machine is healthy, which is what makes it usable in a
1986
2492
  // deployment log.)
1987
- console.log(`\n结论:${allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 ! 都给了修复动作'}`);
1988
- process.exit(allOk ? 0 : 1);
2493
+ console.log(`\n结论:${verdict.allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 ! 都给了成因和做法'}`);
2494
+ process.exit(verdict.allOk ? 0 : 1);
1989
2495
  }
1990
2496
 
1991
2497
  const targets = (only.length ? only : AGENTS).filter((a) => {
@@ -2032,38 +2538,12 @@ if (LIST_WRITES) {
2032
2538
  // Every row carries its **tier**, which is the point: an operator approving "add an MCP server"
2033
2539
  // (①) is not approving "change how the agent behaves" (② rules block, ③ automatic hooks). Files
2034
2540
  // already exist → we append or update keys; missing → we create them.
2035
- // Compare base names, not separators: a `/settings.json$` regex silently classifies nothing on
2036
- // Windows (all three windows legs went red on the first CI run of this change), and a manifest
2037
- // that hides a tier is worse than no manifest.
2038
- const tierOf = (path) => {
2039
- const base = basename(path);
2040
- if (base === 'CLAUDE.md' || base === 'AGENTS.md' || base === 'SOUL.md') return 2;
2041
- if (path.includes('.misakanet-agent')) return 3; // our own state dir
2042
- // `settings.json` carries both the read-only grants (①) and the hooks (③); the caller adds the
2043
- // second row only when the hooks are in scope.
2044
- if (base === 'settings.json' && path.includes('.claude')) return 1;
2045
- return 1; // an MCP entry in the agent's config
2046
- };
2047
- const rows = new Map();
2048
- const add = (path, action, tier) => {
2049
- const key = `${tier}|${path}`;
2050
- if (!rows.has(key)) rows.set(key, { path: redact(path), action, tier });
2051
- };
2052
- for (const agent of AGENTS) {
2053
- if (!detect(agent)) continue;
2054
- for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
2055
- add(path, existsSync(path) ? 'append-or-update' : 'create', tierOf(path));
2056
- if (!MCP_ONLY && basename(path) === 'settings.json' && path.includes('.claude')) {
2057
- add(path, 'append-or-update(钩子)', 3);
2058
- }
2059
- }
2060
- }
2061
- add(join(stateDir(), 'token'), 'create (0600)', 1);
2062
- if (!MCP_ONLY) add(join(stateDir(), 'hook.mjs'), 'create', 3);
2063
-
2541
+ //
2542
+ // The rows come from the shared manifest builder, the same list the install preview prints: one
2543
+ // source of truth, so the manifest an operator approves and the preview a user reads cannot
2544
+ // disagree about what this command would write.
2064
2545
  const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 装钩子(每轮自动执行)' };
2065
- const list = [...rows.values()].filter((row) => (MCP_ONLY ? row.tier === 1 : true))
2066
- .sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
2546
+ const list = buildWriteManifest(targets);
2067
2547
  for (const row of list) {
2068
2548
  console.log(`tier${row.tier}\t${tiers[row.tier]}\t${row.path}\t${row.action}`);
2069
2549
  }
@@ -2079,11 +2559,27 @@ if (LIST_WRITES) {
2079
2559
  process.exit(0);
2080
2560
  }
2081
2561
 
2562
+ // The preview (#3690): the first thing a user sees about what this run is about to write, printed
2563
+ // **before** the first write — so a user who did not expect these files still sees them while
2564
+ // there is nothing to undo yet.
2565
+ //
2566
+ // `--silent` keeps its contract: a clean silent run is an empty log, and this summary is progress
2567
+ // narration, not an error line. `--list-writes` has already exited above with the machine-readable
2568
+ // version of the same list.
2569
+ if (!SILENT && targets.length) {
2570
+ const preview = writePreview(buildWriteManifest(targets));
2571
+ if (preview) console.log(preview);
2572
+ }
2573
+
2082
2574
  try {
2083
2575
  if (!targets.length) {
2084
- need('没检测到 Claude Code / Codex / Hermes 的配置目录 → 请先打开一次你要用的那个助手,再回来运行本命令');
2576
+ need('没检测到任何受支持助手的配置目录(Claude Code / Codex / Hermes / OpenClaw / Codewhale / '
2577
+ + 'Cursor)→ 请先打开一次你要用的那个助手,再回来运行本命令');
2085
2578
  } else {
2086
- const hookPath = MCP_ONLY ? null : await installHook();
2579
+ // Only install the hook when a target can actually consume it. Writing it for a Cursor-only or
2580
+ // Codex-only machine produced a file nothing would ever run, and made `--report` claim
2581
+ // `install-scope: full` for an install that registered an endpoint and nothing else.
2582
+ const hookPath = (MCP_ONLY || !targets.includes('claude')) ? null : await installHook();
2087
2583
  if (!MCP_ONLY) stampVersion();
2088
2584
  const bearer = has('--no-register') ? '' : await ensureIdentity();
2089
2585
  for (const agent of targets) {
@@ -2104,6 +2600,7 @@ try {
2104
2600
  else if (agent === 'hermes') await installHermes(hookPath, bearer);
2105
2601
  else if (agent === 'openclaw') await installOpenclaw(bearer);
2106
2602
  else if (agent === 'codewhale') await installCodewhale(bearer);
2603
+ else if (MCP_ONLY_TARGETS[agent]) await installMcpOnly(agent, bearer);
2107
2604
  installed += 1;
2108
2605
  } catch (err) {
2109
2606
  need(`${agent}:写入配置失败(${redact((err && err.message) || err)})`
@@ -226,6 +226,94 @@ function failureText(payload) {
226
226
  return '';
227
227
  }
228
228
 
229
+ /**
230
+ * Deterministic redaction — the query must not carry the machine's secrets out of it.
231
+ *
232
+ * Issue #3692: the rules block used to *ask* the agent to redact, and an instruction is
233
+ * only a request. When the model did not comply, the internal table name, the home
234
+ * directory path or the token left the machine inside a search query. This table is the
235
+ * mechanical version of that rule: it runs on the error text before the fragment is
236
+ * printed into the agent's context, and again before anything is sent over the network.
237
+ * Redacting only at the network call would not be enough — the un-redacted text would
238
+ * still be in the agent's context, and the agent's own `misakanet_search` call would
239
+ * carry it.
240
+ *
241
+ * The ids are the server's (`workers/lib/redact-patterns.json`), so both ends name the
242
+ * same classes and the client never invents a rule the server does not know. Real
243
+ * domains and personal names are deliberately NOT redacted: the corpus is indexed by error
244
+ * text, and over-redaction would cost search quality for a leak that is not in scope.
245
+ *
246
+ * One path segment: anything but a space, a separator or punctuation that would end the
247
+ * segment (so a trailing comma or bracket in prose stays outside the match).
248
+ */
249
+ const SEG = "[^\\s/\\\\'\"<>,;:!?()\\[\\]{}]";
250
+ // A path only counts as a *filesystem* path when it starts at a known root, or when its
251
+ // last segment carries a file extension. Without that, every API route in the corpus
252
+ // ("/api/v1/users", "/oauth/token") would be swallowed — and the corpus is indexed by
253
+ // error text, so a swallowed route destroys the lesson it belongs to. The roots are
254
+ // case-sensitive, except "Users" and "Volumes", which are written in capitals by
255
+ // convention but arrive in every casing.
256
+ const FS_ROOTS = "(?:home|root|var|opt|etc|usr|tmp|mnt|srv|data|private|workspace|app|code|project"
257
+ + "|[Uu][Ss][Ee][Rr][Ss]|[Vv][Oo][Ll][Uu][Mm][Ee][Ss])";
258
+ const FILE_EXT = "[A-Za-z][A-Za-z0-9_\\-]*";
259
+
260
+ const REDACT_RULES = [
261
+ // A Unix absolute path: either it starts at a known filesystem root, or its last
262
+ // segment has a file extension ("/opt/app/config.yaml"). The look-behind keeps it out
263
+ // of a URL ("https://host/a/b" — the slash follows a word character) and out of a
264
+ // relative path; "file://" is matched as a whole, because its inner path is a path.
265
+ { id: 'abs_path', pattern: `(?<![A-Za-z0-9_:/~.\\-])(?:file://)?/(?:${FS_ROOTS}(?:/${SEG}+)+|(?:${SEG}+/)*${SEG}*\\.${FILE_EXT})`, flags: 'g', replacement: '<PATH>' },
266
+ // A Windows path, with either separator. The look-behind keeps the "s://" of a URL out.
267
+ { id: 'windows_path', pattern: `(?<![A-Za-z0-9_])[A-Za-z]:[\\\\/](?:${SEG}+[\\\\/])*${SEG}*`, flags: 'g', replacement: '<PATH>' },
268
+ // The secret rules are the server's shapes with two additions its copy does not need: a
269
+ // word boundary (so "task_identifier" is not read as the key "sk_identifier") and a floor
270
+ // of six characters on the bearer value (so the phrase "Bearer token" in prose survives).
271
+ // A real credential is longer than that; a real key is not glued to the end of a word.
272
+ { id: 'bearer_token', pattern: '(?<![A-Za-z0-9])(?:Bearer|Authorization)\\s+[A-Za-z0-9\\-._~+/]{6,}=*', flags: 'gi', replacement: '[REDACTED:bearer_token]' },
273
+ { id: 'github_token', pattern: '(?<![A-Za-z0-9])(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9]{10,}', flags: 'g', replacement: '[REDACTED:github_token]' },
274
+ { id: 'api_key', pattern: '(?<![A-Za-z0-9])(?:sk|pk|rk|ak)[_-][A-Za-z0-9]{10,}', flags: 'g', replacement: '[REDACTED:api_key]' },
275
+ // The top-level domain has to be alphabetic: "foo@1.2.3" is an npm version spec, and
276
+ // eating it would destroy the version the reporter was talking about.
277
+ { id: 'email', pattern: '[A-Za-z0-9._%+\\-]+@[A-Za-z0-9\\-]+(?:\\.[A-Za-z0-9\\-]*)*\\.[A-Za-z]{2,}', flags: 'g', replacement: '<REDACTED:email>' },
278
+ ];
279
+
280
+ const COMPILED_REDACT_RULES = REDACT_RULES.map((rule) => ({
281
+ ...rule,
282
+ regex: new RegExp(rule.pattern, rule.flags),
283
+ }));
284
+
285
+ /**
286
+ * Replace every secret the table knows about. Never throws: a broken rule must not become
287
+ * a failed hook (the hook's contract is that it cannot break a session).
288
+ */
289
+ function redact(text) {
290
+ let out = String(text);
291
+ for (const rule of COMPILED_REDACT_RULES) {
292
+ try {
293
+ out = out.replace(rule.regex, rule.replacement);
294
+ } catch (err) {
295
+ debug(`redaction rule ${rule.id} failed: ${err}`);
296
+ }
297
+ }
298
+ return out;
299
+ }
300
+
301
+ /**
302
+ * Cut the query to a length, without cutting a placeholder in half: a truncated
303
+ * `<REDACTED:email` is noise the agent would carry into its own call.
304
+ *
305
+ * Counted in code points, not UTF-16 units, so this and the Python twin cut in the same
306
+ * place — the parity test compares their stdout byte for byte.
307
+ */
308
+ function clip(text, limit) {
309
+ const chars = Array.from(text);
310
+ if (chars.length <= limit) return text;
311
+ let cut = chars.slice(0, limit).join('');
312
+ const open = cut.lastIndexOf('<');
313
+ if (open !== -1 && cut.indexOf('>', open) === -1) cut = cut.slice(0, open);
314
+ return cut;
315
+ }
316
+
229
317
  function target() {
230
318
  const configured = (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
231
319
  const envToken = (process.env.MISAKANET_TOKEN || '').trim();
@@ -251,7 +339,9 @@ async function search(query) {
251
339
  signal: controller.signal,
252
340
  body: JSON.stringify({
253
341
  jsonrpc: '2.0', id: 1, method: 'tools/call',
254
- params: { name: 'misakanet_search', arguments: { query: query.slice(0, 300), top: 1, detail: 'summary' } },
342
+ // Redacted again on the way out: the fragment is already redacted, but this is the
343
+ // only place where the text actually leaves the machine, so it gets its own gate.
344
+ params: { name: 'misakanet_search', arguments: { query: redact(query).slice(0, 300), top: 1, detail: 'summary' } },
255
345
  }),
256
346
  });
257
347
  clearTimeout(timer);
@@ -302,7 +392,9 @@ function promptMode(payload) {
302
392
  async function failureMode(payload) {
303
393
  const text = failureText(payload);
304
394
  if (!text) return;
305
- const fragment = text.split(/\s+/).join(' ').slice(0, 120);
395
+ // Redact first, cut second: the query the agent is told to search with must not carry a
396
+ // path, an address or a token, and the agent may copy it into its own later call.
397
+ const fragment = clip(redact(text).split(/\s+/).join(' '), 120);
306
398
  const lines = [
307
399
  '[MisakaNet] 刚刚有一次工具调用失败。在**重试或换修法之前**先查一次(第二次盲试就是『重复犯错』):',
308
400
  ` misakanet_search(query=${JSON.stringify(fragment)}) # 查不到就用 kind="question" 提 intake,别猜`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@misaka-net/misakanet-setup",
3
- "version": "0.5.6",
4
- "description": "One-command setup: teach your Claude Code / Codex / Hermes / OpenClaw / codewhale to search MisakaNet failure lessons before repeating a mistake, and to distil the session at a checkpoint.",
3
+ "version": "0.6.0",
4
+ "description": "MisakaNet failure lessons for your agents: writes an MCP endpoint into each agent config, rules blocks where the client takes one, and a Claude Code hook. At most one anonymous request leaves this machine at install; then only the agent's calls — unless MISAKANET_HOOK_FETCH=1 (off by default) the hook sends the failed call's error text or command.",
5
5
  "bin": {
6
6
  "misakanet-setup": "bin/misakanet-setup.mjs"
7
7
  },