# Changelog

## 1.13.0 (2026-05-26): Track J bundled release (C5 + E2 + J3 + openclaw hygiene)

### Added

- **J3 reference-class / planning-fallacy detector** (Track J [next] from
  ROADMAP-RESEARCH.md L542). Reads from the E2 predictions table (shipped
  earlier this release cycle) and computes per-class base-rate stats so
  the calling agent can anchor on its past track record rather than the
  inside view (Lovallo-Kahneman 2003). Reactive in v1 (agent calls
  explicitly via MCP / CLI / HTTP / SDK); auto-injection on recall is a
  future episode. Surfaces:
  - CLI: `hippo predict baserate --class <c>` prints summary + 5 stats.
  - HTTP: `GET /v1/predictions/stats?class=X` returns
    `{ baserate: PredictionBaserate }` (Bearer-auth + tenant-scoped, 256
    char class cap).
  - MCP: new `hippo_predict_baserate` tool with description anchoring the
    use case ("call when you make a forward-looking claim..."). Returns
    text-only formatted response.
  - Python SDK: `Hippo.get_prediction_baserate(class_tag)` async +
    `HippoSync.get_prediction_baserate(class_tag)` sync mirror. New
    `PredictionBaserate` Pydantic model.
  - 1 new audit op `predict_baserate` lockstep across `src/cli.ts` +
    `src/server.ts` VALID_AUDIT_OPS + `src/audit.ts` AuditOp union per
    v1.11.5 CRIT A institutional rule. Emitted INSIDE
    `computePredictionBaserate` (single source of truth, no caller-site
    drift; folds plan-eng-critic round-1 HIGH advisory).
  - `PredictionBaserate` shape: `{ classTag, nClosed, nRatioEligible,
    meanEstimate?, meanActual?, meanRatio?, p50Ratio?, mae?, summary }`.
    Estimate=0 rows count in `nClosed` + MAE but excluded from ratio calc
    (avoids div-by-zero); `nRatioEligible` exposes the subset for
    consumer transparency.
  - Edge cases: `nClosed=0` returns null stats + empty summary;
    `closed-unknown` predictions excluded (no actual to compare);
    cross-tenant queries return empty. All covered by 8 store + 4 HTTP +
    3 MCP + 3 Python tests.
- **E2 prediction first-class object** (E2 row from ROADMAP-RESEARCH.md L314,
  Track J pre-req). New canonical `predictions` table (schema v29) for
  ex-ante claims that can be closed against ex-post outcomes. Enables J3
  (reference-class / planning-fallacy detector) as a follow-up episode.
  Surfaces:
  - CLI: `hippo predict "<claim>" --class X [--estimate N] [--unit U] [--target YYYY-MM-DD]`,
    `hippo predict close <id> --state closed|closed-unknown [--actual N] [--note "..."]`,
    `hippo predict list [--class X] [--status open|closed|closed-unknown|all]`,
    `hippo predict show <id>`.
  - HTTP: `POST /v1/predictions`, `GET /v1/predictions`, `GET /v1/predictions/:id`,
    `POST /v1/predictions/:id/close` (Bearer-auth, tenant-scoped, DoS-capped
    on claim 4096 chars + note 2048 chars).
  - Python SDK: `Hippo.predict()`, `predict_close()`, `list_predictions()`,
    `get_prediction()` (async); same on `HippoSync`. New `Prediction`
    Pydantic model.
  - 2 new audit ops: `predict_create`, `predict_close` (lockstep update
    across `src/cli.ts`, `src/server.ts` VALID_AUDIT_OPS sets + `AuditOp`
    union per v1.11.5 CRIT A institutional rule).
  - Each `hippo predict` writes both a memory mirror (tagged
    `['prediction', class_tag]`, recallable) AND a structured predictions
    table row (canonical for J3 base-rate queries). Memory + table dual
    write is atomic via `writeEntry`'s `afterWrite` hook inside SAVEPOINT
    `write_entry`.
  - Cross-tenant safety enforced by BEFORE INSERT + BEFORE UPDATE triggers
    on `predictions` (RAISE ABORT on tenant_id mismatch against referenced
    memory). Memory deletion (forget / consolidate / archive) gracefully
    orphans the prediction via `ON DELETE SET NULL` on `memory_id`; the
    predictions row survives with all fields populated.
  - Closure states: `open` | `closed` | `closed-unknown` (DB CHECK
    constraint + `VALID_CLOSURE_STATES` Set in `src/predictions.ts`).
    J3 computes accuracy from (estimate_value, actual_value) at query time.
- **C5 WYSIATI cutoff transparency** (Track C Pineal Gland, C5 [next] from
  ROADMAP-RESEARCH.md L219). New optional `RecallSuppressionSummary` on
  `RecallResult` with 6 counters describing what the recall pipeline
  excluded from `results[]`: `totalCandidates`, `droppedPreRank`,
  `droppedByBudget`, `summarySubstitutionsAdded`, `freshTailAdded`,
  `suppressedByInterference` (placeholder for future B4 vlPFC work, always 0
  in v1.12.13). Populated by all three recall surfaces: `api.recall` (HTTP
  /v1/memories + Python SDK), `cmdRecall` (CLI), and MCP `hippo_recall`.
  Each pipeline counts its OWN filter activity (per-path honest reports,
  not normalised cross-pipeline numbers); shape is identical across all
  three. CLI surfaces a WYSIATI line in `--why` text output and in JSON
  output. MCP appends a WYSIATI line to the text response when any
  counter is non-zero. New shared helper `buildSuppressionSummary` in
  src/api.ts.
- **Python SDK**: new `RecallSuppressionSummary` Pydantic model
  (snake_case Python attributes; camelCase wire format via `_Base` inherited
  `alias_generator=to_camel`). New optional `suppression_summary` field on
  the existing `RecallResult` model. Exported from `hippo_memory`.

### Tests

- New `tests/api-recall-suppression-summary.test.ts`: 8 cases covering
  shape presence, counter semantics, helper round-trip.
- New `tests/http-recall-suppression-summary.test.ts`: 4 cases covering
  wire-format parity and back-compat (pre-v1.12.13 payloads still parse).
- New `tests/mcp-recall-suppression-summary.test.ts`: 3 cases covering the
  MCP physics/hybrid pipeline parity (proof-of-fix for the plan-eng-critic
  round 1 CRIT: MCP returns MCP-pipeline counters, not api.recall counters).
- New cases in `python/tests/test_models.py`: round-trip + back-compat for
  `RecallSuppressionSummary` and nested-field parse on `RecallResult`.

### Back-compat

- The new field is optional everywhere. Pre-v1.12.13 server payloads
  (without `suppressionSummary`) still parse cleanly on both the TS
  `RecallResult` type and the Python SDK Pydantic model.

### Known limitation (v1.12.14 follow-up)

- MCP `suppressionSummary.droppedByBudget` is an UPPER BOUND, not an
  exact budget-cut count. On the MCP physics/hybrid pipeline, the
  difference `entries.length - results.length` includes three things
  conflated: rows hybridSearch/physicsSearch internally dropped because
  they scored zero (no query match), rows the search engine filtered
  internally (e.g. superseded), and rows that genuinely didn't fit the
  `budget` token cap. For no-match or sparse-match MCP queries, this
  upper bound over-reports budget drops. Independent-review-critic and
  codex-review-critic both flagged this as non-blocking for v1.12.13;
  the honest fix needs `hybridSearch`/`physicsSearch` to expose their
  pre-budget-cut scored count. Tracked as TODO(c5.1) at
  `src/mcp/server.ts`.

## 1.12.12 (2026-05-26): bundled E1-E5 DAG live-coupling arc

Bundles 5 episodes of DAG live-coupling into one npm release. Makes the
existing DAG hierarchy (level 1 facts, level 2 topic summaries, level 3
entity profiles) "live" by wiring rebuild on child mutation and exposing
DAG metadata in recall.

For per-episode detail, see commits 1d5fa93 (E1), 8bed912 (E2 fold),
1022695 (E3), 486940b (E4), 3a6ec19 (E5). PRs #66 through #70.

### What ships

- **E1 schema v28**: 4 new memories columns (summary_dirty,
  last_rebuilt_at, rebuild_count, dag_level_3_built_at). Migration
  is idempotent ALTER + 0 default; safe to roll forward.
- **E2 child-write hooks**: 5 mutation paths fire markSummaryDirty
  on the parent summary (writeEntryDbOnly, supersede CAS,
  deleteEntry, archiveRawMemory, batchWriteAndDelete).
- **E3 sleep-cycle rebuild**: new rebuildDirtySummaries phase
  consumes the dirty queue, regenerates summary content via
  generateDagSummary, atomically refreshes 6 metadata columns,
  syncs the FTS index. Cap via HIPPO_DAG_REBUILD_CAP env (default
  20, hard ceiling 1000).
- **E4 first-class DAG recall**: scoring-layer deboost
  (HIPPO_SUMMARY_DEBOOST, default 0.85) on L2 + L3 summaries in
  hybridSearch and physicsSearch. Freshness micro-boost (1.05)
  when summary was rebuilt within 7 days. 6 new optional
  ScoreBreakdown fields visible with explain=true.
- **E5 level-3 entity profiles**: new buildEntityProfiles phase
  clusters L2 topic summaries by entity tag into L3 entity
  profiles (one per speaker / topic). drillDown gains
  depth?:number opt (default 1, backward compat, hard cap 10)
  with BFS visited-Set dedup. cmdDag tree view renders L3 roots
  with L2 children indented.

### API additions

- store.ts: loadDirtySummaries, loadAllDirtySummaries,
  loadAllL2Summaries, loadChildrenOfSummary, applyRebuildResult,
  markSummaryDirty, markSummaryDirtyInTx,
  clearSummaryDirtyAfterBuild (new 5th param source?:string)
- dag.ts: rebuildDirtySummaries, buildEntityProfiles,
  DagRebuildResult, EntityProfilesBuildResult
- api.ts: DrillDownOpts.depth?:number
- search.ts: isDagSummary (exported helper)
- consolidate.ts: ConsolidationResult gains 5 new fields
  (summariesRebuilt, summariesRebuildFailed,
  summariesZeroChildSkipped, summariesRebuildCapped,
  entityProfilesCreated)
- audit.ts: 2 new AuditOp variants (summary_marked_clean,
  summary_rebuilt)
- HTTP /v1/drill?depth=N and MCP hippo_drill { depth }

### Tests

- E1: 8 cases in tests/dag-dirty-flag-schema.test.ts
- E2: 10 cases in tests/dag-dirty-propagation.test.ts
- E3: 12 cases in tests/dag-rebuild-summaries.test.ts
- E4: 17 cases in tests/dag-recall-first-class.test.ts
- E5: 14 cases in tests/dag-e5-entity-profiles.test.ts
- Full suite: 1906 passed, 4 skipped, 0 failed

### Known follow-ups (deferred)

- E4.5: thread hybridSearch through api.recall for SDK callers
  (MCP + cmdContext already inherit)
- Sleep-cycle mutex for parallel sleep concurrency
- Differentiated L2 vs L3 deboost
- L3 substitution in api.recall overflow path
- buildEntityProfiles re-clustering of late-arriving L2s after L3
  formed

## 1.12.11 (2026-05-25): bundled E1-E5 ui-brain-observatory publish (Obsidian-inspired graph upgrades)

Bundles ui-brain-observatory v0.2.1 through v0.2.5 into one npm release.
The E1 through E4 commits landed on master at 8d6cc47 across PRs #60-#64
but were never `npm publish`ed. v1.12.11 makes the cumulative dashboard
work user-available alongside the v0.2.5 E5 release.

For per-episode detail, see the ui-brain-observatory entries below:
- v0.2.1 E1: tag-based coloring
- v0.2.2 E2: real edges (parents / conflicts / shared-tag pairs)
- v0.2.3 E3: local graph view (N-hop neighborhood)
- v0.2.4 E4: force-directed layout (replaces PCA on XZ)
- v0.2.5 E5: per-project anchor forces (this release)

No SDK / HTTP API / CLI / DB / migration / env changes. Pure UI bundle.

### What ships in the tarball

- `dist/` (SDK + CLI), `dist-ui/` (built dashboard), `bin/` (CLI shims).
- `prepublishOnly` runs `build:all` (root tsc + UI vite build).

## ui-brain-observatory v0.2.5 (2026-05-25): per-project anchor forces (Obsidian-inspired graph upgrades, E5 of 5)

Adds per-project anchor forces to the d3-force layout from E4. Each
unique `path:*` tag gets a stable position on a golden-angle-packed
circle around origin; memories carrying that tag get a gentle pull
toward it (strength 0.08 vs linkStrength 0.4 so edges still dominate).
A new Sidebar Projects mini-panel lets users read which cluster is which
project and click to filter. localStorage persists the project ordering
across sessions, append-only, so existing anchors stay byte-identical
when new project tags appear.

E5 closes the Obsidian-inspired graph upgrades stack. The E4 R2 critic
surfaced three anchor-specific HIGH issues (mass-resettle on new tags,
no in-app legend, no refresh signaling) that demanded proper design
budget; E5 was spun out of E4 at task #106 and addressed each.

### What shipped

**Pure helper `ui/src/state/projectAnchorOrder.ts`.**
- `loadProjectAnchorOrder()` reads `hippo:projectAnchorOrder:v1` from
  localStorage. Two-layer validation: try/catch for JSON.parse, plus
  explicit shape check (Array.isArray(tags), nextIndex is number, every
  inner tuple is `[string, finite-number]`). Inner-tuple validation
  defends against `new Map([['hello']])` silently producing
  `{key:'hello', value:undefined}` and NaN-propagating through anchor
  math.
- `saveProjectAnchorOrder()` JSON-serializes and writes. Silent no-op on
  QuotaExceededError or disabled storage. Layout still works
  session-local without persistence.
- `reconcileProjectOrder(currentTags, order)` appends NEW tags with
  `nextIndex` (alpha-sorted batch order), leaves existing indices
  unchanged. Returns SAME object reference when nothing changed, so
  callers can skip the save call via reference equality.
- `clearProjectAnchorOrder()` exported as `@internal` for tests / a
  future Reset Layout button (v0.3.0).

**Pure helper `ui/src/engine/projectAnchors.ts`.**
- `computeProjectAnchors(memories, order, bound, anchorStrength?)`
  returns `{byTag, byMemoryId, orderedTags}`.
- Anchor angle: `(index * GOLDEN_ANGLE) % 2π` where `GOLDEN_ANGLE = π *
  (3 - sqrt(5))`. Vogel sunflower spiral, dense + collision-free for any
  N up to ~50 indices. Each index has a permanent fixed angle, so
  adding new tags leaves existing anchor positions byte-identical (the
  AC20 stability guarantee, the structural fix for the E4 R2 issue v1
  reintroduced with a slotCount-mod formula).
- `EXCLUDED_PATH_TAGS = new Set(["path:skf_s"])` filters the filesystem
  root tag (60% of memories, would dominate layout).
- Per-memory anchor pick uses the shared `pickShortestPathTag` helper
  (path-mode dedup with `pickColorTag`).
- `orderedTags` lists only tags actually picked as the anchored tag for
  at least one memory, so the Sidebar legend never shows ghost rows for
  longer path tags subsumed by a shorter sibling
  (e.g. `path:hippo-tests` when `path:hippo` is the shorter winner).

**`ui/src/engine/tagPalette.ts` refactor.**
- New exported `pickShortestPathTag(tags, excludeSet?)` extracted from
  `pickColorTag`'s "path" branch. Shared with `computeProjectAnchors`
  (which passes `EXCLUDED_PATH_TAGS` as excludeSet). The "tag" branch
  keeps its inline filter (excludes path:\*, picks shortest non-path),
  structurally inverse of the path-tag rule and not worth a second
  shared helper for one caller.

**`ui/src/engine/forceLayout.ts` extension.**
- Exports `LAYOUT_BOUND = 30` constant. Consumers (scene.populate,
  computeProjectAnchors, ProjectsPanel mini-SVG) now share the magic
  number instead of duplicating `30`.
- `ForceLayoutConfig.projectAnchors?: Map<string, {x, y, strength}>`.
  When present and non-empty, registers `forceX` + `forceY` with
  per-node accessors (memories absent from the map get strength=0).
  Absent or empty config keeps E4 behavior byte-identical (back-compat).

**`ui/src/engine/scene.ts` wiring.**
- `populate()` loads the persisted order, reconciles current `path:*`
  tags, saves only when changed (reference-equality skip), computes
  AnchorLayout, caches it as `this.projectAnchorLayout`, and passes
  `projectAnchors.byMemoryId` to `buildForceLayout`. All synchronous,
  preserving the MUST-STAY-SYNCHRONOUS populate contract.
- New `getProjectAnchorLayout()` accessor returns the cached layout
  (same object reference until next populate, safe as a React useMemo
  dep).

**React state flow `useCanvasEngine.ts` + `LivingMap.tsx`.**
- `useState<AnchorLayout | null>(null)` in `useCanvasEngine`, set in the
  same effect as `setEdgeCounts` right after `scene.populate()` returns
  (the synchronous read on the same render). Returned from the hook.
  Ref-only reads would never trigger re-render; this is the structural
  fix for the CRIT-2 race the plan-eng R1 critic caught on v1.
- `LivingMap` destructures `projectAnchorLayout`, builds a memoized
  `projectsForSidebar` array (tag, count, anchor target), passes it to
  Sidebar.

**Sidebar Projects mini-panel `ui/src/components/ProjectsPanel.tsx`.**
- Lists projects in persistent-anchor-index order (first-seen across
  sessions). Subtitle `(ordered by first-seen)` so users aren't
  surprised by non-alpha order.
- Each row: 20x20 SVG with outer ring + dot at the anchor's screen
  position (`MINI_CENTER + (anchor.x / LAYOUT_BOUND) * MINI_INNER_RADIUS`),
  project name (with `path:` prefix stripped), memory count.
- Button has `aria-label="Filter to project X, N memor(y|ies)"` with
  grammatical pluralization. Decorative SVG is `aria-hidden`. Click
  fires `setQuery(tag)` to filter memories to that project.
- Caps display at 10 rows with passive `+N more` italic label for
  overflow (cluster still visible on canvas; expansion deferred to
  v0.3.0).
- Returns null on empty input so the panel hides itself when no
  qualifying path tags exist.

**`ui/src/components/Header.tsx` external sync mirror.**
- The external query sync effect now mirrors ANY non-equal external
  change into the local input (was: only empty-clear). Click on a
  ProjectsPanel row sets `filterState.query = "path:hippo"` via
  `setQuery`; the Header search input now visibly reflects the active
  query instead of staying blank. Race safety: mirrors only when
  `filterState.query !== debounced`, so during typing the local input
  never gets clobbered by an echo of its own pipeline.

**`ui/src/components/Sidebar.tsx`.**
- New `projects` prop. ProjectsPanel mounted between ViewPanel and the
  Filters header, matching the established between-section pattern from
  E1's ViewPanel insertion.

**`ui/src/tokens.css`.**
- New `.project-row:hover { background: var(--ink-faint); }` rule for
  the new Sidebar rows. Intentionally NO `:focus-visible` override on
  this class: the global `*:focus-visible` rule (2px outline) already
  covers it, and a more-specific rule would downgrade the sitewide
  focus indicator on these rows (a11y regression).

### Plan

`docs/plans/2026-05-25-project-anchors.md` (v1 -> v2 -> v2.1 across 2
plan critic rounds + 1 code-review round + 1 independent-review round +
1 ship-readiness round).

Major v1 -> v2 changes (R1 critic carry-over):
- Switched anchor angle from `slotCount = max(1, nextIndex)` mod-N
  formula (which shifts every existing anchor when N grows, the exact
  E4 R2 bug E5 was spun out to fix) to golden-angle packing where each
  index has a permanent angle independent of total count.
- Wired explicit React state for the AnchorLayout in `useCanvasEngine`
  (ref-only reads at render time would never re-render on populate).
- Extracted `LAYOUT_BOUND` so the bound is one source of truth for
  scene + computeProjectAnchors + ProjectsPanel mini-SVG.
- Added explicit JSON shape validation in load to defend against
  malformed payloads.
- Extracted shared `pickShortestPathTag` to remove the path-tag pick
  duplication between tagPalette and projectAnchors.
- Fixed `orderedTags` to include only actually-anchored tags.
- Added aria-label + aria-hidden + first-seen-order subtitle in
  ProjectsPanel.
- Fixed SVG mini-map geometry to a single coordinate system.

v2 -> v2.1 surgical (R2 PASS-with-must-fix carry-over):
- Scoped `pickShortestPathTag` to path-mode only; tag-mode keeps its
  inline non-path filter.
- Tightened load validation to verify every inner tuple is
  `[string, finite-number]`.
- Dropped a redundant `.project-row:focus-visible` override that would
  have downgraded the global focus standard.

Review fold-in (independent-review-critic must-fix):
- Header search input mirrors non-empty external query changes so the
  ProjectsPanel click affords visible feedback.
- Pluralization in aria-label: `1 memory` vs `N memories`.

### Critic record

| Critic | Round | Verdict | Score |
|---|---|---|---|
| plan-eng-critic | R1 | fail | 4 |
| plan-design-critic | R1 | PASS-with-fixes | 8 |
| plan-eng-critic | R2 | PASS-with-fixes | 7 |
| plan-design-critic | R2 | PASS-with-fixes | 8 |
| code-review-critic | R1 | fail | 7 |
| code-review-critic | R2 | PASS | 9 |
| independent-review-critic | R1 | PASS-with-fixes | 78 |
| ship-readiness-critic | R1 | PASS-with-fixes | 78 |

plan-eng R1 caught two CRIT issues that would have shipped a broken
stability guarantee: the slotCount-mod angle formula reintroduced the
exact mass-resettle bug E5 was created to fix, and the ref-read at
render time would never trigger a re-render on populate. Both fixed in
v2 (golden-angle + explicit useState).

code-review R1 caught that the plan's S7 test list (4 forceLayout
entries to lock AC10/AC11/AC12) was not actually added. R2 folded all
4 plus 7 direct pickShortestPathTag tests.

independent-review R1 caught the Header search-input desync (clicking a
ProjectsPanel row filtered memories silently, with no UI feedback in the
Header search box) and the ungrammatical `1 memories` aria-label. Both
folded in-stage.

### Tests

`npm test --run` from `ui/`: 220 / 220 pass across 14 test files.
- `projectAnchorOrder.test.ts` 16 new (load/save, append-only,
  reference-equality skip, JSON shape, inner-tuple corruption,
  QuotaExceededError silent-skip).
- `projectAnchors.test.ts` 10 new (path:skf_s filter, golden-angle
  formula, AC20 byte-identical stability, multi-tag shortest-wins,
  orderedTags anchored-only filter, strength override).
- `ProjectsPanel.test.tsx` 9 new (empty state, top-N truncation, +N
  more, aria-label with pluralization, SVG aria-hidden, dot position
  formula, click forwards tag, type='button').
- `forceLayout.test.ts` 5 new (LAYOUT_BOUND export, projectAnchors
  config pulls, back-compat byte equality, empty Map no-op, per-node
  strength=0).
- `tagPalette.test.ts` 7 new pickShortestPathTag direct (empty, no path,
  shortest, alpha tiebreak, excludeSet filter, excludeSet excluding
  all, undefined excludeSet).

`npm run build` clean (~1s). `npx tsc --noEmit` clean.

### Out of scope (named, deferred)

- Reset Layout button to clear localStorage ordering (v0.3.0).
- Per-project color customization in the Sidebar legend.
- Drag-to-rearrange projects on the canvas.
- BottomBar `anchors: N` clause.
- Animated transition when a new anchor appears.
- Hover-preview of project spheres on Sidebar-row hover (highlight
  spheres in canvas).
- Query input rewrite to a `project: X` chip pill (instead of raw
  `path:X` text).
- Cross-tab localStorage sync via the storage event.
- Cross-session anchor sync (cloud-persisted ordering).

## ui-brain-observatory v0.2.4 (2026-05-25): force-directed layout (Obsidian-inspired graph upgrades, E4 of 4)

Replaces the PCA-based 3D positioning with a d3-force layout driven by
the explicit edges from E2. Memories sharing conflicts or shared-tag
pairs pull together; isolated memories float at the periphery. Final
positions become a function of STRUCTURE, not embedding-variance.

E4 closes the Obsidian-inspired graph upgrades stack:
- v0.2.1 E1 color-by-tag (PR #61)
- v0.2.2 E2 real edges (PR #62)
- v0.2.3 E3 local graph view (PR #63)
- v0.2.4 E4 force-directed layout (this release)

A 5th episode, **per-project anchor forces (E5)**, was spun out of E4's
plan iteration (task #106) when scope grew too large for one episode.
That work will land separately.

### What shipped

**Pure helper `ui/src/engine/forceLayout.ts`.**
- `buildForceLayout(memories, adjacency, seedPositions, config)` factory.
- 2D simulation on the XZ plane; scene-Y stays as layer offset
  (LAYER_Y_OFFSET) so layer stratification is preserved.
- Forces: `forceLink` from E3 adjacency (strength 0.4, distance 2.5),
  `forceManyBody` (charge -30, Barnes-Hut quadtree), `forceCenter`
  (strength 0.05), `forceCollide` (radius 0.6).
- `alphaDecay` auto-aligned: `1 - Math.pow(alphaMin, 1 / maxTicks)` so
  alpha reaches `alphaMin=0.01` at exactly `maxTicks=300`.
- O(1) `position(id)` via internal `Map<string, ForceNode>`.
- `runToCompletion(maxTicks?)` bounded for freeze case (default 80 ticks,
  ~400ms worst-case main-thread block, within RAIL's acceptable band for
  deliberate user actions).
- `onSettleStateChange((settling, source: 'tick' | 'reduced-motion'))`:
  callback fires once on start, once on done. Source param lets the
  BottomBar suppress the affordance for reduced-motion users.
- Stale-adjacency filter: links whose endpoints are not in `memorySet`
  are dropped at init (no throw on deleted-memory race).

**Scene engine integration (`ui/src/engine/scene.ts`).**
- `BrainScene.populate` signature gains an `adjacency` arg; LivingMap
  owns the memoization and passes both to scene.populate (for force
  layout) AND to its own `localNeighborhood` useMemo (E3). Single
  `buildAdjacency` call per `[memories, conflicts]` change.
- `populate()` ends by building forceLayout from POST-jitter
  basePositions (first populate) OR cached `lastSettledPositions`
  (subsequent populates). Existing memories barely move; new ones
  settle into available space.
- `lastSettledPositions` pruned of deleted ids at populate-tail.
- `animate()` ticks force layout BEFORE drift physics; during settle,
  `mesh.position.copy(basePosition)` directly so the rendered mesh
  doesn't lag the force-driven basePosition update. After settle, the
  existing sin/cos drift resumes around the settled positions. Selection
  pulse and fading-ring billboard run unconditionally so a selected node
  keeps pulsing during settle.
- `setReducedMotion(true)` preserves the existing rafId cleanup
  (`cancelAnimationFrame + rafId = 0`) then `runToCompletion(80)` then
  applies the converged positions then `snapParticlesToFinal()`. Freeze
  pose is the converged layout, not a mid-settle interim.
- Scene-level `onSettleStateChange(cb)` owns its subscriber list and
  forwards from the current forceLayout. Re-subscribes on each populate.
  Replay-on-subscribe handles the React-effect-after-paint race.

**Layer-Y drop (the original "blue blob" root cause).**
- `scene.ts:275` y formula was `pos[1] * SPREAD * 0.5 + layerY + jitter`
  (PCA-y bled into vertical position). Now `layerY + jitter` only. The
  3D shape becomes a clean 3-tier layered slab with structural XZ
  clustering; layer stratification is unambiguous.

**React wiring.**
- `useCanvasEngine` accepts `adjacency` as a prop (owned by LivingMap),
  subscribes once to `scene.onSettleStateChange`, exposes `forceSettling`
  state (typed `"initial" | "refresh" | undefined`). The "initial" /
  "refresh" discriminator distinguishes first-load mass-drift from a
  memory-refresh re-settle.
- `LivingMap` builds `adjacency = useMemo(buildAdjacency, [memories,
  conflicts])` BEFORE the `localNeighborhood` + `visibleIds` useMemos so
  no TDZ on first render (code-review-critic R1 caught this exact crash
  when adjacency was originally hoisted to useCanvasEngine's return).
- `BottomBar.buildAffordance` gains a 4th `forceSettling` arg; when set,
  appends `· layout: settling (initial|refresh)`. Affordance div gets
  `max-width: 50%` + `text-overflow: ellipsis` + `overflow: hidden` so
  the worst-case 6-clause string doesn't crowd out the left/center
  regions.
- Reduced-motion users: `useCanvasEngine` checks `source ===
  "reduced-motion"` in the subscription and skips the React state
  update, so the affordance never appears for users who don't see the
  animation.

### Plan

`docs/plans/2026-05-25-force-layout.md` (v1 -> v2 -> v3 across 4 plan
critic rounds + 2 code-review rounds + 1 independent-review round).

Scope reduction in v3: per-project anchor forces were initially in scope
(Keith's option-3 pick) but the per-anchor design surfaced 3 separate
HIGH issues in R2 critic. Splitting to E5 (task #106) lowered cumulative
risk; E4 ships the lean d3-force replacement.

### Critic record

| Critic | Round | Verdict | Score |
|---|---|---|---|
| plan-eng-critic | R1 | fail | 50 |
| plan-design-critic | R1 | fail | 55 |
| plan-eng-critic | R2 | fail | 70 |
| plan-design-critic | R2 | fail | 70 |
| plan-eng-critic | R3 | fail | 78 |
| plan-design-critic | R3 | PASS | 82 |
| plan-eng-critic | R4 | PASS | 80 |
| plan-design-critic | R4 | PASS | 70 |
| code-review-critic | R1 | fail | 35 |
| code-review-critic | R2 | PASS | 90 |
| independent-review-critic | R1 | PASS | 85 |

code-review-critic R1 caught a real TDZ ReferenceError on `adjacency`
that tests + vite build both missed. R2 verified the flow-inversion fix.

### Tests

- `ui/src/engine/forceLayout.test.ts` (21 tests):
  - Factory contract (8 surface methods)
  - tick / done / runToCompletion (including freeze cap)
  - Clamp to bound on init + per-tick
  - Stale-adjacency filter (R4)
  - `settledPositions` snapshot is consistent with current node state
  - `onSettleStateChange` start + stop firing semantics
  - **Replay-on-subscribe**: subscribing while settling fires `true`
    immediately
  - `position(id)` O(1) microbench (10K calls <10ms)
  - Perf: 300 ticks on 1373-node + ~4000-edge fixture <2.0s with
    `mulberry32(42)` for determinism
- `ui/src/components/BottomBar.test.tsx` (5 new tests):
  - "initial" appends `layout: settling (initial)`
  - "refresh" appends `layout: settling (refresh)`
  - undefined emits no clause (reduced-motion path)
  - Composes after localView clause
  - Full 9-clause stack reads cleanly

Full repo: 1846 tests pass / 4 skipped / 252 test files.
UI: 173 tests pass / 11 test files.
Build: tsc --noEmit clean; vite clean (60 modules; three chunk 510KB no
regression).

### Out of scope (follow-ups)

- **E5: per-project anchor forces** (task #106): separate episode with
  own design budget for legend + anchor-order persistence + refresh
  signaling.
- 3D force (d3-force-3d): would break layer-Y semantic; defer.
- Web Worker offload: only if main-thread jank shows in smoke.
- User-adjustable force parameters: lock taste first.
- Animate transition INTO local view: separate animation system.
- Drag-to-pin (sticky node positions): defer.
- `projection.ts` deletion: kept as warm-start dependency.

## ui-brain-observatory v0.2.3 (2026-05-25): local graph view (Obsidian-inspired graph upgrades, E3 of 4)

Adds the Obsidian-most-used feature: click a memory, click the new
"focus" button in the DetailPanel, and the graph collapses to that
memory plus its N-hop neighborhood (default depth 2) via the explicit
edges added in E2. Esc clears both the focus AND the selection together.

E3 of 4 in the Obsidian-inspired graph upgrades stack:
- v0.2.1 E1 color-by-tag (PR #61)
- v0.2.2 E2 real edges (PR #62)
- v0.2.3 E3 local graph view (this release)
- v0.2.4 E4 force-directed layout (queued)

### What shipped

**`FilterState` extension.**
- New `LocalViewState { centerId, depth }` interface + `localView` field on
  `FilterState`. View AND filter state: included in `isFilterActive` (drives
  scene.setFiltered line-visibility filter) and cleared by `resetFilters`
  (matches "reset = back to full graph").
- `deriveVisibleIds` gains optional 3rd arg `localNeighborhood?: Set<string>`.
  When state.localView is set AND the Set is provided, intersects visible
  memories with the neighborhood. Safe degradation when set but Set is
  undefined.

**Pure helper: `ui/src/engine/localNeighborhood.ts`.**
- `buildAdjacency(memories, conflicts)` unions conflict pairs (both
  directions) + shared-tag pairs from E2's `computeSharedTagPairs`. Hoisted
  to a `useMemo` in LivingMap so it rebuilds only when memories or
  conflicts change (not per localView toggle).
- `computeLocalNeighborhood(adjacency, centerId, depth)` BFS visited-on-
  enqueue, depth-capped at 5 internally, with a NEIGHBORHOOD_CAP of 60
  nodes that triggers a depth-1 fallback (with `cappedFrom` payload) when
  exceeded. Pure + deterministic. Performance budget: <5ms BFS at depth=2
  on a 1373-memory + ~3000-edge adjacency (verified at test time).

**Scene engine extension (Obsidian default: both endpoints must be visible).**
- `BrainScene.setFiltered` extends to filter line visibility too: a line
  is visible only when both endpoint IDs are in the visible set. No frayed
  half-lines. Applies to conflictLines + sharedTagEdges + tendrils (the
  latter is defensive for E4 force-layout).
- `buildConflictLines` userData payload merged: `{status, aId, bId}`.
- `buildSharedTagEdges` userData payload added: `{aId, bId}`.

**`FocusButton` component (DetailPanel header LEFT, distinct from esc on right).**
- Three labels: `focus` (idle) / `focused` (this memory is the center, rust
  accent, non-interactive) / `recenter here` (focus active elsewhere, rust
  text). aria-labels disambiguate each state for screen readers.
- Click sets `{centerId: memory.id, depth: 2}`. Click on "focused" is a
  no-op (`if (isCenter) return`).

**BottomBar dynamic affordance.**
- `buildAffordance` gains an optional 3rd arg `localView?: {size, cappedFrom?}`.
  When local view is active, appends `view = local (N)` to the affordance
  string (or `view = local (N, capped from M)` when the depth-1 fallback
  triggered). Orientation cue when DetailPanel is closed.

**Esc-clears-both wire.**
- `onClickMemory(null)` is the convergence point for all 3 selection-clear
  paths (Esc key via scene.deselect, esc button via DetailPanel onClose,
  click-empty-space via scene.handleClick). All three paths clear both
  `selectedMemory` and `localView`. Stepped Esc deferred to v0.3.0+.

**Stale-centerId guard.**
- App.tsx `setLocalView` callback verifies `centerId` exists in current
  `memories` at set-time. LivingMap `useEffect` clears `localView` if the
  memory is later deleted from the visible set. Both windows covered.

### Plan

`docs/plans/2026-05-25-local-view.md` (v1 to v2 through 2 plan critic
rounds).

### Critic record

| Critic | Round | Verdict | Score |
|---|---|---|---|
| plan-eng-critic | R1 | fail | 62 |
| plan-design-critic | R1 | fail | 58 |
| plan-eng-critic | R2 | PASS | 88 |
| plan-design-critic | R2 | PASS | 84 |
| code-review-critic | R1 | PASS | 88 |
| independent-review-critic | R1 | PASS | 89 |

### Tests

- `ui/src/engine/localNeighborhood.test.ts`: 15 tests covering
  `buildAdjacency` (conflict-only / shared-tag-only / mixed / path-prefix
  exclusion), `computeLocalNeighborhood` (centerId-not-in-adjacency,
  depth=0, depth=1 on line graph, depth=2 on line graph, triangle via
  shared tags, determinism, HARD_DEPTH_CAP=5 enforcement), neighborhood
  cap fallback (3 cases: no-fallback / falls-back / under-cap), and AC8
  perf budget enforcement (<5ms BFS at depth=2 on 1373-node synthesized
  adjacency).
- `ui/src/state/filterState.test.ts`: 8 new tests for `localView`
  composition with other filters, `isFilterActive` branch, safe
  degradation, `resetFilters` clears, survives `setColorMode` change.
- `ui/src/components/BottomBar.test.tsx`: 5 new tests for the
  `view = local (N)` affordance clause, cap-fallback annotation,
  composition with edge counts + color mode, ordering before the bail
  hint.

Full repo: 1846 tests pass / 4 skipped / 252 test files.
UI: 147 tests pass / 10 test files.
Build: vite clean, 60 modules, three chunk 510KB (no regression vs E2).

### Out of scope (follow-ups)

- Depth slider (1-3): hardcoded depth=2 for v1; v0.3.0+.
- Stepped Esc (first clears focus, second clears selection): v0.3.0+ if
  user feedback wants separation.
- Animated zoom-to-cluster: v0.3.0 polish.
- Breadcrumb history (drill into a neighbor of a neighbor): v0.3.0+.
- Sidebar status row: BottomBar covers orientation; defer Sidebar
  duplicate.
- scene.ts unit tests: needs WebGL stubs; deferred.
- Conflict info in Drawer / MemoryTooltip for a11y completeness:
  deferred.

## ui-brain-observatory v0.2.2 (2026-05-25): real edges (Obsidian-inspired graph upgrades, E2 of 4)

Adds explicit edges to the 3D graph: open + resolved conflict lines (shape-
encoded by status), plus a new shared-tag edge class for pairs of memories
sharing >=2 non-path tags. Makes the live 1373-memory fixture visibly-edged
for the first time (1117 resolved conflicts now render where 0 did before).

E2 of 4 in the Obsidian-inspired graph upgrades stack:
- v0.2.1 E1 color-by-tag (PR #61)
- v0.2.2 E2 real edges (this release)
- v0.2.3 E3 local graph view (queued)
- v0.2.4 E4 force-directed layout (queued)

### What shipped

**BE: `listMemoryConflicts` `'*'` sentinel.**
- `src/store.ts:2030` adds a `'*'` sentinel that skips the WHERE-on-status
  clause. 4 SQL branches (tenanted vs unscoped, all-statuses vs specific).
  All 7 existing callers (cli x5, mcp x2, dashboard x1) pass `'open'` or
  default; the sentinel is purely additive.
- `src/dashboard.ts:75` uses `'*'` to fetch all conflict statuses for
  rendering; `dashboard.ts:155` explicitly filters `c.status === 'open'`
  for the `open_conflicts` badge stat so its meaning stays unchanged.

**UI: shared-tag edge engine.**
- New `ui/src/engine/sharedTagPairs.ts`: pure helper
  `computeSharedTagPairs(memories, opts)` with tiered cap (softCap=50,
  hardCap=300, perTagTopK=15). Tags under softCap enumerate all pairs;
  tags in 50-300 emit top-K strongest pairs (preserves `openclaw` 162,
  `claude-code-memory` 68); tags >=300 fully skipped (excludes `error`
  986, `git-learned` 669, `path:*` namespace tags). Pure + deterministic;
  same input -> same output.
- `BrainScene.buildSharedTagEdges()` bails on n>500 (matches existing
  tendril bail); HARD_EDGE_CAP=2000 protects against pathological filters.
  Renders `COLOR_EDGE` (#7a6f63 warm grey, distinct from COLOR_DIM and
  TAG_FALLBACK_COLOR) hairlines at opacity 0.18 + 0.04 * sharedCount
  (range 0.26-0.42; perceptible on parchment).

**UI: conflict edges status-aware.**
- `BrainScene.buildConflictLines()` encodes status in SHAPE (open dashed
  0.3/0.2, resolved dotted 0.05/0.15) not opacity. Opacity stays a score-
  scaled strength signal. Stores `status` on `line.userData` for use by
  `getEdgeCounts()`.

**UI: race-free affordance plumbing.**
- New `BrainScene.getEdgeCounts(): EdgeCounts` (public). useCanvasEngine
  reads it synchronously immediately after `populate()` so React state
  matches scene state without a getter-polling race. populate() has a
  MUST-STAY-SYNCHRONOUS JSDoc warning; getEdgeCounts mirrors the warning.
- `BottomBar.buildAffordance()` is a new pure function that builds the
  affordance string from the current `edgeCounts` + `colorMode`. Lists
  only edge classes that actually render right now; appends a "filter
  to <500 for tag edges" hint when shared-tag rendering bailed.
- Full prop chain: useCanvasEngine -> LivingMap -> BottomBar via the
  `EdgeCounts` interface exported from scene.ts.

**dispose() leak fix.**
- `BrainScene.dispose()` now disposes sharedTagEdges geometry+material on
  full unmount (independent-review-critic R1 catch; populate-teardown
  path was already correct).

### Tokens

- New `COLOR_EDGE = '#7a6f63'` / `COLOR_EDGE_HEX = 0x7a6f63` in tokens.ts.
  Computed WCAG contrast vs parchment `#faf7f2` is 4.58:1 (above 3:1 non-
  text bar for the swatch; hairline composite at 0.26 opacity is sub-WCAG
  but perceptible via delta-E, accepted as a hairline trade-off).

### Plan

`docs/plans/2026-05-25-real-edges.md` (v1 -> v2 through 2 plan critic
rounds, final PASS score 82 each).

Scope reduction from Keith's original "BE producer backfill" pick:
discover overturned the premise. `parents` data is 0% populated across
all 1391 memories (no superseded_by, no dag_parent_id), so no source
data to backfill. Conflict producer already writes correctly; only
exposure was needed. Net scope ~1.5d (down from 3-4d).

### Critic record

| Critic | Round | Verdict | Score |
|---|---|---|---|
| plan-eng-critic | R1 | fail | 62 |
| plan-design-critic | R1 | fail | 58 |
| plan-eng-critic | R2 | **PASS** | **82** |
| plan-design-critic | R2 | **PASS** | **82** |
| code-review-critic | R1 | **PASS** | **88** |
| independent-review-critic | R1 | fail | 84 |
| independent-review-critic | R2 | **PASS** | **92** |

Independent-review-critic R1 caught a real dispose() leak that 4 prior
critic rounds missed. Fresh-eyes review earned its keep.

### Tests

- `tests/store-list-conflicts-all.test.ts`: 4 BE tests for the `'*'`
  sentinel (open-only, all-statuses, default, resolved-empty).
- `ui/src/engine/sharedTagPairs.test.ts`: 11 tests including determinism,
  excludePrefix, tiered cap math (under-softCap, soft-band-emits-top-K,
  hardCap-skips), sort stability, AC7 perf budget <50ms on 500-memory
  fixture (10 tags/memory).
- `ui/src/components/BottomBar.test.tsx`: 11 tests covering 4 affordance
  modes (base / open conflicts / resolved conflicts / shared tags /
  bail hint / color-mode carryover / undefined edges fallback).

Full repo: 1846 tests pass / 4 skipped / 252 test files.
UI: 119 tests pass / 9 test files.

### Out of scope (follow-ups)

- `parents` edges: no source data; deferred until supersede-chain
  producer added (separate BE epic).
- Sidebar toggle for edge-class visibility: v0.2.3+.
- Edge-hover tooltip ("these memories share [error, openclaw]"):
  needs canvas raycaster for THREE.Line picking; v0.2.3+.
- N-hop local view from selected: E3.
- Force-directed layout: E4.
- Pre-existing material-leak on tendril/conflictLine populate teardown
  (geometry-only dispose): separate ticket; unmount-path leak fixed
  here.
- `scene.test.ts` direct BrainScene tests: needs WebGL stubs; defer.
- Conflict info in Drawer / MemoryTooltip for a11y completeness: defer.

## ui-brain-observatory v0.2.1 (2026-05-24): color-by-tag (Obsidian-inspired graph upgrades, E1 of 4)

Adds a "Color by" segmented radio to the Sidebar so users can recolor the
3D memory graph by `tag` or project `path` instead of the default `layer`.
Addresses the "everything looks the same" problem on the live fixture
(1232 of 1373 memories are episodic, so layer-only mode reads as one blue
blob). Tag dimension (156 unique tags) was sitting unused.

E1 of 4 in the Obsidian-inspired graph upgrades stack (E2 real edges from
parents/conflicts/shared-tags, E3 local graph view, E4 force-directed
layout from real edges are queued).

### What shipped

- **3 view modes**: `layer` (default, unchanged), `tag` (top-10 non-path
  tags + grey fallback), `path` (top-8 project paths + grey fallback).
- **`ViewPanel` component** in the Sidebar above the Filters section.
  Three-button segmented radio with full ARIA semantics (radiogroup,
  role=radio, aria-checked, descriptive aria-labels).
- **Stable per-tag palette**: FNV-1a hash with linear-probe collision
  resolution into a 10-color (TAG) / 8-color (PATH) parchment-tuned
  palette. Same tag → same color across sessions. All palette colors
  verified >= 4.5:1 WCAG contrast vs `COLOR_MAP_BG` at test time.
- **Engine wiring**: `BrainScene.setColorMode(mode, memories)` recolors
  every node (sphere + halo material) in O(N) without rebuilding geometry
  or tendrils. `populate()` re-applies the current mode at its tail so
  memory refreshes never flash layer colors.
- **A11y non-color channel**: `MemoryTooltip` surfaces `color: <tag>` in
  tag/path modes; `Drawer` gains a conditional `tag` column (keyboard-
  navigable rows from E5) so color-blind / keyboard-only users have an
  equivalent channel.
- **`BottomBar` affordance key** appends ` · color = <mode>` when non-layer
  so the active encoding is always visible.
- **`resetFilters` preserves `colorMode`** alongside `frozen` (view state,
  not filter state).

### Plan

`docs/plans/2026-05-24-color-by-tag.md`: drafted v1 → v2 → v3 through
three full `/dev-framework-rl` plan critic rounds (plan-eng + plan-design,
final score 86 + 86). v3 dropped the proposed `confidence` mode after R2
caught unavoidable palette-hex collisions with the tag palette; confidence
remains a filter in the existing FilterPanel.

### Tests

- `tagPalette.test.ts`: palette engine determinism + top-N cap + include/
  exclude prefix + linear-probe collision uniqueness + pickColorTag rule
  (shortest tag wins, alpha tiebreak) + resolveColor dispatch + AC10
  contrast assertions for both TAG_PALETTE and PATH_PALETTE.
- `contrast.test.ts`: WCAG luminance + contrastRatio helpers.
- `ViewPanel.test.tsx`: 3-button render + aria-checked + click handler
  + radiogroup labelling.
- `filterState.test.ts`: colorMode is NOT in isFilterActive; deriveVisibleIds
  output is invariant under colorMode change.

97/97 tests pass; vite build clean (60 modules, three chunk 510KB =
baseline, no regression).

### Out of scope (follow-ups)

- E2 real edges from parents/conflicts/shared-tags
- E3 local graph view (N-hop neighbourhood from selected)
- E4 force-directed layout (replaces PCA proximity)
- Confidence-by-color mode (needs separate hue family, deferred)
- User-assignable per-tag colors (Obsidian color groups full parity)
- `colorMode` persistence across sessions (localStorage)
- `trace` layer color (Layer type doesn't include `trace`; 4 memories
  affected; separate ticket)

## ui-brain-observatory v0.2.0 (2026-05-24): hybrid-v4 parchment revamp + a11y

Final ship of the UI hybrid-v4 parchment revamp through 7 stacked PRs
(E0-E5) via `/dev-framework-rl` with all 5 critic gates per stage active.

### What shipped

- **Parchment aesthetic** end-to-end: warm `#f4efe6` bg, Georgia serif body,
  rust accent, three muted layer tints (buffer purple, episodic blue,
  semantic green), cross-hatch texture, framed map area.
- **Header chrome**: hippo + italic 'brain observatory' tagline, 305-count,
  fading badge, parchment search input with clear-X + `/` shortcut + Esc
  to clear, freeze button with F shortcut.
- **Sidebar (340px right)**: Memory Layers panel with per-layer bars,
  Selected memory hint, Filters with reset, Tag cloud with log-scaled
  weight + path:* demotion.
- **Map area framed** with persistent 1px border + map-bg tint + 24px gutter.
- **Three.js node rendering**: parchment clearColor, NoToneMapping,
  bloom disabled, solid spheres (emissive removed), subtle halos.
- **HTML serif label overlay** (E4 marquee): per-frame screen-projection
  via `BrainScene.onRender`, AABB collision avoidance, top-10 selection
  by `strength × log(retrievals+2)`, bracket-wrapped synthetic content
  filtered out.
- **BottomBar**: layer legend + kbd shortcuts (/search · esc clear · f freeze
  · click open · L list) + italic affordance key (size=retrievals
  opacity=strength lines=similarity).
- **Drawer mirror (E5)**: 280px slide-up table with id/content/layer/strength
  /age columns, keyboard nav (arrows + Enter + Esc), filter-aware row count,
  empty state with reset button. Always renders to DOM for SR access.
- **A11y full pass**: `<main>` landmark, role=search, role=region on canvas,
  aria-hidden on inner canvas, sr-only escape hatch, skip-link first
  focusable, 12 aria-labels on interactive elements, focus-visible 2px
  rust outline at WCAG AA contrast.
- **prefers-reduced-motion**: one-shot OS check seeds `frozen=true` on mount;
  freeze button gets explanatory title + sr-only hint when origin is OS.

### Performance — honest shortfall (per plan §S7 honest-reporting protocol)

**Lighthouse perf: 32/100. Target was 80. Shipping with known miss.**

Root cause: Three.js (510KB) + WebGL canvas init dominates LCP on
Lighthouse's simulated cellular3G profile. Real users on local hardware
experience near-instant load; the metric is worst-case simulated.

**v0.27 perf epic queued:** lazy-load Three.js behind splash; static
chrome prerender for first paint; preload three chunk; SW for repeat-visit.

Full audit: `docs/evals/2026-05-24-e5-lighthouse-report.md`.

### Tests

43 vitest specs (Header, FilterPanel, TagCloud, filterState). New components
(Drawer, SkipLink) ship without dedicated tests in v0.26 — deferred to v0.27
hardening pass. Manual keyboard + SR walkthrough verified.

### Critics that ran (all gates active)

- E5 plan: plan-eng-critic R1 82→R2 88 PASS; plan-design-critic R1 87→R2 92 PASS
- E4 retroactive: plan-design-critic R1 64→R2 84→R3 88 PASS; code-review-critic R1 72→R2 78→R3 88 PASS
- E0-E3 plan: plan-eng-critic R1 62→R2 78→R3 78 (cap-hit, human override)

### PR stack

- #53 E0 parchment tokens + test infra + baseline PNG
- #54 E1 wire tokens + delete dashboardHTML + drift guard
- #55 E1.5 BrainScene API extensions (setReducedMotion/setFiltered/onRender)
- #56 E2 Header + debounced search + freeze toggle + FilterState
- #57 E3 Sidebar (StatsPanel + FilterPanel + TagCloud) + 37 tests
- #58 E4 proper (4 HIGH bugs + label overlay + bottom-bar + map frame)
- #59 E5 a11y + drawer + lighthouse + final ship

### v0.27 follow-up epic

- E4 LOW MEDs: AABB layout thrash caching, vestigial width/height props,
  shared panelTitle const, topN JSDoc drift, global collision priority
- Perf: lazy-load Three.js, static prerender, preload chunk, service worker
- Drawer polish: sortable columns, hover preview, row highlight on map-hover
- Mockup-deferred: sortable timeline, minimap, snapshot button, drawer rich features

## 1.12.10 (2026-05-24): D1+D2+D3+D4+D5 design picks bundled ship

All five design decisions from `docs/design-decisions/2026-05-24-blocked-items.md`
authorized via Keith's "go with your picks for all 5" — shipped as one
release. Multi-tenant story now coherent end-to-end.

### D1 — `/v1/sleep` response shape redact-on-egress
- New `src/sleep-redact.ts`: `redactSleepResultForCaller(result, ctx)`
- Non-loopback non-self admin gets cross-tenant counters zeroed
  (`deduped.crossDups`, `.semDups`, `.epiDups`; `audit.errorsRemoved`,
  `.warningCount`; `ambient.totalMemories`, `.avgStrength`).
- Loopback admin + `__host__` system caller: pass-through unchanged.
- Per-invocation counters (`active`, `removed`, `mergedEpisodic`, etc)
  preserved regardless — they describe THIS call's work, not host-wide
  accounting.
- Layered defence: loopback-only gate is upstream today, so this is
  dead code until D3 ships non-loopback serving — lands now so the
  gate is in place when needed.
- Tests: `tests/sleep-redact.test.ts` (5 cases).

### D2 — Consolidate audit row tagged `__host__` synthetic tenant
- `api.sleep` consolidate row now tags `tenant_id='__host__'` instead
  of `ctx.tenantId`. Honest about scope: `api.sleep` is host-wide.
- `actor` field unchanged (operator traceable).
- New `triggeredByTenant` metadata field preserves the forensic trail.
- HTTP `/v1/audit?tenant=__host__` query param added so admins can
  query the synthetic tenant. Defaults to caller's `ctx.tenantId`.
- Tests: `tests/api-sleep-host-tenant.test.ts` (3 cases).
- **Breaking for audit consumers** querying consolidate rows by
  `tenant_id='default'` — switch to `'__host__'`. Test suite updated
  (4 existing tests across `api-sleep.test.ts`, `api-sleep-phase-faults.test.ts`,
  `server-audit-route-consolidate.test.ts`).

### D3 — Non-loopback serving: lock-step commitment
- New `docs/process/non-loopback-sequencing.md` — single-source-of-truth
  for what gates close BEFORE `HIPPO_BIND_ALL` (or equivalent) ships.
- Decision: no flag-first / "behind a feature flag" shipping. Lock-step
  every prerequisite first, then flip in one additive change.
- Current state: D1, D2, L9 (v1.12.1), M6 (v1.12.9) all `[x]`. Still
  `[ ]`: M7 timing, conflict-subsystem residue, the bind-flag design.
- No code change; commitment doc only.

### D4 — `hippo_peers` tenant-scope by default
- `listPeers(globalRoot?, tenantId?)` — when `tenantId` provided,
  filters global entries to that tenant before aggregating projects.
  Undefined = host-wide (back-compat).
- MCP `hippo_peers`: now passes ctx.tenantId. AI consumers see only
  their tenant's contributing projects.
- Dashboard: now passes its tenantId (matches `loadAllEntries(hippoRoot, tenantId)`
  already in scope).
- CLI `hippo peers`: tenant-scoped by default with `--all-tenants`
  opt-out for legacy host-wide view. Output label disambiguates the
  scope.
- Tests: `tests/shared-listpeers-tenant.test.ts` (5 cases).
- **Default behavior change** for MCP + dashboard + CLI. Operators
  genuinely needing cross-tenant peer discovery: `--all-tenants` on
  CLI, `listPeers(root, undefined)` programmatically, or direct SQL.

### D5 — 24h soak harness: confirmed no active "soak-tested" claim
- Grep of `docs/` / `ROADMAP*.md` / `README.md` confirmed: no active
  doc currently claims `soak-tested`. Historical `docs/plans/` files
  correctly call it `scaffold only`.
- Lock-step doc (D3) records the recommitment if 1.x→2.x looms.
- No code or doc change required.

### Tests
1842 passed / 4 skipped / 0 failed across 254 files (+8 since v1.12.9).

### Notes
- Bundled ship lets the multi-tenant story land coherently in one
  release rather than spread across 5 incremental patches.
- D1 + D3 are layered-defence work for the future non-loopback story;
  D2 + D4 are user-facing today (admins querying audit + MCP/CLI peer
  discovery semantics).
- 14th ship of the 2026-05-24 session arc.

## 1.12.9 (2026-05-24): `hippo audit prune --older-than` operator hygiene

Closes A5 v2 M6 from `TODOS.md`: "Audit log unbounded growth." The
`audit_log` table grows by one row per recall/write/outcome/sleep/
supersede/promote/forget/archive_raw/auth_revoke/auth_create call. On a
long-running deployment this accumulates to millions of rows. Keith's
own DB has `total_recalled: 17256` — every one of those left an audit
row.

### Shipped
- **`hippo audit prune --older-than <Nd> [--dry-run] [--tenant <t>]`**
  CLI. Accepts both plain integer days (`30`) and `d`-suffixed form
  (`30d`). Per-tenant by default (matches existing audit CLI
  conventions). `--dry-run` shows count without deleting (operator
  safety). `--json` for scripted callers.
- **`src/audit-prune.ts`** — `pruneAuditLog(db, opts)` helper +
  `parseOlderThanFlag()` + `computeCutoff()`. DELETE wrapped in
  BEGIN/COMMIT so a crash leaves audit_log consistent. The prune itself
  emits an `audit_prune` event with metadata
  `{cutoff, count, dryRun, olderThanDays}` so the maintenance op is
  itself recorded in the audit trail (regulatory floor friendly: even
  after pruning 90+ day rows, there's one audit_prune row left to find).
- **`audit_prune` added to AuditOp**. All 3 parallel allow-lists synced:
  `src/audit.ts` type union, `src/cli.ts` `VALID_AUDIT_OPS`,
  `src/server.ts` `VALID_AUDIT_OPS`. Per the new SKILL §3b
  parallel-allow-list audit step.

### Tests
- 23 unit cases in `tests/audit-prune.test.ts`: parseOlderThanFlag
  (12 cases), computeCutoff (2), pruneAuditLog (9 — delete + preserve,
  per-tenant isolation, dry-run, audit_prune metadata, zero-count
  prune, invalid input rejection, the audit_prune row not pruning
  itself).
- 8 CLI integration cases in `tests/audit-prune-cli.test.ts`: prune
  + report count, --dry-run, --json output, plain integer days,
  --tenant scope, missing --older-than, invalid --older-than, --help.

### Full suite
1829 passed / 4 skipped / 0 failed across 251 files.

### Notes
- Regulatory floor friendly: every prune emits an `audit_prune` row in
  the audit trail with cutoff + count, so the maintenance op itself is
  recorded.
- No schema change; `audit_log` table has existed since v16.
- No HTTP route — audit prune is an operator-CLI concern (matches the
  existing `audit list` shape).
- 13th ship of the 2026-05-24 session arc.

## 1.12.8 (2026-05-24): multi-workspace tenant-routing e2e coverage

Closes a coherent test story after this week's Slack multi-workspace
work: v1.12.5 (workspaces CLI) added the registration surface, v1.12.6 B4
fixed parse-failure tenant attribution, and v1.12.8 now locks the
happy-path e2e: registered team → webhook → memory written with the
mapped tenant_id.

### Shipped
- **`tests/slack-webhook-multi-workspace-tenant.test.ts`** (4 new cases):
  - Registered team routes the ingested memory to the mapped tenant
    (NOT HIPPO_TENANT fallback when slack_workspaces is non-empty)
  - Two-workspace isolation: team-A messages NEVER produce tenant-B
    memories, and vice versa (explicit cross-tenant leak guard)
  - Foreign team (not registered, table non-empty) does NOT leak into
    HIPPO_TENANT — fail-closed contract per v0.39 commit 3
  - Single-workspace install (empty slack_workspaces) preserves
    HIPPO_TENANT fallback ergonomics

### Coverage map after this release

| Layer | Test |
|---|---|
| `resolveTenantForTeam` unit | `tests/slack-tenant-routing.test.ts` |
| Workspaces CLI add/list/remove unit | `tests/slack-workspaces.test.ts` |
| Workspaces CLI integration | `tests/slack-workspaces-cli.test.ts` |
| Unroutable foreign team → DLQ | `tests/v039-slack-hardening.test.ts` |
| Parse-failure tenant attribution | `tests/slack-webhook-parse-failure-tenant.test.ts` |
| **e2e happy path + isolation** | **`tests/slack-webhook-multi-workspace-tenant.test.ts` (NEW)** |

### Tests

1795 passed / 4 skipped / 0 failed across 249 files.

### Notes

- No code changes — additive test coverage only. Closes the TODOS gap
  "Multi-workspace tenant-routing e2e test" surfaced in v0.38 Slack tail.
- 12th ship of the 2026-05-24 session arc; natural high-water mark.

## 1.12.7 (2026-05-24): migration v27 self-heal for partial-v16 DB state

Fixes a real user-facing bug: on a hippo DB where migration v16 had
partial-applied (api_keys + audit_log tables missing despite
schema_version recorded as >= 16), every `hippo context` invocation
printed `Error: no such table: api_keys` to stderr. Surfaced on Keith's
~/.hippo/hippo.db on 2026-05-24, visible as repeated noise on every
UserPromptSubmit hook firing.

### Shipped

- **Migration v27 self-heal.** Re-asserts the v16 schema via
  `CREATE TABLE IF NOT EXISTS api_keys + audit_log` plus their indexes.
  Idempotent — zero cost for DBs without the bug, fixes any DB that has it
  on next open. Also includes the v26 `role` column directly in the
  CREATE so v26's ALTER doesn't have to run on this heal path.
- **Migration v26 defensive guard.** Added `tableExists(db, 'api_keys')`
  check to v26's existing `tableHasColumn` guard. Without this, v26's
  ALTER would crash on the partial-v16-state and block v27 (the heal)
  from ever running. Now v26 no-ops gracefully when api_keys is missing.

### Root cause investigation

Surprising finding: the migration runner has wrapped each migration's
`up()` in `BEGIN ... COMMIT` since the very first SQLite commit
(`2cf72e7`). So the partial-apply on Keith's DB did NOT happen through
missing atomicity — the wrapping has always been correct. Possible causes
of the observed state:

1. `DROP TABLE api_keys` issued post-migration (operator action or
   external SQL).
2. Restore / import from a pre-v16 backup over a v16+ schema_version.
3. Some edge case the BEGIN/COMMIT wrapping doesn't catch (unknown).

Without forensic logs the precise cause stays unknown. The fix is the
same regardless: v27 heals the symptom, v26 won't crash on the input.

### Tests
- 5 cases in `tests/db-migration-v27-self-heal.test.ts`: heals
  partial-v16 state, api_keys has role column, audit_log has v16 shape,
  v27 is no-op on healthy DB, v26 ALTER no-ops when api_keys is missing.
- 18 brittle `.toBe(26)` assertions across 8 test files bumped to
  `.toBe(27)` for the new schema head.

### Full suite

1791 passed / 4 skipped / 0 failed across 248 files.

### Notes

- No min_compatible_binary bump — v27 is a heal migration, not a
  contract-breaking change. Old binaries can still open the DB; they
  just won't see the heal (and don't need to).
- Long-term: `TODOS.md` carries a follow-up to investigate WHY v16
  partial-applied in the first place (transaction wrapping is intact;
  cause unknown).

## 1.12.6 (2026-05-24): D-batch hardening pass — 5 follow-ups

Bundles 5 long-deferred B-sized items from `TODOS.md` per the
`/dev-framework-rl` "batch 4-6 trivial Bs into one hardening pass" rule.
Each item reproduce-checked against current master before drafting per
the new SKILL §3b pre-plan-audit step shipped earlier today.

### B1 — Defensive `kind != 'archived'` filter in `loadSearchRows`

`archivedClauseAlias` / `archivedClauseNoAlias` / `archivedClauseTenantOnly`
added to all 4 candidate-loading paths (empty-terms full-scan, FTS path,
LIKE fallback, full-store fallback). Belt-and-suspenders against future
SAVEPOINT regressions in `archiveRawMemory`, future bugs introducing
`kind='archived'` as a persisted state, and external direct-SQL writes
bypassing the archive helper.

4 tests at `tests/store-recall-archived-filter.test.ts` cover all 4
paths via a SAVEPOINT-bypass simulation (direct SQL UPDATE to leave
`kind='archived'` visible to a concurrent reader).

### B2 — `--owner` format validation (warn-only default)

`OWNER_RE = /^(user|agent):[A-Za-z0-9_-]+$/` + `validateOwner(owner, {strict})`
helper at `src/owner-validation.ts`. Wired into both `cmdRemember` paths:
direct (`cli.ts:677`) and thin-client HTTP fallback (`cli.ts:5680`).

- **Default:** warn-only (log to stderr + accept) to preserve back-compat
  with existing scripted callers passing legacy owner strings.
- **Strict mode:** set `HIPPO_STRICT_OWNER=1` env var → reject + exit 1.
- **Future:** strict will become default once A5 v2 lands (multi-tenant
  owner enforcement).

28 unit tests at `tests/owner-validation.test.ts`.

Scope correction from the 2026-05-22 TODO: "Slack backfill" was a
mis-reference — Slack derives owner from `user_id` in
`messageToRememberOpts` (no CLI flag). Both `--owner` call sites are
`hippo remember` paths.

### B3 — `ingestMessage` status string consistency

Option (a) chosen (unify), not (b) document. `src/connectors/slack/ingest.ts:50`
now returns `status: 'skipped'` (not `'duplicate'`) when `lookupMemoryByEvent`
returns null on a `hasSeenEvent` hit. The cached `memory_id IS NULL` is the
discriminator — non-null still returns `'duplicate'` (an actual memory was
written before).

Closes the paper-cut where a downstream caller's switch/case on `status`
treated functionally-identical outcomes (memory_id=null) as different
branches.

3 new cases at `tests/slack-ingest-empty-body-replay.test.ts`; existing
`tests/slack-ingest.test.ts:46` updated to assert the new contract.

### B4 — DLQ parse-failure tenant attribution (promoted to root-cause fix)

The TODO sanctioned "document or revisit" but the root-cause fix was
small enough to ship: `server.ts:1008` JSON.parse-catch path now uses
`resolveTenantForTeam(db, teamIdFromRaw)` (the same helper the happy path
uses at line 1044) instead of `process.env.HIPPO_TENANT ?? 'default'`.

Pre-fix: parse failure from workspace A landed in the deployment's
tenant DLQ. Post-fix: lands in workspace A's tenant DLQ. Unknown /
un-extractable team → `null` → `'__unroutable__'` sentinel (matches
the existing unroutable bucket convention from v0.39 commit 3).

4 HTTP integration tests at `tests/slack-webhook-parse-failure-tenant.test.ts`:
known team routes correctly, unknown team → unroutable, garbage body →
unroutable, single-workspace install (empty slack_workspaces) preserves
env-fallback ergonomics.

### B5 — `docs/evals/AUTHORING.md` with sentinel-token leakage lesson

Documents the E1.3 (v0.37 Slack ingestion) eval bug: descriptive
scenario IDs (`login_500_error_after_deploy`) leaked into ambient noise
fixtures via shared tokens, inflating BM25 recall from ~30% to ~88%.

Pre-commit checklist:
1. List every string in both signal-data and noise-data fixtures.
2. For each, confirm intentional signal OR opaque enough (e.g. `S1A2B3`,
   ULID).
3. Run a noise-only baseline; recall on signal-side tokens should be at
   floor. If not, there's a leak.

Plus 3 other lessons (pre-registration discipline per v1.8.1,
multi-seed harnesses + paired-comparison statistics, workload-validity
gate before mechanism gate) and an eval-card template.

### Notes
- No schema migration. No breaking changes. All 5 changes are additive
  hardening.
- Full suite: see ship report.

## 1.12.5 (2026-05-24): `hippo slack workspaces` CLI (T2B operator UX)

Closes the v0.38 E1.3 v2 follow-up: "workspace registration CLI". Today operators populating the `slack_workspaces` table for multi-workspace deployments had to write direct SQL. This release adds the CLI surface.

### Shipped
- **`hippo slack workspaces add --team <T> --tenant <t>`** — registers a Slack team_id → hippo tenant_id mapping. Upserts on team_id conflict (operators moving workspaces between tenants shouldn't need a delete+add dance).
- **`hippo slack workspaces list`** — tab-separated rows (team_id, tenant_id, added_at) sorted by team_id. Empty-state message routes operators to the HIPPO_TENANT fallback explanation.
- **`hippo slack workspaces remove --team <T>`** — deletes a registration; reports not-found on miss with non-zero exit.
- Helper module at `src/connectors/slack/workspaces.ts` keeps SQL apart from CLI for unit-testability (mirrors the `connectors/slack/dlq.ts` pattern).

### Tests
- 7 unit cases in `tests/slack-workspaces.test.ts` (real-DB per project rule): insert+envelope, upsert behaviour, empty-state list, stable sort, remove returns true/false, monotonic addedAt.
- 10 CLI integration cases in `tests/slack-workspaces-cli.test.ts`: add/list/remove happy paths, upsert via two adds, not-found-on-remove, usage errors for missing flags, --help short-circuit.

### Notes
- No schema migration — `slack_workspaces` table has existed since v17 (migration in `src/db.ts`).
- No HTTP route — workspace registration is an operator-CLI concern, not a runtime API. The existing `resolveTenantForTeam` (server.ts) already reads this table on every Slack webhook.
- 6th reproduce-check WIN of the v1.12.x sweep arc surfaced that `hippo slack dlq replay` (the sibling follow-up) was already shipped at `cli.ts:5118` — only the workspaces half remained.

## Python SDK v0.2.0 (2026-05-24): HippoSync + ContextEntry.projected() + auth role

PyPI: `hippo-memory-sdk@0.2.0`. Closes 3 of 5 v0.1.0 README-documented limitations. The 4th (`recall` last_retrieval_ids gap) is reframed as a deliberate design choice locked in hippo-memory v1.11.5 (see `tests/api-recall-no-side-effects.test.ts`); the 5th (connector webhooks) is by-design (server-only routes).

### Shipped

- **`HippoSync` class** — sync mirror of `Hippo` using `httpx.Client`. Wire-compatible: same routes, same models, same errors. Use when your code already runs synchronously (CLI scripts, notebooks, `threading.Thread` callbacks). Async `Hippo` remains the recommended default.
- **`ContextEntry.projected()` helper** — projects the full MemoryEntry surface to the CLI's narrower json shape (`id`, `score`, `strength`, `tags`, `confidence`, `content`, `global`). Mirrors `hippo context --format json` per-row output.
- **`auth_create(role=)` parameter** on both `Hippo` and `HippoSync` — matches hippo-memory v1.12.3 server. Pass `role="admin"` (default) or `role="member"`. Member keys are 403-blocked from admin-gated routes (e.g. `/v1/sleep`).
- **`AuthCreated.role` + `AuthKey.role` fields** — populated by hippo-memory v1.12.3+ server. Optional (None on older servers).

### Breaking changes

- **`AuthCreated.key` renamed to `AuthCreated.plaintext`** — the v0.1 `key` field was a model bug never exercised by any integration test (the server returns `plaintext`, not `key`; the model would have raised `ValidationError` on any real auth_create call). v0.2 fixes this. Consumers reading `result.key` see `AttributeError`; switch to `result.plaintext`. Caught while writing the v0.2 sync integration tests.

### Tests
- 8 cases in `tests/test_sync_client.py` covering health, remember+recall roundtrip, get_context, outcome, auth_create (admin + member), auth_list with role, error handling.
- 4 cases in `tests/test_projected.py` (pure unit) covering cli-shape projection, global=True/None handling, optional-fields pass-through.
- Updated `tests/test_models.py` `test_auth_models_roundtrip` for the `key` → `plaintext` rename + role field.
- Full Python suite: **35 passed in 26s** (real-server integration via `node bin/hippo.js serve` fixture).

### Migration notes
- **Sync mode opt-in:** `from hippo_memory import HippoSync` (alongside existing `Hippo`).
- **Server compatibility:** v0.2.0 SDK works against hippo-memory server >=1.11.4 (role fields are optional in models). For full role support (admin gate + audit emit), use server >=1.12.4.
- **AuthCreated breaking change:** rename `result.key` → `result.plaintext` in any v0.1 caller code. The v0.1 field was never functional end-to-end.

## 1.12.4 (2026-05-24): auth_create audit emit (closes v1.12.3 deferral)

PATCH release. Closes the v1.12.3 CHANGELOG-flagged deferral (auth_create un-audited). Mirrors the existing `auth_revoke` audit pattern in `authRevoke` — one audit row per successful mint, plaintext NEVER logged.

### Shipped

- **`api.authCreate` emits `auth_create` audit row** on every successful mint. Same try/catch guard as `authRevoke` so audit failure can't crash a successful mint. Metadata: `{ label, role }`. `targetId`: the new `key_id`. `actor`: `ctx.actor.subject`.
- **`'auth_create'` added to `AuditOp` union** (`src/audit.ts`) + both `VALID_AUDIT_OPS` Sets (`src/cli.ts:4712`, `src/server.ts:71`). `hippo audit list --op auth_create` and `GET /v1/audit?op=auth_create` now work.

### Tests
- 6 cases in `tests/auth-create-audit.test.ts`:
  - Default emit: label + role in metadata, targetId matches keyId, actor matches ctx.
  - Default role logs admin; explicit member logs member.
  - Null label logs `label: null` (not the string `"undefined"`).
  - **Security invariant: plaintext key NEVER appears in audit metadata** (asserted by full JSON-stringified scan).
  - Mint + revoke pair: 1 `auth_create` row + 1 `auth_revoke` row, both `targetId` match.
- Full suite: 1730 passed + 4 skipped + 0 failures.

### Migration notes
- **No DB migration.** `audit_log` table already supports the new op (the column is `TEXT`).
- **No required updates for existing callers.** Audit emit is a side-effect; mint return shape unchanged.

## 1.12.3 (2026-05-24): hippo auth CLI role surfacing (v1.12.0 sub-1 follow-ups)

PATCH release. Bundles two LOW-priority v1.12.0 sub-1 follow-ups: surface the `api_keys.role` column (added in schema v26 by v1.12.0 sub-1) through the CLI and the HTTP route. Pure additive; no behaviour change for callers omitting the new flag/field.

### Shipped

- **`hippo auth create --role admin|member` CLI flag** (defaults to `admin`). Invalid values exit 1 with a typed error — no silent admin fallback on typos. JSON output now includes `role`. Non-JSON output includes `role:` line after `plaintext:`.
- **`hippo auth list` table includes role column.** Header: `key_id  tenant  role  label  created  revoked`. Inserted between tenant and label.
- **`POST /v1/auth/keys` accepts optional `body.role: 'admin' | 'member'`.** Anything else is a 400 (no silent fallback). Mirrors the CLI flag exactly. The mint surface remains permissive (member Bearer can mint admin keys for the same tenant) — gated routes (`/v1/sleep`) enforce the role check, not the mint endpoint.
- **`AuthCreateOpts.role?: 'admin' | 'member'`** (`src/api.ts`). Optional. Defaults to `'admin'`. `AuthCreateResult.role: 'admin' | 'member'` always set.
- **`ApiKeyListItem.role: 'admin' | 'member'`** (`src/auth.ts`). `listApiKeys` SELECT extended to read the column. Fail-safe-to-member cast: any non-`'admin'` value (legacy NULL, corrupted row, future-unknown enum value) reads as `'member'` — never silently grants admin.

### Tests
- 5 cases in `tests/auth-role-cli-surfacing.test.ts`: authCreate default-admin, explicit-admin, member; listApiKeys returns role per row; fail-safe-to-member on corrupted role value.
- Existing `tests/auth.test.ts` 5 cases still pass (back-compat invariant).

### Migration notes
- **No DB migration.** `api_keys.role` column already exists since v1.12.0 sub-1 (schema v26).
- **No required updates for existing callers.** Pre-v1.12.3 mints default to 'admin' on the wire, exactly as they did pre-v1.12.3 implicitly through the DB DEFAULT.
- **To mint a member key:** `hippo auth create --role member --label service:reporter` or `POST /v1/auth/keys {"role":"member","label":"..."}`.

### Out of scope (deferred)
- Member-bearer-cannot-mint-admin-key restriction (mint surface stays permissive; gated routes carry the role check).
- `audit_log` emit on `auth_create` (still un-audited, matching pre-v1.12.3 behaviour).

## 1.12.2 (2026-05-24): api.sleep mid-phase test coverage via __phases DI seam

PATCH release. Adds a test-only `__phases?: Partial<SleepPhases>` field on `SleepOpts` so test files can inject deterministic throws at each of `api.sleep`'s 5 phase boundaries (consolidate / deduplicateStore / auditMemories / autoShare / computeAmbientState). Closes the v1.11.5 deferral (independent-review-critic MED #2) where the `partial: true` + `errorMessage` audit-row branch at `src/api.ts:~2098` was reachable but not test-locked. Back-compat: no public-API change for non-test consumers; the `DEFAULT_SLEEP_PHASES` map preserves all current behaviour when `__phases` is undefined.

### Shipped

- **`SleepPhases` interface** in `src/api.ts` listing the 8 phase dependencies of `api.sleep`.
- **`DEFAULT_SLEEP_PHASES` const** binding each field to the real production implementation (`consolidate`, `deduplicateStore`, `auditMemories`, `autoShare`, `loadAllEntries`, `deleteEntry`, `computeAmbientState`, `loadConfig`).
- **`SleepOpts.__phases?: Partial<SleepPhases>`** test-only field. `@internal` JSDoc warns production callers off. Runtime resolution at `api.sleep` entry is `{ ...DEFAULT_SLEEP_PHASES, ...(opts.__phases ?? {}) }` so partial overrides work cleanly.
- **`api.sleep` body**: all 7 phase-dependency call sites in the try-block rewritten to use `phases.X(...)` instead of the directly-imported `X(...)`. Default behaviour unchanged.

### Tests
- 6 cases in `tests/api-sleep-phase-faults.test.ts`:
  - Phase 1-5 fault injection: each asserts the rejected promise + `consolidate` audit row with `partial: true` + `errorMessage` matching the thrown error + earlier-phase counter preservation.
  - Happy path: `partial: false` + no `errorMessage`.
- Per-test HIPPO_HOME isolation for the autoShare phase 4 (which calls `initGlobal()`).
- Real DB per project convention.

### Migration notes
- **No public-API change.** External library consumers calling `sleep(ctx, { dryRun: true })` see no signature delta. `SleepOpts.__phases` is opt-in test-only.
- **No DB migration.**

### Out of scope (deferred)
- `audit_log` emission on sleep consolidation phases (still TODOS — separate from this test-coverage work).
- `api.recall` last-retrieval-ids parity (separate item).
- `hippo auth create-key --role` / `hippo auth list` role-column CLI surfacing.

## 1.12.1 (2026-05-24): A5 v2 sub-2 — L9 conflict-subsystem tenant-scoping

PATCH release. Closes the L9 deferral (conflict-subsystem tenant-scoping residue) from v1.11.0 + v1.12.0 sub-1. 11 unscoped `loadAllEntries` / `readEntry` sites across 8 background-pipeline files re-classified into 6 PER-TENANT-OPT-IN (`tenantId` plumbed through as optional parameter) + 7 HOST-WIDE-DOCUMENTED (inline JSDoc locks the intentional cross-tenant contract). Back-compat-safe: every new parameter is OPTIONAL with default behaviour matching v1.12.0; no public-API consumer breaks. No schema migration.

### Shipped

- **`tenantId?: string` (optional) on `invalidateMatching(hippoRoot, target, tenantId?)`** (`src/invalidation.ts`). Callers passing `tenantId` get per-tenant invalidation; undefined keeps the host-wide behaviour (pre-1.12.1 default).
- **`tenantId?: string` (optional) in `RefineOptions`** (`src/refine-llm.ts`). Per-tenant refining + parent lookup. Cross-tenant parents return null from `readEntry` and are silently skipped — refine still produces output from merged content alone (graceful degradation).
- **`tenantId?: string` (optional) on `deduplicateLesson` root-string overload** (`src/autolearn.ts`). Pre-loaded-entries overload unchanged (caller controls scope on that path).
- **`tenantId?: string` (optional) in `CaptureOptions`** (`src/capture.ts`). Per-tenant dedup AND write during capture (the createMemory call mirrors the dedup-read guard); ignored when `global: true`.
- **`tenantId?: string` (optional) in `ImportOptions`** (`src/importers.ts`). Per-tenant dedup AND write during import (the createMemory call mirrors the dedup-read guard); ignored when `global: true`. The 5 typed sibling importers (`importChatGPT/Claude/Cursor/GenericFile/Markdown`) pass options through unchanged.
- **`tenantId?: string` (optional) in `autoShare` options bag** (`src/shared.ts`). Per-tenant local-entries read; global read stays unioned (the global root IS the cross-tenant aggregate by design).
- **6 host-wide reader sites documented with L9 JSDoc** — `consolidate.ts:106`, `embeddings.ts:377+425`, `shared.ts:310+353+390`. No behaviour change; the comments lock the contract so a future pass doesn't accidentally "fix" them.
- **Internal callers updated** to pass `resolveTenantId({})` (CLI) or the in-scope `tenantId` (MCP): `cli.ts:1941, 2322, 2471, 3618, 3625, 5899, 6008, 6076`; `mcp/server.ts:760`.
- **`src/api.ts:2041` `api.sleep` autoShare call intentionally unchanged.** `api.sleep` is host-wide by intent per the api.ts:2073-2077 TODO; admin-gated at the HTTP boundary since v1.12.0.

### Tests
- 13 cases in `tests/l9-tenant-scoping.test.ts` — 7 per-tenant negative (cross-tenant leak prevention) + 6 host-wide back-compat parity. Real DB per project convention; multi-tenant fixtures via `createMemory({ tenantId })`.

### Migration notes
- **No DB migration** (schema v26 unchanged).
- **No required updates for existing callers.** All new parameters are optional with defaults preserving v1.12.0 host-wide behaviour. Library consumers gain per-tenant capability opt-in only.
- **To opt in to per-tenant correctness**, library consumers pass `tenantId` on these 6 entry points: `invalidateMatching`, `refineStore` (via `RefineOptions`), `deduplicateLesson` (root-string overload), `cmdCapture` / `cmdCaptureCore` (via `CaptureOptions`), `importEntries` (via `ImportOptions`), `autoShare` (via options bag). The TypeScript types will accept undefined as well as `string`.
- **Future direction:** the optional-now / required-next-major deprecation cycle could land in 1.13.x and the breaking change in 2.0.

### Out of scope (deferred)
- `cli.ts` / `dashboard.ts` unscoped reader sites (single-tenant-per-process trust holds until non-loopback serving lands).
- `dedupe.ts` / `memory.ts` unscoped reader sites (separate audit; not in L9 brief).
- `hippo auth create-key --role` / `hippo auth list` role-column CLI surfacing (v1.12.0 follow-up; tracked in TODOS.md).

## 1.12.0 (2026-05-23): A5 v2 sub-1 — auth/role plumbing (admin gate on /v1/sleep)

MINOR release. Adds an authorization-role layer to api_keys + Context.actor, gates `POST /v1/sleep` on `admin` role. Sub-1 of 2 in the v1.12.0 A5 v2 multi-tenant cluster — sub-2 (L9 background pipelines tenant-scoping across 8 files) deferred to a follow-up release. The Context.actor shape change is the largest public-API surface change since v0.39 (the A3 envelope refactor); existing single-tenant deployments behave identically (legacy keys backfill to `'admin'`, loopback fallback is `'admin'` by default).

### Shipped

- **`Actor` interface + `Context.actor: Actor` shape change.** Previously `Context.actor` was a bare `string` (`'cli'` | `'localhost:cli'` | `'api_key:<key_id>'` | `'mcp'`). v1.12.0 promotes it to `{subject: string, role: 'admin' | 'member'}`. Role checks happen at request boundaries (e.g. `/v1/sleep`); audit helpers continue accepting `actor: string` so callers pass `ctx.actor.subject`.
- **`adminActor(subject)` helper.** New factory in `src/api.ts` returning `{subject, role: 'admin'}`. Used by CLI / MCP / connector entry points (process-local trust). Bearer-authed callers (HTTP `/v1/*`) get their role from the api_keys row via `buildContextWithAuth`.
- **api_keys schema migration v26.** Adds `role TEXT NOT NULL DEFAULT 'admin'`. Migration runs synchronously inside `openHippoDb` before any `createApiKey` INSERT, so the 6-column INSERT in `createApiKey` is safe. Existing keys backfill to `'admin'` via DEFAULT — no behavior change for single-tenant operator deployments.
- **`createApiKey({..., role})` option.** Defaults to `'admin'` (backward-compat). Tests construct member keys via direct DB insert; a `hippo auth create-key --role` CLI flag is deferred to a follow-up (avoids v1.11.5-style scope creep).
- **`ValidateResult.role?: 'admin' | 'member'`.** `validateApiKey` SELECT extended to read the role column; returned on success.
- **Admin-role gate on `POST /v1/sleep`.** Defense-in-depth: today `/v1/sleep` is loopback-only, so the loopback fallback's default `admin` role passes naturally. The gate exists ALREADY so that when non-loopback serving lands (`HIPPO_BIND_ALL` or A5 v2 v2 multi-tenant deployment), `member`-role Bearer tokens are 403'd at the route boundary BEFORE any host-wide consolidation runs.
- **Connector audit-identity fix.** `src/connectors/slack/ingest.ts:73` and `github/ingest.ts:167` previously had `{ ...ctx, actor: ctx.actor || 'connector:slack' }` — the `||` fallback evaluated an object as truthy under the new Context.actor shape, causing connectors to silently lose audit identity. Fix: drop the fallback entirely (ctx is always provided by the route Context construction at server.ts).

### Tests

3 new test files (12 tests total): `tests/auth-role-migration.test.ts` (5 cases incl. legacy 5-col INSERT backfill verification), `tests/api-context-actor-shape.test.ts` (4 cases incl. audit_log subject-string preservation round-trip), `tests/server-sleep-admin-gate.test.ts` (3 cases: loopback no-Bearer 200, admin Bearer 200, member Bearer 403). ~20 existing test files updated for the new Context.actor shape via bulk sed. Suite: 1688 → 1700 passing, 0 fail.

### Migration notes

- **Schema rollback:** v1.11.5 binary opens a v26-migrated DB without issue — the `role` column is ignored on SELECTs that don't name it, and INSERTs without it use the DEFAULT. No explicit downgrade path in the in-code MIGRATIONS array (project convention).
- **External callers of `Context`:** type-level breaking change. SDK consumers using `import { type Context } from 'hippo-memory'` will see `actor: Actor` instead of `actor: string`. The Python SDK (`hippo-memory-sdk` 0.1.0) is HTTP-only and unaffected (`AuditLogEntry.actor` is `str`).

### Out of scope (deferred to v1.12.0 sub-2 / later)

- **L9 background pipelines tenant-scoping** (8 files: `consolidate.ts`, `embeddings.ts`, `invalidation.ts`, `refine-llm.ts`, `autolearn.ts`, `capture.ts`, `importers.ts`, `shared.ts`). Sub-2 of the v1.12.0 A5 v2 cluster — depends on this Actor shape change.
- **`hippo auth create-key --role` CLI flag.** Tests use direct DB insert.
- **`hippo auth list` role column display.**
- **Multi-role RBAC beyond admin/member** (editor/viewer/etc.).
- **Non-loopback serving (`HIPPO_BIND_ALL`).**
- **Per-tenant `/v1/sleep` semantics** (tenant plumbing into `deduplicateStore` — A5 v2 v2 territory).

## 1.11.5 (2026-05-23): Episode A/B/C critic-deferral hardening pass

PATCH release closing 7 of 8 deferrals from the v1.11.3 + v1.11.4 + python-v0.1.0 ship chain. Additive + backward-compatible. Item #5 (per-tenant `/v1/sleep` scoping) deferred to v1.12.0 because plan-eng-critic round 1 surfaced it as MINOR-scope structural work (Context.actor object shape + api_keys schema migration + 12+ call sites).

### Shipped

- **HTTP DoS caps.** `POST /v1/outcome` now rejects `ids.length > 1000` with 400 (each id costs ~3 DB ops; 1000 keeps per-request work bounded). Cap fires BEFORE `buildContextWithAuth` so attack traffic doesn't pay the api-key lookup cost. `GET /v1/context` now rejects `q.length > 1024` with 400 (mirrors the existing scope-cap pattern; 1024 covers real multi-clause queries while bounding BM25 tokenisation).
- **`audit_log` emission on `api.sleep`.** One `'consolidate'` audit row emitted per invocation with phase counters in metadata (`consolidationCount`, `dedupCount`, `auditDeletedCount`, `ambientTotal`, `dryRun`, `noShare`, `partial`). Closes the CLI/MCP parity gap that v1.11.3 T6 fixed for `cmdOutcome`. Emit lives in a `try/finally` so partial-failure paths still emit (`partial: true` + `errorMessage`); the audit emit is itself wrapped in a defensive try/catch so an audit-emit failure does NOT mask the original phase error (logs to stderr instead).
- **Additive `AuditOp` extension: `'consolidate'`.** New union member at `src/audit.ts:130-138`. The Python SDK is unaffected — `python/src/hippo_memory/models.py:298` types `op` as `str` (no enum constraint).
- **Pre-existing `'outcome'` allow-list drift closed.** `VALID_AUDIT_OPS` Sets at `src/cli.ts:4679` and `src/server.ts:70` already missed `'outcome'` despite the union including it and rows being emitted today — `GET /v1/audit?op=outcome` returned 400 "invalid op" and `hippo audit list --op=outcome` exited 1 "Unknown --op value". Both Sets now carry both `'outcome'` and `'consolidate'`. The CLI error message is now regenerated from `Array.from(VALID_AUDIT_OPS).join(' | ')` so future drift can't recur on the message side. **Behaviour change:** `hippo audit list --op=outcome` and `GET /v1/audit?op=outcome` previously errored; they now return the outcome rows. Downstream consumers that caught the error as a "not supported" signal will see a behavior change.
- **`isLoopback` helper unit-tested.** 11 cases at `tests/server-isloopback-helper.test.ts` lock the helper's accepted forms (`'127.0.0.1'`, `'::1'`, `'::ffff:127.0.0.1'`) and rejected forms (RFC1918, IPv4-mapped non-loopback, link-local, fully-expanded `'0:0:0:0:0:0:0:1'`, `undefined`, empty string). No behaviour change to the helper itself — extending it to recognise additional IPv6 forms is a security-adjacent decision out of v1.11.5 scope.
- **`api.recall` no-side-effects contract locked.** Test + JSDoc + `python/README.md` note. `api.recall` does NOT mutate `index.last_retrieval_ids` (only `api.getContext` and CLI `cmdRecall` do). Adding the side-effect would break SDK callers who batch recall calls in a row. SDK callers needing the last-recall outcome path should call `api.getContext` first.
- **CLI render helpers exported for snapshot tests.** `printContextMarkdown` and `renderSleepResult` now exported from `cli.ts` with `@internal` JSDoc tags (NOT a stable public API). 12 snapshot tests at `tests/cli-context-render-snapshot.test.ts` lock the byte-identical output of both render branches across pinnedOnly / markdown default / json / additional-context / framing observe-suggest-assert / hybrid local-only / hybrid with global / dry-run + full sleep. Uses `vi.useFakeTimers({ now: '2026-05-23T20:00:00Z' })` for determinism (resolveConfidence-driven tags would otherwise churn).

### Tests

7 new test cases across `tests/api-sleep.test.ts`, `tests/server-context-route.test.ts`, `tests/server-outcome-route.test.ts`. 4 new test files: `tests/api-recall-no-side-effects.test.ts` (2 tests), `tests/cli-context-render-snapshot.test.ts` (12 snapshots), `tests/server-audit-route-consolidate.test.ts` (3 tests including round-trip + outcome-drift coverage), `tests/server-isloopback-helper.test.ts` (11 cases). Total suite: 1657 → 1688 tests passing, 0 failures.

### Out of scope (deferred to v1.12.0)

- Per-tenant `/v1/sleep` scoping (item #5). Requires Context.actor object shape, api_keys role column, ValidateResult signature change, and 12+ call site updates across api.ts. Bundled with non-loopback serving (`HIPPO_BIND_ALL`) and A5 v2 multi-tenant work.
- Mid-phase failure test for the `partial:true` audit row branch. The path is reachable + the contract is locked in code, but forcing a deterministic mid-phase throw requires DI seams or fault-injection hooks not yet in db.ts. Tracked in TODOS.md.

## Python SDK v0.1.0 (2026-05-23): initial PyPI release

First release of `hippo-memory-sdk` on PyPI. Async Python SDK wrapping the 14 HTTP endpoints from `hippo-memory@1.11.4`. The PyPI distribution name is `hippo-memory-sdk` (the bare `hippo-memory` name was blocked by PyPI's similarity check against an existing `hippomem` project); the Python import name remains `hippo_memory` so user code is `from hippo_memory import Hippo`.

- Live at https://pypi.org/project/hippo-memory-sdk (once the PyPI trusted publisher is configured for `kitfunso/hippo-memory` / workflow `pypi-publish.yml` / environment `pypi`).
- Source in `python/` of the kitfunso/hippo-memory monorepo.
- Install: `pip install hippo-memory-sdk`. Requires Python 3.10+.
- Pins: `httpx>=0.27`, `pydantic>=2.0`. Tested against `hippo-memory@1.11.4` server.
- Async-first: `async with Hippo(...) as client: await client.remember(...)`.
- 14 SDK methods covering health, remember, recall, drill, forget, archive, supersede, promote, outcome, get_context, sleep, assemble, auth_create/list/revoke, audit.
- Tag scheme: push `python-v0.1.0` triggers PyPI publish via GitHub Actions trusted-publisher OIDC (no tokens in repo).
- 13 model round-trip tests + 10 real-server integration tests (function-scoped subprocess fixture spawning `hippo serve` per test, `hippo init` bootstrap before serve to satisfy the requireInit gate).

**Post-merge action for Keith:** configure the trusted publisher at https://pypi.org/manage/account/publishing/ (PyPI Project Name `hippo-memory-sdk`, owner `kitfunso`, repo `hippo-memory`, workflow `pypi-publish.yml`, environment `pypi`) BEFORE pushing the `python-v0.1.0` tag. Otherwise the workflow runs and fails noisily with an OIDC auth error.

See `python/README.md` for quickstart + API + auth + limitations.

## 1.11.4 (2026-05-23): HTTP routes for outcome / context / sleep

Three new HTTP routes added to `src/server.ts`, wrapping the api exports shipped in v1.11.3. Each route is loopback-only (the same trust boundary the rest of the v1 surface already uses).

All three routes inherit the existing `/v1/*` per-IP token-bucket rate limit (`HIPPO_V1_RPS`, default 20 rps with 2x burst; `src/rate-limit.ts`). Operators sweeping sleep / context at high frequency will see 429s once the bucket drains.

- **POST /v1/outcome**: apply a positive / negative outcome to memory ids. Body: `{"ids"?: string[], "good": boolean}`. If `ids` omitted, falls back to the last-recall path (`api.outcomeForLastRecall`); returned shape is `{applied, ids}` in that case where `ids` is the **tenant-filtered applied subset** (NOT the raw `last_retrieval_ids`). Each applied id writes one `audit_log` row (op='outcome', actor from Bearer). Cross-tenant ids silently skip (no audit row, not surfaced in response).
- **GET /v1/context**: assemble a budget-bounded context bundle. Query params: `q?`, `budget?`, `limit?`, `pinned_only?`, `scope?`, `include_recent?`. Returns `ContextResult` JSON (entries + tokens + activeSnapshot + sessionHandoff + recentEvents). No server-side rendering; clients render markdown / json / additional-context. Tenant-scoped via the Bearer.
- **POST /v1/sleep**: run the storage consolidation pipeline. Body: `{"dry_run"?: boolean, "no_share"?: boolean}`. Returns `SleepResult` JSON. **Host-wide semantic** (operates on the whole hippoRoot, not per-tenant), matching CLI `hippo sleep`. Two layers of loopback-only enforcement: serve()'s boot-time host check rejects non-loopback binds, AND the per-request guard in the handler rejects non-loopback connections with 403 (belt-and-suspenders so a future config relaxation doesn't silently expose the host-wide semantic). Tracked in TODOS.md for the day non-loopback serving lands — at that point the route needs an admin-role gate OR api.sleep needs to scope dedup / audit / delete by ctx.tenantId.

Unlocks the v0.1.0 Python SDK (`pip install hippo-memory`, planned Episode C) which thin-wraps these HTTP routes.

### Security fix (in scope)

`api.outcome` and `api.outcomeForLastRecall` now return the tenant-filtered `appliedIds` subset (added field on `outcome`, replaces the raw `last_retrieval_ids` echo in `outcomeForLastRecall`'s `ids` field). Pre-v1.11.4, `outcomeForLastRecall` returned the full `last_retrieval_ids` regardless of tenant, which would have leaked cross-tenant memory IDs to the caller via the new POST /v1/outcome's no-body last-recall response. The CLI (`hippo outcome --good`) is single-operator and was unaffected in practice, but the HTTP route was the first multi-tenant surface to expose this path. Closed before the route shipped; regression test in `tests/server-outcome-route.test.ts` ("cross-tenant last-recall path: response ids field does NOT leak other-tenant ids").

### Shipped

- Three new routes in `src/server.ts` following the established if-block pattern (`buildContextWithAuth` + body / query validation + `sendJson` + `HttpError(400)` for invalid input).
- Defensive per-request loopback assertion on `/v1/sleep` using the canonical `isLoopback()` helper (server.ts:240) so any future extension to recognise additional mapped/IPv6 forms flows through without drift.
- `src/api.ts` `sleep()` JSDoc updated to reflect the loopback-only enforcement is now in place (Episode B preflight item closed; per-tenant scoping deferral remains in TODOS.md).
- Imports for the 4 Episode A api exports (outcome, outcomeForLastRecall, getContext, sleep) added to the api.js import in `src/server.ts`.

### Tests

Three new test files (22 real-HTTP cases total): `server-outcome-route` (8 cases incl. cross-tenant silent skip + audit emission), `server-context-route` (8 cases incl. tenant scoping + pinned_only + activeSnapshot + budget cap), `server-sleep-route` (6 cases incl. dry-run gating + host-wide intentional contract pin). Each spawns a real `serve(port: 0)` instance against an isolated tmp HIPPO_HOME. Full suite green: 1650 passed, 4 skipped, 0 failed.

### Out of scope

- Per-tenant sleep scoping for `/v1/sleep` (tracked in TODOS.md "Episode A follow-ups"; needs non-loopback serving + admin-role gate).
- `POST /v1/sleep` does not emit `audit_log` rows for the dedup / audit-delete phases (matches `api.sleep` parity). Tracked in TODOS.md as the audit-emission follow-up.
- Render helpers in `api.ts` for shared markdown / json / additional-context output (Python SDK consumers render client-side for v0.1; tracked in TODOS.md).
- `ContextResult.entries` returned by GET /v1/context exposes the full `MemoryEntry` surface (not the CLI's projected json subset). Python SDK consumers in Episode C will receive richer payloads than `hippo context --format json` renders. Documented in TODOS.md.

## 1.11.3 (2026-05-23): api.ts refactor (getContext + sleep + outcomeForLastRecall)

An internal refactor enabling future HTTP API expansion (planned v1.11.4) and the Python SDK (planned v0.1.0). Three new exports added to `src/api.ts`:

- `getContext(ctx, opts): Promise<ContextResult>` extracted from `cmdContext` (~315 inline lines collapsed into a pure async function plus a presentation renderer in the CLI). Named `getContext` rather than `context` to avoid collision with the `Context` interface. Covers pinnedOnly fast path, '*' fallback (strength-sorted), and hybrid search (searchBothHybrid for global, physicsSearch / hybridSearch for local-only). markRetrieved + last_retrieval_ids write-back + updateStats(recalled) + recall audit row stay inside api.getContext for parity with `api.recall`.
- `sleep(ctx, opts): Promise<SleepResult>` extracted from `cmdSleepCore` Phase 2-6 (consolidate / dedup / audit / share / ambient). Returns structured counts instead of console-printing. The CLI log-file tee, console rendering, and the auto-learn pre-phase (Phase 1: learnFromRepo + learnFromMemoryMd, intrinsically host-bound via `process.cwd()` / `os.homedir()`) stay in the `cmdSleep` + `cmdSleepCore` wrappers. `deduplicateStore` moved to its own module (`src/dedupe.ts`).
- `outcomeForLastRecall(ctx, good): {applied, ids}` small wrapper around the existing `outcome()` that resolves `loadIndex().last_retrieval_ids` first.

CLI commands (`hippo context`, `hippo sleep`) keep byte-identical stdout. Manual smoke verified all 3 cmdContext format branches (markdown / json / additional-context) and both sleep paths (dry-run / full).

**Behavior fix:** `hippo outcome` now emits one `audit_log` row per affected memory (op='outcome', actor='cli'), matching the MCP `outcome` tool path. Previously the CLI bypassed `api.outcome` and silently skipped the audit emission, an inconsistency between the CLI and MCP surfaces. Downstream consumers of `audit_log` will see new rows from CLI `outcome` invocations going forward; if you rely on counting CLI vs MCP audit rows separately, filter on the `actor` field (`'cli'` vs `'mcp'`).

No public TypeScript API breakage. All `src/api.ts` exports are additive. Tenant-scoping audited: every `loadAllEntries` / `readEntry` in the new `api.getContext` uses `ctx.tenantId`, not `resolveTenantId({})`.

### Shipped

- **`api.outcomeForLastRecall(ctx, good)`**: small (~10 LoC) helper that loads `last_retrieval_ids` and forwards to `api.outcome`. Used by the v1.11.3 `cmdOutcome` rewire and ready for Episode B's HTTP `/v1/outcome` route. Tests (4 real-DB): empty index, multi-id, cross-tenant silent skip, audit emission per id.
- **`api.sleep(ctx, opts)`**: pure async function over the consolidation pipeline. Auto-learn from git + MEMORY.md stays CLI-side. `deduplicateStore` extracted to `src/dedupe.ts` for cross-module access (api.sleep + cmdDedup). Tests (4 real-DB): dryRun, empty store, populated pipeline, noShare gating. Per-test `HIPPO_HOME` isolation prevents auto-share leaks.
- **`api.getContext(ctx, opts)`**: pure async function over cmdContext's data-loading + selection. Render helpers (`printContextMarkdown` etc.) stay CLI-side because they are shared with cmdRecall / cmdSnapshot / cmdHandoffShow. Tests (6 real-DB): empty store, '*' fallback ordering, budget cap, tenant scoping, activeSnapshot return, budget=0 short-circuit.
- **`cmdOutcome` rewired** through `api.outcomeForLastRecall` + `api.outcome`. See the behavior fix above.
- **`cmdSleepCore` / `cmdContext` thin-wrapped** through their respective api functions, with byte-identical stdout via dedicated render helpers (`renderSleepResult` for sleep; existing `printContextMarkdown` for context).

### Tests

Five new test files: `api-context-sleep-contracts` (7 type-level), `api-outcome-for-last-recall` (4 real-DB), `api-sleep` (4 real-DB), `api-context` (6 real-DB). Full suite: 1628 tests passed, 4 skipped, 0 failed.

### Out of scope (planned for v1.11.4 / v0.1.0)

- HTTP routes for `/v1/outcome`, `/v1/context`, `/v1/sleep` (Episode B, v1.11.4).
- Python SDK `hippo-memory` on PyPI (Episode C, v0.1.0).
- Shared `api.renderContext`: print helpers in cli.ts still own the markdown / additional-context rendering. Episode B can extract this if the Python SDK wants server-rendered output.

## 1.11.2 (2026-05-23): opencode plugin installer

A patch release fixing [#24](https://github.com/kitfunso/hippo-memory/issues/24) at root.

### Shipped

- **OpenCode integration switched from JSON hooks to a TS plugin.** v1.10.x-v1.11.1 wrote a Claude Code-style `hooks` block into `~/.config/opencode/opencode.json`, which opencode's strict schema (`additionalProperties: false`) rejects with `ConfigInvalidError: Unrecognized key: hooks` — preventing opencode from launching. Root cause: `src/hooks.ts:4` assumed "Claude Code and OpenCode share the same SessionStart/SessionEnd schema." They don't. OpenCode's actual integration surface is its plugin system (`~/.config/opencode/plugins/`, TS/JS modules with `event` hooks per https://opencode.ai/docs/plugins/). The fix: narrow `JsonHookTarget` to `'claude-code'` (compile-time guard), add `installOpencodePlugin()` that writes a type-free TS plugin at `~/.config/opencode/plugins/hippo.ts` subscribing to `session.idle` (→ `hippo session-end`) and `session.created` (→ `hippo last-sleep`).
- **Surgical migration of broken installs.** On the next `hippo hook install opencode`, the installer detects and removes any hippo-owned hook entries from `opencode.json` so opencode can launch again. The migration is structural — per-entry filter on commands starting with `hippo ` — not substring-matched, so user content that coincidentally mentions `hippo sleep` survives unchanged. When opencode.json is unparseable (the literal failure-mode that motivated the fix), the installer surfaces a `jsonRepairFailed=true` signal that the CLI logs as a clear "manual fix needed" warning.
- **`hippo setup` `pluginTools` branch wired.** Pre-fix this branch only printed `tool.notes` without calling any installer; the fix adds an opencode special-case so `hippo setup` actually installs the plugin for opencode users (was a silent regression hidden behind the original bug).
- **`detectInstalledTools`** marks opencode as `kind: 'plugin'` (was `'json-hook'`), aligning with the actual integration model.
- **Uninstall also runs the legacy migration.** A user running `hippo hook uninstall opencode` to remove hippo entirely now also strips any leftover broken hooks block from `opencode.json` — the downgrade/remove path leaves opencode launchable.
- **Deferred to a future release:** pinned-context auto-injection on opencode. `message.updated` fires per token, not per prompt submit, so there's no clean opencode plugin event equivalent to Claude Code's `UserPromptSubmit`. Users can still call `hippo context --pinned-only` via the MCP server (`hippo mcp`).

### Tests

`tests/opencode-plugin-install.test.ts` is new and covers (real FS, no mocks): plugin source content (marker + event types + no type imports + Bun-$ guard); fresh install writes the plugin file with marker; idempotence when content matches; overwrite when marker matches but content differs (future-proof); surgical migration preserving non-hippo entries; empty-hooks-key cleanup; `jsonRepairFailed=true` on unparseable JSON; no creation of opencode.json when none existed; false-positive defense on coincidental `hippo sleep` substrings; non-object hooks values (string + array) handled gracefully; uninstall removes only marker-bearing files; uninstall refuses user-written files; uninstall runs migration on downgrade path; detectInstalledTools marks opencode as plugin. `tests/_helpers/with-fake-home.ts` is a new shared HOME-isolation helper extracted from `tests/hooks.test.ts`. Full suite green: 220 files, 1605 tests.

### Migration

Users on v1.10.x-v1.11.1 with the broken opencode hooks block recover automatically by running `hippo hook install opencode` on v1.11.2 (the install path auto-migrates). Manual alternative: delete the `hooks` key from `~/.config/opencode/opencode.json`.

## 1.11.1 (2026-05-23): v1.11.0 tenant-isolation residue

A patch release closing the two named residues from the v1.11.0 conflict-subsystem pass, flagged by its independent-review critic and tracked in `TODOS.md`. Plan: `docs/plans/2026-05-22-tenant-isolation-residue.md`.

### Shipped

- **`readEntry` call-site audit.** v0.39 made the `readEntry(hippoRoot, id, tenantId?)` primitive tenant-aware (adds `AND tenant_id = ?` when set) but preserved the unscoped legacy behaviour on omitted `tenantId`, so each downstream caller had to opt in. Twelve sites still passed nothing: nine in `src/cli.ts` (`cmdSupersede`, `cmdTrace`'s local + global + parent walk, `cmdOutcome`, `cmdInspect`, `cmdResolve`'s conflict-display reads, `cmdDecide --supersedes`), one in `src/dashboard.ts` (`POST /api/star/:id`), one in `src/api.ts` (the `promoteToGlobal` call), one in `src/shared.ts` (the `promoteToGlobal` lookup). Each now passes the in-scope `tenantId` via the existing `resolveTenantId({})` helper from `src/tenant.ts`. `cmdResolve`'s sibling `listMemoryConflicts`/`resolveConflict` calls and `cmdTrace`'s `listMemoryConflicts` enumeration (same defect class in the same functions) are folded in. `promoteToGlobal` gains a `tenantId?` opt; `api.promote` passes `ctx.tenantId`. Three legacy `process.env.HIPPO_TENANT ?? 'default'` reads in `cli.ts` (4974, 5237, 5257) are switched to the helper, which uses the strictly-safer `?.trim() || 'default'` form. In a single-tenant deployment (`HIPPO_TENANT` unset, every memory carries `'default'`) nothing changes; in a multi-tenant deployment a CLI / dashboard process running under `HIPPO_TENANT=tenant_b` no longer reads or mutates a `tenant_a` memory by id, and `cmdTrace` no longer surfaces a memory promoted to global under `tenant_a`.
- **Cross-tenant stale conflict auto-resolve.** `replaceDetectedConflicts` already built a `tenantById` map and a `sameTenant(a, b)` helper, and used them in the insert loop and the `conflicts_with_json` rebuild to skip cross-tenant pairs. The resolve-stale loop did not: a re-detected cross-tenant pair sat in `detectedKeys`, the insert skipped it (cross-tenant), and the open row lingered `status='open'` forever (inert, hidden from scoped reads and from the refMap rebuild, but untidy). The resolve-stale condition is extended to `(stale || crossTenant)`, so a pre-fix cross-tenant row now self-heals on the next detector pass. No schema change; no new query (the `tenantById` map is already built one block up).
- **`serveDashboard` returns the `http.Server`.** Small testability change: the return type widens from `void` to `http.Server` with `return server;` at the end. The new dashboard tenant-scoping test depends on it to `server.close()` cleanly. Existing CLI callers discard the return; no behaviour change.

### Tests

`tests/resolve-conflict.test.ts` adds one cross-tenant auto-resolve case (seeds memories under two tenants via `createMemory({tenantId})`, manually inserts a pre-fix open cross-tenant `memory_conflicts` row, runs `replaceDetectedConflicts` with the cross-tenant pair in the detected set, asserts the row is `status='resolved'` with the run's `detectedAt` timestamp). `tests/cli-tenant-scoping.test.ts` is new and adds two `cmdTrace` cases via real CLI spawn (local-store cross-tenant denial under `HIPPO_TENANT=tenant_b`; cross-store global denial for a `g_*` memory promoted under another tenant). `tests/dashboard-tenant-scoping.test.ts` is new and adds one `POST /api/star/:id` cross-tenant mutation denial via real `http.request` against `serveDashboard`. `tests/shared.test.ts` adds one `promoteToGlobal` `opts.tenantId` case. All against the real DB / real CLI / real HTTP. Full suite: 219 files, 1588 tests, green.

### Not in this release

- `refine-llm.ts:151` is intentionally deferred. Its per-parent `readEntry` cannot be safely scoped in isolation because the upstream `loadAllEntries(hippoRoot)` on line 130 is itself unscoped (the L9 "background pipelines bypass tenant filter" item in `TODOS.md`). Scoping the per-parent read alone would silently drop parent text for cross-tenant lineage rows. Refine lands together with the rest of L9 / A5 v2; `TODOS.md` now carries a cross-reference on the L9 entry.
- `hippo_peers`' intentionally cross-project read (A5 v2 trust-boundary work).
- Three other unscoped `listMemoryConflicts` call sites (`cli.ts:2602` `cmdStats`, `cli.ts:2848` `cmdConflicts`, `dashboard.ts:75` `buildDashboardData`) and the five remaining `HIPPO_TENANT ?? 'default'` legacy reads in `src/server.ts`. Same defect class, narrower exposure (no mutation paths); tracked in `TODOS.md` for a follow-up pass.

## 1.11.0 (2026-05-22): v0.40 security and hardening

A minor release working the v0.40 security and hardening follow-ups as a batch. Plans: `docs/plans/2026-05-22-conflict-tenant-isolation.md`, `docs/plans/2026-05-22-v1-rate-limit.md`, `docs/plans/2026-05-22-test-store-isolation.md`.

### Shipped

- **Tenant-isolated conflict subsystem.** `listMemoryConflicts` and `resolveConflict` (`src/store.ts`) took no tenant identifier, so a Bearer scoped to tenant A could enumerate tenant B's conflicts through the `hippo_conflicts` MCP tool and, through `hippo_resolve` with `forget=true`, delete tenant B's memory by id. Both functions gain an optional trailing `tenantId`. When it is set, `listMemoryConflicts` JOINs `memory_conflicts` to `memories` on both conflict members and requires each in-tenant, and `resolveConflict` does the same on its lookup and also carries `AND tenant_id = ?` on every `memories` mutation. The detector `replaceDetectedConflicts` builds an id-to-tenant map and skips cross-tenant pairs both when inserting rows and when rebuilding `conflicts_with_json`, so a cross-tenant conflict row is never persisted and a stale one cannot leak a foreign id. The MCP handlers `hippo_conflicts`, `hippo_resolve` and `hippo_status` pass the resolved tenant. An omitted `tenantId` preserves the pre-existing unscoped behaviour, so the single-tenant CLI and the consolidation pass are unchanged, and the parameter addition is non-breaking.
- **Request-level rate limit on `/v1/*`.** The HTTP server applied no per-client throttle, leaving api-key-id enumeration unbounded against the timing/cache side channel `src/auth.ts` describes. A new `src/rate-limit.ts` token-bucket limiter, built in `serve()` from `HIPPO_V1_RPS` (default 20 rps, burst 2x; a non-positive or non-finite value disables it), is checked in `handleRequest` for `/v1/` paths and throws `429` on exhaustion via the existing `HttpError` path. Memory is bounded by a throttled idle sweep and a hard `maxKeys` LRU cap. `/health` and non-`/v1/` paths are never throttled.
- **Isolated global hippo store for the test suite.** `npm test` resolved the global hippo store (`getGlobalRoot()`: `HIPPO_HOME`, then `XDG_DATA_HOME/hippo`, then `~/.hippo`) to the developer's real `~/.hippo`, and the `tests/_real-store-guard.ts` `globalSetup` snapshotted that shared store before the run and failed the run if it changed. But `~/.hippo` is written continuously by the developer's own Claude Code `UserPromptSubmit` hook, `hippo context`, which strengthens every recalled memory: any prompt submitted while `npm test` ran tripped the guard with a false-positive "test-isolation leak" and exited the run non-zero even though every test passed. `vitest.config.ts` now points `HIPPO_HOME` at a fresh per-run temp dir, so the guard watches a store no external process touches. A genuine test leak is still caught (it mutates the run-level temp store), the developer's real `~/.hippo` is never touched by the suite, and `teardown()` removes the temp dir. The `getGlobalRoot` test in `tests/shared.test.ts` is rewritten hermetic: it controls its own `HIPPO_HOME`/`XDG_DATA_HOME` rather than relying on the ambient `~/.hippo` fallback.

### Tests

`tests/resolve-conflict.test.ts` adds 6 tenant-isolation cases (scoped versus unscoped `listMemoryConflicts`, cross-tenant `resolveConflict` denial, cross-tenant `--forget` failing to delete the loser, the detector skipping a cross-tenant pair, a stale cross-tenant row not seeding `conflicts_with`). `tests/rate-limit.test.ts` adds 7 token-bucket unit cases; `tests/server-lifecycle.test.ts` adds 2 real-server integration cases (a `/v1/` burst drawing `429`s with `/health` exempt, and the `HIPPO_V1_RPS=0` disable knob). `tests/shared.test.ts`'s `getGlobalRoot` test is rewritten as 3 hermetic cases (the `~/.hippo` fallback, the `HIPPO_HOME` branch, the `XDG_DATA_HOME` branch), each controlling its own environment. All against the real DB / real server. Full suite: 217 files, 1583 tests, green.

### Not in this release

- The unscoped `readEntry` / `loadSearchEntries` call-site audit in CLI, dashboard and refine; `hippo_peers` (intentionally cross-project); and garbage collection of stale pre-fix cross-tenant conflict rows. All tracked in `TODOS.md`.

## 1.10.1 (2026-05-21): stop() pidfile-ownership guard

A patch release closing the last open item in the v0.37 server-hardening cluster (deferred from v1.10.0), plus a sync of every version field to `1.10.1`. Plan: `docs/plans/2026-05-21-stop-pidfile-ownership.md`.

### Shipped

- **`stop()` no longer unlinks a newer server's pidfile.** `serve()`'s `stop()` and the `cli.ts` `runViaServerIfAvailable` stale-pidfile self-heal both removed `server.pid` unconditionally. If a second server had started on the same hippoRoot and rewritten the pidfile, the first server's shutdown deleted the second one's pidfile and orphaned it (no longer discoverable by `detectServer` or the thin client). The new `removePidfileIfOwned` (`src/server-detect.ts`) unlinks the pidfile only when its recorded `(pid, started_at)` identity matches the caller's own; `stop()` and the cli self-heal are rewired to it. `removePidfile` (unconditional removal) is retained as a primitive. A residual microsecond read-to-unlink window is documented and accepted, consistent with `detectServer`.
- **Every version field synced to `1.10.1`.** `package.json`, the lockfile, `openclaw.plugin.json`, `src/version.ts` (`PACKAGE_VERSION`), and both `extensions/openclaw-plugin` manifests are now consistent. v1.9.x and v1.10.0 had bumped only some: `openclaw.plugin.json` was stranded at `1.9.3` (caught by `tests/openclaw-package.test.ts`), and `src/version.ts` at `1.8.1` left `/health` and the MCP `serverInfo` under-reporting the version. A version-parity guard covering every manifest is tracked in `TODOS.md` so the drift cannot recur.

### Tests

`tests/server-detect.test.ts` adds 7 `removePidfileIfOwned` unit cases (identity match, `started_at` mismatch, `pid` mismatch, missing pidfile, malformed JSON, valid JSON without identity fields, literal `null`). `tests/server-lifecycle.test.ts` adds an integration test proving a foreign pidfile survives `stop()`. `tests/cli-thin-client.test.ts` adds a spawned-CLI test driving the connection-refused self-heal branch. Full suite: 216 files, 1566 tests, green.

### Not in this release

- `hippo forget --archive` over the server-routed HTTP path (tracked in `TODOS.md`).

## 1.10.0 (2026-05-21): server and lifecycle hardening

A pivot from the F-track research arc to product hardening. This release closes the "server / lifecycle hardening" cluster from `TODOS.md`: deferred follow-ups from the v0.37 server-mode work, the v0.40 security pass, and the A3 envelope review. Plan: `docs/plans/2026-05-21-server-lifecycle-hardening.md`.

### Shipped

- **H1: stale-pidfile and PID-reuse detection.** `detectServer` (`src/server-detect.ts`) is now async. After the `process.kill(pid, 0)` liveness check it issues a `GET /health` and confirms the answering process is this hippo server by matching `started_at`, so a reused pid that passes the signal check but belongs to a different or non-hippo process is caught. `serve()` threads one `startedAt` into both the pidfile and `/health` so the values are comparable. The probe runs only when a pidfile exists and the pid is live, so the common no-server path is unchanged. The probe target is validated as a loopback `http` url, the response body is read under a 64 KB cap, and a probe timeout (ambiguous, since the server may be alive but busy) returns null without unlinking the pidfile.
- **L3: pidfile schema version.** The pidfile now carries `schema: 1`; readers treat a missing `schema` as legacy and still accept it.
- **H3: concurrent `hippo serve` detection.** `serve()` probes for a live peer before `listen()` and refuses to start with a clear "already running on port N" error instead of racing for the pidfile.
- **M3: oversized-body socket leak.** The `BodyTooLargeError` (413) path now calls `req.destroy()` so the remainder of an over-cap request body is not drained into an already-answered exchange.
- **H2: `HIPPO_REQUIRE_SERVER` env knob.** When set, the CLI errors instead of silently falling back to direct mode (which discards a configured `HIPPO_API_KEY`). Default behaviour is unchanged.
- **A3: `hippo forget --archive --reason`.** `hippo forget <id>` on a raw, append-only memory previously failed with a misleading "Memory not found". It now reports the append-only nature and points at `--archive`, which routes to the sanctioned `api.archiveRaw` path. `--archive` always takes the direct path; the HTTP `forget` route does not carry it.

### Tests

New `tests/cli-forget.test.ts` (4 cases). `tests/server-detect.test.ts` rewritten (9 cases: the H1 probe matrix, the L3 schema cases, and a forged-pidfile rejection). `tests/server-lifecycle.test.ts` gains H3 and M3 cases; `tests/cli-thin-client.test.ts` gains H2 and a `forget --archive` bypass case. Full suite: 216 files, 1557 tests, green.

### Not in this release

- Phase 3 tenant-guard threading (deferred to its own plan).
- `hippo forget --archive` over the server-routed HTTP path, and a `stop()` pidfile-ownership check (both tracked in `TODOS.md`).

## 1.9.3 (2026-05-20) — reranker review-tail patch

This release does not re-assert the retracted −10pp magnitude.

Post-merge cleanup of the three review-tail items called out on PR #25, plus the version-bump that aligns `package.json` with the v1.9.x docs shipped on master. No new research; no canonical-doc changes beyond this entry, the README "What's new" block, and the package version.

### Shipped

- **`src/rerankers/llm.ts`** — wires `AbortController` + `setTimeout` around the `fetch` call. Default timeout 30 s; overridable via the new `HIPPO_LLM_RERANKER_TIMEOUT_MS` env var. On timeout (or any other fetch failure) the reranker silently falls back to identity ordering — recall must not hang on a wedged endpoint.
- **`src/rerankers/cross-encoder.ts`** — emits a single `console.warn` on first identity-fallback per process. Prevents the silent-fallback failure mode where a user believes the cross-encoder is doing work it isn't. Subsequent fallbacks within the same process are silent.
- **`src/rerankers/index.ts` + `src/rerankers/types.ts`** — drop the orphaned `RerankSignals` type. Its sole consumer (`src/rerankers/features.ts`) was retracted in v1.9.1 (F10 HARD RETRACTION); the type is now dead code and is removed at both the re-export and the definition.
- **`package.json` / `package-lock.json`** — version 1.8.1 → 1.9.3, aligning the npm-published version with the v1.9.0 / v1.9.1 / v1.9.2 entries already documented on master. No v1.9.0/1/2 line items were ever published to npm; this is the first published `1.9.x` release and it carries the cumulative scope from F6 (rerankers) through F13 (chunk-per-turn) plus the F10 retraction.

### Mechanism cumulative-null status

Per `docs/RETRACTION.md:94-113`. No `src/` change in this patch touches the dlPFC goal-stack mechanism. The cumulative-null status is unaffected.

### Tests

- **`tests/rerankers/llm.test.ts`** — new case asserting the AbortController wires through: a 5 ms timeout + a fetch that respects `init.signal` proves the reranker aborts and falls back to identity ordering without throwing.
- **`tests/rerankers/cross-encoder.test.ts`** — new case asserting `console.warn` is called at most once across three repeated calls (the "don't spam" contract).

### Not in this patch

The Track 2 cross-encoder's real-evaluation gate, the LLM reranker's full characterisation, the cross-encoder model-fetch path under HF egress restrictions — all remain queued per v1.9.0's deferred-characterisation note. F9 hybrid-retrieval parity (BM25 + dense-vector RRF on `_s`, the `[critical, next]` track in `ROADMAP-RESEARCH.md`) is the recommended next research arc.

## 1.9.2 (2026-05-12) — F13 chunk-per-turn LongMemEval R@5 = 86.8 on oracle (Gate-B PASS)

This release does not re-assert the retracted −10pp magnitude.

Plan F13 (LongMemEval R@5 target — Track 6: chunk-per-turn ingestion, `docs/evals/2026-05-12-r5-track6-chunk-per-turn-prereg.md`) addresses the structural pathology that limited every prior LongMemEval track (F8 / F9 / F10 / F11 / F11+F9 / F12): sessions in `data/longmemeval_oracle.json` are 14,292 chars median (~3,500 tokens), but the embedders we can reach (MiniLM, BGE-base, multilingual-e5-large) cap at 512–514 tokens. Every prior track was embedding only the first ~2 turns of each 12-turn session and truncating the rest. F13 replaces session-level embedding with turn-level embedding: each ~550-char turn → one vector, no truncation. At retrieval time the query is scored against all turn vectors and max-pooled by source `session_id` to return top-K sessions. The existing `evaluate_retrieval.py` scorer's session-id matching contract is preserved verbatim (each F13 retrieval result tags itself with `[session_id]`).

Gate-A PASS: 10,866 turns indexed across all 940 oracle sessions. Vector dim 768 (BGE-base), L2-norms in [0.999, 1.001], session_id tags on every turn.

Gate-B PASS: F13 + F9 sub-agent rerank stack R@5 = 86.8 on `data/longmemeval_oracle.json` (threshold ≥ 83.2 = F11+F9 deployable best 78.2 + 5pp). The F13 chunked baseline alone scored 79.0; the F9 rerank converted 7.8/14.4 = 54% of the top-20 headroom — substantially above the ~7-10% capture rate observed on F11+F9 and F12+F9 session-level inputs. Plausible mechanism: a sub-agent reading a focused 500-char turn judges relevance cleanly; a sub-agent reading a 14,000-char session has to skim 12 turns and often picks the first plausible-looking one.

### Roadmap target met (oracle split)

The R@5 ≥ 85 % roadmap target (`ROADMAP-RESEARCH.md` F6) is now met on `data/longmemeval_oracle.json`: 86.8 ≥ 85.0. The target was NON-binding per every prior prereg; the description here is retrospective, not a re-assertion of any retracted magnitude. The figure is descriptive characterisation, not a claim about a different split or embedder.

### Split-mismatch with gbrain (unchanged)

`longmemeval_oracle` carries 3 sessions per haystack; gbrain v0.28.8's published 97.60 figure is on `longmemeval_s_cleaned` (~40 sessions per haystack) with OpenAI `text-embedding-3-large@1536`. Both HF Hub (the `_s` distribution channel) and the OpenAI API are host-blocked from this sandbox (verified 2026-05-12 via `curl -sSI ... 403 host_not_allowed`). F13's 86.8 is NOT directly comparable to gbrain's 97.60 — the split mismatch AND the embedder mismatch are documented in the F13 result doc's binding split-mismatch disclosure.

### Per-K spread (F13 baseline → F13+F9 stack, oracle)

| K | F13 baseline | F13 + F9 stack |
|---:|---:|---:|
| 1 | 51.0 | 70.8 |
| 3 | 72.2 | 84.2 |
| 5 | 79.0 | 86.8 |
| 10 | 86.6 | 90.2 |
| 20 | 93.4 | 93.4 |

### Cross-track R@5 status (as of this release, oracle)

- F8 hybrid tuning on MiniLM:                    76.8 (Gate-B FAIL @ 77.6).
- F9 v2 sub-agent LLM rerank on MiniLM:          78.0 (Gate-B FAIL @ 80.6).
- F11 BGE-base baseline:                         77.0 (Gate-B FAIL @ 81.8).
- F11 + F9 stack:                                78.2 (Gate-B FAIL @ 81.8).
- F10 features-enriched (retracted v1.9.1):      59.2 (Gate-B FAIL @ 80.8).
- F12 multilingual-e5-large + top-100 + F9:      78.8 (Gate-B FAIL @ 83.2, HARD RETRACTION executed 2026-05-11).
- **F13 + F9 stack (new deployable best):        86.8 (Gate-B PASS @ 83.2, margin +3.6).**

### Changes shipped

- `benchmarks/longmemeval/chunk_per_turn_embed.mjs` — one-off turn-level ingestion script. Accepts `--model <id>` (defaults to e5-large; BGE-base supported with appropriate pooling / no prefix).
- `benchmarks/longmemeval/chunk_per_turn_retrieve.mjs` — max-pool turn-to-session retrieval. Output is `evaluate_retrieval.py`-compatible JSONL.
- `docs/evals/2026-05-12-r5-track6-chunk-per-turn-{prereg,result}.md` — F13 prereg + result.
- `.gitignore` — adds `benchmarks/longmemeval/data/turn_index_*.json` and `results/f13_*/`.

No `src/` changes. F13 reuses F11/F12's existing `poolingFor` / `prefixFor` / `preferredBackend` dispatch helpers (retained in `src/embeddings.ts` per F11's and F12's dispatch-shape carve-outs); the cumulative-null status of the dlPFC goal-stack mechanism (`docs/RETRACTION.md:94-113`) is unaffected by this release.

### Outside-voice reviews

- Prereg: PASS_WITH_NOTES (13/13 checks). Three optional improvements applied: eval-contract explicit, session-coverage Gate-A floor, ~40-session count reconciled with the official LongMemEval README.
- Result doc: PASS_WITH_NOTES (14/14 checks). One required fix applied (Provenance section embedder was copied incorrectly from F12 and read e5-large; corrected to BGE-base, the prereg-authorised fallback). One optional fix applied (duplicate cumulative-null section removed).

### Notes for next track

The F13 + F9 sub-agent rerank stack costs 50 sub-agent dispatches per LongMemEval run (~10 min of controller wall time per run on average), and the turn-level index is a 181 MiB JSON artifact that lives at `benchmarks/longmemeval/data/turn_index_bge.json` (gitignored). A future track could harden this into a hippo-store-shaped ingestion path (currently the F13 retriever bypasses the hippo store entirely and reads from `data/longmemeval_oracle.json` directly), but the structural lever — chunk per turn, not per session — is the part that moves the number; making it production-shaped is plumbing, not retrieval research.

---

## 1.9.1 (2026-05-11) — F10 features-reranker retraction

This release does not re-assert the retracted −10pp magnitude.

Plan F10 (LongMemEval R@5 target — Track 3: richer ingest, `docs/plans/2026-05-11-r5-track3-richer-ingest.md`) tested the hypothesis that the features reranker shipped in v1.9.0 would add measurable retrieval value when ingest populated entry-level signals (`confidence`, `kind`, `schema_fit`, `strength`, `outcome_positive`, `outcome_negative`). 19 Claude-sub-agent invocations extracted signals for all 940 LongMemEval sessions (Gate-A coverage: 100% any-field non-default; 3 of 5 fields with ≥ 50% per-field non-default coverage). The features reranker on the enriched store produced R@5 = 59.2 against features-default R@5 = 75.8 on the same bge-base embedding model — 21.6pp short of the prereg's Gate-B threshold of features-default + 5pp.

Per the F10 prereg's HARD RETRACTION clause, the features track is removed from `src/`.

### Retracted

- **`src/rerankers/features.ts`** — the Track 1 features reranker shipped in v1.9.0.
- **`tests/rerankers/features.test.ts`** — the corresponding unit tests.
- **`benchmarks/micro/fixtures/reranker_features.json`** — the micro-eval fixture.
- **The `'features'` case in `src/rerankers/index.ts`** — the dispatcher entry is removed; the registry now contains only `'cross-encoder'` and `'llm'`.

Result doc: `docs/evals/2026-05-11-r5-track3-richer-ingest-result.md`. Prereg: `docs/evals/2026-05-11-r5-track3-richer-ingest-prereg.md`. Outside-voice review: PASS_WITH_NOTES (both notes applied — threshold-deviation acknowledged, TL;DR causal claim softened to hypothesis framing).

### Hypothesis (post-hoc, not isolated from confounds)

Session-level signals (how confident-sounding a session is, what kind of session it is, etc.) appear orthogonal to query-document relevance. The features reranker's per-memory weight variance (e.g. `confW ∈ [0.70, 1.30]` across the four confidence tiers) re-shuffles candidates on dimensions that do not correlate with whether the candidate contains the answer to a given query. A controlled mechanism-isolation experiment is out of scope for this release.

### Preserved (NOT retracted)

- **`src/rerankers/cross-encoder.ts`** — the Track 2 cross-encoder reranker (identity fallback in egress-restricted environments). Its real-evaluation gate is the responsibility of a future plan with HF access or a non-HF mirror that ships the MS-MARCO MiniLM reranker.
- **`src/rerankers/llm.ts`** — the Track 3 LLM reranker skeleton.
- **The `RerankerFn` seam in `hybridSearch`**, the `--reranker` / `--reranker-top-k` CLI flags, the LongMemEval harness flag plumbing, and `benchmarks/longmemeval/run_reranker_sweep.mjs` — all preserved. `run_reranker_sweep.mjs`'s `features_topk*` configs will now raise "Unknown reranker: features" at runtime; updating that sweep config is a separate housekeeping task and is NOT part of this retraction.
- **F11 embedding upgrade (BGE-base):** the `poolingFor` dispatch in `src/embeddings.ts`, the `Xenova/bge-base-en-v1.5` support in `scripts/fetch_embedding_model.mjs`, and the per-model pooling test all stand; F11's standalone Gate-B failed but that result is config-level, not a `src/` retraction.

### Cross-track R@5 status (as of this release)

- F8 hybrid tuning on MiniLM: R@5 = 76.8 (Gate-B FAIL @ 77.6 = baseline + 2pp).
- F9 v2 sub-agent LLM rerank on MiniLM: R@5 = 78.0 (Gate-B FAIL @ 80.6 = baseline + 5pp).
- F11 BGE-base baseline (no reranker): R@5 = 77.0 (Gate-B FAIL @ 81.8 = F8 best + 5pp).
- **F11 + F9 stack (BGE-base + sub-agent LLM rerank, exploratory follow-up appended to F11 result doc 2026-05-11): R@5 = 78.2** (Gate-B FAIL @ 81.8; new cross-track best, margin 0.2 over F9 v2).
- F10 features-enriched (this release, retracted): R@5 = 59.2 (Gate-B FAIL @ 80.8; HARD RETRACTION).

Roadmap target R@5 ≥ 85% is NOT MET by any track. NON-binding per each prereg.

### Mechanism cumulative-null status

Per `docs/RETRACTION.md:94-113`. F10 changes the contents of memory rows but does not alter the goal-stack mechanism in `src/`. The cumulative-null status of the dlPFC goal-stack mechanism is independent of this evaluation.

## v1.9.0 — 2026-05-11 — F6 reranker hardening

This release does not re-assert the retracted −10pp magnitude.

**Shipped:**
- `RerankerFn` seam in `hybridSearch` (`src/search.ts`); reranker runs after MMR, before budget filtering. Default off; opt-in via the `reranker` option.
- `--reranker <name>` and `--reranker-top-k <n>` flags on `hippo recall` (`src/cli.ts`) and on the LongMemEval harness (`benchmarks/longmemeval/retrieve_inprocess.mjs`).
- Track 1 features reranker (`src/rerankers/features.ts`): re-scores the top-K candidates using `MemoryEntry`-level signals (confidence tier, kind, schema_fit, strength, outcome counts, query-doc overlap). No external dependencies; sub-millisecond per query.
- Track 2 cross-encoder reranker (`src/rerankers/cross-encoder.ts`): MS-MARCO MiniLM via `@xenova/transformers` optional peer dep. Falls back to identity ordering if the model fails to load (no transformers installed, no HF access for first-run download, etc.).
- Track 3 LLM reranker skeleton (`src/rerankers/llm.ts`): listwise permutation rerank against an OpenAI-compatible endpoint. Env-gated on `HIPPO_LLM_RERANKER_URL` to prevent accidental cost. Skeleton only — full characterisation deferred.
- LongMemEval sweep orchestrator (`benchmarks/longmemeval/run_reranker_sweep.mjs`) and `scripts/aggregate_reranker_sweep.mjs`.
- Tier-1 micro-eval fixtures: `reranker-features` (smoke test for the CLI wire-up) and `reranker-cross-encoder` (semantic-over-lexical test, falls back to identity in sandboxed environments).

**Eval result:** `docs/evals/2026-05-10-f6-reranker-result.md` (prereg: `docs/evals/2026-05-10-f6-reranker-prereg.md`).

**Workload-validity verdicts (binding gates from the prereg):**
- Gate-A (firing rate per track on 500-question LongMemEval): features track PASS (500/500). Cross-encoder PASS-with-caveat (500/500 invocations all took the identity-fallback branch because the MS-MARCO model was not downloadable in the test environment; this is NOT a real cross-encoder evaluation).
- Gate-B (hyperparameter discrimination across features_topk{20,50,100}): FAIL. The three settings produced byte-identical R@K. Per the prereg, no per-hyperparameter R@5 effect is claimed.

**Roadmap target framing:** `ROADMAP-RESEARCH.md` lists "R@5 ≥ 85% on LongMemEval with the existing hybrid path" as the F6 success criterion. The result doc reports R@5 = 75.4% (features, all three settings) and 75.6% (baseline) on the workload tested. The roadmap target is not met by the current workload + ingest path. Per the prereg this is descriptive characterisation, not a binding gate. The mechanism ships; the path to a real R@5 ≥ 85% attempt requires either a real cross-encoder evaluation (HF access) or a richer ingest path that populates entry-level signals the features reranker reads.

**Mechanism cumulative-null status:** the dlPFC goal-stack mechanism's cumulative-null status (`docs/RETRACTION.md:94-113`) is independent of this release.

## 1.8.1 (2026-05-09)

Pre-commitment retraction patch. The v1.8.0 prereg's "Pre-committed v1.9 direction" — *"v1.9 will run the dlPFC goal-stack mechanism on the LongMemEval R@5 corpus as a cross-validation"* — is **RETRACTED publicly**. Outside-voice review on two iterations of the v1.9 plan (v1 and v2) found six structural barriers that preclude the mechanism from firing on the LongMemEval corpus + canonical harness as shipped, without substantial re-architecture. Per `CLAUDE.md` "Root Cause Over Patches" + the v1.7.9 pre-emptive retraction precedent: public retraction is the principled call. **This release does not re-assert the retracted −10pp magnitude.** Per `docs/RETRACTION.md`.

### Retracted

- **v1.8.0 prereg "Pre-committed v1.9 direction" (LongMemEval R@5 cross-validation).** Six structural barriers identified by source-reading: (1) canonical harness `retrieve_inprocess.mjs` calls `hybridSearch` directly, which never invokes `applyGoalStackBoost`; (2) LongMemEval ingest writes session-tag namespace as `[session_id, date:YYYY-MM-DD]` only — zero content-derived tokens, so boost match (exact-equality) is structurally 0; (3) v2 plan's `pushGoal` API field was wrong (`tag` vs `goalName`); (4) `MAX_ACTIVE_GOAL_DEPTH=3` interaction with top-3 stems would suspend stem[0]; (5) v2 cumulative-null trigger AND clause was unreachable; (6) workload-validity gate ceremonial. Three options considered (re-ingest, harness rewrite, retract); option C (retract) chosen per Root Cause Over Patches.
- **v1.10 pre-commitment from v1.9 plan v2 ("iterate goal-tag mapping").** Downstream of v1.9 design that is now retracted; retracted alongside.

### Added

- **`docs/RETRACTION.md` "Pre-registration discipline rule" subsection at top.** Pre-registration discipline rule: **"No future eval pre-commitment is accepted as binding without (a) source-read of the code paths the design depends on, AND (b) a 1-question dry-run wired through the actual mechanism path that confirms the mechanism FIRES before pre-reg locks."** Codifies the lesson from v1.9.
- **`docs/RETRACTION.md` "v1.9 pre-commitment retraction" subsection.** Full retraction with the 6 structural barriers cited and source-line anchored.
- **`docs/RETRACTION.md` "Mechanism-effect status (cumulative null escalation)" subsection.** Pre-committed as a trigger in v1.9 plan v2; fires here on cumulative null evidence (v1.7.5 SANITY_FAIL, v1.7.6 B*=NULL, v1.7.7 SANITY_FAIL, v1.8.0 SAME=20/20 sign-only, v1.9 untestable). The mechanism's effect, AS MEASURED on the workloads tested, is null. The mechanism's CODE is preserved. Future eval releases will pre-register under the new discipline rule.
- **`docs/evals/2026-05-09-v1.9-pre-commitment-retraction.md`** — full retraction document with audit trail.
- **`docs/plans/2026-05-09-v1.9.0-longmemeval-cross-validation.md`** — preserved historical-retracted plan v2 (audit record of why retraction was the right call).

### Preserved (NOT retracted)

- **dlPFC goal-stack mechanism (CODE):** `pushGoal` / `completeGoal` hooks, `--use-goal-stack` flag (sequential-learning runner), `applyGoalStackBoost` helper, MCP `hippo_recall { session_id }` boost, HTTP `GET /v1/memories?session_id=` boost — all shipped from v1.7.4, all preserved.
- **v1.7.5/6/7/8 results.** Stand. The cumulative null escalation builds on them; it does not retract them.
- **All v1.7.x infrastructure** (adapter contract, calibration, `--restrict-late-to`, audit fixes, adversarial categories).

### What this release does NOT do

- Does NOT pre-commit a new v1.9.x or v1.10.x eval direction. Per the new discipline rule: pre-commit the *rule* (source-read + dry-run before pre-reg), NOT the next eval target. The next eval target will be drafted in a separate plan, with the rule applied.
- Does NOT retract any shipped code. Mechanism remains in code.
- Does NOT retract v1.7.5/6/7/8 results.

### Tests

- 1508 passing (no test changes; doc-only release). 0 regressions vs v1.8.0.

### Process note

Outside voice on v1.9 plan: round 1 found 4 architectural P0s in v1; round 2 found 6 P0s in v2 (3 of which were NEW structural barriers v1 review missed because v1 didn't read the source code). Total 49 findings across both rounds. The lesson is that pre-commitments without source-validation are not binding-quality commitments. v1.8.1 codifies that lesson.

## 1.8.0 (2026-05-09)

Adversarial-categories release. Adds 3 new trap categories to `benchmarks/sequential-learning` (10 → 13). Lesson vocabulary verified <0.30 Jaccard overlap with v1.7.5 lessons (`tools/jaccard-overlap.mjs`; max=0.033). Workload expands 50 → 62 tasks; late-phase metric (`--restrict-late-to 4`) preserved. **Mechanism characterisation only — this release does not re-assert the retracted −10pp magnitude** per `docs/RETRACTION.md`.

### Workload-validity verdict

**PASS** — C2 hippo-base lateMean = 0.25 (lattice rate; 25% across 20 seeds), 20 of 20 seeds non-zero. Pre-registered N=4 lattice rule from v1.7.7 satisfied (`mean ∈ [0.05, 0.50] AND ≥3 distinct seeds non-zero`). Framed as workload-validity / non-saturation check per `docs/RETRACTION.md`, NOT a magnitude criterion.

This is the first time across v1.7.5/6/7/8 that the C2 sanity gate has passed. Adversarial categories produce a non-saturated workload at the metric level (workload-validity sense, not magnitude).

### C3 mechanism characterisation (sign-only direction count, NOT magnitude)

C3 per-seed lattice histogram (over 20 seeds at adversarial-late N=4): 0/4=0 seeds, 1/4=20 seeds, 2/4=0 seeds, 3/4=0 seeds, 4/4=0 seeds. Every seed produced exactly 1 trap-hit out of the last 4 trap encounters.

Sign-only seed-pair direction count (vs C2 hippo-base):
- STRICTLY_LOWER (C3 < C2): **0**
- STRICTLY_HIGHER (C3 > C2): **0**
- TIED (C3 = C2): **20**

The dlPFC goal-stack boost (`--use-goal-stack`, `applyGoalStackBoost`) does not detectably change the per-seed late-4 lattice rate on this workload at this scale. Hook failures: push=0, complete=0. Tie-degeneracy: tiePass=false (all 20 paired diffs are zero).

> **Do not subtract.** Per `docs/RETRACTION.md`, magnitude differences between conditions are not reported in this release.

### Added

- **3 new adversarial trap categories** in `benchmarks/sequential-learning/traps.mjs::TRAP_CATEGORIES`: `timezone_naive`, `idempotency_retry`, `float_accumulation`. Lesson vocabulary verified <0.30 Jaccard overlap with v1.7.5 lessons via Porter-stem + extended stop-words (`tools/jaccard-overlap.mjs`; max=0.033, well under threshold).
- **6 new trap-encounter positions** in `TRAP_PLACEMENTS` (positions 11, 21, 33, 41, 51, 59 — distributed uniformly across early/mid/late regions, NOT clustered in late). Workload size 50 → 62 tasks; trap encounters 25 → 31. Existing 10 categories' positions unchanged.
- **`tools/jaccard-overlap.mjs` + `tests/jaccard-overlap.test.ts`**. Pre-registered Jaccard verification with Porter-stem stemming + extended stop-words (modal verbs, engineering function-verbs).
- **`tests/sl-bm25-mismatch.test.ts`** — independent BM25 sim verification (complement to Jaccard). Confirms new-category lessons are not trivially BM25-matched by existing-10 recall queries.
- **`tests/sl-traps-v1.8.test.ts`** — 17 schema invariants (13 categories, 31 trap encounters, 62 task slots, uniform position distribution, exact positions {11, 21, 33, 41, 51, 59}, adversarial-flag enforcement, existing-10 PRNG-stability vs v1.7.x).
- **`benchmarks/sequential-learning/traps.mjs::N_TASKS`** exported constant (62). `run.mjs` uses `N_TASKS` and `TRAP_CATEGORIES.length` dynamically (no more hardcoded 50/10).
- **`benchmarks/sequential-learning/traps.mjs::TRAP_PLACEMENTS` adversarial flag.** Adversarial categories use fixed positions (no shape-group shuffle). Preserves v1.7.x existing-10 placements unchanged.
- **`docs/evals/2026-05-09-v1.8.0-{adversarial-eval-prereg, claim-inventory, jaccard-verification, category-authoring-iteration-log, adversarial-eval-result}.md|.txt`** — full pre-registration audit trail.

### Pre-committed v1.9 direction (named BEFORE v1.8 ran)

v1.9 will run the dlPFC goal-stack mechanism on the **LongMemEval R@5 corpus** as a cross-validation on a fundamentally different benchmark (different metric: R@5 not trap-rate; different corpus: 500-question public; different mechanism stress: retrieval-on-fixed-corpus vs agent-improvement-over-time). The v1.8 PASS verdict does not change this pre-commitment. Pre-registered in `docs/evals/2026-05-09-v1.8.0-adversarial-eval-prereg.md` "Pre-committed v1.9 direction" subsection.

### Tests

Test count to be re-verified by `npx vitest run` pre-publish; final count cited in commit message and `/ship-check` output.

## 1.7.9 (2026-05-09)

Retraction patch + post-audit P2 polish (3 of 4; P2-1 deferred to v1.7.10). The "−10pp goal-stack lift on sequential-learning benchmark" magnitude claim is RETRACTED publicly across the README hero, benchmark READMEs, RESEARCH/ROADMAP-RESEARCH thesis lines, TODOS, the v1.7.5/6/7 eval result docs, the canonical B3 plan, AND the GitHub Release notes for every prior tag that asserted the magnitude (v0.11.0/v0.39.0/v1.7.4/v1.7.5/v1.7.6/v1.7.7). The mechanism (dlPFC goal-stack boost from v1.7.4) and the benchmark harness remain shipped. v1.8.0 (queued separately) explores adversarial trap categories as **mechanism characterisation** under the magnitude-smuggling guard pinned in `docs/RETRACTION.md` this release.

### Retracted

- **−10pp goal-stack late-phase trap-rate lift on the sequential-learning benchmark.** Three pre-registered workload variants tested without producing a discriminating workload: v1.7.5 (full-late = last 7 of 25, C2 SANITY_FAIL on saturation), v1.7.6 (5 budgets × 10 seeds, B*=NULL on workload floor), v1.7.7 (`--restrict-late-to 4`, C2 SANITY_FAIL on N=4 lattice gate). C2 hippo-base late mean = 0.0% across every seed in every variant. The original v0.39 informal "78% → 14% over 50 tasks" headline does not reproduce on the formal multi-seed harness across three pre-registered workload knobs. **The magnitude is RETRACTED. The mechanism is shipped; no magnitude is currently claimed.**
- **Departure from v1.7.7 pre-reg (deliberate, declared).** The v1.7.7 prereg explicitly distinguished SANITY_FAIL (no retraction) from NOT_SUPPORTED (retraction). v1.7.9 deviates from that distinction on cumulative-evidence grounds. The original prereg's distinction was wrong: three SANITY_FAILs across distinct workload knobs is meaningful negative evidence regardless of formal verdict label. v1.7.9 retracts on cumulative evidence rather than waiting for v1.8. This prioritises public-surface honesty over completing the full pre-registered escalation chain. v1.8 still runs as planned; the retraction is independent of v1.8 outcome.

### Preserved (NOT retracted)

- **Mechanism:** `pushGoal` / `completeGoal` adapter hooks, `--use-goal-stack` flag, `applyGoalStackBoost` helper, MCP `hippo_recall` boost, HTTP `GET /v1/memories?session_id=` boost — all shipped from v1.7.4..v1.7.5, all working as designed.
- **Benchmark harness:** multi-seed adapter contract, paired permutation CI, lattice gate framework, `--restrict-late-to`, `--budget`, `--eval-strict`, `analyze-v1.7.7.mjs`, `aggregate.mjs::pairedPermutationCI` — all shipped, all reusable for v1.8 and beyond.
- **B3 line item:** continues into v1.8.0 as adversarial-categories **mechanism characterisation** with NO magnitude attached. Constraints pinned in `docs/RETRACTION.md`.

### Added

- **`docs/RETRACTION.md`** — pinned magnitude-smuggling guard. Any v1.8 (or later) result doc that contains pre-registered numeric pass/fail thresholds, "magnitude", "Δ = ", "≥Xpp", "lift", or any framing equivalent to a magnitude claim must reference this doc and explain why the framing does not constitute a retracted-magnitude re-assertion.
- **README "What's new in v1.7.9"** + retraction wrapper at the trap-rate-table region; hero bullet at line 39 retracted; v1.7.5/6/7/8 What's-New blocks each carry a `> Updated v1.7.9: magnitude RETRACTED.` forward-pointer.
- **`docs/evals/2026-05-09-v1.7.9-retraction-inventory.md`** captures every retracted location with literal grep anchor.
- **Forward-pointer blocks on v1.7.5 / v1.7.6 / v1.7.7 eval result docs** linking to v1.7.9 retraction (anchored as `## Update 2026-05-09 (v1.7.9 release)` to disambiguate from same-date v1.7.6/7 update tails).
- **GitHub Release retroactive retraction notes** (append-only) on v0.11.0, v0.39.0, v1.7.4, v1.7.5, v1.7.6, v1.7.7.
- **`benchmarks/README.md` + `benchmarks/sequential-learning/README.md`** retraction wrappers — NPM-tarball-shipped surfaces previously missed.
- **Retroactive notes** appended to `CHANGELOG.md` v1.7.4 entry (`> Retired v1.7.9.`) and to the v0.11.0-era benchmark-introduction line (`> RETRACTED v1.7.9`).
- **Thesis-line retractions** in `RESEARCH.md:176` and `ROADMAP-RESEARCH.md:28`.
- **Forward-pointer block** at top of `docs/plans/2026-04-29-b3-dlpfc-depth.md`.

### Fixed (P2 polish from v1.7.8 audit — 3 of 4; P2-1 deferred)

- **(P2-2) README ↔ result.md rounding consistency.** README "What's new in v1.7.7" cited "early=77%, mid=5%" while `docs/evals/2026-05-09-v1.7.7-goal-stack-eval-result.md` reported "77.28%, 4.50%". Both now use one decimal place: "77.3%, 4.5%". **Underlying values unchanged in raw JSON at `results/v1.7.7-eval-C2-hippo-base/benchmark-1778313821987.json`. Math unchanged; only display rounding adjusted. The retraction release does not modify any computed numbers.**
- **(P2-3) `pairedPermutationCI` docstring.** Clarified the implementation as a recentred-percentile bootstrap (sign-flip Monte Carlo) rather than bias-corrected (BCa); documented the 10k resample default and the n<5 short-circuit semantics.
- **(P2-4) `BAND_LOW` / `BAND_HIGH` provenance comment.** `calibrate.mjs` band constants now carry an inline comment citing the v1.7.6 plan v2 commit (`c670ac9`) as the pre-registration anchor and explaining the v1.7.6 derivation (±10pp around v0.11.0 informal headline, since superseded by v1.7.7's N=4 lattice gate `[0.05, 0.50]` — and now retracted v1.7.9).

### Deferred to v1.7.10

- **(P2-1) `Float64Array(1 << n)` micro-opt in `analyze-v1.7.7.mjs::exactPairedPermutationCI`.** Behavior-changing micro-opt in a retraction release would create a "fiddling with numbers in the same release that retracts numbers" audit smell. Deferred. To be shipped as a standalone v1.7.10 perf patch.

### Tests

- Test count unchanged vs v1.7.8 (P2-2/3/4 are rounding + comment + docstring; no new tests added). Actual count re-verified by `npx vitest run` pre-publish; final count cited in commit message and `/ship-check` output, not in this CHANGELOG entry.

## 1.7.8 (2026-05-09)

Audit-fix patch release. Closes 9 P0+P1 items found by retroactive `/review` of v1.7.5/v1.7.6/v1.7.7 (those releases shipped with the review chain partially skipped). No behavior change for end users; integrity fixes for the eval audit trail.

### Fixed

- **(P0-1) Analyzer sanity gate now matches the v1.7.7 pre-reg.** `analyze-v1.7.7.mjs::sanityPass` was using the inherited `[0.04, 0.24]` band (calibrated for chronological-third late slice = last 7 of 25), not the pre-reg's actual N=4 lattice rule (`mean ∈ [0.05, 0.50]` AND `≥3 distinct seeds non-zero`). Bug was dormant on v1.7.7 (eval was C2-only, verdict applied by hand) but would mis-fire on any future re-run or v1.8 reuse. Decision text + console.log updated to match.
- **(P0-2) v1.7.6 calibration result-doc honesty.** Replaced overstated "pre-registration discipline is doing its job" framing with explicit citation of plan v2 (`c670ac9`) + `calibrate.mjs` (`9cd83de`) as the actual pre-registration anchors. v1.7.6 had no separate prereg/inventory/analyzer files — the calibration was a parameter sweep with a mechanical rule in code, not a hypothesis run with verdict templates. New "Pre-registration anchors" block makes this explicit.
- **(P1-1) v1.7.7 prereg SUPPORTED template band drift.** Template still cited `[4%, 24%]` and `lower-CI > 0`. Corrected to match the actual pre-registered N=4 lattice rule (`[5%, 50%]` AND `≥3 distinct seeds non-zero`) so v1.8 reuse won't drift.
- **(P1-3) `selectBStar` reason string honesty.** Reason string unconditionally referenced "(not starved)" even when no candidate carried the `starved` flag (the v1.7.6 reality — guard was deferred). Now conditional on whether any candidate carries the flag.
- **(P1-4) Hippo adapter instance state.** `_sessionId / _pushedCount / _completedCount` were module-level `let` in v1.7.5..v1.7.7 — two parallel adapter consumers (future `--workers N`) would clobber each other's `HIPPO_SESSION_ID` and corrupt counts. Counters also never reset between `init()` calls. Hoisted to instance fields; `hippoExec` takes `sessionId` as explicit arg; `init()` resets all three. Race-condition free for future parallel benchmarks.
- **(P1-5) `ROADMAP-RESEARCH.md:156` forward-pointer.** B3 success criterion cited "−10pp" without a status update; missed by v1.7.5's claim inventory. Added Status update block citing three pre-registered workload variants tested without discrimination (v1.7.5 SANITY_FAIL, v1.7.6 B*=NULL, v1.7.7 SANITY_FAIL), v1.8 as last escalation, falsifiability pre-committed.
- **(P1-6) `runOneBudget` defensive throw.** Empty `j.conditions` would TypeError with no hint of which seed file. Now throws with file path + JSON shape context.

### Tests

- **(P1-2) Verdict-precedence chain locked** with 2 new tests in `tests/sl-analyze-v1.7.7.test.ts` covering `sanityPass=false` wins over hookFailures+tiePass+SUPPORTED-shaped numbers (rank 1) and `hookFailures` wins over tiePass+SUPPORTED when sanity passes (rank 2).
- **(P1-7) `selectBStar` defensive starvation path** with 2 new tests in `tests/sl-calibrate.test.ts` covering skip-on-`starved=true` and reason-string omission when no candidate carries the flag.

### Process note

v1.7.5/v1.7.6/v1.7.7 each shipped without running the full `/self-review → /review → /ship-check → /publish-repo` workflow chain. v1.7.8 is the corrective patch + a memory entry locking the chain in for future releases. The 4 P2 items found in the audit (exact-enumeration `Float64Array` micro-opt; README rounding consistency; `pairedPermutationCI` docstring tightening; `BAND_LOW/BAND_HIGH` provenance comment) are deferred to v1.7.9 if appetite exists.

## 1.7.7 (2026-05-09)

Window-restriction release. Tests the v1.7.6 pre-committed escalation: narrow the late-phase metric from "last 7 of 25 (chronological-third)" to "last 4 of 25" via a new `--restrict-late-to <int>` flag. C2 sanity preflight at the N=4 lattice gate FAILED (mean=0.00% across 20 seeds; 0 of 20 seeds non-zero). C3 (goal-stack ON) was NOT collected — no goal-stack data leak. Per pre-committed escalation, v1.8 retargets to adversarial trap categories. The −10pp goal-stack hypothesis remains untested for the third pre-registered workload variant.

### Added

- **`--restrict-late-to <int>` flag** end-to-end through `run.mjs::parseArgs` → `simulate()` → `hitRateByPhase(results, restrictLateTo)`. When set, late = last N trap encounters; early/mid re-split (Option A: `early = first ceil((n-N)/2)`, `mid = remainder`) so the three slices stay disjoint and exhaustive. Default `null` preserves chronological-third behavior (v1.7.0..v1.7.6 backward-compat). `buildOutput()` records `restrict_late_to` in JSON for audit.
- **`benchmarks/sequential-learning/analyze-v1.7.7.mjs`** with extracted pure `computeVerdict({c2Late, c3Late, delta, ciLow, ciHigh, hookFailures, sanityPass, tiePass})` (post-review P1-2 tie-degeneracy guard) + `exactPairedPermutationCI` helper (post-review P1-3 sensitivity check at near-threshold). Both exported, both unit-tested.
- **`docs/evals/2026-05-09-v1.7.7-goal-stack-eval-{prereg,claim-inventory,result}.md`** — full pre-registration audit trail with three verdict templates (SUPPORTED, NOT_SUPPORTED with cumulative-evidence + cliff-humility + hard-stop retraction, SANITY_FAIL), N=4 lattice-aware sanity gate (`mean ∈ [0.05, 0.50]` AND `≥3 distinct seeds non-zero`), and pre-committed v1.8 escalation constraints.
- **17 new tests** (11 slice-math + 6 verdict). 1476 total passing, 0 regressions.

### Eval

- **v1.7.7 C2 sanity preflight — FAILED at N=4 lattice gate.** 20 seeds at `--restrict-late-to 4 --budget 2000`. C2 hippo-base late mean = 0.00%, with 0 of 20 seeds non-zero. Adapter NOT starved (early=77.28%, mid=4.50% — active recall in earlier phases). The 50-task workload's last 4 trap encounters saturate at 0 trap-hits across every seed regardless of window size. **C3 (goal-stack ON) was NOT collected** — no goal-stack data was produced under SANITY_FAIL. Per pre-committed escalation: v1.8 ships adversarial trap categories with constraints named in the v1.7.7 prereg (≥3 new categories, <40% Jaccard overlap with v1.7.5 lessons, same lattice gate, categories authored BEFORE C3). Result: `docs/evals/2026-05-09-v1.7.7-goal-stack-eval-result.md`.

### Cumulative evidence (informational, magnitude not yet auto-retracted)

Three pre-registered workload variants tested for the −10pp goal-stack hypothesis: v1.7.5 (full-late, SANITY_FAIL after running all 4 conditions), v1.7.6 (5 budgets × 10 seeds, B\*=NULL), v1.7.7 (`--restrict-late-to 4`, C2 SANITY_FAIL). The mechanism (v1.7.4 dlPFC goal-stack boost) remains shipped without a magnitude attached. The hard-stop retraction clause fires on NOT_SUPPORTED, not SANITY_FAIL — v1.7.7 was SANITY_FAIL. If v1.8 also fails, the magnitude claim should be treated as falsified pending a fundamentally different benchmark.

### Fixed

- **`run.mjs` and `calibrate.mjs` are now import-safe.** Both files had a leading `#!/usr/bin/env node` shebang that vitest 1.6.1's `node:vm.Script` transformer cannot parse, throwing SyntaxError on test import. Latent in v1.7.6. Stripped the shebangs; both files are always invoked as `node <file>.mjs` in the repo (never as `./<file>.mjs`). `run.mjs` also wraps its `main().catch(...)` in an `invokedAsScript` guard so tests can import `hitRateByPhase` and `parseRestrictLateTo` without spawning the benchmark.

### Deferred to future release

- **v1.8.0: adversarial trap categories** with pre-committed constraints (≥3 new categories, <40% Jaccard overlap with existing lessons, same N-lattice gate, same C2-before-C3 preflight, categories authored BEFORE C3). Last pre-registered escalation for the −10pp magnitude. If SANITY_FAIL or NOT_SUPPORTED, treat the magnitude as falsified.
- **v1.7.8+: re-enable starvation guard in `calibrate.mjs`** with corrected schema (run.mjs::buildOutput doesn't serialize per-task results in single-seed JSON). Either expose per-task results from buildOutput, or rewrite the guard against multi-seed `seeds[].phases`.

## 1.7.6 (2026-05-09)

Calibration release. Two threads: (1) a `--budget` plumbing + calibration sweep that the v1.7.5 eval result called for as candidate #1 (budget reduction). The calibration showed budget reduction does not produce a discriminating workload — late-phase trap rate is 0% across all 5 budgets {200, 400, 600, 800, 1000} × 10 seeds. Per pre-registered escalation, v1.7.7 will sweep `--restrict-late-to last-4` instead. The −10pp goal-stack hypothesis remains untested. (2) Fresh-tail pinned context injection so memories saved mid-session can appear in the next Claude Code `UserPromptSubmit` injection before they are explicitly pinned.

### Added

- **Fresh-tail pinned context injection.** `hippo context --pinned-only --include-recent <n>` now includes the last N writes regardless of pinning, so memories saved mid-session can appear in the next Claude Code `UserPromptSubmit` injection before they are explicitly pinned. New Claude hook installs use `--include-recent 5`, and existing legacy pinned-only hooks are migrated on `hippo hook install`.
- **`--budget` flag on `run.mjs`.** Plumbed end-to-end through `simulate()` → `adapter.recall(query, budget)`. `hippo.mjs` honors it, `baseline.mjs` and `static.mjs` ignore it cleanly. Default 2000 (backward-compat). Reusable by future eval variants.
- **`benchmarks/sequential-learning/calibrate.mjs`.** Mechanical budget-sweep with pre-registered B* selection rule (`selectBStar`): largest budget where C2 late mean ∈ [4%, 24%] AND lower-CI > 0. Calibration seeds hash-derived from `1000 + 10000 + i` (distinct from hypothesis seeds 0..19). 11 unit tests.
- **`docs/evals/2026-05-09-v1.7.6-calibration-result.md`.** Full sweep result + bug-fix note + escalation pre-commit.

### Eval

- **v1.7.6 calibration — B\* = NULL (workload floor effect).** 5 budgets × 10 seeds = 50 single-seed runs. `phases.late = 0.0` on every run. The 50-task workload's late-phase trap rate is structurally 0% regardless of budget; budget reduction is not a discriminating knob. Per pre-registered escalation: v1.7.7 will use `--restrict-late-to last-4`. Calibration result: `docs/evals/2026-05-09-v1.7.6-calibration-result.md`. The −10pp goal-stack hypothesis remains untested on a discriminating workload (mechanism still shipped from v1.7.4).

### Fixed

- **`calibrate.mjs` starvation guard was structurally inert.** The implementation read `j.conditions[cn].results[]` to count per-task `memoryRecalled` flags, but `run.mjs::buildOutput` (line 393) does not serialize the per-task `results` array in single-seed JSON. False-positive `starved=true` on every candidate. The bug did not affect the v1.7.6 verdict — `lateMean=0%` was the load-bearing rejection signal. Fix: drop the broken extraction; `selectBStar` retains the optional `starved` field defensively. Re-enabling the guard with the corrected schema is tracked for v1.7.7+.

### Deferred to future release

- **v1.7.7: `--restrict-late-to last-4`.** Pre-committed escalation. Single-line workload tweak that narrows the late-phase metric to the last 4 trap encounters where the floor effect is sharpest. Will re-run the 4-condition × 20-seed paired eval at default budget on the restricted metric.
- **v1.7.7+: re-enable starvation guard with corrected schema** (expose per-task results in single-seed JSON OR rewrite the guard against the multi-seed `seeds[].phases` block).
- **v1.8.0: adversarial trap categories** (requires new lesson authoring + harness changes).
- **v1.8.0: vlPFC interference suppression.** Real feature work per RESEARCH.md, separate plan.

## 1.7.5 (2026-05-07)

Sequential-learning benchmark infrastructure release. Closes the v0.39 B3 follow-up that gated public exercising of the dlPFC goal-stack mechanism. Eval ran but stopped per the pre-registered sanity gate due to a floor effect; hypothesis remains open.

### Added

- **Sequential-learning adapter contract — `pushGoal` / `completeGoal` hooks.** `benchmarks/sequential-learning/adapters/interface.mjs` accepts optional paired hooks. `hippo.mjs` adapter implements both via the existing `hippo goal push|complete` CLI commands, with `HIPPO_HOME` / `XDG_DATA_HOME` isolation so the eval can't contaminate the user's real store.
- **Multi-seed eval harness with meaningful seed-driven variance.** `--seed N`, `--n-seeds N`, `--eval-strict` flags on `run.mjs`. `benchmarks/sequential-learning/aggregate.mjs` provides `mean` / `stdDev` / `ciHalfWidth95` (returns 0 for n<5) / `aggregatePhases` / `pairedPermutationCI` (10k resamples, no t-test). `traps.mjs::generateTasks(seed)` randomly assigns categories to position-slots within phase shape groups, preserving total trap count, per-category encounter count, and the early-then-later structure.
- **`--use-goal-stack` runner flag.** When set AND adapter supplies the hooks, the simulator wraps each trap task in goal-push / goal-complete so the dlPFC boost activates. Eval-strict mode hard-fails on hook errors so silent fallback can't masquerade as a null result.
- **Tag-fix on memory store.** Stored memories now include `task.trapCategory` (the category id) as the first tag so the goal-stack boost — which keys on `goalsByTag.has(memoryTag)` — can match. Pre-fix the boost would have matched zero memories regardless of mechanism truth.

### Eval

- **Goal-stack lift on sequential-learning benchmark — STOPPED per pre-registered sanity gate.** 4-condition × 20-seed paired eval ran cleanly (zero hook failures, eval-strict mode). Sanity gate fired before the decision rule could apply: hippo-base (C2) measured 0.0% late-phase trap rate (pre-registered band: [4%, 24%] around the README headline 14%). Both C2 and hippo+goal-stack (C3) saturate at 0% late-phase across all 20 seeds — floor effect, no headroom for the goal-stack mechanism to demonstrate further improvement. The −10pp hypothesis remains untested on a discriminating workload. Future eval needs a harder benchmark variant (smaller `--budget`, adversarial categories, or restricted late-phase window). Pre-registration: `docs/evals/2026-05-07-v1.7.5-goal-stack-eval-prereg.md`. Claim inventory: `docs/evals/2026-05-07-v1.7.5-claim-inventory.md`. Full result + investigation: `docs/evals/2026-05-07-v1.7.5-goal-stack-eval-result.md`. Re-derive numbers: `node benchmarks/sequential-learning/analyze-v1.7.5.mjs`.

### Deferred to future release

- Discriminating workload variant for the goal-stack hypothesis (v1.7.6+): reduce store budget, add adversarial trap categories, OR restrict the late-phase metric to last 4 trap encounters.
- vlPFC interference suppression (v1.8.0): real feature work per RESEARCH.md, separate plan.

## 1.7.4 (2026-05-07)

Internal hygiene release closing 3 of the 5 B3 dlPFC follow-ups deferred from v0.39.0. Adds optional `RecallOpts.sessionId` (and `RecallOpts.goalTag`) so MCP `hippo_recall` and HTTP `GET /v1/memories` callers get the dlPFC goal-stack boost — previously CLI-only. Adds `--no-propagate` flag on `goal complete`. Refactors `enforceDepthCapWithinTx` helper.

### Added

- **`RecallOpts.sessionId?: string`.** Optional session id on the recall input. When set AND `(tenant, session)` has active goals AND `goalTag` is unset, `api.recall` AND MCP `hippo_recall`'s primary band BOTH apply the dlPFC goal-stack boost lifted from v0.38's CLI-only path. Undefined preserves v1.7.3 behaviour (no boost). Lives on `RecallOpts` rather than `Context` because `Context` is shared across remember/recall/assemble/outcome and the boost is recall-only.
- **`RecallOpts.goalTag?: string`.** Optional explicit-tag override. When set, the goal-stack boost is suppressed (mirrors the CLI `--goal X` precedence from v0.38).
- **`applyGoalStackBoost` helper** (`@internal`, exported from `src/goals.ts`). Lifted ~140 lines of CLI ranking logic into a reusable helper. Operates on entry-backed scored rows (NOT projected `RecallResultItem`) so it can run before fresh-tail / summary-substitution appendix rendering. Side effects: `goal_recall_log INSERT OR IGNORE` filtered to local memory ids.
- **MCP `hippo_recall.session_id`** input schema field added (256-char cap; mirrors `fresh_tail_session_id`). Was previously absent — `session_id` was on `hippo_assemble` only.
- **MCP `hippo_recall` primary band boosted.** Boost applied to `physicsSearch`/`hybridSearch` result list before `formatMemories`. Pre-v1.7.4 the MCP primary ordering bypassed `api.recall` and so got no boost even when callers thought they were getting one.
- **HTTP `GET /v1/memories?session_id=...`** query param added (256-char cap, 400 rejected when over).
- **`completeGoal` accepts `noPropagate?: boolean`.** Defaults false (propagate, as in v1.6.x). When true, skips strength multiplier side-effects on recalled memories. CLI: `goal complete --no-propagate`. Status-check idempotency unaffected: a second call after a propagating first call is a true no-op regardless of `noPropagate`.

### Refactored (no behaviour change)

- **`enforceDepthCapWithinTx` helper** extracted from `pushGoalWithDb` and `resumeGoal` (`@internal`). Pure DRY cleanup. Name is explicit about its precondition (caller must already be inside `BEGIN IMMEDIATE`).

### Deferred to v1.7.5

- Sequential-learning adapter contract (`pushGoal/completeGoal` hooks on `benchmarks/sequential-learning/adapters/interface.mjs`). Demonstrate or honestly retire the −10pp trap-rate claim. Needs benchmark runs + honest reporting → own release.
  > **Retired v1.7.9** — the −10pp magnitude is RETRACTED. See `## 1.7.9` at top of file.

### Deferred to v1.8.0

- vlPFC interference suppression. Real feature work per RESEARCH.md. Own plan + outside voice.

## 1.7.3 (2026-05-07)

Hygiene release closing the four lower-confidence items deferred from the v1.7.2 review chain. No public API change. No behaviour change. No schema change.

### Tests

- Module-load assertion runtime test for `RECALL_DEFAULT_DENY_SCOPES` (codex P1-3 from v1.7.2). Extracted `assertNonEmpty` helper from inline guard so the throw path is directly testable.
- `summarize_overflow=0` thin-client serialization on explicit `false` (codex P2-3 from v1.7.2). Pins that `false` produces `=0` rather than omission.

### Refactored (no behaviour change)

- Renamed `loadSearchRows` parameter `recallScope` → `scopeFilter` for readability (v1.7.2 maintainability INFO). Internal-only.

### Documented

- README "What's new" backfill for v1.7.0 and v1.6.5 (skipped at ship time, restored for chronological completeness).

## 1.7.2 (2026-05-06)

Hygiene release closing the four consolidation items deferred from v1.7.1. **No behaviour change for in-spec callers not setting `scorer_window`; new opt-in transport surfaces for `scorer_window` and three pre-existing RecallOpts fields the thin-client was missing.** Additive on patch is permitted under semver since backward-compatible.

### Added

- **`scorerWindow` over the wire.** HTTP `/v1/memories?scorer_window=N`, MCP `hippo_recall.scorer_window`, `client.ts` thin-client serializes `scorerWindow`. Validation lives in `api.recall()` — both transports `Number()`-coerce and forward (string `"abc"`, `0`, negative, non-finite all reach `recall()` for typed rejection with `code: 'invalid_scorer_window'`). MCP `hippo_recall` dispatch verified to propagate `RecallContractError` with class and code intact via the v1.6.5 contract that exceptions reach the caller raw.
- **Thin-client RecallOpts parity sweep.** `client.ts::recall` previously serialized only `q`, `limit`, `mode`, `scope`, `include_continuity` — the HTTP server already accepted three more (`fresh_tail_count`, `fresh_tail_session_id`, `summarize_overflow`). Adding `scorer_window` alone would have perpetuated the drift. All four pre-existing-on-server params plus the new `scorer_window` serialized in this release.
- **`RECALL_DEFAULT_DENY_SCOPES` constant** in `src/store.ts` (NOT re-exported from `src/index.ts` — `@internal`). SQL clause in `loadSearchRows`, JS `passesScopeFilterForRecall` (src/api.ts), MCP physics-scorer branch, MCP `hippo_assemble` closure, CLI `cmdRecall` continuity closure, and `api.recall` continuity inline closure all read from the constant. Adding a literal deny scope is a one-place change. Module-load assertion fails loudly on empty array. Regex-based denies (`<source>:private:*`) stay in JS as a separate step.
- **`RecallScopeFilter`** discriminated union exported from `src/store.ts` (NOT re-exported from `src/index.ts` — `@internal`) — `{ mode: 'default-deny' } | { mode: 'exact'; value: string }`. Internal-only consumer is `loadRecallSearchEntries`; public signature unchanged.
- **`passesScopeFilterForRecall`** exported from `src/api.ts` for test parity with the constant (`@internal`; NOT re-exported from `src/index.ts`). Subject to change without semver bump. Direct deep-import works but is unstable.

### Refactored (no behaviour change)

- `loadSearchRows::recallScope` parameter shape replaced from boxed-nullable `{ value: string | null }` with the discriminated union above. Eliminates the `{ value: undefined }` foot-gun the v1.7.1 review flagged.

### Documented (no migration — by design)

- **CLI recall via `searchBoth` / `searchBothHybrid` (`src/shared.ts:96, 172`, used by `cli.ts:783, 1429`) does NOT default-deny on `unknown:legacy`.** Intentional asymmetry: CLI is an operator-local surface where investigating the quarantine bucket is a feature. Library `recall()` from `index.ts` (used by frontend / programmatic callers) goes through `loadRecallSearchEntries` and DOES default-deny. Import the library API for default-deny semantics; use the CLI for full operator visibility. No `--include-quarantine` flag added; revisit if friction surfaces.

### Notes

- MCP `scorer_window` only narrows `api.recall`'s candidate pool, which feeds the appendix paths (fresh-tail / summarize-overflow) and continuity hits. The primary ranked block over MCP is driven by a separate physics/hybrid scorer over the full tenant store, so `scorer_window` does NOT narrow the main results — only the appendix. Documented in the MCP `hippo_recall.scorer_window` description and the test acknowledges this honestly.

### Deferred to v1.7.3

- Module-load assertion runtime test for `RECALL_DEFAULT_DENY_SCOPES.length === 0` (codex P1-3). Current test pins the constant's current length; doesn't exercise the throw path.
- `RecallScopeFilter` parameter naming polish (review maintainability INFO).
- `summarize_overflow=0` (false path) explicit transport test for the thin-client (codex P2-3).

## 1.7.1 (2026-05-06)

Patch release closing the v1.7.0 `/review` 5-specialist deferred-INFO tail (6 items) plus the codex-flagged `unknown:legacy` leak from the v1.6.5 review. The leak fix is at the **producer layer** (root-cause-over-patches): scope predicate pushed into `loadSearchRows` SQL via a new `loadRecallSearchEntries` helper, so future recall consumers cannot silently re-introduce the leak.

### Fixed

- **`unknown:legacy` scope leaked into `recall` baseRanked.** Pre-existing bug surfaced by codex during the v1.6.5 review. Continuity, drillDown, and assemble already filtered correctly via `passesScopeFilterForRecall`; the BM25 base path at `src/api.ts:393` only filtered `isPrivateScope`. v1.7.1 fixes this at the SQL producer: new `loadRecallSearchEntries` helper in `src/store.ts` adds a recall-mode scope predicate to all four `loadSearchRows` paths (no-terms, FTS, LIKE fallback, full-store fallback). `recall()` migrated; redundant post-load filter kept as defense-in-depth (a future SQL-clause regression cannot silently surface cross-scope rows).
- **Latent code smell in `recall()` candidate-window arg.** Pre-v1.7.1 `loadSearchEntries` was called with raw `opts.scorerWindow` (possibly undefined); the parallel default at the loader signature collapsed to 200 by accident. v1.7.1 passes `windowSize` (`opts.scorerWindow ?? DEFAULT_SEARCH_CANDIDATE_LIMIT`) explicitly. No observable behaviour change.

### Added

- **`loadRecallSearchEntries(hippoRoot, query, limit, tenantId, requestedScope?)`.** New public helper exported from `src/store.ts` and re-exported from `src/index.ts`. Pushes the recall-side scope predicate into SQL: `requestedScope` undefined / `''` → default-deny on `unknown:legacy`; non-empty string → exact match on `m.scope`. Background pipelines (`consolidate`, `embeddings`, `refine-llm`, ...) keep using `loadSearchEntries` so they can see quarantined rows when needed.
- **`HIPPO_FORCE_LIKE_PATH=1` env-var hook in `loadSearchRows`.** Test/diagnostic flag to deterministically force the LIKE-fallback path. Gated at the read-call site only — `syncFtsRow`, `deleteFtsRow`, and `raw-archive.ts::archiveRaw` keep using `isFtsAvailable` honestly so the flag never poisons the on-disk FTS index. Lets tests exercise the LIKE branch independent of FTS5 tokenizer behaviour. Earlier "DROP TABLE memories_fts" / "setMeta('fts5_available','0')" approaches were no-ops because `ensureOptionalFts` (`src/db.ts:998-1029`) re-creates and re-backfills the FTS index on every `openHippoDb`.

### Tests

- `scorerWindow=1` lower-bound regression-pin (testing INFO #2).
- No-terms `ORDER BY created ASC, id ASC` + `created` stamp roundtrip with stamped `valid_from` for future-proofing (testing INFO #3).
- Tenant isolation under wide `scorerWindow` covering FTS, no-terms, AND LIKE-fallback paths — asserted on id-set + length, not metadata (testing INFO #5).
- HTTP `GET /v1/memories?q=...` `body.windowSize === 200` serialization (testing INFO #6).
- Deterministic LIKE fallback via `HIPPO_FORCE_LIKE_PATH=1`; expected row anchored on content (senior P2.3 + INFO #7).
- `unknown:legacy` default-deny for unscoped recall, plus opt-in coverage for the explicit-scope branch and empty-string scope semantics (codex finding from v1.6.5 review).

### Changed

- **Default-deny on `unknown:legacy` is observable for unscoped recall callers.** Any external consumer that had silently grown to depend on the leak (recall returning `unknown:legacy` rows when no scope is set) will now miss those rows. Behaviour was always default-deny per `passesScopeFilterForRecall` JSDoc; the bug-fix-with-visible-effect is intentional. Operators investigating the quarantine bucket should pass explicit `scope: 'unknown:legacy'`.

### Deferred to v1.7.2

- **Migrate continuity / drillDown / assemble inline scope closures onto `loadRecallSearchEntries`.** Those paths already filter correctly via `passesScopeFilterForRecall`; consolidation onto the producer layer is hygiene, not bug-fix.
- **Migrate CLI `cmdRecall` and `shared.ts` cross-deployment helpers** (`cli.ts:783`, `cli.ts:1429`, `shared.ts:96`, `shared.ts:172`) onto the new helper for consistency.
- **`scorerWindow` transport exposure** — HTTP `/v1/memories` `scorer_window` parse, MCP `hippo_recall` arg parse, `client.ts` thin-client serialize. Validation already lives in `recall()`.
- **Discriminated-union refactor** of internal `loadSearchRows` `recallScope?: { value: string | null }` parameter shape (boxed-nullable leaks SQL-builder internals into the loader signature).

## 1.7.0 (2026-05-06)

Foundation release. Surfaces FTS5 BM25 score as `MemoryEntry.bm25_score` provenance metadata (FTS path only) and adds `RecallOpts.scorerWindow` so callers can decouple "how many candidates do I want the scorer to evaluate" from `limit`. Three review-chain rounds shaped this release: `/plan-eng-review` and `/codex review --model gpt-5.5` killed mk1 (4 P0s including a fabricated `bm25_score` column) and mk2 (2 P0s including an MCP cap that addressed a non-existent contract); mk3 dropped F2 unified `RankedMemory` (defer to v1.8 with `recallRanked()` API), dropped the JS BM25 backfill (different scorer / different scale), dropped `hardLimit` (existing semantics already correct), and dropped the MCP cap entirely. F4 + F5 shipped separately in v1.6.5.

### Added

- **`MemoryEntry.bm25_score?: number`.** Raw FTS5 `bm25()` score on the FTS path of `loadSearchEntries`. Populated only when (a) query has terms, AND (b) FTS5 is available, AND (c) FTS join returns rows. `undefined` on every other path: empty query, FTS unavailable, LIKE fallback (substring queries that miss FTS tokenization), full-store fallback, `readEntry`, `loadAllEntries`, manual upsert, `deserializeEntry` from markdown. SQLite FTS5 returns NEGATIVE scores (lower = better, ascending order); NOT a drop-in for the JS-side BM25 scorer in `src/search.ts` which uses different tokenizer / params / sign convention. **Provenance metadata only.**
- **`RecallOpts.scorerWindow?: number`.** When set, decouples scorer candidate pool from `limit`. Default `undefined` preserves the existing store-internal 200-row default, which every pre-v1.7.0 caller silently relied on. Useful when `summarizeOverflow=true` and you want a wider candidate pool to detect more level-2 parent clusters.
- **`RecallResult.windowSize: number`.** The scorer window actually used for this recall (== `opts.scorerWindow ?? 200`). Reported so callers can introspect "did the scorer see enough candidates?" without re-deriving the value.

### Changed

- **`loadSearchEntries` FTS path uses qualified `MEMORY_SEARCH_COLUMNS`.** Every column is `m.<col> AS <col>` so `rowToEntry`'s unqualified field reads keep working unchanged. The trailing `bm25(memories_fts) AS bm25_score` adds the FTS rank as a result column. Non-FTS paths keep `MEMORY_SELECT_COLUMNS` — they don't pay for `bm25()` evaluation or get a column that won't bind. `MemoryRow.bm25_score?: number` documents the optional shape.

### Behaviour preserved (codex mk2-pass review)

- **`--limit` semantics unchanged.** CLI `--limit` is still the hard cap; library `RecallOpts.limit` still caps base BM25 hits with fresh-tail/summary substitutions allowed to extend above. Mk2 of this plan proposed making `limit` a "scorer window" with a separate `hardLimit`; codex P1-1 caught the contradiction with existing API behaviour and the proposal was dropped.
- **No MCP cap added.** `hippo_recall` has no `limit` arg in its input schema (only `budget`); mk2 proposed a "fix" for a non-existent contract. Dropped per codex P0-2.
- **No JS BM25 unification.** `src/search.ts` JS scorer untouched. SQLite FTS5 and the JS scorer are different systems with different tokenizers, scales, and parameters. `bm25_score` is provenance only; re-rank consumers (deferred-queue Track 3) wait on explicit normalization design.

### Deferred to v1.8

- F2 unified `RankedMemory` shape with adapters for back-compat (codex mk1-pass C14 strategic: overengineering for v1.7).
- JS BM25 unification — needs explicit normalization design.
- Deferred-queue items (CLI `--fresh-tail` / `--summarize-overflow` parity, summary mean-of-children re-rank) — sized as ~1 day each on top of v1.7.0 foundations; revisit after this release.

### `/review` skill fixes (post-self-review, multi-specialist pass)

After the senior-code-reviewer pass, the actual gstack `/review` skill ran end-to-end and dispatched 5 specialists in parallel (testing, maintainability, security, performance, api-contract). One CRITICAL and three additional INFO findings:

- **(api-contract CRITICAL) HTTP error body shape inconsistency.** `RecallContractError` previously emitted `{error: <code>, message: <text>}` while every other v1/* error in the same handler emits `{error: <text>}` only. Public contract one-off introduced in v1.6.5 and reinforced in v1.7.0. **Fix:** aligned RecallContractError serialization to `{error: <message>, code: <code>}`. The `error` field now carries the human message across all v1/* errors (matches `sendError` shape); the new `code` field is the typed discriminator. Clients branch on `body.code`, render `body.error`. v1.6.5 callers reading `body.error` for the typed code value need to migrate to `body.code` — flagged as breaking.
- **(testing INFO #1)** Per-iteration `.code` assertion on bad scorerWindow values — was looping `[-5, 1.5, NaN, Infinity]` with only message-regex assertion; one bad value sneaking through to a downstream FTS LIMIT throw would have masked the regression. Converted to `it.each` with explicit `.code === 'invalid_scorer_window'` assertion per iteration. Added `Number.NEGATIVE_INFINITY` case.
- **(testing INFO #4)** Full-store fallback LIMIT regression test added. Codex caught the uncapped fallback bug and the fix was applied, but the existing test inserted only 2 rows so a regression dropping the LIMIT clause would not have been caught. New test inserts 30 rows, requests limit=10, asserts exactly 10 returned.
- **(maintainability INFO)** Two stale cross-file line references in JSDoc removed (`cli.ts:1199` → `cmdRecall (cli.ts)`; `src/store.ts:579-639` → `src/store.ts`). Line numbers rot fast; symbolic refs are stable.
- **(api-contract INFO #3)** `loadSearchEntries` empty-query and full-store-fallback paths now respect the candidate-limit argument (default 200) instead of returning the entire tenant store. External callers of `loadSearchEntries(root, '', undefined, tenant)` who relied on the unbounded shape will see at most 200 rows. Pass an explicit large number to keep the old behaviour. Surfaced here as a behaviour change, not just a bugfix.

### Deferred to v1.7.1 (lower-confidence specialist findings)

- (testing INFO #2) scorerWindow=1 lower-bound legal value test.
- (testing INFO #3) No-terms path ORDER BY assertion (verify chronologically-first rows returned).
- (testing INFO #5) Tenant-isolation test for scorerWindow (insert under two tenants, recall under one, assert no cross-tenant leak via the wider window).
- (testing INFO #6) HTTP integration test asserting `windowSize` IS serialized in the response body (output side, complementing the deferred input-side transport).
- (testing INFO #7) Anchor LIKE-fallback test by asserting the returned content matches the inserted row.
- (api-contract INFO #2) `RecallOpts.scorerWindow` type promises a knob the network does not honour. Two options for v1.7.1: wire `scorer_window` through HTTP/MCP transports, OR split the type so client.ts cannot accept an ignored field.

### Self-review + senior-review fixes (post-codex)

After the codex diff-pass, an explicit `/self-review` + senior-code-reviewer subagent pass on the actual diff caught 4 more issues codex missed (1 P1 lying comment, 1 P1 type-breaking change, 1 P1 second uncapped path, 2 P2 JSDoc):

- **(self-review P1) Lying JSDoc comment in F5 preflight.** Said "Re-checked at the freshTailCount > 0 site" but the codex-fix commit (`225fce1`) had removed that re-check. Comment now reflects single-check semantics.
- **(self-review P1) Second uncapped path in `loadSearchRows`.** Codex diff-pass caught the full-store fallback at the bottom of the function but the no-terms path at the top had the same shape — `SELECT ... FROM memories ORDER BY ...` without `LIMIT`. With `RecallResult.windowSize` now reported as the candidate-pool cap, this was a contract violation: `recall(ctx, { query: '', scorerWindow: 50 })` would report `windowSize: 50` while returning the entire tenant store. Same fix shape: append `LIMIT ?` and pass `limit`. New test in `tests/store-bm25-score.test.ts` asserts the no-terms path honours the LIMIT parameter.
- **(senior P1) `RecallResult.windowSize` was non-optional, breaking downstream type consumers.** Pre-v1.7 callers could write `const r: RecallResult = { results: [], total: 0, tokens: 0 }`; v1.7.0 made `windowSize` required, which is a TS breaking change for test fakes / mocks / type narrowings. Made optional in the interface; values returned by `api.recall` itself always have it set, so consumers reading from `api.recall` can treat as defined. Lowest-blast-radius fix.
- **(senior P2) `RecallContractError` JSDoc** was missing the new `'invalid_scorer_window'` code added in the codex diff-pass. Documented.
- **(senior P2) `RecallOpts.scorerWindow` JSDoc** said "HTTP/MCP/client.ts do NOT serialize this field" — true for input, false for output (`RecallResult.windowSize` IS serialized over the wire via `sendJson`). Clarified the input-vs-output asymmetry.
- **(senior P2.3 deferred to v1.7.1)** Full-store fallback test in `tests/store-bm25-score.test.ts` relies on FTS5 unicode61 tokenizer behaviour; not deterministic across tokenizer changes. Senior-recommended fix: drop `memories_fts` directly via raw SQL inside the test. Deferred.

### Codex diff-pass fixes (post-implementation)

A fourth `/codex review` round on the actual v1.7.0 diff caught 0 P0 + 3 P1 + 1 P2 in the implementation (separate from the plan-stage rounds). All addressed before tag:

- **P1 #1: full-store fallback was uncapped.** `loadSearchRows`'s "FTS=0 → LIKE=0 → fallback" path returned all tenant rows ignoring `limit`. With `scorerWindow` now reported on `RecallResult.windowSize`, an unbounded fallback would lie about candidate-pool size; `scorerWindow: 0` (or other invalid input) would route through FTS/LIKE `LIMIT 0` and dump the whole store. Fix: validate `scorerWindow` as a positive finite integer in `api.recall` (throws `RecallContractError` with new code `invalid_scorer_window`); apply `LIMIT ?` to the full-store fallback in `loadSearchRows`.
- **P1 #2: transports drop scorerWindow silently.** HTTP `/v1/memories`, MCP `hippo_recall`, and `client.ts` thin-client do NOT serialize `scorerWindow`. Documented as **library-only at v1.7.0**; transport exposure planned for v1.7.1 alongside the deferred-queue items that need a wider candidate pool.
- **P1 #3: duplicated default constant.** `recall()` had a local `STORE_DEFAULT_CANDIDATE_LIMIT = 200` separate from store's `DEFAULT_SEARCH_CANDIDATE_LIMIT`. Fix: exported `DEFAULT_SEARCH_CANDIDATE_LIMIT` from `store.ts`, imported in `api.ts`. Single source of truth.
- **P2 #4: widening test passed accidentally.** Original assertion `total <= 25 && total > 0` would have passed even if implementation loaded only `limit` candidates. Strengthened to assert `total === 25` (FTS5 + LIMIT 25 against 30-row matching population) and added 0/-5/1.5/NaN/Infinity rejection tests.

### Tests

- 1366 passing (+12 from v1.6.5's 1354). One additional test (`No-terms path: honours the LIMIT parameter`) covers the self-review-found uncapped path. New: `tests/store-bm25-score.test.ts` (5 tests covering FTS-path populated, FTS-path two-term-better-than-one-term, no-terms path undefined, LIKE-fallback path undefined via substring miss, full-store-fallback path undefined). `tests/api-recall-scorer-window.test.ts` (6 tests: default windowSize=200, opt-in scorerWindow, scorerWindow widens candidate pool with strict `total === 25` assertion, limit semantics unchanged with fresh-tail expansion, scorerWindow=0 throws `RecallContractError`, negative/non-integer/NaN/Infinity all rejected).

## 1.6.5 (2026-05-06)

Two cherry-picked items from the in-progress v1.7 foundations work that have zero contract risk and ship cleanly on their own. Both were originally bundled inside the v1.7 foundations plan; eng review + `/codex review` (gpt-5.5) on the foundations plan flagged them as independently shippable and identified specific traps in the original "library `console.warn`" approach for F5. This release applies the codex-suggested correction (typed error at API boundary, no library stderr noise) and ships the polish atomically.

### Added

- **`RecallContractError` exported class with `.code` field.** Thrown by `api.recall` when `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set AND `freshTailCount > 0` AND `freshTailSessionId` is unset. HTTP returns 400 with `{ error: <code>, message: <string> }` so callers can discriminate without parsing prose; MCP propagates the typed error to the transport (existing -32603 mapping); CLI exits 1 with the message on stderr (existing top-level catch). Default env unset preserves v1.6.x tenant-wide back-compat.
- **Timestamp invariant documented** in `src/memory.ts` (above `MemoryEntry`): all `MemoryEntry` and session-state timestamps are stored as canonical `Date.prototype.toISOString()` output (24 chars, UTC, milliseconds, trailing `Z`). Imports preserving local-time offsets MUST normalize on write.

### Changed

- **`assemble` ISO sort uses byte compare instead of `localeCompare`.** ~50× faster on canonical UTC ISO with no semantic change given the in-process timestamp invariant. Audited in-process timestamp writes in `src/`: zero non-canonical writes (no `toLocaleString` / `toLocaleDateString` / `toLocaleTimeString`, no manual ISO reformatting). **Caveat:** `deserializeEntry` / `rebuildIndex` round-trip frontmatter timestamp strings as-is, so legacy markdown that recorded a non-canonical offset propagates through SQLite without normalization (codex P1 documented in the JSDoc). Importers SHOULD normalize on write; rebuild from drifted markdown is a known limitation. `tests/api-assemble-iso-sort.test.ts` covers (a) byte-cmp ↔ localeCompare equivalence on a fixed sample, (b) a randomized 100-sample cross-check against `Date.parse`, and (c) a real `api.assemble` integration that inserts shuffled raws and asserts ascending order on returned items.
- **`loadFreshRawMemories` JSDoc-deprecated** for tenant-wide use (no `sessionId`). NO runtime `console.warn` introduced — codex C9 explicitly rejected library-level stderr noise. Direct callers bypass the `api.recall` guard, so the JSDoc is the only nudge at that layer.

### Guard placement (intentional, audited)

- `api.recall`: ENFORCED.
- `api.assemble`: NOT enforced. `assemble` is already session-scoped via `loadSessionRawMemories` and never calls tenant-wide `loadFreshRawMemories`. Adding a guard here would be a no-op (codex C9 originally caught the mk1 plan trying to add this).
- `loadFreshRawMemories`: deprecation note only.

### Env semantics

Strict equality check on the literal string `'1'`. Other truthy values (`'true'`, `'yes'`, `'0'`, `''`) are treated as unset — defensive against callers expecting "any truthy" semantics. Test asserts each variant.

### Tests

- 1353 passing (+14 from v1.6.4). New: `tests/api-assemble-iso-sort.test.ts` (2), `tests/recall-fresh-tail-policy.test.ts` (5), `tests/http-recall-fresh-tail-policy.test.ts` (4), `tests/mcp-recall-fresh-tail-policy.test.ts` (3).

### Deferred to v1.7.0 foundations release

- BM25 provenance plumbing (`MemoryEntry.bm25_score`).
- `RecallOpts.scorerWindow` opt-in wider scorer window.
- MCP final-cap fix on `hippo_recall`.

## 1.6.4 (2026-05-05)

Two deferred items from the v1.6.2 senior review queue. Plan-stage `/codex` + `/review` caught 2 P0s in my draft before any code landed (unscoped cross-tenant probe; `validateIdSegment` running after `matchPath` splits — neither would have actually fixed the bugs they were meant to fix). Implementation chain ran clean.

### Changed

- **`drillDown` returns a discriminated `DrillDownOutcome` instead of `Result | null`.** Three failure cases now distinguishable: `not_found` (covers genuinely missing AND wrong-tenant — intentionally collapsed for info-leak defence), `not_drillable` (id is a leaf, level 0/1), `scope_blocked` (caller has no scope grant). HTTP `/v1/recall/drill/:id` maps `not_drillable` to **422 Unprocessable Content**; the other failures stay at 404 to avoid leaking cross-tenant existence or scope grants. CLI and MCP surface a distinguishing message.
- **Behaviour change (potentially breaking):** JS callers receiving the old `null` return must migrate to `'failure' in result` checks. In-tree migrations: `cmdDrillDown` (CLI), `hippo_drill` (MCP), `/v1/recall/drill/:id` route, `tests/dag-drill-down.test.ts`. No external JS consumers known on v1.6.x.

### Added

- **HTTP `:id` segment validation across `/v1/memories/:id*`, `/v1/recall/drill/:id`, `/v1/sessions/:id/assemble`.** Two layers: (1) top-of-handler `rejectEncodedSlash` rejects any `%2F` / `%2f` in the raw URL with 400 BEFORE Node's URL parser collapses encoded slashes (otherwise they'd silently route-mismatch). (2) Post-match `validateIdSegment` enforces charset `[A-Za-z0-9_:.-]` and 256-character cap. Production id shapes (`mem_<hex>`, `sum_<hex>`, `sess-<id>`, Slack bot ids) all pass.

### Acknowledged trade-off

The `not_drillable` 422 is technically a topology leak: a probing authorised caller can enumerate ids and learn which are leaves vs summaries. Same information is already accessible via `recall + drill` shape inspection. No env-gate added; if a deployment wants uniform 404, that's a follow-up.

### Tests

- 1337 passing (+17 from v1.6.3). New: `tests/dag-v164-error-distinguishability.test.ts` covering the four failure shapes, HTTP status mapping, the `%2F` and `%2f` rejection, charset validation across all `:id` routes, and the 256-character boundary. Updated: `tests/dag-drill-down.test.ts` (7 cases migrated to discriminated shape), `tests/http-drill.test.ts` (1 case 404→422), `tests/v039-server-hardening.test.ts` (encoded-slash bypass test allows 400).

## 1.6.3 (2026-05-05)

Two retro review rounds on the v1.6.2 patch surfaced more bugs than the originally-skipped `/review` would have caught alone. The user called out that `/review` and `/ship-check` had been skipped on v1.6.2 (and v1.5.1, v1.5.2, v1.6.0, v1.6.1 before). Running both reviewers retroactively, then a SECOND round of both on the v1.6.3 patch itself, found:

- **`/review` round 1 (on v1.6.2):** 1 P0, 7 P1, 3 P2.
- **`/codex review --uncommitted` (on v1.6.3 patch):** 1 P1 (auth-leak in the new totalRaw COUNT) + 1 P2 (MCP response path drift).
- **`/review` round 2 (on v1.6.3 patch):** 1 P0 (lying comment) + 4 P1 (parser-drift in same file, tenant-id JSDoc, MCP typeof guard, missing boundary test) + 3 P2.

This patch addresses the load-bearing items across both review rounds.

### Fixed

- **`assemble.totalRaw` is now scope-aware unbounded count when truncated** (codex P1 / senior P0). The v1.6.2 cap broke `totalRaw` semantics on long sessions; an earlier draft of this patch ran an unscoped COUNT(*) which would have let a no-scope caller infer how many private rows existed. v1.6.3 SQL-encodes the same default-deny rule `passesScopeFilterForRecall` applies in TS. New helper: `store.countSessionRawMemories(hippoRoot, sessionId, tenantId, scope?)`.
- **MCP `hippo_recall` exposes `fresh_tail_session_id`, `fresh_tail_count`, `summarize_overflow` AND surfaces them in the response.** The first round of the fix wired the args into `apiRecall` but the rendered output still came from the separate physics path, so MCP callers could pass the new opts and see no effect. v1.6.3 appends fresh-tail and substituted-summary items as their own section.
- **HTTP `fresh_tail_session_id` length capped at 256 characters.**
- **HTTP `summarize_overflow` parser tightened** to `=== '1' || === 'true'`. Same fix applied to `summarizeOlder` on `/v1/sessions/:id/assemble` (was loose in the same file). Behaviour change: `?summarize_overflow=banana` and `?summarizeOlder=banana` now correctly evaluate to `false`.

### Deferred to v1.7

- CLI `hippo recall` lacks fresh-tail / summarize-overflow flags. `cmdRecall` runs the physics/hybrid scorer path directly without going through `api.recall`; wiring needs its own plan + tests.
- `drillDown` collapses unknown / wrong-tenant / scope-blocked / leaf cases into a single 404 — caller debuggability.
- HTTP path matcher behaviour with slashes in `sessionId` / `summary_id`.
- Hardcoded `score: 0.5` for substituted summaries.
- `localeCompare` micro-perf on ISO timestamps.
- Tenant-wide `loadFreshRawMemories` default deprecation note.
- Various P2 polish items (CLI flag parser, query parser dedup, audit-handle reuse).

### Process correction (honest version)

Through this session, `/codex`, `/review`, `/self-review`, and `/ship-check` were skipped on multiple releases. Each retro pass found real bugs the prior pass missed: senior `/review` caught a misleading semantic codex missed; codex caught an authorization-leak the senior reviewer's draft introduced. Neither alone is sufficient. Tests + CI is necessary but not sufficient either. The chain exists for a reason, and the user was right to call it out. v1.6.3 ran codex + senior `/review` + self-review + ship-check pre-publish.

### Behaviour changes (potentially breaking)

- HTTP callers passing arbitrary truthy-string values to `?summarize_overflow=` or `?summarizeOlder=` (other than `1` / `true`) will now get `false` instead of `true`. Fix: pass `1` or `true` explicitly.

### Tests

- 1320 passing (+11 from v1.6.2). New: `tests/dag-v163-review-patch.test.ts` (12 cases covering the unbounded scope-aware totalRaw, scope-leak prevention, exact-cap boundary, `countSessionRawMemories` helper, MCP schema parity, HTTP length cap + parser tighten).

## 1.6.2 (2026-05-05)

`/codex review --base v1.5.0` (gpt-5.5, high reasoning) caught two functional bugs that v1.6.1's `/review` chain missed. Verdict from codex: "patch is incorrect." Fixed here.

### Fixed

- **`loadSessionRawMemories` cap returns NEWEST rows.** v1.6.1 introduced a 5000-row SQL cap with `ORDER BY created ASC LIMIT N`, which dropped the newest rows on a session larger than the cap and silently broke fresh-tail protection in `assemble`. v1.6.2 reverses the ORDER and reverses client-side: the cap now ALWAYS preserves the freshest window. The `truncated` flag still fires.
- **`loadFreshRawMemories` accepts `sessionId`.** Pre-v1.6.2 was tenant-wide only — multi-session tenants surfaced cross-session rows tagged `isFreshTail=true` even though the docstring claimed "what did I just see in this session." Now an optional `sessionId` parameter constrains correctly.
- **`RecallOpts.freshTailSessionId`.** Callers wanting session-scoped fresh-tail (the most common case) can now pass the active session id. Without it, fresh-tail stays tenant-wide for back-compat with v1.5.2 callers.

### Process correction

This is the third release where retroactive review caught real bugs that tests + CI missed. Codex via `codex review --base <tag>` works on Windows once you skip the custom prompt and let it use the diff-only mode. Lesson: when codex reads files via `git show | Select-String` (which the sandbox allows), it can review fully. The earlier failures came from prompts that asked codex to do filesystem-relative greps, which the Windows sandbox rejects.

### Tests

- 1307 passing (+6 from v1.6.1). New: `tests/dag-v162-codex-patch.test.ts` covering newest-cap semantics, session-scoped `loadFreshRawMemories`, and `recall` `freshTailSessionId` propagation.

## 1.6.1 (2026-05-05)

Retroactive patch. v1.5.1 / v1.5.2 / v1.6.0 shipped without going through the full `/codex` + `/review` chain — tests + CI passed but a senior cross-model review of the cumulative diff `v1.5.0..HEAD` surfaced 0 P0, 9 P1, 8 P2 findings. This patch addresses the three real holes; the rest are documented or deferred.

### Fixed (P1 from senior review)

- **`loadSessionRawMemories` row cap.** Pre-v1.6.1 had no SQL `LIMIT`, so a degenerate session with 100k raws would materialise 100k `MemoryEntry` objects in JS heap before the budget loop ran. Now caps at 5000 by default (configurable via `AssembleOpts.rowCap`); when the cap is hit, `AssembleResult.truncated: true` flags the caller.
- **`assemble.totalRaw` is now post-scope-filter.** Pre-v1.6.1 reported pre-filter, so an all-private session returned `{totalRaw=N, items=[]}` which looked like a missing-session bug. Now `totalRaw` counts what the caller could actually have seen given their tenant + scope grant.
- **`AssembleOpts.scope` parity with `RecallOpts.scope`.** Pre-v1.6.1, an authorised caller could not assemble a private session even with explicit consent. Now an exact-scope match unlocks the session, mirroring `recall`. Propagated through CLI (`--scope <s>`), MCP (`scope` arg), and HTTP (`?scope=...`).

### Deferred (documented for follow-up)

- P1 #4 drillDown 404 collapses 4 distinct cases (unknown / tenant / scope-blocked / leaf) — caller debuggability.
- P1 #5 Path matcher behaviour with slashes in `sessionId` / `summary_id`.
- P1 #6 Substitution path filters `dag_level === 2` only — design intent, document in plan.
- P1 #7 Hardcoded `score: 0.5` for substituted summaries — re-rank semantics.
- P1 #8 `localeCompare` on ISO timestamps — micro-perf.
- P1 #9 CLI flag parsing `Number(undefined) = NaN` — cosmetic.
- All P2s: comment density, eviction-only-fresh-tail edge case test, audit DB handle reuse.

### Tests

- 1301 passing (+8 from v1.6.0). New: `tests/dag-assemble-v161-patch.test.ts` covering row cap, `truncated` flag, post-scope `totalRaw`, and `scope` opt unlock.

### Process note

The senior cross-model review on the diff was the missing step. Self-driven analysis when codex bails on the Windows sandbox is not equivalent — the workflow chain exists because tests + CI catch build/test failures, not architectural drift, scope leaks, or silent contract bugs. This patch + the explicit retro audit are the correction.

## 1.6.0 (2026-05-05)

Phase 2 of the DAG plan. New `assemble` API: chronologically-ordered context window for a session, with fresh-tail raw rows + level-2 summary substitutions for older rows + bio-aware budget eviction. The differentiator from lossless-claw is the eviction policy: when over budget, Hippo drops the lowest-strength non-fresh-tail item first instead of the oldest, so high-importance older rows survive while low-strength recent ones get summarized.

### Added

- **`api.assemble(ctx, sessionId, opts)`.** Returns `AssembleResult { items, tokens, totalRaw, summarized, evicted }`. Each `AssembledContextItem` carries `id`, `content`, `createdAt`, `strength`, plus optional `isFreshTail` / `isSummary` / `substitutedFor`. Tenant-scoped, default-deny on private scopes, opt-out via `summarizeOlder: false`.
- **`hippo assemble --session <id> [--budget N] [--fresh-tail N] [--no-summarize-older] [--json]`.** CLI wrapper.
- **MCP `hippo_assemble` tool.** Args `session_id`, `budget`, `fresh_tail_count`, `summarize_older`. Renders human-readable items list.
- **HTTP `GET /v1/sessions/:id/assemble?budget=N&freshTail=N&summarizeOlder=0|1`.** Bearer auth, tenant scope from key.
- **`store.loadSessionRawMemories(hippoRoot, sessionId, tenantId)`.** SQL `WHERE kind='raw' AND source_session_id=? AND superseded_by IS NULL ORDER BY created ASC`.

### Phase 3 deferred

The original DAG plan flagged Phase 3 (sub-agent expansion + large file externalization). Both are lossless-claw-specific patterns that don't fit Hippo: `drillDown` already covers detail recovery without sub-process delegation, and Hippo memories are short text rather than multi-MB files. Documented in `docs/plans/2026-05-05-assemble-phase2.md`.

### Tests

- 1293 passing (+20 from v1.5.2). New suites: `tests/dag-assemble.test.ts` (11 cases), `tests/mcp-assemble.test.ts` (4), `tests/http-assemble.test.ts` (5).

## 1.5.2 (2026-05-05)

Phase 1 fresh-tail. Closes the last item on docs/plans/2026-05-05-dag-recall.md.

### Added

- **`RecallOpts.freshTailCount` (default 0).** When > 0, recall surfaces the last N `kind='raw'` rows with `isFreshTail=true` regardless of whether they matched the BM25 query. Tenant + scope filtered, capped at 200. Useful for "what did I just see" continuity recall on top of the query path.
- **Dual-membership semantics.** When a recent row also matches the query, the existing BM25 hit is stamped with `isFreshTail=true` rather than duplicated. Fresh-tail rows that are NOT BM25 hits prepend at score 1.0.
- **`store.loadFreshRawMemories(hippoRoot, count, tenantId)`.** SQL `ORDER BY created DESC LIMIT N` with append-only filter (`superseded_by IS NULL`). Avoids the load-all-then-sort path.

### Tests

- 1273 passing (+6 from v1.5.1). New suite: `tests/dag-fresh-tail.test.ts` covering default-off, basic case, dedup, scope, tenant, prepend.

## 1.5.1 (2026-05-05)

Completes the v1.5.0 drillDown surface area. The `api.drillDown` function shipped in v1.5.0 with CLI access only; this patch wires the same call through the two remaining transports.

### Added

- **MCP `hippo_drill` tool.** Companion to `hippo_recall`. When recall returns an item with `isSummary=true` and `substitutedFor=[ids]`, pass the summary id to `hippo_drill` to recover the original detail. Args: `summary_id` (required), `limit` (default 50), `budget` (token cap). Tenant scoped; default-deny on private scopes for both summary and children.
- **HTTP `GET /v1/recall/drill/:id?limit=N&budget=N`.** Bearer auth, tenant scope from key. Returns `{summary, children, totalChildren, truncated}`. 404 on unknown id, leaf id, or scope-blocked. 400 on bad query params.

### Tests

- 1267 passing (+11 from v1.5.0). New suites: `tests/mcp-drill.test.ts` (5 cases), `tests/http-drill.test.ts` (6 cases).

## 1.5.0 (2026-05-05)

DAG-aware recall — Phase 1. The `dag_level`/`dag_parent_id` columns Hippo has carried since schema v14 are now load-bearing in the recall path. When a query overflows the result limit and ≥2 of the dropped leaves share a level-2 parent summary, recall appends that summary so the user sees a compact pointer to the missing detail. Companion `drillDown` API + `hippo drill <summary-id>` CLI walk down to recover the originals.

Lifts the depth-stratified summary + drill-down patterns from [lossless-claw](https://github.com/Martian-Engineering/lossless-claw) (LCM paper from Voltropy / Martian Engineering) and adapts them to Hippo's score-ranked recall model. Substitution is additive (caps at ceil(limit * 0.3) extras), tenant-scoped, and respects the v1.2.1 `*:private:*` default-deny.

### Added

- **`api.drillDown(ctx, summaryId, opts)`.** Walk one step down the DAG from a level-2+ summary to its direct children. Tenant scoped, scope-filtered on both summary and children. Optional `limit` (default 50) and `budget` (token cap) options. Returns null for non-summaries (level 0/1 leaves are not drillable — they ARE the leaf).
- **`hippo drill <summary-id> [--limit N] [--budget N] [--json]`.** CLI wrapper around `drillDown`. Pretty-prints the summary and its children, or emits JSON for piping.
- **`RecallOpts.summarizeOverflow` (default true).** When entries overflow the limit and share a level-2 parent, the parent is appended. New optional fields on `RecallResultItem`: `isSummary`, `substitutedFor` (child ids), `descendantCount`.
- **`store.loadEntriesByIds(hippoRoot, ids, tenantId)`.** Batched, tenant-scoped lookup capped at 500 ids. Used by the substitution path.
- **`store.loadChildrenOf(hippoRoot, parentId, tenantId)`.** Direct DAG children, tenant-scoped, ordered by `created ASC`. Used by `drillDown`.

### Changed

- **Schema v25.** Added `descendant_count`, `earliest_at`, `latest_at` columns on `memories`. Idempotent ALTER + best-effort backfill from existing summary rows. No `min_compatible_binary` bump — pure metadata, older binaries can open a v25 DB and treat the columns as 0/null.
- **`buildDag` populates the cached metadata at write time.** When sleep consolidation creates a level-2 topic summary, `descendant_count` = cluster size, `earliest_at`/`latest_at` = min/max of member `created` timestamps.

### Tests

- 1256 passing (+19 from v1.4.0). New suites: `tests/dag-summary-metadata.test.ts` (5 cases), `tests/dag-recall-substitution.test.ts` (7 cases), `tests/dag-drill-down.test.ts` (7 cases).

## 1.4.0 (2026-05-05)

First repo-level CI workflow plus the v0.40.0 provenance gate enforced on every PR. The Slack connector transform also closes a gap that pre-v1.4 would have failed `hippo provenance --strict` on any `bot_message` event: userless messages now stamp `owner: bot:<bot_id>` instead of `undefined`.

### Added

- **GitHub Actions CI (`.github/workflows/ci.yml`).** Build + full vitest suite + a provenance gate. Read-only `permissions: contents: read`, 25-minute timeout, uploads `provenance-coverage.json` as a workflow artifact (14-day retention).
- **`scripts/ci-seed-provenance.mjs`.** Ingests one GitHub issue webhook + one Slack message through the real connectors into a fresh hippo store, then runs `hippo provenance --strict --json` against that store. Drop a connector's owner stamp and CI fails.
- **Slack provenance parity test (`tests/slack-provenance-parity.test.ts`).** Mirrors the GitHub parity test: 25 user messages + the bot_message + thread reply + message_changed cases all hold `coverage = 1.0`.
- **`SlackMessageEvent.bot_id`.** Optional field on `src/connectors/slack/types.ts`. `bot_message` subtype payloads carry this instead of `user`.

### Fixed

- **Slack `bot_message` provenance gap.** `src/connectors/slack/transform.ts:38` shipped `owner: undefined` when `message.user` was absent. Now derives `owner: bot:<bot_id>` (or `bot:unknown` as a sentinel). Codex round 1 P1 on `docs/plans/2026-05-05-provenance-ci-gate.md` flagged that skipping userless messages would silently drop existing bot ingestion via the `ingest.ts:54-65` "skipped but seen" path.

### Tests

- 1237 passing (+5 from v1.3.2). New `tests/slack-provenance-parity.test.ts` plus one new `slack-transform` case for the `bot:<bot_id>` derivation.

## 1.3.2 (2026-05-04)

Hotfix for v1.3.1. The retroactive review chain on v1.3.1 (codex round 3 + senior code reviewer) caught residual issues in v1.3.1's own fix, including one that the obvious "make ingest and deletion share keys" patch would have made worse.

### Security (CRITICAL)

- **Deletion key namespace.** v1.3.0 deletion call sites in `src/server.ts` still called `computeIdempotencyKey` with the v1.3.0 `(eventName, rawBody)` argument shape after v1.3.1 changed the signature to `(artifactRef, updatedAt)`. TypeScript didn't flag the drift since both signatures are `(string, string)`. Codex round 3 caught it. The naive fix (use the same artifact-based key for deletion) introduced a NEW bug: ingest had already written the same key to `github_event_log`, so deletion's fast-path `hasSeenKey` check returned `'duplicate'` immediately and refused to archive. v1.3.2 splits the namespace: new `computeDeletionKey(artifactRef, updatedAt)` prepends a `'deleted:'` prefix. Ingest and deletion of the same artifact + same `updated_at` now produce different keys, while two retries of the same deletion still dedupe correctly.

### Fixed

- **DLQ replay routes deleted comments through the deletion handler.** v1.3.1's `cli-impl.ts` ingestHook unconditionally called `ingestEvent`, which would have written a replayed `*.deleted` row as a NEW raw memory instead of archiving. Now branches on `body.action === 'deleted'` and dispatches to `handleCommentDeleted`.
- **`IngestHook` contract dropped phantom `idempotencyKey` arg.** v1.3.1's hook re-derived the key from the parsed event, so the field was a phantom — any future hook that trusted it would dedupe against a stale value. Removed from the type.
- **`compareSemver` throws on pre-release tags.** v1.3.1's implementation silently coerced `Number(n) || 0`, so `'1.3.2-beta'` parsed as `[1,3,0]` and compared LESS than `'1.3.2'`. If anyone ever stamped a pre-release into `meta.min_compatible_binary`, the rollback guard would silently misfire. Now throws on non-numeric segments.

### Tests

- 1232 passing (+7 from v1.3.1). New `tests/github-v1.3.2-hotfix.test.ts` covers the deletion-key namespace separation, two-retries-of-same-deletion dedupe, different-artifact deletion keys, `compareSemver` pre-release rejection, and the `IngestHook` shape.

## 1.3.1 (2026-05-04)

Hotfix for v1.3.0. The retroactive review chain (codex round 2 + senior code reviewer) found 3 P0s and 6 P1s in the v1.3.0 ship that the plan-only review hadn't caught. All addressed here.

### Security (CRITICAL)

- **P0: Rollback guard now enforced.** v1.3.0 stamped `meta.min_compatible_binary='1.2.1'` but never read it on DB open. Older binaries lacking the generic `*:private:*` filter could open a v1.3 DB and leak `github:private:*` rows. v1.3.1 adds the read-side check in `runMigrations` — opening throws if `min_compatible_binary > PACKAGE_VERSION`.
- **P0: Comment-deletion atomicity.** v1.3.0 archived multi-row edit histories in independent transactions and committed the idempotency mark with the FIRST archive. If archives 2..N threw, idempotency was already marked, retries returned `'duplicate'` with `archivedCount: 0`, and survivors stayed searchable. v1.3.1 wraps all archives + idempotency mark in one outer SAVEPOINT — any failure rolls back the entire batch and leaves idempotency unset for retry.
- **P0: Idempotency key now bridges backfill and webhook.** v1.3.0 derived the key from `sha256(eventName + ':' + rawBody)`. Backfill `rawBody = JSON.stringify(restItem)` differed from webhook `rawBody = envelope`, so the same source revision produced two different keys and two memory rows. v1.3.1 derives from `sha256(artifact_ref + ':' + (updated_at ?? ''))` — same revision = same key, regardless of delivery path.

### Fixed

- **DLQ replay actually replays.** v1.3.0's `cmdGithubDlqReplay` called `replayDlqEntry` without an `ingestHook`, hitting the dry-run branch that bumps `retry_count` and prints "replay ok" without re-ingesting. v1.3.1 wires the real ingest hook plus type-guard dispatch.
- **Backfill HWM stays put on capped streams.** `--max N` returned `drained: false`; HWM advance is now gated on `drained: true`. Resume re-fetches the unprocessed tail.
- **Backfill issues HWM advances past skipped PRs.** Items returned via `/issues` that have a `pull_request` field (PRs) are skipped from ingest, but their `updated_at` now still contributes to the HWM. PR-only pages no longer cause the next run to re-fetch the same window forever.
- **DLQ replay supports `GITHUB_WEBHOOK_SECRET_PREVIOUS`.** Operators rotating the webhook secret can replay rows written under the old secret without `--force`.
- **HTTP `/health` and MCP `serverInfo` report the real version.** Both were hardcoded to `0.39.0`. v1.3.1 sources from a single `src/version.ts` const.
- **MCP tool descriptions reflect generic `*:private:*` default-deny.** Were stale on `slack:private:*`-only.

### Internal

- New `src/version.ts` — single source of truth for `PACKAGE_VERSION` plus a small `compareSemver` helper. Bumped manually alongside the four package manifests on every release.
- New `tests/github-v1.3.1-hotfix.test.ts` — 8 regression tests for the rollback guard, deletion atomicity success path, idempotency key bridge, capped-stream HWM, and PR-only HWM advance.

## 1.3.0 (2026-05-04)

GitHub connector. Streams issues, issue comments, pull requests, and PR review comments into hippo as `kind='raw'` rows with full provenance, idempotency, scope tagging, and a dead-letter queue. Built on the v1.2.1 generic `*:private:*` default-deny filter so private GitHub rows cannot leak to no-scope callers.

### Added

- **`POST /v1/connectors/github/events`** webhook route. HMAC verification via `X-Hub-Signature-256`. Idempotency keyed on `sha256(eventName + ':' + rawBody)` — replay-safe even if an attacker mints fresh `X-GitHub-Delivery` UUIDs (codex P0 #3).
- **`hippo github backfill --repo <owner/name>`** CLI. Three independent REST streams (issues, issue comments, PR review comments) with per-stream high-water marks. HWM advances only after a stream fully drains so a crash mid-backfill is safe to restart (codex P1 #3). PRs returned via the `/issues` endpoint are skipped (codex P1 #2).
- **`hippo github dlq list` / `dlq replay <id> [--force]`** CLI. Full replay metadata in `github_dlq` (event_name, delivery_id, signature, installation_id, repo_full_name, retry_count) so replay reproduces the exact dispatch.
- **Tenant routing.** `github_installations` (App-mode) + `github_repositories` (PAT-mode multi-tenant). Fail-closed: a PAT-mode webhook in a multi-tenant install with no repo mapping returns null and DLQs as `unroutable` (codex P0 #4).
- **Comment deletion sync.** `issue_comment.deleted` and `pull_request_review_comment.deleted` archive matching rows via `archiveRaw`. Filtered by `tenant_id + kind='raw'`, archives ALL active matching rows (GitHub edit history can produce multiple rows with the same artifact_ref) — codex P0 #5.
- **Scope mapping.** `github:public:owner/repo` for public repos; `github:private:owner/repo` for private. `repository.private === undefined` falls through to private (fail-safe). Backfilled rows default to private since the REST list endpoints don't reliably surface `private`.

### Schema

- **Migration v24.** Six tables: `github_event_log`, `github_cursors`, `github_dlq`, `github_installations`, `github_repositories`, plus a `meta.min_compatible_binary='1.2.1'` row that older binaries (<1.2.1, no generic-private filter) hit and refuse to open the DB. Rollback safety (codex P0 #2).

### Tests

- 1214 tests passing across the suite (up from 1087 at v1.2.1). 117+ new tests across github-schema, github-types, github-scope, github-transform, github-signature, github-idempotency, github-tenant-routing, github-ratelimit, github-octokit-client, github-ingest, github-deletion, github-backfill, github-dlq, github-webhook-route, github-cli, github-smoke-200, github-provenance-parity.
- 200-event smoke test with explicit security-boundary assertions: idempotency, replay defense, no-scope private denial, cross-source generic-private denial (synthetic `acme:private:demo`), tenant routing failure (codex P2 #2 strengthening).
- Real two-worker race test that exercises the SAVEPOINT collision path (not just the fast-path) — codex P1 #6.

### Plan + audit trail

- `docs/plans/2026-05-04-github-connector.md` — full plan with codex round 1 review report (5 P0, 8 P1, 2 P2 — all consolidated and patched into the plan before any code was written).

## 1.2.1 (2026-05-04)

Pre-flight for v1.3.0 GitHub connector. Codex audit caught that the v1.2 default-deny scope filter only blocked `slack:private:*`, not source-agnostic `*:private:*`. Once a second connector landed (GitHub, Jira, Linear, etc.), no-scope recall would silently leak private rows. v1.2.1 generalizes the rule before any v1.3 work begins, so rolling back is safe.

### Security (CRITICAL)
- **Generic `*:private:*` default-deny.** The recall, continuity, MCP `hippo_recall`, MCP `hippo_context`, and CLI `cmdRecall` filters now reject ANY scope matching `^[a-z][a-z0-9_-]*:private:` for no-scope callers, not just `slack:private:`. Public scopes, null scope, and exact-match scope queries are unchanged. Single source of truth: new `isPrivateScope` export from `src/api.ts`.
- Closes the latent gap that would have exposed `github:private:owner/repo` rows to default-deny callers in v1.3.

### Added
- `tests/scope-filter-generic-private.test.ts`: 13 regression tests covering api.recall (memory + continuity), MCP hippo_recall, MCP hippo_context, with synthetic `acme:private:demo`, `github:private:*`, and `jira:private:*` scopes plus negative tests (substring "private" in middle of public scope, public scopes pass-through).

### Internal
- Comment + MCP tool description updates from "slack:private:* and unknown-legacy" to "ANY *:private:* and unknown-legacy" wherever the filter rule is documented.

## 1.2.0 (2026-05-03)

Closes the v1.0.0 + v1.1.0 known limitations on continuity scope. Continuity is now exposed through MCP and HTTP, and the existing `hippo_context` MCP tool retroactively gets the same scope filter that protects memory recall. The v1.0.0 "Known limitation: scope=NULL on continuity tables" is CLOSED.

### Security (CRITICAL)
- **Cross-scope leak fix on continuity recall.** v1.1.0's filter was `opts.scope || isPublic`, which let any explicit scope see ALL continuity rows regardless of the row's scope. Latent in v1.1 (no scope writers shipped), now fixed to exact-match. Same fix in `api.recall` and `cmdRecall`.
- **`hippo_context` retroactive scope filter.** This MCP tool predates v1.1 and exposed all memories plus the active snapshot to no-scope MCP callers. Now applies the same default-deny rule as `hippo_recall`. Filters BOTH memory results AND the snapshot. New `scope` arg added to the MCP input schema.
- **`loadLatestHandoff` was missing scope on the loaded row.** Caught by codex round 2: SELECTs on `session_handoffs` did not include the new column, so a private handoff would silently surface to no-scope callers because `rowToSessionHandoff` normalized scope to null. All SELECTs now include scope.

### Added
- **MCP `hippo_recall`** accepts `include_continuity: true` and `scope: string`. When continuity is requested, appends a "## Continuity" text section to the existing return string. No structured-shape change to the MCP contract.
- **HTTP `GET /v1/memories?include_continuity=1&scope=...`** propagates both flags to `api.recall`. Sets `Cache-Control: no-store` on responses with continuity.
- **`client.recall`** now sends `include_continuity` and `scope` query params (the v1.1.0 throw guard is gone).

### Schema
- Migration v23: `task_snapshots.scope` added (nullable). Composite index on `(tenant_id, scope, status)`.
- Quarantine policy: pre-existing rows with NULL `scope` on all three continuity tables (`task_snapshots`, `session_events`, `session_handoffs`) are marked `'unknown:legacy'` so the default-deny filter excludes them for no-scope callers. Idempotent via `WHERE scope IS NULL`. Self-heals partial-init stores via `tableExists` guards.

### Writer signatures
- `saveActiveTaskSnapshot`, `appendSessionEvent`, `saveSessionHandoff` accept optional `scope: string | null`.
- `TaskSnapshot`, `SessionEvent`, `SessionHandoff` types carry `scope`.

### Closed from v1.0.0 / v1.1.0
- v1.0.0 "Known limitation: scope=NULL on continuity tables" — CLOSED.
- v1.1.0 "Deferred to v1.2.0: MCP `hippo_recall` continuity + HTTP `GET /v1/memories?include_continuity=true`" — CLOSED.
- v1.1.0 "`client.recall` throws when `includeContinuity` is set" — CLOSED.

### Out of scope (deferred)
- Slack continuity producer. Continuity rows currently only originate from CLI session commands and hooks. Slack-derived continuity (which would set `slack:public:<ch>` / `slack:private:<ch>` automatically) is its own slice.
- Per-scope active snapshots. Active snapshot remains tenant-global; scope is metadata for filtering reads, not a partition key for the active predicate.
- Channel privacy reclassification. Source-time scope is immutable in v1.2. Periodic re-tagging is a v1.3+ concern.

## 1.1.0 (2026-05-03)

Continuity-first recall: one call returns both relevant memories AND where the agent left off. Opt-in via `includeContinuity` (api) or `--continuity` (CLI). Default-off keeps the hot path unchanged.

### Added
- **`api.recall` continuity block.** New `includeContinuity?: boolean` on `RecallOpts`. When true, `RecallResult` includes a `continuity` field (`activeSnapshot`, `sessionHandoff`, `recentSessionEvents`) plus `continuityTokens` for budget visibility. All three reads are tenant-scoped via the v1.0.0 helpers; no risk of cross-tenant leak. Importable: `ContinuityBlock` from `src/api.ts`.
- **`hippo recall <query> --continuity` CLI flag.** Surfaces the snapshot, handoff, and last 5 session events above the memory list. Reuses `printActiveTaskSnapshot` / `printHandoff` / `printSessionEvents` formatters from `cmdContext` for on-screen parity. Zero-result queries with `--continuity` print the resume packet instead of the bare "No memories found" message.

### Design notes
- **No stale-handoff resurrection.** When there is no active snapshot, `sessionHandoff` is null and `recentSessionEvents` is empty. The explicit handoff-without-snapshot path remains `hippo session resume` (src/cli.ts:3022). This avoids surprise resurrection of post-session state in the implicit recall flow.
- **`continuityTokens` reports the FULL payload** (snapshot + handoff + every event's full content). Callers needing a tight resume packet should truncate event content themselves before display. Same `Math.ceil(len/4)` rule used by the existing `tokens` count.
- **Hot path unchanged.** When `includeContinuity` is omitted (or `--continuity` not set on the CLI), no continuity helpers run. The audit log entry for `recall` is identical.

### Performance
- Continuity-on adds ~17ms p99 over the BM25 path on a 2k-store warm-DB benchmark (in-process, no HTTP). Cost is dominated by three additional `openHippoDb`/`closeHippoDb` cycles plus the markdown mirror write inside `loadActiveTaskSnapshot`. This is an opt-in boot-time cost, not per-message hot-path overhead. Optimization (shared connection, readOnly snapshot path) tracked for v1.2.0+.

### Known limitations
- **Continuity tables ship with `scope=NULL`** (carried over from v1.0.0). v1.1.0 adds a forward-compatible default-deny filter in `api.recall` and `cmdRecall`: a no-`scope` caller will not see continuity rows whose `scope` starts with `slack:private:`. This is currently a no-op because no writer sets `scope` on snapshots / handoffs / events. v1.2.0 wires the writers and closes the loop. Until then, callers in multi-tenant deployments with private-channel ingestion should pass an explicit `scope` when calling `recall(..., { includeContinuity: true })` to make the intended scope explicit.
- **`client.recall` throws** when `includeContinuity` is set. HTTP transport for the continuity block lands in v1.2.0; failing loudly is preferable to silently dropping the flag.

### Deferred to v1.2.0
- **MCP `hippo_recall` continuity** and **HTTP `GET /v1/memories?include_continuity=true`** are deferred and will land together with the `scope` read-side filter on continuity tables. Reason: continuity tables ship with `scope=NULL` (v1.0.0 known limitation). Exposing continuity on LLM-facing or remote surfaces before scope filtering widens the unfiltered private-channel surface beyond what v1.0.0 guarantees. The existing `hippo_context` MCP tool (which already exposes the active snapshot) is unchanged in this slice and is included in the v1.2.0 scope-filter audit.

## 1.0.0 (2026-05-03)

Tenant-isolation security release. Closes a cross-tenant data leak on the
continuity tables (snapshots, session events, session handoffs) that the
v0.40.0 measurement gates uncovered. Bumped to 1.0.0 because 7 store
helpers gained a required `tenantId` parameter.

### Security (CRITICAL)
- **task_snapshots cross-tenant leak.** `saveActiveTaskSnapshot`'s supersede UPDATE was tenant-blind: tenant B saving an active snapshot would mark tenant A's row as 'superseded'. Same gap on `loadActiveTaskSnapshot` and `clearActiveTaskSnapshot`. All three now scope reads and writes by `tenantId`.
- **session_events / session_handoffs missing tenant_id.** Both tables predated the v16 tenant migration. Surfacing them through any continuity API would mix tenants. Schema v22 adds `tenant_id` (NOT NULL DEFAULT 'default') with smart backfill via `task_snapshots.session_id` joins. `appendSessionEvent`, `listSessionEvents`, `saveSessionHandoff`, `loadLatestHandoff`, `loadHandoffById`, `findPromotableSessions`, and `traceExistsForSession` are now tenant-scoped.
- **Mirror file leak.** `buffer/active-task.md` and `buffer/recent-session.md` were at fixed paths regardless of tenant. Multi-tenant deployments would have tenant B overwrite tenant A's mirror. Non-default tenants now get `buffer/active-task.<tenantId>.md`; default tenant keeps the unsuffixed path for on-disk back-compat.
- **Slack ingestion missing owner envelope.** `messageToRememberOpts` set `artifact_ref` but not `owner`, so every Slack-ingested raw row failed the v0.40.0 `hippo provenance --strict` gate. Now emits `owner: 'user:<slack_user_id>'` when present. Bot/system messages without `user` keep `owner=null` (correct signal: unattributable, investigate).

### Breaking
- **10 store helpers now take `tenantId` as their second positional argument.** TypeScript callers get a compile error. JS callers from older code would silently misbind a `sessionId` where `tenantId` is now expected. New `assertTenantId` runtime guard rejects the most common misbinding shape (any value matching `/^sess[-_]/i`) with a clear migration message. Affected helpers: `saveActiveTaskSnapshot`, `loadActiveTaskSnapshot`, `clearActiveTaskSnapshot`, `appendSessionEvent`, `listSessionEvents`, `saveSessionHandoff`, `loadLatestHandoff`, `loadHandoffById`, `findPromotableSessions`, `traceExistsForSession`.

### Schema
- Migration v22: `ALTER TABLE session_events ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'` plus a nullable `scope` column for future read-side default-deny work. Same on `session_handoffs`. Composite indexes on `(tenant_id, session_id, created_at)`. Self-heals partial-init stores via `CREATE TABLE IF NOT EXISTS` before the ALTERs. Migration runs inside the existing `BEGIN`/`ROLLBACK` transaction.
- Smart backfill: rows whose session_id maps to exactly one tenant in `task_snapshots` inherit that tenant; ambiguous or unmapped rows stay at `'default'`. Conservative: never crosses tenant boundaries on guesses.

### Known limitations
- **`scope` column on continuity tables is currently NULL on all writes.** The column was added in v22 to support a future read-side default-deny rule (mirroring the existing private-Slack filter on memories), but the read path is not wired yet. Wiring both sides at once will land in a follow-up release. No regression vs v0.40.0; private-channel handoffs were not filtered there either.
- **Backfill ambiguity.** A pre-v22 store with both real `default`-tenant data and unrelated legacy `default` rows could see legacy rows reassigned to a `default`-tenant `task_snapshots.session_id` if they share session ids. Low real-world impact; flagged for the multi-tenant rollout playbook.
- **Slack bot/system messages without `user`** still produce `kind='raw'` rows with no `owner`, which fail `hippo provenance --strict`. Connectors that need bot attribution should emit `owner: 'agent:slack-bot:<bot_id>'` themselves until the connector grows that path natively.
- **Transitive CVEs in `@xenova/transformers` (4 critical via `protobufjs`).** No clean upgrade in the v2 line; `@huggingface/transformers` v4.2.0 is the official successor and the upgrade is tracked for a follow-up release. The vulnerable code path is ONNX model file parsing, not network input — a real attack requires shipping a malicious model file to the user's machine.

### Deferred from v0.40
- Tenant-guard audit on remaining MCP tools (context, status, learn, conflicts, resolve, peers).
- Request-level rate limit on `/v1/auth/keys` and `/v1/*` (mitigated by localhost-default binding).
- p99 hardening, 24h soak harness as a real release gate, B3 dlPFC sequential-learning adapter contract.

## 0.40.0 (2026-05-02)

### Added
- **Company Brain provenance gate.** `hippo provenance [--json] [--strict]` audits every `kind='raw'` row for `owner` + `artifact_ref`. Reports coverage and per-row gaps; `--strict` exits non-zero so CI can block ingestion regressions. Importable: `buildProvenanceCoverage(entries)` from `src/provenance-coverage.ts`.
- **Correction-latency observability.** `hippo correction-latency [--json]` walks `superseded_by` chains, splits pairs into extraction-driven vs manual cohorts, and reports p50 / p95 / max wall-clock lag from receipt to supersession over the extraction cohort only (manual zeros excluded so they never mask real lag). Importable: `buildCorrectionLatency(entries)` from `src/correction-latency.ts`.
- **NaN / cycle / dangling-pointer resilience.** Latency calculator skips pairs with malformed timestamps and tolerates supersession chains that point at unknown targets.

### Docs
- `docs/plans/2026-04-28-company-brain-measurement.md` scorecard updated: provenance and correction-latency rows moved from "blocked" to "measurable now". All 8 rows now have a runnable evidence path.

## 0.39.0 (2026-04-30)

### Security (CRITICAL)
- **Cross-tenant authorization:** `promote()` now verifies memory belongs to ctx.tenantId before promoteToGlobal. `authCreate` ignores body.tenantId and forces ctx.tenantId at HTTP layer.
- **Supersede CAS race:** `supersede()` wraps the transition in BEGIN IMMEDIATE with `WHERE superseded_by IS NULL`; concurrent attempts now throw CONFLICT instead of producing double chains.
- **MCP cross-tenant outcome poisoning:** `lastRecalledIds` now keyed by per-client `clientKey` (HTTP: hash(bearer + remote IP), stdio: 'stdio-${pid}'). Outcome from client B cannot touch client A's recall set.
- **Slack unknown-team fallback:** when `slack_workspaces` is non-empty and the incoming `team_id` is unmapped, the event is sent to DLQ as `unroutable` instead of silently ingesting into the env-default tenant. Escape hatch: `SLACK_ALLOW_UNKNOWN_TEAM_FALLBACK=1`.

### Privacy (BREAKING data shape)
- **GDPR Path A on raw_archive:** archived memories no longer retain content in `raw_archive.payload_json`. Stored shape is `{redacted:true, archived_at, tenant_id, kind, reason}`. Migration v20 redacts existing rows in place. Compliance audit trail preserved via `audit_log`.
- **Recall audit hashes the query:** `audit_log` rows for op='recall' now store `query_hash` (sha256, first 16 hex chars) and `query_length` instead of the truncated query text. Prevents canary content from persisting in audit_log when a caller queries with text matching an archived (RTBF) memory.
- **Mirror reaper post-migration:** `openHippoDb()` runs `cleanupArchivedMirrors` after migrations to delete `<hippoRoot>/{episodic,buffer,semantic}/<id>.md` for every `raw_archive` row. Closes the gap where pre-v0.39 archives left their original-content markdown mirrors on disk. Idempotent via the `gdpr_v20_mirror_cleanup` meta flag (one-shot per DB). `archiveRaw` mirror cleanup is wrapped in try/catch; orphan files self-heal on a future scheduled scan if the unlink ever fails.

### Hardening
- MCP HTTP handlers route through `src/api.ts` so audit + cross-tenant guards apply uniformly
- Bearer lockdown test parameterized over the full 12-route table
- Auth timing leak reduced: `DUMMY_HASH` precomputed at module load; miss path runs scrypt
- `/mcp/stream` re-validates bearer on a 60s heartbeat; new `MCP_SSE_MAX_AGE_SEC` (default 3600s) caps stream age
- Graceful shutdown awaits `server.stop()` before `process.exit`
- Slack ingest race closed via `afterWrite` hook (atomic event_log + memory)
- Slack deletion idempotency closed via new `afterArchive` hook in `archiveRawMemory`
- Slack DLQ: schema additions (team_id, bucket, retry_count, signature, slack_timestamp); `hippo slack dlq replay <id>` command
- Slack signing-secret rotation: accept `SLACK_SIGNING_SECRET_PREVIOUS` during rollover

### Schema
- Migration v19: slack_dlq columns (team_id, bucket, retry_count, signature, slack_timestamp)
- Migration v20: raw_archive.payload_json redacted in place (Path A backfill)

### Retracted
- v0.36 <50ms p99 latency target. v0.36 ships at 58.4ms (sequential single-thread). No current target; revisit in v0.40+ if a real user asks.

### Deferred to v0.40
- Tenant-guard audit on remaining MCP tools (context, status, learn, conflicts, resolve, peers) + any unscoped readEntry/loadSearchEntries call sites in CLI/dashboard/refine
- Request-level rate limit on /v1/* to bound key-id enumeration
- p99 hardening
- 24h soak harness as a real release gate (currently scaffold)
- B3 dlPFC follow-ups (sequential-learning adapter contract, etc.)

## 0.38.0 (2026-04-29)

### Added
- **B3 dlPFC persistent goal stack depth.** Schema v18 adds `goal_stack`, `retrieval_policy`, `goal_recall_log` (with FKs and CHECK constraints, tenant+session indexed). New CLI: `hippo goal push|list|complete|suspend|resume`. Active goals are tenant-and-session scoped, capped at depth 3 via `BEGIN IMMEDIATE` (oldest auto-suspends). When `HIPPO_SESSION_ID` is set, `hippo recall` auto-applies a goal-tag boost (final multiplier hard-capped at 3.0x). Retrieval policies (`error-prioritized`, `schema-fit-biased`, `recency-first`, `hybrid`) further shape ranking. Goal completion with `--outcome` propagates strength changes onto memories whose recall fell within the goal's lifespan window: `outcome >= 0.7` boosts (×1.10), `outcome < 0.3` decays (×0.85), neutral band leaves strength alone. UNIQUE(memory_id, goal_id) on the recall log prevents double-propagation.
- **B3 cluster-discrimination benchmark.** New `benchmarks/micro/fixtures/dlpfc_depth.json` exercises three disjoint memory clusters under three named goals using the existing `run.py` harness. Each query asserts the active goal's cluster is in top-3 AND the other two clusters are NOT in top-3 — a deterministic test that BM25 alone cannot pass since all 18 memories share the query terms. Result captured in `benchmarks/micro/results/b3-depth.json` (3/3 queries pass). A statistical Wilcoxon-paired version moves to v0.39 stretch.

### Deferred
- **Sequential-learning trap-rate lift** moved from B3 success criterion to v0.39 stretch goal. Requires upstream contract change to `benchmarks/sequential-learning/adapters/interface.mjs` adding `pushGoal/completeGoal` hooks; current adapter shape (recall(query) / store(content,tags)) cannot exercise the goal-stack mechanism. Tracked in TODOS.md.
- **MCP/REST goal-stack boost.** v0.38 surfaces the boost only via the CLI (env-driven `HIPPO_SESSION_ID`). v0.39 plumbs `session_id` through `Context` for `recall(ctx, opts)` so MCP and `/v1/recall` callers get the same boost.

### Schema
- Migration v18: `goal_stack` (tenant_id, session_id, goal_name, level CHECK 0..2, parent_goal_id self-FK, status CHECK, success_condition, retrieval_policy_id, created_at, completed_at, outcome_score CHECK 0..1), `retrieval_policy` (FK to goal_stack ON DELETE CASCADE), `goal_recall_log` (FKs to goal_stack and memories, UNIQUE(memory_id, goal_id)).

## 0.37.0 (2026-04-29)

### Added
- **E1.3 Slack append-only ingestion.** Webhook to kind='raw' memories with full provenance (slack:// artifact_ref, scope from channel privacy). Idempotency via slack_event_log, cursor-based backfill resume via slack_cursors, malformed payloads to slack_dlq. Source deletion (Slack message_deleted) routes through archiveRawMemory for GDPR compliance.
- **PUBLIC_ROUTES allow-list + HIPPO_REQUIRE_AUTH knob.** Slack webhook (HMAC-signed, no Bearer) is the first explicit public /v1/* route. Bearer-lockdown test asserts every other /v1/* route returns 401 without auth when HIPPO_REQUIRE_AUTH=1.
- **slack_workspaces tenant routing.** Multi-workspace deployments map team_id to tenant_id; single-workspace deployments fall back to HIPPO_TENANT.
- **api.remember afterWrite hook.** Connectors now stamp idempotency rows atomically with the memory row via a SAVEPOINT-scoped callback, closing the Slack-retry race.
- **Recall scope filter + default-deny on private channels.** No-scope queries cannot see scope='slack:private:*' memories; frontend callers passing undefined scope no longer leak private content.
- **hippo slack CLI.** `hippo slack backfill --channel <id>` (requires SLACK_BOT_TOKEN), `hippo slack dlq list` for malformed-event review.

### Fixed
- **archiveRaw leaves no orphaned mirrors.** Centralized GDPR fix: api.archiveRaw now removes legacy markdown mirrors (mirroring forget()), so an archived raw row cannot be revived by bootstrapLegacyStore on the next process start. Surfaced by the Slack source-deletion test.
- **Schema-version test pins.** Bumped a3-envelope-migration / a5-tenant-migration / pr2-session-continuity from 16 to 17 (was tracking "latest version", not "this migration's version").

### Changed
- Schema v17 adds slack_event_log, slack_cursors, slack_dlq, slack_workspaces tables.

## 0.36.0 (2026-04-29)

### Added
- **A1 server mode.** `hippo serve` runs a persistent daemon on http://127.0.0.1:6789 (configurable via --port or HIPPO_PORT). Exposes /v1/memories, /v1/auth/keys, /v1/audit, MCP-over-HTTP at /mcp, and /health.
- **CLI thin-client.** When `hippo serve` is running, CLI invocations auto-detect via .hippo/server.pid and route through HTTP. Stale pidfile self-heals on first ECONNREFUSED.
- **MCP-over-HTTP/SSE transport.** Existing stdio MCP path unchanged. POST /mcp for synchronous JSON-RPC; GET /mcp/stream for SSE keepalive (server-pushed messages deferred to v0.37.0).
- **Domain layer src/api.ts.** Pure functions for remember/recall/forget/promote/supersede/archiveRaw/auth*/audit. Both server and CLI handlers delegate through this surface.
- **HTTP auth middleware.** Bearer token via Authorization header; loopback (127.0.0.1, ::1, ::ffff:127.0.0.1) accepts unauthenticated requests as actor='localhost:cli'. Non-loopback no-token returns 401. Server refuses to bind 0.0.0.0 without auth.
- **24h soak harness skeleton** at benchmarks/a1/soak.ts. Manual run; results not gated.
- **p99 recall benchmark** at benchmarks/a1/p99-recall.ts. 10k-memory store, top-10 BM25 against tier-1 queries.

### Fixed
- Audit-log tenant attribution: `audit()` helper now uses the entry's tenant_id instead of HIPPO_TENANT env (latent bug, exposed during A1 refactor).
- api.archiveRaw and api.forget now enforce tenant scope: cross-tenant access returns "memory not found" rather than affecting another tenant's row.
- SIGTERM drain: server.closeAllConnections() before server.close() so SSE keepalive streams don't block shutdown.
- MCP-over-HTTP threads hippoRoot + tenantId from the auth context (was previously resolving its own root via cwd walk).

### Internal
- 99 new tests (730 baseline -> 829 + 2 skipped). Headline parity test (cli-thin-client) spawns real subprocess server and verifies audit discriminator. Concurrent recall+write under SQLite single-writer (10 readers x 50 reads + 1 writer x 50 writes) confirms zero locked errors.
- All 5 /review ship blockers closed (C1 pidfile banner, C2 VERSION constants, C3 MCP context plumbing, H4 drain timeout, H5 tenant deny on archive/forget).

### Known issues (tracked for v0.37.0 in TODOS.md)
- **p99 latency:** measured 58.4ms vs 50ms target on 10k store. Architecture ships; latency hardening lands in v0.37.0. Profiling candidates: per-request DB open, audit-emit roundtrip, JSON serialization, hybrid embedding wiring.
- HIPPO_API_KEY silently dropped on stale-pidfile fallback (HIPPO_REQUIRE_SERVER knob coming in v0.37.0).
- Concurrent `hippo serve` on the same hippoRoot has no winner detection; second serve clobbers the first's pidfile.
- Recall mode=hybrid query param accepted but ignored (BM25-only over HTTP). Hybrid wiring deferred.
- MCP-over-HTTP SSE is keepalive-only; no server-pushed messages.

### Deferred to v2 (full multi-tenant)
No new deferrals. A5 v2 follow-ups still tracked in TODOS.md.

## 0.35.0 (2026-04-29)

### Added
- **A5 stub auth track.** Schema v16 adds `tenant_id` to `memories`, `working_memory`, `consolidation_runs`, `task_snapshots`, `memory_conflicts` (default 'default') plus composite indexes. New tables: `api_keys` (scrypt-hashed) and `audit_log` (append-only mutation trail).
- **API key primitives.** `createApiKey` / `validateApiKey` / `revokeApiKey` / `listApiKeys` in src/auth.ts. scrypt + timingSafeEqual. Plaintext returned exactly once on create.
- **Audit log primitives.** `appendAuditEvent` / `queryAuditEvents` in src/audit.ts. Hooks on every mutation: remember, recall, promote, supersede, forget, archive_raw, auth_revoke.
- **Tenant resolution.** `resolveTenantId({db?, apiKey?})` in src/tenant.ts. Order: explicit api key > HIPPO_TENANT env > 'default'.
- **Cross-tenant isolation at recall.** Tenant A's recall does not return tenant B's memories. Enforced on CLI recall/explain/context, MCP server (`hippo_recall`, `hippo_context`, `hippo_status`), and dashboard.
- **CLI surface.** `hippo auth create [--label X] [--tenant Y]`, `hippo auth list [--all]`, `hippo auth revoke <key_id>`, `hippo audit list [--op X] [--since Y] [--limit N] [--json]`.
- **SSO/SCIM stubs** in src/sso.ts. `ssoLogin`, `scimProvisionUser`, `scimDeprovisionUser` throw `NotImplementedError` referencing v2.

### Fixed
- Empty `HIPPO_TENANT` env coerces to 'default' (whitespace-trimmed).
- bigint-safe JSON serialization for audit metadata (mirrors the raw-archive pattern).
- `archiveRawMemory` audit event now uses the row's tenant_id, not the operator's env.

### Internal
- 30 new tests across schema, auth, audit, tenant, store, CLI surfaces. Cross-tenant isolation negative test covers CLI + MCP + dashboard.
- All review findings closed: 4 HIGH (tenant filter holes on MCP/explain/dashboard/context), 7 MEDIUM, 8 LOW.

### Deferred to v2 (tracked in TODOS.md)
- Multi-tenant per-key isolation (one key -> one tenant). Stub treats deployments as single-tenant.
- OAuth/OIDC, SCIM provisioning.
- Audit log retention policy.
- RBAC, rate limiting per tenant.

## 0.34.0 (2026-04-29)

### Added
- **A3 provenance envelope.** Every memory now carries `kind` (`raw | distilled | superseded | archived`), `scope`, `owner`, and `artifact_ref` columns. `hippo recall --why` surfaces the envelope; `hippo remember` accepts `--kind`, `--scope`, `--owner`, `--artifact-ref` flags. See `MEMORY_ENVELOPE.md`.
- **Append-only invariant on `kind='raw'`.** SQLite trigger `trg_memories_raw_append_only` aborts direct DELETE on raw rows. The only legitimate path is `archiveRawMemory(db, id, { reason, who })` which snapshots into the new `raw_archive` table, purges the FTS row, and removes the memory in one SAVEPOINT (sets up A4 right-to-be-forgotten).
- **Schema v14 + v15.** v14 adds the envelope columns, the `raw_archive` table, the append-only trigger, and INSERT/UPDATE CHECK-substitute triggers (ALTER TABLE cannot add CHECK in SQLite). v15 closes a NULL-kind bypass in those triggers and adds `UNIQUE(memory_id, archived_at)` to `raw_archive`. Backwards compatible, auto-migrates.
- **Pineal salience v2.** `--salience-threshold` flag for the recall pipeline (commit `50528a5`).
- **Enterprise execution roadmap (`ROADMAP-RESEARCH.md`).** 90-day plan re-sequenced after Codex + eng-review pass: A3 envelope first (this release), then A5 stub auth, A1 server, E1.3 Slack ingestion. Cuts 7 deferred items into days 91-180.

### Fixed
- **FTS leak in `archiveRawMemory`.** Archived raw content stayed in `memories_fts` until next DB-open backfill; defeated GDPR right-to-be-forgotten. Archive now purges the FTS row inside the same SAVEPOINT.
- **CLI `--kind raw` gated.** Existing `hippo forget` / consolidation / conflict-resolution paths abort on raw rows via the trigger. Until those paths route through `archiveRawMemory`, the CLI restricts `--kind` to `{distilled, superseded}` so users cannot create unforgettable memories.
- **NULL-kind trigger bypass.** v14 triggers used `WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN (...)`, so a direct `kind=NULL` write silently bypassed the CHECK substitute. v15 rejects NULL.
- **`archiveRawMemory` transaction safety.** Now uses `SAVEPOINT` (nestable) instead of `BEGIN`. BigInt-safe JSON serializer for the audit payload.
- **`--scope` envelope trim.** Matched the pre-existing scope-tag trim behavior.

### Internal
- 730 tests (+15 from v0.33.0). New: `tests/a3-envelope-migration.test.ts`, `tests/raw-archive.test.ts`, `tests/recall-why-envelope.test.ts`.
- Reviewed via `/codex`, `/plan-eng-review`, `/review` (Claude pass + adversarial subagent), `/self-review`, `/ship-check`. All ship-blockers resolved before release.

## 0.33.0 (2026-04-23)

### Added
- **Write-time fact extraction.** During `hippo sleep`, episodic memories are now processed by an LLM to extract standalone facts (up to 8 per memory). Facts are stored as semantic-layer entries with `extracted_from` linking back to the source. Extracted facts get a 1.3x search boost and automatically deduplicate against their source entries in results, so users see the precise fact instead of the raw conversation.
- **DAG summarization.** Extracted facts are clustered by Jaccard similarity (>= 0.5) on speaker:/topic: entity tags, then summarized into dag_level=2 parent nodes. When a summary matches a query, its children are injected into results at 0.9x parent score, giving hierarchical drill-down.
- **Multi-hop retrieval.** `hippo recall --multihop` and `multihopSearch()` run a two-pass entity-chained search. Pass 1 retrieves top-K and extracts entity tags not in the original query. Pass 2 reformulates the query with discovered entities and retrieves again. Results merge by highest score per ID.
- **`hippo remember --extract`** triggers immediate fact extraction on the remembered content.
- **`hippo dag --stats`** shows DAG layer distribution (how many entries at each level).
- **Schema v12-v13.** v12 adds `extracted_from` column, v13 adds `dag_level` + `dag_parent_id` with backfill and index. Backwards compatible, auto-migrates on first open.

### Fixed
- **`temporalBoost` O(N^2) refactored to O(N).** Previously called `Math.min(...timestamps)` per entry inside the search loop, risking stack overflow on large stores. Now precomputes range once via `computeTemporalRange()`.
- **Config scoping bug in `consolidate.ts`.** `config` was block-scoped inside the extraction `if` block but referenced from the DAG section outside it. Would cause ReferenceError when no extraction candidates exist but extracted facts are ready for DAG processing.
- **Dead `seenIds` variables removed** from both search paths (populated but never read).

### Internal
- 674 tests (+41 from v0.32.0). 16 new test files covering extraction, DAG, multi-hop, temporal scoring, CLI commands, and integration smoke tests.
- Reviewed via `/review` + `/self-review` + `/qa` + `/ship-check` + senior code review agent.

## 0.32.0 (2026-04-22)

### Added
- **Bi-temporal memory: correction without deletion.** When a belief changes, the old memory stays as historical truth instead of being overwritten. Default recall filters superseded entries so agents see current reality; historical views are explicit. Schema v11 adds `valid_from` and `superseded_by` columns, backwards compatible with v10 stores (ADD COLUMN only, no data transform).
- **`hippo supersede <old-id> "<new content>"`.** Creates a successor memory and links the old one via `superseded_by`. Cycle prevention: if the target is already superseded, the command errors with the successor's ID so you can supersede that one instead. Reuses `--layer`, `--tag`, `--pin` from `remember`.
- **`--include-superseded`** on `hippo recall` / `explain`. Returns historical memories with a `[superseded]` marker in output. Default recall hides them.
- **`--as-of <ISO-date>`** on `hippo recall` / `explain`. Returns the set of memories that were current at that date. Validates input at CLI entry; invalid dates exit with a clear ISO-format hint.
- **Partial index for fast current-only queries.** `CREATE INDEX idx_memories_current ON memories(layer, created) WHERE superseded_by IS NULL` makes the default recall path cheap even with large archives.

### Changed
- **`markRetrieved` is a no-op for superseded entries.** Retrieving a historical memory (via `--include-superseded`) no longer strengthens it or extends its half-life. Historical reads shouldn't revive dead beliefs.
- **`detectConflicts` skips superseded pairs.** No point flagging "these contradict" when one side is historically dead.

### Research
- **Physics search ablation: CUT verdict.** Benchmarked physics-on vs physics-off over 60 stratified LongMemEval-oracle questions (paired bootstrap, 5000 iters). Physics OFF: MRR 0.8388, Recall@5 84.31%, NDCG@5 0.7888. Physics ON: MRR 0.6848, Recall@5 74.17%, NDCG@5 0.6570. All metrics statistically worse with physics; 95% CI excludes zero. Results in `benchmarks/physics-ablation/`. Physics remains in the codebase and is not removed in this release; a decision on removal is tracked as follow-up.
- **LoCoMo harness built.** `benchmarks/locomo/run.py` scores hippo against snap-research's long-conversation memory benchmark using Claude as judge. Sanity run (3 QAs): 2 adversarial abstentions correct, 1 open-domain miss. Full 10-conversation run requires overnight batch due to ~2 turns/sec ingestion.

### Internal
- 633 tests pass (+8 from v0.31.0). 3 new test files: `bi-temporal-migration.test.ts`, `cli-supersede.test.ts`, `bi-temporal-recall.test.ts`.
- 4 commits on master: `091e6de` (schema v11), `026988b` (supersede command), `b538c0d` (recall filters), `7108187` (review fixes).
- Reviewed via `/review` + `/self-review` + `/ship-check`. Two fixes landed: `--as-of` date validation (previously silent no-op on invalid input) and `cmdSupersede --tag` parity with `cmdRemember` (previously only accepted comma-separated, dropped repeated flags).

## 0.31.0 (2026-04-22)

### Added
- **Scope-aware corrections.** Memories can now be tagged with a context scope (e.g. `scope:plan-eng-review`, `scope:qa`) via `hippo remember --scope <name>`. During recall, memories whose scope matches the active scope get a 1.5x boost; memories with a mismatching scope are suppressed 0.5x; unscoped memories stay neutral. A correction said during one skill no longer pollutes unrelated contexts.
- **Auto-detection from env vars.** `detectScope()` reads `HIPPO_SCOPE`, `GSTACK_SKILL`, or `OPENCLAW_SKILL` in priority order. When any is set, `hippo remember` / `recall` / `context` / `explain` auto-apply the scope without explicit flags. Pure env var reads, no I/O on hot paths.
- **`--scope <name>` flag** on `hippo remember`, `hippo recall`, `hippo context`, `hippo explain`. Explicit scope overrides auto-detection.
- **`scopeBoost` in score breakdown.** `hippo explain --why` shows the scope multiplier when it is not 1.0, making scope routing debuggable.

### Internal
- 625 tests pass (+21 from v0.30.1). 3 new test files: `scope.test.ts`, `scope-boost.test.ts`, `scope-context.test.ts`.
- New module: `src/scope.ts` (32 lines). `scopeBoost` added to `src/search.ts` alongside existing `decisionBoost` / `pathBoost` / `outcomeBoost` multipliers.
- Reviewed via `/review` + `/self-review`. Git-branch fallback in `detectScope()` was proposed but dropped after review: it forked git on every UserPromptSubmit hook call (~50-150ms latency per user message) and polluted the tag space with ephemeral branch names.

## 0.30.1 (2026-04-22)

### Fixed
- **`hippo recall --layer <L>` is now a strict filter.** Previously the flag was accepted but silently dropped, so results from other layers leaked in. This broke the intent of `recall --layer trace` and the RSI demo's headline example.
- **`hippo status` now prints a `Trace:` counter.** The new trace layer was tracked internally but never surfaced in the status output.
- **`hippo --version` / `-v` print the package version.** Previously errored with "Unknown command".

### Internal
- 604 tests pass (+5 from v0.30.0). 3 new test files cover the three fixes end-to-end via `execFileSync` against `bin/hippo.js`.
- Caught by `/review` (senior-code-reviewer) + npm smoke test before the GitHub Release for v0.30.0 went public.

## 0.30.0 (2026-04-21) — Sequence binding (recursive-self-improvement foundation)

### Added
- **`Layer.Trace`** — a new memory layer for ordered action→outcome sequences. Traces are first-class `MemoryEntry` rows; they inherit decay, retrieval-strengthening, conflict detection, embeddings, replay, and physics from the existing infrastructure. Four inheritance smoke tests lock that claim.
- **`trace_outcome` + `source_session_id` columns** (schema v3 migration, with a regression test that a pre-v3 store with existing data migrates without loss). `trace_outcome` is `'success' | 'failure' | 'partial' | null`. `source_session_id` is indexed for idempotent auto-promotion.
- **`hippo session complete --session <id> --outcome <...>`** — the terminal event that marks a session as finished with a given outcome. Phase C auto-promotion depends on this event type existing.
- **`hippo trace record --task <t> --steps <json> --outcome <...>`** — explicit trace storage. Takes a JSON array of `{action, observation}` steps plus an outcome. Renders to markdown in the memory's `content` field.
- **`hippo recall --outcome <...>`** — filter results to trace-layer memories with matching outcome. Non-trace entries pass through unaffected.
- **Auto-promotion during `hippo sleep`** — completed sessions (those with a `session_complete` event within `autoTraceWindowDays`, default 7) become bound traces automatically. Idempotent via the `source_session_id` guard; three consecutive sleeps produce exactly one trace per session.
- **`examples/rsi-demo/`** — a minimal recursive-self-improvement agent that uses traces to learn from prior runs. 50-task suite with 10 trap categories. Deterministic. Ships with a measurable pass bar: late-stage success rate must exceed early-stage by at least 20 pp or the demo exits non-zero. Current seed: early 20% → late 100%, gap 0.80.
- **`src/trace.ts`** — `renderTraceContent(rec)` for markdown rendering; `parseSteps(json)` for step validation.

### Changed
- **`detectConflicts` now skips trace-vs-trace pairs.** Two successful traces for "refactor auth" are variants of each other, not contradictions. One-line filter in the conflict pass.
- **Default recall JSON output** includes `layer` always, and `trace_outcome` when present. Additive — existing consumers are unaffected.

### Config
- `config.autoTraceCapture: boolean` (default `true`) — master switch for auto-promotion.
- `config.autoTraceWindowDays: number` (default `7`) — only sessions with a `session_complete` event in the last N days are eligible.

### Grant / positioning
This is the foundation for a recursive-self-improvement story. Three of hippo's primitives (outcome-modulated decay, retrieval-strengthening, conflict resolution) were already aligned with RSI needs. Sequence binding adds the fourth: **bound outcome-linked traces**. Counterfactual memory and executable skill-tier are the next two; they compose on top of this.

### Internal
- 599 tests pass (+28 from v0.29.3). Breakdown: 8 Phase A (enum + fields + v2-with-data migration), 11 Phase B (session complete + trace module + record command + --outcome filter with non-trace pass-through), 9 Phase C (auto-promote + idempotency + windowing + conflict skip + 4 inheritance smokes).
- Phase A applied 3 eng-review blocker fixes before any code: indexed `source_session_id` for idempotency, explicit `hippo session complete` contract, dead `trace_steps_json` column dropped.
- Full plan + post-review revisions at `docs/plans/2026-04-21-sequence-binding.md`.

## 0.29.3 (2026-04-21) — Friendly post-install nudge for Claude Code users

### Added
- **Post-install banner on fresh installs.** `npm install -g hippo-memory` now detects whether Claude Code is present (`~/.claude/` exists) AND whether the Hippo `UserPromptSubmit` hook is already wired (`settings.json` contains `hippo context --pinned-only`). If Claude Code is present and the hook is absent, prints a three-line message pointing the user at `hippo init`. Silent on machines without Claude Code or on reinstalls where the hook is already wired. Opt out with `HIPPO_SKIP_POSTINSTALL=1`.
- **No config writes.** The banner is read-only — it prints to stderr. No surprise edits to `~/.claude/settings.json`, which would be rude and trip security scanners.

### Rationale
Before this, a new user's flow was: `npm install -g` → run some command → "wait, why is nothing happening?" → search docs → find `hippo init`. Three friction points. Now: install → see the banner → copy-paste `hippo init`. One friction point, already highlighted.

## 0.29.2 (2026-04-21) — Fix UserPromptSubmit hook in non-initialized directories

### Fixed
- **`UserPromptSubmit` hook no longer errors in fresh cwds.** The v0.29.x `hippo context --pinned-only` path hard-failed `requireInit` whenever Claude Code opened a session in a directory without a local `.hippo/` store, producing a visible "No .hippo directory found. Run `hippo init` first." error on every user message. Now the pinned-only path falls back to global-only when no local store exists. The non-pinned `hippo context` path still requires init (unchanged).
- **Hook no longer auto-creates `.hippo/` in arbitrary cwds.** Previously, `loadActiveTaskSnapshot` and `loadAllEntries` inside `cmdContext` would silently create `.hippo/` on first invocation. Now both are guarded by `isInitialized(hippoRoot)` so the hook leaves fresh directories untouched.

### Internal
- 571 tests pass (+2): regression tests covering the "missing local .hippo" case and the "neither local nor global has pinned memories" empty-cwd path. Both assert zero `.hippo/` pollution.

## 0.29.1 (2026-04-21) — Raise default pinnedInject.budget to 1500

### Changed
- **`config.pinnedInject.budget` default: 500 → 1500.** The initial 500-token default was too tight for mature hippo installs. Smoke-testing on a store with 10 existing pinned memories (685 tokens) showed new invariants silently dropped off the bottom of the rehearsed set. 1500 matches `defaultContextBudget` and comfortably fits typical pinned-memory counts. Users with a `.hippo/config.json` override keep their explicit value; only the default changes.

### Fixed
- **Test assertion.** `tests/config.test.ts` updated to match the new default.

## 0.29.0 (2026-04-21) — Replay + mid-session pinned re-injection

### Added
- **Replay pass in `hippo sleep`.** Hippo now rehearses a small sample of surviving memories on every consolidation cycle, mirroring hippocampal replay during slow-wave sleep. The sampler weights by reward feedback, emotional valence, under-rehearsal, idle time, and remaining strength. Rehearsed memories get the same retrieval-strengthening that a real `hippo recall` applies (retrieval_count +1, half_life +2 days, last_retrieved refreshed). Exposed via new `src/replay.ts` (`sampleForReplay`, `replayPriority`). Sleep output now emits a `💭 replayed N memories: <ids>` line between the decay and physics passes.
- **`config.replay.count`.** Number of memories to rehearse per sleep cycle (default: 5). Set to 0 to disable. Stale-confidence memories are never rehearsed — staleness is a deliberate signal we don't want to erase.
- **Mid-session pinned-rule re-injection (Claude Code).** Addresses the Opus 4.7 complaint that the model "forgets" rules mid-session. `hippo context` now accepts `--pinned-only` (restrict to pinned memories) and `--format additional-context` (emit Claude Code's `{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"..."}}` JSON shape). `hippo hook install claude-code` (and `opencode`) now installs a `UserPromptSubmit` hook that invokes this every turn, so pinned rules stay in context every message, not just at SessionStart. Read-only: no retrieval_count inflation.
- **`config.pinnedInject.{enabled,budget}`.** Controls the hook behaviour. Defaults: `enabled: true`, `budget: 500` tokens. Disable with `{"pinnedInject":{"enabled":false}}`. Zero output when no pinned memories exist (zero per-turn tax).

### Behaviour changes (users should know)
- **Replay.** Every user's next `hippo sleep` will begin rehearsing up to 5 memories by default. Non-destructive, small positive bias toward high-value memories staying alive. Opt out with `{"replay":{"count":0}}`.
- **Pinned re-injection.** Existing users must re-run `hippo hook install claude-code` (or `opencode`) to pick up the new `UserPromptSubmit` entry — it is NOT auto-added to existing installs. Once installed, every turn's context carries the (read-only) pinned block. Opt out per-user with `{"pinnedInject":{"enabled":false}}`.

### Grant context
Closes the replay gap documented in `docs/plans/2026-04-21-hippocampal-mechanism-audit.md`. The Frontier AI Discovery feasibility study pitch claimed 7 hippocampal mechanisms; pre-audit the code implemented 6. Replay is now `PRESENT` with unit + integration tests.

### Internal
- 569 tests pass (+29 from v0.28.0): 14 unit tests for the replay sampler + priority, 3 integration tests for the consolidation pass wiring, 7 tests for `--pinned-only` command behaviour (filtering, JSON shape, read-only guarantee, config respect, multi-memory injection), 3 tests for the UserPromptSubmit hook install/uninstall/idempotency, 2 tests for the `pinnedInject` config schema.
- Phase A model-profile benchmark infrastructure (`evals/model-profile-bench.json`, `scripts/run-model-profile-bench.mjs`, `scripts/model-profile-judge.mjs`) shipped as a reusable harness. Baseline run produced a null result (4.6 and 4.7 perform identically on our failure modes) — see `docs/plans/2026-04-21-phase-a-decision.md`.
- O1 soak test harness (`scripts/soak-test.mjs`, `scripts/soak-all.mjs`, `benchmarks/soak/`) validates the physics engine stays energy-bounded across 10 synthetic workload profiles. Current sweep at 100 ticks × 80 particles is a smoke scale, not a 100-hour study.
- O2 competitor-benchmark scope documented at `docs/plans/2026-04-21-o2-competitor-benchmark-scope.md` (not started).
- Plan: `docs/plans/2026-04-21-pinned-reinject.md`.

## 0.28.0 (2026-04-20) — Budget saturation fix + LongMemEval parity

### Added
- **`minResults` option on all search functions.** `hybridSearch`, `physicsSearch`, `search`, and `searchBothHybrid` accept `minResults` to guarantee at least N results regardless of token budget. Prevents budget saturation when memories are large (e.g. LongMemEval's 14k-char session dumps fit only 1 per budget=4000). Production default: 1 (backward compatible). CLI: `hippo recall <q> --min-results 5`.
- **`scoring: 'rrf'` option on `hybridSearch`.** Reciprocal rank fusion as an alternative to score blending. Combines BM25 and cosine ranks instead of scores. Available for experimentation; default remains `'blend'`.
- **`hippo refine` command.** LLM-powered semantic rewrite of memories for improved recall quality.

### Fixed
- **LongMemEval regression was benchmark methodology, not scoring.** The v0.27 benchmark runner used `budget=4000` (fitting ~1 memory per query) while v0.11 used FTS5 `top_k=10` with no budget. Corrected benchmark defaults to `budget=1000000, minResults=10`. With fair comparison, v0.27 R@10 = 81.0% vs v0.11 R@10 = 82.6% (1.6pp gap, down from apparent 35pp). v0.27 wins on R@3 (+0.4pp) and answer_in_content@5 (+3.0pp).
- **MMR O(N^2) on large candidate sets.** Capped re-ranking to top-100 candidates. Per-query time dropped from ~50s to ~9s.

### Performance
- **`preparedCorpus` option on `hybridSearch`.** Batch callers skip per-query O(N*docLen) tokenization. Further per-query drop to ~6-7s.

### Internal
- 540 tests pass (up from 537). New coverage: `minResults` guarantees for sync search, async hybridSearch, and edge case (minResults > available).
- Benchmark runner (`retrieve_inprocess.mjs`) defaults updated for fair evaluation.
- Full LongMemEval results documented in `evals/README.md` with corrected methodology.

## 0.27.0 (2026-04-20) — Recall observability + quality

### Added
- **`hippo explain <query>`.** Read-only diagnostic that shows the full score breakdown per retrieved memory: BM25 raw + normalized + weight + matched query terms, cosine + weight, base blend, strength/recency/decision/path/source/outcome multipliers, age, and final composite. Does NOT mark memories as retrieved, so it's safe as a debugging tool. `--json` for programmatic consumers.
- **`hippo trace <id>`.** Single-memory dossier: content, layer/confidence/pinned/tags, age, strength trajectory with 30-day and 90-day projections, effective half-life with reward-factor breakdown, retrieval count + staleness, outcome pos/neg, consolidation parents, and any open conflicts. `--json` supported.
- **`hippo eval [<corpus.json>]`.** Measure recall quality against a test corpus. Metrics: MRR, Recall@5, Recall@10, NDCG@10. `--bootstrap` generates a synthetic corpus from current memories (useful as a smoke test). `--show-cases` prints per-case details so eval doubles as a debugger. `--min-mrr <f>` gates CI by exiting non-zero when mean MRR drops below threshold. `--no-mmr` / `--mmr-lambda <f>` / `--embedding-weight <f>` to A/B tune.
- **MMR diversity re-ranking.** After hybrid scoring, iteratively pick the result maximising `lambda * relevance - (1 - lambda) * max(cos(cand, picked))` so near-duplicate memories don't cluster at the top. Default `lambda=0.7`, configurable via `config.mmr.{enabled, lambda}`. Only applies when embeddings are loaded; pure-BM25 mode is unchanged. Config + CLI off-switches available.
- **Outcome-based retrieval boost.** `hippo outcome --good/--bad` now gives an immediate nudge on the next recall via `1 + 0.15 * tanh((pos - neg) / 2)` clipped to `[0.85, 1.15]`. Distinct from the existing slow reward-factor-via-strength path. `ScoreBreakdown` includes the new field.
- **Real eval corpus** at `evals/real-corpus.json` with 15 hand-curated cases spanning project rules, dev-environment gotchas, external project references, and architecture notes. `scripts/build-eval-corpus.mjs` regenerates from the live store. Baseline numbers documented in `evals/README.md`.

### Fixed
- **Misleading `hybrid` mode label.** When the query was embedded but no document had a cached vector, explain output showed `mode: hybrid` even though only BM25 was contributing. Now split: `hybrid` when a cached vector was used, `hybrid-no-vec` with a hint to run `hippo embed` when not. No scoring change — labeling only.
- **`hippo eval --bootstrap --out <nested/path>.json`** now auto-creates the parent directory instead of failing on ENOENT.

### Internal
- New `src/eval.ts` exports pure-function metrics (`mrr`, `recallAtK`, `ndcgAtK`), a `runEval` driver, and `bootstrapCorpus`.
- `SearchResult.breakdown?: ScoreBreakdown` opt-in via `{ explain: true }` on hybridSearch / physicsSearch / searchBothHybrid. Zero-cost when unset.
- `mmrRerank` helper exported for direct unit testing.
- 523 tests pass (up from 498). New coverage: breakdown math identity, outcomeBoost bounds, MMR reorder at various lambda values, eval metric math, bootstrap filtering.

## 0.26.0 (2026-04-20) — Memory quality

### Added
- **`hippo audit` command.** Checks memory quality and flags low-value entries: too-short content, release/merge/WIP commit noise, sentence fragments, vague entries with no specific details. `--fix` removes errors (auto-deletes). Severities: `error` (removed on fix) and `warning` (reported only).
- **Sleep-time auto-cleanup.** `hippo sleep` now runs the audit and silently removes junk memories (severity `error`). Prevents commit-noise like `"release 0.24.1"` or `"Merge branch main"` from surviving consolidation.
- **Capture quality gate.** `cmdCapture` (markdown importer, Claude Code hooks) filters extractions through `isContentWorthStoring()` so fragments and version bumps never enter the store.

### Fixed
- **Conflict detector over-fires.** Previous detector flagged 800+ spurious "negation polarity mismatch" conflicts from scanning entire memory bodies. Rewritten with stopword-filtered Jaccard, a minimum rare-shared-token gate, and opening-window polarity: enabled/disabled, true/false, and always/never checks now only fire on tokens near the start of a memory. Removes false positives where common English prepositions ("on", "off", "in", "out") happened to co-occur deep in unrelated prose.
- **`hippo remember` accepts empty/tiny inputs.** Now rejects content under 3 characters with a clear error.

### Internal
- New `src/audit.ts` with `auditMemory`, `auditMemories`, `isContentWorthStoring`.
- `scripts/resolve-stale-conflicts.mjs` — one-off migration that marks the pre-0.26 spurious conflicts as resolved so they vanish from the UI and reports.
- Schema migration to version 9 adds `parents_json` and `starred` columns to the memory store (reserved for future UI work; unused in this release).

## 0.25.0 (2026-04-16) — Brain Observatory

### Added
- **Living Map UI.** `hippo dashboard` now serves an interactive particle visualization of your agent's memory. Memories rendered as glowing particles on a 2D canvas with force-directed layout.
  - Color by layer (buffer = blue, episodic = amber, semantic = green)
  - Size by retrieval count, opacity by current strength
  - PCA projection of 384-dim embeddings to 2D with d3-force clustering
  - Hover tooltips, click for full detail panel, search filtering with dimming
  - Red dashed lines between conflicting memories
  - Subtle breathing animation simulating live decay
  - Empty state with getting-started prompt
- **JSON API.** Six endpoints for programmatic access: `/api/memories`, `/api/stats`, `/api/conflicts`, `/api/embeddings`, `/api/peers`, `/api/config`.
- **Static file serving.** Dashboard serves pre-built React SPA from `dist-ui/` with SPA fallback routing. Legacy inline HTML preserved when UI is not built.

### Changed
- `prepublishOnly` now runs `build:all` (TypeScript + UI) to include `dist-ui/` in the npm package.

## 0.24.2 (2026-04-16)

### Added
- **Machine-level daily runner.** `hippo init` now registers each workspace in a global registry and installs one daily runner at 6:15am instead of creating one OS task per project. The new `hippo daily-runner` command sweeps all registered workspaces and runs `hippo learn --git --days 1` followed by `hippo sleep`.

### Changed
- **OpenClaw session-end autosleep is detached.** When the native OpenClaw plugin has `autoSleep` enabled, it now spawns `hippo sleep` in a detached background process on `session_end` so shutdown is not blocked by consolidation.
- **Docs now describe local + global retrieval plus daily refresh separately.** OpenClaw, OpenCode, Pi, and other agent integrations now document the split between query-time retrieval, session-end hooks, and the machine-level daily runner.

### Internal
- Added `src/scheduler.ts` and `tests/scheduler.test.ts` for workspace registry handling, command generation, and daily sweep execution. Full suite passes: 494 tests.

## 0.24.1 (2026-04-15)

### Fixed
- **Conflict detection now gates on content overlap, not shared tags.** `hippo sleep` no longer flags unrelated `feedback` / `policy` memories as contradictions just because they share coarse tags and opposite polarity words.
- **Reworded contradictions still surface.** Opposites like `API auth must be enabled in prod` / `Disable API auth in prod` stay detectable instead of being filtered out by a blunt overlap threshold.
- **`must` and `always` now count as positive polarity.** Contradictions like `Production deploys must require approval` / `Production deploys should not require approval` are caught consistently.

### Internal
- Added regression tests for the exact false-positive pairs from the migrated-store report plus a broader contradiction matrix (`must` vs `should not`, `available` vs `missing`, `works` vs `broken`). Full suite passes: 491 tests.

## 0.24.0 (2026-04-15)

### Added
- **Codex auto-wrap on install and update.** Installing or upgrading `hippo-memory` now runs a postinstall step that looks for `codex` on `PATH`, renames the original launcher to a sibling backup such as `codex.hippo-real.cmd` / `codex.hippo-real.exe`, and drops a Hippo wrapper at the command path users already run. No extra PATH prep, no manual launcher swap.
- **Codex self-heal install path.** Common Hippo commands now opportunistically install the Codex wrapper if Hippo was installed before Codex or the postinstall step could not run at package install time.
- **Codex transcript capture support.** `hippo capture --last-session` now understands Codex rollout transcript JSONL and extracts user and assistant message text from Codex `response_item` payloads.

### Changed
- **Codex integration is no longer AGENTS-only.** `hippo setup` and `hippo hook install codex` now wrap the detected launcher in place instead of asking users to put `~/.hippo/bin` first on `PATH`.
- **Codex docs updated to match the real install path.** README and integration docs now describe automatic wrapping on install/update and the in-place launcher behavior.

### Internal
- Added `src/postinstall.ts` plus `scripts/postinstall.cjs` so published packages apply the Codex integration automatically without making `npm install` fail when Codex is absent.
- Added `tests/codex-wrapper.test.ts` for in-place launcher wrapping, uninstall restore, PATH discovery, and Codex transcript resolution. Full suite passes: 487 tests.

## 0.23.0 (2026-04-13)

### Fixed
- **SessionEnd hook no longer gets SIGTERM'd by TUI teardown.** 0.22.1 installed `hippo sleep --log-file` and `hippo capture --last-session --log-file` as two parallel SessionEnd entries. Claude Code and OpenCode fire SessionEnd hooks while tearing down the TUI, and the process group is killed before the children finish — so the log usually only contained the `consolidating memory...` / `capturing session...` start lines, never the `sleep complete` / `capture complete` markers. 0.23.0 collapses both into a single `hippo session-end --log-file <path>` entry whose parent returns in <100ms after spawning a fully detached Node child (via `child_process.spawn({detached: true, stdio: 'ignore', windowsHide: true}).unref()`). The detached child runs sleep → capture in sequence and writes both outputs to the log. Cross-platform (Windows/macOS/Linux) — no shell wrappers, no `nohup`, no `start /B` quoting hell.

### Added
- **`hippo session-end` subcommand.** Reads stdin synchronously to extract `transcript_path` from the SessionEnd payload, spawns the detached worker, and exits. Short SessionEnd timeout (5s) because the parent returns immediately.
- **Internal `__session-end-worker` subcommand.** Runs sleep → capture sequentially inside the detached child. Failures in one stage do not block the other; both tee their output to the shared log file.

### Changed
- **Auto-migration from 0.22.x split entries.** Re-running `hippo init`, `hippo hook install <target>`, or `hippo setup` detects the old split `hippo sleep --log-file` + `hippo capture --last-session --log-file` SessionEnd entries and collapses them into a single `hippo session-end --log-file` entry. Idempotent — existing 0.22.x installs just need one invocation.
- **Claude Code plugin `hooks.json`** switched its SessionEnd to `hippo session-end`, matching the JSON-hook install path. Also added `hippo last-sleep` to SessionStart so plugin users see the previous session's consolidation output between banners.

### Internal
- `InstallResult` replaced `installedSessionCapture` with `migratedSplitSessionEnd` (the migration flag for the 0.22.x two-entry form).
- `tests/hooks.test.ts` rewritten against the single-entry schema; all 19 cases plus the 481 full-suite tests pass.

## 0.22.1 (2026-04-13)

### Fixed
- **Session-end capture output is no longer invisible.** In 0.22.0 the SessionEnd `hippo capture --last-session` hook printed its "Captured N items" output during TUI teardown, so users never saw it. 0.22.1 adds a `--log-file` flag to `hippo capture` that tees stdout/stderr to the same log file as `hippo sleep`, and `hippo init` / `hippo setup` / `hippo hook install <target>` now install the capture entry as `hippo capture --last-session --log-file "<path>"`. `hippo last-sleep` on the next session start prints both sleep *and* capture output between the banners so you can confirm they ran.
- **Auto-migration from 0.22.0.** Re-running any install path detects legacy `hippo capture --last-session` entries (no `--log-file`) and replaces them with the new form. No manual reinstall needed.

### Added
- **`--transcript <path>` short-circuits stdin read.** When an explicit transcript path is passed, `hippo capture --last-session` no longer attempts to read stdin — avoids blocking in scripted / test contexts.

## 0.22.0 (2026-04-13)

### Added
- **`hippo capture --last-session` is now fully implemented.** Previously a placeholder, it now reads the JSONL transcript of the last agent session and extracts actionable memories (decisions, rules, errors, preferences). Resolution priority: explicit `--transcript <path>` flag, then stdin JSON payload (`{transcript_path, session_id, cwd}` — the shape Claude Code / OpenCode SessionEnd hooks pass), then auto-discovery of the newest `.jsonl` under `~/.claude/projects/`. Skips `thinking` blocks, `tool_use`, and `tool_result` noise; keeps the tail (last 20 user messages, last 10 assistant replies) since session-end is about what was decided near the end.
- **SessionEnd `hippo capture` hook auto-installed.** `hippo init`, `hippo hook install <target>`, and `hippo setup` now install a second SessionEnd entry: `hippo capture --last-session` (timeout 15s). Runs once per session, not per turn — addresses the common request to "capture a session summary at /exit without burning tokens on every reply." Existing installs pick up the new entry automatically on re-run (idempotent).
- **Claude Code plugin (`extensions/claude-code-plugin`) moved `hippo sleep` + `hippo outcome --good` from `Stop` to `SessionEnd`** and added the `hippo capture --last-session` entry alongside. Plugin behavior now matches the JSON-hook install path.

### Internal
- New `summariseTranscript()` and `resolveLastSessionTranscript()` exports in `src/capture.ts`, covered by `tests/capture-last-session.test.ts` (10 cases: tail-truncation, block filtering, malformed JSONL, stdin payload resolution, auto-discovery, graceful fallbacks).
- `InstallResult` gained `installedSessionCapture: boolean`. Uninstall markers now include `hippo capture --last-session` so `hippo hook uninstall <target>` cleans up every entry.

## 0.21.1 (2026-04-12)

### Fixed
- **`hippo init` now installs OpenCode JSON hooks too, not just Claude Code.** The auto-install path was only wiring up `SessionEnd`/`SessionStart` entries for Claude Code, even though `hippo hook install opencode` and `hippo setup` already did so. Now all three entry points behave consistently: any detected JSON-hook tool gets its settings file patched.
- **`hippo setup --dry-run` shows the real filename per tool.** The dry-run message previously hard-coded `settings.json`, so OpenCode was reported as writing to `opencode/settings.json` instead of `opencode.json`.

## 0.21.0 (2026-04-12)

### Added
- **`hippo setup` command.** One-shot configuration across every AI coding tool on the box. Detects installed tools by checking for their config directories (`~/.claude`, `~/.config/opencode`, `~/.openclaw`, `~/.codex`, `~/.cursor`, `~/.pi`) and installs all available SessionEnd + SessionStart hooks in one pass. Idempotent. Supports `--all` (install even if not detected) and `--dry-run`.
- **OpenCode JSON hooks.** OpenCode added Claude-Code-compatible `SessionStart`/`SessionEnd` hooks in Jan 2026, so `hippo setup` and `hippo hook install opencode` now install them into `~/.config/opencode/opencode.json`. Same per-tool log isolation as Claude Code.
- **`hippo last-sleep` command.** Prints the contents of the last `hippo sleep --log-file` output and clears it. Used by the new `SessionStart` hook so users actually see what was consolidated last time (previously, `SessionEnd` stdout was swallowed by the TUI tearing down).
- **`hippo sleep --log-file <path>`.** Tees stdout/stderr to a log file while still printing to the terminal. Cross-platform (no shell redirection needed).

### Changed
- **Claude Code hook now uses `hippo sleep --log-file` + `hippo last-sleep` pair.** Replaces the old `echo ... && hippo sleep` command that produced invisible output. On next session start, the previous consolidation is printed between banners and the log is cleared. Re-running `hippo hook install claude-code` or `hippo setup` migrates existing installs automatically.
- **Per-tool log paths.** Each tool writes to its own log file in `~/.hippo/logs/` (`claude-code-sleep.log`, `opencode-sleep.log`). Prevents Claude Code's SessionEnd from clobbering OpenCode's, and vice versa.

### Internal
- Hook install/uninstall moved from `src/cli.ts` to a dedicated `src/hooks.ts` so tests and third-party callers can use it without running the CLI main().
- New `tests/hooks.test.ts` covers fresh install, idempotency, legacy Stop migration, legacy SessionEnd migration, per-tool log isolation, and uninstall.

## 0.20.3 (2026-04-12)

### Changed
- **Visible confirmation on `SessionEnd` hook.** The `hippo sleep` hook installed by `hippo hook install claude-code` (and the Claude Code plugin) now echoes `[hippo] consolidating memory...` before the run and `[hippo] sleep complete` / `[hippo] sleep failed` after, so users can see that consolidation actually ran on session exit. Previous versions swallowed all output with `2>/dev/null || true`. Existing installs need a reinstall (`hippo hook uninstall claude-code && hippo hook install claude-code`) to pick up the new command — the installer's idempotency check treats any entry containing `hippo sleep` as already installed.

## 0.20.2 (2026-04-12)

### Fixed
- **Claude Code hook now uses `SessionEnd` instead of `Stop`.** Earlier versions installed a `Stop` hook, which fires at the end of every assistant turn — so `hippo sleep` (consolidation + dedup + auto-share) ran on every reply. That was expensive, noisy, and could make the UI feel stuck behind the hook timeout. `SessionEnd` fires once when the session actually terminates, which is the intended behaviour.
- **Automatic migration.** Re-running `hippo hook install claude-code` (or `hippo init` in a project with Claude Code) detects any legacy `Stop` entry that runs `hippo sleep`, removes it, and installs the new `SessionEnd` entry. `hippo hook uninstall claude-code` now cleans up both old and new entries.
- **Never create a new agent-instructions file.** `hippo hook install <target>` and `hippo init` used to create a fresh `CLAUDE.md` / `AGENTS.md` / etc. when none existed in the current directory — polluting the working tree of unrelated projects. Hippo now only patches agent-instruction files that already exist. For `claude-code`, the `SessionEnd` hook in `~/.claude/settings.json` is still installed unconditionally (that's the user-level config, not the project).

## 0.20.1 (2026-04-12)

### Changed
- **Session-end capture in all hook templates.** All agent hooks (claude-code, codex, openclaw, opencode, pi) now instruct the agent to summarize the session (decisions, errors, lessons) into `hippo capture` before exiting. Zero friction — the agent does it automatically as its last action.

## 0.20.0 (2026-04-12)

### Added
- **`hippo dedup` command.** Scans the store for near-duplicate memories (default: 70% Jaccard overlap), keeps the stronger copy, removes the weaker. Shows clear reasoning: count by type (redundant semantic patterns, duplicate episodic lessons, cross-layer duplicates), similarity percentage, and content preview for each pair. Supports `--dry-run` and `--threshold <n>`.
- **Auto-dedup on sleep.** `hippo sleep` now runs dedup after consolidation with a categorized summary of what was removed and why.
- **MEMORY.md import on init and sleep.** `hippo init` and `hippo sleep` scan Claude Code memory files (`~/.claude/projects/<project>/memory/*.md`) and import new entries with deduplication against existing memories.

### Fixed
- **Windows CRLF in MEMORY.md frontmatter.** Frontmatter regex now handles `\r\n` line endings.

## 0.19.1 (2026-04-09)

### Fixed
- **Configured embedding model propagation.** `hippo embed`, hybrid search, and physics search now all respect `embeddings.model` from `config.json` instead of silently falling back to the default model.
- **Stale embedding index on model change.** Switching `embeddings.model` now forces a full embedding rebuild and physics-state reset so query vectors and cached vectors stay compatible.
- **Model-specific pipeline caching.** Embedding pipeline instances are now cached per model instead of being reused across different configured models.
- **Version metadata drift.** Synced package, plugin, MCP server, and dashboard version strings for the 0.19.1 release.

## 0.19.0 (2026-04-08)

### Added
- **Pi coding agent extension.** Native extension at `extensions/pi-extension/` with automatic context injection, error capture (noise filtered + rate limited + deduped), session-end consolidation, and 5 registered tools (hippo_recall, hippo_remember, hippo_outcome, hippo_status, hippo_context).
- `hippo hook install pi` patches AGENTS.md with hippo instructions.
- Pi auto-detected during `hippo init` when `.pi/` directory exists.

## 0.18.0 (2026-04-08)

### Added
- **Multi-project auto-discovery.** `hippo init --scan [dir]` finds all git repos under a directory (default: home, max 2 levels deep) and initializes each one with a `.hippo/` store. Seeds with a full year of git history by default. Also initializes the global store. Use `--days <n>` to control history depth, `--no-learn` to skip git seeding.

## 0.17.0 (2026-04-08)

### Added
- **Auto-share to global on sleep.** `hippo sleep` now promotes high-transfer-score memories (>= 0.6) to the global store after consolidation. Universal lessons (error patterns, tool gotchas) are shared; project-specific memories (file paths, deploy configs) are filtered out. Content dedup prevents duplicates. Configurable via `autoShareOnSleep` in config (default: true). Skip with `--no-share`.

## 0.16.2 (2026-04-08)

### Fixed
- **OpenClaw plugin registers once.** Added module-level guard to prevent repeated tool registration on WebSocket reconnection. Previously, every reconnect attempt re-registered all 10 tools.

## 0.16.1 (2026-04-08)

### Changed
- **`deduplicateLesson` performance.** Accepts pre-loaded `MemoryEntry[]` instead of reloading from disk on every iteration. Eliminates N redundant `loadAllEntries` calls during `hippo learn --git`.

## 0.16.0 (2026-04-08)

### Added
- **Auto-learn from git on init.** `hippo init` now seeds the store with 30 days of git history on first setup. New users get instant memory from their commit history. Skip with `--no-learn`.
- **Auto-learn from git on sleep.** `hippo sleep` now runs `learn --git --days 1` before consolidation, capturing recent commit lessons automatically. Configurable via `autoLearnOnSleep` in config (default: true). Skip with `--no-learn`.

## 0.15.0 (2026-04-08)

### Added
- **Adaptive decay for intermittent agents.** Memories now decay based on how often the agent runs, not just wall-clock time. An agent that runs weekly gets 7x longer half-lives automatically. Three modes available via `decayBasis` in config:
  - `"adaptive"` (default) — auto-scales half-life by average session interval. Daily agents behave identically to before. Weekly agents keep memories ~7x longer.
  - `"session"` — decay by sleep cycle count instead of days. Each `hippo sleep` = 1 "day" in the decay formula. Best for agents with unpredictable schedules.
  - `"clock"` — classic wall-clock decay (previous default behavior).
- `SessionDecayContext` and `loadSessionDecayContext()` exported for programmatic use.
- Sleep counter tracked in meta table, incremented on each consolidation run.

## 0.14.0 (2026-04-08)

### Added
- **Automatic backup cleanup on OpenClaw boot.** The plugin now removes stale `hippo-memory.bak-*` directories from `~/.openclaw/extensions/` at registration time. These leftovers from plugin updates cause duplicate plugin ID errors on next boot.

## 0.13.3 (2026-04-08)

### Fixed
- **`rebuildIndex` ROLLBACK safety.** Wrapped in try-catch to prevent masking the original error if BEGIN fails.
- **MCP bare `require` replaced.** `child_process` now imported at top level instead of dynamic `require()` inside ESM module.
- **MCP notification protocol compliance.** All unknown `notifications/*` methods return null (no response), preventing malformed JSON-RPC responses with `id: undefined`.
- **Dead code in `calculateStrength`.** Removed unreachable `entry.pinned` check (pinned entries return early before reaching the guard).
- **Embedding atomic write cleanup.** `.tmp` file is deleted if `renameSync` fails.
- **`HIPPO_HOME` whitespace rejection.** Environment variables are trimmed before use, preventing whitespace-only values from being treated as valid paths.
- **Autolearn env var regex.** Now handles lowercase env vars (`node_env=prod cmd`). `fetchGitLog` uses `execFileSync` to avoid shell interpolation.

## 0.13.2 (2026-04-08)

### Fixed
- **Windows schtasks `%` expansion.** Schedule setup now rejects paths containing `%` on Windows, preventing environment variable injection in Task Scheduler commands. Also fixed quote escaping from `\"` to `""` (correct for `schtasks /tr`).
- **MCP `conflict_id: 0` rejected.** The `!conflictId` check treated ID `0` as invalid due to JavaScript's `!0 === true`. Now uses `isNaN()`.
- **MCP swallowed async errors.** Failed tool executions now send a JSON-RPC error response instead of silently dropping, preventing clients from hanging.
- **Cross-store budget loop inconsistency.** `searchBoth` and `searchBothHybrid` now always include the first result regardless of budget, matching the fix applied to `search.ts` in v0.13.0.
- **Autolearn env var regex false positives.** Regex anchored to only strip leading `KEY=val` assignments, no longer matching `--ARG=val` mid-command.
- **`bufferToFloat32` crash on corrupt data.** Returns empty array for buffers not divisible by 4 bytes instead of throwing.
- **`embedAll` race condition.** Now uses the same `withEmbedLock` mutex as `embedMemory`, preventing concurrent read-modify-write on `embeddings.json`.

## 0.13.1 (2026-04-08)

### Reverted
- **Physics simulation behavior changes.** Reverted co-location perturbation, position collapse reset, and repulsion direction changes from v0.13.0. These need local validation before shipping. The `velocityAlignmentBonus` NaN guard and `Float32Array` alignment fix are kept (pure safety, no behavior change).

## 0.13.0 (2026-04-08)

### Fixed
- **SECURITY: Command injection in OpenClaw plugin.** `runHippo` now uses `execFileSync` with an args array instead of shell string interpolation. All 15 call sites converted. Tag, ID, and session key parameters are no longer injectable.
- **MCP server Content-Length byte/char mismatch.** Incoming message parser now works with raw Buffers instead of decoded strings, correctly handling multi-byte Unicode characters.
- **NaN propagation in `calculateStrength`.** Added guards for zero `half_life_days` and NaN-safe clamping. Memory IDs now use `crypto.randomUUID` for stronger entropy.
- **Token budget drops top result.** Search now always includes the first (highest-ranked) result regardless of budget, then applies budget logic for subsequent results.
- **Non-atomic embedding writes.** `saveEmbeddingIndex` now writes to a temp file then renames. Added mutex to serialize concurrent `embedMemory` calls.
- **FTS5/LIKE query injection.** Search terms are now properly quoted for FTS5 and escaped for LIKE metacharacters.
- **Physics simulation edge cases.** Zero-magnitude query embeddings guarded against NaN. Co-located particles get random perturbation. Position collapse resets to random unit vector. Float32Array alignment ensured.
- **MCP server swallows all exceptions.** `uncaughtException` and `unhandledRejection` now log to stderr instead of silently swallowing.
- **Recursive DB open in `appendSessionEvent`.** Session event count query now reuses the existing connection.
- **Legacy import not transactional.** `rebuildIndex` legacy import loop now wrapped in BEGIN/COMMIT.
- **Shell injection in schedule setup.** `projectDir` validated for unsafe characters before interpolation into crontab/schtasks.
- **Cross-store dedup ineffective.** Search dedup now uses content hash instead of ID (local/global IDs differ after promote/share).
- **Autolearn stores secrets.** Environment variable assignments are stripped from command text before storing error memories.
- **Silent config parse failure.** Broken `config.json` now warns to stderr instead of silently falling back to defaults.
- **Import truncation silent.** Memories truncated during import now produce a warning.
- **Cached pipeline failure permanent.** Failed embedding pipeline load no longer permanently prevents retries.
- **MCP `notifications/initialized` response.** Notifications no longer receive a JSON-RPC response (protocol compliance).

## 0.12.0 (2026-04-08)

### Added
- **Configurable global store location.** The global Hippo store now respects `$HIPPO_HOME`, then `$XDG_DATA_HOME/hippo`, falling back to `~/.hippo/`. Set `HIPPO_HOME=/path/to/hippo` to keep your home directory clean. Works across CLI, MCP server, and OpenClaw plugin. Closes #5.

## 0.11.2 (2026-04-08)

### Fixed
- **Cross-platform path handling in OpenClaw plugin.** `resolveHippoCwd()` now uses `path/posix` after normalizing backslashes, so Windows-style paths like `C:\repo\.hippo` are correctly parsed on Unix systems. Previously, `path.basename` on Unix treated backslashes as valid filename characters, causing `.hippo` detection to fail. Closes #6.

## 0.11.1 (2026-04-07)

### Fixed
- **OpenClaw plugin: error capture filtering.** The `autoLearn` hook now filters tool errors before storing them as memories. Three filters prevent memory pollution: a noise pattern filter (skips known transient errors like browser timeouts, `ECONNREFUSED`, image path restrictions, `Navigation timeout`), a per-session rate limit (max 5 error memories), and per-session deduplication (same error from same tool captured only once). Previously, every tool failure was stored, causing up to 78% of all memories to be garbage error noise that consolidation then amplified into hundreds of synthetic semantic memories.
- **Orphaned embedding pruning.** `hippo embed` now removes cached vectors for memories that no longer exist. Previously, embedding vectors accumulated indefinitely after memory deletion. `hippo status` and `hippo embed --status` now show orphan counts with a prune hint.

## 0.10.0 (2026-04-07)

### Added
- **Active invalidation**: `hippo learn --git` detects migration/breaking commits and actively weakens memories referencing the old pattern. Manual invalidation via `hippo invalidate "<pattern>"`.
- **Architectural decisions**: `hippo decide` stores one-off decisions with 90-day half-life and verified confidence. Supports `--context` for reasoning and `--supersedes` to chain decisions.
- 1.2x recall boost for decision-tagged memories so they surface despite low retrieval frequency.
- **Path-based memory triggers**: Memories auto-tagged with `path:<segment>` from cwd on creation. Recall boosts memories matching the current directory (up to 1.3x). Works for remember, decide, and learn --git.
- **OpenCode integration**: `hippo hook install opencode` patches AGENTS.md. Auto-detection via `.opencode/` or `opencode.json`. Integration guide with MCP server config and `.opencode/skills/memory/` skill.
- `hippo export [file]` exports all memories as JSON or markdown.
- HippoRAG paper reference added to RESEARCH.md and README.md.

## 0.9.1 (2026-04-06)

### Added
- `hippo hook install claude-code` now also installs a Stop hook in `~/.claude/settings.json` that runs `hippo sleep` automatically when Claude Code exits. No more forgetting to consolidate.
- `hippo init` auto-installs the Stop hook when Claude Code is detected.
- `hippo hook uninstall claude-code` cleanly removes the Stop hook from settings.json.

## 0.8.0 (2026-03-27)

### Added
- Multi-agent shared memory: `hippo share <id>` shares memories with attribution and transfer scoring. Memories tagged with universal patterns (error, platform, gotcha) score higher for sharing; project-specific ones (config, deploy, file-path) are filtered out.
- `hippo share --auto` auto-shares all high-scoring memories. `--dry-run` previews candidates.
- `hippo peers` lists all projects contributing to the global store with memory counts.
- `transferScore()` exported for programmatic transfer quality estimation.
- Conflict resolution CLI: `hippo resolve <id> --keep <mem_id> [--forget]`.
- `hippo dashboard` — local web UI at localhost:3333 with memory health overview, strength distribution chart, conflict management, peer status, and searchable/filterable memory table.
- MCP server: added `hippo_conflicts`, `hippo_resolve`, `hippo_share`, `hippo_peers` tools (10 total).
- OpenClaw plugin: added same 4 tools (9 total).

### Changed
- `hippo resolve` without `--keep` now shows both conflicting memories for comparison.
- Version bumped to 0.8.0 across all manifests.

## 0.7.0 (2026-03-27)

### Added
- Hybrid search: `hippo recall` and `hippo context` now blend BM25 keyword scores with cosine embedding similarity when `@xenova/transformers` is installed. Falls back to pure BM25 otherwise.
- `SearchResult.cosine` field on all search results (0 when embeddings not used).
- `searchBothHybrid()` async function for cross-store (local + global) hybrid search.
- Schema acceleration: `schema_fit` is now auto-computed from tag + content overlap against existing memories. High-fit memories (>0.7) get 1.5x half-life; novel memories (<0.3) get 0.5x.
- `computeSchemaFit()` exported for programmatic use.
- Agent evaluation benchmark: 50-task sequential learning eval comparing no memory, static memory, and hippo. Validates the learning-over-time hypothesis (78% early trap rate -> 14% late).
  > **RETRACTED v1.7.9** — the 78% → 14% magnitude does not reproduce on the formal sequential-learning harness across three pre-registered workload variants (v1.7.5/6/7). See `## 1.7.9` at top of file. Mechanism remains shipped.
- `tests/hybrid-search.test.ts`, `tests/agent-eval.test.ts`, `tests/schema-fit.test.ts`.

### Changed
- `hippo recall`, `hippo context`, and MCP tools (`hippo_recall`, `hippo_context`) upgraded from synchronous BM25-only search to async hybrid search.
- MCP server request handling is now async to support embedding pipeline.
- `hippo remember`, `hippo learn --git`, and `hippo watch` now auto-compute schema_fit instead of defaulting to 0.5.

## 0.6.3 (2026-03-21)

### Fixed
- `hippo learn --git` now distinguishes between "not a git repo" and "real repo with no commits in the lookback window", so multi-repo learn reports the correct status instead of false `No git history found` messages.
- Synced release metadata across package, OpenClaw plugin manifests, and MCP server version reporting.

## 0.6.2 (2026-03-19)

### Added
- `hippo-memory` now exposes root-level OpenClaw package metadata and a root plugin manifest, so `openclaw plugins install hippo-memory` works directly from npm.
- Added an OpenClaw npm-install smoke test script to verify the packed tarball can be installed into an isolated OpenClaw state directory.

### Fixed
- Normalized the published CLI `bin` entry to avoid npm auto-correct warnings during publish.

## 0.6.1 (2026-03-19)

### Added
- OpenClaw plugin package is now included in the npm tarball so npm installs carry the integration files as well as the CLI.

### Changed
- OpenClaw plugin now resolves Hippo from the active workspace instead of arbitrary process cwd, preserving the intended local `.hippo/` plus global `~/.hippo/` lookup model.
- OpenClaw plugin `autoLearn` and `autoSleep` config now map to real hook behavior, including failed-tool capture and session-end consolidation.
- Release metadata is aligned across package, MCP server, lockfile, and OpenClaw plugin manifests.

## 0.5.1 (2026-03-15)

### Added
- `hippo init` now auto-creates a daily cron job (6:15am) for `hippo learn --git --days 1 && hippo sleep`. Cross-platform: crontab on Linux/macOS, Task Scheduler on Windows. Use `--no-schedule` to skip.

## 0.5.0 (2026-03-15)

### Added
- Configurable `defaultHalfLifeDays` in `.hippo/config.json` (default: 7). Adjust for teams that code in bursts.
- Configurable `defaultBudget` (4000) and `defaultContextBudget` (3000) for recall and context commands.
- Auto-sleep: triggers `hippo sleep` after 50 new memories in 24 hours. Configure via `autoSleep.enabled` and `autoSleep.threshold`.
- Configurable `gitLearnPatterns` array for `hippo learn --git`. Default now includes: fix, revert, bug, error, hotfix, bugfix, refactor, perf, chore, breaking, deprecate.

### Changed
- Embeddings default to `"auto"`: uses `@xenova/transformers` if installed, falls back to BM25 silently.
- MCP server refactored to use programmatic API directly (no child process spawning). 10x faster tool calls.
- Git learn patterns broadened: now catches refactor, perf, chore, breaking, and deprecate commits in addition to fix/revert/bug.
- Default context budget raised from 1500 to 3000 for main sessions.

## 0.4.1 (2026-03-15)

### Added
- `hippo mcp` command: MCP server over stdio transport. Works with Cursor, Windsurf, Cline, Claude Desktop, and any MCP-compatible client.
- MCP server exposes 6 tools: hippo_recall, hippo_remember, hippo_outcome, hippo_context, hippo_status, hippo_learn.

## 0.4.0 (2026-03-15)

### Added
- `hippo init` auto-detects agent frameworks (Claude Code, Codex, Cursor, OpenClaw) and installs hooks automatically. Use `--no-hooks` to skip.
- `hippo learn --git --repos <paths>` scans multiple repos in one pass (comma-separated paths).
- Codex integration guide (`integrations/codex.md`).
- CHANGELOG.md with full version history.

### Changed
- README rewritten with auto-hook install docs, multi-repo learn section, and updated comparison table.
- PLAN.md updated with shipped feature status.
- All integration guides updated for auto-install workflow.

## 0.3.1 (2026-03-15)

### Added
- `hippo init` auto-detects agent frameworks (Claude Code, Codex, Cursor, OpenClaw) and installs hooks automatically. Use `--no-hooks` to skip.
- `hippo learn --git --repos <paths>` scans multiple repos in one pass (comma-separated paths).
- Codex integration guide (`integrations/codex.md`).

### Changed
- README rewritten with auto-hook install docs, multi-repo learn section, and updated comparison table.
- OpenClaw integration guide updated with auto-install instructions and multi-repo cron example.

## 0.3.0 (2026-03-13)

### Added
- Cross-tool import: `hippo import --chatgpt`, `--claude`, `--cursor`, `--markdown`, `--file`.
- Conversation capture: `hippo capture --stdin` / `--file` (pattern-based, no LLM).
- Confidence tiers: `--verified`, `--observed`, `--inferred`. Auto-stale after 30 days.
- Observation framing: `hippo context --framing observe|suggest|assert`.
- All import commands support `--dry-run`, `--global`, `--tag`.
- Duplicate detection on import.

## 0.2.0 (2026-03-10)

### Added
- `hippo learn --git` scans recent commits for fix/revert/bug lessons.
- `hippo watch "<command>"` auto-learns from command failures.
- `hippo context --auto` smart context injection (auto-detects task from git).
- `hippo embed` optional embedding support via `@xenova/transformers`.
- `hippo promote` and `hippo sync` for local/global memory management.
- Framework hooks: `hippo hook install claude-code|codex|cursor|openclaw`.

## 0.1.0 (2026-03-01)

### Added
- Core memory system: buffer, episodic, semantic stores.
- `hippo init`, `hippo remember`, `hippo recall`, `hippo sleep`.
- Decay by default (7-day half-life).
- Retrieval strengthening (+2 days per recall).
- Error tagging (2x half-life).
- Outcome feedback (`hippo outcome --good/--bad`).
- Token budgets on recall.
- BM25 search (zero dependencies).
- Markdown + YAML frontmatter storage.
- Global store support (`~/.hippo/`).
