diff --git a/.changeset/framework-domain-preset-run.md b/.changeset/framework-domain-preset-run.md new file mode 100644 index 0000000..90d48ed --- /dev/null +++ b/.changeset/framework-domain-preset-run.md @@ -0,0 +1,14 @@ +--- +'@gemstack/framework': minor +--- + +Run a build under an Open Loop domain preset (#251). + +`runFramework({ preset, modes })` now accepts a user-picked domain preset +({loops, prompts, skills}). Its skills (and their personas) frame every phase of +the run alongside the detected framework skill, the selected domain and active +modes are narrated, and its loops + prompts are materialized into a driver-backed +`LoopEngine` exposed as `result.loop` (each pass is a fresh driver prompt). The +new `driverLoopPrompts` bridge does the materialization. Opt-in and additive: a +run with no preset is unchanged. Driving the exposed loop as a run phase is the +follow-up (#252). diff --git a/packages/framework/src/preset-run.test.ts b/packages/framework/src/preset-run.test.ts new file mode 100644 index 0000000..98b6dba --- /dev/null +++ b/packages/framework/src/preset-run.test.ts @@ -0,0 +1,87 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { defineDomainPreset, defineLoop, defineSkill } from '@gemstack/ai-autopilot' +import { runFramework } from './run.js' +import { FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' +import type { Driver } from './driver/index.js' +import type { FrameworkEvent } from './events.js' + +/** A driver that records the `system` framing it starts with, delegating the run to the fake. */ +function recordingDriver(): { driver: Driver; system: () => string } { + const fd = fakeDriver() + let captured = '' + const driver: Driver = { + name: 'fake', // keep workspace-verify off (no fs in this unit test) + start: opts => { + captured = opts.system ?? '' + return fd.start(opts) + }, + } + return { driver, system: () => captured } +} + +const domainPreset = defineDomainPreset({ + name: 'software-development', + title: 'Software Development', + description: 'General engineering.', + loops: [defineLoop({ on: 'major-change', run: ['code-review'] })], + prompts: [{ id: 'code-review', name: 'code-review', title: 'Code review', description: '', instructions: 'Review the change for correctness.', passes: 1, appliesTo: [] }], + skills: [defineSkill({ name: 'eng-practices', title: 'Engineering Practices', description: 'Code review guidelines.', url: 'https://google.github.io/eng-practices/' })], +}) + +test('a domain preset frames the run and is narrated', async () => { + const events: FrameworkEvent[] = [] + const { driver, system } = recordingDriver() + const { result } = await runFramework({ + intent: FAKE_INTENT, + driver, + cwd: '/tmp/ws', + signals: FAKE_SIGNALS, + preset: domainPreset, + modes: ['technical'], + onEvent: e => events.push(e), + }) + + // The preset's skill framed the session system prompt. + assert.match(system(), /Skill: Engineering Practices/) + assert.match(system(), /google\.github\.io\/eng-practices/) + + // It was narrated (title + active modes). + const log = events.find(e => e.kind === 'log' && /Domain preset: Software Development/.test(e.message)) + assert.ok(log, 'domain preset is logged') + assert.match((log as { message: string }).message, /modes: technical/) + + // The run still completes normally. + assert.equal(result.productionGrade, true) +}) + +test('the preset loop is materialized against the driver and runs its chain', async () => { + const { driver } = recordingDriver() + const { loop } = await runFramework({ + intent: FAKE_INTENT, + driver, + cwd: '/tmp/ws', + signals: FAKE_SIGNALS, + preset: domainPreset, + }) + + assert.ok(loop, 'result.loop is exposed when a preset is supplied') + + // Driving it dispatches the preset's chain through the (fake) driver, and each + // prompt returns the agent's text — proving the driver-backed bridge works. + const run = await loop!.handle({ kind: 'major-change', summary: 'reworked auth' }) + assert.equal(run.matched, true) + assert.deepEqual(run.outcomes.map(o => o.promptId), ['code-review']) + assert.equal(run.outcomes[0]!.ok, true) + assert.ok(run.outcomes[0]!.passes[0]!.text.length > 0) +}) + +test('no loop is exposed without a preset', async () => { + const { loop } = await runFramework({ + intent: FAKE_INTENT, + driver: fakeDriver(), + cwd: '/tmp/ws', + signals: FAKE_SIGNALS, + }) + assert.equal(loop, undefined) +}) diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index e4eb1d9..5fc4a7c 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -3,6 +3,7 @@ import { DecisionLedger, ExtensionRegistry, LocalRunner, + LoopEngine, SkillRegistry, builtinExtensionNames, builtinPresetRegistry, @@ -18,13 +19,14 @@ import { type BootstrapResult, type BootstrapScope, type DeployTarget, + type DomainPreset, type FrameworkDetection, type FrameworkExtension, type FrameworkSignals, type LocalRunnerSession, } from '@gemstack/ai-autopilot' import type { Driver, DriverEvent, DriverSession } from './driver/index.js' -import { decideDeploy, deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove } from './steps.js' +import { decideDeploy, deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove, driverLoopPrompts } from './steps.js' import { hasSessionIdPlaceholder, resolveSessionLink, type FrameworkEvent } from './events.js' /** @@ -83,6 +85,21 @@ export interface RunFrameworkOptions { model?: string /** Signals for preset detection (deps/files). Default: none, so the flagship preset wins. */ signals?: FrameworkSignals + /** + * A user-picked Open Loop domain preset ({loops, prompts, skills}) to run the + * build under (#251). Its skills (and their personas) frame every phase, and + * its loops + prompts are materialized into a driver-backed {@link LoopEngine} + * exposed as {@link RunFrameworkResult.loop}. Load it with `loadDomainPreset` / + * `softwareDevelopmentPreset` (pass `modes` there to activate variants). Omit + * for the framework-only run. + */ + preset?: DomainPreset + /** + * The active modes for {@link preset} (e.g. `['autopilot']`), for narration. + * The preset is expected to be loaded with these already applied; this is the + * label shown to the user. + */ + modes?: readonly 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 @@ -151,6 +168,13 @@ export interface RunFrameworkResult { * config was set or the app could not be booted. */ preview?: AppPreview + /** + * The domain preset's review policy, materialized against this run's driver: + * its loops plus its prompts as driver-backed passes. Present only when a + * {@link RunFrameworkOptions.preset} was supplied. Driving it (dispatching a + * `major-change` / `bug-fix` event) is left to the caller for now (#252). + */ + loop?: LoopEngine } /** @@ -197,9 +221,16 @@ export async function runFramework(opts: RunFrameworkOptions): Promise + definePrompt({ + id: prompt.id, + passes: prompt.passes, + run: async ctx => { + const instructions = promptInstructions(prompt, opts.ledger ? { ledger: opts.ledger } : {}) + const framing = opts.system ? `${opts.system}\n\n${instructions}` : instructions + const turn = await session.prompt(`${framing}\n\n${renderTask(ctx.event)}`, { + ...(opts.signal ? { signal: opts.signal } : {}), + }) + return turn.text + }, + }), + ) +} + /** * A minimal deploy step that only *decides* (does not ship). The real deploy * targets (cloudflareTarget / dokployTarget) live in ai-autopilot and are wired