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
@@ -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
- * `skillsDirectory` is the projection target a harness needs to see `.agents/skills`.
15
- * A harness that reads `.agents/skills` natively has none, and is never projected into.
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 Harness = {
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
- * Codex, Cursor, Copilot CLI, and Devin Desktop read `.agents/skills` directly, so they need no
27
- * projection. Only Claude Code and Gemini CLI read solely their own directory.
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
- { name: 'claude-code', detect: '.claude', skillsDirectory: '.claude/skills' },
37
- { name: 'cursor', detect: '.cursor' },
38
- { name: 'codex', detect: '.codex' },
39
- { name: 'copilot-cli', detect: '.github/skills' },
40
- { name: 'gemini-cli', detect: '.gemini', skillsDirectory: '.gemini/skills' },
41
- { name: 'devin-desktop', detect: '.devin' },
42
- { name: 'windsurf', detect: '.windsurf', skillsDirectory: '.windsurf/skills', deprecated: 'devin-desktop' },
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((harness) => enabled.has(harness.name) || directoryExists(join(root, harness.detect)))
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
+ }