Compare commits

8 Commits

Author SHA1 Message Date
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
17 changed files with 687 additions and 23 deletions

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

@@ -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,7 @@ 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 }
}) })
@@ -138,7 +251,19 @@ export function registerGiteaIpc(): void {
const issue = await client.createIssue({ title: it.title, body: it.body, labelIds }) const issue = await client.createIssue({ title: it.title, body: it.body, labelIds })
created.push({ number: issue.number, title: issue.title }) created.push({ number: issue.number, title: issue.title })
} }
if (created.length) invalidateSnapshot() // new issues enter the board/scope
return { ok: true as const, created } 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

@@ -22,10 +22,17 @@ import {
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 {
@@ -92,7 +99,8 @@ export function registerModelIpc(): void {
const execute = async (name: string, args: unknown) => { const execute = async (name: string, args: unknown) => {
if (!client) return { error: 'gitea is not configured' } if (!client) return { error: 'gitea is not configured' }
if (name === 'query_project') { if (name === 'query_project') {
const snap = await reconcileSnapshot(client) // 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())
} }
@@ -106,6 +114,16 @@ export function registerModelIpc(): void {
? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) } ? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) }
: { proposed: [], note: 'no change — already at that value' } : { 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}` } return { error: `unknown tool: ${name}` }
} }

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,6 +5,8 @@ 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). */
@@ -14,6 +16,10 @@ const api = {
/** File a set of captured issues with their est/* + p/* labels. */ /** File a set of captured issues with their est/* + p/* labels. */
createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues), createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues),
}, },
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'),

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

@@ -101,7 +101,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
{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').replace('propose_change', 'the labels')))).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) => ( {proposals.map((p) => (

View File

@@ -4,6 +4,7 @@ import type {
ChatMessage, ChatMessage,
CaptureProposal, CaptureProposal,
DependencyEdge, DependencyEdge,
DirectiveRecord,
GiteaIssue, GiteaIssue,
GiteaMilestone, GiteaMilestone,
IssueChange, IssueChange,
@@ -27,17 +28,31 @@ export type CaptureResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string } | { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| ({ ok: true } & CaptureProposal) | ({ ok: true } & CaptureProposal)
/** The gitea bridge exposed by the preload over IPC (main-process backed). */ /** A reconciled snapshot as it crosses the bridge. */
export interface GiteaBridge { export interface SnapshotPayload {
status(): Promise<{ configured: boolean; repo: string | null }>
reconcile(): Promise<{
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> createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult>
@@ -55,12 +70,23 @@ export interface ModelBridge {
capture(braindump: string): Promise<CaptureResult> 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 {
interface Window { interface Window {
commitea: { commitea: {
platform: string platform: string
gitea: GiteaBridge gitea: GiteaBridge
model: ModelBridge model: ModelBridge
pmstate: PmStateBridge
} }
} }
} }

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,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

@@ -52,13 +52,40 @@ export const PROPOSE_CHANGE_TOOL: ToolDecl = {
}, },
} }
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL, PROPOSE_CHANGE_TOOL] 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.',
'To change an estimate or priority, call propose_change — it shows the human a diff to approve.', 'To change an estimate or priority, call propose_change — it shows the human a diff to approve.',
'When the PM states standing intent ("pilots first", "freeze scope"), call record_directive to log it.',
'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.', 'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.',
'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.', 'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.',
].join(' ') ].join(' ')

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,28 @@ 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 () => { 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 created = { ...RAW_ISSUE, number: 44, title: 'Retry token refresh', labels: [{ name: 'est/2d' }, { name: 'p/2' }] }
const { fetch, calls } = stubFetch(created, 201) const { fetch, calls } = stubFetch(created, 201)

View File

@@ -105,6 +105,10 @@ export interface GiteaClient {
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. */ /** Open a new issue with a title, optional body, and label ids. Write. */
createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue> createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue>
/** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */
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. */
@@ -232,5 +236,25 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
})) as RawIssue })) as RawIssue
return normalizeIssue(raw) 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

@@ -85,8 +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 { PROPOSE_CHANGE_TOOL, 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 { CAPTURE_SYSTEM, captureWork, parseCaptureArgs, PROPOSE_ISSUES_TOOL } from './agent/capture-work.js'
export type { CaptureProposal, ProposedIssue } 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'