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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/plugins/onebrain/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "onebrain",
"version": "2.4.10",
"version": "2.4.11",
"description": "OneBrain — Where human and AI thinking become one. A powerful thinking partner powered by AI synergy.",
"author": {
"name": "OneBrain Contributors"
Expand Down
6 changes: 3 additions & 3 deletions .claude/plugins/onebrain/skills/doctor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,14 @@ Run all applicable checks based on flags (default: all). Collect findings before
**OneBrain hooks:**
- Read `[vault]/.claude/settings.json` (vault-level settings — the `.claude/` folder inside the vault, not `~/.claude/settings.json`)
- Allowed events: only `Stop` and `PostToolUse` (the latter conditional on `qmd_collection`).
- Check required `Stop` hook: entry exists under `hooks.Stop` and command contains `checkpoint stop` → ✅ / 🔴 missing or wrong
- Sweep all other hook events (PreCompact, PostCompact, UserPromptSubmit, SessionStart, etc.): any entry whose command contains `onebrain` → 🟡 stale onebrain hook under non-allowed event — suggest running /update to remove it. Non-onebrain entries under those events are user-added and must be preserved (not flagged).
- Check required `Stop` hook: entry exists under `hooks.Stop` and its **effective command** (the `command` field joined with `args[]` by spaces — so both legacy `{command: "onebrain checkpoint stop"}` and exec-form `{command: "onebrain", args: ["checkpoint", "stop"]}` reduce to the same string) contains `onebrain checkpoint stop` → ✅ / 🔴 missing or wrong
- Sweep all other hook events (PreCompact, PostCompact, UserPromptSubmit, SessionStart, etc.): any entry whose effective command contains `onebrain` → 🟡 stale onebrain hook under non-allowed event — suggest running /update to remove it. Non-onebrain entries under those events are user-added and must be preserved (not flagged).

**qmd PostToolUse hook (only when `qmd_collection` is set in vault.yml):**
- If `qmd_collection` is absent in vault.yml: skip this entire check
- If `qmd_collection` is present:
- Check `which qmd` (macOS/Linux) or `where qmd` (Windows): qmd binary must be installed → ✅ / 🔴 "qmd not installed — qmd_collection is set but binary is missing; run `/qmd setup` to reinstall"
- Read `[vault]/.claude/settings.json` (same file used for the Stop hook); check that `hooks.PostToolUse` contains an entry whose `command` contains `qmd-reindex` → ✅ / 🔴 "PostToolUse qmd hook missing in settings.json — run /update to register"
- Read `[vault]/.claude/settings.json` (same file used for the Stop hook); check that `hooks.PostToolUse` contains an entry whose effective command (see Stop hook note above for the legacy + exec-form merge rule) contains `onebrain qmd-reindex` → ✅ / 🔴 "PostToolUse qmd hook missing in settings.json — run /update to register"

### Scheduler Health (added 2026-05-12)

Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
latest_version: 2.3.1
latest_version: 2.3.2
released: 2026-05-12
---

Expand All @@ -13,6 +13,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

## v2.3.2 — fix(doctor): detect new Claude Code hook exec-form schema

- `checkSettingsHooks` now joins `command` + `args[]` into the effective command string before substring matching, so canonical exec-form hooks (`{command: "onebrain", args: ["checkpoint", "stop"]}`) are no longer false-flagged as missing
- New `detectHookForm` helper classifies each matching entry as `exec` (canonical), `legacy` (shell-form, wrapper, etc.), or `absent` — legacy entries now warn with "--fix will migrate to exec form" instead of staying invisible
- `detectHookForm` scans all matching entries per event: if any entry is in canonical exec form, the hook is reported as exec even when a legacy duplicate also matches (handles partial-migration state where a stale legacy entry was left behind alongside the new canonical one)
- `effectiveCommand` filters non-string `args[]` entries — hand-edited `settings.json` with stray `null`/numbers can't produce ghost substring matches
- Stale-hook sweep also uses the joined effective command, so a stale `onebrain` reference hidden in `args[]` of a wrapper entry is no longer missed
- 10 unit tests covering exec, legacy shell, bash-wrapper, absent, partial migration, mixed-state Stop+PostToolUse, stale exec-form events, defensive args filtering, and qmd-conditional skipping
- No behavior change for vaults already in canonical exec form (the common case post-v2.3.0)

## v2.3.1 — feat(scheduler): hook-style command mode for direct CLI scheduling

- `ScheduleEntry.command` field added: schedule any CLI binary using the same `command + args[]` shape as Claude Code hooks
Expand Down
6 changes: 5 additions & 1 deletion PLUGIN-CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
latest_version: 2.4.10
latest_version: 2.4.11
released: 2026-05-12
---

Expand All @@ -11,6 +11,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
> **Versioning:** Plugin version is tracked in `plugin.json`. Bump when ANY harness config changes — skills, agents, hooks, INSTRUCTIONS, Gemini settings, slash commands, etc.
> For CLI binary (`@onebrain-ai/cli`) changes, see [CHANGELOG.md](CHANGELOG.md).

## v2.4.11 — 2026-05-12

- docs(doctor): update SKILL.md hook-check description to match new validator behavior — effective command = `command` joined with `args[]`, so canonical exec-form `{command: "onebrain", args: ["checkpoint", "stop"]}` is recognized alongside legacy shell form

## v2.4.10 — 2026-05-12

- feat(pause): new `/pause` skill saves snapshot of long-running work to `07-logs/pause/`; non-terminal — does not clear context
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@onebrain-ai/cli",
"version": "2.3.1",
"version": "2.3.2",
"description": "CLI for OneBrain — personal AI OS for Obsidian with persistent memory, 24+ skills, and Claude Code integration",
"keywords": [
"onebrain",
Expand Down
237 changes: 237 additions & 0 deletions src/lib/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
checkFolders,
checkOrphanCheckpoints,
checkQmdEmbeddings,
checkSettingsHooks,
checkVaultYml,
loadVaultConfig,
} from './index.js';
Expand Down Expand Up @@ -401,3 +402,239 @@ describe('checkOrphanCheckpoints', () => {
expect(result.message).toContain('1');
});
});

// ---------------------------------------------------------------------------
// checkSettingsHooks — exec/legacy schema detection
// ---------------------------------------------------------------------------

describe('checkSettingsHooks — hook schema detection', () => {
let dir: string;

const configWithQmd: VaultConfig = {
folders: {
inbox: '00-inbox',
projects: '01-projects',
areas: '02-areas',
knowledge: '03-knowledge',
resources: '04-resources',
agent: '05-agent',
archive: '06-archive',
logs: '07-logs',
},
qmd_collection: 'ob-test',
checkpoint: { messages: 15, minutes: 30 },
};

beforeEach(async () => {
dir = await makeTmpDir();
await mkdir(join(dir, '.claude'), { recursive: true });
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

async function writeSettings(json: unknown): Promise<void> {
await writeFile(join(dir, '.claude', 'settings.json'), JSON.stringify(json), 'utf8');
}

const allowList = ['Bash(onebrain *)'];

it('accepts canonical exec form for Stop and qmd hooks', async () => {
await writeSettings({
permissions: { allow: allowList },
hooks: {
Stop: [{ matcher: '', hooks: [{ command: 'onebrain', args: ['checkpoint', 'stop'] }] }],
PostToolUse: [
{ matcher: 'Write|Edit', hooks: [{ command: 'onebrain', args: ['qmd-reindex'] }] },
],
},
});

const result = await checkSettingsHooks(dir, configWithQmd);
expect(result.status).toBe('ok');
});

it('accepts legacy shell form for both required hooks but warns to migrate', async () => {
await writeSettings({
permissions: { allow: allowList },
hooks: {
Stop: [{ matcher: '', hooks: [{ command: 'onebrain checkpoint stop' }] }],
PostToolUse: [{ matcher: 'Write|Edit', hooks: [{ command: 'onebrain qmd-reindex' }] }],
},
});

const result = await checkSettingsHooks(dir, configWithQmd);
expect(result.status).toBe('warn');
expect(result.details?.some((d) => d.includes('Stop hook in legacy shell form'))).toBe(true);
expect(
result.details?.some((d) => d.includes('PostToolUse (qmd) hook in legacy shell form')),
).toBe(true);
});

it('warns "missing" when no entry matches at all', async () => {
await writeSettings({
permissions: { allow: allowList },
hooks: { Stop: [{ matcher: '', hooks: [{ command: 'echo hi' }] }] },
});

const result = await checkSettingsHooks(dir, configWithQmd);
expect(result.status).toBe('warn');
expect(result.details?.some((d) => d === 'Stop hook missing')).toBe(true);
expect(result.details?.some((d) => d === 'PostToolUse (qmd) hook missing')).toBe(true);
});

it('treats non-canonical exec form (e.g. bash wrapper) as legacy', async () => {
await writeSettings({
permissions: { allow: allowList },
hooks: {
Stop: [
{
matcher: '',
hooks: [{ command: 'bash', args: ['-lc', 'onebrain checkpoint stop'] }],
},
],
PostToolUse: [
{ matcher: 'Write|Edit', hooks: [{ command: 'onebrain', args: ['qmd-reindex'] }] },
],
},
});

const result = await checkSettingsHooks(dir, configWithQmd);
expect(result.status).toBe('warn');
expect(result.details?.some((d) => d.includes('Stop hook in legacy shell form'))).toBe(true);
});

it('skips qmd hook check when qmd_collection is absent', async () => {
const { qmd_collection: _qmd, ...rest } = configWithQmd;
const noQmd: VaultConfig = rest;
await writeSettings({
permissions: { allow: allowList },
hooks: {
Stop: [{ matcher: '', hooks: [{ command: 'onebrain', args: ['checkpoint', 'stop'] }] }],
},
});

const result = await checkSettingsHooks(dir, noQmd);
expect(result.status).toBe('ok');
});

it('matches required hook even when an extra unrelated entry shares the group', async () => {
await writeSettings({
permissions: { allow: allowList },
hooks: {
Stop: [
{
matcher: '',
hooks: [
{ command: 'echo', args: ['unrelated'] },
{ command: 'onebrain', args: ['checkpoint', 'stop'] },
],
},
],
PostToolUse: [
{ matcher: 'Write|Edit', hooks: [{ command: 'onebrain', args: ['qmd-reindex'] }] },
],
},
});

const result = await checkSettingsHooks(dir, configWithQmd);
expect(result.status).toBe('ok');
});

it('reports exec when canonical and legacy duplicates coexist (partial migration)', async () => {
// Mid-migration state: a legacy shell-form entry was left behind when a
// new canonical entry was added. The canonical one is what actually
// fires, so the check should treat the hook as exec — not legacy.
await writeSettings({
permissions: { allow: allowList },
hooks: {
Stop: [
{
matcher: '',
hooks: [
{ command: 'onebrain checkpoint stop' },
{ command: 'onebrain', args: ['checkpoint', 'stop'] },
],
},
],
PostToolUse: [
{ matcher: 'Write|Edit', hooks: [{ command: 'onebrain', args: ['qmd-reindex'] }] },
],
},
});

const result = await checkSettingsHooks(dir, configWithQmd);
expect(result.status).toBe('ok');
expect(result.details?.some((d) => d.includes('legacy shell form'))).toBe(false);
});

it('evaluates Stop and PostToolUse independently in a mixed migration state', async () => {
// Stop already migrated to exec form, qmd still on legacy. Should fire
// exactly one warning (qmd legacy), not two.
await writeSettings({
permissions: { allow: allowList },
hooks: {
Stop: [{ matcher: '', hooks: [{ command: 'onebrain', args: ['checkpoint', 'stop'] }] }],
PostToolUse: [{ matcher: 'Write|Edit', hooks: [{ command: 'onebrain qmd-reindex' }] }],
},
});

const result = await checkSettingsHooks(dir, configWithQmd);
expect(result.status).toBe('warn');
expect(
result.details?.some((d) => d.includes('PostToolUse (qmd) hook in legacy shell form')),
).toBe(true);
expect(result.details?.some((d) => d.includes('Stop hook'))).toBe(false);
});

it('flags a stale exec-form onebrain hook under a disallowed event', async () => {
// Before this fix, the stale-hook sweep only inspected `command`. An
// exec-form onebrain entry under PreCompact (where it has no business
// running) was masked because the verb hid in args[]. The sweep now
// uses effectiveCommand, so this stale event surfaces correctly.
await writeSettings({
permissions: { allow: allowList },
hooks: {
Stop: [{ matcher: '', hooks: [{ command: 'onebrain', args: ['checkpoint', 'stop'] }] }],
PostToolUse: [
{ matcher: 'Write|Edit', hooks: [{ command: 'onebrain', args: ['qmd-reindex'] }] },
],
PreCompact: [{ matcher: '', hooks: [{ command: 'onebrain', args: ['some-stale-verb'] }] }],
},
});

const result = await checkSettingsHooks(dir, configWithQmd);
expect(result.status).toBe('warn');
expect(result.details?.some((d) => d.includes('stale PreCompact hook'))).toBe(true);
});

it('ignores non-string args entries (defensive against hand-edited settings.json)', async () => {
// settings.json is user-editable JSON, so args could carry a stray
// null/number that would otherwise spread into the joined effective
// command and produce a ghost match.
await writeSettings({
permissions: { allow: allowList },
hooks: {
Stop: [
{
matcher: '',
hooks: [
{
command: 'onebrain',
args: ['checkpoint', null as unknown as string, 'stop'],
},
],
},
],
PostToolUse: [
{ matcher: 'Write|Edit', hooks: [{ command: 'onebrain', args: ['qmd-reindex'] }] },
],
},
});

const result = await checkSettingsHooks(dir, configWithQmd);
// Filtered effective command is `onebrain checkpoint stop` → matches.
expect(result.status).toBe('ok');
});
});
Loading
Loading