# v0.10.6 Implementation Plan

> **For the implementer:** the user explicitly forbids any active git
> action (commit, push, branch, tag, amend). Every "Pause for review"
> step below means: stop, surface the diff, wait for the user to
> stage / commit themselves. Do not run `git add`, `git commit`,
> `git push`, `git stash`, `git tag`, or any history-rewriting
> command without an explicit per-action ask.

**Goal:** Ship `v0.10.6` of `open-second-brain` covering five tracks
from `Projects/OpenSecondBrain/Features/_summary.md`: §30 (logging
discipline), §22 + §5-tail (manifest-backed snapshots and upgrade),
§14 polish (explorer keyboard a11y + layout persistence), §28
(`o2b brain export`), §31 (CR cleanup).

**Architecture:** A new `src/core/brain/manifest.ts` (SHA-256 file
inventory of `Brain/` minus `.snapshots/`) underlies both upgrade and
rollback drift detection. `src/core/brain/upgrade.ts` plus the new
`o2b brain upgrade` CLI verb consume it. `src/core/brain/export.ts`
plus `o2b brain export` give a read-only dump. Hook reminders
(`hooks/lib/detect.ts`, `hooks/lib/messages.ts`) and the `brain-memory`
SKILL get reworded so any of three brain-events clears the stop
guardrail and uncertain matches default to a `speculative` record.
Browser-side, `templates/brain-explorer.html` gains a listbox surface
and `localStorage` persistence.

**Tech Stack:** Bun ≥ 1.1, TypeScript (strict), `bun test`, `bun:sqlite`
(unchanged). No new runtime dependencies. SHA-256 via `node:crypto`.
Hooks are plain TS, executed under Claude Code's PostToolUse / Stop
hook protocols.

**Design source:** `docs/plans/2026-05-18-v0.10.6-design.md`. Refer
back for rationale; this file is the step-by-step.

---

## Phase 0 — Baseline check

### Task 0.1: Verify clean baseline

**Objective:** Confirm `main` is green before any change lands.

**Steps:**

1. Run `bun test` from `/srv/projects/open-second-brain`. Expected:
   all tests pass.
2. Run `bun run typecheck`. Expected: zero errors.
3. Record the current `package.json` version (should read `0.10.5`).

**Pause for review.** Do not change anything yet.

---

## Phase 1 — Manifest infrastructure (§22 + §5-tail foundation)

### Task 1.1: Manifest types and empty `buildManifest`

**Objective:** Land the public API surface of the manifest module
with one failing test.

**Files:**
- Create: `src/core/brain/manifest.ts`
- Create: `tests/core/brain/manifest.test.ts`

**Step 1: Write failing test** — `tests/core/brain/manifest.test.ts`:

```ts
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import {
  BRAIN_MANIFEST_SCHEMA_VERSION,
  buildManifest,
} from "../../../src/core/brain/manifest.ts";

let vault: string;
beforeEach(() => {
  vault = mkdtempSync(join(tmpdir(), "o2b-manifest-"));
  mkdirSync(join(vault, "Brain"), { recursive: true });
});
afterEach(() => rmSync(vault, { recursive: true, force: true }));

describe("buildManifest", () => {
  test("empty Brain → empty files map, schema_version 1", () => {
    const m = buildManifest(join(vault, "Brain"));
    expect(m.schema_version).toBe(BRAIN_MANIFEST_SCHEMA_VERSION);
    expect(m.brain_root).toBe("Brain");
    expect(Object.keys(m.files)).toEqual([]);
    expect(typeof m.generated_at).toBe("string");
  });
});
```

**Step 2: Run test, expect FAIL** — `bun test tests/core/brain/manifest.test.ts`
fails on missing module.

**Step 3: Minimal implementation** — `src/core/brain/manifest.ts`:

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

export interface BrainManifestEntry {
  readonly sha256: string;
  readonly size: number;
}

export interface BrainManifest {
  readonly schema_version: typeof BRAIN_MANIFEST_SCHEMA_VERSION;
  readonly generated_at: string;
  readonly brain_root: "Brain";
  readonly files: Readonly<Record<string, BrainManifestEntry>>;
}

export function buildManifest(_brainRoot: string): BrainManifest {
  return {
    schema_version: BRAIN_MANIFEST_SCHEMA_VERSION,
    generated_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
    brain_root: "Brain",
    files: Object.freeze({}),
  };
}
```

**Step 4: Run test, expect PASS.**

**Pause for review.**

---

### Task 1.2: Walk `Brain/`, sha256 every regular file

**Objective:** Real implementation of `buildManifest`.

**Files:**
- Modify: `src/core/brain/manifest.ts`
- Modify: `tests/core/brain/manifest.test.ts` (add cases)

**Step 1: Add failing tests** for: (a) two files in `preferences/`
produce two entries with stable order; (b) symlink is skipped;
(c) `.snapshots/` is skipped; (d) two runs over identical bytes
produce identical sha256 values; (e) byte-identical generated JSON
across runs (modulo `generated_at`).

Sketch:

```ts
test("two preferences → two entries sorted by path", () => {
  mkdirSync(join(vault, "Brain", "preferences"));
  writeFileSync(join(vault, "Brain", "preferences", "pref-a.md"), "alpha\n");
  writeFileSync(join(vault, "Brain", "preferences", "pref-b.md"), "beta\n");
  const m = buildManifest(join(vault, "Brain"));
  expect(Object.keys(m.files)).toEqual([
    "preferences/pref-a.md",
    "preferences/pref-b.md",
  ]);
  expect(m.files["preferences/pref-a.md"]!.size).toBe(6);
  expect(m.files["preferences/pref-a.md"]!.sha256).toMatch(/^[0-9a-f]{64}$/);
});

test("symlink under Brain/ is skipped", () => { /* symlinkSync */ });
test(".snapshots/ tree is excluded", () => { /* mkdir + file + assert absent */ });
test("identical bytes → identical sha256", () => { /* two roots */ });
```

**Step 2: Run, expect FAIL.**

**Step 3: Implement** the walker following the design doc §22
"Walk semantics" — `lstatSync`, skip symlinks, skip `.snapshots/`,
drop `..` escapes, sort keys lexicographically, hash via
`node:crypto.createHash("sha256")`. Use insertion order on a fresh
object to materialise the sorted key order.

**Step 4: Run, expect PASS.**

**Pause for review.**

---

### Task 1.3: `diffManifests`

**Objective:** Pure comparator with added / removed / changed buckets.

**Files:**
- Modify: `src/core/brain/manifest.ts`
- Modify: `tests/core/brain/manifest.test.ts`

**Step 1: Add failing tests** covering:
- two identical manifests → empty diff in all three buckets;
- left has extra file → `removed`;
- right has extra file → `added`;
- same path different sha256 → `changed`;
- same path same sha256 different size (impossible in practice but
  defensive) → `changed` still surfaces as differing.

**Step 2: FAIL.**

**Step 3:** Add types:

```ts
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 diffManifests(
  a: BrainManifest,
  b: BrainManifest,
): BrainManifestDiff { /* … */ }
```

Implement with a `Set` union of keys, three classification passes,
each bucket sorted by `path`.

**Step 4: PASS.**

**Pause for review.**

---

### Task 1.4: Sidecar I/O helpers

**Objective:** `manifestSidecarPath`, `readManifestSidecar`,
`writeManifestSidecar`.

**Files:**
- Modify: `src/core/brain/manifest.ts`
- Modify: `tests/core/brain/manifest.test.ts`

**Step 1: Failing tests**:
- `manifestSidecarPath(vault, "abc")` returns the
  `Brain/.snapshots/abc.manifest.json` path;
- write then read roundtrip yields equal-by-deep-equality object;
- read of missing path returns `null`;
- read of malformed JSON returns `null` and does NOT throw;
- read of valid JSON with wrong `schema_version` returns `null`.

**Step 2: FAIL.**

**Step 3:** Implementation uses the existing
`atomicWriteFileSync` (under `src/core/fs-atomic.ts`). JSON is
pretty-printed two-space. `readManifestSidecar` swallows all
errors and returns `null`.

```ts
import { atomicWriteFileSync } from "../fs-atomic.ts";
import { brainDirs } from "./paths.ts";
// ...

export function manifestSidecarPath(vault: string, runId: string): string {
  return join(brainDirs(vault).snapshots, `${runId}.manifest.json`);
}
```

**Step 4: PASS.**

**Pause for review.**

---

## Phase 2 — Snapshot sidecar manifest (§5-tail)

### Task 2.1: `SnapshotInfo` gains `manifest_path`

**Objective:** Forward-compat shape change; legacy snapshots get
`manifest_path: null`.

**Files:**
- Modify: `src/core/brain/snapshot.ts`
- Modify: `tests/core/brain/snapshot.test.ts` (if exists; otherwise
  extend the closest existing snapshot test)

**Step 1: Failing test** — `listSnapshots` over a fixture with two
archives, one with sidecar, one without, returns
`manifest_path: <abs>` for the first and `null` for the second.

**Step 2: FAIL.**

**Step 3:** Add `manifest_path: string | null` to `SnapshotInfo`.
In `listSnapshots`, after pushing an info row, set
`manifest_path` from `existsSync(manifestSidecarPath(vault, runId))`.

**Step 4: PASS.**

**Pause for review.**

---

### Task 2.2: `createSnapshot` writes sidecar

**Files:**
- Modify: `src/core/brain/snapshot.ts`
- Modify: `tests/core/brain/snapshot.test.ts`

**Step 1: Failing test** — call `createSnapshot(vault, "run-x")` on
a fixture vault with two preference files; assert sidecar exists,
parse it back via `readManifestSidecar`, assert it lists those two
files with their sha256.

**Step 2: FAIL.**

**Step 3:** After the archive write succeeds (right before the
`return { path: outPath }`), call:

```ts
try {
  const manifest = buildManifest(dirs.brain);
  writeManifestSidecar(vault, runId, manifest);
} catch (err) {
  process.stderr.write(
    `warning: manifest sidecar write failed for ${runId}: ` +
    `${(err as Error).message ?? String(err)}; ` +
    `rollback drift detection will be skipped for this snapshot.\n`,
  );
}
```

**Step 4: PASS.** Add a second test for the failure-path: pre-create
the sidecar path as a directory (so `writeFileSync` fails), confirm
archive still lands and the warning fired on stderr (capture via
`Bun.spawn` if necessary; otherwise mock writeManifestSidecar).

**Pause for review.**

---

### Task 2.3: `pruneSnapshots` removes sidecar

**Files:**
- Modify: `src/core/brain/snapshot.ts`
- Modify: `tests/core/brain/snapshot.test.ts`

**Step 1: Failing test** — create 12 snapshots (each with sidecar)
in a fixture, call `pruneSnapshots(vault, 10)`, assert two oldest
archives AND their sidecars are gone; remaining 10 + 10 stay.

**Step 2: FAIL.**

**Step 3:** Inside the loop that removes victim archives, also call
`rmSync(manifestSidecarPath(vault, v.run_id), { force: true })`.
Wrap in its own try/catch — a missing sidecar (legacy snapshot)
must not abort the prune.

**Step 4: PASS.**

**Pause for review.**

---

## Phase 3 — Rollback drift detection (§5-tail)

### Task 3.1: Add `--force-rollback` flag scaffolding

**Files:**
- Modify: `src/cli/brain.ts`
- Modify: `tests/cli/brain.test.ts` (or `tests/cli/brain.rollback.test.ts`)

**Step 1: Failing test** — `o2b brain rollback <run> --force-rollback`
on a clean snapshot still succeeds; flag is recognised by the parser
(unknown-flag error would surface otherwise).

**Step 2: FAIL.**

**Step 3:** In `cmdBrainRollback`'s `parse(...)` call, add
`"force-rollback": { type: "boolean" }`. Plumb a `forceRollback`
local variable through to the new drift-check (added in Task 3.2).

**Step 4: PASS.**

**Pause for review.**

---

### Task 3.2: Drift check when sidecar exists

**Files:**
- Modify: `src/cli/brain.ts:cmdBrainRollback`
- Modify: `tests/cli/brain.rollback.test.ts`

**Step 1: Failing tests**:
- snapshot taken at T1, live tree mutated at T2, `o2b brain rollback
  <run>` (no `--force-rollback`) exits 2, stderr lists the drifted
  files;
- same scenario with `--force-rollback` proceeds and writes the log
  event with `drift_overridden: "true"` in the payload;
- `--dry-run` mode also reports drift but exits 0 (informational).

**Step 2: FAIL.**

**Step 3:** Before `restoreSnapshot(...)`, after the runId/archive
existence check:

```ts
const stored = readManifestSidecar(vault, runId);
if (stored !== null) {
  const live = buildManifest(brainDirs(vault).brain);
  const drift = diffManifests(stored, live);
  const hasDrift =
    drift.added.length > 0 ||
    drift.removed.length > 0 ||
    drift.changed.length > 0;
  if (hasDrift && !forceRollback && !flags["dry-run"]) {
    // Render the drift, exit 2 with --force-rollback hint.
    return fail(renderDriftMarkdown(drift, runId));
  }
  // Either no drift, --force-rollback, or --dry-run: keep going,
  // but stash the diff for the log payload further down.
}
```

Render helper goes in `src/core/brain/manifest.ts` or a new
`src/core/brain/manifest-render.ts` (decide based on size; keep it
inside `manifest.ts` if under ~40 lines).

**Step 4: PASS.**

**Pause for review.**

---

### Task 3.3: Legacy snapshot (no sidecar) emits warning, proceeds

**Files:**
- Modify: `src/cli/brain.ts:cmdBrainRollback`
- Modify: `tests/cli/brain.rollback.test.ts`

**Step 1: Failing test** — snapshot archive present without sidecar,
`o2b brain rollback <run>` succeeds (with `--yes`) and stderr
contains the legacy-warning line.

**Step 2: FAIL.**

**Step 3:** In the same block from Task 3.2, the `null` branch:

```ts
if (stored === null) {
  process.stderr.write(
    `warning: no manifest sidecar for snapshot '${runId}'; ` +
    `drift detection skipped (snapshot predates v0.10.6).\n`,
  );
}
```

**Step 4: PASS.**

**Pause for review.**

---

### Task 3.4: Log `drift_overridden` on `--force-rollback`

**Files:**
- Modify: `src/cli/brain.ts:cmdBrainRollback` (the final
  `appendLogEvent` call site)
- Modify: `src/core/brain/types.ts:BrainRollbackLogEvent` (extend
  the payload type)

**Step 1: Failing test** — after `--force-rollback`, the log row in
`Brain/log/<today>.md` contains `drift_overridden: true`.

**Step 2: FAIL.**

**Step 3:** Extend `BrainRollbackLogEvent.body` type to include
optional `drift_overridden: string` ("true" when applicable; omit
otherwise to keep the legacy shape clean). In the CLI, populate
when `forceRollback && hasDrift`.

**Step 4: PASS.**

**Pause for review.**

---

## Phase 4 — Upgrade core (§22)

### Task 4.1: Upgrade types + skeleton

**Files:**
- Create: `src/core/brain/upgrade.ts`
- Create: `tests/core/brain/upgrade.test.ts`

**Step 1: Failing test** — `planUpgrade` over a fresh
`o2b init`-style vault returns `pending: 0`, three files all
`status: "noop"`.

**Step 2: FAIL.**

**Step 3:** Stub `planUpgrade` returning a hardcoded noop result so
the test passes; real comparison comes in the next tasks. Surface
`UpgradeFilePlan`, `UpgradePlan`, `UpgradeApplyResult` types from
the design doc.

**Step 4: PASS.**

**Pause for review.**

---

### Task 4.2: `_brain.yaml` text-level merge

**Files:**
- Modify: `src/core/brain/upgrade.ts`
- Modify: `tests/core/brain/upgrade.test.ts`

**Step 1: Failing tests**:
- fixture vault where `_brain.yaml` is missing the `snapshots:`
  block → `planUpgrade` returns `status: "update"` for
  `Brain/_brain.yaml`, the `after` text contains the canonical
  `snapshots:` block at end-of-file;
- fixture missing a nested key (`confidence.high_min`) →
  `status: "update"`, the missing line is inserted at the end of
  the `confidence:` block with two-space indent;
- fixture with all keys present → `status: "noop"`;
- fixture with malformed YAML → `status: "error"` and the message
  surfaces the parser error;
- fixture with duplicated key (`primary_agent:` twice) →
  `status: "error"`.

**Step 2: FAIL.**

**Step 3:** Implement the text-level walker described in the
design doc §22 "Managed files" → `_brain.yaml` merge. Source of
truth for keys is `DEFAULT_BRAIN_CONFIG_YAML` from
`src/core/brain/policy.ts`. Parsing for validity uses the existing
`loadBrainConfig` (catch its error to emit `status: "error"`).

**Step 4: PASS.**

**Pause for review.**

---

### Task 4.3: `_BRAIN.md` and `_OPEN_SECOND_BRAIN.md` byte comparison

**Files:**
- Modify: `src/core/brain/upgrade.ts`
- Modify: `tests/core/brain/upgrade.test.ts`

**Step 1: Failing tests**:
- fixture where `_BRAIN.md` matches the rendered template byte for
  byte → `status: "noop"`;
- fixture where `_BRAIN.md` is the v0.10.5 body (missing the new
  upgrade/export lines added in Task 5.5) → `status: "update"` with
  unified-diff-ready `before` / `after`;
- same for `AI Wiki/_OPEN_SECOND_BRAIN.md`.

(The v0.10.5 fixture body is captured under
`tests/fixtures/upgrade/brain-md-v0.10.5.md` — copy the literal
bytes that `init.ts` would have written before Task 5.5 lands.)

**Step 2: FAIL.**

**Step 3:** Reuse `renderTemplate(readTemplate(...), substitutions)`
from `init.ts` (lift to a shared helper if not already exported).
Compare the rendered target bytes to the live file bytes.

**Step 4: PASS.**

**Pause for review.**

---

### Task 4.4: `applyUpgrade` — snapshot + write + log

**Files:**
- Modify: `src/core/brain/upgrade.ts`
- Modify: `tests/core/brain/upgrade.test.ts`

**Step 1: Failing tests**:
- end-to-end on a fixture with one pending update: post-call the
  three managed files match the canonical template; a snapshot
  named `upgrade-<ts>` exists under `.snapshots/` along with its
  sidecar manifest; `Brain/log/<today>.md` contains an `upgrade`
  log row;
- idempotent: a second call returns `files_updated: []` and writes
  no log row.

**Step 2: FAIL.**

**Step 3:** Implementation order inside `applyUpgrade`:

1. Compute the plan; if `pending === 0`, short-circuit and return
   without touching disk.
2. Compose `run_id = "upgrade-" + isoSecond(now)`.
3. `createSnapshot(vault, run_id)` — sidecar manifest comes along.
4. `atomicWriteFileSync` each `update` file.
5. `appendLogEvent` with the `upgrade` event kind (added in
   Task 4.5) and a payload describing the run_id, agent, updated
   files.

**Step 4: PASS.**

**Pause for review.**

---

### Task 4.5: `BRAIN_LOG_EVENT_KIND.upgrade` and log payload type

**Files:**
- Modify: `src/core/brain/types.ts`
- Modify: `src/core/brain/log.ts` (if it does the event-kind
  validation; otherwise no-op)
- Modify: `tests/core/brain/log.test.ts` (if exists)

**Step 1: Failing test** — `BrainLogEvent` discriminated union
includes a `kind: "upgrade"` member with `run_id`, `agent`, and
`files` payload keys.

**Step 2: FAIL.**

**Step 3:** Add:

```ts
export const BRAIN_LOG_EVENT_KIND = {
  // ... existing entries
  upgrade: "upgrade",
} as const;

export interface BrainUpgradeLogEvent extends BrainLogEventBase {
  readonly eventType: "upgrade";
  readonly body: {
    readonly run_id: string;
    readonly agent: string;
    readonly snapshot: string;
    readonly files: ReadonlyArray<string>;
  };
}
```

Extend the `BrainLogEvent` union.

**Step 4: PASS.**

**Pause for review.**

---

## Phase 5 — Upgrade CLI (§22)

### Task 5.1: `cmdBrainUpgrade` with `--dry-run` (default)

**Files:**
- Modify: `src/cli/brain.ts`
- Create: `tests/cli/brain-upgrade.test.ts`

**Step 1: Failing test** — `o2b brain upgrade --vault <path>` over
a fixture with one pending update prints the per-file diff to
stdout and exits 0.

**Step 2: FAIL.**

**Step 3:** Add `cmdBrainUpgrade` mirroring the shape of
`cmdBrainMigrateFrontmatter`. Flags: `--vault`, `--dry-run`,
`--apply`, `--yes`, `--check`, `--json`. Default to dry-run when
neither `--apply` nor `--check` is passed. Render unified diffs via
a small helper (or reuse the existing snapshot-diff renderer if the
shape lines up — likely not, since this is per-file rather than
per-frontmatter-field; keep a small ad-hoc renderer in the CLI
file).

Wire it into the dispatcher:

```ts
case "upgrade":
  return await cmdBrainUpgrade(rest);
```

**Step 4: PASS.**

**Pause for review.**

---

### Task 5.2: `--check` returns exit 2 on pending updates

**Files:**
- Modify: `src/cli/brain.ts:cmdBrainUpgrade`
- Modify: `tests/cli/brain-upgrade.test.ts`

**Step 1: Failing test** — `--check` over a fixture with pending
updates exits 2; over a clean vault exits 0.

**Step 2: FAIL.**

**Step 3:** Add the `--check` branch returning `plan.pending > 0 ? 2 : 0`.
Validate mutual exclusion with `--apply` (`--apply` and `--check`
together is an error).

**Step 4: PASS.**

**Pause for review.**

---

### Task 5.3: `--apply --yes` end-to-end

**Files:**
- Modify: `src/cli/brain.ts:cmdBrainUpgrade`
- Modify: `tests/cli/brain-upgrade.test.ts`

**Step 1: Failing tests**:
- `--apply --yes` over a fixture with pending updates: managed files
  rewritten, snapshot created, log entry appended, exit 0;
- `--apply` without `--yes` in non-interactive mode (no TTY or
  `--json`) returns exit 1 with the same error pattern as
  `migrate-frontmatter`;
- interactive prompt path (mocked stdin) accepts `y` and proceeds,
  rejects `n` cleanly.

**Step 2: FAIL.**

**Step 3:** Apply path: validate `--yes` in non-interactive mode,
prompt otherwise (reuse `readSingleLine` helper). Call
`applyUpgrade`. Emit human or `--json` output.

**Step 4: PASS.**

**Pause for review.**

---

### Task 5.4: Help text wiring

**Files:**
- Modify: `src/cli/brain.ts` (`BRAIN_HELP` body, `VERB_HELP.upgrade`)
- Modify: `tests/cli/brain.test.ts` (or add to `brain-upgrade.test.ts`)

**Step 1: Failing test** — `o2b brain upgrade --help` prints the
upgrade-specific block and exits 0; `o2b brain --help` lists
`upgrade` among the verbs.

**Step 2: FAIL.**

**Step 3:** Update the strings per the design doc §22 "CLI".

**Step 4: PASS.**

**Pause for review.**

---

### Task 5.5: Update `_BRAIN.md` template (the v0.10.6 real change)

**Files:**
- Modify: `src/core/brain/templates/_BRAIN.md` (or the equivalent
  templates path — confirm via `init.ts:BRAIN_MANUAL_TEMPLATE_PATH`)
- Modify: `tests/core/brain/upgrade.test.ts` (the v0.10.5-fixture
  scenario from Task 4.3 starts surfacing a real diff)

**Step 1: Re-run** the v0.10.5-fixture test from Task 4.3. With the
template change, it should now see the two added lines in the diff.

**Step 2: Add two lines** to the template's "Brain CLI surface"
section (or analogous spot near the existing CLI listing):

```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.
```

Locate the right insertion point by reading the template first; the
existing CLI section is the natural anchor.

**Step 3: Run the full test suite** — `bun test`. Verify the v0.10.6
template renders without breaking any `init.ts` test.

**Step 4: Update** the v0.10.5 fixture in `tests/fixtures/upgrade/`
if needed so it pins the pre-change body exactly.

**Pause for review.**

---

## Phase 6 — §30 §A: stop guardrail broadening

### Task 6.1: Broaden `BRAIN_EVENT_NAME_SUFFIX`

**Files:**
- Modify: `hooks/lib/detect.ts`
- Modify: `tests/hooks/detect.test.ts`

**Step 1: Failing tests** — `isBrainEventToolName` returns `true`
for:
- `event_log_append`,
- `mcp__plugin_open-second-brain_open-second-brain__event_log_append`,
- `brain_feedback`,
- `mcp__plugin_open-second-brain_open-second-brain__brain_feedback`,
- `brain_apply_evidence`,
- `mcp__plugin_open-second-brain_open-second-brain__brain_apply_evidence`,
and `false` for other names.

**Step 2: FAIL** (function not defined yet under the new name).

**Step 3:** Rename `LOG_NAME_SUFFIX` → `BRAIN_EVENT_NAME_SUFFIX`,
broaden the regex per the design doc §30 §A. Rename
`isLogToolName` → `isBrainEventToolName`. Single in-tree consumer
(`summarizeTurn`) updates with it.

**Step 4: PASS.**

**Pause for review.**

---

### Task 6.2: Extend `BRAIN_EVENT_BASH_NEEDLES`

**Files:**
- Modify: `hooks/lib/detect.ts`
- Modify: `tests/hooks/detect.test.ts`

**Step 1: Failing tests** — bash commands `o2b brain feedback ...`
and `o2b brain apply-evidence ...` count as brain-event needles;
random `o2b brain query ...` does not; trailing-space rule for
`vault-log ` still holds.

**Step 2: FAIL.**

**Step 3:** Rename and extend the array:

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

**Step 4: PASS.**

**Pause for review.**

---

### Task 6.3: `summarizeTurn` field rename + propagation

**Files:**
- Modify: `hooks/lib/detect.ts`
- Modify: `hooks/stop-log-guardrail.ts`
- Modify: `tests/hooks/detect.test.ts`
- Modify: `tests/hooks/stop-log-guardrail.test.ts`

**Step 1: Failing tests** — `TurnSummary.hadBrainEvent` exposed by
`summarizeTurn`; `stop-log-guardrail.ts` reads
`summary.hadBrainEvent` instead of `summary.hadLog`.

**Step 2: FAIL.**

**Step 3:** Rename `hadLog` → `hadBrainEvent` on the public type
and at the single consumer.

**Step 4: PASS.**

**Pause for review.**

---

### Task 6.4: New `stopGuardrailReason` text

**Files:**
- Modify: `hooks/lib/messages.ts`
- Modify: `tests/hooks/messages.test.ts`

**Step 1: Failing test** — `stopGuardrailReason("claudecode")` now
contains the strings `"event_log_append"`, `"brain_apply_evidence"`,
and `"brain_feedback"` in that order. The Codex variant likewise.
The `unknown` branch contains all three but no cadence line.

**Step 2: FAIL.**

**Step 3:** Replace the body per the design doc §30 §A "Body text"
section. Keep cadence lines exactly as today.

**Step 4: PASS.**

**Pause for review.**

---

### Task 6.5: End-to-end guardrail scenarios

**Files:**
- Modify: `tests/hooks/stop-log-guardrail.test.ts`

**Step 1: Add scenarios** for the broadened semantics:
- artifact + `brain_feedback` MCP call → silent;
- artifact + `brain_apply_evidence` MCP call → silent;
- artifact + `o2b brain feedback` bash → silent;
- artifact + no brain-event → block with the new text;
- back-compat: artifact + `event_log_append` only → silent.

**Step 2: Run** `bun test tests/hooks/`. Expected: all pass.

**Pause for review.**

---

## Phase 7 — §30 §C: SKILL + reminder reformulation

### Task 7.1: SKILL frontmatter description

**Files:**
- Modify: `skills/brain-memory/SKILL.md`
- Create: `tests/skills/brain-memory.test.ts`

**Step 1: Failing test** — reads `skills/brain-memory/SKILL.md`,
asserts the frontmatter `description:` contains the new
"speculative" wording verbatim and does NOT contain "A misrecorded
signal is worse than a missed one".

**Step 2: FAIL.**

**Step 3:** Rewrite the `description:` field per the design doc
§30 §C. Keep all other frontmatter keys unchanged.

**Step 4: PASS.**

**Pause for review.**

---

### Task 7.2: SKILL body — "When NOT to call" rewrite

**Files:**
- Modify: `skills/brain-memory/SKILL.md`
- Modify: `tests/skills/brain-memory.test.ts`

**Step 1: Failing test** — the body no longer contains
"any case where you are not confident — skip rather than fabricate"
and now contains the speculative-record bullet from the design.
Closing sentence "A false-positive signal eventually distorts the
rule set; a missed signal is recovered on repeat. Prefer
precision." is absent.

**Step 2: FAIL.**

**Step 3:** Apply the body edits per design §30 §C.

**Step 4: PASS.**

**Pause for review.**

---

### Task 7.3: `postWriteReminder` tail update

**Files:**
- Modify: `hooks/lib/messages.ts`
- Modify: `tests/hooks/messages.test.ts`

**Step 1: Failing test** — `postWriteReminder({ … runtime: "unknown" })`
no longer contains "A misrecorded signal is worse than a missed
one" and now contains the speculative-record phrasing.

**Step 2: FAIL.**

**Step 3:** Edit the final paragraph in `postWriteReminder`.

**Step 4: PASS.**

**Pause for review.**

---

## Phase 8 — §14 polish, part 1: listbox accessibility

### Task 8.1: Listbox DOM and styles

**Files:**
- Modify: `templates/brain-explorer.html`
- Modify: `tests/core/brain/explorer.test.ts` (or add a sibling
  `tests/core/brain/explorer-template.test.ts`)

**Step 1: Failing test** — `renderExportedHtml(graph)` output
contains:
- `id="node-list"`,
- `role="listbox"`,
- `aria-activedescendant=""`,
- a `Reset layout` button.

**Step 2: FAIL.**

**Step 3:** Add the listbox markup, CSS, and "Reset layout" button
per design §14 "Listbox accessibility". The existing
`#details` content slides into `#details-body`.

**Step 4: PASS.**

**Pause for review.**

---

### Task 8.2: `renderNodeList` + filter integration

**Objective:** Make the listbox track visible nodes.

**Files:**
- Modify: `templates/brain-explorer.html`

**Step 1: No automated test** (browser-side rendering). Manual QA
steps documented in the PR description. Add an inline note in the
template's `applyFilters` function pointing at the new
`renderNodeList()` call.

**Step 2:** Implement `renderNodeList()` per design — sorts by
`topic` then `id`, builds `<li role="option" id="node-opt-${id}"
aria-selected="false">${topic} — ${principle}</li>`. Called from
`applyFilters`.

**Step 3:** Manual smoke: `o2b brain explorer` against a small
fixture vault; listbox populates on first paint and on filter
input.

**Pause for review.**

---

### Task 8.3: Keyboard handlers

**Files:**
- Modify: `templates/brain-explorer.html`

**Step 1:** Implement handlers per design §14:
- ArrowUp / ArrowDown (with wrap-around);
- Home / End;
- Enter / Space → `selectNode(focusedNodeId)`;
- Escape → reset focus + empty `#details-body`;
- listbox focus shows visible outline.

**Step 2:** Manual QA against the live vault. Note in the PR
description: "tested ArrowUp/Down/Enter/Escape/Home/End under
Chromium, listbox is reachable via Tab from the search input".

**Pause for review.**

---

### Task 8.4: Unify canvas + listbox via `selectNode(id)`

**Files:**
- Modify: `templates/brain-explorer.html`

**Step 1:** Refactor existing `canvas.addEventListener("click", …)`
handler so it calls `selectNode(node.id)` instead of inlining
`selectedId = …; renderPanel(…)`. `selectNode` updates
`selectedId`, calls `renderPanel`, scrolls the listbox to the
matching `<li>`, updates `aria-activedescendant`, redraws canvas
highlight.

**Step 2:** Manual QA: click on canvas updates listbox visible
selection and vice versa.

**Pause for review.**

---

## Phase 9 — §14 polish, part 2: layout persistence

### Task 9.1: localStorage schema + helper

**Files:**
- Modify: `templates/brain-explorer.html`

**Step 1:** Define inline:

```js
const STORAGE_KEY = "osb-explorer-layout:" + (graph.vault_basename || "default");
const SCHEMA = 1;

function loadStored() { /* try/catch JSON.parse, schema check */ }
function saveStored(state) { /* try/catch JSON.stringify + setItem */ }
```

Failures from `localStorage` (quota, private mode) are caught and
the helpers fall back to no-op.

**Step 2:** Manual QA: open DevTools → Application → Local Storage
and confirm the key materialises on first load.

**Pause for review.**

---

### Task 9.2: Boot order — restore positions

**Files:**
- Modify: `templates/brain-explorer.html`

**Step 1:** After `placeInitial()` and before the first physics
step, walk the stored positions: for each node, if
`stored.positions[id]` exists, set `n.x / n.y` to the stored coords
and zero `vx / vy`. Apply stored filters before `applyFilters()` is
called.

**Step 2:** Manual QA: reload the page, layout stays near previous
positions.

**Pause for review.**

---

### Task 9.3: Save triggers

**Files:**
- Modify: `templates/brain-explorer.html`

**Step 1:** Wire three triggers:
1. `window.addEventListener("pagehide", saveLayout)`;
2. Filter / search inputs `change` event → debounced
   `saveLayout` (500 ms);
3. Once the physics damps (`∑|v| < 0.5` over the last frame) →
   `saveLayout` and clear a `pendingSave` flag.

**Step 2:** Manual QA: change a filter, reload, filter state
restores. Move a node by drag (if drag exists) or wait for
stabilisation, reload, positions stick.

**Pause for review.**

---

### Task 9.4: "Reset layout" button wiring

**Files:**
- Modify: `templates/brain-explorer.html`

**Step 1:** `document.getElementById("reset-layout").addEventListener("click", () => { localStorage.removeItem(STORAGE_KEY); location.reload(); });`

**Step 2:** Manual QA: click button, page reloads with fresh
`placeInitial` layout.

**Pause for review.**

---

## Phase 10 — §28 export

### Task 10.1: `exportPreferencesJson`

**Files:**
- Create: `src/core/brain/export.ts`
- Create: `tests/core/brain/export.test.ts`

**Step 1: Failing tests**:
- empty preferences/ → `preferences: []`, valid `generated_at`,
  `vault_basename`;
- mixed statuses (confirmed / unconfirmed / quarantine + one
  retired + one signal in inbox) → exactly the three active rows
  appear, retired and signal excluded;
- row fields match the design's `ExportedPreferenceRow` shape;
- two runs over the same vault return identical `preferences`
  arrays (ordering by `id` ascending).

**Step 2: FAIL.**

**Step 3:** Walk `Brain/preferences/`, `parsePreference`, sort by
`id`, project to the export row shape. Body text after frontmatter
captured via existing `parsePreference` return (extend if it
currently drops the body — confirm before coding). Use
`vaultDisplayName` from `init.ts` (lift to a small shared util if
needed; preserve the existing exporter elsewhere) for
`vault_basename`.

**Step 4: PASS.**

**Pause for review.**

---

### Task 10.2: `exportPreferencesLlmsTxt`

**Files:**
- Modify: `src/core/brain/export.ts`
- Modify: `tests/core/brain/export.test.ts`

**Step 1: Failing tests**:
- empty preferences → only the H1 + summary block, no H2 sections;
- mixed statuses → three `## Confirmed / ## Unconfirmed / ##
  Quarantine` sections in fixed order;
- only one non-empty status → only that section appears;
- a row's bullet is exactly
  `- <pref-id> (topic: <topic>, scope: <scope>): <principle>`;
- scope missing → omit the `, scope:` slot.

**Step 2: FAIL.**

**Step 3:** Implementation follows the design doc §28 "llms-txt
shape" verbatim. Use template literals; no external markdown lib.

**Step 4: PASS.**

**Pause for review.**

---

### Task 10.3: `cmdBrainExport` CLI

**Files:**
- Modify: `src/cli/brain.ts`
- Create: `tests/cli/brain-export.test.ts`

**Step 1: Failing tests**:
- `o2b brain export --format json --vault <path>` over a fixture
  prints JSON to stdout; parse roundtrip works;
- `o2b brain export --format llms-txt --vault <path>` prints
  the expected H1 + sections;
- `--out <path>` writes a file; refuses to overwrite without
  `--force`;
- missing `--format` → exit 2 with usage hint.

**Step 2: FAIL.**

**Step 3:** Add `cmdBrainExport(argv)`. Flags per the design.
Single-line JSON, trailing newline on llms-txt. Wire into the
dispatcher (`case "export": return await cmdBrainExport(rest);`).

**Step 4: PASS.**

**Pause for review.**

---

### Task 10.4: Help text wiring

**Files:**
- Modify: `src/cli/brain.ts` (`BRAIN_HELP`, `VERB_HELP.export`)

**Step 1:** Update help strings per the design doc §28 "VERB_HELP".

**Step 2:** Run `o2b brain --help` and `o2b brain export --help`
manually to confirm. Run the help-text test from Task 10.3 if
written there; otherwise add one assertion.

**Pause for review.**

---

## Phase 11 — §31 CR cleanup

### Task 11.1: MD040 fix in `docs/plans/2026-05-18-brain-maturity-design.md`

**Files:**
- Modify: `docs/plans/2026-05-18-brain-maturity-design.md`

**Step 1:** Read the file, identify every fenced code block lacking
a language tag. For each block:
- shell / bash output → ` ```bash`
- TypeScript → ` ```ts`
- YAML → ` ```yaml`
- Markdown samples → ` ```markdown`
- JSON examples → ` ```json`
- Plain output / ASCII tables → ` ```text`

**Step 2:** Apply edits. No `sed` — block-by-block review.

**Step 3:** Visually skim the rendered Markdown (in Obsidian or VS
Code preview) to confirm no syntax highlighting regressions.

**Pause for review.**

---

### Task 11.2: MD040 fix in `docs/plans/2026-05-18-brain-maturity-impl.md`

Same procedure as Task 11.1 against the impl plan file.

**Pause for review.**

---

### Task 11.3: MD040 fix in `skills/embeddings-setup/SKILL.md`

Same procedure. SKILL.md is user-facing through the skill scanner —
extra care that fenced-language hints are *correct*, not "guessed".

**Pause for review.**

---

### Task 11.4: macOS shim — `DYLD_LIBRARY_PATH=""` test

**Files:**
- Modify: `tests/scripts/macos-sqlite-shim.test.ts`

**Step 1: Add failing test** at the end of the `describe(...)`
block:

```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
});
```

**Step 2: Run** `bun test tests/scripts/macos-sqlite-shim.test.ts`.
Expected: PASS on the first run (the shim already handles `${VAR+set}`
per v0.10.5 CR fix — this test ratchets the contract). If it fails,
the shim has regressed and that becomes the bug to fix.

**Pause for review.**

---

## Phase 12 — Plans, summary, CHANGELOG, version

### Task 12.1: Update `CHANGELOG.md`

**Files:**
- Modify: `CHANGELOG.md`

**Step 1:** Insert the new `## [0.10.6] - 2026-05-18` block above
`## [0.10.5]` per design doc "CHANGELOG" section. No `[Unreleased]`
heading (project convention). Add the link-footer line
`[0.10.6]: https://github.com/itechmeat/open-second-brain/compare/v0.10.5...v0.10.6`.

**Step 2:** Run `grep -n "Unreleased" CHANGELOG.md`. Expected:
zero matches.

**Pause for review.**

---

### Task 12.2: Update `_summary.md` (vault, plain Markdown edit)

**Files:**
- Modify: `/root/vault/Projects/OpenSecondBrain/Features/_summary.md`

**Step 1:** Apply marks per design "Updates to plans / summary"
section:
- §5: mark fully shipped (manifest + abort drift detection).
- §14: append v0.10.6 keyboard + layout-persistence note. Remove
  the two matching items from `## Deferred work`.
- §22, §28: prepend `✅ … *(implemented in v0.10.6)*`.
- §30: mark §A + §C shipped, leave §B / §D / §E open.
- §31: mark §31.1 + §31.2 closed, §31.3 subsumed by §14 polish.

**Step 2:** Russian prose stays Russian; no em-dashes (project
convention from user memory).

**Pause for review.**

---

### Task 12.3: Update `Plan/3` and `Plan/4` (vault)

**Files:**
- Modify: `/root/vault/Projects/OpenSecondBrain/Plan/3. Agent logging discipline.md`
- Modify: `/root/vault/Projects/OpenSecondBrain/Plan/4. v0.10.5 review followups.md`

**Step 1:** In `Plan/3`, add a top-of-doc `shipped:` frontmatter
line listing `§A, §C (v0.10.6)`. Mark §B, §D, §E with `status:
open`.

**Step 2:** In `Plan/4`, mark §1, §2, §4 as shipped in v0.10.6.
§3 already marked done.

**Pause for review.**

---

### Task 12.4: Bump `package.json` version

**Files:**
- Modify: `package.json`

**Step 1:** Change `"version": "0.10.5"` → `"version": "0.10.6"`.

**Step 2:** Run `bun run sync-version` (or the equivalent script
that propagates version constants — check `scripts/sync-version.ts`
for outputs). Expected: any mirrored constants update consistently.

**Step 3:** Run `bun run sync-version:check`. Expected: PASS.

**Pause for review.**

---

### Task 12.5: Full-suite verification

**Steps:**

1. `bun run typecheck` — zero errors.
2. `bun test` — every test passes; warnings only on the legacy
   snapshot path stderr line are acceptable and expected.
3. `o2b brain upgrade --dry-run --vault <fixture>` — manually
   inspect the diff. Expected: only `_BRAIN.md` shows an `update`
   on the v0.10.5 fixture (the two added CLI lines).
4. `o2b brain export --format llms-txt --vault <real-vault>` —
   manually inspect the output for shape.
5. Open `o2b brain explorer --port 7777` against a local vault.
   Tab into the listbox, confirm Arrow keys move focus and Enter
   selects. Reload, confirm layout persists.

**Pause for final review.** Hand the diff to the user for
inspection; they decide on the commit / push / tag flow.

---

## Final checklist

- [ ] All new tests pass under `bun test`.
- [ ] `bun run typecheck` clean.
- [ ] `o2b brain upgrade --dry-run` shows the expected diff on the
      v0.10.5 fixture.
- [ ] `o2b brain export --format json|llms-txt` round-trips.
- [ ] `o2b brain rollback` aborts on drift; `--force-rollback`
      proceeds and logs `drift_overridden: true`.
- [ ] Stop guardrail blocks when none of three brain-events fired;
      stays silent when any one fired.
- [ ] `skills/brain-memory/SKILL.md` and `hooks/lib/messages.ts`
      use the new "speculative" wording.
- [ ] Brain Explorer keyboard listbox reachable via Tab; arrows /
      Enter / Escape behave per design.
- [ ] localStorage layout + filter persistence works in Chromium.
- [ ] CHANGELOG `[0.10.6]` block present, no `[Unreleased]`.
- [ ] `_summary.md`, `Plan/3`, `Plan/4` updated and consistent.
- [ ] `package.json` version `0.10.6`; `sync-version:check` green.
- [ ] No git operations performed by the implementer; the diff is
      handed to the user for commit / push.
