buddy-agent-harness 0.6.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/bin/buddy-agent-harness.mjs +8 -3
- package/dist/cli-Bvnq8zOL.mjs +2122 -0
- package/dist/cli-Bvnq8zOL.mjs.map +1 -0
- package/dist/cli.d.mts +13 -2
- package/dist/cli.d.mts.map +1 -1
- package/dist/cli.mjs +2 -20
- package/dist/index.d.mts +382 -47
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/package.json +3 -2
- package/plugin.json +1 -1
- package/skills/doctor/SKILL.md +20 -34
- package/skills/doctor/references/bridges.md +23 -0
- package/skills/doctor/references/configuration.md +12 -0
- package/skills/doctor/references/harnesses/claude-code.md +39 -0
- package/skills/doctor/references/harnesses/codex.md +37 -0
- package/skills/doctor/references/harnesses/copilot-cli.md +39 -0
- package/skills/doctor/references/harnesses/cursor.md +40 -0
- package/skills/doctor/references/harnesses/devin-desktop.md +20 -0
- package/skills/doctor/references/harnesses/gemini-cli.md +38 -0
- package/skills/doctor/references/harnesses/windsurf.md +27 -0
- package/skills/doctor/references/instructions.md +14 -0
- package/skills/doctor/references/mcp.md +31 -0
- package/skills/doctor/references/nonstandard.md +27 -0
- package/skills/doctor/scripts/doctor.mjs +7 -3
- package/skills/enhance/SKILL.md +4 -0
- package/skills/init/README.md +1 -1
- package/skills/init/SKILL.md +19 -8
- package/skills/init/references/detection.md +5 -3
- package/skills/init/references/harnesses/cursor.md +9 -2
- package/skills/init/scripts/doctor.mjs +14 -0
- package/skills/init/scripts/init.mjs +7 -3
- package/skills/repair/README.md +21 -0
- package/skills/repair/SKILL.md +74 -0
- package/skills/repair/references/classes.md +49 -0
- package/skills/repair/scripts/doctor.mjs +14 -0
- package/src/cli.ts +40 -7
- package/src/command-output/command-output.ts +1 -4
- package/src/diagnose-bridges/diagnose-bridges.ts +21 -13
- package/src/diagnose-bridges/diagnose-instructions.ts +7 -2
- package/src/diagnose-bridges/doctor-guidance.ts +636 -90
- package/src/diagnose-bridges/doctor.command.ts +55 -28
- package/src/diagnose-bridges/git-bridge-state.ts +30 -0
- package/src/diagnose-bridges/locator.ts +36 -0
- package/src/diagnose-configuration/diagnose-configuration.ts +99 -0
- package/src/diagnose-mcp/diagnose-mcp.ts +135 -0
- package/src/diagnose-mcp/mcp-baseline.ts +122 -0
- package/src/diagnose-mcp/mcp-model.ts +88 -0
- package/src/diagnose-mcp/mcp-secrets.ts +101 -0
- package/src/diagnose-mcp/mcp-sources.ts +131 -0
- package/src/diagnose-nonstandard/diagnose-nonstandard.ts +109 -0
- package/src/harness-registry/harness-registry.ts +97 -4
- package/src/harness-registry/mcp-config.ts +37 -0
- package/src/harness-registry/nonstandard-artifact.ts +39 -0
- package/src/index.ts +18 -2
- package/src/initialize-harnesses/init.command.ts +13 -18
- package/src/initialize-harnesses/initialize-harnesses.ts +6 -3
- package/src/is-record/is-record.ts +12 -0
- package/src/skill-projection/skill-projection.ts +102 -16
- package/dist/cli.mjs.map +0 -1
- package/dist/init.command-zbntulIq.mjs +0 -1016
- package/dist/init.command-zbntulIq.mjs.map +0 -1
|
@@ -1,36 +1,56 @@
|
|
|
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
|
|
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']
|
|
12
16
|
instructions: DiagnoseResult['instructions']
|
|
13
17
|
divergence?: DiagnoseResult['divergence']
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
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[]
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
/**
|
|
20
35
|
* AXI §5: the healthy answer states the zero with context, so an agent does not re-run with other
|
|
21
36
|
* flags to confirm that an empty section really meant "nothing wrong".
|
|
22
37
|
*/
|
|
23
|
-
export function
|
|
38
|
+
export function buildDoctorReport(
|
|
39
|
+
bin: string,
|
|
40
|
+
result: DiagnoseResult,
|
|
41
|
+
configuration: ConfigurationFinding[] = [],
|
|
42
|
+
): DoctorReport {
|
|
43
|
+
const findings = [...result.findings, ...configuration]
|
|
24
44
|
// Both sections are bridges, so the healthy line counts them together rather than making a reader
|
|
25
45
|
// add up two numbers to learn that nothing is wrong.
|
|
26
|
-
if (!
|
|
46
|
+
if (!findings.length) {
|
|
27
47
|
const count = result.bridges.length + result.instructions.length
|
|
48
|
+
const bridges = count === 1 ? 'the 1 bridge resolves' : `all ${count} bridges resolve`
|
|
28
49
|
return {
|
|
29
50
|
bin,
|
|
30
51
|
bridges: result.bridges,
|
|
31
52
|
instructions: result.instructions,
|
|
32
|
-
findings:
|
|
33
|
-
count === 1 ? '0 problems found — the 1 bridge resolves' : `0 problems found — all ${count} bridges resolve`,
|
|
53
|
+
findings: `0 problems found — ${bridges} and the configuration around them is current`,
|
|
34
54
|
}
|
|
35
55
|
}
|
|
36
56
|
|
|
@@ -39,8 +59,15 @@ export function buildReport(bin: string, result: DiagnoseResult): DoctorReport {
|
|
|
39
59
|
bridges: result.bridges,
|
|
40
60
|
instructions: result.instructions,
|
|
41
61
|
...(result.divergence.length ? { divergence: result.divergence } : {}),
|
|
42
|
-
findings:
|
|
43
|
-
|
|
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
|
+
],
|
|
44
71
|
}
|
|
45
72
|
}
|
|
46
73
|
|
|
@@ -66,28 +93,28 @@ export const doctorCommand: cli.Command = command({
|
|
|
66
93
|
run(args) {
|
|
67
94
|
try {
|
|
68
95
|
const format = parseFormat(args.format)
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
.map((name) => name.trim())
|
|
72
|
-
.filter(Boolean)
|
|
73
|
-
const unsupported = requested?.filter((name) => !harnessRegistry.some((harness) => harness.name === name))
|
|
74
|
-
if (unsupported?.length)
|
|
75
|
-
throw new Error(
|
|
76
|
-
`Unsupported harness: ${unsupported.join(', ')}. Supported: ${harnessRegistry
|
|
77
|
-
.map((harness) => harness.name)
|
|
78
|
-
.join(', ')}.`,
|
|
79
|
-
)
|
|
96
|
+
const harnesses = parseHarnesses(args.harness)
|
|
97
|
+
const root = args.root ?? process.cwd()
|
|
80
98
|
const result = diagnoseBridges({
|
|
81
|
-
root
|
|
82
|
-
...(
|
|
99
|
+
root,
|
|
100
|
+
...(harnesses.length ? { harnesses } : {}),
|
|
83
101
|
cli: commandInvocation,
|
|
84
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
|
+
]
|
|
85
109
|
// Exit stays 0 even with findings: the diagnosis succeeded, and a non-zero code reads to an
|
|
86
110
|
// agent as "this command is broken, try something else".
|
|
87
|
-
writeResult(
|
|
111
|
+
writeResult(buildDoctorReport(binPath(homedir(), process.argv[1]), result, configuration), format)
|
|
112
|
+
return exitCodes.success
|
|
88
113
|
} catch (error) {
|
|
89
114
|
process.stderr.write(`error: ${error instanceof Error ? error.message : 'Harness diagnosis failed.'}\n`)
|
|
90
|
-
|
|
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
|
|
91
118
|
}
|
|
92
119
|
},
|
|
93
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,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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { isRecord } from '../is-record/is-record.ts'
|
|
2
|
+
/**
|
|
3
|
+
* The shape both sides of an MCP comparison are normalized into.
|
|
4
|
+
*
|
|
5
|
+
* No two MCP configuration files are ever byte-equal — the supported harnesses spread the same
|
|
6
|
+
* servers across six config keys and three serialization formats — so comparison cannot be a
|
|
7
|
+
* diff. Each side is parsed into this model and the models are compared.
|
|
8
|
+
*
|
|
9
|
+
* The field list is a **superset** of what the hosts accept, which is the whole premise of a
|
|
10
|
+
* golden set: `description`, `enabled`, and `timeout` are Goose's, `source` is Zed's, and a
|
|
11
|
+
* converter writing into either has to supply them. Supplying a value the user wrote is
|
|
12
|
+
* transcription; supplying one they did not is invention, and `init` invents nothing. See
|
|
13
|
+
* `.research/agentic-configuration-standards/` (E-MCP-05) for what each host makes up today.
|
|
14
|
+
*/
|
|
15
|
+
export type McpTransport = 'stdio' | 'http' | 'sse'
|
|
16
|
+
|
|
17
|
+
export type McpServer = {
|
|
18
|
+
transport?: McpTransport
|
|
19
|
+
command?: string
|
|
20
|
+
args?: readonly string[]
|
|
21
|
+
env?: Readonly<Record<string, string>>
|
|
22
|
+
url?: string
|
|
23
|
+
headers?: Readonly<Record<string, string>>
|
|
24
|
+
description?: string
|
|
25
|
+
enabled?: boolean
|
|
26
|
+
timeout?: number
|
|
27
|
+
source?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every field of the model, in the order a finding reports them. */
|
|
31
|
+
export const mcpFields = [
|
|
32
|
+
'transport',
|
|
33
|
+
'command',
|
|
34
|
+
'args',
|
|
35
|
+
'env',
|
|
36
|
+
'url',
|
|
37
|
+
'headers',
|
|
38
|
+
'description',
|
|
39
|
+
'enabled',
|
|
40
|
+
'timeout',
|
|
41
|
+
'source',
|
|
42
|
+
] as const
|
|
43
|
+
|
|
44
|
+
export type McpField = (typeof mcpFields)[number]
|
|
45
|
+
|
|
46
|
+
/** The two fields holding one value per name rather than a single value. */
|
|
47
|
+
const mapFields = new Set<McpField>(['env', 'headers'])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether two values of the same field agree. Arrays are ordered — `args` is a command line, and
|
|
51
|
+
* reordering it changes what runs. Maps are not, so they are compared by their sorted entries.
|
|
52
|
+
*/
|
|
53
|
+
function sameValue(field: McpField, left: unknown, right: unknown): boolean {
|
|
54
|
+
if (mapFields.has(field) && isRecord(left) && isRecord(right)) {
|
|
55
|
+
const keys = Object.keys(left)
|
|
56
|
+
return keys.length === Object.keys(right).length && keys.every((key) => left[key] === right[key])
|
|
57
|
+
}
|
|
58
|
+
return JSON.stringify(left) === JSON.stringify(right)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The fields on which a target disagrees with the golden set.
|
|
63
|
+
*
|
|
64
|
+
* Asymmetric by design, and this is the rule that keeps a golden set from accumulating noise. A
|
|
65
|
+
* field the golden set leaves unset is never a difference, however the target fills it: a host
|
|
66
|
+
* restating its own default and a user's deliberate edit are indistinguishable in that position,
|
|
67
|
+
* and pulling both back would grow the golden set on every round-trip. The golden set speaks only
|
|
68
|
+
* about what it says.
|
|
69
|
+
*
|
|
70
|
+
* `env` and `headers` are compared per name for the same reason — a variable only the target sets
|
|
71
|
+
* is that target's business, and one the golden set names must match.
|
|
72
|
+
*/
|
|
73
|
+
export function divergingFields(golden: McpServer, target: McpServer): McpField[] {
|
|
74
|
+
return mcpFields.filter((field) => {
|
|
75
|
+
const declared = golden[field]
|
|
76
|
+
if (declared === undefined) return false
|
|
77
|
+
if (mapFields.has(field) && isRecord(declared) && isRecord(target[field])) {
|
|
78
|
+
const observed = target[field] as Record<string, unknown>
|
|
79
|
+
return Object.keys(declared).some((key) => declared[key] !== observed[key])
|
|
80
|
+
}
|
|
81
|
+
return !sameValue(field, declared, target[field])
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether one field of two models agrees, for naming the side that moved. */
|
|
86
|
+
export function sameField(field: McpField, left: McpServer | undefined, right: McpServer | undefined): boolean {
|
|
87
|
+
return sameValue(field, left?.[field], right?.[field])
|
|
88
|
+
}
|