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
14 changes: 14 additions & 0 deletions .changeset/open-loop-domain-preset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@gemstack/ai-autopilot': minor
---

Add the Open Loop bundle unit: a domain preset = {loops, prompts, skills} (#242).

This is the keystone that ties the three data types the framework already ships
separately into one selectable, composable thing. Author one in code with
`defineDomainPreset`, or load one from a directory of `.md` files (`preset.md` +
`loops/`, `prompts/`, `skills/`) with `loadDomainPreset`. `composeDomainPresets`
merges several into one (loops concatenate; prompts and skills merge by id/name,
later wins), so presets-of-presets falls out; `selectPreset` picks the user's
domain by name. Kept distinct from the framework `Preset` detector in `presets/`
(skipped for the Open Loop MVP) by naming this `DomainPreset`.
21 changes: 21 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@
* - {@link detectFramework} — score a project's deps/files against presets
* - {@link vikePreset} / {@link nextPreset} — the built-ins
* - {@link presetPersonas} — its framework skill's page builder + the shared neutral ones
*
* Domain presets are the Open Loop bundle unit (#204): one selectable, composable
* bundle of {loops, prompts, skills}. Author in code or load a directory of `.md`
* files; compose several into one (presets-of-presets). Distinct from the
* framework `Preset` above — this is the user-picked domain, not a detector.
*
* - {@link defineDomainPreset} / {@link loadDomainPreset} — author, or load from a directory
* - {@link composeDomainPresets} — merge presets into one (later wins on prompt/skill id)
* - {@link selectPreset} — pick the user's chosen domain by name
*/
export { Supervisor } from './supervisor.js'
export { agentPlanner, type AgentPlannerOptions } from './planner.js'
Expand Down Expand Up @@ -319,6 +328,18 @@ export {
type PresetScore,
type FrameworkDetection,
} from './presets/index.js'
export {
defineDomainPreset,
DomainPresetError,
composeDomainPresets,
selectPreset,
loadDomainPreset,
loadLoopsFrom,
loadSkillsFrom,
type DomainPreset,
type DomainPresetSpec,
type DomainPresetMeta,
} from './preset/index.js'
export {
defineFrameworkExtension,
defineSkill,
Expand Down
57 changes: 57 additions & 0 deletions packages/ai-autopilot/src/preset/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { composeDomainPresets, selectPreset } from './compose.js'
import { defineDomainPreset } from './define.js'
import { defineLoop } from '../loop/define.js'
import { defineSkill } from '../extensions/define.js'

const prompt = (id: string, instructions = id) => ({ id, name: id, title: id, description: '', instructions, passes: 1, appliesTo: [] })

const base = defineDomainPreset({
name: 'base',
loops: [defineLoop({ on: 'major-change', run: ['review'] })],
prompts: [prompt('review', 'base-review'), prompt('security')],
skills: [defineSkill({ name: 'vike', title: 'Vike', description: 'v', url: 'https://vike.dev/llms.txt' })],
})

const overlay = defineDomainPreset({
name: 'overlay',
loops: [defineLoop({ on: 'ui-flow', run: ['qa'] })],
prompts: [prompt('review', 'overlay-review'), prompt('qa')],
skills: [defineSkill({ name: 'vike', title: 'Vike v2', description: 'v2', url: 'https://vike.dev/llms.txt' })],
})

describe('composeDomainPresets', () => {
it('concatenates loops and merges prompts/skills with later-wins', () => {
const merged = composeDomainPresets({ name: 'combined', title: 'Combined' }, base, overlay)

assert.equal(merged.name, 'combined')
assert.equal(merged.title, 'Combined')

// loops concatenate, in preset order
assert.equal(merged.loops.length, 2)
assert.deepEqual(merged.loops.map(l => [...l.on]), [['major-change'], ['ui-flow']])

// prompts merge by id, later preset wins, sorted by id
assert.deepEqual(merged.prompts.map(p => p.id), ['qa', 'review', 'security'])
assert.equal(merged.prompts.find(p => p.id === 'review')!.instructions, 'overlay-review')

// skills merge by name, later preset wins
assert.equal(merged.skills.length, 1)
assert.equal(merged.skills[0]!.title, 'Vike v2')
})

it('composing is presets-of-presets: the result is itself a preset', () => {
const parent = composeDomainPresets({ name: 'parent' }, base, overlay)
const grand = composeDomainPresets({ name: 'grand' }, parent)
assert.equal(grand.prompts.length, parent.prompts.length)
assert.equal(grand.name, 'grand')
})
})

describe('selectPreset', () => {
it('picks by name or returns undefined', () => {
assert.equal(selectPreset([base, overlay], 'overlay'), overlay)
assert.equal(selectPreset([base, overlay], 'missing'), undefined)
})
})
39 changes: 39 additions & 0 deletions packages/ai-autopilot/src/preset/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { defineDomainPreset } from './define.js'
import type { DomainPreset, DomainPresetMeta } from './types.js'
import type { Prompt } from '../prompts/types.js'
import type { Skill } from '../extensions/types.js'

/**
* Compose several {@link DomainPreset}s into one under a new label — this is what
* makes presets-of-presets fall out: a parent preset is just the composition of
* the children it selects.
*
* Merge rules:
* - **loops** concatenate in preset order (the loop engine already de-dupes the
* prompt ids a chain resolves to, so overlapping loops are harmless).
* - **prompts** merge by `id` and **skills** by `name`, later presets winning, so
* a preset later in the list overrides a shared body or doc pointer. Both come
* out sorted by their key for a stable result.
*/
export function composeDomainPresets(meta: DomainPresetMeta, ...presets: DomainPreset[]): DomainPreset {
const prompts = new Map<string, Prompt>()
const skills = new Map<string, Skill>()
const loops = presets.flatMap(p => [...p.loops])

for (const preset of presets) {
for (const prompt of preset.prompts) prompts.set(prompt.id, prompt)
for (const skill of preset.skills) skills.set(skill.name, skill)
}

return defineDomainPreset({
...meta,
loops,
prompts: [...prompts.values()].sort((a, b) => a.id.localeCompare(b.id)),
skills: [...skills.values()].sort((a, b) => a.name.localeCompare(b.name)),
})
}

/** Pick the preset with `name` from a set (e.g. the user's chosen domain), or `undefined`. */
export function selectPreset(presets: readonly DomainPreset[], name: string): DomainPreset | undefined {
return presets.find(p => p.name === name)
}
43 changes: 43 additions & 0 deletions packages/ai-autopilot/src/preset/define.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { defineDomainPreset, DomainPresetError } from './define.js'
import { defineLoop } from '../loop/define.js'
import { defineSkill } from '../extensions/define.js'

const aLoop = defineLoop({ on: 'major-change', run: ['review'] })
const aSkill = defineSkill({ name: 'vike', title: 'Vike', description: 'Vike knowledge', url: 'https://vike.dev/llms.txt' })
const aPrompt = { id: 'review', name: 'review', title: 'Review', description: '', instructions: 'do', passes: 1, appliesTo: [] }

describe('defineDomainPreset', () => {
it('freezes the bundle and defaults title/description/lists', () => {
const preset = defineDomainPreset({ name: 'software-development' })
assert.equal(preset.name, 'software-development')
assert.equal(preset.title, 'software-development') // title defaults to name
assert.equal(preset.description, '')
assert.deepEqual(preset.loops, [])
assert.deepEqual(preset.prompts, [])
assert.deepEqual(preset.skills, [])
assert.ok(Object.isFrozen(preset))
assert.throws(() => ((preset as { name: string }).name = 'x'))
})

it('carries the three content types', () => {
const preset = defineDomainPreset({
name: 'sw-dev',
title: 'Software Development',
description: 'General engineering.',
loops: [aLoop],
prompts: [aPrompt],
skills: [aSkill],
})
assert.equal(preset.title, 'Software Development')
assert.deepEqual(preset.loops, [aLoop])
assert.deepEqual(preset.prompts, [aPrompt])
assert.deepEqual(preset.skills, [aSkill])
})

it('rejects a missing or non-kebab name', () => {
assert.throws(() => defineDomainPreset({ name: '' }), DomainPresetError)
assert.throws(() => defineDomainPreset({ name: 'Software Dev' }), DomainPresetError)
})
})
31 changes: 31 additions & 0 deletions packages/ai-autopilot/src/preset/define.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { DomainPreset, DomainPresetSpec } from './types.js'

/** Thrown when a domain preset spec is malformed. Fails fast at definition time. */
export class DomainPresetError extends Error {
constructor(message: string) {
super(`[ai-autopilot] ${message}`)
this.name = 'DomainPresetError'
}
}

const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/

/**
* Validate a {@link DomainPresetSpec} and return a frozen {@link DomainPreset}.
* `title` defaults to `name`, `description` to empty, and the three content lists
* to empty so callers never null-check them.
*/
export function defineDomainPreset(spec: DomainPresetSpec): DomainPreset {
const name = spec.name?.trim()
if (!name) throw new DomainPresetError('preset name is required')
if (!KEBAB.test(name)) throw new DomainPresetError(`preset name must be kebab-case: ${JSON.stringify(spec.name)}`)

return Object.freeze({
name,
title: spec.title?.trim() || name,
description: spec.description?.trim() ?? '',
loops: Object.freeze([...(spec.loops ?? [])]),
prompts: Object.freeze([...(spec.prompts ?? [])]),
skills: Object.freeze([...(spec.skills ?? [])]),
})
}
13 changes: 13 additions & 0 deletions packages/ai-autopilot/src/preset/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* The Open Loop bundle unit (#204, #242): a **domain preset** = {loops, prompts,
* skills}. Author one in code with {@link defineDomainPreset}, load one from a
* directory of `.md` files with {@link loadDomainPreset}, and merge several into
* one with {@link composeDomainPresets} (so presets-of-presets falls out).
*
* Distinct from the framework `Preset` in `presets/` (a project detector); this
* is the user-picked domain bundle.
*/
export { defineDomainPreset, DomainPresetError } from './define.js'
export { composeDomainPresets, selectPreset } from './compose.js'
export { loadDomainPreset, loadLoopsFrom, loadSkillsFrom } from './load.js'
export type { DomainPreset, DomainPresetSpec, DomainPresetMeta } from './types.js'
76 changes: 76 additions & 0 deletions packages/ai-autopilot/src/preset/load.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { loadDomainPreset, loadLoopsFrom, loadSkillsFrom } from './load.js'
import { DomainPresetError } from './define.js'

async function writeFixture(root: string) {
await writeFile(
join(root, 'preset.md'),
`---\nname: software-development\ndescription: General software engineering.\nmetadata:\n title: Software Development\n---\nA domain preset for building software.\n`,
)
await mkdir(join(root, 'loops'))
await writeFile(
join(root, 'loops', 'major-change.md'),
`---\nname: major-change\ndescription: What fires on a major change.\nmetadata:\n on: major-change\n run: [review, security]\n---\nIf the change is substantial, review it then check security.\n`,
)
await mkdir(join(root, 'prompts'))
await writeFile(
join(root, 'prompts', 'review.md'),
`---\nname: review\ndescription: Review a change.\nmetadata:\n loopId: review\n---\nReview the change for correctness.\n`,
)
await mkdir(join(root, 'skills'))
await writeFile(
join(root, 'skills', 'vike.md'),
`---\nname: vike\ndescription: Vike page/routing knowledge.\nmetadata:\n title: Vike\n url: https://vike.dev/llms.txt\n---\n`,
)
}

describe('loadDomainPreset', () => {
let dir: string
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'domain-preset-'))
await writeFixture(dir)
})
after(async () => {
await rm(dir, { recursive: true, force: true })
})

it('loads {loops, prompts, skills} from a directory of .md files', async () => {
const preset = await loadDomainPreset(dir)
assert.equal(preset.name, 'software-development')
assert.equal(preset.title, 'Software Development')
assert.equal(preset.description, 'General software engineering.')

assert.equal(preset.loops.length, 1)
assert.deepEqual([...preset.loops[0]!.on], ['major-change'])
assert.deepEqual([...preset.loops[0]!.run], ['review', 'security'])

assert.equal(preset.prompts.length, 1)
assert.equal(preset.prompts[0]!.id, 'review')
assert.match(preset.prompts[0]!.instructions, /correctness/)

assert.equal(preset.skills.length, 1)
assert.equal(preset.skills[0]!.name, 'vike')
assert.equal(preset.skills[0]!.url, 'https://vike.dev/llms.txt')
assert.equal(preset.skills[0]!.title, 'Vike')
})

it('throws when preset.md is missing', async () => {
const empty = await mkdtemp(join(tmpdir(), 'domain-preset-empty-'))
try {
await assert.rejects(loadDomainPreset(empty), DomainPresetError)
} finally {
await rm(empty, { recursive: true, force: true })
}
})
})

describe('loadLoopsFrom / loadSkillsFrom', () => {
it('return [] for a directory that does not exist', async () => {
assert.deepEqual(await loadLoopsFrom(join(tmpdir(), 'no-such-loops-dir-xyz')), [])
assert.deepEqual(await loadSkillsFrom(join(tmpdir(), 'no-such-skills-dir-xyz')), [])
})
})
Loading
Loading