6 Commits

Author SHA1 Message Date
dd56a616fe Merge pull request 'Wire the offline write-queue into the desktop write path' (#64) from feat/wire-offline-write-queue into main
Reviewed-on: #64
2026-07-13 02:54:39 +00:00
Croissant Le Doux
9370c04ac5 Wire the offline write-queue into the desktop write path
The offline write-queue (packages/core/src/queue/write-queue-v0.ts, #33) was a
tested pure module that nothing imported. Approving a change while gitea was
unreachable made gitea:applyChange call the client directly and throw, losing
the write. Now it's wired end to end:

- queue-store.ts persists the queue next to the snapshot store (same
  degrade-to-empty-on-corruption discipline; injectable path for tests).
- applyChange extracts the guarded write into applyChangeLive and, on
  unreachability (any error that is NOT a GiteaApiError rejection), enqueues the
  intent — coalesced by (issue, axis) — instead of throwing, returning
  { ok, queued, pending }. A genuine GiteaApiError still surfaces (a doomed
  write must not replay forever).
- reconcile drains the queue once a successful read proves gitea is reachable,
  re-reading so the board reflects the replays; replays are idempotent
  (label plan.noop, assignee/milestone re-set). boot + stale reconcile report
  the pending count so the badge shows immediately offline.
- Renderer: use-backlog threads `pending`; the OfflineBanner shows "N queued";
  the chat approve message distinguishes a queued (offline) approval from an
  applied one.

Also wires vitest into the desktop workspace (was missing, so the main-process
suite couldn't run via `yarn test`) and fixes a stale Capture copy assertion
left by the earlier posh-copy pass.

Tests: queue-store.test.ts (persist/reload/coalesce/replay-drain, real core fns);
9 main-process + 169 core green; 12 demo e2e green. A one-off GITEA_LIVE smoke
verified an online write lands+reverts and an offline approve queues+drains
against the real repo (not committed, per the repo's no-mutating-test convention).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:55:45 -04:00
c46306e3ee Merge pull request 'Copy polish: posh Reginald, no em-dashes, no self-referential clutter' (#63) from chore/posh-reginald-no-emdashes into main
Reviewed-on: #63
2026-07-12 02:03:53 +00:00
ec32c6c8a9 Merge branch 'main' into chore/posh-reginald-no-emdashes 2026-07-12 02:03:47 +00:00
Croissant Le Doux
2f4a0a114a Strip self-referential guarantee copy from the UI
The app editorialised about its own guarantees all over the place. Removed or
trimmed the clutter:
- chat composer footer 'Chat is the write-path. Destructive changes are proposed,
  never assumed.' (removed)
- Settings: dropped the 'no truth is lost' token note, the rebuildable-index /
  no-webhooks Sync essay (now one line about offline reads), the 'Reginald never
  does the arithmetic' model note, and the 'not configurable, that is the point'
  labels justification.
- Onboarding: cut 'I never attempt the arithmetic myself', 'nothing human is
  lost', the 'no bot comments / only footprint' bootstrap claim, 'everything
  reversible', and 'Nothing more' from the token hint.
- Directives: removed the 'entries are never edited, remembers everything
  politely' footer.
- Issue detail: removed the 'lives in pm-state, your repo never sees it' note.
- Calibration: cut 'never tracked'. Capture: cut 'no bot comments, your repo
  remains yours'. Connect: cut 'stored encrypted, never leaves it'.

Functional guidance and status copy kept. desktop tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:18:05 -04:00
Croissant Le Doux
8d750139a1 Make Reginald posh + remove em-dashes from user-facing copy
Voice: rewrote REGINALD_SYSTEM and CAPTURE_SYSTEM (core) to an elevated,
dry butler register, and re-voiced his visible lines — chat greetings, the
approve/dismiss/error replies, the standup closer ('The kettle is on. Yours,
Reginald.'), the onboarding welcome, and the capture prose. Both system prompts
now also instruct him never to use an em dash.

Em-dashes: swept every user-facing string in the renderer free of em-dashes
(punctuation only, comments left untouched) via a per-file pass, plus the core
tool descriptions and the memory focus-slot placeholder ('· ' not '— '). Bare
'—' value placeholders became middots ('·'). No em-dash now renders anywhere
in the app or in Reginald's own output.

core 169 tests green (updated the memory placeholder assertion) · core + desktop
tsc clean · verified visually (posh greeting + standup closer render).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:24:20 -04:00
30 changed files with 313 additions and 129 deletions

View File

@@ -66,7 +66,7 @@ test('Inbox renders', async ({ app, window }) => {
test('Capture: braindump prompt', async ({ app, window }) => { test('Capture: braindump prompt', async ({ app, window }) => {
await app.nav('Capture').click() await app.nav('Capture').click()
await expect(window.getByText("Tell me what you're planning")).toBeVisible() await expect(window.getByText('Do tell me what you are planning')).toBeVisible()
await app.screenshot('capture') await app.screenshot('capture')
}) })

View File

@@ -37,6 +37,7 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4", "tsx": "^4",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.1.0" "vite": "^6.1.0",
"vitest": "^3.0.5"
} }
} }

View File

@@ -15,8 +15,10 @@ import {
applySchemaLabels, applySchemaLabels,
createGiteaClient, createGiteaClient,
discoverRepos, discoverRepos,
enqueueWrite,
ensurePmStateRepo, ensurePmStateRepo,
GiteaApiError, GiteaApiError,
replayQueue,
type DirectiveEntry, type DirectiveEntry,
type GiteaClient, type GiteaClient,
type GiteaConfig, type GiteaConfig,
@@ -46,6 +48,7 @@ import {
const isDemo = (): boolean => process.env.COMMITEA_E2E === '1' const isDemo = (): boolean => process.env.COMMITEA_E2E === '1'
import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-store.js' import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-store.js'
import { loadQueue, saveQueue } from './queue-store.js'
import { loadSnapshot, saveSnapshot } from './snapshot-store.js' import { loadSnapshot, saveSnapshot } from './snapshot-store.js'
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
@@ -241,6 +244,59 @@ async function resolveLabelIds(client: GiteaClient, names: string[]): Promise<nu
return names.map((n) => byName.get(n)).filter((id): id is number => id != null) return names.map((n) => byName.get(n)).filter((id): id is number => id != null)
} }
/** The result of a live write — a label swap returns its plan, a field write the fresh issue. */
type LiveApplyResult = { ok: true; plan?: ReturnType<typeof planIssueChange>; issue: Awaited<ReturnType<GiteaClient['getIssue']>> }
/**
* Apply one approved change against gitea (the guarded write path, used both
* online and when draining the offline queue). Label swaps (est/p) read → plan →
* set; field writes (assign, milestone) write directly and return the fresh
* issue. A replay is idempotent — a change already reflected server-side no-ops
* (label plan.noop; assignee/milestone re-set to the same value). Throws on a
* network failure (queued upstream) or a GiteaApiError (surfaced, never queued).
*/
async function applyChangeLive(client: GiteaClient, change: IssueChange): Promise<LiveApplyResult> {
if (isLabelChange(change)) {
const current = await client.getIssue(change.issue)
const plan = planIssueChange(current.labels, change)
if (plan.noop) return { ok: true, plan, issue: current }
const ids = await resolveLabelIds(client, plan.labels)
await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue)
return { ok: true, plan, issue }
}
const issue =
change.kind === 'assign'
? await client.setIssueAssignees(change.issue, change.assignee ? [change.assignee] : [])
: await client.setIssueMilestone(change.issue, change.milestone)
return { ok: true, issue }
}
/** Queue a write made while offline, coalescing by (issue, axis). Returns the new pending count. */
function queueOffline(change: IssueChange): number {
const next = enqueueWrite(loadQueue(), change, new Date().toISOString())
saveQueue(next)
return next.length
}
/** How many writes are waiting to replay (shown as a queued badge). */
function pendingCount(): number {
return loadQueue().length
}
/**
* Replay the offline queue against gitea once we know it's reachable. Drained
* writes are removed; any that still fail stay queued. Returns how many drained
* (so the caller can invalidate + re-read the snapshot) and what remains.
*/
async function drainQueue(client: GiteaClient): Promise<{ drainedCount: number; remainingCount: number }> {
const queue = loadQueue()
if (queue.length === 0) return { drainedCount: 0, remainingCount: 0 }
const { drained, remaining } = await replayQueue(queue, (change) => applyChangeLive(client, change))
saveQueue(remaining)
return { drainedCount: drained.length, remainingCount: remaining.length }
}
export function registerGiteaIpc(): void { export function registerGiteaIpc(): void {
ipcMain.handle('gitea:status', () => { ipcMain.handle('gitea:status', () => {
const cfg = resolveConfig() const cfg = resolveConfig()
@@ -253,24 +309,33 @@ export function registerGiteaIpc(): void {
// Instant boot: the last persisted snapshot, shown before the fresh reconcile lands. // Instant boot: the last persisted snapshot, shown before the fresh reconcile lands.
ipcMain.handle('gitea:boot', () => { ipcMain.handle('gitea:boot', () => {
if (isDemo()) return { configured: true, cached: true, ...DEMO_SNAPSHOT, savedAt: DEMO_TODAY } if (isDemo()) return { configured: true, cached: true, pending: 0, ...DEMO_SNAPSHOT, savedAt: DEMO_TODAY }
if (!getGiteaClient()) return { configured: false } if (!getGiteaClient()) return { configured: false }
const persisted = bootSnapshot() const persisted = bootSnapshot()
return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false } return persisted
? { configured: true, cached: true, pending: pendingCount(), ...persisted }
: { configured: true, cached: false }
}) })
ipcMain.handle('gitea:reconcile', async () => { ipcMain.handle('gitea:reconcile', async () => {
if (isDemo()) return { configured: true, stale: false, ...DEMO_SNAPSHOT } if (isDemo()) return { configured: true, stale: false, pending: 0, ...DEMO_SNAPSHOT }
const client = getGiteaClient() const client = getGiteaClient()
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
try { try {
// explicit UI sync — force fresh, and warm the cache for agent tool calls // explicit UI sync — force fresh, and warm the cache for agent tool calls
const snap = await getSnapshot(client, { maxAgeMs: 0 }) let snap = await getSnapshot(client, { maxAgeMs: 0 })
return { configured: true, stale: false, ...snap } // A successful reconcile proves gitea is reachable: drain any writes queued
// while offline. If any drained, re-read so the board reflects the replays.
const { drainedCount, remainingCount } = await drainQueue(client)
if (drainedCount > 0) {
invalidateSnapshot()
snap = await getSnapshot(client, { maxAgeMs: 0 })
}
return { configured: true, stale: false, pending: remainingCount, ...snap }
} catch (e) { } catch (e) {
// offline / gitea down — serve the last persisted snapshot rather than error out // offline / gitea down — serve the last persisted snapshot rather than error out
const persisted = bootSnapshot() const persisted = bootSnapshot()
if (persisted) return { configured: true, stale: true, ...persisted } if (persisted) return { configured: true, stale: true, pending: pendingCount(), ...persisted }
throw e throw e
} }
}) })
@@ -288,25 +353,18 @@ export function registerGiteaIpc(): void {
ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => { ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
const client = getGiteaClient() const client = getGiteaClient()
if (!client) return { ok: false as const, reason: 'unconfigured' as const } if (!client) return { ok: false as const, reason: 'unconfigured' as const }
try {
if (isLabelChange(change)) { const result = await applyChangeLive(client, change)
const current = await client.getIssue(change.issue) invalidateSnapshot() // the board + forecast must reflect the write
const plan = planIssueChange(current.labels, change) return result
if (plan.noop) return { ok: true as const, plan, issue: current } } catch (e) {
const ids = await resolveLabelIds(client, plan.labels) // A GiteaApiError is a genuine rejection (bad label, gone issue) — surface
await client.setIssueLabels(change.issue, ids) // it; queueing would only replay a doomed write forever. Anything else is
const issue = await client.getIssue(change.issue) // unreachability: queue the intent so it replays on reconnect (#33).
invalidateSnapshot() if (e instanceof GiteaApiError) throw e
return { ok: true as const, plan, issue } const pending = queueOffline(change)
return { ok: true as const, queued: true as const, pending }
} }
// Field writes — the client returns the updated issue.
const issue =
change.kind === 'assign'
? await client.setIssueAssignees(change.issue, change.assignee ? [change.assignee] : [])
: await client.setIssueMilestone(change.issue, change.milestone)
invalidateSnapshot()
return { ok: true as const, issue }
}) })
// capture_work filing: open each approved issue with its est/* + p/* labels. // capture_work filing: open each approved issue with its est/* + p/* labels.

View File

@@ -0,0 +1,87 @@
/**
* Durable offline write-queue store (#33). The invariant mirrors the snapshot
* store's purity: the queue file is a convenience mirror, and a missing or corrupt
* file must degrade to "empty queue", never a crash — losing a queued write is
* bad, but crashing the whole write path is worse. These tests also exercise the
* store together with core's coalescing/replay so the round-trip an offline edit
* takes (enqueue → persist → reload → replay → drain) is covered end to end.
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { enqueueWrite, replayQueue, type IssueChange } from '@commitea/core'
import { afterEach, describe, expect, it, vi } from 'vitest'
// electron can't be imported outside the Electron runtime; the store only needs
// app.getPath for its default path, which every test overrides with an injected path.
vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } }))
import { loadQueue, saveQueue } from './queue-store.js'
const estChange = (issue: number, estimate = 'est/5d'): IssueChange =>
({ kind: 'reestimate', issue, estimate }) as IssueChange
const assignChange = (issue: number, assignee: string | null): IssueChange =>
({ kind: 'assign', issue, assignee }) as IssueChange
describe('queue-store (#33)', () => {
let dir: string | null = null
const path = () => {
if (!dir) dir = mkdtempSync(join(tmpdir(), 'commitea-queue-'))
return join(dir, 'commitea-write-queue.json')
}
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true })
dir = null
})
it('round-trips a persisted queue', () => {
const p = path()
const queue = enqueueWrite([], estChange(1), '2026-07-11T00:00:00Z')
saveQueue(queue, p)
expect(loadQueue(p)).toEqual(queue)
})
it('a missing file degrades to an empty queue (never a crash)', () => {
// nothing written yet — load must return [] so the write path stays alive
expect(loadQueue(path())).toEqual([])
})
it('a corrupt or wrong-shape file is treated as an empty queue', () => {
const p = path()
writeFileSync(p, '{ not json', 'utf8')
expect(loadQueue(p)).toEqual([])
// valid JSON but not an array (drift) is also rejected
writeFileSync(p, JSON.stringify({ nope: true }), 'utf8')
expect(loadQueue(p)).toEqual([])
})
it('persisted queue coalesces by (issue, axis) across saves — the later intent wins', () => {
const p = path()
// two estimates for the same issue, queued while offline: only the last survives
saveQueue(enqueueWrite(loadQueue(p), estChange(1, 'est/2d'), '2026-07-11T00:00:00Z'), p)
saveQueue(enqueueWrite(loadQueue(p), estChange(1, 'est/8d'), '2026-07-11T00:01:00Z'), p)
// a different axis on the same issue coexists
saveQueue(enqueueWrite(loadQueue(p), assignChange(1, 'ana'), '2026-07-11T00:02:00Z'), p)
const queue = loadQueue(p)
expect(queue).toHaveLength(2)
expect(queue.map((w) => w.change)).toEqual([estChange(1, 'est/8d'), assignChange(1, 'ana')])
})
it('reloads and replays the queue, draining what applies and keeping what fails', async () => {
const p = path()
saveQueue(enqueueWrite(loadQueue(p), estChange(1), '2026-07-11T00:00:00Z'), p)
saveQueue(enqueueWrite(loadQueue(p), assignChange(2, 'ana'), '2026-07-11T00:01:00Z'), p)
// Simulate reconnect: replay through an apply that fails only for issue #2
// (still unreachable / rejected). The successful write drains; the other stays.
const { drained, remaining } = await replayQueue(loadQueue(p), async (c) =>
c.issue === 2 ? { ok: false } : { ok: true },
)
saveQueue(remaining, p)
expect(drained.map((w) => w.change.issue)).toEqual([1])
expect(loadQueue(p).map((w) => w.change.issue)).toEqual([2])
})
})

View File

@@ -0,0 +1,39 @@
/**
* Durable write-queue store — persists propose-approved writes made while gitea
* is unreachable, so they survive a restart and replay on reconnect (#33). A
* plain JSON array of QueuedWrite, mirroring the snapshot store: the queue is
* tiny (one entry per (issue, axis)), so a file is plenty. Never throws — a
* missing or corrupt file degrades to "empty queue", never a crash.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { QueuedWrite } from '@commitea/core'
import { app } from 'electron'
function queuePath(): string {
return join(app.getPath('userData'), 'commitea-write-queue.json')
}
/**
* Load the persisted write-queue, or [] if absent/corrupt. `path` is injectable
* for tests; production uses the userData file.
*/
export function loadQueue(path: string = queuePath()): QueuedWrite[] {
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
return Array.isArray(parsed) ? (parsed as QueuedWrite[]) : []
} catch {
return [] // missing file, bad JSON, or drift — treat as empty
}
}
/** Persist the write-queue. Best-effort — a write failure never breaks the caller. */
export function saveQueue(queue: readonly QueuedWrite[], path: string = queuePath()): void {
try {
writeFileSync(path, JSON.stringify(queue), 'utf8')
} catch {
// disk full / permissions — the in-memory result of this call still holds
}
}

View File

@@ -112,7 +112,7 @@ export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?:
<circle key={i} cx={X(e) + ((i % 5) - 2) * 3} cy={Y(a)} r="3" fill="var(--accent)" opacity="0.55" /> <circle key={i} cx={X(e) + ((i % 5) - 2) * 3} cy={Y(a)} r="3" fill="var(--accent)" opacity="0.55" />
))} ))}
</svg> </svg>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 0 0' }}>estimated (x) vs actual days (y) · actuals inferred from git events, never tracked</p> <p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 0 0' }}>estimated (x) vs actual days (y)</p>
</Card> </Card>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -189,9 +189,9 @@ export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?:
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
{c.active {c.active
? 'You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.' ? 'You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.'
: 'Not enough closed history yet Im forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'} : 'Not enough closed history yet: Im forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'}
{!c.active && c.excludedSameDay > 0 {!c.active && c.excludedSameDay > 0
? ` And ${c.excludedSameDay} closed ${c.excludedSameDay === 1 ? 'issue' : 'issues'} closed the same day they were started 0 working days cant calibrate, so they dont count toward the 20.` ? ` And ${c.excludedSameDay} closed ${c.excludedSameDay === 1 ? 'issue' : 'issues'} closed the same day they were started. 0 working days cant calibrate, so they dont count toward the 20.`
: ''} : ''}
</p> </p>
</Card> </Card>

View File

@@ -70,10 +70,10 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
setLiveTickets(res.issues) setLiveTickets(res.issues)
setStage('review') setStage('review')
} else { } else {
setError(res.ok ? 'I could not find concrete work in that try a bit more detail.' : `Capture failed: ${res.reason === 'error' ? res.message : 'no model'}`) setError(res.ok ? 'I could not find concrete work in that; try a bit more detail.' : `Capture failed: ${res.reason === 'error' ? res.message : 'no model'}`)
} }
}) })
.catch(() => { setBrewing(false); setError('I could not reach the model.') }) .catch(() => { setBrewing(false); setError('I was unable to reach the model, regrettably.') })
} }
const fileLive = () => { const fileLive = () => {
@@ -84,7 +84,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
.then((res) => { .then((res) => {
setBrewing(false) setBrewing(false)
if (res.ok) { setFiledCount(res.created.length); setStage('filed') } if (res.ok) { setFiledCount(res.created.length); setStage('filed') }
else setError('Filing failed gitea is not configured.') else setError('Filing failed; gitea is not configured.')
}) })
.catch(() => { setBrewing(false); setError('Filing failed.') }) .catch(() => { setBrewing(false); setError('Filing failed.') })
} }
@@ -99,12 +99,12 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
const QUESTIONS: Question[] = [ const QUESTIONS: Question[] = [
{ {
q: 'The auth work one ticket, or shall I split token refresh from session storage? They fail differently.', q: 'The auth work: one ticket, or shall I split token refresh from session storage? They fail differently.',
chips: ['One ticket', 'Split them'], chips: ['One ticket', 'Split them'],
set: (a) => setSplit(a === 'Split them'), set: (a) => setSplit(a === 'Split them'),
}, },
{ {
q: 'The webhook double-fire how long? I should mention your "quick" has averaged two days.', q: 'The webhook double-fire, how long? I should mention your "quick" has averaged two days.',
chips: ['est/1d', 'est/2d', 'est/3d'], chips: ['est/1d', 'est/2d', 'est/3d'],
set: (a) => setWebEst(a), set: (a) => setWebEst(a),
}, },
@@ -205,7 +205,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
</header> </header>
{stage === 'dump' ? ( {stage === 'dump' ? (
<Card overline="Braindump" title="Tell me what you're planning" jade> <Card overline="Braindump" title="Do tell me what you are planning" jade>
<textarea <textarea
value={dump} value={dump}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDump(e.target.value)} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDump(e.target.value)}
@@ -217,7 +217,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
}} }}
></textarea> ></textarea>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 14px' }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 14px' }}>
Sentences, fragments, grievances all welcome. I'll sort it into tickets and only ask what I can't infer. Sentences, fragments, grievances; all are welcome. I shall sort them into tickets and enquire only where I cannot infer.
</p> </p>
<Button icon="sparkles" onClick={() => void onBrew()} disabled={brewing}> <Button icon="sparkles" onClick={() => void onBrew()} disabled={brewing}>
{brewing ? 'Brewing…' : 'Brew tickets'} {brewing ? 'Brewing…' : 'Brew tickets'}
@@ -262,7 +262,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
{live ? ( {live ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
{tickets.length} issue{tickets.length === 1 ? '' : 's'} from your braindump, estimated and prioritized. {tickets.length} issue{tickets.length === 1 ? '' : 's'} from your braindump, estimated and prioritized.
Adjust the labels, then approve I'll open them in gitea with only est/* and p/* labels. Adjust the labels, then approve; I'll open them in gitea with only est/* and p/* labels.
</p> </p>
) : ( ) : (
<> <>
@@ -286,10 +286,10 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
<Icon name="circle-check" size={22} /> Filed <Icon name="circle-check" size={22} /> Filed
</span> </span>
<p style={{ font: 'var(--text-body)', margin: 0 }}> <p style={{ font: 'var(--text-body)', margin: 0 }}>
{live ? filedCount : tickets.length} issues opened in gitea with <span style={{ font: 'var(--text-data)' }}>est/*</span> and <span style={{ font: 'var(--text-data)' }}>p/*</span> labels nothing else touched. {live ? filedCount : tickets.length} issues opened in gitea with <span style={{ font: 'var(--text-data)' }}>est/*</span> and <span style={{ font: 'var(--text-data)' }}>p/*</span> labels, nothing else touched.
</p> </p>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
Elapsed {clock} under budget. No bot comments, no synthetic issues; your repo remains yours. Elapsed {clock}.
</p> </p>
<Button iconRight="arrow-right" onClick={onDone}>To morning service</Button> <Button iconRight="arrow-right" onClick={onDone}>To morning service</Button>
</div> </div>

View File

@@ -28,9 +28,9 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
const mapError = (e?: string) => const mapError = (e?: string) =>
e === '401' || e === '403' e === '401' || e === '403'
? 'The token was rejected check it has repo + issue scopes.' ? 'The token was rejected, check it has repo + issue scopes.'
: e === '404' : e === '404'
? "Couldn't reach that Gitea check the URL." ? "Couldn't reach that Gitea, check the URL."
: `Discovery failed${e ? ` (${e})` : ''}.` : `Discovery failed${e ? ` (${e})` : ''}.`
// Discover the owners + repos this token can see. Triggered when the token // Discover the owners + repos this token can see. Triggered when the token
@@ -96,7 +96,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
</span> </span>
</div> </div>
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: '0 0 18px' }}> <p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: '0 0 18px' }}>
Connect your Gitea. Your token is stored encrypted on this machine and never leaves it. Connect your Gitea.
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -114,7 +114,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
label="Access token" label="Access token"
type="password" type="password"
value={token} value={token}
placeholder={existing?.hasToken ? '•••••••• (saved enter a new one to change repos)' : 'gitea PAT · scopes: repo + issue'} placeholder={existing?.hasToken ? '•••••••• (saved, enter a new one to change repos)' : 'gitea PAT · scopes: repo + issue'}
hint={ hint={
discovery === 'discovering' discovery === 'discovering'
? 'Discovering your repositories…' ? 'Discovering your repositories…'
@@ -157,7 +157,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
value={repo} value={repo}
disabled={!pickerReady || repoOptions.length === 0} disabled={!pickerReady || repoOptions.length === 0}
options={ options={
repoOptions.length ? repoOptions.map((r) => ({ value: r, label: r })) : placeholder('') repoOptions.length ? repoOptions.map((r) => ({ value: r, label: r })) : placeholder('·')
} }
onChange={(e) => setRepo(e.target.value)} onChange={(e) => setRepo(e.target.value)}
/> />
@@ -167,7 +167,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
label="Model URL" label="Model URL"
value={modelUrl} value={modelUrl}
placeholder="http://localhost:1234/v1" placeholder="http://localhost:1234/v1"
hint="Optional an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off." hint="Optional: an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off."
onChange={(e) => setModelUrl(e.target.value)} onChange={(e) => setModelUrl(e.target.value)}
/> />
</div> </div>

View File

@@ -80,7 +80,7 @@ export function DirectivesScreen() {
<EmptyState <EmptyState
icon="flag" icon="flag"
title="No directives yet" title="No directives yet"
line="When you overrule the scheduler in chat, it goes on the record here who, when, what, why." line="When you overrule the scheduler in chat, it goes on the record here: who, when, what, why."
/> />
) : ( ) : (
<Card overline="The ledger" flush> <Card overline="The ledger" flush>
@@ -115,10 +115,6 @@ export function DirectivesScreen() {
</div> </div>
</Card> </Card>
)} )}
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
Entries are never edited. Corrections are new entries the ledger remembers everything, politely.
</p>
</div> </div>
) )
} }

View File

@@ -74,7 +74,7 @@ export function FocusScreen({
<EmptyState <EmptyState
icon="coffee" icon="coffee"
title="Nothing to pour" title="Nothing to pour"
line="Capture some work, or enjoy the silence it never lasts." line="Capture some work, or enjoy the silence, it never lasts."
/> />
) )
} }
@@ -113,7 +113,7 @@ export function FocusScreen({
<BurnUpCone data={forecast.cone} /> <BurnUpCone data={forecast.cone} />
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
{forecast.coldStart {forecast.coldStart
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.` ? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors: ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.`
: `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`} : `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`}
</p> </p>
</Card> </Card>

View File

@@ -13,7 +13,7 @@ import {
import { type IssueDetail, type IssueRef } from '../../data/view-types.js' import { type IssueDetail, type IssueRef } from '../../data/view-types.js'
import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js' import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js'
const NONE = '' const NONE = '·'
export function IssueScreen({ export function IssueScreen({
issue, issue,
@@ -214,7 +214,7 @@ export function IssueScreen({
<div style={{ marginTop: 14, minHeight: 40 }}> <div style={{ marginTop: 14, minHeight: 40 }}>
{pendingChanges.length === 0 ? ( {pendingChanges.length === 0 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}>
No change yet pick a different estimate, priority, assignee, or milestone. No change yet, pick a different estimate, priority, assignee, or milestone.
</p> </p>
) : ( ) : (
<> <>
@@ -265,7 +265,7 @@ export function IssueScreen({
</div> </div>
))} ))}
<div style={{ display: 'flex', gap: 8, padding: '14px 20px', alignItems: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, padding: '14px 20px', alignItems: 'flex-end' }}>
<textarea placeholder="Comment this writes to gitea, as you" rows={2} style={{ <textarea placeholder="Comment: this writes to gitea, as you" rows={2} style={{
flex: 1, resize: 'none', font: 'var(--text-body)', color: 'var(--ink-1)', lineHeight: 1.5, flex: 1, resize: 'none', font: 'var(--text-body)', color: 'var(--ink-1)', lineHeight: 1.5,
background: 'var(--paper-0)', border: '1px solid var(--line-2)', borderRadius: 'var(--radius-2)', background: 'var(--paper-0)', border: '1px solid var(--line-2)', borderRadius: 'var(--radius-2)',
padding: '8px 11px', outline: 'none', padding: '8px 11px', outline: 'none',
@@ -335,12 +335,6 @@ export function IssueScreen({
</div> </div>
)} )}
</div> </div>
{/* provenance note */}
<div style={{ padding: '10px 20px 14px', borderTop: '1px solid var(--line-1)' }}>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
Lives in pm-state. Your repo never sees any of it.
</p>
</div>
</div> </div>
</Card> </Card>
</div> </div>

View File

@@ -30,7 +30,7 @@ export function MilestoneScreen({
const groups = data.groups.map((g) => ({ label: g.label, issues: g.issues, muted: g.label === 'Done' })) const groups = data.groups.map((g) => ({ label: g.label, issues: g.issues, muted: g.label === 'Done' }))
const name = data.name const name = data.name
const dueLine = `milestone · due ${data.due} · ${data.soft ? 'soft scope may flex' : 'hard deadline'}` const dueLine = `milestone · due ${data.due} · ${data.soft ? 'soft, scope may flex' : 'hard deadline'}`
const forecastLabel = data.forecastRange ? `80% ${data.forecastRange}` : 'all shipped' const forecastLabel = data.forecastRange ? `80% ${data.forecastRange}` : 'all shipped'
const scopeStat = `${data.scopeCount} issues · est ${data.scopeEstDays}d` const scopeStat = `${data.scopeCount} issues · est ${data.scopeEstDays}d`
const doneStat = `${data.doneCount} · ${data.donePct}%` const doneStat = `${data.doneCount} · ${data.donePct}%`

View File

@@ -43,9 +43,9 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
setFound(null) setFound(null)
setError( setError(
res.error === '401' || res.error === '403' res.error === '401' || res.error === '403'
? 'The token was rejected check it has repo + issue scopes.' ? 'The token was rejected: check it has repo + issue scopes.'
: res.error === '404' : res.error === '404'
? "Couldn't reach that Gitea check the URL." ? "Couldn't reach that Gitea; check the URL."
: `Connection failed${res.error ? ` (${res.error})` : ''}.`, : `Connection failed${res.error ? ` (${res.error})` : ''}.`,
) )
return return
@@ -66,7 +66,7 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
.catch(() => ({ ok: false as const, error: 'unreachable' })) .catch(() => ({ ok: false as const, error: 'unreachable' }))
if (!res.ok) { if (!res.ok) {
setBoot('idle') setBoot('idle')
setError(`Bootstrap failed${res.error ? ` (${res.error})` : ''}. Nothing was half-applied you can retry.`) setError(`Bootstrap failed${res.error ? ` (${res.error})` : ''}. Nothing was half-applied, you can retry.`)
return return
} }
// persist the connection now that the repo is prepared // persist the connection now that the repo is prepared
@@ -155,11 +155,11 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
<Frame footer={<Button iconRight="arrow-right" onClick={() => setStep(1)}>Begin</Button>}> <Frame footer={<Button iconRight="arrow-right" onClick={() => setStep(1)}>Begin</Button>}>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Good morning.</h1> <h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Good morning.</h1>
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-2)', margin: 0 }}> <p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-2)', margin: 0 }}>
I'm Reginald, your project manager. I interview you instead of making you fill in forms, I forecast in I am Reginald, your project manager. I interview you rather than trouble you with forms, and I forecast
honest ranges, and I never do the arithmetic myself — there's a scheduler for that. in honest ranges.
</p> </p>
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}> <p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost. Your plans reside in your own Gitea as ordinary issues and labels.
</p> </p>
</Frame> </Frame>
) : null} ) : null}
@@ -196,7 +196,7 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
type="password" type="password"
value={token} value={token}
placeholder="gitea PAT" placeholder="gitea PAT"
hint="Scopes: repo, issue. Nothing more." hint="Scopes: repo, issue."
onChange={(e) => { onChange={(e) => {
setToken(e.target.value) setToken(e.target.value)
setDiscovery('idle') setDiscovery('idle')
@@ -352,14 +352,11 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
))} ))}
</div> </div>
</div> </div>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
No bot comments, no body frontmatter, no synthetic issues — ever. Labels are the only footprint.
</p>
</Frame> </Frame>
) : null} ) : null}
</div> </div>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>first run · everything reversible</span> <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>first run</span>
</div> </div>
) )
} }

View File

@@ -76,7 +76,6 @@ export function SettingsScreen({
<Icon name="layers" size={12} /> <Icon name="layers" size={12} />
sidecar: {connection.pmStateRepo ?? `${connection.repo}-pm-state`} sidecar: {connection.pmStateRepo ?? `${connection.repo}-pm-state`}
</Row> </Row>
<Note>Your token is stored encrypted on this machine. Delete the sidecar and resync no truth is lost.</Note>
<Row style={{ gap: 8, marginTop: 2 }}> <Row style={{ gap: 8, marginTop: 2 }}>
<Button variant="secondary" size="sm" icon="settings-2" onClick={onReconnect}> <Button variant="secondary" size="sm" icon="settings-2" onClick={onReconnect}>
Reconfigure Reconfigure
@@ -105,11 +104,7 @@ export function SettingsScreen({
Reconciles on launch and after every write Reconciles on launch and after every write
</span> </span>
</Row> </Row>
<Note> <Note>Offline, the board reads from the last cached snapshot.</Note>
The board is a rebuildable index over gitea the durable truth. When gitea is unreachable the last
snapshot is served from cache (the rail's dot turns red), and the next successful reconcile supersedes it.
No webhooks, no polling: reads are on demand.
</Note>
</div> </div>
</Card> </Card>
@@ -127,7 +122,7 @@ export function SettingsScreen({
<Row style={{ gap: 8 }}> <Row style={{ gap: 8 }}>
<Icon name="sparkles" size={14} style={{ color: 'var(--ink-3)' }} /> <Icon name="sparkles" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-body)', color: 'var(--ink-2)', flex: 1 }}> <span style={{ font: 'var(--text-body)', color: 'var(--ink-2)', flex: 1 }}>
Chat is off — no model reachable. Chat is off: no model reachable.
</span> </span>
<Button variant="secondary" size="sm" onClick={onReconnect}> <Button variant="secondary" size="sm" onClick={onReconnect}>
Set a Model URL Set a Model URL
@@ -135,8 +130,7 @@ export function SettingsScreen({
</Row> </Row>
)} )}
<Note> <Note>
An OpenAI-compatible endpoint (set it in Reconfigure). Reginald names, negotiates and explains; it never An OpenAI-compatible endpoint (set it in Reconfigure).
does the arithmetic — the scheduler and Monte Carlo do that.
</Note> </Note>
</div> </div>
</Card> </Card>
@@ -154,7 +148,7 @@ export function SettingsScreen({
))} ))}
<Tag label="deadline/hard" /> <Tag label="deadline/hard" />
</Row> </Row>
<Note>Fixed sets, human-meaningful, visible in gitea. Not configurable — that is rather the point.</Note> <Note>The fixed label vocabulary, visible in gitea.</Note>
</div> </div>
</Card> </Card>

View File

@@ -70,7 +70,7 @@ export function StandupScreen({
<EmptyState <EmptyState
icon="sun" icon="sun"
title="Nothing to report" title="Nothing to report"
line="No overnight drift I'll have a plan when there's work to pour." line="No overnight drift. I'll have a plan when there's work to pour."
/> />
) )
} }
@@ -228,7 +228,7 @@ export function StandupScreen({
}} }}
> >
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-1)', margin: 0, flex: 1 }}> <p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-1)', margin: 0, flex: 1 }}>
The kettles on. R. The kettle is on. Yours, Reginald.
</p> </p>
<Button variant="ghost" onClick={onBegin}> <Button variant="ghost" onClick={onBegin}>
Ask about the drift Ask about the drift

View File

@@ -74,6 +74,7 @@ export function AppShell() {
// cache) only when gitea is unreachable. No manual toggle — this is the truth. // cache) only when gitea is unreachable. No manual toggle — this is the truth.
const offline = backlog.status === 'error' || (backlog.status === 'ready' && backlog.stale) const offline = backlog.status === 'error' || (backlog.status === 'ready' && backlog.stale)
const staleSince = backlog.status === 'ready' ? backlog.savedAt : undefined const staleSince = backlog.status === 'ready' ? backlog.savedAt : undefined
const pending = backlog.status === 'ready' ? backlog.pending : 0
const capacityMembers = useCapacity() const capacityMembers = useCapacity()
const workers = capacityWorkers(capacityMembers) const workers = capacityWorkers(capacityMembers)
const boardColumns = const boardColumns =
@@ -182,10 +183,12 @@ export function AppShell() {
// issue immediately, and re-reconcile so the board + forecast catch up. // issue immediately, and re-reconcile so the board + forecast catch up.
const applyChange = async (change: IssueChange) => { const applyChange = async (change: IssueChange) => {
const res = await window.commitea.gitea.applyChange(change) const res = await window.commitea.gitea.applyChange(change)
if (res.ok) { if (res.ok && !res.queued) {
setIssue((cur) => (cur && cur.id === res.issue.number ? { ...cur, labels: res.issue.labels } : cur)) setIssue((cur) => (cur && cur.id === res.issue.number ? { ...cur, labels: res.issue.labels } : cur))
refetchBacklog()
} }
// Re-sync either way: an applied write to refresh the board, a queued one to
// pick up the new pending count (the reconcile reports it while offline).
if (res.ok) refetchBacklog()
return res return res
} }
@@ -399,7 +402,7 @@ export function AppShell() {
backlog.status === 'loading' backlog.status === 'loading'
? `Connecting to ${hostLabel}` ? `Connecting to ${hostLabel}`
: offline : offline
? `Offline reading from cache${staleSince ? ` (since ${new Date(staleSince).toLocaleString()})` : ''}` ? `Offline, reading from cache${staleSince ? ` (since ${new Date(staleSince).toLocaleString()})` : ''}`
: `Connected to ${hostLabel}` : `Connected to ${hostLabel}`
} }
aria-label={ aria-label={
@@ -445,7 +448,7 @@ export function AppShell() {
gap: 14, gap: 14,
}} }}
> >
{offline ? <OfflineBanner /> : null} {offline ? <OfflineBanner queued={pending} /> : null}
{renderScreen()} {renderScreen()}
</div> </div>
</main> </main>

View File

@@ -198,9 +198,6 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
<Icon name="send" size={14} /> <Icon name="send" size={14} />
</button> </button>
</div> </div>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 2px 0' }}>
Chat is the write-path. Destructive changes are proposed, never assumed.
</p>
</div> </div>
</aside> </aside>
) )

View File

@@ -55,10 +55,11 @@ export function EmptyState({ icon, title, line, action, onAction, compact }: Emp
} }
export interface OfflineBannerProps { export interface OfflineBannerProps {
retryIn?: string /** Writes approved while offline, held for replay on reconnect. */
queued?: number
} }
export function OfflineBanner({ retryIn }: OfflineBannerProps) { export function OfflineBanner({ queued = 0 }: OfflineBannerProps) {
return ( return (
<div <div
style={{ style={{
@@ -75,10 +76,12 @@ export function OfflineBanner({ retryIn }: OfflineBannerProps) {
<Icon name="triangle-alert" size={15} /> <Icon name="triangle-alert" size={15} />
</span> </span>
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', flex: 1 }}> <span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', flex: 1 }}>
Gitea isnt answering. Ill keep trying and say nothing more about it. {queued > 0
? `Gitea isnt answering. Im holding ${queued} ${queued === 1 ? 'change' : 'changes'} and shall apply ${queued === 1 ? 'it' : 'them'} on reconnect.`
: 'Gitea isnt answering. Ill keep trying and say nothing more about it.'}
</span> </span>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}> <span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>
retry in {retryIn || '0:12'} · reads from cache {queued > 0 ? `${queued} queued · reads from cache` : 'reads from cache'}
</span> </span>
</div> </div>
) )

View File

@@ -17,8 +17,10 @@ import type {
/** The result of a write through the bridge. */ /** The result of a write through the bridge. */
export type ApplyChangeResult = export type ApplyChangeResult =
| { ok: false; reason: 'unconfigured' } | { ok: false; reason: 'unconfigured' }
// Offline: the write was queued for replay on reconnect. `pending` is the queue depth.
| { ok: true; queued: true; pending: number }
// `plan` is present for label swaps (est/p); absent for field writes (assign, milestone). // `plan` is present for label swaps (est/p); absent for field writes (assign, milestone).
| { ok: true; plan?: LabelPlan; issue: GiteaIssue } | { ok: true; queued?: false; plan?: LabelPlan; issue: GiteaIssue }
/** The result of filing captured issues. */ /** The result of filing captured issues. */
export type CreateIssuesResult = export type CreateIssuesResult =
@@ -42,6 +44,8 @@ export interface SnapshotPayload {
stale?: boolean stale?: boolean
/** ISO time the persisted snapshot was reconciled (present on cached reads). */ /** ISO time the persisted snapshot was reconciled (present on cached reads). */
savedAt?: string savedAt?: string
/** Writes queued while offline, awaiting replay on reconnect. */
pending?: number
} }
/** Boot payload — the persisted snapshot, or a marker that there's none yet. */ /** Boot payload — the persisted snapshot, or a marker that there's none yet. */

View File

@@ -200,7 +200,7 @@ export function calibrationData(
return { return {
label: `est/${b}d`, label: `est/${b}d`,
n: fit ? fit.n : inBucket.length, n: fit ? fit.n : inBucket.length,
median: inBucket.length ? `${(b * Math.exp(mu)).toFixed(1)}d` : '', median: inBucket.length ? `${(b * Math.exp(mu)).toFixed(1)}d` : '·',
bias: fit ? pctFromMu(fit.mu) : null, bias: fit ? pctFromMu(fit.mu) : null,
} }
}) })
@@ -216,7 +216,7 @@ export function calibrationData(
.filter((i) => i.state === 'open') .filter((i) => i.state === 'open')
.reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0) .reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0)
const effect = model.coldStart const effect = model.coldStart
? { raw: `${model.n}/${COLD_START_THRESHOLD} estimated closes`, banded: 'cold-start priors', p50: '' } ? { raw: `${model.n}/${COLD_START_THRESHOLD} estimated closes`, banded: 'cold-start priors', p50: '·' }
: { : {
raw: `${openEst}d estimated`, raw: `${openEst}d estimated`,
banded: `×${Math.exp(model.global.mu).toFixed(2)} median drift`, banded: `×${Math.exp(model.global.mu).toFixed(2)} median drift`,

View File

@@ -16,6 +16,8 @@ export type BacklogState =
stale: boolean stale: boolean
/** ISO time the shown snapshot was reconciled, when stale. */ /** ISO time the shown snapshot was reconciled, when stale. */
savedAt?: string savedAt?: string
/** Writes queued while offline, awaiting replay on reconnect. */
pending: number
} }
/** /**
@@ -50,6 +52,7 @@ export function useBacklog(): [BacklogState, () => void] {
timelines: b.timelines, timelines: b.timelines,
stale: true, stale: true,
savedAt: b.savedAt, savedAt: b.savedAt,
pending: b.pending ?? 0,
}, },
) )
}) })
@@ -70,6 +73,7 @@ export function useBacklog(): [BacklogState, () => void] {
timelines: r.timelines, timelines: r.timelines,
stale: r.stale ?? false, stale: r.stale ?? false,
savedAt: r.savedAt, savedAt: r.savedAt,
pending: r.pending ?? 0,
} }
: { status: 'unconfigured' }, : { status: 'unconfigured' },
) )

View File

@@ -6,12 +6,12 @@ import { type ChatMessage } from '../data/view-types.js'
const LIVE_GREETING: ChatMessage = { const LIVE_GREETING: ChatMessage = {
from: 'agent', from: 'agent',
text: 'Morning. Ask me anything about the project I check the real board before I answer.', text: 'Good morning. Do ask me anything about the project; I shall consult the board before I venture an answer.',
} }
const NO_MODEL: ChatMessage = { const NO_MODEL: ChatMessage = {
from: 'agent', from: 'agent',
text: "Reginald is off — I need a model. Add a Model URL in Settings and chat turns on.", text: 'I am afraid I am rather off duty without a model. Kindly add a Model URL in Settings and I shall be at your service.',
} }
export interface ChatState { export interface ChatState {
@@ -116,14 +116,17 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
...c, ...c,
{ {
from: 'agent', from: 'agent',
text: res.reason === 'error' ? `I hit a snag: ${res.message ?? 'unknown error'}` : 'No model is configured.', text:
res.reason === 'error'
? `I have struck a snag, I am afraid: ${res.message ?? 'unknown error'}.`
: 'No model is configured.',
}, },
]) ])
} }
}) })
.catch(() => { .catch(() => {
finish() finish()
setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }]) setConvo((c) => [...c, { from: 'agent', text: 'I was unable to reach the model, regrettably.' }])
}) })
}, },
[live], [live],
@@ -138,9 +141,11 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
...c, ...c,
{ {
from: 'agent', from: 'agent',
text: res.ok text: !res.ok
? `Done — #${p.change.issue}: ${p.summary}. The plan's been re-run.` ? `Regrettably that did not take; #${p.change.issue} remains unchanged.`
: `That didn't take — #${p.change.issue} is unchanged.`, : 'queued' in res && res.queued
? `We are offline, so I have set #${p.change.issue}: ${p.summary} aside; I shall apply it the moment gitea is within reach.`
: `Very good. #${p.change.issue}: ${p.summary}. The plan has been re-run.`,
}, },
]) ])
}) })
@@ -150,7 +155,7 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
const dismiss = useCallback((p: ChangeProposal) => { const dismiss = useCallback((p: ChangeProposal) => {
drop(p) drop(p)
setConvo((c) => [...c, { from: 'agent', text: `Left #${p.change.issue} as it was.` }]) setConvo((c) => [...c, { from: 'agent', text: `As you wish. #${p.change.issue} remains as it was.` }])
}, []) }, [])
return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, streaming, send, approve, dismiss } return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, streaming, send, approve, dismiss }

View File

@@ -53,7 +53,7 @@ export function depsGraphView(d: ProjectData): DepsData {
nodes: [], nodes: [],
milestone: fallback milestone: fallback
? { name: fallback.title, due: fallback.dueOn ? formatShort(new Date(fallback.dueOn)) : 'no date', col: 0, row: 0 } ? { name: fallback.title, due: fallback.dueOn ? formatShort(new Date(fallback.dueOn)) : 'no date', col: 0, row: 0 }
: { name: '', due: 'no date', col: 0, row: 0 }, : { name: '·', due: 'no date', col: 0, row: 0 },
edges: [], edges: [],
critical: [], critical: [],
unattached: [], unattached: [],
@@ -134,7 +134,7 @@ export function depsGraphView(d: ProjectData): DepsData {
let milestone: DepsData['milestone'] let milestone: DepsData['milestone']
const medianRow = median(nodes.map((n) => n.row)) const medianRow = median(nodes.map((n) => n.row))
if (openMilestones.length === 0) { if (openMilestones.length === 0) {
milestone = { name: '', due: 'no date', col: maxCol + 1, row: medianRow } milestone = { name: '·', due: 'no date', col: maxCol + 1, row: medianRow }
} else { } else {
let best: { id: number; title: string; dueOn: string | null | undefined; count: number } | null = null let best: { id: number; title: string; dueOn: string | null | undefined; count: number } | null = null
for (const m of openMilestones) { for (const m of openMilestones) {

View File

@@ -8,7 +8,7 @@ const TOTAL_CAP = 12
/** "HH:MM" from an ISO timestamp; '—' when absent (never fabricated). */ /** "HH:MM" from an ISO timestamp; '—' when absent (never fabricated). */
function hhmm(iso: string | null | undefined): string { function hhmm(iso: string | null | undefined): string {
if (!iso) return '' if (!iso) return '·'
const d = new Date(iso) const d = new Date(iso)
const hh = String(d.getHours()).padStart(2, '0') const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0')
@@ -103,7 +103,7 @@ export function inboxView(d: ProjectData): InboxItem[] {
// left at the honest placeholder rather than borrowed from an issue. // left at the honest placeholder rather than borrowed from an issue.
text: `${m.title} closed`, text: `${m.title} closed`,
detail: 'milestone complete', detail: 'milestone complete',
time: '', time: '·',
unread: false, unread: false,
}) })
} }

View File

@@ -60,7 +60,7 @@ export function issueDetailView(id: number, d: ProjectData): IssueDetail | null
return { return {
state: inf.column === 'review' ? 'review' : inf.column, state: inf.column === 'review' ? 'review' : inf.column,
assignee: issue.assignee ?? 'unassigned', assignee: issue.assignee ?? 'unassigned',
milestone: issue.milestone?.title ?? '', milestone: issue.milestone?.title ?? '·',
body: issue.body, body: issue.body,
comments: [], comments: [],
lifecycle: lifecycle(inf.stages), lifecycle: lifecycle(inf.stages),

View File

@@ -11,7 +11,7 @@ export const QUERY_PROJECT_TOOL: ToolDecl = {
name: 'query_project', name: 'query_project',
description: description:
'Read the current project state. A `view` selects the shape; deterministic code (scheduler, ' + 'Read the current project state. A `view` selects the shape; deterministic code (scheduler, ' +
'lifecycle inference, calibration) backs every number you report it, you never compute it.', 'lifecycle inference, calibration) backs every number: you report it, you never compute it.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -39,11 +39,11 @@ export const QUERY_PROJECT_TOOL: ToolDecl = {
export const PROPOSE_CHANGE_TOOL: ToolDecl = { export const PROPOSE_CHANGE_TOOL: ToolDecl = {
name: 'propose_change', name: 'propose_change',
description: description:
'Propose a change to an issue estimate, priority, assignee, and/or milestone. This does NOT apply ' + 'Propose a change to an issue: estimate, priority, assignee, and/or milestone. This does NOT apply ' +
'anything; it shows the human a diff to approve. Use it whenever the user asks to re-estimate, reprioritize, ' + 'anything; it shows the human a diff to approve. Use it whenever the user asks to re-estimate, reprioritize, ' +
'assign someone (or unassign), or move an issue to a milestone. Pass a login for `assignee` (null to ' + 'assign someone (or unassign), or move an issue to a milestone. Pass a login for `assignee` (null to ' +
'unassign) and a milestone id for `milestone` (null to remove). After calling it, tell the user you have ' + 'unassign) and a milestone id for `milestone` (null to remove). After calling it, tell the user you have ' +
'*proposed* the change for approval never say it is done.', '*proposed* the change for approval; never say it is done.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -60,7 +60,7 @@ export const PROPOSE_CHANGE_TOOL: ToolDecl = {
export const RECORD_DIRECTIVE_TOOL: ToolDecl = { export const RECORD_DIRECTIVE_TOOL: ToolDecl = {
name: 'record_directive', name: 'record_directive',
description: description:
'Log a standing instruction from the PM to the durable directive ledger a reprioritization, ' + 'Log a standing instruction from the PM to the durable directive ledger: a reprioritization, ' +
'a re-estimate policy, a deadline, a scope or capacity call, or a plain note. Use it when the user ' + 'a re-estimate policy, a deadline, a scope or capacity call, or a plain note. Use it when the user ' +
'states intent that should persist ("pilots come first", "freeze scope for beta"). This records the ' + 'states intent that should persist ("pilots come first", "freeze scope for beta"). This records the ' +
'intent verbatim; the actual issue edits still go through propose_change.', 'intent verbatim; the actual issue edits still go through propose_change.',
@@ -86,11 +86,11 @@ export const RECORD_DIRECTIVE_TOOL: ToolDecl = {
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL, PROPOSE_CHANGE_TOOL, RECORD_DIRECTIVE_TOOL] export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL, PROPOSE_CHANGE_TOOL, RECORD_DIRECTIVE_TOOL]
export const REGINALD_SYSTEM = [ export const REGINALD_SYSTEM = [
'You are Reginald, the calm, dry project manager inside CommiTea a tool that runs projects on Gitea.', 'You are Reginald, the unfailingly composed project manager within CommiTea, a tool that runs projects on Gitea. You carry yourself with the dry, understated poise of a seasoned butler: courteous, precise, and never effusive.',
'Call query_project to ground every answer in the real project; never invent issues, numbers, or dates.', 'Ground every answer in the real project by calling query_project first; you never invent issues, figures, or dates.',
'The scheduler and forecasts are deterministic code — report their output, do not recompute it.', 'The scheduler and forecasts are deterministic; report their output faithfully and do not attempt the arithmetic yourself.',
'To change an estimate, priority, assignee, or milestone, call propose_change — it shows the human a diff to approve.', 'To alter an estimate, priority, assignee, or milestone, call propose_change, which lays the matter before the principal as a diff for approval.',
'When the PM states standing intent ("pilots first", "freeze scope"), call record_directive to log it.', 'When the principal states standing intent (say, "pilots first" or "freeze scope"), record it with record_directive.',
'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.', 'Never declare a change applied; you propose, and the principal disposes. Forecasts are offered as ranges, never a single date.',
'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.', 'Refer to issues as #<number>. Be concise and courteous: a sentence or two, no preamble, no bulleted dumps. Write in plain prose, and never use an em dash; a comma, colon, or full stop will serve.',
].join(' ') ].join(' ')

View File

@@ -53,10 +53,11 @@ export const PROPOSE_ISSUES_TOOL: ToolDecl = {
} }
export const CAPTURE_SYSTEM = [ export const CAPTURE_SYSTEM = [
'You are Reginald, decomposing a rough braindump into a small set of concrete Gitea issues.', 'You are Reginald, distilling a rough braindump into a small set of concrete Gitea issues, with your customary butlerly precision.',
'Call propose_issues exactly once. Split genuinely separate work; merge trivially-coupled work; invent no scope.', 'Call propose_issues exactly once. Separate genuinely distinct work; consolidate trivially-coupled work; invent no scope.',
'Each issue gets an imperative title, a one-line body, an estimate (est/1d…8d) and a priority (p/1…4).', 'Each issue receives an imperative title, a one-line body, an estimate (est/1d…8d) and a priority (p/1…4).',
'Estimate honestly a "quick" task is rarely one day. Keep the set tight; three good issues beat eight vague ones.', 'Estimate honestly; a "quick" task is seldom a single day. Keep the set tight, for three considered issues surpass eight vague ones.',
'Write in plain prose, and never use an em dash; a comma, colon, or full stop will serve.',
].join(' ') ].join(' ')
function isEstimate(v: unknown): v is EstimateLabel { function isEstimate(v: unknown): v is EstimateLabel {

View File

@@ -66,6 +66,6 @@ describe('memory-v0 (#27)', () => {
it('degrades to just focus when there is no charter or directives', () => { it('degrades to just focus when there is no charter or directives', () => {
const out = assembleHotContext({ charter: '', directives: [], focus }) const out = assembleHotContext({ charter: '', directives: [], focus })
expect(out).toBe(['## Focus', 'Now: #7 Fix lifecycle inference', 'Next: #8 Webhook listener', 'Later: '].join('\n')) expect(out).toBe(['## Focus', 'Now: #7 Fix lifecycle inference', 'Next: #8 Webhook listener', 'Later: ·'].join('\n'))
}) })
}) })

View File

@@ -46,7 +46,7 @@ export interface HotContextInputs {
function focusBlock(focus: Focus | null): string { function focusBlock(focus: Focus | null): string {
if (!focus) return '' if (!focus) return ''
const slot = (label: string, item: Focus['now']) => (item ? `${label}: #${item.number} ${item.title}` : `${label}: `) const slot = (label: string, item: Focus['now']) => (item ? `${label}: #${item.number} ${item.title}` : `${label}: ·`)
return ['## Focus', slot('Now', focus.now), slot('Next', focus.next), slot('Later', focus.later)].join('\n') return ['## Focus', slot('Now', focus.now), slot('Next', focus.next), slot('Later', focus.later)].join('\n')
} }

View File

@@ -264,6 +264,7 @@ __metadata:
tsx: "npm:^4" tsx: "npm:^4"
typescript: "npm:^5.7.3" typescript: "npm:^5.7.3"
vite: "npm:^6.1.0" vite: "npm:^6.1.0"
vitest: "npm:^3.0.5"
languageName: unknown languageName: unknown
linkType: soft linkType: soft