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>
This commit is contained in:
@@ -6,18 +6,24 @@
|
||||
* properly later.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import {
|
||||
appendDirective,
|
||||
createGiteaClient,
|
||||
type DirectiveEntry,
|
||||
type GiteaClient,
|
||||
type GiteaConfig,
|
||||
type GiteaLabel,
|
||||
type IssueChange,
|
||||
type LifecycleEvent,
|
||||
makeDirectiveEntry,
|
||||
parseDirectiveLog,
|
||||
planIssueChange,
|
||||
type ProjectSnapshot,
|
||||
type DirectiveInput,
|
||||
} from '@commitea/core'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
@@ -62,6 +68,45 @@ export function getGiteaClient(): GiteaClient | null {
|
||||
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. */
|
||||
export async function reconcileSnapshot(
|
||||
client: GiteaClient,
|
||||
@@ -141,4 +186,15 @@ export function registerGiteaIpc(): void {
|
||||
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) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ import {
|
||||
REGINALD_SYSTEM,
|
||||
REGINALD_TOOLS,
|
||||
runAgentTurn,
|
||||
toDirectiveInput,
|
||||
} from '@commitea/core'
|
||||
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. */
|
||||
function resolveModelRouter(): ModelRouter | null {
|
||||
@@ -106,6 +107,16 @@ export function registerModelIpc(): void {
|
||||
? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) }
|
||||
: { proposed: [], note: 'no change — already at that value' }
|
||||
}
|
||||
if (name === 'record_directive') {
|
||||
const pm = getPmStateClient()
|
||||
if (!pm) return { error: 'pm-state is not configured' }
|
||||
try {
|
||||
const entry = await appendDirectiveEntry(pm, toDirectiveInput(args))
|
||||
return { recorded: { kind: entry.kind, quote: entry.quote } }
|
||||
} catch (e) {
|
||||
return { error: `could not record — is the pm-state repo created? (${e instanceof Error ? e.message : e})` }
|
||||
}
|
||||
}
|
||||
return { error: `unknown tool: ${name}` }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user