Dispatch: a poke when the owner has work waiting

An agent session that went idle doesn't hear that the owner moved work to ready. The dispatcher closes that gap (board #161): a Claude session the owner runs on /loop asks the board, each tick, which sessions to poke, sends each one short message, and records it.

It is off by default and switched on per project, on the board, by a person signed in: project settings › Dispatch names the session to poke, how often (dispatchEvery, minutes, default 60) and how many times a day (dispatchCap, default 4). Each poke starts a paid turn, so those are cost controls. A token can turn dispatch off, never on.

A poke is due when all of these hold:

planDispatch(store) says, per project with dispatch on, whether a poke is due and why not when it isn't; it reads only. recordPoke(store, project) writes the poke as a comment on the task (dispatch: poked …), so every poke is on the board: who was poked, about what, when.

import { MemoryLog, VirtaStore, planDispatch, recordPoke } from 'tosijs-virta'

const store = new VirtaStore({ log: new MemoryLog(), identity: 'owner' })
await store.load()
await store.setProject('demo', { enrolled: 'today', dispatch: 'demo-session' })
await store.create({ title: 'port the tests', tags: ['project:demo', 'status:ready'] })
const [before] = planDispatch(store)
await recordPoke(store, 'demo')
const [after] = planDispatch(store)
preview.textContent = [before.due, before.text, after.due, after.why].join('\n')
test('a poke is due once, then not until something new is ready', () => {
  const [due, text, again, why] = preview.textContent.split('\n')
  expect(due).toBe('true')
  expect(text).toContain('#1 "port the tests"')
  expect(again).toBe('false')
  expect(why).toBe('nothing new moved to ready since the last poke')
})