From 739fc1bcffd7281c7b9fe4b76351644caa0f33d5 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 5 Jul 2026 17:28:57 +0300 Subject: [PATCH] feat(ai-autopilot): modes via conditions frontmatter on preset files A stem..md sibling with metadata.conditions overrides its stem.md base when those modes are active. loadDomainPreset(dir, { modes }) resolves the winner per stem (most specific eligible, else base). Software Development preset gains a technical variant of its major-change loop. Simple fan-out; #245 is the compose follow-up. Closes #244 --- .changeset/open-loop-modes-conditions.md | 14 ++ .../loops/major-change.technical.md | 12 ++ packages/ai-autopilot/src/index.ts | 7 + .../src/preset/conditions.test.ts | 55 ++++++++ .../ai-autopilot/src/preset/conditions.ts | 66 ++++++++++ packages/ai-autopilot/src/preset/index.ts | 2 + packages/ai-autopilot/src/preset/load.test.ts | 17 +++ packages/ai-autopilot/src/preset/load.ts | 121 ++++++++++-------- .../src/preset/software-development.test.ts | 13 ++ 9 files changed, 257 insertions(+), 50 deletions(-) create mode 100644 .changeset/open-loop-modes-conditions.md create mode 100644 packages/ai-autopilot/presets/software-development/loops/major-change.technical.md create mode 100644 packages/ai-autopilot/src/preset/conditions.test.ts create mode 100644 packages/ai-autopilot/src/preset/conditions.ts diff --git a/.changeset/open-loop-modes-conditions.md b/.changeset/open-loop-modes-conditions.md new file mode 100644 index 0000000..74f096e --- /dev/null +++ b/.changeset/open-loop-modes-conditions.md @@ -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..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). diff --git a/packages/ai-autopilot/presets/software-development/loops/major-change.technical.md b/packages/ai-autopilot/presets/software-development/loops/major-change.technical.md new file mode 100644 index 0000000..5d5a87a --- /dev/null +++ b/packages/ai-autopilot/presets/software-development/loops/major-change.technical.md @@ -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. diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index c44948f..56fe5b7 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -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' @@ -339,6 +341,11 @@ export { loadSkillsFrom, builtinPresetsDir, softwareDevelopmentPreset, + selectWinners, + stemOf, + readConditions, + type LoadPresetOptions, + type Conditional, type DomainPreset, type DomainPresetSpec, type DomainPresetMeta, diff --git a/packages/ai-autopilot/src/preset/conditions.test.ts b/packages/ai-autopilot/src/preset/conditions.test.ts new file mode 100644 index 0000000..3cb92b8 --- /dev/null +++ b/packages/ai-autopilot/src/preset/conditions.test.ts @@ -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) + }) +}) diff --git a/packages/ai-autopilot/src/preset/conditions.ts b/packages/ai-autopilot/src/preset/conditions.ts new file mode 100644 index 0000000..f366e82 --- /dev/null +++ b/packages/ai-autopilot/src/preset/conditions.ts @@ -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 | 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(entries: readonly T[], activeModes: readonly string[]): T[] { + const active = new Set(activeModes) + const groups = new Map() + 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 +} diff --git a/packages/ai-autopilot/src/preset/index.ts b/packages/ai-autopilot/src/preset/index.ts index e597810..f87b119 100644 --- a/packages/ai-autopilot/src/preset/index.ts +++ b/packages/ai-autopilot/src/preset/index.ts @@ -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' diff --git a/packages/ai-autopilot/src/preset/load.test.ts b/packages/ai-autopilot/src/preset/load.test.ts index 08ba780..4c9301b 100644 --- a/packages/ai-autopilot/src/preset/load.test.ts +++ b/packages/ai-autopilot/src/preset/load.test.ts @@ -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'), @@ -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 { diff --git a/packages/ai-autopilot/src/preset/load.ts b/packages/ai-autopilot/src/preset/load.ts index a59076b..fd0a4a4 100644 --- a/packages/ai-autopilot/src/preset/load.ts +++ b/packages/ai-autopilot/src/preset/load.ts @@ -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: @@ -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 { +export async function loadDomainPreset(dir: string, opts: LoadPresetOptions = {}): Promise { + const modes = opts.modes ?? [] const manifestPath = join(dir, 'preset.md') let raw: string try { @@ -37,9 +49,9 @@ export async function loadDomainPreset(dir: string): Promise { 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({ @@ -59,62 +71,71 @@ export function builtinPresetsDir(): string { } /** The shipped, stack-agnostic "Software Development" domain preset (#243). */ -export function softwareDevelopmentPreset(): Promise { - return loadDomainPreset(join(builtinPresetsDir(), 'software-development')) +export function softwareDevelopmentPreset(opts: LoadPresetOptions = {}): Promise { + 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 { - 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 { + 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 { - 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 { + 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 { + 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 { +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 { + 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 | undefined, key: string): string | undefined { diff --git a/packages/ai-autopilot/src/preset/software-development.test.ts b/packages/ai-autopilot/src/preset/software-development.test.ts index 11b8b6c..d689108 100644 --- a/packages/ai-autopilot/src/preset/software-development.test.ts +++ b/packages/ai-autopilot/src/preset/software-development.test.ts @@ -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>) => + 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) + }) })