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

27 KiB
Raw Blame History

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

// 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:

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: Commitgit 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

// 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:

// 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: Commitgit 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

// 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:

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: Commitgit 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:
      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: Commitgit 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 fixturetests/fixtures/fake-claude-security.js (shebang; emits text deltas + one security tool call + result, NO draft):

#!/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 testtests/api/security_yerin.test.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 routelib/api/routes/security.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: Commitgit 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:
// 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: Commitgit 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:

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: Commitgit 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:

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: Commitgit 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 VERSION2.0.0-alpha.15.
  • Step 2: CHANGELOG — prepend:
## 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 suitenpx vitest run → all green (serial).
  • Step 4: Commitgit add -A && git commit -m "chore: release 2.0.0-alpha.15 (Yerin online)"
  • Step 5: Deploybash deploy/push.sh/health reports 2.0.0-alpha.15, db_ok. (No new migration; code-only.)
  • Step 6: Prod smokehttps://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.