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-modes-conditions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@gemstack/ai-autopilot': minor
---

Add modes (Autopilot / Technical) to domain presets via `conditions` frontmatter (#244).

A preset content file can now ship mode variants: a `stem.<variant>.md` sibling
that declares `metadata.conditions` (a mode or list) overrides its `stem.md` base
when those modes are active. `loadDomainPreset(dir, { modes })` (and
`softwareDevelopmentPreset({ modes })`) resolve the winner per stem — the most
specific eligible variant, falling back to the base. The shipped Software
Development preset gains a `technical` variant of its major-change loop as an
illustration. This is the simple frontmatter fan-out; composing prompts from
parameters is the follow-up (#245).
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
name: major-change-loop-technical
description: The major-change loop under Technical Control — leaner, the developer drives the depth.
metadata:
on: major-change
run: [code-review]
conditions: technical
---

Technical Control mode: the developer is hands-on, so the loop only auto-runs the
core review and leaves test and security depth to them. Overrides the base
major-change loop when `technical` is active.
7 changes: 7 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@
* - {@link composeDomainPresets} — merge presets into one (later wins on prompt/skill id)
* - {@link selectPreset} — pick the user's chosen domain by name
* - {@link softwareDevelopmentPreset} — the shipped, stack-agnostic built-in
* - {@link loadDomainPreset} `{ modes }` — activate Autopilot/Technical variants
* ({@link selectWinners}): a `conditions` sibling `.md` overrides its base
*/
export { Supervisor } from './supervisor.js'
export { agentPlanner, type AgentPlannerOptions } from './planner.js'
Expand Down Expand Up @@ -339,6 +341,11 @@ export {
loadSkillsFrom,
builtinPresetsDir,
softwareDevelopmentPreset,
selectWinners,
stemOf,
readConditions,
type LoadPresetOptions,
type Conditional,
type DomainPreset,
type DomainPresetSpec,
type DomainPresetMeta,
Expand Down
55 changes: 55 additions & 0 deletions packages/ai-autopilot/src/preset/conditions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { selectWinners, stemOf, readConditions } from './conditions.js'

describe('stemOf', () => {
it('takes the name up to the first dot', () => {
assert.equal(stemOf('loop.md'), 'loop')
assert.equal(stemOf('loop.autopilot.md'), 'loop')
assert.equal(stemOf('major-change.md'), 'major-change') // hyphens are not separators
assert.equal(stemOf('review.technical.md'), 'review')
})
})

describe('readConditions', () => {
it('normalizes a string, a list, or nothing', () => {
assert.deepEqual(readConditions({ conditions: 'autopilot' }), ['autopilot'])
assert.deepEqual(readConditions({ conditions: ['autopilot', 'technical'] }), ['autopilot', 'technical'])
assert.deepEqual(readConditions({}), [])
assert.deepEqual(readConditions(undefined), [])
})
})

describe('selectWinners', () => {
const base = { stem: 'loop', conditions: [] }
const autopilot = { stem: 'loop', conditions: ['autopilot'] }
const technical = { stem: 'loop', conditions: ['technical'] }

it('picks the base when no mode is active', () => {
assert.deepEqual(selectWinners([base, autopilot], []), [base])
})

it('an active-mode variant beats the base', () => {
assert.deepEqual(selectWinners([base, autopilot], ['autopilot']), [autopilot])
})

it('a variant whose mode is not active is ineligible; base falls back', () => {
assert.deepEqual(selectWinners([base, autopilot], ['technical']), [base])
})

it('keeps a variant even when there is no base', () => {
assert.deepEqual(selectWinners([autopilot], ['autopilot']), [autopilot])
assert.deepEqual(selectWinners([autopilot], []), []) // nothing eligible
})

it('the most specific eligible variant wins', () => {
const both = { stem: 'loop', conditions: ['autopilot', 'technical'] }
assert.deepEqual(selectWinners([base, autopilot, both], ['autopilot', 'technical']), [both])
})

it('resolves each stem independently', () => {
const winners = selectWinners([base, technical, { stem: 'prompt', conditions: [] }], ['technical'])
assert.deepEqual(winners.map(w => w.stem).sort(), ['loop', 'prompt'])
assert.equal(winners.find(w => w.stem === 'loop'), technical)
})
})
66 changes: 66 additions & 0 deletions packages/ai-autopilot/src/preset/conditions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Modes as conditional `.md` files (#244). A preset's content directory can hold
* a base file plus mode variants that override it:
*
* ```text
* loops/major-change.md # base — no conditions, always eligible
* loops/major-change.autopilot.md # metadata.conditions: autopilot — wins when autopilot is active
* ```
*
* A file's **stem** (its name up to the first `.`) is its identity; siblings that
* share a stem are variants of one thing. A variant declares `metadata.conditions`
* (a mode name or a list) and only becomes eligible when every one of those modes
* is active. Among the eligible siblings the most specific one wins (the most
* matched conditions), so an active-mode variant beats the base and the base is
* the fallback when nothing matches.
*
* This is the simple frontmatter fan-out; real prompt composition from parameters
* is the follow-up (#245) if the variant files get too duplicative.
*/

/** The filename stem before the first `.` — a file's identity for override grouping. */
export function stemOf(filename: string): string {
return filename.replace(/\.md$/i, '').split('.')[0] ?? filename
}

/** Read `metadata.conditions` as a normalized list of mode names (empty for a base file). */
export function readConditions(meta: Record<string, unknown> | undefined): string[] {
const c = meta?.['conditions']
if (c == null) return []
const list = Array.isArray(c) ? c : [c]
return list.filter((x): x is string => typeof x === 'string').map(s => s.trim()).filter(Boolean)
}

/** A candidate file reduced to what override resolution needs. */
export interface Conditional {
/** Grouping identity — usually {@link stemOf} of the filename. */
readonly stem: string
/** The modes that must all be active for this file to be eligible; empty = base. */
readonly conditions: readonly string[]
}

/**
* From a set of candidate files, keep one winner per stem for the active modes:
* the most-specific eligible variant, falling back to the base. Input order is
* preserved and used to break ties deterministically. Pure — no I/O.
*/
export function selectWinners<T extends Conditional>(entries: readonly T[], activeModes: readonly string[]): T[] {
const active = new Set(activeModes)
const groups = new Map<string, T[]>()
for (const entry of entries) {
const group = groups.get(entry.stem)
if (group) group.push(entry)
else groups.set(entry.stem, [entry])
}

const winners: T[] = []
for (const group of groups.values()) {
let best: T | undefined
for (const entry of group) {
if (!entry.conditions.every(c => active.has(c))) continue // not eligible
if (!best || entry.conditions.length > best.conditions.length) best = entry
}
if (best) winners.push(best)
}
return winners
}
2 changes: 2 additions & 0 deletions packages/ai-autopilot/src/preset/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@ export {
loadSkillsFrom,
builtinPresetsDir,
softwareDevelopmentPreset,
type LoadPresetOptions,
} from './load.js'
export { selectWinners, stemOf, readConditions, type Conditional } from './conditions.js'
export type { DomainPreset, DomainPresetSpec, DomainPresetMeta } from './types.js'
17 changes: 17 additions & 0 deletions packages/ai-autopilot/src/preset/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ async function writeFixture(root: string) {
join(root, 'prompts', 'review.md'),
`---\nname: review\ndescription: Review a change.\nmetadata:\n loopId: review\n---\nReview the change for correctness.\n`,
)
await writeFile(
join(root, 'prompts', 'review.technical.md'),
`---\nname: review-technical\ndescription: Review a change (technical mode).\nmetadata:\n loopId: review\n conditions: technical\n---\nDeep technical review: trace every path.\n`,
)
await mkdir(join(root, 'skills'))
await writeFile(
join(root, 'skills', 'vike.md'),
Expand Down Expand Up @@ -58,6 +62,19 @@ describe('loadDomainPreset', () => {
assert.equal(preset.skills[0]!.title, 'Vike')
})

it('loads only base files when no mode is active', async () => {
const preset = await loadDomainPreset(dir)
assert.equal(preset.prompts.length, 1) // the technical variant is not active
assert.match(preset.prompts[0]!.instructions, /correctness/)
})

it('a conditions variant overrides its base under an active mode', async () => {
const preset = await loadDomainPreset(dir, { modes: ['technical'] })
assert.equal(preset.prompts.length, 1) // still one prompt for id "review" — the variant replaced the base
assert.equal(preset.prompts[0]!.id, 'review')
assert.match(preset.prompts[0]!.instructions, /trace every path/)
})

it('throws when preset.md is missing', async () => {
const empty = await mkdtemp(join(tmpdir(), 'domain-preset-empty-'))
try {
Expand Down
121 changes: 71 additions & 50 deletions packages/ai-autopilot/src/preset/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ import { readFile, readdir } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { parseSkillManifest } from '@gemstack/ai-skills'
import type { SkillManifest } from '@gemstack/ai-skills'
import { defineLoop } from '../loop/define.js'
import { defineSkill } from '../extensions/define.js'
import { loadPromptsFrom } from '../prompts/library.js'
import { parsePrompt } from '../prompts/parse.js'
import type { Loop } from '../loop/types.js'
import type { Prompt } from '../prompts/types.js'
import type { Skill } from '../extensions/types.js'
import { readConditions, selectWinners, stemOf } from './conditions.js'
import { defineDomainPreset, DomainPresetError } from './define.js'
import type { DomainPreset } from './types.js'

/** Options for {@link loadDomainPreset} and the per-directory loaders. */
export interface LoadPresetOptions {
/** Active modes (e.g. `['autopilot']`); a `conditions` variant wins over its base when its modes are active. */
modes?: readonly string[]
}

/**
* Load a {@link DomainPreset} from a directory of `.md` files — the no-code,
* marketplace-shippable form. Layout:
Expand All @@ -23,9 +32,12 @@ import type { DomainPreset } from './types.js'
* ```
*
* The three content subdirectories are all optional — a missing one yields an
* empty list. The preset's identity comes from `preset.md`.
* empty list. The preset's identity comes from `preset.md`. Pass `modes` to
* activate `conditions` variants (see `conditions.ts`); with none, only base
* files load.
*/
export async function loadDomainPreset(dir: string): Promise<DomainPreset> {
export async function loadDomainPreset(dir: string, opts: LoadPresetOptions = {}): Promise<DomainPreset> {
const modes = opts.modes ?? []
const manifestPath = join(dir, 'preset.md')
let raw: string
try {
Expand All @@ -37,9 +49,9 @@ export async function loadDomainPreset(dir: string): Promise<DomainPreset> {
const title = str(manifest.metadata, 'title')

const [loops, prompts, skills] = await Promise.all([
loadLoopsFrom(join(dir, 'loops')),
loadPromptsIn(join(dir, 'prompts')),
loadSkillsFrom(join(dir, 'skills')),
loadLoopsFrom(join(dir, 'loops'), { modes }),
loadPromptsIn(join(dir, 'prompts'), { modes }),
loadSkillsFrom(join(dir, 'skills'), { modes }),
])

return defineDomainPreset({
Expand All @@ -59,62 +71,71 @@ export function builtinPresetsDir(): string {
}

/** The shipped, stack-agnostic "Software Development" domain preset (#243). */
export function softwareDevelopmentPreset(): Promise<DomainPreset> {
return loadDomainPreset(join(builtinPresetsDir(), 'software-development'))
export function softwareDevelopmentPreset(opts: LoadPresetOptions = {}): Promise<DomainPreset> {
return loadDomainPreset(join(builtinPresetsDir(), 'software-development'), opts)
}

/** Load every `*.md` loop file in a directory (a missing directory yields `[]`). */
export async function loadLoopsFrom(dir: string): Promise<Loop[]> {
const files = await mdFiles(dir)
return Promise.all(
files.map(async f => {
const path = join(dir, f)
const { manifest } = parseSkillManifest(await readFile(path, 'utf8'), path)
const meta = manifest.metadata ?? {}
const on = meta.on
const run = meta.run
if (on === undefined) throw new DomainPresetError(`loop ${JSON.stringify(path)} is missing metadata.on`)
if (!Array.isArray(run)) throw new DomainPresetError(`loop ${JSON.stringify(path)} needs metadata.run to be a list`)
return defineLoop({ on: on as string | string[], run: run as string[] })
}),
)
/** Load the loop files in a directory, applying mode overrides (a missing directory yields `[]`). */
export async function loadLoopsFrom(dir: string, opts: LoadPresetOptions = {}): Promise<Loop[]> {
const winners = selectWinners(await manifestEntries(dir), opts.modes ?? [])
return winners.map(({ path, manifest }) => {
const meta = manifest.metadata ?? {}
const on = meta['on']
const run = meta['run']
if (on === undefined) throw new DomainPresetError(`loop ${JSON.stringify(path)} is missing metadata.on`)
if (!Array.isArray(run)) throw new DomainPresetError(`loop ${JSON.stringify(path)} needs metadata.run to be a list`)
return defineLoop({ on: on as string | string[], run: run as string[] })
})
}

/** Load every `*.md` skill pointer in a directory (a missing directory yields `[]`). */
export async function loadSkillsFrom(dir: string): Promise<Skill[]> {
const files = await mdFiles(dir)
return Promise.all(
files.map(async f => {
const path = join(dir, f)
const { manifest } = parseSkillManifest(await readFile(path, 'utf8'), path)
const meta = manifest.metadata ?? {}
const url = str(meta, 'url')
if (!url) throw new DomainPresetError(`skill ${JSON.stringify(path)} is missing metadata.url (its llms.txt pointer)`)
return defineSkill({
name: manifest.name,
title: str(meta, 'title') ?? manifest.name,
description: manifest.description,
url,
})
}),
)
/** Load the prompt bodies in a directory, applying mode overrides (a missing directory yields `[]`). Internal: the public prompt loader is `loadPromptsFrom` in `prompts/`. */
async function loadPromptsIn(dir: string, opts: LoadPresetOptions = {}): Promise<Prompt[]> {
const winners = selectWinners(await manifestEntries(dir), opts.modes ?? [])
return winners.map(({ raw, path }) => parsePrompt(raw, path))
}

/** Load the skill pointers in a directory, applying mode overrides (a missing directory yields `[]`). */
export async function loadSkillsFrom(dir: string, opts: LoadPresetOptions = {}): Promise<Skill[]> {
const winners = selectWinners(await manifestEntries(dir), opts.modes ?? [])
return winners.map(({ path, manifest }) => {
const meta = manifest.metadata ?? {}
const url = str(meta, 'url')
if (!url) throw new DomainPresetError(`skill ${JSON.stringify(path)} is missing metadata.url (its llms.txt pointer)`)
return defineSkill({
name: manifest.name,
title: str(meta, 'title') ?? manifest.name,
description: manifest.description,
url,
})
})
}

// ─── Internals ───────────────────────────────────────────────────

/** `*.md` filenames in a directory, sorted; `[]` when the directory does not exist. */
async function mdFiles(dir: string): Promise<string[]> {
interface Entry {
readonly stem: string
readonly conditions: readonly string[]
readonly path: string
readonly raw: string
readonly manifest: SkillManifest
}

/** Parse every `*.md` file's frontmatter in a directory (a missing directory yields `[]`). */
async function manifestEntries(dir: string): Promise<Entry[]> {
let files: string[]
try {
return (await readdir(dir)).filter(f => f.endsWith('.md')).sort()
files = (await readdir(dir)).filter(f => f.endsWith('.md')).sort()
} catch {
return []
}
}

/** Prompts subdir via the existing loader; a missing/empty dir yields `[]` while real parse errors still surface. */
async function loadPromptsIn(dir: string) {
const files = await mdFiles(dir)
return files.length ? loadPromptsFrom(dir) : []
return Promise.all(
files.map(async f => {
const path = join(dir, f)
const raw = await readFile(path, 'utf8')
const { manifest } = parseSkillManifest(raw, path)
return { stem: stemOf(f), conditions: readConditions(manifest.metadata), path, raw, manifest }
}),
)
}

function str(meta: Record<string, unknown> | undefined, key: string): string | undefined {
Expand Down
13 changes: 13 additions & 0 deletions packages/ai-autopilot/src/preset/software-development.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,17 @@ describe('softwareDevelopmentPreset (shipped built-in)', () => {
const kinds = preset.loops.flatMap(l => [...l.on]).sort()
assert.deepEqual(kinds, ['bug-fix', 'major-change'])
})

it('Technical Control mode overrides the major-change loop with the leaner variant', async () => {
const base = await softwareDevelopmentPreset()
const technical = await softwareDevelopmentPreset({ modes: ['technical'] })

const majorOf = (p: Awaited<ReturnType<typeof softwareDevelopmentPreset>>) =>
p.loops.find(l => l.on.includes('major-change'))!

assert.deepEqual([...majorOf(base).run], ['code-review', 'test-coverage', 'security-review'])
assert.deepEqual([...majorOf(technical).run], ['code-review']) // the variant wins
// still exactly two loops (the variant replaces the base, not adds to it)
assert.equal(technical.loops.length, 2)
})
})
Loading