Files
Void-Homelab/docs/superpowers/plans/2026-06-04-yerin-online.md
root 92299548ee docs: Yerin online implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:03:57 +10:00

608 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: `<<PASTE the exact current SYSTEM string from companion.js here>>`,
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 Voids 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 — `<<PASTE the exact current SYSTEM string from companion.js>>` 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.