buddy-agent-harness 0.7.0 → 0.9.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.
@@ -1,2122 +0,0 @@
1
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
2
- import { cli, command, exitCodes, z } from "clibuilder";
3
- import { homedir, platform } from "node:os";
4
- import { dirname, join, posix, relative, resolve, sep } from "node:path";
5
- import { encode } from "@toon-format/toon";
6
- import { parse } from "smol-toml";
7
- import { execFileSync } from "node:child_process";
8
- //#region src/is-record/is-record.ts
9
- /**
10
- * Whether a value is a record: an object with named keys, and not an array.
11
- *
12
- * Everything this package reads is a file someone else wrote — a golden MCP set, a harness config,
13
- * a projection record — so every parse lands on `unknown` and every reader narrows it here before
14
- * asking for a key. Four modules had their own copy of the same three clauses; a predicate that
15
- * decides what counts as a parsed object is one fact, and a copy of it that drifts changes what a
16
- * file is read to say.
17
- */
18
- function isRecord(value) {
19
- return typeof value === "object" && value !== null && !Array.isArray(value);
20
- }
21
- //#endregion
22
- //#region src/command-output/command-output.ts
23
- const formats = [
24
- "toon",
25
- "json",
26
- "text"
27
- ];
28
- /** Rejects anything but the supported formats so an unknown value never falls back silently. */
29
- function parseFormat(value) {
30
- if (!formats.includes(value)) throw new Error("--format must be toon, json, or text.");
31
- return value;
32
- }
33
- /** The single stdout boundary: internal logic stays on plain objects, encoding happens here. */
34
- function writeResult(value, format) {
35
- process.stdout.write(`${encodeResult(value, format)}\n`);
36
- }
37
- function encodeResult(value, format) {
38
- if (format === "json") return JSON.stringify(value);
39
- return format === "text" ? renderText(value) : encode(value);
40
- }
41
- function cell(value) {
42
- return value === void 0 ? "" : String(value);
43
- }
44
- /** A header row plus one row per record, every column padded to its widest cell. */
45
- function table(rows) {
46
- const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
47
- const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)));
48
- const line = (values) => ` ${values.map((value, index) => value.padEnd(widths[index])).join(" ")}`.trimEnd();
49
- return [line(columns), ...rows.map((row) => line(columns.map((column) => cell(row[column]))))];
50
- }
51
- /**
52
- * The same result rendered for a person rather than for an agent: scalars as `key: value`, records
53
- * as an aligned table, and everything else as a bulleted list. TOON stays the default because it is
54
- * what an agent parses; this is for reading over someone's shoulder.
55
- */
56
- function renderText(value) {
57
- const blocks = Object.entries(value).map(([key, item]) => {
58
- if (!Array.isArray(item)) return [`${key}: ${isRecord(item) ? JSON.stringify(item) : String(item)}`];
59
- if (!item.length) return [`${key}: (none)`];
60
- const body = item.every(isRecord) ? table(item) : item.map((entry) => ` - ${String(entry)}`);
61
- return [`${key}:`, ...body];
62
- });
63
- return blocks.flatMap((block, index) => {
64
- const previous = blocks[index - 1];
65
- return previous && (previous.length > 1 || block.length > 1) ? ["", ...block] : block;
66
- }).join("\n");
67
- }
68
- /** AXI §10: the executable's absolute path, with the user's home directory collapsed to `~`. */
69
- function binPath(home, executable) {
70
- if (!executable) return "buddy-agent-harness";
71
- return home && executable.startsWith(home + sep) ? `~${executable.slice(home.length)}` : executable;
72
- }
73
- //#endregion
74
- //#region src/diagnose-bridges/directory-files.ts
75
- /**
76
- * Every file below `directory`, as paths relative to it, POSIX-separated and sorted. A directory
77
- * that does not exist reads as empty: `doctor` never creates one to look at it.
78
- */
79
- function filesUnder(directory) {
80
- try {
81
- return readdirSync(directory, {
82
- recursive: true,
83
- withFileTypes: true
84
- }).filter((entry) => entry.isFile()).map((entry) => relative(directory, join(entry.parentPath, entry.name)).split(sep).join(posix.sep)).sort();
85
- } catch {
86
- return [];
87
- }
88
- }
89
- //#endregion
90
- //#region src/harness-registry/harness-registry.ts
91
- /**
92
- * The two scopes disagree in general, and a single answer per harness could only record one of them:
93
- * Copilot CLI is `.github/skills` in a repository and `~/.copilot` for the user. Claude Code is the
94
- * only harness that needs a skills projection, and it needs one at both scopes. Codex, Cursor,
95
- * Copilot CLI, Gemini CLI, and Devin Desktop read `.agents/skills` themselves and are never
96
- * projected into.
97
- *
98
- * Gemini CLI carried a `.gemini/skills` projection until E-GEM-02: it reads the `.agents/skills`
99
- * alias at project scope too, where that alias takes precedence over `.gemini/skills`. It still
100
- * needs an instruction bridge, which is a separate axis and unaffected.
101
- *
102
- * Instruction bridges are recorded at project scope only. The user-scope equivalents exist, but
103
- * nothing writes or reads them yet: `init` works inside a repository, and so does `doctor`.
104
- *
105
- * `windsurf` is the former name of Devin Desktop, rebranded 2026-06-02. It is retained as a
106
- * deprecated alias: Devin still scans the legacy `.windsurf/skills` path, so its projection keeps
107
- * working, but new repositories should enable `devin-desktop` and have nothing written for them.
108
- *
109
- * See `.research/agentic-configuration-standards/` for the per-harness sources.
110
- */
111
- const harnessRegistry = [
112
- {
113
- name: "claude-code",
114
- project: {
115
- detect: ".claude",
116
- skillsDirectory: ".claude/skills",
117
- instructionBridge: {
118
- kind: "import",
119
- path: "CLAUDE.md"
120
- },
121
- mcpConfig: {
122
- path: ".mcp.json",
123
- key: "mcpServers",
124
- format: "json"
125
- },
126
- nonstandard: [
127
- {
128
- path: ".claude/commands",
129
- shape: "directory",
130
- kind: "command"
131
- },
132
- {
133
- path: ".claude/rules",
134
- shape: "directory",
135
- kind: "rule"
136
- },
137
- {
138
- path: ".claude/agents",
139
- shape: "directory",
140
- kind: "subagent"
141
- }
142
- ]
143
- },
144
- user: {
145
- detect: ".claude",
146
- skillsDirectory: ".claude/skills"
147
- }
148
- },
149
- {
150
- name: "cursor",
151
- project: {
152
- detect: ".cursor",
153
- mcpConfig: {
154
- path: ".cursor/mcp.json",
155
- key: "mcpServers",
156
- format: "json"
157
- },
158
- nonstandard: [
159
- {
160
- path: ".cursorrules",
161
- shape: "file",
162
- kind: "instructions"
163
- },
164
- {
165
- path: ".cursor/rules",
166
- shape: "directory",
167
- kind: "rule"
168
- },
169
- {
170
- path: ".cursor/commands",
171
- shape: "directory",
172
- kind: "command"
173
- },
174
- {
175
- path: ".cursor/skills",
176
- shape: "directory",
177
- kind: "skill"
178
- }
179
- ]
180
- },
181
- user: { detect: ".cursor" }
182
- },
183
- {
184
- name: "codex",
185
- project: {
186
- detect: ".codex",
187
- mcpConfig: {
188
- path: ".codex/config.toml",
189
- key: "mcp_servers",
190
- format: "toml"
191
- },
192
- nonstandard: [{
193
- path: ".codex/skills",
194
- shape: "directory",
195
- kind: "skill"
196
- }]
197
- },
198
- user: { detect: ".codex" }
199
- },
200
- {
201
- name: "copilot-cli",
202
- project: {
203
- detect: ".github/skills",
204
- nonstandard: [
205
- {
206
- path: ".github/copilot-instructions.md",
207
- shape: "file",
208
- kind: "instructions"
209
- },
210
- {
211
- path: ".github/instructions",
212
- shape: "directory",
213
- kind: "instructions"
214
- },
215
- {
216
- path: ".github/skills",
217
- shape: "directory",
218
- kind: "skill"
219
- }
220
- ]
221
- },
222
- user: { detect: ".copilot" }
223
- },
224
- {
225
- name: "gemini-cli",
226
- project: {
227
- detect: ".gemini",
228
- instructionBridge: {
229
- kind: "settings-entry",
230
- path: ".gemini/settings.json",
231
- key: "context.fileName"
232
- },
233
- mcpConfig: {
234
- path: ".gemini/settings.json",
235
- key: "mcpServers",
236
- format: "json",
237
- shared: true
238
- },
239
- nonstandard: [{
240
- path: "GEMINI.md",
241
- shape: "file",
242
- kind: "instructions"
243
- }, {
244
- path: ".gemini/skills",
245
- shape: "directory",
246
- kind: "skill"
247
- }]
248
- },
249
- user: { detect: ".gemini" }
250
- },
251
- {
252
- name: "devin-desktop",
253
- project: { detect: ".devin" }
254
- },
255
- {
256
- name: "windsurf",
257
- project: {
258
- detect: ".windsurf",
259
- skillsDirectory: ".windsurf/skills",
260
- nonstandard: [{
261
- path: ".windsurfrules",
262
- shape: "file",
263
- kind: "instructions"
264
- }, {
265
- path: ".windsurf/rules",
266
- shape: "directory",
267
- kind: "rule"
268
- }]
269
- },
270
- deprecated: "devin-desktop"
271
- }
272
- ];
273
- const harnessNames = new Set(harnessRegistry.map((harness) => harness.name));
274
- function isHarnessName(value) {
275
- return harnessNames.has(value);
276
- }
277
- /**
278
- * The `--harness` option, as names this registry knows. Both commands take the option and neither
279
- * owns what a name means, so the parse lives beside the list it validates against: a harness added
280
- * to the registry is accepted by both commands with nothing else to change, and the rejection reads
281
- * the same either way.
282
- *
283
- * An unknown name throws rather than being dropped. A caller that asked for a harness by name and
284
- * got a report about the two default ones has been told the wrong thing about their repository.
285
- */
286
- function parseHarnesses(value) {
287
- const requested = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
288
- const unsupported = requested.filter((name) => !isHarnessName(name));
289
- if (unsupported.length) throw new Error(`Unsupported harness: ${unsupported.join(", ")}. Supported: ${harnessRegistry.map((harness) => harness.name).join(", ")}.`);
290
- return requested.filter(isHarnessName);
291
- }
292
- /** Enabled whether or not the repository already contains their directories. */
293
- const defaultHarnesses = ["claude-code", "cursor"];
294
- function directoryExists(path) {
295
- try {
296
- return lstatSync(path).isDirectory();
297
- } catch {
298
- return false;
299
- }
300
- }
301
- /** The default harnesses, plus the preferred ones, plus every harness whose directory is present. */
302
- function selectHarnesses(root, preferred) {
303
- const enabled = /* @__PURE__ */ new Set([...defaultHarnesses, ...preferred]);
304
- return harnessRegistry.filter((harness) => enabled.has(harness.name) || directoryExists(join(root, harness.project.detect)));
305
- }
306
- //#endregion
307
- //#region src/diagnose-bridges/locator.ts
308
- /** The locator as one string: the finding's `path`, and what a repair names. */
309
- function locatorText({ file, server, field, position }) {
310
- if (position) return `${file}#L${position.line}:${position.column}`;
311
- if (server === void 0) return file;
312
- return `${file}#servers.${server}${field === void 0 ? "" : `.${field}`}`;
313
- }
314
- //#endregion
315
- //#region src/diagnose-bridges/doctor-guidance.ts
316
- /**
317
- * A table as the list `doctor` reports, in the order it was written. The keys of a
318
- * `Record<P, RepairRow>` are `P` by construction, which is what the assertion says; nothing here
319
- * claims a row exists.
320
- */
321
- function repairsOf(table) {
322
- return Object.entries(table).map(([problem, row]) => ({
323
- problem,
324
- ...row
325
- }));
326
- }
327
- /** How the command names itself. */
328
- const commandInvocation = "buddy-agent-harness";
329
- /**
330
- * How a skill invokes the command. A skill may run without the binary on PATH, so it goes through
331
- * `npx`, pinned to the caret range of the version that generated the skill.
332
- *
333
- * The pin is what makes the shipped guidance honest. A skill states the findings and flags of the
334
- * version it was generated from, so an unpinned `npx` — which resolves whatever is latest — can
335
- * hand an agent a table that does not describe the CLI it just ran. The caret keeps patches
336
- * flowing and stops at the next breaking line.
337
- */
338
- const skillInvocation = (version) => `npx -y ${commandInvocation}@^${version}`;
339
- /**
340
- * The launcher a skill ships, named for the subcommand it runs so a stack trace or a process list
341
- * says which one it was. One script per command, beside the `SKILL.md` that documents it.
342
- */
343
- const launcherFor = (subcommand) => `scripts/${subcommand}.mjs`;
344
- /**
345
- * How a skill names its launcher: the path as the skill sees it, resolved by the agent against the
346
- * directory it read the `SKILL.md` from.
347
- *
348
- * `node` stays in front. The launcher ships without an executable bit, and its shebang does nothing
349
- * on Windows, so naming the file alone would not run it.
350
- */
351
- const launcherInvocation = (subcommand) => `node ${launcherFor(subcommand)}`;
352
- /**
353
- * How the skill hands a repair to `repair`. Bridge and instruction repairs go to `init`, which
354
- * writes both kinds of bridge in the first place; configuration that is present and wrong goes
355
- * here, because no `init` flag corrects a file the user already wrote.
356
- */
357
- const repairSkillInvocation = "/buddy-agent-harness:repair";
358
- /** How the skill hands a repair back to `init`. */
359
- const initSkillInvocation = "/buddy-agent-harness:init";
360
- /** Where the user authors the golden MCP server set, named in every MCP repair that points at it. */
361
- const goldenSet = ".agents/buddy-agent-harness/mcp.toml";
362
- /** The skills bridges, reported in `bridges`. */
363
- const bridgeTable = {
364
- "no-canonical": {
365
- detail: "the canonical skill directory does not exist, so no bridge can resolve",
366
- repair: (_at, cli) => ({
367
- command: `${cli} init`,
368
- instruction: `run \`${cli} init\` to create .agents/skills and the bridges into it`
369
- }),
370
- skillRepair: () => `run \`${initSkillInvocation}\`, which creates \`.agents/skills\` and the bridges`
371
- },
372
- missing: {
373
- detail: "no bridge at this path — the harness sees zero project skills",
374
- repair: ({ file }, cli) => ({
375
- command: `${cli} init`,
376
- instruction: `run \`${cli} init\` to create the bridge at ${file}`
377
- }),
378
- skillRepair: () => `run \`${initSkillInvocation}\``
379
- },
380
- degraded: {
381
- detail: "expected a directory but found a regular file — checkout without core.symlinks",
382
- repair: ({ file }, cli) => ({
383
- command: `${cli} init --copy --force ${file}`,
384
- instruction: `run \`${cli} init --copy --force ${file}\` to rebuild ${file} as a real directory`
385
- }),
386
- skillRepair: ({ file }) => `run \`${initSkillInvocation} --copy --force ${file}\``
387
- },
388
- stale: {
389
- detail: "symlink does not resolve to .agents/skills",
390
- repair: ({ file }, cli) => ({
391
- command: `${cli} init --force ${file}`,
392
- instruction: `run \`${cli} init --force ${file}\` to repoint ${file} at .agents/skills`
393
- }),
394
- skillRepair: ({ file }) => `run \`${initSkillInvocation} --force ${file}\``
395
- },
396
- "diverged-bridge": {
397
- detail: "only the bridge changed since the two last agreed — an agent wrote through the copy",
398
- repair: ({ file }, cli) => ({
399
- command: "",
400
- instruction: `replace .agents/skills with ${file} to keep the newer edit, then run \`${cli} init --force ${file}\``
401
- }),
402
- skillRepair: ({ file }) => `replace .agents/skills with ${file} to keep the newer edit, then run \`${initSkillInvocation} --force ${file}\``
403
- },
404
- "diverged-canonical": {
405
- detail: "only .agents/skills changed since the two last agreed — the copy is stale",
406
- repair: ({ file }, cli) => ({
407
- command: `${cli} init --copy --force ${file}`,
408
- instruction: `run \`${cli} init --copy --force ${file}\` to rebuild ${file} from the newer .agents/skills`
409
- }),
410
- skillRepair: ({ file }) => `run \`${initSkillInvocation} --copy --force ${file}\``
411
- },
412
- "diverged-both": {
413
- detail: "both sides changed since they last agreed — rebuilding would discard one of them",
414
- repair: ({ file }) => ({
415
- command: "",
416
- instruction: `reconcile .agents/skills with ${file} by hand — rebuilding would discard one of them; \`git diff --no-index .agents/skills ${file}\` shows what differs`
417
- }),
418
- skillRepair: ({ file }) => `run \`git diff --no-index .agents/skills ${file}\` and reconcile by hand`
419
- },
420
- "diverged-unknown": {
421
- detail: "contents differ and no commit where they agreed was found — which side moved is unknown",
422
- repair: ({ file }) => ({
423
- command: "",
424
- instruction: `reconcile .agents/skills with ${file} by hand — which side moved is unknown; \`git diff --no-index .agents/skills ${file}\` shows what differs`
425
- }),
426
- skillRepair: ({ file }) => `run \`git diff --no-index .agents/skills ${file}\` and reconcile by hand`
427
- },
428
- "unpinned-copy": {
429
- detail: "tracked copy without the skip-worktree bit — the tree is dirty with content that must not be committed",
430
- repair: ({ file }) => ({
431
- command: `git ls-files -z ${file} | xargs -0 git update-index --skip-worktree`,
432
- instruction: `run \`git ls-files -z ${file} | xargs -0 git update-index --skip-worktree\` to restore the skip-worktree bit`
433
- }),
434
- skillRepair: ({ file }) => `run \`git ls-files -z ${file} | xargs -0 git update-index --skip-worktree\``
435
- }
436
- };
437
- const bridgeRepairs = repairsOf(bridgeTable);
438
- /**
439
- * The instruction bridges, reported in `instructions`. Every repair is the `init` skill: these are
440
- * files a person wrote, or files carrying content beside the bridge, and deciding what to preserve
441
- * while restoring the bridge is judgment no flag carries. `repair` therefore names the skill in
442
- * both places rather than pretending a shell command exists.
443
- */
444
- const instructionTable = {
445
- "no-instructions": {
446
- detail: "no AGENTS.md at the repository root, so every instruction bridge points at nothing",
447
- repair: () => ({
448
- command: "",
449
- instruction: `hand this to \`${initSkillInvocation}\`, which derives AGENTS.md and the bridges to it`
450
- }),
451
- skillRepair: () => `run \`${initSkillInvocation}\`, which derives AGENTS.md and the bridges to it`
452
- },
453
- "instructions-missing": {
454
- detail: "no instruction bridge at this path — the harness reads none of AGENTS.md",
455
- repair: ({ file }) => ({
456
- command: "",
457
- instruction: `hand ${file} to \`${initSkillInvocation}\`, which writes the bridge into it`
458
- }),
459
- skillRepair: () => `run \`${initSkillInvocation}\``
460
- },
461
- "instructions-unbridged": {
462
- detail: "the file is present but names AGENTS.md nowhere — the harness reads none of it",
463
- repair: ({ file }) => ({
464
- command: "",
465
- instruction: `hand ${file} to \`${initSkillInvocation}\`, which adds the bridge without discarding what the file already says`
466
- }),
467
- skillRepair: () => `run \`${initSkillInvocation}\`, which adds the bridge without discarding what the file already says`
468
- },
469
- "instructions-unreadable": {
470
- detail: "the settings file does not parse, so the harness reads none of it",
471
- repair: ({ file }) => ({
472
- command: "",
473
- instruction: `fix the JSON in ${file} by hand, then hand it to \`${initSkillInvocation}\``
474
- }),
475
- skillRepair: () => `fix the JSON by hand, then run \`${initSkillInvocation}\``
476
- }
477
- };
478
- const instructionRepairs = repairsOf(instructionTable);
479
- /**
480
- * Configuration that is present and wrong. Detected here like everything else, but repaired by the
481
- * `repair` skill rather than by `init` — which is why it is a section of its own.
482
- */
483
- const configurationTable = {
484
- "deprecated-harness": {
485
- detail: "a projection under a harness name that has been superseded — the replacement reads .agents/skills natively and needs no projection at all",
486
- repair: ({ file }) => ({
487
- command: "",
488
- instruction: `remove ${file} and enable the harness that replaced it — \`${repairSkillInvocation}\` offers the correction`
489
- }),
490
- skillRepair: () => `run \`${repairSkillInvocation}\``
491
- },
492
- "ignored-bridge": {
493
- detail: "a .gitignore rule matches this bridge — an untracked bridge swallows a real edit silently",
494
- repair: ({ file }) => ({
495
- command: "",
496
- instruction: `narrow or remove the .gitignore rule matching ${file} — \`${repairSkillInvocation}\` offers the correction`
497
- }),
498
- skillRepair: () => `run \`${repairSkillInvocation}\``
499
- },
500
- "unread-local-override": {
501
- detail: "no harness reads this filename, so everything in it is invisible to every agent",
502
- repair: ({ file }) => ({
503
- command: "",
504
- instruction: `move ${file} to CLAUDE.local.md, or consolidate it into AGENTS.md — \`${repairSkillInvocation}\` offers the correction`
505
- }),
506
- skillRepair: () => `run \`${repairSkillInvocation}\``
507
- },
508
- "unloadable-skill": {
509
- detail: "frontmatter that does not parse, or no description — either one makes a harness skip the skill outright",
510
- repair: ({ file }) => ({
511
- command: "",
512
- instruction: `quote the description in ${file}, or add one — \`${repairSkillInvocation}\` offers the correction`
513
- }),
514
- skillRepair: () => `run \`${repairSkillInvocation}\``
515
- }
516
- };
517
- repairsOf(configurationTable);
518
- /**
519
- * The golden MCP server set against the harness copies of it.
520
- *
521
- * Every repair names a **locator** rather than a file — `.cursor/mcp.json#servers.linear.command` —
522
- * because a file holding twenty servers is not an address. The locator never carries a value: not
523
- * a whole one, and not a prefix. `doctor` is safe to run from a session-start hook, so a value it
524
- * echoes lands in agent context on every session and from there into transcripts, and `sk-ab…`
525
- * leaks into exactly the same place the whole string would.
526
- */
527
- const mcpTable = {
528
- "mcp-golden-unreadable": {
529
- detail: "the golden MCP set does not parse — the locator gives the line and column, and nothing else can be said about it",
530
- repair: (at) => ({
531
- command: "",
532
- instruction: `fix the TOML at ${locatorText(at)}`
533
- }),
534
- skillRepair: (at) => `fix the TOML at ${locatorText(at)} by hand — the reported line and column are all that can be quoted, because the parser's own message repeats the offending line and that line is the one holding the credential`
535
- },
536
- "mcp-target-unreadable": {
537
- detail: "this harness config does not parse, so the harness starts none of its servers and nothing in it can be compared",
538
- repair: (at) => ({
539
- command: "",
540
- instruction: `fix the syntax of ${locatorText(at)}`
541
- }),
542
- skillRepair: (at) => `fix the syntax of ${locatorText(at)} by hand`
543
- },
544
- "mcp-unprojected": {
545
- detail: "the golden set declares this server and the harness config does not carry it",
546
- repair: ({ file, server }) => ({
547
- command: "",
548
- instruction: `add the server ${server} to ${file}, or drop it from the golden set`
549
- }),
550
- skillRepair: (at) => `add the server at ${locatorText(at)} to that file from its golden entry, or drop it from the golden set`
551
- },
552
- "mcp-undeclared": {
553
- detail: "the harness config carries this server and the golden set does not declare it",
554
- repair: ({ file, server }) => ({
555
- command: "",
556
- instruction: `add the server ${server} to ${goldenSet}, or drop it from ${file}`
557
- }),
558
- skillRepair: (at) => `copy the server at ${locatorText(at)} into ${goldenSet}, refusing any literal credential it carries, or drop it from ${at.file}`
559
- },
560
- "mcp-diverged-target": {
561
- detail: "only the harness config changed since the two last agreed — the edit was made through the copy",
562
- repair: (at) => ({
563
- command: "",
564
- instruction: `reconcile the value at ${locatorText(at)} back into ${goldenSet}`
565
- }),
566
- skillRepair: (at) => `reconcile the value at ${locatorText(at)} back into ${goldenSet}, field by field`
567
- },
568
- "mcp-diverged-golden": {
569
- detail: "only the golden set changed since the two last agreed — the harness copy is stale",
570
- repair: (at) => ({
571
- command: "",
572
- instruction: `update ${locatorText(at)} from the golden entry`
573
- }),
574
- skillRepair: (at) => `update ${locatorText(at)} from the golden entry`
575
- },
576
- "mcp-diverged-both": {
577
- detail: "both sides changed since they last agreed — merging either way would discard the other",
578
- repair: (at) => ({
579
- command: "",
580
- instruction: `reconcile ${locatorText(at)} against ${goldenSet} by hand`
581
- }),
582
- skillRepair: (at) => `reconcile ${locatorText(at)} against ${goldenSet} by hand — never merge a three-way conflict automatically`
583
- },
584
- "mcp-diverged-unknown": {
585
- detail: "the two disagree and no baseline says which side moved — neither history nor a last-projected record covers this server",
586
- repair: (at) => ({
587
- command: "",
588
- instruction: `compare ${locatorText(at)} against ${goldenSet} by hand`
589
- }),
590
- skillRepair: (at) => `compare ${locatorText(at)} against ${goldenSet} by hand`
591
- },
592
- "mcp-literal-secret": {
593
- detail: "a credential-bearing field holds a literal rather than a reference to an environment variable",
594
- repair: (at) => ({
595
- command: "",
596
- instruction: `move the value at ${locatorText(at)} into an environment variable and reference it`
597
- }),
598
- skillRepair: (at) => `move the value at ${locatorText(at)} into an environment variable and reference it — read the value from the file, never from this report, and never repeat it back`
599
- },
600
- "mcp-committed-secret": {
601
- detail: "a credential-bearing field holds a literal in a git-tracked file — the credential is committed, and moving it does not un-commit it",
602
- repair: (at) => ({
603
- command: "",
604
- instruction: `rotate the credential behind ${locatorText(at)}, then reference it from an environment variable`
605
- }),
606
- skillRepair: (at) => `rotate the credential behind ${locatorText(at)} at its issuer, then reference it from an environment variable — it is in the repository's history, so moving it is not enough`
607
- }
608
- };
609
- repairsOf(mcpTable);
610
- /**
611
- * Configuration only one harness can read. Every repair here is a **conversion**, and every one of
612
- * them is the `init` skill's: consolidating what a repository already has is exactly its remit, and
613
- * the content is the user's, so it is offered and never written on sight. `repair` owns none of
614
- * them — it corrects configuration that is wrong, and none of this is wrong.
615
- *
616
- * `nonstandard-subagent` is the exception that names no owner. There is no cross-harness subagent
617
- * format to convert to, so the honest report is that the gap exists, not a repair that cannot run.
618
- */
619
- const nonstandardTable = {
620
- "nonstandard-instructions": {
621
- detail: "instruction content only one harness reads — AGENTS.md carries the same prose to all of them",
622
- repair: ({ file }) => ({
623
- command: "",
624
- instruction: `consolidate ${file} into AGENTS.md and leave a generated bridge in its place — \`${initSkillInvocation}\` offers the consolidation`
625
- }),
626
- skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\`, which consolidates it into AGENTS.md`
627
- },
628
- "nonstandard-rule": {
629
- detail: "a path-scoped rule only one harness reads — a skill reaches every harness where the scoping is incidental",
630
- repair: ({ file }) => ({
631
- command: "",
632
- instruction: `convert ${file} into a skill under .agents/skills where its guidance is not tied to the paths it names — \`${initSkillInvocation}\` offers the conversion; keep it where the path scoping is the point`
633
- }),
634
- skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\` to convert into a skill, unless the path scoping is load-bearing`
635
- },
636
- "nonstandard-command": {
637
- detail: "a harness command file — a skill is the portable form, and this harness reads skills too",
638
- repair: ({ file }) => ({
639
- command: "",
640
- instruction: `move ${file} to .agents/skills/<name>/SKILL.md — \`${initSkillInvocation}\` offers the move`
641
- }),
642
- skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\`, which moves it to .agents/skills`
643
- },
644
- "nonstandard-skill": {
645
- detail: "a skill under a harness directory rather than the canonical one, where only that harness finds it",
646
- repair: ({ file }) => ({
647
- command: "",
648
- instruction: `move ${file} to .agents/skills and project it back where the harness needs one — \`${initSkillInvocation}\` offers the move`
649
- }),
650
- skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\`, which moves it to .agents/skills`
651
- },
652
- "nonstandard-subagent": {
653
- detail: "a subagent definition with no cross-harness form — nothing outside this harness can read it, and no canonical form exists yet",
654
- repair: ({ file }) => ({
655
- command: "",
656
- instruction: `leave ${file} where it is and record that it reaches one harness only — no portable form exists to convert it to`
657
- }),
658
- skillRepair: ({ file }) => `leave ${file} in place — no portable form exists yet, so this is work for a person rather than a skill`
659
- }
660
- };
661
- repairsOf(nonstandardTable);
662
- /**
663
- * All five sections, in the order `doctor` reports them. Annotated over the whole union rather than
664
- * inferred, so a problem added to any of the four fails here too if its section was left alone.
665
- */
666
- const doctorTable = {
667
- ...bridgeTable,
668
- ...instructionTable,
669
- ...configurationTable,
670
- ...mcpTable,
671
- ...nonstandardTable
672
- };
673
- const doctorRepairs = repairsOf(doctorTable);
674
- /** The repair for one problem. Total by construction: the table is keyed by the union. */
675
- function repairFor(problem) {
676
- return {
677
- problem,
678
- ...doctorTable[problem]
679
- };
680
- }
681
- const doctorSkill = {
682
- name: "doctor",
683
- description: "Use this skill when a repository loads no project skills, when skills are missing after a clone, when a harness appears to be ignoring AGENTS.md, or when checking whether the agent configuration bridges into .claude/skills, CLAUDE.md, and the other harness files still resolve."
684
- };
685
- /** Written into the generated skill so a reader knows not to edit it in place. */
686
- const generatedSkillWarning = "<!-- Generated from src/diagnose-bridges/doctor-guidance.ts by scripts/generate-skills.ts. Do not edit by hand. -->";
687
- /**
688
- * The shipped `doctor` skill, rendered from the same table the command prints. `version` is the
689
- * package version the skill ships with; it pins the `npx` invocation so the table and the CLI that
690
- * produced it stay on the same breaking line.
691
- *
692
- * The finding tables are NOT here. They live in the reference pages below, and this file carries
693
- * only what every reader needs: how to run it, how to read the report, and where to go next.
694
- */
695
- function renderDoctorSkill(version) {
696
- return `---
697
- name: ${doctorSkill.name}
698
- description: ${doctorSkill.description}
699
- ---
700
-
701
- ${generatedSkillWarning}
702
-
703
- # Harness Doctor
704
-
705
- A repository keeps one canonical configuration: \`.agents/skills\` for its skills and \`AGENTS.md\` for its instructions. Harnesses that cannot read those get bridges pointing at them: Claude Code needs both, and Gemini CLI needs the instruction bridge only — it reads \`.agents/skills\` itself. A bridge that stops resolving is silent: the harness finds nothing and loads zero project skills, with no warning anywhere. An instruction bridge fails the same way and costs more, because the harness then reads none of the repository's instructions at all.
706
-
707
- Diagnose it:
708
-
709
- \`\`\`sh
710
- ${launcherInvocation("doctor")}
711
- \`\`\`
712
-
713
- That path is relative to this skill's own directory. The launcher runs the CLI that shipped beside it against the current working directory, so nothing is downloaded. Fall back to \`${skillInvocation(version)} doctor\` when the launcher cannot be resolved or run, which is the case when the plugin was installed from git rather than npm and its dependencies were never installed.
714
-
715
- The command is read-only. It never repairs anything, so it is safe to run at any point, including from a session-start hook.
716
-
717
- ## Reading the report
718
-
719
- \`bridges\` lists every skills bridge \`init\` would create for this repository, each with a \`status\` of \`ok\`, \`missing\`, \`degraded\`, \`stale\`, or \`diverged\`.
720
-
721
- \`instructions\` lists every instruction bridge into \`AGENTS.md\`, with a \`status\` of \`ok\`, \`missing\`, \`unbridged\`, or \`unreadable\`. They are a separate section because nothing about them is shared: a different \`kind\`, a different status vocabulary, and a repair that is never a command.
722
-
723
- \`findings\` explains each problem and carries more than the two sections above: the configuration, MCP, and non-standard findings have no section of their own, because they are about files rather than about bridges. \`help\` carries each repair, one row per distinct repair, with two columns:
724
-
725
- - \`command\` — a shell invocation that, run exactly as given, **completes** the repair.
726
- - \`instruction\` — the same repair in the imperative, always present and complete on its own.
727
-
728
- \`command\` is empty whenever no single invocation does the job, and that emptiness is the signal: act on \`instruction\` and do not assemble a command out of it. A runnable invocation quoted *inside* an \`instruction\` is not the repair either — \`diverged-both\` names \`git diff --no-index\` because the diff shows you what differs, not because running it reconciles anything. Apply the repair, then re-run \`doctor\`.
729
-
730
- Nothing in \`help\` is wrapped. An earlier version prefixed every repair with \`Run\`, which read as an instruction to paste prose into a shell.
731
-
732
- Do not run an \`init\` command yourself. Rebuilding a skills bridge can move skills a user wrote, and rewriting an instruction file touches prose a person authored — both are the \`init\` skill's judgment, so hand the repair to \`${initSkillInvocation}\` instead. Every such repair carries an empty \`command\`: a skill invocation has no shell equivalent at all.
733
-
734
- When every bridge resolves, \`findings\` says so outright rather than being empty.
735
-
736
- The default output is TOON, which is what you parse. Add \`--format text\` when you need to show the same report to a person, or \`--format json\`.
737
-
738
- ## Where the detail is
739
-
740
- Every \`problem\` name routes to exactly one page. Load the page for the finding in front of you and leave the rest unread — the tables are long, and reading four families to act on one is what this split exists to stop.
741
-
742
- | Page | Load it for |
743
- | --- | --- |
744
- | \`references/bridges.md\` | any \`bridges\` row that is not \`ok\` |
745
- | \`references/instructions.md\` | any \`instructions\` row that is not \`ok\` |
746
- | \`references/configuration.md\` | a finding about the configuration around the bridges rather than a bridge |
747
- | \`references/mcp.md\` | any finding whose path is an MCP locator — **always** before acting on a credential finding |
748
- | \`references/nonstandard.md\` | a finding about configuration only one harness can read |
749
- | \`references/harnesses/<name>.md\` | the paths and files one named harness uses |
750
-
751
- ## Rules
752
-
753
- - Never repair a \`diverged-both\` or \`diverged-unknown\` bridge by re-running \`init\`. Rebuilding overwrites whichever side holds the newer edit. Reconcile the two directories first.
754
- - Edit skills at \`.agents/skills/<name>/SKILL.md\`. Editing through a bridge is only safe when that bridge is a symlink.
755
- - Do not add bridges to \`.gitignore\`. An untracked bridge swallows a real edit silently.
756
- - Never repeat a value from a file an \`mcp-literal-secret\` or \`mcp-committed-secret\` finding points at. The report withheld it on purpose, and quoting it back puts it in the transcript anyway.
757
- - Write instructions in \`AGENTS.md\`, never in \`CLAUDE.md\`. A bridge file holds the import and any harness-specific notes; content written there reaches one harness and drifts from the canonical file.
758
- `;
759
- }
760
- //#endregion
761
- //#region src/diagnose-configuration/diagnose-configuration.ts
762
- const canonicalSkills = ".agents/skills";
763
- const localOverride = "AGENTS.local.md";
764
- /**
765
- * The two frontmatter faults that make a harness skip a skill outright, per
766
- * `skills/init/references/frontmatter.md`: YAML that does not parse, and a missing `description`.
767
- * A `name` that mismatches the directory is a warning and still loads, so it is not reported here.
768
- *
769
- * The unquoted colon is the documented cause of the first, and is checked directly rather than by
770
- * parsing YAML: the package ships no parser, and a targeted check names the actual fault.
771
- */
772
- function frontmatterFault(body) {
773
- const block = /^---\n([\s\S]*?)\n---/.exec(body);
774
- if (!block) return "unparseable";
775
- const value = /^description:[ \t]*(.*)$/m.exec(block[1])?.[1]?.trim();
776
- if (!value) return "no-description";
777
- if (!/^(['"]).*\1$/.test(value) && /:\s/.test(value)) return "unparseable";
778
- }
779
- /**
780
- * Unlike `diagnoseBridges`, this takes no harness preference. Every check here requires the
781
- * projection to exist on disk, and a projection cannot exist without its harness's detection
782
- * directory — which selects that harness anyway. A preference could therefore never add a finding,
783
- * so accepting one would be surface that does nothing.
784
- */
785
- function diagnoseConfiguration({ root, git, cli }) {
786
- const findings = [];
787
- const add = (path, problem) => {
788
- const { detail, repair } = repairFor(problem);
789
- findings.push({
790
- path,
791
- problem,
792
- detail,
793
- repair: repair({ file: path }, cli)
794
- });
795
- };
796
- const harnesses = selectHarnesses(root, []);
797
- for (const harness of harnesses) {
798
- const projection = harness.project.skillsDirectory;
799
- if (harness.deprecated && projection && existsSync(join(root, projection))) add(projection, "deprecated-harness");
800
- }
801
- for (const harness of harnesses) {
802
- const path = harness.project.skillsDirectory;
803
- if (path && existsSync(join(root, path)) && git.isIgnored(path)) add(path, "ignored-bridge");
804
- }
805
- if (existsSync(join(root, localOverride))) add(localOverride, "unread-local-override");
806
- for (const file of filesUnder(join(root, canonicalSkills)).filter((file) => file.endsWith("SKILL.md"))) if (frontmatterFault(readFileSync(join(root, canonicalSkills, file), "utf8"))) add(`${canonicalSkills}/${file}`, "unloadable-skill");
807
- return findings;
808
- }
809
- //#endregion
810
- //#region src/diagnose-bridges/json-with-comments.ts
811
- /**
812
- * `.gemini/settings.json` may legally carry comments — the Gemini CLI loader strips them before
813
- * parsing. A plain `JSON.parse` of a valid settings file therefore throws, and `doctor` would report
814
- * a working bridge as unreadable. Comments are removed before parsing for that reason.
815
- *
816
- * Only comments. A trailing comma stays a parse error, because nothing documents it as accepted.
817
- */
818
- function stripJsonComments(source) {
819
- let output = "";
820
- let index = 0;
821
- let inString = false;
822
- let escaped = false;
823
- while (index < source.length) {
824
- const character = source[index];
825
- if (inString) {
826
- output += character;
827
- if (escaped) escaped = false;
828
- else if (character === "\\") escaped = true;
829
- else if (character === "\"") inString = false;
830
- index += 1;
831
- continue;
832
- }
833
- if (character === "\"") {
834
- inString = true;
835
- output += character;
836
- index += 1;
837
- continue;
838
- }
839
- if (character === "/" && source[index + 1] === "/") {
840
- while (index < source.length && source[index] !== "\n") index += 1;
841
- continue;
842
- }
843
- if (character === "/" && source[index + 1] === "*") {
844
- index += 2;
845
- while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) index += 1;
846
- index += 2;
847
- continue;
848
- }
849
- output += character;
850
- index += 1;
851
- }
852
- return output;
853
- }
854
- /** The parsed value, or `undefined` when the file is not JSON even once its comments are gone. */
855
- function parseJsonWithComments(source) {
856
- try {
857
- return JSON.parse(stripJsonComments(source));
858
- } catch {
859
- return;
860
- }
861
- }
862
- //#endregion
863
- //#region src/diagnose-mcp/mcp-model.ts
864
- /** Every field of the model, in the order a finding reports them. */
865
- const mcpFields = [
866
- "transport",
867
- "command",
868
- "args",
869
- "env",
870
- "url",
871
- "headers",
872
- "description",
873
- "enabled",
874
- "timeout",
875
- "source"
876
- ];
877
- /** The two fields holding one value per name rather than a single value. */
878
- const mapFields = /* @__PURE__ */ new Set(["env", "headers"]);
879
- /**
880
- * Whether two values of the same field agree. Arrays are ordered — `args` is a command line, and
881
- * reordering it changes what runs. Maps are not, so they are compared by their sorted entries.
882
- */
883
- function sameValue(field, left, right) {
884
- if (mapFields.has(field) && isRecord(left) && isRecord(right)) {
885
- const keys = Object.keys(left);
886
- return keys.length === Object.keys(right).length && keys.every((key) => left[key] === right[key]);
887
- }
888
- return JSON.stringify(left) === JSON.stringify(right);
889
- }
890
- /**
891
- * The fields on which a target disagrees with the golden set.
892
- *
893
- * Asymmetric by design, and this is the rule that keeps a golden set from accumulating noise. A
894
- * field the golden set leaves unset is never a difference, however the target fills it: a host
895
- * restating its own default and a user's deliberate edit are indistinguishable in that position,
896
- * and pulling both back would grow the golden set on every round-trip. The golden set speaks only
897
- * about what it says.
898
- *
899
- * `env` and `headers` are compared per name for the same reason — a variable only the target sets
900
- * is that target's business, and one the golden set names must match.
901
- */
902
- function divergingFields(golden, target) {
903
- return mcpFields.filter((field) => {
904
- const declared = golden[field];
905
- if (declared === void 0) return false;
906
- if (mapFields.has(field) && isRecord(declared) && isRecord(target[field])) {
907
- const observed = target[field];
908
- return Object.keys(declared).some((key) => declared[key] !== observed[key]);
909
- }
910
- return !sameValue(field, declared, target[field]);
911
- });
912
- }
913
- /** Whether one field of two models agrees, for naming the side that moved. */
914
- function sameField(field, left, right) {
915
- return sameValue(field, left?.[field], right?.[field]);
916
- }
917
- //#endregion
918
- //#region src/diagnose-mcp/mcp-sources.ts
919
- /** Where the golden set lives, and why it is namespaced rather than sitting at `.agents/mcp.json`. */
920
- const goldenSetPath = ".agents/buddy-agent-harness/mcp.toml";
921
- /** The table the golden set keeps its servers under. */
922
- const goldenKey = "servers";
923
- function stringMap(value) {
924
- if (!isRecord(value)) return void 0;
925
- const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
926
- return entries.length ? Object.fromEntries(entries) : void 0;
927
- }
928
- const transports = /* @__PURE__ */ new Set([
929
- "stdio",
930
- "http",
931
- "sse"
932
- ]);
933
- /**
934
- * The transport a host entry describes. Hosts spell it `type` (Claude Code, VS Code) or
935
- * `transport`, and several state it nowhere and leave it implied by which of `url` and `command`
936
- * is present. Inferring it is what lets a `url` entry in one file compare equal to a `type: http`
937
- * entry in another.
938
- */
939
- function transportOf(entry) {
940
- const declared = entry["type"] ?? entry["transport"];
941
- if (typeof declared === "string" && transports.has(declared)) return declared;
942
- if (typeof entry["url"] === "string") return "http";
943
- if (typeof entry["command"] === "string") return "stdio";
944
- }
945
- /**
946
- * One host entry, or one golden entry, in the canonical model. The two formats carry the same
947
- * field names — the divergence between hosts is the file, the top-level key, and the serialization,
948
- * not the entry — so one reader serves both directions of the comparison.
949
- *
950
- * A field whose value is the wrong type is dropped rather than carried through. A `timeout` that
951
- * is a string is not a timeout, and letting it into the model would report a divergence whose real
952
- * cause is a typo the finding does not name.
953
- */
954
- function serverFrom(entry) {
955
- const args = Array.isArray(entry["args"]) && entry["args"].every((item) => typeof item === "string");
956
- const transport = transportOf(entry);
957
- const env = stringMap(entry["env"]);
958
- const headers = stringMap(entry["headers"]);
959
- return {
960
- ...transport ? { transport } : {},
961
- ...typeof entry["command"] === "string" ? { command: entry["command"] } : {},
962
- ...args ? { args: entry["args"] } : {},
963
- ...env ? { env } : {},
964
- ...typeof entry["url"] === "string" ? { url: entry["url"] } : {},
965
- ...headers ? { headers } : {},
966
- ...typeof entry["description"] === "string" ? { description: entry["description"] } : {},
967
- ...typeof entry["enabled"] === "boolean" ? { enabled: entry["enabled"] } : {},
968
- ...typeof entry["timeout"] === "number" ? { timeout: entry["timeout"] } : {},
969
- ...typeof entry["source"] === "string" ? { source: entry["source"] } : {}
970
- };
971
- }
972
- function serversUnder(document, key) {
973
- const table = isRecord(document) ? document[key] : void 0;
974
- if (!isRecord(table)) return /* @__PURE__ */ new Map();
975
- return new Map(Object.entries(table).filter(([, entry]) => isRecord(entry)).map(([name, entry]) => [name, serverFrom(entry)]));
976
- }
977
- /**
978
- * The position out of a thrown parse error, and nothing else out of it.
979
- *
980
- * `smol-toml` reports `line` and `column` on the error alongside a `codeblock` quoting the source,
981
- * and its `message` embeds that same code block. Both are unusable: in a file of MCP configuration
982
- * the line a parser failed on is exactly the line holding the credential. Only the two numbers are
983
- * read, and anything thrown that does not carry them is reported without a position rather than by
984
- * reaching for its message.
985
- *
986
- * Exported because that restriction is the point of the function rather than an implementation
987
- * detail of it, and it is verified directly.
988
- */
989
- function positionOf(error) {
990
- if (!isRecord(error)) return void 0;
991
- const { line, column } = error;
992
- return typeof line === "number" && typeof column === "number" ? {
993
- line,
994
- column
995
- } : void 0;
996
- }
997
- /** The golden set, parsed. Absent is the common case and is not a fault. */
998
- function parseGoldenSet(source) {
999
- if (source === void 0) return { kind: "absent" };
1000
- try {
1001
- return {
1002
- kind: "servers",
1003
- servers: serversUnder(parse(source), goldenKey)
1004
- };
1005
- } catch (error) {
1006
- return {
1007
- kind: "unreadable",
1008
- position: positionOf(error)
1009
- };
1010
- }
1011
- }
1012
- /**
1013
- * One harness's MCP configuration, parsed. The file is read for its own key and nothing else:
1014
- * `.gemini/settings.json` also carries the instruction bridge, and a reader that treated the whole
1015
- * file as MCP would report the rest of a user's settings as servers it does not recognize.
1016
- *
1017
- * JSON is parsed with comments stripped, because Gemini CLI's loader strips them too and a
1018
- * settings file may legally carry them.
1019
- */
1020
- function parseTarget(config, source) {
1021
- if (source === void 0) return { kind: "absent" };
1022
- if (config.format === "toml") try {
1023
- return {
1024
- kind: "servers",
1025
- servers: serversUnder(parse(source), config.key)
1026
- };
1027
- } catch {
1028
- return { kind: "unreadable" };
1029
- }
1030
- const document = parseJsonWithComments(source);
1031
- if (document === void 0) return { kind: "unreadable" };
1032
- return {
1033
- kind: "servers",
1034
- servers: serversUnder(document, config.key)
1035
- };
1036
- }
1037
- //#endregion
1038
- //#region src/diagnose-mcp/mcp-baseline.ts
1039
- /** Where the record of what was last written to each target lives, beside the golden set it came from. */
1040
- const projectionRecordPath = ".agents/buddy-agent-harness/mcp.projected.json";
1041
- /**
1042
- * The last-projected record: per target path, per server name, the model that was written there.
1043
- *
1044
- * It exists because git can only answer for a file it can see. Harnesses write their MCP
1045
- * configuration themselves, at user scope, into files that are often untracked or ignored outright,
1046
- * and for those "which side moved since they last agreed" has no answer in history. A record
1047
- * written at projection time has one, exactly, because it *is* what was projected.
1048
- *
1049
- * A malformed record answers nothing rather than throwing. It is a cache of an answer, not the
1050
- * answer, and a diagnosis that dies because its cache is corrupt is worse than one that says
1051
- * `unknown`.
1052
- */
1053
- function parseProjectionRecord(source) {
1054
- const document = source === void 0 ? void 0 : parseJsonWithComments(source);
1055
- const targets = isRecord(document) ? document["targets"] : void 0;
1056
- if (!isRecord(targets)) return /* @__PURE__ */ new Map();
1057
- return new Map(Object.entries(targets).filter(([, servers]) => isRecord(servers)).map(([path, servers]) => [path, new Map(Object.entries(servers).filter(([, server]) => isRecord(server)).map(([name, server]) => [name, server]))]));
1058
- }
1059
- /**
1060
- * Which side moved, for one server and one field.
1061
- *
1062
- * The record is asked first, because it records exactly what was written and therefore answers
1063
- * exactly. Where it holds nothing, a **tracked** target falls back to git: the newest commit where
1064
- * the golden set and the target agreed on that field, then which working tree still matches it.
1065
- * Where neither can answer, the direction is `unknown` and the finding says so — naming a side on a
1066
- * guess would send a reconcile at the wrong file.
1067
- */
1068
- var McpBaseline = class {
1069
- options;
1070
- constructor(options) {
1071
- this.options = options;
1072
- }
1073
- /**
1074
- * One parse per commit per file, and one commit walk per target.
1075
- *
1076
- * `lastAgreed` runs per (config, server, field) that diverges without a projection record, and
1077
- * every one of those walks reads the same two files at the same commits. Unmemoized, three
1078
- * targets against five servers with two diverged fields each over fifty commits of history is
1079
- * three thousand `git show` calls and as many parses — on the command the `doctor` skill says is
1080
- * cheap enough for a session-start hook. One instance serves one diagnosis, so the memo lives and
1081
- * dies with it and can never answer for a working tree that has moved on.
1082
- */
1083
- parsed = /* @__PURE__ */ new Map();
1084
- walked = /* @__PURE__ */ new Map();
1085
- directionOf(config, name, field, golden, target) {
1086
- const base = this.baseFor(config, name, field);
1087
- if (base === void 0) return "unknown";
1088
- const goldenMoved = !sameField(field, golden, base);
1089
- const targetMoved = !sameField(field, target, base);
1090
- if (goldenMoved && targetMoved) return "both";
1091
- return targetMoved ? "target" : "golden";
1092
- }
1093
- baseFor(config, name, field) {
1094
- const recorded = this.options.record.get(config.path)?.get(name);
1095
- if (recorded) return recorded;
1096
- return this.lastAgreed(config, name, field);
1097
- }
1098
- /**
1099
- * The newest committed state where both files described this field the same way. Read at model
1100
- * granularity rather than by comparing blobs: the two files never share bytes, and a commit that
1101
- * reformatted one of them did not change what it says.
1102
- */
1103
- lastAgreed(config, name, field) {
1104
- for (const commit of this.commitsTouching(config)) {
1105
- const golden = this.parseAt(commit, goldenSetPath, parseGoldenSet);
1106
- const target = this.parseAt(commit, config.path, (source) => parseTarget(config, source));
1107
- if (golden.kind !== "servers" || target.kind !== "servers") continue;
1108
- const left = golden.servers.get(name);
1109
- const right = target.servers.get(name);
1110
- if (left && right && sameField(field, left, right)) return left;
1111
- }
1112
- }
1113
- commitsTouching(config) {
1114
- const walked = this.walked.get(config.path);
1115
- if (walked) return walked;
1116
- const commits = this.options.git.commitsTouching([goldenSetPath, config.path]);
1117
- this.walked.set(config.path, commits);
1118
- return commits;
1119
- }
1120
- parseAt(commit, path, parse) {
1121
- const key = `${commit}\u0000${path}`;
1122
- const parsed = this.parsed.get(key);
1123
- if (parsed) return parsed;
1124
- const result = parse(this.options.git.contentAt(commit, path));
1125
- this.parsed.set(key, result);
1126
- return result;
1127
- }
1128
- };
1129
- //#endregion
1130
- //#region src/diagnose-mcp/mcp-secrets.ts
1131
- /**
1132
- * Finding a literal credential in MCP configuration, without ever handling one.
1133
- *
1134
- * `doctor` is safe to run from a session-start hook, so its output lands in agent context on every
1135
- * session and from there into transcripts. A value it echoes is amplified far past the file it came
1136
- * from. Every function here answers **where**, never **what**: the return is a field path, and the
1137
- * value never leaves this module.
1138
- *
1139
- * That is also why the redaction lives here rather than in a formatter. `doctor` renders three ways
1140
- * — TOON, `--format text`, and `--format json` — and trusting each of them to mask is three chances
1141
- * to drift. A value that never enters the finding cannot be printed by any of them.
1142
- */
1143
- /**
1144
- * Words that mark a field as credential-bearing wherever they appear in its name. Each is one that
1145
- * effectively never turns up inside an innocent word, so a bare substring test is safe and catches
1146
- * the run-together spellings a segment test would miss — `MYTOKEN`, `githubsecret`.
1147
- */
1148
- const credentialWord = /token|secret|password|passwd|credential/i;
1149
- /**
1150
- * Words that mark a field only when they are a **segment** of its name. `key` and `auth` are the
1151
- * two that matter, and both are common inside words that carry nothing — `MONKEY`, `AUTHOR`,
1152
- * `KEYSTONE_URL`. Matching them as substrings reports those, and a scanner that cries wolf on a
1153
- * hook-run command is one people stop reading.
1154
- */
1155
- const credentialSegment = /* @__PURE__ */ new Set([
1156
- "key",
1157
- "apikey",
1158
- "auth",
1159
- "authorization",
1160
- "bearer"
1161
- ]);
1162
- /** A field name split the two ways these are written: `API_KEY` and `apiKey` alike. */
1163
- function segmentsOf(key) {
1164
- return key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((segment) => segment.toLowerCase());
1165
- }
1166
- /**
1167
- * A value that **names** a secret instead of holding one, in the forms the supported hosts
1168
- * document: `${VAR}`, `$VAR`, and the `${env:VAR}` / `${input:id}` variants.
1169
- *
1170
- * Classification is by shape, not by content. An entropy test on the value would be a guess about
1171
- * a string this module has already decided not to look at, and it would fail in both directions —
1172
- * a short password is low-entropy and a base64 configuration blob is high.
1173
- */
1174
- const reference = /\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*/;
1175
- /**
1176
- * A credential-bearing field passes when it carries a reference anywhere in it, because the common
1177
- * legitimate form is a template rather than a bare variable — `Bearer ${LINEAR_TOKEN}` is the
1178
- * documented way to write an authorization header, and requiring the whole value to be a reference
1179
- * would report every correct one.
1180
- */
1181
- function holdsLiteral(value) {
1182
- return !reference.test(value);
1183
- }
1184
- function credentialKey(key) {
1185
- return credentialWord.test(key) || segmentsOf(key).some((segment) => credentialSegment.has(segment));
1186
- }
1187
- /**
1188
- * Whether a URL carries a credential in itself. Two shapes: a password in the userinfo component,
1189
- * and a credential-named query parameter holding a literal. Neither value is read past the test.
1190
- *
1191
- * A URL that does not parse is not reported. It is malformed rather than leaky, and guessing at its
1192
- * structure with a regular expression is how a scanner starts matching the thing it must not touch.
1193
- */
1194
- function urlHoldsCredential(value) {
1195
- let url;
1196
- try {
1197
- url = new URL(value);
1198
- } catch {
1199
- return false;
1200
- }
1201
- if (url.password) return true;
1202
- for (const [key, parameter] of url.searchParams) if (credentialKey(key) && holdsLiteral(parameter)) return true;
1203
- return false;
1204
- }
1205
- function literalsIn(field, values) {
1206
- return Object.entries(values ?? {}).filter(([key, value]) => credentialKey(key) && holdsLiteral(value)).map(([key]) => `${field}.${key}`);
1207
- }
1208
- /**
1209
- * Every field of one server holding a literal credential, as a path within that server.
1210
- *
1211
- * The path is the whole answer. It is never a truncated value: `sk-ab…` is a leak into the same
1212
- * transcript and buys a reader nothing the field name does not already give them.
1213
- */
1214
- function credentialFields(server) {
1215
- return [
1216
- ...literalsIn("env", server.env),
1217
- ...literalsIn("headers", server.headers),
1218
- ...server.url && urlHoldsCredential(server.url) ? ["url"] : []
1219
- ];
1220
- }
1221
- //#endregion
1222
- //#region src/diagnose-mcp/diagnose-mcp.ts
1223
- function read(root, path) {
1224
- const absolute = join(root, path);
1225
- return existsSync(absolute) ? readFileSync(absolute, "utf8") : void 0;
1226
- }
1227
- /**
1228
- * The distinct MCP files the enabled harnesses read. Two harnesses may name the same file.
1229
- *
1230
- * No harness preference is accepted, for the same reason the configuration half accepts none: every
1231
- * harness documenting an MCP file is already selected without one. Claude Code and Cursor are
1232
- * selected unconditionally; Codex and Gemini CLI keep their file inside their own detection
1233
- * directory, so it cannot exist without that directory selecting them. A preference could never add
1234
- * a finding.
1235
- */
1236
- function targetsOf(root) {
1237
- const seen = /* @__PURE__ */ new Set();
1238
- return selectHarnesses(root, []).map((harness) => harness.project.mcpConfig).filter((config) => config !== void 0).filter((config) => !seen.has(config.path) && seen.add(config.path));
1239
- }
1240
- const divergence = {
1241
- target: "mcp-diverged-target",
1242
- golden: "mcp-diverged-golden",
1243
- both: "mcp-diverged-both",
1244
- unknown: "mcp-diverged-unknown"
1245
- };
1246
- function diagnoseMcp({ root, git, cli }) {
1247
- const findings = [];
1248
- const add = (at, problem) => {
1249
- const { detail, repair } = repairFor(problem);
1250
- findings.push({
1251
- path: locatorText(at),
1252
- problem,
1253
- detail,
1254
- repair: repair(at, cli)
1255
- });
1256
- };
1257
- /**
1258
- * Credentials are reported for every file that holds servers, the golden set included — a user
1259
- * pastes a token into whichever file is open, and the golden set is a file. Severity splits on
1260
- * tracking, because a literal in a tracked file is already a committed credential and moving it
1261
- * to an environment variable does not un-commit it.
1262
- */
1263
- const reportSecrets = (path, servers) => {
1264
- const committed = git.trackingOf(path) !== "untracked";
1265
- for (const [name, server] of servers) for (const field of credentialFields(server)) add({
1266
- file: path,
1267
- server: name,
1268
- field
1269
- }, committed ? "mcp-committed-secret" : "mcp-literal-secret");
1270
- };
1271
- const targets = /* @__PURE__ */ new Map();
1272
- const configs = [];
1273
- for (const config of targetsOf(root)) {
1274
- const parsed = parseTarget(config, read(root, config.path));
1275
- if (parsed.kind === "unreadable") {
1276
- add({ file: config.path }, "mcp-target-unreadable");
1277
- continue;
1278
- }
1279
- if (parsed.kind === "absent") continue;
1280
- reportSecrets(config.path, parsed.servers);
1281
- targets.set(config.path, parsed.servers);
1282
- configs.push(config);
1283
- }
1284
- const golden = parseGoldenSet(read(root, goldenSetPath));
1285
- if (golden.kind === "unreadable") {
1286
- add({
1287
- file: goldenSetPath,
1288
- position: golden.position
1289
- }, "mcp-golden-unreadable");
1290
- return findings;
1291
- }
1292
- if (golden.kind === "absent") return findings;
1293
- reportSecrets(goldenSetPath, golden.servers);
1294
- const baseline = new McpBaseline({
1295
- git,
1296
- record: parseProjectionRecord(read(root, projectionRecordPath))
1297
- });
1298
- for (const config of configs) {
1299
- const servers = targets.get(config.path);
1300
- for (const [name, declared] of golden.servers) {
1301
- const carried = servers.get(name);
1302
- if (!carried) {
1303
- add({
1304
- file: config.path,
1305
- server: name
1306
- }, "mcp-unprojected");
1307
- continue;
1308
- }
1309
- for (const field of divergingFields(declared, carried)) add({
1310
- file: config.path,
1311
- server: name,
1312
- field
1313
- }, divergence[baseline.directionOf(config, name, field, declared, carried)]);
1314
- }
1315
- for (const name of servers.keys()) if (!golden.servers.has(name)) add({
1316
- file: config.path,
1317
- server: name
1318
- }, "mcp-undeclared");
1319
- }
1320
- return findings;
1321
- }
1322
- //#endregion
1323
- //#region src/diagnose-nonstandard/diagnose-nonstandard.ts
1324
- const problemOf = {
1325
- instructions: "nonstandard-instructions",
1326
- rule: "nonstandard-rule",
1327
- command: "nonstandard-command",
1328
- skill: "nonstandard-skill",
1329
- subagent: "nonstandard-subagent"
1330
- };
1331
- /**
1332
- * A `.mdc` rule splits on whether its scoping is load-bearing. `globs:` with a value binds the rule
1333
- * to paths, and `AGENTS.md` has no equivalent — it scopes by directory nesting only — so that one
1334
- * converts to a skill, or stays. Without it the rule is always-on prose, which `AGENTS.md` holds
1335
- * verbatim. Read from frontmatter rather than assumed, because the two need different conversions
1336
- * and guessing wrong sends prose to the wrong destination.
1337
- */
1338
- function scopedByGlobs(body) {
1339
- const block = /^---\n([\s\S]*?)\n---/.exec(body);
1340
- if (!block) return false;
1341
- const value = /^globs:[ \t]*(.*)$/m.exec(block[1])?.[1]?.trim();
1342
- return value !== void 0 && value.length > 0 && value !== "[]";
1343
- }
1344
- /** Every skill directory below `directory`, named by its `SKILL.md`. */
1345
- function skillFiles(directory) {
1346
- return filesUnder(directory).filter((file) => file.endsWith("SKILL.md"));
1347
- }
1348
- /**
1349
- * A harness directory that is a symlink is a projection someone already made, not configuration
1350
- * authored here — reporting it would tell a repository to convert what it already converted.
1351
- */
1352
- function authoredHere(path) {
1353
- try {
1354
- return !lstatSync(path).isSymbolicLink();
1355
- } catch {
1356
- return false;
1357
- }
1358
- }
1359
- function artifactFiles(root, artifact) {
1360
- const absolute = join(root, artifact.path);
1361
- if (!authoredHere(absolute)) return [];
1362
- if (artifact.shape === "file") return [artifact.path];
1363
- return (artifact.kind === "skill" ? skillFiles(absolute) : filesUnder(absolute)).map((file) => posix.join(artifact.path, file));
1364
- }
1365
- /**
1366
- * Unlike the other detectors this takes no harness preference and no enabled set. An artifact is
1367
- * reachable by one harness whether or not that harness is enabled here — a `.cursorrules` in a
1368
- * repository nobody opens in Cursor is still instruction content `AGENTS.md` does not carry — so
1369
- * filtering by the enabled set would hide exactly the drift worth converting.
1370
- */
1371
- function diagnoseNonstandard({ root, cli }) {
1372
- const findings = [];
1373
- for (const harness of harnessRegistry) for (const artifact of harness.project.nonstandard ?? []) for (const path of artifactFiles(root, artifact)) {
1374
- const kind = artifact.kind === "rule" && path.endsWith(".mdc") && !scopedByGlobs(readFileSync(join(root, path), "utf8")) ? "instructions" : artifact.kind;
1375
- const problem = problemOf[kind];
1376
- const { detail, repair } = repairFor(problem);
1377
- findings.push({
1378
- path,
1379
- problem,
1380
- detail,
1381
- repair: repair({ file: path }, cli)
1382
- });
1383
- }
1384
- return findings.sort((left, right) => left.path.localeCompare(right.path));
1385
- }
1386
- //#endregion
1387
- //#region src/skill-projection/skill-projection.ts
1388
- function skillNames(skillsDirectory) {
1389
- return readdirSync(skillsDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
1390
- }
1391
- function pathOccupied(path) {
1392
- try {
1393
- lstatSync(path);
1394
- return true;
1395
- } catch {
1396
- return false;
1397
- }
1398
- }
1399
- /**
1400
- * Whether `target` is a symbolic link that resolves to `canonical`. Resolved paths are compared
1401
- * rather than link text, so a link a user wrote as an absolute path, one this tool wrote as a
1402
- * Windows junction, and one reached through a symbolic link in a parent directory all read as the
1403
- * correct bridge. A link that no longer resolves is not.
1404
- */
1405
- function linksTo(target, canonical) {
1406
- try {
1407
- if (!lstatSync(target).isSymbolicLink()) return false;
1408
- return realpathSync(target) === realpathSync(canonical);
1409
- } catch {
1410
- return false;
1411
- }
1412
- }
1413
- /** Creates the canonical directory when absent, so a fresh repository reports zero skills. */
1414
- function countSkills(canonicalSkills) {
1415
- mkdirSync(canonicalSkills, { recursive: true });
1416
- return skillNames(canonicalSkills).length;
1417
- }
1418
- /**
1419
- * A junction is the only link Windows grants an unprivileged process, and Node resolves a relative
1420
- * junction target against the process directory rather than the link's own, so Windows is given the
1421
- * absolute path. Every other platform keeps the link relative, so it survives the repository moving.
1422
- */
1423
- function linkTarget(target, canonicalSkills) {
1424
- return platform() === "win32" ? resolve(canonicalSkills) : relative(dirname(target), canonicalSkills);
1425
- }
1426
- /**
1427
- * Translates what the option parser hands back into a selection. A valueless `--force` arrives as
1428
- * the literal string `true` — that is how the parser encodes a flag given no value — and means
1429
- * every conflicting target, which is what keeps an existing `init --force` doing what it always did.
1430
- * Anything else is a comma-separated list of targets.
1431
- */
1432
- function parseForce(value) {
1433
- if (value === "true") return true;
1434
- if (value === "false") return false;
1435
- return value.split(",").map((target) => target.trim()).filter((target) => target.length > 0);
1436
- }
1437
- /**
1438
- * Targets are matched against what the caller typed, resolved against the repository root, so the
1439
- * repo-relative form the conflict message prints is the form that selects it back — and an absolute
1440
- * path naming the same directory selects it too.
1441
- */
1442
- function namesTarget(root, target, given) {
1443
- return resolve(root, given) === resolve(target);
1444
- }
1445
- /**
1446
- * Points every harness that cannot read `.agents/skills` at the canonical directory. Symlinking is
1447
- * preferred; a platform that refuses the link falls back to a copy.
1448
- */
1449
- function projectSkills({ root, canonicalSkills, harnesses, copy, force }) {
1450
- const projections = harnesses.filter((harness) => harness.project.skillsDirectory).map((harness) => ({
1451
- harness,
1452
- target: join(root, harness.project.skillsDirectory)
1453
- }));
1454
- const named = Array.isArray(force) ? force : [];
1455
- const unknown = named.filter((given) => !projections.some(({ target }) => namesTarget(root, target, given)));
1456
- if (unknown.length) throw new Error(`No skill target matches:\n${unknown.map((given) => `- ${given}`).join("\n")}\nTargets for the enabled harnesses:\n${projections.map(({ target }) => `- ${relative(root, target)}`).join("\n")}`);
1457
- const forces = (target) => force === true || named.some((given) => namesTarget(root, target, given));
1458
- const blocked = projections.filter(({ target }) => pathOccupied(target) && !linksTo(target, canonicalSkills)).filter(({ target }) => !forces(target));
1459
- if (blocked.length && named.length === 0) throw new Error(`Refusing to replace existing skill targets:\n${blocked.map(({ target }) => `- ${relative(root, target)}`).join("\n")}`);
1460
- const skipped = new Set(blocked.map(({ harness }) => harness.name));
1461
- for (const { harness, target } of projections) {
1462
- if (skipped.has(harness.name)) continue;
1463
- mkdirSync(dirname(target), { recursive: true });
1464
- if (linksTo(target, canonicalSkills)) continue;
1465
- if (pathOccupied(target)) rmSync(target, {
1466
- recursive: true,
1467
- force: true
1468
- });
1469
- if (copy) {
1470
- cpSync(canonicalSkills, target, { recursive: true });
1471
- continue;
1472
- }
1473
- try {
1474
- symlinkSync(linkTarget(target, canonicalSkills), target, platform() === "win32" ? "junction" : void 0);
1475
- } catch (error) {
1476
- if (pathOccupied(target)) throw error;
1477
- cpSync(canonicalSkills, target, { recursive: true });
1478
- }
1479
- }
1480
- return {
1481
- linked: projections.filter(({ harness }) => !skipped.has(harness.name)).map(({ harness }) => harness.name),
1482
- skipped: projections.filter(({ harness }) => skipped.has(harness.name)).map(({ harness }) => harness.name)
1483
- };
1484
- }
1485
- //#endregion
1486
- //#region src/diagnose-bridges/agents-files.ts
1487
- /** Directories that never hold instructions this repository is responsible for bridging. */
1488
- const pruned = /* @__PURE__ */ new Set(["node_modules"]);
1489
- /**
1490
- * Every directory below `root` holding an `AGENTS.md`, repository-relative and POSIX-separated,
1491
- * with the root itself as `''`. Sorted, root first.
1492
- *
1493
- * Dot-directories are pruned along with `node_modules`. That is not only a speed measure:
1494
- * `.agents/AGENTS.md` is canonical shared instructions rather than a subtree-scoped file, and
1495
- * bridging it would claim a scope it does not have.
1496
- */
1497
- function agentsFileDirectories(root) {
1498
- const found = [];
1499
- const walk = (relative) => {
1500
- if (existsSync(join(root, relative, "AGENTS.md"))) found.push(relative);
1501
- let entries;
1502
- try {
1503
- entries = readdirSync(join(root, relative), { withFileTypes: true });
1504
- } catch {
1505
- return;
1506
- }
1507
- for (const entry of entries) {
1508
- if (!entry.isDirectory() || entry.name.startsWith(".") || pruned.has(entry.name)) continue;
1509
- walk(relative ? posix.join(relative, entry.name) : entry.name);
1510
- }
1511
- };
1512
- walk("");
1513
- return found.sort();
1514
- }
1515
- //#endregion
1516
- //#region src/diagnose-bridges/diagnose-instructions.ts
1517
- const canonicalInstructions = "AGENTS.md";
1518
- /**
1519
- * An import bridges the `AGENTS.md` beside it and nothing else, so the check is per directory
1520
- * holding one rather than per harness. A line of its own is enough: the harness reference allows
1521
- * Claude-specific notes below the import, and a file that carries the import plus notes still
1522
- * bridges.
1523
- */
1524
- function inspectImport(target) {
1525
- let stats;
1526
- try {
1527
- stats = lstatSync(target);
1528
- } catch {
1529
- return {
1530
- kind: "none",
1531
- status: "missing",
1532
- problem: "instructions-missing"
1533
- };
1534
- }
1535
- if (stats.isSymbolicLink()) return readlinkSync(target) === "AGENTS.md" ? {
1536
- kind: "symlink",
1537
- status: "ok"
1538
- } : {
1539
- kind: "symlink",
1540
- status: "unbridged",
1541
- problem: "instructions-unbridged"
1542
- };
1543
- return readFileSync(target, "utf8").split("\n").some((line) => line.trim() === `@AGENTS.md`) ? {
1544
- kind: "import",
1545
- status: "ok"
1546
- } : {
1547
- kind: "file",
1548
- status: "unbridged",
1549
- problem: "instructions-unbridged"
1550
- };
1551
- }
1552
- /** Reads a dotted key out of parsed JSON without asserting anything about the rest of the file. */
1553
- function valueAt(settings, key) {
1554
- return key.split(".").reduce((value, segment) => {
1555
- if (typeof value !== "object" || value === null) return void 0;
1556
- return value[segment];
1557
- }, settings);
1558
- }
1559
- function inspectSettingsEntry(target, key) {
1560
- let source;
1561
- try {
1562
- source = readFileSync(target, "utf8");
1563
- } catch {
1564
- return {
1565
- kind: "none",
1566
- status: "missing",
1567
- problem: "instructions-missing"
1568
- };
1569
- }
1570
- const settings = parseJsonWithComments(source);
1571
- if (settings === void 0) return {
1572
- kind: "file",
1573
- status: "unreadable",
1574
- problem: "instructions-unreadable"
1575
- };
1576
- const entry = valueAt(settings, key);
1577
- return Array.isArray(entry) && entry.includes("AGENTS.md") ? {
1578
- kind: "settings-entry",
1579
- status: "ok"
1580
- } : {
1581
- kind: "file",
1582
- status: "unbridged",
1583
- problem: "instructions-unbridged"
1584
- };
1585
- }
1586
- /** Every path an instruction bridge of this kind belongs at, in report order. */
1587
- function pathsFor(root, bridge) {
1588
- if (bridge.kind !== "import") return [bridge.path];
1589
- return agentsFileDirectories(root).map((directory) => directory ? posix.join(directory, bridge.path) : bridge.path);
1590
- }
1591
- function inspect$1(target, bridge) {
1592
- return bridge.kind === "import" ? inspectImport(target) : inspectSettingsEntry(target, bridge.key);
1593
- }
1594
- /**
1595
- * Reports whether every enabled harness can still read `AGENTS.md`.
1596
- *
1597
- * Losing one of these is quieter than losing a skills bridge and costs more: a harness with no
1598
- * instruction bridge reads none of the repository's instructions, and says nothing about it. The
1599
- * checks are read-only, and no repair is a command — rewriting an instruction file is the `init`
1600
- * skill's judgment.
1601
- */
1602
- function diagnoseInstructions(root, harnesses, cli) {
1603
- const bridged = harnesses.map((harness) => ({
1604
- name: harness.name,
1605
- bridge: harness.project.instructionBridge
1606
- })).filter((harness) => Boolean(harness.bridge));
1607
- const instructions = [];
1608
- const findings = [];
1609
- if (!bridged.length) return {
1610
- instructions,
1611
- findings
1612
- };
1613
- if (!agentsFileDirectories(root).includes("")) {
1614
- const { detail, repair } = repairFor("no-instructions");
1615
- findings.push({
1616
- path: canonicalInstructions,
1617
- problem: "no-instructions",
1618
- detail,
1619
- repair: repair({ file: canonicalInstructions }, cli)
1620
- });
1621
- }
1622
- for (const { name, bridge } of bridged) for (const path of pathsFor(root, bridge)) {
1623
- const inspection = inspect$1(join(root, path), bridge);
1624
- instructions.push({
1625
- harness: name,
1626
- path,
1627
- kind: inspection.kind,
1628
- status: inspection.status
1629
- });
1630
- if (inspection.problem) {
1631
- const { detail, repair } = repairFor(inspection.problem);
1632
- findings.push({
1633
- path,
1634
- problem: inspection.problem,
1635
- detail,
1636
- repair: repair({ file: path }, cli)
1637
- });
1638
- }
1639
- }
1640
- return {
1641
- instructions,
1642
- findings
1643
- };
1644
- }
1645
- //#endregion
1646
- //#region src/diagnose-bridges/git-bridge-state.ts
1647
- function git(root, args, input) {
1648
- try {
1649
- return execFileSync("git", args, {
1650
- cwd: root,
1651
- encoding: "utf8",
1652
- ...input === void 0 ? {} : { input },
1653
- stdio: [
1654
- "pipe",
1655
- "pipe",
1656
- "pipe"
1657
- ]
1658
- });
1659
- } catch {
1660
- return;
1661
- }
1662
- }
1663
- function lines(output) {
1664
- return (output ?? "").split("\n").filter(Boolean);
1665
- }
1666
- /**
1667
- * Reads git state for the two sides of a bridge. Every method degrades to "cannot tell" rather than
1668
- * throwing, so `doctor` still reports on a tarball, a worktree without git, or a repository with no
1669
- * commits.
1670
- */
1671
- var GitBridgeState = class {
1672
- root;
1673
- /** Path of `root` relative to the repository root, `''` at the top, `undefined` outside a repository. */
1674
- prefix;
1675
- constructor(root) {
1676
- this.root = root;
1677
- this.prefix = git(root, ["rev-parse", "--show-prefix"])?.trim();
1678
- }
1679
- /**
1680
- * `init --copy` marks a bridge skip-worktree so a machine-local copy over a tracked symlink stops
1681
- * showing as a pending change. The bit is a hint that some checkouts clear, so it is verified
1682
- * rather than assumed.
1683
- */
1684
- trackingOf(path) {
1685
- if (this.prefix === void 0) return "untracked";
1686
- const entries = lines(git(this.root, [
1687
- "ls-files",
1688
- "-v",
1689
- "--",
1690
- path
1691
- ]));
1692
- if (!entries.length) return "untracked";
1693
- return entries.every((entry) => entry.startsWith("S")) ? "skip-worktree" : "tracked";
1694
- }
1695
- /**
1696
- * Whether a `.gitignore` rule matches this path. Asked of git rather than matched against the
1697
- * file, so a rule on a parent directory — the common way a bridge gets ignored — is caught.
1698
- * Outside a repository nothing is ignored, which is the same "cannot tell" degradation the rest
1699
- * of this class uses.
1700
- */
1701
- isIgnored(path) {
1702
- if (this.prefix === void 0) return false;
1703
- return git(this.root, [
1704
- "check-ignore",
1705
- "-q",
1706
- "--",
1707
- path
1708
- ]) !== void 0;
1709
- }
1710
- /**
1711
- * The newest commits that touched any of these paths, newest first. Empty outside a repository,
1712
- * which is the same "cannot tell" degradation as everything else here.
1713
- *
1714
- * Bounded like `lastAgreedTree` is: a baseline that is not in the recent history of the two files
1715
- * is not a baseline anyone can act on, and walking a whole repository to prove its absence costs
1716
- * more than the answer is worth.
1717
- */
1718
- commitsTouching(paths) {
1719
- if (this.prefix === void 0) return [];
1720
- return lines(git(this.root, [
1721
- "rev-list",
1722
- "-n",
1723
- "200",
1724
- "HEAD",
1725
- "--",
1726
- ...paths
1727
- ]));
1728
- }
1729
- /** One file's content at one commit, or `undefined` when it did not exist there. */
1730
- contentAt(commit, path) {
1731
- if (this.prefix === void 0) return void 0;
1732
- return git(this.root, ["show", `${commit}:${this.prefix}${path}`]);
1733
- }
1734
- /**
1735
- * Names the side that moved by finding the newest commit whose two trees agreed, then asking which
1736
- * of the working directories still matches it.
1737
- */
1738
- directionOf(bridge, canonical) {
1739
- if (this.prefix === void 0) return "unknown";
1740
- const base = this.lastAgreedTree(bridge, canonical);
1741
- if (base === void 0) return "unknown";
1742
- const bridgeMoved = !this.matchesTree(bridge, base);
1743
- const canonicalMoved = !this.matchesTree(canonical, base);
1744
- if (bridgeMoved && canonicalMoved) return "both";
1745
- if (bridgeMoved) return "bridge";
1746
- return "canonical";
1747
- }
1748
- /** The tree of the newest commit where both paths held identical content. */
1749
- lastAgreedTree(bridge, canonical) {
1750
- for (const commit of lines(git(this.root, [
1751
- "rev-list",
1752
- "-n",
1753
- "200",
1754
- "HEAD",
1755
- "--",
1756
- bridge,
1757
- canonical
1758
- ]))) {
1759
- const bridgeObject = git(this.root, [
1760
- "rev-parse",
1761
- "-q",
1762
- "--verify",
1763
- `${commit}:${this.prefix}${bridge}`
1764
- ])?.trim();
1765
- if (!bridgeObject) continue;
1766
- if (bridgeObject === git(this.root, [
1767
- "rev-parse",
1768
- "-q",
1769
- "--verify",
1770
- `${commit}:${this.prefix}${canonical}`
1771
- ])?.trim()) return bridgeObject;
1772
- }
1773
- }
1774
- /**
1775
- * Compares a working directory against a git tree by object id. `hash-object` is used rather than
1776
- * hashing in-process so the comparison follows whatever object format the repository uses.
1777
- */
1778
- matchesTree(directory, tree) {
1779
- const expected = new Map(lines(git(this.root, [
1780
- "ls-tree",
1781
- "-r",
1782
- tree
1783
- ])).map((entry) => {
1784
- const [meta, path] = entry.split(" ");
1785
- return [path, meta.split(" ")[2]];
1786
- }));
1787
- const files = filesUnder(join(this.root, directory));
1788
- if (files.length !== expected.size || files.some((file) => !expected.has(file))) return false;
1789
- const hashed = lines(git(this.root, ["hash-object", "--stdin-paths"], files.map((file) => join(directory, file)).join("\n")));
1790
- return hashed.length === files.length && files.every((file, index) => expected.get(file) === hashed[index]);
1791
- }
1792
- };
1793
- //#endregion
1794
- //#region src/diagnose-bridges/diagnose-bridges.ts
1795
- const canonicalPath = ".agents/skills";
1796
- function pathState(target) {
1797
- try {
1798
- const stats = lstatSync(target);
1799
- if (stats.isSymbolicLink()) return "symlink";
1800
- return stats.isDirectory() ? "directory" : "file";
1801
- } catch {
1802
- return "missing";
1803
- }
1804
- }
1805
- function isDirectory(path) {
1806
- try {
1807
- return statSync(path).isDirectory();
1808
- } catch {
1809
- return false;
1810
- }
1811
- }
1812
- function sameContent(left, right) {
1813
- const leftFiles = filesUnder(left);
1814
- const rightFiles = filesUnder(right);
1815
- return leftFiles.length === rightFiles.length && leftFiles.every((file, index) => rightFiles[index] === file && readFileSync(join(left, file)).equals(readFileSync(join(right, file))));
1816
- }
1817
- function inspect(target, path, canonical, git) {
1818
- switch (pathState(target)) {
1819
- case "missing": return {
1820
- kind: "none",
1821
- status: "missing",
1822
- problem: "missing"
1823
- };
1824
- case "file": return {
1825
- kind: "file",
1826
- status: "degraded",
1827
- problem: "degraded"
1828
- };
1829
- case "symlink": return linksTo(target, canonical) ? {
1830
- kind: "symlink",
1831
- status: "ok"
1832
- } : {
1833
- kind: "symlink",
1834
- status: "stale",
1835
- problem: "stale"
1836
- };
1837
- default:
1838
- if (!sameContent(target, canonical)) {
1839
- const direction = git.directionOf(path, canonicalPath);
1840
- return {
1841
- kind: "copy",
1842
- status: "diverged",
1843
- problem: `diverged-${direction}`,
1844
- direction
1845
- };
1846
- }
1847
- return git.trackingOf(path) === "tracked" ? {
1848
- kind: "copy",
1849
- status: "ok",
1850
- problem: "unpinned-copy"
1851
- } : {
1852
- kind: "copy",
1853
- status: "ok"
1854
- };
1855
- }
1856
- }
1857
- /**
1858
- * Reports whether every bridge this repository needs still resolves: the skills bridges into
1859
- * `.agents/skills`, and the instruction bridges into `AGENTS.md`. Read-only: nothing is created,
1860
- * moved, or repaired, so the caller decides what to run from the repair each finding carries.
1861
- */
1862
- function diagnoseBridges({ root, harnesses: preferred = [], cli }) {
1863
- const canonical = join(root, ".agents", "skills");
1864
- const git = new GitBridgeState(root);
1865
- const selected = selectHarnesses(root, preferred);
1866
- const bridged = selected.map((harness) => ({
1867
- name: harness.name,
1868
- skillsDirectory: harness.project.skillsDirectory
1869
- })).filter((harness) => Boolean(harness.skillsDirectory));
1870
- const bridges = [];
1871
- const divergence = [];
1872
- const findings = [];
1873
- if (!isDirectory(canonical)) {
1874
- const { detail, repair } = repairFor("no-canonical");
1875
- findings.push({
1876
- path: canonicalPath,
1877
- problem: "no-canonical",
1878
- detail,
1879
- repair: repair({ file: canonicalPath }, cli)
1880
- });
1881
- }
1882
- for (const harness of bridged) {
1883
- const path = harness.skillsDirectory;
1884
- const inspection = inspect(join(root, path), path, canonical, git);
1885
- bridges.push({
1886
- harness: harness.name,
1887
- path,
1888
- kind: inspection.kind,
1889
- status: inspection.status
1890
- });
1891
- if (inspection.direction) divergence.push({
1892
- path,
1893
- direction: inspection.direction
1894
- });
1895
- if (inspection.problem) {
1896
- const { detail, repair } = repairFor(inspection.problem);
1897
- findings.push({
1898
- path,
1899
- problem: inspection.problem,
1900
- detail,
1901
- repair: repair({ file: path }, cli)
1902
- });
1903
- }
1904
- }
1905
- const instructions = diagnoseInstructions(root, selected, cli);
1906
- findings.push(...instructions.findings);
1907
- return {
1908
- bridges,
1909
- instructions: instructions.instructions,
1910
- divergence,
1911
- findings
1912
- };
1913
- }
1914
- //#endregion
1915
- //#region src/diagnose-bridges/doctor.command.ts
1916
- /**
1917
- * AXI §5: the healthy answer states the zero with context, so an agent does not re-run with other
1918
- * flags to confirm that an empty section really meant "nothing wrong".
1919
- */
1920
- function buildDoctorReport(bin, result, configuration = []) {
1921
- const findings = [...result.findings, ...configuration];
1922
- if (!findings.length) {
1923
- const count = result.bridges.length + result.instructions.length;
1924
- const bridges = count === 1 ? "the 1 bridge resolves" : `all ${count} bridges resolve`;
1925
- return {
1926
- bin,
1927
- bridges: result.bridges,
1928
- instructions: result.instructions,
1929
- findings: `0 problems found — ${bridges} and the configuration around them is current`
1930
- };
1931
- }
1932
- return {
1933
- bin,
1934
- bridges: result.bridges,
1935
- instructions: result.instructions,
1936
- ...result.divergence.length ? { divergence: result.divergence } : {},
1937
- findings: findings.map(({ path, problem, detail }) => ({
1938
- path,
1939
- problem,
1940
- detail
1941
- })),
1942
- help: [...new Map(findings.map((finding) => [`${finding.repair.command}\u0000${finding.repair.instruction}`, finding.repair])).values()]
1943
- };
1944
- }
1945
- const doctorCommand = command({
1946
- name: "doctor",
1947
- description: "Report whether this repository's harness bridges still resolve — skills into .agents/skills, instructions into AGENTS.md. Read-only.",
1948
- options: {
1949
- root: {
1950
- description: "Repository or package directory. Defaults to the current directory.",
1951
- type: z.optional(z.string())
1952
- },
1953
- harness: {
1954
- description: "Comma-separated harnesses to check in addition to Claude Code and Cursor, e.g. codex,gemini-cli.",
1955
- type: z.optional(z.string())
1956
- },
1957
- format: {
1958
- description: "Output format: toon (default), json, or text for a human-readable report.",
1959
- type: z.optional(z.string()),
1960
- default: "toon"
1961
- }
1962
- },
1963
- run(args) {
1964
- try {
1965
- const format = parseFormat(args.format);
1966
- const harnesses = parseHarnesses(args.harness);
1967
- const root = args.root ?? process.cwd();
1968
- const result = diagnoseBridges({
1969
- root,
1970
- ...harnesses.length ? { harnesses } : {},
1971
- cli: commandInvocation
1972
- });
1973
- const git = new GitBridgeState(root);
1974
- const configuration = [
1975
- ...diagnoseConfiguration({
1976
- root,
1977
- git,
1978
- cli: commandInvocation
1979
- }),
1980
- ...diagnoseMcp({
1981
- root,
1982
- git,
1983
- cli: commandInvocation
1984
- }),
1985
- ...diagnoseNonstandard({
1986
- root,
1987
- cli: commandInvocation
1988
- })
1989
- ];
1990
- writeResult(buildDoctorReport(binPath(homedir(), process.argv[1]), result, configuration), format);
1991
- return exitCodes.success;
1992
- } catch (error) {
1993
- process.stderr.write(`error: ${error instanceof Error ? error.message : "Harness diagnosis failed."}\n`);
1994
- return exitCodes.error;
1995
- }
1996
- }
1997
- });
1998
- //#endregion
1999
- //#region src/initialize-harnesses/initialize-harnesses.ts
2000
- function initializeHarnesses({ root, harnesses: preferred = [], copy = false, force = false }) {
2001
- const canonicalSkills = join(root, ".agents", "skills");
2002
- const skills = countSkills(canonicalSkills);
2003
- const harnesses = selectHarnesses(root, preferred);
2004
- const { linked, skipped } = projectSkills({
2005
- root,
2006
- canonicalSkills,
2007
- harnesses,
2008
- copy,
2009
- force
2010
- });
2011
- return {
2012
- root,
2013
- harnesses: harnesses.map((harness) => harness.name),
2014
- native: harnesses.filter((harness) => !harness.project.skillsDirectory).map((harness) => harness.name),
2015
- linked,
2016
- skipped,
2017
- deprecated: harnesses.filter((harness) => harness.deprecated).map((harness) => ({
2018
- name: harness.name,
2019
- replacedBy: harness.deprecated
2020
- })),
2021
- skills,
2022
- copied: copy
2023
- };
2024
- }
2025
- //#endregion
2026
- //#region src/initialize-harnesses/init.command.ts
2027
- const initCommand = command({
2028
- name: "init",
2029
- description: "Set up canonical agent skills for the harnesses already enabled in a repository.",
2030
- options: {
2031
- root: {
2032
- description: "Repository or package directory. Defaults to the current directory.",
2033
- type: z.optional(z.string())
2034
- },
2035
- harness: {
2036
- description: "Comma-separated harnesses to enable in addition to Claude Code and Cursor, e.g. codex,gemini-cli.",
2037
- type: z.optional(z.string())
2038
- },
2039
- copy: {
2040
- description: "Copy skills instead of linking them.",
2041
- type: z.optional(z.boolean())
2042
- },
2043
- force: {
2044
- description: "Replace conflicting target skill directories. Names the targets to replace, comma-separated (e.g. .claude/skills); given alone it replaces every conflicting target.",
2045
- type: z.optional(z.string())
2046
- },
2047
- format: {
2048
- description: "Output format: toon (default), json, or text for a human-readable report.",
2049
- type: z.optional(z.string()),
2050
- default: "toon"
2051
- }
2052
- },
2053
- run(args) {
2054
- try {
2055
- const format = parseFormat(args.format);
2056
- const harnesses = parseHarnesses(args.harness);
2057
- writeResult(initializeHarnesses({
2058
- root: args.root ?? process.cwd(),
2059
- ...harnesses.length ? { harnesses } : {},
2060
- ...args.copy === void 0 ? {} : { copy: args.copy },
2061
- ...args.force === void 0 ? {} : { force: parseForce(args.force) }
2062
- }), format);
2063
- return exitCodes.success;
2064
- } catch (error) {
2065
- process.stderr.write(`error: ${error instanceof Error ? error.message : "Harness initialization failed."}\n`);
2066
- return exitCodes.error;
2067
- }
2068
- }
2069
- });
2070
- const harnessCommand = command({
2071
- name: "agent-harness",
2072
- description: "Commands for configuring agent harness compatibility.",
2073
- commands: [initCommand, doctorCommand]
2074
- });
2075
- function activate({ addCommand }) {
2076
- addCommand(harnessCommand);
2077
- }
2078
- //#endregion
2079
- //#region src/cli.ts
2080
- /**
2081
- * Read from the manifest rather than restated here: a version written into this call is a second
2082
- * home for a number that already has one, and it stayed at `0.1.0` for five minor releases.
2083
- *
2084
- * `../package.json` resolves from `src/cli.ts` and from the bundled `dist/cli.mjs` alike — both sit
2085
- * one directory under the package root.
2086
- */
2087
- const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
2088
- /**
2089
- * A factory rather than a module-level constant: `cli()` builds state, and state built at import
2090
- * time is shared by every later call in the process.
2091
- */
2092
- function app() {
2093
- return cli({
2094
- name: "buddy-agent-harness",
2095
- version,
2096
- description: "Initialize agent harness skill compatibility in consumer repositories."
2097
- }).command(initCommand).command(doctorCommand);
2098
- }
2099
- /**
2100
- * The application boundary: argv in, exit code out, nothing read from or written to the process.
2101
- *
2102
- * The exit code is returned rather than written so that a caller which is not the process — a skill
2103
- * launcher, a test — can run a command and learn how it went. `bin/buddy-agent-harness.mjs` is the
2104
- * only place that turns the returned code back into a process outcome.
2105
- *
2106
- * One exit path this return cannot carry: `clibuilder` answers an unknown option or an unknown
2107
- * command by printing help and writing `process.exitCode` itself, returning nothing. `run` reports
2108
- * `0` there, which is why `bin` and the launchers apply the returned code only when it is non-zero.
2109
- */
2110
- async function run(argv) {
2111
- try {
2112
- const code = await app().parse(argv);
2113
- return typeof code === "number" ? code : exitCodes.success;
2114
- } catch (error) {
2115
- process.stderr.write(`error: ${error instanceof Error ? error.message : "Invalid command."}\n`);
2116
- return exitCodes.usage;
2117
- }
2118
- }
2119
- //#endregion
2120
- export { harnessRegistry as _, initializeHarnesses as a, diagnoseBridges as c, goldenSetPath as d, bridgeRepairs as f, renderDoctorSkill as g, instructionRepairs as h, initCommand as i, diagnoseInstructions as l, doctorSkill as m, activate as n, buildDoctorReport as o, doctorRepairs as p, harnessCommand as r, doctorCommand as s, run as t, diagnoseMcp as u };
2121
-
2122
- //# sourceMappingURL=cli-Bvnq8zOL.mjs.map