blob: ad4e67ccc106af371a933f8220de88644dbaa4e2 [file]
#!/usr/bin/env node
// Generate a Tools & Capabilities summary from an apache/magpie checkout.
//
// Mirrors scripts/gen-skill-counts.mjs: derives the website's /architecture
// page data from the actual framework repo so it never drifts. Invoked by
// scripts/sync-docs.sh after the framework is checked out.
//
// For each tool under tools/<name>/ it captures:
// - title + one-line description (from the README)
// - declared capabilities (the `**Capability:** capability:x + capability:y`
// line every tool README carries)
// - implementation state, combining two signals:
// * hasCode — a real implementation is present (pyproject.toml + src/
// + tests/), an objective fact
// * maturity — the most mature spec-loop status (stable > experimental >
// proposed) among specs that reference the tool
//
// It also reads the capability taxonomy from docs/labels-and-capabilities.md
// and a small skills summary (count + whether every skill is Apache-2.0
// licensed, used to badge the skill families with the ASF mark).
//
// Output is deterministic (no timestamps) so the committed JSON only changes
// when the derived data changes.
//
// Usage: node gen-tools.mjs <magpie-root> <out-json>
import {
readdirSync,
statSync,
existsSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
const [, , root, outPath] = process.argv;
if (!root || !outPath) {
console.error("usage: gen-tools.mjs <magpie-root> <out-json>");
process.exit(1);
}
const toolsDir = join(root, "tools");
const specsDir = join(root, "tools", "spec-loop", "specs");
const skillsDir = join(root, "skills");
const labelsDoc = join(root, "docs", "labels-and-capabilities.md");
const read = (p) => {
try {
return readFileSync(p, "utf8");
} catch {
return "";
}
};
const isDir = (p) => {
try {
return statSync(p).isDirectory();
} catch {
return false;
}
};
// --- taxonomy (two axes, per RFC-AI-0005) --------------------------------
// Skills carry `capability:*`; tools carry `contract:*` / `substrate:*`.
// Parse all three vocabularies from docs/labels-and-capabilities.md.
const labels = read(labelsDoc);
// Skill axis: | `capability:x` | definition |
const capabilities = {};
// Tool axis: | `contract:x`/`substrate:x` | kind | definition |
const contracts = {};
const substrates = {};
for (const line of labels.split("\n")) {
const cap = line.match(/^\|\s*`capability:([a-z]+)`\s*\|\s*(.+?)\s*\|\s*$/);
if (cap) {
capabilities[cap[1]] = cap[2].trim();
continue;
}
const tool = line.match(
/^\|\s*`(contract|substrate):([a-z-]+)`\s*\|\s*[a-z]+\s*\|\s*(.+?)\s*\|\s*$/,
);
if (tool) {
(tool[1] === "contract" ? contracts : substrates)[tool[2]] = tool[3].trim();
}
}
// --- spec maturity per tool ---------------------------------------------
// status precedence (higher = more mature / more done)
const RANK = { off: -1, proposed: 0, experimental: 1, stable: 2 };
const toolMaturity = {}; // tool -> best status string
if (isDir(specsDir)) {
for (const f of readdirSync(specsDir).filter((f) => f.endsWith(".md"))) {
const body = read(join(specsDir, f));
const sm = body.match(/^status:\s*([a-z]+)/m);
const status = sm && sm[1];
if (!status || !(status in RANK)) continue;
const refs = new Set(
[...body.matchAll(/tools\/([a-z0-9-]+)/g)].map((m) => m[1]),
);
for (const t of refs) {
if (
toolMaturity[t] === undefined ||
RANK[status] > RANK[toolMaturity[t]]
) {
toolMaturity[t] = status;
}
}
}
}
// --- per-tool description + capabilities ---------------------------------
function firstParagraph(md) {
// strip HTML comments (doctoc TOC, SPDX header) and leading blank lines
const cleaned = md.replace(/<!--[\s\S]*?-->/g, "");
const lines = cleaned.split("\n");
let i = 0;
// skip to the first H1
while (i < lines.length && !/^#\s/.test(lines[i])) i++;
if (i < lines.length) i++; // move past the H1
// skip blanks and metadata lines (e.g. **Capability:** ...) to the prose
const buf = [];
for (; i < lines.length; i++) {
const l = lines[i].trim();
if (!l) {
if (buf.length) break;
continue;
}
if (/^\*\*[A-Za-z]/.test(l)) continue; // **Capability:** etc.
if (/^#{1,6}\s/.test(l)) {
if (buf.length) break;
continue;
}
if (/^[|>-]/.test(l)) {
if (buf.length) break;
continue;
}
buf.push(l);
}
let text = buf
.join(" ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // unlink markdown links
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\s+/g, " ")
.trim();
if (text.length > 240) text = text.slice(0, 237).replace(/\s\S*$/, "") + "…";
return text;
}
function title(md, name) {
const cleaned = md.replace(/<!--[\s\S]*?-->/g, "");
const m = cleaned.match(/^#\s+(.+?)\s*$/m);
if (!m) return name;
const h1 = m[1]
.replace(/^Tool:\s*/i, "")
.replace(/`/g, "")
.trim();
// Newer tool READMEs use a path-like H1 (e.g. `tools/github/`); fall back to
// the directory name in that case so the card shows a clean title.
if (/^tools\//.test(h1) || h1.includes("/")) return name;
return h1;
}
// The tool `**Capability:**` line now carries contract:/substrate: labels.
function toolLabelsOf(md) {
const m = md.match(/^\*\*Capability:\*\*\s*(.+?)\s*$/m);
if (!m) return [];
return [...m[1].matchAll(/(contract|substrate):([a-z-]+)/g)].map((x) => ({
kind: x[1],
name: x[2],
}));
}
// Tools/skills/families can declare membership of an organization.
function organizationOf(md) {
const m = md.match(/^\*\*Organization:\*\*\s*([A-Za-z0-9_-]+)/m);
return m ? m[1] : null;
}
// Vendor-neutrality metadata every contract tool declares (RFC magpie-site#17):
// **Kind:** interface | implementation
// **Vendor:** <backend identity> | agnostic
// These feed the deterministic vendor-neutrality score below.
function kindOf(md) {
const m = md.match(/^\*\*Kind:\*\*\s*(.+?)\s*$/m);
return m ? m[1].trim() : null;
}
function vendorOf(md) {
const m = md.match(/^\*\*Vendor:\*\*\s*(.+?)\s*$/m);
return m ? m[1].trim() : null;
}
// Substrate tools declare **Harness:** — the agent harness they integrate with
// (or `agnostic`) — for the LLM-integration (agent-harness) neutrality axis.
function harnessOf(md) {
const m = md.match(/^\*\*Harness:\*\*\s*(.+?)\s*$/m);
return m ? m[1].trim() : null;
}
// MCP backing a tool wraps, from its own README marker — the single source of
// truth every MCP-wrapper tool now carries (see tools/AGENTS.md):
// **MCP:** <server> (mcp__<prefix>__*)
// An MCP is just a transport behind a capability contract; the marker lets the
// site badge the wrapping tool and explain the relationship. Returns
// { server, prefix }.
function mcpOf(md) {
const m = md.match(/^\*\*MCP:\*\*\s*(.+?)\s*\((mcp__[^)]+)\)\s*$/m);
return m ? { server: m[1].trim(), prefix: m[2].trim() } : null;
}
const tools = [];
for (const name of readdirSync(toolsDir).sort()) {
const dir = join(toolsDir, name);
if (!isDir(dir)) continue;
const readme = read(join(dir, "README.md"));
if (!readme) continue;
const hasCode =
existsSync(join(dir, "pyproject.toml")) &&
isDir(join(dir, "src")) &&
isDir(join(dir, "tests"));
const tlabels = toolLabelsOf(readme);
tools.push({
name,
title: title(readme, name),
description: firstParagraph(readme),
labels: tlabels,
kind: tlabels.some((l) => l.kind === "contract") ? "contract" : "substrate",
// Vendor-neutrality axis (only meaningful for contract tools): whether this
// is a pure interface or a concrete backend, and the backend's identity.
vendorKind: kindOf(readme),
vendor: vendorOf(readme),
harness: harnessOf(readme),
organization: organizationOf(readme),
// "Wraps an MCP" flag, read from the per-tool README **MCP:** marker.
mcp: mcpOf(readme) ?? null,
hasCode,
maturity: toolMaturity[name] ?? null,
state: hasCode ? "implemented" : "adapter",
docUrl: `https://github.com/apache/magpie/tree/main/tools/${name}`,
});
}
// --- organization membership of skill families ---------------------------
// Each family's docs/<family>/README.md declares its scope with a marker
// line: `> **Scope — `organization: ASF` · 🪶 ASF-specific.**` (or
// `organization: independent`, or no marker for org-agnostic families).
// Derive the family→organization map (and the ASF subset for the oak leaf)
// from that metadata so the website badge stays data-driven.
const docsDir = join(root, "docs");
const orgFamilies = {};
if (isDir(docsDir)) {
for (const fam of readdirSync(docsDir)) {
const readme = join(docsDir, fam, "README.md");
if (!existsSync(readme)) continue;
const m = read(readme).match(
/Scope\s*[—-]\s*`organization:\s*([A-Za-z0-9_-]+)`/i,
);
if (m) orgFamilies[fam] = m[1];
}
}
const asfFamilies = Object.keys(orgFamilies)
.filter((f) => orgFamilies[f] === "ASF")
.sort();
// --- organizations -------------------------------------------------------
// Each organizations/<org>/organization.md carries a fenced `yaml` block:
// organization_identity:
// id: ASF
// name: "Apache Software Foundation"
// url: https://www.apache.org/
// logo: https://www.apache.org/.../asf_logo.svg
const orgsDir = join(root, "organizations");
const organizations = [];
const cleanVal = (v) =>
v
.replace(/#.*$/, "") // strip trailing yaml comment
.trim()
.replace(/^["']|["']$/g, "")
.trim();
if (isDir(orgsDir)) {
for (const id of readdirSync(orgsDir).sort()) {
if (id === "_template") continue;
const orgFile = join(orgsDir, id, "organization.md");
if (!existsSync(orgFile)) continue;
const body = read(orgFile);
const ident = {};
const block = body.match(
/organization_identity:\s*\n([\s\S]*?)(\n```|\n#|\n\S)/,
);
const src = block ? block[1] : "";
for (const line of src.split("\n")) {
const km = line.match(/^\s+(id|name|url|logo):\s*(.*)$/);
if (!km) continue;
const val = cleanVal(km[2]);
ident[km[1]] = val === "null" || val === "" ? null : val;
}
organizations.push({
id: ident.id || id,
name: ident.name || id,
url: ident.url || null,
logo: ident.logo || null,
tools: tools.filter((t) => t.organization === id).map((t) => t.name),
families: Object.keys(orgFamilies)
.filter((f) => orgFamilies[f] === id)
.sort(),
});
}
}
// --- skills summary ------------------------------------------------------
// Collect each skill's frontmatter + body once; the body is scanned below for
// the concrete backends a skill invokes (vendor-neutrality per-skill verdict).
const skillRecords = []; // { name, org, body }
let skillTotal = 0;
let apacheLicensed = 0;
const skillsByOrg = {};
if (isDir(skillsDir)) {
for (const name of readdirSync(skillsDir).sort()) {
const sp = join(skillsDir, name, "SKILL.md");
if (!existsSync(sp)) continue;
skillTotal++;
const text = read(sp);
const fm = text.split(/^---\s*$/m)[1] ?? "";
if (/^license:\s*Apache-2\.0/m.test(fm)) apacheLicensed++;
const om = fm.match(/^organization:\s*([A-Za-z0-9_-]+)/m);
if (om) skillsByOrg[om[1]] = (skillsByOrg[om[1]] || 0) + 1;
// Body = everything after the second `---` (matches the scorer's split).
const parts = text.split(/^---\s*$/m);
const body = parts.length >= 3 ? parts.slice(2).join("---") : text;
skillRecords.push({ name, org: om ? om[1] : "agnostic", body });
}
}
// --- vendor-neutrality score (deterministic, mirrors magpie's
// tools/vendor-neutrality-score) -----------------------------------------
// Measured per capability *contract*. Substrate tools are Magpie's own
// machinery and never count. Each contract belongs to one of three classes
// (the only hand-maintained policy, kept in lockstep with the framework tool):
// vendor-backed → GREEN once ≥ MIN_VENDORS distinct backend vendors ship it
// agnostic → GREEN by construction (one spec serves every backend)
// single-org → GREEN by exemption (bound to one org's data model)
const MIN_VENDORS = 2;
const VENDOR_BACKED = "vendor-backed";
const AGNOSTIC = "agnostic";
const SINGLE_ORG = "single-org";
// [contract, class] in the canonical order the framework tool emits.
// Neutrality CLASS per contract (the only hand-maintained policy). Everything
// defaults to vendor-backed; only the agnostic / single-org exceptions are
// listed. The contract *set* is derived from the taxonomy doc (`contracts`,
// parsed from docs/labels-and-capabilities.md) rather than hard-coded, so a
// renamed or newly-added contract (e.g. mail-draft → mail-create) is scored
// automatically instead of being silently dropped.
const CONTRACT_CLASS = {
"contract:report-relay": AGNOSTIC,
"contract:scan-format": AGNOSTIC,
"contract:project-metadata": SINGLE_ORG,
};
const CONTRACT_POLICY = Object.keys(contracts).map((c) => [
`contract:${c}`,
CONTRACT_CLASS[`contract:${c}`] ?? VENDOR_BACKED,
]);
// High-confidence usage signals (MCP tool names, CLI verbs, hostnames) that a
// skill actually *invokes* a contract's backend — not mere prose mentions.
// Agnostic contracts are omitted (they never change a verdict).
const CONTRACT_USAGE_TOKENS = {
"contract:tracker": [
/mcp__github__/,
/\bgh (?:issue|api|search|run|workflow|release|label)\b/,
/\bJIRA\b/,
],
// `gh pr` is the change-request contract, not tracker (Jira has no PR model).
"contract:change-request": [/\bgh pr\b/],
"contract:source-control": [
/\bgit (?:commit|push|checkout|branch|rebase|merge|switch|worktree)\b/,
/\bsvn (?:checkout|commit|update|cat|list|mkdir|import|move|delete|switch|copy)\b/,
],
"contract:mail-archive": [
/mcp__ponymail__/,
/mcp__claude_ai_Gmail__(?:search_threads|get_thread|list_)/,
],
"contract:mail-create": [
/mcp__claude_ai_Gmail__create_draft/,
/\bcreate_draft\b/,
],
"contract:cve-authority": [/\bVulnogram\b/, /\bcveawg\b/, /\bcve\.org\b/],
"contract:project-metadata": [
/mcp__apache-projects__/,
/\bprojects\.apache\.org\b/,
],
};
const contractTools = tools.filter((t) =>
t.labels.some((l) => l.kind === "contract"),
);
const providersOf = (contract) =>
contractTools.filter((t) =>
t.labels.some(
(l) => l.kind === "contract" && `contract:${l.name}` === contract,
),
);
const vnContracts = CONTRACT_POLICY.map(([contract, klass]) => {
const providers = providersOf(contract);
const interfaces = providers
.filter((t) => t.vendorKind === "interface")
.map((t) => t.name)
.sort();
const implementations = providers
.filter((t) => t.vendorKind === "implementation")
.map((t) => ({ tool: t.name, vendor: t.vendor }))
.sort((a, b) => a.vendor.localeCompare(b.vendor));
const vendors = [...new Set(implementations.map((i) => i.vendor))].sort();
let green = false;
let basis = "";
if (klass === AGNOSTIC) {
green = true;
basis = "vendor-neutral by construction — one spec serves every backend";
} else if (klass === SINGLE_ORG) {
green = true;
const org = vendors.join(", ") || "a single organisation";
basis = `single-organisation capability (${org}); no vendor choice to make`;
} else {
const n = vendors.length;
green = n >= MIN_VENDORS;
basis = green
? `${n} backend vendors: ${vendors.join(", ")}`
: n === 0
? "no backend implemented yet"
: `only ${n} backend vendor (${vendors.join(", ")}); needs ${MIN_VENDORS - n} more`;
}
return {
contract,
class: klass,
green,
basis,
vendors,
interfaces,
implementations,
};
});
const greenByContract = Object.fromEntries(
vnContracts.map((r) => [r.contract, r.green]),
);
// A not-green vendor-backed contract with exactly one backend is a real
// lock-in; name the sole vendor so the report can attribute it.
const soleVendor = {};
for (const r of vnContracts) {
if (r.class === VENDOR_BACKED && !r.green && r.vendors.length === 1) {
soleVendor[r.contract] = r.vendors[0];
}
}
const usagePatterns = Object.entries(CONTRACT_USAGE_TOKENS);
const vnSkills = skillRecords.map(({ name, org, body }) => {
const used = usagePatterns
.filter(([, regexes]) => regexes.some((re) => re.test(body)))
.map(([c]) => c)
.sort();
const coupled = used
.filter((c) => !greenByContract[c] && c in soleVendor)
.map((c) => ({ vendor: soleVendor[c], contract: c }))
.sort(
(a, b) =>
a.vendor.localeCompare(b.vendor) ||
a.contract.localeCompare(b.contract),
);
const verdict =
used.length === 0
? "capability-pure"
: coupled.length
? "vendor-coupled"
: "portable";
return {
skill: name,
organization: org,
contractsUsed: used,
verdict,
coupled,
};
});
const vnByVerdict = { "capability-pure": 0, portable: 0, "vendor-coupled": 0 };
const vnByOrg = {};
for (const s of vnSkills) {
vnByVerdict[s.verdict] = (vnByVerdict[s.verdict] || 0) + 1;
vnByOrg[s.organization] = (vnByOrg[s.organization] || 0) + 1;
}
const vnGreen = vnContracts.filter((r) => r.green).length;
const vnTotal = vnContracts.length;
// --- LLM-integration axis (mirrors magpie's vendor-neutrality-score) --------
// Part A — agent harness: substrate tools declare **Harness:** (a harness or
// `agnostic`); a tool is harness-neutral when agnostic or supporting ≥2 harnesses.
// Emitted only when substrate tools declare the field (i.e. once the upstream
// framework carries it), so a resync against an older magpie stays valid.
const AGNOSTIC_HARNESS = "agnostic";
const substrateTools = tools.filter(
(t) =>
t.labels.some((l) => l.kind === "substrate") &&
!t.labels.some((l) => l.kind === "contract"),
);
const vnHarness = substrateTools
.filter((t) => t.harness)
.map((t) => {
const substrates = t.labels
.filter((l) => l.kind === "substrate")
.map((l) => `substrate:${l.name}`);
let harnesses = [];
let verdict;
if (t.harness.toLowerCase() === AGNOSTIC_HARNESS) {
verdict = "agnostic";
} else {
harnesses = [
...new Set(
t.harness
.split(",")
.map((h) => h.trim())
.filter(Boolean),
),
].sort();
verdict = harnesses.length >= MIN_VENDORS ? "portable" : "coupled";
}
return { tool: t.name, substrates, harnesses, verdict };
})
.sort((a, b) => a.tool.localeCompare(b.tool));
const harnessMatrix = {};
for (const r of vnHarness) {
if (r.verdict === "agnostic")
(harnessMatrix[AGNOSTIC_HARNESS] ??= []).push(r.tool);
else for (const h of r.harnesses) (harnessMatrix[h] ??= []).push(r.tool);
}
for (const h of Object.keys(harnessMatrix)) harnessMatrix[h].sort();
const harnessNeutral = vnHarness.filter((r) => r.verdict !== "coupled").length;
// Part B — approved-LLM endpoint classes from tools/privacy-llm/models.md.
function parseApprovedLlms() {
const md = read(join(toolsDir, "privacy-llm", "models.md"));
const heading = "## The default-approved entries";
if (!md.includes(heading)) return [];
const section = md.split(heading)[1].split("\n## ")[0];
const rows = [];
for (const line of section.split("\n")) {
const l = line.trim();
if (!l.startsWith("|") || l.startsWith("|--") || /\|\s*Class\s*\|/.test(l))
continue;
const cells = l
.replace(/^\||\|$/g, "")
.split("|")
.map((c) => c.trim());
if (cells.length < 3) continue;
const name = cells[0].replace(/\*\*/g, "").replace(/`/g, "").trim();
if (name) rows.push({ class: name, examples: cells[2].trim() });
}
return rows;
}
const vnLlm = parseApprovedLlms();
const vendorNeutrality = {
minVendors: MIN_VENDORS,
overall: {
green: vnGreen,
total: vnTotal,
percent: vnTotal ? Math.round((100 * vnGreen) / vnTotal) : 0,
},
contracts: vnContracts,
...(vnHarness.length > 0 && {
harness: {
neutral: harnessNeutral,
total: vnHarness.length,
percent: vnHarness.length
? Math.round((100 * harnessNeutral) / vnHarness.length)
: 0,
tools: vnHarness,
matrix: harnessMatrix,
},
}),
...(vnLlm.length > 0 && {
llm: {
defaultApproved: vnLlm,
optIn:
"adopter-declared in <project-config>/privacy-llm.md (no fixed list)",
},
}),
skills: {
total: vnSkills.length,
neutral: vnByVerdict["capability-pure"] + vnByVerdict.portable,
byVerdict: vnByVerdict,
byOrg: vnByOrg,
coupled: vnSkills
.filter((s) => s.verdict === "vendor-coupled")
.map((s) => ({ skill: s.skill, coupled: s.coupled })),
// Full per-skill assessment so the site can list the skills in each bucket.
// Sorted by name; `contractsUsed` explains a portable/coupled verdict.
list: [...vnSkills]
.sort((a, b) => a.skill.localeCompare(b.skill))
.map((s) => ({
skill: s.skill,
verdict: s.verdict,
organization: s.organization,
contractsUsed: s.contractsUsed,
coupled: s.coupled,
})),
},
};
const out = {
_comment:
"GENERATED by scripts/gen-tools.mjs — do not edit by hand. Derives from apache/magpie tools/, spec-loop specs, skills/, docs/, and organizations/.",
// Two-axis taxonomy (RFC-AI-0005)
capabilities, // skill axis (capability:*)
contracts, // tool axis — capability contracts (contract:*)
substrates, // tool axis — framework substrate (substrate:*)
tools,
toolsTotal: tools.length,
implementedTotal: tools.filter((t) => t.hasCode).length,
contractsTotal: tools.filter((t) => t.kind === "contract").length,
substratesTotal: tools.filter((t) => t.kind === "substrate").length,
mcpTotal: tools.filter((t) => t.mcp).length,
organizations,
orgFamilies,
asfFamilies,
vendorNeutrality,
skills: {
total: skillTotal,
apacheLicensed,
allApache: skillTotal > 0 && apacheLicensed === skillTotal,
license: "Apache-2.0",
byOrg: skillsByOrg,
},
};
writeFileSync(outPath, JSON.stringify(out, null, 2) + "\n");
console.log(
`✓ tools → ${outPath}: ${tools.length} tools ` +
`(${out.contractsTotal} contract, ${out.substratesTotal} substrate, ${out.implementedTotal} with code), ` +
`${Object.keys(capabilities).length} skill-capabilities, ` +
`${Object.keys(contracts).length} contracts, ${Object.keys(substrates).length} substrates, ` +
`${organizations.length} orgs, ${skillTotal} skills, ` +
`vendor-neutrality ${vendorNeutrality.overall.green}/${vendorNeutrality.overall.total} (${vendorNeutrality.overall.percent}%)`,
);