Compare commits

21 Commits

Author SHA1 Message Date
354ba9227e Merge pull request 'apply_changes: unified mutation tool — estimate/priority/assign/milestone, in-app + agent (#24)' (#53) from feat/apply-changes-assign-milestone into main
Reviewed-on: #53
2026-07-09 19:23:49 +00:00
2de3212210 Merge branch 'main' into feat/apply-changes-assign-milestone 2026-07-09 19:23:43 +00:00
86e9a49c08 Merge pull request 'Productionize: real data everywhere (Phase B) + shareable unsigned macOS .dmg (Phase C)' (#52) from feat/kill-fixtures into main
Reviewed-on: #52
2026-07-09 19:23:39 +00:00
Croissant Le Doux
4fc5b902c4 apply_changes UI: assign + milestone in the dialog and Reginald's tool (#24)
Surfaces the assign/milestone mutations end-to-end so they're usable in-app and
by the agent — the rest of #24.

Agent path:
- propose_change tool + system prompt now accept `assignee` (login/null) and
  `milestone` (id/null). ProposeChangeArgs + proposalsFor extended: a new
  ProposalContext (current assignee/milestone + milestones list) lets a proposal
  skip no-ops and label the milestone. ChangeProposal gains an always-present
  `summary` (plan is now label-only) — chat-panel, use-chat, and the model
  executor render `summary`, so non-label proposals display correctly.

Dialog path:
- Client `listCollaborators()` (prepends the repo owner — /collaborators omits
  them, so a solo-owner repo still has an assignable person). New
  `gitea:collaborators` bridge. The Adjust dialog gains Assignee + Milestone
  pickers (current values from the reconciled backlog); pending assign/remilestone
  changes flow through the existing apply path.

Tests: +4 core (assign/milestone proposals with no-op skip; collaborators
owner-prepend + no-double-add). 138 core green; core + desktop typecheck clean;
14 fixture e2e green; live-backlog now drives the pickers on real data.
Fixed stale P2 refs in live-backlog (P2 is shipped → correctly off the runway).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:54:03 -04:00
Croissant Le Doux
72dcd396f8 apply_changes: unify the mutation tool with assign + milestone (#24)
The write path handled only the est/* and p/* label axes. Extend it to the two
mutations dogfooding proved were missing — assignee and milestone — behind one
unified change model:

- IssueChange gains `assign` and `remilestone` kinds. `planIssueChange` is now
  typed to LabelChange (the label kinds only); `isLabelChange` narrows, and a new
  pure `summarizeChange(change, currentLabels?)` gives one confirm line for ANY
  kind (label kinds delegate to describeChange; field writes describe directly).
- GiteaClient gains `setIssueAssignees` / `setIssueMilestone` (PATCH /issues/{n},
  returning the updated issue; milestone 0 clears).
- The main `gitea:applyChange` handler branches: label swaps resolve ids +
  setIssueLabels as before; field writes call the new client methods. Snapshot
  invalidated either way so board + forecast catch up. ApplyChangeResult.plan is
  now optional (absent for field writes).
- issue-screen uses summarizeChange for its diff preview.

Tests: +5 covering isLabelChange, and summarizeChange for assign/unassign,
milestone set-by-title / by-id / removal, and label delegation. Full core suite
132 green; core + desktop typecheck clean.

Follow-up (rest of #24): surface assignee/milestone pickers in the Adjust dialog
and extend the agent's propose_change tool so Reginald can propose them too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:31:26 -04:00
Croissant Le Doux
3873e652f2 Gantt bars use the capacity-aware lane layout (match the forecast)
ganttView ran schedule() — the single-serial-worker layout — while the Monte
Carlo forecast ran scheduleWithCapacity() over the real lanes. So the bars you
saw didn't match what was forecast (serial 1.0/day vs the team's actual lanes).

Switch ganttView to scheduleWithCapacity(open, deps, workers): startDay/endDay
now come from the same lane layout each forecast trial uses, and `who` shows the
lane an issue actually landed on (falling back to assignee, then blank). The
dogfood harness had the same split — updated it to match and to print the lane
per row.

Verified: typecheck clean, 14 fixture e2e green, live-onboarding e2e renders the
real capacity-aware Gantt with no uncaught errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:23:01 -04:00
Croissant Le Doux
63f0ea1735 Dogfood harness: run CommiTea's engine on its own backlog (#31)
scripts/dogfood-report.ts drives the real @commitea/core engine (schedule +
capacity-aware Monte Carlo forecast + per-milestone runway) against the live
christian/commitea backlog and prints the project report each screen derives.
Read-only; `yarn tsx scripts/dogfood-report.ts`. Adds tsx as a devDependency.

First run surfaced two real gaps the fixtures hid: every open issue was
unassigned (so capacity load-balanced work onto the slow half-time lane and the
standup plan-per-person was empty) and no milestone had a due date (so Runway
couldn't judge on-track/at-risk). Both were fixed as PM actions on the repo via
the write path — all open issues assigned, milestone due dates synthesized from
the forecast — so the app's own numbers are now honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:53:28 -04:00
87a0ba4ba1 Merge branch 'main' into feat/kill-fixtures 2026-07-09 14:41:26 +00:00
62a521e7eb Merge pull request 'feat: config & onboarding — each teammate connects their own Gitea (productionize Phase A)' (#51) from feat/config-onboarding into main
Reviewed-on: #51
2026-07-09 14:41:20 +00:00
Croissant Le Doux
19897f7e53 Phase C: package as an unsigned macOS .dmg (electron-builder)
Adds shareable desktop packaging so the app can be handed to teammates.

- electron-builder@25 + electron-builder.yml: appId io.stephenmann.commitea,
  productName CommiTea, icon from the logo (build/icon.png, 1024²), macOS dmg
  targets for arm64 + x64. Ships only out/** (electron-vite already bundles
  renderer/preload/main with core + react inlined), npmRebuild off,
  electronVersion pinned to 34.5.8 (workspace hoisting defeats auto-detect).
- Unsigned by decision: mac.identity null, hardenedRuntime off. electron-builder
  ad-hoc signs so arm64 runs; not notarized — teammates right-click → Open once.
- Scripts: `yarn pack` (--dir sanity build), `yarn dist` (both dmgs). package.json
  gains version/author/description (required by electron-builder).
- README.md: dev/build/package commands + the Gatekeeper install step.

Verified: packaged arm64 app boots from a fresh profile (lands on onboarding);
`yarn dist` produced CommiTea-0.1.0-arm64.dmg (98M) and CommiTea-0.1.0.dmg (x64,
102M), each mounting with a drag-to-Applications layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:46:37 -04:00
Croissant Le Doux
c120f34a4a Phase B: real data for the last fixture screens
Every remaining fixture-only surface now renders reconciled gitea data,
with the fixture kept only as the no-config demo fallback.

- lib/views/project-data.ts: ProjectData — the uniform input (reconciled
  backlog + deps + timelines + calibration + workers + today) that AppShell
  assembles once and every view builder consumes.
- lib/views/{issue-detail,gantt-view,deps-graph,standup-view,inbox-view}.ts:
  pure builders, ProjectData → the fixture-shaped object each screen already
  renders. Real signals only; honest degradation where a signal isn't derivable
  (buffered p80 vs per-issue Monte Carlo; flat "idle in review"; no fabricated
  inbox mentions/outages).
- Screens take an optional `data?` and fall back to the fixture; AppShell wires
  the real view whenever the backlog is reconciled. Issue "blocks" chips and the
  rail inbox badge now resolve from real data too.
- Dev-only rail surfaces (First run / States / Primitives) gated on
  import.meta.env.DEV || demo — shown in dev + e2e, hidden in a packaged app.
  Rail host label reflects the connected instance.
- Extended the live onboarding e2e to click Board → Gantt → Deps → Issue
  sidecar → Standup → Inbox on real gitea data with a pageerror guard; 14
  fixture e2e stay green, typecheck + prod build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:38:52 -04:00
Croissant Le Doux
0dd26b8b05 feat: config & onboarding — each teammate connects their own Gitea (Phase A)
The foundation for a shareable team build. Replaces the .env.local-only dev config
with a real, per-teammate connection flow.

main:
- config-store.ts: token encrypted at rest via Electron safeStorage (OS keychain),
  config JSON in userData. Token lives only in main; renderer gets everything but.
- resolveConfig: saved config > .env.local (dev) > null; ignored under COMMITEA_E2E.
  pm-state repo defaults to `${repo}-pm-state`. resetClients() re-reads on change so
  saving config takes effect without a restart. gitea:status gains `demo` (e2e).
- IPC: config:get (no token), config:test (authed read validates token+repo),
  config:set (encrypt+save+reset), config:clear. Model bridge reads config.modelUrl
  and probes reachability — chat is "configured" only if a model actually answers;
  localhost default is dev-only (app.isPackaged gate).

renderer:
- ConnectScreen: real onboarding form (URL/owner/repo/PAT/optional model) → test →
  save. AppShell gates on it: demo → shell (fixtures/e2e); configured → shell (real);
  else → connect. Settings Connection card is real (repo/url/model/sidecar) with
  Reconfigure + Disconnect. Chat cleanly disables with a "no model" state instead of
  the scripted canned reply.

Verified: main + desktop typecheck clean, 14 fixture e2e green (demo mode unchanged),
live onboarding e2e: fresh app → connect form → validated PAT → real board (24 done /
10 open). COMMITEA_NO_ENV_LOCAL + COMMITEA_USERDATA are test hooks for the onboarding path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:09:53 -04:00
770c253625 Merge pull request 'feat: capacity-aware scheduling (#8) — real focus factors drive every forecast' (#50) from feat/capacity into main
Reviewed-on: #50
2026-07-09 05:00:20 +00:00
Croissant Le Doux
1636d6bada feat: capacity-aware scheduling (#8) — real focus factors drive every forecast
Turns the single-serial-worker scheduler into a capacity-aware, multi-lane one.
Configured team members become lanes; an issue runs on its assignee's lane (or the
earliest-free lane), its duration scaled by that lane's throughput
(focusFactor × allocation). Every forecast — Focus cone, Runway, milestone
drill-in — is now capacity-aware.

core (@commitea/core):
- capacity/capacity-v0: CapacityMember + capacityPerWorkday + parseCapacityConfig
  (clamps, drops invalid; degrades to []).
- scheduler/scheduler-capacity-v0: scheduleWithCapacity reuses the v0 topo order +
  critical path, re-lays work across lanes (layoutOnLanes, resolveLanes, makespan).
  Empty workers → the single serial plan verbatim.
- forecast() gains options.workers: each MC trial lays sampled durations across the
  lanes and takes the makespan; serial path unchanged. SchedulableIssue gains
  assignee; ScheduledItem gains worker.
- 11 new tests (parse/clamp, parallelism halves makespan, speed scaling, assignee
  routing, cross-lane deps, forecast makespan shrinks with lanes).

app:
- pm-state capacity/members.json read (readCapacity + pmstate:capacity bridge);
  useCapacity hook → workers; forecastBacklog/runwayView/milestoneView pass workers.
- Runway Capacity card shows the real config (person · focus · alloc · pd/day).

Config lives in pm-state (D4); seeded christian(0.8)/stephen(0.6×0.5). Degrades to
the fixture/serial when absent.

Verified: 128 core tests green, desktop typecheck clean, 14 fixture e2e green. Live:
the capacity card is real, and the P2 forecast shifts 32d→37d — honest, since real
focus factors (<1) replace the v0 focus-1.0 assumption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:59:03 -04:00
d80e1266ee Merge pull request 'feat: stream Reginald's replies token-by-token' (#49) from feat/streaming-chat into main
Reviewed-on: #49
2026-07-09 04:43:58 +00:00
cf03827cd5 Merge branch 'main' into feat/streaming-chat 2026-07-09 04:43:51 +00:00
69106b603d Merge pull request 'feat: Runway complete — per-milestone forecasts + real milestone drill-in' (#48) from feat/runway-real into main
Reviewed-on: #48
2026-07-09 04:43:46 +00:00
Croissant Le Doux
dbcdcda5e7 feat: stream Reginald's replies token-by-token
The 26b is slow (~30s/call); the chat now shows the answer forming instead of
freezing until it's done. The final prose streams over SSE; tool-calling turns
stay structured (no partial tokens), so streaming kicks in for the narration.

core (@commitea/core):
- chat-client.complete gains an optional onToken — when set, it requests
  stream:true and parses the OpenAI SSE stream, emitting content deltas and
  assembling streamed tool-call argument fragments into the final result.
- GiteaHttpResponse exposes the optional `body` stream (real fetch has it; stubs
  don't). agent-loop threads onToken to each completion.

app:
- model:chat forwards each delta to the renderer (event.sender.send); preload
  exposes model.onToken(cb) → unsubscribe. useChat accumulates the live stream
  into a growing bubble (with a cursor), replaced by the authoritative final
  content when the turn resolves. Unconfigured → scripted reply, unchanged.

Verified: 118 core tests green (2 streaming: SSE content deltas + tool-call
fragment assembly), desktop typecheck clean, 14 fixture e2e green. Live: a real
turn against gemma-4-26b assembles the correct answer via the streaming path
(live-reginald green) — the reply now renders token-by-token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:22:12 -04:00
Croissant Le Doux
57595852a4 feat: real Milestone drill-in — completes the Runway story
Clicking a milestone on Runway now opens its real detail: scope + done %, a Monte
Carlo cone over the remaining open work, and the milestone's issues grouped by
lifecycle column. Threaded the gitea milestone id through the Runway row → AppShell
→ a milestoneView().

- backlog.ts: milestoneView(id, ...) → { name, due, scope/done, forecast cone +
  range, groups by lifecycle column }. Reuses forecast + buildBurnUpData + lifecycle
  inference. null for an unknown id → the screen shows the demo fixture.
- RunwayMilestone gains an `id`; runwayView sets it; RunwayScreen.onOpenMilestone(id).
- MilestoneScreen takes optional `data`; renders real header/stats/cone/issue-groups
  when present, fixture otherwise.

Verified: desktop typecheck clean, 14 fixture e2e green. Live: clicking "P2 —
Scheduler + Monte Carlo" opens a real detail — 7 issues · est 20d, 0/7 done, cone
"80% Aug 17–26", issues in Triage/In-review from the real event stream (screenshot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:12:11 -04:00
Croissant Le Doux
ae46cb99b3 feat: real Runway — per-milestone Monte Carlo forecasts
The Runway milestone list is now real. Each open gitea milestone's open scope gets
its own Monte Carlo forecast (reusing the P2 engine); the p80 landing range is
shown, and compared to the milestone's due date (on track / at risk) when one
exists. Ranges, never point dates.

- backlog.ts: runwayView(issues, milestones, deps) → RunwayMilestone[] — per
  milestone: forecast its open scope, map p50..p90 to a date range, normalize the
  RunwayBar band across a shared horizon, tone/ note from due-vs-p80. Milestones
  with no open scope (shipped) are omitted; empty → the demo fixture.
- RunwayScreen takes optional `milestones`; AppShell feeds runwayView. The header's
  calibration note was already real (#1).

Scope: each milestone forecasts its remaining work *from today* independently —
they aren't scheduled relative to each other yet (so a smaller later phase can
show an earlier date). Cross-milestone sequencing is a refinement. Capacity stays
fixture — true per-person capacity (focus factor, allocation) is #8, config-driven.

Verified: desktop typecheck clean, 14 fixture e2e green. Live: Runway shows the
real P1/P2/P4/P5 milestones with per-milestone forecasts (e.g. "P2 — Scheduler +
Monte Carlo · 80% Aug 14–25 · 32d of work"); the fixture lists Beta/Pilot/v1.0,
so the real names prove it (new assertion + screenshot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:00:00 -04:00
403aef9d16 Merge pull request 'perf+persistence: the durable reconcile mirror (cache + disk)' (#47) from infra/reconcile-cache into main
Reviewed-on: #47
2026-07-09 03:53:29 +00:00
53 changed files with 5127 additions and 208 deletions

42
apps/desktop/README.md Normal file
View File

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

BIN
apps/desktop/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

View File

@@ -11,6 +11,7 @@ const MAIN = join(here, '..', 'out', 'main', 'index.js')
test.describe('live backlog', () => { test.describe('live backlog', () => {
test('The pot + Focus render real gitea data', async () => { test('The pot + Focus render real gitea data', async () => {
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test') test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
test.setTimeout(90_000)
const app = await electron.launch({ args: [MAIN], env: { ...process.env } }) const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow() const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded') await win.waitForLoadState('domcontentloaded')
@@ -41,6 +42,22 @@ test.describe('live backlog', () => {
await expect( await expect(
win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/), win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/),
).toBeVisible() ).toBeVisible()
// Real per-milestone forecasts — these milestone names come from gitea, not the
// fixture (Beta / Pilot-ready / v1.0). The runway shows milestones with open
// scope; P5 is active (P2 is fully shipped, so it's correctly omitted).
await expect(win.getByText(/P5 — Dogfood/)).toBeVisible()
// Real capacity config from pm-state (christian/stephen), not the fixture (Stephen/Ana K.)
await expect(win.getByText('christian', { exact: true })).toBeVisible()
await expect(win.getByText(/pd\/day/).first()).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' })
// Milestone drill-in — clicking a real (active) milestone opens its real detail
await win.getByText(/P5 — Dogfood/).click()
await expect(win.getByRole('heading', { name: 'P5 — Dogfood + polish' })).toBeVisible()
await expect(win.getByText(/\d+ issues · est \d+d/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-milestone.png'), fullPage: true, animations: 'disabled' })
await rail.getByRole('button', { name: 'Runway' }).click()
await win.getByRole('button', { name: 'Full report' }).click() await win.getByRole('button', { name: 'Full report' }).click()
await expect(win.getByText(/cold-start · \d+\/20|curve active · n ≥ 20/)).toBeVisible() 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' }) await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-calibration.png'), fullPage: true, animations: 'disabled' })
@@ -53,10 +70,15 @@ test.describe('live backlog', () => {
await win.getByRole('main').getByRole('link').first().click() await win.getByRole('main').getByRole('link').first().click()
await expect(win.getByText(/· stephen\/commitea/)).toBeVisible() await expect(win.getByText(/· stephen\/commitea/)).toBeVisible()
await win.getByRole('button', { name: 'Adjust' }).click() await win.getByRole('button', { name: 'Adjust' }).click()
await expect(win.getByText('Adjust estimate & priority')).toBeVisible() await expect(win.getByText('Adjust issue')).toBeVisible()
await win.getByRole('combobox').first().selectOption('est/8d') await win.getByRole('combobox').first().selectOption('est/8d')
await expect(win.getByText('Proposed label change')).toBeVisible() await expect(win.getByText('Proposed change')).toBeVisible()
await expect(win.getByText(/est\/8d/).last()).toBeVisible() await expect(win.getByText(/est\/8d/).last()).toBeVisible()
// the unified tool also drives assignee + milestone — the pickers render and diff
await expect(win.getByText('Assignee', { exact: true })).toBeVisible()
await expect(win.getByText('Milestone', { exact: true })).toBeVisible()
await win.getByRole('combobox').nth(2).selectOption('') // Assignee → Unassigned
await expect(win.getByText('unassign', { exact: true })).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' }) await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' })
await win.getByRole('button', { name: 'Cancel' }).click() // no mutation await win.getByRole('button', { name: 'Cancel' }).click() // no mutation

View File

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

View File

@@ -97,7 +97,7 @@ test('Directives: consequence diff + append-only ledger', async ({ app, window }
test('Settings: connection, schema, and appearance sync with theme', async ({ app, window }) => { test('Settings: connection, schema, and appearance sync with theme', async ({ app, window }) => {
await app.nav('Settings').click() await app.nav('Settings').click()
await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible()
await expect(window.getByText('Managed repos')).toBeVisible() await expect(window.getByRole('heading', { name: 'Connection' })).toBeVisible()
await app.screenshot('settings') await app.screenshot('settings')
// the Evening (dark) radio drives the shared theme // the Evening (dark) radio drives the shared theme
await window.getByText('Evening (dark)').click() await window.getByText('Evening (dark)').click()

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,13 +13,16 @@ import { dirname, join } from 'node:path'
import { import {
appendDirective, appendDirective,
createGiteaClient, createGiteaClient,
GiteaApiError,
type DirectiveEntry, type DirectiveEntry,
type GiteaClient, type GiteaClient,
type GiteaConfig, type GiteaConfig,
type GiteaLabel, type GiteaLabel,
type IssueChange, type IssueChange,
isLabelChange,
type LifecycleEvent, type LifecycleEvent,
makeDirectiveEntry, makeDirectiveEntry,
parseCapacityConfig,
parseDirectiveLog, parseDirectiveLog,
planIssueChange, planIssueChange,
type ProjectSnapshot, type ProjectSnapshot,
@@ -27,6 +30,7 @@ import {
} from '@commitea/core' } from '@commitea/core'
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-store.js'
import { loadSnapshot, saveSnapshot } from './snapshot-store.js' import { loadSnapshot, saveSnapshot } from './snapshot-store.js'
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
@@ -47,9 +51,15 @@ function loadEnvLocalToken(): string | undefined {
return undefined return undefined
} }
function resolveConfig(): GiteaConfig | null { /** Saved config wins (team build); a `.env.local` is the dev fallback; e2e uses fixtures. */
export function resolveConfig(): GiteaConfig | null {
// E2E runs against fixtures — never hit the network from the test harness. // E2E runs against fixtures — never hit the network from the test harness.
if (process.env.COMMITEA_E2E === '1') return null if (process.env.COMMITEA_E2E === '1') return null
const saved = loadConfig()
if (saved) return { baseUrl: saved.baseUrl, token: saved.token, owner: saved.owner, repo: saved.repo }
// COMMITEA_NO_ENV_LOCAL forces the onboarding path (no .env.local fallback) for testing.
if (process.env.COMMITEA_NO_ENV_LOCAL === '1') return null
// dev fallback — .env.local / env vars
const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken() const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken()
if (!token) return null if (!token) return null
return { return {
@@ -60,8 +70,23 @@ function resolveConfig(): GiteaConfig | null {
} }
} }
// Memoized client so both the gitea and model bridges share one instance. /** The pm-state repo name: configured, else `${repo}-pm-state`, else the env override. */
function pmStateRepoName(config: GiteaConfig): string {
return loadConfig()?.pmStateRepo ?? process.env.COMMITEA_PMSTATE_REPO ?? `${config.repo}-pm-state`
}
// Memoized clients; reset via resetClients() when the config changes.
let sharedClient: GiteaClient | null | undefined let sharedClient: GiteaClient | null | undefined
let pmStateClient: GiteaClient | null | undefined
/** Drop the cached clients + snapshot so the next call re-reads the new config. */
export function resetClients(): void {
sharedClient = undefined
pmStateClient = undefined
labelCache = null
invalidateSnapshot()
}
export function getGiteaClient(): GiteaClient | null { export function getGiteaClient(): GiteaClient | null {
if (sharedClient === undefined) { if (sharedClient === undefined) {
const config = resolveConfig() const config = resolveConfig()
@@ -72,13 +97,10 @@ export function getGiteaClient(): GiteaClient | null {
// The pm-state repo holds machine-derived state (the directive ledger). Same // The pm-state repo holds machine-derived state (the directive ledger). Same
// token/host as the work repo, a different repo (the purity split, decisions D4). // token/host as the work repo, a different repo (the purity split, decisions D4).
let pmStateClient: GiteaClient | null | undefined
export function getPmStateClient(): GiteaClient | null { export function getPmStateClient(): GiteaClient | null {
if (pmStateClient === undefined) { if (pmStateClient === undefined) {
const config = resolveConfig() const config = resolveConfig()
pmStateClient = config pmStateClient = config ? createGiteaClient({ ...config, repo: pmStateRepoName(config) }, fetch) : null
? createGiteaClient({ ...config, repo: process.env.COMMITEA_PMSTATE_REPO ?? 'commitea-pm-state' }, fetch)
: null
} }
return pmStateClient return pmStateClient
} }
@@ -109,6 +131,19 @@ export async function readDirectives(client: GiteaClient) {
return parseDirectiveLog(text) return parseDirectiveLog(text)
} }
const CAPACITY_PATH = 'capacity/members.json'
/** Read the capacity config from the pm-state repo (empty when absent). */
export async function readCapacity(client: GiteaClient) {
const file = await client.getFile(CAPACITY_PATH)
if (!file) return []
try {
return parseCapacityConfig(JSON.parse(Buffer.from(file.contentBase64, 'base64').toString('utf8')))
} catch {
return []
}
}
/** Full reconcile: issues + milestones + native deps + lifecycle timelines. */ /** Full reconcile: issues + milestones + native deps + lifecycle timelines. */
export async function reconcileSnapshot( export async function reconcileSnapshot(
client: GiteaClient, client: GiteaClient,
@@ -178,20 +213,38 @@ export function bootSnapshot(): (Snapshot & { savedAt: string }) | null {
/** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */ /** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */
export const AGENT_SNAPSHOT_TTL_MS = 30_000 export const AGENT_SNAPSHOT_TTL_MS = 30_000
export function registerGiteaIpc(): void { // Label cache is reset with the clients (module-level so resetClients can clear it).
const client = getGiteaClient() let labelCache: GiteaLabel[] | null = null
const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null async function resolveLabelIds(client: GiteaClient, names: string[]): Promise<number[]> {
const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id]))
if (!labelCache) labelCache = await client.listLabels()
let byName = lookup()
if (names.some((n) => !byName.has(n))) {
labelCache = await client.listLabels() // a name we don't know — refetch once
byName = lookup()
}
return names.map((n) => byName.get(n)).filter((id): id is number => id != null)
}
ipcMain.handle('gitea:status', () => ({ configured: !!client, repo })) export function registerGiteaIpc(): void {
ipcMain.handle('gitea:status', () => {
const cfg = resolveConfig()
return {
configured: !!cfg,
repo: cfg ? `${cfg.owner}/${cfg.repo}` : null,
demo: process.env.COMMITEA_E2E === '1',
}
})
// Instant boot: the last persisted snapshot, shown before the fresh reconcile lands. // Instant boot: the last persisted snapshot, shown before the fresh reconcile lands.
ipcMain.handle('gitea:boot', () => { ipcMain.handle('gitea:boot', () => {
if (!client) return { configured: false } if (!getGiteaClient()) return { configured: false }
const persisted = bootSnapshot() const persisted = bootSnapshot()
return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false } return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false }
}) })
ipcMain.handle('gitea:reconcile', async () => { ipcMain.handle('gitea:reconcile', async () => {
const client = getGiteaClient()
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
try { try {
// explicit UI sync — force fresh, and warm the cache for agent tool calls // explicit UI sync — force fresh, and warm the cache for agent tool calls
@@ -206,36 +259,37 @@ export function registerGiteaIpc(): void {
}) })
ipcMain.handle('gitea:getIssue', async (_event, index: number) => { ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
const client = getGiteaClient()
if (!client) return null if (!client) return null
return client.getIssue(index) return client.getIssue(index)
}) })
// Cached label list for name→id resolution; refreshed on demand if a name misses. // The write path (apply_changes), applied only after the renderer's propose-
let labelCache: GiteaLabel[] | null = null // approve. Label swaps (est/p) return the plan; field writes (assign, milestone)
async function resolveLabelIds(names: string[]): Promise<number[]> { // return the freshly-read issue directly. Either way the snapshot is invalidated
if (!client) return [] // so the board + forecast reflect the change.
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) => { ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
const client = getGiteaClient()
if (!client) return { ok: false as const, reason: 'unconfigured' as const } if (!client) return { ok: false as const, reason: 'unconfigured' as const }
const current = await client.getIssue(change.issue)
const plan = planIssueChange(current.labels, change) if (isLabelChange(change)) {
if (plan.noop) return { ok: true as const, plan, issue: current } const current = await client.getIssue(change.issue)
const ids = await resolveLabelIds(plan.labels) const plan = planIssueChange(current.labels, change)
await client.setIssueLabels(change.issue, ids) if (plan.noop) return { ok: true as const, plan, issue: current }
const issue = await client.getIssue(change.issue) const ids = await resolveLabelIds(client, plan.labels)
invalidateSnapshot() // the board + forecast must reflect the label change await client.setIssueLabels(change.issue, ids)
return { ok: true as const, plan, issue } const issue = await client.getIssue(change.issue)
invalidateSnapshot()
return { ok: true as const, plan, issue }
}
// Field writes — the client returns the updated issue.
const issue =
change.kind === 'assign'
? await client.setIssueAssignees(change.issue, change.assignee ? [change.assignee] : [])
: await client.setIssueMilestone(change.issue, change.milestone)
invalidateSnapshot()
return { ok: true as const, issue }
}) })
// capture_work filing: open each approved issue with its est/* + p/* labels. // capture_work filing: open each approved issue with its est/* + p/* labels.
@@ -243,11 +297,12 @@ export function registerGiteaIpc(): void {
ipcMain.handle( ipcMain.handle(
'gitea:createIssues', 'gitea:createIssues',
async (_event, issues: { title: string; body?: string; estimate?: string; priority?: string }[]) => { async (_event, issues: { title: string; body?: string; estimate?: string; priority?: string }[]) => {
const client = getGiteaClient()
if (!client) return { ok: false as const, reason: 'unconfigured' as const } if (!client) return { ok: false as const, reason: 'unconfigured' as const }
const created: { number: number; title: string }[] = [] const created: { number: number; title: string }[] = []
for (const it of issues) { for (const it of issues) {
const names = [it.estimate, it.priority].filter((n): n is string => !!n) const names = [it.estimate, it.priority].filter((n): n is string => !!n)
const labelIds = await resolveLabelIds(names) const labelIds = await resolveLabelIds(client, names)
const issue = await client.createIssue({ title: it.title, body: it.body, labelIds }) const issue = await client.createIssue({ title: it.title, body: it.body, labelIds })
created.push({ number: issue.number, title: issue.title }) created.push({ number: issue.number, title: issue.title })
} }
@@ -266,4 +321,55 @@ export function registerGiteaIpc(): void {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) } return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
} }
}) })
// Read the capacity config from the pm-state repo (for capacity-aware forecasts).
ipcMain.handle('pmstate:capacity', async () => {
const pm = getPmStateClient()
if (!pm) return { ok: false as const, reason: 'unconfigured' as const, members: [] }
try {
return { ok: true as const, members: await readCapacity(pm) }
} catch {
return { ok: true as const, members: [] }
}
})
// Assignable people (repo collaborators) for the Adjust dialog's assignee picker.
ipcMain.handle('gitea:collaborators', async () => {
const client = getGiteaClient()
if (!client) return []
try {
return await client.listCollaborators()
} catch {
return []
}
})
// ---- config (team onboarding) ----
ipcMain.handle('config:get', () => publicConfig())
ipcMain.handle('config:set', (_event, cfg: AppConfig) => {
saveConfig(cfg)
resetClients() // new config takes effect without a restart
return { ok: true as const }
})
ipcMain.handle('config:clear', () => {
clearConfig()
resetClients()
return { ok: true as const }
})
// Validate a token + repo before saving: any authed read on the repo proves access.
ipcMain.handle('config:test', async (_event, cfg: AppConfig) => {
try {
const c = createGiteaClient(
{ baseUrl: cfg.baseUrl.replace(/\/+$/, ''), owner: cfg.owner.trim(), repo: cfg.repo.trim(), token: cfg.token },
fetch,
)
await c.listLabels()
return { ok: true as const }
} catch (e) {
return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) }
}
})
} }

View File

@@ -2,6 +2,9 @@ import { join } from 'node:path'
import { BrowserWindow, app, shell } from 'electron' import { BrowserWindow, app, shell } from 'electron'
// Test hook: isolate config/cache to a throwaway dir (must run before any getPath).
if (process.env.COMMITEA_USERDATA) app.setPath('userData', process.env.COMMITEA_USERDATA)
import { registerGiteaIpc } from './gitea.js' import { registerGiteaIpc } from './gitea.js'
import { registerModelIpc } from './model.js' import { registerModelIpc } from './model.js'

View File

@@ -13,7 +13,6 @@ import {
type ChangeProposal, type ChangeProposal,
type ChatMessage, type ChatMessage,
createChatClient, createChatClient,
describeChange,
type ModelRouter, type ModelRouter,
type ProjectView, type ProjectView,
proposalsFor, proposalsFor,
@@ -24,8 +23,9 @@ import {
runAgentTurn, runAgentTurn,
toDirectiveInput, toDirectiveInput,
} from '@commitea/core' } from '@commitea/core'
import { ipcMain } from 'electron' import { app, ipcMain } from 'electron'
import { loadConfig } from './config-store.js'
import { import {
AGENT_SNAPSHOT_TTL_MS, AGENT_SNAPSHOT_TTL_MS,
appendDirectiveEntry, appendDirectiveEntry,
@@ -37,7 +37,11 @@ import {
/** Small local model for prose + the read tool; big model reserved for later decomposition. */ /** Small local model for prose + the read tool; big model reserved for later decomposition. */
function resolveModelRouter(): ModelRouter | null { function resolveModelRouter(): ModelRouter | null {
if (process.env.COMMITEA_E2E === '1') return null // e2e uses the scripted fixture Reginald 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' // Config wins (team build); env is the dev override; localhost is a dev convenience only.
const baseUrl =
loadConfig()?.modelUrl ??
process.env.COMMITEA_MODEL_URL ??
(app.isPackaged ? undefined : 'http://localhost:1234/v1')
if (!baseUrl) return null if (!baseUrl) return null
return { return {
small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' }, small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' },
@@ -45,6 +49,19 @@ function resolveModelRouter(): ModelRouter | null {
} }
} }
/** Probe the endpoint for a usable model; null when unreachable (chat then stays off). */
async function probeModel(baseUrl: string): Promise<string | null> {
try {
const model = await resolveLoadedModel(baseUrl, process.env.COMMITEA_MODEL_SMALL ?? '')
// resolveLoadedModel only returns a real id when the server answered; the default
// fallback means unreachable, so confirm with a lightweight models call.
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`)
return res.ok ? model : null
} catch {
return null
}
}
/** /**
* Resolve which model to actually ask for. An explicit env override wins; * Resolve which model to actually ask for. An explicit env override wins;
* otherwise ask the server which model is *loaded* (LM Studio's native * otherwise ask the server which model is *loaded* (LM Studio's native
@@ -78,19 +95,23 @@ async function resolveLoadedModel(baseUrl: string, override: string): Promise<st
} }
export function registerModelIpc(): void { export function registerModelIpc(): void {
const router = resolveModelRouter()
ipcMain.handle('model:status', async () => { ipcMain.handle('model:status', async () => {
if (!router) return { configured: false, model: null } const r = resolveModelRouter() // resolve fresh so a saved modelUrl takes effect
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model) if (!r) return { configured: false, model: null }
return { configured: true, model } const model = await probeModel(r.small.baseUrl) // only "configured" if a model actually answers
return { configured: !!model, model }
}) })
ipcMain.handle('model:chat', async (_event, messages: ChatMessage[]) => { ipcMain.handle('model:chat', async (event, messages: ChatMessage[]) => {
const router = resolveModelRouter()
if (!router) return { ok: false as const, reason: 'unconfigured' as const } if (!router) return { ok: false as const, reason: 'unconfigured' as const }
const client = getGiteaClient() const client = getGiteaClient()
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model) const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
const chat = createChatClient({ ...router.small, model }, fetch) const chat = createChatClient({ ...router.small, model }, fetch)
// stream the model's prose to the renderer token-by-token
const onToken = (delta: string) => {
if (!event.sender.isDestroyed()) event.sender.send('model:chat:token', delta)
}
// Proposals the model formulates this turn; the renderer approves them (the // Proposals the model formulates this turn; the renderer approves them (the
// write happens through gitea:applyChange, never inside the loop). // write happens through gitea:applyChange, never inside the loop).
@@ -108,10 +129,19 @@ export function registerModelIpc(): void {
const a = (args ?? {}) as ProposeChangeArgs const a = (args ?? {}) as ProposeChangeArgs
const issue = await client.getIssue(a.issue).catch(() => null) const issue = await client.getIssue(a.issue).catch(() => null)
if (!issue) return { error: `issue #${a.issue} not found` } if (!issue) return { error: `issue #${a.issue} not found` }
const built = proposalsFor(a, issue.labels, issue.title) // Milestone titles + current assignee/milestone let a proposal label the
// milestone and skip a no-op assign/move.
const milestones = (a.milestone !== undefined ? await client.listMilestones().catch(() => []) : []).map(
(m) => ({ id: m.id, title: m.title }),
)
const built = proposalsFor(a, issue.labels, issue.title, {
currentAssignee: issue.assignee,
currentMilestoneId: issue.milestone?.id ?? null,
milestones,
})
proposals.push(...built) proposals.push(...built)
return built.length return built.length
? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) } ? { proposed: built.map((p) => ({ issue: a.issue, diff: p.summary })) }
: { proposed: [], note: 'no change — already at that value' } : { proposed: [], note: 'no change — already at that value' }
} }
if (name === 'record_directive') { if (name === 'record_directive') {
@@ -129,10 +159,11 @@ export function registerModelIpc(): void {
try { try {
const turn = await runAgentTurn({ const turn = await runAgentTurn({
complete: (m, t) => chat.complete(m, t), complete: (m, t, ot) => chat.complete(m, t, ot),
messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages], messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages],
tools: REGINALD_TOOLS, tools: REGINALD_TOOLS,
execute, execute,
onToken,
}) })
return { ok: true as const, content: turn.content, steps: turn.steps, proposals } return { ok: true as const, content: turn.content, steps: turn.steps, proposals }
} catch (e) { } catch (e) {
@@ -144,6 +175,7 @@ export function registerModelIpc(): void {
// decomposition (with one loaded local model, that's the loaded one). Returns // decomposition (with one loaded local model, that's the loaded one). Returns
// a proposal; nothing is filed until the Capture tray approves it. // a proposal; nothing is filed until the Capture tray approves it.
ipcMain.handle('model:capture', async (_event, braindump: string) => { ipcMain.handle('model:capture', async (_event, braindump: string) => {
const router = resolveModelRouter()
if (!router) return { ok: false as const, reason: 'unconfigured' as const } if (!router) return { ok: false as const, reason: 'unconfigured' as const }
const model = await resolveLoadedModel(router.big.baseUrl, router.big.model) const model = await resolveLoadedModel(router.big.baseUrl, router.big.model)
const chat = createChatClient({ ...router.big, model }, fetch) const chat = createChatClient({ ...router.big, model }, fetch)

View File

@@ -15,16 +15,36 @@ const api = {
applyChange: (change: unknown) => ipcRenderer.invoke('gitea:applyChange', change), applyChange: (change: unknown) => ipcRenderer.invoke('gitea:applyChange', change),
/** File a set of captured issues with their est/* + p/* labels. */ /** File a set of captured issues with their est/* + p/* labels. */
createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues), createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues),
/** Assignable people (repo collaborators) for the assignee picker. */
collaborators: () => ipcRenderer.invoke('gitea:collaborators'),
}, },
pmstate: { pmstate: {
/** Read the directive ledger from the pm-state repo. */ /** Read the directive ledger from the pm-state repo. */
directives: () => ipcRenderer.invoke('pmstate:directives'), directives: () => ipcRenderer.invoke('pmstate:directives'),
/** Read the capacity config from the pm-state repo. */
capacity: () => ipcRenderer.invoke('pmstate:capacity'),
},
config: {
/** The saved config (never the token) — null when unset. */
get: () => ipcRenderer.invoke('config:get'),
/** Validate a token + repo before saving. */
test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg),
/** Save config (token encrypted in main); takes effect without restart. */
set: (cfg: unknown) => ipcRenderer.invoke('config:set', cfg),
/** Forget the saved config. */
clear: () => ipcRenderer.invoke('config:clear'),
}, },
model: { model: {
/** Whether a model endpoint is configured (else the UI keeps the scripted Reginald). */ /** Whether a model endpoint is configured (else the UI keeps the scripted Reginald). */
status: () => ipcRenderer.invoke('model:status'), status: () => ipcRenderer.invoke('model:status'),
/** One agent turn: messages in, Reginald's prose + the tools it consulted out. */ /** One agent turn: messages in, Reginald's prose + the tools it consulted out. */
chat: (messages: unknown) => ipcRenderer.invoke('model:chat', messages), chat: (messages: unknown) => ipcRenderer.invoke('model:chat', messages),
/** Subscribe to streamed prose tokens for the in-flight turn; returns an unsubscribe. */
onToken: (cb: (delta: string) => void) => {
const listener = (_e: unknown, delta: string) => cb(delta)
ipcRenderer.on('model:chat:token', listener)
return () => ipcRenderer.removeListener('model:chat:token', listener)
},
/** Decompose a braindump into a proposed issue set (capture_work). */ /** Decompose a braindump into a proposed issue set (capture_work). */
capture: (braindump: string) => ipcRenderer.invoke('model:capture', braindump), capture: (braindump: string) => ipcRenderer.invoke('model:capture', braindump),
}, },

View File

@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { COLUMNS, type BoardColumn, type BoardIssue, type IssueRef } from '../../data/fixtures.js' import { COLUMNS, type BoardColumn, type BoardIssue, type DepsData, type GanttData, type IssueRef } from '../../data/fixtures.js'
import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js' import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js'
import { EmptyState } from '../shell/states.js' import { EmptyState } from '../shell/states.js'
import { GanttView } from './gantt-view.js' import { GanttView } from './gantt-view.js'
@@ -12,10 +12,14 @@ import { DepsGraph } from './deps-graph.js'
export function BoardScreen({ export function BoardScreen({
onOpenIssue, onOpenIssue,
columns = COLUMNS, columns = COLUMNS,
gantt,
deps,
loading = false, loading = false,
}: { }: {
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
columns?: BoardColumn[] columns?: BoardColumn[]
gantt?: GanttData
deps?: DepsData
loading?: boolean loading?: boolean
}) { }) {
const [tab, setTab] = React.useState('board'); const [tab, setTab] = React.useState('board');
@@ -90,9 +94,9 @@ export function BoardScreen({
</div> </div>
) )
) : tab === 'deps' ? ( ) : tab === 'deps' ? (
<DepsGraph onOpenIssue={onOpenIssue} /> <DepsGraph onOpenIssue={onOpenIssue} data={deps} />
) : ( ) : (
<GanttView onOpenIssue={onOpenIssue} /> <GanttView onOpenIssue={onOpenIssue} data={gantt} />
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,117 @@
import React from 'react'
import type { PublicConfig } from '../../global.js'
import logoIcon from '../../design/assets/logo-icon.png'
import { Button, Card, Input } from '../ui/index.js'
/**
* Real onboarding — each teammate connects their own Gitea with a personal access
* token. The token is validated (an authed read on the repo) then saved encrypted
* in the main process (never exposed to this renderer). Optional model URL wires
* Reginald; leave it blank and chat simply stays off.
*/
export function ConnectScreen({ onConnected, existing }: { onConnected: () => void; existing?: PublicConfig | null }) {
const [baseUrl, setBaseUrl] = React.useState(existing?.baseUrl ?? 'https://gitea.stephenmann.io')
const [owner, setOwner] = React.useState(existing?.owner ?? '')
const [repo, setRepo] = React.useState(existing?.repo ?? '')
const [token, setToken] = React.useState('')
const [modelUrl, setModelUrl] = React.useState(existing?.modelUrl ?? '')
const [state, setState] = React.useState<'idle' | 'testing' | 'saving'>('idle')
const [error, setError] = React.useState<string | null>(null)
const cfg = () => ({ baseUrl, owner, repo, token, modelUrl: modelUrl.trim() || undefined })
const ready = baseUrl.trim() && owner.trim() && repo.trim() && (token.trim() || existing?.hasToken)
const connect = async () => {
setError(null)
setState('testing')
// reuse the saved token when editing and the field is left blank
const payload = token.trim() ? cfg() : { ...cfg(), token: '' }
if (token.trim()) {
const test = await window.commitea.config.test(cfg()).catch(() => ({ ok: false, error: 'unreachable' }))
if (!test.ok) {
setState('idle')
setError(
test.error === '404'
? "Couldn't find that repo — check owner/name and that the token can see it."
: test.error === '401' || test.error === '403'
? 'The token was rejected — check it has repo + issue scopes.'
: `Connection failed${test.error ? ` (${test.error})` : ''}.`,
)
return
}
}
setState('saving')
await window.commitea.config.set(payload)
onConnected()
}
const field = (
label: string,
value: string,
set: (v: string) => void,
opts: { placeholder?: string; type?: string; hint?: string } = {},
) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
<Input
label={label}
value={value}
type={opts.type}
placeholder={opts.placeholder}
onChange={(e) => set(e.target.value)}
/>
{opts.hint ? <span style={{ font: 'var(--text-caption)', color: 'var(--ink-3)' }}>{opts.hint}</span> : null}
</div>
)
return (
<div
style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--surface-app)',
padding: 24,
}}
>
<Card style={{ width: 460, maxWidth: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<img src={logoIcon} width="28" height="28" alt="" style={{ borderRadius: 7, display: 'block' }} />
<span style={{ font: '400 24px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
Commi<span style={{ color: 'var(--accent-text)' }}>Tea</span>
</span>
</div>
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: '0 0 18px' }}>
Connect your Gitea. Your token is stored encrypted on this machine and never leaves it.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{field('Gitea URL', baseUrl, setBaseUrl, { placeholder: 'https://gitea.example.com' })}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{field('Owner', owner, setOwner, { placeholder: 'your-org' })}
{field('Repository', repo, setRepo, { placeholder: 'your-repo' })}
</div>
{field('Access token', token, setToken, {
type: 'password',
placeholder: existing?.hasToken ? '•••••••• (saved — leave blank to keep)' : 'gitea PAT · scopes: repo + issue',
})}
{field('Model URL', modelUrl, setModelUrl, {
placeholder: 'http://localhost:1234/v1',
hint: 'Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off.',
})}
</div>
{error ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--danger)', margin: '12px 0 0' }}>{error}</p>
) : null}
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<Button onClick={() => void connect()} disabled={!ready || state !== 'idle'}>
{state === 'testing' ? 'Checking…' : state === 'saving' ? 'Connecting…' : existing ? 'Save' : 'Connect'}
</Button>
</div>
</Card>
</div>
)
}

View File

@@ -1,11 +1,11 @@
import React from 'react' import React from 'react'
import { DEPS, type IssueRef } from '../../data/fixtures.js' import { DEPS, type DepsData, type IssueRef } from '../../data/fixtures.js'
import { Tag, Icon } from '../ui/index.js' import { Tag, Icon } from '../ui/index.js'
// Dependency graph drill-in — layered DAG, critical path in spruce // Dependency graph drill-in — layered DAG, critical path in spruce
export function DepsGraph({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: DepsData }) {
const g = DEPS const g = data ?? DEPS
const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84 const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84
const X = (c: number) => PAD + c * COLW const X = (c: number) => PAD + c * COLW

View File

@@ -1,11 +1,11 @@
import React from 'react' import React from 'react'
import { GANTT, type IssueRef } from '../../data/fixtures.js' import { GANTT, type GanttData, type IssueRef } from '../../data/fixtures.js'
import { Icon } from '../ui/index.js' import { Icon } from '../ui/index.js'
// Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails // Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails
export function GanttView({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: GanttData }) {
const g = GANTT; const g = data ?? GANTT;
const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30; const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30;
const chartW = g.days * DAYW; const chartW = g.days * DAYW;

View File

@@ -9,13 +9,15 @@ export function InboxScreen({
onOpenDirectives, onOpenDirectives,
readIds, readIds,
setReadIds, setReadIds,
data,
}: { }: {
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
onOpenDirectives: () => void onOpenDirectives: () => void
readIds: number[] readIds: number[]
setReadIds: React.Dispatch<React.SetStateAction<number[]>> setReadIds: React.Dispatch<React.SetStateAction<number[]>>
data?: InboxItem[]
}) { }) {
const all = INBOX const all = data ?? INBOX
const [tab, setTab] = React.useState('all') const [tab, setTab] = React.useState('all')
const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id) const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id)

View File

@@ -2,13 +2,12 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { import {
describeChange,
type EstimateLabel, type EstimateLabel,
ESTIMATE_LABELS, ESTIMATE_LABELS,
type IssueChange, type IssueChange,
planIssueChange,
type PriorityLabel, type PriorityLabel,
PRIORITY_LABELS, PRIORITY_LABELS,
summarizeChange,
} from '@commitea/core' } from '@commitea/core'
import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js' import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js'
@@ -18,18 +17,28 @@ const NONE = '—'
export function IssueScreen({ export function IssueScreen({
issue, issue,
detail,
onBack, onBack,
onOpenIssue, onOpenIssue,
canWrite = false, canWrite = false,
onApplyChange, onApplyChange,
collaborators = [],
milestones = [],
currentAssignee = null,
currentMilestoneId = null,
}: { }: {
issue: IssueRef issue: IssueRef
detail?: IssueDetail
onBack: () => void onBack: () => void
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
canWrite?: boolean canWrite?: boolean
onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }> onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>
collaborators?: { login: string; name: string }[]
milestones?: { id: number; title: string }[]
currentAssignee?: string | null
currentMilestoneId?: number | null
}) { }) {
const det: IssueDetail = ISSUE_DETAIL[issue.id] || { const det: IssueDetail = detail ?? ISSUE_DETAIL[issue.id] ?? {
state: 'triage', state: 'triage',
assignee: 'Stephen', assignee: 'Stephen',
milestone: 'Beta', milestone: 'Beta',
@@ -60,25 +69,40 @@ export function IssueScreen({
const labels = issue.labels ?? [] const labels = issue.labels ?? []
const curEstimate = labels.find((l) => l.startsWith('est/')) ?? '' const curEstimate = labels.find((l) => l.startsWith('est/')) ?? ''
const curPriority = labels.find((l) => l.startsWith('p/')) ?? '' const curPriority = labels.find((l) => l.startsWith('p/')) ?? ''
const curAssignee = currentAssignee ?? ''
const curMilestone = currentMilestoneId != null ? String(currentMilestoneId) : ''
const [adjustOpen, setAdjustOpen] = useState(false) const [adjustOpen, setAdjustOpen] = useState(false)
const [estimate, setEstimate] = useState(curEstimate) const [estimate, setEstimate] = useState(curEstimate)
const [priority, setPriority] = useState(curPriority) const [priority, setPriority] = useState(curPriority)
const [assignee, setAssignee] = useState(curAssignee)
const [milestone, setMilestone] = useState(curMilestone)
const [applying, setApplying] = useState(false) const [applying, setApplying] = useState(false)
const openAdjust = () => { const openAdjust = () => {
setEstimate(curEstimate) setEstimate(curEstimate)
setPriority(curPriority) setPriority(curPriority)
setAssignee(curAssignee)
setMilestone(curMilestone)
setAdjustOpen(true) setAdjustOpen(true)
} }
// the concrete changes this dialog would apply, one per axis that differs // the concrete changes this dialog would apply, one per field that differs
const pendingChanges: IssueChange[] = [] const pendingChanges: IssueChange[] = []
if (estimate !== curEstimate) if (estimate !== curEstimate)
pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null }) pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null })
if (priority !== curPriority) if (priority !== curPriority)
pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null }) pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null })
if (assignee !== curAssignee)
pendingChanges.push({ kind: 'assign', issue: issue.id, assignee: assignee || null })
if (milestone !== curMilestone)
pendingChanges.push({
kind: 'remilestone',
issue: issue.id,
milestone: milestone ? Number(milestone) : null,
milestoneTitle: milestones.find((m) => String(m.id) === milestone)?.title ?? null,
})
const diffs = pendingChanges.map((c) => describeChange(planIssueChange(labels, c))) const diffs = pendingChanges.map((c) => summarizeChange(c, labels))
const apply = async () => { const apply = async () => {
if (!onApplyChange || pendingChanges.length === 0) return if (!onApplyChange || pendingChanges.length === 0) return
@@ -93,6 +117,14 @@ export function IssueScreen({
const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))] const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))]
const prioOptions = [{ value: '', label: NONE }, ...PRIORITY_LABELS.map((l) => ({ value: l, label: l }))] const prioOptions = [{ value: '', label: NONE }, ...PRIORITY_LABELS.map((l) => ({ value: l, label: l }))]
const assigneeOptions = [
{ value: '', label: 'Unassigned' },
...collaborators.map((c) => ({ value: c.login, label: c.name })),
]
const milestoneOptions = [
{ value: '', label: NONE },
...milestones.map((m) => ({ value: String(m.id), label: m.title })),
]
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -133,7 +165,7 @@ export function IssueScreen({
<Dialog <Dialog
open={adjustOpen} open={adjustOpen}
onClose={() => setAdjustOpen(false)} onClose={() => setAdjustOpen(false)}
title="Adjust estimate & priority" title="Adjust issue"
footer={ footer={
<> <>
<Button variant="ghost" onClick={() => setAdjustOpen(false)}> <Button variant="ghost" onClick={() => setAdjustOpen(false)}>
@@ -159,15 +191,35 @@ export function IssueScreen({
onChange={(e) => setPriority(e.target.value)} onChange={(e) => setPriority(e.target.value)}
/> />
</div> </div>
{(collaborators.length > 0 || milestones.length > 0) && (
<div style={{ display: 'flex', gap: 14, marginTop: 12 }}>
{collaborators.length > 0 ? (
<Select
label="Assignee"
options={assigneeOptions}
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
/>
) : null}
{milestones.length > 0 ? (
<Select
label="Milestone"
options={milestoneOptions}
value={milestone}
onChange={(e) => setMilestone(e.target.value)}
/>
) : null}
</div>
)}
<div style={{ marginTop: 14, minHeight: 40 }}> <div style={{ marginTop: 14, minHeight: 40 }}>
{pendingChanges.length === 0 ? ( {pendingChanges.length === 0 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}>
No change yet pick a different estimate or priority. No change yet pick a different estimate, priority, assignee, or milestone.
</p> </p>
) : ( ) : (
<> <>
<p style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', margin: '0 0 6px' }}> <p style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', margin: '0 0 6px' }}>
Proposed label change Proposed change
</p> </p>
{diffs.map((d) => ( {diffs.map((d) => (
<div key={d} style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}> <div key={d} style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>
@@ -175,7 +227,7 @@ export function IssueScreen({
</div> </div>
))} ))}
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '8px 0 0' }}> <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. Writes to gitea and re-runs the plan. Nothing else changes.
</p> </p>
</> </>
)} )}
@@ -267,7 +319,7 @@ export function IssueScreen({
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>blocks</span> <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>blocks</span>
{det.blocks.map((b) => ( {det.blocks.map((b) => (
<button key={b} type="button" <button key={b} type="button"
onClick={() => onOpenIssue({ id: b, title: b === 91 ? 'Webhook listener: reconcile on reconnect' : 'Monte Carlo engine: percentile bands', labels: b === 91 ? ['est/3d', 'p/2'] : ['est/5d', 'p/1'] })} onClick={() => onOpenIssue({ id: b })}
style={{ font: '500 11px var(--font-mono)', color: 'var(--accent-text)', background: 'var(--spruce-1)', border: '1px solid var(--spruce-2)', borderRadius: 'var(--radius-1)', padding: '2px 7px', cursor: 'pointer' }}> style={{ font: '500 11px var(--font-mono)', color: 'var(--accent-text)', background: 'var(--spruce-1)', border: '1px solid var(--spruce-2)', borderRadius: 'var(--radius-1)', padding: '2px 7px', cursor: 'pointer' }}>
#{b} #{b}
</button> </button>

View File

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

View File

@@ -1,19 +1,35 @@
import React from 'react' import React from 'react'
import { type CapacityMember, capacityPerWorkday } from '@commitea/core'
import { RunwayBar } from '../charts/chart.js' import { RunwayBar } from '../charts/chart.js'
import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js' import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js'
import { RUNWAY, CAPACITY } from '../../data/fixtures.js' import { RUNWAY, CAPACITY, type RunwayMilestone } from '../../data/fixtures.js'
// Runway — capacity vs milestone dates; ranges, never points // Runway — capacity vs milestone dates; ranges, never points.
// `milestones` (real per-milestone forecasts) + `capacity` (real config) override the demo.
export function RunwayScreen({ export function RunwayScreen({
onOpenCalibration, onOpenCalibration,
onOpenMilestone, onOpenMilestone,
calibration, calibration,
milestones,
capacity,
}: { }: {
onOpenCalibration: () => void onOpenCalibration: () => void
onOpenMilestone: () => void onOpenMilestone: (id?: number) => void
calibration?: { n: number; coldStart: boolean } calibration?: { n: number; coldStart: boolean }
milestones?: RunwayMilestone[]
capacity?: CapacityMember[]
}) { }) {
const rows = milestones && milestones.length ? milestones : RUNWAY
const capacityRows =
capacity && capacity.length
? capacity.map((m) => ({
who: m.person,
slices: `focus ${m.focusFactor} · alloc ${Math.round(m.allocation * 100)}%`,
hours: `${capacityPerWorkday(m).toFixed(2)} pd/day`,
}))
: CAPACITY
const calibNote = calibration const calibNote = calibration
? calibration.coldStart ? calibration.coldStart
? `cold-start priors · ${calibration.n}/20 closed issues estimated` ? `cold-start priors · ${calibration.n}/20 closed issues estimated`
@@ -28,8 +44,8 @@ export function RunwayScreen({
<Card overline="Milestones" flush> <Card overline="Milestones" flush>
<div> <div>
{RUNWAY.map((m, i) => ( {rows.map((m, i) => (
<div key={m.name} onClick={onOpenMilestone} style={{ <div key={m.name} onClick={() => onOpenMilestone(m.id)} style={{
display: 'grid', gridTemplateColumns: '160px 1fr 150px 90px', gap: 16, alignItems: 'center', cursor: 'pointer', display: 'grid', gridTemplateColumns: '160px 1fr 150px 90px', gap: 16, alignItems: 'center', cursor: 'pointer',
padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)', padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}} }}
@@ -55,7 +71,7 @@ export function RunwayScreen({
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Capacity" flush> <Card overline="Capacity" flush>
<div> <div>
{CAPACITY.map((p, i) => ( {capacityRows.map((p, i) => (
<div key={p.who} style={{ <div key={p.who} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 20px', display: 'flex', alignItems: 'center', gap: 12, padding: '12px 20px',
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',

View File

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

View File

@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { type IssueRef, STANDUP } from '../../data/fixtures.js' import { type IssueRef, STANDUP, type StandupData } from '../../data/fixtures.js'
import { Badge, Button, Icon } from '../ui/index.js' import { Badge, Button, Icon } from '../ui/index.js'
const standupCSS = ` const standupCSS = `
@@ -36,15 +36,15 @@ const toneColor: Record<string, string> = {
export function StandupScreen({ export function StandupScreen({
onBegin, onBegin,
onOpenIssue, onOpenIssue,
data,
}: { }: {
onBegin: () => void onBegin: () => void
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
data?: StandupData
}) { }) {
const s = STANDUP const s = data ?? STANDUP
const NAG_REF: IssueRef = { const NAG_REF: IssueRef = {
id: s.nag.id, id: s.nag.id,
title: 'Fix lifecycle inference on merge events',
labels: ['est/2d', 'p/1'],
days: s.nag.days, days: s.nag.days,
} }

View File

@@ -3,9 +3,26 @@ import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png' import logoIcon from '../../design/assets/logo-icon.png'
import type { IssueChange } from '@commitea/core' import type { IssueChange } from '@commitea/core'
import type { PublicConfig } from '../../global.js'
import type { IssueRef } from '../../data/fixtures.js' import type { IssueRef } from '../../data/fixtures.js'
import { backlogCalibration, forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js' import { ConnectScreen } from '../screens/connect-screen.js'
import {
backlogCalibration,
capacityWorkers,
forecastBacklog,
issuesToBoardColumns,
milestoneView,
runwayView,
scheduleFocus,
} from '../../lib/backlog.js'
import { useBacklog } from '../../lib/use-backlog.js' import { useBacklog } from '../../lib/use-backlog.js'
import { useCapacity } from '../../lib/use-capacity.js'
import { depsGraphView } from '../../lib/views/deps-graph.js'
import { ganttView } from '../../lib/views/gantt-view.js'
import { inboxView } from '../../lib/views/inbox-view.js'
import { issueDetailView } from '../../lib/views/issue-detail.js'
import type { ProjectData } from '../../lib/views/project-data.js'
import { standupView } from '../../lib/views/standup-view.js'
import { PrimitivesGallery } from '../gallery.js' import { PrimitivesGallery } from '../gallery.js'
import { BoardScreen } from '../screens/board-screen.js' import { BoardScreen } from '../screens/board-screen.js'
import { CalibrationScreen } from '../screens/calibration-screen.js' import { CalibrationScreen } from '../screens/calibration-screen.js'
@@ -87,7 +104,14 @@ export function AppShell() {
const [offline, setOffline] = useState(false) const [offline, setOffline] = useState(false)
const [issue, setIssue] = useState<IssueRef | null>(null) const [issue, setIssue] = useState<IssueRef | null>(null)
const [readIds, setReadIds] = useState<number[]>([]) const [readIds, setReadIds] = useState<number[]>([])
const [milestoneId, setMilestoneId] = useState<number | null>(null)
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
const [demo, setDemo] = useState(false)
const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null)
const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
const [backlog, refetchBacklog] = useBacklog() const [backlog, refetchBacklog] = useBacklog()
const capacityMembers = useCapacity()
const workers = capacityWorkers(capacityMembers)
const boardColumns = const boardColumns =
backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
const focus = const focus =
@@ -96,16 +120,98 @@ export function AppShell() {
backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined
const forecast = const forecast =
backlog.status === 'ready' backlog.status === 'ready'
? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model) ?? undefined) ? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model, workers) ?? undefined)
: undefined : undefined
const runwayMilestones =
backlog.status === 'ready'
? runwayView(backlog.issues, backlog.milestones, backlog.deps, new Date(), workers)
: undefined
const milestone =
backlog.status === 'ready' && milestoneId != null
? (milestoneView(
milestoneId,
backlog.issues,
backlog.milestones,
backlog.deps,
backlog.timelines,
calibration?.model,
new Date(),
workers,
) ?? undefined)
: undefined
// The uniform input every real-data view builder consumes. Assembled once from
// the reconciled backlog + capacity; undefined until the first reconcile lands.
const projectData: ProjectData | undefined =
backlog.status === 'ready'
? {
issues: backlog.issues,
milestones: backlog.milestones,
deps: backlog.deps,
timelines: backlog.timelines,
calibration: calibration?.model,
workers,
today: new Date(),
}
: undefined
const issueDetail =
projectData && issue ? (issueDetailView(issue.id, projectData) ?? undefined) : undefined
const standup = projectData ? standupView(projectData) : undefined
const inbox = projectData ? inboxView(projectData) : undefined
const gantt = projectData ? ganttView(projectData) : undefined
const depsGraph = projectData ? depsGraphView(projectData) : undefined
// The rail's unread badge tracks the real inbox once reconciled (still-unread
// minus what's been opened); demo shows the fixture count.
const inboxUnread = inbox
? inbox.filter((n) => n.unread && !readIds.includes(n.id)).length
: INBOX_UNREAD
// The real connected host (from the saved config), for the rail's status line.
const hostLabel = (() => {
try {
return pubConfig?.baseUrl ? new URL(pubConfig.baseUrl).host : 'gitea'
} catch {
return pubConfig?.baseUrl ?? 'gitea'
}
})()
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
}, [dark]) }, [dark])
// Gate: e2e/demo → the shell (fixtures); configured → the shell (real data);
// otherwise → the connect screen (each teammate brings their own token).
useEffect(() => {
window.commitea.gitea
.status()
.then((s) => {
setDemo(s.demo)
setGate(s.demo || s.configured ? 'ready' : 'connect')
})
.catch(() => setGate('connect'))
window.commitea.config.get().then(setPubConfig).catch(() => {})
// Assignable people for the Adjust dialog's assignee picker (empty when demo/unconfigured).
window.commitea.gitea.collaborators().then(setCollaborators).catch(() => {})
}, [])
// The open issue's current assignee + milestone (from the reconciled backlog),
// so the Adjust dialog can preselect and diff them. Milestones list feeds its picker.
const currentIssue =
backlog.status === 'ready' && issue ? backlog.issues.find((i) => i.number === issue.id) : undefined
const milestoneOptions =
backlog.status === 'ready' ? backlog.milestones.map((m) => ({ id: m.id, title: m.title })) : []
const openIssue = (ref: IssueRef) => { const openIssue = (ref: IssueRef) => {
if (view !== 'issue') setPrevView(view) if (view !== 'issue') setPrevView(view)
setIssue(ref) // Callers into the sidecar (e.g. a "blocks #N" chip) may know only the id.
// Backfill the real title + labels from the reconciled backlog so the header
// isn't blank; fall back to whatever the caller passed (demo fixtures).
let full = ref
if (backlog.status === 'ready' && (!ref.title || !ref.labels)) {
const found = backlog.issues.find((i) => i.number === ref.id)
if (found) full = { ...ref, id: found.number, title: found.title, labels: found.labels }
}
setIssue(full)
setView('issue') setView('issue')
} }
@@ -123,7 +229,7 @@ export function AppShell() {
const NAV: NavEntry[] = [ const NAV: NavEntry[] = [
{ id: 'standup', label: 'Standup', icon: 'sun' }, { id: 'standup', label: 'Standup', icon: 'sun' },
{ id: 'focus', label: 'Morning service', icon: 'coffee' }, { id: 'focus', label: 'Morning service', icon: 'coffee' },
{ id: 'inbox', label: 'Inbox', icon: 'bell', count: INBOX_UNREAD || null }, { id: 'inbox', label: 'Inbox', icon: 'bell', count: inboxUnread || null },
{ id: 'capture', label: 'Capture', icon: 'plus' }, { id: 'capture', label: 'Capture', icon: 'plus' },
{ id: 'board', label: 'The pot', icon: 'square-kanban' }, { id: 'board', label: 'The pot', icon: 'square-kanban' },
{ id: 'runway', label: 'Runway', icon: 'chart-line' }, { id: 'runway', label: 'Runway', icon: 'chart-line' },
@@ -181,12 +287,14 @@ export function AppShell() {
case 'focus': case 'focus':
return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} /> return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} />
case 'standup': case 'standup':
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} /> return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} data={standup} />
case 'board': case 'board':
return ( return (
<BoardScreen <BoardScreen
onOpenIssue={openIssue} onOpenIssue={openIssue}
columns={boardColumns} columns={boardColumns}
gantt={gantt}
deps={depsGraph}
loading={backlog.status === 'loading'} loading={backlog.status === 'loading'}
/> />
) )
@@ -194,14 +302,19 @@ export function AppShell() {
return ( return (
<RunwayScreen <RunwayScreen
onOpenCalibration={() => setView('calibration')} onOpenCalibration={() => setView('calibration')}
onOpenMilestone={() => setView('milestone')} onOpenMilestone={(id) => {
setMilestoneId(id ?? null)
setView('milestone')
}}
calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined} calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined}
milestones={runwayMilestones}
capacity={capacityMembers}
/> />
) )
case 'calibration': case 'calibration':
return <CalibrationScreen onBack={() => setView('runway')} data={calibration?.data} /> return <CalibrationScreen onBack={() => setView('runway')} data={calibration?.data} />
case 'milestone': case 'milestone':
return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} /> return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} data={milestone} />
case 'inbox': case 'inbox':
return ( return (
<InboxScreen <InboxScreen
@@ -209,6 +322,7 @@ export function AppShell() {
onOpenDirectives={() => setView('directives')} onOpenDirectives={() => setView('directives')}
readIds={readIds} readIds={readIds}
setReadIds={setReadIds} setReadIds={setReadIds}
data={inbox}
/> />
) )
case 'capture': case 'capture':
@@ -216,15 +330,33 @@ export function AppShell() {
case 'directives': case 'directives':
return <DirectivesScreen /> return <DirectivesScreen />
case 'settings': case 'settings':
return <SettingsScreen dark={dark} setDark={setDark} /> return (
<SettingsScreen
dark={dark}
setDark={setDark}
connection={pubConfig}
onReconnect={() => setGate('connect')}
onDisconnect={() => {
void window.commitea.config.clear().then(() => {
setPubConfig(null)
setGate('connect')
})
}}
/>
)
case 'issue': case 'issue':
return issue ? ( return issue ? (
<IssueScreen <IssueScreen
issue={issue} issue={issue}
detail={issueDetail}
onBack={() => setView(prevView)} onBack={() => setView(prevView)}
onOpenIssue={openIssue} onOpenIssue={openIssue}
canWrite={backlog.status === 'ready'} canWrite={backlog.status === 'ready'}
onApplyChange={applyChange} onApplyChange={applyChange}
collaborators={collaborators}
milestones={milestoneOptions}
currentAssignee={currentIssue?.assignee ?? null}
currentMilestoneId={currentIssue?.milestone?.id ?? null}
/> />
) : null ) : null
case 'states': case 'states':
@@ -236,6 +368,23 @@ export function AppShell() {
} }
} }
// Connection gate (real onboarding) comes before everything else.
if (gate === 'checking') {
return <div style={{ height: '100vh', background: 'var(--surface-app)' }} />
}
if (gate === 'connect') {
return (
<ConnectScreen
existing={pubConfig}
onConnected={() => {
window.commitea.config.get().then(setPubConfig).catch(() => {})
setGate('ready')
refetchBacklog()
}}
/>
)
}
// First run is full-window — no rail, no chat panel // First run is full-window — no rail, no chat panel
if (view === 'firstrun') { if (view === 'firstrun') {
return <OnboardingScreen onDone={(dest) => setView(dest)} /> return <OnboardingScreen onDone={(dest) => setView(dest)} />
@@ -278,9 +427,15 @@ export function AppShell() {
<div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} /> <div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} />
<NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} /> <NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} />
<NavItem item={{ id: 'firstrun', label: 'First run', icon: 'play' }} /> {/* Dev-only surfaces (fixture galleries / onboarding preview) — shown in dev
<NavItem item={{ id: 'states', label: 'States', icon: 'circle-dashed' }} /> and in demo/e2e mode; hidden in a real configured, packaged app. */}
<NavItem item={{ id: 'primitives', label: 'Primitives', icon: 'layers' }} /> {import.meta.env.DEV || demo ? (
<>
<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' }} />
</>
) : null}
<div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<button <button
@@ -310,7 +465,7 @@ export function AppShell() {
display: 'inline-block', display: 'inline-block',
}} }}
/> />
gitea.stephenmann.io {hostLabel}
</button> </button>
<Switch <Switch
label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>} label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { describeChange, type IssueChange } from '@commitea/core' import type { IssueChange } from '@commitea/core'
import { useChat } from '../../lib/use-chat.js' import { useChat } from '../../lib/use-chat.js'
import { Button, Icon, IconButton } from '../ui/index.js' import { Button, Icon, IconButton } from '../ui/index.js'
@@ -19,7 +19,7 @@ export interface ChatPanelProps {
} }
export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPanelProps) { export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPanelProps) {
const { msgs, thinking, live, model, steps, proposals, send: sendChat, approve, dismiss } = useChat(onApplyChange) const { msgs, thinking, live, model, steps, proposals, streaming, send: sendChat, approve, dismiss } = useChat(onApplyChange)
// shorten "google/gemma-4-26b-a4b-qat" → "gemma-4-26b" for the header chip // 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 modelLabel = model ? (model.split('/').pop() ?? model).replace(/-(qat|instruct|it|gguf)$/i, '') : 'gemma-4'
const [text, setText] = useState('') const [text, setText] = useState('')
@@ -28,7 +28,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
useEffect(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight if (el) el.scrollTop = el.scrollHeight
}, [msgs, thinking]) }, [msgs, thinking, streaming])
const send = () => { const send = () => {
const t = text.trim() const t = text.trim()
@@ -62,7 +62,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
<Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} /> <Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} />
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span> <span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}> <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
{offline ? 'offline · queueing' : live ? `${modelLabel} · local` : 'demo · scripted'} {offline ? 'offline · queueing' : live ? `${modelLabel} · local` : 'no model'}
</span> </span>
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} /> <IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
</header> </header>
@@ -98,7 +98,14 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
The model is away from its desk. Reads still work; writes will wait their turn. The model is away from its desk. Reads still work; writes will wait their turn.
</div> </div>
) : null} ) : null}
{thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering</div> : null} {streaming ? (
<div style={{ font: 'var(--text-agent)', color: 'var(--ink-1)', lineHeight: 1.55 }}>
{streaming}
<span style={{ opacity: 0.5 }}></span>
</div>
) : thinking ? (
<div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering</div>
) : null}
{!thinking && steps.length ? ( {!thinking && steps.length ? (
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', display: 'flex', alignItems: 'center', gap: 5 }}> <div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', display: 'flex', alignItems: 'center', gap: 5 }}>
<Icon name="eye" size={11} /> consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project').replace('propose_change', 'the labels').replace('record_directive', 'the directive ledger')))).join(', ')} <Icon name="eye" size={11} /> consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project').replace('propose_change', 'the labels').replace('record_directive', 'the directive ledger')))).join(', ')}
@@ -120,7 +127,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 'var(--letter-spacing-wide)' }}> <div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 'var(--letter-spacing-wide)' }}>
Proposed · #{p.change.issue} Proposed · #{p.change.issue}
</div> </div>
<div style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>{describeChange(p.plan)}</div> <div style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>{p.summary}</div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" onClick={() => approve(p)} disabled={offline}> <Button size="sm" onClick={() => approve(p)} disabled={offline}>
Approve Approve
@@ -154,8 +161,8 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
send() send()
} }
}} }}
placeholder={offline ? 'Writes wait for the connection…' : 'Tell me what to do…'} placeholder={offline ? 'Writes wait for the connection…' : live ? 'Tell me what to do…' : 'Add a Model URL in Settings…'}
disabled={offline} disabled={offline || !live}
rows={1} rows={1}
style={{ style={{
flex: 1, flex: 1,
@@ -173,15 +180,15 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
type="button" type="button"
onClick={send} onClick={send}
aria-label="Send" aria-label="Send"
disabled={offline} disabled={offline || !live}
style={{ style={{
width: 30, width: 30,
height: 30, height: 30,
borderRadius: 'var(--radius-2)', borderRadius: 'var(--radius-2)',
border: 'none', border: 'none',
cursor: offline ? 'not-allowed' : 'pointer', cursor: offline || !live ? 'not-allowed' : 'pointer',
background: offline ? 'var(--paper-3)' : 'var(--accent)', background: offline || !live ? 'var(--paper-3)' : 'var(--accent)',
color: offline ? 'var(--ink-3)' : 'var(--ink-inverse)', color: offline || !live ? 'var(--ink-3)' : 'var(--ink-inverse)',
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',

View File

@@ -315,6 +315,8 @@ export interface RunwayMilestone {
spread: number spread: number
tone: 'ok' | 'warn' tone: 'ok' | 'warn'
note: string note: string
/** gitea milestone id, for the drill-in (absent on the demo fixture). */
id?: number
} }
export const RUNWAY: RunwayMilestone[] = [ export const RUNWAY: RunwayMilestone[] = [

View File

@@ -1,5 +1,6 @@
import type { import type {
AgentStep, AgentStep,
CapacityMember,
ChangeProposal, ChangeProposal,
ChatMessage, ChatMessage,
CaptureProposal, CaptureProposal,
@@ -16,7 +17,8 @@ import type {
/** The result of a write through the bridge. */ /** The result of a write through the bridge. */
export type ApplyChangeResult = export type ApplyChangeResult =
| { ok: false; reason: 'unconfigured' } | { ok: false; reason: 'unconfigured' }
| { ok: true; plan: LabelPlan; issue: GiteaIssue } // `plan` is present for label swaps (est/p); absent for field writes (assign, milestone).
| { ok: true; plan?: LabelPlan; issue: GiteaIssue }
/** The result of filing captured issues. */ /** The result of filing captured issues. */
export type CreateIssuesResult = export type CreateIssuesResult =
@@ -48,14 +50,43 @@ export type BootPayload =
| { configured: true; cached: false } | { configured: true; cached: false }
| ({ configured: true; cached: true } & Omit<SnapshotPayload, 'configured'>) | ({ configured: true; cached: true } & Omit<SnapshotPayload, 'configured'>)
/** The saved connection config (never includes the token). */
export interface PublicConfig {
baseUrl: string
owner: string
repo: string
pmStateRepo?: string
modelUrl?: string
hasToken: boolean
}
/** What the onboarding/settings form submits. */
export interface ConfigInput {
baseUrl: string
owner: string
repo: string
token: string
pmStateRepo?: string
modelUrl?: string
}
/** The config bridge for team onboarding. */
export interface ConfigBridge {
get(): Promise<PublicConfig | null>
test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }>
set(cfg: ConfigInput): Promise<{ ok: boolean }>
clear(): Promise<{ ok: boolean }>
}
/** The gitea bridge exposed by the preload over IPC (main-process backed). */ /** The gitea bridge exposed by the preload over IPC (main-process backed). */
export interface GiteaBridge { export interface GiteaBridge {
status(): Promise<{ configured: boolean; repo: string | null }> status(): Promise<{ configured: boolean; repo: string | null; demo: boolean }>
boot(): Promise<BootPayload> boot(): Promise<BootPayload>
reconcile(): Promise<SnapshotPayload> reconcile(): Promise<SnapshotPayload>
getIssue(index: number): Promise<GiteaIssue | null> getIssue(index: number): Promise<GiteaIssue | null>
applyChange(change: IssueChange): Promise<ApplyChangeResult> applyChange(change: IssueChange): Promise<ApplyChangeResult>
createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult> createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult>
collaborators(): Promise<{ login: string; name: string }[]>
} }
/** One agent turn's result. */ /** One agent turn's result. */
@@ -68,6 +99,8 @@ export interface ModelBridge {
status(): Promise<{ configured: boolean; model: string | null }> status(): Promise<{ configured: boolean; model: string | null }>
chat(messages: ChatMessage[]): Promise<ChatResult> chat(messages: ChatMessage[]): Promise<ChatResult>
capture(braindump: string): Promise<CaptureResult> capture(braindump: string): Promise<CaptureResult>
/** Subscribe to streamed prose tokens; returns an unsubscribe fn. */
onToken(cb: (delta: string) => void): () => void
} }
/** The result of reading the directive ledger. */ /** The result of reading the directive ledger. */
@@ -75,9 +108,15 @@ export type DirectivesResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string } | { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| { ok: true; directives: DirectiveRecord[] } | { ok: true; directives: DirectiveRecord[] }
/** The result of reading the capacity config. */
export type CapacityResult =
| { ok: false; reason: 'unconfigured'; members: CapacityMember[] }
| { ok: true; members: CapacityMember[] }
/** The pm-state bridge (machine-derived state) exposed by the preload over IPC. */ /** The pm-state bridge (machine-derived state) exposed by the preload over IPC. */
export interface PmStateBridge { export interface PmStateBridge {
directives(): Promise<DirectivesResult> directives(): Promise<DirectivesResult>
capacity(): Promise<CapacityResult>
} }
declare global { declare global {
@@ -87,6 +126,7 @@ declare global {
gitea: GiteaBridge gitea: GiteaBridge
model: ModelBridge model: ModelBridge
pmstate: PmStateBridge pmstate: PmStateBridge
config: ConfigBridge
} }
} }
} }

View File

@@ -2,11 +2,15 @@ import {
type CalibrationModel, type CalibrationModel,
type CalibrationSample, type CalibrationSample,
calibrationSamples, calibrationSamples,
type CapacityMember,
capacityPerWorkday,
COLD_START_THRESHOLD, COLD_START_THRESHOLD,
type DependencyEdge, type DependencyEdge,
fitCalibration, fitCalibration,
forecast, forecast,
type Worker,
type GiteaIssue, type GiteaIssue,
type GiteaMilestone,
inferLifecycle, inferLifecycle,
type LifecycleColumn, type LifecycleColumn,
type LifecycleEvent, type LifecycleEvent,
@@ -16,10 +20,17 @@ import {
type ScheduledItem, type ScheduledItem,
selectFocus, selectFocus,
toDurationModel, toDurationModel,
workingDaysBetween,
} from '@commitea/core' } from '@commitea/core'
import { type BoardColumn, type BoardIssue, type CalibrationData, type FocusIssue } from '../data/fixtures.js' import {
import { type BurnUpData, buildBurnUpData } from './dates.js' type BoardColumn,
type BoardIssue,
type CalibrationData,
type FocusIssue,
type RunwayMilestone,
} from '../data/fixtures.js'
import { addWorkingDays, type BurnUpData, buildBurnUpData, formatRange, formatShort } from './dates.js'
type Timelines = Record<number, LifecycleEvent[]> type Timelines = Record<number, LifecycleEvent[]>
@@ -99,9 +110,15 @@ function toSchedulable(issues: GiteaIssue[]) {
labels: i.labels, labels: i.labels,
estimateDays: i.facts.estimateDays, estimateDays: i.facts.estimateDays,
priority: i.facts.priority, priority: i.facts.priority,
assignee: i.assignee,
})) }))
} }
/** gitea CapacityMembers → scheduler lanes (person + throughput). */
export function capacityWorkers(members: CapacityMember[]): Worker[] {
return members.map((m) => ({ person: m.person, speed: capacityPerWorkday(m) }))
}
export interface ForecastView { export interface ForecastView {
scope: number scope: number
cone: BurnUpData cone: BurnUpData
@@ -124,9 +141,10 @@ export function forecastBacklog(
deps: DependencyEdge[], deps: DependencyEdge[],
today: Date = new Date(), today: Date = new Date(),
calibration?: CalibrationModel, calibration?: CalibrationModel,
workers: Worker[] = [],
): ForecastView | null { ): ForecastView | null {
const model = calibration ? toDurationModel(calibration) : undefined const model = calibration ? toDurationModel(calibration) : undefined
const f = forecast(toSchedulable(issues), deps, model ? { model } : {}) const f = forecast(toSchedulable(issues), deps, { ...(model ? { model } : {}), workers })
const cone = buildBurnUpData(f, today) const cone = buildBurnUpData(f, today)
if (!cone) return null if (!cone) return null
return { return {
@@ -232,3 +250,138 @@ export function scheduleFocus(
later: toFocusIssue(f.later, inf), later: toFocusIssue(f.later, inf),
} }
} }
/**
* Per-milestone Monte Carlo forecast for the Runway screen — each open milestone's
* open scope gets its own cone, and the p80 landing is compared to the milestone's
* due date (ok/at-risk). Ranges, never point dates. Milestones with no open scope
* (already shipped) are omitted. Falls back to the demo when there's nothing real.
*/
export function runwayView(
issues: GiteaIssue[],
milestones: GiteaMilestone[],
deps: DependencyEdge[],
today: Date = new Date(),
workers: Worker[] = [],
): RunwayMilestone[] {
const open = issues.filter((i) => i.state === 'open')
const rows = milestones
.filter((m) => m.state === 'open')
.map((m) => ({ m, scope: open.filter((i) => i.milestone?.id === m.id) }))
.filter((x) => x.scope.length > 0)
.map(({ m, scope }) => {
const f = forecast(
scope.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
})),
deps,
{ workers },
)
const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
const dueDay = m.dueOn ? workingDaysBetween(today, new Date(m.dueOn)) : null
return { m, p50: f.p50Day, p80: f.p80Day, p90, dueDay }
})
const horizon = Math.max(1, ...rows.map((r) => Math.max(r.p90, r.dueDay ?? 0)))
return rows.map(({ m, p50, p80, p90, dueDay }) => {
const onTrack = dueDay == null || p80 <= dueDay
return {
id: m.id,
name: m.title,
due: m.dueOn ? formatShort(new Date(m.dueOn)) : 'no date',
hard: false,
p80: formatRange(addWorkingDays(today, p50), addWorkingDays(today, p90)),
pos: p80 / horizon,
spread: Math.min(0.6, (p90 - p50) / horizon),
tone: onTrack ? ('ok' as const) : ('warn' as const),
note: dueDay == null ? `${Math.ceil(p80)}d of work` : onTrack ? 'on track' : 'at risk',
}
})
}
export interface MilestoneGroup {
label: string
issues: { id: number; title: string; labels: string[]; days?: string }[]
}
export interface MilestoneView {
name: string
due: string
soft: boolean
scopeCount: number
scopeEstDays: number
doneCount: number
donePct: number
/** p50..p90 landing range for the remaining open scope; null when nothing's open. */
forecastRange: string | null
cone: BurnUpData | null
groups: MilestoneGroup[]
}
/**
* The Milestone drill-in: real scope, done %, a Monte Carlo cone over the
* milestone's remaining open work, and its issues grouped by lifecycle column.
* Returns null for an unknown id — the screen then shows the demo fixture.
*/
export function milestoneView(
id: number,
issues: GiteaIssue[],
milestones: GiteaMilestone[],
deps: DependencyEdge[],
timelines: Timelines = {},
calibration?: CalibrationModel,
today: Date = new Date(),
workers: Worker[] = [],
): MilestoneView | null {
const m = milestones.find((x) => x.id === id)
if (!m) return null
const all = issues.filter((i) => i.milestone?.id === id)
const open = all.filter((i) => i.state === 'open')
const done = all.filter((i) => i.state === 'closed')
const scopeEstDays = all.reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0)
const model = calibration ? toDurationModel(calibration) : undefined
const f = forecast(
open.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
})),
deps,
{ ...(model ? { model } : {}), workers },
)
const cone = buildBurnUpData(f, today)
const inf = inferAll(all, timelines, today)
const groups: MilestoneGroup[] = COLUMN_ORDER.map((key) => ({
label: COLUMN_LABELS[key],
issues: all
.filter((i) => inf.get(i.number)!.column === key)
.map((i) => {
const days = inf.get(i.number)!.steepingDays
return { id: i.number, title: i.title, labels: i.labels, days: days != null ? `${days}d` : undefined }
}),
})).filter((g) => g.issues.length > 0)
return {
name: m.title,
due: m.dueOn ? formatShort(new Date(m.dueOn)) : 'no date',
soft: true,
scopeCount: all.length,
scopeEstDays,
doneCount: done.length,
donePct: all.length ? Math.round((done.length / all.length) * 100) : 0,
forecastRange: cone ? cone.rangeLabel : null,
cone,
groups,
}
}

View File

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

View File

@@ -2,13 +2,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { ChangeProposal, ChatMessage as WireMessage, IssueChange } from '@commitea/core' import type { ChangeProposal, ChatMessage as WireMessage, IssueChange } from '@commitea/core'
import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js' import { CHAT, type ChatMessage } from '../data/fixtures.js'
const LIVE_GREETING: ChatMessage = { const LIVE_GREETING: ChatMessage = {
from: 'agent', from: 'agent',
text: 'Morning. Ask me anything about the project — I check the real board before I answer.', text: 'Morning. Ask me anything about the project — I check the real board before I answer.',
} }
const NO_MODEL: ChatMessage = {
from: 'agent',
text: "Reginald is off — I need a model. Add a Model URL in Settings and chat turns on.",
}
export interface ChatState { export interface ChatState {
msgs: ChatMessage[] msgs: ChatMessage[]
thinking: boolean thinking: boolean
@@ -20,6 +25,8 @@ export interface ChatState {
steps: string[] steps: string[]
/** Changes Reginald has proposed and is awaiting approval on. */ /** Changes Reginald has proposed and is awaiting approval on. */
proposals: ChangeProposal[] proposals: ChangeProposal[]
/** The in-flight streamed prose (grows token-by-token) before the turn finalizes. */
streaming: string
send: (text: string) => void send: (text: string) => void
approve: (p: ChangeProposal) => void approve: (p: ChangeProposal) => void
dismiss: (p: ChangeProposal) => void dismiss: (p: ChangeProposal) => void
@@ -41,6 +48,7 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
const [model, setModel] = useState<string | null>(null) const [model, setModel] = useState<string | null>(null)
const [steps, setSteps] = useState<string[]>([]) const [steps, setSteps] = useState<string[]>([])
const [proposals, setProposals] = useState<ChangeProposal[]>([]) const [proposals, setProposals] = useState<ChangeProposal[]>([])
const [streaming, setStreaming] = useState('')
const convoRef = useRef(convo) const convoRef = useRef(convo)
convoRef.current = convo convoRef.current = convo
@@ -52,10 +60,14 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
window.commitea.model window.commitea.model
.status() .status()
.then((s) => { .then((s) => {
if (alive && s.configured) { if (!alive) return
if (s.configured) {
setLive(true) setLive(true)
setModel(s.model) setModel(s.model)
setSeed([LIVE_GREETING]) setSeed([LIVE_GREETING])
} else {
// no model → clean "off" state, not a scripted fixture reply
setSeed([NO_MODEL])
} }
}) })
.catch(() => {}) .catch(() => {})
@@ -75,10 +87,8 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
setProposals([]) setProposals([])
if (!live) { if (!live) {
window.setTimeout(() => { // no model configured — nothing to send (composer is disabled anyway)
setThinking(false) setThinking(false)
setConvo((c) => [...c, { from: 'agent', text: CANNED_REPLY }])
}, 900)
return return
} }
@@ -86,10 +96,17 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
role: m.from === 'user' ? 'user' : 'assistant', role: m.from === 'user' ? 'user' : 'assistant',
content: m.text, content: m.text,
})) }))
setStreaming('')
const unsubscribe = window.commitea.model.onToken((delta) => setStreaming((s) => s + delta))
const finish = () => {
unsubscribe()
setThinking(false)
setStreaming('')
}
window.commitea.model window.commitea.model
.chat(wire) .chat(wire)
.then((res) => { .then((res) => {
setThinking(false) finish()
if (res.ok) { if (res.ok) {
setSteps(res.steps.map((s) => s.tool)) setSteps(res.steps.map((s) => s.tool))
setProposals(res.proposals) setProposals(res.proposals)
@@ -105,7 +122,7 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
} }
}) })
.catch(() => { .catch(() => {
setThinking(false) finish()
setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }]) setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }])
}) })
}, },
@@ -116,14 +133,13 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
(p: ChangeProposal) => { (p: ChangeProposal) => {
if (!onApplyChange) return if (!onApplyChange) return
drop(p) drop(p)
const label = p.plan.added[0] ?? p.plan.removed[0] ?? 'change'
void onApplyChange(p.change).then((res) => { void onApplyChange(p.change).then((res) => {
setConvo((c) => [ setConvo((c) => [
...c, ...c,
{ {
from: 'agent', from: 'agent',
text: res.ok text: res.ok
? `Done — #${p.change.issue} is now ${label}. The plan's been re-run.` ? `Done — #${p.change.issue}: ${p.summary}. The plan's been re-run.`
: `That didn't take — #${p.change.issue} is unchanged.`, : `That didn't take — #${p.change.issue} is unchanged.`,
}, },
]) ])
@@ -137,5 +153,5 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
setConvo((c) => [...c, { from: 'agent', text: `Left #${p.change.issue} as it was.` }]) setConvo((c) => [...c, { from: 'agent', text: `Left #${p.change.issue} as it was.` }])
}, []) }, [])
return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, send, approve, dismiss } return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, streaming, send, approve, dismiss }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,18 +31,20 @@ function stringify(result: unknown): string {
} }
export async function runAgentTurn(opts: { export async function runAgentTurn(opts: {
complete: (messages: ChatMessage[], tools?: ToolDecl[]) => Promise<CompletionResult> complete: (messages: ChatMessage[], tools?: ToolDecl[], onToken?: (delta: string) => void) => Promise<CompletionResult>
messages: ChatMessage[] messages: ChatMessage[]
tools: ToolDecl[] tools: ToolDecl[]
execute: ToolExecutor execute: ToolExecutor
maxSteps?: number maxSteps?: number
/** Streams content deltas as the model produces prose (final-answer streaming). */
onToken?: (delta: string) => void
}): Promise<AgentTurn> { }): Promise<AgentTurn> {
const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS
const convo: ChatMessage[] = [...opts.messages] const convo: ChatMessage[] = [...opts.messages]
const steps: AgentStep[] = [] const steps: AgentStep[] = []
for (let step = 0; step < maxSteps; step++) { for (let step = 0; step < maxSteps; step++) {
const { content, toolCalls } = await opts.complete(convo, opts.tools) const { content, toolCalls } = await opts.complete(convo, opts.tools, opts.onToken)
if (toolCalls.length === 0) { if (toolCalls.length === 0) {
convo.push({ role: 'assistant', content }) convo.push({ role: 'assistant', content })
return { content, steps, messages: convo } return { content, steps, messages: convo }
@@ -63,7 +65,7 @@ export async function runAgentTurn(opts: {
} }
// Out of tool budget — force a final prose answer with tools withheld. // Out of tool budget — force a final prose answer with tools withheld.
const final = await opts.complete(convo, []) const final = await opts.complete(convo, [], opts.onToken)
convo.push({ role: 'assistant', content: final.content }) convo.push({ role: 'assistant', content: final.content })
return { content: final.content, steps, messages: convo } return { content: final.content, steps, messages: convo }
} }

View File

@@ -38,15 +38,19 @@ export const QUERY_PROJECT_TOOL: ToolDecl = {
export const PROPOSE_CHANGE_TOOL: ToolDecl = { export const PROPOSE_CHANGE_TOOL: ToolDecl = {
name: 'propose_change', name: 'propose_change',
description: description:
"Propose an estimate and/or priority change to an issue. This does NOT apply anything — it shows the " + 'Propose a change to an issue — estimate, priority, assignee, and/or milestone. This does NOT apply ' +
'human a diff to approve. Use it whenever the user asks to re-estimate or reprioritize. After calling it, ' + 'anything; it shows the human a diff to approve. Use it whenever the user asks to re-estimate, reprioritize, ' +
"tell the user you've *proposed* the change for approval — never say it is done.", 'assign someone (or unassign), or move an issue to a milestone. Pass a login for `assignee` (null to ' +
'unassign) and a milestone id for `milestone` (null to remove). After calling it, tell the user you have ' +
'*proposed* the change for approval — never say it is done.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
issue: { type: 'number', description: 'the issue number to change' }, issue: { type: 'number', description: 'the issue number to change' },
estimate: { type: 'string', enum: ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'] }, estimate: { type: 'string', enum: ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'] },
priority: { type: 'string', enum: ['p/1', 'p/2', 'p/3', 'p/4'] }, priority: { type: 'string', enum: ['p/1', 'p/2', 'p/3', 'p/4'] },
assignee: { type: 'string', description: 'gitea login to assign, or null to unassign' },
milestone: { type: 'number', description: 'milestone id to set, or null to remove from its milestone' },
}, },
required: ['issue'], required: ['issue'],
}, },
@@ -84,7 +88,7 @@ export const REGINALD_SYSTEM = [
'You are Reginald, the calm, dry project manager inside CommiTea — a tool that runs projects on Gitea.', 'You are Reginald, the 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.', '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.', 'The scheduler and forecasts are deterministic code — report their output, do not recompute it.',
'To change an estimate or priority, call propose_change — it shows the human a diff to approve.', 'To change an estimate, priority, assignee, or milestone, call propose_change — it shows the human a diff to approve.',
'When the PM states standing intent ("pilots first", "freeze scope"), call record_directive to log it.', 'When the PM states standing intent ("pilots first", "freeze scope"), call record_directive to log it.',
'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.', 'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.',
'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.', 'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.',

View File

@@ -25,6 +25,48 @@ describe('createChatClient', () => {
return { fetch, calls } return { fetch, calls }
} }
function sseStub(chunks: string[]) {
const calls: { url: string; body: unknown }[] = []
const fetch: FetchLike = (url, init) => {
calls.push({ url, body: init?.body ? JSON.parse(init.body) : undefined })
const enc = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(c) {
for (const ch of chunks) c.enqueue(enc.encode(ch))
c.close()
},
})
return Promise.resolve({ ok: true, status: 200, body, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
}
return { fetch, calls }
}
it('streams content deltas via onToken and returns the assembled result', async () => {
const { fetch, calls } = sseStub([
'data: {"choices":[{"delta":{"content":"Right "}}]}\n\n',
'data: {"choices":[{"delta":{"content":"now: #2."}}]}\n\n',
'data: [DONE]\n\n',
])
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
const tokens: string[] = []
const res = await client.complete([{ role: 'user', content: 'now?' }], undefined, (d) => tokens.push(d))
expect(tokens).toEqual(['Right ', 'now: #2.'])
expect(res.content).toBe('Right now: #2.')
expect((calls[0].body as { stream?: boolean }).stream).toBe(true)
})
it('assembles a streamed tool call from argument deltas', async () => {
const { fetch } = sseStub([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"query_project","arguments":"{\\"view\\""}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"focus\\"}"}}]}}]}\n\n',
'data: [DONE]\n\n',
])
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
const res = await client.complete([{ role: 'user', content: 'x' }], [{ name: 'query_project', description: '', parameters: {} }], () => {})
expect(res.toolCalls).toEqual([{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }])
})
it('POSTs to /chat/completions and parses content', async () => { it('POSTs to /chat/completions and parses content', async () => {
const { fetch, calls } = stub({ choices: [{ message: { content: 'the focus is #2' } }] }) const { fetch, calls } = stub({ choices: [{ message: { content: 'the focus is #2' } }] })
const client = createChatClient({ baseUrl: 'http://localhost:1234/v1', model: 'gemma' }, fetch) const client = createChatClient({ baseUrl: 'http://localhost:1234/v1', model: 'gemma' }, fetch)

View File

@@ -48,8 +48,12 @@ export interface CompletionResult {
toolCalls: ToolCall[] toolCalls: ToolCall[]
} }
/** Called with each streamed content delta (final-prose streaming). */
export type OnToken = (delta: string) => void
export interface ChatClient { export interface ChatClient {
complete(messages: ChatMessage[], tools?: ToolDecl[]): Promise<CompletionResult> /** When `onToken` is given, the response streams (SSE) and each content delta is emitted. */
complete(messages: ChatMessage[], tools?: ToolDecl[], onToken?: OnToken): Promise<CompletionResult>
} }
/** Map our message shape to the OpenAI wire shape. */ /** Map our message shape to the OpenAI wire shape. */
@@ -85,7 +89,7 @@ export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): Cha
const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions` const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`
return { return {
async complete(messages, tools) { async complete(messages, tools, onToken) {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: config.model, model: config.model,
messages: messages.map(toWireMessage), messages: messages.map(toWireMessage),
@@ -95,11 +99,13 @@ export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): Cha
body.tools = tools.map(toWireTool) body.tools = tools.map(toWireTool)
body.tool_choice = 'auto' body.tool_choice = 'auto'
} }
const stream = !!onToken
if (stream) body.stream = true
const res = await fetchImpl(url, { const res = await fetchImpl(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: stream ? 'text/event-stream' : 'application/json',
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}), ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -108,6 +114,8 @@ export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): Cha
const text = await res.text().catch(() => '') const text = await res.text().catch(() => '')
throw new Error(`model completion failed (${res.status}): ${text.slice(0, 200)}`) throw new Error(`model completion failed (${res.status}): ${text.slice(0, 200)}`)
} }
if (stream && res.body) return readStream(res.body, onToken!)
const json = (await res.json()) as { choices?: { message: RawChoiceMessage }[] } const json = (await res.json()) as { choices?: { message: RawChoiceMessage }[] }
const msg = json.choices?.[0]?.message const msg = json.choices?.[0]?.message
return { return {
@@ -121,3 +129,56 @@ export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): Cha
}, },
} }
} }
/** Streamed tool-call delta: name arrives first, arguments accumulate across chunks. */
interface RawToolDelta {
index: number
id?: string
function?: { name?: string; arguments?: string }
}
/** Parse an OpenAI SSE stream: emit content deltas via onToken, accumulate the final result. */
async function readStream(body: ReadableStream<Uint8Array>, onToken: OnToken): Promise<CompletionResult> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let content = ''
const toolAcc: { id: string; name: string; arguments: string }[] = []
const handle = (data: string) => {
if (data === '[DONE]') return
let chunk: { choices?: { delta?: { content?: string; tool_calls?: RawToolDelta[] } }[] }
try {
chunk = JSON.parse(data)
} catch {
return
}
const delta = chunk.choices?.[0]?.delta
if (!delta) return
if (delta.content) {
content += delta.content
onToken(delta.content)
}
for (const tc of delta.tool_calls ?? []) {
const slot = (toolAcc[tc.index] ??= { id: '', name: '', arguments: '' })
if (tc.id) slot.id = tc.id
if (tc.function?.name) slot.name = tc.function.name
if (tc.function?.arguments) slot.arguments += tc.function.arguments
}
}
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const t = line.trim()
if (t.startsWith('data:')) handle(t.slice(5).trim())
}
}
if (buffer.trim().startsWith('data:')) handle(buffer.trim().slice(5).trim())
return { content, toolCalls: toolAcc.filter((t) => t.name).map((t) => ({ id: t.id, name: t.name, arguments: t.arguments })) }
}

View File

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

View File

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

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { describeChange, type IssueChange, planIssueChange, proposalsFor } from './apply-changes-v0.js' import {
describeChange,
isLabelChange,
type IssueChange,
planIssueChange,
proposalsFor,
summarizeChange,
} from './apply-changes-v0.js'
describe('planIssueChange', () => { describe('planIssueChange', () => {
it('swaps the estimate label, keeping non-axis labels', () => { it('swaps the estimate label, keeping non-axis labels', () => {
@@ -58,11 +65,31 @@ describe('proposalsFor', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/5d', priority: 'p/1' }, ['est/2d', 'p/3'], 'ChangeSource') const props = proposalsFor({ issue: 2, estimate: 'est/5d', priority: 'p/1' }, ['est/2d', 'p/3'], 'ChangeSource')
expect(props).toHaveLength(2) expect(props).toHaveLength(2)
expect(props[0].change).toEqual({ kind: 'reestimate', issue: 2, estimate: 'est/5d' }) expect(props[0].change).toEqual({ kind: 'reestimate', issue: 2, estimate: 'est/5d' })
expect(describeChange(props[0].plan)).toBe('est/2d → est/5d') expect(props[0].summary).toBe('est/2d → est/5d')
expect(props[1].change).toEqual({ kind: 'reprioritize', issue: 2, priority: 'p/1' }) expect(props[1].change).toEqual({ kind: 'reprioritize', issue: 2, priority: 'p/1' })
expect(props[0].issueTitle).toBe('ChangeSource') expect(props[0].issueTitle).toBe('ChangeSource')
}) })
it('proposes an assignment only when it differs from the current assignee', () => {
const change = proposalsFor({ issue: 2, assignee: 'christian' }, [], undefined, { currentAssignee: null })
expect(change).toHaveLength(1)
expect(change[0].change).toEqual({ kind: 'assign', issue: 2, assignee: 'christian' })
expect(change[0].summary).toBe('assign → @christian')
expect(change[0].plan).toBeUndefined()
// already assigned → no proposal
expect(proposalsFor({ issue: 2, assignee: 'christian' }, [], undefined, { currentAssignee: 'christian' })).toEqual([])
})
it('proposes a milestone move, labeled from the milestones list, skipping a no-op', () => {
const ctx = { currentMilestoneId: 1, milestones: [{ id: 3, title: 'P5 — Dogfood' }] }
const props = proposalsFor({ issue: 2, milestone: 3 }, [], undefined, ctx)
expect(props).toHaveLength(1)
expect(props[0].change).toEqual({ kind: 'remilestone', issue: 2, milestone: 3, milestoneTitle: 'P5 — Dogfood' })
expect(props[0].summary).toBe('milestone → P5 — Dogfood')
// same milestone → no proposal
expect(proposalsFor({ issue: 2, milestone: 1 }, [], undefined, ctx)).toEqual([])
})
it('drops a noop axis (already at the requested value)', () => { it('drops a noop axis (already at the requested value)', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/2d', priority: 'p/1' }, ['est/2d', 'p/3']) const props = proposalsFor({ issue: 2, estimate: 'est/2d', priority: 'p/1' }, ['est/2d', 'p/3'])
expect(props.map((p) => p.change.kind)).toEqual(['reprioritize']) // estimate unchanged expect(props.map((p) => p.change.kind)).toEqual(['reprioritize']) // estimate unchanged
@@ -77,3 +104,31 @@ describe('proposalsFor', () => {
expect(proposalsFor({ issue: 2 }, ['est/2d'])).toEqual([]) expect(proposalsFor({ issue: 2 }, ['est/2d'])).toEqual([])
}) })
}) })
describe('unified change model (assign + milestone)', () => {
it('isLabelChange narrows label kinds only', () => {
expect(isLabelChange({ kind: 'reestimate', issue: 1, estimate: 'est/2d' })).toBe(true)
expect(isLabelChange({ kind: 'reprioritize', issue: 1, priority: 'p/1' })).toBe(true)
expect(isLabelChange({ kind: 'assign', issue: 1, assignee: 'christian' })).toBe(false)
expect(isLabelChange({ kind: 'remilestone', issue: 1, milestone: 3 })).toBe(false)
})
it('summarizeChange describes an assignment and an unassignment', () => {
expect(summarizeChange({ kind: 'assign', issue: 1, assignee: 'christian' })).toBe('assign → @christian')
expect(summarizeChange({ kind: 'assign', issue: 1, assignee: null })).toBe('unassign')
})
it('summarizeChange describes a milestone set (by title) and removal', () => {
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: 3, milestoneTitle: 'P5 — Dogfood' })).toBe(
'milestone → P5 — Dogfood',
)
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: 3 })).toBe('milestone → #3')
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: null })).toBe('remove from milestone')
})
it('summarizeChange delegates label kinds to describeChange (needs current labels)', () => {
const change: IssueChange = { kind: 'reestimate', issue: 1, estimate: 'est/5d' }
expect(summarizeChange(change, ['est/2d'])).toBe('est/2d → est/5d')
expect(summarizeChange(change, ['est/5d'])).toBe('no change')
})
})

View File

@@ -17,6 +17,15 @@ import {
export type IssueChange = export type IssueChange =
| { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null } | { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null }
| { kind: 'reprioritize'; issue: number; priority: PriorityLabel | null } | { kind: 'reprioritize'; issue: number; priority: PriorityLabel | null }
| { kind: 'assign'; issue: number; assignee: string | null }
| { kind: 'remilestone'; issue: number; milestone: number | null; milestoneTitle?: string | null }
/** The label axes are the only kinds planned as a label swap; the rest are field writes. */
export function isLabelChange(
change: IssueChange,
): change is Extract<IssueChange, { kind: 'reestimate' | 'reprioritize' }> {
return change.kind === 'reestimate' || change.kind === 'reprioritize'
}
export interface LabelPlan { export interface LabelPlan {
/** The full resulting label-name set (order: kept labels, then the new axis label). */ /** The full resulting label-name set (order: kept labels, then the new axis label). */
@@ -29,7 +38,10 @@ export interface LabelPlan {
noop: boolean noop: boolean
} }
function axisFor(change: IssueChange): { labels: readonly string[]; target: string | null } { /** A change that resolves to a label swap (the only kind `planIssueChange` accepts). */
export type LabelChange = Extract<IssueChange, { kind: 'reestimate' | 'reprioritize' }>
function axisFor(change: LabelChange): { labels: readonly string[]; target: string | null } {
return change.kind === 'reestimate' return change.kind === 'reestimate'
? { labels: ESTIMATE_LABELS, target: change.estimate } ? { labels: ESTIMATE_LABELS, target: change.estimate }
: { labels: PRIORITY_LABELS, target: change.priority } : { labels: PRIORITY_LABELS, target: change.priority }
@@ -40,7 +52,7 @@ function axisFor(change: IssueChange): { labels: readonly string[]; target: stri
* change's axis except the target, adds the target if absent. Setting the axis * 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. * to null clears it. Cleans up a duplicated axis (two `est/*`) as a side effect.
*/ */
export function planIssueChange(current: string[], change: IssueChange): LabelPlan { export function planIssueChange(current: string[], change: LabelChange): LabelPlan {
const { labels: axis, target } = axisFor(change) const { labels: axis, target } = axisFor(change)
const onAxis = current.filter((l) => axis.includes(l)) const onAxis = current.filter((l) => axis.includes(l))
const removed = onAxis.filter((l) => l !== target) const removed = onAxis.filter((l) => l !== target)
@@ -58,18 +70,52 @@ export function describeChange(plan: LabelPlan): string {
return `${from}${to}` return `${from}${to}`
} }
/** A change the agent proposes: the concrete op + its diff, ready for approve-then-apply. */ /**
* One human-readable confirm line for ANY change kind — the unified describe for
* the propose-approve UI. Label kinds delegate to {@link describeChange} (so they
* need the issue's current labels); assign/milestone describe the field write
* directly. Pure; no network.
*/
export function summarizeChange(change: IssueChange, currentLabels: string[] = []): string {
switch (change.kind) {
case 'reestimate':
case 'reprioritize':
return describeChange(planIssueChange(currentLabels, change))
case 'assign':
return change.assignee ? `assign → @${change.assignee}` : 'unassign'
case 'remilestone':
return change.milestone == null
? 'remove from milestone'
: `milestone → ${change.milestoneTitle ?? `#${change.milestone}`}`
}
}
/** A change the agent proposes: the concrete op + its human diff, ready for approve-then-apply. */
export interface ChangeProposal { export interface ChangeProposal {
change: IssueChange change: IssueChange
plan: LabelPlan /** One-line diff for the approve UI — always present ({@link summarizeChange}). */
summary: string
/** The label plan, for label kinds only (absent for assign/milestone). */
plan?: LabelPlan
issueTitle?: string issueTitle?: string
} }
/** What the `propose_change` tool accepts — a target issue and the axes to set. */ /** What the `propose_change` tool accepts — a target issue and the fields to set. */
export interface ProposeChangeArgs { export interface ProposeChangeArgs {
issue: number issue: number
estimate?: EstimateLabel estimate?: EstimateLabel
priority?: PriorityLabel priority?: PriorityLabel
/** Login to assign, or null to unassign. Omit to leave assignee untouched. */
assignee?: string | null
/** Milestone id to set, or null to clear. Omit to leave the milestone untouched. */
milestone?: number | null
}
/** Current state + lookups a proposal needs to skip noops and label the milestone. */
export interface ProposalContext {
currentAssignee?: string | null
currentMilestoneId?: number | null
milestones?: { id: number; title: string }[]
} }
function isEstimate(v: unknown): v is EstimateLabel { function isEstimate(v: unknown): v is EstimateLabel {
@@ -80,25 +126,33 @@ function isPriority(v: unknown): v is PriorityLabel {
} }
/** /**
* Build the concrete, non-noop proposals for a `propose_change` request against * Build the concrete, non-noop proposals for a `propose_change` request. Label
* an issue's current labels. Invalid or unchanged axes are dropped — the agent * axes (est/*, p/*) are validated and never invented; assign/milestone are
* proposes only real changes, and never a label outside the est/* · p/* axes. * emitted only when they differ from the issue's current value (via `ctx`). Each
* proposal carries a human `summary`; label kinds also carry the label plan.
*/ */
export function proposalsFor( export function proposalsFor(
args: ProposeChangeArgs, args: ProposeChangeArgs,
currentLabels: string[], currentLabels: string[],
issueTitle?: string, issueTitle?: string,
ctx: ProposalContext = {},
): ChangeProposal[] { ): ChangeProposal[] {
const out: ChangeProposal[] = [] const out: ChangeProposal[] = []
if (isEstimate(args.estimate)) { const pushLabel = (change: LabelChange) => {
const change: IssueChange = { kind: 'reestimate', issue: args.issue, estimate: args.estimate }
const plan = planIssueChange(currentLabels, change) const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, issueTitle }) if (!plan.noop) out.push({ change, plan, summary: describeChange(plan), issueTitle })
} }
if (isPriority(args.priority)) { if (isEstimate(args.estimate)) pushLabel({ kind: 'reestimate', issue: args.issue, estimate: args.estimate })
const change: IssueChange = { kind: 'reprioritize', issue: args.issue, priority: args.priority } if (isPriority(args.priority)) pushLabel({ kind: 'reprioritize', issue: args.issue, priority: args.priority })
const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, issueTitle }) if (args.assignee !== undefined && args.assignee !== (ctx.currentAssignee ?? null)) {
const change: IssueChange = { kind: 'assign', issue: args.issue, assignee: args.assignee }
out.push({ change, summary: summarizeChange(change), issueTitle })
}
if (args.milestone !== undefined && args.milestone !== (ctx.currentMilestoneId ?? null)) {
const milestoneTitle = ctx.milestones?.find((m) => m.id === args.milestone)?.title ?? null
const change: IssueChange = { kind: 'remilestone', issue: args.issue, milestone: args.milestone, milestoneTitle }
out.push({ change, summary: summarizeChange(change), issueTitle })
} }
return out return out
} }

View File

@@ -120,4 +120,20 @@ describe('forecast', () => {
expect(fitted.coldStart).toBe(false) expect(fitted.coldStart).toBe(false)
expect(fitted.p50Day).toBeLessThan(priors.p50Day) expect(fitted.p50Day).toBeLessThan(priors.p50Day)
}) })
it('capacity lanes parallelize the sim — the makespan shrinks with more workers', () => {
const independent = [issue(1), issue(2), issue(3), issue(4)] // no deps → fully parallelizable
const serial = forecast(independent, [], { trials: 2000, seed: 7 })
const twoLanes = forecast(independent, [], {
trials: 2000,
seed: 7,
workers: [
{ person: 'a', speed: 1 },
{ person: 'b', speed: 1 },
],
})
// two equal lanes ≈ half the serial landing (independent work)
expect(twoLanes.p80Day).toBeLessThan(serial.p80Day)
expect(twoLanes.p80Day).toBeLessThan(serial.p80Day * 0.7)
})
}) })

View File

@@ -18,6 +18,7 @@ import {
schedule, schedule,
type SchedulableIssue, type SchedulableIssue,
} from '../scheduler/scheduler-v0.js' } from '../scheduler/scheduler-v0.js'
import { type LaneInputs, layoutOnLanes, resolveLanes, type Worker } from '../scheduler/scheduler-capacity-v0.js'
export interface LognormalPrior { export interface LognormalPrior {
/** Median log-ratio: sampled median duration = estimate * e^mu. */ /** Median log-ratio: sampled median duration = estimate * e^mu. */
@@ -76,6 +77,12 @@ export interface ForecastOptions {
* the sim; otherwise the code-resident cold-start priors do. * the sim; otherwise the code-resident cold-start priors do.
*/ */
model?: DurationModel model?: DurationModel
/**
* Capacity lanes. When provided, each trial lays sampled durations across the
* lanes (parallel) instead of a single serial worker — the makespan shrinks
* toward the critical path. Empty/absent → single serial worker.
*/
workers?: Worker[]
} }
/** Resolve the lognormal params for an estimate, preferring a fitted model. */ /** Resolve the lognormal params for an estimate, preferring a fitted model. */
@@ -158,15 +165,32 @@ export function forecast(
const priors = order.map((it) => durationParams(it.durationDays, options.model)) const priors = order.map((it) => durationParams(it.durationDays, options.model))
const rng = mulberry32(seed) const rng = mulberry32(seed)
// endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t. // Capacity lanes, when configured — the per-trial layout goes parallel.
const laneInputs: LaneInputs = {
order: order.map((it) => it.number),
blockedBy: new Map(order.map((it) => [it.number, it.blockedBy])),
assignee: new Map(issues.map((i) => [i.number, i.assignee ?? null])),
}
const lanes =
options.workers && options.workers.length ? resolveLanes(options.workers, [...laneInputs.assignee.values()]) : null
// endByRank[k][t] = working day the (k+1)-th issue *to finish* completes on trial t.
const endByRank: number[][] = Array.from({ length: n }, () => new Array<number>(trials)) const endByRank: number[][] = Array.from({ length: n }, () => new Array<number>(trials))
for (let t = 0; t < trials; t++) { for (let t = 0; t < trials; t++) {
let cursor = 0 const sampled = order.map((it, k) => it.durationDays * Math.exp(priors[k].mu + priors[k].sigma * standardNormal(rng)))
for (let k = 0; k < n; k++) { if (lanes) {
const p = priors[k] // parallel: lay the sampled durations across lanes, then sort finish days
const sampled = order[k].durationDays * Math.exp(p.mu + p.sigma * standardNormal(rng)) const byNumber = new Map(order.map((it, k) => [it.number, sampled[k]]))
cursor += sampled const { finishAt } = layoutOnLanes(laneInputs, lanes, (num) => byNumber.get(num)!)
endByRank[k][t] = cursor const finishes = order.map((it) => finishAt.get(it.number)!).sort((a, b) => a - b)
for (let k = 0; k < n; k++) endByRank[k][t] = finishes[k]
} else {
// single serial worker: cumulative sum (already sorted ascending)
let cursor = 0
for (let k = 0; k < n; k++) {
cursor += sampled[k]
endByRank[k][t] = cursor
}
} }
} }

View File

@@ -146,6 +146,22 @@ describe('createGiteaClient.getIssue', () => {
]) ])
}) })
it('listCollaborators maps login+name and prepends the owner when absent', async () => {
const { fetch } = stubFetch([{ login: 'stephen', full_name: 'Stephen M.' }])
const people = await createGiteaClient(CONFIG, fetch).listCollaborators()
// owner (christian) prepended since /collaborators omits them; collaborator name preserved
expect(people).toEqual([
{ login: 'christian', name: 'christian' },
{ login: 'stephen', name: 'Stephen M.' },
])
})
it('listCollaborators does not double-add the owner when already listed', async () => {
const { fetch } = stubFetch([{ login: 'christian', full_name: 'Christian L.' }])
const people = await createGiteaClient(CONFIG, fetch).listCollaborators()
expect(people).toEqual([{ login: 'christian', name: 'Christian L.' }])
})
it('getFile returns null on 404 and content+sha on hit', async () => { it('getFile returns null on 404 and content+sha on hit', async () => {
const miss = stubFetch('nope', 404) const miss = stubFetch('nope', 404)
expect(await createGiteaClient(CONFIG, miss.fetch).getFile('directives/log.jsonl')).toBeNull() expect(await createGiteaClient(CONFIG, miss.fetch).getFile('directives/log.jsonl')).toBeNull()

View File

@@ -101,8 +101,14 @@ export interface GiteaClient {
getIssueTimeline(index: number): Promise<LifecycleEvent[]> getIssueTimeline(index: number): Promise<LifecycleEvent[]>
/** Every label defined on the repo (id + name), for name→id resolution. */ /** Every label defined on the repo (id + name), for name→id resolution. */
listLabels(): Promise<GiteaLabel[]> listLabels(): Promise<GiteaLabel[]>
/** Repo collaborators (login + display name) — the assignable people. */
listCollaborators(): Promise<{ login: string; name: string }[]>
/** Replace an issue's entire label set with the given label ids. Write. */ /** Replace an issue's entire label set with the given label ids. Write. */
setIssueLabels(index: number, labelIds: number[]): Promise<void> setIssueLabels(index: number, labelIds: number[]): Promise<void>
/** Replace an issue's assignees (empty array unassigns); returns the updated issue. Write. */
setIssueAssignees(index: number, logins: string[]): Promise<GiteaIssue>
/** Set (or clear, with null) an issue's milestone by id; returns the updated issue. Write. */
setIssueMilestone(index: number, milestoneId: number | null): Promise<GiteaIssue>
/** Open a new issue with a title, optional body, and label ids. Write. */ /** Open a new issue with a title, optional body, and label ids. Write. */
createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue> createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue>
/** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */ /** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */
@@ -229,6 +235,33 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } }) await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
}, },
async listCollaborators() {
const raw = await requestAll<{ login: string; full_name?: string }>(
(page) => `/collaborators?page=${page}&limit=${PAGE_LIMIT}`,
)
const collabs = raw.map((u) => ({ login: u.login, name: u.full_name || u.login }))
// The owner is assignable but is not returned by /collaborators — prepend them
// so a solo-owner repo still has an assignable person.
if (!collabs.some((c) => c.login === config.owner)) {
collabs.unshift({ login: config.owner, name: config.owner })
}
return collabs
},
async setIssueAssignees(index, logins) {
const raw = (await request(`/issues/${index}`, { method: 'PATCH', body: { assignees: logins } })) as RawIssue
return normalizeIssue(raw)
},
async setIssueMilestone(index, milestoneId) {
// gitea's EditIssueOption takes a milestone id; 0 clears it.
const raw = (await request(`/issues/${index}`, {
method: 'PATCH',
body: { milestone: milestoneId ?? 0 },
})) as RawIssue
return normalizeIssue(raw)
},
async createIssue(input) { async createIssue(input) {
const raw = (await request('/issues', { const raw = (await request('/issues', {
method: 'POST', method: 'POST',

View File

@@ -31,6 +31,8 @@ export interface GiteaHttpResponse {
status: number status: number
json(): Promise<unknown> json(): Promise<unknown>
text(): Promise<string> text(): Promise<string>
/** Present on the real fetch Response; used for SSE streaming (chat). */
body?: ReadableStream<Uint8Array> | null
} }
export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise<GiteaHttpResponse> export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise<GiteaHttpResponse>

View File

@@ -23,8 +23,15 @@ export type {
GiteaRequestInit, GiteaRequestInit,
} from './gitea/types.js' } from './gitea/types.js'
export { describeChange, planIssueChange, proposalsFor } from './changes/apply-changes-v0.js' export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js'
export type { ChangeProposal, IssueChange, LabelPlan, ProposeChangeArgs } from './changes/apply-changes-v0.js' export type {
ChangeProposal,
IssueChange,
LabelChange,
LabelPlan,
ProposalContext,
ProposeChangeArgs,
} from './changes/apply-changes-v0.js'
export { export {
inferColumnV0, inferColumnV0,
@@ -49,6 +56,12 @@ export type {
SchedulePlan, SchedulePlan,
} from './scheduler/scheduler-v0.js' } from './scheduler/scheduler-v0.js'
export { makespan, scheduleWithCapacity } from './scheduler/scheduler-capacity-v0.js'
export type { Worker } from './scheduler/scheduler-capacity-v0.js'
export { capacityPerWorkday, parseCapacityConfig } from './capacity/capacity-v0.js'
export type { CapacityMember } from './capacity/capacity-v0.js'
export { export {
COLD_START_PRIORS, COLD_START_PRIORS,
durationParams, durationParams,
@@ -80,7 +93,7 @@ export type {
} from './calibration/calibration-v0.js' } from './calibration/calibration-v0.js'
export { createChatClient } from './agent/chat-client.js' export { createChatClient } from './agent/chat-client.js'
export type { ChatClient, ChatMessage, CompletionResult, ModelConfig, ToolCall, ToolDecl } from './agent/chat-client.js' export type { ChatClient, ChatMessage, CompletionResult, ModelConfig, OnToken, ToolCall, ToolDecl } from './agent/chat-client.js'
export { pickModel } from './agent/model-router.js' export { pickModel } from './agent/model-router.js'
export type { ModelRouter, TaskKind } from './agent/model-router.js' export type { ModelRouter, TaskKind } from './agent/model-router.js'
export { runAgentTurn } from './agent/agent-loop.js' export { runAgentTurn } from './agent/agent-loop.js'

View File

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

View File

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

2436
yarn.lock

File diff suppressed because it is too large Load Diff