From 48740dab7482e1853b5d280c5f26e811ebead8a3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 5 May 2026 00:00:22 +0100 Subject: [PATCH] feat: add OpenClaw inventory planning --- README.md | 5 + docs/AGENT_USAGE.md | 5 + docs/REPORT_SCHEMA.md | 14 ++ src/cli.mjs | 2 + src/commands/inventory.mjs | 29 +++ src/inventory/openclaw.mjs | 484 +++++++++++++++++++++++++++++++++++++ src/main.mjs | 6 + src/selfcheck.mjs | 74 ++++++ 8 files changed, 619 insertions(+) create mode 100644 src/commands/inventory.mjs create mode 100644 src/inventory/openclaw.mjs diff --git a/README.md b/README.md index 1d10a58..33f8cb0 100644 --- a/README.md +++ b/README.md @@ -230,10 +230,15 @@ Agents should use JSON plans and reports: ```sh node bin/kova.mjs plan --json +node bin/kova.mjs inventory plan --openclaw-bin openclaw --openclaw-repo /path/to/openclaw --json node bin/kova.mjs matrix plan --profile smoke --target runtime:stable --json node bin/kova.mjs matrix run --profile smoke --target runtime:stable --execute --json ``` +`inventory plan` is planner-only. It compares discovered OpenClaw CLI commands, +package scripts, and manifests with Kova surfaces and reports unmodeled +capabilities as warnings. + Kova ships repo-local agent skills: - `.agents/skills/kova-operator` diff --git a/docs/AGENT_USAGE.md b/docs/AGENT_USAGE.md index fb9435c..158f1d5 100644 --- a/docs/AGENT_USAGE.md +++ b/docs/AGENT_USAGE.md @@ -35,11 +35,16 @@ node bin/kova.mjs self-check --json ```sh node bin/kova.mjs plan --json +node bin/kova.mjs inventory plan --openclaw-bin openclaw --openclaw-repo /path/to/openclaw --json node bin/kova.mjs plan --scenario fresh-install --state missing-plugin-index --json node bin/kova.mjs matrix plan --profile smoke --target runtime:stable --json node bin/kova.mjs matrix plan --profile release --target runtime:stable --include tag:plugins --exclude state:broken-plugin-deps --json ``` +Use `inventory plan` when checking whether Kova is missing OpenClaw command, +script, plugin, or extension surfaces. Treat unmodeled inventory entries as +planning warnings until they are intentionally promoted to gate policy. + 3. Dry-run the intended scenario: ```sh diff --git a/docs/REPORT_SCHEMA.md b/docs/REPORT_SCHEMA.md index 498a925..30f1c8d 100644 --- a/docs/REPORT_SCHEMA.md +++ b/docs/REPORT_SCHEMA.md @@ -367,6 +367,20 @@ required state traits, required target kinds, and required metrics. Invalid obligations, such as a scenario proving an unknown requirement or a selected state that cannot satisfy the requirement, fail planning before execution. +`kova inventory plan --json` is planner-only and does not write a run report. It +uses schema `kova.inventory.plan.v1` and includes: + +- `sources`: whether OpenClaw help, package scripts, and manifests were scanned +- `modeledSurfaces`: current Kova surfaces +- `capabilities`: discovered CLI commands, package scripts, plugin manifests, + and extension manifests with matched Kova surface ids when known +- `coverage.warnings`: unmodeled or ambiguous discovered capabilities +- `coverage.blockers`: selected missing or unmodeled capabilities when + `--require-modeled ` is used + +Inventory warnings are discovery signal first. They do not block release gates +until a later policy deliberately promotes them. + ## Summary Output `kova report summarize --json` returns a compact agent-facing diff --git a/src/cli.mjs b/src/cli.mjs index de4ba01..22afa4a 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -39,6 +39,7 @@ Usage: kova setup auth [--provider ] [--method ] [--env-var ] [--value ] [--fallback-policy ] [--json] kova self-check [--json] kova plan [--scenario ] [--json] + kova inventory plan [--openclaw-bin ] [--openclaw-repo ] [--subcommands ] [--require-modeled ] [--max-subcommands ] [--timeout-ms ] [--json] kova run --target [--from ] [--scenario ] [--state ] [--auth ] [--repeat ] [--baseline [path]] [--save-baseline [path] --reviewed-good] [--regression-thresholds ] [--report-dir ] [--health-samples ] [--readiness-interval-ms ] [--resource-sample-interval-ms ] [--deep-profile] [--node-profile] [--heap-snapshot] [--profile-on-failure] [--execute] [--keep-env] [--retain-on-failure] [--json] kova matrix plan --profile --target [--from ] [--include ] [--exclude ] [--parallel ] [--json] kova matrix run --profile --target [--from ] [--include ] [--exclude ] [--auth ] [--parallel ] [--repeat ] [--baseline [path]] [--save-baseline [path] --reviewed-good] [--regression-thresholds ] [--fail-fast] [--gate] [--report-dir ] [--health-samples ] [--readiness-interval-ms ] [--resource-sample-interval-ms ] [--deep-profile] [--node-profile] [--heap-snapshot] [--profile-on-failure] [--execute] [--allow-exhaustive] [--keep-env] [--retain-on-failure] [--json] @@ -62,6 +63,7 @@ Notes: Kova uses OCM to create isolated OpenClaw envs and runtimes. Kova reports on OpenClaw behavior, not OCM behavior. run is dry-run/report-only unless --execute is passed. + inventory is planner-only and reports discovered OpenClaw capabilities that are not mapped to Kova surfaces. Executed exhaustive matrix runs require --allow-exhaustive. cleanup artifacts is dry-run by default and only targets Kova-owned run artifact dirs. --repeat records independent samples and computes aggregate performance stats. diff --git a/src/commands/inventory.mjs b/src/commands/inventory.mjs new file mode 100644 index 0000000..06ce530 --- /dev/null +++ b/src/commands/inventory.mjs @@ -0,0 +1,29 @@ +import { buildOpenClawInventoryPlan } from "../inventory/openclaw.mjs"; + +export async function runInventoryCommand(flags) { + const [subcommand = "plan"] = flags._; + if (subcommand !== "plan") { + throw new Error(`unknown inventory command: ${subcommand}`); + } + + const plan = await buildOpenClawInventoryPlan(flags); + if (flags.json) { + console.log(JSON.stringify(plan, null, 2)); + return; + } + + console.log("OpenClaw inventory plan"); + console.log(`Discovered: ${plan.coverage.discoveredCount}`); + console.log(`Matched: ${plan.coverage.matchedCount}`); + console.log(`Unmodeled: ${plan.coverage.unmodeledCount}`); + for (const source of plan.sources) { + const count = source.commandCount ?? source.scriptCount ?? source.capabilityCount ?? 0; + console.log(`- ${source.id}: ${source.status}${count ? ` (${count})` : ""}`); + } + if (plan.coverage.warnings.length > 0) { + console.log("Warnings:"); + for (const warning of plan.coverage.warnings) { + console.log(`- ${warning.message}`); + } + } +} diff --git a/src/inventory/openclaw.mjs b/src/inventory/openclaw.mjs new file mode 100644 index 0000000..42e7129 --- /dev/null +++ b/src/inventory/openclaw.mjs @@ -0,0 +1,484 @@ +import { readFile, readdir } from "node:fs/promises"; +import { basename, join, relative } from "node:path"; +import { quoteShell, runCommand } from "../commands.mjs"; +import { positiveIntegerFlag } from "../commands/run-support.mjs"; +import { resolveFromCwd } from "../cli.mjs"; +import { loadRegistryContext } from "../registries/context.mjs"; + +const inventorySchemaVersion = "kova.inventory.plan.v1"; +const manifestSearchDirs = ["apps", "extensions", "packages", "plugins", "src"]; +const ignoredDirs = new Set([ + ".git", + ".next", + ".turbo", + "build", + "coverage", + "dist", + "node_modules", + "out", + "target", + "tmp" +]); + +const commandSurfaceMap = new Map([ + ["agent", ["agent-cli-local-turn", "agent-gateway-rpc-turn"]], + ["browser", ["browser-automation"]], + ["dashboard", ["dashboard", "dashboard-session-send-turn"]], + ["mcp", ["mcp-runtime"]], + ["media", ["media-understanding"]], + ["model", ["provider-models"]], + ["models", ["provider-models"]], + ["plugin", ["plugin-lifecycle"]], + ["plugins", ["plugin-lifecycle", "official-plugin-install"]], + ["provider", ["provider-models"]], + ["providers", ["provider-models"]], + ["start", ["release-runtime-startup", "gateway-performance"]], + ["status", ["release-runtime-startup"]], + ["stop", ["release-runtime-startup"]], + ["tui", ["tui", "tui-message-turn"]], + ["update", ["upgrade-existing-user"]], + ["upgrade", ["upgrade-existing-user"]], + ["workspace", ["workspace-scan"]] +]); + +export async function buildOpenClawInventoryPlan(flags = {}) { + const registry = await loadRegistryContext(); + const timeoutMs = positiveIntegerFlag(flags, "timeout_ms", 10000); + const maxSubcommands = positiveIntegerFlag(flags, "max_subcommands", 40); + const openclawBin = normalizeOptionalCommand(flags.openclaw_bin); + const repoPath = normalizeOptionalPath(flags.openclaw_repo); + const requestedSubcommands = parseList(flags.subcommands); + const requiredModeled = parseList(flags.require_modeled); + const sources = []; + const capabilities = []; + + const helpInventory = await discoverCliHelp({ + openclawBin, + requestedSubcommands, + maxSubcommands, + timeoutMs + }); + sources.push(helpInventory.source); + capabilities.push(...helpInventory.capabilities); + + const repoInventory = await discoverRepoInventory({ repoPath }); + sources.push(...repoInventory.sources); + capabilities.push(...repoInventory.capabilities); + + const modeledSurfaces = registry.surfaces.map((surface) => ({ + id: surface.id, + title: surface.title, + ownerArea: surface.ownerArea, + purposes: surface.purposes ?? [] + })); + const classifiedCapabilities = capabilities.map((capability) => + classifyCapability(capability, registry.surfaces) + ); + + return { + schemaVersion: inventorySchemaVersion, + generatedAt: new Date().toISOString(), + openclaw: { + bin: openclawBin, + repoPath + }, + sources, + modeledSurfaces, + capabilities: classifiedCapabilities, + coverage: summarizeCoverage(classifiedCapabilities, modeledSurfaces, { + requiredModeled + }) + }; +} + +async function discoverCliHelp({ openclawBin, requestedSubcommands, maxSubcommands, timeoutMs }) { + if (!openclawBin) { + return { + source: { + id: "openclaw-help", + kind: "cli-help", + status: "skipped", + reason: "--openclaw-bin was not provided" + }, + capabilities: [] + }; + } + + const helpCommand = `${quoteShell(openclawBin)} --help`; + const topLevel = await runCommand(helpCommand, { + timeoutMs, + maxOutputChars: 50000 + }); + if (topLevel.status !== 0) { + return { + source: { + id: "openclaw-help", + kind: "cli-help", + status: "failed", + command: topLevel.command, + statusCode: topLevel.status, + timedOut: topLevel.timedOut, + error: topLevel.stderr.trim() || topLevel.stdout.trim() || "openclaw --help failed" + }, + capabilities: [] + }; + } + + const parsedCommands = requestedSubcommands.length > 0 + ? requestedSubcommands + : parseHelpCommands(topLevel.stdout); + const allUniqueCommands = [...new Set(parsedCommands)].sort(); + const uniqueCommands = allUniqueCommands.slice(0, maxSubcommands); + const capabilities = uniqueCommands.map((command) => ({ + id: `cli:${command}`, + kind: "cli-command", + name: command, + source: "openclaw-help", + path: null, + summary: null, + evidence: { + command: helpCommand + } + })); + + for (const capability of capabilities) { + const result = await runCommand(`${quoteShell(openclawBin)} ${quoteShell(capability.name)} --help`, { + timeoutMs, + maxOutputChars: 30000 + }); + capability.evidence.subcommandHelp = { + command: result.command, + status: result.status, + timedOut: result.timedOut + }; + if (result.status === 0) { + capability.summary = firstUsefulHelpLine(result.stdout); + } + } + + return { + source: { + id: "openclaw-help", + kind: "cli-help", + status: "scanned", + command: topLevel.command, + commandCount: capabilities.length, + discoveredCommandCount: allUniqueCommands.length, + truncated: allUniqueCommands.length > uniqueCommands.length, + requestedSubcommands + }, + capabilities + }; +} + +async function discoverRepoInventory({ repoPath }) { + if (!repoPath) { + return { + sources: [ + { + id: "package-scripts", + kind: "package-json", + status: "skipped", + reason: "--openclaw-repo was not provided" + }, + { + id: "manifests", + kind: "manifest-scan", + status: "skipped", + reason: "--openclaw-repo was not provided" + } + ], + capabilities: [] + }; + } + + const sources = []; + const capabilities = []; + const packagePath = join(repoPath, "package.json"); + try { + const packageJson = JSON.parse(await readFile(packagePath, "utf8")); + const scripts = Object.keys(packageJson.scripts ?? {}).sort(); + for (const script of scripts) { + capabilities.push({ + id: `script:${script}`, + kind: "package-script", + name: script, + source: "package-scripts", + path: relative(repoPath, packagePath), + summary: packageJson.scripts[script], + evidence: { + packageName: packageJson.name ?? null + } + }); + } + sources.push({ + id: "package-scripts", + kind: "package-json", + status: "scanned", + path: packagePath, + scriptCount: scripts.length + }); + } catch (error) { + sources.push({ + id: "package-scripts", + kind: "package-json", + status: error.code === "ENOENT" ? "missing" : "failed", + path: packagePath, + error: error.code === "ENOENT" ? null : error.message + }); + } + + const manifestResult = await discoverManifests(repoPath); + sources.push(manifestResult.source); + capabilities.push(...manifestResult.capabilities); + return { sources, capabilities }; +} + +async function discoverManifests(repoPath) { + const candidates = []; + const roots = manifestSearchDirs.map((dir) => join(repoPath, dir)); + for (const root of roots) { + await collectManifestCandidates(root, repoPath, candidates); + } + + const capabilities = []; + for (const path of candidates.slice(0, 300)) { + try { + const manifest = JSON.parse(await readFile(path, "utf8")); + const kind = classifyManifest(path, manifest); + if (!kind) { + continue; + } + const name = manifest.name ?? manifest.id ?? manifest.displayName ?? basename(path); + capabilities.push({ + id: `${kind}:${normalizeToken(name) || normalizeToken(relative(repoPath, path))}`, + kind, + name, + source: "manifests", + path: relative(repoPath, path), + summary: manifest.description ?? manifest.title ?? null, + evidence: { + manifestId: manifest.id ?? null, + packageName: manifest.name ?? null + } + }); + } catch { + // Non-JSON manifest-looking files are ignored; registry validation catches Kova contracts. + } + } + + return { + source: { + id: "manifests", + kind: "manifest-scan", + status: "scanned", + roots: roots.map((root) => relative(repoPath, root)), + candidateCount: candidates.length, + capabilityCount: capabilities.length, + truncated: candidates.length > 300 + }, + capabilities + }; +} + +async function collectManifestCandidates(root, repoPath, candidates, depth = 0) { + if (depth > 8 || candidates.length > 300) { + return; + } + + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") { + return; + } + throw error; + } + + for (const entry of entries) { + if (entry.isDirectory()) { + if (!ignoredDirs.has(entry.name)) { + await collectManifestCandidates(join(root, entry.name), repoPath, candidates, depth + 1); + } + continue; + } + if (!entry.isFile()) { + continue; + } + const name = entry.name.toLowerCase(); + if (name === "plugin.json" || name === "manifest.json" || name.endsWith(".manifest.json")) { + candidates.push(join(root, entry.name)); + } + } +} + +function classifyManifest(path, manifest) { + const lowerPath = path.toLowerCase(); + if (manifest.openclawPlugin === true || manifest.plugin === true || lowerPath.endsWith("/plugin.json")) { + return "plugin-manifest"; + } + if (manifest.openclawExtension === true || manifest.extension === true || lowerPath.includes("/extensions/")) { + return "extension-manifest"; + } + if (Array.isArray(manifest.contributes) || manifest.activationEvents || manifest.main) { + return lowerPath.includes("plugin") ? "plugin-manifest" : "extension-manifest"; + } + return null; +} + +function classifyCapability(capability, surfaces) { + const matchedSurfaceIds = matchSurfaceIds(capability, surfaces); + return { + ...capability, + modeled: matchedSurfaceIds.length > 0, + matchedSurfaceIds, + matchStatus: matchedSurfaceIds.length === 0 + ? "unmodeled" + : matchedSurfaceIds.length === 1 ? "matched" : "ambiguous" + }; +} + +function matchSurfaceIds(capability, surfaces) { + const normalizedName = normalizeToken(capability.name); + const mapped = commandSurfaceMap.get(normalizedName) ?? []; + const surfaceIds = new Set(surfaces.map((surface) => surface.id)); + const matches = new Set(mapped.filter((id) => surfaceIds.has(id))); + + for (const surface of surfaces) { + const haystack = [ + surface.id, + surface.title, + surface.ownerArea, + ...(surface.purposes ?? []) + ].map(normalizeToken); + if (haystack.includes(normalizedName)) { + matches.add(surface.id); + } + } + + return [...matches].sort(); +} + +function summarizeCoverage(capabilities, modeledSurfaces, options = {}) { + const unmodeled = capabilities.filter((capability) => !capability.modeled); + const matched = capabilities.filter((capability) => capability.modeled); + const ambiguous = matched.filter((capability) => capability.matchStatus === "ambiguous"); + const requiredModeled = options.requiredModeled ?? []; + const capabilitiesById = new Map(capabilities.map((capability) => [capability.id, capability])); + const blockers = requiredModeled.flatMap((id) => { + const capability = capabilitiesById.get(id); + if (!capability) { + return [{ + kind: "required-capability-missing", + capability: id, + message: `required inventory capability ${id} was not discovered` + }]; + } + if (!capability.modeled) { + return [{ + kind: "required-capability-unmodeled", + capability: id, + name: capability.name, + source: capability.source, + message: `required inventory capability ${id} is not mapped to a Kova surface` + }]; + } + return []; + }); + return { + discoveredCount: capabilities.length, + modeledSurfaceCount: modeledSurfaces.length, + matchedCount: matched.length, + ambiguousCount: ambiguous.length, + unmodeledCount: unmodeled.length, + requiredModeled, + ok: blockers.length === 0, + blockers, + warnings: [ + ...unmodeled.map((capability) => ({ + kind: "unmodeled-capability", + capability: capability.id, + name: capability.name, + source: capability.source, + message: `${capability.kind} ${capability.name} is not mapped to a Kova surface` + })), + ...ambiguous.map((capability) => ({ + kind: "ambiguous-capability", + capability: capability.id, + name: capability.name, + source: capability.source, + matchedSurfaceIds: capability.matchedSurfaceIds, + message: `${capability.kind} ${capability.name} maps to multiple Kova surfaces` + })) + ], + unmodeled: unmodeled.map((capability) => ({ + id: capability.id, + kind: capability.kind, + name: capability.name, + source: capability.source, + path: capability.path + })) + }; +} + +function parseHelpCommands(text) { + const commands = []; + let inCommands = false; + for (const rawLine of String(text ?? "").split(/\r?\n/)) { + const line = rawLine.replace(/\u001b\[[0-9;]*m/g, ""); + if (/^\s*(commands|available commands):\s*$/i.test(line)) { + inCommands = true; + continue; + } + if (inCommands && /^\S/.test(line) && !/^\s/.test(rawLine)) { + inCommands = false; + } + if (!inCommands) { + continue; + } + const match = line.match(/^\s{2,}([a-z][a-z0-9:-]*)\b/i); + if (match) { + commands.push(match[1]); + } + } + return commands.filter((command) => !["help", "completion"].includes(command)); +} + +function firstUsefulHelpLine(text) { + return String(text ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line && !/^usage:/i.test(line) && !/^commands:/i.test(line)) ?? null; +} + +function parseList(raw) { + if (!raw || raw === true) { + return []; + } + const values = Array.isArray(raw) ? raw : String(raw).split(","); + return values.map((value) => value.trim()).filter(Boolean); +} + +function normalizeOptionalPath(value) { + if (!value || value === true) { + return null; + } + return resolveFromCwd(String(value)); +} + +function normalizeOptionalCommand(value) { + if (!value || value === true) { + return null; + } + const command = String(value); + return command.includes("/") || command.startsWith(".") ? resolveFromCwd(command) : command; +} + +function normalizeToken(value) { + return String(value ?? "") + .toLowerCase() + .replace(/openclaw/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/src/main.mjs b/src/main.mjs index 35de9e5..0e59fe9 100644 --- a/src/main.mjs +++ b/src/main.mjs @@ -1,5 +1,6 @@ import { parseFlags, printHelp } from "./cli.mjs"; import { runCleanupCliCommand } from "./commands/cleanup.mjs"; +import { runInventoryCommand } from "./commands/inventory.mjs"; import { runMatrixCommand } from "./commands/matrix.mjs"; import { runPlanCommand } from "./commands/plan.mjs"; import { runReportCommand } from "./commands/report.mjs"; @@ -42,6 +43,11 @@ export async function main(argv) { return; } + if (command === "inventory") { + await runInventoryCommand(flags); + return; + } + if (command === "run") { await runScenarioCommand(flags); return; diff --git a/src/selfcheck.mjs b/src/selfcheck.mjs index cfe55c4..9c45f86 100644 --- a/src/selfcheck.mjs +++ b/src/selfcheck.mjs @@ -143,6 +143,7 @@ export async function runSelfCheck(flags = {}) { throw new Error("every scenario must declare the surface requirement ids it proves"); } })); + checks.push(await inventoryPlanCheck(tmp)); checks.push(await jsonCommandCheck("matrix-plan-json", "node bin/kova.mjs matrix plan --profile smoke --target runtime:stable --include scenario:fresh-install --parallel 2 --json", (data) => { assertEqual(data.schemaVersion, "kova.matrix.plan.v1", "matrix plan schema"); assertEqual(data.profile?.id, "smoke", "matrix profile id"); @@ -3794,6 +3795,79 @@ async function cleanupArtifactsCheck(tmp) { }; } +async function inventoryPlanCheck(tmp) { + const binDir = join(tmp, "inventory-bin"); + const repoDir = join(tmp, "inventory-openclaw"); + const openclawBin = join(binDir, "openclaw"); + await mkdir(binDir, { recursive: true }); + await mkdir(join(repoDir, "plugins", "bundled"), { recursive: true }); + await mkdir(join(repoDir, "extensions", "dashboard"), { recursive: true }); + await writeFile(openclawBin, `#!/bin/sh +case "$1" in + --help) + cat <<'HELP' +Usage: openclaw + +Commands: + dashboard Start dashboard + plugins Manage plugins + unknownx Experimental command +HELP + ;; + dashboard) + echo "OpenClaw dashboard help" + ;; + plugins) + echo "OpenClaw plugins help" + ;; + unknownx) + echo "OpenClaw unknownx help" + ;; + *) + echo "unexpected args: $*" >&2 + exit 2 + ;; +esac +`, "utf8"); + await chmod(openclawBin, 0o755); + await writeFile(join(repoDir, "package.json"), `${JSON.stringify({ + name: "openclaw", + scripts: { + build: "pnpm build", + "package-release": "node scripts/package-release.mjs" + } + }, null, 2)}\n`, "utf8"); + await writeFile(join(repoDir, "plugins", "bundled", "plugin.json"), `${JSON.stringify({ + name: "plugins", + description: "Bundled plugin manifest", + openclawPlugin: true + }, null, 2)}\n`, "utf8"); + await writeFile(join(repoDir, "extensions", "dashboard", "manifest.json"), `${JSON.stringify({ + name: "dashboard", + description: "Dashboard extension", + openclawExtension: true + }, null, 2)}\n`, "utf8"); + + return jsonCommandCheck( + "inventory-plan-json", + `node bin/kova.mjs inventory plan --openclaw-bin ${quoteShell(openclawBin)} --openclaw-repo ${quoteShell(repoDir)} --require-modeled cli:unknownx --json`, + (data) => { + assertEqual(data.schemaVersion, "kova.inventory.plan.v1", "inventory schema"); + assertEqual(data.sources?.find((source) => source.id === "openclaw-help")?.status, "scanned", "inventory help source"); + assertEqual(data.sources?.find((source) => source.id === "package-scripts")?.status, "scanned", "inventory package source"); + assertEqual(data.sources?.find((source) => source.id === "manifests")?.status, "scanned", "inventory manifests source"); + assertEqual(data.capabilities?.some((capability) => capability.id === "cli:dashboard" && capability.matchedSurfaceIds?.includes("dashboard")), true, "dashboard command mapped"); + assertEqual(data.capabilities?.some((capability) => capability.id === "cli:unknownx" && capability.matchStatus === "unmodeled"), true, "unknown command warning"); + assertEqual(data.capabilities?.some((capability) => capability.kind === "package-script"), true, "package scripts discovered"); + assertEqual(data.capabilities?.some((capability) => capability.kind === "plugin-manifest"), true, "plugin manifest discovered"); + assertEqual(data.capabilities?.some((capability) => capability.kind === "extension-manifest"), true, "extension manifest discovered"); + assertEqual((data.coverage?.warnings ?? []).some((warning) => warning.capability === "cli:unknownx"), true, "unmodeled warning emitted"); + assertEqual(data.coverage?.ok, false, "required unmodeled capability blocks inventory coverage"); + assertEqual((data.coverage?.blockers ?? []).some((blocker) => blocker.capability === "cli:unknownx"), true, "required unmodeled blocker emitted"); + } + ); +} + function readinessClassificationCheck() { try { const record = {