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:
66
packages/core/src/directives/record-directive-v0.test.ts
Normal file
66
packages/core/src/directives/record-directive-v0.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user