buddy-agent-harness 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +2 -2
- package/README.md +13 -9
- package/dist/cli.d.mts.map +1 -1
- package/dist/cli.mjs +2 -2
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +81 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/init.command-BN_w5DRZ.mjs +686 -0
- package/dist/init.command-BN_w5DRZ.mjs.map +1 -0
- package/package.json +4 -2
- package/plugin.json +2 -2
- package/skills/doctor/README.md +33 -0
- package/skills/doctor/SKILL.md +56 -0
- package/skills/init/README.md +1 -5
- package/skills/init/SKILL.md +13 -1
- package/skills/init/references/frontmatter.md +7 -0
- package/skills/init/references/harnesses/claude-code.md +1 -0
- package/skills/init/references/harnesses/codex.md +6 -0
- package/src/cli.ts +2 -1
- package/src/command-output/command-output.ts +68 -0
- package/src/diagnose-bridges/diagnose-bridges.ts +147 -0
- package/src/diagnose-bridges/directory-files.ts +17 -0
- package/src/diagnose-bridges/doctor-guidance.ts +176 -0
- package/src/diagnose-bridges/doctor.command.ts +87 -0
- package/src/diagnose-bridges/git-bridge-state.ts +102 -0
- package/src/index.ts +14 -0
- package/src/initialize-harnesses/init.command.ts +10 -12
- package/dist/init.command-wxC4cgUc.mjs +0 -203
- package/dist/init.command-wxC4cgUc.mjs.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"init.command-BN_w5DRZ.mjs","names":[],"sources":["../src/command-output/command-output.ts","../src/harness-registry/harness-registry.ts","../src/diagnose-bridges/directory-files.ts","../src/diagnose-bridges/doctor-guidance.ts","../src/diagnose-bridges/git-bridge-state.ts","../src/diagnose-bridges/diagnose-bridges.ts","../src/diagnose-bridges/doctor.command.ts","../src/skill-projection/skill-projection.ts","../src/initialize-harnesses/initialize-harnesses.ts","../src/initialize-harnesses/init.command.ts"],"sourcesContent":["import { sep } from 'node:path'\nimport { encode } from '@toon-format/toon'\n\nexport type OutputFormat = 'json' | 'toon' | 'text'\n\nconst formats: readonly OutputFormat[] = ['toon', 'json', 'text']\n\n/** Rejects anything but the supported formats so an unknown value never falls back silently. */\nexport function parseFormat(value: string | undefined): OutputFormat {\n\tif (!formats.includes(value as OutputFormat)) throw new Error('--format must be toon, json, or text.')\n\treturn value as OutputFormat\n}\n\n/** The single stdout boundary: internal logic stays on plain objects, encoding happens here. */\nexport function writeResult(value: object, format: OutputFormat): void {\n\tprocess.stdout.write(`${encodeResult(value, format)}\\n`)\n}\n\nfunction encodeResult(value: object, format: OutputFormat): string {\n\tif (format === 'json') return JSON.stringify(value)\n\treturn format === 'text' ? renderText(value) : encode(value)\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction cell(value: unknown): string {\n\treturn value === undefined ? '' : String(value)\n}\n\n/** A header row plus one row per record, every column padded to its widest cell. */\nfunction table(rows: Record<string, unknown>[]): string[] {\n\tconst columns = [...new Set(rows.flatMap((row) => Object.keys(row)))]\n\tconst widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)))\n\tconst line = (values: string[]) =>\n\t\t` ${values.map((value, index) => value.padEnd(widths[index] as number)).join(' ')}`.trimEnd()\n\treturn [line(columns), ...rows.map((row) => line(columns.map((column) => cell(row[column]))))]\n}\n\n/**\n * The same result rendered for a person rather than for an agent: scalars as `key: value`, records\n * as an aligned table, and everything else as a bulleted list. TOON stays the default because it is\n * what an agent parses; this is for reading over someone's shoulder.\n */\nexport function renderText(value: object): string {\n\tconst blocks = Object.entries(value).map(([key, item]) => {\n\t\tif (!Array.isArray(item)) return [`${key}: ${isRecord(item) ? JSON.stringify(item) : String(item)}`]\n\t\tif (!item.length) return [`${key}: (none)`]\n\t\tconst body = item.every(isRecord) ? table(item) : item.map((entry) => ` - ${String(entry)}`)\n\t\treturn [`${key}:`, ...body]\n\t})\n\n\t// A blank line wherever a multi-line block meets its neighbour, so a following scalar does not\n\t// read as another row of the table above it.\n\treturn blocks\n\t\t.flatMap((block, index) => {\n\t\t\tconst previous = blocks[index - 1]\n\t\t\treturn previous && (previous.length > 1 || block.length > 1) ? ['', ...block] : block\n\t\t})\n\t\t.join('\\n')\n}\n\n/** AXI §10: the executable's absolute path, with the user's home directory collapsed to `~`. */\nexport function binPath(home: string, executable: string | undefined): string {\n\tif (!executable) return 'buddy-agent-harness'\n\treturn home && executable.startsWith(home + sep) ? `~${executable.slice(home.length)}` : executable\n}\n","import { lstatSync } from 'node:fs'\nimport { join } from 'node:path'\n\nexport type HarnessName =\n\t| 'claude-code'\n\t| 'cursor'\n\t| 'codex'\n\t| 'copilot-cli'\n\t| 'gemini-cli'\n\t| 'devin-desktop'\n\t| 'windsurf'\n\n/**\n * `skillsDirectory` is the projection target a harness needs to see `.agents/skills`.\n * A harness that reads `.agents/skills` natively has none, and is never projected into.\n */\nexport type Harness = {\n\tname: HarnessName\n\tdetect: string\n\tskillsDirectory?: string\n\t/** Set when this name has been superseded; the value is the name that replaces it. */\n\tdeprecated?: HarnessName\n}\n\n/**\n * Codex, Cursor, Copilot CLI, and Devin Desktop read `.agents/skills` directly, so they need no\n * projection. Only Claude Code and Gemini CLI read solely their own directory.\n *\n * `windsurf` is the former name of Devin Desktop, rebranded 2026-06-02. It is retained as a\n * deprecated alias: Devin still scans the legacy `.windsurf/skills` path, so its projection keeps\n * working, but new repositories should enable `devin-desktop` and have nothing written for them.\n *\n * See `.research/agentic-configuration-standards/` for the per-harness sources.\n */\nexport const harnessRegistry: readonly Harness[] = [\n\t{ name: 'claude-code', detect: '.claude', skillsDirectory: '.claude/skills' },\n\t{ name: 'cursor', detect: '.cursor' },\n\t{ name: 'codex', detect: '.codex' },\n\t{ name: 'copilot-cli', detect: '.github/skills' },\n\t{ name: 'gemini-cli', detect: '.gemini', skillsDirectory: '.gemini/skills' },\n\t{ name: 'devin-desktop', detect: '.devin' },\n\t{ name: 'windsurf', detect: '.windsurf', skillsDirectory: '.windsurf/skills', deprecated: 'devin-desktop' },\n]\n\n/** Enabled whether or not the repository already contains their directories. */\nconst defaultHarnesses: readonly HarnessName[] = ['claude-code', 'cursor']\n\nfunction directoryExists(path: string): boolean {\n\ttry {\n\t\treturn lstatSync(path).isDirectory()\n\t} catch {\n\t\treturn false\n\t}\n}\n\n/** The default harnesses, plus the preferred ones, plus every harness whose directory is present. */\nexport function selectHarnesses(root: string, preferred: readonly HarnessName[]): Harness[] {\n\tconst enabled = new Set<HarnessName>([...defaultHarnesses, ...preferred])\n\treturn harnessRegistry.filter((harness) => enabled.has(harness.name) || directoryExists(join(root, harness.detect)))\n}\n","import { readdirSync } from 'node:fs'\nimport { join, posix, relative, sep } from 'node:path'\n\n/**\n * Every file below `directory`, as paths relative to it, POSIX-separated and sorted. A directory\n * that does not exist reads as empty: `doctor` never creates one to look at it.\n */\nexport function filesUnder(directory: string): string[] {\n\ttry {\n\t\treturn readdirSync(directory, { recursive: true, withFileTypes: true })\n\t\t\t.filter((entry) => entry.isFile())\n\t\t\t.map((entry) => relative(directory, join(entry.parentPath, entry.name)).split(sep).join(posix.sep))\n\t\t\t.sort()\n\t} catch {\n\t\treturn []\n\t}\n}\n","/**\n * The one place `doctor`'s guidance is written. The command reads `detail` and `repair` to fill its\n * `findings` and `help` sections; `skills/doctor/SKILL.md` is generated from the same table by\n * `scripts/generate-doctor-skill.ts`, so the shipped skill cannot drift from what the command says.\n */\n\n/** Every way a bridge can fail, in the order `doctor` reports them. */\nexport type BridgeProblem =\n\t| 'no-canonical'\n\t| 'missing'\n\t| 'degraded'\n\t| 'stale'\n\t| 'diverged-bridge'\n\t| 'diverged-canonical'\n\t| 'diverged-both'\n\t| 'diverged-unknown'\n\t| 'unpinned-copy'\n\nexport type Repair = {\n\tproblem: BridgeProblem\n\t/** What `doctor` prints in the `findings` row for this problem. */\n\tdetail: string\n\t/**\n\t * The command that fixes it, for `doctor`'s own output. `path` is the repository-relative bridge\n\t * path; `cli` is how to invoke this tool.\n\t */\n\trepair(path: string, cli: string): string\n\t/**\n\t * What the shipped skill tells an agent to do instead. A repair that rebuilds a bridge delegates\n\t * to the `init` skill rather than calling the `init` command, because rebuilding can move\n\t * user-authored skills and that judgment is the `init` skill's, not `doctor`'s.\n\t */\n\tskillRepair(path: string): string\n}\n\n/** How the command names itself. A skill may run without the binary on PATH, so it uses `npx`. */\nexport const commandInvocation = 'buddy-agent-harness'\nexport const skillInvocation = 'npx -y buddy-agent-harness'\n/** How the skill hands a repair back to `init`. */\nexport const initSkillInvocation = '/buddy-agent-harness:init'\n\nexport const doctorRepairs: readonly Repair[] = [\n\t{\n\t\tproblem: 'no-canonical',\n\t\tdetail: 'the canonical skill directory does not exist, so no bridge can resolve',\n\t\trepair: (_path, cli) => `${cli} init`,\n\t\tskillRepair: () => `run \\`${initSkillInvocation}\\`, which creates \\`.agents/skills\\` and the bridges`,\n\t},\n\t{\n\t\tproblem: 'missing',\n\t\tdetail: 'no bridge at this path — the harness sees zero project skills',\n\t\trepair: (_path, cli) => `${cli} init`,\n\t\tskillRepair: () => `run \\`${initSkillInvocation}\\``,\n\t},\n\t{\n\t\tproblem: 'degraded',\n\t\tdetail: 'expected a directory but found a regular file — checkout without core.symlinks',\n\t\trepair: (_path, cli) => `${cli} init --copy --force`,\n\t\tskillRepair: () => `run \\`${initSkillInvocation} --copy --force\\``,\n\t},\n\t{\n\t\tproblem: 'stale',\n\t\tdetail: 'symlink does not resolve to .agents/skills',\n\t\trepair: (_path, cli) => `${cli} init --force`,\n\t\tskillRepair: () => `run \\`${initSkillInvocation} --force\\``,\n\t},\n\t{\n\t\tproblem: 'diverged-bridge',\n\t\tdetail: 'only the bridge changed since the two last agreed — an agent wrote through the copy',\n\t\trepair: (path, cli) =>\n\t\t\t`replace .agents/skills with ${path} to keep the newer edit and then run ${cli} init --force`,\n\t\tskillRepair: (path) =>\n\t\t\t`replace .agents/skills with ${path} to keep the newer edit, then run \\`${initSkillInvocation} --force\\``,\n\t},\n\t{\n\t\tproblem: 'diverged-canonical',\n\t\tdetail: 'only .agents/skills changed since the two last agreed — the copy is stale',\n\t\trepair: (_path, cli) => `${cli} init --copy --force`,\n\t\tskillRepair: () => `run \\`${initSkillInvocation} --copy --force\\``,\n\t},\n\t{\n\t\tproblem: 'diverged-both',\n\t\tdetail: 'both sides changed since they last agreed — rebuilding would discard one of them',\n\t\trepair: (path) => `git diff --no-index .agents/skills ${path} and reconcile by hand`,\n\t\tskillRepair: (path) => `run \\`git diff --no-index .agents/skills ${path}\\` and reconcile by hand`,\n\t},\n\t{\n\t\tproblem: 'diverged-unknown',\n\t\tdetail: 'contents differ and no commit where they agreed was found — which side moved is unknown',\n\t\trepair: (path) => `git diff --no-index .agents/skills ${path} and reconcile by hand`,\n\t\tskillRepair: (path) => `run \\`git diff --no-index .agents/skills ${path}\\` and reconcile by hand`,\n\t},\n\t{\n\t\tproblem: 'unpinned-copy',\n\t\tdetail: 'tracked copy without the skip-worktree bit — the tree is dirty with content that must not be committed',\n\t\t// The index entry is the tracked symlink on a Windows checkout but the individual files in a\n\t\t// committed copy, so the paths are read back from git rather than assumed.\n\t\trepair: (path) => `git ls-files -z ${path} | xargs -0 git update-index --skip-worktree`,\n\t\tskillRepair: (path) => `run \\`git ls-files -z ${path} | xargs -0 git update-index --skip-worktree\\``,\n\t},\n]\n\nconst repairsByProblem = new Map(doctorRepairs.map((entry) => [entry.problem, entry]))\n\nexport function repairFor(problem: BridgeProblem): Repair {\n\treturn repairsByProblem.get(problem) as Repair\n}\n\nexport const doctorSkill = {\n\tname: 'doctor',\n\tdescription:\n\t\t'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.',\n} as const\n\n/** Written into the generated skill so a reader knows not to edit it in place. */\nexport const generatedSkillWarning =\n\t'<!-- Generated from src/diagnose-bridges/doctor-guidance.ts by scripts/generate-doctor-skill.ts. Do not edit by hand. -->'\n\n/** The shipped `doctor` skill, rendered from the same table the command prints. */\nexport function renderDoctorSkill(): string {\n\t// A repair may itself contain a pipe, which would otherwise end the table cell early.\n\tconst cell = (value: string) => value.replaceAll('|', '\\\\|')\n\tconst rows = doctorRepairs\n\t\t.map((entry) => `| \\`${entry.problem}\\` | ${entry.detail} | ${cell(entry.skillRepair('<path>'))} |`)\n\t\t.join('\\n')\n\n\treturn `---\nname: ${doctorSkill.name}\ndescription: ${doctorSkill.description}\n---\n\n${generatedSkillWarning}\n\n# Harness Doctor\n\n\\`.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.\n\nDiagnose it:\n\n\\`\\`\\`sh\n${skillInvocation} doctor\n\\`\\`\\`\n\nThe command is read-only. It never repairs anything, so it is safe to run at any point, including from a session-start hook.\n\n## Reading the report\n\n\\`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\\`.\n\n\\`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.\n\nWhen every bridge resolves, \\`findings\\` says so outright rather than being empty.\n\nThe 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\\`.\n\n## Findings and their repairs\n\n| Finding | What it means | Repair |\n| --- | --- | --- |\n${rows}\n\nSubstitute the reported bridge path for \\`<path>\\`.\n\n## The Windows case\n\nThe 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.\n\nA 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.\n\n## Rules\n\n- 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.\n- Edit skills at \\`.agents/skills/<name>/SKILL.md\\`. Editing through a bridge is only safe when that bridge is a symlink.\n- Do not add bridges to \\`.gitignore\\`. An untracked bridge swallows a real edit silently.\n`\n}\n","import { execFileSync } from 'node:child_process'\nimport { join } from 'node:path'\nimport { filesUnder } from './directory-files.ts'\n\n/** Which side of a diverged bridge moved since the last commit where the two agreed. */\nexport type DivergenceDirection = 'bridge' | 'canonical' | 'both' | 'unknown'\n\n/** Whether a path is tracked, and if so whether its skip-worktree bit is still set. */\nexport type TrackingState = 'untracked' | 'tracked' | 'skip-worktree'\n\nfunction git(root: string, args: string[], input?: string): string | undefined {\n\ttry {\n\t\treturn execFileSync('git', args, {\n\t\t\tcwd: root,\n\t\t\tencoding: 'utf8',\n\t\t\t...(input === undefined ? {} : { input }),\n\t\t\tstdio: ['pipe', 'pipe', 'pipe'],\n\t\t})\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\nfunction lines(output: string | undefined): string[] {\n\treturn (output ?? '').split('\\n').filter(Boolean)\n}\n\n/**\n * Reads git state for the two sides of a bridge. Every method degrades to \"cannot tell\" rather than\n * throwing, so `doctor` still reports on a tarball, a worktree without git, or a repository with no\n * commits.\n */\nexport class GitBridgeState {\n\t/** Path of `root` relative to the repository root, `''` at the top, `undefined` outside a repository. */\n\tprivate readonly prefix: string | undefined\n\n\tconstructor(private readonly root: string) {\n\t\tthis.prefix = git(root, ['rev-parse', '--show-prefix'])?.trim()\n\t}\n\n\t/**\n\t * `init --copy` marks a bridge skip-worktree so a machine-local copy over a tracked symlink stops\n\t * showing as a pending change. The bit is a hint that some checkouts clear, so it is verified\n\t * rather than assumed.\n\t */\n\ttrackingOf(path: string): TrackingState {\n\t\tif (this.prefix === undefined) return 'untracked'\n\t\tconst entries = lines(git(this.root, ['ls-files', '-v', '--', path]))\n\t\tif (!entries.length) return 'untracked'\n\t\treturn entries.every((entry) => entry.startsWith('S')) ? 'skip-worktree' : 'tracked'\n\t}\n\n\t/**\n\t * Names the side that moved by finding the newest commit whose two trees agreed, then asking which\n\t * of the working directories still matches it.\n\t */\n\tdirectionOf(bridge: string, canonical: string): DivergenceDirection {\n\t\tif (this.prefix === undefined) return 'unknown'\n\t\tconst base = this.lastAgreedTree(bridge, canonical)\n\t\tif (base === undefined) return 'unknown'\n\t\tconst bridgeMoved = !this.matchesTree(bridge, base)\n\t\tconst canonicalMoved = !this.matchesTree(canonical, base)\n\t\tif (bridgeMoved && canonicalMoved) return 'both'\n\t\tif (bridgeMoved) return 'bridge'\n\t\treturn 'canonical'\n\t}\n\n\t/** The tree of the newest commit where both paths held identical content. */\n\tprivate lastAgreedTree(bridge: string, canonical: string): string | undefined {\n\t\tfor (const commit of lines(git(this.root, ['rev-list', '-n', '200', 'HEAD', '--', bridge, canonical]))) {\n\t\t\tconst bridgeObject = git(this.root, ['rev-parse', '-q', '--verify', `${commit}:${this.prefix}${bridge}`])?.trim()\n\t\t\tif (!bridgeObject) continue\n\t\t\tconst canonicalObject = git(this.root, [\n\t\t\t\t'rev-parse',\n\t\t\t\t'-q',\n\t\t\t\t'--verify',\n\t\t\t\t`${commit}:${this.prefix}${canonical}`,\n\t\t\t])?.trim()\n\t\t\tif (bridgeObject === canonicalObject) return bridgeObject\n\t\t}\n\t\treturn undefined\n\t}\n\n\t/**\n\t * Compares a working directory against a git tree by object id. `hash-object` is used rather than\n\t * hashing in-process so the comparison follows whatever object format the repository uses.\n\t */\n\tprivate matchesTree(directory: string, tree: string): boolean {\n\t\tconst expected = new Map(\n\t\t\tlines(git(this.root, ['ls-tree', '-r', tree])).map((entry) => {\n\t\t\t\tconst [meta, path] = entry.split('\\t')\n\t\t\t\treturn [path, (meta as string).split(' ')[2] as string] as const\n\t\t\t}),\n\t\t)\n\t\tconst files = filesUnder(join(this.root, directory))\n\t\tif (files.length !== expected.size || files.some((file) => !expected.has(file))) return false\n\t\tconst hashed = lines(\n\t\t\tgit(this.root, ['hash-object', '--stdin-paths'], files.map((file) => join(directory, file)).join('\\n')),\n\t\t)\n\t\treturn hashed.length === files.length && files.every((file, index) => expected.get(file) === hashed[index])\n\t}\n}\n","import { lstatSync, readFileSync, readlinkSync, statSync } from 'node:fs'\nimport { dirname, join, relative } from 'node:path'\nimport { type Harness, type HarnessName, selectHarnesses } from '../harness-registry/harness-registry.ts'\nimport { filesUnder } from './directory-files.ts'\nimport { type BridgeProblem, repairFor } from './doctor-guidance.ts'\nimport { type DivergenceDirection, GitBridgeState } from './git-bridge-state.ts'\n\nexport type BridgeKind = 'symlink' | 'copy' | 'file' | 'none'\nexport type BridgeStatus = 'ok' | 'missing' | 'degraded' | 'stale' | 'diverged'\n\nexport type BridgeReport = {\n\tharness: HarnessName\n\t/** Repository-relative, as the harness registry declares it. */\n\tpath: string\n\tkind: BridgeKind\n\tstatus: BridgeStatus\n}\n\nexport type BridgeFinding = {\n\tpath: string\n\tdetail: string\n\t/** The command that repairs this finding, already carrying the bridge path. */\n\trepair: string\n}\n\nexport type DivergenceReport = {\n\tpath: string\n\tdirection: DivergenceDirection\n}\n\nexport type DiagnoseOptions = {\n\troot: string\n\tharnesses?: HarnessName[]\n\t/** How to name this tool in the repair commands. */\n\tcli: string\n}\n\nexport type DiagnoseResult = {\n\tbridges: BridgeReport[]\n\tdivergence: DivergenceReport[]\n\tfindings: BridgeFinding[]\n}\n\nconst canonicalPath = '.agents/skills'\n\nfunction pathState(target: string): 'missing' | 'symlink' | 'directory' | 'file' {\n\ttry {\n\t\tconst stats = lstatSync(target)\n\t\tif (stats.isSymbolicLink()) return 'symlink'\n\t\treturn stats.isDirectory() ? 'directory' : 'file'\n\t} catch {\n\t\treturn 'missing'\n\t}\n}\n\nfunction isDirectory(path: string): boolean {\n\ttry {\n\t\treturn statSync(path).isDirectory()\n\t} catch {\n\t\treturn false\n\t}\n}\n\n/** The same test `init` uses to decide a projection is already correct, plus that it still resolves. */\nfunction resolvesToCanonical(target: string, canonical: string): boolean {\n\treturn readlinkSync(target) === relative(dirname(target), canonical) && isDirectory(target)\n}\n\nfunction sameContent(left: string, right: string): boolean {\n\tconst leftFiles = filesUnder(left)\n\tconst rightFiles = filesUnder(right)\n\treturn (\n\t\tleftFiles.length === rightFiles.length &&\n\t\tleftFiles.every(\n\t\t\t(file, index) =>\n\t\t\t\trightFiles[index] === file && readFileSync(join(left, file)).equals(readFileSync(join(right, file))),\n\t\t)\n\t)\n}\n\ntype Inspection = {\n\tkind: BridgeKind\n\tstatus: BridgeStatus\n\tproblem?: BridgeProblem\n\tdirection?: DivergenceDirection\n}\n\nfunction inspect(target: string, path: string, canonical: string, git: GitBridgeState): Inspection {\n\tswitch (pathState(target)) {\n\t\tcase 'missing':\n\t\t\treturn { kind: 'none', status: 'missing', problem: 'missing' }\n\t\t// The flagship Windows failure: git with `core.symlinks=false` writes the link out as a\n\t\t// regular file holding the target path, and the harness silently loads nothing.\n\t\tcase 'file':\n\t\t\treturn { kind: 'file', status: 'degraded', problem: 'degraded' }\n\t\tcase 'symlink':\n\t\t\treturn resolvesToCanonical(target, canonical)\n\t\t\t\t? { kind: 'symlink', status: 'ok' }\n\t\t\t\t: { kind: 'symlink', status: 'stale', problem: 'stale' }\n\t\tdefault: {\n\t\t\tif (!sameContent(target, canonical)) {\n\t\t\t\tconst direction = git.directionOf(path, canonicalPath)\n\t\t\t\treturn { kind: 'copy', status: 'diverged', problem: `diverged-${direction}`, direction }\n\t\t\t}\n\t\t\t// A copy over a tracked symlink only stays out of `git status` while its skip-worktree bit\n\t\t\t// survives, and some checkout and merge operations clear it.\n\t\t\treturn git.trackingOf(path) === 'tracked'\n\t\t\t\t? { kind: 'copy', status: 'ok', problem: 'unpinned-copy' }\n\t\t\t\t: { kind: 'copy', status: 'ok' }\n\t\t}\n\t}\n}\n\n/**\n * Reports whether every bridge `init` would create for this repository still resolves into\n * `.agents/skills`. Read-only: nothing is created, moved, or repaired, so the caller decides what\n * to run from the repair each finding carries.\n */\nexport function diagnoseBridges({ root, harnesses: preferred = [], cli }: DiagnoseOptions): DiagnoseResult {\n\tconst canonical = join(root, '.agents', 'skills')\n\tconst git = new GitBridgeState(root)\n\tconst bridged = selectHarnesses(root, preferred).filter((harness): harness is Harness & { skillsDirectory: string } =>\n\t\tBoolean(harness.skillsDirectory),\n\t)\n\n\tconst bridges: BridgeReport[] = []\n\tconst divergence: DivergenceReport[] = []\n\tconst findings: BridgeFinding[] = []\n\n\tif (!isDirectory(canonical)) {\n\t\tconst { detail, repair } = repairFor('no-canonical')\n\t\tfindings.push({ path: canonicalPath, detail, repair: repair(canonicalPath, cli) })\n\t}\n\n\tfor (const harness of bridged) {\n\t\tconst path = harness.skillsDirectory\n\t\tconst inspection = inspect(join(root, path), path, canonical, git)\n\t\tbridges.push({ harness: harness.name, path, kind: inspection.kind, status: inspection.status })\n\t\tif (inspection.direction) divergence.push({ path, direction: inspection.direction })\n\t\tif (inspection.problem) {\n\t\t\tconst { detail, repair } = repairFor(inspection.problem)\n\t\t\tfindings.push({ path, detail, repair: repair(path, cli) })\n\t\t}\n\t}\n\n\treturn { bridges, divergence, findings }\n}\n","import { homedir } from 'node:os'\nimport type { cli } from 'clibuilder'\nimport { command, z } from 'clibuilder'\nimport { binPath, parseFormat, writeResult } from '../command-output/command-output.ts'\nimport { type HarnessName, harnessRegistry } from '../harness-registry/harness-registry.ts'\nimport { type DiagnoseResult, diagnoseBridges } from './diagnose-bridges.ts'\nimport { commandInvocation } from './doctor-guidance.ts'\n\ntype DoctorReport = {\n\tbin: string\n\tbridges: DiagnoseResult['bridges']\n\tdivergence?: DiagnoseResult['divergence']\n\t/** The repair is lifted out into `help`, so a finding row stays to the diagnosis itself. */\n\tfindings: Omit<DiagnoseResult['findings'][number], 'repair'>[] | string\n\thelp?: string[]\n}\n\n/**\n * AXI §5: the healthy answer states the zero with context, so an agent does not re-run with other\n * flags to confirm that an empty section really meant \"nothing wrong\".\n */\nexport function buildReport(bin: string, result: DiagnoseResult): DoctorReport {\n\tif (!result.findings.length) {\n\t\tconst count = result.bridges.length\n\t\treturn {\n\t\t\tbin,\n\t\t\tbridges: result.bridges,\n\t\t\tfindings:\n\t\t\t\tcount === 1 ? '0 problems found — the 1 bridge resolves' : `0 problems found — all ${count} bridges resolve`,\n\t\t}\n\t}\n\n\treturn {\n\t\tbin,\n\t\tbridges: result.bridges,\n\t\t...(result.divergence.length ? { divergence: result.divergence } : {}),\n\t\tfindings: result.findings.map(({ path, detail }) => ({ path, detail })),\n\t\thelp: [...new Set(result.findings.map((finding) => `Run \\`${finding.repair}\\``))],\n\t}\n}\n\nexport const doctorCommand: cli.Command = command({\n\tname: 'doctor',\n\tdescription: 'Report whether the harness skill bridges in this repository still resolve. Read-only.',\n\toptions: {\n\t\troot: {\n\t\t\tdescription: 'Repository or package directory. Defaults to the current directory.',\n\t\t\ttype: z.optional(z.string()),\n\t\t},\n\t\tharness: {\n\t\t\tdescription: 'Comma-separated harnesses to check in addition to Claude Code and Cursor, e.g. codex,gemini-cli.',\n\t\t\ttype: z.optional(z.string()),\n\t\t},\n\t\tformat: {\n\t\t\tdescription: 'Output format: toon (default), json, or text for a human-readable report.',\n\t\t\ttype: z.optional(z.string()),\n\t\t\tdefault: 'toon',\n\t\t},\n\t},\n\trun(args) {\n\t\ttry {\n\t\t\tconst format = parseFormat(args.format)\n\t\t\tconst requested = args.harness\n\t\t\t\t?.split(',')\n\t\t\t\t.map((name) => name.trim())\n\t\t\t\t.filter(Boolean)\n\t\t\tconst unsupported = requested?.filter((name) => !harnessRegistry.some((harness) => harness.name === name))\n\t\t\tif (unsupported?.length)\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Unsupported harness: ${unsupported.join(', ')}. Supported: ${harnessRegistry\n\t\t\t\t\t\t.map((harness) => harness.name)\n\t\t\t\t\t\t.join(', ')}.`,\n\t\t\t\t)\n\t\t\tconst result = diagnoseBridges({\n\t\t\t\troot: args.root ?? process.cwd(),\n\t\t\t\t...(requested?.length ? { harnesses: requested as HarnessName[] } : {}),\n\t\t\t\tcli: commandInvocation,\n\t\t\t})\n\t\t\t// Exit stays 0 even with findings: the diagnosis succeeded, and a non-zero code reads to an\n\t\t\t// agent as \"this command is broken, try something else\".\n\t\t\twriteResult(buildReport(binPath(homedir(), process.argv[1]), result), format)\n\t\t} catch (error) {\n\t\t\tprocess.stderr.write(`error: ${error instanceof Error ? error.message : 'Harness diagnosis failed.'}\\n`)\n\t\t\tprocess.exitCode = 1\n\t\t}\n\t},\n})\n","import { cpSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync } from 'node:fs'\nimport { dirname, join, relative } from 'node:path'\nimport type { Harness, HarnessName } from '../harness-registry/harness-registry.ts'\n\nfunction skillNames(skillsDirectory: string): string[] {\n\treturn readdirSync(skillsDirectory, { withFileTypes: true })\n\t\t.filter((entry) => entry.isDirectory())\n\t\t.map((entry) => entry.name)\n\t\t.sort()\n}\n\nfunction pathOccupied(path: string): boolean {\n\ttry {\n\t\tlstatSync(path)\n\t\treturn true\n\t} catch {\n\t\treturn false\n\t}\n}\n\nfunction isCorrectLink(target: string, source: string): boolean {\n\treturn lstatSync(target).isSymbolicLink() && readlinkSync(target) === relative(dirname(target), source)\n}\n\n/** Creates the canonical directory when absent, so a fresh repository reports zero skills. */\nexport function countSkills(canonicalSkills: string): number {\n\tmkdirSync(canonicalSkills, { recursive: true })\n\treturn skillNames(canonicalSkills).length\n}\n\nexport type ProjectSkillsOptions = {\n\troot: string\n\tcanonicalSkills: string\n\tharnesses: readonly Harness[]\n\tcopy: boolean\n\tforce: boolean\n}\n\n/**\n * Points every harness that cannot read `.agents/skills` at the canonical directory. Symlinking is\n * preferred; a platform that refuses the link falls back to a copy. Returns the projected harnesses.\n */\nexport function projectSkills({ root, canonicalSkills, harnesses, copy, force }: ProjectSkillsOptions): HarnessName[] {\n\tconst projections = harnesses\n\t\t.filter((harness) => harness.skillsDirectory)\n\t\t.map((harness) => ({\n\t\t\tharness,\n\t\t\ttarget: join(root, harness.skillsDirectory as string),\n\t\t}))\n\n\tconst conflicts = projections\n\t\t.filter(({ target }) => pathOccupied(target) && !isCorrectLink(target, canonicalSkills))\n\t\t.map(({ target }) => target)\n\n\tif (conflicts.length && !force) {\n\t\tthrow new Error(\n\t\t\t`Refusing to replace existing skill targets:\\n${conflicts.map((target) => `- ${target}`).join('\\n')}`,\n\t\t)\n\t}\n\n\tfor (const { target } of projections) {\n\t\tmkdirSync(dirname(target), { recursive: true })\n\t\tif (pathOccupied(target) && isCorrectLink(target, canonicalSkills)) continue\n\t\tif (pathOccupied(target)) rmSync(target, { recursive: true, force: true })\n\t\tif (copy) {\n\t\t\tcpSync(canonicalSkills, target, { recursive: true })\n\t\t\tcontinue\n\t\t}\n\t\ttry {\n\t\t\tsymlinkSync(relative(dirname(target), canonicalSkills), target, 'junction')\n\t\t} catch (error) {\n\t\t\tif (pathOccupied(target)) throw error\n\t\t\tcpSync(canonicalSkills, target, { recursive: true })\n\t\t}\n\t}\n\n\treturn projections.map(({ harness }) => harness.name)\n}\n","import { join } from 'node:path'\nimport { type HarnessName, selectHarnesses } from '../harness-registry/harness-registry.ts'\nimport { countSkills, projectSkills } from '../skill-projection/skill-projection.ts'\n\nexport type InitializeOptions = {\n\troot: string\n\tharnesses?: HarnessName[]\n\tcopy?: boolean\n\tforce?: boolean\n}\nexport type InitializeResult = {\n\troot: string\n\tharnesses: HarnessName[]\n\tnative: HarnessName[]\n\tlinked: HarnessName[]\n\t/** Enabled harnesses whose name has been superseded, as `{ name, replacedBy }`. */\n\tdeprecated: { name: HarnessName; replacedBy: HarnessName }[]\n\tskills: number\n\tcopied: boolean\n}\n\nexport function initializeHarnesses({\n\troot,\n\tharnesses: preferred = [],\n\tcopy = false,\n\tforce = false,\n}: InitializeOptions): InitializeResult {\n\tconst canonicalSkills = join(root, '.agents', 'skills')\n\tconst skills = countSkills(canonicalSkills)\n\tconst harnesses = selectHarnesses(root, preferred)\n\tconst linked = projectSkills({ root, canonicalSkills, harnesses, copy, force })\n\n\treturn {\n\t\troot,\n\t\tharnesses: harnesses.map((harness) => harness.name),\n\t\tnative: harnesses.filter((harness) => !harness.skillsDirectory).map((harness) => harness.name),\n\t\tlinked,\n\t\tdeprecated: harnesses\n\t\t\t.filter((harness) => harness.deprecated)\n\t\t\t.map((harness) => ({ name: harness.name, replacedBy: harness.deprecated as HarnessName })),\n\t\tskills,\n\t\tcopied: copy,\n\t}\n}\n","import type { cli } from 'clibuilder'\nimport { command, z } from 'clibuilder'\nimport { parseFormat, writeResult } from '../command-output/command-output.ts'\nimport { doctorCommand } from '../diagnose-bridges/doctor.command.ts'\nimport { type HarnessName, harnessRegistry } from '../harness-registry/harness-registry.ts'\nimport { initializeHarnesses } from './initialize-harnesses.ts'\n\nexport const initCommand: cli.Command = command({\n\tname: 'init',\n\tdescription: 'Set up canonical agent skills for the harnesses already enabled in a repository.',\n\toptions: {\n\t\troot: {\n\t\t\tdescription: 'Repository or package directory. Defaults to the current directory.',\n\t\t\ttype: z.optional(z.string()),\n\t\t},\n\t\tharness: {\n\t\t\tdescription: 'Comma-separated harnesses to enable in addition to Claude Code and Cursor, e.g. codex,gemini-cli.',\n\t\t\ttype: z.optional(z.string()),\n\t\t},\n\t\tcopy: {\n\t\t\tdescription: 'Copy skills instead of linking them.',\n\t\t\ttype: z.optional(z.boolean()),\n\t\t},\n\t\tforce: {\n\t\t\tdescription: 'Replace conflicting target skill directories.',\n\t\t\ttype: z.optional(z.boolean()),\n\t\t},\n\t\tformat: {\n\t\t\tdescription: 'Output format: toon (default), json, or text for a human-readable report.',\n\t\t\ttype: z.optional(z.string()),\n\t\t\tdefault: 'toon',\n\t\t},\n\t},\n\trun(args) {\n\t\ttry {\n\t\t\tconst format = parseFormat(args.format)\n\t\t\tconst requested = args.harness\n\t\t\t\t?.split(',')\n\t\t\t\t.map((name) => name.trim())\n\t\t\t\t.filter(Boolean)\n\t\t\tconst unsupported = requested?.filter((name) => !harnessRegistry.some((harness) => harness.name === name))\n\t\t\tif (unsupported?.length)\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Unsupported harness: ${unsupported.join(', ')}. Supported: ${harnessRegistry\n\t\t\t\t\t\t.map((harness) => harness.name)\n\t\t\t\t\t\t.join(', ')}.`,\n\t\t\t\t)\n\t\t\twriteResult(\n\t\t\t\tinitializeHarnesses({\n\t\t\t\t\troot: args.root ?? process.cwd(),\n\t\t\t\t\t...(requested?.length ? { harnesses: requested as HarnessName[] } : {}),\n\t\t\t\t\t...(args.copy === undefined ? {} : { copy: args.copy }),\n\t\t\t\t\t...(args.force === undefined ? {} : { force: args.force }),\n\t\t\t\t}),\n\t\t\t\tformat,\n\t\t\t)\n\t\t} catch (error) {\n\t\t\tprocess.stderr.write(`error: ${error instanceof Error ? error.message : 'Harness initialization failed.'}\\n`)\n\t\t\tprocess.exitCode = 1\n\t\t}\n\t},\n})\n\n// Named for the package rather than shortened to `harness`: `repobuddy` mounts every plugin into one\n// command namespace, where `harness` is generic enough to collide with another plugin's.\nexport const harnessCommand: cli.Command = command({\n\tname: 'agent-harness',\n\tdescription: 'Commands for configuring agent harness compatibility.',\n\tcommands: [initCommand, doctorCommand],\n})\n\nexport function activate({ addCommand }: { addCommand(command: typeof harnessCommand): void }): void {\n\taddCommand(harnessCommand)\n}\n"],"mappings":";;;;;;;AAKA,MAAM,UAAmC;CAAC;CAAQ;CAAQ;AAAM;;AAGhE,SAAgB,YAAY,OAAyC;CACpE,IAAI,CAAC,QAAQ,SAAS,KAAqB,GAAG,MAAM,IAAI,MAAM,uCAAuC;CACrG,OAAO;AACR;;AAGA,SAAgB,YAAY,OAAe,QAA4B;CACtE,QAAQ,OAAO,MAAM,GAAG,aAAa,OAAO,MAAM,EAAE,GAAG;AACxD;AAEA,SAAS,aAAa,OAAe,QAA8B;CAClE,IAAI,WAAW,QAAQ,OAAO,KAAK,UAAU,KAAK;CAClD,OAAO,WAAW,SAAS,WAAW,KAAK,IAAI,OAAO,KAAK;AAC5D;AAEA,SAAS,SAAS,OAAkD;CACnE,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC3E;AAEA,SAAS,KAAK,OAAwB;CACrC,OAAO,UAAU,KAAA,IAAY,KAAK,OAAO,KAAK;AAC/C;;AAGA,SAAS,MAAM,MAA2C;CACzD,MAAM,UAAU,CAAC,GAAG,IAAI,IAAI,KAAK,SAAS,QAAQ,OAAO,KAAK,GAAG,CAAC,CAAC,CAAC;CACpE,MAAM,SAAS,QAAQ,KAAK,WAAW,KAAK,IAAI,OAAO,QAAQ,GAAG,KAAK,KAAK,QAAQ,KAAK,IAAI,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC;CAC9G,MAAM,QAAQ,WACb,KAAK,OAAO,KAAK,OAAO,UAAU,MAAM,OAAO,OAAO,MAAgB,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,QAAQ;CAC/F,OAAO,CAAC,KAAK,OAAO,GAAG,GAAG,KAAK,KAAK,QAAQ,KAAK,QAAQ,KAAK,WAAW,KAAK,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;AAC9F;;;;;;AAOA,SAAgB,WAAW,OAAuB;CACjD,MAAM,SAAS,OAAO,QAAQ,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,UAAU;EACzD,IAAI,CAAC,MAAM,QAAQ,IAAI,GAAG,OAAO,CAAC,GAAG,IAAI,IAAI,SAAS,IAAI,IAAI,KAAK,UAAU,IAAI,IAAI,OAAO,IAAI,GAAG;EACnG,IAAI,CAAC,KAAK,QAAQ,OAAO,CAAC,GAAG,IAAI,SAAS;EAC1C,MAAM,OAAO,KAAK,MAAM,QAAQ,IAAI,MAAM,IAAI,IAAI,KAAK,KAAK,UAAU,OAAO,OAAO,KAAK,GAAG;EAC5F,OAAO,CAAC,GAAG,IAAI,IAAI,GAAG,IAAI;CAC3B,CAAC;CAID,OAAO,OACL,SAAS,OAAO,UAAU;EAC1B,MAAM,WAAW,OAAO,QAAQ;EAChC,OAAO,aAAa,SAAS,SAAS,KAAK,MAAM,SAAS,KAAK,CAAC,IAAI,GAAG,KAAK,IAAI;CACjF,CAAC,CAAC,CACD,KAAK,IAAI;AACZ;;AAGA,SAAgB,QAAQ,MAAc,YAAwC;CAC7E,IAAI,CAAC,YAAY,OAAO;CACxB,OAAO,QAAQ,WAAW,WAAW,OAAO,GAAG,IAAI,IAAI,WAAW,MAAM,KAAK,MAAM,MAAM;AAC1F;;;;;;;;;;;;;ACjCA,MAAa,kBAAsC;CAClD;EAAE,MAAM;EAAe,QAAQ;EAAW,iBAAiB;CAAiB;CAC5E;EAAE,MAAM;EAAU,QAAQ;CAAU;CACpC;EAAE,MAAM;EAAS,QAAQ;CAAS;CAClC;EAAE,MAAM;EAAe,QAAQ;CAAiB;CAChD;EAAE,MAAM;EAAc,QAAQ;EAAW,iBAAiB;CAAiB;CAC3E;EAAE,MAAM;EAAiB,QAAQ;CAAS;CAC1C;EAAE,MAAM;EAAY,QAAQ;EAAa,iBAAiB;EAAoB,YAAY;CAAgB;AAC3G;;AAGA,MAAM,mBAA2C,CAAC,eAAe,QAAQ;AAEzE,SAAS,gBAAgB,MAAuB;CAC/C,IAAI;EACH,OAAO,UAAU,IAAI,CAAC,CAAC,YAAY;CACpC,QAAQ;EACP,OAAO;CACR;AACD;;AAGA,SAAgB,gBAAgB,MAAc,WAA8C;CAC3F,MAAM,0BAAU,IAAI,IAAiB,CAAC,GAAG,kBAAkB,GAAG,SAAS,CAAC;CACxE,OAAO,gBAAgB,QAAQ,YAAY,QAAQ,IAAI,QAAQ,IAAI,KAAK,gBAAgB,KAAK,MAAM,QAAQ,MAAM,CAAC,CAAC;AACpH;;;;;;;ACpDA,SAAgB,WAAW,WAA6B;CACvD,IAAI;EACH,OAAO,YAAY,WAAW;GAAE,WAAW;GAAM,eAAe;EAAK,CAAC,CAAC,CACrE,QAAQ,UAAU,MAAM,OAAO,CAAC,CAAC,CACjC,KAAK,UAAU,SAAS,WAAW,KAAK,MAAM,YAAY,MAAM,IAAI,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,MAAM,GAAG,CAAC,CAAC,CAClG,KAAK;CACR,QAAQ;EACP,OAAO,CAAC;CACT;AACD;;;;ACoBA,MAAa,oBAAoB;AACjC,MAAa,kBAAkB;;AAE/B,MAAa,sBAAsB;AAEnC,MAAa,gBAAmC;CAC/C;EACC,SAAS;EACT,QAAQ;EACR,SAAS,OAAO,QAAQ,GAAG,IAAI;EAC/B,mBAAmB,SAAS,oBAAoB;CACjD;CACA;EACC,SAAS;EACT,QAAQ;EACR,SAAS,OAAO,QAAQ,GAAG,IAAI;EAC/B,mBAAmB,SAAS,oBAAoB;CACjD;CACA;EACC,SAAS;EACT,QAAQ;EACR,SAAS,OAAO,QAAQ,GAAG,IAAI;EAC/B,mBAAmB,SAAS,oBAAoB;CACjD;CACA;EACC,SAAS;EACT,QAAQ;EACR,SAAS,OAAO,QAAQ,GAAG,IAAI;EAC/B,mBAAmB,SAAS,oBAAoB;CACjD;CACA;EACC,SAAS;EACT,QAAQ;EACR,SAAS,MAAM,QACd,+BAA+B,KAAK,uCAAuC,IAAI;EAChF,cAAc,SACb,+BAA+B,KAAK,sCAAsC,oBAAoB;CAChG;CACA;EACC,SAAS;EACT,QAAQ;EACR,SAAS,OAAO,QAAQ,GAAG,IAAI;EAC/B,mBAAmB,SAAS,oBAAoB;CACjD;CACA;EACC,SAAS;EACT,QAAQ;EACR,SAAS,SAAS,sCAAsC,KAAK;EAC7D,cAAc,SAAS,4CAA4C,KAAK;CACzE;CACA;EACC,SAAS;EACT,QAAQ;EACR,SAAS,SAAS,sCAAsC,KAAK;EAC7D,cAAc,SAAS,4CAA4C,KAAK;CACzE;CACA;EACC,SAAS;EACT,QAAQ;EAGR,SAAS,SAAS,mBAAmB,KAAK;EAC1C,cAAc,SAAS,yBAAyB,KAAK;CACtD;AACD;AAEA,MAAM,mBAAmB,IAAI,IAAI,cAAc,KAAK,UAAU,CAAC,MAAM,SAAS,KAAK,CAAC,CAAC;AAErF,SAAgB,UAAU,SAAgC;CACzD,OAAO,iBAAiB,IAAI,OAAO;AACpC;AAEA,MAAa,cAAc;CAC1B,MAAM;CACN,aACC;AACF;;AAGA,MAAa,wBACZ;;AAGD,SAAgB,oBAA4B;CAE3C,MAAM,QAAQ,UAAkB,MAAM,WAAW,KAAK,KAAK;CAC3D,MAAM,OAAO,cACX,KAAK,UAAU,OAAO,MAAM,QAAQ,OAAO,MAAM,OAAO,KAAK,KAAK,MAAM,YAAY,QAAQ,CAAC,EAAE,GAAG,CAAC,CACnG,KAAK,IAAI;CAEX,OAAO;QACA,YAAY,KAAK;eACV,YAAY,YAAY;;;EAGrC,sBAAsB;;;;;;;;;EAStB,gBAAgB;;;;;;;;;gNAS8L,oBAAoB;;;;;;;;;;EAUlO,KAAK;;;;;;qVAM8U,oBAAoB;;;;;;;;;;AAUzW;;;ACrKA,SAAS,IAAI,MAAc,MAAgB,OAAoC;CAC9E,IAAI;EACH,OAAO,aAAa,OAAO,MAAM;GAChC,KAAK;GACL,UAAU;GACV,GAAI,UAAU,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM;GACvC,OAAO;IAAC;IAAQ;IAAQ;GAAM;EAC/B,CAAC;CACF,QAAQ;EACP;CACD;AACD;AAEA,SAAS,MAAM,QAAsC;CACpD,QAAQ,UAAU,GAAA,CAAI,MAAM,IAAI,CAAC,CAAC,OAAO,OAAO;AACjD;;;;;;AAOA,IAAa,iBAAb,MAA4B;CAIE;;CAF7B;CAEA,YAAY,MAA+B;EAAd,KAAA,OAAA;EAC5B,KAAK,SAAS,IAAI,MAAM,CAAC,aAAa,eAAe,CAAC,CAAC,EAAE,KAAK;CAC/D;;;;;;CAOA,WAAW,MAA6B;EACvC,IAAI,KAAK,WAAW,KAAA,GAAW,OAAO;EACtC,MAAM,UAAU,MAAM,IAAI,KAAK,MAAM;GAAC;GAAY;GAAM;GAAM;EAAI,CAAC,CAAC;EACpE,IAAI,CAAC,QAAQ,QAAQ,OAAO;EAC5B,OAAO,QAAQ,OAAO,UAAU,MAAM,WAAW,GAAG,CAAC,IAAI,kBAAkB;CAC5E;;;;;CAMA,YAAY,QAAgB,WAAwC;EACnE,IAAI,KAAK,WAAW,KAAA,GAAW,OAAO;EACtC,MAAM,OAAO,KAAK,eAAe,QAAQ,SAAS;EAClD,IAAI,SAAS,KAAA,GAAW,OAAO;EAC/B,MAAM,cAAc,CAAC,KAAK,YAAY,QAAQ,IAAI;EAClD,MAAM,iBAAiB,CAAC,KAAK,YAAY,WAAW,IAAI;EACxD,IAAI,eAAe,gBAAgB,OAAO;EAC1C,IAAI,aAAa,OAAO;EACxB,OAAO;CACR;;CAGA,eAAuB,QAAgB,WAAuC;EAC7E,KAAK,MAAM,UAAU,MAAM,IAAI,KAAK,MAAM;GAAC;GAAY;GAAM;GAAO;GAAQ;GAAM;GAAQ;EAAS,CAAC,CAAC,GAAG;GACvG,MAAM,eAAe,IAAI,KAAK,MAAM;IAAC;IAAa;IAAM;IAAY,GAAG,OAAO,GAAG,KAAK,SAAS;GAAQ,CAAC,CAAC,EAAE,KAAK;GAChH,IAAI,CAAC,cAAc;GAOnB,IAAI,iBANoB,IAAI,KAAK,MAAM;IACtC;IACA;IACA;IACA,GAAG,OAAO,GAAG,KAAK,SAAS;GAC5B,CAAC,CAAC,EAAE,KAAK,GAC6B,OAAO;EAC9C;CAED;;;;;CAMA,YAAoB,WAAmB,MAAuB;EAC7D,MAAM,WAAW,IAAI,IACpB,MAAM,IAAI,KAAK,MAAM;GAAC;GAAW;GAAM;EAAI,CAAC,CAAC,CAAC,CAAC,KAAK,UAAU;GAC7D,MAAM,CAAC,MAAM,QAAQ,MAAM,MAAM,GAAI;GACrC,OAAO,CAAC,MAAO,KAAgB,MAAM,GAAG,CAAC,CAAC,EAAY;EACvD,CAAC,CACF;EACA,MAAM,QAAQ,WAAW,KAAK,KAAK,MAAM,SAAS,CAAC;EACnD,IAAI,MAAM,WAAW,SAAS,QAAQ,MAAM,MAAM,SAAS,CAAC,SAAS,IAAI,IAAI,CAAC,GAAG,OAAO;EACxF,MAAM,SAAS,MACd,IAAI,KAAK,MAAM,CAAC,eAAe,eAAe,GAAG,MAAM,KAAK,SAAS,KAAK,WAAW,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CACvG;EACA,OAAO,OAAO,WAAW,MAAM,UAAU,MAAM,OAAO,MAAM,UAAU,SAAS,IAAI,IAAI,MAAM,OAAO,MAAM;CAC3G;AACD;;;AC1DA,MAAM,gBAAgB;AAEtB,SAAS,UAAU,QAA8D;CAChF,IAAI;EACH,MAAM,QAAQ,UAAU,MAAM;EAC9B,IAAI,MAAM,eAAe,GAAG,OAAO;EACnC,OAAO,MAAM,YAAY,IAAI,cAAc;CAC5C,QAAQ;EACP,OAAO;CACR;AACD;AAEA,SAAS,YAAY,MAAuB;CAC3C,IAAI;EACH,OAAO,SAAS,IAAI,CAAC,CAAC,YAAY;CACnC,QAAQ;EACP,OAAO;CACR;AACD;;AAGA,SAAS,oBAAoB,QAAgB,WAA4B;CACxE,OAAO,aAAa,MAAM,MAAM,SAAS,QAAQ,MAAM,GAAG,SAAS,KAAK,YAAY,MAAM;AAC3F;AAEA,SAAS,YAAY,MAAc,OAAwB;CAC1D,MAAM,YAAY,WAAW,IAAI;CACjC,MAAM,aAAa,WAAW,KAAK;CACnC,OACC,UAAU,WAAW,WAAW,UAChC,UAAU,OACR,MAAM,UACN,WAAW,WAAW,QAAQ,aAAa,KAAK,MAAM,IAAI,CAAC,CAAC,CAAC,OAAO,aAAa,KAAK,OAAO,IAAI,CAAC,CAAC,CACrG;AAEF;AASA,SAAS,QAAQ,QAAgB,MAAc,WAAmB,KAAiC;CAClG,QAAQ,UAAU,MAAM,GAAxB;EACC,KAAK,WACJ,OAAO;GAAE,MAAM;GAAQ,QAAQ;GAAW,SAAS;EAAU;EAG9D,KAAK,QACJ,OAAO;GAAE,MAAM;GAAQ,QAAQ;GAAY,SAAS;EAAW;EAChE,KAAK,WACJ,OAAO,oBAAoB,QAAQ,SAAS,IACzC;GAAE,MAAM;GAAW,QAAQ;EAAK,IAChC;GAAE,MAAM;GAAW,QAAQ;GAAS,SAAS;EAAQ;EACzD;GACC,IAAI,CAAC,YAAY,QAAQ,SAAS,GAAG;IACpC,MAAM,YAAY,IAAI,YAAY,MAAM,aAAa;IACrD,OAAO;KAAE,MAAM;KAAQ,QAAQ;KAAY,SAAS,YAAY;KAAa;IAAU;GACxF;GAGA,OAAO,IAAI,WAAW,IAAI,MAAM,YAC7B;IAAE,MAAM;IAAQ,QAAQ;IAAM,SAAS;GAAgB,IACvD;IAAE,MAAM;IAAQ,QAAQ;GAAK;CAElC;AACD;;;;;;AAOA,SAAgB,gBAAgB,EAAE,MAAM,WAAW,YAAY,CAAC,GAAG,OAAwC;CAC1G,MAAM,YAAY,KAAK,MAAM,WAAW,QAAQ;CAChD,MAAM,MAAM,IAAI,eAAe,IAAI;CACnC,MAAM,UAAU,gBAAgB,MAAM,SAAS,CAAC,CAAC,QAAQ,YACxD,QAAQ,QAAQ,eAAe,CAChC;CAEA,MAAM,UAA0B,CAAC;CACjC,MAAM,aAAiC,CAAC;CACxC,MAAM,WAA4B,CAAC;CAEnC,IAAI,CAAC,YAAY,SAAS,GAAG;EAC5B,MAAM,EAAE,QAAQ,WAAW,UAAU,cAAc;EACnD,SAAS,KAAK;GAAE,MAAM;GAAe;GAAQ,QAAQ,OAAO,eAAe,GAAG;EAAE,CAAC;CAClF;CAEA,KAAK,MAAM,WAAW,SAAS;EAC9B,MAAM,OAAO,QAAQ;EACrB,MAAM,aAAa,QAAQ,KAAK,MAAM,IAAI,GAAG,MAAM,WAAW,GAAG;EACjE,QAAQ,KAAK;GAAE,SAAS,QAAQ;GAAM;GAAM,MAAM,WAAW;GAAM,QAAQ,WAAW;EAAO,CAAC;EAC9F,IAAI,WAAW,WAAW,WAAW,KAAK;GAAE;GAAM,WAAW,WAAW;EAAU,CAAC;EACnF,IAAI,WAAW,SAAS;GACvB,MAAM,EAAE,QAAQ,WAAW,UAAU,WAAW,OAAO;GACvD,SAAS,KAAK;IAAE;IAAM;IAAQ,QAAQ,OAAO,MAAM,GAAG;GAAE,CAAC;EAC1D;CACD;CAEA,OAAO;EAAE;EAAS;EAAY;CAAS;AACxC;;;;;;;AC7HA,SAAgB,YAAY,KAAa,QAAsC;CAC9E,IAAI,CAAC,OAAO,SAAS,QAAQ;EAC5B,MAAM,QAAQ,OAAO,QAAQ;EAC7B,OAAO;GACN;GACA,SAAS,OAAO;GAChB,UACC,UAAU,IAAI,6CAA6C,0BAA0B,MAAM;EAC7F;CACD;CAEA,OAAO;EACN;EACA,SAAS,OAAO;EAChB,GAAI,OAAO,WAAW,SAAS,EAAE,YAAY,OAAO,WAAW,IAAI,CAAC;EACpE,UAAU,OAAO,SAAS,KAAK,EAAE,MAAM,cAAc;GAAE;GAAM;EAAO,EAAE;EACtE,MAAM,CAAC,GAAG,IAAI,IAAI,OAAO,SAAS,KAAK,YAAY,SAAS,QAAQ,OAAO,GAAG,CAAC,CAAC;CACjF;AACD;AAEA,MAAa,gBAA6B,QAAQ;CACjD,MAAM;CACN,aAAa;CACb,SAAS;EACR,MAAM;GACL,aAAa;GACb,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC;EAC5B;EACA,SAAS;GACR,aAAa;GACb,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC;EAC5B;EACA,QAAQ;GACP,aAAa;GACb,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC;GAC3B,SAAS;EACV;CACD;CACA,IAAI,MAAM;EACT,IAAI;GACH,MAAM,SAAS,YAAY,KAAK,MAAM;GACtC,MAAM,YAAY,KAAK,SACpB,MAAM,GAAG,CAAC,CACX,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,CAC1B,OAAO,OAAO;GAChB,MAAM,cAAc,WAAW,QAAQ,SAAS,CAAC,gBAAgB,MAAM,YAAY,QAAQ,SAAS,IAAI,CAAC;GACzG,IAAI,aAAa,QAChB,MAAM,IAAI,MACT,wBAAwB,YAAY,KAAK,IAAI,EAAE,eAAe,gBAC5D,KAAK,YAAY,QAAQ,IAAI,CAAC,CAC9B,KAAK,IAAI,EAAE,EACd;GACD,MAAM,SAAS,gBAAgB;IAC9B,MAAM,KAAK,QAAQ,QAAQ,IAAI;IAC/B,GAAI,WAAW,SAAS,EAAE,WAAW,UAA2B,IAAI,CAAC;IACrE,KAAK;GACN,CAAC;GAGD,YAAY,YAAY,QAAQ,QAAQ,GAAG,QAAQ,KAAK,EAAE,GAAG,MAAM,GAAG,MAAM;EAC7E,SAAS,OAAO;GACf,QAAQ,OAAO,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,4BAA4B,GAAG;GACvG,QAAQ,WAAW;EACpB;CACD;AACD,CAAC;;;AClFD,SAAS,WAAW,iBAAmC;CACtD,OAAO,YAAY,iBAAiB,EAAE,eAAe,KAAK,CAAC,CAAC,CAC1D,QAAQ,UAAU,MAAM,YAAY,CAAC,CAAC,CACtC,KAAK,UAAU,MAAM,IAAI,CAAC,CAC1B,KAAK;AACR;AAEA,SAAS,aAAa,MAAuB;CAC5C,IAAI;EACH,UAAU,IAAI;EACd,OAAO;CACR,QAAQ;EACP,OAAO;CACR;AACD;AAEA,SAAS,cAAc,QAAgB,QAAyB;CAC/D,OAAO,UAAU,MAAM,CAAC,CAAC,eAAe,KAAK,aAAa,MAAM,MAAM,SAAS,QAAQ,MAAM,GAAG,MAAM;AACvG;;AAGA,SAAgB,YAAY,iBAAiC;CAC5D,UAAU,iBAAiB,EAAE,WAAW,KAAK,CAAC;CAC9C,OAAO,WAAW,eAAe,CAAC,CAAC;AACpC;;;;;AAcA,SAAgB,cAAc,EAAE,MAAM,iBAAiB,WAAW,MAAM,SAA8C;CACrH,MAAM,cAAc,UAClB,QAAQ,YAAY,QAAQ,eAAe,CAAC,CAC5C,KAAK,aAAa;EAClB;EACA,QAAQ,KAAK,MAAM,QAAQ,eAAyB;CACrD,EAAE;CAEH,MAAM,YAAY,YAChB,QAAQ,EAAE,aAAa,aAAa,MAAM,KAAK,CAAC,cAAc,QAAQ,eAAe,CAAC,CAAC,CACvF,KAAK,EAAE,aAAa,MAAM;CAE5B,IAAI,UAAU,UAAU,CAAC,OACxB,MAAM,IAAI,MACT,gDAAgD,UAAU,KAAK,WAAW,KAAK,QAAQ,CAAC,CAAC,KAAK,IAAI,GACnG;CAGD,KAAK,MAAM,EAAE,YAAY,aAAa;EACrC,UAAU,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;EAC9C,IAAI,aAAa,MAAM,KAAK,cAAc,QAAQ,eAAe,GAAG;EACpE,IAAI,aAAa,MAAM,GAAG,OAAO,QAAQ;GAAE,WAAW;GAAM,OAAO;EAAK,CAAC;EACzE,IAAI,MAAM;GACT,OAAO,iBAAiB,QAAQ,EAAE,WAAW,KAAK,CAAC;GACnD;EACD;EACA,IAAI;GACH,YAAY,SAAS,QAAQ,MAAM,GAAG,eAAe,GAAG,QAAQ,UAAU;EAC3E,SAAS,OAAO;GACf,IAAI,aAAa,MAAM,GAAG,MAAM;GAChC,OAAO,iBAAiB,QAAQ,EAAE,WAAW,KAAK,CAAC;EACpD;CACD;CAEA,OAAO,YAAY,KAAK,EAAE,cAAc,QAAQ,IAAI;AACrD;;;ACxDA,SAAgB,oBAAoB,EACnC,MACA,WAAW,YAAY,CAAC,GACxB,OAAO,OACP,QAAQ,SAC+B;CACvC,MAAM,kBAAkB,KAAK,MAAM,WAAW,QAAQ;CACtD,MAAM,SAAS,YAAY,eAAe;CAC1C,MAAM,YAAY,gBAAgB,MAAM,SAAS;CACjD,MAAM,SAAS,cAAc;EAAE;EAAM;EAAiB;EAAW;EAAM;CAAM,CAAC;CAE9E,OAAO;EACN;EACA,WAAW,UAAU,KAAK,YAAY,QAAQ,IAAI;EAClD,QAAQ,UAAU,QAAQ,YAAY,CAAC,QAAQ,eAAe,CAAC,CAAC,KAAK,YAAY,QAAQ,IAAI;EAC7F;EACA,YAAY,UACV,QAAQ,YAAY,QAAQ,UAAU,CAAC,CACvC,KAAK,aAAa;GAAE,MAAM,QAAQ;GAAM,YAAY,QAAQ;EAA0B,EAAE;EAC1F;EACA,QAAQ;CACT;AACD;;;ACpCA,MAAa,cAA2B,QAAQ;CAC/C,MAAM;CACN,aAAa;CACb,SAAS;EACR,MAAM;GACL,aAAa;GACb,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC;EAC5B;EACA,SAAS;GACR,aAAa;GACb,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC;EAC5B;EACA,MAAM;GACL,aAAa;GACb,MAAM,EAAE,SAAS,EAAE,QAAQ,CAAC;EAC7B;EACA,OAAO;GACN,aAAa;GACb,MAAM,EAAE,SAAS,EAAE,QAAQ,CAAC;EAC7B;EACA,QAAQ;GACP,aAAa;GACb,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC;GAC3B,SAAS;EACV;CACD;CACA,IAAI,MAAM;EACT,IAAI;GACH,MAAM,SAAS,YAAY,KAAK,MAAM;GACtC,MAAM,YAAY,KAAK,SACpB,MAAM,GAAG,CAAC,CACX,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,CAC1B,OAAO,OAAO;GAChB,MAAM,cAAc,WAAW,QAAQ,SAAS,CAAC,gBAAgB,MAAM,YAAY,QAAQ,SAAS,IAAI,CAAC;GACzG,IAAI,aAAa,QAChB,MAAM,IAAI,MACT,wBAAwB,YAAY,KAAK,IAAI,EAAE,eAAe,gBAC5D,KAAK,YAAY,QAAQ,IAAI,CAAC,CAC9B,KAAK,IAAI,EAAE,EACd;GACD,YACC,oBAAoB;IACnB,MAAM,KAAK,QAAQ,QAAQ,IAAI;IAC/B,GAAI,WAAW,SAAS,EAAE,WAAW,UAA2B,IAAI,CAAC;IACrE,GAAI,KAAK,SAAS,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM,KAAK,KAAK;IACrD,GAAI,KAAK,UAAU,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO,KAAK,MAAM;GACzD,CAAC,GACD,MACD;EACD,SAAS,OAAO;GACf,QAAQ,OAAO,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,iCAAiC,GAAG;GAC5G,QAAQ,WAAW;EACpB;CACD;AACD,CAAC;AAID,MAAa,iBAA8B,QAAQ;CAClD,MAAM;CACN,aAAa;CACb,UAAU,CAAC,aAAa,aAAa;AACtC,CAAC;AAED,SAAgB,SAAS,EAAE,cAA0E;CACpG,WAAW,cAAc;AAC1B"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buddy-agent-harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Initialize standards-based agent configuration across
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Initialize standards-based agent configuration across agent harnesses",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"repobuddy",
|
|
7
7
|
"agent",
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
"build": "tsdown",
|
|
61
61
|
"coverage": "vitest run --coverage",
|
|
62
62
|
"dev": "node bin/buddy-agent-harness.mjs",
|
|
63
|
+
"skill:doctor": "tsx scripts/generate-doctor-skill.ts",
|
|
64
|
+
"skill:doctor:check": "tsx scripts/generate-doctor-skill.ts --check",
|
|
63
65
|
"test": "vitest run",
|
|
64
66
|
"test:dist": "tsc --noEmit -p tsconfig.dist.json && vitest run --config vitest.dist.config.ts",
|
|
65
67
|
"typecheck": "tsc --noEmit"
|
package/plugin.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "buddy-agent-harness",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Initialize standards-based agent configuration across
|
|
4
|
+
"version": "0.3.1",
|
|
5
|
+
"description": "Initialize standards-based agent configuration across agent harnesses",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "repobuddy"
|
|
8
8
|
},
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Harness Doctor
|
|
2
|
+
|
|
3
|
+
Report whether the harness skill bridges in a repository still resolve into `.agents/skills`.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
`init` points every harness that cannot read `.agents/skills` at that directory, normally with a directory-level symlink. `doctor` re-checks each of those bridges after the fact and names the repair for whatever it finds. The command names an `init` invocation for a person at a shell; the skill hands the same repair to the `init` skill, because rebuilding a bridge can move skills a user wrote.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx -y buddy-agent-harness doctor
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Why it exists
|
|
14
|
+
|
|
15
|
+
The failure it is built for is silent. 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 committed symlink out as a regular file whose contents are the target path. The harness looks for a directory, finds a file, and loads zero project skills with no warning anywhere.
|
|
16
|
+
|
|
17
|
+
`core.symlinks` is per-clone and cannot be enforced from the repository, so detection is the only reliable lever. It is also cheap: the path exists but is not a directory.
|
|
18
|
+
|
|
19
|
+
## Read-only by design
|
|
20
|
+
|
|
21
|
+
There is no `--fix`. Every repair is already expressible with `init` flags, and reimplementing them here would drift. On the Windows case it would likely drift wrongly: the naive repair is to recreate the link, which is exactly the operation that failed on that machine. `--copy` is the branch that works there, and `init` already has it.
|
|
22
|
+
|
|
23
|
+
Staying read-only also means the command is safe to wire into a session-start hook without reasoning about whether the hook passes a mutating flag.
|
|
24
|
+
|
|
25
|
+
The exit code is `0` even when findings exist. The diagnosis succeeded; a non-zero code reads to an agent as "this command is broken, try something else."
|
|
26
|
+
|
|
27
|
+
## Divergence
|
|
28
|
+
|
|
29
|
+
A copy is a snapshot rather than a live projection, so writes flow the wrong way through it: an agent asked to update a skill edits `.claude/skills/<name>/SKILL.md` and the change lands in the copy instead of in `.agents/skills`. When the two sides differ, `doctor` names which one moved — using the last commit where they agreed as the base — because "these two differ" is not actionable on its own. When both moved it says so and refuses to guess, since rebuilding would discard one of the edits.
|
|
30
|
+
|
|
31
|
+
## Generated from the command
|
|
32
|
+
|
|
33
|
+
`SKILL.md` is generated from `src/diagnose-bridges/doctor-guidance.ts`, the same table the command prints its findings and repairs from, so the skill cannot drift from the CLI. `pnpm skill:doctor` rewrites it and `pnpm skill:doctor:check` — part of `pnpm verify` — fails when the committed copy is stale.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doctor
|
|
3
|
+
description: 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.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<!-- Generated from src/diagnose-bridges/doctor-guidance.ts by scripts/generate-doctor-skill.ts. Do not edit by hand. -->
|
|
7
|
+
|
|
8
|
+
# Harness Doctor
|
|
9
|
+
|
|
10
|
+
`.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.
|
|
11
|
+
|
|
12
|
+
Diagnose it:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npx -y buddy-agent-harness doctor
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The command is read-only. It never repairs anything, so it is safe to run at any point, including from a session-start hook.
|
|
19
|
+
|
|
20
|
+
## Reading the report
|
|
21
|
+
|
|
22
|
+
`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`.
|
|
23
|
+
|
|
24
|
+
`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 `/buddy-agent-harness:init` instead.
|
|
25
|
+
|
|
26
|
+
When every bridge resolves, `findings` says so outright rather than being empty.
|
|
27
|
+
|
|
28
|
+
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`.
|
|
29
|
+
|
|
30
|
+
## Findings and their repairs
|
|
31
|
+
|
|
32
|
+
| Finding | What it means | Repair |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `no-canonical` | the canonical skill directory does not exist, so no bridge can resolve | run `/buddy-agent-harness:init`, which creates `.agents/skills` and the bridges |
|
|
35
|
+
| `missing` | no bridge at this path — the harness sees zero project skills | run `/buddy-agent-harness:init` |
|
|
36
|
+
| `degraded` | expected a directory but found a regular file — checkout without core.symlinks | run `/buddy-agent-harness:init --copy --force` |
|
|
37
|
+
| `stale` | symlink does not resolve to .agents/skills | run `/buddy-agent-harness:init --force` |
|
|
38
|
+
| `diverged-bridge` | only the bridge changed since the two last agreed — an agent wrote through the copy | replace .agents/skills with <path> to keep the newer edit, then run `/buddy-agent-harness:init --force` |
|
|
39
|
+
| `diverged-canonical` | only .agents/skills changed since the two last agreed — the copy is stale | run `/buddy-agent-harness:init --copy --force` |
|
|
40
|
+
| `diverged-both` | both sides changed since they last agreed — rebuilding would discard one of them | run `git diff --no-index .agents/skills <path>` and reconcile by hand |
|
|
41
|
+
| `diverged-unknown` | contents differ and no commit where they agreed was found — which side moved is unknown | run `git diff --no-index .agents/skills <path>` and reconcile by hand |
|
|
42
|
+
| `unpinned-copy` | tracked copy without the skip-worktree bit — the tree is dirty with content that must not be committed | run `git ls-files -z <path> \| xargs -0 git update-index --skip-worktree` |
|
|
43
|
+
|
|
44
|
+
Substitute the reported bridge path for `<path>`.
|
|
45
|
+
|
|
46
|
+
## The Windows case
|
|
47
|
+
|
|
48
|
+
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. `/buddy-agent-harness:init --copy --force` rebuilds the bridges as real directories on that machine.
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
## Rules
|
|
53
|
+
|
|
54
|
+
- 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.
|
|
55
|
+
- Edit skills at `.agents/skills/<name>/SKILL.md`. Editing through a bridge is only safe when that bridge is a symlink.
|
|
56
|
+
- Do not add bridges to `.gitignore`. An untracked bridge swallows a real edit silently.
|
package/skills/init/README.md
CHANGED
|
@@ -10,11 +10,7 @@ It detects agent configuration you already have — instruction files, skills, c
|
|
|
10
10
|
|
|
11
11
|
## Why the projection step is small
|
|
12
12
|
|
|
13
|
-
Codex, Cursor, GitHub Copilot CLI, and Devin Desktop read `.agents/skills/` natively, so they need nothing. Only Claude Code and Gemini CLI need a link
|
|
14
|
-
|
|
15
|
-
```sh
|
|
16
|
-
npx -y buddy-agent-harness init
|
|
17
|
-
```
|
|
13
|
+
Codex, Cursor, GitHub Copilot CLI, and Devin Desktop read `.agents/skills/` natively, so they need nothing. Only Claude Code and Gemini CLI need a link, which the skill creates by running the `init` command in Phase 4.
|
|
18
14
|
|
|
19
15
|
Claude Code also reads `CLAUDE.md` rather than `AGENTS.md`, so the skill sets up a `CLAUDE.md` that imports `@AGENTS.md`.
|
|
20
16
|
|
package/skills/init/SKILL.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: init
|
|
3
|
-
description: Use this skill when initializing, adopting, or migrating a repository's agent configuration to the open AGENTS.md and Agent Skills standards, so one canonical source works across Claude Code, Codex, Cursor, Copilot CLI, and other
|
|
3
|
+
description: Use this skill when initializing, adopting, or migrating a repository's agent configuration to the open AGENTS.md and Agent Skills standards, so one canonical source works across Claude Code, Codex, Cursor, Copilot CLI, and other agent harnesses.
|
|
4
|
+
argument-hint: '[--root <dir>] [--harness <names>] [--copy] [--force]'
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# Harness Init
|
|
@@ -22,6 +23,17 @@ Most of the field already reads `.agents/skills/` natively: Codex, Cursor, GitHu
|
|
|
22
23
|
|
|
23
24
|
`init` writes skills projections only. Every instruction bridge above is manual work in Phase 4.
|
|
24
25
|
|
|
26
|
+
## Arguments
|
|
27
|
+
|
|
28
|
+
An invocation may carry the command's own flags, most often when `doctor` hands back a repair: `/buddy-agent-harness:init --copy --force`.
|
|
29
|
+
|
|
30
|
+
Read them from the invocation itself rather than from a placeholder. Claude Code appends what the caller typed as `ARGUMENTS: <value>`, and Codex substitutes nothing at all, so on every harness the flags arrive as text you can read. Writing `$ARGUMENTS` into this body would resolve on Claude Code and stay literal everywhere else.
|
|
31
|
+
|
|
32
|
+
- `--root`, `--harness`, `--copy`, `--force`, and `--format` pass through to the command in Phase 4.
|
|
33
|
+
- Prose carries the same weight: "links are unavailable here" means `--copy`, and "replace the bridge" means `--force`.
|
|
34
|
+
- An argument never skips a phase. `--force` still needs the Phase 3 approval, and the survey still runs first.
|
|
35
|
+
- Say what you did not recognize and carry on. Never guess at a flag.
|
|
36
|
+
|
|
25
37
|
Work in five phases. Do not skip Phase 3.
|
|
26
38
|
|
|
27
39
|
## 1. Survey
|
|
@@ -50,10 +50,17 @@ Enforcing `name` == directory name is also worth doing even though it is only a
|
|
|
50
50
|
| `disable-model-invocation` | Claude Code, Cursor |
|
|
51
51
|
| `paths`, legacy `globs` | Cursor only |
|
|
52
52
|
| `model` | Copilot CLI only |
|
|
53
|
+
| `argument-hint`, `arguments` | Claude Code only |
|
|
53
54
|
| hook configuration | Claude Code, Cline, Kiro CLI |
|
|
54
55
|
|
|
55
56
|
Codex additionally reads an `agents/openai.yaml` **sidecar file** beside `SKILL.md` — not frontmatter. See `harnesses/codex.md`; never add one unprompted.
|
|
56
57
|
|
|
58
|
+
## Arguments are the one field group that fails loudly
|
|
59
|
+
|
|
60
|
+
Most harness-specific fields are dropped in silence. `argument-hint` and `arguments` are the exception: they are Claude Code extensions, and the spec's own distribution paths — claude.ai uploads, the Skills API, and `package_skill.py` — reject any field outside `name`, `description`, `license`, `compatibility`, `metadata`, and `allowed-tools` with a hard error rather than ignoring it.
|
|
61
|
+
|
|
62
|
+
So a skill that takes arguments and also ships through claude.ai cannot use the fields at all. A skill distributed only through a plugin can. Either way the behavior belongs in the body, because no harness but Claude Code substitutes anything: Claude Code appends what the caller typed as `ARGUMENTS: <value>` when the body has no `$ARGUMENTS`, Codex has no argument mechanism for skills, and the spec defines none. A body that says how to read the invocation works on all of them; a `$ARGUMENTS` placeholder resolves on one and stays literal on the rest.
|
|
63
|
+
|
|
57
64
|
## What follows from that
|
|
58
65
|
|
|
59
66
|
Claude Code has the largest field surface, so a skill authored for it and shared everywhere carries fields the other harnesses parse and discard. That is a small, unavoidable context cost — accept it rather than maintaining divergent copies.
|
|
@@ -36,6 +36,7 @@ So a nested `AGENTS.md` needs its own `CLAUDE.md` stub to be visible at all, and
|
|
|
36
36
|
Claude Code recognizes the most fields (`context: fork`, `agent:`, `disable-model-invocation`, `once`, `${CLAUDE_SKILL_DIR}`). Other harnesses drop them silently.
|
|
37
37
|
|
|
38
38
|
- Restate anything load-bearing in the Markdown body.
|
|
39
|
+
- `argument-hint` and `arguments` are Claude Code's argument fields, and the only ones that fail loudly elsewhere: claude.ai uploads and the Skills API reject them outright. Claude Code appends what the caller typed as `ARGUMENTS: <value>` when the body has no `$ARGUMENTS`, so a skill reads its arguments without either field. See `frontmatter.md`.
|
|
39
40
|
- **Enforce `name` equal to the directory name.** Claude Code treats `name` as a display label only and resolves the command from the directory name; matching them removes the discrepancy.
|
|
40
41
|
|
|
41
42
|
## Leave alone — canonical-only
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
- Detection directory is `.codex`. Its presence enables Codex in the config record and changes nothing on disk.
|
|
7
7
|
- Apply `frontmatter.md` as normal. Codex requires `name` and `description`, and rejects unquoted colons like every other harness.
|
|
8
8
|
|
|
9
|
+
## Arguments
|
|
10
|
+
|
|
11
|
+
Codex skills have none. No placeholder syntax is documented for `SKILL.md`, and the sidecar below has no argument field. The `$1`–`$9` and `$ARGUMENTS` placeholders belong to custom prompts (`~/.codex/prompts/`), which OpenAI has deprecated in favor of skills.
|
|
12
|
+
|
|
13
|
+
A skill that must take arguments states in its body how to read them from the invocation. That is the one mechanism every harness shares.
|
|
14
|
+
|
|
9
15
|
## Optional sidecar
|
|
10
16
|
|
|
11
17
|
Codex alone reads `agents/openai.yaml` inside a skill directory (display name, icons, brand color, `allow_implicit_invocation`, MCP dependencies).
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cli } from 'clibuilder'
|
|
2
|
+
import { doctorCommand } from './diagnose-bridges/doctor.command.ts'
|
|
2
3
|
import { initCommand } from './initialize-harnesses/init.command.ts'
|
|
3
4
|
|
|
4
5
|
export async function main(): Promise<void> {
|
|
@@ -8,7 +9,7 @@ export async function main(): Promise<void> {
|
|
|
8
9
|
description: 'Initialize agent harness skill compatibility in consumer repositories.',
|
|
9
10
|
})
|
|
10
11
|
try {
|
|
11
|
-
await app.command(initCommand).parse(process.argv)
|
|
12
|
+
await app.command(initCommand).command(doctorCommand).parse(process.argv)
|
|
12
13
|
} catch (error) {
|
|
13
14
|
process.stdout.write(`error: ${error instanceof Error ? error.message : 'Invalid command.'}\n`)
|
|
14
15
|
process.exitCode = 2
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { sep } from 'node:path'
|
|
2
|
+
import { encode } from '@toon-format/toon'
|
|
3
|
+
|
|
4
|
+
export type OutputFormat = 'json' | 'toon' | 'text'
|
|
5
|
+
|
|
6
|
+
const formats: readonly OutputFormat[] = ['toon', 'json', 'text']
|
|
7
|
+
|
|
8
|
+
/** Rejects anything but the supported formats so an unknown value never falls back silently. */
|
|
9
|
+
export function parseFormat(value: string | undefined): OutputFormat {
|
|
10
|
+
if (!formats.includes(value as OutputFormat)) throw new Error('--format must be toon, json, or text.')
|
|
11
|
+
return value as OutputFormat
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The single stdout boundary: internal logic stays on plain objects, encoding happens here. */
|
|
15
|
+
export function writeResult(value: object, format: OutputFormat): void {
|
|
16
|
+
process.stdout.write(`${encodeResult(value, format)}\n`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function encodeResult(value: object, format: OutputFormat): string {
|
|
20
|
+
if (format === 'json') return JSON.stringify(value)
|
|
21
|
+
return format === 'text' ? renderText(value) : encode(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cell(value: unknown): string {
|
|
29
|
+
return value === undefined ? '' : String(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A header row plus one row per record, every column padded to its widest cell. */
|
|
33
|
+
function table(rows: Record<string, unknown>[]): string[] {
|
|
34
|
+
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))]
|
|
35
|
+
const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)))
|
|
36
|
+
const line = (values: string[]) =>
|
|
37
|
+
` ${values.map((value, index) => value.padEnd(widths[index] as number)).join(' ')}`.trimEnd()
|
|
38
|
+
return [line(columns), ...rows.map((row) => line(columns.map((column) => cell(row[column]))))]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The same result rendered for a person rather than for an agent: scalars as `key: value`, records
|
|
43
|
+
* as an aligned table, and everything else as a bulleted list. TOON stays the default because it is
|
|
44
|
+
* what an agent parses; this is for reading over someone's shoulder.
|
|
45
|
+
*/
|
|
46
|
+
export function renderText(value: object): string {
|
|
47
|
+
const blocks = Object.entries(value).map(([key, item]) => {
|
|
48
|
+
if (!Array.isArray(item)) return [`${key}: ${isRecord(item) ? JSON.stringify(item) : String(item)}`]
|
|
49
|
+
if (!item.length) return [`${key}: (none)`]
|
|
50
|
+
const body = item.every(isRecord) ? table(item) : item.map((entry) => ` - ${String(entry)}`)
|
|
51
|
+
return [`${key}:`, ...body]
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// A blank line wherever a multi-line block meets its neighbour, so a following scalar does not
|
|
55
|
+
// read as another row of the table above it.
|
|
56
|
+
return blocks
|
|
57
|
+
.flatMap((block, index) => {
|
|
58
|
+
const previous = blocks[index - 1]
|
|
59
|
+
return previous && (previous.length > 1 || block.length > 1) ? ['', ...block] : block
|
|
60
|
+
})
|
|
61
|
+
.join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** AXI §10: the executable's absolute path, with the user's home directory collapsed to `~`. */
|
|
65
|
+
export function binPath(home: string, executable: string | undefined): string {
|
|
66
|
+
if (!executable) return 'buddy-agent-harness'
|
|
67
|
+
return home && executable.startsWith(home + sep) ? `~${executable.slice(home.length)}` : executable
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|