# Phase B — Complaint Listener Consumer Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Refactor `extensions/complaint-listener/` from agent-YAML-opt-in + env-var-targeted to declarative pluginConfig with subscriptions/destinations arrays, ledger-based context, dual-hook observer with messageId dedup, and ship a self-contained Vite+Svelte SPA that mounts into Phase A's `settings.plugins` slot.

**Architecture:** Backend keeps the SQLite store and forced-tool-call classifier but rewires inputs (hooks → subscription matching) and outputs (N-destination fan-out). New runtime accessor `runtime.messageLedger.getDb()` exposes the existing gateway message ledger for context lookups. Frontend SPA in `extensions/complaint-listener/ui/` uses `@minion-stack/plugin-ui-bridge` to handshake with the hub and talks to the gateway over WS for config + recents.

**Tech Stack:** Node 22 `node:sqlite`, TypeScript strict, vitest, oxlint+oxfmt, Vite 6 + Svelte 5 + TS + Tailwind 4 (SPA), `@minion-stack/plugin-ui-bridge@0.1.0` (from Phase A).

**Source spec:** `docs/superpowers/specs/2026-05-12-complaint-listener-plugin-host-redesign-design.md` Phase B (§B.1–B.5).

**Prerequisite:** Phase A must be merged + deployed first. Reference base SHA after Phase A merge.

**Working directory:** New worktree off `origin/DEV` (post Phase A merge), branch `feat/phase-b-complaint-listener-consumer`.

---

## File Structure

```
extensions/complaint-listener/
├── index.ts                       (REWRITE: dual-hook, dedup, drop opt-in)
├── index.test.ts                  (REWRITE: new wiring tests)
├── minion.plugin.json             (MODIFY: configSchema + ui[])
├── package.json                   (MODIFY: + ui workspace ref)
└── src/
    ├── classifier.ts              (EXTEND: history-aware)
    ├── classifier.test.ts
    ├── context.ts                 (NEW: ledger reader)
    ├── context.test.ts
    ├── dedup.ts                   (NEW: bounded LRU)
    ├── dedup.test.ts
    ├── destinations.ts            (NEW: N-channel fan-out)
    ├── destinations.test.ts
    ├── notifier.ts                (DELETE — replaced by destinations.ts)
    ├── notifier.test.ts           (DELETE)
    ├── store.ts                   (MODIFY: + chat_id col, - summary_state)
    ├── store.test.ts
    ├── subscriptions.ts           (NEW: sub matching)
    ├── subscriptions.test.ts
    ├── summary.ts                 (DELETE)
    ├── summary.test.ts            (DELETE)
    ├── types.ts                   (MODIFY: + ComplaintHistoryEntry)
    └── ui/                        (NEW SPA workspace)
        ├── package.json
        ├── tsconfig.json
        ├── vite.config.ts
        ├── tailwind.config.cjs
        ├── postcss.config.cjs
        ├── index.html
        ├── src/
        │   ├── main.ts
        │   ├── App.svelte
        │   ├── lib/
        │   │   ├── bridge.ts      (PluginBridge wiring)
        │   │   ├── rpc.ts         (WS client)
        │   │   ├── stores.ts      (Svelte 5 state)
        │   │   └── …
        │   └── app.css
        └── dist/                  (gitignored at workspace level, committed at repo level via files glob)

src/gateway/server-methods/
├── complaints.ts                  (NEW: plugins.complaints.recent RPC)
└── complaints.test.ts             (NEW)

src/gateway/server.impl.ts         (MODIFY: register complaints.recent)
src/plugins/runtime/types.ts       (MODIFY: + messageLedger.getDb on runtime)
src/plugins/runtime/build.ts       (or equivalent: wire messageLedger accessor)
src/config/zod-schema.agent-runtime.ts  (MODIFY: revert listeners field from PR #127)
src/config/zod-schema.agent-runtime.test.ts  (MODIFY: drop the field test if present)

package.json                       (MODIFY: + extensions/*/ui/dist/** in files)
```

---

## Task 1: Revert PR #127 `listeners` Zod field

**Files:**
- Modify: `src/config/zod-schema.agent-runtime.ts`

- [ ] **Step 1.1: Locate the listeners block**

Run: `grep -n "listeners:" src/config/zod-schema.agent-runtime.ts`

Confirm there is a block matching:

```ts
    listeners: z
      .object({
        complaints: z.boolean().optional(),
      })
      .strict()
      .optional(),
```

- [ ] **Step 1.2: Remove the block**

Edit the file to remove those 6 lines exactly. Leave the surrounding `capabilities` block and the closing `.strict();` untouched.

- [ ] **Step 1.3: Run config tests; verify pass**

Run: `pnpm vitest run src/config/`

Expected: green. If a test referenced `listeners`, it was added in PR #127's commit — likely none, but search:

Run: `grep -rn "listeners.*complaints" src/config/ 2>/dev/null`

If a test exists, delete it.

- [ ] **Step 1.4: Commit**

```bash
git add src/config/zod-schema.agent-runtime.ts
git commit -m "revert: drop listeners.complaints agent schema field

Phase B replaces YAML opt-in with declarative pluginConfig.
Reverts the schema change introduced in PR #127.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 2: Add `messageLedger.getDb()` to plugin runtime

**Files:**
- Modify: `src/plugins/runtime/types.ts`
- Modify: `src/plugins/runtime/build.ts` (or whatever assembles the runtime object — find via grep)

- [ ] **Step 2.1: Find runtime assembly**

Run: `grep -rln "channel:.*telegram\|sendMessageTelegram" src/plugins/runtime/ | head -5`

Read the file that constructs `runtime.channel`. There will be a sibling place to add `runtime.messageLedger`.

- [ ] **Step 2.2: Write failing test**

Create or modify `src/plugins/runtime/runtime.test.ts`:

```ts
import { describe, it, expect, vi } from "vitest";
import { buildPluginRuntime } from "./build.js";  // or actual path
import { openMessageLedger, closeMessageLedger } from "../../infra/message-ledger.js";
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";

describe("runtime.messageLedger", () => {
  it("getDb returns DatabaseSync when ledger is open", async () => {
    const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "rt-ml-"));
    const dbPath = path.join(tmp, "ml.db");
    openMessageLedger(dbPath);
    try {
      const runtime = buildPluginRuntime({} as any);
      const db = runtime.messageLedger.getDb();
      expect(db).not.toBeNull();
    } finally {
      closeMessageLedger();
      await fs.rm(tmp, { recursive: true, force: true });
    }
  });

  it("getDb returns null when ledger is closed", () => {
    closeMessageLedger();
    const runtime = buildPluginRuntime({} as any);
    expect(runtime.messageLedger.getDb()).toBeNull();
  });
});
```

- [ ] **Step 2.3: Run; verify fail**

Run: `pnpm vitest run src/plugins/runtime/runtime.test.ts`

Expected: 2 failures — either property missing or wrong return type.

- [ ] **Step 2.4: Extend `PluginRuntime` type**

In `src/plugins/runtime/types.ts`, find the top-level `PluginRuntime` (or similar) interface. Add:

```ts
  messageLedger: {
    getDb: typeof import("../../infra/message-ledger.js").getMessageLedgerDb;
  };
```

- [ ] **Step 2.5: Wire it in the runtime builder**

In the assembly file (e.g. `src/plugins/runtime/build.ts`), import and attach:

```ts
import { getMessageLedgerDb } from "../../infra/message-ledger.js";
// ...
const runtime: PluginRuntime = {
  // ...existing fields...
  messageLedger: {
    getDb: getMessageLedgerDb,
  },
};
```

- [ ] **Step 2.6: Run tests; verify pass**

Expected: 2/2 pass.

- [ ] **Step 2.7: Commit**

```bash
git add src/plugins/runtime/types.ts src/plugins/runtime/build.ts src/plugins/runtime/runtime.test.ts
git commit -m "feat(plugin-runtime): expose messageLedger.getDb for context lookups"
```

---

## Task 3: `subscriptions.ts` — match functions

**Files:**
- Create: `extensions/complaint-listener/src/subscriptions.ts`
- Create: `extensions/complaint-listener/src/subscriptions.test.ts`

- [ ] **Step 3.1: Write failing tests** (Appendix A)

- [ ] **Step 3.2: Run; verify fail**

Run: `pnpm vitest run extensions/complaint-listener/src/subscriptions.test.ts`

Expected: module not found.

- [ ] **Step 3.3: Implement** (Appendix B)

- [ ] **Step 3.4: Run; verify pass**

Expected: 8/8 pass.

- [ ] **Step 3.5: Commit**

```bash
git add extensions/complaint-listener/src/subscriptions.ts extensions/complaint-listener/src/subscriptions.test.ts
git commit -m "feat(complaint-listener): subscription matching (channel/agent/catch-all)"
```

---

## Task 4: `dedup.ts` — bounded LRU `seen` set

**Files:**
- Create: `extensions/complaint-listener/src/dedup.ts`
- Create: `extensions/complaint-listener/src/dedup.test.ts`

- [ ] **Step 4.1: Write failing tests**

```ts
import { describe, it, expect } from "vitest";
import { createDedup } from "./dedup.js";

describe("createDedup", () => {
  it("first markSeen returns false (not seen before)", () => {
    const d = createDedup(10);
    expect(d.markSeen("a")).toBe(false);
  });

  it("second markSeen for same id returns true", () => {
    const d = createDedup(10);
    d.markSeen("a");
    expect(d.markSeen("a")).toBe(true);
  });

  it("FIFO eviction past capacity", () => {
    const d = createDedup(3);
    d.markSeen("a");
    d.markSeen("b");
    d.markSeen("c");
    d.markSeen("d");
    expect(d.markSeen("a")).toBe(false);
    expect(d.markSeen("d")).toBe(true);
  });

  it("undefined messageId always returns false (not deduped)", () => {
    const d = createDedup(10);
    expect(d.markSeen(undefined)).toBe(false);
    expect(d.markSeen(undefined)).toBe(false);
  });
});
```

- [ ] **Step 4.2: Run; verify fail**

Run: `pnpm vitest run extensions/complaint-listener/src/dedup.test.ts`

Expected: module not found.

- [ ] **Step 4.3: Implement**

```ts
export interface Dedup {
  markSeen(id: string | undefined): boolean;
}

export function createDedup(capacity: number): Dedup {
  const order: string[] = [];
  const set = new Set<string>();
  return {
    markSeen(id: string | undefined): boolean {
      if (id === undefined) return false;
      if (set.has(id)) return true;
      set.add(id);
      order.push(id);
      if (order.length > capacity) {
        const evict = order.shift();
        if (evict !== undefined) set.delete(evict);
      }
      return false;
    },
  };
}
```

- [ ] **Step 4.4: Run; verify pass**

Expected: 4/4 pass.

- [ ] **Step 4.5: Commit**

```bash
git add extensions/complaint-listener/src/dedup.ts extensions/complaint-listener/src/dedup.test.ts
git commit -m "feat(complaint-listener): bounded LRU dedup for messageId"
```

---

## Task 5: `context.ts` — ledger reader

**Files:**
- Create: `extensions/complaint-listener/src/context.ts`
- Create: `extensions/complaint-listener/src/context.test.ts`

- [ ] **Step 5.1: Write failing tests** (Appendix C — includes a fixture that writes rows to the ledger DB then reads them back)

- [ ] **Step 5.2: Run; verify fail**

Expected: module not found.

- [ ] **Step 5.3: Implement** (Appendix D)

- [ ] **Step 5.4: Run; verify pass**

Expected: 5/5 pass.

- [ ] **Step 5.5: Commit**

```bash
git add extensions/complaint-listener/src/context.ts extensions/complaint-listener/src/context.test.ts
git commit -m "feat(complaint-listener): ledger-backed context reader (last N messages per peer)"
```

---

## Task 6: `store.ts` — add `chat_id`, drop `summary_state`

**Files:**
- Modify: `extensions/complaint-listener/src/store.ts`
- Modify: `extensions/complaint-listener/src/store.test.ts`

- [ ] **Step 6.1: Write failing tests for new shape**

Append to store.test.ts:

```ts
  it("complaints table has chat_id column", () => {
    const cols = db.prepare("PRAGMA table_info(complaints)").all() as Array<{ name: string }>;
    expect(cols.find((c) => c.name === "chat_id")).toBeDefined();
  });

  it("insertComplaint persists chat_id", () => {
    const id = insertComplaint(db, { ...baseParams, chatId: "tg-chat-42" });
    const row = getComplaintById(db, id);
    expect(row?.chat_id).toBe("tg-chat-42");
  });

  it("getComplaintsByChatId filters and orders DESC", () => {
    insertComplaint(db, { ...baseParams, chatId: "tg-a" });
    insertComplaint(db, { ...baseParams, chatId: "tg-b" });
    const id3 = insertComplaint(db, { ...baseParams, chatId: "tg-a" });
    const rows = getComplaintsByChatId(db, "tg-a", 10);
    expect(rows.map((r) => r.chat_id)).toEqual(["tg-a", "tg-a"]);
    expect(rows[0]?.id).toBe(id3);
  });

  it("summary_state table no longer exists", () => {
    const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
    expect(tables.find((t) => t.name === "summary_state")).toBeUndefined();
  });
```

Update import:

```ts
import {
  initSchema,
  insertComplaint,
  markNotified,
  getPendingNotifications,
  getComplaintById,
  countBySeverity,
  getComplaintsByChatId,
} from "./store.js";
```

Drop the obsolete imports `getSummaryYmd`, `setSummaryYmd`, `getComplaintsSince`. Drop their test cases as well.

Update `baseParams`:

```ts
const baseParams = {
  agentId: "agent-1",
  channelId: "whatsapp",
  chatId: "wa-+15551234567",
  fromSender: "+15551234567",
  content: "Your product is broken!",
  result: { /* unchanged */ },
};
```

- [ ] **Step 6.2: Run; verify fail**

Run: `pnpm vitest run extensions/complaint-listener/src/store.test.ts`

Expected: failures for missing `chat_id`, missing `getComplaintsByChatId`, removed exports.

- [ ] **Step 6.3: Rewrite store.ts** (Appendix E)

- [ ] **Step 6.4: Run; verify pass**

Expected: all store tests green.

- [ ] **Step 6.5: Commit**

```bash
git add extensions/complaint-listener/src/store.ts extensions/complaint-listener/src/store.test.ts
git commit -m "feat(complaint-listener): store add chat_id + getComplaintsByChatId, drop summary_state"
```

---

## Task 7: `destinations.ts` — N-channel fan-out

**Files:**
- Create: `extensions/complaint-listener/src/destinations.ts`
- Create: `extensions/complaint-listener/src/destinations.test.ts`
- Delete: `extensions/complaint-listener/src/notifier.ts`
- Delete: `extensions/complaint-listener/src/notifier.test.ts`

- [ ] **Step 7.1: Write failing tests** (Appendix F — covers WA, TG, Discord channel send; empty channel skip; one-fail-other-succeed; all-fail; unknown channel logs warn)

- [ ] **Step 7.2: Run; verify fail**

Expected: module not found.

- [ ] **Step 7.3: Implement** (Appendix G)

- [ ] **Step 7.4: Delete notifier.{ts,test.ts}**

```bash
git rm extensions/complaint-listener/src/notifier.ts extensions/complaint-listener/src/notifier.test.ts
```

- [ ] **Step 7.5: Run; verify pass**

Expected: 7/7 destinations tests pass.

- [ ] **Step 7.6: Commit**

```bash
git add extensions/complaint-listener/src/destinations.ts extensions/complaint-listener/src/destinations.test.ts
git commit -m "feat(complaint-listener): destinations.ts N-channel fan-out replaces notifier.ts"
```

---

## Task 8: Extend `classifier.ts` for conversation history

**Files:**
- Modify: `extensions/complaint-listener/src/classifier.ts`
- Modify: `extensions/complaint-listener/src/classifier.test.ts`
- Modify: `extensions/complaint-listener/src/types.ts`

- [ ] **Step 8.1: Add `ComplaintHistoryEntry` type**

In `types.ts`, append:

```ts
export interface ComplaintHistoryEntry {
  role: "user" | "agent";
  content: string;
  senderId?: string;
  ts: number;
}
```

- [ ] **Step 8.2: Write failing tests for history-aware classifier** (Appendix H)

- [ ] **Step 8.3: Run; verify fail**

Run: `pnpm vitest run extensions/complaint-listener/src/classifier.test.ts`

Expected: signature failures.

- [ ] **Step 8.4: Update classifier.ts** (Appendix I — accepts history array, builds Anthropic messages, keeps TypeBox result schema)

- [ ] **Step 8.5: Run; verify pass**

Expected: all classifier tests pass.

- [ ] **Step 8.6: Commit**

```bash
git add extensions/complaint-listener/src/classifier.ts extensions/complaint-listener/src/classifier.test.ts extensions/complaint-listener/src/types.ts
git commit -m "feat(complaint-listener): classifier accepts conversation history"
```

---

## Task 9: Rewrite `index.ts` — dual hook with dedup, config-driven

**Files:**
- Modify: `extensions/complaint-listener/index.ts`
- Modify: `extensions/complaint-listener/index.test.ts`
- Modify: `extensions/complaint-listener/minion.plugin.json`
- Delete: `extensions/complaint-listener/src/summary.ts`
- Delete: `extensions/complaint-listener/src/summary.test.ts`

- [ ] **Step 9.1: Rewrite index.test.ts** (Appendix J — covers: subscription matching dispatch, dedup across both hooks, classifier-history wiring, fan-out, mark-notified-on-any-success, ledger-disabled warn)

- [ ] **Step 9.2: Run; verify fail**

Expected: all-red.

- [ ] **Step 9.3: Update `minion.plugin.json`** (Appendix K)

- [ ] **Step 9.4: Rewrite index.ts** (Appendix L)

- [ ] **Step 9.5: Delete summary.{ts,test.ts}**

```bash
git rm extensions/complaint-listener/src/summary.ts extensions/complaint-listener/src/summary.test.ts
```

- [ ] **Step 9.6: Run; verify pass**

Expected: all index tests pass.

- [ ] **Step 9.7: Commit**

```bash
git add extensions/complaint-listener/index.ts extensions/complaint-listener/index.test.ts extensions/complaint-listener/minion.plugin.json
git commit -m "feat(complaint-listener): rewrite index.ts as dual-hook observer with dedup + subs"
```

---

## Task 10: `plugins.complaints.recent` RPC

**Files:**
- Create: `src/gateway/server-methods/complaints.ts`
- Create: `src/gateway/server-methods/complaints.test.ts`
- Modify: `src/gateway/server.impl.ts` (register)

- [ ] **Step 10.1: Write failing tests** (Appendix M)

- [ ] **Step 10.2: Run; verify fail**

Expected: module not found.

- [ ] **Step 10.3: Implement complaints.ts** (Appendix N — opens the plugin's own complaints.db, reads rows, returns)

- [ ] **Step 10.4: Register in server.impl.ts**

Add adjacent to the existing `plugins.*` method registrations (Phase A's commit):

```ts
import { pluginsComplaintsRecent } from "./server-methods/complaints.js";
// ...
server.method("plugins.complaints.recent", pluginsComplaintsRecent);
```

- [ ] **Step 10.5: Run; verify pass**

Run: `pnpm vitest run src/gateway/server-methods/complaints.test.ts`

Expected: 4/4 pass.

- [ ] **Step 10.6: Commit**

```bash
git add src/gateway/server-methods/complaints.ts src/gateway/server-methods/complaints.test.ts src/gateway/server.impl.ts
git commit -m "feat(gateway): plugins.complaints.recent RPC"
```

---

## Task 11: Scaffold `extensions/complaint-listener/ui/` SPA

**Files:**
- Create: `extensions/complaint-listener/ui/package.json`
- Create: `extensions/complaint-listener/ui/tsconfig.json`
- Create: `extensions/complaint-listener/ui/vite.config.ts`
- Create: `extensions/complaint-listener/ui/tailwind.config.cjs`
- Create: `extensions/complaint-listener/ui/postcss.config.cjs`
- Create: `extensions/complaint-listener/ui/index.html`
- Create: `extensions/complaint-listener/ui/src/main.ts`
- Create: `extensions/complaint-listener/ui/src/App.svelte`
- Create: `extensions/complaint-listener/ui/src/app.css`
- Create: `extensions/complaint-listener/ui/.gitignore` (just `node_modules/`)

- [ ] **Step 11.1: Files for scaffold** (Appendix O — package.json, vite.config.ts, tsconfig.json, tailwind config, index.html, main.ts, app.css, minimal App.svelte)

- [ ] **Step 11.2: Install + build smoke**

```bash
pnpm --filter ./extensions/complaint-listener/ui install
pnpm --filter ./extensions/complaint-listener/ui run build
ls extensions/complaint-listener/ui/dist/
```

Expected: `dist/index.html` + `dist/assets/*.js` + `dist/assets/*.css` exist.

- [ ] **Step 11.3: Commit**

```bash
git add extensions/complaint-listener/ui/package.json extensions/complaint-listener/ui/tsconfig.json extensions/complaint-listener/ui/vite.config.ts extensions/complaint-listener/ui/tailwind.config.cjs extensions/complaint-listener/ui/postcss.config.cjs extensions/complaint-listener/ui/index.html extensions/complaint-listener/ui/src extensions/complaint-listener/ui/.gitignore
git commit -m "feat(complaint-listener-ui): scaffold Vite + Svelte 5 + Tailwind SPA"
```

---

## Task 12: SPA — bridge + RPC client + state stores

**Files:**
- Create: `extensions/complaint-listener/ui/src/lib/bridge.ts`
- Create: `extensions/complaint-listener/ui/src/lib/rpc.ts`
- Create: `extensions/complaint-listener/ui/src/lib/stores.ts`

- [ ] **Step 12.1: Write bridge.ts** (Appendix P — uses `PluginBridge` from `@minion-stack/plugin-ui-bridge`, exposes a singleton + theme reactive store)

- [ ] **Step 12.2: Write rpc.ts** (Appendix Q — thin WS JSON-RPC client; methods: `plugins.config.get`, `plugins.config.set`, `plugins.complaints.recent`)

- [ ] **Step 12.3: Write stores.ts** (Appendix R — Svelte 5 `$state` runes for config + occupants + recents)

- [ ] **Step 12.4: Add bridge dep**

Edit `extensions/complaint-listener/ui/package.json`:

```json
"dependencies": {
  "@minion-stack/plugin-ui-bridge": "workspace:*",
  "svelte": "^5.0.0"
}
```

Run: `pnpm --filter ./extensions/complaint-listener/ui install`

- [ ] **Step 12.5: Commit**

```bash
git add extensions/complaint-listener/ui/src/lib extensions/complaint-listener/ui/package.json
git commit -m "feat(complaint-listener-ui): bridge + RPC client + state stores"
```

---

## Task 13: SPA — Subscriptions + Destinations + Classifier panels

**Files:**
- Create: `extensions/complaint-listener/ui/src/lib/SubscriptionsPanel.svelte`
- Create: `extensions/complaint-listener/ui/src/lib/DestinationsPanel.svelte`
- Create: `extensions/complaint-listener/ui/src/lib/ClassifierPanel.svelte`
- Modify: `extensions/complaint-listener/ui/src/App.svelte`

- [ ] **Step 13.1: SubscriptionsPanel** (Appendix S — Svelte 5; renders sub list, [+ Add] dialog with three tabs for channel/agent/catch-all; channel & account & agent options pulled from props passed by App)

- [ ] **Step 13.2: DestinationsPanel** (Appendix T — list + add dialog; same channel/account pickers + `to` text input + optional label)

- [ ] **Step 13.3: ClassifierPanel** (Appendix U — model dropdown, contextWindow number, minSeverity dropdown)

- [ ] **Step 13.4: Wire into App.svelte** (Appendix V — top-level layout: header + Subscriptions + Destinations + Classifier + Save button; binds to stores; uses bridge to await `host:hello` before fetching config)

- [ ] **Step 13.5: Visual smoke**

```bash
pnpm --filter ./extensions/complaint-listener/ui run dev
```

Open browser to printed URL; verify layout renders even if RPC fails (mocked when no host:hello).

- [ ] **Step 13.6: Commit**

```bash
git add extensions/complaint-listener/ui/src
git commit -m "feat(complaint-listener-ui): SubscriptionsPanel, DestinationsPanel, ClassifierPanel"
```

---

## Task 14: SPA — Recent complaints panel

**Files:**
- Create: `extensions/complaint-listener/ui/src/lib/RecentsPanel.svelte`
- Modify: `extensions/complaint-listener/ui/src/App.svelte`

- [ ] **Step 14.1: RecentsPanel.svelte** (Appendix W — table of last N rows from `plugins.complaints.recent`; columns: timestamp, severity badge, category, sender, summary, delivered)

- [ ] **Step 14.2: Wire into App.svelte**

Append below the Save button:

```svelte
<section class="mt-8">
  <RecentsPanel recents={$recents} onRefresh={refreshRecents} />
</section>
```

And in the script:

```ts
const recents = $derived(/* fetched on mount via rpc.pluginsComplaintsRecent */);
```

- [ ] **Step 14.3: Visual smoke**

Verify table renders with mocked rows.

- [ ] **Step 14.4: Commit**

```bash
git add extensions/complaint-listener/ui/src/lib/RecentsPanel.svelte extensions/complaint-listener/ui/src/App.svelte
git commit -m "feat(complaint-listener-ui): Recent complaints panel"
```

---

## Task 15: SPA build + dist commit + files glob

**Files:**
- Build: `extensions/complaint-listener/ui/dist/`
- Modify: root `package.json`

- [ ] **Step 15.1: Build the SPA**

```bash
pnpm --filter ./extensions/complaint-listener/ui run build
```

Expected: `extensions/complaint-listener/ui/dist/index.html` + `assets/*` exist.

- [ ] **Step 15.2: Update `extensions/complaint-listener/ui/.gitignore` to NOT ignore dist**

Verify `.gitignore` only has `node_modules/`. Do not ignore `dist`. (Root `.gitignore` likely ignores `dist/` globally — verify and add a negation override if needed.)

Run: `grep -E "^dist|^!.*dist" .gitignore`

If `dist` is globally ignored, add to repo root `.gitignore`:

```
# Allow plugin UI dists to be committed
!extensions/*/ui/dist/
!extensions/*/ui/dist/**
```

- [ ] **Step 15.3: Add files glob to root package.json**

Find the `files` array in root `package.json`. Add:

```json
"files": [
  // existing entries...
  "extensions/*/ui/dist/**"
]
```

- [ ] **Step 15.4: Stage dist + commit**

```bash
git add extensions/complaint-listener/ui/dist .gitignore package.json
git commit -m "build(complaint-listener-ui): commit dist + extend npm files glob"
```

---

## Task 16: Manifest finalization

**Files:**
- Modify: `extensions/complaint-listener/minion.plugin.json`

- [ ] **Step 16.1: Final manifest**

Overwrite with:

```json
{
  "id": "complaint-listener",
  "kind": "plugin",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "subscriptions": {
        "type": "array",
        "default": [],
        "items": {
          "oneOf": [
            {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "kind": { "const": "channel" },
                "channel": { "type": "string" },
                "accountId": { "type": "string" }
              },
              "required": ["kind", "channel"]
            },
            {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "kind": { "const": "agent" },
                "agentId": { "type": "string" }
              },
              "required": ["kind", "agentId"]
            },
            {
              "type": "object",
              "additionalProperties": false,
              "properties": { "kind": { "const": "channel-catchall" } },
              "required": ["kind"]
            }
          ]
        }
      },
      "destinations": {
        "type": "array",
        "default": [],
        "items": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "channel": { "type": "string" },
            "accountId": { "type": "string" },
            "to": { "type": "string" },
            "label": { "type": "string" }
          },
          "required": ["channel", "to"]
        }
      },
      "classifier": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "model": { "type": "string" },
          "contextWindow": { "type": "integer", "minimum": 1, "maximum": 20 },
          "minSeverity": { "enum": ["low", "med", "high"] }
        }
      }
    }
  },
  "ui": [
    {
      "slot": "settings.plugins",
      "title": "Complaint Listener",
      "description": "Watch channels, classify complaints, fan out alerts.",
      "entrypoint": "ui/dist/index.html",
      "icon": "MegaphoneSimple"
    }
  ]
}
```

- [ ] **Step 16.2: Commit**

```bash
git add extensions/complaint-listener/minion.plugin.json
git commit -m "feat(complaint-listener): finalize manifest with pluginConfig + ui[]"
```

---

## Task 17: Full sweep — vitest + tsgo + lint

- [ ] **Step 17.1: Vitest**

```bash
pnpm vitest run extensions/complaint-listener src/gateway/server-methods/complaints src/plugins/runtime/runtime src/config/zod-schema.agent-runtime
```

Expected: all green.

- [ ] **Step 17.2: Tsgo**

```bash
pnpm tsgo 2>&1 | grep -E "complaint-listener|server-methods/complaints|runtime/" | head -20
```

Expected: zero errors in those paths.

- [ ] **Step 17.3: Lint**

```bash
pnpm lint extensions/complaint-listener/ src/gateway/server-methods/complaints.ts src/plugins/runtime/
```

Expected: 0/0.

- [ ] **Step 17.4: SPA type-check**

```bash
pnpm --filter ./extensions/complaint-listener/ui exec tsc --noEmit
```

Expected: clean.

- [ ] **Step 17.5: Fix any failures in the smallest task involved; rerun.**

---

## Task 18: PR + migration runbook

- [ ] **Step 18.1: Push branch**

```bash
git push -u origin HEAD
```

- [ ] **Step 18.2: Open PR**

```bash
gh pr create --base DEV --title "feat(complaint-listener): Phase B consumer of plugin UI host" --body "$(cat <<'BODY'
## Summary
- Refactor extensions/complaint-listener/ to declarative pluginConfig.
- Drop YAML opt-in (reverts PR #127), env-var configuration, daily digest, summary_state, isOptedIn, notifier.ts.
- Add subscriptions.ts / dedup.ts / context.ts / destinations.ts.
- Extend classifier.ts for conversation history; store.ts gains chat_id + getComplaintsByChatId.
- New plugins.complaints.recent RPC.
- Self-contained Vite + Svelte 5 SPA in extensions/complaint-listener/ui/ that mounts into Phase A's settings.plugins slot.
- Manifest finalized with new configSchema + ui[] entry.

Spec: docs/superpowers/specs/2026-05-12-complaint-listener-plugin-host-redesign-design.md
Depends on: Phase A merged + @minion-stack/plugin-ui-bridge@0.1.0 published.

## Test plan
- [x] pnpm vitest run (all green per Task 17)
- [x] pnpm tsgo (clean)
- [x] pnpm lint (clean)
- [x] pnpm --filter ./extensions/complaint-listener/ui exec tsc --noEmit
- [ ] bot-prd migration per Task 19 then smoke checklist per spec §B.5

🤖 Generated with [Claude Code](https://claude.com/claude-code)
BODY
)"
```

- [ ] **Step 18.3: Watch CI and merge**

```bash
gh pr checks --watch
gh pr merge --squash --admin
```

---

## Task 19: bot-prd migration + smoke

> NOT part of TDD execution — confirm with user before running.

- [ ] **Step 19.1: Install new dev build**

```bash
ssh niko@100.80.222.29 "sudo -u bot-prd npm install -g @nikolasp98/minion@dev"
```

- [ ] **Step 19.2: Delete stale Infisical secrets**

Via Infisical UI at http://100.80.222.29:8080 → project `minion-gateway` → env `prod`. Delete:
- `COMPLAINTS_NOTIFY_TELEGRAM_CHAT_ID`
- `COMPLAINTS_NOTIFY_WHATSAPP_TO`
- `COMPLAINTS_SUMMARY_WHATSAPP_TO`
- `COMPLAINTS_SUMMARY_HOUR`
- `COMPLAINTS_TEST_WRITE`

- [ ] **Step 19.3: Enable message ledger + set pluginConfig**

```bash
ssh niko@100.80.222.29 "sudo -u bot-prd bash -c '
  cp ~/.minion/gateway.json ~/.minion/gateway.json.bak-pre-phase-b
  jq \"
    .gateway.messageLedger.enabled = true
    | (.agents.list[] | select(.id==\\\"panik\\\")) |= del(.listeners)
    | .plugins.entries[\\\"complaint-listener\\\"] = {
        enabled: true,
        config: {
          subscriptions: [
            { kind: \\\"channel\\\", channel: \\\"telegram\\\", accountId: \\\"default\\\" }
          ],
          destinations: [
            { channel: \\\"whatsapp\\\", accountId: \\\"default\\\", to: \\\"+51922286663\\\", label: \\\"Niko WA\\\" }
          ]
        }
      }
  \" ~/.minion/gateway.json > /tmp/gw.json && mv /tmp/gw.json ~/.minion/gateway.json
'"
```

- [ ] **Step 19.4: Restart gateway**

```bash
ssh niko@100.80.222.29 "sudo XDG_RUNTIME_DIR=/run/user/\$(id -u bot-prd) -u bot-prd systemctl --user restart minion-gateway"
```

- [ ] **Step 19.5: Verify plugin boot in logs**

```bash
ssh niko@100.80.222.29 "sudo XDG_RUNTIME_DIR=/run/user/\$(id -u bot-prd) -u bot-prd journalctl --user -u minion-gateway --since '30 seconds ago' --no-pager | grep -E 'complaint-listener|message_inbound'"
```

Expect: `complaint-listener: initialized db at ...`, `complaint-listener: subscriptions=N destinations=M`.

- [ ] **Step 19.6: Hub UI check**

Open hub `/settings/plugins`. Confirm "Complaint Listener" tab is present with the iframe loaded; subscriptions + destinations show the values from gateway.json.

- [ ] **Step 19.7: Smoke**

Per spec §B.5:

- [ ] Send a complainy TG DM to `@snifflebottom_bot` from a non-staff TG account.
- [ ] WA alert at +51 922 286 663 lands within ~5s.
- [ ] Send a benign TG DM. No alert.
- [ ] Send 4 benigns + 1 complaint. Alert shows the 4 priors as context.
- [ ] Add second destination via UI. Send complaint. Both destinations alerted.
- [ ] Toggle plugin off in UI. Restart. No plugin logs on inbound.

---

## Self-Review

- **Spec coverage:** §B.1 → Tasks 1-9. §B.2 → Task 10. §B.3 → Tasks 11-15. §B.4 → Task 16. §B.5 → Task 19.
- **Placeholders:** none — every step has exact files, commands, and an appendix pointer for long source bodies.
- **Type consistency:** `ComplaintHistoryEntry` (Task 8) is consumed by `context.ts` (Task 5) and `classifier.ts` (Task 8). Resolved by ordering: Task 5 imports the type but the type isn't created until Task 8. **Fix:** move the type addition to Task 5 (or Task 3 earlier). I'll fix this inline:

**Fix applied:** Task 5 Step 5.3 must also append `ComplaintHistoryEntry` to `types.ts`. Update Appendix D accordingly. Task 8 Step 8.1 then becomes "verify ComplaintHistoryEntry exists in types.ts; skip if already there."

## Appendices reference

Long source bodies (A through W) live in companion file `docs/superpowers/plans/phase-b-appendices.md`.
