# v0.10.6 — Logging discipline, manifest-backed snapshots, explorer polish, export

Status: draft
Owners: TBD
Source:
- `Projects/OpenSecondBrain/Features/_summary.md` §30, §22 + §5-tail, §14 polish, §28, §31.
- `Projects/OpenSecondBrain/Plan/3. Agent logging discipline.md` §A + §C.
- `Projects/OpenSecondBrain/Plan/4. v0.10.5 review followups.md` §1, §2, §4.

## Context

Five tracks ship under v0.10.6. They are independent in surface but
share two cross-cutting artifacts that justify a single PR rather
than five:

- **Brain manifest.** A single file-listing primitive (`{ vault-relative
  path → sha256, size }` across `Brain/` minus `.snapshots/`) underlies
  both §22 (`o2b brain upgrade` builds the current state to compare
  against the target template) and §5-tail (`o2b brain rollback` uses
  the snapshot's stored manifest to detect drift before overwriting
  the live tree). Building this twice would be copy-paste; introducing
  it once keeps the system honest.
- **Agent logging discipline (§30).** The stop guardrail currently
  fires only on missing `event_log_append`. §A extends it to accept any
  of three brain-event tools (`event_log_append`, `brain_feedback`,
  `brain_apply_evidence`) and their CLI equivalents. §C reformulates
  the SKILL trigger so the default for an uncertain match is "record
  with `note: speculative`", not "skip" — the dream pass already
  filters one-off speculative entries that do not recur.

The other three tracks (§14 explorer polish, §28 export, §31 CR
cleanup) ride along because they are small and finish work already
deferred from v0.10.4 / v0.10.5.

## Goals

- **G1.** Reformulate `skills/brain-memory/SKILL.md` so the default
  for an uncertain (preference, artifact) match is to record with
  `note: "speculative; ..."` rather than to skip. Mirror the change
  in the post-write hook reminder text so the agent receives the same
  guidance live.
- **G2.** Extend `hooks/stop-log-guardrail.ts` so any of
  `event_log_append`, `brain_feedback`, `brain_apply_evidence` (as MCP
  tool call or `o2b brain feedback|apply-evidence` bash invocation)
  clears the guardrail. The block text lists all three explicitly.
- **G3.** Introduce `src/core/brain/manifest.ts` with
  `buildManifest(brainRoot)`, `diffManifests(a, b)`, and sidecar I/O
  helpers. SHA-256 per regular file under `Brain/` excluding
  `.snapshots/`, symlinks skipped.
- **G4.** Make `createSnapshot` write a sidecar
  `<run-id>.manifest.json` next to each archive. `listSnapshots`
  surfaces `manifest_path`. `pruneSnapshots` removes both files in
  pair. Failure to write the sidecar leaves the archive valid and
  surfaces a non-fatal warning.
- **G5.** Add `o2b brain upgrade [--dry-run] [--apply] [--yes]
  [--check] [--json] [--vault <path>]`. The verb manages three vault
  files (`_brain.yaml`, `_BRAIN.md`, `AI Wiki/_OPEN_SECOND_BRAIN.md`)
  and uses the existing snapshot infrastructure to take a pre-apply
  snapshot named `upgrade-<ts>`. `--dry-run` prints a per-file diff;
  `--check` exits 2 on pending updates (CI-friendly).
- **G6.** Extend `o2b brain rollback` with drift detection. Before
  extraction, compare the snapshot's stored manifest against a
  freshly-built manifest of the live `Brain/`. If they differ, exit
  2 with a diff unless `--force-rollback` is passed. Legacy snapshots
  without a sidecar manifest skip drift detection with a stderr
  warning.
- **G7.** Update `templates/brain-explorer.html` with a focusable
  `<ul role="listbox">` over the visible nodes, ArrowUp / ArrowDown
  / Enter / Escape handlers, and `aria-activedescendant` tracking.
  Listbox is the accessible source of truth; the canvas remains a
  visual surface.
- **G8.** Persist explorer layout in `localStorage` under
  `osb-explorer-layout:<vault_basename>`. Stored: per-node `[x, y]`,
  status filter set, search string. Boot restores positions before
  physics starts; new nodes fall back to `placeInitial`. A "Reset
  layout" button clears the key.
- **G9.** Add `o2b brain export --format json|llms-txt [--out <path>]
  [--force] [--vault <path>]`. Read-only dump of
  `Brain/preferences/` (`confirmed | unconfirmed | quarantine`); no
  retired, no signals. Stdout by default; `--out` writes a file.
- **G10.** Update `_BRAIN.md` template to mention `o2b brain upgrade`
  and `o2b brain export`. This produces a real, testable
  diff for existing vaults so the `upgrade` flow has a non-trivial
  fixture.
- **G11.** CR cleanup: MD040 language tags in
  `docs/plans/2026-05-18-brain-maturity-design.md`,
  `docs/plans/2026-05-18-brain-maturity-impl.md`,
  `skills/embeddings-setup/SKILL.md`. Add one extra
  `tests/scripts/macos-sqlite-shim.test.ts` case covering
  `DYLD_LIBRARY_PATH=""` preservation.

## Non-goals (explicitly deferred)

Each entry below is recorded in `_summary.md → ## Deferred work` or
in the respective Plan/ doc in the same commit so it survives across
planning sessions.

- **§30 §B — Promote `brain_feedback` / `brain_apply_evidence` from
  deferred MCP to always-loaded.** Belongs to the MCP server surface
  (`plugin.yaml` / `mcp/server.ts`) and is orthogonal to hook /
  SKILL changes. Stays in `Plan/3 §B` with the same trigger.
- **§30 §D — Session-end sanity check via cron.** Hermes-side
  job (or Claude Code SessionEnd hook) that compares artifact count
  vs brain-event count and pings on drift. Out of v0.10.6 scope —
  belongs to the runtime layer. Stays in `Plan/3 §D`.
- **§30 §E — Claude Code MEMORY ↔ OSB Brain bridge.** Either
  `o2b brain import-claude-memory` or a SessionStart hook that
  injects `~/.claude/.../memory/MEMORY.md` alongside `Brain/active.md`.
  Distinct scope and surface. Stays in `Plan/3 §E`.
- **§22 — Interactive upgrade wizard.** Not in v0.10.6. The verb is
  non-interactive by default; `--apply` requires `--yes` in
  non-interactive mode mirroring `migrate-frontmatter` / `rollback`.
- **§28 — Retired and signal export, GraphML / Marp / JSON-LD
  formats.** `_summary.md` §28 explicitly marks the latter three as
  "not needed". Retired / signal export is on demand: add when a
  user case appears.
- **§14 — Obsidian deep-link, live-refresh, Freeze-layout toggle.**
  Already deferred in v0.10.5; remain deferred. Triggers in
  `_summary.md → Deferred work`.

## §30 — Agent logging discipline

### §A — Stop guardrail accepts any brain-event

`hooks/lib/detect.ts`:

- Rename `LOG_NAME_SUFFIX` to `BRAIN_EVENT_NAME_SUFFIX` and broaden:

  ```ts
  const BRAIN_EVENT_NAME_SUFFIX =
    /(?:^|__)(event_log_append|brain_feedback|brain_apply_evidence)$/;
  ```

- `isLogToolName` is renamed to `isBrainEventToolName`. Hooks /
  callers update accordingly. No external consumer (the file is
  internal to `hooks/`).
- `LOG_BASH_NEEDLES` becomes `BRAIN_EVENT_BASH_NEEDLES` and gains
  two entries:

  ```ts
  const BRAIN_EVENT_BASH_NEEDLES = [
    "o2b append-event",
    "vault-log ",
    "o2b brain feedback",
    "o2b brain apply-evidence",
  ];
  ```

  The `vault-log` trailing space rule stays — distinguishes the CLI
  invocation from a literal log path substring.
- `summarizeTurn`'s `hadLog` field stays in shape; it now means "any
  brain-event landed". Rename to `hadBrainEvent` to keep call sites
  honest. Single consumer (`stop-log-guardrail.ts`) updates with it.

`hooks/lib/messages.ts:stopGuardrailReason`:

- Body changes from "did not call `event_log_append`" to an explicit
  three-tool list. Sketch:

  ```text
  Open Second Brain hook: this turn touched files
  (Write / Edit / MultiEdit / apply_patch) but did not call any of:

  - `event_log_append` — durable session-summary line for the day log
  - `brain_apply_evidence` — evidence trail when an active preference
    scoped to the artifact
  - `brain_feedback` — new taste correction the user just expressed

  If the turn produced a durable artifact, pick whichever fits and
  finish. If the change is trivial, just send your reply again — this
  guardrail fires at most once per turn and will let the second Stop
  through.
  ```

- Per-runtime cadence lines (`claudecode`, `codex`, `unknown`) stay
  exactly as today; they are interpolated between the heading and
  the tool list.

### §C — Reformulate SKILL trigger

`skills/brain-memory/SKILL.md`:

- Frontmatter `description` tail switches from "SKIP when not
  confident; A misrecorded signal is worse than a missed one" to
  the speculative-mark policy:

  > SKIP for casual chat, exploration without a stated rule,
  > read-only inspection, and trivial edits. When a preference
  > plausibly applies but you are unsure, RECORD with `note:
  > "speculative; <reason>"` rather than skipping — the dream pass
  > discards single-event speculative entries that do not recur, so
  > coverage costs less than missing the signal.

- `## When NOT to call` section: the fifth bullet ("Cases where a
  preference *might* apply but you are not confident — skip rather
  than fabricate a match") is replaced by an inverse rule:

  > - Cases where a preference *might* apply but you are unsure:
  >   record with `note: "speculative; <reason>"` and let the dream
  >   pass filter one-off entries. Skip only the four bullets above.

- Closing sentence "A false-positive signal eventually distorts the
  rule set; a missed signal is recovered on repeat. Prefer
  precision." is removed.
- New paragraph in `## When to call brain_apply_evidence → Optional`
  documents the `note: "speculative; ..."` convention as the
  recommended escape hatch.

`hooks/lib/messages.ts:postWriteReminder`:

- Final paragraph rewords from "A misrecorded signal is worse than
  a missed one — skip when not confident; the dream pass will pick
  up patterns from repeats" to:

  > When a preference plausibly applies but you are unsure, record
  > with `note: "speculative; <reason>"`. The dream pass discards
  > single-event speculative entries that do not recur.

### Tests

- `tests/hooks/detect.test.ts` (new or extended): every name and
  bash needle case — positive and negative — for the broadened
  `BRAIN_EVENT_NAME_SUFFIX` and `BRAIN_EVENT_BASH_NEEDLES`.
- `tests/hooks/stop-log-guardrail.test.ts` (extend if exists,
  otherwise new): full transcript-driven cases for Claude Code and
  Codex. Each test asserts whether the guardrail emits `{decision:
  "block"}` or stays silent.
- `tests/hooks/messages.test.ts`: snapshot of the three reminder /
  guardrail texts (Claude Code, Codex, unknown) including the
  three-tool list.
- `tests/skills/brain-memory.test.ts` (light textual): the
  `description` frontmatter and `## When NOT to call` body match
  the new wording verbatim. Guards against accidental regression.

## §22 + §5-tail — Manifest, upgrade, drift detection

### Manifest module

New file `src/core/brain/manifest.ts`:

```ts
export const BRAIN_MANIFEST_SCHEMA_VERSION = 1 as const;

export interface BrainManifestEntry {
  readonly sha256: string;   // lowercase hex
  readonly size: number;     // bytes
}

export interface BrainManifest {
  readonly schema_version: 1;
  readonly generated_at: string;          // ISO-8601 UTC, second precision
  readonly brain_root: "Brain";           // forward-compat anchor
  readonly files: Readonly<Record<string, BrainManifestEntry>>;
}

export interface BrainManifestDiffEntry {
  readonly path: string;
  readonly before: BrainManifestEntry | null;
  readonly after: BrainManifestEntry | null;
}

export interface BrainManifestDiff {
  readonly added: ReadonlyArray<BrainManifestDiffEntry>;
  readonly removed: ReadonlyArray<BrainManifestDiffEntry>;
  readonly changed: ReadonlyArray<BrainManifestDiffEntry>;
}

export function buildManifest(brainRoot: string): BrainManifest;
export function diffManifests(a: BrainManifest, b: BrainManifest): BrainManifestDiff;

export function manifestSidecarPath(vault: string, runId: string): string;
export function readManifestSidecar(vault: string, runId: string): BrainManifest | null;
export function writeManifestSidecar(vault: string, runId: string, m: BrainManifest): void;
```

Walk semantics mirror `src/core/brain/snapshot-diff.ts:walkBrain`:

- `lstatSync` (never follow symlinks).
- Skip `.snapshots/` entirely.
- Drop `..` escapes (defense-in-depth).
- Keys sorted lexicographically before serialisation so the JSON file
  is byte-stable across filesystems.

SHA-256 via `node:crypto.createHash("sha256")` over `readFileSync`.
The whole walk holds at most one file in memory at a time; Brain
trees stay well under 10 MB in practice.

Sidecar I/O uses the existing `atomicWriteFileSync`. JSON is
pretty-printed with 2-space indent so a manual `git diff` (or
operator `cat`) is readable.

### Snapshot module changes

`src/core/brain/snapshot.ts`:

- `createSnapshot(vault, runId)`:
  1. Build archive (existing path).
  2. After the archive lands, build a manifest of the **live** tree
     (post-snapshot but pre-mutation; for `dream` and `upgrade` the
     snapshot is taken before mutation, so the live tree at this
     moment equals the snapshot contents).
  3. Write sidecar `<run-id>.manifest.json`.
  4. On manifest write failure, emit a non-fatal stderr warning;
     the archive remains valid. Downstream rollback skips
     drift-check for that snapshot (the legacy path).
- `SnapshotInfo` gains `manifest_path: string | null`.
- `pruneSnapshots(vault, retentionCount)`: when removing a victim
  archive, also `rmSync` the sibling manifest if present.
- `listSnapshots(vault)`: populates `manifest_path` by
  `existsSync(manifestSidecarPath(vault, runId))`.

### `o2b brain upgrade`

New module `src/core/brain/upgrade.ts`:

```ts
export interface UpgradeFilePlan {
  readonly path: string;          // vault-relative
  readonly status: "noop" | "update";
  readonly before: string;        // current bytes (utf8)
  readonly after: string;         // target bytes (utf8)
}

export interface UpgradePlan {
  readonly files: ReadonlyArray<UpgradeFilePlan>;
  readonly pending: number;       // count where status === "update"
}

export interface UpgradeApplyResult {
  readonly run_id: string;        // upgrade-<ts>
  readonly snapshot_path: string;
  readonly files_updated: ReadonlyArray<string>;
}

export function planUpgrade(vault: string): UpgradePlan;
export function applyUpgrade(
  vault: string,
  opts: { agent: string; now?: Date },
): UpgradeApplyResult;
```

#### Managed files

| Path                              | Source of truth                                   | Merge strategy                                   |
|-----------------------------------|---------------------------------------------------|--------------------------------------------------|
| `Brain/_brain.yaml`               | `DEFAULT_BRAIN_CONFIG_YAML` + user overrides      | Schema-key merge: add new top-level / nested keys present in default but absent in user file. Preserve user values for keys present in both. Comments survive untouched. |
| `Brain/_BRAIN.md`                 | `BRAIN_MANUAL_TEMPLATE_PATH` rendered with current substitutions | Full overwrite. Operator-edited copies are flagged in the diff but still overwritten on `--apply`. |
| `AI Wiki/_OPEN_SECOND_BRAIN.md`   | `LEGACY_OVERVIEW_TEMPLATE_PATH` rendered          | Full overwrite (same as `init.ts` does today).   |

`_brain.yaml` merge is the only path with non-trivial logic. The
existing parser (`loadBrainConfig`) is strict and the file carries
hand-written comments, so re-serialising from the parsed object
would destroy formatting. Approach is text-level and conservative:

1. Read the live `_brain.yaml` text.
2. Parse with the existing config loader for validity. If the file
   is malformed, `planUpgrade` returns `status: "error"` for that
   path with the parser's message; `--apply` refuses to proceed.
3. Walk `DEFAULT_BRAIN_CONFIG_YAML` block by block. For each
   top-level section (`dream:`, `retire:`, `confidence:`,
   `snapshots:`, …) and for top-level scalars (`schema_version`,
   `primary_agent`):
   - **Section absent in live file**: append the canonical block
     (with its inline comments and default values) at the end of
     the file, separated by one blank line.
   - **Section present, missing a nested key**: append the missing
     nested line at the end of *that* section's block, matching
     the existing indentation. The walker locates the section's
     end by scanning forward until it hits a non-indented line or
     EOF; the new key inserts before that boundary.
   - **All nested keys present**: no change.
4. Top-level scalars use the same rule: missing scalar appends at
   end-of-file; present scalar is left untouched.
5. If no insertions, return `status: noop`. Existing values and
   ordering are never rewritten.

Edge case: an operator manually duplicated a key (`primary_agent:`
twice). The parser already rejects this on load; `planUpgrade`
surfaces the same error and `--apply` refuses.

For `_BRAIN.md` and `AI Wiki/_OPEN_SECOND_BRAIN.md`, comparison is
byte-equality after rendering substitutions. Any byte difference
triggers `status: update`.

#### CLI

`src/cli/brain.ts:cmdBrainUpgrade`:

- Flags: `--vault`, `--dry-run` (default), `--apply`, `--yes`,
  `--json`, `--check`.
- `--dry-run` (or default): prints a per-file plan. For each
  `update`, renders a unified diff. Exit 0.
- `--check`: same as `--dry-run` but returns exit 2 when `pending >
  0`. Wires into CI for "are vaults drifted from the canonical
  templates" gates.
- `--apply`: validates `--yes` in non-interactive mode (`--json` or
  non-TTY stdin), then:
  1. `createSnapshot(vault, "upgrade-<ts>")` — sidecar manifest
     comes along automatically.
  2. `atomicWriteFileSync` each `update` file.
  3. Append a `BRAIN_LOG_EVENT_KIND.upgrade` log row.
- `--apply` interactive prompt: like `rollback`, shows the file list
  and asks for `y/N`.

JSON output for `--dry-run`:

```json
{
  "schema": 1,
  "pending": 1,
  "files": [
    { "path": "Brain/_brain.yaml", "status": "noop" },
    { "path": "Brain/_BRAIN.md", "status": "update", "before_sha256": "...", "after_sha256": "..." },
    { "path": "AI Wiki/_OPEN_SECOND_BRAIN.md", "status": "noop" }
  ]
}
```

`BRAIN_HELP` and `VERB_HELP.upgrade` updated.

#### Real template change in v0.10.6

`src/core/brain/templates/_BRAIN.md` (or wherever the template
lives — see `BRAIN_MANUAL_TEMPLATE_PATH`) adds two new CLI surface
lines:

```text
- `o2b brain upgrade` — apply schema and template updates from the
  installed open-second-brain version; --dry-run for a per-file plan.
- `o2b brain export` — read-only dump of active preferences in JSON
  or llms-txt format.
```

This guarantees `o2b brain upgrade` has a real diff on any
pre-v0.10.6 vault. Tests can reproduce it deterministically:
init an empty vault with the v0.10.5 template (captured as a
fixture), run `o2b brain upgrade --dry-run`, assert the plan
contains exactly the two-line additions on `_BRAIN.md`.

### Rollback drift detection

`src/cli/brain.ts:cmdBrainRollback`:

- New flag: `--force-rollback`. Distinct from `--yes` (which is
  about prompt confirmation) — `--force-rollback` specifically
  overrides drift detection.
- Sequence:
  1. Validate runId, locate archive.
  2. `readManifestSidecar(vault, runId)`:
     - **Sidecar exists**: build a fresh manifest from live `Brain/`,
       `diffManifests(stored, live)`. If any added / removed /
       changed entry exists and `--force-rollback` not passed →
       print the drift diff (compact, grouped by category), exit 2
       with a one-line hint about `--force-rollback`.
     - **No sidecar** (legacy snapshot): stderr warning
       "no manifest sidecar for snapshot `<run-id>`; drift detection
       skipped (snapshot predates v0.10.6)". Continue.
  3. Existing `--dry-run` path still uses `extractSnapshotToTemp` +
     `diffBrainTrees` to show what would change.
  4. Existing rollback path proceeds.

The rollback log event gains an optional `drift_overridden: "true"`
payload field when `--force-rollback` was used. Default omitted.

#### Drift diff output (markdown mode)

```text
Drift detected between snapshot 'dream-2026-05-17T04-00-00Z' and the
live Brain/ tree. Pass --force-rollback to overwrite anyway.

Added (3):
  - Brain/preferences/pref-imperative-prompts.md
  - Brain/log/2026-05-18.md
  - Brain/inbox/sig-2026-05-18-mocking.md
Changed (1):
  - Brain/_brain.yaml
Removed (0):
  (none)
```

JSON mode emits the same structure as `BrainManifestDiff` plus a
top-level `drift: true` / `false` flag.

### Tests

- `tests/core/brain/manifest.test.ts`: `buildManifest` against a
  fixture tree; symlinks skipped; `.snapshots/` excluded; key order
  stable; `diffManifests` over added/removed/changed; sidecar I/O
  roundtrip.
- `tests/core/brain/snapshot-manifest.test.ts`: `createSnapshot`
  writes both archive and sidecar; sidecar write failure leaves
  archive valid and emits warning; `pruneSnapshots` removes both;
  legacy snapshot (archive without sidecar) listed correctly.
- `tests/core/brain/upgrade.test.ts`: fixture vault with old
  `_BRAIN.md`; `planUpgrade` returns one `update`;
  `applyUpgrade` writes new bytes, takes snapshot named
  `upgrade-<ts>`, appends `upgrade` log event; idempotent re-run is
  `noop`. `_brain.yaml` merge: adding a new section to the default
  appends it at end-of-file with comments; existing values preserved.
- `tests/cli/brain-upgrade.test.ts`: `--dry-run` exit codes,
  `--check` exit 2 on pending, `--apply --yes` end-to-end,
  `--apply` without `--yes` in non-interactive errors out, `--json`
  shapes.
- `tests/cli/brain-rollback-drift.test.ts`: snapshot with manifest
  + drifted live tree → exit 2; same with `--force-rollback` →
  proceeds; legacy snapshot without manifest → warning + proceeds.

## §14 polish — keyboard a11y + layout persistence

All changes confined to `templates/brain-explorer.html`. No new
server endpoint, no schema change to `ExplorerGraph`.

### Listbox accessibility

DOM additions inside the existing right `<aside id="details">`:

```html
<h3>Nodes</h3>
<div id="node-list-wrap">
  <ul
    id="node-list"
    role="listbox"
    tabindex="0"
    aria-label="Brain preferences"
    aria-activedescendant=""
  ></ul>
</div>
<button id="reset-layout" type="button" class="id-copy">Reset layout</button>
<hr />
<div id="details-body">
  <p class="panel-empty">Select a node to inspect.</p>
</div>
```

The existing details panel becomes `#details-body` so the listbox
sits above it without colliding with the inspect markup.

CSS additions (minimal):

```css
#node-list { list-style: none; margin: 0; padding: 0; max-height: 220px; overflow: auto; border: 1px solid var(--border); border-radius: 4px; }
#node-list li { padding: 4px 8px; cursor: pointer; }
#node-list li[aria-selected="true"] { background: var(--accent); color: #fff; }
#node-list:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
```

Behaviour (in the existing IIFE):

- `renderNodeList()`: rebuilds the `<ul>` over visible nodes (the
  same predicate as `applyFilters`). Each `<li>` gets `id="node-opt-${nodeId}"`,
  `role="option"`, `aria-selected="false"`, and a stable order: by
  `topic` ascending, then `id` ascending. Called from `applyFilters`.
- Focus model: `<ul>` is in the tab order (`tabindex="0"`). When
  focused, the keyboard handler intercepts ArrowDown / ArrowUp /
  Home / End / Enter / Space / Escape.
- Selection model: a `focusedNodeId` variable tracks the currently
  highlighted `<li>`. ArrowKeys move it; `aria-activedescendant`
  follows. Enter / Space commit: sets `selectedId = focusedNodeId`,
  calls `renderPanel(nodeById.get(selectedId))`, ensures the canvas
  redraw highlights the node. Escape resets `focusedNodeId` to
  `null` and the panel back to empty.
- Click on a `<li>` selects the corresponding node (commit semantics,
  same as Enter).
- Pointer click on canvas (existing behaviour) updates both
  `selectedId` and `focusedNodeId`, scrolls the listbox so the
  matching `<li>` is visible, so the two surfaces stay in sync.

### Layout persistence

Storage key:

```text
osb-explorer-layout:<vault_basename>
```

Storage shape:

```json
{
  "schema": 1,
  "positions": { "pref-foo": [123.4, 456.7], "pref-bar": [...] },
  "filters": { "status": ["confirmed", "quarantine"], "search": "mock" }
}
```

Boot order changes:

1. Parse `localStorage[key]`. If malformed or `schema !== 1`,
   discard silently.
2. Initialise `statusFilters` and `searchTerm` from stored filters
   (fall back to current defaults).
3. `placeInitial()` produces the topic-clustered defaults.
4. For each node, if `positions[id]` exists, overwrite `n.x` / `n.y`
   with the stored coordinates and zero its `vx` / `vy`. The physics
   engine "warms" from the saved layout instead of jittering from
   scratch.

Save policy:

- A `saveLayout()` function serialises the current state and writes
  to `localStorage`.
- Called: (a) on `pagehide` (catches both reload and tab close);
  (b) on filter / search input commit (debounced 500 ms);
  (c) once after physics damps below a threshold (∑ |v| < 0.5)
  to capture the natural-rest layout.

Reset button:

- `#reset-layout` removes the key, resets filters / search to
  defaults, calls `placeInitial()` and `renderNodeList()`.

Failure modes:

- `localStorage` blocked (private browsing / quota): wrap every
  read/write in try/catch, log to `console.warn` once, fall through
  to non-persistent mode. No user-visible error.

### Tests

The explorer is HTML / vanilla JS in a single template file. We do
not introduce a browser test runner. Instead:

- `tests/core/brain/explorer-template.test.ts`: a smoke that loads
  the rendered HTML and asserts the listbox markup is present
  (regex on `<ul id="node-list"`, `role="listbox"`,
  `aria-activedescendant`, `Reset layout` button).
- `tests/core/brain/explorer-template.test.ts` extends to check
  that the layout-persistence boot code references
  `osb-explorer-layout:` literally — guards against accidental
  rename.

Manual QA notes captured in the PR description for the keyboard
flow itself (cannot reasonably be automated without a browser).

## §28 — `o2b brain export`

### Module

New file `src/core/brain/export.ts`:

```ts
export interface ExportedPreferencesJson {
  readonly schema: 1;
  readonly generated_at: string;        // ISO-8601 UTC
  readonly vault_basename: string;
  readonly preferences: ReadonlyArray<ExportedPreferenceRow>;
}

export interface ExportedPreferenceRow {
  // Full BrainPreference frontmatter, with confidence_value preserved
  // and dates kept as ISO strings.
  readonly id: string;
  readonly topic: string;
  readonly scope: string | null;
  readonly status: "confirmed" | "unconfirmed" | "quarantine";
  readonly principle: string;
  readonly applied_count: number;
  readonly violated_count: number;
  readonly confidence: "low" | "medium" | "high";
  readonly confidence_value: number | null;
  readonly pinned: boolean;
  readonly last_evidence_at: string | null;
  readonly created_at: string;
  readonly confirmed_at: string | null;
  readonly aliases: ReadonlyArray<string> | null;
  readonly tags: ReadonlyArray<string>;
  readonly evidenced_by: ReadonlyArray<string>;
  readonly body: string;                // markdown after frontmatter
}

export function exportPreferencesJson(vault: string): ExportedPreferencesJson;
export function exportPreferencesLlmsTxt(vault: string): string;
```

JSON shape rationale:

- Single top-level object so a future export schema change can land
  via `schema: 2` without breaking diff-friendly stable output.
- One row per preference. No retired, no signals — matches the
  decision recorded in `_summary.md` §28.
- Frontmatter fields are explicit; consumer code does not need to
  parse YAML.

llms-txt shape (per [llmstxt.org](https://llmstxt.org/)):

```text
# <vault_basename> — Brain preferences

> Active preferences captured by Open Second Brain. Auto-generated
> by `o2b brain export --format llms-txt`. Read-only dump; the
> canonical files live under `<vault>/Brain/preferences/`.

## Confirmed

- pref-no-simply-word (topic: writing-style, scope: writing): Do not use 'simply' in technical documentation; it minimises reader effort.
- pref-no-em-dashes (topic: russian-writing, scope: writing): Do not use em-dashes in Russian prose for this vault; use ' - '.
- ...

## Unconfirmed

- pref-... (topic: ...): ...
- ...

## Quarantine

- pref-... (topic: ...): ...
- ...
```

Section headings are emitted only when the corresponding set is
non-empty. Within a section, rows are sorted by `id` ascending so
output is deterministic.

### CLI

`src/cli/brain.ts:cmdBrainExport`:

- Flags: `--format json|llms-txt` (required, no default — explicit
  choice avoids ambiguity if we add formats later), `--out <path>`,
  `--force`, `--vault`.
- Default sink: stdout. JSON is single-line for pipe-friendliness
  (matches other `--json` outputs); llms-txt has a trailing newline
  for shell convention.
- `--out <path>`: refuses to overwrite without `--force`; uses
  `atomicWriteFileSync`.
- `--out` and stdout are mutually exclusive only in implementation
  routing (stdout when `--out` absent); they are not exclusive flags
  the operator sets directly.
- No `--json` flag here (the format choice replaces it). All other
  brain verbs that take `--json` keep theirs unchanged.

`BRAIN_HELP` gains `export`. New `VERB_HELP.export`:

```text
usage: o2b brain export --format json|llms-txt [--out <path>] [--force]
                        [--vault <path>]
Read-only dump of active preferences (confirmed | unconfirmed |
quarantine) from Brain/preferences/. Retired and signal entries are
not included. JSON is single-line; llms-txt follows the
llmstxt.org H1 + summary + H2-section shape.
Default sink is stdout; --out writes to <path> (refuses to overwrite
without --force).
```

### Tests

- `tests/core/brain/export.test.ts`: fixture vault with preferences
  across all three statuses + one retired (should be excluded) +
  one signal (should be excluded). Asserts JSON shape (schema,
  generated_at format, row count, field set per row) and
  llms-txt rendering (section presence/absence, sort order).
- `tests/cli/brain-export.test.ts`: stdout vs `--out` paths, JSON
  parsing roundtrip, `--out` refuses to overwrite without `--force`,
  `--format` required.

## §31 — CR cleanup

### §31.1 — MD040 in planning docs

Manual sweep across:

- `docs/plans/2026-05-18-brain-maturity-design.md`
- `docs/plans/2026-05-18-brain-maturity-impl.md`
- `skills/embeddings-setup/SKILL.md`

Per-block decision:

- Shell / bash output → ` ```bash`
- TypeScript snippets → ` ```ts`
- YAML / `_brain.yaml` excerpts → ` ```yaml`
- Markdown samples → ` ```markdown`
- Plain output, ASCII tables, log lines → ` ```text`
- JSON examples → ` ```json`

No `sed` / bulk script — the wrong tag is worse than a missing one
because it changes syntax highlighting in renderers that respect
the hint.

### §31.2 — macOS shim test for empty DYLD_LIBRARY_PATH

`tests/scripts/macos-sqlite-shim.test.ts` gains one case:

```ts
test("Darwin + explicit empty DYLD_LIBRARY_PATH → preserved verbatim", async () => {
  const fakePrefix = join(tmp, "homebrew-sqlite-lib");
  mkdirSync(fakePrefix, { recursive: true });
  const out = await runShim({
    O2B_MACOS_FORCE_PLATFORM: "Darwin",
    O2B_MACOS_SQLITE_PREFIXES_OVERRIDE: fakePrefix,
    DYLD_LIBRARY_PATH: "",
  });
  expect(out).toBe(""); // unchanged, not auto-populated
});
```

Covers the `${VAR+set}` branch added in v0.10.5 CR fixes.

### §31.3 — Keyboard a11y

Subsumed by §14 polish above. CHANGELOG groups them under §14;
no separate `§31.3` entry needed.

## Updates to plans / summary

`Projects/OpenSecondBrain/Features/_summary.md`:

- §5 — mark as fully shipped (manifest + abort drift detection
  delivered in v0.10.6). Update tail text.
- §14 — append "v0.10.6: keyboard-accessible node inspection and
  layout-state persistence via localStorage". Remove the two
  matching items from `## Deferred work` and from the §14 inline
  parenthetical.
- §22 — replace "6" header with "✅ … *(implemented in v0.10.6)*".
- §28 — same: "✅ … *(implemented in v0.10.6)*".
- §30 — "✅ Agent logging discipline — §A + §C *(implemented in
  v0.10.6: stop-guardrail extended to brain-events, SKILL trigger
  rephrased; §B / §D / §E remain open)*".
- §31 — "✅ v0.10.5 review followups *(closed in v0.10.6: MD040 in
  the three planning docs, extra test for an empty DYLD_LIBRARY_PATH;
  keyboard a11y split into §14 polish)*".

`Projects/OpenSecondBrain/Plan/3. Agent logging discipline.md`:

- Add `shipped: §A, §C (v0.10.6)` line near top.
- §B / §D / §E sections explicitly tagged `status: open`.

`Projects/OpenSecondBrain/Plan/4. v0.10.5 review followups.md`:

- §1 / §2 / §4 tagged shipped. §3 was already done.

## CHANGELOG

`CHANGELOG.md` gains a single new entry above the v0.10.5 block:

```markdown
## [0.10.6] - 2026-05-18

Logging discipline, manifest-backed snapshots, explorer polish, export.

### Added
- §22 + §5-tail — `o2b brain upgrade [--dry-run] [--apply] [--yes]
  [--check] [--json]` …
- §28 — `o2b brain export --format json|llms-txt [--out <path>]
  [--force]` …
- §14 — keyboard listbox over Brain Explorer nodes; layout and
  filter state persisted to localStorage; "Reset layout" button.
- Snapshot sidecar manifest `<run-id>.manifest.json` written by
  `createSnapshot`; used by `o2b brain rollback` for drift detection.

### Changed
- §30 §A — `hooks/stop-log-guardrail.ts` clears on any of
  `event_log_append`, `brain_feedback`, `brain_apply_evidence` (MCP
  or CLI form). Block text lists all three.
- §30 §C — `skills/brain-memory/SKILL.md` and `hooks/lib/messages.ts`
  default to "record with `note: speculative; ...`" rather than skip
  on uncertain matches.
- `o2b brain rollback` aborts with exit 2 on drift detected against
  the snapshot's manifest; `--force-rollback` overrides.
- `_BRAIN.md` template lists `o2b brain upgrade` and
  `o2b brain export`.

### Internal
- New module `src/core/brain/manifest.ts` (sha256 file inventory,
  diff helpers, sidecar I/O).
- New module `src/core/brain/upgrade.ts` (managed-file plan + apply).
- New module `src/core/brain/export.ts` (JSON and llms-txt
  serialisation).
- `BRAIN_LOG_EVENT_KIND.upgrade = "upgrade"`.
- `SnapshotInfo` gains `manifest_path: string | null`.

### Tests
- `tests/core/brain/manifest.test.ts`
- `tests/core/brain/snapshot-manifest.test.ts`
- `tests/core/brain/upgrade.test.ts`
- `tests/core/brain/export.test.ts`
- `tests/cli/brain-upgrade.test.ts`
- `tests/cli/brain-export.test.ts`
- `tests/cli/brain-rollback-drift.test.ts`
- `tests/hooks/detect.test.ts` (extended)
- `tests/hooks/stop-log-guardrail.test.ts` (extended)
- `tests/hooks/messages.test.ts` (extended)
- `tests/core/brain/explorer-template.test.ts` (extended)
- `tests/scripts/macos-sqlite-shim.test.ts` (one new case)

### Docs
- MD040 language tags added to
  `docs/plans/2026-05-18-brain-maturity-design.md`,
  `docs/plans/2026-05-18-brain-maturity-impl.md`,
  `skills/embeddings-setup/SKILL.md`.

[0.10.6]: https://github.com/itechmeat/open-second-brain/compare/v0.10.5...v0.10.6
```

## Migration

No vault data migration is required:

- Existing snapshots without sidecar manifests stay valid; rollback
  falls back to the legacy no-drift-check path with a warning.
- `o2b brain upgrade` is opt-in (default `--dry-run`).
- `o2b brain export` is read-only.
- `_BRAIN.md` template change is picked up only when an operator
  runs `o2b brain upgrade --apply`.

## Risk and known-unknowns

- **Manifest cost on large vaults.** sha256-hashing every file under
  `Brain/` on every snapshot. Vaults with thousands of preferences
  remain well under one second on commodity hardware (Brain trees
  are kilobytes, not megabytes). If a future vault grows past a
  threshold we will add `manifest_skip` for known-derived files; not
  in v0.10.6.
- **Drift detection false positives.** Two snapshots taken seconds
  apart by separate runs would normally drift only on log files.
  Drift detection runs at `rollback` time, not on every snapshot —
  so this is observably benign (operator sees what differs and
  decides). Documenting this in the rollback help text.
- **`_brain.yaml` merge ambiguity.** If the user already added a
  custom key that later collides with a new default key, the
  result would be a duplicated key. The strict YAML parser raises on
  reading such a file, so the collision surfaces as `status:
  "error"` (collapsed under the same bucket as parse errors and
  read failures) and `--apply` refuses to proceed; the operator
  resolves manually. We chose not to split a dedicated
  `"conflict"` status because the parser's own duplicate-key
  detection catches the case earlier in the same code path, and
  the operator-visible outcome is identical.
- **Listbox + canvas synchronisation.** Pointer click on canvas and
  keyboard selection in listbox must stay in lockstep. The
  implementation funnels both through a single `selectNode(id)`
  function — both surfaces update from it.
- **localStorage quota.** With 500 nodes the layout payload is
  ~30 KB; well under the 5 MB origin quota every browser ships.

## Out-of-scope (followups)

- `o2b brain upgrade --interactive` wizard.
- Cron / SessionEnd sanity check on brain-event count (§30 §D).
- MEMORY ↔ Brain bridge (§30 §E).
- Always-loaded MCP promotion for `brain_feedback` and
  `brain_apply_evidence` (§30 §B).
- Retired / signal export for §28.
- Live-refresh, Obsidian deep-link, freeze-layout toggle in
  Brain Explorer.
