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:
@@ -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 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
|
||||
* 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 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 {
|
||||
const labels = over.labels ?? []
|
||||
@@ -59,4 +70,34 @@ describe('agent loop (live model)', () => {
|
||||
},
|
||||
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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 = [
|
||||
'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.',
|
||||
'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.',
|
||||
'When the PM states standing intent ("pilots first", "freeze scope"), call record_directive to log it.',
|
||||
'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.',
|
||||
'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.',
|
||||
].join(' ')
|
||||
|
||||
Reference in New Issue
Block a user