feat: add OpenClaw inventory planning

This commit is contained in:
Shakker 2026-05-05 00:00:22 +01:00
parent 50ed866da1
commit 48740dab74
No known key found for this signature in database
8 changed files with 619 additions and 0 deletions

View File

@ -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`

View File

@ -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

View File

@ -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 <capability>` 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 <report.json> --json` returns a compact agent-facing

View File

@ -39,6 +39,7 @@ Usage:
kova setup auth [--provider <id>] [--method <mock|api-key|env-only|external-cli|oauth|skip>] [--env-var <name>] [--value <secret>] [--fallback-policy <mock|external-cli|none>] [--json]
kova self-check [--json]
kova plan [--scenario <id>] [--json]
kova inventory plan [--openclaw-bin <path>] [--openclaw-repo <path>] [--subcommands <a,b>] [--require-modeled <capability[,capability]>] [--max-subcommands <n>] [--timeout-ms <n>] [--json]
kova run --target <selector> [--from <selector>] [--scenario <id>] [--state <id>] [--auth <mock|live|skip>] [--repeat <n>] [--baseline [path]] [--save-baseline [path] --reviewed-good] [--regression-thresholds <json>] [--report-dir <path>] [--health-samples <n>] [--readiness-interval-ms <n>] [--resource-sample-interval-ms <n>] [--deep-profile] [--node-profile] [--heap-snapshot] [--profile-on-failure] [--execute] [--keep-env] [--retain-on-failure] [--json]
kova matrix plan --profile <id> --target <selector> [--from <selector>] [--include <filter>] [--exclude <filter>] [--parallel <n>] [--json]
kova matrix run --profile <id> --target <selector> [--from <selector>] [--include <filter>] [--exclude <filter>] [--auth <mock|live|skip>] [--parallel <n>] [--repeat <n>] [--baseline [path]] [--save-baseline [path] --reviewed-good] [--regression-thresholds <json>] [--fail-fast] [--gate] [--report-dir <path>] [--health-samples <n>] [--readiness-interval-ms <n>] [--resource-sample-interval-ms <n>] [--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.

View File

@ -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}`);
}
}
}

484
src/inventory/openclaw.mjs Normal file
View File

@ -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, "");
}

View File

@ -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;

View File

@ -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 <command>
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 = {