# Yerin Online 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:** Bring Yerin (read-only security agent) online with a global chat surface, extracting the shared agent-chat foundation (backend `run_turn` + frontend `agent_chat`) that Dross also rides on. **Architecture:** Extract the turn-runner from `companion.js` into `lib/ai/agent/run_turn.js` and personas into `lib/ai/personas/`. Add a global (`space_id NULL`) Yerin endpoint reusing them with `VOID_TOOL_REGISTRY=security`. On the frontend, extract `components/agent_chat.js` from `rightrail.js`; add a `#/sentinel` view that uses it (no draft cards). **Tech Stack:** Node 22 ESM, Express 5, `@modelcontextprotocol/sdk`, vanilla-JS no-build SPA, vitest + supertest (serial). **Spec:** `docs/superpowers/specs/2026-06-04-yerin-online-design.md` --- ### Task 1: `conversations.findOrCreateGlobal` **Files:** - Modify: `lib/db/repos/conversations.js` - Test: `tests/db/conversations_global.test.js` - [ ] **Step 1: Failing test** ```js // tests/db/conversations_global.test.js import { describe, it, expect, beforeAll } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as agents from '../../lib/db/repos/agents.js'; import * as conversations from '../../lib/db/repos/conversations.js'; const owner = { kind: 'user', id: null }; beforeAll(async () => { await resetDb(); await migrateUp(); }); describe('findOrCreateGlobal', () => { it('creates one space-less open conversation and reuses it', async () => { const y = await agents.getBySlug('yerin'); // seeded by 011_yerin.sql const c1 = await conversations.findOrCreateGlobal(y.id, owner); expect(c1.space_id).toBeNull(); expect(c1.agent_id).toBe(y.id); const c2 = await conversations.findOrCreateGlobal(y.id, owner); expect(c2.id).toBe(c1.id); }); }); ``` - [ ] **Step 2: Run → FAIL** (`npx vitest run tests/db/conversations_global.test.js`) — `findOrCreateGlobal is not a function`. - [ ] **Step 3: Implement** — append to `lib/db/repos/conversations.js`: ```js export async function findOrCreateGlobal(agent_id, actor) { const { rows: [existing] } = await pool.query( `SELECT * FROM conversations WHERE agent_id=$1 AND space_id IS NULL AND status='open' ORDER BY started_at DESC LIMIT 1`, [agent_id] ); if (existing) return existing; const { rows: [r] } = await pool.query( `INSERT INTO conversations(title, agent_id, metadata) VALUES($1,$2,$3) RETURNING *`, [null, agent_id, {}] ); await recordAudit(actor, 'create', 'conversation', r.id, null, r); return r; } ``` - [ ] **Step 4: Run → PASS.** - [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(agents): conversations.findOrCreateGlobal for space-less agents"` --- ### Task 2: Personas module **Files:** - Create: `lib/ai/personas/index.js` - Test: `tests/ai/personas.test.js` - [ ] **Step 1: Failing test** ```js // tests/ai/personas.test.js import { describe, it, expect } from 'vitest'; import { PERSONAS, personaFor } from '../../lib/ai/personas/index.js'; describe('personas', () => { it('has companion (Dross) and yerin personas keyed by agent slug', () => { expect(PERSONAS.companion).toMatch(/Dross/); expect(PERSONAS.yerin).toMatch(/Yerin/); }); it('personaFor falls back to companion for unknown slugs', () => { expect(personaFor('yerin')).toBe(PERSONAS.yerin); expect(personaFor('nope')).toBe(PERSONAS.companion); }); }); ``` - [ ] **Step 2: Run → FAIL** (module missing). - [ ] **Step 3: Implement** — create `lib/ai/personas/index.js`. Move the existing `SYSTEM` constant's value **verbatim** out of `lib/api/routes/companion.js` into `PERSONAS.companion`, and add Yerin's: ```js // Agent personas keyed by agent slug. Dross's text moved verbatim from // companion.js. persona_path file loading is a later (migration) concern. export const PERSONAS = { companion: `<>`, yerin: `You are Yerin — once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words — a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything — you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools — audit_log, agent_inventory, pending_review, resource_exposure, token_audit — and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.` }; export function personaFor(slug) { return PERSONAS[slug] || PERSONAS.companion; } ``` - [ ] **Step 4: Run → PASS.** - [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(agents): personas module (Dross + Yerin), keyed by slug"` --- ### Task 3: `run_turn.js` shared turn-runner **Files:** - Create: `lib/ai/agent/run_turn.js` - Test: `tests/ai/agent/run_turn.test.js` - [ ] **Step 1: Failing test** ```js // tests/ai/agent/run_turn.test.js import { describe, it, expect, vi, beforeEach } from 'vitest'; import { readFile } from 'fs/promises'; vi.mock('../../../lib/ai/claude_cli.js', () => ({ runClaudeTurn: vi.fn() })); import { runClaudeTurn } from '../../../lib/ai/claude_cli.js'; import { runAgentTurn } from '../../../lib/ai/agent/run_turn.js'; beforeEach(() => runClaudeTurn.mockReset()); describe('runAgentTurn', () => { it('builds the MCP config (registry + tools + agent + space) and forwards to runClaudeTurn', async () => { let captured; runClaudeTurn.mockImplementation(async (opts) => { captured = { ...opts, cfg: JSON.parse(await readFile(opts.mcpConfigPath, 'utf8')) }; return { text: 'ok', toolTrace: [], usage: null }; }); const out = await runAgentTurn({ agent: { id: 'a1', slug: 'yerin', capabilities: { read: true }, scopes: {} }, persona: 'YERIN', registryName: 'security', toolNames: ['mcp__void__audit_log'], spaceId: null, sessionId: 'c1', userText: 'check', claudeExe: 'claude' }); expect(out.text).toBe('ok'); expect(captured.systemPrompt).toBe('YERIN'); expect(captured.tools).toEqual(['mcp__void__audit_log']); expect(captured.allowedTools).toEqual(['mcp__void__audit_log']); const env = captured.cfg.mcpServers.void.env; expect(env.VOID_TOOL_REGISTRY).toBe('security'); expect(env.VOID_SPACE_ID).toBe(''); expect(JSON.parse(env.VOID_AGENT_JSON).id).toBe('a1'); }); }); ``` - [ ] **Step 2: Run → FAIL** (module missing). - [ ] **Step 3: Implement** — create `lib/ai/agent/run_turn.js`: ```js import { writeFile, unlink } from 'fs/promises'; import { join } from 'path'; import { tmpdir } from 'os'; import { randomUUID } from 'crypto'; import { fileURLToPath } from 'url'; import { runClaudeTurn } from '../claude_cli.js'; // Absolute path to the MCP stdio server the claude child spawns. const STDIO_PATH = fileURLToPath(new URL('../../mcp/companion-stdio.js', import.meta.url)); /** * Shared agent turn-runner: builds the per-turn MCP config (selecting the tool * registry + injecting agent/space/view), runs one claude turn, cleans up. * SSE/persistence stay in the route. Returns runClaudeTurn's result. */ export async function runAgentTurn({ agent, persona, registryName, toolNames, spaceId = null, view = null, sessionId, resume = false, userText, claudeExe = 'claude', home, onEvent }) { const agentActor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities, scopes: agent.scopes }; const mcpConfigPath = join(tmpdir(), `void-mcp-${randomUUID()}.json`); const mcpConfig = { mcpServers: { void: { command: process.execPath, args: [STDIO_PATH], env: { VOID_TOOL_REGISTRY: registryName || '', VOID_SPACE_ID: spaceId || '', VOID_AGENT_JSON: JSON.stringify(agentActor), VOID_VIEW_JSON: view ? JSON.stringify(view) : '', DATABASE_URL: process.env.DATABASE_URL || '', OLLAMA_URL: process.env.OLLAMA_URL || '' } } } }; await writeFile(mcpConfigPath, JSON.stringify(mcpConfig)); try { return await runClaudeTurn({ sessionId, resume, systemPrompt: persona, userText, mcpConfigPath, tools: toolNames, allowedTools: toolNames, claudeExe, home, onEvent }); } finally { unlink(mcpConfigPath).catch(() => {}); } } ``` - [ ] **Step 4: Run → PASS.** - [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(agents): shared runAgentTurn turn-runner"` --- ### Task 4: Refactor `companion.js` onto the shared foundation **Files:** - Modify: `lib/api/routes/companion.js` - [ ] **Step 1: Refactor.** In `lib/api/routes/companion.js`: - Delete the local `SYSTEM` constant; `import { personaFor } from '../../ai/personas/index.js';`. - Delete now-unused imports `writeFile, unlink` (fs/promises), `join` (path), `tmpdir` (os), `randomUUID` (crypto), and the `COMPANION_STDIO_PATH` constant — all now inside `run_turn.js`. Keep `fileURLToPath` only if still used elsewhere (it isn't after removing `COMPANION_STDIO_PATH` → remove it too). - `import { runAgentTurn } from '../../ai/agent/run_turn.js';` - In the `/turn` handler, **remove** the `mcpConfigPath`/`mcpConfig`/`writeFile` block and the trailing `unlink(mcpConfigPath)` calls. Replace the `runClaudeTurn({...})` call with: ```js result = await runAgentTurn({ agent, persona: personaFor(agent.slug), registryName: undefined, // default → companionRegistry toolNames: companionTools, spaceId: req.params.space_id, view, sessionId: convo.id, resume, userText: text, claudeExe, home: process.env.VOID_CLAUDE_HOME || undefined, onEvent: (e) => { /* keep the EXISTING onEvent body unchanged: delta/tool/tool_result(draft parse)/error */ } }); ``` Keep the existing `onEvent` body (delta/tool/tool_result draft-parsing/error → `send(...)`) exactly as-is. Keep `companionTools`, the post-stream `messages.append(assistant…)` with `draft_ids`, and the `done` event. The `catch` block keeps `send('error',…)` + `res.end()` but drop its `unlink(mcpConfigPath)` line. - [ ] **Step 2: Run companion + related tests → PASS (regression)** Run: `npx vitest run tests/api/companion.test.js` Expected: PASS unchanged (SSE delta/tool/draft/done + user/assistant persistence). - [ ] **Step 3: Commit** — `git add -A && git commit -m "refactor(companion): ride on shared runAgentTurn + personas"` --- ### Task 5: Yerin route + mount + fixture **Files:** - Create: `lib/api/routes/security.js` - Create: `tests/fixtures/fake-claude-security.js` - Modify: `lib/api/index.js` - Test: `tests/api/security_yerin.test.js` - [ ] **Step 1: Fake claude fixture** — `tests/fixtures/fake-claude-security.js` (shebang; emits text deltas + one security tool call + result, NO draft): ```js #!/usr/bin/env node const TOOL_USE_ID = 'toolu_yerin_01'; const lines = [ { type: 'system', subtype: 'init', session_id: 'fake-yerin', tools: [], cwd: '/tmp' }, { type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } }, { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'No new threats.' } } }, { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, { type: 'stream_event', event: { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: TOOL_USE_ID, name: 'mcp__void__audit_log', input: {} } } }, { type: 'stream_event', event: { type: 'content_block_stop', index: 1 } }, { type: 'tool_result', tool_use_id: TOOL_USE_ID, content: [{ type: 'text', text: JSON.stringify({ entries: [] }) }] }, { type: 'result', subtype: 'success', is_error: false, result: 'No new threats.', stop_reason: 'end_turn', session_id: 'fake-yerin', total_cost_usd: 0.0001, usage: { input_tokens: 40, output_tokens: 4 } } ]; for (const l of lines) process.stdout.write(JSON.stringify(l) + '\n'); process.exit(0); ``` Then `chmod +x tests/fixtures/fake-claude-security.js`. - [ ] **Step 2: Failing test** — `tests/api/security_yerin.test.js`: ```js import { describe, it, expect, beforeAll } from 'vitest'; import { fileURLToPath } from 'url'; import request from 'supertest'; import { pool } from '../../lib/db/pool.js'; import { createApp } from '../../server.js'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; const FAKE = fileURLToPath(new URL('../fixtures/fake-claude-security.js', import.meta.url)); let app; beforeAll(async () => { await resetDb(); await migrateUp(); process.env.OWNER_TOKEN = 'test-token'; app = createApp(); app.locals.claudeExe = FAKE; }); const auth = (r) => r.set('Authorization', 'Bearer test-token'); describe('Yerin security API', () => { it('GET creates the global conversation and returns Yerin + empty history', async () => { const res = await auth(request(app).get('/api/security/yerin')); expect(res.status).toBe(200); expect(res.body.agent.slug).toBe('yerin'); expect(res.body.conversation_id).toBeTruthy(); expect(res.body.messages).toEqual([]); }); it('POST /turn streams SSE and persists user+assistant; no draft event', async () => { const res = await auth(request(app).post('/api/security/yerin/turn')).send({ text: 'any new threats?' }); expect(res.status).toBe(200); expect(res.headers['content-type']).toMatch(/text\/event-stream/); expect(res.text).toMatch(/event: delta/); expect(res.text).toMatch(/event: tool/); expect(res.text).toMatch(/event: done/); expect(res.text).not.toMatch(/event: draft/); const { rows: msgs } = await pool.query(`SELECT role, body FROM messages ORDER BY created_at`); expect(msgs.map(m => m.role)).toEqual(['user', 'assistant']); expect(msgs[1].body).toBe('No new threats.'); }); }); ``` - [ ] **Step 3: Run → FAIL** (route 404). - [ ] **Step 4: Implement the route** — `lib/api/routes/security.js`: ```js import { Router } from 'express'; import { z } from 'zod'; import { validate } from '../validate.js'; import { asyncWrap } from '../errors.js'; import * as conversations from '../../db/repos/conversations.js'; import * as messages from '../../db/repos/messages.js'; import * as agents from '../../db/repos/agents.js'; import { runAgentTurn } from '../../ai/agent/run_turn.js'; import { personaFor } from '../../ai/personas/index.js'; const YERIN_SLUG = 'yerin'; const SECURITY_TOOLS = [ 'mcp__void__audit_log', 'mcp__void__agent_inventory', 'mcp__void__pending_review', 'mcp__void__resource_exposure', 'mcp__void__token_audit' ]; async function resolveYerin() { const agent = await agents.getBySlug(YERIN_SLUG); const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null }); return { agent, convo }; } export const router = Router(); router.get('/yerin', asyncWrap(async (_req, res) => { const { agent, convo } = await resolveYerin(); const rows = await messages.listByConversation(convo.id); res.json({ conversation_id: convo.id, agent: { id: agent.id, slug: agent.slug, name: agent.name }, messages: rows }); })); const turnSchema = z.object({ text: z.string().min(1) }); router.post('/yerin/turn', validate({ body: turnSchema }), asyncWrap(async (req, res) => { const { agent, convo } = await resolveYerin(); const { text } = req.body; const resume = (await messages.listByConversation(convo.id)).length > 0; await messages.append(convo.id, { role: 'user', body: text }); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude'; let result; try { result = await runAgentTurn({ agent, persona: personaFor(agent.slug), registryName: 'security', toolNames: SECURITY_TOOLS, spaceId: null, view: null, sessionId: convo.id, resume, userText: text, claudeExe, home: process.env.VOID_CLAUDE_HOME || undefined, onEvent: (e) => { if (e.type === 'delta') send('delta', { type: 'delta', text: e.text }); else if (e.type === 'tool') send('tool', { type: 'tool', tool: e.tool, status: e.status }); else if (e.type === 'error') send('error', { type: 'error', message: e.message }); } }); } catch (e) { send('error', { message: String(e?.message || e) }); return res.end(); } const assistant = await messages.append(convo.id, { role: 'assistant', body: result.text, agent_id: agent.id, metadata: { tool_trace: result.toolTrace, usage: result.usage } }); send('done', { assistant_message_id: assistant.id, usage: result.usage }); res.end(); })); ``` - [ ] **Step 5: Mount it** — in `lib/api/index.js`: `import { router as securityRouter } from './routes/security.js';` and, alongside the other `api.use(...)` lines, `api.use('/security', securityRouter);`. - [ ] **Step 6: Run → PASS.** (`npx vitest run tests/api/security_yerin.test.js`) - [ ] **Step 7: Commit** — `git add -A && git commit -m "feat(yerin): global security chat endpoint /api/security/yerin"` --- ### Task 6: Frontend — extract `agent_chat` component **Files:** - Create: `public/components/agent_chat.js` No automated test (no-build vanilla-JS convention; verified manually in Task 8). - [ ] **Step 1: Create `public/components/agent_chat.js`** — generalize the chat mechanics currently in `rightrail.js`. It owns the log + input + send loop; the host supplies URLs/labels: ```js // Reusable agent chat panel. Safe-DOM only; markdown via sanitized html. import { el, mount, clear } from '../dom.js'; import { api } from '../api.js'; import { streamTurn } from '../sse.js'; import { renderMarkdown } from '../markdown.js'; function turnEl(role, agentName, bodyNode) { return el('div', { class: 'turn ' + (role === 'user' ? 'you' : 'ai') }, el('span', { class: 'lbl' }, role === 'user' ? 'YOU' : (agentName || 'AGENT').toUpperCase()), el('span', { class: 'msg' }, bodyNode)); } function chipEl(tool, status, toolLabels) { const name = String(tool || '').replace(/^mcp__void__/, ''); return el('div', { class: 'tools' }, el('span', { class: 'chip' + (status === 'error' ? ' err' : '') }, (toolLabels[name] || name))); } function draftCardEl(d, onResolve) { const card = el('div', { class: 'draftx', dataset: { pc: d.pending_change_id } }, el('div', { class: 'dh' }, 'Proposed change'), el('div', { class: 'dt' }, d.summary || 'a change'), el('div', { class: 'row' }, el('button', { class: 'ok', onclick: () => onResolve(d.pending_change_id, 'approved', card) }, 'Approve'), el('button', { class: 'no', onclick: () => onResolve(d.pending_change_id, 'rejected', card) }, 'Reject'))); return card; } /** * Mounts log + input into `logEl`/`inputEl` and wires send. * opts: { historyUrl, turnUrl, agentName, showDrafts, toolLabels, turnBody } * turnBody(text) → POST body object (lets callers add view, etc.) */ export function wireAgentChat({ logEl, inputEl, historyUrl, turnUrl, agentName, showDrafts = false, toolLabels = {}, turnBody = (text) => ({ text }) }) { async function resolveDraft(id, status, cardNode) { try { await api.post(`/api/pending-changes/${id}/${status === 'approved' ? 'approve' : 'reject'}`); cardNode.classList.add('resolved'); cardNode.appendChild(el('div', { class: 'resolved-tag' }, status)); } catch (e) { cardNode.appendChild(el('div', { class: 'err' }, 'failed: ' + e.message)); } } async function load() { clear(logEl); let data; try { data = await api.get(historyUrl); } catch (e) { mount(logEl, el('p', { class: 'muted' }, 'Could not load history.')); return; } for (const m of (data.messages || [])) { const body = el('div', { class: 'md' }); body.innerHTML = renderMarkdown(m.body || ''); logEl.appendChild(turnEl(m.role, agentName, body)); } logEl.scrollTop = logEl.scrollHeight; } async function send() { const text = inputEl.value.trim(); if (!text) return; inputEl.value = ''; logEl.appendChild(turnEl('user', agentName, document.createTextNode(text))); const aiBody = el('div', { class: 'md' }); const aiTurn = turnEl('assistant', agentName, aiBody); logEl.appendChild(aiTurn); let acc = ''; try { await streamTurn(turnUrl, turnBody(text), (e) => { if (e.type === 'delta') { acc += e.text || ''; aiBody.innerHTML = renderMarkdown(acc); } else if (e.type === 'tool') aiTurn.appendChild(chipEl(e.tool, e.status, toolLabels)); else if (e.type === 'draft' && showDrafts) aiTurn.appendChild(draftCardEl(e, resolveDraft)); else if (e.type === 'error') aiTurn.appendChild(el('div', { class: 'err' }, e.message || 'error')); logEl.scrollTop = logEl.scrollHeight; }); } catch (e) { aiTurn.appendChild(el('div', { class: 'err' }, 'stream failed: ' + e.message)); } } inputEl.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); send(); } }); return { load, send }; } ``` - [ ] **Step 2: Commit** — `git add -A && git commit -m "feat(ui): extract reusable agent_chat panel"` --- ### Task 7: Frontend — point Dross's rail at `agent_chat` **Files:** - Modify: `public/components/rightrail.js` - [ ] **Step 1: Refactor `rightrail.js`** to keep its rail chrome (toggle/collapse/header) but delegate chat to `wireAgentChat`. Replace the internal history/`streamTurn`/turn/chip/draft logic with a call to `wireAgentChat` using the Dross URLs: ```js import { wireAgentChat } from './agent_chat.js'; // …inside initChat(spaceId), after building `log` + `input`: const COMPANION_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change' }; const chat = wireAgentChat({ logEl: log, inputEl: input, historyUrl: `/api/spaces/${spaceId}/companion`, turnUrl: `/api/spaces/${spaceId}/companion/turn`, agentName: 'Companion', showDrafts: true, toolLabels: COMPANION_LABELS, turnBody: (text) => ({ text, view: state.view || null }) }); await chat.load(); ``` Keep the `!spaceId` guard ("Open a Space to chat…") and the Space-change re-init. Remove the now-duplicated helper functions from `rightrail.js`. - [ ] **Step 2: Manual verify** (after Task 9 deploy, or `node server.js` locally): open a Space → Dross rail loads history, a turn streams deltas + tool chips, a `propose_change` still shows an Approve/Reject card that resolves. (No automated test — vanilla no-build UI.) - [ ] **Step 3: Commit** — `git add -A && git commit -m "refactor(ui): Dross rail uses agent_chat"` --- ### Task 8: Frontend — `#/sentinel` Yerin view + nav **Files:** - Create: `public/views/sentinel.js` - Modify: `public/router.js`, `public/app.js`, `public/components/sidebar.js` - [ ] **Step 1: Create `public/views/sentinel.js`**: ```js import { el, mount } from '../dom.js'; import { wireAgentChat } from '../components/agent_chat.js'; const YERIN_LABELS = { audit_log: '🗒️ reading the audit trail', agent_inventory: '👁️ reviewing agents', pending_review: '⏳ checking the approval queue', resource_exposure: '🛡️ checking exposure', token_audit: '🔑 auditing tokens' }; export async function render(main) { const log = el('div', { class: 'rail-log sentinel-log' }); const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Ask Yerin about the Void’s security…' }); mount(main, el('h1', { class: 'view-h1' }, '◆ Sentinel — Yerin'), el('p', { class: 'view-sub' }, 'Read-only security & observability. She watches, reports, and warns — she never acts.'), el('div', { class: 'sentinel-chat' }, log, el('div', { class: 'rail-inputwrap' }, input))); const chat = wireAgentChat({ logEl: log, inputEl: input, historyUrl: '/api/security/yerin', turnUrl: '/api/security/yerin/turn', agentName: 'Yerin', showDrafts: false, toolLabels: YERIN_LABELS }); await chat.load(); } ``` - [ ] **Step 2: Register the route** — in `public/router.js` add to `ROUTES` (before `home`): `{ name: 'sentinel', re: /^\/sentinel$/, keys: [] },` and add `// #/sentinel Yerin security view` to the header comment. - [ ] **Step 3: Register the view loader** — in `public/app.js` `VIEWS`: `sentinel: () => import('./views/sentinel.js'),`. - [ ] **Step 4: Sidebar nav** — in `public/components/sidebar.js`, alongside the global links (near `navItem('Sacred Valley', '/sacred-valley')`), add `navItem('Sentinel', '/sentinel'),`. - [ ] **Step 5: Manual verify** — nav to `#/sentinel`: Yerin view renders, a question streams deltas + security tool chips, NO approve/reject cards appear, history persists across reload. Dross rail still works on Space views. - [ ] **Step 6: Commit** — `git add -A && git commit -m "feat(ui): Sentinel view — Yerin global security chat"` --- ### Task 9: Release alpha.15 + deploy **Files:** `package.json`, `server.js`, `CHANGELOG.md` - [ ] **Step 1: Bump** `package.json` + `server.js` `VERSION` → `2.0.0-alpha.15`. - [ ] **Step 2: CHANGELOG** — prepend: ```markdown ## 2.0.0-alpha.15 — Yerin online (Agent Layer brick 1) - **Yerin**, the read-only security agent, is now a usable agent: a global `#/sentinel` chat surface backed by her 5 security tools (audit/agents/pending/exposure/tokens). She investigates + reports; she never acts. - Extracted the **shared agent-chat foundation** — `runAgentTurn` (backend) + `agent_chat` (frontend) — now used by both Dross and Yerin. Personas live in `lib/ai/personas/`. ``` - [ ] **Step 3: Full suite** — `npx vitest run` → all green (serial). - [ ] **Step 4: Commit** — `git add -A && git commit -m "chore: release 2.0.0-alpha.15 (Yerin online)"` - [ ] **Step 5: Deploy** — `bash deploy/push.sh` → `/health` reports `2.0.0-alpha.15`, `db_ok`. (No new migration; code-only.) - [ ] **Step 6: Prod smoke** — `https://void2-app.hynesy.com` → `#/sentinel`, send Yerin a question, confirm streamed reply + tool chips; confirm Dross still works on a Space. --- ## Self-Review **Spec coverage:** Shared backend service → Task 3; personas module → Task 2; Yerin global endpoint + `findOrCreateGlobal` → Tasks 1, 5; Dross refactor (regression) → Task 4; `agent_chat` extraction → Task 6; rail refactor → Task 7; `#/sentinel` view + nav → Task 8; read-only/no-drafts → Tasks 5 (`not /draft/`) + 8 (`showDrafts:false`); release/deploy → Task 9. All covered. **Placeholder scan:** One intentional move-marker — `<>` in Task 2 (verbatim relocation of an existing constant, not new content). **Type consistency:** `runAgentTurn({agent, persona, registryName, toolNames, spaceId, view, sessionId, resume, userText, claudeExe, home, onEvent})` — same shape in Task 3 def + Tasks 4/5 calls. `personaFor(slug)` keyed by agent slug (`companion`/`yerin`) — consistent Tasks 2/4/5. `wireAgentChat({logEl,inputEl,historyUrl,turnUrl,agentName,showDrafts,toolLabels,turnBody})` — same in Task 6 def + Tasks 7/8 calls. `findOrCreateGlobal(agent_id, actor)` — Tasks 1/5 consistent.