From 6d5bae28fa1e935a3b2acfb67fee0cfe5a11b262 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Thu, 2 Jul 2026 19:44:28 +0300 Subject: [PATCH] feat(ai-autopilot): built-in prompts library, the loop's bodies as data Ships eight stack-aware prompt bodies as markdown under prompts/ (review TLDR + thorough, code-quality, security, refactor, UX, QA, knowledge-base) that already know Vike + universal-orm. builtinLibrary() / loadPromptsFrom(dir) load them into a PromptLibrary, parsed via @gemstack/ai-skills frontmatter so a contributor edits prose, not code. loopPromptsFor(library, makeAgent) materializes them into loop prompts so defaultLoopRules() ids resolve to real bodies (a fresh agent per pass); promptInstructions composes a body with the decisions briefing (#112) and renderTask turns a LoopEvent into the worker task. Closes the turnkey wire across #111 / #112 / #113. Child #111 of the AI-framework epic (#110). --- .changeset/ai-autopilot-prompts-library.md | 5 ++ packages/ai-autopilot/README.md | 33 +++++++ packages/ai-autopilot/package.json | 3 +- packages/ai-autopilot/prompts/code-quality.md | 32 +++++++ .../ai-autopilot/prompts/knowledge-base.md | 31 +++++++ packages/ai-autopilot/prompts/qa.md | 30 +++++++ packages/ai-autopilot/prompts/refactor.md | 29 +++++++ .../ai-autopilot/prompts/review-thorough.md | 34 ++++++++ packages/ai-autopilot/prompts/review-tldr.md | 23 +++++ packages/ai-autopilot/prompts/security.md | 33 +++++++ packages/ai-autopilot/prompts/ux.md | 33 +++++++ packages/ai-autopilot/src/index.ts | 23 +++++ .../ai-autopilot/src/prompts/bridge.test.ts | 87 +++++++++++++++++++ packages/ai-autopilot/src/prompts/bridge.ts | 81 +++++++++++++++++ packages/ai-autopilot/src/prompts/index.ts | 26 ++++++ .../ai-autopilot/src/prompts/library.test.ts | 46 ++++++++++ packages/ai-autopilot/src/prompts/library.ts | 74 ++++++++++++++++ .../ai-autopilot/src/prompts/parse.test.ts | 40 +++++++++ packages/ai-autopilot/src/prompts/parse.ts | 54 ++++++++++++ packages/ai-autopilot/src/prompts/types.ts | 31 +++++++ 20 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 .changeset/ai-autopilot-prompts-library.md create mode 100644 packages/ai-autopilot/prompts/code-quality.md create mode 100644 packages/ai-autopilot/prompts/knowledge-base.md create mode 100644 packages/ai-autopilot/prompts/qa.md create mode 100644 packages/ai-autopilot/prompts/refactor.md create mode 100644 packages/ai-autopilot/prompts/review-thorough.md create mode 100644 packages/ai-autopilot/prompts/review-tldr.md create mode 100644 packages/ai-autopilot/prompts/security.md create mode 100644 packages/ai-autopilot/prompts/ux.md create mode 100644 packages/ai-autopilot/src/prompts/bridge.test.ts create mode 100644 packages/ai-autopilot/src/prompts/bridge.ts create mode 100644 packages/ai-autopilot/src/prompts/index.ts create mode 100644 packages/ai-autopilot/src/prompts/library.test.ts create mode 100644 packages/ai-autopilot/src/prompts/library.ts create mode 100644 packages/ai-autopilot/src/prompts/parse.test.ts create mode 100644 packages/ai-autopilot/src/prompts/parse.ts create mode 100644 packages/ai-autopilot/src/prompts/types.ts diff --git a/.changeset/ai-autopilot-prompts-library.md b/.changeset/ai-autopilot-prompts-library.md new file mode 100644 index 0000000..fee2d10 --- /dev/null +++ b/.changeset/ai-autopilot-prompts-library.md @@ -0,0 +1,5 @@ +--- +"@gemstack/ai-autopilot": minor +--- + +Add the built-in prompts library: the stack-aware prompt *bodies* the loop dispatches, shipped as data. Eight markdown bundles under `prompts/` (review TLDR + thorough, code-quality, security, refactor, UX, QA, knowledge-base) that already know Vike + universal-orm, loaded with `builtinLibrary()` / `loadPromptsFrom(dir)` into a `PromptLibrary` and parsed via `@gemstack/ai-skills`' frontmatter (a contributor edits prose, not code). `loopPromptsFor(library, makeAgent)` materializes them into loop prompts so `defaultLoopRules()` ids resolve to real bodies (a fresh agent per pass); `promptInstructions` composes a body with the decisions briefing (#112) and `renderTask` turns a `LoopEvent` into the worker's task. Closes the turnkey wire across #111 / #112 / #113. Verified end-to-end against the built package. Child #111 of the AI-framework epic (#110). diff --git a/packages/ai-autopilot/README.md b/packages/ai-autopilot/README.md index 279883e..52ada56 100644 --- a/packages/ai-autopilot/README.md +++ b/packages/ai-autopilot/README.md @@ -247,6 +247,39 @@ built-in policy as data; extend it by concatenating your own `defineRule` result The prompt bodies themselves are the prompts library (#111), registered under the ids the rules reference. +## Built-in prompts — the loop's bodies, shipped as data + +The loop dispatches prompts by id; the **prompts library** supplies the bodies. +They ship ready to use and already know the stack (Vike + universal-orm): review +(TLDR + thorough), code-quality, security, refactor, UX, QA, and a knowledge-base +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()` +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' + +const library = await builtinLibrary() // the shipped bodies +const loop = new Loop({ + rules: defaultLoopRules(), // 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 +}) + +await loop.handle({ kind: 'major-change', summary: 'reworked auth', paths: ['src/auth/*'] }) +``` + +`ctx.instructions` is the prompt body composed with the decisions briefing (#112), +ready to set on the agent; the agent carries whatever tools the prompt needs +(`runnerTools(session)` for file/exec, browser tools for the QA prompt). Load your +own bodies from a directory with `loadPromptsFrom(dir)`, or add one to a library +with `library.add(...)`. The bodies are the main open-source contribution surface; +PRs that sharpen them are welcome. + ## Guardrails - **`concurrency`** (optional, default 4) — max workers in flight; positive integer. diff --git a/packages/ai-autopilot/package.json b/packages/ai-autopilot/package.json index 3fd262c..bfa2517 100644 --- a/packages/ai-autopilot/package.json +++ b/packages/ai-autopilot/package.json @@ -28,7 +28,8 @@ "node": ">=22.12.0" }, "files": [ - "dist" + "dist", + "prompts" ], "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/ai-autopilot/prompts/code-quality.md b/packages/ai-autopilot/prompts/code-quality.md new file mode 100644 index 0000000..c9b70c0 --- /dev/null +++ b/packages/ai-autopilot/prompts/code-quality.md @@ -0,0 +1,32 @@ +--- +name: code-quality +description: Assess and improve code quality — clarity, reuse, simplicity. +appliesTo: ["**/*"] +metadata: + title: Code quality + loopId: code-quality + passes: 1 + event: major-change +--- + +You are improving the **quality** of a change in a Vike + universal-orm app. This +is not a bug hunt (the review covers that); it is about whether the code is clear, +simple, and consistent with the codebase around it. + +Look for: + +- **Reuse** — logic reimplemented that a helper, the ORM, or a framework utility + already does. Prefer the existing seam over a new one-off. +- **Simplicity** — nesting, flags, and branches that a smaller shape would remove. + Fewer moving parts, same behavior. +- **Naming** — names that mislead or hide intent; match the vocabulary already in + the file. +- **Consistency** — the change should read like the code it sits next to: same + patterns, same error handling, same comment density (short, only the non-obvious + why). +- **Boundaries** — a function doing two jobs; a module reaching across a layer it + should not know about. + +Propose concrete edits, smallest first. Do not rewrite working code for taste +alone: every suggestion must make the code measurably simpler or clearer, and you +should say which. Leave correctness-neutral style to the formatter. diff --git a/packages/ai-autopilot/prompts/knowledge-base.md b/packages/ai-autopilot/prompts/knowledge-base.md new file mode 100644 index 0000000..dec20fc --- /dev/null +++ b/packages/ai-autopilot/prompts/knowledge-base.md @@ -0,0 +1,31 @@ +--- +name: knowledge-base +description: The always-on business context the agent should know about the project. +appliesTo: ["**/*"] +metadata: + title: Knowledge base / business context + loopId: knowledge-base + passes: 1 +--- + +This is the **business context** for the project: what it is, who it is for, and +what matters. Unlike the other prompts, this is not a task to run — it is standing +context the agent should carry into every other prompt so its work fits the +product, not just the code. + +Fill this in for your project (keep it short and current; stale context is worse +than none): + +- **What we are building** — the product in one or two sentences. +- **Who it is for** — the users, and the one job they hire it to do. +- **Stage & priority** — prototype, launch, or scale; and the single thing that + matters most right now (speed to ship / correctness / polish / cost). +- **Non-negotiables** — constraints the agent must always respect (compliance, + data handling, a platform we target, a budget). +- **Deliberate choices** — the settled decisions and the roads not taken. Keep + these in `DECISIONS.md` (the decisions ledger) so they are not re-litigated; + this file points at the why behind them. + +When you act, prefer the option that serves the stage and priority above. If a +technical choice trades against a business constraint here, surface the trade-off +rather than deciding it silently. diff --git a/packages/ai-autopilot/prompts/qa.md b/packages/ai-autopilot/prompts/qa.md new file mode 100644 index 0000000..7d281a7 --- /dev/null +++ b/packages/ai-autopilot/prompts/qa.md @@ -0,0 +1,30 @@ +--- +name: qa +description: Manually test a user flow like a QA engineer and report what breaks. +appliesTo: ["**/*"] +metadata: + title: QA + loopId: qa + passes: 1 + event: ui-flow +--- + +You are the **QA engineer** for a new flow in a Vike app. Your job is to actually +exercise it in the browser (via the browser tools available to you) and report +what breaks, not to read the code. + +Do this: + +1. **Derive test cases** from the flow: the happy path, plus the edge cases that + break real software — empty input, too-long input, invalid formats, wrong + credentials, double-submit, back-button mid-flow, refresh mid-flow, and a + slow/failed network. +2. **Run each one** in the browser: drive the UI, submit, and observe the actual + result, including console errors and network failures. Do not assume; click it. +3. **Report** every case as: steps to reproduce, expected result, actual result, + and severity (blocker / major / minor). Include the exact input you used. + +Focus on behavior a user would hit. A case only counts as passing if you drove it +and saw it work. End with a short pass/fail summary and the blockers first. If you +cannot reach part of the flow, say what stopped you rather than skipping it +silently. diff --git a/packages/ai-autopilot/prompts/refactor.md b/packages/ai-autopilot/prompts/refactor.md new file mode 100644 index 0000000..b0ac50d --- /dev/null +++ b/packages/ai-autopilot/prompts/refactor.md @@ -0,0 +1,29 @@ +--- +name: refactor +description: Refactor code without changing behavior — reduce, unify, clarify. +appliesTo: ["**/*"] +metadata: + title: Refactoring + loopId: refactor + passes: 1 +--- + +You are refactoring code in a Vike + universal-orm app. The contract is strict: +**behavior does not change.** Same inputs, same outputs, same side effects. You +are changing the shape, not the meaning. + +Aim for: + +- **Less code** — collapse duplication into one seam; delete dead paths; remove + indirection that earns nothing. +- **One way** — when the codebase has two ways to do the same thing, converge on + the one that already fits best; do not introduce a third. +- **Clear boundaries** — split a function that does two jobs; move logic to the + layer that owns it (data logic to the model, view logic to the page). +- **Readability** — names and structure that let the next reader skip the comments. + +Work in small, independently-correct steps and state the behavior-preserving +reason for each. Before finishing, confirm what proves behavior is unchanged +(existing tests, types, a quick trace of the touched paths). If a change would +alter behavior, it is not a refactor — flag it separately as a proposal, do not +smuggle it in. diff --git a/packages/ai-autopilot/prompts/review-thorough.md b/packages/ai-autopilot/prompts/review-thorough.md new file mode 100644 index 0000000..44597e4 --- /dev/null +++ b/packages/ai-autopilot/prompts/review-thorough.md @@ -0,0 +1,34 @@ +--- +name: review-thorough +description: A thorough correctness and design review of a change. +appliesTo: ["**/*"] +metadata: + title: Thorough review + loopId: review + passes: 2 + event: major-change +--- + +You are doing a **thorough** review of a change in a Vike + universal-orm app. +Read the changed code closely and trace the paths it actually runs, not just the +diff in isolation. + +Cover, in priority order: + +1. **Correctness** — off-by-one, wrong conditionals, unhandled `null`/`undefined`, + promises not awaited, error paths that swallow or mishandle failures, race + conditions across `await` points. +2. **Data layer** — universal-orm queries: N+1 access, missing `where` scoping + (leaking another user's/org's rows), writes without the RETURNING data the + caller assumes, migrations that are not backward-compatible with running code. +3. **Vike boundaries** — server-only code (DB drivers, secrets) leaking into a + client bundle; data loaded in the wrong hook; SSR/hydration mismatches. +4. **API contracts** — inputs not validated at the edge; response shapes that + drift from what callers expect. +5. **Design** — the change that works but boxes the codebase in; duplication that + should be shared; a simpler structure that does the same job. + +For each finding give: file + location, what breaks and the concrete input that +triggers it, and the fix. Separate **must-fix** (correctness/security) from +**consider** (design/cleanup). If you are unsure a finding is real, say so rather +than asserting it. End with a one-line verdict: ship, ship-with-fixes, or rework. diff --git a/packages/ai-autopilot/prompts/review-tldr.md b/packages/ai-autopilot/prompts/review-tldr.md new file mode 100644 index 0000000..5c06ef2 --- /dev/null +++ b/packages/ai-autopilot/prompts/review-tldr.md @@ -0,0 +1,23 @@ +--- +name: review-tldr +description: A fast, high-signal review of a change — the headline issues only. +appliesTo: ["**/*"] +metadata: + title: TLDR review + loopId: review-tldr + passes: 1 +--- + +You are reviewing a change for a Vike + universal-orm app. Give the **TLDR**: the +few things that actually matter, not an exhaustive list. + +Report at most the 3 highest-impact findings. For each: one line on what is wrong +and one line on the fix. If the change is fine, say so in one sentence and stop. + +Prioritize, in order: +1. Correctness bugs that ship broken behavior to users. +2. Security or data-loss risks. +3. A design choice that will be expensive to undo later. + +Skip nitpicks, style, and anything a formatter or the thorough review would catch. +Lead with the single most important thing. Be blunt and short. diff --git a/packages/ai-autopilot/prompts/security.md b/packages/ai-autopilot/prompts/security.md new file mode 100644 index 0000000..ce23d93 --- /dev/null +++ b/packages/ai-autopilot/prompts/security.md @@ -0,0 +1,33 @@ +--- +name: security +description: Security audit of a change — authz, input, secrets, data exposure. +appliesTo: ["**/*"] +metadata: + title: Security audit + loopId: security + passes: 1 + event: major-change +--- + +You are doing a **security audit** of a change in a Vike + universal-orm app. +Assume the input is hostile and the caller is not who they claim to be. + +Check, in priority order: + +1. **Authorization** — every data access and mutation is scoped to the current + user/org. A missing `where userId = ...` / ownership check is the most common + real hole: an authenticated user reading or writing another's rows (IDOR). + Server actions and API routes must re-check authz, never trust the client. +2. **Input validation** — untrusted input (params, body, query, headers) is + validated and typed at the edge before it reaches the ORM or the filesystem. + Watch for injection into raw queries, path traversal, and unbounded input. +3. **Secrets & server boundary** — no secret, token, or server-only module ends + up in a client bundle; env secrets are read server-side only. +4. **Data exposure** — responses do not leak fields the caller should not see + (password hashes, other users' data, internal ids used as capabilities). +5. **Auth flows** — session/cookie flags (HttpOnly, Secure, SameSite), token + expiry, and that sign-out actually invalidates. + +For each finding: the vulnerable path, a concrete exploit (who sends what to get +what), severity, and the fix. Do not pad the list with theoretical issues that do +not apply to this change. If you find nothing exploitable, say so plainly. diff --git a/packages/ai-autopilot/prompts/ux.md b/packages/ai-autopilot/prompts/ux.md new file mode 100644 index 0000000..233bb80 --- /dev/null +++ b/packages/ai-autopilot/prompts/ux.md @@ -0,0 +1,33 @@ +--- +name: ux +description: Review a user-facing flow for usability, states, and accessibility. +appliesTo: ["**/*"] +metadata: + title: UX review + loopId: ux + passes: 1 + event: ui-flow +--- + +You are reviewing a **user-facing flow** in a Vike app (e.g. auth, checkout, a +form). Judge it as the user experiences it, not as code. + +Walk the flow end to end and check: + +- **The unhappy paths** — loading, empty, and error states exist and are clear; + a slow or failed request does not leave the user staring at a frozen or blank + screen. This is where flows usually break. +- **Feedback** — every action has a visible response (pending, success, failure); + destructive actions confirm; nothing silently no-ops. +- **Forms** — validation errors are specific and next to the field; the form + survives a failed submit without losing input; labels and required-state are + clear. +- **Clarity** — the primary action is obvious; copy says what will happen; the + user always knows where they are and how to get back. +- **Accessibility** — keyboard reachable, focus is managed across steps, labels + and roles are present, contrast is adequate. +- **Responsive** — the flow works on a narrow screen; nothing overflows or + becomes untappable. + +For each issue: what the user hits, why it hurts, and the concrete fix. Prioritize +the ones that block or confuse a real user over polish. Lead with the worst one. diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 5ee5979..e230baa 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -50,6 +50,13 @@ * - {@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 + * + * The prompts library supplies the loop's prompt *bodies* as data (stack-aware + * markdown): review, code-quality, security, refactor, UX, QA, knowledge-base. + * + * - {@link builtinLibrary} — load the shipped, stack-aware prompt bodies + * - {@link loopPromptsFor} — materialize a library into loop prompts by id + * - {@link promptInstructions} — compose a body with the decisions briefing */ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' @@ -145,6 +152,22 @@ export { type LoopRunResult, type LoopProgress, } from './loop/index.js' +export { + parsePrompt, + PromptError, + PromptLibrary, + builtinPrompts, + builtinLibrary, + builtinPromptsDir, + loadPromptsFrom, + promptInstructions, + renderTask, + toLoopPrompt, + loopPromptsFor, + type Prompt, + type MakePromptAgent, + type PromptAgentContext, +} from './prompts/index.js' export type { Subtask, PlannedSubtask, diff --git a/packages/ai-autopilot/src/prompts/bridge.test.ts b/packages/ai-autopilot/src/prompts/bridge.test.ts new file mode 100644 index 0000000..a6480ee --- /dev/null +++ b/packages/ai-autopilot/src/prompts/bridge.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { Agent } from '@gemstack/ai-sdk' +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 { DecisionLedger } from '../decisions/ledger.js' + +const usage: TokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } + +/** An agent whose prompt() echoes its instructions + the task it received. */ +class EchoAgent extends Agent { + constructor(private readonly note: string) { + super() + } + instructions(): string { + return this.note + } + override async prompt(input: string): Promise { + return { text: `[${this.note}] ${input}`, steps: [], usage, finishReason: 'stop' } + } +} + +const prompt = (over: Partial = {}): Prompt => ({ + id: 'review', name: 'review', title: 'Review', description: '', instructions: 'REVIEW BODY', passes: 1, appliesTo: [], ...over, +}) + +describe('promptInstructions', () => { + it('prepends the decisions briefing when the ledger has rejections', () => { + const ledger = new DecisionLedger() + ledger.reject('Use Redux', 'boilerplate') + const composed = promptInstructions(prompt(), { ledger }) + assert.match(composed, /already considered and rejected/) + assert.match(composed, /REVIEW BODY$/) + }) + + it('is just the body with no ledger or nothing rejected', () => { + assert.equal(promptInstructions(prompt()), 'REVIEW BODY') + assert.equal(promptInstructions(prompt(), { ledger: new DecisionLedger() }), 'REVIEW BODY') + }) +}) + +describe('renderTask', () => { + it('renders kind, summary, and file list', () => { + const t = renderTask({ kind: 'major-change', summary: 'reworked auth', paths: ['src/auth.ts'] }) + assert.match(t, /Change kind: major-change/) + assert.match(t, /Summary: reworked auth/) + assert.match(t, /- src\/auth\.ts/) + }) +}) + +describe('toLoopPrompt', () => { + it('builds a fresh agent per pass and prompts it with the rendered task', async () => { + const seen: PromptAgentContext[] = [] + const lp = toLoopPrompt(prompt({ passes: 3 }), ctx => { + seen.push(ctx) + 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 result = await loop.handle({ kind: 'major-change', summary: 's' }) + + assert.equal(seen.length, 3) // one fresh agent per pass + assert.deepEqual(seen.map(c => c.pass), [1, 2, 3]) + assert.match(seen[0]!.instructions, /REVIEW BODY/) + assert.match(result.outcomes[0]!.passes[0]!.text, /\[p1\] Change kind: major-change/) + }) +}) + +describe('loopPromptsFor', () => { + it('materializes a library so default-policy ids resolve', async () => { + const library = new PromptLibrary([ + prompt({ id: 'review' }), + prompt({ id: 'security', name: 'security', instructions: 'SEC' }), + ]) + const loop = new Loop({ + rules: [defineRule({ on: 'major-change', run: ['review', 'security'] })], + prompts: loopPromptsFor(library, ctx => new EchoAgent(ctx.prompt.id)), + }) + const result = await loop.handle({ kind: 'major-change' }) + assert.deepEqual(result.outcomes.map(o => o.promptId), ['review', 'security']) + assert.ok(result.outcomes.every(o => o.ok)) + }) +}) diff --git a/packages/ai-autopilot/src/prompts/bridge.ts b/packages/ai-autopilot/src/prompts/bridge.ts new file mode 100644 index 0000000..b444149 --- /dev/null +++ b/packages/ai-autopilot/src/prompts/bridge.ts @@ -0,0 +1,81 @@ +import type { Agent } from '@gemstack/ai-sdk' +import { decisionBriefing } from '../decisions/tools.js' +import type { DecisionLedger } from '../decisions/ledger.js' +import { definePrompt } from '../loop/define.js' +import type { LoopEvent, LoopPrompt } from '../loop/types.js' +import { PromptLibrary } from './library.js' +import type { Prompt } from './types.js' + +/** + * Compose a prompt's instructions for a run: the decisions briefing (the ideas + * already rejected, from #112) first, then the prompt body. Returns just the body + * when there is no ledger or nothing has been rejected. + */ +export function promptInstructions(prompt: Prompt, opts: { ledger?: DecisionLedger } = {}): string { + const briefing = opts.ledger ? decisionBriefing(opts.ledger) : '' + return briefing ? `${briefing}\n\n${prompt.instructions}` : prompt.instructions +} + +/** Render a {@link LoopEvent} into the task text a prompt's worker is prompted with. */ +export function renderTask(event: LoopEvent): string { + const parts = [`Change kind: ${event.kind}`] + if (event.summary) parts.push(`Summary: ${event.summary}`) + if (event.paths?.length) parts.push(`Files touched:\n${event.paths.map(p => `- ${p}`).join('\n')}`) + return parts.join('\n') +} + +/** What {@link toLoopPrompt} hands your agent factory on each pass. */ +export interface PromptAgentContext { + prompt: Prompt + event: LoopEvent + /** 1-based pass number; a fresh agent is built per pass. */ + pass: number + passes: number + ledger?: DecisionLedger + /** The composed instructions (briefing + body) — set these on the agent. */ + instructions: string +} + +/** Builds the agent that runs one pass of a prompt. Called fresh each pass. */ +export type MakePromptAgent = (ctx: PromptAgentContext) => Agent + +/** + * Bridge a {@link Prompt} into a {@link LoopPrompt} the loop can dispatch. The + * loop calls this for each of the prompt's `passes`; `makeAgent` builds a fresh + * agent every pass (that is the point — a reset context per pass), which is then + * prompted with the event rendered by {@link renderTask}. The agent should carry + * the file/browser tools the prompt needs (e.g. `runnerTools(session)`); the + * prompt body and the decisions briefing arrive as `ctx.instructions`. + */ +export function toLoopPrompt(prompt: Prompt, makeAgent: MakePromptAgent): LoopPrompt { + return definePrompt({ + id: prompt.id, + passes: prompt.passes, + run: async ctx => { + const instructions = promptInstructions(prompt, ctx.ledger ? { ledger: ctx.ledger } : {}) + const agent = makeAgent({ + prompt, + event: ctx.event, + pass: ctx.pass, + passes: ctx.passes, + ...(ctx.ledger ? { ledger: ctx.ledger } : {}), + instructions, + }) + const response = await agent.prompt(renderTask(ctx.event)) + return response.text ?? '' + }, + }) +} + +/** + * 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. + */ +export function loopPromptsFor( + prompts: PromptLibrary | readonly Prompt[], + makeAgent: MakePromptAgent, +): LoopPrompt[] { + const list = prompts instanceof PromptLibrary ? prompts.all() : [...prompts] + return list.map(p => toLoopPrompt(p, makeAgent)) +} diff --git a/packages/ai-autopilot/src/prompts/index.ts b/packages/ai-autopilot/src/prompts/index.ts new file mode 100644 index 0000000..8658894 --- /dev/null +++ b/packages/ai-autopilot/src/prompts/index.ts @@ -0,0 +1,26 @@ +/** + * The built-in prompts library — stack-aware prompt bodies, shipped as data. + * + * 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 + * prompt is a {@link Prompt}: frontmatter + a markdown instructions body a + * contributor can improve without touching code. + */ +export { parsePrompt, PromptError } from './parse.js' +export { + PromptLibrary, + builtinPrompts, + builtinLibrary, + builtinPromptsDir, + loadPromptsFrom, +} from './library.js' +export { + promptInstructions, + renderTask, + toLoopPrompt, + loopPromptsFor, + type MakePromptAgent, + type PromptAgentContext, +} from './bridge.js' +export type { Prompt } from './types.js' diff --git a/packages/ai-autopilot/src/prompts/library.test.ts b/packages/ai-autopilot/src/prompts/library.test.ts new file mode 100644 index 0000000..6cf073a --- /dev/null +++ b/packages/ai-autopilot/src/prompts/library.test.ts @@ -0,0 +1,46 @@ +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' + +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)) + 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`) + } + // the canonical ids resolve + assert.ok(library.get(LOOP_PROMPTS.review)) + assert.ok(library.get(LOOP_PROMPTS.security)) + }) + + it('groups prompts by their loop event', async () => { + const library = await builtinLibrary() + const majorIds = library.byEvent(LOOP_EVENTS.majorChange).map(p => p.id) + assert.ok(majorIds.includes('review')) + assert.ok(majorIds.includes('code-quality')) + assert.ok(majorIds.includes('security')) + const uiIds = library.byEvent(LOOP_EVENTS.uiFlow).map(p => p.id) + assert.deepEqual(uiIds.sort(), ['qa', 'ux']) + }) + + it('also ships the standalone bodies (refactor, knowledge-base, tldr)', async () => { + const ids = (await builtinPrompts()).map(p => p.id) + for (const id of ['refactor', 'knowledge-base', 'review-tldr']) { + assert.ok(ids.includes(id), `ships "${id}"`) + } + }) +}) + +describe('PromptLibrary', () => { + it('get/add/all keyed by id, sorted', () => { + const lib = new PromptLibrary() + lib.add({ id: 'b', name: 'b', title: 'b', description: '', instructions: 'x', passes: 1, appliesTo: [] }) + lib.add({ id: 'a', name: 'a', title: 'a', description: '', instructions: 'y', passes: 1, appliesTo: [] }) + assert.deepEqual(lib.ids(), ['a', 'b']) + assert.equal(lib.get('a')?.instructions, 'y') + assert.equal(lib.size, 2) + }) +}) diff --git a/packages/ai-autopilot/src/prompts/library.ts b/packages/ai-autopilot/src/prompts/library.ts new file mode 100644 index 0000000..49c14ab --- /dev/null +++ b/packages/ai-autopilot/src/prompts/library.ts @@ -0,0 +1,74 @@ +import { readFile, readdir } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { parsePrompt } from './parse.js' +import type { Prompt } from './types.js' + +/** + * A set of {@link Prompt}s keyed by dispatch id, with lookup helpers. This is + * what you hand to the loop (via `loopPromptsFor`) and what a UI lists. + */ +export class PromptLibrary { + private readonly byId = new Map() + + constructor(prompts: readonly Prompt[] = []) { + for (const p of prompts) this.byId.set(p.id, p) + } + + /** The prompt with this dispatch id, or `undefined`. */ + get(id: string): Prompt | undefined { + return this.byId.get(id) + } + + /** All prompts, sorted by id for a stable order. */ + all(): Prompt[] { + return [...this.byId.values()].sort((a, b) => a.id.localeCompare(b.id)) + } + + /** The dispatch ids in the library. */ + ids(): string[] { + return this.all().map(p => p.id) + } + + /** Prompts that target a given loop event kind (e.g. `major-change`). */ + byEvent(kind: string): Prompt[] { + return this.all().filter(p => p.event === kind) + } + + /** Add or replace a prompt (e.g. a project's own body). Returns `this`. */ + add(prompt: Prompt): this { + this.byId.set(prompt.id, prompt) + return this + } + + get size(): number { + return this.byId.size + } +} + +/** Absolute path to the package's shipped `prompts/` directory. */ +export function builtinPromptsDir(): string { + // From dist/prompts/library.js (and dist-test/…), the package root is two up. + return fileURLToPath(new URL('../../prompts/', import.meta.url)) +} + +/** Load every `*.md` prompt bundle in a directory, in filename order. */ +export async function loadPromptsFrom(dir: string): Promise { + const files = (await readdir(dir)).filter(f => f.endsWith('.md')).sort() + return Promise.all( + files.map(async f => { + const path = join(dir, f) + return parsePrompt(await readFile(path, 'utf8'), path) + }), + ) +} + +/** Load the built-in, stack-aware prompt bodies shipped with the package. */ +export async function builtinPrompts(): Promise { + return loadPromptsFrom(builtinPromptsDir()) +} + +/** The built-in prompts as a ready-to-use {@link PromptLibrary}. */ +export async function builtinLibrary(): Promise { + return new PromptLibrary(await builtinPrompts()) +} diff --git a/packages/ai-autopilot/src/prompts/parse.test.ts b/packages/ai-autopilot/src/prompts/parse.test.ts new file mode 100644 index 0000000..4a3ec8b --- /dev/null +++ b/packages/ai-autopilot/src/prompts/parse.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { parsePrompt, PromptError } from './parse.js' + +const bundle = (front: string, body = 'Do the thing.') => `---\n${front}\n---\n${body}` + +describe('parsePrompt', () => { + it('parses frontmatter + body with sensible defaults', () => { + const p = parsePrompt(bundle('name: review-tldr\ndescription: fast review')) + assert.equal(p.id, 'review-tldr') // defaults to name + assert.equal(p.name, 'review-tldr') + assert.equal(p.title, 'review-tldr') // defaults to name + assert.equal(p.passes, 1) + assert.equal(p.instructions, 'Do the thing.') + assert.equal('event' in p, false) + assert.ok(Object.isFrozen(p)) + }) + + it('reads loopId, title, passes, and event from metadata', () => { + const p = parsePrompt( + bundle('name: review-thorough\ndescription: d\nmetadata:\n loopId: review\n title: Thorough review\n passes: 2\n event: major-change'), + ) + assert.equal(p.id, 'review') + assert.equal(p.title, 'Thorough review') + assert.equal(p.passes, 2) + assert.equal(p.event, 'major-change') + }) + + it('throws on an empty body, a bad passes count, or a non-kebab id', () => { + assert.throws(() => parsePrompt(bundle('name: x\ndescription: d', ' ')), PromptError) + assert.throws( + () => parsePrompt(bundle('name: x\ndescription: d\nmetadata:\n passes: 0')), + PromptError, + ) + assert.throws( + () => parsePrompt(bundle('name: x\ndescription: d\nmetadata:\n loopId: Not_Kebab')), + PromptError, + ) + }) +}) diff --git a/packages/ai-autopilot/src/prompts/parse.ts b/packages/ai-autopilot/src/prompts/parse.ts new file mode 100644 index 0000000..0c8f69a --- /dev/null +++ b/packages/ai-autopilot/src/prompts/parse.ts @@ -0,0 +1,54 @@ +import { parseSkillManifest } from '@gemstack/ai-skills' +import type { Prompt } from './types.js' + +/** Thrown when a prompt bundle's metadata is malformed. */ +export class PromptError extends Error { + constructor(message: string) { + super(`[ai-autopilot] ${message}`) + this.name = 'PromptError' + } +} + +const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +function str(meta: Record, key: string): string | undefined { + const v = meta[key] + return typeof v === 'string' && v.trim() ? v.trim() : undefined +} + +/** + * Parse a prompt bundle (a `SKILL.md`-shaped markdown file) into a {@link Prompt}. + * + * Reuses `@gemstack/ai-skills`' frontmatter parser, then reads the prompt-specific + * fields out of `metadata`: `title`, `loopId` (the dispatch id; defaults to the + * manifest name), `passes` (positive integer, default 1), and `event`. + */ +export function parsePrompt(markdown: string, source?: string): Prompt { + const { manifest, instructions } = parseSkillManifest(markdown, source) + if (!instructions.trim()) throw new PromptError(`prompt "${manifest.name}" has an empty body`) + + const meta = manifest.metadata ?? {} + const id = str(meta, 'loopId') ?? manifest.name + if (!KEBAB.test(id)) throw new PromptError(`prompt "${manifest.name}" id must be kebab-case: ${JSON.stringify(id)}`) + + let passes = 1 + if (meta.passes !== undefined) { + if (!Number.isInteger(meta.passes) || (meta.passes as number) < 1) { + throw new PromptError(`prompt "${manifest.name}" passes must be a positive integer, got ${meta.passes}`) + } + passes = meta.passes as number + } + + const event = str(meta, 'event') + + return Object.freeze({ + id, + name: manifest.name, + title: str(meta, 'title') ?? manifest.name, + description: manifest.description, + instructions, + passes, + ...(event ? { event } : {}), + appliesTo: Object.freeze([...(manifest.appliesTo ?? [])]), + }) +} diff --git a/packages/ai-autopilot/src/prompts/types.ts b/packages/ai-autopilot/src/prompts/types.ts new file mode 100644 index 0000000..0791ac9 --- /dev/null +++ b/packages/ai-autopilot/src/prompts/types.ts @@ -0,0 +1,31 @@ +/** + * The built-in prompts library — the stack-aware prompt *bodies* the loop + * dispatches, shipped as data so they are turnkey and the community can improve + * them with a PR. + * + * A {@link Prompt} is a parsed markdown bundle: frontmatter (name, description, + * and `metadata` for title / loop id / passes / event) plus the instructions + * body. The bodies live as `.md` files under the package's `prompts/` directory, + * loaded at runtime; nothing here is executable, so a non-core contributor edits + * prose, not code. Persona (#98) is the *role*; a prompt is the *task*. + */ + +/** A parsed, ready-to-use prompt bundle. */ +export interface Prompt { + /** Dispatch id the loop references (kebab-case); `metadata.loopId` or `name`. */ + readonly id: string + /** The bundle's manifest name (its file/frontmatter name). */ + readonly name: string + /** Human title for display (`metadata.title`, falling back to the name). */ + readonly title: string + /** One-line summary from frontmatter. */ + readonly description: string + /** The markdown instructions body. */ + readonly instructions: string + /** Fresh-context passes the loop should run this prompt for. Default 1. */ + readonly passes: number + /** The loop event kind this prompt belongs to, when it targets one. */ + readonly event?: string + /** Stack hints (package names / globs) the prompt applies to. */ + readonly appliesTo: readonly string[] +}