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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions collectivus-plugin-kernel-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export interface PluginProvides {
}

export interface PluginContributionManifest {
client?: PluginClientManifest
commands?: PluginCommandManifest[]
config_sections?: PluginConfigSectionManifest[]
sources?: PluginSourceManifest[]
Expand All @@ -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
Expand Down Expand Up @@ -318,6 +333,7 @@ export interface CapabilityRegistry {
require<T = unknown>(requester: PluginName, name: CapabilityName, range?: SemverRange): T
has(name: CapabilityName, range?: SemverRange): boolean
list(): CapabilityRegistration[]
fromProvider<T = unknown>(provider: PluginName | 'core', name: CapabilityName, range?: SemverRange): T | undefined
}

export interface CapabilityRegistration {
Expand Down
10 changes: 10 additions & 0 deletions hypaware-core/plugins-workspace/claude/hypaware.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down
162 changes: 162 additions & 0 deletions hypaware-core/plugins-workspace/claude/src/hook_command.js
Original file line number Diff line number Diff line change
@@ -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 <absolute-path>`
*
* 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 <absolute-path>\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<string, unknown>} */
let event
try {
const parsedEvent = JSON.parse(input || '{}')
event = parsedEvent && typeof parsedEvent === 'object' && !Array.isArray(parsedEvent)
? /** @type {Record<string, unknown>} */ (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<string, unknown>} */
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<string>}
*/
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<string | undefined>}
*/
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
}
9 changes: 9 additions & 0 deletions hypaware-core/plugins-workspace/claude/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 <absolute-path>',
hidden: true,
run: runClaudeSessionContextHook,
})

const skillsRoot = path.resolve(skillsRootDir(), 'skills')
for (const skillName of ['hypaware-query', 'hypaware-ignore', 'hypaware-unignore']) {
ctx.skills.register({
Expand Down
10 changes: 10 additions & 0 deletions hypaware-core/plugins-workspace/codex/hypaware.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
]
Expand Down
21 changes: 11 additions & 10 deletions hypaware-core/smoke/flows/cli_bundled_plugins_activated.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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({
Expand Down Expand Up @@ -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) &&
Expand Down Expand Up @@ -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')
)
Expand Down
59 changes: 45 additions & 14 deletions hypaware-core/smoke/flows/gascity_attach_writes_partition.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')]`
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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',
Expand Down
Loading