Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ai-autopilot-decisions-ledger.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions packages/ai-autopilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions packages/ai-autopilot/src/decisions/define.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
73 changes: 73 additions & 0 deletions packages/ai-autopilot/src/decisions/define.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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)
}
26 changes: 26 additions & 0 deletions packages/ai-autopilot/src/decisions/index.ts
Original file line number Diff line number Diff line change
@@ -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'
54 changes: 54 additions & 0 deletions packages/ai-autopilot/src/decisions/ledger.test.ts
Original file line number Diff line number Diff line change
@@ -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(' '), [])
})
})
136 changes: 136 additions & 0 deletions packages/ai-autopilot/src/decisions/ledger.ts
Original file line number Diff line number Diff line change
@@ -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<string, Decision>()

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