From a91f4c156868a614a6fab4fe3456c1090b3c6ac5 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 5 Jul 2026 16:39:55 +0300 Subject: [PATCH 1/2] feat(ai-autopilot): rename loop engine rules vocabulary to loops A loop is a meta prompt, so it is the user-facing unit. defineLoop / defaultLoops / Loop / LoopSpec replace defineRule / defaultLoopRules / LoopRule / LoopRuleSpec; the engine is LoopEngine (createLoopEngine, LoopEngineOptions); its option key is loops. Vocabulary only. Closes #241 --- .changeset/open-loop-rename-rules-to-loops.md | 7 ++ packages/ai-autopilot/README.md | 22 +++--- .../ai-autopilot/src/bootstrap/steps.test.ts | 24 +++---- packages/ai-autopilot/src/bootstrap/steps.ts | 6 +- packages/ai-autopilot/src/index.ts | 20 +++--- packages/ai-autopilot/src/loop/define.test.ts | 12 ++-- packages/ai-autopilot/src/loop/define.ts | 12 ++-- packages/ai-autopilot/src/loop/index.ts | 16 ++--- packages/ai-autopilot/src/loop/loop.test.ts | 72 +++++++++---------- packages/ai-autopilot/src/loop/loop.ts | 44 ++++++------ packages/ai-autopilot/src/loop/policy.test.ts | 12 ++-- packages/ai-autopilot/src/loop/policy.ts | 14 ++-- packages/ai-autopilot/src/loop/types.ts | 24 +++---- .../ai-autopilot/src/overview/agent.test.ts | 12 ++-- packages/ai-autopilot/src/overview/agent.ts | 2 +- .../ai-autopilot/src/prompts/bridge.test.ts | 10 +-- packages/ai-autopilot/src/prompts/bridge.ts | 4 +- packages/ai-autopilot/src/prompts/index.ts | 2 +- .../ai-autopilot/src/prompts/library.test.ts | 4 +- 19 files changed, 163 insertions(+), 156 deletions(-) create mode 100644 .changeset/open-loop-rename-rules-to-loops.md diff --git a/.changeset/open-loop-rename-rules-to-loops.md b/.changeset/open-loop-rename-rules-to-loops.md new file mode 100644 index 0000000..8335c05 --- /dev/null +++ b/.changeset/open-loop-rename-rules-to-loops.md @@ -0,0 +1,7 @@ +--- +'@gemstack/ai-autopilot': minor +--- + +Rename the loop engine's `rules` vocabulary to `loops` (Open Loop, #241). + +A loop is a meta prompt, so that is the user-facing unit even though rule logic powers it. `defineLoop` / `defaultLoops` / `Loop` / `LoopSpec` replace `defineRule` / `defaultLoopRules` / `LoopRule` / `LoopRuleSpec`; the engine class is now `LoopEngine` (was `Loop`), created via `createLoopEngine` with `LoopEngineOptions`; and its option key is `loops` (was `rules`). Vocabulary only, no behavior change. diff --git a/packages/ai-autopilot/README.md b/packages/ai-autopilot/README.md index f29c8fa..c5bc1a5 100644 --- a/packages/ai-autopilot/README.md +++ b/packages/ai-autopilot/README.md @@ -230,14 +230,14 @@ runs QA + UX. Semantic (a *kind* of change picks a *set* of prompts), not command-driven and not run-on-every-PR. ```ts -import { Loop, definePrompt, defaultLoopRules } from '@gemstack/ai-autopilot' +import { LoopEngine, definePrompt, defaultLoops } from '@gemstack/ai-autopilot' -const loop = new Loop({ - rules: defaultLoopRules(), // major-change → [review, code-quality, security]; ui-flow → [qa, ux] +const loop = new LoopEngine({ + loops: defaultLoops(), // major-change → [review, code-quality, security]; ui-flow → [qa, ux] prompts: [ definePrompt({ id: 'review', passes: 2, run: ctx => runReview(ctx.event) }), definePrompt({ id: 'code-quality', run: ctx => runQuality(ctx.event) }), - // ...register a prompt per id the rules reference + // ...register a prompt per id the loops reference ], ledger, // optional: exposed to each prompt via ctx.ledger (#112) onEvent: e => log(e), // observe match / pass / done (observer-isolated) @@ -260,10 +260,10 @@ Design choices for the two open questions: Each prompt runs for its `passes` with **fresh context every pass** (Rom's finding: re-running the same prompt with a reset context improves the result), so -`run` is expected to build a new agent per call. `defaultLoopRules()` is the -built-in policy as data; extend it by concatenating your own `defineRule` results. +`run` is expected to build a new agent per call. `defaultLoops()` is the +built-in policy as data; extend it by concatenating your own `defineLoop` results. The prompt bodies themselves are the prompts library (#111), registered under the -ids the rules reference. +ids the loops reference. ## Built-in prompts — the loop's bodies, shipped as data @@ -273,16 +273,16 @@ They ship ready to use and already know the stack (Vike + universal-orm): review template. Each is a markdown file under `prompts/`, so a contributor improves the prompt by editing prose, not code. -`loopPromptsFor` materializes a library into loop prompts, so `defaultLoopRules()` +`loopPromptsFor` materializes a library into loop prompts, so `defaultLoops()` ids resolve to real bodies — this is the turnkey wire: ```ts import { agent } from '@gemstack/ai-sdk' -import { Loop, defaultLoopRules, builtinLibrary, loopPromptsFor, runnerTools } from '@gemstack/ai-autopilot' +import { LoopEngine, defaultLoops, builtinLibrary, loopPromptsFor, runnerTools } from '@gemstack/ai-autopilot' const library = await builtinLibrary() // the shipped bodies -const loop = new Loop({ - rules: defaultLoopRules(), // review / code-quality / security / qa / ux ids +const loop = new LoopEngine({ + loops: defaultLoops(), // review / code-quality / security / qa / ux ids prompts: loopPromptsFor(library, ctx => // a FRESH agent per pass agent({ instructions: ctx.instructions, tools: runnerTools(session) })), ledger, // ctx.instructions already includes the decisions briefing diff --git a/packages/ai-autopilot/src/bootstrap/steps.test.ts b/packages/ai-autopilot/src/bootstrap/steps.test.ts index d3cfef6..92cd08e 100644 --- a/packages/ai-autopilot/src/bootstrap/steps.test.ts +++ b/packages/ai-autopilot/src/bootstrap/steps.test.ts @@ -5,8 +5,8 @@ import { agentArchitect, supervisorBuild, loopChecklist, loopImprove } from './s import { agentDeploy, FakeDeployTarget } from './deploy.js' import { Bootstrap } from './bootstrap.js' import { DecisionLedger } from '../decisions/ledger.js' -import { Loop } from '../loop/loop.js' -import { definePrompt, defineRule } from '../loop/define.js' +import { LoopEngine } from '../loop/loop.js' +import { definePrompt, defineLoop } from '../loop/define.js' import type { BootstrapEvent } from './types.js' import type { SupervisorEvent } from '../types.js' import type { ArchitectContext, BuildContext, LoopPassContext } from './types.js' @@ -139,8 +139,8 @@ describe('loopChecklist / loopImprove (default full-fledged loop steps)', () => }) it('reads the { blockers } verdict the production-grade prompt returns', async () => { - const loop = new Loop({ - rules: [defineRule({ on: 'production-check', run: ['production-grade'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'production-check', run: ['production-grade'] })], prompts: [definePrompt({ id: 'production-grade', run: () => '```json\n{ "blockers": ["no auth"] }\n```' })], }) const verdict = await loopChecklist({ loop })(passCtx()) @@ -148,8 +148,8 @@ describe('loopChecklist / loopImprove (default full-fledged loop steps)', () => }) it('treats a missing verdict as a blocker', async () => { - const loop = new Loop({ - rules: [defineRule({ on: 'production-check', run: ['production-grade'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'production-check', run: ['production-grade'] })], prompts: [definePrompt({ id: 'production-grade', run: () => 'no verdict here' })], }) const verdict = await loopChecklist({ loop })(passCtx()) @@ -159,8 +159,8 @@ describe('loopChecklist / loopImprove (default full-fledged loop steps)', () => it('fires the change events so the review chain runs', async () => { let reviewRan = 0 - const loop = new Loop({ - rules: [defineRule({ on: 'major-change', run: ['review'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'major-change', run: ['review'] })], prompts: [definePrompt({ id: 'review', run: () => { reviewRan++; return 'reviewed' } })], }) await loopImprove({ loop })(passCtx({ blockers: ['no auth'] })) @@ -189,10 +189,10 @@ describe('Bootstrap end-to-end with the default steps (offline)', () => { const verdicts = ['```json\n{ "blockers": ["no auth"] }\n```', '```json\n{ "blockers": [] }\n```'] let checked = 0 let improved = 0 - const loop = new Loop({ - rules: [ - defineRule({ on: 'production-check', run: ['production-grade'] }), - defineRule({ on: 'major-change', run: ['fix'] }), + const loop = new LoopEngine({ + loops: [ + defineLoop({ on: 'production-check', run: ['production-grade'] }), + defineLoop({ on: 'major-change', run: ['fix'] }), ], prompts: [ definePrompt({ id: 'production-grade', run: () => verdicts[checked++] ?? '```json\n{ "blockers": [] }\n```' }), diff --git a/packages/ai-autopilot/src/bootstrap/steps.ts b/packages/ai-autopilot/src/bootstrap/steps.ts index f95f82d..8a37b02 100644 --- a/packages/ai-autopilot/src/bootstrap/steps.ts +++ b/packages/ai-autopilot/src/bootstrap/steps.ts @@ -3,7 +3,7 @@ import type { Agent } from '@gemstack/ai-sdk' import { z } from 'zod' import { Supervisor } from '../supervisor.js' import { decisionBriefing } from '../decisions/tools.js' -import { Loop } from '../loop/loop.js' +import { LoopEngine } from '../loop/loop.js' import { LOOP_EVENTS, LOOP_PROMPTS } from '../loop/policy.js' import type { Planner, Synthesizer, SupervisorOptions } from '../types.js' import type { Verdict } from '../loop/verdict.js' @@ -11,7 +11,7 @@ import type { BootstrapSteps, BuildContext } from './types.js' /** * Default wirings of the four bootstrap steps onto the real primitives — - * Supervisor, personas, and the Loop. Each is thin (it constructs a primitive + * Supervisor, personas, and the LoopEngine. Each is thin (it constructs a primitive * and adapts its I/O to the step contract), so the same orchestrator runs * against these in production or against stubs in a test. The model + runner stay * injected: you pass the architect agent, the planner/workers, and the loop. @@ -137,7 +137,7 @@ export function supervisorBuild(opts: SupervisorBuildOptions): BootstrapSteps['b /** Options for {@link loopChecklist} and {@link loopImprove}. */ export interface LoopStepOptions { /** The loop that resolves the prompt ids to bodies (via `loopPromptsFor`). */ - loop: Loop + loop: LoopEngine } /** Options for {@link loopChecklist}. */ diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index af32821..157c772 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -50,9 +50,9 @@ * change (a {@link LoopEvent}) and the right follow-up prompts fire — a major * change runs review + code-quality + security, a new UI flow runs QA + UX. * - * - {@link Loop} — match an event to a prompt chain and run it (N fresh passes) - * - {@link definePrompt} / {@link defineRule} — author prompts and policy rules - * - {@link defaultLoopRules} — the built-in web-app policy + * - {@link LoopEngine} — match an event to a prompt chain and run it (N fresh passes) + * - {@link definePrompt} / {@link defineLoop} — author prompts and policy loops + * - {@link defaultLoops} — the built-in web-app policy * - {@link parseVerdict} / {@link isPassing} — the `{ blockers }` verdict the loop * gates on, so it stops on a review's *outcome*, not just execution * @@ -188,23 +188,23 @@ export { } from './decisions/index.js' export { definePrompt, - defineRule, + defineLoop, LoopError, - Loop, - createLoop, - defaultLoopRules, + LoopEngine, + createLoopEngine, + defaultLoops, LOOP_EVENTS, LOOP_PROMPTS, parseVerdict, isPassing, type Verdict, - type LoopOptions, + type LoopEngineOptions, type LoopEvent, type LoopContext, type LoopPrompt, type LoopPromptSpec, - type LoopRule, - type LoopRuleSpec, + type Loop, + type LoopSpec, type PassResult, type PromptOutcome, type LoopRunResult, diff --git a/packages/ai-autopilot/src/loop/define.test.ts b/packages/ai-autopilot/src/loop/define.test.ts index 8275672..0a04d35 100644 --- a/packages/ai-autopilot/src/loop/define.test.ts +++ b/packages/ai-autopilot/src/loop/define.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { definePrompt, defineRule, LoopError } from './define.js' +import { definePrompt, defineLoop, LoopError } from './define.js' describe('definePrompt', () => { it('defaults passes to 1 and freezes', () => { @@ -17,17 +17,17 @@ describe('definePrompt', () => { }) }) -describe('defineRule', () => { +describe('defineLoop', () => { it('normalizes a single `on` to a de-duped array', () => { - const r = defineRule({ on: 'major-change', run: ['review', 'security'] }) + const r = defineLoop({ on: 'major-change', run: ['review', 'security'] }) assert.deepEqual(r.on, ['major-change']) assert.deepEqual(r.run, ['review', 'security']) assert.ok(Object.isFrozen(r)) }) it('de-dupes kinds and requires non-empty on/run', () => { - assert.deepEqual(defineRule({ on: ['a', 'a', 'b'], run: ['x'] }).on, ['a', 'b']) - assert.throws(() => defineRule({ on: [], run: ['x'] }), LoopError) - assert.throws(() => defineRule({ on: 'a', run: [] }), LoopError) + assert.deepEqual(defineLoop({ on: ['a', 'a', 'b'], run: ['x'] }).on, ['a', 'b']) + assert.throws(() => defineLoop({ on: [], run: ['x'] }), LoopError) + assert.throws(() => defineLoop({ on: 'a', run: [] }), LoopError) }) }) diff --git a/packages/ai-autopilot/src/loop/define.ts b/packages/ai-autopilot/src/loop/define.ts index 086efcd..160ffae 100644 --- a/packages/ai-autopilot/src/loop/define.ts +++ b/packages/ai-autopilot/src/loop/define.ts @@ -1,8 +1,8 @@ -import type { LoopPrompt, LoopPromptSpec, LoopRule, LoopRuleSpec } from './types.js' +import type { LoopPrompt, LoopPromptSpec, Loop, LoopSpec } from './types.js' const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ -/** Thrown when a loop prompt or rule is malformed. Fails fast at definition. */ +/** Thrown when a prompt or loop is malformed. Fails fast at definition. */ export class LoopError extends Error { constructor(message: string) { super(`[ai-autopilot] ${message}`) @@ -29,15 +29,15 @@ export function definePrompt(spec: LoopPromptSpec): LoopPrompt { } /** - * Validate a {@link LoopRuleSpec} and return a frozen {@link LoopRule}. `on` is + * Validate a {@link LoopSpec} and return a frozen {@link Loop}. `on` is * normalized to a de-duped list of event kinds; `run` is the ordered prompt ids. */ -export function defineRule(spec: LoopRuleSpec): LoopRule { +export function defineLoop(spec: LoopSpec): Loop { const kinds = (Array.isArray(spec.on) ? spec.on : [spec.on]).map(k => k?.trim()).filter(Boolean) - if (kinds.length === 0) throw new LoopError('rule `on` needs at least one event kind') + if (kinds.length === 0) throw new LoopError('loop `on` needs at least one event kind') const run = (spec.run ?? []).map(p => p?.trim()).filter(Boolean) - if (run.length === 0) throw new LoopError(`rule on [${kinds.join(', ')}] needs at least one prompt in \`run\``) + if (run.length === 0) throw new LoopError(`loop on [${kinds.join(', ')}] needs at least one prompt in \`run\``) return Object.freeze({ on: Object.freeze([...new Set(kinds)]), diff --git a/packages/ai-autopilot/src/loop/index.ts b/packages/ai-autopilot/src/loop/index.ts index 846d4c5..19884c9 100644 --- a/packages/ai-autopilot/src/loop/index.ts +++ b/packages/ai-autopilot/src/loop/index.ts @@ -2,21 +2,21 @@ * The loop — the event-to-prompt-chain policy of `@gemstack/ai-autopilot`. * * The agent declares a {@link LoopEvent} (a semantic change kind); a - * {@link LoopRule} maps that kind to an ordered chain of {@link LoopPrompt}s; - * the {@link Loop} runs them (N fresh-context passes each) and consults the - * decisions ledger. {@link defaultLoopRules} is the built-in web-app policy. + * {@link Loop} maps that kind to an ordered chain of {@link LoopPrompt}s; + * the {@link LoopEngine} runs them (N fresh-context passes each) and consults the + * decisions ledger. {@link defaultLoops} is the built-in web-app policy. */ -export { definePrompt, defineRule, LoopError } from './define.js' -export { Loop, createLoop, type LoopOptions } from './loop.js' -export { defaultLoopRules, LOOP_EVENTS, LOOP_PROMPTS } from './policy.js' +export { definePrompt, defineLoop, LoopError } from './define.js' +export { LoopEngine, createLoopEngine, type LoopEngineOptions } from './loop.js' +export { defaultLoops, LOOP_EVENTS, LOOP_PROMPTS } from './policy.js' export { parseVerdict, isPassing, type Verdict } from './verdict.js' export type { LoopEvent, LoopContext, LoopPrompt, LoopPromptSpec, - LoopRule, - LoopRuleSpec, + Loop, + LoopSpec, PassResult, PromptOutcome, LoopRunResult, diff --git a/packages/ai-autopilot/src/loop/loop.test.ts b/packages/ai-autopilot/src/loop/loop.test.ts index 2be42b6..968e664 100644 --- a/packages/ai-autopilot/src/loop/loop.test.ts +++ b/packages/ai-autopilot/src/loop/loop.test.ts @@ -1,8 +1,8 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { Loop, createLoop } from './loop.js' -import { definePrompt, defineRule } from './define.js' -import { defaultLoopRules } from './policy.js' +import { LoopEngine, createLoopEngine } from './loop.js' +import { definePrompt, defineLoop } from './define.js' +import { defaultLoops } from './policy.js' import { DecisionLedger } from '../decisions/ledger.js' import type { LoopContext, LoopProgress } from './types.js' @@ -20,31 +20,31 @@ function spyPrompt(id: string, passes?: number) { return { prompt, calls } } -describe('Loop — matching', () => { - const loop = new Loop({ rules: defaultLoopRules(), prompts: [] }) +describe('LoopEngine — matching', () => { + const loop = new LoopEngine({ loops: defaultLoops(), prompts: [] }) - it('resolves the chain for a kind, in order and de-duped across rules', () => { + it('resolves the chain for a kind, in order and de-duped across loops', () => { assert.deepEqual(loop.matches({ kind: 'major-change' }), ['review', 'code-quality', 'security']) assert.deepEqual(loop.matches({ kind: 'ui-flow' }), ['qa', 'ux']) assert.deepEqual(loop.matches({ kind: 'nothing' }), []) }) - it('concatenates and de-dupes when two rules match the same kind', () => { - const l = new Loop({ - rules: [defineRule({ on: 'x', run: ['a', 'b'] }), defineRule({ on: 'x', run: ['b', 'c'] })], + it('concatenates and de-dupes when two loops match the same kind', () => { + const l = new LoopEngine({ + loops: [defineLoop({ on: 'x', run: ['a', 'b'] }), defineLoop({ on: 'x', run: ['b', 'c'] })], prompts: [], }) assert.deepEqual(l.matches({ kind: 'x' }), ['a', 'b', 'c']) }) }) -describe('Loop — dispatch', () => { +describe('LoopEngine — dispatch', () => { it('runs the matched chain in order and reports outcomes', async () => { const review = spyPrompt('review') const security = spyPrompt('security') const order: string[] = [] - const loop = new Loop({ - rules: [defineRule({ on: 'major-change', run: ['review', 'security'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'major-change', run: ['review', 'security'] })], prompts: [ definePrompt({ id: 'review', run: () => { order.push('review'); return 'r' } }), definePrompt({ id: 'security', run: () => { order.push('security'); return 's' } }), @@ -58,9 +58,9 @@ describe('Loop — dispatch', () => { void review; void security }) - it('reports matched:false and runs nothing when no rule fires', async () => { + it('reports matched:false and runs nothing when no loop fires', async () => { const events: LoopProgress[] = [] - const loop = createLoop({ rules: defaultLoopRules(), prompts: [], onEvent: e => events.push(e) }) + const loop = createLoopEngine({ loops: defaultLoops(), prompts: [], onEvent: e => events.push(e) }) const result = await loop.handle({ kind: 'unrelated' }) assert.equal(result.matched, false) assert.deepEqual(result.outcomes, []) @@ -69,17 +69,17 @@ describe('Loop — dispatch', () => { it('runs N fresh-context passes, each with an incrementing pass number', async () => { const { prompt, calls } = spyPrompt('review', 3) - const loop = new Loop({ rules: [defineRule({ on: 'c', run: ['review'] })], prompts: [prompt] }) + const loop = new LoopEngine({ loops: [defineLoop({ on: 'c', run: ['review'] })], prompts: [prompt] }) const result = await loop.handle({ kind: 'c' }) assert.deepEqual(calls.map(c => c.pass), [1, 2, 3]) assert.ok(calls.every(c => c.passes === 3)) assert.deepEqual(result.outcomes[0]?.passes.map(p => p.text), ['review#1', 'review#2', 'review#3']) }) - it('flags a rule that references an unknown prompt without throwing', async () => { + it('flags a loop that references an unknown prompt without throwing', async () => { const events: LoopProgress[] = [] - const loop = new Loop({ - rules: [defineRule({ on: 'c', run: ['ghost'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'c', run: ['ghost'] })], prompts: [], onEvent: e => events.push(e), }) @@ -89,13 +89,13 @@ describe('Loop — dispatch', () => { }) }) -describe('Loop — failure policy', () => { +describe('LoopEngine — failure policy', () => { const failing = definePrompt({ id: 'review', run: () => { throw new Error('boom') } }) const after = definePrompt({ id: 'security', run: () => 'ran' }) - const rules = [defineRule({ on: 'major-change', run: ['review', 'security'] })] + const loops = [defineLoop({ on: 'major-change', run: ['review', 'security'] })] it('continues past a failure by default (fire-and-report)', async () => { - const loop = new Loop({ rules, prompts: [failing, after] }) + const loop = new LoopEngine({ loops, prompts: [failing, after] }) const result = await loop.handle({ kind: 'major-change' }) assert.equal(result.outcomes[0]?.ok, false) assert.equal(result.outcomes[1]?.ok, true) // security still ran @@ -104,20 +104,20 @@ describe('Loop — failure policy', () => { it('stops the chain on failure when continueOnError is false (gate)', async () => { const events: LoopProgress[] = [] - const loop = new Loop({ rules, prompts: [failing, after], continueOnError: false, onEvent: e => events.push(e) }) + const loop = new LoopEngine({ loops, prompts: [failing, after], continueOnError: false, onEvent: e => events.push(e) }) const result = await loop.handle({ kind: 'major-change' }) assert.equal(result.outcomes.length, 1) // security was gated out assert.ok(events.some(e => e.type === 'gate-stop' && e.promptId === 'review')) }) }) -describe('Loop — verdict gating', () => { - const rules = [defineRule({ on: 'check', run: ['production-grade', 'after'] })] +describe('LoopEngine — verdict gating', () => { + const loops = [defineLoop({ on: 'check', run: ['production-grade', 'after'] })] const after = definePrompt({ id: 'after', run: () => 'ran' }) it('parses a verdict onto the outcome and marks blockers not-passing', async () => { const gate = definePrompt({ id: 'production-grade', run: () => '```json\n{ "blockers": ["no auth"] }\n```' }) - const loop = new Loop({ rules, prompts: [gate, after] }) + const loop = new LoopEngine({ loops, prompts: [gate, after] }) const result = await loop.handle({ kind: 'check' }) const outcome = result.outcomes[0]! assert.equal(outcome.ok, true) // it executed @@ -127,7 +127,7 @@ describe('Loop — verdict gating', () => { it('an empty blockers verdict is passing', async () => { const gate = definePrompt({ id: 'production-grade', run: () => '```json\n{ "blockers": [] }\n```' }) - const loop = new Loop({ rules, prompts: [gate, after] }) + const loop = new LoopEngine({ loops, prompts: [gate, after] }) const result = await loop.handle({ kind: 'check' }) assert.equal(result.outcomes[0]?.passing, true) }) @@ -135,7 +135,7 @@ describe('Loop — verdict gating', () => { it('gates the chain on blockers when continueOnError is false', async () => { const events: LoopProgress[] = [] const gate = definePrompt({ id: 'production-grade', run: () => '```json\n{ "blockers": ["no tests"] }\n```' }) - const loop = new Loop({ rules, prompts: [gate, after], continueOnError: false, onEvent: e => events.push(e) }) + const loop = new LoopEngine({ loops, prompts: [gate, after], continueOnError: false, onEvent: e => events.push(e) }) const result = await loop.handle({ kind: 'check' }) assert.equal(result.outcomes.length, 1) // `after` was gated out on the verdict, not an error assert.ok(events.some(e => e.type === 'gate-stop' && e.promptId === 'production-grade')) @@ -143,7 +143,7 @@ describe('Loop — verdict gating', () => { it('a prompt with no verdict still passes when it executes (backward compatible)', async () => { const gate = definePrompt({ id: 'production-grade', run: () => 'no json here' }) - const loop = new Loop({ rules, prompts: [gate, after] }) + const loop = new LoopEngine({ loops, prompts: [gate, after] }) const result = await loop.handle({ kind: 'check' }) assert.equal(result.outcomes[0]?.passing, true) assert.equal(result.outcomes[0]?.verdict, undefined) @@ -151,20 +151,20 @@ describe('Loop — verdict gating', () => { it('verdict: null disables parsing (execution-only gate)', async () => { const gate = definePrompt({ id: 'production-grade', run: () => '```json\n{ "blockers": ["x"] }\n```' }) - const loop = new Loop({ rules, prompts: [gate, after], verdict: null }) + const loop = new LoopEngine({ loops, prompts: [gate, after], verdict: null }) const result = await loop.handle({ kind: 'check' }) assert.equal(result.outcomes[0]?.passing, true) // blockers ignored assert.equal(result.outcomes[0]?.verdict, undefined) }) }) -describe('Loop — decisions + watch', () => { +describe('LoopEngine — decisions + watch', () => { it('exposes the ledger to prompts via context', async () => { const ledger = new DecisionLedger() ledger.reject('Use Redux', 'boilerplate') let seen: DecisionLedger | undefined - const loop = new Loop({ - rules: [defineRule({ on: 'c', run: ['review'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'c', run: ['review'] })], prompts: [definePrompt({ id: 'review', run: ctx => { seen = ctx.ledger; return '' } })], ledger, }) @@ -173,8 +173,8 @@ describe('Loop — decisions + watch', () => { }) it('watch() handles a stream of events in order', async () => { - const loop = new Loop({ - rules: [defineRule({ on: 'c', run: ['review'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'c', run: ['review'] })], prompts: [definePrompt({ id: 'review', run: ctx => ctx.event.summary ?? '' })], }) const results = await loop.watch([ @@ -187,8 +187,8 @@ describe('Loop — decisions + watch', () => { }) it('isolates a throwing onEvent callback', async () => { - const loop = new Loop({ - rules: [defineRule({ on: 'c', run: ['review'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'c', run: ['review'] })], prompts: [definePrompt({ id: 'review', run: () => 'ok' })], onEvent: () => { throw new Error('observer bug') }, }) diff --git a/packages/ai-autopilot/src/loop/loop.ts b/packages/ai-autopilot/src/loop/loop.ts index beb380d..69a4087 100644 --- a/packages/ai-autopilot/src/loop/loop.ts +++ b/packages/ai-autopilot/src/loop/loop.ts @@ -3,7 +3,7 @@ import type { LoopEvent, LoopProgress, LoopPrompt, - LoopRule, + Loop, LoopRunResult, PassResult, PromptOutcome, @@ -11,11 +11,11 @@ import type { import type { Verdict } from './verdict.js' import { parseVerdict } from './verdict.js' -/** Options for {@link Loop}. */ -export interface LoopOptions { +/** Options for {@link LoopEngine}. */ +export interface LoopEngineOptions { /** The policy: which prompt chains fire for which event kinds. */ - rules: LoopRule[] - /** The prompts a rule can reference, as a list or a map keyed by id. */ + loops: Loop[] + /** The prompts a loop can reference, as a list or a map keyed by id. */ prompts: LoopPrompt[] | Record /** Consulted by prompts (exposed on {@link LoopContext.ledger}). Optional. */ ledger?: DecisionLedger @@ -25,7 +25,7 @@ export interface LoopOptions { * of a prior one failing. * - `false` — blocking gate: if a prompt does not *pass*, stop the chain (a * `gate-stop` event); the remaining prompts do not run. "Pass" means the - * final pass executed and, when a {@link LoopOptions.verdict} parser is set, + * final pass executed and, when a {@link LoopEngineOptions.verdict} parser is set, * returned no blockers. */ continueOnError?: boolean @@ -45,13 +45,13 @@ export interface LoopOptions { } /** - * The loop engine. Give it a policy ({@link LoopRule}s) and a set of + * The loop engine. Give it a policy ({@link Loop}s) and a set of * {@link LoopPrompt}s; call {@link handle} with a {@link LoopEvent} the agent * declared and it runs the matching prompt chain (each prompt for its * fresh-context passes), consulting the decisions ledger when one is set. * * ```ts - * const loop = new Loop({ rules: defaultLoopRules(), prompts: [reviewPrompt, ...] }) + * const loop = new LoopEngine({ loops: defaultLoops(), prompts: [reviewPrompt, ...] }) * await loop.handle({ kind: 'major-change', summary: 'reworked auth', paths: ['src/auth/*'] }) * ``` * @@ -59,19 +59,19 @@ export interface LoopOptions { * over a stream of events, feed them through {@link watch}, or run `handle` * inside `launchAutopilot` for a detached background run. */ -export class Loop { - private readonly rules: LoopRule[] +export class LoopEngine { + private readonly loops: Loop[] private readonly prompts: Map private readonly ledger?: DecisionLedger private readonly continueOnError: boolean private readonly parseVerdict: ((text: string) => Verdict | undefined) | null private readonly emit: (event: LoopProgress) => void - constructor(opts: LoopOptions) { - if (!Array.isArray(opts?.rules)) throw new TypeError('[ai-autopilot] Loop requires `rules`') - if (opts.prompts == null) throw new TypeError('[ai-autopilot] Loop requires `prompts`') + constructor(opts: LoopEngineOptions) { + if (!Array.isArray(opts?.loops)) throw new TypeError('[ai-autopilot] LoopEngine requires `loops`') + if (opts.prompts == null) throw new TypeError('[ai-autopilot] LoopEngine requires `prompts`') - this.rules = opts.rules + this.loops = opts.loops this.prompts = indexPrompts(opts.prompts) if (opts.ledger !== undefined) this.ledger = opts.ledger this.continueOnError = opts.continueOnError ?? true @@ -81,14 +81,14 @@ export class Loop { /** * The prompt ids that would fire for `event`, in chain order and de-duped - * across all matching rules. Pure — no prompts run. + * across all matching loops. Pure — no prompts run. */ matches(event: LoopEvent): string[] { const ids: string[] = [] const seen = new Set() - for (const rule of this.rules) { - if (!rule.on.includes(event.kind)) continue - for (const id of rule.run) { + for (const loop of this.loops) { + if (!loop.on.includes(event.kind)) continue + for (const id of loop.run) { if (seen.has(id)) continue seen.add(id) ids.push(id) @@ -170,9 +170,9 @@ export class Loop { } } -/** Factory mirror of `new Loop(...)`. */ -export function createLoop(opts: LoopOptions): Loop { - return new Loop(opts) +/** Factory mirror of `new LoopEngine(...)`. */ +export function createLoopEngine(opts: LoopEngineOptions): LoopEngine { + return new LoopEngine(opts) } // ─── Internals ─────────────────────────────────────────────────── @@ -184,7 +184,7 @@ function indexPrompts(prompts: LoopPrompt[] | Record): Map void { +function makeEmitter(onEvent: LoopEngineOptions['onEvent']): (event: LoopProgress) => void { if (!onEvent) return () => {} return (event) => { try { diff --git a/packages/ai-autopilot/src/loop/policy.test.ts b/packages/ai-autopilot/src/loop/policy.test.ts index de3331e..ae2bc11 100644 --- a/packages/ai-autopilot/src/loop/policy.test.ts +++ b/packages/ai-autopilot/src/loop/policy.test.ts @@ -1,17 +1,17 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { defaultLoopRules, LOOP_EVENTS, LOOP_PROMPTS } from './policy.js' +import { defaultLoops, LOOP_EVENTS, LOOP_PROMPTS } from './policy.js' -describe('defaultLoopRules', () => { +describe('defaultLoops', () => { it('maps the two built-in change kinds to their prompt chains', () => { - const rules = defaultLoopRules() - const major = rules.find(r => r.on.includes(LOOP_EVENTS.majorChange)) - const ui = rules.find(r => r.on.includes(LOOP_EVENTS.uiFlow)) + const loops = defaultLoops() + const major = loops.find(r => r.on.includes(LOOP_EVENTS.majorChange)) + const ui = loops.find(r => r.on.includes(LOOP_EVENTS.uiFlow)) assert.deepEqual(major?.run, [LOOP_PROMPTS.review, LOOP_PROMPTS.codeQuality, LOOP_PROMPTS.security]) assert.deepEqual(ui?.run, [LOOP_PROMPTS.qa, LOOP_PROMPTS.ux]) }) it('returns fresh arrays each call (safe to extend)', () => { - assert.notEqual(defaultLoopRules(), defaultLoopRules()) + assert.notEqual(defaultLoops(), defaultLoops()) }) }) diff --git a/packages/ai-autopilot/src/loop/policy.ts b/packages/ai-autopilot/src/loop/policy.ts index 201e404..b1258bc 100644 --- a/packages/ai-autopilot/src/loop/policy.ts +++ b/packages/ai-autopilot/src/loop/policy.ts @@ -1,5 +1,5 @@ -import { defineRule } from './define.js' -import type { LoopRule } from './types.js' +import { defineLoop } from './define.js' +import type { Loop } from './types.js' /** * Canonical event kinds the built-in policy triggers on. The agent declares one @@ -14,7 +14,7 @@ export const LOOP_EVENTS = { /** * Canonical prompt ids the built-in policy references. The prompts library - * (#111) registers its bundles under these ids so the default rules resolve; + * (#111) registers its bundles under these ids so the default loops resolve; * the loop itself is prompt-source-agnostic and only knows the ids. */ export const LOOP_PROMPTS = { @@ -30,15 +30,15 @@ export const LOOP_PROMPTS = { /** * The built-in loop policy as data: a major change runs review then code-quality * then security; a new UI flow runs QA then UX. Extend it by concatenating your - * own {@link defineRule} results, or replace it wholesale. + * own {@link defineLoop} results, or replace it wholesale. */ -export function defaultLoopRules(): LoopRule[] { +export function defaultLoops(): Loop[] { return [ - defineRule({ + defineLoop({ on: LOOP_EVENTS.majorChange, run: [LOOP_PROMPTS.review, LOOP_PROMPTS.codeQuality, LOOP_PROMPTS.security], }), - defineRule({ + defineLoop({ on: LOOP_EVENTS.uiFlow, run: [LOOP_PROMPTS.qa, LOOP_PROMPTS.ux], }), diff --git a/packages/ai-autopilot/src/loop/types.ts b/packages/ai-autopilot/src/loop/types.ts index 7319e39..c8a4895 100644 --- a/packages/ai-autopilot/src/loop/types.ts +++ b/packages/ai-autopilot/src/loop/types.ts @@ -9,18 +9,18 @@ import type { Verdict } from './verdict.js' * This is the web-app-specific orchestration layer generic harnesses do not * have. It is semantic (a *kind* of change selects a *set* of prompts), not * command-driven or run-on-every-PR. The trigger is a {@link LoopEvent} the - * agent declares; a {@link LoopRule} maps its kind to an ordered chain of - * {@link LoopPrompt}s; the {@link Loop} runs them (N fresh-context passes each) + * agent declares; a {@link Loop} maps its kind to an ordered chain of + * {@link LoopPrompt}s; the {@link LoopEngine} runs them (N fresh-context passes each) * and can consult the decisions ledger along the way. */ /** * A semantic trigger the agent declares after doing work — the input to the - * loop. `kind` selects which rules fire (e.g. `major-change`, `ui-flow`); the + * loop. `kind` selects which loops fire (e.g. `major-change`, `ui-flow`); the * rest is context handed to the prompts that run. */ export interface LoopEvent { - /** The semantic change type; matched against {@link LoopRule.on}. */ + /** The semantic change type; matched against {@link Loop.on}. */ kind: string /** One-line description of what happened, for the prompts that run. */ summary?: string @@ -51,7 +51,7 @@ export interface LoopContext { * invocation rather than carry state across passes. */ export interface LoopPrompt { - /** Stable id, kebab-case; referenced by {@link LoopRule.run}. */ + /** Stable id, kebab-case; referenced by {@link Loop.run}. */ readonly id: string /** Number of fresh-context passes to run. Positive integer; default 1. */ readonly passes: number @@ -67,17 +67,17 @@ export interface LoopPromptSpec { } /** - * A policy rule: when an event of one of `on`'s kinds fires, run the prompts in - * `run`, in order (a chain). Multiple rules can match one event; their prompt - * ids are concatenated in rule order and de-duped. + * A policy loop: when an event of one of `on`'s kinds fires, run the prompts in + * `run`, in order (a chain). Multiple loops can match one event; their prompt + * ids are concatenated in loop order and de-duped. */ -export interface LoopRule { +export interface Loop { readonly on: readonly string[] readonly run: readonly string[] } -/** Author-facing shape for {@link defineRule}; `on` may be a single kind. */ -export interface LoopRuleSpec { +/** Author-facing shape for {@link defineLoop}; `on` may be a single kind. */ +export interface LoopSpec { on: string | string[] run: string[] } @@ -114,7 +114,7 @@ export interface PromptOutcome { /** The full result of handling one {@link LoopEvent}. */ export interface LoopRunResult { event: LoopEvent - /** False when no rule matched the event's kind (nothing ran). */ + /** False when no loop matched the event's kind (nothing ran). */ matched: boolean /** One entry per dispatched prompt, in chain order. */ outcomes: PromptOutcome[] diff --git a/packages/ai-autopilot/src/overview/agent.test.ts b/packages/ai-autopilot/src/overview/agent.test.ts index 1d79902..cc22317 100644 --- a/packages/ai-autopilot/src/overview/agent.test.ts +++ b/packages/ai-autopilot/src/overview/agent.test.ts @@ -3,8 +3,8 @@ import assert from 'node:assert/strict' import { AiFake, agent } from '@gemstack/ai-sdk' import { agentOverview, overviewLoopPrompt } from './agent.js' import { CodeOverviewMaintainer } from './maintainer.js' -import { Loop } from '../loop/loop.js' -import { defineRule } from '../loop/define.js' +import { LoopEngine } from '../loop/loop.js' +import { defineLoop } from '../loop/define.js' import type { CodeOverview } from './types.js' describe('agentOverview (default regenerate over an ai-sdk agent)', () => { @@ -35,8 +35,8 @@ describe('overviewLoopPrompt (wire the maintainer into the loop)', () => { const maintainer = new CodeOverviewMaintainer({ regenerate: () => { regenerated++; return { summary: 'fresh', sections: [] } }, }) - const loop = new Loop({ - rules: [defineRule({ on: 'major-change', run: ['code-overview'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'major-change', run: ['code-overview'] })], prompts: [overviewLoopPrompt(maintainer)], }) const result = await loop.handle({ kind: 'major-change', summary: 'switched build tool', paths: ['vite.config.ts'] }) @@ -52,8 +52,8 @@ describe('overviewLoopPrompt (wire the maintainer into the loop)', () => { overview: { summary: 'kept', sections: [] }, regenerate: () => { regenerated++; return { summary: 'nope', sections: [] } }, }) - const loop = new Loop({ - rules: [defineRule({ on: 'major-change', run: ['code-overview'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'major-change', run: ['code-overview'] })], prompts: [overviewLoopPrompt(maintainer)], }) const result = await loop.handle({ kind: 'major-change', summary: 'tweak copy', paths: ['pages/about.tsx'] }) diff --git a/packages/ai-autopilot/src/overview/agent.ts b/packages/ai-autopilot/src/overview/agent.ts index 6fc4279..4fcf910 100644 --- a/packages/ai-autopilot/src/overview/agent.ts +++ b/packages/ai-autopilot/src/overview/agent.ts @@ -63,7 +63,7 @@ export interface OverviewLoopPromptOptions { /** * Bridge a {@link CodeOverviewMaintainer} into a {@link LoopPrompt}, so adding its - * id to a loop rule (e.g. on `major-change`) makes the overview self-maintain: the + * id to a loop (e.g. on `major-change`) makes the overview self-maintain: the * loop hands it the event, the maintainer refreshes only if the change is * material, and the prompt reports what it did. This is the "regen via the loop" * wiring (#113) the issue asks for. diff --git a/packages/ai-autopilot/src/prompts/bridge.test.ts b/packages/ai-autopilot/src/prompts/bridge.test.ts index a6480ee..b06991e 100644 --- a/packages/ai-autopilot/src/prompts/bridge.test.ts +++ b/packages/ai-autopilot/src/prompts/bridge.test.ts @@ -5,8 +5,8 @@ import type { AgentResponse, TokenUsage } from '@gemstack/ai-sdk' import { promptInstructions, renderTask, toLoopPrompt, loopPromptsFor, type PromptAgentContext } from './bridge.js' import { PromptLibrary } from './library.js' import type { Prompt } from './types.js' -import { Loop } from '../loop/loop.js' -import { defineRule } from '../loop/define.js' +import { LoopEngine } from '../loop/loop.js' +import { defineLoop } from '../loop/define.js' import { DecisionLedger } from '../decisions/ledger.js' const usage: TokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } @@ -60,7 +60,7 @@ describe('toLoopPrompt', () => { return new EchoAgent(`p${ctx.pass}`) }) assert.equal(lp.passes, 3) - const loop = new Loop({ rules: [defineRule({ on: 'major-change', run: ['review'] })], prompts: [lp] }) + const loop = new LoopEngine({ loops: [defineLoop({ on: 'major-change', run: ['review'] })], prompts: [lp] }) const result = await loop.handle({ kind: 'major-change', summary: 's' }) assert.equal(seen.length, 3) // one fresh agent per pass @@ -76,8 +76,8 @@ describe('loopPromptsFor', () => { prompt({ id: 'review' }), prompt({ id: 'security', name: 'security', instructions: 'SEC' }), ]) - const loop = new Loop({ - rules: [defineRule({ on: 'major-change', run: ['review', 'security'] })], + const loop = new LoopEngine({ + loops: [defineLoop({ on: 'major-change', run: ['review', 'security'] })], prompts: loopPromptsFor(library, ctx => new EchoAgent(ctx.prompt.id)), }) const result = await loop.handle({ kind: 'major-change' }) diff --git a/packages/ai-autopilot/src/prompts/bridge.ts b/packages/ai-autopilot/src/prompts/bridge.ts index b444149..dec4f61 100644 --- a/packages/ai-autopilot/src/prompts/bridge.ts +++ b/packages/ai-autopilot/src/prompts/bridge.ts @@ -69,8 +69,8 @@ export function toLoopPrompt(prompt: Prompt, makeAgent: MakePromptAgent): LoopPr /** * Materialize a whole {@link PromptLibrary} (or list of prompts) into loop - * prompts, ready to drop into `new Loop({ prompts })`. This is the turnkey wire: - * the loop's `defaultLoopRules()` ids now resolve to real bodies. + * prompts, ready to drop into `new LoopEngine({ prompts })`. This is the turnkey wire: + * the loop's `defaultLoops()` ids now resolve to real bodies. */ export function loopPromptsFor( prompts: PromptLibrary | readonly Prompt[], diff --git a/packages/ai-autopilot/src/prompts/index.ts b/packages/ai-autopilot/src/prompts/index.ts index 8658894..b5e32c8 100644 --- a/packages/ai-autopilot/src/prompts/index.ts +++ b/packages/ai-autopilot/src/prompts/index.ts @@ -3,7 +3,7 @@ * * Load the built-ins with {@link builtinLibrary} (or {@link loadPromptsFrom} for * your own directory), then materialize them into loop prompts with - * {@link loopPromptsFor} so `defaultLoopRules()` ids resolve to real bodies. Each + * {@link loopPromptsFor} so `defaultLoops()` ids resolve to real bodies. Each * prompt is a {@link Prompt}: frontmatter + a markdown instructions body a * contributor can improve without touching code. */ diff --git a/packages/ai-autopilot/src/prompts/library.test.ts b/packages/ai-autopilot/src/prompts/library.test.ts index c6850eb..63cc69e 100644 --- a/packages/ai-autopilot/src/prompts/library.test.ts +++ b/packages/ai-autopilot/src/prompts/library.test.ts @@ -1,12 +1,12 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { builtinLibrary, builtinPrompts, PromptLibrary } from './library.js' -import { LOOP_PROMPTS, LOOP_EVENTS, defaultLoopRules } from '../loop/policy.js' +import { LOOP_PROMPTS, LOOP_EVENTS, defaultLoops } from '../loop/policy.js' describe('builtin prompts', () => { it('ships a body for every id the default loop policy references', async () => { const library = await builtinLibrary() - const referenced = new Set(defaultLoopRules().flatMap(r => r.run)) + const referenced = new Set(defaultLoops().flatMap(r => r.run)) for (const id of referenced) { assert.ok(library.get(id), `built-in prompt "${id}" exists`) assert.ok(library.get(id)!.instructions.length > 0, `prompt "${id}" has a body`) From 83365e99cf10adac0fff0ce2317f9e5021a87e97 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 5 Jul 2026 16:43:46 +0300 Subject: [PATCH 2/2] fix(ai-autopilot): update bootstrap-quickstart example to loops vocabulary The example consumed the old defineRule / new Loop / rules API. --- examples/bootstrap-quickstart/src/bootstrap.ts | 14 +++++++------- examples/bootstrap-quickstart/src/live.ts | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/bootstrap-quickstart/src/bootstrap.ts b/examples/bootstrap-quickstart/src/bootstrap.ts index 64ed037..d338c68 100644 --- a/examples/bootstrap-quickstart/src/bootstrap.ts +++ b/examples/bootstrap-quickstart/src/bootstrap.ts @@ -7,9 +7,9 @@ import { loopImprove, agentDeploy, cloudflareTarget, - Loop, + LoopEngine, definePrompt, - defineRule, + defineLoop, personaInstructions, personaTools, builtinPresetRegistry, @@ -128,16 +128,16 @@ function presetWorkers(session: RunnerSession, personas: ReturnType verdicts[Math.min(pass++, verdicts.length - 1)]! }), diff --git a/examples/bootstrap-quickstart/src/live.ts b/examples/bootstrap-quickstart/src/live.ts index 601f1f6..afecc39 100644 --- a/examples/bootstrap-quickstart/src/live.ts +++ b/examples/bootstrap-quickstart/src/live.ts @@ -7,9 +7,9 @@ import { loopImprove, agentDeploy, cloudflareTarget, - Loop, + LoopEngine, definePrompt, - defineRule, + defineLoop, personaInstructions, personaTools, builtinPresetRegistry, @@ -78,16 +78,16 @@ function presetWorkers(session: RunnerSession, personas: ReturnType verdicts[Math.min(pass++, verdicts.length - 1)]! }),