From 53ddd08ceabab586e674d59ca327c8f539a196fe Mon Sep 17 00:00:00 2001 From: Prompt Stack Date: Sun, 6 Sep 2026 11:52:59 -0400 Subject: [PATCH 1/2] feat(discovery): support stack category and facet filters --- README.md | 10 ++- .../2026-09-06-stack-catalog-facets.md | 62 +++++++++++++++++++ packages/core/src/index.js | 1 + packages/core/src/installer.js | 5 +- .../src/__tests__/unit/skill-facets.test.js | 43 ++++++++++++- packages/registry-client/src/index.js | 8 +-- packages/registry-client/src/skill-facets.js | 14 +++-- src/commands/info.js | 11 +++- src/commands/list.js | 2 +- src/commands/skill-display.js | 8 ++- 10 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 docs/swe-compliance/2026-09-06-stack-catalog-facets.md diff --git a/README.md b/README.md index 5c53a44..a5f9063 100644 --- a/README.md +++ b/README.md @@ -506,15 +506,21 @@ chmod 600 ~/.rudi/secrets.json MIT -## Skill categories and upgrade behavior +## Stack and skill categories; skill upgrades -Skills use seven primitive categories: web, code, data, documents, media, +Stacks and skills use seven primitive categories: web, code, data, documents, media, communication and agents. Capability, domain and provider values are read from `capability:`, `domain:` and `provider:` tags. Search and list accept `--category`, `--capability`, `--domain`, `--provider` and `--role` filters. `--role=workflow` refers to a skill's role; `--workflows` still selects the separate workflow package kind. +Stacks expose the same `facets` in search, installed listings and +`rudi info stack: --json`, without `skillRole` or `operatorFor` fields. +For example, `rudi search --all --stacks --provider=vercel --capability=deploy` +finds the deployment stack, and `rudi list stacks --category=web` filters the +installed inventory. The role filter applies only to skills. + The primary operator role is derived from a stack's `related.operatorSkill`. Requiring a stack does not make a workflow its operator. Skill JSON includes `skillRole`, `operatorFor` and `facets`; installed inventory uses local/cached diff --git a/docs/swe-compliance/2026-09-06-stack-catalog-facets.md b/docs/swe-compliance/2026-09-06-stack-catalog-facets.md new file mode 100644 index 0000000..df3d22d --- /dev/null +++ b/docs/swe-compliance/2026-09-06-stack-catalog-facets.md @@ -0,0 +1,62 @@ +# Stack catalog facet discovery + +The approved stack catalog health execution extends category and facet +discovery from skills to stacks. Registry owns authored classification; +registry-client owns normalization, facet extraction and matching. Native skill +roles remain derived only for skills from operator relationships. + +## Scope and invariants + +- Base: `89b215068ac0cba607b584b67ee347a8195d5415`; initially clean. +- Risk: medium, shared search/list/info behavior. +- Preserve package IDs, source identities, installed-reader compatibility, + skill-role semantics, ordinary tags, and missing/invalid-input behavior. +- No new CLI command, stack installation, daemon change or native activation. +- Standardize the existing facet implementation; do not add a second parser. +- Local implementation and tests are authorized. Commit, push, PR, merge and + release are separate gates. Planned slices: behavior/tests/docs, then the + regenerated tracked CLI bundle. + +## Execution and proof + +1. Record a failing behavioral test for stack provider/capability/category + matching and verify its expected failure. +2. Extend the current facet owner; keep skill role assignment skill-only. +3. Verify real search/list/info consumers and legacy behavior. +4. Run focused and full CLI tests, build, focused debt and package checks. +5. Review independently against Standards, Spec and Proof; resolve findings. +6. Keep the generated bundle current, preserve the candidate, and record a + closeout receipt or owned receipt gap before delivery. + +The companion registry checklist records the complete 51-stack outcome. +Machine-local proof is retained under +`~/.rudi/outputs/stack-catalog-execution-2026-09-06/`. + +## Completion evidence + +- Shared `describePackage` extracts category/facets for stacks and skills; + the `describeSkill` export remains compatible. Stack descriptions return + before skill-role derivation. Search, installed list and info use that owner. +- Failing search and installed-list/info tests were recorded before the changes + (`cli-facets-red.log`, `cli-installed-facets-red.log`), followed by four + passing facet tests. Final `pnpm test` passes 811 tests with the verified + Node 20.20.2 override; the default installed 20.10 fallback has two unrelated + existing router-test failures. Test assertions were not weakened. +- Frozen-lockfile preparation, build, focused debt, repository debt runner and + package checks pass. Independent review identified stale dependencies in the + first local build; refreshing from the unchanged lockfile corrected it. + Final bundle SHA-256 is + `d27a5240cf3377b1c9c4d9c0e2e16b90c5fd6445b72baaa0503f44b358dded6f`, + independently matching the admin Mac's fresh frozen-lockfile build. +- The independent reviewer ran 52 source/bundle boundary checks, including + legacy metadata, all facets, role filtering, malformed input and network + traps for installed reads. Final Standards, Spec and Proof verdicts pass; + the original bundle finding is closed in `review-cli.md`. +- The admin Mac's isolated candidate passes all 811 tests, build, debt and + package checks. Source changes and build are retained locally in the original + CLI checkout on `codex/stack-catalog-health-20260906`. Main, publishing, + live installs and native host projections are outside this scope. + +Status: implementation and independent review complete; retain the candidate +for separately authorized publication. Final source/checksum and closeout +evidence is recorded in the execution report. diff --git a/packages/core/src/index.js b/packages/core/src/index.js index a4f2d91..609daa1 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -29,6 +29,7 @@ export { searchPackages, getPackage, listPackages, + describePackage, describeSkill, getAvailableRegistryIndex, matchesSkillFilters, diff --git a/packages/core/src/installer.js b/packages/core/src/installer.js index e5ab6e3..a1fae0d 100644 --- a/packages/core/src/installer.js +++ b/packages/core/src/installer.js @@ -26,7 +26,7 @@ import { downloadResolvedPackage, downloadTool, verifyHash, - describeSkill, + describePackage, getAvailableRegistryIndex, } from '@learnrudi/registry-client'; import { resolvePackage, getInstallOrder } from './resolver.js'; @@ -1836,11 +1836,12 @@ export async function listInstalled(kind) { const index = packages.some(pkg => pkg.kind === 'skill') ? getAvailableRegistryIndex() : null; return packages.map(pkg => { + if (pkg.kind === 'stack') return describePackage(pkg, index); if (pkg.kind !== 'skill') return pkg; const lock = readLockfile(pkg.id); const catalogIdentity = pkg.source === 'rudi' && lock?.id === pkg.id && /^[a-f0-9]{64}$/i.test(lock.checksum || ''); - return describeSkill(pkg, index, { catalogIdentity }); + return describePackage(pkg, index, { catalogIdentity }); }); } diff --git a/packages/registry-client/src/__tests__/unit/skill-facets.test.js b/packages/registry-client/src/__tests__/unit/skill-facets.test.js index 2179bc3..755cdd6 100644 --- a/packages/registry-client/src/__tests__/unit/skill-facets.test.js +++ b/packages/registry-client/src/__tests__/unit/skill-facets.test.js @@ -10,7 +10,8 @@ const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)); const index = { schemaVersion: '2', packages: { - 'stack:vercel': { id: 'stack:vercel', kind: 'stack', name: 'Vercel', version: '1.0.0', related: { + 'stack:vercel': { id: 'stack:vercel', kind: 'stack', name: 'Vercel', version: '1.0.0', + meta: { category: 'web', description: 'Deploy websites', tags: ['capability:deploy', 'provider:vercel'] }, related: { operatorSkill: 'skill:vercel', skills: ['skill:vercel', 'skill:publish-site'], } }, 'skill:vercel': { id: 'skill:vercel', kind: 'skill', name: 'Vercel Operator', version: '1.0.0', @@ -58,3 +59,43 @@ test('CLI query and all-skills JSON searches apply the same facet filters', () = const query = search('', {}, ['search', 'web', '--skills', '--role=workflow', '--json']); assert.deepEqual(query.map(pkg => pkg.id), ['skill:publish-site']); }); + +test('CLI stack searches match shared facets without assigning a skill role', () => { + const args = ['--stacks', '--category=web', '--provider=vercel', '--capability=deploy', '--json']; + const all = search('', {}, ['search', '--all', ...args]); + const query = search('', {}, ['search', 'web', ...args]); + assert.deepEqual(all.stack.map(pkg => pkg.id), ['stack:vercel']); + assert.deepEqual(query.map(pkg => pkg.id), ['stack:vercel']); + assert.deepEqual(query[0].facets, { capabilities: ['deploy'], domains: [], providers: ['vercel'] }); + assert.equal(Object.hasOwn(query[0], 'skillRole'), false); + assert.equal(Object.hasOwn(query[0], 'operatorFor'), false); + assert.deepEqual(search('', { kind: 'stack', role: 'operator' }), []); +}); + +test('installed stack list and info expose the same facets as registry search', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-stack-facets-')); + try { + const installed = path.join(root, 'home/stacks/vercel'); + fs.mkdirSync(installed, { recursive: true }); + fs.writeFileSync(path.join(installed, 'manifest.json'), JSON.stringify(index.packages['stack:vercel'])); + fs.writeFileSync(path.join(root, 'index.json'), JSON.stringify(index)); + const run = args => execFileSync(process.execPath, ['src/index.js', ...args], { + cwd: repoRoot, encoding: 'utf8', + env: { ...process.env, RUDI_HOME: path.join(root, 'home'), USE_LOCAL_REGISTRY: 'true', RUDI_REGISTRY_ROOT: root }, + }); + const listed = JSON.parse(run(['list', 'stacks', '--provider=vercel', '--capability=deploy', '--json'])); + assert.deepEqual(listed.map(pkg => pkg.id), ['stack:vercel']); + const info = JSON.parse(run(['info', 'stack:vercel', '--json'])); + assert.deepEqual(info.facets, listed[0].facets); + assert.equal(info.category, 'web'); + assert.equal(Object.hasOwn(info, 'skillRole'), false); + for (const args of [['list', 'stacks'], ['info', 'stack:vercel'], ['search', 'vercel', '--stacks']]) { + const output = run(args); + assert.match(output, /Capabilities: deploy/); + assert.match(output, /Providers: vercel/); + assert.doesNotMatch(output, /Role:|Operator for:/); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/registry-client/src/index.js b/packages/registry-client/src/index.js index a52faaa..b8f10a9 100644 --- a/packages/registry-client/src/index.js +++ b/packages/registry-client/src/index.js @@ -17,9 +17,9 @@ import { normalizeRegistryPackage, } from './registry-contract.js'; import { downloadGitHubDirectory } from './github-source.js'; -import { describeSkill, matchesSkillFilters, normalizeSkillFilters } from './skill-facets.js'; +import { describePackage, matchesSkillFilters, normalizeSkillFilters } from './skill-facets.js'; -export { describeSkill, matchesSkillFilters, normalizeSkillFilters } from './skill-facets.js'; +export { describePackage, describeSkill, matchesSkillFilters, normalizeSkillFilters } from './skill-facets.js'; export { normalizeRegistryPackage, resolveRegistryPackageForPlatform } from './registry-contract.js'; export { @@ -476,7 +476,7 @@ export async function searchPackages(query, options = {}) { const packages = listRegistryPackages(index, k); for (const raw of packages) { - const pkg = describeSkill(raw, index); + const pkg = describePackage(raw, index); if (matchesQuery(pkg, queryLower) && matchesSkillFilters(pkg, filters)) { results.push({ ...pkg, kind: k }); } @@ -590,7 +590,7 @@ export async function getManifest(pkg) { export async function listPackages(kind, options = {}) { const filters = normalizeSkillFilters(options); const index = await fetchIndex(); - return listRegistryPackages(index, kind).map(pkg => describeSkill(pkg, index)) + return listRegistryPackages(index, kind).map(pkg => describePackage(pkg, index)) .filter(pkg => matchesSkillFilters(pkg, filters)); } diff --git a/packages/registry-client/src/skill-facets.js b/packages/registry-client/src/skill-facets.js index d441da8..60b6c87 100644 --- a/packages/registry-client/src/skill-facets.js +++ b/packages/registry-client/src/skill-facets.js @@ -2,8 +2,8 @@ const FACET_KEYS = { capability: 'capabilities', domain: 'domains', provider: 'providers' }; const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -export function describeSkill(pkg, index, { catalogIdentity = true } = {}) { - if (pkg.kind !== 'skill') return pkg; +export function describePackage(pkg, index, { catalogIdentity = true } = {}) { + if (!['skill', 'stack'].includes(pkg.kind)) return pkg; const tags = Array.isArray(pkg.tags) ? pkg.tags : Array.isArray(pkg.meta?.tags) ? pkg.meta.tags : []; const facets = { capabilities: [], domains: [], providers: [] }; for (const tag of tags) { @@ -14,6 +14,8 @@ export function describeSkill(pkg, index, { catalogIdentity = true } = {}) { } } for (const key of Object.values(FACET_KEYS)) facets[key] = [...new Set(facets[key])].sort(); + const described = { ...pkg, category: pkg.category || pkg.meta?.category, tags, facets }; + if (pkg.kind === 'stack') return described; const registered = catalogIdentity && index?.packages?.[pkg.id]?.kind === 'skill' && index.packages[pkg.id].id === pkg.id; const operatorFor = registered ? Object.entries(index.packages) @@ -21,15 +23,15 @@ export function describeSkill(pkg, index, { catalogIdentity = true } = {}) { && value.related?.operatorSkill === pkg.id) .map(([id]) => id).sort() : []; return { - ...pkg, - category: pkg.category || pkg.meta?.category, - tags, - facets, + ...described, skillRole: registered ? (operatorFor.length > 0 ? 'operator' : 'workflow') : 'unknown', operatorFor, }; } +// Retain the public name used by earlier registry-client consumers. +export const describeSkill = describePackage; + export function normalizeSkillFilters(options = {}) { const filters = {}; for (const key of ['category', 'role', ...Object.keys(FACET_KEYS)]) { diff --git a/src/commands/info.js b/src/commands/info.js index cffa030..6815656 100644 --- a/src/commands/info.js +++ b/src/commands/info.js @@ -13,7 +13,7 @@ import fs from 'fs'; import path from 'path'; import { getPackagePath, parsePackageId, PATHS } from '@learnrudi/env'; -import { getShimOwner, validateShim, listInstalled } from '@learnrudi/core'; +import { getShimOwner, validateShim, listInstalled, describePackage } from '@learnrudi/core'; import { inspectRuntimeInstall } from '../runtime-inspection.js'; import { printPackageLifecycle } from './package-lifecycle.js'; import { printSkillDetails } from './skill-display.js'; @@ -83,6 +83,14 @@ export async function cmdInfo(args, flags) { manifest = runtimeInspection.manifest; } + const stack = kind === 'stack' ? describePackage({ + ...manifest, id: pkgId, kind, path: installPath, + }) : null; + if (stack && flags.json) { + console.log(JSON.stringify(stack, null, 2)); + return; + } + console.log(`\nPackage: ${pkgId}`); console.log('─'.repeat(50)); @@ -97,6 +105,7 @@ export async function cmdInfo(args, flags) { (manifest?.npmPackage ? 'npm' : manifest?.pipPackage ? 'pip' : kind); console.log(` Install Type: ${installType}`); printPackageLifecycle(manifest, ' '); + if (stack) printSkillDetails(stack, ' '); // Source if (manifest?.source) { diff --git a/src/commands/list.js b/src/commands/list.js index 05d6fe9..a858123 100644 --- a/src/commands/list.js +++ b/src/commands/list.js @@ -250,7 +250,7 @@ export async function cmdList(args, flags) { } printPackageLifecycle(pkg, ' '); printSkillDetails(pkg); - if (pkg.kind !== 'skill' && pkg.category) { + if (!['skill', 'stack'].includes(pkg.kind) && pkg.category) { console.log(` Category: ${pkg.category}`); } if (pkg.tags && pkg.tags.length > 0) { diff --git a/src/commands/skill-display.js b/src/commands/skill-display.js index 93370e6..0674ab9 100644 --- a/src/commands/skill-display.js +++ b/src/commands/skill-display.js @@ -1,8 +1,10 @@ export function printSkillDetails(pkg, indent = ' ') { - if (pkg.kind !== 'skill') return; + if (!['skill', 'stack'].includes(pkg.kind)) return; if (pkg.category) console.log(`${indent}Category: ${pkg.category}`); - console.log(`${indent}Role: ${pkg.skillRole || 'unknown'}`); - if (pkg.operatorFor?.length) console.log(`${indent}Operator for: ${pkg.operatorFor.join(', ')}`); + if (pkg.kind === 'skill') { + console.log(`${indent}Role: ${pkg.skillRole || 'unknown'}`); + if (pkg.operatorFor?.length) console.log(`${indent}Operator for: ${pkg.operatorFor.join(', ')}`); + } for (const [field, label] of [['capabilities', 'Capabilities'], ['domains', 'Domains'], ['providers', 'Providers']]) { if (pkg.facets?.[field]?.length) console.log(`${indent}${label}: ${pkg.facets[field].join(', ')}`); } From e54091c285afb9e8466f603fa902dde4e0d1c939 Mon Sep 17 00:00:00 2001 From: Prompt Stack Date: Sun, 6 Sep 2026 11:53:41 -0400 Subject: [PATCH 2/2] build(cli): refresh bundle for stack facet discovery --- dist/index.cjs | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index 0c3d92b..7e7b4a0 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -1195,8 +1195,8 @@ var init_github_source = __esm({ }); // packages/registry-client/src/skill-facets.js -function describeSkill(pkg, index, { catalogIdentity = true } = {}) { - if (pkg.kind !== "skill") return pkg; +function describePackage(pkg, index, { catalogIdentity = true } = {}) { + if (!["skill", "stack"].includes(pkg.kind)) return pkg; const tags = Array.isArray(pkg.tags) ? pkg.tags : Array.isArray(pkg.meta?.tags) ? pkg.meta.tags : []; const facets = { capabilities: [], domains: [], providers: [] }; for (const tag of tags) { @@ -1207,13 +1207,12 @@ function describeSkill(pkg, index, { catalogIdentity = true } = {}) { } } for (const key of Object.values(FACET_KEYS)) facets[key] = [...new Set(facets[key])].sort(); + const described = { ...pkg, category: pkg.category || pkg.meta?.category, tags, facets }; + if (pkg.kind === "stack") return described; const registered = catalogIdentity && index?.packages?.[pkg.id]?.kind === "skill" && index.packages[pkg.id].id === pkg.id; const operatorFor = registered ? Object.entries(index.packages).filter(([id, value]) => id.startsWith("stack:") && value?.id === id && value.kind === "stack" && value.related?.operatorSkill === pkg.id).map(([id]) => id).sort() : []; return { - ...pkg, - category: pkg.category || pkg.meta?.category, - tags, - facets, + ...described, skillRole: registered ? operatorFor.length > 0 ? "operator" : "workflow" : "unknown", operatorFor }; @@ -1240,11 +1239,12 @@ function matchesSkillFilters(pkg, filters) { } return true; } -var FACET_KEYS, SLUG; +var FACET_KEYS, SLUG, describeSkill; var init_skill_facets = __esm({ "packages/registry-client/src/skill-facets.js"() { FACET_KEYS = { capability: "capabilities", domain: "domains", provider: "providers" }; SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + describeSkill = describePackage; } }); @@ -1538,7 +1538,7 @@ async function searchPackages(query, options = {}) { for (const k of kinds) { const packages = listRegistryPackages(index, k); for (const raw of packages) { - const pkg = describeSkill(raw, index); + const pkg = describePackage(raw, index); if (matchesQuery(pkg, queryLower) && matchesSkillFilters(pkg, filters)) { results.push({ ...pkg, kind: k }); } @@ -1614,7 +1614,7 @@ async function getManifest(pkg) { async function listPackages(kind, options = {}) { const filters = normalizeSkillFilters(options); const index = await fetchIndex(); - return listRegistryPackages(index, kind).map((pkg) => describeSkill(pkg, index)).filter((pkg) => matchesSkillFilters(pkg, filters)); + return listRegistryPackages(index, kind).map((pkg) => describePackage(pkg, index)).filter((pkg) => matchesSkillFilters(pkg, filters)); } function resolvedBinEntries(bins, packageId) { const entries = Array.isArray(bins) ? bins.map((name) => ({ name, path: name })) : Object.entries(bins || {}).map(([name, config]) => ({ @@ -12147,10 +12147,11 @@ async function listInstalled(kind) { } const index = packages.some((pkg) => pkg.kind === "skill") ? getAvailableRegistryIndex() : null; return packages.map((pkg) => { + if (pkg.kind === "stack") return describePackage(pkg, index); if (pkg.kind !== "skill") return pkg; const lock = readLockfile(pkg.id); const catalogIdentity = pkg.source === "rudi" && lock?.id === pkg.id && /^[a-f0-9]{64}$/i.test(lock.checksum || ""); - return describeSkill(pkg, index, { catalogIdentity }); + return describePackage(pkg, index, { catalogIdentity }); }); } async function updatePackage(id, options = {}) { @@ -13950,6 +13951,7 @@ __export(src_exports, { createStackDependencyInstallCommand: () => createStackDependencyInstallCommand, createToolIndex: () => createToolIndex, deleteLockfile: () => deleteLockfile, + describePackage: () => describePackage, describeSkill: () => describeSkill, discoverSkillPackages: () => discoverSkillPackages, discoverStackTools: () => discoverStackTools, @@ -21891,10 +21893,12 @@ function printPackageLifecycle(pkg, indent = "") { // src/commands/skill-display.js function printSkillDetails(pkg, indent = " ") { - if (pkg.kind !== "skill") return; + if (!["skill", "stack"].includes(pkg.kind)) return; if (pkg.category) console.log(`${indent}Category: ${pkg.category}`); - console.log(`${indent}Role: ${pkg.skillRole || "unknown"}`); - if (pkg.operatorFor?.length) console.log(`${indent}Operator for: ${pkg.operatorFor.join(", ")}`); + if (pkg.kind === "skill") { + console.log(`${indent}Role: ${pkg.skillRole || "unknown"}`); + if (pkg.operatorFor?.length) console.log(`${indent}Operator for: ${pkg.operatorFor.join(", ")}`); + } for (const [field, label] of [["capabilities", "Capabilities"], ["domains", "Domains"], ["providers", "Providers"]]) { if (pkg.facets?.[field]?.length) console.log(`${indent}${label}: ${pkg.facets[field].join(", ")}`); } @@ -30293,7 +30297,7 @@ ${headingForKind2(pkgKind)} (${pkgs.length}):`); } printPackageLifecycle(pkg, " "); printSkillDetails(pkg); - if (pkg.kind !== "skill" && pkg.category) { + if (!["skill", "stack"].includes(pkg.kind) && pkg.category) { console.log(` Category: ${pkg.category}`); } if (pkg.tags && pkg.tags.length > 0) { @@ -38150,6 +38154,16 @@ async function cmdInfo(args, flags) { } manifest = runtimeInspection.manifest; } + const stack = kind === "stack" ? describePackage({ + ...manifest, + id: pkgId, + kind, + path: installPath + }) : null; + if (stack && flags.json) { + console.log(JSON.stringify(stack, null, 2)); + return; + } console.log(` Package: ${pkgId}`); console.log("\u2500".repeat(50)); @@ -38160,6 +38174,7 @@ Package: ${pkgId}`); const installType = manifest?.installType || (manifest?.npmPackage ? "npm" : manifest?.pipPackage ? "pip" : kind); console.log(` Install Type: ${installType}`); printPackageLifecycle(manifest, " "); + if (stack) printSkillDetails(stack, " "); if (manifest?.source) { if (typeof manifest.source === "string") { console.log(` Source: ${manifest.source}`);