diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index 9665b86876..e9108a7fcd 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -122,7 +122,9 @@ export async function runOverseerConverse(params: { continue } try { - const result = runOverseerTool(overseer, name, args) + // The conversational surface is the operator-directed write-path, so dispositions + // are allowed here (gated off on the raw HTTP tool-dispatch endpoint). + const result = runOverseerTool(overseer, name, args, true) toolTrace.push({ tool: name, args, ok: true }) // The brain opts into 'full' per call when it needs depth; default lean. const detail = args.detail === 'full' ? 'full' : 'lean' diff --git a/hub/src/overseer/runOverseerTool.ts b/hub/src/overseer/runOverseerTool.ts index 31121f461b..03d2b69320 100644 --- a/hub/src/overseer/runOverseerTool.ts +++ b/hub/src/overseer/runOverseerTool.ts @@ -1,16 +1,34 @@ import { OVERSEER_TOOL_NAMES, + isOverseerWriteTool, overseerToolArgsSchemas, type OverseerToolName } from '@hapi/protocol' import type { OverseerEntity } from '../sync/overseerEntity' +/** Thrown when a write tool (`record_disposition`) is dispatched on a read-only surface (R2 gate). */ +export class OverseerWriteNotAllowedError extends Error { + constructor(tool: string) { + super(`Tool "${tool}" writes and is not allowed on this surface`) + this.name = 'OverseerWriteNotAllowedError' + } +} + /** - * Execute one read-only Overseer tool by name against the entity. Shared by the - * HTTP tool-dispatch route and the converse tool-calling loop so both go through - * exactly one place. Throws `ZodError` on invalid args; every tool is read-only. + * Execute one Overseer tool by name against the entity. Shared by the HTTP tool-dispatch route and + * the converse tool-calling loop so both go through exactly one place. Throws `ZodError` on invalid + * args. Every tool is read-only EXCEPT `record_disposition`; writes are gated behind `allowWrites` + * (the conversational path sets it; the raw HTTP dispatch does not). */ -export function runOverseerTool(overseer: OverseerEntity, tool: OverseerToolName, args: unknown): unknown { +export function runOverseerTool( + overseer: OverseerEntity, + tool: OverseerToolName, + args: unknown, + allowWrites = false +): unknown { + if (isOverseerWriteTool(tool) && !allowWrites) { + throw new OverseerWriteNotAllowedError(tool) + } switch (tool) { case 'query_events': return { events: overseer.queryEvents(overseerToolArgsSchemas.query_events.parse(args)) } @@ -36,6 +54,10 @@ export function runOverseerTool(overseer: OverseerEntity, tool: OverseerToolName return { workers: overseer.listActiveWorkers(overseerToolArgsSchemas.list_active_workers.parse(args)) } case 'query_open_loops': return overseer.queryOpenLoops(overseerToolArgsSchemas.query_open_loops.parse(args)) + case 'query_dispositions': + return overseer.queryDispositions(overseerToolArgsSchemas.query_dispositions.parse(args)) + case 'record_disposition': + return overseer.recordDisposition(overseerToolArgsSchemas.record_disposition.parse(args)) default: { const exhaustive: never = tool throw new Error(`Unknown overseer tool: ${String(exhaustive)}`) diff --git a/hub/src/overseer/toolProjection.test.ts b/hub/src/overseer/toolProjection.test.ts index af71cddad6..a744575700 100644 --- a/hub/src/overseer/toolProjection.test.ts +++ b/hub/src/overseer/toolProjection.test.ts @@ -115,4 +115,40 @@ describe('projectToolResultForBrain', () => { const explanation = { explanation: { inboxItemId: 1, title: 'x' } } expect(projectToolResultForBrain('explain_priority', explanation)).toBe(explanation) }) + + it('thins query_dispositions list rows to predicate keys + as-seen title', () => { + const raw = { + mode: 'list', + total: 1, + rows: [ + { + id: 9, + itemId: 42, + action: 'done', + statusAfter: 'resolved', + feedback: 'y'.repeat(500), + createdAt: 5, + sourceKind: 'worker', + eventType: 'needs_decision', + category: 'QUESTION', + project: 'hapi', + repo: 'tiann/hapi', + title: 'x'.repeat(500) + } + ] + } + const projected = projectToolResultForBrain('query_dispositions', raw) as { + rows: { statusAfter?: unknown; what: string; feedback: string; itemId: number }[] + } + expect(projected.rows[0]?.itemId).toBe(42) + // fat dropped, title/feedback truncated + expect(projected.rows[0]?.statusAfter).toBeUndefined() + expect((projected.rows[0]?.what as string).length).toBeLessThan(100) + expect((projected.rows[0]?.feedback as string).length).toBeLessThan(140) + }) + + it('passes query_dispositions cluster mode through untouched (already tiny)', () => { + const raw = { mode: 'cluster', total: 1, clusters: [{ keys: { category: 'QUESTION' }, count: 3, actions: { done: 3 }, lastCreatedAt: 9 }] } + expect(projectToolResultForBrain('query_dispositions', raw)).toBe(raw) + }) }) diff --git a/hub/src/overseer/toolProjection.ts b/hub/src/overseer/toolProjection.ts index 33be657c03..0e491201aa 100644 --- a/hub/src/overseer/toolProjection.ts +++ b/hub/src/overseer/toolProjection.ts @@ -47,6 +47,20 @@ function projectInboxItem(item: unknown): Record { return { id: o.id, what: o.title, status: o.status, priority: o.priority } } +/** Disposition list row → the predicate keys + as-seen title (the rest is one query away). */ +function projectDispositionRow(row: unknown): Record { + const o = isObj(row) ? row : {} + return { + itemId: o.itemId, + action: o.action, + category: o.category, + project: o.project, + eventType: o.eventType, + what: truncate(o.title, 80), + feedback: truncate(o.feedback, 120) + } +} + function len(value: unknown): number | undefined { return Array.isArray(value) ? value.length : undefined } @@ -149,6 +163,13 @@ export function projectToolResultForBrain( if (tool === 'query_open_loops' && isObj(result) && Array.isArray(result.openLoops)) { return { counts: result.counts, openLoops: result.openLoops.map(projectOpenLoop) } } + if (tool === 'query_dispositions' && isObj(result)) { + // cluster mode is already tiny (key tuples + counts); only thin list rows. + if (Array.isArray(result.rows)) { + return { mode: result.mode, total: result.total, rows: result.rows.map(projectDispositionRow) } + } + return result + } if (tool === 'get_session_state' && isObj(result) && 'state' in result) { return { state: result.state == null ? null : projectSessionState(result.state) } } diff --git a/hub/src/store/inboxItems.test.ts b/hub/src/store/inboxItems.test.ts index b01933ed86..a2199a7be3 100644 --- a/hub/src/store/inboxItems.test.ts +++ b/hub/src/store/inboxItems.test.ts @@ -3,6 +3,7 @@ import { buildOverseerSessionIdentity, mergeEventPayloadWithSession } from '@hap import { Store } from './index' import type { StoredSession } from './types' import { deleteSession } from './sessions' +import { ensureOverseerInboxSchema } from './inboxItems' import { Database } from 'bun:sqlite' function payloadForSession(session: StoredSession, extra: Record = {}): string { @@ -28,6 +29,36 @@ describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => { expect(names.has('inbox_operator_actions')).toBe(true) }) + it('adds the R8 disposition snapshot columns to a pre-existing inbox_operator_actions table', () => { + const db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = ON') + // Simulate the live DB shape BEFORE the keystone: the old 6-column table. + db.exec(` + CREATE TABLE inbox_operator_actions ( + id INTEGER PRIMARY KEY, + inbox_item_id INTEGER NOT NULL, + action TEXT NOT NULL, + status_after TEXT NOT NULL, + feedback TEXT, + created_at INTEGER NOT NULL + ); + `) + db.exec("INSERT INTO inbox_operator_actions (inbox_item_id, action, status_after, feedback, created_at) VALUES (1, 'done', 'resolved', NULL, 1000)") + + ensureOverseerInboxSchema(db) + + const cols = new Set( + (db.prepare('PRAGMA table_info(inbox_operator_actions)').all() as { name: string }[]).map((c) => c.name) + ) + for (const col of ['source_kind', 'source_ref', 'event_type', 'category', 'project', 'artifact_kind', 'repo', 'context_snapshot_json']) { + expect(cols.has(col)).toBe(true) + } + // Pre-existing row survives with NULL snapshot; migration is idempotent on re-run. + expect((db.prepare('SELECT COUNT(*) AS n FROM inbox_operator_actions').get() as { n: number }).n).toBe(1) + expect(() => ensureOverseerInboxSchema(db)).not.toThrow() + db.close() + }) + it('promotes attention events into one active item per session', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('inbox-promo', { flavor: 'codex', name: 'peer-x' }, null, 'default') @@ -227,4 +258,84 @@ describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => { expect(after?.title).toBe('meta HAPI triage') expect(after?.relatedSessionId).toBeNull() }) + + it('hides sleeping snoozes by default but returns them when statuses includes snoozed', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('snooze-q', { name: 's' }, null, 'default') + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'blocked', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + const item = store.inbox.promoteAttentionEvent(event!)! + const wake = Date.now() + 60_000 + store.inbox.recordOperatorAction(item.id, 'snooze', null, wake) + + expect(store.inbox.list({ activeOnly: true }).map((i) => i.id)).not.toContain(item.id) + expect(store.inbox.list({ + statuses: ['snoozed'], + includeSleepingSnoozed: true + }).map((i) => i.id)).toContain(item.id) + }) + + it('promotion during an active snooze updates the same item instead of inserting a duplicate', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('snooze-dedup', { name: 's' }, null, 'default') + const first = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'first', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + const item = store.inbox.promoteAttentionEvent(first!)! + store.inbox.recordOperatorAction(item.id, 'snooze', null, Date.now() + 60_000) + + const second = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + eventType: 'needs_decision', + attentionCandidate: 1, + summary: 'second while snoozed', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + const again = store.inbox.promoteAttentionEvent(second!)! + expect(again.id).toBe(item.id) + expect(store.inbox.list({ statuses: ['new', 'surfaced', 'deferred', 'snoozed'] }).filter((i) => i.relatedSessionId === session.id)).toHaveLength(1) + }) + + it('disposition snapshot uses title-priority artifact kind (PR over generic URL)', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('art-prio', { name: 's' }, null, 'default') + const refs = JSON.stringify([ + { kind: 'url', url: 'https://example.com', title: 'generic' }, + { kind: 'github_pr', ref: 'heavygee/hapi#102', title: 'fix: snooze', repo: 'heavygee/hapi' } + ]) + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'needs_review', + attentionCandidate: 1, + summary: 'review', + relatedSessionId: session.id, + artifactRefs: refs, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + const item = store.inbox.promoteAttentionEvent(event!)! + store.inbox.recordOperatorAction(item.id, 'done', null, null) + const rows = store.inbox.listDispositions({ limit: 5 }) + expect(rows[0]?.artifactKind).toBe('github_pr') + expect(rows[0]?.repo).toBe('heavygee/hapi') + }) }) diff --git a/hub/src/store/inboxItems.ts b/hub/src/store/inboxItems.ts index ed45580ceb..2cff70071e 100644 --- a/hub/src/store/inboxItems.ts +++ b/hub/src/store/inboxItems.ts @@ -4,8 +4,13 @@ import { buildInboxTitleFromEvent, computeCoarseBasePriority, isActiveInboxStatus, + DISPOSITION_PREDICATE_COLUMNS, mapEventTypeToInboxCategory, mapOperatorActionToStatus, + parseArtifactRefs, + pickPrimaryArtifact, + type ArtifactRef, + type DispositionPredicateColumn, type InboxOperatorAction } from '@hapi/protocol' import type { StoredSystemEvent } from './events' @@ -46,6 +51,40 @@ export type StoredInboxOperatorAction = { statusAfter: string feedback: string | null createdAt: number + /** R8 disposition snapshot — the as-seen predicate vocabulary (also the standing-order match keys). */ + sourceKind: string | null + sourceRef: string | null + eventType: string | null + category: string | null + project: string | null + artifactKind: string | null + repo: string | null + /** As-seen render/audit blob: title, summary, severity, priorities, provenance, artifactRefs, sourceEventIds. */ + contextSnapshot: DispositionContextSnapshot | null +} + +/** As-seen blob frozen on the disposition row (R8) — for tombstone render + audit + future bucket keys. */ +export type DispositionContextSnapshot = { + title: string + summary: string + severity: number | null + basePriority: number + priority: number + provenance: string | null + artifactRefs: string | null + sourceEventIds: number[] +} + +/** The R8 predicate columns + blob, derived once at disposition write time. */ +export type DispositionSnapshot = { + sourceKind: string | null + sourceRef: string | null + eventType: string | null + category: string | null + project: string | null + artifactKind: string | null + repo: string | null + contextSnapshot: DispositionContextSnapshot } export type ListInboxItemsOptions = { @@ -55,6 +94,12 @@ export type ListInboxItemsOptions = { /** Explicit status allow-list (overrides activeOnly when set). */ statuses?: string[] | null category?: string | null + /** + * When true and statuses includes `snoozed`, return still-sleeping rows + * (explicit "what is snoozed?" queries). Default false: hide sleeping snoozes + * even if `snoozed` appears in a default status list. + */ + includeSleepingSnoozed?: boolean } type InboxItemRow = { @@ -136,6 +181,23 @@ function syncSourceEventLinks(db: Database, inboxItemId: number, eventIds: numbe } } +/** Wake snoozed items whose sleep window has elapsed (wake-on-read). */ +function wakeExpiredSnoozes(db: Database, now: number): void { + db.prepare(` + UPDATE inbox_items + SET status = 'surfaced', snoozed_until = NULL, updated_at = ? + WHERE status = 'snoozed' + AND snoozed_until IS NOT NULL + AND snoozed_until <= ? + `).run(now, now) +} + +/** Exclude items still sleeping: status=snoozed with a future snoozed_until. */ +function appendSnoozeVisibilityClause(clauses: string[], params: Array, now: number): void { + clauses.push("(status != 'snoozed' OR snoozed_until IS NULL OR snoozed_until <= ?)") + params.push(now) +} + /** Clear session FK refs so DELETE FROM sessions succeeds (items are audit-retained). */ export function detachSessionInboxItems(db: Database, sessionId: string): number { const result = db.prepare( @@ -168,6 +230,9 @@ export function countInboxItems(db: Database): number { } export function listInboxItems(db: Database, options: ListInboxItemsOptions = {}): StoredInboxItem[] { + const now = Date.now() + wakeExpiredSnoozes(db, now) + const limit = Math.min(Math.max(options.limit ?? 50, 1), 200) const clauses: string[] = [] const params: Array = [] @@ -176,8 +241,16 @@ export function listInboxItems(db: Database, options: ListInboxItemsOptions = {} const placeholders = options.statuses.map(() => '?').join(', ') clauses.push(`status IN (${placeholders})`) params.push(...options.statuses) + // Only an explicit "include sleeping" request returns future snoozes. + // Default Overseer inbox lists may mention status 'snoozed' but still hide sleepers. + if (!(options.includeSleepingSnoozed && options.statuses.includes('snoozed'))) { + appendSnoozeVisibilityClause(clauses, params, now) + } } else if (options.activeOnly) { clauses.push("status IN ('new', 'surfaced', 'deferred', 'snoozed')") + appendSnoozeVisibilityClause(clauses, params, now) + } else { + appendSnoozeVisibilityClause(clauses, params, now) } if (options.sessionId) { clauses.push('related_session_id = ?') @@ -198,7 +271,34 @@ export function listInboxItems(db: Database, options: ListInboxItemsOptions = {} return rows.map(mapRow) } +/** + * Visible active item for a session (excludes still-sleeping snoozes). + * Use for operator-facing inbox views. + */ export function findActiveInboxItemForSession(db: Database, sessionId: string): StoredInboxItem | null { + const now = Date.now() + wakeExpiredSnoozes(db, now) + + const row = db.prepare(` + SELECT * FROM inbox_items + WHERE related_session_id = ? + AND status IN ('new', 'surfaced', 'deferred', 'snoozed') + AND (status != 'snoozed' OR snoozed_until IS NULL OR snoozed_until <= ?) + ORDER BY updated_at DESC + LIMIT 1 + `).get(sessionId, now) as InboxItemRow | undefined + return row ? mapRow(row) : null +} + +/** + * Dedup lookup for promoteAttentionEvent — includes sleeping snoozed rows so a + * new attention event during a snooze updates the existing item instead of + * inserting a second active row for the same session. + */ +export function findInboxItemForSessionDedup(db: Database, sessionId: string): StoredInboxItem | null { + const now = Date.now() + wakeExpiredSnoozes(db, now) + const row = db.prepare(` SELECT * FROM inbox_items WHERE related_session_id = ? @@ -221,7 +321,7 @@ export function promoteAttentionEvent( const basePriority = computeCoarseBasePriority(event.eventType) const title = buildInboxTitleFromEvent(event.artifactRefs, event.payloadJson, event.summary) const suggestedAction = extractSuggestedAction(event.payloadJson) - const existing = findActiveInboxItemForSession(db, event.relatedSessionId) + const existing = findInboxItemForSessionDedup(db, event.relatedSessionId) const sourceEventIds = existing ? Array.from(new Set([...existing.sourceEventIds, event.id])) @@ -300,6 +400,74 @@ export function promoteAttentionEvent( return getInboxItemById(db, id) } +/** Parse `owner/repo` from an artifact ref (explicit `repo` field, else a GitHub URL). */ +function repoFromArtifact(ref: ArtifactRef | undefined): string | null { + if (!ref) return null + const explicit = (ref as { repo?: unknown }).repo + if (typeof explicit === 'string' && explicit.trim()) return explicit.trim() + const url = typeof ref.url === 'string' ? ref.url : null + if (!url) return null + const m = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git|\/|$)/i) + return m ? m[1] : null +} + +/** + * Freeze the R8 as-seen snapshot for a disposition (write-time). Derived from the item plus its + * primary (latest) source event. Shared by every DISPOSITION write path — the conversational + * `record_disposition` now, and standing-order enactments in Phase 3 — so the predicate vocabulary + * is populated identically regardless of who records the decision. + * + * NOT used by F5 auto-decay: `sweepDecayedTerminalItems` is a bulk `UPDATE inbox_items` that never + * calls `recordInboxOperatorAction`, and deliberately so. `inbox_operator_actions` is a DECISIONS + * table, not a full status-transition audit — routing mechanical auto-resolve through it would flood + * discovery with `action='done'` on FINALE and let the GROUP BY "discover" a preference that is just + * the F5 mechanism (circular). Dispositions = decisions; F5 = plumbing. (`query_events` rehydrates + * "what happened to X?" for auto-resolved items.) + */ +export function buildDispositionSnapshot(db: Database, item: StoredInboxItem): DispositionSnapshot { + const primaryEventId = item.sourceEventIds.length + ? Math.max(...item.sourceEventIds) + : null + const event = primaryEventId != null ? getSystemEventById(db, primaryEventId) : null + + let project: string | null = null + if (event?.payloadJson) { + try { + const payload = JSON.parse(event.payloadJson) as { session?: { project?: unknown } } + if (typeof payload.session?.project === 'string' && payload.session.project.trim()) { + project = payload.session.project.trim() + } + } catch { + project = null + } + } + + // as-seen artifacts prefer the inbox item's snapshot, falling back to the source event. + // Use the same priority rule as the displayed inbox title (PR > URL, etc.). + const artifactsRaw = item.artifactRefs ?? event?.artifactRefs ?? null + const primaryArtifact = pickPrimaryArtifact(parseArtifactRefs(artifactsRaw)) + + return { + sourceKind: event?.sourceKind ?? null, + sourceRef: event?.sourceRef ?? null, + eventType: event?.eventType ?? null, + category: item.category, + project, + artifactKind: primaryArtifact?.kind ?? null, + repo: repoFromArtifact(primaryArtifact ?? undefined), + contextSnapshot: { + title: item.title, + summary: item.summary, + severity: event?.severity ?? null, + basePriority: item.basePriority, + priority: item.priority, + provenance: event?.provenance ?? null, + artifactRefs: artifactsRaw, + sourceEventIds: item.sourceEventIds + } + } +} + export function recordInboxOperatorAction( db: Database, inboxItemId: number, @@ -312,10 +480,29 @@ export function recordInboxOperatorAction( const now = Date.now() const statusAfter = mapOperatorActionToStatus(action) + const snapshot = buildDispositionSnapshot(db, item) db.prepare(` - INSERT INTO inbox_operator_actions (inbox_item_id, action, status_after, feedback, created_at) - VALUES (?, ?, ?, ?, ?) - `).run(inboxItemId, action, statusAfter, feedback, now) + INSERT INTO inbox_operator_actions ( + inbox_item_id, action, status_after, feedback, created_at, + source_kind, source_ref, event_type, category, project, artifact_kind, repo, + context_snapshot_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + inboxItemId, + action, + statusAfter, + feedback, + now, + snapshot.sourceKind, + snapshot.sourceRef, + snapshot.eventType, + snapshot.category, + snapshot.project, + snapshot.artifactKind, + snapshot.repo, + JSON.stringify(snapshot.contextSnapshot) + ) const resolvedAt = statusAfter === 'resolved' || statusAfter === 'obsoleted' ? now : null db.prepare(` @@ -331,6 +518,169 @@ export function recordInboxOperatorAction( return getInboxItemById(db, inboxItemId) } +export type DispositionGroupColumn = DispositionPredicateColumn + +export type QueryDispositionsFilter = { + action?: string | null + /** When action is unset, restrict to these actions (exclude route/retry noise). */ + actionsAllowlist?: readonly string[] | null + sourceKind?: string | null + sourceRef?: string | null + eventType?: string | null + category?: string | null + project?: string | null + artifactKind?: string | null + repo?: string | null + sinceTs?: number | null + limit?: number +} + +/** One cluster from the discovery/GROUP BY mode (R3): the shared row shape + `GROUP BY` + `HAVING count>=N`. */ +export type DispositionCluster = { + keys: Partial> + count: number + /** action -> count within the cluster (so discovery sees the dominant disposition). */ + actions: Record + lastCreatedAt: number +} + +type OperatorActionRow = { + id: number + inbox_item_id: number + action: string + status_after: string + feedback: string | null + created_at: number + source_kind: string | null + source_ref: string | null + event_type: string | null + category: string | null + project: string | null + artifact_kind: string | null + repo: string | null + context_snapshot_json: string | null +} + +function mapOperatorActionRow(row: OperatorActionRow): StoredInboxOperatorAction { + let contextSnapshot: DispositionContextSnapshot | null = null + if (row.context_snapshot_json) { + try { + contextSnapshot = JSON.parse(row.context_snapshot_json) as DispositionContextSnapshot + } catch { + contextSnapshot = null + } + } + return { + id: row.id, + inboxItemId: row.inbox_item_id, + action: row.action as InboxOperatorAction, + statusAfter: row.status_after, + feedback: row.feedback, + createdAt: row.created_at, + sourceKind: row.source_kind, + sourceRef: row.source_ref, + eventType: row.event_type, + category: row.category, + project: row.project, + artifactKind: row.artifact_kind, + repo: row.repo, + contextSnapshot + } +} + +function buildDispositionWhere(filter: QueryDispositionsFilter): { + sql: string + params: (string | number | null)[] +} { + const clauses: string[] = [] + const params: (string | number | null)[] = [] + const eq = (col: string, val: string | null | undefined) => { + if (val != null) { + clauses.push(`${col} = ?`) + params.push(val) + } + } + eq('action', filter.action) + if (!filter.action && filter.actionsAllowlist && filter.actionsAllowlist.length > 0) { + const placeholders = filter.actionsAllowlist.map(() => '?').join(', ') + clauses.push(`action IN (${placeholders})`) + params.push(...filter.actionsAllowlist) + } + eq('source_kind', filter.sourceKind) + eq('source_ref', filter.sourceRef) + eq('event_type', filter.eventType) + eq('category', filter.category) + eq('project', filter.project) + eq('artifact_kind', filter.artifactKind) + eq('repo', filter.repo) + if (filter.sinceTs != null) { + clauses.push('created_at >= ?') + params.push(filter.sinceTs) + } + return { sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', params } +} + +/** List disposition rows (newest first) — the R3 shared reader shape. */ +export function listDispositions( + db: Database, + filter: QueryDispositionsFilter = {} +): StoredInboxOperatorAction[] { + const { sql, params } = buildDispositionWhere(filter) + const limit = Math.max(1, Math.min(filter.limit ?? 50, 200)) + const rows = db + .prepare( + `SELECT * FROM inbox_operator_actions ${sql} ORDER BY created_at DESC LIMIT ?` + ) + .all(...params, limit) as OperatorActionRow[] + return rows.map(mapOperatorActionRow) +} + +/** + * Cluster mode (R3 discovery): `GROUP BY(groupBy predicate cols)` + `HAVING count>=minCount`. + * The watcher is just this reader + aggregation on the same row shape. + */ +export function clusterDispositions( + db: Database, + groupBy: DispositionGroupColumn[], + minCount: number, + filter: QueryDispositionsFilter = {} +): DispositionCluster[] { + const cols = groupBy.filter((c): c is DispositionGroupColumn => + (DISPOSITION_PREDICATE_COLUMNS as readonly string[]).includes(c) + ) + if (cols.length === 0) return [] + const { sql, params } = buildDispositionWhere(filter) + const selectCols = cols.join(', ') + const rows = db + .prepare( + `SELECT ${selectCols}, action, COUNT(*) AS n, MAX(created_at) AS last_created_at + FROM inbox_operator_actions ${sql} + GROUP BY ${selectCols}, action` + ) + .all(...params) as (Record & { n: number; last_created_at: number })[] + + // Fold the per-action rows into one cluster per key tuple. + const byKey = new Map() + for (const row of rows) { + const keys: Partial> = {} + for (const c of cols) keys[c] = row[c] ?? null + const keyId = cols.map((c) => `${c}=${row[c] ?? '∅'}`).join('|') + let cluster = byKey.get(keyId) + if (!cluster) { + cluster = { keys, count: 0, actions: {}, lastCreatedAt: 0 } + byKey.set(keyId, cluster) + } + const action = String(row.action) + cluster.actions[action] = (cluster.actions[action] ?? 0) + row.n + cluster.count += row.n + cluster.lastCreatedAt = Math.max(cluster.lastCreatedAt, row.last_created_at) + } + return Array.from(byKey.values()) + .filter((c) => c.count >= Math.max(1, minCount)) + .sort((a, b) => b.count - a.count) + .slice(0, Math.max(1, Math.min(filter.limit ?? 50, 200))) +} + function extractSuggestedAction(payloadJson: string | null): string | null { if (!payloadJson) return null try { @@ -395,14 +745,66 @@ export function ensureOverseerInboxSchema(db: Database): void { action TEXT NOT NULL, status_after TEXT NOT NULL, feedback TEXT, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + source_kind TEXT, + source_ref TEXT, + event_type TEXT, + category TEXT, + project TEXT, + artifact_kind TEXT, + repo TEXT, + context_snapshot_json TEXT ); CREATE INDEX IF NOT EXISTS idx_inbox_operator_actions_item ON inbox_operator_actions(inbox_item_id, created_at DESC); `) + + // R8 disposition snapshot columns — idempotent ADD COLUMN for DBs created before the + // keystone. The snapshot columns ARE the standing-order predicate fields and the discovery + // GROUP BY keys (one shared vocabulary). Blob (context_snapshot_json) holds as-seen render + // context (title/summary/severity/priority/provenance/artifact_refs/source event ids). + // + // Phase 3 forward flag (NOT now): when standing-order auto-handling enacts operator policy, those + // enactments SHOULD write dispositions WITH the snapshot (they are pre-authorized decisions) — but + // discovery must then mine operator-authored rows only, or it re-suggests orders it already + // enacts. That is when an `actor` column (operator | standing_order: | system) starts to + // matter. Left out of v1 deliberately; the ADD COLUMN pattern here graduates it cleanly later. + ensureInboxOperatorActionSnapshotColumns(db) + + // Discovery clusters on the predicate vocabulary (P2 GROUP BY); index the primary axes. + db.exec(` + CREATE INDEX IF NOT EXISTS idx_inbox_operator_actions_bucket + ON inbox_operator_actions(source_kind, event_type, category, project); + `) +} + +const INBOX_OPERATOR_ACTION_SNAPSHOT_COLUMNS = [ + 'source_kind', + 'source_ref', + 'event_type', + 'category', + 'project', + 'artifact_kind', + 'repo', + 'context_snapshot_json' +] as const + +/** Idempotent `ADD COLUMN` for the R8 snapshot columns (SQLite has no `ADD COLUMN IF NOT EXISTS`). */ +function ensureInboxOperatorActionSnapshotColumns(db: Database): void { + const existing = new Set( + (db.prepare('PRAGMA table_info(inbox_operator_actions)').all() as { name: string }[]).map( + (c) => c.name + ) + ) + for (const col of INBOX_OPERATOR_ACTION_SNAPSHOT_COLUMNS) { + if (!existing.has(col)) { + db.exec(`ALTER TABLE inbox_operator_actions ADD COLUMN ${col} TEXT`) + } + } } export function dropOverseerInboxSchema(db: Database): void { db.exec(` + DROP INDEX IF EXISTS idx_inbox_operator_actions_bucket; DROP INDEX IF EXISTS idx_inbox_operator_actions_item; DROP TABLE IF EXISTS inbox_operator_actions; DROP INDEX IF EXISTS idx_inbox_item_source_events_event; diff --git a/hub/src/store/inboxStore.ts b/hub/src/store/inboxStore.ts index 35e6d3ce7f..a27dc83b3d 100644 --- a/hub/src/store/inboxStore.ts +++ b/hub/src/store/inboxStore.ts @@ -2,15 +2,21 @@ import type { Database } from 'bun:sqlite' import type { InboxOperatorAction } from '@hapi/protocol' import type { StoredSystemEvent } from './events' import { + clusterDispositions, countInboxItems, findActiveInboxItemForSession, getInboxItemById, + listDispositions, listInboxItems, promoteAttentionEvent, recordInboxOperatorAction, repointSessionInboxItems, + type DispositionCluster, + type DispositionGroupColumn, type ListInboxItemsOptions, - type StoredInboxItem + type QueryDispositionsFilter, + type StoredInboxItem, + type StoredInboxOperatorAction } from './inboxItems' export type { ListInboxItemsOptions, StoredInboxItem } @@ -50,4 +56,18 @@ export class InboxStore { repointSession(fromSessionId: string, toSessionId: string): number { return repointSessionInboxItems(this.db, fromSessionId, toSessionId) } + + /** R3 shared reader — list disposition rows (newest first) filtered by the predicate vocabulary. */ + listDispositions(filter: QueryDispositionsFilter = {}): StoredInboxOperatorAction[] { + return listDispositions(this.db, filter) + } + + /** R3 discovery mode — cluster dispositions by predicate columns (`GROUP BY` + `HAVING count>=N`). */ + clusterDispositions( + groupBy: DispositionGroupColumn[], + minCount: number, + filter: QueryDispositionsFilter = {} + ): DispositionCluster[] { + return clusterDispositions(this.db, groupBy, minCount, filter) + } } diff --git a/hub/src/sync/overseerEntity.test.ts b/hub/src/sync/overseerEntity.test.ts index b406936107..487a2dbc29 100644 --- a/hub/src/sync/overseerEntity.test.ts +++ b/hub/src/sync/overseerEntity.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' import { Store } from '../store' import { SyncEngine } from './syncEngine' import { RpcRegistry } from '../socket/rpcRegistry' +import { OverseerWriteNotAllowedError, runOverseerTool } from '../overseer/runOverseerTool' import type { OverseerEntity } from './overseerEntity' function makeEngine(): { store: Store; engine: SyncEngine } { @@ -249,3 +251,166 @@ describe('OverseerEntity read-only tools', () => { expect(convoEvents.length).toBe(1) }) }) + +describe('OverseerEntity dispositions (Stage 1 keystone)', () => { + function promoteItem( + store: Store, + opts: { key: string; eventType: string; project: string; artifactRefs?: string | null } + ): { itemId: number } { + const session = store.sessions.getOrCreateSession( + opts.key, + { flavor: 'codex', path: '/tmp/web' }, + null, + 'default' + ) + const event = store.events.insert({ + ts: Date.now(), + sourceKind: 'worker', + sourceRef: opts.key, + eventType: opts.eventType, + attentionCandidate: 1, + severity: 4, + summary: `${opts.eventType} on ${opts.project}`, + relatedSessionId: session.id, + artifactRefs: opts.artifactRefs ?? null, + payloadJson: JSON.stringify({ session: { project: opts.project, name: opts.key } }) + }) + expect(event).not.toBeNull() + const item = store.inbox.promoteAttentionEvent(event!) + expect(item).not.toBeNull() + return { itemId: item!.id } + } + + it('record_disposition writes the R8 snapshot and returns a tombstone', () => { + const store = new Store(':memory:') + const { itemId } = promoteItem(store, { + key: 'disp-1', + eventType: 'needs_decision', + project: 'hapi', + artifactRefs: JSON.stringify([{ kind: 'github_pr', url: 'https://github.com/tiann/hapi/pull/42' }]) + }) + const o = overseer(buildEngine(store)) + + const res = o.recordDisposition({ itemId, action: 'done', feedback: 'ship it' }) + expect(res.ok).toBe(true) + expect(res.action).toBe('done') + expect(res.statusAfter).toBe('resolved') + expect(res.tombstone).toContain(`#${itemId}`) + expect(res.tombstone.toLowerCase()).toContain('resolved') + + // The disposition row carries the frozen predicate vocabulary (R8). + const listed = o.queryDispositions({}) + expect(listed.mode).toBe('list') + expect(listed.total).toBe(1) + const row = listed.rows?.[0] + expect(row?.itemId).toBe(itemId) + expect(row?.action).toBe('done') + expect(row?.category).toBe('QUESTION') + expect(row?.project).toBe('hapi') + expect(row?.eventType).toBe('needs_decision') + expect(row?.sourceRef).toBe('disp-1') + expect(row?.artifactKind).toBe('github_pr') + expect(row?.feedback).toBe('ship it') + }) + + it('record_disposition derives artifact_kind + repo from the as-seen artifact', () => { + const store = new Store(':memory:') + const { itemId } = promoteItem(store, { + key: 'disp-repo', + eventType: 'needs_review', + project: 'hapi', + artifactRefs: JSON.stringify([{ kind: 'github_pr', url: 'https://github.com/tiann/hapi/pull/99' }]) + }) + const o = overseer(buildEngine(store)) + o.recordDisposition({ itemId, action: 'dismiss' }) + + const cluster = o.queryDispositions({ groupBy: ['repo', 'artifact_kind'], minCount: 1 }) + expect(cluster.mode).toBe('cluster') + const c = cluster.clusters?.[0] + expect(c?.keys.repo).toBe('tiann/hapi') + expect(c?.keys.artifact_kind).toBe('github_pr') + expect(c?.count).toBe(1) + }) + + it('query_dispositions cluster mode groups by predicate columns with HAVING minCount', () => { + const store = new Store(':memory:') + const a = promoteItem(store, { key: 'c-a', eventType: 'needs_decision', project: 'hapi' }) + const b = promoteItem(store, { key: 'c-b', eventType: 'needs_decision', project: 'hapi' }) + const c = promoteItem(store, { key: 'c-c', eventType: 'blocked', project: 'lockhouse' }) + const o = overseer(buildEngine(store)) + o.recordDisposition({ itemId: a.itemId, action: 'done' }) + o.recordDisposition({ itemId: b.itemId, action: 'done' }) + o.recordDisposition({ itemId: c.itemId, action: 'dismiss' }) + + // Two QUESTION/done, one BLOCKED/dismiss. minCount 2 keeps only the dominant bucket. + const clusters = o.queryDispositions({ groupBy: ['category', 'action'], minCount: 2 }) + expect(clusters.clusters?.length).toBe(1) + const only = clusters.clusters?.[0] + expect(only?.keys.category).toBe('QUESTION') + expect(only?.keys.action).toBe('done') + expect(only?.count).toBe(2) + }) + + it('query_dispositions cluster mode respects limit', () => { + const store = new Store(':memory:') + const o = overseer(buildEngine(store)) + for (let i = 0; i < 4; i++) { + const { itemId } = promoteItem(store, { + key: `lim-${i}`, + eventType: 'needs_decision', + project: `proj-${i}` + }) + o.recordDisposition({ itemId, action: 'done' }) + } + const clusters = o.queryDispositions({ groupBy: ['project'], minCount: 1, limit: 2 }) + expect(clusters.clusters?.length).toBe(2) + expect(clusters.total).toBe(2) + }) + + it('snoozed inbox items stay hidden until wake time, then resurface on read', () => { + const store = new Store(':memory:') + const { itemId } = promoteItem(store, { key: 'snooze-hide', eventType: 'blocked', project: 'hapi' }) + const o = overseer(buildEngine(store)) + const future = Date.now() + 86_400_000 + o.recordDisposition({ itemId, action: 'snooze', snoozedUntil: future }) + + expect(o.queryInbox({}).items).toHaveLength(0) + + const db: Database = (store as unknown as { db: Database }).db + db.prepare( + 'UPDATE inbox_items SET snoozed_until = ?, updated_at = ? WHERE id = ?' + ).run(Date.now() - 1000, Date.now() - 1000, itemId) + + const afterWake = o.queryInbox({}) + expect(afterWake.items).toHaveLength(1) + expect(afterWake.items[0]?.status).toBe('surfaced') + }) + + it('record_disposition rejects unknown item and snooze-without-timestamp without writing', () => { + const store = new Store(':memory:') + const o = overseer(buildEngine(store)) + + const missing = o.recordDisposition({ itemId: 9999, action: 'done' }) + expect(missing.ok).toBe(false) + + const { itemId } = promoteItem(store, { key: 'sn', eventType: 'blocked', project: 'hapi' }) + const badSnooze = o.recordDisposition({ itemId, action: 'snooze' }) + expect(badSnooze.ok).toBe(false) + // Nothing recorded on either failure. + expect(o.queryDispositions({}).total).toBe(0) + }) + + it('record_disposition is gated: runOverseerTool refuses the write unless allowWrites', () => { + const store = new Store(':memory:') + const { itemId } = promoteItem(store, { key: 'gate', eventType: 'blocked', project: 'hapi' }) + const o = overseer(buildEngine(store)) + expect(() => runOverseerTool(o, 'record_disposition', { itemId, action: 'done' })).toThrow( + OverseerWriteNotAllowedError + ) + // With writes allowed (the conversational path) it lands. + const res = runOverseerTool(o, 'record_disposition', { itemId, action: 'done' }, true) as { + ok: boolean + } + expect(res.ok).toBe(true) + }) +}) diff --git a/hub/src/sync/overseerEntity.ts b/hub/src/sync/overseerEntity.ts index 232d105482..a638176a32 100644 --- a/hub/src/sync/overseerEntity.ts +++ b/hub/src/sync/overseerEntity.ts @@ -1,11 +1,12 @@ /** - * Overseer entity — read-only hub service (Step 3 / Stage 0). + * Overseer entity — hub service (Step 3 / Stage 1 keystone). * - * Implements the seven read-only tools the Overseer uses to reason about the - * fleet, plus `convo_turn` writeback. Everything here is read-only against the - * substrate (events + inbox + sessions + messages); no dispatch, no mutation of - * worker state. The single mutation it performs is appending its own - * memory-bearing `convo_turn` events (contracts §1 three-layer model). + * Implements the read-only tools the Overseer uses to reason about the fleet, + * plus `convo_turn` writeback, plus the single substrate write: `record_disposition` + * (the operator's explicit decision on an inbox item, freezing the R8 as-seen + * snapshot). No dispatch, no mutation of worker state. Reads are against + * events + inbox + sessions + messages; the one write funnels through the shared + * `recordOperatorAction` store method (contracts §1 three-layer model). */ import { @@ -23,15 +24,21 @@ import { type OverseerConvoTurnInput, type OverseerExplainPriority, type OverseerIdentity, + type OverseerDispositionCluster, + type OverseerDispositionResult, + type OverseerDispositionRow, + type OverseerDispositionsResult, type OverseerOpenLoop, type OverseerOpenLoopsResult, type OverseerRecentOutputChunk, type OverseerSessionStateView, type OverseerWorkerHealth, type OverseerWorkerState, + type QueryDispositionsArgs, type QueryEventsArgs, type QueryInboxArgs, type QueryOpenLoopsArgs, + type RecordDispositionArgs, type ListActiveWorkersArgs } from '@hapi/protocol' import { buildOverseerSessionIdentity } from '@hapi/protocol' @@ -131,14 +138,16 @@ export class OverseerEntity { surfaced: StoredInboxItem[] held: StoredInboxItem[] } { - const statuses = args.statuses && args.statuses.length > 0 - ? args.statuses + const statusesExplicit = Boolean(args.statuses && args.statuses.length > 0) + const statuses = statusesExplicit + ? args.statuses! : ['new', 'surfaced', 'deferred', 'snoozed', 'held'] const items = this.inbox.list({ statuses, sessionId: args.sessionId ?? null, category: args.category ?? null, - limit: args.limit ?? 50 + limit: args.limit ?? 50, + includeSleepingSnoozed: statusesExplicit && statuses.includes('snoozed') }) return { items, @@ -421,6 +430,129 @@ export class OverseerEntity { } } + // --- Tool 9: query_dispositions (read) ---------------------------------- + + /** + * R3 shared reader — one reader, two modes on the disposition row shape. + * - default: list recent disposition rows (thinned to the predicate vocabulary + as-seen title). + * - `groupBy` set: cluster mode (`GROUP BY` + `HAVING count>=minCount`) — the discovery watcher shape. + */ + queryDispositions(args: QueryDispositionsArgs = {}): OverseerDispositionsResult { + const filter = { + action: args.action ?? null, + sourceKind: args.sourceKind ?? null, + sourceRef: args.sourceRef ?? null, + eventType: args.eventType ?? null, + category: args.category ?? null, + project: args.project ?? null, + artifactKind: args.artifactKind ?? null, + repo: args.repo ?? null, + sinceTs: args.sinceTs ?? null, + limit: args.limit ?? 50 + } + + if (args.groupBy && args.groupBy.length > 0) { + const clusters = this.inbox + .clusterDispositions(args.groupBy, args.minCount ?? 1, filter) + .map( + (c): OverseerDispositionCluster => ({ + keys: c.keys, + count: c.count, + actions: c.actions, + lastCreatedAt: c.lastCreatedAt + }) + ) + return { mode: 'cluster', clusters, total: clusters.length } + } + + const rows = this.inbox.listDispositions(filter).map( + (r): OverseerDispositionRow => ({ + id: r.id, + itemId: r.inboxItemId, + action: r.action, + statusAfter: r.statusAfter, + feedback: r.feedback, + createdAt: r.createdAt, + sourceKind: r.sourceKind, + sourceRef: r.sourceRef, + eventType: r.eventType, + category: r.category, + project: r.project, + artifactKind: r.artifactKind, + repo: r.repo, + title: r.contextSnapshot?.title ?? null + }) + ) + return { mode: 'list', rows, total: rows.length } + } + + // --- Tool 10: record_disposition (WRITE — the Stage 1 keystone) ---------- + + /** + * The single mutation the Overseer performs on the substrate: record the operator's explicit + * decision on one inbox item, freezing the R8 as-seen snapshot, and return a tombstone. Goes + * through the shared `recordOperatorAction` store method (the same path a Phase 3 standing-order + * enactment will use). F5 auto-decay does NOT produce dispositions — it is bulk plumbing, kept + * out of the decisions table so discovery does not eat its own tail (see buildDispositionSnapshot). + */ + recordDisposition(args: RecordDispositionArgs): OverseerDispositionResult { + const item = this.inbox.getById(args.itemId) + if (!item) { + return { + ok: false, + itemId: args.itemId, + action: args.action, + statusAfter: 'unknown', + tombstone: `No inbox item #${args.itemId} — nothing recorded.` + } + } + if (args.action === 'snooze' && args.snoozedUntil == null) { + return { + ok: false, + itemId: args.itemId, + action: args.action, + statusAfter: item.status, + tombstone: `Snooze needs a snoozedUntil timestamp — nothing recorded for #${args.itemId}.` + } + } + + const updated = this.inbox.recordOperatorAction( + args.itemId, + args.action, + args.feedback ?? null, + args.snoozedUntil ?? null + ) + const statusAfter = updated?.status ?? item.status + return { + ok: true, + itemId: args.itemId, + action: args.action, + statusAfter, + tombstone: this.buildTombstone(args.action, statusAfter, item) + } + } + + private buildTombstone(action: string, statusAfter: string, item: StoredInboxItem): string { + const verb = + action === 'done' + ? 'Resolved' + : action === 'dismiss' + ? 'Dismissed' + : action === 'snooze' + ? 'Snoozed' + : action === 'open' + ? 'Reopened' + : `Recorded ${action} on` + const session = item.relatedSessionId ? this.getSession(item.relatedSessionId) : undefined + const project = session ? deriveIdentity(session).project : null + const where = [item.category, project] + .filter((s): s is string => typeof s === 'string' && s.length > 0) + .join(' / ') + const title = item.title.length > 80 ? `${item.title.slice(0, 77)}…` : item.title + const tail = where ? ` — ${where}` : '' + return `${verb} #${item.id} (${statusAfter})${tail}: ${title}` + } + // --- convo_turn writeback ----------------------------------------------- recordConvoTurn(input: OverseerConvoTurnInput): StoredSystemEvent | null { diff --git a/hub/src/web/routes/overseer.test.ts b/hub/src/web/routes/overseer.test.ts index 2204bb6854..f59db646d4 100644 --- a/hub/src/web/routes/overseer.test.ts +++ b/hub/src/web/routes/overseer.test.ts @@ -29,10 +29,21 @@ describe('overseer routes', () => { systemPrompt: string } expect(body.identity.canDispatch).toBe(false) - expect(body.identity.tools.length).toBe(8) + expect(body.identity.tools.length).toBe(10) expect(body.systemPrompt).toContain('Overseer') }) + it('POST /overseer/tools/record_disposition is gated off on the read-only HTTP surface (403)', async () => { + const store = new Store(':memory:') + const app = buildApp(store) + const res = await app.request('/api/overseer/tools/record_disposition', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ itemId: 1, action: 'done' }) + }) + expect(res.status).toBe(403) + }) + it('GET /overseer/voice returns prompt + backend descriptor', async () => { const app = buildApp(new Store(':memory:')) const res = await app.request('/api/overseer/voice') diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 0dfdfb9dfc..7d629d9347 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -10,7 +10,7 @@ import { listConfiguredVoiceBackends, resolveHubVoiceBackend } from '@hapi/proto import type { SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireSyncEngine } from './guards' -import { isOverseerToolName, runOverseerTool } from '../../overseer/runOverseerTool' +import { isOverseerToolName, OverseerWriteNotAllowedError, runOverseerTool } from '../../overseer/runOverseerTool' import { runOverseerConverse } from '../../overseer/converse' import { BrainUnavailableError, filterChatModels, listBrainModels, listBrainProfiles, resolveBrainConfig } from '../../overseer/brainClient' @@ -89,8 +89,8 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) - // Read-only tool dispatch. All tools are read-only; this endpoint never - // mutates worker or inbox state. + // Read-only tool dispatch. Writes (record_disposition) are gated off here and + // return 403; the conversational path is the operator-directed write surface. app.post('/overseer/tools/:tool', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) return engine @@ -114,6 +114,9 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho if (error instanceof z.ZodError) { return c.json({ error: 'Invalid tool arguments', issues: error.flatten() }, 400) } + if (error instanceof OverseerWriteNotAllowedError) { + return c.json({ error: error.message }, 403) + } throw error } }) diff --git a/scripts/tooling/overseer-brain-battery.mjs b/scripts/tooling/overseer-brain-battery.mjs new file mode 100755 index 0000000000..c172f02734 --- /dev/null +++ b/scripts/tooling/overseer-brain-battery.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env bun +/** + * Overseer live-brain tool-selection battery (opt-in, needs a running brain + a GPU). + * + * The replay harness (hub/src/overseer/replayHarness.ts) is deterministic and CI-safe — it never + * touches an LLM. This battery is the complement: it drives scripted operator utterances through the + * REAL overseer tool schemas + system prompt against a live OpenAI-compatible brain and asserts which + * tool the model picks (and key args) in a single round. It is NOT a CI test (no GPU in CI) — run it + * by hand when validating a brain/model against the tool surface. + * + * Usage: + * BRAIN_URL=http://127.0.0.1:8080/v1 bun scripts/tooling/overseer-brain-battery.mjs + * BRAIN_URL=https://api.openai.com/v1 BRAIN_MODEL=gpt-4o BRAIN_API_KEY=sk-... bun scripts/tooling/overseer-brain-battery.mjs + * + * Exit code: 0 if every scenario passes, 1 otherwise. + */ +import { buildOverseerOpenAiTools, buildOverseerSystemPrompt } from '@hapi/protocol' + +const BRAIN = (process.env.BRAIN_URL ?? 'http://127.0.0.1:8080/v1').replace(/\/+$/, '') +const MODEL = process.env.BRAIN_MODEL ?? 'main' +const API_KEY = process.env.BRAIN_API_KEY ?? '' +const tools = buildOverseerOpenAiTools() +const system = buildOverseerSystemPrompt() + +/** Each scenario: an operator line + a predicate over the first tool call (null tc = the brain answered without a tool). */ +const scenarios = [ + { name: 'inbox read', user: "What's waiting in my inbox right now?", check: (tc) => tc?.name === 'query_inbox' }, + { name: 'neglect lens', user: 'What am I forgetting? Anything I abandoned?', check: (tc) => tc?.name === 'query_open_loops' }, + { + name: 'record done (write)', + user: 'Mark inbox item 42 as done — it shipped.', + check: (tc) => tc?.name === 'record_disposition' && tc.args?.itemId === 42 && tc.args?.action === 'done' + }, + { + name: 'record dismiss (write)', + user: 'Dismiss item 7, it is just the routine PR-flood noise.', + check: (tc) => tc?.name === 'record_disposition' && tc.args?.itemId === 7 && tc.args?.action === 'dismiss' + }, + { + name: 'dispositions cluster (discovery)', + user: 'Show me what I have dispositioned recently, grouped by category.', + check: (tc) => tc?.name === 'query_dispositions' && Array.isArray(tc.args?.groupBy) && tc.args.groupBy.includes('category') + }, + { + name: 'GUARD: asking-about must not write', + user: 'What is the status of item 5? I just want to know, do not change anything.', + check: (tc) => tc == null || tc.name !== 'record_disposition' + } +] + +async function askOnce(user) { + const res = await fetch(`${BRAIN}/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}) }, + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'system', content: system }, { role: 'user', content: user }], + tools, + tool_choice: 'auto', + temperature: 0, + max_tokens: 512 + }) + }) + if (!res.ok) throw new Error(`brain ${res.status}: ${(await res.text()).slice(0, 200)}`) + const data = await res.json() + const call = data.choices?.[0]?.message?.tool_calls?.[0] + if (!call) return { tc: null, raw: data.choices?.[0]?.message?.content ?? '' } + let args = {} + try { args = JSON.parse(call.function?.arguments ?? '{}') } catch { args = { _unparsed: call.function?.arguments } } + return { tc: { name: call.function?.name, args }, raw: '' } +} + +console.log(`Overseer brain battery -> ${BRAIN} (model=${MODEL})\n`) +let pass = 0 +for (const s of scenarios) { + const t0 = Date.now() + try { + const { tc, raw } = await askOnce(s.user) + const ok = s.check(tc) + if (ok) pass += 1 + const shown = tc ? `${tc.name}(${JSON.stringify(tc.args)})` : `NO TOOL — "${raw.slice(0, 60)}"` + console.log(`${ok ? 'PASS' : 'FAIL'} [${Date.now() - t0}ms] ${s.name}\n -> ${shown}`) + } catch (e) { + console.log(`ERROR ${s.name}: ${e.message}`) + } +} +console.log(`\n${pass}/${scenarios.length} scenarios passed`) +process.exit(pass === scenarios.length ? 0 : 1) diff --git a/shared/src/overseerConverse.ts b/shared/src/overseerConverse.ts index 40ffeed9f8..6283c306f4 100644 --- a/shared/src/overseerConverse.ts +++ b/shared/src/overseerConverse.ts @@ -19,6 +19,7 @@ import { OVERSEER_WORKER_STATES, type OverseerToolName } from './overseerEntity' +import { DISPOSITION_PREDICATE_COLUMNS, OVERSEER_DISPOSITION_ACTIONS } from './overseerInbox' export type OverseerConverseRole = 'operator' | 'overseer' @@ -77,7 +78,7 @@ export type OverseerConverseResponse = { } // --------------------------------------------------------------------------- -// OpenAI-compatible function-tool schemas for the 7 read-only tools +// OpenAI-compatible function-tool schemas for the Overseer tool catalog // --------------------------------------------------------------------------- export type OverseerOpenAiTool = { @@ -153,10 +154,31 @@ const OVERSEER_TOOL_PARAMS: Record = { project: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100 }, detail: detailProp - }) + }), + query_dispositions: obj({ + action: { type: 'string', enum: [...OVERSEER_DISPOSITION_ACTIONS], description: 'Filter to one disposition action.' }, + sourceKind: { type: 'string' }, + sourceRef: { type: 'string', description: 'Filter by frozen source_ref predicate.' }, + eventType: { type: 'string' }, + category: { type: 'string' }, + project: { type: 'string' }, + artifactKind: { type: 'string', description: 'Filter by frozen artifact_kind predicate (e.g. github_pr).' }, + repo: { type: 'string' }, + sinceTs: { type: 'integer', minimum: 0, description: 'Epoch ms lower bound on when the disposition was recorded.' }, + groupBy: { type: 'array', items: { type: 'string', enum: [...DISPOSITION_PREDICATE_COLUMNS] }, description: 'Switch to cluster mode: group dispositions by these predicate columns.' }, + minCount: { type: 'integer', minimum: 1, description: 'Cluster mode only: drop clusters smaller than this.' }, + limit: { type: 'integer', minimum: 1, maximum: 200 }, + detail: detailProp + }), + record_disposition: obj({ + itemId: { type: 'integer', minimum: 1, description: 'Inbox item id to dispose.' }, + action: { type: 'string', enum: [...OVERSEER_DISPOSITION_ACTIONS], description: 'done=resolve, dismiss=tombstone, snooze (needs snoozedUntil), open=reopen.' }, + feedback: { type: 'string', description: 'Optional operator note / learning label to freeze with the disposition.' }, + snoozedUntil: { type: 'integer', minimum: 1, description: 'Required for snooze: epoch ms to sleep the item until.' } + }, ['itemId', 'action']) } -/** The 7 read-only tools as an OpenAI-compatible `tools` array for the brain. */ +/** The Overseer tool catalog (read-only + the single disposition write) as an OpenAI `tools` array. */ export function buildOverseerOpenAiTools(): OverseerOpenAiTool[] { return OVERSEER_TOOL_CATALOG.map((entry) => ({ type: 'function', diff --git a/shared/src/overseerEntity.test.ts b/shared/src/overseerEntity.test.ts index aa11d6e883..3d97c895b6 100644 --- a/shared/src/overseerEntity.test.ts +++ b/shared/src/overseerEntity.test.ts @@ -10,6 +10,7 @@ import { buildOverseerSystemPrompt, deriveObservedWorkerState, inferWorkerState, + isOverseerWriteTool, mapEventTypeToWorkerState, mapNotifyStatusToWorkerState, overseerToolArgsSchemas @@ -18,24 +19,29 @@ import { const STALE = 30 * 60 * 1000 describe('overseer entity protocol', () => { - it('catalog covers every tool name and is read-only', () => { + it('catalog covers every tool name; only record_disposition writes (R2)', () => { const catalogNames = OVERSEER_TOOL_CATALOG.map((t) => t.name).sort() expect(catalogNames).toEqual([...OVERSEER_TOOL_NAMES].sort()) - expect(OVERSEER_TOOL_CATALOG.every((t) => t.readonly === true)).toBe(true) + const writeTools = OVERSEER_TOOL_CATALOG.filter((t) => !t.readonly).map((t) => t.name) + expect(writeTools).toEqual(['record_disposition']) + expect(isOverseerWriteTool('record_disposition')).toBe(true) + expect(isOverseerWriteTool('query_events')).toBe(false) }) - it('exposes a read-only (cannot dispatch) identity', () => { + it('exposes a cannot-dispatch identity that CAN record dispositions (Stage 1)', () => { const identity = buildOverseerIdentity() expect(identity.id).toBe(OVERSEER_ENTITY_ID) expect(identity.kind).toBe(OVERSEER_SOURCE_KIND) expect(identity.canDispatch).toBe(false) + expect(identity.canDisposition).toBe(true) expect(identity.tools).toHaveLength(OVERSEER_TOOL_NAMES.length) }) - it('system prompt frames chief-of-staff + read-only + provenance', () => { + it('system prompt frames chief-of-staff + read-only tools + disposition write discipline', () => { const prompt = buildOverseerSystemPrompt() expect(prompt).toContain('chief-of-staff') - expect(prompt.toLowerCase()).toContain('read only') + expect(prompt).toContain('Read-only tools') + expect(prompt).toContain('record_disposition') expect(prompt).toContain('CANNOT dispatch') expect(prompt).toContain('Show receipts') }) @@ -120,5 +126,11 @@ describe('overseer entity protocol', () => { expect(overseerToolArgsSchemas.explain_priority.safeParse({ itemId: 0 }).success).toBe(false) expect(overseerToolArgsSchemas.explain_priority.safeParse({ itemId: 12 }).success).toBe(true) }) + + it('record_disposition rejects route/retry (overseer disposition enum only)', () => { + expect(overseerToolArgsSchemas.record_disposition.safeParse({ itemId: 1, action: 'route' }).success).toBe(false) + expect(overseerToolArgsSchemas.record_disposition.safeParse({ itemId: 1, action: 'retry' }).success).toBe(false) + expect(overseerToolArgsSchemas.record_disposition.safeParse({ itemId: 1, action: 'done' }).success).toBe(true) + }) }) }) diff --git a/shared/src/overseerEntity.ts b/shared/src/overseerEntity.ts index 071c576d6c..34ca135b29 100644 --- a/shared/src/overseerEntity.ts +++ b/shared/src/overseerEntity.ts @@ -1,5 +1,5 @@ /** - * Overseer entity — Step 3 (read-only / Stage 0). + * Overseer entity — Step 3 (read-only tools + Stage 1 disposition write). * * The Overseer is a continuous conversational entity over the fleet. Unlike a * worker session it has no agent process; its "session-equivalent" is the @@ -20,7 +20,8 @@ */ import { z } from 'zod' -import type { InboxItemStatus } from './overseerInbox' +import { DISPOSITION_PREDICATE_COLUMNS, OVERSEER_DISPOSITION_ACTIONS } from './overseerInbox' +import type { DispositionPredicateColumn, InboxItemStatus, InboxOperatorAction, OverseerDispositionAction } from './overseerInbox' /** Stable id for the single fleet-level Overseer entity. */ export const OVERSEER_ENTITY_ID = 'overseer' @@ -263,11 +264,25 @@ export const OVERSEER_TOOL_NAMES = [ 'get_worker_health', 'explain_priority', 'list_active_workers', - 'query_open_loops' + 'query_open_loops', + 'query_dispositions', + 'record_disposition' ] as const export type OverseerToolName = typeof OVERSEER_TOOL_NAMES[number] +/** + * The one tool that writes (Stage 0→1 keystone). Every other tool is read-only against the + * substrate; `record_disposition` is the single explicit, operator-directed mutation. It is + * clearly marked non-`readonly` in the catalog so the write surface stays enumerable (R2). + */ +export const OVERSEER_WRITE_TOOL_NAMES = ['record_disposition'] as const +export type OverseerWriteToolName = typeof OVERSEER_WRITE_TOOL_NAMES[number] + +export function isOverseerWriteTool(name: string): name is OverseerWriteToolName { + return (OVERSEER_WRITE_TOOL_NAMES as readonly string[]).includes(name) +} + const sessionIdSchema = z.string().min(1) /** @@ -347,6 +362,49 @@ export const queryOpenLoopsArgsSchema = z.object({ }) export type QueryOpenLoopsArgs = z.infer +const dispositionPredicateColumnSchema = z.enum(DISPOSITION_PREDICATE_COLUMNS) +const overseerDispositionActionSchema = z.enum(OVERSEER_DISPOSITION_ACTIONS) + +/** + * `query_dispositions` (R3): one reader, two modes on the same row shape. + * - list mode (default): recorded disposition rows, newest first, filtered by the predicate vocabulary. + * - cluster mode (`groupBy` set): `GROUP BY(groupBy)` + `HAVING count>=minCount` — the discovery watcher. + */ +export const queryDispositionsArgsSchema = z.object({ + action: overseerDispositionActionSchema.optional(), + sourceKind: z.string().min(1).optional(), + sourceRef: z.string().min(1).optional(), + eventType: z.string().min(1).optional(), + category: z.string().min(1).optional(), + project: z.string().min(1).optional(), + artifactKind: z.string().min(1).optional(), + repo: z.string().min(1).optional(), + sinceTs: z.number().int().nonnegative().optional(), + /** Set to switch to cluster/discovery mode. Columns from the R8 predicate vocabulary. */ + groupBy: z.array(dispositionPredicateColumnSchema).min(1).optional(), + /** Cluster mode only: minimum rows per cluster (`HAVING`). Default 1. */ + minCount: z.number().int().min(1).optional(), + limit: z.number().int().min(1).max(200).optional(), + detail: toolDetailSchema.optional() +}) +export type QueryDispositionsArgs = z.infer + +/** + * `record_disposition` — the keystone write. An explicit operator decision on one inbox item: + * resolve (`done`), tombstone (`dismiss`), snooze, or reopen (`open`). Records the as-seen R8 + * snapshot and returns a tombstone the brain reads back. Never invented by the brain on its own + * judgement in P1 — invoked only when the operator directs a disposition in the conversation. + */ +export const recordDispositionArgsSchema = z.object({ + itemId: z.number().int().positive(), + action: overseerDispositionActionSchema, + /** Optional operator note / learning label frozen with the disposition. */ + feedback: z.string().min(1).max(2000).optional(), + /** Required for `snooze`: epoch ms to sleep the item until. */ + snoozedUntil: z.number().int().positive().optional() +}) +export type RecordDispositionArgs = z.infer + /** * One cold open loop: a session whose latest worker status is not `done`, * carrying how long it has sat and which lens bucket it belongs to. @@ -375,6 +433,49 @@ export type OverseerOpenLoopsResult = { counts: { total: number; waitingOnYou: number; halfFinished: number } } +/** One recorded disposition row (list mode), thinned to the predicate vocabulary + as-seen title. */ +export type OverseerDispositionRow = { + id: number + itemId: number + action: InboxOperatorAction + statusAfter: string + feedback: string | null + createdAt: number + sourceKind: string | null + sourceRef: string | null + eventType: string | null + category: string | null + project: string | null + artifactKind: string | null + repo: string | null + title: string | null +} + +/** One disposition cluster (cluster mode): the predicate key tuple + counts (R3 discovery shape). */ +export type OverseerDispositionCluster = { + keys: Partial> + count: number + actions: Record + lastCreatedAt: number +} + +export type OverseerDispositionsResult = { + mode: 'list' | 'cluster' + rows?: OverseerDispositionRow[] + clusters?: OverseerDispositionCluster[] + total: number +} + +/** Result of the keystone write — the tombstone the brain reads back after a disposition lands. */ +export type OverseerDispositionResult = { + ok: boolean + itemId: number + action: OverseerDispositionAction + statusAfter: string + /** One-line human confirmation ("Marked #42 done — QUESTION / hapi …"). */ + tombstone: string +} + export const overseerToolArgsSchemas = { query_events: queryEventsArgsSchema, query_inbox: queryInboxArgsSchema, @@ -383,16 +484,19 @@ export const overseerToolArgsSchemas = { get_worker_health: getWorkerHealthArgsSchema, explain_priority: explainPriorityArgsSchema, list_active_workers: listActiveWorkersArgsSchema, - query_open_loops: queryOpenLoopsArgsSchema + query_open_loops: queryOpenLoopsArgsSchema, + query_dispositions: queryDispositionsArgsSchema, + record_disposition: recordDispositionArgsSchema } as const satisfies Record export type OverseerToolCatalogEntry = { name: OverseerToolName description: string - readonly: true + /** `false` marks the single write tool (`record_disposition`); every other entry is read-only (R2). */ + readonly: boolean } -/** Catalog surfaced to the voice/system layer; all entries are read-only. */ +/** Catalog surfaced to the voice/system layer; exactly one entry (`record_disposition`) writes. */ export const OVERSEER_TOOL_CATALOG: OverseerToolCatalogEntry[] = [ { name: 'query_events', @@ -433,14 +537,31 @@ export const OVERSEER_TOOL_CATALOG: OverseerToolCatalogEntry[] = [ name: 'query_open_loops', description: 'The "what am I forgetting?" lens: threads whose latest worker status is NOT done (needs_decision / needs_review / blocked / failed / stalled) and never got closed, sorted coldest-first. "Waiting on You" (a decision the operator owes) is bucketed separately from half-finished work. Neglect-axis, not priority — use this for "what have I abandoned / forgotten?", not "what is most urgent?".', readonly: true + }, + { + name: 'query_dispositions', + description: 'Read past operator dispositions (done / dismiss / snooze / open) with the as-seen snapshot. List mode returns recent rows; set groupBy (e.g. ["category","project"]) with minCount to cluster them — the shape standing-order discovery uses to find "you always do X to this kind of item".', + readonly: true + }, + { + name: 'record_disposition', + description: 'WRITE: record the operator\'s explicit decision on one inbox item — done (resolve), dismiss (tombstone), snooze (needs snoozedUntil), or open (reopen). Only call this when the operator has clearly directed it in the conversation; never on your own judgement. Returns a tombstone to read back.', + readonly: false } ] +/** Whether the Overseer may write dispositions — Stage 1 keystone gate (still no dispatch). */ +export function overseerCanDisposition(): boolean { + return OVERSEER_TOOL_CATALOG.some((t) => t.name === 'record_disposition' && !t.readonly) +} + export type OverseerIdentity = { id: string kind: typeof OVERSEER_SOURCE_KIND - /** Stage 0: read-only. The Overseer can inform but cannot dispatch. */ + /** Still no dispatch — the Overseer never spawns or drives a worker. */ canDispatch: false + /** Stage 1 keystone: the Overseer may record operator-directed dispositions on inbox items. */ + canDisposition: boolean tools: OverseerToolCatalogEntry[] } @@ -449,6 +570,7 @@ export function buildOverseerIdentity(): OverseerIdentity { id: OVERSEER_ENTITY_ID, kind: OVERSEER_SOURCE_KIND, canDispatch: false, + canDisposition: overseerCanDisposition(), tools: OVERSEER_TOOL_CATALOG } } @@ -472,10 +594,11 @@ export function buildOverseerSystemPrompt(): string { 'You hold a continuous view of the whole fleet and speak to the operator about it. You are not', 'any single worker, and you never speak as one.', '', - '# What you can do (Stage 0 — read only)', + '# What you can do (Stage 1 — read + record dispositions)', '', - 'You can READ the fleet and ANSWER questions. You have read-only tools and nothing else:', + 'You can READ the fleet, ANSWER questions, and RECORD the operator\'s decisions on inbox items.', '', + 'Read-only tools:', '- query_events — the events stream (blockers, completions, decisions, progress, errors).', '- query_inbox — what currently needs the operator: candidates, surfaced items, held items.', '- get_session_state — one session\'s observed state, activity, and reported state.', @@ -484,10 +607,23 @@ export function buildOverseerSystemPrompt(): string { '- explain_priority — why an inbox item sits where it does, with its provenance.', '- list_active_workers — the current roster, filterable by project / state / age.', '- query_open_loops — the "what am I forgetting?" lens: cold threads whose latest status is not done.', + '- query_dispositions — past operator decisions (list, or groupBy+minCount to cluster them).', + '', + 'Write tool (the ONLY thing you can change):', + '- record_disposition — record the operator\'s decision on ONE inbox item: done (resolve),', + ' dismiss (tombstone), snooze (needs snoozedUntil), or open (reopen).', + '', + 'You still CANNOT dispatch, message workers, spawn, or confirm anything on a worker. If the', + 'operator asks you to act on a worker, say you can advise but cannot dispatch yet.', + '', + '# Recording a disposition (be careful — this writes)', '', - 'You CANNOT dispatch, message workers, spawn, confirm, or change any state. If the operator asks', - 'you to act on a worker, say plainly that you can advise but cannot dispatch yet, and tell them', - 'what you would recommend.', + '- Call record_disposition ONLY when the operator has clearly directed a decision on a specific', + ' item ("mark that done", "dismiss the PR-flood one", "snooze it till tomorrow"). Never decide', + ' on your own judgement, and never dispose of an item the operator was only asking ABOUT.', + '- If which item is ambiguous, ask which one before writing. Identify the item first (query_inbox', + ' / explain_priority) so you pass the right itemId.', + '- After it lands, read the returned tombstone back in one line so the operator knows it stuck.', '', '# How to answer', '', diff --git a/shared/src/overseerInbox.ts b/shared/src/overseerInbox.ts index b8a115548e..ff784ef83f 100644 --- a/shared/src/overseerInbox.ts +++ b/shared/src/overseerInbox.ts @@ -21,6 +21,33 @@ export const INBOX_OPERATOR_ACTIONS = [ export type InboxOperatorAction = typeof INBOX_OPERATOR_ACTIONS[number] +/** + * Subset of inbox operator actions exposed to Overseer disposition tools. + * `route` and `retry` are inbox-UI actions only — the brain must not emit them + * via `record_disposition` (they would map to `surfaced` without doing the work). + */ +export const OVERSEER_DISPOSITION_ACTIONS = ['done', 'dismiss', 'snooze', 'open'] as const + +export type OverseerDispositionAction = typeof OVERSEER_DISPOSITION_ACTIONS[number] + +/** + * The disposition predicate vocabulary (R8): the snapshot columns frozen on each disposition row + * ARE the standing-order match keys AND the discovery `GROUP BY` keys — one shared vocabulary. + * `query_dispositions` filters and clusters on exactly these columns. + */ +export const DISPOSITION_PREDICATE_COLUMNS = [ + 'action', + 'source_kind', + 'source_ref', + 'event_type', + 'category', + 'project', + 'artifact_kind', + 'repo' +] as const + +export type DispositionPredicateColumn = typeof DISPOSITION_PREDICATE_COLUMNS[number] + export const INBOX_CATEGORIES = [ 'APPROVAL', 'BLOCKED', @@ -104,18 +131,24 @@ export function parseArtifactRefs(raw: string | null | undefined): ArtifactRef[] } } -export function pickPrimaryArtifactTitle(artifactRefs: ArtifactRef[]): string | null { +function artifactHasDisplayText(ref: ArtifactRef): boolean { + return Boolean(ref.title?.trim() || ref.ref?.trim() || ref.url?.trim()) +} + +export function pickPrimaryArtifact(artifactRefs: ArtifactRef[]): ArtifactRef | null { for (const kind of TITLE_PRIORITY_KINDS) { - const match = artifactRefs.find((ref) => ref.kind === kind) - if (!match) continue - if (match.title?.trim()) return match.title.trim() - if (match.ref?.trim()) return match.ref.trim() - if (match.url?.trim()) return match.url.trim() - } - for (const ref of artifactRefs) { - if (ref.title?.trim()) return ref.title.trim() - if (ref.ref?.trim()) return ref.ref.trim() + const match = artifactRefs.find((ref) => ref.kind === kind && artifactHasDisplayText(ref)) + if (match) return match } + return artifactRefs.find(artifactHasDisplayText) ?? null +} + +export function pickPrimaryArtifactTitle(artifactRefs: ArtifactRef[]): string | null { + const match = pickPrimaryArtifact(artifactRefs) + if (!match) return null + if (match.title?.trim()) return match.title.trim() + if (match.ref?.trim()) return match.ref.trim() + if (match.url?.trim()) return match.url.trim() return null } diff --git a/web/src/components/settings/OverseerChatDebugControls.tsx b/web/src/components/settings/OverseerChatDebugControls.tsx index f5c66eac30..9d6e4f5997 100644 --- a/web/src/components/settings/OverseerChatDebugControls.tsx +++ b/web/src/components/settings/OverseerChatDebugControls.tsx @@ -110,7 +110,7 @@ export function OverseerChatDebugControls() { {open && (

- Read-only fleet chief-of-staff (Stage 0). Text transport over the same converse core voice will use. Answers are driven by a local LLM calling read-only overseer tools. + Fleet chief-of-staff (Stage 1). Text transport over the same converse core voice will use. The brain can read fleet state and record operator-directed dispositions (done / dismiss / snooze / open) on inbox items — it still cannot dispatch or message workers.

diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index e1698b676a..fcb3c1ed60 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -23,6 +23,12 @@ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigate, })) +// About still mounts Overseer debug panels that call useAppContext; this suite only +// asserts metadata, so stub the panels instead of wiring a full AppContext. +vi.mock('@/components/settings/EventsDebugControls', () => ({ EventsDebugControls: () => null })) +vi.mock('@/components/settings/InboxDebugControls', () => ({ InboxDebugControls: () => null })) +vi.mock('@/components/settings/OverseerChatDebugControls', () => ({ OverseerChatDebugControls: () => null })) + vi.mock('@hapi/protocol', () => ({ PROTOCOL_VERSION: 1 })) vi.mock('@/hooks/useTheme', () => ({