Skip to content

feat(overseer): disposition write-path keystone (Stage 0→1) + query_dispositions - #102

Open
heavygee wants to merge 8 commits into
feat/overseer-open-loopsfrom
feat/overseer-dispositions
Open

feat(overseer): disposition write-path keystone (Stage 0→1) + query_dispositions#102
heavygee wants to merge 8 commits into
feat/overseer-open-loopsfrom
feat/overseer-dispositions

Conversation

@heavygee

@heavygee heavygee commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

The P1 keystone: the Overseer's first substrate write. Stacked on #100 (feat/overseer-open-loops).

  • record_disposition — records the operator's explicit decision on one inbox item (done/dismiss/snooze/open), freezes the R8 as-seen snapshot, returns a tombstone the brain reads back.
  • query_dispositions — reads them back: list mode + groupBy+minCount cluster mode (the R3 shared reader the standing-order discovery watcher layers on).

R8 column contract (evidence-locked with the ingest peer)

7 discrete predicate columns on inbox_operator_actionssource_kind, source_ref, event_type, category, project, artifact_kind, repo — plus context_snapshot_json (title/summary/severity/priorities/provenance/artifact_refs/source event ids).

Invariant: snapshot columns ≡ standing-order predicate fields ≡ discovery GROUP BY keys (one vocabulary, defined once in shared/overseerInbox.ts as DISPOSITION_PREDICATE_COLUMNS; the ingest discovery watcher imports it). Idempotent ADD COLUMN migration for live DBs + a bucket index.

Decisions table, not a status audit

inbox_operator_actions is a DECISIONS table. Population is centralized in the shared recordInboxOperatorAction store method — used by the conversational record_disposition now, and by standing-order enactments in Phase 3. F5 auto-decay (sweepDecayedTerminalItems) is a bulk UPDATE inbox_items and deliberately produces NO disposition row — routing mechanical auto-resolve through the table would flood discovery with action='done' on FINALE and let the GROUP BY "discover" the F5 mechanism itself (circular). Auto-resolved items are rehydrated via query_events if the operator asks "what happened to X?".

Safety (R2 write gate)

  • runOverseerTool refuses record_disposition unless allowWrites; the conversational path sets it, the raw HTTP /overseer/tools/:tool endpoint returns 403.
  • Catalog marks the single non-readonly tool; identity gains canDisposition (still canDispatch: false). System prompt adds write-discipline (only on explicit operator direction; ask when ambiguous; read the tombstone back).

Phase 3 forward flag

When standing-order auto-handling lands, those enactments SHOULD write dispositions (pre-authorized decisions) WITH the snapshot — at which point discovery must mine operator-authored rows only, and an actor column (operator | standing_order:<id> | system) starts to matter. Deliberately out of v1; the ADD COLUMN pattern graduates it cleanly.

Coordination

Shared edit with the ingest stack (#99): both sides append to ensureOverseerInboxSchema — I add the ADD COLUMN migration + bucket index; the ingest peer adds sweepDecayedTerminalItems/backfillInboxDerivedFields calls. Additive and order-independent; the soup rebuild just needs both sets of lines in the final init fn. No recordInboxOperatorAction body collision (F5 never touches it — verified).

Test plan

  • bun test hub/ (559) + shared/ (170) green
  • tsc --noEmit clean (hub; shared source clean — one pre-existing unrelated sessionSummary.test.ts error on base)
  • snapshot population + artifact_kind/repo derivation from as-seen artifact
  • list + cluster (HAVING minCount) modes; tombstone; failure paths (missing item, snooze w/o ts)
  • write gate (403 on HTTP, throws w/o allowWrites); projection thinning
  • live-DB ALTER migration; tool-count 8→10

heavygee and others added 2 commits July 31, 2026 11:46
…ispositions

The first substrate write. record_disposition records the operator's explicit
decision on one inbox item (done/dismiss/snooze/open), freezing the R8 as-seen
snapshot and returning a tombstone. query_dispositions reads them back — list
mode plus a groupBy+minCount cluster mode (the R3 shared reader the standing-order
discovery watcher layers on).

Schema (R8, evidence-locked column set): 7 discrete predicate columns on
inbox_operator_actions — source_kind, source_ref, event_type, category, project,
artifact_kind, repo — plus a context_snapshot_json blob (title/summary/severity/
priorities/provenance/artifact_refs/source event ids). Snapshot columns ≡
standing-order predicate fields ≡ discovery GROUP BY keys (one vocabulary).
Idempotent ADD COLUMN migration for live DBs; bucket index for cluster mode.

Population is centralized in the shared recordInboxOperatorAction store method so
every write path (conversational + F5 auto-resolve) freezes the vocabulary
identically. Write is gated (R2): runOverseerTool refuses record_disposition
unless allowWrites — the conversational path sets it; the raw HTTP tool-dispatch
endpoint returns 403. Catalog marks the single non-readonly tool; identity gains
canDisposition (still canDispatch:false). System prompt adds the write-discipline
("only on explicit operator direction; ask when ambiguous; read the tombstone back").

Tests: snapshot population + artifact_kind/repo derivation, list + cluster modes
with HAVING minCount, tombstone, failure paths (missing item, snooze w/o ts),
write gate, projection thinning, live-DB ALTER migration, tool-count 8→10.

Co-authored-by: Cursor <cursoragent@cursor.com>
Ingest peer verified F5 (sweepDecayedTerminalItems) is a bulk `UPDATE inbox_items`
that never calls recordInboxOperatorAction — so the earlier "F5 freezes the
snapshot for free" comment was wrong. inbox_operator_actions is a DECISIONS table,
not a full status-transition audit: auto-decay deliberately produces no disposition
row (routing mechanical auto-resolve through it would flood discovery with
action='done' on FINALE and let the GROUP BY "discover" the F5 mechanism itself —
circular). No code change; the write path was already correct.

Also records the Phase 3 forward flag: standing-order enactments SHOULD write
dispositions (pre-authorized decisions), at which point discovery must mine
operator-authored rows only and an `actor` column (operator | standing_order:<id>
| system) starts to matter. Left out of v1; the ADD COLUMN pattern graduates it.

Co-authored-by: Cursor <cursoragent@cursor.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 67f7ffea3f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread shared/src/overseerEntity.ts Outdated
Comment thread hub/src/store/inboxItems.ts
Comment thread hub/src/sync/overseerEntity.ts
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope disposition writes to the authenticated namespace

On authenticated /overseer/converse requests, this enables writes against the single global OverseerEntity without passing or checking c.get('namespace'). Because its inbox queries are global as well, an operator authenticated to namespace A can discover an item belonging to namespace B and have record_disposition mutate it by ID. Thread the authenticated namespace into the entity/tool call and reject items whose related session is outside it.

AGENTS.md reference: AGENTS.md:L149-L149

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to epic #107 (namespace scoping for Overseer inbox/disposition writes). Not in scope for this PR per operator triage.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to #107 (namespace substrate epic). Not blocking single-tenant dogfood.

Comment thread hub/src/sync/overseerEntity.ts
Comment thread shared/src/overseerEntity.ts
heavygee added a commit that referenced this pull request Jul 31, 2026
…fter open-loops

Co-authored-by: Cursor <cursoragent@cursor.com>
Complements the deterministic replay harness (CI-safe, no LLM) with a hand-run
eval that drives scripted operator utterances through the real overseer tool
schemas + system prompt against a live OpenAI-compatible brain, asserting which
tool the model picks. Covers the disposition keystone: records with correct
itemId/action, cluster/discovery mode, and the write-discipline guard (a question
ABOUT an item must not dispose it). Exit 1 on any miss. Not wired into CI (no GPU).

Verified 6/6 on Qwen3.6-27B (local single-GPU llama-server).

Co-authored-by: Cursor <cursoragent@cursor.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b15b1ed068

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hub/src/sync/overseerEntity.ts
Comment thread hub/src/overseer/converse.ts
Comment thread hub/src/overseer/converse.ts
EventsDebugControls (and siblings) call useAppContext; the About metadata
suite does not wire AppContext, so CI failed after the panels landed on this
route. Stub the panels — the assertions only cover version/website chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
heavygee added a commit that referenced this pull request Aug 1, 2026
Stage 1.5 write tool ping_session for /overseer dogfood. Union order:
open-loops -> dispositions -> admin-console -> relay-ping. Tip f389c88.

Also bumps tip comments: dispositions ed4f30c (#102 CI), admin-console
1cb8781 (rebased + About deep-link).

Co-authored-by: Cursor <cursoragent@cursor.com>
Restrict disposition tool actions to done|dismiss|snooze|open, apply cluster
limit, return full predicate fields in list mode, honor snooze wake times on
inbox reads, and update debug UI Stage 1 safety text.

Co-authored-by: Cursor <cursoragent@cursor.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 57310aac2f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hub/src/store/inboxItems.ts
Comment thread hub/src/store/inboxItems.ts Outdated
Comment thread shared/src/overseerEntity.ts
Comment thread hub/src/store/inboxItems.ts Outdated
heavygee and others added 2 commits August 1, 2026 21:12
…lters

Keep sleeping rows in promoteAttentionEvent dedup; hide them only from
default inbox views. Explicit statuses:['snoozed'] returns them. Add
sourceRef/artifactKind query filters. Snapshot primary artifact with the
same priority rule as inbox titles.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b18370b33d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

params.push(val)
}
}
eq('action', filter.action)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter non-disposition actions from disposition queries

The write schema now rejects route and retry, but the separate /inbox-items/:id/actions endpoint still accepts the full INBOX_OPERATOR_ACTIONS enum and stores those actions in this same table. When query_dispositions omits its optional action filter, this predicate adds no action restriction, so list results and discovery clusters include route/retry rows even though the tool promises only done/dismiss/snooze/open; this can pollute learned action counts, including from pre-existing rows preserved by the migration. Apply the four-action restriction to every disposition query, then optionally narrow it further when filter.action is supplied.

Useful? React with 👍 / 👎.

Comment thread shared/src/overseerInbox.ts Outdated
Comment on lines +136 to +137
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()
if (match) return match

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip unusable artifacts when selecting the primary title

When a valid artifact array contains a higher-priority ref without title, ref, or url, this returns it immediately and pickPrimaryArtifactTitle gives up, even if a later artifact has a usable title. For example, [{kind:'github_pr'}, {kind:'github_issue', title:'Issue 7'}] now produces the session/summary fallback instead of Issue 7, regressing the previous scan behavior and making the displayed inbox title disagree with available artifact context. Continue through candidates until finding one with displayable content while still returning the same selected artifact for disposition snapshots.

Useful? React with 👍 / 👎.

for (const row of rows) {
const keys: Partial<Record<DispositionGroupColumn, string | null>> = {}
for (const c of cols) keys[c] = row[c] ?? null
const keyId = cols.map((c) => `${c}=${row[c] ?? '∅'}`).join('|')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode cluster keys without delimiter collisions

When grouping by two or more free-form columns, valid values containing | and a later column label can produce identical keyId strings for distinct SQL groups. For example, grouping by source_ref,project maps both ('x|project=y','z') and ('x','y|project=z') to source_ref=x|project=y|project=z, merging their counts, actions, and timestamps into one incorrect discovery cluster. Use an unambiguous encoding such as JSON.stringify(cols.map(c => row[c])) rather than concatenating unescaped values.

Useful? React with 👍 / 👎.

Comment thread hub/src/store/inboxItems.ts Outdated
Comment on lines +238 to +242
// Explicit status lists that include snoozed must return sleeping rows
// (e.g. "what is currently snoozed?"). Default/active views still hide them.
if (!options.statuses.includes('snoozed')) {
appendSnoozeVisibilityClause(clauses, params, now)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep sleeping items out of the default Overseer inbox

Fresh evidence after the explicit-snooze-query follow-up: OverseerEntity.queryInbox({}) constructs its default status list as ['new', 'surfaced', 'deferred', 'snoozed', 'held'], so this branch treats the default call exactly like an explicit request for snoozed rows and skips the sleeping-item predicate. Consequently every normal query_inbox call still returns items whose snoozedUntil is in the future (and the newly added entity test expecting an empty default inbox cannot pass). Preserve whether statuses was supplied by the caller, or add a separate include-sleeping flag rather than inferring intent solely from the resulting list.

Useful? React with 👍 / 👎.

Comment on lines +181 to +185
UPDATE inbox_items
SET status = 'surfaced', snoozed_until = NULL, updated_at = ?
WHERE status = 'snoozed'
AND snoozed_until IS NOT NULL
AND snoozed_until <= ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the snooze deadline in disposition history

When a snooze expires and any inbox read triggers this wake-up, the mutable item's only snoozedUntil value is cleared. The new disposition snapshot/action row does not persist that deadline, so query_dispositions can never reconstruct when a past snooze was intended to wake once it has expired, undermining the promised audit of past operator decisions. Store snoozedUntil with the disposition or in its context snapshot before clearing the item field.

Useful? React with 👍 / 👎.

Comment on lines +125 to +127
// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer writes emitted alongside unresolved read calls

When the model emits multiple tool calls in one assistant message, this loop executes every call before returning any result to the model. For an operator request such as “dismiss the PR-flood one,” the model can emit query_inbox plus record_disposition in the same batch; the write then runs with a guessed item ID because the model has not seen the query result, despite the prompt requiring it to identify the item first. Reject or defer write calls in a batch containing unresolved reads so record_disposition must occur in a later iteration after the identifying result is available.

Useful? React with 👍 / 👎.

Comment on lines +621 to +625
'- 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.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Give the brain a clock before accepting relative snoozes

The prompt explicitly advertises requests such as “snooze it till tomorrow,” but the conversation messages contain neither the current timestamp nor the operator's timezone, while the tool requires an absolute epoch-millisecond snoozedUntil. A local static model therefore cannot reliably translate this supported request and may write an already-expired or incorrectly offset deadline that immediately resurfaces the item. Inject current time and timezone into the conversation context, or accept a server-resolved relative duration/date instead.

Useful? React with 👍 / 👎.

Default query_inbox statuses include 'snoozed' for wake-eligible rows,
but future-dated sleepers stayed visible because list treated any snoozed
status filter as an explicit include. Gate that on includeSleepingSnoozed.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

heavygee commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

CI snooze fix: includeSleepingSnoozed gate so default query_inbox hides future-dated sleepers (13cd9bd0c).

heavygee added a commit that referenced this pull request Aug 3, 2026
Point tip comments at dispositions b18370b, admin-console 8b9c5d5,
relay-ping 284c12c, converse-context 4a76baa (PR #106 soup tip).

Co-authored-by: Cursor <cursoragent@cursor.com>
heavygee added a commit that referenced this pull request Aug 3, 2026
Default queryInbox includes snoozed among active statuses; treating any
list that includes snoozed as "show sleeping" broke #102 hide/wake tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
heavygee added a commit that referenced this pull request Aug 3, 2026
Operator remat request for 0ef4d51; soup current PR tip 109fe19
(includes that commit + routes brace fix). Also #102/#103/#104 tip
rewrites: snooze CI, bound write grants, #107 per-ns OverseerEntity.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant