buddy-agent-harness 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/bin/buddy-agent-harness.mjs +8 -3
  5. package/dist/cli-Bvnq8zOL.mjs +2122 -0
  6. package/dist/cli-Bvnq8zOL.mjs.map +1 -0
  7. package/dist/cli.d.mts +13 -2
  8. package/dist/cli.d.mts.map +1 -1
  9. package/dist/cli.mjs +2 -20
  10. package/dist/index.d.mts +491 -40
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -2
  13. package/package.json +3 -2
  14. package/plugin.json +1 -1
  15. package/skills/doctor/SKILL.md +27 -23
  16. package/skills/doctor/references/bridges.md +23 -0
  17. package/skills/doctor/references/configuration.md +12 -0
  18. package/skills/doctor/references/harnesses/claude-code.md +39 -0
  19. package/skills/doctor/references/harnesses/codex.md +37 -0
  20. package/skills/doctor/references/harnesses/copilot-cli.md +39 -0
  21. package/skills/doctor/references/harnesses/cursor.md +40 -0
  22. package/skills/doctor/references/harnesses/devin-desktop.md +20 -0
  23. package/skills/doctor/references/harnesses/gemini-cli.md +38 -0
  24. package/skills/doctor/references/harnesses/windsurf.md +27 -0
  25. package/skills/doctor/references/instructions.md +14 -0
  26. package/skills/doctor/references/mcp.md +31 -0
  27. package/skills/doctor/references/nonstandard.md +27 -0
  28. package/skills/doctor/scripts/doctor.mjs +7 -3
  29. package/skills/enhance/SKILL.md +4 -0
  30. package/skills/init/README.md +1 -1
  31. package/skills/init/SKILL.md +24 -10
  32. package/skills/init/references/detection.md +5 -3
  33. package/skills/init/references/harnesses/claude-code.md +2 -0
  34. package/skills/init/references/harnesses/cursor.md +9 -2
  35. package/skills/init/references/harnesses/gemini-cli.md +6 -9
  36. package/skills/init/scripts/doctor.mjs +14 -0
  37. package/skills/init/scripts/init.mjs +7 -3
  38. package/skills/repair/README.md +21 -0
  39. package/skills/repair/SKILL.md +74 -0
  40. package/skills/repair/references/classes.md +49 -0
  41. package/skills/repair/scripts/doctor.mjs +14 -0
  42. package/src/cli.ts +40 -7
  43. package/src/command-output/command-output.ts +1 -4
  44. package/src/diagnose-bridges/agents-files.ts +34 -0
  45. package/src/diagnose-bridges/diagnose-bridges.ts +39 -21
  46. package/src/diagnose-bridges/diagnose-instructions.ts +145 -0
  47. package/src/diagnose-bridges/doctor-guidance.ts +683 -69
  48. package/src/diagnose-bridges/doctor.command.ts +63 -30
  49. package/src/diagnose-bridges/git-bridge-state.ts +30 -0
  50. package/src/diagnose-bridges/json-with-comments.ts +60 -0
  51. package/src/diagnose-bridges/locator.ts +36 -0
  52. package/src/diagnose-configuration/diagnose-configuration.ts +99 -0
  53. package/src/diagnose-mcp/diagnose-mcp.ts +135 -0
  54. package/src/diagnose-mcp/mcp-baseline.ts +122 -0
  55. package/src/diagnose-mcp/mcp-model.ts +88 -0
  56. package/src/diagnose-mcp/mcp-secrets.ts +101 -0
  57. package/src/diagnose-mcp/mcp-sources.ts +131 -0
  58. package/src/diagnose-nonstandard/diagnose-nonstandard.ts +109 -0
  59. package/src/harness-registry/harness-registry.ts +164 -14
  60. package/src/harness-registry/instruction-bridge.ts +31 -0
  61. package/src/harness-registry/mcp-config.ts +37 -0
  62. package/src/harness-registry/nonstandard-artifact.ts +39 -0
  63. package/src/index.ts +33 -4
  64. package/src/initialize-harnesses/init.command.ts +13 -18
  65. package/src/initialize-harnesses/initialize-harnesses.ts +7 -4
  66. package/src/is-record/is-record.ts +12 -0
  67. package/src/skill-projection/skill-projection.ts +104 -18
  68. package/dist/cli.mjs.map +0 -1
  69. package/dist/init.command-c0fBv15T.mjs +0 -714
  70. package/dist/init.command-c0fBv15T.mjs.map +0 -1
@@ -1,47 +1,80 @@
1
1
  import { homedir } from 'node:os'
2
2
  import type { cli } from 'clibuilder'
3
- import { command, z } from 'clibuilder'
3
+ import { command, exitCodes, z } from 'clibuilder'
4
4
  import { binPath, parseFormat, writeResult } from '../command-output/command-output.ts'
5
- import { type HarnessName, harnessRegistry } from '../harness-registry/harness-registry.ts'
5
+ import { type ConfigurationFinding, diagnoseConfiguration } from '../diagnose-configuration/diagnose-configuration.ts'
6
+ import { diagnoseMcp } from '../diagnose-mcp/diagnose-mcp.ts'
7
+ import { diagnoseNonstandard } from '../diagnose-nonstandard/diagnose-nonstandard.ts'
8
+ import { parseHarnesses } from '../harness-registry/harness-registry.ts'
6
9
  import { type DiagnoseResult, diagnoseBridges } from './diagnose-bridges.ts'
7
- import { commandInvocation } from './doctor-guidance.ts'
10
+ import { commandInvocation, type DoctorProblem, type RepairAction } from './doctor-guidance.ts'
11
+ import { GitBridgeState } from './git-bridge-state.ts'
8
12
 
9
- type DoctorReport = {
13
+ export type DoctorReport = {
10
14
  bin: string
11
15
  bridges: DiagnoseResult['bridges']
16
+ instructions: DiagnoseResult['instructions']
12
17
  divergence?: DiagnoseResult['divergence']
13
- /** The repair is lifted out into `help`, so a finding row stays to the diagnosis itself. */
14
- findings: Omit<DiagnoseResult['findings'][number], 'repair'>[] | string
15
- help?: string[]
18
+ /**
19
+ * The repair is lifted out into `help`, so a finding row stays to the diagnosis itself. `problem`
20
+ * stays on the row: it is how a caller routes without parsing `detail` prose.
21
+ */
22
+ findings: { path: string; problem: DoctorProblem; detail: string }[] | string
23
+ /**
24
+ * One entry per distinct repair. Two fields rather than a sentence, because the caller has to
25
+ * tell an executable repair from an instruction, and `RepairAction` is where that lives.
26
+ *
27
+ * Both keys are always emitted, `command` as an empty string when there is none. An optional key
28
+ * would drop the whole array out of TOON's tabular form into the nested list form — worse for
29
+ * exactly the consumer the default format exists for.
30
+ */
31
+ help?: RepairAction[]
16
32
  }
17
33
 
18
34
  /**
19
35
  * AXI §5: the healthy answer states the zero with context, so an agent does not re-run with other
20
36
  * flags to confirm that an empty section really meant "nothing wrong".
21
37
  */
22
- export function buildReport(bin: string, result: DiagnoseResult): DoctorReport {
23
- if (!result.findings.length) {
24
- const count = result.bridges.length
38
+ export function buildDoctorReport(
39
+ bin: string,
40
+ result: DiagnoseResult,
41
+ configuration: ConfigurationFinding[] = [],
42
+ ): DoctorReport {
43
+ const findings = [...result.findings, ...configuration]
44
+ // Both sections are bridges, so the healthy line counts them together rather than making a reader
45
+ // add up two numbers to learn that nothing is wrong.
46
+ if (!findings.length) {
47
+ const count = result.bridges.length + result.instructions.length
48
+ const bridges = count === 1 ? 'the 1 bridge resolves' : `all ${count} bridges resolve`
25
49
  return {
26
50
  bin,
27
51
  bridges: result.bridges,
28
- findings:
29
- count === 1 ? '0 problems found — the 1 bridge resolves' : `0 problems found — all ${count} bridges resolve`,
52
+ instructions: result.instructions,
53
+ findings: `0 problems found — ${bridges} and the configuration around them is current`,
30
54
  }
31
55
  }
32
56
 
33
57
  return {
34
58
  bin,
35
59
  bridges: result.bridges,
60
+ instructions: result.instructions,
36
61
  ...(result.divergence.length ? { divergence: result.divergence } : {}),
37
- findings: result.findings.map(({ path, detail }) => ({ path, detail })),
38
- help: [...new Set(result.findings.map((finding) => `Run \`${finding.repair}\``))],
62
+ findings: findings.map(({ path, problem, detail }) => ({ path, problem, detail })),
63
+ // Deduped on the whole pair: several findings often share one repair, and repeating it reads as
64
+ // more work than there is. Nothing wraps a repair — a `Run …` around every one of them is what
65
+ // this report used to do, and most repairs are not commands.
66
+ help: [
67
+ ...new Map(
68
+ findings.map((finding) => [`${finding.repair.command}\u0000${finding.repair.instruction}`, finding.repair]),
69
+ ).values(),
70
+ ],
39
71
  }
40
72
  }
41
73
 
42
74
  export const doctorCommand: cli.Command = command({
43
75
  name: 'doctor',
44
- description: 'Report whether the harness skill bridges in this repository still resolve. Read-only.',
76
+ description:
77
+ "Report whether this repository's harness bridges still resolve — skills into .agents/skills, instructions into AGENTS.md. Read-only.",
45
78
  options: {
46
79
  root: {
47
80
  description: 'Repository or package directory. Defaults to the current directory.',
@@ -60,28 +93,28 @@ export const doctorCommand: cli.Command = command({
60
93
  run(args) {
61
94
  try {
62
95
  const format = parseFormat(args.format)
63
- const requested = args.harness
64
- ?.split(',')
65
- .map((name) => name.trim())
66
- .filter(Boolean)
67
- const unsupported = requested?.filter((name) => !harnessRegistry.some((harness) => harness.name === name))
68
- if (unsupported?.length)
69
- throw new Error(
70
- `Unsupported harness: ${unsupported.join(', ')}. Supported: ${harnessRegistry
71
- .map((harness) => harness.name)
72
- .join(', ')}.`,
73
- )
96
+ const harnesses = parseHarnesses(args.harness)
97
+ const root = args.root ?? process.cwd()
74
98
  const result = diagnoseBridges({
75
- root: args.root ?? process.cwd(),
76
- ...(requested?.length ? { harnesses: requested as HarnessName[] } : {}),
99
+ root,
100
+ ...(harnesses.length ? { harnesses } : {}),
77
101
  cli: commandInvocation,
78
102
  })
103
+ const git = new GitBridgeState(root)
104
+ const configuration = [
105
+ ...diagnoseConfiguration({ root, git, cli: commandInvocation }),
106
+ ...diagnoseMcp({ root, git, cli: commandInvocation }),
107
+ ...diagnoseNonstandard({ root, cli: commandInvocation }),
108
+ ]
79
109
  // Exit stays 0 even with findings: the diagnosis succeeded, and a non-zero code reads to an
80
110
  // agent as "this command is broken, try something else".
81
- writeResult(buildReport(binPath(homedir(), process.argv[1]), result), format)
111
+ writeResult(buildDoctorReport(binPath(homedir(), process.argv[1]), result, configuration), format)
112
+ return exitCodes.success
82
113
  } catch (error) {
83
114
  process.stderr.write(`error: ${error instanceof Error ? error.message : 'Harness diagnosis failed.'}\n`)
84
- process.exitCode = 1
115
+ // Returned, not written: a command that writes the code reports its failure past `run`
116
+ // rather than to it, leaving a caller that is not the process no way to learn of it.
117
+ return exitCodes.error
85
118
  }
86
119
  },
87
120
  })
@@ -50,6 +50,36 @@ export class GitBridgeState {
50
50
  return entries.every((entry) => entry.startsWith('S')) ? 'skip-worktree' : 'tracked'
51
51
  }
52
52
 
53
+ /**
54
+ * Whether a `.gitignore` rule matches this path. Asked of git rather than matched against the
55
+ * file, so a rule on a parent directory — the common way a bridge gets ignored — is caught.
56
+ * Outside a repository nothing is ignored, which is the same "cannot tell" degradation the rest
57
+ * of this class uses.
58
+ */
59
+ isIgnored(path: string): boolean {
60
+ if (this.prefix === undefined) return false
61
+ return git(this.root, ['check-ignore', '-q', '--', path]) !== undefined
62
+ }
63
+
64
+ /**
65
+ * The newest commits that touched any of these paths, newest first. Empty outside a repository,
66
+ * which is the same "cannot tell" degradation as everything else here.
67
+ *
68
+ * Bounded like `lastAgreedTree` is: a baseline that is not in the recent history of the two files
69
+ * is not a baseline anyone can act on, and walking a whole repository to prove its absence costs
70
+ * more than the answer is worth.
71
+ */
72
+ commitsTouching(paths: readonly string[]): string[] {
73
+ if (this.prefix === undefined) return []
74
+ return lines(git(this.root, ['rev-list', '-n', '200', 'HEAD', '--', ...paths]))
75
+ }
76
+
77
+ /** One file's content at one commit, or `undefined` when it did not exist there. */
78
+ contentAt(commit: string, path: string): string | undefined {
79
+ if (this.prefix === undefined) return undefined
80
+ return git(this.root, ['show', `${commit}:${this.prefix}${path}`])
81
+ }
82
+
53
83
  /**
54
84
  * Names the side that moved by finding the newest commit whose two trees agreed, then asking which
55
85
  * of the working directories still matches it.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `.gemini/settings.json` may legally carry comments — the Gemini CLI loader strips them before
3
+ * parsing. A plain `JSON.parse` of a valid settings file therefore throws, and `doctor` would report
4
+ * a working bridge as unreadable. Comments are removed before parsing for that reason.
5
+ *
6
+ * Only comments. A trailing comma stays a parse error, because nothing documents it as accepted.
7
+ */
8
+ export function stripJsonComments(source: string): string {
9
+ let output = ''
10
+ let index = 0
11
+ let inString = false
12
+ let escaped = false
13
+
14
+ while (index < source.length) {
15
+ const character = source[index] as string
16
+
17
+ if (inString) {
18
+ output += character
19
+ if (escaped) escaped = false
20
+ else if (character === '\\') escaped = true
21
+ else if (character === '"') inString = false
22
+ index += 1
23
+ continue
24
+ }
25
+
26
+ if (character === '"') {
27
+ inString = true
28
+ output += character
29
+ index += 1
30
+ continue
31
+ }
32
+
33
+ // A `//` inside a string is an ordinary URL; outside one it can only start a comment.
34
+ if (character === '/' && source[index + 1] === '/') {
35
+ while (index < source.length && source[index] !== '\n') index += 1
36
+ continue
37
+ }
38
+
39
+ if (character === '/' && source[index + 1] === '*') {
40
+ index += 2
41
+ while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) index += 1
42
+ index += 2
43
+ continue
44
+ }
45
+
46
+ output += character
47
+ index += 1
48
+ }
49
+
50
+ return output
51
+ }
52
+
53
+ /** The parsed value, or `undefined` when the file is not JSON even once its comments are gone. */
54
+ export function parseJsonWithComments(source: string): unknown {
55
+ try {
56
+ return JSON.parse(stripJsonComments(source))
57
+ } catch {
58
+ return undefined
59
+ }
60
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Where a finding is, in parts.
3
+ *
4
+ * A locator addresses a place inside a file — `.cursor/mcp.json#servers.linear.command` — because a
5
+ * file holding twenty servers is not an address. The parts are known wherever a finding is raised,
6
+ * and only the report and the repairs need the string, so the parts are what travel and this module
7
+ * is the one place they become one. Nothing ever recovers a part by splitting a locator back apart:
8
+ * a server may be named `io.github.foo`, and no split of the rendered string tells where the server
9
+ * name ends and the field begins.
10
+ */
11
+
12
+ /**
13
+ * Where a malformed file failed. **Position only.** A parser's own error message quotes the
14
+ * offending line back, and in a file of MCP configuration that line is exactly the one holding a
15
+ * credential — so neither the message nor the code block it carries is ever read. Line and column
16
+ * are enough to fix the file and carry nothing out of it.
17
+ */
18
+ export type Position = { line: number; column: number }
19
+
20
+ export type Locator = {
21
+ /** Repository-relative path of the file the finding is about. */
22
+ file: string
23
+ /** The MCP server inside that file, when the finding is about one. */
24
+ server?: string
25
+ /** The field inside that server, dotted for the per-name fields — `command`, `env.LINEAR_TOKEN`. */
26
+ field?: string
27
+ /** Where a file that does not parse failed, when the parser gave a position. */
28
+ position?: Position | undefined
29
+ }
30
+
31
+ /** The locator as one string: the finding's `path`, and what a repair names. */
32
+ export function locatorText({ file, server, field, position }: Locator): string {
33
+ if (position) return `${file}#L${position.line}:${position.column}`
34
+ if (server === undefined) return file
35
+ return `${file}#servers.${server}${field === undefined ? '' : `.${field}`}`
36
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { filesUnder } from '../diagnose-bridges/directory-files.ts'
4
+ import { type ConfigurationProblem, type RepairAction, repairFor } from '../diagnose-bridges/doctor-guidance.ts'
5
+ import type { GitBridgeState } from '../diagnose-bridges/git-bridge-state.ts'
6
+ import { selectHarnesses } from '../harness-registry/harness-registry.ts'
7
+
8
+ /**
9
+ * The configuration half of `doctor`. Where `diagnose-bridges` asks whether a bridge still
10
+ * resolves, this asks whether the configuration around it is still *right* — a harness name that
11
+ * has been superseded, an instruction file that never reaches `AGENTS.md`, a bridge git-ignores.
12
+ *
13
+ * Everything here is present-and-wrong rather than missing. Configuration that is absent is
14
+ * `init`'s to create; configuration that is wrong is what the `repair` skill acts on, and this
15
+ * module is where it reads its work from. Detection has one home so the two cannot drift.
16
+ *
17
+ * Instruction bridges are deliberately not here: `diagnose-instructions` owns them, and every one
18
+ * of its repairs goes back to the `init` skill rather than to `repair`.
19
+ *
20
+ * Read-only, like every other part of `doctor`.
21
+ */
22
+ export type ConfigurationFinding = {
23
+ /** Repository-relative path the finding is about. */
24
+ path: string
25
+ problem: ConfigurationProblem
26
+ detail: string
27
+ /** What repairs it, already carrying the path. Every fault here is judgment, so `command` is empty. */
28
+ repair: RepairAction
29
+ }
30
+
31
+ export type DiagnoseConfigurationOptions = {
32
+ root: string
33
+ git: GitBridgeState
34
+ /** How to name this tool in the repair commands. */
35
+ cli: string
36
+ }
37
+
38
+ const canonicalSkills = '.agents/skills'
39
+ const localOverride = 'AGENTS.local.md'
40
+
41
+ /**
42
+ * The two frontmatter faults that make a harness skip a skill outright, per
43
+ * `skills/init/references/frontmatter.md`: YAML that does not parse, and a missing `description`.
44
+ * A `name` that mismatches the directory is a warning and still loads, so it is not reported here.
45
+ *
46
+ * The unquoted colon is the documented cause of the first, and is checked directly rather than by
47
+ * parsing YAML: the package ships no parser, and a targeted check names the actual fault.
48
+ */
49
+ function frontmatterFault(body: string): 'unparseable' | 'no-description' | undefined {
50
+ const block = /^---\n([\s\S]*?)\n---/.exec(body)
51
+ if (!block) return 'unparseable'
52
+ const description = /^description:[ \t]*(.*)$/m.exec(block[1] as string)
53
+ const value = description?.[1]?.trim()
54
+ if (!value) return 'no-description'
55
+ const quoted = /^(['"]).*\1$/.test(value)
56
+ if (!quoted && /:\s/.test(value)) return 'unparseable'
57
+ return undefined
58
+ }
59
+
60
+ /**
61
+ * Unlike `diagnoseBridges`, this takes no harness preference. Every check here requires the
62
+ * projection to exist on disk, and a projection cannot exist without its harness's detection
63
+ * directory — which selects that harness anyway. A preference could therefore never add a finding,
64
+ * so accepting one would be surface that does nothing.
65
+ */
66
+ export function diagnoseConfiguration({ root, git, cli }: DiagnoseConfigurationOptions): ConfigurationFinding[] {
67
+ const findings: ConfigurationFinding[] = []
68
+ const add = (path: string, problem: ConfigurationProblem) => {
69
+ const { detail, repair } = repairFor(problem)
70
+ findings.push({ path, problem, detail, repair: repair({ file: path }, cli) })
71
+ }
72
+
73
+ const harnesses = selectHarnesses(root, [])
74
+
75
+ // A superseded name whose projection still exists. `init` reports the deprecation and keeps
76
+ // projecting, because the old path still works — so nothing else ever says it is stale.
77
+ for (const harness of harnesses) {
78
+ const projection = harness.project.skillsDirectory
79
+ if (harness.deprecated && projection && existsSync(join(root, projection))) add(projection, 'deprecated-harness')
80
+ }
81
+
82
+ // An ignored bridge is untracked, and an untracked bridge swallows a real edit silently.
83
+ for (const harness of harnesses) {
84
+ const path = harness.project.skillsDirectory
85
+ if (path && existsSync(join(root, path)) && git.isIgnored(path)) add(path, 'ignored-bridge')
86
+ }
87
+
88
+ // No harness reads this filename, so whatever it holds is invisible to every agent.
89
+ if (existsSync(join(root, localOverride))) add(localOverride, 'unread-local-override')
90
+
91
+ // `filesUnder` has already confirmed each of these is a readable file, so the read needs no guard
92
+ // of its own; a failure here is a genuine IO error and belongs in the command's error path.
93
+ for (const file of filesUnder(join(root, canonicalSkills)).filter((file) => file.endsWith('SKILL.md'))) {
94
+ if (frontmatterFault(readFileSync(join(root, canonicalSkills, file), 'utf8')))
95
+ add(`${canonicalSkills}/${file}`, 'unloadable-skill')
96
+ }
97
+
98
+ return findings
99
+ }
@@ -0,0 +1,135 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { type McpProblem, repairFor } from '../diagnose-bridges/doctor-guidance.ts'
4
+ import type { GitBridgeState } from '../diagnose-bridges/git-bridge-state.ts'
5
+ import { type Locator, locatorText } from '../diagnose-bridges/locator.ts'
6
+ import type { ConfigurationFinding } from '../diagnose-configuration/diagnose-configuration.ts'
7
+ import { selectHarnesses } from '../harness-registry/harness-registry.ts'
8
+ import type { McpConfig } from '../harness-registry/mcp-config.ts'
9
+ import { McpBaseline, type McpDirection, parseProjectionRecord, projectionRecordPath } from './mcp-baseline.ts'
10
+ import { divergingFields, type McpServer } from './mcp-model.ts'
11
+ import { credentialFields } from './mcp-secrets.ts'
12
+ import { goldenSetPath, parseGoldenSet, parseTarget } from './mcp-sources.ts'
13
+
14
+ /**
15
+ * The MCP half of `doctor`: how a repository's **golden MCP server set** and the harness copies of
16
+ * it have drifted apart, and where a literal credential is sitting in either.
17
+ *
18
+ * The golden set is a file the user authors. That is what makes this capability possible at all:
19
+ * `init` reports MCP configuration rather than converting it because converting a config someone
20
+ * wrote for one harness into another's format has to invent values they never wrote, and `init`
21
+ * invents nothing. A set the user authored inverts the premise — a field they filled in is
22
+ * transcription. **No golden set means no drift diagnosis**, and nothing here invents anything
23
+ * either.
24
+ *
25
+ * Read-only, like the rest of `doctor`. Projecting the golden set forward and reconciling a
26
+ * target-side change back into it are writes, and they need an approval-gated home; this module is
27
+ * where that home reads its work from.
28
+ */
29
+ export type DiagnoseMcpOptions = {
30
+ root: string
31
+ git: GitBridgeState
32
+ /** How to name this tool in the repair commands. */
33
+ cli: string
34
+ }
35
+
36
+ function read(root: string, path: string): string | undefined {
37
+ const absolute = join(root, path)
38
+ return existsSync(absolute) ? readFileSync(absolute, 'utf8') : undefined
39
+ }
40
+
41
+ /**
42
+ * The distinct MCP files the enabled harnesses read. Two harnesses may name the same file.
43
+ *
44
+ * No harness preference is accepted, for the same reason the configuration half accepts none: every
45
+ * harness documenting an MCP file is already selected without one. Claude Code and Cursor are
46
+ * selected unconditionally; Codex and Gemini CLI keep their file inside their own detection
47
+ * directory, so it cannot exist without that directory selecting them. A preference could never add
48
+ * a finding.
49
+ */
50
+ function targetsOf(root: string): McpConfig[] {
51
+ const seen = new Set<string>()
52
+ return selectHarnesses(root, [])
53
+ .map((harness) => harness.project.mcpConfig)
54
+ .filter((config): config is McpConfig => config !== undefined)
55
+ .filter((config) => !seen.has(config.path) && seen.add(config.path))
56
+ }
57
+
58
+ const divergence: Record<McpDirection, McpProblem> = {
59
+ target: 'mcp-diverged-target',
60
+ golden: 'mcp-diverged-golden',
61
+ both: 'mcp-diverged-both',
62
+ unknown: 'mcp-diverged-unknown',
63
+ }
64
+
65
+ export function diagnoseMcp({ root, git, cli }: DiagnoseMcpOptions): ConfigurationFinding[] {
66
+ const findings: ConfigurationFinding[] = []
67
+ // The finding carries the locator in parts as far as the repair, which is where a repair naming
68
+ // only the server or only the file reads one off. The string is built here and nowhere else, and
69
+ // nothing recovers a part from it: a server named `io.github.foo` survives no split of it.
70
+ const add = (at: Locator, problem: McpProblem) => {
71
+ const { detail, repair } = repairFor(problem)
72
+ findings.push({ path: locatorText(at), problem, detail, repair: repair(at, cli) })
73
+ }
74
+
75
+ /**
76
+ * Credentials are reported for every file that holds servers, the golden set included — a user
77
+ * pastes a token into whichever file is open, and the golden set is a file. Severity splits on
78
+ * tracking, because a literal in a tracked file is already a committed credential and moving it
79
+ * to an environment variable does not un-commit it.
80
+ */
81
+ const reportSecrets = (path: string, servers: Map<string, McpServer>) => {
82
+ const committed = git.trackingOf(path) !== 'untracked'
83
+ for (const [name, server] of servers)
84
+ for (const field of credentialFields(server))
85
+ add({ file: path, server: name, field }, committed ? 'mcp-committed-secret' : 'mcp-literal-secret')
86
+ }
87
+
88
+ // Targets are read first so an unreadable one is reported whether or not a golden set exists: a
89
+ // harness cannot read that file either, and its servers are not running.
90
+ const targets = new Map<string, Map<string, McpServer>>()
91
+ const configs: McpConfig[] = []
92
+ for (const config of targetsOf(root)) {
93
+ const parsed = parseTarget(config, read(root, config.path))
94
+ if (parsed.kind === 'unreadable') {
95
+ add({ file: config.path }, 'mcp-target-unreadable')
96
+ continue
97
+ }
98
+ // A harness with no MCP file has nothing that could have drifted. Writing one for the first
99
+ // time is projection, which is a write and not this command's.
100
+ if (parsed.kind === 'absent') continue
101
+ reportSecrets(config.path, parsed.servers)
102
+ targets.set(config.path, parsed.servers)
103
+ configs.push(config)
104
+ }
105
+
106
+ const golden = parseGoldenSet(read(root, goldenSetPath))
107
+ if (golden.kind === 'unreadable') {
108
+ add({ file: goldenSetPath, position: golden.position }, 'mcp-golden-unreadable')
109
+ return findings
110
+ }
111
+ if (golden.kind === 'absent') return findings
112
+ reportSecrets(goldenSetPath, golden.servers)
113
+
114
+ const baseline = new McpBaseline({ git, record: parseProjectionRecord(read(root, projectionRecordPath)) })
115
+
116
+ for (const config of configs) {
117
+ const servers = targets.get(config.path) as Map<string, McpServer>
118
+ for (const [name, declared] of golden.servers) {
119
+ const carried = servers.get(name)
120
+ if (!carried) {
121
+ add({ file: config.path, server: name }, 'mcp-unprojected')
122
+ continue
123
+ }
124
+ for (const field of divergingFields(declared, carried))
125
+ add(
126
+ { file: config.path, server: name, field },
127
+ divergence[baseline.directionOf(config, name, field, declared, carried)],
128
+ )
129
+ }
130
+ for (const name of servers.keys())
131
+ if (!golden.servers.has(name)) add({ file: config.path, server: name }, 'mcp-undeclared')
132
+ }
133
+
134
+ return findings
135
+ }
@@ -0,0 +1,122 @@
1
+ import type { GitBridgeState } from '../diagnose-bridges/git-bridge-state.ts'
2
+ import { parseJsonWithComments } from '../diagnose-bridges/json-with-comments.ts'
3
+ import type { McpConfig } from '../harness-registry/mcp-config.ts'
4
+ import { isRecord } from '../is-record/is-record.ts'
5
+ import { type McpField, type McpServer, sameField } from './mcp-model.ts'
6
+ import { goldenSetPath, type ParsedServers, parseGoldenSet, parseTarget } from './mcp-sources.ts'
7
+
8
+ /** Which side of a diverged server moved since the two last agreed. */
9
+ export type McpDirection = 'target' | 'golden' | 'both' | 'unknown'
10
+
11
+ /** Where the record of what was last written to each target lives, beside the golden set it came from. */
12
+ export const projectionRecordPath = '.agents/buddy-agent-harness/mcp.projected.json'
13
+
14
+ /**
15
+ * The last-projected record: per target path, per server name, the model that was written there.
16
+ *
17
+ * It exists because git can only answer for a file it can see. Harnesses write their MCP
18
+ * configuration themselves, at user scope, into files that are often untracked or ignored outright,
19
+ * and for those "which side moved since they last agreed" has no answer in history. A record
20
+ * written at projection time has one, exactly, because it *is* what was projected.
21
+ *
22
+ * A malformed record answers nothing rather than throwing. It is a cache of an answer, not the
23
+ * answer, and a diagnosis that dies because its cache is corrupt is worse than one that says
24
+ * `unknown`.
25
+ */
26
+ export function parseProjectionRecord(source: string | undefined): Map<string, Map<string, McpServer>> {
27
+ const document = source === undefined ? undefined : parseJsonWithComments(source)
28
+ const targets = isRecord(document) ? document['targets'] : undefined
29
+ if (!isRecord(targets)) return new Map()
30
+ return new Map(
31
+ Object.entries(targets)
32
+ .filter(([, servers]) => isRecord(servers))
33
+ .map(([path, servers]) => [
34
+ path,
35
+ new Map(
36
+ Object.entries(servers as Record<string, unknown>)
37
+ .filter(([, server]) => isRecord(server))
38
+ .map(([name, server]) => [name, server as McpServer]),
39
+ ),
40
+ ]),
41
+ )
42
+ }
43
+
44
+ export type BaselineOptions = {
45
+ git: GitBridgeState
46
+ record: Map<string, Map<string, McpServer>>
47
+ }
48
+
49
+ /**
50
+ * Which side moved, for one server and one field.
51
+ *
52
+ * The record is asked first, because it records exactly what was written and therefore answers
53
+ * exactly. Where it holds nothing, a **tracked** target falls back to git: the newest commit where
54
+ * the golden set and the target agreed on that field, then which working tree still matches it.
55
+ * Where neither can answer, the direction is `unknown` and the finding says so — naming a side on a
56
+ * guess would send a reconcile at the wrong file.
57
+ */
58
+ export class McpBaseline {
59
+ constructor(private readonly options: BaselineOptions) {}
60
+
61
+ /**
62
+ * One parse per commit per file, and one commit walk per target.
63
+ *
64
+ * `lastAgreed` runs per (config, server, field) that diverges without a projection record, and
65
+ * every one of those walks reads the same two files at the same commits. Unmemoized, three
66
+ * targets against five servers with two diverged fields each over fifty commits of history is
67
+ * three thousand `git show` calls and as many parses — on the command the `doctor` skill says is
68
+ * cheap enough for a session-start hook. One instance serves one diagnosis, so the memo lives and
69
+ * dies with it and can never answer for a working tree that has moved on.
70
+ */
71
+ private readonly parsed = new Map<string, ParsedServers>()
72
+ private readonly walked = new Map<string, string[]>()
73
+
74
+ directionOf(config: McpConfig, name: string, field: McpField, golden: McpServer, target: McpServer): McpDirection {
75
+ const base = this.baseFor(config, name, field)
76
+ if (base === undefined) return 'unknown'
77
+ const goldenMoved = !sameField(field, golden, base)
78
+ const targetMoved = !sameField(field, target, base)
79
+ if (goldenMoved && targetMoved) return 'both'
80
+ return targetMoved ? 'target' : 'golden'
81
+ }
82
+
83
+ private baseFor(config: McpConfig, name: string, field: McpField): McpServer | undefined {
84
+ const recorded = this.options.record.get(config.path)?.get(name)
85
+ if (recorded) return recorded
86
+ return this.lastAgreed(config, name, field)
87
+ }
88
+
89
+ /**
90
+ * The newest committed state where both files described this field the same way. Read at model
91
+ * granularity rather than by comparing blobs: the two files never share bytes, and a commit that
92
+ * reformatted one of them did not change what it says.
93
+ */
94
+ private lastAgreed(config: McpConfig, name: string, field: McpField): McpServer | undefined {
95
+ for (const commit of this.commitsTouching(config)) {
96
+ const golden = this.parseAt(commit, goldenSetPath, parseGoldenSet)
97
+ const target = this.parseAt(commit, config.path, (source) => parseTarget(config, source))
98
+ if (golden.kind !== 'servers' || target.kind !== 'servers') continue
99
+ const left = golden.servers.get(name)
100
+ const right = target.servers.get(name)
101
+ if (left && right && sameField(field, left, right)) return left
102
+ }
103
+ return undefined
104
+ }
105
+
106
+ private commitsTouching(config: McpConfig): string[] {
107
+ const walked = this.walked.get(config.path)
108
+ if (walked) return walked
109
+ const commits = this.options.git.commitsTouching([goldenSetPath, config.path])
110
+ this.walked.set(config.path, commits)
111
+ return commits
112
+ }
113
+
114
+ private parseAt(commit: string, path: string, parse: (source: string | undefined) => ParsedServers): ParsedServers {
115
+ const key = `${commit}\u0000${path}`
116
+ const parsed = this.parsed.get(key)
117
+ if (parsed) return parsed
118
+ const result = parse(this.options.git.contentAt(commit, path))
119
+ this.parsed.set(key, result)
120
+ return result
121
+ }
122
+ }