From 80aac0eb90e4a8dc78faa45340c86d8b3736cf83 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Wed, 26 Aug 2026 12:34:08 +0700 Subject: [PATCH 01/14] fix(codex): survive plugin cache refreshes --- .../onebrain/.claude-plugin/plugin.json | 4 +- .../onebrain/.codex-plugin/plugin.json | 4 +- .claude/plugins/onebrain/INSTRUCTIONS.md | 17 +- .../onebrain/hooks/check-cli-version.sh | 14 +- .claude/plugins/onebrain/hooks/codex-hook.py | 126 ------------ .../plugins/onebrain/hooks/codex-hooks.json | 16 +- .claude/plugins/onebrain/hooks/hooks.json | 2 +- CHANGELOG.md | 9 +- scripts/check-codex-hooks.py | 185 +++++++++--------- 9 files changed, 133 insertions(+), 244 deletions(-) delete mode 100644 .claude/plugins/onebrain/hooks/codex-hook.py diff --git a/.claude/plugins/onebrain/.claude-plugin/plugin.json b/.claude/plugins/onebrain/.claude-plugin/plugin.json index 7bfa34d0..85e2cf66 100644 --- a/.claude/plugins/onebrain/.claude-plugin/plugin.json +++ b/.claude/plugins/onebrain/.claude-plugin/plugin.json @@ -1,12 +1,12 @@ { "name": "onebrain", - "version": "3.4.11", + "version": "3.4.12", "description": "OneBrain — Your AI Thinking Partner", "author": { "name": "OneBrain Contributors" }, "license": "MIT OR Apache-2.0", "requires": { - "cli": ">=3.4.18" + "cli": ">=3.4.25" } } diff --git a/.claude/plugins/onebrain/.codex-plugin/plugin.json b/.claude/plugins/onebrain/.codex-plugin/plugin.json index 88c975cf..04a11177 100644 --- a/.claude/plugins/onebrain/.codex-plugin/plugin.json +++ b/.claude/plugins/onebrain/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "onebrain", - "version": "3.4.11", + "version": "3.4.12", "description": "OneBrain — Your AI Thinking Partner", "namespace": "onebrain", "skills": "./skills/", @@ -11,6 +11,6 @@ "tagline": "Your AI Thinking Partner" }, "requires": { - "cli": ">=3.4.18" + "cli": ">=3.4.25" } } diff --git a/.claude/plugins/onebrain/INSTRUCTIONS.md b/.claude/plugins/onebrain/INSTRUCTIONS.md index c4641a92..137f4e23 100644 --- a/.claude/plugins/onebrain/INSTRUCTIONS.md +++ b/.claude/plugins/onebrain/INSTRUCTIONS.md @@ -216,13 +216,15 @@ Run before responding to any user message. - Read `onebrain.yml` → load Configuration variables; override defaults once resolved. If `onebrain.yml` is missing, fall back to legacy `vault.yml` (v3.0 name) — same schema; surface a one-line deprecation note in the startup status so the user knows to run `onebrain doctor --fix` to migrate. - Read `[agent_folder]/MEMORY.md` → load identity, personality, active projects - Run session initialization from the vault root: - - **Codex with a SessionStart-injected token:** use the executable path from `ONEBRAIN_BIN` for every CLI call in this chat (never bare `onebrain`; POSIX: `"$ONEBRAIN_BIN" ...`, Windows PowerShell: `& $env:ONEBRAIN_BIN ...`). Run `"$ONEBRAIN_BIN" session init --json --session-token {injected_session_token}` on POSIX or `& $env:ONEBRAIN_BIN session init --json --session-token {injected_session_token}` on Windows. Store the returned `session_token` and verify it exactly equals the injected token. If it differs or the flag is unavailable, preserve the injected token and disable checkpoint/wrapup writes for this chat rather than falling back to terminal/process identity. + - **Codex with SessionStart-injected startup metadata (plugin v3.4.12+):** trust and parse the metadata already collected by the installed CLI hook runner; do **not** run `session init` again. Preserve the injected `session_token`, and use the injected exact executable path for every later CLI call in this chat (never bare `onebrain`). **Legacy fallback (token present but startup metadata absent):** run that exact executable path with `session init --json --session-token {injected_session_token}`, store the returned metadata, and verify the returned token exactly equals the injected token. If it differs or the flag is unavailable, preserve the injected token and disable checkpoint/wrapup writes for this chat rather than falling back to terminal/process identity. - **Claude/Gemini:** run `onebrain session init --json`. Parse JSON output; store `DATETIME` (for greeting), `session_token` (for checkpoints), `search_unembedded`, and `headless` in context. **Read `search_unembedded` (canonical, CLI v3.4.5+); if that key is absent, fall back to the legacy `qmd_unembedded` key** — older CLIs emit only `qmd_unembedded`, while v3.4.5 emits both keys with the same value during the transition. JSON shape: `{"datetime":"Ddd · DD Mon YYYY · HH:MM","session_token":"XXXXX","search_unembedded":N|null,"qmd_unembedded":N|null,"headless":true|false}`. The value is a count `N`, or **`null`** when the search probe couldn't determine it (index missing / timed out / unparseable) — treat `null` as **unknown**, distinct from a genuine `0` (a real index with nothing pending). **CLI v3.1+ requires `--json` because the default output flipped to text.** The v3.0 alias `onebrain session-init` still works and now auto-rewrites to include `--json` when `onebrain plugin update` runs. If the command fails or is unavailable, fall back to running `date '+%a · %d %b %Y · %H:%M'` for `DATETIME`; on Claude/Gemini only, treat `session_token` as `99999`, `search_unembedded` as `0`, and `headless` as `false`. Codex must never invent or replace its injected chat token. If JSON output contains `{"decision":"block","reason":"onebrain-vault-not-found"}` (CLI v3.1+) or `"reason":"onebrain-init-required"` (CLI v3.0 back-compat), skip Steps 2–4; instead output a single message: "OneBrain vault not initialized. Run `/onboarding` to set up your vault." - **If `headless` is `true`** (CLI v3.2.6+, set by `onebrain skill run`): this is an unattended one-shot run, so **skip Steps 2–4 entirely** — no greeting, no startup status, and none of Step 3's memory/inbox/task/orphan/pause scans — and proceed directly to the invoked skill. The `headless` field is absent on older CLIs; treat absent as `false` (normal interactive startup). **Step 2 — Send greeting immediately:** +Send the greeting before starting any Step 3 tool calls; never batch Step 3 onto the greeting's critical path. + Format — use plain text only, no markdown syntax: ``` ──────────────────────────────── @@ -241,23 +243,24 @@ Ddd · DD Mon YYYY · HH:MM | 17:00–21:00 | good evening + ready | 🌆 | | after 21:00 | late night acknowledgement | 🌙 | -- `Ddd · DD Mon YYYY · HH:MM` comes from the `DATETIME` variable set in Step 1 (`onebrain session init --json` JSON output) +- `Ddd · DD Mon YYYY · HH:MM` comes from the `DATETIME` variable set in Step 1 (SessionStart metadata on Codex; `onebrain session init --json` on Claude/Gemini) - Always include a greeting phrase — never omit it. Example for daytime: "Hey [user], ready to go!" On weekends: lighter, less task-focused tone. **No-repeat rule:** don't ask about facts already in context. **Step 3 — After greeting (run all in parallel):** -- `session_token` is already in context from Step 1 (`onebrain session init --json`) — do not re-run detection +- `session_token` is already in context from Step 1 (SessionStart metadata on Codex; `onebrain session init --json` on Claude/Gemini) — do not re-run detection - Read `[agent_folder]/MEMORY-INDEX.md` → load memory file index for lazy-loading -- Load `memory/` files matching active project keywords from MEMORY-INDEX.md Topics column (`status: active` or `needs-review` only). Also match user's first message once it arrives. +- Load `memory/` files only when the user's first message matches their MEMORY-INDEX.md Topics keywords (`status: active` or `needs-review` only). Do not eagerly load every active project; a generic greeting must not pull project memory into context. - Glob `[inbox_folder]/*.md` → count files as `inbox_count` -- Run `onebrain task list --due-by today --json` (from vault root) → parse `data.tasks` +- Run `{injected exact executable} task list --due-by today --limit 5 --json` on Codex, or `onebrain task list --due-by today --limit 5 --json` on Claude/Gemini (from vault root) → parse `data.tasks` (`{file, line, text, due, done}`, fence-aware + open-only). Group overdue first, then due - today. **Fallback (older CLI / non-zero exit):** run the Grep tool twice — `path: + today. Read `data.total` for the full filtered count and use it for the `+N more` line. + **Fallback (older CLI / non-zero exit):** retry once without `--limit`; then run the Grep tool twice — `path: "[projects_folder]"` and `path: "[inbox_folder]"`, pattern `- \[ \] .*📅 [0-9]{4}-[0-9]{2}-[0-9]{2}`, `output_mode: "content"` — combine, keep dates ≤ today. (Fallback still shows fenced demo lines; upgrade the CLI to remove them.) -- Run `onebrain checkpoint orphans "[logs_folder]" "[session_token]" --json` (from vault root) → parse JSON output; read `orphan_count` field. JSON shape: `{"orphan_count":N}`. **CLI v3.1+ requires `--json`** (default is now text). The v3.0 alias `onebrain orphan-scan` still works and is auto-rewritten by `onebrain plugin update`. If the command fails or is unavailable, fall back to a structure-aware glob: if `[logs_folder]/checkpoint/` exists, glob `[logs_folder]/checkpoint/*-checkpoint-*.md` (post-v2.4.0 flat layout); else glob `[logs_folder]/**/*-checkpoint-*.md` (pre-v2.4.0 nested layout — multi-vault user on an unmigrated vault). Then discard files whose date has a non-auto-saved session log (look in `[logs_folder]/session/YYYY/MM/` for post-v2.4.0, or `[logs_folder]/YYYY/MM/` for pre-v2.4.0), and count distinct session tokens among remaining files. +- Run `{injected exact executable} checkpoint orphans "[logs_folder]" "[session_token]" --json` on Codex, or `onebrain checkpoint orphans "[logs_folder]" "[session_token]" --json` on Claude/Gemini (from vault root) → parse JSON output; read `orphan_count` field. JSON shape: `{"orphan_count":N}`. **CLI v3.1+ requires `--json`** (default is now text). The v3.0 alias `onebrain orphan-scan` still works and is auto-rewritten by `onebrain plugin update`. If the command fails or is unavailable, fall back to a structure-aware glob: if `[logs_folder]/checkpoint/` exists, glob `[logs_folder]/checkpoint/*-checkpoint-*.md` (post-v2.4.0 flat layout); else glob `[logs_folder]/**/*-checkpoint-*.md` (pre-v2.4.0 nested layout — multi-vault user on an unmigrated vault). Then discard files whose date has a non-auto-saved session log (look in `[logs_folder]/session/YYYY/MM/` for post-v2.4.0, or `[logs_folder]/YYYY/MM/` for pre-v2.4.0), and count distinct session tokens among remaining files. - Glob `[logs_folder]/scheduler/**/*.err.md` and keep files modified within the last 24 h → count as `sched_err_count`; note the most recent file's skill name as `sched_err_skill`. (Filesystem only — never probe the OS scheduler at startup; /doctor owns the deep check.) - Read `[logs_folder]/pause/_active.md` if present → parse single-line content as `active_pause_slug`. If absent, set `active_pause_slug = null`. Then if non-null: glob `[logs_folder]/pause/*-{active_pause_slug}-pause-*.md` and count them as `active_pause_count`; read the latest file's `date` frontmatter as `active_pause_last_date`. - **Legacy structure detection (post-v2.4.0):** Check whether `[logs_folder]/session/` exists (any of the new top-level subfolders works as a sentinel; `session/` is the most representative). If it does NOT exist AND `[logs_folder]/YYYY/` does exist (legacy structure pre-v2.4.0), set `vault_structure_legacy = true`. If both `session/` and a legacy `YYYY/` exist (partial migration), still treat `vault_structure_legacy = false` — `/update` will resume cleanup on next run. If neither exists (fresh vault), `vault_structure_legacy = false`. diff --git a/.claude/plugins/onebrain/hooks/check-cli-version.sh b/.claude/plugins/onebrain/hooks/check-cli-version.sh index 46c2e63c..f1b1cc7c 100755 --- a/.claude/plugins/onebrain/hooks/check-cli-version.sh +++ b/.claude/plugins/onebrain/hooks/check-cli-version.sh @@ -2,15 +2,15 @@ # OneBrain plugin v3 — SessionStart enforcement hook. # # Refuses to load the session when the `onebrain` CLI is absent or older -# than v3.1.0. Pairs with `requires.cli` in plugin.json — that field is -# metadata for tooling; this hook is the runtime enforcement. The floor is -# v3.1.0 because this plugin emits v3.1 nested commands (`session init`, -# `search reindex`, …) + the `--json` hook contract that v3.0.x can't parse. +# than v3.4.25. Pairs with `requires.cli` in plugin.json — that field is +# metadata for tooling; this hook is the runtime enforcement. The floor +# includes the cache-independent Codex hook runner and bounded startup task +# listing introduced in CLI v3.4.25. # # Comparison strategy: extract the bare MAJOR.MINOR.PATCH from # `onebrain --version` (drops any prerelease suffix like -alpha.9) and -# sort against 3.1.0 with `sort -V`. v2.x Bun users and v3.0.x users are -# blocked with a clear update path. +# compare component-wise against 3.4.25. Older CLI users are blocked with a +# clear update path. # # Output contract: emit a JSON SessionStart payload with `decision: block` # and a `reason` that lists the install / update paths for each platform. @@ -18,7 +18,7 @@ set -u -MIN_VERSION="3.1.0" +MIN_VERSION="3.4.25" block_message() { # Single-string JSON message; embedded newlines stay as literal "\n" in diff --git a/.claude/plugins/onebrain/hooks/codex-hook.py b/.claude/plugins/onebrain/hooks/codex-hook.py deleted file mode 100644 index fbc5e9be..00000000 --- a/.claude/plugins/onebrain/hooks/codex-hook.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -"""Bridge Codex hook `session_id` into OneBrain's session-token environment. - -Always exits zero: hooks must not block a user turn when the CLI or index is -unavailable. -""" - -import json -import os -import shlex -import subprocess -import sys - -MIN_CLI = (3, 4, 18) - - -def onebrain_command(): - return os.environ.get("ONEBRAIN_BIN", "onebrain") - - -def command_examples(token): - command = onebrain_command() - posix_command = shlex.quote(command) - powershell_command = "'" + command.replace("'", "''") + "'" - return ( - f"POSIX `{posix_command} session init --json --session-token {token}`; " - f"Windows PowerShell `& {powershell_command} session init --json --session-token {token}`." - ) - - -def additional_context(message): - json.dump( - { - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": message, - } - }, - sys.stdout, - ) - - -def cli_is_compatible(env): - try: - proc = subprocess.run( - [onebrain_command(), "--version"], - env=env, - capture_output=True, - text=True, - check=False, - timeout=3, - ) - except (OSError, subprocess.TimeoutExpired): - return None - if proc.returncode != 0: - return None - text = f"{proc.stdout} {proc.stderr}" - for word in text.split(): - parts = word.lstrip("v").split(".") - if len(parts) >= 3 and all(part.isdigit() for part in parts[:3]): - return tuple(map(int, parts[:3])) >= MIN_CLI - return None - - -def run(mode, *args): - env = os.environ.copy() - command_timeout = 2 if mode in ("lex", "pending") else 7 - try: - payload = json.load(sys.stdin) - except Exception: - payload = {} - session_id = payload.get("session_id") - if isinstance(session_id, str) and session_id: - env["CODEX_SESSION_ID"] = session_id - if mode == "session-start": - compatible = cli_is_compatible(env) - if compatible is False: - additional_context( - "OneBrain Codex hooks require CLI >= 3.4.18. Run `onebrain update`, " - "then start a new Codex chat. Checkpoint and wrapup isolation are " - "disabled in this chat to avoid mixing session identities." - ) - return - try: - proc = subprocess.run( - [onebrain_command(), *args], - env=env, - capture_output=True, - text=True, - check=False, - timeout=command_timeout, - ) - except (OSError, subprocess.TimeoutExpired): - return - if proc.returncode != 0: - return - if mode == "session-start" and proc.stdout.strip(): - try: - data = json.loads(proc.stdout) - token = data.get("session_token") - if token is None and isinstance(data.get("data"), dict): - token = data["data"].get("session_token") - if isinstance(token, str) and token: - additional_context( - f"OneBrain Codex session_token: {token}. Preserve this token for " - "checkpoint and wrapup isolation in this chat. During startup, " - "invoke this exact executable path (never a bare `onebrain`): " - f"{command_examples(token)} " - "Use the same executable path for every later OneBrain CLI call in this chat " - "so metadata collection cannot replace the hook-derived identity." - ) - except Exception: - return - elif mode == "checkpoint" and proc.stdout.strip(): - sys.stdout.write(proc.stdout) - - -mode = sys.argv[1] if len(sys.argv) > 1 else "" -if mode == "session-start": - run(mode, "session", "init", "--json") -elif mode == "checkpoint": - run(mode, "checkpoint", "stop", "--json") -elif mode == "lex": - run(mode, "search", "reindex", "--lex-only", "--json") -elif mode == "pending": - run(mode, "search", "reindex", "--pending-only", "--json") diff --git a/.claude/plugins/onebrain/hooks/codex-hooks.json b/.claude/plugins/onebrain/hooks/codex-hooks.json index 2d11c245..69fd39a2 100644 --- a/.claude/plugins/onebrain/hooks/codex-hooks.json +++ b/.claude/plugins/onebrain/hooks/codex-hooks.json @@ -6,8 +6,8 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/codex-hook.py\" session-start", - "commandWindows": "py \"%CLAUDE_PLUGIN_ROOT%\\hooks\\codex-hook.py\" session-start", + "command": "onebrain codex-hook session-start", + "commandWindows": "onebrain codex-hook session-start", "timeout": 10 } ] @@ -19,8 +19,8 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/codex-hook.py\" lex", - "commandWindows": "py \"%CLAUDE_PLUGIN_ROOT%\\hooks\\codex-hook.py\" lex", + "command": "onebrain codex-hook lex", + "commandWindows": "onebrain codex-hook lex", "timeout": 10 } ] @@ -31,14 +31,14 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/codex-hook.py\" checkpoint", - "commandWindows": "py \"%CLAUDE_PLUGIN_ROOT%\\hooks\\codex-hook.py\" checkpoint", + "command": "onebrain codex-hook checkpoint", + "commandWindows": "onebrain codex-hook checkpoint", "timeout": 10 }, { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/codex-hook.py\" pending", - "commandWindows": "py \"%CLAUDE_PLUGIN_ROOT%\\hooks\\codex-hook.py\" pending", + "command": "onebrain codex-hook pending", + "commandWindows": "onebrain codex-hook pending", "timeout": 10 } ] diff --git a/.claude/plugins/onebrain/hooks/hooks.json b/.claude/plugins/onebrain/hooks/hooks.json index 8b92ea6c..14574c10 100644 --- a/.claude/plugins/onebrain/hooks/hooks.json +++ b/.claude/plugins/onebrain/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "OneBrain plugin v3 — SessionStart hook that refuses to load against an incompatible OneBrain CLI (< v3.0.0), pairs with `requires.cli` in plugin.json. PreToolUse hooks: (1) gates repeat Read calls on vault .md docs already sent this session (token-optimization Ledger Gate, CLI v3.4.10+, design §5b) — off by default (onebrain.yml `token_optimization.read_hook: off`), always fail-open, see INSTRUCTIONS.md \"Vault-read Ledger Gate (PreToolUse Hook)\"; (2) gates vault-content Grep calls that skip the search cascade's MCP-first step (Track C #221) — always on, always fail-open, see skills/startup/SEARCH.md \"The Cascade\".", + "description": "OneBrain plugin v3 — SessionStart hook that refuses to load against an incompatible OneBrain CLI (< v3.4.25), pairs with `requires.cli` in plugin.json. PreToolUse hooks: (1) gates repeat Read calls on vault .md docs already sent this session (token-optimization Ledger Gate, CLI v3.4.10+, design §5b) — off by default (onebrain.yml `token_optimization.read_hook: off`), always fail-open, see INSTRUCTIONS.md \"Vault-read Ledger Gate (PreToolUse Hook)\"; (2) gates vault-content Grep calls that skip the search cascade's MCP-first step (Track C #221) — always on, always fail-open, see skills/startup/SEARCH.md \"The Cascade\".", "hooks": { "SessionStart": [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index 052d5894..9fc91f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ --- -latest_version: 3.4.11 -released: 2026-07-30 +latest_version: 3.4.12 +released: 2026-08-25 --- # Changelog @@ -11,6 +11,11 @@ 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 changes, see the [`onebrain-ai/onebrain-cli`](https://github.com/onebrain-ai/onebrain-cli/blob/main/CHANGELOG.md) repository. +## v3.4.12 — 2026-08-25 — Codex hooks survive cache refreshes + +- Codex hook manifests call the cache-independent runner in OneBrain CLI v3.4.25+, so an active task no longer retains a dead path when the plugin cache refreshes. +- Codex reuses SessionStart metadata, loads project memory only on topic matches, and bounds startup tasks to five while retaining the full count. + ## v3.4.11 — 2026-07-30 — /digest first run is a complete setup chain - The interview now closes with the Telegram Notification Offer (shared with `/schedule-add`) when the telegram tools are present and no `notifications.telegram_chat_id` is set — sections → schedule → delivery channel in one conversation, no second command. Vaults without the telegram channel hear nothing about it. diff --git a/scripts/check-codex-hooks.py b/scripts/check-codex-hooks.py index 90ef7b43..88dabcd1 100644 --- a/scripts/check-codex-hooks.py +++ b/scripts/check-codex-hooks.py @@ -1,107 +1,114 @@ #!/usr/bin/env python3 -"""Exercise Codex hook session binding with two independent chat IDs.""" +"""Validate Codex hook manifests and their cache-independent lifecycle.""" + +from __future__ import annotations import json import os -import pathlib -import stat +from pathlib import Path +import shutil import subprocess -import sys import tempfile -root = pathlib.Path(__file__).resolve().parents[1] -hook = root / ".claude/plugins/onebrain/hooks/codex-hook.py" -hooks_config = root / ".claude/plugins/onebrain/hooks/codex-hooks.json" +ROOT = Path(__file__).resolve().parents[1] +HOOKS_PATH = ROOT / ".claude/plugins/onebrain/hooks/codex-hooks.json" +EXPECTED_MODES = { + "SessionStart": ["session-start"], + "PostToolUse": ["lex"], + "Stop": ["checkpoint", "pending"], +} -hooks_text = hooks_config.read_text(encoding="utf-8") -if "CODEX_PLUGIN_ROOT" in hooks_text or "CLAUDE_PLUGIN_ROOT" not in hooks_text: - raise SystemExit( - "Codex hooks must use CLAUDE_PLUGIN_ROOT, the plugin-root variable " - "provided by the Codex plugin runtime" - ) -hooks_json = json.loads(hooks_text) -for event, groups in hooks_json["hooks"].items(): - for group in groups: - for command in group["hooks"]: - if "commandWindows" not in command: - raise SystemExit(f"{event} hook is missing commandWindows") - if "${CLAUDE_PLUGIN_ROOT}" not in command["command"]: - raise SystemExit(f"{event} POSIX hook has no portable plugin root") - if "%CLAUDE_PLUGIN_ROOT%\\" not in command["commandWindows"]: - raise SystemExit(f"{event} Windows hook has no portable plugin root") - -with tempfile.TemporaryDirectory() as tmp: - tmp = pathlib.Path(tmp) - fake = tmp / "onebrain" - fake.write_text( - "#!/bin/sh\n" - "if [ \"$1\" = \"--version\" ]; then\n" - " printf 'onebrain 3.4.18\\n'\n" - "else\n" - " printf '{\"session_token\":\"%s\"}' \"$CODEX_SESSION_ID\"\n" - "fi\n", - encoding="utf-8", + +def commands_for(manifest: dict, event: str) -> list[dict]: + return [ + hook + for matcher in manifest["hooks"][event] + for hook in matcher["hooks"] + ] + + +manifest = json.loads(HOOKS_PATH.read_text()) +for event, modes in EXPECTED_MODES.items(): + commands = commands_for(manifest, event) + assert len(commands) == len(modes), f"unexpected {event} hook count" + for command, mode in zip(commands, modes): + expected = f"onebrain codex-hook {mode}" + assert command["command"] == expected, ( + f"{event} must call the installed CLI, not a versioned plugin-cache file" + ) + assert command["commandWindows"] == expected, ( + f"{event} Windows hook must call the installed CLI" + ) + + +with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + fake_bin = temp / "bin" + fake_bin.mkdir() + calls_path = temp / "calls.jsonl" + fake_onebrain = fake_bin / "onebrain" + fake_onebrain.write_text( + """#!/usr/bin/env python3 +import json +import os +import sys + +payload = json.load(sys.stdin) +mode = sys.argv[2] +with open(os.environ["FAKE_ONEBRAIN_CALLS"], "a", encoding="utf-8") as handle: + handle.write(json.dumps({"args": sys.argv[1:], "session_id": payload.get("session_id")}) + "\\n") +if mode == "session-start": + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": f"session={payload.get('session_id')}", + }})) +elif mode == "checkpoint": + print(json.dumps({"continue": True})) +""" ) - fake.chmod(fake.stat().st_mode | stat.S_IXUSR) + fake_onebrain.chmod(0o755) + + # Reproduce the real failure mode: Codex keeps the command after the plugin + # manager deletes the versioned cache directory it originally came from. + deleted_plugin_root = temp / "plugin-cache" / "onebrain" / "3.4.4" + shutil.copytree(ROOT / ".claude/plugins/onebrain", deleted_plugin_root) + shutil.rmtree(deleted_plugin_root) + env = os.environ.copy() - env["PATH"] = f"{tmp}{os.pathsep}{env.get('PATH', '')}" - env["ONEBRAIN_BIN"] = str(fake) + env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}" + env["CLAUDE_PLUGIN_ROOT"] = str(deleted_plugin_root) + env["FAKE_ONEBRAIN_CALLS"] = str(calls_path) - outputs = [] - for session_id in ("same-prefix-chat-a", "same-prefix-chat-b"): - proc = subprocess.run( - [sys.executable, str(hook), "session-start"], + def run(event: str, index: int, session_id: str) -> subprocess.CompletedProcess[str]: + command = commands_for(manifest, event)[index]["command"] + result = subprocess.run( + command, + shell=True, + check=False, input=json.dumps({"session_id": session_id}), text=True, capture_output=True, env=env, - check=True, ) - output = json.loads(proc.stdout) - outputs.append(output["hookSpecificOutput"]["additionalContext"]) - - if not ( - "same-prefix-chat-a" in outputs[0] - and "same-prefix-chat-b" in outputs[1] - and f'`{fake} session init --json --session-token same-prefix-chat-a`' - in outputs[0] - and f'& \'{fake}\' session init --json --session-token same-prefix-chat-b`' - in outputs[1] - and outputs[0] != outputs[1] - ): - raise SystemExit(f"Codex session binding failed: {outputs!r}") - - proc = subprocess.run( - [sys.executable, str(hook), "pending"], - input=json.dumps({"session_id": "quiet-background-hook"}), - text=True, - capture_output=True, - env=env, - check=True, - ) - if proc.stdout: - raise SystemExit(f"Codex background hook leaked non-protocol output: {proc.stdout!r}") - - fake.write_text( - "#!/bin/sh\n" - "if [ \"$1\" = \"--version\" ]; then\n" - " printf 'onebrain 3.4.17\\n'\n" - "else\n" - " exit 99\n" - "fi\n", - encoding="utf-8", - ) - proc = subprocess.run( - [sys.executable, str(hook), "session-start"], - input=json.dumps({"session_id": "old-cli-chat"}), - text=True, - capture_output=True, - env=env, - check=True, - ) - warning = json.loads(proc.stdout)["hookSpecificOutput"]["additionalContext"] - if "require CLI >= 3.4.18" not in warning: - raise SystemExit(f"Codex CLI version gate failed: {warning!r}") + assert result.returncode == 0, result.stderr + return result + + first = run("SessionStart", 0, "codex-a") + second = run("SessionStart", 0, "codex-b") + assert json.loads(first.stdout)["hookSpecificOutput"]["additionalContext"] == "session=codex-a" + assert json.loads(second.stdout)["hookSpecificOutput"]["additionalContext"] == "session=codex-b" + assert run("PostToolUse", 0, "codex-a").stdout == "" + run("Stop", 0, "codex-a") + assert run("Stop", 1, "codex-a").stdout == "" + + calls = [json.loads(line) for line in calls_path.read_text().splitlines()] + assert [call["args"] for call in calls] == [ + ["codex-hook", "session-start"], + ["codex-hook", "session-start"], + ["codex-hook", "lex"], + ["codex-hook", "checkpoint"], + ["codex-hook", "pending"], + ] -print("Codex hooks OK — distinct chat session_id values remain isolated.") +print("codex hooks ok") From 2c2e96892b3deca7092b50f12bfb50c8b18bf6c3 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Wed, 26 Aug 2026 16:26:49 +0700 Subject: [PATCH 02/14] docs: update release date --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc91f7d..48111bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ --- latest_version: 3.4.12 -released: 2026-08-25 +released: 2026-08-26 --- # Changelog @@ -11,7 +11,7 @@ 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 changes, see the [`onebrain-ai/onebrain-cli`](https://github.com/onebrain-ai/onebrain-cli/blob/main/CHANGELOG.md) repository. -## v3.4.12 — 2026-08-25 — Codex hooks survive cache refreshes +## v3.4.12 — 2026-08-26 — Codex hooks survive cache refreshes - Codex hook manifests call the cache-independent runner in OneBrain CLI v3.4.25+, so an active task no longer retains a dead path when the plugin cache refreshes. - Codex reuses SessionStart metadata, loads project memory only on topic matches, and bounds startup tasks to five while retaining the full count. From 31d5a4a0f5c7fd5359eeab5f81505e29ab9c23bf Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Wed, 26 Aug 2026 22:09:42 +0700 Subject: [PATCH 03/14] feat(hooks): unify plugin lifecycle registrations --- .claude/plugins/onebrain/INSTRUCTIONS.md | 8 +- .../plugins/onebrain/hooks/codex-hooks.json | 18 +- .claude/plugins/onebrain/hooks/hooks.json | 11 +- .gemini/settings.json | 15 +- .github/workflows/ci.yml | 4 +- .../task-2-report.md | 63 ++++++ CHANGELOG.md | 1 + CONTRIBUTING.md | 12 +- GEMINI.md | 2 +- docs/install.md | 5 + docs/memory.md | 9 +- scripts/check-codex-hooks.py | 114 ---------- scripts/check-lifecycle-hooks.py | 194 ++++++++++++++++++ 13 files changed, 313 insertions(+), 143 deletions(-) create mode 100644 .superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md delete mode 100644 scripts/check-codex-hooks.py create mode 100644 scripts/check-lifecycle-hooks.py diff --git a/.claude/plugins/onebrain/INSTRUCTIONS.md b/.claude/plugins/onebrain/INSTRUCTIONS.md index 137f4e23..87f90e0b 100644 --- a/.claude/plugins/onebrain/INSTRUCTIONS.md +++ b/.claude/plugins/onebrain/INSTRUCTIONS.md @@ -330,7 +330,7 @@ If the user closes the session without any end-of-session signal, AUTO-SUMMARY d ### Auto Checkpoint (Hook-Triggered) -> **What is session_token?** A chat-unique identifier resolved by `onebrain session init` at startup and kept in context. Codex SessionStart passes the hook payload's complete `session_id` through `CODEX_SESSION_ID`; CLI v3.4.18 hashes the full value into a stable 16-character token, so each Codex chat owns its checkpoints and wrapup even when several chats use one vault. Claude uses `$CLAUDE_CODE_SESSION_ID`; Gemini and older hosts fall through to `$WT_SESSION` → `$TMUX_PANE` → `$TERM_SESSION_ID` → process/day fallbacks. On Codex, if the injected token is missing after compact, do not use a terminal fallback because that could mix chats — stop checkpoint/wrapup and surface the missing hook context. If token resolution fails on any harness, abort checkpoint silently; never guess. +> **What is session_token?** A chat-unique identifier resolved by `onebrain session init` at startup and kept in context. The shared `onebrain hook` bridge receives each harness's complete lifecycle payload; on Codex it passes the payload's full `session_id` to the CLI, which hashes it into a stable 16-character token, so each Codex chat owns its checkpoints and wrapup even when several chats use one vault. Claude uses `$CLAUDE_CODE_SESSION_ID`; Gemini and older hosts fall through to `$WT_SESSION` → `$TMUX_PANE` → `$TERM_SESSION_ID` → process/day fallbacks. On Codex, if the injected token is missing after compact, do not use a terminal fallback because that could mix chats — stop checkpoint/wrapup and surface the missing hook context. If token resolution fails on any harness, abort checkpoint silently; never guess. When a hook sends a message whose reason matches `NN since ` (a zero-padded two-digit number followed by ` since start` or ` since checkpoint-NN`), silently spawn a background agent (mode: bypassPermissions) to write the checkpoint. The background agent receives: `session_token` from context (re-run `onebrain session init` first if not in context — if that fails, abort silently), today's date (YYYY-MM-DD), NN from the reason, `[logs_folder]`, and enough conversation context to write a meaningful checkpoint. @@ -342,7 +342,9 @@ Determine action from the reason: - `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → spawn background agent (mode: bypassPermissions) to write the checkpoint; if session_token is not in context, re-run `onebrain session init` first — if that fails, abort silently; agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching - Ambiguous or unknown → default to stop checkpoint -OneBrain registers the Stop checkpoint hook, plus — when a search collection is configured — a PostToolUse **search-reindex** hook (`onebrain search reindex --lex-only`: incremental keyword reindex on Write/Edit) and a Stop **embed** hook (`onebrain search reindex --pending-only`: deferred embedding pass at session end). PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly via the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. +All three supported harnesses register the same bare `onebrain hook` lifecycle command. The installed CLI selects session initialization, incremental search reindexing, checkpointing, and pending embedding from the stdin `hook_event_name`; Codex has exactly one Stop registration, so checkpoint and embedding work are not duplicated. PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly through the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. + +After upgrading this plugin, start a new agent session so the new registrations are loaded. The old `codex-hook` alias is intentionally absent. **Stop checkpoint format:** Read `skills/startup/references/session-formats.md` → Checkpoint Format. Keep under 250 words. @@ -491,7 +493,7 @@ Users with a populated `schedule:` block never see the preset prompt — preset ## Headless invocation -Scheduled skills run via `onebrain skill run --vault {VAULT} --skill /daily --harness {HARNESS} [--arg key=value ...]`. Claude/Gemini receive slash-style prompts; Codex receives `$onebrain:daily [args]` through `codex exec`. SessionStart, edit reindex, and Stop hooks run for the selected harness. +Scheduled skills run via `onebrain skill run --vault {VAULT} --skill /daily --harness {HARNESS} [--arg key=value ...]`. Claude/Gemini receive slash-style prompts; Codex receives `$onebrain:daily [args]` through `codex exec`. The selected harness invokes the shared `onebrain hook` command for SessionStart, tool-completion, and Stop-equivalent lifecycle events. The scheduler artifacts emitted by `onebrain schedule register` always point at the local `onebrain` binary. Harness binaries are resolved inside that process; override with `CLAUDE_BIN`, `GEMINI_BIN`, or `CODEX_BIN` when needed. diff --git a/.claude/plugins/onebrain/hooks/codex-hooks.json b/.claude/plugins/onebrain/hooks/codex-hooks.json index 69fd39a2..ad900c23 100644 --- a/.claude/plugins/onebrain/hooks/codex-hooks.json +++ b/.claude/plugins/onebrain/hooks/codex-hooks.json @@ -6,8 +6,8 @@ "hooks": [ { "type": "command", - "command": "onebrain codex-hook session-start", - "commandWindows": "onebrain codex-hook session-start", + "command": "onebrain hook", + "commandWindows": "onebrain hook", "timeout": 10 } ] @@ -19,8 +19,8 @@ "hooks": [ { "type": "command", - "command": "onebrain codex-hook lex", - "commandWindows": "onebrain codex-hook lex", + "command": "onebrain hook", + "commandWindows": "onebrain hook", "timeout": 10 } ] @@ -31,14 +31,8 @@ "hooks": [ { "type": "command", - "command": "onebrain codex-hook checkpoint", - "commandWindows": "onebrain codex-hook checkpoint", - "timeout": 10 - }, - { - "type": "command", - "command": "onebrain codex-hook pending", - "commandWindows": "onebrain codex-hook pending", + "command": "onebrain hook", + "commandWindows": "onebrain hook", "timeout": 10 } ] diff --git a/.claude/plugins/onebrain/hooks/hooks.json b/.claude/plugins/onebrain/hooks/hooks.json index 14574c10..2a326a76 100644 --- a/.claude/plugins/onebrain/hooks/hooks.json +++ b/.claude/plugins/onebrain/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "OneBrain plugin v3 — SessionStart hook that refuses to load against an incompatible OneBrain CLI (< v3.4.25), pairs with `requires.cli` in plugin.json. PreToolUse hooks: (1) gates repeat Read calls on vault .md docs already sent this session (token-optimization Ledger Gate, CLI v3.4.10+, design §5b) — off by default (onebrain.yml `token_optimization.read_hook: off`), always fail-open, see INSTRUCTIONS.md \"Vault-read Ledger Gate (PreToolUse Hook)\"; (2) gates vault-content Grep calls that skip the search cascade's MCP-first step (Track C #221) — always on, always fail-open, see skills/startup/SEARCH.md \"The Cascade\".", + "description": "OneBrain plugin v3 — SessionStart lifecycle uses the shared `onebrain hook` bridge plus a version check that refuses to load against an incompatible OneBrain CLI (< v3.4.25), pairing with `requires.cli` in plugin.json. PreToolUse hooks: (1) gates repeat Read calls on vault .md docs already sent this session (token-optimization Ledger Gate, CLI v3.4.10+, design §5b) — off by default (onebrain.yml `token_optimization.read_hook: off`), always fail-open, see INSTRUCTIONS.md \"Vault-read Ledger Gate (PreToolUse Hook)\"; (2) gates vault-content Grep calls that skip the search cascade's MCP-first step (Track C #221) — always on, always fail-open, see skills/startup/SEARCH.md \"The Cascade\".", "hooks": { "SessionStart": [ { @@ -10,6 +10,15 @@ "timeout": 10 } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "onebrain hook", + "timeout": 10 + } + ] } ], "PreToolUse": [ diff --git a/.gemini/settings.json b/.gemini/settings.json index 231b7ff9..a3c8a21f 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -3,13 +3,24 @@ "disableLoopDetection": true }, "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "onebrain hook" + } + ] + } + ], "AfterAgent": [ { "matcher": "*", "hooks": [ { "type": "command", - "command": "onebrain checkpoint stop --json > /dev/null 2>&1; echo '{}'" + "command": "onebrain hook" } ] } @@ -20,7 +31,7 @@ "hooks": [ { "type": "command", - "command": "onebrain search reindex --json > /dev/null 2>&1; echo '{}'" + "command": "onebrain hook" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 165544ed..aa5ce8d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,8 @@ jobs: - run: python3 scripts/check-skill-count.py codex-hooks: - name: codex hook session binding + name: unified lifecycle hook contract runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: python3 scripts/check-codex-hooks.py + - run: python3 scripts/check-lifecycle-hooks.py diff --git a/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md b/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md new file mode 100644 index 00000000..d5feffcb --- /dev/null +++ b/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md @@ -0,0 +1,63 @@ +# Task 2 report — migrate plugin lifecycle registrations + +## Implementation + +- Updated Codex `SessionStart`, `PostToolUse`, and single `Stop` registration to use exactly `onebrain hook` on POSIX and Windows. +- Added `onebrain hook` to Claude `SessionStart` while retaining the CLI-version check and both independent PreToolUse hooks. +- Added Gemini `SessionStart` (`startup`), `AfterTool` (`write_file|replace`), and `AfterAgent` (`*`) registrations, all using bare `onebrain hook`; removed redirects and `echo '{}'` wrappers. +- Renamed the validator to `scripts/check-lifecycle-hooks.py`, expanded it to all three harnesses, and updated `.github/workflows/ci.yml`. +- Validator now checks exact registration counts/commands, preservation of Claude independent hooks, full hook payload forwarding, SessionStart context, `{}` tool/unsupported-event output, Stop protocol output, and operation after deleting the plugin-cache directory. +- Updated `INSTRUCTIONS.md`, `CONTRIBUTING.md`, `GEMINI.md`, `docs/install.md`, `docs/memory.md`, and the current `CHANGELOG.md` entry to document the shared lifecycle command and the required new agent session after upgrading. The old `codex-hook` alias is explicitly documented as absent. + +## TDD evidence + +RED test/contract command: + +```text +$ python3 scripts/check-lifecycle-hooks.py +Traceback (most recent call last): + File "/private/tmp/onebrain-codex-hook-plugin/scripts/check-lifecycle-hooks.py", line 40, in + assert_unified_command(commands[0], f"Codex {event}") + File "/private/tmp/onebrain-codex-hook-plugin/scripts/check-lifecycle-hooks.py", line 29, in assert_unified_command + assert command["command"] == "onebrain hook", f"{event} must call onebrain hook" +AssertionError: Codex SessionStart must call onebrain hook +``` + +RED exit status: `1`. + +After implementing the manifests and validator behavior, the same command produced: + +```text +$ python3 scripts/check-lifecycle-hooks.py +lifecycle hooks ok +``` + +GREEN exit status: `0`. + +## Validation + +All commands below completed with exit status `0`: + +- `python3 scripts/check-lifecycle-hooks.py` — `lifecycle hooks ok` +- `python3.11 scripts/check-config.py` — `Config OK — all tracked JSON/TOML parse; manifest keys present.` +- `python3.11 scripts/check-links.py` — `Links OK — all relative Markdown links resolve.` +- `python3.11 scripts/check-skill-count.py` — `Skill count OK — 31 skills, docs consistent, table rows 32` +- `python3 -B` AST parse of `scripts/check-lifecycle-hooks.py` — `python syntax ok` +- `git diff --check` +- Direct JSON parsing of all three changed manifests. + +The repository's default macOS `python3` is Python 3.9, so `check-config.py` correctly reported that `tomllib` was unavailable; Python 3.11 was used for the repository static checks. `python3 -m py_compile` was not used for the final syntax check because the system interpreter attempted to write an inaccessible user cache; the no-write AST parse passed. + +## Self-review + +- Confirmed no shipped manifest contains a mode-suffixed `codex-hook` invocation or Gemini shell redirect/fallback wrapper. +- Confirmed Codex has exactly one command each for `SessionStart`, `PostToolUse`, and `Stop`; the Stop command is not duplicated. +- Confirmed Claude's version check, `read-hook.sh`, and `grep-gate.sh` registrations remain present. +- Confirmed validator and CI no longer reference the deleted `check-codex-hooks.py`; the replacement validator is the only caller. +- Confirmed no files under `/private/tmp/onebrain-codex-hook-cli` were edited. +- `git diff --check` is clean. + +## Concerns + +- Runtime behavior depends on the companion CLI's hidden `onebrain hook` command from Task 1 and its `hook_event_name` stdin contract; the plugin declares the existing CLI floor `>=3.4.25`. +- The default local Python 3.9 environment cannot perform TOML validation; CI's Python 3.11+ environment is the supported validation environment. diff --git a/CHANGELOG.md b/CHANGELOG.md index 48111bf4..960bc395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Codex hook manifests call the cache-independent runner in OneBrain CLI v3.4.25+, so an active task no longer retains a dead path when the plugin cache refreshes. - Codex reuses SessionStart metadata, loads project memory only on topic matches, and bounds startup tasks to five while retaining the full count. +- Codex, Claude, and Gemini lifecycle registrations now converge on one bare `onebrain hook` command; it selects the action from the stdin `hook_event_name`, and Codex keeps a single Stop entry so checkpoint work is not duplicated. Start a new agent session after upgrading because the old `codex-hook` alias is intentionally absent. ## v3.4.11 — 2026-07-30 — /digest first run is a complete setup chain diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 120ec4d0..af016bb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -241,7 +241,7 @@ Agents are stateless — they receive all context in the prompt payload and do n Hooks run shell commands automatically when the harness performs certain actions. For Claude Code, hook configuration lives in the vault's `.claude/settings.json`; shell scripts (for PostToolUse hooks) go in `.claude/plugins/onebrain/hooks/`. For Gemini CLI, hooks live declaratively in `.gemini/settings.json` (under the `hooks` key). -OneBrain currently registers `Stop` (checkpoint) + optional `PostToolUse` (search-reindex) + optional `Stop` (embed) on the Claude side, and the parallel `AfterAgent` + optional `AfterTool` (search-reindex) on the Gemini side. Reference tables below list every event each harness supports — useful when adding new hooks or porting between harnesses. +OneBrain registers the same bare `onebrain hook` lifecycle command across Codex (`SessionStart`, `PostToolUse`, `Stop`), Claude (`SessionStart` alongside the version check, plus its independent PreToolUse hooks), and Gemini (`SessionStart`, `AfterTool`, `AfterAgent`). The installed CLI selects session init, search reindex, checkpoint, or pending embedding from stdin `hook_event_name`; keep one Stop/AfterAgent entry so checkpoint work is not duplicated. Reference tables below list every event each harness supports — useful when adding new hooks or porting between harnesses. **Claude Code hook events:** @@ -288,9 +288,11 @@ Most Claude hooks support a `matcher` field to filter by tool name or event subt | `PreCompress` | Before chat history compression | `PreCompact` | | `Notification` | On notification events | `Notification` | -Tool-name matchers in Gemini accept regex (e.g. `write_file|replace`) — they match Gemini's actual tool names (`read_file`, `write_file`, `replace`, `run_shell_command`, ...), NOT Claude's names (`Read`, `Write`, `Edit`, `Bash`, ...). Hook commands must emit `{}` on stdout to satisfy Gemini's JSON protocol; OneBrain wraps them as `{cmd} > /dev/null 2>&1; echo '{}'`. +Tool-name matchers in Gemini accept regex (e.g. `write_file|replace`) — they match Gemini's actual tool names (`read_file`, `write_file`, `replace`, `run_shell_command`, ...), NOT Claude's names (`Read`, `Write`, `Edit`, `Bash`, ...). The shared `onebrain hook` command emits `{}` on stdout for tool and unsupported events, satisfying Gemini's JSON protocol without shell redirects or fallback wrappers. -**Example — checkpoint system:** OneBrain's checkpoint system uses the `Stop` hook to auto-save session snapshots. The hook calls `onebrain checkpoint stop` (the CLI binary). The binary tracks message count + elapsed time against configurable thresholds and emits a `decision:block` JSON payload when a checkpoint is due. State is kept in `$TMPDIR/onebrain-{session_token}.state` (format: `count:last_ts:last_stop_nn`) so counts accumulate across responses, including across compact events. +**Example — checkpoint system:** OneBrain's checkpoint system uses the shared `onebrain hook` command on each harness's Stop-equivalent event to auto-save session snapshots. The CLI bridge calls `onebrain checkpoint stop` internally, tracks message count + elapsed time against configurable thresholds, and emits a `decision:block` JSON payload when a checkpoint is due. State is kept in `$TMPDIR/onebrain-{session_token}.state` (format: `count:last_ts:last_stop_nn`) so counts accumulate across responses, including across compact events. + +After upgrading the plugin, start a new agent session so the lifecycle registrations are loaded. The old `codex-hook` alias is intentionally absent. **To add a hook:** @@ -302,7 +304,7 @@ Tool-name matchers in Gemini accept regex (e.g. `write_file|replace`) — they m 4. **Stop hooks must NOT use `"async": true`** — they inject prompts via `decision:block` written to stdout, which requires synchronous completion before Claude's next response. Async execution fires too late for prompt injection. -5. Use `/update` (or `onebrain plugin update`) to register or repair the `Stop` checkpoint hook (and the optional `PostToolUse` search-reindex + `Stop` embed hooks when a search collection is configured in `onebrain.yml`) automatically. +5. Use `/update` (or `onebrain plugin update`) to register or repair the shared `onebrain hook` lifecycle registrations automatically. The installed CLI selects checkpoint, search-reindex, or embedding behavior from each event payload. ## Memory System @@ -359,7 +361,7 @@ MEMORY-INDEX.md must be kept in sync at all times. Every skill that creates, upd Vault setup is owned by the `onebrain` CLI binary (Rust — lives at [`onebrain-ai/onebrain-cli`](https://github.com/onebrain-ai/onebrain-cli)), **not** by shell scripts in this repo. The user flow is: 1. Install the CLI from any path — `brew install onebrain-ai/onebrain/onebrain` (macOS) or `npm install -g @onebrain-ai/cli` or direct download from [onebrain-ai/onebrain-cli/releases](https://github.com/onebrain-ai/onebrain-cli/releases/latest) -2. `onebrain init` — in a new or existing folder, writes `onebrain.yml`, scaffolds the 8 standard folders, downloads the latest plugin bundle, and registers the `Stop` checkpoint hook (plus the `PostToolUse` search-reindex + `Stop` embed hooks when a search collection is configured). Aborts safely if a `onebrain.yml` already exists +2. `onebrain init` — in a new or existing folder, writes `onebrain.yml`, scaffolds the 8 standard folders, downloads the latest plugin bundle, and registers the shared `onebrain hook` lifecycle registrations. The installed CLI selects checkpoint, search-reindex, or embedding behavior from each event payload. Aborts safely if a `onebrain.yml` already exists 3. `/onboarding` — inside the chosen harness, personalises identity + active projects There are no `install.sh` or `install.ps1` scripts to maintain — the equivalent logic lives in the CLI's `init` and `update` commands. Bug fixes for vault bootstrap belong in the [`onebrain-ai/onebrain-cli`](https://github.com/onebrain-ai/onebrain-cli) repo, not this one. diff --git a/GEMINI.md b/GEMINI.md index 0b76e9d1..3fdacddd 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -13,7 +13,7 @@ shared instructions load. Hooks and slash commands are defined at the project root in `.gemini/`: -- `.gemini/settings.json` — declarative hooks (`AfterAgent` → `onebrain checkpoint stop`, `AfterTool` for `write_file|replace` → `onebrain search reindex`; both wrapped to satisfy Gemini's JSON-on-stdout protocol) and `model.disableLoopDetection: true` so legitimate multi-file skill activations don't trip Gemini's repetitive-tool-call heuristic. Version of this content is tracked in the unified `plugin.json` alongside the Claude plugin. +- `.gemini/settings.json` — declarative lifecycle hooks (`SessionStart`, `AfterTool` for `write_file|replace`, and `AfterAgent`) all call the bare `onebrain hook` bridge, which selects behavior from stdin and emits Gemini-compatible `{}` for no-op/tool events; no shell redirect or fallback wrapper is needed. `model.disableLoopDetection: true` keeps legitimate multi-file skill activations from tripping Gemini's repetitive-tool-call heuristic. Version of this content is tracked in the unified `plugin.json` alongside the Claude plugin. - `.gemini/commands/onebrain/*.toml` — 24 user-facing slash commands under the `onebrain:` namespace (`/onebrain:braindump`, `/onebrain:capture`, `/onebrain:research`, ...) that activate the matching skill. Namespacing avoids collisions with Gemini built-ins (`/help`, `/tasks`) and mirrors the Claude plugin path (`.claude/plugins/onebrain/`) Skills, agents, INSTRUCTIONS, and tool-mapping references all live inside the Claude plugin tree at `.claude/plugins/onebrain/...`. The agent reads them on demand via the paths referenced from each TOML's prompt — no duplication needed. diff --git a/docs/install.md b/docs/install.md index c876f56f..95e7d609 100644 --- a/docs/install.md +++ b/docs/install.md @@ -23,6 +23,11 @@ Each harness reads OneBrain's instruction file automatically. Install it, run it > installation, run `onebrain plugin uninstall --harness codex`; user Codex > settings remain intact. +All three harnesses use the shared `onebrain hook` lifecycle command, which +selects behavior from the event payload. After upgrading the plugin, start a +new agent session so the registrations are loaded; the old `codex-hook` alias +is intentionally absent. + ### 1. Install the OneBrain CLI Pick the install path that fits your environment — all three converge on the same v3.x Rust binary. diff --git a/docs/memory.md b/docs/memory.md index 8db410ce..57e58178 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -88,6 +88,9 @@ Checkpoints are concurrent-session safe: each session writes under its own isola **The practical result:** Just say "bye" and OneBrain remembers what's promoted. If the session ends unexpectedly, you lose at most 15 messages — the last checkpoint recovers the rest. -> Auto Checkpoint runs on Claude Code (`Stop`), Gemini CLI (`AfterAgent`), and -> Codex (`Stop`) using the `onebrain` CLI binary. Codex thread identity keeps -> concurrent checkpoint streams isolated. See [Install](install.md). +> Auto Checkpoint runs through the shared `onebrain hook` command on Claude Code +> (`Stop`), Gemini CLI (`AfterAgent`), and Codex (`Stop`). The CLI selects the +> lifecycle action from each hook's stdin payload, and Codex thread identity +> keeps concurrent checkpoint streams isolated. After upgrading the plugin, +> start a new agent session; the old `codex-hook` alias is intentionally absent. +> See [Install](install.md). diff --git a/scripts/check-codex-hooks.py b/scripts/check-codex-hooks.py deleted file mode 100644 index 88dabcd1..00000000 --- a/scripts/check-codex-hooks.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Codex hook manifests and their cache-independent lifecycle.""" - -from __future__ import annotations - -import json -import os -from pathlib import Path -import shutil -import subprocess -import tempfile - - -ROOT = Path(__file__).resolve().parents[1] -HOOKS_PATH = ROOT / ".claude/plugins/onebrain/hooks/codex-hooks.json" -EXPECTED_MODES = { - "SessionStart": ["session-start"], - "PostToolUse": ["lex"], - "Stop": ["checkpoint", "pending"], -} - - -def commands_for(manifest: dict, event: str) -> list[dict]: - return [ - hook - for matcher in manifest["hooks"][event] - for hook in matcher["hooks"] - ] - - -manifest = json.loads(HOOKS_PATH.read_text()) -for event, modes in EXPECTED_MODES.items(): - commands = commands_for(manifest, event) - assert len(commands) == len(modes), f"unexpected {event} hook count" - for command, mode in zip(commands, modes): - expected = f"onebrain codex-hook {mode}" - assert command["command"] == expected, ( - f"{event} must call the installed CLI, not a versioned plugin-cache file" - ) - assert command["commandWindows"] == expected, ( - f"{event} Windows hook must call the installed CLI" - ) - - -with tempfile.TemporaryDirectory() as temp_dir: - temp = Path(temp_dir) - fake_bin = temp / "bin" - fake_bin.mkdir() - calls_path = temp / "calls.jsonl" - fake_onebrain = fake_bin / "onebrain" - fake_onebrain.write_text( - """#!/usr/bin/env python3 -import json -import os -import sys - -payload = json.load(sys.stdin) -mode = sys.argv[2] -with open(os.environ["FAKE_ONEBRAIN_CALLS"], "a", encoding="utf-8") as handle: - handle.write(json.dumps({"args": sys.argv[1:], "session_id": payload.get("session_id")}) + "\\n") -if mode == "session-start": - print(json.dumps({"hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": f"session={payload.get('session_id')}", - }})) -elif mode == "checkpoint": - print(json.dumps({"continue": True})) -""" - ) - fake_onebrain.chmod(0o755) - - # Reproduce the real failure mode: Codex keeps the command after the plugin - # manager deletes the versioned cache directory it originally came from. - deleted_plugin_root = temp / "plugin-cache" / "onebrain" / "3.4.4" - shutil.copytree(ROOT / ".claude/plugins/onebrain", deleted_plugin_root) - shutil.rmtree(deleted_plugin_root) - - env = os.environ.copy() - env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}" - env["CLAUDE_PLUGIN_ROOT"] = str(deleted_plugin_root) - env["FAKE_ONEBRAIN_CALLS"] = str(calls_path) - - def run(event: str, index: int, session_id: str) -> subprocess.CompletedProcess[str]: - command = commands_for(manifest, event)[index]["command"] - result = subprocess.run( - command, - shell=True, - check=False, - input=json.dumps({"session_id": session_id}), - text=True, - capture_output=True, - env=env, - ) - assert result.returncode == 0, result.stderr - return result - - first = run("SessionStart", 0, "codex-a") - second = run("SessionStart", 0, "codex-b") - assert json.loads(first.stdout)["hookSpecificOutput"]["additionalContext"] == "session=codex-a" - assert json.loads(second.stdout)["hookSpecificOutput"]["additionalContext"] == "session=codex-b" - assert run("PostToolUse", 0, "codex-a").stdout == "" - run("Stop", 0, "codex-a") - assert run("Stop", 1, "codex-a").stdout == "" - - calls = [json.loads(line) for line in calls_path.read_text().splitlines()] - assert [call["args"] for call in calls] == [ - ["codex-hook", "session-start"], - ["codex-hook", "session-start"], - ["codex-hook", "lex"], - ["codex-hook", "checkpoint"], - ["codex-hook", "pending"], - ] - -print("codex hooks ok") diff --git a/scripts/check-lifecycle-hooks.py b/scripts/check-lifecycle-hooks.py new file mode 100644 index 00000000..9125684c --- /dev/null +++ b/scripts/check-lifecycle-hooks.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Validate unified OneBrain lifecycle registrations across all harnesses.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +CODEX_HOOKS_PATH = ROOT / ".claude/plugins/onebrain/hooks/codex-hooks.json" +CLAUDE_HOOKS_PATH = ROOT / ".claude/plugins/onebrain/hooks/hooks.json" +GEMINI_SETTINGS_PATH = ROOT / ".gemini/settings.json" + + +def commands_for(manifest: dict, event: str) -> list[dict]: + return [ + hook + for matcher in manifest["hooks"][event] + for hook in matcher["hooks"] + ] + + +def assert_unified_command(command: dict, event: str) -> None: + assert command["command"] == "onebrain hook", f"{event} must call onebrain hook" + if "commandWindows" in command: + assert command["commandWindows"] == "onebrain hook", ( + f"{event} Windows hook must call onebrain hook" + ) + + +codex_manifest = json.loads(CODEX_HOOKS_PATH.read_text(encoding="utf-8")) +for event in ("SessionStart", "PostToolUse", "Stop"): + commands = commands_for(codex_manifest, event) + assert len(commands) == 1, f"Codex must have one {event} command" + assert_unified_command(commands[0], f"Codex {event}") + +assert codex_manifest["hooks"]["PostToolUse"][0]["matcher"] == "Edit|Write|apply_patch" + +claude_manifest = json.loads(CLAUDE_HOOKS_PATH.read_text(encoding="utf-8")) +claude_session_commands = commands_for(claude_manifest, "SessionStart") +assert len(claude_session_commands) == 2, "Claude SessionStart must retain both lifecycle hooks" +assert any( + command["command"] == "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/check-cli-version.sh\"" + for command in claude_session_commands +), ( + "Claude SessionStart must retain the CLI version check" +) +assert sum(command["command"] == "onebrain hook" for command in claude_session_commands) == 1, ( + "Claude SessionStart must add exactly one generic lifecycle hook" +) +assert [ + command["command"] + for command in commands_for(claude_manifest, "PreToolUse") +] == [ + "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/read-hook.sh\"", + "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grep-gate.sh\"", +], "Claude independent PreToolUse hooks changed unexpectedly" + +gemini_settings = json.loads(GEMINI_SETTINGS_PATH.read_text(encoding="utf-8")) +for event in ("SessionStart", "AfterTool", "AfterAgent"): + groups = gemini_settings["hooks"].get(event, []) + assert len(groups) == 1, f"Gemini must have one {event} group" + commands = commands_for(gemini_settings, event) + assert len(commands) == 1, f"Gemini must have one {event} command" + assert_unified_command(commands[0], f"Gemini {event}") +assert gemini_settings["hooks"]["SessionStart"][0]["matcher"] == "startup" +assert gemini_settings["hooks"]["AfterTool"][0]["matcher"] == "write_file|replace" +assert gemini_settings["hooks"]["AfterAgent"][0]["matcher"] == "*" + + +with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + fake_bin = temp / "bin" + fake_bin.mkdir() + calls_path = temp / "calls.jsonl" + fake_onebrain = fake_bin / "onebrain" + fake_onebrain.write_text( + """#!/usr/bin/env python3 +import json +import os +import sys + +payload = json.load(sys.stdin) +with open(os.environ["FAKE_ONEBRAIN_CALLS"], "a", encoding="utf-8") as handle: + handle.write(json.dumps({"args": sys.argv[1:], "payload": payload}) + "\\n") +event = payload.get("hook_event_name") +if event == "SessionStart": + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": f"session={payload.get('session_id')}", + }})) +elif event == "Stop" or event == "AfterAgent": + print(json.dumps({"decision": "block", "reason": "checkpoint due"})) +else: + print("{}") +""" + ) + fake_onebrain.chmod(0o755) + + deleted_plugin_root = temp / "plugin-cache" / "onebrain" / "3.4.4" + shutil.copytree(ROOT / ".claude/plugins/onebrain", deleted_plugin_root) + shutil.rmtree(deleted_plugin_root) + + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}" + env["CLAUDE_PLUGIN_ROOT"] = str(deleted_plugin_root) + env["FAKE_ONEBRAIN_CALLS"] = str(calls_path) + + full_payload = { + "session_id": "lifecycle-session", + "transcript_path": "/tmp/session.jsonl", + "cwd": "/tmp/vault", + "hook_event_name": "SessionStart", + "timestamp": "2026-08-26T10:00:00Z", + "model": "gpt-5.6-sol", + "permission_mode": "default", + } + + def run(command: str, payload: dict) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + shell=True, + check=False, + input=json.dumps(payload), + text=True, + capture_output=True, + env=env, + ) + assert result.returncode == 0, result.stderr + return result + + session_start = run( + codex_manifest["hooks"]["SessionStart"][0]["hooks"][0]["command"], + full_payload, + ) + assert json.loads(session_start.stdout)["hookSpecificOutput"]["additionalContext"] == "session=lifecycle-session" + + tool_payload = {**full_payload, "hook_event_name": "PostToolUse"} + tool = run(codex_manifest["hooks"]["PostToolUse"][0]["hooks"][0]["command"], tool_payload) + assert json.loads(tool.stdout) == {}, "tool lifecycle output must be an empty JSON object" + + no_op_payload = {**full_payload, "hook_event_name": "BeforeTool"} + no_op = run( + codex_manifest["hooks"]["SessionStart"][0]["hooks"][0]["command"], + no_op_payload, + ) + assert json.loads(no_op.stdout) == {}, "unsupported lifecycle events must be no-ops" + + stop_payload = {**full_payload, "hook_event_name": "Stop"} + stop = run(codex_manifest["hooks"]["Stop"][0]["hooks"][0]["command"], stop_payload) + assert json.loads(stop.stdout) == {"decision": "block", "reason": "checkpoint due"} + + for manifest, events in ( + (claude_manifest, ("SessionStart",)), + (gemini_settings, ("SessionStart", "AfterTool", "AfterAgent")), + ): + for event in events: + command = next( + command + for command in commands_for(manifest, event) + if command["command"] == "onebrain hook" + ) + payload = {**full_payload, "hook_event_name": event} + output = run(command["command"], payload) + if event == "SessionStart": + assert "hookSpecificOutput" in json.loads(output.stdout) + elif event == "AfterAgent": + assert json.loads(output.stdout)["decision"] == "block" + else: + assert json.loads(output.stdout) == {} + + calls = [json.loads(line) for line in calls_path.read_text(encoding="utf-8").splitlines()] + assert all(call["args"] == ["hook"] for call in calls) + assert all( + set(call["payload"]) >= set(full_payload) + for call in calls + ), "lifecycle commands did not receive the full event payload" + assert [call["payload"]["hook_event_name"] for call in calls] == [ + "SessionStart", + "PostToolUse", + "BeforeTool", + "Stop", + "SessionStart", + "SessionStart", + "AfterTool", + "AfterAgent", + ] + +print("lifecycle hooks ok") From 8504fbf6534241000e62dae0d530130053767f2e Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Wed, 26 Aug 2026 22:13:03 +0700 Subject: [PATCH 04/14] docs(hooks): list Gemini session start registration --- .../task-2-report.md | 22 +++++++++++++++++++ CONTRIBUTING.md | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md b/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md index d5feffcb..65c9b4f1 100644 --- a/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md +++ b/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md @@ -61,3 +61,25 @@ The repository's default macOS `python3` is Python 3.9, so `check-config.py` cor - Runtime behavior depends on the companion CLI's hidden `onebrain hook` command from Task 1 and its `hook_event_name` stdin contract; the plugin declares the existing CLI floor `>=3.4.25`. - The default local Python 3.9 environment cannot perform TOML validation; CI's Python 3.11+ environment is the supported validation environment. + +## Fix Round 1 + +Review finding: the repository tree documentation described `.gemini/settings.json` as containing only `AfterAgent` and `AfterTool`, omitting `SessionStart`. + +Implementation: updated the `CONTRIBUTING.md` project-tree annotation to list `SessionStart`, `AfterAgent`, and `AfterTool`. No runtime files changed. + +Covering checks: + +```text +$ python3.11 scripts/check-links.py +Links OK — all relative Markdown links resolve. + +$ git diff --check + +$ python3.11 scripts/check-config.py +Config OK — all tracked JSON/TOML parse; manifest keys present. +``` + +All three commands exited `0`. + +Self-review: confirmed the diff contains only the one documentation line and this report section; lifecycle manifests and the CLI repository remain untouched. The corrected tree annotation now names all three Gemini lifecycle registrations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af016bb1..168575b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ The plugin track ships TWO sibling trees — one per harness — both versioned └── task-extractor.md Extract action items from braindumps (used by /braindump) .gemini/ Gemini CLI project config (read by Gemini CLI) -├── settings.json Declarative hooks (AfterAgent, AfterTool) + model.disableLoopDetection +├── settings.json Declarative hooks (SessionStart, AfterAgent, AfterTool) + model.disableLoopDetection └── commands/ └── onebrain/ Slash commands namespaced as /onebrain: └── *.toml One TOML per user-facing skill (29 commands; description + prompt) From 7e2dc61baeb0bd110a91dbdba71051afca1bfbf3 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 27 Aug 2026 20:16:06 +0700 Subject: [PATCH 05/14] fix unified lifecycle hook guidance --- .claude/plugins/onebrain/INSTRUCTIONS.md | 26 +++++------ .../onebrain/hooks/check-cli-version.sh | 22 +++++----- .../plugins/onebrain/skills/update/SKILL.md | 2 +- .../update/references/migration-steps.md | 5 ++- .gemini/settings.json | 1 - scripts/check-lifecycle-hooks.py | 24 ++++++++-- scripts/test-check-cli-version.py | 44 +++++++++++++++++++ 7 files changed, 93 insertions(+), 31 deletions(-) create mode 100644 scripts/test-check-cli-version.py diff --git a/.claude/plugins/onebrain/INSTRUCTIONS.md b/.claude/plugins/onebrain/INSTRUCTIONS.md index 87f90e0b..94ce3a80 100644 --- a/.claude/plugins/onebrain/INSTRUCTIONS.md +++ b/.claude/plugins/onebrain/INSTRUCTIONS.md @@ -206,7 +206,7 @@ If the search tools are NOT available: use Glob/Grep/Read for all vault searches Session startup greets the user immediately, then runs a quick inline status check. -> **Headless exception:** when `onebrain session init` reports `headless: true` (set by `onebrain skill run` for an unattended one-shot run), skip the entire startup ceremony — Steps 2–4 below — and go straight to the requested skill. No greeting, no startup status, no memory/inbox/task/orphan scans. See Step 1. +> **Headless exception:** when SessionStart metadata reports `headless: true` (set by `onebrain skill run` for an unattended one-shot run), skip the entire startup ceremony — Steps 2–4 below — and go straight to the requested skill. No greeting, no startup status, no memory/inbox/task/orphan scans. See Step 1. ### Startup : Immediate @@ -215,10 +215,10 @@ Run before responding to any user message. **Step 1 — Critical path (greeting blocks on these):** Run in parallel: - Read `onebrain.yml` → load Configuration variables; override defaults once resolved. If `onebrain.yml` is missing, fall back to legacy `vault.yml` (v3.0 name) — same schema; surface a one-line deprecation note in the startup status so the user knows to run `onebrain doctor --fix` to migrate. - Read `[agent_folder]/MEMORY.md` → load identity, personality, active projects -- Run session initialization from the vault root: - - **Codex with SessionStart-injected startup metadata (plugin v3.4.12+):** trust and parse the metadata already collected by the installed CLI hook runner; do **not** run `session init` again. Preserve the injected `session_token`, and use the injected exact executable path for every later CLI call in this chat (never bare `onebrain`). **Legacy fallback (token present but startup metadata absent):** run that exact executable path with `session init --json --session-token {injected_session_token}`, store the returned metadata, and verify the returned token exactly equals the injected token. If it differs or the flag is unavailable, preserve the injected token and disable checkpoint/wrapup writes for this chat rather than falling back to terminal/process identity. - - **Claude/Gemini:** run `onebrain session init --json`. - Parse JSON output; store `DATETIME` (for greeting), `session_token` (for checkpoints), `search_unembedded`, and `headless` in context. **Read `search_unembedded` (canonical, CLI v3.4.5+); if that key is absent, fall back to the legacy `qmd_unembedded` key** — older CLIs emit only `qmd_unembedded`, while v3.4.5 emits both keys with the same value during the transition. JSON shape: `{"datetime":"Ddd · DD Mon YYYY · HH:MM","session_token":"XXXXX","search_unembedded":N|null,"qmd_unembedded":N|null,"headless":true|false}`. The value is a count `N`, or **`null`** when the search probe couldn't determine it (index missing / timed out / unparseable) — treat `null` as **unknown**, distinct from a genuine `0` (a real index with nothing pending). **CLI v3.1+ requires `--json` because the default output flipped to text.** The v3.0 alias `onebrain session-init` still works and now auto-rewrites to include `--json` when `onebrain plugin update` runs. If the command fails or is unavailable, fall back to running `date '+%a · %d %b %Y · %H:%M'` for `DATETIME`; on Claude/Gemini only, treat `session_token` as `99999`, `search_unembedded` as `0`, and `headless` as `false`. Codex must never invent or replace its injected chat token. If JSON output contains `{"decision":"block","reason":"onebrain-vault-not-found"}` (CLI v3.1+) or `"reason":"onebrain-init-required"` (CLI v3.0 back-compat), skip Steps 2–4; instead output a single message: "OneBrain vault not initialized. Run `/onboarding` to set up your vault." +- Obtain session startup metadata from the vault root: + - **All harnesses with SessionStart-injected metadata:** trust and parse the metadata already collected by the shared `onebrain hook` runner; do **not** run `session init` again. Preserve the injected `session_token` and exact executable path, and use that exact executable for every later CLI call in this chat — never bare `onebrain`. This is the only authoritative chat identity; startup must not invoke the CLI a second time. + - **Fallback only when no injected context exists:** resolve the executable once and run `{resolved executable} session init --json`; preserve that exact fallback executable for later non-identity CLI calls. Treat its result as startup display metadata only, not proof that its `session_token` identifies this chat. Keep checkpoint and wrapup writes disabled for that fallback session; never substitute a terminal, process, or manually generated token for an injected token. + Parse injected metadata or the permitted fallback JSON; store `DATETIME` (for greeting), `session_token` (for checkpoints only when injected), `search_unembedded`, and `headless` in context. **Read `search_unembedded` (canonical, CLI v3.4.5+); if that key is absent, fall back to the legacy `qmd_unembedded` key** — older CLIs emit only `qmd_unembedded`, while v3.4.5 emits both keys with the same value during the transition. JSON shape: `{"datetime":"Ddd · DD Mon YYYY · HH:MM","session_token":"XXXXX","search_unembedded":N|null,"qmd_unembedded":N|null,"headless":true|false}`. The value is a count `N`, or **`null`** when the search probe couldn't determine it (index missing / timed out / unparseable) — treat `null` as **unknown**, distinct from a genuine `0` (a real index with nothing pending). **CLI v3.1+ requires `--json` because the default output flipped to text.** The v3.0 alias `onebrain session-init` still works and now auto-rewrites to include `--json` when `onebrain plugin update` runs. If injected metadata is missing and the fallback command fails or is unavailable, use `date '+%a · %d %b %Y · %H:%M'` for `DATETIME`, treat `search_unembedded` as `null` and `headless` as `false`, leave `session_token` unset, and keep checkpoint/wrapup writes disabled. If metadata reports `{"decision":"block","reason":"onebrain-vault-not-found"}` (CLI v3.1+) or `"reason":"onebrain-init-required"` (CLI v3.0 back-compat), skip Steps 2–4; instead output a single message: "OneBrain vault not initialized. Run `/onboarding` to set up your vault." - **If `headless` is `true`** (CLI v3.2.6+, set by `onebrain skill run`): this is an unattended one-shot run, so **skip Steps 2–4 entirely** — no greeting, no startup status, and none of Step 3's memory/inbox/task/orphan/pause scans — and proceed directly to the invoked skill. The `headless` field is absent on older CLIs; treat absent as `false` (normal interactive startup). **Step 2 — Send greeting immediately:** @@ -243,24 +243,24 @@ Ddd · DD Mon YYYY · HH:MM | 17:00–21:00 | good evening + ready | 🌆 | | after 21:00 | late night acknowledgement | 🌙 | -- `Ddd · DD Mon YYYY · HH:MM` comes from the `DATETIME` variable set in Step 1 (SessionStart metadata on Codex; `onebrain session init --json` on Claude/Gemini) +- `Ddd · DD Mon YYYY · HH:MM` comes from the `DATETIME` variable set in Step 1 (SessionStart-injected metadata on every harness; the lower-confidence manual fallback only when that context is absent) - Always include a greeting phrase — never omit it. Example for daytime: "Hey [user], ready to go!" On weekends: lighter, less task-focused tone. **No-repeat rule:** don't ask about facts already in context. **Step 3 — After greeting (run all in parallel):** -- `session_token` is already in context from Step 1 (SessionStart metadata on Codex; `onebrain session init --json` on Claude/Gemini) — do not re-run detection +- `session_token` is already in context only when Step 1 received SessionStart-injected metadata — do not re-run detection or initialize a second time. When it is absent, checkpoint/wrapup writes remain disabled. - Read `[agent_folder]/MEMORY-INDEX.md` → load memory file index for lazy-loading - Load `memory/` files only when the user's first message matches their MEMORY-INDEX.md Topics keywords (`status: active` or `needs-review` only). Do not eagerly load every active project; a generic greeting must not pull project memory into context. - Glob `[inbox_folder]/*.md` → count files as `inbox_count` -- Run `{injected exact executable} task list --due-by today --limit 5 --json` on Codex, or `onebrain task list --due-by today --limit 5 --json` on Claude/Gemini (from vault root) → parse `data.tasks` +- Run `{injected or fallback-resolved exact executable} task list --due-by today --limit 5 --json` on every harness (from vault root) → parse `data.tasks` (`{file, line, text, due, done}`, fence-aware + open-only). Group overdue first, then due today. Read `data.total` for the full filtered count and use it for the `+N more` line. **Fallback (older CLI / non-zero exit):** retry once without `--limit`; then run the Grep tool twice — `path: "[projects_folder]"` and `path: "[inbox_folder]"`, pattern `- \[ \] .*📅 [0-9]{4}-[0-9]{2}-[0-9]{2}`, `output_mode: "content"` — combine, keep dates ≤ today. (Fallback still shows fenced demo lines; upgrade the CLI to remove them.) -- Run `{injected exact executable} checkpoint orphans "[logs_folder]" "[session_token]" --json` on Codex, or `onebrain checkpoint orphans "[logs_folder]" "[session_token]" --json` on Claude/Gemini (from vault root) → parse JSON output; read `orphan_count` field. JSON shape: `{"orphan_count":N}`. **CLI v3.1+ requires `--json`** (default is now text). The v3.0 alias `onebrain orphan-scan` still works and is auto-rewritten by `onebrain plugin update`. If the command fails or is unavailable, fall back to a structure-aware glob: if `[logs_folder]/checkpoint/` exists, glob `[logs_folder]/checkpoint/*-checkpoint-*.md` (post-v2.4.0 flat layout); else glob `[logs_folder]/**/*-checkpoint-*.md` (pre-v2.4.0 nested layout — multi-vault user on an unmigrated vault). Then discard files whose date has a non-auto-saved session log (look in `[logs_folder]/session/YYYY/MM/` for post-v2.4.0, or `[logs_folder]/YYYY/MM/` for pre-v2.4.0), and count distinct session tokens among remaining files. +- When injected metadata supplied both the exact executable and `session_token`, run `{injected exact executable} checkpoint orphans "[logs_folder]" "[session_token]" --json` (from vault root) → parse JSON output; read `orphan_count` field. JSON shape: `{"orphan_count":N}`. **CLI v3.1+ requires `--json`** (default is now text). The v3.0 alias `onebrain orphan-scan` still works and is auto-rewritten by `onebrain plugin update`. Without injected identity, skip this CLI call and use the structure-aware glob fallback: if `[logs_folder]/checkpoint/` exists, glob `[logs_folder]/checkpoint/*-checkpoint-*.md` (post-v2.4.0 flat layout); else glob `[logs_folder]/**/*-checkpoint-*.md` (pre-v2.4.0 nested layout — multi-vault user on an unmigrated vault). Then discard files whose date has a non-auto-saved session log (look in `[logs_folder]/session/YYYY/MM/` for post-v2.4.0, or `[logs_folder]/YYYY/MM/` for pre-v2.4.0), and count distinct session tokens among remaining files. - Glob `[logs_folder]/scheduler/**/*.err.md` and keep files modified within the last 24 h → count as `sched_err_count`; note the most recent file's skill name as `sched_err_skill`. (Filesystem only — never probe the OS scheduler at startup; /doctor owns the deep check.) - Read `[logs_folder]/pause/_active.md` if present → parse single-line content as `active_pause_slug`. If absent, set `active_pause_slug = null`. Then if non-null: glob `[logs_folder]/pause/*-{active_pause_slug}-pause-*.md` and count them as `active_pause_count`; read the latest file's `date` frontmatter as `active_pause_last_date`. - **Legacy structure detection (post-v2.4.0):** Check whether `[logs_folder]/session/` exists (any of the new top-level subfolders works as a sentinel; `session/` is the most representative). If it does NOT exist AND `[logs_folder]/YYYY/` does exist (legacy structure pre-v2.4.0), set `vault_structure_legacy = true`. If both `session/` and a legacy `YYYY/` exist (partial migration), still treat `vault_structure_legacy = false` — `/update` will resume cleanup on next run. If neither exists (fresh vault), `vault_structure_legacy = false`. @@ -330,16 +330,16 @@ If the user closes the session without any end-of-session signal, AUTO-SUMMARY d ### Auto Checkpoint (Hook-Triggered) -> **What is session_token?** A chat-unique identifier resolved by `onebrain session init` at startup and kept in context. The shared `onebrain hook` bridge receives each harness's complete lifecycle payload; on Codex it passes the payload's full `session_id` to the CLI, which hashes it into a stable 16-character token, so each Codex chat owns its checkpoints and wrapup even when several chats use one vault. Claude uses `$CLAUDE_CODE_SESSION_ID`; Gemini and older hosts fall through to `$WT_SESSION` → `$TMUX_PANE` → `$TERM_SESSION_ID` → process/day fallbacks. On Codex, if the injected token is missing after compact, do not use a terminal fallback because that could mix chats — stop checkpoint/wrapup and surface the missing hook context. If token resolution fails on any harness, abort checkpoint silently; never guess. +> **What is session_token?** A chat-unique identifier supplied in the SessionStart metadata from the shared `onebrain hook` bridge and kept in context. The hook runner resolves the harness lifecycle payload once, including the exact CLI executable; every harness must preserve both values. If the injected token or executable is missing (including after compact), do not resolve a replacement from terminal, process, or day state — checkpoint and wrapup writes are disabled because that would not prove chat identity. If token resolution fails, abort checkpoint silently; never guess. -When a hook sends a message whose reason matches `NN since ` (a zero-padded two-digit number followed by ` since start` or ` since checkpoint-NN`), silently spawn a background agent (mode: bypassPermissions) to write the checkpoint. The background agent receives: `session_token` from context (re-run `onebrain session init` first if not in context — if that fails, abort silently), today's date (YYYY-MM-DD), NN from the reason, `[logs_folder]`, and enough conversation context to write a meaningful checkpoint. +When a hook sends a message whose reason matches `NN since ` (a zero-padded two-digit number followed by ` since start` or ` since checkpoint-NN`), silently spawn a background agent (mode: bypassPermissions) to write the checkpoint. The background agent receives the injected `session_token` from context, today's date (YYYY-MM-DD), NN from the reason, `[logs_folder]`, and enough conversation context to write a meaningful checkpoint. If injected identity is unavailable, abort silently rather than running `session init` again. The agent writes to `[logs_folder]/checkpoint/YYYY-MM-DD-{session_token}-checkpoint-NN.md` (post-v2.4.0: flat directory). **Always run `mkdir -p [logs_folder]/checkpoint/` before writing** — fresh post-onboarding vaults that haven't run `/update` yet won't have the directory; without the mkdir the Write tool fails silently and the checkpoint is lost. No output to user. The Stop hook is the only checkpoint signal source. It emits `decision:"block",reason:"NN since "` whenever the message-count threshold (default 15) or time threshold (default 30 min) is met. The agent dispatches a background sub-agent to write a checkpoint file, then continues. Determine action from the reason: -- `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → spawn background agent (mode: bypassPermissions) to write the checkpoint; if session_token is not in context, re-run `onebrain session init` first — if that fails, abort silently; agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching +- `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → if the injected `session_token` is in context, spawn a background agent (mode: bypassPermissions) to write the checkpoint; otherwise abort silently. The agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching - Ambiguous or unknown → default to stop checkpoint All three supported harnesses register the same bare `onebrain hook` lifecycle command. The installed CLI selects session initialization, incremental search reindexing, checkpointing, and pending embedding from the stdin `hook_event_name`; Codex has exactly one Stop registration, so checkpoint and embedding work are not duplicated. PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly through the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. @@ -354,7 +354,7 @@ After upgrading this plugin, start a new agent session so the new registrations The plugin statically registers a PreToolUse hook (`hooks/hooks.json`, matcher `Read`, script `hooks/read-hook.sh`, 5s timeout) that gates repeat `Read` calls on vault `.md` files already delivered unchanged this session — the token-optimization "already-sent ledger" (CLI v3.4.10+, design §5b). It calls `onebrain token check ` and translates the CLI's exit-code protocol straight through: exit 0 = allow (first-time read, or the doc changed since it was last sent) — the Read proceeds untouched; exit 2 = deny — stdout carries a reference envelope (`{doc_path, hash, sent_earlier: true, bytes_saved, rematerialize: "onebrain search get --force"}`) that the hook surfaces as the block reason. When a Read is denied this way, use the `--force` re-materialize command from the envelope instead of retrying the same Read — the content was already delivered this session and is unchanged. -This hook is separate from the CLI-registered PostToolUse **search-reindex** / Stop **embed** hooks mentioned above — those are dynamically written into the vault's own `.claude/settings.json` by the CLI. The Ledger Gate hook ships statically in this plugin's `hooks/hooks.json` and is always *present* once the plugin loads; what's conditional is its *behavior*, not its registration. +This hook is separate from the shared lifecycle registrations mentioned above: each lifecycle event runs one `onebrain hook` command, and the installed CLI selects any session, reindex, checkpoint, or embedding work from `hook_event_name`. The Ledger Gate ships statically in this plugin's `hooks/hooks.json` and is always *present* once the plugin loads; what's conditional is its *behavior*, not its registration. **Default: OFF.** The gate is active only when the vault's `onebrain.yml` sets `token_optimization.read_hook: ledger` (default `off`). Since v3.3.1 the hook script itself resolves the config with a cheap grep (walk up to the vault root) and, when `read_hook` is not `ledger`, allows the Read **without spawning `onebrain` at all** — so an off vault pays a grep, not a subprocess, per read. When `ledger` is set (or the config can't be resolved) it falls through to `onebrain token check`, which stays the source of truth for the verdict. (True dynamic registration — not registering the hook when off — is a planned v3.4.11 CLI+plugin follow-up.) diff --git a/.claude/plugins/onebrain/hooks/check-cli-version.sh b/.claude/plugins/onebrain/hooks/check-cli-version.sh index f1b1cc7c..a0ed11cf 100755 --- a/.claude/plugins/onebrain/hooks/check-cli-version.sh +++ b/.claude/plugins/onebrain/hooks/check-cli-version.sh @@ -7,10 +7,11 @@ # includes the cache-independent Codex hook runner and bounded startup task # listing introduced in CLI v3.4.25. # -# Comparison strategy: extract the bare MAJOR.MINOR.PATCH from -# `onebrain --version` (drops any prerelease suffix like -alpha.9) and -# compare component-wise against 3.4.25. Older CLI users are blocked with a -# clear update path. +# Comparison strategy: parse a SemVer MAJOR.MINOR.PATCH with an optional +# prerelease suffix. Compare the numeric core component-wise; a prerelease +# at the exact floor (for example 3.4.25-alpha.1) is below the required +# 3.4.25 release and is blocked. Older CLI users are blocked with a clear +# update path. # # Output contract: emit a JSON SessionStart payload with `decision: block` # and a `reason` that lists the install / update paths for each platform. @@ -37,11 +38,12 @@ if ! command -v onebrain >/dev/null 2>&1; then exit 0 fi -# Extract the bare semver triple from `onebrain --version` output -# (handles `onebrain 3.0.0`, `onebrain 3.0.0-alpha.9`, or any leading text). -CURRENT_VERSION=$(onebrain --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +# Extract SemVer from `onebrain --version` output (handles `onebrain 3.0.0`, +# `onebrain 3.0.0-alpha.9`, build metadata, or leading text). +CURRENT_VERSION_RAW=$(onebrain --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?' | head -1) +CURRENT_VERSION=${CURRENT_VERSION_RAW%%[-+]*} -if [ -z "${CURRENT_VERSION}" ]; then +if [ -z "${CURRENT_VERSION_RAW}" ]; then block_message "OneBrain plugin v3.x could not parse the \`onebrain --version\` output. Verify the CLI is on PATH and at least v${MIN_VERSION} — run \`onebrain --version\` manually to debug." exit 0 fi @@ -63,8 +65,8 @@ version_gte() { return 0 } -if ! version_gte "${CURRENT_VERSION}" "${MIN_VERSION}"; then - block_message "OneBrain plugin v3.x requires CLI >= ${MIN_VERSION}, but found v${CURRENT_VERSION}.\n\nUpdate in place:\n onebrain update\n\nOr reinstall:\n • macOS: brew tap onebrain-ai/onebrain && brew upgrade onebrain (or brew install onebrain-ai/onebrain/onebrain)\n • Linux/Windows: download from https://github.com/onebrain-ai/onebrain-cli/releases/latest\n\nThen restart this session." +if ! version_gte "${CURRENT_VERSION}" "${MIN_VERSION}" || { [ "${CURRENT_VERSION}" = "${MIN_VERSION}" ] && [[ "${CURRENT_VERSION_RAW}" == "${MIN_VERSION}-"* ]]; }; then + block_message "OneBrain plugin v3.x requires CLI >= ${MIN_VERSION}, but found v${CURRENT_VERSION_RAW}.\n\nUpdate in place:\n onebrain update\n\nOr reinstall:\n • macOS: brew tap onebrain-ai/onebrain && brew upgrade onebrain (or brew install onebrain-ai/onebrain/onebrain)\n • Linux/Windows: download from https://github.com/onebrain-ai/onebrain-cli/releases/latest\n\nThen restart this session." exit 0 fi diff --git a/.claude/plugins/onebrain/skills/update/SKILL.md b/.claude/plugins/onebrain/skills/update/SKILL.md index bdb342e4..3cc76712 100644 --- a/.claude/plugins/onebrain/skills/update/SKILL.md +++ b/.claude/plugins/onebrain/skills/update/SKILL.md @@ -142,7 +142,7 @@ Steps: - [x] Step 3: Updated frontmatter on N memory/ files - [x] Step 4: Restructured MEMORY.md → 3 sections - [x] Step 5: Created MEMORY-INDEX.md (N active entries) - - [x] Step 6: Registered Stop hook; removed stale onebrain entries from any other event (PreCompact, PostCompact, etc.) (+ PostToolUse search-reindex & Stop embed hooks if a search collection is configured) + - [x] Step 6: Registered one shared `onebrain hook` runner per lifecycle event; removed stale OneBrain entries from unsupported events (no direct checkpoint/reindex/embed hooks and no `codex-hook` alias). Restart active sessions to load the new registrations. - [x] Step 7: /doctor — N issues - [x] Step 8: Initialized onebrain.yml stats + recap block diff --git a/.claude/plugins/onebrain/skills/update/references/migration-steps.md b/.claude/plugins/onebrain/skills/update/references/migration-steps.md index 9fca88f8..7cdf6bf5 100644 --- a/.claude/plugins/onebrain/skills/update/references/migration-steps.md +++ b/.claude/plugins/onebrain/skills/update/references/migration-steps.md @@ -93,10 +93,11 @@ Always: update `updated:` frontmatter to today. **Step 6: Register OneBrain hooks in `[vault]/.claude/settings.json`** -Runs every /update — idempotent. Ensures all hooks point to the correct script. +Runs every /update — idempotent. Ensures every lifecycle event uses one shared runner. -- Run `onebrain plugin update` — registers the Stop checkpoint hook; auto-registers the PostToolUse search-reindex hook (`search reindex --lex-only`) and the Stop embed hook (`search reindex --pending-only`) when a search collection is configured; removes stale onebrain entries from any other hook event (PreCompact, PostCompact, UserPromptSubmit, etc.); preserves user-added non-onebrain hooks under the same events +- Run `onebrain plugin update` — registers exactly one bare `onebrain hook` runner for each supported lifecycle event. The CLI selects startup metadata, incremental reindexing, checkpointing, and pending embedding from `hook_event_name`; do not register separate direct checkpoint, reindex, or embed commands, and never use a `codex-hook` alias. It removes stale OneBrain entries from unsupported events (PreCompact, PostCompact, UserPromptSubmit, etc.) while preserving user-added non-OneBrain hooks under the same events. - Check output: "all hooks already registered" → ✅ done; "added X" → ✅ registered +- **Restart caveat:** active agent sessions retain the hook registrations they started with. Start a new session after updating before relying on the new lifecycle configuration. **Bash permission for onebrain CLI:** - Read `[vault]/.claude/settings.json` fresh (after `onebrain plugin update` has written to it); check `permissions.allow` contains `"Bash(onebrain *)"` — if missing, add it using an inline Python snippet or targeted JSON edit. Never rewrite the entire file. Example: diff --git a/.gemini/settings.json b/.gemini/settings.json index a3c8a21f..0b6bbc88 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -5,7 +5,6 @@ "hooks": { "SessionStart": [ { - "matcher": "startup", "hooks": [ { "type": "command", diff --git a/scripts/check-lifecycle-hooks.py b/scripts/check-lifecycle-hooks.py index 9125684c..e6e076e7 100644 --- a/scripts/check-lifecycle-hooks.py +++ b/scripts/check-lifecycle-hooks.py @@ -68,7 +68,10 @@ def assert_unified_command(command: dict, event: str) -> None: commands = commands_for(gemini_settings, event) assert len(commands) == 1, f"Gemini must have one {event} command" assert_unified_command(commands[0], f"Gemini {event}") -assert gemini_settings["hooks"]["SessionStart"][0]["matcher"] == "startup" +assert "matcher" not in gemini_settings["hooks"]["SessionStart"][0], ( + "Gemini SessionStart must be unfiltered so startup, resume, and clear all run " + "the shared lifecycle hook" +) assert gemini_settings["hooks"]["AfterTool"][0]["matcher"] == "write_file|replace" assert gemini_settings["hooks"]["AfterAgent"][0]["matcher"] == "*" @@ -156,16 +159,27 @@ def run(command: str, payload: dict) -> subprocess.CompletedProcess[str]: assert json.loads(stop.stdout) == {"decision": "block", "reason": "checkpoint due"} for manifest, events in ( - (claude_manifest, ("SessionStart",)), - (gemini_settings, ("SessionStart", "AfterTool", "AfterAgent")), + (claude_manifest, (("SessionStart", None),)), + ( + gemini_settings, + ( + ("SessionStart", "startup"), + ("SessionStart", "resume"), + ("SessionStart", "clear"), + ("AfterTool", None), + ("AfterAgent", None), + ), + ), ): - for event in events: + for event, source in events: command = next( command for command in commands_for(manifest, event) if command["command"] == "onebrain hook" ) payload = {**full_payload, "hook_event_name": event} + if source is not None: + payload["source"] = source output = run(command["command"], payload) if event == "SessionStart": assert "hookSpecificOutput" in json.loads(output.stdout) @@ -187,6 +201,8 @@ def run(command: str, payload: dict) -> subprocess.CompletedProcess[str]: "Stop", "SessionStart", "SessionStart", + "SessionStart", + "SessionStart", "AfterTool", "AfterAgent", ] diff --git a/scripts/test-check-cli-version.py b/scripts/test-check-cli-version.py new file mode 100644 index 00000000..fbec52de --- /dev/null +++ b/scripts/test-check-cli-version.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Regression checks for the SessionStart CLI version gate.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +CHECKER = ROOT / ".claude/plugins/onebrain/hooks/check-cli-version.sh" + + +def run_check(version: str) -> dict[str, str]: + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + onebrain = temp / "onebrain" + onebrain.write_text( + f"#!/usr/bin/env bash\nprintf '%s\\n' 'onebrain {version}'\n", + encoding="utf-8", + ) + onebrain.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{temp}{os.pathsep}{env['PATH']}" + result = subprocess.run( + ["bash", str(CHECKER)], + check=False, + capture_output=True, + text=True, + env=env, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) if result.stdout.strip() else {} + + +assert run_check("3.4.24")["decision"] == "block" +assert run_check("3.4.25-alpha.1").get("decision") == "block" +assert run_check("3.4.25") == {} +assert run_check("3.4.26-alpha.1") == {} + +print("CLI version gate ok") From 8c86f7d053f7fdc7ecc1b6de1ee03813bb9f314a Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 27 Aug 2026 20:29:53 +0700 Subject: [PATCH 06/14] test CLI version gate in CI --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa5ce8d2..b50163bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,4 +39,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: python3 scripts/check-lifecycle-hooks.py + - run: | + python3 scripts/check-lifecycle-hooks.py + python3 scripts/test-check-cli-version.py From 69eabc181b2253262a92bc075dd89f33692f9124 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 27 Aug 2026 21:15:03 +0700 Subject: [PATCH 07/14] docs: update changelog release date --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 960bc395..8edb408f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ --- latest_version: 3.4.12 -released: 2026-08-26 +released: 2026-08-27 --- # Changelog @@ -11,7 +11,7 @@ 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 changes, see the [`onebrain-ai/onebrain-cli`](https://github.com/onebrain-ai/onebrain-cli/blob/main/CHANGELOG.md) repository. -## v3.4.12 — 2026-08-26 — Codex hooks survive cache refreshes +## v3.4.12 — 2026-08-27 — Codex hooks survive cache refreshes - Codex hook manifests call the cache-independent runner in OneBrain CLI v3.4.25+, so an active task no longer retains a dead path when the plugin cache refreshes. - Codex reuses SessionStart metadata, loads project memory only on topic matches, and bounds startup tasks to five while retaining the full count. From 27a956d91c775d119e49fceae23a7a3201067c6a Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 27 Aug 2026 22:06:16 +0700 Subject: [PATCH 08/14] chore: drop internal workflow scratch report from the repo --- .../task-2-report.md | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 .superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md diff --git a/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md b/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md deleted file mode 100644 index 65c9b4f1..00000000 --- a/.superpowers/sdd/onebrain-unified-hook-plan/task-2-report.md +++ /dev/null @@ -1,85 +0,0 @@ -# Task 2 report — migrate plugin lifecycle registrations - -## Implementation - -- Updated Codex `SessionStart`, `PostToolUse`, and single `Stop` registration to use exactly `onebrain hook` on POSIX and Windows. -- Added `onebrain hook` to Claude `SessionStart` while retaining the CLI-version check and both independent PreToolUse hooks. -- Added Gemini `SessionStart` (`startup`), `AfterTool` (`write_file|replace`), and `AfterAgent` (`*`) registrations, all using bare `onebrain hook`; removed redirects and `echo '{}'` wrappers. -- Renamed the validator to `scripts/check-lifecycle-hooks.py`, expanded it to all three harnesses, and updated `.github/workflows/ci.yml`. -- Validator now checks exact registration counts/commands, preservation of Claude independent hooks, full hook payload forwarding, SessionStart context, `{}` tool/unsupported-event output, Stop protocol output, and operation after deleting the plugin-cache directory. -- Updated `INSTRUCTIONS.md`, `CONTRIBUTING.md`, `GEMINI.md`, `docs/install.md`, `docs/memory.md`, and the current `CHANGELOG.md` entry to document the shared lifecycle command and the required new agent session after upgrading. The old `codex-hook` alias is explicitly documented as absent. - -## TDD evidence - -RED test/contract command: - -```text -$ python3 scripts/check-lifecycle-hooks.py -Traceback (most recent call last): - File "/private/tmp/onebrain-codex-hook-plugin/scripts/check-lifecycle-hooks.py", line 40, in - assert_unified_command(commands[0], f"Codex {event}") - File "/private/tmp/onebrain-codex-hook-plugin/scripts/check-lifecycle-hooks.py", line 29, in assert_unified_command - assert command["command"] == "onebrain hook", f"{event} must call onebrain hook" -AssertionError: Codex SessionStart must call onebrain hook -``` - -RED exit status: `1`. - -After implementing the manifests and validator behavior, the same command produced: - -```text -$ python3 scripts/check-lifecycle-hooks.py -lifecycle hooks ok -``` - -GREEN exit status: `0`. - -## Validation - -All commands below completed with exit status `0`: - -- `python3 scripts/check-lifecycle-hooks.py` — `lifecycle hooks ok` -- `python3.11 scripts/check-config.py` — `Config OK — all tracked JSON/TOML parse; manifest keys present.` -- `python3.11 scripts/check-links.py` — `Links OK — all relative Markdown links resolve.` -- `python3.11 scripts/check-skill-count.py` — `Skill count OK — 31 skills, docs consistent, table rows 32` -- `python3 -B` AST parse of `scripts/check-lifecycle-hooks.py` — `python syntax ok` -- `git diff --check` -- Direct JSON parsing of all three changed manifests. - -The repository's default macOS `python3` is Python 3.9, so `check-config.py` correctly reported that `tomllib` was unavailable; Python 3.11 was used for the repository static checks. `python3 -m py_compile` was not used for the final syntax check because the system interpreter attempted to write an inaccessible user cache; the no-write AST parse passed. - -## Self-review - -- Confirmed no shipped manifest contains a mode-suffixed `codex-hook` invocation or Gemini shell redirect/fallback wrapper. -- Confirmed Codex has exactly one command each for `SessionStart`, `PostToolUse`, and `Stop`; the Stop command is not duplicated. -- Confirmed Claude's version check, `read-hook.sh`, and `grep-gate.sh` registrations remain present. -- Confirmed validator and CI no longer reference the deleted `check-codex-hooks.py`; the replacement validator is the only caller. -- Confirmed no files under `/private/tmp/onebrain-codex-hook-cli` were edited. -- `git diff --check` is clean. - -## Concerns - -- Runtime behavior depends on the companion CLI's hidden `onebrain hook` command from Task 1 and its `hook_event_name` stdin contract; the plugin declares the existing CLI floor `>=3.4.25`. -- The default local Python 3.9 environment cannot perform TOML validation; CI's Python 3.11+ environment is the supported validation environment. - -## Fix Round 1 - -Review finding: the repository tree documentation described `.gemini/settings.json` as containing only `AfterAgent` and `AfterTool`, omitting `SessionStart`. - -Implementation: updated the `CONTRIBUTING.md` project-tree annotation to list `SessionStart`, `AfterAgent`, and `AfterTool`. No runtime files changed. - -Covering checks: - -```text -$ python3.11 scripts/check-links.py -Links OK — all relative Markdown links resolve. - -$ git diff --check - -$ python3.11 scripts/check-config.py -Config OK — all tracked JSON/TOML parse; manifest keys present. -``` - -All three commands exited `0`. - -Self-review: confirmed the diff contains only the one documentation line and this report section; lifecycle manifests and the CLI repository remain untouched. The corrected tree annotation now names all three Gemini lifecycle registrations. From 85cb4e00618071c12641efe2b9e9460a7846ba04 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 27 Aug 2026 22:18:23 +0700 Subject: [PATCH 09/14] =?UTF-8?q?fix(skills):=20align=20identity=20contrac?= =?UTF-8?q?t=20=E2=80=94=20harness-split=20fallback=20+=20token-scoped=20c?= =?UTF-8?q?heckpoint=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SessionStart-injected identity contract had over-generalized Codex's terminal-fallback danger onto Claude/Gemini, disabling their legitimate session init fallback (their tokens derive from harness env like CLAUDE_CODE_SESSION_ID and are chat-unique). Split every fallback/disable rule in INSTRUCTIONS.md by harness: Claude/Gemini recover via `session init --json` and treat the result as authoritative; Codex keeps checkpoint/wrapup disabled and never mints a replacement token. Also align skill docs with the companion CLI's new `checkpoint reset --session-token ` flag, and apply the same harness split wherever a skill told the agent to recover identity via session init. --- .claude/plugins/onebrain/INSTRUCTIONS.md | 15 ++++++++------- .claude/plugins/onebrain/skills/pause/SKILL.md | 6 +++++- .../onebrain/skills/startup/AUTO-SUMMARY.md | 10 +++++++--- .../skills/startup/references/session-formats.md | 2 +- .claude/plugins/onebrain/skills/wrapup/SKILL.md | 6 +++--- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.claude/plugins/onebrain/INSTRUCTIONS.md b/.claude/plugins/onebrain/INSTRUCTIONS.md index 94ce3a80..829da0eb 100644 --- a/.claude/plugins/onebrain/INSTRUCTIONS.md +++ b/.claude/plugins/onebrain/INSTRUCTIONS.md @@ -217,8 +217,9 @@ Run before responding to any user message. - Read `[agent_folder]/MEMORY.md` → load identity, personality, active projects - Obtain session startup metadata from the vault root: - **All harnesses with SessionStart-injected metadata:** trust and parse the metadata already collected by the shared `onebrain hook` runner; do **not** run `session init` again. Preserve the injected `session_token` and exact executable path, and use that exact executable for every later CLI call in this chat — never bare `onebrain`. This is the only authoritative chat identity; startup must not invoke the CLI a second time. - - **Fallback only when no injected context exists:** resolve the executable once and run `{resolved executable} session init --json`; preserve that exact fallback executable for later non-identity CLI calls. Treat its result as startup display metadata only, not proof that its `session_token` identifies this chat. Keep checkpoint and wrapup writes disabled for that fallback session; never substitute a terminal, process, or manually generated token for an injected token. - Parse injected metadata or the permitted fallback JSON; store `DATETIME` (for greeting), `session_token` (for checkpoints only when injected), `search_unembedded`, and `headless` in context. **Read `search_unembedded` (canonical, CLI v3.4.5+); if that key is absent, fall back to the legacy `qmd_unembedded` key** — older CLIs emit only `qmd_unembedded`, while v3.4.5 emits both keys with the same value during the transition. JSON shape: `{"datetime":"Ddd · DD Mon YYYY · HH:MM","session_token":"XXXXX","search_unembedded":N|null,"qmd_unembedded":N|null,"headless":true|false}`. The value is a count `N`, or **`null`** when the search probe couldn't determine it (index missing / timed out / unparseable) — treat `null` as **unknown**, distinct from a genuine `0` (a real index with nothing pending). **CLI v3.1+ requires `--json` because the default output flipped to text.** The v3.0 alias `onebrain session-init` still works and now auto-rewrites to include `--json` when `onebrain plugin update` runs. If injected metadata is missing and the fallback command fails or is unavailable, use `date '+%a · %d %b %Y · %H:%M'` for `DATETIME`, treat `search_unembedded` as `null` and `headless` as `false`, leave `session_token` unset, and keep checkpoint/wrapup writes disabled. If metadata reports `{"decision":"block","reason":"onebrain-vault-not-found"}` (CLI v3.1+) or `"reason":"onebrain-init-required"` (CLI v3.0 back-compat), skip Steps 2–4; instead output a single message: "OneBrain vault not initialized. Run `/onboarding` to set up your vault." + - **Fallback when no injected context exists — Claude/Gemini:** resolve the executable once and run `{resolved executable} session init --json`; preserve that exact executable for every later CLI call in this chat. Its returned `session_token` derives from harness environment (e.g. `CLAUDE_CODE_SESSION_ID`) and is chat-unique, so treat it exactly like an injected token — it IS authoritative for checkpoint and wrapup writes. + - **Fallback when no injected context exists — Codex:** resolve the executable once and run `{resolved executable} session init --json`; preserve that exact fallback executable for later non-identity CLI calls. Treat its result as startup display metadata only, not proof that its `session_token` identifies this chat — a terminal- or process-derived fallback token is not chat-unique on Codex. Keep checkpoint and wrapup writes disabled for that fallback session; never substitute a terminal, process, or manually generated token for an injected token. + Parse injected metadata or the permitted fallback JSON; store `DATETIME` (for greeting), `session_token` (for checkpoints — authoritative when injected on any harness, or from the Claude/Gemini fallback above; left unset and disabled on a Codex fallback), `search_unembedded`, and `headless` in context. **Read `search_unembedded` (canonical, CLI v3.4.5+); if that key is absent, fall back to the legacy `qmd_unembedded` key** — older CLIs emit only `qmd_unembedded`, while v3.4.5 emits both keys with the same value during the transition. JSON shape: `{"datetime":"Ddd · DD Mon YYYY · HH:MM","session_token":"XXXXX","search_unembedded":N|null,"qmd_unembedded":N|null,"headless":true|false}`. The value is a count `N`, or **`null`** when the search probe couldn't determine it (index missing / timed out / unparseable) — treat `null` as **unknown**, distinct from a genuine `0` (a real index with nothing pending). **CLI v3.1+ requires `--json` because the default output flipped to text.** The v3.0 alias `onebrain session-init` still works and now auto-rewrites to include `--json` when `onebrain plugin update` runs. If injected metadata is missing and the fallback command also fails or is unavailable, use `date '+%a · %d %b %Y · %H:%M'` for `DATETIME`, treat `search_unembedded` as `null` and `headless` as `false`, leave `session_token` unset, and keep checkpoint/wrapup writes disabled. If metadata reports `{"decision":"block","reason":"onebrain-vault-not-found"}` (CLI v3.1+) or `"reason":"onebrain-init-required"` (CLI v3.0 back-compat), skip Steps 2–4; instead output a single message: "OneBrain vault not initialized. Run `/onboarding` to set up your vault." - **If `headless` is `true`** (CLI v3.2.6+, set by `onebrain skill run`): this is an unattended one-shot run, so **skip Steps 2–4 entirely** — no greeting, no startup status, and none of Step 3's memory/inbox/task/orphan/pause scans — and proceed directly to the invoked skill. The `headless` field is absent on older CLIs; treat absent as `false` (normal interactive startup). **Step 2 — Send greeting immediately:** @@ -249,7 +250,7 @@ Ddd · DD Mon YYYY · HH:MM On weekends: lighter, less task-focused tone. **No-repeat rule:** don't ask about facts already in context. **Step 3 — After greeting (run all in parallel):** -- `session_token` is already in context only when Step 1 received SessionStart-injected metadata — do not re-run detection or initialize a second time. When it is absent, checkpoint/wrapup writes remain disabled. +- `session_token` is already in context when Step 1 received SessionStart-injected metadata, or — on Claude/Gemini — from the Step 1 fallback `session init --json` call; do not re-run detection or initialize a second time. On Codex, when injected metadata is absent, checkpoint/wrapup writes remain disabled regardless of any fallback display metadata. - Read `[agent_folder]/MEMORY-INDEX.md` → load memory file index for lazy-loading - Load `memory/` files only when the user's first message matches their MEMORY-INDEX.md Topics keywords (`status: active` or `needs-review` only). Do not eagerly load every active project; a generic greeting must not pull project memory into context. - Glob `[inbox_folder]/*.md` → count files as `inbox_count` @@ -260,7 +261,7 @@ On weekends: lighter, less task-focused tone. **No-repeat rule:** don't ask abou "[projects_folder]"` and `path: "[inbox_folder]"`, pattern `- \[ \] .*📅 [0-9]{4}-[0-9]{2}-[0-9]{2}`, `output_mode: "content"` — combine, keep dates ≤ today. (Fallback still shows fenced demo lines; upgrade the CLI to remove them.) -- When injected metadata supplied both the exact executable and `session_token`, run `{injected exact executable} checkpoint orphans "[logs_folder]" "[session_token]" --json` (from vault root) → parse JSON output; read `orphan_count` field. JSON shape: `{"orphan_count":N}`. **CLI v3.1+ requires `--json`** (default is now text). The v3.0 alias `onebrain orphan-scan` still works and is auto-rewritten by `onebrain plugin update`. Without injected identity, skip this CLI call and use the structure-aware glob fallback: if `[logs_folder]/checkpoint/` exists, glob `[logs_folder]/checkpoint/*-checkpoint-*.md` (post-v2.4.0 flat layout); else glob `[logs_folder]/**/*-checkpoint-*.md` (pre-v2.4.0 nested layout — multi-vault user on an unmigrated vault). Then discard files whose date has a non-auto-saved session log (look in `[logs_folder]/session/YYYY/MM/` for post-v2.4.0, or `[logs_folder]/YYYY/MM/` for pre-v2.4.0), and count distinct session tokens among remaining files. +- When context holds an authoritative `session_token` and its exact executable (injected metadata on any harness, or the Claude/Gemini Step 1 fallback), run `{that exact executable} checkpoint orphans "[logs_folder]" "[session_token]" --json` (from vault root) → parse JSON output; read `orphan_count` field. JSON shape: `{"orphan_count":N}`. **CLI v3.1+ requires `--json`** (default is now text). The v3.0 alias `onebrain orphan-scan` still works and is auto-rewritten by `onebrain plugin update`. Without an authoritative `session_token` (Codex without injected metadata), skip this CLI call and use the structure-aware glob fallback: if `[logs_folder]/checkpoint/` exists, glob `[logs_folder]/checkpoint/*-checkpoint-*.md` (post-v2.4.0 flat layout); else glob `[logs_folder]/**/*-checkpoint-*.md` (pre-v2.4.0 nested layout — multi-vault user on an unmigrated vault). Then discard files whose date has a non-auto-saved session log (look in `[logs_folder]/session/YYYY/MM/` for post-v2.4.0, or `[logs_folder]/YYYY/MM/` for pre-v2.4.0), and count distinct session tokens among remaining files. - Glob `[logs_folder]/scheduler/**/*.err.md` and keep files modified within the last 24 h → count as `sched_err_count`; note the most recent file's skill name as `sched_err_skill`. (Filesystem only — never probe the OS scheduler at startup; /doctor owns the deep check.) - Read `[logs_folder]/pause/_active.md` if present → parse single-line content as `active_pause_slug`. If absent, set `active_pause_slug = null`. Then if non-null: glob `[logs_folder]/pause/*-{active_pause_slug}-pause-*.md` and count them as `active_pause_count`; read the latest file's `date` frontmatter as `active_pause_last_date`. - **Legacy structure detection (post-v2.4.0):** Check whether `[logs_folder]/session/` exists (any of the new top-level subfolders works as a sentinel; `session/` is the most representative). If it does NOT exist AND `[logs_folder]/YYYY/` does exist (legacy structure pre-v2.4.0), set `vault_structure_legacy = true`. If both `session/` and a legacy `YYYY/` exist (partial migration), still treat `vault_structure_legacy = false` — `/update` will resume cleanup on next run. If neither exists (fresh vault), `vault_structure_legacy = false`. @@ -330,16 +331,16 @@ If the user closes the session without any end-of-session signal, AUTO-SUMMARY d ### Auto Checkpoint (Hook-Triggered) -> **What is session_token?** A chat-unique identifier supplied in the SessionStart metadata from the shared `onebrain hook` bridge and kept in context. The hook runner resolves the harness lifecycle payload once, including the exact CLI executable; every harness must preserve both values. If the injected token or executable is missing (including after compact), do not resolve a replacement from terminal, process, or day state — checkpoint and wrapup writes are disabled because that would not prove chat identity. If token resolution fails, abort checkpoint silently; never guess. +> **What is session_token?** A chat-unique identifier. On all harnesses it is supplied in the SessionStart metadata from the shared `onebrain hook` bridge and kept in context, alongside the exact CLI executable that every harness must preserve. If that injected token or executable is missing (including after compact): on **Claude/Gemini**, recover it the same way Step 1's fallback does — run `{resolved executable} session init --json` — its `session_token` derives from harness environment (e.g. `CLAUDE_CODE_SESSION_ID`), is chat-unique, and is authoritative for checkpoint/wrapup exactly like an injected token. On **Codex**, do not resolve a replacement from terminal, process, or day state — checkpoint and wrapup writes stay disabled because nothing else proves chat identity; never mint or substitute a token. If token resolution fails under either rule, abort checkpoint silently; never guess. -When a hook sends a message whose reason matches `NN since ` (a zero-padded two-digit number followed by ` since start` or ` since checkpoint-NN`), silently spawn a background agent (mode: bypassPermissions) to write the checkpoint. The background agent receives the injected `session_token` from context, today's date (YYYY-MM-DD), NN from the reason, `[logs_folder]`, and enough conversation context to write a meaningful checkpoint. If injected identity is unavailable, abort silently rather than running `session init` again. +When a hook sends a message whose reason matches `NN since ` (a zero-padded two-digit number followed by ` since start` or ` since checkpoint-NN`), silently spawn a background agent (mode: bypassPermissions) to write the checkpoint. The background agent receives the resolved `session_token` from context — injected on any harness, or, on Claude/Gemini, recovered by re-running `session init --json` if not yet in context — today's date (YYYY-MM-DD), NN from the reason, `[logs_folder]`, and enough conversation context to write a meaningful checkpoint. On Codex, if the injected identity is unavailable, abort silently rather than running `session init` to mint a replacement. The agent writes to `[logs_folder]/checkpoint/YYYY-MM-DD-{session_token}-checkpoint-NN.md` (post-v2.4.0: flat directory). **Always run `mkdir -p [logs_folder]/checkpoint/` before writing** — fresh post-onboarding vaults that haven't run `/update` yet won't have the directory; without the mkdir the Write tool fails silently and the checkpoint is lost. No output to user. The Stop hook is the only checkpoint signal source. It emits `decision:"block",reason:"NN since "` whenever the message-count threshold (default 15) or time threshold (default 30 min) is met. The agent dispatches a background sub-agent to write a checkpoint file, then continues. Determine action from the reason: -- `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → if the injected `session_token` is in context, spawn a background agent (mode: bypassPermissions) to write the checkpoint; otherwise abort silently. The agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching +- `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → if a resolved `session_token` is in context (injected on any harness, or — on Claude/Gemini only — recoverable by re-running `session init --json`), spawn a background agent (mode: bypassPermissions) to write the checkpoint; on Codex, if no injected `session_token` is in context, abort silently rather than minting one. The agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching - Ambiguous or unknown → default to stop checkpoint All three supported harnesses register the same bare `onebrain hook` lifecycle command. The installed CLI selects session initialization, incremental search reindexing, checkpointing, and pending embedding from the stdin `hook_event_name`; Codex has exactly one Stop registration, so checkpoint and embedding work are not duplicated. PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly through the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. diff --git a/.claude/plugins/onebrain/skills/pause/SKILL.md b/.claude/plugins/onebrain/skills/pause/SKILL.md index 83c7bea5..dc0cfe29 100644 --- a/.claude/plugins/onebrain/skills/pause/SKILL.md +++ b/.claude/plugins/onebrain/skills/pause/SKILL.md @@ -63,7 +63,11 @@ Review the current conversation since the last pause file of `active_slug` (or s 1. Today's date as `YYYY-MM-DD`. 2. Ensure directory exists: `mkdir -p [logs_folder]/pause/` -3. Get `session_token` from agent context (run `onebrain session init` to recover if missing). **If `session init` fails or returns no token:** abort the write. Do NOT proceed to Step 5. Output: `⚠️ Could not determine session token. Snapshot not saved — try again or run /doctor.` +3. Get `session_token` from agent context. + - **Claude/Gemini:** if missing, run `onebrain session init --json` to recover it. + - **Codex:** SessionStart injects the token; if missing, do **not** run `session init` to mint a replacement — a terminal- or process-derived token could merge another chat's snapshots. + + **If no token is available after applying the rule above:** abort the write. Do NOT proceed to Step 5. Output: `⚠️ Could not determine session token. Snapshot not saved — try again or run /doctor.` 4. Write to `[logs_folder]/pause/YYYY-MM-DD-{active_slug}-pause-{next_nn}.md`: ```yaml diff --git a/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md b/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md index e7df8872..ad6cb442 100644 --- a/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md +++ b/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md @@ -8,7 +8,11 @@ Run silently (no output) if ALL of these are true: 3. The session had 3 or more user↔assistant exchanges If conditions are met: -- Use `session_token` from context if already loaded (set by `onebrain session init` at startup); if absent, run `onebrain session init --json` and use the `SESSION_TOKEN` value. Glob checkpoint files (post-v2.4.0: checkpoints live in flat `[logs_folder]/checkpoint/` regardless of date): `[logs_folder]/checkpoint/YYYY-MM-DD-{session_token}-checkpoint-*.md`. Also yesterday's (handles cross-midnight sessions): compute yesterday's date (accounting for month/year rollover) and glob `[logs_folder]/checkpoint/YYYY-MM-DD_PREV-{session_token}-checkpoint-*.md`. **Read every file in the glob result** and fully incorporate all of their content into the session summary (not just as background context). Any checkpoint file that exists is unmerged by definition — there is no `merged:` filter. Every checkpoint must appear in the summary before it is deleted. +- Use `session_token` from context if already loaded (set by `onebrain session init` at startup). + - **Codex:** SessionStart injects a token derived from the complete hook `session_id`; it is the chat identity for this auto-summary. If that injected token is absent, abort auto-summary silently (checkpoint files stay in place — /wrapup recovers them at the next session start) rather than falling back to a terminal- or process-derived token, since that could merge another chat's checkpoints. + - **Claude/Gemini:** if absent, run `onebrain session init --json` and use the `SESSION_TOKEN` value. + + Glob checkpoint files (post-v2.4.0: checkpoints live in flat `[logs_folder]/checkpoint/` regardless of date): `[logs_folder]/checkpoint/YYYY-MM-DD-{session_token}-checkpoint-*.md`. Also yesterday's (handles cross-midnight sessions): compute yesterday's date (accounting for month/year rollover) and glob `[logs_folder]/checkpoint/YYYY-MM-DD_PREV-{session_token}-checkpoint-*.md`. **Read every file in the glob result** and fully incorporate all of their content into the session summary (not just as background context). Any checkpoint file that exists is unmerged by definition — there is no `merged:` filter. Every checkpoint must appear in the summary before it is deleted. - Determine NN: count existing `[logs_folder]/session/YYYY/MM/YYYY-MM-DD-session-*.md` files for today; NN = count + 1, zero-padded to 2 digits (01, 02, …). **Verify** `YYYY-MM-DD-session-NN.md` does not already exist before writing; if it does, increment NN until a free slot is found. - **Auto-finalize active pause thread (if any) — runs before the session log write.** Read `[logs_folder]/pause/_active.md`. If absent or empty, skip this step. If a slug is present, apply the three skip conditions from `skills/pause/SKILL.md` → Auto-Finalize section (canonical source — keep in sync): @@ -41,9 +45,9 @@ If conditions are met: - Dedup: strip `📅 YYYY-MM-DD` suffix from candidate and existing `- [ ]`/`- [x]` lines before comparing; skip if same text already exists (open or completed). - Insert at first available point: after last `- [ ]` in `## Action Items` section (or after the `## Action Items` heading if the section exists but is empty) → or before `## Open Questions` → or before `## Related` → or at end of file. - Write the file once. On write error, skip all tasks for this file silently and continue to the next target file. -- After confirming the session log was written, reset the checkpoint hook counter to prevent spurious post-summary checkpoints: +- After confirming the session log was written, reset the checkpoint hook counter to prevent spurious post-summary checkpoints, passing the resolved `session_token` from above (never a re-minted one): ```bash - onebrain checkpoint reset + onebrain checkpoint reset --session-token {session_token} ``` - Delete the checkpoint files from the glob above. Guard: only delete AFTER confirming the session log file was successfully written and is non-empty. Never delete before or during the write. If an individual delete fails, skip it silently — stale checkpoints are cleaned up later by /doctor or by the next /wrapup. Do not delete checkpoint files outside this session's glob result. - If a genuinely useful long-term insight emerged, write it to a new `memory/` file using /learn conventions: filename `[agent_folder]/memory/kebab-case-topic.md`, frontmatter `tags: [agent-memory], type: behavioral, source: auto-summary, status: active, conf: medium, verified: today, updated: today, created: today, topics: [2–4 keywords]`. Add a row to MEMORY-INDEX.md and increment `total_active`. **Do not write to MEMORY.md.** diff --git a/.claude/plugins/onebrain/skills/startup/references/session-formats.md b/.claude/plugins/onebrain/skills/startup/references/session-formats.md index cc45aa61..a8da4ba5 100644 --- a/.claude/plugins/onebrain/skills/startup/references/session-formats.md +++ b/.claude/plugins/onebrain/skills/startup/references/session-formats.md @@ -62,7 +62,7 @@ trigger: stop --- ``` -`session_token` is the same token embedded in the filename — duplicating it in frontmatter lets `/wrapup`, `/doctor`, and `checkpoint orphans` filter checkpoints by token without parsing filenames. The agent (per `INSTRUCTIONS.md` Auto Checkpoint section) sets this from the `session_token` already in context (recovered via `onebrain session init` if missing). +`session_token` is the same token embedded in the filename — duplicating it in frontmatter lets `/wrapup`, `/doctor`, and `checkpoint orphans` filter checkpoints by token without parsing filenames. The agent (per `INSTRUCTIONS.md` Auto Checkpoint section) sets this from the `session_token` already in context — injected at session start on any harness, or, on Claude/Gemini only, recovered via `onebrain session init` if missing; on Codex a missing token aborts the checkpoint instead (see `INSTRUCTIONS.md` for the full per-harness rule). **Body:** use Shared Body Sections above. `## What We Worked On`: 2-3 sentences describing the session focus. diff --git a/.claude/plugins/onebrain/skills/wrapup/SKILL.md b/.claude/plugins/onebrain/skills/wrapup/SKILL.md index 27587686..3533b658 100644 --- a/.claude/plugins/onebrain/skills/wrapup/SKILL.md +++ b/.claude/plugins/onebrain/skills/wrapup/SKILL.md @@ -485,7 +485,7 @@ Reflect on the conversation that just occurred. Identify: --- ``` 5. Body: merged content from step 2, using the Shared Body Sections. -6. After successful write, run `onebrain checkpoint reset`. +6. After successful write, run `onebrain checkpoint reset --session-token {session_token}`. 7. Proceed to Step 4b (action item routing) and onward as normal. After Thread Wrapup writes the session log, the existing Step 5 (Checkpoint Cleanup) still runs — checkpoints from Step 1 are deleted. **Plus, in the new Step 5b (below), pause files and `_active.md` are deleted.** @@ -506,10 +506,10 @@ Create `[logs_folder]/session/YYYY/MM/YYYY-MM-DD-session-NN.md` using the Sessio - If checkpoints were incorporated in Step 1 → use **Standard /wrapup — checkpoints incorporated** - Otherwise → use **Standard /wrapup — no checkpoints incorporated** -After writing the session log, reset the checkpoint hook counter to prevent spurious post-wrapup checkpoints: +After writing the session log, reset the checkpoint hook counter to prevent spurious post-wrapup checkpoints, passing the resolved `session_token` from Step 1 (never a re-minted one): ```bash -onebrain checkpoint reset +onebrain checkpoint reset --session-token {session_token} ``` This writes `0::00` into the session state file (3 fields: count, last_ts, last_stop_nn) — triggering a 60-second skip window and resetting the message counter so the next Stop hook starts fresh. From 2082722d293b159256d71c7eaa7d09a4aca27434 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 27 Aug 2026 22:23:52 +0700 Subject: [PATCH 10/14] fix(hooks): fail-open shell fallback for Codex/Gemini registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex and Gemini hook manifests called bare `onebrain hook` with no version gate. A CLI older than 3.4.25 has no `hook` subcommand, so it exits 2 (clap usage error) — the blocking exit code in the hook protocol — contradicting the "every hook is fail-open" and "rollback disables these hooks" claims. Claude is protected by its check-cli-version.sh SessionStart gate; Codex and Gemini had neither a gate nor a fallback. Wrap each command in a shell-level `|| echo` fallback: SessionStart echoes a warning additionalContext when `onebrain hook` fails, PostToolUse/Stop echo `{}`. Update check-lifecycle-hooks.py to assert the exact wrapped strings and add rollback smoke cases (missing CLI, CLI without the hook subcommand) pinning that the fallback returns exit 0 with the expected stdout. --- .../plugins/onebrain/hooks/codex-hooks.json | 12 +- .gemini/settings.json | 6 +- CHANGELOG.md | 1 + scripts/check-lifecycle-hooks.py | 111 ++++++++++++++++-- 4 files changed, 108 insertions(+), 22 deletions(-) diff --git a/.claude/plugins/onebrain/hooks/codex-hooks.json b/.claude/plugins/onebrain/hooks/codex-hooks.json index ad900c23..7d7abe60 100644 --- a/.claude/plugins/onebrain/hooks/codex-hooks.json +++ b/.claude/plugins/onebrain/hooks/codex-hooks.json @@ -6,8 +6,8 @@ "hooks": [ { "type": "command", - "command": "onebrain hook", - "commandWindows": "onebrain hook", + "command": "onebrain hook || echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"OneBrain lifecycle hooks are inactive: the onebrain CLI is missing or older than 3.4.25. Run onebrain update, then start a new session.\"}}'", + "commandWindows": "onebrain hook || echo {\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"OneBrain lifecycle hooks are inactive: the onebrain CLI is missing or older than 3.4.25. Run onebrain update, then start a new session.\"}}", "timeout": 10 } ] @@ -19,8 +19,8 @@ "hooks": [ { "type": "command", - "command": "onebrain hook", - "commandWindows": "onebrain hook", + "command": "onebrain hook || echo '{}'", + "commandWindows": "onebrain hook || echo {}", "timeout": 10 } ] @@ -31,8 +31,8 @@ "hooks": [ { "type": "command", - "command": "onebrain hook", - "commandWindows": "onebrain hook", + "command": "onebrain hook || echo '{}'", + "commandWindows": "onebrain hook || echo {}", "timeout": 10 } ] diff --git a/.gemini/settings.json b/.gemini/settings.json index 0b6bbc88..3aaa29c1 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -8,7 +8,7 @@ "hooks": [ { "type": "command", - "command": "onebrain hook" + "command": "onebrain hook || echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"OneBrain lifecycle hooks are inactive: the onebrain CLI is missing or older than 3.4.25. Run onebrain update, then start a new session.\"}}'" } ] } @@ -19,7 +19,7 @@ "hooks": [ { "type": "command", - "command": "onebrain hook" + "command": "onebrain hook || echo '{}'" } ] } @@ -30,7 +30,7 @@ "hooks": [ { "type": "command", - "command": "onebrain hook" + "command": "onebrain hook || echo '{}'" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8edb408f..359461f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Codex hook manifests call the cache-independent runner in OneBrain CLI v3.4.25+, so an active task no longer retains a dead path when the plugin cache refreshes. - Codex reuses SessionStart metadata, loads project memory only on topic matches, and bounds startup tasks to five while retaining the full count. - Codex, Claude, and Gemini lifecycle registrations now converge on one bare `onebrain hook` command; it selects the action from the stdin `hook_event_name`, and Codex keeps a single Stop entry so checkpoint work is not duplicated. Start a new agent session after upgrading because the old `codex-hook` alias is intentionally absent. +- Codex and Gemini hook commands now wrap `onebrain hook` in a shell-level `|| echo` fallback, so rolling the CLI back below 3.4.25 degrades to an inert warning instead of a blocking exit code (Claude already has the `check-cli-version.sh` gate). ## v3.4.11 — 2026-07-30 — /digest first run is a complete setup chain diff --git a/scripts/check-lifecycle-hooks.py b/scripts/check-lifecycle-hooks.py index e6e076e7..7641cf3e 100644 --- a/scripts/check-lifecycle-hooks.py +++ b/scripts/check-lifecycle-hooks.py @@ -16,6 +16,31 @@ CLAUDE_HOOKS_PATH = ROOT / ".claude/plugins/onebrain/hooks/hooks.json" GEMINI_SETTINGS_PATH = ROOT / ".gemini/settings.json" +# Shell-level fail-open fallback: when `onebrain` is missing or older than 3.4.25 +# (no `hook` subcommand, clap exits 2 — the blocking exit code), `||` catches it +# and echoes a harmless stand-in payload instead of letting the harness see a +# nonzero exit. Codex and Gemini have no version-gate hook (unlike Claude's +# check-cli-version.sh SessionStart hook), so they need this at the shell level. +FAIL_OPEN_SESSION_START_COMMAND = ( + "onebrain hook || echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\"," + "\"additionalContext\":\"OneBrain lifecycle hooks are inactive: the onebrain CLI " + "is missing or older than 3.4.25. Run onebrain update, then start a new session." + "\"}}'" +) +FAIL_OPEN_SESSION_START_COMMAND_WINDOWS = ( + "onebrain hook || echo {\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\"," + "\"additionalContext\":\"OneBrain lifecycle hooks are inactive: the onebrain CLI " + "is missing or older than 3.4.25. Run onebrain update, then start a new session." + "\"}}" +) +FAIL_OPEN_EMPTY_COMMAND = "onebrain hook || echo '{}'" +FAIL_OPEN_EMPTY_COMMAND_WINDOWS = "onebrain hook || echo {}" + +# Gemini has no commandWindows field at all (single `command` per event); its +# fallback strings are the same POSIX strings as Codex's. +GEMINI_SESSION_START_COMMAND = FAIL_OPEN_SESSION_START_COMMAND +GEMINI_EMPTY_COMMAND = FAIL_OPEN_EMPTY_COMMAND + def commands_for(manifest: dict, event: str) -> list[dict]: return [ @@ -25,19 +50,27 @@ def commands_for(manifest: dict, event: str) -> list[dict]: ] -def assert_unified_command(command: dict, event: str) -> None: - assert command["command"] == "onebrain hook", f"{event} must call onebrain hook" - if "commandWindows" in command: - assert command["commandWindows"] == "onebrain hook", ( - f"{event} Windows hook must call onebrain hook" +def assert_fail_open_command( + command: dict, event: str, expected: str, expected_windows: str | None = None +) -> None: + assert command["command"] == expected, f"{event} must call {expected!r}" + if expected_windows is not None: + assert "commandWindows" in command, f"{event} must define commandWindows" + assert command["commandWindows"] == expected_windows, ( + f"{event} Windows hook must call {expected_windows!r}" ) codex_manifest = json.loads(CODEX_HOOKS_PATH.read_text(encoding="utf-8")) -for event in ("SessionStart", "PostToolUse", "Stop"): +CODEX_EXPECTED = { + "SessionStart": (FAIL_OPEN_SESSION_START_COMMAND, FAIL_OPEN_SESSION_START_COMMAND_WINDOWS), + "PostToolUse": (FAIL_OPEN_EMPTY_COMMAND, FAIL_OPEN_EMPTY_COMMAND_WINDOWS), + "Stop": (FAIL_OPEN_EMPTY_COMMAND, FAIL_OPEN_EMPTY_COMMAND_WINDOWS), +} +for event, (expected, expected_windows) in CODEX_EXPECTED.items(): commands = commands_for(codex_manifest, event) assert len(commands) == 1, f"Codex must have one {event} command" - assert_unified_command(commands[0], f"Codex {event}") + assert_fail_open_command(commands[0], f"Codex {event}", expected, expected_windows) assert codex_manifest["hooks"]["PostToolUse"][0]["matcher"] == "Edit|Write|apply_patch" @@ -61,13 +94,18 @@ def assert_unified_command(command: dict, event: str) -> None: "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grep-gate.sh\"", ], "Claude independent PreToolUse hooks changed unexpectedly" +GEMINI_EXPECTED = { + "SessionStart": GEMINI_SESSION_START_COMMAND, + "AfterTool": GEMINI_EMPTY_COMMAND, + "AfterAgent": GEMINI_EMPTY_COMMAND, +} gemini_settings = json.loads(GEMINI_SETTINGS_PATH.read_text(encoding="utf-8")) for event in ("SessionStart", "AfterTool", "AfterAgent"): groups = gemini_settings["hooks"].get(event, []) assert len(groups) == 1, f"Gemini must have one {event} group" commands = commands_for(gemini_settings, event) assert len(commands) == 1, f"Gemini must have one {event} command" - assert_unified_command(commands[0], f"Gemini {event}") + assert_fail_open_command(commands[0], f"Gemini {event}", GEMINI_EXPECTED[event]) assert "matcher" not in gemini_settings["hooks"]["SessionStart"][0], ( "Gemini SessionStart must be unfiltered so startup, resume, and clear all run " "the shared lifecycle hook" @@ -124,7 +162,9 @@ def assert_unified_command(command: dict, event: str) -> None: "permission_mode": "default", } - def run(command: str, payload: dict) -> subprocess.CompletedProcess[str]: + def run( + command: str, payload: dict, run_env: dict | None = None + ) -> subprocess.CompletedProcess[str]: result = subprocess.run( command, shell=True, @@ -132,7 +172,7 @@ def run(command: str, payload: dict) -> subprocess.CompletedProcess[str]: input=json.dumps(payload), text=True, capture_output=True, - env=env, + env=run_env if run_env is not None else env, ) assert result.returncode == 0, result.stderr return result @@ -158,8 +198,14 @@ def run(command: str, payload: dict) -> subprocess.CompletedProcess[str]: stop = run(codex_manifest["hooks"]["Stop"][0]["hooks"][0]["command"], stop_payload) assert json.loads(stop.stdout) == {"decision": "block", "reason": "checkpoint due"} - for manifest, events in ( - (claude_manifest, (("SessionStart", None),)), + def expected_claude_command(event: str) -> str: + return "onebrain hook" + + def expected_gemini_command(event: str) -> str: + return GEMINI_SESSION_START_COMMAND if event == "SessionStart" else GEMINI_EMPTY_COMMAND + + for manifest, events, expected_command_for in ( + (claude_manifest, (("SessionStart", None),), expected_claude_command), ( gemini_settings, ( @@ -169,13 +215,15 @@ def run(command: str, payload: dict) -> subprocess.CompletedProcess[str]: ("AfterTool", None), ("AfterAgent", None), ), + expected_gemini_command, ), ): for event, source in events: + expected_command = expected_command_for(event) command = next( command for command in commands_for(manifest, event) - if command["command"] == "onebrain hook" + if command["command"] == expected_command ) payload = {**full_payload, "hook_event_name": event} if source is not None: @@ -188,6 +236,43 @@ def run(command: str, payload: dict) -> subprocess.CompletedProcess[str]: else: assert json.loads(output.stdout) == {} + # --- Fallback smoke cases: rollback behavior below the CLI floor (POSIX only) --- + # These pin the shell-level `||` fallback that keeps Codex/Gemini fail-open even + # when `onebrain` is missing entirely, or present but too old to have `hook` + # (clap exits 2 on an unrecognized subcommand — the blocking exit code). + missing_cli_dir = temp / "missing-cli-path" + missing_cli_dir.mkdir() + missing_cli_env = os.environ.copy() + missing_cli_env["PATH"] = str(missing_cli_dir) + + old_cli_bin = temp / "old-cli-bin" + old_cli_bin.mkdir() + old_onebrain = old_cli_bin / "onebrain" + old_onebrain.write_text( + "#!/bin/sh\n" + "echo \"error: unrecognized subcommand 'hook'\" >&2\n" + "exit 2\n" + ) + old_onebrain.chmod(0o755) + old_cli_env = os.environ.copy() + old_cli_env["PATH"] = str(old_cli_bin) + + for rollback_env in (missing_cli_env, old_cli_env): + rollback_session = run( + codex_manifest["hooks"]["SessionStart"][0]["hooks"][0]["command"], + {**full_payload, "hook_event_name": "SessionStart"}, + run_env=rollback_env, + ) + rollback_session_json = json.loads(rollback_session.stdout) + assert "older than 3.4.25" in rollback_session_json["hookSpecificOutput"]["additionalContext"] + + rollback_stop = run( + codex_manifest["hooks"]["Stop"][0]["hooks"][0]["command"], + {**full_payload, "hook_event_name": "Stop"}, + run_env=rollback_env, + ) + assert json.loads(rollback_stop.stdout) == {} + calls = [json.loads(line) for line in calls_path.read_text(encoding="utf-8").splitlines()] assert all(call["args"] == ["hook"] for call in calls) assert all( From 352f2263c04a2be5424590dd9950e606a5cbda0e Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 27 Aug 2026 22:55:11 +0700 Subject: [PATCH 11/14] docs: reconcile prose with the fail-open wrappers and log the identity-contract fix --- .claude/plugins/onebrain/INSTRUCTIONS.md | 2 +- CHANGELOG.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/plugins/onebrain/INSTRUCTIONS.md b/.claude/plugins/onebrain/INSTRUCTIONS.md index 829da0eb..8a1f00a7 100644 --- a/.claude/plugins/onebrain/INSTRUCTIONS.md +++ b/.claude/plugins/onebrain/INSTRUCTIONS.md @@ -343,7 +343,7 @@ Determine action from the reason: - `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → if a resolved `session_token` is in context (injected on any harness, or — on Claude/Gemini only — recoverable by re-running `session init --json`), spawn a background agent (mode: bypassPermissions) to write the checkpoint; on Codex, if no injected `session_token` is in context, abort silently rather than minting one. The agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching - Ambiguous or unknown → default to stop checkpoint -All three supported harnesses register the same bare `onebrain hook` lifecycle command. The installed CLI selects session initialization, incremental search reindexing, checkpointing, and pending embedding from the stdin `hook_event_name`; Codex has exactly one Stop registration, so checkpoint and embedding work are not duplicated. PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly through the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. +All three supported harnesses route lifecycle events through the same `onebrain hook` command — Codex and Gemini wrap it in a shell-level `|| echo` fail-open fallback (inert when the CLI is missing or older than 3.4.25), while Claude registers it bare alongside its separate `check-cli-version.sh` gate. The installed CLI selects session initialization, incremental search reindexing, checkpointing, and pending embedding from the stdin `hook_event_name`; Codex has exactly one Stop registration, so checkpoint and embedding work are not duplicated. PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly through the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. After upgrading this plugin, start a new agent session so the new registrations are loaded. The old `codex-hook` alias is intentionally absent. diff --git a/CHANGELOG.md b/CHANGELOG.md index 359461f8..c7ae9bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Codex hook manifests call the cache-independent runner in OneBrain CLI v3.4.25+, so an active task no longer retains a dead path when the plugin cache refreshes. - Codex reuses SessionStart metadata, loads project memory only on topic matches, and bounds startup tasks to five while retaining the full count. -- Codex, Claude, and Gemini lifecycle registrations now converge on one bare `onebrain hook` command; it selects the action from the stdin `hook_event_name`, and Codex keeps a single Stop entry so checkpoint work is not duplicated. Start a new agent session after upgrading because the old `codex-hook` alias is intentionally absent. +- Codex, Claude, and Gemini lifecycle registrations now converge on one `onebrain hook` command; it selects the action from the stdin `hook_event_name`, and Codex keeps a single Stop entry so checkpoint work is not duplicated. Start a new agent session after upgrading because the old `codex-hook` alias is intentionally absent. +- The chat identity contract is harness-split: Claude/Gemini may recover a token via `session init --json`, Codex never mints one, and `checkpoint reset` now passes `--session-token {session_token}` so wrapup resets the same cadence counter the Stop hook incremented (needs CLI 3.4.25). - Codex and Gemini hook commands now wrap `onebrain hook` in a shell-level `|| echo` fallback, so rolling the CLI back below 3.4.25 degrades to an inert warning instead of a blocking exit code (Claude already has the `check-cli-version.sh` gate). ## v3.4.11 — 2026-07-30 — /digest first run is a complete setup chain From f5b2f1711430575c4c6e8f7742220e83767b4ebc Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 27 Aug 2026 23:34:03 +0700 Subject: [PATCH 12/14] fix(identity): Claude recovers the hook token via its derivation layer; Gemini joins the no-mint rule; wrap Claude hooks Round-2 review findings: - Claude's session-identity fallback now re-runs `session init --json` with ONEBRAIN_HOOK_SESSION_ID set to CLAUDE_CODE_SESSION_ID, so the recovered session_token is provably the same sha256[..16] token the Stop hook increments (empirically verified against the CLI resolver). Gemini moves into the same no-mint bucket as Codex: its resolver has no harness-session env layer, so a terminal/process/day-scoped fallback token could merge chats. - hooks.json's Claude SessionStart generic hook is now wrapped in the same `onebrain hook || echo '{}'` fail-open fallback Codex and Gemini already use, so a pre-3.4.25 or missing CLI degrades gracefully instead of exiting 2 (Claude's check-cli-version.sh gate remains the messaging layer). check-lifecycle-hooks.py updated in lockstep, its Gemini commandWindows absence now asserted explicitly, and the Windows-string limitation is documented near the commandWindows constants. - Stale "bare onebrain hook" / "no wrapper needed" prose in GEMINI.md and CONTRIBUTING.md corrected to describe the fail-open wrapper. - Skills now spell the preserved `{resolved executable}` placeholder instead of bare `onebrain` for session init / checkpoint reset recovery commands, and the stale `SESSION_TOKEN` key reference is corrected to `session_token`. CHANGELOG.md's existing v3.4.12 bullets updated in place (same count) to match. --- .claude/plugins/onebrain/INSTRUCTIONS.md | 18 ++++----- .claude/plugins/onebrain/hooks/hooks.json | 2 +- .../plugins/onebrain/skills/pause/SKILL.md | 4 +- .../onebrain/skills/startup/AUTO-SUMMARY.md | 8 ++-- .../startup/references/session-formats.md | 2 +- .../plugins/onebrain/skills/wrapup/SKILL.md | 10 ++--- CHANGELOG.md | 4 +- CONTRIBUTING.md | 4 +- GEMINI.md | 2 +- scripts/check-lifecycle-hooks.py | 39 ++++++++++++++++--- 10 files changed, 60 insertions(+), 33 deletions(-) diff --git a/.claude/plugins/onebrain/INSTRUCTIONS.md b/.claude/plugins/onebrain/INSTRUCTIONS.md index 8a1f00a7..baa759a4 100644 --- a/.claude/plugins/onebrain/INSTRUCTIONS.md +++ b/.claude/plugins/onebrain/INSTRUCTIONS.md @@ -217,9 +217,9 @@ Run before responding to any user message. - Read `[agent_folder]/MEMORY.md` → load identity, personality, active projects - Obtain session startup metadata from the vault root: - **All harnesses with SessionStart-injected metadata:** trust and parse the metadata already collected by the shared `onebrain hook` runner; do **not** run `session init` again. Preserve the injected `session_token` and exact executable path, and use that exact executable for every later CLI call in this chat — never bare `onebrain`. This is the only authoritative chat identity; startup must not invoke the CLI a second time. - - **Fallback when no injected context exists — Claude/Gemini:** resolve the executable once and run `{resolved executable} session init --json`; preserve that exact executable for every later CLI call in this chat. Its returned `session_token` derives from harness environment (e.g. `CLAUDE_CODE_SESSION_ID`) and is chat-unique, so treat it exactly like an injected token — it IS authoritative for checkpoint and wrapup writes. - - **Fallback when no injected context exists — Codex:** resolve the executable once and run `{resolved executable} session init --json`; preserve that exact fallback executable for later non-identity CLI calls. Treat its result as startup display metadata only, not proof that its `session_token` identifies this chat — a terminal- or process-derived fallback token is not chat-unique on Codex. Keep checkpoint and wrapup writes disabled for that fallback session; never substitute a terminal, process, or manually generated token for an injected token. - Parse injected metadata or the permitted fallback JSON; store `DATETIME` (for greeting), `session_token` (for checkpoints — authoritative when injected on any harness, or from the Claude/Gemini fallback above; left unset and disabled on a Codex fallback), `search_unembedded`, and `headless` in context. **Read `search_unembedded` (canonical, CLI v3.4.5+); if that key is absent, fall back to the legacy `qmd_unembedded` key** — older CLIs emit only `qmd_unembedded`, while v3.4.5 emits both keys with the same value during the transition. JSON shape: `{"datetime":"Ddd · DD Mon YYYY · HH:MM","session_token":"XXXXX","search_unembedded":N|null,"qmd_unembedded":N|null,"headless":true|false}`. The value is a count `N`, or **`null`** when the search probe couldn't determine it (index missing / timed out / unparseable) — treat `null` as **unknown**, distinct from a genuine `0` (a real index with nothing pending). **CLI v3.1+ requires `--json` because the default output flipped to text.** The v3.0 alias `onebrain session-init` still works and now auto-rewrites to include `--json` when `onebrain plugin update` runs. If injected metadata is missing and the fallback command also fails or is unavailable, use `date '+%a · %d %b %Y · %H:%M'` for `DATETIME`, treat `search_unembedded` as `null` and `headless` as `false`, leave `session_token` unset, and keep checkpoint/wrapup writes disabled. If metadata reports `{"decision":"block","reason":"onebrain-vault-not-found"}` (CLI v3.1+) or `"reason":"onebrain-init-required"` (CLI v3.0 back-compat), skip Steps 2–4; instead output a single message: "OneBrain vault not initialized. Run `/onboarding` to set up your vault." + - **Fallback when no injected context exists — Claude:** resolve the executable once, then recover identity via the hook's own derivation layer: POSIX `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json` (PowerShell: `$env:ONEBRAIN_HOOK_SESSION_ID = $env:CLAUDE_CODE_SESSION_ID; & {resolved executable} session init --json`); preserve that exact executable for every later CLI call in this chat. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered `session_token` provably equals the one the Stop hook increments — authoritative for checkpoint and wrapup writes. + - **Fallback when no injected context exists — Gemini and Codex:** resolve the executable once and run `{resolved executable} session init --json`; preserve that exact fallback executable for later non-identity CLI calls. Treat its result as startup display metadata only, not proof that its `session_token` identifies this chat — Gemini has no harness-session env var in the resolver, and a terminal- or process-derived fallback token is not chat-unique on Codex either. Keep checkpoint and wrapup writes disabled for that fallback session; never substitute a terminal, process, or manually generated token for an injected token. + Parse injected metadata or the permitted fallback JSON; store `DATETIME` (for greeting), `session_token` (for checkpoints — authoritative when injected on any harness, or from the Claude fallback above; left unset and disabled on a Gemini or Codex fallback), `search_unembedded`, and `headless` in context. **Read `search_unembedded` (canonical, CLI v3.4.5+); if that key is absent, fall back to the legacy `qmd_unembedded` key** — older CLIs emit only `qmd_unembedded`, while v3.4.5 emits both keys with the same value during the transition. JSON shape: `{"datetime":"Ddd · DD Mon YYYY · HH:MM","session_token":"XXXXX","search_unembedded":N|null,"qmd_unembedded":N|null,"headless":true|false}`. The value is a count `N`, or **`null`** when the search probe couldn't determine it (index missing / timed out / unparseable) — treat `null` as **unknown**, distinct from a genuine `0` (a real index with nothing pending). **CLI v3.1+ requires `--json` because the default output flipped to text.** The v3.0 alias `onebrain session-init` still works and now auto-rewrites to include `--json` when `onebrain plugin update` runs. If injected metadata is missing and the fallback command also fails or is unavailable, use `date '+%a · %d %b %Y · %H:%M'` for `DATETIME`, treat `search_unembedded` as `null` and `headless` as `false`, leave `session_token` unset, and keep checkpoint/wrapup writes disabled. If metadata reports `{"decision":"block","reason":"onebrain-vault-not-found"}` (CLI v3.1+) or `"reason":"onebrain-init-required"` (CLI v3.0 back-compat), skip Steps 2–4; instead output a single message: "OneBrain vault not initialized. Run `/onboarding` to set up your vault." - **If `headless` is `true`** (CLI v3.2.6+, set by `onebrain skill run`): this is an unattended one-shot run, so **skip Steps 2–4 entirely** — no greeting, no startup status, and none of Step 3's memory/inbox/task/orphan/pause scans — and proceed directly to the invoked skill. The `headless` field is absent on older CLIs; treat absent as `false` (normal interactive startup). **Step 2 — Send greeting immediately:** @@ -250,7 +250,7 @@ Ddd · DD Mon YYYY · HH:MM On weekends: lighter, less task-focused tone. **No-repeat rule:** don't ask about facts already in context. **Step 3 — After greeting (run all in parallel):** -- `session_token` is already in context when Step 1 received SessionStart-injected metadata, or — on Claude/Gemini — from the Step 1 fallback `session init --json` call; do not re-run detection or initialize a second time. On Codex, when injected metadata is absent, checkpoint/wrapup writes remain disabled regardless of any fallback display metadata. +- `session_token` is already in context when Step 1 received SessionStart-injected metadata, or — on Claude — from the Step 1 fallback `ONEBRAIN_HOOK_SESSION_ID`-derived `session init --json` call; do not re-run detection or initialize a second time. On Gemini and Codex, when injected metadata is absent, checkpoint/wrapup writes remain disabled regardless of any fallback display metadata. - Read `[agent_folder]/MEMORY-INDEX.md` → load memory file index for lazy-loading - Load `memory/` files only when the user's first message matches their MEMORY-INDEX.md Topics keywords (`status: active` or `needs-review` only). Do not eagerly load every active project; a generic greeting must not pull project memory into context. - Glob `[inbox_folder]/*.md` → count files as `inbox_count` @@ -261,7 +261,7 @@ On weekends: lighter, less task-focused tone. **No-repeat rule:** don't ask abou "[projects_folder]"` and `path: "[inbox_folder]"`, pattern `- \[ \] .*📅 [0-9]{4}-[0-9]{2}-[0-9]{2}`, `output_mode: "content"` — combine, keep dates ≤ today. (Fallback still shows fenced demo lines; upgrade the CLI to remove them.) -- When context holds an authoritative `session_token` and its exact executable (injected metadata on any harness, or the Claude/Gemini Step 1 fallback), run `{that exact executable} checkpoint orphans "[logs_folder]" "[session_token]" --json` (from vault root) → parse JSON output; read `orphan_count` field. JSON shape: `{"orphan_count":N}`. **CLI v3.1+ requires `--json`** (default is now text). The v3.0 alias `onebrain orphan-scan` still works and is auto-rewritten by `onebrain plugin update`. Without an authoritative `session_token` (Codex without injected metadata), skip this CLI call and use the structure-aware glob fallback: if `[logs_folder]/checkpoint/` exists, glob `[logs_folder]/checkpoint/*-checkpoint-*.md` (post-v2.4.0 flat layout); else glob `[logs_folder]/**/*-checkpoint-*.md` (pre-v2.4.0 nested layout — multi-vault user on an unmigrated vault). Then discard files whose date has a non-auto-saved session log (look in `[logs_folder]/session/YYYY/MM/` for post-v2.4.0, or `[logs_folder]/YYYY/MM/` for pre-v2.4.0), and count distinct session tokens among remaining files. +- When context holds an authoritative `session_token` and its exact executable (injected metadata on any harness, or the Claude Step 1 fallback), run `{that exact executable} checkpoint orphans "[logs_folder]" "[session_token]" --json` (from vault root) → parse JSON output; read `orphan_count` field. JSON shape: `{"orphan_count":N}`. **CLI v3.1+ requires `--json`** (default is now text). The v3.0 alias `onebrain orphan-scan` still works and is auto-rewritten by `onebrain plugin update`. Without an authoritative `session_token` (Gemini or Codex without injected metadata), skip this CLI call and use the structure-aware glob fallback: if `[logs_folder]/checkpoint/` exists, glob `[logs_folder]/checkpoint/*-checkpoint-*.md` (post-v2.4.0 flat layout); else glob `[logs_folder]/**/*-checkpoint-*.md` (pre-v2.4.0 nested layout — multi-vault user on an unmigrated vault). Then discard files whose date has a non-auto-saved session log (look in `[logs_folder]/session/YYYY/MM/` for post-v2.4.0, or `[logs_folder]/YYYY/MM/` for pre-v2.4.0), and count distinct session tokens among remaining files. - Glob `[logs_folder]/scheduler/**/*.err.md` and keep files modified within the last 24 h → count as `sched_err_count`; note the most recent file's skill name as `sched_err_skill`. (Filesystem only — never probe the OS scheduler at startup; /doctor owns the deep check.) - Read `[logs_folder]/pause/_active.md` if present → parse single-line content as `active_pause_slug`. If absent, set `active_pause_slug = null`. Then if non-null: glob `[logs_folder]/pause/*-{active_pause_slug}-pause-*.md` and count them as `active_pause_count`; read the latest file's `date` frontmatter as `active_pause_last_date`. - **Legacy structure detection (post-v2.4.0):** Check whether `[logs_folder]/session/` exists (any of the new top-level subfolders works as a sentinel; `session/` is the most representative). If it does NOT exist AND `[logs_folder]/YYYY/` does exist (legacy structure pre-v2.4.0), set `vault_structure_legacy = true`. If both `session/` and a legacy `YYYY/` exist (partial migration), still treat `vault_structure_legacy = false` — `/update` will resume cleanup on next run. If neither exists (fresh vault), `vault_structure_legacy = false`. @@ -331,19 +331,19 @@ If the user closes the session without any end-of-session signal, AUTO-SUMMARY d ### Auto Checkpoint (Hook-Triggered) -> **What is session_token?** A chat-unique identifier. On all harnesses it is supplied in the SessionStart metadata from the shared `onebrain hook` bridge and kept in context, alongside the exact CLI executable that every harness must preserve. If that injected token or executable is missing (including after compact): on **Claude/Gemini**, recover it the same way Step 1's fallback does — run `{resolved executable} session init --json` — its `session_token` derives from harness environment (e.g. `CLAUDE_CODE_SESSION_ID`), is chat-unique, and is authoritative for checkpoint/wrapup exactly like an injected token. On **Codex**, do not resolve a replacement from terminal, process, or day state — checkpoint and wrapup writes stay disabled because nothing else proves chat identity; never mint or substitute a token. If token resolution fails under either rule, abort checkpoint silently; never guess. +> **What is session_token?** A chat-unique identifier. On all harnesses it is supplied in the SessionStart metadata from the shared `onebrain hook` bridge and kept in context, alongside the exact CLI executable that every harness must preserve. If that injected token or executable is missing (including after compact): on **Claude**, recover it the same way Step 1's fallback does — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json` — `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered `session_token` provably equals the one the Stop hook increments — authoritative for checkpoint/wrapup exactly like an injected token. On **Gemini and Codex**, do not resolve a replacement from terminal, process, or day state — checkpoint and wrapup writes stay disabled because nothing else proves chat identity (Gemini has no harness-session env var in the resolver; Codex terminal fallbacks mix chats) — never mint or substitute a token. If token resolution fails under either rule, abort checkpoint silently; never guess. -When a hook sends a message whose reason matches `NN since ` (a zero-padded two-digit number followed by ` since start` or ` since checkpoint-NN`), silently spawn a background agent (mode: bypassPermissions) to write the checkpoint. The background agent receives the resolved `session_token` from context — injected on any harness, or, on Claude/Gemini, recovered by re-running `session init --json` if not yet in context — today's date (YYYY-MM-DD), NN from the reason, `[logs_folder]`, and enough conversation context to write a meaningful checkpoint. On Codex, if the injected identity is unavailable, abort silently rather than running `session init` to mint a replacement. +When a hook sends a message whose reason matches `NN since ` (a zero-padded two-digit number followed by ` since start` or ` since checkpoint-NN`), silently spawn a background agent (mode: bypassPermissions) to write the checkpoint. The background agent receives the resolved `session_token` from context — injected on any harness, or, on Claude, recovered by re-running the `ONEBRAIN_HOOK_SESSION_ID`-derived `session init --json` if not yet in context — today's date (YYYY-MM-DD), NN from the reason, `[logs_folder]`, and enough conversation context to write a meaningful checkpoint. On Gemini and Codex, if the injected identity is unavailable, abort silently rather than running `session init` to mint a replacement. The agent writes to `[logs_folder]/checkpoint/YYYY-MM-DD-{session_token}-checkpoint-NN.md` (post-v2.4.0: flat directory). **Always run `mkdir -p [logs_folder]/checkpoint/` before writing** — fresh post-onboarding vaults that haven't run `/update` yet won't have the directory; without the mkdir the Write tool fails silently and the checkpoint is lost. No output to user. The Stop hook is the only checkpoint signal source. It emits `decision:"block",reason:"NN since "` whenever the message-count threshold (default 15) or time threshold (default 30 min) is met. The agent dispatches a background sub-agent to write a checkpoint file, then continues. Determine action from the reason: -- `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → if a resolved `session_token` is in context (injected on any harness, or — on Claude/Gemini only — recoverable by re-running `session init --json`), spawn a background agent (mode: bypassPermissions) to write the checkpoint; on Codex, if no injected `session_token` is in context, abort silently rather than minting one. The agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching +- `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → if a resolved `session_token` is in context (injected on any harness, or — on Claude only — recoverable by re-running the `ONEBRAIN_HOOK_SESSION_ID`-derived `session init --json`), spawn a background agent (mode: bypassPermissions) to write the checkpoint; on Gemini and Codex, if no injected `session_token` is in context, abort silently rather than minting one. The agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching - Ambiguous or unknown → default to stop checkpoint -All three supported harnesses route lifecycle events through the same `onebrain hook` command — Codex and Gemini wrap it in a shell-level `|| echo` fail-open fallback (inert when the CLI is missing or older than 3.4.25), while Claude registers it bare alongside its separate `check-cli-version.sh` gate. The installed CLI selects session initialization, incremental search reindexing, checkpointing, and pending embedding from the stdin `hook_event_name`; Codex has exactly one Stop registration, so checkpoint and embedding work are not duplicated. PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly through the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. +All three supported harnesses route lifecycle events through the same `onebrain hook` command wrapped in a shell-level `|| echo` fail-open fallback (inert when the CLI is missing or older than 3.4.25); Claude's separate `check-cli-version.sh` SessionStart gate remains the user-facing messaging layer, so Claude's wrapped `onebrain hook` entry falls back to a bare `{}` rather than repeating the version-check message. The installed CLI selects session initialization, incremental search reindexing, checkpointing, and pending embedding from the stdin `hook_event_name`; Codex has exactly one Stop registration, so checkpoint and embedding work are not duplicated. PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly through the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. After upgrading this plugin, start a new agent session so the new registrations are loaded. The old `codex-hook` alias is intentionally absent. diff --git a/.claude/plugins/onebrain/hooks/hooks.json b/.claude/plugins/onebrain/hooks/hooks.json index 2a326a76..1066af07 100644 --- a/.claude/plugins/onebrain/hooks/hooks.json +++ b/.claude/plugins/onebrain/hooks/hooks.json @@ -15,7 +15,7 @@ "hooks": [ { "type": "command", - "command": "onebrain hook", + "command": "onebrain hook || echo '{}'", "timeout": 10 } ] diff --git a/.claude/plugins/onebrain/skills/pause/SKILL.md b/.claude/plugins/onebrain/skills/pause/SKILL.md index dc0cfe29..d1c909c3 100644 --- a/.claude/plugins/onebrain/skills/pause/SKILL.md +++ b/.claude/plugins/onebrain/skills/pause/SKILL.md @@ -64,8 +64,8 @@ Review the current conversation since the last pause file of `active_slug` (or s 1. Today's date as `YYYY-MM-DD`. 2. Ensure directory exists: `mkdir -p [logs_folder]/pause/` 3. Get `session_token` from agent context. - - **Claude/Gemini:** if missing, run `onebrain session init --json` to recover it. - - **Codex:** SessionStart injects the token; if missing, do **not** run `session init` to mint a replacement — a terminal- or process-derived token could merge another chat's snapshots. + - **Claude:** if missing, recover it the same way INSTRUCTIONS.md Step 1's fallback does — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json`. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered value provably equals the token the Stop hook incremented. + - **Codex and Gemini:** SessionStart injects the token; if missing, do **not** run `session init` to mint a replacement — a terminal- or process-derived token could merge another chat's snapshots. **If no token is available after applying the rule above:** abort the write. Do NOT proceed to Step 5. Output: `⚠️ Could not determine session token. Snapshot not saved — try again or run /doctor.` 4. Write to `[logs_folder]/pause/YYYY-MM-DD-{active_slug}-pause-{next_nn}.md`: diff --git a/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md b/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md index ad6cb442..b18c0eb1 100644 --- a/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md +++ b/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md @@ -8,9 +8,9 @@ Run silently (no output) if ALL of these are true: 3. The session had 3 or more user↔assistant exchanges If conditions are met: -- Use `session_token` from context if already loaded (set by `onebrain session init` at startup). - - **Codex:** SessionStart injects a token derived from the complete hook `session_id`; it is the chat identity for this auto-summary. If that injected token is absent, abort auto-summary silently (checkpoint files stay in place — /wrapup recovers them at the next session start) rather than falling back to a terminal- or process-derived token, since that could merge another chat's checkpoints. - - **Claude/Gemini:** if absent, run `onebrain session init --json` and use the `SESSION_TOKEN` value. +- Use `session_token` from context if already loaded (set by `{resolved executable} session init` at startup). + - **Codex and Gemini:** SessionStart injects a token derived from the complete hook `session_id`; it is the chat identity for this auto-summary. If that injected token is absent, abort auto-summary silently (checkpoint files stay in place — /wrapup recovers them at the next session start) rather than falling back to a terminal- or process-derived token, since that could merge another chat's checkpoints. + - **Claude:** if absent, recover it the same way INSTRUCTIONS.md Step 1's fallback does — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json` and use the `session_token` value. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered value provably equals the token the Stop hook incremented. Glob checkpoint files (post-v2.4.0: checkpoints live in flat `[logs_folder]/checkpoint/` regardless of date): `[logs_folder]/checkpoint/YYYY-MM-DD-{session_token}-checkpoint-*.md`. Also yesterday's (handles cross-midnight sessions): compute yesterday's date (accounting for month/year rollover) and glob `[logs_folder]/checkpoint/YYYY-MM-DD_PREV-{session_token}-checkpoint-*.md`. **Read every file in the glob result** and fully incorporate all of their content into the session summary (not just as background context). Any checkpoint file that exists is unmerged by definition — there is no `merged:` filter. Every checkpoint must appear in the summary before it is deleted. - Determine NN: count existing `[logs_folder]/session/YYYY/MM/YYYY-MM-DD-session-*.md` files for today; NN = count + 1, zero-padded to 2 digits (01, 02, …). **Verify** `YYYY-MM-DD-session-NN.md` does not already exist before writing; if it does, increment NN until a free slot is found. @@ -47,7 +47,7 @@ If conditions are met: - Write the file once. On write error, skip all tasks for this file silently and continue to the next target file. - After confirming the session log was written, reset the checkpoint hook counter to prevent spurious post-summary checkpoints, passing the resolved `session_token` from above (never a re-minted one): ```bash - onebrain checkpoint reset --session-token {session_token} + {resolved executable} checkpoint reset --session-token {session_token} ``` - Delete the checkpoint files from the glob above. Guard: only delete AFTER confirming the session log file was successfully written and is non-empty. Never delete before or during the write. If an individual delete fails, skip it silently — stale checkpoints are cleaned up later by /doctor or by the next /wrapup. Do not delete checkpoint files outside this session's glob result. - If a genuinely useful long-term insight emerged, write it to a new `memory/` file using /learn conventions: filename `[agent_folder]/memory/kebab-case-topic.md`, frontmatter `tags: [agent-memory], type: behavioral, source: auto-summary, status: active, conf: medium, verified: today, updated: today, created: today, topics: [2–4 keywords]`. Add a row to MEMORY-INDEX.md and increment `total_active`. **Do not write to MEMORY.md.** diff --git a/.claude/plugins/onebrain/skills/startup/references/session-formats.md b/.claude/plugins/onebrain/skills/startup/references/session-formats.md index a8da4ba5..9c592efa 100644 --- a/.claude/plugins/onebrain/skills/startup/references/session-formats.md +++ b/.claude/plugins/onebrain/skills/startup/references/session-formats.md @@ -62,7 +62,7 @@ trigger: stop --- ``` -`session_token` is the same token embedded in the filename — duplicating it in frontmatter lets `/wrapup`, `/doctor`, and `checkpoint orphans` filter checkpoints by token without parsing filenames. The agent (per `INSTRUCTIONS.md` Auto Checkpoint section) sets this from the `session_token` already in context — injected at session start on any harness, or, on Claude/Gemini only, recovered via `onebrain session init` if missing; on Codex a missing token aborts the checkpoint instead (see `INSTRUCTIONS.md` for the full per-harness rule). +`session_token` is the same token embedded in the filename — duplicating it in frontmatter lets `/wrapup`, `/doctor`, and `checkpoint orphans` filter checkpoints by token without parsing filenames. The agent (per `INSTRUCTIONS.md` Auto Checkpoint section) sets this from the `session_token` already in context — injected at session start on any harness, or, on Claude only, recovered via the `ONEBRAIN_HOOK_SESSION_ID`-derived `session init` if missing; on Gemini and Codex a missing token aborts the checkpoint instead (see `INSTRUCTIONS.md` for the full per-harness rule). **Body:** use Shared Body Sections above. `## What We Worked On`: 2-3 sentences describing the session focus. diff --git a/.claude/plugins/onebrain/skills/wrapup/SKILL.md b/.claude/plugins/onebrain/skills/wrapup/SKILL.md index 3533b658..555e38f9 100644 --- a/.claude/plugins/onebrain/skills/wrapup/SKILL.md +++ b/.claude/plugins/onebrain/skills/wrapup/SKILL.md @@ -60,9 +60,9 @@ After Step 0b, continue to Step 1. ## Step 1: Gather Checkpoint Context 1. Get today's date as `YYYY-MM-DD`. Extract `YYYY` and `MM`. -2. Use `session_token` from context if already loaded (set by `onebrain session init` at startup). - - **Codex:** SessionStart injects a token derived from the complete hook `session_id`. It is the chat identity for both Stop checkpoints and this wrapup. If that injected token is absent, stop and report that Codex hook context is missing; do **not** fall back to a terminal- or process-derived token because that could merge another chat's checkpoints. - - **Claude/Gemini:** if absent, run `onebrain session init --json` and use the `SESSION_TOKEN` value. +2. Use `session_token` from context if already loaded (set by `{resolved executable} session init` at startup). + - **Codex and Gemini:** SessionStart injects a token derived from the complete hook `session_id`. It is the chat identity for both Stop checkpoints and this wrapup. If that injected token is absent, stop and report that hook context is missing; do **not** fall back to a terminal- or process-derived token because that could merge another chat's checkpoints. + - **Claude:** if absent, recover it the same way INSTRUCTIONS.md Step 1's fallback does — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json` and use the `session_token` value. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered value provably equals the token the Stop hook incremented. 3. Glob checkpoint files (post-v2.4.0: checkpoints live in flat `[logs_folder]/checkpoint/` regardless of date): - **Match on the token, with NO date filter:** `[logs_folder]/checkpoint/*-{session_token}-checkpoint-*.md` @@ -485,7 +485,7 @@ Reflect on the conversation that just occurred. Identify: --- ``` 5. Body: merged content from step 2, using the Shared Body Sections. -6. After successful write, run `onebrain checkpoint reset --session-token {session_token}`. +6. After successful write, run `{resolved executable} checkpoint reset --session-token {session_token}`. 7. Proceed to Step 4b (action item routing) and onward as normal. After Thread Wrapup writes the session log, the existing Step 5 (Checkpoint Cleanup) still runs — checkpoints from Step 1 are deleted. **Plus, in the new Step 5b (below), pause files and `_active.md` are deleted.** @@ -509,7 +509,7 @@ Create `[logs_folder]/session/YYYY/MM/YYYY-MM-DD-session-NN.md` using the Sessio After writing the session log, reset the checkpoint hook counter to prevent spurious post-wrapup checkpoints, passing the resolved `session_token` from Step 1 (never a re-minted one): ```bash -onebrain checkpoint reset --session-token {session_token} +{resolved executable} checkpoint reset --session-token {session_token} ``` This writes `0::00` into the session state file (3 fields: count, last_ts, last_stop_nn) — triggering a 60-second skip window and resetting the message counter so the next Stop hook starts fresh. diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ae9bde..8195fc5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Codex hook manifests call the cache-independent runner in OneBrain CLI v3.4.25+, so an active task no longer retains a dead path when the plugin cache refreshes. - Codex reuses SessionStart metadata, loads project memory only on topic matches, and bounds startup tasks to five while retaining the full count. - Codex, Claude, and Gemini lifecycle registrations now converge on one `onebrain hook` command; it selects the action from the stdin `hook_event_name`, and Codex keeps a single Stop entry so checkpoint work is not duplicated. Start a new agent session after upgrading because the old `codex-hook` alias is intentionally absent. -- The chat identity contract is harness-split: Claude/Gemini may recover a token via `session init --json`, Codex never mints one, and `checkpoint reset` now passes `--session-token {session_token}` so wrapup resets the same cadence counter the Stop hook incremented (needs CLI 3.4.25). -- Codex and Gemini hook commands now wrap `onebrain hook` in a shell-level `|| echo` fallback, so rolling the CLI back below 3.4.25 degrades to an inert warning instead of a blocking exit code (Claude already has the `check-cli-version.sh` gate). +- The chat identity contract is harness-split: Claude recovers a missing token via the `ONEBRAIN_HOOK_SESSION_ID`-derivation layer (provably the same sha256[..16] token the Stop hook increments), Gemini and Codex never mint one, and `checkpoint reset` now passes `--session-token {session_token}` so wrapup resets the same cadence counter the Stop hook incremented (needs CLI 3.4.25). +- All three harnesses' hook commands now wrap `onebrain hook` in a shell-level `|| echo` fail-open fallback, so rolling the CLI back below 3.4.25 degrades to an inert warning (or empty object) instead of a blocking exit code — Claude's `check-cli-version.sh` gate remains the user-facing messaging layer. ## v3.4.11 — 2026-07-30 — /digest first run is a complete setup chain diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 168575b4..5db6f513 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -241,7 +241,7 @@ Agents are stateless — they receive all context in the prompt payload and do n Hooks run shell commands automatically when the harness performs certain actions. For Claude Code, hook configuration lives in the vault's `.claude/settings.json`; shell scripts (for PostToolUse hooks) go in `.claude/plugins/onebrain/hooks/`. For Gemini CLI, hooks live declaratively in `.gemini/settings.json` (under the `hooks` key). -OneBrain registers the same bare `onebrain hook` lifecycle command across Codex (`SessionStart`, `PostToolUse`, `Stop`), Claude (`SessionStart` alongside the version check, plus its independent PreToolUse hooks), and Gemini (`SessionStart`, `AfterTool`, `AfterAgent`). The installed CLI selects session init, search reindex, checkpoint, or pending embedding from stdin `hook_event_name`; keep one Stop/AfterAgent entry so checkpoint work is not duplicated. Reference tables below list every event each harness supports — useful when adding new hooks or porting between harnesses. +OneBrain registers the same `onebrain hook` lifecycle command, wrapped in a shell-level `|| echo` fail-open fallback on every harness, across Codex (`SessionStart`, `PostToolUse`, `Stop`), Claude (`SessionStart` alongside the version check, plus its independent PreToolUse hooks), and Gemini (`SessionStart`, `AfterTool`, `AfterAgent`); Claude's separate `check-cli-version.sh` gate remains the user-facing messaging layer, so its wrapped entry falls back to a bare `{}` rather than repeating the version-check message. The installed CLI selects session init, search reindex, checkpoint, or pending embedding from stdin `hook_event_name`; keep one Stop/AfterAgent entry so checkpoint work is not duplicated. Reference tables below list every event each harness supports — useful when adding new hooks or porting between harnesses. **Claude Code hook events:** @@ -288,7 +288,7 @@ Most Claude hooks support a `matcher` field to filter by tool name or event subt | `PreCompress` | Before chat history compression | `PreCompact` | | `Notification` | On notification events | `Notification` | -Tool-name matchers in Gemini accept regex (e.g. `write_file|replace`) — they match Gemini's actual tool names (`read_file`, `write_file`, `replace`, `run_shell_command`, ...), NOT Claude's names (`Read`, `Write`, `Edit`, `Bash`, ...). The shared `onebrain hook` command emits `{}` on stdout for tool and unsupported events, satisfying Gemini's JSON protocol without shell redirects or fallback wrappers. +Tool-name matchers in Gemini accept regex (e.g. `write_file|replace`) — they match Gemini's actual tool names (`read_file`, `write_file`, `replace`, `run_shell_command`, ...), NOT Claude's names (`Read`, `Write`, `Edit`, `Bash`, ...). The shared `onebrain hook` command emits `{}` on stdout for tool and unsupported events, satisfying Gemini's JSON protocol; each registration additionally wraps the command in a shell-level `|| echo '{}'` fail-open fallback so a missing or pre-3.4.25 CLI degrades to a harmless no-op instead of a blocking exit code. **Example — checkpoint system:** OneBrain's checkpoint system uses the shared `onebrain hook` command on each harness's Stop-equivalent event to auto-save session snapshots. The CLI bridge calls `onebrain checkpoint stop` internally, tracks message count + elapsed time against configurable thresholds, and emits a `decision:block` JSON payload when a checkpoint is due. State is kept in `$TMPDIR/onebrain-{session_token}.state` (format: `count:last_ts:last_stop_nn`) so counts accumulate across responses, including across compact events. diff --git a/GEMINI.md b/GEMINI.md index 3fdacddd..2df51b0d 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -13,7 +13,7 @@ shared instructions load. Hooks and slash commands are defined at the project root in `.gemini/`: -- `.gemini/settings.json` — declarative lifecycle hooks (`SessionStart`, `AfterTool` for `write_file|replace`, and `AfterAgent`) all call the bare `onebrain hook` bridge, which selects behavior from stdin and emits Gemini-compatible `{}` for no-op/tool events; no shell redirect or fallback wrapper is needed. `model.disableLoopDetection: true` keeps legitimate multi-file skill activations from tripping Gemini's repetitive-tool-call heuristic. Version of this content is tracked in the unified `plugin.json` alongside the Claude plugin. +- `.gemini/settings.json` — declarative lifecycle hooks (`SessionStart`, `AfterTool` for `write_file|replace`, and `AfterAgent`) all call the `onebrain hook` bridge wrapped in a shell-level `|| echo` fail-open fallback (inert once the installed CLI is 3.4.25+; degrades to a harmless stand-in payload when the CLI is missing or older), which otherwise selects behavior from stdin and emits Gemini-compatible `{}` for no-op/tool events. `model.disableLoopDetection: true` keeps legitimate multi-file skill activations from tripping Gemini's repetitive-tool-call heuristic. Version of this content is tracked in the unified `plugin.json` alongside the Claude plugin. - `.gemini/commands/onebrain/*.toml` — 24 user-facing slash commands under the `onebrain:` namespace (`/onebrain:braindump`, `/onebrain:capture`, `/onebrain:research`, ...) that activate the matching skill. Namespacing avoids collisions with Gemini built-ins (`/help`, `/tasks`) and mirrors the Claude plugin path (`.claude/plugins/onebrain/`) Skills, agents, INSTRUCTIONS, and tool-mapping references all live inside the Claude plugin tree at `.claude/plugins/onebrain/...`. The agent reads them on demand via the paths referenced from each TOML's prompt — no duplication needed. diff --git a/scripts/check-lifecycle-hooks.py b/scripts/check-lifecycle-hooks.py index 7641cf3e..c535c6e3 100644 --- a/scripts/check-lifecycle-hooks.py +++ b/scripts/check-lifecycle-hooks.py @@ -20,13 +20,23 @@ # (no `hook` subcommand, clap exits 2 — the blocking exit code), `||` catches it # and echoes a harmless stand-in payload instead of letting the harness see a # nonzero exit. Codex and Gemini have no version-gate hook (unlike Claude's -# check-cli-version.sh SessionStart hook), so they need this at the shell level. +# check-cli-version.sh SessionStart hook), so they need the message-carrying +# variant at the shell level for SessionStart. Claude's SessionStart already +# runs check-cli-version.sh as its own hook entry to carry that message, so +# Claude's generic `onebrain hook` entry only needs the plain empty-object +# fallback (below) — wrapped for the same rollback safety, but silent because +# the gate script is the user-facing messaging layer. FAIL_OPEN_SESSION_START_COMMAND = ( "onebrain hook || echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\"," "\"additionalContext\":\"OneBrain lifecycle hooks are inactive: the onebrain CLI " "is missing or older than 3.4.25. Run onebrain update, then start a new session." "\"}}'" ) +# commandWindows constants below are asserted byte-exactly (see +# assert_fail_open_command) but never executed on this runner — the rollback +# smokes below only shell out to the POSIX `command` string. There is no +# Windows CI runner for this script, so the cmd.exe-shaped fallback strings +# are pinned by string comparison only, not by an actual cmd.exe execution. FAIL_OPEN_SESSION_START_COMMAND_WINDOWS = ( "onebrain hook || echo {\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\"," "\"additionalContext\":\"OneBrain lifecycle hooks are inactive: the onebrain CLI " @@ -59,6 +69,10 @@ def assert_fail_open_command( assert command["commandWindows"] == expected_windows, ( f"{event} Windows hook must call {expected_windows!r}" ) + else: + assert "commandWindows" not in command, ( + f"{event} must not define commandWindows" + ) codex_manifest = json.loads(CODEX_HOOKS_PATH.read_text(encoding="utf-8")) @@ -83,8 +97,10 @@ def assert_fail_open_command( ), ( "Claude SessionStart must retain the CLI version check" ) -assert sum(command["command"] == "onebrain hook" for command in claude_session_commands) == 1, ( - "Claude SessionStart must add exactly one generic lifecycle hook" +assert sum( + command["command"] == FAIL_OPEN_EMPTY_COMMAND for command in claude_session_commands +) == 1, ( + "Claude SessionStart must add exactly one wrapped generic lifecycle hook" ) assert [ command["command"] @@ -199,7 +215,7 @@ def run( assert json.loads(stop.stdout) == {"decision": "block", "reason": "checkpoint due"} def expected_claude_command(event: str) -> str: - return "onebrain hook" + return FAIL_OPEN_EMPTY_COMMAND def expected_gemini_command(event: str) -> str: return GEMINI_SESSION_START_COMMAND if event == "SessionStart" else GEMINI_EMPTY_COMMAND @@ -237,8 +253,8 @@ def expected_gemini_command(event: str) -> str: assert json.loads(output.stdout) == {} # --- Fallback smoke cases: rollback behavior below the CLI floor (POSIX only) --- - # These pin the shell-level `||` fallback that keeps Codex/Gemini fail-open even - # when `onebrain` is missing entirely, or present but too old to have `hook` + # These pin the shell-level `||` fallback that keeps Codex/Gemini/Claude fail-open + # even when `onebrain` is missing entirely, or present but too old to have `hook` # (clap exits 2 on an unrecognized subcommand — the blocking exit code). missing_cli_dir = temp / "missing-cli-path" missing_cli_dir.mkdir() @@ -273,6 +289,17 @@ def expected_gemini_command(event: str) -> str: ) assert json.loads(rollback_stop.stdout) == {} + # Claude's generic SessionStart hook is wrapped the same way — its own + # check-cli-version.sh gate (run separately, not exercised here) carries + # the user-facing message, so this entry only needs to prove it stays + # exit-0 with an empty object instead of surfacing the CLI's exit 2. + rollback_claude = run( + FAIL_OPEN_EMPTY_COMMAND, + {**full_payload, "hook_event_name": "SessionStart"}, + run_env=rollback_env, + ) + assert json.loads(rollback_claude.stdout) == {} + calls = [json.loads(line) for line in calls_path.read_text(encoding="utf-8").splitlines()] assert all(call["args"] == ["hook"] for call in calls) assert all( From e13127165f2d9fa445259cfdc713be23af5c7a56 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 27 Aug 2026 23:59:29 +0700 Subject: [PATCH 13/14] fix(identity): mid-session recovery uses the resolve-only token verb; scope wrapper claims accurately Round-3 audit fixes: - Mid-session Claude token recovery (INSTRUCTIONS.md Auto Checkpoint section, pause/wrapup/AUTO-SUMMARY SKILL.md, session-formats.md) now calls the resolve-only `session token --json` instead of `session init --json`. `session init` deletes the Stop-hook cadence state file whenever it runs mid-session, so recovery must not use it; Step 1's startup fallback keeps `session init` on purpose since a fresh session needs that state-clean side effect and the full startup metadata. - Corrected the inverted fail-open description: the `|| echo` wrapper is inert once the installed CLI is 3.4.25+, and degrades to a harmless stand-in payload when the CLI is missing or older (INSTRUCTIONS.md). - Rescoped "all three harnesses wrapped" claims (CHANGELOG.md, CONTRIBUTING.md, INSTRUCTIONS.md): only Codex's and Gemini's entries plus Claude's SessionStart are plugin-shipped and wrapped in `|| echo`. Claude's PostToolUse/Stop entries are CLI-registered into the vault's .claude/settings.json in exec form and can't carry a shell `||`; those are covered by the check-cli-version.sh SessionStart gate instead. - Dropped stale "bare" wording in migration-steps.md's /update hook registration step, mirroring the corrected CONTRIBUTING.md phrasing. - Quoted the `{resolved executable}` placeholder in every recovery/reset one-liner (POSIX and PowerShell forms) so paths with spaces don't break. --- .claude/plugins/onebrain/INSTRUCTIONS.md | 12 ++++++------ .claude/plugins/onebrain/skills/pause/SKILL.md | 2 +- .../plugins/onebrain/skills/startup/AUTO-SUMMARY.md | 6 +++--- .../skills/startup/references/session-formats.md | 2 +- .../skills/update/references/migration-steps.md | 2 +- .claude/plugins/onebrain/skills/wrapup/SKILL.md | 8 ++++---- CHANGELOG.md | 2 +- CONTRIBUTING.md | 2 +- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.claude/plugins/onebrain/INSTRUCTIONS.md b/.claude/plugins/onebrain/INSTRUCTIONS.md index baa759a4..45c45036 100644 --- a/.claude/plugins/onebrain/INSTRUCTIONS.md +++ b/.claude/plugins/onebrain/INSTRUCTIONS.md @@ -217,8 +217,8 @@ Run before responding to any user message. - Read `[agent_folder]/MEMORY.md` → load identity, personality, active projects - Obtain session startup metadata from the vault root: - **All harnesses with SessionStart-injected metadata:** trust and parse the metadata already collected by the shared `onebrain hook` runner; do **not** run `session init` again. Preserve the injected `session_token` and exact executable path, and use that exact executable for every later CLI call in this chat — never bare `onebrain`. This is the only authoritative chat identity; startup must not invoke the CLI a second time. - - **Fallback when no injected context exists — Claude:** resolve the executable once, then recover identity via the hook's own derivation layer: POSIX `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json` (PowerShell: `$env:ONEBRAIN_HOOK_SESSION_ID = $env:CLAUDE_CODE_SESSION_ID; & {resolved executable} session init --json`); preserve that exact executable for every later CLI call in this chat. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered `session_token` provably equals the one the Stop hook increments — authoritative for checkpoint and wrapup writes. - - **Fallback when no injected context exists — Gemini and Codex:** resolve the executable once and run `{resolved executable} session init --json`; preserve that exact fallback executable for later non-identity CLI calls. Treat its result as startup display metadata only, not proof that its `session_token` identifies this chat — Gemini has no harness-session env var in the resolver, and a terminal- or process-derived fallback token is not chat-unique on Codex either. Keep checkpoint and wrapup writes disabled for that fallback session; never substitute a terminal, process, or manually generated token for an injected token. + - **Fallback when no injected context exists — Claude:** resolve the executable once, then recover identity via the hook's own derivation layer: POSIX `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" "{resolved executable}" session init --json` (PowerShell: `$env:ONEBRAIN_HOOK_SESSION_ID = $env:CLAUDE_CODE_SESSION_ID; & "{resolved executable}" session init --json`); preserve that exact executable for every later CLI call in this chat. This is the Step 1 startup path — `session init` is correct here because its state-clean side effect (resetting the Stop-hook cadence file) is exactly what a fresh session needs, and the fallback needs the full startup metadata `session init` returns. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered `session_token` provably equals the one the Stop hook increments — authoritative for checkpoint and wrapup writes. + - **Fallback when no injected context exists — Gemini and Codex:** resolve the executable once and run `"{resolved executable}" session init --json`; preserve that exact fallback executable for later non-identity CLI calls. Treat its result as startup display metadata only, not proof that its `session_token` identifies this chat — Gemini has no harness-session env var in the resolver, and a terminal- or process-derived fallback token is not chat-unique on Codex either. Keep checkpoint and wrapup writes disabled for that fallback session; never substitute a terminal, process, or manually generated token for an injected token. Parse injected metadata or the permitted fallback JSON; store `DATETIME` (for greeting), `session_token` (for checkpoints — authoritative when injected on any harness, or from the Claude fallback above; left unset and disabled on a Gemini or Codex fallback), `search_unembedded`, and `headless` in context. **Read `search_unembedded` (canonical, CLI v3.4.5+); if that key is absent, fall back to the legacy `qmd_unembedded` key** — older CLIs emit only `qmd_unembedded`, while v3.4.5 emits both keys with the same value during the transition. JSON shape: `{"datetime":"Ddd · DD Mon YYYY · HH:MM","session_token":"XXXXX","search_unembedded":N|null,"qmd_unembedded":N|null,"headless":true|false}`. The value is a count `N`, or **`null`** when the search probe couldn't determine it (index missing / timed out / unparseable) — treat `null` as **unknown**, distinct from a genuine `0` (a real index with nothing pending). **CLI v3.1+ requires `--json` because the default output flipped to text.** The v3.0 alias `onebrain session-init` still works and now auto-rewrites to include `--json` when `onebrain plugin update` runs. If injected metadata is missing and the fallback command also fails or is unavailable, use `date '+%a · %d %b %Y · %H:%M'` for `DATETIME`, treat `search_unembedded` as `null` and `headless` as `false`, leave `session_token` unset, and keep checkpoint/wrapup writes disabled. If metadata reports `{"decision":"block","reason":"onebrain-vault-not-found"}` (CLI v3.1+) or `"reason":"onebrain-init-required"` (CLI v3.0 back-compat), skip Steps 2–4; instead output a single message: "OneBrain vault not initialized. Run `/onboarding` to set up your vault." - **If `headless` is `true`** (CLI v3.2.6+, set by `onebrain skill run`): this is an unattended one-shot run, so **skip Steps 2–4 entirely** — no greeting, no startup status, and none of Step 3's memory/inbox/task/orphan/pause scans — and proceed directly to the invoked skill. The `headless` field is absent on older CLIs; treat absent as `false` (normal interactive startup). @@ -331,19 +331,19 @@ If the user closes the session without any end-of-session signal, AUTO-SUMMARY d ### Auto Checkpoint (Hook-Triggered) -> **What is session_token?** A chat-unique identifier. On all harnesses it is supplied in the SessionStart metadata from the shared `onebrain hook` bridge and kept in context, alongside the exact CLI executable that every harness must preserve. If that injected token or executable is missing (including after compact): on **Claude**, recover it the same way Step 1's fallback does — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json` — `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered `session_token` provably equals the one the Stop hook increments — authoritative for checkpoint/wrapup exactly like an injected token. On **Gemini and Codex**, do not resolve a replacement from terminal, process, or day state — checkpoint and wrapup writes stay disabled because nothing else proves chat identity (Gemini has no harness-session env var in the resolver; Codex terminal fallbacks mix chats) — never mint or substitute a token. If token resolution fails under either rule, abort checkpoint silently; never guess. +> **What is session_token?** A chat-unique identifier. On all harnesses it is supplied in the SessionStart metadata from the shared `onebrain hook` bridge and kept in context, alongside the exact CLI executable that every harness must preserve. If that injected token or executable is missing (including after compact): on **Claude**, recover it with the resolve-only verb — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" "{resolved executable}" session token --json` and read `session_token` from its JSON. Use `session token`, not `session init`, for this mid-session recovery: `session init` deletes the Stop-hook cadence state file whenever it runs mid-session, while `session token` is resolve-only and touches no state files — it applies the same resolution chain, including the top-priority `ONEBRAIN_HOOK_SESSION_ID` layer, so the recovered `session_token` provably equals the one the Stop hook increments — authoritative for checkpoint/wrapup exactly like an injected token. On **Gemini and Codex**, do not resolve a replacement from terminal, process, or day state — checkpoint and wrapup writes stay disabled because nothing else proves chat identity (Gemini has no harness-session env var in the resolver; Codex terminal fallbacks mix chats) — never mint or substitute a token. If token resolution fails under either rule, abort checkpoint silently; never guess. -When a hook sends a message whose reason matches `NN since ` (a zero-padded two-digit number followed by ` since start` or ` since checkpoint-NN`), silently spawn a background agent (mode: bypassPermissions) to write the checkpoint. The background agent receives the resolved `session_token` from context — injected on any harness, or, on Claude, recovered by re-running the `ONEBRAIN_HOOK_SESSION_ID`-derived `session init --json` if not yet in context — today's date (YYYY-MM-DD), NN from the reason, `[logs_folder]`, and enough conversation context to write a meaningful checkpoint. On Gemini and Codex, if the injected identity is unavailable, abort silently rather than running `session init` to mint a replacement. +When a hook sends a message whose reason matches `NN since ` (a zero-padded two-digit number followed by ` since start` or ` since checkpoint-NN`), silently spawn a background agent (mode: bypassPermissions) to write the checkpoint. The background agent receives the resolved `session_token` from context — injected on any harness, or, on Claude, recovered by re-running the `ONEBRAIN_HOOK_SESSION_ID`-derived `session token --json` if not yet in context — today's date (YYYY-MM-DD), NN from the reason, `[logs_folder]`, and enough conversation context to write a meaningful checkpoint. On Gemini and Codex, if the injected identity is unavailable, abort silently rather than running `session token` to mint a replacement. The agent writes to `[logs_folder]/checkpoint/YYYY-MM-DD-{session_token}-checkpoint-NN.md` (post-v2.4.0: flat directory). **Always run `mkdir -p [logs_folder]/checkpoint/` before writing** — fresh post-onboarding vaults that haven't run `/update` yet won't have the directory; without the mkdir the Write tool fails silently and the checkpoint is lost. No output to user. The Stop hook is the only checkpoint signal source. It emits `decision:"block",reason:"NN since "` whenever the message-count threshold (default 15) or time threshold (default 30 min) is met. The agent dispatches a background sub-agent to write a checkpoint file, then continues. Determine action from the reason: -- `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → if a resolved `session_token` is in context (injected on any harness, or — on Claude only — recoverable by re-running the `ONEBRAIN_HOOK_SESSION_ID`-derived `session init --json`), spawn a background agent (mode: bypassPermissions) to write the checkpoint; on Gemini and Codex, if no injected `session_token` is in context, abort silently rather than minting one. The agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching +- `Stop hook blocking error` with reason matching `NN since ` (zero-padded NN + ` since start` or ` since checkpoint-NN`) → if a resolved `session_token` is in context (injected on any harness, or — on Claude only — recoverable by re-running the `ONEBRAIN_HOOK_SESSION_ID`-derived `session token --json`), spawn a background agent (mode: bypassPermissions) to write the checkpoint; on Gemini and Codex, if no injected `session_token` is in context, abort silently rather than minting one. The agent receives session_token, NN and since-context from the reason, today's date, and `[logs_folder]`; main session continues immediately after dispatching - Ambiguous or unknown → default to stop checkpoint -All three supported harnesses route lifecycle events through the same `onebrain hook` command wrapped in a shell-level `|| echo` fail-open fallback (inert when the CLI is missing or older than 3.4.25); Claude's separate `check-cli-version.sh` SessionStart gate remains the user-facing messaging layer, so Claude's wrapped `onebrain hook` entry falls back to a bare `{}` rather than repeating the version-check message. The installed CLI selects session initialization, incremental search reindexing, checkpointing, and pending embedding from the stdin `hook_event_name`; Codex has exactly one Stop registration, so checkpoint and embedding work are not duplicated. PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly through the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. +All three supported harnesses route lifecycle events through the same `onebrain hook` command. Every plugin-shipped registration — all of Codex's and Gemini's entries, plus Claude's `SessionStart` — wraps that command in a shell-level `|| echo` fail-open fallback: inert once the installed CLI is 3.4.25+, degrading to a harmless stand-in payload when the CLI is missing or older. Claude's own `PostToolUse` and `Stop` entries are CLI-registered directly into the vault's `.claude/settings.json` in exec form (`command:"onebrain", args:["hook"]`) and can't carry a shell `||`, so they're covered instead by the separate `check-cli-version.sh` SessionStart gate, which remains the user-facing messaging layer — Claude's wrapped `SessionStart` entry falls back to a bare `{}` rather than repeating the version-check message. The installed CLI selects session initialization, incremental search reindexing, checkpointing, and pending embedding from the stdin `hook_event_name`; Codex has exactly one Stop registration, so checkpoint and embedding work are not duplicated. PreCompact and PostCompact are not registered: PreCompact's `decision:"block"` aborts the compact entirely (bad UX), and Claude Code's PostCompact is observational-only — its stdout cannot reach the agent. Compact events (auto or manual) are observed indirectly through the Stop hook's accumulated message count, which carries across compacts and drives the next checkpoint emission via the normal threshold logic. After upgrading this plugin, start a new agent session so the new registrations are loaded. The old `codex-hook` alias is intentionally absent. diff --git a/.claude/plugins/onebrain/skills/pause/SKILL.md b/.claude/plugins/onebrain/skills/pause/SKILL.md index d1c909c3..057213a4 100644 --- a/.claude/plugins/onebrain/skills/pause/SKILL.md +++ b/.claude/plugins/onebrain/skills/pause/SKILL.md @@ -64,7 +64,7 @@ Review the current conversation since the last pause file of `active_slug` (or s 1. Today's date as `YYYY-MM-DD`. 2. Ensure directory exists: `mkdir -p [logs_folder]/pause/` 3. Get `session_token` from agent context. - - **Claude:** if missing, recover it the same way INSTRUCTIONS.md Step 1's fallback does — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json`. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered value provably equals the token the Stop hook incremented. + - **Claude:** if missing, recover it with the resolve-only verb — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" "{resolved executable}" session token --json` and read `session_token` from its JSON. Use `session token`, not `session init`, here: this is mid-session recovery, and `session init` deletes the Stop-hook cadence state file whenever it runs mid-session, while `session token` touches no state files. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered value provably equals the token the Stop hook incremented. - **Codex and Gemini:** SessionStart injects the token; if missing, do **not** run `session init` to mint a replacement — a terminal- or process-derived token could merge another chat's snapshots. **If no token is available after applying the rule above:** abort the write. Do NOT proceed to Step 5. Output: `⚠️ Could not determine session token. Snapshot not saved — try again or run /doctor.` diff --git a/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md b/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md index b18c0eb1..c67f4e8c 100644 --- a/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md +++ b/.claude/plugins/onebrain/skills/startup/AUTO-SUMMARY.md @@ -8,9 +8,9 @@ Run silently (no output) if ALL of these are true: 3. The session had 3 or more user↔assistant exchanges If conditions are met: -- Use `session_token` from context if already loaded (set by `{resolved executable} session init` at startup). +- Use `session_token` from context if already loaded (set by `"{resolved executable}" session init` at startup). - **Codex and Gemini:** SessionStart injects a token derived from the complete hook `session_id`; it is the chat identity for this auto-summary. If that injected token is absent, abort auto-summary silently (checkpoint files stay in place — /wrapup recovers them at the next session start) rather than falling back to a terminal- or process-derived token, since that could merge another chat's checkpoints. - - **Claude:** if absent, recover it the same way INSTRUCTIONS.md Step 1's fallback does — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json` and use the `session_token` value. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered value provably equals the token the Stop hook incremented. + - **Claude:** if absent, recover it with the resolve-only verb — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" "{resolved executable}" session token --json` and use the `session_token` value. Use `session token`, not `session init`, for this mid-session recovery: `session init` deletes the Stop-hook cadence state file whenever it runs mid-session, while `session token` is resolve-only and touches no state files. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered value provably equals the token the Stop hook incremented. Glob checkpoint files (post-v2.4.0: checkpoints live in flat `[logs_folder]/checkpoint/` regardless of date): `[logs_folder]/checkpoint/YYYY-MM-DD-{session_token}-checkpoint-*.md`. Also yesterday's (handles cross-midnight sessions): compute yesterday's date (accounting for month/year rollover) and glob `[logs_folder]/checkpoint/YYYY-MM-DD_PREV-{session_token}-checkpoint-*.md`. **Read every file in the glob result** and fully incorporate all of their content into the session summary (not just as background context). Any checkpoint file that exists is unmerged by definition — there is no `merged:` filter. Every checkpoint must appear in the summary before it is deleted. - Determine NN: count existing `[logs_folder]/session/YYYY/MM/YYYY-MM-DD-session-*.md` files for today; NN = count + 1, zero-padded to 2 digits (01, 02, …). **Verify** `YYYY-MM-DD-session-NN.md` does not already exist before writing; if it does, increment NN until a free slot is found. @@ -47,7 +47,7 @@ If conditions are met: - Write the file once. On write error, skip all tasks for this file silently and continue to the next target file. - After confirming the session log was written, reset the checkpoint hook counter to prevent spurious post-summary checkpoints, passing the resolved `session_token` from above (never a re-minted one): ```bash - {resolved executable} checkpoint reset --session-token {session_token} + "{resolved executable}" checkpoint reset --session-token {session_token} ``` - Delete the checkpoint files from the glob above. Guard: only delete AFTER confirming the session log file was successfully written and is non-empty. Never delete before or during the write. If an individual delete fails, skip it silently — stale checkpoints are cleaned up later by /doctor or by the next /wrapup. Do not delete checkpoint files outside this session's glob result. - If a genuinely useful long-term insight emerged, write it to a new `memory/` file using /learn conventions: filename `[agent_folder]/memory/kebab-case-topic.md`, frontmatter `tags: [agent-memory], type: behavioral, source: auto-summary, status: active, conf: medium, verified: today, updated: today, created: today, topics: [2–4 keywords]`. Add a row to MEMORY-INDEX.md and increment `total_active`. **Do not write to MEMORY.md.** diff --git a/.claude/plugins/onebrain/skills/startup/references/session-formats.md b/.claude/plugins/onebrain/skills/startup/references/session-formats.md index 9c592efa..f2948cb5 100644 --- a/.claude/plugins/onebrain/skills/startup/references/session-formats.md +++ b/.claude/plugins/onebrain/skills/startup/references/session-formats.md @@ -62,7 +62,7 @@ trigger: stop --- ``` -`session_token` is the same token embedded in the filename — duplicating it in frontmatter lets `/wrapup`, `/doctor`, and `checkpoint orphans` filter checkpoints by token without parsing filenames. The agent (per `INSTRUCTIONS.md` Auto Checkpoint section) sets this from the `session_token` already in context — injected at session start on any harness, or, on Claude only, recovered via the `ONEBRAIN_HOOK_SESSION_ID`-derived `session init` if missing; on Gemini and Codex a missing token aborts the checkpoint instead (see `INSTRUCTIONS.md` for the full per-harness rule). +`session_token` is the same token embedded in the filename — duplicating it in frontmatter lets `/wrapup`, `/doctor`, and `checkpoint orphans` filter checkpoints by token without parsing filenames. The agent (per `INSTRUCTIONS.md` Auto Checkpoint section) sets this from the `session_token` already in context — injected at session start on any harness, or, on Claude only, recovered via the `ONEBRAIN_HOOK_SESSION_ID`-derived `session token` (resolve-only — leaves the Stop-hook cadence counter untouched, unlike `session init`) if missing; on Gemini and Codex a missing token aborts the checkpoint instead (see `INSTRUCTIONS.md` for the full per-harness rule). **Body:** use Shared Body Sections above. `## What We Worked On`: 2-3 sentences describing the session focus. diff --git a/.claude/plugins/onebrain/skills/update/references/migration-steps.md b/.claude/plugins/onebrain/skills/update/references/migration-steps.md index 7cdf6bf5..d430d46d 100644 --- a/.claude/plugins/onebrain/skills/update/references/migration-steps.md +++ b/.claude/plugins/onebrain/skills/update/references/migration-steps.md @@ -95,7 +95,7 @@ Always: update `updated:` frontmatter to today. Runs every /update — idempotent. Ensures every lifecycle event uses one shared runner. -- Run `onebrain plugin update` — registers exactly one bare `onebrain hook` runner for each supported lifecycle event. The CLI selects startup metadata, incremental reindexing, checkpointing, and pending embedding from `hook_event_name`; do not register separate direct checkpoint, reindex, or embed commands, and never use a `codex-hook` alias. It removes stale OneBrain entries from unsupported events (PreCompact, PostCompact, UserPromptSubmit, etc.) while preserving user-added non-OneBrain hooks under the same events. +- Run `onebrain plugin update` — registers exactly one `onebrain hook` runner for each supported lifecycle event (the CLI writes Claude's `PostToolUse`/`Stop` entries in exec form; plugin manifests carry the `|| echo` wrapper for Codex, Gemini, and Claude's `SessionStart`). The CLI selects startup metadata, incremental reindexing, checkpointing, and pending embedding from `hook_event_name`; do not register separate direct checkpoint, reindex, or embed commands, and never use a `codex-hook` alias. It removes stale OneBrain entries from unsupported events (PreCompact, PostCompact, UserPromptSubmit, etc.) while preserving user-added non-OneBrain hooks under the same events. - Check output: "all hooks already registered" → ✅ done; "added X" → ✅ registered - **Restart caveat:** active agent sessions retain the hook registrations they started with. Start a new session after updating before relying on the new lifecycle configuration. diff --git a/.claude/plugins/onebrain/skills/wrapup/SKILL.md b/.claude/plugins/onebrain/skills/wrapup/SKILL.md index 555e38f9..8f3323db 100644 --- a/.claude/plugins/onebrain/skills/wrapup/SKILL.md +++ b/.claude/plugins/onebrain/skills/wrapup/SKILL.md @@ -60,9 +60,9 @@ After Step 0b, continue to Step 1. ## Step 1: Gather Checkpoint Context 1. Get today's date as `YYYY-MM-DD`. Extract `YYYY` and `MM`. -2. Use `session_token` from context if already loaded (set by `{resolved executable} session init` at startup). +2. Use `session_token` from context if already loaded (set by `"{resolved executable}" session init` at startup). - **Codex and Gemini:** SessionStart injects a token derived from the complete hook `session_id`. It is the chat identity for both Stop checkpoints and this wrapup. If that injected token is absent, stop and report that hook context is missing; do **not** fall back to a terminal- or process-derived token because that could merge another chat's checkpoints. - - **Claude:** if absent, recover it the same way INSTRUCTIONS.md Step 1's fallback does — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" {resolved executable} session init --json` and use the `session_token` value. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered value provably equals the token the Stop hook incremented. + - **Claude:** if absent, recover it with the resolve-only verb — run `ONEBRAIN_HOOK_SESSION_ID="$CLAUDE_CODE_SESSION_ID" "{resolved executable}" session token --json` and use the `session_token` value. Use `session token`, not `session init`, for this mid-session recovery: `session init` deletes the Stop-hook cadence state file whenever it runs mid-session, while `session token` is resolve-only and touches no state files. `ONEBRAIN_HOOK_SESSION_ID` is the resolver's top-priority layer and applies the same sha256[..16] derivation the Stop hook applies to `CLAUDE_CODE_SESSION_ID`, so the recovered value provably equals the token the Stop hook incremented. 3. Glob checkpoint files (post-v2.4.0: checkpoints live in flat `[logs_folder]/checkpoint/` regardless of date): - **Match on the token, with NO date filter:** `[logs_folder]/checkpoint/*-{session_token}-checkpoint-*.md` @@ -485,7 +485,7 @@ Reflect on the conversation that just occurred. Identify: --- ``` 5. Body: merged content from step 2, using the Shared Body Sections. -6. After successful write, run `{resolved executable} checkpoint reset --session-token {session_token}`. +6. After successful write, run `"{resolved executable}" checkpoint reset --session-token {session_token}`. 7. Proceed to Step 4b (action item routing) and onward as normal. After Thread Wrapup writes the session log, the existing Step 5 (Checkpoint Cleanup) still runs — checkpoints from Step 1 are deleted. **Plus, in the new Step 5b (below), pause files and `_active.md` are deleted.** @@ -509,7 +509,7 @@ Create `[logs_folder]/session/YYYY/MM/YYYY-MM-DD-session-NN.md` using the Sessio After writing the session log, reset the checkpoint hook counter to prevent spurious post-wrapup checkpoints, passing the resolved `session_token` from Step 1 (never a re-minted one): ```bash -{resolved executable} checkpoint reset --session-token {session_token} +"{resolved executable}" checkpoint reset --session-token {session_token} ``` This writes `0::00` into the session state file (3 fields: count, last_ts, last_stop_nn) — triggering a 60-second skip window and resetting the message counter so the next Stop hook starts fresh. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8195fc5a..74b57921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Codex reuses SessionStart metadata, loads project memory only on topic matches, and bounds startup tasks to five while retaining the full count. - Codex, Claude, and Gemini lifecycle registrations now converge on one `onebrain hook` command; it selects the action from the stdin `hook_event_name`, and Codex keeps a single Stop entry so checkpoint work is not duplicated. Start a new agent session after upgrading because the old `codex-hook` alias is intentionally absent. - The chat identity contract is harness-split: Claude recovers a missing token via the `ONEBRAIN_HOOK_SESSION_ID`-derivation layer (provably the same sha256[..16] token the Stop hook increments), Gemini and Codex never mint one, and `checkpoint reset` now passes `--session-token {session_token}` so wrapup resets the same cadence counter the Stop hook incremented (needs CLI 3.4.25). -- All three harnesses' hook commands now wrap `onebrain hook` in a shell-level `|| echo` fail-open fallback, so rolling the CLI back below 3.4.25 degrades to an inert warning (or empty object) instead of a blocking exit code — Claude's `check-cli-version.sh` gate remains the user-facing messaging layer. +- Every plugin-shipped lifecycle registration (all of Codex's and Gemini's hook commands, plus Claude's `SessionStart`) now wraps `onebrain hook` in a shell-level `|| echo` fail-open fallback, so rolling the CLI back below 3.4.25 degrades to an inert warning (or empty object) instead of a blocking exit code; Claude's CLI-registered `PostToolUse`/`Stop` entries use exec form and can't carry a shell `||`, so they stay covered by the `check-cli-version.sh` SessionStart gate, which remains the user-facing messaging layer. ## v3.4.11 — 2026-07-30 — /digest first run is a complete setup chain diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5db6f513..d0f28aa6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -241,7 +241,7 @@ Agents are stateless — they receive all context in the prompt payload and do n Hooks run shell commands automatically when the harness performs certain actions. For Claude Code, hook configuration lives in the vault's `.claude/settings.json`; shell scripts (for PostToolUse hooks) go in `.claude/plugins/onebrain/hooks/`. For Gemini CLI, hooks live declaratively in `.gemini/settings.json` (under the `hooks` key). -OneBrain registers the same `onebrain hook` lifecycle command, wrapped in a shell-level `|| echo` fail-open fallback on every harness, across Codex (`SessionStart`, `PostToolUse`, `Stop`), Claude (`SessionStart` alongside the version check, plus its independent PreToolUse hooks), and Gemini (`SessionStart`, `AfterTool`, `AfterAgent`); Claude's separate `check-cli-version.sh` gate remains the user-facing messaging layer, so its wrapped entry falls back to a bare `{}` rather than repeating the version-check message. The installed CLI selects session init, search reindex, checkpoint, or pending embedding from stdin `hook_event_name`; keep one Stop/AfterAgent entry so checkpoint work is not duplicated. Reference tables below list every event each harness supports — useful when adding new hooks or porting between harnesses. +OneBrain registers the same `onebrain hook` lifecycle command across every harness: Codex (`SessionStart`, `PostToolUse`, `Stop`), Claude (`SessionStart` alongside the version check, plus its independent PreToolUse hooks), and Gemini (`SessionStart`, `AfterTool`, `AfterAgent`). Every plugin-shipped registration — all of Codex's and Gemini's entries, plus Claude's `SessionStart` — wraps the command in a shell-level `|| echo` fail-open fallback. Claude's own `PostToolUse` and `Stop` entries are CLI-registered directly into the vault's `.claude/settings.json` in exec form (`command:"onebrain", args:["hook"]`) and can't carry a shell `||`, so they're covered instead by the `check-cli-version.sh` SessionStart gate, which remains the user-facing messaging layer — Claude's wrapped `SessionStart` entry falls back to a bare `{}` rather than repeating the version-check message. The installed CLI selects session init, search reindex, checkpoint, or pending embedding from stdin `hook_event_name`; keep one Stop/AfterAgent entry so checkpoint work is not duplicated. Reference tables below list every event each harness supports — useful when adding new hooks or porting between harnesses. **Claude Code hook events:** From f3e9452805910c381339f1c536129b60b8c7e9f7 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Fri, 28 Aug 2026 09:43:37 +0700 Subject: [PATCH 14/14] docs: update changelog release date --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b57921..b2a6c8c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ --- latest_version: 3.4.12 -released: 2026-08-27 +released: 2026-08-28 --- # Changelog @@ -11,7 +11,7 @@ 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 changes, see the [`onebrain-ai/onebrain-cli`](https://github.com/onebrain-ai/onebrain-cli/blob/main/CHANGELOG.md) repository. -## v3.4.12 — 2026-08-27 — Codex hooks survive cache refreshes +## v3.4.12 — 2026-08-28 — Codex hooks survive cache refreshes - Codex hook manifests call the cache-independent runner in OneBrain CLI v3.4.25+, so an active task no longer retains a dead path when the plugin cache refreshes. - Codex reuses SessionStart metadata, loads project memory only on topic matches, and bounds startup tasks to five while retaining the full count.