@consiliency/agent-board-cli 1.22.4 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,757 @@
1
+ // CREDPATH IF-0-CREDPATH-4 — where each harness registers the board, and how doctor reads it.
2
+ //
3
+ // `HARNESS_CONFIG_SCOPES` is the frozen scope table. The plan froze a first version and required
4
+ // SL-5 to re-check every row against the installed harness before building on it; the rows below
5
+ // are the re-checked table, and each amendment says what was measured and how (dev0, 2026-09-25):
6
+ //
7
+ // claude 2.1.282 The binary names `managed-mcp.json`, `managed-settings.d` and
8
+ // `CLAUDE_CONFIG_DIR`; the plan's row had none of them. Added: the managed MCP
9
+ // file, managed settings (`managedMcpServers` + `hooks`, file and `.d/*.json`
10
+ // drop-ins), and `CLAUDE_CONFIG_DIR` relocating `.claude.json` and
11
+ // `settings.json`. `~/.claude/settings.local.json` stays excluded: Claude Code
12
+ // does not read it at user scope (the v11 incident).
13
+ // codex 0.157.0 `codex mcp list` in a temp `CODEX_HOME` MEASURED a project layer the plan
14
+ // lacked: `<dir>/.codex/config.toml` for every dir from cwd up to the git root,
15
+ // each loaded only when the nearest `[projects."<path>"]` entry at or above it
16
+ // says `trust_level = "trusted"` (root trusted + sub untrusted → root only; no
17
+ // entry → not loaded).
18
+ // grok 0.1.220 Row kept as its README documents (`~/.grok/README.md:1873,1889`). The
19
+ // measurement is display-only: `grok mcp list` shows a user-scope server (the
20
+ // positive control) but not a project one, so it is not evidence either way.
21
+ // agy 1.2.11 `agy mcp add` / `disable` in a temp HOME wrote
22
+ // `{"mcpServers":{<name>:{command,args,disabled}}}`; an empty file is no entries.
23
+ // `~/.gemini/config/projects/*.json` holds only `id`/`name`: no project scope.
24
+ // opencode 1.18.32 `opencode debug config` MEASURED a walk the plan lacked: `opencode.json[c]` and
25
+ // `.opencode/opencode.json[c]` in every dir from cwd up to the git root are
26
+ // merged, and plugins are deduplicated BY NAME, ignoring the version (user
27
+ // `@0.1.1` + project bare → one entry). dev0's live entry is versioned.
28
+ // pi 0.87.1 Installed on dev0 (the brief said otherwise). Its `docs/packages.md`:
29
+ // `PI_CODING_AGENT_DIR` relocates `~/.pi/agent`; entries are strings or
30
+ // `{source}` objects; npm packages are identified by name WITHOUT the version,
31
+ // and a project entry replaces the personal one. dev0's live entry is
32
+ // `npm:@consiliency/agent-board-pi@0.3.1`.
33
+ //
34
+ // Consequence for "exactly once": a harness that deduplicates by name (claude, codex and grok by
35
+ // server name across layers; opencode and pi by package name) cannot load one server twice under
36
+ // one name, so duplicates are counted over DISTINCT names in the merged set. Two differently
37
+ // named board servers are two processes, and that is what fails.
38
+ //
39
+ // PRECEDENCE (code-review round 1, Astra 3). A same-named entry at a higher layer overrides the
40
+ // lower one, so doctor first builds each harness's EFFECTIVE server map and only then judges it:
41
+ // a board entry that a higher layer disables or re-points is not a registration. Each row carries
42
+ // `rank` (higher overrides) and, for a walked row, which end of the walk wins. Measured on dev0,
43
+ // 2026-09-26, in temp homes with real `git init` repositories:
44
+ // codex 0.157.1 `codex mcp list --json`: a trusted project layer overrides the user layer
45
+ // FIELD BY FIELD (`enabled = false` alone disables the user's server; `command`
46
+ // alone keeps the user's `args`), and the layer nearest cwd wins.
47
+ // opencode 1.18.32 `opencode debug config`: field-by-field too; user < every `opencode.json[c]`
48
+ // (nearest cwd wins) < every `.opencode/` file, where the FARTHEST (git root)
49
+ // wins — measured, and the opposite of the `opencode.json` walk.
50
+ // claude 2.1.283 `claude mcp get` with a temp `CLAUDE_CONFIG_DIR`: local (`projects[cwd]`)
51
+ // beats user (measured: it names the winning scope, not its fields). That the
52
+ // whole entry is REPLACED, not overlaid, is inferred from claude keeping each
53
+ // scope as its own map, not measured. project vs user could not be measured (an
54
+ // unapproved `.mcp.json` is not shown); the documented order local > project >
55
+ // user is used, and managed scopes are ranked above all.
56
+ // grok unmeasured (display-only, above): ranked like codex, which it mirrors.
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
60
+ // project entry is counted as loaded); opencode's `OPENCODE_CONFIG`/`OPENCODE_CONFIG_DIR` are not
61
+ // read; `managed-mcp.json`'s exclusive-control mode is not modelled; `claude mcp get` still showed
62
+ // a local entry carrying `disabled: true`, so doctor's reading of that field as "not launched" is
63
+ // stricter than claude.
64
+ //
65
+ // Config text is DATA. Every object the readers build has a null prototype and every lookup is an
66
+ // own-property lookup; a `__proto__`, `constructor` or `prototype` key makes the file unparseable
67
+ // (round 1, Astra 1: `[__proto__.mcp_servers.x]` used to write onto `Object.prototype`).
68
+ import * as nodeFs from "node:fs";
69
+ import { dirname, isAbsolute, join, resolve } from "node:path";
70
+ export const BOARD_MCP_MARKER = "agent-board-mcp";
71
+ export const METADATA_ONLY_FLAG = "--metadata-only";
72
+ export const OPENCODE_PLUGIN_PACKAGE = "@consiliency/agent-board-opencode";
73
+ export const PI_PACKAGE = "npm:@consiliency/agent-board-pi";
74
+ /** Claude Code events that each need exactly one `wake-hook.sh` hook (`docs/local-wake-signal.md`). */
75
+ export const CLAUDE_REQUIRED_HOOK_EVENTS = Object.freeze(["SessionStart", "FileChanged"]);
76
+ export const WAKE_HOOK_MARKER = "wake-hook.sh";
77
+ const WRAPPER_MAX_BYTES = 64 * 1024;
78
+ const missing = (error) => {
79
+ const code = error.code;
80
+ return code === "ENOENT" || code === "ENOTDIR";
81
+ };
82
+ export const nodeRegistrationFs = {
83
+ readFile(path) {
84
+ try {
85
+ return nodeFs.readFileSync(path, "utf8");
86
+ }
87
+ catch (error) {
88
+ if (missing(error))
89
+ return null;
90
+ throw error;
91
+ }
92
+ },
93
+ readdir(path) {
94
+ try {
95
+ return nodeFs.readdirSync(path).sort();
96
+ }
97
+ catch (error) {
98
+ if (missing(error))
99
+ return null;
100
+ throw error;
101
+ }
102
+ },
103
+ lstat(path) {
104
+ try {
105
+ const stats = nodeFs.lstatSync(path);
106
+ return { uid: stats.uid, size: stats.size, isFile: stats.isFile() };
107
+ }
108
+ catch (error) {
109
+ if (missing(error))
110
+ return null;
111
+ throw error;
112
+ }
113
+ },
114
+ exists(path) {
115
+ try {
116
+ nodeFs.lstatSync(path);
117
+ return true;
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ },
123
+ getuid: () => process.getuid?.() ?? -1,
124
+ };
125
+ // ---------------------------------------------------------------------------------------------
126
+ // Readers
127
+ // ---------------------------------------------------------------------------------------------
128
+ export class UnparseableConfigError extends Error {
129
+ constructor(message) {
130
+ super(message);
131
+ this.name = "UnparseableConfigError";
132
+ }
133
+ }
134
+ /** Keys that name a prototype slot rather than data: a file carrying one is refused. */
135
+ export const FORBIDDEN_CONFIG_KEYS = new Set(["__proto__", "constructor", "prototype"]);
136
+ /** A prototype-free object: an inherited property cannot answer a lookup on it. */
137
+ const dict = () => Object.create(null);
138
+ /**
139
+ * A prototype-free copy of the sources' OWN enumerable properties, later sources winning. Not
140
+ * `Object.assign`, which the #403 code-provenance class check (fleet_diagnosability A10) reserves
141
+ * because it can copy an error's `code`.
142
+ */
143
+ function overlay(...sources) {
144
+ const out = dict();
145
+ for (const source of sources)
146
+ for (const key of Object.keys(source))
147
+ out[key] = source[key];
148
+ return out;
149
+ }
150
+ /** An OWN property of `value`, else undefined: nothing is read through a prototype. */
151
+ export function own(value, key) {
152
+ return value !== null && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key)
153
+ ? value[key]
154
+ : undefined;
155
+ }
156
+ /** JSON.parse reviver: refuse prototype keys and rebuild every object without a prototype. */
157
+ function prototypeFree(key, value) {
158
+ if (FORBIDDEN_CONFIG_KEYS.has(key))
159
+ throw new UnparseableConfigError(`refused key ${key}`);
160
+ if (value === null || typeof value !== "object" || Array.isArray(value))
161
+ return value;
162
+ return overlay(value);
163
+ }
164
+ function parseJson(text) {
165
+ try {
166
+ return JSON.parse(text, prototypeFree);
167
+ }
168
+ catch (error) {
169
+ if (error instanceof UnparseableConfigError)
170
+ throw error;
171
+ throw new UnparseableConfigError(error.message);
172
+ }
173
+ }
174
+ /** JSONC: `//` and `/* *\/` comments and trailing commas removed outside strings, then JSON. */
175
+ export function parseJsonc(text) {
176
+ let out = "";
177
+ let index = 0;
178
+ while (index < text.length) {
179
+ const char = text[index];
180
+ if (char === "\"") {
181
+ let end = index + 1;
182
+ while (end < text.length && text[end] !== "\"")
183
+ end += text[end] === "\\" ? 2 : 1;
184
+ out += text.slice(index, end + 1);
185
+ index = end + 1;
186
+ }
187
+ else if (char === "/" && text[index + 1] === "/") {
188
+ while (index < text.length && text[index] !== "\n")
189
+ index += 1;
190
+ }
191
+ else if (char === "/" && text[index + 1] === "*") {
192
+ const end = text.indexOf("*/", index + 2);
193
+ if (end < 0)
194
+ throw new UnparseableConfigError("unterminated block comment");
195
+ index = end + 2;
196
+ }
197
+ else {
198
+ out += char;
199
+ index += 1;
200
+ }
201
+ }
202
+ return parseJson(out.replace(/,(\s*[}\]])/g, "$1"));
203
+ }
204
+ function parseJsonDocument(text, allowEmpty) {
205
+ if (allowEmpty && text.trim() === "")
206
+ return dict();
207
+ return parseJson(text);
208
+ }
209
+ /**
210
+ * A restricted TOML reader: enough of the grammar to read `[mcp_servers.*]` and `[projects.*]`
211
+ * tables without a new dependency, and fail-closed on anything outside it. It handles bare and
212
+ * quoted dotted keys, `[table]` and `[[array.table]]` headers, basic/literal (and multi-line)
213
+ * strings, arrays over several lines, inline tables, and bare scalars. Sub-tables nest, so
214
+ * `[mcp_servers.x.env]` is `x`'s `env`, never a server named `x.env`.
215
+ */
216
+ export function parseRestrictedToml(text) {
217
+ const root = dict();
218
+ let table = root;
219
+ let pos = 0;
220
+ const fail = (what) => {
221
+ const line = text.slice(0, pos).split("\n").length;
222
+ throw new UnparseableConfigError(`TOML line ${line}: ${what}`);
223
+ };
224
+ const skipInline = () => {
225
+ while (pos < text.length && (text[pos] === " " || text[pos] === "\t"))
226
+ pos += 1;
227
+ if (text[pos] === "#")
228
+ while (pos < text.length && text[pos] !== "\n")
229
+ pos += 1;
230
+ };
231
+ const skipAll = () => {
232
+ for (;;) {
233
+ skipInline();
234
+ if (text[pos] === "\n" || text[pos] === "\r")
235
+ pos += 1;
236
+ else
237
+ return;
238
+ }
239
+ };
240
+ const readBasic = () => {
241
+ if (text.startsWith("\"\"\"", pos)) {
242
+ const end = text.indexOf("\"\"\"", pos + 3);
243
+ if (end < 0)
244
+ fail("unterminated multi-line string");
245
+ const raw = text.slice(pos + 3, end).replace(/^\r?\n/, "");
246
+ pos = end + 3;
247
+ return raw;
248
+ }
249
+ let end = pos + 1;
250
+ while (end < text.length && text[end] !== "\"" && text[end] !== "\n")
251
+ end += text[end] === "\\" ? 2 : 1;
252
+ if (text[end] !== "\"")
253
+ fail("unterminated string");
254
+ const raw = text.slice(pos, end + 1);
255
+ pos = end + 1;
256
+ try {
257
+ return JSON.parse(raw.replace(/\\U([0-9A-Fa-f]{8})/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16))));
258
+ }
259
+ catch {
260
+ return fail("invalid escape in string");
261
+ }
262
+ };
263
+ const readLiteral = () => {
264
+ if (text.startsWith("'''", pos)) {
265
+ const end = text.indexOf("'''", pos + 3);
266
+ if (end < 0)
267
+ fail("unterminated multi-line literal string");
268
+ const raw = text.slice(pos + 3, end).replace(/^\r?\n/, "");
269
+ pos = end + 3;
270
+ return raw;
271
+ }
272
+ const end = text.indexOf("'", pos + 1);
273
+ if (end < 0 || text.slice(pos, end).includes("\n"))
274
+ fail("unterminated literal string");
275
+ const raw = text.slice(pos + 1, end);
276
+ pos = end + 1;
277
+ return raw;
278
+ };
279
+ const readKeyPart = () => {
280
+ skipInline();
281
+ const part = text[pos] === "\"" ? readBasic() : text[pos] === "'" ? readLiteral() : (() => {
282
+ const match = /^[A-Za-z0-9_-]+/.exec(text.slice(pos));
283
+ if (!match)
284
+ return fail("expected a key");
285
+ pos += match[0].length;
286
+ return match[0];
287
+ })();
288
+ if (FORBIDDEN_CONFIG_KEYS.has(part))
289
+ fail(`refused key ${part}`);
290
+ return part;
291
+ };
292
+ const readKey = () => {
293
+ const parts = [readKeyPart()];
294
+ for (;;) {
295
+ skipInline();
296
+ if (text[pos] !== ".")
297
+ return parts;
298
+ pos += 1;
299
+ parts.push(readKeyPart());
300
+ }
301
+ };
302
+ const readValue = () => {
303
+ skipInline();
304
+ const char = text[pos];
305
+ if (char === "\"")
306
+ return readBasic();
307
+ if (char === "'")
308
+ return readLiteral();
309
+ if (char === "[") {
310
+ pos += 1;
311
+ const items = [];
312
+ for (;;) {
313
+ skipAll();
314
+ if (text[pos] === "]") {
315
+ pos += 1;
316
+ return items;
317
+ }
318
+ items.push(readValue());
319
+ skipAll();
320
+ if (text[pos] === ",")
321
+ pos += 1;
322
+ else if (text[pos] !== "]")
323
+ fail("expected , or ] in array");
324
+ }
325
+ }
326
+ if (char === "{") {
327
+ pos += 1;
328
+ const inline = dict();
329
+ skipInline();
330
+ if (text[pos] === "}") {
331
+ pos += 1;
332
+ return inline;
333
+ }
334
+ for (;;) {
335
+ const key = readKey();
336
+ skipInline();
337
+ if (text[pos] !== "=")
338
+ fail("expected = in inline table");
339
+ pos += 1;
340
+ assign(inline, key, readValue());
341
+ skipInline();
342
+ if (text[pos] === ",")
343
+ pos += 1;
344
+ else if (text[pos] === "}") {
345
+ pos += 1;
346
+ return inline;
347
+ }
348
+ else
349
+ fail("expected , or } in inline table");
350
+ }
351
+ }
352
+ const match = /^[A-Za-z0-9_:.+\-]+(?:[ T][0-9:.+\-Z]+)?/.exec(text.slice(pos));
353
+ if (!match)
354
+ return fail("unsupported value");
355
+ pos += match[0].length;
356
+ const bare = match[0];
357
+ if (bare === "true")
358
+ return true;
359
+ if (bare === "false")
360
+ return false;
361
+ if (/^[+-]?[0-9_]+$/.test(bare))
362
+ return Number(bare.replace(/_/g, ""));
363
+ if (/^[+-]?[0-9_]+(\.[0-9_]+)?([eE][+-]?[0-9]+)?$/.test(bare))
364
+ return Number(bare.replace(/_/g, ""));
365
+ if (/^\d{4}-\d{2}-\d{2}/.test(bare) || /^\d{2}:\d{2}/.test(bare) || /^[+-]?(inf|nan)$/.test(bare))
366
+ return bare;
367
+ return fail("unsupported bare value");
368
+ };
369
+ const descend = (from, key, arrayTable) => {
370
+ let node = from;
371
+ key.forEach((part, index) => {
372
+ const last = index === key.length - 1;
373
+ let next = own(node, part);
374
+ if (last && arrayTable) {
375
+ if (next === undefined)
376
+ node[part] = next = [];
377
+ if (!Array.isArray(next))
378
+ fail(`${part} is not an array of tables`);
379
+ const entry = dict();
380
+ next.push(entry);
381
+ node = entry;
382
+ return;
383
+ }
384
+ if (Array.isArray(next))
385
+ next = next[next.length - 1];
386
+ if (next === undefined)
387
+ node[part] = next = dict();
388
+ if (!next || typeof next !== "object" || Array.isArray(next))
389
+ fail(`${part} is not a table`);
390
+ node = next;
391
+ });
392
+ return node;
393
+ };
394
+ const assign = (into, key, value) => {
395
+ const parent = descend(into, key.slice(0, -1), false);
396
+ const last = key[key.length - 1];
397
+ if (Object.prototype.hasOwnProperty.call(parent, last))
398
+ fail(`duplicate key ${last}`);
399
+ parent[last] = value;
400
+ };
401
+ for (;;) {
402
+ skipAll();
403
+ if (pos >= text.length)
404
+ return root;
405
+ if (text[pos] === "[") {
406
+ const arrayTable = text[pos + 1] === "[";
407
+ pos += arrayTable ? 2 : 1;
408
+ const key = readKey();
409
+ skipInline();
410
+ if (text[pos] !== "]" || (arrayTable && text[pos + 1] !== "]"))
411
+ fail("unterminated table header");
412
+ pos += arrayTable ? 2 : 1;
413
+ table = descend(root, key, arrayTable);
414
+ }
415
+ else {
416
+ const key = readKey();
417
+ skipInline();
418
+ if (text[pos] !== "=")
419
+ fail("expected =");
420
+ pos += 1;
421
+ assign(table, key, readValue());
422
+ }
423
+ skipInline();
424
+ if (pos < text.length && text[pos] !== "\n" && text[pos] !== "\r")
425
+ fail("trailing characters");
426
+ }
427
+ }
428
+ const empty = () => ({ mcp: [], hooks: Object.create(null), packages: [] });
429
+ const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
430
+ /** Judge-relevant fields of one entry, from its own fields only. */
431
+ export function mcpEntry(name, fields, shape) {
432
+ let command = null;
433
+ let args = [];
434
+ const rawCommand = own(fields, "command");
435
+ if (shape === "opencode") {
436
+ if (Array.isArray(rawCommand)) {
437
+ command = typeof rawCommand[0] === "string" ? rawCommand[0] : null;
438
+ args = rawCommand.slice(1).filter((item) => typeof item === "string");
439
+ }
440
+ }
441
+ else {
442
+ command = typeof rawCommand === "string" ? rawCommand : null;
443
+ const rawArgs = own(fields, "args");
444
+ args = Array.isArray(rawArgs) ? rawArgs.filter((item) => typeof item === "string") : [];
445
+ }
446
+ const disabled = own(fields, "disabled") === true || own(fields, "enabled") === false;
447
+ return { name, command, args, disabled, fields, shape };
448
+ }
449
+ function mcpFromMap(map, shape) {
450
+ if (map === undefined || map === null)
451
+ return [];
452
+ if (!isObject(map))
453
+ throw new UnparseableConfigError("MCP server map is not an object");
454
+ return Object.keys(map).map((name) => {
455
+ const raw = own(map, name);
456
+ if (!isObject(raw))
457
+ throw new UnparseableConfigError(`MCP server ${name} is not an object`);
458
+ return mcpEntry(name, overlay(raw), shape);
459
+ });
460
+ }
461
+ function hooksFrom(settings) {
462
+ const out = Object.create(null);
463
+ const hooksMap = own(settings, "hooks");
464
+ if (!isObject(settings) || hooksMap === undefined)
465
+ return out;
466
+ if (!isObject(hooksMap))
467
+ throw new UnparseableConfigError("hooks is not an object");
468
+ for (const event of Object.keys(hooksMap)) {
469
+ const groups = own(hooksMap, event);
470
+ if (!Array.isArray(groups))
471
+ throw new UnparseableConfigError(`hooks.${event} is not an array`);
472
+ for (const group of groups) {
473
+ const hooks = isObject(group) ? own(group, "hooks") : undefined;
474
+ if (!Array.isArray(hooks))
475
+ throw new UnparseableConfigError(`hooks.${event} has a group with no hooks array`);
476
+ for (const hook of hooks) {
477
+ const command = own(hook, "command");
478
+ if (isObject(hook) && typeof command === "string")
479
+ (out[event] ??= []).push(command);
480
+ }
481
+ }
482
+ }
483
+ return out;
484
+ }
485
+ function packageSpecs(list, field) {
486
+ if (list === undefined)
487
+ return [];
488
+ if (!Array.isArray(list))
489
+ throw new UnparseableConfigError(`${field} is not an array`);
490
+ return list.flatMap((item) => {
491
+ if (typeof item === "string")
492
+ return [item];
493
+ const source = own(item, "source");
494
+ if (isObject(item) && typeof source === "string")
495
+ return [source];
496
+ if (Array.isArray(item) && typeof item[0] === "string")
497
+ return [item[0]];
498
+ return [];
499
+ });
500
+ }
501
+ /** Directories from `cwd` up to its git root (inclusive); just `cwd` outside a repository. */
502
+ export function walkToGitRoot(cwd, fs) {
503
+ const dirs = [];
504
+ let current = resolve(cwd);
505
+ for (;;) {
506
+ dirs.push(current);
507
+ if (fs.exists(join(current, ".git")))
508
+ return dirs;
509
+ const parent = dirname(current);
510
+ if (parent === current)
511
+ return [resolve(cwd)];
512
+ current = parent;
513
+ }
514
+ }
515
+ const claudeConfigDir = (ctx) => {
516
+ const value = ctx.env.CLAUDE_CONFIG_DIR;
517
+ return value && isAbsolute(value) ? value : null;
518
+ };
519
+ const claudeJsonPath = (ctx) => join(claudeConfigDir(ctx) ?? ctx.home, ".claude.json");
520
+ const claudeUserDir = (ctx) => claudeConfigDir(ctx) ?? join(ctx.home, ".claude");
521
+ const managedSettingsPaths = (ctx) => [
522
+ join(ctx.claudeManagedDir, "managed-settings.json"),
523
+ ...(ctx.fs.readdir(join(ctx.claudeManagedDir, "managed-settings.d")) ?? [])
524
+ .filter((name) => name.endsWith(".json"))
525
+ .map((name) => join(ctx.claudeManagedDir, "managed-settings.d", name)),
526
+ ];
527
+ const codexHome = (ctx) => {
528
+ const value = ctx.env.CODEX_HOME;
529
+ return value && isAbsolute(value) ? value : join(ctx.home, ".codex");
530
+ };
531
+ const xdgConfig = (ctx) => {
532
+ const value = ctx.env.XDG_CONFIG_HOME;
533
+ return value && value.trim() !== "" && isAbsolute(value) ? value : join(ctx.home, ".config");
534
+ };
535
+ const piAgentDir = (ctx) => {
536
+ const value = ctx.env.PI_CODING_AGENT_DIR;
537
+ return value && isAbsolute(value) ? value : join(ctx.home, ".pi", "agent");
538
+ };
539
+ /** codex loads `<dir>/.codex/config.toml` only when the nearest `[projects]` entry at or above `<dir>` is trusted. */
540
+ function codexLayerTrusted(path, ctx) {
541
+ const text = ctx.fs.readFile(join(codexHome(ctx), "config.toml"));
542
+ if (text === null)
543
+ return false;
544
+ const projects = own(parseRestrictedToml(text), "projects");
545
+ if (!isObject(projects))
546
+ return false;
547
+ let dir = dirname(dirname(path));
548
+ for (;;) {
549
+ const entry = own(projects, dir);
550
+ if (isObject(entry))
551
+ return own(entry, "trust_level") === "trusted";
552
+ const parent = dirname(dir);
553
+ if (parent === dir)
554
+ return false;
555
+ dir = parent;
556
+ }
557
+ }
558
+ const row = (definition) => Object.freeze(definition);
559
+ /** The frozen, re-checked scope table (see the header for each amendment and its evidence). */
560
+ export const HARNESS_CONFIG_SCOPES = Object.freeze({
561
+ claude: Object.freeze([
562
+ row({ name: "claude_user_mcp", parser: "json", rank: 10, paths: (ctx) => [claudeJsonPath(ctx)],
563
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "claude") }) }),
564
+ row({ name: "claude_local_mcp", parser: "json", rank: 30, paths: (ctx) => [claudeJsonPath(ctx)],
565
+ extract: (doc, ctx) => {
566
+ const project = own(own(doc, "projects"), resolve(ctx.cwd));
567
+ return { ...empty(), mcp: mcpFromMap(own(project, "mcpServers"), "claude") };
568
+ } }),
569
+ row({ name: "claude_project_mcp", parser: "json", rank: 20, paths: (ctx) => [join(ctx.cwd, ".mcp.json")],
570
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "claude") }) }),
571
+ row({ name: "claude_managed_mcp", parser: "json", rank: 40, paths: (ctx) => [join(ctx.claudeManagedDir, "managed-mcp.json")],
572
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "claude") }) }),
573
+ row({ name: "claude_managed_settings", parser: "json", rank: 40, paths: managedSettingsPaths,
574
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "managedMcpServers"), "claude"), hooks: hooksFrom(doc) }) }),
575
+ row({ name: "claude_user_settings", parser: "json", rank: 10, paths: (ctx) => [join(claudeUserDir(ctx), "settings.json")],
576
+ extract: (doc) => ({ ...empty(), hooks: hooksFrom(doc) }) }),
577
+ row({ name: "claude_project_settings", parser: "json", rank: 20, paths: (ctx) => [join(ctx.cwd, ".claude", "settings.json")],
578
+ extract: (doc) => ({ ...empty(), hooks: hooksFrom(doc) }) }),
579
+ row({ name: "claude_project_local_settings", parser: "json", rank: 30, paths: (ctx) => [join(ctx.cwd, ".claude", "settings.local.json")],
580
+ extract: (doc) => ({ ...empty(), hooks: hooksFrom(doc) }) }),
581
+ ]),
582
+ codex: Object.freeze([
583
+ row({ name: "codex_user", parser: "toml", rank: 10, paths: (ctx) => [join(codexHome(ctx), "config.toml")],
584
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcp_servers"), "toml") }) }),
585
+ row({ name: "codex_project", parser: "toml", rank: 20, walkWins: "nearest",
586
+ paths: (ctx) => walkToGitRoot(ctx.cwd, ctx.fs).map((dir) => join(dir, ".codex", "config.toml"))
587
+ .filter((path) => path !== join(codexHome(ctx), "config.toml")),
588
+ loaded: codexLayerTrusted,
589
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcp_servers"), "toml") }) }),
590
+ ]),
591
+ grok: Object.freeze([
592
+ row({ name: "grok_user", parser: "toml", rank: 10, paths: (ctx) => [join(ctx.home, ".grok", "config.toml")],
593
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcp_servers"), "toml") }) }),
594
+ row({ name: "grok_project", parser: "toml", rank: 20, walkWins: "nearest",
595
+ paths: (ctx) => walkToGitRoot(ctx.cwd, ctx.fs).map((dir) => join(dir, ".grok", "config.toml"))
596
+ .filter((path) => path !== join(ctx.home, ".grok", "config.toml")),
597
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcp_servers"), "toml") }) }),
598
+ ]),
599
+ agy: Object.freeze([
600
+ row({ name: "agy_user", parser: "json_or_empty", rank: 10, paths: (ctx) => [join(ctx.home, ".gemini", "config", "mcp_config.json")],
601
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "agy") }) }),
602
+ ]),
603
+ opencode: Object.freeze([
604
+ row({ name: "opencode_user", parser: "jsonc", rank: 10,
605
+ paths: (ctx) => ["opencode.json", "opencode.jsonc"].map((name) => join(xdgConfig(ctx), "opencode", name)),
606
+ extract: (doc) => opencodeContribution(doc) }),
607
+ // Nearest directory wins, but INSIDE one directory .json is applied before .jsonc: measured on
608
+ // opencode 1.18.32 (`opencode debug config`, dev0, 2026-09-26) — .jsonc won in both orders. So the directories are reversed here and the pair keeps its order: a flat
609
+ // `walkWins: "nearest"` reverse would also swap the two files (Q-6).
610
+ row({ name: "opencode_project", parser: "jsonc", rank: 20,
611
+ paths: (ctx) => [...walkToGitRoot(ctx.cwd, ctx.fs)].reverse().flatMap((dir) => ["opencode.json", "opencode.jsonc"].map((name) => join(dir, name))),
612
+ extract: (doc) => opencodeContribution(doc) }),
613
+ row({ name: "opencode_project_dotdir", parser: "jsonc", rank: 30, walkWins: "farthest",
614
+ paths: (ctx) => walkToGitRoot(ctx.cwd, ctx.fs).flatMap((dir) => ["opencode.json", "opencode.jsonc"].map((name) => join(dir, ".opencode", name))),
615
+ extract: (doc) => opencodeContribution(doc) }),
616
+ ]),
617
+ pi: Object.freeze([
618
+ row({ name: "pi_user", parser: "json", rank: 10, paths: (ctx) => [join(piAgentDir(ctx), "settings.json")],
619
+ extract: (doc) => ({ ...empty(), packages: packageSpecs(own(doc, "packages"), "packages") }) }),
620
+ row({ name: "pi_project", parser: "json", rank: 20, paths: (ctx) => [join(ctx.cwd, ".pi", "settings.json")],
621
+ extract: (doc) => ({ ...empty(), packages: packageSpecs(own(doc, "packages"), "packages") }) }),
622
+ ]),
623
+ });
624
+ /**
625
+ * How a harness combines a same-named entry across its layers (see the header): codex and
626
+ * opencode overlay the higher layer's fields (measured); claude replaces the whole entry
627
+ * (inferred); grok is assumed to behave like codex (unmeasured); agy and pi have one layer, so
628
+ * their value is never used.
629
+ */
630
+ export const MCP_LAYER_MERGE = Object.freeze({
631
+ claude: "replace", codex: "fields", grok: "fields", agy: "fields", opencode: "fields", pi: "fields",
632
+ });
633
+ function opencodeContribution(doc) {
634
+ if (!isObject(doc))
635
+ throw new UnparseableConfigError("opencode config is not an object");
636
+ return { ...empty(), mcp: mcpFromMap(own(doc, "mcp"), "opencode"), packages: packageSpecs(own(doc, "plugin"), "plugin") };
637
+ }
638
+ // ---------------------------------------------------------------------------------------------
639
+ // Judgement
640
+ // ---------------------------------------------------------------------------------------------
641
+ /** A package spec's identity without its version: `@scope/name@1.2.3` → `@scope/name`. */
642
+ export function packageIdentity(spec) {
643
+ const at = spec.lastIndexOf("@");
644
+ const scopeEnd = spec.indexOf("/");
645
+ return at > 0 && at > scopeEnd && spec[at - 1] !== ":" ? spec.slice(0, at) : spec;
646
+ }
647
+ /** A board MCP entry names `agent-board-mcp` in its command/args, or through ONE user-owned wrapper file. */
648
+ export function judgeMcpEntry(entry, fs) {
649
+ const words = [entry.command ?? "", ...entry.args];
650
+ const flagged = words.some((word) => word === METADATA_ONLY_FLAG || word.startsWith(`${METADATA_ONLY_FLAG}=`));
651
+ if (words.some((word) => word.includes(BOARD_MCP_MARKER)))
652
+ return { board: true, flagged };
653
+ if (entry.command && isAbsolute(entry.command)) {
654
+ const stats = fs.lstat(entry.command);
655
+ if (stats && stats.isFile && stats.uid === fs.getuid() && stats.size < WRAPPER_MAX_BYTES) {
656
+ const text = fs.readFile(entry.command) ?? "";
657
+ if (text.includes(BOARD_MCP_MARKER))
658
+ return { board: true, flagged: flagged || text.includes(METADATA_ONLY_FLAG) };
659
+ }
660
+ }
661
+ return { board: false, flagged: false };
662
+ }
663
+ /**
664
+ * Read every scope row of `harness` for `cwd`. A file that fails to parse is reported, not guessed.
665
+ * `files` is in ascending precedence: a later file's same-named entry overrides an earlier one's.
666
+ */
667
+ export function readScopes(harness, ctx) {
668
+ const files = [];
669
+ const unparseable = [];
670
+ const rows = [...HARNESS_CONFIG_SCOPES[harness]].sort((a, b) => a.rank - b.rank);
671
+ for (const scope of rows) {
672
+ // `paths` is in application order (the last file wins), except a walked row with `walkWins`,
673
+ // whose paths come cwd first and are reversed here when the nearest directory must win.
674
+ const paths = scope.paths(ctx);
675
+ for (const path of scope.walkWins === "nearest" ? [...paths].reverse() : paths) {
676
+ const text = ctx.fs.readFile(path);
677
+ if (text === null)
678
+ continue;
679
+ try {
680
+ if (scope.loaded && !scope.loaded(path, ctx))
681
+ continue;
682
+ const document = scope.parser === "toml" ? parseRestrictedToml(text)
683
+ : scope.parser === "jsonc" ? parseJsonc(text)
684
+ : parseJsonDocument(text, scope.parser === "json_or_empty");
685
+ files.push({ row: scope.name, path, contribution: scope.extract(document, ctx) });
686
+ }
687
+ catch (error) {
688
+ if (!(error instanceof UnparseableConfigError))
689
+ throw error;
690
+ unparseable.push(scope.name);
691
+ }
692
+ }
693
+ }
694
+ return { files, unparseable };
695
+ }
696
+ /** IF-0-CREDPATH-4 registration, for one working directory. */
697
+ export function judgeRegistration(harness, ctx) {
698
+ const { files, unparseable } = readScopes(harness, ctx);
699
+ const failures = [];
700
+ // The EFFECTIVE server map first: a higher layer's same-named entry overrides (replacing it, or
701
+ // overlaying its fields), so a board entry disabled or re-pointed above is judged as such.
702
+ const effective = new Map();
703
+ for (const file of files) {
704
+ for (const entry of file.contribution.mcp) {
705
+ const lower = effective.get(entry.name)?.entry;
706
+ const merged = lower && MCP_LAYER_MERGE[harness] === "fields"
707
+ ? mcpEntry(entry.name, overlay(lower.fields, entry.fields), entry.shape)
708
+ : entry;
709
+ effective.set(entry.name, { entry: merged, row: file.row });
710
+ }
711
+ }
712
+ const boardNames = new Set();
713
+ const boardRows = new Set();
714
+ let flagged = false;
715
+ for (const { entry, row: scopeRow } of effective.values()) {
716
+ if (entry.disabled)
717
+ continue;
718
+ const verdict = judgeMcpEntry(entry, ctx.fs);
719
+ if (!verdict.board)
720
+ continue;
721
+ boardNames.add(entry.name);
722
+ boardRows.add(scopeRow);
723
+ flagged ||= verdict.flagged;
724
+ }
725
+ if (flagged)
726
+ failures.push("metadata_only_flag");
727
+ if (boardNames.size > 1)
728
+ failures.push(`duplicate_registration:${[...boardRows].sort().join("+")}`);
729
+ if (harness === "opencode" || harness === "pi") {
730
+ const wanted = harness === "opencode" ? OPENCODE_PLUGIN_PACKAGE : PI_PACKAGE;
731
+ const present = files.some((file) => file.contribution.packages.some((spec) => packageIdentity(spec) === wanted));
732
+ if (!present)
733
+ failures.push("no_registration");
734
+ }
735
+ else if (boardNames.size === 0) {
736
+ failures.push("no_registration");
737
+ }
738
+ if (harness === "claude") {
739
+ 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);
741
+ if (count === 0)
742
+ failures.push(`missing_hook:${event}`);
743
+ if (count > 1)
744
+ failures.push(`duplicate_hook:${event}`);
745
+ }
746
+ }
747
+ // 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
750
+ ? failures
751
+ : failures.filter((failure) => failure !== "no_registration" && !failure.startsWith("missing_hook:"));
752
+ 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" };
757
+ }