Compare commits

22 Commits

Author SHA1 Message Date
Croissant Le Doux
25cf0a6d39 feat: Reginald follows the model you load (auto-detect) + shows it in the header
Instead of a hardcoded model name (which forces LM Studio to JIT-swap your loaded
model out — and fails when a big model already fills memory), resolve the model
at request time: an explicit env override wins, else ask the server which model
is *loaded* (LM Studio's native /api/v0/models), else the first non-embedding
model, else a default. Reginald now uses whatever you load, no config churn.

- main/model.ts: resolveLoadedModel() drives both model:status and model:chat;
  COMMITEA_MODEL_SMALL still overrides.
- useChat exposes the resolved model id; the panel header shows it
  (google/gemma-4-26b-a4b-qat → "gemma-4-26b-a4b · local").
- live-reginald e2e: header assertion relaxed to the loaded model; timeouts
  raised for a slow big local model (~2 calls/turn + a reconcile).

Verified: 14 fixture e2e green; live e2e drives the app against the loaded
gemma-4-26b — "What now?" → "You should work on #2 … on the critical path,
unblocks #33 and #4" (the real scheduler pick), header shows the live model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:23:14 -04:00
Croissant Le Doux
3de887417c feat: Reginald is real — model router + agent loop + query_project (P4)
The fixture chat panel is now a working agent. Ask Reginald a question and it
consults the real project through a tool loop, then answers in grounded prose.
Read-only v0 — writes still go through the propose-approve controls.

core (@commitea/core/agent):
- chat-client: OpenAI-wire chat completions over an injected fetch (same seam as
  gitea). Points at any OpenAI-compatible endpoint (LM Studio/Ollama/OpenAI).
- model-router: small model for prose + the read tool; big model reserved for
  later decomposition (pickModel).
- agent-loop: runAgentTurn drives call→tool→result→call until prose (or a step
  budget), recording each tool step. Injected complete + execute → fully testable.
- query-project: the single read tool's engine — compact focus/board/calibration/
  issue/search views built from scheduler + lifecycle + calibration; unbuilt views
  return a notImplemented marker (never fabricated). The model reports, never computes.
- agent-tools: query_project declaration + Reginald's system prompt.

app:
- main model bridge (model:status, model:chat) runs the loop; query_project
  reconciles the repo and builds the view. Model traffic stays in main (token/CSP).
  gitea.ts refactored to share getGiteaClient + reconcileSnapshot.
- preload + global.d.ts expose the model bridge; useChat drives the panel — real
  agent turn when a model is configured, scripted fixture reply otherwise (so
  fixture e2e is unchanged). A subtle "consulted the project" activity line.

Model config (env, defaults to LM Studio on :1234): COMMITEA_MODEL_URL /
_SMALL (google/gemma-4-e4b) / _BIG (qwen/qwen3.6-35b-a3b). COMMITEA_E2E=1 keeps
it unconfigured so the panel stays scripted.

Verified: 88 core tests green (14 agent: client parse, loop tool/error/budget,
all views) + a gated live integration test. Desktop typecheck clean, 14 fixture
e2e green. Gated live e2e drives the real app against gitea + gemma-4-e4b: asked
"what now?", Reginald called query_project and answered "focus is on issue #2"
(the real scheduler pick).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:23:14 -04:00
e8bf71e970 Merge pull request 'P4-3: the write path — apply estimate/priority changes to gitea (apply_changes)' (#41) from p4/apply-changes into main
Reviewed-on: #41
2026-07-09 00:25:26 +00:00
Croissant Le Doux
6198f21d9a feat: the write path — apply estimate/priority changes to gitea (P4-3 apply_changes)
The first write path. Read, forecast, and calibration were all real; now you can
*manage* CommiTea from CommiTea. Estimates/priority are exclusive label axes, so
a change is a label swap — proposed, approved, then written. Nothing is assumed.

core (@commitea/core):
- planIssueChange(current, change): pure diff planner — swaps the est/*|p/* axis,
  clears on null, dedups a doubled axis; returns the resulting label set + a
  before/after diff + noop flag. describeChange() renders "est/2d → est/5d".
- request() seam extended for writes (method/body, JSON, 204). client gains
  listLabels() (name→id) and setIssueLabels() (PUT /issues/{n}/labels).

app:
- main bridge gitea:applyChange — resolves plan.labels → ids (cached, refetch on
  miss), PUTs, returns the plan + fresh issue. Token never leaves main.
- preload + global.d.ts expose applyChange; useBacklog returns a refetch so a
  write re-reconciles the board + forecast.
- Issue screen: an Adjust button (shown only when configured) opens a
  propose-approve Dialog — estimate/priority pickers, live "est/3d → est/8d"
  consequence, Apply/Cancel. AppShell wires it, reflects new labels on the open
  issue immediately, and refetches.

Verified: 83 core tests green (7 apply-changes + 2 client-write new), desktop
typecheck clean, 14 fixture e2e green. Live spec exercises propose + CANCEL (no
mutation); the real PUT was verified once manually (change #2 est/3d→est/8d→200,
reverted clean). Icon: pencil (no sliders-horizontal in the set).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:14:47 -04:00
a901b7f855 Merge pull request 'Calibration → forecast flips off cold-start (#1); re-lands Monte Carlo (#10) + lifecycle (#5)' (#40) from p2/calibration into main
Reviewed-on: #40
2026-07-08 23:54:48 +00:00
Croissant Le Doux
7e26de1b6c feat: calibration from closed-issue actuals → forecast flips off cold-start (#1)
Close the D3 loop. The forecast now learns from the team's own estimate-vs-actual
history (the working time #5 infers from git events) instead of guessing forever.

core (@commitea/core/calibration-v0):
- fitCalibration(samples): lognormal fit on log(actual/estimate) — global +
  per-bucket (once a bucket clears the floor) + per-person bias. coldStart until
  n >= 20 closed-with-estimate issues.
- calibrationSamples(): pull those samples from the closed backlog via lifecycle
  inference (estimate label vs inferred actualWorkingDays).
- toDurationModel(): project the fit to the params forecast consumes.
- forecast() gains options.model: when past cold-start, fitted params drive the
  sim (per bucket, global fallback); otherwise the code priors do. Forecast.coldStart
  now reflects the model. nearestBucket extracted + exported.

app:
- AppShell fits calibration once from the reconciled backlog, feeds the model into
  forecastBacklog (cone), and drives the Calibration screen + Runway header.
- Focus cone footer, Runway note, and Calibration screen now say cold-start (N/20)
  vs calibrated (on N closed) from real data; Calibration scatter / bucket bias /
  per-person all fitted, degrading honestly on a thin dataset.

Known refinement: same-day closes yield 0 working-day actuals (day-granular) and
are excluded, so a fast-moving repo can sit at n=0 — honest, but a fractional
(hours-based) actual would let those count. Per-person uses gitea login, not
display name, until the person map lands.

Note: also re-lands #10 (Monte Carlo) and #5 (lifecycle) which merged into their
stacked base branches but never propagated to main (stacked-merge trap); this
branch is cut from main and carries all three so main is whole again.

Verified: 74 core tests green (9 calibration + 2 forecast-switch added), desktop
typecheck clean, 14 fixture e2e green, live spec asserts the real cold-start
calibration surface (Runway note + screen badge fitted from actuals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:51:50 -04:00
Croissant Le Doux
9cedd8646e 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:42:46 -04:00
Croissant Le Doux
be70c8607d 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:42:46 -04:00
ca45f1e97c Merge pull request 'P2: deterministic scheduler → real Now/Next/Later' (#37) from p2/scheduler-focus into main
Reviewed-on: #37
2026-07-08 23:22:41 +00:00
e6be8cc64b Merge branch 'main' into p2/scheduler-focus 2026-07-08 23:22:35 +00:00
Croissant Le Doux
68a93de098 feat: deterministic scheduler → real Now/Next/Later (P2 thin slice)
CommiTea now recommends its own next unit of work from the live backlog.

- @commitea/core: `schedule()` — dependency topo-sort with priority +
  estimate tie-breaks, single serial capacity, cycle detection, and
  critical-path marking; `selectFocus()` takes the top three. Pure,
  deterministic; the LLM does none of this. +11 tests (39 in core).
  Client gains `getIssueDependencies`.
- main: reconcile also fetches native issue dependencies for the open
  scope and returns edges.
- renderer: `scheduleFocus()` maps real issues+deps→Now/Next/Later;
  Focus renders scheduler output (fixture fallback when unconfigured).

v0 scope (each a later slice): single serial worker (per-person
capacity #8), point durations (Monte Carlo cone #10), estimate-only
(calibration #5). Verified: 14 e2e green (fixtures) + gated live spec —
the board shows the real 25 open + 9 closed, and Focus picks #2
ChangeSource (critical path) as Now. Screenshots confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:12:22 -04:00
ecf1bb16e8 Merge pull request 'P1: read the real gitea backlog into the app' (#36) from p1/backlog-read into main
Reviewed-on: #36
2026-07-08 21:00:33 +00:00
Croissant Le Doux
94199639f2 feat: read the real gitea backlog into the app (P1 thin slice)
The app now displays its own live backlog instead of fixtures. First
end of the sync loop — the tap-root (#1) grows list reads and a read
path through the Electron main process.

- @commitea/core: client gains `listIssues` (paginated, PRs excluded)
  and `listMilestones`; a `lifecycle-v0` mapper (closed→done,
  labelled/milestoned→triage, bare→diagnosis — steeping/review await
  event inference in P1-5). +10 unit tests.
- main: gitea bridge over IPC (token stays in main, never the renderer);
  config from env / .env.local; gated off under COMMITEA_E2E so the
  committed e2e stays on fixtures. Preload exposes the typed bridge.
- renderer: useBacklog() reconciles once on mount; issuesToBoardColumns
  shapes real issues into The pot. Board takes optional real columns +
  a loading state, falling back to demo fixtures when unconfigured.

Verified: 14 e2e green (fixture mode) + a gated live spec that launches
against the real repo — the board renders the actual 25 open + 9 closed
issues (screenshot). SQLite mirror + reconcile-on-a-timer + lifecycle
event inference are the next slices (#2/#3/#5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:57:57 -04:00
103b6cc98d Merge pull request 'CommiTea foundation + P3 UI: gitea client, e2e harness, all product screens' (#35) from p1/gitea-read-client into main
Reviewed-on: #35
2026-07-08 20:42:13 +00:00
Croissant Le Doux
0fc53d03be feat(desktop): Directives, Settings, Onboarding — P3 complete (#21)
The last three P3 screens, retiring every placeholder:
- DirectivesScreen — jade consequence diff (propose-approve: Make it so
  / Amend / Withdraw, resolving moves it into the ledger) + append-only
  ledger (seq, who/when/why, verbatim quote, status badge).
- SettingsScreen — gitea connection + managed repos, sync switches,
  model router, read-only label schema, rituals, appearance radios
  (wired to the shared theme), single danger action.
- OnboardingScreen — full-window first run: welcome → connect (test
  gate) → repo pick → propose-approve bootstrap; onDone routes into the
  app. Shell early-returns it (no rail/chat), matching the design.

Shell routes directives/settings and the firstrun full-window flow.
DIRECTIVES fixture added. typecheck + 14 e2e green (incl. directive
resolve, appearance↔theme sync, onboarding test gate); all three
screenshot-verified.

Closes P3-8. P3 (UI views) complete — all 14 screens live on fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:22:59 -04:00
Croissant Le Doux
5176589f0c feat(desktop): complete the P3 screens — Board, Runway, Issue, Capture (#17–#20)
Ports the nine remaining design-handoff screens to typed TSX and wires
them into the shell, retiring the placeholders for every primary view.

- The pot (#17 / P3-4): BoardScreen 5-column kanban with live search +
  empty state, Tabs into GanttView (state bars, critical inset rules,
  dotted 80% tails, today rule) and DepsGraph (layered DAG, bezier edges,
  spruce critical path, milestone terminal).
- Runway (#18 / P3-5): RunwayScreen (range bars via RunwayBar, capacity,
  calibration teaser), CalibrationScreen (scatter + honest diagonal +
  ×1.18 fit, per-label/per-person bias), MilestoneScreen (stats strip,
  cone, grouped issues).
- Issue + Inbox (#19 / P3-6): IssueScreen (human intent left, machine-
  derived lifecycle/forecast/deps right, "writes as you" composer),
  InboxScreen (day-grouped, filter tabs, unread dots, mark-all-read).
- Capture (#20 / P3-7): CaptureScreen dump→interview→review→filed state
  machine with running clock.

Shell: openIssue now carries an IssueRef; adds readIds (inbox) + issue
state; routes board/runway/calibration/milestone/issue/inbox/capture and
the runway→calibration/milestone + issue back-stack. Focus/Standup pass
IssueRefs. Select/Input gain defaultValue (Capture's editable tray).
Fixtures mirrored from data.js. typecheck + 11 e2e green; all nine
screens screenshot-verified in light.

Closes P3-4, P3-5, P3-6, P3-7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:41:45 -04:00
Croissant Le Doux
6879bedb13 feat(desktop): Focus + Standup screens + burn-up cone (#16)
First real P3 screens, replacing the placeholders:
- FocusScreen — Now/Next/Later cards (Now = jade + Start/Defer footer),
  each with #id, label tags, steeping badge, agent-font rationale, and
  a title link that drives the shell issue drill-in. Milestone card
  wraps the burn-up cone.
- StandupScreen — typeset letter: overnight drift (dot + delta), per-
  person plan (initials avatars), warn-tint stale-blocker nag; sections
  settle in on a 90ms stagger, reduced-motion-safe.
- charts/chart.tsx — BurnUpCone (actual polyline, forecast cone with
  dashed 80% bounds, jade today rule) + RunwayBar (for P3-5). Fixed
  sample geometry until P2 feeds it.

Fixtures (today/focus/standup) mirrored from data.js. Shell routes
focus/standup to the real screens; the drill-in now starts from a Focus
card. Screenshot helper freezes animations so fade-in screens capture
settled. typecheck + 9 e2e green; light/standup/states verified.

Closes P3-3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:40:37 -04:00
Croissant Le Doux
f18c6d1078 feat(desktop): app shell — rail, chat panel, routing, states (#15)
Reimplements the handoff Shell in React/TS: 208px left rail (nav +
connection dot + Evening service theme switch), max-1120 main column,
330px Reginald chat panel. Routing across top-level views with an
issue drill-in + back-stack of one; data-theme owned by the shell.

- ChatPanel: fixture write-path — echoes a canned reply so layout +
  interactions are real; model router wiring lands in P4.
- states.tsx: EmptyState / OfflineBanner / ModelAwayState + the States
  specimen gallery, ported from the handoff.
- PlaceholderScreen stands in for not-yet-built views (P3-3+), keeping
  navigation live; it also exposes the issue drill-in for now.
- Gallery loses its own theme toggle (shell owns data-theme); reachable
  via a Primitives rail entry as a living reference.

Fixtures mirrored from the handoff's data.js. typecheck + 7 e2e green
(nav, theme, offline banner + disabled composer, chat echo, drill-in
back-stack); light/dark/states screenshots verified.

Closes P3-2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:32:05 -04:00
Croissant Le Doux
635025113c feat(desktop): port the 15 design-system primitives (#14)
Verbatim port of the handoff primitives into components/ui/ — Icon,
Button, IconButton, Badge, Tag, Card, Tabs (core); Input, Select,
Checkbox, Radio, Switch (forms); Dialog, Toast, Tooltip (feedback).
Each keeps its injected token-referencing CSS byte-for-byte; the
handoff .d.ts contracts become the exported prop interfaces. Barrel at
components/ui/index.ts.

Adds a PrimitivesGallery (app root for now; real shell is P3-2) that
exercises every primitive with a light/dark toggle. Smoke suite asserts
the gallery, section coverage, theme flip, and dialog open/Escape;
screenshots captured for both themes. typecheck + 5 e2e green.

Closes P3-1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:12:00 -04:00
Croissant Le Doux
96c2b8b1c2 test(desktop): Playwright electron e2e harness
Launches the built app (out/main/index.js) via Playwright's _electron
API — no browser project, no chromium download. Adds a launch fixture
(electronApp/window/app), an AppPage page object with a screenshot
helper for autonomous visual review, and a boot smoke suite (shell
renders, @commitea/core label-parse runs in the renderer, preload API
exposed). Scripts: e2e (build+run), e2e:only, e2e:report. Artifacts
gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:38:59 -04:00
Croissant Le Doux
d6f531eb6d feat(core): gitea read client behind an injected fetch (#1)
createGiteaClient(config, fetchImpl).getIssue(index) fetches one issue
and normalizes raw gitea JSON to a typed GiteaIssue with scheduler
facts precomputed via extractLabelFacts. Network is an injected
FetchLike (core has no DOM lib; global fetch is structurally
assignable), so it unit-tests against a stub — no live calls in the
suite. Non-2xx responses throw GiteaApiError carrying status + body.

Closes P1-1. Verified end-to-end against the live repo's issue #9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:33:29 -04:00
Croissant Le Doux
9920634e74 docs: settle open design items + dogfood backlog
Resolve the five design-session open items from PLAN.md:
- decisions.md: soft write-path, poll-only sync (NAT), lognormal
  cold-start priors, purity test binds the SQLite cache
- pm-state.md: sidecar layout + directive/capacity/calibration
  schemas + lifecycle inference table
- agent-tools.md: query_project read tool + three write tools

Also gitignore .env.* (protect the gitea PAT) and record the P0
actual: 10 labels, 5 milestones, 34 tracer-bullet issues + 51
dependencies filed on christian/commitea as the first managed project.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:30:23 -04:00
85 changed files with 8882 additions and 34 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
GITEA_TOKEN=

8
.gitignore vendored
View File

@@ -3,7 +3,15 @@ dist/
out/
*.log
.DS_Store
# playwright e2e artifacts
.artifacts/
test-results/
playwright-report/
.last-run.json
.env
.env.*
!.env.example
.yarn/*
!.yarn/patches
!.yarn/plugins

View File

@@ -25,6 +25,20 @@ yarn test # unit tests (vitest)
yarn typecheck
```
### End-to-end (Electron + Playwright)
```sh
yarn workspace @commitea/desktop e2e # build, then drive the built app
yarn workspace @commitea/desktop e2e:only # reuse existing out/ build (tight loop)
yarn workspace @commitea/desktop e2e:report # open the last HTML report
```
Tests launch the built app (`out/main/index.js`) through Playwright's
`_electron` API — no browser project, no chromium download. Fixtures and page
objects live in `apps/desktop/e2e/`; screenshots land in
`e2e/.artifacts/screens/` for visual review. Page objects use user-facing
locators (`getByRole`/`getByText`), never CSS/DOM structure.
## Conventions
- Yarn 4 workspaces; ESM everywhere; `.js` extensions on relative imports

View File

@@ -0,0 +1,48 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
_electron as electron,
expect,
test as base,
type ElectronApplication,
type Page,
} from '@playwright/test'
import { AppPage } from './pages/app.page.js'
const here = dirname(fileURLToPath(import.meta.url))
/** The built main-process entry Playwright launches. `yarn e2e` builds it first. */
const MAIN_ENTRY = join(here, '..', 'out', 'main', 'index.js')
interface CommiteaFixtures {
/** The launched Electron application. */
electronApp: ElectronApplication
/** The app's first (and only) BrowserWindow, as a Playwright Page. */
window: Page
/** Page Object over the app shell. */
app: AppPage
}
export const test = base.extend<CommiteaFixtures>({
electronApp: async ({}, use) => {
const electronApp = await electron.launch({
args: [MAIN_ENTRY],
env: { ...process.env, NODE_ENV: 'test', COMMITEA_E2E: '1' },
})
await use(electronApp)
await electronApp.close()
},
window: async ({ electronApp }, use) => {
const window = await electronApp.firstWindow()
await window.waitForLoadState('domcontentloaded')
await use(window)
},
app: async ({ window }, use) => {
await use(new AppPage(window))
},
})
export { expect }

View File

@@ -0,0 +1,65 @@
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 only (GITEA_LIVE=1). Launches WITHOUT COMMITEA_E2E so the main process
// reconciles the real repo via .env.local, and asserts real data renders.
test.describe('live backlog', () => {
test('The pot + Focus render real gitea data', async () => {
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
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' })
// The pot — waiting here also lets the reconcile (issues + deps + timelines) complete
await rail.getByRole('button', { name: 'The pot' }).click()
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 })
// Lifecycle inference (#5): columns come from the real event stream — merged
// work lands in Done, so that column is non-empty (proves timelines drove it,
// not the three-column fallback which would still show closed issues in Done).
await expect(win.getByText('Done', { exact: true })).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' })
// Back to Focus — the deterministic scheduler's real Now/Next/Later. These
// rationale phrases are emitted only by the scheduler, never by the demo fixture.
await rail.getByRole('button', { name: 'Morning service' }).click()
await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible()
// Real Monte Carlo cone — this headline is emitted only for real forecasts.
await expect(win.getByText(/80% of the open backlog lands by/)).toBeVisible()
await expect(win.getByText(/Cold-start priors/)).toBeVisible()
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()
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 estimate & priority')).toBeVisible()
await win.getByRole('combobox').first().selectOption('est/8d')
await expect(win.getByText('Proposed label change')).toBeVisible()
await expect(win.getByText(/est\/8d/).last()).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()
})
})

View File

@@ -0,0 +1,33 @@
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()
})
})

View File

@@ -0,0 +1,50 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, type Locator, type Page } from '@playwright/test'
const here = dirname(fileURLToPath(import.meta.url))
/** Where `screenshot()` drops PNGs — gitignored, readable for visual review. */
const SCREENS_DIR = join(here, '..', '.artifacts', 'screens')
/**
* Page Object over the CommiTea app shell. As real screens land (P3), add
* per-screen page objects and accessor methods here; keep locators
* user-facing (getByRole/getByText), never CSS/DOM structure.
*/
export class AppPage {
constructor(readonly page: Page) {}
/** The rail wordmark — present on every in-app view. */
get wordmark(): Locator {
return this.page.getByText('CommiTea', { exact: true })
}
/** Navigate via a left-rail entry by its label. Scoped to the Primary rail so
* it never collides with in-screen breadcrumb buttons of the same name, and
* matches by prefix so count badges (e.g. "Inbox 3") still resolve. */
nav(label: string): Locator {
return this.page.getByRole('navigation', { name: 'Primary' }).getByRole('button', { name: label })
}
/** Assert the shell has rendered. */
async expectLoaded(): Promise<void> {
await expect(this.wordmark).toBeVisible()
}
/** Read the `commitea` preload API surface from the renderer. */
async preloadApi(): Promise<Record<string, unknown> | undefined> {
return this.page.evaluate(
() => (globalThis as unknown as { commitea?: Record<string, unknown> }).commitea,
)
}
/** Capture a full-page screenshot for visual review; returns the path.
* `animations: 'disabled'` fast-forwards finite CSS animations to their end
* state, so fade-in screens (e.g. Standup) capture settled, not mid-fade. */
async screenshot(name: string): Promise<string> {
const path = join(SCREENS_DIR, `${name}.png`)
await this.page.screenshot({ path, fullPage: true, animations: 'disabled' })
return path
}
}

View File

@@ -0,0 +1,146 @@
import { expect, test } from './fixtures.js'
test('boots into the shell with the Focus screen', async ({ app, window }) => {
await app.expectLoaded()
await expect(window.getByText('Reginald', { exact: true })).toBeVisible()
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
await app.screenshot('shell-light')
})
test('Focus shows Now/Next/Later cards and the burn-up cone', async ({ window }) => {
for (const slot of ['Now', 'Next', 'Later']) {
await expect(window.getByText(slot, { exact: true })).toBeVisible()
}
await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible()
await expect(window.getByText(/scope · 42 issues/)).toBeVisible()
})
test('Standup renders the drift / plan / nag letter', async ({ app, window }) => {
await app.nav('Standup').click()
await expect(window.getByRole('heading', { name: 'Morning standup' })).toBeVisible()
await expect(window.getByText('Overnight drift')).toBeVisible()
await expect(window.getByText('Stephen', { exact: true })).toBeVisible()
await app.screenshot('standup')
})
test('Board: columns, search filter, and Gantt/Deps tabs', async ({ app, window }) => {
await app.nav('The pot').click()
await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible()
await expect(window.getByText('Diagnosis', { exact: true })).toBeVisible()
await expect(window.getByText('Steeping', { exact: true })).toBeVisible()
await app.screenshot('board')
await window.getByPlaceholder(/Search the pot/).fill('lifecycle')
await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible()
await expect(window.getByText('Dark theme: cone fill too faint')).toBeHidden()
await window.getByPlaceholder(/Search the pot/).fill('')
await window.getByRole('tab', { name: 'Gantt' }).click()
await expect(window.getByText(/80% · Mar 312/)).toBeVisible()
await app.screenshot('gantt')
await window.getByRole('tab', { name: 'Dependencies' }).click()
await expect(window.getByText('critical path', { exact: true })).toBeVisible()
await app.screenshot('deps')
})
test('Runway: milestones, capacity, and drill-ins', async ({ app, window }) => {
await app.nav('Runway').click()
await expect(window.getByRole('heading', { name: 'Runway' })).toBeVisible()
await expect(window.getByText('Pilot-ready')).toBeVisible()
await expect(window.getByText('deadline/hard')).toBeVisible()
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: notifications and mark-all-read', async ({ app, window }) => {
await app.nav('Inbox').click()
await expect(window.getByRole('heading', { name: 'Inbox' })).toBeVisible()
await expect(window.getByText(/window moved/)).toBeVisible()
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 advances to the interview', async ({ app, window }) => {
await app.nav('Capture').click()
await expect(window.getByText("Tell me what you're planning")).toBeVisible()
await app.screenshot('capture')
await window.getByRole('button', { name: 'Brew tickets' }).click()
await expect(window.getByText(/Interview · 1 of 3/)).toBeVisible()
})
test('Directives: consequence diff + append-only ledger', async ({ app, window }) => {
await app.nav('Directives').click()
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 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, schema, and appearance sync with theme', async ({ app, window }) => {
await app.nav('Settings').click()
await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible()
await expect(window.getByText('Managed repos')).toBeVisible()
await app.screenshot('settings')
// the Evening (dark) radio drives the shared theme
await window.getByText('Evening (dark)').click()
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark')
})
test('Onboarding: full-window first-run flow with test gate', async ({ app, window }) => {
await app.nav('First run').click()
await expect(window.getByRole('heading', { name: 'Good morning.' })).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 window.getByRole('button', { name: 'Back' }).click()
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
})
test('Evening service toggle flips the document theme', async ({ app, window }) => {
await window.getByText('Evening service', { exact: true }).click()
await expect(window.locator('html')).toHaveAttribute('data-theme', '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 }) => {
const api = await app.preloadApi()
expect(api).toBeDefined()
expect(api).toHaveProperty('platform')
})

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"moduleResolution": "Bundler",
"noEmit": true
},
"include": ["."]
}

View File

@@ -7,7 +7,10 @@
"dev": "electron-vite dev 2>&1 | tee desktop.log",
"build": "electron-vite build",
"start": "electron-vite preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"e2e": "electron-vite build && playwright test",
"e2e:only": "playwright test",
"e2e:report": "playwright show-report e2e/.artifacts/report"
},
"dependencies": {
"@commitea/core": "workspace:*",
@@ -15,6 +18,7 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^22.13.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",

View File

@@ -0,0 +1,26 @@
import { defineConfig } from '@playwright/test'
/**
* E2E harness for the Electron app. Tests launch the *built* app
* (`out/main/index.js`) through Playwright's `_electron` API — see
* `e2e/fixtures.ts`. There is no browser project and no chromium download:
* `_electron` drives the app's own bundled electron.
*
* Run `yarn e2e` (builds first) for a fresh run, or `yarn e2e:only` to reuse
* the existing `out/` build during a tight iteration loop.
*/
export default defineConfig({
testDir: './e2e',
outputDir: './e2e/.artifacts/test-results',
fullyParallel: false,
workers: 1, // one electron instance at a time — deterministic, avoids window races
forbidOnly: !!process.env.CI,
retries: 0,
timeout: 30_000,
expect: { timeout: 5_000 },
reporter: [['list'], ['html', { outputFolder: './e2e/.artifacts/report', open: 'never' }]],
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
})

View File

@@ -0,0 +1,127 @@
/**
* Main-process gitea bridge. All gitea traffic runs here — the token never
* reaches the renderer (which is CSP-locked to 'self' anyway). The renderer
* calls these over IPC (see preload). Config for the dogfood slice comes from
* the environment or the repo's .env.local; Settings/Onboarding wire it up
* properly later.
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import {
createGiteaClient,
type GiteaClient,
type GiteaConfig,
type GiteaLabel,
type IssueChange,
type LifecycleEvent,
planIssueChange,
type ProjectSnapshot,
} from '@commitea/core'
import { ipcMain } from 'electron'
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
function loadEnvLocalToken(): string | undefined {
let dir = process.cwd()
for (let i = 0; i < 6; i++) {
try {
const txt = readFileSync(join(dir, '.env.local'), 'utf8')
const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(txt)
if (m) return m[1].trim()
} catch {
// not in this dir — keep walking up
}
const parent = dirname(dir)
if (parent === dir) break
dir = parent
}
return undefined
}
function resolveConfig(): GiteaConfig | null {
// E2E runs against fixtures — never hit the network from the test harness.
if (process.env.COMMITEA_E2E === '1') return null
const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken()
if (!token) return null
return {
baseUrl: process.env.GITEA_BASE_URL ?? 'https://gitea.stephenmann.io',
token,
owner: process.env.GITEA_OWNER ?? 'christian',
repo: process.env.GITEA_REPO ?? 'commitea',
}
}
// Memoized client so both the gitea and model bridges share one instance.
let sharedClient: GiteaClient | null | undefined
export function getGiteaClient(): GiteaClient | null {
if (sharedClient === undefined) {
const config = resolveConfig()
sharedClient = config ? createGiteaClient(config, fetch) : null
}
return sharedClient
}
/** 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()])
// dependency edges among the open scope (the scheduler only plans what's left)
const open = issues.filter((i) => i.state === 'open')
const perIssue = await Promise.all(
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
)
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
// lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals)
const timelineEntries = await Promise.all(
issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const),
)
const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries)
return { issues, milestones, deps, timelines }
}
export function registerGiteaIpc(): void {
const client = getGiteaClient()
const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null
ipcMain.handle('gitea:status', () => ({ configured: !!client, repo }))
ipcMain.handle('gitea:reconcile', async () => {
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
const snap = await reconcileSnapshot(client)
return { configured: true, ...snap }
})
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
if (!client) return null
return client.getIssue(index)
})
// Cached label list for name→id resolution; refreshed on demand if a name misses.
let labelCache: GiteaLabel[] | null = null
async function resolveLabelIds(names: string[]): Promise<number[]> {
if (!client) return []
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)
}
// The write path (apply_changes). Additive label swaps, applied only after the
// renderer's propose-approve. Returns the plan + the freshly-read issue.
ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
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(plan.labels)
await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue)
return { ok: true as const, plan, issue }
})
}

View File

@@ -2,6 +2,9 @@ import { join } from 'node:path'
import { BrowserWindow, app, shell } from 'electron'
import { registerGiteaIpc } from './gitea.js'
import { registerModelIpc } from './model.js'
function createWindow(): void {
const win = new BrowserWindow({
width: 1440,
@@ -32,6 +35,8 @@ function createWindow(): void {
}
void app.whenReady().then(() => {
registerGiteaIpc()
registerModelIpc()
createWindow()
app.on('activate', () => {

View File

@@ -0,0 +1,103 @@
/**
* 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,
type ChatMessage,
createChatClient,
type ModelRouter,
type ProjectView,
type QueryFilters,
REGINALD_SYSTEM,
REGINALD_TOOLS,
runAgentTurn,
} from '@commitea/core'
import { ipcMain } from 'electron'
import { getGiteaClient, reconcileSnapshot } 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
const baseUrl = process.env.COMMITEA_MODEL_URL ?? '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 ?? '' },
}
}
/**
* 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 {
const router = resolveModelRouter()
ipcMain.handle('model:status', async () => {
if (!router) return { configured: false, model: null }
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
return { configured: true, model }
})
ipcMain.handle('model:chat', async (_event, messages: ChatMessage[]) => {
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)
const execute = async (name: string, args: unknown) => {
if (name !== 'query_project') return { error: `unknown tool: ${name}` }
if (!client) return { error: 'gitea is not configured' }
const snap = await reconcileSnapshot(client)
const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters }
return buildProjectView(a.view, a.filters, snap, new Date())
}
try {
const turn = await runAgentTurn({
complete: (m, t) => chat.complete(m, t),
messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages],
tools: REGINALD_TOOLS,
execute,
})
return { ok: true as const, content: turn.content, steps: turn.steps }
} catch (e) {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
}
})
}

View File

@@ -1,7 +1,23 @@
import { contextBridge } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
const api = {
platform: process.platform,
gitea: {
/** Whether the main process has a gitea token + target repo configured. */
status: () => ipcRenderer.invoke('gitea:status'),
/** Full read of the managed repo — every issue + milestone. */
reconcile: () => ipcRenderer.invoke('gitea:reconcile'),
/** One issue by index, normalized (or null if unconfigured). */
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),
},
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),
},
}
export type CommiteaApi = typeof api

View File

@@ -1,26 +1,5 @@
import { extractLabelFacts } from '@commitea/core'
const facts = extractLabelFacts(['est/3d', 'p/2', 'deadline/hard'])
import { AppShell } from './components/shell/app-shell.js'
export function App() {
return (
<main className="min-h-screen bg-paper-0 font-sans text-ink-1">
<div className="mx-auto max-w-[1120px] px-7 py-16">
<header className="border-b-[3px] border-double border-ink-1 pb-4">
<h1 className="font-display text-5xl">CommiTea</h1>
<p className="mt-2 font-mono text-sm text-ink-3">
scaffold · phase 0 · gitea connection pending
</p>
</header>
<p className="mt-8 max-w-[60ch] font-agent text-lg leading-relaxed">
Good morning. The scaffold stands and the kettle is on, but I have nothing to manage
yet. Connect me to gitea and we shall put the pot to work.
</p>
<p className="mt-6 font-mono text-sm text-ink-2">
label parse check: est/{facts.estimateDays}d · p/{facts.priority} · hard{' '}
{String(facts.hardDeadline)}
</p>
</div>
</main>
)
return <AppShell />
}

View File

@@ -0,0 +1,183 @@
import type { BurnUpData } from '../../lib/dates.js'
/**
* Charts — geometry, not decoration. Ported from the handoff's Chart.js. When a
* `data` prop is supplied the cone is drawn from real Monte Carlo output
* (#10); without it, the fixed sample paths below stand in (demo mode). The
* shapes (cone from today, 80% band, today rule) are final.
*/
export interface BurnUpConeProps {
width?: number
height?: number
/** Real forecast geometry. When present it replaces the demo sample paths. */
data?: BurnUpData
}
export function BurnUpCone({ width = 640, height = 220, data }: BurnUpConeProps) {
if (data) return <BurnUpConeReal width={width} height={height} data={data} />
const pad = { l: 34, r: 96, t: 16, b: 26 }
const W = width - pad.l - pad.r
const H = height - pad.t - pad.b
const x = (f: number) => pad.l + f * W
const y = (f: number) => pad.t + (1 - f) * H
const today = 0.58
const actual: number[][] = [
[0, 0],
[0.08, 0.05],
[0.18, 0.13],
[0.26, 0.16],
[0.36, 0.27],
[0.46, 0.38],
[0.58, 0.47],
]
const coneHi: number[][] = [
[0.58, 0.47],
[0.72, 0.66],
[0.86, 0.88],
[0.95, 1.0],
]
const coneLo: number[][] = [
[0.58, 0.47],
[0.74, 0.58],
[0.9, 0.74],
[1.0, 0.86],
]
const mid: number[][] = [
[0.58, 0.47],
[0.76, 0.63],
[0.92, 0.83],
[1.0, 0.93],
]
const pts = (arr: number[][]) => arr.map(([a, b]) => `${x(a)},${y(b)}`).join(' ')
const cone = [...coneHi, ...[...coneLo].reverse()]
return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }}>
{/* gridlines */}
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
<line key={f} x1={pad.l} x2={width - pad.r} y1={y(f)} y2={y(f)} stroke="var(--line-1)" strokeWidth="1" />
))}
{/* scope */}
<line x1={pad.l} x2={width - pad.r} y1={y(1)} y2={y(1)} stroke="var(--line-2)" strokeWidth="1.5" />
<text x={pad.l} y={y(1) - 6} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
scope · 42 issues
</text>
{/* cone */}
<polygon points={pts(cone)} fill="var(--cone-fill)" />
<polyline points={pts(coneHi)} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
<polyline points={pts(coneLo)} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
<polyline points={pts(mid)} fill="none" stroke="var(--cone-line)" strokeWidth="1.4" />
{/* actual */}
<polyline points={pts(actual)} fill="none" stroke="var(--cone-actual)" strokeWidth="2" />
<circle cx={x(0.58)} cy={y(0.47)} r="3.5" fill="var(--cone-actual)" />
{/* today rule */}
<line x1={x(today)} x2={x(today)} y1={pad.t} y2={height - pad.b} stroke="var(--jade)" strokeWidth="1" />
<text x={x(today) + 5} y={pad.t + 10} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--jade-7)' }}>
today
</text>
{/* 80% band label */}
<text x={width - pad.r + 10} y={y(0.95)} style={{ font: '500 11.5px var(--font-mono)', fill: 'var(--ink-1)' }}>
80%
</text>
<text x={width - pad.r + 10} y={y(0.95) + 14} style={{ font: '400 11px var(--font-mono)', fill: 'var(--ink-2)' }}>
Mar 312
</text>
{/* x labels */}
<text x={pad.l} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
Jan 6
</text>
<text x={width - pad.r - 34} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
Mar 15
</text>
</svg>
)
}
/** Data-driven twin of BurnUpCone — same visual grammar, real forecast geometry. */
function BurnUpConeReal({ width, height, data }: { width: number; height: number; data: BurnUpData }) {
const pad = { l: 34, r: 96, t: 16, b: 26 }
const W = width - pad.l - pad.r
const H = height - pad.t - pad.b
const x = (day: number) => pad.l + (day / data.horizonDays) * W
const y = (frac: number) => pad.t + (1 - frac) * H
const hi = data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`).join(' ')
const lo = data.band.map((p) => `${x(p.loDay)},${y(p.fraction)}`).join(' ')
const mid = data.band.map((p) => `${x(p.midDay)},${y(p.fraction)}`).join(' ')
const cone = [
...data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`),
...[...data.band].reverse().map((p) => `${x(p.loDay)},${y(p.fraction)}`),
].join(' ')
return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }}>
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
<line key={f} x1={pad.l} x2={width - pad.r} y1={y(f)} y2={y(f)} stroke="var(--line-1)" strokeWidth="1" />
))}
{/* scope */}
<line x1={pad.l} x2={width - pad.r} y1={y(1)} y2={y(1)} stroke="var(--line-2)" strokeWidth="1.5" />
<text x={pad.l} y={y(1) - 6} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
scope · {data.scope} {data.scope === 1 ? 'issue' : 'issues'}
</text>
{/* cone */}
<polygon points={cone} fill="var(--cone-fill)" />
<polyline points={hi} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
<polyline points={lo} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
<polyline points={mid} fill="none" stroke="var(--cone-line)" strokeWidth="1.4" />
{/* origin = today, 0% of remaining scope */}
<circle cx={x(0)} cy={y(0)} r="3.5" fill="var(--cone-actual)" />
{/* today rule */}
<line x1={x(0)} x2={x(0)} y1={pad.t} y2={height - pad.b} stroke="var(--jade)" strokeWidth="1" />
<text x={x(0) + 5} y={pad.t + 10} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--jade-7)' }}>
today
</text>
{/* 80% band label */}
<text x={width - pad.r + 10} y={y(0.95)} style={{ font: '500 11.5px var(--font-mono)', fill: 'var(--ink-1)' }}>
80%
</text>
<text x={width - pad.r + 10} y={y(0.95) + 14} style={{ font: '400 11px var(--font-mono)', fill: 'var(--ink-2)' }}>
{data.rangeLabel}
</text>
{/* x labels */}
<text x={pad.l} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
{data.startLabel}
</text>
<text x={width - pad.r - 34} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
{data.endLabel}
</text>
</svg>
)
}
export interface RunwayBarMilestone {
tone: 'ok' | 'warn'
pos: number
spread: number
}
/** Milestone due-date vs forecast-range position. Used by the Runway screen (P3-5). */
export function RunwayBar({ m }: { m: RunwayBarMilestone }) {
const toneColor = m.tone === 'warn' ? 'var(--warn)' : 'var(--ok)'
const left = Math.max(0, (m.pos - m.spread / 2) * 100)
const w = Math.min(100 - left, m.spread * 100)
return (
<div style={{ position: 'relative', height: 22, background: 'var(--paper-2)', borderRadius: 4, overflow: 'hidden' }}>
<div
style={{
position: 'absolute',
left: `${left}%`,
width: `${w}%`,
top: 0,
bottom: 0,
background: 'var(--cone-fill)',
borderLeft: `1.5px solid ${toneColor}`,
borderRight: `1.5px solid ${toneColor}`,
}}
/>
<div style={{ position: 'absolute', left: `${m.pos * 100}%`, top: 0, bottom: 0, width: 2, background: toneColor }} />
<div style={{ position: 'absolute', left: 'calc(88% - 1px)', top: 0, bottom: 0, width: 2, background: 'var(--ink-1)' }} />
</div>
)
}

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

@@ -0,0 +1,99 @@
import React from 'react'
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 { EmptyState } from '../shell/states.js'
import { GanttView } from './gantt-view.js'
import { DepsGraph } from './deps-graph.js'
// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs.
// `columns` defaults to demo fixtures; the shell passes real reconciled data
// when gitea is configured. `loading` covers the first reconcile.
export function BoardScreen({
onOpenIssue,
columns = COLUMNS,
loading = false,
}: {
onOpenIssue: (issue: IssueRef) => void
columns?: BoardColumn[]
loading?: boolean
}) {
const [tab, setTab] = React.useState('board');
const [query, setQuery] = React.useState('');
const q = query.trim().toLowerCase();
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 openCount = columns.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0);
const IssueCard = ({ issue }: { issue: BoardIssue }) => (
<div
onClick={() => onOpenIssue(issue)}
style={{
background: 'var(--surface-card)', border: '1px solid var(--line-1)',
borderRadius: 'var(--radius-2)', padding: '10px 12px', cursor: 'pointer',
boxShadow: 'var(--shadow-1)', display: 'flex', flexDirection: 'column', gap: 8,
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.borderColor = 'var(--line-2)'; }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.borderColor = 'var(--line-1)'; }}
>
<div style={{ font: 'var(--text-small)', fontWeight: 500, color: 'var(--ink-1)' }}>{issue.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>#{issue.id}</span>
{issue.labels.map((l) => <Tag key={l} label={l} style={{ transform: 'scale(0.95)', transformOrigin: 'left center' }} />)}
{issue.days ? <span style={{ font: '400 11px var(--font-mono)', color: 'var(--warn)' }}>{issue.days}</span> : null}
{issue.pr ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, font: '400 11px var(--font-mono)', color: 'var(--info)' }}><Icon name="git-pull-request" size={12} />{issue.pr}</span> : null}
<span style={{
marginLeft: 'auto', width: 20, height: 20, borderRadius: '50%',
background: 'var(--spruce-2)', color: 'var(--accent-text)',
font: '600 9px/20px var(--font-sans)', textAlign: 'center', flexShrink: 0,
}}>{issue.who}</span>
</div>
</div>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, height: '100%', minHeight: 0 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0, whiteSpace: 'nowrap' }}>The pot</h1>
<div style={{ width: 240 }}><Input icon="search" placeholder="Search the pot…" value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} /></div>
</header>
<Tabs
items={[
{ id: 'board', label: 'Board', icon: 'square-kanban', count: openCount },
{ id: 'gantt', label: 'Gantt', icon: 'chart-no-axes-gantt' },
{ id: 'deps', label: 'Dependencies', icon: 'network' },
]}
active={tab} onChange={setTab}
/>
{tab === 'board' ? (
loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--ink-3)' }}>
<Icon name="loader-circle" size={16} />
<span style={{ font: 'var(--text-small)' }}>Reconciling with gitea</span>
</div>
) : anyMatch ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, alignItems: 'start', flex: 1, minHeight: 0, overflow: 'auto' }}>
{filtered.map((col) => (
<div key={col.id} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 2px 4px', borderBottom: '1px solid var(--line-1)', whiteSpace: 'nowrap' }}>
<span style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-2)' }}>{col.label}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{col.issues.length}</span>
</div>
{col.issues.map((i) => <IssueCard key={i.id} issue={i} />)}
</div>
))}
</div>
) : (
<div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}>
<EmptyState icon="search" title="Nothing by that name"
line={`The pot holds ${columns.reduce((n, c) => n + c.issues.length, 0)} issues; none of them answer to “${query.trim()}”.`} />
</div>
)
) : tab === 'deps' ? (
<DepsGraph onOpenIssue={onOpenIssue} />
) : (
<GanttView onOpenIssue={onOpenIssue} />
)}
</div>
);
}

View File

@@ -0,0 +1,187 @@
import React from 'react'
import { CALIBRATION, type CalibrationData } from '../../data/fixtures.js'
import { Badge, Card, Icon } from '../ui/index.js'
// Calibration report — estimate-vs-actual evidence behind the cones.
// `data` (real fit from closed-issue actuals) overrides the demo fixture.
export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?: CalibrationData }) {
const c = data ?? CALIBRATION
// scatter chart geometry
const W = 420,
H = 300,
pad = { l: 36, r: 16, t: 14, b: 30 }
const maxD = 9
const X = (d: number) => pad.l + (d / maxD) * (W - pad.l - pad.r)
const Y = (d: number) => H - pad.b - (d / maxD) * (H - pad.t - pad.b)
const BiasBar = ({ bias }: { bias: number | null }) => {
if (bias == null)
return <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>n too small</span>
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
<div style={{ position: 'relative', flex: 1, height: 10, background: 'var(--paper-2)', borderRadius: 3 }}>
<div style={{ position: 'absolute', left: '30%', top: -2, bottom: -2, width: 1.5, background: 'var(--line-2)' }}></div>
<div
style={{
position: 'absolute',
left: '30%',
top: 1.5,
height: 7,
width: `${Math.min(bias * 1.6, 66)}%`,
background: bias > 15 ? 'var(--warn)' : 'var(--ok)',
borderRadius: '0 3px 3px 0',
opacity: 0.75,
}}
></div>
</div>
<span style={{ font: '500 11.5px var(--font-mono)', color: bias > 15 ? 'var(--warn)' : 'var(--ok)', width: 42, textAlign: 'right' }}>
+{bias}%
</span>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<button
type="button"
onClick={onBack}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: 'none',
border: 'none',
font: '500 12.5px var(--font-sans)',
color: 'var(--ink-2)',
cursor: 'pointer',
padding: '2px 0',
marginBottom: 10,
}}
>
<Icon name="arrow-left" size={14} /> Runway
</button>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14, display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<div>
<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>
</div>
{c.active ? (
<Badge tone="ok" dot>curve active · n 20</Badge>
) : (
<Badge tone="warn" dot>cold-start · {c.n}/20</Badge>
)}
</header>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, alignItems: 'start' }}>
{/* scatter */}
<Card overline="Estimate vs actual" title="The shape of hope" jade>
<svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: 'block' }}>
{[1, 3, 5, 8].map((d) => (
<React.Fragment key={d}>
<line x1={X(d)} x2={X(d)} y1={pad.t} y2={H - pad.b} stroke="var(--line-1)" strokeWidth="1" />
<text x={X(d)} y={H - 12} textAnchor="middle" style={{ font: '400 10px var(--font-mono)', fill: 'var(--ink-3)' }}>{d}d</text>
<line x1={pad.l} x2={W - pad.r} y1={Y(d)} y2={Y(d)} stroke="var(--line-1)" strokeWidth="1" />
<text x={pad.l - 6} y={Y(d) + 3} textAnchor="end" style={{ font: '400 10px var(--font-mono)', fill: 'var(--ink-3)' }}>{d}d</text>
</React.Fragment>
))}
{/* perfect line */}
<line x1={X(0)} y1={Y(0)} x2={X(maxD)} y2={Y(maxD)} stroke="var(--line-2)" strokeWidth="1.2" strokeDasharray="4 4" />
<text x={X(7.6)} y={Y(7.6) + 14} style={{ font: '400 10px var(--font-mono)', fill: 'var(--ink-3)' }}>honest</text>
{/* fit */}
<line x1={X(0)} y1={Y(0)} x2={X(maxD / c.fit)} y2={Y(maxD)} stroke="var(--cone-line)" strokeWidth="1.6" />
<text x={X(4.1)} y={Y(4.1 * c.fit) - 8} style={{ font: '500 10px var(--font-mono)', fill: 'var(--cone-line)' }}>you · ×{c.fit}</text>
{/* points */}
{c.scatter.map(([e, a], i) => (
<circle key={i} cx={X(e) + ((i % 5) - 2) * 3} cy={Y(a)} r="3" fill="var(--accent)" opacity="0.55" />
))}
</svg>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 0 0' }}>estimated (x) vs actual days (y) · actuals inferred from git events, never tracked</p>
</Card>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* per-label bias */}
<Card overline="Bias by estimate label" flush>
<div>
{c.labels.map((r, i) => (
<div
key={r.label}
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '10px 20px',
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}}
>
<span style={{ font: '500 11.5px var(--font-mono)', color: 'var(--ink-1)', width: 52 }}>{r.label}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', width: 66 }}>n={r.n} · {r.median}</span>
<BiasBar bias={r.bias} />
</div>
))}
</div>
</Card>
{/* per-person */}
<Card overline="By person" flush>
<div>
{c.people.map((p, i) => (
<div
key={p.who}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '11px 20px',
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}}
>
<span
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: 'var(--spruce-2)',
color: 'var(--accent-text)',
font: '600 9px/24px var(--font-sans)',
textAlign: 'center',
flexShrink: 0,
}}
>
{p.who
.split(' ')
.map((w) => w[0])
.join('')}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>{p.who}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}> · n={p.n} · {p.note}</span>
</div>
<span style={{ font: '500 12px var(--font-mono)', color: p.bias > 15 ? 'var(--warn)' : 'var(--ok)' }}>+{p.bias}%</span>
</div>
))}
</div>
</Card>
{/* effect on forecasts */}
<Card overline="What this does to your forecasts">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ font: '400 12.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>{c.effect.raw}</span>
<Icon name="arrow-right" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.effect.banded}</span>
</div>
<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.'
: 'Not enough closed history yet — Im forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'}
</p>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,209 @@
import React from 'react'
import { Badge, Button, Card, Icon, Select, Tag } from '../ui/index.js'
// Capture interview — braindump → interview → approved ticket set (< 2 min)
interface Ticket {
title: string
est: string
p: string
dep?: string
byReginald?: boolean
}
interface Question {
q: string
chips: string[]
set: (a: string) => void
}
export function CaptureScreen({ onDone }: { onDone: () => void }) {
const [stage, setStage] = React.useState<'dump' | 'interview' | 'review' | 'filed'>('dump')
const [dump, setDump] = React.useState(
'auth is flaky — token refresh dies silently, sometimes session storage goes stale. ' +
'also the webhook debounce thing keeps double-firing. and we owe docs for auth setup'
)
const [qi, setQi] = React.useState(0)
const [log, setLog] = React.useState<{ q: string; a: string }[]>([])
const [split, setSplit] = React.useState<boolean | null>(null)
const [webEst, setWebEst] = React.useState<string | null>(null)
const [secs, setSecs] = React.useState(0)
const running = stage === 'interview' || stage === 'review'
React.useEffect(() => {
if (!running) return
const t = setInterval(() => setSecs((s) => s + 1), 1000)
return () => clearInterval(t)
}, [running])
const clock = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
const QUESTIONS: Question[] = [
{
q: 'The auth work — one ticket, or shall I split token refresh from session storage? They fail differently.',
chips: ['One ticket', 'Split them'],
set: (a) => setSplit(a === 'Split them'),
},
{
q: 'The webhook double-fire — how long? I should mention your "quick" has averaged two days.',
chips: ['est/1d', 'est/2d', 'est/3d'],
set: (a) => setWebEst(a),
},
{
q: 'Milestone Beta, I presume? It has room, provided the auth work stays under four days.',
chips: ['Beta', 'New milestone'],
set: () => {},
},
]
const answer = (a: string) => {
QUESTIONS[qi].set(a)
setLog((l) => [...l, { q: QUESTIONS[qi].q, a }])
if (qi + 1 < QUESTIONS.length) setQi(qi + 1)
else setStage('review')
}
// draft tickets build as the interview progresses
const tickets: Ticket[] = []
if (split === true) {
tickets.push({ title: 'Token refresh: retry with backoff', est: 'est/2d', p: 'p/2' })
tickets.push({ title: 'Session storage: stale reads on wake', est: 'est/1d', p: 'p/3' })
} else if (split === false) {
tickets.push({ title: 'Auth: token refresh + session storage', est: 'est/3d', p: 'p/2' })
}
if (webEst) tickets.push({ title: 'Webhook debounce: double-fire guard', est: webEst, p: 'p/1', dep: 'blocked by auth work' })
if (stage === 'review' || stage === 'filed') {
tickets.push({ title: 'Docs: auth setup guide', est: 'est/1d', p: 'p/4', byReginald: true })
}
const totalDays = tickets.reduce((n, t) => n + parseInt(t.est.replace('est/', '')), 0)
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 Tray = ({ editable }: { editable?: boolean }) => (
<Card overline="The tray" title={tickets.length ? `${tickets.length} draft${tickets.length > 1 ? 's' : ''}` : 'Empty, for now'} flush>
<div>
{tickets.length === 0 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0, padding: '14px 20px 18px' }}>
Tickets appear here as we talk.
</p>
) : tickets.map((t, i) => (
<div key={t.title} style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: '12px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}}>
<div style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>{t.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{editable ? (
<>
<Select options={estOptions} defaultValue={t.est} style={{ width: 96 }} />
<Select options={pOptions} defaultValue={t.p} style={{ width: 76 }} />
</>
) : (
<>
<Tag label={t.est} />
<Tag label={t.p} />
</>
)}
{t.dep ? <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="network" size={11} />{t.dep}</span> : null}
{t.byReginald ? <Badge tone="jade">added by Reginald</Badge> : null}
</div>
</div>
))}
</div>
</Card>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14, display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<div>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Capture</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>braindump approved tickets</p>
</div>
{stage !== 'dump' ? (
<div style={{ textAlign: 'right' }}>
<div style={{ font: '400 24px/1 var(--font-serif-display)', color: secs > 120 ? 'var(--warn)' : 'var(--ink-1)' }}>{clock}</div>
<div style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginTop: 4 }}>budget 2:00</div>
</div>
) : null}
</header>
{stage === 'dump' ? (
<Card overline="Braindump" title="Tell me what you're planning" jade>
<textarea
value={dump}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDump(e.target.value)}
rows={5}
style={{
width: '100%', resize: 'vertical', font: 'var(--text-body)', color: 'var(--ink-1)',
background: 'var(--paper-0)', border: '1px solid var(--line-2)', borderRadius: 'var(--radius-2)',
padding: '10px 12px', outline: 'none', lineHeight: 1.55,
}}
></textarea>
<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.
</p>
<Button icon="sparkles" onClick={() => setStage('interview')}>Brew tickets</Button>
</Card>
) : null}
{stage === 'interview' ? (
<div style={{ display: 'grid', gridTemplateColumns: '1.3fr 1fr', gap: 14, alignItems: 'start' }}>
<Card overline={`Interview · ${qi + 1} of ${QUESTIONS.length}`} jade>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{log.map((e, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6, opacity: 0.66 }}>
<span style={{ font: 'var(--text-agent)', color: 'var(--ink-2)' }}>{e.q}</span>
<span style={{ alignSelf: 'flex-start', font: 'var(--text-small)', background: 'var(--paper-2)', borderRadius: 'var(--radius-round)', padding: '4px 11px' }}>{e.a}</span>
</div>
))}
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-1)', margin: 0 }}>{QUESTIONS[qi].q}</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{QUESTIONS[qi].chips.map((c) => (
<Button key={c} variant="secondary" size="sm" onClick={() => answer(c)}>{c}</Button>
))}
</div>
</div>
</Card>
<Tray />
</div>
) : null}
{stage === 'review' ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Consequence" title={`${tickets.length} tickets · ~${totalDays}d of work`} jade
footer={<>
<Button onClick={() => setStage('filed')}>Approve all</Button>
<Button variant="ghost" onClick={() => { setStage('dump'); setQi(0); setLog([]); setSplit(null); setWebEst(null); setSecs(0); }}>Discard</Button>
</>}>
<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.
</p>
<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?
</p>
</Card>
<Tray editable />
</div>
) : null}
{stage === 'filed' ? (
<Card jade style={{ maxWidth: 560 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-start' }}>
<span style={{ color: 'var(--ok)', display: 'inline-flex', alignItems: 'center', gap: 8, font: 'var(--text-title)' }}>
<Icon name="circle-check" size={22} /> Filed
</span>
<p style={{ font: 'var(--text-body)', margin: 0 }}>
{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 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.
</p>
<Button iconRight="arrow-right" onClick={onDone}>To morning service</Button>
</div>
</Card>
) : null}
</div>
)
}

View File

@@ -0,0 +1,130 @@
import React from 'react'
import { DEPS, type IssueRef } from '../../data/fixtures.js'
import { Tag, Icon } from '../ui/index.js'
// Dependency graph drill-in — layered DAG, critical path in spruce
export function DepsGraph({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
const g = DEPS
const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84
const X = (c: number) => PAD + c * COLW
const Y = (r: number) => PAD + r * ROWH
const maxCol = g.milestone.col
const maxRow = Math.max(...g.nodes.map((n) => n.row), g.milestone.row)
const W = PAD * 2 + maxCol * COLW + NW
const H = PAD * 2 + maxRow * ROWH + NH
const MSW = 158, MSH = 44
const pos: Record<string, { x: number; y: number; w: number; h: number }> = {}
g.nodes.forEach((n) => { pos[n.id] = { x: X(n.col), y: Y(n.row), w: NW, h: NH } })
pos['ms'] = { x: X(g.milestone.col), y: Y(g.milestone.row) + (NH - MSH) / 2, w: MSW, h: MSH }
const edgePath = (e: { from: number; to: number | 'ms'; crit?: boolean }) => {
const a = pos[e.from], b = pos[e.to]
const x1 = a.x + a.w, y1 = a.y + a.h / 2
const x2 = b.x, y2 = b.y + b.h / 2
const dx = Math.max(28, (x2 - x1) / 2)
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2 - 5} ${y2}`
}
const STATES: Record<string, { icon: string; color: string; label: string }> = {
done: { icon: 'circle-check', color: 'var(--ok)', label: 'done' },
steeping: { icon: 'clock', color: 'var(--warn)', label: 'steeping' },
review: { icon: 'git-pull-request', color: 'var(--info)', label: 'in review' },
triage: { icon: 'circle-dot', color: 'var(--ink-3)', label: 'triage' },
diagnosis: { icon: 'circle-dashed', color: 'var(--ink-3)', label: 'diagnosis' },
}
const Node = ({ n }: { n: (typeof DEPS.nodes)[number] }) => {
const st = STATES[n.state]
const crit = g.critical.includes(n.id)
return (
<div
onClick={() => onOpenIssue({ id: n.id, title: n.title, labels: n.tags, rationale: n.rationale, days: n.state === 'steeping' ? n.days : undefined })}
style={{
position: 'absolute', left: pos[n.id].x, top: pos[n.id].y, width: NW, height: NH,
background: n.state === 'done' ? 'var(--paper-2)' : 'var(--surface-card)',
border: `1px solid ${crit ? 'var(--spruce-5)' : 'var(--line-1)'}`,
boxShadow: crit ? 'var(--shadow-1), inset 2px 0 0 var(--accent)' : 'var(--shadow-1)',
borderRadius: 'var(--radius-2)', padding: '9px 11px', cursor: 'pointer',
display: 'flex', flexDirection: 'column', gap: 6,
opacity: n.state === 'done' ? 0.72 : 1,
transition: 'border-color var(--duration-fast) var(--ease-out)',
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.borderColor = crit ? 'var(--accent)' : 'var(--line-2)' }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.borderColor = crit ? 'var(--spruce-5)' : 'var(--line-1)' }}
>
<div style={{
font: '500 12px/1.3 var(--font-sans)', color: 'var(--ink-1)',
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>{n.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 'auto' }}>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>#{n.id}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, font: '400 10.5px var(--font-mono)', color: st.color, whiteSpace: 'nowrap' }}>
<Icon name={st.icon} size={11} />
{st.label}{n.state === 'steeping' && n.days ? ` ${n.days}` : ''}
</span>
{n.tags.filter((t) => t.startsWith('p/')).map((t) => (
<span key={t} style={{ font: '500 10.5px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>{t}</span>
))}
</div>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, minHeight: 0 }}>
{/* legend */}
<div style={{ display: 'flex', alignItems: 'center', gap: 18, font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{ width: 22, height: 2, background: 'var(--accent)', display: 'inline-block' }}></span>critical path
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{ width: 22, height: 0, borderTop: '1.5px solid var(--line-2)', display: 'inline-block' }}></span>blocks
</span>
<span style={{ marginLeft: 'auto', color: 'var(--ink-3)' }}>unattached: {g.unattached.map((i) => `#${i}`).join(' · ')}</span>
</div>
{/* graph canvas */}
<div style={{ overflow: 'auto', flex: 1, minHeight: 0, border: '1px solid var(--line-1)', borderRadius: 'var(--radius-3)', background: 'var(--surface-app)' }}>
<div style={{ position: 'relative', width: W, height: H }}>
<svg width={W} height={H} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
<defs>
<marker id="dg-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0.5 L 7.5 4 L 0 7.5 z" fill="var(--line-2)"></path>
</marker>
<marker id="dg-arrow-crit" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0.5 L 7.5 4 L 0 7.5 z" fill="var(--accent)"></path>
</marker>
</defs>
{g.edges.map((e, i) => (
<path key={i} d={edgePath(e)} fill="none"
stroke={e.crit ? 'var(--accent)' : 'var(--line-2)'}
strokeWidth={e.crit ? 2 : 1.5}
markerEnd={`url(#${e.crit ? 'dg-arrow-crit' : 'dg-arrow'})`} />
))}
</svg>
{g.nodes.map((n) => <Node key={n.id} n={n} />)}
{/* milestone terminal */}
<div style={{
position: 'absolute', left: pos['ms'].x, top: pos['ms'].y, width: MSW, height: MSH,
display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px',
background: 'var(--accent)', color: 'var(--ink-inverse)',
borderRadius: 'var(--radius-round)', boxShadow: 'var(--shadow-2)',
}}>
<Icon name="milestone" size={15} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ font: '600 12.5px/1.2 var(--font-sans)' }}>{g.milestone.name}</span>
<span style={{ font: '400 10.5px/1.2 var(--font-mono)', opacity: 0.8 }}>due {g.milestone.due}</span>
</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>
)
}

View File

@@ -0,0 +1,104 @@
import React from 'react'
import { DIRECTIVES, type DirectivePending, type DirectiveEntry } from '../../data/fixtures.js'
import { Card, Button, Badge, Icon } from '../ui/index.js'
// Directive log — append-only ledger + the consequence diff (propose-approve)
export function DirectivesScreen() {
const [pending, setPending] = React.useState<DirectivePending | null>(DIRECTIVES.pending)
const [entries, setEntries] = React.useState<DirectiveEntry[]>(DIRECTIVES.entries)
const resolve = (status: string) => {
setEntries((e) => [{
seq: pending!.seq, who: pending!.who, when: pending!.when, what: pending!.what, why: 'pilot demo on the 14th',
status, consequence: status === 'applied' ? '#78 +5d · Beta 80% Mar 514' : 'withdrawn before apply',
}, ...e]);
setPending(null);
};
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 }> = {
applied: { tone: 'ok', label: 'applied' },
withdrawn: { tone: 'neutral', label: 'withdrawn' },
superseded: { tone: 'info', label: 'superseded' },
};
return (
<div style={{ maxWidth: 760, margin: '0 auto', 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 }}>Directives</h1>
<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>
</header>
{pending ? (
<Card overline={`Awaiting your word · directive #00${pending.seq}`} title="Consequence diff" jade
footer={<>
<Button onClick={() => resolve('applied')}>Make it so</Button>
<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>
<div>
{entries.map((e, i) => {
const sb = statusBadge[e.status];
return (
<div 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 }}>
<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>
</div>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<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>
{e.why ? <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>· why: {e.why}</span> : null}
<span style={{ marginLeft: 'auto' }}><Badge tone={sb.tone}>{sb.label}</Badge></span>
</div>
<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>
</Card>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
Entries are never edited. Corrections are new entries the ledger remembers everything, politely.
</p>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import React from 'react'
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
import { type ForecastView, type FocusView } from '../../lib/backlog.js'
import { BurnUpCone } from '../charts/chart.js'
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
/**
* Morning service — the Now/Next/Later focus cards + the burn-up cone.
* `focus` (scheduler) and `forecast` (Monte Carlo) override the demo fixtures
* when gitea is configured; both fall back to the handoff demo otherwise.
*/
export function FocusScreen({
onOpenIssue,
focus,
forecast,
}: {
onOpenIssue: (issue: IssueRef) => void
focus?: FocusView
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 }) => (
<Card
overline={slot}
jade={jade}
title={
<a
href="#"
onClick={(e) => {
e.preventDefault()
onOpenIssue({ id: issue.id, title: issue.title, labels: issue.labels })
}}
style={{ color: 'inherit', border: 'none' }}
>
{issue.title}
</a>
}
actions={<IconButton icon="ellipsis" label="More" size="sm" />}
footer={
jade ? (
<>
<Button size="sm">Start</Button>
<Button size="sm" variant="ghost">
Defer
</Button>
<span style={{ marginLeft: 'auto', font: 'var(--text-caption)', color: 'var(--ink-3)' }}>
scheduler pick · critical path
</span>
</>
) : null
}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<span style={{ font: 'var(--text-data)', color: 'var(--ink-3)' }}>#{issue.id}</span>
{issue.labels.map((l) => (
<Tag key={l} label={l} />
))}
{issue.steeping ? (
<Badge tone="warn" dot>
steeping {issue.steeping}
</Badge>
) : null}
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>{issue.rationale}</p>
</Card>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header
style={{
borderBottom: 'var(--rule-double)',
paddingBottom: 14,
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
}}
>
<div>
<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>
<Badge tone="ok" dot>
ahead of forecast
</Badge>
</header>
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}>
{view.now ? <FocusRow slot="Now" issue={view.now} jade /> : null}
{view.next ? <FocusRow slot="Next" issue={view.next} /> : null}
{view.later ? <FocusRow slot="Later" issue={view.later} /> : null}
</div>
<Card
overline={forecast ? 'Backlog · open scope' : 'Milestone · Beta'}
title={
forecast ? (
<>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" />}
>
<BurnUpCone data={forecast?.cone} />
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
{forecast
? forecast.coldStart
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.`
: `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`
: 'The cone has narrowed since Friday. Im quietly pleased.'}
</p>
</Card>
</div>
)
}

View File

@@ -0,0 +1,105 @@
import React from 'react'
import { GANTT, type IssueRef } from '../../data/fixtures.js'
import { Icon } from '../ui/index.js'
// Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails
export function GanttView({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
const g = GANTT;
const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30;
const chartW = g.days * DAYW;
const W = LABELW + chartW;
const H = HEADH + g.rows.length * ROWH;
const X = (d: number) => LABELW + d * DAYW;
const BAR: Record<string, { bg: string; border: string; text: string }> = {
done: { bg: 'var(--paper-3)', border: 'transparent', text: 'var(--ink-3)' },
steeping: { bg: 'var(--accent)', border: 'transparent', text: 'var(--ink-inverse)' },
review: { bg: 'var(--info-tint)', border: 'var(--info)', text: 'var(--info)' },
scheduled: { bg: 'var(--spruce-2)', border: 'var(--spruce-3)', text: 'var(--accent-text)' },
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, minHeight: 0 }}>
{/* legend */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap', flexWrap: 'wrap' }}>
{[['steeping', 'in work'], ['review', 'in review'], ['scheduled', 'scheduled'], ['done', 'done']].map(([k, label]) => (
<span key={k} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 16, height: 9, borderRadius: 3, background: BAR[k].bg, border: `1px solid ${BAR[k].border}`, display: 'inline-block' }}></span>{label}
</span>
))}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 18, height: 0, borderTop: '2px dotted var(--cone-line)', display: 'inline-block' }}></span>80% tail
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 2, height: 12, background: 'var(--jade)', display: 'inline-block' }}></span>today
</span>
</div>
{/* chart */}
<div style={{ overflow: 'auto', flex: 1, minHeight: 0, border: '1px solid var(--line-1)', borderRadius: 'var(--radius-3)', background: 'var(--surface-card)' }}>
<div style={{ position: 'relative', width: W, height: H }}>
{/* week gridlines + labels */}
{g.weeks.map((w) => (
<React.Fragment key={w.at}>
<div style={{ position: 'absolute', left: X(w.at), top: HEADH, bottom: 0, width: 1, background: 'var(--line-1)' }}></div>
<span style={{ position: 'absolute', left: X(w.at) + 5, top: 8, font: '400 10.5px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>{w.label}</span>
</React.Fragment>
))}
{/* milestone 80% band */}
<div style={{ position: 'absolute', left: X(g.band.from), width: (g.band.to - g.band.from) * DAYW, top: HEADH, bottom: 0, background: 'var(--cone-fill)' }}></div>
<span style={{ position: 'absolute', left: X(g.band.from) + 5, bottom: 6, font: '500 10.5px var(--font-mono)', color: 'var(--cone-line)', whiteSpace: 'nowrap' }}>{g.band.label}</span>
{/* due marker */}
<div style={{ position: 'absolute', left: X(g.due.at) - 1, top: HEADH, bottom: 0, width: 2, background: 'var(--ink-1)' }}></div>
<span style={{ position: 'absolute', left: X(g.due.at) - 4, top: HEADH - 12, width: 8, height: 8, background: 'var(--ink-1)', transform: 'rotate(45deg)' }}></span>
{/* today rule */}
<div style={{ position: 'absolute', left: X(g.today), top: HEADH, bottom: 0, width: 2, background: 'var(--jade)' }}></div>
{/* rows */}
{g.rows.map((r, i) => {
const top = HEADH + i * ROWH;
const st = BAR[r.state];
return (
<React.Fragment key={r.id}>
{/* row hairline */}
<div style={{ position: 'absolute', left: 0, right: 0, top: top, height: 1, background: 'var(--line-1)', opacity: 0.6 }}></div>
{/* label cell (sticky) */}
<div
onClick={() => onOpenIssue({ id: r.id, title: r.title, labels: [], days: r.state === 'steeping' ? '4d' : undefined })}
style={{
position: 'absolute', left: 0, width: LABELW, height: ROWH, top: top, zIndex: 2, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px 0 14px', background: 'var(--surface-card)',
borderRight: '1px solid var(--line-1)',
boxShadow: r.crit ? 'inset 2px 0 0 var(--accent)' : 'none',
}}
>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', flexShrink: 0 }}>#{r.id}</span>
<span style={{ font: '500 12px/1.3 var(--font-sans)', color: 'var(--ink-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
<span style={{ font: '600 8.5px/16px var(--font-sans)', color: 'var(--accent-text)', background: 'var(--spruce-2)', width: 16, height: 16, borderRadius: '50%', textAlign: 'center', flexShrink: 0, marginLeft: 'auto' }}>{r.who}</span>
</div>
{/* bar */}
<div style={{
position: 'absolute', left: X(r.start), width: (r.end - r.start) * DAYW, top: top + 9, height: 18,
background: st.bg, border: `1px solid ${st.border}`, borderRadius: 4,
boxShadow: r.crit && r.state !== 'steeping' ? 'inset 0 -2px 0 var(--accent)' : 'none',
}}></div>
{/* 80% tail */}
{r.p80 ? (
<>
<div style={{ position: 'absolute', left: X(r.end), width: (r.p80 - r.end) * DAYW, top: top + 17, height: 0, borderTop: '2px dotted var(--cone-line)' }}></div>
<div style={{ position: 'absolute', left: X(r.p80) - 1, top: top + 13, width: 2, height: 10, background: 'var(--cone-line)' }}></div>
</>
) : null}
</React.Fragment>
);
})}
</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>
);
}

View File

@@ -0,0 +1,107 @@
import React from 'react'
import { INBOX, type InboxItem, type IssueRef } from '../../data/fixtures.js'
import { Button, Card, Icon, Tabs } from '../ui/index.js'
// Inbox — Reginald only rings the bell when it matters
export function InboxScreen({
onOpenIssue,
onOpenDirectives,
readIds,
setReadIds,
}: {
onOpenIssue: (issue: IssueRef) => void
onOpenDirectives: () => void
readIds: number[]
setReadIds: React.Dispatch<React.SetStateAction<number[]>>
}) {
const all = INBOX
const [tab, setTab] = React.useState('all')
const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id)
const unreadCount = all.filter((n) => !isRead(n)).length
const FILTERS: Record<string, (n: InboxItem) => boolean> = {
all: () => true,
mentions: (n) => n.type === 'mention' || n.type === 'assignment',
drift: (n) => n.type === 'drift' || n.type === 'nag' || n.type === 'milestone',
system: (n) => n.type === 'system' || n.type === 'review',
}
const items = all.filter(FILTERS[tab])
const days = [...new Set(items.map((n) => n.day))]
const toneColor: Record<string, string> = { ok: 'var(--ok)', warn: 'var(--warn)', info: 'var(--info)', neutral: 'var(--ink-3)' }
const open = (n: InboxItem) => {
setReadIds((r) => (r.includes(n.id) ? r : [...r, n.id]))
if (n.issue) onOpenIssue(n.issue)
else if (n.to === 'directives') onOpenDirectives()
}
return (
<div style={{ maxWidth: 760, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 14 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14, display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<div>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Inbox</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>
{unreadCount ? `${unreadCount} unread` : 'all read'} · nothing here rings twice
</p>
</div>
{unreadCount ? (
<Button variant="ghost" size="sm" onClick={() => setReadIds(all.map((n) => n.id))}>Mark all read</Button>
) : null}
</header>
<Tabs
items={[
{ id: 'all', label: 'All', count: all.length },
{ id: 'mentions', label: 'Mentions', icon: 'message-square' },
{ id: 'drift', label: 'Drift', icon: 'chart-line' },
{ id: 'system', label: 'System', icon: 'refresh-cw' },
]}
active={tab} onChange={setTab}
/>
<Card flush>
<div>
{days.map((day) => (
<div key={day}>
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', padding: '12px 20px 4px' }}>{day}</div>
{items.filter((n) => n.day === day).map((n) => {
const read = isRead(n)
return (
<div key={n.id}
onClick={() => open(n)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 12, padding: '11px 20px',
cursor: n.issue || n.to ? 'pointer' : 'default',
opacity: read ? 0.72 : 1,
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'var(--paper-2)' }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'transparent' }}
>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: read ? 'transparent' : 'var(--accent)', flexShrink: 0, marginTop: 7 }}></span>
<span style={{ color: toneColor[n.tone], display: 'inline-flex', marginTop: 1, flexShrink: 0 }}>
<Icon name={n.icon} size={15} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: read ? 'var(--text-body)' : 'var(--text-body-strong)', color: 'var(--ink-1)' }}>
{n.who ? <span style={{ fontWeight: 600 }}>{n.who} </span> : null}{n.text}
</div>
<div style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', marginTop: 2 }}>{n.detail}</div>
</div>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap', marginTop: 2 }}>{n.time}</span>
</div>
)
})}
</div>
))}
</div>
</Card>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
I only ring the bell when it matters. The rest can wait for morning service.
</p>
</div>
)
}

View File

@@ -0,0 +1,297 @@
// Issue detail — human intent (gitea) on the left, machine-derived (pm-state) on the right
import React, { useState } from 'react'
import {
describeChange,
type EstimateLabel,
ESTIMATE_LABELS,
type IssueChange,
planIssueChange,
type PriorityLabel,
PRIORITY_LABELS,
} from '@commitea/core'
import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js'
import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js'
const NONE = '—'
export function IssueScreen({
issue,
onBack,
onOpenIssue,
canWrite = false,
onApplyChange,
}: {
issue: IssueRef
onBack: () => void
onOpenIssue: (issue: IssueRef) => void
canWrite?: boolean
onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>
}) {
const det: IssueDetail = ISSUE_DETAIL[issue.id] || {
state: 'triage',
assignee: 'Stephen',
milestone: 'Beta',
body: '',
comments: [],
lifecycle: [
{ stage: 'Diagnosis', event: 'issue opened', when: 'Feb 4 · 10:20', icon: 'circle-dot', done: true },
{ stage: 'Triage', event: 'labeled · milestoned Beta', when: 'Feb 5 · 09:12', icon: 'tag', done: true },
{ stage: 'Work start', event: 'first branch or commit ref', when: 'pending', icon: 'git-commit-horizontal', done: false },
{ 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: 'starts wk of Feb 16', note: 'queue position from scheduler' },
blocks: [],
blockedBy: [],
note: '',
}
const stateBadge = (
{
steeping: { tone: 'warn', label: `steeping${issue.days ? ' ' + issue.days : ''}` },
triage: { tone: 'neutral', label: 'triage' },
review: { tone: 'info', label: 'in review' },
done: { tone: 'ok', label: 'done' },
} as Record<string, { tone: 'warn' | 'neutral' | 'info' | 'ok'; label: string }>
)[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 [adjustOpen, setAdjustOpen] = useState(false)
const [estimate, setEstimate] = useState(curEstimate)
const [priority, setPriority] = useState(curPriority)
const [applying, setApplying] = useState(false)
const openAdjust = () => {
setEstimate(curEstimate)
setPriority(curPriority)
setAdjustOpen(true)
}
// the concrete changes this dialog would apply, one per axis 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 })
const diffs = pendingChanges.map((c) => describeChange(planIssueChange(labels, c)))
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 }))]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* breadcrumb + header */}
<div>
<button type="button" onClick={onBack} style={{
display: 'inline-flex', alignItems: 'center', gap: 6, background: 'none', border: 'none',
font: '500 12.5px var(--font-sans)', color: 'var(--ink-2)', cursor: 'pointer', padding: '2px 0', marginBottom: 10,
}}>
<Icon name="arrow-left" size={14} /> Back
</button>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14, display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ font: '400 13px var(--font-mono)', color: 'var(--ink-3)', margin: '0 0 6px' }}>#{issue.id} · stephen/commitea</p>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>{issue.title}</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<Badge tone={stateBadge.tone} dot>{stateBadge.label}</Badge>
{(issue.labels || []).map((l) => <Tag key={l} label={l} />)}
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Icon name="milestone" size={12} />{det.milestone}
</span>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Icon name="user" size={12} />{det.assignee}
</span>
</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>
</div>
</header>
</div>
<Dialog
open={adjustOpen}
onClose={() => setAdjustOpen(false)}
title="Adjust estimate & priority"
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>
<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 or priority.
</p>
) : (
<>
<p style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', margin: '0 0 6px' }}>
Proposed label 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 the label to gitea and re-runs the plan. Nothing else changes.
</p>
</>
)}
</div>
</Dialog>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16, alignItems: 'start' }}>
{/* left: human intent */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card overline="Description">
{det.body ? (
<p style={{ font: 'var(--text-body)', color: 'var(--ink-1)', margin: 0, lineHeight: 1.6 }}>{det.body}</p>
) : (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}>
No description was written. I have opinions about that, but I'll keep them warm.
</p>
)}
</Card>
<Card overline={`Comments · ${det.comments.length}`} flush>
<div>
{det.comments.map((c, i) => (
<div key={i} style={{ display: 'flex', gap: 12, padding: '14px 20px', borderBottom: '1px solid var(--line-1)' }}>
<span style={{
width: 24, height: 24, borderRadius: '50%', background: 'var(--spruce-2)', color: 'var(--accent-text)',
font: '600 9px/24px var(--font-sans)', textAlign: 'center', flexShrink: 0,
}}>{c.who.split(' ').map((w) => w[0]).join('')}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.who}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>{c.when}</span>
</div>
<p style={{ font: 'var(--text-body)', color: 'var(--ink-1)', margin: '4px 0 0' }}>{c.text}</p>
</div>
</div>
))}
<div style={{ display: 'flex', gap: 8, padding: '14px 20px', alignItems: 'flex-end' }}>
<textarea placeholder="Comment — this writes to gitea, as you" rows={2} style={{
flex: 1, resize: 'none', font: 'var(--text-body)', color: 'var(--ink-1)', lineHeight: 1.5,
background: 'var(--paper-0)', border: '1px solid var(--line-2)', borderRadius: 'var(--radius-2)',
padding: '8px 11px', outline: 'none',
}}></textarea>
<Button size="sm" variant="secondary">Comment</Button>
</div>
</div>
</Card>
{det.note ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0, padding: '0 2px' }}>{det.note}</p>
) : null}
</div>
{/* right: machine-derived sidecar */}
<Card overline="Machine-derived" flush>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{/* lifecycle */}
<div style={{ padding: '14px 20px', display: 'flex', flexDirection: 'column', gap: 0 }}>
{det.lifecycle.map((s, i) => (
<div key={s.stage} style={{ display: 'flex', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<span style={{ display: 'inline-flex', color: s.done ? 'var(--ok)' : 'var(--ink-3)', padding: '2px 0' }}>
<Icon name={s.icon} size={14} />
</span>
{i < det.lifecycle.length - 1 ? <span style={{ width: 1, flex: 1, minHeight: 14, background: s.done ? 'var(--spruce-3)' : 'var(--line-1)' }}></span> : null}
</div>
<div style={{ paddingBottom: i < det.lifecycle.length - 1 ? 12 : 0 }}>
<div style={{ font: `500 12px var(--font-sans)`, color: s.done ? 'var(--ink-1)' : 'var(--ink-3)' }}>{s.stage}</div>
<div style={{ font: '400 10.5px var(--font-mono)', color: 'var(--ink-3)', marginTop: 2, whiteSpace: 'nowrap' }}>{s.event}</div>
<div style={{ font: '400 10.5px var(--font-mono)', color: s.done ? 'var(--ink-2)' : 'var(--ink-3)', whiteSpace: 'nowrap' }}>{s.when}</div>
</div>
</div>
))}
</div>
{/* forecast */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--line-1)' }}>
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 6 }}>Forecast</div>
<div style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>80% {det.forecast.p80}</div>
<div style={{ font: '400 10.5px var(--font-mono)', color: 'var(--ink-3)', marginTop: 3 }}>{det.forecast.note}</div>
</div>
{/* dependencies */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--line-1)' }}>
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 6 }}>Dependencies</div>
{det.blocks.length === 0 && det.blockedBy.length === 0 ? (
<div style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>none</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{det.blocks.length ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>blocks</span>
{det.blocks.map((b) => (
<button key={b} type="button"
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' }}>
#{b}
</button>
))}
</div>
) : null}
{det.blockedBy.length ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>blocked by</span>
{det.blockedBy.map((b) => <span key={b} style={{ font: '500 11px var(--font-mono)', color: 'var(--ink-2)' }}>#{b}</span>)}
</div>
) : null}
</div>
)}
</div>
{/* provenance note */}
<div style={{ padding: '10px 20px 14px', borderTop: '1px solid var(--line-1)' }}>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
Lives in pm-state. Your repo never sees any of it.
</p>
</div>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import React from 'react'
import { COLUMNS, type IssueRef } from '../../data/fixtures.js'
import { BurnUpCone } from '../charts/chart.js'
import { Badge, Button, Card, Icon, Tag } from '../ui/index.js'
// Milestone detail — scope, cone, issues; forecasts stay ranges
export function MilestoneScreen({ onBack, onOpenIssue }: { onBack: () => void; onOpenIssue: (issue: IssueRef) => void }) {
const cols = COLUMNS
const byState = (ids: number[]) =>
cols.flatMap((c) => c.issues.map((i) => ({ ...i, col: c.label }))).filter((i) => ids.includes(i.id))
const groups = [
{ label: 'Steeping', issues: byState([87, 84]) },
{ label: 'In review', issues: byState([92]) },
{ label: 'Queued', issues: byState([102, 103, 99, 96, 78]) },
{ label: 'Done', issues: byState([71, 69, 65]), muted: true },
]
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={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 5 }}>{label}</div>
<div style={{ font: `500 14px var(--font-mono)`, color: tone || 'var(--ink-1)', whiteSpace: 'nowrap' }}>{value}</div>
</div>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<button type="button" onClick={onBack} style={{
display: 'inline-flex', alignItems: 'center', gap: 6, background: 'none', border: 'none',
font: '500 12.5px var(--font-sans)', color: 'var(--ink-2)', cursor: 'pointer', padding: '2px 0', marginBottom: 10,
}}>
<Icon name="arrow-left" size={14} /> Runway
</button>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14, display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<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 }}>
<Icon name="milestone" size={13} /> milestone · due Mar 15 · soft scope may flex
</p>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Beta</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
<Badge tone="ok" dot>ahead of forecast</Badge>
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>80% Mar 312</span>
</div>
</div>
<Button variant="secondary" icon="arrow-up-right">Open in Gitea</Button>
</header>
</div>
{/* stats strip */}
<Card flush>
<div style={{ display: 'flex' }}>
<Stat label="Scope" value="42 issues · est 61d" />
<Stat label="Done" value="24 · 57%" />
<Stat label="Forecast" value="80% Mar 312" />
<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 }}>Drift · 7d</div>
<div style={{ font: '500 14px var(--font-mono)', color: 'var(--ok)', whiteSpace: 'nowrap' }}>2d · cone narrowed</div>
</div>
</div>
</Card>
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Burn-up" title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 312</span></>} jade>
<BurnUpCone />
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
Comfortably ahead. Beta needs #87 more than it needs my commentary.
</p>
</Card>
<Card overline="Issues" flush>
<div>
{groups.map((g) => (
<div key={g.label}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '10px 20px 6px' }}>
<span style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)' }}>{g.label}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{g.issues.length}</span>
</div>
{g.issues.map((i) => (
<div key={i.id}
onClick={() => onOpenIssue({ id: i.id, title: i.title, labels: i.labels, days: i.days })}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '7px 20px', cursor: 'pointer',
opacity: g.muted ? 0.6 : 1,
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'var(--paper-2)'; }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'transparent'; }}
>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', width: 34, flexShrink: 0 }}>#{i.id}</span>
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{i.title}</span>
{(i.labels || []).filter((l) => l.startsWith('est/')).map((l) => <Tag key={l} label={l} />)}
</div>
))}
</div>
))}
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
import React from 'react'
import logoIcon from '../../design/assets/logo-icon.png'
import { Badge, Button, Icon, Input, Radio, Tag } from '../ui/index.js'
// Onboarding / first connect — welcome → connect gitea → choose repo → bootstrap
export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture') => void }) {
const [step, setStep] = React.useState<number>(0)
const [conn, setConn] = React.useState<'idle' | 'testing' | 'ok'>('idle')
const [repo, setRepo] = React.useState('stephen/commitea')
const [boot, setBoot] = React.useState<number>(-1) // -1 idle, 0..2 running, 3 done
React.useEffect(() => {
if (conn !== 'testing') return
const t = setTimeout(() => setConn('ok'), 1100)
return () => clearTimeout(t)
}, [conn])
React.useEffect(() => {
if (boot < 0 || boot >= 3) return
const t = setTimeout(() => setBoot(boot + 1), 700)
return () => clearTimeout(t)
}, [boot])
const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap']
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 }) => (
<div style={{
background: 'var(--surface-card)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-3)',
boxShadow: 'var(--shadow-jade-line), var(--shadow-2)', padding: '30px 36px',
display: 'flex', flexDirection: 'column', gap: 16, width: '100%',
}}>
{children}
{footer ? <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid var(--line-1)', paddingTop: 16 }}>{footer}</div> : null}
</div>
)
return (
<div style={{
minHeight: '100vh', background: 'var(--surface-app)', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', padding: 32, gap: 22,
}} data-screen-label="onboarding">
{/* brand */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<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)' }}>
Commi<span style={{ color: 'var(--accent-text)' }}>Tea</span>
</span>
</div>
{/* stepper */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{STEPS.map((s, i) => (
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{
width: 20, height: 20, borderRadius: '50%', textAlign: 'center',
font: '500 10.5px/20px var(--font-mono)',
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)',
}}>{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>
</span>
{i < STEPS.length - 1 ? <span style={{ width: 24, height: 1, background: 'var(--line-2)' }}></span> : null}
</div>
))}
</div>
<div style={{ width: 'min(540px, 100%)' }}>
{step === 0 ? (
<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>
<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 honest ranges, and I never do the arithmetic myself — there's a scheduler for that.
</p>
<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.
</p>
</Frame>
) : null}
{step === 1 ? (
<Frame footer={<>
<Button variant="ghost" onClick={() => setStep(0)}>Back</Button>
<Button iconRight="arrow-right" disabled={conn !== 'ok'} onClick={() => setStep(2)}>Continue</Button>
</>}>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Your Gitea</h1>
<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', alignItems: 'center', gap: 12 }}>
<Button variant="secondary" icon={conn === 'testing' ? 'loader-circle' : 'zap'} disabled={conn === 'testing'} onClick={() => setConn('testing')}>
{conn === 'testing' ? 'Ringing the bell…' : 'Test connection'}
</Button>
{conn === 'ok' ? <Badge tone="ok" dot>connected · 3 repos visible</Badge> : null}
</div>
</Frame>
) : null}
{step === 2 ? (
<Frame footer={<>
<Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
<Button iconRight="arrow-right" onClick={() => setStep(3)}>Continue</Button>
</>}>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Which repo shall I manage?</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{['stephen/commitea', 'stephen/novelpad', 'stephen/infra'].map((r: string) => (
<label key={r} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', cursor: 'pointer',
background: repo === r ? 'var(--spruce-1)' : 'var(--paper-0)',
border: `1px solid ${repo === r ? 'var(--spruce-3)' : 'var(--line-1)'}`,
borderRadius: 'var(--radius-2)',
}}>
<Radio name="repo" checked={repo === r} onChange={() => setRepo(r)} />
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{r}</span>
</label>
))}
</div>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>One to start. You can add more later in Settings.</p>
</Frame>
) : null}
{step === 3 ? (
<Frame footer={boot === 3 ? <>
<Button variant="ghost" onClick={() => onDone('focus')}>Just look around</Button>
<Button icon="sparkles" onClick={() => onDone('capture')}>Start a capture</Button>
</> : <>
<Button variant="ghost" onClick={() => setStep(2)} disabled={boot >= 0}>Back</Button>
<Button disabled={boot >= 0} onClick={() => setBoot(0)}>Make it so</Button>
</>}>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>
{boot === 3 ? 'All set.' : 'With your approval'}
</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{BOOT_TASKS.map((t: string, i: number) => (
<div key={t} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ display: 'inline-flex', color: boot > i ? 'var(--ok)' : boot === i ? 'var(--warn)' : 'var(--ink-3)' }}>
<Icon name={boot > i ? 'circle-check' : boot === i ? 'loader-circle' : 'circle-dashed'} size={15} />
</span>
<span style={{ font: 'var(--text-body)', color: boot > i ? 'var(--ink-1)' : 'var(--ink-2)', whiteSpace: 'nowrap' }}>{t}</span>
</div>
))}
<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>
{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 }}>
No bot comments, no body frontmatter, no synthetic issues ever. Labels are the only footprint.
</p>
)}
</Frame>
) : null}
</div>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>first run · everything reversible</span>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { RunwayBar } from '../charts/chart.js'
import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js'
import { RUNWAY, CAPACITY } from '../../data/fixtures.js'
// Runway — capacity vs milestone dates; ranges, never points
export function RunwayScreen({
onOpenCalibration,
onOpenMilestone,
calibration,
}: {
onOpenCalibration: () => void
onOpenMilestone: () => void
calibration?: { n: number; coldStart: boolean }
}) {
const calibNote = calibration
? calibration.coldStart
? `cold-start priors · ${calibration.n}/20 closed issues estimated`
: `calibrated on ${calibration.n} closed ${calibration.n === 1 ? 'issue' : 'issues'}`
: 'calibrated on 27 closed issues'
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 }}>Runway</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates · {calibNote}</p>
</header>
<Card overline="Milestones" flush>
<div>
{RUNWAY.map((m, i) => (
<div key={m.name} onClick={onOpenMilestone} style={{
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)',
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'var(--paper-2)'; }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'transparent'; }}>
<div>
<div style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)', display: 'flex', alignItems: 'center', gap: 7 }}>
<Icon name="milestone" size={14} style={{ color: 'var(--ink-3)' }} />{m.name}
</div>
<div style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', marginTop: 3 }}>
due {m.due}{m.hard ? ' ' : ''}
</div>
{m.hard ? <Tag label="deadline/hard" style={{ marginTop: 5 }} /> : null}
</div>
<RunwayBar m={m} />
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)' }}>80% {m.p80}</span>
<Badge tone={m.tone} dot>{m.note}</Badge>
</div>
))}
</div>
</Card>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Capacity" flush>
<div>
{CAPACITY.map((p, i) => (
<div key={p.who} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 20px',
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}}>
<span style={{
width: 26, height: 26, borderRadius: '50%', background: 'var(--spruce-2)', color: 'var(--accent-text)',
font: '600 10px/26px var(--font-sans)', textAlign: 'center', flexShrink: 0,
}}>{p.who.split(' ').map((w: string) => w[0]).join('')}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: 'var(--text-body-strong)' }}>{p.who}</div>
<div style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', marginTop: 2 }}>{p.slices}</div>
</div>
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)' }}>{p.hours}</span>
</div>
))}
</div>
</Card>
<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>
);
}

View File

@@ -0,0 +1,137 @@
import React from 'react'
import { Badge, Button, Card, Icon, IconButton, Input, Radio, Select, Switch, Tag } from '../ui/index.js'
// Settings — gitea connection, sync, model roles, labels, rituals, appearance
export function SettingsScreen({ dark, setDark }: { dark: boolean; setDark: (v: boolean) => void }) {
const [webhooks, setWebhooks] = React.useState(true)
const [reconcile, setReconcile] = React.useState(true)
const [poll, setPoll] = React.useState(true)
const [nag, setNag] = React.useState(true)
const Row = ({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, ...style }}>{children}</div>
)
const Note = ({ children }: { children: React.ReactNode }) => (
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>{children}</p>
)
return (
<div style={{ maxWidth: 720, margin: '0 auto', 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 }}>Settings</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>config lives in pm-state · versioned, portable</p>
</header>
<Card overline="Gitea" title="Connection">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<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)' }}>
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)', flex: 1 }}>stephen/commitea</span>
<Badge tone="ok" dot>syncing</Badge>
<IconButton icon="x" label="Stop managing" size="sm" />
</Row>
<Row style={{ padding: '8px 12px', background: 'var(--paper-0)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-2)' }}>
<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>
<Note>The sidecar holds machine-derived state only. Delete it and resync no truth is lost.</Note>
<Button variant="secondary" size="sm" icon="plus" style={{ alignSelf: 'flex-start' }}>Add repo</Button>
</div>
</div>
</Card>
<Card overline="Sync" title="Staying current">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Row>
<Switch label="Webhooks while running" checked={webhooks} onChange={(e) => setWebhooks(e.target.checked)} />
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>endpoint :48731 · healthy</span>
</Row>
<Switch label="Full reconcile on launch" checked={reconcile} onChange={(e) => setReconcile(e.target.checked)} />
<Row>
<Switch label="Poll fallback" checked={poll} onChange={(e) => setPoll(e.target.checked)} />
<div style={{ marginLeft: 'auto', width: 140 }}>
<Select options={[{ value: '2', label: 'every 2 min' }, { value: '5', label: 'every 5 min' }, { value: '15', label: 'every 15 min' }]} defaultValue="5" />
</div>
</Row>
<Note>last reconcile 3.2s · 500 issues · nothing lost</Note>
</div>
</Card>
<Card overline="Models" title="The router">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 14px' }}>
<Select label="Prose & rituals" options={[
{ value: 'gemma-4b', label: 'gemma-4b · local' },
{ value: 'qwen-7b', label: 'qwen-7b · local' },
]} defaultValue="gemma-4b" />
<Input label="Base URL" mono defaultValue="http://localhost:1234/v1" />
<Select label="Decomposition & negotiation" options={[
{ value: 'qwen-72b', label: 'qwen-72b · lm-studio box' },
{ value: 'gpt-4o', label: 'gpt-4o · OpenAI API' },
]} defaultValue="qwen-72b" />
<Input label="Base URL" mono defaultValue="http://10.0.0.42:1234/v1" />
</div>
<Row>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>hot memory 2k tokens · math is never delegated to either</span>
</Row>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
The small one writes my standup; the large one argues with your estimates. Neither is allowed near the arithmetic.
</p>
</div>
</Card>
<Card overline="Labels" title="Schema">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Row style={{ flexWrap: 'wrap', gap: 6 }}>
{['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((l) => <Tag key={l} label={l} />)}
</Row>
<Row style={{ flexWrap: 'wrap', gap: 6 }}>
{['p/1', 'p/2', 'p/3', 'p/4'].map((l) => <Tag key={l} label={l} />)}
<Tag label="deadline/hard" />
</Row>
<Note>Fixed sets, human-meaningful, visible in gitea. Not configurable that is rather the point.</Note>
</div>
</Card>
<Card overline="Rituals" title="Reginald's calendar">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Row>
<span style={{ font: 'var(--text-body)', color: 'var(--ink-1)' }}>Morning standup</span>
<div style={{ marginLeft: 'auto', width: 120 }}>
<Select options={[{ value: '0630', label: '06:30' }, { value: '0700', label: '07:00' }, { value: '0800', label: '08:00' }]} defaultValue="0700" />
</div>
</Row>
<Row>
<Switch label="Stale-blocker nagging" checked={nag} onChange={(e) => setNag(e.target.checked)} />
<div style={{ marginLeft: 'auto', width: 140 }}>
<Select options={[{ value: '2', label: 'after 2 days' }, { value: '3', label: 'after 3 days' }, { value: '5', label: 'after 5 days' }]} defaultValue="3" />
</div>
</Row>
</div>
</Card>
<Card overline="Appearance" title="Service">
<div style={{ display: 'flex', gap: 20 }}>
<Radio name="theme" label="Morning (light)" checked={!dark} onChange={() => setDark(false)} />
<Radio name="theme" label="Evening (dark)" checked={dark} onChange={() => setDark(true)} />
</div>
</Card>
<Card>
<Row>
<div style={{ flex: 1 }}>
<div style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Forget this gitea</div>
<Note>Removes the connection and the local cache. Gitea itself is untouched.</Note>
</div>
<Button variant="danger">Forget</Button>
</Row>
</Card>
</div>
)
}

View File

@@ -0,0 +1,212 @@
import React from 'react'
import { type IssueRef, STANDUP } from '../../data/fixtures.js'
import { Badge, Button, Icon } from '../ui/index.js'
const standupCSS = `
@keyframes ct-standup-settle {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.ct-standup-section { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
.ct-standup-section { animation: ct-standup-settle 320ms var(--ease-out) both; }
}
`
;(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-standup-css')) {
const s = document.createElement('style')
s.id = 'ct-standup-css'
s.textContent = standupCSS
document.head.appendChild(s)
}
})()
const toneColor: Record<string, string> = {
ok: 'var(--ok)',
warn: 'var(--warn)',
danger: 'var(--danger)',
}
/**
* Morning standup — a typeset letter from Reginald: overnight drift, today's
* plan, stale-blocker nag. Sections settle in once, reduced-motion-safe. Data
* is fixture (data.js) until the scheduler + lifecycle inference feed it.
*/
export function StandupScreen({
onBegin,
onOpenIssue,
}: {
onBegin: () => void
onOpenIssue: (issue: IssueRef) => void
}) {
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 }) => (
<section
className="ct-standup-section"
style={{ animationDelay: `${order * 90}ms`, display: 'flex', flexDirection: 'column', gap: 12 }}
>
<p
style={{
font: 'var(--text-overline)',
letterSpacing: 'var(--letter-spacing-wide)',
textTransform: 'uppercase',
color: 'var(--ink-3)',
margin: 0,
borderTop: '1px solid var(--line-1)',
paddingTop: 14,
}}
>
{overline}
</p>
{children}
</section>
)
return (
<div style={{ maxWidth: 660, margin: '0 auto' }}>
<article
style={{
background: 'var(--surface-card)',
border: '1px solid var(--line-1)',
borderRadius: 'var(--radius-3)',
boxShadow: 'var(--shadow-jade-line), var(--shadow-1)',
padding: '36px 44px 32px',
display: 'flex',
flexDirection: 'column',
gap: 20,
}}
>
<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} · prepared 07:00</p>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning standup</h1>
</header>
<Section overline="Overnight drift" order={1}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{s.drift.map((d) => (
<div key={d.text} style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<span
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: toneColor[d.tone],
flexShrink: 0,
position: 'relative',
top: -1,
}}
/>
<span style={{ font: 'var(--text-body)', color: 'var(--ink-1)', flex: 1 }}>{d.text}</span>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>
{d.delta}
</span>
</div>
))}
</div>
</Section>
<Section overline="Today's plan" order={2}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{s.plan.map((p) => (
<div key={p.who} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<span
style={{
width: 26,
height: 26,
borderRadius: '50%',
background: 'var(--spruce-2)',
color: 'var(--accent-text)',
font: '600 10px/26px var(--font-sans)',
textAlign: 'center',
flexShrink: 0,
}}
>
{p.who
.split(' ')
.map((w) => w[0])
.join('')}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>
{p.who}
</span>
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)' }}>{p.pick}</span>
<span style={{ font: 'var(--text-small)', color: 'var(--ink-2)' }}>{p.title}</span>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '4px 0 0' }}>{p.why}</p>
</div>
</div>
))}
</div>
</Section>
<Section overline="Stale blockers" order={3}>
<div
role="button"
tabIndex={0}
onClick={() => onOpenIssue(NAG_REF)}
onKeyDown={(e) => {
if (e.key === 'Enter') onOpenIssue(NAG_REF)
}}
style={{
display: 'flex',
gap: 10,
alignItems: 'flex-start',
cursor: 'pointer',
background: 'var(--warn-tint)',
borderRadius: 'var(--radius-2)',
padding: '12px 14px',
}}
>
<span style={{ color: 'var(--warn)', display: 'inline-flex', marginTop: 2 }}>
<Icon name="clock" size={15} />
</span>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)' }}>#{s.nag.id}</span>
<Badge tone="warn" dot>
steeping {s.nag.days}
</Badge>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>
blocks {s.nag.blocks.join(', ')}
</span>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '5px 0 0' }}>{s.nag.text}</p>
</div>
</div>
</Section>
<footer
className="ct-standup-section"
style={{
animationDelay: '360ms',
borderTop: '1px solid var(--line-1)',
paddingTop: 16,
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-1)', margin: 0, flex: 1 }}>
The kettles on. R.
</p>
<Button variant="ghost" onClick={onBegin}>
Ask about the drift
</Button>
<Button iconRight="arrow-right" onClick={onBegin}>
Begin the day
</Button>
</footer>
</article>
</div>
)
}

View File

@@ -0,0 +1,343 @@
import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png'
import type { IssueChange } from '@commitea/core'
import type { IssueRef } from '../../data/fixtures.js'
import { backlogCalibration, forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
import { useBacklog } from '../../lib/use-backlog.js'
import { PrimitivesGallery } from '../gallery.js'
import { BoardScreen } from '../screens/board-screen.js'
import { CalibrationScreen } from '../screens/calibration-screen.js'
import { CaptureScreen } from '../screens/capture-screen.js'
import { DirectivesScreen } from '../screens/directives-screen.js'
import { FocusScreen } from '../screens/focus-screen.js'
import { InboxScreen } from '../screens/inbox-screen.js'
import { IssueScreen } from '../screens/issue-screen.js'
import { MilestoneScreen } from '../screens/milestone-screen.js'
import { OnboardingScreen } from '../screens/onboarding-screen.js'
import { RunwayScreen } from '../screens/runway-screen.js'
import { SettingsScreen } from '../screens/settings-screen.js'
import { StandupScreen } from '../screens/standup-screen.js'
import { Icon, Switch } from '../ui/index.js'
import { ChatPanel } from './chat-panel.js'
import { PlaceholderScreen } from './placeholder-screen.js'
import { OfflineBanner, StatesScreen } from './states.js'
type View =
| 'standup'
| 'focus'
| 'inbox'
| 'capture'
| 'board'
| 'runway'
| 'directives'
| 'settings'
| 'states'
| 'firstrun'
| 'primitives'
| 'issue'
| 'calibration'
| 'milestone'
interface NavEntry {
id: View
label: string
icon: string
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() {
const [view, setView] = useState<View>('focus')
const [prevView, setPrevView] = useState<View>('focus')
const [dark, setDark] = useState(false)
const [offline, setOffline] = useState(false)
const [issue, setIssue] = useState<IssueRef | null>(null)
const [readIds, setReadIds] = useState<number[]>([])
const [backlog, refetchBacklog] = useBacklog()
const boardColumns =
backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
const focus =
backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps, backlog.timelines) : undefined
const calibration =
backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined
const forecast =
backlog.status === 'ready'
? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model) ?? undefined)
: undefined
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
}, [dark])
const openIssue = (ref: IssueRef) => {
if (view !== 'issue') setPrevView(view)
setIssue(ref)
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[] = [
{ id: 'standup', label: 'Standup', icon: 'sun' },
{ id: 'focus', label: 'Morning service', icon: 'coffee' },
{ id: 'inbox', label: 'Inbox', icon: 'bell', count: INBOX_UNREAD || null },
{ id: 'capture', label: 'Capture', icon: 'plus' },
{ id: 'board', label: 'The pot', icon: 'square-kanban' },
{ id: 'runway', label: 'Runway', icon: 'chart-line' },
{ id: 'directives', label: 'Directives', icon: 'flag' },
]
const NavItem = ({ item }: { item: NavEntry }) => {
const active =
view === item.id ||
(view === 'issue' && prevView === item.id) ||
((view === 'calibration' || view === 'milestone') && item.id === 'runway')
return (
<button
type="button"
onClick={() => setView(item.id)}
aria-current={active ? 'page' : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
font: `${active ? 600 : 500} 13.5px/1 var(--font-sans)`,
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
background: active ? 'var(--paper-2)' : 'transparent',
border: 'none',
borderRadius: 'var(--radius-2)',
padding: '9px 12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background var(--duration-fast) var(--ease-out)',
}}
>
<Icon name={item.icon} size={16} style={{ color: active ? 'var(--accent-text)' : 'var(--ink-3)' }} />
{item.label}
{item.count ? (
<span
style={{
marginLeft: 'auto',
font: '500 10.5px/16px var(--font-mono)',
color: 'var(--accent-text)',
background: 'var(--spruce-2)',
borderRadius: 'var(--radius-round)',
padding: '0 6px',
}}
>
{item.count}
</span>
) : null}
</button>
)
}
const renderScreen = () => {
switch (view) {
case 'focus':
return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} />
case 'standup':
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
case 'board':
return (
<BoardScreen
onOpenIssue={openIssue}
columns={boardColumns}
loading={backlog.status === 'loading'}
/>
)
case 'runway':
return (
<RunwayScreen
onOpenCalibration={() => setView('calibration')}
onOpenMilestone={() => setView('milestone')}
calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined}
/>
)
case 'calibration':
return <CalibrationScreen onBack={() => setView('runway')} data={calibration?.data} />
case 'milestone':
return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} />
case 'inbox':
return (
<InboxScreen
onOpenIssue={openIssue}
onOpenDirectives={() => setView('directives')}
readIds={readIds}
setReadIds={setReadIds}
/>
)
case 'capture':
return <CaptureScreen onDone={() => setView('focus')} />
case 'directives':
return <DirectivesScreen />
case 'settings':
return <SettingsScreen dark={dark} setDark={setDark} />
case 'issue':
return issue ? (
<IssueScreen
issue={issue}
onBack={() => setView(prevView)}
onOpenIssue={openIssue}
canWrite={backlog.status === 'ready'}
onApplyChange={applyChange}
/>
) : null
case 'states':
return <StatesScreen onCapture={() => setView('capture')} />
case 'primitives':
return <PrimitivesGallery />
default:
return <PlaceholderScreen title={TITLE[view] ?? view} phase={PHASE[view] ?? 'a later phase'} />
}
}
// First run is full-window — no rail, no chat panel
if (view === 'firstrun') {
return <OnboardingScreen onDone={(dest) => setView(dest)} />
}
return (
<div
data-screen-label={`app-${view}`}
style={{
display: 'flex',
height: '100vh',
minWidth: 1280,
background: 'var(--surface-app)',
overflow: 'hidden',
}}
>
{/* left rail */}
<nav
aria-label="Primary"
style={{
width: 208,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: '18px 12px 14px',
borderRight: '1px solid var(--line-1)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '0 12px 16px' }}>
<img src={logoIcon} width="24" height="24" alt="" style={{ borderRadius: 6, display: 'block' }} />
<span style={{ font: '400 21px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
Commi<span style={{ color: 'var(--accent-text)' }}>Tea</span>
</span>
</div>
{NAV.map((n) => (
<NavItem key={n.id} item={n} />
))}
<div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} />
<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 }}>
<button
type="button"
onClick={() => setOffline((o) => !o)}
title="Toggle the connection (demo)"
aria-label={offline ? 'Connection: offline' : 'Connection: online'}
style={{
font: '400 11px var(--font-mono)',
color: 'var(--ink-3)',
display: 'flex',
alignItems: 'center',
gap: 6,
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
textAlign: 'left',
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: offline ? 'var(--danger)' : 'var(--ok)',
display: 'inline-block',
}}
/>
gitea.stephenmann.io
</button>
<Switch
label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>}
checked={dark}
onChange={(e) => setDark(e.target.checked)}
/>
</div>
</nav>
{/* main */}
<main style={{ flex: 1, minWidth: 0, overflowY: 'auto', padding: '24px 28px' }}>
<div
style={{
maxWidth: 1120,
margin: '0 auto',
height: view === 'board' ? '100%' : 'auto',
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
{offline ? <OfflineBanner /> : null}
{renderScreen()}
</div>
</main>
<ChatPanel onOpenDirectives={() => setView('directives')} offline={offline} />
</div>
)
}

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useRef, useState } from 'react'
import { useChat } from '../../lib/use-chat.js'
import { Icon, IconButton } from '../ui/index.js'
/**
* Reginald's panel — chat is the write-path (decisions.md D1). Wired to the
* model bridge via `useChat`: when a model is configured, sending drives a real
* agent turn (query_project + prose); otherwise it echoes the scripted fixture
* reply so the layout stays real. Writes still go through propose-approve.
*/
export interface ChatPanelProps {
onOpenDirectives?: () => void
offline?: boolean
}
export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
const { msgs, thinking, live, model, steps, send: sendChat } = useChat()
// 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 scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, [msgs, thinking])
const send = () => {
const t = text.trim()
if (!t) return
setText('')
sendChat(t)
}
return (
<aside
style={{
width: 330,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
background: 'var(--surface-card)',
borderLeft: '1px solid var(--line-1)',
minHeight: 0,
}}
>
<header
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '14px 16px',
borderBottom: '1px solid var(--line-1)',
flexShrink: 0,
}}
>
<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: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
{offline ? 'offline · queueing' : live ? `${modelLabel} · local` : 'demo · scripted'}
</span>
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
</header>
<div
ref={scrollRef}
style={{ flex: 1, overflowY: 'auto', padding: 16, display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0 }}
>
{msgs.map((m, i) =>
m.from === 'agent' ? (
<div key={i} style={{ font: 'var(--text-agent)', color: 'var(--ink-1)', lineHeight: 1.55 }}>
{m.text}
</div>
) : (
<div
key={i}
style={{
alignSelf: 'flex-end',
maxWidth: '85%',
background: 'var(--paper-2)',
borderRadius: '10px 10px 2px 10px',
padding: '8px 12px',
font: 'var(--text-small)',
color: 'var(--ink-1)',
}}
>
{m.text}
</div>
),
)}
{offline ? (
<div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>
The model is away from its desk. Reads still work; writes will wait their turn.
</div>
) : null}
{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')))).join(', ')}
</div>
) : null}
</div>
<div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 8,
background: 'var(--paper-0)',
border: '1px solid var(--line-2)',
borderRadius: 'var(--radius-2)',
padding: '8px 8px 8px 12px',
}}
>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}}
placeholder={offline ? 'Writes wait for the connection…' : 'Tell me what to do…'}
disabled={offline}
rows={1}
style={{
flex: 1,
resize: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
font: 'var(--text-body)',
color: 'var(--ink-1)',
lineHeight: 1.45,
maxHeight: 96,
}}
/>
<button
type="button"
onClick={send}
aria-label="Send"
disabled={offline}
style={{
width: 30,
height: 30,
borderRadius: 'var(--radius-2)',
border: 'none',
cursor: offline ? 'not-allowed' : 'pointer',
background: offline ? 'var(--paper-3)' : 'var(--accent)',
color: offline ? 'var(--ink-3)' : 'var(--ink-inverse)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon name="send" size={14} />
</button>
</div>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 2px 0' }}>
Chat is the write-path. Destructive changes are proposed, never assumed.
</p>
</div>
</aside>
)
}

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

@@ -0,0 +1,232 @@
import React from 'react'
import { Badge, Button, Icon } from '../ui/index.js'
/**
* Shared empty/trouble states + the States gallery screen, ported from the
* handoff. EmptyState/OfflineBanner/ModelAwayState are reused across real
* screens as they land; StatesScreen is the specimen gallery.
*/
export interface EmptyStateProps {
icon: string
title: React.ReactNode
line: React.ReactNode
action?: string
onAction?: () => void
compact?: boolean
}
export function EmptyState({ icon, title, line, action, onAction, compact }: EmptyStateProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 10,
padding: compact ? '28px 20px' : '52px 24px',
}}
>
<span
style={{
width: 44,
height: 44,
borderRadius: '50%',
background: 'var(--paper-2)',
color: 'var(--ink-3)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon name={icon} size={19} />
</span>
<div style={{ font: '400 20px/1.25 var(--font-serif-display)', color: 'var(--ink-1)' }}>{title}</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0, maxWidth: 380 }}>{line}</p>
{action ? (
<Button style={{ marginTop: 6 }} onClick={onAction}>
{action}
</Button>
) : null}
</div>
)
}
export interface OfflineBannerProps {
retryIn?: string
}
export function OfflineBanner({ retryIn }: OfflineBannerProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
background: 'var(--warn-tint)',
border: '1px solid var(--warn)',
borderRadius: 'var(--radius-2)',
padding: '9px 14px',
}}
>
<span style={{ color: 'var(--warn)', display: 'inline-flex' }}>
<Icon name="triangle-alert" size={15} />
</span>
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', flex: 1 }}>
Gitea isnt answering. Ill keep trying and say nothing more about it.
</span>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>
retry in {retryIn || '0:12'} · reads from cache
</span>
</div>
)
}
export function ModelAwayState() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: 'var(--ink-3)', display: 'inline-flex' }}>
<Icon name="sparkles" size={15} />
</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={{ marginLeft: 'auto' }}>
<Badge>queued: 1 directive</Badge>
</span>
</div>
<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.
</p>
</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

@@ -0,0 +1,44 @@
import React from 'react';
/**
* Status pill — muted tint background, colored text. For state words like "ahead", "behind", "steeping".
*/
export interface BadgeProps {
/** @default 'neutral' */
tone?: 'ok' | 'warn' | 'danger' | 'info' | 'neutral' | 'jade';
/** Leading status dot @default false */
dot?: boolean;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-badge {
display: inline-flex; align-items: center; gap: 6px;
font: 500 12px/1 var(--font-sans);
padding: 4px 9px;
border-radius: var(--radius-round);
}
.ct-badge__dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.ct-badge--ok { background: var(--ok-tint); color: var(--ok); }
.ct-badge--warn { background: var(--warn-tint); color: var(--warn); }
.ct-badge--danger { background: var(--danger-tint); color: var(--danger); }
.ct-badge--info { background: var(--info-tint); color: var(--info); }
.ct-badge--neutral { background: var(--paper-2); color: var(--ink-2); }
.ct-badge--jade { background: var(--jade-tint); color: var(--jade-7); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-badge-css')) {
const s = document.createElement('style'); s.id = 'ct-badge-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Badge({ tone = 'neutral', dot = false, children, style, ...rest }: BadgeProps) {
return (
<span className={`ct-badge ct-badge--${tone}`} style={style} {...rest}>
{dot ? <span className="ct-badge__dot"></span> : null}
{children}
</span>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* CommiTea button. Plain verbs only ("Approve", "Brew plan", "Defer") — never witty.
*/
export interface ButtonProps {
/** @default 'primary' */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
/** @default 'md' */
size?: 'sm' | 'md';
/** Lucide icon name rendered before the label */
icon?: string;
/** Lucide icon name rendered after the label */
iconRight?: string;
disabled?: boolean;
onClick?: (e: React.MouseEvent) => void;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font: 600 14px/1 var(--font-sans);
border-radius: var(--radius-2);
border: 1px solid transparent;
cursor: pointer;
white-space: nowrap;
transition: background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.ct-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.ct-btn--md { height: 34px; padding: 0 14px; }
.ct-btn--sm { height: 28px; padding: 0 10px; font-size: 13px; }
.ct-btn--primary { background: var(--accent); color: var(--text-on-accent); }
.ct-btn--primary:hover:not(:disabled) { background: var(--accent-hover); }
.ct-btn--primary:active:not(:disabled) { background: var(--accent-pressed); }
.ct-btn--secondary { background: var(--surface-card); border-color: var(--line-2); color: var(--ink-1); }
.ct-btn--secondary:hover:not(:disabled) { background: var(--paper-2); }
.ct-btn--secondary:active:not(:disabled) { background: var(--paper-3); }
.ct-btn--ghost { background: transparent; color: var(--ink-2); }
.ct-btn--ghost:hover:not(:disabled) { background: var(--paper-2); color: var(--ink-1); }
.ct-btn--ghost:active:not(:disabled) { background: var(--paper-3); }
.ct-btn--danger { background: var(--danger); color: var(--ink-inverse); }
.ct-btn--danger:hover:not(:disabled) { filter: brightness(0.92); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-button-css')) {
const s = document.createElement('style'); s.id = 'ct-button-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Button({
variant = 'primary',
size = 'md',
icon,
iconRight,
disabled = false,
children,
style,
...rest
}: ButtonProps) {
const iconSize = size === 'sm' ? 15 : 17;
return (
<button
type="button"
className={`ct-btn ct-btn--${size} ct-btn--${variant}`}
disabled={disabled}
style={style}
{...rest}
>
{icon ? <Icon name={icon} size={iconSize} /> : null}
{children}
{iconRight ? <Icon name={iconRight} size={iconSize} /> : null}
</button>
);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
/**
* Paper card — hairline border, 10px radius, whispered shadow. `jade` adds the jade top rule
* reserved for the view's key card (e.g. the Focus card).
*/
export interface CardProps {
/** Caslon title */
title?: React.ReactNode;
/** Small uppercase label above the title */
overline?: string;
/** Right-aligned header actions (IconButtons) */
actions?: React.ReactNode;
/** Footer row below a hairline */
footer?: React.ReactNode;
/** Jade top rule — one per view @default false */
jade?: boolean;
/** Remove body padding (tables, charts) @default false */
flush?: boolean;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-card {
background: var(--surface-card);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-3);
box-shadow: var(--shadow-1);
}
.ct-card--jade { box-shadow: var(--shadow-jade-line), var(--shadow-1); }
.ct-card__header {
display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
padding: 16px 20px 0;
}
.ct-card__title {
font: var(--text-title);
color: var(--ink-1);
margin: 0;
}
.ct-card__overline {
font: var(--text-overline);
letter-spacing: var(--letter-spacing-wide);
text-transform: uppercase;
color: var(--ink-3);
margin: 0 0 6px;
}
.ct-card__body { padding: 14px 20px 18px; }
.ct-card__body--flush { padding: 0; }
.ct-card__footer {
display: flex; align-items: center; gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border-hairline);
}
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-card-css')) {
const s = document.createElement('style'); s.id = 'ct-card-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Card({ title, overline, actions, footer, jade = false, flush = false, children, style, ...rest }: CardProps) {
return (
<section className={`ct-card${jade ? ' ct-card--jade' : ''}`} style={style} {...rest}>
{(title || overline || actions) ? (
<header className="ct-card__header">
<div>
{overline ? <p className="ct-card__overline">{overline}</p> : null}
{title ? <h2 className="ct-card__title">{title}</h2> : null}
</div>
{actions ? <div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>{actions}</div> : null}
</header>
) : null}
<div className={`ct-card__body${flush ? ' ct-card__body--flush' : ''}`}>{children}</div>
{footer ? <footer className="ct-card__footer">{footer}</footer> : null}
</section>
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* Checkbox with label; 16px box, spruce when checked.
*/
export interface CheckboxProps {
label?: React.ReactNode;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
style?: React.CSSProperties;
}
const CSS = `
.ct-check { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); }
.ct-check--disabled { opacity: 0.5; cursor: not-allowed; }
.ct-check__input { position: absolute; opacity: 0; width: 0; height: 0; }
.ct-check__box {
width: 16px; height: 16px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
background: var(--surface-card);
border: 1px solid var(--line-2);
border-radius: var(--radius-1);
color: transparent;
transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out);
}
.ct-check:hover:not(.ct-check--disabled) .ct-check__box { border-color: var(--border-strong); }
.ct-check__input:checked + .ct-check__box { background: var(--accent); border-color: var(--accent); color: var(--ink-inverse); }
.ct-check__input:focus-visible + .ct-check__box { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-check-css')) {
const s = document.createElement('style'); s.id = 'ct-check-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Checkbox({ label, checked, onChange, disabled = false, style, ...rest }: CheckboxProps) {
return (
<label className={`ct-check${disabled ? ' ct-check--disabled' : ''}`} style={style}>
<input
type="checkbox"
className="ct-check__input"
checked={checked}
onChange={onChange}
disabled={disabled}
{...rest}
/>
<span className="ct-check__box"><Icon name="check" size={12} strokeWidth={2.5} /></span>
{label ? <span>{label}</span> : null}
</label>
);
}

View File

@@ -0,0 +1,82 @@
import React from 'react'
import { IconButton } from './icon-button.js'
/**
* Modal dialog — Caslon title over a double stationery rule; used for propose-approve
* moments and destructive confirms.
*/
export interface DialogProps {
open: boolean;
onClose?: () => void;
title: React.ReactNode;
/** Right-aligned action row (Buttons) */
footer?: React.ReactNode;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-dialog-scrim {
position: fixed; inset: 0;
background: rgba(32, 38, 29, 0.4);
display: flex; align-items: center; justify-content: center;
z-index: 100;
animation: ct-dialog-fade var(--duration-base) var(--ease-out);
}
.ct-dialog {
background: var(--surface-card);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-3);
box-shadow: var(--shadow-3);
width: min(480px, calc(100vw - 48px));
max-height: calc(100vh - 96px);
display: flex; flex-direction: column;
animation: ct-dialog-rise var(--duration-base) var(--ease-out);
}
.ct-dialog__header {
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
padding: 20px 20px 12px;
border-bottom: 3px double var(--border-strong);
margin: 0 20px; padding-left: 0; padding-right: 0;
}
.ct-dialog__title { font: var(--text-title); color: var(--ink-1); margin: 0; }
.ct-dialog__body { padding: 16px 20px; overflow-y: auto; font: var(--text-body); color: var(--ink-1); }
.ct-dialog__footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 20px 20px;
}
@keyframes ct-dialog-fade { from { opacity: 0; } }
@keyframes ct-dialog-rise { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) {
.ct-dialog-scrim, .ct-dialog { animation: none; }
}
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-dialog-css')) {
const s = document.createElement('style'); s.id = 'ct-dialog-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Dialog({ open, onClose, title, footer, children, style }: DialogProps) {
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && onClose) onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="ct-dialog-scrim" onClick={(e: React.MouseEvent) => { if (e.target === e.currentTarget && onClose) onClose(); }}>
<div className="ct-dialog" role="dialog" aria-modal="true" style={style}>
<header className="ct-dialog__header">
<h2 className="ct-dialog__title">{title}</h2>
{onClose ? <IconButton icon="x" label="Close" size="sm" onClick={onClose} /> : null}
</header>
<div className="ct-dialog__body">{children}</div>
{footer ? <footer className="ct-dialog__footer">{footer}</footer> : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Icon } from './icon.js';
/**
* Square icon-only button; label is mandatory (becomes aria-label + title).
*/
export interface IconButtonProps {
/** Lucide icon name */
icon: string;
/** Required accessible label (also the tooltip) */
label: string;
/** @default 'ghost' */
variant?: 'ghost' | 'outline';
/** @default 'md' */
size?: 'sm' | 'md';
disabled?: boolean;
onClick?: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
const CSS = `
.ct-iconbtn {
display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--radius-2);
border: 1px solid transparent;
background: transparent;
color: var(--ink-2);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
}
.ct-iconbtn:hover:not(:disabled) { background: var(--paper-2); color: var(--ink-1); }
.ct-iconbtn:active:not(:disabled) { background: var(--paper-3); }
.ct-iconbtn:disabled { opacity: 0.45; cursor: not-allowed; }
.ct-iconbtn--md { width: 34px; height: 34px; }
.ct-iconbtn--sm { width: 28px; height: 28px; }
.ct-iconbtn--outline { border-color: var(--line-2); background: var(--surface-card); }
.ct-iconbtn--outline:hover:not(:disabled) { background: var(--paper-2); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-iconbtn-css')) {
const s = document.createElement('style'); s.id = 'ct-iconbtn-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function IconButton({
icon,
label,
variant = 'ghost',
size = 'md',
disabled = false,
style,
...rest
}: IconButtonProps) {
return (
<button
type="button"
className={`ct-iconbtn ct-iconbtn--${size}${variant === 'outline' ? ' ct-iconbtn--outline' : ''}`}
aria-label={label}
title={label}
disabled={disabled}
style={style}
{...rest}
>
<Icon name={icon} size={size === 'sm' ? 15 : 17} />
</button>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
/**
* CommiTea design-system primitives, ported verbatim from the design handoff
* (docs/design/components/). Each keeps its injected token-referencing CSS; the
* contracts in the handoff's `.d.ts` files are reproduced here as the exported
* prop interfaces. Import from `#~/components/ui` — never reach into a file.
*/
export * from './icon.js'
export * from './button.js'
export * from './icon-button.js'
export * from './badge.js'
export * from './tag.js'
export * from './card.js'
export * from './tabs.js'
export * from './input.js'
export * from './checkbox.js'
export * from './radio.js'
export * from './switch.js'
export * from './select.js'
export * from './dialog.js'
export * from './toast.js'
export * from './tooltip.js'

View File

@@ -0,0 +1,76 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* Single-line text input with optional label, hint/error and leading icon.
*/
export interface InputProps {
label?: string;
/** Small gray helper under the field */
hint?: string;
/** Replaces hint; turns the border madder red */
error?: string;
/** Lucide icon name, leading */
icon?: string;
/** Mono text for machine values (urls, tokens) @default false */
mono?: boolean;
placeholder?: string;
value?: string;
defaultValue?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
type?: string;
style?: React.CSSProperties;
}
const CSS = `
.ct-field { display: flex; flex-direction: column; gap: 6px; }
.ct-field__label { font: 600 13px/1.2 var(--font-sans); color: var(--ink-1); }
.ct-field__wrap { position: relative; display: flex; align-items: center; }
.ct-field__icon { position: absolute; left: 10px; color: var(--ink-3); pointer-events: none; display: flex; }
.ct-input {
width: 100%; height: 34px;
font: var(--text-body);
color: var(--ink-1);
background: var(--surface-card);
border: 1px solid var(--line-2);
border-radius: var(--radius-2);
padding: 0 12px;
transition: border-color var(--duration-fast) var(--ease-out);
}
.ct-input::placeholder { color: var(--ink-3); }
.ct-input:hover:not(:disabled):not(:focus) { border-color: var(--border-strong); background: var(--paper-1); }
.ct-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.ct-input:disabled { opacity: 0.5; background: var(--paper-2); cursor: not-allowed; }
.ct-input--icon { padding-left: 32px; }
.ct-input--error { border-color: var(--danger); }
.ct-input--error:focus { border-color: var(--danger); box-shadow: 0 0 0 1px var(--danger); }
.ct-input--mono { font: var(--text-data); }
.ct-field__hint { font: var(--text-caption); color: var(--ink-3); margin: 0; }
.ct-field__error { font: var(--text-caption); color: var(--danger); margin: 0; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-input-css')) {
const s = document.createElement('style'); s.id = 'ct-input-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Input({ label, hint, error, icon, mono = false, style, ...rest }: InputProps) {
const cls = [
'ct-input',
icon ? 'ct-input--icon' : '',
error ? 'ct-input--error' : '',
mono ? 'ct-input--mono' : '',
].filter(Boolean).join(' ');
return (
<div className="ct-field" style={style}>
{label ? <label className="ct-field__label">{label}</label> : null}
<div className="ct-field__wrap">
{icon ? <span className="ct-field__icon"><Icon name={icon} size={15} /></span> : null}
<input className={cls} {...rest} />
</div>
{error ? <p className="ct-field__error">{error}</p> : hint ? <p className="ct-field__hint">{hint}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
/**
* Radio button with label; group by `name`.
*/
export interface RadioProps {
label?: React.ReactNode;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
name?: string;
value?: string;
disabled?: boolean;
style?: React.CSSProperties;
}
const CSS = `
.ct-radio { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); }
.ct-radio--disabled { opacity: 0.5; cursor: not-allowed; }
.ct-radio__input { position: absolute; opacity: 0; width: 0; height: 0; }
.ct-radio__dot {
width: 16px; height: 16px; flex-shrink: 0;
border: 1px solid var(--line-2);
border-radius: 50%;
background: var(--surface-card);
display: inline-flex; align-items: center; justify-content: center;
transition: border-color var(--duration-fast) var(--ease-out);
}
.ct-radio__dot::after {
content: '';
width: 8px; height: 8px; border-radius: 50%;
background: transparent;
transition: background var(--duration-fast) var(--ease-out);
}
.ct-radio:hover:not(.ct-radio--disabled) .ct-radio__dot { border-color: var(--border-strong); }
.ct-radio__input:checked + .ct-radio__dot { border-color: var(--accent); }
.ct-radio__input:checked + .ct-radio__dot::after { background: var(--accent); }
.ct-radio__input:focus-visible + .ct-radio__dot { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-radio-css')) {
const s = document.createElement('style'); s.id = 'ct-radio-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Radio({ label, checked, onChange, name, value, disabled = false, style, ...rest }: RadioProps) {
return (
<label className={`ct-radio${disabled ? ' ct-radio--disabled' : ''}`} style={style}>
<input
type="radio"
className="ct-radio__input"
checked={checked}
onChange={onChange}
name={name}
value={value}
disabled={disabled}
{...rest}
/>
<span className="ct-radio__dot"></span>
{label ? <span>{label}</span> : null}
</label>
);
}

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* Styled native select with Lucide chevron.
*/
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps {
label?: string;
options: SelectOption[];
value?: string;
defaultValue?: string;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
disabled?: boolean;
style?: React.CSSProperties;
}
const CSS = `
.ct-select-field { display: flex; flex-direction: column; gap: 6px; }
.ct-select-field__label { font: 600 13px/1.2 var(--font-sans); color: var(--ink-1); }
.ct-select__wrap { position: relative; display: flex; align-items: center; }
.ct-select {
width: 100%; height: 34px;
font: var(--text-body);
color: var(--ink-1);
background: var(--surface-card);
border: 1px solid var(--line-2);
border-radius: var(--radius-2);
padding: 0 30px 0 12px;
appearance: none;
cursor: pointer;
transition: border-color var(--duration-fast) var(--ease-out);
}
.ct-select:hover:not(:disabled):not(:focus) { border-color: var(--border-strong); }
.ct-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.ct-select:disabled { opacity: 0.5; cursor: not-allowed; }
.ct-select__chevron { position: absolute; right: 10px; color: var(--ink-3); pointer-events: none; display: flex; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-select-css')) {
const s = document.createElement('style'); s.id = 'ct-select-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Select({ label, options, style, ...rest }: SelectProps) {
return (
<div className="ct-select-field" style={style}>
{label ? <label className="ct-select-field__label">{label}</label> : null}
<div className="ct-select__wrap">
<select className="ct-select" {...rest}>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<span className="ct-select__chevron"><Icon name="chevron-down" size={15} /></span>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
/**
* On/off switch; spruce when on. For live state, not form options.
*/
export interface SwitchProps {
label?: React.ReactNode;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
style?: React.CSSProperties;
}
const CSS = `
.ct-switch { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); }
.ct-switch--disabled { opacity: 0.5; cursor: not-allowed; }
.ct-switch__input { position: absolute; opacity: 0; width: 0; height: 0; }
.ct-switch__track {
width: 34px; height: 20px; flex-shrink: 0;
border-radius: var(--radius-round);
background: var(--paper-3);
border: 1px solid var(--line-2);
position: relative;
transition: background var(--duration-base) var(--ease-out), border-color var(--duration-base) var(--ease-out);
}
.ct-switch__track::after {
content: '';
position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; border-radius: 50%;
background: var(--surface-card);
box-shadow: var(--shadow-1);
transition: transform var(--duration-base) var(--ease-out);
}
.ct-switch__input:checked + .ct-switch__track { background: var(--accent); border-color: var(--accent); }
.ct-switch__input:checked + .ct-switch__track::after { transform: translateX(14px); }
.ct-switch__input:focus-visible + .ct-switch__track { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-switch-css')) {
const s = document.createElement('style'); s.id = 'ct-switch-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Switch({ label, checked, onChange, disabled = false, style, ...rest }: SwitchProps) {
return (
<label className={`ct-switch${disabled ? ' ct-switch--disabled' : ''}`} style={style}>
<input
type="checkbox"
role="switch"
className="ct-switch__input"
checked={checked}
onChange={onChange}
disabled={disabled}
{...rest}
/>
<span className="ct-switch__track"></span>
{label ? <span>{label}</span> : null}
</label>
);
}

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { Icon } from './icon.js';
/**
* Underline tabs for view switching (Board / Gantt / Dependencies).
*/
export interface TabItem {
id: string;
label: string;
/** Lucide icon name */
icon?: string;
/** Mono count rendered after the label */
count?: number;
}
export interface TabsProps {
items: TabItem[];
/** id of the active tab */
active: string;
onChange?: (id: string) => void;
style?: React.CSSProperties;
}
const CSS = `
.ct-tabs {
display: flex; gap: 2px;
border-bottom: 1px solid var(--border-hairline);
}
.ct-tab {
display: inline-flex; align-items: center; gap: 7px;
font: 500 13.5px/1 var(--font-sans);
color: var(--ink-2);
background: none; border: none;
padding: 10px 12px;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
}
.ct-tab:hover { color: var(--ink-1); }
.ct-tab--active {
color: var(--ink-1);
font-weight: 600;
border-bottom-color: var(--accent);
}
.ct-tab__count { font: 400 11.5px/1 var(--font-mono); color: var(--ink-3); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-tabs-css')) {
const s = document.createElement('style'); s.id = 'ct-tabs-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Tabs({ items, active, onChange, style, ...rest }: TabsProps) {
return (
<div className="ct-tabs" role="tablist" style={style} {...rest}>
{items.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={item.id === active}
className={`ct-tab${item.id === active ? ' ct-tab--active' : ''}`}
onClick={() => onChange && onChange(item.id)}
>
{item.icon ? <Icon name={item.icon} size={15} /> : null}
{item.label}
{item.count != null ? <span className="ct-tab__count">{item.count}</span> : null}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* Gitea label chip — renders label text verbatim in mono; tone derives from the label itself
* (est/[1d 2d 3d 5d 8d], p/1..4, deadline/hard).
*/
export interface TagProps {
/** Verbatim gitea label, e.g. "est/3d", "p/1", "deadline/hard" */
label: string;
/** Renders a small remove button when provided */
onRemove?: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
const CSS = `
.ct-tag {
display: inline-flex; align-items: center; gap: 5px;
font: 500 11.5px/1 var(--font-mono);
letter-spacing: var(--letter-spacing-label);
padding: 4px 9px;
border-radius: var(--radius-round);
white-space: nowrap;
}
.ct-tag--est { background: var(--label-est-bg); color: var(--label-est-text); }
.ct-tag--p1 { background: var(--danger-tint); color: var(--label-p1); }
.ct-tag--p2 { background: var(--warn-tint); color: var(--label-p2); }
.ct-tag--p3 { background: var(--info-tint); color: var(--label-p3); }
.ct-tag--p4 { background: var(--paper-2); color: var(--label-p4); }
.ct-tag--hard { background: var(--label-hard); color: var(--ink-inverse); }
.ct-tag--plain { background: var(--paper-2); color: var(--ink-2); }
.ct-tag__x {
display: inline-flex; padding: 0; margin: 0 -3px 0 0;
background: none; border: none; color: inherit; cursor: pointer; opacity: 0.6;
}
.ct-tag__x:hover { opacity: 1; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-tag-css')) {
const s = document.createElement('style'); s.id = 'ct-tag-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
function toneFor(label: string) {
if (label.startsWith('est/')) return 'est';
if (label === 'p/1') return 'p1';
if (label === 'p/2') return 'p2';
if (label === 'p/3') return 'p3';
if (label === 'p/4') return 'p4';
if (label === 'deadline/hard') return 'hard';
return 'plain';
}
export function Tag({ label, onRemove, style, ...rest }: TagProps) {
return (
<span className={`ct-tag ct-tag--${toneFor(label)}`} style={style} {...rest}>
{label}
{onRemove ? (
<button type="button" className="ct-tag__x" aria-label={`Remove ${label}`} onClick={onRemove}>
<Icon name="x" size={11} strokeWidth={2} />
</button>
) : null}
</span>
);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { Icon } from './icon.js';
import { IconButton } from './icon-button.js';
/**
* Transient notification card. Presentational — the consumer owns positioning/stacking.
*/
export interface ToastProps {
/** @default 'info' */
tone?: 'ok' | 'warn' | 'danger' | 'info';
title?: React.ReactNode;
onDismiss?: () => void;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-toast {
display: flex; align-items: flex-start; gap: 10px;
background: var(--surface-card);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-2);
box-shadow: var(--shadow-2);
padding: 12px 14px;
max-width: 420px;
font: var(--text-body);
color: var(--ink-1);
animation: ct-toast-in var(--duration-slow) var(--ease-out);
}
.ct-toast__icon { display: flex; margin-top: 1px; }
.ct-toast--ok .ct-toast__icon { color: var(--ok); }
.ct-toast--warn .ct-toast__icon { color: var(--warn); }
.ct-toast--danger .ct-toast__icon { color: var(--danger); }
.ct-toast--info .ct-toast__icon { color: var(--info); }
.ct-toast__content { flex: 1; min-width: 0; }
.ct-toast__title { font: var(--text-body-strong); margin: 0 0 2px; }
@keyframes ct-toast-in { from { opacity: 0; transform: translateY(6px); } }
@media (prefers-reduced-motion: reduce) { .ct-toast { animation: none; } }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-toast-css')) {
const s = document.createElement('style'); s.id = 'ct-toast-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
const TOAST_ICONS: Record<string, string> = {
ok: 'circle-check',
warn: 'triangle-alert',
danger: 'circle-alert',
info: 'info',
};
export function Toast({ tone = 'info', title, onDismiss, children, style, ...rest }: ToastProps) {
return (
<div className={`ct-toast ct-toast--${tone}`} role="status" style={style} {...rest}>
<span className="ct-toast__icon"><Icon name={TOAST_ICONS[tone]} size={16} /></span>
<div className="ct-toast__content">
{title ? <p className="ct-toast__title">{title}</p> : null}
{children}
</div>
{onDismiss ? <IconButton icon="x" label="Dismiss" size="sm" onClick={onDismiss} /> : null}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
/**
* Hover/focus tooltip — ink capsule, plain facts only.
*/
export interface TooltipProps {
content: React.ReactNode;
/** @default 'top' */
side?: 'top' | 'bottom';
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-tooltip-wrap { position: relative; display: inline-flex; }
.ct-tooltip {
position: absolute; bottom: calc(100% + 7px); left: 50%;
transform: translateX(-50%) translateY(2px);
background: var(--ink-1);
color: var(--ink-inverse);
font: 500 12px/1.4 var(--font-sans);
padding: 5px 9px;
border-radius: var(--radius-1);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);
z-index: 50;
}
.ct-tooltip--bottom { bottom: auto; top: calc(100% + 7px); transform: translateX(-50%) translateY(-2px); }
.ct-tooltip-wrap:hover .ct-tooltip,
.ct-tooltip-wrap:focus-within .ct-tooltip {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.ct-tooltip code { font: 500 11px var(--font-mono); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-tooltip-css')) {
const s = document.createElement('style'); s.id = 'ct-tooltip-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Tooltip({ content, side = 'top', children, style }: TooltipProps) {
return (
<span className="ct-tooltip-wrap" style={style}>
{children}
<span className={`ct-tooltip${side === 'bottom' ? ' ct-tooltip--bottom' : ''}`} role="tooltip">
{content}
</span>
</span>
);
}

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

@@ -0,0 +1,51 @@
import type {
AgentStep,
ChatMessage,
DependencyEdge,
GiteaIssue,
GiteaMilestone,
IssueChange,
LabelPlan,
LifecycleEvent,
} from '@commitea/core'
/** The result of a write through the bridge. */
export type ApplyChangeResult =
| { ok: false; reason: 'unconfigured' }
| { ok: true; plan: LabelPlan; issue: GiteaIssue }
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
export interface GiteaBridge {
status(): Promise<{ configured: boolean; repo: string | null }>
reconcile(): Promise<{
configured: boolean
issues: GiteaIssue[]
milestones: GiteaMilestone[]
deps: DependencyEdge[]
/** Normalized lifecycle events keyed by issue number. */
timelines: Record<number, LifecycleEvent[]>
}>
getIssue(index: number): Promise<GiteaIssue | null>
applyChange(change: IssueChange): Promise<ApplyChangeResult>
}
/** One agent turn's result. */
export type ChatResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| { ok: true; content: string; steps: AgentStep[] }
/** 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>
}
declare global {
interface Window {
commitea: {
platform: string
gitea: GiteaBridge
model: ModelBridge
}
}
}

View File

@@ -0,0 +1,234 @@
import {
type CalibrationModel,
type CalibrationSample,
calibrationSamples,
COLD_START_THRESHOLD,
type DependencyEdge,
fitCalibration,
forecast,
type GiteaIssue,
inferLifecycle,
type LifecycleColumn,
type LifecycleEvent,
type LifecycleInference,
PRIOR_BUCKETS,
schedule,
type ScheduledItem,
selectFocus,
toDurationModel,
} from '@commitea/core'
import { type BoardColumn, type BoardIssue, type CalibrationData, type FocusIssue } from '../data/fixtures.js'
import { type BurnUpData, buildBurnUpData } from './dates.js'
type Timelines = Record<number, LifecycleEvent[]>
/** Infer every issue's lifecycle once; callers index by issue number. */
function inferAll(issues: GiteaIssue[], timelines: Timelines, asOf: Date): Map<number, LifecycleInference> {
return new Map(issues.map((i) => [i.number, inferLifecycle(i, timelines[i.number] ?? [], asOf)]))
}
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
diagnosis: 'Diagnosis',
triage: 'Triage',
steeping: 'Steeping',
review: 'In review',
done: 'Done',
}
const COLUMN_ORDER: LifecycleColumn[] = ['diagnosis', 'triage', 'steeping', 'review', 'done']
function initials(login: string): string {
return login.slice(0, 2).toUpperCase()
}
/**
* Shape real gitea issues into the Board's five columns via lifecycle inference
* (#5). Steeping / In-review are now populated from the event stream (first
* commit ref → steeping, first PR ref → review); the `days` badge is the
* steeping age in working days.
*/
export function issuesToBoardColumns(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): BoardColumn[] {
const inf = inferAll(issues, timelines, asOf)
return COLUMN_ORDER.map((key) => ({
id: key,
label: COLUMN_LABELS[key],
issues: issues
.filter((i) => inf.get(i.number)!.column === key)
.map((i): BoardIssue => {
const li = inf.get(i.number)!
return {
id: i.number,
title: i.title,
labels: i.labels,
who: i.assignee ? initials(i.assignee) : '·',
days: li.steepingDays != null ? `${li.steepingDays}d` : undefined,
}
}),
}))
}
export interface FocusView {
now: FocusIssue | null
next: FocusIssue | null
later: FocusIssue | null
}
function toFocusIssue(item: ScheduledItem | null, inf?: Map<number, LifecycleInference>): FocusIssue | null {
if (!item) return null
const steepingDays = inf?.get(item.number)?.steepingDays ?? null
return {
id: item.number,
title: item.title,
labels: item.labels,
rationale: item.rationale,
steeping: steepingDays != null ? `${steepingDays}d` : undefined,
}
}
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,
}))
}
export interface ForecastView {
scope: number
cone: BurnUpData
p80Label: 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
* burn-up cone. When a calibration model is supplied and past cold-start, its
* fitted params drive the sim. Returns null when there's nothing to forecast.
* `today` is injectable for tests.
*/
export function forecastBacklog(
issues: GiteaIssue[],
deps: DependencyEdge[],
today: Date = new Date(),
calibration?: CalibrationModel,
): ForecastView | null {
const model = calibration ? toDurationModel(calibration) : undefined
const f = forecast(toSchedulable(issues), deps, model ? { model } : {})
const cone = buildBurnUpData(f, today)
if (!cone) return null
return {
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 } {
const samples = calibrationSamples(issues, timelines, asOf)
const model = fitCalibration(samples)
return { model, data: calibrationData(model, samples, issues) }
}
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[],
): 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,
}
}
/**
* Run the deterministic scheduler over the open backlog and take the top three
* as Now/Next/Later. Estimates + priority come from label facts; dependency
* edges come from gitea's native issue dependencies.
*/
export function scheduleFocus(
issues: GiteaIssue[],
deps: DependencyEdge[],
timelines: Timelines = {},
asOf: Date = new Date(),
): FocusView {
const plan = schedule(toSchedulable(issues), deps)
const f = selectFocus(plan)
const inf = inferAll(issues, timelines, asOf)
return {
now: toFocusIssue(f.now, inf),
next: toFocusIssue(f.next, inf),
later: toFocusIssue(f.later, inf),
}
}

View File

@@ -0,0 +1,70 @@
import type { Forecast } from '@commitea/core'
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
/** Advance `base` by whole working days (skipping Sat/Sun). Fractions round to nearest day. */
export function addWorkingDays(base: Date, workingDays: number): Date {
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate())
let remaining = Math.max(0, Math.round(workingDays))
while (remaining > 0) {
d.setDate(d.getDate() + 1)
const day = d.getDay()
if (day !== 0 && day !== 6) remaining -= 1
}
return d
}
/** "Mar 6" */
export function formatShort(d: Date): string {
return `${MONTHS[d.getMonth()]} ${d.getDate()}`
}
/** "Feb 24 Mar 6" (collapses the month when both ends share it → "Mar 312"). */
export function formatRange(lo: Date, hi: Date): string {
if (lo.getMonth() === hi.getMonth()) return `${MONTHS[lo.getMonth()]} ${lo.getDate()}${hi.getDate()}`
return `${formatShort(lo)} ${formatShort(hi)}`
}
export interface BurnUpBandPoint {
fraction: number
loDay: number
midDay: number
hiDay: number
}
/** Chart-ready geometry + calendar labels derived from a Monte Carlo forecast. */
export interface BurnUpData {
scope: number
horizonDays: number
band: BurnUpBandPoint[]
p80Day: number
p80Label: string
rangeLabel: string
startLabel: string
endLabel: string
}
/**
* Map a working-day forecast onto a calendar-anchored burn-up cone. `today`
* anchors day 0; the cone emanates from (today, 0) and widens to the right.
* The historical "actual" polyline behind today awaits lifecycle events (#5).
*/
export function buildBurnUpData(f: Forecast, today: Date): BurnUpData | null {
if (f.scope === 0 || f.curve.length === 0) return null
const last = f.curve[f.curve.length - 1]
const horizonDays = Math.max(1, Math.ceil(last.p90Day * 1.05))
const band: BurnUpBandPoint[] = [
{ fraction: 0, loDay: 0, midDay: 0, hiDay: 0 },
...f.curve.map((p) => ({ fraction: p.fraction, loDay: p.p10Day, midDay: p.p50Day, hiDay: p.p90Day })),
]
return {
scope: f.scope,
horizonDays,
band,
p80Day: f.p80Day,
p80Label: formatShort(addWorkingDays(today, f.p80Day)),
rangeLabel: formatRange(addWorkingDays(today, f.p50Day), addWorkingDays(today, last.p90Day)),
startLabel: formatShort(today),
endLabel: formatShort(addWorkingDays(today, horizonDays)),
}
}

View File

@@ -0,0 +1,55 @@
import { useCallback, useEffect, useState } from 'react'
import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
export type BacklogState =
| { status: 'loading' }
| { status: 'unconfigured' }
| { status: 'error'; message: string }
| {
status: 'ready'
issues: GiteaIssue[]
milestones: GiteaMilestone[]
deps: DependencyEdge[]
timelines: Record<number, LifecycleEvent[]>
}
/**
* Reconcile the managed repo through the main-process bridge. Runs once on
* mount; the returned `refetch` re-reconciles after a write so the board and
* forecast reflect the change. `unconfigured` means no token — the UI falls
* back to demo fixtures. Errors (network, bad token) surface as `error`.
*/
export function useBacklog(): [BacklogState, () => void] {
const [state, setState] = useState<BacklogState>({ status: 'loading' })
const [nonce, setNonce] = useState(0)
const refetch = useCallback(() => setNonce((n) => n + 1), [])
useEffect(() => {
let alive = true
window.commitea.gitea
.reconcile()
.then((r) => {
if (!alive) return
setState(
r.configured
? {
status: 'ready',
issues: r.issues,
milestones: r.milestones,
deps: r.deps,
timelines: r.timelines,
}
: { status: 'unconfigured' },
)
})
.catch((e: unknown) => {
if (alive) setState({ status: 'error', message: e instanceof Error ? e.message : String(e) })
})
return () => {
alive = false
}
}, [nonce])
return [state, refetch]
}

View File

@@ -0,0 +1,105 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ChatMessage as WireMessage } from '@commitea/core'
import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js'
const LIVE_GREETING: ChatMessage = {
from: 'agent',
text: 'Morning. Ask me anything about the project — I check the real board before I answer.',
}
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[]
send: (text: string) => void
}
/**
* Reginald's conversation. When a model is configured, `send` drives one agent
* turn through the main-process bridge (which runs the tool loop). Otherwise it
* echoes the scripted fixture reply, so the layout stays real with no model and
* fixture e2e is unaffected. The fixture greeting is display-only — only real
* turns (`convo`) are sent to the model as history.
*/
export function useChat(): ChatState {
const [seed, setSeed] = useState<ChatMessage[]>(CHAT)
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 convoRef = useRef(convo)
convoRef.current = convo
useEffect(() => {
let alive = true
window.commitea.model
.status()
.then((s) => {
if (alive && s.configured) {
setLive(true)
setModel(s.model)
setSeed([LIVE_GREETING])
}
})
.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([])
if (!live) {
window.setTimeout(() => {
setThinking(false)
setConvo((c) => [...c, { from: 'agent', text: CANNED_REPLY }])
}, 900)
return
}
const wire: WireMessage[] = nextConvo.map((m) => ({
role: m.from === 'user' ? 'user' : 'assistant',
content: m.text,
}))
window.commitea.model
.chat(wire)
.then((res) => {
setThinking(false)
if (res.ok) {
setSteps(res.steps.map((s) => s.tool))
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(() => {
setThinking(false)
setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }])
})
},
[live],
)
return { msgs: [...seed, ...convo], thinking, live, model, steps, send }
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -87,7 +87,11 @@ directives. Electron app, one window, "tell me what to do" experience.
Deliverables per phase; replace TBD with actuals at phase close.
- **P0 — Scaffold** — repo, Electron shell, gitea API client + token auth,
`pm-state` repo bootstrap. Actual: TBD
`pm-state` repo bootstrap. Actual (2026-07-08): yarn4 workspaces + electron
shell + `@commitea/core` label schema + design system mirrored. **Dogfood
backlog filed** on `christian/commitea`: 10 labels (est/p/deadline, exclusive
scopes), 5 phase milestones, 34 tracer-bullet issues with 51 native
dependencies. `pm-state` repo bootstrap still pending (P1-6 / P5-1).
- **P1 — Sync + data model** — read mirror into SQLite cache, webhook
listener + reconcile-on-launch, lifecycle inference from event stream,
label schema applied. Actual: TBD
@@ -113,12 +117,16 @@ vertical slices within phases where possible.)
- Concurrent multi-PM directive writing
- Cloud LLM as *requirement* (it's a config option, not a dependency)
## Open items for design session
## Open items for design session — RESOLVED 2026-07-08
- Chat-as-only-write-path: strict or soft (UI edits allowed but agent
observes/objects)?
- `pm-state` file formats (JSONL event log settled; capacity/calibration
schemas TBD)
- Tool schema for the fat `query_project` tool
- Webhook endpoint mechanics per instance (port allocation, cleanup on quit)
- Cold-start estimate distributions (industry priors vs uniform pessimism)
Settled in [decisions.md](./decisions.md), [pm-state.md](./pm-state.md),
[agent-tools.md](./agent-tools.md):
- Chat-as-only-write-path → **soft, split by semantics** (decisions.md D1)
- `pm-state` file formats → directive/capacity/calibration schemas (pm-state.md)
- Fat `query_project` tool schema → one read tool + three write tools
(agent-tools.md)
- Webhook mechanics → **poll + reconcile only in v1** (NAT), behind a
`ChangeSource` interface (decisions.md D2)
- Cold-start distributions **lognormal per-bucket priors** in code
(decisions.md D3)

119
docs/agent-tools.md Normal file
View File

@@ -0,0 +1,119 @@
# Agent tools
Reginald gets **few, fat tools** so a small local model (gemma-4b class) can
survive with one thing to reach for. One read tool, three write tools. All I/O
is compact JSON; ticket data is fetched through tools, never copied into hot
memory ([PLAN.md](./PLAN.md) memory layers).
Model routing (per PLAN.md): the read tool + prose/standup run on the small
local model; `capture_work` decomposition and `record_directive` negotiation
route to the big model.
## `query_project` (read)
The single read tool. A `view` enum selects the shape; `filters` narrows it. The
scheduler's deterministic output backs every forecast field — the model never
computes, it reports.
```jsonc
query_project({
view: "focus" | "issue" | "milestone" | "board" | "runway"
| "calibration" | "directives" | "standup" | "search",
filters?: {
issueId?: number,
milestoneId?: number,
state?: "diagnosis" | "triage" | "steeping" | "in_review" | "done",
assignee?: string, // gitea username
label?: string,
query?: string, // free text, for view:"search"
limit?: number // default 20, max 100
}
})
```
View payloads (compact; forecasts always ranges, never point dates):
- **focus** — `{ now, next[], later[] }`, each `{ issue, title, rationale }`.
- **issue** — intent (title, description, comments, assignee, labels) + derived
(lifecycle timeline, per-issue forecast range, dependency ids, provenance).
- **milestone** — `{ due, hard, stats, cone: {p10,p50,p80 dates}, issues[] }`.
- **board** — issues grouped by the five lifecycle columns.
- **runway** — per-milestone `{ due, band: {p50,p80}, slack }` + capacity list.
- **calibration** — `{ n, coldStart, globalMultiplier, byLabelBias[], byPersonBias[] }`.
- **directives** — pending consequence diff + recent ledger entries.
- **standup** — drift report, per-person plan, stale blockers.
- **search** — issues matching `query`.
## `capture_work` (write — big model)
Braindump → interview → proposed issue set. Returns a **proposal**, never files
directly; the Capture screen's review tray edits it before `apply_changes` files
it. Decomposition + estimate negotiation is the one place the big model earns its
keep.
```jsonc
capture_work({
braindump: string,
answers?: { question: string, answer: string }[] // interview turns so far
})
// → { needsMoreInfo?: string[], // follow-up questions; present as chips
// proposal?: { issues: [{ title, body, estimate: EstimateLabel,
// priority?: PriorityLabel, deps?: number[],
// milestone?: number }],
// consequence: string } } // one-line schedule impact
```
## `apply_changes` (write — unified mutation)
Every mutation funnels here: filing captured issues, label/estimate/priority/
milestone/dependency edits. Additive ops apply directly; **destructive ops
require `approved: true`** (the caller obtains approval via the consequence
diff / Dialog first — see [decisions.md](./decisions.md) D1). Batched so one call
= one coherent change with one consequence.
```jsonc
apply_changes({
ops: [
{ op: "create_issue", title, body, labels?, milestone?, deps? },
{ op: "set_estimate", issue, estimate: EstimateLabel },
{ op: "set_priority", issue, priority: PriorityLabel },
{ op: "set_milestone", issue, milestone: number | null },
{ op: "set_deadline_hard", milestone: number, hard: boolean },
{ op: "add_dep", issue, dependsOn: number },
{ op: "remove_dep", issue, dependsOn: number }, // destructive
{ op: "close_issue", issue }, // destructive
{ op: "remove_label", issue, label } // destructive
],
approved?: boolean // required iff any op is destructive
})
// → { applied: number, consequence: string, rejected?: {op, reason}[] }
```
Only touches labels in the CommiTea namespaces (`est/*`, `p/*`, `deadline/hard`)
plus native issue fields — never invents labels, comments, or synthetic issues
(zero-pollution goal).
## `record_directive` (write — big model)
Appends to the directive log ([pm-state.md](./pm-state.md)), triggers a scheduler
re-run, and returns the consequence diff for propose-approve. Does **not** mutate
gitea itself — a directive is intent; its effects land through `apply_changes`
after approval.
```jsonc
record_directive({
kind: "reprioritize" | "reestimate" | "set-deadline" | "scope" | "capacity" | "note",
quote: string, // verbatim PM words, stored in the ledger
target?: { issue?: number, milestone?: number, member?: string },
params?: object, // structured effect, e.g. { priority: 1 }
rationale?: string
})
// → { directiveId, consequence: { before, after }[], summary: string }
```
## Not tools
Reads that are pure UI state (theme, current view, back-stack) never go through
tools. The scheduler, Monte Carlo, calibration fit, and lifecycle inference are
**code**, invoked by the runtime around these tools — the model requests a view
or proposes a change; deterministic code produces every number.

73
docs/decisions.md Normal file
View File

@@ -0,0 +1,73 @@
# Settled design decisions
Resolves the "Open items for design session" in [PLAN.md](./PLAN.md). Dated
2026-07-08. Companion docs: [pm-state.md](./pm-state.md) (sidecar formats),
[agent-tools.md](./agent-tools.md) (tool schemas).
## D1 — Write path is soft, split by semantics
Chat (Reginald) is the write path for **PM-semantic** mutations; gitea-native
content stays directly editable.
- **Through the agent** — estimates (`est/*`), priority (`p/*`),
`deadline/hard`, milestone assignment, dependency edits, and directives.
Additive ops act directly; destructive ops go propose-approve (Dialog or the
consequence diff on the Directives screen).
- **Direct, reconciled** — issue title/description, comments, assignees. Edit
them in the CommiTea UI ("composer writes to gitea, as you") or in the gitea
web UI; reconcile absorbs out-of-band edits because **gitea is the source of
truth** for intent. The agent observes changes on the next reconcile and may
object in standup, but never blocks them.
Rejected: strict (every write through chat). Hostile to quick edits and fights
the reconcile-from-gitea model — an edit made in gitea's own web UI would be
un-representable.
## D2 — Sync is poll + reconcile, no live webhooks in v1
`gitea.stephenmann.io` is remote and the desktop app sits behind NAT, so the
server cannot POST to a localhost webhook. v1:
- **Reconcile on launch** — full read of the work repo(s) + `pm-state` repo into
the local SQLite cache.
- **Light poll while running** — `since`/conditional-request poll of issues and
the issue timeline (target 3060 s cadence; visible < 2 s is a webhook-era
goal, relaxed to the poll interval for v1).
- The change-source is an **interface** (`ChangeSource`) with a polling
implementation; a webhook implementation can plug in later for a LAN /
self-hosted / tunnelled instance without touching the reconcile core.
Rejected now: reachability-detection hybrid (moving parts, cleanup-on-quit),
outbound tunnel (runtime dependency + public ingress). Both remain future
options behind the same interface.
## D3 — Cold-start forecasts use lognormal per-bucket priors
Before the team has n 20 closed issues with estimates, Monte Carlo samples a
**lognormal** actual-duration distribution per estimate bucket
(`1d/2d/3d/5d/8d`), with a pessimism-skewed median (actuals run long). The
priors live in code (`@commitea/core`), not in a data file. At n 20 the
scheduler switches to the team's own empirical fit (see calibration model in
[pm-state.md](./pm-state.md)); `byLabel` / `byPerson` bias terms layer on once
their own sample sizes clear a floor.
Rejected: uniform pessimism multiplier (`actual = est × U[1.3, 2.0]`) cruder,
dishonest tails, no path to per-bucket calibration.
## D4 — Purity test applies to the SQLite cache, not the pm-state repo
The plan's invariant *delete the sidecar → resync → no truth lost* is about
the **local SQLite cache**, which is a rebuildable index over two durable
sources:
- **Work repo(s) in gitea** human-authored intent (issues, milestones + due
dates, dependencies, assignees, labels, comments).
- **`pm-state` repo in gitea** the sidecar's own durable truth that is *not*
regenerable from the work repo: directive log, capacity config, charter, and
the (cached-but-committed) calibration model.
Delete SQLite rebuild from both repos nothing lost. The `pm-state` repo is
never the thing you delete; it is versioned and backed up in gitea like any
other repo. Regenerable state (issue mirror, inferred lifecycle timestamps,
Monte Carlo forecasts, focus snapshot) lives in SQLite only and is recomputed on
rebuild. See [pm-state.md](./pm-state.md) for the file/table split.

123
docs/pm-state.md Normal file
View File

@@ -0,0 +1,123 @@
# pm-state: the sidecar store
Machine-derived and PM-authored state that has no home in the work repo. Split
across two tiers per [decisions.md](./decisions.md) D4:
- **`pm-state` gitea repo** — durable, versioned truth. Committed files below.
- **local SQLite** — rebuildable cache/index. Never the source of truth.
## `pm-state` repo layout
```
charter.md durable project charter + hot-memory seed (human + agent authored)
directives/log.jsonl durable append-only directive ledger (conflict-free merge)
capacity/members.yaml durable per-person capacity model (human-set)
calibration/model.json cached fitted calibration; regenerable from actuals, committed for
reproducibility + offline forecasting
```
`forecasts/`, `focus/`, and inferred lifecycle timestamps are **not** committed
— they are SQLite-only and recomputed on rebuild (regenerable from the work repo
+ calibration model).
## Directive log — `directives/log.jsonl`
Append-only, one JSON object per line. **Merge is concatenation**: order is
derived from `ts` at read time, so two clients appending never produce a git
conflict. `id` is the durable key; `seq` is a display ordinal computed on read,
never stored (avoids the "who owns the next number" contention). v1 is
single-writer (one PM); this format is already safe for the deferred
multi-writer case.
```jsonc
{
"id": "d_01J8...", // crypto.randomUUID at write; durable identity
"ts": "2026-07-08T14:03:00Z", // ISO 8601 UTC; sole ordering key
"actor": "christian", // gitea username of the directive-giver
"kind": "reprioritize", // reprioritize | reestimate | set-deadline | scope | capacity | note
"target": { "issue": 87 }, // { issue } | { milestone } | { member } | null (project-wide)
"quote": "bump the auth bug above everything", // verbatim, shown in the ledger
"params": { "priority": 1 }, // structured effect the scheduler applies
"rationale": "pilot customer blocked", // why (optional but nagged for)
"status": "accepted" // proposed | accepted | amended | withdrawn
}
```
Lifecycle: a directive is recorded as `proposed`, the scheduler re-runs, the
agent presents the consequence diff, and the PM's response flips it to
`accepted` / `amended` / `withdrawn`. All four states stay in the ledger
(append a status-change line; never mutate a prior line).
## Capacity — `capacity/members.yaml`
Estimate unit is **ideal person-days**. Capacity is expressed in ideal
person-days available per calendar day.
```yaml
members:
- gitea: christian
focusFactor: 0.8 # productive fraction of a working day (0..1)
projectAllocation: 0.6 # share of focused time on THIS project (0..1)
workdays: [mon, tue, wed, thu, fri]
daysOff: [] # ISO dates, e.g. ["2026-07-14"]; PTO calendars deferred
# other standing slices (compliance 0.2, pilots 0.2) are documentation only —
# only projectAllocation feeds the scheduler.
```
Derived: `capacityPerWorkday = focusFactor * projectAllocation` (ideal
person-days per working day). The scheduler spreads this across `workdays`,
zeroing `daysOff`. Missing member ⇒ excluded from capacity, flagged by the agent.
## Calibration — `calibration/model.json`
Fitted from closed-issue actuals (estimate label vs inferred elapsed working
time). Lognormal on `log(actual / estimate)`.
```jsonc
{
"version": 1,
"fittedAt": "2026-07-08T00:00:00Z",
"n": 42, // closed issues with an estimate feeding the fit
"coldStart": false, // true while n < 20 → scheduler uses code priors instead
"global": { "mu": 0.166, "sigma": 0.45 }, // lognormal params on log-ratio; median ratio = e^mu ≈ 1.18
"byBucket": { // per estimate label; falls back to global when its n is thin
"1d": { "mu": 0.30, "sigma": 0.55, "n": 12 },
"2d": { "mu": 0.18, "sigma": 0.40, "n": 9 }
// 3d / 5d / 8d ...
},
"byLabel": { "backend": { "biasMu": 0.12, "n": 7 } }, // additive to mu; applied when n ≥ floor
"byPerson": { "christian": { "biasMu": -0.05, "n": 20 } }
}
```
Cold-start (`coldStart: true`, or a bucket with `n` below floor): the scheduler
ignores the file's fitted params for that axis and samples the **code-resident
lognormal priors** in `@commitea/core` (per D3). The file still records whatever
partial `n` exists so the UI's calibration teaser can show progress toward 20.
## SQLite cache (rebuildable — not committed)
Mirror + derived tables, rebuilt from both gitea repos on reconcile:
- `issues`, `labels`, `milestones`, `comments`, `issue_events` — verbatim work-repo mirror
- `lifecycle` — inferred per-issue timestamps (see below), keyed by issue
- `forecasts` — last Monte Carlo run per milestone/issue (regenerable)
- `focus` — current Now/Next/Later snapshot (regenerable)
- `directives` — indexed view of `log.jsonl` for fast querying
## Lifecycle inference
Timestamps derived from the gitea issue timeline; no manual time tracking. Maps
onto the board's five columns:
| Board column | Enter when | Source event |
|--------------|--------------------------------------------------------|-------------------------|
| Diagnosis | issue opened | `opened` |
| Triage | first label or milestone applied | `label` / `milestone` |
| Steeping | first branch or commit references the issue | `commit_ref` / branch |
| In review | a PR referencing the issue is opened | PR `opened` |
| Done | issue closed (PR merged is the deploy signal within) | `closed` / PR `merged` |
Elapsed **working** time between Steeping→Done (minus non-workdays/daysOff) is
the "actual" that feeds calibration. Re-openings append new segments; the fit
uses summed working time.

View File

@@ -0,0 +1,62 @@
import { 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 query_project and narrates the real result.
*/
const LIVE = !!process.env.COMMITEA_MODEL_LIVE
const BASE = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
const MODEL = process.env.COMMITEA_MODEL_SMALL ?? '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,
)
})

View File

@@ -0,0 +1,69 @@
/**
* 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[]) => Promise<CompletionResult>
messages: ChatMessage[]
tools: ToolDecl[]
execute: ToolExecutor
maxSteps?: number
}): 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)
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, [])
convo.push({ role: 'assistant', content: final.content })
return { content: final.content, steps, messages: convo }
}

View File

@@ -0,0 +1,46 @@
/**
* 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'],
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.',
},
filters: {
type: 'object',
properties: {
issueId: { type: 'number' },
query: { type: 'string' },
limit: { type: 'number' },
},
},
},
required: ['view'],
},
}
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_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.',
'Forecasts are ranges, never single dates. Refer to issues as #<number>.',
'Be brief and plain. A sentence or two is usually enough. No preamble, no bullet-point dumps.',
].join(' ')

View File

@@ -0,0 +1,239 @@
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 }
}
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('standup', undefined, snap, asOf)).toEqual({ notImplemented: 'standup' })
})
})

View File

@@ -0,0 +1,123 @@
/**
* 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[]
}
export interface ChatClient {
complete(messages: ChatMessage[], tools?: ToolDecl[]): 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) {
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 res = await fetchImpl(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: '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)}`)
}
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,
})),
}
},
}
}

View File

@@ -0,0 +1,22 @@
/**
* 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

@@ -0,0 +1,146 @@
/**
* `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).
*
* v0 serves focus / board / calibration / issue. The remaining views
* (milestone / runway / standup / search) 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 })),
}
}
/** 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)
default:
return { notImplemented: view }
}
}

View File

@@ -0,0 +1,121 @@
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,
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([])
})
})

View File

@@ -0,0 +1,127 @@
/**
* 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
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { describeChange, type IssueChange, planIssueChange } 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')
})
})

View File

@@ -0,0 +1,59 @@
/**
* 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 }
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
}
function axisFor(change: IssueChange): { 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: IssueChange): 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}`
}

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
import { COLD_START_PRIORS, forecast, priorForEstimate } from './forecast-v0.js'
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over }
}
describe('priorForEstimate', () => {
it('returns the exact prior for a bucket day count', () => {
expect(priorForEstimate(3)).toBe(COLD_START_PRIORS[3])
expect(priorForEstimate(8)).toBe(COLD_START_PRIORS[8])
})
it('snaps a non-bucket estimate to the nearest bucket', () => {
expect(priorForEstimate(4)).toBe(COLD_START_PRIORS[3]) // tie → smaller bucket
expect(priorForEstimate(6)).toBe(COLD_START_PRIORS[5])
expect(priorForEstimate(100)).toBe(COLD_START_PRIORS[8])
})
it('every prior is pessimistic (median actual runs longer than the estimate)', () => {
for (const p of Object.values(COLD_START_PRIORS)) expect(p.mu).toBeGreaterThan(0)
})
})
describe('forecast', () => {
const scope = [
issue(1, { estimateDays: 2 }),
issue(2, { estimateDays: 3 }),
issue(3, { estimateDays: 1 }),
]
it('is reproducible: same seed → identical result', () => {
const a = forecast(scope, [], { trials: 500, seed: 42 })
const b = forecast(scope, [], { trials: 500, seed: 42 })
expect(a).toEqual(b)
})
it('percentiles are ordered p50 <= p80 <= p95', () => {
const f = forecast(scope, [], { trials: 3000 })
expect(f.p50Day).toBeLessThanOrEqual(f.p80Day)
expect(f.p80Day).toBeLessThanOrEqual(f.p95Day)
})
it('the burn-up curve is monotonic in both fraction and day', () => {
const f = forecast(scope, [], { trials: 3000 })
expect(f.curve).toHaveLength(3)
for (let i = 1; i < f.curve.length; i++) {
expect(f.curve[i].fraction).toBeGreaterThan(f.curve[i - 1].fraction)
expect(f.curve[i].p50Day).toBeGreaterThan(f.curve[i - 1].p50Day)
}
expect(f.curve.at(-1)!.fraction).toBeCloseTo(1)
})
it('lo/mid/hi are ordered within each cone point', () => {
const f = forecast(scope, [], { trials: 3000 })
for (const pt of f.curve) {
expect(pt.p10Day).toBeLessThanOrEqual(pt.p50Day)
expect(pt.p50Day).toBeLessThanOrEqual(pt.p90Day)
}
})
it('the median landing runs longer than the raw estimate sum (pessimism)', () => {
const rawSum = 2 + 3 + 1
const f = forecast(scope, [], { trials: 4000 })
expect(f.p50Day).toBeGreaterThan(rawSum)
})
it('reports scope and cold-start honestly', () => {
const f = forecast(scope, [], { trials: 100 })
expect(f.scope).toBe(3)
expect(f.coldStart).toBe(true)
})
it('a dependency cycle yields an empty, zeroed forecast', () => {
const edges: DependencyEdge[] = [
{ issue: 1, dependsOn: 2 },
{ issue: 2, dependsOn: 1 },
]
const f = forecast([issue(1), issue(2)], edges, { trials: 100 })
expect(f.scope).toBe(0)
expect(f.curve).toEqual([])
expect(f.p80Day).toBe(0)
})
it('an empty scope forecasts nothing', () => {
const f = forecast([], [], { trials: 100 })
expect(f.scope).toBe(0)
expect(f.curve).toEqual([])
})
it('unestimated issues fall back to the default-estimate prior', () => {
const f = forecast([issue(1, { estimateDays: null }), issue(2, { estimateDays: null })], [], {
trials: 500,
})
expect(f.scope).toBe(2)
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)
})
})

View File

@@ -0,0 +1,193 @@
/**
* Monte Carlo forecast, v0. The LLM never does this — it's plain, seeded,
* reproducible code (evidence-based scheduling). It samples an actual duration
* per open issue from a lognormal prior, walks the scheduler's deterministic
* order on a single serial worker, and reads percentiles off the resulting
* completion-day distribution.
*
* v0 simplifications (each a later slice, not a hack):
* - single serial worker; per-person capacity + parallelism is #8.
* - cold-start priors only; the team's empirical calibration fit lands at
* n >= 20 closed-with-estimate issues (#5 supplies the actuals).
* - forecast covers remaining (open) scope from today forward; the historical
* burn-up "actual" polyline needs lifecycle event dates (#5).
*/
import {
type DependencyEdge,
schedule,
type SchedulableIssue,
} from '../scheduler/scheduler-v0.js'
export interface LognormalPrior {
/** Median log-ratio: sampled median duration = estimate * e^mu. */
mu: number
/** Spread of log(actual / estimate). */
sigma: number
}
/**
* Code-resident cold-start priors (D3). Lognormal on log(actual / estimate):
* sampled actual = estimateDays * exp(N(mu, sigma)). mu > 0 encodes the honest
* fact that actuals run long; sigma shrinks as tickets grow (a snag doubles a
* 1-day task but barely dents an 8-day one). The team's fitted model replaces
* these at n >= 20 (see calibration model in pm-state.md).
*/
export const COLD_START_PRIORS: Record<number, LognormalPrior> = {
1: { mu: 0.25, sigma: 0.55 },
2: { mu: 0.22, sigma: 0.48 },
3: { mu: 0.2, sigma: 0.44 },
5: { mu: 0.18, sigma: 0.4 },
8: { mu: 0.16, sigma: 0.36 },
}
export const PRIOR_BUCKETS = [1, 2, 3, 5, 8]
/** Nearest estimate bucket (ties resolve to the smaller bucket). */
export function nearestBucket(days: number): number {
let best = PRIOR_BUCKETS[0]
for (const b of PRIOR_BUCKETS) {
if (Math.abs(b - days) < Math.abs(best - days)) best = b
}
return 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 {
/** Simulation trials. More = smoother tails, linear cost. */
trials?: number
/** PRNG seed. Fixed by default so a forecast is reproducible. */
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
}
/** 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 {
/** Cumulative fraction of remaining scope complete (0 < f <= 1]. */
fraction: number
/** Working-day offset from today at the p10 / p50 / p90 of reaching it. */
p10Day: number
p50Day: number
p90Day: number
}
export interface Forecast {
/** Open issues in scope (== scheduled count). */
scope: number
trials: number
/** true while code priors drive the sim; false once the empirical fit is in (v1). */
coldStart: boolean
/** Working-day offsets from today for the whole scope landing. */
p50Day: number
p80Day: number
p95Day: number
/** Burn-up cone, one point per scheduled issue, fraction ascending. */
curve: BurnUpPoint[]
}
const DEFAULT_TRIALS = 2000
const DEFAULT_SEED = 0x9e3779b9
/** mulberry32 — small, fast, seedable PRNG (no reliance on Math.random). */
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** BoxMuller standard normal from a uniform PRNG. */
function standardNormal(rng: () => number): number {
let u1 = rng()
const u2 = rng()
if (u1 < 1e-12) u1 = 1e-12
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
}
function percentile(sortedAsc: number[], q: number): number {
const idx = Math.min(sortedAsc.length - 1, Math.max(0, Math.round(q * (sortedAsc.length - 1))))
return sortedAsc[idx]
}
/**
* Monte Carlo over the deterministic schedule order. Order is fixed (it's
* derived from estimate labels + dependencies, not sampled), so only durations
* vary across trials — the cone stretches, it never reorders.
*/
export function forecast(
issues: SchedulableIssue[],
edges: DependencyEdge[],
options: ForecastOptions = {},
): Forecast {
const trials = options.trials ?? DEFAULT_TRIALS
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 n = order.length
if (n === 0) {
return { scope: 0, trials, coldStart, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] }
}
const priors = order.map((it) => durationParams(it.durationDays, options.model))
const rng = mulberry32(seed)
// endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t.
const endByRank: number[][] = Array.from({ length: n }, () => new Array<number>(trials))
for (let t = 0; t < trials; t++) {
let cursor = 0
for (let k = 0; k < n; k++) {
const p = priors[k]
const sampled = order[k].durationDays * Math.exp(p.mu + p.sigma * standardNormal(rng))
cursor += sampled
endByRank[k][t] = cursor
}
}
const curve: BurnUpPoint[] = endByRank.map((row, k) => {
const sorted = [...row].sort((a, b) => a - b)
return {
fraction: (k + 1) / n,
p10Day: percentile(sorted, 0.1),
p50Day: percentile(sorted, 0.5),
p90Day: percentile(sorted, 0.9),
}
})
const total = [...endByRank[n - 1]].sort((a, b) => a - b)
return {
scope: n,
trials,
coldStart,
p50Day: percentile(total, 0.5),
p80Day: percentile(total, 0.8),
p95Day: percentile(total, 0.95),
curve,
}
}

View File

@@ -0,0 +1,228 @@
import { describe, expect, it } from 'vitest'
import { createGiteaClient, normalizeIssue, normalizeTimeline } from './client.js'
import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js'
const CONFIG: GiteaConfig = {
baseUrl: 'https://gitea.stephenmann.io',
token: 'tok_secret',
owner: 'christian',
repo: 'commitea',
}
/** Raw gitea issue JSON as the API returns it. */
const RAW_ISSUE = {
number: 9,
title: 'Deterministic scheduler',
body: 'topo order → dated plan',
state: 'open',
labels: [{ name: 'est/5d' }, { name: 'p/1' }, { name: 'deadline/hard' }],
milestone: { id: 7, title: 'P2 — Scheduler + Monte Carlo', due_on: '2026-09-01T00:00:00Z' },
assignee: { login: 'christian' },
assignees: [{ login: 'christian' }, { login: 'stephen' }],
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T01:00:00Z',
closed_at: null,
html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/9',
}
/** A stub fetch that records calls and returns a canned 200 JSON body. */
function stubFetch(body: unknown, status = 200): {
fetch: FetchLike
calls: { url: string; init?: GiteaRequestInit }[]
} {
const calls: { url: string; init?: GiteaRequestInit }[] = []
const fetch: FetchLike = (url, init) => {
calls.push({ url, init })
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
})
}
return { fetch, calls }
}
describe('normalizeTimeline', () => {
it('maps known gitea event types to lifecycle signals and drops the rest', () => {
const events = normalizeTimeline([
{ type: 'label', created_at: '2026-01-06T09:00:00Z' },
{ type: 'milestone', created_at: '2026-01-06T09:05:00Z' },
{ type: 'commit_ref', created_at: '2026-01-07T12:00:00Z' },
{ type: 'pull_ref', created_at: '2026-01-08T12:00:00Z' },
{ type: 'comment', created_at: '2026-01-08T13:00:00Z' }, // dropped
{ type: 'add_dependency', created_at: '2026-01-08T14:00:00Z' }, // dropped
{ type: 'close', created_at: '2026-01-12T09:00:00Z' },
])
expect(events).toEqual([
{ type: 'triage', at: '2026-01-06T09:00:00Z' },
{ type: 'triage', at: '2026-01-06T09:05:00Z' },
{ type: 'commit', at: '2026-01-07T12:00:00Z' },
{ type: 'pull', at: '2026-01-08T12:00:00Z' },
{ type: 'close', at: '2026-01-12T09:00:00Z' },
])
})
it('skips events missing a timestamp', () => {
expect(normalizeTimeline([{ type: 'commit_ref', created_at: '' }])).toEqual([])
})
})
describe('createGiteaClient.getIssue', () => {
it('returns a normalized, typed issue from canned JSON', async () => {
const { fetch } = stubFetch(RAW_ISSUE)
const issue = await createGiteaClient(CONFIG, fetch).getIssue(9)
expect(issue.number).toBe(9)
expect(issue.title).toBe('Deterministic scheduler')
expect(issue.state).toBe('open')
expect(issue.labels).toEqual(['est/5d', 'p/1', 'deadline/hard'])
expect(issue.url).toBe('https://gitea.stephenmann.io/christian/commitea/issues/9')
})
it('precomputes scheduler facts via extractLabelFacts', async () => {
const { fetch } = stubFetch(RAW_ISSUE)
const issue = await createGiteaClient(CONFIG, fetch).getIssue(9)
expect(issue.facts.estimateDays).toBe(5)
expect(issue.facts.priority).toBe(1)
expect(issue.facts.hardDeadline).toBe(true)
})
it('maps the milestone ref and assignees', async () => {
const { fetch } = stubFetch(RAW_ISSUE)
const issue = await createGiteaClient(CONFIG, fetch).getIssue(9)
expect(issue.milestone).toEqual({
id: 7,
title: 'P2 — Scheduler + Monte Carlo',
dueOn: '2026-09-01T00:00:00Z',
})
expect(issue.assignee).toBe('christian')
expect(issue.assignees).toEqual(['christian', 'stephen'])
})
it('hits the configured repo path with the token auth header', async () => {
const { fetch, calls } = stubFetch(RAW_ISSUE)
await createGiteaClient(CONFIG, fetch).getIssue(9)
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe(
'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9',
)
expect(calls[0].init?.headers?.Authorization).toBe('token tok_secret')
})
it('tolerates a trailing slash on baseUrl', async () => {
const { fetch, calls } = stubFetch(RAW_ISSUE)
await createGiteaClient({ ...CONFIG, baseUrl: 'https://gitea.stephenmann.io/' }, fetch).getIssue(9)
expect(calls[0].url).toBe(
'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9',
)
})
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('throws GiteaApiError carrying status + body on a non-2xx response', async () => {
const { fetch } = stubFetch('not found', 404)
const client = createGiteaClient(CONFIG, fetch)
await expect(client.getIssue(999)).rejects.toBeInstanceOf(GiteaApiError)
await expect(client.getIssue(999)).rejects.toMatchObject({ status: 404, body: 'not found' })
})
})
describe('createGiteaClient.listIssues', () => {
/** Stub fetch that pages 50-at-a-time and records the paths it was asked for. */
function pagedFetch(pages: unknown[][]): { fetch: FetchLike; paths: string[] } {
const paths: string[] = []
const fetch: FetchLike = (url) => {
paths.push(url)
const page = Number(/[?&]page=(\d+)/.exec(url)?.[1] ?? '1')
const body = pages[page - 1] ?? []
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(''),
})
}
return { fetch, paths }
}
it('walks every page until a short page ends it', async () => {
const full = Array.from({ length: 50 }, (_, i) => ({ ...RAW_ISSUE, number: i + 1 }))
const tail = [{ ...RAW_ISSUE, number: 51 }]
const { fetch, paths } = pagedFetch([full, tail])
const issues = await createGiteaClient(CONFIG, fetch).listIssues()
expect(issues).toHaveLength(51)
expect(paths).toHaveLength(2) // stopped after the short second page
expect(paths[0]).toContain('/issues?type=issues&state=all&page=1&limit=50')
})
it('excludes pull requests even if the API returns them', async () => {
const { fetch } = pagedFetch([
[{ ...RAW_ISSUE, number: 1 }, { ...RAW_ISSUE, number: 2, pull_request: { url: 'x' } }],
])
const issues = await createGiteaClient(CONFIG, fetch).listIssues()
expect(issues.map((i) => i.number)).toEqual([1])
})
it('passes the state filter through', async () => {
const { fetch, paths } = pagedFetch([[]])
await createGiteaClient(CONFIG, fetch).listIssues({ state: 'open' })
expect(paths[0]).toContain('state=open')
})
})
describe('normalizeIssue', () => {
it('defaults missing labels/assignees/body to empty and maps a closed state', () => {
const issue = normalizeIssue({
number: 1,
title: 'bare issue',
body: null,
state: 'closed',
labels: null,
milestone: null,
assignee: null,
assignees: null,
created_at: '2026-07-01T00:00:00Z',
updated_at: '2026-07-02T00:00:00Z',
closed_at: '2026-07-02T00:00:00Z',
html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/1',
})
expect(issue.state).toBe('closed')
expect(issue.body).toBe('')
expect(issue.labels).toEqual([])
expect(issue.assignee).toBeNull()
expect(issue.assignees).toEqual([])
expect(issue.milestone).toBeNull()
expect(issue.facts.estimateDays).toBeNull()
expect(issue.closedAt).toBe('2026-07-02T00:00:00Z')
})
})

View File

@@ -0,0 +1,226 @@
/**
* Gitea read client. Config + injected fetch → typed, normalized domain
* objects with scheduler facts precomputed. `getIssue` proved the vertical
* slice (P1-1); `listIssues`/`listMilestones` are the reconcile reads (P1-4)
* layered on the same `request` seam. Pagination is handled here so callers
* get the full set.
*/
import { type LifecycleEvent, type LifecycleEventType } from '../lifecycle/lifecycle-v0.js'
import { extractLabelFacts } from '../labels/label-schema.js'
import {
GiteaApiError,
type FetchLike,
type GiteaConfig,
type GiteaIssue,
type GiteaLabel,
type GiteaMilestone,
type GiteaMilestoneRef,
} from './types.js'
/** The subset of gitea's raw issue JSON we read. */
interface RawLabel {
name: string
}
interface RawMilestone {
id: number
title: string
due_on: string | null
}
interface RawUser {
login: string
}
interface RawIssue {
number: number
title: string
body: string | null
state: string
labels: RawLabel[] | null
milestone: RawMilestone | null
assignee: RawUser | null
assignees: RawUser[] | null
created_at: string
updated_at: string
closed_at: string | null
html_url: string
/** Present (non-null) when the row is actually a pull request. */
pull_request?: unknown
}
interface RawMilestoneFull {
id: number
title: string
description: string | null
due_on: string | null
state: string
open_issues: number
closed_issues: number
}
/** The subset of a gitea timeline comment we read. */
interface RawTimelineComment {
type: string
created_at: string
}
/** gitea timeline `type` → our lifecycle signal. Unmapped types are dropped. */
const TIMELINE_TYPE_MAP: Record<string, LifecycleEventType> = {
label: 'triage',
milestone: 'triage',
assignees: 'triage',
commit_ref: 'commit',
pull_ref: 'pull',
close: 'close',
reopen: 'reopen',
}
/** Map a raw gitea timeline to normalized lifecycle events. Pure. */
export function normalizeTimeline(raw: RawTimelineComment[]): LifecycleEvent[] {
const out: LifecycleEvent[] = []
for (const c of raw) {
const type = TIMELINE_TYPE_MAP[c.type]
if (type && c.created_at) out.push({ type, at: c.created_at })
}
return out
}
export interface ListIssuesOptions {
/** @default 'all' */
state?: 'open' | 'closed' | 'all'
}
export interface GiteaClient {
/** Fetch one issue by its per-repo index, normalized. */
getIssue(index: number): Promise<GiteaIssue>
/** Fetch every issue (all pages), pull requests excluded. */
listIssues(opts?: ListIssuesOptions): Promise<GiteaIssue[]>
/** Fetch every milestone (all pages). */
listMilestones(): Promise<GiteaMilestone[]>
/** The issue indices this issue depends on (its blockers). */
getIssueDependencies(index: number): Promise<number[]>
/** Normalized lifecycle events for one issue (all pages of its timeline). */
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
/** Every label defined on the repo (id + name), for name→id resolution. */
listLabels(): Promise<GiteaLabel[]>
/** Replace an issue's entire label set with the given label ids. Write. */
setIssueLabels(index: number, labelIds: number[]): Promise<void>
}
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */
export function normalizeIssue(raw: RawIssue): GiteaIssue {
const labels = (raw.labels ?? []).map((l) => l.name)
const milestone: GiteaMilestoneRef | null = raw.milestone
? { id: raw.milestone.id, title: raw.milestone.title, dueOn: raw.milestone.due_on }
: null
const assignees = (raw.assignees ?? []).map((u) => u.login)
return {
number: raw.number,
title: raw.title,
body: raw.body ?? '',
state: raw.state === 'closed' ? 'closed' : 'open',
labels,
facts: extractLabelFacts(labels),
milestone,
assignee: raw.assignee?.login ?? null,
assignees,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
closedAt: raw.closed_at,
url: raw.html_url,
}
}
/** Map a raw milestone resource to the normalized shape. Pure. */
export function normalizeMilestone(raw: RawMilestoneFull): GiteaMilestone {
return {
id: raw.id,
title: raw.title,
description: raw.description ?? '',
dueOn: raw.due_on,
state: raw.state === 'closed' ? 'closed' : 'open',
openIssues: raw.open_issues,
closedIssues: raw.closed_issues,
}
}
const PAGE_LIMIT = 50
export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): GiteaClient {
const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1`
const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}`
async function request(path: string, init?: { method?: string; body?: unknown }): Promise<unknown> {
const method = init?.method ?? 'GET'
const hasBody = init?.body !== undefined
const res = await fetchImpl(`${repoBase}${path}`, {
method,
headers: {
Authorization: `token ${config.token}`,
Accept: 'application/json',
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
},
body: hasBody ? JSON.stringify(init!.body) : undefined,
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `${method} ${path} failed (${res.status})`, body)
}
// writes may reply 204 No Content
return res.status === 204 ? null : res.json()
}
/** Follow gitea's page-limit pagination until a short page is returned. */
async function requestAll<T>(build: (page: number) => string): Promise<T[]> {
const out: T[] = []
for (let page = 1; ; page++) {
const batch = (await request(build(page))) as T[]
out.push(...batch)
if (batch.length < PAGE_LIMIT) return out
}
}
return {
async getIssue(index) {
return normalizeIssue((await request(`/issues/${index}`)) as RawIssue)
},
async listIssues(opts) {
const state = opts?.state ?? 'all'
const raw = await requestAll<RawIssue>(
(page) => `/issues?type=issues&state=${state}&page=${page}&limit=${PAGE_LIMIT}`,
)
// `type=issues` should exclude PRs, but guard anyway.
return raw.filter((r) => r.pull_request == null).map(normalizeIssue)
},
async listMilestones() {
const raw = await requestAll<RawMilestoneFull>(
(page) => `/milestones?state=all&page=${page}&limit=${PAGE_LIMIT}`,
)
return raw.map(normalizeMilestone)
},
async getIssueDependencies(index) {
const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[]
return raw.map((d) => d.number)
},
async getIssueTimeline(index) {
const raw = await requestAll<RawTimelineComment>(
(page) => `/issues/${index}/timeline?page=${page}&limit=${PAGE_LIMIT}`,
)
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 } })
},
}
}

View File

@@ -0,0 +1,96 @@
/**
* Types for the gitea read client. Deliberately minimal: `@commitea/core` is
* pure (no DOM lib, no Electron, no network), so we don't reach for the global
* `fetch`/`Response`/`RequestInit` DOM types — we define the small structural
* surface we actually use. The real `globalThis.fetch` is structurally
* assignable to `FetchLike`, so the desktop main process passes it verbatim
* while tests pass a stub.
*/
import type { LabelFacts } from '../labels/label-schema.js'
/** Connection config. `baseUrl` is the instance root (no `/api/v1`). */
export interface GiteaConfig {
/** e.g. `https://gitea.stephenmann.io` — trailing slash tolerated. */
baseUrl: string
/** Personal access token (scopes: issue/repository/user). */
token: string
owner: string
repo: string
}
export interface GiteaRequestInit {
method?: string
headers?: Record<string, string>
body?: string
}
/** The slice of a `fetch` Response we consume. */
export interface GiteaHttpResponse {
ok: boolean
status: number
json(): Promise<unknown>
text(): Promise<string>
}
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). */
export interface GiteaMilestoneRef {
id: number
title: string
/** ISO date the milestone is due, or null. */
dueOn: string | null
}
/** A milestone resource with its open/closed counts. */
export interface GiteaMilestone {
id: number
title: string
description: string
dueOn: string | null
state: 'open' | 'closed'
openIssues: number
closedIssues: number
}
/**
* Normalized issue — camelCase, label names flattened, scheduler-facing
* `facts` precomputed via `extractLabelFacts`. This is the domain shape the
* rest of CommiTea works with; raw gitea JSON never escapes this module.
*/
export interface GiteaIssue {
number: number
title: string
body: string
state: 'open' | 'closed'
labels: string[]
facts: LabelFacts
milestone: GiteaMilestoneRef | null
/** Primary assignee username, or null. */
assignee: string | null
/** All assignee usernames (includes the primary). */
assignees: string[]
createdAt: string
updatedAt: string
closedAt: string | null
url: string
}
/** Thrown on a non-2xx gitea response; carries the status + raw body. */
export class GiteaApiError extends Error {
constructor(
readonly status: number,
message: string,
readonly body?: string,
) {
super(message)
this.name = 'GiteaApiError'
}
}

View File

@@ -8,3 +8,83 @@ export {
parsePriorityLabel,
} from './labels/label-schema.js'
export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js'
export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
export { GiteaApiError } from './gitea/types.js'
export type {
FetchLike,
GiteaConfig,
GiteaHttpResponse,
GiteaIssue,
GiteaLabel,
GiteaMilestone,
GiteaMilestoneRef,
GiteaRequestInit,
} from './gitea/types.js'
export { describeChange, planIssueChange } from './changes/apply-changes-v0.js'
export type { IssueChange, LabelPlan } from './changes/apply-changes-v0.js'
export {
inferColumnV0,
inferLifecycle,
LIFECYCLE_COLUMNS,
workingDaysBetween,
} from './lifecycle/lifecycle-v0.js'
export type {
LifecycleColumn,
LifecycleEvent,
LifecycleEventType,
LifecycleInference,
LifecycleStages,
} from './lifecycle/lifecycle-v0.js'
export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js'
export type {
DependencyEdge,
Focus,
SchedulableIssue,
ScheduledItem,
SchedulePlan,
} from './scheduler/scheduler-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,
calibrationSamples,
COLD_START_THRESHOLD,
fitCalibration,
toDurationModel,
} from './calibration/calibration-v0.js'
export type {
BucketFit,
CalibrationModel,
CalibrationSample,
PersonBias,
} from './calibration/calibration-v0.js'
export { createChatClient } from './agent/chat-client.js'
export type { ChatClient, ChatMessage, CompletionResult, ModelConfig, 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 { QUERY_PROJECT_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'

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import {
inferColumnV0,
inferLifecycle,
type LifecycleEvent,
workingDaysBetween,
} from './lifecycle-v0.js'
const base = { state: 'open' as const, labels: [] as string[], milestone: null }
describe('inferColumnV0', () => {
it('closed issues are done', () => {
expect(inferColumnV0({ ...base, state: 'closed' })).toBe('done')
})
it('open + labelled is triage', () => {
expect(inferColumnV0({ ...base, labels: ['est/2d'] })).toBe('triage')
})
it('open + milestoned is triage', () => {
expect(inferColumnV0({ ...base, milestone: { id: 6, title: 'P1', dueOn: null } })).toBe('triage')
})
it('open + bare is diagnosis', () => {
expect(inferColumnV0(base)).toBe('diagnosis')
})
it('never guesses steeping or review in v0', () => {
// even a closed, labelled, milestoned issue resolves to a real column, never the event-only ones
const col = inferColumnV0({ ...base, labels: ['est/2d', 'p/1'], milestone: { id: 6, title: 'P1', dueOn: null } })
expect(['steeping', 'review']).not.toContain(col)
})
})
describe('workingDaysBetween', () => {
it('counts weekdays in [start, end), excluding weekends', () => {
// Mon 2026-01-05 → Mon 2026-01-12 spans a full week = 5 working days
expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-12'))).toBe(5)
})
it('is 0 for same day or reversed', () => {
expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-05'))).toBe(0)
expect(workingDaysBetween(new Date('2026-01-12'), new Date('2026-01-05'))).toBe(0)
})
it('skips a weekend inside the interval', () => {
// Fri 2026-01-09 → Mon 2026-01-12: only Fri counts (Sat/Sun excluded) = 1
expect(workingDaysBetween(new Date('2026-01-09'), new Date('2026-01-12'))).toBe(1)
})
})
describe('inferLifecycle', () => {
const openBase = { ...base, createdAt: '2026-01-05T09:00:00Z', closedAt: null }
const asOf = new Date('2026-01-14T09:00:00Z')
const ev = (type: LifecycleEvent['type'], at: string): LifecycleEvent => ({ type, at })
it('bare open issue with no events is diagnosis', () => {
const inf = inferLifecycle(openBase, [], asOf)
expect(inf.column).toBe('diagnosis')
expect(inf.stages.opened).toBe(openBase.createdAt)
expect(inf.actualWorkingDays).toBeNull()
expect(inf.steepingDays).toBeNull()
})
it('a triage event (or a current label) moves it to triage', () => {
expect(inferLifecycle(openBase, [ev('triage', '2026-01-06T09:00:00Z')], asOf).column).toBe('triage')
expect(inferLifecycle({ ...openBase, labels: ['p/1'] }, [], asOf).column).toBe('triage')
})
it('a commit ref moves an open issue to steeping and counts its working age', () => {
const inf = inferLifecycle(
{ ...openBase, labels: ['est/2d'] },
[ev('triage', '2026-01-05T10:00:00Z'), ev('commit', '2026-01-07T12:00:00Z')],
asOf,
)
expect(inf.column).toBe('steeping')
expect(inf.stages.steeping).toBe('2026-01-07T12:00:00Z')
// Wed 2026-01-07 → Wed 2026-01-14 = 5 working days
expect(inf.steepingDays).toBe(5)
})
it('a pull ref outranks a commit ref → review', () => {
const inf = inferLifecycle(
openBase,
[ev('commit', '2026-01-07T12:00:00Z'), ev('pull', '2026-01-08T12:00:00Z')],
asOf,
)
expect(inf.column).toBe('review')
expect(inf.steepingDays).toBeNull() // only steeping issues carry an age
})
it('closed → done, with actual working time from work-start to close', () => {
const inf = inferLifecycle(
{ ...openBase, state: 'closed', closedAt: '2026-01-12T09:00:00Z' },
[ev('commit', '2026-01-07T09:00:00Z'), ev('pull', '2026-01-08T09:00:00Z'), ev('close', '2026-01-12T09:00:00Z')],
asOf,
)
expect(inf.column).toBe('done')
expect(inf.stages.done).toBe('2026-01-12T09:00:00Z')
// Wed 2026-01-07 → Mon 2026-01-12 = Wed,Thu,Fri = 3 working days
expect(inf.actualWorkingDays).toBe(3)
})
it('takes the earliest event of each kind for stage timestamps', () => {
const inf = inferLifecycle(
openBase,
[ev('commit', '2026-01-09T09:00:00Z'), ev('commit', '2026-01-07T09:00:00Z')],
asOf,
)
expect(inf.stages.steeping).toBe('2026-01-07T09:00:00Z')
})
it('falls back to opened when a closed issue has no work-start signal', () => {
const inf = inferLifecycle(
{ ...openBase, state: 'closed', closedAt: '2026-01-08T09:00:00Z' },
[ev('close', '2026-01-08T09:00:00Z')],
asOf,
)
// Mon 2026-01-05 → Thu 2026-01-08 = Mon,Tue,Wed = 3 working days
expect(inf.actualWorkingDays).toBe(3)
})
})

View File

@@ -0,0 +1,129 @@
/**
* Lifecycle inference.
*
* `inferColumnV0` places the coarse three columns from a single issues-list
* read (state/labels/milestone). `inferLifecycle` (#5) adds the issue timeline:
* first commit ref → Steeping, first PR ref → In review, close → Done — and
* derives the estimate-vs-actual working time that feeds calibration (D3).
*/
import type { GiteaIssue } from '../gitea/types.js'
export type LifecycleColumn = 'diagnosis' | 'triage' | 'steeping' | 'review' | 'done'
export const LIFECYCLE_COLUMNS: readonly LifecycleColumn[] = [
'diagnosis',
'triage',
'steeping',
'review',
'done',
]
/**
* Closed → done. Open with any human intent applied (a label or a milestone)
* → triage. Open and bare → diagnosis. Never returns steeping/review — that's
* `inferLifecycle`, which reads the event stream.
*/
export function inferColumnV0(issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone'>): LifecycleColumn {
if (issue.state === 'closed') return 'done'
const hasIntent = issue.labels.length > 0 || issue.milestone !== null
return hasIntent ? 'triage' : 'diagnosis'
}
/** A timeline signal, normalized from gitea's raw event stream. */
export type LifecycleEventType = 'triage' | 'commit' | 'pull' | 'close' | 'reopen'
export interface LifecycleEvent {
type: LifecycleEventType
/** ISO timestamp. */
at: string
}
/** Timestamps for the stages an issue has reached (absent = not yet reached). */
export interface LifecycleStages {
opened: string
/** First label / milestone / assignment. */
triaged?: string
/** First commit referencing the issue — work began. */
steeping?: string
/** First PR referencing the issue — in review. */
review?: string
/** Closed. */
done?: string
}
export interface LifecycleInference {
column: LifecycleColumn
stages: LifecycleStages
/**
* Working days from work-start (steeping → triaged → opened, first available)
* to done. Only for closed issues — this is the "actual" calibration learns
* from. null while open.
*/
actualWorkingDays: number | null
/** Working days the issue has been steeping (first commit → asOf); null unless currently steeping. */
steepingDays: number | null
}
const DAY_MS = 86_400_000
/**
* Whole working days (MonFri) in the half-open interval [start, end). Same day
* or reversed → 0. Day-granular by design — estimates are in days.
*/
export function workingDaysBetween(start: Date, end: Date): number {
const s = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())
const e = Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate())
if (e <= s) return 0
let count = 0
for (let t = s; t < e; t += DAY_MS) {
const dow = new Date(t).getUTCDay()
if (dow !== 0 && dow !== 6) count += 1
}
return count
}
/**
* The five-column inference. Column reflects the furthest stage still in play:
* closed → done; else an open PR ref → review; else a commit ref → steeping;
* else any triage signal (or a current label/milestone) → triage; else
* diagnosis. Stage timestamps are the earliest event of each kind.
*/
export function inferLifecycle(
issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone' | 'createdAt' | 'closedAt'>,
events: LifecycleEvent[],
asOf: Date,
): LifecycleInference {
const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at))
const firstOf = (type: LifecycleEventType) => sorted.find((e) => e.type === type)?.at
const stages: LifecycleStages = { opened: issue.createdAt }
const triagedAt = firstOf('triage')
const steepingAt = firstOf('commit')
const reviewAt = firstOf('pull')
if (triagedAt) stages.triaged = triagedAt
if (steepingAt) stages.steeping = steepingAt
if (reviewAt) stages.review = reviewAt
const doneAt = issue.state === 'closed' ? (issue.closedAt ?? firstOf('close')) : undefined
if (doneAt) stages.done = doneAt
let column: LifecycleColumn
if (issue.state === 'closed') column = 'done'
else if (reviewAt) column = 'review'
else if (steepingAt) column = 'steeping'
else if (triagedAt || issue.labels.length > 0 || issue.milestone !== null) column = 'triage'
else column = 'diagnosis'
let actualWorkingDays: number | null = null
if (stages.done) {
const start = stages.steeping ?? stages.triaged ?? stages.opened
actualWorkingDays = workingDaysBetween(new Date(start), new Date(stages.done))
}
let steepingDays: number | null = null
if (column === 'steeping' && stages.steeping) {
steepingDays = workingDaysBetween(new Date(stages.steeping), asOf)
}
return { column, stages, actualWorkingDays, steepingDays }
}

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { type DependencyEdge, schedule, type SchedulableIssue, selectFocus } from './scheduler-v0.js'
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over }
}
describe('schedule', () => {
it('lays open issues out serially with cumulative start/finish', () => {
const plan = schedule([issue(1, { estimateDays: 2 }), issue(2, { estimateDays: 3 })], [])
expect(plan.cycle).toBeNull()
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
// total span = sum of durations, no overlap (single serial worker)
const spans = plan.items.map((i) => [i.startDay, i.endDay])
expect(spans).toContainEqual([0, expect.any(Number)])
expect(byN[1].durationDays + byN[2].durationDays).toBe(5)
expect(Math.max(...plan.items.map((i) => i.endDay))).toBe(5)
})
it('respects dependencies: a dependency is scheduled before its dependent', () => {
// #2 depends on #1
const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }]
const plan = schedule([issue(2, { priority: 1 }), issue(1, { priority: 4 })], edges)
const order = plan.items.map((i) => i.number)
expect(order.indexOf(1)).toBeLessThan(order.indexOf(2))
// #2 records #1 as a blocker; #1 records #2 as blocked
const two = plan.items.find((i) => i.number === 2)!
expect(two.blockedBy).toEqual([1])
const one = plan.items.find((i) => i.number === 1)!
expect(one.blocks).toEqual([2])
})
it('breaks ties among ready roots by priority, then estimate', () => {
const plan = schedule(
[issue(1, { priority: 3 }), issue(2, { priority: 1 }), issue(3, { priority: 2 })],
[],
)
expect(plan.items.map((i) => i.number)).toEqual([2, 3, 1])
})
it('detects a dependency cycle instead of looping', () => {
const edges: DependencyEdge[] = [
{ issue: 1, dependsOn: 2 },
{ issue: 2, dependsOn: 1 },
]
const plan = schedule([issue(1), issue(2)], edges)
expect(plan.items).toHaveLength(0)
expect(plan.cycle).not.toBeNull()
expect(plan.cycle!.sort()).toEqual([1, 2])
})
it('drops dependencies on out-of-scope (closed) issues', () => {
// #2 depends on #9 which isn't in scope → treated as satisfied
const plan = schedule([issue(2)], [{ issue: 2, dependsOn: 9 }])
expect(plan.cycle).toBeNull()
expect(plan.items.find((i) => i.number === 2)!.blockedBy).toEqual([])
})
it('marks the longest dependency chain critical', () => {
// chain 1→2→3 (each 3d = 9d) vs standalone 4 (2d): 1,2,3 critical, 4 not
const edges: DependencyEdge[] = [
{ issue: 2, dependsOn: 1 },
{ issue: 3, dependsOn: 2 },
]
const issues = [
issue(1, { estimateDays: 3 }),
issue(2, { estimateDays: 3 }),
issue(3, { estimateDays: 3 }),
issue(4, { estimateDays: 2 }),
]
const plan = schedule(issues, edges)
const crit = plan.items.filter((i) => i.critical).map((i) => i.number).sort()
expect(crit).toEqual([1, 2, 3])
expect(plan.items.find((i) => i.number === 4)!.critical).toBe(false)
})
it('defaults a null estimate to DEFAULT_ESTIMATE_DAYS', () => {
const plan = schedule([issue(1, { estimateDays: null })], [])
expect(plan.items[0].durationDays).toBe(2)
})
})
describe('selectFocus', () => {
it('picks the first three of the ordered plan as now/next/later', () => {
const plan = schedule([issue(1, { priority: 1 }), issue(2, { priority: 2 }), issue(3, { priority: 3 })], [])
const focus = selectFocus(plan)
expect(focus.now?.number).toBe(1)
expect(focus.next?.number).toBe(2)
expect(focus.later?.number).toBe(3)
})
it('returns nulls past the end of a short plan', () => {
const focus = selectFocus(schedule([issue(1)], []))
expect(focus.now?.number).toBe(1)
expect(focus.next).toBeNull()
expect(focus.later).toBeNull()
})
})

View File

@@ -0,0 +1,182 @@
/**
* Deterministic scheduler, v0. Orders the open backlog by dependency topology,
* then priority, then estimate; lays it out on a single serial capacity and
* marks the critical path. The LLM never does this — it's plain code.
*
* v0 simplifications (each is a later slice, not a hack):
* - single serial worker; per-person capacity is #8.
* - point durations from estimate labels; Monte Carlo cones are #10.
* - a null estimate defaults to DEFAULT_ESTIMATE_DAYS.
* Dependencies on out-of-scope issues (closed/done, or not passed in) are
* treated as satisfied and dropped — you schedule what's left to do.
*/
export const DEFAULT_ESTIMATE_DAYS = 2
export interface SchedulableIssue {
number: number
title: string
labels: string[]
/** From label facts; null when unestimated. */
estimateDays: number | null
/** 1 (most urgent) … 4; null when unset. */
priority: number | null
}
export interface DependencyEdge {
issue: number
dependsOn: number
}
export interface ScheduledItem {
number: number
title: string
labels: string[]
/** 0-based position in the plan. */
order: number
/** Working-day offsets from now. */
startDay: number
endDay: number
durationDays: number
/** In-scope dependencies (all still open, so all still blocking). */
blockedBy: number[]
/** Issues that depend on this one. */
blocks: number[]
/** On a longest-duration dependency chain. */
critical: boolean
rationale: string
}
export interface SchedulePlan {
items: ScheduledItem[]
/** A dependency cycle (issue numbers) if one was found — the plan is then empty. */
cycle: number[] | null
}
function durationOf(issue: SchedulableIssue): number {
return issue.estimateDays ?? DEFAULT_ESTIMATE_DAYS
}
/** Best-first comparison among ready nodes: priority (1 first), then larger estimate, then number. */
function readyRank(a: SchedulableIssue, b: SchedulableIssue): number {
const pa = a.priority ?? 99
const pb = b.priority ?? 99
if (pa !== pb) return pa - pb
const da = durationOf(a)
const db = durationOf(b)
if (da !== db) return db - da
return a.number - b.number
}
function rationaleFor(item: {
critical: boolean
blockedBy: number[]
blocks: number[]
priority: number | null
}): string {
const blocksNote = item.blocks.length ? `unblocks ${item.blocks.map((n) => `#${n}`).join(', ')}` : ''
if (item.critical) return ['on the critical path', blocksNote].filter(Boolean).join(' · ')
if (item.blockedBy.length) return `waits on ${item.blockedBy.map((n) => `#${n}`).join(', ')}`
if (blocksNote) return blocksNote
if (item.priority != null) return `p/${item.priority} · ready`
return 'ready'
}
export function schedule(issues: SchedulableIssue[], edges: DependencyEdge[]): SchedulePlan {
const byNumber = new Map(issues.map((i) => [i.number, i]))
// keep only edges whose endpoints are both in scope
const scoped = edges.filter((e) => byNumber.has(e.issue) && byNumber.has(e.dependsOn))
const deps = new Map<number, number[]>() // issue → its dependencies
const dependents = new Map<number, number[]>() // issue → who depends on it
for (const i of issues) {
deps.set(i.number, [])
dependents.set(i.number, [])
}
for (const e of scoped) {
deps.get(e.issue)!.push(e.dependsOn)
dependents.get(e.dependsOn)!.push(e.issue)
}
// Kahn topo with priority tie-break
const indegree = new Map(issues.map((i) => [i.number, deps.get(i.number)!.length]))
const order: number[] = []
const ready = issues.filter((i) => indegree.get(i.number) === 0)
while (ready.length) {
ready.sort(readyRank)
const next = ready.shift()!
order.push(next.number)
for (const dep of dependents.get(next.number)!) {
const d = indegree.get(dep)! - 1
indegree.set(dep, d)
if (d === 0) ready.push(byNumber.get(dep)!)
}
}
if (order.length < issues.length) {
// cycle: report the nodes that never became ready
const stuck = issues.filter((i) => indegree.get(i.number)! > 0).map((i) => i.number)
return { items: [], cycle: stuck }
}
// longest-path (by duration) for critical-path marking
const longestThrough = new Map<number, number>() // node → longest chain duration passing through it
const before = new Map<number, number>() // longest duration of chain ending at node's start
for (const n of order) {
const depMax = Math.max(0, ...deps.get(n)!.map((d) => before.get(d)! + durationOf(byNumber.get(d)!)))
before.set(n, depMax)
}
const after = new Map<number, number>()
for (const n of [...order].reverse()) {
const depMax = Math.max(0, ...dependents.get(n)!.map((d) => after.get(d)! + durationOf(byNumber.get(d)!)))
after.set(n, depMax)
}
let globalMax = 0
for (const n of order) {
const total = before.get(n)! + durationOf(byNumber.get(n)!) + after.get(n)!
longestThrough.set(n, total)
globalMax = Math.max(globalMax, total)
}
// serial layout
let cursor = 0
const items: ScheduledItem[] = order.map((n, idx) => {
const issue = byNumber.get(n)!
const dur = durationOf(issue)
const start = cursor
cursor += dur
const blockedBy = deps.get(n)!
const blocks = dependents.get(n)!
const critical = globalMax > 0 && longestThrough.get(n) === globalMax
return {
number: n,
title: issue.title,
labels: issue.labels,
order: idx,
startDay: start,
endDay: cursor,
durationDays: dur,
blockedBy,
blocks,
critical,
rationale: rationaleFor({ critical, blockedBy, blocks, priority: issue.priority }),
}
})
return { items, cycle: null }
}
export interface Focus {
now: ScheduledItem | null
next: ScheduledItem | null
later: ScheduledItem | null
}
/** Now/Next/Later = the first three of the plan (already dependency- + priority-ordered). */
export function selectFocus(plan: SchedulePlan): Focus {
return {
now: plan.items[0] ?? null,
next: plan.items[1] ?? null,
later: plan.items[2] ?? null,
}
}

View File

@@ -241,6 +241,7 @@ __metadata:
resolution: "@commitea/desktop@workspace:apps/desktop"
dependencies:
"@commitea/core": "workspace:*"
"@playwright/test": "npm:^1.61.1"
"@types/node": "npm:^22.13.1"
"@types/react": "npm:^18.3.18"
"@types/react-dom": "npm:^18.3.5"
@@ -720,6 +721,17 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:^1.61.1":
version: 1.61.1
resolution: "@playwright/test@npm:1.61.1"
dependencies:
playwright: "npm:1.61.1"
bin:
playwright: cli.js
checksum: 10c0/45004139eaa7c7ec8129e4ed77a9d734aa09ed40b69162b871e4123f1d70217b7b73651431a9343ed5090d5fed11c6df1bff2a56ff4b87dc7b0371b5da87cf9c
languageName: node
linkType: hard
"@rolldown/pluginutils@npm:1.0.0-beta.27":
version: 1.0.0-beta.27
resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27"
@@ -1890,6 +1902,16 @@ __metadata:
languageName: node
linkType: hard
"fsevents@npm:2.3.2":
version: 2.3.2
resolution: "fsevents@npm:2.3.2"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3":
version: 2.3.3
resolution: "fsevents@npm:2.3.3"
@@ -1900,6 +1922,15 @@ __metadata:
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>":
version: 2.3.2
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::version=2.3.2&hash=df0bf1"
dependencies:
node-gyp: "npm:latest"
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin<compat/fsevents>":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::version=2.3.3&hash=df0bf1"
@@ -2458,6 +2489,30 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.61.1":
version: 1.61.1
resolution: "playwright-core@npm:1.61.1"
bin:
playwright-core: cli.js
checksum: 10c0/c28896ba82a602182e240ed4f9c467fb0dd9cb57d510f8aba9f25183fe1cde3a0105725a29baffa4157fe885bbb11e228032a2aad0424111584fde45303f07bd
languageName: node
linkType: hard
"playwright@npm:1.61.1":
version: 1.61.1
resolution: "playwright@npm:1.61.1"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.61.1"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/cea1bc4d2a64ec3ef683891606774029da7b8a8036ed98332565cec2f8e89549ca1fab9a89fbfba4e08e27e0d7e7d148031e965af66d5ff97bb3c34058cfcad0
languageName: node
linkType: hard
"postcss-import@npm:^15.1.0":
version: 15.1.0
resolution: "postcss-import@npm:15.1.0"