buddy-agent-harness 0.1.0 → 0.3.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,147 @@
1
+ import { lstatSync, readFileSync, readlinkSync, statSync } from 'node:fs'
2
+ import { dirname, join, relative } from 'node:path'
3
+ import { type Harness, type HarnessName, selectHarnesses } from '../harness-registry/harness-registry.ts'
4
+ import { filesUnder } from './directory-files.ts'
5
+ import { type BridgeProblem, repairFor } from './doctor-guidance.ts'
6
+ import { type DivergenceDirection, GitBridgeState } from './git-bridge-state.ts'
7
+
8
+ export type BridgeKind = 'symlink' | 'copy' | 'file' | 'none'
9
+ export type BridgeStatus = 'ok' | 'missing' | 'degraded' | 'stale' | 'diverged'
10
+
11
+ export type BridgeReport = {
12
+ harness: HarnessName
13
+ /** Repository-relative, as the harness registry declares it. */
14
+ path: string
15
+ kind: BridgeKind
16
+ status: BridgeStatus
17
+ }
18
+
19
+ export type BridgeFinding = {
20
+ path: string
21
+ detail: string
22
+ /** The command that repairs this finding, already carrying the bridge path. */
23
+ repair: string
24
+ }
25
+
26
+ export type DivergenceReport = {
27
+ path: string
28
+ direction: DivergenceDirection
29
+ }
30
+
31
+ export type DiagnoseOptions = {
32
+ root: string
33
+ harnesses?: HarnessName[]
34
+ /** How to name this tool in the repair commands. */
35
+ cli: string
36
+ }
37
+
38
+ export type DiagnoseResult = {
39
+ bridges: BridgeReport[]
40
+ divergence: DivergenceReport[]
41
+ findings: BridgeFinding[]
42
+ }
43
+
44
+ const canonicalPath = '.agents/skills'
45
+
46
+ function pathState(target: string): 'missing' | 'symlink' | 'directory' | 'file' {
47
+ try {
48
+ const stats = lstatSync(target)
49
+ if (stats.isSymbolicLink()) return 'symlink'
50
+ return stats.isDirectory() ? 'directory' : 'file'
51
+ } catch {
52
+ return 'missing'
53
+ }
54
+ }
55
+
56
+ function isDirectory(path: string): boolean {
57
+ try {
58
+ return statSync(path).isDirectory()
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+
64
+ /** The same test `init` uses to decide a projection is already correct, plus that it still resolves. */
65
+ function resolvesToCanonical(target: string, canonical: string): boolean {
66
+ return readlinkSync(target) === relative(dirname(target), canonical) && isDirectory(target)
67
+ }
68
+
69
+ function sameContent(left: string, right: string): boolean {
70
+ const leftFiles = filesUnder(left)
71
+ const rightFiles = filesUnder(right)
72
+ return (
73
+ leftFiles.length === rightFiles.length &&
74
+ leftFiles.every(
75
+ (file, index) =>
76
+ rightFiles[index] === file && readFileSync(join(left, file)).equals(readFileSync(join(right, file))),
77
+ )
78
+ )
79
+ }
80
+
81
+ type Inspection = {
82
+ kind: BridgeKind
83
+ status: BridgeStatus
84
+ problem?: BridgeProblem
85
+ direction?: DivergenceDirection
86
+ }
87
+
88
+ function inspect(target: string, path: string, canonical: string, git: GitBridgeState): Inspection {
89
+ switch (pathState(target)) {
90
+ case 'missing':
91
+ return { kind: 'none', status: 'missing', problem: 'missing' }
92
+ // The flagship Windows failure: git with `core.symlinks=false` writes the link out as a
93
+ // regular file holding the target path, and the harness silently loads nothing.
94
+ case 'file':
95
+ return { kind: 'file', status: 'degraded', problem: 'degraded' }
96
+ case 'symlink':
97
+ return resolvesToCanonical(target, canonical)
98
+ ? { kind: 'symlink', status: 'ok' }
99
+ : { kind: 'symlink', status: 'stale', problem: 'stale' }
100
+ default: {
101
+ if (!sameContent(target, canonical)) {
102
+ const direction = git.directionOf(path, canonicalPath)
103
+ return { kind: 'copy', status: 'diverged', problem: `diverged-${direction}`, direction }
104
+ }
105
+ // A copy over a tracked symlink only stays out of `git status` while its skip-worktree bit
106
+ // survives, and some checkout and merge operations clear it.
107
+ return git.trackingOf(path) === 'tracked'
108
+ ? { kind: 'copy', status: 'ok', problem: 'unpinned-copy' }
109
+ : { kind: 'copy', status: 'ok' }
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Reports whether every bridge `init` would create for this repository still resolves into
116
+ * `.agents/skills`. Read-only: nothing is created, moved, or repaired, so the caller decides what
117
+ * to run from the repair each finding carries.
118
+ */
119
+ export function diagnoseBridges({ root, harnesses: preferred = [], cli }: DiagnoseOptions): DiagnoseResult {
120
+ const canonical = join(root, '.agents', 'skills')
121
+ const git = new GitBridgeState(root)
122
+ const bridged = selectHarnesses(root, preferred).filter((harness): harness is Harness & { skillsDirectory: string } =>
123
+ Boolean(harness.skillsDirectory),
124
+ )
125
+
126
+ const bridges: BridgeReport[] = []
127
+ const divergence: DivergenceReport[] = []
128
+ const findings: BridgeFinding[] = []
129
+
130
+ if (!isDirectory(canonical)) {
131
+ const { detail, repair } = repairFor('no-canonical')
132
+ findings.push({ path: canonicalPath, detail, repair: repair(canonicalPath, cli) })
133
+ }
134
+
135
+ for (const harness of bridged) {
136
+ const path = harness.skillsDirectory
137
+ const inspection = inspect(join(root, path), path, canonical, git)
138
+ bridges.push({ harness: harness.name, path, kind: inspection.kind, status: inspection.status })
139
+ if (inspection.direction) divergence.push({ path, direction: inspection.direction })
140
+ if (inspection.problem) {
141
+ const { detail, repair } = repairFor(inspection.problem)
142
+ findings.push({ path, detail, repair: repair(path, cli) })
143
+ }
144
+ }
145
+
146
+ return { bridges, divergence, findings }
147
+ }
@@ -0,0 +1,17 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join, posix, relative, sep } from 'node:path'
3
+
4
+ /**
5
+ * Every file below `directory`, as paths relative to it, POSIX-separated and sorted. A directory
6
+ * that does not exist reads as empty: `doctor` never creates one to look at it.
7
+ */
8
+ export function filesUnder(directory: string): string[] {
9
+ try {
10
+ return readdirSync(directory, { recursive: true, withFileTypes: true })
11
+ .filter((entry) => entry.isFile())
12
+ .map((entry) => relative(directory, join(entry.parentPath, entry.name)).split(sep).join(posix.sep))
13
+ .sort()
14
+ } catch {
15
+ return []
16
+ }
17
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The one place `doctor`'s guidance is written. The command reads `detail` and `repair` to fill its
3
+ * `findings` and `help` sections; `skills/doctor/SKILL.md` is generated from the same table by
4
+ * `scripts/generate-doctor-skill.ts`, so the shipped skill cannot drift from what the command says.
5
+ */
6
+
7
+ /** Every way a bridge can fail, in the order `doctor` reports them. */
8
+ export type BridgeProblem =
9
+ | 'no-canonical'
10
+ | 'missing'
11
+ | 'degraded'
12
+ | 'stale'
13
+ | 'diverged-bridge'
14
+ | 'diverged-canonical'
15
+ | 'diverged-both'
16
+ | 'diverged-unknown'
17
+ | 'unpinned-copy'
18
+
19
+ export type Repair = {
20
+ problem: BridgeProblem
21
+ /** What `doctor` prints in the `findings` row for this problem. */
22
+ detail: string
23
+ /**
24
+ * The command that fixes it, for `doctor`'s own output. `path` is the repository-relative bridge
25
+ * path; `cli` is how to invoke this tool.
26
+ */
27
+ repair(path: string, cli: string): string
28
+ /**
29
+ * What the shipped skill tells an agent to do instead. A repair that rebuilds a bridge delegates
30
+ * to the `init` skill rather than calling the `init` command, because rebuilding can move
31
+ * user-authored skills and that judgment is the `init` skill's, not `doctor`'s.
32
+ */
33
+ skillRepair(path: string): string
34
+ }
35
+
36
+ /** How the command names itself. A skill may run without the binary on PATH, so it uses `npx`. */
37
+ export const commandInvocation = 'buddy-agent-harness'
38
+ export const skillInvocation = 'npx -y buddy-agent-harness'
39
+ /** How the skill hands a repair back to `init`. */
40
+ export const initSkillInvocation = '/buddy-agent-harness:init'
41
+
42
+ export const doctorRepairs: readonly Repair[] = [
43
+ {
44
+ problem: 'no-canonical',
45
+ detail: 'the canonical skill directory does not exist, so no bridge can resolve',
46
+ repair: (_path, cli) => `${cli} init`,
47
+ skillRepair: () => `run \`${initSkillInvocation}\`, which creates \`.agents/skills\` and the bridges`,
48
+ },
49
+ {
50
+ problem: 'missing',
51
+ detail: 'no bridge at this path — the harness sees zero project skills',
52
+ repair: (_path, cli) => `${cli} init`,
53
+ skillRepair: () => `run \`${initSkillInvocation}\``,
54
+ },
55
+ {
56
+ problem: 'degraded',
57
+ detail: 'expected a directory but found a regular file — checkout without core.symlinks',
58
+ repair: (_path, cli) => `${cli} init --copy --force`,
59
+ skillRepair: () => `run \`${initSkillInvocation} --copy --force\``,
60
+ },
61
+ {
62
+ problem: 'stale',
63
+ detail: 'symlink does not resolve to .agents/skills',
64
+ repair: (_path, cli) => `${cli} init --force`,
65
+ skillRepair: () => `run \`${initSkillInvocation} --force\``,
66
+ },
67
+ {
68
+ problem: 'diverged-bridge',
69
+ detail: 'only the bridge changed since the two last agreed — an agent wrote through the copy',
70
+ repair: (path, cli) =>
71
+ `replace .agents/skills with ${path} to keep the newer edit and then run ${cli} init --force`,
72
+ skillRepair: (path) =>
73
+ `replace .agents/skills with ${path} to keep the newer edit, then run \`${initSkillInvocation} --force\``,
74
+ },
75
+ {
76
+ problem: 'diverged-canonical',
77
+ detail: 'only .agents/skills changed since the two last agreed — the copy is stale',
78
+ repair: (_path, cli) => `${cli} init --copy --force`,
79
+ skillRepair: () => `run \`${initSkillInvocation} --copy --force\``,
80
+ },
81
+ {
82
+ problem: 'diverged-both',
83
+ detail: 'both sides changed since they last agreed — rebuilding would discard one of them',
84
+ repair: (path) => `git diff --no-index .agents/skills ${path} and reconcile by hand`,
85
+ skillRepair: (path) => `run \`git diff --no-index .agents/skills ${path}\` and reconcile by hand`,
86
+ },
87
+ {
88
+ problem: 'diverged-unknown',
89
+ detail: 'contents differ and no commit where they agreed was found — which side moved is unknown',
90
+ repair: (path) => `git diff --no-index .agents/skills ${path} and reconcile by hand`,
91
+ skillRepair: (path) => `run \`git diff --no-index .agents/skills ${path}\` and reconcile by hand`,
92
+ },
93
+ {
94
+ problem: 'unpinned-copy',
95
+ detail: 'tracked copy without the skip-worktree bit — the tree is dirty with content that must not be committed',
96
+ // The index entry is the tracked symlink on a Windows checkout but the individual files in a
97
+ // committed copy, so the paths are read back from git rather than assumed.
98
+ repair: (path) => `git ls-files -z ${path} | xargs -0 git update-index --skip-worktree`,
99
+ skillRepair: (path) => `run \`git ls-files -z ${path} | xargs -0 git update-index --skip-worktree\``,
100
+ },
101
+ ]
102
+
103
+ const repairsByProblem = new Map(doctorRepairs.map((entry) => [entry.problem, entry]))
104
+
105
+ export function repairFor(problem: BridgeProblem): Repair {
106
+ return repairsByProblem.get(problem) as Repair
107
+ }
108
+
109
+ export const doctorSkill = {
110
+ name: 'doctor',
111
+ description:
112
+ '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.',
113
+ } as const
114
+
115
+ /** Written into the generated skill so a reader knows not to edit it in place. */
116
+ export const generatedSkillWarning =
117
+ '<!-- Generated from src/diagnose-bridges/doctor-guidance.ts by scripts/generate-doctor-skill.ts. Do not edit by hand. -->'
118
+
119
+ /** The shipped `doctor` skill, rendered from the same table the command prints. */
120
+ export function renderDoctorSkill(): string {
121
+ // A repair may itself contain a pipe, which would otherwise end the table cell early.
122
+ const cell = (value: string) => value.replaceAll('|', '\\|')
123
+ const rows = doctorRepairs
124
+ .map((entry) => `| \`${entry.problem}\` | ${entry.detail} | ${cell(entry.skillRepair('<path>'))} |`)
125
+ .join('\n')
126
+
127
+ return `---
128
+ name: ${doctorSkill.name}
129
+ description: ${doctorSkill.description}
130
+ ---
131
+
132
+ ${generatedSkillWarning}
133
+
134
+ # Harness Doctor
135
+
136
+ \`.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.
137
+
138
+ Diagnose it:
139
+
140
+ \`\`\`sh
141
+ ${skillInvocation} doctor
142
+ \`\`\`
143
+
144
+ The command is read-only. It never repairs anything, so it is safe to run at any point, including from a session-start hook.
145
+
146
+ ## Reading the report
147
+
148
+ \`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\`.
149
+
150
+ \`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.
151
+
152
+ When every bridge resolves, \`findings\` says so outright rather than being empty.
153
+
154
+ 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\`.
155
+
156
+ ## Findings and their repairs
157
+
158
+ | Finding | What it means | Repair |
159
+ | --- | --- | --- |
160
+ ${rows}
161
+
162
+ Substitute the reported bridge path for \`<path>\`.
163
+
164
+ ## The Windows case
165
+
166
+ 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.
167
+
168
+ 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.
169
+
170
+ ## Rules
171
+
172
+ - 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.
173
+ - Edit skills at \`.agents/skills/<name>/SKILL.md\`. Editing through a bridge is only safe when that bridge is a symlink.
174
+ - Do not add bridges to \`.gitignore\`. An untracked bridge swallows a real edit silently.
175
+ `
176
+ }
@@ -0,0 +1,87 @@
1
+ import { homedir } from 'node:os'
2
+ import type { cli } from 'clibuilder'
3
+ import { command, z } from 'clibuilder'
4
+ import { binPath, parseFormat, writeResult } from '../command-output/command-output.ts'
5
+ import { type HarnessName, harnessRegistry } from '../harness-registry/harness-registry.ts'
6
+ import { type DiagnoseResult, diagnoseBridges } from './diagnose-bridges.ts'
7
+ import { commandInvocation } from './doctor-guidance.ts'
8
+
9
+ type DoctorReport = {
10
+ bin: string
11
+ bridges: DiagnoseResult['bridges']
12
+ divergence?: DiagnoseResult['divergence']
13
+ /** The repair is lifted out into `help`, so a finding row stays to the diagnosis itself. */
14
+ findings: Omit<DiagnoseResult['findings'][number], 'repair'>[] | string
15
+ help?: string[]
16
+ }
17
+
18
+ /**
19
+ * AXI §5: the healthy answer states the zero with context, so an agent does not re-run with other
20
+ * flags to confirm that an empty section really meant "nothing wrong".
21
+ */
22
+ export function buildReport(bin: string, result: DiagnoseResult): DoctorReport {
23
+ if (!result.findings.length) {
24
+ const count = result.bridges.length
25
+ return {
26
+ bin,
27
+ bridges: result.bridges,
28
+ findings:
29
+ count === 1 ? '0 problems found — the 1 bridge resolves' : `0 problems found — all ${count} bridges resolve`,
30
+ }
31
+ }
32
+
33
+ return {
34
+ bin,
35
+ bridges: result.bridges,
36
+ ...(result.divergence.length ? { divergence: result.divergence } : {}),
37
+ findings: result.findings.map(({ path, detail }) => ({ path, detail })),
38
+ help: [...new Set(result.findings.map((finding) => `Run \`${finding.repair}\``))],
39
+ }
40
+ }
41
+
42
+ export const doctorCommand: cli.Command = command({
43
+ name: 'doctor',
44
+ description: 'Report whether the harness skill bridges in this repository still resolve. Read-only.',
45
+ options: {
46
+ root: {
47
+ description: 'Repository or package directory. Defaults to the current directory.',
48
+ type: z.optional(z.string()),
49
+ },
50
+ harness: {
51
+ description: 'Comma-separated harnesses to check in addition to Claude Code and Cursor, e.g. codex,gemini-cli.',
52
+ type: z.optional(z.string()),
53
+ },
54
+ format: {
55
+ description: 'Output format: toon (default), json, or text for a human-readable report.',
56
+ type: z.optional(z.string()),
57
+ default: 'toon',
58
+ },
59
+ },
60
+ run(args) {
61
+ try {
62
+ const format = parseFormat(args.format)
63
+ const requested = args.harness
64
+ ?.split(',')
65
+ .map((name) => name.trim())
66
+ .filter(Boolean)
67
+ const unsupported = requested?.filter((name) => !harnessRegistry.some((harness) => harness.name === name))
68
+ if (unsupported?.length)
69
+ throw new Error(
70
+ `Unsupported harness: ${unsupported.join(', ')}. Supported: ${harnessRegistry
71
+ .map((harness) => harness.name)
72
+ .join(', ')}.`,
73
+ )
74
+ const result = diagnoseBridges({
75
+ root: args.root ?? process.cwd(),
76
+ ...(requested?.length ? { harnesses: requested as HarnessName[] } : {}),
77
+ cli: commandInvocation,
78
+ })
79
+ // Exit stays 0 even with findings: the diagnosis succeeded, and a non-zero code reads to an
80
+ // agent as "this command is broken, try something else".
81
+ writeResult(buildReport(binPath(homedir(), process.argv[1]), result), format)
82
+ } catch (error) {
83
+ process.stderr.write(`error: ${error instanceof Error ? error.message : 'Harness diagnosis failed.'}\n`)
84
+ process.exitCode = 1
85
+ }
86
+ },
87
+ })
@@ -0,0 +1,102 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { join } from 'node:path'
3
+ import { filesUnder } from './directory-files.ts'
4
+
5
+ /** Which side of a diverged bridge moved since the last commit where the two agreed. */
6
+ export type DivergenceDirection = 'bridge' | 'canonical' | 'both' | 'unknown'
7
+
8
+ /** Whether a path is tracked, and if so whether its skip-worktree bit is still set. */
9
+ export type TrackingState = 'untracked' | 'tracked' | 'skip-worktree'
10
+
11
+ function git(root: string, args: string[], input?: string): string | undefined {
12
+ try {
13
+ return execFileSync('git', args, {
14
+ cwd: root,
15
+ encoding: 'utf8',
16
+ ...(input === undefined ? {} : { input }),
17
+ stdio: ['pipe', 'pipe', 'pipe'],
18
+ })
19
+ } catch {
20
+ return undefined
21
+ }
22
+ }
23
+
24
+ function lines(output: string | undefined): string[] {
25
+ return (output ?? '').split('\n').filter(Boolean)
26
+ }
27
+
28
+ /**
29
+ * Reads git state for the two sides of a bridge. Every method degrades to "cannot tell" rather than
30
+ * throwing, so `doctor` still reports on a tarball, a worktree without git, or a repository with no
31
+ * commits.
32
+ */
33
+ export class GitBridgeState {
34
+ /** Path of `root` relative to the repository root, `''` at the top, `undefined` outside a repository. */
35
+ private readonly prefix: string | undefined
36
+
37
+ constructor(private readonly root: string) {
38
+ this.prefix = git(root, ['rev-parse', '--show-prefix'])?.trim()
39
+ }
40
+
41
+ /**
42
+ * `init --copy` marks a bridge skip-worktree so a machine-local copy over a tracked symlink stops
43
+ * showing as a pending change. The bit is a hint that some checkouts clear, so it is verified
44
+ * rather than assumed.
45
+ */
46
+ trackingOf(path: string): TrackingState {
47
+ if (this.prefix === undefined) return 'untracked'
48
+ const entries = lines(git(this.root, ['ls-files', '-v', '--', path]))
49
+ if (!entries.length) return 'untracked'
50
+ return entries.every((entry) => entry.startsWith('S')) ? 'skip-worktree' : 'tracked'
51
+ }
52
+
53
+ /**
54
+ * Names the side that moved by finding the newest commit whose two trees agreed, then asking which
55
+ * of the working directories still matches it.
56
+ */
57
+ directionOf(bridge: string, canonical: string): DivergenceDirection {
58
+ if (this.prefix === undefined) return 'unknown'
59
+ const base = this.lastAgreedTree(bridge, canonical)
60
+ if (base === undefined) return 'unknown'
61
+ const bridgeMoved = !this.matchesTree(bridge, base)
62
+ const canonicalMoved = !this.matchesTree(canonical, base)
63
+ if (bridgeMoved && canonicalMoved) return 'both'
64
+ if (bridgeMoved) return 'bridge'
65
+ return 'canonical'
66
+ }
67
+
68
+ /** The tree of the newest commit where both paths held identical content. */
69
+ private lastAgreedTree(bridge: string, canonical: string): string | undefined {
70
+ for (const commit of lines(git(this.root, ['rev-list', '-n', '200', 'HEAD', '--', bridge, canonical]))) {
71
+ const bridgeObject = git(this.root, ['rev-parse', '-q', '--verify', `${commit}:${this.prefix}${bridge}`])?.trim()
72
+ if (!bridgeObject) continue
73
+ const canonicalObject = git(this.root, [
74
+ 'rev-parse',
75
+ '-q',
76
+ '--verify',
77
+ `${commit}:${this.prefix}${canonical}`,
78
+ ])?.trim()
79
+ if (bridgeObject === canonicalObject) return bridgeObject
80
+ }
81
+ return undefined
82
+ }
83
+
84
+ /**
85
+ * Compares a working directory against a git tree by object id. `hash-object` is used rather than
86
+ * hashing in-process so the comparison follows whatever object format the repository uses.
87
+ */
88
+ private matchesTree(directory: string, tree: string): boolean {
89
+ const expected = new Map(
90
+ lines(git(this.root, ['ls-tree', '-r', tree])).map((entry) => {
91
+ const [meta, path] = entry.split('\t')
92
+ return [path, (meta as string).split(' ')[2] as string] as const
93
+ }),
94
+ )
95
+ const files = filesUnder(join(this.root, directory))
96
+ if (files.length !== expected.size || files.some((file) => !expected.has(file))) return false
97
+ const hashed = lines(
98
+ git(this.root, ['hash-object', '--stdin-paths'], files.map((file) => join(directory, file)).join('\n')),
99
+ )
100
+ return hashed.length === files.length && files.every((file, index) => expected.get(file) === hashed[index])
101
+ }
102
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,17 @@
1
+ export type {
2
+ BridgeFinding,
3
+ BridgeKind,
4
+ BridgeReport,
5
+ BridgeStatus,
6
+ DiagnoseOptions,
7
+ DiagnoseResult,
8
+ DivergenceReport,
9
+ } from './diagnose-bridges/diagnose-bridges.ts'
10
+ export { diagnoseBridges } from './diagnose-bridges/diagnose-bridges.ts'
11
+ 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'
14
+ export type { DivergenceDirection } from './diagnose-bridges/git-bridge-state.ts'
1
15
  export type { HarnessName } from './harness-registry/harness-registry.ts'
2
16
  export { harnessRegistry } from './harness-registry/harness-registry.ts'
3
17
  export { activate, harnessCommand, initCommand } from './initialize-harnesses/init.command.ts'
@@ -1,13 +1,10 @@
1
- import { encode } from '@toon-format/toon'
2
1
  import type { cli } from 'clibuilder'
3
2
  import { command, z } from 'clibuilder'
3
+ import { parseFormat, writeResult } from '../command-output/command-output.ts'
4
+ import { doctorCommand } from '../diagnose-bridges/doctor.command.ts'
4
5
  import { type HarnessName, harnessRegistry } from '../harness-registry/harness-registry.ts'
5
6
  import { initializeHarnesses } from './initialize-harnesses.ts'
6
7
 
7
- function write(value: object, format: 'json' | 'toon' | undefined): void {
8
- process.stdout.write(`${format === 'json' ? JSON.stringify(value) : encode(value)}\n`)
9
- }
10
-
11
8
  export const initCommand: cli.Command = command({
12
9
  name: 'init',
13
10
  description: 'Set up canonical agent skills for the harnesses already enabled in a repository.',
@@ -17,7 +14,7 @@ export const initCommand: cli.Command = command({
17
14
  type: z.optional(z.string()),
18
15
  },
19
16
  harness: {
20
- description: 'Comma-separated harnesses to enable in addition to Claude Code and Cursor, e.g. codex,windsurf.',
17
+ description: 'Comma-separated harnesses to enable in addition to Claude Code and Cursor, e.g. codex,gemini-cli.',
21
18
  type: z.optional(z.string()),
22
19
  },
23
20
  copy: {
@@ -29,15 +26,14 @@ export const initCommand: cli.Command = command({
29
26
  type: z.optional(z.boolean()),
30
27
  },
31
28
  format: {
32
- description: 'Output format: toon (default) or json.',
29
+ description: 'Output format: toon (default), json, or text for a human-readable report.',
33
30
  type: z.optional(z.string()),
34
31
  default: 'toon',
35
32
  },
36
33
  },
37
34
  run(args) {
38
35
  try {
39
- const format = args.format
40
- if (format !== 'toon' && format !== 'json') throw new Error('--format must be toon or json.')
36
+ const format = parseFormat(args.format)
41
37
  const requested = args.harness
42
38
  ?.split(',')
43
39
  .map((name) => name.trim())
@@ -49,7 +45,7 @@ export const initCommand: cli.Command = command({
49
45
  .map((harness) => harness.name)
50
46
  .join(', ')}.`,
51
47
  )
52
- write(
48
+ writeResult(
53
49
  initializeHarnesses({
54
50
  root: args.root ?? process.cwd(),
55
51
  ...(requested?.length ? { harnesses: requested as HarnessName[] } : {}),
@@ -65,10 +61,12 @@ export const initCommand: cli.Command = command({
65
61
  },
66
62
  })
67
63
 
64
+ // Named for the package rather than shortened to `harness`: `repobuddy` mounts every plugin into one
65
+ // command namespace, where `harness` is generic enough to collide with another plugin's.
68
66
  export const harnessCommand: cli.Command = command({
69
- name: 'harness',
67
+ name: 'agent-harness',
70
68
  description: 'Commands for configuring agent harness compatibility.',
71
- commands: [initCommand],
69
+ commands: [initCommand, doctorCommand],
72
70
  })
73
71
 
74
72
  export function activate({ addCommand }: { addCommand(command: typeof harnessCommand): void }): void {
@@ -1,30 +0,0 @@
1
- {
2
- "$schema": "https://raw.githubusercontent.com/cyberuni/universal-plugin/refs/heads/main/schema/v1.json",
3
- "name": "buddy-agent-harness",
4
- "version": "0.1.0",
5
- "description": "Initialize standards-based agent configuration across coding-agent harnesses",
6
- "author": {
7
- "name": "repobuddy"
8
- },
9
- "repository": "https://github.com/repobuddy/buddy-agent-harness",
10
- "license": "MIT",
11
- "keywords": ["agent", "skills", "harness", "claude-code", "codex"],
12
- "skills": "./skills/",
13
- "vendorExtensions": {
14
- "claude-code": {},
15
- "cursor": {
16
- "category": "productivity",
17
- "tags": ["agent", "skills"]
18
- },
19
- "codex": {
20
- "interface": {
21
- "displayName": "Buddy Agent Harness",
22
- "category": "productivity"
23
- }
24
- },
25
- "copilot-cli": {
26
- "category": "productivity",
27
- "tags": ["agent", "skills"]
28
- }
29
- }
30
- }