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/framework-domain-preset-run.md
Original file line number Diff line number Diff line change
@@ -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).
87 changes: 87 additions & 0 deletions packages/framework/src/preset-run.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
58 changes: 55 additions & 3 deletions packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DecisionLedger,
ExtensionRegistry,
LocalRunner,
LoopEngine,
SkillRegistry,
builtinExtensionNames,
builtinPresetRegistry,
Expand All @@ -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'

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -197,9 +221,16 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
const activeExtensions = extensionRegistry.match(signals, {
include: optInBuiltins ? builtinExtensionNames : [],
})
// A user-picked domain preset (#251) frames the whole run: its skills (and the
// personas they carry) compose in alongside the detected framework's skill.
const domainPreset = opts.preset
const matchedSkills = new SkillRegistry().match(signals)
const skills = composeSkills({
matched: preset.skill ? [preset.skill, ...matchedSkills] : matchedSkills,
matched: [
...(preset.skill ? [preset.skill] : []),
...matchedSkills,
...(domainPreset ? domainPreset.skills : []),
],
extensions: activeExtensions,
})
const personas = composePersonas({
Expand Down Expand Up @@ -228,6 +259,13 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
kind: 'log',
message: `Detected ${detection.framework ?? preset.framework} (confidence ${detection.confidence}); framing with ${personas.length} persona(s)${extensionNote}${skillNote}`,
})
if (domainPreset) {
const modeNote = opts.modes?.length ? ` (modes: ${opts.modes.join(', ')})` : ''
emit({
kind: 'log',
message: `Domain preset: ${domainPreset.title}${modeNote}; ${domainPreset.loops.length}-loop review policy available`,
})
}

// Watch the black box for its real session id (the {type:'result'} event) and
// surface it as `session-update` once known — that is the honest handle a UI
Expand Down Expand Up @@ -282,6 +320,20 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
const workspaceOpt = verifyWorkspace ? { verifyWorkspace: true } : {}

const ledger = new DecisionLedger()

// Materialize the domain preset's review policy against this run's driver: its
// loops, with its prompts as driver-backed passes sharing the run's ledger and
// abort signal. Exposed on the result; who drives it is the follow-up (#252).
const loop = domainPreset
? new LoopEngine({
loops: [...domainPreset.loops],
prompts: driverLoopPrompts(session, domainPreset.prompts, {
ledger,
...(opts.signal ? { signal: opts.signal } : {}),
}),
})
: undefined

let preview: AppPreview | undefined
try {
const bootstrap = new Bootstrap({
Expand Down Expand Up @@ -312,7 +364,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
// process a caller that ignores `preview` would never stop.
if (runner && s?.keepAlive) preview = await startAppPreview(runner, s, emit)
emit({ kind: 'end', ok: true })
return { result, detection, events, ledger, ...(preview ? { preview } : {}) }
return { result, detection, events, ledger, ...(preview ? { preview } : {}), ...(loop ? { loop } : {}) }
} catch (err) {
// A user interrupt (the dashboard Stop button / Ctrl+C aborts the signal) is a
// clean stop, not a failure — mark it so surfaces show "stopped".
Expand Down
33 changes: 32 additions & 1 deletion packages/framework/src/steps.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
import { parseVerdict, STACK_TRADEOFFS } from '@gemstack/ai-autopilot'
import { definePrompt, parseVerdict, promptInstructions, renderTask, STACK_TRADEOFFS } from '@gemstack/ai-autopilot'
import type {
ArchitectAlternative,
ArchitectContext,
ArchitectDecision,
ArchitectPlan,
BuildContext,
DecisionLedger,
DeployContext,
DeployOutcome,
DeployTarget,
LoopPassContext,
LoopPrompt,
PlannedSubtask,
Prompt,
SubtaskResult,
SupervisorRun,
Verdict,
Expand Down Expand Up @@ -304,6 +307,34 @@ export function driverImprove(
}
}

/**
* Materialize a domain preset's {@link Prompt} bodies into driver-backed
* {@link LoopPrompt}s so its loops can run through the wrapped agent. Each pass is
* one fresh {@link DriverSession.prompt} call (the driver's fresh-context unit),
* prompted with the composed instructions (body + decisions briefing) and the
* rendered {@link renderTask | loop event}, returning the agent's text.
*/
export function driverLoopPrompts(
session: DriverSession,
prompts: readonly Prompt[],
opts: { ledger?: DecisionLedger; signal?: AbortSignal } & DriverStepOptions = {},
): LoopPrompt[] {
return prompts.map(prompt =>
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
Expand Down
Loading