# v0.10.12 — Operational Friction Reduction Implementation Plan

> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.

**Goal:** Four features reducing daily OSB operational friction: `o2b update`, weekly brain digest, Cursor SQLite deep parsing, Obsidian deep-link in explorer.

**Architecture:** All features extend existing modules — InstallAdapter registry, brain_digest windowed mode, discipline-report transcript resolvers, brain-explorer HTML template. No new architectural layers.

**Tech Stack:** Bun, TypeScript, bun:sqlite, proper-lockfile

---

### Task 1: Parse `--window` flag for `brain_digest`

**Objective:** Add `parseWindow` utility that accepts `Nd` or bare `N` format.

**Files:**
- Modify: `src/cli/brain/verbs/digest.ts`
- Test: `tests/cli/brain-digest-window-cli.test.ts`

**Step 1: Write failing test**

Create `tests/cli/brain-digest-window-cli.test.ts`:

```typescript
import { describe, expect, test } from "bun:test";
import { parseWindow } from "../../../src/cli/brain/verbs/digest.ts";

describe("parseWindow", () => {
  test("parses '7d' as 7 days", () => {
    expect(parseWindow("7d")).toBe(7);
  });

  test("parses bare '7' as 7 days", () => {
    expect(parseWindow("7")).toBe(7);
  });

  test("parses '1d' as 1 day", () => {
    expect(parseWindow("1d")).toBe(1);
  });

  test("parses '30d' as 30 days", () => {
    expect(parseWindow("30d")).toBe(30);
  });

  test("rejects '0d'", () => {
    expect(() => parseWindow("0d")).toThrow();
  });

  test("rejects '-1d'", () => {
    expect(() => parseWindow("-1d")).toThrow();
  });

  test("rejects 'abc'", () => {
    expect(() => parseWindow("abc")).toThrow();
  });

  test("rejects '1.5d'", () => {
    expect(() => parseWindow("1.5d")).toThrow();
  });
});
```

**Step 2: Run test to verify failure**

Run: `bun test tests/cli/brain-digest-window-cli.test.ts`
Expected: FAIL — `parseWindow` not exported

**Step 3: Add `parseWindow` function**

In `src/cli/brain/verbs/digest.ts`, add before `cmdBrainDigest`:

```typescript
export function parseWindow(raw: string): number {
  const m = /^(\d+)(?:d)?$/.exec(raw);
  if (!m) throw new Error(`invalid --window value: ${raw} (expected Nd or N, e.g. 7d)`);
  const n = parseInt(m[1]!, 10);
  if (n <= 0) throw new Error(`invalid --window value: ${raw} (must be positive)`);
  return n;
}
```

**Step 4: Run test to verify pass**

Run: `bun test tests/cli/brain-digest-window-cli.test.ts`
Expected: PASS — all 8 tests

**Step 5: Commit**

```bash
git add src/cli/brain/verbs/digest.ts tests/cli/brain-digest-window-cli.test.ts
git commit -m "feat: add parseWindow utility for --window flag"
```

---

### Task 2: Wire `--window` flag into `cmdBrainDigest`

**Objective:** Pass parsed window days to `renderDigest` as the `since` calculation basis.

**Files:**
- Modify: `src/cli/brain/verbs/digest.ts`

**Step 1: Add `--window` to parse flags**

Add `"window": { type: "string" }` to the `parse()` call.

**Step 2: Compute `since` from `--window` when provided**

After the existing `sinceDate`/`untilDate` logic, add:

```typescript
if (flags["window"]) {
  const windowDays = parseWindow(String(flags["window"]));
  const until = untilDate ?? new Date();
  sinceDate = new Date(until.getTime() - windowDays * 24 * 60 * 60 * 1000);
}
```

**Step 3: Run existing tests**

Run: `bun test tests/core/brain/digest-most-applied.test.ts tests/core/brain.digest.test.ts`
Expected: PASS

**Step 4: Commit**

```bash
git add src/cli/brain/verbs/digest.ts
git commit -m "feat: wire --window flag into brain digest CLI"
```

---

### Task 3: Add agent quality summary core computation

**Objective:** Compute per-agent event counts + confirmed/retired attribution for the digest window.

**Files:**
- Create: `src/core/brain/digest-agent-summary.ts`
- Test: `tests/core/brain/digest-agent-summary.test.ts`

**Step 1: Write test file**

Create `tests/core/brain/digest-agent-summary.test.ts`:

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

import { computeAgentSummary } from "../../../src/core/brain/digest-agent-summary.ts";
import { appendLogEvent } from "../../../src/core/brain/log.ts";
import {
  BRAIN_LOG_EVENT_KIND,
  BRAIN_APPLY_RESULT,
} from "../../../src/core/brain/types.ts";

let vault: string;

beforeEach(() => {
  vault = mkdtempSync(join(tmpdir(), "osb-agent-summary-"));
  mkdirSync(join(vault, "Brain", "preferences"), { recursive: true });
  mkdirSync(join(vault, "Brain", "retired"), { recursive: true });
  mkdirSync(join(vault, "Brain", "log"), { recursive: true });
});

afterEach(() => {
  try { rmSync(vault, { recursive: true, force: true }); } catch {}
});

describe("computeAgentSummary", () => {
  test("returns empty array when no log entries", () => {
    const since = new Date("2026-05-13T00:00:00Z");
    const until = new Date("2026-05-20T00:00:00Z");
    const summary = computeAgentSummary(vault, since, until);
    expect(summary).toEqual([]);
  });

  test("counts events per agent", () => {
    appendLogEvent(vault, {
      timestamp: "2026-05-15T10:00:00Z",
      eventType: BRAIN_LOG_EVENT_KIND.feedback,
      body: { topic: "testing", signal: "positive", principle: "test" },
      agent: "claude-vps-agent",
    });
    appendLogEvent(vault, {
      timestamp: "2026-05-16T10:00:00Z",
      eventType: BRAIN_LOG_EVENT_KIND.applyEvidence,
      body: { preference: "[[pref-a]]", result: BRAIN_APPLY_RESULT.applied, artifact: "[[x.md]]" },
      agent: "claude-vps-agent",
    });
    appendLogEvent(vault, {
      timestamp: "2026-05-17T10:00:00Z",
      eventType: BRAIN_LOG_EVENT_KIND.feedback,
      body: { topic: "naming", signal: "negative", principle: "no abbreviations" },
      agent: "codex-agent",
    });

    const since = new Date("2026-05-13T00:00:00Z");
    const until = new Date("2026-05-20T00:00:00Z");
    const summary = computeAgentSummary(vault, since, until);

    expect(summary.length).toBe(2);
    const claude = summary.find((a) => a.agent === "claude-vps-agent")!;
    expect(claude.total_events).toBe(2);
    expect(claude.feedback_count).toBe(1);
    expect(claude.apply_evidence_count).toBe(1);
    expect(claude.note_count).toBe(0);

    const codex = summary.find((a) => a.agent === "codex-agent")!;
    expect(codex.total_events).toBe(1);
  });

  test("excludes events outside the window", () => {
    appendLogEvent(vault, {
      timestamp: "2026-05-01T10:00:00Z",
      eventType: BRAIN_LOG_EVENT_KIND.feedback,
      body: { topic: "old", signal: "positive", principle: "old" },
      agent: "claude-vps-agent",
    });

    const since = new Date("2026-05-13T00:00:00Z");
    const until = new Date("2026-05-20T00:00:00Z");
    const summary = computeAgentSummary(vault, since, until);
    expect(summary).toEqual([]);
  });
});
```

**Step 2: Run test to verify failure**

Run: `bun test tests/core/brain/digest-agent-summary.test.ts`
Expected: FAIL — module not found

**Step 3: Implement `computeAgentSummary`**

Create `src/core/brain/digest-agent-summary.ts`:

```typescript
/**
 * Agent quality summary for the brain digest.
 *
 * Groups Brain/log events by agent within the given window and returns
 * per-agent counts: total events, breakdown by type (feedback /
 * apply-evidence / note), and attribution to confirmed/retired rules.
 */

import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

import { brainDirs } from "./paths.ts";
import { parseLogDay, type BrainLogEntry } from "./log.ts";
import { BRAIN_LOG_EVENT_KIND } from "./types.ts";

export interface AgentSummaryEntry {
  readonly agent: string;
  readonly total_events: number;
  readonly feedback_count: number;
  readonly apply_evidence_count: number;
  readonly note_count: number;
  readonly confirmed_attributed: number;
  readonly retired_attributed: number;
}

export function computeAgentSummary(
  vault: string,
  since: Date,
  until: Date,
): ReadonlyArray<AgentSummaryEntry> {
  const logs = readLogsInWindow(vault, since, until);
  const byAgent = new Map<string, AgentAccumulator>();

  for (const entry of logs) {
    const agent = entry.agent ?? "unknown";
    let acc = byAgent.get(agent);
    if (!acc) {
      acc = {
        agent,
        total_events: 0,
        feedback_count: 0,
        apply_evidence_count: 0,
        note_count: 0,
        confirmed_attributed: 0,
        retired_attributed: 0,
      };
      byAgent.set(agent, acc);
    }
    acc.total_events++;
    switch (entry.eventType) {
      case BRAIN_LOG_EVENT_KIND.feedback:
        acc.feedback_count++;
        break;
      case BRAIN_LOG_EVENT_KIND.applyEvidence:
        acc.apply_evidence_count++;
        break;
      case BRAIN_LOG_EVENT_KIND.note:
        acc.note_count++;
        break;
    }
  }

  // Attribution: for each log entry that references a preference,
  // check if that preference was confirmed or retired in the window.
  const seenPrefs = new Set<string>();
  for (const entry of logs) {
    const agent = entry.agent ?? "unknown";
    const acc = byAgent.get(agent);
    if (!acc) continue;

    const pref = entry.body["preference"];
    if (typeof pref !== "string") continue;
    const prefId = extractPrefId(pref);
    if (!prefId) continue;

    const key = `${agent}:${prefId}`;
    if (seenPrefs.has(key)) continue;
    seenPrefs.add(key);

    const confirmedAt = getConfirmedAt(vault, prefId);
    const retiredAt = getRetiredAt(vault, prefId);

    if (confirmedAt && isInWindow(confirmedAt, since, until)) {
      acc.confirmed_attributed++;
    }
    if (retiredAt && isInWindow(retiredAt, since, until)) {
      acc.retired_attributed++;
    }
  }

  return [...byAgent.values()].sort((a, b) => b.total_events - a.total_events);
}

interface AgentAccumulator {
  agent: string;
  total_events: number;
  feedback_count: number;
  apply_evidence_count: number;
  note_count: number;
  confirmed_attributed: number;
  retired_attributed: number;
}

function readLogsInWindow(
  vault: string,
  since: Date,
  until: Date,
): BrainLogEntry[] {
  const dirs = brainDirs(vault);
  if (!existsSync(dirs.log)) return [];
  const sinceDay = since.toISOString().slice(0, 10);
  const untilDay = until.toISOString().slice(0, 10);
  const out: BrainLogEntry[] = [];
  const dates = readdirSync(dirs.log, { withFileTypes: true })
    .filter((d) => d.isFile() && d.name.endsWith(".md"))
    .map((d) => d.name.slice(0, -3))
    .filter((n) => /^\d{4}-\d{2}-\d{2}$/.test(n))
    .sort();
  for (const date of dates) {
    if (date < addDays(sinceDay, -1)) continue;
    if (date > addDays(untilDay, 1)) continue;
    const { entries } = parseLogDay(vault, date);
    for (const e of entries) {
      if (e.timestamp >= since.toISOString() && e.timestamp < until.toISOString()) {
        out.push(e);
      }
    }
  }
  return out;
}

function addDays(day: string, delta: number): string {
  const t = Date.parse(`${day}T00:00:00Z`);
  if (!Number.isFinite(t)) return day;
  return new Date(t + delta * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
}

function extractPrefId(raw: string): string | null {
  const m = /\[\[([^\]|#]+)/.exec(raw);
  if (m) return m[1]!.trim();
  if (raw.startsWith("pref-")) return raw;
  return null;
}

function getConfirmedAt(vault: string, prefId: string): string | null {
  const dirs = brainDirs(vault);
  const path = join(dirs.preferences, `${prefId}.md`);
  if (!existsSync(path)) return null;
  try {
    const text = readFileSync(path, "utf8");
    const m = /^confirmed_at:\s*(.+)$/m.exec(text);
    return m ? m[1]!.trim() : null;
  } catch {
    return null;
  }
}

function getRetiredAt(vault: string, prefId: string): string | null {
  const dirs = brainDirs(vault);
  const retiredId = prefId.replace("pref-", "ret-");
  const path = join(dirs.retired, `${retiredId}.md`);
  if (!existsSync(path)) return null;
  try {
    const text = readFileSync(path, "utf8");
    const m = /^retired_at:\s*(.+)$/m.exec(text);
    return m ? m[1]!.trim() : null;
  } catch {
    return null;
  }
}

function isInWindow(iso: string, since: Date, until: Date): boolean {
  const t = Date.parse(iso);
  if (!Number.isFinite(t)) return false;
  return t >= since.getTime() && t < until.getTime();
}
```

**Step 4: Run test to verify pass**

Run: `bun test tests/core/brain/digest-agent-summary.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add src/core/brain/digest-agent-summary.ts tests/core/brain/digest-agent-summary.test.ts
git commit -m "feat: add agent quality summary computation for digest"
```

---

### Task 4: Integrate agent summary into digest renderer

**Objective:** Add agent quality summary section to digest Markdown and JSON output.

**Files:**
- Modify: `src/core/brain/digest.ts`

**Step 1: Add import and new JSON types**

In `src/core/brain/digest.ts`:

```typescript
import { computeAgentSummary, type AgentSummaryEntry } from "./digest-agent-summary.ts";
```

Add before `DigestJson`:

```typescript
export interface DigestJsonAgentSummary {
  readonly agent: string;
  readonly total_events: number;
  readonly feedback_count: number;
  readonly apply_evidence_count: number;
  readonly note_count: number;
  readonly confirmed_attributed: number;
  readonly retired_attributed: number;
}
```

Add to `DigestJson`:

```typescript
readonly agent_summary: ReadonlyArray<DigestJsonAgentSummary>;
```

**Step 2: Add to `DigestData` interface**

```typescript
readonly agent_summary: ReadonlyArray<AgentSummaryEntry>;
```

**Step 3: Compute in `collectDigestData`**

Before the return in `collectDigestData`:

```typescript
const agent_summary = computeAgentSummary(vault, since, until);
```

Add to return object: `agent_summary,`

**Step 4: Add to JSON payload**

In the JSON construction:

```typescript
agent_summary: data.agent_summary,
```

**Step 5: Add to Markdown rendering**

In `renderMarkdown`, after the `most_applied` section and before `top_applied`:

```typescript
if (data.agent_summary.length > 0) {
  lines.push(`## Agent summary (${data.agent_summary.length})`, "");
  for (const item of data.agent_summary) {
    const parts = [
      `**${item.agent}**`,
      `${item.total_events} events`,
      `(feedback: ${item.feedback_count}, apply: ${item.apply_evidence_count}, note: ${item.note_count})`,
    ];
    if (item.confirmed_attributed > 0) parts.push(`→ ${item.confirmed_attributed} confirmed`);
    if (item.retired_attributed > 0) parts.push(`→ ${item.retired_attributed} retired`);
    lines.push(`- ${parts.join(" ")}`);
  }
  lines.push("");
}
```

**Step 6: Run all digest tests**

Run: `bun test tests/core/brain/digest*.test.ts`
Expected: PASS

**Step 7: Commit**

```bash
git add src/core/brain/digest.ts
git commit -m "feat: integrate agent summary into digest renderer"
```

---

### Task 5: Update `scripts/discipline-report.ts` for `--window`

**Objective:** Parameterize the discipline report script to support `--window` flag.

**Files:**
- Modify: `scripts/discipline-report.ts`

**Step 1: Rewrite the script**

Replace `scripts/discipline-report.ts` with:

```typescript
#!/usr/bin/env -S bun
import { resolveVault } from "../src/core/config.ts";
import { runDisciplineReport } from "../src/core/discipline/report.ts";
import { renderDigest } from "../src/core/brain/digest.ts";

function parseArgs(): { vault: string; window: string | null } {
  const argv = process.argv.slice(2);
  let vault: string | undefined;
  let window: string | null = null;
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === "--vault" && argv[i + 1]) {
      vault = argv[++i]!;
    } else if (argv[i] === "--window" && argv[i + 1]) {
      window = argv[++i]!;
    }
  }
  const v = vault ?? resolveVault();
  if (!v) {
    process.stderr.write(
      "o2b-discipline-report: no vault configured; set VAULT_DIR env or pass --vault <path>\n",
    );
    process.exit(2);
  }
  return { vault: v, window };
}

const { vault, window } = parseArgs();

if (window) {
  const m = /^(\d+)(?:d)?$/.exec(window);
  if (!m) {
    process.stderr.write(`o2b-discipline-report: invalid --window value: ${window}\n`);
    process.exit(2);
  }
  const days = parseInt(m[1]!, 10);
  if (days <= 0) {
    process.stderr.write(`o2b-discipline-report: --window must be positive: ${window}\n`);
    process.exit(2);
  }
  const now = new Date();
  const since = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
  const result = renderDigest(vault, { since, until: now, format: "markdown" });
  process.stdout.write(result.content);
  if (!result.content.endsWith("\n")) process.stdout.write("\n");
} else {
  const res = runDisciplineReport({ vault });
  if (res.status === "disabled") {
    process.stderr.write("o2b-discipline-report: discipline_report disabled in Brain/_brain.yaml\n");
    process.exit(0);
  }
  process.stdout.write(res.text + "\n");
}
```

**Step 2: Run existing path (no --window)**

Run: `bun run scripts/discipline-report.ts --vault /root/vault`
Expected: outputs daily discipline report (or "disabled")

**Step 3: Commit**

```bash
git add scripts/discipline-report.ts
git commit -m "feat: add --window flag to discipline-report script"
```

---

### Task 6: Add `--weekly` flag to `o2b discipline install`

**Objective:** Install a weekly digest cron job alongside the daily one.

**Files:**
- Modify: `src/cli/discipline-install.ts`
- Test: `tests/cli/discipline-install.test.ts`

**Step 1: Read existing test file**

Read `tests/cli/discipline-install.test.ts` to understand patterns.

**Step 2: Modify `disciplineInstallVerb`**

In `src/cli/discipline-install.ts`, add `--weekly` flag parsing:

```typescript
let weekly = false;
// In the for loop, add:
if (args[i] === "--weekly") {
  weekly = true;
  continue;
}
```

Add helper functions:

```typescript
function weeklyJobId(vault: string): string {
  const slug = createHash("sha256")
    .update(resolve(vault) + ":weekly")
    .digest("hex")
    .slice(0, 12);
  return `osb-weekly-brain-digest-${slug}`;
}

function weeklyScriptPath(): string {
  return scriptPath() + " --window 7d";
}
```

When `weekly` is true, use:
- `id: weeklyJobId(vault)`
- `name: "osb-weekly-brain-digest"`
- `script: weeklyScriptPath()`
- Default schedule `"59 8 * * 1"` (override with `--at`)

**Step 3: Run existing tests**

Run: `bun test tests/cli/discipline-install.test.ts`
Expected: PASS

**Step 4: Commit**

```bash
git add src/cli/discipline-install.ts
git commit -m "feat: add --weekly flag to discipline install"
```

---

### Task 7: Implement `o2b update` core logic

**Objective:** Core update orchestration — detect → plan → compare → apply per target.

**Files:**
- Create: `src/core/install/update.ts`
- Create: `tests/core/install/update.test.ts`

**Step 1: Write test file**

Create `tests/core/install/update.test.ts`:

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

import { runUpdate } from "../../../src/core/install/update.ts";
import { createRegistry } from "../../../src/core/install/registry.ts";
import type {
  InstallAdapter,
  InstallEnv,
  DetectResult,
  InstallPlan,
  ApplyResult,
  VerifyResult,
} from "../../../src/core/install/types.ts";

let vault: string;
let home: string;

beforeEach(() => {
  vault = mkdtempSync(join(tmpdir(), "osb-update-"));
  home = mkdtempSync(join(tmpdir(), "osb-update-home-"));
  mkdirSync(join(vault, "Brain"), { recursive: true });
  mkdirSync(join(vault, ".open-second-brain"), { recursive: true });
});

afterEach(() => {
  try { rmSync(vault, { recursive: true, force: true }); } catch {}
  try { rmSync(home, { recursive: true, force: true }); } catch {}
});

function makeEnv(): InstallEnv {
  return { vault, home, cwd: process.cwd(), env: {}, now: new Date() };
}

function fakeAdapter(
  target: string,
  status: "installed" | "not-installed" = "installed",
  payloadHash?: string,
): InstallAdapter {
  return {
    target,
    label: target,
    detect(): DetectResult {
      return { target, status, configPath: `/tmp/${target}-config`, notes: [] };
    },
    plan(): InstallPlan {
      return {
        target,
        steps: [{ kind: "json-merge", path: "/tmp/c.json", preview: "merge" }],
        postNotes: ["restart " + target],
      };
    },
    apply(): ApplyResult {
      return {
        target,
        steps_executed: 1,
        manifest: {
          target,
          applied_at: new Date().toISOString(),
          operation: "json-merge",
          config_path: "/tmp/c.json",
        },
      };
    },
    uninstall() {
      return { target, removed_keys: [], removed_paths: [], skipped: [] };
    },
    verify(): VerifyResult {
      return { target, status: "ok", details: [], fix_hint: null };
    },
  };
}

describe("runUpdate", () => {
  test("skips not-installed targets", () => {
    const reg = createRegistry();
    reg.register(fakeAdapter("cursor", "not-installed"));
    const result = runUpdate(reg, makeEnv(), { dryRun: false, force: false, target: null });
    expect(result.targets.length).toBe(1);
    expect(result.targets[0].status).toBe("skipped");
  });

  test("applies when no previous manifest exists", () => {
    const reg = createRegistry();
    reg.register(fakeAdapter("claudecode"));
    const result = runUpdate(reg, makeEnv(), { dryRun: false, force: false, target: null });
    expect(result.targets[0].status).toBe("applied");
  });

  test("dry-run reports would-apply without applying", () => {
    const reg = createRegistry();
    reg.register(fakeAdapter("claudecode"));
    const result = runUpdate(reg, makeEnv(), { dryRun: true, force: false, target: null });
    expect(result.targets[0].status).toBe("would-apply");
  });
});
```

**Step 2: Run test to verify failure**

Run: `bun test tests/core/install/update.test.ts`
Expected: FAIL — module not found

**Step 3: Implement `runUpdate`**

Create `src/core/install/update.ts`:

```typescript
/**
 * Update orchestrator for v0.10.12.
 *
 * Runs the InstallAdapter pipeline (detect → plan → compare → apply)
 * across all registered targets or a single --target. Skips targets
 * where the payload hash hasn't changed since the last update.
 */

import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";

import type { Registry } from "./registry.ts";
import type {
  InstallAdapter,
  InstallEnv,
  ApplyOpts,
  McpPayload,
} from "./types.ts";
import { InstallError } from "./types.ts";
import { buildPayload } from "./payload.ts";

export interface UpdateTargetResult {
  readonly target: string;
  readonly status: "applied" | "up-to-date" | "skipped" | "would-apply" | "error" | "verified-drift";
  readonly reason?: string;
  readonly postNotes?: ReadonlyArray<string>;
  readonly error?: string;
  readonly hint?: string;
}

export interface UpdateResult {
  readonly targets: ReadonlyArray<UpdateTargetResult>;
}

export interface UpdateOptions {
  readonly dryRun: boolean;
  readonly force: boolean;
  readonly target: string | null;
}

interface SidecarManifest {
  targets: Record<string, {
    payload_hash: string;
    applied_at: string;
  }>;
}

function sidecarPath(vault: string): string {
  return join(vault, ".open-second-brain", "update.lock.json");
}

function loadSidecar(vault: string): SidecarManifest {
  const path = sidecarPath(vault);
  if (!existsSync(path)) return { targets: {} };
  try {
    return JSON.parse(readFileSync(path, "utf8"));
  } catch {
    return { targets: {} };
  }
}

function saveSidecar(vault: string, manifest: SidecarManifest): void {
  const path = sidecarPath(vault);
  const dir = join(vault, ".open-second-brain");
  if (!existsSync(dir)) {
    try { mkdirSync(dir, { recursive: true }); } catch {}
  }
  writeFileSync(path, JSON.stringify(manifest, sortedReplacer, 2) + "\n");
}

function computePayloadHash(payload: McpPayload): string {
  return createHash("sha256")
    .update(JSON.stringify(payload, sortedReplacer))
    .digest("hex");
}

function sortedReplacer(_key: string, value: unknown): unknown {
  if (value && typeof value === "object" && !Array.isArray(value)) {
    const sorted: Record<string, unknown> = {};
    for (const k of Object.keys(value as Record<string, unknown>).sort()) {
      sorted[k] = (value as Record<string, unknown>)[k];
    }
    return sorted;
  }
  return value;
}

export function runUpdate(
  registry: Registry,
  env: InstallEnv,
  opts: UpdateOptions,
): UpdateResult {
  const targets = opts.target
    ? [registry.get(opts.target)].filter((a): a is InstallAdapter => a !== undefined)
    : registry.list();

  const results: UpdateTargetResult[] = [];
  const sidecar = loadSidecar(env.vault);

  for (const adapter of targets) {
    const detect = adapter.detect(env);

    if (detect.status === "not-installed" || detect.status === "unsupported-on-this-platform") {
      results.push({
        target: adapter.target,
        status: "skipped",
        reason: `${adapter.label} is ${detect.status}`,
        postNotes: detect.notes.length > 0 ? detect.notes : undefined,
      });
      continue;
    }

    let payload: McpPayload;
    try {
      payload = buildPayload({
        vault: env.vault,
        agent_name: env.env["VAULT_AGENT_NAME"] ?? null,
        timezone: env.env["VAULT_TIMEZONE"] ?? null,
      });
    } catch (e) {
      results.push({
        target: adapter.target,
        status: "error",
        error: `Failed to build payload: ${(e as Error).message}`,
      });
      continue;
    }

    const currentHash = computePayloadHash(payload);
    const previous = sidecar.targets[adapter.target];

    if (previous && previous.payload_hash === currentHash && !opts.force) {
      results.push({
        target: adapter.target,
        status: "up-to-date",
        reason: "payload unchanged",
      });
      continue;
    }

    const plan = adapter.plan(payload, env);

    if (opts.dryRun) {
      results.push({
        target: adapter.target,
        status: "would-apply",
        postNotes: plan.postNotes,
      });
      continue;
    }

    const applyOpts: ApplyOpts = {
      dryRun: false,
      force: opts.force,
      stdout: process.stdout,
      stderr: process.stderr,
    };

    try {
      adapter.apply(plan, payload, env, applyOpts);
    } catch (e) {
      if (e instanceof InstallError) {
        results.push({
          target: adapter.target,
          status: "error",
          error: e.message,
          hint: e.hint,
        });
      } else {
        results.push({
          target: adapter.target,
          status: "error",
          error: (e as Error).message,
        });
      }
      continue;
    }

    sidecar.targets[adapter.target] = {
      payload_hash: currentHash,
      applied_at: new Date().toISOString(),
    };
    saveSidecar(env.vault, sidecar);

    results.push({
      target: adapter.target,
      status: "applied",
      postNotes: plan.postNotes,
    });
  }

  return { targets: results };
}
```

**Step 4: Run test to verify pass**

Run: `bun test tests/core/install/update.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add src/core/install/update.ts tests/core/install/update.test.ts
git commit -m "feat: add update orchestrator core logic"
```

---

### Task 8: Implement `o2b update` CLI verb

**Objective:** CLI handler for `o2b update` with flag parsing and output rendering.

**Files:**
- Create: `src/cli/update.ts`
- Modify: `src/cli/main.ts`
- Test: `tests/cli/update.test.ts`

**Step 1: Write test file**

Create `tests/cli/update.test.ts`:

```typescript
import { describe, expect, test } from "bun:test";
import { parseUpdateArgs } from "../../src/cli/update.ts";

describe("parseUpdateArgs", () => {
  test("parses no flags", () => {
    const args = parseUpdateArgs([]);
    expect(args.target).toBeNull();
    expect(args.dryRun).toBe(false);
    expect(args.force).toBe(false);
    expect(args.json).toBe(false);
  });

  test("parses --target", () => {
    const args = parseUpdateArgs(["--target", "claudecode"]);
    expect(args.target).toBe("claudecode");
  });

  test("parses --dry-run", () => {
    const args = parseUpdateArgs(["--dry-run"]);
    expect(args.dryRun).toBe(true);
  });

  test("parses --force", () => {
    const args = parseUpdateArgs(["--force"]);
    expect(args.force).toBe(true);
  });

  test("parses --json", () => {
    const args = parseUpdateArgs(["--json"]);
    expect(args.json).toBe(true);
  });
});
```

**Step 2: Run test to verify failure**

Run: `bun test tests/cli/update.test.ts`
Expected: FAIL

**Step 3: Implement CLI verb**

Create `src/cli/update.ts`:

```typescript
/**
 * `o2b update` CLI verb.
 *
 * Exit codes:
 *   0  all targets updated or up-to-date
 *   1  runtime error
 *   2  usage error
 *   3  drift detected after update
 */

import { homedir } from "node:os";

import { parseFlags } from "./argparse.ts";
import { defaultConfigPath, discoverConfig } from "../core/config.ts";
import { defaultRegistry } from "../core/install/registry.ts";
import "../core/install/adapters/aider.ts";
import "../core/install/adapters/copilot-cli.ts";
import "../core/install/adapters/cursor.ts";
import "../core/install/adapters/gemini-cli.ts";
import "../core/install/adapters/generic.ts";
import "../core/install/adapters/kiro.ts";
import "../core/install/adapters/opencode.ts";
import "../core/install/adapters/pi.ts";

import { runUpdate, type UpdateResult, type UpdateTargetResult } from "../core/install/update.ts";
import type { InstallEnv, VerifyResult } from "../core/install/types.ts";

interface ParsedUpdateArgs {
  readonly target: string | null;
  readonly dryRun: boolean;
  readonly force: boolean;
  readonly json: boolean;
}

export function parseUpdateArgs(argv: string[]): ParsedUpdateArgs {
  const { flags } = parseFlags(argv, {
    target: { type: "string" },
    "dry-run": { type: "boolean" },
    force: { type: "boolean" },
    json: { type: "boolean" },
  });
  return {
    target: (flags["target"] as string | undefined) ?? null,
    dryRun: Boolean(flags["dry-run"]),
    force: Boolean(flags["force"]),
    json: Boolean(flags["json"]),
  };
}

function buildEnv(): InstallEnv {
  const cfg = discoverConfig(defaultConfigPath()).data;
  const vault = cfg["vault"] ?? process.env["VAULT_DIR"] ?? "";
  const env = { ...process.env } as Record<string, string>;
  if (cfg["agent_name"]) env["VAULT_AGENT_NAME"] = cfg["agent_name"];
  if (cfg["timezone"]) env["VAULT_TIMEZONE"] = cfg["timezone"];
  return { vault, home: homedir(), cwd: process.cwd(), env, now: new Date() };
}

function renderResult(result: UpdateResult, json: boolean): void {
  if (json) {
    process.stdout.write(JSON.stringify(result, null, 2) + "\n");
    return;
  }

  for (const t of result.targets) {
    const icon = statusIcon(t.status);
    process.stdout.write(`${icon} ${t.target}`);
    if (t.reason) process.stdout.write(` — ${t.reason}`);
    if (t.error) process.stdout.write(` — ERROR: ${t.error}`);
    if (t.hint) process.stdout.write(`\n   hint: ${t.hint}`);
    if (t.postNotes && t.postNotes.length > 0) {
      for (const note of t.postNotes) process.stdout.write(`\n   → ${note}`);
    }
    process.stdout.write("\n");
  }

  const applied = result.targets.filter((t) => t.status === "applied").length;
  const upToDate = result.targets.filter((t) => t.status === "up-to-date").length;
  const skipped = result.targets.filter((t) => t.status === "skipped").length;
  const errors = result.targets.filter((t) => t.status === "error").length;
  process.stdout.write(`\n${applied} applied, ${upToDate} up-to-date, ${skipped} skipped`);
  if (errors > 0) process.stdout.write(`, ${errors} errors`);
  process.stdout.write("\n");
}

function statusIcon(status: UpdateTargetResult["status"]): string {
  switch (status) {
    case "applied": return "✓";
    case "up-to-date": return "=";
    case "skipped": return "○";
    case "would-apply": return "~";
    case "error": return "✗";
    case "verified-drift": return "!";
    default: return "?";
  }
}

export async function cmdUpdate(argv: string[]): Promise<number> {
  let args: ParsedUpdateArgs;
  try {
    args = parseUpdateArgs(argv);
  } catch (e) {
    process.stderr.write(`error: ${(e as Error).message}\n`);
    return 2;
  }

  const env = buildEnv();
  if (!env.vault) {
    process.stderr.write("error: vault not configured. Set VAULT_DIR or run `o2b init`.\n");
    return 2;
  }

  if (args.target && !defaultRegistry.get(args.target)) {
    process.stderr.write(
      `error: unknown --target: ${args.target}. ` +
        `Available: ${defaultRegistry.targets().join(", ")}\n`,
    );
    return 2;
  }

  const result = runUpdate(defaultRegistry, env, {
    dryRun: args.dryRun,
    force: args.force,
    target: args.target,
  });

  renderResult(result, args.json);

  // Post-update verify
  if (!args.dryRun) {
    const targets = args.target
      ? [defaultRegistry.get(args.target)!]
      : defaultRegistry.list();
    const verifyResults: VerifyResult[] = [];
    for (const a of targets) {
      if (a.detect(env).status === "installed") {
        verifyResults.push(a.verify(env));
      }
    }
    const drift = verifyResults.some((r) => r.status === "drift");
    if (drift) {
      process.stderr.write("\nwarning: drift detected after update. Run `o2b install --check` for details.\n");
      return 3;
    }
  }

  const hasError = result.targets.some((t) => t.status === "error");
  return hasError ? 1 : 0;
}
```

**Step 4: Run test to verify pass**

Run: `bun test tests/cli/update.test.ts`
Expected: PASS

**Step 5: Register in main.ts**

In `src/cli/main.ts`:

1. Add import:
```typescript
import { cmdUpdate } from "./update.ts";
```

2. Add case in the switch (before `default`):
```typescript
case "update":
  return await cmdUpdate(rest);
```

3. Add to HELP string (under the main Commands section):
```
  update                    Update OSB installation across all detected runtimes
```

**Step 6: Run typecheck and tests**

Run: `bun run typecheck`
Run: `bun test tests/cli/update.test.ts tests/core/install/update.test.ts`
Expected: both pass

**Step 7: Commit**

```bash
git add src/cli/update.ts src/cli/main.ts tests/cli/update.test.ts
git commit -m "feat: add o2b update CLI verb"
```

---

### Task 9: Cursor SQLite deep parsing

**Objective:** Replace mtime-only probe with SQLite parsing returning session count + message count.

**Files:**
- Modify: `src/core/discipline/transcripts/types.ts`
- Modify: `src/core/discipline/transcripts/cursor.ts`
- Modify: `src/core/discipline/transcripts/index.ts`
- Test: `tests/core/discipline/transcripts/cursor-sqlite.test.ts`

**Step 1: Extend `TranscriptRuntime` interface**

In `src/core/discipline/transcripts/types.ts`, add:

```typescript
export interface TranscriptDetail {
  readonly sessionCount: number;
  readonly messageCount: number;
}

export interface TranscriptRuntime {
  readonly runtime: "claudecode" | "codex" | "cursor";
  readonly agentHint: string | null;
  collect(dayStartMs: number, dayEndMs: number, home?: string): string[];
  /**
   * Optional detailed collector returning session/message counts.
   * Falls back to `collect().length` if not implemented.
   */
  collectDetail?(dayStartMs: number, dayEndMs: number, home?: string): TranscriptDetail | null;
}
```

Add to `TranscriptRuntimeActivity`:

```typescript
export interface TranscriptRuntimeActivity {
  readonly runtime: string;
  readonly fileCount: number;
  readonly agentHint: string | null;
  readonly detail?: TranscriptDetail;
}
```

**Step 2: Update aggregator to use `collectDetail`**

In `src/core/discipline/transcripts/index.ts`, modify `collectTranscriptActivity`:

```typescript
for (const r of runtimes) {
  const files = r.collect(opts.dayStartMs, opts.dayEndMs, opts.home);
  const detail = r.collectDetail?.(opts.dayStartMs, opts.dayEndMs, opts.home) ?? null;
  byRuntime.push({
    runtime: r.runtime,
    fileCount: files.length,
    agentHint: r.agentHint,
    ...(detail ? { detail } : {}),
  });
  total += files.length;
}
```

**Step 3: Implement Cursor SQLite parsing**

Rewrite `src/core/discipline/transcripts/cursor.ts`:

```typescript
/**
 * Cursor session-transcript paths for the discipline report.
 *
 * v0.10.12: reads `state.vscdb` SQLite to extract session count and
 * message count. Falls back to mtime probe on any error.
 */

import { existsSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

import type { TranscriptDetail, TranscriptRuntime } from "./types.ts";

export const cursorTranscript: TranscriptRuntime = {
  runtime: "cursor",
  agentHint: "cursor-vps-agent",
  collect(dayStartMs, dayEndMs, home = homedir()): string[] {
    const dbs = findDatabases(home);
    const out: string[] = [];
    for (const db of dbs) {
      try {
        const st = statSync(db);
        if (st.mtimeMs >= dayStartMs && st.mtimeMs < dayEndMs) out.push(db);
      } catch {}
    }
    return out;
  },
  collectDetail(dayStartMs, dayEndMs, home = homedir()): TranscriptDetail | null {
    const dbs = findDatabases(home);
    let totalSessions = 0;
    let totalMessages = 0;
    let anyActive = false;

    for (const db of dbs) {
      try {
        const result = queryCursorDb(db, dayStartMs, dayEndMs);
        if (result) {
          totalSessions += result.sessionCount;
          totalMessages += result.messageCount;
          if (result.sessionCount > 0) anyActive = true;
        }
      } catch {
        // Fall back to mtime for this specific DB
        try {
          const st = statSync(db);
          if (st.mtimeMs >= dayStartMs && st.mtimeMs < dayEndMs) anyActive = true;
        } catch {}
      }
    }

    if (!anyActive && totalSessions === 0) return null;
    return { sessionCount: totalSessions, messageCount: totalMessages };
  },
};

function findDatabases(home: string): string[] {
  const roots = [
    join(home, ".config", "Cursor", "User", "workspaceStorage"),
    join(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage"),
    join(home, ".cursor", "workspaceStorage"),
  ];
  const out: string[] = [];
  for (const root of roots) {
    if (!existsSync(root)) continue;
    let dirs: import("node:fs").Dirent[];
    try {
      dirs = readdirSync(root, { withFileTypes: true });
    } catch {
      continue;
    }
    for (const d of dirs) {
      if (!d.isDirectory()) continue;
      const db = join(root, d.name, "state.vscdb");
      if (existsSync(db)) out.push(db);
    }
  }
  return out;
}

function queryCursorDb(
  dbPath: string,
  dayStartMs: number,
  dayEndMs: number,
): { sessionCount: number; messageCount: number } | null {
  let Database: typeof import("bun:sqlite").Database;
  try {
    Database = require("bun:sqlite").Database;
  } catch {
    return null;
  }

  let db: InstanceType<typeof Database>;
  try {
    db = new Database(dbPath, { readonly: true });
  } catch {
    return null;
  }

  try {
    // Cursor stores session data in ItemTable with keys like
    // "sessionData.<sessionId>". The value is a JSON blob.
    // We query all session keys and parse the JSON to count messages
    // and check timestamps.
    const rows = db
      .query("SELECT key, value FROM ItemTable WHERE key LIKE 'sessionData.%'")
      .all() as Array<{ key: string; value: string }>;

    let sessionCount = 0;
    let messageCount = 0;

    for (const row of rows) {
      try {
        const data = JSON.parse(row.value);
        // Cursor session data structure: the value may contain
        // `messages` array or `turns` array. We count whatever
        // array-like structure is present.
        const messages = data?.messages ?? data?.turns ?? data?.data?.messages;
        if (Array.isArray(messages)) {
          // Check if any message falls in the day window
          let hasActivityInWindow = false;
          for (const msg of messages) {
            const ts = msg?.timestamp ?? msg?.createdAt ?? msg?.created_at;
            if (ts) {
              const tsMs = typeof ts === "number" ? ts : Date.parse(ts);
              if (Number.isFinite(tsMs) && tsMs >= dayStartMs && tsMs < dayEndMs) {
                hasActivityInWindow = true;
              }
            }
          }
          if (hasActivityInWindow) {
            sessionCount++;
            messageCount += messages.length;
          }
        }
      } catch {
        // Malformed JSON — skip this session
      }
    }

    return { sessionCount, messageCount };
  } finally {
    try { db.close(); } catch {}
  }
}
```

**Step 4: Write test file**

Create `tests/core/discipline/transcripts/cursor-sqlite.test.ts`:

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

import { cursorTranscript } from "../../../../src/core/discipline/transcripts/cursor.ts";

describe("cursorTranscript collectDetail", () => {
  let home: string;

  beforeEach(() => {
    home = mkdtempSync(join(tmpdir(), "osb-cursor-"));
  });

  afterEach(() => {
    try { rmSync(home, { recursive: true, force: true }); } catch {}
  });

  test("returns null when no Cursor data exists", () => {
    const dayStart = Date.now() - 24 * 60 * 60 * 1000;
    const dayEnd = Date.now();
    const detail = cursorTranscript.collectDetail!(dayStart, dayEnd, home);
    expect(detail).toBeNull();
  });

  test("falls back gracefully when SQLite is unavailable", () => {
    // Create a fake directory structure without actual SQLite
    const wsRoot = join(home, ".config", "Cursor", "User", "workspaceStorage", "fake-hash");
    mkdirSync(wsRoot, { recursive: true });
    // Create a non-SQLite file where state.vscdb would be
    writeFileSync(join(wsRoot, "state.vscdb"), "not a sqlite database");

    const dayStart = Date.now() - 24 * 60 * 60 * 1000;
    const dayEnd = Date.now();
    // Should not throw — graceful fallback
    const detail = cursorTranscript.collectDetail!(dayStart, dayEnd, home);
    // Returns null because the file isn't a valid SQLite DB
    expect(detail).toBeNull();
  });
});
```

**Step 5: Run test to verify pass**

Run: `bun test tests/core/discipline/transcripts/cursor-sqlite.test.ts`
Expected: PASS

**Step 6: Run all discipline tests**

Run: `bun test tests/discipline/ tests/core/discipline/`
Expected: PASS

**Step 7: Commit**

```bash
git add src/core/discipline/transcripts/types.ts src/core/discipline/transcripts/cursor.ts src/core/discipline/transcripts/index.ts tests/core/discipline/transcripts/cursor-sqlite.test.ts
git commit -m "feat: add Cursor SQLite deep parsing for transcript activity"
```

---

### Task 10: Obsidian deep-link in explorer

**Objective:** Double-click on a node in live-mode opens the file in Obsidian.

**Files:**
- Modify: `src/core/brain/explorer.ts`
- Modify: `templates/brain-explorer.html`
- Test: `tests/core/brain/explorer-deeplink.test.ts`

**Step 1: Add `__VAULT_PATH__` placeholder to template**

In `templates/brain-explorer.html`, add near the `__GRAPH_JSON__` line (in the `<script>` block where graph data is injected):

```html
<script>
const VAULT_PATH = "__VAULT_PATH__";
</script>
```

**Step 2: Modify `renderExportedHtml` to accept vaultPath**

In `src/core/brain/explorer.ts`, add a new placeholder constant:

```typescript
const VAULT_PATH_PLACEHOLDER = "__VAULT_PATH__";
```

Modify `renderExportedHtml` to accept an optional vaultPath:

```typescript
export function renderExportedHtml(graph: ExplorerGraph, vaultPath?: string): string {
  const json = JSON.stringify(graph).replace(/[<>&]/g, (c) => {
    switch (c) {
      case "<": return "\\u003c";
      case ">": return "\\u003e";
      case "&": return "\\u0026";
      default: return c;
    }
  });
  let html = loadTemplate().replace(PLACEHOLDER, () => json);
  if (vaultPath) {
    html = html.replace(VAULT_PATH_PLACEHOLDER, vaultPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'"));
  } else {
    html = html.replace(VAULT_PATH_PLACEHOLDER, "");
  }
  return html;
}
```

**Step 3: Pass vaultPath in live server**

In `buildLiveServer`, modify the response:

```typescript
if (url.pathname === "/" || url.pathname === "/index.html") {
  const graph = collectExplorerData(vault);
  const html = renderExportedHtml(graph, vault);
  return new Response(html, {
    headers: { "content-type": "text/html; charset=utf-8" },
  });
}
```

**Step 4: Add double-click handler to template**

In `templates/brain-explorer.html`, in the `selectNode` function or as a separate double-click handler on the canvas:

```javascript
// After the existing canvas click handler, add:
canvas.addEventListener("dblclick", (e) => {
  if (!VAULT_PATH) return;
  const r = canvas.getBoundingClientRect();
  const x = e.clientX - r.left;
  const y = e.clientY - r.top;
  const n = findNode(x, y);
  if (!n) return;
  const filePath = VAULT_PATH + "/Brain/preferences/" + n.id + ".md";
  const uri = "obsidian://open?path=" + encodeURIComponent(filePath);
  const opened = window.open(uri, "_blank");
  if (!opened) {
    // Fallback: copy path to clipboard
    navigator.clipboard.writeText(filePath).then(() => {
      showTooltip("Obsidian not detected — path copied to clipboard");
    }).catch(() => {
      showTooltip("Path: " + filePath);
    });
  }
});

function showTooltip(text) {
  let tip = document.getElementById("deeplink-tooltip");
  if (!tip) {
    tip = document.createElement("div");
    tip.id = "deeplink-tooltip";
    tip.style.cssText = "position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:8px 16px;border-radius:4px;font-size:12px;z-index:9999;transition:opacity 0.3s;";
    document.body.appendChild(tip);
  }
  tip.textContent = text;
  tip.style.opacity = "1";
  clearTimeout(tip._timer);
  tip._timer = setTimeout(() => { tip.style.opacity = "0"; }, 3000);
}
```

Also add double-click handler for listbox items:

```javascript
// In the listbox onclick handler, add dblclick:
list.addEventListener("dblclick", (ev) => {
  if (!VAULT_PATH) return;
  const li = ev.target.closest("li[data-node-id]");
  if (!li) return;
  const nodeId = li.getAttribute("data-node-id");
  const filePath = VAULT_PATH + "/Brain/preferences/" + nodeId + ".md";
  const uri = "obsidian://open?path=" + encodeURIComponent(filePath);
  window.open(uri, "_blank");
});
```

**Step 5: Write test**

Create `tests/core/brain/explorer-deeplink.test.ts`:

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

import { collectExplorerData, renderExportedHtml, __resetExplorerTemplateCacheForTests } from "../../../src/core/brain/explorer.ts";

let vault: string;

beforeEach(() => {
  vault = mkdtempSync(join(tmpdir(), "osb-explorer-dl-"));
  mkdirSync(join(vault, "Brain", "preferences"), { recursive: true });
  mkdirSync(join(vault, "Brain", "retired"), { recursive: true });
});

afterEach(() => {
  __resetExplorerTemplateCacheForTests();
  try { rmSync(vault, { recursive: true, force: true }); } catch {}
});

describe("explorer deep-link", () => {
  test("renderExportedHtml substitutes vault path", () => {
    const graph = collectExplorerData(vault);
    const html = renderExportedHtml(graph, "/my/vault");
    expect(html).toContain("/my/vault");
    expect(html).not.toContain("__VAULT_PATH__");
  });

  test("renderExportedHtml with no vault path renders empty", () => {
    const graph = collectExplorerData(vault);
    const html = renderExportedHtml(graph);
    expect(html).not.toContain("__VAULT_PATH__");
  });
});
```

**Step 6: Run test to verify pass**

Run: `bun test tests/core/brain/explorer-deeplink.test.ts`
Expected: PASS

**Step 7: Run all explorer tests**

Run: `bun test tests/core/brain/explorer*.test.ts`
Expected: PASS

**Step 8: Commit**

```bash
git add src/core/brain/explorer.ts templates/brain-explorer.html tests/core/brain/explorer-deeplink.test.ts
git commit -m "feat: add Obsidian deep-link on double-click in explorer"
```

---

### Task 11: Bump version and update CHANGELOG

**Objective:** Bump version to 0.10.12 and add changelog entry.

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

**Step 1: Bump version in package.json**

Change `"version": "0.10.11"` to `"version": "0.10.12"`.

**Step 2: Add CHANGELOG entry**

At the top of `CHANGELOG.md`:

```markdown
## 0.10.12 — 2026-05-XX

### Added
- `o2b update` — one-command update across all detected runtimes. Skips unchanged payloads via sidecar hash comparison. Supports `--target`, `--dry-run`, `--force`, `--json`.
- `o2b brain digest --window 7d` — arbitrary time windows for the digest. Weekly cron installable via `o2b discipline install --weekly`.
- Agent quality summary in digest — per-agent event counts, type breakdown, and confirmed/retired attribution.
- Cursor SQLite deep parsing — discipline report now reads `state.vscdb` for session count + message count, eliminating mtime false-positives.
- Obsidian deep-link in explorer — double-click a node in live mode to open the file in Obsidian.

### Changed
- `scripts/discipline-report.ts` now accepts `--window Nd` for digest mode.
```

**Step 3: Run typecheck**

Run: `bun run typecheck`
Expected: PASS

**Step 4: Run full test suite**

Run: `bun test`
Expected: PASS

**Step 5: Commit**

```bash
git add package.json CHANGELOG.md
git commit -m "chore: bump version to 0.10.12, update CHANGELOG"
```

---

## Verification Checklist

After all tasks:

```bash
# Typecheck
bun run typecheck

# Full test suite
bun test

# Manual smoke test — update
o2b update --dry-run

# Manual smoke test — digest with window
o2b brain digest --window 7d

# Manual smoke test — discipline install weekly
o2b discipline install --weekly --telegram-target telegram:-1003895040510:216 --vault /root/vault

# Manual smoke test — explorer live
o2b brain explorer &
# Open http://127.0.0.1:7777 in browser, double-click a node
```
