Voice: rewrote REGINALD_SYSTEM and CAPTURE_SYSTEM (core) to an elevated,
dry butler register, and re-voiced his visible lines — chat greetings, the
approve/dismiss/error replies, the standup closer ('The kettle is on. Yours,
Reginald.'), the onboarding welcome, and the capture prose. Both system prompts
now also instruct him never to use an em dash.
Em-dashes: swept every user-facing string in the renderer free of em-dashes
(punctuation only, comments left untouched) via a per-file pass, plus the core
tool descriptions and the memory focus-slot placeholder ('· ' not '— '). Bare
'—' value placeholders became middots ('·'). No em-dash now renders anywhere
in the app or in Reginald's own output.
core 169 tests green (updated the memory placeholder assertion) · core + desktop
tsc clean · verified visually (posh greeting + standup closer render).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- perf.test.ts: the @200 absolute check flaked under machine load (single-shot vs
a 1000ms bound; nominal ~230ms). Measure best-of-3 (a micro-benchmark's minimum
reflects true compute cost, not load spikes) against a 1500ms catastrophic-
regression guard. The scaling test remains the real O(n²) guard.
- ModelAwayState: remove the hardcoded 'queued: 1 directive' badge (no live queue
count is wired) and the now-unused Badge import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 4-step wizard is now fully live — nothing faked.
Backend (core + main):
- GiteaClient.createLabel (POST /labels).
- core/gitea/bootstrap.ts: SCHEMA_LABELS (the full est/p/deadline vocabulary with
colors+descriptions), applySchemaLabels (idempotent — creates only missing),
repoExists + ensurePmStateRepo (token-scoped, org vs personal endpoint). 7 tests.
- config:bootstrap IPC handler + preload bridge + BootstrapResult type.
Wizard:
- Connect step → live config.discover ('connected · N repos visible' is real).
- Repo step → owner dropdown + repo radios from the discovered repos.
- Bootstrap step → real: applies the label schema + ensures the pm-state sidecar,
shows the actual outcome (created vs already-present), then saves config.
- Gate: first run (no config) → wizard; reconnect (existing) → the compact card.
Removed the dead 'firstrun' dev view.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1 — cut showcases:
- Delete gallery.tsx (Primitives) and StatesScreen/Specimen from states.tsx
(keep the reusable EmptyState/OfflineBanner/ModelAwayState).
- Delete placeholder-screen.tsx ('built in a later phase' stub).
- app-shell: drop the states/primitives views, the dev-rail block, the
PHASE/TITLE maps, the INBOX_UNREAD=3 fixture fallback, and the now-dead demo state.
P2 — real connectivity:
- Replace the fake 'toggle the connection (demo)' button with a live status dot
derived from the reconcile: green online, red when serving the stale cache
(gitea unreachable), amber while connecting. OfflineBanner + chat offline now
reflect real state, not a manual toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cache is a rebuildable index over gitea, never a source of truth (D4).
Two tests lock that invariant where the durable cache actually lives:
- packages/core: cache-purity-v0.test.ts — file-backed node:sqlite. Build the
SQLite mirror from a gitea snapshot, capture every re-derived field, delete the
.sqlite file, rebuild from the same snapshot, assert byte-identical. Plus a
structural D4 guard: every issues-table column must map to a gitea field, so a
future user-authored column can't silently break rebuild-ability.
- apps/desktop: snapshot-store.test.ts — the shipped durable cache is the JSON
snapshot-store. Delete the file → loadSnapshot returns null (degrades to
no-cache, never throws), which is what forces the next getSnapshot to reconcile
fresh from gitea. Corrupt/partial files are likewise treated as no-cache.
Stands up vitest for the desktop main process (first unit tests there);
electron is mocked, snapshot path is injected.
No native better-sqlite3 shipped: the SQLite mirror has no consumer on any hot
path yet, so wiring it into main (native module + asarUnpack + dmg re-verify)
would add packaging risk for no runtime benefit. The purity invariant is proven
at the seam for both caches; the native driver migration is deferred until
SQLite becomes load-bearing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- index.ts: keep both cache (#3) and queue (#33) exports
- agent.test.ts: #28 landed the standup impl but left agent.test.ts asserting
standup is notImplemented (its real test moved to query-project.test.ts);
retarget the unbuilt-view assertion to milestone/runway, which are still stubs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lock in the PLAN.md compute targets so a regression that slips an O(n²) into the
scheduler or forecast fails the suite:
- scheduler + capacity layout + Monte Carlo forecast < 1s @ 200 open issues —
measured 232ms, comfortable headroom.
- scaling stays ~linear (400 issues ≈ 3.9x the 100-issue time; asserts < 8x to
rule out O(n²) while tolerating jitter).
Representative fixture: 200 open issues with varied estimates/priorities/assignees
across 3 capacity lanes + a light acyclic dependency web. Bounds are the real
targets with margin so timing jitter can't flake CI; actuals are logged.
Reconcile-<5s@500 is network-bound (~2N gitea calls) and stays covered by the live
reconcile — this benchmarks the pure compute the app runs each turn. +2 core tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
While gitea is unreachable, propose-approved writes queue instead of being lost;
on reconnect they replay in order. The hard requirement is no duplication.
The mechanism is coalescing by axis. Every write targets one field of one issue
(estimate / priority / assignee / milestone), so `issue:kind` is the axis identity.
Queuing a second write to the same axis supersedes the first (moved to the tail),
so a burst of edits replays as one final write — and replay is idempotent (the
apply path no-ops a change already reflected server-side).
- `enqueueWrite` (coalesce), `pendingWrites`, `coalesceKey`, `affectedIssues`
(what a post-replay reconcile re-reads), and `replayQueue(queue, apply)` which
drains through the same guarded write path and returns the writes that still
failed so they stay queued. Never throws — a failure is data.
Acceptance met: a burst of offline edits + reconnect lands the final state with a
single apply per axis (not one per edit); still-failing writes stay queued. +4
core tests; typecheck green.
Follow-up: persist the queue in main + trigger replay on the reconnect signal
(the offline banner + disabled composer already exist) — the coalesce/replay
core is the tested heart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reginald's context is tiered so the model always sees what matters without ever
copying ticket data into the prompt:
- HOT (this module) — charter + active directives + the focus snapshot, packed
under a hard token budget (2k). Assembled fresh each turn as the prompt seed.
- WARM — the append-only directive/event ledger + digest, summarized on demand.
- COLD — gitea + sidecar via query_project. Ticket bodies/comments/detail live
here and are NEVER inlined; the model fetches them by number when needed.
- `assembleHotContext(inputs, budget=2000)`: focus (tiny, always kept) → most
recent active directives (each while they fit ~⅔) → charter fills the true
remainder, truncated on a line boundary. Measures the fixed tail exactly and
reserves for header/joiner/ellipsis so the total never exceeds budget.
- `estimateTokens` (tokenizer-free ~4 chars/token, slight over-estimate so a real
tokenizer stays under), `activeDirectives` (accepted/amended, most-recent-first).
Acceptance met: hot assembles under the 2k budget even with a ~34k-token charter;
nothing ticket-shaped is inlined (only numbers + titles for focus). +5 core tests;
full core suite + typecheck green.
Follow-up: wire assembleHotContext into the live system prompt in main (needs
charter + directives + focus at chat time) — the assembly + budget is the tested core.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The StandupScreen already renders drift + plan + nag from real data (standup-view,
#52), but the agent's query_project standup view was a `notImplemented` stub, so
Reginald couldn't answer standup questions from deterministic data.
Implement `standupView(snap, asOf)`: today's plan (the scheduler's earliest pick
per person, with why — critical path / blocks / order), overnight drift (real
anomalies: issues sitting in review, or steeping past their estimate), and the
single stalest blocker to nag about (+ what it blocks). All deterministic; the
model narrates. Added 'standup' to the query_project tool's view enum.
Acceptance met: standup surfaces schedule drift + at least one stale blocker
(and stays calm — no nag, empty drift — when nothing is steeping). +2 core tests;
typecheck clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A rebuildable local mirror of the reconciled backlog — an index over gitea's
durable truth, never the source of truth (D4). This lands the core seam:
- `cache/cache-v0.ts`: the 5-table schema (issues/labels/milestones/comments/
issue_events), a `CacheDriver` injected-IO interface (exec/run/get/all), and
pure row<->domain mappers. `upsertIssue` mirrors one issue (+ its milestone),
upsert-by-number so a re-reconcile updates in place; `readIssue` re-derives
`facts` from the stored label set via extractLabelFacts, so the mirror can't
drift from the label semantics. Facts are never stored.
- Resolves#3's open scope: pure mappers + SQL in core (driver-agnostic), native
driver in main. Tests bind node:sqlite (present in the Node 24 test runtime) to
the same CacheDriver seam better-sqlite3 will fill in main.
Acceptance met: upsert one reconciled issue, read back, assert
estimateDays/priority/hardDeadline (+ upsert-in-place, no-milestone, and miss
cases). Core suite green; typecheck clean.
Follow-up (noted in the plan): the main-process better-sqlite3 adapter +
snapshot-store migration — packaging-sensitive (native module), kept out of this
slice so the shippable .dmg stays verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cold-start surface showed "N/20 closed issues estimated", implying you're
just (20−N) closes away. But calibrationSamples silently drops closed+estimated
issues that closed in 0 working days (same-day closes) — real closes that
structurally can't calibrate. On this repo that's 10 of 24 closes hidden: the
note read 14/20 as if 6 away, when a third of the history will never count.
- core: `calibrationCoverage(issues, timelines, asOf)` → { candidates, usable,
excludedSameDay }, counting the silently-excluded same-day closes. Pure, tested.
- surface it: CalibrationData gains `excludedSameDay`; backlogCalibration returns
the coverage; the Runway note and the Calibration screen now say "… · N same-day
closes can't calibrate" so the thin sample is explained, not just reported.
Verified on christian/commitea: closed=24, usable=14, excludedSameDay=10.
131 core green (incl. new coverage test); core + desktop typecheck; 14 fixture e2e.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
The last agent tool. When the PM states standing intent ("pilots come first"),
Reginald logs it verbatim to an append-only JSONL ledger in the pm-state repo —
a directive is intent; its effects still land through propose_change. This
completes Reginald's tool surface: query_project · propose_change · capture_work
· record_directive.
core (@commitea/core):
- directives/record-directive-v0: schema (kind/quote/target/params/rationale +
id/ts/status), serialize/parseDirectiveLog (ts-ordered, seq computed on read,
corrupt lines skipped), appendDirective (concatenation merge), toDirectiveInput.
- RECORD_DIRECTIVE_TOOL + system prompt update ("log standing intent; never claim
a change is applied").
- gitea client: getFile/putFile (contents API, base64-agnostic) for the pm-state repo.
app:
- main: a pm-state client (same token, `commitea-pm-state` repo — the purity
split, D4); appendDirectiveEntry (read→append→write, id/ts stamped here),
readDirectives. model:chat executes record_directive; pmstate:directives reads
the ledger. Degrades cleanly when the pm-state repo is absent.
- Directives screen shows the real ledger when present, the fixture demo otherwise.
Note: the pm-state repo isn't created yet — my token lacks write:user (repo
creation). Create `commitea-pm-state` (private) to activate the live path; all the
code + tests are in place. Override with COMMITEA_PMSTATE_REPO.
Verified: 116 core tests green (8 directive + 2 contents-API added), desktop
typecheck clean, 14 fixture e2e green. Gated live test: the real gemma-4-26b calls
record_directive for "pilots come first" (logs intent, doesn't claim to apply it);
the append/read + POST/PUT contents paths are unit-tested.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The last big agent capability. In the Capture screen, a rough braindump runs real
big-model decomposition into a small, estimated issue set; you review/edit the
labels and approve, and the issues are opened in gitea. This is the one place the
big model earns its keep (docs/agent-tools.md).
core (@commitea/core):
- capture-work: PROPOSE_ISSUES_TOOL + CAPTURE_SYSTEM; captureWork(complete, dump)
forces a single structured decomposition and returns validated issues; parseCaptureArgs
drops blank titles + invalid est/p labels. ProposedIssue / CaptureProposal.
- gitea client: createIssue({title, body?, labelIds?}) → POST /issues, normalized.
app:
- model bridge model:capture runs captureWork on the (loaded) big model.
- gitea bridge gitea:createIssues opens each approved issue with its est/* + p/*
labels (reusing the #41 label-id resolver — zero-pollution, no invented labels).
- Capture screen: when a model is configured, "Brew tickets" runs real capture and
"Approve all" files the set; otherwise the scripted demo interview runs. Fixed a
race — the brew handler re-checks model status at click time so a configured
model never falls into the scripted path before status resolves.
Verified: 108 core tests green (7 capture + createIssue added), desktop typecheck
clean, 14 fixture e2e green. Gated live e2e against gemma-4-26b: the auth braindump
→ 3 real tickets ("Resolve token refresh + session staleness" est/3d p/1, "Fix
webhook double-firing" est/2d p/2, "Write auth setup docs" est/1d p/3), reviewable
and editable; Discard so the test files nothing (createIssue POST is unit-tested).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes "chat is the write-path" (decisions.md D1). Ask Reginald to re-estimate
or reprioritize an issue; it formulates a proposal, you approve it inline, and the
write runs through the same guarded apply_changes engine the Issue screen uses.
The model never writes — it proposes; the app owns approval + execution.
core (@commitea/core):
- propose_change tool declaration + REGINALD_SYSTEM updated ("never claim a change
is applied; you propose, the human approves").
- proposalsFor(args, currentLabels, title): pure — builds the concrete, non-noop
ChangeProposal(s) (change + label diff) for a propose_change request, dropping
invalid/unchanged axes. ChangeProposal / ProposeChangeArgs types.
app:
- model bridge executes propose_change by planning against the issue's current
labels (no write) and returns the proposals with the turn.
- useChat surfaces pending proposals + approve/dismiss; approve calls onApplyChange
(AppShell's guarded handler → PUT + board/forecast refetch), dismiss leaves it.
- ChatPanel renders each proposal as a propose-approve card (diff + Approve/Dismiss).
Verified: 101 core tests green (4 proposalsFor added), desktop typecheck clean,
14 fixture e2e green. Gated live e2e against gemma-4-26b: "Set the estimate on #3
to est/5d" → Reginald proposes "est/2d → est/5d" as an inline card, says it's
*proposed* not done; Dismiss leaves the repo untouched. The approve→write path is
the #41 engine (separately verified change→revert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fixture chat panel is now a working agent. Ask Reginald a question and it
consults the real project through a tool loop, then answers in grounded prose.
Read-only v0 — writes still go through the propose-approve controls.
core (@commitea/core/agent):
- chat-client: OpenAI-wire chat completions over an injected fetch (same seam as
gitea). Points at any OpenAI-compatible endpoint (LM Studio/Ollama/OpenAI).
- model-router: small model for prose + the read tool; big model reserved for
later decomposition (pickModel).
- agent-loop: runAgentTurn drives call→tool→result→call until prose (or a step
budget), recording each tool step. Injected complete + execute → fully testable.
- query-project: the single read tool's engine — compact focus/board/calibration/
issue/search views built from scheduler + lifecycle + calibration; unbuilt views
return a notImplemented marker (never fabricated). The model reports, never computes.
- agent-tools: query_project declaration + Reginald's system prompt.
app:
- main model bridge (model:status, model:chat) runs the loop; query_project
reconciles the repo and builds the view. Model traffic stays in main (token/CSP).
gitea.ts refactored to share getGiteaClient + reconcileSnapshot.
- preload + global.d.ts expose the model bridge; useChat drives the panel — real
agent turn when a model is configured, scripted fixture reply otherwise (so
fixture e2e is unchanged). A subtle "consulted the project" activity line.
Model config (env, defaults to LM Studio on :1234): COMMITEA_MODEL_URL /
_SMALL (google/gemma-4-e4b) / _BIG (qwen/qwen3.6-35b-a3b). COMMITEA_E2E=1 keeps
it unconfigured so the panel stays scripted.
Verified: 88 core tests green (14 agent: client parse, loop tool/error/budget,
all views) + a gated live integration test. Desktop typecheck clean, 14 fixture
e2e green. Gated live e2e drives the real app against gitea + gemma-4-e4b: asked
"what now?", Reginald called query_project and answered "focus is on issue #2"
(the real scheduler pick).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first write path. Read, forecast, and calibration were all real; now you can
*manage* CommiTea from CommiTea. Estimates/priority are exclusive label axes, so
a change is a label swap — proposed, approved, then written. Nothing is assumed.
core (@commitea/core):
- planIssueChange(current, change): pure diff planner — swaps the est/*|p/* axis,
clears on null, dedups a doubled axis; returns the resulting label set + a
before/after diff + noop flag. describeChange() renders "est/2d → est/5d".
- request() seam extended for writes (method/body, JSON, 204). client gains
listLabels() (name→id) and setIssueLabels() (PUT /issues/{n}/labels).
app:
- main bridge gitea:applyChange — resolves plan.labels → ids (cached, refetch on
miss), PUTs, returns the plan + fresh issue. Token never leaves main.
- preload + global.d.ts expose applyChange; useBacklog returns a refetch so a
write re-reconciles the board + forecast.
- Issue screen: an Adjust button (shown only when configured) opens a
propose-approve Dialog — estimate/priority pickers, live "est/3d → est/8d"
consequence, Apply/Cancel. AppShell wires it, reflects new labels on the open
issue immediately, and refetches.
Verified: 83 core tests green (7 apply-changes + 2 client-write new), desktop
typecheck clean, 14 fixture e2e green. Live spec exercises propose + CANCEL (no
mutation); the real PUT was verified once manually (change #2 est/3d→est/8d→200,
reverted clean). Icon: pencil (no sliders-horizontal in the set).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the D3 loop. The forecast now learns from the team's own estimate-vs-actual
history (the working time #5 infers from git events) instead of guessing forever.
core (@commitea/core/calibration-v0):
- fitCalibration(samples): lognormal fit on log(actual/estimate) — global +
per-bucket (once a bucket clears the floor) + per-person bias. coldStart until
n >= 20 closed-with-estimate issues.
- calibrationSamples(): pull those samples from the closed backlog via lifecycle
inference (estimate label vs inferred actualWorkingDays).
- toDurationModel(): project the fit to the params forecast consumes.
- forecast() gains options.model: when past cold-start, fitted params drive the
sim (per bucket, global fallback); otherwise the code priors do. Forecast.coldStart
now reflects the model. nearestBucket extracted + exported.
app:
- AppShell fits calibration once from the reconciled backlog, feeds the model into
forecastBacklog (cone), and drives the Calibration screen + Runway header.
- Focus cone footer, Runway note, and Calibration screen now say cold-start (N/20)
vs calibrated (on N closed) from real data; Calibration scatter / bucket bias /
per-person all fitted, degrading honestly on a thin dataset.
Known refinement: same-day closes yield 0 working-day actuals (day-granular) and
are excluded, so a fast-moving repo can sit at n=0 — honest, but a fractional
(hours-based) actual would let those count. Per-person uses gitea login, not
display name, until the person map lands.
Note: also re-lands #10 (Monte Carlo) and #5 (lifecycle) which merged into their
stacked base branches but never propagated to main (stacked-merge trap); this
branch is cut from main and carries all three so main is whole again.
Verified: 74 core tests green (9 calibration + 2 forecast-switch added), desktop
typecheck clean, 14 fixture e2e green, live spec asserts the real cold-start
calibration surface (Runway note + screen badge fitted from actuals).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fill the board's Steeping / In-review columns (and the calibration actuals)
from real gitea timeline events, replacing the three-column-only v0.
core (@commitea/core):
- inferLifecycle(issue, events, asOf): five-column inference — closed → done;
open PR ref → review; commit ref → steeping; any triage signal → triage;
else diagnosis. Earliest event of each kind fixes the stage timestamp.
- Derives actualWorkingDays (work-start → close) — the estimate-vs-actual the
calibration fit (D3) learns from — and steepingDays (first commit → now) for
the board age badge.
- workingDaysBetween(): whole Mon–Fri days in [start, end), day-granular.
- normalizeTimeline() + client.getIssueTimeline(): map gitea's raw timeline
(label/milestone → triage, commit_ref → commit, pull_ref → pull, close,
reopen), drop the rest. Paginated.
app:
- reconcile now fetches every issue's timeline and returns it keyed by number;
threaded through the bridge → useBacklog → board/focus.
- issuesToBoardColumns + scheduleFocus run inferLifecycle: real Steeping/In-review
columns, steeping-age `days` badge, focus-card steeping badge.
Known refinement: gitea's pull_ref fires on any PR mention, so an issue merely
referenced in a PR body can read as In-review; distinguishing closing refs from
mentions needs the PR link's state (later). Re-opening multi-segment actuals
also deferred.
Verified: 63 core tests green (15 lifecycle, incl. workingDaysBetween + the five
transitions), desktop typecheck clean, 14 fixture e2e green, live spec asserts
the board's Done column is populated from real events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the demo cone on Morning service with a real, seeded Monte Carlo
forecast over the open backlog. The LLM never does this — it's plain,
reproducible code (evidence-based scheduling).
core (@commitea/core/forecast-v0):
- Code-resident lognormal cold-start priors per estimate bucket (D3):
sampled actual = estimate * exp(N(mu, sigma)), mu > 0 (actuals run long),
sigma shrinks as tickets grow. Replaced by the team's empirical fit at
n >= 20 (#5 supplies the actuals).
- forecast(): seeded mulberry32 + Box-Muller over the scheduler's
deterministic order (order is fixed from estimates/deps; only durations
vary, so the cone stretches, never reorders). Returns p50/p80/p95 landing
+ a per-issue burn-up curve (p10/p50/p90). 12 unit tests; reproducible.
renderer:
- lib/dates.ts: working-day -> calendar mapper (skips weekends) + buildBurnUpData.
- BurnUpCone gains a data-driven twin; falls back byte-identical to the
fixture cone when no forecast (demo mode unchanged).
- Focus card shows the real "80% of the open backlog lands by <range>",
real scope count, and names the cold-start priors.
v0 scope (each a later slice): single serial worker (capacity is #8);
cold-start priors only (empirical fit is #5); no historical actual polyline
(needs lifecycle events, #5). Header chrome (reconcile time, ahead/behind
badge) stays fixture until milestone due dates land.
Verified: 51 core tests green, desktop typecheck clean, 14 fixture e2e green,
live spec asserts the real cone renders (25 open issues, "lands by Nov 11-27").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CommiTea now recommends its own next unit of work from the live backlog.
- @commitea/core: `schedule()` — dependency topo-sort with priority +
estimate tie-breaks, single serial capacity, cycle detection, and
critical-path marking; `selectFocus()` takes the top three. Pure,
deterministic; the LLM does none of this. +11 tests (39 in core).
Client gains `getIssueDependencies`.
- main: reconcile also fetches native issue dependencies for the open
scope and returns edges.
- renderer: `scheduleFocus()` maps real issues+deps→Now/Next/Later;
Focus renders scheduler output (fixture fallback when unconfigured).
v0 scope (each a later slice): single serial worker (per-person
capacity #8), point durations (Monte Carlo cone #10), estimate-only
(calibration #5). Verified: 14 e2e green (fixtures) + gated live spec —
the board shows the real 25 open + 9 closed, and Focus picks #2
ChangeSource (critical path) as Now. Screenshots confirmed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The app now displays its own live backlog instead of fixtures. First
end of the sync loop — the tap-root (#1) grows list reads and a read
path through the Electron main process.
- @commitea/core: client gains `listIssues` (paginated, PRs excluded)
and `listMilestones`; a `lifecycle-v0` mapper (closed→done,
labelled/milestoned→triage, bare→diagnosis — steeping/review await
event inference in P1-5). +10 unit tests.
- main: gitea bridge over IPC (token stays in main, never the renderer);
config from env / .env.local; gated off under COMMITEA_E2E so the
committed e2e stays on fixtures. Preload exposes the typed bridge.
- renderer: useBacklog() reconciles once on mount; issuesToBoardColumns
shapes real issues into The pot. Board takes optional real columns +
a loading state, falling back to demo fixtures when unconfigured.
Verified: 14 e2e green (fixture mode) + a gated live spec that launches
against the real repo — the board renders the actual 25 open + 9 closed
issues (screenshot). SQLite mirror + reconcile-on-a-timer + lifecycle
event inference are the next slices (#2/#3/#5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
createGiteaClient(config, fetchImpl).getIssue(index) fetches one issue
and normalizes raw gitea JSON to a typed GiteaIssue with scheduler
facts precomputed via extractLabelFacts. Network is an injected
FetchLike (core has no DOM lib; global fetch is structurally
assignable), so it unit-tests against a stub — no live calls in the
suite. Non-2xx responses throw GiteaApiError carrying status + body.
Closes P1-1. Verified end-to-end against the live repo's issue #9.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>