moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2026 Egzakta Group d.o.o. · waggle-os.ai
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Full license text: https://www.apache.org/licenses/LICENSE-2.0.txt

View File

@@ -0,0 +1,61 @@
# @hive-mind/shim-core
Foundation utilities for the [hive-mind-clients](https://github.com/marolinik/hive-mind-clients) cross-IDE silent capture shim portfolio.
This package is shared by every per-IDE shim (`@hive-mind/claude-code-hooks`, `@hive-mind/cursor-hooks`, etc.). It exposes typed interfaces, deterministic helpers, and a single CLI bridge to the hive-mind core — so each shim only has to translate IDE-native hook events into a common shape, then hand off to shim-core.
**Status**: pre-1.0, Wave 1 in development.
**Target**: Node >= 20, ESM-only.
**Peer dependency**: hive-mind-cli >= 0.1.0 (installed via `npm install -g @hive-mind/cli` or a local link).
## What's in here
| Module | Purpose |
|---|---|
| `frame-encoder` | Translate `HookEvent` -> `HookFrame` (the I/P/B-style payload that ships to hive-mind). |
| `workspace-resolver` | Pick the right `.mind` file: per-project marker if present, else `~/.hive-mind/global.mind`. |
| `cli-bridge` | The single chokepoint. Spawns `hive-mind-cli mcp call <tool> --args <json> --json` and returns typed results. |
| `hook-event-types` | Canonical `EventType`, `ShimSource`, `HookEvent` interfaces shared across shims. |
| `importance-classifier` | Pure-functional rules that map content -> `temporary` / `important` / `critical`. |
| `prompt-summarizer` | Deterministic extractive summarizer (no LLM call). Used by `Stop`-hook handlers to compress turns. |
| `retry-bridge` | Exponential backoff with jitter and per-attempt timeout. Wraps every CLI call. |
| `logger` | Zero-dep structured JSON logger. Writes to stderr so hook stdout stays clean. |
## Usage (from a shim)
```ts
import {
createCliBridge,
encodeFrame,
resolveWorkspace,
type HookEvent,
} from '@hive-mind/shim-core';
const bridge = createCliBridge();
const event: HookEvent = {
eventType: 'user-prompt-submit',
source: 'claude-code',
cwd: process.cwd(),
timestamp_iso: new Date().toISOString(),
payload: { content: 'How do I X?', session_id: 'abc-123' },
};
const workspace = await resolveWorkspace(event.cwd);
await bridge.switchWorkspace(workspace.path);
const frame = encodeFrame(event);
await bridge.saveMemory(frame);
```
## Bridge architecture
Every operation goes through one transport: `hive-mind-cli mcp call <tool> --args <json> --json --timeout-ms N`. That's a deliberate chokepoint:
- New MCP tools added upstream are reachable immediately via `bridge.callMcpTool('<new_tool>', args)`.
- Schema or arg changes in upstream MCP tools surface in one place, not 6 shims.
- Failure modes (timeout, exit non-zero, malformed JSON, `isError=true`) are normalized into thrown `Error`s by the bridge.
## License
Apache-2.0 — see [the repo root](https://github.com/marolinik/hive-mind-clients/blob/main/LICENSE) for the full text.

View File

@@ -0,0 +1,46 @@
{
"name": "@waggle/hive-mind-shim-core",
"version": "0.1.0",
"description": "Foundation utilities for hive-mind cross-IDE silent capture shims: frame encoding, workspace resolution, hive-mind-cli mcp call bridge, hook event types, importance classifier, deterministic prompt summarizer, retry-with-jitter, structured logger.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --build",
"build:clean": "tsc --build --clean",
"typecheck": "tsc --build && tsc --noEmit -p tsconfig.test.json",
"test": "npm run build --workspace @waggle/hive-mind-cli && node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/hive-mind-shim-core/tests",
"test:watch": "vitest"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/marolinik/waggle-os.git",
"directory": "packages/hive-mind-shim-core"
},
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-shim-core#readme",
"bugs": {
"url": "https://github.com/marolinik/waggle-os/issues"
},
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
"keywords": [
"hive-mind",
"memory",
"ai",
"mcp",
"shim",
"foundation"
],
"types": "dist/index.d.ts"
}

View File

@@ -0,0 +1,370 @@
/**
* Bridge to hive-mind-cli — uses `mcp call <tool>` for all MCP tools
* uniformly. Single chokepoint, no per-command CLI surface drift.
*
* Wire format: spawn `hive-mind-cli mcp call <tool> --args <json> --json`
* as a short-lived child process; parse stdout JSON. The CLI prints the
* McpCallResult shape (see hive-mind/packages/cli/src/commands/mcp-call.ts).
*
* IMPORTANT (Commit 1.4 — MCP surface alignment):
* - There is no `switch_workspace` MCP tool. Workspace targeting is
* per-call: `save_memory` and `recall_memory` accept a `workspace`
* argument naming a workspace id. The bridge tracks an "active
* workspace id" via `setWorkspaceById` so callers don't have to
* thread it through every call. Pass `undefined` to clear.
* - The MCP `save_memory.source` field is a four-value provenance
* enum (`'user_stated' | 'tool_verified' | 'agent_inferred' |
* 'system'`), NOT the IDE name. Hook captures use `'system'`.
* - `save_memory` does NOT accept `scope`, `parent`, or `metadata` —
* those fields on HookFrame are flattened into a content prefix
* by `frameToSavePayload` before reaching the wire.
* - `cleanup_frames` is the actual MCP tool name (was `compact_memory`
* in the original brief; alias removed in Commit 1.4).
*/
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
import { frameToSavePayload, type HookFrame, type SaveMemorySource } from './frame-encoder.js';
import { withRetry, type RetryOptions } from './retry-bridge.js';
import { createLogger, type Logger } from './logger.js';
export interface McpCallResultContent {
type: string;
text?: string;
[k: string]: unknown;
}
export interface McpCallResult {
ok: boolean;
tool: string;
content?: McpCallResultContent[];
isError?: boolean;
error?: string;
}
export type SpawnFn = (
command: string,
args: readonly string[],
options?: SpawnOptions,
) => ChildProcess;
export type { SaveMemorySource };
export interface CliBridgeOptions {
/** Path to (or PATH-resolved name of) the hive-mind-cli binary. Default 'hive-mind-cli'. */
cli_path?: string;
/** Default per-call timeout in ms. Default 5000. */
timeout_ms?: number;
/** Default retry count. Default 3. */
max_retries?: number;
/** Initial active workspace id (omit for personal mind). */
initial_workspace_id?: string;
/** Logger override. */
logger?: Logger;
/** Test hook — overrides child_process.spawn. */
spawnImpl?: SpawnFn;
}
export interface SaveMemoryResult {
/** Frame id as returned by the upstream save_memory tool, stringified. */
id: string;
success: boolean;
/** Workspace the frame was saved into ('personal' if no workspace was active). */
workspace: string;
}
/** Shape returned by `recall_memory` for a single hit. Matches upstream. */
export interface MemoryHit {
id: number;
content: string;
importance: string;
source: string;
score: number;
created_at: string;
/** 'personal' or `workspace:<id>` depending on origin. */
from: string;
}
export interface RecallMemoryOptions {
limit?: number;
/** Explicit workspace id; null forces personal memory even when a workspace is active. */
workspace?: string | null;
scope?: 'current' | 'personal' | 'all';
profile?: 'balanced' | 'recent' | 'important' | 'connected';
}
export interface CallMcpOptions {
timeoutMs?: number;
retry?: Partial<RetryOptions>;
}
export type CleanupMode = 'compact' | 'wipe_imports' | 'wipe_all' | 'reconcile';
export interface CleanupFramesOptions {
workspace?: string;
/** Default 'compact' (safe maintenance). 'wipe_all' is destructive. */
mode?: CleanupMode;
maxTempAgeDays?: number;
maxDeprecatedAgeDays?: number;
}
export interface CliBridge {
/** Generic escape hatch for any MCP tool not covered by a wrapper. */
callMcpTool<T = unknown>(
toolName: string,
args: Record<string, unknown>,
opts?: CallMcpOptions,
): Promise<T>;
/** Save a HookFrame; lossy fields (scope/parent) flatten into the content prefix. */
saveMemory(frame: HookFrame, opts?: { workspace?: string }): Promise<SaveMemoryResult>;
/** Hybrid-search recall against personal mind (default) or a named workspace. */
recallMemory(query: string, opts?: RecallMemoryOptions): Promise<MemoryHit[]>;
/** Trigger upstream's frame compaction maintenance pass. Default mode='compact'. */
cleanupFrames(opts?: CleanupFramesOptions): Promise<{ pruned: number }>;
/** Set / clear the active workspace id used by save+recall when caller doesn't specify. */
setWorkspaceById(workspaceId: string | undefined): void;
/** Read the active workspace id (undefined = personal mind). */
getActiveWorkspaceId(): string | undefined;
}
const DEFAULT_CLI_PATH = 'hive-mind-cli';
const DEFAULT_TIMEOUT_MS = 5000;
const DEFAULT_MAX_RETRIES = 3;
const SPAWN_GRACE_MS = 500;
interface CollectedOutput {
stdout: string;
stderr: string;
code: number;
}
interface SpawnTarget {
command: string;
args: readonly string[];
}
/**
* Resolve a cross-platform `(command, args)` tuple for invoking the
* hive-mind CLI. If `cliPath` ends with a JavaScript extension we
* route through Node directly (works everywhere, no shell quoting).
*
* Known limitation (Wave 1): on Windows, a bare `cli_path: 'hive-mind-cli'`
* will fail with ENOENT because npm's bin is a `.cmd` shim that requires
* a parent shell to launch. Workaround until Wave 1.5: pass an absolute
* path to `dist/index.js` via `cli_path` so we route through Node.
*/
function buildSpawnTarget(cliPath: string, args: readonly string[]): SpawnTarget {
if (cliPath.endsWith('.js') || cliPath.endsWith('.mjs') || cliPath.endsWith('.cjs')) {
return { command: process.execPath, args: [cliPath, ...args] };
}
return { command: cliPath, args };
}
function spawnAndCollect(
cliPath: string,
args: readonly string[],
timeoutMs: number,
spawnImpl: SpawnFn,
): Promise<CollectedOutput> {
return new Promise((resolve, reject) => {
let settled = false;
const target = buildSpawnTarget(cliPath, args);
const child = spawnImpl(target.command, target.args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { child.kill('SIGTERM'); } catch { /* already dead */ }
reject(new Error(`hive-mind-cli timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(err);
});
child.on('exit', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
code: code ?? 0,
});
});
});
}
function parseMcpCallOutput(stdout: string): McpCallResult {
const trimmed = stdout.trim();
if (!trimmed) {
return { ok: false, tool: '', error: 'empty CLI output' };
}
try {
return JSON.parse(trimmed) as McpCallResult;
} catch (err) {
return {
ok: false,
tool: '',
error: `failed to parse CLI JSON output: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
function unwrapTextContent(result: McpCallResult): string {
if (!result.content || result.content.length === 0) return '';
const first = result.content.find((c) => c.type === 'text' && typeof c.text === 'string');
return typeof first?.text === 'string' ? first.text : '';
}
function tryParseJson<T>(text: string): T | undefined {
if (!text) return undefined;
try {
return JSON.parse(text) as T;
} catch {
return undefined;
}
}
export function createCliBridge(opts: CliBridgeOptions = {}): CliBridge {
const cliPath = opts.cli_path ?? DEFAULT_CLI_PATH;
const defaultTimeout = opts.timeout_ms ?? DEFAULT_TIMEOUT_MS;
const defaultMaxRetries = opts.max_retries ?? DEFAULT_MAX_RETRIES;
const log = opts.logger ?? createLogger({ name: 'shim-core/cli-bridge' });
const spawnImpl: SpawnFn = opts.spawnImpl ?? (spawn as unknown as SpawnFn);
let activeWorkspaceId: string | undefined = opts.initial_workspace_id ?? workspaceIdFromEnvironment();
async function callMcpTool<T>(
toolName: string,
args: Record<string, unknown>,
callOpts: CallMcpOptions = {},
): Promise<T> {
const callTimeout = callOpts.timeoutMs ?? defaultTimeout;
const retryCfg: RetryOptions = {
maxRetries: defaultMaxRetries,
timeoutMs: callTimeout + SPAWN_GRACE_MS * 2,
...callOpts.retry,
};
return withRetry<T>(async () => {
const cliArgs = [
'mcp', 'call', toolName,
'--args', JSON.stringify(args),
'--json',
'--timeout-ms', String(callTimeout),
];
log.debug('hive-mind-cli mcp call', { tool: toolName, cliPath });
const { stdout, stderr, code } = await spawnAndCollect(
cliPath,
cliArgs,
callTimeout + SPAWN_GRACE_MS,
spawnImpl,
);
if (code !== 0) {
log.warn('hive-mind-cli exited non-zero', { code, stderr: stderr.slice(0, 500) });
throw new Error(`hive-mind-cli exited with code ${code}: ${stderr.slice(0, 200)}`);
}
const result = parseMcpCallOutput(stdout);
if (!result.ok) {
throw new Error(`mcp tool ${toolName} failed: ${result.error ?? 'unknown error'}`);
}
if (result.isError) {
throw new Error(`mcp tool ${toolName} reported isError: ${unwrapTextContent(result)}`);
}
const text = unwrapTextContent(result);
const parsed = tryParseJson<T>(text);
if (parsed !== undefined) return parsed;
return result as unknown as T;
}, retryCfg);
}
function setWorkspaceById(workspaceId: string | undefined): void {
activeWorkspaceId = workspaceId;
}
function getActiveWorkspaceId(): string | undefined {
return activeWorkspaceId;
}
async function saveMemory(
frame: HookFrame,
opts: { workspace?: string } = {},
): Promise<SaveMemoryResult> {
const payload = frameToSavePayload(frame);
const wireArgs: Record<string, unknown> = {
content: payload.content,
importance: payload.importance,
source: payload.source,
};
const targetWorkspace = opts.workspace ?? activeWorkspaceId;
if (targetWorkspace) wireArgs['workspace'] = targetWorkspace;
const result = await callMcpTool<{
id?: number | string;
workspace?: string;
}>('save_memory', wireArgs);
const rawId = result.id;
const id = typeof rawId === 'string' ? rawId : (rawId === undefined || rawId === null ? '' : String(rawId));
return {
id,
success: true,
workspace: result.workspace ?? targetWorkspace ?? 'personal',
};
}
async function recallMemory(
query: string,
recallOpts: RecallMemoryOptions = {},
): Promise<MemoryHit[]> {
const wireArgs: Record<string, unknown> = { query };
if (recallOpts.limit !== undefined) wireArgs['limit'] = recallOpts.limit;
const targetWorkspace = Object.hasOwn(recallOpts, 'workspace')
? recallOpts.workspace ?? undefined
: activeWorkspaceId;
if (targetWorkspace) wireArgs['workspace'] = targetWorkspace;
if (recallOpts.scope !== undefined) wireArgs['scope'] = recallOpts.scope;
if (recallOpts.profile !== undefined) wireArgs['profile'] = recallOpts.profile;
const raw = await callMcpTool<unknown>('recall_memory', wireArgs);
if (Array.isArray(raw)) {
return raw as MemoryHit[];
}
// Empty result case: upstream returns plain text "No memories found for query: ..."
// which our JSON parser falls through on, returning the raw McpCallResult.
return [];
}
async function cleanupFrames(
cleanupOpts: CleanupFramesOptions = {},
): Promise<{ pruned: number }> {
const wireArgs: Record<string, unknown> = {
mode: cleanupOpts.mode ?? 'compact',
};
const targetWorkspace = cleanupOpts.workspace ?? activeWorkspaceId;
if (targetWorkspace) wireArgs['workspace'] = targetWorkspace;
if (cleanupOpts.maxTempAgeDays !== undefined) wireArgs['max_temp_age_days'] = cleanupOpts.maxTempAgeDays;
if (cleanupOpts.maxDeprecatedAgeDays !== undefined) wireArgs['max_deprecated_age_days'] = cleanupOpts.maxDeprecatedAgeDays;
const result = await callMcpTool<{ pruned?: number; deleted?: number }>('cleanup_frames', wireArgs);
return { pruned: result.pruned ?? result.deleted ?? 0 };
}
return {
callMcpTool,
saveMemory,
recallMemory,
cleanupFrames,
setWorkspaceById,
getActiveWorkspaceId,
};
}
function workspaceIdFromEnvironment(): string | undefined {
const value = process.env.WAGGLE_WORKSPACE_ID?.trim();
if (!value || value.length > 200 || /[\0\r\n]/.test(value)) return undefined;
return value;
}

View File

@@ -0,0 +1,47 @@
import type { CliBridge, MemoryHit, RecallMemoryOptions } from './cli-bridge.js';
export interface RecallContextOptions {
limit: number;
workspaceId?: string;
profile?: RecallMemoryOptions['profile'];
}
/** Recall personal and current-workspace memory without ever widening to all workspaces. */
export async function recallPersonalAndWorkspace(
bridge: CliBridge,
query: string,
options: RecallContextOptions,
): Promise<MemoryHit[]> {
const limit = Math.max(1, Math.floor(options.limit));
const workspaceId = options.workspaceId ?? bridge.getActiveWorkspaceId();
const personal = bridge.recallMemory(query, {
limit,
scope: 'personal',
workspace: null,
...(options.profile ? { profile: options.profile } : {}),
});
const workspace = workspaceId
? bridge.recallMemory(query, {
limit,
scope: 'current',
workspace: workspaceId,
...(options.profile ? { profile: options.profile } : {}),
})
: Promise.resolve([]);
const [personalHits, workspaceHits] = await Promise.all([personal, workspace]);
return mergeRankedHits([...personalHits, ...workspaceHits], limit);
}
function mergeRankedHits(hits: MemoryHit[], limit: number): MemoryHit[] {
const ranked = [...hits].sort((left, right) => right.score - left.score);
const seen = new Set<string>();
const merged: MemoryHit[] = [];
for (const hit of ranked) {
const key = hit.content.trim().replace(/\s+/g, ' ').toLocaleLowerCase();
if (seen.has(key)) continue;
seen.add(key);
merged.push(hit);
if (merged.length >= limit) break;
}
return merged;
}

View File

@@ -0,0 +1,137 @@
/**
* Encode a HookEvent into a HookFrame, and flatten a HookFrame into
* the lossy SavePayload that survives the upstream save_memory wire.
*
* Frame model maps to hive-mind core:
* - temporary -> ephemeral chatter, decays fast
* - normal -> default for substantive content (upstream default)
* - important -> turns containing decisions / failures / actions
* - critical -> user-facing rules / preferences, retained indefinitely
*
* IMPORTANT (Commit 1.4): the upstream `save_memory` schema only accepts
* `{ content, importance, source, workspace }` — `scope`, `parent`, and
* `metadata` from HookFrame are not part of that schema. We preserve them
* as a tagged content prefix (`[session:X parent:Y src:claude-code event:Z]`)
* so a future search/recall can still attribute frames even though the
* upstream search index treats the prefix as plain text.
*/
import type { HookEvent, ShimSource } from './hook-event-types.js';
import { classifyImportance, type Importance } from './importance-classifier.js';
/**
* Provenance enum required by the upstream `save_memory.source` field.
* Distinct from `HookFrame.source` (which is the IDE name like
* 'claude-code'). Hook captures default to `'system'`.
*/
export type SaveMemorySource = 'user_stated' | 'tool_verified' | 'agent_inferred' | 'system';
export interface HookFrame {
content: string;
importance: Importance;
scope: string;
source: ShimSource;
parent?: string;
metadata: HookFrameMetadata;
}
export interface HookFrameMetadata {
cwd: string;
timestamp_iso: string;
event_type?: string;
project?: string;
target_version?: string;
}
export interface EncodeOptions {
/** Override the auto-classified importance. */
importance?: Importance;
/** Override scope (defaults to payload.session_id or 'default'). */
scope?: string;
/** Parent frame id (links responses to prompts). */
parent?: string;
/** Override extracted content (defaults to first non-empty payload field). */
content?: string;
}
const CONTENT_KEYS = ['content', 'text', 'prompt', 'response', 'message'] as const;
const SCOPE_KEYS = ['session_id', 'sessionId', 'session', 'scope'] as const;
const PROJECT_KEYS = ['project', 'workspace', 'project_name'] as const;
const VERSION_KEYS = ['target_version', 'targetVersion', 'version'] as const;
function pickString(payload: Record<string, unknown>, keys: readonly string[]): string | undefined {
for (const key of keys) {
const v = payload[key];
if (typeof v === 'string' && v.length > 0) return v;
}
return undefined;
}
export function encodeFrame(event: HookEvent, opts: EncodeOptions = {}): HookFrame {
const content = opts.content ?? pickString(event.payload, CONTENT_KEYS) ?? '';
const importance = opts.importance
?? classifyImportance(content, { eventType: event.eventType, source: event.source });
const scope = opts.scope ?? pickString(event.payload, SCOPE_KEYS) ?? 'default';
const project = pickString(event.payload, PROJECT_KEYS);
const target_version = pickString(event.payload, VERSION_KEYS);
const metadata: HookFrameMetadata = {
cwd: event.cwd,
timestamp_iso: event.timestamp_iso,
event_type: event.eventType,
};
if (project !== undefined) metadata.project = project;
if (target_version !== undefined) metadata.target_version = target_version;
const frame: HookFrame = {
content,
importance,
scope,
source: event.source,
metadata,
};
if (opts.parent !== undefined) frame.parent = opts.parent;
return frame;
}
/**
* The lossy projection of HookFrame onto upstream save_memory's schema.
* Fields that aren't in the schema (scope, parent, IDE source, event_type)
* are tagged into a content prefix so they survive as searchable text.
*/
export interface SavePayload {
content: string;
importance: Importance;
source: SaveMemorySource;
}
const SAVE_MEMORY_DEFAULT_SOURCE: SaveMemorySource = 'system';
function buildPrefix(frame: HookFrame): string {
const tokens: string[] = [];
if (frame.scope && frame.scope !== 'default') tokens.push(`session:${frame.scope}`);
if (frame.parent !== undefined) tokens.push(`parent:${frame.parent}`);
tokens.push(`src:${frame.source}`);
if (frame.metadata.event_type) tokens.push(`event:${frame.metadata.event_type}`);
if (frame.metadata.project) tokens.push(`project:${frame.metadata.project}`);
return tokens.length > 0 ? `[hm ${tokens.join(' ')}] ` : '';
}
/**
* Flatten a HookFrame for the save_memory wire. The IDE-provenance
* `frame.source` (e.g. 'claude-code') gets embedded in the content
* prefix, while the MCP-level `source` field is set to the provenance
* enum value passed in opts (default 'system' — hooks aren't user-stated
* or agent-inferred; they're system-captured).
*/
export function frameToSavePayload(
frame: HookFrame,
opts: { mcpSource?: SaveMemorySource } = {},
): SavePayload {
const prefix = buildPrefix(frame);
return {
content: prefix + frame.content,
importance: frame.importance,
source: opts.mcpSource ?? SAVE_MEMORY_DEFAULT_SOURCE,
};
}

View File

@@ -0,0 +1,61 @@
/**
* Canonical hook event surface — shared by every per-IDE shim.
*
* Each shim translates its IDE's native event into one of these types
* before handing off to shim-core encoders/bridges. This is the
* contract that keeps the rest of shim-core IDE-agnostic.
*/
export type ShimSource =
| 'claude-code'
| 'cursor'
| 'hermes'
| 'codex'
| 'opencode'
| 'openclaw';
export type EventType =
| 'session-start'
| 'session-end'
| 'user-prompt-submit'
| 'pre-compact'
| 'stop'
| 'pre-tool-use'
| 'post-tool-use';
export interface HookEvent {
eventType: EventType;
source: ShimSource;
cwd: string;
timestamp_iso: string;
payload: Record<string, unknown>;
}
export const ALL_EVENT_TYPES: readonly EventType[] = [
'session-start',
'session-end',
'user-prompt-submit',
'pre-compact',
'stop',
'pre-tool-use',
'post-tool-use',
] as const;
export const ALL_SOURCES: readonly ShimSource[] = [
'claude-code',
'cursor',
'hermes',
'codex',
'opencode',
'openclaw',
] as const;
export function isEventType(value: unknown): value is EventType {
return typeof value === 'string'
&& (ALL_EVENT_TYPES as readonly string[]).includes(value);
}
export function isShimSource(value: unknown): value is ShimSource {
return typeof value === 'string'
&& (ALL_SOURCES as readonly string[]).includes(value);
}

View File

@@ -0,0 +1,99 @@
/**
* Rule-based importance classifier. Pure-functional, no LLM calls.
*
* Four importance levels matching hive-mind core's Importance type:
* - temporary: ephemeral chatter explicitly marked for fast decay
* - normal: default for substantive content with no special signal
* (this is the upstream MCP default — was 'temporary' in
* pre-1.4 shim, raised to 'normal' in Commit 1.4 to align
* with the upstream save_memory schema)
* - important: turns containing decisions / failures / actions
* - critical: user-facing rules / preferences / "always"/"never"
* directives, retained indefinitely
*
* Higher importance wins when multiple rules match.
*/
export type Importance = 'temporary' | 'normal' | 'important' | 'critical';
export interface ImportanceRule {
pattern: RegExp | string;
importance: Importance;
reason: string;
}
export interface ClassifyContext {
eventType?: string;
source?: string;
}
const TIER: Record<Importance, number> = {
temporary: 0,
normal: 1,
important: 2,
critical: 3,
};
const CRITICAL_PATTERNS: readonly ImportanceRule[] = [
{ pattern: /\balways\b/i, importance: 'critical', reason: 'directive: always' },
{ pattern: /\bnever\b/i, importance: 'critical', reason: 'directive: never' },
{ pattern: /\bMEMORY\.md\b/i, importance: 'critical', reason: 'memory rule reference' },
{ pattern: /\bCLAUDE\.md\b/i, importance: 'critical', reason: 'rules document reference' },
{ pattern: /\b(my preference|i prefer|please always|please never)\b/i, importance: 'critical', reason: 'user preference' },
{ pattern: /\b(do not|don'?t)\s+(use|do|run|invoke|call)\b/i, importance: 'critical', reason: 'prohibition' },
];
const IMPORTANT_PATTERNS: readonly ImportanceRule[] = [
{ pattern: /\b(decided|decision|conclusion|resolved|chose)\b/i, importance: 'important', reason: 'decision statement' },
{ pattern: /\b(implement|fix|refactor|migrate|deploy|ship)\b/i, importance: 'important', reason: 'action verb' },
{ pattern: /\b(error|bug|issue|broke|broken|fails?|failed)\b/i, importance: 'important', reason: 'failure signal' },
{ pattern: /\bTODO\b|\bFIXME\b/, importance: 'important', reason: 'work marker' },
];
export const DEFAULT_RULES: readonly ImportanceRule[] = [
...CRITICAL_PATTERNS,
...IMPORTANT_PATTERNS,
];
function compilePattern(p: RegExp | string): RegExp {
return p instanceof RegExp ? p : new RegExp(p);
}
function applyRules(content: string, rules: readonly ImportanceRule[], floor: Importance): Importance {
let best: Importance = floor;
for (const rule of rules) {
const re = compilePattern(rule.pattern);
if (re.test(content) && TIER[rule.importance] > TIER[best]) {
best = rule.importance;
}
}
return best;
}
export function classifyImportance(
content: string,
context: ClassifyContext = {},
): Importance {
if (!content || content.trim().length === 0) {
return 'temporary';
}
// Session boundaries always retain at least 'important' importance:
// start/end establish project context that we don't want to decay.
// Everything else floors at 'normal' to align with the upstream
// save_memory default and avoid silent decay of substantive turns.
const floor: Importance = (context.eventType === 'session-start' || context.eventType === 'session-end')
? 'important'
: 'normal';
return applyRules(content, DEFAULT_RULES, floor);
}
export function classifyWithRules(
content: string,
rules: readonly ImportanceRule[],
fallback: Importance = 'normal',
): Importance {
if (!content || content.trim().length === 0) return fallback;
return applyRules(content, rules, fallback);
}

View File

@@ -0,0 +1,81 @@
/**
* @hive-mind/shim-core — barrel export.
*
* Foundation utilities for the cross-IDE silent-capture shim portfolio.
*/
export type {
HookEvent,
EventType,
ShimSource,
} from './hook-event-types.js';
export {
ALL_EVENT_TYPES,
ALL_SOURCES,
isEventType,
isShimSource,
} from './hook-event-types.js';
export type {
HookFrame,
HookFrameMetadata,
EncodeOptions,
SavePayload,
SaveMemorySource,
} from './frame-encoder.js';
export { encodeFrame, frameToSavePayload } from './frame-encoder.js';
export type {
Workspace,
WorkspaceMode,
ResolveOptions,
} from './workspace-resolver.js';
export { resolveWorkspace, isAbsoluteWorkspacePath } from './workspace-resolver.js';
export type {
CliBridge,
CliBridgeOptions,
McpCallResult,
McpCallResultContent,
SaveMemoryResult,
MemoryHit,
RecallMemoryOptions,
CleanupFramesOptions,
CleanupMode,
CallMcpOptions,
SpawnFn,
} from './cli-bridge.js';
export { createCliBridge } from './cli-bridge.js';
export type { RecallContextOptions } from './context-recall.js';
export { recallPersonalAndWorkspace } from './context-recall.js';
export type {
Importance,
ImportanceRule,
ClassifyContext,
} from './importance-classifier.js';
export {
classifyImportance,
classifyWithRules,
DEFAULT_RULES,
} from './importance-classifier.js';
export type { SummarizeOptions } from './prompt-summarizer.js';
export { summarizeTurn } from './prompt-summarizer.js';
export type { RetryOptions } from './retry-bridge.js';
export { withRetry, computeBackoff } from './retry-bridge.js';
export type { Logger, LogLevel, CreateLoggerOptions } from './logger.js';
export { createLogger } from './logger.js';
export type {
EmitSignalOptions,
EmittedSignal,
SignalSubtype,
SignalType,
} from './signal-emitter.js';
export {
emitSignalToWaggleDance,
maybeEmitDiscovery,
} from './signal-emitter.js';

View File

@@ -0,0 +1,76 @@
/**
* Zero-dependency structured logger. Emits one JSON object per line on
* stderr (so hook scripts can keep stdout clean for return values).
*
* Levels: debug=10, info=20, warn=30, error=40. Default level is 'info';
* override via constructor opts.level or env HIVE_MIND_SHIM_LOG_LEVEL.
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
function isLogLevel(value: string): value is LogLevel {
return value === 'debug' || value === 'info' || value === 'warn' || value === 'error';
}
function parseLevel(value: string | undefined): LogLevel | undefined {
if (!value) return undefined;
const normalized = value.toLowerCase();
return isLogLevel(normalized) ? normalized : undefined;
}
export interface Logger {
debug(msg: string, meta?: Record<string, unknown>): void;
info(msg: string, meta?: Record<string, unknown>): void;
warn(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, meta?: Record<string, unknown>): void;
}
export interface CreateLoggerOptions {
/** Optional component name attached to every log line. */
name?: string;
/** Filter threshold; defaults to env HIVE_MIND_SHIM_LOG_LEVEL or 'info'. */
level?: LogLevel;
/** Custom write target for tests; defaults to process.stderr.write. */
write?: (line: string) => void;
/** Override clock for deterministic tests. */
now?: () => Date;
}
export function createLogger(opts: CreateLoggerOptions = {}): Logger {
const name = opts.name ?? 'shim-core';
const envLevel = parseLevel(process.env['HIVE_MIND_SHIM_LOG_LEVEL']);
const level = opts.level ?? envLevel ?? 'info';
const threshold = LEVEL_ORDER[level];
const write = opts.write ?? ((line: string): void => { process.stderr.write(line); });
const now = opts.now ?? ((): Date => new Date());
function emit(lvl: LogLevel, msg: string, meta?: Record<string, unknown>): void {
if (LEVEL_ORDER[lvl] < threshold) return;
const entry: Record<string, unknown> = {
timestamp: now().toISOString(),
level: lvl,
name,
msg,
};
if (meta) {
for (const k of Object.keys(meta)) {
entry[k] = meta[k];
}
}
write(JSON.stringify(entry) + '\n');
}
return {
debug: (msg, meta): void => emit('debug', msg, meta),
info: (msg, meta): void => emit('info', msg, meta),
warn: (msg, meta): void => emit('warn', msg, meta),
error: (msg, meta): void => emit('error', msg, meta),
};
}

View File

@@ -0,0 +1,56 @@
/**
* Deterministic extractive turn summarizer. Pure text reduction; NO LLM.
*
* Strategy:
* 1. Replace fenced code blocks with the literal token "[code]" so a
* large embedded snippet doesn't blow the budget.
* 2. Collapse whitespace + newlines to a single space.
* 3. Split on sentence boundaries.
* 4. Take leading sentences until maxChars is consumed (with a
* one-char reservation for the ellipsis).
* 5. Append a single Unicode ellipsis if anything was dropped.
*
* This is intentionally NOT an LLM-grade summary. Its job is to make
* Stop-hook traffic small enough for `save_memory` while still carrying
* the leading sentence verbatim — the highest-signal part of any turn.
*/
export interface SummarizeOptions {
/** Maximum output length in characters. Defaults to 500. */
maxChars?: number;
}
const DEFAULT_MAX_CHARS = 500;
const ELLIPSIS = '…';
const SENTENCE_BOUNDARY = /(?<=[.!?])\s+(?=[A-Z([])/g;
export function summarizeTurn(content: string, opts: SummarizeOptions = {}): string {
const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
if (!content || content.trim().length === 0) return '';
const collapsed = content
.replace(/\r\n/g, '\n')
.replace(/```[\s\S]*?```/g, '[code]')
.replace(/\n+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (collapsed.length <= maxChars) return collapsed;
const sentences = collapsed.split(SENTENCE_BOUNDARY);
const parts: string[] = [];
let used = 0;
for (const s of sentences) {
const sep = parts.length === 0 ? 0 : 1;
if (used + s.length + sep > maxChars - 1) break;
parts.push(s);
used += s.length + sep;
}
if (parts.length === 0) {
return collapsed.slice(0, Math.max(0, maxChars - 1)) + ELLIPSIS;
}
const out = parts.join(' ');
return out.length < collapsed.length ? out + ELLIPSIS : out;
}

View File

@@ -0,0 +1,88 @@
/**
* Exponential-backoff retry with jitter and per-attempt timeout.
*
* Each attempt is wrapped in a Promise.race against a timeout. On
* failure, the helper sleeps backoff = clamp(base * 2^attempt, max)
* +/- jitterFactor * exp before retrying. Total attempts capped at
* (maxRetries + 1): one initial + N retries.
*/
export interface RetryOptions {
/** Number of retries after the initial attempt. Default 3 (so 4 attempts total). */
maxRetries?: number;
/** Base backoff in ms. Default 200. */
baseBackoffMs?: number;
/** Cap on per-attempt backoff. Default 5000. */
maxBackoffMs?: number;
/** Jitter as a fraction of the computed backoff (+/-). Default 0.25. */
jitterFactor?: number;
/** Per-attempt timeout in ms. Default 5000. */
timeoutMs?: number;
/** Test hook — overrides setTimeout-based delay. */
delay?: (ms: number) => Promise<void>;
/** Test hook — overrides Math.random for deterministic jitter. */
random?: () => number;
}
const DEFAULTS: Required<Pick<
RetryOptions,
'maxRetries' | 'baseBackoffMs' | 'maxBackoffMs' | 'jitterFactor' | 'timeoutMs'
>> = {
maxRetries: 3,
baseBackoffMs: 200,
maxBackoffMs: 5000,
jitterFactor: 0.25,
timeoutMs: 5000,
};
function defaultDelay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`operation timed out after ${ms}ms`));
}, ms);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(err) => { clearTimeout(timer); reject(err instanceof Error ? err : new Error(String(err))); },
);
});
}
export function computeBackoff(
attempt: number,
baseMs: number,
maxMs: number,
jitterFactor: number,
random: () => number = Math.random,
): number {
const exp = Math.min(baseMs * Math.pow(2, attempt), maxMs);
// random() in [0,1); shift to [-1,1) for symmetric jitter.
const jitter = exp * jitterFactor * (random() * 2 - 1);
return Math.max(0, Math.round(exp + jitter));
}
export async function withRetry<T>(
fn: () => Promise<T>,
opts: RetryOptions = {},
): Promise<T> {
const cfg = { ...DEFAULTS, ...opts };
const delay = opts.delay ?? defaultDelay;
const random = opts.random ?? Math.random;
let lastErr: unknown;
for (let attempt = 0; attempt <= cfg.maxRetries; attempt += 1) {
try {
return await withTimeout(fn(), cfg.timeoutMs);
} catch (err) {
lastErr = err;
if (attempt === cfg.maxRetries) break;
const backoff = computeBackoff(attempt, cfg.baseBackoffMs, cfg.maxBackoffMs, cfg.jitterFactor, random);
await delay(backoff);
}
}
if (lastErr instanceof Error) throw lastErr;
throw new Error(typeof lastErr === 'string' ? lastErr : 'retry failed without error message');
}

View File

@@ -0,0 +1,222 @@
/**
* AI-OS Phase 1D — Signal emitter shim.
*
* Hook packages (claude-code / cursor / claude-desktop / …) call
* `emitSignalToWaggleDance()` to push a WaggleDance v2 signal into
* the user's local Waggle sidecar. The sidecar persists the signal
* to its in-memory bus and re-emits it through the legacy UI stream
* so the WaggleDanceApp shows cross-tool activity in real time.
*
* Design contract:
*
* 1. **Fail open.** The hook chain must never block on a sidecar
* that's not running. Any error (ENOTFOUND, ECONNREFUSED,
* non-2xx, JSON parse) returns null and logs a single warning
* to stderr. The host AI tool (Claude Code, Cursor) never sees
* a hook failure.
*
* 2. **No external deps.** This module uses only Node 20's built-in
* `fetch` — no node-fetch / undici install. Hook packages run
* in the user's environment with whatever lockfile they have;
* we minimize footprint.
*
* 3. **Timeout-bounded.** A 2-second default timeout protects
* against a hung sidecar. The hook chain has its own outer
* timeout; this is defense in depth.
*
* 4. **Opt-in by default.** The emitter is a library function
* (not a side effect on import). Hooks must call it explicitly.
* Phase 1E wires the claude-code Stop hook to invoke this when
* the importance classifier returns 'high' or 'critical'.
*
* URL resolution:
*
* options.url > env.WAGGLE_SIDECAR_URL > http://127.0.0.1:3333
*
* The default port matches packages/launcher/src/cli.ts.
*/
import type { EventType } from './hook-event-types.js';
/** Allowed v2 subtypes — matches the protocol enum on the server. */
export type SignalSubtype =
| 'knowledge_check'
| 'task_delegation'
| 'skill_request'
| 'model_recommendation'
| 'knowledge_match'
| 'task_claim'
| 'discovery'
| 'routed_share'
| 'skill_share'
| 'model_recipe';
/** Allowed types — matches the protocol enum. */
export type SignalType = 'broadcast' | 'request' | 'response';
export interface EmitSignalOptions {
/** Top-level Waggle Dance protocol type. */
type: SignalType;
/** Protocol subtype. Must be valid for the chosen type. */
subtype: SignalSubtype;
/** Free-form structured content. The server preserves this verbatim. */
content: Record<string, unknown>;
/** Logical sender ('claude-code-hook', 'cursor-hook', …). Defaults to 'hook'. */
senderId?: string;
/** Optional team id. Defaults server-side to `personal::<senderId>`. */
teamId?: string;
/** Optional reference to a prior message (for responses). */
referenceId?: string;
/** Optional routing list (for routed_share). */
routing?: Array<{ userId: string; reason: string }>;
/**
* Override the sidecar URL. Resolution order:
* options.url > env.WAGGLE_SIDECAR_URL > http://127.0.0.1:3333
*/
url?: string;
/** Narrow collaboration credential. Defaults to env.WAGGLE_RUN_TOKEN. */
runToken?: string;
/** Override the request timeout (default 2000ms). */
timeoutMs?: number;
/**
* Test hook — injects a custom fetch implementation. Production
* uses Node 20's global fetch.
*/
fetchImpl?: typeof fetch;
/**
* Test hook — captures stderr lines instead of writing to
* process.stderr. Returns the emitted message.
*/
onWarn?: (message: string) => void;
}
export interface EmittedSignal {
/** Server-assigned message id (UUID). */
id: string;
/** Resolved team id (`personal::<senderId>` if not provided). */
teamId: string;
/** Resolved sender id. */
senderId: string;
/** Echoed type. */
type: SignalType;
/** Echoed subtype. */
subtype: SignalSubtype;
/** Echoed content. */
content: Record<string, unknown>;
/** Echoed referenceId. */
referenceId: string | null;
/** Echoed routing. */
routing: Array<{ userId: string; reason: string }> | null;
/** Server-assigned ISO timestamp. */
createdAt: string;
}
function resolveUrl(opts: EmitSignalOptions): string {
if (opts.url) return opts.url;
const fromEnv = typeof process !== 'undefined' && process.env
? process.env.WAGGLE_SIDECAR_URL
: undefined;
return fromEnv && fromEnv.length > 0 ? fromEnv : 'http://127.0.0.1:3333';
}
function resolveRunToken(opts: EmitSignalOptions): string | undefined {
const value = opts.runToken ?? (
typeof process !== 'undefined' && process.env ? process.env.WAGGLE_RUN_TOKEN : undefined
);
if (!value || value.length < 32 || value.length > 200 || /[\r\n]/.test(value)) return undefined;
return value;
}
function warn(opts: EmitSignalOptions, message: string): void {
if (opts.onWarn) {
opts.onWarn(message);
return;
}
try {
process.stderr.write(`[waggle-signal-emitter] ${message}\n`);
} catch {
/* nothing we can do; bail */
}
}
/**
* POST a v2 signal to the local Waggle sidecar.
*
* Returns the server-persisted signal on success, or null on any
* failure (network, timeout, non-2xx, parse). Always fail open —
* the host AI tool's hook chain must continue.
*/
export async function emitSignalToWaggleDance(
opts: EmitSignalOptions,
): Promise<EmittedSignal | null> {
const url = resolveUrl(opts);
const timeoutMs = opts.timeoutMs ?? 2000;
const fetchFn = opts.fetchImpl ?? fetch;
const runToken = resolveRunToken(opts);
const body = {
type: opts.type,
subtype: opts.subtype,
content: opts.content,
senderId: opts.senderId ?? 'hook',
...(opts.teamId !== undefined ? { teamId: opts.teamId } : {}),
...(opts.referenceId !== undefined ? { referenceId: opts.referenceId } : {}),
...(opts.routing !== undefined ? { routing: opts.routing } : {}),
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetchFn(`${url}/api/waggle-dance/signal`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(runToken ? { 'x-waggle-run-token': runToken } : {}),
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
warn(opts, `non-2xx response (${res.status}) — signal dropped`);
return null;
}
const json = (await res.json()) as { message?: unknown };
if (!json || typeof json !== 'object' || !('message' in json) || !json.message) {
warn(opts, 'malformed response body — signal dropped');
return null;
}
return json.message as EmittedSignal;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
warn(opts, `sidecar unreachable (${reason}) — signal dropped`);
return null;
} finally {
clearTimeout(timer);
}
}
/**
* Convenience: classify a hook lifecycle event and decide whether
* to emit a discovery signal. Returns the emitted signal or null
* (no emission necessary, or emission failed).
*
* The default policy is: emit on `stop` and `pre-compact` if
* `importance` is 'high' or 'critical'. Hook packages override
* with their own policy when they need to emit other subtypes.
*/
export async function maybeEmitDiscovery(
eventType: EventType,
importance: 'low' | 'normal' | 'high' | 'critical',
payload: Record<string, unknown>,
opts: Omit<EmitSignalOptions, 'type' | 'subtype' | 'content'>,
): Promise<EmittedSignal | null> {
if (importance !== 'high' && importance !== 'critical') return null;
if (eventType !== 'stop' && eventType !== 'pre-compact') return null;
return emitSignalToWaggleDance({
...opts,
type: 'broadcast',
subtype: 'discovery',
content: { ...payload, eventType, importance },
});
}

View File

@@ -0,0 +1,85 @@
/**
* Resolve which .mind file a hook event should write to.
*
* Resolution rules:
* 1. If `<cwd>/.hive-mind/workspace.mind` exists, return per-project
* mode using that file.
* 2. Else, walk up looking for `.hive-mind/workspace.mind` (project
* root marker).
* 3. Else, fall back to global mode at `~/.hive-mind/global.mind`.
*
* The candidate path is constructed from controlled inputs (resolved
* cwd + hardcoded `.hive-mind/workspace.mind`), so there is no path-
* injection vector through this function. Defense-in-depth against
* malicious symlinks pointing outside the workspace is the caller's
* responsibility (e.g. via fs.realpath if the threat model requires).
*/
import { access, constants } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve as pathResolve } from 'node:path';
export type WorkspaceMode = 'global' | 'per-project';
export interface Workspace {
path: string;
cwd: string;
mode: WorkspaceMode;
}
export interface ResolveOptions {
/** Override $HOME for tests. */
home?: string;
/** Custom existence probe; defaults to fs.access. */
exists?: (p: string) => Promise<boolean>;
/** Whether to walk up the directory tree to find a project marker. Default true. */
walkUp?: boolean;
}
const HIVE_DIR = '.hive-mind';
const PROJECT_DB = 'workspace.mind';
const GLOBAL_DB = 'global.mind';
async function defaultExists(p: string): Promise<boolean> {
try {
await access(p, constants.F_OK);
return true;
} catch {
return false;
}
}
export async function resolveWorkspace(
cwd?: string,
opts: ResolveOptions = {},
): Promise<Workspace> {
const startCwd = pathResolve(cwd ?? process.cwd());
const home = opts.home ?? homedir();
const exists = opts.exists ?? defaultExists;
const walkUp = opts.walkUp ?? true;
if (walkUp) {
let dir = startCwd;
while (true) {
const candidate = join(dir, HIVE_DIR, PROJECT_DB);
if (await exists(candidate)) {
return { path: candidate, cwd: startCwd, mode: 'per-project' };
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
} else {
const candidate = join(startCwd, HIVE_DIR, PROJECT_DB);
if (await exists(candidate)) {
return { path: candidate, cwd: startCwd, mode: 'per-project' };
}
}
const globalPath = join(home, HIVE_DIR, GLOBAL_DB);
return { path: globalPath, cwd: startCwd, mode: 'global' };
}
export function isAbsoluteWorkspacePath(p: string): boolean {
return isAbsolute(p);
}

View File

@@ -0,0 +1,292 @@
import { describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { createCliBridge, type SpawnFn } from '../src/cli-bridge.js';
import type { ChildProcess } from 'node:child_process';
import type { HookFrame } from '../src/frame-encoder.js';
interface MockChildOptions {
stdout?: string;
stderr?: string;
exitCode?: number;
emitError?: Error;
delayMs?: number;
}
interface MockSpawnRecord {
command: string;
args: readonly string[];
}
function mockChild(opts: MockChildOptions = {}): ChildProcess {
const emitter = new EventEmitter();
const stdout = Readable.from([Buffer.from(opts.stdout ?? '')]);
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
const child = Object.assign(emitter, {
stdout,
stderr,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
setImmediate(() => {
if (opts.emitError) {
emitter.emit('error', opts.emitError);
return;
}
if (opts.delayMs && opts.delayMs > 0) {
setTimeout(() => emitter.emit('exit', opts.exitCode ?? 0), opts.delayMs);
return;
}
emitter.emit('exit', opts.exitCode ?? 0);
});
return child;
}
function makeSpawnImpl(records: MockSpawnRecord[], childOpts: MockChildOptions): SpawnFn {
return ((command, args) => {
records.push({ command, args });
return mockChild(childOpts);
}) as SpawnFn;
}
function jsonResultEnvelope(payload: unknown, opts: { isError?: boolean } = {}): string {
const result = {
ok: true,
tool: 'test_tool',
content: [{ type: 'text', text: JSON.stringify(payload) }],
isError: opts.isError ?? false,
};
return JSON.stringify(result, null, 2);
}
function plainTextEnvelope(text: string): string {
return JSON.stringify({
ok: true,
tool: 'recall_memory',
content: [{ type: 'text', text }],
isError: false,
}, null, 2);
}
const SAMPLE_FRAME: HookFrame = {
content: 'hello world',
importance: 'normal',
scope: 'sess-7',
source: 'claude-code',
metadata: {
cwd: '/proj',
timestamp_iso: '2026-04-28T10:00:00.000Z',
event_type: 'user-prompt-submit',
},
};
describe('createCliBridge.callMcpTool', () => {
it('spawns hive-mind-cli with mcp call args and parses success', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 1 }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const result = await bridge.callMcpTool<{ id: number }>('save_memory', { content: 'x' });
expect(result.id).toBe(1);
expect(records).toHaveLength(1);
expect(records[0].command).toBe('hive-mind-cli');
expect(records[0].args).toEqual([
'mcp', 'call', 'save_memory',
'--args', JSON.stringify({ content: 'x' }),
'--json',
'--timeout-ms', '5000',
]);
});
it('honours custom cli_path', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ ok: 1 }) });
const bridge = createCliBridge({ spawnImpl, cli_path: '/usr/local/bin/hmc', max_retries: 0 });
await bridge.callMcpTool('any_tool', {});
expect(records[0].command).toBe('/usr/local/bin/hmc');
});
it('throws when CLI exits non-zero', async () => {
const spawnImpl = makeSpawnImpl([], {
stdout: '',
stderr: 'boom',
exitCode: 2,
});
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.callMcpTool('save_memory', {})).rejects.toThrow(/exited with code 2/);
});
it('throws when stdout is malformed JSON', async () => {
const spawnImpl = makeSpawnImpl([], { stdout: 'not-json{', exitCode: 0 });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.callMcpTool('any', {})).rejects.toThrow(/failed to parse CLI JSON output/);
});
it('throws when result.ok is false', async () => {
const stdout = JSON.stringify({ ok: false, tool: 'save_memory', error: 'tool missing' });
const spawnImpl = makeSpawnImpl([], { stdout });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.callMcpTool('save_memory', {})).rejects.toThrow(/save_memory failed/);
});
it('throws when result.isError is true (tool-reported error)', async () => {
const stdout = JSON.stringify({
ok: true,
tool: 'recall_memory',
isError: true,
content: [{ type: 'text', text: 'no such workspace' }],
});
const spawnImpl = makeSpawnImpl([], { stdout });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.callMcpTool('recall_memory', {})).rejects.toThrow(/no such workspace/);
});
it('returns raw McpCallResult when content text is not JSON-parseable', async () => {
const stdout = JSON.stringify({
ok: true,
tool: 'plain',
content: [{ type: 'text', text: 'human-readable output' }],
isError: false,
});
const spawnImpl = makeSpawnImpl([], { stdout });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const result = await bridge.callMcpTool<{ ok: boolean; content?: unknown[] }>('plain', {});
expect(result.ok).toBe(true);
expect(Array.isArray(result.content)).toBe(true);
});
});
describe('createCliBridge.saveMemory (Commit 1.4 wire format)', () => {
it('passes only content + importance + source to save_memory; embeds scope/parent/source/event in content prefix', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 9, workspace: 'personal' }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const result = await bridge.saveMemory(SAMPLE_FRAME);
expect(result).toEqual({ id: '9', success: true, workspace: 'personal' });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(Object.keys(wireArgs).sort()).toEqual(['content', 'importance', 'source']);
expect(wireArgs['source']).toBe('system');
expect(wireArgs['importance']).toBe('normal');
expect(wireArgs['content']).toContain('[hm session:sess-7 src:claude-code event:user-prompt-submit] ');
expect(wireArgs['content']).toContain('hello world');
});
it('includes workspace arg when active workspace id is set', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 1, workspace: 'team-foo' }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
bridge.setWorkspaceById('team-foo');
await bridge.saveMemory(SAMPLE_FRAME);
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(wireArgs['workspace']).toBe('team-foo');
});
it('per-call workspace override beats setWorkspaceById', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 2 }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
bridge.setWorkspaceById('default-ws');
await bridge.saveMemory(SAMPLE_FRAME, { workspace: 'override-ws' });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(wireArgs['workspace']).toBe('override-ws');
});
});
describe('createCliBridge.recallMemory', () => {
it('returns MemoryHit[] when upstream replies with a JSON array', async () => {
const hits = [{
id: 1,
content: 'past',
importance: 'normal',
source: 'system',
score: 0.91,
created_at: '2026-04-28T10:00:00.000Z',
from: 'personal',
}];
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope(hits) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const out = await bridge.recallMemory('past');
expect(out).toHaveLength(1);
expect(out[0].score).toBe(0.91);
});
it('returns [] when upstream responds with the "No memories found" plain-text envelope', async () => {
const spawnImpl = makeSpawnImpl([], { stdout: plainTextEnvelope('No memories found for query: "test"') });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const out = await bridge.recallMemory('test');
expect(out).toEqual([]);
});
it('passes query + limit + scope + profile through wire args', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: plainTextEnvelope('No memories found for query: "x"') });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await bridge.recallMemory('x', { limit: 5, scope: 'all', profile: 'recent' });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(wireArgs).toMatchObject({ query: 'x', limit: 5, scope: 'all', profile: 'recent' });
});
it('can force personal recall while a workspace is active', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope([]) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0, initial_workspace_id: 'workspace-a' });
await bridge.recallMemory('', { scope: 'personal', workspace: null });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(wireArgs).toEqual({ query: '', scope: 'personal' });
});
});
describe('createCliBridge.cleanupFrames', () => {
it('calls cleanup_frames and returns pruned count', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ pruned: 7 }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const out = await bridge.cleanupFrames();
expect(records[0].args[2]).toBe('cleanup_frames');
expect(out.pruned).toBe(7);
});
it('falls back to .deleted alias if upstream uses that field name', async () => {
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({ deleted: 3 }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const out = await bridge.cleanupFrames();
expect(out.pruned).toBe(3);
});
});
describe('createCliBridge workspace state', () => {
it('setWorkspaceById + getActiveWorkspaceId round-trip', () => {
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({}) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
expect(bridge.getActiveWorkspaceId()).toBeUndefined();
bridge.setWorkspaceById('foo');
expect(bridge.getActiveWorkspaceId()).toBe('foo');
bridge.setWorkspaceById(undefined);
expect(bridge.getActiveWorkspaceId()).toBeUndefined();
});
it('initial_workspace_id seeds the active id', () => {
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({}) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0, initial_workspace_id: 'startup-ws' });
expect(bridge.getActiveWorkspaceId()).toBe('startup-ws');
});
it('seeds the active workspace from WAGGLE_WORKSPACE_ID', () => {
const previous = process.env.WAGGLE_WORKSPACE_ID;
process.env.WAGGLE_WORKSPACE_ID = 'workspace-from-launch';
try {
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({}) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
expect(bridge.getActiveWorkspaceId()).toBe('workspace-from-launch');
} finally {
if (previous === undefined) delete process.env.WAGGLE_WORKSPACE_ID;
else process.env.WAGGLE_WORKSPACE_ID = previous;
}
});
});

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import type { CliBridge, MemoryHit } from '../src/cli-bridge.js';
import { recallPersonalAndWorkspace } from '../src/context-recall.js';
const personal: MemoryHit = {
id: 1, content: 'personal preference', importance: 'important', source: 'system',
score: 0.7, created_at: '2026-01-01T00:00:00.000Z', from: 'personal',
};
const workspace: MemoryHit = {
id: 2, content: 'workspace decision', importance: 'important', source: 'system',
score: 0.9, created_at: '2026-01-02T00:00:00.000Z', from: 'workspace:alpha',
};
function bridge(activeWorkspaceId?: string): CliBridge & { recallMemory: ReturnType<typeof vi.fn> } {
const recallMemory = vi.fn(async (_query: string, options?: { workspace?: string | null }) => {
return options?.workspace === null ? [personal] : [workspace, { ...personal, id: 99 }];
});
return {
recallMemory,
getActiveWorkspaceId: () => activeWorkspaceId,
} as unknown as CliBridge & { recallMemory: ReturnType<typeof vi.fn> };
}
describe('recallPersonalAndWorkspace', () => {
it('recalls only personal plus the selected workspace, ranks, and deduplicates', async () => {
const value = bridge('alpha');
const hits = await recallPersonalAndWorkspace(value, 'decision', { limit: 5 });
expect(value.recallMemory.mock.calls).toEqual([
['decision', { limit: 5, scope: 'personal', workspace: null }],
['decision', { limit: 5, scope: 'current', workspace: 'alpha' }],
]);
expect(hits).toEqual([workspace, personal]);
expect(value.recallMemory.mock.calls.flat().join(' ')).not.toContain("scope: 'all'");
});
it('does not query another workspace when no workspace is active', async () => {
const value = bridge();
const hits = await recallPersonalAndWorkspace(value, '', { limit: 1 });
expect(value.recallMemory).toHaveBeenCalledTimes(1);
expect(hits).toEqual([personal]);
});
});

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { encodeFrame, frameToSavePayload, type HookFrame } from '../src/frame-encoder.js';
import type { HookEvent } from '../src/hook-event-types.js';
function event(overrides: Partial<HookEvent> = {}): HookEvent {
return {
eventType: 'user-prompt-submit',
source: 'claude-code',
cwd: '/proj/foo',
timestamp_iso: '2026-04-28T10:00:00.000Z',
payload: {},
...overrides,
};
}
describe('encodeFrame', () => {
it('encodes a basic event with content from payload.content', () => {
const frame = encodeFrame(event({ payload: { content: 'hi there' } }));
expect(frame.content).toBe('hi there');
expect(frame.source).toBe('claude-code');
expect(frame.metadata.cwd).toBe('/proj/foo');
expect(frame.metadata.event_type).toBe('user-prompt-submit');
expect(frame.metadata.timestamp_iso).toBe('2026-04-28T10:00:00.000Z');
});
it('falls back to payload.text / payload.prompt / payload.message', () => {
expect(encodeFrame(event({ payload: { text: 't' } })).content).toBe('t');
expect(encodeFrame(event({ payload: { prompt: 'p' } })).content).toBe('p');
expect(encodeFrame(event({ payload: { message: 'm' } })).content).toBe('m');
});
it('classifies importance from content by default', () => {
expect(encodeFrame(event({ payload: { content: 'always run lint' } })).importance).toBe('critical');
expect(encodeFrame(event({ payload: { content: 'we decided X' } })).importance).toBe('important');
// Commit 1.4: substantive default raised from 'temporary' to 'normal'.
expect(encodeFrame(event({ payload: { content: 'hello' } })).importance).toBe('normal');
});
it('importance override beats classifier', () => {
const frame = encodeFrame(event({ payload: { content: 'hello' } }), { importance: 'critical' });
expect(frame.importance).toBe('critical');
});
it('extracts scope from payload.session_id (or aliases)', () => {
expect(encodeFrame(event({ payload: { session_id: 'abc' } })).scope).toBe('abc');
expect(encodeFrame(event({ payload: { sessionId: 'def' } })).scope).toBe('def');
});
it('falls back to scope="default" when no session id is present', () => {
expect(encodeFrame(event({ payload: {} })).scope).toBe('default');
});
it('scope option overrides payload-derived scope', () => {
const frame = encodeFrame(event({ payload: { session_id: 'a' } }), { scope: 'b' });
expect(frame.scope).toBe('b');
});
it('includes optional metadata only when present', () => {
const withProj = encodeFrame(event({ payload: { content: 'x', project: 'waggle' } }));
expect(withProj.metadata.project).toBe('waggle');
const withoutProj = encodeFrame(event({ payload: { content: 'x' } }));
expect(withoutProj.metadata.project).toBeUndefined();
});
it('attaches parent id when supplied', () => {
const frame = encodeFrame(event({ payload: { content: 'x' } }), { parent: 'frame-7' });
expect(frame.parent).toBe('frame-7');
});
it('omits parent when not supplied', () => {
const frame = encodeFrame(event({ payload: { content: 'x' } }));
expect(frame.parent).toBeUndefined();
});
it('content option overrides payload extraction', () => {
const frame = encodeFrame(event({ payload: { content: 'original' } }), { content: 'override' });
expect(frame.content).toBe('override');
});
});
describe('frameToSavePayload (Commit 1.4)', () => {
function makeFrame(overrides: Partial<HookFrame> = {}): HookFrame {
return {
content: 'the actual content',
importance: 'normal',
scope: 'sess-123',
source: 'claude-code',
metadata: {
cwd: '/proj',
timestamp_iso: '2026-04-28T10:00:00.000Z',
event_type: 'user-prompt-submit',
},
...overrides,
};
}
it('embeds session/source/event in a content prefix and defaults source to "system"', () => {
const out = frameToSavePayload(makeFrame());
expect(out.content).toBe('[hm session:sess-123 src:claude-code event:user-prompt-submit] the actual content');
expect(out.importance).toBe('normal');
expect(out.source).toBe('system');
});
it('drops session token when scope is "default"', () => {
const out = frameToSavePayload(makeFrame({ scope: 'default' }));
expect(out.content).not.toContain('session:');
expect(out.content).toContain('src:claude-code');
});
it('includes parent token when present', () => {
const out = frameToSavePayload(makeFrame({ parent: 'frame-99' }));
expect(out.content).toContain('parent:frame-99');
});
it('includes project token from metadata when present', () => {
const out = frameToSavePayload(makeFrame({ metadata: {
cwd: '/proj',
timestamp_iso: '2026-04-28T10:00:00.000Z',
event_type: 'stop',
project: 'waggle-os',
}}));
expect(out.content).toContain('project:waggle-os');
});
it('mcpSource override is honoured', () => {
const out = frameToSavePayload(makeFrame(), { mcpSource: 'agent_inferred' });
expect(out.source).toBe('agent_inferred');
});
it('omits prefix entirely when no metadata tokens would be added', () => {
const minimalFrame: HookFrame = {
content: 'plain',
importance: 'normal',
scope: 'default',
source: 'claude-code',
metadata: { cwd: '/p', timestamp_iso: 't' }, // no event_type, no project
};
const out = frameToSavePayload(minimalFrame);
// src token is always present, so we still get a prefix:
expect(out.content).toBe('[hm src:claude-code] plain');
});
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
ALL_EVENT_TYPES,
ALL_SOURCES,
isEventType,
isShimSource,
} from '../src/hook-event-types.js';
describe('hook-event-types', () => {
it('ALL_EVENT_TYPES contains the 7 canonical events', () => {
expect([...ALL_EVENT_TYPES].sort()).toEqual([
'post-tool-use',
'pre-compact',
'pre-tool-use',
'session-end',
'session-start',
'stop',
'user-prompt-submit',
]);
});
it('ALL_SOURCES contains the 6 supported IDEs', () => {
expect([...ALL_SOURCES].sort()).toEqual([
'claude-code',
'codex',
'cursor',
'hermes',
'openclaw',
'opencode',
]);
});
it('isEventType narrows known strings', () => {
expect(isEventType('session-start')).toBe(true);
expect(isEventType('user-prompt-submit')).toBe(true);
expect(isEventType('pre-tool-use')).toBe(true);
});
it('isEventType rejects unknown / non-string values', () => {
expect(isEventType('made-up-event')).toBe(false);
expect(isEventType('')).toBe(false);
expect(isEventType(null)).toBe(false);
expect(isEventType(42)).toBe(false);
expect(isEventType({ eventType: 'session-start' })).toBe(false);
});
it('isShimSource narrows known and rejects unknown', () => {
expect(isShimSource('claude-code')).toBe(true);
expect(isShimSource('codex')).toBe(true);
expect(isShimSource('not-a-real-ide')).toBe(false);
expect(isShimSource(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
classifyImportance,
classifyWithRules,
DEFAULT_RULES,
} from '../src/importance-classifier.js';
describe('classifyImportance — default rules', () => {
it('"always" pattern is critical', () => {
expect(classifyImportance('always run lint before commit')).toBe('critical');
});
it('"never" pattern is critical', () => {
expect(classifyImportance('never commit secrets')).toBe('critical');
});
it('MEMORY.md reference is critical', () => {
expect(classifyImportance('see MEMORY.md for the full list')).toBe('critical');
});
it('CLAUDE.md reference is critical', () => {
expect(classifyImportance('this rule is documented in CLAUDE.md')).toBe('critical');
});
it('"do not use X" prohibition is critical', () => {
expect(classifyImportance('do not use the legacy adapter')).toBe('critical');
expect(classifyImportance("don't run that command")).toBe('critical');
});
it('decision verbs land at important', () => {
expect(classifyImportance('we decided to use Postgres')).toBe('important');
expect(classifyImportance('chose Option A over B')).toBe('important');
});
it('action verbs land at important', () => {
expect(classifyImportance('implement the auth middleware')).toBe('important');
expect(classifyImportance('refactor the bridge module')).toBe('important');
});
it('failure signals land at important', () => {
expect(classifyImportance('the build failed on Windows')).toBe('important');
});
it('TODO / FIXME markers land at important', () => {
expect(classifyImportance('TODO: wire up retry')).toBe('important');
expect(classifyImportance('FIXME parameter is wrong')).toBe('important');
});
it('plain chatter floors at "normal" (Commit 1.4 — was "temporary")', () => {
expect(classifyImportance('hi there')).toBe('normal');
expect(classifyImportance('thanks')).toBe('normal');
});
it('empty / whitespace-only input is still temporary (early return short-circuits floor)', () => {
expect(classifyImportance('')).toBe('temporary');
expect(classifyImportance(' \n\t')).toBe('temporary');
});
it('session-start / session-end events floor at important', () => {
expect(classifyImportance('hi', { eventType: 'session-start' })).toBe('important');
expect(classifyImportance('thanks', { eventType: 'session-end' })).toBe('important');
});
it('critical patterns still beat the session-start floor', () => {
expect(classifyImportance('always test before push', { eventType: 'session-start' })).toBe('critical');
});
});
describe('classifyWithRules', () => {
it('returns fallback when content is empty', () => {
expect(classifyWithRules('', DEFAULT_RULES, 'temporary')).toBe('temporary');
expect(classifyWithRules('', DEFAULT_RULES, 'normal')).toBe('normal');
});
it('uses custom rule set (default fallback is "normal" in Commit 1.4)', () => {
const rules = [{ pattern: /xyzzy/i, importance: 'critical' as const, reason: 'magic word' }];
expect(classifyWithRules('the password is xyzzy', rules)).toBe('critical');
expect(classifyWithRules('the password is hunter2', rules)).toBe('normal');
// Explicit fallback override still works:
expect(classifyWithRules('the password is hunter2', rules, 'temporary')).toBe('temporary');
});
it('higher importance wins when multiple rules match', () => {
const rules = [
{ pattern: /always/i, importance: 'critical' as const, reason: 'directive' },
{ pattern: /implement/i, importance: 'important' as const, reason: 'verb' },
];
expect(classifyWithRules('always implement tests first', rules)).toBe('critical');
});
});

View File

@@ -0,0 +1,165 @@
/**
* Wire-format round-trip integration test.
*
* Exercises the cli-bridge against a REAL hive-mind-cli + real
* hive-mind MCP server, in a freshly-init'd tmpdir-isolated mind so
* the test never touches the user's actual ~/.hive-mind.
*
* Acts as a fitness function: if the upstream MCP surface drifts
* (tool renames, schema field changes, importance enum changes, etc.)
* this test fails before any shim ships. Mocked unit tests would
* silently keep passing — that's exactly the gap that produced
* Commit 1.4 in the first place.
*
* Skipped automatically when `hive-mind-cli` is not on PATH (CI
* matrices without the upstream installed). Look for the
* "integration: hive-mind-cli not on PATH" log line.
*/
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { createCliBridge } from '../../src/cli-bridge.js';
import { encodeFrame } from '../../src/frame-encoder.js';
import type { HookEvent } from '../../src/hook-event-types.js';
const PROBE_TIMEOUT_MS = 5000;
const INIT_TIMEOUT_MS = 15000;
const TEST_TIMEOUT_MS = 30000;
const CLI_BIN = 'hive-mind-cli';
/**
* Path resolution priority:
* 1. HIVE_MIND_CLI_JS env override (CI / offline testing)
* 2. In-monorepo package at packages/hive-mind-cli/dist/index.js
* 3. Sibling checkout at ../hive-mind/packages/cli/dist/index.js
* 4. Fall back to 'hive-mind-cli' on PATH (relies on cli-bridge's
* Windows shell:true codepath; acceptable for unit/integration but
* forced JS path keeps things hermetic).
*/
function resolveCliJsPath(): string | undefined {
const envOverride = process.env['HIVE_MIND_CLI_JS'];
if (envOverride && existsSync(envOverride)) return envOverride;
const monorepo = resolve(import.meta.dirname, '..', '..', '..', 'hive-mind-cli', 'dist', 'index.js');
if (existsSync(monorepo)) return monorepo;
const sibling = resolve(process.cwd(), '..', 'hive-mind', 'packages', 'cli', 'dist', 'index.js');
if (existsSync(sibling)) return sibling;
return undefined;
}
const RESOLVED_CLI_JS = resolveCliJsPath();
const SPAWN_CMD = RESOLVED_CLI_JS ?? CLI_BIN;
function probeCli(): boolean {
try {
const probeArgs = RESOLVED_CLI_JS ? [RESOLVED_CLI_JS, '--help'] : ['--help'];
const probeBin = RESOLVED_CLI_JS ? process.execPath : CLI_BIN;
const probe = spawnSync(probeBin, probeArgs, {
stdio: 'pipe',
timeout: PROBE_TIMEOUT_MS,
shell: !RESOLVED_CLI_JS,
});
return probe.status === 0;
} catch {
return false;
}
}
const cliReachable = probeCli();
let tmpHome: string | undefined;
let priorDataDir: string | undefined;
beforeAll(async () => {
if (!cliReachable) {
console.log('[integration] hive-mind-cli not on PATH — round-trip suite will skip.');
return;
}
tmpHome = await mkdtemp(join(tmpdir(), 'hmc-integration-'));
priorDataDir = process.env['HIVE_MIND_DATA_DIR'];
process.env['HIVE_MIND_DATA_DIR'] = tmpHome;
// Initialize an isolated mind file under the tmpdir.
const initBin = RESOLVED_CLI_JS ? process.execPath : CLI_BIN;
const initArgs = RESOLVED_CLI_JS ? [RESOLVED_CLI_JS, 'init'] : ['init'];
const init = spawnSync(initBin, initArgs, {
stdio: 'pipe',
timeout: INIT_TIMEOUT_MS,
shell: !RESOLVED_CLI_JS,
env: process.env,
});
if (init.status !== 0) {
throw new Error(`hive-mind-cli init failed: ${init.stderr?.toString() ?? '(no stderr)'}`);
}
}, INIT_TIMEOUT_MS);
afterAll(async () => {
if (!cliReachable) return;
if (priorDataDir === undefined) {
delete process.env['HIVE_MIND_DATA_DIR'];
} else {
process.env['HIVE_MIND_DATA_DIR'] = priorDataDir;
}
if (tmpHome) {
await rm(tmpHome, { recursive: true, force: true });
}
});
describe.skipIf(!cliReachable)('integration: cli-bridge ↔ hive-mind-cli round-trip', () => {
it('save_memory + recall_memory complete a wire-level round-trip with HookFrame inputs', async () => {
const bridge = createCliBridge({ max_retries: 0, timeout_ms: 15000, cli_path: SPAWN_CMD });
const marker = `roundtrip-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const event: HookEvent = {
eventType: 'user-prompt-submit',
source: 'claude-code',
cwd: '/integration/test',
timestamp_iso: new Date().toISOString(),
payload: {
content: `${marker} this is the integration save body`,
session_id: `int-sess-${marker}`,
},
};
const frame = encodeFrame(event, { importance: 'normal' });
const saveResult = await bridge.saveMemory(frame);
expect(saveResult.success).toBe(true);
expect(saveResult.id).not.toBe('');
expect(Number(saveResult.id)).toBeGreaterThan(0);
// Recall by the unique marker — content prefix carries it through.
const hits = await bridge.recallMemory(marker, { limit: 5 });
expect(hits.length).toBeGreaterThanOrEqual(1);
const hit = hits[0];
expect(hit.content).toContain(marker);
expect(hit.content).toContain('src:claude-code');
expect(hit.importance).toBe('normal');
expect(hit.from).toBe('personal');
}, TEST_TIMEOUT_MS);
it('cleanup_frames responds without error', async () => {
const bridge = createCliBridge({ max_retries: 0, timeout_ms: 15000, cli_path: SPAWN_CMD });
const out = await bridge.cleanupFrames();
expect(typeof out.pruned).toBe('number');
expect(out.pruned).toBeGreaterThanOrEqual(0);
}, TEST_TIMEOUT_MS);
// NOTE: an "empty recall" integration check would be too brittle —
// upstream hybrid search returns very-low-score hits even for
// unrelated queries (semantic vector recall has no zero-score floor).
// The plain-text "No memories found" envelope is exercised by the
// unit test in cli-bridge.test.ts with a mocked spawn instead.
it('save_memory rejects an invalid source (regression: enum mismatch must surface, not be silently swallowed)', async () => {
const bridge = createCliBridge({ max_retries: 0, timeout_ms: 15000, cli_path: SPAWN_CMD });
// Bypass frameToSavePayload — use raw callMcpTool to send a bad source.
await expect(bridge.callMcpTool('save_memory', {
content: 'should fail',
source: 'claude-code', // INVALID — must be one of the four provenance enum values
})).rejects.toThrow(/Invalid enum value/);
}, TEST_TIMEOUT_MS);
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { createLogger } from '../src/logger.js';
function captureLines(): { lines: string[]; write: (l: string) => void } {
const lines: string[] = [];
return { lines, write: (l) => lines.push(l) };
}
const FROZEN_NOW = new Date('2026-04-28T10:00:00.000Z');
describe('createLogger', () => {
it('emits a JSON line per call with name + level + msg + timestamp', () => {
const cap = captureLines();
const log = createLogger({ name: 'test', level: 'debug', write: cap.write, now: () => FROZEN_NOW });
log.info('hello');
expect(cap.lines).toHaveLength(1);
const entry = JSON.parse(cap.lines[0]) as Record<string, unknown>;
expect(entry).toMatchObject({
timestamp: '2026-04-28T10:00:00.000Z',
level: 'info',
name: 'test',
msg: 'hello',
});
});
it('filters levels below threshold', () => {
const cap = captureLines();
const log = createLogger({ level: 'warn', write: cap.write, now: () => FROZEN_NOW });
log.debug('d');
log.info('i');
log.warn('w');
log.error('e');
expect(cap.lines.map((l) => (JSON.parse(l) as { level: string }).level))
.toEqual(['warn', 'error']);
});
it('merges meta fields into the entry without clobbering core fields', () => {
const cap = captureLines();
const log = createLogger({ level: 'info', write: cap.write, now: () => FROZEN_NOW });
log.info('x', { tool: 'save_memory', latencyMs: 42 });
const entry = JSON.parse(cap.lines[0]) as Record<string, unknown>;
expect(entry['tool']).toBe('save_memory');
expect(entry['latencyMs']).toBe(42);
expect(entry['msg']).toBe('x');
});
it('honours HIVE_MIND_SHIM_LOG_LEVEL when no explicit level is provided', () => {
const prior = process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = 'error';
try {
const cap = captureLines();
const log = createLogger({ write: cap.write, now: () => FROZEN_NOW });
log.info('i');
log.error('e');
expect(cap.lines).toHaveLength(1);
expect((JSON.parse(cap.lines[0]) as { level: string }).level).toBe('error');
} finally {
if (prior === undefined) delete process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
else process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = prior;
}
});
it('explicit level option overrides env var', () => {
const prior = process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = 'error';
try {
const cap = captureLines();
const log = createLogger({ level: 'debug', write: cap.write, now: () => FROZEN_NOW });
log.debug('d');
expect(cap.lines).toHaveLength(1);
} finally {
if (prior === undefined) delete process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
else process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = prior;
}
});
it('falls back to default name "shim-core"', () => {
const cap = captureLines();
const log = createLogger({ write: cap.write, now: () => FROZEN_NOW });
log.info('hi');
const entry = JSON.parse(cap.lines[0]) as { name: string };
expect(entry.name).toBe('shim-core');
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { summarizeTurn } from '../src/prompt-summarizer.js';
describe('summarizeTurn', () => {
it('returns empty string for empty input', () => {
expect(summarizeTurn('')).toBe('');
expect(summarizeTurn(' \n ')).toBe('');
});
it('returns input unchanged when within budget (collapsed whitespace)', () => {
expect(summarizeTurn('Short reply.')).toBe('Short reply.');
expect(summarizeTurn('Hello world.\n\n\nNice.')).toBe('Hello world. Nice.');
});
it('truncates with ellipsis when over budget', () => {
const long = 'A'.repeat(800);
const out = summarizeTurn(long, { maxChars: 100 });
expect(out.length).toBeLessThanOrEqual(100);
expect(out.endsWith('…')).toBe(true);
});
it('keeps the leading sentence when possible', () => {
const text = 'First sentence here. Second one is longer and contains more words. Third.';
const out = summarizeTurn(text, { maxChars: 30 });
expect(out.startsWith('First sentence here.')).toBe(true);
expect(out.endsWith('…')).toBe(true);
});
it('replaces fenced code blocks with [code]', () => {
const text = 'Here is some code:\n```ts\nconst x = 1;\n```\nthat is all.';
const out = summarizeTurn(text);
expect(out).toContain('[code]');
expect(out).not.toContain('const x = 1;');
});
it('respects custom maxChars', () => {
const text = 'A'.repeat(50) + '. ' + 'B'.repeat(50);
expect(summarizeTurn(text, { maxChars: 200 })).toBe(text);
const small = summarizeTurn(text, { maxChars: 30 });
expect(small.length).toBeLessThanOrEqual(30);
});
it('hard-truncates when no sentence boundary fits', () => {
const blob = 'A'.repeat(500);
const out = summarizeTurn(blob, { maxChars: 50 });
expect(out.length).toBe(50);
expect(out.endsWith('…')).toBe(true);
});
it('is deterministic — same input -> same output', () => {
const a = summarizeTurn('Repeatable. Same. Output.', { maxChars: 30 });
const b = summarizeTurn('Repeatable. Same. Output.', { maxChars: 30 });
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest';
import { computeBackoff, withRetry } from '../src/retry-bridge.js';
describe('computeBackoff', () => {
it('exponentially increases with attempt count', () => {
const fixedRandom = (): number => 0.5; // jitter = 0
expect(computeBackoff(0, 100, 5000, 0, fixedRandom)).toBe(100);
expect(computeBackoff(1, 100, 5000, 0, fixedRandom)).toBe(200);
expect(computeBackoff(2, 100, 5000, 0, fixedRandom)).toBe(400);
expect(computeBackoff(3, 100, 5000, 0, fixedRandom)).toBe(800);
});
it('is clamped to maxBackoffMs', () => {
const fixedRandom = (): number => 0.5;
expect(computeBackoff(20, 100, 1000, 0, fixedRandom)).toBe(1000);
});
it('jitter is bounded by jitterFactor on either side', () => {
// random=0 -> -1 multiplier; random=1 -> +1 multiplier
const lower = computeBackoff(2, 100, 5000, 0.25, () => 0);
const upper = computeBackoff(2, 100, 5000, 0.25, () => 0.999999);
expect(lower).toBeGreaterThanOrEqual(Math.round(400 * 0.75));
expect(upper).toBeLessThanOrEqual(Math.round(400 * 1.25) + 1);
});
it('returns >= 0', () => {
expect(computeBackoff(0, 100, 5000, 5.0, () => 0)).toBeGreaterThanOrEqual(0);
});
});
describe('withRetry', () => {
it('returns the value on first-attempt success', async () => {
const fn = vi.fn(async () => 42);
const result = await withRetry(fn, { maxRetries: 3, delay: async () => undefined });
expect(result).toBe(42);
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries until success', async () => {
let calls = 0;
const fn = vi.fn(async () => {
calls += 1;
if (calls < 3) throw new Error('flaky');
return 'ok';
});
const result = await withRetry(fn, {
maxRetries: 5,
delay: async () => undefined,
random: () => 0.5,
});
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(3);
});
it('throws the last error after exhausting retries', async () => {
const fn = vi.fn(async () => { throw new Error('persistent'); });
await expect(withRetry(fn, { maxRetries: 2, delay: async () => undefined })).rejects.toThrow('persistent');
expect(fn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it('per-attempt timeout fires when fn never resolves', async () => {
const fn = vi.fn(() => new Promise<never>(() => { /* hang */ }));
await expect(withRetry(fn, {
maxRetries: 1,
timeoutMs: 20,
delay: async () => undefined,
})).rejects.toThrow(/timed out/);
expect(fn).toHaveBeenCalledTimes(2);
});
it('passes the configured delay through (test hook)', async () => {
const delays: number[] = [];
const fn = vi.fn(async () => { throw new Error('x'); });
await expect(withRetry(fn, {
maxRetries: 2,
baseBackoffMs: 100,
jitterFactor: 0,
delay: async (ms) => { delays.push(ms); },
random: () => 0.5,
})).rejects.toThrow();
expect(delays).toEqual([100, 200]);
});
it('wraps non-Error rejections', async () => {
const fn = vi.fn(async () => { throw 'plain string'; });
await expect(withRetry(fn, {
maxRetries: 0,
delay: async () => undefined,
})).rejects.toThrow('plain string');
});
});

View File

@@ -0,0 +1,302 @@
/**
* AI-OS Phase 1D — signal emitter tests.
*
* Verifies: URL resolution order, request shape, fail-open semantics
* on network errors, timeout enforcement, the maybeEmitDiscovery
* policy helper.
*/
import { describe, it, expect } from 'vitest';
import {
emitSignalToWaggleDance,
maybeEmitDiscovery,
type EmittedSignal,
} from '../src/signal-emitter.js';
/**
* Build a fake fetch that captures the request and returns a stub
* 201 response with a synthetic signal echo.
*/
function makeOkFetch(): typeof fetch & { calls: Array<{ url: string; init: RequestInit | undefined }> } {
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init });
const sig: EmittedSignal = {
id: 'srv-' + Math.random().toString(36).slice(2),
teamId: 'personal::test',
senderId: 'test',
type: 'broadcast',
subtype: 'discovery',
content: {},
referenceId: null,
routing: null,
createdAt: new Date().toISOString(),
};
return new Response(JSON.stringify({ dispatched: true, message: sig }), {
status: 201,
headers: { 'content-type': 'application/json' },
});
}) as typeof fetch & { calls: typeof calls };
impl.calls = calls;
return impl;
}
describe('emitSignalToWaggleDance', () => {
it('POSTs to the default sidecar URL when no override', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: { topic: 'x' },
fetchImpl: f,
});
expect(f.calls).toHaveLength(1);
expect(f.calls[0].url).toBe('http://127.0.0.1:3333/api/waggle-dance/signal');
});
it('honors the url option', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: f,
url: 'http://127.0.0.1:4000',
});
expect(f.calls[0].url).toBe('http://127.0.0.1:4000/api/waggle-dance/signal');
});
it('reads WAGGLE_SIDECAR_URL env when no url option', async () => {
const prev = process.env.WAGGLE_SIDECAR_URL;
process.env.WAGGLE_SIDECAR_URL = 'http://127.0.0.1:9999';
try {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: f,
});
expect(f.calls[0].url).toBe('http://127.0.0.1:9999/api/waggle-dance/signal');
} finally {
if (prev === undefined) delete process.env.WAGGLE_SIDECAR_URL;
else process.env.WAGGLE_SIDECAR_URL = prev;
}
});
it('serializes the body with the expected shape', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: { tool: 'claude-code', topic: 'rotation' },
senderId: 'claude-code-hook',
fetchImpl: f,
});
const body = JSON.parse(f.calls[0].init?.body as string);
expect(body).toMatchObject({
type: 'broadcast',
subtype: 'discovery',
senderId: 'claude-code-hook',
content: { tool: 'claude-code', topic: 'rotation' },
});
});
it('authenticates with the narrow run token without putting it in the body', async () => {
const f = makeOkFetch();
const token = 'run-token-with-at-least-thirty-two-bytes-1234';
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: { topic: 'safe' },
runToken: token,
fetchImpl: f,
});
expect((f.calls[0].init?.headers as Record<string, string>)['x-waggle-run-token']).toBe(token);
expect(f.calls[0].init?.body).not.toContain(token);
});
it('reads WAGGLE_RUN_TOKEN for installed hook processes', async () => {
const previous = process.env.WAGGLE_RUN_TOKEN;
const token = 'environment-run-token-with-enough-entropy-1234';
process.env.WAGGLE_RUN_TOKEN = token;
try {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast', subtype: 'discovery', content: {}, fetchImpl: f,
});
expect((f.calls[0].init?.headers as Record<string, string>)['x-waggle-run-token']).toBe(token);
} finally {
if (previous === undefined) delete process.env.WAGGLE_RUN_TOKEN;
else process.env.WAGGLE_RUN_TOKEN = previous;
}
});
it('defaults senderId to "hook" when not provided', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: f,
});
const body = JSON.parse(f.calls[0].init?.body as string);
expect(body.senderId).toBe('hook');
});
it('only includes optional fields when set', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'response',
subtype: 'knowledge_match',
content: { matched: 1 },
senderId: 'cursor',
referenceId: 'r-1',
fetchImpl: f,
});
const body = JSON.parse(f.calls[0].init?.body as string);
expect(body.referenceId).toBe('r-1');
expect(body).not.toHaveProperty('routing');
expect(body).not.toHaveProperty('teamId');
});
it('returns the server message on 201', async () => {
const f = makeOkFetch();
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: f,
});
expect(out).not.toBeNull();
expect(out!.id).toMatch(/^srv-/);
});
it('returns null and warns on network errors (ECONNREFUSED simulation)', async () => {
const warnCalls: string[] = [];
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: (async () => {
throw new Error('ECONNREFUSED 127.0.0.1:3333');
}) as typeof fetch,
onWarn: (m) => warnCalls.push(m),
});
expect(out).toBeNull();
expect(warnCalls).toHaveLength(1);
expect(warnCalls[0]).toContain('ECONNREFUSED');
});
it('returns null and warns on non-2xx response', async () => {
const warnCalls: string[] = [];
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: (async () => new Response('bad request', { status: 400 })) as typeof fetch,
onWarn: (m) => warnCalls.push(m),
});
expect(out).toBeNull();
expect(warnCalls[0]).toContain('400');
});
it('returns null and warns on malformed response body', async () => {
const warnCalls: string[] = [];
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: (async () =>
new Response('{"no-message-field": true}', {
status: 201,
headers: { 'content-type': 'application/json' },
})) as typeof fetch,
onWarn: (m) => warnCalls.push(m),
});
expect(out).toBeNull();
expect(warnCalls[0]).toContain('malformed');
});
it('enforces a per-request timeout', async () => {
const warnCalls: string[] = [];
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
timeoutMs: 50,
fetchImpl: ((_url: string, init?: RequestInit) => {
// Return a never-resolving promise; the abort signal should trigger.
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
});
}) as typeof fetch,
onWarn: (m) => warnCalls.push(m),
});
expect(out).toBeNull();
expect(warnCalls[0]).toContain('sidecar unreachable');
});
});
describe('maybeEmitDiscovery', () => {
it('emits when Stop + high importance', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery(
'stop',
'high',
{ topic: 'rotation' },
{ fetchImpl: f, senderId: 'cc' },
);
expect(out).not.toBeNull();
expect(f.calls).toHaveLength(1);
const body = JSON.parse(f.calls[0].init?.body as string);
expect(body.subtype).toBe('discovery');
expect(body.content.eventType).toBe('stop');
expect(body.content.importance).toBe('high');
expect(body.content.topic).toBe('rotation');
});
it('emits when PreCompact + critical', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery(
'pre-compact',
'critical',
{},
{ fetchImpl: f },
);
expect(out).not.toBeNull();
});
it('does not emit on low importance', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery('stop', 'low', {}, { fetchImpl: f });
expect(out).toBeNull();
expect(f.calls).toHaveLength(0);
});
it('does not emit on normal importance', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery('stop', 'normal', {}, { fetchImpl: f });
expect(out).toBeNull();
expect(f.calls).toHaveLength(0);
});
it('does not emit on non-stop/pre-compact events', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery(
'user-prompt-submit',
'high',
{},
{ fetchImpl: f },
);
expect(out).toBeNull();
expect(f.calls).toHaveLength(0);
});
});
// Note: the end-to-end integration test against a real Waggle sidecar
// lives in packages/server/tests/signal-emitter-integration.test.ts —
// that's the correct layer to depend on @waggle/server. This module
// (shim-core) stays a leaf with no inbound deps from the server, which
// is what lets hook packages consume it without dragging the sidecar
// in.

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { isAbsoluteWorkspacePath, resolveWorkspace } from '../src/workspace-resolver.js';
import { join, resolve } from 'node:path';
// Resolve path fixtures against the current platform so tests work on
// both POSIX (e.g. /fake/home) and Windows (D:\fake\home). The resolver
// internally calls path.resolve on its cwd input — fixtures must match.
const FAKE_HOME = resolve('/fake/home');
function makeExists(present: readonly string[]): (p: string) => Promise<boolean> {
const set = new Set(present);
return async (p: string) => set.has(p);
}
describe('resolveWorkspace', () => {
it('falls back to global mode when no project marker exists', async () => {
const ws = await resolveWorkspace(resolve('/some/random/cwd'), {
home: FAKE_HOME,
exists: makeExists([]),
walkUp: false,
});
expect(ws.mode).toBe('global');
expect(ws.path).toBe(join(FAKE_HOME, '.hive-mind', 'global.mind'));
});
it('returns per-project mode when marker exists at cwd', async () => {
const cwd = resolve('/proj/foo');
const marker = join(cwd, '.hive-mind', 'workspace.mind');
const ws = await resolveWorkspace(cwd, {
home: FAKE_HOME,
exists: makeExists([marker]),
walkUp: false,
});
expect(ws.mode).toBe('per-project');
expect(ws.path).toBe(marker);
expect(ws.cwd).toBe(cwd);
});
it('walks up to a parent project marker', async () => {
const projectRoot = resolve('/proj/foo');
const cwd = join(projectRoot, 'src', 'deep', 'nested');
const marker = join(projectRoot, '.hive-mind', 'workspace.mind');
const ws = await resolveWorkspace(cwd, {
home: FAKE_HOME,
exists: makeExists([marker]),
walkUp: true,
});
expect(ws.mode).toBe('per-project');
expect(ws.path).toBe(marker);
});
it('skips the walk when walkUp is false', async () => {
const projectRoot = resolve('/proj/foo');
const cwd = join(projectRoot, 'src', 'deep');
const ancestorMarker = join(projectRoot, '.hive-mind', 'workspace.mind');
const ws = await resolveWorkspace(cwd, {
home: FAKE_HOME,
exists: makeExists([ancestorMarker]),
walkUp: false,
});
expect(ws.mode).toBe('global');
});
it('records the original cwd even when resolving to global', async () => {
const cwd = resolve('/proj/foo');
const ws = await resolveWorkspace(cwd, {
home: FAKE_HOME,
exists: makeExists([]),
walkUp: true,
});
expect(ws.cwd).toBe(cwd);
expect(ws.mode).toBe('global');
});
it('uses provided home override for the global fallback', async () => {
const customHome = resolve('/custom/home');
const ws = await resolveWorkspace(resolve('/proj/x'), {
home: customHome,
exists: makeExists([]),
walkUp: false,
});
expect(ws.path).toBe(join(customHome, '.hive-mind', 'global.mind'));
});
});
describe('isAbsoluteWorkspacePath', () => {
it('detects absolute paths on the current platform', () => {
expect(isAbsoluteWorkspacePath(resolve('/etc/foo'))).toBe(true);
expect(isAbsoluteWorkspacePath('relative/path')).toBe(false);
});
});

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests/**", "dist/**", "node_modules/**"]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist/**", "node_modules/**"]
}