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:
Croissant Le Doux
2026-07-08 22:39:12 -04:00
parent 6e8a6a15bc
commit ba9ea43b4c
12 changed files with 426 additions and 6 deletions

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)
})
})