From 83917735dc01f4b0c26cbd625c8ce50849f8786a Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 5 Jul 2026 20:21:51 +0300 Subject: [PATCH] feat(framework): fire the preset bug-fix loop via a build event kind (#265) Every domain preset shipped a bug-fix loop but domainLoopChecklist always dispatched major-change, so those loops never ran. A run now picks the build event kind (runFramework buildEvent / framework --kind), and a preset can declare its own default via preset.md metadata.event (DomainPreset.defaultEvent). Precedence: run choice > preset default > major-change; an unmatched kind still falls back to the built-in checklist. --- .changeset/bugfix-event-path.md | 6 ++ .../ai-autopilot/src/preset/compose.test.ts | 9 ++ packages/ai-autopilot/src/preset/compose.ts | 3 + .../ai-autopilot/src/preset/define.test.ts | 6 ++ packages/ai-autopilot/src/preset/define.ts | 3 + packages/ai-autopilot/src/preset/load.test.ts | 19 ++++ packages/ai-autopilot/src/preset/load.ts | 2 + packages/ai-autopilot/src/preset/types.ts | 6 ++ packages/framework/src/cli.test.ts | 12 +++ packages/framework/src/cli.ts | 11 +++ packages/framework/src/run.test.ts | 98 ++++++++++++++++++- packages/framework/src/run.ts | 20 +++- 12 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 .changeset/bugfix-event-path.md diff --git a/.changeset/bugfix-event-path.md b/.changeset/bugfix-event-path.md new file mode 100644 index 0000000..5504542 --- /dev/null +++ b/.changeset/bugfix-event-path.md @@ -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 ` 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. diff --git a/packages/ai-autopilot/src/preset/compose.test.ts b/packages/ai-autopilot/src/preset/compose.test.ts index 08f5225..c7fe0e3 100644 --- a/packages/ai-autopilot/src/preset/compose.test.ts +++ b/packages/ai-autopilot/src/preset/compose.test.ts @@ -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', () => { diff --git a/packages/ai-autopilot/src/preset/compose.ts b/packages/ai-autopilot/src/preset/compose.ts index 60bc7c3..9d6beaa 100644 --- a/packages/ai-autopilot/src/preset/compose.ts +++ b/packages/ai-autopilot/src/preset/compose.ts @@ -19,6 +19,8 @@ export function composeDomainPresets(meta: DomainPresetMeta, ...presets: DomainP const prompts = new Map() const skills = new Map() const loops = presets.flatMap(p => [...p.loops]) + // Carry the default build event kind; a later preset that declares one wins. + const defaultEvent = presets.reduce((acc, p) => p.defaultEvent ?? acc, undefined) for (const preset of presets) { for (const prompt of preset.prompts) prompts.set(prompt.id, prompt) @@ -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)), diff --git a/packages/ai-autopilot/src/preset/define.test.ts b/packages/ai-autopilot/src/preset/define.test.ts index d32f6f0..166f69d 100644 --- a/packages/ai-autopilot/src/preset/define.test.ts +++ b/packages/ai-autopilot/src/preset/define.test.ts @@ -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) + }) }) diff --git a/packages/ai-autopilot/src/preset/define.ts b/packages/ai-autopilot/src/preset/define.ts index 812208b..d9369b9 100644 --- a/packages/ai-autopilot/src/preset/define.ts +++ b/packages/ai-autopilot/src/preset/define.ts @@ -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 ?? [])]), diff --git a/packages/ai-autopilot/src/preset/load.test.ts b/packages/ai-autopilot/src/preset/load.test.ts index 4c9301b..28120f1 100644 --- a/packages/ai-autopilot/src/preset/load.test.ts +++ b/packages/ai-autopilot/src/preset/load.test.ts @@ -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 }) + } + }) +}) diff --git a/packages/ai-autopilot/src/preset/load.ts b/packages/ai-autopilot/src/preset/load.ts index 11a70b0..3604071 100644 --- a/packages/ai-autopilot/src/preset/load.ts +++ b/packages/ai-autopilot/src/preset/load.ts @@ -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 }), @@ -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, diff --git a/packages/ai-autopilot/src/preset/types.ts b/packages/ai-autopilot/src/preset/types.ts index cb3ba1e..d078e82 100644 --- a/packages/ai-autopilot/src/preset/types.ts +++ b/packages/ai-autopilot/src/preset/types.ts @@ -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. */ @@ -42,6 +47,7 @@ export interface DomainPresetSpec { name: string title?: string description?: string + defaultEvent?: string loops?: readonly Loop[] prompts?: readonly Prompt[] skills?: readonly Skill[] diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index 8127d9e..c5b9679 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -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']) @@ -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') diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 0c9ecee..ca9fce2 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -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 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 @@ -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 @@ -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 @@ -425,6 +432,9 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { const link = chooseSessionLink(opts, fake) diff --git a/packages/framework/src/run.test.ts b/packages/framework/src/run.test.ts index 6ae8256..40bdda0 100644 --- a/packages/framework/src/run.test.ts +++ b/packages/framework/src/run.test.ts @@ -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. */ @@ -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({ diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 6bb38d0..8857a46 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -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 @@ -325,10 +333,18 @@ export async function runFramework(opts: RunFrameworkOptions): Promise