Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id> --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
Expand Down
43 changes: 29 additions & 14 deletions dist/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
};
Expand All @@ -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;
}
});

Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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]) => ({
Expand Down Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -13950,6 +13951,7 @@ __export(src_exports, {
createStackDependencyInstallCommand: () => createStackDependencyInstallCommand,
createToolIndex: () => createToolIndex,
deleteLockfile: () => deleteLockfile,
describePackage: () => describePackage,
describeSkill: () => describeSkill,
discoverSkillPackages: () => discoverSkillPackages,
discoverStackTools: () => discoverStackTools,
Expand Down Expand Up @@ -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(", ")}`);
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
Expand All @@ -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}`);
Expand Down
62 changes: 62 additions & 0 deletions docs/swe-compliance/2026-09-06-stack-catalog-facets.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/core/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export {
searchPackages,
getPackage,
listPackages,
describePackage,
describeSkill,
getAvailableRegistryIndex,
matchesSkillFilters,
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/installer.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
downloadResolvedPackage,
downloadTool,
verifyHash,
describeSkill,
describePackage,
getAvailableRegistryIndex,
} from '@learnrudi/registry-client';
import { resolvePackage, getInstallOrder } from './resolver.js';
Expand Down Expand Up @@ -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 });
});
}

Expand Down
43 changes: 42 additions & 1 deletion packages/registry-client/src/__tests__/unit/skill-facets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 });
}
});
8 changes: 4 additions & 4 deletions packages/registry-client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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));
}

Expand Down
14 changes: 8 additions & 6 deletions packages/registry-client/src/skill-facets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -14,22 +14,24 @@ 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)
.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,
};
}

// 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)]) {
Expand Down
Loading