From cb5b6e4abc882bba77cfa65183fd99e85db9dfe1 Mon Sep 17 00:00:00 2001 From: Prompt Stack Date: Tue, 29 Sep 2026 16:00:41 -0400 Subject: [PATCH 1/3] feat(skills): preserve private native names and host policy --- AGENTS.md | 6 + README.md | 42 ++++ .../2026-09-29-native-skill-naming.md | 78 +++++++ .../unit/native-skill-command-policy.test.js | 71 ++++++ .../unit/native-skill-policy.test.js | 204 ++++++++++++++++++ src/native-skills/lifecycle.js | 165 ++++++++------ src/native-skills/policy.js | 44 ++++ 7 files changed, 550 insertions(+), 60 deletions(-) create mode 100644 docs/swe-compliance/2026-09-29-native-skill-naming.md create mode 100644 src/__tests__/unit/native-skill-command-policy.test.js create mode 100644 src/__tests__/unit/native-skill-policy.test.js create mode 100644 src/native-skills/policy.js diff --git a/AGENTS.md b/AGENTS.md index 6df9393..bd69ad2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,12 @@ MCP config, managed instructions, and native skill projections are separate: automatically; drifted and unmanaged trees are preserved unless exact scoped `--force` is supplied. Whole-inventory force also requires `--all`. - Native skill changes set `restartRequired`; RUDI does not claim host hot reload. +- Private native names and allowed hosts come from `$RUDI_HOME/native-skills.json`. + Package IDs and receipt filenames remain stable. Receipt schema 3 separates + package ID from native name; schema 2 remains readable. Never infer ownership + from a matching name, move old targets automatically, or overwrite private + variants during a rename. Keep naming policy in the shared native lifecycle, + not individual command adapters. Discover installed stacks with `rudi list stacks --json` or inspect `~/.rudi/cache/tool-index.json`. Rebuild with `rudi index --json`. Do not use or diff --git a/README.md b/README.md index a5f9063..dbba1fb 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,48 @@ native sessions to load the new projection; RUDI does not claim hot reload. states. `rudi agent hosts --json` counts only receipt-backed, digest-matching trees as synchronized; unrelated skill directories do not qualify. +Private native names and host restrictions can be configured in +`$RUDI_HOME/native-skills.json` (default `~/.rudi/native-skills.json`): + +```json +{ + "schemaVersion": 1, + "skills": { + "skill:image-generator": { "name": "image-generate" }, + "skill:codex-project-task-archiver": { "hosts": ["codex"] } + } +} +``` + +Package IDs, canonical directories, lockfiles, and receipt filenames stay stable: +install/update/check/remove still use `skill:image-generator`; the native folder, +frontmatter name, and exact self-invocations use `image-generate`. Omitted `name` +keeps the package name. Omitted `hosts` permits all supported hosts; an empty +array excludes all hosts. Reconciliation returns `excluded` on disallowed hosts +without creating or removing a tree, including with `--force`. Explicit package +removal still cleans unchanged receipt-owned trees on previously allowed hosts. This is private local policy and +does not rename public registry packages. + +Policy objects reject unknown fields, unsupported versions, invalid names, +unknown/duplicate hosts, duplicate target names, and symlinked paths. A name +reserved by another policy entry or owned by another package cannot be taken +over, including with force. Invalid policy prevents native reconciliation. + +Receipt schema 3 records the stable `skillId` separately from the native +`skillName`; schema 2 remains readable. During a rename, an existing old target +blocks reconciliation. After that target has been deliberately preserved or +relocated, sync adopts an exact rendered new tree and updates the same receipt +atomically. A customized new tree remains `unmanaged` and the prior receipt is +retained; normal sync never recreates the retired name. A missing new tree may +be created. Use `--dry-run` to inspect these decisions first. Removal follows +the receipt's recorded target and digest, preserving unowned or changed trees. +Changing policy does not automatically move or delete existing directories. + +Older CLI versions cannot read schema 3 receipts. Keep receipt backups when +rolling out this feature; reverting the executable alone is not a complete +rollback. Keep the new CLI and private policy together on every participating +workstation before running native reconciliation there. + ### Running Headless Agent Hosts `rudi agent` is the supported headless execution surface. Foreground launches diff --git a/docs/swe-compliance/2026-09-29-native-skill-naming.md b/docs/swe-compliance/2026-09-29-native-skill-naming.md new file mode 100644 index 0000000..2594504 --- /dev/null +++ b/docs/swe-compliance/2026-09-29-native-skill-naming.md @@ -0,0 +1,78 @@ +# Private native skill naming + +## Phase 0: Baseline and manual lookup + +- Scope: durable native names and host restrictions, with stable package IDs. +- Baseline: main at 60c78b09c6f2c6204c4b3349eeb6fa2fb523300b. Preserve the existing npm runtime binding work, README additions, and generated bundle changes. Baseline patch retained in the calling task's work directory. +- Manual: index, Master Engineering Doctrine, Agent Co-Pilot Operating Standard, testing doctrine, horizontal stewardship standard. +- Risk: high, because ownership receipts are persistent state used by removal. +- Horizontal scan: install, related install, update, sync, check, and removal already share src/native-skills/lifecycle.js. Resolve the shared naming contract here; do not add parallel command implementations. + +## Phase 1: Scope lock + +- In scope: private ~/.rudi/native-skills.json, strict policy validation, rendered names and invocations, receipt compatibility, preservation and reporting of conflicts, command integration tests, documentation, build and verification on both Macs. +- Non-goals: public package renames, registry edits, changes to private skill bodies, unrelated runtime binding work, automatic deletion of old or customized folders. +- Interface: schemaVersion 1 with skills keyed by package ID; each entry can specify name and hosts. Missing policy preserves defaults. Receipt filenames remain keyed by stable package ID; schema 3 separates skillId from skillName and reads schema 2 safely. +- Trust boundaries: JSON configuration, symlinks, names, host names, native trees, receipts. Invalid or ambiguous ownership fails closed. Exact rendered trees may be adopted; differing custom trees remain unmanaged. +- Rollback: preserve prior receipts on failure; stage tree writes with existing guarded promotion and rollback. Existing old targets block renaming until deliberately reconciled. +- Commit slices: (1) policy and lifecycle behavior with tests; (2) docs and verification evidence; (3) generated build. Commits, publication, and installed CLI activation are not authorized by the execution skill alone. +- Review: fresh-context Standards/Spec/Proof review after tests; high-risk migration proof before live receipt changes. + +## Phase 2: Red tests + +- One behavior at a time: native alias, invalid policy, excluded host, old receipt migration, collision/drift preservation, metadata invocation, discovery/removal and command paths. +- Record red/green commands below as performed. + +## Phase 3: Implementation + +- Allowed paths: src/native-skills/, affected unit tests and necessary command adapters, README.md, AGENTS.md, this checklist, generated dist output. +- No new dependency; structured lifecycle results retain package ID, native name, state/action, error, and restart requirement. + +## Phase 4: Green tests and refactor + +- Rerun unchanged red command, then affected lifecycle and command tests. Preserve existing safety tests. + +## Phase 5: Full verification + +- Implementation verification completed: targeted and full suites, build, focused SWE debt scan, repository debt runner, package dry-run, isolated real-state rehearsal, independent review, and admin Mac source reconciliation. Clean release verification is recorded below; publication and live activation remain delivery steps. + +## Phase 6: Docs, contracts, and closure + +- Pending: evidence, exact delivery boundary, closeout receipt, final verdict. Goal remains active until the full approved outcome is verified or a remaining gate is explicitly reported. + +## Implementation execution evidence + +- Red/green: `pnpm test src/__tests__/unit/native-skill-policy.test.js` first failed on the old folder name, then passed after policy wiring. Subsequent unchanged tests failed then passed for excluded hosts, schema-2 adoption, mapped discovery/removal, bundled metadata invocation, cross-package ownership collision, and missing skill namespace. +- Regression: 95 focused lifecycle/command tests passed before additional boundary coverage. A separate child-process integration test exercises actual native adapters for direct install, related install, update, sync, check and removal with an isolated HOME; package fetching is injected, native lifecycle is real. +- Runtime provenance: Homebrew's default Node failed to launch (missing simdjson dylib). Node 20.10 runs focused tests but lacks import.meta.dirname needed by two existing router tests. Node 22.23.2 then exposed an existing native-addon ABI mismatch. Final verification uses checksum-verified Node 20.19.0 with `RUDI_CLI_TEST_NODE` set explicitly because the repository runner prefers bundled Node 20 over PATH. +- Focused debt: `swe_debt_scan` using `pr-review` profile reports 0 findings for the two implementation files and two new test files. The default daemon-only entrypoints reported false orphan warnings; the repository's CLI profile resolves them without code changes. +- Isolated installed-state rehearsal on primary Mac: 10 native trees copied, all retained byte/mode-identically, all retired folders remain absent after two syncs. Three exact Claude trees were adopted; seven customized variants remained unmanaged with prior receipts preserved. No live native files or receipts changed. +- Build and `npm pack --dry-run` passed; tracked bundle includes preserved pre-existing runtime binding work. No commits, publication, or installed executable activation performed. + +## Verified implementation boundary + +- Final full suite: **827 passed, 0 failed** on both primary arm64 and admin x64 Macs, using checksum-verified Node 20.19.0 in isolated diagnostic directories. This provides import.meta.dirname while retaining the native addon ABI; installed runtimes/dependencies were unchanged. +- Final focused debt: 0 errors, 0 warnings, 0 informational findings using the CLI `pr-review` profile. Repository changed-file debt runner passed on both Macs. +- Build and package dry-run passed on both Macs. Source and build checksum parity verified; no installed CLI, native naming policy, native tree, or receipt was activated/modified during this implementation phase. +- Independent review: initially revise for duplicate schema-3 receipts claiming one removal target. A regression first observed unsafe removal, then passed after ownership checks were added to removal and host summary. Focused confirmation: Standards pass, Spec pass, Proof pass, overall pass. Explicit host-exclusion removal behavior is documented and covered. +- Admin source reconciliation: exact seven-file patch applied only after baseline SHA-256 checks, preserving its existing dirty work. Both repos retain main at the baseline revision; nothing staged, committed, pushed, published, or released. +- Real-state rehearsals: 20 native trees across both Macs retained their exact bytes/modes through dry-run, apply, and repeated sync in temporary copies. Seven exact trees adopted; 13 customized trees preserved as unmanaged. No old-name folder was recreated. Canonical package IDs remained unchanged. +- Horizontal disposition: standardize contract, resolved in this change through one shared lifecycle. No duplicate command-specific implementation or registry/package-ID migration. +- Planned slices remain uncommitted: implementation/tests; docs; generated bundle. Existing runtime-binding work is preserved and must remain separately attributable during any later commit/release preparation. +- Final verdict at this boundary: **ready for authorized rollout**, not activated. Remaining: authorize and carry out installed CLI delivery plus the exact private policy on both Macs, retain receipt backups, run exact non-forced syncs and verify live checks. A public release/commit/push is a separate authorization gate. Reverting only the executable cannot read new schema-3 receipts; rollback must preserve paired receipt state. +- Goal remains active until authorized delivery and live verification are complete. + +## Authorized release preparation + +- User explicitly approved commit, PR/merge, release, and installation on both Macs after the implementation review. +- Release version: 1.10.27 (npm latest observed as 1.10.26 before preparation). +- Isolated release branch starts from accepted origin/main; only the task-owned source/test/docs patch is transferred. Generated output is rebuilt here. Unrelated runtime-binding edits remain in the original checkouts and are excluded from this release. +- Managed worktree creation was unavailable for the projectless chat (not a Git repository); a named Git worktree under RUDI/worktrees/cli is used as the documented fallback. +- Fresh release tests, build, production audit, package inventory, PR CI, npm trusted publication, peer source reconciliation, installed checksums and live policy verification are required before completion. + +- Release audit red: production dependency audit rejected fast-uri 3.1.6 for GHSA-qw65-cvwx-89v3 and GHSA-58mr-gqgx-xq4g. The existing override and lock resolution advance narrowly to 3.1.8; the dependency-floor contract test follows the patched floor. Production audit then passed with zero vulnerabilities. No other dependency resolution changed. +- Clean release baseline excluded two tests belonging to unrelated runtime work: the initial isolated suite passed 825/825, compared with 827 in the original mixed checkout. The isolated suite is rerun after dependency remediation. + +- Final isolated release proof: 825/825 tests passed after remediation, production audit has zero vulnerabilities, focused CLI debt scan has zero findings, six-file package inventory matches the publishing contract, and nine direct compiled-CLI invocations pass the policy/alias/preservation/removal checks. + +- Fresh independent release review: Standards pass, Spec pass, Proof pass, no actionable findings. Reviewer reran 21 tests, reproduced the generated artifacts byte-for-byte, and explicitly scanned all five changed JavaScript files with zero findings. Reviewed bundle SHA-256: 237bd3a7efa0ec90c73ecfdfa7033d97238d233bf24eb72bce3cdedc68ef8b3a. PR CI and live rollout remain delivery proof. diff --git a/src/__tests__/unit/native-skill-command-policy.test.js b/src/__tests__/unit/native-skill-command-policy.test.js new file mode 100644 index 0000000..82456ca --- /dev/null +++ b/src/__tests__/unit/native-skill-command-policy.test.js @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +test('install, related install, update, sync, check and removal honor one private naming policy', t => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-native-command-policy-')); + t.after(() => fs.rmSync(home, { recursive: true, force: true })); + const rudiHome = path.join(home, '.rudi'); + const source = path.join(rudiHome, 'skills/image-generator'); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(path.join(source, 'SKILL.md'), '---\nname: image-generator\ndescription: Generate images\n---\n\nVersion one.\n'); + fs.writeFileSync(path.join(rudiHome, 'native-skills.json'), JSON.stringify({ + schemaVersion: 1, skills: { 'skill:image-generator': { name: 'image-generate', hosts: ['codex', 'claude'] } }, + })); + const script = String.raw` + import assert from 'node:assert/strict'; + import fs from 'node:fs'; + import path from 'node:path'; + import { cmdInstall, syncRelatedSkillWrappers } from './src/commands/install.js'; + import { runUpdate } from './src/commands/update.js'; + import { syncSelectedSkillsToNativeHosts } from './src/commands/skills.js'; + import { getSkillCheck } from './src/commands/check.js'; + import { cleanupRemovedSkill } from './src/commands/remove.js'; + const source = path.join(process.env.RUDI_HOME, 'skills/image-generator'); + const skill = { id: 'skill:image-generator', kind: 'skill', name: 'image-generator', version: '1.0.0', source: 'rudi', path: source, entryPath: path.join(source, 'SKILL.md'), dependencies: [], requires: {} }; + const agents = [{ id: 'codex' }, { id: 'claude-code' }]; + const listInstalled = async () => [skill]; + await cmdInstall([skill.id], {}, { + fetchIndex: async () => ({}), resolvePackage: async () => skill, + installPackage: async () => ({ success: true, id: skill.id, path: source, installed: [skill.id] }), + installedAgents: agents, exit: code => assert.fail('unexpected exit ' + code), + }); + for (const root of [process.env.CODEX_HOME, process.env.CLAUDE_HOME]) { + assert.equal(fs.existsSync(path.join(root, 'skills/image-generate/SKILL.md')), true); + assert.equal(fs.existsSync(path.join(root, 'skills/image-generator')), false); + } + await syncRelatedSkillWrappers([skill], [{ success: true, id: skill.id, path: source }], agents); + const sync = await syncSelectedSkillsToNativeHosts({ targets: ['codex', 'claude', 'gemini'], skillIds: [skill.id], dryRun: true }, { listInstalled }); + assert.equal(sync.results.codex.results[0].action, 'would_current'); + assert.equal(sync.results.gemini.results[0].action, 'excluded'); + const update = await runUpdate([skill.id], {}, { + listInstalled, fetchIndex: async () => ({}), log() {}, error: message => assert.fail(message), + updatePackage: async () => { + fs.appendFileSync(skill.entryPath, 'Version two.\n'); + return { success: true, id: skill.id, path: source }; + }, + }); + assert.equal(update.failed, 0); + assert.deepEqual(update.skillProjection.targets, ['codex', 'claude']); + assert.match(fs.readFileSync(path.join(process.env.CODEX_HOME, 'skills/image-generate/SKILL.md'), 'utf8'), /Version two/); + const checked = await getSkillCheck('image-generator', { listInstalled }); + assert.equal(checked.projections.codex.state, 'current'); + assert.equal(checked.projections.claude.state, 'current'); + assert.equal(checked.projections.gemini.state, 'excluded'); + fs.appendFileSync(path.join(process.env.CLAUDE_HOME, 'skills/image-generate/SKILL.md'), 'Private customization.\n'); + const removed = await cleanupRemovedSkill(skill); + assert.equal(removed.results.codex.action, 'removed'); + assert.equal(removed.results.claude.action, 'drifted'); + assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, 'skills/image-generate')), false); + assert.equal(fs.existsSync(path.join(process.env.CLAUDE_HOME, 'skills/image-generate')), true); + `; + assert.doesNotThrow(() => execFileSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: fileURLToPath(new URL('../../..', import.meta.url)), + env: { ...process.env, HOME: home, RUDI_HOME: rudiHome, CODEX_HOME: path.join(home, '.codex'), CLAUDE_HOME: path.join(home, '.claude'), GEMINI_HOME: path.join(home, '.gemini'), ANTIGRAVITY_HOME: path.join(home, '.antigravity') }, + encoding: 'utf8', timeout: 30000, + })); +}); diff --git a/src/__tests__/unit/native-skill-policy.test.js b/src/__tests__/unit/native-skill-policy.test.js new file mode 100644 index 0000000..4ed6c84 --- /dev/null +++ b/src/__tests__/unit/native-skill-policy.test.js @@ -0,0 +1,204 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { reconcileNativeSkill, getManagedNativeSkillHosts, summarizeNativeSkillHost, removeNativeSkillProjection } from '../../native-skills/lifecycle.js'; + +function fixture(t) { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-native-policy-')); + t.after(() => fs.rmSync(homeDir, { recursive: true, force: true })); + const entryPath = path.join(homeDir, '.rudi', 'skills', 'image-generator', 'SKILL.md'); + fs.mkdirSync(path.dirname(entryPath), { recursive: true }); + fs.writeFileSync(entryPath, '---\nname: image-generator\ndescription: Generate an image\n---\n\nUse the image workflow.\n'); + const options = { homeDir, env: {}, host: 'codex', skill: { + id: 'skill:image-generator', entryPath, version: '1.0.0', source: 'rudi', + } }; + const policyPath = path.join(homeDir, '.rudi', 'native-skills.json'); + const writePolicy = (skills) => fs.writeFileSync(policyPath, JSON.stringify({ schemaVersion: 1, skills })); + return { options, policyPath, writePolicy }; +} + +test('private native naming renders an alias while retaining the package and receipt identity', async t => { + const { options, writePolicy } = fixture(t); + writePolicy({ 'skill:image-generator': { name: 'image-generate' } }); + const result = await reconcileNativeSkill(options); + assert.equal(result.action, 'created'); + assert.equal(path.basename(result.targetDir), 'image-generate'); + assert.equal(path.basename(result.receiptPath), 'image-generator.json'); + assert.equal(result.id, 'skill:image-generator'); + assert.match(fs.readFileSync(path.join(result.targetDir, 'SKILL.md'), 'utf8'), /name: "image-generate"/); + assert.match(fs.readFileSync(path.join(result.targetDir, 'agents/openai.yaml'), 'utf8'), /\$image-generate\b/); + assert.equal(fs.existsSync(path.join(path.dirname(result.targetDir), 'image-generator')), false); + assert.equal((await reconcileNativeSkill(options)).action, 'current'); +}); + +test('host restrictions skip excluded projections even with force and a missing source', async t => { + const { options, writePolicy } = fixture(t); + writePolicy({ 'skill:image-generator': { name: 'image-generate', hosts: ['codex'] } }); + fs.unlinkSync(options.skill.entryPath); + const result = await reconcileNativeSkill({ ...options, host: 'claude', force: true }); + assert.equal(result.action, 'excluded'); + assert.equal(fs.existsSync(path.join(options.homeDir, '.claude')), false); +}); + +test('a schema 2 receipt adopts an exact renamed tree without recreating the retired name', async t => { + const { options, writePolicy } = fixture(t); + const old = await reconcileNativeSkill(options); + const legacy = JSON.parse(fs.readFileSync(old.receiptPath, 'utf8')); + legacy.schemaVersion = 2; + fs.writeFileSync(old.receiptPath, JSON.stringify(legacy)); + fs.rmSync(old.targetDir, { recursive: true }); + writePolicy({ 'skill:image-generator': { name: 'image-generate' } }); + const staged = await reconcileNativeSkill({ ...options, receiptRoot: path.join(options.homeDir, 'staged-receipts') }); + const before = fs.readFileSync(path.join(staged.targetDir, 'SKILL.md')); + assert.equal((await reconcileNativeSkill({ ...options, dryRun: true })).action, 'would_adopt'); + assert.equal(JSON.parse(fs.readFileSync(old.receiptPath)).schemaVersion, 2); + const adopted = await reconcileNativeSkill(options); + assert.equal(adopted.action, 'adopted'); + assert.deepEqual(fs.readFileSync(path.join(staged.targetDir, 'SKILL.md')), before); + const receipt = JSON.parse(fs.readFileSync(adopted.receiptPath)); + assert.equal(receipt.schemaVersion, 3); + assert.equal(receipt.skillName, 'image-generate'); + assert.equal(receipt.createdAt, legacy.createdAt); + assert.equal(fs.existsSync(old.targetDir), false); + assert.equal((await reconcileNativeSkill(options)).action, 'current'); +}); + + +test('discovery, status and removal follow the receipt native name with a stable package ID', async t => { + const { options, writePolicy } = fixture(t); + writePolicy({ 'skill:image-generator': { name: 'image-generate', hosts: ['codex'] } }); + const created = await reconcileNativeSkill(options); + assert.deepEqual(await getManagedNativeSkillHosts(options.skill, options), ['codex']); + const summary = await summarizeNativeSkillHost('codex', options); + assert.equal(summary.current, 1); + assert.equal(summary.failed, 0); + assert.equal((await removeNativeSkillProjection({ ...options, dryRun: true })).action, 'would_remove'); + assert.equal(fs.existsSync(created.targetDir), true); + assert.equal((await removeNativeSkillProjection(options)).action, 'removed'); + assert.equal(fs.existsSync(created.targetDir), false); + assert.equal(fs.existsSync(created.receiptPath), false); +}); + +test('alias rendering rewrites only exact self-invocations in bundled metadata', async t => { + const { options, writePolicy } = fixture(t); + const agents = path.join(path.dirname(options.skill.entryPath), 'agents'); + fs.mkdirSync(agents); + fs.writeFileSync(path.join(agents, 'openai.yaml'), 'interface:\n default_prompt: "Use $image-generator, then $image-generator-extra."\npolicy:\n allow_implicit_invocation: false\n'); + writePolicy({ 'skill:image-generator': { name: 'image-generate' } }); + const result = await reconcileNativeSkill(options); + assert.equal(result.action, 'created'); + assert.equal(fs.readFileSync(path.join(result.targetDir, 'agents/openai.yaml'), 'utf8'), 'interface:\n default_prompt: "Use $image-generate, then $image-generator-extra."\npolicy:\n allow_implicit_invocation: false\n'); +}); + +test('a mapped name cannot take ownership from another package even with force', async t => { + const { options, writePolicy } = fixture(t); + const owner = await reconcileNativeSkill({ ...options, skill: { ...options.skill, id: 'skill:image-generate' } }); + const original = fs.readFileSync(owner.receiptPath); + writePolicy({ 'skill:image-generator': { name: 'image-generate' } }); + const result = await reconcileNativeSkill({ ...options, force: true }); + assert.equal(result.action, 'failed'); + assert.match(result.error, /already owned/); + assert.deepEqual(fs.readFileSync(owner.receiptPath), original); + assert.equal(fs.existsSync(path.join(path.dirname(owner.receiptPath), 'image-generator.json')), false); +}); + +test('invalid policies fail closed without creating native files', async t => { + const cases = [ + '{', JSON.stringify({ schemaVersion: 2, skills: {} }), + JSON.stringify({ schemaVersion: 1, skills: { 'skill:image-generator': { name: '../escape' } } }), + JSON.stringify({ schemaVersion: 1, skills: { 'skill:image-generator': { hosts: ['unknown'] } } }), + JSON.stringify({ schemaVersion: 1, skills: { 'skill:image-generator': { hosts: ['codex', 'codex'] } } }), + JSON.stringify({ schemaVersion: 1, skills: { 'skill:image-generator': { names: 'typo' } } }), + JSON.stringify({ schemaVersion: 1, skills: { 'skill:a': { name: 'same' }, 'skill:b': { name: 'same' } } }), + ]; + for (const content of cases) { + const { options, policyPath } = fixture(t); + fs.writeFileSync(policyPath, content); + assert.equal((await reconcileNativeSkill(options)).action, 'failed'); + assert.equal(fs.existsSync(path.join(options.homeDir, '.codex')), false); + } +}); + +test('policy file symlinks are rejected without writing through them', async t => { + const { options, policyPath } = fixture(t); + const outside = path.join(options.homeDir, 'outside.json'); + fs.writeFileSync(outside, '{"schemaVersion":1,"skills":{}}'); + fs.symlinkSync(outside, policyPath); + const result = await reconcileNativeSkill(options); + assert.equal(result.action, 'failed'); + assert.match(result.error, /symbolic links/); +}); + +test('divergent renamed trees and their legacy receipt remain untouched on repeated sync', async t => { + const { options, writePolicy } = fixture(t); + const old = await reconcileNativeSkill(options); + const originalReceipt = fs.readFileSync(old.receiptPath); + fs.rmSync(old.targetDir, { recursive: true }); + writePolicy({ 'skill:image-generator': { name: 'image-generate' } }); + const custom = path.join(path.dirname(old.targetDir), 'image-generate'); + fs.mkdirSync(custom); + fs.writeFileSync(path.join(custom, 'SKILL.md'), 'private workflow'); + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = await reconcileNativeSkill(options); + assert.equal(result.action, 'unmanaged'); + assert.equal(fs.readFileSync(path.join(custom, 'SKILL.md'), 'utf8'), 'private workflow'); + assert.deepEqual(fs.readFileSync(old.receiptPath), originalReceipt); + assert.equal(fs.existsSync(old.targetDir), false); + } +}); + +test('a surviving old target blocks migration without modifying either tree or receipt', async t => { + const { options, writePolicy } = fixture(t); + const old = await reconcileNativeSkill(options); + const originalReceipt = fs.readFileSync(old.receiptPath); + writePolicy({ 'skill:image-generator': { name: 'image-generate' } }); + const result = await reconcileNativeSkill({ ...options, force: true }); + assert.equal(result.action, 'failed'); + assert.match(result.error, /Prior native skill target still exists/); + assert.deepEqual(fs.readFileSync(old.receiptPath), originalReceipt); + assert.equal(fs.existsSync(old.targetDir), true); + assert.equal(fs.existsSync(path.join(path.dirname(old.targetDir), 'image-generate')), false); +}); + +test('native naming rejects IDs without the skill namespace', async t => { + const { options, writePolicy } = fixture(t); + writePolicy({}); + const result = await reconcileNativeSkill({ ...options, skill: { ...options.skill, id: 'image-generator' } }); + assert.equal(result.action, 'failed'); + assert.match(result.error, /Invalid native skill package id/); + assert.equal(fs.existsSync(path.join(options.homeDir, '.codex')), false); +}); + +test('ambiguous receipt ownership blocks removal and is not reported as synchronized', async t => { + const { options, writePolicy } = fixture(t); + writePolicy({ 'skill:image-generator': { name: 'image-generate' } }); + const created = await reconcileNativeSkill(options); + const original = fs.readFileSync(created.receiptPath); + const duplicate = { ...JSON.parse(original), skillId: 'skill:another-package' }; + const duplicatePath = path.join(path.dirname(created.receiptPath), 'another-package.json'); + fs.writeFileSync(duplicatePath, JSON.stringify(duplicate)); + assert.equal((await removeNativeSkillProjection({ ...options, dryRun: true })).action, 'failed'); + const result = await removeNativeSkillProjection(options); + assert.equal(result.action, 'failed'); + assert.match(result.error, /already owned/); + assert.equal(fs.existsSync(created.targetDir), true); + assert.deepEqual(fs.readFileSync(created.receiptPath), original); + assert.equal(fs.existsSync(duplicatePath), true); + const summary = await summarizeNativeSkillHost('codex', options); + assert.equal(summary.current, 0); + assert.equal(summary.failed, 2); + assert.equal(summary.skillsSynchronized, false); +}); + +test('excluding a previously managed host preserves it during sync but explicit removal cleans it', async t => { + const { options, writePolicy } = fixture(t); + const created = await reconcileNativeSkill(options); + writePolicy({ 'skill:image-generator': { hosts: [] } }); + assert.equal((await reconcileNativeSkill(options)).action, 'excluded'); + assert.equal(fs.existsSync(created.targetDir), true); + assert.deepEqual(await getManagedNativeSkillHosts(options.skill, options), []); + assert.equal((await removeNativeSkillProjection(options)).action, 'removed'); + assert.equal(fs.existsSync(created.targetDir), false); +}); diff --git a/src/native-skills/lifecycle.js b/src/native-skills/lifecycle.js index cfbe491..eb4687b 100644 --- a/src/native-skills/lifecycle.js +++ b/src/native-skills/lifecycle.js @@ -5,15 +5,12 @@ import * as fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -export const NATIVE_SKILL_HOSTS = Object.freeze([ - 'codex', - 'claude', - 'gemini', - 'antigravity', -]); +import { NATIVE_SKILL_HOSTS, resolveNativeSkillRule, validateNativeSkillPolicy } from './policy.js'; + +export { NATIVE_SKILL_HOSTS }; const RESOURCE_DIRECTORIES = Object.freeze(['assets', 'references', 'scripts']); -const RECEIPT_SCHEMA_VERSION = 2; +const RECEIPT_SCHEMA_VERSION = 3; const DIGEST_PATTERN = /^[a-f0-9]{64}$/; const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const TRUSTED_PLATFORM_SYMLINKS = new Set( @@ -50,7 +47,7 @@ function yamlString(value) { export function normalizeNativeSkillName(skill) { const raw = String(skill?.id || '').replace(/^skill:/, ''); - if (!SKILL_NAME_PATTERN.test(raw)) { + if (typeof skill?.id !== 'string' || !skill.id.startsWith('skill:') || !SKILL_NAME_PATTERN.test(raw)) { throw new Error(`Invalid native skill package id: ${skill?.id || ''}`); } return raw; @@ -65,16 +62,22 @@ function defaultPrompt(skillName, description, displayName) { return `Use $${skillName} to ${action}.`; } -export function buildPortableSkillFiles(skill, sourceContent) { - const skillName = normalizeNativeSkillName(skill); +function renameSelfInvocation(content, skill, skillName) { + if (!content) return ''; + const packageName = normalizeNativeSkillName(skill); + if (packageName === skillName) return content; + return content.replace(new RegExp(`\\$${packageName}(?![a-zA-Z0-9_-])`, 'g'), `$${skillName}`); +} + +export function buildPortableSkillFiles(skill, sourceContent, skillName = normalizeNativeSkillName(skill)) { const parsed = parseSkillDocument(sourceContent); const displayName = compactText(parsed.metadata.name || skill.name || skillName, 80); // Trigger conditions can occur at the end; UI summaries have separate limits. const description = String( skill.description || parsed.metadata.description || `${displayName} RUDI skill`, ).replace(/\s+/g, ' ').trim(); - const body = parsed.body - || `Use the installed RUDI skill \`skill:${skillName}\` as the source of truth.`; + const body = renameSelfInvocation(parsed.body, skill, skillName) + || `Use the installed RUDI skill \`${skill.id}\` as the source of truth.`; const skillMd = [ '---', `name: ${yamlString(skillName)}`, @@ -87,9 +90,8 @@ export function buildPortableSkillFiles(skill, sourceContent) { return { skillName, skillMd }; } -export function buildCodexSkillFiles(skill, sourceContent) { - const baseFiles = buildPortableSkillFiles(skill, sourceContent); - const { skillName } = baseFiles; +export function buildCodexSkillFiles(skill, sourceContent, skillName = normalizeNativeSkillName(skill)) { + const baseFiles = buildPortableSkillFiles(skill, sourceContent, skillName); const parsed = parseSkillDocument(sourceContent); const displayName = humanizeSkillDisplayName(parsed.metadata.name || skill.name || skillName); const description = compactText( @@ -141,6 +143,25 @@ export function getNativeSkillReceiptRoot(options = {}) { return path.join(rudiHome, 'state', 'native-skills'); } +async function nativeSkillRule(host, skill, options) { + assertSupportedHost(host); + normalizeNativeSkillName(skill); + const policyPath = path.join(path.dirname(path.dirname(getNativeSkillReceiptRoot(options))), 'native-skills.json'); + await assertNoSymlinkPathComponents(policyPath, 'Native skill policy path', { allowMissingTail: true }); + let content; + try { + const stat = await fsp.lstat(policyPath); + assertRealEntry(stat, policyPath, 'file'); + if (stat.size > 1024 * 1024) throw new Error('Native skill policy exceeds 1 MiB'); + content = await fsp.readFile(policyPath, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return { name: normalizeNativeSkillName(skill), allowed: true }; + throw error; + } + const policy = validateNativeSkillPolicy(JSON.parse(content)); + return resolveNativeSkillRule(policy, skill.id, host); +} + export function configuredNativeSkillHosts(installedAgents = []) { const ids = new Set((installedAgents || []).map(agent => agent?.id).filter(Boolean)); const hosts = []; @@ -301,9 +322,8 @@ function resolveSourceIdentity(source) { || null; } -async function buildProjection(host, skill) { +async function buildProjection(host, skill, skillName = normalizeNativeSkillName(skill)) { assertSupportedHost(host); - const skillName = normalizeNativeSkillName(skill); if (skill.conflictingPaths?.length) { throw new Error(`Conflicting skill formats for ${skill.id}; reconcile canonical sources before native sync`); } @@ -322,8 +342,8 @@ async function buildProjection(host, skill) { const sourceContent = await fsp.readFile(sourcePath); const sourceText = sourceContent.toString('utf8'); const generated = host === 'codex' - ? buildCodexSkillFiles(skill, sourceText) - : buildPortableSkillFiles(skill, sourceText); + ? buildCodexSkillFiles(skill, sourceText, skillName) + : buildPortableSkillFiles(skill, sourceText, skillName); const entries = [{ type: 'file', relativePath: 'SKILL.md', @@ -355,6 +375,7 @@ async function buildProjection(host, skill) { packageDigest = digestEntries(sourceEntries).digest; } if (host === 'codex') { + codexMetadata = Buffer.from(renameSelfInvocation(codexMetadata.toString('utf8'), skill, skillName)); entries.push({ type: 'directory', relativePath: 'agents', mode: 0o755 }); entries.push({ type: 'file', @@ -428,6 +449,27 @@ function receiptPathFor(receiptRoot, host, skillName) { return path.join(path.resolve(receiptRoot), host, `${skillName}.json`); } +async function assertTargetOwnership(receiptRoot, host, skillId, targetDir) { + const directory = path.join(receiptRoot, host); + await assertSafeRoot(directory, 'Native skill receipt directory'); + let names; + try { + names = await fsp.readdir(directory); + } catch (error) { + if (error.code === 'ENOENT') return; + throw error; + } + for (const name of names.filter(name => name.endsWith('.json'))) { + const packageName = name.slice(0, -5); + if (!SKILL_NAME_PATTERN.test(packageName)) throw new Error(`Invalid receipt name: ${name}`); + if (`skill:${packageName}` === skillId) continue; + const receipt = await readReceipt(path.join(directory, name), { host, skillId: `skill:${packageName}` }); + if (receipt?.targetDir === targetDir) { + throw new Error(`Native skill target already owned by ${receipt.skillId}: ${targetDir}`); + } + } +} + function isIsoTimestamp(value) { if (typeof value !== 'string') return false; const parsed = new Date(value); @@ -442,19 +484,20 @@ function validateReceipt(receipt, expected = {}) { if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) { throw new Error('Native skill receipt must be an object'); } - if (receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) { + if (![2, RECEIPT_SCHEMA_VERSION].includes(receipt.schemaVersion)) { throw new Error(`Unsupported native skill receipt schema: ${receipt.schemaVersion}`); } if (!NATIVE_SKILL_HOSTS.includes(receipt.host)) { throw new Error(`Invalid native skill receipt host: ${receipt.host}`); } - if (!String(receipt.skillId || '').startsWith('skill:')) { + if (!String(receipt.skillId || '').startsWith('skill:') + || !SKILL_NAME_PATTERN.test(receipt.skillId.slice(6))) { throw new Error('Invalid native skill receipt skillId'); } if (!SKILL_NAME_PATTERN.test(receipt.skillName || '')) { throw new Error('Invalid native skill receipt skillName'); } - if (receipt.skillId !== `skill:${receipt.skillName}`) { + if (receipt.schemaVersion === 2 && receipt.skillId !== `skill:${receipt.skillName}`) { throw new Error('Native skill receipt id/name mismatch'); } if (typeof receipt.packageVersion !== 'string' || !receipt.packageVersion.trim()) { @@ -628,25 +671,29 @@ function resultBase(host, projection, targetDir, receiptPath) { export async function inspectNativeSkillProjection(options = {}) { const host = options.host; const skill = options.skill; - const projection = await buildProjection(host, skill); + const rule = await nativeSkillRule(host, skill, options); + if (!rule.allowed) return { host, id: skill.id, skillName: rule.name, state: 'excluded', restartRequired: false }; + const projection = await buildProjection(host, skill, rule.name); const targetRoot = path.resolve(options.targetRoot || getNativeSkillRoot(host, options)); const receiptRoot = path.resolve(options.receiptRoot || getNativeSkillReceiptRoot(options)); await assertSafeRoot(targetRoot, 'Native skill target root'); await assertSafeRoot(receiptRoot, 'Native skill receipt root'); const targetDir = path.join(targetRoot, projection.skillName); - const receiptPath = receiptPathFor(receiptRoot, host, projection.skillName); - const receiptExpectation = { - host, - skillId: projection.skillId, - skillName: projection.skillName, - targetDir, - }; - const receipt = await readReceipt(receiptPath, receiptExpectation); + const receiptPath = receiptPathFor(receiptRoot, host, normalizeNativeSkillName(skill)); + const receipt = await readReceipt(receiptPath, { host, skillId: projection.skillId }); + if (receipt) { + validateReceipt(receipt, { targetDir: path.join(targetRoot, receipt.skillName) }); + } + const renamed = Boolean(receipt && receipt.targetDir !== targetDir); + if (renamed && await inspectTree(receipt.targetDir)) { + throw new Error(`Prior native skill target still exists; preserve and reconcile it before renaming: ${receipt.targetDir}`); + } + await assertTargetOwnership(receiptRoot, host, skill.id, targetDir); const actual = await inspectTree(targetDir); let state; if (!actual) { state = 'missing'; - } else if (!receipt) { + } else if (!receipt || renamed) { state = 'unmanaged'; } else if (actual.digest !== receipt.renderedTreeDigest) { state = 'drifted'; @@ -665,7 +712,7 @@ export async function inspectNativeSkillProjection(options = {}) { ...resultBase(host, projection, targetDir, receiptPath), actualTreeDigest: actual?.digest || null, expectedMatchesActual: actual?.digest === projection.renderedTreeDigest, - managed: Boolean(receipt), + managed: Boolean(receipt) && !renamed, receipt, state, }; @@ -758,6 +805,7 @@ export async function reconcileNativeSkill(options = {}) { const force = options.force === true; const dryRun = options.dryRun === true; + if (inspected.state === 'excluded') return { ...base, action: 'excluded', reason: 'Host excluded by native skill policy' }; if (inspected.state === 'current') { return { ...base, action: dryRun ? 'would_current' : 'current' }; } @@ -777,7 +825,7 @@ export async function reconcileNativeSkill(options = {}) { } if (inspected.state === 'unmanaged' && inspected.expectedMatchesActual) { if (dryRun) return { ...base, action: 'would_adopt' }; - const projection = await buildProjection(host, skill); + const projection = await buildProjection(host, skill, inspected.skillName); await assertSafeRoot(path.dirname(inspected.targetDir), 'Native skill target root'); await assertSafeRoot(path.dirname(path.dirname(inspected.receiptPath)), 'Native skill receipt root'); const current = await inspectTree(inspected.targetDir); @@ -787,18 +835,16 @@ export async function reconcileNativeSkill(options = {}) { const receiptExpectation = { host, skillId: projection.skillId, - skillName: projection.skillName, - targetDir: inspected.targetDir, }; - await assertReceiptUnchanged(inspected.receiptPath, null, receiptExpectation); + await assertReceiptUnchanged(inspected.receiptPath, inspected.receipt, receiptExpectation); await atomicWriteReceipt( inspected.receiptPath, - receiptFor(host, projection, inspected.targetDir), + receiptFor(host, projection, inspected.targetDir, inspected.receipt), ); return { ...base, action: 'adopted' }; } - const projection = await buildProjection(host, skill); + const projection = await buildProjection(host, skill, inspected.skillName); const receipt = receiptFor(host, projection, inspected.targetDir, inspected.receipt); const targetChanges = inspected.actualTreeDigest !== projection.renderedTreeDigest; const action = inspected.state === 'missing' ? 'created' : 'updated'; @@ -817,8 +863,6 @@ export async function reconcileNativeSkill(options = {}) { const receiptExpectation = { host, skillId: projection.skillId, - skillName: projection.skillName, - targetDir: inspected.targetDir, }; await assertReceiptUnchanged( inspected.receiptPath, @@ -840,8 +884,6 @@ export async function reconcileNativeSkill(options = {}) { receiptExpectation: { host, skillId: projection.skillId, - skillName: projection.skillName, - targetDir: inspected.targetDir, }, priorActualDigest: inspected.actualTreeDigest, writeReceipt: options.operations?.writeReceipt || atomicWriteReceipt, @@ -968,15 +1010,19 @@ export async function removeNativeSkillProjection(options = {}) { const skill = options.skill; try { assertSupportedHost(host); - const skillName = normalizeNativeSkillName(skill); + const packageName = normalizeNativeSkillName(skill); + const rule = await nativeSkillRule(host, skill, options); const targetRoot = path.resolve(options.targetRoot || getNativeSkillRoot(host, options)); const receiptRoot = path.resolve(options.receiptRoot || getNativeSkillReceiptRoot(options)); await assertSafeRoot(targetRoot, 'Native skill target root'); await assertSafeRoot(receiptRoot, 'Native skill receipt root'); + const receiptPath = receiptPathFor(receiptRoot, host, packageName); + const receipt = await readReceipt(receiptPath, { host, skillId: skill.id }); + const skillName = receipt?.skillName || rule.name; const targetDir = path.join(targetRoot, skillName); - const receiptPath = receiptPathFor(receiptRoot, host, skillName); const receiptExpectation = { host, skillId: skill.id, skillName, targetDir }; - const receipt = await readReceipt(receiptPath, receiptExpectation); + if (receipt) validateReceipt(receipt, receiptExpectation); + await assertTargetOwnership(receiptRoot, host, skill.id, targetDir); const actual = await inspectTree(targetDir); const base = { host, id: skill.id, skillName, targetDir, receiptPath, restartRequired: false }; if (!receipt) { @@ -1098,15 +1144,15 @@ export async function summarizeNativeSkillHost(host, options = {}) { } for (const name of names) { try { - const skillName = name.slice(0, -'.json'.length); - if (!SKILL_NAME_PATTERN.test(skillName)) throw new Error(`Invalid receipt name: ${name}`); - const targetDir = path.join(targetRoot, skillName); + const packageName = name.slice(0, -'.json'.length); + if (!SKILL_NAME_PATTERN.test(packageName)) throw new Error(`Invalid receipt name: ${name}`); const receipt = await readReceipt(path.join(hostReceiptRoot, name), { host, - skillId: `skill:${skillName}`, - skillName, - targetDir, + skillId: `skill:${packageName}`, }); + const targetDir = path.join(targetRoot, receipt.skillName); + validateReceipt(receipt, { targetDir }); + await assertTargetOwnership(receiptRoot, host, receipt.skillId, targetDir); const actual = await inspectTree(targetDir); summary.totalManaged += 1; if (!actual) summary.missing += 1; @@ -1130,15 +1176,14 @@ export async function getManagedNativeSkillHosts(skill, options = {}) { await assertSafeRoot(receiptRoot, 'Native skill receipt root'); const hosts = []; for (const host of NATIVE_SKILL_HOSTS) { + const rule = await nativeSkillRule(host, skill, options); + if (!rule.allowed) continue; const receiptPath = receiptPathFor(receiptRoot, host, skillName); - const targetDir = path.join(getNativeSkillRoot(host, options), skillName); - const receipt = await readReceipt(receiptPath, { - host, - skillId: skill.id, - skillName, - targetDir, - }); - if (receipt) hosts.push(host); + const receipt = await readReceipt(receiptPath, { host, skillId: skill.id }); + if (receipt) { + validateReceipt(receipt, { targetDir: path.join(getNativeSkillRoot(host, options), receipt.skillName) }); + hosts.push(host); + } } return hosts; } diff --git a/src/native-skills/policy.js b/src/native-skills/policy.js new file mode 100644 index 0000000..ab1f52c --- /dev/null +++ b/src/native-skills/policy.js @@ -0,0 +1,44 @@ +export const NATIVE_SKILL_HOSTS = Object.freeze(['codex', 'claude', 'gemini', 'antigravity']); +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function object(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function validateNativeSkillPolicy(value) { + if (!object(value) || value.schemaVersion !== 1 || !object(value.skills) + || Object.keys(value).some(key => !['schemaVersion', 'skills'].includes(key))) { + throw new Error('Invalid native skill policy: expected schemaVersion 1 and skills object'); + } + const names = new Set(); + for (const [id, rule] of Object.entries(value.skills)) { + if (!id.startsWith('skill:') || !NAME.test(id.slice(6)) || !object(rule) + || Object.keys(rule).some(key => !['name', 'hosts'].includes(key))) { + throw new Error(`Invalid native skill policy entry: ${id}`); + } + const name = rule.name === undefined ? id.slice(6) : rule.name; + if (typeof name !== 'string' || name.length > 64 || !NAME.test(name)) { + throw new Error(`Invalid native skill policy name for ${id}`); + } + if (rule.hosts !== undefined && (!Array.isArray(rule.hosts) + || rule.hosts.some(host => !NATIVE_SKILL_HOSTS.includes(host)) + || new Set(rule.hosts).size !== rule.hosts.length)) { + throw new Error(`Invalid native skill policy hosts for ${id}`); + } + if (names.has(name)) throw new Error(`Native skill policy name collision: ${name}`); + names.add(name); + } + return value; +} + +export function resolveNativeSkillRule(policy, id, host) { + const rule = policy.skills[id]; + const name = rule?.name ?? id.slice(6); + // Reserve aliases even when the other package has not yet been installed. + for (const [owner, other] of Object.entries(policy.skills)) { + if (owner !== id && (other.name ?? owner.slice(6)) === name) { + throw new Error(`Native skill policy name collision: ${id} and ${owner}`); + } + } + return { name, allowed: !rule?.hosts || rule.hosts.includes(host) }; +} From 8f4f14de1c813e057ef63f0a2f66d7339672787e Mon Sep 17 00:00:00 2001 From: Prompt Stack Date: Tue, 29 Sep 2026 16:00:56 -0400 Subject: [PATCH 2/3] fix(deps): patch fast-uri for CLI 1.10.27 --- package.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- src/__tests__/unit/quality-workflow-contract.test.js | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index ce246d9..82e80d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@learnrudi/cli", - "version": "1.10.26", + "version": "1.10.27", "packageManager": "pnpm@10.22.0", "description": "RUDI CLI - Install and manage local MCP stacks, runtimes, daemon lifecycle, and agent router integrations", "type": "module", @@ -40,7 +40,7 @@ }, "pnpm": { "overrides": { - "fast-uri": "3.1.6" + "fast-uri": "3.1.8" } }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b3644f..ee5095b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - fast-uri: 3.1.6 + fast-uri: 3.1.8 importers: @@ -409,8 +409,8 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -725,7 +725,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -856,7 +856,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-uri@3.1.6: {} + fast-uri@3.1.8: {} file-uri-to-path@1.0.0: {} diff --git a/src/__tests__/unit/quality-workflow-contract.test.js b/src/__tests__/unit/quality-workflow-contract.test.js index 57bb12a..ac594c9 100644 --- a/src/__tests__/unit/quality-workflow-contract.test.js +++ b/src/__tests__/unit/quality-workflow-contract.test.js @@ -133,7 +133,7 @@ test('publishable workspace packages declare remediated dependency floors', () = const manifestPackage = JSON.parse(read('packages/manifest/package.json')); assert.equal(cliPackage.dependencies.ajv, '^8.18.0'); - assert.equal(cliPackage.pnpm.overrides['fast-uri'], '3.1.6'); + assert.equal(cliPackage.pnpm.overrides['fast-uri'], '3.1.8'); assert.equal(corePackage.dependencies.yaml, '^2.8.3'); assert.equal(dbPackage.dependencies.uuid, '^11.1.1'); assert.equal(manifestPackage.dependencies.ajv, '^8.18.0'); From 5f2a5e43a9aa6a03cb91add48692a6ffdcc98233 Mon Sep 17 00:00:00 2001 From: Prompt Stack Date: Tue, 29 Sep 2026 16:00:56 -0400 Subject: [PATCH 3/3] build(cli): prepare native skill naming release 1.10.27 --- dist/index.cjs | 250 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 170 insertions(+), 80 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index 7e7b4a0..ae76133 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -17115,12 +17115,13 @@ var require_data = __commonJS({ } }); -// node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/lib/utils.js +// node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/utils.js var require_utils = __commonJS({ - "node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/lib/utils.js"(exports2, module2) { + "node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/utils.js"(exports2, module2) { "use strict"; var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu); var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u); + var isPort = RegExp.prototype.test.bind(/^\d*$/u); var isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu); var isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu); var isPathCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/]$/u); @@ -17586,8 +17587,12 @@ var require_utils = __commonJS({ uriTokens.push(host); } if (typeof component.port === "number" || typeof component.port === "string") { + const port = String(component.port); + if (!isPort(port)) { + throw new TypeError("URI port is malformed."); + } uriTokens.push(":"); - uriTokens.push(String(component.port)); + uriTokens.push(port); } return uriTokens.length ? uriTokens.join("") : void 0; } @@ -17612,9 +17617,9 @@ var require_utils = __commonJS({ } }); -// node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/lib/schemes.js +// node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/schemes.js var require_schemes = __commonJS({ - "node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/lib/schemes.js"(exports2, module2) { + "node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/schemes.js"(exports2, module2) { "use strict"; var { isUUID } = require_utils(); var URN_REG = /^([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-./:;=@]|%[\da-f]{2})+)$/iu; @@ -17823,9 +17828,9 @@ var require_schemes = __commonJS({ } }); -// node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/index.js +// node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/index.js var require_fast_uri = __commonJS({ - "node_modules/.pnpm/fast-uri@3.1.6/node_modules/fast-uri/index.js"(exports2, module2) { + "node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/index.js"(exports2, module2) { "use strict"; var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils(); var { SCHEMES, getSchemeHandler } = require_schemes(); @@ -18030,12 +18035,15 @@ var require_fast_uri = __commonJS({ } return false; } + function isIPLiteral(host) { + return host[0] === "[" && host[host.length - 1] === "]"; + } function hasMalformedComponentPercentEncoding(matches) { const host = matches[4]; - return hasMalformedPercentEncoding(matches[3]) || host !== void 0 && !(host[0] === "[" && host[host.length - 1] === "]") && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches[6]) || hasMalformedPercentEncoding(matches[7]) || hasMalformedPercentEncoding(matches[8]); + return hasMalformedPercentEncoding(matches[3]) || host !== void 0 && !isIPLiteral(host) && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches[6]) || hasMalformedPercentEncoding(matches[7]) || hasMalformedPercentEncoding(matches[8]); } function canonicalizeHost(parsed, options, schemeHandler, isIP) { - if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport) && parsed.host && parsed.host[0] !== "[" && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) { + if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport) && parsed.host && !isIPLiteral(parsed.host) && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) { try { parsed.host = new URL("http://" + parsed.host).hostname; } catch (e) { @@ -18122,10 +18130,11 @@ var require_fast_uri = __commonJS({ if (parsed.host) { const ipv4result = isIPv4(parsed.host); if (ipv4result === false) { - const bracketedIPLiteral = parsed.host[0] === "[" && parsed.host[parsed.host.length - 1] === "]"; + const bracketedIPLiteral = isIPLiteral(parsed.host); + const hasIPLiteralBracket = parsed.host.indexOf("[") !== -1 || parsed.host.indexOf("]") !== -1; const ipv6result = normalizeIPv6(parsed.host); isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true; - malformedIPLiteral = bracketedIPLiteral && ipv6result.error === true; + malformedIPLiteral = hasIPLiteralBracket && (!bracketedIPLiteral || ipv6result.error === true); parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase(); if (malformedIPLiteral) { parsed.error = parsed.error || "URI host is malformed."; @@ -18148,14 +18157,17 @@ var require_fast_uri = __commonJS({ parsed.error = parsed.error || "URI is not a " + options.reference + " reference."; } const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme); - malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP); - if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) { - if (uri.indexOf("%") !== -1) { - if (parsed.host !== void 0 && !malformedIPLiteral) { - const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true); - parsed.host = reescapeHostDelimiters(host, isIP); - } + if (!malformedIPLiteral) { + malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP); + } + if (uri.indexOf("%") !== -1 && parsed.host !== void 0 && !malformedIPLiteral) { + let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true); + if (!isIP) { + host = normalizePercentEncoding(host.toLowerCase()); } + parsed.host = reescapeHostDelimiters(host, isIP); + } + if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) { if (parsed.path) { parsed.path = normalizePathEncoding(parsed.path); } @@ -22531,14 +22543,48 @@ init_src5(); var fsp = __toESM(require("node:fs/promises"), 1); var import_node_os = __toESM(require("node:os"), 1); var import_node_path4 = __toESM(require("node:path"), 1); -var NATIVE_SKILL_HOSTS = Object.freeze([ - "codex", - "claude", - "gemini", - "antigravity" -]); + +// src/native-skills/policy.js +var NATIVE_SKILL_HOSTS = Object.freeze(["codex", "claude", "gemini", "antigravity"]); +var NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function validateNativeSkillPolicy(value) { + if (!object(value) || value.schemaVersion !== 1 || !object(value.skills) || Object.keys(value).some((key) => !["schemaVersion", "skills"].includes(key))) { + throw new Error("Invalid native skill policy: expected schemaVersion 1 and skills object"); + } + const names = /* @__PURE__ */ new Set(); + for (const [id, rule] of Object.entries(value.skills)) { + if (!id.startsWith("skill:") || !NAME.test(id.slice(6)) || !object(rule) || Object.keys(rule).some((key) => !["name", "hosts"].includes(key))) { + throw new Error(`Invalid native skill policy entry: ${id}`); + } + const name = rule.name === void 0 ? id.slice(6) : rule.name; + if (typeof name !== "string" || name.length > 64 || !NAME.test(name)) { + throw new Error(`Invalid native skill policy name for ${id}`); + } + if (rule.hosts !== void 0 && (!Array.isArray(rule.hosts) || rule.hosts.some((host) => !NATIVE_SKILL_HOSTS.includes(host)) || new Set(rule.hosts).size !== rule.hosts.length)) { + throw new Error(`Invalid native skill policy hosts for ${id}`); + } + if (names.has(name)) throw new Error(`Native skill policy name collision: ${name}`); + names.add(name); + } + return value; +} +function resolveNativeSkillRule(policy, id, host) { + const rule = policy.skills[id]; + const name = rule?.name ?? id.slice(6); + for (const [owner, other] of Object.entries(policy.skills)) { + if (owner !== id && (other.name ?? owner.slice(6)) === name) { + throw new Error(`Native skill policy name collision: ${id} and ${owner}`); + } + } + return { name, allowed: !rule?.hosts || rule.hosts.includes(host) }; +} + +// src/native-skills/lifecycle.js var RESOURCE_DIRECTORIES = Object.freeze(["assets", "references", "scripts"]); -var RECEIPT_SCHEMA_VERSION = 2; +var RECEIPT_SCHEMA_VERSION = 3; var DIGEST_PATTERN = /^[a-f0-9]{64}$/; var SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; var TRUSTED_PLATFORM_SYMLINKS = new Set( @@ -22566,7 +22612,7 @@ function yamlString(value) { } function normalizeNativeSkillName(skill) { const raw = String(skill?.id || "").replace(/^skill:/, ""); - if (!SKILL_NAME_PATTERN.test(raw)) { + if (typeof skill?.id !== "string" || !skill.id.startsWith("skill:") || !SKILL_NAME_PATTERN.test(raw)) { throw new Error(`Invalid native skill package id: ${skill?.id || ""}`); } return raw; @@ -22578,14 +22624,19 @@ function defaultPrompt(skillName, description, displayName) { const action = compactText(lowerFirst(description || `run the ${displayName} workflow`), 120); return `Use $${skillName} to ${action}.`; } -function buildPortableSkillFiles(skill, sourceContent) { - const skillName = normalizeNativeSkillName(skill); +function renameSelfInvocation(content, skill, skillName) { + if (!content) return ""; + const packageName = normalizeNativeSkillName(skill); + if (packageName === skillName) return content; + return content.replace(new RegExp(`\\$${packageName}(?![a-zA-Z0-9_-])`, "g"), `$${skillName}`); +} +function buildPortableSkillFiles(skill, sourceContent, skillName = normalizeNativeSkillName(skill)) { const parsed = parseSkillDocument(sourceContent); const displayName = compactText(parsed.metadata.name || skill.name || skillName, 80); const description = String( skill.description || parsed.metadata.description || `${displayName} RUDI skill` ).replace(/\s+/g, " ").trim(); - const body = parsed.body || `Use the installed RUDI skill \`skill:${skillName}\` as the source of truth.`; + const body = renameSelfInvocation(parsed.body, skill, skillName) || `Use the installed RUDI skill \`${skill.id}\` as the source of truth.`; const skillMd = [ "---", `name: ${yamlString(skillName)}`, @@ -22597,9 +22648,8 @@ function buildPortableSkillFiles(skill, sourceContent) { ].join("\n"); return { skillName, skillMd }; } -function buildCodexSkillFiles(skill, sourceContent) { - const baseFiles = buildPortableSkillFiles(skill, sourceContent); - const { skillName } = baseFiles; +function buildCodexSkillFiles(skill, sourceContent, skillName = normalizeNativeSkillName(skill)) { + const baseFiles = buildPortableSkillFiles(skill, sourceContent, skillName); const parsed = parseSkillDocument(sourceContent); const displayName = humanizeSkillDisplayName(parsed.metadata.name || skill.name || skillName); const description = compactText( @@ -22646,6 +22696,24 @@ function getNativeSkillReceiptRoot(options = {}) { const rudiHome = import_node_path4.default.resolve(env.RUDI_HOME || import_node_path4.default.join(taskHome(options), ".rudi")); return import_node_path4.default.join(rudiHome, "state", "native-skills"); } +async function nativeSkillRule(host, skill, options) { + assertSupportedHost(host); + normalizeNativeSkillName(skill); + const policyPath = import_node_path4.default.join(import_node_path4.default.dirname(import_node_path4.default.dirname(getNativeSkillReceiptRoot(options))), "native-skills.json"); + await assertNoSymlinkPathComponents(policyPath, "Native skill policy path", { allowMissingTail: true }); + let content; + try { + const stat = await fsp.lstat(policyPath); + assertRealEntry(stat, policyPath, "file"); + if (stat.size > 1024 * 1024) throw new Error("Native skill policy exceeds 1 MiB"); + content = await fsp.readFile(policyPath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return { name: normalizeNativeSkillName(skill), allowed: true }; + throw error; + } + const policy = validateNativeSkillPolicy(JSON.parse(content)); + return resolveNativeSkillRule(policy, skill.id, host); +} function configuredNativeSkillHosts(installedAgents = []) { const ids = new Set((installedAgents || []).map((agent) => agent?.id).filter(Boolean)); const hosts = []; @@ -22783,9 +22851,8 @@ function resolveSourceIdentity(source) { if (!source || typeof source !== "object" || Array.isArray(source)) return null; return source.resolvedCommit || source.checksum || source.requestedRef || source.type || null; } -async function buildProjection(host, skill) { +async function buildProjection(host, skill, skillName = normalizeNativeSkillName(skill)) { assertSupportedHost(host); - const skillName = normalizeNativeSkillName(skill); if (skill.conflictingPaths?.length) { throw new Error(`Conflicting skill formats for ${skill.id}; reconcile canonical sources before native sync`); } @@ -22803,7 +22870,7 @@ async function buildProjection(host, skill) { assertRealEntry(sourceStat, sourcePath, "file"); const sourceContent = await fsp.readFile(sourcePath); const sourceText = sourceContent.toString("utf8"); - const generated = host === "codex" ? buildCodexSkillFiles(skill, sourceText) : buildPortableSkillFiles(skill, sourceText); + const generated = host === "codex" ? buildCodexSkillFiles(skill, sourceText, skillName) : buildPortableSkillFiles(skill, sourceText, skillName); const entries = [{ type: "file", relativePath: "SKILL.md", @@ -22835,6 +22902,7 @@ async function buildProjection(host, skill) { packageDigest = digestEntries(sourceEntries).digest; } if (host === "codex") { + codexMetadata = Buffer.from(renameSelfInvocation(codexMetadata.toString("utf8"), skill, skillName)); entries.push({ type: "directory", relativePath: "agents", mode: 493 }); entries.push({ type: "file", @@ -22904,6 +22972,26 @@ async function inspectTree(root) { function receiptPathFor(receiptRoot, host, skillName) { return import_node_path4.default.join(import_node_path4.default.resolve(receiptRoot), host, `${skillName}.json`); } +async function assertTargetOwnership(receiptRoot, host, skillId, targetDir) { + const directory = import_node_path4.default.join(receiptRoot, host); + await assertSafeRoot(directory, "Native skill receipt directory"); + let names; + try { + names = await fsp.readdir(directory); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + for (const name of names.filter((name2) => name2.endsWith(".json"))) { + const packageName = name.slice(0, -5); + if (!SKILL_NAME_PATTERN.test(packageName)) throw new Error(`Invalid receipt name: ${name}`); + if (`skill:${packageName}` === skillId) continue; + const receipt = await readReceipt(import_node_path4.default.join(directory, name), { host, skillId: `skill:${packageName}` }); + if (receipt?.targetDir === targetDir) { + throw new Error(`Native skill target already owned by ${receipt.skillId}: ${targetDir}`); + } + } +} function isIsoTimestamp(value) { if (typeof value !== "string") return false; const parsed = new Date(value); @@ -22916,19 +23004,19 @@ function validateReceipt(receipt, expected = {}) { if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) { throw new Error("Native skill receipt must be an object"); } - if (receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) { + if (![2, RECEIPT_SCHEMA_VERSION].includes(receipt.schemaVersion)) { throw new Error(`Unsupported native skill receipt schema: ${receipt.schemaVersion}`); } if (!NATIVE_SKILL_HOSTS.includes(receipt.host)) { throw new Error(`Invalid native skill receipt host: ${receipt.host}`); } - if (!String(receipt.skillId || "").startsWith("skill:")) { + if (!String(receipt.skillId || "").startsWith("skill:") || !SKILL_NAME_PATTERN.test(receipt.skillId.slice(6))) { throw new Error("Invalid native skill receipt skillId"); } if (!SKILL_NAME_PATTERN.test(receipt.skillName || "")) { throw new Error("Invalid native skill receipt skillName"); } - if (receipt.skillId !== `skill:${receipt.skillName}`) { + if (receipt.schemaVersion === 2 && receipt.skillId !== `skill:${receipt.skillName}`) { throw new Error("Native skill receipt id/name mismatch"); } if (typeof receipt.packageVersion !== "string" || !receipt.packageVersion.trim()) { @@ -23092,25 +23180,29 @@ function resultBase(host, projection, targetDir, receiptPath) { async function inspectNativeSkillProjection(options = {}) { const host = options.host; const skill = options.skill; - const projection = await buildProjection(host, skill); + const rule = await nativeSkillRule(host, skill, options); + if (!rule.allowed) return { host, id: skill.id, skillName: rule.name, state: "excluded", restartRequired: false }; + const projection = await buildProjection(host, skill, rule.name); const targetRoot = import_node_path4.default.resolve(options.targetRoot || getNativeSkillRoot(host, options)); const receiptRoot = import_node_path4.default.resolve(options.receiptRoot || getNativeSkillReceiptRoot(options)); await assertSafeRoot(targetRoot, "Native skill target root"); await assertSafeRoot(receiptRoot, "Native skill receipt root"); const targetDir = import_node_path4.default.join(targetRoot, projection.skillName); - const receiptPath = receiptPathFor(receiptRoot, host, projection.skillName); - const receiptExpectation = { - host, - skillId: projection.skillId, - skillName: projection.skillName, - targetDir - }; - const receipt = await readReceipt(receiptPath, receiptExpectation); + const receiptPath = receiptPathFor(receiptRoot, host, normalizeNativeSkillName(skill)); + const receipt = await readReceipt(receiptPath, { host, skillId: projection.skillId }); + if (receipt) { + validateReceipt(receipt, { targetDir: import_node_path4.default.join(targetRoot, receipt.skillName) }); + } + const renamed = Boolean(receipt && receipt.targetDir !== targetDir); + if (renamed && await inspectTree(receipt.targetDir)) { + throw new Error(`Prior native skill target still exists; preserve and reconcile it before renaming: ${receipt.targetDir}`); + } + await assertTargetOwnership(receiptRoot, host, skill.id, targetDir); const actual = await inspectTree(targetDir); let state; if (!actual) { state = "missing"; - } else if (!receipt) { + } else if (!receipt || renamed) { state = "unmanaged"; } else if (actual.digest !== receipt.renderedTreeDigest) { state = "drifted"; @@ -23123,7 +23215,7 @@ async function inspectNativeSkillProjection(options = {}) { ...resultBase(host, projection, targetDir, receiptPath), actualTreeDigest: actual?.digest || null, expectedMatchesActual: actual?.digest === projection.renderedTreeDigest, - managed: Boolean(receipt), + managed: Boolean(receipt) && !renamed, receipt, state }; @@ -23213,6 +23305,7 @@ async function reconcileNativeSkill(options = {}) { }; const force = options.force === true; const dryRun = options.dryRun === true; + if (inspected.state === "excluded") return { ...base, action: "excluded", reason: "Host excluded by native skill policy" }; if (inspected.state === "current") { return { ...base, action: dryRun ? "would_current" : "current" }; } @@ -23232,7 +23325,7 @@ async function reconcileNativeSkill(options = {}) { } if (inspected.state === "unmanaged" && inspected.expectedMatchesActual) { if (dryRun) return { ...base, action: "would_adopt" }; - const projection2 = await buildProjection(host, skill); + const projection2 = await buildProjection(host, skill, inspected.skillName); await assertSafeRoot(import_node_path4.default.dirname(inspected.targetDir), "Native skill target root"); await assertSafeRoot(import_node_path4.default.dirname(import_node_path4.default.dirname(inspected.receiptPath)), "Native skill receipt root"); const current = await inspectTree(inspected.targetDir); @@ -23241,18 +23334,16 @@ async function reconcileNativeSkill(options = {}) { } const receiptExpectation = { host, - skillId: projection2.skillId, - skillName: projection2.skillName, - targetDir: inspected.targetDir + skillId: projection2.skillId }; - await assertReceiptUnchanged(inspected.receiptPath, null, receiptExpectation); + await assertReceiptUnchanged(inspected.receiptPath, inspected.receipt, receiptExpectation); await atomicWriteReceipt( inspected.receiptPath, - receiptFor(host, projection2, inspected.targetDir) + receiptFor(host, projection2, inspected.targetDir, inspected.receipt) ); return { ...base, action: "adopted" }; } - const projection = await buildProjection(host, skill); + const projection = await buildProjection(host, skill, inspected.skillName); const receipt = receiptFor(host, projection, inspected.targetDir, inspected.receipt); const targetChanges = inspected.actualTreeDigest !== projection.renderedTreeDigest; const action = inspected.state === "missing" ? "created" : "updated"; @@ -23270,9 +23361,7 @@ async function reconcileNativeSkill(options = {}) { } const receiptExpectation = { host, - skillId: projection.skillId, - skillName: projection.skillName, - targetDir: inspected.targetDir + skillId: projection.skillId }; await assertReceiptUnchanged( inspected.receiptPath, @@ -23293,9 +23382,7 @@ async function reconcileNativeSkill(options = {}) { priorReceipt: inspected.receipt, receiptExpectation: { host, - skillId: projection.skillId, - skillName: projection.skillName, - targetDir: inspected.targetDir + skillId: projection.skillId }, priorActualDigest: inspected.actualTreeDigest, writeReceipt: options.operations?.writeReceipt || atomicWriteReceipt @@ -23413,15 +23500,19 @@ async function removeNativeSkillProjection(options = {}) { const skill = options.skill; try { assertSupportedHost(host); - const skillName = normalizeNativeSkillName(skill); + const packageName = normalizeNativeSkillName(skill); + const rule = await nativeSkillRule(host, skill, options); const targetRoot = import_node_path4.default.resolve(options.targetRoot || getNativeSkillRoot(host, options)); const receiptRoot = import_node_path4.default.resolve(options.receiptRoot || getNativeSkillReceiptRoot(options)); await assertSafeRoot(targetRoot, "Native skill target root"); await assertSafeRoot(receiptRoot, "Native skill receipt root"); + const receiptPath = receiptPathFor(receiptRoot, host, packageName); + const receipt = await readReceipt(receiptPath, { host, skillId: skill.id }); + const skillName = receipt?.skillName || rule.name; const targetDir = import_node_path4.default.join(targetRoot, skillName); - const receiptPath = receiptPathFor(receiptRoot, host, skillName); const receiptExpectation = { host, skillId: skill.id, skillName, targetDir }; - const receipt = await readReceipt(receiptPath, receiptExpectation); + if (receipt) validateReceipt(receipt, receiptExpectation); + await assertTargetOwnership(receiptRoot, host, skill.id, targetDir); const actual = await inspectTree(targetDir); const base = { host, id: skill.id, skillName, targetDir, receiptPath, restartRequired: false }; if (!receipt) { @@ -23539,15 +23630,15 @@ async function summarizeNativeSkillHost(host, options = {}) { } for (const name of names) { try { - const skillName = name.slice(0, -".json".length); - if (!SKILL_NAME_PATTERN.test(skillName)) throw new Error(`Invalid receipt name: ${name}`); - const targetDir = import_node_path4.default.join(targetRoot, skillName); + const packageName = name.slice(0, -".json".length); + if (!SKILL_NAME_PATTERN.test(packageName)) throw new Error(`Invalid receipt name: ${name}`); const receipt = await readReceipt(import_node_path4.default.join(hostReceiptRoot, name), { host, - skillId: `skill:${skillName}`, - skillName, - targetDir + skillId: `skill:${packageName}` }); + const targetDir = import_node_path4.default.join(targetRoot, receipt.skillName); + validateReceipt(receipt, { targetDir }); + await assertTargetOwnership(receiptRoot, host, receipt.skillId, targetDir); const actual = await inspectTree(targetDir); summary.totalManaged += 1; if (!actual) summary.missing += 1; @@ -23568,15 +23659,14 @@ async function getManagedNativeSkillHosts(skill, options = {}) { await assertSafeRoot(receiptRoot, "Native skill receipt root"); const hosts = []; for (const host of NATIVE_SKILL_HOSTS) { + const rule = await nativeSkillRule(host, skill, options); + if (!rule.allowed) continue; const receiptPath = receiptPathFor(receiptRoot, host, skillName); - const targetDir = import_node_path4.default.join(getNativeSkillRoot(host, options), skillName); - const receipt = await readReceipt(receiptPath, { - host, - skillId: skill.id, - skillName, - targetDir - }); - if (receipt) hosts.push(host); + const receipt = await readReceipt(receiptPath, { host, skillId: skill.id }); + if (receipt) { + validateReceipt(receipt, { targetDir: import_node_path4.default.join(getNativeSkillRoot(host, options), receipt.skillName) }); + hosts.push(host); + } } return hosts; } @@ -41391,7 +41481,7 @@ async function cmdLeverage(args, flags) { } // src/index.js -var VERSION = true ? "1.10.26" : process.env.npm_package_version || "0.0.0"; +var VERSION = true ? "1.10.27" : process.env.npm_package_version || "0.0.0"; var RETIRED_COMMANDS = /* @__PURE__ */ new Map([ ["apply", "Provider transcripts remain authoritative; organization-plan execution was removed."], ["database", "Use Studio only if you still need the isolated compatibility database."],