# v0.10.10 — pull-channels for runtimes without SessionStart

## 1. Problem

The current `Brain/active.md` digest is reachable through two push
paths (Claude Code `SessionStart` hook, Codex identity-reminder
injection) and one MCP resource (`osb://preferences/active`). That
leaves three concrete gaps:

1. **MCP hosts that ignore resources.** Cursor, Aider, raw Claude API
   and similar runtimes can call MCP *tools* but do not auto-load MCP
   resources. Without a tool surface for the active digest, those
   agents start every session blind to confirmed preferences.
2. **`brain_note` has no non-MCP entrypoint.** Cron jobs and shell
   scripts cannot reach the `Brain/log/<date>.md` `note` event kind
   that `brain_note` writes. They have to fall back to `vault-log` /
   `o2b append-event` (which write to `Daily/`), recreating the
   pre-v0.10.8 split the §32 deprecation was supposed to close.
3. **`o2b status` is silent about semantic search.** The CLI reports
   config presence but never hints that the vector index is disabled.
   Operators only discover that semantic is off when they specifically
   try `o2b search status` or `o2b search check`.

The shared theme is *pull*: when push channels are unavailable or
inapplicable, the agent or operator needs a small, discoverable surface
that returns the same information.

A fourth, orthogonal request lands in the same release: surface the
**Most-applied (30d)** preferences in `Brain/active.md`. The digest
currently shows what preferences *exist* but not which ones are
actually firing. A top-N section by `apply-evidence` events over a
sliding 30-day window gives both the agent (via the injected file)
and the operator (via reading active.md directly) a concrete signal
of which confirmed rules carry weight right now.

## 2. Goals

- Ship an MCP tool `brain_context` in the always-loaded writer-server
  scope that returns the current `Brain/active.md` body plus structured
  counts. Discoverable without `ToolSearch`, callable without
  arguments, idempotent.
- Extend `Brain/active.md` with a new `## Most-applied (30d)` section
  listing up to ten `confirmed` / `quarantine` preferences with the
  highest count of `apply-evidence (result: applied)` events whose
  `timestamp` falls inside the sliding 30-day window. Section is
  omitted entirely when the count is zero.
- Ship a CLI verb `o2b brain note "<text>"` that mirrors the MCP
  `brain_note` tool one-to-one for cron jobs and shell scripts. The
  CLI and the MCP handler share a single `appendBrainNote` core
  function.
- Extend `o2b status` with a single-line semantic-search hint. Print
  `semantic: off (run 'o2b search check' for setup steps)` in
  human-output and `semantic_enabled` / `embedding_key_present` /
  `semantic_hint` keys in `--json` whenever `semantic_enabled` is
  `false` *or* the embedding key is missing.

## 3. Non-goals

- Auto-discovery of agent runtime to decide whether to advertise
  `brain_context` (Cursor vs Claude Code etc.). The tool is always
  advertised; runtimes with a working SessionStart hook can ignore it.
- Filter / pagination / scope arguments on `brain_context`. The tool
  returns the full `active.md` body; callers that want a slice already
  have `brain_query`.
- Configurable `Most-applied` window or limit. The 30-day window and
  the top-10 limit are baked in. If a user complaint surfaces, the
  block can grow a `_brain.yaml` knob in a later release.
- Multi-line `o2b brain note` (`--stdin` etc.). The MCP tool collapses
  newlines into a single line; the CLI matches that contract.
- Backfilling existing `Brain/log/<date>` files. The Most-applied
  section reads whatever the log walker already returns.
- A new MCP resource. `osb://preferences/active` already covers
  resource-capable hosts; the tool exists for hosts that ignore
  resources.
- Renaming the always-loaded MCP server. It is still named
  `open-second-brain-writer` even though one of its tools is now a
  reader. The rename is not worth the breaking change on existing
  client configs. A short comment in the file explains the
  asymmetry.

## 4. Architecture

### 4.1 Module layout

```text
src/core/brain/most-applied.ts      (new)    pure computation: log → ranked entries
src/core/brain/note.ts              (new)    shared appendBrainNote core
src/core/brain/active.ts            (edit)   call most-applied, render section, expose count
src/cli/brain/verbs/note.ts         (new)    CLI verb wrapping appendBrainNote
src/cli/brain.ts                    (edit)   dispatcher registers note verb
src/cli/brain/help-text.ts          (edit)   help line for `o2b brain note`
src/cli/main.ts                     (edit)   cmdStatus emits semantic line
src/cli/helpers.ts                  (edit)   resolveSemanticConfigState helper
src/mcp/brain-tools.ts              (edit)   toolBrainContext + toolBrainNote routed via core
src/mcp/tools.ts                    (edit)   WRITER_TOOL_NAMES adds brain_context
src/mcp/instructions.ts             (edit)   writer-server description mentions brain_context
hooks/lib/detect.ts                 (edit)   BRAIN_EVENT_BASH_NEEDLES adds "o2b brain note "
hooks/README.md                     (edit)   note CLI mentioned as third clear-trigger
docs/architecture.md                (edit)   writer-server now hosts one reader tool
CHANGELOG.md                        (edit)   new [0.10.10] entry
```

All edits are additive. The only non-additive surface is the
extraction of the `brain_note` body into `appendBrainNote` — the MCP
handler shrinks to a thin wrapper around the same function the CLI
calls.

### 4.2 Data flow

```text
Brain/log/*.md|.jsonl  ──► readLogDay (existing) ──► most-applied ──► render
Brain/preferences/*.md ──► parsePreference (existing) ──┘
Brain/retired/*.md     ──► parseRetired (existing) ─────► render

regenerateActive ──► renderBody ──► atomicWriteFileSync ──► active.md

brain_context (MCP) ──► regenerateActive (self-heal) ──► read active.md + computeBrainStatus
o2b brain note      ──► appendBrainNote ──► appendLogEvent (existing)
brain_note (MCP)    ──► appendBrainNote ──► appendLogEvent (existing)
o2b status          ──► discoverConfig + resolveSemanticConfigState
```

There is no new persistent state. The Most-applied section is fully
derived from existing log files at render time.

## 5. `brain_context` MCP tool

### 5.1 Surface

```text
name:        brain_context
description: Pull the current Brain/active.md body plus active-preference
             counts. Use at session start when SessionStart hook is
             unavailable (Cursor, Aider, raw Claude API).
inputSchema: { type: "object", properties: {}, additionalProperties: false }
result:
  {
    vault_path:    string,         // ctx.vault, absolute
    present:       boolean,        // Brain/ directory exists
    active_path:   string,         // absolute path to Brain/active.md
    content:       string,         // active.md as it lives on disk, "" if absent
    counts: {
      confirmed:          number,
      quarantine:         number,
      retired_recent:     number,
      most_applied_30d:   number
    },
    generated_at: string | null,   // active.md frontmatter; null when file absent
    error?:       string           // present only when self-heal regen failed
  }
```

### 5.2 Behaviour

1. If `Brain/` is not present in the vault: return
   `present: false`, `content: ""`, `counts` all zero,
   `generated_at: null`. No error. New vaults are a valid state for
   this tool to describe.
2. Otherwise: call `regenerateActive(vault)`. That function is
   idempotent — it writes only when the body differs from disk, so
   calling it on every `brain_context` invocation costs at most the
   preference + retired + log walk it would do during dream. Use the
   returned `counts` directly. If the call throws (disk full,
   permission error), return `present: true`, `content: ""`,
   zero counts, `generated_at: null`, and the error message in
   `error: string`.
3. Read `Brain/active.md` from disk and parse its frontmatter to
   pull `generated_at`. The file is guaranteed to exist after step
   2. Return the full body in `content`.

The tool never *writes anything new* on its own — `regenerateActive`
either no-ops (file body unchanged) or self-heals a missing /
out-of-date file. Two consecutive calls against an unchanged Brain
return byte-equal payloads (the `generated_at` reflects the file's
frontmatter, not the wall clock at the second call).

### 5.3 Scope

Added to `WRITER_TOOL_NAMES` so it loads in the
`open-second-brain-writer` MCP server (the always-loaded scope).
A code comment at the set declaration explains why a read tool now
lives in the "writer" scope (always-load semantics, not write
semantics).

### 5.4 Idempotence

See §5.2 — the property falls out of `regenerateActive`'s existing
no-op-on-equal-body contract.

## 6. `Most-applied (30d)` section in `active.md`

### 6.1 Computation: `computeMostApplied`

Signature:

```ts
export interface MostAppliedEntry {
  readonly preference: BrainPreference;
  readonly applied_30d: number;
}
export function computeMostApplied(
  vault: string,
  preferences: ReadonlyArray<BrainPreference>,
  opts?: { readonly now?: Date; readonly limit?: number },
): ReadonlyArray<MostAppliedEntry>;
```

Algorithm:

1. `now = opts.now ?? new Date()`. `limit = opts.limit ?? 10`.
2. Window `[now - 30 * 24 * 3600 * 1000, now]` in milliseconds.
3. Build a `Map<string, BrainPreference>` keyed by
   `normaliseWikilinkTarget(pref.id)` from the `preferences` argument
   (already filtered to `confirmed | quarantine` upstream).
4. Walk `Brain/log/`. Use the same allow-list regex as `status.ts`
   (`/^\d{4}-\d{2}-\d{2}\.md$/`). Compute the file's date prefix
   integer (`YYYY-MM-DD`) and skip files whose date is more than one
   day older than the window start (one-day slack covers UTC drift).
5. For each remaining day: call `readLogDay(vault, date)` (existing
   JSONL-preferred reader). Iterate `entries`:
   - `e.eventType` must equal `BRAIN_LOG_EVENT_KIND.applyEvidence`.
   - `e.result` must equal `"applied"`.
   - `Date.parse(e.timestamp)` must lie inside the window.
   - `normaliseWikilinkTarget(e.preference)` must hit the preference
     map (otherwise the event belongs to a retired or unknown rule
     and the section excludes it by design).
6. Tally `Map<string, number>` keyed by the normalised preference id.
7. Sort descending by count, tiebreaker `id` ascending. Take `limit`.
8. Map back to `{ preference, applied_30d }`.

Error handling: a parse failure on one log day calls
`process.stderr.write` (matching `readLogDay`'s own warn-and-continue
contract) and moves on. The function never throws.

### 6.2 Render

In `renderBody`, between `Confirmed` and `Quarantine`:

```text
## Most-applied (30d) (<N>)

- `pref-foo` (scope: <scope>, applied_30d: <n>) — <principle>
- `pref-bar` (applied_30d: <n>) — <principle>
…
```

Section is omitted when `N === 0`. The `scope:` segment is omitted
when the preference has no scope.

### 6.3 Counts plumbing

`RegenerateActiveResult.counts` grows a fourth field:

```ts
readonly counts: {
  readonly confirmed: number;
  readonly quarantine: number;
  readonly retired_recent: number;
  readonly most_applied_30d: number;   // NEW
};
```

No external consumer of `RegenerateActiveResult.counts` exists today
outside the function's tests, so the addition is safe.

### 6.4 Idempotence

`renderBody` already compares byte-for-byte against the on-disk body
before writing. The Most-applied section is deterministic given
the same logs and the same `now` — but `now` advances. Two render
runs at different wall clocks against an unchanged log set produce
the same body iff no `apply-evidence` event crossed the window
boundary in between. That is the same idempotence model the rest of
`active.md` already lives by (the frontmatter `generated_at`
timestamp is the only intentional variance), so no special handling
is needed.

## 7. CLI: `o2b brain note`

### 7.1 Core extraction

The body of `toolBrainNote` (`src/mcp/brain-tools.ts:324-358`)
collapses to four steps: validate text, sanitise, resolve agent,
`appendLogEvent`. We extract those steps into
`src/core/brain/note.ts`:

```ts
export interface AppendBrainNoteInput {
  readonly vault: string;
  readonly text: string;
  readonly agent?: string;
  readonly now?: Date;
  readonly configPath?: string;
}

export interface AppendBrainNoteResult {
  readonly logged_at: string;
  readonly log_path: string;            // vault-relative
  readonly absolute_log_path: string;
  readonly agent: string;
}

export function appendBrainNote(
  input: AppendBrainNoteInput,
): AppendBrainNoteResult;
```

The function throws plain `Error` on empty/missing text (the MCP
wrapper translates that into `MCPError(INVALID_PARAMS, ...)`; the
CLI wrapper translates it into a 2-exit with the same message).

### 7.2 CLI surface

```text
o2b brain note <text> [--agent <name>] [--vault <path>] [--config <path>] [--json]
```

- `<text>` — single positional argument, required. Empty / whitespace
  only → exit 2 with `error: brain note requires text`.
- `--agent` — override identity. Defaults to
  `resolveAgentName(config)`, same as the MCP handler.
- `--vault` / `--config` — same shape as every other Brain verb.
- `--json` — emit `{logged_at, log_path, absolute_log_path, agent}`
  as a single JSON object to stdout. Default human form is
  `logged note at <log_path>\n`.

### 7.3 Hook detector

`hooks/lib/detect.ts:BRAIN_EVENT_BASH_NEEDLES` adds the string
`"o2b brain note "`. The trailing space prevents `o2b brain note-…`
or `o2b brain notes` from matching. The `BRAIN_EVENT_NAME_SUFFIX`
already covers the MCP-side `brain_note` tool name.

### 7.4 Help text

`src/cli/brain/help-text.ts` gains a `note` entry alongside
`feedback`, `apply-evidence`, etc. The description points operators
at the cron / shell-script use case.

## 8. `o2b status` semantic hint

### 8.1 Decision helper

A new helper in `src/cli/helpers.ts`:

```ts
export interface SemanticConfigState {
  readonly semantic_enabled: boolean;
  readonly embedding_key_present: boolean;
  readonly off: boolean;            // (!enabled) || (!key)
  readonly hint: string | null;     // human-friendly one-liner, or null
}

export function resolveSemanticConfigState(
  configData: Readonly<Record<string, unknown>>,
  env: NodeJS.ProcessEnv,
): SemanticConfigState;
```

Implementation matches the `truthy` predicate already in
`writeSearchInitBlock`. Both `cmdInit` and `cmdStatus` start calling
this helper; the duplicated truthy/key logic in
`writeSearchInitBlock` collapses onto the same source.

A small wrinkle: `o2b status` should not nag when search is disabled
outright (`search_enabled: "false"`). The helper exposes
`searchDisabledByConfig(configData)` so the caller can suppress the
hint in that branch. (Alternatively, the helper itself reads
`search_enabled` and returns `off: false, hint: null` when search is
disabled — simpler, picked.)

### 8.2 Human output

After the existing `config_keys:` block:

```text
semantic: off (run 'o2b search check' for setup steps)
```

Only when `off === true` AND `search_enabled !== "false"`. Otherwise
nothing.

### 8.3 JSON output

The `--json` payload grows three keys, always present:

```text
{
  "config_path": "...",
  "config_exists": true,
  "config_keys": [...],
  "vault": "...",
  "semantic_enabled": false,
  "embedding_key_present": false,
  "semantic_hint": "run 'o2b search check' for setup steps"
}
```

`semantic_hint` is `null` when `off === false`. Machine consumers can
either read the booleans directly or display the prebuilt hint.

## 9. Tests (TDD order)

Each test file is written *before* the matching implementation. The
order below matches the implementation order in §10.

1. `tests/core/brain/most-applied.test.ts` — drives §6.1.
2. `tests/core/brain/active.test.ts` extensions — drives §6.2,
   §6.3.
3. `tests/mcp/brain-context.test.ts` — drives §5.
4. `tests/core/brain/note.test.ts` — drives §7.1.
5. `tests/cli/brain-note.test.ts` (execFile-based) — drives §7.2.
6. `tests/hooks/detect.test.ts` extensions — drives §7.3.
7. `tests/cli/status.test.ts` — drives §8.
8. `tests/e2e/v0.10.10-pull-channels.test.ts` — one happy-path
   covering `o2b brain note` writing to `Brain/log/today.md` with
   correct identity and the `note` event kind appearing in the JSONL
   sidecar.

Coverage targets:

- `computeMostApplied`: window boundaries, retired exclusion,
  quarantine inclusion, wikilink line-range normalisation, malformed
  log day, empty vault.
- `appendBrainNote`: multi-line collapse, length cap, empty text
  rejection, agent resolution priority (explicit > config > env).
- `brain_context`: present/absent Brain, present/absent active.md
  with self-heal, idempotent payload, `most_applied_30d` count
  reflected.
- `o2b status`: human-output presence/absence under each of the four
  state matrices (enabled × key-present), JSON shape under same,
  suppression when `search_enabled` is `false`.
- `hooks/detect`: positive match for `o2b brain note "…"`, negative
  match for `o2b brain notes`, `o2b brain not-quite`.

## 10. Implementation order

1. Tests in §9.1 (most-applied), implementation in
   `src/core/brain/most-applied.ts`.
2. Tests in §9.2 (active.md), wiring in `src/core/brain/active.ts`.
3. Tests in §9.3 (brain_context), handler in
   `src/mcp/brain-tools.ts`, registration in `src/mcp/tools.ts`,
   instructions update in `src/mcp/instructions.ts`.
4. Tests in §9.4 (appendBrainNote core), extraction from
   `src/mcp/brain-tools.ts` into `src/core/brain/note.ts`. MCP
   handler refactored to thin wrapper; existing MCP tests stay
   green.
5. Tests in §9.5 (CLI verb), implementation in
   `src/cli/brain/verbs/note.ts`, dispatcher in `src/cli/brain.ts`,
   help text.
6. Tests in §9.6 (hook detector), needle list update.
7. Tests in §9.7 (`o2b status`), helper extraction in
   `src/cli/helpers.ts`, render update in `src/cli/main.ts`.
8. E2E in §9.8.
9. Documentation pass: `CHANGELOG.md`, `docs/architecture.md`,
   `hooks/README.md`, `src/mcp/instructions.ts`,
   `Projects/OpenSecondBrain/Features/_summary.md` (vault edit —
   operator-confirmed).

## 11. Vault summary update

After implementation lands, the vault-side
`Projects/OpenSecondBrain/Features/_summary.md` is updated in three
small places (operator-confirmed edit, no automated cron writes):

- §1 shipped-in header gains "+v0.10.10 Most-applied (30d) section".
- "Disputed / deferred" — the `brain_context` bullet is removed
  (no longer disputed).
- "Deferred work" — the §32B CLI bullet moves to a shipped reference
  pointing at the v0.10.10 release notes.

## 12. Deferred / out of scope for this release

These appear in `Brain/` / `_summary.md` `## Deferred work` only if
they remain open after this release ships. They are not part of the
v0.10.10 scope:

- Configurable Most-applied window (default 30d) or limit (default
  10) via `_brain.yaml`. Trigger: first operator request after
  shipping.
- Rename the always-loaded MCP server from
  `open-second-brain-writer` to something neutral
  (`open-second-brain-core` or similar). Trigger: a second reader
  tool joins the writer scope.
- `brain_context` filters (`scope`, `topic`). Trigger: a real agent
  use case where the full active.md is too large.
- `o2b brain note --stdin` for multi-line piping. Trigger: a real
  cron / script that wants multi-line. (MCP `brain_note` does not
  support multi-line either; the limitation is intentional.)
- Surfacing the Most-applied section through `brain_digest` as a
  second hot block alongside the existing `top_applied` (lifetime).
  Trigger: a digest reader who specifically wants both lifetime and
  windowed views in one render.

## 13. Risks

- **`now` injection in tests.** `computeMostApplied` accepts `now`
  via `opts.now` because the window calculation otherwise reads the
  system clock. Every caller in production passes the default
  (`new Date()`). Tests inject a fixed `now` for deterministic
  windowing. This is the same pattern `active.ts` already follows.
- **Reading 30 log days on every `regenerateActive`.** Worst case is
  30 JSONL files × ~50 entries each. `readLogDay` is JSONL-preferred
  and already part of the dream hot path. No observable regression.
- **Writer-server scope drift.** Adding a read tool to a server
  named "writer" is a naming inconsistency. Mitigation: comment at
  the `WRITER_TOOL_NAMES` declaration explaining the always-load
  rationale. Rename is deferred — see §12.
- **`o2b status` JSON shape grows three keys.** Existing consumers
  do not depend on the JSON shape today (verified via the absence
  of a `status --json` test outside `cli`). The three new keys are
  always-present booleans / nullable string, additive only.
