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>
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
/**
|
|
* 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`
|
|
}
|