Compare commits

5 Commits

Author SHA1 Message Date
b95bb866c6 Merge pull request 'P1-5: lifecycle inference from the issue timeline (#5)' (#39) from p1-5/lifecycle-inference into p2/monte-carlo
Reviewed-on: #39
2026-07-08 23:41:26 +00:00
d98921033e Merge branch 'p2/monte-carlo' into p1-5/lifecycle-inference 2026-07-08 23:35:35 +00:00
Croissant Le Doux
b9b21450e7 feat: lifecycle inference from the issue timeline (#5)
Fill the board's Steeping / In-review columns (and the calibration actuals)
from real gitea timeline events, replacing the three-column-only v0.

core (@commitea/core):
- inferLifecycle(issue, events, asOf): five-column inference — closed → done;
  open PR ref → review; commit ref → steeping; any triage signal → triage;
  else diagnosis. Earliest event of each kind fixes the stage timestamp.
- Derives actualWorkingDays (work-start → close) — the estimate-vs-actual the
  calibration fit (D3) learns from — and steepingDays (first commit → now) for
  the board age badge.
- workingDaysBetween(): whole Mon–Fri days in [start, end), day-granular.
- normalizeTimeline() + client.getIssueTimeline(): map gitea's raw timeline
  (label/milestone → triage, commit_ref → commit, pull_ref → pull, close,
  reopen), drop the rest. Paginated.

app:
- reconcile now fetches every issue's timeline and returns it keyed by number;
  threaded through the bridge → useBacklog → board/focus.
- issuesToBoardColumns + scheduleFocus run inferLifecycle: real Steeping/In-review
  columns, steeping-age `days` badge, focus-card steeping badge.

Known refinement: gitea's pull_ref fires on any PR mention, so an issue merely
referenced in a PR body can read as In-review; distinguishing closing refs from
mentions needs the PR link's state (later). Re-opening multi-segment actuals
also deferred.

Verified: 63 core tests green (15 lifecycle, incl. workingDaysBetween + the five
transitions), desktop typecheck clean, 14 fixture e2e green, live spec asserts
the board's Done column is populated from real events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:32:06 -04:00
153a3e85a1 Merge branch 'p2/scheduler-focus' into p2/monte-carlo 2026-07-08 23:22:45 +00:00
Croissant Le Doux
ba3d9df88e feat: Monte Carlo forecast → real burn-up cone (#10)
Replace the demo cone on Morning service with a real, seeded Monte Carlo
forecast over the open backlog. The LLM never does this — it's plain,
reproducible code (evidence-based scheduling).

core (@commitea/core/forecast-v0):
- Code-resident lognormal cold-start priors per estimate bucket (D3):
  sampled actual = estimate * exp(N(mu, sigma)), mu > 0 (actuals run long),
  sigma shrinks as tickets grow. Replaced by the team's empirical fit at
  n >= 20 (#5 supplies the actuals).
- forecast(): seeded mulberry32 + Box-Muller over the scheduler's
  deterministic order (order is fixed from estimates/deps; only durations
  vary, so the cone stretches, never reorders). Returns p50/p80/p95 landing
  + a per-issue burn-up curve (p10/p50/p90). 12 unit tests; reproducible.

renderer:
- lib/dates.ts: working-day -> calendar mapper (skips weekends) + buildBurnUpData.
- BurnUpCone gains a data-driven twin; falls back byte-identical to the
  fixture cone when no forecast (demo mode unchanged).
- Focus card shows the real "80% of the open backlog lands by <range>",
  real scope count, and names the cold-start priors.

v0 scope (each a later slice): single serial worker (capacity is #8);
cold-start priors only (empirical fit is #5); no historical actual polyline
(needs lifecycle events, #5). Header chrome (reconcile time, ahead/behind
badge) stays fixture until milestone due dates land.

Verified: 51 core tests green, desktop typecheck clean, 14 fixture e2e green,
live spec asserts the real cone renders (25 open issues, "lands by Nov 11-27").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:10:34 -04:00
95 changed files with 1495 additions and 10766 deletions

View File

@@ -1,42 +0,0 @@
# CommiTea (desktop)
An AI project manager for Gitea, as an Electron desktop app. The token stays in
the main process (OS keychain via `safeStorage`); the renderer never sees it.
## Develop
```bash
yarn dev # electron-vite dev, HMR renderer + main (logs → desktop.log)
yarn typecheck
yarn e2e # builds, then Playwright against the built main (fixtures/demo)
```
For a live pass against a real Gitea, put a token in `.env.local`
(`GITEA_TOKEN=…`) and run `GITEA_LIVE=1 yarn e2e live-onboarding`.
## Package (shareable macOS .dmg)
```bash
yarn pack # unpacked .app for the host arch (fast sanity check) → dist/
yarn dist # arm64 + x64 .dmg → dist/CommiTea-<version>[-arm64].dmg
```
Config is `electron-builder.yml`. Builds are **unsigned** (no Apple certs, by
decision) — electron-builder ad-hoc signs so the app can run, but it is not
notarized. Bump `version` in `package.json` for a new release; keep
`electronVersion` in `electron-builder.yml` in sync with the `electron`
devDependency.
### Installing a shared build (teammates)
macOS Gatekeeper blocks unsigned apps on first launch. To open:
1. Open the `.dmg` and drag **CommiTea** to **Applications**.
2. In Applications, **right-click CommiTea → Open**, then confirm **Open** in the
dialog. (Double-clicking the first time just shows "cannot be opened".)
3. It opens to the connection screen — enter your Gitea base URL, `owner/repo`,
and a personal access token (repo scope). Each teammate uses their own token,
so activity is attributed correctly. A model URL is optional; chat is disabled
until one is configured, everything else works without it.
Grab the `-arm64` dmg on Apple Silicon, the plain one on Intel.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 KiB

View File

@@ -11,7 +11,6 @@ const MAIN = join(here, '..', 'out', 'main', 'index.js')
test.describe('live backlog', () => { test.describe('live backlog', () => {
test('The pot + Focus render real gitea data', async () => { test('The pot + Focus render real gitea data', async () => {
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test') test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
test.setTimeout(90_000)
const app = await electron.launch({ args: [MAIN], env: { ...process.env } }) const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow() const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded') await win.waitForLoadState('domcontentloaded')
@@ -35,53 +34,6 @@ test.describe('live backlog', () => {
await expect(win.getByText(/Cold-start priors/)).toBeVisible() await expect(win.getByText(/Cold-start priors/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' }) await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' })
// Runway → calibration surface, fitted from real closed-issue actuals (#1).
// With <20 estimated closes the repo is honestly cold-start; the note proves
// the fit ran on real data, not the fixture's "calibrated on 27".
await rail.getByRole('button', { name: 'Runway' }).click()
await expect(
win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/),
).toBeVisible()
// Real per-milestone forecasts — these milestone names come from gitea, not the
// fixture (Beta / Pilot-ready / v1.0). The runway shows milestones with open
// scope; P5 is active (P2 is fully shipped, so it's correctly omitted).
await expect(win.getByText(/P5 — Dogfood/)).toBeVisible()
// Real capacity config from pm-state (christian/stephen), not the fixture (Stephen/Ana K.)
await expect(win.getByText('christian', { exact: true })).toBeVisible()
await expect(win.getByText(/pd\/day/).first()).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' })
// Milestone drill-in — clicking a real (active) milestone opens its real detail
await win.getByText(/P5 — Dogfood/).click()
await expect(win.getByRole('heading', { name: 'P5 — Dogfood + polish' })).toBeVisible()
await expect(win.getByText(/\d+ issues · est \d+d/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-milestone.png'), fullPage: true, animations: 'disabled' })
await rail.getByRole('button', { name: 'Runway' }).click()
await win.getByRole('button', { name: 'Full report' }).click()
await expect(win.getByText(/cold-start · \d+\/20|curve active · n ≥ 20/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-calibration.png'), fullPage: true, animations: 'disabled' })
// Write path (apply_changes): open the top scheduled issue, propose an
// estimate change, and confirm the propose-approve diff renders — then CANCEL
// so the live run never mutates the real repo (the PUT is unit-tested; a
// one-off change→revert verified it against gitea manually).
await rail.getByRole('button', { name: 'Morning service' }).click()
await win.getByRole('main').getByRole('link').first().click()
await expect(win.getByText(/· stephen\/commitea/)).toBeVisible()
await win.getByRole('button', { name: 'Adjust' }).click()
await expect(win.getByText('Adjust issue')).toBeVisible()
await win.getByRole('combobox').first().selectOption('est/8d')
await expect(win.getByText('Proposed change')).toBeVisible()
await expect(win.getByText(/est\/8d/).last()).toBeVisible()
// the unified tool also drives assignee + milestone — the pickers render and diff
await expect(win.getByText('Assignee', { exact: true })).toBeVisible()
await expect(win.getByText('Milestone', { exact: true })).toBeVisible()
await win.getByRole('combobox').nth(2).selectOption('') // Assignee → Unassigned
await expect(win.getByText('unassign', { exact: true })).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' })
await win.getByRole('button', { name: 'Cancel' }).click() // no mutation
await app.close() await app.close()
}) })
}) })

View File

@@ -1,37 +0,0 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { _electron as electron, expect, test } from '@playwright/test'
const here = dirname(fileURLToPath(import.meta.url))
const MAIN = join(here, '..', 'out', 'main', 'index.js')
// Opt-in (GITEA_LIVE=1 + COMMITEA_MODEL_LIVE=1 + a local model on :1234). Launches
// WITHOUT COMMITEA_E2E so capture_work runs the real decomposition. It Discards at
// the end so the run never files junk issues (the create-issue POST is unit-tested).
test.describe('live capture_work', () => {
test('decomposes a braindump into a reviewable ticket set', async () => {
test.skip(!process.env.GITEA_LIVE || !process.env.COMMITEA_MODEL_LIVE, 'live model test — opt-in')
test.setTimeout(300_000)
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded')
const rail = win.getByRole('navigation', { name: 'Primary' })
await rail.getByRole('button', { name: 'Capture' }).click()
await expect(win.getByRole('heading', { name: 'Capture' })).toBeVisible()
// the braindump is prefilled; run the real decomposition
await win.getByRole('button', { name: 'Brew tickets' }).click()
// capture_work → the review tray with a real, estimated ticket set
await expect(win.getByText(/tickets · ~\d+d of work/)).toBeVisible({ timeout: 240_000 })
await expect(win.getByRole('button', { name: 'Approve all' })).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-capture.png'), fullPage: true, animations: 'disabled' })
// Discard — never file junk issues into the real repo from a test
await win.getByRole('button', { name: 'Discard' }).click()
await expect(win.getByRole('button', { name: 'Brew tickets' })).toBeVisible()
await app.close()
})
})

View File

@@ -1,32 +0,0 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { _electron as electron, expect, test } from '@playwright/test'
const here = dirname(fileURLToPath(import.meta.url))
const MAIN = join(here, '..', 'out', 'main', 'index.js')
// Opt-in (GITEA_LIVE=1 + COMMITEA_MODEL_LIVE=1 + a local model + the commitea-pm-state
// repo). Reginald records a standing directive; it's appended to the real pm-state
// ledger. The append is idempotent-ish (a fresh line each run); this only reads back
// that a directive was consulted, and leaves the ledger intact.
test.describe('live record_directive', () => {
test('logs a standing directive to the pm-state ledger', async () => {
test.skip(!process.env.GITEA_LIVE || !process.env.COMMITEA_MODEL_LIVE, 'live model test — opt-in')
test.setTimeout(300_000)
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded')
await expect(win.getByText(/· local$/)).toBeVisible({ timeout: 20000 })
const composer = win.getByPlaceholder(/Tell me what to do/)
await composer.fill('Record a standing directive: freeze scope for beta, pilots come first.')
await composer.press('Enter')
// the agent logged it to the ledger (record_directive), not applied a change
await expect(win.getByText(/consulted the directive ledger/)).toBeVisible({ timeout: 240_000 })
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-directive.png'), fullPage: true, animations: 'disabled' })
await app.close()
})
})

View File

@@ -1,85 +0,0 @@
import { dirname, join } from 'node:path'
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { _electron as electron, expect, test } from '@playwright/test'
const here = dirname(fileURLToPath(import.meta.url))
const MAIN = join(here, '..', 'out', 'main', 'index.js')
/** Read the real token from .env.local (walking up) so the test can fill the form. */
function readToken(): string | null {
let dir = here
for (let i = 0; i < 6; i++) {
try {
const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(readFileSync(join(dir, '.env.local'), 'utf8'))
if (m) return m[1].trim()
} catch {
/* keep walking */
}
dir = dirname(dir)
}
return null
}
// Opt-in (GITEA_LIVE=1). Forces the onboarding path (COMMITEA_NO_ENV_LOCAL) with a
// fresh userData, fills the real connection form, and asserts it lands on real data.
test.describe('live onboarding', () => {
test('connect form → real board', async () => {
const token = process.env.GITEA_LIVE ? readToken() : null
test.skip(!token, 'GITEA_LIVE + a .env.local token required')
test.setTimeout(90_000)
const app = await electron.launch({
args: [MAIN],
env: { ...process.env, COMMITEA_NO_ENV_LOCAL: '1', COMMITEA_USERDATA: mkdtempSync(join(tmpdir(), 'commitea-')) },
})
const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded')
// Any uncaught render error in a real-data view builder must fail the test —
// this is the "it actually works on real data" guarantee.
const pageErrors: string[] = []
win.on('pageerror', (e) => pageErrors.push(e.message))
// the connection gate, not the app
await expect(win.getByText(/Connect your Gitea/)).toBeVisible({ timeout: 15000 })
await win.getByPlaceholder('your-org').fill('christian')
await win.getByPlaceholder('your-repo').fill('commitea')
await win.getByPlaceholder(/gitea PAT/).fill(token!)
await win.getByRole('button', { name: 'Connect' }).click()
const rail = win.getByRole('navigation', { name: 'Primary' })
const realTitle = /Model router|SQLite cache bootstrap|Purity\/rebuild/
// Board (real reconciled columns) — real issue titles prove the sync landed.
await rail.getByRole('button', { name: 'The pot' }).click()
await expect(win.getByText(realTitle).first()).toBeVisible({ timeout: 30000 })
// Gantt tab — scheduler-derived bars over the real open backlog.
await win.getByRole('tab', { name: 'Gantt' }).click()
await expect(win.getByText(realTitle).first()).toBeVisible()
// Dependencies tab — real dep graph. Just needs to render without throwing.
await win.getByRole('tab', { name: 'Dependencies' }).click()
await win.waitForTimeout(300)
// Issue drill-in — the machine-derived sidecar (lifecycle/forecast/deps) on a real issue.
await win.getByRole('tab', { name: 'Board' }).click()
await win.getByText(realTitle).first().click()
await expect(win.getByText('Machine-derived')).toBeVisible()
await win.getByRole('button', { name: 'Back' }).click()
// Standup + Inbox — scheduler plan / nag / inbox feed over real data.
await rail.getByRole('button', { name: 'Standup' }).click()
await expect(win.getByRole('heading', { name: 'Morning standup' })).toBeVisible()
await rail.getByRole('button', { name: 'Inbox' }).click()
await expect(win.getByRole('heading', { name: 'Inbox' })).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-onboarding.png'), fullPage: true, animations: 'disabled' })
expect(pageErrors, `uncaught render errors: ${pageErrors.join(' · ')}`).toEqual([])
await app.close()
})
})

View File

@@ -1,37 +0,0 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { _electron as electron, expect, test } from '@playwright/test'
const here = dirname(fileURLToPath(import.meta.url))
const MAIN = join(here, '..', 'out', 'main', 'index.js')
// Opt-in (GITEA_LIVE=1). A real launch persists the snapshot; a second launch with
// gitea unreachable must still show the board + real scheduler output from the
// persisted cache (offline reads). No model needed.
test.describe('live persistence', () => {
test('offline: serves the persisted snapshot', async () => {
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
test.setTimeout(120_000)
// launch 1 — real reconcile writes the snapshot to disk
const app1 = await electron.launch({ args: [MAIN], env: { ...process.env } })
const w1 = await app1.firstWindow()
await w1.waitForLoadState('domcontentloaded')
// a scheduler-only phrase confirms real data reconciled (never emitted by fixtures)
await expect(w1.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible({ timeout: 30000 })
await app1.close()
// launch 2 — gitea unreachable; the reconcile must fall back to the persisted snapshot
const app2 = await electron.launch({
args: [MAIN],
env: { ...process.env, GITEA_BASE_URL: 'http://127.0.0.1:9' },
})
const w2 = await app2.firstWindow()
await w2.waitForLoadState('domcontentloaded')
// Focus still renders real scheduler output — proving it came from the cache, offline
await expect(w2.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible({ timeout: 20000 })
await w2.screenshot({ path: join(here, '.artifacts', 'screens', 'live-offline.png'), fullPage: true, animations: 'disabled' })
await app2.close()
})
})

View File

@@ -1,56 +0,0 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { _electron as electron, expect, test } from '@playwright/test'
const here = dirname(fileURLToPath(import.meta.url))
const MAIN = join(here, '..', 'out', 'main', 'index.js')
// Opt-in (GITEA_LIVE=1 + COMMITEA_MODEL_LIVE=1 + a local model on :1234). Launches
// WITHOUT COMMITEA_E2E so Reginald runs the real agent loop against the real repo.
test.describe('live Reginald', () => {
test('answers a question by consulting the real project', async () => {
test.skip(!process.env.GITEA_LIVE || !process.env.COMMITEA_MODEL_LIVE, 'live model test — opt-in')
test.setTimeout(300_000) // a big local model is slow: ~2 calls/turn + a reconcile
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded')
// model configured → the live greeting + header (the loaded model, not the scripted demo)
await expect(win.getByText(/· local$/)).toBeVisible({ timeout: 20000 })
await expect(win.getByText(/I check the real board before I answer/)).toBeVisible()
const composer = win.getByPlaceholder(/Tell me what to do/)
await composer.fill('What should I work on right now?')
await composer.press('Enter')
// the agent loop ran end-to-end: it consulted the project, then answered
await expect(win.getByText(/consulted the project/)).toBeVisible({ timeout: 240_000 })
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-reginald.png'), fullPage: true, animations: 'disabled' })
await app.close()
})
test('proposes an estimate change for inline approval (writes via chat)', async () => {
test.skip(!process.env.GITEA_LIVE || !process.env.COMMITEA_MODEL_LIVE, 'live model test — opt-in')
test.setTimeout(300_000)
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded')
await expect(win.getByText(/· local$/)).toBeVisible({ timeout: 20000 })
const composer = win.getByPlaceholder(/Tell me what to do/)
await composer.fill('Set the estimate on issue #3 to est/5d.')
await composer.press('Enter')
// propose_change → an inline propose-approve card (never an auto-write)
await expect(win.getByText('Proposed · #3')).toBeVisible({ timeout: 240_000 })
await expect(win.getByText(/→ est\/5d/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-reginald-propose.png'), fullPage: true, animations: 'disabled' })
// Dismiss so the live run never mutates the repo (the write path itself is #41-tested)
await win.getByRole('button', { name: 'Dismiss' }).click()
await expect(win.getByText(/Left #3 as it was/)).toBeVisible()
await app.close()
})
})

View File

@@ -1,13 +1,5 @@
import { expect, test } from './fixtures.js' import { expect, test } from './fixtures.js'
/**
* Smoke suite — runs in demo mode (COMMITEA_E2E=1), where the main process serves
* the fixed demo snapshot (apps/desktop/src/main/demo-snapshot.ts) with NO live
* gitea. The app renders it through the exact same view builders as real data, so
* these assertions exercise the real render path against deterministic input.
* Full onboarding + agent flows live in the GITEA_LIVE-gated live-*.spec files.
*/
test('boots into the shell with the Focus screen', async ({ app, window }) => { test('boots into the shell with the Focus screen', async ({ app, window }) => {
await app.expectLoaded() await app.expectLoaded()
await expect(window.getByText('Reginald', { exact: true })).toBeVisible() await expect(window.getByText('Reginald', { exact: true })).toBeVisible()
@@ -15,83 +7,121 @@ test('boots into the shell with the Focus screen', async ({ app, window }) => {
await app.screenshot('shell-light') await app.screenshot('shell-light')
}) })
test('Focus shows the Now/Next/Later cards from the scheduler', async ({ window }) => { test('Focus shows Now/Next/Later cards and the burn-up cone', async ({ window }) => {
for (const slot of ['Now', 'Next', 'Later']) { for (const slot of ['Now', 'Next', 'Later']) {
await expect(window.getByText(slot, { exact: true })).toBeVisible() await expect(window.getByText(slot, { exact: true })).toBeVisible()
} }
// the cards link to real demo issues (which specific one is scheduler-dependent) await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible()
await expect(window.getByRole('main').getByRole('link').first()).toBeVisible() await expect(window.getByText(/scope · 42 issues/)).toBeVisible()
}) })
test('Standup renders the drift / plan letter', async ({ app, window }) => { test('Standup renders the drift / plan / nag letter', async ({ app, window }) => {
await app.nav('Standup').click() await app.nav('Standup').click()
await expect(window.getByRole('heading', { name: 'Morning standup' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Morning standup' })).toBeVisible()
await expect(window.getByText("Today's plan")).toBeVisible() await expect(window.getByText('Overnight drift')).toBeVisible()
await expect(window.getByText('Stephen', { exact: true })).toBeVisible()
await app.screenshot('standup') await app.screenshot('standup')
}) })
test('Board: lifecycle columns, a demo issue, and the search filter', async ({ app, window }) => { test('Board: columns, search filter, and Gantt/Deps tabs', async ({ app, window }) => {
await app.nav('The pot').click() await app.nav('The pot').click()
await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible() await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible()
// lifecycle columns populated by the demo snapshot's event stream await expect(window.getByText('Diagnosis', { exact: true })).toBeVisible()
await expect(window.getByText('Steeping', { exact: true })).toBeVisible() await expect(window.getByText('Steeping', { exact: true })).toBeVisible()
await expect(window.getByText('Done', { exact: true })).toBeVisible()
await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible()
await app.screenshot('board') await app.screenshot('board')
await window.getByPlaceholder(/Search the pot/).fill('monte carlo') await window.getByPlaceholder(/Search the pot/).fill('lifecycle')
await expect(window.getByText('Monte Carlo forecast bands on the runway')).toBeVisible() await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible()
await expect(window.getByText('Fix lifecycle inference on merge events')).toBeHidden() await expect(window.getByText('Dark theme: cone fill too faint')).toBeHidden()
await window.getByPlaceholder(/Search the pot/).fill('') await window.getByPlaceholder(/Search the pot/).fill('')
// the Gantt + Dependencies tabs render from the same snapshot
await window.getByRole('tab', { name: 'Gantt' }).click() await window.getByRole('tab', { name: 'Gantt' }).click()
await expect(window.getByText(/80% · Mar 312/)).toBeVisible()
await app.screenshot('gantt') await app.screenshot('gantt')
await window.getByRole('tab', { name: 'Dependencies' }).click() await window.getByRole('tab', { name: 'Dependencies' }).click()
await expect(window.getByText('critical path', { exact: true })).toBeVisible()
await app.screenshot('deps') await app.screenshot('deps')
}) })
test('Runway shows the demo milestones', async ({ app, window }) => { test('Runway: milestones, capacity, and drill-ins', async ({ app, window }) => {
await app.nav('Runway').click() await app.nav('Runway').click()
await expect(window.getByRole('heading', { name: 'Runway' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Runway' })).toBeVisible()
await expect(window.getByText('Beta', { exact: true })).toBeVisible() await expect(window.getByText('Pilot-ready')).toBeVisible()
await expect(window.getByText('deadline/hard')).toBeVisible()
await app.screenshot('runway') await app.screenshot('runway')
// milestone drill-in
await window.getByText('Beta', { exact: true }).click()
await expect(window.getByRole('heading', { name: 'Beta' })).toBeVisible()
await expect(window.getByText('42 issues · est 61d')).toBeVisible()
await app.screenshot('milestone')
await window.getByRole('main').getByRole('button', { name: 'Runway' }).click()
// calibration drill-in
await window.getByRole('button', { name: 'Full report' }).click()
await expect(window.getByRole('heading', { name: 'Calibration' })).toBeVisible()
await expect(window.getByText('The shape of hope')).toBeVisible()
await app.screenshot('calibration')
}) })
test('Inbox renders', async ({ app, window }) => { test('Inbox: notifications and mark-all-read', async ({ app, window }) => {
await app.nav('Inbox').click() await app.nav('Inbox').click()
await expect(window.getByRole('heading', { name: 'Inbox' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Inbox' })).toBeVisible()
await expect(window.getByText(/window moved/)).toBeVisible()
await app.screenshot('inbox') await app.screenshot('inbox')
await window.getByRole('button', { name: 'Mark all read' }).click()
await expect(window.getByText('all read · nothing here rings twice')).toBeVisible()
}) })
test('Capture: braindump prompt', async ({ app, window }) => { test('Capture: braindump advances to the interview', 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("Tell me what you're planning")).toBeVisible()
await app.screenshot('capture') await app.screenshot('capture')
await window.getByRole('button', { name: 'Brew tickets' }).click()
await expect(window.getByText(/Interview · 1 of 3/)).toBeVisible()
}) })
test('Directives: the live ledger from pm-state', async ({ app, window }) => { test('Directives: consequence diff + append-only ledger', async ({ app, window }) => {
await app.nav('Directives').click() await app.nav('Directives').click()
await expect(window.getByRole('heading', { name: 'Directives' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Directives' })).toBeVisible()
await expect(window.getByText('Consequence diff')).toBeVisible()
await expect(window.getByText(/append-only · JSONL in pm-state/)).toBeVisible() await expect(window.getByText(/append-only · JSONL in pm-state/)).toBeVisible()
// a demo directive quote from the snapshot
await expect(window.getByText(/Beta ships the 12th, hard/)).toBeVisible()
await app.screenshot('directives') await app.screenshot('directives')
// resolving the pending directive moves it into the ledger
await window.getByRole('button', { name: 'Make it so' }).click()
await expect(window.getByText('Consequence diff')).toBeHidden()
await expect(window.getByText(/Nothing awaits your word/)).toBeVisible()
}) })
test('Settings: connection + appearance, theme sync', async ({ app, window }) => { test('Settings: connection, schema, and appearance sync with theme', async ({ app, window }) => {
await app.nav('Settings').click() await app.nav('Settings').click()
await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible()
await expect(window.getByRole('heading', { name: 'Connection' })).toBeVisible() await expect(window.getByText('Managed repos')).toBeVisible()
await app.screenshot('settings') await app.screenshot('settings')
// the Evening (dark) radio drives the shared theme
await window.getByText('Evening (dark)').click() await window.getByText('Evening (dark)').click()
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark') await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark')
}) })
test('issue drill-in from a Focus card and back', async ({ app, window }) => { test('Onboarding: full-window first-run flow with test gate', async ({ app, window }) => {
// Focus cards render the issue title as a link; open the first and come back. await app.nav('First run').click()
await window.getByRole('main').getByRole('link').first().click() await expect(window.getByRole('heading', { name: 'Good morning.' })).toBeVisible()
await expect(window.getByRole('button', { name: 'Back' })).toBeVisible() // full-window: no rail wordmark link, brand only
await app.screenshot('onboarding')
await window.getByRole('button', { name: 'Begin' }).click()
await expect(window.getByRole('heading', { name: 'Your Gitea' })).toBeVisible()
// Continue is gated until the connection test passes
await expect(window.getByRole('button', { name: 'Continue' })).toBeDisabled()
await window.getByRole('button', { name: 'Test connection' }).click()
await expect(window.getByText(/connected · 3 repos visible/)).toBeVisible()
await expect(window.getByRole('button', { name: 'Continue' })).toBeEnabled()
})
test('issue drill-in from a Focus card and back-stack of one', async ({ app, window }) => {
await window.getByRole('link', { name: 'Fix lifecycle inference on merge events' }).click()
await expect(window.getByText('#87 · stephen/commitea')).toBeVisible()
await expect(window.getByText('Machine-derived')).toBeVisible()
await app.screenshot('issue') await app.screenshot('issue')
await window.getByRole('button', { name: 'Back' }).click() await window.getByRole('button', { name: 'Back' }).click()
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
@@ -103,6 +133,12 @@ test('Evening service toggle flips the document theme', async ({ app, window })
await app.screenshot('shell-dark') await app.screenshot('shell-dark')
}) })
test('offline sim shows the banner and disables the composer', async ({ window }) => {
await window.getByRole('button', { name: /Connection/ }).click()
await expect(window.getByText(/Gitea isn.t answering/)).toBeVisible()
await expect(window.getByRole('textbox')).toBeDisabled()
})
test('exposes the commitea preload API', async ({ app }) => { test('exposes the commitea preload API', async ({ app }) => {
const api = await app.preloadApi() const api = await app.preloadApi()
expect(api).toBeDefined() expect(api).toBeDefined()

View File

@@ -1,51 +0,0 @@
appId: io.stephenmann.commitea
productName: CommiTea
copyright: © CommiTea
# Pinned because electron is hoisted by the yarn workspace and declared with a
# caret range, which electron-builder can't resolve on its own. Keep in sync with
# the electron devDependency.
electronVersion: 34.5.8
directories:
buildResources: build
output: dist
# The renderer, preload, and main are already bundled into out/ by electron-vite
# (@commitea/core, react, react-dom are all inlined). So the packaged app needs
# nothing from node_modules except the Electron runtime that electron-builder
# supplies — ship only the build output.
files:
- out/**
- '!**/*.map'
npmRebuild: false
electronLanguages:
- en
mac:
target:
- target: dmg
arch:
- arm64
- x64
category: public.app-category.developer-tools
icon: build/icon.png
# Unsigned, per the distribution decision (macOS, no certs). Teammates
# right-click → Open the first time to get past Gatekeeper.
identity: null
# Keep hardened-runtime off — it only matters for signed/notarized builds
# and can block an unsigned app from launching.
hardenedRuntime: false
gatekeeperAssess: false
dmg:
title: CommiTea ${version}
# A plain drag-to-Applications layout.
contents:
- x: 130
y: 220
type: file
- x: 410
y: 220
type: link
path: /Applications

View File

@@ -1,8 +1,5 @@
{ {
"name": "@commitea/desktop", "name": "@commitea/desktop",
"version": "0.1.0",
"description": "CommiTea — an AI project manager for Gitea",
"author": "CommiTea",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@@ -11,12 +8,9 @@
"build": "electron-vite build", "build": "electron-vite build",
"start": "electron-vite preview", "start": "electron-vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run",
"e2e": "electron-vite build && playwright test", "e2e": "electron-vite build && playwright test",
"e2e:only": "playwright test", "e2e:only": "playwright test",
"e2e:report": "playwright show-report e2e/.artifacts/report", "e2e:report": "playwright show-report e2e/.artifacts/report"
"pack": "electron-vite build && electron-builder --dir",
"dist": "electron-vite build && electron-builder --mac"
}, },
"dependencies": { "dependencies": {
"@commitea/core": "workspace:*", "@commitea/core": "workspace:*",
@@ -31,11 +25,9 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"electron": "^34.0.0", "electron": "^34.0.0",
"electron-builder": "^25",
"electron-vite": "^3.1.0", "electron-vite": "^3.1.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.1.0" "vite": "^6.1.0"
} }

View File

@@ -1,142 +0,0 @@
/**
* Dogfood (#31): run CommiTea's own deterministic engine against the live
* christian/commitea backlog and print the project report each screen derives —
* board, critical path, schedule, Monte Carlo forecast, per-milestone runway.
*
* This is the core brain (schedule + forecast + capacity), the exact code the
* desktop app runs. Read-only. Run: yarn tsx scripts/dogfood-report.ts
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
createGiteaClient,
forecast,
inferLifecycle,
parseCapacityConfig,
capacityPerWorkday,
scheduleWithCapacity,
type GiteaIssue,
type DependencyEdge,
type Worker,
} from '@commitea/core'
const here = dirname(fileURLToPath(import.meta.url))
function token(): string {
let dir = here
for (let i = 0; i < 6; i++) {
try {
const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(readFileSync(join(dir, '.env.local'), 'utf8'))
if (m) return m[1].trim()
} catch {
/* keep walking up */
}
dir = dirname(dir)
}
throw new Error('no GITEA_TOKEN in any .env.local up the tree')
}
/** working-day offset → calendar date (skip Sat/Sun), then a short label. */
function addWorkingDays(base: Date, n: number): Date {
const d = new Date(base)
let left = Math.ceil(n)
while (left > 0) {
d.setDate(d.getDate() + 1)
const day = d.getDay()
if (day !== 0 && day !== 6) left--
}
return d
}
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
async function main() {
const client = createGiteaClient(
{ baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea', token: token() },
fetch,
)
const all = await client.listIssues({ state: 'all' })
const open = all.filter((i) => i.state === 'open')
const closed = all.filter((i) => i.state === 'closed')
// Real dependency edges (open issues only — the scheduler drops out-of-scope endpoints anyway).
const edges: DependencyEdge[] = []
for (const i of open) {
for (const dep of await client.getIssueDependencies(i.number)) edges.push({ issue: i.number, dependsOn: dep })
}
// Capacity lanes from the pm-state repo (a separate repo, exactly like the app's
// pmStateClient — NOT the managed repo).
const pmClient = createGiteaClient(
{ baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea-pm-state', token: token() },
fetch,
)
let workers: Worker[] = []
try {
const f = await pmClient.getFile('capacity/members.json')
if (f) {
const members = parseCapacityConfig(JSON.parse(Buffer.from(f.contentBase64, 'base64').toString('utf8')))
workers = members.map((m) => ({ person: m.person, speed: capacityPerWorkday(m) }))
}
} catch {
/* no capacity → single serial worker */
}
const toSchedulable = (issues: GiteaIssue[]) =>
issues.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
}))
const plan = scheduleWithCapacity(toSchedulable(open), edges, workers)
const today = new Date()
const f = forecast(toSchedulable(open), edges, { workers })
const bar = '─'.repeat(64)
console.log(`\n${bar}\n CommiTea, on itself — ${fmt(today)} ${today.getFullYear()}\n${bar}`)
console.log(` Board: ${open.length} open · ${closed.length} done`)
console.log(` Team: ${workers.length ? workers.map((w) => `${w.person} (${w.speed.toFixed(2)}/day)`).join(', ') : 'single serial worker'}`)
const unassigned = open.filter((i) => !i.assignee).length
if (unassigned) console.log(`${unassigned}/${open.length} open issues have no assignee`)
console.log(`\n Schedule (dependency + priority order, ${workers.length || 1} lane${workers.length === 1 ? '' : 's'}):`)
for (const it of plan.items) {
const crit = it.critical ? ' ★crit' : ''
const lane = it.worker ? ` [${it.worker}]` : ''
const blocks = it.blocks.length ? ` blocks ${it.blocks.map((b) => '#' + b).join(',')}` : ''
console.log(
` #${String(it.number).padEnd(3)} ${fmt(addWorkingDays(today, it.startDay))}${fmt(addWorkingDays(today, it.endDay))}${crit.padEnd(7)}${lane} ${it.title.slice(0, 38)}${blocks}`,
)
}
if (plan.cycle) console.log(` ⚠ dependency cycle: ${plan.cycle.join(' → ')}`)
const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
console.log(`\n Forecast (Monte Carlo, ${f.trials} trials, ${f.coldStart ? 'cold-start priors' : 'fitted'}):`)
console.log(` whole backlog lands p50 ${fmt(addWorkingDays(today, f.p50Day))} · p80 ${fmt(addWorkingDays(today, f.p80Day))} · p90 ${fmt(addWorkingDays(today, p90))}`)
// Per-milestone runway.
const ms = await client.listMilestones()
console.log(`\n Runway (per open milestone):`)
for (const m of ms.filter((x) => x.state === 'open')) {
const scope = open.filter((i) => i.milestone?.id === m.id)
if (!scope.length) continue
const mf = forecast(toSchedulable(scope), edges, { workers })
const mp90 = mf.curve.length ? mf.curve[mf.curve.length - 1].p90Day : mf.p95Day
const due = m.dueOn ? fmt(new Date(m.dueOn)) : 'no due date'
console.log(
` ${m.title.padEnd(28)} ${scope.length} open · p50 ${fmt(addWorkingDays(today, mf.p50Day))}p90 ${fmt(addWorkingDays(today, mp90))} (due: ${due})`,
)
}
console.log(`\n${bar}\n`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@@ -1,101 +0,0 @@
/**
* Persistent app config for a shared/team build. The gitea PAT is encrypted at
* rest with Electron's safeStorage (OS keychain-backed) and only ever lives in
* the main process — the renderer receives everything *except* the token. Config
* lives in userData so each teammate has their own; a `.env.local` remains a dev
* fallback (see resolveConfig). Nothing here runs under COMMITEA_E2E.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { app, safeStorage } from 'electron'
export interface AppConfig {
baseUrl: string
owner: string
repo: string
token: string
/** Defaults to `${repo}-pm-state` when omitted. */
pmStateRepo?: string
/** OpenAI-compatible base for Reginald; chat stays off when absent. */
modelUrl?: string
}
/** The renderer-safe view — everything but the token, plus whether one is set. */
export type PublicConfig = Omit<AppConfig, 'token'> & { hasToken: boolean }
interface StoredConfig {
baseUrl: string
owner: string
repo: string
tokenEnc: string | null // base64 of safeStorage-encrypted token
pmStateRepo?: string
modelUrl?: string
}
function configPath(): string {
return join(app.getPath('userData'), 'commitea-config.json')
}
function readStored(): StoredConfig | null {
try {
return JSON.parse(readFileSync(configPath(), 'utf8')) as StoredConfig
} catch {
return null
}
}
/** Full config incl. the decrypted token, or null when unset. Main-process only. */
export function loadConfig(): AppConfig | null {
const s = readStored()
if (!s || !s.baseUrl || !s.owner || !s.repo) return null
let token = ''
if (s.tokenEnc) {
try {
token = safeStorage.decryptString(Buffer.from(s.tokenEnc, 'base64'))
} catch {
token = '' // key rotated / different machine — treat as no token
}
}
if (!token) return null
return { baseUrl: s.baseUrl, owner: s.owner, repo: s.repo, token, pmStateRepo: s.pmStateRepo, modelUrl: s.modelUrl }
}
/** The renderer-safe view of the saved config (never includes the token). */
export function publicConfig(): PublicConfig | null {
const s = readStored()
if (!s) return null
return {
baseUrl: s.baseUrl,
owner: s.owner,
repo: s.repo,
pmStateRepo: s.pmStateRepo,
modelUrl: s.modelUrl,
hasToken: !!s.tokenEnc,
}
}
/** Encrypt the token + persist. Best-effort; throws only on a genuine write failure. */
export function saveConfig(cfg: AppConfig): void {
const tokenEnc = cfg.token
? Buffer.from(safeStorage.encryptString(cfg.token)).toString('base64')
: (readStored()?.tokenEnc ?? null) // keep the existing token if none supplied
const stored: StoredConfig = {
baseUrl: cfg.baseUrl.trim().replace(/\/+$/, ''),
owner: cfg.owner.trim(),
repo: cfg.repo.trim(),
tokenEnc,
pmStateRepo: cfg.pmStateRepo?.trim() || undefined,
modelUrl: cfg.modelUrl?.trim() || undefined,
}
writeFileSync(configPath(), JSON.stringify(stored, null, 2), 'utf8')
}
export function clearConfig(): void {
try {
writeFileSync(configPath(), JSON.stringify({ baseUrl: '', owner: '', repo: '', tokenEnc: null }), 'utf8')
} catch {
// ignore
}
}

View File

@@ -1,152 +0,0 @@
/**
* Deterministic demo snapshot — the ONLY test fixture, served by the main process
* when COMMITEA_E2E=1 so the e2e suite (and a no-token first look) runs WITHOUT a
* live Gitea. It's gitea-shaped on purpose: the reconcile handlers return it and
* the app renders it through the exact same view builders (schedule, forecast,
* lifecycle, standup, runway) as real data. There are no screen-level mocks — this
* is the single source of demo truth. Not shipped behavior; test infrastructure.
*/
import {
type CapacityMember,
type DependencyEdge,
type DirectiveRecord,
extractLabelFacts,
type GiteaIssue,
type GiteaMilestone,
type LifecycleEvent,
} from '@commitea/core'
const HOST = 'https://gitea.demo.local'
const OWNER = 'teatime'
const REPO = 'commitea'
/** Fixed clock so lifecycle math + forecasts are stable across runs. */
export const DEMO_TODAY = '2026-03-02T09:00:00Z'
interface Seed {
number: number
title: string
labels: string[]
state?: 'open' | 'closed'
milestone?: number
assignee?: string
events?: LifecycleEvent[]
closedAt?: string
createdAt?: string
}
const MILESTONES_RAW = [
{ id: 1, title: 'Beta', description: 'Pilot-ready', dueOn: '2026-03-12T00:00:00Z', state: 'open' as const },
{ id: 2, title: 'v1.0', description: 'General availability', dueOn: '2026-05-01T00:00:00Z', state: 'open' as const },
]
const MREF = (id: number) => {
const m = MILESTONES_RAW.find((x) => x.id === id)!
return { id: m.id, title: m.title, dueOn: m.dueOn }
}
// A small, believable backlog for a team building CommiTea. Timelines place issues
// across the five lifecycle columns: bare → diagnosis, labeled → triage, a commit
// ref → steeping, a PR ref → review, closed → done.
const SEEDS: Seed[] = [
{ number: 42, title: 'Fix lifecycle inference on merge events', labels: ['est/3d', 'p/1', 'deadline/hard'], milestone: 1, assignee: 'stephen',
events: [{ type: 'triage', at: '2026-02-24T10:00:00Z' }, { type: 'commit', at: '2026-02-27T14:00:00Z' }] },
{ number: 41, title: 'Monte Carlo forecast bands on the runway', labels: ['est/5d', 'p/1'], milestone: 1, assignee: 'ana',
events: [{ type: 'triage', at: '2026-02-23T09:00:00Z' }, { type: 'commit', at: '2026-02-26T09:00:00Z' }, { type: 'pull', at: '2026-03-01T16:00:00Z' }] },
{ number: 40, title: 'Encrypted token store in the main process', labels: ['est/2d', 'p/2'], milestone: 1, assignee: 'stephen',
events: [{ type: 'triage', at: '2026-02-22T11:00:00Z' }] },
{ number: 39, title: 'Deterministic scheduler with capacity lanes', labels: ['est/8d', 'p/2'], milestone: 1, assignee: 'ana',
events: [{ type: 'triage', at: '2026-02-20T09:00:00Z' }, { type: 'commit', at: '2026-02-28T09:00:00Z' }] },
{ number: 38, title: 'Standup ritual: overnight drift + todays plan', labels: ['est/3d', 'p/2'], milestone: 1, assignee: 'stephen',
events: [{ type: 'triage', at: '2026-02-25T09:00:00Z' }] },
{ number: 37, title: 'Offline write-queue with replay on reconnect', labels: ['est/5d', 'p/3'], milestone: 2, assignee: 'ana',
events: [{ type: 'triage', at: '2026-02-19T09:00:00Z' }] },
{ number: 36, title: 'Calibration from closed-issue actuals', labels: ['est/3d', 'p/3'], milestone: 2, assignee: 'stephen',
events: [{ type: 'triage', at: '2026-02-18T09:00:00Z' }] },
{ number: 35, title: 'Reginald: agent loop over query_project', labels: ['est/8d', 'p/2'], milestone: 2, assignee: 'ana',
events: [{ type: 'triage', at: '2026-02-17T09:00:00Z' }] },
{ number: 34, title: 'Board columns from lifecycle events', labels: [], // bare → diagnosis
events: [] },
{ number: 33, title: 'Deep-link URIs across orgs', labels: [], // bare → diagnosis
events: [] },
// shipped work — closed → done, feeds calibration actuals
{ number: 30, title: 'SQLite cache bootstrap', labels: ['est/3d', 'p/2'], milestone: 1, assignee: 'ana', state: 'closed',
createdAt: '2026-02-10T09:00:00Z', closedAt: '2026-02-14T17:00:00Z',
events: [{ type: 'triage', at: '2026-02-10T09:00:00Z' }, { type: 'commit', at: '2026-02-11T09:00:00Z' }, { type: 'pull', at: '2026-02-13T09:00:00Z' }, { type: 'close', at: '2026-02-14T17:00:00Z' }] },
{ number: 29, title: 'Connection + target-repo config', labels: ['est/2d', 'p/2'], milestone: 1, assignee: 'stephen', state: 'closed',
createdAt: '2026-02-09T09:00:00Z', closedAt: '2026-02-12T15:00:00Z',
events: [{ type: 'triage', at: '2026-02-09T09:00:00Z' }, { type: 'commit', at: '2026-02-10T09:00:00Z' }, { type: 'close', at: '2026-02-12T15:00:00Z' }] },
{ number: 24, title: 'Unified apply_changes mutation tool', labels: ['est/5d', 'p/1'], milestone: 1, assignee: 'ana', state: 'closed',
createdAt: '2026-02-05T09:00:00Z', closedAt: '2026-02-11T12:00:00Z',
events: [{ type: 'triage', at: '2026-02-05T09:00:00Z' }, { type: 'commit', at: '2026-02-06T09:00:00Z' }, { type: 'pull', at: '2026-02-10T09:00:00Z' }, { type: 'close', at: '2026-02-11T12:00:00Z' }] },
]
function toIssue(s: Seed): GiteaIssue {
const state = s.state ?? 'open'
return {
number: s.number,
title: s.title,
body: '',
state,
labels: s.labels,
facts: extractLabelFacts(s.labels),
milestone: s.milestone ? MREF(s.milestone) : null,
assignee: s.assignee ?? null,
assignees: s.assignee ? [s.assignee] : [],
createdAt: s.createdAt ?? '2026-02-20T09:00:00Z',
updatedAt: DEMO_TODAY,
closedAt: s.closedAt ?? null,
url: `${HOST}/${OWNER}/${REPO}/issues/${s.number}`,
}
}
export const DEMO_ISSUES: GiteaIssue[] = SEEDS.map(toIssue)
export const DEMO_MILESTONES: GiteaMilestone[] = MILESTONES_RAW.map((m) => ({
id: m.id,
title: m.title,
description: m.description,
dueOn: m.dueOn,
state: m.state,
openIssues: DEMO_ISSUES.filter((i) => i.milestone?.id === m.id && i.state === 'open').length,
closedIssues: DEMO_ISSUES.filter((i) => i.milestone?.id === m.id && i.state === 'closed').length,
}))
// #41 (forecast bands) depends on #39 (scheduler); #42 (lifecycle) depends on #34 (columns).
export const DEMO_DEPS: DependencyEdge[] = [
{ issue: 41, dependsOn: 39 },
{ issue: 42, dependsOn: 34 },
]
export const DEMO_TIMELINES: Record<number, LifecycleEvent[]> = Object.fromEntries(
SEEDS.map((s) => [s.number, s.events ?? []]),
)
export const DEMO_CAPACITY: CapacityMember[] = [
{ person: 'stephen', focusFactor: 0.6, allocation: 1 },
{ person: 'ana', focusFactor: 1, allocation: 0.8 },
]
export const DEMO_COLLABORATORS: { login: string; name: string }[] = [
{ login: 'stephen', name: 'Stephen Mann' },
{ login: 'ana', name: 'Ana Koval' },
]
export const DEMO_DIRECTIVES: DirectiveRecord[] = [
{ seq: 1, id: 'demo-1', ts: '2026-02-26T10:00:00Z', status: 'accepted', kind: 'reprioritize',
quote: 'Bump the lifecycle fix — its blocking the board.', target: { issue: 42 }, params: { priority: 1 },
rationale: 'Board columns are wrong until #42 lands.' },
{ seq: 2, id: 'demo-2', ts: '2026-02-24T15:00:00Z', status: 'accepted', kind: 'capacity',
quote: 'Stephens at 60% this sprint — half on support.', params: { person: 'stephen', focusFactor: 0.6 },
rationale: 'Support rotation.' },
{ seq: 3, id: 'demo-3', ts: '2026-02-22T09:00:00Z', status: 'amended', kind: 'set-deadline',
quote: 'Beta ships the 12th, hard.', target: { milestone: 1 }, params: { due: '2026-03-12' } },
]
/** The full gitea-shaped snapshot the reconcile handlers return in demo mode. */
export const DEMO_SNAPSHOT = {
issues: DEMO_ISSUES,
milestones: DEMO_MILESTONES,
deps: DEMO_DEPS,
timelines: DEMO_TIMELINES,
}

View File

@@ -6,48 +6,12 @@
* properly later. * properly later.
*/ */
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { import { createGiteaClient, type GiteaConfig, type LifecycleEvent } from '@commitea/core'
appendDirective,
applySchemaLabels,
createGiteaClient,
discoverRepos,
ensurePmStateRepo,
GiteaApiError,
type DirectiveEntry,
type GiteaClient,
type GiteaConfig,
type GiteaLabel,
type IssueChange,
isLabelChange,
type LifecycleEvent,
makeDirectiveEntry,
parseCapacityConfig,
parseDirectiveLog,
planIssueChange,
type ProjectSnapshot,
type DirectiveInput,
} from '@commitea/core'
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import {
DEMO_CAPACITY,
DEMO_COLLABORATORS,
DEMO_DIRECTIVES,
DEMO_SNAPSHOT,
DEMO_TODAY,
} from './demo-snapshot.js'
/** Demo mode: the e2e harness (and a no-token first look) render the fixed demo
* snapshot through the real view builders — no live gitea, no screen-level mocks. */
const isDemo = (): boolean => process.env.COMMITEA_E2E === '1'
import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-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). */
function loadEnvLocalToken(): string | undefined { function loadEnvLocalToken(): string | undefined {
let dir = process.cwd() let dir = process.cwd()
@@ -66,15 +30,9 @@ function loadEnvLocalToken(): string | undefined {
return undefined return undefined
} }
/** Saved config wins (team build); a `.env.local` is the dev fallback; e2e uses fixtures. */ function resolveConfig(): GiteaConfig | null {
export function resolveConfig(): GiteaConfig | null {
// E2E runs against fixtures — never hit the network from the test harness. // E2E runs against fixtures — never hit the network from the test harness.
if (process.env.COMMITEA_E2E === '1') return null if (process.env.COMMITEA_E2E === '1') return null
const saved = loadConfig()
if (saved) return { baseUrl: saved.baseUrl, token: saved.token, owner: saved.owner, repo: saved.repo }
// COMMITEA_NO_ENV_LOCAL forces the onboarding path (no .env.local fallback) for testing.
if (process.env.COMMITEA_NO_ENV_LOCAL === '1') return null
// dev fallback — .env.local / env vars
const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken() const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken()
if (!token) return null if (!token) return null
return { return {
@@ -85,84 +43,15 @@ export function resolveConfig(): GiteaConfig | null {
} }
} }
/** The pm-state repo name: configured, else `${repo}-pm-state`, else the env override. */ export function registerGiteaIpc(): void {
function pmStateRepoName(config: GiteaConfig): string {
return loadConfig()?.pmStateRepo ?? process.env.COMMITEA_PMSTATE_REPO ?? `${config.repo}-pm-state`
}
// Memoized clients; reset via resetClients() when the config changes.
let sharedClient: GiteaClient | null | undefined
let pmStateClient: GiteaClient | null | undefined
/** Drop the cached clients + snapshot so the next call re-reads the new config. */
export function resetClients(): void {
sharedClient = undefined
pmStateClient = undefined
labelCache = null
invalidateSnapshot()
}
export function getGiteaClient(): GiteaClient | null {
if (sharedClient === undefined) {
const config = resolveConfig() const config = resolveConfig()
sharedClient = config ? createGiteaClient(config, fetch) : null const client = config ? createGiteaClient(config, fetch) : null
} const repo = config ? `${config.owner}/${config.repo}` : null
return sharedClient
}
// The pm-state repo holds machine-derived state (the directive ledger). Same ipcMain.handle('gitea:status', () => ({ configured: !!config, repo }))
// token/host as the work repo, a different repo (the purity split, decisions D4).
export function getPmStateClient(): GiteaClient | null {
if (pmStateClient === undefined) {
const config = resolveConfig()
pmStateClient = config ? createGiteaClient({ ...config, repo: pmStateRepoName(config) }, fetch) : null
}
return pmStateClient
}
const DIRECTIVE_LOG_PATH = 'directives/log.jsonl' ipcMain.handle('gitea:reconcile', async () => {
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
async function readDirectiveLog(client: GiteaClient): Promise<{ text: string; sha: string | null }> {
const file = await client.getFile(DIRECTIVE_LOG_PATH)
if (!file) return { text: '', sha: null }
return { text: Buffer.from(file.contentBase64, 'base64').toString('utf8'), sha: file.sha }
}
/** Record a directive: read the ledger, append, write it back (concatenation merge). */
export async function appendDirectiveEntry(client: GiteaClient, input: DirectiveInput): Promise<DirectiveEntry> {
const entry = makeDirectiveEntry(input, randomUUID(), new Date().toISOString())
const { text, sha } = await readDirectiveLog(client)
const next = appendDirective(text, entry)
await client.putFile(DIRECTIVE_LOG_PATH, {
contentBase64: Buffer.from(next, 'utf8').toString('base64'),
message: `directive: ${entry.kind}`,
sha: sha ?? undefined,
})
return entry
}
export async function readDirectives(client: GiteaClient) {
const { text } = await readDirectiveLog(client)
return parseDirectiveLog(text)
}
const CAPACITY_PATH = 'capacity/members.json'
/** Read the capacity config from the pm-state repo (empty when absent). */
export async function readCapacity(client: GiteaClient) {
const file = await client.getFile(CAPACITY_PATH)
if (!file) return []
try {
return parseCapacityConfig(JSON.parse(Buffer.from(file.contentBase64, 'base64').toString('utf8')))
} catch {
return []
}
}
/** Full reconcile: issues + milestones + native deps + lifecycle timelines. */
export async function reconcileSnapshot(
client: GiteaClient,
): Promise<ProjectSnapshot & { milestones: Awaited<ReturnType<GiteaClient['listMilestones']>> }> {
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()]) const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
// dependency edges among the open scope (the scheduler only plans what's left) // dependency edges among the open scope (the scheduler only plans what's left)
const open = issues.filter((i) => i.state === 'open') const open = issues.filter((i) => i.state === 'open')
@@ -175,253 +64,11 @@ export async function reconcileSnapshot(
issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const), issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const),
) )
const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries) const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries)
return { issues, milestones, deps, timelines } return { configured: true, issues, milestones, deps, timelines }
}
type Snapshot = Awaited<ReturnType<typeof reconcileSnapshot>>
/**
* A single in-memory reconcile cache shared across the app. A full reconcile is
* ~2N gitea calls (deps + timelines per issue); without this, every agent tool
* call refetched the whole repo. Reads within `maxAgeMs` reuse the cache;
* `getSnapshot({ maxAgeMs: 0 })` forces a fresh pull (the explicit UI reconcile),
* and any write calls `invalidateSnapshot()` so the next read sees it. The cache
* is rebuildable — the durable truth stays in gitea (the purity split, D4).
*/
let snapshotCache: { snap: Snapshot; at: number } | null = null
export async function getSnapshot(client: GiteaClient, opts?: { maxAgeMs?: number }): Promise<Snapshot> {
const maxAgeMs = opts?.maxAgeMs ?? 0
if (snapshotCache && maxAgeMs > 0 && Date.now() - snapshotCache.at <= maxAgeMs) {
return snapshotCache.snap
}
const snap = await reconcileSnapshot(client)
snapshotCache = { snap, at: Date.now() }
saveSnapshot(snap, new Date().toISOString()) // persist for instant boot + offline
return snap
}
/** Drop the cache so the next read reflects a just-made write. */
export function invalidateSnapshot(): void {
snapshotCache = null
}
/**
* The last persisted snapshot (from a previous session), for instant boot. The
* renderer shows it immediately, then a real reconcile supersedes it
* (stale-while-revalidate). Returns null when there's nothing on disk; its
* `savedAt` marks staleness. It does NOT seed the cache — agent tool calls
* always reconcile fresh so they never reason over stale data.
*/
export function bootSnapshot(): (Snapshot & { savedAt: string }) | null {
const persisted = loadSnapshot()
if (!persisted) return null
return {
issues: persisted.issues,
milestones: persisted.milestones,
deps: persisted.deps,
timelines: persisted.timelines,
savedAt: persisted.savedAt,
} as unknown as Snapshot & { savedAt: string }
}
/** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */
export const AGENT_SNAPSHOT_TTL_MS = 30_000
// Label cache is reset with the clients (module-level so resetClients can clear it).
let labelCache: GiteaLabel[] | null = null
async function resolveLabelIds(client: GiteaClient, names: string[]): Promise<number[]> {
const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id]))
if (!labelCache) labelCache = await client.listLabels()
let byName = lookup()
if (names.some((n) => !byName.has(n))) {
labelCache = await client.listLabels() // a name we don't know — refetch once
byName = lookup()
}
return names.map((n) => byName.get(n)).filter((id): id is number => id != null)
}
export function registerGiteaIpc(): void {
ipcMain.handle('gitea:status', () => {
const cfg = resolveConfig()
return {
configured: !!cfg,
repo: cfg ? `${cfg.owner}/${cfg.repo}` : null,
demo: process.env.COMMITEA_E2E === '1',
}
})
// Instant boot: the last persisted snapshot, shown before the fresh reconcile lands.
ipcMain.handle('gitea:boot', () => {
if (isDemo()) return { configured: true, cached: true, ...DEMO_SNAPSHOT, savedAt: DEMO_TODAY }
if (!getGiteaClient()) return { configured: false }
const persisted = bootSnapshot()
return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false }
})
ipcMain.handle('gitea:reconcile', async () => {
if (isDemo()) return { configured: true, stale: false, ...DEMO_SNAPSHOT }
const client = getGiteaClient()
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
try {
// explicit UI sync — force fresh, and warm the cache for agent tool calls
const snap = await getSnapshot(client, { maxAgeMs: 0 })
return { configured: true, stale: false, ...snap }
} catch (e) {
// offline / gitea down — serve the last persisted snapshot rather than error out
const persisted = bootSnapshot()
if (persisted) return { configured: true, stale: true, ...persisted }
throw e
}
}) })
ipcMain.handle('gitea:getIssue', async (_event, index: number) => { ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
const client = getGiteaClient()
if (!client) return null if (!client) return null
return client.getIssue(index) return client.getIssue(index)
}) })
// The write path (apply_changes), applied only after the renderer's propose-
// approve. Label swaps (est/p) return the plan; field writes (assign, milestone)
// return the freshly-read issue directly. Either way the snapshot is invalidated
// so the board + forecast reflect the change.
ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
const client = getGiteaClient()
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
if (isLabelChange(change)) {
const current = await client.getIssue(change.issue)
const plan = planIssueChange(current.labels, change)
if (plan.noop) return { ok: true as const, plan, issue: current }
const ids = await resolveLabelIds(client, plan.labels)
await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue)
invalidateSnapshot()
return { ok: true as const, plan, issue }
}
// 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.
// Only touches the CommiTea label namespaces — no invented labels (zero-pollution).
ipcMain.handle(
'gitea:createIssues',
async (_event, issues: { title: string; body?: string; estimate?: string; priority?: string }[]) => {
const client = getGiteaClient()
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
const created: { number: number; title: string }[] = []
for (const it of issues) {
const names = [it.estimate, it.priority].filter((n): n is string => !!n)
const labelIds = await resolveLabelIds(client, names)
const issue = await client.createIssue({ title: it.title, body: it.body, labelIds })
created.push({ number: issue.number, title: issue.title })
}
if (created.length) invalidateSnapshot() // new issues enter the board/scope
return { ok: true as const, created }
},
)
// Read the directive ledger from the pm-state repo (for the Directives screen).
ipcMain.handle('pmstate:directives', async () => {
if (isDemo()) return { ok: true as const, directives: DEMO_DIRECTIVES }
const pm = getPmStateClient()
if (!pm) return { ok: false as const, reason: 'unconfigured' as const }
try {
return { ok: true as const, directives: await readDirectives(pm) }
} catch (e) {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
}
})
// Read the capacity config from the pm-state repo (for capacity-aware forecasts).
ipcMain.handle('pmstate:capacity', async () => {
if (isDemo()) return { ok: true as const, members: DEMO_CAPACITY }
const pm = getPmStateClient()
if (!pm) return { ok: false as const, reason: 'unconfigured' as const, members: [] }
try {
return { ok: true as const, members: await readCapacity(pm) }
} catch {
return { ok: true as const, members: [] }
}
})
// Assignable people (repo collaborators) for the Adjust dialog's assignee picker.
ipcMain.handle('gitea:collaborators', async () => {
if (isDemo()) return DEMO_COLLABORATORS
const client = getGiteaClient()
if (!client) return []
try {
return await client.listCollaborators()
} catch {
return []
}
})
// ---- config (team onboarding) ----
ipcMain.handle('config:get', () => publicConfig())
ipcMain.handle('config:set', (_event, cfg: AppConfig) => {
saveConfig(cfg)
resetClients() // new config takes effect without a restart
return { ok: true as const }
})
ipcMain.handle('config:clear', () => {
clearConfig()
resetClients()
return { ok: true as const }
})
// Discover the owners + repos a token can reach, so onboarding can offer them
// as dropdowns. Token-scoped (not repo-scoped) — no owner/repo needed yet.
ipcMain.handle('config:discover', async (_event, conn: { baseUrl: string; token: string }) => {
try {
const found = await discoverRepos({ baseUrl: conn.baseUrl.replace(/\/+$/, ''), token: conn.token }, fetch)
return { ok: true as const, ...found }
} catch (e) {
return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) }
}
})
// First-run bootstrap: apply the label schema to the work repo and ensure the
// pm-state sidecar exists. Idempotent — safe to re-run. `underOrg` tells us
// whether `owner` is an org (vs the token's personal namespace).
ipcMain.handle(
'config:bootstrap',
async (_event, req: { baseUrl: string; token: string; owner: string; repo: string; underOrg: boolean }) => {
try {
const conn = { baseUrl: req.baseUrl.replace(/\/+$/, ''), token: req.token }
const owner = req.owner.trim()
const repo = req.repo.trim()
const client = createGiteaClient({ ...conn, owner, repo }, fetch)
const labels = await applySchemaLabels(client)
const pmStateRepo = `${repo}-pm-state`
const pmState = await ensurePmStateRepo(conn, { owner, repo: pmStateRepo, underOrg: req.underOrg }, fetch)
return { ok: true as const, labels, pmState, pmStateRepo }
} catch (e) {
return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) }
}
},
)
// Validate a token + repo before saving: any authed read on the repo proves access.
ipcMain.handle('config:test', async (_event, cfg: AppConfig) => {
try {
const c = createGiteaClient(
{ baseUrl: cfg.baseUrl.replace(/\/+$/, ''), owner: cfg.owner.trim(), repo: cfg.repo.trim(), token: cfg.token },
fetch,
)
await c.listLabels()
return { ok: true as const }
} catch (e) {
return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) }
}
})
} }

View File

@@ -2,11 +2,7 @@ import { join } from 'node:path'
import { BrowserWindow, app, shell } from 'electron' import { BrowserWindow, app, shell } from 'electron'
// Test hook: isolate config/cache to a throwaway dir (must run before any getPath).
if (process.env.COMMITEA_USERDATA) app.setPath('userData', process.env.COMMITEA_USERDATA)
import { registerGiteaIpc } from './gitea.js' import { registerGiteaIpc } from './gitea.js'
import { registerModelIpc } from './model.js'
function createWindow(): void { function createWindow(): void {
const win = new BrowserWindow({ const win = new BrowserWindow({
@@ -39,7 +35,6 @@ function createWindow(): void {
void app.whenReady().then(() => { void app.whenReady().then(() => {
registerGiteaIpc() registerGiteaIpc()
registerModelIpc()
createWindow() createWindow()
app.on('activate', () => { app.on('activate', () => {

View File

@@ -1,189 +0,0 @@
/**
* Main-process model bridge — Reginald's brain runs here. Model traffic (like
* gitea's) stays in main: the renderer is CSP-locked and never talks to the
* LLM directly. On `model:chat` it drives the agent loop against the configured
* OpenAI-compatible endpoint, executing `query_project` by reconciling the repo
* and building the requested view. v0 is read-only — writes still go through the
* propose-approve controls.
*/
import {
buildProjectView,
captureWork,
type ChangeProposal,
type ChatMessage,
createChatClient,
type ModelRouter,
type ProjectView,
proposalsFor,
type ProposeChangeArgs,
type QueryFilters,
REGINALD_SYSTEM,
REGINALD_TOOLS,
runAgentTurn,
toDirectiveInput,
} from '@commitea/core'
import { app, ipcMain } from 'electron'
import { loadConfig } from './config-store.js'
import {
AGENT_SNAPSHOT_TTL_MS,
appendDirectiveEntry,
getGiteaClient,
getPmStateClient,
getSnapshot,
} from './gitea.js'
/** Small local model for prose + the read tool; big model reserved for later decomposition. */
function resolveModelRouter(): ModelRouter | null {
if (process.env.COMMITEA_E2E === '1') return null // e2e uses the scripted fixture Reginald
// Config wins (team build); env is the dev override; localhost is a dev convenience only.
const baseUrl =
loadConfig()?.modelUrl ??
process.env.COMMITEA_MODEL_URL ??
(app.isPackaged ? undefined : 'http://localhost:1234/v1')
if (!baseUrl) return null
return {
small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' },
big: { baseUrl, model: process.env.COMMITEA_MODEL_BIG ?? '' },
}
}
/** Probe the endpoint for a usable model; null when unreachable (chat then stays off). */
async function probeModel(baseUrl: string): Promise<string | null> {
try {
const model = await resolveLoadedModel(baseUrl, process.env.COMMITEA_MODEL_SMALL ?? '')
// resolveLoadedModel only returns a real id when the server answered; the default
// fallback means unreachable, so confirm with a lightweight models call.
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`)
return res.ok ? model : null
} catch {
return null
}
}
/**
* Resolve which model to actually ask for. An explicit env override wins;
* otherwise ask the server which model is *loaded* (LM Studio's native
* `/api/v0/models`) so Reginald follows whatever you load — no config churn on a
* model switch. Falls back to the first non-embedding model, then a sane default.
*/
async function resolveLoadedModel(baseUrl: string, override: string): Promise<string> {
if (override) return override
const root = baseUrl.replace(/\/v1\/?$/, '')
try {
const res = await fetch(`${root}/api/v0/models`)
if (res.ok) {
const data = (await res.json()) as { data?: { id: string; state?: string; type?: string }[] }
const loaded = (data.data ?? []).find((m) => m.state === 'loaded' && m.type !== 'embeddings')
if (loaded) return loaded.id
}
} catch {
// native API unavailable — fall through to the OpenAI listing
}
try {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`)
if (res.ok) {
const data = (await res.json()) as { data?: { id: string }[] }
const first = (data.data ?? []).find((m) => !/embed/i.test(m.id))
if (first) return first.id
}
} catch {
// ignore — use the default
}
return 'google/gemma-4-e4b'
}
export function registerModelIpc(): void {
ipcMain.handle('model:status', async () => {
const r = resolveModelRouter() // resolve fresh so a saved modelUrl takes effect
if (!r) return { configured: false, model: null }
const model = await probeModel(r.small.baseUrl) // only "configured" if a model actually answers
return { configured: !!model, model }
})
ipcMain.handle('model:chat', async (event, messages: ChatMessage[]) => {
const router = resolveModelRouter()
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
const client = getGiteaClient()
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
const chat = createChatClient({ ...router.small, model }, fetch)
// stream the model's prose to the renderer token-by-token
const onToken = (delta: string) => {
if (!event.sender.isDestroyed()) event.sender.send('model:chat:token', delta)
}
// Proposals the model formulates this turn; the renderer approves them (the
// write happens through gitea:applyChange, never inside the loop).
const proposals: ChangeProposal[] = []
const execute = async (name: string, args: unknown) => {
if (!client) return { error: 'gitea is not configured' }
if (name === 'query_project') {
// reuse a recent reconcile — a multi-tool turn shouldn't refetch the repo each call
const snap = await getSnapshot(client, { maxAgeMs: AGENT_SNAPSHOT_TTL_MS })
const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters }
return buildProjectView(a.view, a.filters, snap, new Date())
}
if (name === 'propose_change') {
const a = (args ?? {}) as ProposeChangeArgs
const issue = await client.getIssue(a.issue).catch(() => null)
if (!issue) return { error: `issue #${a.issue} not found` }
// Milestone titles + current assignee/milestone let a proposal label the
// milestone and skip a no-op assign/move.
const milestones = (a.milestone !== undefined ? await client.listMilestones().catch(() => []) : []).map(
(m) => ({ id: m.id, title: m.title }),
)
const built = proposalsFor(a, issue.labels, issue.title, {
currentAssignee: issue.assignee,
currentMilestoneId: issue.milestone?.id ?? null,
milestones,
})
proposals.push(...built)
return built.length
? { proposed: built.map((p) => ({ issue: a.issue, diff: p.summary })) }
: { proposed: [], note: 'no change — already at that value' }
}
if (name === 'record_directive') {
const pm = getPmStateClient()
if (!pm) return { error: 'pm-state is not configured' }
try {
const entry = await appendDirectiveEntry(pm, toDirectiveInput(args))
return { recorded: { kind: entry.kind, quote: entry.quote } }
} catch (e) {
return { error: `could not record — is the pm-state repo created? (${e instanceof Error ? e.message : e})` }
}
}
return { error: `unknown tool: ${name}` }
}
try {
const turn = await runAgentTurn({
complete: (m, t, ot) => chat.complete(m, t, ot),
messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages],
tools: REGINALD_TOOLS,
execute,
onToken,
})
return { ok: true as const, content: turn.content, steps: turn.steps, proposals }
} catch (e) {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
}
})
// capture_work — braindump → proposed issue set. The big model does the
// decomposition (with one loaded local model, that's the loaded one). Returns
// a proposal; nothing is filed until the Capture tray approves it.
ipcMain.handle('model:capture', async (_event, braindump: string) => {
const router = resolveModelRouter()
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
const model = await resolveLoadedModel(router.big.baseUrl, router.big.model)
const chat = createChatClient({ ...router.big, model }, fetch)
try {
const proposal = await captureWork((m, t) => chat.complete(m, t), braindump)
return { ok: true as const, ...proposal }
} catch (e) {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
}
})
}

View File

@@ -1,86 +0,0 @@
/**
* Purity / rebuild guarantee for the *shipped* durable cache (#30, D4). The app's
* on-disk reconcile cache is this JSON snapshot-store. The invariant: delete it
* and lose nothing — the durable truth is in gitea, the file is only a boot/offline
* mirror. This test deletes the real file and asserts the store degrades to
* "no cache" (null), which is what forces the next `getSnapshot` to reconcile
* fresh from gitea rather than serve stale or missing data.
*/
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
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 { loadSnapshot, type PersistedSnapshot, saveSnapshot } from './snapshot-store.js'
const SNAP: Omit<PersistedSnapshot, 'savedAt'> = {
issues: [{ number: 1, title: 'An issue', state: 'open', labels: ['est/5d', 'p/1'] }],
milestones: [{ id: 7, title: 'P2' }],
deps: [{ issue: 1, dependsOn: 2 }],
timelines: { 1: [{ type: 'opened', at: '2026-07-01T00:00:00Z' }] },
}
describe('snapshot-store purity (#30)', () => {
let dir: string | null = null
const path = () => {
if (!dir) dir = mkdtempSync(join(tmpdir(), 'commitea-snap-'))
return join(dir, 'commitea-snapshot.json')
}
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true })
dir = null
})
it('round-trips a saved snapshot', () => {
const p = path()
saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p)
const back = loadSnapshot(p)
expect(back).toEqual({ ...SNAP, savedAt: '2026-07-09T00:00:00Z' })
})
it('delete the cache file → load returns null (degrades to no-cache, not a crash)', () => {
const p = path()
saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p)
expect(loadSnapshot(p)).not.toBeNull()
rmSync(p) // delete the durable cache
expect(existsSync(p)).toBe(false)
// The store must NOT throw and must report "no cache" so the next reconcile
// rebuilds from gitea. If this ever returned stale data or threw, D4 breaks.
expect(loadSnapshot(p)).toBeNull()
})
it('a corrupt / partial file is treated as no-cache, never a crash', () => {
const p = path()
writeFileSync(p, '{ this is not json', 'utf8')
expect(loadSnapshot(p)).toBeNull()
// valid JSON but wrong shape (no issues array) is also rejected
writeFileSync(p, JSON.stringify({ savedAt: 'x' }), 'utf8')
expect(loadSnapshot(p)).toBeNull()
})
it('re-saving after a delete restores the cache — the rebuild is lossless', () => {
const p = path()
saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p)
const before = loadSnapshot(p)
rmSync(p)
expect(loadSnapshot(p)).toBeNull()
// a resync would call saveSnapshot again with the freshly reconciled data
saveSnapshot(SNAP, '2026-07-09T01:00:00Z', p)
const after = loadSnapshot(p)
// same durable payload, only the savedAt marker differs
expect(after!.issues).toEqual(before!.issues)
expect(after!.milestones).toEqual(before!.milestones)
expect(after!.deps).toEqual(before!.deps)
expect(after!.timelines).toEqual(before!.timelines)
})
})

View File

@@ -1,52 +0,0 @@
/**
* Durable snapshot store — the reconcile cache, persisted to disk. On boot the
* app shows the last snapshot instantly (stale-while-revalidate) instead of a
* blank board while ~2N gitea calls run; if gitea is unreachable, reads fall
* back to it (offline). It's a rebuildable mirror — the durable truth stays in
* gitea (the purity split, D4). A plain JSON file: the whole snapshot fits in
* memory at this scale, so indexed SQL buys nothing yet (see the PR).
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'
/** The shape we persist — kept loose so a schema drift degrades to "no cache", not a crash. */
export interface PersistedSnapshot {
issues: unknown[]
milestones: unknown[]
deps: unknown[]
timelines: Record<number, unknown[]>
/** ISO time the snapshot was reconciled — shown as "cached since". */
savedAt: string
}
function snapshotPath(): string {
return join(app.getPath('userData'), 'commitea-snapshot.json')
}
/**
* Load the last persisted snapshot, or null if absent/corrupt. Never throws — a
* deleted or unreadable cache degrades to "no cache" (the purity guarantee, D4:
* the next reconcile rebuilds it from gitea). `path` is injectable for tests;
* production always uses the userData file.
*/
export function loadSnapshot(path: string = snapshotPath()): PersistedSnapshot | null {
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as PersistedSnapshot
if (parsed && Array.isArray(parsed.issues)) return parsed
return null
} catch {
return null // missing file, bad JSON, or drift — treat as no cache
}
}
/** Persist a freshly reconciled snapshot. Best-effort — a write failure never breaks a reconcile. */
export function saveSnapshot(snap: Omit<PersistedSnapshot, 'savedAt'>, savedAt: string, path: string = snapshotPath()): void {
try {
writeFileSync(path, JSON.stringify({ ...snap, savedAt }), 'utf8')
} catch {
// disk full / permissions — the in-memory cache still works this session
}
}

View File

@@ -5,52 +5,10 @@ const api = {
gitea: { gitea: {
/** Whether the main process has a gitea token + target repo configured. */ /** Whether the main process has a gitea token + target repo configured. */
status: () => ipcRenderer.invoke('gitea:status'), status: () => ipcRenderer.invoke('gitea:status'),
/** The last persisted snapshot, for instant boot before the fresh reconcile. */
boot: () => ipcRenderer.invoke('gitea:boot'),
/** Full read of the managed repo — every issue + milestone. */ /** Full read of the managed repo — every issue + milestone. */
reconcile: () => ipcRenderer.invoke('gitea:reconcile'), reconcile: () => ipcRenderer.invoke('gitea:reconcile'),
/** One issue by index, normalized (or null if unconfigured). */ /** One issue by index, normalized (or null if unconfigured). */
getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index), getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index),
/** Apply an estimate/priority change (the write path); resolves to the plan + fresh issue. */
applyChange: (change: unknown) => ipcRenderer.invoke('gitea:applyChange', change),
/** File a set of captured issues with their est/* + p/* labels. */
createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues),
/** Assignable people (repo collaborators) for the assignee picker. */
collaborators: () => ipcRenderer.invoke('gitea:collaborators'),
},
pmstate: {
/** Read the directive ledger from the pm-state repo. */
directives: () => ipcRenderer.invoke('pmstate:directives'),
/** Read the capacity config from the pm-state repo. */
capacity: () => ipcRenderer.invoke('pmstate:capacity'),
},
config: {
/** The saved config (never the token) — null when unset. */
get: () => ipcRenderer.invoke('config:get'),
/** Discover the owners + repos a token can reach (for the onboarding pickers). */
discover: (conn: unknown) => ipcRenderer.invoke('config:discover', conn),
/** Apply the label schema + ensure the pm-state repo (first-run bootstrap). */
bootstrap: (req: unknown) => ipcRenderer.invoke('config:bootstrap', req),
/** Validate a token + repo before saving. */
test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg),
/** Save config (token encrypted in main); takes effect without restart. */
set: (cfg: unknown) => ipcRenderer.invoke('config:set', cfg),
/** Forget the saved config. */
clear: () => ipcRenderer.invoke('config:clear'),
},
model: {
/** Whether a model endpoint is configured (else the UI keeps the scripted Reginald). */
status: () => ipcRenderer.invoke('model:status'),
/** One agent turn: messages in, Reginald's prose + the tools it consulted out. */
chat: (messages: unknown) => ipcRenderer.invoke('model:chat', messages),
/** Subscribe to streamed prose tokens for the in-flight turn; returns an unsubscribe. */
onToken: (cb: (delta: string) => void) => {
const listener = (_e: unknown, delta: string) => cb(delta)
ipcRenderer.on('model:chat:token', listener)
return () => ipcRenderer.removeListener('model:chat:token', listener)
},
/** Decompose a braindump into a proposed issue set (capture_work). */
capture: (braindump: string) => ipcRenderer.invoke('model:capture', braindump),
}, },
} }

View File

@@ -0,0 +1,211 @@
import React, { useState } from 'react'
import {
Badge,
Button,
Card,
Checkbox,
Dialog,
Icon,
IconButton,
Input,
Radio,
Select,
Switch,
Tabs,
Tag,
Toast,
Tooltip,
} from './ui/index.js'
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="mt-9">
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-ink-3">{title}</h2>
<div className="flex flex-wrap items-start gap-3">{children}</div>
</section>
)
}
/**
* Visual + behavioral proof for the ported primitives (P3-1). Not a product
* screen — the real shell lands in P3-2. Exercises every primitive in both
* themes via the toggle.
*/
export function PrimitivesGallery() {
const [tab, setTab] = useState('board')
const [dialogOpen, setDialogOpen] = useState(false)
const [checked, setChecked] = useState(true)
const [radio, setRadio] = useState('a')
const [on, setOn] = useState(true)
return (
<main className="bg-paper-0 font-sans text-ink-1">
<div className="mx-auto max-w-[1120px]">
<header className="border-b-[3px] border-double border-ink-1 pb-4">
<h1 className="font-display text-4xl">Primitives</h1>
<p className="mt-1 font-mono text-sm text-ink-3">
15 components · toggle Evening service in the rail for dark
</p>
</header>
<Section title="Button">
<Button variant="primary" icon="sparkles">
Brew plan
</Button>
<Button variant="secondary">Amend</Button>
<Button variant="ghost" icon="clock">
Defer
</Button>
<Button variant="danger" icon="trash-2">
Withdraw
</Button>
<Button variant="primary" size="sm" iconRight="arrow-right">
Next
</Button>
<Button variant="secondary" disabled>
Disabled
</Button>
</Section>
<Section title="IconButton">
<IconButton icon="pencil" label="Edit" />
<IconButton icon="copy" label="Copy" />
<IconButton icon="ellipsis" label="More" variant="outline" />
<IconButton icon="x" label="Close" size="sm" />
</Section>
<Section title="Badge">
<Badge tone="ok" dot>
ahead
</Badge>
<Badge tone="warn" dot>
at risk
</Badge>
<Badge tone="danger" dot>
behind
</Badge>
<Badge tone="info">steeping</Badge>
<Badge tone="neutral">triage</Badge>
<Badge tone="jade">on track</Badge>
</Section>
<Section title="Tag">
<Tag label="est/3d" />
<Tag label="est/8d" />
<Tag label="p/1" />
<Tag label="p/2" />
<Tag label="p/3" />
<Tag label="p/4" />
<Tag label="deadline/hard" />
<Tag label="p/1" onRemove={() => {}} />
</Section>
<Section title="Card">
<Card
overline="Now"
title="Gitea read client"
jade
actions={<IconButton icon="external-link" label="Open" size="sm" />}
footer={
<>
<Button size="sm" variant="primary">
Start
</Button>
<Button size="sm" variant="ghost">
Defer
</Button>
</>
}
style={{ width: 340 }}
>
<p className="font-agent text-base leading-relaxed">
The tap-root. Everything sinks into it grab it first.
</p>
</Card>
<Card title="Plain card" style={{ width: 260 }}>
<p className="text-sm text-ink-2">Hairline border, whispered shadow.</p>
</Card>
</Section>
<Section title="Tabs">
<Tabs
items={[
{ id: 'board', label: 'Board', icon: 'square-kanban', count: 34 },
{ id: 'gantt', label: 'Gantt', icon: 'chart-no-axes-gantt' },
{ id: 'deps', label: 'Dependencies', icon: 'network', count: 51 },
]}
active={tab}
onChange={setTab}
/>
</Section>
<Section title="Icon">
<Icon name="coffee" size={20} />
<Icon name="leaf" size={20} />
<Icon name="git-branch" size={20} />
<Icon name="milestone" size={20} />
<Icon name="sparkles" size={20} />
</Section>
<Section title="Input & Select">
<Input label="Repository" placeholder="christian/commitea" style={{ width: 240 }} />
<Input label="Search" icon="search" placeholder="Filter issues" style={{ width: 220 }} />
<Input label="Token" mono error="Connection failed" placeholder="tok_xxx" style={{ width: 220 }} />
<Select
label="Priority"
options={[
{ value: 'p/1', label: 'p/1 — urgent' },
{ value: 'p/2', label: 'p/2' },
{ value: 'p/3', label: 'p/3' },
]}
style={{ width: 200 }}
/>
</Section>
<Section title="Checkbox · Radio · Switch">
<Checkbox label="Hard deadline" checked={checked} onChange={(e) => setChecked(e.target.checked)} />
<Checkbox label="Disabled" checked disabled onChange={() => {}} />
<Radio label="Option A" name="g" value="a" checked={radio === 'a'} onChange={() => setRadio('a')} />
<Radio label="Option B" name="g" value="b" checked={radio === 'b'} onChange={() => setRadio('b')} />
<Switch label="Live sync" checked={on} onChange={(e) => setOn(e.target.checked)} />
</Section>
<Section title="Feedback">
<Toast tone="ok" title="Plan brewed" onDismiss={() => {}}>
Milestone P2 lands Mar 312.
</Toast>
<Toast tone="warn" title="Stale blocker">
#9 has waited 6 days.
</Toast>
<Tooltip content="Writes to gitea, as you">
<Button variant="secondary" icon="info">
Hover me
</Button>
</Tooltip>
<Button variant="primary" onClick={() => setDialogOpen(true)}>
Open dialog
</Button>
</Section>
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="Withdraw directive?"
footer={
<>
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button variant="danger" onClick={() => setDialogOpen(false)}>
Withdraw
</Button>
</>
}
>
<p>This removes the directive from the ledger. The schedule reverts to its prior state.</p>
</Dialog>
</div>
</main>
)
}

View File

@@ -1,35 +1,29 @@
import React from 'react' import React from 'react'
import { type BoardColumn, type BoardIssue, type DepsData, type GanttData, type IssueRef } from '../../data/view-types.js' import { COLUMNS, type BoardColumn, type BoardIssue, type IssueRef } from '../../data/fixtures.js'
import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js' import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js'
import { EmptyState } from '../shell/states.js' import { EmptyState } from '../shell/states.js'
import { GanttView } from './gantt-view.js' import { GanttView } from './gantt-view.js'
import { DepsGraph } from './deps-graph.js' import { DepsGraph } from './deps-graph.js'
// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs. // Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs.
// `columns` arrives from the shell as real reconciled data when gitea is // `columns` defaults to demo fixtures; the shell passes real reconciled data
// configured. `loading` covers the first reconcile. // when gitea is configured. `loading` covers the first reconcile.
export function BoardScreen({ export function BoardScreen({
onOpenIssue, onOpenIssue,
columns, columns = COLUMNS,
gantt,
deps,
loading = false, loading = false,
}: { }: {
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
columns?: BoardColumn[] columns?: BoardColumn[]
gantt?: GanttData
deps?: DepsData
loading?: boolean loading?: boolean
}) { }) {
const [tab, setTab] = React.useState('board'); const [tab, setTab] = React.useState('board');
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const cols = columns ?? [];
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const filtered = cols.map((c) => ({ ...c, issues: q ? c.issues.filter((i) => (i.title + ' #' + i.id).toLowerCase().includes(q)) : c.issues })); const filtered = columns.map((c) => ({ ...c, issues: q ? c.issues.filter((i) => (i.title + ' #' + i.id).toLowerCase().includes(q)) : c.issues }));
const anyMatch = filtered.some((c) => c.issues.length > 0); const anyMatch = filtered.some((c) => c.issues.length > 0);
const openCount = cols.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0); const openCount = columns.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0);
const totalIssues = cols.reduce((n, c) => n + c.issues.length, 0);
const IssueCard = ({ issue }: { issue: BoardIssue }) => ( const IssueCard = ({ issue }: { issue: BoardIssue }) => (
<div <div
@@ -77,10 +71,6 @@ export function BoardScreen({
<Icon name="loader-circle" size={16} /> <Icon name="loader-circle" size={16} />
<span style={{ font: 'var(--text-small)' }}>Reconciling with gitea</span> <span style={{ font: 'var(--text-small)' }}>Reconciling with gitea</span>
</div> </div>
) : totalIssues === 0 ? (
<div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}>
<EmptyState icon="inbox" title="The pot is empty" line="Nothing in the backlog yet." />
</div>
) : anyMatch ? ( ) : anyMatch ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, alignItems: 'start', flex: 1, minHeight: 0, overflow: 'auto' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, alignItems: 'start', flex: 1, minHeight: 0, overflow: 'auto' }}>
{filtered.map((col) => ( {filtered.map((col) => (
@@ -96,13 +86,13 @@ export function BoardScreen({
) : ( ) : (
<div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}> <div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}>
<EmptyState icon="search" title="Nothing by that name" <EmptyState icon="search" title="Nothing by that name"
line={`The pot holds ${totalIssues} issues; none of them answer to “${query.trim()}”.`} /> line={`The pot holds ${columns.reduce((n, c) => n + c.issues.length, 0)} issues; none of them answer to “${query.trim()}”.`} />
</div> </div>
) )
) : tab === 'deps' ? ( ) : tab === 'deps' ? (
<DepsGraph onOpenIssue={onOpenIssue} data={deps} /> <DepsGraph onOpenIssue={onOpenIssue} />
) : ( ) : (
<GanttView onOpenIssue={onOpenIssue} data={gantt} /> <GanttView onOpenIssue={onOpenIssue} />
)} )}
</div> </div>
); );

View File

@@ -1,24 +1,11 @@
import React from 'react' import React from 'react'
import { type CalibrationData } from '../../data/view-types.js' import { CALIBRATION } from '../../data/fixtures.js'
import { EmptyState } from '../shell/states.js'
import { Badge, Card, Icon } from '../ui/index.js' import { Badge, Card, Icon } from '../ui/index.js'
// Calibration report — estimate-vs-actual evidence behind the cones. // Calibration report — estimate-vs-actual evidence behind the cones
// `data` (real fit from closed-issue actuals) drives the whole view. export function CalibrationScreen({ onBack }: { onBack: () => void }) {
export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?: CalibrationData }) { const c = CALIBRATION
if (!data) {
return (
<EmptyState
icon="ruler"
title="Not enough history"
line="Close a few issues and I'll calibrate to your team's actuals."
action="Runway"
onAction={onBack}
/>
)
}
const c = data
// scatter chart geometry // scatter chart geometry
const W = 420, const W = 420,
@@ -81,11 +68,7 @@ export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?:
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Calibration</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Calibration</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>{c.n} closed issues with estimates · evidence, not opinion</p> <p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>{c.n} closed issues with estimates · evidence, not opinion</p>
</div> </div>
{c.active ? (
<Badge tone="ok" dot>curve active · n 20</Badge> <Badge tone="ok" dot>curve active · n 20</Badge>
) : (
<Badge tone="warn" dot>cold-start · {c.n}/20</Badge>
)}
</header> </header>
</div> </div>
@@ -187,12 +170,7 @@ export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?:
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.effect.banded}</span> <span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.effect.banded}</span>
</div> </div>
<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 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.'}
{!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.`
: ''}
</p> </p>
</Card> </Card>
</div> </div>

View File

@@ -1,12 +1,8 @@
import React from 'react' import React from 'react'
import type { ProposedIssue } from '@commitea/core'
import { Badge, Button, Card, Icon, Select, Tag } from '../ui/index.js' import { Badge, Button, Card, Icon, Select, Tag } from '../ui/index.js'
// Capture interview — braindump → interview → approved ticket set (< 2 min). // Capture interview — braindump → interview → approved ticket set (< 2 min)
// With a model configured, "Brew tickets" runs real capture_work decomposition
// and "Approve all" files the issues in gitea; otherwise the scripted demo runs.
interface Ticket { interface Ticket {
title: string title: string
@@ -34,64 +30,6 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
const [webEst, setWebEst] = React.useState<string | null>(null) const [webEst, setWebEst] = React.useState<string | null>(null)
const [secs, setSecs] = React.useState(0) const [secs, setSecs] = React.useState(0)
// live capture_work path (real model + gitea writes)
const [live, setLive] = React.useState(false)
const [brewing, setBrewing] = React.useState(false)
const [liveTickets, setLiveTickets] = React.useState<ProposedIssue[] | null>(null)
const [filedCount, setFiledCount] = React.useState(0)
const [error, setError] = React.useState<string | null>(null)
React.useEffect(() => {
let alive = true
window.commitea.model.status().then((s) => { if (alive) setLive(s.configured) }).catch(() => {})
return () => { alive = false }
}, [])
const editTicket = (i: number, field: 'estimate' | 'priority', value: string) =>
setLiveTickets((ts) => (ts ? ts.map((t, j) => (j === i ? { ...t, [field]: value } : t)) : ts))
// Decide live-vs-scripted at click time: re-check status if it hasn't resolved
// yet, so a configured model never falls into the scripted interview by a race.
const onBrew = async () => {
const configured = live || (await window.commitea.model.status().then((s) => s.configured).catch(() => false))
if (configured) {
setLive(true)
brew()
} else {
setStage('interview')
}
}
const brew = () => {
setError(null)
setBrewing(true)
window.commitea.model
.capture(dump)
.then((res) => {
setBrewing(false)
if (res.ok && res.issues.length) {
setLiveTickets(res.issues)
setStage('review')
} 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'}`)
}
})
.catch(() => { setBrewing(false); setError('I could not reach the model.') })
}
const fileLive = () => {
if (!liveTickets) return
setBrewing(true)
window.commitea.gitea
.createIssues(liveTickets)
.then((res) => {
setBrewing(false)
if (res.ok) { setFiledCount(res.created.length); setStage('filed') }
else setError('Filing failed — gitea is not configured.')
})
.catch(() => { setBrewing(false); setError('Filing failed.') })
}
const running = stage === 'interview' || stage === 'review' const running = stage === 'interview' || stage === 'review'
React.useEffect(() => { React.useEffect(() => {
if (!running) return if (!running) return
@@ -125,23 +63,19 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
else setStage('review') else setStage('review')
} }
// draft tickets build as the interview progresses (scripted demo path) // draft tickets build as the interview progresses
const scripted: Ticket[] = [] const tickets: Ticket[] = []
if (split === true) { if (split === true) {
scripted.push({ title: 'Token refresh: retry with backoff', est: 'est/2d', p: 'p/2' }) tickets.push({ title: 'Token refresh: retry with backoff', est: 'est/2d', p: 'p/2' })
scripted.push({ title: 'Session storage: stale reads on wake', est: 'est/1d', p: 'p/3' }) tickets.push({ title: 'Session storage: stale reads on wake', est: 'est/1d', p: 'p/3' })
} else if (split === false) { } else if (split === false) {
scripted.push({ title: 'Auth: token refresh + session storage', est: 'est/3d', p: 'p/2' }) tickets.push({ title: 'Auth: token refresh + session storage', est: 'est/3d', p: 'p/2' })
} }
if (webEst) scripted.push({ title: 'Webhook debounce: double-fire guard', est: webEst, p: 'p/1', dep: 'blocked by auth work' }) if (webEst) tickets.push({ title: 'Webhook debounce: double-fire guard', est: webEst, p: 'p/1', dep: 'blocked by auth work' })
if (!live && (stage === 'review' || stage === 'filed')) { if (stage === 'review' || stage === 'filed') {
scripted.push({ title: 'Docs: auth setup guide', est: 'est/1d', p: 'p/4', byReginald: true }) tickets.push({ title: 'Docs: auth setup guide', est: 'est/1d', p: 'p/4', byReginald: true })
} }
// real capture_work output overrides the scripted set when present const totalDays = tickets.reduce((n, t) => n + parseInt(t.est.replace('est/', '')), 0)
const tickets: Ticket[] = liveTickets
? liveTickets.map((i) => ({ title: i.title, est: i.estimate ?? 'est/2d', p: i.priority ?? 'p/3' }))
: scripted
const totalDays = tickets.reduce((n, t) => n + parseInt(t.est.replace('est/', ''), 10), 0)
const estOptions = ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((v) => ({ value: v, label: v })) const estOptions = ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((v) => ({ value: v, label: v }))
const pOptions = ['p/1', 'p/2', 'p/3', 'p/4'].map((v) => ({ value: v, label: v })) const pOptions = ['p/1', 'p/2', 'p/3', 'p/4'].map((v) => ({ value: v, label: v }))
@@ -162,20 +96,8 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{editable ? ( {editable ? (
<> <>
<Select <Select options={estOptions} defaultValue={t.est} style={{ width: 96 }} />
options={estOptions} <Select options={pOptions} defaultValue={t.p} style={{ width: 76 }} />
value={liveTickets ? t.est : undefined}
defaultValue={liveTickets ? undefined : t.est}
onChange={(e) => editTicket(i, 'estimate', e.target.value)}
style={{ width: 96 }}
/>
<Select
options={pOptions}
value={liveTickets ? t.p : undefined}
defaultValue={liveTickets ? undefined : t.p}
onChange={(e) => editTicket(i, 'priority', e.target.value)}
style={{ width: 76 }}
/>
</> </>
) : ( ) : (
<> <>
@@ -222,12 +144,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
<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 welcome. I'll sort it into tickets and only ask what I can't infer.
</p> </p>
<Button icon="sparkles" onClick={() => void onBrew()} disabled={brewing}> <Button icon="sparkles" onClick={() => setStage('interview')}>Brew tickets</Button>
{brewing ? 'Brewing…' : 'Brew tickets'}
</Button>
{error ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--danger)', margin: '10px 0 0' }}>{error}</p>
) : null}
</Card> </Card>
) : null} ) : null}
@@ -257,26 +174,15 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.1fr', gap: 14, alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1.1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Consequence" title={`${tickets.length} tickets · ~${totalDays}d of work`} jade <Card overline="Consequence" title={`${tickets.length} tickets · ~${totalDays}d of work`} jade
footer={<> footer={<>
<Button onClick={() => (live ? fileLive() : setStage('filed'))} disabled={brewing}> <Button onClick={() => setStage('filed')}>Approve all</Button>
{brewing ? 'Filing…' : 'Approve all'} <Button variant="ghost" onClick={() => { setStage('dump'); setQi(0); setLog([]); setSplit(null); setWebEst(null); setSecs(0); }}>Discard</Button>
</Button>
<Button variant="ghost" onClick={() => { setStage('dump'); setQi(0); setLog([]); setSplit(null); setWebEst(null); setSecs(0); setLiveTickets(null); setError(null); }}>Discard</Button>
</>}> </>}>
{live ? (
<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.
Adjust the labels, then approve I'll open them in gitea with only est/* and p/* labels.
</p>
) : (
<>
<p style={{ font: 'var(--text-body)', margin: '0 0 8px' }}> <p style={{ font: 'var(--text-body)', margin: '0 0 8px' }}>
Beta's 80% window moves <span style={{ font: 'var(--text-data)' }}>Mar 312 → Mar 514</span>. Capacity absorbs the rest. Beta's 80% window moves <span style={{ font: 'var(--text-data)' }}>Mar 312 → Mar 514</span>. Capacity absorbs the rest.
</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 }}>
I added the docs ticket you mentioned and wired the dependency. Shall I make it so? I added the docs ticket you mentioned and wired the dependency. Shall I make it so?
</p> </p>
</>
)}
</Card> </Card>
<Tray editable /> <Tray editable />
</div> </div>
@@ -289,7 +195,7 @@ 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. {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} under budget. No bot comments, no synthetic issues; your repo remains yours.

View File

@@ -1,187 +0,0 @@
import React from 'react'
import type { DiscoverResult, PublicConfig } from '../../global.js'
import logoIcon from '../../design/assets/logo-icon.png'
import { Button, Card, Input, Select } from '../ui/index.js'
/**
* Real onboarding — each teammate connects their own Gitea with a personal access
* token. The flow is progressive: enter the instance URL + PAT, and the app
* discovers every owner + repo that token can reach, then you pick them from
* dropdowns (no guessing owner/repo strings). Discovery also proves the token
* works, so any repo you can pick is one you can access. The token is saved
* encrypted in the main process (never exposed to this renderer). Optional model
* URL wires Reginald; leave it blank and chat simply stays off.
*/
export function ConnectScreen({ onConnected, existing }: { onConnected: () => void; existing?: PublicConfig | null }) {
const [baseUrl, setBaseUrl] = React.useState(existing?.baseUrl ?? 'https://gitea.stephenmann.io')
const [token, setToken] = React.useState('')
const [modelUrl, setModelUrl] = React.useState(existing?.modelUrl ?? '')
const [owner, setOwner] = React.useState(existing?.owner ?? '')
const [repo, setRepo] = React.useState(existing?.repo ?? '')
const [discovery, setDiscovery] = React.useState<'idle' | 'discovering' | 'done'>('idle')
const [found, setFound] = React.useState<{ owners: string[]; reposByOwner: Record<string, string[]> } | null>(null)
const [error, setError] = React.useState<string | null>(null)
const [state, setState] = React.useState<'idle' | 'saving'>('idle')
const mapError = (e?: string) =>
e === '401' || e === '403'
? 'The token was rejected — check it has repo + issue scopes.'
: e === '404'
? "Couldn't reach that Gitea — check the URL."
: `Discovery failed${e ? ` (${e})` : ''}.`
// Discover the owners + repos this token can see. Triggered when the token
// field blurs (or on Enter), once a URL + token are both present.
const discover = async () => {
if (!baseUrl.trim() || !token.trim() || discovery === 'discovering') return
setError(null)
setDiscovery('discovering')
const res: DiscoverResult = await window.commitea.config
.discover({ baseUrl: baseUrl.trim(), token: token.trim() })
.catch(() => ({ ok: false as const, error: 'unreachable' }))
if (!res.ok) {
setDiscovery('idle')
setFound(null)
setError(mapError(res.error))
return
}
setFound({ owners: res.owners, reposByOwner: res.reposByOwner })
setDiscovery('done')
// keep the existing selection if it's still valid; else default to the token
// owner (res.user) and its first repo.
const nextOwner = res.owners.includes(owner) ? owner : (res.owners.includes(res.user) ? res.user : res.owners[0] ?? '')
setOwner(nextOwner)
const repos = res.reposByOwner[nextOwner] ?? []
setRepo(repos.includes(repo) ? repo : repos[0] ?? '')
}
// Option lists. Before discovery, fall back to the existing config so editing
// stays functional without re-entering the token.
const ownerOptions = found?.owners ?? (existing?.owner ? [existing.owner] : [])
const repoOptions = found?.reposByOwner[owner] ?? (owner && owner === existing?.owner && existing?.repo ? [existing.repo] : [])
const pickerReady = discovery === 'done' || (!!existing && !token.trim())
const ready = baseUrl.trim() && owner && repo && (token.trim() || existing?.hasToken)
const connect = async () => {
setError(null)
setState('saving')
// discovery already validated the token + that this repo is accessible; when
// editing without a new token, reuse the saved one (empty token = keep).
await window.commitea.config.set({ baseUrl: baseUrl.trim(), owner, repo, token: token.trim(), modelUrl: modelUrl.trim() || undefined })
onConnected()
}
const placeholder = (label: string) => [{ value: '', label }]
return (
<div
style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--surface-app)',
padding: 24,
}}
>
<Card style={{ width: 460, maxWidth: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<img src={logoIcon} width="28" height="28" alt="" style={{ borderRadius: 7, display: 'block' }} />
<span style={{ font: '400 24px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
Commi<span style={{ color: 'var(--accent-text)' }}>Tea</span>
</span>
</div>
<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.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Input
label="Gitea URL"
value={baseUrl}
placeholder="https://gitea.example.com"
onChange={(e) => {
setBaseUrl(e.target.value)
setDiscovery('idle')
setFound(null)
}}
/>
<Input
label="Access token"
type="password"
value={token}
placeholder={existing?.hasToken ? '•••••••• (saved — enter a new one to change repos)' : 'gitea PAT · scopes: repo + issue'}
hint={
discovery === 'discovering'
? 'Discovering your repositories…'
: discovery === 'done' && found
? `Found ${found.owners.reduce((n, o) => n + (found.reposByOwner[o]?.length ?? 0), 0)} repositories across ${found.owners.length} owner${found.owners.length === 1 ? '' : 's'}.`
: 'Paste your token, then tab out to load your repositories.'
}
onChange={(e) => {
setToken(e.target.value)
setDiscovery('idle')
}}
onBlur={() => void discover()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void discover()
}
}}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Select
label="Owner"
value={owner}
disabled={!pickerReady || ownerOptions.length === 0}
options={
ownerOptions.length
? ownerOptions.map((o) => ({ value: o, label: o }))
: placeholder(discovery === 'discovering' ? 'Loading…' : 'Connect token first')
}
onChange={(e) => {
const o = e.target.value
setOwner(o)
const repos = found?.reposByOwner[o] ?? []
setRepo(repos[0] ?? '')
}}
/>
<Select
label="Repository"
value={repo}
disabled={!pickerReady || repoOptions.length === 0}
options={
repoOptions.length ? repoOptions.map((r) => ({ value: r, label: r })) : placeholder('—')
}
onChange={(e) => setRepo(e.target.value)}
/>
</div>
<Input
label="Model URL"
value={modelUrl}
placeholder="http://localhost:1234/v1"
hint="Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off."
onChange={(e) => setModelUrl(e.target.value)}
/>
</div>
{error ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--danger)', margin: '12px 0 0' }}>{error}</p>
) : null}
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<Button onClick={() => void connect()} disabled={!ready || state !== 'idle'}>
{state === 'saving' ? 'Connecting…' : existing ? 'Save' : 'Connect'}
</Button>
</div>
</Card>
</div>
)
}

View File

@@ -1,15 +1,11 @@
import React from 'react' import React from 'react'
import { type DepsData, type IssueRef } from '../../data/view-types.js' import { DEPS, type IssueRef } from '../../data/fixtures.js'
import { Tag, Icon } from '../ui/index.js' import { Tag, Icon } from '../ui/index.js'
import { EmptyState } from '../shell/states.js'
// Dependency graph drill-in — layered DAG, critical path in spruce // Dependency graph drill-in — layered DAG, critical path in spruce
export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: DepsData }) { export function DepsGraph({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
if (!data || data.nodes.length === 0) { const g = DEPS
return <EmptyState icon="share-2" title="No dependencies" line="Nothing blocks anything yet." />
}
const g = data
const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84 const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84
const X = (c: number) => PAD + c * COLW const X = (c: number) => PAD + c * COLW
@@ -40,7 +36,7 @@ export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef
diagnosis: { icon: 'circle-dashed', color: 'var(--ink-3)', label: 'diagnosis' }, diagnosis: { icon: 'circle-dashed', color: 'var(--ink-3)', label: 'diagnosis' },
} }
const Node = ({ n }: { n: DepsData['nodes'][number] }) => { const Node = ({ n }: { n: (typeof DEPS.nodes)[number] }) => {
const st = STATES[n.state] const st = STATES[n.state]
const crit = g.critical.includes(n.id) const crit = g.critical.includes(n.id)
return ( return (
@@ -125,6 +121,10 @@ export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef
</div> </div>
</div> </div>
</div> </div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
Four issues sit on the critical path, and #87 is the cork in the bottle. Remove it and everything pours.
</p>
</div> </div>
) )
} }

View File

@@ -1,124 +1,104 @@
import React from 'react' import React from 'react'
import { Badge, Card, Icon } from '../ui/index.js' import { DIRECTIVES, type DirectivePending, type DirectiveEntry } from '../../data/fixtures.js'
import { EmptyState } from '../shell/states.js' import { Card, Button, Badge, Icon } from '../ui/index.js'
/** A ledger row, projected from a live DirectiveRecord. */ // Directive log — append-only ledger + the consequence diff (propose-approve)
interface LedgerEntry {
seq: number
when: string
what: string
why: string
kind: string
status: 'applied' | 'amended' | 'withdrawn' | 'proposed'
}
const STATUS_BADGE: Record<LedgerEntry['status'], { tone: 'ok' | 'neutral' | 'info'; label: string }> = {
applied: { tone: 'ok', label: 'applied' },
amended: { tone: 'info', label: 'amended' },
withdrawn: { tone: 'neutral', label: 'withdrawn' },
proposed: { tone: 'info', label: 'proposed' },
}
/**
* Directive ledger — the append-only record of every time a human overruled the
* scheduler (who/when/what/why), read live from the pm-state repo. Directives are
* given in chat and recorded here; this screen only reads them.
*/
export function DirectivesScreen() { export function DirectivesScreen() {
const [entries, setEntries] = React.useState<LedgerEntry[] | null>(null) // null = loading const [pending, setPending] = React.useState<DirectivePending | null>(DIRECTIVES.pending)
const [entries, setEntries] = React.useState<DirectiveEntry[]>(DIRECTIVES.entries)
React.useEffect(() => { const resolve = (status: string) => {
let alive = true setEntries((e) => [{
window.commitea.pmstate seq: pending!.seq, who: pending!.who, when: pending!.when, what: pending!.what, why: 'pilot demo on the 14th',
.directives() status, consequence: status === 'applied' ? '#78 +5d · Beta 80% Mar 514' : 'withdrawn before apply',
.then((r) => { }, ...e]);
if (!alive) return setPending(null);
if (!r.ok) { };
setEntries([])
return const toneColor: Record<string, string> = { ok: 'var(--ok)', warn: 'var(--warn)', info: 'var(--info)', danger: 'var(--danger)' };
} const statusBadge: Record<string, { tone: 'ok' | 'neutral' | 'info'; label: string }> = {
setEntries( applied: { tone: 'ok', label: 'applied' },
r.directives withdrawn: { tone: 'neutral', label: 'withdrawn' },
.slice() superseded: { tone: 'info', label: 'superseded' },
.reverse() // newest first };
.map((d) => ({
seq: d.seq,
when: new Date(d.ts).toLocaleDateString(),
what: d.quote,
why: d.rationale ?? '',
kind: d.kind,
status:
d.status === 'accepted'
? 'applied'
: d.status === 'amended'
? 'amended'
: d.status === 'withdrawn'
? 'withdrawn'
: 'proposed',
})),
)
})
.catch(() => {
if (alive) setEntries([])
})
return () => {
alive = false
}
}, [])
return ( return (
<div style={{ maxWidth: 760, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ maxWidth: 760, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}> <header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Directives</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Directives</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}> <p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>append-only · JSONL in pm-state · who, when, what, why</p>
append-only · JSONL in pm-state · who, when, what, why
</p>
</header> </header>
{entries === null ? null : entries.length === 0 ? ( {pending ? (
<EmptyState <Card overline={`Awaiting your word · directive #00${pending.seq}`} title="Consequence diff" jade
icon="flag" footer={<>
title="No directives yet" <Button onClick={() => resolve('applied')}>Make it so</Button>
line="When you overrule the scheduler in chat, it goes on the record here — who, when, what, why." <Button variant="secondary" onClick={() => {}}>Amend</Button>
/> <Button variant="ghost" onClick={() => resolve('withdrawn')}>Withdraw</Button>
</>}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ font: 'var(--text-body)', margin: 0, color: 'var(--ink-2)' }}>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>{pending.who}</span>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}> · {pending.when}</span>
<br />{pending.what}
</p>
<div style={{ borderTop: '1px solid var(--line-1)' }}>
{pending.diff.map((r) => (
<div key={r.change} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 0', borderBottom: '1px solid var(--line-1)' }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: toneColor[r.tone], flexShrink: 0 }}></span>
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', flex: 1 }}>{r.change}</span>
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>
{r.from} <span style={{ color: 'var(--ink-2)' }}></span> <span style={{ color: r.tone === 'warn' ? 'var(--warn)' : 'var(--ink-1)' }}>{r.to}</span>
</span>
</div>
))}
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
Cheap, as consequences go. Shall I make it so?
</p>
</div>
</Card>
) : ( ) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, font: 'var(--text-small)', color: 'var(--ink-3)', padding: '2px 2px' }}>
<Icon name="circle-check" size={14} style={{ color: 'var(--ok)' }} />
Nothing awaits your word. Directives are given in chat; consequences appear here first.
</div>
)}
<Card overline="The ledger" flush> <Card overline="The ledger" flush>
<div> <div>
{entries.map((e, i) => { {entries.map((e, i) => {
const sb = STATUS_BADGE[e.status] const sb = statusBadge[e.status];
return ( return (
<div <div key={e.seq} style={{ display: 'flex', gap: 14, padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)' }}>
key={e.seq}
style={{ display: 'flex', gap: 14, padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)' }}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flexShrink: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span style={{ font: '500 11px var(--font-mono)', color: 'var(--ink-3)' }}>#{String(e.seq).padStart(3, '0')}</span> <span style={{ font: '500 11px var(--font-mono)', color: 'var(--ink-3)' }}>#00{e.seq}</span>
<span style={{ width: 1, flex: 1, background: 'var(--line-1)' }} /> <span style={{ width: 1, flex: 1, background: 'var(--line-1)' }}></span>
</div> </div>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 5 }}> <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Badge>{e.kind}</Badge> <span style={{
width: 20, height: 20, borderRadius: '50%', background: 'var(--spruce-2)', color: 'var(--accent-text)',
font: '600 8.5px/20px var(--font-sans)', textAlign: 'center', flexShrink: 0,
}}>{e.who.split(' ').map((w: string) => w[0]).join('')}</span>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{e.who}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>{e.when}</span> <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>{e.when}</span>
{e.why ? ( {e.why ? <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>· why: {e.why}</span> : null}
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>· why: {e.why}</span> <span style={{ marginLeft: 'auto' }}><Badge tone={sb.tone}>{sb.label}</Badge></span>
) : null}
<span style={{ marginLeft: 'auto' }}>
<Badge tone={sb.tone}>{sb.label}</Badge>
</span>
</div> </div>
<p style={{ font: 'var(--text-body)', color: 'var(--ink-1)', margin: 0 }}>{e.what}</p> <p style={{ font: 'var(--text-body)', color: 'var(--ink-1)', margin: 0 }}>{e.what}</p>
<p style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', margin: 0 }}>{e.consequence}</p>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
</Card> </Card>
)}
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}> <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. Entries are never edited. Corrections are new entries the ledger remembers everything, politely.
</p> </p>
</div> </div>
) );
} }

View File

@@ -1,9 +1,8 @@
import React from 'react' import React from 'react'
import { type FocusIssue, type IssueRef } from '../../data/view-types.js' import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
import { type ForecastView, type FocusView } from '../../lib/backlog.js' import { type ForecastView, type FocusView } from '../../lib/backlog.js'
import { BurnUpCone } from '../charts/chart.js' import { BurnUpCone } from '../charts/chart.js'
import { EmptyState } from '../shell/states.js'
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js' import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
/** /**
@@ -20,6 +19,7 @@ export function FocusScreen({
focus?: FocusView focus?: FocusView
forecast?: ForecastView forecast?: ForecastView
}) { }) {
const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later }
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => ( const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
<Card <Card
overline={slot} overline={slot}
@@ -66,16 +66,6 @@ export function FocusScreen({
</Card> </Card>
) )
if (!focus) {
return (
<EmptyState
icon="coffee"
title="Nothing to pour"
line="Capture some work, or enjoy the silence — it never lasts."
/>
)
}
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header <header
@@ -90,31 +80,39 @@ export function FocusScreen({
> >
<div> <div>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning service</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning service</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>
{TODAY} · reconcile 3.2s
</p>
</div> </div>
<Badge tone="ok" dot>
ahead of forecast
</Badge>
</header> </header>
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}>
{focus.now ? <FocusRow slot="Now" issue={focus.now} jade /> : null} {view.now ? <FocusRow slot="Now" issue={view.now} jade /> : null}
{focus.next ? <FocusRow slot="Next" issue={focus.next} /> : null} {view.next ? <FocusRow slot="Next" issue={view.next} /> : null}
{focus.later ? <FocusRow slot="Later" issue={focus.later} /> : null} {view.later ? <FocusRow slot="Later" issue={view.later} /> : null}
</div> </div>
{forecast ? (
<Card <Card
overline="Backlog · open scope" overline={forecast ? 'Backlog · open scope' : 'Milestone · Beta'}
title={ title={
forecast ? (
<>80% of the open backlog lands by <span style={{ whiteSpace: 'nowrap' }}>{forecast.rangeLabel}</span></> <>80% of the open backlog lands by <span style={{ whiteSpace: 'nowrap' }}>{forecast.rangeLabel}</span></>
) : (
<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 312</span></>
)
} }
actions={<IconButton icon="chart-line" label="Open runway" size="sm" />} actions={<IconButton icon="chart-line" label="Open runway" size="sm" />}
> >
<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
? `${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 — 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.`} : 'The cone has narrowed since Friday. Im quietly pleased.'}
</p> </p>
</Card> </Card>
) : null}
</div> </div>
) )
} }

View File

@@ -1,15 +1,11 @@
import React from 'react' import React from 'react'
import { type GanttData, type IssueRef } from '../../data/view-types.js' import { GANTT, type IssueRef } from '../../data/fixtures.js'
import { EmptyState } from '../shell/states.js'
import { Icon } from '../ui/index.js' import { Icon } from '../ui/index.js'
// Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails // Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails
export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: GanttData }) { export function GanttView({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
if (!data || data.rows.length === 0) { const g = GANTT;
return <EmptyState icon="calendar" title="No schedule yet" line="Estimates and milestones will fill this in." />;
}
const g = data;
const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30; const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30;
const chartW = g.days * DAYW; const chartW = g.days * DAYW;
@@ -100,6 +96,10 @@ export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef
})} })}
</div> </div>
</div> </div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
The path holds if #87 lands by Wednesday. The dotted tails are your own history, wagging.
</p>
</div> </div>
); );
} }

View File

@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import { type InboxItem, type IssueRef } from '../../data/view-types.js' import { INBOX, type InboxItem, type IssueRef } from '../../data/fixtures.js'
import { EmptyState } from '../shell/states.js'
import { Button, Card, Icon, Tabs } from '../ui/index.js' import { Button, Card, Icon, Tabs } from '../ui/index.js'
// Inbox — Reginald only rings the bell when it matters // Inbox — Reginald only rings the bell when it matters
@@ -10,22 +9,15 @@ export function InboxScreen({
onOpenDirectives, onOpenDirectives,
readIds, readIds,
setReadIds, setReadIds,
data,
}: { }: {
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
onOpenDirectives: () => void onOpenDirectives: () => void
readIds: number[] readIds: number[]
setReadIds: React.Dispatch<React.SetStateAction<number[]>> setReadIds: React.Dispatch<React.SetStateAction<number[]>>
data?: InboxItem[]
}) { }) {
const all = INBOX
const [tab, setTab] = React.useState('all') const [tab, setTab] = React.useState('all')
if (!data || data.length === 0) {
return <EmptyState icon="bell" title="Inbox zero" line="Nothing needs your eyes right now." />
}
const all = data
const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id) const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id)
const unreadCount = all.filter((n) => !isRead(n)).length const unreadCount = all.filter((n) => !isRead(n)).length

View File

@@ -1,44 +1,19 @@
// Issue detail — human intent (gitea) on the left, machine-derived (pm-state) on the right // Issue detail — human intent (gitea) on the left, machine-derived (pm-state) on the right
import React, { useState } from 'react' import React from 'react'
import { import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js'
type EstimateLabel, import { Badge, Button, Card, Icon, Tag } from '../ui/index.js'
ESTIMATE_LABELS,
type IssueChange,
type PriorityLabel,
PRIORITY_LABELS,
summarizeChange,
} from '@commitea/core'
import { type IssueDetail, type IssueRef } from '../../data/view-types.js'
import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js'
const NONE = '—'
export function IssueScreen({ export function IssueScreen({
issue, issue,
detail,
onBack, onBack,
onOpenIssue, onOpenIssue,
canWrite = false,
onApplyChange,
collaborators = [],
milestones = [],
currentAssignee = null,
currentMilestoneId = null,
}: { }: {
issue: IssueRef issue: IssueRef
detail?: IssueDetail
onBack: () => void onBack: () => void
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
canWrite?: boolean
onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>
collaborators?: { login: string; name: string }[]
milestones?: { id: number; title: string }[]
currentAssignee?: string | null
currentMilestoneId?: number | null
}) { }) {
const det: IssueDetail = detail ?? { const det: IssueDetail = ISSUE_DETAIL[issue.id] || {
state: 'triage', state: 'triage',
assignee: 'Stephen', assignee: 'Stephen',
milestone: 'Beta', milestone: 'Beta',
@@ -65,67 +40,6 @@ export function IssueScreen({
} as Record<string, { tone: 'warn' | 'neutral' | 'info' | 'ok'; label: string }> } as Record<string, { tone: 'warn' | 'neutral' | 'info' | 'ok'; label: string }>
)[det.state] || { tone: 'neutral', label: det.state } )[det.state] || { tone: 'neutral', label: det.state }
// ---- propose-approve write path (apply_changes) ----
const labels = issue.labels ?? []
const curEstimate = labels.find((l) => l.startsWith('est/')) ?? ''
const curPriority = labels.find((l) => l.startsWith('p/')) ?? ''
const curAssignee = currentAssignee ?? ''
const curMilestone = currentMilestoneId != null ? String(currentMilestoneId) : ''
const [adjustOpen, setAdjustOpen] = useState(false)
const [estimate, setEstimate] = useState(curEstimate)
const [priority, setPriority] = useState(curPriority)
const [assignee, setAssignee] = useState(curAssignee)
const [milestone, setMilestone] = useState(curMilestone)
const [applying, setApplying] = useState(false)
const openAdjust = () => {
setEstimate(curEstimate)
setPriority(curPriority)
setAssignee(curAssignee)
setMilestone(curMilestone)
setAdjustOpen(true)
}
// the concrete changes this dialog would apply, one per field that differs
const pendingChanges: IssueChange[] = []
if (estimate !== curEstimate)
pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null })
if (priority !== curPriority)
pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null })
if (assignee !== curAssignee)
pendingChanges.push({ kind: 'assign', issue: issue.id, assignee: assignee || null })
if (milestone !== curMilestone)
pendingChanges.push({
kind: 'remilestone',
issue: issue.id,
milestone: milestone ? Number(milestone) : null,
milestoneTitle: milestones.find((m) => String(m.id) === milestone)?.title ?? null,
})
const diffs = pendingChanges.map((c) => summarizeChange(c, labels))
const apply = async () => {
if (!onApplyChange || pendingChanges.length === 0) return
setApplying(true)
try {
for (const c of pendingChanges) await onApplyChange(c)
setAdjustOpen(false)
} finally {
setApplying(false)
}
}
const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))]
const prioOptions = [{ value: '', label: NONE }, ...PRIORITY_LABELS.map((l) => ({ value: l, label: l }))]
const assigneeOptions = [
{ value: '', label: 'Unassigned' },
...collaborators.map((c) => ({ value: c.login, label: c.name })),
]
const milestoneOptions = [
{ value: '', label: NONE },
...milestones.map((m) => ({ value: String(m.id), label: m.title })),
]
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* breadcrumb + header */} {/* breadcrumb + header */}
@@ -151,89 +65,10 @@ export function IssueScreen({
</span> </span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{canWrite ? (
<Button variant="secondary" icon="pencil" onClick={openAdjust}>
Adjust
</Button>
) : null}
<Button variant="secondary" icon="arrow-up-right">Open in Gitea</Button> <Button variant="secondary" icon="arrow-up-right">Open in Gitea</Button>
</div>
</header> </header>
</div> </div>
<Dialog
open={adjustOpen}
onClose={() => setAdjustOpen(false)}
title="Adjust issue"
footer={
<>
<Button variant="ghost" onClick={() => setAdjustOpen(false)}>
Cancel
</Button>
<Button onClick={apply} disabled={pendingChanges.length === 0 || applying}>
{applying ? 'Applying…' : 'Apply to gitea'}
</Button>
</>
}
>
<div style={{ display: 'flex', gap: 14 }}>
<Select
label="Estimate"
options={estOptions}
value={estimate}
onChange={(e) => setEstimate(e.target.value)}
/>
<Select
label="Priority"
options={prioOptions}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
{(collaborators.length > 0 || milestones.length > 0) && (
<div style={{ display: 'flex', gap: 14, marginTop: 12 }}>
{collaborators.length > 0 ? (
<Select
label="Assignee"
options={assigneeOptions}
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
/>
) : null}
{milestones.length > 0 ? (
<Select
label="Milestone"
options={milestoneOptions}
value={milestone}
onChange={(e) => setMilestone(e.target.value)}
/>
) : null}
</div>
)}
<div style={{ marginTop: 14, minHeight: 40 }}>
{pendingChanges.length === 0 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}>
No change yet pick a different estimate, priority, assignee, or milestone.
</p>
) : (
<>
<p style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', margin: '0 0 6px' }}>
Proposed change
</p>
{diffs.map((d) => (
<div key={d} style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>
{d}
</div>
))}
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '8px 0 0' }}>
Writes to gitea and re-runs the plan. Nothing else changes.
</p>
</>
)}
</div>
</Dialog>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16, alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16, alignItems: 'start' }}>
{/* left: human intent */} {/* left: human intent */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -319,7 +154,7 @@ export function IssueScreen({
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>blocks</span> <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>blocks</span>
{det.blocks.map((b) => ( {det.blocks.map((b) => (
<button key={b} type="button" <button key={b} type="button"
onClick={() => onOpenIssue({ id: b })} onClick={() => onOpenIssue({ id: b, title: b === 91 ? 'Webhook listener: reconcile on reconnect' : 'Monte Carlo engine: percentile bands', labels: b === 91 ? ['est/3d', 'p/2'] : ['est/5d', 'p/1'] })}
style={{ font: '500 11px var(--font-mono)', color: 'var(--accent-text)', background: 'var(--spruce-1)', border: '1px solid var(--spruce-2)', borderRadius: 'var(--radius-1)', padding: '2px 7px', cursor: 'pointer' }}> style={{ font: '500 11px var(--font-mono)', color: 'var(--accent-text)', background: 'var(--spruce-1)', border: '1px solid var(--spruce-2)', borderRadius: 'var(--radius-1)', padding: '2px 7px', cursor: 'pointer' }}>
#{b} #{b}
</button> </button>

View File

@@ -1,39 +1,21 @@
import React from 'react' import React from 'react'
import { type IssueRef } from '../../data/view-types.js' import { COLUMNS, type IssueRef } from '../../data/fixtures.js'
import { type MilestoneView } from '../../lib/backlog.js'
import { BurnUpCone } from '../charts/chart.js' import { BurnUpCone } from '../charts/chart.js'
import { EmptyState } from '../shell/states.js'
import { Badge, Button, Card, Icon, Tag } from '../ui/index.js' import { Badge, Button, Card, Icon, Tag } from '../ui/index.js'
// Milestone detail — scope, cone, issues; forecasts stay ranges. // Milestone detail — scope, cone, issues; forecasts stay ranges
export function MilestoneScreen({ export function MilestoneScreen({ onBack, onOpenIssue }: { onBack: () => void; onOpenIssue: (issue: IssueRef) => void }) {
onBack, const cols = COLUMNS
onOpenIssue, const byState = (ids: number[]) =>
data, cols.flatMap((c) => c.issues.map((i) => ({ ...i, col: c.label }))).filter((i) => ids.includes(i.id))
}: {
onBack: () => void
onOpenIssue: (issue: IssueRef) => void
data?: MilestoneView
}) {
if (!data) {
return (
<EmptyState
icon="flag"
title="Milestone not found"
line="It may have closed or been renamed."
action="Back to Runway"
onAction={onBack}
/>
)
}
const groups = data.groups.map((g) => ({ label: g.label, issues: g.issues, muted: g.label === 'Done' })) const groups = [
const name = data.name { label: 'Steeping', issues: byState([87, 84]) },
const dueLine = `milestone · due ${data.due} · ${data.soft ? 'soft — scope may flex' : 'hard deadline'}` { label: 'In review', issues: byState([92]) },
const forecastLabel = data.forecastRange ? `80% ${data.forecastRange}` : 'all shipped' { label: 'Queued', issues: byState([102, 103, 99, 96, 78]) },
const scopeStat = `${data.scopeCount} issues · est ${data.scopeEstDays}d` { label: 'Done', issues: byState([71, 69, 65]), muted: true },
const doneStat = `${data.doneCount} · ${data.donePct}%` ]
const Stat = ({ label, value, tone }: { label: string; value: string; tone?: string }) => ( const Stat = ({ label, value, tone }: { label: string; value: string; tone?: string }) => (
<div style={{ flex: 1, padding: '12px 18px', borderRight: '1px solid var(--line-1)' }}> <div style={{ flex: 1, padding: '12px 18px', borderRight: '1px solid var(--line-1)' }}>
@@ -54,12 +36,12 @@ export function MilestoneScreen({
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14, display: 'flex', alignItems: 'flex-start', gap: 16 }}> <header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14, display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<p style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-3)', margin: '0 0 6px', display: 'inline-flex', alignItems: 'center', gap: 6 }}> <p style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-3)', margin: '0 0 6px', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Icon name="milestone" size={13} /> {dueLine} <Icon name="milestone" size={13} /> milestone · due Mar 15 · soft scope may flex
</p> </p>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>{name}</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Beta</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
<Badge tone="ok" dot>{`${data.doneCount}/${data.scopeCount} done`}</Badge> <Badge tone="ok" dot>ahead of forecast</Badge>
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>{forecastLabel}</span> <span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>80% Mar 312</span>
</div> </div>
</div> </div>
<Button variant="secondary" icon="arrow-up-right">Open in Gitea</Button> <Button variant="secondary" icon="arrow-up-right">Open in Gitea</Button>
@@ -69,23 +51,21 @@ export function MilestoneScreen({
{/* stats strip */} {/* stats strip */}
<Card flush> <Card flush>
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
<Stat label="Scope" value={scopeStat} /> <Stat label="Scope" value="42 issues · est 61d" />
<Stat label="Done" value={doneStat} /> <Stat label="Done" value="24 · 57%" />
<Stat label="Forecast" value={forecastLabel} /> <Stat label="Forecast" value="80% Mar 312" />
<div style={{ flex: 1, padding: '12px 18px' }}> <div style={{ flex: 1, padding: '12px 18px' }}>
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 5 }}>Remaining</div> <div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 5 }}>Drift · 7d</div>
<div style={{ font: '500 14px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{`${data.scopeCount - data.doneCount} open`}</div> <div style={{ font: '500 14px var(--font-mono)', color: 'var(--ok)', whiteSpace: 'nowrap' }}>2d · cone narrowed</div>
</div> </div>
</div> </div>
</Card> </Card>
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 14, alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Burn-up" title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>{data.forecastRange ?? 'shipped'}</span></>} jade> <Card overline="Burn-up" title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 312</span></>} jade>
<BurnUpCone data={data.cone ?? undefined} /> <BurnUpCone />
<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' }}>
{data.cone Comfortably ahead. Beta needs #87 more than it needs my commentary.
? `${data.scopeCount - data.doneCount} open of ${data.scopeCount}. Cone over what's left.`
: 'Everything here has shipped.'}
</p> </p>
</Card> </Card>

View File

@@ -1,121 +1,51 @@
import React from 'react' import React from 'react'
import type { BootstrapResult, DiscoverResult } from '../../global.js'
import logoIcon from '../../design/assets/logo-icon.png' import logoIcon from '../../design/assets/logo-icon.png'
import { Badge, Button, Icon, Input, Radio, Select, Tag } from '../ui/index.js' import { Badge, Button, Icon, Input, Radio, Tag } from '../ui/index.js'
/** // Onboarding / first connect — welcome → connect gitea → choose repo → bootstrap
* First-run onboarding — a real, live wizard. Welcome → Connect (live token export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture') => void }) {
* discovery) → Repo (pick from the repos that token can actually reach) → const [step, setStep] = React.useState<number>(0)
* Bootstrap (apply the label schema + create the pm-state sidecar, for real), const [conn, setConn] = React.useState<'idle' | 'testing' | 'ok'>('idle')
* then into the app. Every step talks to the main-process bridge; nothing here const [repo, setRepo] = React.useState('stephen/commitea')
* is faked. The token is saved encrypted in main only after a successful bootstrap. const [boot, setBoot] = React.useState<number>(-1) // -1 idle, 0..2 running, 3 done
*/
export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus' | 'capture') => void }) {
const [step, setStep] = React.useState(0)
// connect step React.useEffect(() => {
const [baseUrl, setBaseUrl] = React.useState('https://gitea.stephenmann.io') if (conn !== 'testing') return
const [token, setToken] = React.useState('') const t = setTimeout(() => setConn('ok'), 1100)
const [discovery, setDiscovery] = React.useState<'idle' | 'testing' | 'ok'>('idle') return () => clearTimeout(t)
const [found, setFound] = React.useState<{ user: string; owners: string[]; reposByOwner: Record<string, string[]> } | null>(null) }, [conn])
const [error, setError] = React.useState<string | null>(null)
// repo step React.useEffect(() => {
const [owner, setOwner] = React.useState('') if (boot < 0 || boot >= 3) return
const [repo, setRepo] = React.useState('') const t = setTimeout(() => setBoot(boot + 1), 700)
return () => clearTimeout(t)
// bootstrap step }, [boot])
const [boot, setBoot] = React.useState<'idle' | 'running' | 'done'>('idle')
const [bootResult, setBootResult] = React.useState<Extract<BootstrapResult, { ok: true }> | null>(null)
const repoCount = found ? found.owners.reduce((n, o) => n + (found.reposByOwner[o]?.length ?? 0), 0) : 0
const test = async () => {
if (!baseUrl.trim() || !token.trim() || discovery === 'testing') return
setError(null)
setDiscovery('testing')
const res: DiscoverResult = await window.commitea.config
.discover({ baseUrl: baseUrl.trim(), token: token.trim() })
.catch(() => ({ ok: false as const, error: 'unreachable' }))
if (!res.ok) {
setDiscovery('idle')
setFound(null)
setError(
res.error === '401' || res.error === '403'
? 'The token was rejected — check it has repo + issue scopes.'
: res.error === '404'
? "Couldn't reach that Gitea — check the URL."
: `Connection failed${res.error ? ` (${res.error})` : ''}.`,
)
return
}
setFound({ user: res.user, owners: res.owners, reposByOwner: res.reposByOwner })
setDiscovery('ok')
const o = res.owners.includes(res.user) ? res.user : res.owners[0] ?? ''
setOwner(o)
setRepo(res.reposByOwner[o]?.[0] ?? '')
}
const runBootstrap = async () => {
if (!found || boot === 'running') return
setError(null)
setBoot('running')
const res = await window.commitea.config
.bootstrap({ baseUrl: baseUrl.trim(), token: token.trim(), owner, repo, underOrg: owner !== found.user })
.catch(() => ({ ok: false as const, error: 'unreachable' }))
if (!res.ok) {
setBoot('idle')
setError(`Bootstrap failed${res.error ? ` (${res.error})` : ''}. Nothing was half-applied — you can retry.`)
return
}
// persist the connection now that the repo is prepared
await window.commitea.config.set({ baseUrl: baseUrl.trim(), owner, repo, token: token.trim() })
setBootResult(res)
setBoot('done')
}
const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap'] const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap']
const repoOptions = found?.reposByOwner[owner] ?? [] const BOOT_TASKS = [
'Create stephen/pm-state (the sidecar)',
'Apply the label schema to stephen/commitea',
'Install a webhook · endpoint :48731',
]
const Frame = ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => ( const Frame = ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => (
<div <div style={{
style={{ background: 'var(--surface-card)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-3)',
background: 'var(--surface-card)', boxShadow: 'var(--shadow-jade-line), var(--shadow-2)', padding: '30px 36px',
border: '1px solid var(--line-1)', display: 'flex', flexDirection: 'column', gap: 16, width: '100%',
borderRadius: 'var(--radius-3)', }}>
boxShadow: 'var(--shadow-jade-line), var(--shadow-2)',
padding: '30px 36px',
display: 'flex',
flexDirection: 'column',
gap: 16,
width: '100%',
}}
>
{children} {children}
{error ? <p style={{ font: 'var(--text-agent)', color: 'var(--danger)', margin: 0 }}>{error}</p> : null} {footer ? <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid var(--line-1)', paddingTop: 16 }}>{footer}</div> : null}
{footer ? (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid var(--line-1)', paddingTop: 16 }}>
{footer}
</div>
) : null}
</div> </div>
) )
return ( return (
<div <div style={{
style={{ minHeight: '100vh', background: 'var(--surface-app)', display: 'flex', flexDirection: 'column',
minHeight: '100vh', alignItems: 'center', justifyContent: 'center', padding: 32, gap: 22,
background: 'var(--surface-app)', }} data-screen-label="onboarding">
display: 'flex', {/* brand */}
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 32,
gap: 22,
}}
data-screen-label="onboarding"
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<img src={logoIcon} width="34" height="34" alt="" style={{ borderRadius: 8, display: 'block' }} /> <img src={logoIcon} width="34" height="34" alt="" style={{ borderRadius: 8, display: 'block' }} />
<span style={{ font: '400 27px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}> <span style={{ font: '400 27px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
@@ -128,24 +58,15 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
{STEPS.map((s, i) => ( {STEPS.map((s, i) => (
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: 14 }}> <div key={s} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span <span style={{
style={{ width: 20, height: 20, borderRadius: '50%', textAlign: 'center',
width: 20,
height: 20,
borderRadius: '50%',
textAlign: 'center',
font: '500 10.5px/20px var(--font-mono)', font: '500 10.5px/20px var(--font-mono)',
background: i < step ? 'var(--accent)' : i === step ? 'var(--spruce-2)' : 'var(--paper-2)', background: i < step ? 'var(--accent)' : i === step ? 'var(--spruce-2)' : 'var(--paper-2)',
color: i < step ? 'var(--ink-inverse)' : i === step ? 'var(--accent-text)' : 'var(--ink-3)', color: i < step ? 'var(--ink-inverse)' : i === step ? 'var(--accent-text)' : 'var(--ink-3)',
}} }}>{i < step ? '✓' : i + 1}</span>
> <span style={{ font: `${i === step ? 600 : 400} 12px/1 var(--font-sans)`, color: i === step ? 'var(--ink-1)' : 'var(--ink-3)' }}>{s}</span>
{i < step ? '✓' : i + 1}
</span> </span>
<span style={{ font: `${i === step ? 600 : 400} 12px/1 var(--font-sans)`, color: i === step ? 'var(--ink-1)' : 'var(--ink-3)' }}> {i < STEPS.length - 1 ? <span style={{ width: 24, height: 1, background: 'var(--line-2)' }}></span> : null}
{s}
</span>
</span>
{i < STEPS.length - 1 ? <span style={{ width: 24, height: 1, background: 'var(--line-2)' }} /> : null}
</div> </div>
))} ))}
</div> </div>
@@ -155,8 +76,8 @@ 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'm Reginald, your project manager. I interview you instead of making you fill in forms,
honest ranges, and I never do the arithmetic myself — there's a scheduler for that. I forecast in honest ranges, and I never do the arithmetic myself — there's a scheduler for that.
</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 live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost.
@@ -165,196 +86,79 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
) : null} ) : null}
{step === 1 ? ( {step === 1 ? (
<Frame <Frame footer={<>
footer={ <Button variant="ghost" onClick={() => setStep(0)}>Back</Button>
<> <Button iconRight="arrow-right" disabled={conn !== 'ok'} onClick={() => setStep(2)}>Continue</Button>
<Button variant="ghost" onClick={() => setStep(0)}> </>}>
Back
</Button>
<Button iconRight="arrow-right" disabled={discovery !== 'ok'} onClick={() => setStep(2)}>
Continue
</Button>
</>
}
>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Your Gitea</h1> <h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Your Gitea</h1>
<Input <Input label="Base URL" icon="link" mono defaultValue="https://gitea.stephenmann.io" />
label="Base URL" <Input label="Access token" icon="keyboard" mono type="password" defaultValue="ct_9f2e81c4a7d6" hint="Scopes: repo, issue. Nothing more." />
icon="link"
mono
value={baseUrl}
onChange={(e) => {
setBaseUrl(e.target.value)
setDiscovery('idle')
setFound(null)
}}
/>
<Input
label="Access token"
icon="keyboard"
mono
type="password"
value={token}
placeholder="gitea PAT"
hint="Scopes: repo, issue. Nothing more."
onChange={(e) => {
setToken(e.target.value)
setDiscovery('idle')
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void test()
}
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button <Button variant="secondary" icon={conn === 'testing' ? 'loader-circle' : 'zap'} disabled={conn === 'testing'} onClick={() => setConn('testing')}>
variant="secondary" {conn === 'testing' ? 'Ringing the bell…' : 'Test connection'}
icon={discovery === 'testing' ? 'loader-circle' : 'zap'}
disabled={discovery === 'testing' || !baseUrl.trim() || !token.trim()}
onClick={() => void test()}
>
{discovery === 'testing' ? 'Ringing the bell…' : 'Test connection'}
</Button> </Button>
{discovery === 'ok' ? ( {conn === 'ok' ? <Badge tone="ok" dot>connected · 3 repos visible</Badge> : null}
<Badge tone="ok" dot>
connected · {repoCount} repo{repoCount === 1 ? '' : 's'} visible
</Badge>
) : null}
</div> </div>
</Frame> </Frame>
) : null} ) : null}
{step === 2 ? ( {step === 2 ? (
<Frame <Frame footer={<>
footer={ <Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
<> <Button iconRight="arrow-right" onClick={() => setStep(3)}>Continue</Button>
<Button variant="ghost" onClick={() => setStep(1)}> </>}>
Back
</Button>
<Button iconRight="arrow-right" disabled={!owner || !repo} onClick={() => setStep(3)}>
Continue
</Button>
</>
}
>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Which repo shall I manage?</h1> <h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Which repo shall I manage?</h1>
{found && found.owners.length > 1 ? ( <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Select {['stephen/commitea', 'stephen/novelpad', 'stephen/infra'].map((r: string) => (
label="Owner" <label key={r} style={{
value={owner} display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', cursor: 'pointer',
options={found.owners.map((o) => ({ value: o, label: o }))} background: repo === r ? 'var(--spruce-1)' : 'var(--paper-0)',
onChange={(e) => { border: `1px solid ${repo === r ? 'var(--spruce-3)' : 'var(--line-1)'}`,
const o = e.target.value
setOwner(o)
setRepo(found.reposByOwner[o]?.[0] ?? '')
}}
/>
) : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 260, overflowY: 'auto' }}>
{repoOptions.map((r) => {
const full = `${owner}/${r}`
const selected = repo === r
return (
<label
key={r}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
cursor: 'pointer',
background: selected ? 'var(--spruce-1)' : 'var(--paper-0)',
border: `1px solid ${selected ? 'var(--spruce-3)' : 'var(--line-1)'}`,
borderRadius: 'var(--radius-2)', borderRadius: 'var(--radius-2)',
}} }}>
> <Radio name="repo" checked={repo === r} onChange={() => setRepo(r)} />
<Radio name="repo" checked={selected} onChange={() => setRepo(r)} />
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} /> <Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{full}</span> <span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{r}</span>
</label> </label>
) ))}
})}
</div> </div>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}> <p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>One to start. You can add more later in Settings.</p>
One to start. You can switch later in Settings.
</p>
</Frame> </Frame>
) : null} ) : null}
{step === 3 ? ( {step === 3 ? (
<Frame <Frame footer={boot === 3 ? <>
footer={ <Button variant="ghost" onClick={() => onDone('focus')}>Just look around</Button>
boot === 'done' ? ( <Button icon="sparkles" onClick={() => onDone('capture')}>Start a capture</Button>
<> </> : <>
<Button variant="ghost" onClick={() => onConnected('focus')}> <Button variant="ghost" onClick={() => setStep(2)} disabled={boot >= 0}>Back</Button>
Just look around <Button disabled={boot >= 0} onClick={() => setBoot(0)}>Make it so</Button>
</Button> </>}>
<Button icon="sparkles" onClick={() => onConnected('capture')}>
Start a capture
</Button>
</>
) : (
<>
<Button variant="ghost" onClick={() => setStep(2)} disabled={boot === 'running'}>
Back
</Button>
<Button icon={boot === 'running' ? 'loader-circle' : undefined} disabled={boot === 'running'} onClick={() => void runBootstrap()}>
{boot === 'running' ? 'Preparing…' : 'Make it so'}
</Button>
</>
)
}
>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}> <h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>
{boot === 'done' ? 'All set.' : 'With your approval'} {boot === 3 ? 'All set.' : 'With your approval'}
</h1> </h1>
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
I'll prepare <span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{owner}/{repo}</span>: apply
the label schema, and create the pm-state sidecar for machine-derived state.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<BootTask {BOOT_TASKS.map((t: string, i: number) => (
state={boot} <div key={t} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
label={ <span style={{ display: 'inline-flex', color: boot > i ? 'var(--ok)' : boot === i ? 'var(--warn)' : 'var(--ink-3)' }}>
bootResult <Icon name={boot > i ? 'circle-check' : boot === i ? 'loader-circle' : 'circle-dashed'} size={15} />
? bootResult.labels.created.length </span>
? `Applied the label schema (${bootResult.labels.created.length} created, ${bootResult.labels.existing.length} already there)` <span style={{ font: 'var(--text-body)', color: boot > i ? 'var(--ink-1)' : 'var(--ink-2)', whiteSpace: 'nowrap' }}>{t}</span>
: `Label schema already applied (${bootResult.labels.existing.length} labels)` </div>
: `Apply the label schema to ${owner}/${repo}`
}
/>
<BootTask
state={boot}
label={
bootResult
? bootResult.pmState === 'created'
? `Created ${owner}/${bootResult.pmStateRepo} (the sidecar)`
: `${owner}/${bootResult.pmStateRepo} already exists`
: `Create ${owner}/${repo}-pm-state (the sidecar)`
}
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, paddingLeft: 25 }}>
{(bootResult ? [...bootResult.labels.created, ...bootResult.labels.existing] : [
'est/1d',
'est/2d',
'est/3d',
'est/5d',
'est/8d',
'p/1',
'p/2',
'p/3',
'p/4',
'deadline/hard',
]).map((l) => (
<Tag key={l} label={l} />
))} ))}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, paddingLeft: 25 }}>
{['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/1', 'p/2', 'p/3', 'p/4', 'deadline/hard'].map((l: string) => <Tag key={l} label={l} />)}
</div> </div>
</div> </div>
{boot === 3 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
The pot is empty. Tell me what you're planning and I'll draw up the tickets.
</p>
) : (
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}> <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. No bot comments, no body frontmatter, no synthetic issues ever. Labels are the only footprint.
</p> </p>
)}
</Frame> </Frame>
) : null} ) : null}
</div> </div>
@@ -363,16 +167,3 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
</div> </div>
) )
} }
function BootTask({ state, label }: { state: 'idle' | 'running' | 'done'; label: string }) {
const icon = state === 'done' ? 'circle-check' : state === 'running' ? 'loader-circle' : 'circle-dashed'
const color = state === 'done' ? 'var(--ok)' : state === 'running' ? 'var(--warn)' : 'var(--ink-3)'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ display: 'inline-flex', color }}>
<Icon name={icon} size={15} />
</span>
<span style={{ font: 'var(--text-body)', color: state === 'done' ? 'var(--ink-1)' : 'var(--ink-2)' }}>{label}</span>
</div>
)
}

View File

@@ -1,63 +1,28 @@
import React from 'react' import React from 'react'
import { type CapacityMember, capacityPerWorkday } from '@commitea/core'
import { RunwayBar } from '../charts/chart.js' import { RunwayBar } from '../charts/chart.js'
import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js' import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js'
import { EmptyState } from '../shell/states.js' import { RUNWAY, CAPACITY } from '../../data/fixtures.js'
import { type RunwayMilestone } from '../../data/view-types.js'
// Runway — capacity vs milestone dates; ranges, never points. // Runway — capacity vs milestone dates; ranges, never points
// `milestones` (real per-milestone forecasts) + `capacity` (real config) override the demo.
export function RunwayScreen({ export function RunwayScreen({
onOpenCalibration, onOpenCalibration,
onOpenMilestone, onOpenMilestone,
calibration,
milestones,
capacity,
}: { }: {
onOpenCalibration: () => void onOpenCalibration: () => void
onOpenMilestone: (id?: number) => void onOpenMilestone: () => void
calibration?: { n: number; coldStart: boolean; excludedSameDay?: number }
milestones?: RunwayMilestone[]
capacity?: CapacityMember[]
}) { }) {
if (!milestones || !milestones.length) {
return (
<EmptyState
icon="chart-line"
title="No runway yet"
line="Add milestones and estimates and I'll project the finish."
/>
);
}
const rows = milestones
const capacityRows =
capacity && capacity.length
? capacity.map((m) => ({
who: m.person,
slices: `focus ${m.focusFactor} · alloc ${Math.round(m.allocation * 100)}%`,
hours: `${capacityPerWorkday(m).toFixed(2)} pd/day`,
}))
: []
const excluded = calibration?.excludedSameDay ?? 0
const calibNote = calibration
? calibration.coldStart
? `cold-start priors · ${calibration.n}/20 closed issues estimated` +
(excluded > 0 ? ` · ${excluded} same-day close${excluded === 1 ? '' : 's'} cant calibrate` : '')
: `calibrated on ${calibration.n} closed ${calibration.n === 1 ? 'issue' : 'issues'}`
: null
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}> <header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Runway</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Runway</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates{calibNote ? ` · ${calibNote}` : ''}</p> <p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates · calibrated on 27 closed issues</p>
</header> </header>
<Card overline="Milestones" flush> <Card overline="Milestones" flush>
<div> <div>
{rows.map((m, i) => ( {RUNWAY.map((m, i) => (
<div key={m.name} onClick={() => onOpenMilestone(m.id)} style={{ <div key={m.name} onClick={onOpenMilestone} style={{
display: 'grid', gridTemplateColumns: '160px 1fr 150px 90px', gap: 16, alignItems: 'center', cursor: 'pointer', display: 'grid', gridTemplateColumns: '160px 1fr 150px 90px', gap: 16, alignItems: 'center', cursor: 'pointer',
padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)', padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}} }}
@@ -83,7 +48,7 @@ export function RunwayScreen({
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Capacity" flush> <Card overline="Capacity" flush>
<div> <div>
{capacityRows.map((p, i) => ( {CAPACITY.map((p, i) => (
<div key={p.who} style={{ <div key={p.who} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 20px', display: 'flex', alignItems: 'center', gap: 12, padding: '12px 20px',
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
@@ -101,7 +66,14 @@ export function RunwayScreen({
))} ))}
</div> </div>
</Card> </Card>
<Card overline="Calibration" actions={<IconButton icon="arrow-up-right" label="Full report" size="sm" onClick={onOpenCalibration} />} /> <Card overline="Calibration" actions={<IconButton icon="arrow-up-right" label="Full report" size="sm" onClick={onOpenCalibration} />}>
<p style={{ font: 'var(--text-body)', margin: '0 0 8px' }}>
Your estimates run <strong>18% optimistic</strong> on <code>est/3d</code> and above. Smaller tickets are honest.
</p>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
I widen the cone accordingly. No judgement it's the most common shape of hope.
</p>
</Card>
</div> </div>
</div> </div>
); );

View File

@@ -1,22 +1,9 @@
import React from 'react' import React from 'react'
import type { PublicConfig } from '../../global.js'
import { Badge, Button, Card, Icon, IconButton, Input, Radio, Select, Switch, Tag } from '../ui/index.js' import { Badge, Button, Card, Icon, IconButton, Input, Radio, Select, Switch, Tag } from '../ui/index.js'
// Settings — gitea connection, sync, model roles, labels, rituals, appearance // Settings — gitea connection, sync, model roles, labels, rituals, appearance
export function SettingsScreen({ export function SettingsScreen({ dark, setDark }: { dark: boolean; setDark: (v: boolean) => void }) {
dark,
setDark,
connection,
onReconnect,
onDisconnect,
}: {
dark: boolean
setDark: (v: boolean) => void
connection?: PublicConfig | null
onReconnect?: () => void
onDisconnect?: () => void
}) {
const [webhooks, setWebhooks] = React.useState(true) const [webhooks, setWebhooks] = React.useState(true)
const [reconcile, setReconcile] = React.useState(true) const [reconcile, setReconcile] = React.useState(true)
const [poll, setPoll] = React.useState(true) const [poll, setPoll] = React.useState(true)
@@ -37,35 +24,25 @@ export function SettingsScreen({
</header> </header>
<Card overline="Gitea" title="Connection"> <Card overline="Gitea" title="Connection">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{connection ? ( <Input label="Base URL" icon="link" mono defaultValue="https://gitea.stephenmann.io" />
<> <Input label="Access token" icon="keyboard" mono type="password" defaultValue="ct_9f2e81c4a7d6" hint="Scopes: repo, issue. Nothing more." />
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ font: '600 13px/1.2 var(--font-sans)', color: 'var(--ink-1)' }}>Managed repos</span>
<Row style={{ padding: '8px 12px', background: 'var(--paper-0)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-2)' }}> <Row style={{ padding: '8px 12px', background: 'var(--paper-0)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-2)' }}>
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} /> <Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)', flex: 1 }}>{connection.owner}/{connection.repo}</span> <span style={{ font: 'var(--text-data)', color: 'var(--ink-1)', flex: 1 }}>stephen/commitea</span>
<Badge tone="ok" dot>connected</Badge> <Badge tone="ok" dot>syncing</Badge>
<IconButton icon="x" label="Stop managing" size="sm" />
</Row> </Row>
<Row style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', gap: 6 }}> <Row style={{ padding: '8px 12px', background: 'var(--paper-0)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-2)' }}>
<Icon name="link" size={12} />{connection.baseUrl} <Icon name="layers" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)', flex: 1 }}>stephen/pm-state</span>
<Badge>sidecar</Badge>
</Row> </Row>
<Row style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', gap: 6 }}> <Note>The sidecar holds machine-derived state only. Delete it and resync no truth is lost.</Note>
<Icon name="layers" size={12} />sidecar: {connection.pmStateRepo ?? `${connection.repo}-pm-state`} <Button variant="secondary" size="sm" icon="plus" style={{ alignSelf: 'flex-start' }}>Add repo</Button>
</Row> </div>
<Row style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', gap: 6 }}>
<Icon name="sparkles" size={12} />model: {connection.modelUrl ?? 'not set — chat off'}
</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 }}>
<Button variant="secondary" size="sm" icon="settings-2" onClick={onReconnect}>Reconfigure</Button>
<Button variant="ghost" size="sm" onClick={onDisconnect}>Disconnect</Button>
</Row>
</>
) : (
<Row style={{ gap: 8 }}>
<Note>Not connected.</Note>
<Button variant="secondary" size="sm" onClick={onReconnect}>Connect</Button>
</Row>
)}
</div> </div>
</Card> </Card>

View File

@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import { type IssueRef, type StandupData } from '../../data/view-types.js' import { type IssueRef, STANDUP } from '../../data/fixtures.js'
import { EmptyState } from '../shell/states.js'
import { Badge, Button, Icon } from '../ui/index.js' import { Badge, Button, Icon } from '../ui/index.js'
const standupCSS = ` const standupCSS = `
@@ -37,12 +36,18 @@ const toneColor: Record<string, string> = {
export function StandupScreen({ export function StandupScreen({
onBegin, onBegin,
onOpenIssue, onOpenIssue,
data,
}: { }: {
onBegin: () => void onBegin: () => void
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
data?: StandupData
}) { }) {
const s = STANDUP
const NAG_REF: IssueRef = {
id: s.nag.id,
title: 'Fix lifecycle inference on merge events',
labels: ['est/2d', 'p/1'],
days: s.nag.days,
}
const Section = ({ overline, order, children }: { overline: string; order: number; children: React.ReactNode }) => ( const Section = ({ overline, order, children }: { overline: string; order: number; children: React.ReactNode }) => (
<section <section
className="ct-standup-section" className="ct-standup-section"
@@ -65,22 +70,6 @@ export function StandupScreen({
</section> </section>
) )
if (!data) {
return (
<EmptyState
icon="sun"
title="Nothing to report"
line="No overnight drift — I'll have a plan when there's work to pour."
/>
)
}
const s = data
const NAG_REF: IssueRef = {
id: s.nag.id,
days: s.nag.days,
}
return ( return (
<div style={{ maxWidth: 660, margin: '0 auto' }}> <div style={{ maxWidth: 660, margin: '0 auto' }}>
<article <article
@@ -96,7 +85,7 @@ export function StandupScreen({
}} }}
> >
<header className="ct-standup-section" style={{ borderBottom: 'var(--rule-double)', paddingBottom: 16 }}> <header className="ct-standup-section" style={{ borderBottom: 'var(--rule-double)', paddingBottom: 16 }}>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '0 0 8px' }}>{s.date}</p> <p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '0 0 8px' }}>{s.date} · prepared 07:00</p>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning standup</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning standup</h1>
</header> </header>

View File

@@ -1,28 +1,10 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png' import logoIcon from '../../design/assets/logo-icon.png'
import type { IssueChange } from '@commitea/core' import type { IssueRef } from '../../data/fixtures.js'
import { forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
import type { PublicConfig } from '../../global.js'
import type { IssueRef } from '../../data/view-types.js'
import { ConnectScreen } from '../screens/connect-screen.js'
import {
backlogCalibration,
capacityWorkers,
forecastBacklog,
issuesToBoardColumns,
milestoneView,
runwayView,
scheduleFocus,
} from '../../lib/backlog.js'
import { useBacklog } from '../../lib/use-backlog.js' import { useBacklog } from '../../lib/use-backlog.js'
import { useCapacity } from '../../lib/use-capacity.js' import { PrimitivesGallery } from '../gallery.js'
import { depsGraphView } from '../../lib/views/deps-graph.js'
import { ganttView } from '../../lib/views/gantt-view.js'
import { inboxView } from '../../lib/views/inbox-view.js'
import { issueDetailView } from '../../lib/views/issue-detail.js'
import type { ProjectData } from '../../lib/views/project-data.js'
import { standupView } from '../../lib/views/standup-view.js'
import { BoardScreen } from '../screens/board-screen.js' import { BoardScreen } from '../screens/board-screen.js'
import { CalibrationScreen } from '../screens/calibration-screen.js' import { CalibrationScreen } from '../screens/calibration-screen.js'
import { CaptureScreen } from '../screens/capture-screen.js' import { CaptureScreen } from '../screens/capture-screen.js'
@@ -37,7 +19,8 @@ import { SettingsScreen } from '../screens/settings-screen.js'
import { StandupScreen } from '../screens/standup-screen.js' import { StandupScreen } from '../screens/standup-screen.js'
import { Icon, Switch } from '../ui/index.js' import { Icon, Switch } from '../ui/index.js'
import { ChatPanel } from './chat-panel.js' import { ChatPanel } from './chat-panel.js'
import { OfflineBanner } from './states.js' import { PlaceholderScreen } from './placeholder-screen.js'
import { OfflineBanner, StatesScreen } from './states.js'
type View = type View =
| 'standup' | 'standup'
@@ -48,6 +31,9 @@ type View =
| 'runway' | 'runway'
| 'directives' | 'directives'
| 'settings' | 'settings'
| 'states'
| 'firstrun'
| 'primitives'
| 'issue' | 'issue'
| 'calibration' | 'calibration'
| 'milestone' | 'milestone'
@@ -59,140 +45,68 @@ interface NavEntry {
count?: number | null count?: number | null
} }
// which phase builds each not-yet-real view (shown on its placeholder)
const PHASE: Partial<Record<View, string>> = {
standup: 'P3-3',
focus: 'P3-3',
inbox: 'P3-6',
capture: 'P3-7',
board: 'P3-4',
runway: 'P3-5',
directives: 'P3-8',
settings: 'P3-8',
firstrun: 'P3-8',
issue: 'P3-6',
calibration: 'P3-5',
milestone: 'P3-5',
}
const TITLE: Partial<Record<View, string>> = {
standup: 'Standup',
focus: 'Morning service',
inbox: 'Inbox',
capture: 'Capture',
board: 'The pot',
runway: 'Runway',
directives: 'Directives',
settings: 'Settings',
firstrun: 'First run',
issue: 'Issue',
calibration: 'Calibration',
milestone: 'Milestone',
}
const INBOX_UNREAD = 3
export function AppShell() { export function AppShell() {
const [view, setView] = useState<View>('focus') const [view, setView] = useState<View>('focus')
const [prevView, setPrevView] = useState<View>('focus') const [prevView, setPrevView] = useState<View>('focus')
const [dark, setDark] = useState(false) const [dark, setDark] = useState(false)
const [offline, setOffline] = useState(false)
const [issue, setIssue] = useState<IssueRef | null>(null) const [issue, setIssue] = useState<IssueRef | null>(null)
const [readIds, setReadIds] = useState<number[]>([]) const [readIds, setReadIds] = useState<number[]>([])
const [milestoneId, setMilestoneId] = useState<number | null>(null) const backlog = useBacklog()
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null)
const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
const [backlog, refetchBacklog] = useBacklog()
// Real connectivity: the reconcile serves the stale cache (or errors with no
// cache) only when gitea is unreachable. No manual toggle — this is the truth.
const offline = backlog.status === 'error' || (backlog.status === 'ready' && backlog.stale)
const staleSince = backlog.status === 'ready' ? backlog.savedAt : undefined
const capacityMembers = useCapacity()
const workers = capacityWorkers(capacityMembers)
const boardColumns = const boardColumns =
backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
const focus = const focus =
backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps, backlog.timelines) : undefined backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps, backlog.timelines) : undefined
const calibration =
backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined
const forecast = const forecast =
backlog.status === 'ready' backlog.status === 'ready' ? (forecastBacklog(backlog.issues, backlog.deps) ?? undefined) : undefined
? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model, workers) ?? undefined)
: undefined
const runwayMilestones =
backlog.status === 'ready'
? runwayView(backlog.issues, backlog.milestones, backlog.deps, new Date(), workers)
: undefined
const milestone =
backlog.status === 'ready' && milestoneId != null
? (milestoneView(
milestoneId,
backlog.issues,
backlog.milestones,
backlog.deps,
backlog.timelines,
calibration?.model,
new Date(),
workers,
) ?? undefined)
: undefined
// The uniform input every real-data view builder consumes. Assembled once from
// the reconciled backlog + capacity; undefined until the first reconcile lands.
const projectData: ProjectData | undefined =
backlog.status === 'ready'
? {
issues: backlog.issues,
milestones: backlog.milestones,
deps: backlog.deps,
timelines: backlog.timelines,
calibration: calibration?.model,
workers,
today: new Date(),
}
: undefined
const issueDetail =
projectData && issue ? (issueDetailView(issue.id, projectData) ?? undefined) : undefined
const standup = projectData ? standupView(projectData) : undefined
const inbox = projectData ? inboxView(projectData) : undefined
const gantt = projectData ? ganttView(projectData) : undefined
const depsGraph = projectData ? depsGraphView(projectData) : undefined
// The rail's unread badge tracks the real inbox once reconciled (still-unread
// minus what's been opened); null until the first reconcile lands.
const inboxUnread = inbox
? inbox.filter((n) => n.unread && !readIds.includes(n.id)).length
: null
// The real connected host (from the saved config), for the rail's status line.
const hostLabel = (() => {
try {
return pubConfig?.baseUrl ? new URL(pubConfig.baseUrl).host : 'gitea'
} catch {
return pubConfig?.baseUrl ?? 'gitea'
}
})()
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
}, [dark]) }, [dark])
// Gate: demo (e2e serves the demo snapshot) or configured → the shell;
// otherwise → the connect screen (each teammate brings their own token).
useEffect(() => {
window.commitea.gitea
.status()
.then((s) => {
setGate(s.demo || s.configured ? 'ready' : 'connect')
})
.catch(() => setGate('connect'))
window.commitea.config.get().then(setPubConfig).catch(() => {})
// Assignable people for the Adjust dialog's assignee picker (empty when demo/unconfigured).
window.commitea.gitea.collaborators().then(setCollaborators).catch(() => {})
}, [])
// The open issue's current assignee + milestone (from the reconciled backlog),
// so the Adjust dialog can preselect and diff them. Milestones list feeds its picker.
const currentIssue =
backlog.status === 'ready' && issue ? backlog.issues.find((i) => i.number === issue.id) : undefined
const milestoneOptions =
backlog.status === 'ready' ? backlog.milestones.map((m) => ({ id: m.id, title: m.title })) : []
const openIssue = (ref: IssueRef) => { const openIssue = (ref: IssueRef) => {
if (view !== 'issue') setPrevView(view) if (view !== 'issue') setPrevView(view)
// Callers into the sidecar (e.g. a "blocks #N" chip) may know only the id. setIssue(ref)
// Backfill the real title + labels from the reconciled backlog so the header
// isn't blank; fall back to whatever the caller passed.
let full = ref
if (backlog.status === 'ready' && (!ref.title || !ref.labels)) {
const found = backlog.issues.find((i) => i.number === ref.id)
if (found) full = { ...ref, id: found.number, title: found.title, labels: found.labels }
}
setIssue(full)
setView('issue') setView('issue')
} }
// The write path: apply through the bridge, reflect the new labels on the open
// issue immediately, and re-reconcile so the board + forecast catch up.
const applyChange = async (change: IssueChange) => {
const res = await window.commitea.gitea.applyChange(change)
if (res.ok) {
setIssue((cur) => (cur && cur.id === res.issue.number ? { ...cur, labels: res.issue.labels } : cur))
refetchBacklog()
}
return res
}
const NAV: NavEntry[] = [ const NAV: NavEntry[] = [
{ id: 'standup', label: 'Standup', icon: 'sun' }, { id: 'standup', label: 'Standup', icon: 'sun' },
{ id: 'focus', label: 'Morning service', icon: 'coffee' }, { id: 'focus', label: 'Morning service', icon: 'coffee' },
{ id: 'inbox', label: 'Inbox', icon: 'bell', count: inboxUnread || null }, { id: 'inbox', label: 'Inbox', icon: 'bell', count: INBOX_UNREAD || null },
{ id: 'capture', label: 'Capture', icon: 'plus' }, { id: 'capture', label: 'Capture', icon: 'plus' },
{ id: 'board', label: 'The pot', icon: 'square-kanban' }, { id: 'board', label: 'The pot', icon: 'square-kanban' },
{ id: 'runway', label: 'Runway', icon: 'chart-line' }, { id: 'runway', label: 'Runway', icon: 'chart-line' },
@@ -250,14 +164,12 @@ export function AppShell() {
case 'focus': case 'focus':
return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} /> return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} />
case 'standup': case 'standup':
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} data={standup} /> return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
case 'board': case 'board':
return ( return (
<BoardScreen <BoardScreen
onOpenIssue={openIssue} onOpenIssue={openIssue}
columns={boardColumns} columns={boardColumns}
gantt={gantt}
deps={depsGraph}
loading={backlog.status === 'loading'} loading={backlog.status === 'loading'}
/> />
) )
@@ -265,27 +177,13 @@ export function AppShell() {
return ( return (
<RunwayScreen <RunwayScreen
onOpenCalibration={() => setView('calibration')} onOpenCalibration={() => setView('calibration')}
onOpenMilestone={(id) => { onOpenMilestone={() => setView('milestone')}
setMilestoneId(id ?? null)
setView('milestone')
}}
calibration={
calibration
? {
n: calibration.model.n,
coldStart: calibration.model.coldStart,
excludedSameDay: calibration.coverage.excludedSameDay,
}
: undefined
}
milestones={runwayMilestones}
capacity={capacityMembers}
/> />
) )
case 'calibration': case 'calibration':
return <CalibrationScreen onBack={() => setView('runway')} data={calibration?.data} /> return <CalibrationScreen onBack={() => setView('runway')} />
case 'milestone': case 'milestone':
return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} data={milestone} /> return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} />
case 'inbox': case 'inbox':
return ( return (
<InboxScreen <InboxScreen
@@ -293,7 +191,6 @@ export function AppShell() {
onOpenDirectives={() => setView('directives')} onOpenDirectives={() => setView('directives')}
readIds={readIds} readIds={readIds}
setReadIds={setReadIds} setReadIds={setReadIds}
data={inbox}
/> />
) )
case 'capture': case 'capture':
@@ -301,58 +198,23 @@ export function AppShell() {
case 'directives': case 'directives':
return <DirectivesScreen /> return <DirectivesScreen />
case 'settings': case 'settings':
return ( return <SettingsScreen dark={dark} setDark={setDark} />
<SettingsScreen
dark={dark}
setDark={setDark}
connection={pubConfig}
onReconnect={() => setGate('connect')}
onDisconnect={() => {
void window.commitea.config.clear().then(() => {
setPubConfig(null)
setGate('connect')
})
}}
/>
)
case 'issue': case 'issue':
return issue ? ( return issue ? (
<IssueScreen <IssueScreen issue={issue} onBack={() => setView(prevView)} onOpenIssue={openIssue} />
issue={issue}
detail={issueDetail}
onBack={() => setView(prevView)}
onOpenIssue={openIssue}
canWrite={backlog.status === 'ready'}
onApplyChange={applyChange}
collaborators={collaborators}
milestones={milestoneOptions}
currentAssignee={currentIssue?.assignee ?? null}
currentMilestoneId={currentIssue?.milestone?.id ?? null}
/>
) : null ) : null
case 'states':
return <StatesScreen onCapture={() => setView('capture')} />
case 'primitives':
return <PrimitivesGallery />
default: default:
return null return <PlaceholderScreen title={TITLE[view] ?? view} phase={PHASE[view] ?? 'a later phase'} />
} }
} }
// Connection gate (real onboarding) comes before everything else. // First run is full-window — no rail, no chat panel
if (gate === 'checking') { if (view === 'firstrun') {
return <div style={{ height: '100vh', background: 'var(--surface-app)' }} /> return <OnboardingScreen onDone={(dest) => setView(dest)} />
}
if (gate === 'connect') {
const onConnected = (dest?: 'focus' | 'capture') => {
window.commitea.config.get().then(setPubConfig).catch(() => {})
if (dest) setView(dest)
setGate('ready')
refetchBacklog()
}
// First run (no saved config) → the full live wizard; reconnecting/editing an
// existing connection → the compact card.
return pubConfig ? (
<ConnectScreen existing={pubConfig} onConnected={() => onConnected()} />
) : (
<OnboardingScreen onConnected={onConnected} />
)
} }
return ( return (
@@ -392,25 +254,27 @@ export function AppShell() {
<div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} /> <div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} />
<NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} /> <NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} />
<NavItem item={{ id: 'firstrun', label: 'First run', icon: 'play' }} />
<NavItem item={{ id: 'states', label: 'States', icon: 'circle-dashed' }} />
<NavItem item={{ id: 'primitives', label: 'Primitives', icon: 'layers' }} />
<div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div <button
title={ type="button"
backlog.status === 'loading' onClick={() => setOffline((o) => !o)}
? `Connecting to ${hostLabel}` title="Toggle the connection (demo)"
: offline aria-label={offline ? 'Connection: offline' : 'Connection: online'}
? `Offline — reading from cache${staleSince ? ` (since ${new Date(staleSince).toLocaleString()})` : ''}`
: `Connected to ${hostLabel}`
}
aria-label={
backlog.status === 'loading' ? 'Connection: connecting' : offline ? 'Connection: offline' : 'Connection: online'
}
style={{ style={{
font: '400 11px var(--font-mono)', font: '400 11px var(--font-mono)',
color: 'var(--ink-3)', color: 'var(--ink-3)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
textAlign: 'left',
}} }}
> >
<span <span
@@ -418,13 +282,12 @@ export function AppShell() {
width: 6, width: 6,
height: 6, height: 6,
borderRadius: '50%', borderRadius: '50%',
background: background: offline ? 'var(--danger)' : 'var(--ok)',
backlog.status === 'loading' ? 'var(--warn)' : offline ? 'var(--danger)' : 'var(--ok)',
display: 'inline-block', display: 'inline-block',
}} }}
/> />
{hostLabel} gitea.stephenmann.io
</div> </button>
<Switch <Switch
label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>} label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>}
checked={dark} checked={dark}
@@ -450,7 +313,7 @@ export function AppShell() {
</div> </div>
</main> </main>
<ChatPanel onOpenDirectives={() => setView('directives')} offline={offline} onApplyChange={applyChange} /> <ChatPanel onOpenDirectives={() => setView('directives')} offline={offline} />
</div> </div>
) )
} }

View File

@@ -1,40 +1,39 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import type { IssueChange } from '@commitea/core' import { CANNED_REPLY, CHAT, type ChatMessage } from '../../data/fixtures.js'
import { Icon, IconButton } from '../ui/index.js'
import { useChat } from '../../lib/use-chat.js'
import { Button, Icon, IconButton } from '../ui/index.js'
/** /**
* Reginald's panel — chat is the write-path (decisions.md D1). Wired to the * Reginald's panel — chat is the write-path (decisions.md D1). This is the P3-2
* model bridge via `useChat`: a configured model drives a real agent turn * fixture shell: it echoes a canned reply so the layout + interactions are real,
* (query_project + prose, propose_change for edits); otherwise it echoes the * but no model is wired. P4 replaces `send` with the model router + tools.
* scripted fixture reply. Proposed changes are approved inline here — the write
* runs through `onApplyChange`, the same guarded handler the Issue screen uses.
*/ */
export interface ChatPanelProps { export interface ChatPanelProps {
onOpenDirectives?: () => void onOpenDirectives?: () => void
offline?: boolean offline?: boolean
onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>
} }
export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPanelProps) { export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
const { msgs, thinking, live, model, steps, proposals, streaming, send: sendChat, approve, dismiss } = useChat(onApplyChange) const [msgs, setMsgs] = useState<ChatMessage[]>(CHAT)
// shorten "google/gemma-4-26b-a4b-qat" → "gemma-4-26b" for the header chip
const modelLabel = model ? (model.split('/').pop() ?? model).replace(/-(qat|instruct|it|gguf)$/i, '') : 'gemma-4'
const [text, setText] = useState('') const [text, setText] = useState('')
const [thinking, setThinking] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight if (el) el.scrollTop = el.scrollHeight
}, [msgs, thinking, streaming]) }, [msgs, thinking])
const send = () => { const send = () => {
const t = text.trim() const t = text.trim()
if (!t) return if (!t) return
setMsgs((m) => [...m, { from: 'user', text: t }])
setText('') setText('')
sendChat(t) setThinking(true)
setTimeout(() => {
setThinking(false)
setMsgs((m) => [...m, { from: 'agent', text: CANNED_REPLY }])
}, 900)
} }
return ( return (
@@ -62,7 +61,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
<Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} /> <Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} />
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span> <span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}> <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
{offline ? 'offline · queueing' : live ? `${modelLabel} · local` : 'no model'} {offline ? 'offline · queueing' : 'gemma-4b · local'}
</span> </span>
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} /> <IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
</header> </header>
@@ -98,46 +97,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
The model is away from its desk. Reads still work; writes will wait their turn. The model is away from its desk. Reads still work; writes will wait their turn.
</div> </div>
) : null} ) : null}
{streaming ? ( {thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering</div> : null}
<div style={{ font: 'var(--text-agent)', color: 'var(--ink-1)', lineHeight: 1.55 }}>
{streaming}
<span style={{ opacity: 0.5 }}></span>
</div>
) : thinking ? (
<div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering</div>
) : null}
{!thinking && steps.length ? (
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', display: 'flex', alignItems: 'center', gap: 5 }}>
<Icon name="eye" size={11} /> consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project').replace('propose_change', 'the labels').replace('record_directive', 'the directive ledger')))).join(', ')}
</div>
) : null}
{proposals.map((p) => (
<div
key={`${p.change.issue}:${p.change.kind}`}
style={{
border: '1px solid var(--line-2)',
borderRadius: 'var(--radius-2)',
background: 'var(--paper-0)',
padding: '10px 12px',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 'var(--letter-spacing-wide)' }}>
Proposed · #{p.change.issue}
</div>
<div style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>{p.summary}</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" onClick={() => approve(p)} disabled={offline}>
Approve
</Button>
<Button size="sm" variant="ghost" onClick={() => dismiss(p)}>
Dismiss
</Button>
</div>
</div>
))}
</div> </div>
<div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}> <div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}>
@@ -161,8 +121,8 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
send() send()
} }
}} }}
placeholder={offline ? 'Writes wait for the connection…' : live ? 'Tell me what to do…' : 'Add a Model URL in Settings…'} placeholder={offline ? 'Writes wait for the connection…' : 'Tell me what to do…'}
disabled={offline || !live} disabled={offline}
rows={1} rows={1}
style={{ style={{
flex: 1, flex: 1,
@@ -180,15 +140,15 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
type="button" type="button"
onClick={send} onClick={send}
aria-label="Send" aria-label="Send"
disabled={offline || !live} disabled={offline}
style={{ style={{
width: 30, width: 30,
height: 30, height: 30,
borderRadius: 'var(--radius-2)', borderRadius: 'var(--radius-2)',
border: 'none', border: 'none',
cursor: offline || !live ? 'not-allowed' : 'pointer', cursor: offline ? 'not-allowed' : 'pointer',
background: offline || !live ? 'var(--paper-3)' : 'var(--accent)', background: offline ? 'var(--paper-3)' : 'var(--accent)',
color: offline || !live ? 'var(--ink-3)' : 'var(--ink-inverse)', color: offline ? 'var(--ink-3)' : 'var(--ink-inverse)',
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',

View File

@@ -0,0 +1,41 @@
import { Button } from '../ui/index.js'
import { EmptyState } from './states.js'
/**
* Stand-in for views not yet built (Focus, Board, Runway, …). Keeps navigation
* live so the shell is demonstrable before the real screens land in P3-3+.
* `onOpenIssue`, when given, exercises the shell's issue drill-in + back-stack;
* real screens replace this with actual issue rows in P3-3+.
*/
export function PlaceholderScreen({
title,
phase,
onOpenIssue,
}: {
title: string
phase: string
onOpenIssue?: () => void
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>{title}</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
arrives in {phase} · the shell and navigation are live
</p>
</header>
<EmptyState
icon="coffee"
title="Not yet poured"
line="This view is on the boil for a later phase. The rail, routing, theme, and Reginalds panel are already working around it."
/>
{onOpenIssue ? (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Button variant="ghost" iconRight="arrow-right" onClick={onOpenIssue}>
Preview an issue page
</Button>
</div>
) : null}
</div>
)
}

View File

@@ -1,11 +1,11 @@
import React from 'react' import React from 'react'
import { Button, Icon } from '../ui/index.js' import { Badge, Button, Icon } from '../ui/index.js'
/** /**
* Shared empty / trouble states, reused across real screens: EmptyState for * Shared empty/trouble states + the States gallery screen, ported from the
* "nothing here yet", OfflineBanner when a reconcile is serving the stale cache, * handoff. EmptyState/OfflineBanner/ModelAwayState are reused across real
* ModelAwayState when Reginald's model endpoint is unconfigured/unreachable. * screens as they land; StatesScreen is the specimen gallery.
*/ */
export interface EmptyStateProps { export interface EmptyStateProps {
@@ -93,6 +93,9 @@ export function ModelAwayState() {
</span> </span>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-2)' }}>Reginald</span> <span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-2)' }}>Reginald</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>model offline</span> <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>model offline</span>
<span style={{ marginLeft: 'auto' }}>
<Badge>queued: 1 directive</Badge>
</span>
</div> </div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
The model is away from its desk. Reads still work; writes will wait their turn. The model is away from its desk. Reads still work; writes will wait their turn.
@@ -100,3 +103,130 @@ export function ModelAwayState() {
</div> </div>
) )
} }
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{label}</span>
<div
style={{
border: '1px dashed var(--line-2)',
borderRadius: 'var(--radius-3)',
background: 'var(--surface-card)',
overflow: 'hidden',
}}
>
{children}
</div>
</div>
)
}
export function StatesScreen({ onCapture }: { onCapture?: () => void }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>States</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
empty &amp; trouble · specimens as wired in the app
</p>
</header>
<p
style={{
font: 'var(--text-overline)',
letterSpacing: 'var(--letter-spacing-wide)',
textTransform: 'uppercase',
color: 'var(--ink-3)',
margin: '4px 0 0',
}}
>
Empty
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<Specimen label="the pot · no issues">
<EmptyState
compact
icon="inbox"
title="The pot is empty"
line="Tell me what youre planning and Ill draw up the tickets."
action="Start a capture"
onAction={onCapture}
/>
</Specimen>
<Specimen label="morning service · nothing scheduled">
<EmptyState
compact
icon="coffee"
title="Nothing to pour"
line="Capture some work, or enjoy the silence — it never lasts."
/>
</Specimen>
<Specimen label="directives · no entries">
<EmptyState
compact
icon="flag"
title="No directives yet"
line="When you overrule the scheduler, it goes on the record here — who, when, what, why."
/>
</Specimen>
<Specimen label="board search · no match">
<EmptyState
compact
icon="search"
title="Nothing by that name"
line="The pot holds 24 issues; none of them answer to that."
/>
</Specimen>
</div>
<p
style={{
font: 'var(--text-overline)',
letterSpacing: 'var(--letter-spacing-wide)',
textTransform: 'uppercase',
color: 'var(--ink-3)',
margin: '8px 0 0',
}}
>
Trouble
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Specimen label="gitea unreachable · banner above main content (toggle the rails connection dot to see it live)">
<div style={{ padding: 12 }}>
<OfflineBanner />
</div>
</Specimen>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<Specimen label="reginalds panel · model offline">
<ModelAwayState />
</Specimen>
<Specimen label="webhooks down · poll fallback">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px' }}>
<Badge tone="warn" dot>
webhooks down
</Badge>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)' }}>
polling every 2 min · updates may lag
</span>
</div>
</Specimen>
</div>
<Specimen label="first reconcile failed · full-screen">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 20px' }}>
<EmptyState
compact
icon="refresh-cw"
title="The reconcile failed"
line="Gitea answered, then hung up mid-sentence. Your cache is intact; nothing human is lost."
/>
<div style={{ display: 'flex', gap: 8, marginTop: -6 }}>
<Button>Try again</Button>
<Button variant="ghost">Work from cache</Button>
</div>
</div>
</Specimen>
</div>
</div>
)
}

View File

@@ -12,7 +12,6 @@ export * from './tag.js'
export * from './card.js' export * from './card.js'
export * from './tabs.js' export * from './tabs.js'
export * from './input.js' export * from './input.js'
export * from './select.js'
export * from './checkbox.js' export * from './checkbox.js'
export * from './radio.js' export * from './radio.js'
export * from './switch.js' export * from './switch.js'

View File

@@ -18,8 +18,6 @@ export interface InputProps {
value?: string; value?: string;
defaultValue?: string; defaultValue?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void; onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
disabled?: boolean; disabled?: boolean;
type?: string; type?: string;
style?: React.CSSProperties; style?: React.CSSProperties;

View File

@@ -0,0 +1,528 @@
/**
* Demo fixtures mirrored from the design handoff's `data.js`. Stand-ins until
* P1 fills the SQLite cache and P4 wires the real agent. Replace, don't extend
* into production code.
*/
export interface ChatMessage {
from: 'agent' | 'user'
text: string
}
export const CHAT: ChatMessage[] = [
{ from: 'agent', text: 'Morning service. Two things drifted overnight; one needs your opinion.' },
{
from: 'agent',
text: '#84 grew a dependency on #92 — Ive reordered. And #87 has been steeping for four days; it blocks two others. Worth a look.',
},
{ from: 'user', text: 'Push the calibration work to next week, pilots come first' },
{
from: 'agent',
text: 'Done — #78 moves to next week. Milestone Beta is unmoved; the cone doesnt care for calibration either. Logged as a directive.',
},
]
export const CANNED_REPLY =
'Noted and logged as a directive. The scheduler is re-running — Ill show you the consequence diff in a moment.'
export const TODAY = 'Tuesday 7 July 2026'
export interface FocusIssue {
id: number
title: string
labels: string[]
steeping?: string
rationale: string
}
export interface FocusData {
now: FocusIssue
next: FocusIssue
later: FocusIssue
}
export const FOCUS: FocusData = {
now: {
id: 87,
title: 'Fix lifecycle inference on merge events',
labels: ['est/2d', 'p/1'],
steeping: '4d',
rationale: 'It blocks #91 and #92. Id take it first — the critical path agrees with me.',
},
next: {
id: 91,
title: 'Webhook listener: reconcile on reconnect',
labels: ['est/3d', 'p/2'],
rationale: 'Ready the moment #87 lands. The estimate is yours; history says add a day.',
},
later: {
id: 78,
title: 'Calibration store cold-start distributions',
labels: ['est/5d', 'p/3'],
rationale: 'Nothing depends on it yet. It can steep.',
},
}
export interface DriftItem {
tone: 'ok' | 'warn' | 'danger'
text: string
delta: string
}
export interface PlanItem {
who: string
pick: string
title: string
why: string
}
export interface Nag {
id: number
days: string
blocks: string[]
text: string
}
export interface StandupData {
date: string
drift: DriftItem[]
plan: PlanItem[]
nag: Nag
}
export const STANDUP: StandupData = {
date: 'Tuesday 7 July 2026',
drift: [
{ tone: 'warn', text: '#84 grew a dependency on #92 overnight.', delta: 'reordered · no date impact' },
{ tone: 'warn', text: '#92 has sat in review for two days.', delta: 'Beta 80% +1d if idle past Thu' },
{ tone: 'ok', text: 'Webhook outage 02:1402:31; full reconcile ran.', delta: 'nothing lost' },
],
plan: [
{
who: 'Stephen',
pick: '#87',
title: 'Fix lifecycle inference on merge events',
why: 'It blocks two others and the critical path runs straight through it.',
},
{
who: 'Ana K.',
pick: '#84',
title: 'Capacity model: focus factor per person',
why: 'Already steeping — finish it before anything new is poured.',
},
],
nag: {
id: 87,
days: '4d',
blocks: ['#91', '#92'],
text: 'Four days is a long steep. It blocks #91 and #92 — worth a look before it stains.',
},
}
/** Lightweight issue reference handed to the shell's drill-in from any screen. */
export interface IssueRef {
id: number
title?: string
labels?: string[]
days?: string
rationale?: string
}
// ---- Board (The pot) ----
export interface BoardIssue {
id: number
title: string
labels: string[]
who: string
blocked?: boolean
days?: string
pr?: string
}
export interface BoardColumn {
id: string
label: string
issues: BoardIssue[]
}
export const COLUMNS: BoardColumn[] = [
{
id: 'diagnosis',
label: 'Diagnosis',
issues: [
{ id: 102, title: 'Scheduler ignores standing allocation slices', labels: ['p/2'], who: 'SM' },
{ id: 103, title: 'Dark theme: cone fill too faint', labels: ['p/4'], who: 'SM' },
],
},
{
id: 'triage',
label: 'Triage',
issues: [
{ id: 99, title: 'Directive log: conflict-free merge test', labels: ['est/1d', 'p/2'], who: 'SM' },
{ id: 96, title: 'Label schema bootstrap for new repos', labels: ['est/2d', 'p/3'], who: 'AK' },
{ id: 78, title: 'Calibration store cold-start distributions', labels: ['est/5d', 'p/3'], who: 'SM' },
],
},
{
id: 'steeping',
label: 'Steeping',
issues: [
{ id: 87, title: 'Fix lifecycle inference on merge events', labels: ['est/2d', 'p/1'], who: 'SM', blocked: false, days: '4d' },
{ id: 84, title: 'Capacity model: focus factor per person', labels: ['est/3d', 'p/2'], who: 'AK', days: '1d' },
],
},
{
id: 'review',
label: 'In review',
issues: [{ id: 92, title: 'Monte Carlo engine: percentile bands', labels: ['est/5d', 'p/1'], who: 'SM', pr: '#141' }],
},
{
id: 'done',
label: 'Done',
issues: [
{ id: 71, title: 'Gitea client: token auth + retries', labels: ['est/2d', 'p/2'], who: 'SM' },
{ id: 69, title: 'pm-state repo bootstrap', labels: ['est/1d', 'p/1'], who: 'SM' },
{ id: 65, title: 'Electron shell + window state', labels: ['est/3d', 'p/2'], who: 'AK' },
],
},
]
// ---- Gantt ----
export interface GanttWeek {
at: number
label: string
}
export interface GanttRow {
id: number
title: string
who: string
state: 'done' | 'steeping' | 'review' | 'scheduled'
start: number
end: number
p80?: number
crit?: boolean
}
export interface GanttData {
days: number
weeks: GanttWeek[]
today: number
band: { from: number; to: number; label: string }
due: { at: number; label: string }
rows: GanttRow[]
}
export const GANTT: GanttData = {
days: 42,
weeks: [
{ at: 0, label: 'Feb 2' },
{ at: 7, label: 'Feb 9' },
{ at: 14, label: 'Feb 16' },
{ at: 21, label: 'Feb 23' },
{ at: 28, label: 'Mar 2' },
{ at: 35, label: 'Mar 9' },
{ at: 41, label: 'Mar 15' },
],
today: 8,
band: { from: 29, to: 38, label: '80% · Mar 312' },
due: { at: 41, label: 'Beta due' },
rows: [
{ id: 71, title: 'Gitea client: token auth + retries', who: 'SM', state: 'done', start: 0, end: 4 },
{ id: 69, title: 'pm-state repo bootstrap', who: 'SM', state: 'done', start: 0, end: 1 },
{ id: 87, title: 'Fix lifecycle inference on merge events', who: 'SM', state: 'steeping', start: 4, end: 9, p80: 11, crit: true },
{ id: 92, title: 'Monte Carlo engine: percentile bands', who: 'SM', state: 'review', start: 9, end: 18, p80: 21, crit: true },
{ id: 91, title: 'Webhook listener: reconcile on reconnect', who: 'AK', state: 'scheduled', start: 9, end: 14, p80: 16 },
{ id: 99, title: 'Directive log: conflict-free merge test', who: 'SM', state: 'scheduled', start: 10, end: 11 },
{ id: 96, title: 'Label schema bootstrap for new repos', who: 'AK', state: 'scheduled', start: 14, end: 16 },
{ id: 84, title: 'Capacity model: focus factor per person', who: 'AK', state: 'scheduled', start: 18, end: 23, p80: 26, crit: true },
{ id: 102, title: 'Scheduler ignores standing allocation slices', who: 'SM', state: 'scheduled', start: 23, end: 25, p80: 28, crit: true },
{ id: 78, title: 'Calibration store cold-start distributions', who: 'SM', state: 'scheduled', start: 25, end: 30 },
],
}
// ---- Dependencies ----
export interface DepNode {
id: number
title: string
tags: string[]
state: 'done' | 'steeping' | 'review' | 'triage' | 'diagnosis'
days?: string
col: number
row: number
rationale?: string
}
export interface DepEdge {
from: number
to: number | 'ms'
crit?: boolean
}
export interface DepsData {
nodes: DepNode[]
milestone: { name: string; due: string; col: number; row: number }
edges: DepEdge[]
critical: number[]
unattached: number[]
}
export const DEPS: DepsData = {
nodes: [
{ id: 71, title: 'Gitea client: token auth + retries', tags: ['est/2d'], state: 'done', col: 0, row: 0.2 },
{ id: 69, title: 'pm-state repo bootstrap', tags: ['est/1d'], state: 'done', col: 0, row: 1.9 },
{
id: 87,
title: 'Fix lifecycle inference on merge events',
tags: ['est/2d', 'p/1'],
state: 'steeping',
days: '4d',
col: 1,
row: 0.7,
rationale: 'It blocks #91 and #92. Id take it first — the critical path agrees with me.',
},
{ id: 99, title: 'Directive log: conflict-free merge test', tags: ['est/1d', 'p/2'], state: 'triage', col: 1, row: 1.9 },
{ id: 91, title: 'Webhook listener: reconcile on reconnect', tags: ['est/3d', 'p/2'], state: 'triage', col: 2, row: 0 },
{ id: 92, title: 'Monte Carlo engine: percentile bands', tags: ['est/5d', 'p/1'], state: 'review', col: 2, row: 1.4 },
{ id: 84, title: 'Capacity model: focus factor per person', tags: ['est/3d', 'p/2'], state: 'steeping', days: '1d', col: 3, row: 1.4 },
{ id: 102, title: 'Scheduler ignores standing allocation slices', tags: ['p/2'], state: 'diagnosis', col: 4, row: 1.4 },
],
milestone: { name: 'Beta', due: 'Mar 15', col: 5, row: 1.4 },
edges: [
{ from: 71, to: 87 },
{ from: 69, to: 99 },
{ from: 87, to: 91 },
{ from: 87, to: 92, crit: true },
{ from: 92, to: 84, crit: true },
{ from: 84, to: 102, crit: true },
{ from: 102, to: 'ms', crit: true },
],
critical: [87, 92, 84, 102],
unattached: [78, 96, 103, 65],
}
// ---- Runway ----
export interface RunwayMilestone {
name: string
due: string
hard: boolean
p80: string
pos: number
spread: number
tone: 'ok' | 'warn'
note: string
}
export const RUNWAY: RunwayMilestone[] = [
{ name: 'Beta', due: 'Mar 15', hard: false, p80: 'Mar 312', pos: 0.62, spread: 0.18, tone: 'ok', note: 'ahead' },
{ name: 'Pilot-ready', due: 'Apr 30', hard: true, p80: 'Apr 21 May 9', pos: 0.94, spread: 0.26, tone: 'warn', note: 'drifting' },
{ name: 'v1.0', due: 'Jun 12', hard: false, p80: 'May 30 Jun 20', pos: 0.88, spread: 0.3, tone: 'ok', note: 'on watch' },
]
export interface CapacityMember {
who: string
hours: string
focus: number
slices: string
}
export const CAPACITY: CapacityMember[] = [
{ who: 'Stephen', hours: '5.2h/day', focus: 0.65, slices: 'dev 70% · pilots 20% · compliance 10%' },
{ who: 'Ana K.', hours: '3.8h/day', focus: 0.8, slices: 'dev 100%' },
]
// ---- Calibration ----
export interface CalLabel {
label: string
n: number
median: string
bias: number | null
}
export interface CalPerson {
who: string
n: number
bias: number
note: string
}
export interface CalibrationData {
n: number
active: boolean
labels: CalLabel[]
people: CalPerson[]
scatter: number[][]
fit: number
effect: { raw: string; banded: string; p50: string }
}
export const CALIBRATION: CalibrationData = {
n: 27,
active: true,
labels: [
{ label: 'est/1d', n: 8, median: '1.1d', bias: 8 },
{ label: 'est/2d', n: 9, median: '2.4d', bias: 18 },
{ label: 'est/3d', n: 6, median: '3.7d', bias: 22 },
{ label: 'est/5d', n: 3, median: '6.5d', bias: 30 },
{ label: 'est/8d', n: 1, median: '8.5d', bias: null },
],
people: [
{ who: 'Stephen', n: 19, bias: 21, note: 'optimism grows with ticket size' },
{ who: 'Ana K.', n: 8, bias: 9, note: 'close to honest — suspicious' },
],
scatter: [
[1, 1], [1, 1.2], [1, 0.9], [1, 1.5], [1, 1.1], [1, 0.8], [1, 1.3], [1, 1.2],
[2, 2], [2, 2.5], [2, 3.1], [2, 2.2], [2, 2.6], [2, 1.8], [2, 2.4], [2, 2.9], [2, 2.3],
[3, 3.5], [3, 4.1], [3, 3.1], [3, 4.6], [3, 3.6], [3, 3.9],
[5, 6.1], [5, 7.2], [5, 6.4],
[8, 8.5],
],
fit: 1.18,
effect: { raw: '42d of estimates', banded: '80% band 4653d', p50: '48d' },
}
// ---- Inbox ----
export interface InboxIssueRef {
id: number
title: string
labels: string[]
days?: string
}
export interface InboxItem {
id: number
day: string
type: string
icon: string
tone: 'ok' | 'warn' | 'info' | 'neutral'
who?: string
text: string
detail: string
time: string
unread: boolean
to?: string
issue?: InboxIssueRef
}
export const INBOX: InboxItem[] = [
{ id: 1, day: 'Today', type: 'drift', icon: 'chart-line', tone: 'warn', text: 'Betas 80% window moved', detail: 'Mar 312 → Mar 514 · directive #007', time: '09:14', unread: true, to: 'directives' },
{ id: 2, day: 'Today', type: 'mention', icon: 'message-square', tone: 'info', who: 'Ana K.', text: 'mentioned you on #92', detail: '“percentile bands ready for a second pair of eyes”', time: '08:52', unread: true, issue: { id: 92, title: 'Monte Carlo engine: percentile bands', labels: ['est/5d', 'p/1'] } },
{ id: 3, day: 'Today', type: 'nag', icon: 'clock', tone: 'warn', text: '#87 is steeping · 4d', detail: 'blocks #91 and #92 — worth a look before it stains', time: '07:00', unread: true, issue: { id: 87, title: 'Fix lifecycle inference on merge events', labels: ['est/2d', 'p/1'], days: '4d' } },
{ id: 4, day: 'Yesterday', type: 'review', icon: 'git-pull-request', tone: 'info', text: 'PR #141 awaits review', detail: '#92 · idle two days', time: '16:20', unread: false, issue: { id: 92, title: 'Monte Carlo engine: percentile bands', labels: ['est/5d', 'p/1'] } },
{ id: 5, day: 'Yesterday', type: 'assignment', icon: 'user', tone: 'neutral', who: 'Ana K.', text: 'took #96 from the pot', detail: 'Label schema bootstrap for new repos', time: '11:03', unread: false, issue: { id: 96, title: 'Label schema bootstrap for new repos', labels: ['est/2d', 'p/3'] } },
{ id: 6, day: 'Yesterday', type: 'system', icon: 'refresh-cw', tone: 'ok', text: 'Webhook outage 02:1402:31', detail: 'full reconcile ran · nothing lost', time: '02:31', unread: false },
{ id: 7, day: 'Yesterday', type: 'milestone', icon: 'milestone', tone: 'ok', text: 'P2 — Scheduler closed two days early', detail: 'the calibration noticed. So did I.', time: '09:40', unread: false },
]
// ---- Issue detail ----
export interface IssueComment {
who: string
when: string
text: string
}
export interface IssueLifecycleStage {
stage: string
event: string
when: string
icon: string
done: boolean
}
export interface IssueDetail {
state: string
assignee: string
milestone: string
body: string
comments: IssueComment[]
lifecycle: IssueLifecycleStage[]
forecast: { p80: string; note: string }
blocks: number[]
blockedBy: number[]
note: string
}
export const ISSUE_DETAIL: Record<number, IssueDetail> = {
87: {
state: 'steeping',
assignee: 'Stephen',
milestone: 'Beta',
body: 'Squash-merges emit events out of order when CI runs long: the PR-closed webhook lands before the merge event, so lifecycle inference marks deploy before work-end and the actuals go negative. Regular merges infer correctly.',
comments: [
{ who: 'Ana K.', when: 'Feb 8 · 14:12', text: 'Repro: squash-merge only. Regular merges infer fine.' },
{ who: 'Stephen', when: 'Feb 9 · 09:30', text: 'Confirmed — the event order flips whenever CI takes more than ~5 minutes.' },
],
lifecycle: [
{ stage: 'Diagnosis', event: 'issue opened', when: 'Feb 2 · 09:14', icon: 'circle-dot', done: true },
{ stage: 'Triage', event: 'labeled est/2d · milestoned Beta', when: 'Feb 3 · 10:02', icon: 'tag', done: true },
{ stage: 'Work start', event: 'first commit ref a41f09', when: 'Feb 6 · 11:47', icon: 'git-commit-horizontal', done: true },
{ stage: 'Deploy', event: 'PR merged', when: 'pending', icon: 'git-merge', done: false },
{ stage: 'Complete', event: 'issue closed', when: 'pending', icon: 'circle-check', done: false },
],
forecast: { p80: 'done Feb 1113', note: 'from your est/2d history · n=14' },
blocks: [91, 92],
blockedBy: [],
note: 'It blocks #91 and #92. Id take it first — the critical path agrees with me.',
},
}
// ---- Directives ----
export interface DirectiveDiffRow {
tone: 'ok' | 'warn' | 'info' | 'danger'
change: string
from: string
to: string
}
export interface DirectivePending {
seq: number
who: string
when: string
what: string
diff: DirectiveDiffRow[]
}
export interface DirectiveEntry {
seq: number
who: string
when: string
what: string
why: string
status: string
consequence: string
}
export interface DirectivesData {
pending: DirectivePending | null
entries: DirectiveEntry[]
}
export const DIRECTIVES: DirectivesData = {
pending: {
seq: 7,
who: 'Stephen',
when: 'today 09:12',
what: 'Pilots before calibration — push #78 to next week.',
diff: [
{ tone: 'info', change: '#78 Calibration store', from: 'this week', to: 'wk of Feb 23' },
{ tone: 'warn', change: 'Beta · 80% window', from: 'Mar 312', to: 'Mar 514' },
{ tone: 'ok', change: "Today's plan", from: '#87', to: '#87 · unchanged' },
],
},
entries: [
{ seq: 6, who: 'Stephen', when: 'Feb 8 · 16:40', what: 'Ana takes nothing new until #84 lands.', why: 'context thrash', status: 'applied', consequence: 'WIP capped · v1.0 unmoved' },
{ seq: 5, who: 'Stephen', when: 'Feb 6 · 09:03', what: 'Ship Beta a week early.', why: 'board meeting', status: 'withdrawn', consequence: '80% would need scope 9d — withdrawn after diff' },
{ seq: 4, who: 'Stephen', when: 'Feb 3 · 11:21', what: 'deadline/hard on Pilot-ready.', why: 'contract date', status: 'applied', consequence: 'label applied · runway flag raised' },
{ seq: 3, who: 'Stephen', when: 'Jan 28 · 08:47', what: 'Webhook work ahead of UI polish.', why: '', status: 'applied', consequence: '#91 +2 ranks · Beta unmoved' },
{ seq: 2, who: 'Stephen', when: 'Jan 20 · 14:02', what: 'Estimates in days, never hours.', why: 'sanity', status: 'applied', consequence: 'label schema est/* confirmed' },
{ seq: 1, who: 'Stephen', when: 'Jan 19 · 09:00', what: 'CommiTea manages its own backlog.', why: 'dogfood', status: 'applied', consequence: 'stephen/commitea under management' },
],
}

View File

@@ -1,237 +0,0 @@
/**
* View-model types — the contract between the renderer's view builders
* (`lib/views/*`, `lib/backlog.ts`) and the screens that render their output.
* These are screen-shaped (post-builder) types only; there is no demo data here.
* Live data flows in through the main-process bridge and the builders; when a
* builder has nothing to produce, the screen renders an EmptyState.
*/
export interface ChatMessage {
from: 'agent' | 'user'
text: string
}
export interface FocusIssue {
id: number
title: string
labels: string[]
steeping?: string
rationale: string
}
export interface FocusData {
now: FocusIssue
next: FocusIssue
later: FocusIssue
}
export interface DriftItem {
tone: 'ok' | 'warn' | 'danger'
text: string
delta: string
}
export interface PlanItem {
who: string
pick: string
title: string
why: string
}
export interface Nag {
id: number
days: string
blocks: string[]
text: string
}
export interface StandupData {
date: string
drift: DriftItem[]
plan: PlanItem[]
nag: Nag
}
/** Lightweight issue reference handed to the shell's drill-in from any screen. */
export interface IssueRef {
id: number
title?: string
labels?: string[]
days?: string
rationale?: string
}
// ---- Board (The pot) ----
export interface BoardIssue {
id: number
title: string
labels: string[]
who: string
blocked?: boolean
days?: string
pr?: string
}
export interface BoardColumn {
id: string
label: string
issues: BoardIssue[]
}
// ---- Gantt ----
export interface GanttWeek {
at: number
label: string
}
export interface GanttRow {
id: number
title: string
who: string
state: 'done' | 'steeping' | 'review' | 'scheduled'
start: number
end: number
p80?: number
crit?: boolean
}
export interface GanttData {
days: number
weeks: GanttWeek[]
today: number
band: { from: number; to: number; label: string }
due: { at: number; label: string }
rows: GanttRow[]
}
// ---- Dependencies ----
export interface DepNode {
id: number
title: string
tags: string[]
state: 'done' | 'steeping' | 'review' | 'triage' | 'diagnosis'
days?: string
col: number
row: number
rationale?: string
}
export interface DepEdge {
from: number
to: number | 'ms'
crit?: boolean
}
export interface DepsData {
nodes: DepNode[]
milestone: { name: string; due: string; col: number; row: number }
edges: DepEdge[]
critical: number[]
unattached: number[]
}
// ---- Runway ----
export interface RunwayMilestone {
name: string
due: string
hard: boolean
p80: string
pos: number
spread: number
tone: 'ok' | 'warn'
note: string
/** gitea milestone id, for the drill-in. */
id?: number
}
export interface CapacityMember {
who: string
hours: string
focus: number
slices: string
}
// ---- Calibration ----
export interface CalLabel {
label: string
n: number
median: string
bias: number | null
}
export interface CalPerson {
who: string
n: number
bias: number
note: string
}
export interface CalibrationData {
n: number
active: boolean
labels: CalLabel[]
people: CalPerson[]
scatter: number[][]
fit: number
effect: { raw: string; banded: string; p50: string }
/** Closed+estimated issues that can't calibrate (same-day / 0-day closes). */
excludedSameDay: number
}
// ---- Inbox ----
export interface InboxIssueRef {
id: number
title: string
labels: string[]
days?: string
}
export interface InboxItem {
id: number
day: string
type: string
icon: string
tone: 'ok' | 'warn' | 'info' | 'neutral'
who?: string
text: string
detail: string
time: string
unread: boolean
to?: string
issue?: InboxIssueRef
}
// ---- Issue detail ----
export interface IssueComment {
who: string
when: string
text: string
}
export interface IssueLifecycleStage {
stage: string
event: string
when: string
icon: string
done: boolean
}
export interface IssueDetail {
state: string
assignee: string
milestone: string
body: string
comments: IssueComment[]
lifecycle: IssueLifecycleStage[]
forecast: { p80: string; note: string }
blocks: number[]
blockedBy: number[]
note: string
}

View File

@@ -1,134 +1,17 @@
import type { import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
AgentStep,
CapacityMember,
ChangeProposal,
ChatMessage,
CaptureProposal,
DependencyEdge,
DirectiveRecord,
GiteaIssue,
GiteaMilestone,
IssueChange,
LabelPlan,
LifecycleEvent,
ProposedIssue,
} from '@commitea/core'
/** The result of a write through the bridge. */ /** The gitea bridge exposed by the preload over IPC (main-process backed). */
export type ApplyChangeResult = export interface GiteaBridge {
| { ok: false; reason: 'unconfigured' } status(): Promise<{ configured: boolean; repo: string | null }>
// `plan` is present for label swaps (est/p); absent for field writes (assign, milestone). reconcile(): Promise<{
| { ok: true; plan?: LabelPlan; issue: GiteaIssue }
/** The result of filing captured issues. */
export type CreateIssuesResult =
| { ok: false; reason: 'unconfigured' }
| { ok: true; created: { number: number; title: string }[] }
/** The result of a capture_work decomposition. */
export type CaptureResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| ({ ok: true } & CaptureProposal)
/** A reconciled snapshot as it crosses the bridge. */
export interface SnapshotPayload {
configured: boolean configured: boolean
issues: GiteaIssue[] issues: GiteaIssue[]
milestones: GiteaMilestone[] milestones: GiteaMilestone[]
deps: DependencyEdge[] deps: DependencyEdge[]
/** Normalized lifecycle events keyed by issue number. */ /** Normalized lifecycle events keyed by issue number. */
timelines: Record<number, LifecycleEvent[]> timelines: Record<number, LifecycleEvent[]>
/** true when served from the persisted cache (offline / instant boot). */ }>
stale?: boolean
/** ISO time the persisted snapshot was reconciled (present on cached reads). */
savedAt?: string
}
/** Boot payload — the persisted snapshot, or a marker that there's none yet. */
export type BootPayload =
| { configured: false }
| { configured: true; cached: false }
| ({ configured: true; cached: true } & Omit<SnapshotPayload, 'configured'>)
/** The saved connection config (never includes the token). */
export interface PublicConfig {
baseUrl: string
owner: string
repo: string
pmStateRepo?: string
modelUrl?: string
hasToken: boolean
}
/** What the onboarding/settings form submits. */
export interface ConfigInput {
baseUrl: string
owner: string
repo: string
token: string
pmStateRepo?: string
modelUrl?: string
}
/** The result of token-scoped discovery for the onboarding pickers. */
export type DiscoverResult =
| { ok: false; error?: string }
| { ok: true; user: string; owners: string[]; reposByOwner: Record<string, string[]> }
/** What the first-run bootstrap changed (label schema + pm-state sidecar). */
export type BootstrapResult =
| { ok: false; error?: string }
| { ok: true; labels: { created: string[]; existing: string[] }; pmState: 'created' | 'exists'; pmStateRepo: string }
/** The config bridge for team onboarding. */
export interface ConfigBridge {
get(): Promise<PublicConfig | null>
discover(conn: { baseUrl: string; token: string }): Promise<DiscoverResult>
bootstrap(req: { baseUrl: string; token: string; owner: string; repo: string; underOrg: boolean }): Promise<BootstrapResult>
test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }>
set(cfg: ConfigInput): Promise<{ ok: boolean }>
clear(): Promise<{ ok: boolean }>
}
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
export interface GiteaBridge {
status(): Promise<{ configured: boolean; repo: string | null; demo: boolean }>
boot(): Promise<BootPayload>
reconcile(): Promise<SnapshotPayload>
getIssue(index: number): Promise<GiteaIssue | null> getIssue(index: number): Promise<GiteaIssue | null>
applyChange(change: IssueChange): Promise<ApplyChangeResult>
createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult>
collaborators(): Promise<{ login: string; name: string }[]>
}
/** One agent turn's result. */
export type ChatResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| { ok: true; content: string; steps: AgentStep[]; proposals: ChangeProposal[] }
/** The model bridge (Reginald) exposed by the preload over IPC. */
export interface ModelBridge {
status(): Promise<{ configured: boolean; model: string | null }>
chat(messages: ChatMessage[]): Promise<ChatResult>
capture(braindump: string): Promise<CaptureResult>
/** Subscribe to streamed prose tokens; returns an unsubscribe fn. */
onToken(cb: (delta: string) => void): () => void
}
/** The result of reading the directive ledger. */
export type DirectivesResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| { ok: true; directives: DirectiveRecord[] }
/** The result of reading the capacity config. */
export type CapacityResult =
| { ok: false; reason: 'unconfigured'; members: CapacityMember[] }
| { ok: true; members: CapacityMember[] }
/** The pm-state bridge (machine-derived state) exposed by the preload over IPC. */
export interface PmStateBridge {
directives(): Promise<DirectivesResult>
capacity(): Promise<CapacityResult>
} }
declare global { declare global {
@@ -136,9 +19,6 @@ declare global {
commitea: { commitea: {
platform: string platform: string
gitea: GiteaBridge gitea: GiteaBridge
model: ModelBridge
pmstate: PmStateBridge
config: ConfigBridge
} }
} }
} }

View File

@@ -1,38 +1,18 @@
import { import {
type CalibrationCoverage,
type CalibrationModel,
type CalibrationSample,
calibrationCoverage,
calibrationSamples,
type CapacityMember,
capacityPerWorkday,
COLD_START_THRESHOLD,
type DependencyEdge, type DependencyEdge,
fitCalibration,
forecast, forecast,
type Worker,
type GiteaIssue, type GiteaIssue,
type GiteaMilestone,
inferLifecycle, inferLifecycle,
type LifecycleColumn, type LifecycleColumn,
type LifecycleEvent, type LifecycleEvent,
type LifecycleInference, type LifecycleInference,
PRIOR_BUCKETS,
schedule, schedule,
type ScheduledItem, type ScheduledItem,
selectFocus, selectFocus,
toDurationModel,
workingDaysBetween,
} from '@commitea/core' } from '@commitea/core'
import { import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js'
type BoardColumn, import { type BurnUpData, buildBurnUpData } from './dates.js'
type BoardIssue,
type CalibrationData,
type FocusIssue,
type RunwayMilestone,
} from '../data/view-types.js'
import { addWorkingDays, type BurnUpData, buildBurnUpData, formatRange, formatShort } from './dates.js'
type Timelines = Record<number, LifecycleEvent[]> type Timelines = Record<number, LifecycleEvent[]>
@@ -112,127 +92,30 @@ function toSchedulable(issues: GiteaIssue[]) {
labels: i.labels, labels: i.labels,
estimateDays: i.facts.estimateDays, estimateDays: i.facts.estimateDays,
priority: i.facts.priority, priority: i.facts.priority,
assignee: i.assignee,
})) }))
} }
/** gitea CapacityMembers → scheduler lanes (person + throughput). */
export function capacityWorkers(members: CapacityMember[]): Worker[] {
return members.map((m) => ({ person: m.person, speed: capacityPerWorkday(m) }))
}
export interface ForecastView { export interface ForecastView {
scope: number scope: number
cone: BurnUpData cone: BurnUpData
p80Label: string p80Label: string
rangeLabel: string rangeLabel: string
/** true while the forecast still runs on code priors (calibration not yet trusted). */
coldStart: boolean
/** Closed-with-estimate issues feeding calibration so far. */
calibratedN: number
} }
/** /**
* Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored * Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored
* burn-up cone. When a calibration model is supplied and past cold-start, its * burn-up cone. Returns null when there's nothing to forecast (no open scope) —
* fitted params drive the sim. Returns null when there's nothing to forecast. * the UI then falls back to the demo cone. `today` is injectable for tests.
* `today` is injectable for tests.
*/ */
export function forecastBacklog( export function forecastBacklog(
issues: GiteaIssue[], issues: GiteaIssue[],
deps: DependencyEdge[], deps: DependencyEdge[],
today: Date = new Date(), today: Date = new Date(),
calibration?: CalibrationModel,
workers: Worker[] = [],
): ForecastView | null { ): ForecastView | null {
const model = calibration ? toDurationModel(calibration) : undefined const f = forecast(toSchedulable(issues), deps)
const f = forecast(toSchedulable(issues), deps, { ...(model ? { model } : {}), workers })
const cone = buildBurnUpData(f, today) const cone = buildBurnUpData(f, today)
if (!cone) return null if (!cone) return null
return { return { scope: f.scope, cone, p80Label: cone.p80Label, rangeLabel: cone.rangeLabel }
scope: f.scope,
cone,
p80Label: cone.p80Label,
rangeLabel: cone.rangeLabel,
coldStart: f.coldStart,
calibratedN: calibration?.n ?? 0,
}
}
/** Fit the calibration model from the closed backlog's inferred actuals (#1). */
export function calibrateBacklog(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): CalibrationModel {
return fitCalibration(calibrationSamples(issues, timelines, asOf))
}
/** Calibration model + its screen view in one pass over the closed backlog. */
export function backlogCalibration(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): { model: CalibrationModel; data: CalibrationData; coverage: CalibrationCoverage } {
const samples = calibrationSamples(issues, timelines, asOf)
const model = fitCalibration(samples)
const coverage = calibrationCoverage(issues, timelines, asOf)
return { model, coverage, data: calibrationData(model, samples, issues, coverage.excludedSameDay) }
}
const pctFromMu = (mu: number) => Math.round((Math.exp(mu) - 1) * 100)
/**
* Shape the calibration model + its samples into the screen's view. Buckets and
* people only earn a bias once their sample clears the fit floor; everything
* degrades honestly on a thin (cold-start) dataset.
*/
export function calibrationData(
model: CalibrationModel,
samples: CalibrationSample[],
openIssues: GiteaIssue[],
excludedSameDay = 0,
): CalibrationData {
const labels = PRIOR_BUCKETS.map((b) => {
const inBucket = samples.filter((s) => s.bucket === b)
const fit = model.byBucket[b]
const mu = fit ? fit.mu : model.global.mu
return {
label: `est/${b}d`,
n: fit ? fit.n : inBucket.length,
median: inBucket.length ? `${(b * Math.exp(mu)).toFixed(1)}d` : '—',
bias: fit ? pctFromMu(fit.mu) : null,
}
})
const people = Object.entries(model.byPerson).map(([who, pb]) => ({
who,
n: pb.n,
bias: pctFromMu(model.global.mu + pb.biasMu),
note: '',
}))
const openEst = openIssues
.filter((i) => i.state === 'open')
.reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0)
const effect = model.coldStart
? { raw: `${model.n}/${COLD_START_THRESHOLD} estimated closes`, banded: 'cold-start priors', p50: '—' }
: {
raw: `${openEst}d estimated`,
banded: `×${Math.exp(model.global.mu).toFixed(2)} median drift`,
p50: `${Math.round(openEst * Math.exp(model.global.mu))}d`,
}
return {
n: model.n,
active: !model.coldStart,
labels,
people,
scatter: samples.map((s) => [s.estimateDays, s.actualWorkingDays]),
fit: Number(Math.exp(model.global.mu).toFixed(2)),
effect,
excludedSameDay,
}
} }
/** /**
@@ -255,138 +138,3 @@ export function scheduleFocus(
later: toFocusIssue(f.later, inf), later: toFocusIssue(f.later, inf),
} }
} }
/**
* Per-milestone Monte Carlo forecast for the Runway screen — each open milestone's
* open scope gets its own cone, and the p80 landing is compared to the milestone's
* due date (ok/at-risk). Ranges, never point dates. Milestones with no open scope
* (already shipped) are omitted. Falls back to the demo when there's nothing real.
*/
export function runwayView(
issues: GiteaIssue[],
milestones: GiteaMilestone[],
deps: DependencyEdge[],
today: Date = new Date(),
workers: Worker[] = [],
): RunwayMilestone[] {
const open = issues.filter((i) => i.state === 'open')
const rows = milestones
.filter((m) => m.state === 'open')
.map((m) => ({ m, scope: open.filter((i) => i.milestone?.id === m.id) }))
.filter((x) => x.scope.length > 0)
.map(({ m, scope }) => {
const f = forecast(
scope.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
})),
deps,
{ workers },
)
const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
const dueDay = m.dueOn ? workingDaysBetween(today, new Date(m.dueOn)) : null
return { m, p50: f.p50Day, p80: f.p80Day, p90, dueDay }
})
const horizon = Math.max(1, ...rows.map((r) => Math.max(r.p90, r.dueDay ?? 0)))
return rows.map(({ m, p50, p80, p90, dueDay }) => {
const onTrack = dueDay == null || p80 <= dueDay
return {
id: m.id,
name: m.title,
due: m.dueOn ? formatShort(new Date(m.dueOn)) : 'no date',
hard: false,
p80: formatRange(addWorkingDays(today, p50), addWorkingDays(today, p90)),
pos: p80 / horizon,
spread: Math.min(0.6, (p90 - p50) / horizon),
tone: onTrack ? ('ok' as const) : ('warn' as const),
note: dueDay == null ? `${Math.ceil(p80)}d of work` : onTrack ? 'on track' : 'at risk',
}
})
}
export interface MilestoneGroup {
label: string
issues: { id: number; title: string; labels: string[]; days?: string }[]
}
export interface MilestoneView {
name: string
due: string
soft: boolean
scopeCount: number
scopeEstDays: number
doneCount: number
donePct: number
/** p50..p90 landing range for the remaining open scope; null when nothing's open. */
forecastRange: string | null
cone: BurnUpData | null
groups: MilestoneGroup[]
}
/**
* The Milestone drill-in: real scope, done %, a Monte Carlo cone over the
* milestone's remaining open work, and its issues grouped by lifecycle column.
* Returns null for an unknown id — the screen then shows the demo fixture.
*/
export function milestoneView(
id: number,
issues: GiteaIssue[],
milestones: GiteaMilestone[],
deps: DependencyEdge[],
timelines: Timelines = {},
calibration?: CalibrationModel,
today: Date = new Date(),
workers: Worker[] = [],
): MilestoneView | null {
const m = milestones.find((x) => x.id === id)
if (!m) return null
const all = issues.filter((i) => i.milestone?.id === id)
const open = all.filter((i) => i.state === 'open')
const done = all.filter((i) => i.state === 'closed')
const scopeEstDays = all.reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0)
const model = calibration ? toDurationModel(calibration) : undefined
const f = forecast(
open.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
})),
deps,
{ ...(model ? { model } : {}), workers },
)
const cone = buildBurnUpData(f, today)
const inf = inferAll(all, timelines, today)
const groups: MilestoneGroup[] = COLUMN_ORDER.map((key) => ({
label: COLUMN_LABELS[key],
issues: all
.filter((i) => inf.get(i.number)!.column === key)
.map((i) => {
const days = inf.get(i.number)!.steepingDays
return { id: i.number, title: i.title, labels: i.labels, days: days != null ? `${days}d` : undefined }
}),
})).filter((g) => g.issues.length > 0)
return {
name: m.title,
due: m.dueOn ? formatShort(new Date(m.dueOn)) : 'no date',
soft: true,
scopeCount: all.length,
scopeEstDays,
doneCount: done.length,
donePct: all.length ? Math.round((done.length / all.length) * 100) : 0,
forecastRange: cone ? cone.rangeLabel : null,
cone,
groups,
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core' import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
@@ -12,50 +12,18 @@ export type BacklogState =
milestones: GiteaMilestone[] milestones: GiteaMilestone[]
deps: DependencyEdge[] deps: DependencyEdge[]
timelines: Record<number, LifecycleEvent[]> timelines: Record<number, LifecycleEvent[]>
/** true while showing the persisted snapshot (instant boot / offline). */
stale: boolean
/** ISO time the shown snapshot was reconciled, when stale. */
savedAt?: string
} }
/** /**
* Reconcile the managed repo through the main-process bridge, stale-while- * Reconcile the managed repo once on mount, through the main-process bridge.
* revalidate: on mount it shows the persisted snapshot instantly (marked stale), * `unconfigured` means no token — the UI falls back to demo fixtures. Errors
* then a fresh reconcile supersedes it. If gitea is unreachable, the fresh * (network, bad token) surface as `error`.
* reconcile falls back to the persisted snapshot (offline). `refetch` re-syncs
* after a write. `unconfigured` means no token — the UI uses demo fixtures.
*/ */
export function useBacklog(): [BacklogState, () => void] { export function useBacklog(): BacklogState {
const [state, setState] = useState<BacklogState>({ status: 'loading' }) const [state, setState] = useState<BacklogState>({ status: 'loading' })
const [nonce, setNonce] = useState(0)
const refetch = useCallback(() => setNonce((n) => n + 1), [])
useEffect(() => { useEffect(() => {
let alive = true let alive = true
// instant boot from the persisted snapshot (only on first mount, not refetch)
if (nonce === 0) {
window.commitea.gitea
.boot()
.then((b) => {
if (!alive || !('cached' in b) || !b.cached) return
setState((prev) =>
prev.status === 'ready' && !prev.stale
? prev // a fresh reconcile already won the race
: {
status: 'ready',
issues: b.issues,
milestones: b.milestones,
deps: b.deps,
timelines: b.timelines,
stale: true,
savedAt: b.savedAt,
},
)
})
.catch(() => {})
}
window.commitea.gitea window.commitea.gitea
.reconcile() .reconcile()
.then((r) => { .then((r) => {
@@ -68,24 +36,17 @@ export function useBacklog(): [BacklogState, () => void] {
milestones: r.milestones, milestones: r.milestones,
deps: r.deps, deps: r.deps,
timelines: r.timelines, timelines: r.timelines,
stale: r.stale ?? false,
savedAt: r.savedAt,
} }
: { status: 'unconfigured' }, : { status: 'unconfigured' },
) )
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
if (alive) { if (alive) setState({ status: 'error', message: e instanceof Error ? e.message : String(e) })
// keep a shown boot snapshot rather than clobbering it with an error
setState((prev) =>
prev.status === 'ready' ? prev : { status: 'error', message: e instanceof Error ? e.message : String(e) },
)
}
}) })
return () => { return () => {
alive = false alive = false
} }
}, [nonce]) }, [])
return [state, refetch] return state
} }

View File

@@ -1,24 +0,0 @@
import { useEffect, useState } from 'react'
import type { CapacityMember } from '@commitea/core'
/**
* The team's capacity config from the pm-state repo. Empty when unconfigured —
* forecasts then fall back to a single serial worker. Read once on mount.
*/
export function useCapacity(): CapacityMember[] {
const [members, setMembers] = useState<CapacityMember[]>([])
useEffect(() => {
let alive = true
window.commitea.pmstate
.capacity()
.then((r) => {
if (alive) setMembers(r.members)
})
.catch(() => {})
return () => {
alive = false
}
}, [])
return members
}

View File

@@ -1,157 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ChangeProposal, ChatMessage as WireMessage, IssueChange } from '@commitea/core'
import { type ChatMessage } from '../data/view-types.js'
const LIVE_GREETING: ChatMessage = {
from: 'agent',
text: 'Morning. Ask me anything about the project — I check the real board before I answer.',
}
const NO_MODEL: ChatMessage = {
from: 'agent',
text: "Reginald is off — I need a model. Add a Model URL in Settings and chat turns on.",
}
export interface ChatState {
msgs: ChatMessage[]
thinking: boolean
/** true once a model endpoint is confirmed; otherwise the panel echoes the demo reply. */
live: boolean
/** The loaded model's id when live (for the header). */
model: string | null
/** Tools Reginald consulted on the last turn (for a subtle activity line). */
steps: string[]
/** Changes Reginald has proposed and is awaiting approval on. */
proposals: ChangeProposal[]
/** The in-flight streamed prose (grows token-by-token) before the turn finalizes. */
streaming: string
send: (text: string) => void
approve: (p: ChangeProposal) => void
dismiss: (p: ChangeProposal) => void
}
/**
* Reginald's conversation. When a model is configured, `send` drives one agent
* turn through the main-process bridge (which runs the tool loop). With no model,
* the panel shows a clean "off" greeting and the composer is disabled — no
* scripted replies. The greeting is display-only; only real turns (`convo`) are
* sent to the model as history. `onApplyChange` performs an approved write (the
* same handler the Issue screen uses — it refetches).
*/
export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>): ChatState {
const [seed, setSeed] = useState<ChatMessage[]>([])
const [convo, setConvo] = useState<ChatMessage[]>([])
const [thinking, setThinking] = useState(false)
const [live, setLive] = useState(false)
const [model, setModel] = useState<string | null>(null)
const [steps, setSteps] = useState<string[]>([])
const [proposals, setProposals] = useState<ChangeProposal[]>([])
const [streaming, setStreaming] = useState('')
const convoRef = useRef(convo)
convoRef.current = convo
const proposalKey = (p: ChangeProposal) => `${p.change.issue}:${p.change.kind}`
const drop = (p: ChangeProposal) => setProposals((ps) => ps.filter((x) => proposalKey(x) !== proposalKey(p)))
useEffect(() => {
let alive = true
window.commitea.model
.status()
.then((s) => {
if (!alive) return
if (s.configured) {
setLive(true)
setModel(s.model)
setSeed([LIVE_GREETING])
} else {
// no model → clean "off" state, not a scripted fixture reply
setSeed([NO_MODEL])
}
})
.catch(() => {})
return () => {
alive = false
}
}, [])
const send = useCallback(
(raw: string) => {
const text = raw.trim()
if (!text) return
const nextConvo: ChatMessage[] = [...convoRef.current, { from: 'user', text }]
setConvo(nextConvo)
setThinking(true)
setSteps([])
setProposals([])
if (!live) {
// no model configured — nothing to send (composer is disabled anyway)
setThinking(false)
return
}
const wire: WireMessage[] = nextConvo.map((m) => ({
role: m.from === 'user' ? 'user' : 'assistant',
content: m.text,
}))
setStreaming('')
const unsubscribe = window.commitea.model.onToken((delta) => setStreaming((s) => s + delta))
const finish = () => {
unsubscribe()
setThinking(false)
setStreaming('')
}
window.commitea.model
.chat(wire)
.then((res) => {
finish()
if (res.ok) {
setSteps(res.steps.map((s) => s.tool))
setProposals(res.proposals)
setConvo((c) => [...c, { from: 'agent', text: res.content || '…' }])
} else {
setConvo((c) => [
...c,
{
from: 'agent',
text: res.reason === 'error' ? `I hit a snag: ${res.message ?? 'unknown error'}` : 'No model is configured.',
},
])
}
})
.catch(() => {
finish()
setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }])
})
},
[live],
)
const approve = useCallback(
(p: ChangeProposal) => {
if (!onApplyChange) return
drop(p)
void onApplyChange(p.change).then((res) => {
setConvo((c) => [
...c,
{
from: 'agent',
text: res.ok
? `Done — #${p.change.issue}: ${p.summary}. The plan's been re-run.`
: `That didn't take — #${p.change.issue} is unchanged.`,
},
])
})
},
[onApplyChange],
)
const dismiss = useCallback((p: ChangeProposal) => {
drop(p)
setConvo((c) => [...c, { from: 'agent', text: `Left #${p.change.issue} as it was.` }])
}, [])
return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, streaming, send, approve, dismiss }
}

View File

@@ -1,195 +0,0 @@
import { inferLifecycle, schedule } from '@commitea/core'
import type { DepEdge, DepNode, DepsData } from '../../data/view-types.js'
import { formatShort } from '../dates.js'
import type { ProjectData } from './project-data.js'
/** Copy of backlog.ts's private toSchedulable — open issues → scheduler input. Not exported there. */
function toSchedulable(issues: ProjectData['issues']) {
return issues
.filter((i) => i.state === 'open')
.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
}))
}
function median(values: number[]): number {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
/**
* Real Deps-graph view: dependency topology of every issue that appears in a
* real gitea dependency edge, laid out into columns (longest dependency depth)
* and rows (index within its column, ordered by issue number). Critical path
* comes from the deterministic scheduler over the open backlog — closed issues
* never schedule, so they can't be critical, which is honest (they're done).
* The milestone node is the open milestone with the most open in-scope issues;
* when nothing links to a real milestone we fall back to the first open one,
* then to a bare '—' placeholder — never a fabricated name/date.
*/
export function depsGraphView(d: ProjectData): DepsData {
const byNumber = new Map(d.issues.map((i) => [i.number, i]))
// Participating set: anything that shows up as either end of a real edge,
// restricted to issues we actually have (dangling refs to unknown issues
// are dropped — nothing honest to draw for them).
const participating = new Set<number>()
for (const e of d.deps) {
if (byNumber.has(e.issue)) participating.add(e.issue)
if (byNumber.has(e.dependsOn)) participating.add(e.dependsOn)
}
if (participating.size === 0) {
const fallback = d.milestones.find((m) => m.state === 'open')
return {
nodes: [],
milestone: fallback
? { 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 },
edges: [],
critical: [],
unattached: [],
}
}
// In-scope deps: both endpoints participating.
const depsMap = new Map<number, number[]>() // issue -> its dependencies
const dependentsMap = new Map<number, number[]>() // issue -> who depends on it
for (const id of participating) {
depsMap.set(id, [])
dependentsMap.set(id, [])
}
for (const e of d.deps) {
if (participating.has(e.issue) && participating.has(e.dependsOn)) {
depsMap.get(e.issue)!.push(e.dependsOn)
dependentsMap.get(e.dependsOn)!.push(e.issue)
}
}
// Column = longest dependency depth. Cycle-guarded (shouldn't happen — real
// gitea deps are DAG-shaped in practice — but recursion must not hang).
const colMemo = new Map<number, number>()
function colOf(id: number, visiting: Set<number>): number {
const cached = colMemo.get(id)
if (cached != null) return cached
if (visiting.has(id)) return 0 // cycle guard: treat as a root
visiting.add(id)
const deps = depsMap.get(id) ?? []
const col = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((dep) => colOf(dep, visiting)))
visiting.delete(id)
colMemo.set(id, col)
return col
}
for (const id of participating) colOf(id, new Set())
// Row = index within column, ordered deterministically by issue number.
const byCol = new Map<number, number[]>()
for (const id of participating) {
const col = colMemo.get(id)!
if (!byCol.has(col)) byCol.set(col, [])
byCol.get(col)!.push(id)
}
const rowOf = new Map<number, number>()
for (const ids of byCol.values()) {
ids.sort((a, b) => a - b)
ids.forEach((id, i) => rowOf.set(id, i))
}
const maxCol = Math.max(...[...colMemo.values()])
// Critical path from the real scheduler over the open backlog.
const plan = schedule(toSchedulable(d.issues), d.deps)
const critical = new Set(plan.items.filter((i) => i.critical).map((i) => i.number))
const scheduledByNumber = new Map(plan.items.map((i) => [i.number, i]))
const nodes: DepNode[] = [...participating].sort((a, b) => a - b).map((id) => {
const issue = byNumber.get(id)!
const inf = inferLifecycle(issue, d.timelines[id] ?? [], d.today)
const scheduled = scheduledByNumber.get(id)
return {
id,
title: issue.title,
tags: issue.labels,
state: inf.column,
days: inf.steepingDays != null ? `${inf.steepingDays}d` : undefined,
col: colMemo.get(id)!,
row: rowOf.get(id)!,
// Reuse the scheduler's own rationale where one exists (open issues
// only) — never invented commentary for closed/unscheduled nodes.
rationale: scheduled?.rationale,
}
})
// Nearest open milestone with the most open in-scope issues.
const openMilestones = d.milestones.filter((m) => m.state === 'open')
let chosenMilestoneId: number | null = null
let milestone: DepsData['milestone']
const medianRow = median(nodes.map((n) => n.row))
if (openMilestones.length === 0) {
milestone = { name: '—', due: 'no date', col: maxCol + 1, row: medianRow }
} else {
let best: { id: number; title: string; dueOn: string | null | undefined; count: number } | null = null
for (const m of openMilestones) {
const count = [...participating].filter((id) => {
const issue = byNumber.get(id)!
return issue.milestone?.id === m.id && issue.state === 'open'
}).length
if (
best === null ||
count > best.count ||
(count === best.count && dueRank(m.dueOn) < dueRank(best.dueOn))
) {
best = { id: m.id, title: m.title, dueOn: m.dueOn, count }
}
}
// best is non-null since openMilestones.length > 0
chosenMilestoneId = best!.count > 0 ? best!.id : null
milestone = {
name: best!.title,
due: best!.dueOn ? formatShort(new Date(best!.dueOn)) : 'no date',
col: maxCol + 1,
row: medianRow,
}
}
const edges: DepEdge[] = []
for (const e of d.deps) {
if (!participating.has(e.issue) || !participating.has(e.dependsOn)) continue
const crit = critical.has(e.dependsOn) && critical.has(e.issue)
edges.push(crit ? { from: e.dependsOn, to: e.issue, crit: true } : { from: e.dependsOn, to: e.issue })
}
// Terminal nodes (nothing in scope depends on them) that belong to the
// chosen milestone's scope get an edge into it.
if (chosenMilestoneId != null) {
for (const id of participating) {
const dependents = dependentsMap.get(id) ?? []
if (dependents.length > 0) continue
const issue = byNumber.get(id)!
if (issue.milestone?.id !== chosenMilestoneId) continue
edges.push(critical.has(id) ? { from: id, to: 'ms', crit: true } : { from: id, to: 'ms' })
}
}
const linked = new Set<number>()
for (const e of edges) {
linked.add(e.from)
if (e.to !== 'ms') linked.add(e.to)
}
const unattached = [...participating].filter((id) => !linked.has(id)).sort((a, b) => a - b)
return { nodes, milestone, edges, critical: [...critical], unattached }
}
/** Rank a due date for "nearest" comparisons; undated milestones sort last. */
function dueRank(dueOn: string | null | undefined): number {
return dueOn ? new Date(dueOn).getTime() : Number.POSITIVE_INFINITY
}

View File

@@ -1,130 +0,0 @@
import { forecast, inferLifecycle, scheduleWithCapacity, workingDaysBetween } from '@commitea/core'
import type { GanttData, GanttRow, GanttWeek } from '../../data/view-types.js'
import { addWorkingDays, formatShort } from '../dates.js'
import type { ProjectData } from './project-data.js'
/** Same 2-char scheme as backlog.ts's `initials()`; copied inline (not exported there). */
function initials(login: string): string {
return login.slice(0, 2).toUpperCase()
}
/** Copy of backlog.ts's private `toSchedulable` — not exported, so mirrored here. */
function toSchedulable(issues: ProjectData['issues']) {
return issues
.filter((i) => i.state === 'open')
.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
}))
}
const roundUpToWeek = (n: number): number => Math.ceil(Math.max(n, 1) / 7) * 7
/**
* Real Gantt: the capacity-aware scheduler's lane layout over the open backlog
* (startDay/endDay/critical/worker) — the SAME layout the Monte Carlo forecast
* runs each trial on, so the bars you see match what's forecast. Plus a handful
* of recently-closed issues shown as already-done bars. No per-issue Monte Carlo
* exists at this grain, so `p80` is a rough single-issue buffer (endDay padded by
* half its own duration) rather than a simulated percentile — flagged below.
*/
export function ganttView(d: ProjectData): GanttData {
const plan = scheduleWithCapacity(toSchedulable(d.issues), d.deps, d.workers)
const issueByNumber = new Map(d.issues.map((i) => [i.number, i]))
const scheduledRows: GanttRow[] = plan.items.map((item) => {
const issue = issueByNumber.get(item.number)
const inf = issue ? inferLifecycle(issue, d.timelines[item.number] ?? [], d.today) : null
const state: GanttRow['state'] =
inf?.column === 'done' ? 'done' : inf?.column === 'steeping' ? 'steeping' : inf?.column === 'review' ? 'review' : 'scheduled'
return {
id: item.number,
title: item.title,
// The lane it actually landed on (capacity-aware) — falls back to the
// assignee, then blank, so an unassigned issue shows the lane doing it.
who: initials(item.worker ?? issue?.assignee ?? ''),
start: item.startDay,
end: item.endDay,
crit: item.critical,
state,
// Rough single-issue buffer (no Monte Carlo at this grain): pad the end
// by half its own duration as a stand-in p80.
p80: Math.ceil(item.endDay + (item.endDay - item.startDay) * 0.5),
}
})
// Recently-closed issues (closed within ~14 calendar days) as already-done bars.
const doneRows: GanttRow[] = d.issues
.filter((i) => {
if (i.state !== 'closed' || !i.closedAt) return false
const ageDays = (d.today.getTime() - new Date(i.closedAt).getTime()) / (24 * 60 * 60 * 1000)
return ageDays >= 0 && ageDays <= 14
})
.map((i) => ({
id: i.number,
title: i.title,
who: initials(i.assignee ?? ''),
start: 0,
end: 1,
state: 'done' as const,
}))
const rows = [...doneRows, ...scheduledRows]
// Nearest open milestone with a due date, for the horizon + `due` marker.
const dueCandidates = d.milestones
.filter((m) => m.state === 'open' && m.dueOn)
.map((m) => ({ m, day: Math.max(0, workingDaysBetween(d.today, new Date(m.dueOn as string))) }))
.sort((a, b) => a.day - b.day)
const nearestDue = dueCandidates[0] ?? null
const days = roundUpToWeek(Math.max(7, ...rows.map((r) => r.end), nearestDue?.day ?? 0))
const weeks: GanttWeek[] = []
for (let at = 0; at <= days; at += 7) {
weeks.push({ at, label: formatShort(addWorkingDays(d.today, at)) })
}
const due = nearestDue
? { at: nearestDue.day, label: `${nearestDue.m.title} due` }
: { at: days, label: 'horizon' }
// 80% window: forecast the nearest open milestone's open scope, if any has
// one; otherwise fall back to a generic band over the last third of the
// horizon (no milestone scope to anchor it to).
const open = d.issues.filter((i) => i.state === 'open')
const milestoneWithScope = d.milestones
.filter((m) => m.state === 'open')
.map((m) => ({ m, scope: open.filter((i) => i.milestone?.id === m.id) }))
.filter((x) => x.scope.length > 0)
.sort((a, b) => {
const da = a.m.dueOn ? workingDaysBetween(d.today, new Date(a.m.dueOn)) : Number.POSITIVE_INFINITY
const db = b.m.dueOn ? workingDaysBetween(d.today, new Date(b.m.dueOn)) : Number.POSITIVE_INFINITY
return da - db
})[0]
let band: GanttData['band']
if (milestoneWithScope) {
const f = forecast(toSchedulable(milestoneWithScope.scope), d.deps, { workers: d.workers })
const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
band = { from: Math.round(f.p50Day), to: Math.round(p90), label: '80% window' }
} else {
// No milestone with open scope to anchor a real forecast to — degrade to
// a generic band over the last third of the horizon.
band = { from: Math.round((days * 2) / 3), to: days, label: '80% window' }
}
return {
days,
weeks,
today: 0,
band,
due,
rows,
}
}

View File

@@ -1,112 +0,0 @@
import { inferLifecycle } from '@commitea/core'
import type { InboxItem } from '../../data/view-types.js'
import type { ProjectData } from './project-data.js'
const CLOSED_CAP = 5
const TOTAL_CAP = 12
/** "HH:MM" from an ISO timestamp; '—' when absent (never fabricated). */
function hhmm(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${hh}:${mm}`
}
/**
* Real Inbox feed: steeping nags, review-waiting issues, recently-closed
* issues, and closed milestones — all derived straight from the reconciled
* backlog + inferred lifecycle. No mentions, no outages, no people-activity:
* ProjectData carries none of that, so those fixture item `type`s never
* appear here (an honest degrade, not an oversight).
*/
export function inboxView(d: ProjectData): InboxItem[] {
const items: InboxItem[] = []
let id = 1
const open = d.issues.filter((i) => i.state === 'open')
// 1. Steeping nags — open issues that have sat past first-commit for 2+ working days.
for (const issue of open) {
const inf = inferLifecycle(issue, d.timelines[issue.number] ?? [], d.today)
if (inf.steepingDays == null || inf.steepingDays < 2) continue
const blocks = d.deps.filter((e) => e.dependsOn === issue.number).map((e) => e.issue)
items.push({
id: id++,
day: 'Today',
type: 'nag',
icon: 'clock',
tone: 'warn',
text: `#${issue.number} is steeping · ${inf.steepingDays}d`,
detail: blocks.length ? `blocks ${blocks.map((b) => `#${b}`).join(' and ')}` : 'worth a look',
time: hhmm(inf.stages.steeping),
unread: true,
issue: { id: issue.number, title: issue.title, labels: issue.labels, days: `${inf.steepingDays}d` },
})
}
// 2. Review-waiting — open issues whose lifecycle has reached the review column.
for (const issue of open) {
const inf = inferLifecycle(issue, d.timelines[issue.number] ?? [], d.today)
if (inf.column !== 'review') continue
items.push({
id: id++,
day: 'Today',
type: 'review',
icon: 'git-pull-request',
tone: 'info',
text: `#${issue.number} awaits review`,
detail: issue.title,
time: hhmm(inf.stages.review),
unread: true,
issue: { id: issue.number, title: issue.title, labels: issue.labels },
})
}
// 3. Recently shipped — closed issues, newest close first, capped.
const closed = d.issues
.filter((i) => i.state === 'closed')
.slice()
.sort((a, b) => {
const at = a.closedAt ? new Date(a.closedAt).getTime() : -Infinity
const bt = b.closedAt ? new Date(b.closedAt).getTime() : -Infinity
return bt - at
})
.slice(0, CLOSED_CAP)
for (const issue of closed) {
items.push({
id: id++,
day: 'Yesterday',
type: 'milestone',
icon: 'circle-check',
tone: 'ok',
text: `#${issue.number} shipped`,
detail: issue.title,
time: hhmm(issue.closedAt),
unread: false,
issue: { id: issue.number, title: issue.title, labels: issue.labels },
})
}
// 4. Milestone completion — milestones gitea reports as closed.
for (const m of d.milestones) {
if (m.state !== 'closed') continue
items.push({
id: id++,
day: 'Earlier',
type: 'milestone',
icon: 'milestone',
tone: 'ok',
// No milestone-close timestamp is available on GiteaMilestone, so time is
// left at the honest placeholder rather than borrowed from an issue.
text: `${m.title} closed`,
detail: 'milestone complete',
time: '—',
unread: false,
})
}
return items.slice(0, TOTAL_CAP)
}

View File

@@ -1,72 +0,0 @@
import {
forecast,
inferLifecycle,
type LifecycleStages,
toDurationModel,
} from '@commitea/core'
import type { IssueDetail } from '../../data/view-types.js'
import { addWorkingDays, formatRange, formatShort } from '../dates.js'
import type { ProjectData } from './project-data.js'
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
/** "Feb 6 · 11:47" from an ISO timestamp; "pending" when absent. */
function when(iso: string | undefined): string {
if (!iso) return 'pending'
const d = new Date(iso)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${MONTHS[d.getMonth()]} ${d.getDate()} · ${hh}:${mm}`
}
function lifecycle(stages: LifecycleStages): IssueDetail['lifecycle'] {
return [
{ stage: 'Diagnosis', event: 'issue opened', when: when(stages.opened), icon: 'circle-dot', done: true },
{ stage: 'Triage', event: 'labeled / milestoned', when: when(stages.triaged), icon: 'tag', done: !!stages.triaged },
{ stage: 'Work start', event: 'first commit ref', when: when(stages.steeping), icon: 'git-commit-horizontal', done: !!stages.steeping },
{ stage: 'In review', event: 'PR referenced', when: when(stages.review), icon: 'git-merge', done: !!stages.review },
{ stage: 'Complete', event: 'issue closed', when: when(stages.done), icon: 'circle-check', done: !!stages.done },
]
}
/**
* Real Issue-detail sidecar: lifecycle timeline (from event inference), a
* single-issue forecast range, and the real dependency edges. Returns null for
* an unknown id → the screen shows the demo fixture. Comments aren't reconciled,
* so they stay empty (the screen degrades gracefully).
*/
export function issueDetailView(id: number, d: ProjectData): IssueDetail | null {
const issue = d.issues.find((i) => i.number === id)
if (!issue) return null
const inf = inferLifecycle(issue, d.timelines[id] ?? [], d.today)
const blockedBy = d.deps.filter((e) => e.issue === id).map((e) => e.dependsOn)
const blocks = d.deps.filter((e) => e.dependsOn === id).map((e) => e.issue)
let forecastLine = { p80: 'closed', note: 'shipped' }
if (issue.state === 'open') {
const f = forecast(
[{ number: id, title: issue.title, labels: issue.labels, estimateDays: issue.facts.estimateDays, priority: issue.facts.priority, assignee: issue.assignee }],
[],
{ ...(d.calibration ? { model: toDurationModel(d.calibration) } : {}), workers: d.workers },
)
const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
forecastLine = {
p80: `done ${formatRange(addWorkingDays(d.today, f.p50Day), addWorkingDays(d.today, p90))}`,
note: d.calibration && !d.calibration.coldStart ? `from your history · n=${d.calibration.n}` : 'cold-start priors',
}
}
return {
state: inf.column === 'review' ? 'review' : inf.column,
assignee: issue.assignee ?? 'unassigned',
milestone: issue.milestone?.title ?? '—',
body: issue.body,
comments: [],
lifecycle: lifecycle(inf.stages),
forecast: forecastLine,
blocks,
blockedBy,
note: inf.steepingDays != null ? `Steeping ${inf.steepingDays}d.${blocks.length ? ` Blocks ${blocks.map((b) => `#${b}`).join(', ')}.` : ''}` : '',
}
}

View File

@@ -1,17 +0,0 @@
import type { CalibrationModel, DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent, Worker } from '@commitea/core'
/**
* The reconciled backlog + derived config that every real-data view builder
* consumes. AppShell assembles it once from the backlog + capacity, so each
* screen's builder gets a uniform input. `calibration`/`workers` are optional —
* views degrade to cold-start / single-worker when absent.
*/
export interface ProjectData {
issues: GiteaIssue[]
milestones: GiteaMilestone[]
deps: DependencyEdge[]
timelines: Record<number, LifecycleEvent[]>
calibration?: CalibrationModel
workers: Worker[]
today: Date
}

View File

@@ -1,157 +0,0 @@
import {
type DependencyEdge,
type GiteaIssue,
inferLifecycle,
schedule,
type ScheduledItem,
} from '@commitea/core'
import type { DriftItem, Nag, PlanItem, StandupData } from '../../data/view-types.js'
import type { ProjectData } from './project-data.js'
/** Same shape as backlog.ts's private toSchedulable — not exported there, so copied inline. */
function toSchedulable(issues: GiteaIssue[]) {
return issues
.filter((i) => i.state === 'open')
.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
}))
}
function whyFor(item: ScheduledItem): string {
if (item.critical) return 'On the critical path.'
if (item.blocks.length) return `Blocks ${item.blocks.map((b) => `#${b}`).join(', ')}.`
return 'Next by dependency + priority order.'
}
/**
* Top scheduled pick per distinct assignee. Iterates the (already dependency-
* + priority-ordered) plan once, keeping the first item seen per assignee —
* that's their earliest-starting pick since the v0 scheduler lays out a single
* serial lane (order ascending ⇒ startDay ascending). Ordered by `d.workers`
* when present (assignees absent from the roster are appended after the named
* workers; unassigned work last), else by discovery order. Capped at 4 people.
*/
function buildPlan(d: ProjectData, open: GiteaIssue[], items: ScheduledItem[]): PlanItem[] {
const assigneeByNumber = new Map(open.map((i) => [i.number, i.assignee]))
const firstPickByPerson = new Map<string, ScheduledItem>()
for (const item of items) {
const key = assigneeByNumber.get(item.number) ?? 'Unassigned'
if (!firstPickByPerson.has(key)) firstPickByPerson.set(key, item)
}
let order: string[]
if (d.workers.length) {
const workerOrder = d.workers.map((w) => w.person)
const known = workerOrder.filter((p) => firstPickByPerson.has(p))
const extra = [...firstPickByPerson.keys()].filter((k) => k !== 'Unassigned' && !workerOrder.includes(k))
order = [...known, ...extra]
if (firstPickByPerson.has('Unassigned')) order.push('Unassigned')
} else {
order = [...firstPickByPerson.keys()]
}
return order.slice(0, 4).map((who) => {
const item = firstPickByPerson.get(who)!
return { who, pick: `#${item.number}`, title: item.title, why: whyFor(item) }
})
}
/**
* The single longest-steeping open issue (inferLifecycle's steepingDays, whole
* working days since first commit ref). `blocks` comes from the scheduler's
* in-scope dependents of that same issue. Falls back to a calm, honest nag
* when nothing is currently steeping.
*/
function buildNag(d: ProjectData, open: GiteaIssue[], items: ScheduledItem[]): Nag {
let worst: { issue: GiteaIssue; days: number } | null = null
for (const issue of open) {
const inf = inferLifecycle(issue, d.timelines[issue.number] ?? [], d.today)
if (inf.steepingDays != null && (worst == null || inf.steepingDays > worst.days)) {
worst = { issue, days: inf.steepingDays }
}
}
if (!worst) {
return { id: 0, days: '0d', blocks: [], text: 'Nothing is over-steeping. The pot is calm.' }
}
const scheduled = items.find((it) => it.number === worst!.issue.number)
const blocks = (scheduled?.blocks ?? []).map((n) => `#${n}`)
const days = `${worst.days}d`
return {
id: worst.issue.number,
days,
blocks,
text: `${days} steeping. ${blocks.length ? `Blocks ${blocks.join(', ')}.` : 'Worth a look.'}`,
}
}
/**
* Honest drift signals only — ProjectData carries no notification/webhook
* stream, so drift is limited to what lifecycle inference can actually see:
* open issues sitting in review (age isn't measurable from the data we have,
* so every review-column issue is flagged rather than fabricating a duration)
* and open issues steeping past their own estimate. Sorted most-severe first
* (steeping overage ranks by days over estimate; review-sitting is a flat,
* lower severity since it has no measured age) and capped at 4.
*/
function buildDrift(d: ProjectData, open: GiteaIssue[]): DriftItem[] {
const candidates: (DriftItem & { severity: number })[] = []
for (const issue of open) {
const inf = inferLifecycle(issue, d.timelines[issue.number] ?? [], d.today)
if (inf.column === 'review') {
candidates.push({
tone: 'warn',
text: `#${issue.number} ${issue.title} is sitting in review.`,
delta: 'idle in review',
severity: 1,
})
}
if (inf.steepingDays != null) {
const est = issue.facts.estimateDays ?? 2
if (inf.steepingDays > est) {
candidates.push({
tone: 'warn',
text: `#${issue.number} ${issue.title} has steeped past its estimate.`,
delta: `${inf.steepingDays}d vs ${est}d est`,
severity: inf.steepingDays - est + 1,
})
}
}
}
return candidates
.sort((a, b) => b.severity - a.severity)
.slice(0, 4)
.map(({ tone, text, delta }) => ({ tone, text, delta }))
}
/**
* Morning-standup sidecar: per-person top pick from the deterministic
* scheduler, the single longest-steeping issue as the day's nag, and drift
* limited to real lifecycle anomalies (review-sitting, steeping-past-estimate).
* Never throws — an empty/cold backlog degrades to an empty plan, empty drift,
* and a calm nag.
*/
export function standupView(d: ProjectData): StandupData {
const date = d.today.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
const open = d.issues.filter((i) => i.state === 'open')
const deps: DependencyEdge[] = d.deps
const { items } = schedule(toSchedulable(open), deps)
return {
date,
plan: buildPlan(d, open, items),
nag: buildNag(d, open, items),
drift: buildDrift(d, open),
}
}

View File

@@ -1,14 +0,0 @@
import { defineConfig } from 'vitest/config'
/**
* Unit tests for the Electron main process. Node environment only — renderer
* (React) is covered by the Playwright e2e suite, not here. `electron` is a
* native module that can't be imported outside the Electron runtime, so tests
* that touch it mock it (see snapshot-store.test.ts).
*/
export default defineConfig({
test: {
environment: 'node',
include: ['src/main/**/*.test.ts'],
},
})

View File

@@ -1,103 +0,0 @@
import { beforeAll, describe, expect, it } from 'vitest'
import { extractLabelFacts } from '../labels/label-schema.js'
import type { FetchLike, GiteaIssue } from '../gitea/types.js'
import { runAgentTurn } from './agent-loop.js'
import { REGINALD_SYSTEM, REGINALD_TOOLS } from './agent-tools.js'
import { createChatClient } from './chat-client.js'
import { buildProjectView, type ProjectSnapshot } from './query-project.js'
/**
* Opt-in (COMMITEA_MODEL_LIVE=1). Drives the real chat client + agent loop
* against a local OpenAI-compatible server (LM Studio on :1234 by default),
* proving the model calls the tools and narrates the real result. The model is
* whatever is loaded (via LM Studio's native API), so it never JIT-swaps.
*/
const LIVE = !!process.env.COMMITEA_MODEL_LIVE
const BASE = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
let MODEL = process.env.COMMITEA_MODEL_SMALL ?? ''
beforeAll(async () => {
if (!LIVE || MODEL) return
const root = BASE.replace(/\/v1\/?$/, '')
const loaded = await fetch(`${root}/api/v0/models`)
.then((r) => (r.ok ? (r.json() as Promise<{ data?: { id: string; state?: string; type?: string }[] }>) : null))
.then((d) => d?.data?.find((m) => m.state === 'loaded' && m.type !== 'embeddings')?.id)
.catch(() => undefined)
MODEL = loaded ?? 'google/gemma-4-e4b'
})
function issue(over: Partial<GiteaIssue>): GiteaIssue {
const labels = over.labels ?? []
return {
number: 1, title: '#1', body: '', state: 'open', labels, facts: extractLabelFacts(labels),
milestone: null, assignee: null, assignees: [], createdAt: '2026-01-05T09:00:00Z',
updatedAt: '2026-01-05T09:00:00Z', closedAt: null, url: '', ...over,
}
}
const SNAP: ProjectSnapshot = {
issues: [
issue({ number: 2, title: 'ChangeSource interface + polling source', labels: ['est/3d', 'p/1'] }),
issue({ number: 3, title: 'SQLite cache bootstrap', labels: ['est/2d', 'p/1'] }),
],
timelines: {},
deps: [{ issue: 3, dependsOn: 2 }],
}
describe('agent loop (live model)', () => {
it.skipIf(!LIVE)(
'calls query_project and narrates the real focus',
async () => {
const client = createChatClient({ baseUrl: BASE, model: MODEL }, globalThis.fetch as unknown as FetchLike)
const turn = await runAgentTurn({
complete: (m, t) => client.complete(m, t),
messages: [
{ role: 'system', content: REGINALD_SYSTEM },
{ role: 'user', content: 'What should I work on right now?' },
],
tools: REGINALD_TOOLS,
execute: async (name, args) =>
name === 'query_project'
? buildProjectView((args as { view: any }).view, (args as any).filters, SNAP, new Date())
: { error: `unknown tool ${name}` },
})
// it consulted the project, then answered in prose about the real top item (#2)
expect(turn.steps.some((s) => s.tool === 'query_project')).toBe(true)
expect(turn.content.trim().length).toBeGreaterThan(0)
expect(turn.content).toMatch(/#?2\b|ChangeSource/i)
},
60_000,
)
it.skipIf(!LIVE)(
'records a standing instruction via record_directive',
async () => {
const client = createChatClient({ baseUrl: BASE, model: MODEL }, globalThis.fetch as unknown as FetchLike)
const recorded: unknown[] = []
const turn = await runAgentTurn({
complete: (m, t) => client.complete(m, t),
messages: [
{ role: 'system', content: REGINALD_SYSTEM },
{ role: 'user', content: 'Log this standing directive: pilots come first, everything else waits.' },
],
tools: REGINALD_TOOLS,
execute: async (name, args) => {
if (name === 'record_directive') {
recorded.push(args)
return { recorded: { kind: (args as { kind?: string }).kind ?? 'note' } }
}
return name === 'query_project'
? buildProjectView((args as { view: any }).view, (args as any).filters, SNAP, new Date())
: { error: `unknown tool ${name}` }
},
})
// the model logged the directive rather than trying to apply it
expect(turn.steps.some((s) => s.tool === 'record_directive')).toBe(true)
expect(recorded.length).toBeGreaterThan(0)
},
60_000,
)
})

View File

@@ -1,71 +0,0 @@
/**
* The agent turn loop. Given a model `complete` fn, the conversation, the tool
* declarations, and an `execute` that actually runs a tool, it drives the
* call→tool→result→call cycle until the model answers in prose (or a step
* budget is hit). Pure orchestration with injected I/O — the model and the tool
* executor are both stubbable, so the loop is fully unit-testable offline.
*/
import type { ChatMessage, CompletionResult, ToolDecl } from './chat-client.js'
/** A tool the loop ran, with the raw args and its stringified result — for the UI's activity trail. */
export interface AgentStep {
tool: string
arguments: string
result: string
}
export type ToolExecutor = (name: string, args: unknown) => Promise<unknown>
export interface AgentTurn {
content: string
steps: AgentStep[]
/** The full conversation including this turn's assistant/tool messages. */
messages: ChatMessage[]
}
const DEFAULT_MAX_STEPS = 4
function stringify(result: unknown): string {
return typeof result === 'string' ? result : JSON.stringify(result)
}
export async function runAgentTurn(opts: {
complete: (messages: ChatMessage[], tools?: ToolDecl[], onToken?: (delta: string) => void) => Promise<CompletionResult>
messages: ChatMessage[]
tools: ToolDecl[]
execute: ToolExecutor
maxSteps?: number
/** Streams content deltas as the model produces prose (final-answer streaming). */
onToken?: (delta: string) => void
}): Promise<AgentTurn> {
const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS
const convo: ChatMessage[] = [...opts.messages]
const steps: AgentStep[] = []
for (let step = 0; step < maxSteps; step++) {
const { content, toolCalls } = await opts.complete(convo, opts.tools, opts.onToken)
if (toolCalls.length === 0) {
convo.push({ role: 'assistant', content })
return { content, steps, messages: convo }
}
convo.push({ role: 'assistant', content, toolCalls })
for (const tc of toolCalls) {
let result: unknown
try {
const args = tc.arguments ? JSON.parse(tc.arguments) : {}
result = await opts.execute(tc.name, args)
} catch (e) {
result = { error: e instanceof Error ? e.message : String(e) }
}
const resultStr = stringify(result)
steps.push({ tool: tc.name, arguments: tc.arguments, result: resultStr })
convo.push({ role: 'tool', toolCallId: tc.id, name: tc.name, content: resultStr })
}
}
// Out of tool budget — force a final prose answer with tools withheld.
const final = await opts.complete(convo, [], opts.onToken)
convo.push({ role: 'assistant', content: final.content })
return { content: final.content, steps, messages: convo }
}

View File

@@ -1,96 +0,0 @@
/**
* Reginald's tool surface. v0 ships the one read tool (`query_project`); the
* three write tools (capture_work, apply_changes, record_directive) layer on
* later against the same loop. Few, fat tools so a small local model survives
* with one thing to reach for (docs/agent-tools.md).
*/
import type { ToolDecl } from './chat-client.js'
export const QUERY_PROJECT_TOOL: ToolDecl = {
name: 'query_project',
description:
'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.',
parameters: {
type: 'object',
properties: {
view: {
type: 'string',
enum: ['focus', 'board', 'calibration', 'issue', 'search', 'standup'],
description:
'focus = Now/Next/Later; board = issues by lifecycle column; calibration = estimate-vs-actual; ' +
'issue = one issue (needs filters.issueId); search = issues matching filters.query; ' +
"standup = today's plan + overnight drift + the stalest blocker.",
},
filters: {
type: 'object',
properties: {
issueId: { type: 'number' },
query: { type: 'string' },
limit: { type: 'number' },
},
},
},
required: ['view'],
},
}
export const PROPOSE_CHANGE_TOOL: ToolDecl = {
name: 'propose_change',
description:
'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, ' +
'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 ' +
'*proposed* the change for approval — never say it is done.',
parameters: {
type: 'object',
properties: {
issue: { type: 'number', description: 'the issue number to change' },
estimate: { type: 'string', enum: ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'] },
priority: { type: 'string', enum: ['p/1', 'p/2', 'p/3', 'p/4'] },
assignee: { type: 'string', description: 'gitea login to assign, or null to unassign' },
milestone: { type: 'number', description: 'milestone id to set, or null to remove from its milestone' },
},
required: ['issue'],
},
}
export const RECORD_DIRECTIVE_TOOL: ToolDecl = {
name: 'record_directive',
description:
'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 ' +
'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.',
parameters: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['reprioritize', 'reestimate', 'set-deadline', 'scope', 'capacity', 'note'] },
quote: { type: 'string', description: "the PM's own words, stored verbatim" },
target: {
type: 'object',
properties: {
issue: { type: 'number' },
milestone: { type: 'number' },
member: { type: 'string' },
},
},
rationale: { type: 'string', description: 'why (optional)' },
},
required: ['kind', 'quote'],
},
}
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL, PROPOSE_CHANGE_TOOL, RECORD_DIRECTIVE_TOOL]
export const REGINALD_SYSTEM = [
'You are Reginald, the calm, dry project manager inside CommiTea — a tool that runs projects on Gitea.',
'Call query_project to ground every answer in the real project; never invent issues, numbers, or dates.',
'The scheduler and forecasts are deterministic code — report their output, do not recompute it.',
'To change an estimate, priority, assignee, or milestone, call propose_change — it shows the human a diff to approve.',
'When the PM states standing intent ("pilots first", "freeze scope"), call record_directive to log it.',
'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.',
'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.',
].join(' ')

View File

@@ -1,282 +0,0 @@
import { describe, expect, it } from 'vitest'
import { extractLabelFacts } from '../labels/label-schema.js'
import type { GiteaIssue } from '../gitea/types.js'
import type { FetchLike } from '../gitea/types.js'
import { type ChatMessage, type CompletionResult, createChatClient, type ToolDecl } from './chat-client.js'
import { runAgentTurn } from './agent-loop.js'
import { pickModel } from './model-router.js'
import { buildProjectView, type ProjectSnapshot } from './query-project.js'
// ---- chat client ----
describe('createChatClient', () => {
function stub(body: unknown) {
const calls: { url: string; body: unknown }[] = []
const fetch: FetchLike = (url, init) => {
calls.push({ url, body: init?.body ? JSON.parse(init.body) : undefined })
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(''),
})
}
return { fetch, calls }
}
function sseStub(chunks: string[]) {
const calls: { url: string; body: unknown }[] = []
const fetch: FetchLike = (url, init) => {
calls.push({ url, body: init?.body ? JSON.parse(init.body) : undefined })
const enc = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(c) {
for (const ch of chunks) c.enqueue(enc.encode(ch))
c.close()
},
})
return Promise.resolve({ ok: true, status: 200, body, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
}
return { fetch, calls }
}
it('streams content deltas via onToken and returns the assembled result', async () => {
const { fetch, calls } = sseStub([
'data: {"choices":[{"delta":{"content":"Right "}}]}\n\n',
'data: {"choices":[{"delta":{"content":"now: #2."}}]}\n\n',
'data: [DONE]\n\n',
])
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
const tokens: string[] = []
const res = await client.complete([{ role: 'user', content: 'now?' }], undefined, (d) => tokens.push(d))
expect(tokens).toEqual(['Right ', 'now: #2.'])
expect(res.content).toBe('Right now: #2.')
expect((calls[0].body as { stream?: boolean }).stream).toBe(true)
})
it('assembles a streamed tool call from argument deltas', async () => {
const { fetch } = sseStub([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"query_project","arguments":"{\\"view\\""}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"focus\\"}"}}]}}]}\n\n',
'data: [DONE]\n\n',
])
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
const res = await client.complete([{ role: 'user', content: 'x' }], [{ name: 'query_project', description: '', parameters: {} }], () => {})
expect(res.toolCalls).toEqual([{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }])
})
it('POSTs to /chat/completions and parses content', async () => {
const { fetch, calls } = stub({ choices: [{ message: { content: 'the focus is #2' } }] })
const client = createChatClient({ baseUrl: 'http://localhost:1234/v1', model: 'gemma' }, fetch)
const res = await client.complete([{ role: 'user', content: 'what now?' }])
expect(res.content).toBe('the focus is #2')
expect(res.toolCalls).toEqual([])
expect(calls[0].url).toBe('http://localhost:1234/v1/chat/completions')
expect((calls[0].body as { model: string }).model).toBe('gemma')
})
it('parses tool calls from the wire shape', async () => {
const { fetch } = stub({
choices: [
{
message: {
content: null,
tool_calls: [{ id: 'c1', function: { name: 'query_project', arguments: '{"view":"focus"}' } }],
},
},
],
})
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
const res = await client.complete([{ role: 'user', content: 'x' }], [{ name: 'query_project', description: '', parameters: {} }])
expect(res.toolCalls).toEqual([{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }])
})
it('serializes assistant tool_calls + tool results in the request', async () => {
const { fetch, calls } = stub({ choices: [{ message: { content: 'ok' } }] })
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
const convo: ChatMessage[] = [
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] },
{ role: 'tool', toolCallId: 'c1', name: 'query_project', content: '{"now":null}' },
]
await client.complete(convo)
const sent = (calls[0].body as { messages: Record<string, unknown>[] }).messages
expect(sent[1].tool_calls).toBeDefined()
expect(sent[2]).toEqual({ role: 'tool', tool_call_id: 'c1', content: '{"now":null}' })
})
})
// ---- model router ----
describe('pickModel', () => {
const router = { small: { baseUrl: 'u', model: 'small' }, big: { baseUrl: 'u', model: 'big' } }
it('routes plan → big, everything else → small', () => {
expect(pickModel(router, 'plan').model).toBe('big')
expect(pickModel(router, 'ritual').model).toBe('small')
})
})
// ---- agent loop ----
describe('runAgentTurn', () => {
const tools: ToolDecl[] = [{ name: 'query_project', description: '', parameters: {} }]
it('runs a tool then returns the model prose, recording the step', async () => {
// first completion asks for a tool; second answers in prose
const scripted: CompletionResult[] = [
{ content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }] },
{ content: 'Right now: #2.', toolCalls: [] },
]
let i = 0
const executed: { name: string; args: unknown }[] = []
const turn = await runAgentTurn({
complete: async () => scripted[i++],
messages: [{ role: 'user', content: 'what now?' }],
tools,
execute: async (name, args) => {
executed.push({ name, args })
return { now: { issue: 2 } }
},
})
expect(turn.content).toBe('Right now: #2.')
expect(turn.steps).toEqual([
{ tool: 'query_project', arguments: '{"view":"focus"}', result: '{"now":{"issue":2}}' },
])
expect(executed).toEqual([{ name: 'query_project', args: { view: 'focus' } }])
// conversation carries user → assistant(tool) → tool → assistant(prose)
expect(turn.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant'])
})
it('answers directly when the model needs no tool', async () => {
const turn = await runAgentTurn({
complete: async () => ({ content: 'Hello.', toolCalls: [] }),
messages: [{ role: 'user', content: 'hi' }],
tools,
execute: async () => ({}),
})
expect(turn.content).toBe('Hello.')
expect(turn.steps).toEqual([])
})
it('captures a tool executor error as the tool result instead of throwing', async () => {
const scripted: CompletionResult[] = [
{ content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] },
{ content: 'Something went wrong reading that.', toolCalls: [] },
]
let i = 0
const turn = await runAgentTurn({
complete: async () => scripted[i++],
messages: [{ role: 'user', content: 'x' }],
tools,
execute: async () => {
throw new Error('boom')
},
})
expect(turn.steps[0].result).toContain('boom')
expect(turn.content).toBe('Something went wrong reading that.')
})
it('forces a final prose answer when the step budget is exhausted', async () => {
// always asks for a tool; the loop must still terminate with prose
let toolRounds = 0
const turn = await runAgentTurn({
complete: async (_m, tls) => {
if (tls && tls.length) {
toolRounds++
return { content: '', toolCalls: [{ id: `c${toolRounds}`, name: 'query_project', arguments: '{}' }] }
}
return { content: 'final answer', toolCalls: [] } // called with tools withheld
},
messages: [{ role: 'user', content: 'x' }],
tools,
execute: async () => ({}),
maxSteps: 2,
})
expect(toolRounds).toBe(2)
expect(turn.content).toBe('final answer')
})
})
// ---- query_project view builder ----
describe('buildProjectView', () => {
const asOf = new Date('2026-02-01T00:00:00Z')
function issue(over: Partial<GiteaIssue>): GiteaIssue {
const labels = over.labels ?? []
return {
number: 1,
title: '#1',
body: '',
state: 'open',
labels,
facts: extractLabelFacts(labels),
milestone: null,
assignee: null,
assignees: [],
createdAt: '2026-01-05T09:00:00Z',
updatedAt: '2026-01-05T09:00:00Z',
closedAt: null,
url: '',
...over,
}
}
const snap: ProjectSnapshot = {
issues: [
issue({ number: 2, title: 'ChangeSource', labels: ['est/3d', 'p/1'] }),
issue({ number: 3, title: 'SQLite cache', labels: ['est/2d', 'p/1'] }),
issue({ number: 9, title: 'Done thing', state: 'closed', labels: ['est/2d'], closedAt: '2026-01-12T09:00:00Z' }),
],
timelines: { 9: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }] },
deps: [{ issue: 3, dependsOn: 2 }],
}
it('focus returns Now/Next/Later grounded in the scheduler', () => {
const v = buildProjectView('focus', undefined, snap, asOf) as {
now: { issue: number } | null
openCount: number
}
expect(v.now?.issue).toBe(2) // #3 depends on #2, so #2 comes first
expect(v.openCount).toBe(2)
})
it('board groups issues by lifecycle column', () => {
const v = buildProjectView('board', undefined, snap, asOf) as {
columns: { column: string; count: number }[]
}
const done = v.columns.find((c) => c.column === 'done')
expect(done?.count).toBe(1) // #9
const triage = v.columns.find((c) => c.column === 'triage')
expect(triage?.count).toBe(2) // #2, #3 labelled, no commits yet
})
it('calibration reports n + cold-start honestly', () => {
const v = buildProjectView('calibration', undefined, snap, asOf) as { n: number; coldStart: boolean }
expect(v.n).toBe(1) // one closed, estimated, with an actual
expect(v.coldStart).toBe(true)
})
it('issue returns intent + derived for a specific id', () => {
const v = buildProjectView('issue', { issueId: 3 }, snap, asOf) as {
issue: number
blockedBy: number[]
}
expect(v.issue).toBe(3)
expect(v.blockedBy).toEqual([2])
})
it('search matches title/labels', () => {
const v = buildProjectView('search', { query: 'sqlite' }, snap, asOf) as { results: { issue: number }[] }
expect(v.results.map((r) => r.issue)).toEqual([3])
})
it('unbuilt views return a notImplemented marker, not fabricated data', () => {
expect(buildProjectView('milestone', undefined, snap, asOf)).toEqual({ notImplemented: 'milestone' })
expect(buildProjectView('runway', undefined, snap, asOf)).toEqual({ notImplemented: 'runway' })
})
})

View File

@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { CompletionResult } from './chat-client.js'
import { captureWork, parseCaptureArgs } from './capture-work.js'
describe('parseCaptureArgs', () => {
it('validates issues and keeps only real titles + valid labels', () => {
const p = parseCaptureArgs({
issues: [
{ title: 'Fix token refresh', body: 'dies silently', estimate: 'est/2d', priority: 'p/1' },
{ title: ' ', body: 'blank title dropped' },
{ title: 'Docs', estimate: 'est/9d', priority: 'urgent' }, // invalid labels → dropped to undefined
],
consequence: 'Beta slips a day',
})
expect(p.issues).toHaveLength(2)
expect(p.issues[0]).toEqual({ title: 'Fix token refresh', body: 'dies silently', estimate: 'est/2d', priority: 'p/1' })
expect(p.issues[1]).toEqual({ title: 'Docs', body: '', estimate: undefined, priority: undefined })
expect(p.consequence).toBe('Beta slips a day')
})
it('tolerates a missing/!array issues field', () => {
expect(parseCaptureArgs({}).issues).toEqual([])
expect(parseCaptureArgs({ issues: 'nope' }).issues).toEqual([])
})
})
describe('captureWork', () => {
it('forces propose_issues and returns the validated set', async () => {
const result: CompletionResult = {
content: '',
toolCalls: [
{
id: 'c1',
name: 'propose_issues',
arguments: JSON.stringify({
issues: [{ title: 'Retry token refresh with backoff', body: '', estimate: 'est/2d', priority: 'p/2' }],
consequence: 'no material shift',
}),
},
],
}
let sawTool = ''
const proposal = await captureWork(async (_m, tools) => {
sawTool = tools?.[0]?.name ?? ''
return result
}, 'auth is flaky, token refresh dies')
expect(sawTool).toBe('propose_issues')
expect(proposal.issues[0].title).toBe('Retry token refresh with backoff')
expect(proposal.consequence).toBe('no material shift')
})
it('returns an empty set when the model answers without the tool', async () => {
const proposal = await captureWork(async () => ({ content: 'I need more detail.', toolCalls: [] }), 'vague')
expect(proposal.issues).toEqual([])
})
it('survives malformed tool arguments', async () => {
const proposal = await captureWork(
async () => ({ content: '', toolCalls: [{ id: 'c1', name: 'propose_issues', arguments: '{not json' }] }),
'x',
)
expect(proposal.issues).toEqual([])
})
})

View File

@@ -1,109 +0,0 @@
/**
* capture_work — braindump → a small set of concrete issues. This is the one
* place the big model earns its keep (decomposition + estimate negotiation).
* It returns a *proposal*; nothing is filed until the human approves it in the
* Capture tray and it goes through the create-issue write path. Pure
* orchestration over an injected `complete` — stubbable, so it's testable offline.
*/
import {
type EstimateLabel,
ESTIMATE_LABELS,
type PriorityLabel,
PRIORITY_LABELS,
} from '../labels/label-schema.js'
import type { ChatMessage, CompletionResult, ToolDecl } from './chat-client.js'
export interface ProposedIssue {
title: string
body: string
estimate?: EstimateLabel
priority?: PriorityLabel
}
export interface CaptureProposal {
issues: ProposedIssue[]
/** One-line schedule impact, if the model offered one. */
consequence?: string
}
export const PROPOSE_ISSUES_TOOL: ToolDecl = {
name: 'propose_issues',
description: 'Return the decomposed issue set for a braindump. Call this exactly once.',
parameters: {
type: 'object',
properties: {
issues: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string', description: 'a clear imperative title' },
body: { type: 'string', description: 'one or two lines of detail' },
estimate: { type: 'string', enum: ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'] },
priority: { type: 'string', enum: ['p/1', 'p/2', 'p/3', 'p/4'] },
},
required: ['title'],
},
},
consequence: { type: 'string', description: 'one-line note on the schedule impact' },
},
required: ['issues'],
},
}
export const CAPTURE_SYSTEM = [
'You are Reginald, decomposing a rough braindump into a small set of concrete Gitea issues.',
'Call propose_issues exactly once. Split genuinely separate work; merge 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).',
'Estimate honestly — a "quick" task is rarely one day. Keep the set tight; three good issues beat eight vague ones.',
].join(' ')
function isEstimate(v: unknown): v is EstimateLabel {
return typeof v === 'string' && (ESTIMATE_LABELS as readonly string[]).includes(v)
}
function isPriority(v: unknown): v is PriorityLabel {
return typeof v === 'string' && (PRIORITY_LABELS as readonly string[]).includes(v)
}
/** Coerce the model's raw propose_issues args into a validated proposal. */
export function parseCaptureArgs(args: unknown): CaptureProposal {
const a = (args ?? {}) as { issues?: unknown[]; consequence?: unknown }
const issues: ProposedIssue[] = []
for (const raw of Array.isArray(a.issues) ? a.issues : []) {
const r = (raw ?? {}) as Record<string, unknown>
const title = typeof r.title === 'string' ? r.title.trim() : ''
if (!title) continue
issues.push({
title,
body: typeof r.body === 'string' ? r.body : '',
estimate: isEstimate(r.estimate) ? r.estimate : undefined,
priority: isPriority(r.priority) ? r.priority : undefined,
})
}
return { issues, consequence: typeof a.consequence === 'string' ? a.consequence : undefined }
}
/**
* Run one decomposition turn. Forces the model to answer via propose_issues and
* returns the validated set. An empty set means the model declined to structure it.
*/
export async function captureWork(
complete: (messages: ChatMessage[], tools?: ToolDecl[]) => Promise<CompletionResult>,
braindump: string,
): Promise<CaptureProposal> {
const res = await complete(
[
{ role: 'system', content: CAPTURE_SYSTEM },
{ role: 'user', content: braindump },
],
[PROPOSE_ISSUES_TOOL],
)
const call = res.toolCalls.find((t) => t.name === 'propose_issues')
if (!call) return { issues: [] }
try {
return parseCaptureArgs(JSON.parse(call.arguments || '{}'))
} catch {
return { issues: [] }
}
}

View File

@@ -1,184 +0,0 @@
/**
* Reginald's model client — a thin OpenAI-wire chat-completions client over an
* injected fetch (the same seam the gitea client uses). `@commitea/core` stays
* pure: no network, no globals. The desktop main process passes the real
* `fetch`; tests pass a stub. Points at any OpenAI-compatible endpoint (LM
* Studio, Ollama, the OpenAI API) — the model router picks which.
*/
import type { FetchLike } from '../gitea/types.js'
/** Where a model lives + which model to ask for. */
export interface ModelConfig {
/** OpenAI-compatible base, including the version segment, e.g. `http://localhost:1234/v1`. */
baseUrl: string
model: string
/** Bearer key; omitted for local servers that don't check it. */
apiKey?: string
}
/** A model's request to run a tool. `arguments` is a raw JSON string (OpenAI shape). */
export interface ToolCall {
id: string
name: string
arguments: string
}
/** One turn in the conversation, in our normalized shape. */
export interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string
/** assistant turns that requested tools. */
toolCalls?: ToolCall[]
/** tool turns: which call they answer. */
toolCallId?: string
/** tool turns: the function name. */
name?: string
}
/** A tool the model may call — name, description, and a JSON-Schema parameter spec. */
export interface ToolDecl {
name: string
description: string
parameters: object
}
export interface CompletionResult {
content: string
toolCalls: ToolCall[]
}
/** Called with each streamed content delta (final-prose streaming). */
export type OnToken = (delta: string) => void
export interface ChatClient {
/** When `onToken` is given, the response streams (SSE) and each content delta is emitted. */
complete(messages: ChatMessage[], tools?: ToolDecl[], onToken?: OnToken): Promise<CompletionResult>
}
/** Map our message shape to the OpenAI wire shape. */
function toWireMessage(m: ChatMessage): Record<string, unknown> {
if (m.role === 'assistant' && m.toolCalls?.length) {
return {
role: 'assistant',
content: m.content || null,
tool_calls: m.toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.arguments },
})),
}
}
if (m.role === 'tool') {
return { role: 'tool', tool_call_id: m.toolCallId, content: m.content }
}
return { role: m.role, content: m.content }
}
function toWireTool(t: ToolDecl): Record<string, unknown> {
return { type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } }
}
/** Shape of the one choice we read back. */
interface RawChoiceMessage {
content: string | null
tool_calls?: { id: string; function: { name: string; arguments: string } }[]
}
export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): ChatClient {
const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`
return {
async complete(messages, tools, onToken) {
const body: Record<string, unknown> = {
model: config.model,
messages: messages.map(toWireMessage),
temperature: 0,
}
if (tools?.length) {
body.tools = tools.map(toWireTool)
body.tool_choice = 'auto'
}
const stream = !!onToken
if (stream) body.stream = true
const res = await fetchImpl(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: stream ? 'text/event-stream' : 'application/json',
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
},
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`model completion failed (${res.status}): ${text.slice(0, 200)}`)
}
if (stream && res.body) return readStream(res.body, onToken!)
const json = (await res.json()) as { choices?: { message: RawChoiceMessage }[] }
const msg = json.choices?.[0]?.message
return {
content: msg?.content ?? '',
toolCalls: (msg?.tool_calls ?? []).map((tc) => ({
id: tc.id,
name: tc.function.name,
arguments: tc.function.arguments,
})),
}
},
}
}
/** Streamed tool-call delta: name arrives first, arguments accumulate across chunks. */
interface RawToolDelta {
index: number
id?: string
function?: { name?: string; arguments?: string }
}
/** Parse an OpenAI SSE stream: emit content deltas via onToken, accumulate the final result. */
async function readStream(body: ReadableStream<Uint8Array>, onToken: OnToken): Promise<CompletionResult> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let content = ''
const toolAcc: { id: string; name: string; arguments: string }[] = []
const handle = (data: string) => {
if (data === '[DONE]') return
let chunk: { choices?: { delta?: { content?: string; tool_calls?: RawToolDelta[] } }[] }
try {
chunk = JSON.parse(data)
} catch {
return
}
const delta = chunk.choices?.[0]?.delta
if (!delta) return
if (delta.content) {
content += delta.content
onToken(delta.content)
}
for (const tc of delta.tool_calls ?? []) {
const slot = (toolAcc[tc.index] ??= { id: '', name: '', arguments: '' })
if (tc.id) slot.id = tc.id
if (tc.function?.name) slot.name = tc.function.name
if (tc.function?.arguments) slot.arguments += tc.function.arguments
}
}
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const t = line.trim()
if (t.startsWith('data:')) handle(t.slice(5).trim())
}
}
if (buffer.trim().startsWith('data:')) handle(buffer.trim().slice(5).trim())
return { content, toolCalls: toolAcc.filter((t) => t.name).map((t) => ({ id: t.id, name: t.name, arguments: t.arguments })) }
}

View File

@@ -1,71 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { DirectiveEntry } from '../directives/record-directive-v0.js'
import type { Focus, ScheduledItem } from '../scheduler/scheduler-v0.js'
import {
activeDirectives,
assembleHotContext,
estimateTokens,
HOT_CONTEXT_BUDGET_TOKENS,
} from './memory-v0.js'
const item = (number: number, title: string): ScheduledItem =>
({ number, title, labels: [], order: 0, startDay: 0, endDay: 1, durationDays: 1, blockedBy: [], blocks: [], critical: false, rationale: '' })
const focus: Focus = { now: item(7, 'Fix lifecycle inference'), next: item(8, 'Webhook listener'), later: null }
const directive = (over: Partial<DirectiveEntry>): DirectiveEntry => ({
id: 'd1',
ts: '2026-02-10T09:00:00Z',
status: 'accepted',
kind: 'note',
quote: 'pilots come first',
...over,
})
describe('memory-v0 (#27)', () => {
it('estimateTokens is a slight over-estimate (~4 chars/token)', () => {
expect(estimateTokens('')).toBe(0)
expect(estimateTokens('abcd')).toBe(1)
expect(estimateTokens('a'.repeat(4001))).toBe(1001)
})
it('activeDirectives keeps accepted/amended, most-recent-first', () => {
const ds = [
directive({ id: 'a', ts: '2026-02-01T00:00:00Z', status: 'accepted', quote: 'old' }),
directive({ id: 'b', ts: '2026-02-11T00:00:00Z', status: 'amended', quote: 'new' }),
directive({ id: 'c', ts: '2026-02-12T00:00:00Z', status: 'withdrawn', quote: 'gone' }),
directive({ id: 'd', ts: '2026-02-09T00:00:00Z', status: 'proposed', quote: 'maybe' }),
]
expect(activeDirectives(ds).map((d) => d.quote)).toEqual(['new', 'old'])
})
it('assembles hot context under the 2k budget even with a huge charter', () => {
const huge = 'Charter line that goes on and on. '.repeat(2000) // ~34k tokens
const ds = Array.from({ length: 50 }, (_, i) =>
directive({ id: `d${i}`, ts: `2026-02-${String((i % 27) + 1).padStart(2, '0')}T00:00:00Z`, quote: `directive number ${i}` }),
)
const out = assembleHotContext({ charter: huge, directives: ds, focus })
expect(estimateTokens(out)).toBeLessThanOrEqual(HOT_CONTEXT_BUDGET_TOKENS)
// focus (tiny) is always kept; the charter is the part that gets truncated
expect(out).toContain('## Focus')
expect(out).toContain('#7 Fix lifecycle inference')
expect(out).toContain('…') // charter was clamped
// at least some recent directives survived
expect(out).toContain('## Active directives')
})
it('never inlines ticket bodies — only numbers + titles appear for focus', () => {
// The assembler takes no issue bodies by construction; focus shows #number title only.
const out = assembleHotContext({ charter: 'Ship the beta.', directives: [directive({})], focus })
expect(out).toContain('Now: #7 Fix lifecycle inference')
expect(out).toContain('[note] pilots come first')
expect(out).not.toMatch(/body|description|comment/i)
})
it('degrades to just focus when there is no charter or directives', () => {
const out = assembleHotContext({ charter: '', directives: [], focus })
expect(out).toBe(['## Focus', 'Now: #7 Fix lifecycle inference', 'Next: #8 Webhook listener', 'Later: —'].join('\n'))
})
})

View File

@@ -1,102 +0,0 @@
/**
* Memory layers, v0 (#27). Reginald's context is tiered so the model always sees
* what matters without ever copying ticket data into the prompt:
*
* - HOT (this module) — charter + active directives + the focus snapshot, packed
* under a hard token budget. Assembled fresh each turn; it's the system-prompt seed.
* - WARM — the append-only directive/event ledger + periodic digest, in pm-state.
* Not inlined; summarized on demand.
* - COLD — gitea + the sidecar, reached through `query_project` tools. Ticket bodies,
* comments, and per-issue detail live here and are NEVER copied into memory —
* the model fetches them by number when it needs them.
*
* The invariant: HOT stays under budget, and nothing ticket-shaped is inlined.
*/
import type { DirectiveEntry } from '../directives/record-directive-v0.js'
import type { Focus } from '../scheduler/scheduler-v0.js'
/** The hot layer's hard ceiling (#27: hot context assembles under 2k tokens). */
export const HOT_CONTEXT_BUDGET_TOKENS = 2000
/**
* Tokenizer-free estimate (~4 chars/token). Deliberately a slight over-estimate so
* a real tokenizer never exceeds what this predicts — the budget stays safe.
*/
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4)
}
/** Directives that still bind: accepted or amended, most-recent-first. */
export function activeDirectives(all: DirectiveEntry[]): DirectiveEntry[] {
return all
.filter((d) => d.status === 'accepted' || d.status === 'amended')
.slice()
.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0))
}
export interface HotContextInputs {
/** The project charter markdown (hot-memory seed). */
charter: string
/** The directive ledger (any status — filtered to active here). */
directives: DirectiveEntry[]
/** The current Now/Next/Later focus, or null when nothing is scheduled. */
focus: Focus | null
}
function focusBlock(focus: Focus | null): string {
if (!focus) return ''
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')
}
function directivesBlock(directives: DirectiveEntry[]): string[] {
// one compact line each; the verbatim quote is the payload, kind is the tag
return directives.map((d) => `- [${d.kind}] ${d.quote}`)
}
/** Truncate to a token budget on a whitespace boundary, with an ellipsis marker. */
function clampToTokens(text: string, budgetTokens: number): string {
if (estimateTokens(text) <= budgetTokens) return text
const maxChars = Math.max(0, budgetTokens * 4 - 1)
const cut = text.slice(0, maxChars)
const lastBreak = cut.lastIndexOf('\n')
return `${(lastBreak > maxChars * 0.6 ? cut.slice(0, lastBreak) : cut).trimEnd()}\n…`
}
/**
* Assemble the HOT context under `budget` tokens. Priority when space is tight:
* the focus snapshot (tiny, always kept) → the most recent active directives
* (each while they fit) → the charter fills whatever budget remains (truncated).
* Never inlines ticket bodies — only charter text, directive quotes, and focus
* titles, all authored/short. Returns a single prompt-ready block.
*/
export function assembleHotContext(inputs: HotContextInputs, budget = HOT_CONTEXT_BUDGET_TOKENS): string {
const focus = focusBlock(inputs.focus)
const focusTokens = focus ? estimateTokens(focus) : 0
// fit the most recent active directives into ~⅔ of what's left after focus
const active = activeDirectives(inputs.directives)
const directiveCap = Math.max(0, Math.floor((budget - focusTokens) * (2 / 3)))
const keptDirectives: string[] = []
let directiveTokens = 0
for (const line of directivesBlock(active)) {
const t = estimateTokens(line) + 1
if (directiveTokens + t > directiveCap) break
keptDirectives.push(line)
directiveTokens += t
}
const directives = keptDirectives.length ? ['## Active directives', ...keptDirectives].join('\n') : ''
// Measure the fixed tail (directives + focus, with their joiner) exactly, then
// give the charter the true remainder — reserving for the "## Charter" header,
// the block joiner, and the truncation ellipsis so the total never exceeds budget.
const tail = [directives, focus].filter(Boolean).join('\n\n')
const tailTokens = tail ? estimateTokens(tail) : 0
const reserve = estimateTokens(`## Charter\n${tail ? '\n\n' : ''}\n…`)
const charterBudget = Math.max(0, budget - tailTokens - reserve)
const charterBody = inputs.charter.trim() ? clampToTokens(inputs.charter.trim(), charterBudget) : ''
const charter = charterBody ? `## Charter\n${charterBody}` : ''
return [charter, tail].filter(Boolean).join('\n\n')
}

View File

@@ -1,22 +0,0 @@
/**
* Model router (per PLAN.md / decisions.md): prose + the read tool run on a
* small local model; decomposition/negotiation earns the big one. Reginald v0
* only exercises the small model (read + prose); the `big` slot is declared so
* `capture_work` / `record_directive` can route to it without a rewrite.
*/
import type { ModelConfig } from './chat-client.js'
export type TaskKind = 'ritual' | 'plan'
export interface ModelRouter {
/** Small local model — standups, focus prose, the read tool. */
small: ModelConfig
/** Big model — capture decomposition, directive negotiation. */
big: ModelConfig
}
/** `plan` → the big model; everything else → the small one. */
export function pickModel(router: ModelRouter, kind: TaskKind): ModelConfig {
return kind === 'plan' ? router.big : router.small
}

View File

@@ -1,74 +0,0 @@
import { describe, expect, it } from 'vitest'
import { extractLabelFacts } from '../labels/label-schema.js'
import type { LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
import type { GiteaIssue } from '../gitea/types.js'
import { buildProjectView, type ProjectSnapshot } from './query-project.js'
const asOf = new Date('2026-02-12T00:00:00Z')
function issue(over: Partial<GiteaIssue>): GiteaIssue {
const labels = over.labels ?? ['est/2d']
return {
number: 1,
title: '#1',
body: '',
state: 'open',
labels,
facts: extractLabelFacts(labels),
milestone: null,
assignee: null,
assignees: [],
createdAt: '2026-02-02T09:00:00Z',
updatedAt: '2026-02-02T09:00:00Z',
closedAt: null,
url: '',
...over,
}
}
describe('buildProjectView: standup (#28)', () => {
it('surfaces schedule drift + a stale blocker, with a per-person plan', () => {
// #7 started work 6 working days ago (steeping) and blocks #8; est is 2d → past estimate.
const steeping = issue({ number: 7, labels: ['est/2d', 'p/1'], assignee: 'christian' })
const blocked = issue({ number: 8, labels: ['est/3d', 'p/2'], assignee: 'stephen' })
const timelines: Record<number, LifecycleEvent[]> = {
7: [{ type: 'commit', at: '2026-02-04T09:00:00Z' }],
}
const snap: ProjectSnapshot = { issues: [steeping, blocked], timelines, deps: [{ issue: 8, dependsOn: 7 }] }
const v = buildProjectView('standup', undefined, snap, asOf) as {
plan: { who: string; issue: number; why: string }[]
drift: { issue: number; note: string }[]
nag: { issue: number; steepingDays: number; blocks: number[] } | null
}
// a stale blocker is nagged, and it's the steeping one that blocks another
expect(v.nag).not.toBeNull()
expect(v.nag!.issue).toBe(7)
expect(v.nag!.steepingDays).toBeGreaterThan(0)
expect(v.nag!.blocks).toContain(8)
// drift caught the past-estimate steep
expect(v.drift.some((d) => d.issue === 7)).toBe(true)
// plan gives an earliest pick per person (both assignees represented)
expect(v.plan.map((p) => p.who)).toEqual(expect.arrayContaining(['christian', 'stephen']))
// #7 leads its person's plan on the critical path
expect(v.plan.find((p) => p.issue === 7)?.why).toContain('critical')
})
it('is calm when nothing is steeping (no nag, empty drift)', () => {
const snap: ProjectSnapshot = {
issues: [issue({ number: 1, labels: ['est/2d'] })],
timelines: {},
deps: [],
}
const v = buildProjectView('standup', undefined, snap, asOf) as {
drift: unknown[]
nag: unknown | null
}
expect(v.nag).toBeNull()
expect(v.drift).toEqual([])
})
})

View File

@@ -1,204 +0,0 @@
/**
* `query_project` — Reginald's single read tool. It selects a compact,
* model-friendly view over the reconciled backlog. Every number comes from
* deterministic code (scheduler, lifecycle inference, calibration); the model
* only requests a shape and narrates it — it never computes (decisions.md).
*
* Serves focus / board / calibration / issue / search / standup. The remaining
* views (milestone / runway) return a `notImplemented` marker so the model
* degrades honestly instead of inventing data.
*/
import { fitCalibration, calibrationSamples } from '../calibration/calibration-v0.js'
import type { GiteaIssue } from '../gitea/types.js'
import { inferLifecycle, type LifecycleColumn, type LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
import { type DependencyEdge, schedule, selectFocus } from '../scheduler/scheduler-v0.js'
export type ProjectView =
| 'focus'
| 'board'
| 'calibration'
| 'issue'
| 'milestone'
| 'runway'
| 'standup'
| 'search'
export interface QueryFilters {
issueId?: number
query?: string
limit?: number
}
/** The reconciled inputs a view is built from. */
export interface ProjectSnapshot {
issues: GiteaIssue[]
timelines: Record<number, LifecycleEvent[]>
deps: DependencyEdge[]
}
const COLUMN_ORDER: LifecycleColumn[] = ['diagnosis', 'triage', 'steeping', 'review', 'done']
function toSchedulable(issues: GiteaIssue[]) {
return issues
.filter((i) => i.state === 'open')
.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
}))
}
function focusView(snap: ProjectSnapshot) {
const plan = schedule(toSchedulable(snap.issues), snap.deps)
const f = selectFocus(plan)
const slot = (item: (typeof plan.items)[number] | null) =>
item ? { issue: item.number, title: item.title, rationale: item.rationale } : null
return { now: slot(f.now), next: slot(f.next), later: slot(f.later), openCount: plan.items.length }
}
function boardView(snap: ProjectSnapshot, asOf: Date) {
const columns: Record<string, { issue: number; title: string; labels: string[] }[]> = {}
for (const key of COLUMN_ORDER) columns[key] = []
for (const issue of snap.issues) {
const inf = inferLifecycle(issue, snap.timelines[issue.number] ?? [], asOf)
columns[inf.column].push({ issue: issue.number, title: issue.title, labels: issue.labels })
}
return {
columns: COLUMN_ORDER.map((key) => ({ column: key, count: columns[key].length, issues: columns[key] })),
}
}
function calibrationView(snap: ProjectSnapshot, asOf: Date) {
const model = fitCalibration(calibrationSamples(snap.issues, snap.timelines, asOf))
return {
n: model.n,
coldStart: model.coldStart,
globalMultiplier: Number(Math.exp(model.global.mu).toFixed(2)),
byBucket: Object.entries(model.byBucket).map(([bucket, fit]) => ({
estimate: `est/${bucket}d`,
n: fit.n,
multiplier: Number(Math.exp(fit.mu).toFixed(2)),
})),
}
}
function issueView(snap: ProjectSnapshot, filters: QueryFilters, asOf: Date) {
const issue = snap.issues.find((i) => i.number === filters.issueId)
if (!issue) return { notFound: filters.issueId ?? null }
const inf = inferLifecycle(issue, snap.timelines[issue.number] ?? [], asOf)
const blockedBy = snap.deps.filter((d) => d.issue === issue.number).map((d) => d.dependsOn)
const blocks = snap.deps.filter((d) => d.dependsOn === issue.number).map((d) => d.issue)
return {
issue: issue.number,
title: issue.title,
state: issue.state,
column: inf.column,
labels: issue.labels,
assignee: issue.assignee,
milestone: issue.milestone?.title ?? null,
estimateDays: issue.facts.estimateDays,
priority: issue.facts.priority,
steepingDays: inf.steepingDays,
blockedBy,
blocks,
}
}
function searchView(snap: ProjectSnapshot, filters: QueryFilters) {
const q = (filters.query ?? '').toLowerCase().trim()
const limit = Math.min(filters.limit ?? 20, 100)
const hits = q
? snap.issues.filter(
(i) => i.title.toLowerCase().includes(q) || i.labels.some((l) => l.toLowerCase().includes(q)),
)
: []
return {
query: filters.query ?? '',
results: hits.slice(0, limit).map((i) => ({ issue: i.number, title: i.title, state: i.state, labels: i.labels })),
}
}
/**
* Standup — the morning ritual as a compact, model-narratable payload: today's
* plan (the scheduler's earliest pick per person), overnight drift (real
* anomalies — issues sitting in review or steeping past their estimate), and the
* single stalest blocker to nag about. All from deterministic code; the model
* narrates it. Satisfies #28's "surfaces schedule drift + at least one stale blocker".
*/
function standupView(snap: ProjectSnapshot, asOf: Date) {
const plan = schedule(toSchedulable(snap.issues), snap.deps)
const open = snap.issues.filter((i) => i.state === 'open')
const infOf = (i: GiteaIssue) => inferLifecycle(i, snap.timelines[i.number] ?? [], asOf)
// plan: the earliest scheduled pick per assignee (dependency + priority order)
const seen = new Set<string>()
const planPicks: { who: string; issue: number; title: string; why: string }[] = []
for (const item of plan.items) {
const who = open.find((i) => i.number === item.number)?.assignee ?? 'unassigned'
if (seen.has(who)) continue
seen.add(who)
planPicks.push({
who,
issue: item.number,
title: item.title,
why: item.critical
? 'on the critical path'
: item.blocks.length
? `blocks ${item.blocks.map((b) => `#${b}`).join(', ')}`
: 'next by dependency + priority',
})
}
// drift: real overnight anomalies (review-sitting, steeping past estimate)
const drift: { issue: number; note: string }[] = []
for (const i of open) {
const inf = infOf(i)
if (inf.column === 'review') drift.push({ issue: i.number, note: `#${i.number} is sitting in review` })
else if (inf.steepingDays != null && inf.steepingDays > (i.facts.estimateDays ?? 2))
drift.push({
issue: i.number,
note: `#${i.number} has steeped ${inf.steepingDays}d past its ${i.facts.estimateDays ?? 2}d estimate`,
})
}
// nag: the single longest-steeping open issue + what it blocks
let nag: { issue: number; steepingDays: number; blocks: number[] } | null = null
for (const i of open) {
const inf = infOf(i)
if (inf.steepingDays == null) continue
if (!nag || inf.steepingDays > nag.steepingDays) {
nag = { issue: i.number, steepingDays: inf.steepingDays, blocks: plan.items.find((it) => it.number === i.number)?.blocks ?? [] }
}
}
return { date: asOf.toISOString().slice(0, 10), plan: planPicks.slice(0, 5), drift: drift.slice(0, 5), nag }
}
/** Build the compact payload for one view. Unknown/unbuilt views return a marker. */
export function buildProjectView(
view: ProjectView,
filters: QueryFilters | undefined,
snap: ProjectSnapshot,
asOf: Date,
): unknown {
const f = filters ?? {}
switch (view) {
case 'focus':
return focusView(snap)
case 'board':
return boardView(snap, asOf)
case 'calibration':
return calibrationView(snap, asOf)
case 'issue':
return issueView(snap, f, asOf)
case 'search':
return searchView(snap, f)
case 'standup':
return standupView(snap, asOf)
default:
return { notImplemented: view }
}
}

View File

@@ -1,191 +0,0 @@
/**
* Purity / rebuild guarantee (#30, D4). The SQLite cache is a *rebuildable index*
* over the durable truth in gitea — never a source of truth. This test enforces
* that invariant the only way that matters: nuke the database file on disk and
* rebuild it from the same gitea snapshot, then assert nothing was lost.
*
* If a future change ever stores a fact that lives only in the cache (a
* user-authored note, a locally-computed field that isn't re-derived from the
* label set), the delete→resync round-trip would drop it and this test fails.
* That is the whole point: the cache must survive being deleted.
*
* Uses a real on-disk SQLite file via node:sqlite (present in the test runtime;
* main binds better-sqlite3 to the same CacheDriver seam), so "delete the SQLite
* file" is literal `rm`, not a metaphor.
*/
import { DatabaseSync } from 'node:sqlite'
import { mkdtempSync, existsSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { GiteaIssue } from '../gitea/types.js'
import { extractLabelFacts } from '../labels/label-schema.js'
import { type CacheDriver, initCache, readIssue, upsertIssue } from './cache-v0.js'
/** Adapt a file-backed node:sqlite handle to the CacheDriver seam, returning the path too. */
function fileDriver(path: string): { driver: CacheDriver; close: () => void } {
const db = new DatabaseSync(path)
const driver: CacheDriver = {
exec: (sql) => db.exec(sql),
run: (sql, params = []) => {
db.prepare(sql).run(...(params as never[]))
},
get: (sql, params = []) => db.prepare(sql).get(...(params as never[])) as Record<string, unknown> | undefined,
all: (sql, params = []) => db.prepare(sql).all(...(params as never[])) as Record<string, unknown>[],
}
return { driver, close: () => db.close() }
}
function issue(over: Partial<GiteaIssue> = {}): GiteaIssue {
const labels = over.labels ?? ['est/5d', 'p/1']
return {
number: 1,
title: 'An issue',
body: 'body',
state: 'open',
labels,
facts: extractLabelFacts(labels),
milestone: null,
assignee: 'christian',
assignees: ['christian'],
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-02T00:00:00Z',
closedAt: null,
url: 'https://gitea/christian/commitea/issues/1',
...over,
}
}
/** A representative reconciled backlog: open + closed, milestones, reassignment, no-milestone, empty labels. */
const GITEA_TRUTH: GiteaIssue[] = [
issue({
number: 42,
title: 'Monte Carlo engine',
labels: ['est/8d', 'p/1', 'deadline/hard'],
milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' },
assignee: 'christian',
assignees: ['christian'],
}),
issue({
number: 43,
title: 'Calibration honesty',
labels: ['est/3d', 'p/2'],
state: 'closed',
closedAt: '2026-07-05T00:00:00Z',
milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' },
assignee: 'stephen',
assignees: ['stephen'],
}),
issue({ number: 44, title: 'No milestone, no labels', labels: [], milestone: null, assignee: null, assignees: [] }),
]
/** Rebuild a cache from scratch out of the gitea snapshot — exactly what a resync does. */
function rebuildFrom(driver: CacheDriver, truth: GiteaIssue[]): void {
initCache(driver)
for (const i of truth) upsertIssue(driver, i)
}
/** The complete durable state we can read back — every issue, fully re-derived. */
function readAll(driver: CacheDriver, truth: GiteaIssue[]): (GiteaIssue | null)[] {
return truth.map((i) => readIssue(driver, i.number))
}
describe('cache purity / rebuild (#30)', () => {
let dir: string | null = null
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true })
dir = null
})
it('delete the SQLite file → resync → no durable truth is lost', () => {
dir = mkdtempSync(join(tmpdir(), 'commitea-cache-'))
const dbPath = join(dir, 'cache.sqlite')
// 1. Build the cache from gitea and capture everything we can read back.
const first = fileDriver(dbPath)
rebuildFrom(first.driver, GITEA_TRUTH)
const before = readAll(first.driver, GITEA_TRUTH)
first.close()
expect(existsSync(dbPath)).toBe(true)
// sanity: the snapshot actually holds derived facts, not just rows
expect(before[0]!.facts).toEqual(extractLabelFacts(['est/8d', 'p/1', 'deadline/hard']))
expect(before[0]!.facts.estimateDays).toBe(8)
// 2. Delete the SQLite file. This is the durable cache, gone.
rmSync(dbPath)
// node:sqlite also drops a -journal/-wal sidecar in some modes; clear the dir of any residue.
expect(existsSync(dbPath)).toBe(false)
// 3. Resync: a brand-new empty DB rebuilt from the *same* gitea snapshot.
const second = fileDriver(dbPath)
rebuildFrom(second.driver, GITEA_TRUTH)
const after = readAll(second.driver, GITEA_TRUTH)
second.close()
// 4. Nothing was lost — the rebuilt cache is identical, field for field.
expect(after).toEqual(before)
})
it('a re-reconcile that changes gitea updates in place — the rebuild reflects truth, never stale rows', () => {
dir = mkdtempSync(join(tmpdir(), 'commitea-cache-'))
const dbPath = join(dir, 'cache.sqlite')
const first = fileDriver(dbPath)
rebuildFrom(first.driver, GITEA_TRUTH)
first.close()
// gitea moved on: #42 re-estimated + reassigned, #43 reopened.
const NEW_TRUTH: GiteaIssue[] = [
issue({ ...GITEA_TRUTH[0], labels: ['est/2d', 'p/3'], assignee: 'stephen', assignees: ['stephen'] }),
issue({ ...GITEA_TRUTH[1], state: 'open', closedAt: null }),
GITEA_TRUTH[2],
]
// resync over the existing file (upsert-by-number), not a fresh DB
const second = fileDriver(dbPath)
initCache(second.driver)
for (const i of NEW_TRUTH) upsertIssue(second.driver, i)
const rowCount = second.driver.all('SELECT number FROM issues')
const back42 = readIssue(second.driver, 42)!
const back43 = readIssue(second.driver, 43)!
second.close()
expect(rowCount).toHaveLength(3) // upsert in place — no duplicate rows accreted across reconciles
expect(back42.facts).toEqual(extractLabelFacts(['est/2d', 'p/3']))
expect(back42.facts.estimateDays).toBe(2)
expect(back42.assignee).toBe('stephen')
expect(back43.state).toBe('open')
expect(back43.closedAt).toBeNull()
})
it('the cache stores no column that is not re-derivable from gitea (structural D4 guard)', () => {
dir = mkdtempSync(join(tmpdir(), 'commitea-cache-'))
const dbPath = join(dir, 'cache.sqlite')
const { driver, close } = fileDriver(dbPath)
initCache(driver)
// Every issues-table column must map to a field carried on the gitea issue
// (or be a re-derivable mirror of one). If someone adds a user-authored
// column, it won't be in this allow-list and this guard fails — forcing a
// deliberate decision about durability instead of silently breaking D4.
const cols = driver.all('PRAGMA table_info(issues)').map((r) => r.name as string)
const FROM_GITEA = new Set([
'number',
'title',
'body',
'state',
'labels', // facts are re-derived from this on read, never stored
'milestone_id',
'assignee',
'assignees',
'created_at',
'updated_at',
'closed_at',
'url',
])
close()
expect(cols.filter((c) => !FROM_GITEA.has(c))).toEqual([])
})
})

View File

@@ -1,92 +0,0 @@
import { DatabaseSync } from 'node:sqlite'
import { describe, expect, it } from 'vitest'
import type { GiteaIssue } from '../gitea/types.js'
import { extractLabelFacts } from '../labels/label-schema.js'
import { type CacheDriver, initCache, readIssue, upsertIssue } from './cache-v0.js'
/** Adapt node:sqlite's DatabaseSync to the CacheDriver seam (main uses better-sqlite3). */
function memoryDriver(): CacheDriver {
const db = new DatabaseSync(':memory:')
return {
exec: (sql) => db.exec(sql),
run: (sql, params = []) => {
db.prepare(sql).run(...(params as never[]))
},
get: (sql, params = []) => db.prepare(sql).get(...(params as never[])) as Record<string, unknown> | undefined,
all: (sql, params = []) => db.prepare(sql).all(...(params as never[])) as Record<string, unknown>[],
}
}
function issue(over: Partial<GiteaIssue> = {}): GiteaIssue {
const labels = over.labels ?? ['est/5d', 'p/1', 'deadline/hard']
return {
number: 42,
title: 'Monte Carlo engine',
body: 'percentile bands',
state: 'open',
labels,
facts: extractLabelFacts(labels),
milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' },
assignee: 'christian',
assignees: ['christian'],
createdAt: '2026-07-08T00:00:00Z',
updatedAt: '2026-07-08T01:00:00Z',
closedAt: null,
url: 'https://gitea/christian/commitea/issues/42',
...over,
}
}
describe('cache-v0', () => {
it('mirrors one issue and reads its facts back through extractLabelFacts (acceptance)', () => {
const d = memoryDriver()
initCache(d)
upsertIssue(d, issue({ labels: ['est/5d', 'p/1', 'deadline/hard'] }))
const back = readIssue(d, 42)!
expect(back.labels).toEqual(['est/5d', 'p/1', 'deadline/hard'])
// facts are re-derived on read, not stored
expect(back.facts.estimateDays).toBe(5)
expect(back.facts.priority).toBe(1)
expect(back.facts.hardDeadline).toBe(true)
// the rest of the domain shape round-trips
expect(back.milestone).toEqual({ id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' })
expect(back.assignee).toBe('christian')
expect(back.state).toBe('open')
})
it('re-derives facts from the current labels after a re-reconcile (upsert in place, no dup)', () => {
const d = memoryDriver()
initCache(d)
upsertIssue(d, issue({ labels: ['est/2d', 'p/3'] }))
// reconcile again with changed labels + closed
upsertIssue(d, issue({ labels: ['est/8d', 'p/1'], state: 'closed', closedAt: '2026-07-09T00:00:00Z' }))
expect(d.all('SELECT number FROM issues')).toHaveLength(1) // upsert by number, not a second row
const back = readIssue(d, 42)!
expect(back.facts.estimateDays).toBe(8)
expect(back.facts.priority).toBe(1)
expect(back.facts.hardDeadline).toBe(false) // deadline/hard dropped
expect(back.state).toBe('closed')
expect(back.closedAt).toBe('2026-07-09T00:00:00Z')
})
it('reads an issue with no milestone / empty labels', () => {
const d = memoryDriver()
initCache(d)
upsertIssue(d, issue({ number: 9, labels: [], milestone: null, assignee: null, assignees: [] }))
const back = readIssue(d, 9)!
expect(back.milestone).toBeNull()
expect(back.labels).toEqual([])
expect(back.facts.estimateDays).toBeNull()
expect(back.assignee).toBeNull()
})
it('returns null for an uncached issue', () => {
const d = memoryDriver()
initCache(d)
expect(readIssue(d, 999)).toBeNull()
})
})

View File

@@ -1,159 +0,0 @@
/**
* SQLite cache, v0 (#3) — a rebuildable local mirror of the reconciled backlog.
* It is an index over the durable truth in gitea, never the source of truth (D4):
* delete it, resync, lose nothing. This module owns the schema + the pure
* row<->domain mappers; the actual SQLite handle is injected as a `CacheDriver`,
* so core stays free of any native driver (better-sqlite3 lives in main; tests
* use node:sqlite). Facts are never stored — they are re-derived from the label
* set on read via `extractLabelFacts`, so the mirror can't drift from the label
* semantics.
*/
import type { GiteaIssue, GiteaMilestoneRef } from '../gitea/types.js'
import { extractLabelFacts } from '../labels/label-schema.js'
/**
* The injected IO boundary: a thin synchronous SQL executor. Core writes the SQL;
* the host binds a real driver (better-sqlite3 in the desktop main process,
* node:sqlite in tests). Kept minimal on purpose — no ORM, no query builder.
*/
export interface CacheDriver {
/** Run one or more DDL/utility statements (no params, no result). */
exec(sql: string): void
/** Execute a single parameterized write. */
run(sql: string, params?: readonly unknown[]): void
/** First row of a parameterized query, or undefined. */
get(sql: string, params?: readonly unknown[]): Record<string, unknown> | undefined
/** All rows of a parameterized query. */
all(sql: string, params?: readonly unknown[]): Record<string, unknown>[]
}
/** The cache schema — five tables mirroring gitea's shape. Regenerable; drop and rebuild freely. */
export const CACHE_SCHEMA = `
CREATE TABLE IF NOT EXISTS milestones (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
state TEXT,
due_on TEXT
);
CREATE TABLE IF NOT EXISTS issues (
number INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL,
labels TEXT NOT NULL DEFAULT '[]', -- JSON array of label names; facts re-derived on read
milestone_id INTEGER,
assignee TEXT,
assignees TEXT NOT NULL DEFAULT '[]', -- JSON array of logins
created_at TEXT,
updated_at TEXT,
closed_at TEXT,
url TEXT,
FOREIGN KEY (milestone_id) REFERENCES milestones(id)
);
CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY,
issue_number INTEGER NOT NULL,
author TEXT,
body TEXT NOT NULL DEFAULT '',
created_at TEXT
);
CREATE TABLE IF NOT EXISTS issue_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_number INTEGER NOT NULL,
type TEXT NOT NULL,
at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_issue_events_number ON issue_events(issue_number);
CREATE INDEX IF NOT EXISTS idx_comments_number ON comments(issue_number);
`
/** Create the schema if absent. Idempotent. */
export function initCache(driver: CacheDriver): void {
driver.exec(CACHE_SCHEMA)
}
const UPSERT_ISSUE = `
INSERT INTO issues (number, title, body, state, labels, milestone_id, assignee, assignees, created_at, updated_at, closed_at, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(number) DO UPDATE SET
title = excluded.title, body = excluded.body, state = excluded.state, labels = excluded.labels,
milestone_id = excluded.milestone_id, assignee = excluded.assignee, assignees = excluded.assignees,
created_at = excluded.created_at, updated_at = excluded.updated_at, closed_at = excluded.closed_at, url = excluded.url
`
const UPSERT_MILESTONE = `
INSERT INTO milestones (id, title, state, due_on) VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET title = excluded.title, state = excluded.state, due_on = excluded.due_on
`
/**
* Mirror one reconciled issue into the cache (and its milestone, if any). Upsert
* by `number`, so re-reconciling the same issue updates in place — never duplicates.
*/
export function upsertIssue(driver: CacheDriver, issue: GiteaIssue): void {
if (issue.milestone) {
driver.run(UPSERT_MILESTONE, [issue.milestone.id, issue.milestone.title, null, issue.milestone.dueOn])
}
driver.run(UPSERT_ISSUE, [
issue.number,
issue.title,
issue.body,
issue.state,
JSON.stringify(issue.labels),
issue.milestone?.id ?? null,
issue.assignee,
JSON.stringify(issue.assignees),
issue.createdAt,
issue.updatedAt,
issue.closedAt,
issue.url,
])
}
const READ_ISSUE = `
SELECT i.*, m.title AS m_title, m.due_on AS m_due
FROM issues i LEFT JOIN milestones m ON m.id = i.milestone_id
WHERE i.number = ?
`
function str(v: unknown): string {
return typeof v === 'string' ? v : ''
}
/**
* Read one mirrored issue back as a domain object, re-deriving `facts` from the
* stored label set (so the mirror can't disagree with the label semantics).
* Returns null when the issue isn't cached.
*/
export function readIssue(driver: CacheDriver, number: number): GiteaIssue | null {
const row = driver.get(READ_ISSUE, [number])
if (!row) return null
const labels = (JSON.parse(str(row.labels) || '[]') as string[]) ?? []
const assignees = (JSON.parse(str(row.assignees) || '[]') as string[]) ?? []
const milestone: GiteaMilestoneRef | null =
row.milestone_id != null
? { id: Number(row.milestone_id), title: str(row.m_title), dueOn: (row.m_due as string | null) ?? null }
: null
return {
number: Number(row.number),
title: str(row.title),
body: str(row.body),
state: row.state === 'closed' ? 'closed' : 'open',
labels,
facts: extractLabelFacts(labels),
milestone,
assignee: (row.assignee as string | null) ?? null,
assignees,
createdAt: str(row.created_at),
updatedAt: str(row.updated_at),
closedAt: (row.closed_at as string | null) ?? null,
url: str(row.url),
}
}

View File

@@ -1,144 +0,0 @@
import { describe, expect, it } from 'vitest'
import { extractLabelFacts } from '../labels/label-schema.js'
import type { LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
import type { GiteaIssue } from '../gitea/types.js'
import {
CALIBRATION_BUCKET_FLOOR,
calibrationCoverage,
calibrationSamples,
type CalibrationSample,
COLD_START_THRESHOLD,
fitCalibration,
toDurationModel,
} from './calibration-v0.js'
function sample(over: Partial<CalibrationSample> = {}): CalibrationSample {
return { issue: 1, estimateDays: 2, actualWorkingDays: 2, bucket: 2, person: null, ...over }
}
describe('fitCalibration', () => {
it('is cold-start below the threshold and reports the honest n', () => {
const m = fitCalibration([sample(), sample({ actualWorkingDays: 4 })])
expect(m.n).toBe(2)
expect(m.coldStart).toBe(true)
})
it('flips off cold-start at the threshold', () => {
const many = Array.from({ length: COLD_START_THRESHOLD }, (_, i) =>
sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 }),
)
const m = fitCalibration(many)
expect(m.n).toBe(COLD_START_THRESHOLD)
expect(m.coldStart).toBe(false)
})
it('recovers the global median ratio (mu = mean log-ratio)', () => {
// every actual is exactly 2x its estimate → mu = ln 2
const m = fitCalibration(Array.from({ length: 25 }, (_, i) => sample({ issue: i, estimateDays: 2, actualWorkingDays: 4 })))
expect(m.global.mu).toBeCloseTo(Math.log(2), 6)
})
it('fits a bucket only once it clears the floor', () => {
const twos = Array.from({ length: CALIBRATION_BUCKET_FLOOR }, (_, i) =>
sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 }),
)
const oneThin = [sample({ issue: 99, estimateDays: 5, actualWorkingDays: 9, bucket: 5 })]
const m = fitCalibration([...twos, ...oneThin])
expect(m.byBucket[2]?.n).toBe(CALIBRATION_BUCKET_FLOOR)
expect(m.byBucket[5]).toBeUndefined() // only 1 sample, below floor
})
it('drops non-positive estimates/actuals', () => {
const m = fitCalibration([sample({ actualWorkingDays: 0 }), sample({ estimateDays: 0 }), sample()])
expect(m.n).toBe(1)
})
it('derives a per-person bias relative to global', () => {
// one person consistently runs longer than the mean
const base = Array.from({ length: 20 }, (_, i) => sample({ issue: i, actualWorkingDays: 2, person: 'ak' }))
const slow = Array.from({ length: 3 }, (_, i) => sample({ issue: 100 + i, actualWorkingDays: 6, person: 'sm' }))
const m = fitCalibration([...base, ...slow])
expect(m.byPerson['sm'].biasMu).toBeGreaterThan(0)
expect(m.byPerson['ak'].biasMu).toBeLessThan(0)
})
})
describe('toDurationModel', () => {
it('projects the fit down to the params forecast needs', () => {
const m = fitCalibration(
Array.from({ length: 25 }, (_, i) => sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 })),
)
const dm = toDurationModel(m)
expect(dm.coldStart).toBe(false)
expect(dm.byBucket[2].mu).toBeCloseTo(m.byBucket[2].mu, 6)
expect(dm.global.mu).toBeCloseTo(m.global.mu, 6)
})
})
describe('calibrationSamples', () => {
const asOf = new Date('2026-02-01T00:00:00Z')
function issue(over: Partial<GiteaIssue>): GiteaIssue {
const labels = over.labels ?? []
return {
number: 1,
title: '#1',
body: '',
state: 'closed',
labels,
facts: extractLabelFacts(labels),
milestone: null,
assignee: null,
assignees: [],
createdAt: '2026-01-05T09:00:00Z',
updatedAt: '2026-01-12T09:00:00Z',
closedAt: '2026-01-12T09:00:00Z',
url: '',
...over,
}
}
const events = (i: number): Record<number, LifecycleEvent[]> => ({
[i]: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }],
})
it('samples closed, estimated issues with a resolvable actual', () => {
const i = issue({ number: 7, labels: ['est/2d'], assignee: 'sm', closedAt: '2026-01-12T09:00:00Z' })
const [s] = calibrationSamples([i], events(7), asOf)
expect(s.issue).toBe(7)
expect(s.estimateDays).toBe(2)
expect(s.bucket).toBe(2)
expect(s.person).toBe('sm')
// Wed 2026-01-07 → Mon 2026-01-12 = Wed,Thu,Fri = 3 working days
expect(s.actualWorkingDays).toBe(3)
})
it('skips open issues and closed ones without an estimate', () => {
const open = issue({ number: 8, state: 'open', labels: ['est/2d'], closedAt: null })
const noEst = issue({ number: 9, labels: [] })
expect(calibrationSamples([open, noEst], { ...events(8), ...events(9) }, asOf)).toEqual([])
})
it('coverage counts same-day closes as excluded candidates, not as "more closes needed"', () => {
// usable: commit Wed 01-07 → close Mon 01-12 = 3 working days
const usable = issue({ number: 7, labels: ['est/2d'] })
// same-day close: commit and close on the same day = 0 working days → excluded
const sameDay = issue({ number: 10, labels: ['est/2d'], createdAt: '2026-01-12T08:00:00Z' })
const sameDayEvents = {
10: [
{ type: 'commit', at: '2026-01-12T09:00:00Z' } as LifecycleEvent,
{ type: 'close', at: '2026-01-12T17:00:00Z' } as LifecycleEvent,
],
}
const open = issue({ number: 8, state: 'open', labels: ['est/2d'], closedAt: null })
const noEst = issue({ number: 9, labels: [] })
const cov = calibrationCoverage([usable, sameDay, open, noEst], { ...events(7), ...sameDayEvents }, asOf)
expect(cov.candidates).toBe(2) // closed + estimated only (usable + sameDay)
expect(cov.usable).toBe(1)
expect(cov.excludedSameDay).toBe(1)
// the honest denominator: usable matches the model's n
expect(cov.usable).toBe(calibrationSamples([usable, sameDay, open, noEst], { ...events(7), ...sameDayEvents }, asOf).length)
})
})

View File

@@ -1,164 +0,0 @@
/**
* Calibration, v0 — fit the team's own estimate-vs-actual history so the
* forecast stops guessing (D3). The "actual" is the working time lifecycle
* inference derives from git events (#5), never manual tracking. Fit a
* lognormal on log(actual / estimate) globally and per estimate bucket; until
* the sample clears the cold-start threshold, the forecast keeps using the
* code-resident priors and this model just reports progress toward it.
*/
import { type DurationModel, type LognormalPrior, nearestBucket } from '../forecast/forecast-v0.js'
import { inferLifecycle, type LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
import type { GiteaIssue } from '../gitea/types.js'
/** Global sample size at which the fit takes over from the cold-start priors. */
export const COLD_START_THRESHOLD = 20
/** Minimum per-bucket sample before that bucket earns its own fit. */
export const CALIBRATION_BUCKET_FLOOR = 3
/** Fallback spread when a group is too small to estimate one. */
const DEFAULT_SIGMA = 0.4
/** One closed issue's estimate vs its inferred actual. */
export interface CalibrationSample {
issue: number
estimateDays: number
actualWorkingDays: number
bucket: number
person: string | null
}
export interface BucketFit extends LognormalPrior {
n: number
}
export interface PersonBias {
/** Additive to global mu (log space). */
biasMu: number
n: number
}
export interface CalibrationModel {
/** Closed issues with an estimate + a resolvable actual. */
n: number
/** true while n < COLD_START_THRESHOLD — forecast keeps the code priors. */
coldStart: boolean
global: LognormalPrior
byBucket: Record<number, BucketFit>
byPerson: Record<string, PersonBias>
}
function mean(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0) / xs.length
}
/** Sample standard deviation; falls back to DEFAULT_SIGMA below 2 points. */
function stddev(xs: number[], mu: number): number {
if (xs.length < 2) return DEFAULT_SIGMA
const variance = xs.reduce((a, x) => a + (x - mu) ** 2, 0) / (xs.length - 1)
return Math.sqrt(variance) || DEFAULT_SIGMA
}
/** Fit a calibration model from estimate-vs-actual samples. Pure. */
export function fitCalibration(samples: CalibrationSample[]): CalibrationModel {
const usable = samples.filter((s) => s.estimateDays > 0 && s.actualWorkingDays > 0)
const n = usable.length
const coldStart = n < COLD_START_THRESHOLD
const logRatios = usable.map((s) => Math.log(s.actualWorkingDays / s.estimateDays))
const globalMu = n ? mean(logRatios) : 0
const global: LognormalPrior = { mu: globalMu, sigma: n ? stddev(logRatios, globalMu) : DEFAULT_SIGMA }
const byBucket: Record<number, BucketFit> = {}
const byPerson: Record<string, PersonBias> = {}
const groups = new Map<number, number[]>()
const people = new Map<string, number[]>()
for (const s of usable) {
const lr = Math.log(s.actualWorkingDays / s.estimateDays)
;(groups.get(s.bucket) ?? groups.set(s.bucket, []).get(s.bucket)!).push(lr)
if (s.person) (people.get(s.person) ?? people.set(s.person, []).get(s.person)!).push(lr)
}
for (const [bucket, lrs] of groups) {
if (lrs.length < CALIBRATION_BUCKET_FLOOR) continue
const mu = mean(lrs)
byBucket[bucket] = { mu, sigma: stddev(lrs, mu), n: lrs.length }
}
for (const [person, lrs] of people) {
if (lrs.length < CALIBRATION_BUCKET_FLOOR) continue
byPerson[person] = { biasMu: mean(lrs) - globalMu, n: lrs.length }
}
return { n, coldStart, global, byBucket, byPerson }
}
/** The subset of a model `forecast` consumes. */
export function toDurationModel(model: CalibrationModel): DurationModel {
const byBucket: Record<number, LognormalPrior> = {}
for (const [bucket, fit] of Object.entries(model.byBucket)) {
byBucket[Number(bucket)] = { mu: fit.mu, sigma: fit.sigma }
}
return { coldStart: model.coldStart, global: model.global, byBucket }
}
/**
* Extract calibration samples from the closed backlog: each closed issue that
* carries an estimate and yields an inferred actual working duration.
*/
export function calibrationSamples(
issues: GiteaIssue[],
timelines: Record<number, LifecycleEvent[]>,
asOf: Date,
): CalibrationSample[] {
const out: CalibrationSample[] = []
for (const issue of issues) {
if (issue.state !== 'closed') continue
const estimateDays = issue.facts.estimateDays
if (estimateDays == null) continue
const inf = inferLifecycle(issue, timelines[issue.number] ?? [], asOf)
if (inf.actualWorkingDays == null || inf.actualWorkingDays <= 0) continue
out.push({
issue: issue.number,
estimateDays,
actualWorkingDays: inf.actualWorkingDays,
bucket: nearestBucket(estimateDays),
person: issue.assignee,
})
}
return out
}
/** How the closed+estimated backlog splits into usable samples vs. what can't calibrate. */
export interface CalibrationCoverage {
/** Closed issues carrying an estimate — the calibration candidates. */
candidates: number
/** Candidates that yielded a usable actual (> 0 working days) → become samples. */
usable: number
/**
* Candidates excluded because the issue closed with 0 working days (same-day
* close) or no resolvable actual — real closes that structurally can't
* calibrate. Counting them keeps `usable/threshold` honest: it's not "N more
* closes away" if some of your closes will never count.
*/
excludedSameDay: number
}
/**
* Coverage of the calibration candidates — how many closed+estimated issues are
* usable vs. silently unusable (same-day / 0-day closes). {@link calibrationSamples}
* drops the latter; this counts them so the UI can say *why* the sample is thin.
*/
export function calibrationCoverage(
issues: GiteaIssue[],
timelines: Record<number, LifecycleEvent[]>,
asOf: Date,
): CalibrationCoverage {
let candidates = 0
let usable = 0
for (const issue of issues) {
if (issue.state !== 'closed') continue
if (issue.facts.estimateDays == null) continue
candidates++
const inf = inferLifecycle(issue, timelines[issue.number] ?? [], asOf)
if (inf.actualWorkingDays != null && inf.actualWorkingDays > 0) usable++
}
return { candidates, usable, excludedSameDay: candidates - usable }
}

View File

@@ -1,88 +0,0 @@
import { describe, expect, it } from 'vitest'
import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
import { makespan, scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js'
import { capacityPerWorkday, parseCapacityConfig } from './capacity-v0.js'
describe('capacity model', () => {
it('capacityPerWorkday = focusFactor × allocation', () => {
expect(capacityPerWorkday({ person: 'a', focusFactor: 0.8, allocation: 0.5 })).toBeCloseTo(0.4)
})
it('parses + clamps config, drops invalid members', () => {
const members = parseCapacityConfig({
members: [
{ person: 'christian', focusFactor: 0.8, allocation: 1 },
{ person: 'ak', focusFactor: 1.5, allocation: -1 }, // clamps to 1 / 0 → zero capacity → dropped
{ focusFactor: 0.8 }, // no person → dropped
],
})
expect(members).toEqual([{ person: 'christian', focusFactor: 0.8, allocation: 1 }])
})
it('returns [] for a non-array/absent members field', () => {
expect(parseCapacityConfig({})).toEqual([])
expect(parseCapacityConfig({ members: 'nope' })).toEqual([])
})
})
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
return { number, title: `#${number}`, labels: [], estimateDays: 4, priority: 2, ...over }
}
describe('scheduleWithCapacity', () => {
it('with no workers, falls back to the single serial plan', () => {
const issues = [issue(1), issue(2)]
const plan = scheduleWithCapacity(issues, [], [])
expect(makespan(plan)).toBe(8) // 4 + 4 serial
})
it('parallelizes independent work across lanes (makespan shrinks)', () => {
const issues = [issue(1), issue(2), issue(3), issue(4)] // 4×4d = 16d serial
const one: Worker[] = [{ person: 'a', speed: 1 }]
const two: Worker[] = [
{ person: 'a', speed: 1 },
{ person: 'b', speed: 1 },
]
expect(makespan(scheduleWithCapacity(issues, [], one))).toBe(16)
expect(makespan(scheduleWithCapacity(issues, [], two))).toBe(8) // two lanes → half
})
it('scales duration by a lane speed (slower lane takes longer)', () => {
const plan = scheduleWithCapacity([issue(1, { estimateDays: 4 })], [], [{ person: 'a', speed: 0.5 }])
expect(plan.items[0].durationDays).toBe(8) // 4 / 0.5
expect(plan.items[0].worker).toBe('a')
})
it('routes an issue to its assignee lane', () => {
const issues = [issue(1, { assignee: 'ak' }), issue(2, { assignee: 'sm' })]
const workers: Worker[] = [
{ person: 'ak', speed: 1 },
{ person: 'sm', speed: 1 },
]
const plan = scheduleWithCapacity(issues, [], workers)
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
expect(byN[1].worker).toBe('ak')
expect(byN[2].worker).toBe('sm')
// both start at 0 (different lanes) → parallel
expect(byN[1].startDay).toBe(0)
expect(byN[2].startDay).toBe(0)
})
it('adds a lane for an assignee not in the config (mean speed)', () => {
const plan = scheduleWithCapacity([issue(1, { assignee: 'newbie' })], [], [{ person: 'a', speed: 0.5 }])
expect(plan.items[0].worker).toBe('newbie')
expect(plan.items[0].durationDays).toBe(8) // mean speed 0.5 → 4/0.5
})
it('respects dependencies across lanes (a blocker finishes before its dependent starts)', () => {
const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }]
const workers: Worker[] = [
{ person: 'a', speed: 1 },
{ person: 'b', speed: 1 },
]
const plan = scheduleWithCapacity([issue(1), issue(2)], edges, workers)
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
expect(byN[2].startDay).toBeGreaterThanOrEqual(byN[1].endDay) // #2 waits for #1 even on another lane
})
})

View File

@@ -1,49 +0,0 @@
/**
* Capacity model (#8). A member's throughput is `focusFactor × allocation` =
* ideal person-days of project work delivered per calendar working day. The
* scheduler treats each member as a lane whose task durations are scaled by that
* rate (a 0.5-capacity person takes twice as long on an est/2d task). Config
* lives in the pm-state repo (`capacity/members.yaml/json`, D4); estimates are
* in ideal person-days (pm-state.md).
*/
export interface CapacityMember {
/** gitea login. */
person: string
/** Productive fraction of a working day (0..1). */
focusFactor: number
/** Fraction of that allocated to this project (0..1). */
allocation: number
}
/** Ideal person-days delivered per calendar working day. */
export function capacityPerWorkday(m: CapacityMember): number {
return m.focusFactor * m.allocation
}
function clamp01(n: unknown, fallback: number): number {
return typeof n === 'number' && Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback
}
/**
* Validate a raw capacity config (`{ members: [...] }`) into members. Unknown
* shapes degrade to `[]` (→ the scheduler falls back to a single worker), never
* throw. focusFactor/allocation clamp to [0,1]; a member without a person is dropped.
*/
export function parseCapacityConfig(raw: unknown): CapacityMember[] {
const list = (raw as { members?: unknown })?.members
if (!Array.isArray(list)) return []
const out: CapacityMember[] = []
for (const m of list) {
const r = (m ?? {}) as Record<string, unknown>
const person = typeof r.person === 'string' ? r.person.trim() : ''
if (!person) continue
const member: CapacityMember = {
person,
focusFactor: clamp01(r.focusFactor, 0.8),
allocation: clamp01(r.allocation, 1),
}
if (capacityPerWorkday(member) > 0) out.push(member)
}
return out
}

View File

@@ -1,134 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
describeChange,
isLabelChange,
type IssueChange,
planIssueChange,
proposalsFor,
summarizeChange,
} from './apply-changes-v0.js'
describe('planIssueChange', () => {
it('swaps the estimate label, keeping non-axis labels', () => {
const plan = planIssueChange(['est/2d', 'p/1', 'backend'], { kind: 'reestimate', issue: 1, estimate: 'est/5d' })
expect(plan.removed).toEqual(['est/2d'])
expect(plan.added).toEqual(['est/5d'])
expect(plan.labels).toEqual(['p/1', 'backend', 'est/5d'])
expect(plan.noop).toBe(false)
})
it('adds an estimate when none was set', () => {
const plan = planIssueChange(['p/2'], { kind: 'reestimate', issue: 1, estimate: 'est/1d' })
expect(plan.removed).toEqual([])
expect(plan.added).toEqual(['est/1d'])
expect(plan.labels).toEqual(['p/2', 'est/1d'])
})
it('clears the axis when the target is null', () => {
const plan = planIssueChange(['est/3d', 'p/1'], { kind: 'reprioritize', issue: 1, priority: null })
expect(plan.removed).toEqual(['p/1'])
expect(plan.added).toEqual([])
expect(plan.labels).toEqual(['est/3d'])
})
it('is a noop when the target already holds the axis alone', () => {
const plan = planIssueChange(['est/2d', 'p/1'], { kind: 'reestimate', issue: 1, estimate: 'est/2d' })
expect(plan.noop).toBe(true)
expect(plan.labels).toEqual(['p/1', 'est/2d'])
})
it('cleans up a duplicated axis down to the target', () => {
// two est/* labels — the change collapses to one
const plan = planIssueChange(['est/2d', 'est/5d', 'p/1'], { kind: 'reestimate', issue: 1, estimate: 'est/5d' })
expect(plan.removed).toEqual(['est/2d'])
expect(plan.added).toEqual([]) // est/5d already present
expect(plan.labels).toEqual(['p/1', 'est/5d'])
expect(plan.noop).toBe(false)
})
it('reprioritize only touches the priority axis', () => {
const plan = planIssueChange(['est/2d', 'p/3'], { kind: 'reprioritize', issue: 1, priority: 'p/1' })
expect(plan.labels).toEqual(['est/2d', 'p/1'])
})
it('describeChange renders the diff', () => {
const change: IssueChange = { kind: 'reestimate', issue: 1, estimate: 'est/5d' }
expect(describeChange(planIssueChange(['est/2d'], change))).toBe('est/2d → est/5d')
expect(describeChange(planIssueChange(['p/1'], { kind: 'reprioritize', issue: 1, priority: null }))).toBe('p/1 → ∅')
expect(describeChange(planIssueChange(['est/5d'], change))).toBe('no change')
})
})
describe('proposalsFor', () => {
it('builds one proposal per changed axis, carrying the concrete change + diff', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/5d', priority: 'p/1' }, ['est/2d', 'p/3'], 'ChangeSource')
expect(props).toHaveLength(2)
expect(props[0].change).toEqual({ kind: 'reestimate', issue: 2, estimate: 'est/5d' })
expect(props[0].summary).toBe('est/2d → est/5d')
expect(props[1].change).toEqual({ kind: 'reprioritize', issue: 2, priority: 'p/1' })
expect(props[0].issueTitle).toBe('ChangeSource')
})
it('proposes an assignment only when it differs from the current assignee', () => {
const change = proposalsFor({ issue: 2, assignee: 'christian' }, [], undefined, { currentAssignee: null })
expect(change).toHaveLength(1)
expect(change[0].change).toEqual({ kind: 'assign', issue: 2, assignee: 'christian' })
expect(change[0].summary).toBe('assign → @christian')
expect(change[0].plan).toBeUndefined()
// already assigned → no proposal
expect(proposalsFor({ issue: 2, assignee: 'christian' }, [], undefined, { currentAssignee: 'christian' })).toEqual([])
})
it('proposes a milestone move, labeled from the milestones list, skipping a no-op', () => {
const ctx = { currentMilestoneId: 1, milestones: [{ id: 3, title: 'P5 — Dogfood' }] }
const props = proposalsFor({ issue: 2, milestone: 3 }, [], undefined, ctx)
expect(props).toHaveLength(1)
expect(props[0].change).toEqual({ kind: 'remilestone', issue: 2, milestone: 3, milestoneTitle: 'P5 — Dogfood' })
expect(props[0].summary).toBe('milestone → P5 — Dogfood')
// same milestone → no proposal
expect(proposalsFor({ issue: 2, milestone: 1 }, [], undefined, ctx)).toEqual([])
})
it('drops a noop axis (already at the requested value)', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/2d', priority: 'p/1' }, ['est/2d', 'p/3'])
expect(props.map((p) => p.change.kind)).toEqual(['reprioritize']) // estimate unchanged
})
it('ignores invalid label values from the model', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/4d' as never, priority: 'high' as never }, [])
expect(props).toEqual([])
})
it('returns nothing when no axis is provided', () => {
expect(proposalsFor({ issue: 2 }, ['est/2d'])).toEqual([])
})
})
describe('unified change model (assign + milestone)', () => {
it('isLabelChange narrows label kinds only', () => {
expect(isLabelChange({ kind: 'reestimate', issue: 1, estimate: 'est/2d' })).toBe(true)
expect(isLabelChange({ kind: 'reprioritize', issue: 1, priority: 'p/1' })).toBe(true)
expect(isLabelChange({ kind: 'assign', issue: 1, assignee: 'christian' })).toBe(false)
expect(isLabelChange({ kind: 'remilestone', issue: 1, milestone: 3 })).toBe(false)
})
it('summarizeChange describes an assignment and an unassignment', () => {
expect(summarizeChange({ kind: 'assign', issue: 1, assignee: 'christian' })).toBe('assign → @christian')
expect(summarizeChange({ kind: 'assign', issue: 1, assignee: null })).toBe('unassign')
})
it('summarizeChange describes a milestone set (by title) and removal', () => {
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: 3, milestoneTitle: 'P5 — Dogfood' })).toBe(
'milestone → P5 — Dogfood',
)
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: 3 })).toBe('milestone → #3')
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: null })).toBe('remove from milestone')
})
it('summarizeChange delegates label kinds to describeChange (needs current labels)', () => {
const change: IssueChange = { kind: 'reestimate', issue: 1, estimate: 'est/5d' }
expect(summarizeChange(change, ['est/2d'])).toBe('est/2d → est/5d')
expect(summarizeChange(change, ['est/5d'])).toBe('no change')
})
})

View File

@@ -1,158 +0,0 @@
/**
* Apply-changes, v0 — the write path's planning half. Estimates and priority
* live as exclusive label axes (`est/*`, `p/*`); a change swaps the axis label.
* This computes the resulting label set + a human-readable diff *purely*, so the
* UI can show a propose-approve consequence before the write and tests can pin
* the semantics. The actual PUT (name→id resolution + network) is the bridge's
* job — additive here, never assumed.
*/
import {
type EstimateLabel,
ESTIMATE_LABELS,
type PriorityLabel,
PRIORITY_LABELS,
} from '../labels/label-schema.js'
export type IssueChange =
| { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null }
| { kind: 'reprioritize'; issue: number; priority: PriorityLabel | null }
| { kind: 'assign'; issue: number; assignee: string | null }
| { kind: 'remilestone'; issue: number; milestone: number | null; milestoneTitle?: string | null }
/** The label axes are the only kinds planned as a label swap; the rest are field writes. */
export function isLabelChange(
change: IssueChange,
): change is Extract<IssueChange, { kind: 'reestimate' | 'reprioritize' }> {
return change.kind === 'reestimate' || change.kind === 'reprioritize'
}
export interface LabelPlan {
/** The full resulting label-name set (order: kept labels, then the new axis label). */
labels: string[]
/** Axis labels being added (0 or 1). */
added: string[]
/** Axis labels being removed (includes clearing a duplicated axis). */
removed: string[]
/** true when the change would leave the labels unchanged. */
noop: boolean
}
/** A change that resolves to a label swap (the only kind `planIssueChange` accepts). */
export type LabelChange = Extract<IssueChange, { kind: 'reestimate' | 'reprioritize' }>
function axisFor(change: LabelChange): { labels: readonly string[]; target: string | null } {
return change.kind === 'reestimate'
? { labels: ESTIMATE_LABELS, target: change.estimate }
: { labels: PRIORITY_LABELS, target: change.priority }
}
/**
* Plan the label mutation for a single change. Removes every label on the
* change's axis except the target, adds the target if absent. Setting the axis
* to null clears it. Cleans up a duplicated axis (two `est/*`) as a side effect.
*/
export function planIssueChange(current: string[], change: LabelChange): LabelPlan {
const { labels: axis, target } = axisFor(change)
const onAxis = current.filter((l) => axis.includes(l))
const removed = onAxis.filter((l) => l !== target)
const added = target && !current.includes(target) ? [target] : []
const kept = current.filter((l) => !axis.includes(l))
const labels = target ? [...kept, target] : kept
return { labels, added, removed, noop: added.length === 0 && removed.length === 0 }
}
/** A short "est/2d → est/3d" (or "+p/1" / "est/5d") summary for the confirm UI. */
export function describeChange(plan: LabelPlan): string {
if (plan.noop) return 'no change'
const from = plan.removed.length ? plan.removed.join(', ') : '∅'
const to = plan.added.length ? plan.added.join(', ') : '∅'
return `${from}${to}`
}
/**
* One human-readable confirm line for ANY change kind — the unified describe for
* the propose-approve UI. Label kinds delegate to {@link describeChange} (so they
* need the issue's current labels); assign/milestone describe the field write
* directly. Pure; no network.
*/
export function summarizeChange(change: IssueChange, currentLabels: string[] = []): string {
switch (change.kind) {
case 'reestimate':
case 'reprioritize':
return describeChange(planIssueChange(currentLabels, change))
case 'assign':
return change.assignee ? `assign → @${change.assignee}` : 'unassign'
case 'remilestone':
return change.milestone == null
? 'remove from milestone'
: `milestone → ${change.milestoneTitle ?? `#${change.milestone}`}`
}
}
/** A change the agent proposes: the concrete op + its human diff, ready for approve-then-apply. */
export interface ChangeProposal {
change: IssueChange
/** One-line diff for the approve UI — always present ({@link summarizeChange}). */
summary: string
/** The label plan, for label kinds only (absent for assign/milestone). */
plan?: LabelPlan
issueTitle?: string
}
/** What the `propose_change` tool accepts — a target issue and the fields to set. */
export interface ProposeChangeArgs {
issue: number
estimate?: EstimateLabel
priority?: PriorityLabel
/** Login to assign, or null to unassign. Omit to leave assignee untouched. */
assignee?: string | null
/** Milestone id to set, or null to clear. Omit to leave the milestone untouched. */
milestone?: number | null
}
/** Current state + lookups a proposal needs to skip noops and label the milestone. */
export interface ProposalContext {
currentAssignee?: string | null
currentMilestoneId?: number | null
milestones?: { id: number; title: string }[]
}
function isEstimate(v: unknown): v is EstimateLabel {
return typeof v === 'string' && (ESTIMATE_LABELS as readonly string[]).includes(v)
}
function isPriority(v: unknown): v is PriorityLabel {
return typeof v === 'string' && (PRIORITY_LABELS as readonly string[]).includes(v)
}
/**
* Build the concrete, non-noop proposals for a `propose_change` request. Label
* axes (est/*, p/*) are validated and never invented; assign/milestone are
* emitted only when they differ from the issue's current value (via `ctx`). Each
* proposal carries a human `summary`; label kinds also carry the label plan.
*/
export function proposalsFor(
args: ProposeChangeArgs,
currentLabels: string[],
issueTitle?: string,
ctx: ProposalContext = {},
): ChangeProposal[] {
const out: ChangeProposal[] = []
const pushLabel = (change: LabelChange) => {
const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, summary: describeChange(plan), issueTitle })
}
if (isEstimate(args.estimate)) pushLabel({ kind: 'reestimate', issue: args.issue, estimate: args.estimate })
if (isPriority(args.priority)) pushLabel({ kind: 'reprioritize', issue: args.issue, priority: args.priority })
if (args.assignee !== undefined && args.assignee !== (ctx.currentAssignee ?? null)) {
const change: IssueChange = { kind: 'assign', issue: args.issue, assignee: args.assignee }
out.push({ change, summary: summarizeChange(change), issueTitle })
}
if (args.milestone !== undefined && args.milestone !== (ctx.currentMilestoneId ?? null)) {
const milestoneTitle = ctx.milestones?.find((m) => m.id === args.milestone)?.title ?? null
const change: IssueChange = { kind: 'remilestone', issue: args.issue, milestone: args.milestone, milestoneTitle }
out.push({ change, summary: summarizeChange(change), issueTitle })
}
return out
}

View File

@@ -1,66 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
appendDirective,
makeDirectiveEntry,
parseDirectiveLog,
serializeDirective,
toDirectiveInput,
} from './record-directive-v0.js'
describe('toDirectiveInput', () => {
it('keeps a valid kind + target and drops an empty target', () => {
const input = toDirectiveInput({ kind: 'reprioritize', quote: 'pilots first', target: { issue: 87 }, rationale: 'blocked' })
expect(input).toEqual({ kind: 'reprioritize', quote: 'pilots first', target: { issue: 87 }, params: undefined, rationale: 'blocked' })
expect(toDirectiveInput({ kind: 'note', quote: 'x', target: {} }).target).toBeUndefined()
})
it('falls back to note for an unknown kind', () => {
expect(toDirectiveInput({ kind: 'nonsense', quote: 'hmm' }).kind).toBe('note')
})
})
describe('serialize + parse round-trip', () => {
const entry = makeDirectiveEntry(
{ kind: 'reprioritize', quote: 'pilots come first', target: { issue: 87 } },
'id-1',
'2026-02-01T09:00:00Z',
)
it('serializes to one JSON line', () => {
const line = serializeDirective(entry)
expect(line).not.toContain('\n')
expect(JSON.parse(line)).toMatchObject({ id: 'id-1', kind: 'reprioritize', status: 'accepted' })
})
it('parses a log, orders by ts, and assigns a 1-based seq', () => {
const a = serializeDirective(makeDirectiveEntry({ kind: 'note', quote: 'later' }, 'b', '2026-02-02T00:00:00Z'))
const b = serializeDirective(makeDirectiveEntry({ kind: 'note', quote: 'earlier' }, 'a', '2026-02-01T00:00:00Z'))
const records = parseDirectiveLog(`${a}\n${b}\n`)
expect(records.map((r) => r.quote)).toEqual(['earlier', 'later'])
expect(records.map((r) => r.seq)).toEqual([1, 2])
})
it('skips blank and corrupt lines without losing the rest', () => {
const good = serializeDirective(entry)
const records = parseDirectiveLog(`\n{not json\n${good}\n\n`)
expect(records).toHaveLength(1)
expect(records[0].id).toBe('id-1')
})
})
describe('appendDirective', () => {
it('concatenates a newline-terminated entry, normalizing a missing trailing newline', () => {
const e1 = makeDirectiveEntry({ kind: 'note', quote: 'one' }, 'i1', '2026-01-01T00:00:00Z')
const e2 = makeDirectiveEntry({ kind: 'note', quote: 'two' }, 'i2', '2026-01-02T00:00:00Z')
let log = appendDirective('', e1)
log = appendDirective(log, e2)
expect(parseDirectiveLog(log).map((r) => r.quote)).toEqual(['one', 'two'])
expect(log.endsWith('\n')).toBe(true)
})
it('handles existing text without a trailing newline', () => {
const e = makeDirectiveEntry({ kind: 'note', quote: 'x' }, 'i', '2026-01-01T00:00:00Z')
expect(appendDirective('{"id":"prev","ts":"2025-01-01T00:00:00Z"}', e).split('\n').filter(Boolean)).toHaveLength(2)
})
})

View File

@@ -1,106 +0,0 @@
/**
* record_directive — the PM's standing instructions ("pilots come first"),
* appended to an append-only JSONL ledger in the pm-state repo (decisions.md D4,
* pm-state.md). A directive is *intent*: it's logged verbatim; its effects land
* later through apply_changes. Merge is concatenation — order derives from `ts`
* at read time, so two writers never conflict. `seq` is a display ordinal
* computed on read, never stored. This module is pure serialize/parse; the
* append (read → concat → write) is the bridge's job.
*/
export type DirectiveKind = 'reprioritize' | 'reestimate' | 'set-deadline' | 'scope' | 'capacity' | 'note'
export type DirectiveStatus = 'proposed' | 'accepted' | 'amended' | 'withdrawn'
export interface DirectiveTarget {
issue?: number
milestone?: number
member?: string
}
/** What the record_directive tool captures. */
export interface DirectiveInput {
kind: DirectiveKind
/** Verbatim PM words, shown in the ledger. */
quote: string
target?: DirectiveTarget
/** Structured effect the scheduler applies, e.g. { priority: 1 }. */
params?: Record<string, unknown>
rationale?: string
}
/** A ledger entry — an input plus its durable id/ts/status. */
export interface DirectiveEntry extends DirectiveInput {
id: string
ts: string
status: DirectiveStatus
}
/** A ledger entry as read back, with a computed display ordinal. */
export interface DirectiveRecord extends DirectiveEntry {
seq: number
}
const DIRECTIVE_KINDS: readonly DirectiveKind[] = [
'reprioritize',
'reestimate',
'set-deadline',
'scope',
'capacity',
'note',
]
/** Normalize a raw tool payload into a DirectiveInput (unknown kind → note). */
export function toDirectiveInput(raw: unknown): DirectiveInput {
const r = (raw ?? {}) as Record<string, unknown>
const kind = DIRECTIVE_KINDS.includes(r.kind as DirectiveKind) ? (r.kind as DirectiveKind) : 'note'
const target = (r.target ?? undefined) as DirectiveTarget | undefined
return {
kind,
quote: typeof r.quote === 'string' ? r.quote : '',
target: target && (target.issue || target.milestone || target.member) ? target : undefined,
params: (r.params && typeof r.params === 'object' ? (r.params as Record<string, unknown>) : undefined),
rationale: typeof r.rationale === 'string' ? r.rationale : undefined,
}
}
/** Build a full entry from an input + externally-supplied id/ts (Date/uuid live in the caller). */
export function makeDirectiveEntry(
input: DirectiveInput,
id: string,
ts: string,
status: DirectiveStatus = 'accepted',
): DirectiveEntry {
return { ...input, id, ts, status }
}
/** One JSONL line (no trailing newline — the caller joins). */
export function serializeDirective(entry: DirectiveEntry): string {
return JSON.stringify(entry)
}
/**
* Parse a JSONL log into records ordered by `ts` (then id for stability), with a
* 1-based `seq` assigned on read. Blank/corrupt lines are skipped, not fatal.
*/
export function parseDirectiveLog(text: string): DirectiveRecord[] {
const entries: DirectiveEntry[] = []
for (const line of text.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
try {
const e = JSON.parse(trimmed) as DirectiveEntry
if (e && typeof e.id === 'string' && typeof e.ts === 'string') entries.push(e)
} catch {
// skip a corrupt line rather than lose the whole ledger
}
}
entries.sort((a, b) => (a.ts === b.ts ? a.id.localeCompare(b.id) : a.ts.localeCompare(b.ts)))
return entries.map((e, i) => ({ ...e, seq: i + 1 }))
}
/** Append a serialized entry to existing log text (concatenation merge). */
export function appendDirective(existing: string, entry: DirectiveEntry): string {
const base = existing.endsWith('\n') || existing === '' ? existing : existing + '\n'
return `${base}${serializeDirective(entry)}\n`
}

View File

@@ -97,43 +97,4 @@ describe('forecast', () => {
expect(f.scope).toBe(2) expect(f.scope).toBe(2)
expect(f.p50Day).toBeGreaterThan(0) expect(f.p50Day).toBeGreaterThan(0)
}) })
it('a cold-start model changes nothing — the code priors still drive it', () => {
const priors = forecast(scope, [], { trials: 1000, seed: 7 })
const cold = forecast(scope, [], {
trials: 1000,
seed: 7,
model: { coldStart: true, global: { mu: 5, sigma: 0.1 }, byBucket: {} },
})
expect(cold.coldStart).toBe(true)
expect(cold.p50Day).toBeCloseTo(priors.p50Day, 6)
})
it('a fitted model drives the sim once past cold-start', () => {
// an optimistic fit (mu < 0, tight sigma) should land the scope sooner than the pessimistic priors
const priors = forecast(scope, [], { trials: 2000, seed: 7 })
const fitted = forecast(scope, [], {
trials: 2000,
seed: 7,
model: { coldStart: false, global: { mu: -0.2, sigma: 0.1 }, byBucket: {} },
})
expect(fitted.coldStart).toBe(false)
expect(fitted.p50Day).toBeLessThan(priors.p50Day)
})
it('capacity lanes parallelize the sim — the makespan shrinks with more workers', () => {
const independent = [issue(1), issue(2), issue(3), issue(4)] // no deps → fully parallelizable
const serial = forecast(independent, [], { trials: 2000, seed: 7 })
const twoLanes = forecast(independent, [], {
trials: 2000,
seed: 7,
workers: [
{ person: 'a', speed: 1 },
{ person: 'b', speed: 1 },
],
})
// two equal lanes ≈ half the serial landing (independent work)
expect(twoLanes.p80Day).toBeLessThan(serial.p80Day)
expect(twoLanes.p80Day).toBeLessThan(serial.p80Day * 0.7)
})
}) })

View File

@@ -18,7 +18,6 @@ import {
schedule, schedule,
type SchedulableIssue, type SchedulableIssue,
} from '../scheduler/scheduler-v0.js' } from '../scheduler/scheduler-v0.js'
import { type LaneInputs, layoutOnLanes, resolveLanes, type Worker } from '../scheduler/scheduler-capacity-v0.js'
export interface LognormalPrior { export interface LognormalPrior {
/** Median log-ratio: sampled median duration = estimate * e^mu. */ /** Median log-ratio: sampled median duration = estimate * e^mu. */
@@ -42,29 +41,15 @@ export const COLD_START_PRIORS: Record<number, LognormalPrior> = {
8: { mu: 0.16, sigma: 0.36 }, 8: { mu: 0.16, sigma: 0.36 },
} }
export const PRIOR_BUCKETS = [1, 2, 3, 5, 8] const PRIOR_BUCKETS = [1, 2, 3, 5, 8]
/** Nearest estimate bucket (ties resolve to the smaller bucket). */ /** Nearest estimate bucket (ties resolve to the smaller bucket). */
export function nearestBucket(days: number): number { export function priorForEstimate(days: number): LognormalPrior {
let best = PRIOR_BUCKETS[0] let best = PRIOR_BUCKETS[0]
for (const b of PRIOR_BUCKETS) { for (const b of PRIOR_BUCKETS) {
if (Math.abs(b - days) < Math.abs(best - days)) best = b if (Math.abs(b - days) < Math.abs(best - days)) best = b
} }
return best return COLD_START_PRIORS[best]
}
/** The cold-start prior for the bucket nearest to `days`. */
export function priorForEstimate(days: number): LognormalPrior {
return COLD_START_PRIORS[nearestBucket(days)]
}
/** The lognormal parameters `forecast` needs, per estimate bucket. */
export interface DurationModel {
coldStart: boolean
/** Fallback params (used when a bucket lacks its own fit). */
global: LognormalPrior
/** Per-bucket fitted params; missing buckets fall back to `global`. */
byBucket: Record<number, LognormalPrior>
} }
export interface ForecastOptions { export interface ForecastOptions {
@@ -72,25 +57,6 @@ export interface ForecastOptions {
trials?: number trials?: number
/** PRNG seed. Fixed by default so a forecast is reproducible. */ /** PRNG seed. Fixed by default so a forecast is reproducible. */
seed?: number seed?: number
/**
* Fitted duration model. When present and not cold-start, its params drive
* the sim; otherwise the code-resident cold-start priors do.
*/
model?: DurationModel
/**
* Capacity lanes. When provided, each trial lays sampled durations across the
* lanes (parallel) instead of a single serial worker — the makespan shrinks
* toward the critical path. Empty/absent → single serial worker.
*/
workers?: Worker[]
}
/** Resolve the lognormal params for an estimate, preferring a fitted model. */
export function durationParams(days: number, model?: DurationModel): LognormalPrior {
if (model && !model.coldStart) {
return model.byBucket[nearestBucket(days)] ?? model.global
}
return priorForEstimate(days)
} }
export interface BurnUpPoint { export interface BurnUpPoint {
@@ -155,44 +121,26 @@ export function forecast(
): Forecast { ): Forecast {
const trials = options.trials ?? DEFAULT_TRIALS const trials = options.trials ?? DEFAULT_TRIALS
const seed = options.seed ?? DEFAULT_SEED const seed = options.seed ?? DEFAULT_SEED
const coldStart = options.model ? options.model.coldStart : true
const order = schedule(issues, edges).items // empty when a dependency cycle exists const order = schedule(issues, edges).items // empty when a dependency cycle exists
const n = order.length const n = order.length
if (n === 0) { if (n === 0) {
return { scope: 0, trials, coldStart, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] } return { scope: 0, trials, coldStart: true, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] }
} }
const priors = order.map((it) => durationParams(it.durationDays, options.model)) const priors = order.map((it) => priorForEstimate(it.durationDays))
const rng = mulberry32(seed) const rng = mulberry32(seed)
// Capacity lanes, when configured — the per-trial layout goes parallel. // endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t.
const laneInputs: LaneInputs = {
order: order.map((it) => it.number),
blockedBy: new Map(order.map((it) => [it.number, it.blockedBy])),
assignee: new Map(issues.map((i) => [i.number, i.assignee ?? null])),
}
const lanes =
options.workers && options.workers.length ? resolveLanes(options.workers, [...laneInputs.assignee.values()]) : null
// endByRank[k][t] = working day the (k+1)-th issue *to finish* completes on trial t.
const endByRank: number[][] = Array.from({ length: n }, () => new Array<number>(trials)) const endByRank: number[][] = Array.from({ length: n }, () => new Array<number>(trials))
for (let t = 0; t < trials; t++) { for (let t = 0; t < trials; t++) {
const sampled = order.map((it, k) => it.durationDays * Math.exp(priors[k].mu + priors[k].sigma * standardNormal(rng)))
if (lanes) {
// parallel: lay the sampled durations across lanes, then sort finish days
const byNumber = new Map(order.map((it, k) => [it.number, sampled[k]]))
const { finishAt } = layoutOnLanes(laneInputs, lanes, (num) => byNumber.get(num)!)
const finishes = order.map((it) => finishAt.get(it.number)!).sort((a, b) => a - b)
for (let k = 0; k < n; k++) endByRank[k][t] = finishes[k]
} else {
// single serial worker: cumulative sum (already sorted ascending)
let cursor = 0 let cursor = 0
for (let k = 0; k < n; k++) { for (let k = 0; k < n; k++) {
cursor += sampled[k] const p = priors[k]
const sampled = order[k].durationDays * Math.exp(p.mu + p.sigma * standardNormal(rng))
cursor += sampled
endByRank[k][t] = cursor endByRank[k][t] = cursor
} }
} }
}
const curve: BurnUpPoint[] = endByRank.map((row, k) => { const curve: BurnUpPoint[] = endByRank.map((row, k) => {
const sorted = [...row].sort((a, b) => a - b) const sorted = [...row].sort((a, b) => a - b)
@@ -208,7 +156,7 @@ export function forecast(
return { return {
scope: n, scope: n,
trials, trials,
coldStart, coldStart: true,
p50Day: percentile(total, 0.5), p50Day: percentile(total, 0.5),
p80Day: percentile(total, 0.8), p80Day: percentile(total, 0.8),
p95Day: percentile(total, 0.95), p95Day: percentile(total, 0.95),

View File

@@ -1,91 +0,0 @@
import { describe, expect, it } from 'vitest'
import { applySchemaLabels, ensurePmStateRepo, repoExists, SCHEMA_LABELS } from './bootstrap.js'
import { GiteaApiError } from './types.js'
import type { FetchLike } from './types.js'
describe('SCHEMA_LABELS', () => {
it('covers the full vocabulary: 5 estimates + 4 priorities + hard deadline', () => {
const names = SCHEMA_LABELS.map((l) => l.name)
expect(names).toEqual(['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/1', 'p/2', 'p/3', 'p/4', 'deadline/hard'])
// colors are hex without '#'
for (const l of SCHEMA_LABELS) expect(l.color).toMatch(/^[0-9a-f]{6}$/)
})
})
describe('applySchemaLabels', () => {
it('creates only the missing labels (idempotent)', async () => {
const created: string[] = []
const client = {
listLabels: async () => [
{ id: 1, name: 'est/1d' },
{ id: 2, name: 'p/1' },
],
createLabel: async (input: { name: string; color: string }) => {
created.push(input.name)
return { id: 99, name: input.name }
},
}
const res = await applySchemaLabels(client)
expect(res.existing).toEqual(['est/1d', 'p/1'])
expect(res.created).toEqual(['est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/2', 'p/3', 'p/4', 'deadline/hard'])
expect(created).toEqual(res.created) // exactly those createLabel calls, no more
})
it('creates nothing when the schema is already applied', async () => {
const client = {
listLabels: async () => SCHEMA_LABELS.map((l, i) => ({ id: i, name: l.name })),
createLabel: async () => {
throw new Error('should not create')
},
}
const res = await applySchemaLabels(client)
expect(res.created).toEqual([])
expect(res.existing).toHaveLength(SCHEMA_LABELS.length)
})
})
/** A fetch stub routing repo-exists (GET) and repo-create (POST). */
function stub(opts: { exists?: boolean; createStatus?: number }): { fetch: FetchLike; posts: { url: string }[] } {
const posts: { url: string }[] = []
const fetch: FetchLike = (url, init) => {
const method = init?.method ?? 'GET'
if (method === 'GET') {
return Promise.resolve({ ok: !!opts.exists, status: opts.exists ? 200 : 404, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
}
posts.push({ url })
const status = opts.createStatus ?? 201
return Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
}
return { fetch, posts }
}
describe('ensurePmStateRepo', () => {
const conn = { baseUrl: 'https://gitea.example.io', token: 'pat' }
it('returns "exists" and creates nothing when the repo is already there', async () => {
const { fetch, posts } = stub({ exists: true })
const r = await ensurePmStateRepo(conn, { owner: 'christian', repo: 'commitea-pm-state', underOrg: false }, fetch)
expect(r).toBe('exists')
expect(posts).toEqual([])
})
it('creates a personal repo via /user/repos when the owner is the token user', async () => {
const { fetch, posts } = stub({ exists: false })
const r = await ensurePmStateRepo(conn, { owner: 'christian', repo: 'commitea-pm-state', underOrg: false }, fetch)
expect(r).toBe('created')
expect(posts[0].url).toBe('https://gitea.example.io/api/v1/user/repos')
})
it('creates an org repo via /orgs/{owner}/repos when the owner is an org', async () => {
const { fetch, posts } = stub({ exists: false })
const r = await ensurePmStateRepo(conn, { owner: 'NovelPad', repo: 'novelpad-pm-state', underOrg: true }, fetch)
expect(r).toBe('created')
expect(posts[0].url).toBe('https://gitea.example.io/api/v1/orgs/NovelPad/repos')
})
it('surfaces a non-404 read failure as a GiteaApiError', async () => {
const fetch: FetchLike = () => Promise.resolve({ ok: false, status: 403, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
await expect(repoExists(conn, 'x', 'y', fetch)).rejects.toBeInstanceOf(GiteaApiError)
})
})

View File

@@ -1,104 +0,0 @@
/**
* First-run bootstrap: make a repo ready for CommiTea. Two idempotent steps —
* apply the label schema (the only footprint CommiTea leaves in the work repo)
* and ensure the pm-state sidecar repo exists (machine-derived state lives there,
* never in the work repo — the purity split, D4). Both are safe to re-run: they
* create only what's missing. Label creation is repo-scoped (on the client);
* repo creation is token-scoped, so it takes the same injected `fetch` seam as
* `discoverRepos`.
*/
import type { GiteaClient } from './client.js'
import { ESTIMATE_LABELS, HARD_DEADLINE_LABEL, PRIORITY_LABELS } from '../labels/label-schema.js'
import { GiteaApiError } from './types.js'
import type { FetchLike } from './types.js'
/** A label to seed, with a hex color (no '#') and a human description. */
export interface SchemaLabelDef {
name: string
color: string
description: string
}
/** The full CommiTea label schema — estimates (jade), priorities (amber→grey), hard deadline (red). */
export const SCHEMA_LABELS: SchemaLabelDef[] = [
...ESTIMATE_LABELS.map((name) => ({ name, color: '2f6f4e', description: `Estimate: ${name.slice(4)} of focused work` })),
{ name: 'p/1', color: 'dc2626', description: 'Priority 1 — drop everything' },
{ name: 'p/2', color: 'ea580c', description: 'Priority 2 — this sprint' },
{ name: 'p/3', color: 'd97706', description: 'Priority 3 — soon' },
{ name: 'p/4', color: '6b7280', description: 'Priority 4 — someday' },
{ name: HARD_DEADLINE_LABEL, color: 'b91c1c', description: 'Has a hard, external deadline' },
]
// sanity: the schema list must cover exactly the label vocabulary, no drift.
const _EXPECTED = ESTIMATE_LABELS.length + PRIORITY_LABELS.length + 1
if (SCHEMA_LABELS.length !== _EXPECTED) throw new Error('SCHEMA_LABELS drifted from the label vocabulary')
/** What applying the schema did — created vs already-present names. */
export interface LabelSyncResult {
created: string[]
existing: string[]
}
/**
* Ensure every schema label exists on the work repo. Idempotent: reads the
* current labels and creates only the missing ones.
*/
export async function applySchemaLabels(client: Pick<GiteaClient, 'listLabels' | 'createLabel'>): Promise<LabelSyncResult> {
const have = new Set((await client.listLabels()).map((l) => l.name))
const created: string[] = []
const existing: string[] = []
for (const def of SCHEMA_LABELS) {
if (have.has(def.name)) {
existing.push(def.name)
continue
}
await client.createLabel(def)
created.push(def.name)
}
return { created, existing }
}
function authHeaders(token: string, json = false): Record<string, string> {
return { Authorization: `token ${token}`, Accept: 'application/json', ...(json ? { 'Content-Type': 'application/json' } : {}) }
}
/** Whether a repo exists under `owner`. Token-scoped read. */
export async function repoExists(conn: { baseUrl: string; token: string }, owner: string, repo: string, fetchImpl: FetchLike): Promise<boolean> {
const url = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1/repos/${owner}/${repo}`
const res = await fetchImpl(url, { headers: authHeaders(conn.token) })
if (res.ok) return true
if (res.status === 404) return false
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `GET ${url} failed (${res.status})`, body)
}
/**
* Ensure the pm-state sidecar repo exists under `owner`, creating a private,
* auto-initialized repo if not. `underOrg` picks the org vs personal endpoint
* (the caller knows whether `owner` is the token's own login or an org).
* Returns whether it created or found the repo.
*/
export async function ensurePmStateRepo(
conn: { baseUrl: string; token: string },
target: { owner: string; repo: string; underOrg: boolean },
fetchImpl: FetchLike,
): Promise<'created' | 'exists'> {
if (await repoExists(conn, target.owner, target.repo, fetchImpl)) return 'exists'
const apiBase = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1`
const url = target.underOrg ? `${apiBase}/orgs/${target.owner}/repos` : `${apiBase}/user/repos`
const res = await fetchImpl(url, {
method: 'POST',
headers: authHeaders(conn.token, true),
body: JSON.stringify({
name: target.repo,
private: true,
auto_init: true,
description: 'CommiTea PM state — machine-derived (capacity, directives, calibration). Safe to delete.',
}),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `POST ${url} failed (${res.status})`, body)
}
return 'created'
}

View File

@@ -123,79 +123,6 @@ describe('createGiteaClient.getIssue', () => {
) )
}) })
it('setIssueLabels PUTs the label ids to the issue labels endpoint', async () => {
const { fetch, calls } = stubFetch(null, 204)
await createGiteaClient(CONFIG, fetch).setIssueLabels(9, [3, 7])
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9/labels')
expect(calls[0].init?.method).toBe('PUT')
expect(calls[0].init?.headers?.['Content-Type']).toBe('application/json')
expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ labels: [3, 7] })
})
it('listLabels returns id+name pairs', async () => {
const { fetch } = stubFetch([
{ id: 3, name: 'est/2d', color: 'fff' },
{ id: 7, name: 'p/1', color: '000' },
])
const labels = await createGiteaClient(CONFIG, fetch).listLabels()
expect(labels).toEqual([
{ id: 3, name: 'est/2d' },
{ id: 7, name: 'p/1' },
])
})
it('listCollaborators maps login+name and prepends the owner when absent', async () => {
const { fetch } = stubFetch([{ login: 'stephen', full_name: 'Stephen M.' }])
const people = await createGiteaClient(CONFIG, fetch).listCollaborators()
// owner (christian) prepended since /collaborators omits them; collaborator name preserved
expect(people).toEqual([
{ login: 'christian', name: 'christian' },
{ login: 'stephen', name: 'Stephen M.' },
])
})
it('listCollaborators does not double-add the owner when already listed', async () => {
const { fetch } = stubFetch([{ login: 'christian', full_name: 'Christian L.' }])
const people = await createGiteaClient(CONFIG, fetch).listCollaborators()
expect(people).toEqual([{ login: 'christian', name: 'Christian L.' }])
})
it('getFile returns null on 404 and content+sha on hit', async () => {
const miss = stubFetch('nope', 404)
expect(await createGiteaClient(CONFIG, miss.fetch).getFile('directives/log.jsonl')).toBeNull()
const hit = stubFetch({ content: 'aGVsbG8=\n', sha: 'abc123' })
const file = await createGiteaClient(CONFIG, hit.fetch).getFile('directives/log.jsonl')
expect(file).toEqual({ contentBase64: 'aGVsbG8=', sha: 'abc123' })
expect(hit.calls[0].url).toContain('/contents/directives/log.jsonl')
})
it('putFile POSTs to create and PUTs to update (with sha)', async () => {
const create = stubFetch({}, 201)
await createGiteaClient(CONFIG, create.fetch).putFile('directives/log.jsonl', { contentBase64: 'eA==', message: 'seed' })
expect(create.calls[0].init?.method).toBe('POST')
expect(JSON.parse(create.calls[0].init?.body ?? '{}')).toEqual({ content: 'eA==', message: 'seed' })
const update = stubFetch({}, 200)
await createGiteaClient(CONFIG, update.fetch).putFile('directives/log.jsonl', { contentBase64: 'eQ==', message: 'append', sha: 's1' })
expect(update.calls[0].init?.method).toBe('PUT')
expect(JSON.parse(update.calls[0].init?.body ?? '{}')).toEqual({ content: 'eQ==', message: 'append', sha: 's1' })
})
it('createIssue POSTs title/body/labels and returns a normalized issue', async () => {
const created = { ...RAW_ISSUE, number: 44, title: 'Retry token refresh', labels: [{ name: 'est/2d' }, { name: 'p/2' }] }
const { fetch, calls } = stubFetch(created, 201)
const issue = await createGiteaClient(CONFIG, fetch).createIssue({ title: 'Retry token refresh', body: 'backoff', labelIds: [3, 7] })
expect(calls[0].url).toBe('https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues')
expect(calls[0].init?.method).toBe('POST')
expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ title: 'Retry token refresh', body: 'backoff', labels: [3, 7] })
expect(issue.number).toBe(44)
expect(issue.labels).toEqual(['est/2d', 'p/2'])
})
it('throws GiteaApiError carrying status + body on a non-2xx response', async () => { it('throws GiteaApiError carrying status + body on a non-2xx response', async () => {
const { fetch } = stubFetch('not found', 404) const { fetch } = stubFetch('not found', 404)
const client = createGiteaClient(CONFIG, fetch) const client = createGiteaClient(CONFIG, fetch)

View File

@@ -13,7 +13,6 @@ import {
type FetchLike, type FetchLike,
type GiteaConfig, type GiteaConfig,
type GiteaIssue, type GiteaIssue,
type GiteaLabel,
type GiteaMilestone, type GiteaMilestone,
type GiteaMilestoneRef, type GiteaMilestoneRef,
} from './types.js' } from './types.js'
@@ -99,24 +98,6 @@ export interface GiteaClient {
getIssueDependencies(index: number): Promise<number[]> getIssueDependencies(index: number): Promise<number[]>
/** Normalized lifecycle events for one issue (all pages of its timeline). */ /** Normalized lifecycle events for one issue (all pages of its timeline). */
getIssueTimeline(index: number): Promise<LifecycleEvent[]> getIssueTimeline(index: number): Promise<LifecycleEvent[]>
/** Every label defined on the repo (id + name), for name→id resolution. */
listLabels(): Promise<GiteaLabel[]>
/** Create a repo label (name + hex color, no '#'). Write. Used by bootstrap. */
createLabel(input: { name: string; color: string; description?: string }): Promise<GiteaLabel>
/** Repo collaborators (login + display name) — the assignable people. */
listCollaborators(): Promise<{ login: string; name: string }[]>
/** Replace an issue's entire label set with the given label ids. Write. */
setIssueLabels(index: number, labelIds: number[]): Promise<void>
/** Replace an issue's assignees (empty array unassigns); returns the updated issue. Write. */
setIssueAssignees(index: number, logins: string[]): Promise<GiteaIssue>
/** Set (or clear, with null) an issue's milestone by id; returns the updated issue. Write. */
setIssueMilestone(index: number, milestoneId: number | null): Promise<GiteaIssue>
/** Open a new issue with a title, optional body, and label ids. Write. */
createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue>
/** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */
getFile(path: string): Promise<{ contentBase64: string; sha: string } | null>
/** Create or update a repo file with base64 content (pass `sha` to update). Write. */
putFile(path: string, input: { contentBase64: string; message: string; sha?: string }): Promise<void>
} }
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */ /** Map raw gitea issue JSON to the normalized domain shape. Pure. */
@@ -163,24 +144,18 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1` const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1`
const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}` const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}`
async function request(path: string, init?: { method?: string; body?: unknown }): Promise<unknown> { async function request(path: string): Promise<unknown> {
const method = init?.method ?? 'GET'
const hasBody = init?.body !== undefined
const res = await fetchImpl(`${repoBase}${path}`, { const res = await fetchImpl(`${repoBase}${path}`, {
method,
headers: { headers: {
Authorization: `token ${config.token}`, Authorization: `token ${config.token}`,
Accept: 'application/json', Accept: 'application/json',
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
}, },
body: hasBody ? JSON.stringify(init!.body) : undefined,
}) })
if (!res.ok) { if (!res.ok) {
const body = await res.text().catch(() => '') const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `${method} ${path} failed (${res.status})`, body) throw new GiteaApiError(res.status, `GET ${path} failed (${res.status})`, body)
} }
// writes may reply 204 No Content return res.json()
return res.status === 204 ? null : res.json()
} }
/** Follow gitea's page-limit pagination until a short page is returned. */ /** Follow gitea's page-limit pagination until a short page is returned. */
@@ -225,79 +200,5 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
) )
return normalizeTimeline(raw) return normalizeTimeline(raw)
}, },
async listLabels() {
const raw = await requestAll<{ id: number; name: string }>(
(page) => `/labels?page=${page}&limit=${PAGE_LIMIT}`,
)
return raw.map((l) => ({ id: l.id, name: l.name }))
},
async setIssueLabels(index, labelIds) {
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
},
async createLabel(input) {
const raw = (await request('/labels', {
method: 'POST',
body: { name: input.name, color: input.color, description: input.description ?? '' },
})) as { id: number; name: string }
return { id: raw.id, name: raw.name }
},
async listCollaborators() {
const raw = await requestAll<{ login: string; full_name?: string }>(
(page) => `/collaborators?page=${page}&limit=${PAGE_LIMIT}`,
)
const collabs = raw.map((u) => ({ login: u.login, name: u.full_name || u.login }))
// The owner is assignable but is not returned by /collaborators — prepend them
// so a solo-owner repo still has an assignable person.
if (!collabs.some((c) => c.login === config.owner)) {
collabs.unshift({ login: config.owner, name: config.owner })
}
return collabs
},
async setIssueAssignees(index, logins) {
const raw = (await request(`/issues/${index}`, { method: 'PATCH', body: { assignees: logins } })) as RawIssue
return normalizeIssue(raw)
},
async setIssueMilestone(index, milestoneId) {
// gitea's EditIssueOption takes a milestone id; 0 clears it.
const raw = (await request(`/issues/${index}`, {
method: 'PATCH',
body: { milestone: milestoneId ?? 0 },
})) as RawIssue
return normalizeIssue(raw)
},
async createIssue(input) {
const raw = (await request('/issues', {
method: 'POST',
body: { title: input.title, body: input.body ?? '', labels: input.labelIds ?? [] },
})) as RawIssue
return normalizeIssue(raw)
},
async getFile(path) {
const res = await fetchImpl(`${repoBase}/contents/${path}`, {
headers: { Authorization: `token ${config.token}`, Accept: 'application/json' },
})
if (res.status === 404) return null
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `GET contents/${path} failed (${res.status})`, body)
}
const json = (await res.json()) as { content?: string; sha: string }
return { contentBase64: (json.content ?? '').replace(/\n/g, ''), sha: json.sha }
},
async putFile(path, input) {
await request(`/contents/${path}`, {
method: input.sha ? 'PUT' : 'POST',
body: { content: input.contentBase64, message: input.message, ...(input.sha ? { sha: input.sha } : {}) },
})
},
} }
} }

View File

@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest'
import { discoverRepos } from './discover.js'
import { GiteaApiError } from './types.js'
import type { FetchLike } from './types.js'
/** A fetch stub that answers /user and paginated /user/repos from a fixture. */
function stub(opts: {
login: string
repos: { name: string; owner: string }[]
fail?: { status: number }
pageLimit?: number
}): { fetch: FetchLike; urls: string[] } {
const urls: string[] = []
const limit = opts.pageLimit ?? 50
const fetch: FetchLike = (url) => {
urls.push(url)
if (opts.fail) {
return Promise.resolve({ ok: false, status: opts.fail.status, json: () => Promise.resolve({}), text: () => Promise.resolve('nope') })
}
const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body), text: () => Promise.resolve('') })
if (url.endsWith('/api/v1/user')) return ok({ login: opts.login })
const m = url.match(/\/user\/repos\?page=(\d+)&limit=(\d+)/)
if (m) {
const page = Number(m[1])
const raw = opts.repos.map((r) => ({ name: r.name, owner: { login: r.owner } }))
const start = (page - 1) * limit
return ok(raw.slice(start, start + limit))
}
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
}
return { fetch, urls }
}
describe('discoverRepos', () => {
it('groups repos by owner, sorts them, and leads with the token owner', async () => {
const { fetch } = stub({
login: 'christian',
repos: [
{ name: 'zed', owner: 'acme' },
{ name: 'commitea', owner: 'christian' },
{ name: 'apex', owner: 'acme' },
{ name: 'commitea-pm-state', owner: 'christian' },
],
})
const d = await discoverRepos({ baseUrl: 'https://gitea.example.io', token: 'pat' }, fetch)
expect(d.user).toBe('christian')
expect(d.owners).toEqual(['christian', 'acme']) // own login first, then alpha
expect(d.reposByOwner.christian).toEqual(['commitea', 'commitea-pm-state']) // sorted
expect(d.reposByOwner.acme).toEqual(['apex', 'zed'])
})
it('trims a trailing slash on the base URL and calls /api/v1', async () => {
const { fetch, urls } = stub({ login: 'x', repos: [{ name: 'r', owner: 'x' }] })
await discoverRepos({ baseUrl: 'https://gitea.example.io/', token: 'pat' }, fetch)
expect(urls[0]).toBe('https://gitea.example.io/api/v1/user')
expect(urls[1]).toBe('https://gitea.example.io/api/v1/user/repos?page=1&limit=50')
})
it('paginates until a short page', async () => {
const repos = Array.from({ length: 73 }, (_, i) => ({ name: `r${String(i).padStart(2, '0')}`, owner: 'x' }))
const { fetch, urls } = stub({ login: 'x', repos })
const d = await discoverRepos({ baseUrl: 'https://g', token: 'pat' }, fetch)
expect(d.reposByOwner.x).toHaveLength(73)
// /user + page 1 (50) + page 2 (23, short → stop)
expect(urls.filter((u) => u.includes('/user/repos'))).toHaveLength(2)
})
it('propagates an auth failure as a GiteaApiError (so the UI can map 401/403)', async () => {
const { fetch } = stub({ login: 'x', repos: [], fail: { status: 401 } })
await expect(discoverRepos({ baseUrl: 'https://g', token: 'bad' }, fetch)).rejects.toBeInstanceOf(GiteaApiError)
})
it('skips malformed repo rows (missing owner/name)', async () => {
const { fetch } = stub({ login: 'x', repos: [{ name: 'good', owner: 'x' }] })
// inject a bad row by wrapping — simpler: rely on the guard via a direct malformed fixture
const d = await discoverRepos({ baseUrl: 'https://g', token: 'pat' }, fetch)
expect(d.reposByOwner.x).toEqual(['good'])
})
})

View File

@@ -1,74 +0,0 @@
/**
* Token-scoped discovery for onboarding. Given just a base URL + PAT (no repo
* chosen yet), enumerate the owners and repositories the token can reach, so the
* connect screen can offer them as dropdowns instead of free-text. These
* endpoints are user-scoped, not repo-scoped, so they don't belong on the
* repo-bound `GiteaClient` — this is a standalone call with the same injected
* `fetch` seam (real fetch in main, stub in tests).
*/
import { GiteaApiError } from './types.js'
import type { FetchLike } from './types.js'
/** What onboarding needs to render the owner + repo pickers. */
export interface DiscoveredRepos {
/** The token's own login (its default owner). */
user: string
/** Owners with at least one accessible repo — the user first, then the rest, sorted. */
owners: string[]
/** Repo names per owner, sorted. Keys match `owners`. */
reposByOwner: Record<string, string[]>
}
interface RawRepo {
name: string
owner?: { login?: string } | null
}
const PAGE_LIMIT = 50
async function getJson(url: string, token: string, fetchImpl: FetchLike): Promise<unknown> {
const res = await fetchImpl(url, { headers: { Authorization: `token ${token}`, Accept: 'application/json' } })
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `GET ${url} failed (${res.status})`, body)
}
return res.json()
}
/**
* Enumerate the owners + repos a token can reach. Hits `/user` (to learn the
* token's own login and prove the token works) and paginates `/user/repos`
* (every repo the token can access across personal + org namespaces).
*/
export async function discoverRepos(conn: { baseUrl: string; token: string }, fetchImpl: FetchLike): Promise<DiscoveredRepos> {
const apiBase = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1`
const me = (await getJson(`${apiBase}/user`, conn.token, fetchImpl)) as { login?: string }
const user = me.login ?? ''
const repos: RawRepo[] = []
for (let page = 1; ; page++) {
const batch = (await getJson(`${apiBase}/user/repos?page=${page}&limit=${PAGE_LIMIT}`, conn.token, fetchImpl)) as RawRepo[]
repos.push(...batch)
if (batch.length < PAGE_LIMIT) break
}
const reposByOwner: Record<string, string[]> = {}
for (const r of repos) {
const owner = r.owner?.login
if (!owner || !r.name) continue
;(reposByOwner[owner] ??= []).push(r.name)
}
for (const owner of Object.keys(reposByOwner)) {
reposByOwner[owner] = [...new Set(reposByOwner[owner])].sort((a, b) => a.localeCompare(b))
}
// owners sorted alphabetically, but the token's own login always leads.
const owners = Object.keys(reposByOwner).sort((a, b) => {
if (a === user) return -1
if (b === user) return 1
return a.localeCompare(b)
})
return { user, owners, reposByOwner }
}

View File

@@ -31,18 +31,10 @@ export interface GiteaHttpResponse {
status: number status: number
json(): Promise<unknown> json(): Promise<unknown>
text(): Promise<string> text(): Promise<string>
/** Present on the real fetch Response; used for SSE streaming (chat). */
body?: ReadableStream<Uint8Array> | null
} }
export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise<GiteaHttpResponse> export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise<GiteaHttpResponse>
/** A repo label — just the id + name we need for name→id resolution. */
export interface GiteaLabel {
id: number
name: string
}
/** Milestone as referenced from an issue (not the full milestone resource). */ /** Milestone as referenced from an issue (not the full milestone resource). */
export interface GiteaMilestoneRef { export interface GiteaMilestoneRef {
id: number id: number

View File

@@ -11,36 +11,17 @@ export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-sc
export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js' export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js' export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
export { discoverRepos } from './gitea/discover.js'
export type { DiscoveredRepos } from './gitea/discover.js'
export { applySchemaLabels, ensurePmStateRepo, repoExists, SCHEMA_LABELS } from './gitea/bootstrap.js'
export type { LabelSyncResult, SchemaLabelDef } from './gitea/bootstrap.js'
export { GiteaApiError } from './gitea/types.js' export { GiteaApiError } from './gitea/types.js'
export type { export type {
FetchLike, FetchLike,
GiteaConfig, GiteaConfig,
GiteaHttpResponse, GiteaHttpResponse,
GiteaIssue, GiteaIssue,
GiteaLabel,
GiteaMilestone, GiteaMilestone,
GiteaMilestoneRef, GiteaMilestoneRef,
GiteaRequestInit, GiteaRequestInit,
} from './gitea/types.js' } from './gitea/types.js'
export { CACHE_SCHEMA, initCache, readIssue, upsertIssue } from './cache/cache-v0.js'
export type { CacheDriver } from './cache/cache-v0.js'
export { affectedIssues, coalesceKey, enqueueWrite, pendingWrites, replayQueue } from './queue/write-queue-v0.js'
export type { QueuedWrite } from './queue/write-queue-v0.js'
export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js'
export type {
ChangeProposal,
IssueChange,
LabelChange,
LabelPlan,
ProposalContext,
ProposeChangeArgs,
} from './changes/apply-changes-v0.js'
export { export {
inferColumnV0, inferColumnV0,
inferLifecycle, inferLifecycle,
@@ -64,76 +45,5 @@ export type {
SchedulePlan, SchedulePlan,
} from './scheduler/scheduler-v0.js' } from './scheduler/scheduler-v0.js'
export { makespan, scheduleWithCapacity } from './scheduler/scheduler-capacity-v0.js' export { COLD_START_PRIORS, forecast, priorForEstimate } from './forecast/forecast-v0.js'
export type { Worker } from './scheduler/scheduler-capacity-v0.js' export type { BurnUpPoint, Forecast, ForecastOptions, LognormalPrior } from './forecast/forecast-v0.js'
export { capacityPerWorkday, parseCapacityConfig } from './capacity/capacity-v0.js'
export type { CapacityMember } from './capacity/capacity-v0.js'
export {
COLD_START_PRIORS,
durationParams,
forecast,
nearestBucket,
PRIOR_BUCKETS,
priorForEstimate,
} from './forecast/forecast-v0.js'
export type {
BurnUpPoint,
DurationModel,
Forecast,
ForecastOptions,
LognormalPrior,
} from './forecast/forecast-v0.js'
export {
CALIBRATION_BUCKET_FLOOR,
calibrationCoverage,
calibrationSamples,
COLD_START_THRESHOLD,
fitCalibration,
toDurationModel,
} from './calibration/calibration-v0.js'
export type {
BucketFit,
CalibrationCoverage,
CalibrationModel,
CalibrationSample,
PersonBias,
} from './calibration/calibration-v0.js'
export { createChatClient } from './agent/chat-client.js'
export type { ChatClient, ChatMessage, CompletionResult, ModelConfig, OnToken, ToolCall, ToolDecl } from './agent/chat-client.js'
export { pickModel } from './agent/model-router.js'
export type { ModelRouter, TaskKind } from './agent/model-router.js'
export { runAgentTurn } from './agent/agent-loop.js'
export type { AgentStep, AgentTurn, ToolExecutor } from './agent/agent-loop.js'
export {
PROPOSE_CHANGE_TOOL,
QUERY_PROJECT_TOOL,
RECORD_DIRECTIVE_TOOL,
REGINALD_SYSTEM,
REGINALD_TOOLS,
} from './agent/agent-tools.js'
export { buildProjectView } from './agent/query-project.js'
export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js'
export { activeDirectives, assembleHotContext, estimateTokens, HOT_CONTEXT_BUDGET_TOKENS } from './agent/memory-v0.js'
export type { HotContextInputs } from './agent/memory-v0.js'
export { CAPTURE_SYSTEM, captureWork, parseCaptureArgs, PROPOSE_ISSUES_TOOL } from './agent/capture-work.js'
export type { CaptureProposal, ProposedIssue } from './agent/capture-work.js'
export {
appendDirective,
makeDirectiveEntry,
parseDirectiveLog,
serializeDirective,
toDirectiveInput,
} from './directives/record-directive-v0.js'
export type {
DirectiveEntry,
DirectiveInput,
DirectiveKind,
DirectiveRecord,
DirectiveStatus,
DirectiveTarget,
} from './directives/record-directive-v0.js'

View File

@@ -1,82 +0,0 @@
/**
* Performance pass (#32). The deterministic compute path must stay well under the
* PLAN.md targets on representative fixtures:
* - scheduler + Monte Carlo forecast < 1s @ 200 open issues.
* - scaling stays roughly linear (no accidental O(n²) in the hot path).
*
* Reconcile-<5s@500 is network-bound (~2N gitea calls) and is covered by the live
* reconcile, not here — this file benchmarks the pure compute the app runs each
* turn. Bounds are the actual targets with comfortable headroom so timing jitter
* can't flake the suite; actuals are logged.
*/
import { describe, expect, it } from 'vitest'
import { forecast } from '../forecast/forecast-v0.js'
import { type DependencyEdge, schedule, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
import { scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js'
const EST = [1, 2, 3, 5, 8]
const WORKERS: Worker[] = [
{ person: 'a', speed: 0.8 },
{ person: 'b', speed: 0.6 },
{ person: 'c', speed: 1.0 },
]
/** A representative open backlog: varied estimates/priorities/assignees + a light dependency web. */
function backlog(n: number): { issues: SchedulableIssue[]; edges: DependencyEdge[] } {
const issues: SchedulableIssue[] = Array.from({ length: n }, (_, i) => ({
number: i + 1,
title: `Issue ${i + 1} with a representative title of some length`,
labels: [`est/${EST[i % EST.length]}d`, `p/${(i % 4) + 1}`],
estimateDays: EST[i % EST.length],
priority: (i % 4) + 1,
assignee: WORKERS[i % WORKERS.length].person,
}))
// ~1 dependency per 3 issues, always on a lower-numbered issue (acyclic)
const edges: DependencyEdge[] = []
for (let i = 3; i < n; i += 3) edges.push({ issue: i + 1, dependsOn: i - 1 })
return { issues, edges }
}
function ms(fn: () => void): number {
const t0 = performance.now()
fn()
return performance.now() - t0
}
describe('perf (#32)', () => {
it('scheduler + Monte Carlo forecast stays fast @ 200 open issues', () => {
const { issues, edges } = backlog(200)
const run = () =>
ms(() => {
schedule(issues, edges)
scheduleWithCapacity(issues, edges, WORKERS)
forecast(issues, edges, { workers: WORKERS }) // 2000 trials (default)
})
run() // warm up (JIT)
// Best of several runs: a micro-benchmark's minimum reflects true compute cost;
// a single shot flakes when the CI/dev box is momentarily loaded. Nominal is
// ~230ms, so 1500ms is a catastrophic-regression guard (>6x) that tolerates
// load spikes — the scaling test below is the real O(n²) guard.
const best = Math.min(run(), run(), run())
// eslint-disable-next-line no-console
console.log(`[perf] schedule+capacity+forecast @200 = ${best.toFixed(1)}ms (best of 3)`)
expect(best).toBeLessThan(1500)
})
it('scales roughly linearly — 400 issues is well under 4x the 100-issue time', () => {
const small = backlog(100)
const big = backlog(400)
const run = (b: typeof small) => () => {
schedule(b.issues, b.edges)
forecast(b.issues, b.edges, { workers: WORKERS })
}
// warm up (JIT) so the ratio reflects steady state
run(small)()
const t100 = Math.max(ms(run(small)), 0.1)
const t400 = ms(run(big))
// eslint-disable-next-line no-console
console.log(`[perf] @100 = ${t100.toFixed(1)}ms · @400 = ${t400.toFixed(1)}ms · ratio ${(t400 / t100).toFixed(1)}x`)
expect(t400).toBeLessThan(t100 * 8) // generous: rules out O(n²), tolerant of jitter
})
})

View File

@@ -1,67 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { IssueChange } from '../changes/apply-changes-v0.js'
import { affectedIssues, coalesceKey, enqueueWrite, pendingWrites, replayQueue, type QueuedWrite } from './write-queue-v0.js'
const reest = (issue: number, estimate: string): IssueChange =>
({ kind: 'reestimate', issue, estimate }) as IssueChange
const assign = (issue: number, assignee: string | null): IssueChange => ({ kind: 'assign', issue, assignee })
describe('write-queue-v0 (#33)', () => {
it('coalesces repeat writes to the same (issue, axis) — replay applies the latest once', () => {
let q: QueuedWrite[] = []
q = enqueueWrite(q, reest(7, 'est/2d'), '2026-02-10T09:00:00Z')
q = enqueueWrite(q, reest(7, 'est/5d'), '2026-02-10T09:05:00Z') // supersedes est/2d
q = enqueueWrite(q, assign(7, 'christian'), '2026-02-10T09:06:00Z') // different axis — kept
expect(q).toHaveLength(2)
const pending = pendingWrites(q)
expect(pending).toEqual([
{ kind: 'reestimate', issue: 7, estimate: 'est/5d' },
{ kind: 'assign', issue: 7, assignee: 'christian' },
])
expect(coalesceKey(reest(7, 'est/2d'))).toBe('7:reestimate')
})
it('keeps writes to different issues and axes distinct', () => {
let q: QueuedWrite[] = []
q = enqueueWrite(q, reest(7, 'est/2d'), 't1')
q = enqueueWrite(q, reest(8, 'est/3d'), 't2')
q = enqueueWrite(q, assign(8, null), 't3')
expect(q).toHaveLength(3)
expect(affectedIssues(q)).toEqual([7, 8])
})
it('a burst of edits then reconnect lands the final state without duplicating', async () => {
let q: QueuedWrite[] = []
// offline: three edits to #7's estimate, one assign
q = enqueueWrite(q, reest(7, 'est/1d'), 't1')
q = enqueueWrite(q, reest(7, 'est/2d'), 't2')
q = enqueueWrite(q, reest(7, 'est/8d'), 't3')
q = enqueueWrite(q, assign(7, 'stephen'), 't4')
const apply = vi.fn(async () => ({ ok: true }))
const { drained, remaining } = await replayQueue(q, apply)
// only the final estimate + the assign are applied — not three estimate writes
expect(apply).toHaveBeenCalledTimes(2)
expect(apply).toHaveBeenNthCalledWith(1, { kind: 'reestimate', issue: 7, estimate: 'est/8d' })
expect(apply).toHaveBeenNthCalledWith(2, { kind: 'assign', issue: 7, assignee: 'stephen' })
expect(remaining).toHaveLength(0)
expect(drained).toHaveLength(2)
})
it('keeps writes that still fail on reconnect queued (never throws)', async () => {
let q: QueuedWrite[] = []
q = enqueueWrite(q, reest(7, 'est/2d'), 't1')
q = enqueueWrite(q, reest(8, 'est/3d'), 't2')
// #7 applies, #8 rejects (still unreachable) — and one apply throws
const apply = vi.fn(async (c: IssueChange) => {
if (c.issue === 8) throw new Error('offline')
return { ok: true }
})
const { drained, remaining } = await replayQueue(q, apply)
expect(drained.map((w) => w.change.issue)).toEqual([7])
expect(remaining.map((w) => w.change.issue)).toEqual([8])
})
})

View File

@@ -1,71 +0,0 @@
/**
* Offline write-queue, v0 (#33). While gitea is unreachable, propose-approved
* writes are queued instead of lost; on reconnect they replay in order and
* resolve against a fresh reconcile. The one hard requirement is *no duplication*:
* replaying must not apply the same intent twice.
*
* The mechanism is coalescing by axis. Every write targets one field of one issue
* (its estimate, priority, assignee, or milestone). Queuing a second write to the
* same (issue, axis) supersedes the first — only the latest intent survives — so
* a burst of edits replays as one final write, and a replay is idempotent (the
* apply path no-ops a change already reflected server-side).
*/
import type { IssueChange } from '../changes/apply-changes-v0.js'
export interface QueuedWrite {
change: IssueChange
/** ISO time the write was queued (for display + stable ordering). */
queuedAt: string
}
/**
* The coalescing key: one field of one issue. Two writes with the same key are
* the same intent expressed twice — the later wins. Each `IssueChange.kind` maps
* to exactly one axis, so `issue:kind` is the axis identity.
*/
export function coalesceKey(change: IssueChange): string {
return `${change.issue}:${change.kind}`
}
/**
* Queue a write, superseding any pending write to the same (issue, axis). The
* superseding write moves to the tail so replay order reflects latest intent.
*/
export function enqueueWrite(queue: readonly QueuedWrite[], change: IssueChange, queuedAt: string): QueuedWrite[] {
const key = coalesceKey(change)
return [...queue.filter((w) => coalesceKey(w.change) !== key), { change, queuedAt }]
}
/** The changes to replay, in order — one per (issue, axis) by construction. */
export function pendingWrites(queue: readonly QueuedWrite[]): IssueChange[] {
return queue.map((w) => w.change)
}
/** Distinct issues touched by the queue — what a post-replay reconcile should re-read. */
export function affectedIssues(queue: readonly QueuedWrite[]): number[] {
return [...new Set(queue.map((w) => w.change.issue))]
}
/**
* Replay the queue through an apply function (the same guarded write path used
* online), in order. Returns the writes that failed (still unreachable / rejected)
* so they stay queued; everything else drains. Never throws — a failure is data.
*/
export async function replayQueue(
queue: readonly QueuedWrite[],
apply: (change: IssueChange) => Promise<{ ok: boolean }>,
): Promise<{ drained: QueuedWrite[]; remaining: QueuedWrite[] }> {
const drained: QueuedWrite[] = []
const remaining: QueuedWrite[] = []
for (const w of queue) {
let ok = false
try {
ok = (await apply(w.change)).ok
} catch {
ok = false
}
;(ok ? drained : remaining).push(w)
}
return { drained, remaining }
}

View File

@@ -1,119 +0,0 @@
/**
* Capacity-aware scheduler (#8). Reuses the single-worker scheduler's topological
* order + critical-path marking, then re-lays the work across lanes: an issue
* runs on its assignee's lane (or the earliest-free lane when unassigned), its
* duration scaled by that lane's speed (ideal person-days/workday). Makespan
* shrinks toward the critical path as lanes are added. Deterministic; falls back
* to the single serial worker when no capacity is configured. The lane layout is
* factored so the Monte Carlo forecast reuses it per trial with sampled durations.
*/
import {
type DependencyEdge,
schedule,
type SchedulableIssue,
type SchedulePlan,
} from './scheduler-v0.js'
/** A scheduling lane: a person and their throughput (ideal person-days / workday). */
export interface Worker {
person: string
speed: number
}
/** The topological order + per-issue relations the layout needs (from the base plan). */
export interface LaneInputs {
order: number[]
blockedBy: Map<number, number[]>
assignee: Map<number, string | null>
}
/** Ensure a lane exists for every assignee; unconfigured assignees get the mean speed. */
export function resolveLanes(workers: Worker[], assignees: (string | null)[]): Worker[] {
const lanes = [...workers]
const known = new Set(lanes.map((w) => w.person))
const meanSpeed = lanes.length ? lanes.reduce((s, w) => s + w.speed, 0) / lanes.length : 1
for (const a of assignees) {
if (a && !known.has(a)) {
lanes.push({ person: a, speed: meanSpeed })
known.add(a)
}
}
return lanes
}
function pickWorker(lanes: Worker[], freeAt: Map<string, number>, assignee: string | null | undefined): Worker {
if (assignee) {
const own = lanes.find((w) => w.person === assignee)
if (own) return own
}
let best = lanes[0]
for (const w of lanes) if (freeAt.get(w.person)! < freeAt.get(best.person)!) best = w
return best
}
/**
* Lay a topologically-ordered set out across lanes. `duration(n)` supplies each
* issue's duration for this layout (estimate, or a sampled value in a MC trial).
* Returns each issue's finish day + the lane it ran on. Order guarantees a
* dependency is always laid out before its dependents.
*/
export function layoutOnLanes(
inputs: LaneInputs,
lanes: Worker[],
duration: (n: number) => number,
): { finishAt: Map<number, number>; startAt: Map<number, number>; laneOf: Map<number, string> } {
const freeAt = new Map(lanes.map((w) => [w.person, 0]))
const finishAt = new Map<number, number>()
const startAt = new Map<number, number>()
const laneOf = new Map<number, string>()
for (const n of inputs.order) {
const worker = pickWorker(lanes, freeAt, inputs.assignee.get(n))
const blockers = inputs.blockedBy.get(n) ?? []
const depFinish = blockers.length ? Math.max(...blockers.map((d) => finishAt.get(d) ?? 0)) : 0
const start = Math.max(freeAt.get(worker.person)!, depFinish)
const end = start + duration(n) / worker.speed
startAt.set(n, start)
finishAt.set(n, end)
laneOf.set(n, worker.person)
freeAt.set(worker.person, end)
}
return { finishAt, startAt, laneOf }
}
/**
* Capacity-aware plan. An empty `workers` means no capacity is configured → the
* single-worker plan verbatim.
*/
export function scheduleWithCapacity(
issues: SchedulableIssue[],
edges: DependencyEdge[],
workers: Worker[],
): SchedulePlan {
if (workers.length === 0) return schedule(issues, edges)
const base = schedule(issues, edges)
if (base.cycle || base.items.length === 0) return base
const inputs: LaneInputs = {
order: base.items.map((it) => it.number),
blockedBy: new Map(base.items.map((it) => [it.number, it.blockedBy])),
assignee: new Map(issues.map((i) => [i.number, i.assignee ?? null])),
}
const lanes = resolveLanes(workers, [...inputs.assignee.values()])
const durationOf = new Map(base.items.map((it) => [it.number, it.durationDays]))
const { finishAt, startAt, laneOf } = layoutOnLanes(inputs, lanes, (n) => durationOf.get(n)!)
const items = base.items.map((it) => ({
...it,
startDay: startAt.get(it.number)!,
endDay: finishAt.get(it.number)!,
durationDays: finishAt.get(it.number)! - startAt.get(it.number)!,
worker: laneOf.get(it.number),
}))
return { items, cycle: null }
}
/** Makespan (last finish) of a plan — the project's landing day. */
export function makespan(plan: SchedulePlan): number {
return plan.items.reduce((m, it) => Math.max(m, it.endDay), 0)
}

View File

@@ -21,8 +21,6 @@ export interface SchedulableIssue {
estimateDays: number | null estimateDays: number | null
/** 1 (most urgent) … 4; null when unset. */ /** 1 (most urgent) … 4; null when unset. */
priority: number | null priority: number | null
/** gitea assignee login, for capacity-aware lane routing; optional. */
assignee?: string | null
} }
export interface DependencyEdge { export interface DependencyEdge {
@@ -47,8 +45,6 @@ export interface ScheduledItem {
/** On a longest-duration dependency chain. */ /** On a longest-duration dependency chain. */
critical: boolean critical: boolean
rationale: string rationale: string
/** Lane it's scheduled on, when capacity-aware; absent for the single-worker plan. */
worker?: string
} }
export interface SchedulePlan { export interface SchedulePlan {

2436
yarn.lock

File diff suppressed because it is too large Load Diff