Compare commits

12 Commits

Author SHA1 Message Date
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
Croissant Le Doux
2f6636684e feat: persist the reconcile cache to disk — instant boot + offline reads
Extends the in-memory cache into a durable mirror. The reconcile snapshot is
written to disk on every successful reconcile; on boot the app shows it instantly
(stale-while-revalidate) instead of a blank board, and if gitea is unreachable,
reads fall back to it (offline). Rebuildable — the durable truth stays in gitea.

- snapshot-store.ts: load/save the snapshot as JSON in app userData (never throws;
  corrupt/absent → "no cache"). At this scale (~34 issues, 37KB) the whole snapshot
  fits in memory, so a JSON file beats indexed SQL — no query benefit yet, no
  native-module (better-sqlite3/electron-rebuild) or WASM dependency. That's the
  next step if the mirror ever needs indexed queries over larger data.
- gitea.ts: getSnapshot persists on a fresh pull; bootSnapshot() returns the
  persisted snapshot (without seeding the cache — agents still reconcile fresh);
  gitea:boot serves it; gitea:reconcile falls back to it on failure (stale:true).
- useBacklog: stale-while-revalidate — boot instantly, then a fresh reconcile
  supersedes; a reconcile error keeps the shown snapshot instead of erroring.

Verified: desktop typecheck clean, 14 fixture e2e green. Live: the snapshot
persists (34 issues / 44 deps / 34 timelines / 5 milestones written to disk); a
second launch with gitea unreachable renders the full real board — NOW/NEXT/LATER
+ the Monte Carlo cone — entirely from the cache (new live-persistence e2e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:43:44 -04:00
Croissant Le Doux
fca36f9075 perf: main-process reconcile cache — stop refetching the repo on every tool call
A full reconcile is ~2N gitea calls (deps + timelines per issue). Every agent
tool call (query_project) was doing a fresh one; the UI reconcile and the agent
didn't share anything. Now a single in-memory snapshot cache backs both.

- gitea.ts: getSnapshot(client, { maxAgeMs }) — reads within the window reuse the
  cache; maxAgeMs:0 forces fresh. invalidateSnapshot() drops it. The explicit UI
  reconcile forces fresh (and warms the cache); agent tool calls tolerate a 30s
  TTL to stay responsive; applyChange + createIssues invalidate so the board and
  forecast reflect the write immediately.
- model.ts: query_project reads getSnapshot (30s TTL) instead of reconciling live.

This is the SQLite mirror's cache semantics in memory — rebuildable, the durable
truth stays in gitea (purity split, D4). Persistent SQLite (offline + instant
boot) is a separate slice: Electron 34's Node 20 has no node:sqlite, so it needs
better-sqlite3 + electron-rebuild or sql.js/WASM — deferred as its own decision.

Verified: desktop typecheck clean, 14 fixture e2e green, live Reginald still
answers correctly from the cache (writes invalidate → board stays correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:18:47 -04:00
aab7fd6eeb Merge pull request 'Verify record_directive end-to-end against the pm-state repo' (#46) from p4/directive-verify into main
Reviewed-on: #46
2026-07-09 03:12:50 +00:00
Croissant Le Doux
469c63fb95 test: verify record_directive end-to-end + label the ledger in the activity line
The pm-state repo now exists, so the last unverified path is proven: Reginald
records a directive to the real ledger.

- chat panel: the "consulted" activity line names record_directive as "the
  directive ledger" (matching query_project → the project, propose_change → the labels).
- live-directive e2e (gated): a directive typed in chat → Reginald logs it and
  says "recorded", consulting the directive ledger.

Verified live against gemma-4-26b + the commitea-pm-state repo: "freeze scope for
beta, pilots come first" appended to directives/log.jsonl as
{kind: scope, quote: <verbatim>, status: accepted} — logged, never claimed applied.
Reginald is now complete and every tool is verified end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:07:21 -04:00
3d8a320a04 Merge pull request 'P4: complete Reginald — capture_work + record_directive' (#45) from p4/capture-work into main
Reviewed-on: #45
2026-07-09 03:01:51 +00:00
Croissant Le Doux
ba9ea43b4c feat: record_directive — the PM's ledger in pm-state (P4, completes Reginald)
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>
2026-07-08 22:39:12 -04:00
Croissant Le Doux
6e8a6a15bc feat: capture_work — braindump → decomposed issues → filed in gitea (P4)
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>
2026-07-08 22:20:00 -04:00
652c8c86b9 Merge pull request 'P4: writes via chat — Reginald proposes, you approve inline' (#43) from p4/chat-writes into main
Reviewed-on: #43
2026-07-09 02:17:46 +00:00
Croissant Le Doux
47c45ffa3e feat: writes via chat — Reginald proposes, you approve inline (P4)
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>
2026-07-08 21:38:25 -04:00
31 changed files with 1541 additions and 91 deletions

View File

@@ -41,6 +41,18 @@ 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 (which lists Beta / Pilot-ready / v1.0).
await expect(win.getByText(/P2 — Scheduler/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' })
// Milestone drill-in — clicking a real milestone opens its real detail
await win.getByText(/P2 — Scheduler/).click()
await expect(win.getByRole('heading', { name: 'P2 — Scheduler + Monte Carlo' })).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' })

View File

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

View File

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

View File

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

View File

@@ -30,4 +30,27 @@ test.describe('live Reginald', () => {
await app.close() await app.close()
}) })
test('proposes an estimate change for inline approval (writes via chat)', async () => {
test.skip(!process.env.GITEA_LIVE || !process.env.COMMITEA_MODEL_LIVE, 'live model test — opt-in')
test.setTimeout(300_000)
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded')
await expect(win.getByText(/· local$/)).toBeVisible({ timeout: 20000 })
const composer = win.getByPlaceholder(/Tell me what to do/)
await composer.fill('Set the estimate on issue #3 to est/5d.')
await composer.press('Enter')
// propose_change → an inline propose-approve card (never an auto-write)
await expect(win.getByText('Proposed · #3')).toBeVisible({ timeout: 240_000 })
await expect(win.getByText(/→ est\/5d/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-reginald-propose.png'), fullPage: true, animations: 'disabled' })
// Dismiss so the live run never mutates the repo (the write path itself is #41-tested)
await win.getByRole('button', { name: 'Dismiss' }).click()
await expect(win.getByText(/Left #3 as it was/)).toBeVisible()
await app.close()
})
}) })

View File

@@ -6,21 +6,29 @@
* properly later. * properly later.
*/ */
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { import {
appendDirective,
createGiteaClient, createGiteaClient,
type DirectiveEntry,
type GiteaClient, type GiteaClient,
type GiteaConfig, type GiteaConfig,
type GiteaLabel, type GiteaLabel,
type IssueChange, type IssueChange,
type LifecycleEvent, type LifecycleEvent,
makeDirectiveEntry,
parseDirectiveLog,
planIssueChange, planIssueChange,
type ProjectSnapshot, type ProjectSnapshot,
type DirectiveInput,
} from '@commitea/core' } from '@commitea/core'
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { loadSnapshot, saveSnapshot } from './snapshot-store.js'
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
function loadEnvLocalToken(): string | undefined { function loadEnvLocalToken(): string | undefined {
let dir = process.cwd() let dir = process.cwd()
@@ -62,6 +70,45 @@ export function getGiteaClient(): GiteaClient | null {
return sharedClient return sharedClient
} }
// 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).
let pmStateClient: GiteaClient | null | undefined
export function getPmStateClient(): GiteaClient | null {
if (pmStateClient === undefined) {
const config = resolveConfig()
pmStateClient = config
? createGiteaClient({ ...config, repo: process.env.COMMITEA_PMSTATE_REPO ?? 'commitea-pm-state' }, fetch)
: null
}
return pmStateClient
}
const DIRECTIVE_LOG_PATH = 'directives/log.jsonl'
async function readDirectiveLog(client: GiteaClient): Promise<{ text: string; sha: string | null }> {
const file = await client.getFile(DIRECTIVE_LOG_PATH)
if (!file) return { text: '', sha: null }
return { text: Buffer.from(file.contentBase64, 'base64').toString('utf8'), sha: file.sha }
}
/** Record a directive: read the ledger, append, write it back (concatenation merge). */
export async function appendDirectiveEntry(client: GiteaClient, input: DirectiveInput): Promise<DirectiveEntry> {
const entry = makeDirectiveEntry(input, randomUUID(), new Date().toISOString())
const { text, sha } = await readDirectiveLog(client)
const next = appendDirective(text, entry)
await client.putFile(DIRECTIVE_LOG_PATH, {
contentBase64: Buffer.from(next, 'utf8').toString('base64'),
message: `directive: ${entry.kind}`,
sha: sha ?? undefined,
})
return entry
}
export async function readDirectives(client: GiteaClient) {
const { text } = await readDirectiveLog(client)
return parseDirectiveLog(text)
}
/** 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,
@@ -81,16 +128,81 @@ export async function reconcileSnapshot(
return { issues, milestones, deps, timelines } return { issues, milestones, deps, timelines }
} }
type Snapshot = Awaited<ReturnType<typeof reconcileSnapshot>>
/**
* A single in-memory reconcile cache shared across the app. A full reconcile is
* ~2N gitea calls (deps + timelines per issue); without this, every agent tool
* call refetched the whole repo. Reads within `maxAgeMs` reuse the cache;
* `getSnapshot({ maxAgeMs: 0 })` forces a fresh pull (the explicit UI reconcile),
* and any write calls `invalidateSnapshot()` so the next read sees it. The cache
* is rebuildable — the durable truth stays in gitea (the purity split, D4).
*/
let snapshotCache: { snap: Snapshot; at: number } | null = null
export async function getSnapshot(client: GiteaClient, opts?: { maxAgeMs?: number }): Promise<Snapshot> {
const maxAgeMs = opts?.maxAgeMs ?? 0
if (snapshotCache && maxAgeMs > 0 && Date.now() - snapshotCache.at <= maxAgeMs) {
return snapshotCache.snap
}
const snap = await reconcileSnapshot(client)
snapshotCache = { snap, at: Date.now() }
saveSnapshot(snap, new Date().toISOString()) // persist for instant boot + offline
return snap
}
/** Drop the cache so the next read reflects a just-made write. */
export function invalidateSnapshot(): void {
snapshotCache = null
}
/**
* The last persisted snapshot (from a previous session), for instant boot. The
* renderer shows it immediately, then a real reconcile supersedes it
* (stale-while-revalidate). Returns null when there's nothing on disk; its
* `savedAt` marks staleness. It does NOT seed the cache — agent tool calls
* always reconcile fresh so they never reason over stale data.
*/
export function bootSnapshot(): (Snapshot & { savedAt: string }) | null {
const persisted = loadSnapshot()
if (!persisted) return null
return {
issues: persisted.issues,
milestones: persisted.milestones,
deps: persisted.deps,
timelines: persisted.timelines,
savedAt: persisted.savedAt,
} as unknown as Snapshot & { savedAt: string }
}
/** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */
export const AGENT_SNAPSHOT_TTL_MS = 30_000
export function registerGiteaIpc(): void { export function registerGiteaIpc(): void {
const client = getGiteaClient() const client = getGiteaClient()
const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null
ipcMain.handle('gitea:status', () => ({ configured: !!client, repo })) ipcMain.handle('gitea:status', () => ({ configured: !!client, repo }))
// Instant boot: the last persisted snapshot, shown before the fresh reconcile lands.
ipcMain.handle('gitea:boot', () => {
if (!client) return { configured: false }
const persisted = bootSnapshot()
return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false }
})
ipcMain.handle('gitea:reconcile', async () => { ipcMain.handle('gitea:reconcile', async () => {
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
const snap = await reconcileSnapshot(client) try {
return { configured: true, ...snap } // explicit UI sync — force fresh, and warm the cache for agent tool calls
const snap = await getSnapshot(client, { maxAgeMs: 0 })
return { configured: true, stale: false, ...snap }
} catch (e) {
// offline / gitea down — serve the last persisted snapshot rather than error out
const persisted = bootSnapshot()
if (persisted) return { configured: true, stale: true, ...persisted }
throw e
}
}) })
ipcMain.handle('gitea:getIssue', async (_event, index: number) => { ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
@@ -122,6 +234,36 @@ export function registerGiteaIpc(): void {
const ids = await resolveLabelIds(plan.labels) const ids = await resolveLabelIds(plan.labels)
await client.setIssueLabels(change.issue, ids) await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue) const issue = await client.getIssue(change.issue)
invalidateSnapshot() // the board + forecast must reflect the label change
return { ok: true as const, plan, issue } return { ok: true as const, plan, issue }
}) })
// capture_work filing: open each approved issue with its est/* + p/* labels.
// Only touches the CommiTea label namespaces — no invented labels (zero-pollution).
ipcMain.handle(
'gitea:createIssues',
async (_event, issues: { title: string; body?: string; estimate?: string; priority?: string }[]) => {
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
const created: { number: number; title: string }[] = []
for (const it of issues) {
const names = [it.estimate, it.priority].filter((n): n is string => !!n)
const labelIds = await resolveLabelIds(names)
const issue = await client.createIssue({ title: it.title, body: it.body, labelIds })
created.push({ number: issue.number, title: issue.title })
}
if (created.length) invalidateSnapshot() // new issues enter the board/scope
return { ok: true as const, created }
},
)
// Read the directive ledger from the pm-state repo (for the Directives screen).
ipcMain.handle('pmstate:directives', async () => {
const pm = getPmStateClient()
if (!pm) return { ok: false as const, reason: 'unconfigured' as const }
try {
return { ok: true as const, directives: await readDirectives(pm) }
} catch (e) {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
}
})
} }

View File

@@ -9,18 +9,30 @@
import { import {
buildProjectView, buildProjectView,
captureWork,
type ChangeProposal,
type ChatMessage, type ChatMessage,
createChatClient, createChatClient,
describeChange,
type ModelRouter, type ModelRouter,
type ProjectView, type ProjectView,
proposalsFor,
type ProposeChangeArgs,
type QueryFilters, type QueryFilters,
REGINALD_SYSTEM, REGINALD_SYSTEM,
REGINALD_TOOLS, REGINALD_TOOLS,
runAgentTurn, runAgentTurn,
toDirectiveInput,
} from '@commitea/core' } from '@commitea/core'
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { getGiteaClient, reconcileSnapshot } from './gitea.js' import {
AGENT_SNAPSHOT_TTL_MS,
appendDirectiveEntry,
getGiteaClient,
getPmStateClient,
getSnapshot,
} from './gitea.js'
/** Small local model for prose + the read tool; big model reserved for later decomposition. */ /** Small local model for prose + the read tool; big model reserved for later decomposition. */
function resolveModelRouter(): ModelRouter | null { function resolveModelRouter(): ModelRouter | null {
@@ -80,13 +92,40 @@ export function registerModelIpc(): void {
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)
// Proposals the model formulates this turn; the renderer approves them (the
// write happens through gitea:applyChange, never inside the loop).
const proposals: ChangeProposal[] = []
const execute = async (name: string, args: unknown) => { const execute = async (name: string, args: unknown) => {
if (name !== 'query_project') return { error: `unknown tool: ${name}` }
if (!client) return { error: 'gitea is not configured' } if (!client) return { error: 'gitea is not configured' }
const snap = await reconcileSnapshot(client) if (name === 'query_project') {
// reuse a recent reconcile — a multi-tool turn shouldn't refetch the repo each call
const snap = await getSnapshot(client, { maxAgeMs: AGENT_SNAPSHOT_TTL_MS })
const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters } const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters }
return buildProjectView(a.view, a.filters, snap, new Date()) return buildProjectView(a.view, a.filters, snap, new Date())
} }
if (name === 'propose_change') {
const a = (args ?? {}) as ProposeChangeArgs
const issue = await client.getIssue(a.issue).catch(() => null)
if (!issue) return { error: `issue #${a.issue} not found` }
const built = proposalsFor(a, issue.labels, issue.title)
proposals.push(...built)
return built.length
? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) }
: { proposed: [], note: 'no change — already at that value' }
}
if (name === 'record_directive') {
const pm = getPmStateClient()
if (!pm) return { error: 'pm-state is not configured' }
try {
const entry = await appendDirectiveEntry(pm, toDirectiveInput(args))
return { recorded: { kind: entry.kind, quote: entry.quote } }
} catch (e) {
return { error: `could not record — is the pm-state repo created? (${e instanceof Error ? e.message : e})` }
}
}
return { error: `unknown tool: ${name}` }
}
try { try {
const turn = await runAgentTurn({ const turn = await runAgentTurn({
@@ -95,7 +134,22 @@ export function registerModelIpc(): void {
tools: REGINALD_TOOLS, tools: REGINALD_TOOLS,
execute, execute,
}) })
return { ok: true as const, content: turn.content, steps: turn.steps } return { ok: true as const, content: turn.content, steps: turn.steps, proposals }
} catch (e) {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
}
})
// capture_work — braindump → proposed issue set. The big model does the
// decomposition (with one loaded local model, that's the loaded one). Returns
// a proposal; nothing is filed until the Capture tray approves it.
ipcMain.handle('model:capture', async (_event, braindump: string) => {
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
const model = await resolveLoadedModel(router.big.baseUrl, router.big.model)
const chat = createChatClient({ ...router.big, model }, fetch)
try {
const proposal = await captureWork((m, t) => chat.complete(m, t), braindump)
return { ok: true as const, ...proposal }
} catch (e) { } catch (e) {
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) }
} }

View File

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

View File

@@ -5,18 +5,28 @@ const api = {
gitea: { gitea: {
/** Whether the main process has a gitea token + target repo configured. */ /** Whether the main process has a gitea token + target repo configured. */
status: () => ipcRenderer.invoke('gitea:status'), status: () => ipcRenderer.invoke('gitea:status'),
/** The last persisted snapshot, for instant boot before the fresh reconcile. */
boot: () => ipcRenderer.invoke('gitea:boot'),
/** Full read of the managed repo — every issue + milestone. */ /** Full read of the managed repo — every issue + milestone. */
reconcile: () => ipcRenderer.invoke('gitea:reconcile'), reconcile: () => ipcRenderer.invoke('gitea:reconcile'),
/** One issue by index, normalized (or null if unconfigured). */ /** One issue by index, normalized (or null if unconfigured). */
getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index), getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index),
/** Apply an estimate/priority change (the write path); resolves to the plan + fresh issue. */ /** Apply an estimate/priority change (the write path); resolves to the plan + fresh issue. */
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. */
createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues),
},
pmstate: {
/** Read the directive ledger from the pm-state repo. */
directives: () => ipcRenderer.invoke('pmstate:directives'),
}, },
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),
/** Decompose a braindump into a proposed issue set (capture_work). */
capture: (braindump: string) => ipcRenderer.invoke('model:capture', braindump),
}, },
} }

View File

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

View File

@@ -8,6 +8,35 @@ export function DirectivesScreen() {
const [pending, setPending] = React.useState<DirectivePending | null>(DIRECTIVES.pending) const [pending, setPending] = React.useState<DirectivePending | null>(DIRECTIVES.pending)
const [entries, setEntries] = React.useState<DirectiveEntry[]>(DIRECTIVES.entries) const [entries, setEntries] = React.useState<DirectiveEntry[]>(DIRECTIVES.entries)
// Real ledger from the pm-state repo, when it exists — else the fixture demo.
React.useEffect(() => {
let alive = true
window.commitea.pmstate
.directives()
.then((r) => {
if (!alive || !r.ok || r.directives.length === 0) return
setPending(null)
setEntries(
r.directives
.slice()
.reverse()
.map((d) => ({
seq: d.seq,
who: 'You',
when: new Date(d.ts).toLocaleDateString(),
what: d.quote,
why: d.rationale ?? '',
status: d.status === 'accepted' ? 'applied' : d.status === 'withdrawn' ? 'withdrawn' : 'superseded',
consequence: '',
})),
)
})
.catch(() => {})
return () => {
alive = false
}
}, [])
const resolve = (status: string) => { const resolve = (status: string) => {
setEntries((e) => [{ setEntries((e) => [{
seq: pending!.seq, who: pending!.who, when: pending!.when, what: pending!.what, why: 'pilot demo on the 14th', seq: pending!.seq, who: pending!.who, when: pending!.when, what: pending!.what, why: 'pilot demo on the 14th',

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

@@ -2,18 +2,22 @@ import React from 'react'
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) overrides the demo when present.
export function RunwayScreen({ export function RunwayScreen({
onOpenCalibration, onOpenCalibration,
onOpenMilestone, onOpenMilestone,
calibration, calibration,
milestones,
}: { }: {
onOpenCalibration: () => void onOpenCalibration: () => void
onOpenMilestone: () => void onOpenMilestone: (id?: number) => void
calibration?: { n: number; coldStart: boolean } calibration?: { n: number; coldStart: boolean }
milestones?: RunwayMilestone[]
}) { }) {
const rows = milestones && milestones.length ? milestones : RUNWAY
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 +32,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)',
}} }}

View File

@@ -4,7 +4,14 @@ import logoIcon from '../../design/assets/logo-icon.png'
import type { IssueChange } from '@commitea/core' import type { IssueChange } from '@commitea/core'
import type { IssueRef } from '../../data/fixtures.js' import type { IssueRef } from '../../data/fixtures.js'
import { backlogCalibration, forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js' import {
backlogCalibration,
forecastBacklog,
issuesToBoardColumns,
milestoneView,
runwayView,
scheduleFocus,
} from '../../lib/backlog.js'
import { useBacklog } from '../../lib/use-backlog.js' import { useBacklog } from '../../lib/use-backlog.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'
@@ -87,6 +94,7 @@ 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 [backlog, refetchBacklog] = useBacklog() const [backlog, refetchBacklog] = useBacklog()
const boardColumns = const boardColumns =
backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
@@ -98,6 +106,19 @@ export function AppShell() {
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) ?? undefined)
: undefined : undefined
const runwayMilestones =
backlog.status === 'ready' ? runwayView(backlog.issues, backlog.milestones, backlog.deps) : undefined
const milestone =
backlog.status === 'ready' && milestoneId != null
? (milestoneView(
milestoneId,
backlog.issues,
backlog.milestones,
backlog.deps,
backlog.timelines,
calibration?.model,
) ?? undefined)
: undefined
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
@@ -194,14 +215,18 @@ 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}
/> />
) )
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
@@ -337,7 +362,7 @@ export function AppShell() {
</div> </div>
</main> </main>
<ChatPanel onOpenDirectives={() => setView('directives')} offline={offline} /> <ChatPanel onOpenDirectives={() => setView('directives')} offline={offline} onApplyChange={applyChange} />
</div> </div>
) )
} }

View File

@@ -1,21 +1,25 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { describeChange, type IssueChange } from '@commitea/core'
import { useChat } from '../../lib/use-chat.js' import { useChat } from '../../lib/use-chat.js'
import { Icon, IconButton } from '../ui/index.js' import { Button, Icon, IconButton } from '../ui/index.js'
/** /**
* Reginald's panel — chat is the write-path (decisions.md D1). Wired to the * Reginald's panel — chat is the write-path (decisions.md D1). Wired to the
* model bridge via `useChat`: when a model is configured, sending drives a real * model bridge via `useChat`: a configured model drives a real agent turn
* agent turn (query_project + prose); otherwise it echoes the scripted fixture * (query_project + prose, propose_change for edits); otherwise it echoes the
* reply so the layout stays real. Writes still go through propose-approve. * scripted fixture reply. Proposed changes are approved inline here — the write
* runs through `onApplyChange`, the same guarded handler the Issue screen uses.
*/ */
export interface ChatPanelProps { export interface ChatPanelProps {
onOpenDirectives?: () => void onOpenDirectives?: () => void
offline?: boolean offline?: boolean
onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>
} }
export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPanelProps) {
const { msgs, thinking, live, model, steps, send: sendChat } = useChat() const { msgs, thinking, live, model, steps, proposals, 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('')
@@ -97,9 +101,36 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
{thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering</div> : null} {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')))).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(', ')}
</div> </div>
) : null} ) : null}
{proposals.map((p) => (
<div
key={`${p.change.issue}:${p.change.kind}`}
style={{
border: '1px solid var(--line-2)',
borderRadius: 'var(--radius-2)',
background: 'var(--paper-0)',
padding: '10px 12px',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 'var(--letter-spacing-wide)' }}>
Proposed · #{p.change.issue}
</div>
<div style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>{describeChange(p.plan)}</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" onClick={() => approve(p)} disabled={offline}>
Approve
</Button>
<Button size="sm" variant="ghost" onClick={() => dismiss(p)}>
Dismiss
</Button>
</div>
</div>
))}
</div> </div>
<div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}> <div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}>

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,12 +1,16 @@
import type { import type {
AgentStep, AgentStep,
ChangeProposal,
ChatMessage, ChatMessage,
CaptureProposal,
DependencyEdge, DependencyEdge,
DirectiveRecord,
GiteaIssue, GiteaIssue,
GiteaMilestone, GiteaMilestone,
IssueChange, IssueChange,
LabelPlan, LabelPlan,
LifecycleEvent, LifecycleEvent,
ProposedIssue,
} from '@commitea/core' } from '@commitea/core'
/** The result of a write through the bridge. */ /** The result of a write through the bridge. */
@@ -14,30 +18,66 @@ export type ApplyChangeResult =
| { ok: false; reason: 'unconfigured' } | { ok: false; reason: 'unconfigured' }
| { ok: true; plan: LabelPlan; issue: GiteaIssue } | { ok: true; plan: LabelPlan; issue: GiteaIssue }
/** The gitea bridge exposed by the preload over IPC (main-process backed). */ /** The result of filing captured issues. */
export interface GiteaBridge { export type CreateIssuesResult =
status(): Promise<{ configured: boolean; repo: string | null }> | { ok: false; reason: 'unconfigured' }
reconcile(): Promise<{ | { ok: true; created: { number: number; title: string }[] }
/** The result of a capture_work decomposition. */
export type CaptureResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| ({ ok: true } & CaptureProposal)
/** A reconciled snapshot as it crosses the bridge. */
export interface SnapshotPayload {
configured: boolean configured: boolean
issues: GiteaIssue[] issues: GiteaIssue[]
milestones: GiteaMilestone[] milestones: GiteaMilestone[]
deps: DependencyEdge[] deps: DependencyEdge[]
/** Normalized lifecycle events keyed by issue number. */ /** Normalized lifecycle events keyed by issue number. */
timelines: Record<number, LifecycleEvent[]> timelines: Record<number, LifecycleEvent[]>
}> /** true when served from the persisted cache (offline / instant boot). */
stale?: boolean
/** ISO time the persisted snapshot was reconciled (present on cached reads). */
savedAt?: string
}
/** Boot payload — the persisted snapshot, or a marker that there's none yet. */
export type BootPayload =
| { configured: false }
| { configured: true; cached: false }
| ({ configured: true; cached: true } & Omit<SnapshotPayload, 'configured'>)
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
export interface GiteaBridge {
status(): Promise<{ configured: boolean; repo: string | null }>
boot(): Promise<BootPayload>
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>
} }
/** One agent turn's result. */ /** One agent turn's result. */
export type ChatResult = export type ChatResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string } | { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| { ok: true; content: string; steps: AgentStep[] } | { ok: true; content: string; steps: AgentStep[]; proposals: ChangeProposal[] }
/** The model bridge (Reginald) exposed by the preload over IPC. */ /** The model bridge (Reginald) exposed by the preload over IPC. */
export interface ModelBridge { 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>
}
/** The result of reading the directive ledger. */
export type DirectivesResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| { ok: true; directives: DirectiveRecord[] }
/** The pm-state bridge (machine-derived state) exposed by the preload over IPC. */
export interface PmStateBridge {
directives(): Promise<DirectivesResult>
} }
declare global { declare global {
@@ -46,6 +86,7 @@ declare global {
platform: string platform: string
gitea: GiteaBridge gitea: GiteaBridge
model: ModelBridge model: ModelBridge
pmstate: PmStateBridge
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import {
fitCalibration, fitCalibration,
forecast, forecast,
type GiteaIssue, type GiteaIssue,
type GiteaMilestone,
inferLifecycle, inferLifecycle,
type LifecycleColumn, type LifecycleColumn,
type LifecycleEvent, type LifecycleEvent,
@@ -16,10 +17,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[]>
@@ -232,3 +240,133 @@ 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(),
): 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,
})),
deps,
)
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(),
): 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,
})),
deps,
model ? { model } : {},
)
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

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

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type { ChatMessage as WireMessage } from '@commitea/core' import type { ChangeProposal, ChatMessage as WireMessage, IssueChange } from '@commitea/core'
import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js' import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js'
@@ -18,7 +18,11 @@ export interface ChatState {
model: string | null model: string | null
/** Tools Reginald consulted on the last turn (for a subtle activity line). */ /** Tools Reginald consulted on the last turn (for a subtle activity line). */
steps: string[] steps: string[]
/** Changes Reginald has proposed and is awaiting approval on. */
proposals: ChangeProposal[]
send: (text: string) => void send: (text: string) => void
approve: (p: ChangeProposal) => void
dismiss: (p: ChangeProposal) => void
} }
/** /**
@@ -26,18 +30,23 @@ export interface ChatState {
* turn through the main-process bridge (which runs the tool loop). Otherwise it * turn through the main-process bridge (which runs the tool loop). Otherwise it
* echoes the scripted fixture reply, so the layout stays real with no model and * echoes the scripted fixture reply, so the layout stays real with no model and
* fixture e2e is unaffected. The fixture greeting is display-only — only real * fixture e2e is unaffected. The fixture greeting is display-only — only real
* turns (`convo`) are sent to the model as history. * turns (`convo`) are sent to the model as history. `onApplyChange` performs an
* approved write (the same handler the Issue screen uses — it refetches).
*/ */
export function useChat(): ChatState { export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>): ChatState {
const [seed, setSeed] = useState<ChatMessage[]>(CHAT) const [seed, setSeed] = useState<ChatMessage[]>(CHAT)
const [convo, setConvo] = useState<ChatMessage[]>([]) const [convo, setConvo] = useState<ChatMessage[]>([])
const [thinking, setThinking] = useState(false) const [thinking, setThinking] = useState(false)
const [live, setLive] = useState(false) const [live, setLive] = useState(false)
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 convoRef = useRef(convo) const convoRef = useRef(convo)
convoRef.current = convo convoRef.current = convo
const proposalKey = (p: ChangeProposal) => `${p.change.issue}:${p.change.kind}`
const drop = (p: ChangeProposal) => setProposals((ps) => ps.filter((x) => proposalKey(x) !== proposalKey(p)))
useEffect(() => { useEffect(() => {
let alive = true let alive = true
window.commitea.model window.commitea.model
@@ -63,6 +72,7 @@ export function useChat(): ChatState {
setConvo(nextConvo) setConvo(nextConvo)
setThinking(true) setThinking(true)
setSteps([]) setSteps([])
setProposals([])
if (!live) { if (!live) {
window.setTimeout(() => { window.setTimeout(() => {
@@ -82,6 +92,7 @@ export function useChat(): ChatState {
setThinking(false) setThinking(false)
if (res.ok) { if (res.ok) {
setSteps(res.steps.map((s) => s.tool)) setSteps(res.steps.map((s) => s.tool))
setProposals(res.proposals)
setConvo((c) => [...c, { from: 'agent', text: res.content || '…' }]) setConvo((c) => [...c, { from: 'agent', text: res.content || '…' }])
} else { } else {
setConvo((c) => [ setConvo((c) => [
@@ -101,5 +112,30 @@ export function useChat(): ChatState {
[live], [live],
) )
return { msgs: [...seed, ...convo], thinking, live, model, steps, send } const approve = useCallback(
(p: ChangeProposal) => {
if (!onApplyChange) return
drop(p)
const label = p.plan.added[0] ?? p.plan.removed[0] ?? 'change'
void onApplyChange(p.change).then((res) => {
setConvo((c) => [
...c,
{
from: 'agent',
text: res.ok
? `Done — #${p.change.issue} is now ${label}. The plan's been re-run.`
: `That didn't take — #${p.change.issue} is unchanged.`,
},
])
})
},
[onApplyChange],
)
const dismiss = useCallback((p: ChangeProposal) => {
drop(p)
setConvo((c) => [...c, { from: 'agent', text: `Left #${p.change.issue} as it was.` }])
}, [])
return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, send, approve, dismiss }
} }

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest' import { beforeAll, describe, expect, it } from 'vitest'
import { extractLabelFacts } from '../labels/label-schema.js' import { extractLabelFacts } from '../labels/label-schema.js'
import type { FetchLike, GiteaIssue } from '../gitea/types.js' import type { FetchLike, GiteaIssue } from '../gitea/types.js'
@@ -10,11 +10,22 @@ import { buildProjectView, type ProjectSnapshot } from './query-project.js'
/** /**
* Opt-in (COMMITEA_MODEL_LIVE=1). Drives the real chat client + agent loop * Opt-in (COMMITEA_MODEL_LIVE=1). Drives the real chat client + agent loop
* against a local OpenAI-compatible server (LM Studio on :1234 by default), * against a local OpenAI-compatible server (LM Studio on :1234 by default),
* proving the model calls query_project and narrates the real result. * proving the model calls the tools and narrates the real result. The model is
* whatever is loaded (via LM Studio's native API), so it never JIT-swaps.
*/ */
const LIVE = !!process.env.COMMITEA_MODEL_LIVE const LIVE = !!process.env.COMMITEA_MODEL_LIVE
const BASE = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1' const BASE = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
const MODEL = process.env.COMMITEA_MODEL_SMALL ?? 'google/gemma-4-e4b' let MODEL = process.env.COMMITEA_MODEL_SMALL ?? ''
beforeAll(async () => {
if (!LIVE || MODEL) return
const root = BASE.replace(/\/v1\/?$/, '')
const loaded = await fetch(`${root}/api/v0/models`)
.then((r) => (r.ok ? (r.json() as Promise<{ data?: { id: string; state?: string; type?: string }[] }>) : null))
.then((d) => d?.data?.find((m) => m.state === 'loaded' && m.type !== 'embeddings')?.id)
.catch(() => undefined)
MODEL = loaded ?? 'google/gemma-4-e4b'
})
function issue(over: Partial<GiteaIssue>): GiteaIssue { function issue(over: Partial<GiteaIssue>): GiteaIssue {
const labels = over.labels ?? [] const labels = over.labels ?? []
@@ -59,4 +70,34 @@ describe('agent loop (live model)', () => {
}, },
60_000, 60_000,
) )
it.skipIf(!LIVE)(
'records a standing instruction via record_directive',
async () => {
const client = createChatClient({ baseUrl: BASE, model: MODEL }, globalThis.fetch as unknown as FetchLike)
const recorded: unknown[] = []
const turn = await runAgentTurn({
complete: (m, t) => client.complete(m, t),
messages: [
{ role: 'system', content: REGINALD_SYSTEM },
{ role: 'user', content: 'Log this standing directive: pilots come first, everything else waits.' },
],
tools: REGINALD_TOOLS,
execute: async (name, args) => {
if (name === 'record_directive') {
recorded.push(args)
return { recorded: { kind: (args as { kind?: string }).kind ?? 'note' } }
}
return name === 'query_project'
? buildProjectView((args as { view: any }).view, (args as any).filters, SNAP, new Date())
: { error: `unknown tool ${name}` }
},
})
// the model logged the directive rather than trying to apply it
expect(turn.steps.some((s) => s.tool === 'record_directive')).toBe(true)
expect(recorded.length).toBeGreaterThan(0)
},
60_000,
)
}) })

View File

@@ -35,12 +35,57 @@ export const QUERY_PROJECT_TOOL: ToolDecl = {
}, },
} }
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL] export const PROPOSE_CHANGE_TOOL: ToolDecl = {
name: 'propose_change',
description:
"Propose an estimate and/or priority change to an issue. This does NOT apply anything — it shows the " +
'human a diff to approve. Use it whenever the user asks to re-estimate or reprioritize. After calling it, ' +
"tell the user you've *proposed* the change for approval — never say it is done.",
parameters: {
type: 'object',
properties: {
issue: { type: 'number', description: 'the issue number to change' },
estimate: { type: 'string', enum: ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'] },
priority: { type: 'string', enum: ['p/1', 'p/2', 'p/3', 'p/4'] },
},
required: ['issue'],
},
}
export const RECORD_DIRECTIVE_TOOL: ToolDecl = {
name: 'record_directive',
description:
'Log a standing instruction from the PM to the durable directive ledger — a reprioritization, ' +
'a re-estimate policy, a deadline, a scope or capacity call, or a plain note. Use it when the user ' +
'states intent that should persist ("pilots come first", "freeze scope for beta"). This records the ' +
'intent verbatim; the actual issue edits still go through propose_change.',
parameters: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['reprioritize', 'reestimate', 'set-deadline', 'scope', 'capacity', 'note'] },
quote: { type: 'string', description: "the PM's own words, stored verbatim" },
target: {
type: 'object',
properties: {
issue: { type: 'number' },
milestone: { type: 'number' },
member: { type: 'string' },
},
},
rationale: { type: 'string', description: 'why (optional)' },
},
required: ['kind', 'quote'],
},
}
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL, PROPOSE_CHANGE_TOOL, RECORD_DIRECTIVE_TOOL]
export const REGINALD_SYSTEM = [ 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.',
'Forecasts are ranges, never single dates. Refer to issues as #<number>.', 'To change an estimate or priority, call propose_change — it shows the human a diff to approve.',
'Be brief and plain. A sentence or two is usually enough. No preamble, no bullet-point dumps.', 'When the PM states standing intent ("pilots first", "freeze scope"), call record_directive to log it.',
'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.',
'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.',
].join(' ') ].join(' ')

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { describeChange, type IssueChange, planIssueChange } from './apply-changes-v0.js' import { describeChange, type IssueChange, planIssueChange, proposalsFor } 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', () => {
@@ -52,3 +52,28 @@ describe('planIssueChange', () => {
expect(describeChange(planIssueChange(['est/5d'], change))).toBe('no change') expect(describeChange(planIssueChange(['est/5d'], change))).toBe('no change')
}) })
}) })
describe('proposalsFor', () => {
it('builds one proposal per changed axis, carrying the concrete change + diff', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/5d', priority: 'p/1' }, ['est/2d', 'p/3'], 'ChangeSource')
expect(props).toHaveLength(2)
expect(props[0].change).toEqual({ kind: 'reestimate', issue: 2, estimate: 'est/5d' })
expect(describeChange(props[0].plan)).toBe('est/2d → est/5d')
expect(props[1].change).toEqual({ kind: 'reprioritize', issue: 2, priority: 'p/1' })
expect(props[0].issueTitle).toBe('ChangeSource')
})
it('drops a noop axis (already at the requested value)', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/2d', priority: 'p/1' }, ['est/2d', 'p/3'])
expect(props.map((p) => p.change.kind)).toEqual(['reprioritize']) // estimate unchanged
})
it('ignores invalid label values from the model', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/4d' as never, priority: 'high' as never }, [])
expect(props).toEqual([])
})
it('returns nothing when no axis is provided', () => {
expect(proposalsFor({ issue: 2 }, ['est/2d'])).toEqual([])
})
})

View File

@@ -57,3 +57,48 @@ export function describeChange(plan: LabelPlan): string {
const to = plan.added.length ? plan.added.join(', ') : '∅' const to = plan.added.length ? plan.added.join(', ') : '∅'
return `${from}${to}` return `${from}${to}`
} }
/** A change the agent proposes: the concrete op + its diff, ready for approve-then-apply. */
export interface ChangeProposal {
change: IssueChange
plan: LabelPlan
issueTitle?: string
}
/** What the `propose_change` tool accepts — a target issue and the axes to set. */
export interface ProposeChangeArgs {
issue: number
estimate?: EstimateLabel
priority?: PriorityLabel
}
function isEstimate(v: unknown): v is EstimateLabel {
return typeof v === 'string' && (ESTIMATE_LABELS as readonly string[]).includes(v)
}
function isPriority(v: unknown): v is PriorityLabel {
return typeof v === 'string' && (PRIORITY_LABELS as readonly string[]).includes(v)
}
/**
* Build the concrete, non-noop proposals for a `propose_change` request against
* an issue's current labels. Invalid or unchanged axes are dropped — the agent
* proposes only real changes, and never a label outside the est/* · p/* axes.
*/
export function proposalsFor(
args: ProposeChangeArgs,
currentLabels: string[],
issueTitle?: string,
): ChangeProposal[] {
const out: ChangeProposal[] = []
if (isEstimate(args.estimate)) {
const change: IssueChange = { kind: 'reestimate', issue: args.issue, estimate: args.estimate }
const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, issueTitle })
}
if (isPriority(args.priority)) {
const change: IssueChange = { kind: 'reprioritize', issue: args.issue, priority: args.priority }
const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, issueTitle })
}
return out
}

View File

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

View File

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

View File

@@ -146,6 +146,40 @@ describe('createGiteaClient.getIssue', () => {
]) ])
}) })
it('getFile returns null on 404 and content+sha on hit', async () => {
const miss = stubFetch('nope', 404)
expect(await createGiteaClient(CONFIG, miss.fetch).getFile('directives/log.jsonl')).toBeNull()
const hit = stubFetch({ content: 'aGVsbG8=\n', sha: 'abc123' })
const file = await createGiteaClient(CONFIG, hit.fetch).getFile('directives/log.jsonl')
expect(file).toEqual({ contentBase64: 'aGVsbG8=', sha: 'abc123' })
expect(hit.calls[0].url).toContain('/contents/directives/log.jsonl')
})
it('putFile POSTs to create and PUTs to update (with sha)', async () => {
const create = stubFetch({}, 201)
await createGiteaClient(CONFIG, create.fetch).putFile('directives/log.jsonl', { contentBase64: 'eA==', message: 'seed' })
expect(create.calls[0].init?.method).toBe('POST')
expect(JSON.parse(create.calls[0].init?.body ?? '{}')).toEqual({ content: 'eA==', message: 'seed' })
const update = stubFetch({}, 200)
await createGiteaClient(CONFIG, update.fetch).putFile('directives/log.jsonl', { contentBase64: 'eQ==', message: 'append', sha: 's1' })
expect(update.calls[0].init?.method).toBe('PUT')
expect(JSON.parse(update.calls[0].init?.body ?? '{}')).toEqual({ content: 'eQ==', message: 'append', sha: 's1' })
})
it('createIssue POSTs title/body/labels and returns a normalized issue', async () => {
const created = { ...RAW_ISSUE, number: 44, title: 'Retry token refresh', labels: [{ name: 'est/2d' }, { name: 'p/2' }] }
const { fetch, calls } = stubFetch(created, 201)
const issue = await createGiteaClient(CONFIG, fetch).createIssue({ title: 'Retry token refresh', body: 'backoff', labelIds: [3, 7] })
expect(calls[0].url).toBe('https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues')
expect(calls[0].init?.method).toBe('POST')
expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ title: 'Retry token refresh', body: 'backoff', labels: [3, 7] })
expect(issue.number).toBe(44)
expect(issue.labels).toEqual(['est/2d', 'p/2'])
})
it('throws GiteaApiError carrying status + body on a non-2xx response', async () => { it('throws GiteaApiError carrying status + body on a non-2xx response', async () => {
const { fetch } = stubFetch('not found', 404) const { fetch } = stubFetch('not found', 404)
const client = createGiteaClient(CONFIG, fetch) const client = createGiteaClient(CONFIG, fetch)

View File

@@ -103,6 +103,12 @@ export interface GiteaClient {
listLabels(): Promise<GiteaLabel[]> listLabels(): Promise<GiteaLabel[]>
/** 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>
/** Open a new issue with a title, optional body, and label ids. Write. */
createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue>
/** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */
getFile(path: string): Promise<{ contentBase64: string; sha: string } | null>
/** Create or update a repo file with base64 content (pass `sha` to update). Write. */
putFile(path: string, input: { contentBase64: string; message: string; sha?: string }): Promise<void>
} }
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */ /** Map raw gitea issue JSON to the normalized domain shape. Pure. */
@@ -222,5 +228,33 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
async setIssueLabels(index, labelIds) { async setIssueLabels(index, labelIds) {
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } }) await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
}, },
async createIssue(input) {
const raw = (await request('/issues', {
method: 'POST',
body: { title: input.title, body: input.body ?? '', labels: input.labelIds ?? [] },
})) as RawIssue
return normalizeIssue(raw)
},
async getFile(path) {
const res = await fetchImpl(`${repoBase}/contents/${path}`, {
headers: { Authorization: `token ${config.token}`, Accept: 'application/json' },
})
if (res.status === 404) return null
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `GET contents/${path} failed (${res.status})`, body)
}
const json = (await res.json()) as { content?: string; sha: string }
return { contentBase64: (json.content ?? '').replace(/\n/g, ''), sha: json.sha }
},
async putFile(path, input) {
await request(`/contents/${path}`, {
method: input.sha ? 'PUT' : 'POST',
body: { content: input.contentBase64, message: input.message, ...(input.sha ? { sha: input.sha } : {}) },
})
},
} }
} }

View File

@@ -23,8 +23,8 @@ export type {
GiteaRequestInit, GiteaRequestInit,
} from './gitea/types.js' } from './gitea/types.js'
export { describeChange, planIssueChange } from './changes/apply-changes-v0.js' export { describeChange, planIssueChange, proposalsFor } from './changes/apply-changes-v0.js'
export type { IssueChange, LabelPlan } from './changes/apply-changes-v0.js' export type { ChangeProposal, IssueChange, LabelPlan, ProposeChangeArgs } from './changes/apply-changes-v0.js'
export { export {
inferColumnV0, inferColumnV0,
@@ -85,6 +85,30 @@ 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'
export type { AgentStep, AgentTurn, ToolExecutor } from './agent/agent-loop.js' export type { AgentStep, AgentTurn, ToolExecutor } from './agent/agent-loop.js'
export { QUERY_PROJECT_TOOL, REGINALD_SYSTEM, REGINALD_TOOLS } from './agent/agent-tools.js' export {
PROPOSE_CHANGE_TOOL,
QUERY_PROJECT_TOOL,
RECORD_DIRECTIVE_TOOL,
REGINALD_SYSTEM,
REGINALD_TOOLS,
} from './agent/agent-tools.js'
export { buildProjectView } from './agent/query-project.js' export { buildProjectView } from './agent/query-project.js'
export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js' export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js'
export { CAPTURE_SYSTEM, captureWork, parseCaptureArgs, PROPOSE_ISSUES_TOOL } from './agent/capture-work.js'
export type { CaptureProposal, ProposedIssue } from './agent/capture-work.js'
export {
appendDirective,
makeDirectiveEntry,
parseDirectiveLog,
serializeDirective,
toDirectiveInput,
} from './directives/record-directive-v0.js'
export type {
DirectiveEntry,
DirectiveInput,
DirectiveKind,
DirectiveRecord,
DirectiveStatus,
DirectiveTarget,
} from './directives/record-directive-v0.js'