# Alert-watcher interactive reply flow — 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:** Let owners reply to alert-watcher WhatsApp alerts with a digit (preset 1/2/3) or freeform text, and forward that reply to the original complainer on the origin channel.

**Architecture:** Add one SQLite table (`pending_replies`), one new drone (`suggestionsDrone`), one new whatsapp `message_inbound` hook with CAS claim semantics, and a body formatter that renders the 3 suggestions inline with the existing alert. First owner to reply wins; losing owners get a courtesy DM.

**Tech Stack:** TypeScript, `node:sqlite`, `@nikolasp98/drone` (TypeBox schema-only), `@sinclair/typebox`, vitest. All work lives under `minion/extensions/alert-watcher/`.

**Spec:** `minion/extensions/alert-watcher/docs/2026-05-18-interactive-reply-flow-design.md`

---

## File Structure

**New files** (all under `minion/extensions/alert-watcher/`):
- `src/reply-flow/types.ts` — `PendingReply`, `ReplyKind`, `ReplyFlowConfig`, `Suggestions` types.
- `src/reply-flow/parser.ts` — `parseReplySelection(message, count)`: preset/freeform discriminated union.
- `src/reply-flow/parser.test.ts`
- `src/reply-flow/suggestions-drone.ts` — `suggestionsDrone` (defineDrone) + `runSuggestionsDrone` wrapper.
- `src/reply-flow/suggestions-drone.test.ts`
- `src/reply-flow/store.ts` — `initReplySchema`, `insertPending`, `findOpenPendingForSender`, `claimPending` (CAS), `markForwarded`, `releasePending`.
- `src/reply-flow/store.test.ts`
- `src/reply-flow/format.ts` — `appendSuggestionsBlock(existingBody, suggestions, language)`.
- `src/reply-flow/format.test.ts`
- `src/reply-flow/forward.ts` — `forwardToOrigin(api, channel, accountId, chatId, text)`.
- `src/reply-flow/handle-reply.ts` — top-level handler invoked from the new whatsapp `message_inbound` hook.
- `src/reply-flow/handle-reply.test.ts`

**Modified files**:
- `index.ts` — call `initReplySchema`, run `runSuggestionsDrone` after classification, insert pending row, register whatsapp `message_inbound` hook calling `handleOwnerReply`, format alert with suggestions.
- `minion.plugin.json` — add `replyFlow` config block to `configSchema`.

**Test seeding pattern**: Each `*.test.ts` uses a fresh in-memory `DatabaseSync(":memory:")` per test.

---

## Task 1: Types + schema migration

**Files:**
- Create: `src/reply-flow/types.ts`
- Create: `src/reply-flow/store.ts`
- Create: `src/reply-flow/store.test.ts`

- [ ] **Step 1: Write failing test for schema init**

The test verifies the table exists with required columns and that the partial index `idx_pending_open` is created. Two cases: schema creation, idempotent re-call. Test imports from `./store.js` and `./types.js`. Use `DatabaseSync(":memory:")`. Required columns to assert: `alert_id`, `origin_chat_id`, `destinations_json`, `suggestions_json`, `expires_at`, `claimed_by`, `forwarded_ok`. Index name: `idx_pending_open`.

- [ ] **Step 2: Run test, expect FAIL**

Run: `cd minion/extensions/alert-watcher && pnpm vitest run src/reply-flow/store.test.ts`
Expected: module-not-found error for `./store.js`.

- [ ] **Step 3: Write types module**

`src/reply-flow/types.ts` exports:
- `ReplyKind = "preset_1" | "preset_2" | "preset_3" | "freeform"`
- `PendingReplyRow` interface (id, alert_id, origin_channel, origin_account_id|null, origin_chat_id, destinations_json string, suggestions_json string, language|null, expires_at, claimed_by|null, claimed_at|null, reply_text|null, reply_kind|null, forwarded_ok|null, created_at)
- `ReplyFlowConfig` interface (enabled boolean, windowMs number, notifyLoser boolean, suggestions {enabled, count, maxChars})
- `DEFAULT_REPLY_FLOW_CONFIG` const: `{enabled:true, windowMs:3_600_000, notifyLoser:true, suggestions:{enabled:true, count:3, maxChars:140}}`

- [ ] **Step 4: Write schema init**

`src/reply-flow/store.ts` exports `initReplySchema(db: DatabaseSync): void` that runs a CREATE TABLE IF NOT EXISTS statement for `pending_replies` with columns:
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `alert_id INTEGER NOT NULL REFERENCES complaints(id)`
- `origin_channel TEXT NOT NULL`
- `origin_account_id TEXT` (nullable)
- `origin_chat_id TEXT NOT NULL`
- `destinations_json TEXT NOT NULL`
- `suggestions_json TEXT NOT NULL`
- `language TEXT` (nullable)
- `expires_at INTEGER NOT NULL`
- `claimed_by TEXT` (nullable)
- `claimed_at INTEGER` (nullable)
- `reply_text TEXT` (nullable)
- `reply_kind TEXT` (nullable)
- `forwarded_ok INTEGER` (nullable)
- `created_at INTEGER NOT NULL`

Plus partial index:
`CREATE INDEX IF NOT EXISTS idx_pending_open ON pending_replies(expires_at) WHERE claimed_by IS NULL;`

Use `db.exec(\`...\`)` for the statement (single statement, idempotent).

- [ ] **Step 5: Run test, expect PASS**

Run: `pnpm vitest run src/reply-flow/store.test.ts`
Expected: 2/2 pass.

- [ ] **Step 6: Commit**

```
git add src/reply-flow/types.ts src/reply-flow/store.ts src/reply-flow/store.test.ts
git commit -m "feat(alert-watcher): pending_replies schema + reply-flow types"
```

---

## Task 2: Number-vs-freeform parser

**Files:**
- Create: `src/reply-flow/parser.ts`
- Create: `src/reply-flow/parser.test.ts`

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

`parseReplySelection(message: string, count: number)` returns `{kind:"preset", index:number}` for inputs matching `^[1-3][.)]?$` (after trim) AND index < count; else `{kind:"freeform"}`. Tests cover:
- Preset cases: `"1"` → idx 0, `"2"` → idx 1, `"3"` → idx 2, `" 1 "`, `"1."`, `"2)"`, `"3."`
- Freeform cases: `"option 1"`, `"#1"`, `"1!"`, `"1 actually send Y"`, `"4"`, `"0"`, `""`, `"  "`
- Count clamp: `parseReplySelection("3", 2)` → freeform (count=2 means valid presets are only 1 and 2)

- [ ] **Step 2: Run, expect FAIL**

Module missing.

- [ ] **Step 3: Implement parser**

`src/reply-flow/parser.ts` exports:
- `type ParsedSelection = { kind: "preset"; index: number } | { kind: "freeform" }`
- `function parseReplySelection(message: string, count: number): ParsedSelection`
- Regex: `/^([1-3])[.)]?$/`
- Logic: trim; if empty → freeform; regex match → derive idx = digit-1; if idx >= count → freeform; else preset.

- [ ] **Step 4: Run, expect PASS**

10 test cases.

- [ ] **Step 5: Commit**

```
git add src/reply-flow/parser.ts src/reply-flow/parser.test.ts
git commit -m "feat(alert-watcher): preset-vs-freeform reply parser"
```

---

## Task 3: Suggestions drone

**Files:**
- Create: `src/reply-flow/suggestions-drone.ts`
- Create: `src/reply-flow/suggestions-drone.test.ts`

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

Test 1 (`buildSuggestionsPrompt`): verifies the rendered prompt contains the language code, severity, category, sender message text, system prompt fragment, count, and the string `"exactly 3"` (or matching count value). Pure string test, no LLM.

Test 2 (`runSuggestionsDrone` graceful fallback): mock a DroneHost whose `complete` method rejects, call `runSuggestionsDrone(host, "haiku", validInput)`, assert it resolves to `[]` (empty array, not throw).

- [ ] **Step 2: Run, expect FAIL**

Module missing.

- [ ] **Step 3: Implement drone**

`src/reply-flow/suggestions-drone.ts` exports:
- `SuggestionsInput` interface: `{language, severity:"low"|"med"|"high", category, summary, senderMessage, systemPrompt, count, maxChars}`
- `buildSuggestionsPrompt(input: SuggestionsInput): string` — composes multi-line instruction referencing all fields. Includes hard guidance "Each reply MUST be in language `<language>`, be ≤<maxChars> chars, cover a distinct strategy, no placeholders, generate exactly <count>".
- TypeBox schema `OutputSchema = Type.Object({ replies: Type.Array(Type.String(), {minItems:3, maxItems:3}) })` — note: count is currently locked to 3 because TypeBox `minItems`/`maxItems` are static; if `count` ever needs to be configurable, schema would need to be built dynamically (out of scope here, default count=3).
- `const drone = defineDrone({ id: "alert-watcher-suggestions", outputSchema: OutputSchema })`
- `async function runSuggestionsDrone(host, model, input): Promise<string[]>` — calls `runDrone(drone, {host, model, prompt: buildSuggestionsPrompt(input), maxTokens:500, temperature:0.4})`. Returns `result.data.replies.map(trim).filter(non-empty).slice(0,count)`. On any throw, returns `[]`.

- [ ] **Step 4: Run, expect PASS**

2 tests.

- [ ] **Step 5: Commit**

```
git add src/reply-flow/suggestions-drone.ts src/reply-flow/suggestions-drone.test.ts
git commit -m "feat(alert-watcher): suggestions drone (schema-only TypeBox)"
```

---

## Task 4: Store helpers (insert, find, claim, markForwarded, releasePending)

**Files:**
- Modify: `src/reply-flow/store.ts`
- Modify: `src/reply-flow/store.test.ts`

- [ ] **Step 1: Write lifecycle tests**

Append to `store.test.ts` a `describe("pending_replies lifecycle")` block. Setup helper: create `:memory:` DB, add stub `complaints` table with one row (`CREATE TABLE complaints (id INTEGER PRIMARY KEY AUTOINCREMENT); INSERT ... DEFAULT VALUES;`), call `initReplySchema`. Test cases:

1. **Insert + find returns row before claim** — call `insertPending` with destinations=["+5199","+5188"], `findOpenPendingForSender(db, "+5199", now)` → row found by id.
2. **findOpenPendingForSender ignores expired** — insert with `expires_at = Date.now() - 1000`, lookup returns undefined.
3. **claimPending CAS race** — two consecutive `claimPending(db, id, "+5199", now)` then `claimPending(db, id, "+5188", now)` → first returns true, second returns false.
4. **claimPending refuses expired** — insert with `expires_at = Date.now() - 1`, claim returns false.
5. **markForwarded sets fields** — claim, then `markForwarded(db, id, "preset_1", "ok message", true)`; SELECT row, assert `reply_text="ok message"`, `reply_kind="preset_1"`, `forwarded_ok=1`.
6. **releasePending un-claims for retry** — claim by `+5199`, releasePending, assert `claimed_by IS NULL`, then `claimPending(db, id, "+5188", now)` returns true.

- [ ] **Step 2: Run, expect FAIL** on every new test (functions undefined).

- [ ] **Step 3: Implement helpers**

Append to `src/reply-flow/store.ts`:
- `import type { PendingReplyRow, ReplyKind } from "./types.js";`
- `InsertPendingParams` interface (alertId, originChannel, originAccountId|null, originChatId, destinations:string[], suggestions:string[], language|null, expiresAt:number).
- `function insertPending(db, p: InsertPendingParams): number` — INSERT row with `JSON.stringify(destinations)`, `JSON.stringify(suggestions)`, `created_at = Date.now()`. Returns `Number(run.lastInsertRowid)`.
- `function findOpenPendingForSender(db, sender: string, now: number): PendingReplyRow | undefined` — `SELECT * FROM pending_replies WHERE claimed_by IS NULL AND expires_at > ? ORDER BY id DESC`, iterate rows, parse `destinations_json`, return first row whose array includes `sender`. Comment why we can't use a JSON operator and why full scan of open rows is acceptable (partial index keeps the row count tiny).
- `function claimPending(db, id: number, claimedBy: string, now: number): boolean` — `UPDATE pending_replies SET claimed_by=?, claimed_at=? WHERE id=? AND claimed_by IS NULL AND expires_at > ?`. Return `Number(r.changes) === 1`.
- `function markForwarded(db, id: number, kind: ReplyKind, text: string, ok: boolean): void` — UPDATE reply_text, reply_kind, forwarded_ok columns.
- `function releasePending(db, id: number): void` — UPDATE claimed_by=NULL, claimed_at=NULL.

- [ ] **Step 4: Run, expect PASS** — 2 schema tests + 6 lifecycle tests.

- [ ] **Step 5: Commit**

```
git add src/reply-flow/store.ts src/reply-flow/store.test.ts
git commit -m "feat(alert-watcher): pending_replies store helpers + CAS claim"
```

---

## Task 5: Alert body formatter

**Files:**
- Create: `src/reply-flow/format.ts`
- Create: `src/reply-flow/format.test.ts`

- [ ] **Step 1: Write tests**

`appendSuggestionsBlock(body: string, suggestions: string[], language: string): string`. Test cases:
1. Spanish with 3 suggestions: returns string containing the original body, `"1. uno"`, `"2. dos"`, `"3. tres"`, and Spanish prompt `"Responde con 1/2/3"`.
2. English with 3 suggestions: contains English prompt `"Reply with 1/2/3"`.
3. Empty suggestions: omits numbered list, includes Spanish `"Responde con tu propio mensaje"` (or English equivalent for "en").
4. Partial suggestions (1 item): contains `"1. only"`, does not contain `"2."`.

- [ ] **Step 2: Run, expect FAIL**

- [ ] **Step 3: Implement formatter**

`src/reply-flow/format.ts` exports `appendSuggestionsBlock`. Internal `PROMPT_TEXT` record keyed by language (`"es"`, `"en"`), each value `{header, withSuggestions, freeformOnly}`. If `suggestions.length === 0`: return `${body}\n\n${freeformOnly}`. Else: number each line `${i+1}. ${s}`, join with newlines, return `${body}\n\n${header}\n${numbered}\n\n${withSuggestions}`. Fallback to English if language not in record.

Text content:
- Spanish header: `"Respuestas sugeridas:"`
- Spanish withSuggestions: `"Responde con 1/2/3 o escribe tu propio mensaje."`
- Spanish freeformOnly: `"Responde con tu propio mensaje para responderle al cliente."`
- English header: `"Suggested replies:"`
- English withSuggestions: `"Reply with 1/2/3 or type your own message."`
- English freeformOnly: `"Reply with your own message to respond to the customer."`

- [ ] **Step 4: Run, expect PASS** — 4 tests.

- [ ] **Step 5: Commit**

```
git add src/reply-flow/format.ts src/reply-flow/format.test.ts
git commit -m "feat(alert-watcher): suggestion block formatter (es/en)"
```

---

## Task 6: Forward helper

**Files:**
- Create: `src/reply-flow/forward.ts`

(Exercised indirectly by `handle-reply.test.ts` in Task 7. No standalone test.)

- [ ] **Step 1: Implement forward**

`src/reply-flow/forward.ts`:
- Import `MinionPluginApi` from `../../../../src/plugins/types.js`.
- Internal `CHANNEL_METHOD_NAMES` record: `{whatsapp:"sendMessageWhatsApp", telegram:"sendMessageTelegram", discord:"sendMessageDiscord"}` (mirrors `src/destinations.ts`).
- Internal `resolveSender(api, channel)` returns the channel's send function or `null`. Same logic as `src/destinations.ts:resolveSender`.
- Exported `async function forwardToOrigin(api, originChannel, originAccountId, originChatId, text): Promise<{ok:true}|{ok:false;error:string}>`. If no sender → `{ok:false, error:"no sender for channel <name>"}`. Otherwise call `sender(chatId, text, {accountId: originAccountId ?? "default"})`; success → `{ok:true}`; thrown error caught → `{ok:false, error:String(err)}`.

- [ ] **Step 2: Commit**

```
git add src/reply-flow/forward.ts
git commit -m "feat(alert-watcher): forward-to-origin helper"
```

---

## Task 7: Reply handler (CAS claim + forward + retry + loser DM)

**Files:**
- Create: `src/reply-flow/handle-reply.ts`
- Create: `src/reply-flow/handle-reply.test.ts`

- [ ] **Step 1: Write tests**

Setup helpers in the test file:
- `setupDb()` — fresh `:memory:` DB, stub `complaints` table with id=1, `initReplySchema`.
- `mkApi(sendOk = true)` — mock `api.runtime.channel.telegram.sendMessageTelegram` (rejects with `"network down"` when `sendOk=false`), `api.runtime.channel.whatsapp.sendMessageWhatsApp` (always ok), mock `api.logger`.
- Default config: `{enabled:true, windowMs:60000, notifyLoser:true, suggestions:{enabled:true,count:3,maxChars:140}}`.

Test cases:
1. **Ignores non-destination sender** — pending row destinations=["+5199","+5188"]; call handler with sender="+5177", message="1"; expect `{status:"ignored"}`, sender mocks not called.
2. **Forwards preset selection verbatim** — pending row destinations=["+5199","+5188"], suggestions=["alpha","beta","gamma"]; handler with sender="+5199", message="2"; expect `{status:"forwarded", kind:"preset_2", text:"beta"}`; telegram mock called with chatId, "beta", `{accountId:"default"}`; whatsapp mock called with `"+5188"`, body containing `"beta"` (loser DM).
3. **Forwards freeform text verbatim** — pending row destinations=["+5199"]; handler with sender="+5199", message="custom reply text"; expect `{status:"forwarded", kind:"freeform", text:"custom reply text"}`; telegram called with chatId, "custom reply text".
4. **Second-owner reply is ignored** — handler #1 with sender="+5199", message="1"; handler #2 with sender="+5188", message="2"; expect handler #2 returns `{status:"ignored"}`.
5. **Forwarding failure releases claim and DMs claimer** — `mkApi(false)`; handler with sender="+5199", message="1"; expect `{status:"forward_failed"}`; whatsapp DM called with sender="+5199" body matching `/Couldn't send|No se pudo enviar/`; SELECT row, expect `claimed_by IS NULL` (so a retry can claim again).
6. **Skips loser DM when notifyLoser=false** — destinations=["+5199","+5188"]; handler with `{...cfg, notifyLoser:false}`; expect whatsapp mock NOT called.
7. **Ignores expired pending** — insert with `expires_at = Date.now()-1`; handler returns `{status:"ignored"}`.

- [ ] **Step 2: Run, expect FAIL**

- [ ] **Step 3: Implement handler**

`src/reply-flow/handle-reply.ts`:
- Import `DatabaseSync` (type), `MinionPluginApi`, store helpers, `parseReplySelection`, `forwardToOrigin`, `ReplyFlowConfig`, `ReplyKind`.
- Constants:
  - `LOSER_TEXT: Record<string, (winner, text) => string>` with `es` and `en` variants. Spanish: `"✅ ${w} ya respondió: \"${t}\""`. English: `"✅ ${w} already replied: \"${t}\""`.
  - `FAILURE_TEXT: Record<string, (err) => string>`. Spanish: `"❌ No se pudo enviar al cliente: ${e}. Responde de nuevo para reintentar."`. English: `"❌ Couldn't send to complainer: ${e}. Reply again to retry."`.
- `ReplyHandlerInput = {sender: string, message: string}`.
- `ReplyHandlerResult = {status:"ignored"} | {status:"forwarded", kind:ReplyKind, text:string} | {status:"forward_failed", error:string}`.
- Private `async function dmOwner(api, ownerPhone, text)` — looks up `api.runtime.channel.whatsapp.sendMessageWhatsApp`, calls it with `{accountId:"default"}`. Try/catch — warns on error, never throws.
- Exported `async function handleOwnerReply(db, api, cfg, input): Promise<ReplyHandlerResult>`:
  1. If `!cfg.enabled` → `{status:"ignored"}`.
  2. `pending = findOpenPendingForSender(db, input.sender, now)`; if undefined → ignored.
  3. `if (!claimPending(db, pending.id, input.sender, now))` → ignored.
  4. Parse suggestions (JSON), parse selection. Resolve `{kind, text}`: preset → `kind="preset_${index+1}"`, text=suggestions[index]; freeform → kind="freeform", text=input.message.
  5. Call `forwardToOrigin(api, pending.origin_channel, pending.origin_account_id, pending.origin_chat_id, text)`.
  6. On failure: `markForwarded(db, id, kind, text, false)`, `releasePending(db, id)`, DM claimer with `FAILURE_TEXT[lang](error)`, return `{status:"forward_failed", error}`.
  7. On success: `markForwarded(db, id, kind, text, true)`. If `cfg.notifyLoser`: parse destinations, filter out the winner, DM each loser with `LOSER_TEXT[lang](winnerLabel, text)`. Return `{status:"forwarded", kind, text}`.
- Language resolution: `lang = pending.language ?? "en"`.
- Winner label in DM: use `input.sender` raw (phone number) for now — a future enhancement could resolve label from destinations config.

- [ ] **Step 4: Run, expect PASS** — 7/7 tests.

- [ ] **Step 5: Commit**

```
git add src/reply-flow/handle-reply.ts src/reply-flow/handle-reply.test.ts
git commit -m "feat(alert-watcher): owner-reply handler — CAS + forward + retry + loser DM"
```

---

## Task 8: Wire into index.ts

**Files:**
- Modify: `index.ts`

- [ ] **Step 1: Add imports**

At the top of `index.ts`, after existing imports, add:

```
import { initReplySchema, insertPending } from "./src/reply-flow/store.js";
import { runSuggestionsDrone } from "./src/reply-flow/suggestions-drone.js";
import { appendSuggestionsBlock } from "./src/reply-flow/format.js";
import { handleOwnerReply } from "./src/reply-flow/handle-reply.js";
import { DEFAULT_REPLY_FLOW_CONFIG, type ReplyFlowConfig } from "./src/reply-flow/types.js";
```

- [ ] **Step 2: Extend ResolvedConfig type**

Add `replyFlow: ReplyFlowConfig` to the `ResolvedConfig` interface (sibling of `cooldown`).

- [ ] **Step 3: Extend resolveConfig**

Inside `resolveConfig`, after the cooldown block, before the return, add resolution logic that reads `raw.replyFlow` and `raw.replyFlow.suggestions`, applying `DEFAULT_REPLY_FLOW_CONFIG` per-field defaults for missing keys. Add `replyFlow,` to the returned object.

- [ ] **Step 4: Call initReplySchema after initSchema**

In `start()`, immediately after `initSchema(db);`, add:

```
initReplySchema(db);
```

- [ ] **Step 5: Generate suggestions + insert pending row inside handle()**

Locate the existing block in `handle()` that does:
```
const text = formatAlert(event, result, history, cfg.language);
const delivery = await sendAlerts(api, cfg.destinations, text);
if (delivery.successCount > 0) {
  cooldown.markAlerted(chatKey, result.severity);
  if (rowId > 0) markNotified(db, rowId);
}
```

Replace with:
```
let suggestions: string[] = [];
if (cfg.replyFlow.enabled && cfg.replyFlow.suggestions.enabled) {
  suggestions = await runSuggestionsDrone(host, cfg.classifier.model, {
    language: cfg.language,
    severity: result.severity,
    category: result.category,
    summary: result.summary,
    senderMessage: event.content,
    systemPrompt: cfg.classifier.systemPrompt ?? "",
    count: cfg.replyFlow.suggestions.count,
    maxChars: cfg.replyFlow.suggestions.maxChars,
  });
}

const baseText = formatAlert(event, result, history, cfg.language);
const text = cfg.replyFlow.enabled
  ? appendSuggestionsBlock(baseText, suggestions, cfg.language)
  : baseText;

const delivery = await sendAlerts(api, cfg.destinations, text);

if (cfg.replyFlow.enabled && rowId > 0 && delivery.successCount > 0) {
  const destPhones = cfg.destinations
    .filter((d) => d.channel === "whatsapp")
    .map((d) => d.to);
  if (destPhones.length > 0) {
    insertPending(db, {
      alertId: rowId,
      originChannel: event.channel,
      originAccountId: event.accountId,
      originChatId: event.chatId,
      destinations: destPhones,
      suggestions,
      language: cfg.language,
      expiresAt: Date.now() + cfg.replyFlow.windowMs,
    });
  }
}

if (delivery.successCount > 0) {
  cooldown.markAlerted(chatKey, result.severity);
  if (rowId > 0) markNotified(db, rowId);
}
```

`host` is the local `createGatewayDroneHost({config: api.config})` already defined earlier in `handle()` for the classifier — reuse it.

- [ ] **Step 6: Register whatsapp message_inbound hook**

After the existing `api.on("message_received", ...)` block at the end of the plugin export, add:

```
api.on("message_inbound", async (event) => {
  if (event.channel !== "whatsapp") return;
  if (!db) return;
  const cfg = resolveConfig(api.pluginConfig as Record<string, unknown>);
  if (!cfg.replyFlow.enabled) return;
  await handleOwnerReply(db, api, cfg.replyFlow, {
    sender: event.senderId ?? "",
    message: event.content,
  });
});
```

Note: the existing `api.on("message_inbound", ...)` block at the bottom of `index.ts` is the classifier path. We add a SECOND `api.on("message_inbound", ...)` for the whatsapp reply path. Both handlers fire; the channel filter inside each one disambiguates.

- [ ] **Step 7: Type-check + run full plugin test suite**

```
cd minion/extensions/alert-watcher && pnpm vitest run
```
Expected: every existing test still passes, every new reply-flow test passes.

```
cd minion && pnpm tsgo
```
Expected: no new TS errors (pre-existing unrelated extension errors are tolerated, see [[project_voicecall_latency_phase1_telemetry]]).

- [ ] **Step 8: Commit**

```
git add index.ts
git commit -m "feat(alert-watcher): wire suggestions drone + pending_replies + whatsapp reply hook"
```

---

## Task 9: Plugin manifest config schema

**Files:**
- Modify: `minion.plugin.json`

- [ ] **Step 1: Add replyFlow block to configSchema.properties**

Insert as a sibling of `classifier` (and any other existing top-level config blocks):

```
"replyFlow": {
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "enabled": { "type": "boolean", "default": true },
    "windowMs": { "type": "integer", "minimum": 60000, "maximum": 86400000, "default": 3600000 },
    "notifyLoser": { "type": "boolean", "default": true },
    "suggestions": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "enabled": { "type": "boolean", "default": true },
        "count": { "type": "integer", "minimum": 1, "maximum": 5, "default": 3 },
        "maxChars": { "type": "integer", "minimum": 40, "maximum": 500, "default": 140 }
      }
    }
  }
}
```

- [ ] **Step 2: Validate JSON**

```
python3 -m json.tool < minion.plugin.json > /dev/null
```
Expected: exit 0.

- [ ] **Step 3: Commit**

```
git add minion.plugin.json
git commit -m "feat(alert-watcher): replyFlow config schema in manifest"
```

---

## Task 10: Build, publish, deploy, live test

**Files:** none modified — operational.

- [ ] **Step 1: Build the worktree**

```
CI=true npm pack --ignore-scripts
node --import tsx scripts/build-extensions.ts
```

Produces tarball + rebuilds `extensions/alert-watcher/index.js`.

- [ ] **Step 2: Publish (dev tag)**

```
npm publish ./nikolasp98-minion-<version>.tgz --access public --tag dev --auth-type=web
```

OTP via browser. Verify:
```
curl -s "https://registry.npmjs.org/@nikolasp98/minion" | python3 -c "import json,sys; d=json.load(sys.stdin); print('<version> live:', '<version>' in d['versions'])"
```

- [ ] **Step 3: Deploy via rsync --delete**

```
scp ./nikolasp98-minion-<version>.tgz bot-prd@netcup:/tmp/
ssh bot-prd@netcup "cd /tmp && rm -rf reply-pkg && mkdir reply-pkg && cd reply-pkg && tar xzf ../nikolasp98-minion-<version>.tgz && \
  rsync -a --delete package/dist/ ~/.local/lib/node_modules/@nikolasp98/minion/dist/ && \
  rsync -a --delete package/extensions/alert-watcher/ ~/.local/lib/node_modules/@nikolasp98/minion/extensions/alert-watcher/ && \
  cp package/package.json ~/.local/lib/node_modules/@nikolasp98/minion/package.json"
```

Verify:
```
ssh bot-prd@netcup "grep -c pending_replies ~/.local/lib/node_modules/@nikolasp98/minion/extensions/alert-watcher/*.js"
```
Expected: ≥ 1.

- [ ] **Step 4: Update gateway.json + restart**

```
ssh bot-prd@netcup 'python3 -c "
import json
p=\"/home/bot-prd/.minion/gateway.json\"
c=json.load(open(p))
aw=c[\"plugins\"][\"entries\"][\"alert-watcher\"][\"config\"]
aw[\"replyFlow\"]={\"enabled\":True,\"windowMs\":3600000,\"notifyLoser\":True,\"suggestions\":{\"enabled\":True,\"count\":3,\"maxChars\":140}}
json.dump(c, open(p,\"w\"), indent=2)
"; systemctl --user restart minion-gateway'
```

Verify boot:
```
ssh bot-prd@netcup "journalctl --user -u minion-gateway --since '30 sec ago' --no-pager | grep -iE 'alert-watcher|telegram.*start|whatsapp.*Listening'"
```
Expected: `subscriptions=1 destinations=2`, telegram provider started, whatsapp listening.

- [ ] **Step 5: Live test — happy path**

User sends Spanish complaint to `@snifflebottom_bot`. In a separate terminal, tail:
```
ssh bot-prd@netcup "journalctl --user -u minion-gateway -f --since now -o cat | grep -iE 'alert-watcher|complaint|whatsapp.*Sending'"
```

Expected within ~5s:
- ledger inbound row
- `alert-watcher: complaint logged (id=N, severity=high, alerts_delivered=2/2)`
- Two outbound WhatsApp messages each containing `1.`, `2.`, `3.` numbered suggestions
- Spanish prompt text `"Responde con 1/2/3 o escribe tu propio mensaje."`

Verify pending row:
```
ssh bot-prd@netcup "sqlite3 ~/.minion/plugins/alert-watcher/alerts.db 'SELECT id,alert_id,origin_channel,origin_chat_id,language,(expires_at - strftime(\"%s\",\"now\")*1000) AS ttl_ms,claimed_by FROM pending_replies ORDER BY id DESC LIMIT 1'"
```
Expected: one row, `claimed_by` NULL, ttl_ms close to 3,600,000.

- [ ] **Step 6: Live test — preset reply**

Owner 1 replies `2` on WhatsApp to the alert.

Expected:
- Telegram message sent to complainer with suggestion #2 verbatim
- Owner 2 receives `✅ <Owner 1 phone> ya respondió: "..."`
- DB: `claimed_by=<Owner 1 phone>`, `reply_kind="preset_2"`, `reply_text="<suggestion 2>"`, `forwarded_ok=1`

Verify:
```
ssh bot-prd@netcup "sqlite3 ~/.minion/plugins/alert-watcher/alerts.db 'SELECT claimed_by,reply_kind,reply_text,forwarded_ok FROM pending_replies ORDER BY id DESC LIMIT 1'"
```

- [ ] **Step 7: Live test — freeform reply**

Trigger another complaint. Owner 1 replies with freeform text (not 1/2/3). Verify:
- Telegram receives the freeform text verbatim
- `reply_kind="freeform"`, `forwarded_ok=1`

- [ ] **Step 8: Live test — second-owner loses**

Trigger another complaint. Owner 1 replies `1` quickly. Owner 2 then replies `2`. Verify only one telegram message (Owner 1's choice) reached the complainer; Owner 2's `2` was silently ignored.

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

```
git push origin <branch>
gh pr create --title "feat(alert-watcher): interactive reply flow" --body-file <(cat <<'BODY'
## Summary
- Suggestion drone generates 3 short replies in cfg.language, appended to WhatsApp alerts
- New pending_replies table tracks open alerts awaiting owner response (1h window)
- Owner replies on WhatsApp (1/2/3 = preset verbatim, else freeform) get forwarded to complainer on origin channel
- First-reply-wins via SQL CAS; losing owners get a courtesy DM
- Forward failures release the claim for retry
- Manifest config schema additions

## Spec
docs/2026-05-18-interactive-reply-flow-design.md

## Test plan
- [x] Unit: parser, CAS race, claim/release, suggestions drone failure fallback, formatter
- [x] Integration: 7-test matrix in handle-reply.test.ts
- [x] Live: Spanish complaint -> numbered suggestions -> owner replies 2 -> telegram delivery + loser DM
- [x] Live: Freeform reply path
- [x] Live: Second-owner-loses path

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

---

## Self-Review

**Spec coverage:**
- Suggestion drone per alert → Task 3 ✓
- First-reply-wins via CAS → Task 4 (claim test) + Task 7 (handler) ✓
- 1-hour window → Task 1 (`expires_at` column), Task 7 (CAS checks expiry), Task 8 (config) ✓
- Plain-text reply to complainer → Task 7 (forwardToOrigin passes raw text, no attribution) ✓
- Number parser `^[1-3][.)]?$` → Task 2 ✓
- Forward-failure resets claim → Task 4 (releasePending) + Task 7 ✓
- Loser DM toggle → Task 7 ✓
- Language honored → Task 3 (drone prompt), Task 5 (formatter es/en), Task 7 (loser/failure DMs es/en) ✓
- Config schema → Task 9 ✓
- Live test → Task 10 ✓

**Placeholder scan:** no TBD/TODO. All code-spec sections give actual TypeScript constructs (interfaces, function signatures, regex literals, SQL DDL).

**Type consistency:**
- `ReplyKind = "preset_1" | "preset_2" | "preset_3" | "freeform"` used in `types.ts`, `store.ts` (`markForwarded`), `handle-reply.ts` (result + winner mapping). Mapping `preset_${index+1}` only valid for index in [0,1,2] — parser guarantees this.
- `parseReplySelection` returns `{kind:"preset",index:number}` — handler casts to `preset_${index+1}` which assumes count ≤ 3. Suggestions count is hard-locked to 3 (TypeBox schema minItems/maxItems), so this assumption holds even if `cfg.suggestions.count` is set to 1 or 2 (parser will reject `"3"` via count clamp).
- `findOpenPendingForSender` returns `PendingReplyRow | undefined` — handler checks `if (!pending)`.
- `forwardToOrigin` return `{ok:true}|{ok:false;error:string}` — discriminated correctly.
- `DEFAULT_REPLY_FLOW_CONFIG` shape matches `ReplyFlowConfig` interface.
- `cfg.suggestions.count` default 3 in Task 1 type, in manifest Task 9, and in suggestions drone output schema Task 3 (`minItems:3, maxItems:3`). Consistent.

**Soft assumption flagged in spec (forwarding):** `forwardToOrigin` only knows about telegram/whatsapp/discord. If a future subscription source channel adds itself (matrix, slack), CHANNEL_METHOD_NAMES needs an entry. Non-blocking for current telegram-only deployment.

---

## Execution Handoff

Plan complete and saved to `minion/extensions/alert-watcher/docs/2026-05-18-interactive-reply-flow-plan.md`. Two execution options:

**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration

**2. Inline Execution** — execute tasks in this session using `superpowers:executing-plans`, batched with checkpoints

Which approach?

---

## Task 11: UI panel for replyFlow settings

**Files:**
- Modify: `ui/src/lib/stores.svelte.ts` — extend `cfg` state shape with `replyFlow`.
- Create: `ui/src/lib/ReplyFlowPanel.svelte` — new settings panel.
- Modify: `ui/src/App.svelte` — import panel, mount in layout, include `replyFlow` in dirty-snapshot + save payload + hydrate-on-load.

The alert-watcher UI is a Svelte 5 iframe that talks to the gateway via `PluginBridge` RPC (`plugins.config.get` / `plugins.config.set`). The host renders the save bar — plugins just stream dirty state via `notifyDirty(dirty)` and respond to `host:save` via `onSaveRequest` (see [[reference_plugin_save_bar_hub_layer]]).

- [ ] **Step 1: Extend cfg store shape**

In `ui/src/lib/stores.svelte.ts`, add `replyFlow` to the `cfg` state type (sibling of `classifier`):

```
replyFlow: {
  enabled?: boolean;
  windowMs?: number;
  notifyLoser?: boolean;
  suggestions?: {
    enabled?: boolean;
    count?: number;
    maxChars?: number;
  };
};
```

Initialize `replyFlow: {}` in the `$state(...)` default object.

- [ ] **Step 2: Create ReplyFlowPanel.svelte**

`ui/src/lib/ReplyFlowPanel.svelte` mirrors `ClassifierPanel.svelte`'s structure. Component:
- Props: `{ replyFlow: ReplyFlow; onChange: (next: ReplyFlow) => void }`.
- Renders a `<section class="space-y-3">` with `<header>` ("Interactive replies", subtitle "Owners reply to alerts directly from WhatsApp.") and a grid of controls in a single bordered card.

Controls:

1. **Master toggle** — checkbox "Enabled" bound to `replyFlow.enabled` (default true). When unchecked, alerts go out without numbered suggestions and the message_inbound reply hook is a no-op.
2. **Window (minutes)** — number input min=1, max=1440, step=1, default 60. Maps to `windowMs * 60_000`. Helper text "How long owners have to reply after an alert is sent (1–1440 min)".
3. **Notify losers** — checkbox "Notify other owners when one wins" bound to `replyFlow.notifyLoser` (default true). Helper text "When enabled, owners who didn't win the claim get a courtesy DM showing what was sent."
4. **Suggestions enabled** — checkbox "Generate suggested replies" bound to `replyFlow.suggestions.enabled` (default true). Helper text "Uses the classifier model to generate 3 short replies in the configured language."
5. **Suggestion count** — number input min=1, max=5, default 3. Bound to `replyFlow.suggestions.count`. Disabled when suggestions.enabled is false.
6. **Suggestion max chars** — number input min=40, max=500, default 140. Bound to `replyFlow.suggestions.maxChars`. Helper text "Maximum length per suggestion."

Use the same Tailwind classes as `ClassifierPanel.svelte` (`h-10`, `rounded-md`, `border border-[var(--color-border)]`, `bg-[var(--color-surface-2)]`, etc.) for visual consistency. For checkboxes use a wrapper like `<label class="flex items-center gap-2 text-sm">` with the input + label text.

Update callback pattern (mirror `ClassifierPanel`):
```
function update<K extends keyof ReplyFlow>(key: K, value: ReplyFlow[K]) {
  onChange({ ...replyFlow, [key]: value });
}
function updateSuggestions(patch: Partial<NonNullable<ReplyFlow["suggestions"]>>) {
  onChange({ ...replyFlow, suggestions: { ...(replyFlow.suggestions ?? {}), ...patch } });
}
```

Windows-input transform: convert the user-entered minutes to/from `windowMs`. Inside the input handler: `update("windowMs", Math.max(60_000, Math.round(minutes * 60_000)))`. Display value: `Math.round((replyFlow.windowMs ?? 3_600_000) / 60_000)`.

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

In `ui/src/App.svelte`:

1. Add import next to other panel imports:
   ```
   import ReplyFlowPanel from "./lib/ReplyFlowPanel.svelte";
   ```

2. Extend `dirty` derived + `snapshotNow()` to include `replyFlow`:
   ```
   const dirty = $derived(
     JSON.stringify({
       subscriptions: cfg.subscriptions,
       destinations: cfg.destinations,
       classifier: cfg.classifier,
       language: cfg.language,
       replyFlow: cfg.replyFlow,
     }) !== savedSnapshot,
   );
   function snapshotNow() {
     savedSnapshot = JSON.stringify({
       subscriptions: cfg.subscriptions,
       destinations: cfg.destinations,
       classifier: cfg.classifier,
       language: cfg.language,
       replyFlow: cfg.replyFlow,
     });
   }
   ```

3. Extend `onMount` hydration after the existing `cfg.language = ...` line:
   ```
   cfg.replyFlow = (got.config.replyFlow as any) ?? {};
   ```

4. Extend the save handler `onSaveRequest(...)` to include `replyFlow` in the config payload sent via `pluginsConfigSet`. Search for the existing call site that builds `{ subscriptions, destinations, classifier, language }` and add `replyFlow: cfg.replyFlow`.

5. Mount the panel in the layout — add `<ReplyFlowPanel replyFlow={cfg.replyFlow} onChange={(next) => (cfg.replyFlow = next)} />` near `ClassifierPanel` (place after `ClassifierPanel`, before `PromptsPanel`).

- [ ] **Step 4: Type-check + visual sanity**

Run from `minion/extensions/alert-watcher/ui/`:
```
bun run check
```

Expected: 0 errors. (Svelte 5 + svelte-check; new component should typecheck against the new `replyFlow` cfg shape.)

Then build:
```
bun run build
```

Expected: bundle written to `dist/`.

- [ ] **Step 5: Commit**

```
git add ui/src/lib/stores.svelte.ts ui/src/lib/ReplyFlowPanel.svelte ui/src/App.svelte
git commit -m "feat(alert-watcher/ui): ReplyFlow settings panel"
```

- [ ] **Step 6: Live verify in hub**

After Task 10 deploys, open the hub `/settings/plugins/alert-watcher` iframe and verify:
- New "Interactive replies" panel renders between Classifier and Prompts.
- Toggles save correctly — dirty bar appears on change, "Saved" confirmation after.
- Reload page: settings persist (reads back from gateway.json via `plugins.config.get`).
- Turning off "Generate suggested replies" greys out count + maxChars inputs.
- Setting window to 5 (min) and saving → check `gateway.json` for `replyFlow.windowMs: 300000`.

---

## Spec coverage delta after Task 11

- UI for replyFlow settings → Task 11 ✓ (was implicit in spec; making explicit per user request "add UI configs to tweak settings")

User quote: *"subagents; also add UI configs to tweak settings"*.
