buddy-agent-harness 0.4.1 → 0.6.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.
@@ -0,0 +1,140 @@
1
+ import { lstatSync, readFileSync, readlinkSync } from 'node:fs'
2
+ import { join, posix } from 'node:path'
3
+ import type { Harness, HarnessName } from '../harness-registry/harness-registry.ts'
4
+ import type { InstructionBridge } from '../harness-registry/instruction-bridge.ts'
5
+ import { agentsFileDirectories } from './agents-files.ts'
6
+ import type { BridgeFinding } from './diagnose-bridges.ts'
7
+ import { type InstructionProblem, repairFor } from './doctor-guidance.ts'
8
+ import { parseJsonWithComments } from './json-with-comments.ts'
9
+
10
+ /** What is on disk where an instruction bridge belongs. */
11
+ export type InstructionKind = 'import' | 'symlink' | 'settings-entry' | 'file' | 'none'
12
+ /**
13
+ * `unbridged` is the case a skills bridge has no equivalent of: the file is there, so nothing looks
14
+ * wrong, and it names `AGENTS.md` nowhere — a `CLAUDE.md` overwritten with real content, or a
15
+ * settings file another tool rewrote.
16
+ */
17
+ export type InstructionStatus = 'ok' | 'missing' | 'unbridged' | 'unreadable'
18
+
19
+ export type InstructionReport = {
20
+ harness: HarnessName
21
+ /** Repository-relative path of the file that carries the bridge. */
22
+ path: string
23
+ kind: InstructionKind
24
+ status: InstructionStatus
25
+ }
26
+
27
+ export const canonicalInstructions = 'AGENTS.md'
28
+
29
+ type Inspection = {
30
+ kind: InstructionKind
31
+ status: InstructionStatus
32
+ problem?: InstructionProblem
33
+ }
34
+
35
+ /**
36
+ * An import bridges the `AGENTS.md` beside it and nothing else, so the check is per directory
37
+ * holding one rather than per harness. A line of its own is enough: the harness reference allows
38
+ * Claude-specific notes below the import, and a file that carries the import plus notes still
39
+ * bridges.
40
+ */
41
+ function inspectImport(target: string): Inspection {
42
+ let stats: ReturnType<typeof lstatSync>
43
+ try {
44
+ stats = lstatSync(target)
45
+ } catch {
46
+ return { kind: 'none', status: 'missing', problem: 'instructions-missing' }
47
+ }
48
+
49
+ if (stats.isSymbolicLink()) {
50
+ return readlinkSync(target) === canonicalInstructions
51
+ ? { kind: 'symlink', status: 'ok' }
52
+ : { kind: 'symlink', status: 'unbridged', problem: 'instructions-unbridged' }
53
+ }
54
+
55
+ const imported = readFileSync(target, 'utf8')
56
+ .split('\n')
57
+ .some((line) => line.trim() === `@${canonicalInstructions}`)
58
+ return imported
59
+ ? { kind: 'import', status: 'ok' }
60
+ : { kind: 'file', status: 'unbridged', problem: 'instructions-unbridged' }
61
+ }
62
+
63
+ /** Reads a dotted key out of parsed JSON without asserting anything about the rest of the file. */
64
+ function valueAt(settings: unknown, key: string): unknown {
65
+ return key.split('.').reduce<unknown>((value, segment) => {
66
+ if (typeof value !== 'object' || value === null) return undefined
67
+ return (value as Record<string, unknown>)[segment]
68
+ }, settings)
69
+ }
70
+
71
+ function inspectSettingsEntry(target: string, key: string): Inspection {
72
+ let source: string
73
+ try {
74
+ source = readFileSync(target, 'utf8')
75
+ } catch {
76
+ return { kind: 'none', status: 'missing', problem: 'instructions-missing' }
77
+ }
78
+
79
+ const settings = parseJsonWithComments(source)
80
+ if (settings === undefined) return { kind: 'file', status: 'unreadable', problem: 'instructions-unreadable' }
81
+
82
+ const entry = valueAt(settings, key)
83
+ return Array.isArray(entry) && entry.includes(canonicalInstructions)
84
+ ? { kind: 'settings-entry', status: 'ok' }
85
+ : { kind: 'file', status: 'unbridged', problem: 'instructions-unbridged' }
86
+ }
87
+
88
+ /** Every path an instruction bridge of this kind belongs at, in report order. */
89
+ function pathsFor(root: string, bridge: InstructionBridge): string[] {
90
+ if (bridge.kind !== 'import') return [bridge.path]
91
+ return agentsFileDirectories(root).map((directory) => (directory ? posix.join(directory, bridge.path) : bridge.path))
92
+ }
93
+
94
+ function inspect(target: string, bridge: InstructionBridge): Inspection {
95
+ return bridge.kind === 'import' ? inspectImport(target) : inspectSettingsEntry(target, bridge.key)
96
+ }
97
+
98
+ /**
99
+ * Reports whether every enabled harness can still read `AGENTS.md`.
100
+ *
101
+ * Losing one of these is quieter than losing a skills bridge and costs more: a harness with no
102
+ * instruction bridge reads none of the repository's instructions, and says nothing about it. The
103
+ * checks are read-only, and no repair is a command — rewriting an instruction file is the `init`
104
+ * skill's judgment.
105
+ */
106
+ export function diagnoseInstructions(
107
+ root: string,
108
+ harnesses: readonly Harness[],
109
+ cli: string,
110
+ ): { instructions: InstructionReport[]; findings: BridgeFinding[] } {
111
+ // Project scope only: `doctor` diagnoses a repository, and the user-scope bridges are neither
112
+ // written nor read by this tool.
113
+ const bridged = harnesses
114
+ .map((harness) => ({ name: harness.name, bridge: harness.project.instructionBridge }))
115
+ .filter((harness): harness is { name: HarnessName; bridge: InstructionBridge } => Boolean(harness.bridge))
116
+
117
+ const instructions: InstructionReport[] = []
118
+ const findings: BridgeFinding[] = []
119
+ if (!bridged.length) return { instructions, findings }
120
+
121
+ // Only reported when something bridges into it. Every other harness reads `AGENTS.md` where it
122
+ // lies, so its absence is a repository that has not run `init` rather than a broken bridge.
123
+ if (!agentsFileDirectories(root).includes('')) {
124
+ const { detail, repair } = repairFor('no-instructions')
125
+ findings.push({ path: canonicalInstructions, detail, repair: repair(canonicalInstructions, cli) })
126
+ }
127
+
128
+ for (const { name, bridge } of bridged) {
129
+ for (const path of pathsFor(root, bridge)) {
130
+ const inspection = inspect(join(root, path), bridge)
131
+ instructions.push({ harness: name, path, kind: inspection.kind, status: inspection.status })
132
+ if (inspection.problem) {
133
+ const { detail, repair } = repairFor(inspection.problem)
134
+ findings.push({ path, detail, repair: repair(path, cli) })
135
+ }
136
+ }
137
+ }
138
+
139
+ return { instructions, findings }
140
+ }
@@ -4,7 +4,7 @@
4
4
  * `scripts/generate-skills.ts`, so the shipped skill cannot drift from what the command says.
5
5
  */
6
6
 
7
- /** Every way a bridge can fail, in the order `doctor` reports them. */
7
+ /** Every way a skills bridge can fail, in the order `doctor` reports them. */
8
8
  export type BridgeProblem =
9
9
  | 'no-canonical'
10
10
  | 'missing'
@@ -16,8 +16,22 @@ export type BridgeProblem =
16
16
  | 'diverged-unknown'
17
17
  | 'unpinned-copy'
18
18
 
19
+ /**
20
+ * Every way an instruction bridge can fail. Separate from `BridgeProblem` because the two share no
21
+ * repair: a skills bridge is rebuilt with `init` flags, and an instruction bridge is a file whose
22
+ * content is the user's, so every repair here goes back to the `init` skill.
23
+ */
24
+ export type InstructionProblem =
25
+ | 'no-instructions'
26
+ | 'instructions-missing'
27
+ | 'instructions-unbridged'
28
+ | 'instructions-unreadable'
29
+
30
+ /** Everything `doctor` can report against, across both sections. */
31
+ export type DoctorProblem = BridgeProblem | InstructionProblem
32
+
19
33
  export type Repair = {
20
- problem: BridgeProblem
34
+ problem: DoctorProblem
21
35
  /** What `doctor` prints in the `findings` row for this problem. */
22
36
  detail: string
23
37
  /**
@@ -83,7 +97,8 @@ await import(join(packageRoot, 'bin', '${commandInvocation}.mjs'))
83
97
  /** How the skill hands a repair back to `init`. */
84
98
  export const initSkillInvocation = '/buddy-agent-harness:init'
85
99
 
86
- export const doctorRepairs: readonly Repair[] = [
100
+ /** The skills bridges, reported in `bridges`. */
101
+ export const bridgeRepairs: readonly Repair[] = [
87
102
  {
88
103
  problem: 'no-canonical',
89
104
  detail: 'the canonical skill directory does not exist, so no bridge can resolve',
@@ -144,16 +159,53 @@ export const doctorRepairs: readonly Repair[] = [
144
159
  },
145
160
  ]
146
161
 
162
+ /**
163
+ * The instruction bridges, reported in `instructions`. Every repair is the `init` skill: these are
164
+ * files a person wrote, or files carrying content beside the bridge, and deciding what to preserve
165
+ * while restoring the bridge is judgment no flag carries. `repair` therefore names the skill in
166
+ * both places rather than pretending a shell command exists.
167
+ */
168
+ export const instructionRepairs: readonly Repair[] = [
169
+ {
170
+ problem: 'no-instructions',
171
+ detail: 'no AGENTS.md at the repository root, so every instruction bridge points at nothing',
172
+ repair: () => initSkillInvocation,
173
+ skillRepair: () => `run \`${initSkillInvocation}\`, which derives AGENTS.md and the bridges to it`,
174
+ },
175
+ {
176
+ problem: 'instructions-missing',
177
+ detail: 'no instruction bridge at this path — the harness reads none of AGENTS.md',
178
+ repair: () => initSkillInvocation,
179
+ skillRepair: () => `run \`${initSkillInvocation}\``,
180
+ },
181
+ {
182
+ problem: 'instructions-unbridged',
183
+ detail: 'the file is present but names AGENTS.md nowhere — the harness reads none of it',
184
+ repair: () => initSkillInvocation,
185
+ skillRepair: () =>
186
+ `run \`${initSkillInvocation}\`, which adds the bridge without discarding what the file already says`,
187
+ },
188
+ {
189
+ problem: 'instructions-unreadable',
190
+ detail: 'the settings file does not parse, so the harness reads none of it',
191
+ repair: () => initSkillInvocation,
192
+ skillRepair: () => `fix the JSON by hand, then run \`${initSkillInvocation}\``,
193
+ },
194
+ ]
195
+
196
+ /** Both sections, in the order `doctor` reports them. */
197
+ export const doctorRepairs: readonly Repair[] = [...bridgeRepairs, ...instructionRepairs]
198
+
147
199
  const repairsByProblem = new Map(doctorRepairs.map((entry) => [entry.problem, entry]))
148
200
 
149
- export function repairFor(problem: BridgeProblem): Repair {
201
+ export function repairFor(problem: DoctorProblem): Repair {
150
202
  return repairsByProblem.get(problem) as Repair
151
203
  }
152
204
 
153
205
  export const doctorSkill = {
154
206
  name: 'doctor',
155
207
  description:
156
- 'Use this skill when a repository loads no project skills, when skills are missing after a clone, or when checking whether the agent configuration bridges into .claude/skills and the other harness directories still resolve.',
208
+ 'Use this skill when a repository loads no project skills, when skills are missing after a clone, when a harness appears to be ignoring AGENTS.md, or when checking whether the agent configuration bridges into .claude/skills, CLAUDE.md, and the other harness files still resolve.',
157
209
  } as const
158
210
 
159
211
  /** Written into the generated skill so a reader knows not to edit it in place. */
@@ -168,9 +220,10 @@ export const generatedSkillWarning =
168
220
  export function renderDoctorSkill(version: string): string {
169
221
  // A repair may itself contain a pipe, which would otherwise end the table cell early.
170
222
  const cell = (value: string) => value.replaceAll('|', '\\|')
171
- const rows = doctorRepairs
172
- .map((entry) => `| \`${entry.problem}\` | ${entry.detail} | ${cell(entry.skillRepair('<path>'))} |`)
173
- .join('\n')
223
+ const rows = (repairs: readonly Repair[]) =>
224
+ repairs
225
+ .map((entry) => `| \`${entry.problem}\` | ${entry.detail} | ${cell(entry.skillRepair('<path>'))} |`)
226
+ .join('\n')
174
227
 
175
228
  return `---
176
229
  name: ${doctorSkill.name}
@@ -181,7 +234,7 @@ ${generatedSkillWarning}
181
234
 
182
235
  # Harness Doctor
183
236
 
184
- \`.agents/skills\` is the canonical skill directory. Harnesses that cannot read it — Claude Code and Gemini CLI — get a bridge pointing at it, normally a directory symlink. A bridge that stops resolving is silent: the harness finds nothing and loads zero project skills, with no warning anywhere.
237
+ A repository keeps one canonical configuration: \`.agents/skills\` for its skills and \`AGENTS.md\` for its instructions. Harnesses that cannot read those get bridges pointing at them: Claude Code needs both, and Gemini CLI needs the instruction bridge only — it reads \`.agents/skills\` itself. A bridge that stops resolving is silent: the harness finds nothing and loads zero project skills, with no warning anywhere. An instruction bridge fails the same way and costs more, because the harness then reads none of the repository's instructions at all.
185
238
 
186
239
  Diagnose it:
187
240
 
@@ -195,22 +248,36 @@ The command is read-only. It never repairs anything, so it is safe to run at any
195
248
 
196
249
  ## Reading the report
197
250
 
198
- \`bridges\` lists every bridge \`init\` would create for this repository, each with a \`status\` of \`ok\`, \`missing\`, \`degraded\`, \`stale\`, or \`diverged\`. \`findings\` explains each problem and \`help\` names its repair. Apply the repair from the table below, then re-run \`doctor\`.
251
+ \`bridges\` lists every skills bridge \`init\` would create for this repository, each with a \`status\` of \`ok\`, \`missing\`, \`degraded\`, \`stale\`, or \`diverged\`.
252
+
253
+ \`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
+
255
+ \`findings\` explains each problem from either section and \`help\` names its repair. Apply the repair from the tables below, then re-run \`doctor\`.
199
256
 
200
- \`help\` names the \`init\` command for a person at a shell. Do not run it yourself. Rebuilding a bridge can move skills a user wrote, and that judgment belongs to the \`init\` skill — hand the repair to \`${initSkillInvocation}\` instead.
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.
201
258
 
202
259
  When every bridge resolves, \`findings\` says so outright rather than being empty.
203
260
 
204
261
  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\`.
205
262
 
206
- ## Findings and their repairs
263
+ ## Skills bridge findings
207
264
 
208
265
  | Finding | What it means | Repair |
209
266
  | --- | --- | --- |
210
- ${rows}
267
+ ${rows(bridgeRepairs)}
211
268
 
212
269
  Substitute the reported bridge path for \`<path>\`.
213
270
 
271
+ ## Instruction bridge findings
272
+
273
+ | Finding | What it means | Repair |
274
+ | --- | --- | --- |
275
+ ${rows(instructionRepairs)}
276
+
277
+ \`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
+
279
+ 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.
280
+
214
281
  ## The Windows case
215
282
 
216
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.
@@ -222,5 +289,6 @@ A copy is a snapshot rather than a live projection, and an agent that edits a sk
222
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.
223
290
  - Edit skills at \`.agents/skills/<name>/SKILL.md\`. Editing through a bridge is only safe when that bridge is a symlink.
224
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.
225
293
  `
226
294
  }
@@ -9,6 +9,7 @@ import { commandInvocation } from './doctor-guidance.ts'
9
9
  type DoctorReport = {
10
10
  bin: string
11
11
  bridges: DiagnoseResult['bridges']
12
+ instructions: DiagnoseResult['instructions']
12
13
  divergence?: DiagnoseResult['divergence']
13
14
  /** The repair is lifted out into `help`, so a finding row stays to the diagnosis itself. */
14
15
  findings: Omit<DiagnoseResult['findings'][number], 'repair'>[] | string
@@ -20,11 +21,14 @@ type DoctorReport = {
20
21
  * flags to confirm that an empty section really meant "nothing wrong".
21
22
  */
22
23
  export function buildReport(bin: string, result: DiagnoseResult): DoctorReport {
24
+ // Both sections are bridges, so the healthy line counts them together rather than making a reader
25
+ // add up two numbers to learn that nothing is wrong.
23
26
  if (!result.findings.length) {
24
- const count = result.bridges.length
27
+ const count = result.bridges.length + result.instructions.length
25
28
  return {
26
29
  bin,
27
30
  bridges: result.bridges,
31
+ instructions: result.instructions,
28
32
  findings:
29
33
  count === 1 ? '0 problems found — the 1 bridge resolves' : `0 problems found — all ${count} bridges resolve`,
30
34
  }
@@ -33,6 +37,7 @@ export function buildReport(bin: string, result: DiagnoseResult): DoctorReport {
33
37
  return {
34
38
  bin,
35
39
  bridges: result.bridges,
40
+ instructions: result.instructions,
36
41
  ...(result.divergence.length ? { divergence: result.divergence } : {}),
37
42
  findings: result.findings.map(({ path, detail }) => ({ path, detail })),
38
43
  help: [...new Set(result.findings.map((finding) => `Run \`${finding.repair}\``))],
@@ -41,7 +46,8 @@ export function buildReport(bin: string, result: DiagnoseResult): DoctorReport {
41
46
 
42
47
  export const doctorCommand: cli.Command = command({
43
48
  name: 'doctor',
44
- description: 'Report whether the harness skill bridges in this repository still resolve. Read-only.',
49
+ description:
50
+ "Report whether this repository's harness bridges still resolve — skills into .agents/skills, instructions into AGENTS.md. Read-only.",
45
51
  options: {
46
52
  root: {
47
53
  description: 'Repository or package directory. Defaults to the current directory.',
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `.gemini/settings.json` may legally carry comments — the Gemini CLI loader strips them before
3
+ * parsing. A plain `JSON.parse` of a valid settings file therefore throws, and `doctor` would report
4
+ * a working bridge as unreadable. Comments are removed before parsing for that reason.
5
+ *
6
+ * Only comments. A trailing comma stays a parse error, because nothing documents it as accepted.
7
+ */
8
+ export function stripJsonComments(source: string): string {
9
+ let output = ''
10
+ let index = 0
11
+ let inString = false
12
+ let escaped = false
13
+
14
+ while (index < source.length) {
15
+ const character = source[index] as string
16
+
17
+ if (inString) {
18
+ output += character
19
+ if (escaped) escaped = false
20
+ else if (character === '\\') escaped = true
21
+ else if (character === '"') inString = false
22
+ index += 1
23
+ continue
24
+ }
25
+
26
+ if (character === '"') {
27
+ inString = true
28
+ output += character
29
+ index += 1
30
+ continue
31
+ }
32
+
33
+ // A `//` inside a string is an ordinary URL; outside one it can only start a comment.
34
+ if (character === '/' && source[index + 1] === '/') {
35
+ while (index < source.length && source[index] !== '\n') index += 1
36
+ continue
37
+ }
38
+
39
+ if (character === '/' && source[index + 1] === '*') {
40
+ index += 2
41
+ while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) index += 1
42
+ index += 2
43
+ continue
44
+ }
45
+
46
+ output += character
47
+ index += 1
48
+ }
49
+
50
+ return output
51
+ }
52
+
53
+ /** The parsed value, or `undefined` when the file is not JSON even once its comments are gone. */
54
+ export function parseJsonWithComments(source: string): unknown {
55
+ try {
56
+ return JSON.parse(stripJsonComments(source))
57
+ } catch {
58
+ return undefined
59
+ }
60
+ }
@@ -1,5 +1,6 @@
1
1
  import { lstatSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import type { InstructionBridge } from './instruction-bridge.ts'
3
4
 
4
5
  export type HarnessName =
5
6
  | 'claude-code'
@@ -10,21 +11,56 @@ export type HarnessName =
10
11
  | 'devin-desktop'
11
12
  | 'windsurf'
12
13
 
14
+ /** The two scopes a harness reads configuration at. Their roots differ; their shape does not. */
15
+ export type HarnessScopeName = 'project' | 'user'
16
+
13
17
  /**
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.
18
+ * What one harness does at one scope. Paths are relative to that scope's root: the repository root
19
+ * at project scope, the user's home directory at user scope.
20
+ *
21
+ * `detect` is the directory whose presence means this harness is configured at this scope — a
22
+ * different question per scope, which is why it is recorded per scope rather than once.
23
+ *
24
+ * `skillsDirectory` is the projection target the harness needs to see `.agents/skills` at this
25
+ * scope. A harness that reads `.agents/skills` natively there has none, and is never projected into.
26
+ *
27
+ * `instructionBridge` is the same question for `AGENTS.md`: what the harness needs at this scope in
28
+ * order to read it. It belongs per scope for the same reason `skillsDirectory` does — the file
29
+ * differs. Unlike `skillsDirectory`, the `init` command does not write it; the `init` skill does. It
30
+ * is recorded here to be diagnosed and gated per harness, not to be projected.
16
31
  */
17
- export type Harness = {
18
- name: HarnessName
32
+ export type HarnessScope = {
19
33
  detect: string
20
34
  skillsDirectory?: string
35
+ instructionBridge?: InstructionBridge
36
+ }
37
+
38
+ export type Harness = {
39
+ name: HarnessName
40
+ /** What this harness does inside a repository. `init` and `doctor` act only here. */
41
+ project: HarnessScope
42
+ /**
43
+ * What this harness does for the user, outside any repository. Absent when no user-scope path is
44
+ * primary-sourced — Devin documents none. Described and diagnosable; never written to.
45
+ */
46
+ user?: HarnessScope
21
47
  /** Set when this name has been superseded; the value is the name that replaces it. */
22
48
  deprecated?: HarnessName
23
49
  }
24
50
 
25
51
  /**
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.
52
+ * The two scopes disagree in general, and a single answer per harness could only record one of them:
53
+ * Copilot CLI is `.github/skills` in a repository and `~/.copilot` for the user. Claude Code is the
54
+ * only harness that needs a skills projection, and it needs one at both scopes. Codex, Cursor,
55
+ * Copilot CLI, Gemini CLI, and Devin Desktop read `.agents/skills` themselves and are never
56
+ * projected into.
57
+ *
58
+ * Gemini CLI carried a `.gemini/skills` projection until E-GEM-02: it reads the `.agents/skills`
59
+ * alias at project scope too, where that alias takes precedence over `.gemini/skills`. It still
60
+ * needs an instruction bridge, which is a separate axis and unaffected.
61
+ *
62
+ * Instruction bridges are recorded at project scope only. The user-scope equivalents exist, but
63
+ * nothing writes or reads them yet: `init` works inside a repository, and so does `doctor`.
28
64
  *
29
65
  * `windsurf` is the former name of Devin Desktop, rebranded 2026-06-02. It is retained as a
30
66
  * deprecated alias: Devin still scans the legacy `.windsurf/skills` path, so its projection keeps
@@ -33,13 +69,32 @@ export type Harness = {
33
69
  * See `.research/agentic-configuration-standards/` for the per-harness sources.
34
70
  */
35
71
  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' },
72
+ {
73
+ name: 'claude-code',
74
+ project: {
75
+ detect: '.claude',
76
+ skillsDirectory: '.claude/skills',
77
+ instructionBridge: { kind: 'import', path: 'CLAUDE.md' },
78
+ },
79
+ user: { detect: '.claude', skillsDirectory: '.claude/skills' },
80
+ },
81
+ { name: 'cursor', project: { detect: '.cursor' }, user: { detect: '.cursor' } },
82
+ { name: 'codex', project: { detect: '.codex' }, user: { detect: '.codex' } },
83
+ { name: 'copilot-cli', project: { detect: '.github/skills' }, user: { detect: '.copilot' } },
84
+ {
85
+ name: 'gemini-cli',
86
+ project: {
87
+ detect: '.gemini',
88
+ instructionBridge: { kind: 'settings-entry', path: '.gemini/settings.json', key: 'context.fileName' },
89
+ },
90
+ user: { detect: '.gemini' },
91
+ },
92
+ { name: 'devin-desktop', project: { detect: '.devin' } },
93
+ {
94
+ name: 'windsurf',
95
+ project: { detect: '.windsurf', skillsDirectory: '.windsurf/skills' },
96
+ deprecated: 'devin-desktop',
97
+ },
43
98
  ]
44
99
 
45
100
  /** Enabled whether or not the repository already contains their directories. */
@@ -56,5 +111,7 @@ function directoryExists(path: string): boolean {
56
111
  /** The default harnesses, plus the preferred ones, plus every harness whose directory is present. */
57
112
  export function selectHarnesses(root: string, preferred: readonly HarnessName[]): Harness[] {
58
113
  const enabled = new Set<HarnessName>([...defaultHarnesses, ...preferred])
59
- return harnessRegistry.filter((harness) => enabled.has(harness.name) || directoryExists(join(root, harness.detect)))
114
+ return harnessRegistry.filter(
115
+ (harness) => enabled.has(harness.name) || directoryExists(join(root, harness.project.detect)),
116
+ )
60
117
  }
@@ -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
+ }
package/src/index.ts CHANGED
@@ -8,12 +8,25 @@ export type {
8
8
  DivergenceReport,
9
9
  } from './diagnose-bridges/diagnose-bridges.ts'
10
10
  export { diagnoseBridges } from './diagnose-bridges/diagnose-bridges.ts'
11
+ export type {
12
+ InstructionKind,
13
+ InstructionReport,
14
+ InstructionStatus,
15
+ } from './diagnose-bridges/diagnose-instructions.ts'
16
+ export { diagnoseInstructions } from './diagnose-bridges/diagnose-instructions.ts'
11
17
  export { doctorCommand } from './diagnose-bridges/doctor.command.ts'
12
- export type { BridgeProblem, Repair } from './diagnose-bridges/doctor-guidance.ts'
13
- export { doctorRepairs, doctorSkill, renderDoctorSkill } from './diagnose-bridges/doctor-guidance.ts'
18
+ export type { BridgeProblem, DoctorProblem, InstructionProblem, Repair } from './diagnose-bridges/doctor-guidance.ts'
19
+ export {
20
+ bridgeRepairs,
21
+ doctorRepairs,
22
+ doctorSkill,
23
+ instructionRepairs,
24
+ renderDoctorSkill,
25
+ } from './diagnose-bridges/doctor-guidance.ts'
14
26
  export type { DivergenceDirection } from './diagnose-bridges/git-bridge-state.ts'
15
- export type { HarnessName } from './harness-registry/harness-registry.ts'
27
+ export type { Harness, HarnessName, HarnessScope, HarnessScopeName } from './harness-registry/harness-registry.ts'
16
28
  export { harnessRegistry } from './harness-registry/harness-registry.ts'
29
+ export type { InstructionBridge } from './harness-registry/instruction-bridge.ts'
17
30
  export { activate, harnessCommand, initCommand } from './initialize-harnesses/init.command.ts'
18
31
  export type { InitializeOptions, InitializeResult } from './initialize-harnesses/initialize-harnesses.ts'
19
32
  export { initializeHarnesses } from './initialize-harnesses/initialize-harnesses.ts'
@@ -33,7 +33,7 @@ export function initializeHarnesses({
33
33
  return {
34
34
  root,
35
35
  harnesses: harnesses.map((harness) => harness.name),
36
- native: harnesses.filter((harness) => !harness.skillsDirectory).map((harness) => harness.name),
36
+ native: harnesses.filter((harness) => !harness.project.skillsDirectory).map((harness) => harness.name),
37
37
  linked,
38
38
  deprecated: harnesses
39
39
  .filter((harness) => harness.deprecated)
@@ -42,10 +42,10 @@ export type ProjectSkillsOptions = {
42
42
  */
43
43
  export function projectSkills({ root, canonicalSkills, harnesses, copy, force }: ProjectSkillsOptions): HarnessName[] {
44
44
  const projections = harnesses
45
- .filter((harness) => harness.skillsDirectory)
45
+ .filter((harness) => harness.project.skillsDirectory)
46
46
  .map((harness) => ({
47
47
  harness,
48
- target: join(root, harness.skillsDirectory as string),
48
+ target: join(root, harness.project.skillsDirectory as string),
49
49
  }))
50
50
 
51
51
  const conflicts = projections