buddy-agent-harness 0.11.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 -553
- package/dist/index.d.mts +196 -329
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +517 -631
- 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 +12 -38
- 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 +19 -60
- 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 +22 -75
- 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,20 +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.
|
|
17878
|
-
*/
|
|
17879
|
-
/**
|
|
17880
|
-
* Exported for `mcp-inventory.ts`, which reads a handful of sources this module's own `McpConfig`
|
|
17881
|
-
* shape cannot describe — a plugin's flat `.mcp.json`, a project entry nested inside `~/.claude.json`,
|
|
17882
|
-
* and hosts whose `command` is an array or an object rather than a string. Each of those normalizes
|
|
17883
|
-
* its raw entry into this same field shape before calling this function, so one reader still decides
|
|
17884
|
-
* what counts as `transport`, `args`, and the rest.
|
|
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.
|
|
17885
17671
|
*/
|
|
17886
17672
|
function serverFrom(entry) {
|
|
17887
17673
|
const args = Array.isArray(entry["args"]) && entry["args"].every((item) => typeof item === "string");
|
|
@@ -17907,16 +17693,8 @@ function serversUnder(document, key) {
|
|
|
17907
17693
|
return new Map(Object.entries(table).filter(([, entry]) => isRecord(entry)).map(([name, entry]) => [name, serverFrom(entry)]));
|
|
17908
17694
|
}
|
|
17909
17695
|
/**
|
|
17910
|
-
*
|
|
17911
|
-
*
|
|
17912
|
-
* `smol-toml` reports `line` and `column` on the error alongside a `codeblock` quoting the source,
|
|
17913
|
-
* and its `message` embeds that same code block. Both are unusable: in a file of MCP configuration
|
|
17914
|
-
* the line a parser failed on is exactly the line holding the credential. Only the two numbers are
|
|
17915
|
-
* read, and anything thrown that does not carry them is reported without a position rather than by
|
|
17916
|
-
* reaching for its message.
|
|
17917
|
-
*
|
|
17918
|
-
* Exported because that restriction is the point of the function rather than an implementation
|
|
17919
|
-
* 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.
|
|
17920
17698
|
*/
|
|
17921
17699
|
function positionOf(error) {
|
|
17922
17700
|
if (!isRecord(error)) return void 0;
|
|
@@ -17942,12 +17720,8 @@ function parseGoldenSet(source) {
|
|
|
17942
17720
|
}
|
|
17943
17721
|
}
|
|
17944
17722
|
/**
|
|
17945
|
-
* One harness's
|
|
17946
|
-
*
|
|
17947
|
-
* file as MCP would report the rest of a user's settings as servers it does not recognize.
|
|
17948
|
-
*
|
|
17949
|
-
* JSON is parsed with comments stripped, because Gemini CLI's loader strips them too and a
|
|
17950
|
-
* 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.
|
|
17951
17725
|
*/
|
|
17952
17726
|
function parseTarget(config, source) {
|
|
17953
17727
|
if (source === void 0) return { kind: "absent" };
|
|
@@ -17968,49 +17742,26 @@ function parseTarget(config, source) {
|
|
|
17968
17742
|
}
|
|
17969
17743
|
//#endregion
|
|
17970
17744
|
//#region src/diagnose-mcp/mcp-baseline.ts
|
|
17971
|
-
/** Where the record of what was last written to each target lives, beside the golden set it came from. */
|
|
17972
|
-
const projectionRecordPath = ".agents/buddy-agent-harness/mcp.projected.json";
|
|
17973
17745
|
/**
|
|
17974
|
-
*
|
|
17975
|
-
*
|
|
17976
|
-
* It exists because git can only answer for a file it can see. Harnesses write their MCP
|
|
17977
|
-
* configuration themselves, at user scope, into files that are often untracked or ignored outright,
|
|
17978
|
-
* and for those "which side moved since they last agreed" has no answer in history. A record
|
|
17979
|
-
* written at projection time has one, exactly, because it *is* what was projected.
|
|
17980
|
-
*
|
|
17981
|
-
* A malformed record answers nothing rather than throwing. It is a cache of an answer, not the
|
|
17982
|
-
* answer, and a diagnosis that dies because its cache is corrupt is worse than one that says
|
|
17983
|
-
* `unknown`.
|
|
17746
|
+
* Where the record of what was last written to each target lives, beside the golden set it came
|
|
17747
|
+
* from.
|
|
17984
17748
|
*/
|
|
17749
|
+
const projectionRecordPath = ".agents/buddy-agent-harness/mcp.projected.json";
|
|
17985
17750
|
function parseProjectionRecord(source) {
|
|
17986
17751
|
const document = source === void 0 ? void 0 : parseJsonWithComments(source);
|
|
17987
17752
|
const targets = isRecord(document) ? document["targets"] : void 0;
|
|
17988
17753
|
if (!isRecord(targets)) return /* @__PURE__ */ new Map();
|
|
17989
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]))]));
|
|
17990
17755
|
}
|
|
17991
|
-
/**
|
|
17992
|
-
* Which side moved, for one server and one field.
|
|
17993
|
-
*
|
|
17994
|
-
* The record is asked first, because it records exactly what was written and therefore answers
|
|
17995
|
-
* exactly. Where it holds nothing, a **tracked** target falls back to git: the newest commit where
|
|
17996
|
-
* the golden set and the target agreed on that field, then which working tree still matches it.
|
|
17997
|
-
* Where neither can answer, the direction is `unknown` and the finding says so — naming a side on a
|
|
17998
|
-
* guess would send a reconcile at the wrong file.
|
|
17999
|
-
*/
|
|
18000
17756
|
var McpBaseline = class {
|
|
18001
17757
|
options;
|
|
18002
17758
|
constructor(options) {
|
|
18003
17759
|
this.options = options;
|
|
18004
17760
|
}
|
|
18005
17761
|
/**
|
|
18006
|
-
*
|
|
18007
|
-
*
|
|
18008
|
-
*
|
|
18009
|
-
* every one of those walks reads the same two files at the same commits. Unmemoized, three
|
|
18010
|
-
* targets against five servers with two diverged fields each over fifty commits of history is
|
|
18011
|
-
* three thousand `git show` calls and as many parses — on the command the `doctor` skill says is
|
|
18012
|
-
* cheap enough for a session-start hook. One instance serves one diagnosis, so the memo lives and
|
|
18013
|
-
* 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.
|
|
18014
17765
|
*/
|
|
18015
17766
|
parsed = /* @__PURE__ */ new Map();
|
|
18016
17767
|
walked = /* @__PURE__ */ new Map();
|
|
@@ -18028,9 +17779,8 @@ var McpBaseline = class {
|
|
|
18028
17779
|
return this.lastAgreed(config, name, field);
|
|
18029
17780
|
}
|
|
18030
17781
|
/**
|
|
18031
|
-
*
|
|
18032
|
-
*
|
|
18033
|
-
* 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.
|
|
18034
17784
|
*/
|
|
18035
17785
|
lastAgreed(config, name, field) {
|
|
18036
17786
|
for (const commit of this.commitsTouching(config)) {
|
|
@@ -18061,28 +17811,18 @@ var McpBaseline = class {
|
|
|
18061
17811
|
//#endregion
|
|
18062
17812
|
//#region src/diagnose-mcp/mcp-secrets.ts
|
|
18063
17813
|
/**
|
|
18064
|
-
* Finding a literal credential in MCP configuration, without ever handling one
|
|
18065
|
-
*
|
|
18066
|
-
*
|
|
18067
|
-
* session and from there into transcripts. A value it echoes is amplified far past the file it came
|
|
18068
|
-
* from. Every function here answers **where**, never **what**: the return is a field path, and the
|
|
18069
|
-
* value never leaves this module.
|
|
18070
|
-
*
|
|
18071
|
-
* That is also why the redaction lives here rather than in a formatter. `doctor` renders three ways
|
|
18072
|
-
* — TOON, `--format text`, and `--format json` — and trusting each of them to mask is three chances
|
|
18073
|
-
* 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.
|
|
18074
17817
|
*/
|
|
18075
17818
|
/**
|
|
18076
|
-
*
|
|
18077
|
-
*
|
|
18078
|
-
* 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.
|
|
18079
17821
|
*/
|
|
18080
17822
|
const credentialWord = /token|secret|password|passwd|credential/i;
|
|
18081
17823
|
/**
|
|
18082
|
-
*
|
|
18083
|
-
*
|
|
18084
|
-
* `KEYSTONE_URL`. Matching them as substrings reports those, and a scanner that cries wolf on a
|
|
18085
|
-
* 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`.
|
|
18086
17826
|
*/
|
|
18087
17827
|
const credentialSegment = /* @__PURE__ */ new Set([
|
|
18088
17828
|
"key",
|
|
@@ -18096,19 +17836,15 @@ function segmentsOf(key) {
|
|
|
18096
17836
|
return key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((segment) => segment.toLowerCase());
|
|
18097
17837
|
}
|
|
18098
17838
|
/**
|
|
18099
|
-
* A value that
|
|
18100
|
-
*
|
|
18101
|
-
*
|
|
18102
|
-
* Classification is by shape, not by content. An entropy test on the value would be a guess about
|
|
18103
|
-
* a string this module has already decided not to look at, and it would fail in both directions —
|
|
18104
|
-
* 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.
|
|
18105
17842
|
*/
|
|
18106
17843
|
const reference = /\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*/;
|
|
18107
17844
|
/**
|
|
18108
|
-
*
|
|
18109
|
-
*
|
|
18110
|
-
*
|
|
18111
|
-
* 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.
|
|
18112
17848
|
*/
|
|
18113
17849
|
function holdsLiteral(value) {
|
|
18114
17850
|
return !reference.test(value);
|
|
@@ -18117,11 +17853,8 @@ function credentialKey(key) {
|
|
|
18117
17853
|
return credentialWord.test(key) || segmentsOf(key).some((segment) => credentialSegment.has(segment));
|
|
18118
17854
|
}
|
|
18119
17855
|
/**
|
|
18120
|
-
*
|
|
18121
|
-
*
|
|
18122
|
-
*
|
|
18123
|
-
* A URL that does not parse is not reported. It is malformed rather than leaky, and guessing at its
|
|
18124
|
-
* 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.
|
|
18125
17858
|
*/
|
|
18126
17859
|
function urlHoldsCredential(value) {
|
|
18127
17860
|
let url;
|
|
@@ -18137,12 +17870,7 @@ function urlHoldsCredential(value) {
|
|
|
18137
17870
|
function literalsIn(field, values) {
|
|
18138
17871
|
return Object.entries(values ?? {}).filter(([key, value]) => credentialKey(key) && holdsLiteral(value)).map(([key]) => `${field}.${key}`);
|
|
18139
17872
|
}
|
|
18140
|
-
/**
|
|
18141
|
-
* Every field of one server holding a literal credential, as a path within that server.
|
|
18142
|
-
*
|
|
18143
|
-
* The path is the whole answer. It is never a truncated value: `sk-ab…` is a leak into the same
|
|
18144
|
-
* transcript and buys a reader nothing the field name does not already give them.
|
|
18145
|
-
*/
|
|
17873
|
+
/** Field paths only — never a truncated value; `sk-ab…` is still a leak into the same transcript. */
|
|
18146
17874
|
function credentialFields(server) {
|
|
18147
17875
|
return [
|
|
18148
17876
|
...literalsIn("env", server.env),
|
|
@@ -18157,12 +17885,8 @@ function read(root, path) {
|
|
|
18157
17885
|
return existsSync(absolute) ? readFileSync(absolute, "utf8") : void 0;
|
|
18158
17886
|
}
|
|
18159
17887
|
/**
|
|
18160
|
-
* The distinct MCP files the enabled harnesses read.
|
|
18161
|
-
*
|
|
18162
|
-
* No harness preference is accepted, for the same reason the configuration half accepts none: every
|
|
18163
|
-
* harness documenting an MCP file is already selected without one. Claude Code and Cursor are
|
|
18164
|
-
* selected unconditionally; Codex and Gemini CLI keep their file inside their own detection
|
|
18165
|
-
* 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
|
|
18166
17890
|
* a finding.
|
|
18167
17891
|
*/
|
|
18168
17892
|
function targetsOf(root) {
|
|
@@ -18186,12 +17910,6 @@ function diagnoseMcp({ root, git, cli }) {
|
|
|
18186
17910
|
repair: repair(at, cli)
|
|
18187
17911
|
});
|
|
18188
17912
|
};
|
|
18189
|
-
/**
|
|
18190
|
-
* Credentials are reported for every file that holds servers, the golden set included — a user
|
|
18191
|
-
* pastes a token into whichever file is open, and the golden set is a file. Severity splits on
|
|
18192
|
-
* tracking, because a literal in a tracked file is already a committed credential and moving it
|
|
18193
|
-
* to an environment variable does not un-commit it.
|
|
18194
|
-
*/
|
|
18195
17913
|
const reportSecrets = (path, servers) => {
|
|
18196
17914
|
const committed = git.trackingOf(path) !== "untracked";
|
|
18197
17915
|
for (const [name, server] of servers) for (const field of credentialFields(server)) add({
|
|
@@ -18261,11 +17979,9 @@ const problemOf = {
|
|
|
18261
17979
|
subagent: "nonstandard-subagent"
|
|
18262
17980
|
};
|
|
18263
17981
|
/**
|
|
18264
|
-
*
|
|
18265
|
-
* to
|
|
18266
|
-
*
|
|
18267
|
-
* verbatim. Read from frontmatter rather than assumed, because the two need different conversions
|
|
18268
|
-
* 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.
|
|
18269
17985
|
*/
|
|
18270
17986
|
function scopedByGlobs(body) {
|
|
18271
17987
|
const block = /^---\n([\s\S]*?)\n---/.exec(body);
|
|
@@ -18278,8 +17994,8 @@ function skillFiles(directory) {
|
|
|
18278
17994
|
return filesUnder(directory).filter((file) => file.endsWith("SKILL.md"));
|
|
18279
17995
|
}
|
|
18280
17996
|
/**
|
|
18281
|
-
* A
|
|
18282
|
-
*
|
|
17997
|
+
* A symlinked path is already a projection, not authored config — reporting it would tell a repo to
|
|
17998
|
+
* convert what it already converted.
|
|
18283
17999
|
*/
|
|
18284
18000
|
function authoredHere(path) {
|
|
18285
18001
|
try {
|
|
@@ -18295,10 +18011,8 @@ function artifactFiles(root, artifact) {
|
|
|
18295
18011
|
return (artifact.kind === "skill" ? skillFiles(absolute) : filesUnder(absolute)).map((file) => posix.join(artifact.path, file));
|
|
18296
18012
|
}
|
|
18297
18013
|
/**
|
|
18298
|
-
*
|
|
18299
|
-
*
|
|
18300
|
-
* repository nobody opens in Cursor is still instruction content `AGENTS.md` does not carry — so
|
|
18301
|
-
* 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.
|
|
18302
18016
|
*/
|
|
18303
18017
|
function diagnoseNonstandard({ root, cli }) {
|
|
18304
18018
|
const findings = [];
|
|
@@ -18316,6 +18030,129 @@ function diagnoseNonstandard({ root, cli }) {
|
|
|
18316
18030
|
return findings.sort((left, right) => left.path.localeCompare(right.path));
|
|
18317
18031
|
}
|
|
18318
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
|
|
18319
18156
|
//#region src/skill-projection/skill-projection.ts
|
|
18320
18157
|
function skillNames(skillsDirectory) {
|
|
18321
18158
|
return readdirSync(skillsDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
@@ -18329,10 +18166,8 @@ function pathOccupied(path) {
|
|
|
18329
18166
|
}
|
|
18330
18167
|
}
|
|
18331
18168
|
/**
|
|
18332
|
-
*
|
|
18333
|
-
*
|
|
18334
|
-
* Windows junction, and one reached through a symbolic link in a parent directory all read as the
|
|
18335
|
-
* 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.
|
|
18336
18171
|
*/
|
|
18337
18172
|
function linksTo(target, canonical) {
|
|
18338
18173
|
try {
|
|
@@ -18348,18 +18183,15 @@ function countSkills(canonicalSkills) {
|
|
|
18348
18183
|
return skillNames(canonicalSkills).length;
|
|
18349
18184
|
}
|
|
18350
18185
|
/**
|
|
18351
|
-
*
|
|
18352
|
-
*
|
|
18353
|
-
* 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.
|
|
18354
18188
|
*/
|
|
18355
18189
|
function linkTarget(target, canonicalSkills) {
|
|
18356
18190
|
return platform() === "win32" ? resolve(canonicalSkills) : relative(dirname(target), canonicalSkills);
|
|
18357
18191
|
}
|
|
18358
18192
|
/**
|
|
18359
|
-
*
|
|
18360
|
-
*
|
|
18361
|
-
* every conflicting target, which is what keeps an existing `init --force` doing what it always did.
|
|
18362
|
-
* 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.
|
|
18363
18195
|
*/
|
|
18364
18196
|
function parseForce(value) {
|
|
18365
18197
|
if (value === "true") return true;
|
|
@@ -18367,17 +18199,13 @@ function parseForce(value) {
|
|
|
18367
18199
|
return value.split(",").map((target) => target.trim()).filter((target) => target.length > 0);
|
|
18368
18200
|
}
|
|
18369
18201
|
/**
|
|
18370
|
-
*
|
|
18371
|
-
*
|
|
18372
|
-
* 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.
|
|
18373
18204
|
*/
|
|
18374
18205
|
function namesTarget(root, target, given) {
|
|
18375
18206
|
return resolve(root, given) === resolve(target);
|
|
18376
18207
|
}
|
|
18377
|
-
/**
|
|
18378
|
-
* Points every harness that cannot read `.agents/skills` at the canonical directory. Symlinking is
|
|
18379
|
-
* preferred; a platform that refuses the link falls back to a copy.
|
|
18380
|
-
*/
|
|
18208
|
+
/** Symlinks by default; falls back to a copy where the platform refuses the link. */
|
|
18381
18209
|
function projectSkills({ root, canonicalSkills, harnesses, copy, force }) {
|
|
18382
18210
|
const projections = harnesses.filter((harness) => harness.project.skillsDirectory).map((harness) => ({
|
|
18383
18211
|
harness,
|
|
@@ -18419,12 +18247,8 @@ function projectSkills({ root, canonicalSkills, harnesses, copy, force }) {
|
|
|
18419
18247
|
/** Directories that never hold instructions this repository is responsible for bridging. */
|
|
18420
18248
|
const pruned = /* @__PURE__ */ new Set(["node_modules"]);
|
|
18421
18249
|
/**
|
|
18422
|
-
*
|
|
18423
|
-
*
|
|
18424
|
-
*
|
|
18425
|
-
* Dot-directories are pruned along with `node_modules`. That is not only a speed measure:
|
|
18426
|
-
* `.agents/AGENTS.md` is canonical shared instructions rather than a subtree-scoped file, and
|
|
18427
|
-
* 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.
|
|
18428
18252
|
*/
|
|
18429
18253
|
function agentsFileDirectories(root) {
|
|
18430
18254
|
const found = [];
|
|
@@ -18447,40 +18271,6 @@ function agentsFileDirectories(root) {
|
|
|
18447
18271
|
//#endregion
|
|
18448
18272
|
//#region src/diagnose-bridges/diagnose-instructions.ts
|
|
18449
18273
|
const canonicalInstructions = "AGENTS.md";
|
|
18450
|
-
/**
|
|
18451
|
-
* An import bridges the `AGENTS.md` beside it and nothing else, so the check is per directory
|
|
18452
|
-
* holding one rather than per harness. A line of its own is enough: the harness reference allows
|
|
18453
|
-
* Claude-specific notes below the import, and a file that carries the import plus notes still
|
|
18454
|
-
* bridges.
|
|
18455
|
-
*/
|
|
18456
|
-
function inspectImport(target) {
|
|
18457
|
-
let stats;
|
|
18458
|
-
try {
|
|
18459
|
-
stats = lstatSync(target);
|
|
18460
|
-
} catch {
|
|
18461
|
-
return {
|
|
18462
|
-
kind: "none",
|
|
18463
|
-
status: "missing",
|
|
18464
|
-
problem: "instructions-missing"
|
|
18465
|
-
};
|
|
18466
|
-
}
|
|
18467
|
-
if (stats.isSymbolicLink()) return readlinkSync(target) === "AGENTS.md" ? {
|
|
18468
|
-
kind: "symlink",
|
|
18469
|
-
status: "ok"
|
|
18470
|
-
} : {
|
|
18471
|
-
kind: "symlink",
|
|
18472
|
-
status: "unbridged",
|
|
18473
|
-
problem: "instructions-unbridged"
|
|
18474
|
-
};
|
|
18475
|
-
return readFileSync(target, "utf8").split("\n").some((line) => line.trim() === `@AGENTS.md`) ? {
|
|
18476
|
-
kind: "import",
|
|
18477
|
-
status: "ok"
|
|
18478
|
-
} : {
|
|
18479
|
-
kind: "file",
|
|
18480
|
-
status: "unbridged",
|
|
18481
|
-
problem: "instructions-unbridged"
|
|
18482
|
-
};
|
|
18483
|
-
}
|
|
18484
18274
|
/** Reads a dotted key out of parsed JSON without asserting anything about the rest of the file. */
|
|
18485
18275
|
function valueAt(settings, key) {
|
|
18486
18276
|
return key.split(".").reduce((value, segment) => {
|
|
@@ -18488,6 +18278,14 @@ function valueAt(settings, key) {
|
|
|
18488
18278
|
return value[segment];
|
|
18489
18279
|
}, settings);
|
|
18490
18280
|
}
|
|
18281
|
+
function exists(target) {
|
|
18282
|
+
try {
|
|
18283
|
+
lstatSync(target);
|
|
18284
|
+
return true;
|
|
18285
|
+
} catch {
|
|
18286
|
+
return false;
|
|
18287
|
+
}
|
|
18288
|
+
}
|
|
18491
18289
|
function inspectSettingsEntry(target, key) {
|
|
18492
18290
|
let source;
|
|
18493
18291
|
try {
|
|
@@ -18515,21 +18313,45 @@ function inspectSettingsEntry(target, key) {
|
|
|
18515
18313
|
problem: "instructions-unbridged"
|
|
18516
18314
|
};
|
|
18517
18315
|
}
|
|
18518
|
-
|
|
18519
|
-
|
|
18520
|
-
|
|
18521
|
-
|
|
18522
|
-
}
|
|
18523
|
-
|
|
18524
|
-
|
|
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
|
+
};
|
|
18525
18351
|
}
|
|
18526
18352
|
/**
|
|
18527
|
-
*
|
|
18528
|
-
*
|
|
18529
|
-
* Losing one of these is quieter than losing a skills bridge and costs more: a harness with no
|
|
18530
|
-
* instruction bridge reads none of the repository's instructions, and says nothing about it. The
|
|
18531
|
-
* checks are read-only, and no repair is a command — rewriting an instruction file is the `init`
|
|
18532
|
-
* skill's judgment.
|
|
18353
|
+
* Checks two things: whether each instruction bridge resolves, and whether anything suppresses a
|
|
18354
|
+
* harness that reads `AGENTS.md` natively.
|
|
18533
18355
|
*/
|
|
18534
18356
|
function diagnoseInstructions(root, harnesses, cli) {
|
|
18535
18357
|
const bridged = harnesses.map((harness) => ({
|
|
@@ -18538,11 +18360,9 @@ function diagnoseInstructions(root, harnesses, cli) {
|
|
|
18538
18360
|
})).filter((harness) => Boolean(harness.bridge));
|
|
18539
18361
|
const instructions = [];
|
|
18540
18362
|
const findings = [];
|
|
18541
|
-
|
|
18542
|
-
|
|
18543
|
-
|
|
18544
|
-
};
|
|
18545
|
-
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("")) {
|
|
18546
18366
|
const { detail, repair } = repairFor("no-instructions");
|
|
18547
18367
|
findings.push({
|
|
18548
18368
|
path: canonicalInstructions,
|
|
@@ -18551,24 +18371,42 @@ function diagnoseInstructions(root, harnesses, cli) {
|
|
|
18551
18371
|
repair: repair({ file: canonicalInstructions }, cli)
|
|
18552
18372
|
});
|
|
18553
18373
|
}
|
|
18554
|
-
for (const { name, bridge } of bridged)
|
|
18555
|
-
const inspection =
|
|
18374
|
+
for (const { name, bridge } of bridged) {
|
|
18375
|
+
const inspection = inspectSettingsEntry(join(root, bridge.path), bridge.key);
|
|
18556
18376
|
instructions.push({
|
|
18557
18377
|
harness: name,
|
|
18558
|
-
path,
|
|
18378
|
+
path: bridge.path,
|
|
18559
18379
|
kind: inspection.kind,
|
|
18560
18380
|
status: inspection.status
|
|
18561
18381
|
});
|
|
18562
18382
|
if (inspection.problem) {
|
|
18563
18383
|
const { detail, repair } = repairFor(inspection.problem);
|
|
18564
18384
|
findings.push({
|
|
18565
|
-
path,
|
|
18385
|
+
path: bridge.path,
|
|
18566
18386
|
problem: inspection.problem,
|
|
18567
18387
|
detail,
|
|
18568
|
-
repair: repair({ file: path }, cli)
|
|
18388
|
+
repair: repair({ file: bridge.path }, cli)
|
|
18569
18389
|
});
|
|
18570
18390
|
}
|
|
18571
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
|
+
}
|
|
18572
18410
|
return {
|
|
18573
18411
|
instructions,
|
|
18574
18412
|
findings
|
|
@@ -18596,25 +18434,20 @@ function lines(output) {
|
|
|
18596
18434
|
return (output ?? "").split("\n").filter(Boolean);
|
|
18597
18435
|
}
|
|
18598
18436
|
/**
|
|
18599
|
-
*
|
|
18600
|
-
*
|
|
18601
|
-
* 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.
|
|
18602
18439
|
*/
|
|
18603
18440
|
var GitBridgeState = class {
|
|
18604
18441
|
root;
|
|
18605
|
-
/**
|
|
18606
|
-
|
|
18442
|
+
/** `''` at the top, `undefined` outside a repository. */
|
|
18443
|
+
repoRootPrefix;
|
|
18607
18444
|
constructor(root) {
|
|
18608
18445
|
this.root = root;
|
|
18609
|
-
this.
|
|
18446
|
+
this.repoRootPrefix = git(root, ["rev-parse", "--show-prefix"])?.trim();
|
|
18610
18447
|
}
|
|
18611
|
-
/**
|
|
18612
|
-
* `init --copy` marks a bridge skip-worktree so a machine-local copy over a tracked symlink stops
|
|
18613
|
-
* showing as a pending change. The bit is a hint that some checkouts clear, so it is verified
|
|
18614
|
-
* rather than assumed.
|
|
18615
|
-
*/
|
|
18448
|
+
/** The skip-worktree bit is a hint some checkouts clear, so it is verified rather than assumed. */
|
|
18616
18449
|
trackingOf(path) {
|
|
18617
|
-
if (this.
|
|
18450
|
+
if (this.repoRootPrefix === void 0) return "untracked";
|
|
18618
18451
|
const entries = lines(git(this.root, [
|
|
18619
18452
|
"ls-files",
|
|
18620
18453
|
"-v",
|
|
@@ -18625,13 +18458,11 @@ var GitBridgeState = class {
|
|
|
18625
18458
|
return entries.every((entry) => entry.startsWith("S")) ? "skip-worktree" : "tracked";
|
|
18626
18459
|
}
|
|
18627
18460
|
/**
|
|
18628
|
-
*
|
|
18629
|
-
*
|
|
18630
|
-
* Outside a repository nothing is ignored, which is the same "cannot tell" degradation the rest
|
|
18631
|
-
* 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.
|
|
18632
18463
|
*/
|
|
18633
18464
|
isIgnored(path) {
|
|
18634
|
-
if (this.
|
|
18465
|
+
if (this.repoRootPrefix === void 0) return false;
|
|
18635
18466
|
return git(this.root, [
|
|
18636
18467
|
"check-ignore",
|
|
18637
18468
|
"-q",
|
|
@@ -18640,15 +18471,11 @@ var GitBridgeState = class {
|
|
|
18640
18471
|
]) !== void 0;
|
|
18641
18472
|
}
|
|
18642
18473
|
/**
|
|
18643
|
-
*
|
|
18644
|
-
*
|
|
18645
|
-
*
|
|
18646
|
-
* Bounded like `lastAgreedTree` is: a baseline that is not in the recent history of the two files
|
|
18647
|
-
* is not a baseline anyone can act on, and walking a whole repository to prove its absence costs
|
|
18648
|
-
* 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.
|
|
18649
18476
|
*/
|
|
18650
18477
|
commitsTouching(paths) {
|
|
18651
|
-
if (this.
|
|
18478
|
+
if (this.repoRootPrefix === void 0) return [];
|
|
18652
18479
|
return lines(git(this.root, [
|
|
18653
18480
|
"rev-list",
|
|
18654
18481
|
"-n",
|
|
@@ -18660,15 +18487,11 @@ var GitBridgeState = class {
|
|
|
18660
18487
|
}
|
|
18661
18488
|
/** One file's content at one commit, or `undefined` when it did not exist there. */
|
|
18662
18489
|
contentAt(commit, path) {
|
|
18663
|
-
if (this.
|
|
18664
|
-
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}`]);
|
|
18665
18492
|
}
|
|
18666
|
-
/**
|
|
18667
|
-
* Names the side that moved by finding the newest commit whose two trees agreed, then asking which
|
|
18668
|
-
* of the working directories still matches it.
|
|
18669
|
-
*/
|
|
18670
18493
|
directionOf(bridge, canonical) {
|
|
18671
|
-
if (this.
|
|
18494
|
+
if (this.repoRootPrefix === void 0) return "unknown";
|
|
18672
18495
|
const base = this.lastAgreedTree(bridge, canonical);
|
|
18673
18496
|
if (base === void 0) return "unknown";
|
|
18674
18497
|
const bridgeMoved = !this.matchesTree(bridge, base);
|
|
@@ -18692,20 +18515,20 @@ var GitBridgeState = class {
|
|
|
18692
18515
|
"rev-parse",
|
|
18693
18516
|
"-q",
|
|
18694
18517
|
"--verify",
|
|
18695
|
-
`${commit}:${this.
|
|
18518
|
+
`${commit}:${this.repoRootPrefix}${bridge}`
|
|
18696
18519
|
])?.trim();
|
|
18697
18520
|
if (!bridgeObject) continue;
|
|
18698
18521
|
if (bridgeObject === git(this.root, [
|
|
18699
18522
|
"rev-parse",
|
|
18700
18523
|
"-q",
|
|
18701
18524
|
"--verify",
|
|
18702
|
-
`${commit}:${this.
|
|
18525
|
+
`${commit}:${this.repoRootPrefix}${canonical}`
|
|
18703
18526
|
])?.trim()) return bridgeObject;
|
|
18704
18527
|
}
|
|
18705
18528
|
}
|
|
18706
18529
|
/**
|
|
18707
|
-
* Compares
|
|
18708
|
-
*
|
|
18530
|
+
* Compares by object id via `hash-object` rather than hashing in-process, so it follows
|
|
18531
|
+
* whatever object format the repository uses.
|
|
18709
18532
|
*/
|
|
18710
18533
|
matchesTree(directory, tree) {
|
|
18711
18534
|
const expected = new Map(lines(git(this.root, [
|
|
@@ -18787,9 +18610,8 @@ function inspect(target, path, canonical, git) {
|
|
|
18787
18610
|
}
|
|
18788
18611
|
}
|
|
18789
18612
|
/**
|
|
18790
|
-
*
|
|
18791
|
-
*
|
|
18792
|
-
* 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.
|
|
18793
18615
|
*/
|
|
18794
18616
|
function diagnoseBridges({ root, harnesses: preferred = [], cli }) {
|
|
18795
18617
|
const canonical = join(root, ".agents", "skills");
|
|
@@ -18846,11 +18668,12 @@ function diagnoseBridges({ root, harnesses: preferred = [], cli }) {
|
|
|
18846
18668
|
//#endregion
|
|
18847
18669
|
//#region src/diagnose-bridges/doctor.command.ts
|
|
18848
18670
|
/**
|
|
18849
|
-
* AXI §5: the healthy answer states the zero with context, so an agent
|
|
18850
|
-
*
|
|
18671
|
+
* AXI §5: the healthy answer states the zero with context, so an agent doesn't re-run to confirm
|
|
18672
|
+
* nothing was wrong.
|
|
18851
18673
|
*/
|
|
18852
|
-
function buildDoctorReport(bin, result, configuration = []) {
|
|
18674
|
+
function buildDoctorReport(bin, result, configuration = [], overrides = []) {
|
|
18853
18675
|
const findings = [...result.findings, ...configuration];
|
|
18676
|
+
const governances = overrides.length ? overrides : "0 governance overrides — no .agents/governances at project, user, or machine scope";
|
|
18854
18677
|
if (!findings.length) {
|
|
18855
18678
|
const count = result.bridges.length + result.instructions.length;
|
|
18856
18679
|
const bridges = count === 1 ? "the 1 bridge resolves" : `all ${count} bridges resolve`;
|
|
@@ -18858,6 +18681,7 @@ function buildDoctorReport(bin, result, configuration = []) {
|
|
|
18858
18681
|
bin,
|
|
18859
18682
|
bridges: result.bridges,
|
|
18860
18683
|
instructions: result.instructions,
|
|
18684
|
+
governances,
|
|
18861
18685
|
findings: `0 problems found — ${bridges} and the configuration around them is current`
|
|
18862
18686
|
};
|
|
18863
18687
|
}
|
|
@@ -18865,6 +18689,7 @@ function buildDoctorReport(bin, result, configuration = []) {
|
|
|
18865
18689
|
bin,
|
|
18866
18690
|
bridges: result.bridges,
|
|
18867
18691
|
instructions: result.instructions,
|
|
18692
|
+
governances,
|
|
18868
18693
|
...result.divergence.length ? { divergence: result.divergence } : {},
|
|
18869
18694
|
findings: findings.map(({ path, problem, detail }) => ({
|
|
18870
18695
|
path,
|
|
@@ -18897,6 +18722,7 @@ const doctorCommand = command({
|
|
|
18897
18722
|
const format = parseFormat(args.format);
|
|
18898
18723
|
const harnesses = parseHarnesses(args.harness);
|
|
18899
18724
|
const root = args.root ?? process.cwd();
|
|
18725
|
+
const home = homedir();
|
|
18900
18726
|
const result = diagnoseBridges({
|
|
18901
18727
|
root,
|
|
18902
18728
|
...harnesses.length ? { harnesses } : {},
|
|
@@ -18919,7 +18745,17 @@ const doctorCommand = command({
|
|
|
18919
18745
|
cli: commandInvocation
|
|
18920
18746
|
})
|
|
18921
18747
|
];
|
|
18922
|
-
|
|
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);
|
|
18923
18759
|
return exitCodes.success;
|
|
18924
18760
|
} catch (error) {
|
|
18925
18761
|
process.stderr.write(`error: ${error instanceof Error ? error.message : "Harness diagnosis failed."}\n`);
|
|
@@ -18928,10 +18764,111 @@ const doctorCommand = command({
|
|
|
18928
18764
|
}
|
|
18929
18765
|
});
|
|
18930
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
|
|
18931
18867
|
//#region src/initialize-harnesses/initialize-harnesses.ts
|
|
18932
18868
|
function initializeHarnesses({ root, harnesses: preferred = [], copy = false, force = false }) {
|
|
18933
18869
|
const canonicalSkills = join(root, ".agents", "skills");
|
|
18934
18870
|
const skills = countSkills(canonicalSkills);
|
|
18871
|
+
const governances = countProjectGovernances(root);
|
|
18935
18872
|
const harnesses = selectHarnesses(root, preferred);
|
|
18936
18873
|
const { linked, skipped } = projectSkills({
|
|
18937
18874
|
root,
|
|
@@ -18951,6 +18888,7 @@ function initializeHarnesses({ root, harnesses: preferred = [], copy = false, fo
|
|
|
18951
18888
|
replacedBy: harness.deprecated
|
|
18952
18889
|
})),
|
|
18953
18890
|
skills,
|
|
18891
|
+
governances,
|
|
18954
18892
|
copied: copy
|
|
18955
18893
|
};
|
|
18956
18894
|
}
|
|
@@ -19002,27 +18940,16 @@ const initCommand = command({
|
|
|
19002
18940
|
//#endregion
|
|
19003
18941
|
//#region src/cli.ts
|
|
19004
18942
|
const version = typeof __PACKAGE_VERSION__ !== "undefined" ? __PACKAGE_VERSION__ : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
19005
|
-
/**
|
|
19006
|
-
* A factory rather than a module-level constant: `cli()` builds state, and state built at import
|
|
19007
|
-
* time is shared by every later call in the process.
|
|
19008
|
-
*/
|
|
19009
18943
|
function app() {
|
|
19010
18944
|
return cli({
|
|
19011
18945
|
name: "buddy-agent-harness",
|
|
19012
18946
|
version,
|
|
19013
18947
|
description: "Initialize agent harness skill compatibility in consumer repositories."
|
|
19014
|
-
}).command(initCommand).command(doctorCommand).command(depPluginsCommand);
|
|
18948
|
+
}).command(initCommand).command(doctorCommand).command(depPluginsCommand).command(governanceCommand);
|
|
19015
18949
|
}
|
|
19016
18950
|
/**
|
|
19017
|
-
* The application boundary: argv in, exit code out
|
|
19018
|
-
*
|
|
19019
|
-
* The exit code is returned rather than written so that a caller which is not the process — a skill
|
|
19020
|
-
* launcher, a test — can run a command and learn how it went. `bin/buddy-agent-harness.mjs` is the
|
|
19021
|
-
* only place that turns the returned code back into a process outcome.
|
|
19022
|
-
*
|
|
19023
|
-
* One exit path this return cannot carry: `clibuilder` answers an unknown option or an unknown
|
|
19024
|
-
* command by printing help and writing `process.exitCode` itself, returning nothing. `run` reports
|
|
19025
|
-
* `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.
|
|
19026
18953
|
*/
|
|
19027
18954
|
async function run(argv) {
|
|
19028
18955
|
try {
|