Compare commits

5 Commits

Author SHA1 Message Date
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
18 changed files with 861 additions and 29 deletions

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

@@ -6,18 +6,24 @@
* 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'
@@ -62,6 +68,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,
@@ -124,4 +169,32 @@ export function registerGiteaIpc(): void {
const issue = await client.getIssue(change.issue) const issue = await client.getIssue(change.issue)
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 })
}
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,6 +9,7 @@
import { import {
buildProjectView, buildProjectView,
captureWork,
type ChangeProposal, type ChangeProposal,
type ChatMessage, type ChatMessage,
createChatClient, createChatClient,
@@ -21,10 +22,11 @@ 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 { appendDirectiveEntry, getGiteaClient, getPmStateClient, reconcileSnapshot } 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 {
@@ -105,6 +107,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}` }
} }
@@ -120,4 +132,19 @@ export function registerModelIpc(): void {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) } return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
} }
}) })
// 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) {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
}
})
} }

View File

@@ -11,12 +11,20 @@ const api = {
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

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

@@ -2,12 +2,15 @@ import type {
AgentStep, AgentStep,
ChangeProposal, 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. */
@@ -15,6 +18,16 @@ export type ApplyChangeResult =
| { ok: false; reason: 'unconfigured' } | { ok: false; reason: 'unconfigured' }
| { ok: true; plan: LabelPlan; issue: GiteaIssue } | { ok: true; plan: LabelPlan; issue: GiteaIssue }
/** The result of filing captured issues. */
export type CreateIssuesResult =
| { ok: false; reason: 'unconfigured' }
| { 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)
/** The gitea bridge exposed by the preload over IPC (main-process backed). */ /** The gitea bridge exposed by the preload over IPC (main-process backed). */
export interface GiteaBridge { export interface GiteaBridge {
status(): Promise<{ configured: boolean; repo: string | null }> status(): Promise<{ configured: boolean; repo: string | null }>
@@ -28,6 +41,7 @@ export interface GiteaBridge {
}> }>
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. */
@@ -39,6 +53,17 @@ export type ChatResult =
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 {
@@ -47,6 +72,7 @@ declare global {
platform: string platform: string
gitea: GiteaBridge gitea: GiteaBridge
model: ModelBridge model: ModelBridge
pmstate: PmStateBridge
} }
} }
} }

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

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

@@ -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 { 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 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'