diff --git a/docs/design/context-profile-delivery.md b/docs/design/context-profile-delivery.md index d7eaa9721..7a771c815 100644 --- a/docs/design/context-profile-delivery.md +++ b/docs/design/context-profile-delivery.md @@ -62,6 +62,28 @@ An agent can call the resolver at task boundaries and read the returned context. The task call sends the query and selected reference content on standard input to `codex exec -` or `claude --print`, with no added task permissions or hook overrides. Current-provider launches inherit the provider process environment. An isolated native launch passes only the pinned home paths, `PATH`, a fixed locale, a private temporary directory, and required Windows system root; caller credentials, proxy settings, runtime injection and unrelated secrets are excluded. Its timeout is 90 seconds after a proposal or 120 seconds without one, uses an uncatchable termination signal, and captures at most 1 MiB. Dry run reports the pending proposal without a provider call. A zero provider exit code records process completion; task success and native skill invocation remain unverified. Routine interactive turns outside this launcher do not gain automatic routing. +## Prompt suggestions (opt-in hook) + +Ordinary interactive turns can receive advisory skill suggestions from a UserPromptSubmit hook. The hook prints up to three skill IDs with one-line descriptions and the `resolve` command to load one. Like implicit admission, a suggestion needs a matched term in the skill name or curated triggers, or an exact name; it never returns a skill body, changes the saved mode or selection, or grants authority. Loading still goes through `resolve`, which re-verifies canonical sources. + +`ecc profile routing-index --state-root ` writes a metadata index for the current managed generation: the entries the resolver could suggest (not excluded, admissible without explicit selection), their curated triggers and precomputed retrieval vectors in a compact little-endian encoding. Building refuses a store whose generation no longer matches the canonical sources. A small pointer named by the generation binds the index to the state receipt; the entries file is named by the SHA-256 of its bytes. A `set`, `mode` or `rollback` retires the index until it is rebuilt, and the hook then stays silent with a stderr diagnostic; returning to an earlier generation reports its old index as missing, so the SessionStart builder rebuilds it. Reads validate every entry's fields and are bounded at 4 MiB and 2,048 entries. A build fails if skill sources change while it runs, and a non-UTF-8 skill source fails it instead of disappearing. `--dry-run` reports whether the current index exists. + +The hook is inert unless `ECC_CONTEXT_STATE_ROOT` names the store, and it is not registered by default. It is also silent in manual mode (checked before the index is read), for slash commands, prompts under 12 characters and malformed input, and suppresses output once its 150 ms budget (`ECC_CONTEXT_SUGGEST_BUDGET_MS`) is spent. The budget is checked between steps and cannot interrupt a stalled read, so the snippet sets Claude Code's own `timeout` as the hard bound. In suggest mode the hook names the `ecc profile mode auto` command instead of `resolve --load`, because suggest mode never returns bodies. An optional async SessionStart hook builds a missing index. To opt in, add to Claude settings, with `` the ECC installation: + +```json +{ + "env": { "ECC_CONTEXT_STATE_ROOT": "/absolute/dedicated/profile-store" }, + "hooks": { + "UserPromptSubmit": [{ "hooks": [{ "type": "command", "timeout": 2, + "command": "node \"/scripts/hooks/run-with-flags.js\" user-prompt:context-suggestions scripts/hooks/context-prompt-suggestions.js" }] }], + "SessionStart": [{ "hooks": [{ "type": "command", "async": true, "timeout": 30, + "command": "node \"/scripts/hooks/run-with-flags.js\" session:context-routing-index scripts/hooks/context-routing-index.js" }] }] + } +} +``` + +Both hooks honor `ECC_HOOKS_ENABLED`, `ECC_HOOK_PROFILE` and `ECC_DISABLED_HOOKS` through `run-with-flags`. + ## Isolated native Codex generations `prepare-native` registers the managed carrier in a fresh ECC-owned home, verifies exact discovery through the allowlisted Codex 0.154.0 or 0.155.1 binary, and only then selects that native generation. It writes a bounded `AGENTS.md` bootstrap bound to the installed CLI source, managed roots, carrier, executable and receipt. It copies no credentials or user configuration and never rewrites the user's provider home. `native-status` checks the recorded generation, executable fingerprint, bootstrap source identity and managed-store binding. A launch pins that verified binary instead of resolving a different executable from PATH. Explicit preparation can refresh a changed executable or installed-source binding while preserving the prior generation and receipts. @@ -88,7 +110,7 @@ Real execution requires an explicit flag and provider authentication. Codex uses ## Community integration -Jeffrey Montoya's [#2788](https://github.com/affaan-m/ECC/pull/2788) informed whole-tree staging, ownership receipts and reversible generations. LovePlayCode's [#2844](https://github.com/affaan-m/ECC/pull/2844) informed deterministic grouping and explicit exclusion. Jeffrey's [#2945](https://github.com/affaan-m/ECC/pull/2945) informed bounded ID/description ranking and deterministic ties. Canonical source digests replace independent routing-cache authority. [#2740](https://github.com/affaan-m/ECC/pull/2740) remains aligned with native context meters and truthful measurement labels. +Jeffrey Montoya's [#2788](https://github.com/affaan-m/ECC/pull/2788) informed whole-tree staging, ownership receipts and reversible generations. LovePlayCode's [#2844](https://github.com/affaan-m/ECC/pull/2844) informed deterministic grouping and explicit exclusion. Jeffrey's [#2945](https://github.com/affaan-m/ECC/pull/2945) informed bounded ID/description ranking, deterministic ties and the suggest-only prompt hook. Canonical source digests replace independent routing-cache authority. [#2740](https://github.com/affaan-m/ECC/pull/2740) remains aligned with native context meters and truthful measurement labels. These are attributed adaptations of concepts; contributor commits have not been silently relabeled as our implementation. Source PR disposition remains separate. diff --git a/docs/design/context-profile-delivery.tdd.md b/docs/design/context-profile-delivery.tdd.md index 571bd2a75..e1c95b8ff 100644 --- a/docs/design/context-profile-delivery.tdd.md +++ b/docs/design/context-profile-delivery.tdd.md @@ -108,4 +108,29 @@ Session-only Claude discovery adapts the ownership and receipt intent of #2788 t - No authenticated model call, interactive turn or native skill invocation ran. The projection is the host's estimate for the plugin listing, not whole-context truth. - Maintainer credential-free macOS arm64 check, October 10: Claude Code 2.1.296 prepared Lean with three skills (~289 listing tokens), Full with the then-current 302 skills (~25,982 listing tokens), reported stale after each managed profile switch, and restored Lean through managed and native rollback. These are observed plugin-listing projections for that snapshot, not whole-context measurements. +## Prompt suggestions + +The suggest-only prompt hook from #2945 now reads a generation-bound metadata index instead of its own catalog cache. + +- RED: routing-index and hook cases fail before the modules exist. GREEN adds a stored-vector key to the entry shape, a stale-source refusal and the session-start builder; all 16 cases pass, with a retrieval case proving stored vectors rank identically to computed ones. +- The resolver path took 300 to 500 ms per prompt from a source checkout, almost all in rehashing 293 skills and 584 files. Loading Ajv lazily and storing vectors moved the hook's own work to about 95 to 110 ms on a Full Claude index (276 entries, 1.8 MB); wrapper invocations measured 140 to 180 ms against a 71 ms disabled baseline on Linux x64. +- RED: on the full registry the function-word prompt still produced three unanchored suggestions. GREEN applies the routing policy v5 anchor to suggestions; the prompt now yields none and a React keyboard-focus prompt still leads with `skill:frontend-a11y`. +- Stored vectors moved from JSON pairs to little-endian base64 (RED: format test fails; GREEN: exact decode). The Full index shrank from 1.8 MB to 0.97 MB, its parse from 26 ms to 6 ms, and hook work to about 89 to 98 ms; wrapper invocations measured 149 to 190 ms against a 61 ms disabled baseline. The resolver's two registry reads are left intact as its source-consistency check. +- Selection, retrieval, profile CLI and hook-wrapper suites pass unchanged. +- No interactive Claude or Codex session loaded the hook; suggestion quality is bounded by the resolver ranking measured above. + +Review follow-up, October 8: + +- RED (15 index, hook and selection cases fail): a rollback to an indexed generation threw instead of reporting the index missing; digest-consistent entries with a non-string ID, name, description, owner or trigger, or a non-skill ID, were accepted; an oversized index was read in full; an anchored skill ranked fourth was hidden behind three unanchored matches; a skill edited mid-build was published under the stored generation; manual mode read the index before going silent; suggest mode advertised `resolve --load`, which returns no bodies there; admission denials were matched by message text, so a non-UTF-8 skill silently left the index. +- GREEN: a valid pointer for an earlier receipt is retired (`missing`) and the SessionStart builder rebuilds it; entries are validated against a closed key set before decoding and returned as new objects; pointer and index reads are bounded at 4 KiB, 4 MiB and 2,048 entries; suggestions filter for anchors before trimming to three; a build refuses plan or registry digests that differ from the checked carrier; the hook reads only the state binding before its manual-mode exit; suggest mode prints `ecc profile mode auto` with the current revision; denials carry `ECC_CONTEXT_*` codes and everything else propagates. +- Budget: a worker thread would make the 150 ms budget a hard bound but measured 135 to 150 ms for the same work (about 85 ms in process), so it would suppress most suggestions. The hook keeps in-process checks between steps, the read bounds cap the work at a real registry's size, and the documented snippet sets Claude Code's `timeout` as the outer bound. +- Full Claude index, auto mode, Linux x64 Node 22.22.0: wrapper invocations 108 to 122 ms against 29 to 45 ms with the hook disabled. + +Review follow-up, October 9: + +- RED (1 case fails): a build interrupted while writing the entries file left partial bytes under its digest name, and every later build refused them, so the index could not be restored. GREEN writes the file through the store's temporary-file-and-rename path and replaces a digest-named file whose bytes do not hash to its name. +- Sources edited after the entries are read: a new case pins that the published index still matches the stored generation and that the next build reports the generation stale. A final source recheck before the pointer would only discard a correct index. The triggers manifest, though, is not bound to the generation: an edit to it after `ecc profile set` reaches the next index unnoticed. Binding it is a store change, left open. +- Schema validation stays manual on the read path. On Windows x64, Node 24, loading Ajv took about 44 ms and compiling an index schema about 20 ms in a fresh process, against 22 ms for loading the whole routing module. That would cost the prompt-time read about 64 ms of its 150 ms budget, so Ajv stays off the prompt path, as `validateSchema` already documents. +- Budget: the October 8 decision stands. On the same Windows machine a prototype worker measured 64 to 77 ms against about 55 ms in process, and stopped a simulated 600 ms read at 152 ms. That gap is smaller than the Linux measurement above, so the trade-off may be worth revisiting with numbers from both platforms. + These boundaries keep the shipped behavior distinct from the M1 release gate. Authenticated outcome observations, a complete Tier 2 disk diff, live-install migration, additional-provider activation, whole-context token truth and release defaults remain unverified until their explicit prerequisites are available. diff --git a/scripts/hooks/context-prompt-suggestions.js b/scripts/hooks/context-prompt-suggestions.js new file mode 100644 index 000000000..3920d0276 --- /dev/null +++ b/scripts/hooks/context-prompt-suggestions.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +'use strict'; + +/** + * UserPromptSubmit hook: suggest up to three skills from the managed profile's + * routing index. Suggest-only; it loads no skill body, changes no selection, + * and grants nothing. Opt in by setting ECC_CONTEXT_STATE_ROOT to the store + * used with `ecc profile set`, and build the index with + * `ecc profile routing-index --state-root `. + */ + +const path = require('node:path'); + +const MIN_PROMPT_LENGTH = 12; +const MAX_PROMPT_BYTES = 8192; +const DEFAULT_BUDGET_MS = 150; + +function budgetMs(env) { + const value = Number(env.ECC_CONTEXT_SUGGEST_BUDGET_MS); + return Number.isFinite(value) && value >= 0 ? Math.min(value, 1000) : DEFAULT_BUDGET_MS; +} + +function promptFrom(rawInput) { + try { + const input = JSON.parse(rawInput); + return typeof input?.prompt === 'string' ? input.prompt : null; + } catch { + return null; + } +} + +function message(binding, suggestions) { + // A quoted JSON path is data, not shell escaping. Keep user-selected paths + // out of executable examples so metacharacters cannot become shell code. + const root = ''; + const action = binding.selectionMode === 'suggest' + ? `Suggest mode never loads bodies. To load one, switch with ecc profile mode auto --state-root ${root} --expected-revision ${binding.revision}, then resolve it by ID.` + : `To load one, run ecc profile resolve --state-root ${root} --task-input - --load with its ID in explicitIds or proposedIds.`; + return ['ECC context suggestions (advisory; nothing was loaded):', + `Store path (data): ${JSON.stringify(binding.root)}. Replace with this path as one shell argument.`, + ...suggestions.map(item => `- ${item.id}: ${item.description}`), action, ''].join('\n'); +} + +function run(rawInput, _context = {}, env = process.env) { + const started = Date.now(); + const stateRoot = env.ECC_CONTEXT_STATE_ROOT; + if (!stateRoot || !path.isAbsolute(stateRoot) || path.resolve(stateRoot) !== stateRoot) return ''; + const prompt = promptFrom(rawInput); + if (!prompt || prompt.trim().length < MIN_PROMPT_LENGTH || prompt.trimStart().startsWith('/') + || Buffer.byteLength(prompt) > MAX_PROMPT_BYTES) return ''; + const budget = budgetMs(env); + try { + const routing = require('../lib/context-routing-index'); + const binding = routing.readBinding(stateRoot); + if (binding.selectionMode === 'manual' || Date.now() - started >= budget) return ''; + const index = routing.readRoutingIndex(stateRoot, binding); + if (!index) { + return { stdout: '', stderr: '[ContextSuggest] No routing index for the current profile; run ecc profile routing-index --state-root .' }; + } + if (Date.now() - started >= budget) return ''; + const suggestions = routing.suggestContext(index, prompt); + if (!suggestions.length || Date.now() - started > budget) return ''; + return message(binding, suggestions); + } catch (error) { + return { stdout: '', stderr: `[ContextSuggest] ${String(error.message).replace(/[^\x20-\x7E]/g, '?')}` }; + } +} + +module.exports = { run }; diff --git a/scripts/hooks/context-routing-index.js b/scripts/hooks/context-routing-index.js new file mode 100644 index 000000000..75dd59cd4 --- /dev/null +++ b/scripts/hooks/context-routing-index.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +'use strict'; + +/** + * SessionStart hook (register with "async": true): build the routing index + * for ECC_CONTEXT_STATE_ROOT when the current managed generation has none. + * Reads canonical skill sources; writes only inside the managed store. + */ + +const path = require('node:path'); + +function run(_rawInput, _context = {}, env = process.env) { + const stateRoot = env.ECC_CONTEXT_STATE_ROOT; + if (!stateRoot || !path.isAbsolute(stateRoot) || path.resolve(stateRoot) !== stateRoot) return ''; + try { + const routing = require('../lib/context-routing-index'); + if (routing.routingIndexStatus(stateRoot).status === 'current') return ''; + const written = routing.writeRoutingIndex({ stateRoot }); + return { stdout: '', stderr: `[ContextSuggest] Routing index built with ${written.entries} entries.` }; + } catch (error) { + return { stdout: '', stderr: `[ContextSuggest] ${String(error.message).replace(/[^\x20-\x7E]/g, '?')}` }; + } +} + +module.exports = { run }; diff --git a/scripts/lib/context-profile-commands.js b/scripts/lib/context-profile-commands.js index f855bdfc6..78f5a5f42 100644 --- a/scripts/lib/context-profile-commands.js +++ b/scripts/lib/context-profile-commands.js @@ -5,7 +5,7 @@ const fs = require('node:fs'); const { createSourceReader } = require('./context-profile-support'); const NATIVE_COMMANDS = ['prepare-native', 'native-status', 'native-rollback', 'native-recover']; -const COMMANDS = ['start', 'resolve', 'run', 'set', 'mode', 'status', 'rollback', 'recover', ...NATIVE_COMMANDS]; +const COMMANDS = ['start', 'resolve', 'run', 'set', 'mode', 'status', 'rollback', 'recover', 'routing-index', ...NATIVE_COMMANDS]; const VALUE_FLAGS = ['--task-input', '--previous', '--expected-digest', '--state-root', '--expected-revision', '--target', '--selection', '--include', '--exclude', '--native-root']; @@ -137,6 +137,11 @@ function execute(options) { } return { selection: resolveTaskContext(input) }; } + if (options.command === 'routing-index') { + const routing = require('./context-routing-index'); + return { routing: options.dryRun ? routing.routingIndexStatus(options['state-root']) + : routing.writeRoutingIndex({ stateRoot: options['state-root'] }) }; + } const store = require('./context-profile-store'); const common = { stateRoot: options['state-root'], ...(options['expected-revision'] === undefined ? {} : { expectedRevision: Number(options['expected-revision']) }) }; @@ -166,6 +171,7 @@ function run(argv) { summary: options.command === 'start' ? 'Opt-in interactive session uses the verified isolated generation and inherited terminal. Context selection remains advisory.' : options.command === 'run' ? 'Task launch uses selected context and the provider configuration. Inspect the launch result.' : options.command === 'resolve' ? 'Task context resolved within the selected profile.' + : options.command === 'routing-index' ? 'Routing index inspected. Prompt suggestions stay advisory; resolve verifies sources before loading.' : 'Managed profile generation inspected. Native activation is a separate provider boundary.', activation: value.selection?.activation || 'unobserved', next_actions: [], artifacts: [], ...value }; } diff --git a/scripts/lib/context-profile-support.js b/scripts/lib/context-profile-support.js index 017990d06..d0a34b9dd 100644 --- a/scripts/lib/context-profile-support.js +++ b/scripts/lib/context-profile-support.js @@ -3,7 +3,6 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const Ajv = require('ajv'); const { SUPPORTED_INSTALL_TARGETS } = require('./install-manifests'); const DEFAULT_REPO_ROOT = path.resolve(__dirname, '../..'); @@ -187,6 +186,8 @@ const schemaValidators = new Map(); function validateSchema(value, schemaName) { if (!schemaValidators.has(schemaName)) { const schema = JSON.parse(fs.readFileSync(path.join(DEFAULT_REPO_ROOT, 'schemas', schemaName), 'utf8')); + // Loaded on first validation: prompt-time readers never validate schemas. + const Ajv = require('ajv'); schemaValidators.set(schemaName, new Ajv({ allErrors: true, strict: true }).compile(schema)); } const validate = schemaValidators.get(schemaName); diff --git a/scripts/lib/context-retrieval.js b/scripts/lib/context-retrieval.js index adadd3b64..dbeec54b2 100644 --- a/scripts/lib/context-retrieval.js +++ b/scripts/lib/context-retrieval.js @@ -84,9 +84,27 @@ function fieldTokens(entry, field) { return tokenize(`${entry.ownerModuleId || ''} ${entry.packId || ''}`); } +// A precomputed dense leg as sorted [dimension, weight] pairs. JSON preserves +// the doubles exactly, so a stored vector ranks identically to a computed one. +function sparseDense(entry) { + const vector = denseVector([fieldTokens(entry, 'name'), fieldTokens(entry, 'description')]); + return vector.flatMap((value, dimension) => value === 0 ? [] : [[dimension, value]]); +} + +function expandDense(pairs) { + const vector = new Array(DENSE_DIM).fill(0); + for (const pair of pairs) { + if (!Array.isArray(pair) || pair.length !== 2 || !Number.isInteger(pair[0]) || pair[0] < 0 || pair[0] >= DENSE_DIM + || !Number.isFinite(pair[1])) throw new Error('Invalid precomputed dense vector'); + vector[pair[0]] = pair[1]; + } + return vector; +} + /** Build a reusable retrieval index over registry-shaped entries. Entries may * carry a `triggers` array (from the checked-in skill-triggers manifest) that - * is weighted between name and description. */ + * is weighted between name and description, and a `dense` array from + * sparseDense to skip recomputing the n-gram leg. */ function buildRetrievalIndex(entries) { const documents = entries.map(entry => { const fields = {}; @@ -102,7 +120,7 @@ function buildRetrievalIndex(entries) { } } return { entry, fields, weighted, docLength, - dense: denseVector([fields.name, fields.description]), + dense: entry.dense ? expandDense(entry.dense) : denseVector([fields.name, fields.description]), aliases: [...new Set([entry.id.slice('skill:'.length), entry.name].filter(Boolean).map(normalizedName))] }; }); const documentFrequency = new Map(); @@ -184,5 +202,5 @@ function searchRetrieval(index, query, { limit = 5 } = {}) { }); } -module.exports = { buildRetrievalIndex, searchRetrieval, tokenize, +module.exports = { buildRetrievalIndex, searchRetrieval, sparseDense, tokenize, internals: { denseVector, dot, DENSE_ADMIT_COSINE, DENSE_DIM } }; diff --git a/scripts/lib/context-routing-index.js b/scripts/lib/context-routing-index.js new file mode 100644 index 000000000..b2081101f --- /dev/null +++ b/scripts/lib/context-routing-index.js @@ -0,0 +1,178 @@ +'use strict'; + +// A metadata-only routing index for prompt-time suggestions. Each index is +// named by the managed generation it was built for and bound to that state's +// receipt, so a store change retires it. It never authorizes loading: the +// resolver re-verifies canonical sources before returning any skill body. +const path = require('node:path'); +const io = require('./context-profile-store-fs'); +const { buildRetrievalIndex, searchRetrieval, sparseDense } = require('./context-retrieval'); +const { DEFAULT_REPO_ROOT, digestObject, stableStringify } = require('./context-profile-support'); + +const SCHEMA = 'ecc.context-routing-index.v1'; +const POINTER_SCHEMA = 'ecc.context-routing-pointer.v1'; +const DIGEST = /^[a-f0-9]{64}$/; +const MAX_SUGGESTIONS = 3; +const MAX_DESCRIPTION = 120; +// A Full index is under 1 MiB; these bounds keep a prompt-time read proportional +// to a real registry even when the store holds something else. +const MAX_POINTER_BYTES = 4096; +const MAX_INDEX_BYTES = 4 * 1024 * 1024; +const MAX_ENTRIES = 2048; +const SKILL_ID = /^skill:[a-z0-9]+(?:-[a-z0-9]+)*$/; +const ENTRY_KEYS = new Set(['id', 'name', 'description', 'ownerModuleId', 'packId', 'triggers', 'dense']); + +function checkedRoot(stateRoot) { + if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot) || path.resolve(stateRoot) !== stateRoot) { + throw new Error('stateRoot must be an absolute managed store directory'); + } + return stateRoot; +} + +// Reads only the ownership marker, state pointer and its receipt: enough to +// bind an index to the current generation without rehashing the payload. +function readBinding(stateRoot) { + const root = checkedRoot(stateRoot); + const marker = { schemaVersion: 'ecc.context-store.v1', destinationDigest: digestObject({ root }) }; + const markerFile = path.join(root, 'store.json'); + if (!io.inspect(markerFile, true).stat || stableStringify(io.readJson(markerFile)) !== stableStringify(marker)) { + throw new Error('Directory is not an owned ECC managed store'); + } + const state = io.readJson(path.join(root, 'state.json')); + if (state.schemaVersion !== 'ecc.context-store-state.v1' || !DIGEST.test(state.receiptDigest) || !DIGEST.test(state.generationDigest)) { + throw new Error('Managed state integrity mismatch'); + } + const receipt = io.readJson(path.join(root, 'receipts', `${state.receiptDigest}.json`)); + if (digestObject(receipt) !== state.receiptDigest || receipt.generationDigest !== state.generationDigest) { + throw new Error('Managed receipt and state integrity mismatch'); + } + return { root, generationDigest: state.generationDigest, receiptDigest: state.receiptDigest, + revision: receipt.revision, selectionMode: receipt.selection?.selectionMode || null }; +} + +// Stored vectors are little-endian uint16 dimensions and float64 weights in +// base64: half the size of JSON pairs and exact on any platform. +function encodeDense(pairs) { + const indexes = Buffer.alloc(pairs.length * 2); + const values = Buffer.alloc(pairs.length * 8); + pairs.forEach(([dimension, value], position) => { + indexes.writeUInt16LE(dimension, position * 2); + values.writeDoubleLE(value, position * 8); + }); + return { indexes: indexes.toString('base64'), values: values.toString('base64') }; +} + +function decodeDense(dense) { + if (!dense || typeof dense.indexes !== 'string' || typeof dense.values !== 'string') return null; + const indexes = Buffer.from(dense.indexes, 'base64'); + const values = Buffer.from(dense.values, 'base64'); + if (indexes.length % 2 || values.length !== indexes.length * 4) return null; + const pairs = new Array(indexes.length / 2); + for (let position = 0; position < pairs.length; position++) { + pairs[position] = [indexes.readUInt16LE(position * 2), values.readDoubleLE(position * 8)]; + } + return pairs; +} + +// routing/.json is a small pointer bound to the state receipt; the +// entries live in routing/indexes/.json so readers verify them +// by hashing raw bytes instead of re-serializing a large object. +const pointerPath = (root, generationDigest) => path.join(root, 'routing', `${generationDigest}.json`); +const entriesPath = (root, digest) => path.join(root, 'routing', 'indexes', `${digest}.json`); + +/** Build the index for the configured, recovered store. Reads canonical sources. */ +function writeRoutingIndex({ stateRoot, repoRoot = DEFAULT_REPO_ROOT } = {}) { + const root = checkedRoot(stateRoot); + const status = require('./context-profile-store').getStoreStatus({ stateRoot: root }); + if (!status.configured || status.recoveryRequired) throw new Error('Routing index requires a configured, recovered managed store'); + const binding = readBinding(root); + if (binding.receiptDigest !== status.receiptDigest) throw new Error('Managed state changed during routing index build'); + const carrier = require('./context-carriers').planContextCarrier({ repoRoot, profileId: status.profileId, target: status.target, + selectionMode: status.selectionMode, include: status.include, exclude: status.exclude }); + if (carrier.carrierDigest !== status.carrierDigest) throw new Error('Stored profile source is stale; set the current generation before indexing'); + const { routingEntries } = require('./context-selection'); + const routing = routingEntries({ repoRoot, profileId: status.profileId, target: status.target, + selectionMode: status.selectionMode, include: status.include, exclude: status.exclude }); + if (routing.planDigest !== carrier.planDigest || routing.registryDigest !== carrier.registryDigest) { + throw new Error('Skill sources changed during routing index build; run it again'); + } + const index = { schemaVersion: SCHEMA, generationDigest: binding.generationDigest, receiptDigest: binding.receiptDigest, + ...routing, entries: routing.entries.map(entry => ({ ...entry, dense: encodeDense(sparseDense(entry)) })) }; + const bytes = io.jsonBytes(index); + const digest = io.hash(bytes); + const file = entriesPath(root, digest); + io.mkdir(path.join(root, 'routing')); io.mkdir(path.dirname(file)); + // Temp file and rename, so an interrupted build never leaves partial bytes + // under a digest name; a file whose bytes do not hash to its name (left by + // an interrupted in-place write) is replaced rather than trusted. + if (!io.inspect(file, true).stat || io.hash(io.read(file)) !== digest) io.atomicJson(file, index); + if (readBinding(root).receiptDigest !== binding.receiptDigest) throw new Error('Managed state changed during routing index build'); + const pointer = { schemaVersion: POINTER_SCHEMA, generationDigest: binding.generationDigest, + receiptDigest: binding.receiptDigest, indexDigest: digest, bytes: bytes.length }; + io.atomicJson(pointerPath(root, binding.generationDigest), { ...pointer, pointerDigest: digestObject(pointer) }); + return { status: 'written', path: file, generationDigest: binding.generationDigest, entries: routing.entries.length }; +} + +const optionalString = value => value === undefined || typeof value === 'string'; + +function validEntry(entry) { + return entry !== null && typeof entry === 'object' && !Array.isArray(entry) + && Object.keys(entry).every(key => ENTRY_KEYS.has(key)) + && typeof entry.id === 'string' && SKILL_ID.test(entry.id) && typeof entry.description === 'string' + && optionalString(entry.name) && optionalString(entry.ownerModuleId) && optionalString(entry.packId) + && (entry.triggers === undefined || (Array.isArray(entry.triggers) && entry.triggers.every(trigger => typeof trigger === 'string'))); +} + +/** Return the index for the current generation, or null when none was built + * for this state. A pointer left by an earlier state of the same generation + * (after a rollback or mode change) is retired, not corrupt. */ +function readRoutingIndex(stateRoot, binding = readBinding(stateRoot)) { + if (binding.root !== checkedRoot(stateRoot)) throw new Error('Routing binding belongs to a different store'); + const file = pointerPath(binding.root, binding.generationDigest); + if (!io.inspect(path.dirname(file), true).stat || !io.inspect(file, true).stat) return null; + const invalid = () => new Error('Routing index integrity mismatch; rebuild it with ecc profile routing-index'); + if (io.inspect(file).stat.size > MAX_POINTER_BYTES) throw invalid(); + const { pointerDigest, ...pointer } = io.readJson(file); + if (pointer.schemaVersion !== POINTER_SCHEMA || digestObject(pointer) !== pointerDigest || !DIGEST.test(pointer.indexDigest || '') + || !DIGEST.test(pointer.receiptDigest || '') || pointer.generationDigest !== binding.generationDigest + || !Number.isSafeInteger(pointer.bytes) || pointer.bytes <= 0 || pointer.bytes > MAX_INDEX_BYTES) throw invalid(); + if (pointer.receiptDigest !== binding.receiptDigest) return null; + const entriesFile = entriesPath(binding.root, pointer.indexDigest); + if (io.inspect(entriesFile).stat.size !== pointer.bytes) throw invalid(); + const bytes = io.read(entriesFile); + if (bytes.length !== pointer.bytes || io.hash(bytes) !== pointer.indexDigest) throw invalid(); + const index = JSON.parse(bytes.toString('utf8')); + if (index.schemaVersion !== SCHEMA || index.generationDigest !== binding.generationDigest + || index.receiptDigest !== binding.receiptDigest || !Array.isArray(index.entries) || index.entries.length > MAX_ENTRIES + || !index.entries.every(validEntry)) throw invalid(); + const entries = index.entries.map(entry => ({ ...entry, dense: decodeDense(entry.dense) })); + if (entries.some(entry => !entry.dense)) throw invalid(); + return { ...index, entries }; +} + +function routingIndexStatus(stateRoot) { + const binding = readBinding(stateRoot); + const index = readRoutingIndex(stateRoot, binding); + return { status: index ? 'current' : 'missing', path: pointerPath(binding.root, binding.generationDigest), + generationDigest: binding.generationDigest, entries: index ? index.entries.length : 0 }; +} + +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]+', 'g'); +const singleLine = text => String(text).replace(CONTROL_CHARACTERS, ' ').replace(/\s+/g, ' ').trim(); + +/** Rank index entries for a prompt. IDs and one-line descriptions only; like + * implicit admission, a suggestion needs a name or trigger anchor term. */ +function suggestContext(index, prompt, { limit = MAX_SUGGESTIONS } = {}) { + // Filter before trimming so unanchored matches cannot crowd out anchored ones. + return searchRetrieval(buildRetrievalIndex(index.entries), prompt, { limit: index.entries.length }) + .filter(candidate => SKILL_ID.test(candidate.id) && (candidate.exact || candidate.anchorTerms.length > 0)) + .slice(0, Math.min(limit, MAX_SUGGESTIONS)) + .map(candidate => { + const description = singleLine(candidate.description); + return { id: candidate.id, + description: description.length > MAX_DESCRIPTION ? `${description.slice(0, MAX_DESCRIPTION - 1)}…` : description }; + }); +} + +module.exports = { readBinding, readRoutingIndex, routingIndexStatus, suggestContext, writeRoutingIndex }; diff --git a/scripts/lib/context-selection.js b/scripts/lib/context-selection.js index 073084979..e0e7f93cf 100644 --- a/scripts/lib/context-selection.js +++ b/scripts/lib/context-selection.js @@ -97,18 +97,26 @@ function policyFor(entry, reader) { dynamic: /!`/.test(source) }; } +// Admission denials carry stable codes so callers can tell a skill that may +// not be selected from a source that failed verification. +function denied(message, code) { + const error = new Error(message); + error.code = code; + return error; +} + function selectedClosure(ids, explicit, byId, excluded, reader) { const selected = new Set(); function visit(id) { if (!byId.has(id)) throw new Error(`Unknown context ID: ${id}`); - if (excluded.has(id)) throw new Error(`Context ID is excluded: ${id}`); + if (excluded.has(id)) throw denied(`Context ID is excluded: ${id}`, 'ECC_CONTEXT_EXCLUDED'); if (selected.has(id)) return; const entry = byId.get(id); const policy = policyFor(entry, reader); - if (policy.manualOnly && !explicit.has(id)) throw new Error(`Context ID is manual-only: ${id}`); - if (policy.authority || policy.dynamic) throw new Error(`Context requires native authority or dynamic-content review: ${id}`); + if (policy.manualOnly && !explicit.has(id)) throw denied(`Context ID is manual-only: ${id}`, 'ECC_CONTEXT_MANUAL_ONLY'); + if (policy.authority || policy.dynamic) throw denied(`Context requires native authority or dynamic-content review: ${id}`, 'ECC_CONTEXT_AUTHORITY'); selected.add(id); - if (selected.size > MAX_SELECTED) throw new Error('Task selection exceeds the skill limit'); + if (selected.size > MAX_SELECTED) throw denied('Task selection exceeds the skill limit', 'ECC_CONTEXT_SKILL_LIMIT'); entry.dependencies.forEach(visit); } ids.forEach(visit); @@ -122,7 +130,7 @@ function readSelected(ids, byId, reader) { return [...new Set([entry.sourcePath, ...entry.requiredResources])].map(sourcePath => { const actual = verifiedResource(entry, sourcePath, reader); total += actual.bytes; - if (total > MAX_CONTEXT_BYTES) throw new Error('Task context exceeds the 32000-byte budget; choose a narrower immediate step'); + if (total > MAX_CONTEXT_BYTES) throw denied('Task context exceeds the 32000-byte budget; choose a narrower immediate step', 'ECC_CONTEXT_BYTE_BUDGET'); const content = actual.content.toString('utf8'); if (!Buffer.from(content, 'utf8').equals(actual.content) || content.includes('\0')) throw new Error('Required context resource is not UTF-8 text'); return { id, path: sourcePath, digest: actual.digest, bytes: actual.bytes, content }; @@ -140,6 +148,42 @@ function validatePrevious(previous) { } } +const ADMISSION_DENIALS = new Set(['ECC_CONTEXT_EXCLUDED', 'ECC_CONTEXT_MANUAL_ONLY', 'ECC_CONTEXT_AUTHORITY', + 'ECC_CONTEXT_SKILL_LIMIT', 'ECC_CONTEXT_BYTE_BUDGET']); + +function admissibility(byId, excluded, reader) { + return id => { + try { + const closure = selectedClosure([id], new Set(), byId, excluded, reader); + readSelected(closure, byId, reader); + return true; + } catch (error) { + // Only coded admission denials remove a suggestion. Source drift, + // malformed policy and non-UTF-8 resources fail the build instead. + if (ADMISSION_DENIALS.has(error.code)) return false; + throw error; + } + }; +} + +/** Entries the resolver could suggest for a profile: not excluded and admissible + * without explicit selection. Metadata only; bodies are never returned. */ +function routingEntries({ repoRoot = DEFAULT_REPO_ROOT, profileId = 'lean@1', target = 'codex', + selectionMode = 'auto', include = [], exclude = [] } = {}) { + const plan = compileContextProfile({ repoRoot, profileId, target, selectionMode, include, exclude }); + const registry = loadContextRegistry({ repoRoot }); + const { triggers } = loadSkillTriggers({ repoRoot }); + if (registry.registryDigest !== plan.registryDigest) throw new Error('Registry changed during routing index build'); + const byId = new Map(registry.entries.map(entry => [entry.id, entry])); + const excluded = new Set(plan.excludedIds); + const admissible = admissibility(byId, excluded, createSourceReader(repoRoot)); + const entries = registry.entries.filter(entry => !excluded.has(entry.id) && admissible(entry.id)) + .map(entry => ({ id: entry.id, name: entry.name, description: entry.description, + ownerModuleId: entry.ownerModuleId, packId: entry.packId, ...(triggers[entry.id] ? { triggers: triggers[entry.id] } : {}) })); + return { profileId: plan.profileId, target, selectionMode, planDigest: plan.planDigest, registryDigest: registry.registryDigest, + routingPolicyVersion: ROUTING_POLICY_VERSION, triggersDigest: digestObject(triggers), entries }; +} + /** Pure task-scoped resolver. Returned context never invokes a native skill or changes permissions. */ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'lean@1', target = 'codex', selectionMode = 'auto', include = [], exclude = [], load = false, previous = null, expectedDigest = null } = {}) { @@ -156,7 +200,7 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l const proposedIds = [...(task.proposedIds || [])].sort(); [...explicitIds, ...proposedIds].forEach(id => { if (!byId.has(id)) throw new Error(`Unknown context ID: ${id}`); - if (excluded.has(id)) throw new Error(`Context ID is excluded: ${id}`); + if (excluded.has(id)) throw denied(`Context ID is excluded: ${id}`, 'ECC_CONTEXT_EXCLUDED'); }); const taskBinding = { sessionId: task.sessionId, taskId: task.taskId, revision: task.revision, phase: task.phase }; const bindingDigest = digestObject({ ...taskBinding, planDigest: plan.planDigest, @@ -164,18 +208,7 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l queryDigest: digestObject(task.query || '') }); const reused = Boolean(previous && previous.bindingDigest === bindingDigest && !task.noWorkflow && ['selected', 'none'].includes(previous.decision) && !explicitIds.length && !proposedIds.length); - const admissible = id => { - try { - const closure = selectedClosure([id], new Set(), byId, excluded, reader); - readSelected(closure, byId, reader); - return true; - } catch (error) { - // Only known admission denials remove a suggestion. Source drift and - // malformed policy still fail closed instead of disappearing from view. - if (/manual-only|requires native authority|is excluded|exceeds the skill limit|32000-byte budget|not UTF-8 text/.test(error.message)) return false; - throw error; - } - }; + const admissible = admissibility(byId, excluded, reader); const { candidates } = task.noWorkflow || selectionMode === 'manual' || reused ? { candidates: [] } : candidatesFor(task.query || '', registry.entries, excluded, admissible, triggers); // Auto admission: free-text routing loads the ranked top skill only on @@ -277,4 +310,4 @@ function resolveDeclinedFallback(options, selection) { receipt: { ...receiptValue, receiptDigest: digestObject(receiptValue) } }; } -module.exports = { resolveTaskContext, resolveDeclinedFallback }; +module.exports = { resolveTaskContext, resolveDeclinedFallback, routingEntries }; diff --git a/scripts/profile.js b/scripts/profile.js index 0aefd7b06..f515dba8d 100644 --- a/scripts/profile.js +++ b/scripts/profile.js @@ -35,6 +35,7 @@ Experimental managed profiles and bounded task context: ecc profile mode auto|manual|suggest --state-root [--dry-run] [--json] ecc profile rollback --state-root [--expected-revision N] [--json] ecc profile recover --state-root [--json] + ecc profile routing-index --state-root [--dry-run] [--json] ecc profile start --state-root --native-root [--dry-run] ecc profile prepare-native --state-root --native-root [--dry-run] [--json] ecc profile native-status --state-root --native-root [--json] @@ -47,6 +48,7 @@ nothing is installed. Authenticate separately in the isolated home; credentials Set stages owned generations; provider discovery is verified separately. Resolve returns context only with --load; suggest and --dry-run never return skill bodies. Use resolve --state-root to honor the saved base, mode and exclusions. +Routing-index writes the metadata index read by the opt-in prompt suggestion hook; rebuild it after set, mode or rollback. Run with --native-root to use a verified isolated Codex or Claude generation. Existing sessions are unchanged. `; } diff --git a/tests/hooks/context-prompt-suggestions.test.js b/tests/hooks/context-prompt-suggestions.test.js new file mode 100644 index 000000000..4089528aa --- /dev/null +++ b/tests/hooks/context-prompt-suggestions.test.js @@ -0,0 +1,136 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); +const { withFixture } = require('../lib/helpers/context-fixture'); +const store = require('../../scripts/lib/context-profile-store'); +const routing = require('../../scripts/lib/context-routing-index'); +const hook = require('../../scripts/hooks/context-prompt-suggestions'); + +const PROMPT = JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'help with feature work in shared code' }); + +function fixture(callback, { mode = 'auto', index = true } = {}) { + return withFixture(repoRoot => { + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-prompt-suggest-')); + const stateRoot = path.join(parent, 'managed'); + try { + store.applyStore({ repoRoot, stateRoot, target: 'claude', selectionMode: mode }); + if (index) routing.writeRoutingIndex({ repoRoot, stateRoot }); + return callback({ stateRoot, env: { ECC_CONTEXT_STATE_ROOT: stateRoot } }); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } + }); +} + +const stdout = result => (typeof result === 'string' ? result : result.stdout); + +test('the hook is silent unless a managed store is explicitly configured', () => fixture(() => { + assert.equal(stdout(hook.run(PROMPT, {}, {})), ''); + assert.equal(stdout(hook.run(PROMPT, {}, { ECC_CONTEXT_STATE_ROOT: 'relative/store' })), ''); +})); + +test('suggestions are advisory IDs with bounded descriptions and the resolve command', () => fixture(({ stateRoot, env }) => { + const text = stdout(hook.run(PROMPT, {}, env)); + assert.match(text, /advisory; nothing was loaded/); + assert.match(text, /skill:feature/); + assert.match(text, /profile resolve --state-root/); + assert.ok(text.includes(JSON.stringify(stateRoot))); + assert.doesNotMatch(text, /Instructions remain on demand/); + assert.ok(text.split('\n').filter(line => line.startsWith('- skill:')).length <= 3); + assert.ok(text.split('\n').every(line => line.length <= 400)); +})); + +test('manual mode, short prompts, slash commands and malformed input stay silent', () => { + fixture(({ env }) => assert.equal(stdout(hook.run(PROMPT, {}, env)), ''), { mode: 'manual' }); + fixture(({ env }) => { + for (const raw of [JSON.stringify({ prompt: 'fix it' }), JSON.stringify({ prompt: '/plan help with feature work' }), + '{not json', JSON.stringify({ prompt: 42 }), '']) { + assert.equal(stdout(hook.run(raw, {}, env)), ''); + } + }); +}); + +test('a missing index is a diagnostic, never an error or a rebuild', () => fixture(({ env }) => { + const result = hook.run(PROMPT, {}, env); + assert.equal(stdout(result), ''); + assert.match(result.stderr, /routing-index/); +}, { index: false })); + +test('a tampered index or exhausted budget fails open to no suggestion', () => fixture(({ stateRoot, env }) => { + assert.equal(stdout(hook.run(PROMPT, {}, { ...env, ECC_CONTEXT_SUGGEST_BUDGET_MS: '0' })), ''); + const directory = path.join(stateRoot, 'routing'); + const file = path.join(directory, fs.readdirSync(directory).find(name => name.endsWith('.json'))); + fs.writeFileSync(file, '{"schemaVersion":"ecc.context-routing-index.v1"}'); + const result = hook.run(PROMPT, {}, env); + assert.equal(stdout(result), ''); + assert.match(result.stderr, /integrity|index/); +})); + +test('the hook runs through run-with-flags and returns its text on stdout', () => fixture(({ env }) => { + const wrapper = path.join(__dirname, '../../scripts/hooks/run-with-flags.js'); + const result = spawnSync(process.execPath, [wrapper, 'user-prompt:context-suggestions', 'scripts/hooks/context-prompt-suggestions.js'], + { input: PROMPT, encoding: 'utf8', env: { PATH: process.env.PATH, ...env }, timeout: 30000 }); + assert.equal(result.status, 0); + assert.match(result.stdout, /skill:feature/); +})); + +test('the session-start builder indexes the current generation once and stays silent when disabled', () => { + const builder = require('../../scripts/hooks/context-routing-index'); + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-routing-builder-')); + const stateRoot = path.join(parent, 'managed'); + try { + assert.equal(builder.run('{}', {}, {}), ''); + store.applyStore({ stateRoot, target: 'claude' }); + const env = { ECC_CONTEXT_STATE_ROOT: stateRoot }; + assert.match(builder.run('{}', {}, env).stderr, /built/); + assert.equal(routing.routingIndexStatus(stateRoot).status, 'current'); + assert.equal(builder.run('{}', {}, env), ''); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } +}); + +test('manual mode is silent before the index is read, even when it is missing or corrupt', () => { + fixture(({ env }) => assert.deepEqual(hook.run(PROMPT, {}, env), ''), { mode: 'manual', index: false }); + fixture(({ stateRoot, env }) => { + const directory = path.join(stateRoot, 'routing'); + fs.writeFileSync(path.join(directory, fs.readdirSync(directory).find(name => name.endsWith('.json'))), '{'); + assert.deepEqual(hook.run(PROMPT, {}, env), ''); + }, { mode: 'manual' }); +}); + +test('loading advice matches the saved mode', () => { + fixture(({ env }) => { + const text = stdout(hook.run(PROMPT, {}, env)); + assert.match(text, /skill:feature/); + assert.match(text, /--load/); + }); + fixture(({ stateRoot, env }) => { + const text = stdout(hook.run(PROMPT, {}, env)); + assert.match(text, /skill:feature/); + assert.doesNotMatch(text, /--load/); + assert.match(text, /suggest mode/i); + assert.ok(text.includes(`ecc profile mode auto --state-root `)); + }, { mode: 'suggest' }); +}); + +test('shell metacharacters in a store path remain data rather than executable advice', () => { + const root = '/tmp/store-$(touch SHOULD_NOT_RUN)-`echo injected`-"quoted"'; + const routingModule = require('../../scripts/lib/context-routing-index'); + const originals = { readBinding: routingModule.readBinding, readRoutingIndex: routingModule.readRoutingIndex, + suggestContext: routingModule.suggestContext }; + try { + routingModule.readBinding = () => ({ root, revision: 1, selectionMode: 'auto' }); + routingModule.readRoutingIndex = () => ({ entries: [] }); + routingModule.suggestContext = () => [{ id: 'skill:feature', description: 'Focused feature help.' }]; + const text = stdout(hook.run(PROMPT, {}, { ECC_CONTEXT_STATE_ROOT: root })); + assert.ok(text.includes(`Store path (data): ${JSON.stringify(root)}`)); + const commandLine = text.split('\n').find(line => line.startsWith('To load one')); + assert.ok(commandLine.includes('--state-root ')); + assert.ok(!commandLine.includes(root)); + assert.doesNotMatch(commandLine, /touch|injected/); + } finally { + Object.assign(routingModule, originals); + } +}); diff --git a/tests/lib/context-retrieval.test.js b/tests/lib/context-retrieval.test.js index dab83ba75..21d63e7a9 100644 --- a/tests/lib/context-retrieval.test.js +++ b/tests/lib/context-retrieval.test.js @@ -105,3 +105,14 @@ test('actual registry: every candidate carries matched terms and a fused score', assert.equal(candidate.description, candidate.description.slice(0, 2048)); } }); + +test('precomputed dense vectors rank identically to computed ones', () => { + const { sparseDense } = require('../../scripts/lib/context-retrieval'); + const { entries } = loadContextRegistry({ repoRoot: DEFAULT_REPO_ROOT }); + const computed = buildRetrievalIndex(entries); + const stored = buildRetrievalIndex(JSON.parse(JSON.stringify(entries.map(entry => ({ ...entry, dense: sparseDense(entry) }))))); + for (const query of ['review a postgres migration that adds an indexed column', 'navigate faster', 'two services own one record']) { + assert.deepEqual(searchRetrieval(stored, query, { limit: 15 }), searchRetrieval(computed, query, { limit: 15 })); + } + assert.throws(() => buildRetrievalIndex([{ ...entries[0], dense: [[2048, 1]] }]), /dense vector/); +}); diff --git a/tests/lib/context-routing-index.test.js b/tests/lib/context-routing-index.test.js new file mode 100644 index 000000000..6aada591a --- /dev/null +++ b/tests/lib/context-routing-index.test.js @@ -0,0 +1,236 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { withFixture, write } = require('./helpers/context-fixture'); +const store = require('../../scripts/lib/context-profile-store'); +const routing = require('../../scripts/lib/context-routing-index'); + +function fixture(callback) { + return withFixture(repoRoot => { + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-routing-index-')); + const stateRoot = path.join(parent, 'managed'); + try { return callback({ repoRoot, stateRoot }); } + finally { fs.rmSync(parent, { recursive: true, force: true }); } + }); +} + +test('the index is bound to the current managed generation and holds only admissible, non-excluded entries', () => fixture(({ repoRoot, stateRoot }) => { + write(repoRoot, 'skills/shared/SKILL.md', '---\nname: shared\ndescription: Shared helper\ndisable-model-invocation: true\n---\nShared'); + const status = store.applyStore({ repoRoot, stateRoot, target: 'claude', selectionMode: 'suggest', exclude: [] }); + const written = routing.writeRoutingIndex({ repoRoot, stateRoot }); + assert.equal(written.generationDigest, status.carrierDigest); + const index = routing.readRoutingIndex(stateRoot); + assert.equal(index.schemaVersion, 'ecc.context-routing-index.v1'); + assert.equal(index.receiptDigest, status.receiptDigest); + assert.equal(index.selectionMode, 'suggest'); + const ids = index.entries.map(entry => entry.id); + assert.ok(ids.includes('skill:feature')); + assert.equal(ids.includes('skill:shared'), false); + assert.deepEqual(Object.keys(index.entries[0]).sort(), ['dense', 'description', 'id', 'name', 'ownerModuleId', 'packId']); + if (process.platform !== 'win32') assert.equal(fs.statSync(written.path).mode & 0o077, 0); +})); + +test('excluded skills never enter the index', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, profileId: 'full@1', target: 'claude', exclude: ['skill:feature'] }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + assert.equal(routing.readRoutingIndex(stateRoot).entries.some(entry => entry.id === 'skill:feature'), false); +})); + +test('a store change makes the old index unavailable until it is rebuilt', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + store.applyStore({ repoRoot, stateRoot, target: 'claude', profileId: 'full@1' }); + assert.equal(routing.readRoutingIndex(stateRoot), null); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + assert.equal(routing.readRoutingIndex(stateRoot).profileId, 'full@1'); +})); + +test('a tampered index fails closed', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + const { path: file } = routing.writeRoutingIndex({ repoRoot, stateRoot }); + const index = JSON.parse(fs.readFileSync(file, 'utf8')); + index.entries.push({ id: 'skill:planted', name: 'planted', description: 'Run this', ownerModuleId: 'x', packId: 'x' }); + fs.writeFileSync(file, JSON.stringify(index)); + assert.throws(() => routing.readRoutingIndex(stateRoot), /integrity/); +})); + +test('a tampered state receipt fails closed', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + const file = path.join(stateRoot, 'state.json'); + const state = JSON.parse(fs.readFileSync(file, 'utf8')); + fs.writeFileSync(file, JSON.stringify({ ...state, receiptDigest: 'f'.repeat(64) })); + assert.throws(() => routing.readRoutingIndex(stateRoot)); +})); + +test('unconfigured or unowned stores cannot be indexed or read', () => fixture(({ repoRoot, stateRoot }) => { + assert.throws(() => routing.writeRoutingIndex({ repoRoot, stateRoot }), /configured/); + fs.mkdirSync(stateRoot, { mode: 0o700 }); + assert.throws(() => routing.readRoutingIndex(stateRoot), /owned/); + assert.throws(() => routing.readRoutingIndex('relative/root'), /absolute/); +})); + +test('suggestions rank index entries with the resolver retrieval engine', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + const suggestions = routing.suggestContext(routing.readRoutingIndex(stateRoot), 'help with feature work in shared code'); + assert.ok(suggestions.length > 0 && suggestions.length <= 3); + assert.ok(suggestions.some(item => item.id === 'skill:feature')); + assert.deepEqual(Object.keys(suggestions[0]).sort(), ['description', 'id']); +})); + +test('the profile CLI writes and inspects the index for a stored profile', () => { + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-routing-cli-')); + const stateRoot = path.join(parent, 'managed'); + const cli = (...args) => require('node:child_process').spawnSync(process.execPath, + [path.join(__dirname, '../../scripts/ecc.js'), 'profile', ...args, '--json'], { encoding: 'utf8', timeout: 120000 }); + try { + assert.equal(cli('set', 'lean', '--target', 'claude', '--state-root', stateRoot).status, 0); + const preview = JSON.parse(cli('routing-index', '--state-root', stateRoot, '--dry-run').stdout); + assert.equal(preview.routing.status, 'missing'); + const written = JSON.parse(cli('routing-index', '--state-root', stateRoot).stdout); + assert.equal(written.routing.status, 'written'); + assert.ok(written.routing.entries > 100); + assert.equal(JSON.parse(cli('routing-index', '--state-root', stateRoot, '--dry-run').stdout).routing.status, 'current'); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } +}); + +test('an index is never built from sources that differ from the stored generation', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + assert.throws(() => routing.writeRoutingIndex({ stateRoot }), /stale/); + assert.equal(fs.existsSync(path.join(stateRoot, 'routing')), false); +})); + +test('actual registry: suggestions need a name or trigger anchor like implicit admission', () => { + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-routing-anchor-')); + const stateRoot = path.join(parent, 'managed'); + try { + store.applyStore({ stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ stateRoot }); + const index = routing.readRoutingIndex(stateRoot); + assert.deepEqual(routing.suggestContext(index, 'two services both think they own the same record'), []); + assert.equal(routing.suggestContext(index, 'add keyboard focus handling to our react settings form')[0].id, 'skill:frontend-a11y'); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } +}); + +test('stored vectors use a compact little-endian encoding that decodes exactly', () => fixture(({ repoRoot, stateRoot }) => { + const { sparseDense } = require('../../scripts/lib/context-retrieval'); + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + const { path: file } = routing.writeRoutingIndex({ repoRoot, stateRoot }); + const stored = JSON.parse(fs.readFileSync(file, 'utf8')).entries[0]; + assert.deepEqual(Object.keys(stored.dense).sort(), ['indexes', 'values']); + const decoded = routing.readRoutingIndex(stateRoot).entries.find(entry => entry.id === stored.id); + const { dense: _dense, ...metadata } = decoded; + assert.deepEqual(decoded.dense, sparseDense(metadata)); +})); + +function republish(stateRoot, change) { + const io = require('../../scripts/lib/context-profile-store-fs'); + const { digestObject } = require('../../scripts/lib/context-profile-support'); + const directory = path.join(stateRoot, 'routing'); + const pointerFile = path.join(directory, fs.readdirSync(directory).find(name => name.endsWith('.json'))); + const { pointerDigest: _digest, ...pointer } = JSON.parse(fs.readFileSync(pointerFile, 'utf8')); + const index = JSON.parse(fs.readFileSync(path.join(directory, 'indexes', `${pointer.indexDigest}.json`), 'utf8')); + const bytes = io.jsonBytes(change(index)); + const indexDigest = io.hash(bytes); + fs.writeFileSync(path.join(directory, 'indexes', `${indexDigest}.json`), bytes); + const next = { ...change.pointer?.(pointer) || pointer, indexDigest, bytes: bytes.length }; + fs.writeFileSync(pointerFile, JSON.stringify({ ...next, pointerDigest: digestObject(next) })); +} + +test('rollback to an indexed generation retires its old pointer instead of failing', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + store.applyStore({ repoRoot, stateRoot, target: 'claude', profileId: 'full@1' }); + store.rollbackStore({ stateRoot, expectedRevision: store.getStoreStatus({ stateRoot }).revision }); + assert.equal(routing.readRoutingIndex(stateRoot), null); + assert.equal(routing.routingIndexStatus(stateRoot).status, 'missing'); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + assert.equal(routing.routingIndexStatus(stateRoot).status, 'current'); +})); + +const MALFORMED = { + 'non-string id': entry => ({ ...entry, id: 42 }), + 'id outside the skill namespace': entry => ({ ...entry, id: 'agent:planner' }), + 'non-string name': entry => ({ ...entry, name: 5 }), + 'non-string description': entry => ({ ...entry, description: {} }), + 'non-array triggers': entry => ({ ...entry, triggers: 'review' }), + 'non-string trigger': entry => ({ ...entry, triggers: [1] }), + 'non-string owner module': entry => ({ ...entry, ownerModuleId: [] }), +}; +for (const [label, change] of Object.entries(MALFORMED)) { + test(`a digest-consistent index with a ${label} entry fails as an integrity error`, () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + republish(stateRoot, index => ({ ...index, entries: [change(index.entries[0]), ...index.entries.slice(1)] })); + assert.throws(() => routing.readRoutingIndex(stateRoot), /integrity/); + })); +} + +test('an index larger than the read bound fails before it is read', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + const filler = Array.from({ length: 6000 }, (_, position) => ({ id: `skill:filler-${position}`, name: `filler-${position}`, + description: 'x'.repeat(900), ownerModuleId: 'm', packId: 'p', dense: { indexes: '', values: '' } })); + republish(stateRoot, index => ({ ...index, entries: [...index.entries, ...filler] })); + assert.throws(() => routing.readRoutingIndex(stateRoot), /integrity|bound/); +})); + +test('an anchored suggestion is not hidden by unanchored candidates ranked above it', () => { + const filler = position => ({ id: `skill:filler-${position}`, name: `filler-${position}`, + description: `migrate database schema safely today with care number ${position}`, ownerModuleId: 'm', packId: 'p' }); + const index = { entries: [1, 2, 3, 4].map(filler) + .concat({ id: 'skill:schema-tool', name: 'schema-tool', description: 'Generic helper', ownerModuleId: 'm', packId: 'p' }) }; + assert.deepEqual(routing.suggestContext(index, 'migrate database schema safely today').map(item => item.id), ['skill:schema-tool']); +}); + +test('an index file left partial by an interrupted build is replaced on the next build', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + const { path: file } = routing.writeRoutingIndex({ repoRoot, stateRoot }); + const bytes = fs.readFileSync(file); + // A build that stops mid-write leaves a partial file under the digest name + // and has not yet published the pointer. + const directory = path.join(stateRoot, 'routing'); + fs.rmSync(path.join(directory, fs.readdirSync(directory).find(name => name.endsWith('.json')))); + fs.writeFileSync(file, bytes.subarray(0, Math.floor(bytes.length / 2))); + assert.equal(routing.readRoutingIndex(stateRoot), null); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + assert.ok(fs.readFileSync(file).equals(bytes)); + assert.equal(routing.routingIndexStatus(stateRoot).status, 'current'); +})); + +test('a source edited after entries are read leaves an index that matches the stored generation', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + const selection = require('../../scripts/lib/context-selection'); + const original = selection.routingEntries; + const skill = path.join(repoRoot, 'skills/feature/SKILL.md'); + const stored = fs.readFileSync(skill, 'utf8').match(/description: (.*)/)[1]; + selection.routingEntries = options => { + const routed = original(options); + fs.writeFileSync(skill, fs.readFileSync(skill, 'utf8').replace(/description: .*/, 'description: Edited after entries')); + return routed; + }; + try { routing.writeRoutingIndex({ repoRoot, stateRoot }); } finally { selection.routingEntries = original; } + assert.equal(routing.readRoutingIndex(stateRoot).entries.find(entry => entry.id === 'skill:feature').description, stored); + // The drift is the generation's, and the next build reports it. + assert.throws(() => routing.writeRoutingIndex({ repoRoot, stateRoot }), /stale/); +})); + +test('a skill edited during the build never publishes an index under the stored generation', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + const selection = require('../../scripts/lib/context-selection'); + const original = selection.routingEntries; + selection.routingEntries = options => { + const file = path.join(repoRoot, 'skills/feature/SKILL.md'); + fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace(/description: .*/, 'description: Edited mid-build')); + return original(options); + }; + try { + assert.throws(() => routing.writeRoutingIndex({ repoRoot, stateRoot }), /changed|stale/); + } finally { selection.routingEntries = original; } + assert.equal(routing.readRoutingIndex(stateRoot), null); +})); diff --git a/tests/lib/context-selection.test.js b/tests/lib/context-selection.test.js index 74db84a45..c0e37d4a7 100644 --- a/tests/lib/context-selection.test.js +++ b/tests/lib/context-selection.test.js @@ -319,3 +319,18 @@ test('invalid input and oversized bodies fail closed', () => withFixture(repoRoo overrides: [{ id: 'skill:feature', requiredResources: ['skills/feature/references/details.md'] }] })); assert.throws(() => resolve(repoRoot, { explicitIds: ['skill:feature'] }, { load: true }), /budget/); })); + +test('routing entries drop only coded admission denials; a corrupt source still fails the build', () => withFixture(root => { + const { routingEntries } = require('../../scripts/lib/context-selection'); + write(root, 'skills/shared/SKILL.md', '---\nname: shared\ndescription: Shared helper\ndisable-model-invocation: true\n---\nShared'); + assert.equal(routingEntries({ repoRoot: root, target: 'claude' }).entries.some(entry => entry.id === 'skill:shared'), false); + const file = path.join(root, 'skills/feature/SKILL.md'); + fs.writeFileSync(file, Buffer.concat([fs.readFileSync(file), Buffer.from([0xff, 0x0a])])); + assert.throws(() => routingEntries({ repoRoot: root, target: 'claude' }), /UTF-8/); +})); + +test('admission denials carry stable codes', () => withFixture(root => { + write(root, 'skills/shared/SKILL.md', '---\nname: shared\ndescription: Shared helper\ndisable-model-invocation: true\n---\nShared'); + assert.throws(() => resolve(root, { query: 'shared', proposedIds: ['skill:shared'] }, { load: true }), + error => error.code === 'ECC_CONTEXT_MANUAL_ONLY'); +}));