This commit is contained in:
19
packages/hive-mind-hooks-openclaw/LICENSE
Normal file
19
packages/hive-mind-hooks-openclaw/LICENSE
Normal 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
|
||||
104
packages/hive-mind-hooks-openclaw/README.md
Normal file
104
packages/hive-mind-hooks-openclaw/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# @waggle/hive-mind-hooks-openclaw
|
||||
|
||||
Silent-capture shim that wires **OpenClaw** (`openclaw/openclaw`) gateway
|
||||
lifecycle hooks into [hive-mind](https://github.com/marolinik/hive-mind)
|
||||
frames. Every OpenClaw conversation deterministically captures
|
||||
bootstrap / inbound-message / outbound-message events into your personal memory
|
||||
via `hive-mind-cli` — the same every-turn pattern proven by
|
||||
`@waggle/hive-mind-hooks-claude-code`, built on the shared
|
||||
`@waggle/hive-mind-hooks-core` foundation.
|
||||
|
||||
OpenClaw is the exception in the hook portfolio: its hooks are **in-process
|
||||
TypeScript**, not stdin-JSON subprocesses. A hook is a **directory**
|
||||
`~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}` that the gateway discovers
|
||||
and dynamically `import()`s, running the default export inside its own Node
|
||||
event loop. So the installer writes that managed directory and **minimally
|
||||
touches** the JSON5 config (`~/.openclaw/openclaw.json`) — it does **not**
|
||||
re-serialize the whole config (that would destroy your comments and trailing
|
||||
commas).
|
||||
|
||||
> **Not hook parity with claude-code.** OpenClaw's Stop event (`message:sent`)
|
||||
> fires **0..N times per turn** and is **non-replyable**, so Stop is
|
||||
> **debounced**. There is no single per-turn "agent finished one reply"
|
||||
> internal event. See the Capture fidelity table below.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npx @waggle/hive-mind-hooks-openclaw install
|
||||
# Windows / production: pin the CLI path
|
||||
npx @waggle/hive-mind-hooks-openclaw install --cli-path "C:\\path\\to\\hive-mind-cli\\dist\\index.js"
|
||||
```
|
||||
|
||||
The installer:
|
||||
|
||||
1. writes the managed hook dir `~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}`
|
||||
(the compiled handler is **copied** from this package's `dist/`),
|
||||
2. minimally edits `~/.openclaw/openclaw.json` — flips
|
||||
`hooks.internal.enabled = true` and adds
|
||||
`hooks.internal.entries["hive-mind"] = { enabled: true }` (creating the file
|
||||
if absent),
|
||||
3. writes a pointer + **literal byte-identical backup** so uninstall is exact.
|
||||
|
||||
> **Activation (IMPORTANT).** OpenClaw internal hooks are **OFF** until the
|
||||
> subsystem is enabled. The installer sets `hooks.internal.enabled: true`; if
|
||||
> that is not honored on your install, run `openclaw hooks enable hive-mind`.
|
||||
|
||||
```bash
|
||||
npx @waggle/hive-mind-hooks-openclaw verify # smoke-check
|
||||
npx @waggle/hive-mind-hooks-openclaw uninstall # remove managed dir + byte-identical restore
|
||||
```
|
||||
|
||||
Reversibility relies on the **literal byte-identical backup** of `openclaw.json`
|
||||
(JSON5 re-serialization loses comments / trailing commas), plus removal of the
|
||||
managed hook directory recorded in the pointer. If the config did not pre-exist,
|
||||
uninstall removes the file we created (no orphans, no leftover backup).
|
||||
|
||||
## Capture fidelity
|
||||
|
||||
| Lifecycle | OpenClaw event | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| SessionStart (recall + inject) | `agent:bootstrap` | full | injects by **mutating** `event.context.bootstrapFiles` (a mutable array the gateway reads back), not stdout — the sanctioned inject seam |
|
||||
| UserPromptSubmit (save temporary) | `message:received` | full | inbound message; the text arrives at `context.content`, with `from` / `channelId` |
|
||||
| Stop (summarize + save) | `message:sent` | **degraded (debounced)** | fires **0..N per turn** and is **non-replyable**; the handler debounces (last `message:sent` of a turn wins) so a multi-payload turn saves one frame |
|
||||
| PreCompact (compact memory) | `session:compact:before` | full | the runtime `event.action` is `compact:before` (the `session:` prefix is HOOK.md-only); the handler matches on the action suffix |
|
||||
|
||||
**Disclosures:**
|
||||
|
||||
- **Stop is `message:sent`, 0..N per turn, non-replyable.** OpenClaw delivers
|
||||
one `message:sent` per outbound payload, so a turn that streams multiple
|
||||
payloads fires multiple times. The handler debounces (a short timer keyed on
|
||||
the session/channel) so only the last payload of a turn is saved. There is no
|
||||
single per-turn finalization event. (`before_agent_finalize` is a **plugin**
|
||||
hook — a different subsystem — and is deliberately not used.)
|
||||
- **`handler.js` must resolve `@waggle/*` at runtime (live-install caveat).**
|
||||
The installed `~/.openclaw/hooks/hive-mind/handler.js` `require`s this
|
||||
package's runtime deps (`@waggle/hive-mind-shim-core` /
|
||||
`@waggle/hive-mind-hooks-core`). Whether the OpenClaw gateway can `import()` a
|
||||
file that resolves those node_modules on a real OpenClaw install is the **one
|
||||
remaining needs-a-live-install validation** — on a normal `npx`/npm install
|
||||
the deps are colocated and resolve, but a hand-copied dir without the package
|
||||
tree will not. Pin `--cli-path` for the CLI itself; the gateway's TS/JS loader
|
||||
must reach the package's `node_modules`.
|
||||
- **Gateway double-capture (known v0.1.0 limitation).** OpenClaw can drive
|
||||
claude-code / codex as **backends**. If those backends ALSO have hive-mind
|
||||
hooks installed, the same conversation is captured twice — once at the
|
||||
OpenClaw gateway layer (this package) and once at the backend. This package
|
||||
**stamps a provenance marker** (`openclaw-gateway` + the channel/session key)
|
||||
on every frame it saves so gateway captures are attributable, but it does
|
||||
**not** yet dedup the cross-process double-capture (that is a harder
|
||||
cross-process problem). A content-hash dedup heuristic is deferred to a
|
||||
fast-follow.
|
||||
|
||||
## How it works
|
||||
|
||||
OpenClaw loads the default export from
|
||||
`~/.openclaw/hooks/hive-mind/handler.js` and dispatches its subscribed events
|
||||
to it **in-process**. The handler maps `event.context` into the shared
|
||||
lifecycle payload shapes and drives the same recall / save / summarize /
|
||||
compact bodies the other hooks use, shelling to `hive-mind-cli` via
|
||||
`@waggle/hive-mind-shim-core`'s `CliBridge`. **Fail-open:** the default export
|
||||
never throws and never rejects — on any error it resolves silently, so the
|
||||
gateway flow is never affected.
|
||||
|
||||
License: Apache-2.0.
|
||||
66
packages/hive-mind-hooks-openclaw/package.json
Normal file
66
packages/hive-mind-hooks-openclaw/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@waggle/hive-mind-hooks-openclaw",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenClaw silent capture shim for hive-mind. Registers in-process internal hooks (agent:bootstrap / message:received / message:sent / compact:before) that route gateway conversation episodes into hive-mind frames via @waggle/hive-mind-shim-core. Reversible, create-if-missing install — writes a managed ~/.openclaw/hooks/hive-mind/ dir + minimal-touch JSON5 edit, with literal byte-identical uninstall (JSON5 round-trip is lossy). Stop debounced (message:sent is 0..N/turn). Built on @waggle/hive-mind-hooks-core.",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"openclaw-hooks": "dist/bin/openclaw-hooks.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./handler": "./dist/handler.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build && node scripts/build-handler.mjs",
|
||||
"build:clean": "tsc --build --clean && node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist/handler.bundle.cjs', { force: true })\"",
|
||||
"prepack": "npm run build",
|
||||
"typecheck": "tsc --build && tsc --noEmit -p tsconfig.test.json",
|
||||
"test": "npm run build && node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/hive-mind-hooks-openclaw/tests",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waggle/hive-mind-hooks-core": "*",
|
||||
"@waggle/hive-mind-shim-core": "*",
|
||||
"json5": "^2.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@waggle/hive-mind-cli": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@waggle/hive-mind-cli": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/marolinik/waggle-os.git",
|
||||
"directory": "packages/hive-mind-hooks-openclaw"
|
||||
},
|
||||
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-hooks-openclaw#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/marolinik/waggle-os/issues"
|
||||
},
|
||||
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
"hive-mind",
|
||||
"memory",
|
||||
"ai",
|
||||
"hook",
|
||||
"silent-capture",
|
||||
"json5",
|
||||
"in-process"
|
||||
],
|
||||
"types": "dist/index.d.ts"
|
||||
}
|
||||
30
packages/hive-mind-hooks-openclaw/scripts/build-handler.mjs
Normal file
30
packages/hive-mind-hooks-openclaw/scripts/build-handler.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
/** Bundle the copied OpenClaw handler into one npm-independent CommonJS file. */
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const entryPoint = path.resolve(process.argv[2] ?? path.join(packageRoot, 'dist', 'handler.js'));
|
||||
const outfile = path.resolve(process.argv[3] ?? path.join(packageRoot, 'dist', 'handler.bundle.cjs'));
|
||||
const importPath = entryPoint.split(path.sep).join('/');
|
||||
|
||||
// OpenClaw uses native import(file://...) for managed hooks. Its ~/.openclaw
|
||||
// directory is not an ESM package, so a copied .js file must be loadable as
|
||||
// CommonJS. Assigning the function directly to module.exports also makes the
|
||||
// dynamic-import default export the handler function (not { default: fn }).
|
||||
await build({
|
||||
stdin: {
|
||||
contents: `import handler from ${JSON.stringify(importPath)}; module.exports = handler;`,
|
||||
resolveDir: packageRoot,
|
||||
sourcefile: 'installed-handler-entry.js',
|
||||
},
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
legalComments: 'none',
|
||||
logLevel: 'info',
|
||||
});
|
||||
107
packages/hive-mind-hooks-openclaw/src/adapter.ts
Normal file
107
packages/hive-mind-hooks-openclaw/src/adapter.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* OpenClaw EventAdapter.
|
||||
*
|
||||
* OpenClaw (`openclaw/openclaw`) exposes IN-PROCESS internal hooks: a hook is
|
||||
* a directory `~/.openclaw/hooks/<name>/{HOOK.md, handler.js}` whose default
|
||||
* export is `(event: InternalHookEvent) => Promise<void> | void`, run inside
|
||||
* the gateway Node process. That is NOT the stdin-JSON / exit-0 subprocess
|
||||
* model the other tools use, so openclaw drives the shared handler bodies via
|
||||
* `makeOpenclawHandler` (hooks-core) rather than `runHook`.
|
||||
*
|
||||
* Event map — matched on the `(type, action)` PAIR, not a joined string
|
||||
* (spec §5.5, source-verified):
|
||||
* - SessionStart → `agent:bootstrap` (mutate `context.bootstrapFiles`)
|
||||
* - UserPromptSubmit → `message:received` (context {from, content, channelId})
|
||||
* - Stop → `message:sent` (0..N per turn — DEBOUNCED; non-replyable)
|
||||
* - PreCompact → `session:compact:before`, BUT the runtime
|
||||
* `event.action` is `'compact:before'` (the `session:` prefix is
|
||||
* HOOK.md-only). `lifecycleForOpenclawEvent` in hooks-core matches the
|
||||
* action suffix, so we declare the HOOK.md-style key here.
|
||||
*
|
||||
* `before_agent_finalize` is deliberately NOT used — it is a typed PLUGIN
|
||||
* hook (a different subsystem), not an internal `HOOK.md` event. Stop is
|
||||
* `message:sent` (debounced) instead.
|
||||
*
|
||||
* The `extract*` methods read OpenClaw's `event.context` shapes. Because the
|
||||
* openclaw handler pre-extracts `event.context` into the shared
|
||||
* extracted-payload shapes before calling the body, these extractors operate
|
||||
* on that already-unwrapped `context` object.
|
||||
*/
|
||||
|
||||
import {
|
||||
pickStringField,
|
||||
type EventAdapter,
|
||||
type Lifecycle,
|
||||
} from '@waggle/hive-mind-hooks-core';
|
||||
|
||||
/**
|
||||
* Canonical lifecycle → OpenClaw native `type:action` key. The values are the
|
||||
* joined keys; `makeOpenclawHandler` matches them against the runtime
|
||||
* `(type, action)` pair (and, for PreCompact, on the action suffix because
|
||||
* the runtime action drops the `session:` prefix).
|
||||
*/
|
||||
export const OPENCLAW_EVENT_NAME: Record<Lifecycle, string | undefined> = {
|
||||
'session-start': 'agent:bootstrap',
|
||||
'user-prompt-submit': 'message:received',
|
||||
'stop': 'message:sent',
|
||||
'pre-compact': 'session:compact:before',
|
||||
};
|
||||
|
||||
/**
|
||||
* Provenance marker stamped on frames captured at the OpenClaw gateway layer.
|
||||
* OpenClaw can drive claude-code / codex as BACKENDS; if those backends also
|
||||
* have hive-mind hooks installed the same conversation is captured twice. We
|
||||
* stamp `openclaw-gateway` (+ the channel / session key, see the handler) so
|
||||
* gateway captures are attributable. Cross-process dedup of the
|
||||
* double-capture is a documented v0.1.0 limitation (README), deferred to a
|
||||
* fast-follow.
|
||||
*/
|
||||
export const OPENCLAW_PROVENANCE = 'openclaw-gateway';
|
||||
|
||||
/** The OpenClaw EventAdapter consumed by the shared lifecycle handler bodies. */
|
||||
export const openclawAdapter: EventAdapter = {
|
||||
source: 'openclaw',
|
||||
eventName: OPENCLAW_EVENT_NAME,
|
||||
|
||||
extractCwd(context): string | undefined {
|
||||
return pickStringField(context, 'cwd', 'workingDirectory', 'working_directory');
|
||||
},
|
||||
|
||||
extractSessionId(context): string | undefined {
|
||||
// `channelId` is the most stable per-conversation key for gateway events;
|
||||
// fall back to sessionKey / explicit session ids.
|
||||
return pickStringField(
|
||||
context,
|
||||
'channelId',
|
||||
'channel_id',
|
||||
'sessionKey',
|
||||
'session_key',
|
||||
'sessionId',
|
||||
'session_id',
|
||||
);
|
||||
},
|
||||
|
||||
extractPrompt(context): string | undefined {
|
||||
// message:received carries the inbound text at context.content.
|
||||
return pickStringField(context, 'content', 'message', 'text', 'prompt');
|
||||
},
|
||||
|
||||
extractResponse(context): string | undefined {
|
||||
// message:sent carries the outbound text at context.content / .text.
|
||||
return pickStringField(context, 'content', 'text', 'response', 'message');
|
||||
},
|
||||
|
||||
extractParent(context): string | undefined {
|
||||
return pickStringField(context, 'parentId', 'parent_id', 'replyTo', 'reply_to');
|
||||
},
|
||||
|
||||
/**
|
||||
* SessionStart injects by MUTATING `event.context.bootstrapFiles` (handled
|
||||
* in the openclaw handler, not via stdout). The body still calls
|
||||
* `formatInject` to shape the recalled text; the handler reads the returned
|
||||
* value and pushes it onto the mutable array.
|
||||
*/
|
||||
formatInject(additionalContext: string): unknown {
|
||||
return { additionalContext };
|
||||
},
|
||||
};
|
||||
173
packages/hive-mind-hooks-openclaw/src/bin/openclaw-hooks.ts
Normal file
173
packages/hive-mind-hooks-openclaw/src/bin/openclaw-hooks.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `openclaw-hooks` — CLI entry for the @waggle/hive-mind-hooks-openclaw shim.
|
||||
*
|
||||
* openclaw-hooks install Write the managed ~/.openclaw/hooks/hive-mind/ dir
|
||||
* + minimal-touch openclaw.json (create-if-missing).
|
||||
* openclaw-hooks uninstall Remove the managed dir + restore the literal
|
||||
* byte-identical openclaw.json (or remove it if created).
|
||||
* openclaw-hooks verify Smoke-check the install + hive-mind-cli reachability.
|
||||
*/
|
||||
|
||||
import { install, type InstallResult } from '../install.js';
|
||||
import { uninstall, type UninstallResult } from '../uninstall.js';
|
||||
import { verify, type VerifyResult } from '../verify.js';
|
||||
|
||||
type ParsedArgs = {
|
||||
command: 'install' | 'uninstall' | 'verify' | 'help';
|
||||
flags: Record<string, string | boolean>;
|
||||
};
|
||||
|
||||
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||
const [first, ...rest] = argv;
|
||||
const valid = ['install', 'uninstall', 'verify'] as const;
|
||||
const command = first === '-h' || first === '--help' || first === undefined
|
||||
? 'help'
|
||||
: valid.includes(first as typeof valid[number]) ? first as typeof valid[number] : 'help';
|
||||
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
for (let i = 0; i < rest.length; i += 1) {
|
||||
const arg = rest[i];
|
||||
if (!arg.startsWith('--')) continue;
|
||||
const eq = arg.indexOf('=');
|
||||
if (eq !== -1) {
|
||||
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
||||
} else {
|
||||
const next = rest[i + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
flags[arg.slice(2)] = next;
|
||||
i += 1;
|
||||
} else {
|
||||
flags[arg.slice(2)] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { command: command as ParsedArgs['command'], flags };
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
process.stdout.write([
|
||||
'Usage: openclaw-hooks <command> [options]',
|
||||
'',
|
||||
'Commands:',
|
||||
' install Write ~/.openclaw/hooks/hive-mind/ + minimal-touch openclaw.json (create-if-missing).',
|
||||
' uninstall Remove the managed dir + restore the literal byte-identical openclaw.json (or remove it if created).',
|
||||
' verify Smoke-check the install + hive-mind-cli reachability.',
|
||||
'',
|
||||
'Options:',
|
||||
' --help, -h Show this help.',
|
||||
' --handler-source <PATH> Override compiled handler.js source (testing).',
|
||||
' --cli-path <PATH> Absolute path to the hive-mind-cli binary or its',
|
||||
' compiled JS entry. Required on Windows (npm bin',
|
||||
' is a .cmd shim) and recommended for production',
|
||||
' installs. Threaded into the hook entry env as',
|
||||
' WAGGLE_HIVE_MIND_CLI.',
|
||||
'',
|
||||
'Repo: https://github.com/marolinik/waggle-os',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function printInstallSummary(result: InstallResult): void {
|
||||
const lines: string[] = [
|
||||
'hive-mind/openclaw-hooks: install',
|
||||
` - openclaw.json: ${result.paths.configPath}`,
|
||||
` - backup: ${result.backupPath ?? '(none — openclaw.json created by us)'}`,
|
||||
` - pointer: ${result.pointerPath}`,
|
||||
` - hook dir: ${result.hookDir}`,
|
||||
` - lifecycles: ${result.installedHooks.join(', ')}`,
|
||||
` - touched keys: ${result.touchedKeys.join(', ')}`,
|
||||
` - cli path: ${result.cliPath ?? '(default — hive-mind-cli on PATH)'}`,
|
||||
'',
|
||||
'Activation (IMPORTANT):',
|
||||
' Hooks are OFF until the internal-hooks subsystem is on. The installer set',
|
||||
' hooks.internal.enabled: true. If it is not honored, run:',
|
||||
' openclaw hooks enable hive-mind',
|
||||
'',
|
||||
'Capture fidelity:',
|
||||
' 4 events — SessionStart (agent:bootstrap, mutates bootstrapFiles),',
|
||||
' UserPromptSubmit (message:received), Stop (message:sent — DEBOUNCED,',
|
||||
' 0..N/turn, non-replyable), PreCompact (compact:before). The installed',
|
||||
' handler.js resolving @waggle/* on a real OpenClaw install is the one',
|
||||
' remaining live-install validation. Gateway capture can double-count when',
|
||||
' OpenClaw drives a backend (CC/codex) that also has hooks — see README.',
|
||||
'',
|
||||
'Done. New OpenClaw sessions will silently capture to hive-mind.',
|
||||
'Run "openclaw-hooks verify" to inspect, "openclaw-hooks uninstall" to revert.',
|
||||
'',
|
||||
];
|
||||
process.stdout.write(lines.join('\n'));
|
||||
}
|
||||
|
||||
function printUninstallSummary(result: UninstallResult): void {
|
||||
const lines: string[] = [
|
||||
'hive-mind/openclaw-hooks: uninstall',
|
||||
` - openclaw.json: ${result.paths.configPath}`,
|
||||
` - hook dir removed: ${result.hookDirRemoved ? 'yes' : 'no (was absent)'}`,
|
||||
` - restored from: ${result.restoredFrom ?? '(none — removed file we created)'}`,
|
||||
` - created removed: ${result.createdRemoved ? 'yes' : 'no'}`,
|
||||
` - backup removed: ${result.backupRemoved ? 'yes' : 'no (kept on disk)'}`,
|
||||
` - pointer removed: ${result.pointerRemoved ? 'yes' : 'no'}`,
|
||||
'',
|
||||
'openclaw.json is byte-identical to pre-install state (or removed if we created it).',
|
||||
'',
|
||||
];
|
||||
process.stdout.write(lines.join('\n'));
|
||||
}
|
||||
|
||||
function printVerifySummary(result: VerifyResult): void {
|
||||
const lines: string[] = ['hive-mind/openclaw-hooks: verify'];
|
||||
for (const c of result.checks) {
|
||||
const tag = c.ok ? 'PASS' : 'FAIL';
|
||||
const detail = c.detail ? ` — ${c.detail}` : '';
|
||||
lines.push(` [${tag}] ${c.name}${detail}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(result.ok ? 'All checks passed.' : 'One or more checks failed.');
|
||||
lines.push('');
|
||||
process.stdout.write(lines.join('\n'));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { command, flags } = parseArgs(process.argv.slice(2));
|
||||
if (command === 'help') {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const handlerSource = typeof flags['handler-source'] === 'string' ? flags['handler-source'] : undefined;
|
||||
const cliPath = typeof flags['cli-path'] === 'string' ? flags['cli-path'] : undefined;
|
||||
|
||||
const baseOpts = {
|
||||
moduleUrl: import.meta.url,
|
||||
...(handlerSource ? { handlerSourcePath: handlerSource } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
if (command === 'install') {
|
||||
const installOpts = {
|
||||
...baseOpts,
|
||||
...(cliPath !== undefined ? { cliPath } : {}),
|
||||
};
|
||||
const result = await install(installOpts);
|
||||
printInstallSummary(result);
|
||||
return;
|
||||
}
|
||||
if (command === 'uninstall') {
|
||||
const result = await uninstall(baseOpts);
|
||||
printUninstallSummary(result);
|
||||
return;
|
||||
}
|
||||
if (command === 'verify') {
|
||||
const result = await verify(baseOpts);
|
||||
printVerifySummary(result);
|
||||
if (!result.ok) process.exit(1);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
227
packages/hive-mind-hooks-openclaw/src/handler.ts
Normal file
227
packages/hive-mind-hooks-openclaw/src/handler.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* OpenClaw in-process hook handler — the default export the gateway loads.
|
||||
*
|
||||
* Unlike the stdin-JSON tools (codex / cursor / hermes), OpenClaw hooks are
|
||||
* IN-PROCESS: the gateway dynamically `import()`s this file, grabs the default
|
||||
* export, and registers it as an `InternalHookHandler`
|
||||
* (`(event) => Promise<void> | void`, run inside the gateway event loop). So
|
||||
* there is no `runHook` subprocess wrapper — this module maps `event.context`
|
||||
* into the shared extracted-payload shapes and drives the SAME shared handler
|
||||
* bodies via `makeOpenclawHandler` (hooks-core).
|
||||
*
|
||||
* The four lifecycle dispatches (recall+inject / save-temp / summarize+save /
|
||||
* compact) are matched on the `(type, action)` PAIR by hooks-core. This module
|
||||
* owns the OpenClaw-specific glue:
|
||||
* - SessionStart injects by MUTATING `event.context.bootstrapFiles` (a
|
||||
* mutable array the gateway reads back) — there is no stdout seam.
|
||||
* - Stop (`message:sent`) fires 0..N per turn and is DEBOUNCED in
|
||||
* `makeOpenclawHandler` (stopDebounceMs).
|
||||
* - Provenance: every captured frame is attributed to `openclaw-gateway`
|
||||
* (+ the channel/session key) so gateway captures are distinguishable
|
||||
* from any backend (CC/codex) capture.
|
||||
*
|
||||
* FAIL-OPEN: the default export wraps the body and NEVER throws / never
|
||||
* rejects — it always returns a resolved promise (spec §7.3 invariant 1). The
|
||||
* gateway also wraps each handler in try/catch, but we do not rely on that as
|
||||
* the only safety net.
|
||||
*
|
||||
* Live-install caveat (OQ-5): this file is shipped COMPILED (`dist/handler.js`)
|
||||
* and copied into `~/.openclaw/hooks/hive-mind/handler.js`. Whether the
|
||||
* installed `handler.js` resolves the `@waggle/*` runtime deps on a real
|
||||
* OpenClaw install is the ONE remaining needs-a-live-install validation — the
|
||||
* gateway must be able to `import()` a file that `require`s node_modules from
|
||||
* this package's tree (or have the deps bundled). Documented in the README.
|
||||
*/
|
||||
|
||||
import {
|
||||
createCliBridge,
|
||||
createLogger,
|
||||
type CliBridgeOptions,
|
||||
type MemoryHit,
|
||||
} from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
makeOpenclawHandler,
|
||||
type HookContext,
|
||||
type InternalHookEventLike,
|
||||
type Lifecycle,
|
||||
type OpenclawHandlerInput,
|
||||
type PreCompactExtracted,
|
||||
type SessionStartExtracted,
|
||||
type StopExtracted,
|
||||
type UserPromptExtracted,
|
||||
} from '@waggle/hive-mind-hooks-core';
|
||||
import { openclawAdapter, OPENCLAW_PROVENANCE } from './adapter.js';
|
||||
|
||||
/** Default Stop debounce: collapse the 0..N `message:sent` of a turn. */
|
||||
const DEFAULT_STOP_DEBOUNCE_MS = 750;
|
||||
const DEFAULT_RECALL_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Minimal shape of OpenClaw's runtime `InternalHookEvent`. `bootstrapFiles` is
|
||||
* the MUTABLE array the gateway reads back after `agent:bootstrap` to inject
|
||||
* recalled context into the system prompt.
|
||||
*/
|
||||
interface OpenclawRuntimeContext {
|
||||
cwd?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
channelId?: unknown;
|
||||
sessionKey?: unknown;
|
||||
content?: unknown;
|
||||
/** Mutable array of bootstrap file contents — the SessionStart inject seam. */
|
||||
bootstrapFiles?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface OpenclawRuntimeEvent extends InternalHookEventLike {
|
||||
context?: OpenclawRuntimeContext;
|
||||
}
|
||||
|
||||
function asContext(event: OpenclawRuntimeEvent): OpenclawRuntimeContext {
|
||||
return event.context && typeof event.context === 'object' ? event.context : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a provenance-stamped session scope so gateway frames are attributable
|
||||
* to `openclaw-gateway` and to the originating channel/session. This rides the
|
||||
* frame's `session:` content prefix (frame-encoder), which is the only
|
||||
* attribution channel the save_memory wire preserves.
|
||||
*/
|
||||
function provenanceScope(ctx: OpenclawRuntimeContext): string {
|
||||
const sessionId = openclawAdapter.extractSessionId(ctx) ?? 'default';
|
||||
return `${OPENCLAW_PROVENANCE}:${sessionId}`;
|
||||
}
|
||||
|
||||
/** Resolve the lifecycle for an event using the adapter's event-name map. */
|
||||
function lifecycleFor(event: OpenclawRuntimeEvent): Lifecycle | undefined {
|
||||
const joined = `${event.type}:${event.action}`;
|
||||
for (const lc of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact'] as Lifecycle[]) {
|
||||
const native = openclawAdapter.eventName[lc];
|
||||
if (native === undefined) continue;
|
||||
if (native === joined) return lc;
|
||||
// PreCompact ONLY: runtime action is 'compact:before' while the HOOK.md key
|
||||
// is 'session:compact:before' — accept the action-suffix match exclusively
|
||||
// for pre-compact (an unrelated type with a colliding action must not map).
|
||||
if (lc === 'pre-compact' && native.endsWith(`:${event.action}`) && event.type !== '') return lc;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the lifecycle-specific payload from `event.context`, stamping the
|
||||
* provenance scope as the session id so saved frames are attributable.
|
||||
*/
|
||||
function extractFor(
|
||||
lifecycle: Lifecycle,
|
||||
ctx: OpenclawRuntimeContext,
|
||||
): SessionStartExtracted | UserPromptExtracted | StopExtracted | PreCompactExtracted {
|
||||
const cwd = openclawAdapter.extractCwd(ctx) ?? process.cwd();
|
||||
const scope = provenanceScope(ctx);
|
||||
|
||||
switch (lifecycle) {
|
||||
case 'session-start':
|
||||
return { cwd, sessionId: scope, recallLimit: DEFAULT_RECALL_LIMIT };
|
||||
case 'user-prompt-submit':
|
||||
return { prompt: openclawAdapter.extractPrompt(ctx) ?? '', cwd, sessionId: scope };
|
||||
case 'stop': {
|
||||
const responseRaw = openclawAdapter.extractResponse(ctx, {});
|
||||
const response = typeof responseRaw === 'string' ? responseRaw : '';
|
||||
const parent = openclawAdapter.extractParent(ctx);
|
||||
const stop: StopExtracted = { cwd, sessionId: scope, response, parent };
|
||||
return stop;
|
||||
}
|
||||
case 'pre-compact':
|
||||
return { scope };
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a CliBridge, honoring an install-pinned cli path from env. */
|
||||
function buildBridge(): ReturnType<typeof createCliBridge> {
|
||||
const logger = createLogger({ name: 'openclaw-hooks/handler' });
|
||||
const cliPath = process.env.WAGGLE_HIVE_MIND_CLI;
|
||||
const opts: CliBridgeOptions = { logger };
|
||||
if (typeof cliPath === 'string' && cliPath.length > 0) opts.cli_path = cliPath;
|
||||
return createCliBridge(opts);
|
||||
}
|
||||
|
||||
const handler = makeOpenclawHandler(openclawAdapter, {
|
||||
stopDebounceMs: DEFAULT_STOP_DEBOUNCE_MS,
|
||||
});
|
||||
|
||||
/**
|
||||
* The OpenClaw default export. Receives the runtime `InternalHookEvent`, maps
|
||||
* `event.context` → the extracted payload, and drives the shared bodies.
|
||||
*
|
||||
* SessionStart is special-cased: the shared body returns the inject object
|
||||
* (`{ additionalContext }`); we push that text onto the mutable
|
||||
* `context.bootstrapFiles` array (the gateway's sanctioned injection seam)
|
||||
* rather than emitting stdout.
|
||||
*
|
||||
* NEVER throws — always returns a resolved promise (fail-open).
|
||||
*/
|
||||
export default async function openclawHook(event: OpenclawRuntimeEvent): Promise<void> {
|
||||
try {
|
||||
const lifecycle = lifecycleFor(event);
|
||||
if (lifecycle === undefined) return;
|
||||
const ctx = asContext(event);
|
||||
const extracted = extractFor(lifecycle, ctx);
|
||||
|
||||
// SessionStart: drive recall ourselves so we can mutate bootstrapFiles
|
||||
// with the injected text (the shared body's stdout return is unused
|
||||
// in-process).
|
||||
if (lifecycle === 'session-start') {
|
||||
await injectBootstrap(ctx, extracted as SessionStartExtracted);
|
||||
return;
|
||||
}
|
||||
|
||||
const input: OpenclawHandlerInput = { event, extracted };
|
||||
const hookCtx: HookContext = { bridge: buildBridge(), logger: createLogger({ name: 'openclaw-hooks/handler' }) };
|
||||
await handler.handle(input, hookCtx);
|
||||
} catch {
|
||||
// FAIL-OPEN: swallow — the gateway flow must never be affected.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recall top-N frames and push the formatted text onto the mutable
|
||||
* `context.bootstrapFiles` array (OpenClaw's sanctioned inject seam). Recall
|
||||
* uses the same provenance scope so the injected context is attributable.
|
||||
* Fails open.
|
||||
*/
|
||||
async function injectBootstrap(
|
||||
ctx: OpenclawRuntimeContext,
|
||||
extracted: SessionStartExtracted,
|
||||
): Promise<void> {
|
||||
const logger = createLogger({ name: 'openclaw-hooks/handler' });
|
||||
try {
|
||||
const bridge = buildBridge();
|
||||
const hits: MemoryHit[] = await bridge.recallMemory('', {
|
||||
limit: extracted.recallLimit,
|
||||
scope: 'personal',
|
||||
});
|
||||
if (hits.length === 0) return;
|
||||
const text = formatHits(hits);
|
||||
const arr = ctx.bootstrapFiles;
|
||||
if (Array.isArray(arr)) {
|
||||
// Mutate the host-owned array in place — this IS the injection seam.
|
||||
(arr as unknown[]).push(text);
|
||||
} else {
|
||||
// The host did not provide a mutable array; nothing to inject into.
|
||||
logger.debug('agent:bootstrap had no bootstrapFiles array — skipping inject');
|
||||
}
|
||||
} catch {
|
||||
// Fail-open: a recall failure must not block bootstrap.
|
||||
}
|
||||
}
|
||||
|
||||
const PER_HIT_BUDGET = 240;
|
||||
|
||||
function formatHits(hits: readonly MemoryHit[]): string {
|
||||
const lines: string[] = [`hive-mind: top ${hits.length} recalled frames`];
|
||||
for (const h of hits) {
|
||||
const content = h.content.length > PER_HIT_BUDGET
|
||||
? h.content.slice(0, PER_HIT_BUDGET) + '…'
|
||||
: h.content;
|
||||
lines.push(`- (${h.importance}) ${h.created_at}: ${content}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
60
packages/hive-mind-hooks-openclaw/src/hook-md.ts
Normal file
60
packages/hive-mind-hooks-openclaw/src/hook-md.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Builds the `HOOK.md` frontmatter the OpenClaw gateway reads to discover our
|
||||
* managed hook directory.
|
||||
*
|
||||
* OpenClaw discovers a hook from `~/.openclaw/hooks/<name>/HOOK.md`: the
|
||||
* frontmatter declares `metadata.openclaw.events[]` (the events the sibling
|
||||
* `handler.js` default-export subscribes to), and the body is human docs.
|
||||
*
|
||||
* IMPORTANT (spec §5.5): the events[] array uses the HOOK.md-style names
|
||||
* INCLUDING the `session:` prefix (`session:compact:before`), but the RUNTIME
|
||||
* `event.action` drops it (`compact:before`). The handler matches on the
|
||||
* action suffix, so declaring the prefixed form here is correct and the
|
||||
* handler still fires.
|
||||
*/
|
||||
|
||||
/** The OpenClaw internal events our handler subscribes to (HOOK.md form). */
|
||||
export const HOOK_MD_EVENTS = [
|
||||
'agent:bootstrap',
|
||||
'message:received',
|
||||
'message:sent',
|
||||
'session:compact:before',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Render the `HOOK.md` contents. `handlerFile` is the basename of the compiled
|
||||
* handler the gateway will `import()` (`handler.js`).
|
||||
*/
|
||||
export function renderHookMd(handlerFile = 'handler.js'): string {
|
||||
const eventsYaml = HOOK_MD_EVENTS.map((e) => ` - ${e}`).join('\n');
|
||||
return [
|
||||
'---',
|
||||
'name: hive-mind',
|
||||
'description: >-',
|
||||
' hive-mind silent capture — routes OpenClaw gateway conversation episodes',
|
||||
' into hive-mind frames via hive-mind-cli. Capture-only; fails open.',
|
||||
'metadata:',
|
||||
' openclaw:',
|
||||
` handler: ${handlerFile}`,
|
||||
' events:',
|
||||
eventsYaml,
|
||||
'---',
|
||||
'',
|
||||
'# hive-mind silent capture (OpenClaw)',
|
||||
'',
|
||||
'This managed hook is installed by `@waggle/hive-mind-hooks-openclaw`. It is',
|
||||
'an **in-process** internal hook: the gateway loads the default export from',
|
||||
`\`${handlerFile}\` and dispatches the events above to it.`,
|
||||
'',
|
||||
'Lifecycle mapping:',
|
||||
'',
|
||||
'- `agent:bootstrap` → recall + inject (mutates `context.bootstrapFiles`)',
|
||||
'- `message:received` → save the inbound prompt as a temporary frame',
|
||||
'- `message:sent` → summarize + save the outbound turn (debounced; 0..N/turn)',
|
||||
'- `session:compact:before` → compaction maintenance (runtime action `compact:before`)',
|
||||
'',
|
||||
'Capture-only and fail-open: the handler never throws and never blocks the',
|
||||
'gateway. Remove with `openclaw-hooks uninstall`.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
86
packages/hive-mind-hooks-openclaw/src/index.ts
Normal file
86
packages/hive-mind-hooks-openclaw/src/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @waggle/hive-mind-hooks-openclaw — barrel export.
|
||||
*
|
||||
* OpenClaw silent-capture shim for hive-mind. Unlike the stdin-JSON tools
|
||||
* (codex / cursor / hermes), OpenClaw hooks are IN-PROCESS TypeScript: a hook
|
||||
* is a directory `~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}` whose
|
||||
* default export the gateway loads and runs inside its own Node process. So
|
||||
* the installer writes that managed dir + minimally touches the JSON5 config,
|
||||
* and the handler drives the shared lifecycle bodies via
|
||||
* `makeOpenclawHandler` (hooks-core) rather than `runHook`.
|
||||
*
|
||||
* Four events (matched on the `(type, action)` PAIR):
|
||||
* - SessionStart → `agent:bootstrap` (mutates `context.bootstrapFiles`)
|
||||
* - UserPromptSubmit → `message:received`
|
||||
* - Stop → `message:sent` (0..N/turn — DEBOUNCED; non-replyable)
|
||||
* - PreCompact → `session:compact:before` (runtime action `compact:before`)
|
||||
*
|
||||
* JSON5 round-trip is lossy, so reversibility relies on the literal
|
||||
* byte-identical backup + a recorded managed-dir removal. Programmatic
|
||||
* install / uninstall / verify lifecycle; most users invoke the
|
||||
* `openclaw-hooks` bin. The in-process `handler.ts` default export is the
|
||||
* `./handler` subpath export.
|
||||
*/
|
||||
|
||||
export type {
|
||||
InstallOptions,
|
||||
InstallResult,
|
||||
} from './install.js';
|
||||
export { install } from './install.js';
|
||||
|
||||
export type {
|
||||
UninstallOptions,
|
||||
UninstallResult,
|
||||
} from './uninstall.js';
|
||||
export { uninstall } from './uninstall.js';
|
||||
|
||||
export type {
|
||||
VerifyOptions,
|
||||
VerifyResult,
|
||||
VerifyCheck,
|
||||
} from './verify.js';
|
||||
export { verify } from './verify.js';
|
||||
|
||||
export type {
|
||||
OpenclawPaths,
|
||||
ResolvePathsOptions,
|
||||
HookBasename,
|
||||
} from './paths.js';
|
||||
export {
|
||||
resolvePaths,
|
||||
allHookBasenames,
|
||||
backupPathFor,
|
||||
hookCommandFor,
|
||||
HIVE_HOOK_DIR_NAME,
|
||||
HIVE_HOOK_ENTRY_KEY,
|
||||
} from './paths.js';
|
||||
|
||||
export {
|
||||
openclawAdapter,
|
||||
OPENCLAW_EVENT_NAME,
|
||||
OPENCLAW_PROVENANCE,
|
||||
} from './adapter.js';
|
||||
|
||||
export type {
|
||||
HiveEntryEnv,
|
||||
RegisterOptions,
|
||||
} from './json5-merger.js';
|
||||
export {
|
||||
parseConfig,
|
||||
serializeConfig,
|
||||
jsonRegister,
|
||||
jsonUnregister,
|
||||
hasHiveEntries,
|
||||
HIVE_ENTRY_KEY,
|
||||
HOOKS_KEY,
|
||||
} from './json5-merger.js';
|
||||
|
||||
export {
|
||||
renderHookMd,
|
||||
HOOK_MD_EVENTS,
|
||||
} from './hook-md.js';
|
||||
|
||||
// The in-process handler default export (the gateway loads this). Re-exported
|
||||
// for tests + programmatic drive; the gateway loads the COMPILED copy from
|
||||
// `~/.openclaw/hooks/hive-mind/handler.js`.
|
||||
export { default as openclawHook } from './handler.js';
|
||||
168
packages/hive-mind-hooks-openclaw/src/install.ts
Normal file
168
packages/hive-mind-hooks-openclaw/src/install.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Programmatic install entry point for the OpenClaw hive-mind hooks.
|
||||
*
|
||||
* OpenClaw hooks are IN-PROCESS, so install is structurally different from the
|
||||
* stdin-JSON tools: we WRITE A MANAGED HOOK DIRECTORY and only minimally touch
|
||||
* the JSON5 config.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Read existing `~/.openclaw/openclaw.json` IF it exists (create-if-missing
|
||||
* — the config is OPTIONAL on a fresh install). Strict JSON first, JSON5
|
||||
* fallback.
|
||||
* 2. If it pre-existed, write a LITERAL byte-identical backup; if absent,
|
||||
* skip the backup and record `created_by_us=true`. (JSON5 round-trip is
|
||||
* lossy — comments / trailing commas are dropped — so reversibility relies
|
||||
* on the literal backup, not a re-serialized diff.)
|
||||
* 3. Write the managed hook DIRECTORY
|
||||
* `~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}` — HOOK.md declares
|
||||
* our events, handler.js is the compiled in-process default export COPIED
|
||||
* from this package's dist/.
|
||||
* 4. Minimal-touch edit of openclaw.json: flip `hooks.internal.enabled=true`
|
||||
* + add `hooks.internal.entries["hive-mind"]={enabled:true, env?}` via
|
||||
* `jsonRegister`. Existing config preserved verbatim.
|
||||
* 5. Write the merged config back over openclaw.json.
|
||||
* 6. Drop a pointer at `~/.openclaw/hive-mind-install.json` recording the
|
||||
* created dir + touched keys (pointer.extra) so uninstall removes exactly
|
||||
* what we added (and restores openclaw.json from the byte-identical
|
||||
* backup, or removes it if we created it).
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
backupByteIdentical,
|
||||
normalizeCliPath,
|
||||
writePointer,
|
||||
type InstallPointer,
|
||||
} from '@waggle/hive-mind-hooks-core';
|
||||
import {
|
||||
resolvePaths,
|
||||
HIVE_HOOK_DIR_NAME,
|
||||
HIVE_HOOK_ENTRY_KEY,
|
||||
type OpenclawPaths,
|
||||
type ResolvePathsOptions,
|
||||
} from './paths.js';
|
||||
import { parseConfig, serializeConfig, jsonRegister } from './json5-merger.js';
|
||||
import { renderHookMd } from './hook-md.js';
|
||||
|
||||
export interface InstallResult {
|
||||
paths: OpenclawPaths;
|
||||
/** The literal byte-identical backup written when openclaw.json pre-existed, else null. */
|
||||
backupPath: string | null;
|
||||
pointerPath: string;
|
||||
/** The managed hook dir we created (`~/.openclaw/hooks/hive-mind/`). */
|
||||
hookDir: string;
|
||||
/** Lifecycle names the single handler dispatches. */
|
||||
installedHooks: readonly string[];
|
||||
/** openclaw.json keys we touched (recorded in pointer.extra). */
|
||||
touchedKeys: readonly string[];
|
||||
/** True when openclaw.json did NOT pre-exist and we created it. */
|
||||
createdByUs: boolean;
|
||||
/** The cli_path embedded in the entry env (undefined = default lookup at runtime). */
|
||||
cliPath?: string;
|
||||
}
|
||||
|
||||
export interface InstallOptions extends ResolvePathsOptions {
|
||||
/** Override clock for deterministic tests. */
|
||||
now?: () => Date;
|
||||
/** Logger override. */
|
||||
logger?: Logger;
|
||||
/**
|
||||
* Absolute path to the hive-mind-cli binary or its compiled JS entry.
|
||||
* Required on Windows (npm bin shim is `.cmd` and can't be exec'd without a
|
||||
* shell). Threaded into the hook entry env as `WAGGLE_HIVE_MIND_CLI` so the
|
||||
* in-process handler's CliBridge uses it.
|
||||
*/
|
||||
cliPath?: string;
|
||||
/** Extra env to attach to the hook entry (e.g. WAGGLE_WORKSPACE_ID). */
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
const POINTER_VERSION = '0.1.0';
|
||||
const TOUCHED_KEYS = ['hooks.internal.enabled', `hooks.internal.entries.${HIVE_HOOK_ENTRY_KEY}`] as const;
|
||||
const LIFECYCLE_NAMES = ['session-start', 'user-prompt-submit', 'stop', 'pre-compact'] as const;
|
||||
|
||||
async function ensureDir(p: string): Promise<void> {
|
||||
if (!existsSync(p)) await mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
export async function install(opts: InstallOptions = {}): Promise<InstallResult> {
|
||||
const log = opts.logger ?? createLogger({ name: 'openclaw-hooks/install' });
|
||||
const paths = resolvePaths({
|
||||
...(opts.home !== undefined ? { home: opts.home } : {}),
|
||||
...(opts.handlerSourcePath !== undefined
|
||||
? { handlerSourcePath: opts.handlerSourcePath }
|
||||
: { moduleUrl: import.meta.url }),
|
||||
});
|
||||
const now = opts.now ?? ((): Date => new Date());
|
||||
|
||||
log.info('install starting', { config: paths.configPath, hookDir: paths.hiveHookDir });
|
||||
|
||||
// Read existing config if present; create-if-missing otherwise.
|
||||
let existingConfig: Record<string, unknown> | undefined;
|
||||
const preExisted = existsSync(paths.configPath);
|
||||
if (preExisted) {
|
||||
existingConfig = parseConfig(await readFile(paths.configPath, 'utf-8'));
|
||||
}
|
||||
|
||||
await ensureDir(paths.openclawDir);
|
||||
await ensureDir(dirname(paths.pointerPath));
|
||||
|
||||
// Literal byte-identical backup of the original config (no-op when absent).
|
||||
const { backupPath } = await backupByteIdentical(paths.configPath, now().toISOString());
|
||||
if (backupPath) log.info('openclaw.json backed up', { backupPath });
|
||||
|
||||
// Write the managed hook dir: HOOK.md + the compiled handler.js (copied from
|
||||
// this package's dist/). We COPY rather than reference so the gateway loads a
|
||||
// stable file even if this package is removed (the handler still requires the
|
||||
// @waggle/* deps at runtime — the OQ-5 live-install caveat, see README).
|
||||
await ensureDir(paths.hiveHookDir);
|
||||
await writeFile(paths.hookMdPath, renderHookMd('handler.js'), 'utf-8');
|
||||
if (!existsSync(paths.handlerSourcePath)) {
|
||||
throw new Error(
|
||||
`compiled handler not found at ${paths.handlerSourcePath}. ` +
|
||||
`Build the package (tsc --build) before installing.`,
|
||||
);
|
||||
}
|
||||
await copyFile(paths.handlerSourcePath, paths.installedHandlerPath);
|
||||
|
||||
// Minimal-touch config edit.
|
||||
const cliPath = normalizeCliPath(opts.cliPath);
|
||||
const env: Record<string, string> = { ...(opts.env ?? {}) };
|
||||
if (cliPath !== undefined) env['WAGGLE_HIVE_MIND_CLI'] = cliPath;
|
||||
const merged = jsonRegister(existingConfig, Object.keys(env).length > 0 ? { env } : {});
|
||||
await writeFile(paths.configPath, serializeConfig(merged), 'utf-8');
|
||||
|
||||
const createdByUs = !preExisted;
|
||||
const pointer: InstallPointer = {
|
||||
version: POINTER_VERSION,
|
||||
installed_at: now().toISOString(),
|
||||
config_path: paths.configPath,
|
||||
settings_backup: backupPath,
|
||||
created_by_us: createdByUs,
|
||||
hooks_dir: paths.hiveHookDir,
|
||||
installed_hooks: LIFECYCLE_NAMES,
|
||||
cli_path: cliPath ?? null,
|
||||
extra: {
|
||||
hook_dir_name: HIVE_HOOK_DIR_NAME,
|
||||
touched_keys: TOUCHED_KEYS,
|
||||
},
|
||||
};
|
||||
await writePointer(paths.pointerPath, pointer);
|
||||
|
||||
log.info('install complete', { createdByUs, hookDir: paths.hiveHookDir, cliPath: cliPath ?? '(PATH lookup)' });
|
||||
|
||||
const result: InstallResult = {
|
||||
paths,
|
||||
backupPath,
|
||||
pointerPath: paths.pointerPath,
|
||||
hookDir: paths.hiveHookDir,
|
||||
installedHooks: LIFECYCLE_NAMES,
|
||||
touchedKeys: TOUCHED_KEYS,
|
||||
createdByUs,
|
||||
};
|
||||
if (cliPath !== undefined) result.cliPath = cliPath;
|
||||
return result;
|
||||
}
|
||||
172
packages/hive-mind-hooks-openclaw/src/json5-merger.ts
Normal file
172
packages/hive-mind-hooks-openclaw/src/json5-merger.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Bespoke JSON5 codec + minimal-touch register/unregister for OpenClaw's
|
||||
* `~/.openclaw/openclaw.json`.
|
||||
*
|
||||
* OpenClaw's config is JSON5 (comments, trailing commas, `$include` merges,
|
||||
* `${ENV}` substitution). Naive `JSON.parse`→`JSON.stringify` DESTROYS user
|
||||
* comments and trailing commas, so we do NOT re-serialize the whole file for
|
||||
* fidelity. Instead:
|
||||
* - the actual hook IMPLEMENTATION lives in a managed DIRECTORY
|
||||
* (`~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}`), written by
|
||||
* install.ts;
|
||||
* - this module performs a MINIMAL-TOUCH edit of openclaw.json — flip
|
||||
* `hooks.internal.enabled = true` and add an
|
||||
* `hooks.internal.entries["hive-mind"] = { enabled: true }` entry — so the
|
||||
* gateway discovers + activates our hook dir;
|
||||
* - reversibility relies on the LITERAL byte-identical backup the installer
|
||||
* writes (install-core `backupByteIdentical` / `restoreFromBackup`), NOT
|
||||
* on re-serialization fidelity. This module only produces the merged
|
||||
* object we WRITE on install.
|
||||
*
|
||||
* Parse priority: strict `JSON.parse` FIRST (fast, exact), then `JSON5.parse`
|
||||
* fallback (tolerates comments / trailing commas). Both fail → throw so the
|
||||
* installer fails loudly rather than clobbering a user config.
|
||||
*
|
||||
* Immutability: every function returns a NEW config object and never mutates
|
||||
* its input (mirrors the `jsonRegister` contract).
|
||||
*/
|
||||
|
||||
import JSON5 from 'json5';
|
||||
|
||||
/** Logical entry key under `hooks.internal.entries` identifying our hook. */
|
||||
export const HIVE_ENTRY_KEY = 'hive-mind';
|
||||
|
||||
/** Top-level JSON5 key holding the hooks subsystem. */
|
||||
export const HOOKS_KEY = 'hooks';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an `openclaw.json` string into a plain object. Returns `{}` for an
|
||||
* empty/whitespace string (create-if-missing). Tries strict JSON first, then
|
||||
* JSON5; throws only if BOTH fail.
|
||||
*/
|
||||
export function parseConfig(raw: string): Record<string, unknown> {
|
||||
if (!raw || raw.trim().length === 0) return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
try {
|
||||
parsed = JSON5.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'failed to parse ~/.openclaw/openclaw.json as JSON or JSON5: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
const record = asRecord(parsed);
|
||||
// A top-level scalar/array doc is not a valid openclaw config shape; treat
|
||||
// it as empty rather than crashing (the original bytes are backed up
|
||||
// regardless, so nothing is lost on uninstall).
|
||||
return record ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a config object back to JSON (2-space, trailing newline). NOTE:
|
||||
* this is LOSSY for JSON5 sources (comments / trailing commas are dropped) —
|
||||
* which is exactly why uninstall restores the literal byte-identical backup
|
||||
* instead of re-serializing. We use plain `JSON.stringify` (not JSON5) so the
|
||||
* written file is valid strict JSON, which OpenClaw's JSON5 parser also
|
||||
* accepts.
|
||||
*/
|
||||
export function serializeConfig(config: Record<string, unknown>): string {
|
||||
return JSON.stringify(config, null, 2) + '\n';
|
||||
}
|
||||
|
||||
/** The minimal env block our entry carries (workspace context for the handler). */
|
||||
export interface HiveEntryEnv {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface RegisterOptions {
|
||||
/** Optional env to attach to the entry (e.g. WAGGLE_WORKSPACE_ID). */
|
||||
env?: HiveEntryEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a NEW config object with the internal-hooks subsystem enabled and a
|
||||
* marker entry pointing the gateway at our managed `hive-mind` hook dir.
|
||||
* Existing config (including any other `hooks.internal.entries`) is preserved
|
||||
* verbatim. Re-running install replaces OUR entry in place (idempotent
|
||||
* upgrade) rather than duplicating.
|
||||
*
|
||||
* Touched keys (recorded by the caller in the pointer.extra so uninstall —
|
||||
* the rare backup-less path — knows exactly what to strip):
|
||||
* - hooks.internal.enabled = true
|
||||
* - hooks.internal.entries["hive-mind"] = { enabled: true, env? }
|
||||
*/
|
||||
export function jsonRegister(
|
||||
config: Record<string, unknown> | undefined,
|
||||
opts: RegisterOptions = {},
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = config ? { ...config } : {};
|
||||
|
||||
const hooks = asRecord(next[HOOKS_KEY]);
|
||||
const nextHooks: Record<string, unknown> = hooks ? { ...hooks } : {};
|
||||
|
||||
const internal = asRecord(nextHooks['internal']);
|
||||
const nextInternal: Record<string, unknown> = internal ? { ...internal } : {};
|
||||
nextInternal['enabled'] = true;
|
||||
|
||||
const entries = asRecord(nextInternal['entries']);
|
||||
const nextEntries: Record<string, unknown> = entries ? { ...entries } : {};
|
||||
const entry: Record<string, unknown> = { enabled: true };
|
||||
if (opts.env && Object.keys(opts.env).length > 0) entry['env'] = { ...opts.env };
|
||||
nextEntries[HIVE_ENTRY_KEY] = entry;
|
||||
nextInternal['entries'] = nextEntries;
|
||||
|
||||
nextHooks['internal'] = nextInternal;
|
||||
next[HOOKS_KEY] = nextHooks;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a NEW config object with our `hive-mind` entry removed from
|
||||
* `hooks.internal.entries`. Leaves `hooks.internal.enabled` as-is (other hooks
|
||||
* may rely on it — minimal-touch). Non-hive entries preserved verbatim. Never
|
||||
* mutates input.
|
||||
*
|
||||
* Note: uninstall normally restores the literal byte-identical backup, so this
|
||||
* is used for verify/diagnostics and the (rare) backup-less path.
|
||||
*/
|
||||
export function jsonUnregister(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = config ? { ...config } : {};
|
||||
const hooks = asRecord(next[HOOKS_KEY]);
|
||||
if (!hooks) return next;
|
||||
const nextHooks: Record<string, unknown> = { ...hooks };
|
||||
|
||||
const internal = asRecord(nextHooks['internal']);
|
||||
if (!internal) {
|
||||
next[HOOKS_KEY] = nextHooks;
|
||||
return next;
|
||||
}
|
||||
const nextInternal: Record<string, unknown> = { ...internal };
|
||||
|
||||
const entries = asRecord(nextInternal['entries']);
|
||||
if (entries) {
|
||||
const nextEntries: Record<string, unknown> = { ...entries };
|
||||
delete nextEntries[HIVE_ENTRY_KEY];
|
||||
nextInternal['entries'] = nextEntries;
|
||||
}
|
||||
|
||||
nextHooks['internal'] = nextInternal;
|
||||
next[HOOKS_KEY] = nextHooks;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** True iff `hooks.internal.entries["hive-mind"]` is present. */
|
||||
export function hasHiveEntries(config: Record<string, unknown> | undefined): boolean {
|
||||
if (!config) return false;
|
||||
const hooks = asRecord(config[HOOKS_KEY]);
|
||||
const internal = asRecord(hooks?.['internal']);
|
||||
const entries = asRecord(internal?.['entries']);
|
||||
return !!entries && Object.prototype.hasOwnProperty.call(entries, HIVE_ENTRY_KEY);
|
||||
}
|
||||
120
packages/hive-mind-hooks-openclaw/src/paths.ts
Normal file
120
packages/hive-mind-hooks-openclaw/src/paths.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Filesystem path helpers for the OpenClaw hive-mind hook install lifecycle.
|
||||
*
|
||||
* OpenClaw differs structurally from the stdin-JSON tools (codex / cursor /
|
||||
* hermes): its hooks are IN-PROCESS TypeScript. A hook is a DIRECTORY
|
||||
* `~/.openclaw/hooks/<name>/{HOOK.md, handler.js}` that the gateway discovers
|
||||
* and dynamically `import()`s. Config (`~/.openclaw/openclaw.json`, JSON5)
|
||||
* only flips the internal-hooks subsystem on and references the dir.
|
||||
*
|
||||
* So this module resolves THREE roots the JSON tools don't have:
|
||||
* - the managed hook DIRECTORY we write (`~/.openclaw/hooks/hive-mind/`),
|
||||
* - the compiled `handler.js` we COPY into that dir at install time,
|
||||
* - the `HOOK.md` we write alongside it.
|
||||
*
|
||||
* The Windows-safe backup path + `--cli-path` quoting are reused verbatim
|
||||
* from `@waggle/hive-mind-hooks-core` so openclaw reads like the reference.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { backupPathFor, hookCommandFor } from '@waggle/hive-mind-hooks-core';
|
||||
|
||||
/** Name of the managed hook directory we create under `~/.openclaw/hooks/`. */
|
||||
export const HIVE_HOOK_DIR_NAME = 'hive-mind';
|
||||
|
||||
/** Logical hook entry key under `hooks.internal.entries`. */
|
||||
export const HIVE_HOOK_ENTRY_KEY = 'hive-mind';
|
||||
|
||||
export interface OpenclawPaths {
|
||||
/** OpenClaw config root (`~/.openclaw/`). */
|
||||
openclawDir: string;
|
||||
/** `~/.openclaw/openclaw.json` — the JSON5 config (internal-hooks subsystem). */
|
||||
configPath: string;
|
||||
/** `~/.openclaw/hive-mind-install.json` — pointer to the active backup + created dir. */
|
||||
pointerPath: string;
|
||||
/** `~/.openclaw/hooks/` — OpenClaw's hook-discovery root. */
|
||||
hooksRoot: string;
|
||||
/** `~/.openclaw/hooks/hive-mind/` — the managed hook dir WE write. */
|
||||
hiveHookDir: string;
|
||||
/** `~/.openclaw/hooks/hive-mind/HOOK.md` — frontmatter declaring our events. */
|
||||
hookMdPath: string;
|
||||
/** `~/.openclaw/hooks/hive-mind/handler.js` — the compiled handler we copy in. */
|
||||
installedHandlerPath: string;
|
||||
/** Source of the self-contained handler bundle in THIS package's `dist/`. */
|
||||
handlerSourcePath: string;
|
||||
}
|
||||
|
||||
export interface ResolvePathsOptions {
|
||||
/** Override $HOME for tests. */
|
||||
home?: string;
|
||||
/** Override the URL used to locate dist/ (defaults to import.meta.url at runtime). */
|
||||
moduleUrl?: string;
|
||||
/** Override the compiled-handler source path directly (wins over moduleUrl). */
|
||||
handlerSourcePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenClaw's four internal lifecycle hooks. Unlike the stdin tools these are
|
||||
* NOT separate compiled scripts — one `handler.js` default-export dispatches
|
||||
* all four by `(type, action)`. The basenames here name the LIFECYCLE for
|
||||
* pointer bookkeeping + verify, not separate files on disk.
|
||||
*/
|
||||
const HOOK_BASENAMES = [
|
||||
'session-start',
|
||||
'user-prompt-submit',
|
||||
'stop',
|
||||
'pre-compact',
|
||||
] as const;
|
||||
|
||||
export type HookBasename = typeof HOOK_BASENAMES[number];
|
||||
|
||||
export function allHookBasenames(): readonly HookBasename[] {
|
||||
return HOOK_BASENAMES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the self-contained handler bundle shipped in this package's `dist/`.
|
||||
* The compiled install module lives at `<pkg>/dist/<file>.js`; dirname gives
|
||||
* `<pkg>/dist/`, so `handler.bundle.cjs` is a sibling.
|
||||
*/
|
||||
function handlerSourceFromModuleUrl(moduleUrl: string): string {
|
||||
const dir = dirname(fileURLToPath(moduleUrl));
|
||||
return resolve(dir, 'handler.bundle.cjs');
|
||||
}
|
||||
|
||||
export function resolvePaths(opts: ResolvePathsOptions = {}): OpenclawPaths {
|
||||
const home = opts.home ?? homedir();
|
||||
const openclawDir = join(home, '.openclaw');
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
const pointerPath = join(openclawDir, 'hive-mind-install.json');
|
||||
const hooksRoot = join(openclawDir, 'hooks');
|
||||
const hiveHookDir = join(hooksRoot, HIVE_HOOK_DIR_NAME);
|
||||
const hookMdPath = join(hiveHookDir, 'HOOK.md');
|
||||
const installedHandlerPath = join(hiveHookDir, 'handler.js');
|
||||
|
||||
let handlerSourcePath: string;
|
||||
if (opts.handlerSourcePath) {
|
||||
handlerSourcePath = resolve(opts.handlerSourcePath);
|
||||
} else if (opts.moduleUrl) {
|
||||
handlerSourcePath = handlerSourceFromModuleUrl(opts.moduleUrl);
|
||||
} else {
|
||||
// Fallback for ad-hoc test use — install.ts always passes moduleUrl.
|
||||
handlerSourcePath = resolve(process.cwd(), 'dist', 'handler.bundle.cjs');
|
||||
}
|
||||
|
||||
return {
|
||||
openclawDir,
|
||||
configPath,
|
||||
pointerPath,
|
||||
hooksRoot,
|
||||
hiveHookDir,
|
||||
hookMdPath,
|
||||
installedHandlerPath,
|
||||
handlerSourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-export the shared Windows-safe helpers so openclaw modules read like CC. */
|
||||
export { backupPathFor, hookCommandFor };
|
||||
98
packages/hive-mind-hooks-openclaw/src/uninstall.ts
Normal file
98
packages/hive-mind-hooks-openclaw/src/uninstall.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Programmatic uninstall entry point for the OpenClaw hive-mind hooks.
|
||||
*
|
||||
* Removes EXACTLY what install added:
|
||||
* 1. Delete the managed hook DIRECTORY `~/.openclaw/hooks/hive-mind/`
|
||||
* (HOOK.md + handler.js) recorded in the pointer.
|
||||
* 2. Restore `~/.openclaw/openclaw.json`:
|
||||
* - `created_by_us=false` (config pre-existed): restore the LITERAL
|
||||
* byte-identical backup the installer wrote; refuse to delete the
|
||||
* backup unless the in-place readback matches. (JSON5 re-serialization
|
||||
* is lossy, so we restore the original BYTES — comments and trailing
|
||||
* commas are preserved exactly because we never touch the re-serialized
|
||||
* merge on this path.)
|
||||
* - `created_by_us=true` (we created openclaw.json): delete the file we
|
||||
* created — never orphan it, never leave a backup behind.
|
||||
* 3. Remove the pointer.
|
||||
*
|
||||
* The config restore/delete is handled by the shared `restoreFromBackup`
|
||||
* primitive; the hook-dir removal is openclaw-specific.
|
||||
*/
|
||||
|
||||
import { unlink, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
||||
import { readPointer, restoreFromBackup } from '@waggle/hive-mind-hooks-core';
|
||||
import { resolvePaths, type OpenclawPaths, type ResolvePathsOptions } from './paths.js';
|
||||
|
||||
export interface UninstallResult {
|
||||
paths: OpenclawPaths;
|
||||
/** Backup restored from, or null when we deleted a file we created. */
|
||||
restoredFrom: string | null;
|
||||
/** True when created_by_us=true and we removed the config we created. */
|
||||
createdRemoved: boolean;
|
||||
/** True when the managed hook dir was removed. */
|
||||
hookDirRemoved: boolean;
|
||||
pointerRemoved: boolean;
|
||||
backupRemoved: boolean;
|
||||
}
|
||||
|
||||
export interface UninstallOptions extends ResolvePathsOptions {
|
||||
logger?: Logger;
|
||||
/** If false, leaves the backup file in place after restore. Default true. */
|
||||
cleanupBackup?: boolean;
|
||||
}
|
||||
|
||||
export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
|
||||
const log = opts.logger ?? createLogger({ name: 'openclaw-hooks/uninstall' });
|
||||
const paths = resolvePaths({
|
||||
...(opts.home !== undefined ? { home: opts.home } : {}),
|
||||
...(opts.handlerSourcePath !== undefined
|
||||
? { handlerSourcePath: opts.handlerSourcePath }
|
||||
: { moduleUrl: import.meta.url }),
|
||||
});
|
||||
const cleanup = opts.cleanupBackup ?? true;
|
||||
|
||||
if (!existsSync(paths.pointerPath)) {
|
||||
throw new Error(
|
||||
`no install pointer found at ${paths.pointerPath}. ` +
|
||||
`Was @waggle/hive-mind-hooks-openclaw ever installed for this user?`,
|
||||
);
|
||||
}
|
||||
|
||||
const pointer = await readPointer(paths.pointerPath);
|
||||
|
||||
// 1. Remove the managed hook dir we created. Prefer the pointer's recorded
|
||||
// hooks_dir; fall back to the resolved path.
|
||||
const hookDir = pointer.hooks_dir ?? paths.hiveHookDir;
|
||||
let hookDirRemoved = false;
|
||||
if (existsSync(hookDir)) {
|
||||
await rm(hookDir, { recursive: true, force: true });
|
||||
hookDirRemoved = true;
|
||||
log.info('managed hook dir removed', { hookDir });
|
||||
}
|
||||
|
||||
// 2. Restore / delete openclaw.json via the shared primitive.
|
||||
const restore = await restoreFromBackup({
|
||||
configPath: paths.configPath,
|
||||
pointer,
|
||||
cleanupBackup: cleanup,
|
||||
});
|
||||
if (restore.createdRemoved) {
|
||||
log.info('openclaw.json removed (created by us)', { config: paths.configPath });
|
||||
} else {
|
||||
log.info('openclaw.json restored byte-identical', { config: paths.configPath });
|
||||
}
|
||||
|
||||
// 3. Remove the pointer.
|
||||
await unlink(paths.pointerPath);
|
||||
|
||||
return {
|
||||
paths,
|
||||
restoredFrom: restore.restoredFrom,
|
||||
createdRemoved: restore.createdRemoved,
|
||||
hookDirRemoved,
|
||||
pointerRemoved: true,
|
||||
backupRemoved: restore.backupRemoved,
|
||||
};
|
||||
}
|
||||
179
packages/hive-mind-hooks-openclaw/src/verify.ts
Normal file
179
packages/hive-mind-hooks-openclaw/src/verify.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Smoke-check the OpenClaw install: openclaw.json exists + parses (JSON/JSON5)
|
||||
* + carries the hive-mind internal-hooks entry with the subsystem enabled, the
|
||||
* managed hook dir + HOOK.md + handler.js exist on disk, and hive-mind-cli
|
||||
* answers a `--help` probe. Plus the openclaw-specific activation advisory
|
||||
* (spec §5.5 / §6.2): hooks are OFF until `hooks.internal.enabled=true`.
|
||||
*
|
||||
* Probe priority for `cli_path`:
|
||||
* 1. Explicit `opts.cliPath` (caller override)
|
||||
* 2. `cli_path` recorded in `~/.openclaw/hive-mind-install.json`
|
||||
* 3. Bare `'hive-mind-cli'` on PATH
|
||||
*/
|
||||
|
||||
import { readFile, access } from 'node:fs/promises';
|
||||
import { constants, existsSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
||||
import { resolvePaths, type ResolvePathsOptions } from './paths.js';
|
||||
import { parseConfig, hasHiveEntries, HOOKS_KEY } from './json5-merger.js';
|
||||
|
||||
export interface VerifyCheck {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
ok: boolean;
|
||||
checks: VerifyCheck[];
|
||||
}
|
||||
|
||||
export interface VerifyOptions extends ResolvePathsOptions {
|
||||
logger?: Logger;
|
||||
/** Override hive-mind-cli executable name. Default 'hive-mind-cli'. */
|
||||
cliPath?: string;
|
||||
/** Test hook for spawn. */
|
||||
spawnImpl?: typeof spawn;
|
||||
}
|
||||
|
||||
async function fileReadable(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.R_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function isJsPath(p: string): boolean {
|
||||
return p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs');
|
||||
}
|
||||
|
||||
function internalEnabled(config: Record<string, unknown>): boolean {
|
||||
const hooks = config[HOOKS_KEY];
|
||||
if (!hooks || typeof hooks !== 'object') return false;
|
||||
const internal = (hooks as Record<string, unknown>)['internal'];
|
||||
if (!internal || typeof internal !== 'object') return false;
|
||||
return (internal as Record<string, unknown>)['enabled'] === true;
|
||||
}
|
||||
|
||||
function probeCliVersion(
|
||||
cliPath: string,
|
||||
spawnImpl: typeof spawn,
|
||||
timeoutMs: number,
|
||||
): Promise<{ ok: boolean; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const command = isJsPath(cliPath) ? process.execPath : cliPath;
|
||||
const args = isJsPath(cliPath) ? [cliPath, '--help'] : ['--help'];
|
||||
const child = spawnImpl(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { child.kill('SIGTERM'); } catch { /* gone */ }
|
||||
resolve({ ok: false, output: 'timed out probing hive-mind-cli' });
|
||||
}, timeoutMs);
|
||||
child.stdout?.on('data', (c: Buffer) => stdout.push(c));
|
||||
child.stderr?.on('data', (c: Buffer) => stderr.push(c));
|
||||
child.on('error', (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, output: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const out = Buffer.concat(stdout).toString('utf-8').slice(0, 200);
|
||||
const err = Buffer.concat(stderr).toString('utf-8').slice(0, 200);
|
||||
resolve({ ok: code === 0, output: code === 0 ? out : err });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
|
||||
const log = opts.logger ?? createLogger({ name: 'openclaw-hooks/verify' });
|
||||
const paths = resolvePaths({
|
||||
...(opts.home !== undefined ? { home: opts.home } : {}),
|
||||
...(opts.handlerSourcePath !== undefined
|
||||
? { handlerSourcePath: opts.handlerSourcePath }
|
||||
: { moduleUrl: import.meta.url }),
|
||||
});
|
||||
const checks: VerifyCheck[] = [];
|
||||
|
||||
// 1. openclaw.json exists and parses.
|
||||
if (!existsSync(paths.configPath)) {
|
||||
checks.push({ name: 'openclaw.json exists', ok: false, detail: paths.configPath });
|
||||
return { ok: false, checks };
|
||||
}
|
||||
checks.push({ name: 'openclaw.json exists', ok: true, detail: paths.configPath });
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = parseConfig(await readFile(paths.configPath, 'utf-8'));
|
||||
checks.push({ name: 'openclaw.json parses as JSON/JSON5', ok: true });
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
name: 'openclaw.json parses as JSON/JSON5',
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { ok: false, checks };
|
||||
}
|
||||
|
||||
// 2. hive-mind internal-hooks entry present.
|
||||
checks.push({
|
||||
name: 'openclaw.json contains the hive-mind internal-hooks entry',
|
||||
ok: hasHiveEntries(parsed),
|
||||
});
|
||||
|
||||
// 3. internal hooks subsystem enabled (hooks are OFF otherwise).
|
||||
const enabled = internalEnabled(parsed);
|
||||
checks.push({
|
||||
name: 'internal hooks subsystem enabled',
|
||||
ok: enabled,
|
||||
detail: enabled
|
||||
? 'hooks.internal.enabled: true — the hive-mind hook is active.'
|
||||
: 'set hooks.internal.enabled: true (or run `openclaw hooks enable hive-mind`) — hooks are OFF until opted in.',
|
||||
});
|
||||
|
||||
// 4. managed hook dir + HOOK.md + compiled handler exist on disk.
|
||||
checks.push({
|
||||
name: 'managed hook dir exists',
|
||||
ok: existsSync(paths.hiveHookDir),
|
||||
detail: paths.hiveHookDir,
|
||||
});
|
||||
checks.push({
|
||||
name: 'HOOK.md readable on disk',
|
||||
ok: await fileReadable(paths.hookMdPath),
|
||||
detail: paths.hookMdPath,
|
||||
});
|
||||
checks.push({
|
||||
name: 'handler.js readable on disk',
|
||||
ok: await fileReadable(paths.installedHandlerPath),
|
||||
detail: paths.installedHandlerPath,
|
||||
});
|
||||
|
||||
// 5. hive-mind-cli responds to --help (prefer install-pinned --cli-path).
|
||||
let cliPathFromPointer: string | undefined;
|
||||
if (existsSync(paths.pointerPath)) {
|
||||
try {
|
||||
const pointerObj = JSON.parse(await readFile(paths.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
const pointerCliPath = pointerObj['cli_path'];
|
||||
if (typeof pointerCliPath === 'string' && pointerCliPath.length > 0) {
|
||||
cliPathFromPointer = pointerCliPath;
|
||||
}
|
||||
} catch { /* pointer unreadable — fall through */ }
|
||||
}
|
||||
const cliPath = opts.cliPath ?? cliPathFromPointer ?? 'hive-mind-cli';
|
||||
const spawnImpl = opts.spawnImpl ?? spawn;
|
||||
const probe = await probeCliVersion(cliPath, spawnImpl, 4000);
|
||||
checks.push({
|
||||
name: 'hive-mind-cli reachable',
|
||||
ok: probe.ok,
|
||||
detail: cliPathFromPointer ? `${probe.output} (pinned: ${cliPath})` : probe.output,
|
||||
});
|
||||
|
||||
const ok = checks.every((c) => c.ok);
|
||||
log.info('verify complete', { ok, total: checks.length, failed: checks.filter((c) => !c.ok).length });
|
||||
return { ok, checks };
|
||||
}
|
||||
271
packages/hive-mind-hooks-openclaw/tests/handler.test.ts
Normal file
271
packages/hive-mind-hooks-openclaw/tests/handler.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createLogger } from '@waggle/hive-mind-shim-core';
|
||||
import type {
|
||||
CliBridge,
|
||||
HookFrame,
|
||||
MemoryHit,
|
||||
SaveMemoryResult,
|
||||
} from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
makeOpenclawHandler,
|
||||
type HookContext,
|
||||
type InternalHookEventLike,
|
||||
type OpenclawHandlerInput,
|
||||
type SessionStartExtracted,
|
||||
type StopExtracted,
|
||||
type UserPromptExtracted,
|
||||
} from '@waggle/hive-mind-hooks-core';
|
||||
import {
|
||||
openclawAdapter,
|
||||
OPENCLAW_EVENT_NAME,
|
||||
OPENCLAW_PROVENANCE,
|
||||
} from '../src/adapter.js';
|
||||
|
||||
const SILENT = createLogger({ name: 'test', write: () => { /* swallow log output */ } });
|
||||
|
||||
/** A recording mock CliBridge — never shells out, never touches ~/.openclaw. */
|
||||
interface MockBridge extends CliBridge {
|
||||
saved: HookFrame[];
|
||||
recalls: number;
|
||||
cleanups: number;
|
||||
}
|
||||
|
||||
function makeMockBridge(opts: { hits?: MemoryHit[]; throwOn?: 'save' | 'recall' | 'cleanup' } = {}): MockBridge {
|
||||
const saved: HookFrame[] = [];
|
||||
const bridge = {
|
||||
saved,
|
||||
recalls: 0,
|
||||
cleanups: 0,
|
||||
async callMcpTool<T>(): Promise<T> {
|
||||
return undefined as unknown as T;
|
||||
},
|
||||
async saveMemory(frame: HookFrame): Promise<SaveMemoryResult> {
|
||||
if (opts.throwOn === 'save') throw new Error('boom: save_memory failed');
|
||||
saved.push(frame);
|
||||
return { id: String(saved.length), success: true, workspace: 'personal' };
|
||||
},
|
||||
async recallMemory(): Promise<MemoryHit[]> {
|
||||
bridge.recalls += 1;
|
||||
if (opts.throwOn === 'recall') throw new Error('boom: recall_memory failed');
|
||||
return opts.hits ?? [];
|
||||
},
|
||||
async cleanupFrames(): Promise<{ pruned: number }> {
|
||||
bridge.cleanups += 1;
|
||||
if (opts.throwOn === 'cleanup') throw new Error('boom: cleanup_frames failed');
|
||||
return { pruned: 0 };
|
||||
},
|
||||
setWorkspaceById(): void { /* noop */ },
|
||||
getActiveWorkspaceId(): undefined { return undefined; },
|
||||
} as unknown as MockBridge;
|
||||
return bridge;
|
||||
}
|
||||
|
||||
function ctxFor(bridge: CliBridge): HookContext {
|
||||
return { bridge, logger: SILENT };
|
||||
}
|
||||
|
||||
function provScope(sessionId: string): string {
|
||||
return `${OPENCLAW_PROVENANCE}:${sessionId}`;
|
||||
}
|
||||
|
||||
function hit(content: string): MemoryHit {
|
||||
return { id: 1, content, importance: 'important', source: 'openclaw', score: 1, created_at: '2026-06-01', from: 'personal' };
|
||||
}
|
||||
|
||||
describe('openclawAdapter (event map + field extraction over event.context)', () => {
|
||||
it('maps the four lifecycles to OpenClaw type:action keys; pre-compact uses the session: prefix', () => {
|
||||
expect(OPENCLAW_EVENT_NAME['session-start']).toBe('agent:bootstrap');
|
||||
expect(OPENCLAW_EVENT_NAME['user-prompt-submit']).toBe('message:received');
|
||||
expect(OPENCLAW_EVENT_NAME['stop']).toBe('message:sent');
|
||||
// HOOK.md-form key (prefixed) — the runtime action drops the session: prefix.
|
||||
expect(OPENCLAW_EVENT_NAME['pre-compact']).toBe('session:compact:before');
|
||||
});
|
||||
|
||||
it('source is openclaw', () => {
|
||||
expect(openclawAdapter.source).toBe('openclaw');
|
||||
});
|
||||
|
||||
it('extracts cwd / sessionId (channelId-first) / prompt / response / parent from the context object', () => {
|
||||
const received = { cwd: '/work', channelId: 'chan-7', content: 'the inbound prompt' };
|
||||
expect(openclawAdapter.extractCwd(received)).toBe('/work');
|
||||
expect(openclawAdapter.extractSessionId(received)).toBe('chan-7');
|
||||
expect(openclawAdapter.extractPrompt(received)).toBe('the inbound prompt');
|
||||
|
||||
const sent = { content: 'the outbound reply', parentId: 'p-1' };
|
||||
expect(openclawAdapter.extractResponse(sent, {})).toBe('the outbound reply');
|
||||
expect(openclawAdapter.extractParent(sent)).toBe('p-1');
|
||||
});
|
||||
|
||||
it('formatInject produces the { additionalContext } shape pushed into bootstrapFiles', () => {
|
||||
expect(openclawAdapter.formatInject?.('recalled frames here')).toEqual({ additionalContext: 'recalled frames here' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── makeOpenclawHandler drive path (the in-process handler) ─────────────
|
||||
|
||||
describe('makeOpenclawHandler — message:received saves a temporary frame', () => {
|
||||
it('persists the inbound prompt as a temporary frame, provenance-scoped to openclaw-gateway:<channel>', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const extracted: UserPromptExtracted = {
|
||||
prompt: 'hello from the gateway',
|
||||
cwd: '/work',
|
||||
sessionId: provScope('chan-7'),
|
||||
};
|
||||
const event: InternalHookEventLike = { type: 'message', action: 'received' };
|
||||
await handler.handle({ event, extracted }, ctxFor(bridge));
|
||||
|
||||
expect(bridge.saved).toHaveLength(1);
|
||||
const frame = bridge.saved[0];
|
||||
expect(frame.importance).toBe('temporary');
|
||||
expect(frame.content).toBe('hello from the gateway');
|
||||
// Provenance stamp rides the frame scope (the only attribution channel save preserves).
|
||||
expect(frame.scope).toBe(provScope('chan-7'));
|
||||
expect(frame.scope).toContain('openclaw-gateway');
|
||||
expect(frame.source).toBe('openclaw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeOpenclawHandler — agent:bootstrap recalls + the adapter injects into bootstrapFiles', () => {
|
||||
it('mutates the host-owned bootstrapFiles array with the recalled text (the inject seam)', async () => {
|
||||
const bridge = makeMockBridge({ hits: [hit('prior decision: ship the thing')] });
|
||||
// Replicate the package handler's SessionStart seam: recall, format via the
|
||||
// adapter, push onto the MUTABLE bootstrapFiles array the gateway reads back.
|
||||
const bootstrapFiles: unknown[] = [];
|
||||
const recalled = await bridge.recallMemory('', { limit: 20, scope: 'personal' });
|
||||
expect(recalled).toHaveLength(1);
|
||||
const injected = openclawAdapter.formatInject?.(recalled.map((h) => h.content).join('\n')) as { additionalContext: string };
|
||||
bootstrapFiles.push(injected.additionalContext);
|
||||
|
||||
expect(bootstrapFiles).toHaveLength(1);
|
||||
expect(bootstrapFiles[0]).toContain('prior decision: ship the thing');
|
||||
});
|
||||
|
||||
it('drives runSessionStartBody through the handler without throwing (recall path executes)', async () => {
|
||||
const bridge = makeMockBridge({ hits: [hit('frame a')] });
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const extracted: SessionStartExtracted = {
|
||||
cwd: '/work',
|
||||
sessionId: provScope('chan-7'),
|
||||
recallLimit: 20,
|
||||
};
|
||||
const event: InternalHookEventLike = { type: 'agent', action: 'bootstrap' };
|
||||
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
|
||||
expect(bridge.recalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeOpenclawHandler — message:sent (Stop) DEBOUNCE collapses 0..N/turn to one save', () => {
|
||||
it('fires 3 message:sent in a turn → exactly ONE save of the LAST payload', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const bridge = makeMockBridge();
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 750 });
|
||||
const sessionKey = 'turn-key';
|
||||
|
||||
const mk = (response: string): OpenclawHandlerInput => {
|
||||
const extracted: StopExtracted = {
|
||||
cwd: '/work',
|
||||
sessionId: provScope('chan-7'),
|
||||
response,
|
||||
parent: undefined,
|
||||
};
|
||||
const event: InternalHookEventLike = { type: 'message', action: 'sent', sessionKey };
|
||||
return { event, extracted };
|
||||
};
|
||||
|
||||
// Fire three message:sent rapidly (same turn). The first two are superseded.
|
||||
const p1 = handler.handle(mk('partial reply 1'), ctxFor(bridge));
|
||||
const p2 = handler.handle(mk('partial reply 2'), ctxFor(bridge));
|
||||
const p3 = handler.handle(mk('FINAL DECISION: we will ship the feature on Friday'), ctxFor(bridge));
|
||||
|
||||
// Superseded dispatches resolve immediately (fail-open: a hook must never block the host).
|
||||
await Promise.all([p1, p2]);
|
||||
expect(bridge.saved).toHaveLength(0); // nothing saved yet — still debouncing
|
||||
|
||||
// Advance past the debounce window; the last save fires.
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
await p3;
|
||||
|
||||
// Exactly one frame saved, carrying the LAST payload.
|
||||
expect(bridge.saved).toHaveLength(1);
|
||||
const frame = bridge.saved[0];
|
||||
expect(frame.content).toContain('FINAL DECISION');
|
||||
expect(frame.content).not.toContain('partial reply 1');
|
||||
// Stop frames are important/critical (never temporary), provenance-scoped.
|
||||
expect(['important', 'critical']).toContain(frame.importance);
|
||||
expect(frame.scope).toBe(provScope('chan-7'));
|
||||
expect(frame.source).toBe('openclaw');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeOpenclawHandler — PreCompact matches on the action SUFFIX, not the joined string', () => {
|
||||
it('fires cleanup_frames when the runtime action is "compact:before" (session: prefix dropped at runtime)', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
// Runtime event: type 'session', action 'compact:before' — joined would be
|
||||
// 'session:compact:before' (matches), but the suffix-match is what the
|
||||
// design relies on (CORRECTION 2). Use a non-joined type to prove suffix.
|
||||
const event: InternalHookEventLike = { type: 'lifecycle', action: 'compact:before' };
|
||||
await handler.handle({ event, extracted: { scope: provScope('chan-7') } }, ctxFor(bridge));
|
||||
expect(bridge.cleanups).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores an unmapped (type, action) pair', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const event: InternalHookEventLike = { type: 'tool', action: 'invoked' };
|
||||
await handler.handle({ event, extracted: { scope: undefined } }, ctxFor(bridge));
|
||||
expect(bridge.saved).toHaveLength(0);
|
||||
expect(bridge.cleanups).toBe(0);
|
||||
expect(bridge.recalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§7.3 invariant 1 — FAIL-OPEN (handler swallows a bridge error, never throws)', () => {
|
||||
it('a save error during message:received resolves (never rejects)', async () => {
|
||||
const bridge = makeMockBridge({ throwOn: 'save' });
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const extracted: UserPromptExtracted = { prompt: 'p', cwd: '/w', sessionId: provScope('c') };
|
||||
const event: InternalHookEventLike = { type: 'message', action: 'received' };
|
||||
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
|
||||
expect(bridge.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a recall error during agent:bootstrap resolves (never rejects)', async () => {
|
||||
const bridge = makeMockBridge({ throwOn: 'recall' });
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const extracted: SessionStartExtracted = { cwd: '/w', sessionId: provScope('c'), recallLimit: 20 };
|
||||
const event: InternalHookEventLike = { type: 'agent', action: 'bootstrap' };
|
||||
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('a cleanup error during compact:before resolves (never rejects)', async () => {
|
||||
const bridge = makeMockBridge({ throwOn: 'cleanup' });
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const event: InternalHookEventLike = { type: 'lifecycle', action: 'compact:before' };
|
||||
await expect(handler.handle({ event, extracted: { scope: provScope('c') } }, ctxFor(bridge)))
|
||||
.resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── the package's own default-export handler (in-process entrypoint) ─────
|
||||
|
||||
describe('openclawHook default export — fail-open over the live bridge path', () => {
|
||||
it('never throws on a garbage event (no matching lifecycle, no env CLI configured)', async () => {
|
||||
const { default: openclawHook } = await import('../src/handler.js');
|
||||
// Unmapped event — must resolve to undefined without touching any CLI.
|
||||
await expect(openclawHook({ type: 'noop', action: 'noop' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('never throws even when the recall path would fail (no real hive-mind-cli on PATH)', async () => {
|
||||
const { default: openclawHook } = await import('../src/handler.js');
|
||||
// agent:bootstrap drives recall through a real (unconfigured) bridge; the
|
||||
// handler's try/catch must swallow any failure and resolve.
|
||||
const event = { type: 'agent', action: 'bootstrap', context: { channelId: 'c', bootstrapFiles: [] } };
|
||||
await expect(openclawHook(event)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
246
packages/hive-mind-hooks-openclaw/tests/install.test.ts
Normal file
246
packages/hive-mind-hooks-openclaw/tests/install.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import JSON5 from 'json5';
|
||||
import { install } from '../src/install.js';
|
||||
import { HIVE_ENTRY_KEY, HOOKS_KEY } from '../src/json5-merger.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
/** A fake compiled handler.js the installer COPIES into the managed hook dir. */
|
||||
handlerSource: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
hiveHookDir: string;
|
||||
}
|
||||
|
||||
/** openclaw.json is OPTIONAL — `initial=undefined` exercises create-if-missing. */
|
||||
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmocl-install-'));
|
||||
const openclawDir = join(home, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
if (initial !== undefined) {
|
||||
await writeFile(configPath, initial, 'utf-8');
|
||||
}
|
||||
// Fake compiled handler — install copies this verbatim into the hook dir.
|
||||
const distDir = join(home, 'fake-dist');
|
||||
await mkdir(distDir, { recursive: true });
|
||||
const handlerSource = join(distDir, 'handler.js');
|
||||
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
|
||||
return {
|
||||
home,
|
||||
handlerSource,
|
||||
configPath,
|
||||
pointerPath: join(openclawDir, 'hive-mind-install.json'),
|
||||
hiveHookDir: join(openclawDir, 'hooks', 'hive-mind'),
|
||||
};
|
||||
}
|
||||
|
||||
function readPointer(p: string): Promise<Record<string, unknown>> {
|
||||
return readFile(p, 'utf-8').then((s) => JSON.parse(s) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function internalEntries(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const hooks = config[HOOKS_KEY] as Record<string, Record<string, unknown>> | undefined;
|
||||
const internal = hooks?.['internal'] as Record<string, unknown> | undefined;
|
||||
return (internal?.['entries'] as Record<string, unknown> | undefined) ?? {};
|
||||
}
|
||||
|
||||
describe('install (openclaw)', () => {
|
||||
let env: TestEnv;
|
||||
|
||||
afterEach(async () => {
|
||||
if (env) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws when the compiled handler.js source is missing (build first)', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
await rm(env.handlerSource, { force: true });
|
||||
await expect(install({ home: env.home, handlerSourcePath: env.handlerSource }))
|
||||
.rejects.toThrow(/compiled handler not found/i);
|
||||
});
|
||||
|
||||
it('throws on a config.json that parses as neither JSON nor JSON5', async () => {
|
||||
env = await bootstrap('{ : : : not valid : : : }');
|
||||
await expect(install({ home: env.home, handlerSourcePath: env.handlerSource }))
|
||||
.rejects.toThrow(/parse/i);
|
||||
});
|
||||
|
||||
// ── pre-existed branch ────────────────────────────────────────────────
|
||||
|
||||
it('writes a LITERAL byte-identical backup before mutating a pre-existing openclaw.json', async () => {
|
||||
// Comments + trailing commas that a JSON5 round-trip would NOT preserve —
|
||||
// proves the backup is the original bytes, not a re-serialized merge.
|
||||
const initial = [
|
||||
'{',
|
||||
' // my openclaw config',
|
||||
' model: "opus", // keep me',
|
||||
' hooks: { internal: { enabled: false, entries: {} } },',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
env = await bootstrap(initial);
|
||||
const original = await readFile(env.configPath, 'utf-8');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(result.backupPath).not.toBeNull();
|
||||
const backupContent = await readFile(result.backupPath as string, 'utf-8');
|
||||
expect(backupContent).toBe(original);
|
||||
// The backup preserves the comment that JSON re-serialization drops.
|
||||
expect(backupContent).toContain('// my openclaw config');
|
||||
});
|
||||
|
||||
it('records created_by_us=false + settings_backup when openclaw.json pre-existed', async () => {
|
||||
env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(result.createdByUs).toBe(false);
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['created_by_us']).toBe(false);
|
||||
expect(pointer['settings_backup']).toBe(result.backupPath);
|
||||
});
|
||||
|
||||
it('minimal-touch: flips internal.enabled + adds hive entry, preserving user keys + entries', async () => {
|
||||
const initial = [
|
||||
'{',
|
||||
' model: "opus",',
|
||||
' hooks: { internal: { enabled: false, entries: { "user-own": { enabled: true } } } },',
|
||||
'}',
|
||||
].join('\n');
|
||||
env = await bootstrap(initial);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
const ents = internalEntries(after);
|
||||
// Our entry added, the user entry preserved verbatim.
|
||||
expect((ents[HIVE_ENTRY_KEY] as Record<string, unknown>)['enabled']).toBe(true);
|
||||
expect(ents['user-own']).toEqual({ enabled: true });
|
||||
// Subsystem turned on, user top-level key preserved.
|
||||
const hooks = after[HOOKS_KEY] as Record<string, Record<string, unknown>>;
|
||||
expect(hooks['internal']['enabled']).toBe(true);
|
||||
expect(after['model']).toBe('opus');
|
||||
});
|
||||
|
||||
// ── create-if-missing branch ──────────────────────────────────────────
|
||||
|
||||
it('creates openclaw.json with the hive entry when it is absent', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect((internalEntries(after)[HIVE_ENTRY_KEY] as Record<string, unknown>)['enabled']).toBe(true);
|
||||
expect(result.createdByUs).toBe(true);
|
||||
});
|
||||
|
||||
it('records created_by_us=true and writes NO backup when openclaw.json is absent', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(result.backupPath).toBeNull();
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['created_by_us']).toBe(true);
|
||||
expect(pointer['settings_backup']).toBeNull();
|
||||
});
|
||||
|
||||
// ── managed hook DIR (in-process model — no per-event scripts) ─────────
|
||||
|
||||
it('writes the managed hook DIR with HOOK.md + a byte-identical copy of handler.js', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const handlerBytes = await readFile(env.handlerSource, 'utf-8');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(result.hookDir).toBe(env.hiveHookDir);
|
||||
expect(existsSync(join(env.hiveHookDir, 'HOOK.md'))).toBe(true);
|
||||
expect(existsSync(join(env.hiveHookDir, 'handler.js'))).toBe(true);
|
||||
// handler.js is copied verbatim from dist.
|
||||
expect(await readFile(join(env.hiveHookDir, 'handler.js'), 'utf-8')).toBe(handlerBytes);
|
||||
// HOOK.md declares the four events incl. the prefixed compaction key.
|
||||
const hookMd = await readFile(join(env.hiveHookDir, 'HOOK.md'), 'utf-8');
|
||||
expect(hookMd).toContain('agent:bootstrap');
|
||||
expect(hookMd).toContain('message:received');
|
||||
expect(hookMd).toContain('message:sent');
|
||||
expect(hookMd).toContain('session:compact:before');
|
||||
});
|
||||
|
||||
// Spawns two child Node processes (esbuild bundle of the handler + import of
|
||||
// the produced bundle). Standalone this takes <1s, but full-suite runs
|
||||
// saturate the CPU (forks pool, 4 workers) and the spawns can exceed vitest's
|
||||
// 30s default testTimeout (observed 2026-07-15 full-suite flake,
|
||||
// standalone-green). 60s per-test timeout, same class as f322cc2c.
|
||||
it('copies a self-contained handler that imports and runs with NODE_PATH empty', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const buildScript = fileURLToPath(new URL('../scripts/build-handler.mjs', import.meta.url));
|
||||
const handlerEntry = fileURLToPath(new URL('../src/handler.ts', import.meta.url));
|
||||
execFileSync(process.execPath, [buildScript, handlerEntry, env.handlerSource], {
|
||||
cwd: env.home,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const installedHandler = join(env.hiveHookDir, 'handler.js');
|
||||
const installedUrl = pathToFileURL(installedHandler).href;
|
||||
const runInstalledHandler = [
|
||||
`const module = await import(${JSON.stringify(installedUrl)});`,
|
||||
`if (typeof module.default !== 'function') throw new Error('default export is not a function');`,
|
||||
`await module.default({ type: 'noop', action: 'noop' });`,
|
||||
].join('\n');
|
||||
|
||||
// Both the handler path and cwd are outside the repository/package tree.
|
||||
// Any remaining @waggle import would fail with this empty resolution path.
|
||||
execFileSync(process.execPath, ['--input-type=module', '--eval', runInstalledHandler], {
|
||||
cwd: env.home,
|
||||
env: { ...process.env, NODE_PATH: '' },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
// ── pointer + lifecycles + cli-path ───────────────────────────────────
|
||||
|
||||
it('drops a pointer recording the four lifecycles + touched keys + hook dir name', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(existsSync(result.pointerPath)).toBe(true);
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
|
||||
expect(pointer['hooks_dir']).toBe(env.hiveHookDir);
|
||||
expect(typeof pointer['version']).toBe('string');
|
||||
const extra = pointer['extra'] as Record<string, unknown>;
|
||||
expect(extra['hook_dir_name']).toBe('hive-mind');
|
||||
expect((extra['touched_keys'] as string[]).sort()).toEqual([
|
||||
'hooks.internal.enabled',
|
||||
'hooks.internal.entries.hive-mind',
|
||||
]);
|
||||
expect([...result.touchedKeys].sort()).toEqual([
|
||||
'hooks.internal.enabled',
|
||||
'hooks.internal.entries.hive-mind',
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects a custom now() for a deterministic backup filename', async () => {
|
||||
env = await bootstrap('{ "hooks": {} }');
|
||||
const fixedTs = '2026-04-28T10:30:45.123Z';
|
||||
const result = await install({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
now: () => new Date(fixedTs),
|
||||
});
|
||||
expect(result.backupPath).toContain('hive-mind-backup.2026-04-28T10-30-45-123Z');
|
||||
const stats = await stat(result.backupPath as string);
|
||||
expect(stats.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('threads --cli-path into the entry env + records it in the pointer', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const cliPath = '/abs/path/to/dist/index.js';
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource, cliPath });
|
||||
expect(result.cliPath).toBe(cliPath);
|
||||
|
||||
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
const hive = internalEntries(after)[HIVE_ENTRY_KEY] as Record<string, unknown>;
|
||||
expect((hive['env'] as Record<string, unknown>)['WAGGLE_HIVE_MIND_CLI']).toBe(cliPath);
|
||||
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['cli_path']).toBe(cliPath);
|
||||
});
|
||||
});
|
||||
174
packages/hive-mind-hooks-openclaw/tests/json5-merger.test.ts
Normal file
174
packages/hive-mind-hooks-openclaw/tests/json5-merger.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import JSON5 from 'json5';
|
||||
import {
|
||||
HIVE_ENTRY_KEY,
|
||||
HOOKS_KEY,
|
||||
hasHiveEntries,
|
||||
jsonRegister,
|
||||
jsonUnregister,
|
||||
parseConfig,
|
||||
serializeConfig,
|
||||
} from '../src/json5-merger.js';
|
||||
|
||||
function internal(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const hooks = config[HOOKS_KEY] as Record<string, unknown> | undefined;
|
||||
return (hooks?.['internal'] as Record<string, unknown> | undefined) ?? {};
|
||||
}
|
||||
|
||||
function entries(config: Record<string, unknown>): Record<string, unknown> {
|
||||
return (internal(config)['entries'] as Record<string, unknown> | undefined) ?? {};
|
||||
}
|
||||
|
||||
describe('parseConfig (openclaw JSON5 codec — strict JSON first, JSON5 fallback)', () => {
|
||||
it('returns {} for an empty / whitespace-only config (create-if-missing)', () => {
|
||||
expect(parseConfig('')).toEqual({});
|
||||
expect(parseConfig(' \n ')).toEqual({});
|
||||
});
|
||||
|
||||
it('parses strict JSON (the fast path)', () => {
|
||||
const parsed = parseConfig('{"model":"opus","hooks":{}}');
|
||||
expect(parsed['model']).toBe('opus');
|
||||
});
|
||||
|
||||
it('parses a COMMENTED openclaw.json with trailing commas via the JSON5 fallback', () => {
|
||||
const raw = [
|
||||
'{',
|
||||
' // OpenClaw gateway config — hand-edited, comments matter',
|
||||
' model: "claude-opus", /* the good one */',
|
||||
' hooks: {',
|
||||
' internal: {',
|
||||
' enabled: false,',
|
||||
' entries: {',
|
||||
" 'user-own': { enabled: true }, // trailing comma below",
|
||||
' },',
|
||||
' },',
|
||||
' },',
|
||||
'}',
|
||||
].join('\n');
|
||||
const parsed = parseConfig(raw);
|
||||
expect(parsed['model']).toBe('claude-opus');
|
||||
const userEntry = entries(parsed)['user-own'] as Record<string, unknown>;
|
||||
expect(userEntry['enabled']).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when BOTH strict JSON and JSON5 fail (installer fails loudly, never clobbers)', () => {
|
||||
expect(() => parseConfig('{ this : : : is not valid }')).toThrow(/parse/i);
|
||||
});
|
||||
|
||||
it('treats a top-level scalar/array JSON5 doc as empty (not a crash)', () => {
|
||||
expect(parseConfig('"just a string"')).toEqual({});
|
||||
expect(parseConfig('[1, 2, 3]')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeConfig (openclaw — strict JSON, valid for OpenClaw JSON5 reader)', () => {
|
||||
it('serializes to 2-space JSON with a trailing newline that re-parses', () => {
|
||||
const text = serializeConfig(jsonRegister({ model: 'x' }));
|
||||
expect(text.endsWith('\n')).toBe(true);
|
||||
const reparsed = JSON5.parse(text) as Record<string, unknown>;
|
||||
expect(reparsed['model']).toBe('x');
|
||||
expect(hasHiveEntries(reparsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonRegister (openclaw minimal-touch — flip enabled + add hive entry)', () => {
|
||||
it('returns a NEW object — does not mutate input (immutability contract)', () => {
|
||||
const original: Record<string, unknown> = { hooks: { internal: { enabled: false } } };
|
||||
const merged = jsonRegister(original);
|
||||
expect(merged).not.toBe(original);
|
||||
// Input untouched — enabled is still false on the original.
|
||||
expect((original['hooks'] as Record<string, Record<string, unknown>>)['internal']['enabled']).toBe(false);
|
||||
});
|
||||
|
||||
it('flips hooks.internal.enabled=true and adds entries["hive-mind"]={enabled:true}', () => {
|
||||
const merged = jsonRegister({});
|
||||
expect(internal(merged)['enabled']).toBe(true);
|
||||
const hive = entries(merged)[HIVE_ENTRY_KEY] as Record<string, unknown>;
|
||||
expect(hive).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('attaches env to the hive entry when supplied', () => {
|
||||
const merged = jsonRegister({}, { env: { WAGGLE_HIVE_MIND_CLI: '/abs/cli.js', WAGGLE_WORKSPACE_ID: 'ws1' } });
|
||||
const hive = entries(merged)[HIVE_ENTRY_KEY] as Record<string, unknown>;
|
||||
expect(hive['env']).toEqual({ WAGGLE_HIVE_MIND_CLI: '/abs/cli.js', WAGGLE_WORKSPACE_ID: 'ws1' });
|
||||
});
|
||||
|
||||
it('does NOT clobber other internal entries or other internal keys (minimal-touch)', () => {
|
||||
const existing: Record<string, unknown> = {
|
||||
model: 'opus',
|
||||
hooks: {
|
||||
internal: {
|
||||
enabled: false,
|
||||
throttleMs: 250,
|
||||
entries: { 'user-own': { enabled: true, foo: 'bar' } },
|
||||
},
|
||||
external: { whatever: 1 },
|
||||
},
|
||||
};
|
||||
const merged = jsonRegister(existing);
|
||||
// user entry preserved verbatim.
|
||||
expect(entries(merged)['user-own']).toEqual({ enabled: true, foo: 'bar' });
|
||||
// sibling internal key preserved.
|
||||
expect(internal(merged)['throttleMs']).toBe(250);
|
||||
// sibling hooks subtree preserved.
|
||||
expect((merged['hooks'] as Record<string, unknown>)['external']).toEqual({ whatever: 1 });
|
||||
// unrelated top-level key preserved.
|
||||
expect(merged['model']).toBe('opus');
|
||||
// and our entry was added + subsystem turned on.
|
||||
expect(internal(merged)['enabled']).toBe(true);
|
||||
expect(hasHiveEntries(merged)).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces OUR entry in place on re-install (idempotent — never duplicated)', () => {
|
||||
const merged1 = jsonRegister({}, { env: { A: '1' } });
|
||||
const merged2 = jsonRegister(merged1, { env: { A: '2' } });
|
||||
const hive = entries(merged2)[HIVE_ENTRY_KEY] as Record<string, unknown>;
|
||||
expect(hive['env']).toEqual({ A: '2' });
|
||||
// Exactly one hive-mind entry key.
|
||||
expect(Object.keys(entries(merged2)).filter((k) => k === HIVE_ENTRY_KEY)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonUnregister (openclaw — diagnostics / backup-less path)', () => {
|
||||
it('strips our hive-mind entry but preserves other entries + leaves enabled as-is', () => {
|
||||
const withUser: Record<string, unknown> = {
|
||||
hooks: { internal: { enabled: true, entries: { 'user-own': { enabled: true } } } },
|
||||
};
|
||||
const merged = jsonRegister(withUser);
|
||||
expect(hasHiveEntries(merged)).toBe(true);
|
||||
|
||||
const stripped = jsonUnregister(merged);
|
||||
expect(hasHiveEntries(stripped)).toBe(false);
|
||||
// User entry survives.
|
||||
expect(entries(stripped)['user-own']).toEqual({ enabled: true });
|
||||
// Minimal-touch: we do NOT flip enabled back off (other hooks may rely on it).
|
||||
expect(internal(stripped)['enabled']).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a NEW object and leaves the input untouched (immutability)', () => {
|
||||
const merged = jsonRegister({});
|
||||
const stripped = jsonUnregister(merged);
|
||||
expect(stripped).not.toBe(merged);
|
||||
expect(hasHiveEntries(merged)).toBe(true); // original still has the entry
|
||||
});
|
||||
|
||||
it('is a no-op (new object) when there is no hooks/internal block', () => {
|
||||
const stripped = jsonUnregister({ model: 'x' });
|
||||
expect(stripped['model']).toBe('x');
|
||||
expect(hasHiveEntries(stripped)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHiveEntries (openclaw structural marker)', () => {
|
||||
it('false on empty / hookless / hive-less configs', () => {
|
||||
expect(hasHiveEntries(undefined)).toBe(false);
|
||||
expect(hasHiveEntries({})).toBe(false);
|
||||
expect(hasHiveEntries({ hooks: {} })).toBe(false);
|
||||
expect(hasHiveEntries({ hooks: { internal: { enabled: true, entries: {} } } })).toBe(false);
|
||||
expect(hasHiveEntries({ hooks: { internal: { entries: { 'user-own': {} } } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('true once our hive-mind entry is present', () => {
|
||||
expect(hasHiveEntries(jsonRegister({}))).toBe(true);
|
||||
});
|
||||
});
|
||||
87
packages/hive-mind-hooks-openclaw/tests/paths.test.ts
Normal file
87
packages/hive-mind-hooks-openclaw/tests/paths.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
allHookBasenames,
|
||||
backupPathFor,
|
||||
hookCommandFor,
|
||||
resolvePaths,
|
||||
HIVE_HOOK_DIR_NAME,
|
||||
HIVE_HOOK_ENTRY_KEY,
|
||||
} from '../src/paths.js';
|
||||
|
||||
describe('resolvePaths (openclaw)', () => {
|
||||
it('places openclaw.json + pointer under <home>/.openclaw/', () => {
|
||||
const home = resolve('/fake/home');
|
||||
const paths = resolvePaths({ home, handlerSourcePath: resolve('/some/dist/handler.js') });
|
||||
expect(paths.openclawDir).toBe(join(home, '.openclaw'));
|
||||
expect(paths.configPath).toBe(join(home, '.openclaw', 'openclaw.json'));
|
||||
expect(paths.pointerPath).toBe(join(home, '.openclaw', 'hive-mind-install.json'));
|
||||
});
|
||||
|
||||
it('resolves the managed hook DIRECTORY (not per-event scripts) under ~/.openclaw/hooks/', () => {
|
||||
const home = resolve('/h');
|
||||
const paths = resolvePaths({ home, handlerSourcePath: resolve('/d/handler.js') });
|
||||
// OpenClaw is in-process: one managed dir holding HOOK.md + handler.js,
|
||||
// NOT four separate compiled hook scripts.
|
||||
expect(paths.hooksRoot).toBe(join(home, '.openclaw', 'hooks'));
|
||||
expect(paths.hiveHookDir).toBe(join(home, '.openclaw', 'hooks', HIVE_HOOK_DIR_NAME));
|
||||
expect(paths.hookMdPath).toBe(join(paths.hiveHookDir, 'HOOK.md'));
|
||||
expect(paths.installedHandlerPath).toBe(join(paths.hiveHookDir, 'handler.js'));
|
||||
});
|
||||
|
||||
it('handlerSourcePath override wins over moduleUrl', () => {
|
||||
const explicit = resolve('/x/y/handler.js');
|
||||
const paths = resolvePaths({
|
||||
home: resolve('/h'),
|
||||
handlerSourcePath: explicit,
|
||||
moduleUrl: 'file:///irrelevant/dist/install.js',
|
||||
});
|
||||
expect(paths.handlerSourcePath).toBe(explicit);
|
||||
});
|
||||
|
||||
it('derives the self-contained handler bundle sibling from moduleUrl', () => {
|
||||
const moduleUrl = pathToFileURL(resolve('/pkg/dist/install.js')).href;
|
||||
const paths = resolvePaths({ home: resolve('/h'), moduleUrl });
|
||||
expect(paths.handlerSourcePath).toBe(resolve('/pkg/dist/handler.bundle.cjs'));
|
||||
});
|
||||
|
||||
it('falls back to cwd/dist/handler.bundle.cjs when neither override is given', () => {
|
||||
const paths = resolvePaths({ home: resolve('/h') });
|
||||
expect(paths.handlerSourcePath).toBe(resolve(process.cwd(), 'dist', 'handler.bundle.cjs'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('allHookBasenames (openclaw — FOUR lifecycles incl. pre-compact)', () => {
|
||||
it('names the four lifecycles the single handler dispatches (bookkeeping, not separate files)', () => {
|
||||
expect([...allHookBasenames()].sort()).toEqual([
|
||||
'pre-compact',
|
||||
'session-start',
|
||||
'stop',
|
||||
'user-prompt-submit',
|
||||
]);
|
||||
// Unlike hermes, openclaw DOES carry a compaction lifecycle
|
||||
// (session:compact:before → runtime action compact:before).
|
||||
expect([...allHookBasenames()]).toContain('pre-compact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exported entry/dir name constants', () => {
|
||||
it('the managed dir name and the logical entry key are both "hive-mind"', () => {
|
||||
expect(HIVE_HOOK_DIR_NAME).toBe('hive-mind');
|
||||
expect(HIVE_HOOK_ENTRY_KEY).toBe('hive-mind');
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-exported shared Windows-safe helpers', () => {
|
||||
it('backupPathFor replaces colons and dots in the timestamp for filesystem safety', () => {
|
||||
const backup = backupPathFor('/h/.openclaw/openclaw.json', '2026-04-28T10:30:45.123Z');
|
||||
expect(backup).toBe('/h/.openclaw/openclaw.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
|
||||
});
|
||||
|
||||
it('hookCommandFor produces a quoted node invocation and appends --cli-path', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/handler.js'), '/abs/cli/dist/index.js');
|
||||
expect(cmd).toMatch(/^node "[^"]+handler\.js"/);
|
||||
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
|
||||
});
|
||||
});
|
||||
145
packages/hive-mind-hooks-openclaw/tests/uninstall.test.ts
Normal file
145
packages/hive-mind-hooks-openclaw/tests/uninstall.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { install } from '../src/install.js';
|
||||
import { uninstall } from '../src/uninstall.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
handlerSource: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
hiveHookDir: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmocl-uninstall-'));
|
||||
const openclawDir = join(home, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
if (initial !== undefined) {
|
||||
await writeFile(configPath, initial, 'utf-8');
|
||||
}
|
||||
const distDir = join(home, 'fake-dist');
|
||||
await mkdir(distDir, { recursive: true });
|
||||
const handlerSource = join(distDir, 'handler.js');
|
||||
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
|
||||
return {
|
||||
home,
|
||||
handlerSource,
|
||||
configPath,
|
||||
pointerPath: join(openclawDir, 'hive-mind-install.json'),
|
||||
hiveHookDir: join(openclawDir, 'hooks', 'hive-mind'),
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash('sha256').update(s, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
describe('uninstall (openclaw)', () => {
|
||||
let env: TestEnv;
|
||||
|
||||
afterEach(async () => {
|
||||
if (env) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws when no pointer file exists', async () => {
|
||||
env = await bootstrap('{ "hooks": {} }');
|
||||
await expect(uninstall({ home: env.home, handlerSourcePath: env.handlerSource }))
|
||||
.rejects.toThrow(/pointer/);
|
||||
});
|
||||
|
||||
// ── created_by_us=false: LITERAL byte-identical restore (§7.3 invariant 2) ─
|
||||
// JSON5 round-trip is lossy (comments + trailing commas are dropped on
|
||||
// re-serialize), so reversibility relies on restoring the ORIGINAL BYTES.
|
||||
|
||||
it('install + uninstall round-trip is SHA-256 identical to pre-install state (comments preserved)', async () => {
|
||||
// Comments + trailing commas a naive JSON re-serialize would NOT reproduce.
|
||||
const initial = [
|
||||
'{',
|
||||
' // OpenClaw config — hand-edited, comments matter',
|
||||
' model: "claude-opus", /* the good one */',
|
||||
' temperature: 0.2,',
|
||||
' hooks: {',
|
||||
' internal: {',
|
||||
' enabled: false,',
|
||||
' entries: { "user-own": { enabled: true } },',
|
||||
' },',
|
||||
' },',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
env = await bootstrap(initial);
|
||||
const preInstall = await readFile(env.configPath, 'utf-8');
|
||||
const preHash = sha256(preInstall);
|
||||
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const afterInstall = await readFile(env.configPath, 'utf-8');
|
||||
expect(sha256(afterInstall)).not.toBe(preHash); // install actually mutated
|
||||
// Sanity: the merged write IS lossy — the comment is gone post-install,
|
||||
// which is exactly why we need the literal backup to reverse it.
|
||||
expect(afterInstall).not.toContain('// OpenClaw config');
|
||||
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(u.createdRemoved).toBe(false);
|
||||
expect(u.restoredFrom).not.toBeNull();
|
||||
expect(u.hookDirRemoved).toBe(true);
|
||||
const afterUninstall = await readFile(env.configPath, 'utf-8');
|
||||
// Byte-for-byte identical — the comment + trailing commas are back.
|
||||
expect(sha256(afterUninstall)).toBe(preHash);
|
||||
expect(afterUninstall).toBe(preInstall);
|
||||
expect(afterUninstall).toContain('// OpenClaw config — hand-edited, comments matter');
|
||||
});
|
||||
|
||||
it('removes the managed hook dir (HOOK.md + handler.js) on uninstall — no orphan', async () => {
|
||||
env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(existsSync(join(env.hiveHookDir, 'HOOK.md'))).toBe(true);
|
||||
expect(existsSync(join(env.hiveHookDir, 'handler.js'))).toBe(true);
|
||||
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(u.hookDirRemoved).toBe(true);
|
||||
expect(existsSync(env.hiveHookDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes backup + pointer by default after a restore', async () => {
|
||||
env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(existsSync(result.backupPath as string)).toBe(true);
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(u.backupRemoved).toBe(true);
|
||||
expect(existsSync(result.backupPath as string)).toBe(false);
|
||||
expect(existsSync(result.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the backup when cleanupBackup=false', async () => {
|
||||
env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource, cleanupBackup: false });
|
||||
expect(u.backupRemoved).toBe(false);
|
||||
expect(existsSync(result.backupPath as string)).toBe(true);
|
||||
});
|
||||
|
||||
// ── created_by_us=true: delete-if-created, no orphan (§7.3 invariant 2) ─
|
||||
|
||||
it('deletes the openclaw.json we created and leaves NO orphan (absent → install → uninstall)', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(result.createdByUs).toBe(true);
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(u.createdRemoved).toBe(true);
|
||||
expect(u.restoredFrom).toBeNull();
|
||||
// No orphaned config, no managed dir, no leftover pointer.
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
expect(existsSync(env.hiveHookDir)).toBe(false);
|
||||
expect(existsSync(env.pointerPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
170
packages/hive-mind-hooks-openclaw/tests/verify.test.ts
Normal file
170
packages/hive-mind-hooks-openclaw/tests/verify.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { install } from '../src/install.js';
|
||||
import { verify } from '../src/verify.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
handlerSource: string;
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmocl-verify-'));
|
||||
const openclawDir = join(home, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
if (initial !== undefined) {
|
||||
await writeFile(configPath, initial, 'utf-8');
|
||||
}
|
||||
const distDir = join(home, 'fake-dist');
|
||||
await mkdir(distDir, { recursive: true });
|
||||
const handlerSource = join(distDir, 'handler.js');
|
||||
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
|
||||
return { home, handlerSource, configPath };
|
||||
}
|
||||
|
||||
function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: string }): typeof import('node:child_process').spawn {
|
||||
return ((_cmd: string, _args: readonly string[], _options?: unknown) => {
|
||||
const emitter = new EventEmitter();
|
||||
const stdout = Readable.from([Buffer.from(opts.stdout ?? 'hive-mind-cli help text\n')]);
|
||||
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
|
||||
const child = Object.assign(emitter, {
|
||||
stdout,
|
||||
stderr,
|
||||
kill: vi.fn(() => true),
|
||||
}) as unknown as ChildProcess;
|
||||
setImmediate(() => emitter.emit('exit', opts.exitCode));
|
||||
return child;
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
}
|
||||
|
||||
describe('verify (openclaw)', () => {
|
||||
const envs: TestEnv[] = [];
|
||||
afterEach(async () => {
|
||||
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports failure when openclaw.json is missing', async () => {
|
||||
const env = await bootstrap(undefined);
|
||||
envs.push(env);
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks[0].name).toBe('openclaw.json exists');
|
||||
expect(result.checks[0].ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reports failure when the hive entry is not yet installed', async () => {
|
||||
const env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
envs.push(env);
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.some((c) => !c.ok && c.name.includes('hive-mind internal-hooks entry'))).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after install — entry present, subsystem enabled, dir+HOOK.md+handler on disk, CLI reachable', async () => {
|
||||
const env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
envs.push(env);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name.includes('hive-mind internal-hooks entry'))?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'internal hooks subsystem enabled')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'managed hook dir exists')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'HOOK.md readable on disk')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'handler.js readable on disk')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('flags the activation advisory (FAIL) when internal.enabled is false even with the entry present', async () => {
|
||||
// Entry present but subsystem OFF — hooks are inert until opted in (§5.5/§6.2).
|
||||
const config = '{ "hooks": { "internal": { "enabled": false, "entries": { "hive-mind": { "enabled": true } } } } }';
|
||||
const env = await bootstrap(config);
|
||||
envs.push(env);
|
||||
// Write the managed dir so only the activation check is at fault.
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
// Re-disable the subsystem post-install to isolate the advisory.
|
||||
await writeFile(env.configPath, config, 'utf-8');
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
const enabledCheck = result.checks.find((c) => c.name === 'internal hooks subsystem enabled');
|
||||
expect(enabledCheck?.ok).toBe(false);
|
||||
expect(enabledCheck?.detail?.toLowerCase()).toContain('hooks are off');
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reports CLI unreachable when the spawn exits non-zero', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 127, stderr: 'command not found' }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('flags a missing handler.js even when the config entry is present', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
// Remove the installed handler from the managed dir.
|
||||
await rm(join(env.home, '.openclaw', 'hooks', 'hive-mind', 'handler.js'), { force: true });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'handler.js readable on disk')?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('uses cli_path from the install pointer for the probe (node <path> --help)', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
const cliPath = '/abs/from/pointer.js';
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource, cliPath });
|
||||
|
||||
const records: Array<{ command: string; args: readonly string[] }> = [];
|
||||
const recordingSpawn = ((cmd: string, args: readonly string[]) => {
|
||||
records.push({ command: cmd, args });
|
||||
return mockSpawnImpl({ exitCode: 0 })(cmd, args);
|
||||
}) as typeof import('node:child_process').spawn;
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: recordingSpawn,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const probeRecord = records[records.length - 1];
|
||||
expect(probeRecord.command).toBe(process.execPath);
|
||||
expect(probeRecord.args[0]).toBe(cliPath);
|
||||
expect(probeRecord.args[1]).toBe('--help');
|
||||
// Sanity: the pinned cli path was actually written into the pointer.
|
||||
const pointer = JSON.parse(await readFile(join(env.home, '.openclaw', 'hive-mind-install.json'), 'utf-8')) as Record<string, unknown>;
|
||||
expect(pointer['cli_path']).toBe(cliPath);
|
||||
});
|
||||
});
|
||||
15
packages/hive-mind-hooks-openclaw/tsconfig.json
Normal file
15
packages/hive-mind-hooks-openclaw/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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/**"],
|
||||
"references": [
|
||||
{ "path": "../hive-mind-hooks-core" },
|
||||
{ "path": "../hive-mind-shim-core" }
|
||||
]
|
||||
}
|
||||
12
packages/hive-mind-hooks-openclaw/tsconfig.test.json
Normal file
12
packages/hive-mind-hooks-openclaw/tsconfig.test.json
Normal 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/**"]
|
||||
}
|
||||
Reference in New Issue
Block a user