From 92299548eeb60e42be9d3b045eb2bdf6137db3c5 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 21:03:57 +1000 Subject: [PATCH] docs: Yerin online implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-04-yerin-online.md | 607 ++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-yerin-online.md diff --git a/docs/superpowers/plans/2026-06-04-yerin-online.md b/docs/superpowers/plans/2026-06-04-yerin-online.md new file mode 100644 index 0000000..cdde61b --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-yerin-online.md @@ -0,0 +1,607 @@ +# 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.