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.
- 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 +491 -40
- 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 +27 -23
- 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 +24 -10
- package/skills/init/references/detection.md +5 -3
- package/skills/init/references/harnesses/claude-code.md +2 -0
- package/skills/init/references/harnesses/cursor.md +9 -2
- package/skills/init/references/harnesses/gemini-cli.md +6 -9
- 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/agents-files.ts +34 -0
- package/src/diagnose-bridges/diagnose-bridges.ts +39 -21
- package/src/diagnose-bridges/diagnose-instructions.ts +145 -0
- package/src/diagnose-bridges/doctor-guidance.ts +683 -69
- package/src/diagnose-bridges/doctor.command.ts +63 -30
- package/src/diagnose-bridges/git-bridge-state.ts +30 -0
- package/src/diagnose-bridges/json-with-comments.ts +60 -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 +164 -14
- package/src/harness-registry/instruction-bridge.ts +31 -0
- package/src/harness-registry/mcp-config.ts +37 -0
- package/src/harness-registry/nonstandard-artifact.ts +39 -0
- package/src/index.ts +33 -4
- package/src/initialize-harnesses/init.command.ts +13 -18
- package/src/initialize-harnesses/initialize-harnesses.ts +7 -4
- package/src/is-record/is-record.ts +12 -0
- package/src/skill-projection/skill-projection.ts +104 -18
- package/dist/cli.mjs.map +0 -1
- package/dist/init.command-c0fBv15T.mjs +0 -714
- package/dist/init.command-c0fBv15T.mjs.map +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { McpServer } from './mcp-model.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Finding a literal credential in MCP configuration, without ever handling one.
|
|
5
|
+
*
|
|
6
|
+
* `doctor` is safe to run from a session-start hook, so its output lands in agent context on every
|
|
7
|
+
* session and from there into transcripts. A value it echoes is amplified far past the file it came
|
|
8
|
+
* from. Every function here answers **where**, never **what**: the return is a field path, and the
|
|
9
|
+
* value never leaves this module.
|
|
10
|
+
*
|
|
11
|
+
* That is also why the redaction lives here rather than in a formatter. `doctor` renders three ways
|
|
12
|
+
* — TOON, `--format text`, and `--format json` — and trusting each of them to mask is three chances
|
|
13
|
+
* to drift. A value that never enters the finding cannot be printed by any of them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Words that mark a field as credential-bearing wherever they appear in its name. Each is one that
|
|
18
|
+
* effectively never turns up inside an innocent word, so a bare substring test is safe and catches
|
|
19
|
+
* the run-together spellings a segment test would miss — `MYTOKEN`, `githubsecret`.
|
|
20
|
+
*/
|
|
21
|
+
const credentialWord = /token|secret|password|passwd|credential/i
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Words that mark a field only when they are a **segment** of its name. `key` and `auth` are the
|
|
25
|
+
* two that matter, and both are common inside words that carry nothing — `MONKEY`, `AUTHOR`,
|
|
26
|
+
* `KEYSTONE_URL`. Matching them as substrings reports those, and a scanner that cries wolf on a
|
|
27
|
+
* hook-run command is one people stop reading.
|
|
28
|
+
*/
|
|
29
|
+
const credentialSegment = new Set(['key', 'apikey', 'auth', 'authorization', 'bearer'])
|
|
30
|
+
|
|
31
|
+
/** A field name split the two ways these are written: `API_KEY` and `apiKey` alike. */
|
|
32
|
+
function segmentsOf(key: string): string[] {
|
|
33
|
+
return key
|
|
34
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
35
|
+
.split(/[^A-Za-z0-9]+/)
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.map((segment) => segment.toLowerCase())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A value that **names** a secret instead of holding one, in the forms the supported hosts
|
|
42
|
+
* document: `${VAR}`, `$VAR`, and the `${env:VAR}` / `${input:id}` variants.
|
|
43
|
+
*
|
|
44
|
+
* Classification is by shape, not by content. An entropy test on the value would be a guess about
|
|
45
|
+
* a string this module has already decided not to look at, and it would fail in both directions —
|
|
46
|
+
* a short password is low-entropy and a base64 configuration blob is high.
|
|
47
|
+
*/
|
|
48
|
+
const reference = /\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A credential-bearing field passes when it carries a reference anywhere in it, because the common
|
|
52
|
+
* legitimate form is a template rather than a bare variable — `Bearer ${LINEAR_TOKEN}` is the
|
|
53
|
+
* documented way to write an authorization header, and requiring the whole value to be a reference
|
|
54
|
+
* would report every correct one.
|
|
55
|
+
*/
|
|
56
|
+
function holdsLiteral(value: string): boolean {
|
|
57
|
+
return !reference.test(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function credentialKey(key: string): boolean {
|
|
61
|
+
return credentialWord.test(key) || segmentsOf(key).some((segment) => credentialSegment.has(segment))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a URL carries a credential in itself. Two shapes: a password in the userinfo component,
|
|
66
|
+
* and a credential-named query parameter holding a literal. Neither value is read past the test.
|
|
67
|
+
*
|
|
68
|
+
* A URL that does not parse is not reported. It is malformed rather than leaky, and guessing at its
|
|
69
|
+
* structure with a regular expression is how a scanner starts matching the thing it must not touch.
|
|
70
|
+
*/
|
|
71
|
+
function urlHoldsCredential(value: string): boolean {
|
|
72
|
+
let url: URL
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(value)
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
if (url.password) return true
|
|
79
|
+
for (const [key, parameter] of url.searchParams) if (credentialKey(key) && holdsLiteral(parameter)) return true
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function literalsIn(field: 'env' | 'headers', values: Readonly<Record<string, string>> | undefined): string[] {
|
|
84
|
+
return Object.entries(values ?? {})
|
|
85
|
+
.filter(([key, value]) => credentialKey(key) && holdsLiteral(value))
|
|
86
|
+
.map(([key]) => `${field}.${key}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every field of one server holding a literal credential, as a path within that server.
|
|
91
|
+
*
|
|
92
|
+
* The path is the whole answer. It is never a truncated value: `sk-ab…` is a leak into the same
|
|
93
|
+
* transcript and buys a reader nothing the field name does not already give them.
|
|
94
|
+
*/
|
|
95
|
+
export function credentialFields(server: McpServer): string[] {
|
|
96
|
+
return [
|
|
97
|
+
...literalsIn('env', server.env),
|
|
98
|
+
...literalsIn('headers', server.headers),
|
|
99
|
+
...(server.url && urlHoldsCredential(server.url) ? ['url'] : []),
|
|
100
|
+
]
|
|
101
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { parse as parseToml } from 'smol-toml'
|
|
2
|
+
import { parseJsonWithComments } from '../diagnose-bridges/json-with-comments.ts'
|
|
3
|
+
import type { Position } from '../diagnose-bridges/locator.ts'
|
|
4
|
+
import type { McpConfig } from '../harness-registry/mcp-config.ts'
|
|
5
|
+
import { isRecord } from '../is-record/is-record.ts'
|
|
6
|
+
import type { McpServer, McpTransport } from './mcp-model.ts'
|
|
7
|
+
|
|
8
|
+
/** Where the golden set lives, and why it is namespaced rather than sitting at `.agents/mcp.json`. */
|
|
9
|
+
export const goldenSetPath = '.agents/buddy-agent-harness/mcp.toml'
|
|
10
|
+
|
|
11
|
+
/** The table the golden set keeps its servers under. */
|
|
12
|
+
const goldenKey = 'servers'
|
|
13
|
+
|
|
14
|
+
export type ParsedServers =
|
|
15
|
+
/** The file is absent. Nothing to compare, and not a fault. */
|
|
16
|
+
| { kind: 'absent' }
|
|
17
|
+
| { kind: 'servers'; servers: Map<string, McpServer> }
|
|
18
|
+
| { kind: 'unreadable'; position?: Position | undefined }
|
|
19
|
+
|
|
20
|
+
function stringMap(value: unknown): Record<string, string> | undefined {
|
|
21
|
+
if (!isRecord(value)) return undefined
|
|
22
|
+
const entries = Object.entries(value).filter(([, item]) => typeof item === 'string') as [string, string][]
|
|
23
|
+
return entries.length ? Object.fromEntries(entries) : undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const transports = new Set<string>(['stdio', 'http', 'sse'])
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The transport a host entry describes. Hosts spell it `type` (Claude Code, VS Code) or
|
|
30
|
+
* `transport`, and several state it nowhere and leave it implied by which of `url` and `command`
|
|
31
|
+
* is present. Inferring it is what lets a `url` entry in one file compare equal to a `type: http`
|
|
32
|
+
* entry in another.
|
|
33
|
+
*/
|
|
34
|
+
function transportOf(entry: Record<string, unknown>): McpTransport | undefined {
|
|
35
|
+
const declared = entry['type'] ?? entry['transport']
|
|
36
|
+
if (typeof declared === 'string' && transports.has(declared)) return declared as McpTransport
|
|
37
|
+
if (typeof entry['url'] === 'string') return 'http'
|
|
38
|
+
if (typeof entry['command'] === 'string') return 'stdio'
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One host entry, or one golden entry, in the canonical model. The two formats carry the same
|
|
44
|
+
* field names — the divergence between hosts is the file, the top-level key, and the serialization,
|
|
45
|
+
* not the entry — so one reader serves both directions of the comparison.
|
|
46
|
+
*
|
|
47
|
+
* A field whose value is the wrong type is dropped rather than carried through. A `timeout` that
|
|
48
|
+
* is a string is not a timeout, and letting it into the model would report a divergence whose real
|
|
49
|
+
* cause is a typo the finding does not name.
|
|
50
|
+
*/
|
|
51
|
+
function serverFrom(entry: Record<string, unknown>): McpServer {
|
|
52
|
+
const args = Array.isArray(entry['args']) && entry['args'].every((item) => typeof item === 'string')
|
|
53
|
+
const transport = transportOf(entry)
|
|
54
|
+
const env = stringMap(entry['env'])
|
|
55
|
+
const headers = stringMap(entry['headers'])
|
|
56
|
+
return {
|
|
57
|
+
...(transport ? { transport } : {}),
|
|
58
|
+
...(typeof entry['command'] === 'string' ? { command: entry['command'] } : {}),
|
|
59
|
+
...(args ? { args: entry['args'] as string[] } : {}),
|
|
60
|
+
...(env ? { env } : {}),
|
|
61
|
+
...(typeof entry['url'] === 'string' ? { url: entry['url'] } : {}),
|
|
62
|
+
...(headers ? { headers } : {}),
|
|
63
|
+
...(typeof entry['description'] === 'string' ? { description: entry['description'] } : {}),
|
|
64
|
+
...(typeof entry['enabled'] === 'boolean' ? { enabled: entry['enabled'] } : {}),
|
|
65
|
+
...(typeof entry['timeout'] === 'number' ? { timeout: entry['timeout'] } : {}),
|
|
66
|
+
...(typeof entry['source'] === 'string' ? { source: entry['source'] } : {}),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function serversUnder(document: unknown, key: string): Map<string, McpServer> {
|
|
71
|
+
const table = isRecord(document) ? document[key] : undefined
|
|
72
|
+
if (!isRecord(table)) return new Map()
|
|
73
|
+
return new Map(
|
|
74
|
+
Object.entries(table)
|
|
75
|
+
.filter(([, entry]) => isRecord(entry))
|
|
76
|
+
.map(([name, entry]) => [name, serverFrom(entry as Record<string, unknown>)]),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The position out of a thrown parse error, and nothing else out of it.
|
|
82
|
+
*
|
|
83
|
+
* `smol-toml` reports `line` and `column` on the error alongside a `codeblock` quoting the source,
|
|
84
|
+
* and its `message` embeds that same code block. Both are unusable: in a file of MCP configuration
|
|
85
|
+
* the line a parser failed on is exactly the line holding the credential. Only the two numbers are
|
|
86
|
+
* read, and anything thrown that does not carry them is reported without a position rather than by
|
|
87
|
+
* reaching for its message.
|
|
88
|
+
*
|
|
89
|
+
* Exported because that restriction is the point of the function rather than an implementation
|
|
90
|
+
* detail of it, and it is verified directly.
|
|
91
|
+
*/
|
|
92
|
+
export function positionOf(error: unknown): Position | undefined {
|
|
93
|
+
if (!isRecord(error)) return undefined
|
|
94
|
+
const { line, column } = error
|
|
95
|
+
return typeof line === 'number' && typeof column === 'number' ? { line, column } : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The golden set, parsed. Absent is the common case and is not a fault. */
|
|
99
|
+
export function parseGoldenSet(source: string | undefined): ParsedServers {
|
|
100
|
+
if (source === undefined) return { kind: 'absent' }
|
|
101
|
+
try {
|
|
102
|
+
return { kind: 'servers', servers: serversUnder(parseToml(source), goldenKey) }
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { kind: 'unreadable', position: positionOf(error) }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One harness's MCP configuration, parsed. The file is read for its own key and nothing else:
|
|
110
|
+
* `.gemini/settings.json` also carries the instruction bridge, and a reader that treated the whole
|
|
111
|
+
* file as MCP would report the rest of a user's settings as servers it does not recognize.
|
|
112
|
+
*
|
|
113
|
+
* JSON is parsed with comments stripped, because Gemini CLI's loader strips them too and a
|
|
114
|
+
* settings file may legally carry them.
|
|
115
|
+
*/
|
|
116
|
+
export function parseTarget(config: McpConfig, source: string | undefined): ParsedServers {
|
|
117
|
+
if (source === undefined) return { kind: 'absent' }
|
|
118
|
+
if (config.format === 'toml') {
|
|
119
|
+
try {
|
|
120
|
+
return { kind: 'servers', servers: serversUnder(parseToml(source), config.key) }
|
|
121
|
+
} catch {
|
|
122
|
+
return { kind: 'unreadable' }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const document = parseJsonWithComments(source)
|
|
126
|
+
// `parseJsonWithComments` answers `undefined` for a file that does not parse. A file whose
|
|
127
|
+
// literal content is `null` parses and holds no servers, which reads the same here and is
|
|
128
|
+
// reported the same way it would be for `{}`.
|
|
129
|
+
if (document === undefined) return { kind: 'unreadable' }
|
|
130
|
+
return { kind: 'servers', servers: serversUnder(document, config.key) }
|
|
131
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, posix } from 'node:path'
|
|
3
|
+
import { filesUnder } from '../diagnose-bridges/directory-files.ts'
|
|
4
|
+
import { type NonstandardProblem, type RepairAction, repairFor } from '../diagnose-bridges/doctor-guidance.ts'
|
|
5
|
+
import { harnessRegistry } from '../harness-registry/harness-registry.ts'
|
|
6
|
+
import type { NonstandardArtifact, NonstandardKind } from '../harness-registry/nonstandard-artifact.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The fifth section of `doctor`: configuration that works for exactly one harness.
|
|
10
|
+
*
|
|
11
|
+
* The other four ask whether something is broken. This one asks how far what is there reaches, and
|
|
12
|
+
* reports the answer when it is "one harness". Nothing here is a fault — a Cursor rule does what it
|
|
13
|
+
* says — so nothing here is repaired by correcting it. Each finding names the canonical form it
|
|
14
|
+
* would convert to, so a repository can be walked toward one where every harness file is generated
|
|
15
|
+
* from a canonical source rather than authored beside it.
|
|
16
|
+
*
|
|
17
|
+
* Read-only, like every other part of `doctor`.
|
|
18
|
+
*/
|
|
19
|
+
export type NonstandardFinding = {
|
|
20
|
+
/** Repository-relative path of the artifact, POSIX-separated. */
|
|
21
|
+
path: string
|
|
22
|
+
problem: NonstandardProblem
|
|
23
|
+
detail: string
|
|
24
|
+
/** The conversion. `command` is always empty: every one of these is judgment about content. */
|
|
25
|
+
repair: RepairAction
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DiagnoseNonstandardOptions = {
|
|
29
|
+
root: string
|
|
30
|
+
/** How to name this tool in the repair commands. */
|
|
31
|
+
cli: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const problemOf: Record<NonstandardKind, NonstandardProblem> = {
|
|
35
|
+
instructions: 'nonstandard-instructions',
|
|
36
|
+
rule: 'nonstandard-rule',
|
|
37
|
+
command: 'nonstandard-command',
|
|
38
|
+
skill: 'nonstandard-skill',
|
|
39
|
+
subagent: 'nonstandard-subagent',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A `.mdc` rule splits on whether its scoping is load-bearing. `globs:` with a value binds the rule
|
|
44
|
+
* to paths, and `AGENTS.md` has no equivalent — it scopes by directory nesting only — so that one
|
|
45
|
+
* converts to a skill, or stays. Without it the rule is always-on prose, which `AGENTS.md` holds
|
|
46
|
+
* verbatim. Read from frontmatter rather than assumed, because the two need different conversions
|
|
47
|
+
* and guessing wrong sends prose to the wrong destination.
|
|
48
|
+
*/
|
|
49
|
+
function scopedByGlobs(body: string): boolean {
|
|
50
|
+
const block = /^---\n([\s\S]*?)\n---/.exec(body)
|
|
51
|
+
if (!block) return false
|
|
52
|
+
const globs = /^globs:[ \t]*(.*)$/m.exec(block[1] as string)
|
|
53
|
+
const value = globs?.[1]?.trim()
|
|
54
|
+
return value !== undefined && value.length > 0 && value !== '[]'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every skill directory below `directory`, named by its `SKILL.md`. */
|
|
58
|
+
function skillFiles(directory: string): string[] {
|
|
59
|
+
return filesUnder(directory).filter((file) => file.endsWith('SKILL.md'))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A harness directory that is a symlink is a projection someone already made, not configuration
|
|
64
|
+
* authored here — reporting it would tell a repository to convert what it already converted.
|
|
65
|
+
*/
|
|
66
|
+
function authoredHere(path: string): boolean {
|
|
67
|
+
try {
|
|
68
|
+
return !lstatSync(path).isSymbolicLink()
|
|
69
|
+
} catch {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function artifactFiles(root: string, artifact: NonstandardArtifact): string[] {
|
|
75
|
+
const absolute = join(root, artifact.path)
|
|
76
|
+
// A path that is not there fails this too — `lstat` throws and the catch reads as "not ours".
|
|
77
|
+
if (!authoredHere(absolute)) return []
|
|
78
|
+
if (artifact.shape === 'file') return [artifact.path]
|
|
79
|
+
const below = artifact.kind === 'skill' ? skillFiles(absolute) : filesUnder(absolute)
|
|
80
|
+
return below.map((file) => posix.join(artifact.path, file))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Unlike the other detectors this takes no harness preference and no enabled set. An artifact is
|
|
85
|
+
* reachable by one harness whether or not that harness is enabled here — a `.cursorrules` in a
|
|
86
|
+
* repository nobody opens in Cursor is still instruction content `AGENTS.md` does not carry — so
|
|
87
|
+
* filtering by the enabled set would hide exactly the drift worth converting.
|
|
88
|
+
*/
|
|
89
|
+
export function diagnoseNonstandard({ root, cli }: DiagnoseNonstandardOptions): NonstandardFinding[] {
|
|
90
|
+
const findings: NonstandardFinding[] = []
|
|
91
|
+
|
|
92
|
+
for (const harness of harnessRegistry) {
|
|
93
|
+
for (const artifact of harness.project.nonstandard ?? []) {
|
|
94
|
+
for (const path of artifactFiles(root, artifact)) {
|
|
95
|
+
const kind =
|
|
96
|
+
artifact.kind === 'rule' && path.endsWith('.mdc') && !scopedByGlobs(readFileSync(join(root, path), 'utf8'))
|
|
97
|
+
? 'instructions'
|
|
98
|
+
: artifact.kind
|
|
99
|
+
const problem = problemOf[kind]
|
|
100
|
+
const { detail, repair } = repairFor(problem)
|
|
101
|
+
findings.push({ path, problem, detail, repair: repair({ file: path }, cli) })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// No dedupe: one artifact is declared by one harness, and `harness-registry.test.ts` holds that
|
|
107
|
+
// invariant. Collapsing a duplicate here would hide the registry mistake rather than surface it.
|
|
108
|
+
return findings.sort((left, right) => left.path.localeCompare(right.path))
|
|
109
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { lstatSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import type { InstructionBridge } from './instruction-bridge.ts'
|
|
4
|
+
import type { McpConfig } from './mcp-config.ts'
|
|
5
|
+
import type { NonstandardArtifact } from './nonstandard-artifact.ts'
|
|
3
6
|
|
|
4
7
|
export type HarnessName =
|
|
5
8
|
| 'claude-code'
|
|
@@ -10,21 +13,67 @@ export type HarnessName =
|
|
|
10
13
|
| 'devin-desktop'
|
|
11
14
|
| 'windsurf'
|
|
12
15
|
|
|
16
|
+
/** The two scopes a harness reads configuration at. Their roots differ; their shape does not. */
|
|
17
|
+
export type HarnessScopeName = 'project' | 'user'
|
|
18
|
+
|
|
13
19
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
20
|
+
* What one harness does at one scope. Paths are relative to that scope's root: the repository root
|
|
21
|
+
* at project scope, the user's home directory at user scope.
|
|
22
|
+
*
|
|
23
|
+
* `detect` is the directory whose presence means this harness is configured at this scope — a
|
|
24
|
+
* different question per scope, which is why it is recorded per scope rather than once.
|
|
25
|
+
*
|
|
26
|
+
* `skillsDirectory` is the projection target the harness needs to see `.agents/skills` at this
|
|
27
|
+
* scope. A harness that reads `.agents/skills` natively there has none, and is never projected into.
|
|
28
|
+
*
|
|
29
|
+
* `mcpConfig` is where this harness keeps its MCP servers at this scope. Unlike the other two it
|
|
30
|
+
* is not a bridge into a canonical file the harness cannot read — it is the harness's own
|
|
31
|
+
* configuration, which `doctor` compares against the golden set and never writes.
|
|
32
|
+
*
|
|
33
|
+
* `nonstandard` is what this harness reads that no other harness can. Unlike the three above it is
|
|
34
|
+
* not something the tool maintains — it is what a repository accumulated before it was canonical,
|
|
35
|
+
* and `doctor` reports it so it can be converted. Declared here because the paths are per harness
|
|
36
|
+
* and belong beside the harness's other paths, not in a second list that would drift from this one.
|
|
37
|
+
*
|
|
38
|
+
* `instructionBridge` is the same question for `AGENTS.md`: what the harness needs at this scope in
|
|
39
|
+
* order to read it. It belongs per scope for the same reason `skillsDirectory` does — the file
|
|
40
|
+
* differs. Unlike `skillsDirectory`, the `init` command does not write it; the `init` skill does. It
|
|
41
|
+
* is recorded here to be diagnosed and gated per harness, not to be projected.
|
|
16
42
|
*/
|
|
17
|
-
export type
|
|
18
|
-
name: HarnessName
|
|
43
|
+
export type HarnessScope = {
|
|
19
44
|
detect: string
|
|
20
45
|
skillsDirectory?: string
|
|
46
|
+
instructionBridge?: InstructionBridge
|
|
47
|
+
mcpConfig?: McpConfig
|
|
48
|
+
nonstandard?: readonly NonstandardArtifact[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type Harness = {
|
|
52
|
+
name: HarnessName
|
|
53
|
+
/** What this harness does inside a repository. `init` and `doctor` act only here. */
|
|
54
|
+
project: HarnessScope
|
|
55
|
+
/**
|
|
56
|
+
* What this harness does for the user, outside any repository. Absent when no user-scope path is
|
|
57
|
+
* primary-sourced — Devin documents none. Described and diagnosable; never written to.
|
|
58
|
+
*/
|
|
59
|
+
user?: HarnessScope
|
|
21
60
|
/** Set when this name has been superseded; the value is the name that replaces it. */
|
|
22
61
|
deprecated?: HarnessName
|
|
23
62
|
}
|
|
24
63
|
|
|
25
64
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
65
|
+
* The two scopes disagree in general, and a single answer per harness could only record one of them:
|
|
66
|
+
* Copilot CLI is `.github/skills` in a repository and `~/.copilot` for the user. Claude Code is the
|
|
67
|
+
* only harness that needs a skills projection, and it needs one at both scopes. Codex, Cursor,
|
|
68
|
+
* Copilot CLI, Gemini CLI, and Devin Desktop read `.agents/skills` themselves and are never
|
|
69
|
+
* projected into.
|
|
70
|
+
*
|
|
71
|
+
* Gemini CLI carried a `.gemini/skills` projection until E-GEM-02: it reads the `.agents/skills`
|
|
72
|
+
* alias at project scope too, where that alias takes precedence over `.gemini/skills`. It still
|
|
73
|
+
* needs an instruction bridge, which is a separate axis and unaffected.
|
|
74
|
+
*
|
|
75
|
+
* Instruction bridges are recorded at project scope only. The user-scope equivalents exist, but
|
|
76
|
+
* nothing writes or reads them yet: `init` works inside a repository, and so does `doctor`.
|
|
28
77
|
*
|
|
29
78
|
* `windsurf` is the former name of Devin Desktop, rebranded 2026-06-02. It is retained as a
|
|
30
79
|
* deprecated alias: Devin still scans the legacy `.windsurf/skills` path, so its projection keeps
|
|
@@ -33,15 +82,114 @@ export type Harness = {
|
|
|
33
82
|
* See `.research/agentic-configuration-standards/` for the per-harness sources.
|
|
34
83
|
*/
|
|
35
84
|
export const harnessRegistry: readonly Harness[] = [
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
85
|
+
{
|
|
86
|
+
name: 'claude-code',
|
|
87
|
+
project: {
|
|
88
|
+
detect: '.claude',
|
|
89
|
+
skillsDirectory: '.claude/skills',
|
|
90
|
+
instructionBridge: { kind: 'import', path: 'CLAUDE.md' },
|
|
91
|
+
mcpConfig: { path: '.mcp.json', key: 'mcpServers', format: 'json' },
|
|
92
|
+
nonstandard: [
|
|
93
|
+
{ path: '.claude/commands', shape: 'directory', kind: 'command' },
|
|
94
|
+
{ path: '.claude/rules', shape: 'directory', kind: 'rule' },
|
|
95
|
+
{ path: '.claude/agents', shape: 'directory', kind: 'subagent' },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
user: { detect: '.claude', skillsDirectory: '.claude/skills' },
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'cursor',
|
|
102
|
+
project: {
|
|
103
|
+
detect: '.cursor',
|
|
104
|
+
mcpConfig: { path: '.cursor/mcp.json', key: 'mcpServers', format: 'json' },
|
|
105
|
+
nonstandard: [
|
|
106
|
+
{ path: '.cursorrules', shape: 'file', kind: 'instructions' },
|
|
107
|
+
{ path: '.cursor/rules', shape: 'directory', kind: 'rule' },
|
|
108
|
+
{ path: '.cursor/commands', shape: 'directory', kind: 'command' },
|
|
109
|
+
{ path: '.cursor/skills', shape: 'directory', kind: 'skill' },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
user: { detect: '.cursor' },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'codex',
|
|
116
|
+
project: {
|
|
117
|
+
detect: '.codex',
|
|
118
|
+
mcpConfig: { path: '.codex/config.toml', key: 'mcp_servers', format: 'toml' },
|
|
119
|
+
nonstandard: [{ path: '.codex/skills', shape: 'directory', kind: 'skill' }],
|
|
120
|
+
},
|
|
121
|
+
user: { detect: '.codex' },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'copilot-cli',
|
|
125
|
+
project: {
|
|
126
|
+
detect: '.github/skills',
|
|
127
|
+
nonstandard: [
|
|
128
|
+
{ path: '.github/copilot-instructions.md', shape: 'file', kind: 'instructions' },
|
|
129
|
+
{ path: '.github/instructions', shape: 'directory', kind: 'instructions' },
|
|
130
|
+
{ path: '.github/skills', shape: 'directory', kind: 'skill' },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
user: { detect: '.copilot' },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'gemini-cli',
|
|
137
|
+
project: {
|
|
138
|
+
detect: '.gemini',
|
|
139
|
+
instructionBridge: { kind: 'settings-entry', path: '.gemini/settings.json', key: 'context.fileName' },
|
|
140
|
+
mcpConfig: { path: '.gemini/settings.json', key: 'mcpServers', format: 'json', shared: true },
|
|
141
|
+
nonstandard: [
|
|
142
|
+
{ path: 'GEMINI.md', shape: 'file', kind: 'instructions' },
|
|
143
|
+
{ path: '.gemini/skills', shape: 'directory', kind: 'skill' },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
user: { detect: '.gemini' },
|
|
147
|
+
},
|
|
148
|
+
{ name: 'devin-desktop', project: { detect: '.devin' } },
|
|
149
|
+
{
|
|
150
|
+
name: 'windsurf',
|
|
151
|
+
project: {
|
|
152
|
+
detect: '.windsurf',
|
|
153
|
+
skillsDirectory: '.windsurf/skills',
|
|
154
|
+
nonstandard: [
|
|
155
|
+
{ path: '.windsurfrules', shape: 'file', kind: 'instructions' },
|
|
156
|
+
{ path: '.windsurf/rules', shape: 'directory', kind: 'rule' },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
deprecated: 'devin-desktop',
|
|
160
|
+
},
|
|
43
161
|
]
|
|
44
162
|
|
|
163
|
+
const harnessNames = new Set<string>(harnessRegistry.map((harness) => harness.name))
|
|
164
|
+
|
|
165
|
+
function isHarnessName(value: string): value is HarnessName {
|
|
166
|
+
return harnessNames.has(value)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The `--harness` option, as names this registry knows. Both commands take the option and neither
|
|
171
|
+
* owns what a name means, so the parse lives beside the list it validates against: a harness added
|
|
172
|
+
* to the registry is accepted by both commands with nothing else to change, and the rejection reads
|
|
173
|
+
* the same either way.
|
|
174
|
+
*
|
|
175
|
+
* An unknown name throws rather than being dropped. A caller that asked for a harness by name and
|
|
176
|
+
* got a report about the two default ones has been told the wrong thing about their repository.
|
|
177
|
+
*/
|
|
178
|
+
export function parseHarnesses(value: string | undefined): HarnessName[] {
|
|
179
|
+
const requested = (value ?? '')
|
|
180
|
+
.split(',')
|
|
181
|
+
.map((name) => name.trim())
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
const unsupported = requested.filter((name) => !isHarnessName(name))
|
|
184
|
+
if (unsupported.length)
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Unsupported harness: ${unsupported.join(', ')}. Supported: ${harnessRegistry
|
|
187
|
+
.map((harness) => harness.name)
|
|
188
|
+
.join(', ')}.`,
|
|
189
|
+
)
|
|
190
|
+
return requested.filter(isHarnessName)
|
|
191
|
+
}
|
|
192
|
+
|
|
45
193
|
/** Enabled whether or not the repository already contains their directories. */
|
|
46
194
|
const defaultHarnesses: readonly HarnessName[] = ['claude-code', 'cursor']
|
|
47
195
|
|
|
@@ -56,5 +204,7 @@ function directoryExists(path: string): boolean {
|
|
|
56
204
|
/** The default harnesses, plus the preferred ones, plus every harness whose directory is present. */
|
|
57
205
|
export function selectHarnesses(root: string, preferred: readonly HarnessName[]): Harness[] {
|
|
58
206
|
const enabled = new Set<HarnessName>([...defaultHarnesses, ...preferred])
|
|
59
|
-
return harnessRegistry.filter(
|
|
207
|
+
return harnessRegistry.filter(
|
|
208
|
+
(harness) => enabled.has(harness.name) || directoryExists(join(root, harness.project.detect)),
|
|
209
|
+
)
|
|
60
210
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a harness needs in order to read the repository's `AGENTS.md`.
|
|
3
|
+
*
|
|
4
|
+
* A skills projection is one shape — a directory pointing at `.agents/skills` — so the registry
|
|
5
|
+
* models it as a path. Instruction bridges are not one shape: Claude Code needs a Markdown file
|
|
6
|
+
* whose body imports `AGENTS.md`, and Gemini CLI needs `AGENTS.md` added to an array inside a JSON
|
|
7
|
+
* settings file. A second bare path field would describe the first and lie about the second, so the
|
|
8
|
+
* variant carries what its own check needs.
|
|
9
|
+
*
|
|
10
|
+
* The `init` command writes none of these. They are the `init` skill's work, which is why every
|
|
11
|
+
* finding against one names the skill rather than a flag.
|
|
12
|
+
*/
|
|
13
|
+
export type InstructionBridge =
|
|
14
|
+
| {
|
|
15
|
+
/**
|
|
16
|
+
* A Markdown file whose body is an `@AGENTS.md` import, or a symlink to `AGENTS.md`. One is
|
|
17
|
+
* needed beside every `AGENTS.md` in the tree, not only the root file: an import bridges the
|
|
18
|
+
* one file next to it, so a nested `AGENTS.md` is invisible until it has its own.
|
|
19
|
+
*/
|
|
20
|
+
kind: 'import'
|
|
21
|
+
/** Repository-relative name, the same in every directory that holds an `AGENTS.md`. */
|
|
22
|
+
path: string
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
/** An `AGENTS.md` entry in an array inside a JSON settings file. */
|
|
26
|
+
kind: 'settings-entry'
|
|
27
|
+
/** Repository-relative path of the settings file. */
|
|
28
|
+
path: string
|
|
29
|
+
/** Dotted path to the array within it, as the harness documents it. */
|
|
30
|
+
key: string
|
|
31
|
+
}
|