From d4f1dd2f40b1f3776b239f7132df0c08e968a8a6 Mon Sep 17 00:00:00 2001
From: Suppaseth Charoenkarnka
Date: Fri, 22 May 2026 16:29:46 +0700
Subject: [PATCH 1/2] =?UTF-8?q?chore:=20plugin-only=20trim=20=E2=80=94=20s?=
=?UTF-8?q?trip=20Bun=20CLI=20source=20=C2=B7=20CHANGELOG=20split=20=C2=B7?=
=?UTF-8?q?=20README=20install=20update=20(v3.0.2)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This repo is now strictly the OneBrain plugin (skills, agents, hooks, INSTRUCTIONS,
harness configs). The legacy v2.x TypeScript/Bun CLI source has been removed; the
Rust CLI lives at `onebrain-ai/onebrain-cli` and ships from its own GH Releases.
Repo file changes:
- rm Bun toolchain: src/ (full tree, 17k lines), package.json, bun.lock,
tsconfig.json, biome.json. AGPL on the legacy code stays preserved in git
history.
- rm legacy repo-root CHANGELOG.md (Bun-era CLI changelog, 38 KB)
- mv PLUGIN-CHANGELOG.md → CHANGELOG.md (per [[onebrain-changelog-structure]]
one-changelog-per-repo rule)
plugin.json:
- bump 3.0.1 → 3.0.2 (manifest change + scope reduction = version bump per
repo convention)
CHANGELOG.md (v3.0.2 entry, 6 bullets within 8-bullet cap):
- chore: plugin-only trim summary
- rm Bun toolchain from root
- CHANGELOG split + heading rename
- README update — install paths now point at the Rust CLI (Homebrew tap, npm
wrapper, GH Release direct, onebrain update self-installer); license badge
MIT → AGPL-3.0-only
- GH Releases on this repo wiped (separate one-off; see follow-up)
README.md:
- Update version badge from npm to GH Release on onebrain-ai/onebrain-cli
- License badge MIT → AGPL-3.0-only
- Install section rewrite: brew tap + npm wrapper + GH Release direct + self-
update path, with link to onebrain-ai/onebrain-cli
- Drop "Optional bun" hint — v3 is a self-contained Rust binary
- Auto Checkpoint footnote updated with both install paths
Separately (NOT in this PR diff): all 39 GH Releases (v2.0.0 → v2.3.3) and
matching v2.x tags will be deleted from onebrain-ai/onebrain after merge.
Canonical Releases stream is onebrain-ai/onebrain-cli/releases going forward.
---
.../onebrain/.claude-plugin/plugin.json | 2 +-
CHANGELOG.md | 706 ++++++----
PLUGIN-CHANGELOG.md | 537 --------
README.md | 28 +-
biome.json | 41 -
bun.lock | 62 -
package.json | 49 -
src/commands/doctor.test.ts | 984 --------------
src/commands/doctor.ts | 584 --------
src/commands/init.integration.test.ts | 359 -----
src/commands/init.test.ts | 422 ------
src/commands/init.ts | 745 -----------
.../__snapshots__/checkpoint.test.ts.snap | 12 -
.../__snapshots__/orphan-scan.test.ts.snap | 13 -
.../__snapshots__/session-init.test.ts.snap | 15 -
src/commands/internal/checkpoint.test.ts | 526 --------
src/commands/internal/checkpoint.ts | 355 -----
src/commands/internal/cli-banner.test.ts | 252 ----
src/commands/internal/cli-banner.ts | 617 ---------
src/commands/internal/cli-ui.ts | 148 --
src/commands/internal/harness.test.ts | 136 --
src/commands/internal/harness.ts | 61 -
src/commands/internal/migrate.test.ts | 336 -----
src/commands/internal/migrate.ts | 216 ---
src/commands/internal/orphan-scan.test.ts | 699 ----------
src/commands/internal/orphan-scan.ts | 424 ------
src/commands/internal/qmd-reindex.test.ts | 136 --
src/commands/internal/qmd-reindex.ts | 56 -
src/commands/internal/register-hooks.test.ts | 1191 -----------------
src/commands/internal/register-hooks.ts | 620 ---------
src/commands/internal/session-init.test.ts | 678 ----------
src/commands/internal/session-init.ts | 469 -------
src/commands/internal/vault-sync.test.ts | 1017 --------------
src/commands/internal/vault-sync.ts | 968 --------------
src/commands/register-schedule.test.ts | 367 -----
src/commands/register-schedule.ts | 327 -----
src/commands/run-skill.test.ts | 286 ----
src/commands/run-skill.ts | 108 --
src/commands/update.integration.test.ts | 249 ----
src/commands/update.test.ts | 283 ----
src/commands/update.ts | 328 -----
src/index.ts | 238 ----
src/lib/fs-atomic.test.ts | 74 -
src/lib/fs-atomic.ts | 21 -
src/lib/fs-mkdir-safe.test.ts | 73 -
src/lib/fs-mkdir-safe.ts | 50 -
src/lib/index.test.ts | 640 ---------
src/lib/index.ts | 27 -
src/lib/parser.ts | 105 --
src/lib/patch-utf8.test.ts | 93 --
src/lib/patch-utf8.ts | 26 -
src/lib/scheduler/cron-parse.test.ts | 88 --
src/lib/scheduler/cron-parse.ts | 79 --
src/lib/scheduler/entry.test.ts | 89 --
src/lib/scheduler/entry.ts | 59 -
src/lib/scheduler/launchd.test.ts | 153 ---
src/lib/scheduler/launchd.ts | 131 --
src/lib/scheduler/log-paths.test.ts | 14 -
src/lib/scheduler/log-paths.ts | 13 -
src/lib/scheduler/types.ts | 24 -
src/lib/types.ts | 54 -
src/lib/validator.ts | 760 -----------
src/scripts/postinstall.test.ts | 20 -
src/scripts/postinstall.ts | 141 --
tsconfig.json | 21 -
65 files changed, 470 insertions(+), 17935 deletions(-)
delete mode 100644 PLUGIN-CHANGELOG.md
delete mode 100644 biome.json
delete mode 100644 bun.lock
delete mode 100644 package.json
delete mode 100644 src/commands/doctor.test.ts
delete mode 100644 src/commands/doctor.ts
delete mode 100644 src/commands/init.integration.test.ts
delete mode 100644 src/commands/init.test.ts
delete mode 100644 src/commands/init.ts
delete mode 100644 src/commands/internal/__snapshots__/checkpoint.test.ts.snap
delete mode 100644 src/commands/internal/__snapshots__/orphan-scan.test.ts.snap
delete mode 100644 src/commands/internal/__snapshots__/session-init.test.ts.snap
delete mode 100644 src/commands/internal/checkpoint.test.ts
delete mode 100644 src/commands/internal/checkpoint.ts
delete mode 100644 src/commands/internal/cli-banner.test.ts
delete mode 100644 src/commands/internal/cli-banner.ts
delete mode 100644 src/commands/internal/cli-ui.ts
delete mode 100644 src/commands/internal/harness.test.ts
delete mode 100644 src/commands/internal/harness.ts
delete mode 100644 src/commands/internal/migrate.test.ts
delete mode 100644 src/commands/internal/migrate.ts
delete mode 100644 src/commands/internal/orphan-scan.test.ts
delete mode 100644 src/commands/internal/orphan-scan.ts
delete mode 100644 src/commands/internal/qmd-reindex.test.ts
delete mode 100644 src/commands/internal/qmd-reindex.ts
delete mode 100644 src/commands/internal/register-hooks.test.ts
delete mode 100644 src/commands/internal/register-hooks.ts
delete mode 100644 src/commands/internal/session-init.test.ts
delete mode 100644 src/commands/internal/session-init.ts
delete mode 100644 src/commands/internal/vault-sync.test.ts
delete mode 100644 src/commands/internal/vault-sync.ts
delete mode 100644 src/commands/register-schedule.test.ts
delete mode 100644 src/commands/register-schedule.ts
delete mode 100644 src/commands/run-skill.test.ts
delete mode 100644 src/commands/run-skill.ts
delete mode 100644 src/commands/update.integration.test.ts
delete mode 100644 src/commands/update.test.ts
delete mode 100644 src/commands/update.ts
delete mode 100644 src/index.ts
delete mode 100644 src/lib/fs-atomic.test.ts
delete mode 100644 src/lib/fs-atomic.ts
delete mode 100644 src/lib/fs-mkdir-safe.test.ts
delete mode 100644 src/lib/fs-mkdir-safe.ts
delete mode 100644 src/lib/index.test.ts
delete mode 100644 src/lib/index.ts
delete mode 100644 src/lib/parser.ts
delete mode 100644 src/lib/patch-utf8.test.ts
delete mode 100644 src/lib/patch-utf8.ts
delete mode 100644 src/lib/scheduler/cron-parse.test.ts
delete mode 100644 src/lib/scheduler/cron-parse.ts
delete mode 100644 src/lib/scheduler/entry.test.ts
delete mode 100644 src/lib/scheduler/entry.ts
delete mode 100644 src/lib/scheduler/launchd.test.ts
delete mode 100644 src/lib/scheduler/launchd.ts
delete mode 100644 src/lib/scheduler/log-paths.test.ts
delete mode 100644 src/lib/scheduler/log-paths.ts
delete mode 100644 src/lib/scheduler/types.ts
delete mode 100644 src/lib/types.ts
delete mode 100644 src/lib/validator.ts
delete mode 100644 src/scripts/postinstall.test.ts
delete mode 100644 src/scripts/postinstall.ts
delete mode 100644 tsconfig.json
diff --git a/.claude/plugins/onebrain/.claude-plugin/plugin.json b/.claude/plugins/onebrain/.claude-plugin/plugin.json
index 0204f28c..8c5e39d2 100644
--- a/.claude/plugins/onebrain/.claude-plugin/plugin.json
+++ b/.claude/plugins/onebrain/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "onebrain",
- "version": "3.0.1",
+ "version": "3.0.2",
"description": "OneBrain — Where human and AI thinking become one. A powerful thinking partner powered by AI synergy.",
"author": {
"name": "OneBrain Contributors"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f185b7e7..4e082a30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,356 +1,546 @@
---
-latest_version: 2.3.3
-released: 2026-05-13
+latest_version: 3.0.2
+released: 2026-05-22
---
-# CLI Changelog
+# Changelog
-All notable changes to the OneBrain CLI binary (`@onebrain-ai/cli`).
+All notable changes to the OneBrain plugin — i.e., any vault-deployed content (Claude plugin under `.claude/plugins/onebrain/`, Gemini config under `.gemini/`, future harness configs).
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
-> **Versioning:** CLI version is tracked in `package.json`. Bump only when TypeScript source changes.
-> For plugin changes (skills, agents, hooks, INSTRUCTIONS), see [PLUGIN-CHANGELOG.md](PLUGIN-CHANGELOG.md).
+> **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.0.2 — 2026-05-22
+
+- **chore: plugin-only trim** — this repo is now strictly the OneBrain plugin (skills, agents, hooks, INSTRUCTIONS, harness configs). The legacy v2.x TypeScript/Bun CLI source has been removed; the Rust CLI lives at [`onebrain-ai/onebrain-cli`](https://github.com/onebrain-ai/onebrain-cli) and ships from its own GitHub Releases.
+- **rm Bun toolchain from repo root**: `src/`, `package.json`, `bun.lock`, `tsconfig.json`, `biome.json`. AGPL on the legacy code stays preserved in git history.
+- **CHANGELOG split**: previous repo-root `CHANGELOG.md` was the legacy CLI changelog — removed. `PLUGIN-CHANGELOG.md` renamed to `CHANGELOG.md` (one changelog per repo per [`[[onebrain-changelog-structure]]`](../05-agent/memory/onebrain-changelog-structure.md)). All historical plugin entries preserved verbatim.
+- **README update**: install instructions now point at the Rust CLI install paths (Homebrew tap, npm wrapper `@onebrain-ai/cli@3.0.0+`, GitHub Releases direct download, `onebrain update` self-installer). License badge MIT → AGPL-3.0-only (matches the [`v3.0.1` relicense](#v301--2026-05-22)).
+- **GitHub Releases**: all 39 Bun-era releases (v2.0.0 → v2.3.3) and matching tags removed from this repo. The plugin repo no longer publishes Releases — the canonical Releases stream is [`onebrain-ai/onebrain-cli/releases`](https://github.com/onebrain-ai/onebrain-cli/releases).
+- **No skill / hook / agent code changes** in this release — purely repo trim + manifest.
+
+## v3.0.1 — 2026-05-22
+
+- **chore: relicense MIT → AGPL-3.0-only** to align with the OneBrain CLI (`onebrain-ai/onebrain-cli`), which has shipped under AGPL since v3.0.0-alpha.0. Same license now spans the binary and the plugin manifest / skills / hooks that consume it, so the network-use copyleft (AGPL §13) applies consistently across the whole OneBrain runtime.
+- **LICENSE**: replaced 21-line MIT text with the canonical 661-line GNU Affero General Public License v3.
+- **plugin.json**: added `"license": "AGPL-3.0-only"` field. Previously absent; existing copies of the plugin (v2.x and v3.0.0) inherited the repo-root LICENSE — explicit field documents intent at the manifest level.
+- **plugin.json**: bump `3.0.0` → `3.0.1` for the license-field addition (manifest change ⇒ version bump per repo convention).
+- **MIT compatibility**: prior releases (v2.x through v3.0.0) remain available under MIT for downstream users who pinned to those versions. AGPL applies from v3.0.1 forward.
+- **No skill / hook / agent code changes** in this release — purely the license + manifest update.
+
+## v3.0.0 — 2026-05-22
+
+- **feat(plugin.json): pin plugin to CLI v3.0+** — adds `requires.cli: ">=3.0.0"` field. Aligned with the OneBrain CLI v3.0.0 (Rust) GA shipped 2026-05-22. The pin in PR #180 was reverted in PR #181 because the CLI wasn't ready; this re-applies it now that v3.0.0 is live.
+- **feat(hooks): SessionStart enforcement hook** — new `hooks/hooks.json` + `hooks/check-cli-version.sh` emits a `decision: block` JSON payload when the plugin boots alongside a CLI older than v3.0.0 (or no CLI on PATH); Claude Code refuses to start the session on that payload. Passes silently when v3.x is present. Pairs with the `requires.cli` field — the field is metadata for tooling, the hook is the runtime enforcement. Hook execution capped at 10 s via `timeout` in `hooks.json`.
+- **chore(plugin.json): bump 2.4.14 → 3.0.0** — major-version break: this plugin release refuses to load against v2.x Bun-era CLIs. Existing v2.x users will see the SessionStart `decision: block` until they `onebrain update` (or reinstall via the new Homebrew tap / forthcoming npm wrapper).
+- **Version comparison strategy**: hook extracts bare `MAJOR.MINOR.PATCH` via `grep -oE` (strips prerelease suffixes), then walks a pure-bash integer compare per field against the v3.0.0 floor. No dependency on GNU `sort -V` (absent from some older macOS BSD `sort` builds). Practical effect: any v3.0.0-alpha.x user passes (they're already on the Rust binary), only v2.x is blocked.
+- **Distribution channels live at this release**: GitHub Releases (canonical, since v3.0.0 GA), Homebrew tap (`onebrain-ai/homebrew-onebrain` → `brew install onebrain-ai/onebrain/onebrain`), `onebrain update` self-installer.
+- **What is NOT in this PR** (tracked for a follow-up plugin-trim PR per `[[onebrain-skill-design]]` single-responsibility): plugin repo README rewrite, trim to plugin-only (move CLI legacy `CHANGELOG.md` out of this repo), rename `PLUGIN-CHANGELOG.md` → `CHANGELOG.md`. Today's PR is scoped strictly to the pin + the runtime enforcement.
+
+## v2.4.14 — 2026-05-20
+
+- chore: revert the "pin plugin to CLI v3.0 (Rust)" PR (#180). Keeping the plugin compatible with both the Bun v2.3.3 CLI and the in-flight v3.0.0-alpha.* line until the Rust port reaches GA. The `requires.cli` pin will be re-applied (likely to `>=3.0.0`) once the CLI GA tag ships.
+- Plugin version stays at 2.4.x; the previous bump to `3.0.0-alpha.1` in PR #180 was a leading-edge signal that turned out to be premature.
+
+## v2.4.13 — 2026-05-19
+
+- docs(update): add Known Gotcha about WebFetch returning summarized markdown — recommend `curl -fsSL` for raw-content fetches (`plugin.json`/`settings.json` JSON parsing, `PLUGIN-CHANGELOG.md` verbatim display, `SKILL.md` self-update bootstrap). Symptoms section helps diagnose silent corruption from summarized fetches.
+- docs(update): add inline ⚠️ pointers at the four WebFetch call sites (version check, changelog display, SKILL.md bootstrap, settings.json merge) directing readers to the Known Gotcha when raw-content semantics matter — closes the "warning lives at the bottom but step prose says use WebFetch" coherence gap.
+
+## v2.4.12 — 2026-05-13
+
+- docs(INSTRUCTIONS): rewrite "Headless invocation" section to describe the real contract — scheduler now goes through `onebrain run-skill` which spawns `claude -p "/skill args" --add-dir ` (the previous `claude --vault X --skill /name --headless` shape was never implemented on any binary; see CLI v2.3.3 for the fix)
+- docs(INSTRUCTIONS): document `CLAUDE_BIN` env override for setups where `claude` is installed outside the probe list (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`)
+- docs(INSTRUCTIONS): clarify that skill `args:` map values are appended as `key=value` tokens to the slash-command prompt and reach skills via Claude Code's standard ARGUMENTS slot
+
+## v2.4.11 — 2026-05-12
+
+- docs(doctor): update SKILL.md hook-check description to match new validator behavior — effective command = `command` joined with `args[]`, so canonical exec-form `{command: "onebrain", args: ["checkpoint", "stop"]}` is recognized alongside legacy shell form
+
+## v2.4.10 — 2026-05-12
+
+- feat(pause): new `/pause` skill saves snapshot of long-running work to `07-logs/pause/`; non-terminal — does not clear context
+- feat(resume): new `/resume` skill loads active pause-thread state in fresh sessions; idempotent in same-session
+- feat(wrapup): Step 0 detects active pause thread; "Yes" branch consolidates all pause files of slug + current checkpoints into one session log
+- feat(wrapup, auto-summary): auto-finalize active pause thread before daily session log write (3 skip conditions prevent noise)
+- feat(startup): banner shows `📂 active pause: {slug} ({N} snapshots)` when `_active.md` present
+- feat(doctor): 3 new pause health checks (orphan pointer, missing pointer, idle > 14d)
+- docs(instructions): wire `/pause` `/resume` into skills table, vault structure, file naming, routing, response profiles
+- docs(session-formats): add Pause File Format + `synthesized_from_pause` session-log frontmatter case
+
+## 2.4.9 — 2026-05-12
+
+- `/schedule-add` Step 0 first-run preset selector — Minimal / Essentials / Maintenance Plus / Custom (E15-B)
+- `/onboarding` adds preset selection step after agent identity setup (default = Essentials)
+- Canonical preset tier definitions at `_shared/schedule-presets.md` — single source of truth
+- Tier 3 preset mixes skill-mode and command-mode entries (live example of E15-A schema)
+- INSTRUCTIONS.md + README + CONTRIBUTING document preset bundles
+
+## 2.4.8 — 2026-05-12
+
+- `/schedule-list` displays both skill-mode (`skill: /name (k=v)`) and command-mode (`cmd: binary arg1 arg2`) entries
+- INSTRUCTIONS.md adds "Skill mode vs command mode" subsection with full YAML example
+- README + CONTRIBUTING document command mode as alternative to thin wrapper skills
+- Companion to CLI v2.3.1 which introduces the command-mode backend
+
+## 2.4.7 — 2026-05-12
+
+- 4 new wizard skills: `/schedule-add` (recurring), `/schedule-once` (one-shot), `/schedule-list`, `/schedule-remove` (E9.1)
+- 26 user-facing skills declare `schedulable:` / `schedulable_with_args:` frontmatter — gates which skills the CLI scheduler accepts (E9.4)
+- `/doctor` extended with Scheduler Health section: scans `.err.md` files, detects drift between `vault.yml` and installed plists, flags 3+ consecutive failures + expired one-shots (E9.2)
+- INSTRUCTIONS.md adds "Scheduling — which tool to use" + "Headless invocation" sections (E9.3) — disambiguates OneBrain scheduler vs Claude Code `/loop` and `/schedule`
+- `/help` MAINTAIN tier lists the 4 new schedule commands
+
+## 2.4.6 — 2026-05-12
+
+- Remove vault-author-specific references from plugin source for genericity
+- /search: drop hardcoded `[projects_folder]/onebrain/plans/*.md` source; replace with generic `[projects_folder]/**/*.md` covering embedded specs/plans/design docs (keeps search coverage for projects with any folder layout)
+- /search: replace hardcoded vault folder names with `[knowledge_folder]`/`[projects_folder]`/`[resources_folder]`/`[areas_folder]` placeholders; update progress line + frontmatter description to match
+- /clone: replace personal example path with generic `/path/to/source/vault` in audit-log template
+- /capture, /consolidate: replace concrete vault-author note paths in routing/moved examples with `[folder]/example/...` placeholders
+
+## 2.4.5 — 2026-05-12
+
+- Hot-fix: enforced English-only across /search per `onebrain-repo-english-only` rule
+- Removed non-English auto-invoke triggers; kept 3 English: `search vault`, `find in vault`, `why did`
+- Removed non-English example phrases and regex tokens from SKILL.md body + references + INSTRUCTIONS routing description
+- Removed premature `schedulable` / `schedulable_with_args` / `required_args` frontmatter (defer to E9 scheduler shipping in a later PR)
+- Bilingual user input still routes via agent's intent matching on the English description; non-English literals were redundant
+
+## 2.4.4 — 2026-05-12
+
+- New skill /search — general vault retrieval (E5)
+- Answers both what + why questions across MEMORY/memory/sessions/plans/decisions logs/notes
+- Uses qmd (lex+vec+hyde) with grep fallback
+- Auto-invoke triggers: `search vault`, `find in vault`, `why did` (initial entry shipped with non-English triggers; hot-fixed in 2.4.5)
+- Registered under 🔍 RECALL tier in /help
+
+## 2.4.3 — 2026-05-12
+
+- Added `## Progress reporting` section to 6 long-running skills (E3)
+- Skills updated: /research, /consolidate, /distill, /reorganize, /connect, /import
+- Format: `→ [step N/M] ` emitted at each major step
+- Trust improvement during multi-step skill runs
+
+## 2.4.2 — 2026-05-12
+
+- /help reorganized into 4 Workflow tiers: 📥 INPUT · ⚙️ PROCESS · 🔍 RECALL · 🔧 MAINTAIN
+- /onboarding moved from Maintain → Input (first run only)
+- README skill list reordered to mirror tier structure
+- Discoverability win for new users; existing users now see skills by phase
## [Unreleased]
-## v2.3.3 — fix(scheduler): make scheduled skills actually run
+## v2.4.1 — fix(qmd, /update): drop stale `--qmd` / `--remove-qmd` flags from docs
+
+CLI dropped both `--qmd` and `--remove-qmd` from `onebrain register-hooks` in v2.1.0 (auto-detects from `vault.yml`'s `qmd_collection` instead — present registers the hook, absent strips it). Three plugin docs still told users and `/update` to pass these flags. On Windows after upgrading to CLI v2.2.1+, both `/update` and `/qmd uninstall` surfaced this as `unknown option` errors from commander.
+
+- fix(skills/qmd/SKILL.md): Step 8 (`/qmd setup`) and Step 4b (`/qmd uninstall`) now run `onebrain register-hooks` (no flag); auto-detects from vault.yml.
+- fix(skills/update/references/migration-steps.md): Step 6 merges the qmd-hook bullet into the unconditional `register-hooks` call — no separate `--qmd` invocation.
+
+## v2.4.0 — feat(07-logs): subfolder restructure + per-skill log entries
+
+Restructure `07-logs/` into 4 typed subfolders and add audit log entries for 12 skills. Companion CLI release v2.2.2 updates `orphan-scan` and the Stop hook's NN-counting helper to read from the new flat `checkpoint/` directory.
+
+- feat(07-logs): split into `session/YYYY/MM/`, `checkpoint/` (flat), `update/` (flat), `log/YYYY/MM/`. Mental model: session/checkpoint = NN per run, everything else = append per day.
+- feat(/update Step 0): idempotent migration moves files to the new layout (preserve YYYY/MM for session; flatten checkpoint + update). Detect-by-residual-files re-runs cleanly on interrupt.
+- feat(startup): legacy structure detection nudges /update with a one-line banner; orphan-scan fallback auto-detects pre- vs post-v2.4.0 layout (multi-vault user safety).
+- feat(skills): /recap, /distill, /memory-review, /learn, /consolidate, /connect, /reorganize, /onboarding, /qmd, /clone, /doctor, /weekly each write an audit log to `log/YYYY/MM/`. Shared `_shared/audit-log-format.md` reference deduplicates frontmatter + append-per-day algorithm + run-section heading + failure-mode rules.
+- feat(audit-log frontmatter): canonical 3-field schema (`tags: [audit-log, X]`, `skill: /X`, `date: YYYY-MM-DD`) across all skill audit logs; per-skill discriminators (topic, subcommand, mode, path, version). Tag taxonomy unified — `[doctor-log]`, `[update-log]`, `[weekly-review]` flipped to the `[audit-log, X]` umbrella so a single Obsidian `tag:#audit-log` query surfaces every skill run.
+- feat(session log + checkpoint frontmatter): `session_token: ` added to all 5 Session Log Format cases and the Checkpoint Format. Token previously lived only in checkpoint filenames; cross-referencing a session log to its checkpoints required parsing filenames. Now `rg "session_token: abc12345" 07-logs/` surfaces every artifact for that session.
+- feat(/wrapup): orphan recovery reads flat `checkpoint/`, writes to `session/YYYY/MM/`. Cross-midnight handling simplified to filename-date math. Progress signal for N>3 orphan groups. CRLF-safe marker check.
+- feat(/doctor): 07-logs structure check (verifies the 4 subfolders); housekeeping warning at >1000 log files. /reorganize now aborts if pre-v2.4.0 structure is detected (run /update first).
+- fix(checkpoint-hook.sh + checkpoint.ts): both write to and read from flat `checkpoint/`. Pre-fix, NN counting was reading from legacy `YYYY/MM/` path → every checkpoint after migration would have collided at NN=01.
+- fix(/update backup): `[archive_folder]/[agent_folder]/...` instead of hardcoded `05-agent` so users who remapped `folders.agent` see backups land in the matching subfolder.
+- fix(migrate.ts runBackfillRecapped): walks `[logs_folder]/session/YYYY/MM/` post-v2.4.0 (was walking `[logs_folder]/YYYY/MM/` and silently skipping all session logs).
+
+## v2.3.4 — docs(instructions): establish 11 iron-rule Working Principles
+
+Promote `## Working Principles` in `INSTRUCTIONS.md` from 4 unnumbered guidelines to 11 numbered iron rules with a precedence-stating intro. These are non-negotiable defaults that apply across every session, every skill, every workflow — and explicitly take precedence over skill-specific instructions when in conflict. Synthesised from a 60+ memory audit, an insights-report friction analysis, and 29 reviewer-passes across 15 distinct role perspectives (writer, designer, student, PM, lawyer, doctor, therapist, teacher, sales, fiction author, financial analyst, journalist, founder, translator, dev) so the language survives universally — neither dev-jargon nor watered-down for technical work.
+
+- docs(INSTRUCTIONS.md): convert `## Working Principles` to numbered list with intro stating these rules outrank skill-specific instructions when they conflict.
+- docs(INSTRUCTIONS.md): new rules — *Speak in the user's vocabulary · Verify before asserting · Find the cause, not the symptom · Show a draft before extensive work · Update plan and task status in real time · Don't make the user wait · Update on evidence, not pressure · Carry changes through to related places.*
+- docs(INSTRUCTIONS.md): rewrite original 4 rules per cross-role review — drop dev-only references (`AskUserQuestion`, slash-command exception list, "refactor"); merge "Surgical changes" into "Minimal footprint" with cleanup-after-yourself extension; add verifiable-criteria preference to "Define success".
+- docs(INSTRUCTIONS.md): each rule body now includes register-matching, root-cause depth, draft-first for structural work, streaming-vs-background nuance, and explicit code-context coverage (callers, tests, types, migrations) — keeping rigor for dev users while staying accessible to writers, students, lawyers, clinicians, and operators.
+- docs(INSTRUCTIONS.md): bullet 11 restructured per round-3 cross-role consensus — universal list (other notes, files that reference it, similar cases) leads, dev-specific examples fenced as `(in code: …)` so non-dev readers have a clear visual signal to skim past while dev users keep their precision.
+
+## v2.3.3 — feat(wrapup): PR #156 follow-ups (configurable threshold + recovered-log marker + fallback row)
+
+Three PR #156 follow-ups bundled. CLI track ships matching changes in v2.3.0 (see [CHANGELOG.md](CHANGELOG.md)).
+
+- feat(wrapup/SKILL.md): Step 1b resolves `threshold_minutes = max(60, 2 * checkpoint.minutes)` from vault.yml once before scanning groups (was: hard-coded 60). Users who raised `checkpoint.minutes` to 60/90 now get a proportionally larger guard. Missing/malformed vault.yml falls back to 60-min default — recovery is critical-path, never block on config issues. Cross-link to the symmetric CLI helper documents the contract.
+- feat(session-formats.md, wrapup/SKILL.md): standardise the `` body marker for recovered session logs. The `already-recovered` short-circuit (Step 1b → step a) now matches via the marker instead of `case: recovered` frontmatter — version-independent, harness-independent, names the specific token+date pair so multi-group recovery logs short-circuit per group rather than as a whole.
+- fix(wrapup/SKILL.md): the marker match is **anchored to start-of-line**, not bare substring. A session log that quotes the marker as documentation in mid-paragraph cannot trigger a false short-circuit (which would have destructively deleted checkpoints based on a documentation quote). Spec text in session-formats.md tightened in lockstep.
+- fix(wrapup/SKILL.md): step (f) now re-reads the recovered session log and verifies the marker survived the write before falling through to step (g)'s checkpoint delete. If the marker is missing (LLM omission / partial write / encoding glitch), recovery aborts for that group: no delete, recovered-log path lands in `orphaned_recovered_logs`, and `skipped_active` records `marker_write_failed` for each file. This converts a silent destructive duplicate into an investigable skip.
+- feat(wrapup/SKILL.md): new `marker_write_failed` enum value added to `skipped_active.reason` with its own row in the `{reason_summary}` rendering table.
+- feat(wrapup/SKILL.md): `{reason_summary}` rendering table gets a catch-all fallback row for unmapped enum values. The surface signal stays generic so a missing explicit row is visible to the user and prompts contributors to add the proper mapping. Cross-linked to the enum definition at the top of Step 1b so future contributors who add a new `reason` value see both anchors.
+
+## v2.3.2 — refactor(update): delegate CLI bump to `onebrain update`
+
+`/update`'s "CLI Version Check" section was duplicating logic that the `onebrain update` CLI already owns: GitHub release lookup, package-manager detection, install, and binary validation. The skill now defers to the CLI as the single source of truth for the CLI bump.
+
+- refactor(update/SKILL.md): replace 30-line "CLI Version Check" block (raw `npm view` + AskUserQuestion + per-shell bun/npm detection + `npm install -g` / `bun install -g`) with a 3-step delegation: probe `onebrain --version`, run `onebrain update`, surface non-zero exits.
+- refactor(update/SKILL.md): drop the npm/bun selection prompt — `onebrain update` already picks `bun` on macOS/Linux and `npm` on Windows; user already consented to `/update` upstream, so a second AskUserQuestion is noise.
+- docs(update/SKILL.md): add Known Gotcha — raw `npm install -g` / `bun install -g` calls are reserved for first-time CLI bootstrap (README/install scripts), never `/update`.
+- note: pure SKILL.md change, no CLI/binary change. CLI version unchanged at 2.2.0.
+
+## v2.3.1 — fix(wrapup): active-session guard prevents cross-harness checkpoint loss
+
+Wrapup's orphan recovery (Step 1b) auto-recovered any non-current-token checkpoint into a synthesised session log and deleted the originals — including in-flight checkpoints belonging to a *different live harness* in the same vault. Closes the cross-harness contamination path observed when running Claude + Gemini concurrently.
+
+- fix(wrapup/SKILL.md): add Active-Session Guard to Step 1b — for each orphan group, stat the newest checkpoint mtime; if `age_minutes < 60`, skip recovery (do not read, do not write a session log, do not delete) and surface in the Step 7 report as `skipped_active`.
+- fix(wrapup/SKILL.md): explicit fail-safe — any stat error, unparseable mtime, or negative age forces skip-active. Destructive default on ambiguity is forbidden.
+- fix(wrapup/SKILL.md): pre-delete re-stat in step 1b/f aborts the delete (and removes the just-written recovered log) if the owning session wrote a new checkpoint mid-recovery — closes the read→write→delete race.
+- fix(wrapup/SKILL.md): exact `stat -f '%m'` (BSD) / `stat -c '%Y'` (GNU) commands spelled out so the LLM doesn't pick the wrong flag silently across platforms.
+- fix(wrapup/SKILL.md): Step 7 `skipped_active` block is now MUST-emit (not soft-conditional) and renders `{path, age_minutes}` tuples — the user's only signal that a parallel harness owns checkpoints on disk.
+- note: 60-minute threshold gives a buffer of two full auto-checkpoint windows (hook fires every 15 messages or 30 minutes). False-positives (idle but live sessions > 60 min) remain non-destructive — the owning user's next /wrapup consumes its own checkpoints.
+- note: pure SKILL.md change, no CLI/binary change. CLI version unchanged at 2.2.0.
+
+## v2.3.0 — feat(gemini): project-level `.gemini/` config alongside `.claude/`
+
+Project-level `.gemini/` config ships alongside the Claude plugin so a single `onebrain init` (or `/update`) sets up both harnesses in the user's vault. Skills, agents, and INSTRUCTIONS stay single-source-of-truth in `.claude/plugins/onebrain/` — both harnesses reference them on demand, no duplication.
+
+- feat(.gemini/settings.json): declarative hooks — `AfterAgent` (matcher `*`) → `onebrain checkpoint stop` (= Claude `Stop` parity); `AfterTool` (matcher `write_file|replace`, regex against Gemini's actual tool names) → `onebrain qmd-reindex` (= Claude `PostToolUse` parity). Both wrapped as `{cmd} > /dev/null 2>&1; echo '{}'` to satisfy Gemini's JSON-on-stdout protocol.
+- feat(.gemini/settings.json): `model.disableLoopDetection: true` so legitimate multi-file skill activations (e.g. `/onebrain:help` reading SKILL.md + plugin.json + skills folder) don't trip Gemini's repetitive-tool-call heuristic.
+- feat(.gemini/commands/onebrain): 25 hand-curated `.toml` slash commands under the `onebrain:` namespace (`/onebrain:braindump`, `/onebrain:capture`, ...). Namespacing avoids collisions with Gemini built-ins (`/help`, `/tasks`) and mirrors the Claude plugin path. Tab-complete on the suffix works (`/dail` → `/onebrain:daily`).
+- note: this release establishes the unified-plugin policy — anything inside `.claude/plugins/onebrain/` OR `.gemini/` (or future harness configs) bumps the plugin track. CLI track (`package.json`) stays independent for TS source changes only.
+- note: distribution — `vault-sync` (CLI v2.2.0+) auto-deploys `.gemini/` to the vault root alongside `.claude/plugins/onebrain/`. No manual install step required.
+
+## v2.2.5 — fix: Windows skill + script compat (PowerShell / cmd / native Python)
+
+Audit pass over every skill snippet that assumed Bash / Unix-only tooling. Closes #128, #129, #130.
+
+- fix(open-in-obsidian.sh): use `cygpath -m` on MINGW/CYGWIN/MSYS to emit `C:/...` paths Obsidian accepts; percent-encode the URI so spaces and `#`/`&`/`?` in vault paths or filenames no longer truncate the launch (#130)
+- fix(reading-notes): default filename template uses ` - ` instead of ` : ` so notes save on NTFS without truncation; gotcha note removed (#130)
+- fix(import/markitdown-setup): drop the WSL-only gate on Windows; detect `python3` / `python` / `py -3` (and matching `pip` / `py -3 -m pip`) so native Windows installs can install markitdown (#128)
+- fix(qmd setup): replace `openssl rand -hex 3` / `python3 -c …` with `node -e "…randomBytes(3)…"`; `basename` swapped for a Node one-liner — Node is portable and already required by the CLI (#128)
+- fix(skills): cross-platform shell guidance — `which X || where X` for package detection; describe outcome (mkdir / mv / cp / rm / ls) so the model picks the shell-native form on PowerShell/cmd; drop `"$PWD"` from `onebrain vault-sync` (CLI defaults to cwd) (#129)
+- fix(INSTRUCTIONS startup): replace `LC_ALL=en_US.UTF-8 grep -r …` with the Grep tool — UTF-8 handling is platform-correct and PowerShell can dispatch it (#129)
+- fix(doctor SKILL): always strip trailing whitespace from vault.yml-derived paths (CRLF on Windows); normalize `installPath` separators before substring checks against the cache dir (#129)
+- fix(skills/help, /onboarding, /learn): use `$HOME` / `$env:USERPROFILE` instead of the literal `~` for Glob/Read calls — the Glob tool does not expand tildes (#129)
+
+## v2.2.4 — feat(update): backfill vault-side config drift after migration
+
+- feat(/update SKILL): Step 8 now adds `update_channel: stable` to vault.yml when missing
+- feat(/update SKILL): new Step 9 rewrites stale `extraKnownMarketplaces.onebrain.source.repo` (`kengio/onebrain` → `onebrain-ai/onebrain`) in vault `.claude/settings.json`
+
+## v2.2.3 — fix: session-log glob across /wrapup, /daily, /weekly, /distill, /reorganize, INSTRUCTIONS
+
+Same class of bug across multiple skills: globbing `[logs_folder]/.../*.md` matches checkpoint files (`*-checkpoint-*.md`) and `/update` migration logs (`*-update-*.md`) in addition to actual session logs. Tightened every affected pattern to `*-session-*.md` and added an inline note explaining why so it doesn't drift back.
+
+- fix(/wrapup SKILL): Step 6 recap-reminder glob narrowed from `07-logs/YYYY/MM/*.md` to `*-session-*.md`. The bare `*.md` pattern was inflating the displayed unrecapped count (reporting 10 unrecapped when only 2 actual session logs were unrecapped).
+- fix(/daily SKILL): Phase 1 "find most recent session log" glob narrowed to `*-session-*.md`. Previously a more recent checkpoint or `/update` log could be picked as "most recent", causing the briefing to read the wrong file.
+- fix(/weekly SKILL): Step 1 weekly file list narrowed to `*-session-*.md` so the review doesn't include checkpoint or update logs.
+- fix(/distill SKILL): Step 2 session-log search narrowed to `*-session-*.md` so non-session files in the logs folder don't contribute distillation content.
+- fix(/reorganize SKILL): flat-root logs glob narrowed from `[logs_folder]/*.md` to `*-session-*.md` so a flat checkpoint or update log isn't treated as a legacy session log to migrate.
+- fix(INSTRUCTIONS Recalling Information): Step 3 grep hint now specifies `**/*-session-*.md` so the agent doesn't default to bare `*.md` when searching past decisions.
+
+## v2.2.2 — chore: migrate to onebrain-ai org
+
+- chore(/update SKILL): raw GitHub URL templates updated to `onebrain-ai/onebrain` for plugin file fetches
+- chore(plugin.json): version bump aligned with CLI v2.1.7 org migration
+- note: existing vaults still work via GitHub auto-redirect; `/update` will pick up new URLs going forward
+
+## v2.2.1 — fix: align with CLI v2.1.6 (Stop-hook-only)
+
+- fix(INSTRUCTIONS): drop entire PostCompact section (Path A/B + auto-wrapup routing). Single dispatch row — `NN since ` → write checkpoint. Note added explaining why PostCompact + PreCompact are not registered
+- feat(/wrapup + AUTO-SUMMARY): explicit **preservation rule** — deduplicate, don't summarize. Every unique decision, action item, learning, and topic must appear in the session log. No length cap. Heuristic: combined Key Decisions + Action Items + Open Questions length ≥ sum across all checkpoints
+- fix(/doctor SKILL): hook check rewritten to allowed-events sweep (Stop + PostToolUse only); sample report shows stale-entry warnings instead of PostCompact-specific failure
+- fix(session-formats.md): drop "PostCompact Path A/B" frontmatter case; keep "Recovered from checkpoints" for /wrapup orphan recovery
+- fix(/wrapup SKILL.md): state-file note updated to 3-field `0::00`; PostCompact follow-up signal wording removed
+- fix(/update migration-steps + SKILL): clarify Stop-hook-only registration; session-end synthesis is via AUTO-SUMMARY or manual /wrapup
+
+## v2.2.0 — fix: PostCompact session log; simplify checkpoint cleanup; stronger qmd-first search
+
+- fix(INSTRUCTIONS PostCompact): inline writes replace background-agent dispatch — Path B silently failed because background agents don't see the main agent's compacted context. Path A still consolidates leftover checkpoints + deletes them, identical to /wrapup.
+- fix(wrapup + AUTO-SUMMARY): drop Step 5 (mark `merged: true`) and Step 6 safety-net scan. Checkpoints deleted directly after session log write verified — the log is the recovery proof.
+- fix(session-formats): remove `merged: false` from checkpoint frontmatter template.
+- fix(doctor): orphan-checkpoint check no longer reads `merged:` frontmatter — any leftover checkpoint is unmerged by definition.
+- feat(INSTRUCTIONS + QMD.md): stronger qmd-first guidance — qmd is the explicit default for vault content searches; Grep reserved for non-content lookups.
+- chore(memory-health-checks): drop the `merged: true` straggler row; ignore the field on legacy files.
+
+## v2.1.0
+
+- docs(onboarding): update install.sh reference → onebrain init; remove method/runtime.harness from vault.yml template
+- docs(skills): remove method: onebrain from qmd and reorganize skill examples
+- fix(doctor): --fix removes deprecated vault.yml keys (method, runtime.harness) in addition to onebrain_version
+
+## v2.0.10 — fix: background agent checkpoint writes; updated hook reason format in INSTRUCTIONS
+
+- fix(instructions): Auto Checkpoint routing now parses NN from hook reason; filename built from context session_token
+- fix(instructions): stop hook and postcompact writes dispatched to background agent (mode: bypassPermissions) — main session no longer blocks on file writes
+- fix(instructions): postcompact uses bare `auto-wrapup` reason; session_token sourced from context with session-init fallback
+- fix(instructions): session-init failure explicitly aborts silently; routing table checks auto-wrapup reason first; Path A steps follow Path A dispatch (no longer split by Path B)
+
+## v2.0.9 — fix: startup grep locale, postcompact routing, wrapup score-0 fallback
+
+- fix(INSTRUCTIONS): startup task scan uses `LC_ALL=en_US.UTF-8` prefix on grep — prevents emoji pattern failures on macOS
+- fix(INSTRUCTIONS): postcompact auto-wrapup Path A (step 9, after verify) and Path B now route action items to project notes — matches /wrapup Step 4b parity
+- fix(wrapup): add session-context fallback in Step 4b-3b — score-0 tasks are routed to the project identified from `## What We Worked On` instead of being skipped; separate skipped_score0/skipped_ties lists
+- fix(auto-summary): add session-context fallback for score-0 tasks in step 3 with explicit tokenization delimiters — matches /wrapup Step 4b-3b parity
-- New hidden subcommand `onebrain run-skill --vault X --skill /name [--arg key=value ...]` spawns `claude -p "/onebrain:skill args" --add-dir ` with `cwd=`; resolves `claude` via `CLAUDE_BIN` env → known prefixes (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`) → PATH; the scheduler plist now invokes this instead of the never-implemented `--vault/--skill/--headless` flags
-- `register-schedule` resolves command-mode binary names to absolute paths via `/usr/bin/which`, since launchd inherits a restricted PATH that excludes Homebrew/Bun/`~/.local/bin`; absolute paths now also `existsSync`-checked and relative paths resolve against the vault root (not `process.cwd()`); unresolved binaries throw at register time so failures don't hide until run time
-- `labelForEntry` derives command-mode labels from the binary basename so `command: onebrain` and `command: /opt/homebrew/bin/onebrain` produce the same plist (and still collide correctly with skill `/onebrain`); `register-schedule` no longer mutates caller-supplied entries — the resolved path stays internal to plist generation
-- `register-schedule --test ` drives `runSkillCommand` instead of spawning `claude` with non-existent flags, and propagates the child exit code (POSIX-conventional `128 + signal` for signal kills, `127` for spawn errors, `78`/`EX_CONFIG` for missing `vault.yml`)
-- Hardening: `--arg` collector rejects empty keys; `buildPrompt` throws on empty skill names; `CLAUDE_BIN` typos surface as warnings instead of silently falling through to the probe list; one-shot plist's self-delete path now derives from the same label as the `launchctl bootout` target so they can never drift
-- `process.argv[1]` dev-mode fallback: when running via `bun run src/index.ts`, argv[1] resolves to an unexecutable `.ts` file; `register-schedule` now detects that and falls back to `which onebrain`
-- 23 new unit tests covering `buildPrompt`, `resolveCommandBinary` (4 branches incl. relative-path resolution), the spawn surface, signal/error/exit-code propagation, and the `CLAUDE_BIN` typo + override paths; existing scheduler tests rewritten for the new plist shape with explicit `not.toContain('--headless')` regression sentinels
-- Plists generated by pre-v2.3.3 CLI silently exit 78 on every fire — run `onebrain register-schedule` once after upgrading to regenerate them; a `/doctor` check for stale-shape plists is filed as a follow-up
+## v2.0.8 — refactor: extract shared session formats; remove backfill-recapped from /update
-## v2.3.2 — fix(doctor): detect new Claude Code hook exec-form schema
+- refactor(startup): add `skills/startup/references/session-formats.md` — canonical checkpoint + session log templates shared across all writers
+- refactor(INSTRUCTIONS): replace inline checkpoint/session log format blocks with reference to session-formats.md
+- refactor(wrapup): replace inline session log templates (Step 1b orphan recovery, Step 4) with reference to session-formats.md
+- refactor(AUTO-SUMMARY): replace inline format description with reference to session-formats.md
+- fix(update): remove migration Step 6 (backfill-recapped) — session logs without recapped: are naturally candidates for /recap, no backfill needed
-- `checkSettingsHooks` now joins `command` + `args[]` into the effective command string before substring matching, so canonical exec-form hooks (`{command: "onebrain", args: ["checkpoint", "stop"]}`) are no longer false-flagged as missing
-- New `detectHookForm` helper classifies each matching entry as `exec` (canonical), `legacy` (shell-form, wrapper, etc.), or `absent` — legacy entries now warn with "--fix will migrate to exec form" instead of staying invisible
-- `detectHookForm` scans all matching entries per event: if any entry is in canonical exec form, the hook is reported as exec even when a legacy duplicate also matches (handles partial-migration state where a stale legacy entry was left behind alongside the new canonical one)
-- `effectiveCommand` filters non-string `args[]` entries — hand-edited `settings.json` with stray `null`/numbers can't produce ghost substring matches
-- Stale-hook sweep also uses the joined effective command, so a stale `onebrain` reference hidden in `args[]` of a wrapper entry is no longer missed
-- 10 unit tests covering exec, legacy shell, bash-wrapper, absent, partial migration, mixed-state Stop+PostToolUse, stale exec-form events, defensive args filtering, and qmd-conditional skipping
-- No behavior change for vaults already in canonical exec form (the common case post-v2.3.0)
+## v2.0.7 — fix: postcompact Path B, remove PreCompact hook
-## v2.3.1 — feat(scheduler): hook-style command mode for direct CLI scheduling
+- fix(INSTRUCTIONS): postcompact auto-wrapup adds Path B — when no checkpoint files exist, synthesize session log from current context (was a no-op, causing auto-compact to write nothing)
+- fix(INSTRUCTIONS): checkpoint trigger now matches reason prefix — `since start` / `since checkpoint-NN` suffix no longer prevents file creation
+- fix(INSTRUCTIONS): PreCompact is now a no-op and no longer registered; PostCompact resets counter in all paths
+- fix(INSTRUCTIONS): remove merged:true write step from postcompact; simplify delete step
+- fix(INSTRUCTIONS): update session_token tooltip to include $TMUX_PANE and $TERM_SESSION_ID priority
+- fix(doctor): replace PreCompact required-check with stale-hook warning (🟡 suggest /update to remove)
+- fix(update): migration-steps.md and SKILL.md updated to reflect Stop/PostCompact-only hook registration
+- fix(wrapup): update session token mismatch gotcha note to reflect CLI v2.0.12 fix
-- `ScheduleEntry.command` field added: schedule any CLI binary using the same `command + args[]` shape as Claude Code hooks
-- `args` is now `Record` (skill mode → `--key=value` flags) OR `string[]` (command mode → positional argv)
-- `validateEntry` enforces exactly-one-of(skill, command) + matching args shape; type guards `isSkillMode` / `isCommandMode` exported
-- One-shot command entries reject shell-special chars in args (`"`, `$`, backtick, `\`) — same guard as v2.3.0
-- `register-schedule --status` displays command entries as `cmd: ` and skill entries with inline `(key=value)` args
-- Collision detection extended: skill and command entries derive plist labels independently; conflicts reported with mode-tagged names
-- Zero breaking change to existing skill-mode entries — full v2.3.0 test suite passes without modification
+## v2.0.6 — fix: replace bash scripts with CLI; fix SessionStart hook breaking vault after /update
-## v2.3.0 — feat(scheduler): OneBrain scheduler — launchd-backed recurring + one-shot schedules (E9)
+- fix(register-hooks): remove SessionStart hook registration — session-init is called by agent startup, not via hook
+- fix(wrapup): reset-checkpoint-counter.sh → onebrain checkpoint reset
+- fix(update): vault-sync.sh → onebrain vault-sync; backfill-recapped.sh → onebrain migrate backfill-recapped
+- fix(update): pin-to-vault.sh + clean-plugin-cache.sh → onebrain vault-sync (doctor, onboarding)
+- feat(qmd): register-hooks.sh --qmd/--remove-qmd → onebrain register-hooks --qmd/--remove-qmd
+- chore: delete all replaced bash scripts (hooks/, update/scripts/, wrapup/scripts/)
+- fix(update): bootstrap step downloads only SKILL.md — no bash scripts needed
-- New subcommand `onebrain register-schedule` — registers scheduled skills with macOS launchd, reading the `schedule:` block from `vault.yml`
-- Flags: `--dry-run`, `--remove`, `--refresh`, `--resume `, `--status`, `--test `
-- Recurring schedules via 5-field cron syntax in `cron:` field; validated before plist emission
-- One-shot schedules via ISO `at: "YYYY-MM-DD HH:MM"` field; plist emits self-delete shell wrapper that auto-uninstalls after firing
-- Schedulable validation: rejects entries pointing at skills without `schedulable:` or `schedulable_with_args:` frontmatter (or with missing `required_args`)
-- Plist collision detection: two entries normalizing to the same `~/Library/LaunchAgents/com.onebrain.
-
-
-
+
+
+
@@ -200,7 +200,7 @@ OneBrain has automatic behaviors that run without you doing anything:
**The practical result:** Just say "bye" and everything is saved. If the session ends unexpectedly, you lose at most 15 messages — the last checkpoint recovers the rest.
-> Auto Checkpoint runs on Claude Code (`Stop` hook) and Gemini CLI (`AfterAgent` hook), and uses the `onebrain` CLI binary. Install with `npm install -g @onebrain-ai/cli`. Auto Session Summary works with any agent that follows INSTRUCTIONS.md.
+> Auto Checkpoint runs on Claude Code (`Stop` hook) and Gemini CLI (`AfterAgent` hook), and uses the `onebrain` CLI binary. Install via Homebrew (`brew install onebrain-ai/onebrain/onebrain`) or npm (`npm install -g @onebrain-ai/cli`) — see the [Installation section](#installation). Auto Session Summary works with any agent that follows INSTRUCTIONS.md.
---
@@ -282,12 +282,26 @@ Each harness reads OneBrain's instruction file automatically. Install it, run it
### 1. Install the OneBrain CLI
+Pick the install path that fits your environment — all three converge on the same v3.x Rust binary.
+
```bash
+# macOS (Homebrew tap — recommended)
+brew tap onebrain-ai/onebrain
+brew install onebrain
+
+# Any platform via npm wrapper (postinstall downloads the platform binary)
npm install -g @onebrain-ai/cli
-# or: bun install -g @onebrain-ai/cli
+
+# Direct download — pick the matching target triple for your platform
+# https://github.com/onebrain-ai/onebrain-cli/releases/latest
```
-The installer automatically downloads the correct compiled binary for your platform — no Bun installation required.
+The full CLI source + release pipeline lives at [`onebrain-ai/onebrain-cli`](https://github.com/onebrain-ai/onebrain-cli). After install, use the built-in self-installer to refresh in place:
+
+```bash
+onebrain update # prompt-and-confirm
+onebrain update --check # dry-run
+```
### 2. Create and initialize your vault
@@ -595,7 +609,7 @@ CLI flags:
Verify with `git --version` before running the installer.
-**Optional:** [bun](https://bun.sh) — not required for most users. `npm install -g @onebrain-ai/cli` automatically downloads a compiled binary for your platform. Bun is only needed if you're on an unsupported platform or want to install from source.
+**Source builds (optional):** The published v3.x CLI is a self-contained Rust binary — `npm install`, `brew install`, and direct GH Release download all give you the same artifact, no build dependencies needed. Building from source requires a [Rust toolchain](https://rustup.rs) (`rustup default stable`); see [`onebrain-ai/onebrain-cli`](https://github.com/onebrain-ai/onebrain-cli#build-from-source) for instructions.
**Windows:** Git for Windows (above) includes Git Bash, which provides the `bash` environment required to run all hooks.
diff --git a/biome.json b/biome.json
deleted file mode 100644
index fe489f79..00000000
--- a/biome.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
- "organizeImports": {
- "enabled": true
- },
- "linter": {
- "enabled": true,
- "rules": {
- "recommended": true,
- "correctness": {
- "noUnusedVariables": "error",
- "noUnusedImports": "error"
- },
- "complexity": {
- "useLiteralKeys": "off"
- },
- "style": {
- "noNonNullAssertion": "warn"
- },
- "suspicious": {
- "noExplicitAny": "error"
- }
- }
- },
- "formatter": {
- "enabled": true,
- "indentStyle": "space",
- "indentWidth": 2,
- "lineWidth": 100
- },
- "javascript": {
- "formatter": {
- "quoteStyle": "single",
- "trailingCommas": "all",
- "semicolons": "always"
- }
- },
- "files": {
- "ignore": ["node_modules", "dist", "*.js", ".obsidian", ".claude/settings.json"]
- }
-}
diff --git a/bun.lock b/bun.lock
deleted file mode 100644
index 4d9b6ee1..00000000
--- a/bun.lock
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "lockfileVersion": 1,
- "configVersion": 1,
- "workspaces": {
- "": {
- "name": "@onebrain-ai/cli",
- "dependencies": {
- "@clack/prompts": "^0.9",
- "commander": "^12",
- "picocolors": "^1",
- "yaml": "^2",
- },
- "devDependencies": {
- "@biomejs/biome": "^1.9",
- "@types/bun": "latest",
- "@types/node": "^20",
- "typescript": "^5.7",
- },
- },
- },
- "packages": {
- "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
-
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
-
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
-
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
-
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
-
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
-
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
-
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
-
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
-
- "@clack/core": ["@clack/core@0.4.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA=="],
-
- "@clack/prompts": ["@clack/prompts@0.9.1", "", { "dependencies": { "@clack/core": "0.4.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg=="],
-
- "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
-
- "@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="],
-
- "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
-
- "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
-
- "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
-
- "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
-
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
-
- "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
-
- "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
- }
-}
diff --git a/package.json b/package.json
deleted file mode 100644
index 675ee1e6..00000000
--- a/package.json
+++ /dev/null
@@ -1,49 +0,0 @@
-{
- "name": "@onebrain-ai/cli",
- "version": "2.3.3",
- "description": "CLI for OneBrain — personal AI OS for Obsidian with persistent memory, 24+ skills, and Claude Code integration",
- "keywords": [
- "onebrain",
- "obsidian",
- "ai",
- "cli",
- "memory",
- "knowledge-management",
- "claude",
- "agent",
- "pkm",
- "productivity",
- "vault"
- ],
- "homepage": "https://onebrain.run",
- "repository": {
- "type": "git",
- "url": "git+https://github.com/onebrain-ai/onebrain.git"
- },
- "bugs": "https://github.com/onebrain-ai/onebrain/issues",
- "license": "MIT",
- "type": "module",
- "bin": {
- "onebrain": "dist/onebrain"
- },
- "files": ["dist/onebrain", "dist/postinstall.js"],
- "scripts": {
- "build": "bun build src/index.ts --outfile dist/onebrain --target bun",
- "build:postinstall": "bun build src/scripts/postinstall.ts --outfile dist/postinstall.js --target node",
- "postinstall": "node dist/postinstall.js",
- "test": "bun test --pass-with-no-tests src/",
- "typecheck": "tsc --noEmit"
- },
- "dependencies": {
- "@clack/prompts": "^0.9",
- "commander": "^12",
- "picocolors": "^1",
- "yaml": "^2"
- },
- "devDependencies": {
- "@biomejs/biome": "^1.9",
- "@types/bun": "latest",
- "@types/node": "^20",
- "typescript": "^5.7"
- }
-}
diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts
deleted file mode 100644
index 33c46479..00000000
--- a/src/commands/doctor.test.ts
+++ /dev/null
@@ -1,984 +0,0 @@
-/**
- * Tests for `onebrain doctor` — runDoctor()
- *
- * All @onebrain/core validators are injected via opts so tests are
- * fast, offline, and deterministic. No mock.module needed.
- */
-
-import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
-import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { parse as parseYaml } from 'yaml';
-
-import {
- DEFAULT_CHECKPOINT,
- type VaultConfig,
- checkClaudeSettings,
- checkVaultYmlKeys,
-} from '../lib/index.js';
-import { type DoctorOptions, runDoctor } from './doctor.js';
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-async function makeTempVault(): Promise {
- const dir = join(
- tmpdir(),
- `onebrain-doctor-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
- );
- await mkdir(dir, { recursive: true });
- return dir;
-}
-
-const DEFAULT_CONFIG: VaultConfig = {
- folders: {
- inbox: '00-inbox',
- projects: '01-projects',
- areas: '02-areas',
- knowledge: '03-knowledge',
- resources: '04-resources',
- agent: '05-agent',
- archive: '06-archive',
- logs: '07-logs',
- },
- checkpoint: { ...DEFAULT_CHECKPOINT },
-};
-
-function makeAllOkValidators(): Required<
- Pick<
- DoctorOptions,
- | 'checkVaultYmlFn'
- | 'loadVaultConfigFn'
- | 'checkFoldersFn'
- | 'checkQmdEmbeddingsFn'
- | 'checkOrphanCheckpointsFn'
- | 'checkPluginFilesFn'
- | 'checkVaultYmlKeysFn'
- | 'checkSettingsHooksFn'
- | 'checkClaudeSettingsFn'
- >
-> {
- return {
- checkVaultYmlFn: async () => ({ check: 'vault.yml', status: 'ok', message: 'valid' }),
- loadVaultConfigFn: async () => DEFAULT_CONFIG,
- checkFoldersFn: async () => ({ check: 'folders', status: 'ok', message: '8/8 present' }),
- checkQmdEmbeddingsFn: async () => ({
- check: 'qmd-embeddings',
- status: 'ok',
- message: 'all embedded',
- }),
- checkOrphanCheckpointsFn: async () => ({
- check: 'orphan-checkpoints',
- status: 'ok',
- message: '0 orphans',
- }),
- checkPluginFilesFn: async () => ({
- check: 'plugin-files',
- status: 'ok',
- message: 'all present',
- }),
- checkVaultYmlKeysFn: async () => ({
- check: 'vault.yml-keys',
- status: 'ok',
- message: 'all keys valid',
- }),
- checkSettingsHooksFn: async () => ({
- check: 'settings-hooks',
- status: 'ok',
- message: 'all hooks registered',
- }),
- checkClaudeSettingsFn: async () => ({ check: 'claude-settings', status: 'ok', message: 'ok' }),
- };
-}
-
-let tempDir: string;
-
-beforeEach(async () => {
- tempDir = await makeTempVault();
-});
-
-afterEach(async () => {
- await rm(tempDir, { recursive: true, force: true });
-});
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-describe('runDoctor', () => {
- // ── Exit codes ─────────────────────────────────────────────────────────────
-
- describe('exit codes', () => {
- it('returns exitCode 1 when any check returns status error', async () => {
- const validators = makeAllOkValidators();
- validators.checkVaultYmlFn = async () => ({
- check: 'vault.yml',
- status: 'error',
- message: 'not found',
- });
-
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
-
- expect(result.exitCode).toBe(1);
- expect(result.ok).toBe(false);
- expect(result.errorCount).toBeGreaterThanOrEqual(1);
- });
-
- it('returns exitCode 0 when checks return only warnings (no errors)', async () => {
- const validators = makeAllOkValidators();
- validators.checkFoldersFn = async () => ({
- check: 'folders',
- status: 'warn',
- message: '7/8 present',
- });
- validators.checkOrphanCheckpointsFn = async () => ({
- check: 'orphan-checkpoints',
- status: 'warn',
- message: '2 unmerged checkpoint(s)',
- });
-
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
-
- expect(result.exitCode).toBe(0);
- expect(result.ok).toBe(true);
- expect(result.warningCount).toBeGreaterThanOrEqual(2);
- expect(result.errorCount).toBe(0);
- });
-
- it('returns exitCode 0 when all checks pass', async () => {
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
-
- expect(result.exitCode).toBe(0);
- expect(result.ok).toBe(true);
- expect(result.errorCount).toBe(0);
- expect(result.warningCount).toBe(0);
- });
- });
-
- // ── Summary line selection ─────────────────────────────────────────────────
-
- describe('summary line selection', () => {
- it('shows "N errors, N warnings" when both errors and warnings exist', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- const validators = makeAllOkValidators();
- validators.checkVaultYmlFn = async () => ({
- check: 'vault.yml',
- status: 'error',
- message: 'not found',
- });
- validators.checkFoldersFn = async () => ({
- check: 'folders',
- status: 'warn',
- message: '7/8 present',
- });
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- expect(outputChunks.join('')).toMatch(/Summary: \d+ checks · 1 error\(s\) · 1 warning\(s\)/);
- });
-
- it('shows "N errors, N warnings" summary when only errors exist', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- const validators = makeAllOkValidators();
- validators.checkVaultYmlFn = async () => ({
- check: 'vault.yml',
- status: 'error',
- message: 'not found',
- });
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- const output = outputChunks.join('');
- expect(output).toMatch(/Summary: \d+ checks · 1 error\(s\)/m);
- });
-
- it('shows "N warnings — ok to run" when only warnings (no errors)', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- const validators = makeAllOkValidators();
- validators.checkOrphanCheckpointsFn = async () => ({
- check: 'orphan-checkpoints',
- status: 'warn',
- message: '1 unmerged checkpoint(s)',
- });
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- expect(outputChunks.join('')).toMatch(/Summary: \d+ checks · 1 warning\(s\) — ok to run/);
- });
-
- it('shows "All checks passed" when no errors or warnings', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- expect(outputChunks.join('')).toMatch(/Summary: \d+ checks — all passed/);
- });
- });
-
- // ── TTY vs non-TTY output formatting ──────────────────────────────────────
-
- describe('TTY vs non-TTY output', () => {
- it('non-TTY: plain title present in output', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- expect(outputChunks.join('')).toMatch(/OneBrain Doctor/);
- });
-
- it('TTY: runDoctor completes with exitCode 0 when all checks pass', async () => {
- // In TTY mode, summary goes through clack outro() which bypasses console.log.
- // We verify the return value instead of capturing console output.
- const result = await runDoctor({
- vaultDir: tempDir,
- isTTY: true,
- delayFn: async () => {},
- ...makeAllOkValidators(),
- });
- expect(result.exitCode).toBe(0);
- expect(result.ok).toBe(true);
- expect(result.errorCount).toBe(0);
- expect(result.warningCount).toBe(0);
- });
- });
-
- // ── loadVaultConfig failure resilience ────────────────────────────────────
-
- describe('loadVaultConfig failure resilience', () => {
- it('continues with default config when loadVaultConfigFn throws after valid vault.yml', async () => {
- let foldersConfigReceived: VaultConfig | undefined;
- const validators = makeAllOkValidators();
- validators.checkVaultYmlFn = async () => ({
- check: 'vault.yml',
- status: 'ok',
- message: 'valid',
- });
- validators.loadVaultConfigFn = async () => {
- throw new Error('parse error');
- };
- validators.checkFoldersFn = async (_vaultDir, config) => {
- foldersConfigReceived = config;
- return { check: 'folders', status: 'ok', message: '8/8 present' };
- };
-
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
-
- expect(result.ok).toBe(true);
- expect(result.exitCode).toBe(0);
- expect(foldersConfigReceived?.folders.inbox).toBe('00-inbox');
- expect(foldersConfigReceived?.folders.logs).toBe('07-logs');
- });
-
- it('skips loadVaultConfigFn when checkVaultYml returns error', async () => {
- let loadCalled = false;
- const validators = makeAllOkValidators();
- validators.checkVaultYmlFn = async () => ({
- check: 'vault.yml',
- status: 'error',
- message: 'not found',
- });
- validators.loadVaultConfigFn = async () => {
- loadCalled = true;
- return DEFAULT_CONFIG;
- };
-
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
-
- expect(loadCalled).toBe(false);
- });
- });
-
- // ── Hint lines ────────────────────────────────────────────────────────────
-
- describe('hint lines', () => {
- it('includes hint line in output when a check returns a hint', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- const validators = makeAllOkValidators();
- validators.checkVaultYmlFn = async () => ({
- check: 'vault.yml',
- status: 'error',
- message: 'vault.yml not found',
- hint: 'Run onebrain init to create vault.yml',
- });
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- expect(outputChunks.join('')).toContain('→ Run onebrain init to create vault.yml');
- });
-
- it('does not include a hint line when check has no hint', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- expect(outputChunks.join('')).not.toContain('→');
- });
- });
-
- // ── Unicode symbol preservation ───────────────────────────────────────────
-
- describe('unicode symbol preservation', () => {
- it('ok status icon [✓] is present in output and survives UTF-8 encode → decode', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- const output = outputChunks.join('');
- expect(output).toContain('[✓]');
-
- // Round-trip: the output must survive UTF-8 encode → decode without data loss
- const encoded = new TextEncoder().encode(output);
- const decoded = new TextDecoder('utf-8', { fatal: true }).decode(encoded);
- expect(decoded).toBe(output);
- });
-
- it('error status icon [✗] and warn icon [!] are present in output', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- const validators = makeAllOkValidators();
- validators.checkVaultYmlFn = async () => ({
- check: 'vault.yml',
- status: 'error',
- message: 'not found',
- });
- validators.checkFoldersFn = async () => ({
- check: 'folders',
- status: 'warn',
- message: '7/8 present',
- });
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- const output = outputChunks.join('');
- expect(output).toContain('[✗]');
- expect(output).toContain('[!]');
- });
-
- it('hint arrow → is preserved in output UTF-8 round-trip', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- const validators = makeAllOkValidators();
- validators.checkVaultYmlFn = async () => ({
- check: 'vault.yml',
- status: 'error',
- message: 'vault.yml not found',
- hint: 'Run onebrain init to create vault.yml',
- });
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- const output = outputChunks.join('');
- expect(output).toContain('→');
-
- // The → arrow must survive a UTF-8 encode → decode round-trip
- const encoded = new TextEncoder().encode(output);
- const decoded = new TextDecoder('utf-8', { fatal: true }).decode(encoded);
- expect(decoded).toBe(output);
- });
-
- it('non-TTY output is valid UTF-8 (round-trip)', async () => {
- const outputChunks: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') outputChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
- } finally {
- process.stdout.write = originalWrite;
- }
-
- const output = outputChunks.join('');
- const encoded = new TextEncoder().encode(output);
- const decoded = new TextDecoder('utf-8', { fatal: true }).decode(encoded);
- expect(decoded).toBe(output);
- });
- });
-
- // ── errorCount / warningCount accuracy ────────────────────────────────────
-
- describe('result counts', () => {
- it('accurately counts multiple errors and warnings across all checks', async () => {
- const validators = makeAllOkValidators();
- validators.checkVaultYmlFn = async () => ({
- check: 'vault.yml',
- status: 'error',
- message: 'not found',
- });
- validators.checkFoldersFn = async () => ({
- check: 'folders',
- status: 'error',
- message: '0/8 present',
- });
- validators.checkOrphanCheckpointsFn = async () => ({
- check: 'orphan-checkpoints',
- status: 'warn',
- message: '1 unmerged checkpoint(s)',
- });
-
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
-
- expect(result.errorCount).toBe(2);
- expect(result.warningCount).toBe(1);
- expect(result.exitCode).toBe(1);
- });
-
- it('returns errorCount 0 and warningCount 0 when all checks pass', async () => {
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
- expect(result.errorCount).toBe(0);
- expect(result.warningCount).toBe(0);
- });
- });
-
- // ── --fix flag ─────────────────────────────────────────────────────────────
-
- describe('fix flag', () => {
- it('fix: all checks pass → nothing to fix (non-TTY)', async () => {
- const registerHooksCalled = { called: false };
- const result = await runDoctor({
- vaultDir: tempDir,
- isTTY: false,
- fix: true,
- ...makeAllOkValidators(),
- registerHooksFn: async (_vaultDir) => {
- registerHooksCalled.called = true;
- },
- });
-
- expect(result.ok).toBe(true);
- expect(result.errorCount).toBe(0);
- // registerHooks should NOT be called (nothing to fix)
- expect(registerHooksCalled.called).toBe(false);
- });
-
- it('fix: settings-hooks warn → registerHooksFn called (non-TTY)', async () => {
- const registerHooksCalled = { called: false };
-
- const result = await runDoctor({
- vaultDir: tempDir,
- isTTY: false,
- fix: true,
- ...makeAllOkValidators(),
- checkSettingsHooksFn: async () => ({
- check: 'settings-hooks',
- status: 'warn',
- message: 'SessionStart hook missing',
- hint: 'Run onebrain doctor --fix',
- }),
- registerHooksFn: async (_vaultDir) => {
- registerHooksCalled.called = true;
- },
- });
-
- expect(result.ok).toBe(true); // warn doesn't make ok=false
- expect(registerHooksCalled.called).toBe(true);
- });
- });
-
- // ── new checks contribute to counts ───────────────────────────────────────
-
- describe('new checks count contribution', () => {
- it('new checks (plugin-files, vault.yml-keys, settings-hooks) contribute to error/warning counts', async () => {
- const result = await runDoctor({
- vaultDir: tempDir,
- isTTY: false,
- ...makeAllOkValidators(),
- checkPluginFilesFn: async () => ({
- check: 'plugin-files',
- status: 'error',
- message: 'missing: INSTRUCTIONS.md',
- }),
- checkVaultYmlKeysFn: async () => ({
- check: 'vault.yml-keys',
- status: 'warn',
- message: 'deprecated key: onebrain_version',
- }),
- checkSettingsHooksFn: async () => ({
- check: 'settings-hooks',
- status: 'ok',
- message: 'hooks ok',
- }),
- });
-
- expect(result.errorCount).toBe(1);
- expect(result.warningCount).toBe(1);
- expect(result.ok).toBe(false);
- });
- });
-
- // ── #133: missing update_channel → warning + auto-fix ────────────────────
-
- describe('vault.yml missing update_channel (#133)', () => {
- it('emits warning (not error) when only update_channel is missing; --fix backfills "stable"', async () => {
- // Real vault.yml on disk so checkVaultYmlKeys + getFix both touch real files.
- const vaultYmlPath = join(tempDir, 'vault.yml');
- await writeFile(
- vaultYmlPath,
- `folders:
- inbox: 00-inbox
- projects: 01-projects
- areas: 02-areas
- knowledge: 03-knowledge
- resources: 04-resources
- agent: 05-agent
- archive: 06-archive
- logs: 07-logs
-`,
- 'utf8',
- );
-
- // 1. Validator: warning, not error
- const validators = makeAllOkValidators();
- validators.checkVaultYmlKeysFn = checkVaultYmlKeys;
-
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
-
- expect(result.errorCount).toBe(0);
- expect(result.warningCount).toBe(1);
-
- // 2. --fix runs and writes update_channel: stable
- await runDoctor({
- vaultDir: tempDir,
- isTTY: false,
- fix: true,
- ...validators,
- });
-
- const after = parseYaml(await readFile(vaultYmlPath, 'utf8')) as Record;
- expect(after['update_channel']).toBe('stable');
-
- // Idempotency: second --fix run produces byte-identical vault.yml.
- const firstBytes = await readFile(vaultYmlPath, 'utf8');
- await runDoctor({
- vaultDir: tempDir,
- isTTY: false,
- fix: true,
- ...validators,
- });
- const secondBytes = await readFile(vaultYmlPath, 'utf8');
- expect(secondBytes).toBe(firstBytes);
- });
-
- // Defense in depth (#7): the validator must propagate `missing key:
- // update_channel` into result.details — otherwise downstream getFix() at
- // doctor.ts (which checks r.details.some(...)) silently no-ops.
- it('checkVaultYmlKeys exposes "missing key: update_channel" in details when only that key is absent', async () => {
- const vaultYmlPath = join(tempDir, 'vault.yml');
- await writeFile(
- vaultYmlPath,
- `folders:
- inbox: 00-inbox
- projects: 01-projects
- areas: 02-areas
- knowledge: 03-knowledge
- resources: 04-resources
- agent: 05-agent
- archive: 06-archive
- logs: 07-logs
-`,
- 'utf8',
- );
-
- const r = await checkVaultYmlKeys(tempDir);
- expect(r.status).toBe('warn');
- expect(r.details).toBeDefined();
- expect(r.details).toContain('missing key: update_channel');
- });
- });
-
- // ── stale extraKnownMarketplaces.onebrain.source.repo → warn + auto-fix ──
-
- describe('stale extraKnownMarketplaces.onebrain.source.repo', () => {
- it('emits warning when stale repo present; --fix rewrites to canonical', async () => {
- // Vault-level settings.json: /.claude/settings.json with stale repo
- await mkdir(join(tempDir, '.claude'), { recursive: true });
- const settingsPath = join(tempDir, '.claude', 'settings.json');
- const initialContent = `${JSON.stringify(
- {
- extraKnownMarketplaces: {
- onebrain: {
- source: { repo: 'kengio/onebrain' },
- },
- },
- },
- null,
- 2,
- )}\n`;
- await writeFile(settingsPath, initialContent, 'utf8');
-
- const validators = makeAllOkValidators();
- // Real implementation reads vault-level settings.json
- validators.checkClaudeSettingsFn = (vaultDir) => checkClaudeSettings(vaultDir);
-
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
-
- expect(result.errorCount).toBe(0);
- expect(result.warningCount).toBe(1);
-
- // --fix rewrites
- await runDoctor({ vaultDir: tempDir, isTTY: false, fix: true, ...validators });
-
- const after = JSON.parse(await readFile(settingsPath, 'utf8')) as Record;
- const repo = (
- (
- (after['extraKnownMarketplaces'] as Record)?.['onebrain'] as Record<
- string,
- unknown
- >
- )?.['source'] as Record
- )?.['repo'];
- expect(repo).toBe('onebrain-ai/onebrain');
-
- // Trailing newline preserved
- expect((await readFile(settingsPath, 'utf8')).endsWith('\n')).toBe(true);
-
- // Idempotent: second --fix run is a no-op (file content unchanged)
- const beforeSecond = await readFile(settingsPath, 'utf8');
- await runDoctor({ vaultDir: tempDir, isTTY: false, fix: true, ...validators });
- const afterSecond = await readFile(settingsPath, 'utf8');
- expect(afterSecond).toBe(beforeSecond);
- });
-
- it('malformed JSON in [vault]/.claude/settings.json → warn, no throw', async () => {
- await mkdir(join(tempDir, '.claude'), { recursive: true });
- const settingsPath = join(tempDir, '.claude', 'settings.json');
- await writeFile(settingsPath, '{not json', 'utf8');
-
- const r = await checkClaudeSettings(tempDir);
- expect(r.status).toBe('warn');
- expect(r.message.toLowerCase()).toContain('json');
-
- // runDoctor (full pipeline) must not crash either
- const validators = makeAllOkValidators();
- validators.checkClaudeSettingsFn = (vaultDir) => checkClaudeSettings(vaultDir);
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- expect(result.errorCount).toBe(0);
- expect(result.warningCount).toBeGreaterThanOrEqual(1);
- });
- });
-
- // ── #5: fixFailedCount + exit code propagation ───────────────────────────
-
- describe('--fix failure propagation', () => {
- it('fix that throws → fixFailedCount === 1, exitCode 1, ok false', async () => {
- // Unix-only: relies on POSIX ENOTDIR semantics for mkdir-under-regular-file.
- // Windows surfaces a different errno and the trick isn't reliable there.
- if (process.platform === 'win32') return;
-
- // Set up: a missing-folder warning that will trigger getFix(), but the
- // fix's mkdir will fail because we point at an unwritable parent.
- const validators = makeAllOkValidators();
- validators.checkFoldersFn = async () => ({
- check: 'folders',
- status: 'error',
- message: '0/8 present',
- hint: 'Missing: 00-inbox',
- details: ['missing: 00-inbox'],
- });
-
- // Run --fix against a NON-EXISTENT vaultDir so mkdir fails
- // (parent doesn't exist, recursive: true still succeeds — instead use a
- // path component that's a regular file so mkdir errors with ENOTDIR).
- const blockerFile = join(tempDir, 'blocker');
- await writeFile(blockerFile, 'not-a-dir', 'utf8');
- const blockedVault = join(blockerFile, 'sub-vault');
-
- const result = await runDoctor({
- vaultDir: blockedVault,
- isTTY: false,
- fix: true,
- ...validators,
- });
-
- expect(result.fixFailedCount).toBe(1);
- expect(result.exitCode).toBe(1);
- expect(result.ok).toBe(false);
- });
-
- it('fix that succeeds → fixFailedCount === 0', async () => {
- const validators = makeAllOkValidators();
- validators.checkFoldersFn = async () => ({
- check: 'folders',
- status: 'error',
- message: '0/1 present',
- hint: 'Missing: 00-inbox',
- details: ['missing: 00-inbox'],
- });
-
- const result = await runDoctor({
- vaultDir: tempDir,
- isTTY: false,
- fix: true,
- ...validators,
- });
-
- expect(result.fixFailedCount).toBe(0);
- // Original folders check was an error → exitCode 1 still applies
- expect(result.errorCount).toBe(1);
- });
- });
-
- // ── #6: loadVaultConfig non-ENOENT error surfaces to stderr ──────────────
-
- describe('loadVaultConfig error surfacing', () => {
- it('non-ENOENT loadVaultConfig error writes warning to stderr; ENOENT stays silent', async () => {
- const stderrChunks: string[] = [];
- const originalWrite = process.stderr.write.bind(process.stderr);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stderr as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') stderrChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- const validators = makeAllOkValidators();
- validators.loadVaultConfigFn = async () => {
- throw new Error('parse error: unexpected token at line 3');
- };
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- } finally {
- process.stderr.write = originalWrite;
- }
-
- expect(stderrChunks.join('')).toContain('doctor: vault.yml load warning:');
- expect(stderrChunks.join('')).toContain('parse error');
- });
-
- it('ENOENT loadVaultConfig error is silent (first-run path)', async () => {
- const stderrChunks: string[] = [];
- const originalWrite = process.stderr.write.bind(process.stderr);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stderr as any).write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => {
- if (typeof chunk === 'string') stderrChunks.push(chunk);
- return originalWrite(
- chunk as string,
- ...(args as [BufferEncoding?, ((err?: Error | null) => void)?]),
- );
- };
- try {
- const validators = makeAllOkValidators();
- validators.loadVaultConfigFn = async () => {
- const err = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
- err.code = 'ENOENT';
- throw err;
- };
- await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- } finally {
- process.stderr.write = originalWrite;
- }
-
- expect(stderrChunks.join('')).not.toContain('vault.yml load warning');
- });
- });
-
- // ── #6: end-to-end smoke — 3 drifts at once ──────────────────────────────
-
- describe('end-to-end: 3 drifts in one --fix pass', () => {
- it('missing update_channel + deprecated key + stale marketplace repo all auto-fix in one run', async () => {
- // 1. vault.yml: missing update_channel + deprecated `method:` key
- const vaultYmlPath = join(tempDir, 'vault.yml');
- await writeFile(
- vaultYmlPath,
- `method: legacy
-folders:
- inbox: 00-inbox
- projects: 01-projects
- areas: 02-areas
- knowledge: 03-knowledge
- resources: 04-resources
- agent: 05-agent
- archive: 06-archive
- logs: 07-logs
-`,
- 'utf8',
- );
-
- // 2. [vault]/.claude/settings.json: stale marketplace repo
- await mkdir(join(tempDir, '.claude'), { recursive: true });
- const settingsPath = join(tempDir, '.claude', 'settings.json');
- const stale = `${JSON.stringify(
- {
- extraKnownMarketplaces: {
- onebrain: { source: { repo: 'kengio/onebrain' } },
- },
- },
- null,
- 2,
- )}\n`;
- await writeFile(settingsPath, stale, 'utf8');
-
- const validators = makeAllOkValidators();
- validators.checkVaultYmlKeysFn = checkVaultYmlKeys;
- validators.checkClaudeSettingsFn = (vaultDir) => checkClaudeSettings(vaultDir);
-
- // Pre-fix: at least 2 warnings (vault.yml-keys covers both update_channel
- // missing + deprecated method: under one check; claude-settings is the 2nd).
- const pre = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- expect(pre.warningCount).toBeGreaterThanOrEqual(2);
-
- // --fix all
- const result = await runDoctor({ vaultDir: tempDir, isTTY: false, fix: true, ...validators });
- expect(result.fixFailedCount).toBe(0);
- expect(result.errorCount).toBe(0);
-
- // Each file shows the expected post-fix state.
- const vaultAfter = parseYaml(await readFile(vaultYmlPath, 'utf8')) as Record;
- expect(vaultAfter['update_channel']).toBe('stable');
- expect(vaultAfter['method']).toBeUndefined();
-
- const settingsAfter = JSON.parse(await readFile(settingsPath, 'utf8')) as Record<
- string,
- unknown
- >;
- const repo = (
- (
- (settingsAfter['extraKnownMarketplaces'] as Record)?.[
- 'onebrain'
- ] as Record
- )?.['source'] as Record
- )?.['repo'];
- expect(repo).toBe('onebrain-ai/onebrain');
-
- // Re-run doctor (no --fix): post-fix vault is healthy on these dimensions.
- const post = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
- expect(post.errorCount).toBe(0);
- // No warning should match any of the 3 drift signatures.
- const warnTexts: string[] = [];
- // We don't have direct access to results, so re-run the validators that we
- // used for the drifts and assert they're 'ok' now.
- const ymlKeys = await checkVaultYmlKeys(tempDir);
- const claude = await checkClaudeSettings(tempDir);
- warnTexts.push(JSON.stringify(ymlKeys));
- warnTexts.push(JSON.stringify(claude));
- expect(ymlKeys.status).toBe('ok');
- expect(claude.status).toBe('ok');
- });
- });
-});
diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts
deleted file mode 100644
index 3d344b73..00000000
--- a/src/commands/doctor.ts
+++ /dev/null
@@ -1,584 +0,0 @@
-import pc from 'picocolors';
-import {
- DEFAULT_CHECKPOINT,
- type DoctorResult,
- type VaultConfig,
- atomicWrite,
- checkClaudeSettings,
- checkFolders,
- checkOrphanCheckpoints,
- checkPluginFiles,
- checkQmdEmbeddings,
- checkSettingsHooks,
- checkVaultYml,
- checkVaultYmlKeys,
- loadVaultConfig,
-} from '../lib/index.js';
-import { printBanner } from './internal/cli-banner.js';
-import {
- askYesNo,
- barBlank,
- barLine,
- barOpen,
- close,
- makeStepFn,
- writeLine,
-} from './internal/cli-ui.js';
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-export interface DoctorOptions {
- /** Vault root directory (default: process.cwd()). */
- vaultDir?: string;
- /** Whether stdout is a TTY (default: process.stdout.isTTY). */
- isTTY?: boolean;
- /** Auto-fix detected issues. */
- fix?: boolean;
- /** Injectable validators — real implementations are used when absent. */
- checkVaultYmlFn?: (vaultDir: string) => Promise;
- loadVaultConfigFn?: (vaultDir: string) => Promise;
- checkFoldersFn?: (vaultDir: string, config: VaultConfig) => Promise;
- checkQmdEmbeddingsFn?: (config: VaultConfig) => Promise;
- checkOrphanCheckpointsFn?: (vaultDir: string, config: VaultConfig) => Promise;
- checkPluginFilesFn?: (vaultDir: string) => Promise;
- checkVaultYmlKeysFn?: (vaultDir: string) => Promise;
- checkSettingsHooksFn?: (vaultDir: string, config: VaultConfig) => Promise;
- checkClaudeSettingsFn?: (vaultDir: string) => Promise;
- registerHooksFn?: (vaultDir: string) => Promise;
- /** Injectable delay (for tests — pass `async () => {}` to skip animation delays). */
- delayFn?: (ms: number) => Promise;
-}
-
-export interface DoctorCommandResult {
- ok: boolean;
- exitCode: number;
- errorCount: number;
- warningCount: number;
- /** Number of fixes that threw during `--fix`. 0 when --fix wasn't requested. */
- fixFailedCount: number;
-}
-
-// ---------------------------------------------------------------------------
-// Main runDoctor (pure, testable)
-// ---------------------------------------------------------------------------
-
-export async function runDoctor(opts: DoctorOptions = {}): Promise {
- const vaultDir = opts.vaultDir ?? process.cwd();
- const isTTY = opts.isTTY ?? process.stdout.isTTY ?? false;
-
- const checkVaultYmlFn = opts.checkVaultYmlFn ?? checkVaultYml;
- const loadVaultConfigFn = opts.loadVaultConfigFn ?? loadVaultConfig;
- const checkFoldersFn = opts.checkFoldersFn ?? checkFolders;
- const checkQmdEmbeddingsFn = opts.checkQmdEmbeddingsFn ?? checkQmdEmbeddings;
- const checkOrphanCheckpointsFn = opts.checkOrphanCheckpointsFn ?? checkOrphanCheckpoints;
- const checkPluginFilesFn = opts.checkPluginFilesFn ?? checkPluginFiles;
- const checkVaultYmlKeysFn = opts.checkVaultYmlKeysFn ?? checkVaultYmlKeys;
- const checkSettingsHooksFn = opts.checkSettingsHooksFn ?? checkSettingsHooks;
- const checkClaudeSettingsFn = opts.checkClaudeSettingsFn ?? checkClaudeSettings;
-
- if (isTTY) {
- await printBanner();
- }
-
- const createStep = makeStepFn(isTTY);
- const delay = opts.delayFn ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
- const randDelay = () =>
- isTTY ? delay(Math.floor(Math.random() * 1000) + 1000) : Promise.resolve();
-
- function fmtResult(r: DoctorResult): string {
- if (r.status === 'ok') return pc.dim(r.message);
- if (r.status === 'warn') return pc.yellow(r.message);
- return pc.red(r.message);
- }
-
- // ---------------------------------------------------------------------------
- // Default config
- // ---------------------------------------------------------------------------
-
- let config: VaultConfig = {
- folders: {
- inbox: '00-inbox',
- projects: '01-projects',
- areas: '02-areas',
- knowledge: '03-knowledge',
- resources: '04-resources',
- agent: '05-agent',
- archive: '06-archive',
- logs: '07-logs',
- },
- checkpoint: { ...DEFAULT_CHECKPOINT },
- };
-
- // ---------------------------------------------------------------------------
- // Step 1: vault.yml (always first — needed to load config)
- // ---------------------------------------------------------------------------
-
- const sp1 = createStep('📋', 'vault.yml');
- const vaultYmlResult = await checkVaultYmlFn(vaultDir);
- if (vaultYmlResult.status === 'ok') {
- try {
- config = await loadVaultConfigFn(vaultDir);
- } catch (err) {
- // ENOENT → first-run path: defaults are correct, stay silent.
- // Any other code (YAML parse, EACCES, EIO) needs to surface so the user
- // doesn't silently see "vault.yml ok" while the rest of doctor falls back
- // to defaults that don't match their actual layout.
- const code = (err as NodeJS.ErrnoException)?.code;
- if (code !== 'ENOENT') {
- const msg = err instanceof Error ? err.message : String(err);
- process.stderr.write(`doctor: vault.yml load warning: ${msg}\n`);
- }
- }
- }
- await randDelay();
- sp1?.stop(fmtResult(vaultYmlResult), vaultYmlResult.details);
-
- // ---------------------------------------------------------------------------
- // Steps 2–7: remaining checks
- // ---------------------------------------------------------------------------
-
- let foldersResult: DoctorResult;
- let qmdResult: DoctorResult;
- let orphanResult: DoctorResult;
- let pluginFilesResult: DoctorResult;
- let vaultYmlKeysResult: DoctorResult;
- let settingsHooksResult: DoctorResult;
- let claudeSettingsResult: DoctorResult;
-
- if (isTTY) {
- const sp2 = createStep('⚙️', 'Config schema');
- vaultYmlKeysResult = await checkVaultYmlKeysFn(vaultDir);
- await randDelay();
- sp2!.stop(fmtResult(vaultYmlKeysResult), vaultYmlKeysResult.details);
-
- const sp3 = createStep('📁', 'Vault folders');
- foldersResult = await checkFoldersFn(vaultDir, config);
- await randDelay();
- sp3!.stop(fmtResult(foldersResult), foldersResult.details);
-
- const sp4 = createStep('📦', 'Plugin integrity');
- pluginFilesResult = await checkPluginFilesFn(vaultDir);
- await randDelay();
- sp4!.stop(fmtResult(pluginFilesResult), pluginFilesResult.details);
-
- const sp5 = createStep('🪝', 'Hooks & permissions');
- settingsHooksResult = await checkSettingsHooksFn(vaultDir, config);
- await randDelay();
- sp5!.stop(fmtResult(settingsHooksResult), settingsHooksResult.details);
-
- const sp6 = createStep('📍', 'Orphan checkpoints');
- orphanResult = await checkOrphanCheckpointsFn(vaultDir, config);
- await randDelay();
- sp6!.stop(fmtResult(orphanResult), orphanResult.details);
-
- const sp7 = createStep('🔍', 'Search index');
- qmdResult = await checkQmdEmbeddingsFn(config);
- await randDelay();
- sp7!.stop(fmtResult(qmdResult), qmdResult.details);
-
- const sp8 = createStep('🛒', 'Marketplace config');
- claudeSettingsResult = await checkClaudeSettingsFn(vaultDir);
- await randDelay();
- sp8!.stop(fmtResult(claudeSettingsResult), claudeSettingsResult.details);
- } else {
- [
- foldersResult,
- qmdResult,
- orphanResult,
- pluginFilesResult,
- vaultYmlKeysResult,
- settingsHooksResult,
- claudeSettingsResult,
- ] = await Promise.all([
- checkFoldersFn(vaultDir, config),
- checkQmdEmbeddingsFn(config),
- checkOrphanCheckpointsFn(vaultDir, config),
- checkPluginFilesFn(vaultDir),
- checkVaultYmlKeysFn(vaultDir),
- checkSettingsHooksFn(vaultDir, config),
- checkClaudeSettingsFn(vaultDir),
- ]);
- }
-
- const results = [
- vaultYmlResult,
- vaultYmlKeysResult,
- foldersResult,
- pluginFilesResult,
- settingsHooksResult,
- orphanResult,
- qmdResult,
- claudeSettingsResult,
- ];
-
- const totalChecks = results.length;
- const errorCount = results.filter((r) => r.status === 'error').length;
- const warningCount = results.filter((r) => r.status === 'warn').length;
- // fixableCount drives the "→ Run doctor --fix" hint. Exclude advisory fixes
- // so checks like qmd-embeddings (potentially long-running, opt-in) do not
- // nudge the user toward `--fix`. They still run when --fix is invoked.
- const fixableCount = results.filter((r) => {
- if (r.status === 'ok') return false;
- const fix = getFix(r);
- return fix !== null && !fix.advisory;
- }).length;
- const showFixHint = !opts.fix && fixableCount > 0;
-
- const summaryParts = [`${totalChecks} checks`];
- if (errorCount > 0) summaryParts.push(`${errorCount} error(s)`);
- if (warningCount > 0) summaryParts.push(`${warningCount} warning(s)`);
-
- // ---------------------------------------------------------------------------
- // Output
- // ---------------------------------------------------------------------------
-
- if (!isTTY) {
- printNonTtyOutput(results, totalChecks, errorCount, warningCount, showFixHint, fixableCount);
- } else {
- if (errorCount > 0) {
- close(`${summaryParts.join(' · ')} — fix before using`, true);
- } else if (warningCount > 0) {
- close(`${summaryParts.join(' · ')} — advisory only, safe to run`, false, true);
- } else {
- close(pc.green(`${summaryParts.join(' · ')} — all passed`));
- }
- if (showFixHint) {
- process.stdout.write(
- `\n→ Run ${pc.cyan('onebrain doctor --fix')} to auto-fix ${fixableCount} issue(s)\n`,
- );
- }
- }
-
- let fixFailedCount = 0;
- if (opts.fix) {
- fixFailedCount = await applyFixes(vaultDir, results, isTTY, opts.registerHooksFn);
- }
-
- const ok = errorCount === 0 && fixFailedCount === 0;
- return {
- ok,
- exitCode: ok ? 0 : 1,
- errorCount,
- warningCount,
- fixFailedCount,
- };
-}
-
-// ---------------------------------------------------------------------------
-// CLI entry point — thin wrapper, calls process.exit
-// ---------------------------------------------------------------------------
-
-export async function doctorCommand(opts: DoctorOptions = {}): Promise {
- const result = await runDoctor(opts);
- process.exit(result.exitCode);
-}
-
-// ---------------------------------------------------------------------------
-// Non-TTY output (plain text table)
-// ---------------------------------------------------------------------------
-
-function printNonTtyOutput(
- results: DoctorResult[],
- totalChecks: number,
- errorCount: number,
- warningCount: number,
- showFixHint: boolean,
- fixableCount: number,
-): void {
- const lines: string[] = ['OneBrain Doctor', ''];
- for (const result of results) {
- const icon = result.status === 'ok' ? '[✓]' : result.status === 'warn' ? '[!]' : '[✗]';
- lines.push(` ${icon} ${result.check.padEnd(20)} ${result.message}`);
- if (result.hint) lines.push(` → ${result.hint}`);
- if (result.details) for (const d of result.details) lines.push(` · ${d}`);
- }
- lines.push('');
- if (errorCount > 0) {
- lines.push(
- `Summary: ${totalChecks} checks · ${errorCount} error(s) · ${warningCount} warning(s) — fix before using`,
- );
- } else if (warningCount > 0) {
- lines.push(`Summary: ${totalChecks} checks · ${warningCount} warning(s) — ok to run`);
- } else {
- lines.push(`Summary: ${totalChecks} checks — all passed`);
- }
- if (showFixHint)
- lines.push(`hint: run onebrain doctor --fix to auto-fix ${fixableCount} issue(s)`);
- process.stdout.write(`${lines.join('\n')}\n`);
-}
-
-// ---------------------------------------------------------------------------
-// Fix helpers
-// ---------------------------------------------------------------------------
-
-type FixFn = (
- vaultDir: string,
- registerHooksFn?: (vaultDir: string) => Promise,
-) => Promise;
-
-interface Fix {
- fn: FixFn;
- description: string;
- /**
- * Advisory fixes still run when the user explicitly invokes `--fix`, but they
- * do not contribute to `fixableCount`, so plain `onebrain doctor` does not
- * suggest `--fix` solely because of them. Use this for fixes whose work is
- * potentially long-running or otherwise opt-in (e.g. qmd embedding).
- */
- advisory?: boolean;
-}
-
-function getFix(r: DoctorResult): Fix | null {
- // settings-hooks → run register-hooks
- if (r.check === 'settings-hooks' && r.status === 'warn') {
- const issues = (r.details ?? []).filter((d) => !d.startsWith('Run '));
- const description =
- issues.length > 0
- ? `Fix: ${issues.join(', ')}`
- : 'Repair Claude Code hooks in .claude/settings.json';
- return {
- fn: async (vaultDir, registerHooksFn) => {
- const fn =
- registerHooksFn ??
- (async (dir: string) => {
- const { runRegisterHooks } = await import('./internal/register-hooks.js');
- await runRegisterHooks({ vaultDir: dir });
- });
- await fn(vaultDir);
- },
- description,
- };
- }
-
- // vault.yml-keys: deprecated keys → remove them; missing soft-required keys → backfill
- const hasDeprecatedKeys = r.details?.some(
- (d) =>
- d.includes('deprecated key: onebrain_version') ||
- d.includes('deprecated key: method') ||
- d.includes('deprecated key: runtime.harness'),
- );
- const hasMissingUpdateChannel = r.details?.some((d) => d === 'missing key: update_channel');
- if (
- r.check === 'vault.yml-keys' &&
- r.status === 'warn' &&
- (hasDeprecatedKeys || hasMissingUpdateChannel)
- ) {
- const deprecated = (r.details ?? [])
- .filter((d) => d.startsWith('deprecated key:'))
- .map((d) => d.slice('deprecated key: '.length).split(' ')[0] ?? d);
- const fixParts: string[] = [];
- if (hasMissingUpdateChannel) fixParts.push('add update_channel: stable');
- if (deprecated.length > 0) fixParts.push(`remove deprecated: ${deprecated.join(', ')}`);
- const description =
- fixParts.length > 0 ? `Fix vault.yml: ${fixParts.join('; ')}` : 'Fix vault.yml';
- return {
- fn: async (vaultDir) => {
- const { readFile } = await import('node:fs/promises');
- const { join } = await import('node:path');
- const { parse, stringify } = await import('yaml');
- const vaultYmlPath = join(vaultDir, 'vault.yml');
- const text = await readFile(vaultYmlPath, 'utf8');
- const raw = (parse(text) ?? {}) as Record;
- const details = r.details ?? [];
- if (details.some((d) => d.includes('onebrain_version')))
- raw['onebrain_version'] = undefined;
- if (details.some((d) => d.includes('deprecated key: method'))) raw['method'] = undefined;
- if (details.some((d) => d.includes('runtime.harness'))) {
- const runtime = raw['runtime'] as Record | undefined;
- if (runtime) {
- runtime['harness'] = undefined;
- if (Object.keys(runtime).filter((k) => runtime[k] !== undefined).length === 0) {
- raw['runtime'] = undefined;
- }
- }
- }
- if (details.some((d) => d === 'missing key: update_channel')) {
- raw['update_channel'] = 'stable';
- }
- await atomicWrite(vaultYmlPath, stringify(raw, { lineWidth: 0 }), 'vault.yml');
- },
- description,
- };
- }
-
- // qmd-embeddings: unembedded docs → qmd update + qmd embed.
- // Marked advisory so plain `onebrain doctor` does not nudge the user toward
- // `--fix` solely for embeddings (embedding can be slow). When the user does
- // run `--fix`, the embedding still happens.
- if (r.check === 'qmd-embeddings' && r.status === 'warn' && r.message.includes('unembedded')) {
- const pendingMatch = r.message.match(/(\d+) unembedded/);
- const count = pendingMatch?.[1] ?? 'some';
- return {
- advisory: true,
- fn: async (vaultDir) => {
- const { join } = await import('node:path');
- const { parse: parseYaml } = await import('yaml');
- const { readFile } = await import('node:fs/promises');
- const raw = parseYaml(await readFile(join(vaultDir, 'vault.yml'), 'utf8')) as Record<
- string,
- unknown
- >;
- const collection = raw['qmd_collection'] as string | undefined;
- if (!collection) return;
-
- const qmd =
- Bun.which('qmd') ??
- Bun.which('qmd', {
- PATH: `${process.env['HOME'] ?? ''}/.bun/bin:${process.env['PATH'] ?? ''}`,
- });
- if (!qmd) return;
-
- await Bun.spawn([qmd, 'update', '-c', collection], {
- stdout: 'ignore',
- stderr: 'ignore',
- }).exited;
-
- await Bun.spawn([qmd, 'embed'], {
- stdout: 'ignore',
- stderr: 'ignore',
- }).exited;
- },
- description: `Embed ${count} unembedded document(s) (qmd update + embed)`,
- };
- }
-
- // claude-settings: stale extraKnownMarketplaces.onebrain.source.repo → rewrite
- if (
- r.check === 'claude-settings' &&
- r.status === 'warn' &&
- r.details?.some((d) => d.startsWith('stale extraKnownMarketplaces.onebrain.source.repo:'))
- ) {
- return {
- fn: async (vaultDir) => {
- const { readFile } = await import('node:fs/promises');
- const { join } = await import('node:path');
- const settingsPath = join(vaultDir, '.claude', 'settings.json');
- const text = await readFile(settingsPath, 'utf8');
- const raw = JSON.parse(text) as Record;
- const marketplaces = raw['extraKnownMarketplaces'] as Record | undefined;
- const onebrain = marketplaces?.['onebrain'] as Record | undefined;
- const source = onebrain?.['source'] as Record | undefined;
- if (!source || source['repo'] !== 'kengio/onebrain') return; // idempotent — already canonical or absent
- source['repo'] = 'onebrain-ai/onebrain';
- // Preserve 2-space indentation (matches Claude Code's own formatter) + trailing newline
- const trailingNewline = text.endsWith('\n') ? '\n' : '';
- const updated = `${JSON.stringify(raw, null, 2)}${trailingNewline}`;
- await atomicWrite(settingsPath, updated, '.claude/settings.json');
- },
- description: 'Rewrite stale marketplace repo: kengio/onebrain → onebrain-ai/onebrain',
- };
- }
-
- // folders missing → mkdir -p
- if (r.check === 'folders' && r.status === 'error' && r.hint) {
- const missingStr = r.hint.replace('Missing: ', '');
- return {
- fn: async (vaultDir) => {
- const { mkdirIdempotent } = await import('../lib/index.js');
- const { join } = await import('node:path');
- const missing = missingStr
- .split(', ')
- .map((f) => f.trim())
- .filter(Boolean);
- for (const folder of missing) {
- await mkdirIdempotent(join(vaultDir, folder));
- }
- },
- description: `Create missing folders: ${missingStr}`,
- };
- }
-
- return null;
-}
-
-async function applyFixes(
- vaultDir: string,
- results: DoctorResult[],
- isTTY: boolean,
- registerHooksFn: ((vaultDir: string) => Promise) | undefined,
-): Promise {
- const fixable = results.filter((r) => r.status !== 'ok' && getFix(r) !== null);
-
- if (fixable.length === 0) {
- // The previous close() already closed the bar pattern with `└`; render
- // "Nothing to fix" as a plain line (no `│` prefix) to match that closure.
- if (isTTY) writeLine(`${pc.green('◆')} Nothing to fix`);
- else writeLine('nothing to fix');
- return 0;
- }
-
- if (isTTY) {
- // The previous close() emitted └. Start a fresh bar group with ┌ so
- // the fix-application section reads as its own clack-style box.
- writeLine('');
- barOpen(pc.bold(`${fixable.length} fix(es) to apply:`));
- barBlank();
- for (const r of fixable) {
- barLine(` ${pc.cyan('◆')} ${getFix(r)!.description}`);
- }
- barBlank();
-
- const answer = await askYesNo('Apply all?');
-
- if (answer === null || answer === false) {
- barLine(pc.dim('No'));
- barBlank();
- close(`No changes made — run ${pc.cyan('onebrain doctor --fix')} to apply`);
- return 0;
- }
- barLine('Yes');
- barBlank();
- }
-
- let fixed = 0;
- let fixFailed = 0;
- const unfixable: DoctorResult[] = [];
-
- for (const r of results) {
- if (r.status === 'ok') continue;
- const fix = getFix(r);
- if (!fix) {
- unfixable.push(r);
- continue;
- }
- try {
- await fix.fn(vaultDir, registerHooksFn);
- fixed++;
- if (isTTY) barLine(`${pc.green('◆')} ${fix.description}`);
- } catch (err) {
- fixFailed++;
- const errMsg = err instanceof Error ? err.message : String(err);
- if (isTTY) {
- barLine(`${pc.yellow('▲')} Could not fix ${r.check}: ${errMsg}`);
- } else {
- process.stderr.write(`doctor: fix failed for ${r.check}: ${errMsg}\n`);
- }
- }
- }
-
- if (isTTY) {
- barBlank();
- if (fixed > 0) barLine(`${pc.green('◆')} Fixed ${fixed} issue(s)`);
- if (fixFailed > 0) {
- barLine(`${pc.yellow('▲')} ${fixFailed} fix(es) failed — see warnings above`);
- }
- if (unfixable.length > 0) {
- barLine(`${pc.yellow('▲')} ${unfixable.length} issue(s) require manual action:`);
- for (const r of unfixable) {
- barLine(` ${r.check}: ${r.hint ?? 'no auto-fix available'}`);
- }
- }
- barBlank();
- close('Done');
- } else {
- process.stdout.write(`fixed: ${fixed}\n`);
- if (fixFailed > 0) process.stdout.write(`fix-failed: ${fixFailed}\n`);
- if (unfixable.length > 0) {
- process.stdout.write(`manual: ${unfixable.map((r) => r.check).join(', ')}\n`);
- }
- }
-
- return fixFailed;
-}
diff --git a/src/commands/init.integration.test.ts b/src/commands/init.integration.test.ts
deleted file mode 100644
index 829fdcc7..00000000
--- a/src/commands/init.integration.test.ts
+++ /dev/null
@@ -1,359 +0,0 @@
-/**
- * Integration tests for `initCommand` (the CLI entry point that wraps runInit).
- *
- * These tests exercise the full init flow end-to-end through initCommand(), which
- * calls runInit() and then process.exit(). All network calls (vault-sync, register-hooks)
- * are injected as mocks so tests run offline and fast.
- *
- * Note: initCommand() calls process.exit(1) on failure. We test runInit() directly for
- * error cases where the exit would abort the test process.
- */
-
-import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
-import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
-import { homedir, tmpdir } from 'node:os';
-import { join } from 'node:path';
-
-import { type InitOptions, runInit } from './init.js';
-
-// ---------------------------------------------------------------------------
-// Suite-level guard: real ~/.claude/plugins/installed_plugins.json must NOT
-// be touched by any test in this file (#146 regression hardening).
-// ---------------------------------------------------------------------------
-
-const REAL_REGISTRY_PATH = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
-let realRegistrySnapshot: { mtimeMs: number; bytes: string } | null = null;
-let realRegistryWasMissing = false;
-
-beforeAll(async () => {
- try {
- const s = await stat(REAL_REGISTRY_PATH);
- const bytes = await readFile(REAL_REGISTRY_PATH, 'utf8');
- realRegistrySnapshot = { mtimeMs: s.mtimeMs, bytes };
- } catch {
- realRegistryWasMissing = true;
- }
-});
-
-afterAll(async () => {
- if (realRegistryWasMissing) {
- let exists = false;
- try {
- await stat(REAL_REGISTRY_PATH);
- exists = true;
- } catch {
- exists = false;
- }
- if (exists) {
- throw new Error(
- `Test suite created ${REAL_REGISTRY_PATH} which did not exist before tests ran (#146 regression).`,
- );
- }
- return;
- }
- if (!realRegistrySnapshot) return;
- const after = await readFile(REAL_REGISTRY_PATH, 'utf8');
- if (after !== realRegistrySnapshot.bytes) {
- throw new Error(
- `Test suite mutated ${REAL_REGISTRY_PATH} (#146 regression). Some test omitted installedPluginsPath injection. Bytes differ: before=${realRegistrySnapshot.bytes.length}, after=${after.length}.`,
- );
- }
-});
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-async function makeTempVault(): Promise {
- const dir = join(
- tmpdir(),
- `onebrain-init-int-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
- );
- await mkdir(dir, { recursive: true });
- return dir;
-}
-
-async function fileExists(path: string): Promise {
- try {
- await stat(path);
- return true;
- } catch {
- return false;
- }
-}
-
-async function readVaultYml(vaultDir: string): Promise> {
- const { parse } = await import('yaml');
- const text = await readFile(join(vaultDir, 'vault.yml'), 'utf8');
- return (parse(text) ?? {}) as Record;
-}
-
-const noopVaultSync = async (_vaultDir: string, _opts: Record) => {};
-const noopRegisterHooks = async (_vaultDir: string) => {};
-
-const STANDARD_FOLDERS = [
- '00-inbox',
- '01-projects',
- '02-areas',
- '03-knowledge',
- '04-resources',
- '05-agent',
- '06-archive',
- '07-logs',
-];
-
-let tempDir: string;
-// Per-test isolated installed_plugins.json. Tests must NEVER fall back to
-// the real ~/.claude/plugins/installed_plugins.json (#146).
-let isolatedInstalledPath: string;
-
-beforeEach(async () => {
- tempDir = await makeTempVault();
- isolatedInstalledPath = join(tempDir, '.isolated-installed_plugins.json');
-});
-
-afterEach(async () => {
- await rm(tempDir, { recursive: true, force: true });
-});
-
-// ---------------------------------------------------------------------------
-// Scenario 1: Fresh vault (non-TTY) — all 8 folders created, vault.yml with folders: section
-// ---------------------------------------------------------------------------
-
-describe('init integration: fresh vault (non-TTY)', () => {
- it('creates all 8 standard folders plus inbox/imports sub-directory', async () => {
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(true);
- expect(result.exitCode).toBe(0);
-
- for (const folder of STANDARD_FOLDERS) {
- expect(await fileExists(join(tempDir, folder))).toBe(true);
- }
- expect(await fileExists(join(tempDir, '00-inbox', 'imports'))).toBe(true);
- });
-
- it('vault.yml exists with required top-level fields and folders: section', async () => {
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
- expect(result.ok).toBe(true);
-
- expect(await fileExists(join(tempDir, 'vault.yml'))).toBe(true);
-
- const parsed = await readVaultYml(tempDir);
- expect(parsed).toHaveProperty('folders');
- expect(typeof parsed['folders']).toBe('object');
- // Verify all expected folder keys are present
- const folders = parsed['folders'] as Record;
- expect(folders['inbox']).toBe('00-inbox');
- expect(folders['logs']).toBe('07-logs');
- expect(parsed['update_channel']).toBeDefined();
- expect(parsed['update_channel']).toBe('stable');
- });
-
- it('command completes without throwing, result.ok is true', async () => {
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- // Should not throw
- await expect(runInit(opts)).resolves.toMatchObject({ ok: true, exitCode: 0 });
- });
-});
-
-// ---------------------------------------------------------------------------
-// Scenario 2: Existing vault.yml + non-TTY + no --force → exit 1 with error
-// ---------------------------------------------------------------------------
-
-describe('init integration: existing vault.yml, no --force (non-TTY)', () => {
- it('returns exitCode 1 and error message containing vault.yml and --force', async () => {
- await writeFile(join(tempDir, 'vault.yml'), 'method: onebrain\n', 'utf8');
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(false);
- expect(result.exitCode).toBe(1);
- expect(result.message).toBeDefined();
- expect(result.message).toMatch(/vault\.yml/);
- expect(result.message).toMatch(/--force/);
- });
-
- it('does not create folders or overwrite vault.yml when returning early', async () => {
- const originalContent = 'method: legacy\n';
- await writeFile(join(tempDir, 'vault.yml'), originalContent, 'utf8');
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- await runInit(opts);
-
- // vault.yml should be unchanged
- const content = await readFile(join(tempDir, 'vault.yml'), 'utf8');
- expect(content).toBe(originalContent);
-
- // Folders should NOT have been created (early return before folder step)
- expect(await fileExists(join(tempDir, '00-inbox'))).toBe(false);
- });
-});
-
-// ---------------------------------------------------------------------------
-// Scenario 3: Plugin files present → skip vault-sync download
-// ---------------------------------------------------------------------------
-
-describe('init integration: plugin files present (skip vault-sync)', () => {
- it('skips vault-sync when .claude/plugins/onebrain/plugin.json already exists', async () => {
- // Pre-create plugin.json to simulate existing plugin files
- const pluginMetaDir = join(tempDir, '.claude', 'plugins', 'onebrain', '.claude-plugin');
- await mkdir(pluginMetaDir, { recursive: true });
- await writeFile(
- join(pluginMetaDir, 'plugin.json'),
- JSON.stringify({ version: '1.11.0' }),
- 'utf8',
- );
-
- let vaultSyncCallCount = 0;
- const trackingVaultSync = async (_vaultDir: string, _opts: Record) => {
- vaultSyncCallCount++;
- };
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- force: true,
- vaultSyncFn: trackingVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(true);
- expect(result.pluginSkipped).toBe(true);
- // vault-sync was NOT called because plugin files already exist
- expect(vaultSyncCallCount).toBe(0);
- });
-
- it('still creates folders and vault.yml even when vault-sync is skipped', async () => {
- const pluginDir = join(tempDir, '.claude', 'plugins', 'onebrain');
- await mkdir(pluginDir, { recursive: true });
- await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({ version: '1.11.0' }), 'utf8');
-
- // Pre-create vault.yml so --force is needed
- await writeFile(join(tempDir, 'vault.yml'), 'method: legacy\n', 'utf8');
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- force: true,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(true);
-
- // Folders created
- for (const folder of STANDARD_FOLDERS) {
- expect(await fileExists(join(tempDir, folder))).toBe(true);
- }
-
- // vault.yml updated (legacy method key is gone)
- const parsed = await readVaultYml(tempDir);
- expect(parsed['update_channel']).toBeDefined();
- });
-});
-
-// ---------------------------------------------------------------------------
-// Scenario 4: marketplace source in installed_plugins.json → skip registration
-// ---------------------------------------------------------------------------
-
-describe('init integration: marketplace source → skip plugin registration', () => {
- it('skips plugin registration when installed_plugins.json has source: marketplace', async () => {
- // Set up fake installed_plugins.json with marketplace entry
- const pluginsMetaDir = join(tempDir, '.claude-meta');
- await mkdir(pluginsMetaDir, { recursive: true });
- const installedPluginsPath = join(pluginsMetaDir, 'installed_plugins.json');
- await writeFile(
- installedPluginsPath,
- JSON.stringify({
- plugins: {
- 'onebrain@1.0.0': [{ source: 'marketplace', installPath: '/some/marketplace/path' }],
- },
- }),
- 'utf8',
- );
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- installedPluginsPath,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(true);
- expect(result.pluginRegistrationSkipped).toBe(true);
- });
-
- it('does not crash or exit non-zero when marketplace entry present', async () => {
- const pluginsMetaDir = join(tempDir, '.claude-meta');
- await mkdir(pluginsMetaDir, { recursive: true });
- const installedPluginsPath = join(pluginsMetaDir, 'installed_plugins.json');
- await writeFile(
- installedPluginsPath,
- JSON.stringify({
- plugins: {
- 'onebrain@2.0.0': [{ source: 'marketplace', installPath: '/marketplace/path' }],
- },
- }),
- 'utf8',
- );
-
- // Should complete normally
- await expect(
- runInit({
- vaultDir: tempDir,
- isTTY: false,
- installedPluginsPath,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- }),
- ).resolves.toMatchObject({ ok: true, exitCode: 0 });
- });
-});
diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts
deleted file mode 100644
index ef39a109..00000000
--- a/src/commands/init.test.ts
+++ /dev/null
@@ -1,422 +0,0 @@
-/**
- * Unit tests for `onebrain init`
- *
- * Tests run against a temp vault dir. Process TTY is always false in test
- * (piped stdout), so all non-TTY paths are exercised directly.
- *
- * Vault-sync and register-hooks are mocked so tests stay offline and fast.
- */
-
-import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
-import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
-import { homedir, tmpdir } from 'node:os';
-import { join } from 'node:path';
-
-import { type InitOptions, runInit } from './init.js';
-
-// ---------------------------------------------------------------------------
-// Suite-level guard: real ~/.claude/plugins/installed_plugins.json must NOT
-// be touched by any test in this file (#146 regression hardening).
-// ---------------------------------------------------------------------------
-
-const REAL_REGISTRY_PATH = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
-let realRegistrySnapshot: { mtimeMs: number; bytes: string } | null = null;
-let realRegistryWasMissing = false;
-
-beforeAll(async () => {
- try {
- const s = await stat(REAL_REGISTRY_PATH);
- const bytes = await readFile(REAL_REGISTRY_PATH, 'utf8');
- realRegistrySnapshot = { mtimeMs: s.mtimeMs, bytes };
- } catch {
- realRegistryWasMissing = true;
- }
-});
-
-afterAll(async () => {
- if (realRegistryWasMissing) {
- let exists = false;
- try {
- await stat(REAL_REGISTRY_PATH);
- exists = true;
- } catch {
- exists = false;
- }
- if (exists) {
- throw new Error(
- `Test suite created ${REAL_REGISTRY_PATH} which did not exist before tests ran (#146 regression).`,
- );
- }
- return;
- }
- if (!realRegistrySnapshot) return;
- const after = await readFile(REAL_REGISTRY_PATH, 'utf8');
- if (after !== realRegistrySnapshot.bytes) {
- throw new Error(
- `Test suite mutated ${REAL_REGISTRY_PATH} (#146 regression). Some test omitted installedPluginsPath injection. Bytes differ: before=${realRegistrySnapshot.bytes.length}, after=${after.length}.`,
- );
- }
-});
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-async function makeTempVault(): Promise {
- const dir = join(
- tmpdir(),
- `onebrain-init-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
- );
- await mkdir(dir, { recursive: true });
- return dir;
-}
-
-async function fileExists(path: string): Promise {
- try {
- await stat(path);
- return true;
- } catch {
- return false;
- }
-}
-
-async function readVaultYml(vaultDir: string): Promise> {
- const { parse } = await import('yaml');
- const text = await readFile(join(vaultDir, 'vault.yml'), 'utf8');
- return (parse(text) ?? {}) as Record;
-}
-
-// Noop mocks — capture calls but do nothing
-const noopVaultSync = async (_vaultDir: string, _opts: Record) => {
- // no-op
-};
-
-const noopRegisterHooks = async (_vaultDir: string) => {
- // no-op
-};
-
-let tempDir: string;
-// Per-test isolated installed_plugins.json. Tests must NEVER fall back to
-// the real ~/.claude/plugins/installed_plugins.json (#146).
-let isolatedInstalledPath: string;
-
-beforeEach(async () => {
- tempDir = await makeTempVault();
- isolatedInstalledPath = join(tempDir, '.isolated-installed_plugins.json');
-});
-
-afterEach(async () => {
- await rm(tempDir, { recursive: true, force: true });
-});
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-describe('runInit', () => {
- it('fresh vault — creates all standard folders + vault.yml + calls vault-sync', async () => {
- let vaultSyncCalled = false;
- const mockVaultSync = async (_vaultDir: string, _opts: Record) => {
- vaultSyncCalled = true;
- };
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- vaultSyncFn: mockVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(true);
-
- // All 8 standard folders should exist
- const folders = [
- '00-inbox',
- '01-projects',
- '02-areas',
- '03-knowledge',
- '04-resources',
- '05-agent',
- '06-archive',
- '07-logs',
- ];
- for (const folder of folders) {
- expect(await fileExists(join(tempDir, folder))).toBe(true);
- }
-
- // imports subdirectory inside inbox
- expect(await fileExists(join(tempDir, '00-inbox', 'imports'))).toBe(true);
-
- // vault.yml written
- expect(await fileExists(join(tempDir, 'vault.yml'))).toBe(true);
- const vaultYml = await readVaultYml(tempDir);
- expect(vaultYml['update_channel']).toBe('stable');
-
- // folders count: 8 standard + inbox/imports = 9 total
- expect(result.foldersCreated).toBe(9);
-
- // vault-sync was called (plugin files not present)
- expect(vaultSyncCalled).toBe(true);
- });
-
- it('existing vault.yml in non-TTY → exit 1 with message', async () => {
- await writeFile(join(tempDir, 'vault.yml'), 'method: onebrain\n', 'utf8');
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(false);
- expect(result.exitCode).toBe(1);
- expect(result.message).toContain('vault.yml exists');
- expect(result.message).toContain('--force');
- });
-
- it('--force overwrites existing vault.yml', async () => {
- await writeFile(join(tempDir, 'vault.yml'), 'method: legacy\n', 'utf8');
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- force: true,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(true);
- const vaultYml = await readVaultYml(tempDir);
- expect(vaultYml['update_channel']).toBe('stable');
- });
-
- it('plugin files already present — skips vault-sync download', async () => {
- // Pre-create plugin.json to simulate existing plugin files
- const pluginMetaDir = join(tempDir, '.claude', 'plugins', 'onebrain', '.claude-plugin');
- await mkdir(pluginMetaDir, { recursive: true });
- await writeFile(
- join(pluginMetaDir, 'plugin.json'),
- JSON.stringify({ version: '1.11.0' }),
- 'utf8',
- );
-
- let vaultSyncCalled = false;
- const mockVaultSync = async (_vaultDir: string, _opts: Record) => {
- vaultSyncCalled = true;
- };
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- vaultSyncFn: mockVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(true);
- expect(vaultSyncCalled).toBe(false);
- expect(result.pluginSkipped).toBe(true);
- });
-
- it('source:marketplace in installed_plugins.json — skips plugin registration', async () => {
- // Create installed_plugins.json with marketplace entry
- const pluginsDir = join(tempDir, '.claude-meta');
- await mkdir(pluginsDir, { recursive: true });
- const installedPluginsPath = join(pluginsDir, 'installed_plugins.json');
- await writeFile(
- installedPluginsPath,
- JSON.stringify({
- plugins: {
- 'onebrain@1.0.0': [{ source: 'marketplace', installPath: '/some/path' }],
- },
- }),
- 'utf8',
- );
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(true);
- expect(result.pluginRegistrationSkipped).toBe(true);
- });
-
- it('existing folders not double-counted in foldersCreated', async () => {
- // Pre-create some folders
- await mkdir(join(tempDir, '00-inbox'), { recursive: true });
- await mkdir(join(tempDir, '01-projects'), { recursive: true });
-
- const opts: InitOptions = {
- vaultDir: tempDir,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
-
- const result = await runInit(opts);
-
- expect(result.ok).toBe(true);
- // 9 total (8 standard + imports), 2 already exist → 7 created
- expect(result.foldersCreated).toBe(7);
- });
-
- it('non-TTY output starts with OneBrain Init header', async () => {
- const lines: string[] = [];
- const originalWrite = process.stdout.write.bind(process.stdout);
- // biome-ignore lint/suspicious/noExplicitAny: overriding overloaded write for test capture
- (process.stdout as any).write = (
- chunk: string | Uint8Array,
- encoding?: BufferEncoding,
- cb?: (err?: Error | null) => void,
- ): boolean => {
- if (typeof chunk === 'string') lines.push(chunk);
- else if (chunk instanceof Uint8Array) lines.push(Buffer.from(chunk).toString('utf8'));
- return originalWrite(chunk, encoding as BufferEncoding, cb);
- };
-
- try {
- const opts: InitOptions = {
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- };
- const result = await runInit(opts);
- expect(result.ok).toBe(true);
- } finally {
- process.stdout.write = originalWrite;
- }
-
- const fullOutput = lines.join('');
- expect(fullOutput).toMatch(/^OneBrain Init\n/);
- });
-
- it('installPluginsFn: community-plugins.json missing → pluginsInstalled=0, pluginsFailed=0', async () => {
- const result = await runInit({
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- installPluginsFn: async (_vaultDir, _opts) => {
- // community-plugins.json doesn't exist — return empty
- return { installed: [], failed: [] };
- },
- });
-
- expect(result.ok).toBe(true);
- expect(result.pluginsInstalled).toBe(0);
- expect(result.pluginsFailed).toBe(0);
- });
-
- it('installPluginsFn: invalid plugin ID → pluginsFailed counted', async () => {
- const result = await runInit({
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- installPluginsFn: async (_vaultDir, _opts) => {
- // Invalid ID rejected
- return { installed: [], failed: [{ id: 'bad/id', reason: 'invalid id' }] };
- },
- });
-
- expect(result.ok).toBe(true);
- expect(result.pluginsFailed).toBe(1);
- expect(result.pluginsInstalled).toBe(0);
- });
-
- it('vault-sync fatal failure → exitCode 1', async () => {
- const result = await runInit({
- vaultDir: tempDir,
- isTTY: false,
- vaultSyncFn: async (_vaultDir, _opts) => {
- throw new Error('network error');
- },
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- });
-
- expect(result.ok).toBe(false);
- expect(result.exitCode).toBe(1);
- });
-
- it('TTY — directory confirm cancelled → aborts before creating any files', async () => {
- const result = await runInit({
- vaultDir: tempDir,
- isTTY: true,
- confirmFn: async () => false,
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- });
-
- expect(result.ok).toBe(true);
- expect(result.exitCode).toBe(0);
- // Nothing should have been created
- const hasVaultYml = await fileExists(join(tempDir, 'vault.yml'));
- const has00inbox = await fileExists(join(tempDir, '00-inbox'));
- expect(hasVaultYml).toBe(false);
- expect(has00inbox).toBe(false);
- });
-
- it('TTY — existing vault.yml + confirm dir + decline overwrite → aborts without overwriting', async () => {
- await writeFile(join(tempDir, 'vault.yml'), 'method: legacy\n', 'utf8');
-
- let callCount = 0;
- const result = await runInit({
- vaultDir: tempDir,
- isTTY: true,
- confirmFn: async () => {
- callCount++;
- return callCount === 1; // yes to dir, no to overwrite
- },
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- });
-
- expect(result.ok).toBe(true);
- expect(result.exitCode).toBe(0);
- // vault.yml must remain unchanged
- const text = await readFile(join(tempDir, 'vault.yml'), 'utf8');
- expect(text).toContain('method: legacy');
- });
-
- it('TTY + --force — skips directory confirmation, runs init', async () => {
- let confirmCalled = false;
- const result = await runInit({
- vaultDir: tempDir,
- isTTY: true,
- force: true,
- confirmFn: async () => {
- confirmCalled = true;
- return true;
- },
- vaultSyncFn: noopVaultSync,
- registerHooksFn: noopRegisterHooks,
- installedPluginsPath: isolatedInstalledPath,
- installPluginsFn: async () => ({ installed: [], failed: [] }),
- delayFn: async () => {},
- });
-
- expect(result.ok).toBe(true);
- expect(confirmCalled).toBe(false);
- });
-});
diff --git a/src/commands/init.ts b/src/commands/init.ts
deleted file mode 100644
index 53a6fdab..00000000
--- a/src/commands/init.ts
+++ /dev/null
@@ -1,745 +0,0 @@
-/**
- * init — Initialize a new OneBrain vault
- *
- * Steps:
- * 1. Detect existing vault.yml (--force, non-TTY exit-1, TTY prompt)
- * 2. Write vault.yml
- * 3. Create standard folders (8 + inbox/imports)
- * 4. Download plugin files (skip if .claude/plugins/onebrain/.claude-plugin/plugin.json exists)
- * 4b. Install Obsidian community plugins
- * 5. Register plugin (skip if source:marketplace entry exists)
- * 6. Run register-hooks
- *
- * TTY: uses @clack/prompts layout
- * Non-TTY: plain text lines
- *
- * Exit code: 0 on success, 1 on failure.
- */
-
-import { readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
-import { homedir } from 'node:os';
-import { dirname, join } from 'node:path';
-import pc from 'picocolors';
-import { stringify as stringifyYaml } from 'yaml';
-import { mkdirIdempotent } from '../lib/index.js';
-import { printBanner, resolveBinaryVersion } from './internal/cli-banner.js';
-import {
- askYesNo,
- barBlank,
- barLine,
- close,
- dotLine,
- makeStepFn,
- writeLine,
-} from './internal/cli-ui.js';
-
-const binaryVersion = resolveBinaryVersion();
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-export interface InitOptions {
- /** Vault root directory (default: process.cwd()). */
- vaultDir?: string;
- /** Overwrite existing vault.yml without prompting. */
- force?: boolean;
- /** Whether stdout is a TTY (default: process.stdout.isTTY). */
- isTTY?: boolean;
- /** Override path to installed_plugins.json (for tests). */
- installedPluginsPath?: string;
- /** Injectable vault-sync function (for tests). */
- vaultSyncFn?: (
- vaultDir: string,
- opts: { branch?: string; includeObsidian?: boolean; embedded?: boolean },
- ) => Promise;
- /** Injectable community plugin installer function (for tests). */
- installPluginsFn?: (
- vaultDir: string,
- opts: { githubToken?: string },
- ) => Promise;
- /** Injectable register-hooks function (for tests). */
- registerHooksFn?: (vaultDir: string) => Promise;
- /** Injectable delay function (for tests — bypasses artificial pauses). */
- delayFn?: (ms: number) => Promise;
- /** Injectable confirmation prompt (for tests — replaces askYesNo). */
- confirmFn?: (question: string) => Promise;
-}
-
-export interface InitResult {
- ok: boolean;
- exitCode: number;
- /** Human-readable message (used for non-TTY output / test assertions). */
- message?: string;
- foldersCreated: number;
- pluginSkipped: boolean;
- pluginRegistrationSkipped: boolean;
- pluginsInstalled: number;
- pluginsFailed: number;
-}
-
-export interface PluginInstallResult {
- installed: string[];
- failed: Array<{ id: string; reason: string }>;
-}
-
-// ---------------------------------------------------------------------------
-// Standard vault folders
-// ---------------------------------------------------------------------------
-
-/** [folder-path, create-parent-only] */
-const STANDARD_FOLDERS: string[] = [
- '00-inbox',
- '01-projects',
- '02-areas',
- '03-knowledge',
- '04-resources',
- '05-agent',
- '06-archive',
- '07-logs',
-];
-
-// inbox/imports is a sub-directory that must also be created
-const INBOX_IMPORTS = join('00-inbox', 'imports');
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-async function pathExists(p: string): Promise {
- try {
- await stat(p);
- return true;
- } catch {
- return false;
- }
-}
-
-// ---------------------------------------------------------------------------
-// Steps
-// ---------------------------------------------------------------------------
-
-async function createFolders(vaultDir: string): Promise {
- let created = 0;
-
- const allPaths = [...STANDARD_FOLDERS, INBOX_IMPORTS];
-
- for (const rel of allPaths) {
- const full = join(vaultDir, rel);
- if (!(await pathExists(full))) {
- await mkdirIdempotent(full);
- created++;
- }
- }
-
- return created;
-}
-
-const VAULT_YML_DEFAULTS = {
- update_channel: 'stable',
- folders: {
- inbox: '00-inbox',
- projects: '01-projects',
- areas: '02-areas',
- knowledge: '03-knowledge',
- resources: '04-resources',
- agent: '05-agent',
- archive: '06-archive',
- logs: '07-logs',
- },
- checkpoint: {
- messages: 15,
- minutes: 30,
- },
-};
-
-async function writeVaultYml(vaultDir: string): Promise {
- const content = stringifyYaml(VAULT_YML_DEFAULTS, { lineWidth: 0 });
- await writeFile(join(vaultDir, 'vault.yml'), content, 'utf8');
-}
-
-/**
- * Step 4: Download plugin files (skip if already present).
- * Returns { skipped, driftWarning }.
- */
-async function downloadPluginFiles(
- vaultDir: string,
- vaultSyncFn: (
- vaultDir: string,
- opts: { branch?: string; includeObsidian?: boolean; embedded?: boolean },
- ) => Promise,
-): Promise<{ skipped: boolean; driftWarning?: string; failed?: boolean }> {
- const pluginJsonPath = join(
- vaultDir,
- '.claude',
- 'plugins',
- 'onebrain',
- '.claude-plugin',
- 'plugin.json',
- );
-
- if (await pathExists(pluginJsonPath)) {
- // Check version drift
- let pluginVersion: string | undefined;
- try {
- const text = await readFile(pluginJsonPath, 'utf8');
- const parsed = JSON.parse(text) as Record;
- pluginVersion = typeof parsed['version'] === 'string' ? parsed['version'] : undefined;
- } catch {
- // Non-fatal
- }
-
- let driftWarning: string | undefined;
- if (pluginVersion && binaryVersion !== 'dev' && pluginVersion !== binaryVersion) {
- driftWarning = `Plugin files v${pluginVersion}, binary v${binaryVersion} — run onebrain update to sync.`;
- }
-
- return driftWarning !== undefined ? { skipped: true, driftWarning } : { skipped: true };
- }
-
- // Plugin files not present — run vault-sync (non-fatal)
- try {
- await vaultSyncFn(vaultDir, { includeObsidian: true, embedded: true });
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- process.stderr.write(`init: vault-sync warning: ${msg}\n`);
- return { skipped: false, failed: true };
- }
-
- return { skipped: false };
-}
-
-async function countPluginContents(vaultDir: string): Promise<{ skills: number; agents: number }> {
- const base = join(vaultDir, '.claude', 'plugins', 'onebrain');
- const [skillEntries, agentEntries] = await Promise.all([
- readdir(join(base, 'skills'), { withFileTypes: true }).catch(() => []),
- readdir(join(base, 'agents'), { withFileTypes: true }).catch(() => []),
- ]);
- return {
- skills: skillEntries.filter((e) => e.isDirectory()).length,
- agents: agentEntries.filter((e) => e.name.endsWith('.md')).length,
- };
-}
-
-/**
- * Step 5: Register plugin in installed_plugins.json.
- * Skips if a source:marketplace entry already exists.
- * Returns { skipped }.
- */
-async function registerPlugin(
- vaultDir: string,
- installedPluginsPath: string,
-): Promise<{ skipped: boolean }> {
- // Read existing file
- let data: Record;
- try {
- const text = await readFile(installedPluginsPath, 'utf8');
- data = JSON.parse(text) as Record;
- } catch {
- data = { plugins: {} };
- }
-
- const plugins = (data['plugins'] ?? {}) as Record;
- data['plugins'] = plugins;
-
- // Check if any onebrain@ key has a marketplace entry
- const hasMarketplace = Object.keys(plugins)
- .filter((k) => k.startsWith('onebrain@'))
- .some((k) => {
- const entries = plugins[k] as Array>;
- return entries.some((e) => e['source'] === 'marketplace');
- });
-
- if (hasMarketplace) {
- return { skipped: true };
- }
-
- // Read plugin version from .claude-plugin/plugin.json or plugin.json
- let pluginVersion = '0.0.0';
- const candidatePaths = [
- join(vaultDir, '.claude', 'plugins', 'onebrain', '.claude-plugin', 'plugin.json'),
- join(vaultDir, '.claude', 'plugins', 'onebrain', 'plugin.json'),
- ];
- for (const p of candidatePaths) {
- try {
- const text = await readFile(p, 'utf8');
- const parsed = JSON.parse(text) as Record;
- if (typeof parsed['version'] === 'string') {
- pluginVersion = parsed['version'];
- break;
- }
- } catch {
- // Try next
- }
- }
-
- const installPath = join(vaultDir, '.claude', 'plugins', 'onebrain');
- const key = `onebrain@${pluginVersion}`;
-
- // Upsert entry
- if (!plugins[key]) {
- plugins[key] = [];
- }
- const entries = plugins[key] as Array>;
- const existingIdx = entries.findIndex((e) => e['source'] !== 'marketplace');
-
- if (existingIdx >= 0) {
- const existing = entries[existingIdx];
- if (existing) {
- existing['installPath'] = installPath;
- existing['version'] = pluginVersion;
- }
- } else {
- entries.push({ source: 'local', installPath, version: pluginVersion });
- }
-
- // Write atomically
- const tmpPath = `${installedPluginsPath}.tmp`;
- try {
- await mkdirIdempotent(dirname(installedPluginsPath));
- await writeFile(tmpPath, JSON.stringify(data, null, 4), 'utf8');
- await rename(tmpPath, installedPluginsPath);
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- process.stderr.write(`init: plugin registration warning: ${msg}\n`);
- return { skipped: false };
- }
-
- return { skipped: false };
-}
-
-// ---------------------------------------------------------------------------
-// Community plugin installer
-// ---------------------------------------------------------------------------
-
-const PLUGIN_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
-const REGISTRY_URL =
- 'https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json';
-const GITHUB_API_BASE = 'https://api.github.com/repos';
-
-interface RegistryEntry {
- id: string;
- repo: string;
-}
-
-async function installObsidianPlugins(
- vaultDir: string,
- opts: { githubToken?: string },
-): Promise {
- const communityPluginsPath = join(vaultDir, '.obsidian', 'community-plugins.json');
-
- // Read list of plugins to install
- let pluginIds: string[];
- try {
- const text = await readFile(communityPluginsPath, 'utf8');
- pluginIds = JSON.parse(text) as string[];
- if (!Array.isArray(pluginIds) || pluginIds.length === 0) {
- return { installed: [], failed: [] };
- }
- } catch {
- return { installed: [], failed: [] };
- }
-
- const installed: string[] = [];
- const failed: Array<{ id: string; reason: string }> = [];
-
- // Validate all IDs first
- const validIds: string[] = [];
- for (const id of pluginIds) {
- if (!PLUGIN_ID_PATTERN.test(id)) {
- failed.push({ id, reason: 'invalid id' });
- } else {
- validIds.push(id);
- }
- }
-
- if (validIds.length === 0) return { installed, failed };
-
- // Fetch Obsidian plugin registry
- const authHeaders: Record = opts.githubToken
- ? { Authorization: `token ${opts.githubToken}` }
- : {};
-
- let registry: RegistryEntry[];
- try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 10000);
- const resp = await fetch(REGISTRY_URL, { signal: controller.signal, headers: authHeaders });
- clearTimeout(timeoutId);
- if (!resp.ok) throw new Error(`Registry fetch failed: HTTP ${resp.status}`);
- registry = (await resp.json()) as RegistryEntry[];
- } catch (err) {
- const reason = `registry unavailable: ${err instanceof Error ? err.message : String(err)}`;
- for (const id of validIds) {
- failed.push({ id, reason });
- }
- return { installed, failed };
- }
-
- // Install each plugin
- for (const id of validIds) {
- const pluginDir = join(vaultDir, '.obsidian', 'plugins', id);
- const manifestPath = join(pluginDir, 'manifest.json');
-
- // Idempotency: skip if already installed
- if (await pathExists(manifestPath)) {
- installed.push(id);
- continue;
- }
-
- const entry = registry.find((r) => r.id === id);
- if (!entry) {
- failed.push({ id, reason: 'not in registry' });
- continue;
- }
-
- // Fetch latest release assets
- let assets: Array<{ name: string; browser_download_url: string }>;
- try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 10000);
- const resp = await fetch(`${GITHUB_API_BASE}/${entry.repo}/releases/latest`, {
- signal: controller.signal,
- headers: { Accept: 'application/vnd.github.v3+json', ...authHeaders },
- });
- clearTimeout(timeoutId);
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const json = (await resp.json()) as Record;
- assets = (json['assets'] ?? []) as Array<{ name: string; browser_download_url: string }>;
- } catch (err) {
- failed.push({
- id,
- reason: `release fetch failed: ${err instanceof Error ? err.message : String(err)}`,
- });
- continue;
- }
-
- // Download assets
- await mkdirIdempotent(pluginDir);
- let pluginFailed = false;
-
- for (const assetName of ['main.js', 'manifest.json', 'styles.css'] as const) {
- const asset = assets.find((a) => a.name === assetName);
- if (!asset) {
- if (assetName === 'styles.css') continue; // optional
- pluginFailed = true;
- failed.push({ id, reason: `missing required asset: ${assetName}` });
- break;
- }
-
- try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 30000);
- const resp = await fetch(asset.browser_download_url, {
- signal: controller.signal,
- headers: authHeaders,
- });
- clearTimeout(timeoutId);
- if (!resp.ok) {
- if (assetName === 'styles.css') continue; // optional 404
- throw new Error(`HTTP ${resp.status}`);
- }
- const buf = await resp.arrayBuffer();
- await writeFile(join(pluginDir, assetName), Buffer.from(buf));
- } catch (err) {
- if (assetName === 'styles.css') continue; // optional
- pluginFailed = true;
- failed.push({
- id,
- reason: `download failed (${assetName}): ${err instanceof Error ? err.message : String(err)}`,
- });
- break;
- }
- }
-
- if (pluginFailed) {
- // Clean up partial install
- try {
- const { rm } = await import('node:fs/promises');
- await rm(pluginDir, { recursive: true, force: true });
- } catch {
- // best-effort cleanup
- }
- } else {
- installed.push(id);
- }
- }
-
- return { installed, failed };
-}
-
-// ---------------------------------------------------------------------------
-// Main runInit
-// ---------------------------------------------------------------------------
-
-export async function runInit(opts: InitOptions = {}): Promise {
- const vaultDir = opts.vaultDir ?? process.cwd();
- const isTTY = opts.isTTY ?? process.stdout.isTTY ?? false;
- const force = opts.force ?? false;
- const installedPluginsPath =
- opts.installedPluginsPath ?? join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
-
- // Injectable dependencies (real implementations lazy-loaded)
- const vaultSyncFn =
- opts.vaultSyncFn ??
- (async (
- dir: string,
- syncOpts: { branch?: string; includeObsidian?: boolean; embedded?: boolean },
- ) => {
- const { vaultSyncCommand } = await import('./internal/vault-sync.js');
- await vaultSyncCommand(dir, syncOpts);
- });
-
- const registerHooksFn =
- opts.registerHooksFn ??
- (async (dir: string) => {
- const { runRegisterHooks } = await import('./internal/register-hooks.js');
- await runRegisterHooks({ vaultDir: dir, isTTY: false, silent: true });
- });
-
- const result: InitResult = {
- ok: false,
- exitCode: 0,
- foldersCreated: 0,
- pluginSkipped: false,
- pluginRegistrationSkipped: false,
- pluginsInstalled: 0,
- pluginsFailed: 0,
- };
-
- const createStep = makeStepFn(isTTY);
- const delay = opts.delayFn ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
- const randDelay = () =>
- isTTY ? delay(Math.floor(Math.random() * 1000) + 1000) : Promise.resolve();
- const _confirmFn = opts.confirmFn ?? askYesNo;
-
- // ── Step 0/1: Directory confirmation + vault.yml guard ────────────────────
-
- const vaultYmlPath = join(vaultDir, 'vault.yml');
- const vaultYmlExists = await pathExists(vaultYmlPath);
-
- if (isTTY) {
- await printBanner();
-
- if (!force) {
- barLine(`${pc.dim('vault root')} ${pc.cyan(vaultDir)}`);
- barBlank();
- const proceed = await _confirmFn('Initialize OneBrain vault here?');
- if (proceed === null || proceed === false) {
- barLine(pc.dim('No'));
- barBlank();
- close('Aborted');
- result.ok = true;
- result.exitCode = 0;
- return result;
- }
- barLine('Yes');
- barBlank();
-
- if (vaultYmlExists) {
- const overwrite = await _confirmFn('vault.yml already exists. Overwrite?');
- if (overwrite === null || overwrite === false) {
- barLine(pc.dim('No'));
- barBlank();
- close('Aborted');
- result.ok = true;
- result.exitCode = 0;
- return result;
- }
- barLine('Yes');
- barBlank();
- }
- }
- } else {
- if (vaultYmlExists && !force) {
- const msg = 'vault.yml exists. Re-run with --force to overwrite.';
- process.stdout.write(`${msg}\n`);
- result.message = msg;
- result.exitCode = 1;
- return result;
- }
- writeLine('OneBrain Init');
- }
-
- // ── Step 2: Write vault.yml ────────────────────────────────────────────────
-
- const sp2 = createStep('📋', 'vault.yml');
- await writeVaultYml(vaultDir);
- if (sp2) {
- await randDelay();
- sp2.stop(pc.dim('written'), ['update_channel: stable', 'checkpoint: 15 msgs · 30 min']);
- } else {
- writeLine('vault.yml: written');
- }
-
- // ── Step 3: Create standard folders ───────────────────────────────────────
-
- const sp3 = createStep('📁', 'Vault folders');
- const foldersCreated = await createFolders(vaultDir);
- result.foldersCreated = foldersCreated;
- if (sp3) {
- await randDelay();
- sp3.stop(
- `${foldersCreated} folder${foldersCreated !== 1 ? 's' : ''} created`,
- STANDARD_FOLDERS,
- );
- } else {
- writeLine(`folders: ${foldersCreated} created`);
- }
-
- // ── Step 4: Download plugin files ─────────────────────────────────────────
- // Peek first: if plugin files are absent, vault-sync will run and output its
- // own clack UI. Starting a spinner before vault-sync causes the interval's
- // \x1b[1A\x1b[2K to erase vault-sync's output lines. Only use the spinner
- // on the skip path (files already present → no vault-sync output).
-
- const pluginJsonPath = join(
- vaultDir,
- '.claude',
- 'plugins',
- 'onebrain',
- '.claude-plugin',
- 'plugin.json',
- );
- const pluginFilesExist = await pathExists(pluginJsonPath);
- const sp4 = pluginFilesExist ? createStep('📦', 'Plugin files') : null;
-
- const { skipped: pluginSkipped, failed: pluginDownloadFailed } = await downloadPluginFiles(
- vaultDir,
- vaultSyncFn,
- );
- result.pluginSkipped = pluginSkipped;
-
- if (sp4) {
- // Skip path — stop the spinner
- if (pluginDownloadFailed) {
- sp4.stop('download failed');
- } else {
- const { skills, agents } = await countPluginContents(vaultDir);
- sp4.stop(pc.dim('already installed'), [`${skills} skills · ${agents} agents`]);
- }
- } else if (isTTY) {
- // Download path — vault-sync rendered its own UI; output our completion line
- if (!pluginDownloadFailed) {
- const { skills, agents } = await countPluginContents(vaultDir);
- dotLine('📦', 'Plugin files');
- barLine(pc.dim('downloaded'));
- barLine(` · ${skills} skills · ${agents} agents`);
- barBlank();
- }
- } else {
- if (pluginSkipped) writeLine('plugin-files: skipped');
- else if (!pluginDownloadFailed) writeLine('plugin-files: installed');
- }
-
- if (pluginDownloadFailed) {
- result.exitCode = 1;
- if (isTTY) {
- close('Could not download plugin files. Check your internet connection and try again.', true);
- } else {
- writeLine('error: vault-sync failed — run onebrain update to download plugin files');
- }
- return result;
- }
-
- // ── Step 4b: Install community plugins ────────────────────────────────────
-
- const installPluginsFn = opts.installPluginsFn ?? installObsidianPlugins;
- const githubToken = process.env['GITHUB_TOKEN'];
- const sp4b = createStep('🔌', 'Obsidian plugins');
- const pluginResult = await installPluginsFn(vaultDir, {
- ...(githubToken ? { githubToken } : {}),
- });
- result.pluginsInstalled = pluginResult.installed.length;
- result.pluginsFailed = pluginResult.failed.length;
-
- if (sp4b) {
- await randDelay();
- const n = pluginResult.installed.length;
- const details = [
- ...pluginResult.installed,
- ...pluginResult.failed.map((f) => `${f.id} (skipped)`),
- ];
- sp4b.stop(pc.dim(n > 0 ? `${n} installed` : 'none'), details.length > 0 ? details : undefined);
- } else {
- if (pluginResult.installed.length > 0)
- writeLine(`plugins: ${pluginResult.installed.join(', ')} installed`);
- if (pluginResult.failed.length > 0)
- writeLine(`plugins-skipped: ${pluginResult.failed.map((f) => f.id).join(', ')}`);
- }
-
- // ── Step 5: Register plugin ────────────────────────────────────────────────
-
- const sp5 = createStep('📌', 'Plugin registration');
- const { skipped: pluginRegistrationSkipped } = await registerPlugin(
- vaultDir,
- installedPluginsPath,
- );
- result.pluginRegistrationSkipped = pluginRegistrationSkipped;
-
- if (sp5) {
- await randDelay();
- sp5.stop(pc.dim(pluginRegistrationSkipped ? 'skipped' : 'registered'), [
- `source: ${pluginRegistrationSkipped ? 'marketplace' : 'local'}`,
- ]);
- } else {
- writeLine(`plugin: ${pluginRegistrationSkipped ? 'skipped (marketplace)' : 'registered'}`);
- }
-
- // ── Step 6: Register hooks ─────────────────────────────────────────────────
-
- const sp6 = createStep('🪝', 'Hooks & permissions');
- let hooksOk = true;
- try {
- await registerHooksFn(vaultDir);
- } catch (err) {
- hooksOk = false;
- const msg = err instanceof Error ? err.message : String(err);
- process.stderr.write(`init: register-hooks warning: ${msg}\n`);
- }
-
- if (sp6) {
- await randDelay();
- sp6.stop(
- hooksOk ? undefined : 'not registered — run onebrain update',
- hooksOk ? ['Stop ✓', 'Bash(onebrain *) ✓'] : undefined,
- );
- } else {
- writeLine(`hooks: ${hooksOk ? 'ok' : 'warning — hooks not registered; run onebrain update'}`);
- }
-
- // ── Done ──────────────────────────────────────────────────────────────────
-
- result.ok = true;
- result.exitCode = 0;
-
- if (isTTY) {
- barLine(pc.dim(`─── Next steps ${'─'.repeat(25)}`));
- barBlank();
- barLine(` ${pc.bold(pc.cyan('1'))} 📁 Open Obsidian → open this folder as vault`);
- barLine(` ${pc.bold(pc.cyan('2'))} 🤖 Run ${pc.cyan('claude')}`);
- barLine(` ${pc.bold(pc.cyan('3'))} 🧠 Type ${pc.cyan('/onboarding')} to personalize`);
- barBlank();
- close(`✨ ${pc.bold('Ready')} — ${pc.cyan('/onboarding')}`);
- } else {
- writeLine('done: run /onboarding in Claude to finish setup');
- }
-
- return result;
-}
-
-// ---------------------------------------------------------------------------
-// CLI entry point (called from index.ts)
-// ---------------------------------------------------------------------------
-
-export interface InitCommandOptions {
- vaultDir?: string;
- force?: boolean;
-}
-
-export async function initCommand(opts: InitCommandOptions = {}): Promise {
- const result = await runInit(opts);
- if (!result.ok) {
- process.exit(result.exitCode || 1);
- }
-}
diff --git a/src/commands/internal/__snapshots__/checkpoint.test.ts.snap b/src/commands/internal/__snapshots__/checkpoint.test.ts.snap
deleted file mode 100644
index 1aa4b68d..00000000
--- a/src/commands/internal/__snapshots__/checkpoint.test.ts.snap
+++ /dev/null
@@ -1,12 +0,0 @@
-// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
-
-exports[`handleStop stop block JSON shape matches snapshot { decision: "block", reason: "...-checkpoint-NN.md since ..." } 1`] = `
-[
- "decision",
- "reason",
-]
-`;
-
-exports[`handleStop stop block JSON shape matches snapshot { decision: "block", reason: "...-checkpoint-NN.md since ..." } 2`] = `"block"`;
-
-exports[`handleStop stop block JSON shape matches snapshot { decision: "block", reason: "...-checkpoint-NN.md since ..." } 3`] = `"string"`;
diff --git a/src/commands/internal/__snapshots__/orphan-scan.test.ts.snap b/src/commands/internal/__snapshots__/orphan-scan.test.ts.snap
deleted file mode 100644
index cbac0acb..00000000
--- a/src/commands/internal/__snapshots__/orphan-scan.test.ts.snap
+++ /dev/null
@@ -1,13 +0,0 @@
-// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
-
-exports[`runOrphanScan output shape matches snapshot { orphan_count: N } 1`] = `
-{
- "orphan_count": 0,
-}
-`;
-
-exports[`runOrphanScan output shape matches snapshot { orphan_count: N } 2`] = `
-{
- "orphan_count": 1,
-}
-`;
diff --git a/src/commands/internal/__snapshots__/session-init.test.ts.snap b/src/commands/internal/__snapshots__/session-init.test.ts.snap
deleted file mode 100644
index 58ba5057..00000000
--- a/src/commands/internal/__snapshots__/session-init.test.ts.snap
+++ /dev/null
@@ -1,15 +0,0 @@
-// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
-
-exports[`runSessionInit normal payload output shape matches snapshot 1`] = `
-[
- "datetime",
- "qmd_unembedded",
- "session_token",
-]
-`;
-
-exports[`runSessionInit normal payload output shape matches snapshot 2`] = `"string"`;
-
-exports[`runSessionInit normal payload output shape matches snapshot 3`] = `"string"`;
-
-exports[`runSessionInit normal payload output shape matches snapshot 4`] = `"number"`;
diff --git a/src/commands/internal/checkpoint.test.ts b/src/commands/internal/checkpoint.test.ts
deleted file mode 100644
index 79867ee8..00000000
--- a/src/commands/internal/checkpoint.test.ts
+++ /dev/null
@@ -1,526 +0,0 @@
-/**
- * checkpoint.test.ts — tests for checkpoint command (stop/reset)
- */
-
-import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
-import { mkdir, rm, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import {
- handleReset,
- handleStop,
- maxCheckpointNnSync,
- readState,
- writeState,
-} from './checkpoint.js';
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-async function makeTmpDir(): Promise {
- const base = join(tmpdir(), `ob-cp-test-${Math.random().toString(36).slice(2)}`);
- await mkdir(base, { recursive: true });
- return base;
-}
-
-const TOKEN = '41928';
-const VALID_VAULT_YML = `
-method: onebrain
-update_channel: stable
-folders:
- inbox: 00-inbox
- logs: 07-logs
-checkpoint:
- messages: 5
- minutes: 10
-`.trim();
-
-function stateFile(tmpDir: string, token: string): string {
- return join(tmpDir, `onebrain-${token}.state`);
-}
-
-async function createCheckpointFile(
- vaultDir: string,
- now: number,
- token: string,
- nn: number,
-): Promise {
- const d = new Date(now * 1000);
- const yyyy = d.getFullYear().toString();
- const mm = String(d.getMonth() + 1).padStart(2, '0');
- const dd = String(d.getDate()).padStart(2, '0');
- const date = `${yyyy}-${mm}-${dd}`;
- const dir = join(vaultDir, '07-logs', 'checkpoint');
- await mkdir(dir, { recursive: true });
- const nnStr = String(nn).padStart(2, '0');
- await writeFile(
- join(dir, `${date}-${token}-checkpoint-${nnStr}.md`),
- `---\ndate: ${date}\ncheckpoint: ${nnStr}\nmerged: false\n---\n`,
- 'utf8',
- );
-}
-
-// Capture stdout written via process.stdout.write
-function captureStdout(): { stop: () => string } {
- const chunks: string[] = [];
- const original = process.stdout.write.bind(process.stdout);
- process.stdout.write = (chunk: string | Uint8Array) => {
- chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk));
- return true;
- };
- return {
- stop: () => {
- process.stdout.write = original;
- return chunks.join('');
- },
- };
-}
-
-// ---------------------------------------------------------------------------
-// readState / writeState
-// ---------------------------------------------------------------------------
-
-describe('readState / writeState', () => {
- let tmpDir: string;
-
- beforeEach(async () => {
- tmpDir = await makeTmpDir();
- });
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- it('returns default state when no file exists', () => {
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(0);
- expect(state.last_ts).toBe(0);
- expect(state.last_stop_nn).toBe('00');
- });
-
- it('reads 3-field state correctly', async () => {
- await writeFile(stateFile(tmpDir, TOKEN), '3:1000000:02', 'utf8');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(3);
- expect(state.last_ts).toBe(1000000);
- expect(state.last_stop_nn).toBe('02');
- });
-
- it('treats 4-field legacy state (pending_checkpoint flag) as malformed → resets to 0:0:00', async () => {
- await writeFile(stateFile(tmpDir, TOKEN), '0:1000000:02:1', 'utf8');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(0);
- expect(state.last_ts).toBe(0);
- expect(state.last_stop_nn).toBe('00');
- const raw = await Bun.file(stateFile(tmpDir, TOKEN)).text();
- expect(raw).toBe('0:0:00');
- });
-
- it('treats 4-field legacy pending_stub state as malformed → resets to 0:0:00', async () => {
- await writeFile(
- stateFile(tmpDir, TOKEN),
- '0:1000000:03:2026-04-23-41928-checkpoint-04.md',
- 'utf8',
- );
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(0);
- expect(state.last_ts).toBe(0);
- expect(state.last_stop_nn).toBe('00');
- const raw = await Bun.file(stateFile(tmpDir, TOKEN)).text();
- expect(raw).toBe('0:0:00');
- });
-
- it('treats v1 2-field state as parse error → resets to 0:0:00', async () => {
- await writeFile(stateFile(tmpDir, TOKEN), '5:1000000', 'utf8');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(0);
- expect(state.last_ts).toBe(0);
- expect(state.last_stop_nn).toBe('00');
- const raw = await Bun.file(stateFile(tmpDir, TOKEN)).text();
- expect(raw).toBe('0:0:00');
- });
-
- it('treats malformed state as parse error → resets to 0:0:00', async () => {
- await writeFile(stateFile(tmpDir, TOKEN), 'bad:data:here', 'utf8');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(0);
- expect(state.last_ts).toBe(0);
- expect(state.last_stop_nn).toBe('00');
- const raw = await Bun.file(stateFile(tmpDir, TOKEN)).text();
- expect(raw).toBe('0:0:00');
- });
-
- it('writeState writes 3-field format', () => {
- writeState(TOKEN, { count: 2, last_ts: 999, last_stop_nn: '01' }, tmpDir);
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(2);
- expect(state.last_ts).toBe(999);
- expect(state.last_stop_nn).toBe('01');
- });
-});
-
-// ---------------------------------------------------------------------------
-// handleReset
-// ---------------------------------------------------------------------------
-
-describe('handleReset', () => {
- let tmpDir: string;
-
- beforeEach(async () => {
- tmpDir = await makeTmpDir();
- });
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- it('writes 0::00 to state file', async () => {
- const now = 1700000000;
- const cap = captureStdout();
- handleReset(TOKEN, now, tmpDir);
- const out = cap.stop();
- const raw = await Bun.file(stateFile(tmpDir, TOKEN)).text();
- expect(raw).toBe(`0:${now}:00`);
- expect(out).toBe('');
- });
-
- it('overwrites existing v1 state file cleanly', async () => {
- await writeFile(stateFile(tmpDir, TOKEN), '99:123456789', 'utf8');
- const now = 1700000001;
- handleReset(TOKEN, now, tmpDir);
- const raw = await Bun.file(stateFile(tmpDir, TOKEN)).text();
- expect(raw).toBe(`0:${now}:00`);
- });
-
- it('overwrites legacy 4-field state — slot 4 dropped on reset', async () => {
- await writeFile(stateFile(tmpDir, TOKEN), '5:1000:03:1', 'utf8');
- handleReset(TOKEN, 1700000000, tmpDir);
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(0);
- expect(state.last_stop_nn).toBe('00');
- const raw = await Bun.file(stateFile(tmpDir, TOKEN)).text();
- expect(raw).toBe('0:1700000000:00'); // 3 fields
- });
-
- it('produces no stdout', () => {
- const cap = captureStdout();
- handleReset(TOKEN, 1000000, tmpDir);
- const out = cap.stop();
- expect(out).toBe('');
- });
-});
-
-// ---------------------------------------------------------------------------
-// handleStop
-// ---------------------------------------------------------------------------
-
-describe('handleStop', () => {
- let tmpDir: string;
- let vaultDir: string;
-
- beforeEach(async () => {
- tmpDir = await makeTmpDir();
- vaultDir = await makeTmpDir();
- await writeFile(join(vaultDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
- });
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- await rm(vaultDir, { recursive: true, force: true });
- });
-
- it('SKIP_WINDOW: count=0 and last_ts within 60s → exit 0, state unchanged', async () => {
- const now = 1700000100;
- const recentTs = now - 30; // 30s ago
- writeState(TOKEN, { count: 0, last_ts: recentTs, last_stop_nn: '01' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- const out = cap.stop();
-
- expect(out).toBe('');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(0);
- expect(state.last_ts).toBe(recentTs);
- });
-
- it('SKIP_WINDOW: count=0 but last_ts > 60s ago → NOT skipped, count increments to 1', () => {
- const now = 1700000100;
- const oldTs = now - 90; // 90s ago
- writeState(TOKEN, { count: 0, last_ts: oldTs, last_stop_nn: '00' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- cap.stop();
-
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(1);
- expect(state.last_ts).toBe(oldTs); // preserved when threshold not met / min_activity guard
- });
-
- it('threshold not met → no stdout, state updated with incremented count', () => {
- const now = 1700000100;
- writeState(TOKEN, { count: 2, last_ts: now - 10, last_stop_nn: '00' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- const out = cap.stop();
-
- expect(out).toBe('');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(3); // incremented
- });
-
- it('MIN_ACTIVITY guard: count increments to 1, threshold met by time, but no emit', () => {
- const now = 1700001000;
- const oldTs = now - 700; // 700s > 600s threshold
- writeState(TOKEN, { count: 0, last_ts: oldTs, last_stop_nn: '01' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- const out = cap.stop();
-
- expect(out).toBe('');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(1);
- });
-
- it('threshold met (messages) → emits block with NN and since suffix', async () => {
- const now = 1700001000;
- await createCheckpointFile(vaultDir, now, TOKEN, 1);
- writeState(TOKEN, { count: 4, last_ts: now - 10, last_stop_nn: '01' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- const out = cap.stop();
-
- const parsed = JSON.parse(out.trim());
- expect(parsed.decision).toBe('block');
- expect(parsed.reason).toBe('02 since checkpoint-01');
- });
-
- it('threshold met → state reset (count=0, last_ts=now)', async () => {
- const now = 1700001000;
- writeState(TOKEN, { count: 4, last_ts: now - 10, last_stop_nn: '00' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- cap.stop();
-
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(0);
- expect(state.last_ts).toBe(now);
- });
-
- it('NN derivation: disk scan is authoritative over state last_stop_nn', async () => {
- const now = 1700001000;
- await createCheckpointFile(vaultDir, now, TOKEN, 1);
- writeState(TOKEN, { count: 4, last_ts: now - 10, last_stop_nn: '02' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- const out = cap.stop();
-
- const parsed = JSON.parse(out.trim());
- expect(parsed.decision).toBe('block');
- expect(parsed.reason).toBe('02 since checkpoint-01');
- const state = readState(TOKEN, tmpDir);
- expect(state.last_stop_nn).toBe('02');
- });
-
- it('elapsed calc: last_ts=0 → elapsed=0, never triggers time threshold alone', () => {
- const now = 1700001000;
- writeState(TOKEN, { count: 1, last_ts: 0, last_stop_nn: '00' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- const out = cap.stop();
-
- expect(out).toBe('');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(2);
- });
-
- it('precompact-then-stop same turn: count=0, not in SKIP_WINDOW → count=1, below MIN_ACTIVITY, no emit', () => {
- const now = 1700002000;
- const oldTs = now - 900;
- writeState(TOKEN, { count: 0, last_ts: oldTs, last_stop_nn: '02' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- const out = cap.stop();
-
- expect(out).toBe('');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(1);
- });
-
- it('no state file (first run): count starts at 0, increments to 1, no emit', () => {
- const now = 1700000000;
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- cap.stop();
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(1);
- });
-
- it('last_ts=0 + count=4 → decision:block, last_ts updated, last_stop_nn incremented', async () => {
- const now = 1700000800;
- await createCheckpointFile(vaultDir, now, TOKEN, 1);
- await createCheckpointFile(vaultDir, now, TOKEN, 2);
- writeState(TOKEN, { count: 4, last_ts: 0, last_stop_nn: '02' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- const out = cap.stop();
-
- const parsed = JSON.parse(out.trim());
- expect(parsed.decision).toBe('block');
-
- const state = readState(TOKEN, tmpDir);
- expect(state.last_ts).toBe(now);
- expect(state.last_stop_nn).toBe('03');
- });
-
- it('last_ts=0 + count=0 → SKIP_WINDOW does NOT fire (guard needs last_ts > 0)', () => {
- const now = 1700001500;
- writeState(TOKEN, { count: 0, last_ts: 0, last_stop_nn: '00' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, vaultDir, now, tmpDir);
- const out = cap.stop();
-
- expect(out).toBe('');
- const state = readState(TOKEN, tmpDir);
- expect(state.count).toBe(1);
- expect(state.last_ts).toBe(0);
- });
-
- it('falls back to defaults when vault.yml is missing (messages=15, minutes=30)', () => {
- const emptyVault = tmpDir; // no vault.yml
- const now = 1700001000;
- writeState(TOKEN, { count: 14, last_ts: now - 10, last_stop_nn: '00' }, tmpDir);
-
- const cap = captureStdout();
- handleStop(TOKEN, emptyVault, now, tmpDir);
- const out = cap.stop();
-
- const parsed = JSON.parse(out.trim());
- expect(parsed.decision).toBe('block');
- });
-});
-
-// ---------------------------------------------------------------------------
-// Unicode round-trip in checkpoint files
-// ---------------------------------------------------------------------------
-
-describe('unicode in checkpoint files', () => {
- let tmpDir: string;
-
- beforeEach(async () => {
- tmpDir = await makeTmpDir();
- });
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- it('checkpoint file with unicode body (✅ ❌ →) is written and read back losslessly', async () => {
- const now = 1700001000;
- const d = new Date(now * 1000);
- const yyyy = d.getFullYear().toString();
- const mm = String(d.getMonth() + 1).padStart(2, '0');
- const dd = String(d.getDate()).padStart(2, '0');
- const date = `${yyyy}-${mm}-${dd}`;
- const dir = join(tmpDir, '07-logs', yyyy, mm);
- await mkdir(dir, { recursive: true });
-
- const unicodeBody = [
- '---',
- 'tags: [checkpoint, session-log]',
- `date: ${date}`,
- 'checkpoint: 01',
- 'merged: false',
- '---',
- '',
- '## What We Worked On',
- 'Refactored the session init flow → improved startup speed.',
- '',
- "## What Worked / Didn't Work",
- '- ✅ Session token resolves correctly',
- '- ❌ Stale state file cleanup had edge case',
- '',
- '## Action Items',
- `- [ ] Fix stale state cleanup 📅 ${date}`,
- ].join('\n');
-
- const filePath = join(dir, `${date}-${TOKEN}-checkpoint-01.md`);
- await writeFile(filePath, unicodeBody, 'utf8');
-
- // Read back via Bun.file (same API used internally by checkpoint code)
- const readBack = await Bun.file(filePath).text();
- expect(readBack).toBe(unicodeBody);
-
- // Verify specific unicode characters survived
- expect(readBack).toContain('→');
- expect(readBack).toContain('✅');
- expect(readBack).toContain('❌');
- });
-
- it('state file with token containing only ASCII — content is valid UTF-8', async () => {
- const now = 1700001000;
- writeState(TOKEN, { count: 3, last_ts: now, last_stop_nn: '02' }, tmpDir);
-
- const stateContent = await Bun.file(stateFile(tmpDir, TOKEN)).text();
- // State file is ASCII, so it's trivially valid UTF-8
- const encoded = new TextEncoder().encode(stateContent);
- const decoded = new TextDecoder('utf-8', { fatal: true }).decode(encoded);
- expect(decoded).toBe(stateContent);
- });
-});
-
-// ---------------------------------------------------------------------------
-// maxCheckpointNnSync
-// ---------------------------------------------------------------------------
-
-describe('maxCheckpointNnSync', () => {
- let tmpDir: string;
-
- beforeEach(async () => {
- tmpDir = await makeTmpDir();
- });
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- const VAULT = () => tmpDir;
- const DATE = '2023-11-14';
- const LOGS = '07-logs';
-
- it('returns 0 when no checkpoint files exist', () => {
- const result = maxCheckpointNnSync(VAULT(), DATE, TOKEN, LOGS);
- expect(result).toBe(0);
- });
-
- it('returns max NN from checkpoint files on disk', async () => {
- const dir = join(VAULT(), LOGS, 'checkpoint');
- await mkdir(dir, { recursive: true });
- await writeFile(join(dir, `${DATE}-${TOKEN}-checkpoint-01.md`), '', 'utf8');
- await writeFile(join(dir, `${DATE}-${TOKEN}-checkpoint-03.md`), '', 'utf8');
- const result = maxCheckpointNnSync(VAULT(), DATE, TOKEN, LOGS);
- expect(result).toBe(3);
- });
-
- it('ignores files for other tokens', async () => {
- const dir = join(VAULT(), LOGS, 'checkpoint');
- await mkdir(dir, { recursive: true });
- await writeFile(join(dir, `${DATE}-99999-checkpoint-05.md`), '', 'utf8');
- const result = maxCheckpointNnSync(VAULT(), DATE, TOKEN, LOGS);
- expect(result).toBe(0);
- });
-
- it('ignores files for other dates', async () => {
- const dir = join(VAULT(), LOGS, 'checkpoint');
- await mkdir(dir, { recursive: true });
- await writeFile(join(dir, `2023-11-15-${TOKEN}-checkpoint-02.md`), '', 'utf8');
- const result = maxCheckpointNnSync(VAULT(), DATE, TOKEN, LOGS);
- expect(result).toBe(0);
- });
-});
diff --git a/src/commands/internal/checkpoint.ts b/src/commands/internal/checkpoint.ts
deleted file mode 100644
index 297f0fa2..00000000
--- a/src/commands/internal/checkpoint.ts
+++ /dev/null
@@ -1,355 +0,0 @@
-/**
- * checkpoint — internal command
- *
- * Implements stop/reset modes. The Stop hook is the only checkpoint signal
- * source; session logs are produced only by /wrapup (manual) or AUTO-SUMMARY
- * (end-of-session signal). PostCompact is intentionally NOT registered —
- * Claude Code's PostCompact hook is observational-only (its stdout cannot
- * reach the agent), so any signal we emitted from it would be silently
- * ignored. Trust the Stop hook's count / time threshold to drive checkpoint
- * emission; compact events don't get special handling.
- *
- * State file: $TMPDIR/onebrain-{session_token}.state
- * Format: count:last_ts:last_stop_nn
- *
- * - count number of Stop messages since the last checkpoint emission
- * - last_ts unix seconds of the most recent state-touching event
- * - last_stop_nn bookkeeping for the most recent checkpoint NN written (debug only)
- *
- * Checkpoint NN is always derived from actual files on disk — this guarantees
- * sequential numbering even when Claude fails to write a file (e.g. context full).
- *
- * Hook signal delivery:
- * - Stop: emits {decision:"block",reason:"NN since "} when count or
- * time threshold is met. The agent dispatches a background sub-agent to
- * write the checkpoint file capturing the conversation since the last NN.
- *
- * Session log creation lives outside this file — see /wrapup skill and
- * AUTO-SUMMARY. Both consolidate accumulated checkpoint files into one
- * session log per call ("1 session = 1 session log").
- *
- * Concurrency: each hook runs as its own short-lived CLI subprocess. State writes use
- * atomic write-then-rename (pid-suffixed temp file → POSIX rename) so concurrent writers
- * cannot tear the file.
- *
- * Exit code always 0. Errors go to stderr only.
- * JSON decision blocks go to process.stdout.write (no console.log).
- */
-
-import { readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
-import { tmpdir as osTmpdir } from 'node:os';
-import { join } from 'node:path';
-
-// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-const SKIP_WINDOW = 60; // seconds — suppress re-trigger after reset
-const MIN_ACTIVITY = 2; // minimum messages to warrant checkpoint
-
-// Default thresholds (used when vault.yml is missing/unreadable)
-const DEFAULT_MESSAGES_THRESHOLD = 15;
-const DEFAULT_MINUTES_THRESHOLD = 30;
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-export interface CheckpointState {
- count: number;
- last_ts: number;
- last_stop_nn: string;
-}
-
-// ---------------------------------------------------------------------------
-// State helpers
-// ---------------------------------------------------------------------------
-
-function stateFilePath(token: string, tmpDir: string): string {
- return join(tmpDir, `onebrain-${token}.state`);
-}
-
-const FRESH_STATE_DISK = '0:0:00';
-
-/**
- * Read state from $tmpDir/onebrain-{token}.state.
- * Returns default state if file is missing or malformed.
- *
- * State file must be exactly 3 colon-separated fields. Legacy formats
- * (v1 2-field, or 4-field state from pre-v2.1.6 `pending_checkpoint` /
- * pre-v2.0 `pending_stub` filename) are treated as malformed → reset to
- * fresh state. The reset costs at most one checkpoint cycle of progress
- * (count resets to 0); time threshold continues to work.
- *
- * Sync — checkpoint hooks must not add async latency.
- */
-export function readState(token: string, tmpDir: string = osTmpdir()): CheckpointState {
- const path = stateFilePath(token, tmpDir);
- try {
- const raw = readFileSync(path, 'utf8').trim();
- const parts = raw.split(':');
- if (parts.length !== 3) {
- throw new Error('state file must be exactly 3 fields');
- }
- const count = Number(parts[0]);
- const last_ts = Number(parts[1]);
- const last_stop_nn = parts[2] ?? '00';
-
- if (!Number.isInteger(count) || !Number.isInteger(last_ts) || !/^\d{2}$/.test(last_stop_nn)) {
- throw new Error('malformed state');
- }
-
- return { count, last_ts, last_stop_nn };
- } catch {
- // Missing or malformed → fresh state, eagerly rewritten so subsequent reads
- // short-circuit cleanly.
- try {
- writeFileSync(stateFilePath(token, tmpDir), FRESH_STATE_DISK, 'utf8');
- } catch (writeErr) {
- process.stderr.write(
- `checkpoint: failed to rewrite state file for token ${token}: ${writeErr}\n`,
- );
- }
- return {
- count: 0,
- last_ts: 0,
- last_stop_nn: '00',
- };
- }
-}
-
-/**
- * Write state to $tmpDir/onebrain-{token}.state (3-field format) via atomic
- * write-then-rename. The pid-suffixed temp file + POSIX rename mirrors the
- * pattern used by `register-hooks.ts:writeSettings` and prevents torn reads
- * if a writer is interrupted mid-write.
- *
- * Sync. Errors logged to stderr.
- */
-export function writeState(
- token: string,
- state: CheckpointState,
- tmpDir: string = osTmpdir(),
-): void {
- const path = stateFilePath(token, tmpDir);
- const tmpPath = `${path}.tmp.${process.pid}`;
- const content = `${state.count}:${state.last_ts}:${state.last_stop_nn}`;
- try {
- writeFileSync(tmpPath, content, 'utf8');
- renameSync(tmpPath, path);
- } catch (err) {
- process.stderr.write(`checkpoint: failed to write state file ${path}: ${err}\n`);
- // Best-effort cleanup of temp file if write succeeded but rename failed.
- try {
- unlinkSync(tmpPath);
- } catch {
- // ignore
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Config helper
-// ---------------------------------------------------------------------------
-
-const DEFAULT_LOGS_FOLDER = '07-logs';
-
-/**
- * Load vault settings from vault.yml (thresholds + logs folder).
- * Returns defaults if vault.yml is missing or throws.
- * Sync via readFileSync + regex parse — avoids async in stop hook hot path.
- */
-function loadVaultSettings(vaultRoot: string): {
- messagesThreshold: number;
- minutesThreshold: number;
- logsFolder: string;
-} {
- try {
- const vaultYml = join(vaultRoot, 'vault.yml');
- const raw = readFileSync(vaultYml, 'utf8');
- let messages = DEFAULT_MESSAGES_THRESHOLD;
- let minutes = DEFAULT_MINUTES_THRESHOLD;
- let logsFolder = DEFAULT_LOGS_FOLDER;
-
- const checkpointBlock = raw.match(/^checkpoint:\s*\n((?:[ \t]+[^\n]+\n?)*)/m);
- if (checkpointBlock?.[1]) {
- const block = checkpointBlock[1];
- const msgMatch = block.match(/messages:\s*(\d+)/);
- const minMatch = block.match(/minutes:\s*(\d+)/);
- if (msgMatch?.[1]) messages = Number(msgMatch[1]);
- if (minMatch?.[1]) minutes = Number(minMatch[1]);
- }
-
- const foldersBlock = raw.match(/^folders:\s*\n((?:[ \t]+[^\n]+\n?)*)/m);
- if (foldersBlock?.[1]) {
- const logsMatch = foldersBlock[1].match(/logs:\s*['"]?([^'"\s]+)['"]?/);
- if (logsMatch?.[1]) logsFolder = logsMatch[1];
- }
-
- return { messagesThreshold: messages, minutesThreshold: minutes, logsFolder };
- } catch {
- return {
- messagesThreshold: DEFAULT_MESSAGES_THRESHOLD,
- minutesThreshold: DEFAULT_MINUTES_THRESHOLD,
- logsFolder: DEFAULT_LOGS_FOLDER,
- };
- }
-}
-
-/**
- * Scan the flat checkpoint directory and return the highest checkpoint NN
- * for this session. Returns 0 if no checkpoint files exist (i.e. next NN
- * should be 01). Sync — safe to use in handleStop's hot path.
- *
- * Post-v2.4.0: checkpoints live at `[logs_folder]/checkpoint/` flat (no
- * YYYY/MM nesting). The `${date}-${token}-checkpoint-` prefix isolates
- * this session's checkpoints from other sessions/tokens sharing the dir.
- * `date` is kept in the signature for the prefix; the directory itself
- * is no longer date-derived.
- */
-export function maxCheckpointNnSync(
- vaultRoot: string,
- date: string,
- token: string,
- logsFolder: string,
-): number {
- const dir = join(vaultRoot, logsFolder, 'checkpoint');
- const prefix = `${date}-${token}-checkpoint-`;
- try {
- let max = 0;
- for (const f of readdirSync(dir)) {
- if (!f.startsWith(prefix) || !f.endsWith('.md')) continue;
- const m = f.match(/-checkpoint-(\d{2})\.md$/);
- if (m) max = Math.max(max, Number(m[1]));
- }
- return max;
- } catch {
- return 0;
- }
-}
-
-// ---------------------------------------------------------------------------
-// Date helpers
-// ---------------------------------------------------------------------------
-
-function formatDate(epochSeconds: number): string {
- const d = new Date(epochSeconds * 1000);
- const yyyy = d.getFullYear().toString();
- const mm = String(d.getMonth() + 1).padStart(2, '0');
- const dd = String(d.getDate()).padStart(2, '0');
- return `${yyyy}-${mm}-${dd}`;
-}
-
-// ---------------------------------------------------------------------------
-// JSON output helper
-// ---------------------------------------------------------------------------
-
-function emitBlock(reason: string): void {
- process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
-}
-
-// ---------------------------------------------------------------------------
-// reset mode
-// ---------------------------------------------------------------------------
-
-/**
- * Reset state: write 0::00 to state file.
- *
- * Called by the agent after a session log is written via /wrapup. Clears
- * count and last_stop_nn so the next checkpoint cycle starts from scratch.
- *
- * No stdout. Exit 0 always.
- */
-export function handleReset(
- token: string,
- now: number = Math.floor(Date.now() / 1000),
- tmpDir: string = osTmpdir(),
-): void {
- writeState(token, { count: 0, last_ts: now, last_stop_nn: '00' }, tmpDir);
-}
-
-// ---------------------------------------------------------------------------
-// stop mode
-// ---------------------------------------------------------------------------
-
-/**
- * Stop hook: increment message count, check thresholds, emit block if needed.
- * Sync — no async/await.
- */
-export function handleStop(
- token: string,
- vaultRoot: string,
- now: number = Math.floor(Date.now() / 1000),
- tmpDir: string = osTmpdir(),
-): void {
- const state = readState(token, tmpDir);
-
- // SKIP_WINDOW: if count=0 and last_ts is within 60s, this is right after a /wrapup reset
- if (state.count === 0 && state.last_ts > 0 && now - state.last_ts < SKIP_WINDOW) {
- return; // exit 0, state unchanged
- }
-
- // Increment count
- state.count += 1;
-
- const { messagesThreshold, minutesThreshold, logsFolder } = loadVaultSettings(vaultRoot);
- const timeThreshold = minutesThreshold * 60;
-
- // Elapsed: last_ts=0 is fresh-state sentinel → treat as 0 elapsed
- const elapsed = state.last_ts === 0 ? 0 : now - state.last_ts;
-
- const thresholdMet = state.count >= messagesThreshold || elapsed >= timeThreshold;
-
- if (!thresholdMet) {
- // Update count but preserve last_ts
- writeState(token, { ...state }, tmpDir);
- return;
- }
-
- // MIN_ACTIVITY guard: threshold fired but not enough messages
- if (state.count < MIN_ACTIVITY) {
- // Preserve last_ts so time clock doesn't restart
- writeState(token, { ...state }, tmpDir);
- return;
- }
-
- // Derive NN from disk — guarantees sequential numbering even if a previous
- // checkpoint block fired but Claude never wrote the file.
- const date = formatDate(now);
- const maxNn = maxCheckpointNnSync(vaultRoot, date, token, logsFolder);
- const nextNn = String(maxNn + 1).padStart(2, '0');
- const since =
- maxNn === 0 ? ' since start' : ` since checkpoint-${String(maxNn).padStart(2, '0')}`;
- emitBlock(`${nextNn}${since}`);
-
- writeState(token, { count: 0, last_ts: now, last_stop_nn: nextNn }, tmpDir);
-}
-
-// ---------------------------------------------------------------------------
-// CLI entry point
-// ---------------------------------------------------------------------------
-
-/**
- * Dispatch to the correct mode handler.
- * Always exits 0 (errors to stderr only).
- */
-export async function checkpointCommand(
- mode: string,
- token: string,
- vaultRoot: string,
-): Promise {
- try {
- switch (mode) {
- case 'stop':
- handleStop(token, vaultRoot);
- break;
- case 'reset':
- handleReset(token);
- break;
- default:
- process.stderr.write(`checkpoint: unknown mode '${mode}'\n`);
- }
- } catch (err) {
- process.stderr.write(`checkpoint: unexpected error in ${mode} mode: ${err}\n`);
- }
-}
diff --git a/src/commands/internal/cli-banner.test.ts b/src/commands/internal/cli-banner.test.ts
deleted file mode 100644
index a2af5a9b..00000000
--- a/src/commands/internal/cli-banner.test.ts
+++ /dev/null
@@ -1,252 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
-
-import {
- FINAL_COLOR,
- PREFIX_COLOR,
- SUBTITLE,
- TAGLINE_FALLBACK,
- TRAILING_COLOR,
- isInteractiveStdout,
- printBanner,
-} from './cli-banner.js';
-
-// ---------------------------------------------------------------------------
-// Helpers — capture stdout + toggle isTTY/COLORTERM safely across tests
-// ---------------------------------------------------------------------------
-
-interface StdoutSpy {
- chunks: string[];
- restore: () => void;
-}
-
-function spyStdout(): StdoutSpy {
- const chunks: string[] = [];
- const original = process.stdout.write.bind(process.stdout);
- process.stdout.write = ((chunk: string | Uint8Array): boolean => {
- chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
- return true;
- }) as typeof process.stdout.write;
- return {
- chunks,
- restore: () => {
- process.stdout.write = original;
- },
- };
-}
-
-function setIsTTY(value: boolean): () => void {
- // When `isTTY` lives on the prototype (common in non-TTY CI), the descriptor
- // lookup returns undefined; we must `deleteProperty` on restore instead of
- // writing `undefined`, otherwise the prototype lookup gets shadowed for the
- // rest of the test run.
- const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
- Object.defineProperty(process.stdout, 'isTTY', { value, configurable: true, writable: true });
- return () => {
- if (descriptor) Object.defineProperty(process.stdout, 'isTTY', descriptor);
- else Reflect.deleteProperty(process.stdout, 'isTTY');
- };
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-describe('printBanner — non-TTY (piped/CI) static path', () => {
- // Non-interactive output (piped, redirected, CI logs) must still print the
- // banner, statically and brand-colored — never animated, never cursor-toggling.
- let spy: StdoutSpy;
- let restoreIsTTY: () => void;
- let savedColorterm: string | undefined;
-
- beforeEach(() => {
- spy = spyStdout();
- restoreIsTTY = setIsTTY(false);
- savedColorterm = process.env['COLORTERM'];
- process.env['COLORTERM'] = 'truecolor'; // simulate piped output from a truecolor host
- });
-
- afterEach(() => {
- spy.restore();
- restoreIsTTY();
- if (savedColorterm === undefined) Reflect.deleteProperty(process.env, 'COLORTERM');
- else process.env['COLORTERM'] = savedColorterm;
- });
-
- it('writes the canonical uppercase tagline + subtitle', async () => {
- await printBanner();
- const all = spy.chunks.join('');
- expect(all).toContain(TAGLINE_FALLBACK);
- expect(all).toContain(SUBTITLE);
- expect(all.indexOf(SUBTITLE)).toBeGreaterThan(all.indexOf(TAGLINE_FALLBACK));
- });
-
- it('does not toggle the cursor or run animation (no \\x1b[?25l / \\x1b[?25h)', async () => {
- await printBanner();
- const all = spy.chunks.join('');
- expect(all).not.toContain('\x1b[?25l');
- expect(all).not.toContain('\x1b[?25h');
- // Animated path also emits cursor-up rewinds (ESC + [ + digits + F).
- // String-search avoids putting a literal ESC inside a regex.
- expect(all).not.toContain('\x1b[1F');
- expect(all).not.toContain('\x1b[11F');
- });
-
- it('uses brand cyan #00f3ff truecolor RGB when COLORTERM=truecolor (not generic 16-color cyan)', async () => {
- await printBanner();
- const all = spy.chunks.join('');
- // Truecolor SGR for #00f3ff = ESC[...;38;2;0;243;255m
- expect(all).toContain('38;2;0;243;255');
- // Should NOT use the generic 16-color cyan SGR ESC[36m (picocolors' pc.cyan).
- expect(all).not.toContain('\x1b[36m');
- });
-
- it('renders wordmark cells with the brand gradient (animation lives on the word)', async () => {
- await printBanner();
- const all = spy.chunks.join('');
- // The wordmark is the gradient canvas — many distinct truecolor SGR
- // codes should appear (not a flat solid color).
- const matches = all.match(/38;2;\d+;\d+;\d+/g) ?? [];
- const unique = new Set(matches);
- expect(unique.size).toBeGreaterThan(5);
- });
-
- it('exit path resolves promptly without hanging on animation timers', async () => {
- // Animated path runs ≥3 × SENTENCE_HOLD_MS plus intro and lock-shimmer
- // delays — easily >1s. Static exit path has no awaits and must return
- // well under that. A regression where isInteractiveStdout() wrongly
- // reports true (or the early return is removed) would make this hang.
- // 250ms threshold leaves ~4× headroom over the regression signal while
- // tolerating cold-CI variance.
- const start = performance.now();
- await expect(printBanner()).resolves.toBeUndefined();
- const elapsed = performance.now() - start;
- expect(elapsed).toBeLessThan(250);
- });
-});
-
-describe('printBanner — TTY without truecolor (16-color static fallback)', () => {
- let spy: StdoutSpy;
- let restoreIsTTY: () => void;
- let savedColorterm: string | undefined;
-
- beforeEach(() => {
- spy = spyStdout();
- restoreIsTTY = setIsTTY(true);
- savedColorterm = process.env['COLORTERM'];
- Reflect.deleteProperty(process.env, 'COLORTERM');
- });
-
- afterEach(() => {
- spy.restore();
- restoreIsTTY();
- if (savedColorterm === undefined) Reflect.deleteProperty(process.env, 'COLORTERM');
- else process.env['COLORTERM'] = savedColorterm;
- });
-
- it('writes the canonical uppercase tagline', async () => {
- await printBanner();
- const all = spy.chunks.join('');
- expect(all).toContain(TAGLINE_FALLBACK);
- expect(TAGLINE_FALLBACK).toBe('YOUR AI THINKING PARTNER');
- });
-
- it('writes the subtitle below the tagline', async () => {
- await printBanner();
- const all = spy.chunks.join('');
- expect(all).toContain(SUBTITLE);
- expect(all.indexOf(SUBTITLE)).toBeGreaterThan(all.indexOf(TAGLINE_FALLBACK));
- });
-
- it('does not run the truecolor animation (no cursor hide/show)', async () => {
- await printBanner();
- const all = spy.chunks.join('');
- expect(all).not.toContain('\x1b[?25l');
- expect(all).not.toContain('\x1b[?25h');
- });
-
- it('exit path resolves promptly without hanging on animation timers', async () => {
- // Symmetric to the non-TTY suite's exit-path guard. Both `!isInteractiveStdout()`
- // and `!supportsRgb()` independently route to the static path; this asserts the
- // !supportsRgb() branch (isTTY=true, no COLORTERM) also bails out fast.
- const start = performance.now();
- await expect(printBanner()).resolves.toBeUndefined();
- const elapsed = performance.now() - start;
- expect(elapsed).toBeLessThan(250);
- });
-});
-
-describe('brand color exports', () => {
- it('PREFIX_COLOR matches brand cyan #00f3ff', () => {
- expect(PREFIX_COLOR).toEqual([0, 243, 255]);
- });
-
- it('TRAILING_COLOR matches brand magenta #ff2d92', () => {
- expect(TRAILING_COLOR).toEqual([255, 45, 146]);
- });
-
- it('FINAL_COLOR settles to brand cyan (matches PREFIX_COLOR)', () => {
- expect(FINAL_COLOR).toEqual(PREFIX_COLOR);
- });
-});
-
-describe('canonical tagline + subtitle text', () => {
- it('TAGLINE_FALLBACK is the canonical uppercase form', () => {
- expect(TAGLINE_FALLBACK).toBe('YOUR AI THINKING PARTNER');
- });
-
- it('SUBTITLE is the canonical descriptive line', () => {
- expect(SUBTITLE).toBe('A unified intelligence in your Obsidian vault');
- });
-});
-
-describe('isInteractiveStdout — issue #131 env var override', () => {
- // bun-compiled binaries on Windows misdetect Git Bash MinTTY as non-TTY.
- // FORCE_COLOR=3 / ONEBRAIN_FORCE_TTY=1 let users override that detection
- // so they still get the animated banner. This suite locks the gate logic.
- let restoreIsTTY: () => void;
- let savedForceColor: string | undefined;
- let savedForceTty: string | undefined;
-
- beforeEach(() => {
- restoreIsTTY = setIsTTY(false);
- savedForceColor = process.env['FORCE_COLOR'];
- savedForceTty = process.env['ONEBRAIN_FORCE_TTY'];
- Reflect.deleteProperty(process.env, 'FORCE_COLOR');
- Reflect.deleteProperty(process.env, 'ONEBRAIN_FORCE_TTY');
- });
-
- afterEach(() => {
- restoreIsTTY();
- if (savedForceColor === undefined) Reflect.deleteProperty(process.env, 'FORCE_COLOR');
- else process.env['FORCE_COLOR'] = savedForceColor;
- if (savedForceTty === undefined) Reflect.deleteProperty(process.env, 'ONEBRAIN_FORCE_TTY');
- else process.env['ONEBRAIN_FORCE_TTY'] = savedForceTty;
- });
-
- it('returns false when isTTY=false and no override env var set', () => {
- expect(isInteractiveStdout()).toBe(false);
- });
-
- it('returns true when ONEBRAIN_FORCE_TTY=1 even with isTTY=false', () => {
- process.env['ONEBRAIN_FORCE_TTY'] = '1';
- expect(isInteractiveStdout()).toBe(true);
- });
-
- it('returns true when FORCE_COLOR=3 even with isTTY=false', () => {
- process.env['FORCE_COLOR'] = '3';
- expect(isInteractiveStdout()).toBe(true);
- });
-
- it('returns false for FORCE_COLOR=1 or 2 — only level 3 (truecolor) opts in', () => {
- process.env['FORCE_COLOR'] = '1';
- expect(isInteractiveStdout()).toBe(false);
- process.env['FORCE_COLOR'] = '2';
- expect(isInteractiveStdout()).toBe(false);
- });
-
- it('returns true when isTTY=true regardless of env vars', () => {
- restoreIsTTY();
- restoreIsTTY = setIsTTY(true);
- expect(isInteractiveStdout()).toBe(true);
- });
-});
diff --git a/src/commands/internal/cli-banner.ts b/src/commands/internal/cli-banner.ts
deleted file mode 100644
index 3ea4802c..00000000
--- a/src/commands/internal/cli-banner.ts
+++ /dev/null
@@ -1,617 +0,0 @@
-import pc from 'picocolors';
-
-declare const BUILD_VERSION: string;
-
-export function resolveBinaryVersion(): string {
- if (typeof BUILD_VERSION !== 'undefined') return BUILD_VERSION;
- try {
- const pkg = require('../../../package.json') as { version?: string };
- return pkg.version ?? 'dev';
- } catch {
- return 'dev';
- }
-}
-
-// ---------------------------------------------------------------------------
-// Banner data — figlet "big" font camelcase "OneBrain" wordmark, alone.
-// No top/bottom border, no logo — the wordmark IS the brand mark, and the
-// gradient + shimmer animation paints directly on the letters.
-//
-// Layout:
-// inner: lead 5 + wordmark (43 cols, 6 rows) = 48 visible cols
-// tagline: lead 5 + 24 chars (anchored to wordmark left)
-// subtitle: lead 5 + 45 chars
-//
-// The "big" figlet font has natural mixed-case glyph support (uppercase O/B,
-// lowercase n/e/r/a/i/n) and a chunky outlined+filled feel that reads as a
-// block-letter brand mark in the terminal. Each letter cell takes the brand
-// gradient (magenta → mid-pink → cyan) along the diagonal sweep, so the
-// wordmark itself becomes the canvas for the animation.
-// ---------------------------------------------------------------------------
-
-const ART_LINES = [
- ' ____ ____ _ ',
- ' / __ \\ | _ \\ (_) ',
- '| | | |_ __ ___| |_) |_ __ __ _ _ _ __ ',
- "| | | | '_ \\ / _ \\ _ <| '__/ _` | | '_ \\ ",
- '| |__| | | | | __/ |_) | | | (_| | | | | |',
- ' \\____/|_| |_|\\___|____/|_| \\__,_|_|_| |_|',
-];
-
-const PREFIX = 'YOUR AI ';
-const TAGLINE_LEAD = ' '; // 5 spaces — anchors under wordmark column
-export const TAGLINE_FALLBACK = `${PREFIX}THINKING PARTNER`;
-export const SUBTITLE = 'A unified intelligence in your Obsidian vault';
-const BANNER_LINE_COUNT = 1 + ART_LINES.length + 3;
-
-type Rgb = [number, number, number];
-
-interface Sentence {
- trailing: string;
- trailingWords: string[];
- /** Per-word ms/char tick rate (length matches trailingWords) */
- wordTicks: number[];
-}
-
-// Color scheme — aligned with website CI brand palette:
-// "YOUR AI" prefix → brand cyan #00f3ff throughout
-// trailing 2 words → brand magenta #ff2d92 during all sentences
-// final lock shimmer → sweeps full tagline; behind the head, every char
-// settles to brand cyan (magenta "burns out" to cyan)
-// subtitle → brand cyan dimmed along its own hue axis, reads as
-// a secondary descriptive layer while staying inside
-// the cyan family per the brand-CI memory.
-//
-// PREFIX/TRAILING/FINAL colors and TAGLINE_FALLBACK/SUBTITLE are exported
-// only for the colocated test suite — not part of the public CLI API.
-export const PREFIX_COLOR: Rgb = [0, 243, 255]; // #00f3ff brand cyan
-export const TRAILING_COLOR: Rgb = [255, 45, 146]; // #ff2d92 brand magenta
-export const FINAL_COLOR: Rgb = [0, 243, 255]; // #00f3ff brand cyan
-const SUBTITLE_COLOR: Rgb = [0, 170, 178]; // brand cyan ~70% — same hue, lower intensity
-
-const SENTENCES: Sentence[] = [
- { trailing: 'REMEMBERS YOU', trailingWords: ['REMEMBERS', 'YOU'], wordTicks: [24, 32] },
- { trailing: 'CATCHES INSIGHTS', trailingWords: ['CATCHES', 'INSIGHTS'], wordTicks: [27, 26] },
- { trailing: 'THINKING PARTNER', trailingWords: ['THINKING', 'PARTNER'], wordTicks: [26, 31] },
-];
-
-// ---------------------------------------------------------------------------
-// Color helpers
-// ---------------------------------------------------------------------------
-
-function supportsRgb(): boolean {
- // FORCE_COLOR=3 is the npm-CLI convention for "force 24-bit color" — honor
- // it so users on environments that under-report truecolor (Git Bash MinTTY,
- // some CI runners) can opt into the brand-gradient render.
- if (process.env['FORCE_COLOR'] === '3') return true;
- const c = process.env['COLORTERM'] ?? '';
- return c === 'truecolor' || c === '24bit';
-}
-
-// Treat stdout as interactive when (a) it is a real TTY, or (b) the user has
-// explicitly opted in via env var. Issue #131: bun-compiled binaries on
-// Windows misdetect Git Bash MinTTY pipes as non-TTY; FORCE_COLOR=3 or
-// ONEBRAIN_FORCE_TTY=1 lets those users still get the animated banner.
-// Exported for the colocated test suite — not part of the public CLI API.
-export function isInteractiveStdout(): boolean {
- if (process.env['ONEBRAIN_FORCE_TTY'] === '1') return true;
- if (process.env['FORCE_COLOR'] === '3') return true;
- return Boolean(process.stdout.isTTY);
-}
-
-function rgb(r: number, g: number, b: number, ch: string): string {
- return `\x1b[1;38;2;${r};${g};${b}m${ch}\x1b[0m`;
-}
-function rgbStr(c: Rgb, ch: string): string {
- return rgb(c[0], c[1], c[2], ch);
-}
-
-// Brand gradient — 3-stop magenta → mid pink → cyan, mirrors the diagonal
-// gradient on the brain SVG (top-left magenta, bottom-right cyan). Replaces
-// the old full-hue rainbow so every banner frame stays inside the OneBrain
-// brand palette across the whole CLI surface.
-const BRAND_STOPS: Array<{ t: number; rgb: Rgb }> = [
- { t: 0, rgb: [255, 45, 146] }, // #ff2d92 brand magenta
- { t: 0.55, rgb: [255, 90, 163] }, // #ff5aa3 mid pink (matches SVG mid-stop)
- { t: 1, rgb: [0, 243, 255] }, // #00f3ff brand cyan
-];
-
-function brandGradient(t: number): Rgb {
- const tt = Math.max(0, Math.min(1, t));
- for (let i = 0; i < BRAND_STOPS.length - 1; i++) {
- const a = BRAND_STOPS[i]!;
- const b = BRAND_STOPS[i + 1]!;
- if (tt <= b.t) {
- const local = (tt - a.t) / (b.t - a.t);
- return [
- Math.round(a.rgb[0] + (b.rgb[0] - a.rgb[0]) * local),
- Math.round(a.rgb[1] + (b.rgb[1] - a.rgb[1]) * local),
- Math.round(a.rgb[2] + (b.rgb[2] - a.rgb[2]) * local),
- ];
- }
- }
- return BRAND_STOPS[BRAND_STOPS.length - 1]!.rgb;
-}
-
-// Diagonal `d = col - 3 * row` is the only axis the gradient varies along.
-// `[DIAG_MIN, DIAG_MAX]` is derived once from `ART_LINES` so the color map
-// (this file) and the animation iteration loops in `playBannerIntro` always
-// agree on the banner extent — single source of truth.
-const [DIAG_MIN, DIAG_MAX] = ((): [number, number] => {
- let min = 0;
- let max = 0;
- for (let row = 0; row < ART_LINES.length; row++) {
- min = Math.min(min, -row * 3);
- max = Math.max(max, ART_LINES[row]!.length - 1 - row * 3);
- }
- return [min, max];
-})();
-const DIAG_RANGE = DIAG_MAX - DIAG_MIN;
-
-function gradientForCell(row: number, col: number): Rgb {
- const d = col - 3 * row;
- return brandGradient((d - DIAG_MIN) / DIAG_RANGE);
-}
-
-const WHITE_SGR = '\x1b[1;97m';
-const SGR_RESET = '\x1b[0m';
-
-function whiteCell(ch: string): string {
- return `${WHITE_SGR}${ch}${SGR_RESET}`;
-}
-
-function neonLine(line: string, lineIndex = 0): string {
- return line
- .split('')
- .map((ch, col) => {
- if (ch === ' ') return ch;
- const [r, g, b] = gradientForCell(lineIndex, col);
- return rgb(r, g, b, ch);
- })
- .join('');
-}
-
-function whiteLine(line: string): string {
- return line
- .split('')
- .map((ch) => (ch === ' ' ? ch : `\x1b[1;97m${ch}\x1b[0m`))
- .join('');
-}
-
-function whiteGlowLine(line: string, alpha: number): string {
- return line
- .split('')
- .map((ch) => (ch === ' ' ? ch : `\x1b[1;38;2;${alpha};${alpha};${alpha}m${ch}\x1b[0m`))
- .join('');
-}
-
-function renderSubtitle(): string {
- // Faint (SGR 2) + custom RGB — secondary descriptive layer, intentionally
- // less prominent than the bold brand tagline above it.
- const [r, g, b] = SUBTITLE_COLOR;
- return `\x1b[2;38;2;${r};${g};${b}m${SUBTITLE}\x1b[0m`;
-}
-
-function dimLine(line: string): string {
- // Cyan-leaning dim — the unbuilt CRT-scan pre-state stays inside the brand
- // hue family rather than reading as neutral terminal grey.
- return line
- .split('')
- .map((ch) => (ch === ' ' ? ch : `\x1b[2;38;2;30;60;70m${ch}\x1b[0m`))
- .join('');
-}
-
-function scanLineCh(line: string): string {
- return line
- .split('')
- .map((ch) => (ch === ' ' ? ch : rgb(140, 255, 255, ch)))
- .join('');
-}
-
-// Cursor + wipe scanner share a bright cyan accent that stays close to the
-// brand cyan #00f3ff but reads with more presence on dark backgrounds.
-const SCAN_CYAN: Rgb = [140, 255, 255];
-const CURSOR = rgb(SCAN_CYAN[0], SCAN_CYAN[1], SCAN_CYAN[2], '▌');
-const GLYPHS = '▓░▒█│┤┐└┴┬├─┼╪╫╬╧╨╤╥╙╘╒╓┘┌║▌▀▄▐∆ƒΩ§¶±÷×ø¥€';
-const randGlyph = () => GLYPHS[Math.floor(Math.random() * GLYPHS.length)] ?? '?';
-const glitchWhite = (g: string) => `\x1b[1;97m${g}\x1b[0m`;
-
-// ---------------------------------------------------------------------------
-// Frame printer
-// ---------------------------------------------------------------------------
-
-function outb(str: string): void {
- process.stdout.write(Buffer.from(str, 'utf8'));
-}
-
-function printFrame(artLines: string[], tagline: string): void {
- outb('\n');
- for (const l of artLines) outb(`${l}\n`);
- outb('\n');
- outb(`${tagline}\n`);
- outb('\n');
-}
-
-function blankTagline(): string {
- return `${TAGLINE_LEAD}${' '.repeat(TAGLINE_FALLBACK.length)}`;
-}
-
-function buildTaglineLine(prefixLockedChars: number, trailingPart: string): string {
- let s = TAGLINE_LEAD;
- for (let i = 0; i < PREFIX.length; i++) {
- if (i < prefixLockedChars) {
- s += PREFIX[i] === ' ' ? ' ' : rgbStr(PREFIX_COLOR, PREFIX[i]!);
- } else {
- s += ' ';
- }
- }
- s += trailingPart;
- return `${s}\x1b[K`;
-}
-
-// Tagline tempo
-const LOCK_LATENCY = 4;
-const PREFIX_TICK_MS = [27, 27]; // "Your" / "AI"
-const INTER_WORD_PAUSE_MS = 65;
-const SENTENCE_HOLD_MS = 500;
-const WIPE_TICK_MS = 22;
-const WIPE_TRAIL = 3;
-const WIPE_PAUSE_MS = 80;
-
-// ---------------------------------------------------------------------------
-// Banner intro — 3-phase reveal (sequential)
-// ---------------------------------------------------------------------------
-
-async function playBannerIntro(brandArt: string[], whiteArt: string[]): Promise {
- const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
- const up = (n: number) => outb(`\x1b[${n}F`);
-
- // Phase 1A — CRT scan top→bottom, banner builds in white
- printFrame(ART_LINES.map(dimLine), blankTagline());
-
- for (let scan = 0; scan < ART_LINES.length; scan++) {
- await delay(55);
- up(BANNER_LINE_COUNT);
- printFrame(
- ART_LINES.map((l, i) => {
- if (i < scan - 2) return whiteLine(l);
- if (i === scan - 2) return whiteGlowLine(l, 200);
- if (i === scan - 1) return whiteGlowLine(l, 230);
- if (i === scan) return scanLineCh(l);
- return dimLine(l);
- }),
- blankTagline(),
- );
- }
-
- await delay(40);
- up(BANNER_LINE_COUNT);
- printFrame(whiteArt, blankTagline());
-
- // Hold pure white (CRT settle) — 600ms
- await delay(600);
-
- // Phase 1B — brand gradient (magenta → cyan) flows diagonally across the
- // wordmark itself, mirroring the SVG brand logo's gradient direction.
- // Every glyph cell takes its own gradient color along the diagonal sweep.
- function flowFrame(frontD: number): string[] {
- return ART_LINES.map((line, row) =>
- line
- .split('')
- .map((ch, col) => {
- if (ch === ' ') return ch;
- const d = col - 3 * row;
- if (d <= frontD) {
- const [r, g, b] = gradientForCell(row, col);
- return rgb(r, g, b, ch);
- }
- return whiteCell(ch);
- })
- .join(''),
- );
- }
-
- for (let d = DIAG_MIN; d <= DIAG_MAX; d++) {
- await delay(9);
- up(BANNER_LINE_COUNT);
- printFrame(flowFrame(d), blankTagline());
- }
- up(BANNER_LINE_COUNT);
- printFrame(brandArt, blankTagline());
- await delay(180);
-
- // Phase 1C — white shimmer sweeps the same diagonal direction over the
- // gradient-painted wordmark; non-highlight cells stay in their gradient.
- function shimmerArtFrame(highlight: number): string[] {
- return ART_LINES.map((line, row) =>
- line
- .split('')
- .map((ch, col) => {
- if (ch === ' ') return ch;
- const d = col - 3 * row;
- if (Math.abs(d - highlight) <= 1) return whiteCell(ch);
- const [r, g, b] = gradientForCell(row, col);
- return rgb(r, g, b, ch);
- })
- .join(''),
- );
- }
- for (let d = DIAG_MIN; d <= DIAG_MAX; d++) {
- await delay(9);
- up(BANNER_LINE_COUNT);
- printFrame(shimmerArtFrame(d), blankTagline());
- }
- up(BANNER_LINE_COUNT);
- printFrame(brandArt, blankTagline());
- await delay(80);
-}
-
-// ---------------------------------------------------------------------------
-// Tagline phase — 3 rotating sentences via wipe swap, then lock shimmer
-// ---------------------------------------------------------------------------
-
-async function decodeFirstSentence(brandArt: string[], s: Sentence): Promise {
- const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
- const up = (n: number) => outb(`\x1b[${n}F`);
-
- const prefixWords = ['YOUR', 'AI'];
- for (let wi = 0; wi < prefixWords.length; wi++) {
- const w = prefixWords[wi]!;
- const tickMs = PREFIX_TICK_MS[wi]!;
- const totalTicks = w.length + LOCK_LATENCY;
- const baseIdx = prefixWords.slice(0, wi).reduce((a, x) => a + x.length + 1, 0);
-
- for (let t = 1; t <= totalTicks; t++) {
- await delay(tickMs);
- up(BANNER_LINE_COUNT);
- let prefixPart = TAGLINE_LEAD;
- for (let i = 0; i < PREFIX.length; i++) {
- if (i < baseIdx) {
- prefixPart += PREFIX[i] === ' ' ? ' ' : rgbStr(PREFIX_COLOR, PREFIX[i]!);
- } else if (i >= baseIdx + w.length) {
- prefixPart += ' ';
- } else {
- const localIdx = i - baseIdx;
- const age = t - localIdx;
- if (age > LOCK_LATENCY) prefixPart += rgbStr(PREFIX_COLOR, PREFIX[i]!);
- else if (age > 0) prefixPart += glitchWhite(randGlyph());
- else if (age === 0 && t < w.length) prefixPart += CURSOR;
- else prefixPart += ' ';
- }
- }
- const trailingBlank = ' '.repeat(s.trailing.length);
- printFrame(brandArt, `${prefixPart}${trailingBlank}\x1b[K`);
- }
- if (wi < prefixWords.length - 1) {
- await delay(INTER_WORD_PAUSE_MS);
- }
- }
-
- await delay(INTER_WORD_PAUSE_MS);
- await decodeTrailing(brandArt, s, PREFIX.length);
-}
-
-async function decodeTrailing(
- brandArt: string[],
- s: Sentence,
- lockedPrefixChars: number,
-): Promise {
- const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
- const up = (n: number) => outb(`\x1b[${n}F`);
-
- const words = s.trailingWords;
- const ticks = s.wordTicks;
- const offsets: number[] = [];
- let off = 0;
- for (const w of words) {
- offsets.push(off);
- off += w.length + 1;
- }
-
- for (let wi = 0; wi < words.length; wi++) {
- const w = words[wi]!;
- const tickMs = ticks[wi]!;
- const totalTicks = w.length + LOCK_LATENCY;
-
- for (let t = 1; t <= totalTicks; t++) {
- await delay(tickMs);
- up(BANNER_LINE_COUNT);
- let trailing = '';
- for (let j = 0; j < s.trailing.length; j++) {
- const ch = s.trailing[j]!;
- if (ch === ' ') {
- trailing += ' ';
- continue;
- }
- let owningWi = -1;
- let localIdx = -1;
- for (let k = 0; k < words.length; k++) {
- const start = offsets[k]!;
- const end = start + words[k]!.length;
- if (j >= start && j < end) {
- owningWi = k;
- localIdx = j - start;
- break;
- }
- }
- if (owningWi < wi) {
- trailing += rgbStr(TRAILING_COLOR, ch);
- } else if (owningWi > wi) {
- trailing += ' ';
- } else {
- const age = t - localIdx;
- if (age > LOCK_LATENCY) trailing += rgbStr(TRAILING_COLOR, ch);
- else if (age > 0) trailing += glitchWhite(randGlyph());
- else if (age === 0 && t < w.length) trailing += CURSOR;
- else trailing += ' ';
- }
- }
- printFrame(brandArt, buildTaglineLine(lockedPrefixChars, trailing));
- }
- if (wi < words.length - 1) {
- await delay(INTER_WORD_PAUSE_MS);
- }
- }
-}
-
-async function wipeSwapTransition(brandArt: string[], from: Sentence, to: Sentence): Promise {
- const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
- const up = (n: number) => outb(`\x1b[${n}F`);
-
- // Phase A: bright cyan scanner R→L erases the trailing
- for (let pos = from.trailing.length - 1; pos >= -WIPE_TRAIL; pos--) {
- await delay(WIPE_TICK_MS);
- up(BANNER_LINE_COUNT);
- let trailing = '';
- for (let j = 0; j < from.trailing.length; j++) {
- const ch = from.trailing[j]!;
- if (ch === ' ') {
- trailing += ' ';
- continue;
- }
- const offset = j - pos;
- if (offset >= 0 && offset <= WIPE_TRAIL) {
- trailing += rgb(140, 255, 255, randGlyph());
- } else if (j > pos) {
- trailing += ' ';
- } else {
- trailing += rgbStr(TRAILING_COLOR, ch);
- }
- }
- printFrame(brandArt, buildTaglineLine(PREFIX.length, trailing));
- }
- await delay(WIPE_PAUSE_MS);
-
- // Phase B: type+glitch decode of the new trailing
- await decodeTrailing(brandArt, to, PREFIX.length);
-}
-
-async function lockShimmer(brandArt: string[], s: Sentence): Promise {
- const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
- const up = (n: number) => outb(`\x1b[${n}F`);
-
- const SHIMMER_TICK_MS = 22;
- const TRAIL = 3;
- // Brand-aligned shimmer trail: white head → pale cyan → brand cyan tail
- // (matches FINAL_COLOR), so the wave settles cleanly into the brand palette.
- const STOPS: Rgb[] = [
- [255, 255, 255], // head — pure white flash
- [180, 220, 255], // mid — pale cyan
- [0, 243, 255], // tail — brand cyan #00f3ff
- ];
- const fullText = PREFIX + s.trailing;
- const N = fullText.length;
-
- for (let pos = 0; pos <= N + TRAIL; pos++) {
- await delay(SHIMMER_TICK_MS);
- up(BANNER_LINE_COUNT);
- let line = TAGLINE_LEAD;
- for (let j = 0; j < N; j++) {
- const ch = fullText[j]!;
- if (ch === ' ') {
- line += ' ';
- continue;
- }
- const offset = pos - j;
- if (offset >= 0 && offset < TRAIL) {
- line += rgbStr(STOPS[offset]!, ch);
- } else if (offset >= TRAIL) {
- // Behind head — every char settles to neon cyan
- line += rgbStr(FINAL_COLOR, ch);
- } else {
- // Ahead of head — original locked color
- const baseColor = j < PREFIX.length ? PREFIX_COLOR : TRAILING_COLOR;
- line += rgbStr(baseColor, ch);
- }
- }
- line += '\x1b[K';
- printFrame(brandArt, line);
- }
-
- // Final settle — entire tagline neon cyan + subtitle layer. Replaces the
- // final printFrame so we can append the subtitle line. This block writes
- // `BANNER_LINE_COUNT + 1` lines (one extra for the subtitle); the on-screen
- // banner footprint after `printBanner()` returns is therefore one line
- // taller than `BANNER_LINE_COUNT` reports. Callers must NOT use
- // BANNER_LINE_COUNT to rewind past `printBanner()`'s output — it describes
- // a single animation frame, not the final on-screen height.
- up(BANNER_LINE_COUNT);
- let finalLine = TAGLINE_LEAD;
- for (let j = 0; j < N; j++) {
- const ch = fullText[j]!;
- finalLine += ch === ' ' ? ' ' : rgbStr(FINAL_COLOR, ch);
- }
- finalLine += '\x1b[K';
- const subtitleLine = `${TAGLINE_LEAD}${renderSubtitle()}\x1b[K`;
- outb('\n');
- for (const l of brandArt) outb(`${l}\n`);
- outb('\n');
- outb(`${finalLine}\n`);
- outb(`${subtitleLine}\n`);
- outb('\n');
- await delay(150);
-}
-
-// ---------------------------------------------------------------------------
-// Public entry point
-// ---------------------------------------------------------------------------
-
-// Render a static, non-animated banner — used when stdout is not an
-// interactive TTY (piped, redirected, CI logs) OR when the terminal does not
-// support 24-bit color. With truecolor we paint each cell using the same
-// brand gradient as the animated path; without truecolor we fall back to
-// `pc.cyan` so 16-color terminals still see a brand-aligned monochrome line.
-function printStaticBanner(): void {
- const truecolor = supportsRgb();
- outb('\n');
- if (truecolor) {
- for (let i = 0; i < ART_LINES.length; i++) outb(`${neonLine(ART_LINES[i]!, i)}\n`);
- } else {
- for (const l of ART_LINES) outb(`${pc.bold(pc.cyan(l))}\n`);
- }
- outb('\n');
- if (truecolor) {
- // Tagline is a single solid block (one ANSI wrap around the whole string)
- // since all chars share FINAL_COLOR. Per-char wrapping is reserved for the
- // animated path where each cell can have a different color.
- outb(`${TAGLINE_LEAD}${rgbStr(FINAL_COLOR, TAGLINE_FALLBACK)}\n`);
- outb(`${TAGLINE_LEAD}${renderSubtitle()}\n`);
- } else {
- outb(`${TAGLINE_LEAD}${pc.bold(pc.cyan(TAGLINE_FALLBACK))}\n`);
- outb(`${TAGLINE_LEAD}${pc.dim(pc.cyan(SUBTITLE))}\n`);
- }
- outb('\n');
-}
-
-export async function printBanner(): Promise {
- // Animation requires both an interactive TTY (cursor positioning works) and
- // 24-bit color (for the brand gradient). Anything else gets the static
- // banner — still brand-colored when truecolor is available.
- if (!isInteractiveStdout() || !supportsRgb()) {
- printStaticBanner();
- return;
- }
-
- const brandArt = ART_LINES.map((l, i) => neonLine(l, i));
- const whiteArt = ART_LINES.map((l) => whiteLine(l));
-
- try {
- // Hide system cursor inside the try block so the finally always restores it.
- outb('\x1b[?25l');
- await playBannerIntro(brandArt, whiteArt);
-
- // Tagline rotation
- await decodeFirstSentence(brandArt, SENTENCES[0]!);
- await new Promise((r) => setTimeout(r, SENTENCE_HOLD_MS));
-
- await wipeSwapTransition(brandArt, SENTENCES[0]!, SENTENCES[1]!);
- await new Promise((r) => setTimeout(r, SENTENCE_HOLD_MS));
-
- await wipeSwapTransition(brandArt, SENTENCES[1]!, SENTENCES[2]!);
- await new Promise((r) => setTimeout(r, SENTENCE_HOLD_MS));
-
- await lockShimmer(brandArt, SENTENCES[2]!);
- } finally {
- outb('\x1b[?25h');
- }
-}
diff --git a/src/commands/internal/cli-ui.ts b/src/commands/internal/cli-ui.ts
deleted file mode 100644
index b4d57f03..00000000
--- a/src/commands/internal/cli-ui.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-/**
- * Shared CLI UI primitives used by init, update, and doctor commands.
- *
- * Layout language (clack-inspired):
- * ┌ message ← barOpen (start a new bar group)
- * │ message ← barLine / barBlank
- * ● emoji label ← createStep (done state)
- * └ message ← close (finish bar group)
- */
-
-import pc from 'picocolors';
-
-// ── Constants ──────────────────────────────────────────────────────────────────
-
-const bar = pc.cyan('│');
-const dot = pc.green('●');
-
-// Force UTF-8 bytes. Bun's TTY write path encodes strings with system locale;
-// writing Buffer bypasses that and always produces correct UTF-8 output.
-function out(str: string): void {
- process.stdout.write(Buffer.from(str, 'utf8'));
-}
-
-// ── Output helpers ─────────────────────────────────────────────────────────────
-
-export function writeLine(msg: string): void {
- out(`${msg}\n`);
-}
-
-export function barLine(msg: string): void {
- out(`${bar} ${msg}\n`);
-}
-
-export function barBlank(): void {
- out(`${bar}\n`);
-}
-
-/** Open a new bar group with a top corner. Use after a previous close()
- * to start a visually-distinct second section without leaving an orphan │. */
-export function barOpen(msg: string): void {
- out(`${pc.cyan('┌')} ${msg}\n`);
-}
-
-export function close(msg: string, isError = false, isWarning = false): void {
- if (isError) {
- out(`${pc.cyan('└')} ${pc.bold(pc.red(msg))}\n`);
- } else if (isWarning) {
- out(`${pc.cyan('└')} ${pc.yellow(msg)}\n`);
- } else {
- out(`${pc.cyan('└')} ${msg}\n`);
- }
-}
-
-/** Output a completed-step dot line without a preceding spinner. */
-export function dotLine(emoji: string, label: string): void {
- out(`${dot} ${emoji} ${label}\n`);
-}
-
-// ── Spinner step ───────────────────────────────────────────────────────────────
-
-/**
- * Returns a createStep factory bound to the given isTTY flag.
- * Usage:
- * const createStep = makeStepFn(isTTY);
- * const sp = createStep('📋', 'vault.yml');
- * // ... async work ...
- * sp?.stop('valid', ['key: value']);
- */
-export function makeStepFn(isTTY: boolean) {
- return function createStep(emoji: string, label: string) {
- if (!isTTY) return null;
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
- let i = 0;
- out(`${pc.green(frames[0]!)} ${emoji} ${label}…\n`);
- const timer = setInterval(() => {
- i = (i + 1) % frames.length;
- out(`\x1b[1A\x1b[2K${pc.green(frames[i]!)} ${emoji} ${label}…\n`);
- }, 80);
- return {
- stop(result?: string, details?: string[]) {
- clearInterval(timer);
- process.stdout.write(Buffer.from('\x1b[1A\x1b[2K', 'utf8'));
- out(`${dot} ${emoji} ${label}\n`);
- if (result !== undefined) barLine(result);
- if (details) for (const d of details) barLine(` · ${d}`);
- barBlank();
- },
- };
- };
-}
-
-// ── Yes / No prompt ────────────────────────────────────────────────────────────
-
-/**
- * Renders a toggleable Yes/No prompt without clack's gray separator line.
- * Arrow keys / Tab toggle the selection; Enter confirms; y/n shortcut; Ctrl+C → null.
- * Returns true (Yes), false (No), or null (cancelled).
- */
-export async function askYesNo(question: string): Promise {
- out(`${pc.cyan('◆')} ${question}\n`);
- process.stdout.write(Buffer.from('\x1b[?25l', 'utf8'));
-
- function renderOptions(yes: boolean): void {
- const yesLabel = yes ? `${pc.bold(pc.green('●'))} Yes` : `${pc.dim('○')} Yes`;
- const noLabel = yes ? `${pc.dim('○')} No` : `${pc.bold(pc.green('●'))} No`;
- out(`\x1b[2K${bar} ${yesLabel} / ${noLabel}\r`);
- }
-
- const answer = await new Promise((resolve) => {
- let selected = true;
- renderOptions(selected);
- const { stdin } = process;
- const wasRaw = stdin.isTTY ? stdin.isRaw : false;
- if (stdin.isTTY) stdin.setRawMode(true);
- stdin.resume();
- function onData(buf: Buffer): void {
- const key = buf.toString();
- if (key === '\x03') {
- stdin.removeListener('data', onData);
- if (stdin.isTTY) stdin.setRawMode(wasRaw);
- stdin.pause();
- resolve(null);
- } else if (key === '\r' || key === '\n') {
- stdin.removeListener('data', onData);
- if (stdin.isTTY) stdin.setRawMode(wasRaw);
- stdin.pause();
- resolve(selected);
- } else if (key === 'y' || key === 'Y') {
- stdin.removeListener('data', onData);
- if (stdin.isTTY) stdin.setRawMode(wasRaw);
- stdin.pause();
- resolve(true);
- } else if (key === 'n' || key === 'N') {
- stdin.removeListener('data', onData);
- if (stdin.isTTY) stdin.setRawMode(wasRaw);
- stdin.pause();
- resolve(false);
- } else if (key === '\x1b[C' || key === '\x1b[D' || key === '\t') {
- selected = !selected;
- renderOptions(selected);
- }
- }
- stdin.on('data', onData);
- });
-
- process.stdout.write(Buffer.from('\n\x1b[?25h\x1b[1A\x1b[2K', 'utf8'));
- return answer;
-}
diff --git a/src/commands/internal/harness.test.ts b/src/commands/internal/harness.test.ts
deleted file mode 100644
index eb6202e6..00000000
--- a/src/commands/internal/harness.test.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * Tests for harness detection.
- *
- * `detectHarnesses()` returns ALL detected harnesses (multi-harness vaults
- * matter — a vault with both .claude/ and .gemini/ wants OneBrain configured
- * for both). `detectHarness()` is a thin wrapper that returns the first.
- */
-
-import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
-import { mkdir, rm } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { detectHarness, detectHarnesses } from './harness.js';
-
-let vaultDir: string;
-
-beforeEach(async () => {
- vaultDir = join(tmpdir(), `ob-harness-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
- await mkdir(vaultDir, { recursive: true });
- // biome-ignore lint/performance/noDelete: env cleanup requires delete to unset
- delete process.env['ONEBRAIN_HARNESS'];
-});
-
-afterEach(async () => {
- await rm(vaultDir, { recursive: true, force: true });
- // biome-ignore lint/performance/noDelete: env cleanup requires delete to unset
- delete process.env['ONEBRAIN_HARNESS'];
-});
-
-// ---------------------------------------------------------------------------
-// detectHarnesses
-// ---------------------------------------------------------------------------
-
-describe('detectHarnesses', () => {
- test('both .claude/ and .gemini/ present → ["claude", "gemini"] (claude first)', async () => {
- await mkdir(join(vaultDir, '.claude'), { recursive: true });
- await mkdir(join(vaultDir, '.gemini'), { recursive: true });
-
- const result = await detectHarnesses(vaultDir);
- expect(result).toEqual(['claude', 'gemini']);
- });
-
- test('only .claude/ present → ["claude"]', async () => {
- await mkdir(join(vaultDir, '.claude'), { recursive: true });
-
- const result = await detectHarnesses(vaultDir);
- expect(result).toEqual(['claude']);
- });
-
- test('only .gemini/ present → ["gemini"]', async () => {
- await mkdir(join(vaultDir, '.gemini'), { recursive: true });
-
- const result = await detectHarnesses(vaultDir);
- expect(result).toEqual(['gemini']);
- });
-
- test('neither directory present → ["direct"]', async () => {
- const result = await detectHarnesses(vaultDir);
- expect(result).toEqual(['direct']);
- });
-
- test('ONEBRAIN_HARNESS=claude → ["claude"] (overrides directory detection)', async () => {
- process.env['ONEBRAIN_HARNESS'] = 'claude';
- // .gemini/ present should be ignored when env is set
- await mkdir(join(vaultDir, '.gemini'), { recursive: true });
-
- const result = await detectHarnesses(vaultDir);
- expect(result).toEqual(['claude']);
- });
-
- test('ONEBRAIN_HARNESS=claude-code → ["claude"] (alias)', async () => {
- process.env['ONEBRAIN_HARNESS'] = 'claude-code';
- const result = await detectHarnesses(vaultDir);
- expect(result).toEqual(['claude']);
- });
-
- test('ONEBRAIN_HARNESS=gemini → ["gemini"]', async () => {
- process.env['ONEBRAIN_HARNESS'] = 'gemini';
- // .claude/ present should be ignored when env is set
- await mkdir(join(vaultDir, '.claude'), { recursive: true });
-
- const result = await detectHarnesses(vaultDir);
- expect(result).toEqual(['gemini']);
- });
-
- test('ONEBRAIN_HARNESS=direct → ["direct"]', async () => {
- process.env['ONEBRAIN_HARNESS'] = 'direct';
- const result = await detectHarnesses(vaultDir);
- expect(result).toEqual(['direct']);
- });
-
- test('ONEBRAIN_HARNESS=garbage → falls back to directory detection', async () => {
- process.env['ONEBRAIN_HARNESS'] = 'not-a-harness';
- await mkdir(join(vaultDir, '.claude'), { recursive: true });
-
- const result = await detectHarnesses(vaultDir);
- expect(result).toEqual(['claude']);
- });
-});
-
-// ---------------------------------------------------------------------------
-// detectHarness — backward-compat wrapper
-// ---------------------------------------------------------------------------
-
-describe('detectHarness (legacy single-value wrapper)', () => {
- test('both .claude/ and .gemini/ → "claude" (first detected)', async () => {
- await mkdir(join(vaultDir, '.claude'), { recursive: true });
- await mkdir(join(vaultDir, '.gemini'), { recursive: true });
-
- const result = await detectHarness(vaultDir);
- expect(result).toBe('claude');
- });
-
- test('only .claude/ → "claude"', async () => {
- await mkdir(join(vaultDir, '.claude'), { recursive: true });
- const result = await detectHarness(vaultDir);
- expect(result).toBe('claude');
- });
-
- test('only .gemini/ → "gemini"', async () => {
- await mkdir(join(vaultDir, '.gemini'), { recursive: true });
- const result = await detectHarness(vaultDir);
- expect(result).toBe('gemini');
- });
-
- test('neither → "direct"', async () => {
- const result = await detectHarness(vaultDir);
- expect(result).toBe('direct');
- });
-
- test('ONEBRAIN_HARNESS=gemini → "gemini"', async () => {
- process.env['ONEBRAIN_HARNESS'] = 'gemini';
- const result = await detectHarness(vaultDir);
- expect(result).toBe('gemini');
- });
-});
diff --git a/src/commands/internal/harness.ts b/src/commands/internal/harness.ts
deleted file mode 100644
index b74e775f..00000000
--- a/src/commands/internal/harness.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { stat } from 'node:fs/promises';
-import { join } from 'node:path';
-
-export type Harness = 'claude' | 'gemini' | 'direct';
-
-async function pathExists(p: string): Promise {
- try {
- await stat(p);
- return true;
- } catch {
- return false;
- }
-}
-
-/**
- * Detect which AI runtime harness(es) are in use.
- *
- * A vault may configure multiple harnesses (e.g. both .claude/ and .gemini/
- * present means the user wants OneBrain hooks installed for BOTH). Return all
- * detected; caller decides what to do with each.
- *
- * Priority:
- * 1. ONEBRAIN_HARNESS env var (explicit override — single value, returned as
- * a one-element array; honors backward compat with the single-harness API).
- * 2. .claude/ and .gemini/ directory presence — independent checks; either or
- * both can be returned.
- * 3. fallback → ['direct']
- */
-export async function detectHarnesses(vaultRoot: string): Promise {
- const env = process.env['ONEBRAIN_HARNESS'];
- if (env) {
- if (env === 'claude' || env === 'claude-code') return ['claude'];
- if (env === 'gemini') return ['gemini'];
- if (env === 'direct') return ['direct'];
- process.stderr.write(
- `harness: unknown ONEBRAIN_HARNESS value "${env}" — ignoring, falling back to directory detection\n`,
- );
- }
-
- const detected: Harness[] = [];
- if (await pathExists(join(vaultRoot, '.claude'))) detected.push('claude');
- if (await pathExists(join(vaultRoot, '.gemini'))) detected.push('gemini');
-
- if (detected.length === 0) return ['direct'];
- return detected;
-}
-
-/**
- * Backward-compat shim: keep the old `detectHarness` signature for any callers
- * outside register-hooks that consume a single value. Returns the FIRST
- * detected harness, mirroring the new priority (claude before gemini).
- *
- * `detectHarnesses` always returns at least one element (`['direct']` as
- * fallback), so the destructured value is non-undefined — the explicit
- * `?? 'direct'` is a defense-in-depth guard that lets us avoid a non-null
- * assertion without changing observable behavior.
- */
-export async function detectHarness(vaultRoot: string): Promise {
- const [first] = await detectHarnesses(vaultRoot);
- return first ?? 'direct';
-}
diff --git a/src/commands/internal/migrate.test.ts b/src/commands/internal/migrate.test.ts
deleted file mode 100644
index 934090e5..00000000
--- a/src/commands/internal/migrate.test.ts
+++ /dev/null
@@ -1,336 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
-import { chmodSync } from 'node:fs';
-import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-
-import { runBackfillRecapped } from './migrate.js';
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-async function makeTmpDir(): Promise {
- return mkdtemp(join(tmpdir(), 'onebrain-test-migrate-'));
-}
-
-/**
- * Make a session-log month directory at the post-v2.4.0 path
- * (`/session/YYYY/MM/`).
- */
-async function makeMonthDir(logsDir: string, year: string, month: string): Promise {
- const dir = join(logsDir, 'session', year, month);
- await mkdir(dir, { recursive: true });
- return dir;
-}
-
-/**
- * Create a session log file with optional frontmatter
- */
-async function writeSessionLog(
- dir: string,
- filename: string,
- frontmatter?: Record,
-): Promise {
- let content = '';
- if (frontmatter) {
- content += '---\n';
- for (const [key, value] of Object.entries(frontmatter)) {
- if (typeof value === 'string') {
- content += `${key}: ${value}\n`;
- } else if (typeof value === 'boolean') {
- content += `${key}: ${value}\n`;
- } else {
- content += `${key}: ${JSON.stringify(value)}\n`;
- }
- }
- content += '---\n';
- }
- content += '\n## Session\n\nTest content\n';
-
- await writeFile(join(dir, filename), content);
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-describe('runBackfillRecapped', () => {
- let tmpDir: string;
- let logsDir: string;
- const today = new Date().toISOString().slice(0, 10);
-
- beforeEach(async () => {
- tmpDir = await makeTmpDir();
- logsDir = join(tmpDir, '07-logs');
- await mkdir(logsDir, { recursive: true });
- });
-
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- it('returns backfilled: 0, skipped: 0 for empty logs dir', async () => {
- const result = await runBackfillRecapped(logsDir);
- expect(result).toEqual({ backfilled: 0, skipped: 0 });
- });
-
- it('returns backfilled: 0, skipped: 0 when logs dir does not exist', async () => {
- const nonexistent = join(tmpDir, 'nonexistent');
- const result = await runBackfillRecapped(nonexistent);
- expect(result).toEqual({ backfilled: 0, skipped: 0 });
- });
-
- it('skips checkpoint files (filename contains -checkpoint-)', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- await writeSessionLog(monthDir, '2026-04-20-abc12345-checkpoint-01.md', {
- tags: 'checkpoint',
- checkpoint: '01',
- recapped: false,
- });
-
- const result = await runBackfillRecapped(logsDir);
- expect(result).toEqual({ backfilled: 0, skipped: 0 });
- });
-
- it('skips files that already have recapped field', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
- tags: 'session-log',
- recapped: '2026-04-20',
- });
-
- const result = await runBackfillRecapped(logsDir);
- expect(result).toEqual({ backfilled: 0, skipped: 0 });
- });
-
- it('skips session log with recapped: false (field present, falsy value)', async () => {
- // recapped: false means "field exists" → !== undefined → skip
- // A falsy check (!recapped) would incorrectly backfill this file
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- await writeSessionLog(monthDir, '2026-04-21-session-01.md', {
- tags: 'session-log',
- recapped: false,
- });
-
- const result = await runBackfillRecapped(logsDir);
- expect(result).toEqual({ backfilled: 0, skipped: 0 });
- });
-
- it('adds recapped field to session log missing it', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
- tags: 'session-log',
- date: '2026-04-20',
- });
-
- const result = await runBackfillRecapped(logsDir);
- expect(result).toEqual({ backfilled: 1, skipped: 0 });
-
- // Verify file was updated
- const content = await Bun.file(join(monthDir, '2026-04-20-session-01.md')).text();
- expect(content).toContain(`recapped: ${today}`);
- });
-
- it('skips files with malformed frontmatter', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- // Missing closing ---
- await writeFile(
- join(monthDir, '2026-04-20-session-01.md'),
- '---\ntags: session-log\ndate: 2026-04-20\n\n## Session\nContent\n',
- );
-
- const result = await runBackfillRecapped(logsDir);
- expect(result.skipped).toBeGreaterThan(0);
- });
-
- it('processes multiple files in multiple months', async () => {
- const monthDir1 = await makeMonthDir(logsDir, '2026', '04');
- const monthDir2 = await makeMonthDir(logsDir, '2026', '03');
-
- // File in April without recapped
- await writeSessionLog(monthDir1, '2026-04-20-session-01.md', {
- tags: 'session-log',
- date: '2026-04-20',
- });
-
- // File in March without recapped
- await writeSessionLog(monthDir2, '2026-03-15-session-01.md', {
- tags: 'session-log',
- date: '2026-03-15',
- });
-
- // File that already has recapped
- await writeSessionLog(monthDir1, '2026-04-19-session-01.md', {
- tags: 'session-log',
- date: '2026-04-19',
- recapped: '2026-04-19',
- });
-
- const result = await runBackfillRecapped(logsDir);
- expect(result).toEqual({ backfilled: 2, skipped: 0 });
- });
-
- it('skips files without frontmatter', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- // File with no frontmatter
- await writeFile(join(monthDir, '2026-04-20-session-01.md'), '## Session\n\nContent\n');
-
- const result = await runBackfillRecapped(logsDir);
- expect(result).toEqual({ backfilled: 0, skipped: 1 });
- });
-
- it('does not treat ---something as a closing frontmatter delimiter', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- // Body contains a line starting with ---foo which must NOT close the frontmatter.
- // The real closing --- (bare, followed by newline) appears later.
- const content = [
- '---',
- 'tags: session-log',
- 'date: 2026-04-20',
- '---',
- '',
- '## Session',
- '',
- '---some-separator',
- '',
- 'Content below separator.',
- ].join('\n');
- await writeFile(join(monthDir, '2026-04-20-session-01.md'), content);
-
- const result = await runBackfillRecapped(logsDir);
- // File has valid frontmatter (no recapped), should be backfilled
- expect(result).toEqual({ backfilled: 1, skipped: 0 });
-
- // Verify frontmatter was updated correctly without corrupting the body
- const updated = await Bun.file(join(monthDir, '2026-04-20-session-01.md')).text();
- expect(updated).toContain(`recapped: ${today}`);
- expect(updated).toContain('---some-separator');
- });
-
- it('preserves existing frontmatter fields when adding recapped', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
- tags: 'session-log',
- date: '2026-04-20',
- foo: 'bar',
- });
-
- const result = await runBackfillRecapped(logsDir);
- expect(result).toEqual({ backfilled: 1, skipped: 0 });
-
- // Verify all fields are preserved
- const content = await Bun.file(join(monthDir, '2026-04-20-session-01.md')).text();
- expect(content).toContain('tags: session-log');
- expect(content).toContain('date: 2026-04-20');
- expect(content).toContain('foo: bar');
- expect(content).toContain(`recapped: ${today}`);
- });
-
- it('writeFile failure (EACCES via chmodSync): skipped === 1, backfilled === 1 (other file writable)', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
-
- // File 1: readable, writable — will be backfilled
- const _file1 = join(monthDir, '2026-04-20-session-01.md');
- await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
- tags: 'session-log',
- date: '2026-04-20',
- });
-
- // File 2: readable, but write-protected — will cause EACCES on writeFile
- const file2 = join(monthDir, '2026-04-21-session-01.md');
- await writeSessionLog(monthDir, '2026-04-21-session-01.md', {
- tags: 'session-log',
- date: '2026-04-21',
- });
- chmodSync(file2, 0o444);
-
- try {
- const result = await runBackfillRecapped(logsDir);
- // One file successfully backfilled, one failed due to EACCES
- expect(result.backfilled).toBe(1);
- expect(result.skipped).toBe(1);
- } finally {
- // Restore permissions so cleanup works
- chmodSync(file2, 0o644);
- }
- });
-
- it('all files read-only: backfilled === 0, skipped === 2', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
-
- const file1 = join(monthDir, '2026-04-20-session-01.md');
- const file2 = join(monthDir, '2026-04-21-session-01.md');
-
- await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
- tags: 'session-log',
- date: '2026-04-20',
- });
- await writeSessionLog(monthDir, '2026-04-21-session-01.md', {
- tags: 'session-log',
- date: '2026-04-21',
- });
-
- chmodSync(file1, 0o444);
- chmodSync(file2, 0o444);
-
- try {
- const result = await runBackfillRecapped(logsDir);
- expect(result.backfilled).toBe(0);
- expect(result.skipped).toBe(2);
- } finally {
- chmodSync(file1, 0o644);
- chmodSync(file2, 0o644);
- }
- });
-
- it('cutoffDate: skips logs newer than cutoff, backfills older ones', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
- tags: 'session-log',
- date: '2026-04-20',
- });
- await writeSessionLog(monthDir, '2026-04-25-session-01.md', {
- tags: 'session-log',
- date: '2026-04-25',
- });
-
- // cutoff = 2026-04-22 → 2026-04-20 is backfilled, 2026-04-25 is skipped
- const result = await runBackfillRecapped(logsDir, '2026-04-22');
- expect(result.backfilled).toBe(1);
- expect(result.skipped).toBe(0);
-
- const older = await readFile(join(monthDir, '2026-04-20-session-01.md'), 'utf8');
- const newer = await readFile(join(monthDir, '2026-04-25-session-01.md'), 'utf8');
- expect(older).toContain('recapped:');
- expect(newer).not.toContain('recapped:');
- });
-
- it('cutoffDate: equal to log date → log is backfilled (boundary inclusive)', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- await writeSessionLog(monthDir, '2026-04-22-session-01.md', {
- tags: 'session-log',
- date: '2026-04-22',
- });
-
- // cutoff = 2026-04-22 → date equal to cutoff → should be backfilled
- const result = await runBackfillRecapped(logsDir, '2026-04-22');
- expect(result.backfilled).toBe(1);
- });
-
- it('handles idempotent re-runs: only first run backfills', async () => {
- const monthDir = await makeMonthDir(logsDir, '2026', '04');
- await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
- tags: 'session-log',
- date: '2026-04-20',
- });
-
- // First run
- const result1 = await runBackfillRecapped(logsDir);
- expect(result1).toEqual({ backfilled: 1, skipped: 0 });
-
- // Second run — should skip since recapped now exists
- const result2 = await runBackfillRecapped(logsDir);
- expect(result2).toEqual({ backfilled: 0, skipped: 0 });
- });
-});
diff --git a/src/commands/internal/migrate.ts b/src/commands/internal/migrate.ts
deleted file mode 100644
index aacdf8e2..00000000
--- a/src/commands/internal/migrate.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-/**
- * migrate — internal command
- *
- * Runs one-time migration scripts to update vault state.
- * Currently supports `backfill-recapped` migration.
- *
- * Output: plain text summary (not JSON)
- * Exit code: always 0 (internal pattern)
- */
-
-import { readFile, readdir, writeFile } from 'node:fs/promises';
-import { join } from 'node:path';
-import { parse, stringify } from 'yaml';
-import { loadVaultConfig } from '../../lib/index.js';
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-export type MigrateResult = {
- backfilled: number;
- skipped: number;
-};
-
-// ---------------------------------------------------------------------------
-// Frontmatter helpers
-// ---------------------------------------------------------------------------
-
-/**
- * Extract YAML frontmatter from markdown text.
- * Returns { frontmatter object, remainingText } or null if no valid frontmatter.
- */
-function parseFrontmatterWithRest(rawText: string): {
- frontmatter: Record;
- rest: string;
-} | null {
- const text = rawText.replace(/\r\n/g, '\n');
- if (!text.startsWith('---')) return null;
-
- // Require closing --- to be followed by newline or end-of-string (bare --- line only).
- // Rejects lines like ---foo or ---some-separator as false closers.
- const endMatch = /\n---(\n|$)/.exec(text.slice(3));
- if (!endMatch) return null;
- const endIdx = 3 + endMatch.index;
- const rest = text.slice(endIdx + endMatch[0].length);
-
- const fmText = text.slice(3, endIdx).trim();
-
- try {
- const parsed = parse(fmText);
- if (parsed && typeof parsed === 'object') {
- return {
- frontmatter: parsed as Record,
- rest,
- };
- }
- return null;
- } catch {
- return null;
- }
-}
-
-// ---------------------------------------------------------------------------
-// File listing helper
-// ---------------------------------------------------------------------------
-
-async function listMdFiles(dir: string): Promise {
- try {
- const entries = await readdir(dir);
- return entries.filter((e) => e.endsWith('.md'));
- } catch {
- return [];
- }
-}
-
-// ---------------------------------------------------------------------------
-// runBackfillRecapped (testable core)
-// ---------------------------------------------------------------------------
-
-/**
- * Core logic for backfill-recapped migration.
- * Scans session logs at `[logsFolder]/session/YYYY/MM/` and adds
- * `recapped: ` to frontmatter where missing.
- *
- * Post-v2.4.0: session logs live under `session/YYYY/MM/`, not at the
- * `logsFolder` top level. Caller (`/update`) always runs the 07-logs
- * structure migration (Step 0) before invoking this, so by the time
- * we walk, the new layout is in place.
- *
- * @param logsFolder - absolute path to logs folder
- * @param cutoffDate - ISO date string (YYYY-MM-DD); skip logs with date > cutoffDate
- * @returns MigrateResult with backfilled and skipped counts
- */
-export async function runBackfillRecapped(
- logsFolder: string,
- cutoffDate?: string,
-): Promise {
- const today = new Date().toISOString().slice(0, 10);
- let backfilled = 0;
- let skipped = 0;
-
- // List all year directories under session/ (post-v2.4.0 layout).
- const sessionRoot = join(logsFolder, 'session');
- let yearDirs: string[] = [];
- try {
- yearDirs = await readdir(sessionRoot);
- } catch {
- // session/ doesn't exist (fresh vault, no session logs yet, or
- // pre-v2.4.0 vault that hasn't run the structure migration).
- // Return empty — nothing to backfill.
- return { backfilled: 0, skipped: 0 };
- }
-
- for (const yearDir of yearDirs) {
- const yearPath = join(sessionRoot, yearDir);
-
- // List all month directories under year
- let monthDirs: string[] = [];
- try {
- monthDirs = await readdir(yearPath);
- } catch {
- continue;
- }
-
- for (const monthDir of monthDirs) {
- const monthPath = join(yearPath, monthDir);
- const files = await listMdFiles(monthPath);
-
- for (const fname of files) {
- const fpath = join(monthPath, fname);
-
- // Whitelist: only session logs get the `recapped:` frontmatter
- // backfill. The logs folder also contains `*-checkpoint-*.md`,
- // `*-update-vX.Y.Z.md`, and `*-weekly.md` — none of which carry
- // a `recapped:` field by convention. The previous blacklist
- // (`-checkpoint-` only) silently mutated update + weekly log
- // frontmatter with a meaningless `recapped:` value.
- if (!fname.includes('-session-')) {
- continue;
- }
-
- // Skip logs newer than cutoff (YYYY-MM-DD prefix from filename)
- if (cutoffDate) {
- const dateMatch = fname.match(/^(\d{4}-\d{2}-\d{2})/);
- if (dateMatch?.[1] !== undefined && dateMatch[1] > cutoffDate) {
- continue;
- }
- }
-
- try {
- const content = await readFile(fpath, 'utf8');
- const parsed = parseFrontmatterWithRest(content);
-
- if (!parsed) {
- // No frontmatter or malformed
- process.stderr.write(`migrate: ${fname} — malformed frontmatter\n`);
- skipped++;
- continue;
- }
-
- const { frontmatter, rest } = parsed;
-
- // Skip if already has recapped
- if (frontmatter['recapped'] !== undefined) {
- continue;
- }
-
- // Add recapped field
- frontmatter['recapped'] = today;
-
- // Rebuild file with updated frontmatter
- const updatedFm = stringify(frontmatter);
- const updatedContent = `---\n${updatedFm}---\n${rest}`;
-
- await writeFile(fpath, updatedContent, 'utf8');
- backfilled++;
- } catch (error) {
- process.stderr.write(`migrate: error processing ${fname}: ${error}\n`);
- skipped++;
- }
- }
- }
- }
-
- return { backfilled, skipped };
-}
-
-// ---------------------------------------------------------------------------
-// CLI entry point
-// ---------------------------------------------------------------------------
-
-/**
- * Run migrate as a CLI command.
- * Currently supports 'backfill-recapped' migration.
- * Always exits 0 (internal pattern).
- */
-export async function migrateCommand(
- migrationName: string,
- cutoffDate?: string,
- vaultDir?: string,
-): Promise {
- try {
- const vaultRoot = vaultDir ?? process.cwd();
- const config = await loadVaultConfig(vaultRoot);
- const logsFolder = join(vaultRoot, config.folders.logs);
-
- if (migrationName === 'backfill-recapped') {
- const result = await runBackfillRecapped(logsFolder, cutoffDate);
- process.stdout.write(`backfilled: ${result.backfilled} files, skipped: ${result.skipped}\n`);
- } else {
- process.stderr.write(`migrate: unknown migration '${migrationName}'\n`);
- }
- } catch (error) {
- process.stderr.write(`migrate: ${error}\n`);
- }
-}
diff --git a/src/commands/internal/orphan-scan.test.ts b/src/commands/internal/orphan-scan.test.ts
deleted file mode 100644
index 35ee6aa3..00000000
--- a/src/commands/internal/orphan-scan.test.ts
+++ /dev/null
@@ -1,699 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
-import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-
-import { runOrphanScan } from './orphan-scan.js';
-
-// ---------------------------------------------------------------------------
-// Pinned clock
-//
-// runOrphanScan reads "today" from an injected `now: Date`. Tests must pass
-// PINNED_NOW (or another explicit Date) so behavior never depends on the wall
-// clock — fixture day numbers were silently colliding with the active-session
-// guard whenever today's day matched a hardcoded fixture day.
-//
-// RULE: Do not call `new Date()` (no args) or `Date.now()` in this file.
-// `new Date('')` is fine — it's deterministic.
-// All other time-dependent values must derive from PINNED_NOW or be hardcoded.
-// ---------------------------------------------------------------------------
-
-// 12:00Z is mid-day in UTC — safe across all real-world TZs (≥12h from any local midnight).
-//
-// PINNED_NOW is intentionally far in the future: the new Active-Session Guard
-// (PR #156 follow-up) compares each fixture file's mtime to PINNED_NOW. For
-// fixtures that don't pin their own mtime via setMtime(), the on-disk mtime
-// is the real wall clock; if PINNED_NOW were close to today, a future test
-// run could see wall_clock > PINNED_NOW (negative age), trip the future-mtime
-// fail-safe, and silently flip "expected orphan" tests to "skipped active".
-// 2099-01-01 keeps fixtures unambiguously in the past relative to PINNED_NOW
-// for the foreseeable life of this codebase without breaking any
-// month-boundary fixtures (TODAY/PAST_DATE/PREV_DATE all stay in 2098).
-const PINNED_NOW = new Date('2099-01-15T12:00:00Z');
-const TODAY = '2099-01-15';
-const THIS_YEAR = '2099';
-const THIS_MONTH = '01';
-const PREV_YEAR = '2098';
-const PREV_MONTH = '12';
-const PAST_DATE = '2099-01-01'; // any day in current month != TODAY
-const PREV_DATE = '2098-12-15';
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-async function makeTmpDir(): Promise {
- return mkdtemp(join(tmpdir(), 'onebrain-os-test-'));
-}
-
-function checkpointName(date: string, token: string, nn: number): string {
- return `${date}-${token}-checkpoint-${String(nn).padStart(2, '0')}.md`;
-}
-
-function sessionLogName(date: string, nn: number): string {
- return `${date}-session-${String(nn).padStart(2, '0')}.md`;
-}
-
-function checkpointFrontmatter(merged: boolean, date = PAST_DATE): string {
- return `---\ntags: [checkpoint, session-log]\ndate: ${date}\ncheckpoint: 01\ntrigger: stop\nmerged: ${merged}\n---\n\n## What We Worked On\n\nTest content.`;
-}
-
-function sessionLogFrontmatter(autoSaved: boolean): string {
- return `---\ntags: [session-log]\ndate: ${PAST_DATE}\nauto-saved: ${autoSaved}\n---\n\n## Session\n\nTest.`;
-}
-
-// Post-v2.4.0 layout:
-// - checkpoints live in `logsDir/checkpoint/` (flat)
-// - session logs live in `logsDir/session/YYYY/MM/`
-//
-// `makeThisMonthDir` and `makeMonthDir` now return the flat checkpoint
-// directory regardless of year/month — the `year`/`month` parameters
-// remain in the signature so call sites that previously relied on them
-// continue to compile, but those values are ignored here. Tests that
-// need a session-log directory must call `makeSessionMonthDir` explicitly.
-async function makeCheckpointDir(logsDir: string): Promise {
- const dir = join(logsDir, 'checkpoint');
- await mkdir(dir, { recursive: true });
- return dir;
-}
-
-async function makeSessionMonthDir(logsDir: string, year: string, month: string): Promise {
- const dir = join(logsDir, 'session', year, month);
- await mkdir(dir, { recursive: true });
- return dir;
-}
-
-async function makeMonthDir(logsDir: string, _year: string, _month: string): Promise {
- return makeCheckpointDir(logsDir);
-}
-
-async function makeThisMonthDir(logsDir: string): Promise {
- return makeCheckpointDir(logsDir);
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-describe('runOrphanScan', () => {
- let tmpDir: string;
- let logsDir: string;
-
- beforeEach(async () => {
- tmpDir = await makeTmpDir();
- logsDir = join(tmpDir, '07-logs');
- await mkdir(logsDir, { recursive: true });
- });
-
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- it('returns orphan_count: 0 when no checkpoint files exist', async () => {
- const result = await runOrphanScan(logsDir, 'abc12345', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- // Update snapshots: bun test --update-snapshots
- it('output shape matches snapshot { orphan_count: N }', async () => {
- // Zero orphans — verifies the shape is { orphan_count: 0 }
- const zeroResult = await runOrphanScan(logsDir, 'abc12345', PINNED_NOW, tmpDir);
- expect(zeroResult).toMatchSnapshot();
-
- // One orphan — verifies the shape is { orphan_count: 1 }
- const monthDir = await makeThisMonthDir(logsDir);
- await writeFile(
- join(monthDir, `${PAST_DATE}-snaptoken-checkpoint-01.md`),
- '---\ntags: [checkpoint]\nmerged: false\n---\n\nContent.',
- 'utf8',
- );
- const oneResult = await runOrphanScan(logsDir, 'differenttoken', PINNED_NOW, tmpDir);
- expect(oneResult).toMatchSnapshot();
- });
-
- it('returns orphan_count: 0 when logs folder does not exist', async () => {
- const result = await runOrphanScan(join(tmpDir, 'nonexistent'), 'abc12345', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- // Since v2.2.0, /wrapup deletes checkpoints directly after the session log
- // is verified — any checkpoint file that still exists is unmerged by
- // definition. Legacy `merged: true` files (and the `merged: "true"` quoted
- // variant) are now treated identically to unmerged files, so the only thing
- // that suppresses an orphan is a manual session log for that date or the
- // current session token match.
- it('counts legacy checkpoint with merged: true as an orphan', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const fname = checkpointName(PAST_DATE, 'token11', 1);
- await writeFile(join(monthDir, fname), checkpointFrontmatter(true), 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('counts legacy checkpoint with merged: "true" (quoted string) as an orphan', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const fname = checkpointName(PAST_DATE, 'tokenStrTrue', 1);
- const content = `---\ntags: [checkpoint, session-log]\ndate: ${PAST_DATE}\ncheckpoint: 01\ntrigger: stop\nmerged: "true"\n---\n\n## What We Worked On\n\nTest content.`;
- await writeFile(join(monthDir, fname), content, 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('skips checkpoint files matching current session token', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const fname = checkpointName(PAST_DATE, 'current99', 1);
- await writeFile(join(monthDir, fname), checkpointFrontmatter(false), 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('skips checkpoint when a manual (non-auto-saved) session log exists for that date', async () => {
- const checkpointDir = await makeThisMonthDir(logsDir);
- const sessionMonthDir = await makeSessionMonthDir(logsDir, THIS_YEAR, THIS_MONTH);
- const cpName = checkpointName(PAST_DATE, 'tokenAA', 1);
- await writeFile(join(checkpointDir, cpName), checkpointFrontmatter(false), 'utf8');
- const logName = sessionLogName(PAST_DATE, 1);
- await writeFile(join(sessionMonthDir, logName), sessionLogFrontmatter(false), 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('does NOT skip when only auto-saved session log exists for that date', async () => {
- const checkpointDir = await makeThisMonthDir(logsDir);
- const sessionMonthDir = await makeSessionMonthDir(logsDir, THIS_YEAR, THIS_MONTH);
- const cpName = checkpointName(PAST_DATE, 'tokenBB', 1);
- await writeFile(join(checkpointDir, cpName), checkpointFrontmatter(false), 'utf8');
- const logName = sessionLogName(PAST_DATE, 1);
- await writeFile(join(sessionMonthDir, logName), sessionLogFrontmatter(true), 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- // Regression: `hasManualSessionLog` only matches files whose name
- // contains the literal `-session-` infix. Post-v2.4.0 the session
- // folder (`session/YYYY/MM/`) physically separates session logs from
- // other artifacts, but the whitelist remains as defense-in-depth so
- // a future stray non-session file in `session/` (e.g., a manual edit
- // accident) cannot false-suppress an orphan checkpoint.
- it('does NOT suppress orphan when a non-session file lives next to session logs', async () => {
- const checkpointDir = await makeThisMonthDir(logsDir);
- const sessionMonthDir = await makeSessionMonthDir(logsDir, THIS_YEAR, THIS_MONTH);
- const cpName = checkpointName(PAST_DATE, 'tokenStrayFile', 1);
- await writeFile(join(checkpointDir, cpName), checkpointFrontmatter(false), 'utf8');
- // Stray non-session file dropped into session/YYYY/MM/ (e.g. manual edit
- // mistake). Without the `-session-` infix filter, hasManualSessionLog
- // would accept it and falsely suppress the orphan.
- await writeFile(
- join(sessionMonthDir, `${PAST_DATE}-stray-note.md`),
- `---\ntags: [stray]\ndate: ${PAST_DATE}\n---\n\nManual edit\n`,
- 'utf8',
- );
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- // Companion case: both an update log AND a manual session log exist
- // for the same date — the manual session log still wins, orphan
- // suppressed. Verifies the whitelist didn't regress the skip behavior.
- // Post-v2.4.0: update log lives in update/ (own folder), so its presence
- // is irrelevant to hasManualSessionLog (which only scans session/YYYY/MM/).
- // Test still useful as regression: confirms orphan stays suppressed when
- // a real manual session log is present.
- it('still skips when both an update-log and a manual session log exist for that date', async () => {
- const checkpointDir = await makeThisMonthDir(logsDir);
- const sessionMonthDir = await makeSessionMonthDir(logsDir, THIS_YEAR, THIS_MONTH);
- const updateDir = join(logsDir, 'update');
- await mkdir(updateDir, { recursive: true });
- const cpName = checkpointName(PAST_DATE, 'tokenULSL', 1);
- await writeFile(join(checkpointDir, cpName), checkpointFrontmatter(false), 'utf8');
- await writeFile(
- join(updateDir, `${PAST_DATE}-update-v2.1.10.md`),
- `---\ntags: [update-log]\ndate: ${PAST_DATE}\n---\n\nUpdate.`,
- 'utf8',
- );
- const logName = sessionLogName(PAST_DATE, 1);
- await writeFile(join(sessionMonthDir, logName), sessionLogFrontmatter(false), 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('counts unmerged orphan checkpoints from current month', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- for (const token of ['tokenCC', 'tokenDD']) {
- const cpName = checkpointName(PAST_DATE, token, 1);
- await writeFile(join(monthDir, cpName), checkpointFrontmatter(false), 'utf8');
- }
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 2 });
- });
-
- it('counts orphans from previous month dir', async () => {
- const monthDir = await makeMonthDir(logsDir, PREV_YEAR, PREV_MONTH);
- const cpName = checkpointName(PREV_DATE, 'tokenEE', 1);
- await writeFile(join(monthDir, cpName), checkpointFrontmatter(false, PREV_DATE), 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('multiple checkpoints for same token in same month count as one orphan session', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- for (let i = 1; i <= 2; i++) {
- const cpName = checkpointName(PAST_DATE, 'tokenFF', i);
- await writeFile(join(monthDir, cpName), checkpointFrontmatter(false), 'utf8');
- }
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('handles files with missing frontmatter gracefully (counts as orphan)', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const cpName = checkpointName(PAST_DATE, 'tokenGG', 1);
- await writeFile(join(monthDir, cpName), '# No frontmatter here\n\nContent.', 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it("creates a checkpoint file with today's actual date → orphan_count: 0 (today boundary skipped)", async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const fname = checkpointName(TODAY, 'todaytoken', 1);
- await writeFile(join(monthDir, fname), checkpointFrontmatter(false, TODAY), 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it("today's file skipped but a past date in same month still counted", async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const todayFname = checkpointName(TODAY, 'todaytoken', 1);
- await writeFile(join(monthDir, todayFname), checkpointFrontmatter(false, TODAY), 'utf8');
- const pastFname = checkpointName(PAST_DATE, 'pasttoken', 1);
- await writeFile(join(monthDir, pastFname), checkpointFrontmatter(false), 'utf8');
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('combines orphans from both months in total count', async () => {
- const thisMonthDir = await makeThisMonthDir(logsDir);
- const prevMonthDir = await makeMonthDir(logsDir, PREV_YEAR, PREV_MONTH);
-
- await writeFile(
- join(thisMonthDir, checkpointName(PAST_DATE, 'tokenHH', 1)),
- checkpointFrontmatter(false),
- 'utf8',
- );
- await writeFile(
- join(prevMonthDir, checkpointName(PREV_DATE, 'tokenII', 1)),
- checkpointFrontmatter(false, PREV_DATE),
- 'utf8',
- );
-
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 2 });
- });
-
- // -------------------------------------------------------------------------
- // Active-Session Guard (60-min mtime window) — symmetric with /wrapup PR #156
- // -------------------------------------------------------------------------
-
- // Helper: pin a file's mtime to a specific moment relative to PINNED_NOW so
- // the guard's `now - mtime` math is deterministic. Uses utimes() with Date
- // objects (the seconds-as-number form depends on platform stat resolution).
- async function setMtime(path: string, mtime: Date): Promise {
- await utimes(path, mtime, mtime);
- }
-
- // 60 min before PINNED_NOW (boundary: counted as orphan — guard is `< 60`).
- const SIXTY_MIN_AGO = new Date(PINNED_NOW.getTime() - 60 * 60 * 1000);
- // 30 min before PINNED_NOW (active session in another harness — skipped).
- const THIRTY_MIN_AGO = new Date(PINNED_NOW.getTime() - 30 * 60 * 1000);
- // 90 min before PINNED_NOW (clearly stale — counted).
- const NINETY_MIN_AGO = new Date(PINNED_NOW.getTime() - 90 * 60 * 1000);
- // 5 min into the future from PINNED_NOW (clock skew — fail-safe skip).
- const FIVE_MIN_FUTURE = new Date(PINNED_NOW.getTime() + 5 * 60 * 1000);
-
- it('skips group whose newest checkpoint mtime is < 60 min old (active in another harness)', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const fname = checkpointName(PAST_DATE, 'activeTok', 1);
- const fpath = join(monthDir, fname);
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, THIRTY_MIN_AGO);
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('counts group whose newest checkpoint mtime is exactly 60 min old (boundary)', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const fname = checkpointName(PAST_DATE, 'boundaryTok', 1);
- const fpath = join(monthDir, fname);
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, SIXTY_MIN_AGO);
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('counts group whose newest checkpoint mtime is > 60 min old (truly stale)', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const fname = checkpointName(PAST_DATE, 'staleTok', 1);
- const fpath = join(monthDir, fname);
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, NINETY_MIN_AGO);
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('newest mtime wins: group with one stale + one fresh checkpoint is skipped', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const oldFname = checkpointName(PAST_DATE, 'mixTok', 1);
- const newFname = checkpointName(PAST_DATE, 'mixTok', 2);
- const oldPath = join(monthDir, oldFname);
- const newPath = join(monthDir, newFname);
- await writeFile(oldPath, checkpointFrontmatter(false), 'utf8');
- await writeFile(newPath, checkpointFrontmatter(false), 'utf8');
- await setMtime(oldPath, NINETY_MIN_AGO);
- await setMtime(newPath, THIRTY_MIN_AGO);
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('fail-safe: future mtime (clock skew, negative age) is skipped, not counted', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const fname = checkpointName(PAST_DATE, 'skewTok', 1);
- const fpath = join(monthDir, fname);
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, FIVE_MIN_FUTURE);
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('cross-month group: token spanning both months is evaluated against globally-newest mtime', async () => {
- // Same token has files in both months: prev-month file is stale,
- // current-month file is fresh. Newest wins → group is active → skip.
- const thisMonth = await makeThisMonthDir(logsDir);
- const prevMonth = await makeMonthDir(logsDir, PREV_YEAR, PREV_MONTH);
- const thisPath = join(thisMonth, checkpointName(PAST_DATE, 'crossTok', 2));
- const prevPath = join(prevMonth, checkpointName(PREV_DATE, 'crossTok', 1));
- await writeFile(thisPath, checkpointFrontmatter(false), 'utf8');
- await writeFile(prevPath, checkpointFrontmatter(false, PREV_DATE), 'utf8');
- await setMtime(thisPath, THIRTY_MIN_AGO);
- await setMtime(prevPath, NINETY_MIN_AGO);
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('mixed groups: one stale (counted) + one active (skipped) → orphan_count: 1', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const stalePath = join(monthDir, checkpointName(PAST_DATE, 'staleMixTok', 1));
- const activePath = join(monthDir, checkpointName(PAST_DATE, 'activeMixTok', 1));
- await writeFile(stalePath, checkpointFrontmatter(false), 'utf8');
- await writeFile(activePath, checkpointFrontmatter(false), 'utf8');
- await setMtime(stalePath, NINETY_MIN_AGO);
- await setMtime(activePath, THIRTY_MIN_AGO);
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- // Regression guard: proves the suite is wall-clock-independent.
- // Same fixture file, three different injected `now` values, three
- // different outcomes — confirms the today-skip is driven by the
- // injected clock, not by `new Date()` leaking in.
- //
- // Uses anchor dates inside 2099 (sharing PINNED_NOW's epoch) so the fixture
- // and the three injected `now` values all sit inside the same month/prev-month
- // window the scanner walks. After PINNED_NOW was bumped to 2099 to keep
- // wall-clock mtimes safely in the past, this test was rewritten to use
- // 2099 anchor dates rather than the original 2026 ones — the original
- // dates fell outside the 2099-anchored month dirs and the scanner found
- // no fixture to evaluate.
- it('today-skip is driven by injected `now`, not by wall-clock', async () => {
- // Pre-pin the fixture's mtime so the new Active-Session Guard never
- // fires on this regression test (the guard depends on mtime, not on
- // the injected `now` — the test is about today-skip semantics, not
- // about the active-session guard).
- const fixtureDate = '2099-02-10';
- const fixtureMonthDir = await makeMonthDir(logsDir, '2099', '02');
- const fname = checkpointName(fixtureDate, 'reg-token', 1);
- const fpath = join(fixtureMonthDir, fname);
- await writeFile(fpath, checkpointFrontmatter(false, fixtureDate), 'utf8');
- // Pin mtime well past the largest configurable threshold so every
- // `now` value below sees the fixture as "stale enough to count".
- await setMtime(fpath, NINETY_MIN_AGO);
-
- // now = Feb 10 → fixture date == today → skipped (today-skip path)
- const sameDay = await runOrphanScan(
- logsDir,
- 'current99',
- new Date('2099-02-10T12:00:00Z'),
- tmpDir,
- );
- expect(sameDay).toEqual({ orphan_count: 0 });
-
- // now = Feb 15 → fixture is a past date in the same month → counted
- const laterSameMonth = await runOrphanScan(
- logsDir,
- 'current99',
- new Date('2099-02-15T12:00:00Z'),
- tmpDir,
- );
- expect(laterSameMonth).toEqual({ orphan_count: 1 });
-
- // now = Mar 5 → fixture is in previous month → still counted
- const nextMonth = await runOrphanScan(
- logsDir,
- 'current99',
- new Date('2099-03-05T12:00:00Z'),
- tmpDir,
- );
- expect(nextMonth).toEqual({ orphan_count: 1 });
- });
-
- // -------------------------------------------------------------------------
- // Configurable Active-Session Guard threshold (vault.yml-driven)
- //
- // Policy (orphan-scan.ts: getActiveSessionGuardMs): the threshold is
- // `max(60, 2 * checkpoint.minutes)` minutes, derived from vault.yml in
- // the supplied vaultRoot. Failure modes (missing/malformed vault.yml,
- // non-positive checkpoint.minutes) silently fall back to 60 minutes.
- // -------------------------------------------------------------------------
-
- // Minimal valid vault.yml: only the keys the parser actually requires
- // beyond defaults. `loadVaultConfig` synthesises folders + update_channel
- // when absent, so we pin only the field under test.
- async function writeVaultYml(vaultRoot: string, body: string): Promise {
- await writeFile(join(vaultRoot, 'vault.yml'), body, 'utf8');
- }
-
- it('uses default 60-min threshold when vault.yml is absent (existing behavior)', async () => {
- // Without vault.yml, getActiveSessionGuardMs falls back to 60 min.
- // 50-min-old checkpoint is younger than the threshold → skipped.
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'fallbackTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, new Date(PINNED_NOW.getTime() - 50 * 60 * 1000));
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('checkpoint.minutes=60 raises threshold to 120 — 90-min-old group still skipped', async () => {
- // checkpoint.minutes=60 → max(60, 2*60)=120 min threshold.
- // A 90-min-old group is younger than 120 → still active → skipped.
- // Under the old 60-min hard-coded threshold this would have been
- // counted as an orphan.
- await writeVaultYml(tmpDir, 'checkpoint:\n messages: 15\n minutes: 60\n');
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'cp60ActiveTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, NINETY_MIN_AGO);
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('checkpoint.minutes=60 with 130-min-old group → counted (past raised threshold)', async () => {
- // Above-threshold age still counts even with a raised guard, so the
- // policy doesn't silently swallow truly stale groups.
- await writeVaultYml(tmpDir, 'checkpoint:\n messages: 15\n minutes: 60\n');
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'cp60StaleTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, new Date(PINNED_NOW.getTime() - 130 * 60 * 1000));
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('checkpoint.minutes=15 keeps threshold at 60-min floor (max wins, not 30)', async () => {
- // Policy: max(60, 2*15)=60. A user who lowered checkpoint.minutes
- // below 30 doesn't accidentally tighten the guard below the PR #156
- // baseline. 50-min-old → still active.
- await writeVaultYml(tmpDir, 'checkpoint:\n messages: 15\n minutes: 15\n');
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'cp15Tok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, new Date(PINNED_NOW.getTime() - 50 * 60 * 1000));
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('malformed vault.yml falls back to 60-min default (no startup blocking)', async () => {
- // YAML that loadVaultConfig will throw on (top-level array, not a
- // mapping). Fail-safe falls back to 60-min default → 50-min-old
- // group is still active and skipped. The stderr warning side-effect
- // is asserted separately below; suppress it here so test output
- // stays clean.
- await writeVaultYml(tmpDir, '- not\n- a\n- mapping\n');
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'malformedTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, new Date(PINNED_NOW.getTime() - 50 * 60 * 1000));
- const { result } = await captureStderr(() =>
- runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir),
- );
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('vault.yml without checkpoint key uses parser default (30 min) → threshold 60', async () => {
- // loadVaultConfig defaults checkpoint.minutes to 30 when the
- // `checkpoint` key is absent. Threshold = max(60, 2*30) = 60.
- // 50-min-old group is younger → skipped.
- await writeVaultYml(tmpDir, 'update_channel: stable\n');
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'noCpKeyTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, new Date(PINNED_NOW.getTime() - 50 * 60 * 1000));
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- it('vault.yml with non-positive checkpoint.minutes falls back to 60-min default', async () => {
- // checkpoint.minutes <= 0 is malformed config — getActiveSessionGuardMs
- // skips it rather than producing a zero-or-negative threshold (which
- // would count every checkpoint as orphan, including in-flight ones).
- await writeVaultYml(tmpDir, 'checkpoint:\n messages: 15\n minutes: 0\n');
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'cpZeroTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, new Date(PINNED_NOW.getTime() - 50 * 60 * 1000));
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 0 });
- });
-
- // -------------------------------------------------------------------------
- // Telemetry + input validation (review round 1 follow-ups)
- //
- // Silent fallbacks hide real bugs. These tests pin the contract:
- // - vault.yml NOT FOUND → silent fallback (expected absence; some banner
- // consumers run from non-vault dirs).
- // - vault.yml UNREADABLE for any other reason → stderr warning + fallback
- // (the user must be able to discover that their config is being ignored).
- // - vaultRoot empty string → throw (programming bug; never silently
- // consume a stranger vault.yml resolved against process.cwd()).
- // -------------------------------------------------------------------------
-
- /**
- * Run an async callback while capturing process.stderr writes. Returns
- * the captured string. Restores the original write hook even on throw.
- */
- async function captureStderr(fn: () => Promise): Promise<{ stderr: string; result: T }> {
- const original = process.stderr.write.bind(process.stderr);
- let captured = '';
- // process.stderr.write has multiple overloads; cast through unknown to
- // sidestep them — the test only ever calls it with a string.
- process.stderr.write = ((chunk: string) => {
- captured += chunk;
- return true;
- }) as unknown as typeof process.stderr.write;
- try {
- const result = await fn();
- return { stderr: captured, result };
- } finally {
- process.stderr.write = original;
- }
- }
-
- it('missing vault.yml is silent (expected absence — no stderr noise)', async () => {
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'silentTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, NINETY_MIN_AGO);
- const { stderr } = await captureStderr(() =>
- runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir),
- );
- expect(stderr).toBe('');
- });
-
- it('malformed vault.yml writes a one-line warning to stderr and falls back', async () => {
- await writeVaultYml(tmpDir, '- not\n- a\n- mapping\n');
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'malformedWarnTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, NINETY_MIN_AGO);
- const { stderr, result } = await captureStderr(() =>
- runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir),
- );
- expect(stderr).toContain('onebrain orphan-scan: vault.yml unreadable');
- expect(stderr).toContain('60-min Active-Session Guard default');
- expect(stderr.endsWith('\n')).toBe(true);
- // Fallback still applied — orphan still counted (NINETY_MIN_AGO > 60-min default).
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('prefix-not-substring: parser errors that merely contain "vault.yml not found" still emit the warning', async () => {
- // Pin the contract that the round-1 P0 fix relies on: a parse error
- // whose message *contains* the substring "vault.yml not found" but
- // does NOT start with the canonical prefix `vault.yml not found at `
- // must NOT be silently classified as an expected absence.
- //
- // To force this, we write yaml that parses to a top-level array (the
- // parser path that throws `vault.yml must be a YAML mapping. Got: array`)
- // but include the literal substring `vault.yml not found` in the
- // body. The yaml parser succeeds; the mapping check fails; the
- // resulting Error.message starts with `vault.yml must be a YAML
- // mapping`, not the prefix — so the classifier must reject and the
- // stderr warning must fire.
- await writeVaultYml(tmpDir, '- "vault.yml not found"\n- "in this array"\n');
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'subTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, NINETY_MIN_AGO);
- const { stderr, result } = await captureStderr(() =>
- runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir),
- );
- // The error message contains the substring but doesn't START with
- // the prefix → classifier returns false → warning fires.
- expect(stderr).toContain('onebrain orphan-scan: vault.yml unreadable');
- expect(stderr).toContain('YAML mapping');
- // Fallback still applied — orphan still counted.
- expect(result).toEqual({ orphan_count: 1 });
- });
-
- it('stderr write that throws does not crash the JSON contract (EPIPE fallback)', async () => {
- // Simulate stderr being closed (EPIPE-class condition). The warning
- // is best-effort; under no circumstance should it bubble up and
- // replace the stdout JSON contract with a thrown stack trace. The
- // banner consumer would then fall back to its own default and the
- // user would lose both the warning AND the orphan count.
- await writeVaultYml(tmpDir, '- not\n- a\n- mapping\n');
- const monthDir = await makeThisMonthDir(logsDir);
- const fpath = join(monthDir, checkpointName(PAST_DATE, 'epipeTok', 1));
- await writeFile(fpath, checkpointFrontmatter(false), 'utf8');
- await setMtime(fpath, NINETY_MIN_AGO);
- const original = process.stderr.write.bind(process.stderr);
- process.stderr.write = (() => {
- throw new Error('EPIPE: broken pipe');
- }) as unknown as typeof process.stderr.write;
- try {
- // Must not throw; result must still be correct (orphan counted).
- const result = await runOrphanScan(logsDir, 'current99', PINNED_NOW, tmpDir);
- expect(result).toEqual({ orphan_count: 1 });
- } finally {
- process.stderr.write = original;
- }
- });
-
- it('throws when vaultRoot is empty (programming bug, fail loud)', async () => {
- // Passing empty string would resolve `vault.yml` against process.cwd()
- // and could silently consume an unrelated vault.yml. The CLI wrapper
- // always passes process.cwd() (non-empty), so this only protects
- // future programmatic callers.
- await expect(runOrphanScan(logsDir, 'current99', PINNED_NOW, '')).rejects.toThrow(
- 'vaultRoot is required',
- );
- });
-});
diff --git a/src/commands/internal/orphan-scan.ts b/src/commands/internal/orphan-scan.ts
deleted file mode 100644
index 377cde40..00000000
--- a/src/commands/internal/orphan-scan.ts
+++ /dev/null
@@ -1,424 +0,0 @@
-/**
- * orphan-scan — internal command
- *
- * Scans `[logs_folder]/checkpoint/` (flat) for unmerged checkpoint files
- * (orphans). An orphan is a checkpoint whose session was never wrapped up
- * via /wrapup.
- *
- * Structure (post-v2.4.0): checkpoints live at `[logs]/checkpoint/` flat,
- * session logs at `[logs]/session/YYYY/MM/`. Filenames retain their date
- * prefix (`YYYY-MM-DD-{token}-checkpoint-NN.md`).
- *
- * No date-range filter: `checkpoint/` is supposed to be ephemeral
- * (cleaned by /wrapup after each session). Stale checkpoints surfacing
- * here are bugs to expose, not hide — the Active-Session Guard catches
- * legitimately active cross-harness sessions, and /doctor's "old
- * checkpoint" warning catches anything else.
- *
- * Active-Session Guard: groups whose newest checkpoint is younger than the
- * vault.yml-derived threshold are NOT counted as orphans — they belong to
- * a still-active session in another harness (Claude + Gemini in the same
- * vault see each other's tokens as "non-current"). Symmetric with the
- * guard in /wrapup Step 1b (PR #156) so the startup banner doesn't
- * false-positive when /wrapup correctly skips the same files.
- *
- * Threshold policy: `max(60, 2 * checkpoint.minutes)` minutes. Default
- * checkpoint.minutes is 30 → 60-min threshold (unchanged from PR #156).
- * Users who raise checkpoint.minutes (e.g. 60 or 90) get a proportionally
- * larger guard so legitimate live sessions don't get false-positived.
- *
- * Output: JSON { orphan_count: N }
- * Exit code always 0.
- */
-
-import { readFile, readdir, stat } from 'node:fs/promises';
-import { join } from 'node:path';
-import { parse } from 'yaml';
-import { VAULT_YML_NOT_FOUND_PREFIX, loadVaultConfig } from '../../lib/parser.js';
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-export type OrphanScanResult = {
- orphan_count: number;
-};
-
-// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-/**
- * Minimum acceptable Active-Session Guard threshold, in minutes. Used as
- * the floor in the `max(MIN_GUARD_MINUTES, 2 * checkpoint.minutes)` policy
- * so a user who lowered `checkpoint.minutes` below 30 doesn't accidentally
- * tighten the guard below the PR #156 baseline. Distinct semantics from
- * `DEFAULT_ACTIVE_SESSION_GUARD_MS` — the floor coincides with the default
- * fallback today by calibration, not by invariant. Keep them separate so
- * a future change to `DEFAULT_CHECKPOINT.minutes` doesn't accidentally
- * shift the user-visible floor.
- */
-const MIN_GUARD_MINUTES = 60;
-
-/**
- * Default Active-Session Guard threshold in milliseconds, used when
- * vault.yml is missing, malformed, or has no usable `checkpoint.minutes`.
- * Mirrors the original 60-min hard-coded window in /wrapup SKILL.md
- * Step 1b — two full default 30-min checkpoint windows. The banner is
- * best-effort information; a config issue must not block startup, so any
- * vault.yml read failure falls back here rather than throwing.
- */
-const DEFAULT_ACTIVE_SESSION_GUARD_MS = 60 * 60 * 1000;
-
-// ---------------------------------------------------------------------------
-// Frontmatter helpers
-// ---------------------------------------------------------------------------
-
-/**
- * Extract YAML frontmatter from markdown text.
- * Returns parsed object or null if no valid frontmatter.
- */
-function parseFrontmatter(rawText: string): Record | null {
- const text = rawText.replace(/\r\n/g, '\n');
- if (!text.startsWith('---')) return null;
- const endIdx = text.indexOf('\n---', 3);
- if (endIdx === -1) return null;
- const fm = text.slice(3, endIdx).trim();
- try {
- const parsed = parse(fm);
- return parsed && typeof parsed === 'object' ? (parsed as Record) : null;
- } catch {
- return null;
- }
-}
-
-// ---------------------------------------------------------------------------
-// File listing helper
-// ---------------------------------------------------------------------------
-
-async function listMdFiles(dir: string): Promise {
- try {
- const entries = await readdir(dir);
- return entries.filter((e) => e.endsWith('.md'));
- } catch {
- return [];
- }
-}
-
-// ---------------------------------------------------------------------------
-// Active-Session Guard helpers
-// ---------------------------------------------------------------------------
-
-/**
- * Resolve the Active-Session Guard threshold in milliseconds from
- * `vault.yml`'s `checkpoint.minutes`. Policy: `max(MIN_GUARD_MINUTES,
- * 2 * checkpoint.minutes)` minutes — gives every harness two full
- * checkpoint windows of "live session" grace, regardless of how
- * `checkpoint.minutes` was customized. The floor preserves the PR #156
- * baseline so users who lowered `checkpoint.minutes` below 30 don't
- * accidentally tighten the guard.
- *
- * Fail-safe behavior, with telemetry:
- * - **Expected absence** (vault.yml not found, ENOENT): silently fall back
- * to the default. Some banner consumers run from non-vault directories.
- * - **Real malformation** (parse error, non-mapping root, EACCES, etc.):
- * write a one-line warning to stderr so the user can discover that
- * their config is being ignored, then fall back. The startup banner
- * parses stdout JSON only, so stderr can carry diagnostic noise without
- * corrupting the JSON contract.
- * - **Non-finite/non-positive `checkpoint.minutes`**: silently fall back.
- * The yaml lib already coerced the value; bad user input here surfaces
- * via /doctor's checkpoint validator (src/lib/validator.ts), not here.
- *
- * Either way, the function returns a positive number — the banner must
- * not block on a config issue.
- */
-async function getActiveSessionGuardMs(vaultRoot: string): Promise {
- try {
- const config = await loadVaultConfig(vaultRoot);
- const cpMinutes = config.checkpoint.minutes;
- if (typeof cpMinutes !== 'number' || !Number.isFinite(cpMinutes) || cpMinutes <= 0) {
- return DEFAULT_ACTIVE_SESSION_GUARD_MS;
- }
- const minutes = Math.max(MIN_GUARD_MINUTES, 2 * cpMinutes);
- return minutes * 60 * 1000;
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- // ENOENT-style "vault.yml not found at ." messages are produced
- // by loadVaultConfig itself; match the prefix using the shared
- // exported constant so changing the message in parser.ts propagates
- // here automatically (no two-file string drift). A malformed vault.yml
- // whose error message merely *contains* the substring would not slip
- // through silently — `startsWith` requires the full prefix.
- const isExpectedAbsence = msg.startsWith(VAULT_YML_NOT_FOUND_PREFIX);
- if (!isExpectedAbsence) {
- try {
- process.stderr.write(
- `onebrain orphan-scan: vault.yml unreadable, using ${MIN_GUARD_MINUTES}-min Active-Session Guard default (${msg})\n`,
- );
- } catch {
- // stderr is closed/full (EPIPE/ENOSPC) — best-effort warning.
- // Continue with the default rather than crashing the JSON
- // contract on stdout. The user loses the warning in this rare
- // edge case but the banner still surfaces orphan_count correctly.
- }
- }
- return DEFAULT_ACTIVE_SESSION_GUARD_MS;
- }
-}
-
-/**
- * Get a single file's mtime in epoch milliseconds, or null on any error
- * (vanished, EACCES, NFS hiccup, unparseable mtime). Caller treats null
- * as "ambiguous" — fail-safe: skip rather than count.
- */
-async function getMtimeMs(path: string): Promise {
- try {
- const s = await stat(path);
- if (typeof s.mtimeMs !== 'number' || !Number.isFinite(s.mtimeMs)) return null;
- return s.mtimeMs;
- } catch {
- return null;
- }
-}
-
-/**
- * Get the newest mtime across a list of files. Returns null if the list is
- * empty OR any single stat failed (fail-safe propagation — one ambiguous
- * file forces the whole group to be treated as ambiguous, never partially
- * counted).
- */
-async function getNewestMtimeMs(filePaths: string[]): Promise {
- if (filePaths.length === 0) return null;
- let newest = Number.NEGATIVE_INFINITY;
- for (const p of filePaths) {
- const m = await getMtimeMs(p);
- if (m === null) return null;
- if (m > newest) newest = m;
- }
- return Number.isFinite(newest) ? newest : null;
-}
-
-/**
- * Decide whether a group of checkpoint files belongs to a still-active
- * session in another harness (or is otherwise ambiguous and unsafe to
- * count). Returns true → caller MUST NOT count this group as an orphan.
- *
- * Fail-safe: any stat failure, negative age (future mtime / clock skew),
- * or empty group forces a skip. The destructive default (count as orphan
- * under uncertainty) is forbidden — the symmetric /wrapup recovery uses
- * the same rule, so a banner that surfaces an "orphan" the wrapup will
- * refuse to recover would be a confusing UX loop.
- */
-async function isGroupActiveOrAmbiguous(
- filePaths: string[],
- nowMs: number,
- guardMs: number,
-): Promise {
- // Belt-and-suspenders: only `runOrphanScan` calls this, and it derives
- // `guardMs` from `getActiveSessionGuardMs` which floors at
- // `MIN_GUARD_MINUTES * 60 * 1000` (positive). A future refactor that
- // lets a different caller bypass that helper could pass a non-positive
- // `guardMs` and silently flip every group to "counted as orphan" —
- // which under /wrapup symmetry would destructively act on live
- // sessions. Treat invalid input the same as ambiguous (skip).
- if (!Number.isFinite(guardMs) || guardMs <= 0) return true;
- const newest = await getNewestMtimeMs(filePaths);
- if (newest === null) return true;
- const ageMs = nowMs - newest;
- if (ageMs < 0) return true;
- return ageMs < guardMs;
-}
-
-// ---------------------------------------------------------------------------
-// Core scan logic
-// ---------------------------------------------------------------------------
-
-/**
- * Check whether a given date has a manually-run session log (non-auto-saved).
- * Returns true if such a log exists.
- */
-async function hasManualSessionLog(monthDir: string, date: string): Promise {
- const files = await listMdFiles(monthDir);
- // Whitelist `-session-` infix (not blacklist `-checkpoint-`). The logs
- // folder also contains `*-update-vX.Y.Z.md` migration logs from `/update`
- // and `*-weekly.md` files from `/weekly`. With the previous blacklist
- // filter, those would fall through and silently suppress the orphan
- // count for any date that happens to have one of them alongside a real
- // orphan checkpoint. The whitelist guarantees we only consider files
- // that actually look like session logs.
- const sessionLogs = files.filter(
- (f) => f.startsWith(date) && f.includes('-session-') && f.endsWith('.md'),
- );
-
- for (const logName of sessionLogs) {
- try {
- const content = await readFile(join(monthDir, logName), 'utf8');
- const fm = parseFrontmatter(content);
- // auto-saved: true → written by auto-summary, NOT a wrapup log → keep scanning
- if (fm && (fm['auto-saved'] === true || fm['auto-saved'] === 'true')) continue;
- // Either no frontmatter or auto-saved is false/absent → this is a manual wrapup log
- return true;
- } catch {
- // Can't read — skip
- }
- }
- return false;
-}
-
-/**
- * Collect candidate orphan groups from the flat `checkpoint/` directory.
- *
- * Returns a Map of `token → absolute file paths`. A "candidate" is any
- * checkpoint file whose token != current session, whose date != today,
- * and whose date has no manual session log in the matching session
- * folder (`[logs]/session/YYYY/MM/`).
- *
- * Active-Session mtime filtering is intentionally NOT applied here — the
- * guard runs once at the merged level in `runOrphanScan`, so groups are
- * evaluated against their globally-newest mtime.
- */
-async function collectCandidateGroups(
- checkpointDir: string,
- sessionDir: string,
- currentToken: string,
- today: string,
-): Promise