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
6 changes: 6 additions & 0 deletions .changeset/bugfix-event-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@gemstack/ai-autopilot': minor
'@gemstack/framework': minor
---

Domain preset runs can now pick a build event kind, so a preset's bug-fix loop actually fires. A run chooses it with `runFramework({ buildEvent })` / the `framework --kind <name>` flag, and a preset can declare its own default via `preset.md` `metadata.event` (surfaced as `DomainPreset.defaultEvent`). Precedence: run choice > preset default > `major-change`; an event the preset has no loop for still falls back to the built-in checklist.
9 changes: 9 additions & 0 deletions packages/ai-autopilot/src/preset/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ describe('composeDomainPresets', () => {
assert.equal(grand.prompts.length, parent.prompts.length)
assert.equal(grand.name, 'grand')
})

it('carries the last declared defaultEvent through composition', () => {
const triage = defineDomainPreset({ name: 'triage', defaultEvent: 'bug-fix' })
assert.equal(composeDomainPresets({ name: 'p' }, base, triage).defaultEvent, 'bug-fix')
// a later preset without one does not clear an earlier default
assert.equal(composeDomainPresets({ name: 'p' }, triage, overlay).defaultEvent, 'bug-fix')
// none declared -> absent
assert.equal('defaultEvent' in composeDomainPresets({ name: 'p' }, base, overlay), false)
})
})

describe('selectPreset', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/ai-autopilot/src/preset/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function composeDomainPresets(meta: DomainPresetMeta, ...presets: DomainP
const prompts = new Map<string, Prompt>()
const skills = new Map<string, Skill>()
const loops = presets.flatMap(p => [...p.loops])
// Carry the default build event kind; a later preset that declares one wins.
const defaultEvent = presets.reduce<string | undefined>((acc, p) => p.defaultEvent ?? acc, undefined)

for (const preset of presets) {
for (const prompt of preset.prompts) prompts.set(prompt.id, prompt)
Expand All @@ -27,6 +29,7 @@ export function composeDomainPresets(meta: DomainPresetMeta, ...presets: DomainP

return defineDomainPreset({
...meta,
...(defaultEvent ? { defaultEvent } : {}),
loops,
prompts: [...prompts.values()].sort((a, b) => a.id.localeCompare(b.id)),
skills: [...skills.values()].sort((a, b) => a.name.localeCompare(b.name)),
Expand Down
6 changes: 6 additions & 0 deletions packages/ai-autopilot/src/preset/define.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,10 @@ describe('defineDomainPreset', () => {
assert.throws(() => defineDomainPreset({ name: '' }), DomainPresetError)
assert.throws(() => defineDomainPreset({ name: 'Software Dev' }), DomainPresetError)
})

it('carries an optional defaultEvent, omitting it when blank', () => {
assert.equal(defineDomainPreset({ name: 'triage', defaultEvent: 'bug-fix' }).defaultEvent, 'bug-fix')
assert.equal('defaultEvent' in defineDomainPreset({ name: 'sw-dev' }), false)
assert.equal('defaultEvent' in defineDomainPreset({ name: 'sw-dev', defaultEvent: ' ' }), false)
})
})
3 changes: 3 additions & 0 deletions packages/ai-autopilot/src/preset/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ export function defineDomainPreset(spec: DomainPresetSpec): DomainPreset {
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)}`)

const defaultEvent = spec.defaultEvent?.trim()

return Object.freeze({
name,
title: spec.title?.trim() || name,
description: spec.description?.trim() ?? '',
...(defaultEvent ? { defaultEvent } : {}),
loops: Object.freeze([...(spec.loops ?? [])]),
prompts: Object.freeze([...(spec.prompts ?? [])]),
skills: Object.freeze([...(spec.skills ?? [])]),
Expand Down
19 changes: 19 additions & 0 deletions packages/ai-autopilot/src/preset/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,22 @@ describe('loadLoopsFrom / loadSkillsFrom', () => {
assert.deepEqual(await loadSkillsFrom(join(tmpdir(), 'no-such-skills-dir-xyz')), [])
})
})

describe('loadDomainPreset defaultEvent', () => {
it('reads metadata.event from preset.md, absent when unset', async () => {
const withEvent = await mkdtemp(join(tmpdir(), 'domain-preset-event-'))
const without = await mkdtemp(join(tmpdir(), 'domain-preset-noevent-'))
try {
await writeFile(
join(withEvent, 'preset.md'),
`---\nname: bug-triage\ndescription: Fix bugs.\nmetadata:\n event: bug-fix\n---\nA triage preset.\n`,
)
await writeFile(join(without, 'preset.md'), `---\nname: sw-dev\ndescription: Build software.\n---\nA preset.\n`)
assert.equal((await loadDomainPreset(withEvent)).defaultEvent, 'bug-fix')
assert.equal('defaultEvent' in (await loadDomainPreset(without)), false)
} finally {
await rm(withEvent, { recursive: true, force: true })
await rm(without, { recursive: true, force: true })
}
})
})
2 changes: 2 additions & 0 deletions packages/ai-autopilot/src/preset/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export async function loadDomainPreset(dir: string, opts: LoadPresetOptions = {}
}
const { manifest } = parseSkillManifest(raw, manifestPath)
const title = str(manifest.metadata, 'title')
const event = str(manifest.metadata, 'event')

const [loops, prompts, skills] = await Promise.all([
loadLoopsFrom(join(dir, 'loops'), { modes }),
Expand All @@ -57,6 +58,7 @@ export async function loadDomainPreset(dir: string, opts: LoadPresetOptions = {}
return defineDomainPreset({
name: manifest.name,
...(title ? { title } : {}),
...(event ? { defaultEvent: event } : {}),
description: manifest.description,
loops,
prompts,
Expand Down
6 changes: 6 additions & 0 deletions packages/ai-autopilot/src/preset/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export interface DomainPreset {
readonly title: string
/** One-line summary of the domain this preset covers. */
readonly description: string
/**
* The loop event kind a run dispatches for this preset by default (e.g.
* `bug-fix`). A run may override it; absent means `major-change`.
*/
readonly defaultEvent?: string
/** The meta prompts: event-kind to prompt-chain mappings. */
readonly loops: readonly Loop[]
/** The prompt bodies the loops dispatch by id. */
Expand All @@ -42,6 +47,7 @@ export interface DomainPresetSpec {
name: string
title?: string
description?: string
defaultEvent?: string
loops?: readonly Loop[]
prompts?: readonly Prompt[]
skills?: readonly Skill[]
Expand Down
12 changes: 12 additions & 0 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ test('parseArgs reads --preset and the mode flags (#256)', () => {
assert.equal(dflt.technical, false)
})

test('parseArgs reads --kind as the build event (#265)', () => {
assert.equal(parseArgs(['--kind', 'bug-fix', 'x']).buildEvent, 'bug-fix')
assert.equal(parseArgs(['x']).buildEvent, undefined)
})

test('activeModes maps the mode flags to Open Loop mode names', () => {
assert.deepEqual(activeModes({ autopilot: false, technical: false }), [])
assert.deepEqual(activeModes({ autopilot: true, technical: false }), ['autopilot'])
Expand Down Expand Up @@ -127,6 +132,13 @@ test('runCli notes mode flags given without a preset', async () => {
assert.ok(err.some(l => /have no effect without a preset/.test(l)))
})

test('runCli notes --kind given without a preset (#265)', async () => {
const { io, err } = capture()
const code = await runCli(['--fake', '--no-dashboard', '--kind', 'bug-fix'], io)
assert.equal(code, 0)
assert.ok(err.some(l => /--kind bug-fix has no effect without a preset/.test(l)))
})

test('chooseSessionLink defaults a live run to the claude.ai/code session list (#212)', () => {
assert.equal(chooseSessionLink({ sessionLink: undefined }, false), CLAUDE_CODE_SESSION_LIST)
assert.equal(CLAUDE_CODE_SESSION_LIST, 'https://claude.ai/code')
Expand Down
11 changes: 11 additions & 0 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ Options:
--technical Activate the preset's Technical mode variants.
(--preset / --autopilot / --technical can also be set per
repo in the-framework.yml; these flags override it.)
--kind <name> Build event kind the preset's review loop fires for, e.g.
bug-fix or major-change (default: the preset's own, else
major-change). Selects which review chain gates the run.
--compose-extensions Opt the built-in capability extensions in (auth, data,
rbac, crud, shell) so the agent composes them instead of
hand-rolling. Vike-only; installed framework-* extensions
Expand Down Expand Up @@ -131,6 +134,7 @@ export interface CliOptions {
preset?: string | undefined
autopilot: boolean
technical: boolean
buildEvent?: string | undefined
maxPasses?: number
deploy?: string | undefined
cfProject?: string | undefined
Expand Down Expand Up @@ -201,6 +205,9 @@ export function parseArgs(argv: string[]): CliOptions {
case '--technical':
opts.technical = true
break
case '--kind':
opts.buildEvent = argv[++i]
break
case '--resume':
opts.resume = true
break
Expand Down Expand Up @@ -425,6 +432,9 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
if (modes.length && !domainPreset) {
io.err(`note: ${modes.join(' + ')} mode(s) have no effect without a preset.`)
}
if (opts.buildEvent && !domainPreset) {
io.err(`note: --kind ${opts.buildEvent} has no effect without a preset.`)
}

// Fail early and clearly if a live run's prerequisites are missing.
if (!fake && !opts.skipPreflight) {
Expand Down Expand Up @@ -544,6 +554,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(discovered ? { extensions: discovered } : {}),
...(opts.composeExtensions ? { composeExtensions: true } : {}),
...(domainPreset ? { preset: domainPreset, ...(modes.length ? { modes } : {}) } : {}),
...(opts.buildEvent ? { buildEvent: opts.buildEvent } : {}),
...(memory.length ? { memory } : {}),
...((): { sessionLink?: string } => {
const link = chooseSessionLink(opts, fake)
Expand Down
98 changes: 97 additions & 1 deletion packages/framework/src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { defineDomainPreset, defineFrameworkExtension, defineLoop, definePersona
import type { Prompt } from '@gemstack/ai-autopilot'
import { DEFAULT_MAX_PASSES, runFramework } from './run.js'
import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js'
import { FakeDriver, type Driver } from './driver/index.js'
import { FakeDriver, type Driver, type DriverSession } from './driver/index.js'
import type { FrameworkEvent } from './events.js'

/** A driver that records the `system` framing it is started with, delegating the run to the fake. */
Expand Down Expand Up @@ -283,6 +283,102 @@ test('the domain review loop blocks, improve runs, then it clears (#252)', async
assert.equal(result.productionGrade, true)
})

/** A preset with both a major-change and a bug-fix loop, each running a sentinel-tagged prompt. */
function dualLoopPreset(opts: { defaultEvent?: string } = {}) {
const prompt = (id: string, sentinel: string): Prompt => ({
id,
name: id,
title: id,
description: '',
instructions: `${sentinel} — review and end with a { blockers } verdict.`,
passes: 1,
appliesTo: [],
})
return defineDomainPreset({
name: 'test-domain',
title: 'Test Domain',
...(opts.defaultEvent ? { defaultEvent: opts.defaultEvent } : {}),
loops: [
defineLoop({ on: 'major-change', run: ['major-review'] }),
defineLoop({ on: 'bug-fix', run: ['bug-review'] }),
],
prompts: [prompt('major-review', 'MAJOR-SENTINEL'), prompt('bug-review', 'BUGFIX-SENTINEL')],
skills: [],
})
}

/** A fake driver that records every prompt text it is sent, so a test can see which loop fired. */
function promptRecordingDriver(): { driver: Driver; prompts: () => string[] } {
const sent: string[] = []
const inner = new FakeDriver({
turns: [
{ text: '```json\n{"stack":"X","narration":"n","decisions":[]}\n```' }, // architect
{ text: 'Built the app.' }, // build
{ text: 'Reviewed.\n```json\n{"blockers":[]}\n```' }, // review, clean
],
sessionId: 'test',
})
const driver: Driver = {
name: 'fake',
start: async opts => {
const session = await inner.start(opts)
const wrapped: DriverSession = {
id: session.id,
cwd: session.cwd,
prompt: (text, o) => {
sent.push(text)
return session.prompt(text, o)
},
dispose: () => session.dispose(),
}
return wrapped
},
}
return { driver, prompts: () => sent }
}

test('a bug-fix build event fires the preset bug-fix loop, not major-change (#265)', async () => {
const events: FrameworkEvent[] = []
const { driver, prompts } = promptRecordingDriver()
await runFramework({
intent: FAKE_INTENT,
driver,
cwd: '/tmp/ws',
signals: FAKE_SIGNALS,
preset: dualLoopPreset(),
buildEvent: 'bug-fix',
onEvent: e => events.push(e),
})
assert.ok(events.some(e => e.kind === 'log' && /Test Domain loop drives the bug-fix review/.test(e.message)))
assert.ok(prompts().some(p => p.includes('BUGFIX-SENTINEL'))) // the bug-fix chain ran...
assert.ok(!prompts().some(p => p.includes('MAJOR-SENTINEL'))) // ...and the major-change chain did not
})

test('a preset defaultEvent selects the loop; an explicit buildEvent overrides it (#265)', async () => {
const byDefault = promptRecordingDriver()
await runFramework({
intent: FAKE_INTENT,
driver: byDefault.driver,
cwd: '/tmp/ws',
signals: FAKE_SIGNALS,
preset: dualLoopPreset({ defaultEvent: 'bug-fix' }),
onEvent: () => {},
})
assert.ok(byDefault.prompts().some(p => p.includes('BUGFIX-SENTINEL'))) // preset default alone reaches bug-fix

const overridden = promptRecordingDriver()
await runFramework({
intent: FAKE_INTENT,
driver: overridden.driver,
cwd: '/tmp/ws',
signals: FAKE_SIGNALS,
preset: dualLoopPreset({ defaultEvent: 'bug-fix' }),
buildEvent: 'major-change',
onEvent: () => {},
})
assert.ok(overridden.prompts().some(p => p.includes('MAJOR-SENTINEL'))) // run choice wins over the preset default
})

test('a registered extension auto-activates by its signal, no opt-in needed (#190)', async () => {
const { driver, system } = recordingDriver()
const audit = defineFrameworkExtension({
Expand Down
20 changes: 18 additions & 2 deletions packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ export interface RunFrameworkOptions {
* label shown to the user.
*/
modes?: readonly string[]
/**
* The loop event kind the review phase dispatches (#265) — this is what makes a
* run a bug fix vs a feature: `bug-fix` fires the preset's bug-fix loop, the
* default `major-change` fires its major-change loop. Overrides the preset's own
* `defaultEvent`. A kind the preset has no loop for falls back to the built-in
* checklist, so a run is never left unreviewed. No-op without a preset.
*/
buildEvent?: string
/**
* Opt the built-in capability extensions in (auth, data, rbac, crud, shell) so
* a from-scratch build is framed to compose them instead of hand-rolling
Expand Down Expand Up @@ -325,10 +333,18 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
// domain preset, its loop *replaces* the checklist (#252) — each pass fires the
// preset's review chain through the driver — falling back to the built-in when
// the preset has no loop for the build event, so a run is never left unreviewed.
// The build event kind: an explicit run choice wins, else the preset's own
// default, else `major-change`. This is how a `bug-fix` run reaches the preset's
// bug-fix loop (#265).
const buildEvent = opts.buildEvent ?? domainPreset?.defaultEvent ?? 'major-change'
const reviewChecklist = loop
? domainLoopChecklist(loop, { fallback: driverChecklist(session) })
? domainLoopChecklist(loop, { kind: buildEvent, fallback: driverChecklist(session) })
: driverChecklist(session)
if (loop) emit({ kind: 'log', message: `Review policy: the ${domainPreset!.title} loop drives the production-grade checks` })
if (loop)
emit({
kind: 'log',
message: `Review policy: the ${domainPreset!.title} loop drives the ${buildEvent} review`,
})

// Boot-and-serve gate: adopt the agent's workspace so the checklist can gate
// on the app actually running (mergeChecklists unions the review with a real
Expand Down
Loading