| #!/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}%)`, |
| ); |