buddy-agent-harness 0.10.0 → 0.12.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/bin/buddy-agent-harness.mjs +4 -3
- package/dist/cli.mjs +480 -546
- package/dist/index.d.mts +205 -298
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +682 -621
- package/dist/index.mjs.map +1 -1
- package/dist/skill-scripts/doctor.mjs +45 -42
- package/dist/skill-scripts/init.mjs +45 -42
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/{doctor → doctor-buddy-agent-harness}/README.md +1 -1
- package/skills/{doctor → doctor-buddy-agent-harness}/SKILL.md +9 -7
- package/skills/{doctor → doctor-buddy-agent-harness}/references/bridges.md +7 -7
- package/skills/{doctor → doctor-buddy-agent-harness}/references/harnesses/claude-code.md +3 -2
- package/skills/{doctor → doctor-buddy-agent-harness}/references/harnesses/codex.md +1 -1
- package/skills/{doctor → doctor-buddy-agent-harness}/references/harnesses/copilot-cli.md +1 -1
- package/skills/{doctor → doctor-buddy-agent-harness}/references/harnesses/cursor.md +1 -1
- package/skills/{doctor → doctor-buddy-agent-harness}/references/harnesses/devin-desktop.md +1 -1
- package/skills/{doctor → doctor-buddy-agent-harness}/references/harnesses/gemini-cli.md +1 -1
- package/skills/doctor-buddy-agent-harness/references/instructions.md +22 -0
- package/skills/{doctor → doctor-buddy-agent-harness}/references/nonstandard.md +4 -4
- package/skills/doctor-buddy-agent-harness/scripts/doctor.mjs +303 -0
- package/skills/enhance/SKILL.md +12 -3
- package/skills/enhance/references/delegation.history.md +11 -1
- package/skills/enhance/references/delegation.md +23 -2
- package/skills/{init → init-buddy-agent-harness}/README.md +1 -1
- package/skills/{init → init-buddy-agent-harness}/SKILL.md +22 -14
- package/skills/{init → init-buddy-agent-harness}/agents/openai.yaml +1 -1
- package/skills/{init → init-buddy-agent-harness}/references/agents-md.md +38 -19
- package/skills/{init → init-buddy-agent-harness}/references/detection.md +2 -2
- package/skills/init-buddy-agent-harness/references/harnesses/claude-code.md +64 -0
- package/skills/init-buddy-agent-harness/scripts/doctor.mjs +303 -0
- package/skills/init-buddy-agent-harness/scripts/init.mjs +303 -0
- package/skills/repair/SKILL.md +2 -2
- package/skills/repair/references/classes.md +2 -2
- package/skills/repair/scripts/doctor.mjs +45 -42
- package/src/cli.ts +15 -24
- package/src/command-output/command-output.ts +21 -12
- package/src/dep-plugins/dep-plugins.command.ts +10 -26
- package/src/dep-plugins/dep-plugins.ts +13 -46
- package/src/dep-plugins/generate.ts +6 -34
- package/src/dep-plugins/harness-plugins.ts +16 -78
- package/src/dep-plugins/reconcile.ts +10 -27
- package/src/dep-plugins/resolve.ts +7 -46
- package/src/diagnose-bridges/agents-files.ts +2 -6
- package/src/diagnose-bridges/diagnose-bridges.ts +9 -13
- package/src/diagnose-bridges/diagnose-instructions.ts +73 -57
- package/src/diagnose-bridges/directory-files.ts +1 -4
- package/src/diagnose-bridges/doctor-guidance.ts +97 -148
- package/src/diagnose-bridges/doctor.command.ts +45 -21
- package/src/diagnose-bridges/git-bridge-state.ts +25 -35
- package/src/diagnose-bridges/json-with-comments.ts +2 -7
- package/src/diagnose-bridges/locator.ts +5 -14
- package/src/diagnose-configuration/diagnose-configuration.ts +17 -27
- package/src/diagnose-mcp/diagnose-mcp.ts +8 -34
- package/src/diagnose-mcp/mcp-baseline.ts +9 -33
- package/src/diagnose-mcp/mcp-model.ts +2 -29
- package/src/diagnose-mcp/mcp-secrets.ts +18 -45
- package/src/diagnose-mcp/mcp-sources.ts +13 -32
- package/src/diagnose-nonstandard/diagnose-nonstandard.ts +11 -22
- package/src/governance-overrides/governance-overrides.ts +148 -0
- package/src/governance-overrides/governance.command.ts +147 -0
- package/src/harness-registry/harness-registry.ts +24 -54
- package/src/harness-registry/instruction-bridge.ts +10 -29
- package/src/harness-registry/mcp-config.ts +4 -28
- package/src/harness-registry/nonstandard-artifact.ts +6 -18
- package/src/index.ts +18 -0
- package/src/initialize-harnesses/init.command.ts +6 -5
- package/src/initialize-harnesses/initialize-harnesses.ts +12 -1
- package/src/is-record/is-record.ts +0 -9
- package/src/mcp-inventory/mcp-inventory.ts +200 -40
- package/src/skill-projection/skill-projection.ts +16 -28
- package/src/skill-scripts/doctor.ts +4 -6
- package/src/skill-scripts/init.ts +4 -5
- package/src/skill-scripts/launchers.ts +5 -10
- package/skills/doctor/references/instructions.md +0 -14
- package/skills/doctor/scripts/doctor.mjs +0 -300
- package/skills/init/references/harnesses/claude-code.md +0 -54
- package/skills/init/scripts/doctor.mjs +0 -300
- package/skills/init/scripts/init.mjs +0 -300
- /package/skills/{doctor → doctor-buddy-agent-harness}/references/configuration.md +0 -0
- /package/skills/{doctor → doctor-buddy-agent-harness}/references/harnesses/windsurf.md +0 -0
- /package/skills/{doctor → doctor-buddy-agent-harness}/references/mcp.md +0 -0
- /package/skills/{init → init-buddy-agent-harness}/references/frontmatter.md +0 -0
- /package/skills/{init → init-buddy-agent-harness}/references/harnesses/codex.md +0 -0
- /package/skills/{init → init-buddy-agent-harness}/references/harnesses/copilot-cli.md +0 -0
- /package/skills/{init → init-buddy-agent-harness}/references/harnesses/cursor.md +0 -0
- /package/skills/{init → init-buddy-agent-harness}/references/harnesses/devin-desktop.md +0 -0
- /package/skills/{init → init-buddy-agent-harness}/references/harnesses/gemini-cli.md +0 -0
- /package/skills/{init → init-buddy-agent-harness}/references/standard.md +0 -0
package/dist/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { a as __toESM, i as __require, n as unpartial, r as __commonJSMin, t as
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, extname, join, parse, posix, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { homedir, platform } from "node:os";
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
//#region ../../node_modules/.pnpm/clibuilder@10.1.0/node_modules/clibuilder/esm/argv.js
|
|
@@ -15651,15 +15651,6 @@ function resolveOptions(options) {
|
|
|
15651
15651
|
}
|
|
15652
15652
|
//#endregion
|
|
15653
15653
|
//#region src/is-record/is-record.ts
|
|
15654
|
-
/**
|
|
15655
|
-
* Whether a value is a record: an object with named keys, and not an array.
|
|
15656
|
-
*
|
|
15657
|
-
* Everything this package reads is a file someone else wrote — a golden MCP set, a harness config,
|
|
15658
|
-
* a projection record — so every parse lands on `unknown` and every reader narrows it here before
|
|
15659
|
-
* asking for a key. Four modules had their own copy of the same three clauses; a predicate that
|
|
15660
|
-
* decides what counts as a parsed object is one fact, and a copy of it that drifts changes what a
|
|
15661
|
-
* file is read to say.
|
|
15662
|
-
*/
|
|
15663
15654
|
function isRecord(value) {
|
|
15664
15655
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15665
15656
|
}
|
|
@@ -15679,6 +15670,13 @@ function parseFormat(value) {
|
|
|
15679
15670
|
function writeResult(value, format) {
|
|
15680
15671
|
process.stdout.write(`${encodeResult(value, format)}\n`);
|
|
15681
15672
|
}
|
|
15673
|
+
/**
|
|
15674
|
+
* Writes a document exactly as read, bypassing the encoders — a Markdown body run through TOON or
|
|
15675
|
+
* the text renderer comes back as one escaped line. A command writes one or the other, never both.
|
|
15676
|
+
*/
|
|
15677
|
+
function writeDocument(content) {
|
|
15678
|
+
process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
|
|
15679
|
+
}
|
|
15682
15680
|
function encodeResult(value, format) {
|
|
15683
15681
|
if (format === "json") return JSON.stringify(value);
|
|
15684
15682
|
return format === "text" ? renderText(value) : encode(value);
|
|
@@ -15686,18 +15684,13 @@ function encodeResult(value, format) {
|
|
|
15686
15684
|
function cell(value) {
|
|
15687
15685
|
return value === void 0 ? "" : String(value);
|
|
15688
15686
|
}
|
|
15689
|
-
/** A header row plus one row per record, every column padded to its widest cell. */
|
|
15690
15687
|
function table(rows) {
|
|
15691
15688
|
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
15692
15689
|
const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)));
|
|
15693
15690
|
const line = (values) => ` ${values.map((value, index) => value.padEnd(widths[index])).join(" ")}`.trimEnd();
|
|
15694
15691
|
return [line(columns), ...rows.map((row) => line(columns.map((column) => cell(row[column]))))];
|
|
15695
15692
|
}
|
|
15696
|
-
/**
|
|
15697
|
-
* The same result rendered for a person rather than for an agent: scalars as `key: value`, records
|
|
15698
|
-
* as an aligned table, and everything else as a bulleted list. TOON stays the default because it is
|
|
15699
|
-
* what an agent parses; this is for reading over someone's shoulder.
|
|
15700
|
-
*/
|
|
15693
|
+
/** Renders for a person, not an agent — TOON stays the default since that's what an agent parses. */
|
|
15701
15694
|
function renderText(value) {
|
|
15702
15695
|
const blocks = Object.entries(value).map(([key, item]) => {
|
|
15703
15696
|
if (!Array.isArray(item)) return [`${key}: ${isRecord(item) ? JSON.stringify(item) : String(item)}`];
|
|
@@ -15710,19 +15703,19 @@ function renderText(value) {
|
|
|
15710
15703
|
return previous && (previous.length > 1 || block.length > 1) ? ["", ...block] : block;
|
|
15711
15704
|
}).join("\n");
|
|
15712
15705
|
}
|
|
15713
|
-
/**
|
|
15714
|
-
|
|
15715
|
-
|
|
15716
|
-
|
|
15706
|
+
/**
|
|
15707
|
+
* AXI §10: collapses the home directory to `~` so a path in a report is portable to another
|
|
15708
|
+
* machine.
|
|
15709
|
+
*/
|
|
15710
|
+
function collapseHome(home, path) {
|
|
15711
|
+
return home && path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path;
|
|
15712
|
+
}
|
|
15713
|
+
function displayBinPath(home, executable) {
|
|
15714
|
+
return executable ? collapseHome(home, executable) : "buddy-agent-harness";
|
|
15717
15715
|
}
|
|
15718
15716
|
//#endregion
|
|
15719
15717
|
//#region src/dep-plugins/dep-plugins.ts
|
|
15720
|
-
/**
|
|
15721
|
-
* Marketplace names generic enough that two repositories are likely to pick the same one. The
|
|
15722
|
-
* collision is silent and costly: registering a second marketplace under a name already in use
|
|
15723
|
-
* replaces the first, and the install that follows reports "already installed" and does nothing —
|
|
15724
|
-
* so the repository silently runs the other one's plugin versions.
|
|
15725
|
-
*/
|
|
15718
|
+
/** Generic marketplace names: a second registration under one of these replaces the first, silently. */
|
|
15726
15719
|
const GENERIC_NAMES = /* @__PURE__ */ new Set([
|
|
15727
15720
|
"deps",
|
|
15728
15721
|
"dependencies",
|
|
@@ -15736,19 +15729,15 @@ function isGenericName(name) {
|
|
|
15736
15729
|
return GENERIC_NAMES.has(name.toLowerCase());
|
|
15737
15730
|
}
|
|
15738
15731
|
/**
|
|
15739
|
-
*
|
|
15740
|
-
*
|
|
15741
|
-
* because the unscoped half is the part most likely to repeat across organizations.
|
|
15732
|
+
* Folds a scope into the name (`@acme/web` → `acme-web`) rather than dropping it — the unscoped
|
|
15733
|
+
* part collides more often.
|
|
15742
15734
|
*/
|
|
15743
15735
|
function marketplaceName(packageName) {
|
|
15744
15736
|
return packageName.replace(/^@/, "").replace(/\//g, "-").replace(/[^a-zA-Z0-9-_.]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "agent-plugins";
|
|
15745
15737
|
}
|
|
15746
15738
|
/**
|
|
15747
|
-
*
|
|
15748
|
-
*
|
|
15749
|
-
* Development dependencies are included on purpose: a plugin that supplies a project's testing or
|
|
15750
|
-
* release workflow is a development dependency, and excluding them would drop most of the cases this
|
|
15751
|
-
* exists for. Transitive dependencies are never included — see `resolve.ts`.
|
|
15739
|
+
* Includes `devDependencies` — a plugin can supply a project's testing or release workflow.
|
|
15740
|
+
* Transitive dependencies are excluded; see `resolve.ts`.
|
|
15752
15741
|
*/
|
|
15753
15742
|
function declaredDependencies(manifest) {
|
|
15754
15743
|
if (!manifest || typeof manifest !== "object") return [];
|
|
@@ -15762,12 +15751,8 @@ function declaredDependencies(manifest) {
|
|
|
15762
15751
|
return [...names].sort();
|
|
15763
15752
|
}
|
|
15764
15753
|
/**
|
|
15765
|
-
*
|
|
15766
|
-
*
|
|
15767
|
-
* moved — fall out of regeneration instead of needing reconciliation logic of their own.
|
|
15768
|
-
*
|
|
15769
|
-
* Entries are sorted by name so that a regeneration whose inputs did not change produces a
|
|
15770
|
-
* byte-identical file, which is what lets a check mode compare rather than interpret.
|
|
15754
|
+
* Derived in full every run, never merged with what's on disk. Entries are sorted so unchanged
|
|
15755
|
+
* inputs serialize identically, which `--check` relies on.
|
|
15771
15756
|
*/
|
|
15772
15757
|
function buildCatalog({ name, owner, plugins }) {
|
|
15773
15758
|
const entries = [...plugins].sort((a, b) => a.plugin.localeCompare(b.plugin)).map(({ plugin, package: pkg, version, description }) => {
|
|
@@ -15792,22 +15777,12 @@ function buildCatalog({ name, owner, plugins }) {
|
|
|
15792
15777
|
plugins: entries
|
|
15793
15778
|
};
|
|
15794
15779
|
}
|
|
15795
|
-
/**
|
|
15780
|
+
/** Includes the trailing newline. */
|
|
15796
15781
|
function serializeCatalog(catalog) {
|
|
15797
15782
|
return `${JSON.stringify(catalog, null, " ")}\n`;
|
|
15798
15783
|
}
|
|
15799
15784
|
//#endregion
|
|
15800
15785
|
//#region src/dep-plugins/resolve.ts
|
|
15801
|
-
/**
|
|
15802
|
-
* Locating a declared dependency on disk, and reading the plugin manifest it may ship.
|
|
15803
|
-
*
|
|
15804
|
-
* Resolution rather than scanning. Walking `node_modules` finds transitive dependencies nobody
|
|
15805
|
-
* vetted, and a skill or plugin is instruction text that steers an agent — an unvetted transitive
|
|
15806
|
-
* dependency is a supply-chain surface, not a convenience. Asking the module resolver where a
|
|
15807
|
-
* *declared* dependency lives answers the question that was actually asked, and it is correct under
|
|
15808
|
-
* npm, pnpm, yarn with the node-modules linker, and bun alike, because all four put a real directory
|
|
15809
|
-
* on disk. Only Yarn PnP does not, and it is reported rather than guessed at.
|
|
15810
|
-
*/
|
|
15811
15786
|
function readJson$1(path) {
|
|
15812
15787
|
try {
|
|
15813
15788
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -15815,10 +15790,6 @@ function readJson$1(path) {
|
|
|
15815
15790
|
return;
|
|
15816
15791
|
}
|
|
15817
15792
|
}
|
|
15818
|
-
/**
|
|
15819
|
-
* Every ancestor `node_modules/<pkg>` from `startDir` upward. The fallback path only — see
|
|
15820
|
-
* {@link packageDir}.
|
|
15821
|
-
*/
|
|
15822
15793
|
function ancestorCandidates(startDir, pkg) {
|
|
15823
15794
|
const segments = pkg.split("/");
|
|
15824
15795
|
const candidates = [];
|
|
@@ -15832,17 +15803,8 @@ function ancestorCandidates(startDir, pkg) {
|
|
|
15832
15803
|
return candidates;
|
|
15833
15804
|
}
|
|
15834
15805
|
/**
|
|
15835
|
-
*
|
|
15836
|
-
*
|
|
15837
|
-
* Two mechanisms, in order, because neither alone is sufficient:
|
|
15838
|
-
*
|
|
15839
|
-
* 1. **`require.resolve('<pkg>/package.json')`, anchored at the declaring manifest.** The anchor
|
|
15840
|
-
* matters: under pnpm's isolated store only the package that declares a dependency can resolve it,
|
|
15841
|
-
* so resolving from a workspace root would miss what a workspace member declared.
|
|
15842
|
-
* 2. **A walk up `node_modules`.** Roughly one package in seven declares an `exports` map without a
|
|
15843
|
-
* `./package.json` entry, and those refuse the first mechanism with `ERR_PACKAGE_PATH_NOT_EXPORTED`
|
|
15844
|
-
* however plainly they are installed. Resolving a deeper file instead is not an escape — the same
|
|
15845
|
-
* map blocks that too.
|
|
15806
|
+
* Anchored at the declaring manifest (required for pnpm's isolation), then falls back to a
|
|
15807
|
+
* `node_modules` walk — an `exports` map without a `./package.json` entry blocks the first.
|
|
15846
15808
|
*/
|
|
15847
15809
|
function packageDir(pkg, fromManifest) {
|
|
15848
15810
|
try {
|
|
@@ -15851,16 +15813,7 @@ function packageDir(pkg, fromManifest) {
|
|
|
15851
15813
|
} catch {}
|
|
15852
15814
|
for (const candidate of ancestorCandidates(dirname(fromManifest), pkg)) if (existsSync(join(candidate, "package.json"))) return candidate;
|
|
15853
15815
|
}
|
|
15854
|
-
/**
|
|
15855
|
-
* Reads the plugin a dependency ships, if it ships one.
|
|
15856
|
-
*
|
|
15857
|
-
* The version comes from the dependency's own `package.json` rather than from a lockfile. It is the
|
|
15858
|
-
* same number — the version actually installed — and reading it needs no knowledge of which of the
|
|
15859
|
-
* four lockfile formats this repository uses.
|
|
15860
|
-
*
|
|
15861
|
-
* A manifest at the package root is what the Agent Plugins Specification pins, and it is the form a
|
|
15862
|
-
* harness accepts as a marketplace source without any vendor-specific manifest alongside it.
|
|
15863
|
-
*/
|
|
15816
|
+
/** A manifest lives at the package root — the Agent Plugins Specification's pinned location. */
|
|
15864
15817
|
function readDepPlugin(pkg, fromManifest) {
|
|
15865
15818
|
const dir = packageDir(pkg, fromManifest);
|
|
15866
15819
|
if (!dir) return {
|
|
@@ -15904,10 +15857,8 @@ function readDepPlugin(pkg, fromManifest) {
|
|
|
15904
15857
|
};
|
|
15905
15858
|
}
|
|
15906
15859
|
/**
|
|
15907
|
-
* True
|
|
15908
|
-
*
|
|
15909
|
-
* source must be installable, so a PnP project needs `nodeLinker: node-modules` or the affected
|
|
15910
|
-
* dependencies unplugged. Detected rather than worked around, so the report can say so.
|
|
15860
|
+
* True under Yarn PnP, where packages live in zip archives rather than directories — nothing here
|
|
15861
|
+
* has an installable directory.
|
|
15911
15862
|
*/
|
|
15912
15863
|
function isPnp() {
|
|
15913
15864
|
return Boolean(process.versions["pnp"]);
|
|
@@ -15915,35 +15866,14 @@ function isPnp() {
|
|
|
15915
15866
|
//#endregion
|
|
15916
15867
|
//#region src/dep-plugins/generate.ts
|
|
15917
15868
|
/**
|
|
15918
|
-
*
|
|
15919
|
-
|
|
15920
|
-
/**
|
|
15921
|
-
* The catalog's home, relative to the repository root.
|
|
15922
|
-
*
|
|
15923
|
-
* Under this tool's own directory, beside the other files it owns there — the same place as
|
|
15924
|
-
* `mcp.toml` and `mcp.projected.json`, and the same shape as `.agents/repobuddy/` and
|
|
15925
|
-
* `.agents/cyberlegion/`. A directory named for what it holds rather than for who writes it would be
|
|
15926
|
-
* a second convention in a tree that already has one.
|
|
15927
|
-
*
|
|
15928
|
-
* Not the repository root's `.claude-plugin/`, which is where a repository publishes its *own*
|
|
15929
|
-
* plugins for its *own* consumers. Merging the two would leak every dependency-provided plugin into
|
|
15930
|
-
* what a consumer sees on adding the published marketplace, and no harness has a notion of a private
|
|
15931
|
-
* catalog entry to prevent that. Being a separate directory also makes this one a marketplace root
|
|
15932
|
-
* in its own right, which is what lets its entries be resolved without reaching outside it.
|
|
15869
|
+
* Not the repository root's `.claude-plugin/` — that's the repo's own published catalog; merging
|
|
15870
|
+
* would leak dependency plugins into it.
|
|
15933
15871
|
*/
|
|
15934
15872
|
const CATALOG_DIR = join(".agents", "buddy-agent-harness");
|
|
15935
15873
|
const CATALOG_PATH = join(CATALOG_DIR, ".claude-plugin", "marketplace.json");
|
|
15936
15874
|
function readManifest(path) {
|
|
15937
15875
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
15938
15876
|
}
|
|
15939
|
-
/**
|
|
15940
|
-
* Derives the catalog from the repository's declared dependencies.
|
|
15941
|
-
*
|
|
15942
|
-
* Derived in full every time. A plugin newly added to `package.json` appears, one removed disappears,
|
|
15943
|
-
* and a version that moved follows — all three without a line of reconciliation logic, because there
|
|
15944
|
-
* is nothing to reconcile against. That is the same rule the vendor manifests already follow: the
|
|
15945
|
-
* artifact is derived, never authored.
|
|
15946
|
-
*/
|
|
15947
15877
|
function deriveCatalog(root) {
|
|
15948
15878
|
const manifestPath = join(root, "package.json");
|
|
15949
15879
|
const manifest = readManifest(manifestPath);
|
|
@@ -15972,11 +15902,8 @@ function deriveCatalog(root) {
|
|
|
15972
15902
|
};
|
|
15973
15903
|
}
|
|
15974
15904
|
/**
|
|
15975
|
-
*
|
|
15976
|
-
*
|
|
15977
|
-
* Comparing the serialized form rather than the parsed one is deliberate: a byte comparison is what
|
|
15978
|
-
* lets a check mode be a comparison instead of an interpretation, and `buildCatalog` sorts its
|
|
15979
|
-
* entries so that unchanged inputs serialize identically.
|
|
15905
|
+
* Compares serialized bytes, not parsed values — that's what makes `--check` a comparison rather
|
|
15906
|
+
* than an interpretation.
|
|
15980
15907
|
*/
|
|
15981
15908
|
function catalogStatus(root, catalog) {
|
|
15982
15909
|
let current;
|
|
@@ -15988,7 +15915,7 @@ function catalogStatus(root, catalog) {
|
|
|
15988
15915
|
if (current === serializeCatalog(catalog)) return "unchanged";
|
|
15989
15916
|
return current === void 0 ? "created" : "updated";
|
|
15990
15917
|
}
|
|
15991
|
-
/**
|
|
15918
|
+
/** A write that would change nothing writes nothing. */
|
|
15992
15919
|
function writeCatalog(root, catalog) {
|
|
15993
15920
|
const outcome = catalogStatus(root, catalog);
|
|
15994
15921
|
if (outcome === "unchanged") return outcome;
|
|
@@ -15999,31 +15926,6 @@ function writeCatalog(root, catalog) {
|
|
|
15999
15926
|
}
|
|
16000
15927
|
//#endregion
|
|
16001
15928
|
//#region src/dep-plugins/harness-plugins.ts
|
|
16002
|
-
/**
|
|
16003
|
-
* What each harness does with a marketplace catalog, and how to read what it currently has.
|
|
16004
|
-
*
|
|
16005
|
-
* The runtimes diverge more than they look, and every difference below was established by running
|
|
16006
|
-
* the shipped CLI rather than by reading a document:
|
|
16007
|
-
*
|
|
16008
|
-
* - **Claude Code** refreshes with `plugin update`; re-running `plugin install` is a no-op that
|
|
16009
|
-
* reports "already installed" and leaves the stale copy in place. Every verb defaults to user
|
|
16010
|
-
* scope, so each one has to name `--scope project` explicitly.
|
|
16011
|
-
* - **Codex** has no update verb and no scope. Re-running `plugin add` re-reads the catalog and
|
|
16012
|
-
* replaces the cached copy, so the same command installs and refreshes. It records no version in
|
|
16013
|
-
* its config — the installed version is only in the cache directory's name.
|
|
16014
|
-
* - **Copilot CLI** accepts a path source but **rejects an `npm` source**, so it cannot consume this
|
|
16015
|
-
* catalog at all. See {@link COPILOT_LIMITATION}.
|
|
16016
|
-
* - **Cursor** exposes no plugin subcommand from a terminal.
|
|
16017
|
-
*
|
|
16018
|
-
* A consumer should not have to know any of that, which is why it is here rather than in a document.
|
|
16019
|
-
*/
|
|
16020
|
-
/**
|
|
16021
|
-
* Why Copilot CLI is absent from the table. Recorded rather than dropped, so the reason travels with
|
|
16022
|
-
* the code: `copilot plugin marketplace add` rejects this catalog with
|
|
16023
|
-
* `Invalid marketplace.json: plugins.0.source: Invalid input`, while the same catalog with a
|
|
16024
|
-
* `./path` source is accepted. Supporting it needs a second, path-sourced catalog — and a path source
|
|
16025
|
-
* cannot escape its marketplace root, which is the constraint the `npm` source was chosen to avoid.
|
|
16026
|
-
*/
|
|
16027
15929
|
const COPILOT_LIMITATION = "Copilot CLI rejects an npm marketplace source, so it cannot install from this catalog. It needs a path-sourced catalog, which cannot reach node_modules from a subdirectory.";
|
|
16028
15930
|
function readJson(path) {
|
|
16029
15931
|
try {
|
|
@@ -16039,19 +15941,9 @@ function directories(path) {
|
|
|
16039
15941
|
return [];
|
|
16040
15942
|
}
|
|
16041
15943
|
}
|
|
16042
|
-
/**
|
|
16043
|
-
* `CLAUDE_CONFIG_DIR` wins where set, which is also what keeps a test off a developer's real
|
|
16044
|
-
* configuration.
|
|
16045
|
-
*/
|
|
16046
15944
|
function claudeConfigDir(home, env = process.env) {
|
|
16047
15945
|
return env["CLAUDE_CONFIG_DIR"] || join(home, ".claude");
|
|
16048
15946
|
}
|
|
16049
|
-
/**
|
|
16050
|
-
* Reads what Claude Code has installed.
|
|
16051
|
-
*
|
|
16052
|
-
* The file keys each plugin by `<plugin>@<marketplace>` and holds an *array* per key, because the
|
|
16053
|
-
* same plugin can be installed at more than one scope and, at project scope, once per project.
|
|
16054
|
-
*/
|
|
16055
15947
|
function readClaudeState(configDir) {
|
|
16056
15948
|
const installedFile = readJson(join(configDir, "plugins", "installed_plugins.json"));
|
|
16057
15949
|
const installed = [];
|
|
@@ -16090,16 +15982,10 @@ function readClaudeState(configDir) {
|
|
|
16090
15982
|
const claudeCode = {
|
|
16091
15983
|
name: "claude-code",
|
|
16092
15984
|
scoped: true,
|
|
16093
|
-
|
|
15985
|
+
installedOnMachine: (home, env = process.env) => existsSync(claudeConfigDir(home, env)),
|
|
16094
15986
|
state: (home, env = process.env) => readClaudeState(claudeConfigDir(home, env)),
|
|
16095
15987
|
register: (catalogDir) => `claude plugin marketplace add ./${catalogDir} --scope project`,
|
|
16096
15988
|
install: (id) => `claude plugin install ${id} --scope project`,
|
|
16097
|
-
/**
|
|
16098
|
-
* Not `install`: re-running that on an already-installed plugin changes nothing and says so, which
|
|
16099
|
-
* reads as success. `--scope project` is required rather than cosmetic — every verb defaults to
|
|
16100
|
-
* user scope, so an update omitting it fails with "not installed at scope user" against a plugin
|
|
16101
|
-
* that is plainly installed.
|
|
16102
|
-
*/
|
|
16103
15989
|
update: (id) => `claude plugin update ${id} --scope project`,
|
|
16104
15990
|
uninstall: (id) => `claude plugin uninstall ${id} --scope project`,
|
|
16105
15991
|
refreshMarketplace: (marketplace) => `claude plugin marketplace update ${marketplace}`
|
|
@@ -16107,16 +15993,6 @@ const claudeCode = {
|
|
|
16107
15993
|
function codexHome(home, env = process.env) {
|
|
16108
15994
|
return env["CODEX_HOME"] || join(home, ".codex");
|
|
16109
15995
|
}
|
|
16110
|
-
/**
|
|
16111
|
-
* Reads what Codex has installed.
|
|
16112
|
-
*
|
|
16113
|
-
* The installed version comes from the cache layout, `plugins/cache/<marketplace>/<plugin>/<version>`,
|
|
16114
|
-
* because Codex's `config.toml` records only that a plugin is enabled — never which version.
|
|
16115
|
-
*
|
|
16116
|
-
* Registered marketplaces are read from `config.toml` by scanning for its `[marketplaces.<name>]`
|
|
16117
|
-
* tables. A deliberately narrow read rather than a TOML parser: only two keys are needed, and the
|
|
16118
|
-
* cost of being wrong is a report that suggests re-registering, which is harmless to re-run.
|
|
16119
|
-
*/
|
|
16120
15996
|
function readCodexState(home) {
|
|
16121
15997
|
const cacheRoot = join(home, "plugins", "cache");
|
|
16122
15998
|
const installed = [];
|
|
@@ -16157,30 +16033,20 @@ function readCodexState(home) {
|
|
|
16157
16033
|
}
|
|
16158
16034
|
const runtimes = [claudeCode, {
|
|
16159
16035
|
name: "codex",
|
|
16160
|
-
/**
|
|
16161
|
-
* Codex installs into `CODEX_HOME` with no notion of scope, so one install is shared by every
|
|
16162
|
-
* repository on the machine. Two repositories whose catalogs share a marketplace name cannot be
|
|
16163
|
-
* told apart here, which is what makes the marketplace name derived from the package name matter.
|
|
16164
|
-
*/
|
|
16165
16036
|
scoped: false,
|
|
16166
|
-
|
|
16037
|
+
installedOnMachine: (home, env = process.env) => existsSync(codexHome(home, env)),
|
|
16167
16038
|
state: (home, env = process.env) => readCodexState(codexHome(home, env)),
|
|
16168
16039
|
register: (catalogDir) => `codex plugin marketplace add ./${catalogDir}`,
|
|
16169
16040
|
install: (id) => `codex plugin add ${id}`,
|
|
16170
|
-
/** No update verb: re-running `add` re-reads the catalog and replaces the cached copy. */
|
|
16171
16041
|
update: (id) => `codex plugin add ${id}`,
|
|
16172
16042
|
uninstall: (id) => `codex plugin remove ${id}`,
|
|
16173
|
-
/** `marketplace upgrade` refreshes git snapshots only; `add` already re-reads a local catalog. */
|
|
16174
16043
|
refreshMarketplace: () => ""
|
|
16175
16044
|
}];
|
|
16176
16045
|
//#endregion
|
|
16177
16046
|
//#region src/dep-plugins/reconcile.ts
|
|
16178
16047
|
/**
|
|
16179
|
-
*
|
|
16180
|
-
*
|
|
16181
|
-
* On a scoped runtime a user-scoped install is deliberately excluded rather than counted: it is
|
|
16182
|
-
* shared with every other repository on the machine, so treating it as satisfying this repository's
|
|
16183
|
-
* catalog would let one project's pin silently stand in for another's.
|
|
16048
|
+
* Excludes a user-scoped install on a scoped runtime — it's shared machine-wide, so it can't
|
|
16049
|
+
* satisfy this repository's catalog alone.
|
|
16184
16050
|
*/
|
|
16185
16051
|
function ours(installed, marketplace, root, scoped) {
|
|
16186
16052
|
const suffix = `@${marketplace}`;
|
|
@@ -16230,7 +16096,6 @@ function reconcile({ catalog, catalogDir, root, installed, marketplaces, scoped
|
|
|
16230
16096
|
}
|
|
16231
16097
|
//#endregion
|
|
16232
16098
|
//#region src/dep-plugins/dep-plugins.command.ts
|
|
16233
|
-
/** The command that performs one action, so a reader is not left to look the verb up per runtime. */
|
|
16234
16099
|
function commandFor(runtime, action, marketplace) {
|
|
16235
16100
|
switch (action.kind) {
|
|
16236
16101
|
case "register": return runtime.register(CATALOG_DIR);
|
|
@@ -16243,11 +16108,6 @@ function commandFor(runtime, action, marketplace) {
|
|
|
16243
16108
|
case "uninstall": return runtime.uninstall(action.subject);
|
|
16244
16109
|
}
|
|
16245
16110
|
}
|
|
16246
|
-
/**
|
|
16247
|
-
* Notes name the conditions a caller cannot see from the entry list and would otherwise discover as a
|
|
16248
|
-
* failure later — a dependency that is declared but absent, a marketplace name two repositories are
|
|
16249
|
-
* likely to share, a resolver under which no dependency has a directory at all.
|
|
16250
|
-
*/
|
|
16251
16111
|
function notesFor(derived) {
|
|
16252
16112
|
const notes = [];
|
|
16253
16113
|
if (derived.pnp) notes.push("Yarn Plug’n’Play is active: dependencies live inside zip archives, so no harness can install from them. Set nodeLinker: node-modules, or unplug the plugin dependencies.");
|
|
@@ -16281,7 +16141,7 @@ const depPluginsCommand = command({
|
|
|
16281
16141
|
const outcome = args.check ? catalogStatus(root, derived.catalog) === "unchanged" ? "current" : "would-change" : writeCatalog(root, derived.catalog);
|
|
16282
16142
|
const home = homedir();
|
|
16283
16143
|
const notes = notesFor(derived);
|
|
16284
|
-
const detected = runtimes.filter((candidate) => candidate.
|
|
16144
|
+
const detected = runtimes.filter((candidate) => candidate.installedOnMachine(home));
|
|
16285
16145
|
const harness = [];
|
|
16286
16146
|
const actions = [];
|
|
16287
16147
|
for (const runtime of detected) {
|
|
@@ -16329,10 +16189,7 @@ const depPluginsCommand = command({
|
|
|
16329
16189
|
});
|
|
16330
16190
|
//#endregion
|
|
16331
16191
|
//#region src/diagnose-bridges/directory-files.ts
|
|
16332
|
-
/**
|
|
16333
|
-
* Every file below `directory`, as paths relative to it, POSIX-separated and sorted. A directory
|
|
16334
|
-
* that does not exist reads as empty: `doctor` never creates one to look at it.
|
|
16335
|
-
*/
|
|
16192
|
+
/** A directory that does not exist reads as empty: `doctor` never creates one to look at it. */
|
|
16336
16193
|
function filesUnder(directory) {
|
|
16337
16194
|
try {
|
|
16338
16195
|
return readdirSync(directory, {
|
|
@@ -16345,36 +16202,18 @@ function filesUnder(directory) {
|
|
|
16345
16202
|
}
|
|
16346
16203
|
//#endregion
|
|
16347
16204
|
//#region src/harness-registry/harness-registry.ts
|
|
16348
|
-
/**
|
|
16349
|
-
* The two scopes disagree in general, and a single answer per harness could only record one of them:
|
|
16350
|
-
* Copilot CLI is `.github/skills` in a repository and `~/.copilot` for the user. Claude Code is the
|
|
16351
|
-
* only harness that needs a skills projection, and it needs one at both scopes. Codex, Cursor,
|
|
16352
|
-
* Copilot CLI, Gemini CLI, and Devin Desktop read `.agents/skills` themselves and are never
|
|
16353
|
-
* projected into.
|
|
16354
|
-
*
|
|
16355
|
-
* Gemini CLI carried a `.gemini/skills` projection until E-GEM-02: it reads the `.agents/skills`
|
|
16356
|
-
* alias at project scope too, where that alias takes precedence over `.gemini/skills`. It still
|
|
16357
|
-
* needs an instruction bridge, which is a separate axis and unaffected.
|
|
16358
|
-
*
|
|
16359
|
-
* Instruction bridges are recorded at project scope only. The user-scope equivalents exist, but
|
|
16360
|
-
* nothing writes or reads them yet: `init` works inside a repository, and so does `doctor`.
|
|
16361
|
-
*
|
|
16362
|
-
* `windsurf` is the former name of Devin Desktop, rebranded 2026-06-02. It is retained as a
|
|
16363
|
-
* deprecated alias: Devin still scans the legacy `.windsurf/skills` path, so its projection keeps
|
|
16364
|
-
* working, but new repositories should enable `devin-desktop` and have nothing written for them.
|
|
16365
|
-
*
|
|
16366
|
-
* See `.research/agentic-configuration-standards/` for the per-harness sources.
|
|
16367
|
-
*/
|
|
16205
|
+
/** See `.research/agentic-configuration-standards/` for the per-harness sources. */
|
|
16368
16206
|
const harnessRegistry = [
|
|
16369
16207
|
{
|
|
16370
16208
|
name: "claude-code",
|
|
16371
16209
|
project: {
|
|
16372
16210
|
detect: ".claude",
|
|
16373
16211
|
skillsDirectory: ".claude/skills",
|
|
16374
|
-
|
|
16375
|
-
|
|
16376
|
-
|
|
16377
|
-
|
|
16212
|
+
shadowedBy: [
|
|
16213
|
+
"CLAUDE.md",
|
|
16214
|
+
".claude/CLAUDE.md",
|
|
16215
|
+
"CLAUDE.local.md"
|
|
16216
|
+
],
|
|
16378
16217
|
mcpConfig: {
|
|
16379
16218
|
path: ".mcp.json",
|
|
16380
16219
|
key: "mcpServers",
|
|
@@ -16532,13 +16371,8 @@ function isHarnessName(value) {
|
|
|
16532
16371
|
return harnessNames.has(value);
|
|
16533
16372
|
}
|
|
16534
16373
|
/**
|
|
16535
|
-
*
|
|
16536
|
-
*
|
|
16537
|
-
* to the registry is accepted by both commands with nothing else to change, and the rejection reads
|
|
16538
|
-
* the same either way.
|
|
16539
|
-
*
|
|
16540
|
-
* An unknown name throws rather than being dropped. A caller that asked for a harness by name and
|
|
16541
|
-
* got a report about the two default ones has been told the wrong thing about their repository.
|
|
16374
|
+
* An unknown name throws rather than being dropped — silently falling back to the defaults would
|
|
16375
|
+
* misreport what was diagnosed.
|
|
16542
16376
|
*/
|
|
16543
16377
|
function parseHarnesses(value) {
|
|
16544
16378
|
const requested = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
@@ -16571,9 +16405,8 @@ function locatorText({ file, server, field, position }) {
|
|
|
16571
16405
|
//#endregion
|
|
16572
16406
|
//#region src/diagnose-bridges/doctor-guidance.ts
|
|
16573
16407
|
/**
|
|
16574
|
-
*
|
|
16575
|
-
*
|
|
16576
|
-
* claims a row exists.
|
|
16408
|
+
* Turns a table into the list `doctor` reports; the cast is safe because a `Record<P, RepairRow>`'s
|
|
16409
|
+
* keys are `P` by construction.
|
|
16577
16410
|
*/
|
|
16578
16411
|
function repairsOf(table) {
|
|
16579
16412
|
return Object.entries(table).map(([problem, row]) => ({
|
|
@@ -16581,19 +16414,11 @@ function repairsOf(table) {
|
|
|
16581
16414
|
...row
|
|
16582
16415
|
}));
|
|
16583
16416
|
}
|
|
16584
|
-
/** How the command names itself. */
|
|
16585
16417
|
const commandInvocation = "buddy-agent-harness";
|
|
16586
|
-
/**
|
|
16587
|
-
* How the skill hands a repair to `repair`. Bridge and instruction repairs go to `init`, which
|
|
16588
|
-
* writes both kinds of bridge in the first place; configuration that is present and wrong goes
|
|
16589
|
-
* here, because no `init` flag corrects a file the user already wrote.
|
|
16590
|
-
*/
|
|
16591
16418
|
const repairSkillInvocation = "/buddy-agent-harness:repair";
|
|
16592
|
-
|
|
16593
|
-
const initSkillInvocation = "/buddy-agent-harness:init";
|
|
16419
|
+
const initSkillInvocation = `/buddy-agent-harness:init-buddy-agent-harness`;
|
|
16594
16420
|
/** Where the user authors the golden MCP server set, named in every MCP repair that points at it. */
|
|
16595
16421
|
const goldenSet = ".agents/buddy-agent-harness/mcp.toml";
|
|
16596
|
-
/** The skills bridges, reported in `bridges`. */
|
|
16597
16422
|
const bridgeTable = {
|
|
16598
16423
|
"no-canonical": {
|
|
16599
16424
|
detail: "the canonical skill directory does not exist, so no bridge can resolve",
|
|
@@ -16670,19 +16495,17 @@ const bridgeTable = {
|
|
|
16670
16495
|
};
|
|
16671
16496
|
repairsOf(bridgeTable);
|
|
16672
16497
|
/**
|
|
16673
|
-
*
|
|
16674
|
-
*
|
|
16675
|
-
* while restoring the bridge is judgment no flag carries. `repair` therefore names the skill in
|
|
16676
|
-
* both places rather than pretending a shell command exists.
|
|
16498
|
+
* Everything reported in `instructions`: bridges into `AGENTS.md` and the files that suppress it.
|
|
16499
|
+
* Every repair is the `init-buddy-agent-harness` skill's judgment, never a shell command.
|
|
16677
16500
|
*/
|
|
16678
16501
|
const instructionTable = {
|
|
16679
16502
|
"no-instructions": {
|
|
16680
|
-
detail: "no AGENTS.md at the repository root, so
|
|
16503
|
+
detail: "no AGENTS.md at the repository root, so the instructions this repository has reach one harness at most",
|
|
16681
16504
|
repair: () => ({
|
|
16682
16505
|
command: "",
|
|
16683
|
-
instruction: `hand this to \`${initSkillInvocation}\`, which
|
|
16506
|
+
instruction: `hand this to \`${initSkillInvocation}\`, which consolidates what the repository has into AGENTS.md, or derives it`
|
|
16684
16507
|
}),
|
|
16685
|
-
skillRepair: () => `run \`${initSkillInvocation}\`, which derives AGENTS.md
|
|
16508
|
+
skillRepair: () => `run \`${initSkillInvocation}\`, which consolidates or derives AGENTS.md`
|
|
16686
16509
|
},
|
|
16687
16510
|
"instructions-missing": {
|
|
16688
16511
|
detail: "no instruction bridge at this path — the harness reads none of AGENTS.md",
|
|
@@ -16700,6 +16523,22 @@ const instructionTable = {
|
|
|
16700
16523
|
}),
|
|
16701
16524
|
skillRepair: () => `run \`${initSkillInvocation}\`, which adds the bridge without discarding what the file already says`
|
|
16702
16525
|
},
|
|
16526
|
+
"instructions-shadowing": {
|
|
16527
|
+
detail: "this file suppresses the AGENTS.md beside it — the harness reads this instead, and none of AGENTS.md",
|
|
16528
|
+
repair: ({ file }) => ({
|
|
16529
|
+
command: "",
|
|
16530
|
+
instruction: `hand ${file} to \`${initSkillInvocation}\`, which consolidates what it says into AGENTS.md, or adds an @AGENTS.md import above it where the file has to stay`
|
|
16531
|
+
}),
|
|
16532
|
+
skillRepair: () => `run \`${initSkillInvocation}\`, which consolidates the file into AGENTS.md or imports AGENTS.md from it`
|
|
16533
|
+
},
|
|
16534
|
+
"instructions-superseded": {
|
|
16535
|
+
detail: "a bridge from before the harness read AGENTS.md itself — it still works, and nothing needs it",
|
|
16536
|
+
repair: ({ file }) => ({
|
|
16537
|
+
command: "",
|
|
16538
|
+
instruction: `remove ${file}, or keep it for sessions that cannot read AGENTS.md directly — \`${initSkillInvocation}\` offers the choice`
|
|
16539
|
+
}),
|
|
16540
|
+
skillRepair: () => `run \`${initSkillInvocation}\`, which offers to remove it`
|
|
16541
|
+
},
|
|
16703
16542
|
"instructions-unreadable": {
|
|
16704
16543
|
detail: "the settings file does not parse, so the harness reads none of it",
|
|
16705
16544
|
repair: ({ file }) => ({
|
|
@@ -16710,10 +16549,6 @@ const instructionTable = {
|
|
|
16710
16549
|
}
|
|
16711
16550
|
};
|
|
16712
16551
|
repairsOf(instructionTable);
|
|
16713
|
-
/**
|
|
16714
|
-
* Configuration that is present and wrong. Detected here like everything else, but repaired by the
|
|
16715
|
-
* `repair` skill rather than by `init` — which is why it is a section of its own.
|
|
16716
|
-
*/
|
|
16717
16552
|
const configurationTable = {
|
|
16718
16553
|
"deprecated-harness": {
|
|
16719
16554
|
detail: "a projection under a harness name that has been superseded — the replacement reads .agents/skills natively and needs no projection at all",
|
|
@@ -16750,13 +16585,8 @@ const configurationTable = {
|
|
|
16750
16585
|
};
|
|
16751
16586
|
repairsOf(configurationTable);
|
|
16752
16587
|
/**
|
|
16753
|
-
*
|
|
16754
|
-
*
|
|
16755
|
-
* Every repair names a **locator** rather than a file — `.cursor/mcp.json#servers.linear.command` —
|
|
16756
|
-
* because a file holding twenty servers is not an address. The locator never carries a value: not
|
|
16757
|
-
* a whole one, and not a prefix. `doctor` is safe to run from a session-start hook, so a value it
|
|
16758
|
-
* echoes lands in agent context on every session and from there into transcripts, and `sk-ab…`
|
|
16759
|
-
* leaks into exactly the same place the whole string would.
|
|
16588
|
+
* Every repair names a locator rather than a file, and the locator never carries a credential
|
|
16589
|
+
* value.
|
|
16760
16590
|
*/
|
|
16761
16591
|
const mcpTable = {
|
|
16762
16592
|
"mcp-golden-unreadable": {
|
|
@@ -16842,13 +16672,8 @@ const mcpTable = {
|
|
|
16842
16672
|
};
|
|
16843
16673
|
repairsOf(mcpTable);
|
|
16844
16674
|
/**
|
|
16845
|
-
*
|
|
16846
|
-
*
|
|
16847
|
-
* the content is the user's, so it is offered and never written on sight. `repair` owns none of
|
|
16848
|
-
* them — it corrects configuration that is wrong, and none of this is wrong.
|
|
16849
|
-
*
|
|
16850
|
-
* `nonstandard-subagent` is the exception that names no owner. There is no cross-harness subagent
|
|
16851
|
-
* format to convert to, so the honest report is that the gap exists, not a repair that cannot run.
|
|
16675
|
+
* Every repair here is a conversion owned by the `init-buddy-agent-harness` skill, except
|
|
16676
|
+
* `nonstandard-subagent`, which names no owner since no cross-harness format exists to convert to.
|
|
16852
16677
|
*/
|
|
16853
16678
|
const nonstandardTable = {
|
|
16854
16679
|
"nonstandard-instructions": {
|
|
@@ -16894,8 +16719,8 @@ const nonstandardTable = {
|
|
|
16894
16719
|
};
|
|
16895
16720
|
repairsOf(nonstandardTable);
|
|
16896
16721
|
/**
|
|
16897
|
-
*
|
|
16898
|
-
*
|
|
16722
|
+
* Annotated over the whole union rather than inferred, so an added problem fails to compile until
|
|
16723
|
+
* its section carries it.
|
|
16899
16724
|
*/
|
|
16900
16725
|
const doctorTable = {
|
|
16901
16726
|
...bridgeTable,
|
|
@@ -16917,12 +16742,10 @@ function repairFor(problem) {
|
|
|
16917
16742
|
const canonicalSkills = ".agents/skills";
|
|
16918
16743
|
const localOverride = "AGENTS.local.md";
|
|
16919
16744
|
/**
|
|
16920
|
-
* The two frontmatter faults that make a harness skip a skill outright
|
|
16921
|
-
* `skills/init/references/frontmatter.md
|
|
16922
|
-
* A
|
|
16923
|
-
*
|
|
16924
|
-
* The unquoted colon is the documented cause of the first, and is checked directly rather than by
|
|
16925
|
-
* parsing YAML: the package ships no parser, and a targeted check names the actual fault.
|
|
16745
|
+
* The two frontmatter faults that make a harness skip a skill outright (see
|
|
16746
|
+
* `skills/init-buddy-agent-harness/references/frontmatter.md`): unparseable YAML, and a missing
|
|
16747
|
+
* `description`. A name/directory mismatch only warns and still loads, so it's not checked here.
|
|
16748
|
+
* Checked directly for an unquoted colon rather than by parsing YAML — the package ships no parser.
|
|
16926
16749
|
*/
|
|
16927
16750
|
function frontmatterFault(body) {
|
|
16928
16751
|
const block = /^---\n([\s\S]*?)\n---/.exec(body);
|
|
@@ -16932,10 +16755,8 @@ function frontmatterFault(body) {
|
|
|
16932
16755
|
if (!/^(['"]).*\1$/.test(value) && /:\s/.test(value)) return "unparseable";
|
|
16933
16756
|
}
|
|
16934
16757
|
/**
|
|
16935
|
-
*
|
|
16936
|
-
*
|
|
16937
|
-
* directory — which selects that harness anyway. A preference could therefore never add a finding,
|
|
16938
|
-
* so accepting one would be surface that does nothing.
|
|
16758
|
+
* Takes no harness preference, unlike `diagnoseBridges`: every check here needs the projection to
|
|
16759
|
+
* already exist on disk, which already selects the harness — a preference could never add a finding.
|
|
16939
16760
|
*/
|
|
16940
16761
|
function diagnoseConfiguration({ root, git, cli }) {
|
|
16941
16762
|
const findings = [];
|
|
@@ -16963,13 +16784,6 @@ function diagnoseConfiguration({ root, git, cli }) {
|
|
|
16963
16784
|
}
|
|
16964
16785
|
//#endregion
|
|
16965
16786
|
//#region src/diagnose-bridges/json-with-comments.ts
|
|
16966
|
-
/**
|
|
16967
|
-
* `.gemini/settings.json` may legally carry comments — the Gemini CLI loader strips them before
|
|
16968
|
-
* parsing. A plain `JSON.parse` of a valid settings file therefore throws, and `doctor` would report
|
|
16969
|
-
* a working bridge as unreadable. Comments are removed before parsing for that reason.
|
|
16970
|
-
*
|
|
16971
|
-
* Only comments. A trailing comma stays a parse error, because nothing documents it as accepted.
|
|
16972
|
-
*/
|
|
16973
16787
|
function stripJsonComments(source) {
|
|
16974
16788
|
let output = "";
|
|
16975
16789
|
let index = 0;
|
|
@@ -17031,10 +16845,7 @@ const mcpFields = [
|
|
|
17031
16845
|
];
|
|
17032
16846
|
/** The two fields holding one value per name rather than a single value. */
|
|
17033
16847
|
const mapFields = /* @__PURE__ */ new Set(["env", "headers"]);
|
|
17034
|
-
/**
|
|
17035
|
-
* Whether two values of the same field agree. Arrays are ordered — `args` is a command line, and
|
|
17036
|
-
* reordering it changes what runs. Maps are not, so they are compared by their sorted entries.
|
|
17037
|
-
*/
|
|
16848
|
+
/** Arrays compare ordered (`args` is a command line); maps compare by sorted entries. */
|
|
17038
16849
|
function sameValue(field, left, right) {
|
|
17039
16850
|
if (mapFields.has(field) && isRecord(left) && isRecord(right)) {
|
|
17040
16851
|
const keys = Object.keys(left);
|
|
@@ -17042,18 +16853,6 @@ function sameValue(field, left, right) {
|
|
|
17042
16853
|
}
|
|
17043
16854
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
17044
16855
|
}
|
|
17045
|
-
/**
|
|
17046
|
-
* The fields on which a target disagrees with the golden set.
|
|
17047
|
-
*
|
|
17048
|
-
* Asymmetric by design, and this is the rule that keeps a golden set from accumulating noise. A
|
|
17049
|
-
* field the golden set leaves unset is never a difference, however the target fills it: a host
|
|
17050
|
-
* restating its own default and a user's deliberate edit are indistinguishable in that position,
|
|
17051
|
-
* and pulling both back would grow the golden set on every round-trip. The golden set speaks only
|
|
17052
|
-
* about what it says.
|
|
17053
|
-
*
|
|
17054
|
-
* `env` and `headers` are compared per name for the same reason — a variable only the target sets
|
|
17055
|
-
* is that target's business, and one the golden set names must match.
|
|
17056
|
-
*/
|
|
17057
16856
|
function divergingFields(golden, target) {
|
|
17058
16857
|
return mcpFields.filter((field) => {
|
|
17059
16858
|
const declared = golden[field];
|
|
@@ -17843,7 +17642,6 @@ function parse$1(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
|
|
|
17843
17642
|
//#region src/diagnose-mcp/mcp-sources.ts
|
|
17844
17643
|
/** Where the golden set lives, and why it is namespaced rather than sitting at `.agents/mcp.json`. */
|
|
17845
17644
|
const goldenSetPath = ".agents/buddy-agent-harness/mcp.toml";
|
|
17846
|
-
/** The table the golden set keeps its servers under. */
|
|
17847
17645
|
const goldenKey = "servers";
|
|
17848
17646
|
function stringMap(value) {
|
|
17849
17647
|
if (!isRecord(value)) return void 0;
|
|
@@ -17856,10 +17654,8 @@ const transports = /* @__PURE__ */ new Set([
|
|
|
17856
17654
|
"sse"
|
|
17857
17655
|
]);
|
|
17858
17656
|
/**
|
|
17859
|
-
*
|
|
17860
|
-
*
|
|
17861
|
-
* is present. Inferring it is what lets a `url` entry in one file compare equal to a `type: http`
|
|
17862
|
-
* entry in another.
|
|
17657
|
+
* Infers transport when unstated (`url` → `http`, `command` → `stdio`), so a `url` entry in one
|
|
17658
|
+
* file compares equal to a `type: http` entry in another.
|
|
17863
17659
|
*/
|
|
17864
17660
|
function transportOf(entry) {
|
|
17865
17661
|
const declared = entry["type"] ?? entry["transport"];
|
|
@@ -17868,13 +17664,10 @@ function transportOf(entry) {
|
|
|
17868
17664
|
if (typeof entry["command"] === "string") return "stdio";
|
|
17869
17665
|
}
|
|
17870
17666
|
/**
|
|
17871
|
-
* One host
|
|
17872
|
-
*
|
|
17873
|
-
*
|
|
17874
|
-
*
|
|
17875
|
-
* A field whose value is the wrong type is dropped rather than carried through. A `timeout` that
|
|
17876
|
-
* is a string is not a timeout, and letting it into the model would report a divergence whose real
|
|
17877
|
-
* cause is a typo the finding does not name.
|
|
17667
|
+
* One host or golden entry, converted into the shared model; a field of the wrong type is dropped
|
|
17668
|
+
* rather than carried through, so a `timeout` that is a string does not report as a divergence.
|
|
17669
|
+
* Also used by `mcp-inventory.ts`, which normalizes its own raw shapes into these same fields
|
|
17670
|
+
* first.
|
|
17878
17671
|
*/
|
|
17879
17672
|
function serverFrom(entry) {
|
|
17880
17673
|
const args = Array.isArray(entry["args"]) && entry["args"].every((item) => typeof item === "string");
|
|
@@ -17900,16 +17693,8 @@ function serversUnder(document, key) {
|
|
|
17900
17693
|
return new Map(Object.entries(table).filter(([, entry]) => isRecord(entry)).map(([name, entry]) => [name, serverFrom(entry)]));
|
|
17901
17694
|
}
|
|
17902
17695
|
/**
|
|
17903
|
-
*
|
|
17904
|
-
*
|
|
17905
|
-
* `smol-toml` reports `line` and `column` on the error alongside a `codeblock` quoting the source,
|
|
17906
|
-
* and its `message` embeds that same code block. Both are unusable: in a file of MCP configuration
|
|
17907
|
-
* the line a parser failed on is exactly the line holding the credential. Only the two numbers are
|
|
17908
|
-
* read, and anything thrown that does not carry them is reported without a position rather than by
|
|
17909
|
-
* reaching for its message.
|
|
17910
|
-
*
|
|
17911
|
-
* Exported because that restriction is the point of the function rather than an implementation
|
|
17912
|
-
* detail of it, and it is verified directly.
|
|
17696
|
+
* Only line and column — never `message` or `codeblock`, which quote the source line and could echo
|
|
17697
|
+
* a credential.
|
|
17913
17698
|
*/
|
|
17914
17699
|
function positionOf(error) {
|
|
17915
17700
|
if (!isRecord(error)) return void 0;
|
|
@@ -17935,12 +17720,8 @@ function parseGoldenSet(source) {
|
|
|
17935
17720
|
}
|
|
17936
17721
|
}
|
|
17937
17722
|
/**
|
|
17938
|
-
* One harness's
|
|
17939
|
-
*
|
|
17940
|
-
* file as MCP would report the rest of a user's settings as servers it does not recognize.
|
|
17941
|
-
*
|
|
17942
|
-
* JSON is parsed with comments stripped, because Gemini CLI's loader strips them too and a
|
|
17943
|
-
* settings file may legally carry them.
|
|
17723
|
+
* One harness's config, parsed under its own key only — `.gemini/settings.json` also carries the
|
|
17724
|
+
* instruction bridge. JSON is parsed with comments stripped, matching Gemini CLI's own loader.
|
|
17944
17725
|
*/
|
|
17945
17726
|
function parseTarget(config, source) {
|
|
17946
17727
|
if (source === void 0) return { kind: "absent" };
|
|
@@ -17961,49 +17742,26 @@ function parseTarget(config, source) {
|
|
|
17961
17742
|
}
|
|
17962
17743
|
//#endregion
|
|
17963
17744
|
//#region src/diagnose-mcp/mcp-baseline.ts
|
|
17964
|
-
/** Where the record of what was last written to each target lives, beside the golden set it came from. */
|
|
17965
|
-
const projectionRecordPath = ".agents/buddy-agent-harness/mcp.projected.json";
|
|
17966
17745
|
/**
|
|
17967
|
-
*
|
|
17968
|
-
*
|
|
17969
|
-
* It exists because git can only answer for a file it can see. Harnesses write their MCP
|
|
17970
|
-
* configuration themselves, at user scope, into files that are often untracked or ignored outright,
|
|
17971
|
-
* and for those "which side moved since they last agreed" has no answer in history. A record
|
|
17972
|
-
* written at projection time has one, exactly, because it *is* what was projected.
|
|
17973
|
-
*
|
|
17974
|
-
* A malformed record answers nothing rather than throwing. It is a cache of an answer, not the
|
|
17975
|
-
* answer, and a diagnosis that dies because its cache is corrupt is worse than one that says
|
|
17976
|
-
* `unknown`.
|
|
17746
|
+
* Where the record of what was last written to each target lives, beside the golden set it came
|
|
17747
|
+
* from.
|
|
17977
17748
|
*/
|
|
17749
|
+
const projectionRecordPath = ".agents/buddy-agent-harness/mcp.projected.json";
|
|
17978
17750
|
function parseProjectionRecord(source) {
|
|
17979
17751
|
const document = source === void 0 ? void 0 : parseJsonWithComments(source);
|
|
17980
17752
|
const targets = isRecord(document) ? document["targets"] : void 0;
|
|
17981
17753
|
if (!isRecord(targets)) return /* @__PURE__ */ new Map();
|
|
17982
17754
|
return new Map(Object.entries(targets).filter(([, servers]) => isRecord(servers)).map(([path, servers]) => [path, new Map(Object.entries(servers).filter(([, server]) => isRecord(server)).map(([name, server]) => [name, server]))]));
|
|
17983
17755
|
}
|
|
17984
|
-
/**
|
|
17985
|
-
* Which side moved, for one server and one field.
|
|
17986
|
-
*
|
|
17987
|
-
* The record is asked first, because it records exactly what was written and therefore answers
|
|
17988
|
-
* exactly. Where it holds nothing, a **tracked** target falls back to git: the newest commit where
|
|
17989
|
-
* the golden set and the target agreed on that field, then which working tree still matches it.
|
|
17990
|
-
* Where neither can answer, the direction is `unknown` and the finding says so — naming a side on a
|
|
17991
|
-
* guess would send a reconcile at the wrong file.
|
|
17992
|
-
*/
|
|
17993
17756
|
var McpBaseline = class {
|
|
17994
17757
|
options;
|
|
17995
17758
|
constructor(options) {
|
|
17996
17759
|
this.options = options;
|
|
17997
17760
|
}
|
|
17998
17761
|
/**
|
|
17999
|
-
*
|
|
18000
|
-
*
|
|
18001
|
-
*
|
|
18002
|
-
* every one of those walks reads the same two files at the same commits. Unmemoized, three
|
|
18003
|
-
* targets against five servers with two diverged fields each over fifty commits of history is
|
|
18004
|
-
* three thousand `git show` calls and as many parses — on the command the `doctor` skill says is
|
|
18005
|
-
* cheap enough for a session-start hook. One instance serves one diagnosis, so the memo lives and
|
|
18006
|
-
* dies with it and can never answer for a working tree that has moved on.
|
|
17762
|
+
* Memoized per instance: unmemoized, `lastAgreed` would repeat the same `git show` and parse
|
|
17763
|
+
* for every diverged field. The memo dies with the instance, so it never answers for a working
|
|
17764
|
+
* tree that has since moved on.
|
|
18007
17765
|
*/
|
|
18008
17766
|
parsed = /* @__PURE__ */ new Map();
|
|
18009
17767
|
walked = /* @__PURE__ */ new Map();
|
|
@@ -18021,9 +17779,8 @@ var McpBaseline = class {
|
|
|
18021
17779
|
return this.lastAgreed(config, name, field);
|
|
18022
17780
|
}
|
|
18023
17781
|
/**
|
|
18024
|
-
*
|
|
18025
|
-
*
|
|
18026
|
-
* reformatted one of them did not change what it says.
|
|
17782
|
+
* Compared at model granularity, not blob bytes — the two files never share bytes, so a
|
|
17783
|
+
* reformat wouldn't register as agreement lost.
|
|
18027
17784
|
*/
|
|
18028
17785
|
lastAgreed(config, name, field) {
|
|
18029
17786
|
for (const commit of this.commitsTouching(config)) {
|
|
@@ -18054,28 +17811,18 @@ var McpBaseline = class {
|
|
|
18054
17811
|
//#endregion
|
|
18055
17812
|
//#region src/diagnose-mcp/mcp-secrets.ts
|
|
18056
17813
|
/**
|
|
18057
|
-
* Finding a literal credential in MCP configuration, without ever handling one
|
|
18058
|
-
*
|
|
18059
|
-
*
|
|
18060
|
-
* session and from there into transcripts. A value it echoes is amplified far past the file it came
|
|
18061
|
-
* from. Every function here answers **where**, never **what**: the return is a field path, and the
|
|
18062
|
-
* value never leaves this module.
|
|
18063
|
-
*
|
|
18064
|
-
* That is also why the redaction lives here rather than in a formatter. `doctor` renders three ways
|
|
18065
|
-
* — TOON, `--format text`, and `--format json` — and trusting each of them to mask is three chances
|
|
18066
|
-
* to drift. A value that never enters the finding cannot be printed by any of them.
|
|
17814
|
+
* Finding a literal credential in MCP configuration, without ever handling one: every function
|
|
17815
|
+
* here answers where, never what, and the value itself never leaves this module — redaction lives
|
|
17816
|
+
* here, once, rather than being trusted to each of `doctor`'s three output formats.
|
|
18067
17817
|
*/
|
|
18068
17818
|
/**
|
|
18069
|
-
*
|
|
18070
|
-
*
|
|
18071
|
-
* the run-together spellings a segment test would miss — `MYTOKEN`, `githubsecret`.
|
|
17819
|
+
* Marks a field as credential-bearing anywhere in its name — safe as a substring, since none of
|
|
17820
|
+
* these turn up inside an innocent word.
|
|
18072
17821
|
*/
|
|
18073
17822
|
const credentialWord = /token|secret|password|passwd|credential/i;
|
|
18074
17823
|
/**
|
|
18075
|
-
*
|
|
18076
|
-
*
|
|
18077
|
-
* `KEYSTONE_URL`. Matching them as substrings reports those, and a scanner that cries wolf on a
|
|
18078
|
-
* hook-run command is one people stop reading.
|
|
17824
|
+
* Marks a field only as a whole name segment: `key`/`auth` are common inside innocent words like
|
|
17825
|
+
* `MONKEY` or `AUTHOR`.
|
|
18079
17826
|
*/
|
|
18080
17827
|
const credentialSegment = /* @__PURE__ */ new Set([
|
|
18081
17828
|
"key",
|
|
@@ -18089,19 +17836,15 @@ function segmentsOf(key) {
|
|
|
18089
17836
|
return key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((segment) => segment.toLowerCase());
|
|
18090
17837
|
}
|
|
18091
17838
|
/**
|
|
18092
|
-
* A value that
|
|
18093
|
-
*
|
|
18094
|
-
*
|
|
18095
|
-
* Classification is by shape, not by content. An entropy test on the value would be a guess about
|
|
18096
|
-
* a string this module has already decided not to look at, and it would fail in both directions —
|
|
18097
|
-
* a short password is low-entropy and a base64 configuration blob is high.
|
|
17839
|
+
* A value that names a secret rather than holding one: `${VAR}`, `$VAR`, `${env:VAR}`,
|
|
17840
|
+
* `${input:id}`. Classification is by shape, not entropy — a short password and a base64 blob look
|
|
17841
|
+
* identical to an entropy test.
|
|
18098
17842
|
*/
|
|
18099
17843
|
const reference = /\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*/;
|
|
18100
17844
|
/**
|
|
18101
|
-
*
|
|
18102
|
-
*
|
|
18103
|
-
*
|
|
18104
|
-
* would report every correct one.
|
|
17845
|
+
* True unless a reference appears anywhere in the value — `Bearer ${LINEAR_TOKEN}` is the
|
|
17846
|
+
* documented template form, so requiring the whole value to be a reference would flag it as
|
|
17847
|
+
* literal.
|
|
18105
17848
|
*/
|
|
18106
17849
|
function holdsLiteral(value) {
|
|
18107
17850
|
return !reference.test(value);
|
|
@@ -18110,11 +17853,8 @@ function credentialKey(key) {
|
|
|
18110
17853
|
return credentialWord.test(key) || segmentsOf(key).some((segment) => credentialSegment.has(segment));
|
|
18111
17854
|
}
|
|
18112
17855
|
/**
|
|
18113
|
-
*
|
|
18114
|
-
*
|
|
18115
|
-
*
|
|
18116
|
-
* A URL that does not parse is not reported. It is malformed rather than leaky, and guessing at its
|
|
18117
|
-
* structure with a regular expression is how a scanner starts matching the thing it must not touch.
|
|
17856
|
+
* A password in the URL's userinfo, or a credential-named query parameter holding a literal. An
|
|
17857
|
+
* unparseable URL is not reported — malformed, not leaky.
|
|
18118
17858
|
*/
|
|
18119
17859
|
function urlHoldsCredential(value) {
|
|
18120
17860
|
let url;
|
|
@@ -18130,12 +17870,7 @@ function urlHoldsCredential(value) {
|
|
|
18130
17870
|
function literalsIn(field, values) {
|
|
18131
17871
|
return Object.entries(values ?? {}).filter(([key, value]) => credentialKey(key) && holdsLiteral(value)).map(([key]) => `${field}.${key}`);
|
|
18132
17872
|
}
|
|
18133
|
-
/**
|
|
18134
|
-
* Every field of one server holding a literal credential, as a path within that server.
|
|
18135
|
-
*
|
|
18136
|
-
* The path is the whole answer. It is never a truncated value: `sk-ab…` is a leak into the same
|
|
18137
|
-
* transcript and buys a reader nothing the field name does not already give them.
|
|
18138
|
-
*/
|
|
17873
|
+
/** Field paths only — never a truncated value; `sk-ab…` is still a leak into the same transcript. */
|
|
18139
17874
|
function credentialFields(server) {
|
|
18140
17875
|
return [
|
|
18141
17876
|
...literalsIn("env", server.env),
|
|
@@ -18150,12 +17885,8 @@ function read(root, path) {
|
|
|
18150
17885
|
return existsSync(absolute) ? readFileSync(absolute, "utf8") : void 0;
|
|
18151
17886
|
}
|
|
18152
17887
|
/**
|
|
18153
|
-
* The distinct MCP files the enabled harnesses read.
|
|
18154
|
-
*
|
|
18155
|
-
* No harness preference is accepted, for the same reason the configuration half accepts none: every
|
|
18156
|
-
* harness documenting an MCP file is already selected without one. Claude Code and Cursor are
|
|
18157
|
-
* selected unconditionally; Codex and Gemini CLI keep their file inside their own detection
|
|
18158
|
-
* directory, so it cannot exist without that directory selecting them. A preference could never add
|
|
17888
|
+
* The distinct MCP files the enabled harnesses read. No `--harness` preference is accepted: every
|
|
17889
|
+
* harness documenting an MCP file is already selected without one, so a preference could never add
|
|
18159
17890
|
* a finding.
|
|
18160
17891
|
*/
|
|
18161
17892
|
function targetsOf(root) {
|
|
@@ -18179,12 +17910,6 @@ function diagnoseMcp({ root, git, cli }) {
|
|
|
18179
17910
|
repair: repair(at, cli)
|
|
18180
17911
|
});
|
|
18181
17912
|
};
|
|
18182
|
-
/**
|
|
18183
|
-
* Credentials are reported for every file that holds servers, the golden set included — a user
|
|
18184
|
-
* pastes a token into whichever file is open, and the golden set is a file. Severity splits on
|
|
18185
|
-
* tracking, because a literal in a tracked file is already a committed credential and moving it
|
|
18186
|
-
* to an environment variable does not un-commit it.
|
|
18187
|
-
*/
|
|
18188
17913
|
const reportSecrets = (path, servers) => {
|
|
18189
17914
|
const committed = git.trackingOf(path) !== "untracked";
|
|
18190
17915
|
for (const [name, server] of servers) for (const field of credentialFields(server)) add({
|
|
@@ -18254,11 +17979,9 @@ const problemOf = {
|
|
|
18254
17979
|
subagent: "nonstandard-subagent"
|
|
18255
17980
|
};
|
|
18256
17981
|
/**
|
|
18257
|
-
*
|
|
18258
|
-
* to
|
|
18259
|
-
*
|
|
18260
|
-
* verbatim. Read from frontmatter rather than assumed, because the two need different conversions
|
|
18261
|
-
* and guessing wrong sends prose to the wrong destination.
|
|
17982
|
+
* `globs:` with a value binds a `.mdc` rule to paths — `AGENTS.md` has no equivalent, so that rule
|
|
17983
|
+
* converts to a skill; without it, the rule is always-on prose that `AGENTS.md` holds verbatim.
|
|
17984
|
+
* Read from frontmatter rather than assumed, since guessing wrong sends prose to the wrong destination.
|
|
18262
17985
|
*/
|
|
18263
17986
|
function scopedByGlobs(body) {
|
|
18264
17987
|
const block = /^---\n([\s\S]*?)\n---/.exec(body);
|
|
@@ -18271,8 +17994,8 @@ function skillFiles(directory) {
|
|
|
18271
17994
|
return filesUnder(directory).filter((file) => file.endsWith("SKILL.md"));
|
|
18272
17995
|
}
|
|
18273
17996
|
/**
|
|
18274
|
-
* A
|
|
18275
|
-
*
|
|
17997
|
+
* A symlinked path is already a projection, not authored config — reporting it would tell a repo to
|
|
17998
|
+
* convert what it already converted.
|
|
18276
17999
|
*/
|
|
18277
18000
|
function authoredHere(path) {
|
|
18278
18001
|
try {
|
|
@@ -18288,10 +18011,8 @@ function artifactFiles(root, artifact) {
|
|
|
18288
18011
|
return (artifact.kind === "skill" ? skillFiles(absolute) : filesUnder(absolute)).map((file) => posix.join(artifact.path, file));
|
|
18289
18012
|
}
|
|
18290
18013
|
/**
|
|
18291
|
-
*
|
|
18292
|
-
*
|
|
18293
|
-
* repository nobody opens in Cursor is still instruction content `AGENTS.md` does not carry — so
|
|
18294
|
-
* filtering by the enabled set would hide exactly the drift worth converting.
|
|
18014
|
+
* Takes no harness preference or enabled set, unlike the other detectors — an artifact matters
|
|
18015
|
+
* whether or not its harness is enabled here, so filtering by the enabled set would hide it.
|
|
18295
18016
|
*/
|
|
18296
18017
|
function diagnoseNonstandard({ root, cli }) {
|
|
18297
18018
|
const findings = [];
|
|
@@ -18309,6 +18030,129 @@ function diagnoseNonstandard({ root, cli }) {
|
|
|
18309
18030
|
return findings.sort((left, right) => left.path.localeCompare(right.path));
|
|
18310
18031
|
}
|
|
18311
18032
|
//#endregion
|
|
18033
|
+
//#region src/governance-overrides/governance-overrides.ts
|
|
18034
|
+
function managedGovernancesDir(platform, programData) {
|
|
18035
|
+
if (platform === "darwin") return "/Library/Application Support/BuddyAgentHarness/governances";
|
|
18036
|
+
if (platform === "win32") return join(programData || "C:\\ProgramData", "BuddyAgentHarness", "governances");
|
|
18037
|
+
return "/etc/buddy-agent-harness/governances";
|
|
18038
|
+
}
|
|
18039
|
+
/**
|
|
18040
|
+
* Still read below the layer above, so a machine already using it keeps working; reported as
|
|
18041
|
+
* deprecated so an admin knows to move it.
|
|
18042
|
+
*/
|
|
18043
|
+
function deprecatedManagedGovernancesDir(platform, programData) {
|
|
18044
|
+
if (platform === "darwin") return "/Library/Application Support/UniPlugin/governances";
|
|
18045
|
+
if (platform === "win32") return join(programData || "C:\\ProgramData", "UniPlugin", "governances");
|
|
18046
|
+
return "/etc/universal-plugin/governances";
|
|
18047
|
+
}
|
|
18048
|
+
function projectGovernancesDir(root) {
|
|
18049
|
+
return join(root, ".agents", "governances");
|
|
18050
|
+
}
|
|
18051
|
+
function userGovernancesDir(home) {
|
|
18052
|
+
return join(home, ".agents", "governances");
|
|
18053
|
+
}
|
|
18054
|
+
/**
|
|
18055
|
+
* Walks up to the nearest `package.json` rather than a relative literal — this module sits at a
|
|
18056
|
+
* different depth in `src/` vs the bundle.
|
|
18057
|
+
*/
|
|
18058
|
+
function packageGovernancesDir(from = fileURLToPath(import.meta.url)) {
|
|
18059
|
+
let dir = dirname(from);
|
|
18060
|
+
for (;;) {
|
|
18061
|
+
if (existsSync(join(dir, "package.json"))) return join(dir, "governances");
|
|
18062
|
+
const parent = dirname(dir);
|
|
18063
|
+
if (parent === dir) return join(dir, "governances");
|
|
18064
|
+
dir = parent;
|
|
18065
|
+
}
|
|
18066
|
+
}
|
|
18067
|
+
function governanceLayers({ root, home, platform, programData }) {
|
|
18068
|
+
return [
|
|
18069
|
+
{
|
|
18070
|
+
scope: "project",
|
|
18071
|
+
dir: projectGovernancesDir(root)
|
|
18072
|
+
},
|
|
18073
|
+
{
|
|
18074
|
+
scope: "user",
|
|
18075
|
+
dir: userGovernancesDir(home)
|
|
18076
|
+
},
|
|
18077
|
+
{
|
|
18078
|
+
scope: "managed",
|
|
18079
|
+
dir: managedGovernancesDir(platform, programData)
|
|
18080
|
+
},
|
|
18081
|
+
{
|
|
18082
|
+
scope: "managed-deprecated",
|
|
18083
|
+
dir: deprecatedManagedGovernancesDir(platform, programData)
|
|
18084
|
+
},
|
|
18085
|
+
{
|
|
18086
|
+
scope: "package",
|
|
18087
|
+
dir: packageGovernancesDir()
|
|
18088
|
+
}
|
|
18089
|
+
];
|
|
18090
|
+
}
|
|
18091
|
+
/**
|
|
18092
|
+
* Filters by scope rather than slicing the list, so a layer added later can't quietly become an
|
|
18093
|
+
* override by position.
|
|
18094
|
+
*/
|
|
18095
|
+
function overrideLayers(layers) {
|
|
18096
|
+
return layers.filter((layer) => layer.scope !== "package");
|
|
18097
|
+
}
|
|
18098
|
+
const namePattern = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/i;
|
|
18099
|
+
/**
|
|
18100
|
+
* Rejects rather than sanitizes — silently answering a different question is how untrusted input
|
|
18101
|
+
* reads a file outside the layer.
|
|
18102
|
+
*/
|
|
18103
|
+
function parseGovernanceName(value) {
|
|
18104
|
+
if (value.endsWith(".md")) throw new Error(`"${value}" names a file. Ask for the governance by name, without the .md extension.`);
|
|
18105
|
+
if (!namePattern.test(value)) throw new Error(`"${value}" is not a governance name. A name is letters, digits, hyphens, and dots — not a path.`);
|
|
18106
|
+
return value;
|
|
18107
|
+
}
|
|
18108
|
+
function tryReadDocument(path) {
|
|
18109
|
+
try {
|
|
18110
|
+
return readFileSync(path, "utf8");
|
|
18111
|
+
} catch {
|
|
18112
|
+
return;
|
|
18113
|
+
}
|
|
18114
|
+
}
|
|
18115
|
+
/** Empty for a layer that's missing, unreadable, or not a directory — never throws. */
|
|
18116
|
+
function documentNames(dir) {
|
|
18117
|
+
try {
|
|
18118
|
+
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name.slice(0, -3));
|
|
18119
|
+
} catch {
|
|
18120
|
+
return [];
|
|
18121
|
+
}
|
|
18122
|
+
}
|
|
18123
|
+
/** The first layer that holds the name wins; the rest are not read. */
|
|
18124
|
+
function resolveGovernance(name, layers) {
|
|
18125
|
+
for (const { scope, dir } of layers) {
|
|
18126
|
+
const path = join(dir, `${name}.md`);
|
|
18127
|
+
const content = tryReadDocument(path);
|
|
18128
|
+
if (content !== void 0) return {
|
|
18129
|
+
name,
|
|
18130
|
+
scope,
|
|
18131
|
+
path,
|
|
18132
|
+
content
|
|
18133
|
+
};
|
|
18134
|
+
}
|
|
18135
|
+
}
|
|
18136
|
+
/** Every name any layer holds, each reported at the layer that would win, sorted by name. */
|
|
18137
|
+
function listGovernances(layers) {
|
|
18138
|
+
const found = /* @__PURE__ */ new Map();
|
|
18139
|
+
for (const { scope, dir } of layers) for (const name of documentNames(dir)) if (!found.has(name)) found.set(name, {
|
|
18140
|
+
name,
|
|
18141
|
+
scope,
|
|
18142
|
+
path: join(dir, `${name}.md`)
|
|
18143
|
+
});
|
|
18144
|
+
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
18145
|
+
}
|
|
18146
|
+
/**
|
|
18147
|
+
* Creates the directory if absent — a fresh repository gets one obvious place to put an override,
|
|
18148
|
+
* and a count of zero.
|
|
18149
|
+
*/
|
|
18150
|
+
function countProjectGovernances(root) {
|
|
18151
|
+
const dir = projectGovernancesDir(root);
|
|
18152
|
+
mkdirSync(dir, { recursive: true });
|
|
18153
|
+
return documentNames(dir).length;
|
|
18154
|
+
}
|
|
18155
|
+
//#endregion
|
|
18312
18156
|
//#region src/skill-projection/skill-projection.ts
|
|
18313
18157
|
function skillNames(skillsDirectory) {
|
|
18314
18158
|
return readdirSync(skillsDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
@@ -18322,10 +18166,8 @@ function pathOccupied(path) {
|
|
|
18322
18166
|
}
|
|
18323
18167
|
}
|
|
18324
18168
|
/**
|
|
18325
|
-
*
|
|
18326
|
-
*
|
|
18327
|
-
* Windows junction, and one reached through a symbolic link in a parent directory all read as the
|
|
18328
|
-
* correct bridge. A link that no longer resolves is not.
|
|
18169
|
+
* Compares resolved paths, not link text, so an absolute link, a Windows junction, or a link
|
|
18170
|
+
* reached through a parent symlink all read as correct.
|
|
18329
18171
|
*/
|
|
18330
18172
|
function linksTo(target, canonical) {
|
|
18331
18173
|
try {
|
|
@@ -18341,18 +18183,15 @@ function countSkills(canonicalSkills) {
|
|
|
18341
18183
|
return skillNames(canonicalSkills).length;
|
|
18342
18184
|
}
|
|
18343
18185
|
/**
|
|
18344
|
-
*
|
|
18345
|
-
*
|
|
18346
|
-
* absolute path. Every other platform keeps the link relative, so it survives the repository moving.
|
|
18186
|
+
* Windows junctions resolve a relative target against the process directory, not the link's own, so
|
|
18187
|
+
* Windows gets an absolute path; everywhere else stays relative to survive the repository moving.
|
|
18347
18188
|
*/
|
|
18348
18189
|
function linkTarget(target, canonicalSkills) {
|
|
18349
18190
|
return platform() === "win32" ? resolve(canonicalSkills) : relative(dirname(target), canonicalSkills);
|
|
18350
18191
|
}
|
|
18351
18192
|
/**
|
|
18352
|
-
*
|
|
18353
|
-
*
|
|
18354
|
-
* every conflicting target, which is what keeps an existing `init --force` doing what it always did.
|
|
18355
|
-
* Anything else is a comma-separated list of targets.
|
|
18193
|
+
* A valueless `--force` arrives as the literal string `"true"` — the parser's flag encoding — and
|
|
18194
|
+
* means every target; anything else is a comma-separated list.
|
|
18356
18195
|
*/
|
|
18357
18196
|
function parseForce(value) {
|
|
18358
18197
|
if (value === "true") return true;
|
|
@@ -18360,17 +18199,13 @@ function parseForce(value) {
|
|
|
18360
18199
|
return value.split(",").map((target) => target.trim()).filter((target) => target.length > 0);
|
|
18361
18200
|
}
|
|
18362
18201
|
/**
|
|
18363
|
-
*
|
|
18364
|
-
*
|
|
18365
|
-
* path naming the same directory selects it too.
|
|
18202
|
+
* Resolves both sides so the repo-relative path a conflict message prints also selects it back, and
|
|
18203
|
+
* so does an absolute path naming the same directory.
|
|
18366
18204
|
*/
|
|
18367
18205
|
function namesTarget(root, target, given) {
|
|
18368
18206
|
return resolve(root, given) === resolve(target);
|
|
18369
18207
|
}
|
|
18370
|
-
/**
|
|
18371
|
-
* Points every harness that cannot read `.agents/skills` at the canonical directory. Symlinking is
|
|
18372
|
-
* preferred; a platform that refuses the link falls back to a copy.
|
|
18373
|
-
*/
|
|
18208
|
+
/** Symlinks by default; falls back to a copy where the platform refuses the link. */
|
|
18374
18209
|
function projectSkills({ root, canonicalSkills, harnesses, copy, force }) {
|
|
18375
18210
|
const projections = harnesses.filter((harness) => harness.project.skillsDirectory).map((harness) => ({
|
|
18376
18211
|
harness,
|
|
@@ -18412,12 +18247,8 @@ function projectSkills({ root, canonicalSkills, harnesses, copy, force }) {
|
|
|
18412
18247
|
/** Directories that never hold instructions this repository is responsible for bridging. */
|
|
18413
18248
|
const pruned = /* @__PURE__ */ new Set(["node_modules"]);
|
|
18414
18249
|
/**
|
|
18415
|
-
*
|
|
18416
|
-
*
|
|
18417
|
-
*
|
|
18418
|
-
* Dot-directories are pruned along with `node_modules`. That is not only a speed measure:
|
|
18419
|
-
* `.agents/AGENTS.md` is canonical shared instructions rather than a subtree-scoped file, and
|
|
18420
|
-
* bridging it would claim a scope it does not have.
|
|
18250
|
+
* Dot-directories are pruned along with `node_modules`: `.agents/AGENTS.md` is canonical shared
|
|
18251
|
+
* instructions, not a subtree-scoped file.
|
|
18421
18252
|
*/
|
|
18422
18253
|
function agentsFileDirectories(root) {
|
|
18423
18254
|
const found = [];
|
|
@@ -18440,40 +18271,6 @@ function agentsFileDirectories(root) {
|
|
|
18440
18271
|
//#endregion
|
|
18441
18272
|
//#region src/diagnose-bridges/diagnose-instructions.ts
|
|
18442
18273
|
const canonicalInstructions = "AGENTS.md";
|
|
18443
|
-
/**
|
|
18444
|
-
* An import bridges the `AGENTS.md` beside it and nothing else, so the check is per directory
|
|
18445
|
-
* holding one rather than per harness. A line of its own is enough: the harness reference allows
|
|
18446
|
-
* Claude-specific notes below the import, and a file that carries the import plus notes still
|
|
18447
|
-
* bridges.
|
|
18448
|
-
*/
|
|
18449
|
-
function inspectImport(target) {
|
|
18450
|
-
let stats;
|
|
18451
|
-
try {
|
|
18452
|
-
stats = lstatSync(target);
|
|
18453
|
-
} catch {
|
|
18454
|
-
return {
|
|
18455
|
-
kind: "none",
|
|
18456
|
-
status: "missing",
|
|
18457
|
-
problem: "instructions-missing"
|
|
18458
|
-
};
|
|
18459
|
-
}
|
|
18460
|
-
if (stats.isSymbolicLink()) return readlinkSync(target) === "AGENTS.md" ? {
|
|
18461
|
-
kind: "symlink",
|
|
18462
|
-
status: "ok"
|
|
18463
|
-
} : {
|
|
18464
|
-
kind: "symlink",
|
|
18465
|
-
status: "unbridged",
|
|
18466
|
-
problem: "instructions-unbridged"
|
|
18467
|
-
};
|
|
18468
|
-
return readFileSync(target, "utf8").split("\n").some((line) => line.trim() === `@AGENTS.md`) ? {
|
|
18469
|
-
kind: "import",
|
|
18470
|
-
status: "ok"
|
|
18471
|
-
} : {
|
|
18472
|
-
kind: "file",
|
|
18473
|
-
status: "unbridged",
|
|
18474
|
-
problem: "instructions-unbridged"
|
|
18475
|
-
};
|
|
18476
|
-
}
|
|
18477
18274
|
/** Reads a dotted key out of parsed JSON without asserting anything about the rest of the file. */
|
|
18478
18275
|
function valueAt(settings, key) {
|
|
18479
18276
|
return key.split(".").reduce((value, segment) => {
|
|
@@ -18481,6 +18278,14 @@ function valueAt(settings, key) {
|
|
|
18481
18278
|
return value[segment];
|
|
18482
18279
|
}, settings);
|
|
18483
18280
|
}
|
|
18281
|
+
function exists(target) {
|
|
18282
|
+
try {
|
|
18283
|
+
lstatSync(target);
|
|
18284
|
+
return true;
|
|
18285
|
+
} catch {
|
|
18286
|
+
return false;
|
|
18287
|
+
}
|
|
18288
|
+
}
|
|
18484
18289
|
function inspectSettingsEntry(target, key) {
|
|
18485
18290
|
let source;
|
|
18486
18291
|
try {
|
|
@@ -18508,21 +18313,45 @@ function inspectSettingsEntry(target, key) {
|
|
|
18508
18313
|
problem: "instructions-unbridged"
|
|
18509
18314
|
};
|
|
18510
18315
|
}
|
|
18511
|
-
|
|
18512
|
-
|
|
18513
|
-
|
|
18514
|
-
|
|
18515
|
-
}
|
|
18516
|
-
|
|
18517
|
-
|
|
18316
|
+
function inspectShadow(target) {
|
|
18317
|
+
let stats;
|
|
18318
|
+
try {
|
|
18319
|
+
stats = lstatSync(target);
|
|
18320
|
+
} catch {
|
|
18321
|
+
return;
|
|
18322
|
+
}
|
|
18323
|
+
if (stats.isSymbolicLink()) return readlinkSync(target) === "AGENTS.md" ? {
|
|
18324
|
+
kind: "symlink",
|
|
18325
|
+
status: "superseded",
|
|
18326
|
+
problem: "instructions-superseded"
|
|
18327
|
+
} : {
|
|
18328
|
+
kind: "symlink",
|
|
18329
|
+
status: "shadowing",
|
|
18330
|
+
problem: "instructions-shadowing"
|
|
18331
|
+
};
|
|
18332
|
+
let body;
|
|
18333
|
+
try {
|
|
18334
|
+
body = readFileSync(target, "utf8");
|
|
18335
|
+
} catch {
|
|
18336
|
+
return {
|
|
18337
|
+
kind: "file",
|
|
18338
|
+
status: "unreadable",
|
|
18339
|
+
problem: "instructions-unreadable"
|
|
18340
|
+
};
|
|
18341
|
+
}
|
|
18342
|
+
return body.split("\n").some((line) => line.trim() === `@AGENTS.md`) ? {
|
|
18343
|
+
kind: "import",
|
|
18344
|
+
status: "superseded",
|
|
18345
|
+
problem: "instructions-superseded"
|
|
18346
|
+
} : {
|
|
18347
|
+
kind: "file",
|
|
18348
|
+
status: "shadowing",
|
|
18349
|
+
problem: "instructions-shadowing"
|
|
18350
|
+
};
|
|
18518
18351
|
}
|
|
18519
18352
|
/**
|
|
18520
|
-
*
|
|
18521
|
-
*
|
|
18522
|
-
* Losing one of these is quieter than losing a skills bridge and costs more: a harness with no
|
|
18523
|
-
* instruction bridge reads none of the repository's instructions, and says nothing about it. The
|
|
18524
|
-
* checks are read-only, and no repair is a command — rewriting an instruction file is the `init`
|
|
18525
|
-
* skill's judgment.
|
|
18353
|
+
* Checks two things: whether each instruction bridge resolves, and whether anything suppresses a
|
|
18354
|
+
* harness that reads `AGENTS.md` natively.
|
|
18526
18355
|
*/
|
|
18527
18356
|
function diagnoseInstructions(root, harnesses, cli) {
|
|
18528
18357
|
const bridged = harnesses.map((harness) => ({
|
|
@@ -18531,11 +18360,9 @@ function diagnoseInstructions(root, harnesses, cli) {
|
|
|
18531
18360
|
})).filter((harness) => Boolean(harness.bridge));
|
|
18532
18361
|
const instructions = [];
|
|
18533
18362
|
const findings = [];
|
|
18534
|
-
|
|
18535
|
-
|
|
18536
|
-
|
|
18537
|
-
};
|
|
18538
|
-
if (!agentsFileDirectories(root).includes("")) {
|
|
18363
|
+
const directories = agentsFileDirectories(root);
|
|
18364
|
+
const shadowsAtRoot = harnesses.some((harness) => (harness.project.shadowedBy ?? []).some((shadow) => exists(join(root, shadow))));
|
|
18365
|
+
if ((bridged.length || shadowsAtRoot) && !directories.includes("")) {
|
|
18539
18366
|
const { detail, repair } = repairFor("no-instructions");
|
|
18540
18367
|
findings.push({
|
|
18541
18368
|
path: canonicalInstructions,
|
|
@@ -18544,24 +18371,42 @@ function diagnoseInstructions(root, harnesses, cli) {
|
|
|
18544
18371
|
repair: repair({ file: canonicalInstructions }, cli)
|
|
18545
18372
|
});
|
|
18546
18373
|
}
|
|
18547
|
-
for (const { name, bridge } of bridged)
|
|
18548
|
-
const inspection =
|
|
18374
|
+
for (const { name, bridge } of bridged) {
|
|
18375
|
+
const inspection = inspectSettingsEntry(join(root, bridge.path), bridge.key);
|
|
18549
18376
|
instructions.push({
|
|
18550
18377
|
harness: name,
|
|
18551
|
-
path,
|
|
18378
|
+
path: bridge.path,
|
|
18552
18379
|
kind: inspection.kind,
|
|
18553
18380
|
status: inspection.status
|
|
18554
18381
|
});
|
|
18555
18382
|
if (inspection.problem) {
|
|
18556
18383
|
const { detail, repair } = repairFor(inspection.problem);
|
|
18557
18384
|
findings.push({
|
|
18558
|
-
path,
|
|
18385
|
+
path: bridge.path,
|
|
18559
18386
|
problem: inspection.problem,
|
|
18560
18387
|
detail,
|
|
18561
|
-
repair: repair({ file: path }, cli)
|
|
18388
|
+
repair: repair({ file: bridge.path }, cli)
|
|
18562
18389
|
});
|
|
18563
18390
|
}
|
|
18564
18391
|
}
|
|
18392
|
+
for (const harness of harnesses) for (const shadow of harness.project.shadowedBy ?? []) for (const directory of directories) {
|
|
18393
|
+
const path = directory ? posix.join(directory, shadow) : shadow;
|
|
18394
|
+
const inspection = inspectShadow(join(root, path));
|
|
18395
|
+
if (!inspection) continue;
|
|
18396
|
+
instructions.push({
|
|
18397
|
+
harness: harness.name,
|
|
18398
|
+
path,
|
|
18399
|
+
kind: inspection.kind,
|
|
18400
|
+
status: inspection.status
|
|
18401
|
+
});
|
|
18402
|
+
const { detail, repair } = repairFor(inspection.problem);
|
|
18403
|
+
findings.push({
|
|
18404
|
+
path,
|
|
18405
|
+
problem: inspection.problem,
|
|
18406
|
+
detail,
|
|
18407
|
+
repair: repair({ file: path }, cli)
|
|
18408
|
+
});
|
|
18409
|
+
}
|
|
18565
18410
|
return {
|
|
18566
18411
|
instructions,
|
|
18567
18412
|
findings
|
|
@@ -18589,25 +18434,20 @@ function lines(output) {
|
|
|
18589
18434
|
return (output ?? "").split("\n").filter(Boolean);
|
|
18590
18435
|
}
|
|
18591
18436
|
/**
|
|
18592
|
-
*
|
|
18593
|
-
*
|
|
18594
|
-
* commits.
|
|
18437
|
+
* Every method degrades to "cannot tell" rather than throwing, so `doctor` still reports on a
|
|
18438
|
+
* tarball or a repository with no commits.
|
|
18595
18439
|
*/
|
|
18596
18440
|
var GitBridgeState = class {
|
|
18597
18441
|
root;
|
|
18598
|
-
/**
|
|
18599
|
-
|
|
18442
|
+
/** `''` at the top, `undefined` outside a repository. */
|
|
18443
|
+
repoRootPrefix;
|
|
18600
18444
|
constructor(root) {
|
|
18601
18445
|
this.root = root;
|
|
18602
|
-
this.
|
|
18446
|
+
this.repoRootPrefix = git(root, ["rev-parse", "--show-prefix"])?.trim();
|
|
18603
18447
|
}
|
|
18604
|
-
/**
|
|
18605
|
-
* `init --copy` marks a bridge skip-worktree so a machine-local copy over a tracked symlink stops
|
|
18606
|
-
* showing as a pending change. The bit is a hint that some checkouts clear, so it is verified
|
|
18607
|
-
* rather than assumed.
|
|
18608
|
-
*/
|
|
18448
|
+
/** The skip-worktree bit is a hint some checkouts clear, so it is verified rather than assumed. */
|
|
18609
18449
|
trackingOf(path) {
|
|
18610
|
-
if (this.
|
|
18450
|
+
if (this.repoRootPrefix === void 0) return "untracked";
|
|
18611
18451
|
const entries = lines(git(this.root, [
|
|
18612
18452
|
"ls-files",
|
|
18613
18453
|
"-v",
|
|
@@ -18618,13 +18458,11 @@ var GitBridgeState = class {
|
|
|
18618
18458
|
return entries.every((entry) => entry.startsWith("S")) ? "skip-worktree" : "tracked";
|
|
18619
18459
|
}
|
|
18620
18460
|
/**
|
|
18621
|
-
*
|
|
18622
|
-
*
|
|
18623
|
-
* Outside a repository nothing is ignored, which is the same "cannot tell" degradation the rest
|
|
18624
|
-
* of this class uses.
|
|
18461
|
+
* Asked of git rather than matched against the file, so a rule on a parent directory is caught
|
|
18462
|
+
* too.
|
|
18625
18463
|
*/
|
|
18626
18464
|
isIgnored(path) {
|
|
18627
|
-
if (this.
|
|
18465
|
+
if (this.repoRootPrefix === void 0) return false;
|
|
18628
18466
|
return git(this.root, [
|
|
18629
18467
|
"check-ignore",
|
|
18630
18468
|
"-q",
|
|
@@ -18633,15 +18471,11 @@ var GitBridgeState = class {
|
|
|
18633
18471
|
]) !== void 0;
|
|
18634
18472
|
}
|
|
18635
18473
|
/**
|
|
18636
|
-
*
|
|
18637
|
-
*
|
|
18638
|
-
*
|
|
18639
|
-
* Bounded like `lastAgreedTree` is: a baseline that is not in the recent history of the two files
|
|
18640
|
-
* is not a baseline anyone can act on, and walking a whole repository to prove its absence costs
|
|
18641
|
-
* more than the answer is worth.
|
|
18474
|
+
* Bounded to the last 200 commits: a baseline outside that range costs more to find than it's
|
|
18475
|
+
* worth acting on.
|
|
18642
18476
|
*/
|
|
18643
18477
|
commitsTouching(paths) {
|
|
18644
|
-
if (this.
|
|
18478
|
+
if (this.repoRootPrefix === void 0) return [];
|
|
18645
18479
|
return lines(git(this.root, [
|
|
18646
18480
|
"rev-list",
|
|
18647
18481
|
"-n",
|
|
@@ -18653,15 +18487,11 @@ var GitBridgeState = class {
|
|
|
18653
18487
|
}
|
|
18654
18488
|
/** One file's content at one commit, or `undefined` when it did not exist there. */
|
|
18655
18489
|
contentAt(commit, path) {
|
|
18656
|
-
if (this.
|
|
18657
|
-
return git(this.root, ["show", `${commit}:${this.
|
|
18490
|
+
if (this.repoRootPrefix === void 0) return void 0;
|
|
18491
|
+
return git(this.root, ["show", `${commit}:${this.repoRootPrefix}${path}`]);
|
|
18658
18492
|
}
|
|
18659
|
-
/**
|
|
18660
|
-
* Names the side that moved by finding the newest commit whose two trees agreed, then asking which
|
|
18661
|
-
* of the working directories still matches it.
|
|
18662
|
-
*/
|
|
18663
18493
|
directionOf(bridge, canonical) {
|
|
18664
|
-
if (this.
|
|
18494
|
+
if (this.repoRootPrefix === void 0) return "unknown";
|
|
18665
18495
|
const base = this.lastAgreedTree(bridge, canonical);
|
|
18666
18496
|
if (base === void 0) return "unknown";
|
|
18667
18497
|
const bridgeMoved = !this.matchesTree(bridge, base);
|
|
@@ -18685,20 +18515,20 @@ var GitBridgeState = class {
|
|
|
18685
18515
|
"rev-parse",
|
|
18686
18516
|
"-q",
|
|
18687
18517
|
"--verify",
|
|
18688
|
-
`${commit}:${this.
|
|
18518
|
+
`${commit}:${this.repoRootPrefix}${bridge}`
|
|
18689
18519
|
])?.trim();
|
|
18690
18520
|
if (!bridgeObject) continue;
|
|
18691
18521
|
if (bridgeObject === git(this.root, [
|
|
18692
18522
|
"rev-parse",
|
|
18693
18523
|
"-q",
|
|
18694
18524
|
"--verify",
|
|
18695
|
-
`${commit}:${this.
|
|
18525
|
+
`${commit}:${this.repoRootPrefix}${canonical}`
|
|
18696
18526
|
])?.trim()) return bridgeObject;
|
|
18697
18527
|
}
|
|
18698
18528
|
}
|
|
18699
18529
|
/**
|
|
18700
|
-
* Compares
|
|
18701
|
-
*
|
|
18530
|
+
* Compares by object id via `hash-object` rather than hashing in-process, so it follows
|
|
18531
|
+
* whatever object format the repository uses.
|
|
18702
18532
|
*/
|
|
18703
18533
|
matchesTree(directory, tree) {
|
|
18704
18534
|
const expected = new Map(lines(git(this.root, [
|
|
@@ -18780,9 +18610,8 @@ function inspect(target, path, canonical, git) {
|
|
|
18780
18610
|
}
|
|
18781
18611
|
}
|
|
18782
18612
|
/**
|
|
18783
|
-
*
|
|
18784
|
-
*
|
|
18785
|
-
* moved, or repaired, so the caller decides what to run from the repair each finding carries.
|
|
18613
|
+
* Read-only: nothing is created, moved, or repaired — the caller decides what to run from the
|
|
18614
|
+
* repair each finding carries.
|
|
18786
18615
|
*/
|
|
18787
18616
|
function diagnoseBridges({ root, harnesses: preferred = [], cli }) {
|
|
18788
18617
|
const canonical = join(root, ".agents", "skills");
|
|
@@ -18839,11 +18668,12 @@ function diagnoseBridges({ root, harnesses: preferred = [], cli }) {
|
|
|
18839
18668
|
//#endregion
|
|
18840
18669
|
//#region src/diagnose-bridges/doctor.command.ts
|
|
18841
18670
|
/**
|
|
18842
|
-
* AXI §5: the healthy answer states the zero with context, so an agent
|
|
18843
|
-
*
|
|
18671
|
+
* AXI §5: the healthy answer states the zero with context, so an agent doesn't re-run to confirm
|
|
18672
|
+
* nothing was wrong.
|
|
18844
18673
|
*/
|
|
18845
|
-
function buildDoctorReport(bin, result, configuration = []) {
|
|
18674
|
+
function buildDoctorReport(bin, result, configuration = [], overrides = []) {
|
|
18846
18675
|
const findings = [...result.findings, ...configuration];
|
|
18676
|
+
const governances = overrides.length ? overrides : "0 governance overrides — no .agents/governances at project, user, or machine scope";
|
|
18847
18677
|
if (!findings.length) {
|
|
18848
18678
|
const count = result.bridges.length + result.instructions.length;
|
|
18849
18679
|
const bridges = count === 1 ? "the 1 bridge resolves" : `all ${count} bridges resolve`;
|
|
@@ -18851,6 +18681,7 @@ function buildDoctorReport(bin, result, configuration = []) {
|
|
|
18851
18681
|
bin,
|
|
18852
18682
|
bridges: result.bridges,
|
|
18853
18683
|
instructions: result.instructions,
|
|
18684
|
+
governances,
|
|
18854
18685
|
findings: `0 problems found — ${bridges} and the configuration around them is current`
|
|
18855
18686
|
};
|
|
18856
18687
|
}
|
|
@@ -18858,6 +18689,7 @@ function buildDoctorReport(bin, result, configuration = []) {
|
|
|
18858
18689
|
bin,
|
|
18859
18690
|
bridges: result.bridges,
|
|
18860
18691
|
instructions: result.instructions,
|
|
18692
|
+
governances,
|
|
18861
18693
|
...result.divergence.length ? { divergence: result.divergence } : {},
|
|
18862
18694
|
findings: findings.map(({ path, problem, detail }) => ({
|
|
18863
18695
|
path,
|
|
@@ -18890,6 +18722,7 @@ const doctorCommand = command({
|
|
|
18890
18722
|
const format = parseFormat(args.format);
|
|
18891
18723
|
const harnesses = parseHarnesses(args.harness);
|
|
18892
18724
|
const root = args.root ?? process.cwd();
|
|
18725
|
+
const home = homedir();
|
|
18893
18726
|
const result = diagnoseBridges({
|
|
18894
18727
|
root,
|
|
18895
18728
|
...harnesses.length ? { harnesses } : {},
|
|
@@ -18912,7 +18745,17 @@ const doctorCommand = command({
|
|
|
18912
18745
|
cli: commandInvocation
|
|
18913
18746
|
})
|
|
18914
18747
|
];
|
|
18915
|
-
|
|
18748
|
+
const overrides = listGovernances(overrideLayers(governanceLayers({
|
|
18749
|
+
root,
|
|
18750
|
+
home,
|
|
18751
|
+
platform: process.platform,
|
|
18752
|
+
programData: process.env["ProgramData"]
|
|
18753
|
+
}))).map(({ name, scope, path }) => ({
|
|
18754
|
+
name,
|
|
18755
|
+
scope,
|
|
18756
|
+
path: collapseHome(home, path)
|
|
18757
|
+
}));
|
|
18758
|
+
writeResult(buildDoctorReport(displayBinPath(home, process.argv[1]), result, configuration, overrides), format);
|
|
18916
18759
|
return exitCodes.success;
|
|
18917
18760
|
} catch (error) {
|
|
18918
18761
|
process.stderr.write(`error: ${error instanceof Error ? error.message : "Harness diagnosis failed."}\n`);
|
|
@@ -18921,10 +18764,111 @@ const doctorCommand = command({
|
|
|
18921
18764
|
}
|
|
18922
18765
|
});
|
|
18923
18766
|
//#endregion
|
|
18767
|
+
//#region src/governance-overrides/governance.command.ts
|
|
18768
|
+
function layersFor(args) {
|
|
18769
|
+
const layers = governanceLayers({
|
|
18770
|
+
root: args.root ?? process.cwd(),
|
|
18771
|
+
home: homedir(),
|
|
18772
|
+
platform: process.platform,
|
|
18773
|
+
programData: process.env["ProgramData"]
|
|
18774
|
+
});
|
|
18775
|
+
return args["overrides-only"] ? overrideLayers(layers) : layers;
|
|
18776
|
+
}
|
|
18777
|
+
const DEPRECATED_MANAGED = "deprecated — move these documents to the managed layer above";
|
|
18778
|
+
const rootOption = {
|
|
18779
|
+
description: "Repository or package directory. Defaults to the current directory.",
|
|
18780
|
+
type: optionalType(stringType())
|
|
18781
|
+
};
|
|
18782
|
+
const overridesOnlyOption = {
|
|
18783
|
+
description: "Resolve only the project, user, and machine-wide layers. Never returns a governance this package ships, and exits non-zero when no layer has one.",
|
|
18784
|
+
type: optionalType(booleanType())
|
|
18785
|
+
};
|
|
18786
|
+
const governanceCommand = command({
|
|
18787
|
+
name: "governance",
|
|
18788
|
+
description: "Read the governance documents the project, user, and machine-wide override layers hold. Read-only.",
|
|
18789
|
+
commands: [command({
|
|
18790
|
+
name: "list",
|
|
18791
|
+
description: "List every governance the override layers hold, each at the layer that would win.",
|
|
18792
|
+
options: {
|
|
18793
|
+
root: rootOption,
|
|
18794
|
+
"overrides-only": overridesOnlyOption,
|
|
18795
|
+
format: {
|
|
18796
|
+
description: "Output format: toon (default), json, or text for a human-readable report.",
|
|
18797
|
+
type: optionalType(stringType()),
|
|
18798
|
+
default: "toon"
|
|
18799
|
+
}
|
|
18800
|
+
},
|
|
18801
|
+
run(args) {
|
|
18802
|
+
try {
|
|
18803
|
+
const format = parseFormat(args.format);
|
|
18804
|
+
const home = homedir();
|
|
18805
|
+
const layers = layersFor(args);
|
|
18806
|
+
const entries = listGovernances(layers);
|
|
18807
|
+
writeResult({
|
|
18808
|
+
layers: layers.map(({ scope, dir }) => ({
|
|
18809
|
+
scope,
|
|
18810
|
+
path: collapseHome(home, dir),
|
|
18811
|
+
status: scope === "managed-deprecated" ? DEPRECATED_MANAGED : ""
|
|
18812
|
+
})),
|
|
18813
|
+
governances: entries.length ? entries.map(({ name, scope, path }) => ({
|
|
18814
|
+
name,
|
|
18815
|
+
scope,
|
|
18816
|
+
path: collapseHome(home, path)
|
|
18817
|
+
})) : "0 governances — no layer holds one"
|
|
18818
|
+
}, format);
|
|
18819
|
+
return exitCodes.success;
|
|
18820
|
+
} catch (error) {
|
|
18821
|
+
process.stderr.write(`error: ${error instanceof Error ? error.message : "Governance listing failed."}\n`);
|
|
18822
|
+
return exitCodes.error;
|
|
18823
|
+
}
|
|
18824
|
+
}
|
|
18825
|
+
}), command({
|
|
18826
|
+
name: "show",
|
|
18827
|
+
description: "Print one governance document, resolved through the layers in order.",
|
|
18828
|
+
arguments: [{
|
|
18829
|
+
name: "name",
|
|
18830
|
+
description: "The governance name, without the `.md` extension.",
|
|
18831
|
+
type: stringType()
|
|
18832
|
+
}],
|
|
18833
|
+
options: {
|
|
18834
|
+
root: rootOption,
|
|
18835
|
+
"overrides-only": overridesOnlyOption,
|
|
18836
|
+
format: {
|
|
18837
|
+
description: "Output format: text (default) writes the document itself; toon and json wrap it with the layer it came from.",
|
|
18838
|
+
type: optionalType(stringType()),
|
|
18839
|
+
default: "text"
|
|
18840
|
+
}
|
|
18841
|
+
},
|
|
18842
|
+
run(args) {
|
|
18843
|
+
try {
|
|
18844
|
+
const format = parseFormat(args.format);
|
|
18845
|
+
const name = parseGovernanceName(args.name);
|
|
18846
|
+
const found = resolveGovernance(name, layersFor(args));
|
|
18847
|
+
if (!found) {
|
|
18848
|
+
process.stderr.write(args["overrides-only"] ? `error: no override for governance "${name}" in the project, user, or machine-wide layer.\n` : `error: no governance named "${name}" in any layer.\n`);
|
|
18849
|
+
return exitCodes.error;
|
|
18850
|
+
}
|
|
18851
|
+
if (format === "text") writeDocument(found.content);
|
|
18852
|
+
else writeResult({
|
|
18853
|
+
name: found.name,
|
|
18854
|
+
scope: found.scope,
|
|
18855
|
+
path: collapseHome(homedir(), found.path),
|
|
18856
|
+
content: found.content
|
|
18857
|
+
}, format);
|
|
18858
|
+
return exitCodes.success;
|
|
18859
|
+
} catch (error) {
|
|
18860
|
+
process.stderr.write(`error: ${error instanceof Error ? error.message : "Governance lookup failed."}\n`);
|
|
18861
|
+
return exitCodes.error;
|
|
18862
|
+
}
|
|
18863
|
+
}
|
|
18864
|
+
})]
|
|
18865
|
+
});
|
|
18866
|
+
//#endregion
|
|
18924
18867
|
//#region src/initialize-harnesses/initialize-harnesses.ts
|
|
18925
18868
|
function initializeHarnesses({ root, harnesses: preferred = [], copy = false, force = false }) {
|
|
18926
18869
|
const canonicalSkills = join(root, ".agents", "skills");
|
|
18927
18870
|
const skills = countSkills(canonicalSkills);
|
|
18871
|
+
const governances = countProjectGovernances(root);
|
|
18928
18872
|
const harnesses = selectHarnesses(root, preferred);
|
|
18929
18873
|
const { linked, skipped } = projectSkills({
|
|
18930
18874
|
root,
|
|
@@ -18944,6 +18888,7 @@ function initializeHarnesses({ root, harnesses: preferred = [], copy = false, fo
|
|
|
18944
18888
|
replacedBy: harness.deprecated
|
|
18945
18889
|
})),
|
|
18946
18890
|
skills,
|
|
18891
|
+
governances,
|
|
18947
18892
|
copied: copy
|
|
18948
18893
|
};
|
|
18949
18894
|
}
|
|
@@ -18995,27 +18940,16 @@ const initCommand = command({
|
|
|
18995
18940
|
//#endregion
|
|
18996
18941
|
//#region src/cli.ts
|
|
18997
18942
|
const version = typeof __PACKAGE_VERSION__ !== "undefined" ? __PACKAGE_VERSION__ : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
18998
|
-
/**
|
|
18999
|
-
* A factory rather than a module-level constant: `cli()` builds state, and state built at import
|
|
19000
|
-
* time is shared by every later call in the process.
|
|
19001
|
-
*/
|
|
19002
18943
|
function app() {
|
|
19003
18944
|
return cli({
|
|
19004
18945
|
name: "buddy-agent-harness",
|
|
19005
18946
|
version,
|
|
19006
18947
|
description: "Initialize agent harness skill compatibility in consumer repositories."
|
|
19007
|
-
}).command(initCommand).command(doctorCommand).command(depPluginsCommand);
|
|
18948
|
+
}).command(initCommand).command(doctorCommand).command(depPluginsCommand).command(governanceCommand);
|
|
19008
18949
|
}
|
|
19009
18950
|
/**
|
|
19010
|
-
* The application boundary: argv in, exit code out
|
|
19011
|
-
*
|
|
19012
|
-
* The exit code is returned rather than written so that a caller which is not the process — a skill
|
|
19013
|
-
* launcher, a test — can run a command and learn how it went. `bin/buddy-agent-harness.mjs` is the
|
|
19014
|
-
* only place that turns the returned code back into a process outcome.
|
|
19015
|
-
*
|
|
19016
|
-
* One exit path this return cannot carry: `clibuilder` answers an unknown option or an unknown
|
|
19017
|
-
* command by printing help and writing `process.exitCode` itself, returning nothing. `run` reports
|
|
19018
|
-
* `0` there, which is why `bin` and the launchers apply the returned code only when it is non-zero.
|
|
18951
|
+
* The application boundary: argv in, exit code out — returned rather than written, so a caller that
|
|
18952
|
+
* is not the process (a skill launcher, a test) can act on it.
|
|
19019
18953
|
*/
|
|
19020
18954
|
async function run(argv) {
|
|
19021
18955
|
try {
|