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.
Files changed (65) 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 +382 -47
  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 +20 -34
  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 +19 -8
  32. package/skills/init/references/detection.md +5 -3
  33. package/skills/init/references/harnesses/cursor.md +9 -2
  34. package/skills/init/scripts/doctor.mjs +14 -0
  35. package/skills/init/scripts/init.mjs +7 -3
  36. package/skills/repair/README.md +21 -0
  37. package/skills/repair/SKILL.md +74 -0
  38. package/skills/repair/references/classes.md +49 -0
  39. package/skills/repair/scripts/doctor.mjs +14 -0
  40. package/src/cli.ts +40 -7
  41. package/src/command-output/command-output.ts +1 -4
  42. package/src/diagnose-bridges/diagnose-bridges.ts +21 -13
  43. package/src/diagnose-bridges/diagnose-instructions.ts +7 -2
  44. package/src/diagnose-bridges/doctor-guidance.ts +636 -90
  45. package/src/diagnose-bridges/doctor.command.ts +55 -28
  46. package/src/diagnose-bridges/git-bridge-state.ts +30 -0
  47. package/src/diagnose-bridges/locator.ts +36 -0
  48. package/src/diagnose-configuration/diagnose-configuration.ts +99 -0
  49. package/src/diagnose-mcp/diagnose-mcp.ts +135 -0
  50. package/src/diagnose-mcp/mcp-baseline.ts +122 -0
  51. package/src/diagnose-mcp/mcp-model.ts +88 -0
  52. package/src/diagnose-mcp/mcp-secrets.ts +101 -0
  53. package/src/diagnose-mcp/mcp-sources.ts +131 -0
  54. package/src/diagnose-nonstandard/diagnose-nonstandard.ts +109 -0
  55. package/src/harness-registry/harness-registry.ts +97 -4
  56. package/src/harness-registry/mcp-config.ts +37 -0
  57. package/src/harness-registry/nonstandard-artifact.ts +39 -0
  58. package/src/index.ts +18 -2
  59. package/src/initialize-harnesses/init.command.ts +13 -18
  60. package/src/initialize-harnesses/initialize-harnesses.ts +6 -3
  61. package/src/is-record/is-record.ts +12 -0
  62. package/src/skill-projection/skill-projection.ts +102 -16
  63. package/dist/cli.mjs.map +0 -1
  64. package/dist/init.command-zbntulIq.mjs +0 -1016
  65. package/dist/init.command-zbntulIq.mjs.map +0 -1
@@ -1,3 +1,8 @@
1
+ import type { Harness, HarnessScope } from '../harness-registry/harness-registry.ts'
2
+ import { harnessRegistry } from '../harness-registry/harness-registry.ts'
3
+ import type { NonstandardKind } from '../harness-registry/nonstandard-artifact.ts'
4
+ import { type Locator, locatorText } from './locator.ts'
5
+
1
6
  /**
2
7
  * The one place `doctor`'s guidance is written. The command reads `detail` and `repair` to fill its
3
8
  * `findings` and `help` sections; `skills/doctor/SKILL.md` is generated from the same table by
@@ -21,30 +26,120 @@ export type BridgeProblem =
21
26
  * repair: a skills bridge is rebuilt with `init` flags, and an instruction bridge is a file whose
22
27
  * content is the user's, so every repair here goes back to the `init` skill.
23
28
  */
29
+ /**
30
+ * Configuration that is present and **wrong**, as against a bridge that does not resolve. These are
31
+ * the faults the `repair` skill owns: none is expressible as an `init` flag, because `init`
32
+ * consolidates and creates but never corrects a file the user already wrote.
33
+ */
34
+ export type ConfigurationFault = 'deprecated-harness' | 'ignored-bridge' | 'unread-local-override' | 'unloadable-skill'
35
+
36
+ /**
37
+ * Every way the golden MCP server set and a harness's copy of it can disagree, plus the two
38
+ * credential findings.
39
+ *
40
+ * A family of its own because nothing it says is shared with the others. A bridge either resolves
41
+ * or does not; two MCP files never share a byte, so every finding here is the result of a semantic
42
+ * comparison, and each one names a **server** and usually a **field** rather than a file.
43
+ */
44
+ export type McpProblem =
45
+ | 'mcp-golden-unreadable'
46
+ | 'mcp-target-unreadable'
47
+ | 'mcp-unprojected'
48
+ | 'mcp-undeclared'
49
+ | 'mcp-diverged-target'
50
+ | 'mcp-diverged-golden'
51
+ | 'mcp-diverged-both'
52
+ | 'mcp-diverged-unknown'
53
+ | 'mcp-literal-secret'
54
+ | 'mcp-committed-secret'
55
+
56
+ /**
57
+ * Configuration that works, for exactly one harness. Not wrong and not missing — the two states the
58
+ * other families cover — which is why it is a family of its own: every other finding says something
59
+ * is broken, and these say something reaches less far than the repository meant.
60
+ *
61
+ * Each one names the canonical form it converts to, because surfacing without a destination is just
62
+ * a list of files somebody already knows about.
63
+ */
64
+ export type NonstandardProblem =
65
+ | 'nonstandard-instructions'
66
+ | 'nonstandard-rule'
67
+ | 'nonstandard-command'
68
+ | 'nonstandard-skill'
69
+ | 'nonstandard-subagent'
70
+
24
71
  export type InstructionProblem =
25
72
  | 'no-instructions'
26
73
  | 'instructions-missing'
27
74
  | 'instructions-unbridged'
28
75
  | 'instructions-unreadable'
29
76
 
30
- /** Everything `doctor` can report against, across both sections. */
31
- export type DoctorProblem = BridgeProblem | InstructionProblem
77
+ /** Everything `doctor` can report against, across every section. */
78
+ export type DoctorProblem = BridgeProblem | InstructionProblem | ConfigurationFault | McpProblem | NonstandardProblem
79
+
80
+ /** Alias kept for callers that name the whole set rather than one section. */
81
+ export type ConfigurationProblem = DoctorProblem
82
+
83
+ /**
84
+ * What resolves one finding, for `doctor`'s own output. Two fields rather than one string, because
85
+ * the caller `doctor` is written for is an agent parsing TOON, and the question it has to answer is
86
+ * "can I execute this, or is this judgment I hand to a skill?" A single string leaves that
87
+ * answerable only by parsing English.
88
+ *
89
+ * The contract is exact, and it is what makes the field safe to act on blindly:
90
+ *
91
+ * - `command` non-empty — a shell invocation that, run verbatim, **completes** the repair.
92
+ * - `command` empty — no single invocation does; act on `instruction` and do not synthesize one.
93
+ *
94
+ * So a diagnostic worth running is not a `command`. `diverged-both` carries none even though
95
+ * `git diff --no-index …` is perfectly runnable: the diff shows what differs, it does not reconcile
96
+ * anything. That is what stops an agent executing every `command` it is handed from rebuilding a
97
+ * diverged bridge over whichever side holds the newer edit. A skill invocation is not a `command`
98
+ * either — nothing in a shell runs `/buddy-agent-harness:init`.
99
+ */
100
+ export type RepairAction = {
101
+ /** A shell invocation that completes the repair, or empty when none does. */
102
+ command: string
103
+ /** The imperative, in prose, always present and complete on its own. */
104
+ instruction: string
105
+ }
32
106
 
33
107
  export type Repair = {
34
108
  problem: DoctorProblem
35
109
  /** What `doctor` prints in the `findings` row for this problem. */
36
110
  detail: string
37
111
  /**
38
- * The command that fixes it, for `doctor`'s own output. `path` is the repository-relative bridge
39
- * path; `cli` is how to invoke this tool.
112
+ * What fixes it, for `doctor`'s own output. `at` is where the finding is, in parts — the file
113
+ * alone for a bridge, the file, server, and field for an MCP finding; `cli` is how to invoke this
114
+ * tool. A repair that names only part of a locator reads it off `at` rather than splitting the
115
+ * rendered string, which no separator survives: a server may be named `io.github.foo`.
40
116
  */
41
- repair(path: string, cli: string): string
117
+ repair(at: Locator, cli: string): RepairAction
42
118
  /**
43
119
  * What the shipped skill tells an agent to do instead. A repair that rebuilds a bridge delegates
44
120
  * to the `init` skill rather than calling the `init` command, because rebuilding can move
45
121
  * user-authored skills and that judgment is the `init` skill's, not `doctor`'s.
46
122
  */
47
- skillRepair(path: string): string
123
+ skillRepair(at: Locator): string
124
+ }
125
+
126
+ /**
127
+ * One row of a repair table. The problem is the key it is filed under rather than a field on it, so
128
+ * a table typed `Record<…Problem, RepairRow>` cannot be written with a row missing: adding a
129
+ * variant to one of the unions above fails to compile until its row exists. That is the only place
130
+ * the invariant can be held — a union is not enumerable at runtime, so no test can walk it, and a
131
+ * lookup that asserts its own completeness reads `.detail` off `undefined` the first time it is
132
+ * wrong.
133
+ */
134
+ export type RepairRow = Omit<Repair, 'problem'>
135
+
136
+ /**
137
+ * A table as the list `doctor` reports, in the order it was written. The keys of a
138
+ * `Record<P, RepairRow>` are `P` by construction, which is what the assertion says; nothing here
139
+ * claims a row exists.
140
+ */
141
+ function repairsOf<Problem extends DoctorProblem>(table: Record<Problem, RepairRow>): readonly Repair[] {
142
+ return (Object.entries(table) as [Problem, RepairRow][]).map(([problem, row]) => ({ problem, ...row }))
48
143
  }
49
144
 
50
145
  /** How the command names itself. */
@@ -84,80 +179,116 @@ export function renderSkillLauncher(subcommand: string): string {
84
179
  return `#!/usr/bin/env node
85
180
  // Generated from src/diagnose-bridges/doctor-guidance.ts by scripts/generate-skills.ts. Do not edit by hand.
86
181
  import { dirname, join } from 'node:path'
87
- import { fileURLToPath } from 'node:url'
182
+ import { fileURLToPath, pathToFileURL } from 'node:url'
88
183
 
89
184
  // <package>/skills/<skill>/scripts/${subcommand}.mjs: four levels up is the package root.
90
185
  const packageRoot = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))))
186
+ const { run } = await import(pathToFileURL(join(packageRoot, 'dist', 'cli.mjs')).href)
91
187
 
92
- process.argv.splice(2, 0, '${subcommand}')
93
- await import(join(packageRoot, 'bin', '${commandInvocation}.mjs'))
188
+ // The subcommand is composed into a fresh argv rather than spliced into the global one, so nothing
189
+ // outside this file observes the rewrite. Applied only when non-zero, so a usage code clibuilder
190
+ // recorded itself is not overwritten by the zero \`run\` returns on that path.
191
+ const code = await run([...process.argv.slice(0, 2), '${subcommand}', ...process.argv.slice(2)])
192
+ if (code !== 0) process.exitCode = code
94
193
  `
95
194
  }
96
195
 
196
+ /**
197
+ * How the skill hands a repair to `repair`. Bridge and instruction repairs go to `init`, which
198
+ * writes both kinds of bridge in the first place; configuration that is present and wrong goes
199
+ * here, because no `init` flag corrects a file the user already wrote.
200
+ */
201
+ export const repairSkillInvocation = '/buddy-agent-harness:repair'
202
+
97
203
  /** How the skill hands a repair back to `init`. */
98
204
  export const initSkillInvocation = '/buddy-agent-harness:init'
99
205
 
206
+ /** Where the user authors the golden MCP server set, named in every MCP repair that points at it. */
207
+ const goldenSet = '.agents/buddy-agent-harness/mcp.toml'
208
+
100
209
  /** The skills bridges, reported in `bridges`. */
101
- export const bridgeRepairs: readonly Repair[] = [
102
- {
103
- problem: 'no-canonical',
210
+ const bridgeTable: Record<BridgeProblem, RepairRow> = {
211
+ 'no-canonical': {
104
212
  detail: 'the canonical skill directory does not exist, so no bridge can resolve',
105
- repair: (_path, cli) => `${cli} init`,
213
+ repair: (_at, cli) => ({
214
+ command: `${cli} init`,
215
+ instruction: `run \`${cli} init\` to create .agents/skills and the bridges into it`,
216
+ }),
106
217
  skillRepair: () => `run \`${initSkillInvocation}\`, which creates \`.agents/skills\` and the bridges`,
107
218
  },
108
- {
109
- problem: 'missing',
219
+ missing: {
110
220
  detail: 'no bridge at this path — the harness sees zero project skills',
111
- repair: (_path, cli) => `${cli} init`,
221
+ repair: ({ file }, cli) => ({
222
+ command: `${cli} init`,
223
+ instruction: `run \`${cli} init\` to create the bridge at ${file}`,
224
+ }),
112
225
  skillRepair: () => `run \`${initSkillInvocation}\``,
113
226
  },
114
- {
115
- problem: 'degraded',
227
+ degraded: {
116
228
  detail: 'expected a directory but found a regular file — checkout without core.symlinks',
117
- repair: (_path, cli) => `${cli} init --copy --force`,
118
- skillRepair: () => `run \`${initSkillInvocation} --copy --force\``,
229
+ repair: ({ file }, cli) => ({
230
+ command: `${cli} init --copy --force ${file}`,
231
+ instruction: `run \`${cli} init --copy --force ${file}\` to rebuild ${file} as a real directory`,
232
+ }),
233
+ skillRepair: ({ file }) => `run \`${initSkillInvocation} --copy --force ${file}\``,
119
234
  },
120
- {
121
- problem: 'stale',
235
+ stale: {
122
236
  detail: 'symlink does not resolve to .agents/skills',
123
- repair: (_path, cli) => `${cli} init --force`,
124
- skillRepair: () => `run \`${initSkillInvocation} --force\``,
237
+ repair: ({ file }, cli) => ({
238
+ command: `${cli} init --force ${file}`,
239
+ instruction: `run \`${cli} init --force ${file}\` to repoint ${file} at .agents/skills`,
240
+ }),
241
+ skillRepair: ({ file }) => `run \`${initSkillInvocation} --force ${file}\``,
125
242
  },
126
- {
127
- problem: 'diverged-bridge',
243
+ 'diverged-bridge': {
128
244
  detail: 'only the bridge changed since the two last agreed — an agent wrote through the copy',
129
- repair: (path, cli) =>
130
- `replace .agents/skills with ${path} to keep the newer edit and then run ${cli} init --force`,
131
- skillRepair: (path) =>
132
- `replace .agents/skills with ${path} to keep the newer edit, then run \`${initSkillInvocation} --force\``,
245
+ // No command, unlike `diverged-canonical`, and the asymmetry is real: `init` only ever builds a
246
+ // bridge *from* the canonical directory, so no flag promotes the bridge's newer content back
247
+ // into it. Deciding to keep that side is the caller's, and the rebuild is what follows.
248
+ repair: ({ file }, cli) => ({
249
+ command: '',
250
+ instruction: `replace .agents/skills with ${file} to keep the newer edit, then run \`${cli} init --force ${file}\``,
251
+ }),
252
+ skillRepair: ({ file }) =>
253
+ `replace .agents/skills with ${file} to keep the newer edit, then run \`${initSkillInvocation} --force ${file}\``,
133
254
  },
134
- {
135
- problem: 'diverged-canonical',
255
+ 'diverged-canonical': {
136
256
  detail: 'only .agents/skills changed since the two last agreed — the copy is stale',
137
- repair: (_path, cli) => `${cli} init --copy --force`,
138
- skillRepair: () => `run \`${initSkillInvocation} --copy --force\``,
257
+ repair: ({ file }, cli) => ({
258
+ command: `${cli} init --copy --force ${file}`,
259
+ instruction: `run \`${cli} init --copy --force ${file}\` to rebuild ${file} from the newer .agents/skills`,
260
+ }),
261
+ skillRepair: ({ file }) => `run \`${initSkillInvocation} --copy --force ${file}\``,
139
262
  },
140
- {
141
- problem: 'diverged-both',
263
+ 'diverged-both': {
142
264
  detail: 'both sides changed since they last agreed — rebuilding would discard one of them',
143
- repair: (path) => `git diff --no-index .agents/skills ${path} and reconcile by hand`,
144
- skillRepair: (path) => `run \`git diff --no-index .agents/skills ${path}\` and reconcile by hand`,
265
+ repair: ({ file }) => ({
266
+ command: '',
267
+ instruction: `reconcile .agents/skills with ${file} by hand — rebuilding would discard one of them; \`git diff --no-index .agents/skills ${file}\` shows what differs`,
268
+ }),
269
+ skillRepair: ({ file }) => `run \`git diff --no-index .agents/skills ${file}\` and reconcile by hand`,
145
270
  },
146
- {
147
- problem: 'diverged-unknown',
271
+ 'diverged-unknown': {
148
272
  detail: 'contents differ and no commit where they agreed was found — which side moved is unknown',
149
- repair: (path) => `git diff --no-index .agents/skills ${path} and reconcile by hand`,
150
- skillRepair: (path) => `run \`git diff --no-index .agents/skills ${path}\` and reconcile by hand`,
273
+ repair: ({ file }) => ({
274
+ command: '',
275
+ instruction: `reconcile .agents/skills with ${file} by hand — which side moved is unknown; \`git diff --no-index .agents/skills ${file}\` shows what differs`,
276
+ }),
277
+ skillRepair: ({ file }) => `run \`git diff --no-index .agents/skills ${file}\` and reconcile by hand`,
151
278
  },
152
- {
153
- problem: 'unpinned-copy',
279
+ 'unpinned-copy': {
154
280
  detail: 'tracked copy without the skip-worktree bit — the tree is dirty with content that must not be committed',
155
281
  // The index entry is the tracked symlink on a Windows checkout but the individual files in a
156
282
  // committed copy, so the paths are read back from git rather than assumed.
157
- repair: (path) => `git ls-files -z ${path} | xargs -0 git update-index --skip-worktree`,
158
- skillRepair: (path) => `run \`git ls-files -z ${path} | xargs -0 git update-index --skip-worktree\``,
283
+ repair: ({ file }) => ({
284
+ command: `git ls-files -z ${file} | xargs -0 git update-index --skip-worktree`,
285
+ instruction: `run \`git ls-files -z ${file} | xargs -0 git update-index --skip-worktree\` to restore the skip-worktree bit`,
286
+ }),
287
+ skillRepair: ({ file }) => `run \`git ls-files -z ${file} | xargs -0 git update-index --skip-worktree\``,
159
288
  },
160
- ]
289
+ }
290
+
291
+ export const bridgeRepairs: readonly Repair[] = repairsOf(bridgeTable)
161
292
 
162
293
  /**
163
294
  * The instruction bridges, reported in `instructions`. Every repair is the `init` skill: these are
@@ -165,41 +296,246 @@ export const bridgeRepairs: readonly Repair[] = [
165
296
  * while restoring the bridge is judgment no flag carries. `repair` therefore names the skill in
166
297
  * both places rather than pretending a shell command exists.
167
298
  */
168
- export const instructionRepairs: readonly Repair[] = [
169
- {
170
- problem: 'no-instructions',
299
+ const instructionTable: Record<InstructionProblem, RepairRow> = {
300
+ 'no-instructions': {
171
301
  detail: 'no AGENTS.md at the repository root, so every instruction bridge points at nothing',
172
- repair: () => initSkillInvocation,
302
+ repair: () => ({
303
+ command: '',
304
+ instruction: `hand this to \`${initSkillInvocation}\`, which derives AGENTS.md and the bridges to it`,
305
+ }),
173
306
  skillRepair: () => `run \`${initSkillInvocation}\`, which derives AGENTS.md and the bridges to it`,
174
307
  },
175
- {
176
- problem: 'instructions-missing',
308
+ 'instructions-missing': {
177
309
  detail: 'no instruction bridge at this path — the harness reads none of AGENTS.md',
178
- repair: () => initSkillInvocation,
310
+ repair: ({ file }) => ({
311
+ command: '',
312
+ instruction: `hand ${file} to \`${initSkillInvocation}\`, which writes the bridge into it`,
313
+ }),
179
314
  skillRepair: () => `run \`${initSkillInvocation}\``,
180
315
  },
181
- {
182
- problem: 'instructions-unbridged',
316
+ 'instructions-unbridged': {
183
317
  detail: 'the file is present but names AGENTS.md nowhere — the harness reads none of it',
184
- repair: () => initSkillInvocation,
318
+ repair: ({ file }) => ({
319
+ command: '',
320
+ instruction: `hand ${file} to \`${initSkillInvocation}\`, which adds the bridge without discarding what the file already says`,
321
+ }),
185
322
  skillRepair: () =>
186
323
  `run \`${initSkillInvocation}\`, which adds the bridge without discarding what the file already says`,
187
324
  },
188
- {
189
- problem: 'instructions-unreadable',
325
+ 'instructions-unreadable': {
190
326
  detail: 'the settings file does not parse, so the harness reads none of it',
191
- repair: () => initSkillInvocation,
327
+ repair: ({ file }) => ({
328
+ command: '',
329
+ instruction: `fix the JSON in ${file} by hand, then hand it to \`${initSkillInvocation}\``,
330
+ }),
192
331
  skillRepair: () => `fix the JSON by hand, then run \`${initSkillInvocation}\``,
193
332
  },
194
- ]
333
+ }
334
+
335
+ export const instructionRepairs: readonly Repair[] = repairsOf(instructionTable)
336
+
337
+ /**
338
+ * Configuration that is present and wrong. Detected here like everything else, but repaired by the
339
+ * `repair` skill rather than by `init` — which is why it is a section of its own.
340
+ */
341
+ const configurationTable: Record<ConfigurationFault, RepairRow> = {
342
+ 'deprecated-harness': {
343
+ detail:
344
+ 'a projection under a harness name that has been superseded — the replacement reads .agents/skills natively and needs no projection at all',
345
+ repair: ({ file }) => ({
346
+ command: '',
347
+ instruction: `remove ${file} and enable the harness that replaced it — \`${repairSkillInvocation}\` offers the correction`,
348
+ }),
349
+ skillRepair: () => `run \`${repairSkillInvocation}\``,
350
+ },
351
+ 'ignored-bridge': {
352
+ detail: 'a .gitignore rule matches this bridge — an untracked bridge swallows a real edit silently',
353
+ repair: ({ file }) => ({
354
+ command: '',
355
+ instruction: `narrow or remove the .gitignore rule matching ${file} — \`${repairSkillInvocation}\` offers the correction`,
356
+ }),
357
+ skillRepair: () => `run \`${repairSkillInvocation}\``,
358
+ },
359
+ 'unread-local-override': {
360
+ detail: 'no harness reads this filename, so everything in it is invisible to every agent',
361
+ repair: ({ file }) => ({
362
+ command: '',
363
+ instruction: `move ${file} to CLAUDE.local.md, or consolidate it into AGENTS.md — \`${repairSkillInvocation}\` offers the correction`,
364
+ }),
365
+ skillRepair: () => `run \`${repairSkillInvocation}\``,
366
+ },
367
+ 'unloadable-skill': {
368
+ detail: 'frontmatter that does not parse, or no description — either one makes a harness skip the skill outright',
369
+ repair: ({ file }) => ({
370
+ command: '',
371
+ instruction: `quote the description in ${file}, or add one — \`${repairSkillInvocation}\` offers the correction`,
372
+ }),
373
+ skillRepair: () => `run \`${repairSkillInvocation}\``,
374
+ },
375
+ }
376
+
377
+ const configurationRepairs: readonly Repair[] = repairsOf(configurationTable)
378
+
379
+ /**
380
+ * The golden MCP server set against the harness copies of it.
381
+ *
382
+ * Every repair names a **locator** rather than a file — `.cursor/mcp.json#servers.linear.command` —
383
+ * because a file holding twenty servers is not an address. The locator never carries a value: not
384
+ * a whole one, and not a prefix. `doctor` is safe to run from a session-start hook, so a value it
385
+ * echoes lands in agent context on every session and from there into transcripts, and `sk-ab…`
386
+ * leaks into exactly the same place the whole string would.
387
+ */
388
+ const mcpTable: Record<McpProblem, RepairRow> = {
389
+ 'mcp-golden-unreadable': {
390
+ detail:
391
+ 'the golden MCP set does not parse — the locator gives the line and column, and nothing else can be said about it',
392
+ repair: (at) => ({ command: '', instruction: `fix the TOML at ${locatorText(at)}` }),
393
+ skillRepair: (at) =>
394
+ `fix the TOML at ${locatorText(at)} by hand — the reported line and column are all that can be quoted, because the parser's own message repeats the offending line and that line is the one holding the credential`,
395
+ },
396
+ 'mcp-target-unreadable': {
397
+ detail:
398
+ 'this harness config does not parse, so the harness starts none of its servers and nothing in it can be compared',
399
+ repair: (at) => ({ command: '', instruction: `fix the syntax of ${locatorText(at)}` }),
400
+ skillRepair: (at) => `fix the syntax of ${locatorText(at)} by hand`,
401
+ },
402
+ 'mcp-unprojected': {
403
+ detail: 'the golden set declares this server and the harness config does not carry it',
404
+ repair: ({ file, server }) => ({
405
+ command: '',
406
+ instruction: `add the server ${server} to ${file}, or drop it from the golden set`,
407
+ }),
408
+ skillRepair: (at) =>
409
+ `add the server at ${locatorText(at)} to that file from its golden entry, or drop it from the golden set`,
410
+ },
411
+ 'mcp-undeclared': {
412
+ detail: 'the harness config carries this server and the golden set does not declare it',
413
+ repair: ({ file, server }) => ({
414
+ command: '',
415
+ instruction: `add the server ${server} to ${goldenSet}, or drop it from ${file}`,
416
+ }),
417
+ skillRepair: (at) =>
418
+ `copy the server at ${locatorText(at)} into ${goldenSet}, refusing any literal credential it carries, or drop it from ${at.file}`,
419
+ },
420
+ 'mcp-diverged-target': {
421
+ detail: 'only the harness config changed since the two last agreed — the edit was made through the copy',
422
+ repair: (at) => ({ command: '', instruction: `reconcile the value at ${locatorText(at)} back into ${goldenSet}` }),
423
+ skillRepair: (at) => `reconcile the value at ${locatorText(at)} back into ${goldenSet}, field by field`,
424
+ },
425
+ 'mcp-diverged-golden': {
426
+ detail: 'only the golden set changed since the two last agreed — the harness copy is stale',
427
+ repair: (at) => ({ command: '', instruction: `update ${locatorText(at)} from the golden entry` }),
428
+ skillRepair: (at) => `update ${locatorText(at)} from the golden entry`,
429
+ },
430
+ 'mcp-diverged-both': {
431
+ detail: 'both sides changed since they last agreed — merging either way would discard the other',
432
+ repair: (at) => ({ command: '', instruction: `reconcile ${locatorText(at)} against ${goldenSet} by hand` }),
433
+ skillRepair: (at) =>
434
+ `reconcile ${locatorText(at)} against ${goldenSet} by hand — never merge a three-way conflict automatically`,
435
+ },
436
+ 'mcp-diverged-unknown': {
437
+ detail:
438
+ 'the two disagree and no baseline says which side moved — neither history nor a last-projected record covers this server',
439
+ repair: (at) => ({ command: '', instruction: `compare ${locatorText(at)} against ${goldenSet} by hand` }),
440
+ skillRepair: (at) => `compare ${locatorText(at)} against ${goldenSet} by hand`,
441
+ },
442
+ 'mcp-literal-secret': {
443
+ detail: 'a credential-bearing field holds a literal rather than a reference to an environment variable',
444
+ repair: (at) => ({
445
+ command: '',
446
+ instruction: `move the value at ${locatorText(at)} into an environment variable and reference it`,
447
+ }),
448
+ skillRepair: (at) =>
449
+ `move the value at ${locatorText(at)} into an environment variable and reference it — read the value from the file, never from this report, and never repeat it back`,
450
+ },
451
+ 'mcp-committed-secret': {
452
+ detail:
453
+ 'a credential-bearing field holds a literal in a git-tracked file — the credential is committed, and moving it does not un-commit it',
454
+ repair: (at) => ({
455
+ command: '',
456
+ instruction: `rotate the credential behind ${locatorText(at)}, then reference it from an environment variable`,
457
+ }),
458
+ skillRepair: (at) =>
459
+ `rotate the credential behind ${locatorText(at)} at its issuer, then reference it from an environment variable — it is in the repository's history, so moving it is not enough`,
460
+ },
461
+ }
195
462
 
196
- /** Both sections, in the order `doctor` reports them. */
197
- export const doctorRepairs: readonly Repair[] = [...bridgeRepairs, ...instructionRepairs]
463
+ const mcpRepairs: readonly Repair[] = repairsOf(mcpTable)
198
464
 
199
- const repairsByProblem = new Map(doctorRepairs.map((entry) => [entry.problem, entry]))
465
+ /**
466
+ * Configuration only one harness can read. Every repair here is a **conversion**, and every one of
467
+ * them is the `init` skill's: consolidating what a repository already has is exactly its remit, and
468
+ * the content is the user's, so it is offered and never written on sight. `repair` owns none of
469
+ * them — it corrects configuration that is wrong, and none of this is wrong.
470
+ *
471
+ * `nonstandard-subagent` is the exception that names no owner. There is no cross-harness subagent
472
+ * format to convert to, so the honest report is that the gap exists, not a repair that cannot run.
473
+ */
474
+ const nonstandardTable: Record<NonstandardProblem, RepairRow> = {
475
+ 'nonstandard-instructions': {
476
+ detail: 'instruction content only one harness reads — AGENTS.md carries the same prose to all of them',
477
+ repair: ({ file }) => ({
478
+ command: '',
479
+ instruction: `consolidate ${file} into AGENTS.md and leave a generated bridge in its place — \`${initSkillInvocation}\` offers the consolidation`,
480
+ }),
481
+ skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\`, which consolidates it into AGENTS.md`,
482
+ },
483
+ 'nonstandard-rule': {
484
+ detail: 'a path-scoped rule only one harness reads — a skill reaches every harness where the scoping is incidental',
485
+ repair: ({ file }) => ({
486
+ command: '',
487
+ instruction: `convert ${file} into a skill under .agents/skills where its guidance is not tied to the paths it names — \`${initSkillInvocation}\` offers the conversion; keep it where the path scoping is the point`,
488
+ }),
489
+ skillRepair: ({ file }) =>
490
+ `hand ${file} to \`${initSkillInvocation}\` to convert into a skill, unless the path scoping is load-bearing`,
491
+ },
492
+ 'nonstandard-command': {
493
+ detail: 'a harness command file — a skill is the portable form, and this harness reads skills too',
494
+ repair: ({ file }) => ({
495
+ command: '',
496
+ instruction: `move ${file} to .agents/skills/<name>/SKILL.md — \`${initSkillInvocation}\` offers the move`,
497
+ }),
498
+ skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\`, which moves it to .agents/skills`,
499
+ },
500
+ 'nonstandard-skill': {
501
+ detail: 'a skill under a harness directory rather than the canonical one, where only that harness finds it',
502
+ repair: ({ file }) => ({
503
+ command: '',
504
+ instruction: `move ${file} to .agents/skills and project it back where the harness needs one — \`${initSkillInvocation}\` offers the move`,
505
+ }),
506
+ skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\`, which moves it to .agents/skills`,
507
+ },
508
+ 'nonstandard-subagent': {
509
+ detail:
510
+ 'a subagent definition with no cross-harness form — nothing outside this harness can read it, and no canonical form exists yet',
511
+ repair: ({ file }) => ({
512
+ command: '',
513
+ instruction: `leave ${file} where it is and record that it reaches one harness only — no portable form exists to convert it to`,
514
+ }),
515
+ skillRepair: ({ file }) =>
516
+ `leave ${file} in place — no portable form exists yet, so this is work for a person rather than a skill`,
517
+ },
518
+ }
519
+
520
+ export const nonstandardRepairs: readonly Repair[] = repairsOf(nonstandardTable)
200
521
 
522
+ /**
523
+ * All five sections, in the order `doctor` reports them. Annotated over the whole union rather than
524
+ * inferred, so a problem added to any of the four fails here too if its section was left alone.
525
+ */
526
+ const doctorTable: Record<DoctorProblem, RepairRow> = {
527
+ ...bridgeTable,
528
+ ...instructionTable,
529
+ ...configurationTable,
530
+ ...mcpTable,
531
+ ...nonstandardTable,
532
+ }
533
+
534
+ export const doctorRepairs: readonly Repair[] = repairsOf(doctorTable)
535
+
536
+ /** The repair for one problem. Total by construction: the table is keyed by the union. */
201
537
  export function repairFor(problem: DoctorProblem): Repair {
202
- return repairsByProblem.get(problem) as Repair
538
+ return { problem, ...doctorTable[problem] }
203
539
  }
204
540
 
205
541
  export const doctorSkill = {
@@ -212,19 +548,34 @@ export const doctorSkill = {
212
548
  export const generatedSkillWarning =
213
549
  '<!-- Generated from src/diagnose-bridges/doctor-guidance.ts by scripts/generate-skills.ts. Do not edit by hand. -->'
214
550
 
551
+ /**
552
+ * A file the generator writes under `skills/doctor/`, path relative to that directory. The skill is
553
+ * split the way `init`'s is: a lean `SKILL.md` an agent always reads, and reference pages it loads
554
+ * only for the family it is acting on. One flat file made every reader of one finding pay for the
555
+ * prose behind all five.
556
+ */
557
+ export type GeneratedDoc = { path: string; content: string }
558
+
559
+ /** A repair table, rendered for the skill's reader. */
560
+ function repairTable(repairs: readonly Repair[]): string {
561
+ // A repair may itself contain a pipe, which would otherwise end the table cell early.
562
+ const cell = (value: string) => value.replaceAll('|', '\\|')
563
+ return `| Finding | What it means | Repair |
564
+ | --- | --- | --- |
565
+ ${repairs
566
+ .map((entry) => `| \`${entry.problem}\` | ${entry.detail} | ${cell(entry.skillRepair({ file: '<path>' }))} |`)
567
+ .join('\n')}`
568
+ }
569
+
215
570
  /**
216
571
  * The shipped `doctor` skill, rendered from the same table the command prints. `version` is the
217
572
  * package version the skill ships with; it pins the `npx` invocation so the table and the CLI that
218
573
  * produced it stay on the same breaking line.
574
+ *
575
+ * The finding tables are NOT here. They live in the reference pages below, and this file carries
576
+ * only what every reader needs: how to run it, how to read the report, and where to go next.
219
577
  */
220
578
  export function renderDoctorSkill(version: string): string {
221
- // A repair may itself contain a pipe, which would otherwise end the table cell early.
222
- const cell = (value: string) => value.replaceAll('|', '\\|')
223
- const rows = (repairs: readonly Repair[]) =>
224
- repairs
225
- .map((entry) => `| \`${entry.problem}\` | ${entry.detail} | ${cell(entry.skillRepair('<path>'))} |`)
226
- .join('\n')
227
-
228
579
  return `---
229
580
  name: ${doctorSkill.name}
230
581
  description: ${doctorSkill.description}
@@ -252,43 +603,238 @@ The command is read-only. It never repairs anything, so it is safe to run at any
252
603
 
253
604
  \`instructions\` lists every instruction bridge into \`AGENTS.md\`, with a \`status\` of \`ok\`, \`missing\`, \`unbridged\`, or \`unreadable\`. They are a separate section because nothing about them is shared: a different \`kind\`, a different status vocabulary, and a repair that is never a command.
254
605
 
255
- \`findings\` explains each problem from either section and \`help\` names its repair. Apply the repair from the tables below, then re-run \`doctor\`.
606
+ \`findings\` explains each problem and carries more than the two sections above: the configuration, MCP, and non-standard findings have no section of their own, because they are about files rather than about bridges. \`help\` carries each repair, one row per distinct repair, with two columns:
256
607
 
257
- Do not run an \`init\` command yourself. Rebuilding a skills bridge can move skills a user wrote, and rewriting an instruction file touches prose a person authored — both are the \`init\` skill's judgment, so hand the repair to \`${initSkillInvocation}\` instead. A \`help\` line naming that skill has no shell equivalent at all.
608
+ - \`command\` — a shell invocation that, run exactly as given, **completes** the repair.
609
+ - \`instruction\` — the same repair in the imperative, always present and complete on its own.
610
+
611
+ \`command\` is empty whenever no single invocation does the job, and that emptiness is the signal: act on \`instruction\` and do not assemble a command out of it. A runnable invocation quoted *inside* an \`instruction\` is not the repair either — \`diverged-both\` names \`git diff --no-index\` because the diff shows you what differs, not because running it reconciles anything. Apply the repair, then re-run \`doctor\`.
612
+
613
+ Nothing in \`help\` is wrapped. An earlier version prefixed every repair with \`Run\`, which read as an instruction to paste prose into a shell.
614
+
615
+ Do not run an \`init\` command yourself. Rebuilding a skills bridge can move skills a user wrote, and rewriting an instruction file touches prose a person authored — both are the \`init\` skill's judgment, so hand the repair to \`${initSkillInvocation}\` instead. Every such repair carries an empty \`command\`: a skill invocation has no shell equivalent at all.
258
616
 
259
617
  When every bridge resolves, \`findings\` says so outright rather than being empty.
260
618
 
261
619
  The default output is TOON, which is what you parse. Add \`--format text\` when you need to show the same report to a person, or \`--format json\`.
262
620
 
263
- ## Skills bridge findings
621
+ ## Where the detail is
622
+
623
+ Every \`problem\` name routes to exactly one page. Load the page for the finding in front of you and leave the rest unread — the tables are long, and reading four families to act on one is what this split exists to stop.
624
+
625
+ | Page | Load it for |
626
+ | --- | --- |
627
+ | \`references/bridges.md\` | any \`bridges\` row that is not \`ok\` |
628
+ | \`references/instructions.md\` | any \`instructions\` row that is not \`ok\` |
629
+ | \`references/configuration.md\` | a finding about the configuration around the bridges rather than a bridge |
630
+ | \`references/mcp.md\` | any finding whose path is an MCP locator — **always** before acting on a credential finding |
631
+ | \`references/nonstandard.md\` | a finding about configuration only one harness can read |
632
+ | \`references/harnesses/<name>.md\` | the paths and files one named harness uses |
633
+
634
+ ## Rules
635
+
636
+ - Never repair a \`diverged-both\` or \`diverged-unknown\` bridge by re-running \`init\`. Rebuilding overwrites whichever side holds the newer edit. Reconcile the two directories first.
637
+ - Edit skills at \`.agents/skills/<name>/SKILL.md\`. Editing through a bridge is only safe when that bridge is a symlink.
638
+ - Do not add bridges to \`.gitignore\`. An untracked bridge swallows a real edit silently.
639
+ - Never repeat a value from a file an \`mcp-literal-secret\` or \`mcp-committed-secret\` finding points at. The report withheld it on purpose, and quoting it back puts it in the transcript anyway.
640
+ - Write instructions in \`AGENTS.md\`, never in \`CLAUDE.md\`. A bridge file holds the import and any harness-specific notes; content written there reaches one harness and drifts from the canonical file.
641
+ `
642
+ }
643
+
644
+ /** Where each kind of harness-exclusive artifact is meant to end up, for the per-harness tables. */
645
+ function conversionOf(kind: NonstandardKind): string {
646
+ switch (kind) {
647
+ case 'instructions':
648
+ return '`AGENTS.md`, with a generated bridge left behind'
649
+ case 'rule':
650
+ return 'a skill, where the path scoping is incidental'
651
+ case 'command':
652
+ return '`.agents/skills/<name>/SKILL.md`'
653
+ case 'skill':
654
+ return '`.agents/skills`, projected back if needed'
655
+ case 'subagent':
656
+ return 'nothing yet — no cross-harness format exists'
657
+ }
658
+ }
264
659
 
265
- | Finding | What it means | Repair |
660
+ /** How one harness's scope reads, for its generated reference page. */
661
+ function scopeRows(scope: HarnessScope): string {
662
+ const bridge = scope.instructionBridge
663
+ const mcp = scope.mcpConfig
664
+ return [
665
+ `| detection directory | \`${scope.detect}\` |`,
666
+ `| skills projection | ${scope.skillsDirectory ? `\`${scope.skillsDirectory}\` — written by \`init\`` : 'none — reads `.agents/skills` natively'} |`,
667
+ `| instruction bridge | ${
668
+ bridge === undefined
669
+ ? 'none'
670
+ : bridge.kind === 'import'
671
+ ? `\`${bridge.path}\` — an import of \`AGENTS.md\``
672
+ : `\`${bridge.path}\` — \`AGENTS.md\` in the \`${bridge.key}\` entry`
673
+ } |`,
674
+ `| MCP configuration | ${mcp === undefined ? 'none' : `\`${mcp.path}\` — the \`${mcp.key}\` key, ${mcp.format}${mcp.shared ? ', shared with other settings' : ''}`} |`,
675
+ ].join('\n')
676
+ }
677
+
678
+ /**
679
+ * One page per harness, generated from the registry rather than written by hand. These are the
680
+ * paths the detectors actually use, so a page written beside them would drift the first time a
681
+ * harness moved a file. Editorial judgment about a harness — what is contested, what not to
682
+ * generate — stays in the `init` skill's own reference pages, which these link to where one exists.
683
+ */
684
+ function harnessPage(harness: Harness, hasInitReference: boolean): GeneratedDoc {
685
+ const deprecated =
686
+ harness.deprecated === undefined
687
+ ? ''
688
+ : `\n> **Superseded by \`${harness.deprecated}\`.** The legacy paths still work, so a projection here keeps resolving and \`doctor\` reports the name as deprecated rather than broken. New repositories should enable \`${harness.deprecated}\`.\n`
689
+
690
+ const user =
691
+ harness.user === undefined
692
+ ? '\nNo user-scope paths are primary-sourced for this harness, so `doctor` describes none.\n'
693
+ : `\n## User scope
694
+
695
+ Described, never written: \`init\` and \`doctor\` both work inside a repository.
696
+
697
+ | What | Path |
698
+ | --- | --- |
699
+ ${scopeRows(harness.user)}
700
+ `
701
+
702
+ const artifacts = harness.project.nonstandard ?? []
703
+ const nonstandard =
704
+ artifacts.length === 0
705
+ ? ''
706
+ : `\n## Configuration only this harness reads
707
+
708
+ Reported by \`doctor\` so it can be converted; see \`../nonstandard.md\` for what each conversion is.
709
+
710
+ | Path | Kind | Converts to |
266
711
  | --- | --- | --- |
267
- ${rows(bridgeRepairs)}
712
+ ${artifacts
713
+ .map(
714
+ (artifact) =>
715
+ `| \`${artifact.path}${artifact.shape === 'directory' ? '/' : ''}\` | ${artifact.kind} | ${conversionOf(artifact.kind)} |`,
716
+ )
717
+ .join('\n')}
718
+ `
719
+
720
+ const editorial = hasInitReference
721
+ ? `\n## Judgment about this harness\n\nWhat to generate for it, what to leave alone, and which claims are contested: \`../../../init/references/harnesses/${harness.name}.md\`. That page is hand-written and is the one to read before writing anything for this harness.\n`
722
+ : ''
723
+
724
+ return {
725
+ path: `references/harnesses/${harness.name}.md`,
726
+ content: `${generatedSkillWarning}
727
+
728
+ # ${harness.name}
729
+ ${deprecated}
730
+ ## Project scope
731
+
732
+ Where \`doctor\` looks inside a repository.
733
+
734
+ | What | Path |
735
+ | --- | --- |
736
+ ${scopeRows(harness.project)}
737
+ ${user}${nonstandard}${editorial}`,
738
+ }
739
+ }
740
+
741
+ /**
742
+ * The reference pages, split by finding family plus one per harness. `initReferences` names the
743
+ * harnesses the `init` skill has a hand-written page for; it is read off the filesystem by the
744
+ * generator rather than written down here, so a page added there is linked without a second edit.
745
+ */
746
+ export function renderDoctorReferences(initReferences: ReadonlySet<string>): GeneratedDoc[] {
747
+ return [
748
+ {
749
+ path: 'references/bridges.md',
750
+ content: `${generatedSkillWarning}
751
+
752
+ # Skills bridge findings
753
+
754
+ ${repairTable(bridgeRepairs)}
268
755
 
269
756
  Substitute the reported bridge path for \`<path>\`.
270
757
 
271
- ## Instruction bridge findings
758
+ ## The Windows case
272
759
 
273
- | Finding | What it means | Repair |
274
- | --- | --- | --- |
275
- ${rows(instructionRepairs)}
760
+ The common failure is \`degraded\`. Creating a symlink on Windows needs a privilege most accounts do not have, and Git for Windows gates it separately with \`core.symlinks\`, which its installer leaves off. With \`core.symlinks=false\` git does not error — it writes the symlink out as a regular file whose contents are the target path. \`${initSkillInvocation} --copy --force\` rebuilds every bridge as a real directory on that machine; name one bridge to rebuild only that one.
761
+
762
+ A copy is a snapshot rather than a live projection, and an agent that edits a skill through it writes into the copy instead of into \`.agents/skills\`. That is what the \`diverged\` findings catch.
763
+ `,
764
+ },
765
+ {
766
+ path: 'references/instructions.md',
767
+ content: `${generatedSkillWarning}
768
+
769
+ # Instruction bridge findings
770
+
771
+ ${repairTable(instructionRepairs)}
276
772
 
277
773
  \`unbridged\` is the one to read carefully. The file is there and looks fine, and it names \`AGENTS.md\` nowhere — a \`CLAUDE.md\` someone overwrote with real content, or a \`.gemini/settings.json\` another tool rewrote without \`AGENTS.md\` in \`context.fileName\`. Never fix it by replacing the file: the content that displaced the bridge may be the only copy of something.
278
774
 
279
775
  An instruction bridge is reported per file, so a monorepo gets one row per \`AGENTS.md\` in the tree. Each nested \`AGENTS.md\` needs its own stub — an import bridges the file beside it and nothing deeper.
776
+ `,
777
+ },
778
+ {
779
+ path: 'references/configuration.md',
780
+ content: `${generatedSkillWarning}
280
781
 
281
- ## The Windows case
782
+ # Configuration findings
282
783
 
283
- The common failure is \`degraded\`. Creating a symlink on Windows needs a privilege most accounts do not have, and Git for Windows gates it separately with \`core.symlinks\`, which its installer leaves off. With \`core.symlinks=false\` git does not error — it writes the symlink out as a regular file whose contents are the target path. \`${initSkillInvocation} --copy --force\` rebuilds the bridges as real directories on that machine.
784
+ The bridges resolve, and the configuration around them is still wrong: a superseded harness name, a git-ignored bridge, a local-override file nothing reads, a skill whose frontmatter makes every harness skip it. None of these is an \`init\` flag — \`init\` consolidates and creates, and will not correct a file the user already wrote. They go to the \`repair\` skill, which offers each correction with its before and after and writes only what is approved.
284
785
 
285
- A copy is a snapshot rather than a live projection, and an agent that edits a skill through it writes into the copy instead of into \`.agents/skills\`. That is what the \`diverged\` findings catch.
786
+ ${repairTable(configurationRepairs)}
787
+ `,
788
+ },
789
+ {
790
+ path: 'references/mcp.md',
791
+ content: `${generatedSkillWarning}
286
792
 
287
- ## Rules
793
+ # MCP findings
288
794
 
289
- - Never repair a \`diverged-both\` or \`diverged-unknown\` bridge by re-running \`init\`. Rebuilding overwrites whichever side holds the newer edit. Reconcile the two directories first.
290
- - Edit skills at \`.agents/skills/<name>/SKILL.md\`. Editing through a bridge is only safe when that bridge is a symlink.
291
- - Do not add bridges to \`.gitignore\`. An untracked bridge swallows a real edit silently.
292
- - Write instructions in \`AGENTS.md\`, never in \`CLAUDE.md\`. A bridge file holds the import and any harness-specific notes; content written there reaches one harness and drifts from the canonical file.
293
- `
795
+ A repository may keep a **golden MCP server set** at \`${goldenSet}\` — one canonical entry per server, in the superset of fields the supported hosts accept, written by the user. Where it exists, \`doctor\` compares it against each harness's own MCP configuration and reports how the two have drifted. **No golden set means no MCP drift findings at all**, and a harness with no MCP file yet has nothing that could have drifted.
796
+
797
+ Comparison is semantic. Six config keys across three file formats means no two of these files are ever byte-equal, so each side is parsed into one model and the models are compared. A field the golden set leaves unset is never a difference, however a harness fills it in: a host restating its own default and a user's deliberate edit are indistinguishable there, and treating both as changes is what makes a golden set accumulate noise.
798
+
799
+ Each finding names a **locator**, not a file: \`.cursor/mcp.json#servers.linear.command\` is the server and field, and that is what you route on.
800
+
801
+ ${repairTable(mcpRepairs)}
802
+
803
+ ## Credentials
804
+
805
+ The two secret findings are the ones to handle carefully.
806
+
807
+ - **The report never contains the value.** It gives you the locator and stops. Read the value out of the file named in the locator, and do not repeat it into your reply, into a commit message, or into any other file. There is no truncated preview to work from because a truncated credential is a leaked credential in the same transcript.
808
+ - **\`mcp-committed-secret\` is not \`mcp-literal-secret\` with worse wording.** The file is tracked, so the value is in the repository's history and every clone already has it. Moving it into an environment variable fixes the working tree and changes nothing about that. Rotate it at its issuer first.
809
+ - **A reference passes.** \`\${LINEAR_TOKEN}\` and \`Bearer \${LINEAR_TOKEN}\` are the documented ways to write these fields and are never reported. The test is that shape, not how random the value looks.
810
+ - **An unreadable golden set is reported by position only.** The parser's own message quotes the line it failed on, and in this file that line is the one holding the credential — so neither the message nor the offending line is ever carried into the report. Open the file at the reported line and column.
811
+ `,
812
+ },
813
+ {
814
+ path: 'references/nonstandard.md',
815
+ content: `${generatedSkillWarning}
816
+
817
+ # Non-standard configuration
818
+
819
+ Configuration only one harness can read. Nothing here is broken — a Cursor rule does exactly what it says — which is why none of it is in the other four families. What these findings report is **reach**: guidance that lands in one tool and nowhere else, where nobody finds out except by noticing an agent behave differently somewhere else.
820
+
821
+ Each finding names the canonical form it converts to. The direction is always the same: move the content to a canonical source, and let the harness file be **generated** from it rather than authored beside it. A repository with none of these is the target; it is a direction to walk, not a gate to pass.
822
+
823
+ ${repairTable(nonstandardRepairs)}
824
+
825
+ ## Two of them need judgment, not a move
826
+
827
+ **A path-scoped rule may have nowhere to go.** \`AGENTS.md\` scopes by directory nesting and a skill is loaded on relevance, so neither reproduces "these globs and no others". Where the scoping is incidental — a rule that happens to name paths but says something generally true — a skill carries it everywhere. Where the scoping is the point, leave it, and the finding stays as the record of why.
828
+
829
+ **A subagent has no portable form at all.** No cross-harness format exists, so the finding is the gap rather than a repair. It names no skill, and it is work for a person if it is work at all.
830
+
831
+ ## What is not reported
832
+
833
+ A harness directory that is a **symlink** is a projection someone already made, not configuration authored here. An artifact is reported whether or not its harness is enabled: a \`.cursorrules\` in a repository nobody opens in Cursor is still instruction content \`AGENTS.md\` does not carry, and filtering by the enabled set would hide exactly the drift worth converting.
834
+
835
+ Hooks, LSP settings, and output styles are not covered yet. Their event names and shapes differ by harness with no safe projection, and half-reporting them would be worse than the silence.
836
+ `,
837
+ },
838
+ ...harnessRegistry.map((harness) => harnessPage(harness, initReferences.has(harness.name))),
839
+ ]
294
840
  }