diff --git a/.changeset/ai-autopilot-decisions-ledger.md b/.changeset/ai-autopilot-decisions-ledger.md new file mode 100644 index 0000000..4c198e4 --- /dev/null +++ b/.changeset/ai-autopilot-decisions-ledger.md @@ -0,0 +1,5 @@ +--- +"@gemstack/ai-autopilot": minor +--- + +Add the decisions ledger: durable project memory so a run stops re-pitching ideas that were already turned down. `DecisionLedger` records rejected ideas and settled choices and answers `consult(idea)` / `wasRejected(idea)` (lexical token-overlap matching, deterministic) before the agent proposes; it round-trips a human-editable `DECISIONS.md` via `loadLedger` / `saveLedger` over a storage-agnostic `LedgerFs` seam (a subset of the runner's `RunnerFs`, with a `nodeLedgerFs()` host adapter). `decisionTools(ledger)` exposes `consult_decisions` + `record_decision` to an agent and `decisionBriefing(ledger)` renders the rejected set as a system-prompt fragment. Verified end-to-end on real disk. First child (#112) of the AI-framework epic (#110); the state layer "the loop" (#113) will consult. diff --git a/packages/ai-autopilot/README.md b/packages/ai-autopilot/README.md index 2dbaf3a..bdc43dc 100644 --- a/packages/ai-autopilot/README.md +++ b/packages/ai-autopilot/README.md @@ -160,6 +160,49 @@ for await (const event of run.stream()) sendToClient(event) // replays history, subscriber still sees the full history. Use `formatEvent(event)` to render an event as a line yourself. +## Decisions — durable memory (stop re-pitching rejected ideas) + +The most felt failure mode of an AI dev tool is re-suggesting the same thing it +already proposed and got turned down. The **decisions ledger** records the +project's rejected ideas and settled choices so a run remembers. It is *data*: it +round-trips a human-editable `DECISIONS.md` you can read and edit yourself. + +Two operations: **record** a decision, **consult** before proposing. + +```ts +import { DecisionLedger, loadLedger, saveLedger, nodeLedgerFs } from '@gemstack/ai-autopilot' + +const fs = nodeLedgerFs() +const ledger = await loadLedger(fs) // reads DECISIONS.md (empty if absent) + +ledger.reject('Use Redux for state', 'Too much boilerplate; Zustand covers it', ['state']) +ledger.accept('Use Vike for SSR', 'Fits the stack') +await saveLedger(fs, ledger) // writes DECISIONS.md + +ledger.wasRejected('add redux for state') // true — do not re-propose +ledger.consult('add a redux store') // [{ decision, score, overlap }] +``` + +Expose it to an agent so the policy runs itself: `consult_decisions` before it +proposes, `record_decision` after a choice is made, plus a system-prompt briefing +of the rejected set. + +```ts +import { agent } from '@gemstack/ai-sdk' +import { decisionTools, decisionBriefing } from '@gemstack/ai-autopilot' + +const worker = agent({ + instructions: [decisionBriefing(ledger), basePrompt].filter(Boolean).join('\n\n'), + tools: decisionTools(ledger, { onRecord: l => saveLedger(fs, l) }), +}) +``` + +`consult` matching is lexical and deterministic (token overlap over title + +tags), cheap enough to run before every proposal; a semantic upgrade can sit +behind the same contract later. The `LedgerFs` seam is a subset of the runner's +`RunnerFs`, so the ledger persists inside a sandbox the same way it does on the +host. This is the foundation "the loop" (#113) consults on major changes. + ## Guardrails - **`concurrency`** (optional, default 4) — max workers in flight; positive integer. diff --git a/packages/ai-autopilot/src/decisions/define.test.ts b/packages/ai-autopilot/src/decisions/define.test.ts new file mode 100644 index 0000000..576144a --- /dev/null +++ b/packages/ai-autopilot/src/decisions/define.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { defineDecision, DecisionError, slugify, tokenize } from './define.js' + +describe('defineDecision', () => { + it('validates and freezes, defaulting status to rejected and id to a title slug', () => { + const d = defineDecision({ title: 'Use Redux for State', rationale: 'Too much boilerplate' }) + assert.equal(d.id, 'use-redux-for-state') + assert.equal(d.status, 'rejected') + assert.deepEqual(d.tags, []) + assert.equal('date' in d, false) + assert.ok(Object.isFrozen(d)) + }) + + it('normalizes and de-dupes tags to lowercase', () => { + const d = defineDecision({ title: 'x', rationale: 'y', tags: ['State', 'state', ' Frontend '] }) + assert.deepEqual(d.tags, ['state', 'frontend']) + }) + + it('keeps an explicit id (slugified) and omits blank optional fields', () => { + const d = defineDecision({ title: 'x', rationale: 'y', id: 'My Id', date: ' ' }) + assert.equal(d.id, 'my-id') + assert.equal('date' in d, false) + }) + + it('throws on a missing title, rationale, or unknown status', () => { + assert.throws(() => defineDecision({ title: ' ', rationale: 'y' }), DecisionError) + assert.throws(() => defineDecision({ title: 'x', rationale: '' }), DecisionError) + assert.throws( + () => defineDecision({ title: 'x', rationale: 'y', status: 'maybe' as never }), + DecisionError, + ) + }) + + it('throws when a title slugs to nothing and no id is given', () => { + assert.throws(() => defineDecision({ title: '!!!', rationale: 'y' }), DecisionError) + }) +}) + +describe('slugify / tokenize', () => { + it('slugify produces trimmed kebab-case', () => { + assert.equal(slugify(' Hello, World!! '), 'hello-world') + }) + + it('tokenize drops stop words and short tokens, de-dupes', () => { + assert.deepEqual(tokenize('Use the Redux store for state').sort(), ['redux', 'state', 'store']) + }) +}) diff --git a/packages/ai-autopilot/src/decisions/define.ts b/packages/ai-autopilot/src/decisions/define.ts new file mode 100644 index 0000000..e05a127 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/define.ts @@ -0,0 +1,73 @@ +import type { Decision, DecisionSpec, DecisionStatus } from './types.js' + +const STATUSES: readonly DecisionStatus[] = ['rejected', 'accepted', 'superseded'] + +/** Thrown when a {@link DecisionSpec} is malformed. Fails fast at record time. */ +export class DecisionError extends Error { + constructor(message: string) { + super(`[ai-autopilot] ${message}`) + this.name = 'DecisionError' + } +} + +/** Turn free text into a kebab-case slug (used for a decision id). */ +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) + .replace(/-+$/g, '') +} + +/** Split text into lowercased word tokens, dropping short/stop words. */ +const STOP_WORDS = new Set([ + 'the', 'a', 'an', 'and', 'or', 'for', 'to', 'of', 'in', 'on', 'with', 'use', + 'using', 'via', 'we', 'our', 'it', 'is', 'as', 'by', 'at', 'be', 'this', 'that', +]) +export function tokenize(text: string): string[] { + const seen = new Set() + for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) { + if (raw.length < 3 || STOP_WORDS.has(raw)) continue + seen.add(raw) + } + return [...seen] +} + +/** + * Validate a {@link DecisionSpec} and return a frozen {@link Decision}. + * + * Defaults keep the call site terse: `status` is `rejected` (the common case), + * `tags` is empty, and `id` is a slug of the title. Optional fields are omitted + * entirely rather than set to `undefined`, so the record stays clean under + * `exactOptionalPropertyTypes`. + */ +export function defineDecision(spec: DecisionSpec): Decision { + const title = spec.title?.trim() + if (!title) throw new DecisionError('decision title is required') + const rationale = spec.rationale?.trim() + if (!rationale) throw new DecisionError(`decision "${title}" needs a rationale`) + + const status = spec.status ?? 'rejected' + if (!STATUSES.includes(status)) { + throw new DecisionError(`decision "${title}" has an unknown status: ${JSON.stringify(status)}`) + } + + const id = (spec.id?.trim() ? slugify(spec.id) : slugify(title)) + if (!id) throw new DecisionError(`decision "${title}" produced an empty id; give it an explicit id`) + + const tags = Object.freeze( + [...new Set((spec.tags ?? []).map(t => t.trim().toLowerCase()).filter(Boolean))], + ) + + const decision: Decision = { + id, + title, + status, + rationale, + tags, + ...(spec.date?.trim() ? { date: spec.date.trim() } : {}), + ...(spec.supersededBy?.trim() ? { supersededBy: slugify(spec.supersededBy) } : {}), + } + return Object.freeze(decision) +} diff --git a/packages/ai-autopilot/src/decisions/index.ts b/packages/ai-autopilot/src/decisions/index.ts new file mode 100644 index 0000000..9632c86 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/index.ts @@ -0,0 +1,26 @@ +/** + * Decisions — the durable memory layer of `@gemstack/ai-autopilot`. + * + * A {@link DecisionLedger} records the choices and rejected ideas of a project + * so a run stops re-pitching what was already turned down. Record with + * {@link DecisionLedger.record}, check before proposing with + * {@link DecisionLedger.consult}, and round-trip a human-editable `DECISIONS.md` + * with {@link loadLedger} / {@link saveLedger}. Expose it to an agent with + * {@link decisionTools} and {@link decisionBriefing}. + */ +export { defineDecision, DecisionError, slugify } from './define.js' +export { DecisionLedger, createLedger, type ConsultOptions } from './ledger.js' +export { parseDecisions, serializeDecisions } from './markdown.js' +export { + loadLedger, + saveLedger, + nodeLedgerFs, + DECISIONS_FILE, + type LedgerFs, +} from './store.js' +export { + decisionTools, + decisionBriefing, + type DecisionToolsOptions, +} from './tools.js' +export type { Decision, DecisionSpec, DecisionStatus, DecisionMatch } from './types.js' diff --git a/packages/ai-autopilot/src/decisions/ledger.test.ts b/packages/ai-autopilot/src/decisions/ledger.test.ts new file mode 100644 index 0000000..be701c5 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/ledger.test.ts @@ -0,0 +1,54 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { DecisionLedger, createLedger } from './ledger.js' + +describe('DecisionLedger — record / query', () => { + it('records and reads back, with reject/accept shorthands', () => { + const l = new DecisionLedger() + l.reject('Use Redux for state', 'Too much boilerplate', ['state']) + l.accept('Use Vike for SSR', 'Fits the stack') + assert.equal(l.size, 2) + assert.deepEqual(l.rejected().map(d => d.id), ['use-redux-for-state']) + assert.equal(l.get('use-vike-for-ssr')?.status, 'accepted') + }) + + it('re-recording the same id replaces in place (reject → accept)', () => { + const l = new DecisionLedger() + l.reject('Use Postgres', 'too heavy for a prototype') + l.accept('Use Postgres', 'the app grew, we need it') + assert.equal(l.size, 1) + assert.equal(l.get('use-postgres')?.status, 'accepted') + }) +}) + +describe('DecisionLedger — consult', () => { + const ledger = createLedger() + ledger.reject('Use Redux for state management', 'Too much boilerplate', ['state']) + ledger.accept('Use Vike for SSR', 'Fits the stack', ['ssr']) + + it('surfaces a prior rejected idea for a re-pitch', () => { + const matches = ledger.consult('add redux for managing state') + assert.equal(matches[0]?.decision.id, 'use-redux-for-state-management') + assert.ok(matches[0]!.score >= 0.5) + assert.ok(matches[0]!.overlap.includes('redux')) + }) + + it('wasRejected is the fast re-pitch check', () => { + assert.equal(ledger.wasRejected('lets use redux for state'), true) + assert.equal(ledger.wasRejected('use vike for ssr'), false) // accepted, not rejected + assert.equal(ledger.wasRejected('add a graphql layer'), false) // never decided + }) + + it('returns nothing below the threshold and honors status/limit filters', () => { + assert.deepEqual(ledger.consult('a completely unrelated idea'), []) + assert.deepEqual( + ledger.consult('vike ssr rendering', { status: 'rejected' }), + [], + ) + assert.equal(ledger.consult('state', { limit: 0 }).length, 0) + }) + + it('ignores an empty idea', () => { + assert.deepEqual(ledger.consult(' '), []) + }) +}) diff --git a/packages/ai-autopilot/src/decisions/ledger.ts b/packages/ai-autopilot/src/decisions/ledger.ts new file mode 100644 index 0000000..4db1d66 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/ledger.ts @@ -0,0 +1,136 @@ +import { defineDecision, tokenize } from './define.js' +import { parseDecisions, serializeDecisions } from './markdown.js' +import type { Decision, DecisionMatch, DecisionSpec, DecisionStatus } from './types.js' + +/** Options for {@link DecisionLedger.consult}. */ +export interface ConsultOptions { + /** Minimum overlap score to return a match, in `(0, 1]`. Default `0.5`. */ + threshold?: number + /** Only match decisions with one of these statuses. Default: all. */ + status?: DecisionStatus | DecisionStatus[] + /** Cap the number of matches returned (highest score first). */ + limit?: number +} + +/** + * An in-memory store of {@link Decision}s with the two operations the moat needs: + * {@link record} (append a choice or rejected idea) and {@link consult} (find + * prior decisions that a new idea resembles, so the agent does not re-pitch a + * rejected one). + * + * The ledger is the canonical form; {@link toMarkdown} / {@link fromMarkdown} + * bind it to a human-editable `DECISIONS.md`. Matching is lexical and + * deterministic (token overlap over title + tags) — good enough to catch a + * re-pitch and cheap enough to run before every proposal; a semantic upgrade can + * sit behind the same {@link consult} contract later. + */ +export class DecisionLedger { + /** Insertion-ordered by id. */ + private readonly byId = new Map() + + constructor(decisions: readonly Decision[] = []) { + for (const d of decisions) this.byId.set(d.id, d) + } + + /** + * Record a decision. A spec with an id that already exists replaces the prior + * one (e.g. an idea first rejected, later accepted), keeping its original slot + * so the ledger order is stable. Returns the frozen {@link Decision}. + */ + record(spec: DecisionSpec): Decision { + const decision = defineDecision(spec) + this.byId.set(decision.id, decision) + return decision + } + + /** Record a rejected idea. Shorthand for `record({ ..., status: 'rejected' })`. */ + reject(title: string, rationale: string, tags?: string[]): Decision { + return this.record({ title, rationale, status: 'rejected', ...(tags ? { tags } : {}) }) + } + + /** Record an accepted choice. Shorthand for `record({ ..., status: 'accepted' })`. */ + accept(title: string, rationale: string, tags?: string[]): Decision { + return this.record({ title, rationale, status: 'accepted', ...(tags ? { tags } : {}) }) + } + + /** The decision with this id, or `undefined`. */ + get(id: string): Decision | undefined { + return this.byId.get(id) + } + + /** All decisions, in insertion order. */ + all(): Decision[] { + return [...this.byId.values()] + } + + /** Only the rejected decisions — the set the agent must not re-propose. */ + rejected(): Decision[] { + return this.all().filter(d => d.status === 'rejected') + } + + /** Number of recorded decisions. */ + get size(): number { + return this.byId.size + } + + /** + * Find prior decisions that `idea` resembles. Tokenizes the idea and each + * decision (title + tags), scores by overlap (shared tokens over the smaller + * token set, so a short idea still matches a longer decision), and returns the + * matches at or above `threshold`, highest score first. + * + * Consult before proposing: a non-empty result for a `rejected` match means + * "you already suggested this and it was turned down". + */ + consult(idea: string, opts: ConsultOptions = {}): DecisionMatch[] { + const threshold = opts.threshold ?? 0.5 + const wanted = opts.status + ? new Set(Array.isArray(opts.status) ? opts.status : [opts.status]) + : undefined + const ideaTokens = new Set(tokenize(idea)) + if (ideaTokens.size === 0) return [] + + const matches: DecisionMatch[] = [] + for (const decision of this.byId.values()) { + if (wanted && !wanted.has(decision.status)) continue + const decisionTokens = new Set([...tokenize(decision.title), ...decision.tags]) + if (decisionTokens.size === 0) continue + + const overlap = [...ideaTokens].filter(t => decisionTokens.has(t)) + if (overlap.length === 0) continue + const score = overlap.length / Math.min(ideaTokens.size, decisionTokens.size) + if (score < threshold) continue + matches.push({ decision, score, overlap }) + } + + matches.sort((a, b) => b.score - a.score) + return opts.limit !== undefined ? matches.slice(0, opts.limit) : matches + } + + /** + * True when `idea` matches a prior *rejected* decision at or above the given + * threshold — the fast "has this already been turned down?" check. + */ + wasRejected(idea: string, threshold?: number): boolean { + return this.consult(idea, { + status: 'rejected', + ...(threshold !== undefined ? { threshold } : {}), + limit: 1, + }).length > 0 + } + + /** Serialize to a human-readable `DECISIONS.md`. */ + toMarkdown(): string { + return serializeDecisions(this.all()) + } + + /** Build a ledger from `DECISIONS.md` contents. */ + static fromMarkdown(markdown: string): DecisionLedger { + return new DecisionLedger(parseDecisions(markdown)) + } +} + +/** Factory mirror of `new DecisionLedger(...)`, for a fluent call site. */ +export function createLedger(decisions: readonly Decision[] = []): DecisionLedger { + return new DecisionLedger(decisions) +} diff --git a/packages/ai-autopilot/src/decisions/markdown.test.ts b/packages/ai-autopilot/src/decisions/markdown.test.ts new file mode 100644 index 0000000..027d602 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/markdown.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { DecisionLedger } from './ledger.js' +import { parseDecisions, serializeDecisions } from './markdown.js' + +describe('decisions markdown', () => { + it('round-trips a ledger through serialize → parse', () => { + const l = new DecisionLedger() + l.record({ title: 'Use Redux', rationale: 'Too much boilerplate', tags: ['state'], date: '2026-07-02' }) + l.accept('Use Vike for SSR', 'Fits the stack') + + const reparsed = DecisionLedger.fromMarkdown(l.toMarkdown()) + assert.deepEqual( + reparsed.all().map(d => ({ id: d.id, status: d.status, tags: d.tags, date: d.date })), + l.all().map(d => ({ id: d.id, status: d.status, tags: d.tags, date: d.date })), + ) + assert.equal(reparsed.get('use-redux')?.rationale, 'Too much boilerplate') + }) + + it('parses a hand-written file forgivingly (missing [status] → rejected)', () => { + const md = [ + '# Decisions', + '', + '## Drop the GraphQL layer', + '', + 'REST is enough for our surface.', + ].join('\n') + const [d] = parseDecisions(md) + assert.equal(d?.status, 'rejected') + assert.equal(d?.id, 'drop-the-graphql-layer') + assert.equal(d?.rationale, 'REST is enough for our surface.') + }) + + it('skips a section with no rationale rather than throwing', () => { + const md = '# Decisions\n\n## [accepted] Empty one\n- id: empty-one\n\n## [rejected] Real one\n\nbecause reasons' + const ds = parseDecisions(md) + assert.deepEqual(ds.map(d => d.id), ['real-one']) + }) + + it('serializes the header, status tag, and metadata', () => { + const md = serializeDecisions([ + { id: 'x', title: 'X', status: 'accepted', rationale: 'y', tags: ['a', 'b'] }, + ]) + assert.match(md, /^# Decisions/) + assert.match(md, /## \[accepted\] X/) + assert.match(md, /- tags: a, b/) + }) +}) diff --git a/packages/ai-autopilot/src/decisions/markdown.ts b/packages/ai-autopilot/src/decisions/markdown.ts new file mode 100644 index 0000000..dad1e17 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/markdown.ts @@ -0,0 +1,97 @@ +import { defineDecision } from './define.js' +import type { Decision, DecisionSpec, DecisionStatus } from './types.js' + +/** + * The `DECISIONS.md` format — human-first and git-friendly, so the user can read + * and edit the same file the agent consults. One `##` section per decision: + * + * ```md + * # Decisions + * + * ## [rejected] Use Redux for state + * - id: use-redux-for-state + * - tags: state, frontend + * - date: 2026-07-02 + * + * Too much boilerplate for an app this size; Zustand covers our needs. + * ``` + * + * The heading carries the status in brackets and the title; a metadata bullet + * list (all optional) carries id/tags/date/superseded-by; the prose beneath is + * the rationale. Parsing is forgiving — a missing `[status]` defaults to + * `rejected` and unknown metadata keys are ignored — so a hand-edited file still + * loads. {@link serializeDecisions} and {@link parseDecisions} round-trip. + */ +const HEADER = '# Decisions' +const INTRO = + 'Settled choices and rejected ideas. The agent reads this before proposing, so it does not\n' + + 're-pitch what was already turned down. Edit freely; one `##` section per decision.' + +const STATUSES: readonly DecisionStatus[] = ['rejected', 'accepted', 'superseded'] + +/** Render decisions to `DECISIONS.md` contents. */ +export function serializeDecisions(decisions: readonly Decision[]): string { + const blocks = decisions.map(d => { + const meta = [`- id: ${d.id}`] + if (d.tags.length) meta.push(`- tags: ${d.tags.join(', ')}`) + if (d.date) meta.push(`- date: ${d.date}`) + if (d.supersededBy) meta.push(`- superseded-by: ${d.supersededBy}`) + return `## [${d.status}] ${d.title}\n${meta.join('\n')}\n\n${d.rationale}` + }) + return `${HEADER}\n\n${INTRO}\n\n${blocks.join('\n\n')}\n`.replace(/\n{3,}/g, '\n\n') +} + +const HEADING_RE = /^##\s+(?:\[(\w+)\]\s*)?(.+?)\s*$/ +const META_RE = /^-\s+([a-z-]+)\s*:\s*(.*)$/i + +/** Parse `DECISIONS.md` contents into decisions. Malformed sections are skipped. */ +export function parseDecisions(markdown: string): Decision[] { + const lines = markdown.split(/\r?\n/) + const decisions: Decision[] = [] + + let current: { spec: DecisionSpec; body: string[] } | null = null + const flush = () => { + if (!current) return + const rationale = current.body.join('\n').trim() + // A section with no rationale is incomplete; skip rather than throw so one + // bad hand-edit does not sink the whole file. + if (rationale) { + try { + decisions.push(defineDecision({ ...current.spec, rationale })) + } catch { + // ignore an unparseable section + } + } + current = null + } + + for (const line of lines) { + const heading = HEADING_RE.exec(line) + if (heading && !line.startsWith('# ')) { + flush() + const rawStatus = heading[1]?.toLowerCase() + const status = (STATUSES as string[]).includes(rawStatus ?? '') + ? (rawStatus as DecisionStatus) + : 'rejected' + current = { spec: { title: heading[2] ?? '', rationale: '', status }, body: [] } + continue + } + if (!current) continue + + const meta = META_RE.exec(line) + if (meta && current.body.length === 0) { + const key = meta[1]?.toLowerCase() + const value = meta[2]?.trim() ?? '' + if (key === 'id') current.spec.id = value + else if (key === 'date') current.spec.date = value + else if (key === 'superseded-by') current.spec.supersededBy = value + else if (key === 'tags') current.spec.tags = value.split(',').map(t => t.trim()).filter(Boolean) + continue + } + + current.body.push(line) + } + flush() + + return decisions +} diff --git a/packages/ai-autopilot/src/decisions/store.test.ts b/packages/ai-autopilot/src/decisions/store.test.ts new file mode 100644 index 0000000..370cf1b --- /dev/null +++ b/packages/ai-autopilot/src/decisions/store.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { DecisionLedger } from './ledger.js' +import { loadLedger, saveLedger, DECISIONS_FILE, type LedgerFs } from './store.js' + +/** An in-memory {@link LedgerFs} for tests (also proves the RunnerFs-shaped seam). */ +function memFs(seed: Record = {}): LedgerFs & { files: Record } { + const files = { ...seed } + return { + files, + async read(path) { + const v = files[path] + if (v === undefined) throw new Error(`ENOENT: ${path}`) + return v + }, + async write(path, contents) { + files[path] = contents + }, + async exists(path) { + return path in files + }, + } +} + +describe('decisions store', () => { + it('returns an empty ledger when the file is absent', async () => { + const ledger = await loadLedger(memFs()) + assert.equal(ledger.size, 0) + }) + + it('saves to DECISIONS.md and loads back an equivalent ledger', async () => { + const fs = memFs() + const ledger = new DecisionLedger() + ledger.reject('Use Redux', 'boilerplate', ['state']) + await saveLedger(fs, ledger) + + assert.ok(fs.files[DECISIONS_FILE]) + const loaded = await loadLedger(fs) + assert.equal(loaded.get('use-redux')?.rationale, 'boilerplate') + assert.equal(loaded.wasRejected('add redux'), true) + }) + + it('honors a custom path', async () => { + const fs = memFs() + await saveLedger(fs, new DecisionLedger([]), 'docs/DECISIONS.md') + assert.ok('docs/DECISIONS.md' in fs.files) + }) +}) diff --git a/packages/ai-autopilot/src/decisions/store.ts b/packages/ai-autopilot/src/decisions/store.ts new file mode 100644 index 0000000..7d55b42 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/store.ts @@ -0,0 +1,61 @@ +import { DecisionLedger } from './ledger.js' + +/** + * The slice of a filesystem the store needs. Deliberately a subset of the + * runner's `RunnerFs`, so a booted {@link RunnerSession}'s `fs` satisfies it + * directly — the ledger persists inside a sandbox the same way it does on the + * host. {@link nodeLedgerFs} is the host adapter. + */ +export interface LedgerFs { + read(path: string): Promise + write(path: string, contents: string): Promise + exists(path: string): Promise +} + +/** Default location of the ledger file, at the project/workspace root. */ +export const DECISIONS_FILE = 'DECISIONS.md' + +/** + * Load a {@link DecisionLedger} from `path`. A missing file yields an empty + * ledger (the expected first-run state), so callers never branch on existence. + */ +export async function loadLedger(fs: LedgerFs, path: string = DECISIONS_FILE): Promise { + if (!(await fs.exists(path))) return new DecisionLedger() + return DecisionLedger.fromMarkdown(await fs.read(path)) +} + +/** Persist a ledger to `path` as `DECISIONS.md`. */ +export async function saveLedger( + fs: LedgerFs, + ledger: DecisionLedger, + path: string = DECISIONS_FILE, +): Promise { + await fs.write(path, ledger.toMarkdown()) +} + +/** + * A {@link LedgerFs} backed by the host filesystem (`node:fs/promises`). The + * import is dynamic so the decisions core stays free of a hard `node:fs` + * dependency — the ledger and its markdown work in any runtime; only this + * adapter touches disk. + */ +export function nodeLedgerFs(): LedgerFs { + return { + async read(path) { + const { readFile } = await import('node:fs/promises') + return readFile(path, 'utf8') + }, + async write(path, contents) { + const { writeFile } = await import('node:fs/promises') + await writeFile(path, contents, 'utf8') + }, + async exists(path) { + const { stat } = await import('node:fs/promises') + try { + return (await stat(path)).isFile() + } catch { + return false + } + }, + } +} diff --git a/packages/ai-autopilot/src/decisions/tools.test.ts b/packages/ai-autopilot/src/decisions/tools.test.ts new file mode 100644 index 0000000..2e37065 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/tools.test.ts @@ -0,0 +1,57 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { AnyTool } from '@gemstack/ai-sdk' +import { DecisionLedger } from './ledger.js' +import { decisionTools, decisionBriefing } from './tools.js' + +/** Invoke a tool's server handler directly. */ +function call(tools: AnyTool[], name: string, input: unknown) { + const tool = tools.find(t => t.definition.name === name) + assert.ok(tool, `tool ${name} exists`) + return (tool!.execute as (i: unknown) => unknown)(input) +} + +describe('decisionTools', () => { + it('consult_decisions surfaces a prior rejected idea', async () => { + const ledger = new DecisionLedger() + ledger.reject('Use Redux for state', 'Too much boilerplate', ['state']) + const r = (await call(decisionTools(ledger), 'consult_decisions', { + idea: 'add redux for state', + })) as { matches: Array<{ status: string; title: string }> } + assert.equal(r.matches[0]?.status, 'rejected') + assert.match(r.matches[0]!.title, /Redux/) + }) + + it('record_decision appends and fires onRecord for persistence', async () => { + const ledger = new DecisionLedger() + let persisted = 0 + const tools = decisionTools(ledger, { onRecord: () => { persisted++ } }) + const r = (await call(tools, 'record_decision', { + title: 'Drop GraphQL', + status: 'rejected', + rationale: 'REST is enough', + })) as { ok: boolean; id: string } + assert.equal(r.id, 'drop-graphql') + assert.equal(ledger.wasRejected('use graphql'), true) + assert.equal(persisted, 1) + }) + + it('honors the record toggle and name prefix', () => { + const names = decisionTools(new DecisionLedger(), { record: false, prefix: 'decisions' }).map( + t => t.definition.name, + ) + assert.deepEqual(names, ['decisions_consult_decisions']) + }) +}) + +describe('decisionBriefing', () => { + it('renders rejected ideas and is empty when there are none', () => { + assert.equal(decisionBriefing(new DecisionLedger()), '') + const ledger = new DecisionLedger() + ledger.reject('Use Redux', 'boilerplate') + ledger.accept('Use Vike', 'good') + const brief = decisionBriefing(ledger) + assert.match(brief, /Use Redux \(rejected: boilerplate\)/) + assert.doesNotMatch(brief, /Use Vike/) // accepted choices are not in the "do not propose" list + }) +}) diff --git a/packages/ai-autopilot/src/decisions/tools.ts b/packages/ai-autopilot/src/decisions/tools.ts new file mode 100644 index 0000000..b5c9538 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/tools.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' +import { toolDefinition, type AnyTool } from '@gemstack/ai-sdk' +import type { DecisionLedger } from './ledger.js' +import type { DecisionStatus } from './types.js' + +/** Options for {@link decisionTools}. */ +export interface DecisionToolsOptions { + /** + * Prefix for every tool name (e.g. `decisions` → `decisions_consult`). Useful + * when an agent already has a same-named tool. Default: no prefix. + */ + prefix?: string + /** Expose the `record_decision` tool. Default `true`. */ + record?: boolean + /** + * Called after a decision is recorded, so a caller can persist the ledger + * (e.g. `() => saveLedger(fs, ledger)`). Awaited; a rejection surfaces as a + * tool error. Omit to keep the ledger in memory only. + */ + onRecord?: (ledger: DecisionLedger) => void | Promise +} + +/** + * Expose a {@link DecisionLedger} to an agent as `ai-sdk` tools. This is how the + * "consult before proposing, append on decide" policy reaches the model: + * + * - `consult_decisions` — before proposing an idea, check whether it was already + * decided; a rejected match means "do not re-pitch this". + * - `record_decision` — after an idea is accepted or rejected, append it so the + * next session remembers. + * + * Pair with {@link decisionBriefing} to also front-load the rejected set into the + * system prompt. + */ +export function decisionTools(ledger: DecisionLedger, opts: DecisionToolsOptions = {}): AnyTool[] { + const name = (base: string) => (opts.prefix ? `${opts.prefix}_${base}` : base) + const tools: AnyTool[] = [] + + tools.push( + toolDefinition({ + name: name('consult_decisions'), + description: + 'Check whether an idea was already decided on this project before proposing it. ' + + 'Returns matching prior decisions; a rejected match means do not re-propose it.', + inputSchema: z.object({ + idea: z.string().describe('The idea or change you are about to propose, in a short phrase.'), + }), + }).server(async ({ idea }) => { + const matches = ledger.consult(idea) + return { + matches: matches.map(m => ({ + title: m.decision.title, + status: m.decision.status, + rationale: m.decision.rationale, + score: Number(m.score.toFixed(2)), + })), + } + }) as unknown as AnyTool, + ) + + if (opts.record !== false) { + tools.push( + toolDefinition({ + name: name('record_decision'), + description: + 'Record a decision so it is not revisited: a rejected idea (with why) or an accepted choice.', + inputSchema: z.object({ + title: z.string().describe('One-line statement of the idea or choice.'), + status: z + .enum(['rejected', 'accepted', 'superseded']) + .describe('rejected = turned down; accepted = committed to.'), + rationale: z.string().describe('Why it was rejected or chosen.'), + tags: z.array(z.string()).optional().describe('Topic tags to match related ideas.'), + }), + }) + .server(async ({ title, status, rationale, tags }) => { + const decision = ledger.record({ + title, + status: status as DecisionStatus, + rationale, + ...(tags ? { tags } : {}), + }) + await opts.onRecord?.(ledger) + return { ok: true, id: decision.id } + }) + .modelOutput(r => `recorded ${r.id}`) as unknown as AnyTool, + ) + } + + return tools +} + +/** + * Render the rejected ideas as a system-prompt fragment to prepend to an agent's + * instructions, so it starts a run already aware of what not to re-propose. + * Returns `''` when nothing has been rejected, so it can be concatenated + * unconditionally. + */ +export function decisionBriefing(ledger: DecisionLedger): string { + const rejected = ledger.rejected() + if (rejected.length === 0) return '' + const lines = rejected.map(d => `- ${d.title} (rejected: ${d.rationale})`) + return ( + 'This project has already considered and rejected the following ideas. ' + + 'Do not propose them again unless the user explicitly reopens the question:\n' + + lines.join('\n') + ) +} diff --git a/packages/ai-autopilot/src/decisions/types.ts b/packages/ai-autopilot/src/decisions/types.ts new file mode 100644 index 0000000..82d8ec8 --- /dev/null +++ b/packages/ai-autopilot/src/decisions/types.ts @@ -0,0 +1,64 @@ +/** + * The decisions ledger — a durable record of the choices and rejected ideas of + * a project, so an autopilot run stops re-pitching what was already turned down. + * + * A {@link Decision} is *data*: a settled choice or a rejected idea with the + * reason behind it. The ledger is the canonical store; it round-trips to a + * human-readable `DECISIONS.md` the user can also read and edit. Before an agent + * proposes something it consults the ledger; when a suggestion is accepted or + * rejected it appends to it. This is the "declare intent once, respect it" seam. + */ + +/** + * The standing of a recorded decision. + * + * - `rejected` — an idea that was considered and turned down; the agent must not + * re-propose it. The primary reason the ledger exists. + * - `accepted` — a settled choice the project has committed to. + * - `superseded` — a past decision replaced by a later one (see + * {@link Decision.supersededBy}); kept for history, not enforced. + */ +export type DecisionStatus = 'rejected' | 'accepted' | 'superseded' + +/** A single recorded decision or rejected idea. Frozen once defined. */ +export interface Decision { + /** Stable slug, kebab-case; derived from {@link title} when not supplied. */ + readonly id: string + /** One-line statement of the idea or choice (e.g. "Use Redux for state"). */ + readonly title: string + /** Whether the idea was rejected, accepted, or later superseded. */ + readonly status: DecisionStatus + /** The "because Y": why it was rejected or chosen. */ + readonly rationale: string + /** Free-form topic tags used to match related ideas (lowercased). */ + readonly tags: readonly string[] + /** ISO date the decision was recorded, when known. */ + readonly date?: string + /** For `superseded` decisions, the id of the decision that replaced it. */ + readonly supersededBy?: string +} + +/** + * The author-facing shape passed to {@link defineDecision} / `ledger.record`. + * Optional fields default: `status` to `rejected`, `tags` to empty, `id` to a + * slug of the title. + */ +export interface DecisionSpec { + title: string + rationale: string + status?: DecisionStatus + id?: string + tags?: string[] + date?: string + supersededBy?: string +} + +/** A prior decision surfaced by {@link DecisionLedger.consult} for a new idea. */ +export interface DecisionMatch { + /** The matching prior decision. */ + decision: Decision + /** Overlap score in `(0, 1]`; higher means a closer match. */ + score: number + /** The terms the idea and the decision share, for an explainable match. */ + overlap: readonly string[] +} diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 1951e63..aedc119 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -34,6 +34,14 @@ * - {@link terminalSink} — print events inline (terminal surface) * - {@link EventStream} — replayable multi-consumer event transport * - {@link launchAutopilot} — a detached background run handle + * + * Decisions are the durable memory layer: a ledger of the project's rejected + * ideas and settled choices, so a run stops re-pitching what was already turned + * down. It round-trips a human-editable `DECISIONS.md`. + * + * - {@link DecisionLedger} — record decisions, consult before proposing + * - {@link loadLedger} / {@link saveLedger} — persist to `DECISIONS.md` + * - {@link decisionTools} / {@link decisionBriefing} — expose it to an agent */ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' @@ -86,6 +94,28 @@ export { type AutopilotStatus, type LaunchOptions, } from './surface/index.js' +export { + defineDecision, + DecisionError, + slugify, + DecisionLedger, + createLedger, + parseDecisions, + serializeDecisions, + loadLedger, + saveLedger, + nodeLedgerFs, + DECISIONS_FILE, + decisionTools, + decisionBriefing, + type ConsultOptions, + type LedgerFs, + type DecisionToolsOptions, + type Decision, + type DecisionSpec, + type DecisionStatus, + type DecisionMatch, +} from './decisions/index.js' export type { Subtask, PlannedSubtask,