@consiliency/agent-board-cli 1.23.0 → 1.24.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.
@@ -55,8 +55,168 @@
55
55
  // user is used, and managed scopes are ranked above all.
56
56
  // grok unmeasured (display-only, above): ranked like codex, which it mirrors.
57
57
  //
58
- // Floors: plugin-contributed MCP servers and hooks (claude `enabledPlugins`, codex plugins) are
59
- // not read; claude's `.mcp.json` approval state and pi/grok project trust are not modelled (a
58
+ // CCPLUGIN IF-0-CCPLUGIN-3 — claude plugins are read by a dedicated reader (`readClaudePluginScopes`),
59
+ // not a `ScopeRow`. SL-2.0 measured it with `claude plugin list --json`, `plugin install/enable/
60
+ // disable --scope …` and `claude mcp get` (which spawns the server, so a marker command shows
61
+ // WHICH tree ran), in temp `HOME`/`CLAUDE_CONFIG_DIR`s, claude 2.1.283 on dev0, 2026-09-26:
62
+ // enable `enabledPlugins` is per plugin id, local > project > user, the highest scope that
63
+ // holds a BOOLEAN wins (all 18 user/project/local combinations of true/false/absent
64
+ // measured). A non-boolean (`["1.31.0"]`, `"true"`, `1`, `null`, `{}`) defines
65
+ // nothing: `plugin list` shows it disabled at user scope and the user value
66
+ // standing under a local one. Managed settings are ranked above all (inferred: not
67
+ // writable without root).
68
+ // row `installed_plugins.json` is `{version: 2, plugins: {<id>: [row…]}}`, a row
69
+ // `{scope, installPath, version, installedAt, lastUpdated, gitCommitSha?,
70
+ // projectPath?}` (`projectPath` on local and project rows). AMENDS the plan: the
71
+ // loaded row is NOT the one whose scope matches the winning enable. It is the FIRST
72
+ // row, in array order, that applies to the cwd — scope user/managed, or a
73
+ // `projectPath` equal to the cwd or in the same git repository (the binary's
74
+ // `uw`). Measured by reordering rows: [user, local(cwd)] loads the user row's tree
75
+ // in the local row's own project; [local(cwd), user] loads the local one. Claude
76
+ // skips a first row whose path fails its own trust check and takes the next; doctor
77
+ // stops there with `plugin_path_untrusted` instead (stricter, deliberately).
78
+ // source a github marketplace source is `{source:"github", repo, ref?, path?,
79
+ // sparsePaths?, skipLfs?}`, identical in `extraKnownMarketplaces` and
80
+ // `known_marketplaces.json` (measured: `marketplace add anthropics/claude-code@main`).
81
+ // There is no marketplace-level `sha`; that is the plugin-entry source's, and the
82
+ // installed row carries `gitCommitSha`. `source_ref` is `ref`. Repos compare
83
+ // case-insensitively, as the binary's own matcher does.
84
+ // tree a github-sourced plugin runs from its row's `installPath` in the cache (measured:
85
+ // editing the cached `plugin.json` changed what ran, the marketplace clone's did
86
+ // not). A DIRECTORY-sourced plugin runs from the source directory, not the cache
87
+ // (editing the source without reinstalling changed what ran). Ours never has a
88
+ // directory source; for a FOREIGN directory plugin this is a floor: doctor reads
89
+ // the cache copy, which may not be what runs.
90
+ // auto-update off globally when `DISABLE_UPDATES` or `DISABLE_AUTOUPDATER` is truthy
91
+ // (1/true/yes/on), `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is non-empty, or
92
+ // `.claude.json` has `autoUpdates: false` (unless `installMethod` is native and
93
+ // `autoUpdatesProtectedForNative`); `FORCE_AUTOUPDATE_PLUGINS` lifts that gate.
94
+ // Then per marketplace: the highest settings scope DECLARING it (its `autoUpdate`,
95
+ // if boolean), else `known_marketplaces.json`, else a default that is on only for
96
+ // official names (read from the binary, not run). Floor: the binary reads a repo
97
+ // scope's (project/local) declaration only behind an internal gate doctor cannot
98
+ // see; doctor counts it. Env inputs are as doctor sees them.
99
+ //
100
+ // disabled `.claude.json` `projects[<key>].disabledMcpServers` turns a server off in ONE project
101
+ // (measured 2026-09-26, `claude mcp get`/`mcp list` with marker commands: the
102
+ // disabled server shows "Disabled for this project" and is never spawned). `<key>`
103
+ // is the git root of the cwd's REALPATH, else that realpath: a subdirectory and a
104
+ // symlinked cwd are covered, a key naming the subdirectory itself is not, and a
105
+ // non-git parent's key does not reach a child. Entries match the server name
106
+ // EXACTLY (case- and space-sensitive); non-string entries are skipped and a
107
+ // non-array list is ignored. It disables a user, local or project (`.mcp.json`)
108
+ // server of that name, and a PLUGIN server by `plugin:<plugin>:<server>` (the bare
109
+ // server name or `plugin:<plugin>` does not). A LOCAL server
110
+ // (`projects[<key>].mcpServers`) is keyed the same way (measured: a local server
111
+ // under the root key runs in a subdirectory), so every `.claude.json` `projects`
112
+ // read goes through `claudeProjectKey` (pinned by DM-8's source scan).
113
+ // handlers (panel B2; read from the binary's hook schema and executors, not run) a hook
114
+ // handler's `type` is `command`, `http`, `prompt`, `agent` or `mcp_tool`.
115
+ // FileChanged runs through the outside-REPL executor, which runs command, http and
116
+ // mcp_tool handlers and refuses prompt and agent ("not yet supported outside
117
+ // REPL"). SessionStart runs through the main executor, where prompt and agent run
118
+ // when a conversation context exists (refused otherwise, and agent always on
119
+ // PermissionRequest). Stop, PreToolUse and UserPromptSubmit run all five. Doctor
120
+ // keeps EVERY handler: our plugin must be exactly the frozen set (both events, one
121
+ // command handler each, nothing else of any type on any event), and ANY non-command
122
+ // handler on SessionStart or FileChanged, in any scope, makes registration
123
+ // undetermined (`unjudgeable_hook`) — conservatively including a prompt/agent one
124
+ // on FileChanged that the binary refuses. Its url, prompt, server or tool is never
125
+ // kept, so never echoed. Floor: what such a handler does is not judged.
126
+ // hook policy (round-2 F027/F028) MEASURED with `claude --init-only` ("Run Setup and
127
+ // SessionStart:startup hooks, then exit": no model, no credentials in the temp
128
+ // config), 2.1.283, 2026-09-26, marker-writing SessionStart hooks in user settings
129
+ // and a directory plugin: `disableAllHooks: true` in user, project OR local
130
+ // settings stops every settings and plugin hook; the highest scope holding a
131
+ // boolean wins (local false over user true → hooks run); `allowManagedHooksOnly`
132
+ // and `strictPluginOnlyCustomization` outside managed settings change nothing;
133
+ // `CLAUDE_CODE_SAFE_MODE` or `CLAUDE_CODE_SIMPLE` truthy stops them all (`0` does
134
+ // not). MANAGED settings need root, so their rules are READ from the binary (hook
135
+ // loader and plugin-hook gate), not run: managed `disableAllHooks` → no hooks;
136
+ // managed `allowManagedHooksOnly`, safe mode, or a non-managed `disableAllHooks` →
137
+ // managed settings hooks only, and plugin hooks only from plugins managed
138
+ // `enabledPlugins` enables (none in safe mode); managed
139
+ // `strictPluginOnlyCustomization` (true, or listing "hooks") → settings hooks from
140
+ // managed settings only, plugins unaffected. Doctor applies this BEFORE counting:
141
+ // a wake hook the policy switches off is `hooks_disabled_by_policy` (a failure,
142
+ // never a pass). The binary takes the managed-plugin set from managed
143
+ // `prependPlugins`/`appendPlugins` when present; doctor cannot evaluate that, so a
144
+ // restricted policy with either is undetermined (`hook_policy_unjudgeable`).
145
+ // Floors: `--safe-mode` and `--bare` on a launch's argv are invisible (env only,
146
+ // as doctor sees it); a settings file the binary rejects as invalid is dropped
147
+ // whole by it and read by doctor.
148
+ // managed tier (round-3 N1, evidence-first per the owner; READ from the 2.1.283 binaries —
149
+ // linux-x64, darwin-arm64 and win32-x64 packages — not run) the managed tier is ONE
150
+ // source, first-wins: server-managed > MDM > the managed-settings file (+ `.d`).
151
+ // Doctor looks for EVIDENCE of each non-file source and never reads its content:
152
+ // - remote: `<config home>/remote-settings.json` (`TJ()`; the config home is
153
+ // `CLAUDE_CONFIG_DIR` or `~/.claude`; no env override). A planted file did not
154
+ // change `--init-only` in a temp config with no account (measured): it is
155
+ // honoured only for an eligible account. (`JE()` names a
156
+ // `CLAUDE_CODE_REMOTE_SETTINGS_PATH` override in a log line, but returns
157
+ // undefined in 2.1.283: compiled out.)
158
+ // - remote, storage-v5 (round 4): with the v5 backend on (`N()`), the payload is
159
+ // written to the state store, `Re.state("remote-settings")` = namespace `state`,
160
+ // id `remote-settings`, and the signed-cache sidecars
161
+ // `remote-settings.json.signature.json` / `.signature-iat.json` go beside the
162
+ // json path. The config home lists `state` and `storage-v2`, but the store's
163
+ // exact on-disk name (file or directory, extension) is NOT recoverable from
164
+ // strings, so doctor probes every candidate — `state/remote-settings[.json]`,
165
+ // `storage-v2/state/remote-settings[.json]` (source `remote_settings_state`) — and
166
+ // both sidecars (`remote_settings_signature`); any present is evidence. Whether v5
167
+ // is on is decided at runtime by a server-served flag (`tengu_hover_rest`, pinned
168
+ // once per process), so its default cannot be told from the binary.
169
+ // - macOS: `/Library/Managed Preferences/<login>/com.anthropic.claudecode.plist`
170
+ // ("per-user managed preferences", login = `os.userInfo().username`) and
171
+ // `/Library/Managed Preferences/com.anthropic.claudecode.plist` ("device-level");
172
+ // the binary converts them with `/usr/bin/plutil`. Existence only. No login name
173
+ // (os.userInfo() throws or gives none) → `managed_policy_check_failed:login_unavailable`,
174
+ // never a silent skip.
175
+ // - Windows: `%SYSTEMROOT%\System32\reg.exe query <key> /v Settings` (default
176
+ // `C:\Windows`) for `HKLM\SOFTWARE\Policies\ClaudeCode` and
177
+ // `HKCU\SOFTWARE\Policies\ClaudeCode`, 5 s timeout. Doctor runs the same query
178
+ // (argv array, no shell): exit 0 → evidence; reg's "unable to find" → none (the
179
+ // binary's own test); anything else, a timeout or no reg.exe →
180
+ // `managed_policy_check_failed:<reg_failed|reg_timeout|reg_unavailable>`.
181
+ // - WSL (the binary's test: `WSL_DISTRO_NAME`, or /proc/version naming microsoft or
182
+ // wsl): Windows policy is inherited only when an ADMIN Windows source sets
183
+ // `wslInheritsWindowsSettings` — `/mnt/c/Program Files/ClaudeCode/managed-settings.json`
184
+ // (+ `.d`) or HKLM, queried through `/mnt/c/Windows/System32/reg.exe` when present.
185
+ // Evidence of either → the tier is replaced; HKCU alone cannot apply on WSL and is
186
+ // not probed; no interop reg.exe → HKLM is not queried (the binary cannot either).
187
+ // Evidence (or a probe that cannot finish) → the managed FILE tier contributes no
188
+ // policy, hooks or servers, and registration is `hook_policy_unjudgeable` (or the
189
+ // check-failed code) unless a failure outside it stands. No evidence → judged
190
+ // normally. The `managed_policy` report field and its one text line say what was
191
+ // checked; they never change the exit code. The managed-settings directory
192
+ // defaults to the binary's per platform (`Zh()`): `/etc/claude-code`,
193
+ // `/Library/Application Support/ClaudeCode`, `C:\Program Files\ClaudeCode`.
194
+ // Floors: a macOS profile delivering settings under another domain, a storage-v5
195
+ // layout other than the probed candidates, and a plist's, key's or store's
196
+ // CONTENT are not read — presence is the whole signal. `scripts/check-child-argv-credentials.mjs`
197
+ // passes but does not inspect this call: its program is a variable, which that
198
+ // checker skips (probed: a planted credential in this argv still scanned clean).
199
+ // trust the binary skips hook execution when the workspace trust dialog is not
200
+ // accepted (`Tie()`); doctor does not read trust (floor).
201
+ // matchers (round-2 F029) a group's `matcher` filters: on SessionStart the start source
202
+ // (measured: "resume" did not fire on startup; "" and "*" did), on FileChanged
203
+ // basename(file_path), where it also adds static watch paths (binary). Our frozen
204
+ // groups carry none (absent or ""); a settings or foreign wake hook with any other
205
+ // matcher is `unjudgeable_hook`.
206
+ // freeze (F025) our hooks are compared on type, command, matcher, and the exact handler
207
+ // and group key NAMES: FileChanged `{type, command, asyncRewake: true}`,
208
+ // SessionStart `{type, command}`, so a `timeout`, `if`, `statusMessage` or a
209
+ // SessionStart `asyncRewake` is a mismatch. Floor: the hook script's bytes are not
210
+ // hashed (a github source of our repository is trusted by source).
211
+ // disabled vs identity (panel S4, intended): the foreign-plugin and stanza checks read a
212
+ // plugin's declared tree whether or not `disabledMcpServers` turns its server off
213
+ // here. What a plugin IS does not depend on one project's switch; only the
214
+ // registration count and the pin (which need a launched server) honour it.
215
+ //
216
+ // Floors: codex plugins are not read; a plugin's `userConfig`, managed servers under
217
+ // `disabledMcpServers` (unmeasured, so never excluded), and an `--add-dir`/`--settings` layer are
218
+ // not modelled; claude's `.mcp.json`
219
+ // approval state and pi/grok project trust are not modelled (a
60
220
  // project entry is counted as loaded); opencode's `OPENCODE_CONFIG`/`OPENCODE_CONFIG_DIR` are not
61
221
  // read; `managed-mcp.json`'s exclusive-control mode is not modelled; `claude mcp get` still showed
62
222
  // a local entry carrying `disabled: true`, so doctor's reading of that field as "not launched" is
@@ -65,16 +225,46 @@
65
225
  // Config text is DATA. Every object the readers build has a null prototype and every lookup is an
66
226
  // own-property lookup; a `__proto__`, `constructor` or `prototype` key makes the file unparseable
67
227
  // (round 1, Astra 1: `[__proto__.mcp_servers.x]` used to write onto `Object.prototype`).
228
+ import { spawnSync } from "node:child_process";
68
229
  import * as nodeFs from "node:fs";
69
- import { dirname, isAbsolute, join, resolve } from "node:path";
230
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
70
231
  export const BOARD_MCP_MARKER = "agent-board-mcp";
71
232
  export const METADATA_ONLY_FLAG = "--metadata-only";
72
233
  export const OPENCODE_PLUGIN_PACKAGE = "@consiliency/agent-board-opencode";
73
234
  export const PI_PACKAGE = "npm:@consiliency/agent-board-pi";
74
235
  /** Claude Code events that each need exactly one `wake-hook.sh` hook (`docs/local-wake-signal.md`). */
75
236
  export const CLAUDE_REQUIRED_HOOK_EVENTS = Object.freeze(["SessionStart", "FileChanged"]);
237
+ /** A wake hook is a command with a token whose BASENAME is this (see `namesWakeHook`). */
76
238
  export const WAKE_HOOK_MARKER = "wake-hook.sh";
77
239
  const WRAPPER_MAX_BYTES = 64 * 1024;
240
+ // IF-0-CCPLUGIN-1, as frozen on outcome B (`plans/CCPLUGIN-SPIKE.md`). SL-1's manifest check owns
241
+ // the plugin tree; these are the same frozen strings, read from the plan, not from its files.
242
+ export const CLAUDE_PLUGIN_ID = "agent-board@message-board";
243
+ export const CLAUDE_PLUGIN_MARKETPLACE = "message-board";
244
+ /** Compared lower-cased, as Claude Code compares repos. */
245
+ export const CLAUDE_PLUGIN_REPO = "consiliency/message-board";
246
+ export const CLAUDE_PLUGIN_SERVER = "agent-board";
247
+ export const CLAUDE_PLUGIN_MCP_PACKAGE = "@consiliency/agent-board-mcp";
248
+ /** The SL-0 outcome: `B` = the FileChanged hook carries `asyncRewake: true`. */
249
+ export const CLAUDE_WAKE_SHAPE = "B";
250
+ export const CLAUDE_PLUGIN_HOOK_COMMANDS = Object.freeze({
251
+ SessionStart: "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wake-hook.sh\" SessionStart",
252
+ FileChanged: "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wake-hook.sh\" FileChanged",
253
+ });
254
+ /** The stanza a plugin of version `version` must carry: the pin IS the plugin version. */
255
+ export function claudePluginStanza(version) {
256
+ return { command: "npx", args: ["-y", `${CLAUDE_PLUGIN_MCP_PACKAGE}@${version}`, "--harness", "claude"] };
257
+ }
258
+ /**
259
+ * Whether a hook command names the wake hook. The tokenizer is frozen: split on whitespace, strip
260
+ * quotes, no comment handling; a token matches when its basename is exactly `wake-hook.sh`, so
261
+ * `codex-wake-hook.sh` does not. Floor: a hook that never names the basename is invisible.
262
+ */
263
+ export function namesWakeHook(command) {
264
+ // Quotes are removed by split/join, not a regex: a quote inside a regex literal desyncs the
265
+ // comment/literal stripper that the #403 class checks run over this file.
266
+ return command.split(/\s+/).some((token) => token.split("\"").join("").split("'").join("").split("/").pop() === WAKE_HOOK_MARKER);
267
+ }
78
268
  const missing = (error) => {
79
269
  const code = error.code;
80
270
  return code === "ENOENT" || code === "ENOTDIR";
@@ -120,6 +310,16 @@ export const nodeRegistrationFs = {
120
310
  return false;
121
311
  }
122
312
  },
313
+ realpath(path) {
314
+ try {
315
+ return nodeFs.realpathSync(path);
316
+ }
317
+ catch (error) {
318
+ if (missing(error))
319
+ return null;
320
+ throw error;
321
+ }
322
+ },
123
323
  getuid: () => process.getuid?.() ?? -1,
124
324
  };
125
325
  // ---------------------------------------------------------------------------------------------
@@ -425,6 +625,8 @@ export function parseRestrictedToml(text) {
425
625
  fail("trailing characters");
426
626
  }
427
627
  }
628
+ /** A `type: "command"` handler with a command: the only kind doctor can judge. */
629
+ const isCommand = (hook) => hook.type === "command" && hook.command !== null;
428
630
  const empty = () => ({ mcp: [], hooks: Object.create(null), packages: [] });
429
631
  const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
430
632
  /** Judge-relevant fields of one entry, from its own fields only. */
@@ -473,10 +675,22 @@ function hooksFrom(settings) {
473
675
  const hooks = isObject(group) ? own(group, "hooks") : undefined;
474
676
  if (!Array.isArray(hooks))
475
677
  throw new UnparseableConfigError(`hooks.${event} has a group with no hooks array`);
678
+ // EVERY handler is kept, whatever its type: an http/mcp_tool/prompt/agent handler still
679
+ // runs (panel B2), so dropping it would hide it from the exact-set and unjudgeable checks.
680
+ const matcher = own(group, "matcher");
476
681
  for (const hook of hooks) {
682
+ if (!isObject(hook))
683
+ throw new UnparseableConfigError(`hooks.${event} has a handler that is not an object`);
684
+ const type = own(hook, "type");
477
685
  const command = own(hook, "command");
478
- if (isObject(hook) && typeof command === "string")
479
- (out[event] ??= []).push(command);
686
+ (out[event] ??= []).push({
687
+ type: typeof type === "string" ? type : "",
688
+ command: type === "command" && typeof command === "string" ? command : null,
689
+ asyncRewake: own(hook, "asyncRewake") === true,
690
+ hasMatcher: matcher !== undefined && matcher !== "",
691
+ handlerKeys: Object.keys(hook).sort(),
692
+ groupKeys: Object.keys(group).sort(),
693
+ });
480
694
  }
481
695
  }
482
696
  }
@@ -563,7 +777,7 @@ export const HARNESS_CONFIG_SCOPES = Object.freeze({
563
777
  extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "claude") }) }),
564
778
  row({ name: "claude_local_mcp", parser: "json", rank: 30, paths: (ctx) => [claudeJsonPath(ctx)],
565
779
  extract: (doc, ctx) => {
566
- const project = own(own(doc, "projects"), resolve(ctx.cwd));
780
+ const project = own(own(doc, "projects"), claudeProjectKey(ctx));
567
781
  return { ...empty(), mcp: mcpFromMap(own(project, "mcpServers"), "claude") };
568
782
  } }),
569
783
  row({ name: "claude_project_mcp", parser: "json", rank: 20, paths: (ctx) => [join(ctx.cwd, ".mcp.json")],
@@ -636,6 +850,494 @@ function opencodeContribution(doc) {
636
850
  return { ...empty(), mcp: mcpFromMap(own(doc, "mcp"), "opencode"), packages: packageSpecs(own(doc, "plugin"), "plugin") };
637
851
  }
638
852
  // ---------------------------------------------------------------------------------------------
853
+ // Claude Code plugins (IF-0-CCPLUGIN-3; the measurements are in the header)
854
+ // ---------------------------------------------------------------------------------------------
855
+ /** Synthetic row names: authored, so no plugin id, marketplace name or path reaches a reason. */
856
+ export const CLAUDE_PLUGIN_ROW = "claude_plugin";
857
+ export const CLAUDE_FOREIGN_PLUGIN_ROW = "claude_plugin_foreign";
858
+ const INSTALLED_PLUGINS_SCHEMA = 2;
859
+ // Managed (admin) policy sources other than the managed-settings file, exactly as Claude Code
860
+ // 2.1.283 reads them (strings of the linux-x64, darwin-arm64 and win32-x64 binaries; read, not
861
+ // run; see the header's `managed tier`). Doctor looks for EVIDENCE of each and never reads content.
862
+ export const CLAUDE_REMOTE_SETTINGS_FILE = "remote-settings.json";
863
+ /**
864
+ * Storage-v5 (round 4): the binary writes the server-managed payload to its state store
865
+ * (`Re.state("remote-settings")`, a `state` namespace in the config home) and a signed-cache
866
+ * sidecar beside the json path. The store's exact on-disk name is not recoverable from strings,
867
+ * so every candidate is probed; any one present is evidence.
868
+ */
869
+ export const CLAUDE_REMOTE_SETTINGS_STATE_CANDIDATES = Object.freeze([
870
+ join("state", "remote-settings"), join("state", "remote-settings.json"),
871
+ join("storage-v2", "state", "remote-settings"), join("storage-v2", "state", "remote-settings.json"),
872
+ ]);
873
+ export const CLAUDE_REMOTE_SETTINGS_SIDECARS = Object.freeze([
874
+ "remote-settings.json.signature.json", "remote-settings.json.signature-iat.json",
875
+ ]);
876
+ export const MACOS_MANAGED_PREFERENCES = "/Library/Managed Preferences";
877
+ export const MACOS_POLICY_PLIST = "com.anthropic.claudecode.plist";
878
+ export const WINDOWS_POLICY_KEYS = Object.freeze({
879
+ hklm: "HKLM\\SOFTWARE\\Policies\\ClaudeCode",
880
+ hkcu: "HKCU\\SOFTWARE\\Policies\\ClaudeCode",
881
+ });
882
+ export const WINDOWS_POLICY_VALUE = "Settings";
883
+ /** Where WSL's Windows interop puts reg.exe; the binary reads HKLM policy through this path. */
884
+ export const WSL_REG_EXE = "/mnt/c/Windows/System32/reg.exe";
885
+ export const WSL_WINDOWS_MANAGED_DIR = "/mnt/c/Program Files/ClaudeCode";
886
+ const REG_TIMEOUT_MS = 5000;
887
+ /**
888
+ * The binary's own query: `reg query <key> /v Settings`, an argv array, no shell, 5 s. Exit 0 →
889
+ * the value is there; reg's "unable to find" → not there (the binary's own test); anything else —
890
+ * no reg.exe, a timeout, another failure — is an error, never "absent".
891
+ */
892
+ export function regQueryWith(timeoutMs) {
893
+ return (regExe, key) => {
894
+ const result = spawnSync(regExe, ["query", key, "/v", WINDOWS_POLICY_VALUE], {
895
+ encoding: "utf8", timeout: timeoutMs, windowsHide: true, stdio: ["ignore", "pipe", "pipe"],
896
+ });
897
+ if (result.error) {
898
+ return { kind: "error", cause: result.error.code === "ETIMEDOUT" ? "reg_timeout" : "reg_unavailable" };
899
+ }
900
+ if (result.signal !== null)
901
+ return { kind: "error", cause: "reg_timeout" };
902
+ if (result.status === 0)
903
+ return { kind: "present" };
904
+ return /unable to find/i.test(`${result.stdout ?? ""}${result.stderr ?? ""}`) ? { kind: "absent" } : { kind: "error", cause: "reg_failed" };
905
+ };
906
+ }
907
+ export const nodeRegQuery = regQueryWith(REG_TIMEOUT_MS);
908
+ /** The binary's WSL test: `WSL_DISTRO_NAME`, or /proc/version naming microsoft or wsl. */
909
+ function onWsl(ctx) {
910
+ if (ctx.env.WSL_DISTRO_NAME)
911
+ return true;
912
+ const version = (ctx.fs.readFile("/proc/version") ?? "").toLowerCase();
913
+ return version.includes("microsoft") || version.includes("wsl");
914
+ }
915
+ export function probeManagedPolicy(ctx) {
916
+ const checked = [];
917
+ const evidence = [];
918
+ const errors = [];
919
+ const present = (name, path) => {
920
+ checked.push(name);
921
+ if (ctx.fs.exists(path))
922
+ evidence.push(name);
923
+ };
924
+ const registry = (name, regExe, key) => {
925
+ checked.push(name);
926
+ const outcome = (ctx.regQuery ?? nodeRegQuery)(regExe, key);
927
+ if (outcome.kind === "present")
928
+ evidence.push(name);
929
+ else if (outcome.kind === "error")
930
+ errors.push(outcome.cause);
931
+ };
932
+ const anyOf = (name, paths) => {
933
+ checked.push(name);
934
+ if (paths.some((path) => ctx.fs.exists(path)))
935
+ evidence.push(name);
936
+ };
937
+ const home = claudeUserDir(ctx);
938
+ present("remote_settings", join(home, CLAUDE_REMOTE_SETTINGS_FILE));
939
+ anyOf("remote_settings_state", CLAUDE_REMOTE_SETTINGS_STATE_CANDIDATES.map((relative) => join(home, relative)));
940
+ anyOf("remote_settings_signature", CLAUDE_REMOTE_SETTINGS_SIDECARS.map((relative) => join(home, relative)));
941
+ const platform = ctx.platform ?? "linux";
942
+ if (platform === "darwin") {
943
+ // No login name → the per-user plist cannot be located: an unfinished probe, never a skip.
944
+ if (ctx.userName)
945
+ present("macos_user_plist", join(MACOS_MANAGED_PREFERENCES, ctx.userName, MACOS_POLICY_PLIST));
946
+ else {
947
+ checked.push("macos_user_plist");
948
+ errors.push("login_unavailable");
949
+ }
950
+ present("macos_device_plist", join(MACOS_MANAGED_PREFERENCES, MACOS_POLICY_PLIST));
951
+ return { checked, evidence, errors, unreadableSourcesPossible: true, platformLabel: "macOS" };
952
+ }
953
+ if (platform === "win32") {
954
+ const regExe = `${ctx.env.SYSTEMROOT || "C:\\Windows"}\\System32\\reg.exe`;
955
+ registry("windows_hklm", regExe, WINDOWS_POLICY_KEYS.hklm);
956
+ registry("windows_hkcu", regExe, WINDOWS_POLICY_KEYS.hkcu);
957
+ return { checked, evidence, errors, unreadableSourcesPossible: true, platformLabel: "Windows" };
958
+ }
959
+ if (onWsl(ctx)) {
960
+ // WSL inherits Windows policy only when an ADMIN Windows source enables it (HKLM or the
961
+ // Program Files file); HKCU alone cannot, so it is not probed here.
962
+ present("wsl_program_files_settings", join(WSL_WINDOWS_MANAGED_DIR, "managed-settings.json"));
963
+ present("wsl_program_files_settings_d", join(WSL_WINDOWS_MANAGED_DIR, "managed-settings.d"));
964
+ if (ctx.fs.exists(WSL_REG_EXE))
965
+ registry("wsl_windows_hklm", WSL_REG_EXE, WINDOWS_POLICY_KEYS.hklm);
966
+ return { checked, evidence, errors, unreadableSourcesPossible: true, platformLabel: "WSL" };
967
+ }
968
+ return { checked, evidence, errors, unreadableSourcesPossible: false, platformLabel: "Linux" };
969
+ }
970
+ /** The binary's `Le`: an env flag is on when it is 1/true/yes/on. */
971
+ const truthyFlag = (value) => value !== undefined && ["1", "true", "yes", "on"].includes(value.toLowerCase().trim());
972
+ /** Evidence of (or an unfinished probe for) a tier that replaces the managed settings FILE. */
973
+ export const managedFileTierReplaced = (probe) => probe.evidence.length > 0 || probe.errors.length > 0;
974
+ function claudeHookPolicy(ctx, allLayers, probe) {
975
+ // The caller drops the managed FILE layers when a tier doctor cannot read replaces them (round-4
976
+ // codex: one filter, at the read, for every consumer); this function only reports the fact.
977
+ const managedTierUnreadable = managedFileTierReplaced(probe);
978
+ const layers = allLayers;
979
+ // The highest layer holding a boolean wins (measured for disableAllHooks: local false over user true).
980
+ const lastBoolean = (key, only) => {
981
+ let value;
982
+ for (const layer of layers) {
983
+ if (only === "managed" && !layer.managed)
984
+ continue;
985
+ const candidate = own(layer.document, key);
986
+ if (typeof candidate === "boolean")
987
+ value = candidate;
988
+ }
989
+ return value;
990
+ };
991
+ const managedIds = new Set();
992
+ let strict;
993
+ let unjudgeable = false;
994
+ for (const layer of layers) {
995
+ if (!layer.managed)
996
+ continue;
997
+ const enabled = own(layer.document, "enabledPlugins");
998
+ if (isObject(enabled)) {
999
+ for (const id of Object.keys(enabled)) {
1000
+ // As the binary merges managed documents (read, not run): its schema takes a boolean, a
1001
+ // version array or an object here, and reads anything else as UNSET, so a later string,
1002
+ // number or null leaves the earlier value standing. A valid non-true value replaces it,
1003
+ // and `eb()` counts only a literal true.
1004
+ const value = own(enabled, id);
1005
+ if (value === true && id.includes("@"))
1006
+ managedIds.add(id);
1007
+ else if (value === false || Array.isArray(value) || isObject(value))
1008
+ managedIds.delete(id);
1009
+ }
1010
+ }
1011
+ const value = own(layer.document, "strictPluginOnlyCustomization");
1012
+ if (value !== undefined)
1013
+ strict = value;
1014
+ if (own(layer.document, "prependPlugins") !== undefined || own(layer.document, "appendPlugins") !== undefined)
1015
+ unjudgeable = true;
1016
+ }
1017
+ const managedDisableAll = lastBoolean("disableAllHooks", "managed") === true;
1018
+ const managedOnly = lastBoolean("allowManagedHooksOnly", "managed") === true;
1019
+ const anyDisableAll = lastBoolean("disableAllHooks") === true;
1020
+ const safeMode = truthyFlag(ctx.env.CLAUDE_CODE_SAFE_MODE);
1021
+ const bare = truthyFlag(ctx.env.CLAUDE_CODE_SIMPLE);
1022
+ const strictHooks = strict === true || (Array.isArray(strict) && strict.includes("hooks"));
1023
+ const managedPluginIds = managedIds;
1024
+ if (bare || managedDisableAll)
1025
+ return { settings: "none", plugins: "none", managedPluginIds, unjudgeable: false, managedTierUnreadable, probe };
1026
+ const settings = managedOnly || safeMode || strictHooks || anyDisableAll ? "managed_only" : "all";
1027
+ const plugins = safeMode ? "none" : managedOnly || anyDisableAll ? "managed_only" : "all";
1028
+ return { settings, plugins, managedPluginIds, unjudgeable: unjudgeable && plugins === "managed_only", managedTierUnreadable, probe };
1029
+ }
1030
+ class UntrustedPluginPathError extends Error {
1031
+ }
1032
+ const claudePluginsDir = (ctx) => join(claudeUserDir(ctx), "plugins");
1033
+ /** Settings files that carry `enabledPlugins`/`extraKnownMarketplaces`, in ascending precedence. */
1034
+ const claudeSettingsLayers = (ctx) => [
1035
+ join(claudeUserDir(ctx), "settings.json"),
1036
+ join(ctx.cwd, ".claude", "settings.json"),
1037
+ join(ctx.cwd, ".claude", "settings.local.json"),
1038
+ ...managedSettingsPaths(ctx),
1039
+ ];
1040
+ const within = (path, root) => path.startsWith(root.endsWith(sep) ? root : root + sep);
1041
+ /** The git repository root at or above `dir`, or null outside one. */
1042
+ function gitRootOf(dir, fs) {
1043
+ const walked = walkToGitRoot(dir, fs);
1044
+ const top = walked[walked.length - 1];
1045
+ return fs.exists(join(top, ".git")) ? top : null;
1046
+ }
1047
+ /**
1048
+ * Claude Code's row applicability (`uw`): user/managed rows everywhere, a project's rows in its
1049
+ * repository. Measured from a symlinked cwd (panel S2): Claude's cwd is the REALPATH, while the
1050
+ * row's `projectPath` is compared as recorded — a projectPath that is itself a symlink never
1051
+ * applies, even from that symlink.
1052
+ */
1053
+ function rowApplies(row, ctx) {
1054
+ const scope = own(row, "scope");
1055
+ if (scope === "user" || scope === "managed")
1056
+ return true;
1057
+ const projectPath = own(row, "projectPath");
1058
+ if (typeof projectPath !== "string" || projectPath === "")
1059
+ return false;
1060
+ const cwd = ctx.fs.realpath(ctx.cwd) ?? resolve(ctx.cwd);
1061
+ if (resolve(projectPath) === cwd)
1062
+ return true;
1063
+ const root = gitRootOf(cwd, ctx.fs);
1064
+ return root !== null && gitRootOf(resolve(projectPath), ctx.fs) === root;
1065
+ }
1066
+ function readJsonFile(fs, path) {
1067
+ const text = fs.readFile(path);
1068
+ return text === null ? null : parseJson(text);
1069
+ }
1070
+ const truthyEnv = (value) => value !== undefined && ["1", "true", "yes", "on"].includes(value.toLowerCase().trim());
1071
+ /** `source` names a github marketplace of this repository, with no alternate manifest path. */
1072
+ function isOurMarketplaceSource(source) {
1073
+ const repo = own(source, "repo");
1074
+ return isObject(source) && own(source, "source") === "github" && typeof repo === "string"
1075
+ && repo.toLowerCase() === CLAUDE_PLUGIN_REPO && own(source, "path") === undefined;
1076
+ }
1077
+ /**
1078
+ * Reads one plugin tree, confined to the cache. `installPath` must resolve (realpath) strictly
1079
+ * inside `<configDir>/plugins/cache/`, and so must every file read under it, else
1080
+ * `UntrustedPluginPathError` and nothing beyond it is read: the only call made on an untrusted
1081
+ * path is the `realpath` that judged it.
1082
+ */
1083
+ function readPluginTree(ctx, installPath, serverPrefix) {
1084
+ if (!isAbsolute(installPath))
1085
+ throw new UntrustedPluginPathError();
1086
+ const cache = ctx.fs.realpath(join(claudePluginsDir(ctx), "cache"));
1087
+ const root = cache === null ? null : ctx.fs.realpath(installPath);
1088
+ if (cache === null || root === null || !within(root, cache))
1089
+ throw new UntrustedPluginPathError();
1090
+ const confined = (relative) => {
1091
+ const lexical = resolve(root, relative);
1092
+ if (!within(lexical, root))
1093
+ throw new UntrustedPluginPathError();
1094
+ const real = ctx.fs.realpath(lexical);
1095
+ if (real !== null && !within(real, root))
1096
+ throw new UntrustedPluginPathError();
1097
+ return real;
1098
+ };
1099
+ // Each file is read at most once: a manifest naming the default file (`"hooks":
1100
+ // "./hooks/hooks.json"`) must not count its hooks twice.
1101
+ const consumed = new Set();
1102
+ const readConfined = (relative) => {
1103
+ const real = confined(relative);
1104
+ if (real === null || consumed.has(real))
1105
+ return null;
1106
+ consumed.add(real);
1107
+ return readJsonFile(ctx.fs, real);
1108
+ };
1109
+ const manifest = readConfined(join(".claude-plugin", "plugin.json")) ?? dict();
1110
+ const contribution = empty();
1111
+ const addServers = (map) => {
1112
+ for (const entry of mcpFromMap(map, "claude"))
1113
+ contribution.mcp.push({ ...entry, name: `${serverPrefix}:${entry.name}` });
1114
+ };
1115
+ const serversIn = (document) => own(document, "mcpServers") ?? document;
1116
+ const addHooks = (document) => {
1117
+ const hooks = hooksFrom(isObject(own(document, "hooks")) ? document : overlay({ hooks: document }));
1118
+ for (const event of Object.keys(hooks))
1119
+ (contribution.hooks[event] ??= []).push(...hooks[event]);
1120
+ };
1121
+ // A manifest field is an inline object, a path relative to the plugin root, or a list of either.
1122
+ const eachDeclared = (value, field, add, inline) => {
1123
+ if (value === undefined)
1124
+ return;
1125
+ for (const item of Array.isArray(value) ? value : [value]) {
1126
+ if (typeof item === "string") {
1127
+ const document = readConfined(item);
1128
+ if (document !== null)
1129
+ add(document);
1130
+ }
1131
+ else if (isObject(item))
1132
+ inline(item);
1133
+ else
1134
+ throw new UnparseableConfigError(`plugin ${field} is neither a path nor an object`);
1135
+ }
1136
+ };
1137
+ eachDeclared(own(manifest, "mcpServers"), "mcpServers", (document) => addServers(serversIn(document)), addServers);
1138
+ const dotMcp = readConfined(".mcp.json");
1139
+ if (dotMcp !== null)
1140
+ addServers(serversIn(dotMcp));
1141
+ const hooksFile = readConfined(join("hooks", "hooks.json"));
1142
+ if (hooksFile !== null)
1143
+ addHooks(hooksFile);
1144
+ eachDeclared(own(manifest, "hooks"), "hooks", addHooks, addHooks);
1145
+ return { contribution, manifest };
1146
+ }
1147
+ /**
1148
+ * One handler, exactly as frozen (round-2 F025/F029): type `command`, the frozen command, no
1149
+ * filtering matcher, `asyncRewake: true` on FileChanged and ABSENT on SessionStart, and no other
1150
+ * handler or group field (so a `timeout`, `if` or `statusMessage` is a mismatch too).
1151
+ */
1152
+ function matchesFrozenHandler(event, hook) {
1153
+ const handlerKeys = event === "FileChanged" && CLAUDE_WAKE_SHAPE === "B" ? ["asyncRewake", "command", "type"] : ["command", "type"];
1154
+ return isCommand(hook) && hook.command === CLAUDE_PLUGIN_HOOK_COMMANDS[event]
1155
+ && !hook.hasMatcher && hook.groupKeys.every((key) => key === "hooks" || key === "matcher")
1156
+ && JSON.stringify(hook.handlerKeys) === JSON.stringify(handlerKeys)
1157
+ && (handlerKeys.includes("asyncRewake") ? hook.asyncRewake : true);
1158
+ }
1159
+ function sameKeys(value, keys) {
1160
+ return isObject(value) && Object.keys(value).sort().join("\n") === [...keys].sort().join("\n");
1161
+ }
1162
+ /** Ours carries exactly the frozen stanza, derived from its own `version`, and exactly the frozen hooks. */
1163
+ function oursMatchesFreeze(contribution, version) {
1164
+ if (version === null)
1165
+ return false;
1166
+ const expected = claudePluginStanza(version);
1167
+ const servers = contribution.mcp;
1168
+ const stanzaOk = servers.length === 1
1169
+ && servers[0].name.endsWith(`:${CLAUDE_PLUGIN_SERVER}`)
1170
+ && sameKeys(servers[0].fields, ["command", "args"])
1171
+ && own(servers[0].fields, "command") === expected.command
1172
+ && JSON.stringify(own(servers[0].fields, "args")) === JSON.stringify(expected.args);
1173
+ // Exactly the frozen set: both events, one command handler each, and no other handler of any
1174
+ // type on any event (panel B2a, S3).
1175
+ const events = Object.keys(contribution.hooks).filter((event) => contribution.hooks[event].length > 0).sort();
1176
+ const hooksOk = JSON.stringify(events) === JSON.stringify(Object.keys(CLAUDE_PLUGIN_HOOK_COMMANDS).sort())
1177
+ && events.every((event) => contribution.hooks[event].length === 1 && matchesFrozenHandler(event, contribution.hooks[event][0]));
1178
+ return stanzaOk && hooksOk;
1179
+ }
1180
+ function claudeAutoUpdate(ctx, declarations, known) {
1181
+ const envDisabled = truthyEnv(ctx.env.DISABLE_UPDATES) || truthyEnv(ctx.env.DISABLE_AUTOUPDATER)
1182
+ || !!ctx.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
1183
+ let claudeJson;
1184
+ try {
1185
+ claudeJson = readJsonFile(ctx.fs, claudeJsonPath(ctx));
1186
+ }
1187
+ catch (error) {
1188
+ if (!(error instanceof UnparseableConfigError))
1189
+ throw error;
1190
+ return { effective: false, source: "undetermined" };
1191
+ }
1192
+ const configDisabled = own(claudeJson, "autoUpdates") === false
1193
+ && !(own(claudeJson, "installMethod") === "native" && own(claudeJson, "autoUpdatesProtectedForNative") === true);
1194
+ if ((envDisabled || configDisabled) && !truthyEnv(ctx.env.FORCE_AUTOUPDATE_PLUGINS)) {
1195
+ return { effective: false, source: envDisabled ? "env_disabled" : "config_disabled" };
1196
+ }
1197
+ // The highest settings scope that declares the marketplace replaces the lower ones whole.
1198
+ const fromSettings = declarations.length > 0 ? own(declarations[declarations.length - 1], "autoUpdate") : undefined;
1199
+ if (typeof fromSettings === "boolean")
1200
+ return { effective: fromSettings, source: "settings" };
1201
+ const fromKnown = own(own(known, CLAUDE_PLUGIN_MARKETPLACE), "autoUpdate");
1202
+ if (typeof fromKnown === "boolean")
1203
+ return { effective: fromKnown, source: "known_marketplaces" };
1204
+ return { effective: false, source: "default" };
1205
+ }
1206
+ /**
1207
+ * IF-0-CCPLUGIN-3: every plugin enabled for `ctx.cwd`, followed from `enabledPlugins` to its
1208
+ * installed row and read from the cache. Ours is the plugin id `agent-board@message-board` whose
1209
+ * marketplace is declared, EVERYWHERE it is declared, as the github repository
1210
+ * Consiliency/message-board; anything else contributing a board server or a wake hook is foreign.
1211
+ */
1212
+ export function readClaudePluginScopes(ctx) {
1213
+ const unparseable = new Set();
1214
+ const failures = new Set();
1215
+ const files = [];
1216
+ let untrusted = false;
1217
+ const summary = {
1218
+ installed: false, serverActive: false, version: null, pinnedMcpVersion: null, sourceRef: null, autoUpdate: { effective: false, source: "default" },
1219
+ };
1220
+ const tolerant = (read, fallback) => {
1221
+ try {
1222
+ return read();
1223
+ }
1224
+ catch (error) {
1225
+ if (!(error instanceof UnparseableConfigError))
1226
+ throw error;
1227
+ unparseable.add(CLAUDE_PLUGIN_ROW);
1228
+ return fallback;
1229
+ }
1230
+ };
1231
+ // Round-4 codex: when a tier doctor cannot read replaces the managed settings FILE, that file is
1232
+ // not read at all, so it feeds NO consumer — plugin enablement, marketplace declarations (and so
1233
+ // provenance and "ours"), the source ref, auto-update, the hook policy — and its parse errors are
1234
+ // not reported. Only the probe's own evidence speaks for it.
1235
+ const probe = probeManagedPolicy(ctx);
1236
+ const managedPaths = new Set(managedSettingsPaths(ctx));
1237
+ const fileTierReplaced = managedFileTierReplaced(probe);
1238
+ const layers = claudeSettingsLayers(ctx).flatMap((path) => {
1239
+ const managed = managedPaths.has(path);
1240
+ if (managed && fileTierReplaced)
1241
+ return [];
1242
+ const document = tolerant(() => readJsonFile(ctx.fs, path), null);
1243
+ return document === null ? [] : [{ managed, document }];
1244
+ });
1245
+ const settings = layers.map((layer) => layer.document);
1246
+ const hookPolicy = claudeHookPolicy(ctx, layers, probe);
1247
+ const known = tolerant(() => readJsonFile(ctx.fs, join(claudePluginsDir(ctx), "known_marketplaces.json")), null);
1248
+ const installed = tolerant(() => {
1249
+ const document = readJsonFile(ctx.fs, join(claudePluginsDir(ctx), "installed_plugins.json"));
1250
+ if (document !== null && (own(document, "version") !== INSTALLED_PLUGINS_SCHEMA || !isObject(own(document, "plugins")))) {
1251
+ throw new UnparseableConfigError("installed_plugins.json has an unknown shape");
1252
+ }
1253
+ return document;
1254
+ }, null);
1255
+ // enabledPlugins: the highest scope holding a boolean wins; anything else defines nothing.
1256
+ const enabled = new Map();
1257
+ for (const document of settings) {
1258
+ const map = own(document, "enabledPlugins");
1259
+ if (!isObject(map))
1260
+ continue;
1261
+ for (const id of Object.keys(map)) {
1262
+ const value = own(map, id);
1263
+ if (typeof value === "boolean")
1264
+ enabled.set(id, value);
1265
+ }
1266
+ }
1267
+ const declarationsOf = (marketplace) => settings.flatMap((document) => {
1268
+ const declaration = own(own(document, "extraKnownMarketplaces"), marketplace);
1269
+ return declaration === undefined ? [] : [declaration];
1270
+ });
1271
+ const ourDeclarations = declarationsOf(CLAUDE_PLUGIN_MARKETPLACE);
1272
+ const ourKnown = own(known, CLAUDE_PLUGIN_MARKETPLACE);
1273
+ const everyDeclaration = [...ourDeclarations, ...(ourKnown === undefined ? [] : [ourKnown])];
1274
+ const marketplaceIsOurs = everyDeclaration.length > 0
1275
+ && everyDeclaration.every((declaration) => isOurMarketplaceSource(own(declaration, "source")));
1276
+ if (marketplaceIsOurs) {
1277
+ // The winning declaration's ref: the highest settings scope, else known_marketplaces.json.
1278
+ const ref = own(own(everyDeclaration[ourDeclarations.length > 0 ? ourDeclarations.length - 1 : 0], "source"), "ref");
1279
+ summary.sourceRef = typeof ref === "string" ? ref : null;
1280
+ }
1281
+ summary.autoUpdate = tolerant(() => claudeAutoUpdate(ctx, ourDeclarations, known), { effective: false, source: "undetermined" });
1282
+ for (const [id, on] of enabled) {
1283
+ if (!on)
1284
+ continue;
1285
+ const at = id.lastIndexOf("@");
1286
+ if (at <= 0)
1287
+ continue;
1288
+ const name = id.slice(0, at);
1289
+ const rows = own(own(installed, "plugins"), id);
1290
+ if (rows === undefined)
1291
+ continue;
1292
+ if (!Array.isArray(rows)) {
1293
+ unparseable.add(CLAUDE_PLUGIN_ROW);
1294
+ continue;
1295
+ }
1296
+ // The first applicable row is THE row: one without a string installPath stops here like an
1297
+ // untrusted path, rather than falling through to a later row (panel S8).
1298
+ const row = rows.find((candidate) => rowApplies(candidate, ctx));
1299
+ if (row === undefined)
1300
+ continue;
1301
+ if (typeof own(row, "installPath") !== "string") {
1302
+ untrusted = true;
1303
+ continue;
1304
+ }
1305
+ const ours = id === CLAUDE_PLUGIN_ID && marketplaceIsOurs;
1306
+ let tree;
1307
+ try {
1308
+ tree = readPluginTree(ctx, own(row, "installPath"), `plugin:${name}`);
1309
+ }
1310
+ catch (error) {
1311
+ if (error instanceof UntrustedPluginPathError) {
1312
+ untrusted = true;
1313
+ continue;
1314
+ }
1315
+ if (!(error instanceof UnparseableConfigError))
1316
+ throw error;
1317
+ unparseable.add(ours ? CLAUDE_PLUGIN_ROW : CLAUDE_FOREIGN_PLUGIN_ROW);
1318
+ continue;
1319
+ }
1320
+ const { contribution, manifest } = tree;
1321
+ files.push({ row: ours ? CLAUDE_PLUGIN_ROW : CLAUDE_FOREIGN_PLUGIN_ROW, path: own(row, "installPath"), contribution, pluginId: id });
1322
+ if (ours) {
1323
+ const version = own(manifest, "version");
1324
+ summary.installed = true;
1325
+ summary.version = typeof version === "string" ? version : null;
1326
+ const pinArg = contribution.mcp[0]?.args.find((arg) => arg.startsWith(`${CLAUDE_PLUGIN_MCP_PACKAGE}@`));
1327
+ summary.pinnedMcpVersion = pinArg === undefined ? null : pinArg.slice(CLAUDE_PLUGIN_MCP_PACKAGE.length + 1);
1328
+ if (!oursMatchesFreeze(contribution, summary.version))
1329
+ failures.add("plugin_stanza_mismatch");
1330
+ }
1331
+ else {
1332
+ const boardServer = contribution.mcp.some((entry) => judgeMcpEntry(entry, ctx.fs).board);
1333
+ const wakeHook = Object.keys(contribution.hooks).some((event) => contribution.hooks[event].some((hook) => isCommand(hook) && namesWakeHook(hook.command)));
1334
+ if (boardServer || wakeHook)
1335
+ failures.add("foreign_board_plugin");
1336
+ }
1337
+ }
1338
+ return { files, unparseable: [...unparseable], untrusted, failures: [...failures], summary, hookPolicy };
1339
+ }
1340
+ // ---------------------------------------------------------------------------------------------
639
1341
  // Judgement
640
1342
  // ---------------------------------------------------------------------------------------------
641
1343
  /** A package spec's identity without its version: `@scope/name@1.2.3` → `@scope/name`. */
@@ -693,10 +1395,42 @@ export function readScopes(harness, ctx) {
693
1395
  }
694
1396
  return { files, unparseable };
695
1397
  }
1398
+ const MANAGED_MCP_ROWS = new Set(["claude_managed_mcp", "claude_managed_settings"]);
1399
+ /** The `.claude.json` `projects` key Claude Code uses for `cwd`: the git root of its realpath, else the realpath. */
1400
+ export function claudeProjectKey(ctx) {
1401
+ const real = ctx.fs.realpath(ctx.cwd) ?? resolve(ctx.cwd);
1402
+ return gitRootOf(real, ctx.fs) ?? real;
1403
+ }
1404
+ /**
1405
+ * Server names `projects[<key>].disabledMcpServers` turns off here (see the header). An
1406
+ * unparseable `.claude.json` disables nothing: its own row already reports it.
1407
+ */
1408
+ function claudeDisabledServers(ctx) {
1409
+ let document;
1410
+ try {
1411
+ document = readJsonFile(ctx.fs, claudeJsonPath(ctx));
1412
+ }
1413
+ catch (error) {
1414
+ if (error instanceof UnparseableConfigError)
1415
+ return new Set();
1416
+ throw error;
1417
+ }
1418
+ const list = own(own(own(document, "projects"), claudeProjectKey(ctx)), "disabledMcpServers");
1419
+ return new Set(Array.isArray(list) ? list.filter((name) => typeof name === "string") : []);
1420
+ }
696
1421
  /** IF-0-CREDPATH-4 registration, for one working directory. */
697
1422
  export function judgeRegistration(harness, ctx) {
698
- const { files, unparseable } = readScopes(harness, ctx);
699
- const failures = [];
1423
+ const scopes = readScopes(harness, ctx);
1424
+ // claude plugins are synthetic files ranked above user settings. Their server names are
1425
+ // namespaced (`plugin:<name>:<server>`), so no same-name override crosses them either way.
1426
+ const plugins = harness === "claude" ? readClaudePluginScopes(ctx) : null;
1427
+ // When a server-managed or MDM tier replaces the managed settings FILE (round-3 N1), that file
1428
+ // contributes nothing — neither servers nor hooks; the verdict is held undetermined below.
1429
+ const fileTierReplaced = plugins?.hookPolicy.managedTierUnreadable === true;
1430
+ const files = [...scopes.files.filter((file) => !(fileTierReplaced && file.row === "claude_managed_settings")), ...(plugins?.files ?? [])];
1431
+ const scopeUnparseable = scopes.unparseable.filter((row) => !(fileTierReplaced && row === "claude_managed_settings"));
1432
+ const unparseable = plugins ? [...scopeUnparseable, ...plugins.unparseable] : scopes.unparseable;
1433
+ const failures = plugins ? [...plugins.failures] : [];
700
1434
  // The EFFECTIVE server map first: a higher layer's same-named entry overrides (replacing it, or
701
1435
  // overlaying its fields), so a board entry disabled or re-pointed above is judged as such.
702
1436
  const effective = new Map();
@@ -712,9 +1446,12 @@ export function judgeRegistration(harness, ctx) {
712
1446
  const boardNames = new Set();
713
1447
  const boardRows = new Set();
714
1448
  let flagged = false;
1449
+ const disabledHere = harness === "claude" ? claudeDisabledServers(ctx) : new Set();
715
1450
  for (const { entry, row: scopeRow } of effective.values()) {
716
1451
  if (entry.disabled)
717
1452
  continue;
1453
+ if (disabledHere.has(entry.name) && !MANAGED_MCP_ROWS.has(scopeRow))
1454
+ continue;
718
1455
  const verdict = judgeMcpEntry(entry, ctx.fs);
719
1456
  if (!verdict.board)
720
1457
  continue;
@@ -735,23 +1472,64 @@ export function judgeRegistration(harness, ctx) {
735
1472
  else if (boardNames.size === 0) {
736
1473
  failures.push("no_registration");
737
1474
  }
738
- if (harness === "claude") {
1475
+ let hookCounts;
1476
+ let unjudgeable = false;
1477
+ let policyUnjudgeable = false;
1478
+ let policyCheckFailed = [];
1479
+ if (harness === "claude" && plugins) {
1480
+ // The EFFECTIVE hooks first (round-2 F027/F028): a file whose hooks the policy switches off
1481
+ // contributes none. Dropping a wake hook that way is a failure of its own, never a pass.
1482
+ const policy = plugins.hookPolicy;
1483
+ const isPluginFile = (file) => file.row === CLAUDE_PLUGIN_ROW || file.row === CLAUDE_FOREIGN_PLUGIN_ROW;
1484
+ const runs = (file) => isPluginFile(file)
1485
+ ? policy.plugins === "all" || (policy.plugins === "managed_only" && (policy.unjudgeable || policy.managedPluginIds.has(file.pluginId ?? "")))
1486
+ : policy.settings === "all" || (policy.settings === "managed_only" && file.row === "claude_managed_settings");
1487
+ const namesAWakeHook = (file) => Object.keys(file.contribution.hooks)
1488
+ .some((event) => file.contribution.hooks[event].some((hook) => isCommand(hook) && namesWakeHook(hook.command)));
1489
+ if (files.some((file) => !runs(file) && namesAWakeHook(file)))
1490
+ failures.push("hooks_disabled_by_policy");
1491
+ policyUnjudgeable = policy.unjudgeable || policy.probe.evidence.length > 0;
1492
+ policyCheckFailed = [...new Set(policy.probe.errors)].map((cause) => `managed_policy_check_failed:${cause}`);
1493
+ const hooksOf = (file, event) => runs(file) ? file.contribution.hooks[event] ?? [] : [];
1494
+ const wakeHooks = (event) => files.flatMap((file) => hooksOf(file, event).filter((hook) => isCommand(hook) && namesWakeHook(hook.command)));
1495
+ // A non-command handler on a wake event runs, but doctor cannot tell what it does (panel B2b);
1496
+ // nor can it tell what a settings or foreign wake hook filtered by a matcher fires on (F029).
1497
+ // Ours with a matcher is a stanza mismatch instead.
1498
+ unjudgeable = CLAUDE_REQUIRED_HOOK_EVENTS.some((event) => files.some((file) => hooksOf(file, event)
1499
+ .some((hook) => !isCommand(hook) || (hook.hasMatcher && file.row !== CLAUDE_PLUGIN_ROW && namesWakeHook(hook.command)))));
1500
+ hookCounts = { SessionStart: 0, FileChanged: 0 };
739
1501
  for (const event of CLAUDE_REQUIRED_HOOK_EVENTS) {
740
- const count = files.reduce((sum, file) => sum + (file.contribution.hooks[event] ?? []).filter((command) => command.includes(WAKE_HOOK_MARKER)).length, 0);
1502
+ const count = wakeHooks(event).length;
1503
+ hookCounts[event] = count;
741
1504
  if (count === 0)
742
1505
  failures.push(`missing_hook:${event}`);
743
1506
  if (count > 1)
744
1507
  failures.push(`duplicate_hook:${event}`);
745
1508
  }
1509
+ // Shape B: a FileChanged wake hook wakes an idle session only through asyncRewake.
1510
+ if (CLAUDE_WAKE_SHAPE === "B" && wakeHooks("FileChanged").some((hook) => !hook.asyncRewake))
1511
+ failures.push("hook_not_rewake");
746
1512
  }
1513
+ const extras = plugins
1514
+ ? { plugin: { ...plugins.summary, serverActive: boardRows.has(CLAUDE_PLUGIN_ROW) }, hookCounts, managedPolicy: plugins.hookPolicy.probe }
1515
+ : {};
747
1516
  // An absence is only evidence when every file was read: the missing entry may be in the file
748
- // that did not parse. A duplicate or the flag, seen in files that DID parse, stands.
749
- const standing = unparseable.length === 0
1517
+ // that did not parse, under the plugin path doctor refused to follow, or a handler doctor
1518
+ // cannot judge. A duplicate or the flag, seen in files that DID parse, stands.
1519
+ const complete = unparseable.length === 0 && !plugins?.untrusted && !unjudgeable && !policyUnjudgeable && policyCheckFailed.length === 0;
1520
+ const standing = complete
750
1521
  ? failures
751
1522
  : failures.filter((failure) => failure !== "no_registration" && !failure.startsWith("missing_hook:"));
752
1523
  if (standing.length > 0)
753
- return { status: "fail", reason: standing.join(",") };
754
- if (unparseable.length > 0)
755
- return { status: "undetermined", reason: `unparseable:${[...new Set(unparseable)].join("+")}` };
756
- return { status: "pass", reason: "registered" };
1524
+ return { status: "fail", reason: standing.join(","), ...extras };
1525
+ const unknowns = [
1526
+ ...(unparseable.length > 0 ? [`unparseable:${[...new Set(unparseable)].join("+")}`] : []),
1527
+ ...(plugins?.untrusted ? ["plugin_path_untrusted"] : []),
1528
+ ...(unjudgeable ? ["unjudgeable_hook"] : []),
1529
+ ...(policyUnjudgeable ? ["hook_policy_unjudgeable"] : []),
1530
+ ...policyCheckFailed,
1531
+ ];
1532
+ if (unknowns.length > 0)
1533
+ return { status: "undetermined", reason: unknowns.join(","), ...extras };
1534
+ return { status: "pass", reason: "registered", ...extras };
757
1535
  }