Files
ECC/scripts/lib/context-carriers.js
T

176 lines
8.8 KiB
JavaScript

'use strict';
const crypto = require('node:crypto');
const path = require('node:path');
const { compileContextProfile } = require('./context-profiles');
const { loadContextRegistry } = require('./context-pack-registry');
const {
DEFAULT_REPO_ROOT, createSourceReader, digestObject, stableStringify,
validateRelativePath, validateSchema,
} = require('./context-profile-support');
const INPUT_KEYS = new Set(['repoRoot', 'profileId', 'selectionMode', 'target', 'include', 'exclude']);
const LAYOUTS = Object.freeze({
claude: { id: 'claude-plugin@1', skillRoot: 'skills', manifestPath: '.claude-plugin/plugin.json' },
codex: { id: 'codex-plugin@1', skillRoot: 'skills', manifestPath: '.codex-plugin/plugin.json' },
pi: { id: 'pi-package@1', skillRoot: 'skills', manifestPath: 'package.json' },
opencode: { id: 'opencode-project@1', skillRoot: '.opencode/skills', manifestPath: null },
cursor: { id: 'cursor-project@1', skillRoot: '.cursor/skills', manifestPath: null },
});
const SHA256 = /^[a-f0-9]{64}$/;
const NATIVE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function validateInput(options) {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new Error('Carrier options must be an object');
}
for (const key of Reflect.ownKeys(options)) {
if (!INPUT_KEYS.has(key)) throw new Error(`Unknown carrier input option: ${String(key)}`);
}
}
function adapterDigest() {
const reader = createSourceReader(DEFAULT_REPO_ROOT);
return digestObject(['scripts/lib/context-carriers.js', 'schemas/context-carrier.schema.json']
.map(source => ({ path: source, digest: reader.read(source).digest })));
}
function validateEntryResources(entry) {
if (!Array.isArray(entry.resources) || !entry.resources.length || !Array.isArray(entry.requiredResources)) {
throw new Error(`Missing resource inventory or required-resource metadata: ${entry.id}`);
}
const sourceRoot = `skills/${entry.id.slice('skill:'.length)}`;
if (entry.sourcePath !== `${sourceRoot}/SKILL.md`) {
throw new Error(`Source resource is not the canonical skill entrypoint: ${entry.id}`);
}
const resources = new Set();
for (const resource of entry.resources) {
validateRelativePath(resource.path);
if (!resource.path.startsWith(`${sourceRoot}/`)) throw new Error(`Resource must belong to ${sourceRoot}`);
if (resources.has(resource.path)) throw new Error(`Duplicate source resource: ${resource.path}`);
if (!SHA256.test(resource.digest) || !Number.isSafeInteger(resource.bytes) || resource.bytes < 0) {
throw new Error(`Invalid resource digest or byte count: ${resource.path}`);
}
if (path.posix.basename(resource.path).toLowerCase() === 'skill.md' && resource.path !== entry.sourcePath) {
throw new Error(`Nested or duplicate skill discovery entry: ${resource.path}`);
}
resources.add(resource.path);
}
for (const required of [entry.sourcePath, ...entry.requiredResources]) {
validateRelativePath(required);
if (!resources.has(required)) throw new Error(`Required resource missing from inventory: ${required}`);
}
}
function selectedEntries(context, registry) {
const byId = new Map(registry.entries.map(entry => [entry.id, entry]));
const names = new Set();
return context.selectedIds.map(id => {
const entry = byId.get(id);
if (!entry) throw new Error(`Selected skill missing from registry: ${id}`);
if (typeof entry.name !== 'string' || entry.name.length > 64 || !NATIVE_NAME.test(entry.name)) {
throw new Error(`Invalid portable native skill name: ${id}`);
}
if (names.has(entry.name)) throw new Error(`Duplicate native skill name: ${entry.name}`);
names.add(entry.name);
validateEntryResources(entry);
return entry;
});
}
function copyDescriptors(entries, layout) {
return entries.flatMap(entry => {
const sourceRoot = path.posix.dirname(entry.sourcePath);
return entry.resources.map(resource => ({
kind: 'copy', skillId: entry.id, sourcePath: resource.path,
destinationPath: `${layout.skillRoot}/${entry.name}/${resource.path.slice(sourceRoot.length + 1)}`,
digest: resource.digest, bytes: resource.bytes,
}));
});
}
// New, allowlisted discovery manifests. Never inherit source hooks, MCP, commands,
// package scripts, or Pi extensions. OpenCode/Cursor use native project directories.
function generatedManifest(target, layout) {
if (!layout.manifestPath) return [];
const name = 'ecc-context-carrier';
const manifests = {
claude: { name, skills: ['./skills/'] },
codex: { name, skills: './skills/' },
pi: { name, private: true, pi: { skills: ['./skills'] } },
};
const content = `${stableStringify(manifests[target])}\n`;
return [{
kind: 'generated', destinationPath: layout.manifestPath, content, encoding: 'utf8',
digest: crypto.createHash('sha256').update(content, 'utf8').digest('hex'),
bytes: Buffer.byteLength(content, 'utf8'),
}];
}
function validateDestinations(files) {
const destinations = new Set();
const directories = new Map();
for (const file of files) {
validateRelativePath(file.destinationPath);
const destination = file.destinationPath.normalize('NFC').toLowerCase();
if (destinations.has(destination) || directories.has(destination)) {
throw new Error(`Carrier destination collision: ${file.destinationPath}`);
}
const parts = file.destinationPath.split('/');
for (let index = 1; index < parts.length; index++) {
const originalAncestor = parts.slice(0, index).join('/');
const ancestor = originalAncestor.normalize('NFC').toLowerCase();
if (destinations.has(ancestor)) throw new Error(`Carrier file/directory collision: ${file.destinationPath}`);
if (directories.has(ancestor) && directories.get(ancestor) !== originalAncestor) {
throw new Error(`Carrier ancestor directory alias collision: ${file.destinationPath}`);
}
directories.set(ancestor, originalAncestor);
}
destinations.add(destination);
}
}
/** Plan a skill-only carrier from canonical sources. Never write or invoke a host. */
function planContextCarrier(options = {}) {
validateInput(options);
const context = compileContextProfile(options);
const registry = loadContextRegistry({ repoRoot: options.repoRoot || DEFAULT_REPO_ROOT });
if (registry.registryDigest !== context.registryDigest) {
throw new Error('Registry digest changed between context compilation and carrier planning');
}
const selected = selectedEntries(context, registry);
const layout = LAYOUTS[context.target] || null;
const files = layout ? [...copyDescriptors(selected, layout), ...generatedManifest(context.target, layout)] : [];
validateDestinations(files);
const value = {
schemaVersion: 'ecc.context-carrier.v1', status: layout ? 'planned' : 'unsupported',
active: false, disposition: 'proposed', nativeSupport: 'unobserved',
target: context.target, profileId: context.profileId, selectionMode: context.selectionMode,
registryDigest: context.registryDigest, profileDigest: context.profileDigest,
compilerDigest: context.compilerDigest, planDigest: context.planDigest,
adapterDigest: adapterDigest(), layout: layout ? { ...layout } : null,
selectedIds: [...context.selectedIds], routedIds: [...context.routedIds], excludedIds: [...context.excludedIds],
entries: selected.map(entry => ({
id: entry.id, name: entry.name, sourcePath: entry.sourcePath, contentDigest: entry.contentDigest,
requiredResources: [...entry.requiredResources],
installSupport: entry.declaredInstallTargets.includes(context.target) ? 'declared' : 'not-declared',
})),
files: [...files].sort((left, right) => left.destinationPath < right.destinationPath ? -1 : 1),
limitations: [
'Read-only file proposal; no artifact was written, installed, activated, or loaded by a native host.',
'Only selected whole skill trees are planned. Routed loading is unimplemented; no router or catalog bootstrap is added.',
'Canonical skill IDs are retained; destination directories use validated native metadata names without rewriting source bytes.',
'Owner-module install declarations are separate from source-backed layouts and do not certify native discovery.',
'Explicit bundled resources are preserved; external runtime and prose workflow dependencies remain unreviewed.',
'Source digests bind observed bytes, not an atomic snapshot. Materialization must revalidate every source descriptor.',
'Native discovery, invocation, permissions, hooks, and whole-context token costs remain unobserved.',
...(layout ? [] : ['This recognized target has no implemented carrier layout; zero files are planned.']),
],
};
const carrier = { ...value, carrierDigest: digestObject(value) };
validateSchema(carrier, 'context-carrier.schema.json');
return carrier;
}
module.exports = { planContextCarrier };