From 6658fc760afc9bba141486d115c388618804d76e Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Mon, 25 May 2026 20:32:30 -0700 Subject: [PATCH 1/7] chore: open plugin surface integration branch From 56945bd9d2f32240817f8bd6e0eede7e0f9f6cc2 Mon Sep 17 00:00:00 2001 From: Phil Cunliffe Date: Mon, 25 May 2026 20:54:34 -0700 Subject: [PATCH 2/7] feat: config-backed sink materialization (hy-9d39h) (#37) * feat: config-backed sink materialization (hy-9d39h) Add production runtime materialization for configured sinks, so daemon boot and CLI dispatch automatically instantiate sinks from config.sinks entries without manual kernel.sinks.instantiate() calls. - Add `fromProvider(provider, name, range)` to capability registry for provider-specific capability lookup - New `src/core/sinks/materialize.js` resolves writer/destination/encoder capabilities and calls sinks.instantiate() for all three sink shapes (request, blob, table-format) - Wire materializeSinks into daemon runtime (after plugin activation) and CLI dispatch (after boot) - Update local_parquet_export smoke to use config-backed sinks instead of manual instantiation, proving the production path works - 19 new tests covering all sink shapes, error modes, and fromProvider Co-Authored-By: Claude Opus 4.7 (1M context) * fix(types): resolve CI typecheck failures for sink materialization (hy-9d39h) - Add tmpRoot to RunDaemonOptions interface - Add fromProvider to CapabilityRegistry interface and activation facade - Capture config.sinks before withSpan callback to preserve TS narrowing Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- collectivus-plugin-kernel-types.d.ts | 1 + .../smoke/flows/local_parquet_export.js | 97 ++-- src/core/cli/dispatch.js | 6 + src/core/daemon/runtime.js | 17 + src/core/daemon/types.d.ts | 2 + src/core/registry/capabilities.d.ts | 6 + src/core/registry/capabilities.js | 19 + src/core/runtime/activation.js | 1 + src/core/sinks/materialize.js | 324 +++++++++++ test/core/sink-materialize.test.js | 535 ++++++++++++++++++ 10 files changed, 949 insertions(+), 59 deletions(-) create mode 100644 src/core/sinks/materialize.js create mode 100644 test/core/sink-materialize.test.js diff --git a/collectivus-plugin-kernel-types.d.ts b/collectivus-plugin-kernel-types.d.ts index 5b804d7..851ec71 100644 --- a/collectivus-plugin-kernel-types.d.ts +++ b/collectivus-plugin-kernel-types.d.ts @@ -318,6 +318,7 @@ export interface CapabilityRegistry { require(requester: PluginName, name: CapabilityName, range?: SemverRange): T has(name: CapabilityName, range?: SemverRange): boolean list(): CapabilityRegistration[] + fromProvider(provider: PluginName | 'core', name: CapabilityName, range?: SemverRange): T | undefined } export interface CapabilityRegistration { diff --git a/hypaware-core/smoke/flows/local_parquet_export.js b/hypaware-core/smoke/flows/local_parquet_export.js index 40f6e8d..5db011c 100644 --- a/hypaware-core/smoke/flows/local_parquet_export.js +++ b/hypaware-core/smoke/flows/local_parquet_export.js @@ -7,17 +7,12 @@ import { fileURLToPath } from 'node:url' import { parquetReadObjects } from 'hyparquet' -import { Attr, installObservability } from '../../../src/core/observability/index.js' +import { installObservability } from '../../../src/core/observability/index.js' import { defaultConfigPath } from '../../../src/core/config/schema.js' import { runDaemon } from '../../../src/core/daemon/runtime.js' import { dispatch } from '../../../src/core/cli/dispatch.js' -/** - * @import { ActivePlugin, SinkEncoder } from '../../../collectivus-plugin-kernel-types.d.ts' - */ - const HERE = path.dirname(fileURLToPath(import.meta.url)) -const PLUGINS_WORKSPACE = path.resolve(HERE, '..', '..', 'plugins-workspace') /** * Phase 7 smoke — Parquet export through the `@hypaware/local-fs` + @@ -64,7 +59,18 @@ export async function run({ harness, expect }) { ) } - // ----- Stage config: otel + local-fs + format-parquet ----- + // ----- Stage config: otel + local-fs + format-parquet with + // config-backed sinks ----- + const goodDir = path.join(harness.tmpDir, 'sink-good') + const brokenDir = path.join(harness.tmpDir, 'sink-broken') + await fs.mkdir(goodDir, { recursive: true }) + await fs.mkdir(brokenDir, { recursive: true }) + // Stage the failure up front (before the daemon's auto-tick loop + // can race past it): writeBlob will try to mkdir + // `/logs/partition=all` recursively, which hits ENOTDIR + // because `/logs` is already a regular file. + await fs.writeFile(path.join(brokenDir, 'logs'), 'not-a-directory') + const configPath = defaultConfigPath(harness.hypHome) await fs.mkdir(path.dirname(configPath), { recursive: true }) await fs.writeFile(configPath, JSON.stringify({ @@ -74,22 +80,24 @@ export async function run({ harness, expect }) { { name: '@hypaware/format-parquet' }, { name: '@hypaware/local-fs' }, ], + sinks: { + good: { + writer: '@hypaware/format-parquet', + destination: '@hypaware/local-fs', + config: { schedule: '* * * * *', dir: goodDir }, + }, + broken: { + writer: '@hypaware/format-parquet', + destination: '@hypaware/local-fs', + config: { schedule: '* * * * *', dir: brokenDir }, + }, + }, query: { cache: { retention: { default_days: 30 } } }, }, null, 2)) process.env.HYP_HOME = harness.hypHome process.env.HYP_CONFIG = configPath - const goodDir = path.join(harness.tmpDir, 'sink-good') - const brokenDir = path.join(harness.tmpDir, 'sink-broken') - await fs.mkdir(goodDir, { recursive: true }) - await fs.mkdir(brokenDir, { recursive: true }) - // Stage the failure up front (before the daemon's auto-tick loop - // can race past it): writeBlob will try to mkdir - // `/logs/partition=all` recursively, which hits ENOTDIR - // because `/logs` is already a regular file. - await fs.writeFile(path.join(brokenDir, 'logs'), 'not-a-directory') - // ----- Boot the daemon (otel listener auto-starts; local-fs's sink // contribution registers; format-parquet's encoder capability // lands in the registry) ----- @@ -116,53 +124,28 @@ export async function run({ harness, expect }) { buildLogsPayload(harness.devRunId), ) - // ----- Wire two sink instances on the daemon's kernel ----- + // ----- Verify sinks were materialized from config ----- const kernel = handle.runtime - const encoder = /** @type {SinkEncoder} */ ( - kernel.capabilities.require('@hypaware/local-fs', 'hypaware.encoder', '^1.0.0') - ) const contribution = kernel.sinks.getContribution('@hypaware/local-fs', 'local-fs') expect.that( 'sinks: local-fs contributed a local-fs sink under daemon boot', contribution, (v) => v !== undefined, ) - if (!contribution) return - const localFsDir = path.join(PLUGINS_WORKSPACE, 'local-fs') - /** @type {ActivePlugin} */ - const destinationPlugin = { - name: '@hypaware/local-fs', - version: '1.0.0', - manifest: { - schema_version: 1, - name: '@hypaware/local-fs', - version: '1.0.0', - hypaware_api: '^1.0.0', - runtime: 'node', - entrypoint: './src/index.js', - }, - rootDir: localFsDir, - } + const goodHandle = kernel.sinks.get('good') + expect.that( + 'sinks: "good" instance materialized from config (not manual instantiate)', + goodHandle, + (v) => v !== undefined && v.kind === 'blob' && v.writer === '@hypaware/format-parquet', + ) - for (const instance of ['good', 'broken']) { - await kernel.sinks.instantiate({ - kind: 'blob', - instanceName: instance, - destination: contribution, - writerPlugin: '@hypaware/format-parquet', - encoder, - config: { schedule: '* * * * *', dir: instance === 'good' ? goodDir : brokenDir }, - plugin: destinationPlugin, - paths: { - rootDir: localFsDir, - stateDir: path.join(harness.stateDir, 'plugins', '@hypaware/local-fs', instance), - cacheDir: path.join(harness.stateDir, 'cache', 'plugins', '@hypaware/local-fs', instance), - tempDir: path.join(harness.tmpDir, 'plugin-temp', instance), - }, - log: makeNoopLogger(), - }) - } + const brokenHandle = kernel.sinks.get('broken') + expect.that( + 'sinks: "broken" instance materialized from config', + brokenHandle, + (v) => v !== undefined && v.kind === 'blob', + ) // ----- Drive the forced tick through the CLI (`hyp sink force`) ----- const forceStdout = makeBuf() @@ -381,10 +364,6 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } -function makeNoopLogger() { - return { debug() {}, info() {}, warn() {}, error() {} } -} - function makeBuf() { /** @type {string[]} */ const chunks = [] diff --git a/src/core/cli/dispatch.js b/src/core/cli/dispatch.js index d9c2997..1ee8207 100644 --- a/src/core/cli/dispatch.js +++ b/src/core/cli/dispatch.js @@ -20,6 +20,7 @@ import { createKernelRuntime } from '../runtime/activation.js' import { bootKernel } from '../runtime/boot.js' import { readObservabilityEnv } from '../observability/env.js' import { registerCoreCommands } from './core_commands.js' +import { materializeSinks } from '../sinks/materialize.js' /** * @import { ActivePlugin, CommandRegistration, CommandRegistry, CommandRunContext, HypAwareV2Config } from '../../../collectivus-plugin-kernel-types.d.ts' @@ -114,6 +115,11 @@ export async function dispatch(argv, opts = {}) { kernel = boot.runtime activePlugins = boot.activePlugins if (boot.config) activeConfig = boot.config + + await materializeSinks(kernel, boot.config, { + stateRoot: path.join(obsEnv.hypHome, 'hypaware'), + runId: env.DEV_RUN_ID ?? `cli-${process.pid}`, + }) } if (argv.length === 0) { diff --git a/src/core/daemon/runtime.js b/src/core/daemon/runtime.js index a649264..05b64d3 100644 --- a/src/core/daemon/runtime.js +++ b/src/core/daemon/runtime.js @@ -14,6 +14,7 @@ import { readObservabilityEnv } from '../observability/env.js' import { loadConfigFile } from '../config/schema.js' import { bootKernel } from '../runtime/boot.js' import { createSinkDriver } from '../sinks/driver.js' +import { materializeSinks } from '../sinks/materialize.js' import { clearPidFile, pidFilePath, @@ -211,6 +212,22 @@ export async function runDaemon(opts = {}) { status.healthyAt = new Date(healthyAtMs).toISOString() } + // ----- Materialize config-backed sinks ----- + const sinkResult = await materializeSinks(boot.runtime, boot.config, { + stateRoot, + runId, + tmpRoot: opts.tmpRoot, + }) + if (sinkResult.errors.length > 0) { + for (const e of sinkResult.errors) { + fileLog.error('daemon.sink_materialize_failed', { + instance: e.instance, + error_kind: e.errorKind, + message: e.message, + }) + } + } + // ----- Sink driver ----- const driver = createSinkDriver({ sinkRegistry: boot.runtime.sinks, diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index 8d0c3d7..8320084 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -368,6 +368,8 @@ export interface RunDaemonOptions { installSignalHandlers?: boolean /** Phase 3 only supports foreground; surfaced for symmetry with `--foreground`. */ foreground?: boolean + /** Temp directory root for sink materialization scratch files. */ + tmpRoot?: string } export interface DaemonLogger { diff --git a/src/core/registry/capabilities.d.ts b/src/core/registry/capabilities.d.ts index cd7337f..0e7a74d 100644 --- a/src/core/registry/capabilities.d.ts +++ b/src/core/registry/capabilities.d.ts @@ -8,6 +8,12 @@ export type { CapabilityRegistry, CapabilityRegistration } export interface CapabilityRegistryHandle extends CapabilityRegistry { /** Internal-only inspector used by dep_graph and tests. */ _registrations(): Array + /** + * Resolve a capability from a specific provider plugin. Returns the + * value if the named provider registered the capability within the + * semver range, or `undefined` otherwise. + */ + fromProvider(provider: string, name: string, range?: string): T | undefined } /** diff --git a/src/core/registry/capabilities.js b/src/core/registry/capabilities.js index 8e4472d..71073db 100644 --- a/src/core/registry/capabilities.js +++ b/src/core/registry/capabilities.js @@ -73,6 +73,24 @@ export function createCapabilityRegistry() { return /** @type {T} */ (chosen.value) } + /** + * Resolve a capability from a specific provider plugin. Returns the + * value if the named provider registered the capability within the + * semver range, or `undefined` otherwise. + * + * @template T + * @param {string} provider + * @param {string} name + * @param {string} [range] + * @returns {T | undefined} + */ + function fromProvider(provider, name, range) { + const match = registrations.find( + (r) => r.provider === provider && r.name === name && matchesSemverRange(r.version, range) + ) + return match ? /** @type {T} */ (match.value) : undefined + } + /** * @param {string} name * @param {string} [range] @@ -99,6 +117,7 @@ export function createCapabilityRegistry() { require: requireCapability, has, list, + fromProvider, _registrations, } } diff --git a/src/core/runtime/activation.js b/src/core/runtime/activation.js index b85cc43..1a05bcb 100644 --- a/src/core/runtime/activation.js +++ b/src/core/runtime/activation.js @@ -197,6 +197,7 @@ function createCapabilitiesFacade(pluginName, registry) { }, has(name, range) { return registry.has(name, range) }, list() { return registry.list() }, + fromProvider(provider, name, range) { return registry.fromProvider(provider, name, range) }, } } diff --git a/src/core/sinks/materialize.js b/src/core/sinks/materialize.js new file mode 100644 index 0000000..82a3713 --- /dev/null +++ b/src/core/sinks/materialize.js @@ -0,0 +1,324 @@ +// @ts-check + +import { Attr, getLogger, withSpan } from '../observability/index.js' +import { + CAP_BLOB_STORE, + CAP_ENCODER, + CAP_TABLE_FORMAT, +} from '../config/validate.js' + +/** + * @import { + * ActivePlugin, + * BlobSinkConfigInstance, + * BlobStore, + * HypAwareV2Config, + * PluginName, + * RequestSinkConfigInstance, + * SinkEncoder, + * TableFormatProvider, + * } from '../../../collectivus-plugin-kernel-types.d.ts' + * @import { KernelRuntime } from '../runtime/activation.d.ts' + * @import { ExtendedSinkHandle } from '../registry/types.d.ts' + */ + +/** + * @typedef {object} MaterializeResult + * @property {ExtendedSinkHandle[]} handles + * @property {MaterializeError[]} errors + */ + +/** + * @typedef {object} MaterializeError + * @property {string} instance + * @property {string} errorKind + * @property {string} message + */ + +/** + * Materialize every sink instance declared in `config.sinks` by + * resolving capabilities from the activated plugin registries and + * calling `runtime.sinks.instantiate(...)`. + * + * Three sink shapes are supported: + * + * - **request**: `{ plugin }` — resolves that plugin's registered sink + * contribution. + * - **blob**: `{ writer, destination }` where the writer provides + * `hypaware.encoder` — resolves encoder and destination blob-store + * contributions. + * - **table-format**: `{ writer, destination }` where the writer provides + * `hypaware.table-format` — resolves table-format provider, blob-store + * from destination, inner encoder from `config.encoder` pin or default + * `@hypaware/format-parquet`. + * + * Errors are collected, not thrown, so a single misconfigured sink does + * not block the others. Each error is logged with `sink.materialize_failed`. + * + * @param {KernelRuntime} runtime + * @param {HypAwareV2Config | null} config + * @param {{ stateRoot: string, runId: string, tmpRoot?: string }} opts + * @returns {Promise} + */ +export async function materializeSinks(runtime, config, opts) { + const log = getLogger('sinks') + + if (!config?.sinks || Object.keys(config.sinks).length === 0) { + return { handles: [], errors: [] } + } + + const sinks = config.sinks + + return withSpan( + 'sink.materialize', + { + [Attr.COMPONENT]: 'sinks', + [Attr.OPERATION]: 'sink.materialize', + sink_count: Object.keys(sinks).length, + status: 'ok', + }, + async (span) => { + /** @type {ExtendedSinkHandle[]} */ + const handles = [] + /** @type {MaterializeError[]} */ + const errors = [] + + for (const [instanceName, raw] of Object.entries(sinks)) { + try { + const handle = await materializeOne( + runtime, instanceName, raw, config, opts, log + ) + handles.push(handle) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const errorKind = (err && typeof err === 'object' && 'errorKind' in err) + ? String(/** @type {any} */ (err).errorKind) + : 'sink_materialize_failed' + errors.push({ instance: instanceName, errorKind, message }) + log.error('sink.materialize_failed', { + [Attr.SINK_INSTANCE]: instanceName, + [Attr.ERROR_KIND]: errorKind, + message, + }) + } + } + + if (errors.length > 0) { + span.setAttribute('status', 'degraded') + span.setAttribute('sink_errors', errors.length) + } + span.setAttribute('sinks_materialized', handles.length) + + return { handles, errors } + }, + { component: 'sinks' } + ) +} + +/** + * @param {KernelRuntime} runtime + * @param {string} instanceName + * @param {BlobSinkConfigInstance | RequestSinkConfigInstance} raw + * @param {HypAwareV2Config} config + * @param {{ stateRoot: string, runId: string, tmpRoot?: string }} opts + * @param {ReturnType} log + * @returns {Promise} + */ +async function materializeOne(runtime, instanceName, raw, config, opts, log) { + if ('plugin' in raw && !('writer' in raw)) { + return materializeRequest(runtime, instanceName, raw, opts) + } + if ('writer' in raw && 'destination' in raw) { + return materializeBlob(runtime, instanceName, raw, config, opts) + } + throw materializeError( + 'sink_config_invalid', + `sink '${instanceName}' has neither writer/destination nor plugin` + ) +} + +/** + * @param {KernelRuntime} runtime + * @param {string} instanceName + * @param {RequestSinkConfigInstance} raw + * @param {{ stateRoot: string, runId: string, tmpRoot?: string }} opts + * @returns {Promise} + */ +async function materializeRequest(runtime, instanceName, raw, opts) { + const pluginName = /** @type {PluginName} */ (raw.plugin) + + const ctx = runtime.activationContexts.get(pluginName) + if (!ctx) { + throw materializeError( + 'sink_plugin_not_active', + `sink '${instanceName}': plugin '${pluginName}' is not active` + ) + } + + const contributions = runtime.sinks.listContributions() + .filter((c) => c.plugin === pluginName) + if (contributions.length === 0) { + throw materializeError( + 'sink_contribution_missing', + `sink '${instanceName}': plugin '${pluginName}' is active but registered no sink contributions` + ) + } + if (contributions.length > 1) { + throw materializeError( + 'sink_contribution_ambiguous', + `sink '${instanceName}': plugin '${pluginName}' registered ${contributions.length} sink contributions — cannot select unambiguously` + ) + } + + const { contribution } = contributions[0] + return runtime.sinks.instantiate({ + kind: 'request', + instanceName, + contribution, + config: raw.config ?? {}, + plugin: ctx.plugin, + paths: ctx.paths, + log: ctx.log, + }) +} + +/** + * @param {KernelRuntime} runtime + * @param {string} instanceName + * @param {BlobSinkConfigInstance} raw + * @param {HypAwareV2Config} config + * @param {{ stateRoot: string, runId: string, tmpRoot?: string }} opts + * @returns {Promise} + */ +async function materializeBlob(runtime, instanceName, raw, config, opts) { + const writerName = /** @type {PluginName} */ (raw.writer) + const destName = /** @type {PluginName} */ (raw.destination) + + const writerCtx = runtime.activationContexts.get(writerName) + if (!writerCtx) { + throw materializeError( + 'sink_plugin_not_active', + `sink '${instanceName}': writer plugin '${writerName}' is not active` + ) + } + + const destCtx = runtime.activationContexts.get(destName) + if (!destCtx) { + throw materializeError( + 'sink_plugin_not_active', + `sink '${instanceName}': destination plugin '${destName}' is not active` + ) + } + + const tableFormat = /** @type {TableFormatProvider | undefined} */ ( + runtime.capabilities.fromProvider(writerName, CAP_TABLE_FORMAT) + ) + + if (tableFormat) { + return materializeTableFormat( + runtime, instanceName, raw, config, opts, writerName, destName, + writerCtx, destCtx, tableFormat + ) + } + + const encoder = /** @type {SinkEncoder | undefined} */ ( + runtime.capabilities.fromProvider(writerName, CAP_ENCODER) + ) + if (!encoder) { + throw materializeError( + 'sink_capability_missing', + `sink '${instanceName}': writer '${writerName}' provides neither ${CAP_ENCODER} nor ${CAP_TABLE_FORMAT}` + ) + } + + const contributions = runtime.sinks.listContributions() + .filter((c) => c.plugin === destName) + if (contributions.length === 0) { + throw materializeError( + 'sink_contribution_missing', + `sink '${instanceName}': destination '${destName}' registered no sink contributions` + ) + } + + const { contribution } = contributions[0] + return runtime.sinks.instantiate({ + kind: 'blob', + instanceName, + destination: contribution, + writerPlugin: writerName, + encoder, + config: raw.config ?? {}, + plugin: destCtx.plugin, + paths: destCtx.paths, + log: destCtx.log, + }) +} + +/** + * @param {KernelRuntime} runtime + * @param {string} instanceName + * @param {BlobSinkConfigInstance} raw + * @param {HypAwareV2Config} config + * @param {{ stateRoot: string, runId: string, tmpRoot?: string }} opts + * @param {PluginName} writerName + * @param {PluginName} destName + * @param {import('../../../collectivus-plugin-kernel-types.d.ts').PluginActivationContext} writerCtx + * @param {import('../../../collectivus-plugin-kernel-types.d.ts').PluginActivationContext} destCtx + * @param {TableFormatProvider} tableFormat + * @returns {Promise} + */ +async function materializeTableFormat( + runtime, instanceName, raw, config, opts, + writerName, destName, writerCtx, destCtx, tableFormat +) { + const blobStore = /** @type {BlobStore | undefined} */ ( + runtime.capabilities.fromProvider(destName, CAP_BLOB_STORE) + ) + if (!blobStore) { + throw materializeError( + 'sink_capability_missing', + `sink '${instanceName}': destination '${destName}' does not provide ${CAP_BLOB_STORE}` + ) + } + + const encoderPin = typeof raw.config?.encoder === 'string' + ? /** @type {PluginName} */ (raw.config.encoder) + : /** @type {PluginName} */ ('@hypaware/format-parquet') + + const encoder = /** @type {SinkEncoder | undefined} */ ( + runtime.capabilities.fromProvider(encoderPin, CAP_ENCODER) + ) + if (!encoder) { + throw materializeError( + 'sink_capability_missing', + `sink '${instanceName}': inner encoder '${encoderPin}' does not provide ${CAP_ENCODER} (is it active?)` + ) + } + + return runtime.sinks.instantiate({ + kind: 'table-format', + instanceName, + tableFormat, + writerPlugin: writerName, + destinationPlugin: destName, + blobStore, + encoder, + config: raw.config ?? {}, + plugin: writerCtx.plugin, + paths: writerCtx.paths, + log: writerCtx.log, + query: runtime.query, + storage: runtime.storage, + }) +} + +/** + * @param {string} errorKind + * @param {string} message + * @returns {Error & { errorKind: string }} + */ +function materializeError(errorKind, message) { + const err = /** @type {Error & { errorKind: string }} */ (new Error(message)) + err.errorKind = errorKind + return err +} diff --git a/test/core/sink-materialize.test.js b/test/core/sink-materialize.test.js new file mode 100644 index 0000000..db1cff2 --- /dev/null +++ b/test/core/sink-materialize.test.js @@ -0,0 +1,535 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import { Readable } from 'node:stream' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs/promises' + +import { createCapabilityRegistry } from '../../src/core/registry/capabilities.js' +import { createSinkRegistry } from '../../src/core/registry/sinks.js' +import { createQueryRegistry } from '../../src/core/registry/datasets.js' +import { createSourceRegistry } from '../../src/core/registry/sources.js' +import { createCommandRegistry } from '../../src/core/registry/commands.js' +import { createConfigRegistry } from '../../src/core/config/schema.js' +import { createQueryStorageService } from '../../src/core/cache/storage.js' +import { materializeSinks } from '../../src/core/sinks/materialize.js' + +/** + * @import { + * ActivePlugin, + * BlobStore, + * PluginActivationContext, + * PluginLogger, + * PluginPaths, + * Sink, + * SinkEncoder, + * TableFormatProvider, + * } from '../../collectivus-plugin-kernel-types.d.ts' + * @import { KernelRuntime } from '../../src/core/runtime/activation.d.ts' + */ + +function makeNoopLogger() { + return /** @type {PluginLogger} */ ({ + debug() {}, info() {}, warn() {}, error() {}, + }) +} + +/** + * @param {string} name + * @returns {ActivePlugin} + */ +function makePlugin(name) { + return { + name, version: '1.0.0', + manifest: { + schema_version: 1, name, version: '1.0.0', + hypaware_api: '^1.0.0', runtime: 'node', entrypoint: './src/index.js', + }, + rootDir: '/fake', + } +} + +/** @returns {PluginPaths} */ +function makePaths() { + return { rootDir: '/fake', stateDir: '/fake/state', cacheDir: '/fake/cache', tempDir: '/fake/tmp' } +} + +/** @returns {SinkEncoder} */ +function makeEncoder() { + return { + format: 'parquet', extension: 'parquet', supports: ['queryable'], + async encodePartition(partition) { + const bytes = new TextEncoder().encode(`${partition.dataset}-bytes`) + return { filename: `${partition.dataset}.parquet`, bytes, bytesWritten: bytes.byteLength, rowCount: 1 } + }, + } +} + +/** @returns {BlobStore} */ +function makeBlobStore() { + /** @type {Map} */ + const objects = new Map() + return { + kind: 'memory', + async putObject(input) { + const bytes = input.body instanceof Uint8Array ? input.body : new Uint8Array() + objects.set(input.key, bytes) + return { key: input.key } + }, + async getObject(input) { + const bytes = objects.get(input.key) + if (!bytes) return null + return { body: Readable.from([bytes]), contentLength: bytes.byteLength } + }, + listObjects() { return { async *[Symbol.asyncIterator]() {} } }, + async deleteObject() {}, + } +} + +/** @returns {TableFormatProvider} */ +function makeTableFormatProvider() { + return { + format: 'iceberg', supports: ['queryable'], + async createSink(ctx) { + return /** @type {Sink} */ ({ + async exportBatch() { return { status: 'exported', partitionsExported: 0, bytesWritten: 0 } }, + async close() {}, + }) + }, + } +} + +/** @returns {Sink} */ +function makeSink() { + return { + async exportBatch() { return { status: 'exported', partitionsExported: 0, bytesWritten: 0 } }, + async close() {}, + } +} + +/** + * @param {Partial} [overrides] + * @returns {KernelRuntime} + */ +function makeRuntime(overrides = {}) { + const tmpDir = path.join(os.tmpdir(), `hyp-test-materialize-${Date.now()}`) + const cacheRoot = path.join(tmpDir, 'cache') + return /** @type {KernelRuntime} */ ({ + capabilities: overrides.capabilities ?? createCapabilityRegistry(), + commands: createCommandRegistry(), + configRegistry: createConfigRegistry(), + sources: createSourceRegistry(), + sinks: overrides.sinks ?? createSinkRegistry(), + query: createQueryRegistry(), + storage: createQueryStorageService({ cacheRoot }), + cacheRoot, + skills: { register() {}, list() { return [] } }, + initPresets: { register() {}, get() { return undefined }, list() { return [] } }, + activationContexts: overrides.activationContexts ?? new Map(), + }) +} + +/** + * @param {string} pluginName + * @param {KernelRuntime} runtime + * @returns {PluginActivationContext} + */ +function registerActivationContext(pluginName, runtime) { + const plugin = makePlugin(pluginName) + const ctx = /** @type {PluginActivationContext} */ ({ + plugin, + config: {}, + env: process.env, + paths: makePaths(), + log: makeNoopLogger(), + permissions: { has() { return false }, require() {}, request() { return Promise.resolve(false) } }, + capabilities: runtime.capabilities, + commands: runtime.commands, + configRegistry: runtime.configRegistry, + sources: runtime.sources, + sinks: runtime.sinks, + query: runtime.query, + storage: runtime.storage, + skills: runtime.skills, + initPresets: runtime.initPresets, + requireCapability(name) { return runtime.capabilities.require(pluginName, name) }, + provideCapability(name, version, value) { runtime.capabilities.provide(pluginName, name, version, value) }, + }) + runtime.activationContexts.set(pluginName, ctx) + return ctx +} + + +// ----- fromProvider capability tests ----- + +test('fromProvider returns the capability value from the specified provider', () => { + const registry = createCapabilityRegistry() + registry.provide('plugin-a', 'hypaware.encoder', '1.0.0', { format: 'a' }) + registry.provide('plugin-b', 'hypaware.encoder', '1.0.0', { format: 'b' }) + + const a = registry.fromProvider('plugin-a', 'hypaware.encoder') + const b = registry.fromProvider('plugin-b', 'hypaware.encoder') + + assert.deepStrictEqual(a, { format: 'a' }) + assert.deepStrictEqual(b, { format: 'b' }) +}) + +test('fromProvider returns undefined when the provider has not registered the capability', () => { + const registry = createCapabilityRegistry() + registry.provide('plugin-a', 'hypaware.encoder', '1.0.0', { format: 'a' }) + + const result = registry.fromProvider('plugin-b', 'hypaware.encoder') + assert.strictEqual(result, undefined) +}) + +test('fromProvider respects semver range', () => { + const registry = createCapabilityRegistry() + registry.provide('plugin-a', 'hypaware.encoder', '2.0.0', { format: 'v2' }) + + assert.strictEqual(registry.fromProvider('plugin-a', 'hypaware.encoder', '^1.0.0'), undefined) + assert.deepStrictEqual(registry.fromProvider('plugin-a', 'hypaware.encoder', '^2.0.0'), { format: 'v2' }) +}) + + +// ----- materializeSinks tests ----- + +test('materializeSinks returns empty when config has no sinks', async () => { + const runtime = makeRuntime() + const result = await materializeSinks(runtime, { version: 2 }, { stateRoot: '/tmp', runId: 'test' }) + assert.deepStrictEqual(result, { handles: [], errors: [] }) +}) + +test('materializeSinks returns empty when config is null', async () => { + const runtime = makeRuntime() + const result = await materializeSinks(runtime, null, { stateRoot: '/tmp', runId: 'test' }) + assert.deepStrictEqual(result, { handles: [], errors: [] }) +}) + +test('materializeSinks materializes a request sink from a plugin with one contribution', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/central', runtime) + + runtime.sinks.register({ + name: 'central-http', + plugin: '@hypaware/central', + supports: [], + create: async () => makeSink(), + }) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'my-central': { plugin: '@hypaware/central', config: { schedule: '* * * * *' } }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 0) + assert.strictEqual(result.handles.length, 1) + assert.strictEqual(result.handles[0].instanceName, 'my-central') + assert.strictEqual(result.handles[0].kind, 'request') +}) + +test('materializeSinks errors when request sink plugin is not active', async () => { + const runtime = makeRuntime() + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'my-central': { plugin: '@hypaware/central' }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].instance, 'my-central') + assert.strictEqual(result.errors[0].errorKind, 'sink_plugin_not_active') +}) + +test('materializeSinks errors when request sink plugin has no contributions', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/central', runtime) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'my-central': { plugin: '@hypaware/central' }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].errorKind, 'sink_contribution_missing') +}) + +test('materializeSinks errors when request sink plugin has multiple contributions', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/central', runtime) + + runtime.sinks.register({ + name: 'http-a', + plugin: '@hypaware/central', + supports: [], + create: async () => makeSink(), + }) + runtime.sinks.register({ + name: 'http-b', + plugin: '@hypaware/central', + supports: [], + create: async () => makeSink(), + }) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'my-central': { plugin: '@hypaware/central' }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].errorKind, 'sink_contribution_ambiguous') +}) + +test('materializeSinks materializes a blob sink (encoder writer + destination)', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/format-parquet', runtime) + registerActivationContext('@hypaware/local-fs', runtime) + + runtime.capabilities.provide('@hypaware/format-parquet', 'hypaware.encoder', '1.0.0', makeEncoder()) + runtime.capabilities.provide('@hypaware/local-fs', 'hypaware.blob-store', '1.0.0', makeBlobStore()) + runtime.sinks.register({ + name: 'local-fs', + plugin: '@hypaware/local-fs', + supports: ['queryable'], + create: async () => makeSink(), + }) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'local-parquet': { + writer: '@hypaware/format-parquet', + destination: '@hypaware/local-fs', + config: { schedule: '* * * * *' }, + }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 0) + assert.strictEqual(result.handles.length, 1) + assert.strictEqual(result.handles[0].instanceName, 'local-parquet') + assert.strictEqual(result.handles[0].kind, 'blob') + assert.strictEqual(result.handles[0].writer, '@hypaware/format-parquet') + assert.strictEqual(result.handles[0].destination, '@hypaware/local-fs') +}) + +test('materializeSinks materializes a table-format sink', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/format-iceberg', runtime) + registerActivationContext('@hypaware/format-parquet', runtime) + registerActivationContext('@hypaware/local-fs', runtime) + + runtime.capabilities.provide('@hypaware/format-iceberg', 'hypaware.table-format', '1.0.0', makeTableFormatProvider()) + runtime.capabilities.provide('@hypaware/local-fs', 'hypaware.blob-store', '1.0.0', makeBlobStore()) + runtime.capabilities.provide('@hypaware/format-parquet', 'hypaware.encoder', '1.0.0', makeEncoder()) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'iceberg-local': { + writer: '@hypaware/format-iceberg', + destination: '@hypaware/local-fs', + config: { schedule: '0 * * * *' }, + }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 0) + assert.strictEqual(result.handles.length, 1) + assert.strictEqual(result.handles[0].instanceName, 'iceberg-local') + assert.strictEqual(result.handles[0].kind, 'table-format') + assert.strictEqual(result.handles[0].writer, '@hypaware/format-iceberg') + assert.strictEqual(result.handles[0].destination, '@hypaware/local-fs') +}) + +test('materializeSinks table-format sink uses config.encoder pin', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/format-iceberg', runtime) + registerActivationContext('@hypaware/format-jsonl', runtime) + registerActivationContext('@hypaware/local-fs', runtime) + + runtime.capabilities.provide('@hypaware/format-iceberg', 'hypaware.table-format', '1.0.0', makeTableFormatProvider()) + runtime.capabilities.provide('@hypaware/local-fs', 'hypaware.blob-store', '1.0.0', makeBlobStore()) + const jsonlEncoder = { ...makeEncoder(), format: 'jsonl', extension: 'jsonl' } + runtime.capabilities.provide('@hypaware/format-jsonl', 'hypaware.encoder', '1.0.0', jsonlEncoder) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'iceberg-jsonl': { + writer: '@hypaware/format-iceberg', + destination: '@hypaware/local-fs', + config: { encoder: '@hypaware/format-jsonl', schedule: '0 * * * *' }, + }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 0) + assert.strictEqual(result.handles[0].encoder?.format, 'jsonl') +}) + +test('materializeSinks errors when writer plugin is not active', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/local-fs', runtime) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'broken': { + writer: '@hypaware/format-parquet', + destination: '@hypaware/local-fs', + }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].errorKind, 'sink_plugin_not_active') + assert.ok(result.errors[0].message.includes('writer')) +}) + +test('materializeSinks errors when destination plugin is not active', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/format-parquet', runtime) + runtime.capabilities.provide('@hypaware/format-parquet', 'hypaware.encoder', '1.0.0', makeEncoder()) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'broken': { + writer: '@hypaware/format-parquet', + destination: '@hypaware/local-fs', + }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].errorKind, 'sink_plugin_not_active') + assert.ok(result.errors[0].message.includes('destination')) +}) + +test('materializeSinks errors when writer provides neither encoder nor table-format', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/format-parquet', runtime) + registerActivationContext('@hypaware/local-fs', runtime) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'broken': { + writer: '@hypaware/format-parquet', + destination: '@hypaware/local-fs', + }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].errorKind, 'sink_capability_missing') +}) + +test('materializeSinks errors when table-format destination has no blob-store', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/format-iceberg', runtime) + registerActivationContext('@hypaware/local-fs', runtime) + registerActivationContext('@hypaware/format-parquet', runtime) + + runtime.capabilities.provide('@hypaware/format-iceberg', 'hypaware.table-format', '1.0.0', makeTableFormatProvider()) + runtime.capabilities.provide('@hypaware/format-parquet', 'hypaware.encoder', '1.0.0', makeEncoder()) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'broken': { + writer: '@hypaware/format-iceberg', + destination: '@hypaware/local-fs', + }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].errorKind, 'sink_capability_missing') + assert.ok(result.errors[0].message.includes('blob-store')) +}) + +test('materializeSinks errors when table-format encoder pin is not active', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/format-iceberg', runtime) + registerActivationContext('@hypaware/local-fs', runtime) + + runtime.capabilities.provide('@hypaware/format-iceberg', 'hypaware.table-format', '1.0.0', makeTableFormatProvider()) + runtime.capabilities.provide('@hypaware/local-fs', 'hypaware.blob-store', '1.0.0', makeBlobStore()) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'broken': { + writer: '@hypaware/format-iceberg', + destination: '@hypaware/local-fs', + config: { encoder: '@hypaware/format-jsonl' }, + }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].errorKind, 'sink_capability_missing') + assert.ok(result.errors[0].message.includes('format-jsonl')) +}) + +test('materializeSinks continues past failures and reports all errors', async () => { + const runtime = makeRuntime() + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'bad-a': { plugin: '@hypaware/central' }, + 'bad-b': { plugin: '@hypaware/webhook' }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 2) + assert.strictEqual(result.handles.length, 0) + assert.strictEqual(result.errors[0].instance, 'bad-a') + assert.strictEqual(result.errors[1].instance, 'bad-b') +}) + +test('materializeSinks destination contribution missing for blob sink', async () => { + const runtime = makeRuntime() + registerActivationContext('@hypaware/format-parquet', runtime) + registerActivationContext('@hypaware/local-fs', runtime) + runtime.capabilities.provide('@hypaware/format-parquet', 'hypaware.encoder', '1.0.0', makeEncoder()) + + const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + version: 2, + sinks: { + 'no-contrib': { + writer: '@hypaware/format-parquet', + destination: '@hypaware/local-fs', + }, + }, + }) + + const result = await materializeSinks(runtime, config, { stateRoot: '/tmp', runId: 'test' }) + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].errorKind, 'sink_contribution_missing') +}) From be35ca7fa0ea0df5daad7c0053e9ddd189db4d20 Mon Sep 17 00:00:00 2001 From: Phil Cunliffe Date: Mon, 25 May 2026 21:10:16 -0700 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20plugin-owned=20product=20logic=20?= =?UTF-8?q?=E2=80=94=20client=20descriptors,=20generalized=20skills,=20hoo?= =?UTF-8?q?k=20migration=20(hy-bv310)=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move client-specific and plugin-specific product behavior out of core and behind plugin-owned manifest descriptors: - Add `contributes.client` descriptor to plugin manifests with skill_dir, attach_probe, and required_upstreams fields - Extend plugin catalog to extract client descriptors from manifests - Replace hardcoded client loops in status.js with catalog-driven descriptor iteration for attach probing - Generalize `hyp skills install` to resolve skill directories from catalog instead of hardcoding .claude/skills and .codex/skills - Move `claude-hook session-context` command from core to the Claude plugin — registered during plugin activation - Rewrite V1 advisory diagnostics to derive client/upstream checks and encoder/blob-store checks from catalog metadata instead of hardcoded plugin name sets Co-authored-by: Claude Opus 4.7 (1M context) --- collectivus-plugin-kernel-types.d.ts | 15 ++ .../claude/hypaware.plugin.json | 10 + .../claude/src/hook_command.js | 162 +++++++++++++ .../plugins-workspace/claude/src/index.js | 9 + .../codex/hypaware.plugin.json | 10 + src/core/cli/core_commands.js | 213 +++--------------- src/core/cli/walkthrough.js | 25 +- src/core/config/validate.js | 129 ++++++----- src/core/daemon/status.js | 123 +++++----- src/core/plugin_catalog.js | 33 ++- test/core/command-dispatch.test.js | 27 +++ test/core/config.test.js | 86 +++++-- .../claude-session-context-hook.test.js | 15 ++ 13 files changed, 527 insertions(+), 330 deletions(-) create mode 100644 hypaware-core/plugins-workspace/claude/src/hook_command.js diff --git a/collectivus-plugin-kernel-types.d.ts b/collectivus-plugin-kernel-types.d.ts index 851ec71..e51274b 100644 --- a/collectivus-plugin-kernel-types.d.ts +++ b/collectivus-plugin-kernel-types.d.ts @@ -124,6 +124,7 @@ export interface PluginProvides { } export interface PluginContributionManifest { + client?: PluginClientManifest commands?: PluginCommandManifest[] config_sections?: PluginConfigSectionManifest[] sources?: PluginSourceManifest[] @@ -133,6 +134,20 @@ export interface PluginContributionManifest { init_presets?: PluginInitPresetManifest[] } +export interface PluginClientManifest { + name: string + skill_dir: string + attach_probe?: PluginAttachProbeManifest + required_upstreams?: string[] +} + +export interface PluginAttachProbeManifest { + format: 'json' | 'toml' + settings_file: string + marker_key?: string + marker_header?: string +} + export interface PluginCommandManifest { name: string summary?: string diff --git a/hypaware-core/plugins-workspace/claude/hypaware.plugin.json b/hypaware-core/plugins-workspace/claude/hypaware.plugin.json index bb07f85..ffaec2e 100644 --- a/hypaware-core/plugins-workspace/claude/hypaware.plugin.json +++ b/hypaware-core/plugins-workspace/claude/hypaware.plugin.json @@ -20,6 +20,16 @@ } }, "contributes": { + "client": { + "name": "claude", + "skill_dir": ".claude/skills", + "attach_probe": { + "format": "json", + "settings_file": ".claude/settings.json", + "marker_key": "_hypaware" + }, + "required_upstreams": ["anthropic"] + }, "skills": [ { "name": "hypaware-query", "clients": ["claude"] }, { "name": "hypaware-ignore", "clients": ["claude"] }, diff --git a/hypaware-core/plugins-workspace/claude/src/hook_command.js b/hypaware-core/plugins-workspace/claude/src/hook_command.js new file mode 100644 index 0000000..564c499 --- /dev/null +++ b/hypaware-core/plugins-workspace/claude/src/hook_command.js @@ -0,0 +1,162 @@ +// @ts-check + +import { execFile } from 'node:child_process' +import path from 'node:path' +import { promisify } from 'node:util' + +import { appendSessionContext } from './session_context.js' + +/** + * @import { CommandRunContext } from '../../../../collectivus-plugin-kernel-types.d.ts' + */ + +const execFileAsync = promisify(execFile) + +/** + * `hyp claude-hook session-context --state-file ` + * + * Plugin-contributed command registered by the `@hypaware/claude` + * adapter during activation. Claude sends hook events on stdin; this + * appends one JSONL record per event to the plugin's session-context + * state file. The Claude exchange projector reads the same file when + * it projects an Anthropic exchange and recovers `cwd` / `git_branch` + * for the row. + * + * Hooks must never interrupt Claude Code. Malformed input, a missing + * `--state-file`, a git lookup failure, or a write error all degrade + * to "no context recorded" with exit 0. + * + * @param {string[]} argv + * @param {CommandRunContext} ctx + */ +export async function runClaudeSessionContextHook(argv, ctx) { + if (argv.includes('--help') || argv.includes('-h')) { + ctx.stdout.write('usage: hyp claude-hook session-context --state-file \n') + return 0 + } + const parsed = parseArgs(argv) + const stateFile = parsed.stateFile ?? (parsed.legacyPort ? legacyStateFile(ctx.env) : undefined) + if (!stateFile) return 0 + + const input = await readStdin(ctx.stdin ?? process.stdin) + /** @type {Record} */ + let event + try { + const parsedEvent = JSON.parse(input || '{}') + event = parsedEvent && typeof parsedEvent === 'object' && !Array.isArray(parsedEvent) + ? /** @type {Record} */ (parsedEvent) + : {} + } catch { + return 0 + } + + const sessionId = str(event.session_id) + const cwd = str(event.new_cwd) ?? str(event.cwd) + if (!sessionId || !cwd) return 0 + const transcriptPath = str(event.transcript_path) + const gitBranch = await currentGitBranch(cwd) + + /** @type {Record} */ + const record = { + session_id: sessionId, + cwd, + ts: new Date().toISOString(), + } + if (transcriptPath) record.transcript_path = transcriptPath + if (gitBranch) record.git_branch = gitBranch + + try { + await appendSessionContext(stateFile, /** @type {any} */ (record)) + } catch { + /* hook MUST never throw back into Claude — exit 0 even on write failure */ + } + return 0 +} + +/** + * @param {string[]} argv + * @returns {{ stateFile?: string, legacyPort?: number }} + */ +function parseArgs(argv) { + /** @type {{ stateFile?: string, legacyPort?: number }} */ + const out = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--state-file' || arg.startsWith('--state-file=')) { + const value = arg === '--state-file' ? argv[++i] : arg.slice('--state-file='.length) + if (typeof value === 'string' && value.length > 0 && path.isAbsolute(value)) { + out.stateFile = value + } + } else if (arg === '--port' || arg.startsWith('--port=')) { + const value = arg === '--port' ? argv[++i] : arg.slice('--port='.length) + const port = typeof value === 'string' ? Number.parseInt(value, 10) : NaN + if (Number.isInteger(port) && port > 0 && port <= 65535) out.legacyPort = port + } + } + return out +} + +/** @param {NodeJS.ProcessEnv} env */ +function legacyStateFile(env) { + const home = env.HOME + const hypHome = env.HYP_HOME || (home ? path.join(home, '.hyp') : undefined) + if (!hypHome) return undefined + return path.join(hypHome, 'hypaware', 'plugins', '@hypaware', 'claude', 'session-context.jsonl') +} + +/** + * @param {NodeJS.ReadStream} stdin + * @returns {Promise} + */ +function readStdin(stdin) { + if (stdin.isTTY) return Promise.resolve('') + stdin.setEncoding('utf8') + return new Promise((resolve) => { + let data = '' + let settled = false + const finish = (/** @type {string} */ value) => { + if (settled) return + settled = true + clearTimeout(timeout) + resolve(value) + } + const timeout = setTimeout(() => finish(data), 1000) + stdin.on('data', (chunk) => { data += chunk }) + stdin.on('end', () => finish(data)) + stdin.on('error', () => finish('')) + }) +} + +/** + * @param {string} cwd + * @returns {Promise} + */ +async function currentGitBranch(cwd) { + try { + const { stdout } = await execFileAsync( + 'git', + ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], + { timeout: 1000 } + ) + const branch = stdout.trim() + if (branch && branch !== 'HEAD') return branch + } catch { + return undefined + } + try { + const { stdout } = await execFileAsync( + 'git', + ['-C', cwd, 'rev-parse', '--short', 'HEAD'], + { timeout: 1000 } + ) + const commit = stdout.trim() + return commit || undefined + } catch { + return undefined + } +} + +/** @param {unknown} value */ +function str(value) { + return typeof value === 'string' && value.length > 0 ? value : undefined +} diff --git a/hypaware-core/plugins-workspace/claude/src/index.js b/hypaware-core/plugins-workspace/claude/src/index.js index f16a08b..ebd7314 100644 --- a/hypaware-core/plugins-workspace/claude/src/index.js +++ b/hypaware-core/plugins-workspace/claude/src/index.js @@ -10,6 +10,7 @@ import { defaultConfigPath } from '../../../../src/core/config/schema.js' import { attach, defaultSettingsPath, detach } from './settings.js' import { anthropicUpstreamPreset, createClaudeExchangeProjector } from './projector.js' import { defaultSessionContextFile } from './session_context.js' +import { runClaudeSessionContextHook } from './hook_command.js' /** * @import { AiGatewayCapability, AiGatewayClientAttachContext, AiGatewayClientDetachContext, CommandRunContext, HypAwareV2Config, PluginActivationContext } from '../../../../collectivus-plugin-kernel-types.d.ts' @@ -217,6 +218,14 @@ export async function activate(ctx) { }, }) + ctx.commands.register({ + name: 'claude-hook session-context', + summary: 'Internal Claude Code hook — appends session context to the state file', + usage: 'hyp claude-hook session-context --state-file ', + hidden: true, + run: runClaudeSessionContextHook, + }) + const skillsRoot = path.resolve(skillsRootDir(), 'skills') for (const skillName of ['hypaware-query', 'hypaware-ignore', 'hypaware-unignore']) { ctx.skills.register({ diff --git a/hypaware-core/plugins-workspace/codex/hypaware.plugin.json b/hypaware-core/plugins-workspace/codex/hypaware.plugin.json index 5fc3313..389e522 100644 --- a/hypaware-core/plugins-workspace/codex/hypaware.plugin.json +++ b/hypaware-core/plugins-workspace/codex/hypaware.plugin.json @@ -19,6 +19,16 @@ } }, "contributes": { + "client": { + "name": "codex", + "skill_dir": ".codex/skills", + "attach_probe": { + "format": "toml", + "settings_file": ".codex/config.toml", + "marker_header": "[model_providers.hypaware]" + }, + "required_upstreams": ["openai", "chatgpt"] + }, "skills": [ { "name": "hypaware-query", "clients": ["codex"] } ] diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index 502d2e5..0a6ad16 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -1,9 +1,7 @@ // @ts-check -import { execFile } from 'node:child_process' import fs from 'node:fs/promises' import path from 'node:path' -import { promisify } from 'node:util' import { Attr, withSpan } from '../observability/index.js' import { readObservabilityEnv } from '../observability/env.js' @@ -30,9 +28,6 @@ import { decideConfirmation, renderConfirmationSummary, } from '../plugin_install/confirm.js' -import { - appendSessionContext, -} from '../../../hypaware-core/plugins-workspace/claude/src/session_context.js' /** * @import { AiGatewayCapability, CommandRegistration, CommandRunContext, HypAwareV2Config } from '../../../collectivus-plugin-kernel-types.d.ts' @@ -44,8 +39,6 @@ import { * @import { CommandRegistryExtended, InitFlags } from './types.d.ts' */ -const execFileAsync = promisify(execFile) - /** * Register the V1 core command set onto the supplied registry. These * commands are NOT plugin contributions — they ship with the kernel @@ -195,13 +188,6 @@ function buildCoreCommands() { usage: 'hyp ignore', run: runIgnore, }, - { - name: 'claude-hook session-context', - summary: 'Internal Claude Code hook compatibility shim', - usage: 'hyp claude-hook session-context --state-file ', - hidden: true, - run: runClaudeSessionContextHook, - }, { name: 'skills install', summary: 'Install registered skills into AI client directories', @@ -2452,163 +2438,6 @@ async function runIgnore(_argv, ctx) { return 0 } -/** - * `hyp claude-hook session-context --state-file ` - * - * Internal command installed into Claude Code's hook list by the - * `@hypaware/claude` adapter. Claude sends hook events on stdin; this - * appends one JSONL record per event to the plugin's session-context - * state file. The Claude exchange projector reads the same file when - * it projects an Anthropic exchange and recovers `cwd` / `git_branch` - * for the row. - * - * Phase 2 removed the HTTP `/_hypaware/session-context` channel — - * sending session context now lives entirely on disk, so the daemon - * does not have to be running for the hook to do its job (the file - * is read at projection time, not at hook-fire time). - * - * Hooks must never interrupt Claude Code. Malformed input, a missing - * `--state-file`, a git lookup failure, or a write error all degrade - * to "no context recorded" with exit 0. - * - * @param {string[]} argv - * @param {CommandRunContext} ctx - */ -async function runClaudeSessionContextHook(argv, ctx) { - if (argv.includes('--help') || argv.includes('-h')) { - ctx.stdout.write('usage: hyp claude-hook session-context --state-file \n') - return 0 - } - const parsed = parseClaudeSessionContextHookArgs(argv) - const stateFile = parsed.stateFile ?? (parsed.legacyPort ? legacyClaudeSessionContextFile(ctx.env) : undefined) - if (!stateFile) return 0 - - const input = await readHookStdin(ctx.stdin ?? process.stdin) - /** @type {Record} */ - let event - try { - const parsedEvent = JSON.parse(input || '{}') - event = parsedEvent && typeof parsedEvent === 'object' && !Array.isArray(parsedEvent) - ? /** @type {Record} */ (parsedEvent) - : {} - } catch { - return 0 - } - - const sessionId = stringValue(event.session_id) - const cwd = stringValue(event.new_cwd) ?? stringValue(event.cwd) - if (!sessionId || !cwd) return 0 - const transcriptPath = stringValue(event.transcript_path) - const gitBranch = await currentGitBranch(cwd) - - /** @type {Record} */ - const record = { - session_id: sessionId, - cwd, - ts: new Date().toISOString(), - } - if (transcriptPath) record.transcript_path = transcriptPath - if (gitBranch) record.git_branch = gitBranch - - try { - await appendSessionContext(stateFile, /** @type {any} */ (record)) - } catch { - /* hook MUST never throw back into Claude — exit 0 even on write failure */ - } - return 0 -} - -/** - * @param {string[]} argv - * @returns {{ stateFile?: string, legacyPort?: number }} - */ -function parseClaudeSessionContextHookArgs(argv) { - /** @type {{ stateFile?: string, legacyPort?: number }} */ - const out = {} - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--state-file' || arg.startsWith('--state-file=')) { - const value = arg === '--state-file' ? argv[++i] : arg.slice('--state-file='.length) - if (typeof value === 'string' && value.length > 0 && path.isAbsolute(value)) { - out.stateFile = value - } - } else if (arg === '--port' || arg.startsWith('--port=')) { - const value = arg === '--port' ? argv[++i] : arg.slice('--port='.length) - const port = typeof value === 'string' ? Number.parseInt(value, 10) : NaN - if (Number.isInteger(port) && port > 0 && port <= 65535) out.legacyPort = port - } - } - return out -} - -/** @param {NodeJS.ProcessEnv} env */ -function legacyClaudeSessionContextFile(env) { - const home = env.HOME - const hypHome = env.HYP_HOME || (home ? path.join(home, '.hyp') : undefined) - if (!hypHome) return undefined - return path.join(hypHome, 'hypaware', 'plugins', '@hypaware', 'claude', 'session-context.jsonl') -} - -/** - * @param {NodeJS.ReadStream} stdin - * @returns {Promise} - */ -function readHookStdin(stdin) { - if (stdin.isTTY) return Promise.resolve('') - stdin.setEncoding('utf8') - return new Promise((resolve) => { - let data = '' - let settled = false - const finish = (value) => { - if (settled) return - settled = true - clearTimeout(timeout) - resolve(value) - } - const timeout = setTimeout(() => finish(data), 1000) - stdin.on('data', (chunk) => { data += chunk }) - stdin.on('end', () => finish(data)) - stdin.on('error', () => finish('')) - }) -} - -/** - * @param {string} cwd - * @returns {Promise} - */ -async function currentGitBranch(cwd) { - try { - const { stdout } = await execFileAsync( - 'git', - ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], - { timeout: 1000 } - ) - const branch = stdout.trim() - if (branch && branch !== 'HEAD') return branch - } catch { - return undefined - } - try { - const { stdout } = await execFileAsync( - 'git', - ['-C', cwd, 'rev-parse', '--short', 'HEAD'], - { timeout: 1000 } - ) - const commit = stdout.trim() - return commit || undefined - } catch { - return undefined - } -} - -/** - * @param {unknown} value - * @returns {string | undefined} - */ -function stringValue(value) { - return typeof value === 'string' && value.length > 0 ? value : undefined -} - /** * `hyp skills install [--client ]` * @@ -2639,11 +2468,18 @@ async function runSkillsInstall(argv, ctx) { return 1 } + const skillDirMap = await buildSkillDirMap() + let count = 0 for (const skill of skills) { for (const targetClient of skill.clients) { if (parsed.client !== 'all' && parsed.client !== targetClient) continue - const dest = path.join(homeDir, clientSkillDir(targetClient), skill.name) + const skillDir = skillDirMap.get(targetClient) + if (!skillDir) { + ctx.stderr.write(`warning: skill '${skill.name}' targets unknown client '${targetClient}'\n`) + continue + } + const dest = path.join(homeDir, skillDir, skill.name) try { await fs.rm(dest, { recursive: true, force: true }) await copyDir(skill.sourceDir, dest) @@ -2659,19 +2495,35 @@ async function runSkillsInstall(argv, ctx) { return 0 } +/** + * Build a map from client name to skill directory by reading plugin + * manifests. This avoids hardcoding `.claude/skills` / `.codex/skills` + * in core. + * + * @returns {Promise>} + */ +async function buildSkillDirMap() { + /** @type {Map} */ + const map = new Map() + try { + const bundled = await discoverBundledPlugins() + const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) + for (const [clientName, descriptor] of catalog.clientDescriptors) { + map.set(clientName, descriptor.skillDir) + } + } catch { /* discovery failure → empty map → warnings per skill */ } + return map +} + /** @param {string[]} argv */ function parseSkillsArgs(argv) { - /** @type {{ client: 'all' | 'claude' | 'codex', error?: string }} */ + /** @type {{ client: string, error?: string }} */ const r = { client: 'all' } for (let i = 0; i < argv.length; i++) { const arg = argv[i] if (arg === '--client' || arg.startsWith('--client=')) { const value = arg === '--client' ? argv[++i] : arg.slice('--client='.length) if (!value) { r.error = '--client requires a name'; return r } - if (value !== 'all' && value !== 'claude' && value !== 'codex') { - r.error = `--client: expected all, claude, or codex (got "${value}")` - return r - } r.client = value continue } @@ -2681,13 +2533,6 @@ function parseSkillsArgs(argv) { return r } -/** @param {'claude'|'codex'|'all'} client */ -function clientSkillDir(client) { - if (client === 'claude') return '.claude/skills' - if (client === 'codex') return '.codex/skills' - throw new Error(`clientSkillDir: '${client}' has no per-client directory`) -} - /** * @param {string} src * @param {string} dest diff --git a/src/core/cli/walkthrough.js b/src/core/cli/walkthrough.js index b769f44..873786e 100644 --- a/src/core/cli/walkthrough.js +++ b/src/core/cli/walkthrough.js @@ -7,6 +7,8 @@ import readline from 'node:readline/promises' import { Attr, getLogger, withSpan } from '../observability/index.js' import { defaultConfigPath } from '../config/schema.js' import { readObservabilityEnv } from '../observability/env.js' +import { discoverBundledPlugins } from '../runtime/bundled.js' +import { buildPluginCatalog } from '../plugin_catalog.js' import { ensureDurableBinForNpx } from './global_install.js' import { multiselect, text, PromptCancelledError } from './tui/index.js' import { shouldUseTui } from './tui-router.js' @@ -965,6 +967,7 @@ async function runPickerFinale(args) { } if (clientsPicked.length > 0 && skills) { + const skillDirMap = await buildWalkthroughSkillDirMap() await withSpan( 'skills.install', { @@ -978,7 +981,9 @@ async function runPickerFinale(args) { for (const skill of skills.list()) { for (const targetClient of skill.clients) { if (!clientsPicked.includes(targetClient)) continue - const dest = path.join(homeDir, clientSkillDir(targetClient), skill.name) + const skillDir = skillDirMap.get(targetClient) + if (!skillDir) continue + const dest = path.join(homeDir, skillDir, skill.name) if (dryRun) { stdout.write(`(dry-run) Would install skill '${skill.name}' → ${dest}\n`) } else { @@ -1071,10 +1076,20 @@ function endpointFromListen(listen) { return `http://${formattedHost}:${port}` } -/** @param {'claude'|'codex'} client */ -function clientSkillDir(client) { - if (client === 'claude') return '.claude/skills' - return '.codex/skills' +/** + * @returns {Promise>} + */ +async function buildWalkthroughSkillDirMap() { + /** @type {Map} */ + const map = new Map() + try { + const bundled = await discoverBundledPlugins() + const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) + for (const [clientName, descriptor] of catalog.clientDescriptors) { + map.set(clientName, descriptor.skillDir) + } + } catch { /* discovery failure → empty map */ } + return map } /** diff --git a/src/core/config/validate.js b/src/core/config/validate.js index 9711132..76f5a2b 100644 --- a/src/core/config/validate.js +++ b/src/core/config/validate.js @@ -598,13 +598,6 @@ function runPerPluginSectionValidators(config, registry, errors) { /* ---------- Phase 8 V1 diagnostics ---------- */ -const CLIENT_PLUGINS = /** @type {ReadonlySet} */ ( - new Set(/** @type {PluginName[]} */ (['@hypaware/claude', '@hypaware/codex'])) -) -const ENCODER_PLUGINS = /** @type {ReadonlySet} */ ( - new Set(/** @type {PluginName[]} */ (['@hypaware/format-parquet', '@hypaware/format-jsonl'])) -) -const LOCAL_FS_PLUGIN = /** @type {PluginName} */ ('@hypaware/local-fs') const AI_GATEWAY_PLUGIN = /** @type {PluginName} */ ('@hypaware/ai-gateway') /** @@ -614,87 +607,84 @@ const AI_GATEWAY_PLUGIN = /** @type {PluginName} */ ('@hypaware/ai-gateway') * give `hyp status` a concrete list of "what's wrong and how to fix * it" lines. * - * Each diagnostic carries one or more `repair` strings — concrete - * commands the operator can run. The status renderer surfaces them - * verbatim under each diagnostic line. + * Client and upstream checks use the plugin catalog's client + * descriptors rather than hardcoded plugin names — any plugin that + * declares a `contributes.client` with `required_upstreams` gets the + * same diagnostic coverage. + * + * Encoder / blob-store checks use the capability metadata from the + * catalog so new encoder or destination plugins are automatically + * included. * * @param {HypAwareV2Config | null | undefined} config + * @param {{ clientDescriptors?: Map, knownPlugins?: Map }} [ctx] * @returns {V1Diagnostic[]} */ -export function diagnoseV1Config(config) { +export function diagnoseV1Config(config, ctx = {}) { /** @type {V1Diagnostic[]} */ const out = [] if (!config) return out const enabledByName = enabledPluginIndex(config) const gatewayConfig = enabledByName.get(AI_GATEWAY_PLUGIN) + const clientDescriptors = ctx.clientDescriptors ?? new Map() + const knownPlugins = ctx.knownPlugins ?? firstPartyPluginMetadata() + + for (const [clientName, descriptor] of clientDescriptors) { + const pluginName = descriptor.plugin + if (!enabledByName.has(pluginName)) continue - for (const clientName of CLIENT_PLUGINS) { - if (!enabledByName.has(clientName)) continue if (gatewayConfig === undefined) { out.push({ kind: 'client_without_gateway', - pointer: pluginPointer(config, clientName), + pointer: pluginPointer(config, pluginName), message: - `client plugin '${clientName}' is enabled but '${AI_GATEWAY_PLUGIN}' is not — ` + + `client plugin '${pluginName}' is enabled but '${AI_GATEWAY_PLUGIN}' is not — ` + `attach commands will fail until the gateway is enabled.`, repair: [ `hyp init --from-file # re-run picker to add the gateway`, - `hyp attach --client ${clientName === '@hypaware/claude' ? 'claude' : 'codex'}`, + `hyp attach --client ${clientName}`, ], }) } - } - if (enabledByName.has(/** @type {PluginName} */ ('@hypaware/claude'))) { - if (gatewayConfig !== undefined && !gatewayHasUpstreamProvider(gatewayConfig, 'anthropic')) { - out.push({ - kind: 'gateway_missing_anthropic_upstream', - pointer: pluginPointer(config, AI_GATEWAY_PLUGIN), - message: - `'@hypaware/claude' is enabled but the gateway has no Anthropic upstream — ` + - `Claude requests will have nowhere to forward.`, - repair: [ - `hyp init --from-file # re-run picker to add the upstream`, - `hyp attach --client claude`, - ], - }) + if (gatewayConfig !== undefined && descriptor.requiredUpstreams?.length) { + const hasAny = descriptor.requiredUpstreams.some((u) => + gatewayHasUpstreamProvider(gatewayConfig, u) + ) + if (!hasAny) { + const upstreamList = descriptor.requiredUpstreams.join(' or ') + out.push({ + kind: /** @type {V1DiagnosticKind} */ (`gateway_missing_${descriptor.requiredUpstreams[0]}_upstream`), + pointer: pluginPointer(config, AI_GATEWAY_PLUGIN), + message: + `'${pluginName}' is enabled but the gateway has no ${upstreamList} upstream — ` + + `${clientName} requests will have nowhere to forward.`, + repair: [ + `hyp init --from-file # re-run picker to add the upstream`, + `hyp attach --client ${clientName}`, + ], + }) + } } } - if (enabledByName.has(/** @type {PluginName} */ ('@hypaware/codex'))) { - if ( - gatewayConfig !== undefined && - !gatewayHasUpstreamProvider(gatewayConfig, 'openai') && - !gatewayHasUpstreamProvider(gatewayConfig, 'chatgpt') - ) { - out.push({ - kind: 'gateway_missing_openai_upstream', - pointer: pluginPointer(config, AI_GATEWAY_PLUGIN), - message: - `'@hypaware/codex' is enabled but the gateway has no OpenAI or ChatGPT upstream — ` + - `Codex requests will have nowhere to forward.`, - repair: [ - `hyp init --from-file # re-run picker to add the upstream`, - `hyp attach --client codex`, - ], - }) - } - } + const encoderPlugins = derivePluginsByCapability(knownPlugins, 'hypaware.encoder', 'provides') + const blobStorePlugins = derivePluginsByCapability(knownPlugins, 'hypaware.blob-store', 'provides') if (config.sinks) { for (const [name, raw] of Object.entries(config.sinks)) { if (!('writer' in raw) && !('destination' in raw)) continue const writer = 'writer' in raw && typeof raw.writer === 'string' ? raw.writer : null const destination = 'destination' in raw && typeof raw.destination === 'string' ? raw.destination : null - if (destination !== LOCAL_FS_PLUGIN) continue - if (writer !== null && enabledByName.has(writer) && ENCODER_PLUGINS.has(writer)) continue + if (!blobStorePlugins.has(/** @type {PluginName} */ (destination))) continue + if (writer !== null && enabledByName.has(writer) && encoderPlugins.has(writer)) continue out.push({ kind: 'sink_missing_encoder', pointer: `/sinks/${name}`, message: - `sink '${name}' targets '${LOCAL_FS_PLUGIN}' but no encoder plugin ` + - `(${[...ENCODER_PLUGINS].join(' or ')}) is enabled — local export will produce no files.`, + `sink '${name}' targets '${destination}' but no encoder plugin ` + + `(${[...encoderPlugins].join(' or ')}) is enabled — local export will produce no files.`, repair: [ `hyp init --from-file # re-run picker and pick "local Parquet export"`, ], @@ -705,6 +695,24 @@ export function diagnoseV1Config(config) { return out } +/** + * Derive the set of plugins providing (or requiring) a given capability + * from the known-plugins metadata map. + * + * @param {Map} known + * @param {string} capability + * @param {'provides' | 'requires'} direction + * @returns {Set} + */ +function derivePluginsByCapability(known, capability, direction) { + /** @type {Set} */ + const out = new Set() + for (const [name, meta] of known) { + if (meta[direction]?.[capability]) out.add(name) + } + return out +} + /** * @param {HypAwareV2Config} config * @returns {Map} @@ -736,21 +744,24 @@ function pluginPointer(config, name) { * config shape is intentionally loose, so check all three. * * @param {JsonObject} gatewayConfig - * @param {'anthropic'|'openai'|'chatgpt'} provider + * @param {string} provider */ function gatewayHasUpstreamProvider(gatewayConfig, provider) { const upstreams = gatewayConfig?.upstreams if (!Array.isArray(upstreams)) return false - const hostHint = - provider === 'anthropic' ? 'anthropic.com' - : provider === 'chatgpt' ? 'chatgpt.com' - : 'openai.com' + /** @type {Record} */ + const HOST_HINTS = { + anthropic: 'anthropic.com', + chatgpt: 'chatgpt.com', + openai: 'openai.com', + } + const hostHint = HOST_HINTS[provider] for (const raw of upstreams) { if (!raw || typeof raw !== 'object') continue const u = /** @type {Record} */ (raw) if (typeof u.provider === 'string' && u.provider === provider) return true if (typeof u.name === 'string' && u.name === provider) return true - if (typeof u.base_url === 'string' && u.base_url.includes(hostHint)) return true + if (hostHint && typeof u.base_url === 'string' && u.base_url.includes(hostHint)) return true } return false } diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index e2ab07a..bcb73eb 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -118,23 +118,28 @@ export async function collectHypAwareStatus(opts = {}) { const config = loaded.ok ? loaded.config : null const configExists = loaded.ok || (loaded.errorKind !== 'config_missing') + /** @type {import('../plugin_catalog.js').PluginCatalog | undefined} */ + let catalog + try { + /** @type {import('../manifest.js').LoadedManifest[]} */ + let bundledLoaded = [] + /** @type {import('../manifest.js').LoadedManifest[]} */ + let installedLoaded = [] + try { + const bundled = await discoverBundledPlugins() + bundledLoaded = [...bundled.loaded, ...bundled.excluded] + } catch { /* bundled discovery failure is non-fatal */ } + try { + const installed = await discoverInstalledPlugins({ stateDir: stateRoot }) + installedLoaded = installed.loaded + } catch { /* installed discovery failure is non-fatal */ } + catalog = buildPluginCatalog(bundledLoaded, installedLoaded) + } catch { /* catalog build failure is non-fatal */ } + /** @type {ConfigValidationError[]} */ let validationErrors = [] - if (loaded.ok) { + if (loaded.ok && catalog) { try { - /** @type {import('../manifest.js').LoadedManifest[]} */ - let bundledLoaded = [] - /** @type {import('../manifest.js').LoadedManifest[]} */ - let installedLoaded = [] - try { - const bundled = await discoverBundledPlugins() - bundledLoaded = [...bundled.loaded, ...bundled.excluded] - } catch { /* bundled discovery failure is non-fatal */ } - try { - const installed = await discoverInstalledPlugins({ stateDir: stateRoot }) - installedLoaded = installed.loaded - } catch { /* installed discovery failure is non-fatal */ } - const catalog = buildPluginCatalog(bundledLoaded, installedLoaded) const result = await validateConfig(loaded.config, { knownPlugins: catalog.pluginMetadata, knownDatasets: catalog.knownDatasets, @@ -183,7 +188,10 @@ export async function collectHypAwareStatus(opts = {}) { } // V1 advisory diagnostics layered on top. - const v1Diagnostics = diagnoseV1Config(config) + const v1Diagnostics = diagnoseV1Config(config, { + clientDescriptors: catalog?.clientDescriptors, + knownPlugins: catalog?.pluginMetadata, + }) for (const d of v1Diagnostics) { diagnostics.push({ severity: 'warning', @@ -363,10 +371,12 @@ export async function collectHypAwareStatus(opts = {}) { // ----- client attach ----- /** @type {ClientAttachReport[]} */ const clients = [] - for (const clientName of /** @type {const} */ (['claude', 'codex'])) { - const pluginName = clientName === 'claude' ? '@hypaware/claude' : '@hypaware/codex' - const configured = activePlugins.includes(pluginName) - const probe = await probeClientAttach({ clientName, homeDir, env }) + const clientDescriptors = catalog?.clientDescriptors ?? new Map() + for (const [clientName, descriptor] of clientDescriptors) { + const configured = activePlugins.includes(descriptor.plugin) + const probe = descriptor.attachProbe + ? await probeClientAttachFromDescriptor({ descriptor, homeDir, env }) + : { attached: false } clients.push({ name: clientName, configured, @@ -380,7 +390,7 @@ export async function collectHypAwareStatus(opts = {}) { diagnostics.push({ severity: 'warning', kind: 'client_attach_missing', - message: `'${pluginName}' is enabled but ${clientName} settings show no HypAware marker — run 'hyp attach --client ${clientName}'`, + message: `'${descriptor.plugin}' is enabled but ${clientName} settings show no HypAware marker — run 'hyp attach --client ${clientName}'`, repair: [`hyp attach --client ${clientName}`], }) } @@ -511,33 +521,30 @@ function readRetention(config) { return { days: 30, source: 'default' } } -const CLAUDE_MARKER = '_hypaware' -const CODEX_PROVIDER_HEADER = '[model_providers.hypaware]' - /** - * Probe the on-disk settings for a client adapter and report whether - * the HypAware attach marker is present. + * Probe on-disk client settings using the descriptor's attach_probe + * definition. Supports JSON (marker key lookup) and TOML (header + * string search) formats. Returns a probe result without importing + * any client plugin code. * - * - claude: parses `~/.claude/settings.json`, looks for the - * `_hypaware` marker the adapter writes. - * - codex: reads `~/.codex/config.toml`, looks for the - * `[model_providers.hypaware]` header the adapter writes. - * - * @param {{ clientName: 'claude'|'codex', homeDir: string, env?: NodeJS.ProcessEnv }} args + * @param {{ descriptor: import('../plugin_catalog.js').ClientDescriptor, homeDir: string, env?: NodeJS.ProcessEnv }} args * @returns {Promise<{ attached: boolean, settingsPath?: string, version?: string, port?: string, error?: string }>} */ -async function probeClientAttach({ clientName, homeDir, env }) { - if (!homeDir) return { attached: false } - if (clientName === 'claude') { - const settingsPath = path.join(homeDir, '.claude', 'settings.json') - try { - const raw = await fsp.readFile(settingsPath, 'utf8') +async function probeClientAttachFromDescriptor({ descriptor, homeDir, env }) { + if (!homeDir || !descriptor.attachProbe) return { attached: false } + const probe = descriptor.attachProbe + const settingsPath = resolveClientSettingsPath(descriptor.name, probe.settings_file, env, homeDir) + + try { + const raw = await fsp.readFile(settingsPath, 'utf8') + + if (probe.format === 'json' && probe.marker_key) { /** @type {unknown} */ const parsed = JSON.parse(raw) if (!parsed || typeof parsed !== 'object') { return { attached: false, settingsPath } } - const marker = /** @type {Record} */ (parsed)[CLAUDE_MARKER] + const marker = /** @type {Record} */ (parsed)[probe.marker_key] if (!marker || typeof marker !== 'object') return { attached: false, settingsPath } const markerObj = /** @type {Record} */ (marker) return { @@ -546,21 +553,13 @@ async function probeClientAttach({ clientName, homeDir, env }) { version: typeof markerObj.version === 'string' ? markerObj.version : undefined, port: typeof markerObj.port === 'number' ? String(markerObj.port) : undefined, } - } catch (err) { - const code = err && /** @type {NodeJS.ErrnoException} */ (err).code - if (code === 'ENOENT') return { attached: false, settingsPath } - return { - attached: false, - settingsPath, - error: err instanceof Error ? err.message : String(err), - } } - } - // codex - const settingsPath = path.join(codexHomeDir(env, homeDir), 'config.toml') - try { - const raw = await fsp.readFile(settingsPath, 'utf8') - return { attached: raw.includes(CODEX_PROVIDER_HEADER), settingsPath } + + if (probe.format === 'toml' && probe.marker_header) { + return { attached: raw.includes(probe.marker_header), settingsPath } + } + + return { attached: false, settingsPath } } catch (err) { const code = err && /** @type {NodeJS.ErrnoException} */ (err).code if (code === 'ENOENT') return { attached: false, settingsPath } @@ -573,13 +572,25 @@ async function probeClientAttach({ clientName, homeDir, env }) { } /** + * Resolve the absolute settings-file path for a client. The manifest + * `settings_file` is relative to `$HOME` (e.g. `.codex/config.toml`). + * Client-specific env overrides like `CODEX_HOME` replace the first + * directory component (`.codex` → `$CODEX_HOME`). + * + * @param {string} clientName + * @param {string} settingsFile * @param {NodeJS.ProcessEnv | undefined} env * @param {string} homeDir + * @returns {string} */ -function codexHomeDir(env, homeDir) { - const codexHome = env?.CODEX_HOME - if (typeof codexHome === 'string' && codexHome.length > 0) return codexHome - return path.join(homeDir, '.codex') +function resolveClientSettingsPath(clientName, settingsFile, env, homeDir) { + const envKey = `${clientName.toUpperCase()}_HOME` + const override = env?.[envKey] + if (typeof override === 'string' && override.length > 0) { + const parts = settingsFile.split('/') + return path.join(override, ...parts.slice(1)) + } + return path.join(homeDir, ...settingsFile.split('/')) } /** diff --git a/src/core/plugin_catalog.js b/src/core/plugin_catalog.js index 1332457..c16000c 100644 --- a/src/core/plugin_catalog.js +++ b/src/core/plugin_catalog.js @@ -1,7 +1,7 @@ // @ts-check /** - * @import { CapabilityName, PluginContributionManifest, PluginManifest, PluginName } from '../../collectivus-plugin-kernel-types.d.ts' + * @import { CapabilityName, PluginAttachProbeManifest, PluginContributionManifest, PluginManifest, PluginName } from '../../collectivus-plugin-kernel-types.d.ts' * @import { LoadedManifest } from './manifest.js' * @import { PluginMetadata } from './config/types.d.ts' */ @@ -14,11 +14,21 @@ * @property {PluginContributionManifest | undefined} contributes */ +/** + * @typedef {object} ClientDescriptor + * @property {PluginName} plugin + * @property {string} name + * @property {string} skillDir + * @property {PluginAttachProbeManifest} [attachProbe] + * @property {string[]} [requiredUpstreams] + */ + /** * @typedef {object} PluginCatalog * @property {Map} plugins * @property {Map} pluginMetadata * @property {Set} knownDatasets + * @property {Map} clientDescriptors */ /** @@ -41,6 +51,8 @@ export function buildPluginCatalog(bundledManifests, installedManifests = []) { const pluginMetadata = new Map() /** @type {Set} */ const knownDatasets = new Set() + /** @type {Map} */ + const clientDescriptors = new Map() for (const source of [bundledManifests, installedManifests]) { for (const entry of source) { @@ -64,10 +76,27 @@ export function buildPluginCatalog(bundledManifests, installedManifests = []) { } } } + + const client = entry.manifest.contributes?.client + if (client && typeof client.name === 'string' && typeof client.skill_dir === 'string') { + if (!clientDescriptors.has(client.name)) { + /** @type {ClientDescriptor} */ + const descriptor = { + plugin: name, + name: client.name, + skillDir: client.skill_dir, + } + if (client.attach_probe) descriptor.attachProbe = client.attach_probe + if (Array.isArray(client.required_upstreams)) { + descriptor.requiredUpstreams = client.required_upstreams + } + clientDescriptors.set(client.name, descriptor) + } + } } } - return { plugins, pluginMetadata, knownDatasets } + return { plugins, pluginMetadata, knownDatasets, clientDescriptors } } /** diff --git a/test/core/command-dispatch.test.js b/test/core/command-dispatch.test.js index 255b30c..42e2efd 100644 --- a/test/core/command-dispatch.test.js +++ b/test/core/command-dispatch.test.js @@ -11,11 +11,27 @@ import { registerCoreCommands } from '../../src/core/cli/core_commands.js' import { dispatch } from '../../src/core/cli/dispatch.js' import { createCommandRegistry } from '../../src/core/registry/commands.js' import { createKernelRuntime } from '../../src/core/runtime/activation.js' +import { runClaudeSessionContextHook } from '../../hypaware-core/plugins-workspace/claude/src/hook_command.js' + +function hookKernelAndRegistry() { + const registry = createCommandRegistry() + registerCoreCommands(registry) + registry.register({ + name: 'claude-hook session-context', + summary: 'Internal Claude Code hook', + usage: 'hyp claude-hook session-context --state-file ', + hidden: true, + run: runClaudeSessionContextHook, + }) + const kernel = createKernelRuntime({ commandRegistry: registry }) + return { kernel, registry } +} test('Claude session-context hook exits 0 without --state-file', async () => { const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-hook-')) const stdout = makeBuf() const stderr = makeBuf() + const { kernel, registry: hookRegistry } = hookKernelAndRegistry() const code = await dispatch( ['claude-hook', 'session-context'], @@ -24,6 +40,8 @@ test('Claude session-context hook exits 0 without --state-file', async () => { stderr, stdin: stdinFor(''), env: { ...process.env, HYP_HOME: hypHome }, + registry: hookRegistry, + kernel, } ) @@ -37,6 +55,7 @@ test('Claude session-context hook appends one JSONL record per event to --state- const stateFile = path.join(hypHome, 'session-context.jsonl') const stdout = makeBuf() const stderr = makeBuf() + const { kernel, registry: hookRegistry } = hookKernelAndRegistry() const code = await dispatch( ['claude-hook', 'session-context', '--state-file', stateFile], @@ -50,6 +69,8 @@ test('Claude session-context hook appends one JSONL record per event to --state- hook_event_name: 'SessionStart', }), env: { ...process.env, HYP_HOME: hypHome }, + registry: hookRegistry, + kernel, } ) @@ -72,6 +93,7 @@ test('Claude session-context hook ignores events without session context', async const stateFile = path.join(hypHome, 'session-context.jsonl') const stdout = makeBuf() const stderr = makeBuf() + const { kernel, registry: hookRegistry } = hookKernelAndRegistry() const code = await dispatch( ['claude-hook', 'session-context', '--state-file', stateFile], @@ -80,6 +102,8 @@ test('Claude session-context hook ignores events without session context', async stderr, stdin: stdinFor({ cwd: '/tmp/not-a-git-repo' }), env: { ...process.env, HYP_HOME: hypHome }, + registry: hookRegistry, + kernel, } ) @@ -93,6 +117,7 @@ test('legacy Claude session-context hook --port writes the default plugin state const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-hook-legacy-')) const stdout = makeBuf() const stderr = makeBuf() + const { kernel, registry: hookRegistry } = hookKernelAndRegistry() const code = await dispatch( ['claude-hook', 'session-context', '--port', '4388'], @@ -105,6 +130,8 @@ test('legacy Claude session-context hook --port writes the default plugin state transcript_path: '/tmp/sess-legacy.jsonl', }), env: { ...process.env, HYP_HOME: hypHome }, + registry: hookRegistry, + kernel, } ) diff --git a/test/core/config.test.js b/test/core/config.test.js index 9a1e0aa..9481576 100644 --- a/test/core/config.test.js +++ b/test/core/config.test.js @@ -157,17 +157,26 @@ test('isCronExpression accepts narrow standard cron and rejects aliases', () => assert.equal(isCronExpression('60 * * * *'), false) }) -test('diagnoseV1Config reports advisory product wiring gaps', () => { - const diagnostics = diagnoseV1Config({ - version: 2, - plugins: [ - { name: '@hypaware/codex' }, - { name: '@hypaware/local-fs' }, - ], - sinks: { - local: { writer: '@hypaware/format-parquet', destination: '@hypaware/local-fs' }, +test('diagnoseV1Config reports advisory product wiring gaps', async () => { + const bundled = await discoverBundledPlugins() + const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) + + const diagnostics = diagnoseV1Config( + { + version: 2, + plugins: [ + { name: '@hypaware/codex' }, + { name: '@hypaware/local-fs' }, + ], + sinks: { + local: { writer: '@hypaware/format-parquet', destination: '@hypaware/local-fs' }, + }, }, - }) + { + clientDescriptors: catalog.clientDescriptors, + knownPlugins: catalog.pluginMetadata, + } + ) assert.deepEqual( diagnostics.map((diagnostic) => diagnostic.kind).sort(), @@ -196,6 +205,26 @@ test('buildPluginCatalog derives capability metadata from bundled manifests', as }) }) +test('buildPluginCatalog extracts client descriptors from manifests', async () => { + const bundled = await discoverBundledPlugins() + const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) + + assert.ok(catalog.clientDescriptors.has('claude')) + const claude = catalog.clientDescriptors.get('claude') + assert.equal(claude?.plugin, '@hypaware/claude') + assert.equal(claude?.skillDir, '.claude/skills') + assert.equal(claude?.attachProbe?.format, 'json') + assert.equal(claude?.attachProbe?.marker_key, '_hypaware') + assert.deepEqual(claude?.requiredUpstreams, ['anthropic']) + + assert.ok(catalog.clientDescriptors.has('codex')) + const codex = catalog.clientDescriptors.get('codex') + assert.equal(codex?.plugin, '@hypaware/codex') + assert.equal(codex?.skillDir, '.codex/skills') + assert.equal(codex?.attachProbe?.format, 'toml') + assert.deepEqual(codex?.requiredUpstreams, ['openai', 'chatgpt']) +}) + test('buildPluginCatalog collects known datasets from manifest contributions', async () => { const bundled = await discoverBundledPlugins() const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) @@ -364,21 +393,30 @@ test('buildPluginCatalog bundled wins over installed on name collision', () => { assert.deepEqual(meta?.provides, { 'hypaware.blob-store': '1.0.0' }) }) -test('diagnoseV1Config treats ChatGPT as a valid Codex upstream', () => { - const diagnostics = diagnoseV1Config({ - version: 2, - plugins: [ - { - name: '@hypaware/ai-gateway', - config: { - upstreams: [ - { name: 'chatgpt', base_url: 'https://chatgpt.com', provider: 'chatgpt' }, - ], +test('diagnoseV1Config treats ChatGPT as a valid Codex upstream', async () => { + const bundled = await discoverBundledPlugins() + const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) + + const diagnostics = diagnoseV1Config( + { + version: 2, + plugins: [ + { + name: '@hypaware/ai-gateway', + config: { + upstreams: [ + { name: 'chatgpt', base_url: 'https://chatgpt.com', provider: 'chatgpt' }, + ], + }, }, - }, - { name: '@hypaware/codex' }, - ], - }) + { name: '@hypaware/codex' }, + ], + }, + { + clientDescriptors: catalog.clientDescriptors, + knownPlugins: catalog.pluginMetadata, + } + ) assert.equal( diagnostics.some((diagnostic) => diagnostic.kind === 'gateway_missing_openai_upstream'), diff --git a/test/plugins/claude-session-context-hook.test.js b/test/plugins/claude-session-context-hook.test.js index 0111a38..cd00321 100644 --- a/test/plugins/claude-session-context-hook.test.js +++ b/test/plugins/claude-session-context-hook.test.js @@ -9,8 +9,11 @@ import test from 'node:test' import { registerCoreCommands } from '../../src/core/cli/core_commands.js' import { dispatch } from '../../src/core/cli/dispatch.js' +import { createCommandRegistry } from '../../src/core/registry/commands.js' +import { createKernelRuntime } from '../../src/core/runtime/activation.js' import { createAiGatewayMessageProjector } from '../../hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js' import { createClaudeExchangeProjector } from '../../hypaware-core/plugins-workspace/claude/src/projector.js' +import { runClaudeSessionContextHook } from '../../hypaware-core/plugins-workspace/claude/src/hook_command.js' import { appendSessionContext, defaultSessionContextFile, pickLatestMatching, readSessionContext } from '../../hypaware-core/plugins-workspace/claude/src/session_context.js' /** @@ -36,6 +39,16 @@ test('hook → state file → projector roundtrip writes cwd onto the row', asyn // Stage 1: hook writes a record into the state file. const stdout = makeBuf() const stderr = makeBuf() + const registry = createCommandRegistry() + registerCoreCommands(registry) + registry.register({ + name: 'claude-hook session-context', + summary: 'Internal Claude Code hook', + usage: 'hyp claude-hook session-context --state-file ', + hidden: true, + run: runClaudeSessionContextHook, + }) + const kernel = createKernelRuntime({ commandRegistry: registry }) const code = await dispatch( ['claude-hook', 'session-context', '--state-file', env.stateFile], { @@ -48,6 +61,8 @@ test('hook → state file → projector roundtrip writes cwd onto the row', asyn hook_event_name: 'SessionStart', }), env: { ...process.env, HYP_HOME: env.hypHome }, + registry, + kernel, } ) assert.equal(code, 0) From bf503c4f8d23ab222e9818ada9b2410020c9d103 Mon Sep 17 00:00:00 2001 From: Phil Cunliffe Date: Mon, 25 May 2026 21:30:10 -0700 Subject: [PATCH 4/7] feat: gascity acceptance and plugin-surface cleanup (hy-sadjq) (#39) Reframe gascity from "excluded and invisible" to "excluded from default activation but discoverable through the plugin catalog." Update test and smoke labels/comments to reflect that gascity absence in status is about the config scenario, not a blanket prohibition. Add catalog test for gascity contributions (source, commands, dataset, init preset, skill). Add detach coverage to the gascity smoke test. Co-authored-by: Claude Opus 4.7 (1M context) --- .../flows/cli_bundled_plugins_activated.js | 21 +++---- .../flows/gascity_attach_writes_partition.js | 59 ++++++++++++++----- src/core/plugin_catalog.js | 10 +++- src/core/runtime/boot.js | 10 ++-- src/core/runtime/bundled.js | 30 ++++++---- test/core/config.test.js | 30 ++++++++++ 6 files changed, 116 insertions(+), 44 deletions(-) diff --git a/hypaware-core/smoke/flows/cli_bundled_plugins_activated.js b/hypaware-core/smoke/flows/cli_bundled_plugins_activated.js index 43f8329..a187985 100644 --- a/hypaware-core/smoke/flows/cli_bundled_plugins_activated.js +++ b/hypaware-core/smoke/flows/cli_bundled_plugins_activated.js @@ -21,10 +21,11 @@ import { defaultConfigPath } from '../../../src/core/config/schema.js' * 3. `hyp attach --client codex --dry-run` reaches the Codex adapter * (same shape). * 4. `hyp status --json` emits a stable JSON document listing the - * configured sources, sinks, clients, and active plugins, *without* - * mentioning `@hypaware/central` or `@hypaware/gascity` (both - * remain loadable for developers but are excluded from the V1 - * default surface). + * configured sources, sinks, clients, and active plugins. Because + * neither `@hypaware/central` nor `@hypaware/gascity` is in this + * config, they must not appear — they are excluded from default + * activation but remain discoverable through the plugin catalog and + * activatable via explicit config or init presets. * * Telemetry contract (per bead): * - One `kernel.boot` root span per dispatch boot. @@ -48,9 +49,9 @@ export async function run({ harness, expect }) { // Stage a v2 config that selects six of the nine V1-bundled // plugins. `@hypaware/format-jsonl`, `@hypaware/s3`, and // `@hypaware/format-iceberg` are intentionally omitted so the smoke - // can assert the "skipped" log surface, and we exclude - // `@hypaware/central` / `@hypaware/gascity` because they are not on - // the V1 default surface in the first place. + // can assert the "skipped" log surface. `@hypaware/central` and + // `@hypaware/gascity` are not in this config — they are excluded from + // default activation but activatable via explicit config. const configPath = defaultConfigPath(harness.hypHome) await fs.mkdir(path.dirname(configPath), { recursive: true }) await fs.writeFile(configPath, JSON.stringify({ @@ -131,7 +132,7 @@ export async function run({ harness, expect }) { (rows) => Array.isArray(rows) && rows.every((/** @type {any} */ r) => r.source === 'bundled') ) expect.that( - 'plugins: no excluded-from-default plugin (central/gascity) appears', + 'plugins: unconfigured plugins (central/gascity) absent from active list', (listed.plugins ?? []).map((/** @type {any} */ p) => p.name), (v) => Array.isArray(v) && @@ -219,12 +220,12 @@ export async function run({ harness, expect }) { typeof v.state === 'string' ) expect.that( - 'status: JSON does not reference @hypaware/central', + 'status: unconfigured @hypaware/central absent from status JSON', statusStdout.text(), (v) => typeof v === 'string' && !v.includes('@hypaware/central') ) expect.that( - 'status: JSON does not reference @hypaware/gascity', + 'status: unconfigured @hypaware/gascity absent from status JSON', statusStdout.text(), (v) => typeof v === 'string' && !v.includes('@hypaware/gascity') ) diff --git a/hypaware-core/smoke/flows/gascity_attach_writes_partition.js b/hypaware-core/smoke/flows/gascity_attach_writes_partition.js index 51de455..540e598 100644 --- a/hypaware-core/smoke/flows/gascity_attach_writes_partition.js +++ b/hypaware-core/smoke/flows/gascity_attach_writes_partition.js @@ -16,19 +16,17 @@ import { activatePlugins } from '../../../src/core/runtime/loader.js' import { loadManifests } from '../../../src/core/manifest.js' /** - * Phase 8.6 smoke. Boots `@hypaware/gascity` from the in-repo - * workspace, attaches an in-process fixture supervisor through - * `hyp gascity attach`, drives a few SSE-shaped frames, and asserts - * the §Phase 8.6 contract from the implementation plan: + * Gascity plugin-surface acceptance smoke. Boots `@hypaware/gascity` + * from the in-repo workspace and exercises the full plugin lifecycle + * through plugin-owned contributions: * - * - traces: a `source.start` span tagged `hyp_plugin=@hypaware/gascity` - * appears on the first `gascity attach` - * - traces: a `source.reload` span tagged `hyp_plugin=@hypaware/gascity` - * appears on the second `gascity attach` - * - query: `select count(*) from gascity_messages` returns the number - * of frames the fixture pushed - * - cache: at least one `cache.append` span landed for - * `hyp_dataset=gascity_messages` + * - `gascity attach` starts/reloads the source through plugin code + * - `gascity list` shows attached city state + * - `select count(*) from gascity_messages` returns captured rows + * - `gascity detach` removes a city and reloads cleanly + * - traces: `source.start` tagged `hyp_plugin=@hypaware/gascity` + * - traces: `source.reload` on subsequent attach/detach + * - cache: `cache.append` spans for `hyp_dataset=gascity_messages` * * The fixture supervisor lives entirely in this file and is wired to * the plugin via `globalThis[Symbol.for('hypaware-gascity:transport')]` @@ -171,6 +169,39 @@ export async function run({ harness, expect }) { (v) => v === true ) + // Detach hypburb and verify the source reloads cleanly. + const detachOut = await dispatchCommand( + ['gascity', 'detach', 'hypburb'], + { kernel, registry, harness } + ) + expect.that( + "stdout: detach prints confirmation for 'hypburb'", + detachOut.includes('hypburb'), + (v) => v === true + ) + + // After detach, list should still show hyptown but not hypburb. + const list2Stdout = makeBuf() + const list2Stderr = makeBuf() + await dispatch(['gascity', 'list'], { + stdout: list2Stdout, + stderr: list2Stderr, + kernel, + registry, + env: smokeEnv(harness), + }) + const list2Text = list2Stdout.text() + expect.that( + 'stdout: gascity list after detach still includes hyptown', + list2Text.includes('hyptown'), + (v) => v === true + ) + expect.that( + 'stdout: gascity list after detach no longer includes hypburb', + list2Text.includes('hypburb'), + (v) => v === false + ) + await obs.shutdown() fixture.uninstall() @@ -199,9 +230,9 @@ export async function run({ harness, expect }) { (/** @type {any} */ t) => t.name === 'source.reload' ) expect.that( - 'traces: at least one source.reload span emitted (from the second attach)', + 'traces: at least 2 source.reload spans emitted (attach + detach)', reloadSpans, - (rows) => rows.length >= 1 + (rows) => rows.length >= 2 ) expect.that( 'traces: source.reload tagged hyp_plugin=@hypaware/gascity', diff --git a/src/core/plugin_catalog.js b/src/core/plugin_catalog.js index c16000c..a5f8e16 100644 --- a/src/core/plugin_catalog.js +++ b/src/core/plugin_catalog.js @@ -33,8 +33,14 @@ /** * Build a plugin catalog from loaded manifests. The catalog derives - * capability metadata, known datasets, and contribution summaries - * from the manifest files themselves rather than a hardcoded table. + * capability metadata, known datasets, client descriptors, and + * contribution summaries from the manifest files themselves rather + * than a hardcoded table. + * + * Callers should pass both `bundled.loaded` and `bundled.excluded` + * manifests so excluded plugins (like `@hypaware/gascity`) remain + * visible for config validation and descriptor resolution even though + * they are not activated by default. * * Duplicate plugin names are resolved by first-writer-wins: the first * manifest array is treated as authoritative (bundled plugins), so diff --git a/src/core/runtime/boot.js b/src/core/runtime/boot.js index 254cdb3..e23d580 100644 --- a/src/core/runtime/boot.js +++ b/src/core/runtime/boot.js @@ -128,12 +128,10 @@ export async function bootKernel(opts = {}) { const loadedConfig = configPath ? await loadConfigFile(configPath) : null const config = loadedConfig?.ok ? loadedConfig.config : null - // The full plugin pool the kernel knows about: V1 allowlist plus - // the excluded-from-default set (so developers can still activate - // `@hypaware/central` or `@hypaware/gascity` by naming them in - // config), plus every plugin in `plugin-lock.json` whose manifest - // loaded. `all-bundled` boots intentionally skip the excluded and - // installed sets so the picker only sees the V1 default surface. + // Full plugin pool: V1 allowlist + excluded-from-default set + + // installed plugins. Excluded plugins are in the pool so they + // activate when named in config or an init preset — the allowlist + // only governs default activation, not discoverability. const installedNames = new Set(installed.loaded.map((m) => m.manifest.name)) const excludedAvailable = discovered.excluded.filter( (m) => !installedNames.has(/** @type {PluginName} */ (m.manifest.name)) diff --git a/src/core/runtime/bundled.js b/src/core/runtime/bundled.js index c8fbf62..dae822d 100644 --- a/src/core/runtime/bundled.js +++ b/src/core/runtime/bundled.js @@ -12,12 +12,14 @@ import { loadManifests } from '../manifest.js' */ /** - * V1 bundled plugin allowlist (finish-v1.md §Phase 2). A plugin must - * appear here to be discoverable through the default boot path. The - * allowlist exists so the V1 default install does not pull - * `@hypaware/central` or `@hypaware/gascity` into the picker, the - * default config, or the V1 smokes — both remain on disk for - * developers but are excluded from V1 acceptance gates. + * V1 bundled plugin allowlist. A plugin must appear here to be + * activated by the default boot profiles (`all-bundled`, + * `all-available`). Excluded plugins (`@hypaware/central`, + * `@hypaware/gascity`) are still discoverable through the plugin + * catalog — their manifest contributions (datasets, client + * descriptors, capability metadata) are visible to config validation + * and the walkthrough. They are activatable via explicit config or + * init presets; the allowlist only governs default activation. * * @type {ReadonlySet} */ @@ -34,10 +36,12 @@ export const V1_BUNDLED_PLUGIN_ALLOWLIST = new Set(/** @type {PluginName[]} */ ( ])) /** - * Bundled plugins present in the repo workspace but excluded from the - * V1 default surface. They remain loadable for developers (via - * explicit manifest discovery) but never appear in the V1 picker, - * default configs, V1 docs, or V1 smokes. + * Bundled plugins excluded from default activation. Their manifests + * are still loaded by `discoverBundledPlugins` (in the `excluded` + * bucket) so the plugin catalog can derive datasets, client + * descriptors, and capability metadata for config validation. + * Activation requires explicit config (`{ name: '@hypaware/gascity' }`) + * or an init preset — the picker and default boot profiles skip them. * * @type {ReadonlySet} */ @@ -67,8 +71,10 @@ export function defaultBundledWorkspaceDir() { * - `loaded` — manifests whose `name` is in the V1 allowlist. * - `excluded` — manifests whose `name` is in the V1 exclude set * (`@hypaware/central`, `@hypaware/gascity`). These - * remain available for developers but are not surfaced - * by the default boot path. + * are excluded from default activation but their + * manifests feed the plugin catalog so datasets, + * client descriptors, and capability metadata remain + * visible to config validation and the walkthrough. * - `unknownDirs` — directories that hold a parseable manifest under * a name the kernel doesn't recognise as bundled. * diff --git a/test/core/config.test.js b/test/core/config.test.js index 9481576..c3c8354 100644 --- a/test/core/config.test.js +++ b/test/core/config.test.js @@ -233,6 +233,36 @@ test('buildPluginCatalog collects known datasets from manifest contributions', a assert.ok(catalog.knownDatasets.has('logs')) assert.ok(catalog.knownDatasets.has('traces')) assert.ok(catalog.knownDatasets.has('metrics')) + assert.ok( + catalog.knownDatasets.has('gascity_messages'), + 'catalog includes gascity_messages from excluded plugin manifest' + ) +}) + +test('buildPluginCatalog includes excluded gascity plugin as a catalog entry', async () => { + const bundled = await discoverBundledPlugins() + const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) + + const entry = catalog.plugins.get('@hypaware/gascity') + assert.ok(entry, 'gascity must be in catalog when excluded manifests are included') + assert.equal(entry.name, '@hypaware/gascity') + assert.ok(entry.contributes, 'gascity catalog entry must carry its contributions') + assert.ok( + entry.contributes.sources?.some((s) => s.name === 'gascity'), + 'gascity contributes a "gascity" source' + ) + assert.ok( + entry.contributes.commands?.some((c) => c.name === 'gascity attach'), + 'gascity contributes a "gascity attach" command' + ) + assert.ok( + entry.contributes.init_presets?.some((p) => p.name === 'gascity'), + 'gascity contributes a "gascity" init preset' + ) + assert.ok( + entry.contributes.skills?.some((s) => s.name === 'hypaware-gascity'), + 'gascity contributes the hypaware-gascity skill' + ) }) test('validateConfig uses catalog-derived metadata for sink validation', async () => { From 8911f3f41d90564a1c6c0760b62c8c347cd64e5f Mon Sep 17 00:00:00 2001 From: Phil Cunliffe Date: Mon, 25 May 2026 22:36:04 -0700 Subject: [PATCH 5/7] fix: replace inline import types with @import declarations and sanitize env var derivation (hy-hfne7) (#40) Replace 20+ inline import('...') type annotations with @import JSDoc declarations at file tops across validate.js, status.js, materialize.js, and sink-materialize.test.js. Fix resolveClientSettingsPath to sanitize non-alphanumeric characters (hyphens, dots, etc.) in client names when deriving env var keys, so that hyphenated clients like 'claude-desktop' produce valid POSIX env var names (CLAUDE_DESKTOP_HOME instead of CLAUDE-DESKTOP_HOME). Co-authored-by: Claude Opus 4.7 (1M context) --- src/core/config/validate.js | 3 ++- src/core/daemon/status.js | 12 +++++++----- src/core/sinks/materialize.js | 7 ++++--- test/core/sink-materialize.test.js | 29 +++++++++++++++-------------- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/core/config/validate.js b/src/core/config/validate.js index 76f5a2b..706c48a 100644 --- a/src/core/config/validate.js +++ b/src/core/config/validate.js @@ -5,6 +5,7 @@ import { Attr, getLogger, withSpan } from '../observability/index.js' /** * @import { BlobSinkConfigInstance, CapabilityName, ConfigRegistry, HypAwareV2Config, JsonObject, PluginManifest, PluginName, RequestSinkConfigInstance, ValidationError } from '../../../collectivus-plugin-kernel-types.d.ts' * @import { LoadedManifest } from '../manifest.js' + * @import { ClientDescriptor } from '../plugin_catalog.js' */ /** @@ -617,7 +618,7 @@ const AI_GATEWAY_PLUGIN = /** @type {PluginName} */ ('@hypaware/ai-gateway') * included. * * @param {HypAwareV2Config | null | undefined} config - * @param {{ clientDescriptors?: Map, knownPlugins?: Map }} [ctx] + * @param {{ clientDescriptors?: Map, knownPlugins?: Map }} [ctx] * @returns {V1Diagnostic[]} */ export function diagnoseV1Config(config, ctx = {}) { diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index bcb73eb..8481f12 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -34,6 +34,8 @@ import { * @import { ConfigValidationError, V1Diagnostic } from '../config/types.d.ts' * @import { ClientAttachReport, CollectStatusOptions, DaemonState, DaemonStatus, HypAwareStatusReport, ServiceState, SinkSnapshot, SourceSnapshot, StatusDiagnostic, StatusDiagnosticKind } from './types.d.ts' * @import { Dirent } from 'node:fs' + * @import { PluginCatalog, ClientDescriptor } from '../plugin_catalog.js' + * @import { LoadedManifest } from '../manifest.js' */ /** @@ -118,12 +120,12 @@ export async function collectHypAwareStatus(opts = {}) { const config = loaded.ok ? loaded.config : null const configExists = loaded.ok || (loaded.errorKind !== 'config_missing') - /** @type {import('../plugin_catalog.js').PluginCatalog | undefined} */ + /** @type {PluginCatalog | undefined} */ let catalog try { - /** @type {import('../manifest.js').LoadedManifest[]} */ + /** @type {LoadedManifest[]} */ let bundledLoaded = [] - /** @type {import('../manifest.js').LoadedManifest[]} */ + /** @type {LoadedManifest[]} */ let installedLoaded = [] try { const bundled = await discoverBundledPlugins() @@ -527,7 +529,7 @@ function readRetention(config) { * string search) formats. Returns a probe result without importing * any client plugin code. * - * @param {{ descriptor: import('../plugin_catalog.js').ClientDescriptor, homeDir: string, env?: NodeJS.ProcessEnv }} args + * @param {{ descriptor: ClientDescriptor, homeDir: string, env?: NodeJS.ProcessEnv }} args * @returns {Promise<{ attached: boolean, settingsPath?: string, version?: string, port?: string, error?: string }>} */ async function probeClientAttachFromDescriptor({ descriptor, homeDir, env }) { @@ -584,7 +586,7 @@ async function probeClientAttachFromDescriptor({ descriptor, homeDir, env }) { * @returns {string} */ function resolveClientSettingsPath(clientName, settingsFile, env, homeDir) { - const envKey = `${clientName.toUpperCase()}_HOME` + const envKey = `${clientName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_HOME` const override = env?.[envKey] if (typeof override === 'string' && override.length > 0) { const parts = settingsFile.split('/') diff --git a/src/core/sinks/materialize.js b/src/core/sinks/materialize.js index 82a3713..5476239 100644 --- a/src/core/sinks/materialize.js +++ b/src/core/sinks/materialize.js @@ -16,6 +16,7 @@ import { * PluginName, * RequestSinkConfigInstance, * SinkEncoder, + * PluginActivationContext, * TableFormatProvider, * } from '../../../collectivus-plugin-kernel-types.d.ts' * @import { KernelRuntime } from '../runtime/activation.d.ts' @@ -121,7 +122,7 @@ export async function materializeSinks(runtime, config, opts) { * @param {BlobSinkConfigInstance | RequestSinkConfigInstance} raw * @param {HypAwareV2Config} config * @param {{ stateRoot: string, runId: string, tmpRoot?: string }} opts - * @param {ReturnType} log + * @param {ReturnType} log * @returns {Promise} */ async function materializeOne(runtime, instanceName, raw, config, opts, log) { @@ -262,8 +263,8 @@ async function materializeBlob(runtime, instanceName, raw, config, opts) { * @param {{ stateRoot: string, runId: string, tmpRoot?: string }} opts * @param {PluginName} writerName * @param {PluginName} destName - * @param {import('../../../collectivus-plugin-kernel-types.d.ts').PluginActivationContext} writerCtx - * @param {import('../../../collectivus-plugin-kernel-types.d.ts').PluginActivationContext} destCtx + * @param {PluginActivationContext} writerCtx + * @param {PluginActivationContext} destCtx * @param {TableFormatProvider} tableFormat * @returns {Promise} */ diff --git a/test/core/sink-materialize.test.js b/test/core/sink-materialize.test.js index db1cff2..f5ca595 100644 --- a/test/core/sink-materialize.test.js +++ b/test/core/sink-materialize.test.js @@ -20,6 +20,7 @@ import { materializeSinks } from '../../src/core/sinks/materialize.js' * @import { * ActivePlugin, * BlobStore, + * HypAwareV2Config, * PluginActivationContext, * PluginLogger, * PluginPaths, @@ -218,7 +219,7 @@ test('materializeSinks materializes a request sink from a plugin with one contri create: async () => makeSink(), }) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'my-central': { plugin: '@hypaware/central', config: { schedule: '* * * * *' } }, @@ -235,7 +236,7 @@ test('materializeSinks materializes a request sink from a plugin with one contri test('materializeSinks errors when request sink plugin is not active', async () => { const runtime = makeRuntime() - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'my-central': { plugin: '@hypaware/central' }, @@ -252,7 +253,7 @@ test('materializeSinks errors when request sink plugin has no contributions', as const runtime = makeRuntime() registerActivationContext('@hypaware/central', runtime) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'my-central': { plugin: '@hypaware/central' }, @@ -281,7 +282,7 @@ test('materializeSinks errors when request sink plugin has multiple contribution create: async () => makeSink(), }) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'my-central': { plugin: '@hypaware/central' }, @@ -307,7 +308,7 @@ test('materializeSinks materializes a blob sink (encoder writer + destination)', create: async () => makeSink(), }) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'local-parquet': { @@ -337,7 +338,7 @@ test('materializeSinks materializes a table-format sink', async () => { runtime.capabilities.provide('@hypaware/local-fs', 'hypaware.blob-store', '1.0.0', makeBlobStore()) runtime.capabilities.provide('@hypaware/format-parquet', 'hypaware.encoder', '1.0.0', makeEncoder()) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'iceberg-local': { @@ -368,7 +369,7 @@ test('materializeSinks table-format sink uses config.encoder pin', async () => { const jsonlEncoder = { ...makeEncoder(), format: 'jsonl', extension: 'jsonl' } runtime.capabilities.provide('@hypaware/format-jsonl', 'hypaware.encoder', '1.0.0', jsonlEncoder) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'iceberg-jsonl': { @@ -388,7 +389,7 @@ test('materializeSinks errors when writer plugin is not active', async () => { const runtime = makeRuntime() registerActivationContext('@hypaware/local-fs', runtime) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'broken': { @@ -409,7 +410,7 @@ test('materializeSinks errors when destination plugin is not active', async () = registerActivationContext('@hypaware/format-parquet', runtime) runtime.capabilities.provide('@hypaware/format-parquet', 'hypaware.encoder', '1.0.0', makeEncoder()) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'broken': { @@ -430,7 +431,7 @@ test('materializeSinks errors when writer provides neither encoder nor table-for registerActivationContext('@hypaware/format-parquet', runtime) registerActivationContext('@hypaware/local-fs', runtime) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'broken': { @@ -454,7 +455,7 @@ test('materializeSinks errors when table-format destination has no blob-store', runtime.capabilities.provide('@hypaware/format-iceberg', 'hypaware.table-format', '1.0.0', makeTableFormatProvider()) runtime.capabilities.provide('@hypaware/format-parquet', 'hypaware.encoder', '1.0.0', makeEncoder()) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'broken': { @@ -478,7 +479,7 @@ test('materializeSinks errors when table-format encoder pin is not active', asyn runtime.capabilities.provide('@hypaware/format-iceberg', 'hypaware.table-format', '1.0.0', makeTableFormatProvider()) runtime.capabilities.provide('@hypaware/local-fs', 'hypaware.blob-store', '1.0.0', makeBlobStore()) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'broken': { @@ -498,7 +499,7 @@ test('materializeSinks errors when table-format encoder pin is not active', asyn test('materializeSinks continues past failures and reports all errors', async () => { const runtime = makeRuntime() - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'bad-a': { plugin: '@hypaware/central' }, @@ -519,7 +520,7 @@ test('materializeSinks destination contribution missing for blob sink', async () registerActivationContext('@hypaware/local-fs', runtime) runtime.capabilities.provide('@hypaware/format-parquet', 'hypaware.encoder', '1.0.0', makeEncoder()) - const config = /** @type {import('../../collectivus-plugin-kernel-types.d.ts').HypAwareV2Config} */ ({ + const config = /** @type {HypAwareV2Config} */ ({ version: 2, sinks: { 'no-contrib': { From 82643c4e5035504a1db57afb16dfbff0967298bb Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Tue, 26 May 2026 11:07:57 -0700 Subject: [PATCH 6/7] fix: address PR 35 diagnostic review feedback --- src/core/config/types.d.ts | 10 ++--- src/core/config/validate.js | 56 +++++++++++++++++++++++-- src/core/daemon/status.js | 4 +- test/core/config.test.js | 45 ++++++++++++++++++++ test/core/daemon.test.js | 84 ++++++++++++++++++++++++++++++++++++- 5 files changed, 187 insertions(+), 12 deletions(-) diff --git a/src/core/config/types.d.ts b/src/core/config/types.d.ts index d5505d4..8f968c8 100644 --- a/src/core/config/types.d.ts +++ b/src/core/config/types.d.ts @@ -51,18 +51,16 @@ export type ConfigValidationError = ValidationError & { errorKind: ConfigValidat * * - `client_without_gateway`: a client plugin (`@hypaware/claude` or * `@hypaware/codex`) is enabled but `@hypaware/ai-gateway` is not. - * - `gateway_missing_anthropic_upstream`: `@hypaware/claude` is enabled - * but no Anthropic upstream is registered with the gateway config. - * - `gateway_missing_openai_upstream`: `@hypaware/codex` is enabled - * but no OpenAI upstream is registered. + * - `gateway_missing_*_upstream`: a client plugin is enabled but the + * gateway config does not include one of its required upstream + * providers. * - `sink_missing_encoder`: a local-fs sink is configured but no * encoder plugin (`@hypaware/format-parquet` / * `@hypaware/format-jsonl`) is enabled. */ export type V1DiagnosticKind = | 'client_without_gateway' - | 'gateway_missing_anthropic_upstream' - | 'gateway_missing_openai_upstream' + | `gateway_missing_${string}_upstream` | 'sink_missing_encoder' export interface V1Diagnostic { diff --git a/src/core/config/validate.js b/src/core/config/validate.js index 706c48a..7027e3e 100644 --- a/src/core/config/validate.js +++ b/src/core/config/validate.js @@ -12,7 +12,6 @@ import { Attr, getLogger, withSpan } from '../observability/index.js' * @import { * ConfigValidationErrorKind, * ConfigValidationError, - * V1DiagnosticKind, * V1Diagnostic, * PluginMetadata, * ValidateContext, @@ -601,6 +600,55 @@ function runPerPluginSectionValidators(config, registry, errors) { const AI_GATEWAY_PLUGIN = /** @type {PluginName} */ ('@hypaware/ai-gateway') +/** + * Fallback client descriptors for first-party clients. `hyp status` + * uses catalog-derived descriptors when manifest discovery succeeds, + * but diagnostics should still catch common Claude/Codex wiring gaps + * when bundled manifest discovery is unavailable. + * + * @returns {Map} + */ +function firstPartyClientDescriptors() { + return new Map(/** @type {[string, ClientDescriptor][]} */ ([ + ['claude', { + plugin: /** @type {PluginName} */ ('@hypaware/claude'), + name: 'claude', + skillDir: '.claude/skills', + attachProbe: { + format: 'json', + settings_file: '.claude/settings.json', + marker_key: '_hypaware', + }, + requiredUpstreams: ['anthropic'], + }], + ['codex', { + plugin: /** @type {PluginName} */ ('@hypaware/codex'), + name: 'codex', + skillDir: '.codex/skills', + attachProbe: { + format: 'toml', + settings_file: '.codex/config.toml', + marker_header: '[model_providers.hypaware]', + }, + requiredUpstreams: ['openai', 'chatgpt'], + }], + ])) +} + +/** + * @param {Map | undefined} clientDescriptors + * @returns {Map} + */ +function clientDescriptorsWithFallback(clientDescriptors) { + const fallback = firstPartyClientDescriptors() + if (!clientDescriptors || clientDescriptors.size === 0) return fallback + const out = new Map(clientDescriptors) + for (const [name, descriptor] of fallback) { + if (!out.has(name)) out.set(name, descriptor) + } + return out +} + /** * Walk a v2 config and report Phase 8 V1 diagnostic findings. The * checks are advisory: they do not fail `hyp config validate` (so a @@ -628,7 +676,7 @@ export function diagnoseV1Config(config, ctx = {}) { const enabledByName = enabledPluginIndex(config) const gatewayConfig = enabledByName.get(AI_GATEWAY_PLUGIN) - const clientDescriptors = ctx.clientDescriptors ?? new Map() + const clientDescriptors = clientDescriptorsWithFallback(ctx.clientDescriptors) const knownPlugins = ctx.knownPlugins ?? firstPartyPluginMetadata() for (const [clientName, descriptor] of clientDescriptors) { @@ -650,13 +698,15 @@ export function diagnoseV1Config(config, ctx = {}) { } if (gatewayConfig !== undefined && descriptor.requiredUpstreams?.length) { + const primaryUpstream = descriptor.requiredUpstreams[0] + if (typeof primaryUpstream !== 'string' || primaryUpstream.length === 0) continue const hasAny = descriptor.requiredUpstreams.some((u) => gatewayHasUpstreamProvider(gatewayConfig, u) ) if (!hasAny) { const upstreamList = descriptor.requiredUpstreams.join(' or ') out.push({ - kind: /** @type {V1DiagnosticKind} */ (`gateway_missing_${descriptor.requiredUpstreams[0]}_upstream`), + kind: `gateway_missing_${primaryUpstream}_upstream`, pointer: pluginPointer(config, AI_GATEWAY_PLUGIN), message: `'${pluginName}' is enabled but the gateway has no ${upstreamList} upstream — ` + diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index 8481f12..51308f9 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -532,7 +532,7 @@ function readRetention(config) { * @param {{ descriptor: ClientDescriptor, homeDir: string, env?: NodeJS.ProcessEnv }} args * @returns {Promise<{ attached: boolean, settingsPath?: string, version?: string, port?: string, error?: string }>} */ -async function probeClientAttachFromDescriptor({ descriptor, homeDir, env }) { +export async function probeClientAttachFromDescriptor({ descriptor, homeDir, env }) { if (!homeDir || !descriptor.attachProbe) return { attached: false } const probe = descriptor.attachProbe const settingsPath = resolveClientSettingsPath(descriptor.name, probe.settings_file, env, homeDir) @@ -585,7 +585,7 @@ async function probeClientAttachFromDescriptor({ descriptor, homeDir, env }) { * @param {string} homeDir * @returns {string} */ -function resolveClientSettingsPath(clientName, settingsFile, env, homeDir) { +export function resolveClientSettingsPath(clientName, settingsFile, env, homeDir) { const envKey = `${clientName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_HOME` const override = env?.[envKey] if (typeof override === 'string' && override.length > 0) { diff --git a/test/core/config.test.js b/test/core/config.test.js index c3c8354..ebc2d60 100644 --- a/test/core/config.test.js +++ b/test/core/config.test.js @@ -184,6 +184,51 @@ test('diagnoseV1Config reports advisory product wiring gaps', async () => { ) }) +test('diagnoseV1Config falls back to first-party client descriptors', () => { + const withoutGateway = diagnoseV1Config({ + version: 2, + plugins: [{ name: '@hypaware/codex' }], + }) + assert.deepEqual(withoutGateway.map((diagnostic) => diagnostic.kind), ['client_without_gateway']) + + const missingUpstream = diagnoseV1Config({ + version: 2, + plugins: [ + { name: '@hypaware/ai-gateway', config: { upstreams: [] } }, + { name: '@hypaware/claude' }, + ], + }) + assert.deepEqual(missingUpstream.map((diagnostic) => diagnostic.kind), [ + 'gateway_missing_anthropic_upstream', + ]) +}) + +test('diagnoseV1Config emits descriptor-derived upstream diagnostic kinds', () => { + const diagnostics = diagnoseV1Config( + { + version: 2, + plugins: [ + { name: '@hypaware/ai-gateway', config: { upstreams: [] } }, + { name: '@third-party/gemini' }, + ], + }, + { + clientDescriptors: new Map([ + ['gemini', { + plugin: '@third-party/gemini', + name: 'gemini', + skillDir: '.gemini/skills', + requiredUpstreams: ['gemini'], + }], + ]), + } + ) + + assert.deepEqual(diagnostics.map((diagnostic) => diagnostic.kind), [ + 'gateway_missing_gemini_upstream', + ]) +}) + test('buildPluginCatalog derives capability metadata from bundled manifests', async () => { const bundled = await discoverBundledPlugins() const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) diff --git a/test/core/daemon.test.js b/test/core/daemon.test.js index 2bab21f..38814d9 100644 --- a/test/core/daemon.test.js +++ b/test/core/daemon.test.js @@ -8,11 +8,18 @@ import path from 'node:path' import { renderDaemonInstall } from '../../src/core/daemon/install.js' import { runDaemon } from '../../src/core/daemon/runtime.js' -import { readStatusFile, statusFilePath, writeStatusFile } from '../../src/core/daemon/status.js' +import { + probeClientAttachFromDescriptor, + readStatusFile, + resolveClientSettingsPath, + statusFilePath, + writeStatusFile, +} from '../../src/core/daemon/status.js' import { defaultConfigPath } from '../../src/core/config/schema.js' import { writeLock } from '../../src/core/plugin_install/lock.js' /** + * @import { ClientDescriptor } from '../../src/core/plugin_catalog.js' * @import { DaemonStatus } from '../../src/core/daemon/types.d.ts' */ @@ -43,6 +50,81 @@ test('readStatusFile returns null before the daemon has written status', async ( assert.equal(readStatusFile(tmp), null) }) +test('resolveClientSettingsPath sanitizes client env override names', () => { + assert.equal( + resolveClientSettingsPath( + 'claude-desktop', + '.claude-desktop/settings.json', + { CLAUDE_DESKTOP_HOME: '/tmp/claude-desktop-home' }, + '/Users/hyp' + ), + '/tmp/claude-desktop-home/settings.json' + ) + assert.equal( + resolveClientSettingsPath('codex', '.codex/config.toml', {}, '/Users/hyp'), + '/Users/hyp/.codex/config.toml' + ) +}) + +test('probeClientAttachFromDescriptor reads JSON attach markers', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-attach-json-')) + const settingsPath = path.join(tmp, '.claude', 'settings.json') + await fs.mkdir(path.dirname(settingsPath), { recursive: true }) + await fs.writeFile(settingsPath, JSON.stringify({ _hypaware: { version: '2.0.0', port: 4388 } })) + + const descriptor = /** @type {ClientDescriptor} */ ({ + plugin: '@hypaware/claude', + name: 'claude', + skillDir: '.claude/skills', + attachProbe: { + format: 'json', + settings_file: '.claude/settings.json', + marker_key: '_hypaware', + }, + }) + + assert.deepEqual( + await probeClientAttachFromDescriptor({ descriptor, homeDir: tmp }), + { + attached: true, + settingsPath, + version: '2.0.0', + port: '4388', + } + ) +}) + +test('probeClientAttachFromDescriptor honors sanitized TOML home overrides', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-attach-toml-')) + const overrideHome = path.join(tmp, 'claude-desktop-home') + const settingsPath = path.join(overrideHome, 'config.toml') + await fs.mkdir(overrideHome, { recursive: true }) + await fs.writeFile(settingsPath, '[hypaware.gateway]\n') + + const descriptor = /** @type {ClientDescriptor} */ ({ + plugin: '@third-party/claude-desktop', + name: 'claude-desktop', + skillDir: '.claude-desktop/skills', + attachProbe: { + format: 'toml', + settings_file: '.claude-desktop/config.toml', + marker_header: '[hypaware.gateway]', + }, + }) + + assert.deepEqual( + await probeClientAttachFromDescriptor({ + descriptor, + homeDir: tmp, + env: { CLAUDE_DESKTOP_HOME: overrideHome }, + }), + { + attached: true, + settingsPath, + } + ) +}) + test('renderDaemonInstall renders a deterministic systemd dry-run payload', () => { const plan = renderDaemonInstall({ platform: 'linux', From 9fc5948779445f497432958d791eff425139b08c Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Tue, 26 May 2026 11:20:17 -0700 Subject: [PATCH 7/7] fix: surface CLI sink materialization warnings --- src/core/cli/dispatch.js | 7 +++++- test/core/command-dispatch.test.js | 39 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/core/cli/dispatch.js b/src/core/cli/dispatch.js index 1ee8207..50c1558 100644 --- a/src/core/cli/dispatch.js +++ b/src/core/cli/dispatch.js @@ -116,10 +116,15 @@ export async function dispatch(argv, opts = {}) { activePlugins = boot.activePlugins if (boot.config) activeConfig = boot.config - await materializeSinks(kernel, boot.config, { + const sinkResult = await materializeSinks(kernel, boot.config, { stateRoot: path.join(obsEnv.hypHome, 'hypaware'), runId: env.DEV_RUN_ID ?? `cli-${process.pid}`, }) + for (const err of sinkResult.errors) { + stderr.write( + `warning: sink '${err.instance}' not materialized [${err.errorKind}]: ${err.message}\n` + ) + } } if (argv.length === 0) { diff --git a/test/core/command-dispatch.test.js b/test/core/command-dispatch.test.js index 42e2efd..5a42540 100644 --- a/test/core/command-dispatch.test.js +++ b/test/core/command-dispatch.test.js @@ -167,6 +167,45 @@ test('hidden Claude hook command is omitted from top-level help', async () => { assert.equal(stdout.text().includes('claude-hook'), false) }) +test('dispatch surfaces boot-path sink materialization warnings', async () => { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-dispatch-sink-warning-')) + const configPath = path.join(hypHome, 'hypaware-config.json') + await fs.writeFile(configPath, JSON.stringify({ + version: 2, + plugins: [{ name: '@hypaware/local-fs' }], + sinks: { + local: { + writer: '@hypaware/format-parquet', + destination: '@hypaware/local-fs', + }, + }, + })) + + const registry = createCommandRegistry() + registry.register({ + name: 'noop', + summary: 'Test command', + usage: 'hyp noop', + async run() { return 0 }, + }) + const stdout = makeBuf() + const stderr = makeBuf() + + const code = await dispatch(['noop'], { + stdout, + stderr, + registry, + env: { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: configPath }, + }) + + assert.equal(code, 0) + assert.equal(stdout.text(), '') + assert.match( + stderr.text(), + /warning: sink 'local' not materialized \[sink_plugin_not_active\]/ + ) +}) + test('attach accepts a positional client name', async () => { const { registry, kernel, calls } = fakeClientKernel() const stdout = makeBuf()