framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
191 lines • 11.9 kB
JavaScript
import { renderTemplate } from './prompt-template.js';
import { DATA_BRANCH_PROTOCOL, SYSTEM_PROMPT, TICKETING_FORMAT, TODO_FORMAT } from './prompts.generated.js';
import { AWAIT_PROTOCOL, BROWSER_PROTOCOL, HANDS_OFF_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js';
// No Node imports here, deliberately. This module composes the prompt and the
// dashboard renders it in the browser (#520), so reading the user's SYSTEM.md off
// disk lives in `system-prompt-file.ts` instead. Keep it that way: one `node:fs`
// import here puts `node:fs` in the browser bundle.
/**
* The system prompt (#326), verbatim, as a template. It supersedes the
* anti-lazy-pill (#297/#301) it grew out of: the prompt is analyzed first — an
* ambiguous one becomes a ranked `showChoices()` list, a
* large scope becomes a PLAN file to approve, a very large one also spins off a TODO
* backlog (consumed by the backlog loop, #323), the work moves onto its own
* `tf-<session>` branch before the first change, and the alternatives flow
* rates problem "variability" before code is written.
*
* Two layers make it executable:
* - `${{ ... }}` fragments are JS evaluated against a {@link TfContext} (#350).
* - The trailing `# User prompt` section is the user-prompt slot; use
* {@link renderSystemPrompt} to render and split the two halves.
*
* The `<SHOW_*>` / `<AWAIT>` macros are interpreted by the agent itself (Rom's
* call on #326); the await protocol (#337/#339) pins how the stop-signal is
* emitted so the turn-boundary gates can detect it.
*
* The text lives in `prompts/system_prompt.md` (#551), not here. It is Rom's living
* doc: change it on #326 first, then sync the markdown.
*/
export const SYSTEM_PROMPT_TEMPLATE = SYSTEM_PROMPT;
/** The neutral context used when a caller has none: an empty prompt. */
const DEFAULT_TF = { prompt: '' };
// The knowledge base lives under `knowledge-base/` (#683): one file per kind rather than a
// single flat doc, so the agent folds each learning back into the right file.
const DECISIONS_DOC = { path: 'knowledge-base/DECISIONS.md', comment: 'decisions taken, and why' };
const FACTS_DOC = { path: 'knowledge-base/FACTS.md', comment: 'non-obvious facts relevant to the project' };
const INSIGHTS_DOC = { path: 'knowledge-base/INSIGHTS.md', comment: 'insights relevant to the project' };
/**
* The business-knowledge docs (#537): what the repo has learned about itself, which the
* agent both reads at the start of an agent and folds new knowledge back into at merge. The
* on-before-mergeable prompt's `## Business knowledge` section names this exact set, so the
* agent is never told to read one set of files and update another (pinned by a test). A
* subset of {@link CONTEXT_DOCS}.
*/
export const BUSINESS_KNOWLEDGE_DOCS = [DECISIONS_DOC, FACTS_DOC, INSIGHTS_DOC];
/**
* The two file-format specs, carried in the agent's own system channel (#1163).
*
* Two of the {@link CONTEXT_DOCS} have a shape the agent has to follow: `tickets/**.md` and
* `TODO_AGENTS.md`. The #674 call is that their spec ships *inside the package* rather than being
* materialized into the user's repo, so a change to the format rides with the package version
* instead of going stale in a committed file. What that call left open is how the agent reads it,
* and the answer was a path — `node_modules/@gemstack/the-framework/prompts/*.md`.
*
* That path only resolves when the framework happens to be a root dependency of the repo it is
* working on. It is not there for a global or npx install, not there in a fresh worktree before an
* install, and not there in this repo at all, where the framework is a workspace package rather
* than a dependency of the root. So the spec was unopenable, and the two files it governs drifted
* from it (#1163/#1162) with nothing to notice.
*
* Carrying the content keeps what #674 wanted — the spec still rides with the package version,
* and nothing is written into the user's repo — and makes it something the agent has already read
* rather than something it has to go and find. It is framework-authored prompt content, so like
* the context bullets it goes with the built-in prompt and `--vanilla` drops it.
*/
const CONTEXT_FORMATS = [TICKETING_FORMAT, TODO_FORMAT, DATA_BRANCH_PROTOCOL];
/** The heading each spec opens with, so a bullet can name the section that answers it.
* Must track the spec's own H1 — #1420 renamed it "Ticketing format", and a bullet naming a
* section that does not exist sends the agent to follow a format it cannot find. */
const TICKET_FORMAT_HEADING = 'Ticketing format';
const TODO_FORMAT_HEADING = 'TODO_AGENTS.md';
/** Where both of those live and how they are edited (#1582): the data branch, via its protocol. */
const DATA_BRANCH_HEADING = 'The data branch';
/**
* Everything the agent keeps in context when it starts (#683), which
* {@link systemPromptBlock} renders as the `Context:` bullets. A superset of
* {@link BUSINESS_KNOWLEDGE_DOCS}: it adds `GOAL.md`, `BUSINESS_LOGIC.md`, and the
* roadmap/queue/history pointers the agent reads but does *not* fold knowledge back into —
* `tickets/**.md` (the potential work, whose file shape is the `Ticketing format` spec, #684/#674)
* and the `TODO_AGENTS.md` task queue (whose shape is the `TODO_AGENTS.md` spec, #880). Repo-root
* paths, because that is the agent's cwd. README is left out: a repo's own `README.md` already
* covers the overview.
*
* The two format-bearing bullets point at {@link CONTEXT_FORMATS}, which travels in the same
* channel, rather than at a file the agent has to go and open (#1163).
*/
export const CONTEXT_DOCS = [
DECISIONS_DOC,
{ path: 'GOAL.md', comment: 'the goal of the project (long-term direction, scope, non-scope, ...)' },
// The codebase's business logic, documented (#683). Root-level beside GOAL.md, per the OP. A
// pointer the agent reads, not a doc it folds knowledge back into at merge, so it stays out of
// BUSINESS_KNOWLEDGE_DOCS.
{ path: 'BUSINESS_LOGIC.md', comment: 'codebase business logic' },
FACTS_DOC,
INSIGHTS_DOC,
// What the market looks like (#694): written by the [Market research] preset and read by the
// follow-up that turns it into tickets. A pointer the agent reads, not a doc it folds knowledge
// back into, so it stays out of BUSINESS_KNOWLEDGE_DOCS.
{ path: 'knowledge-base/MARKET_RESEARCH.md', comment: 'the market the project competes in' },
// The catch-all (#683): any other file the agent parks under knowledge-base/.
{ path: 'knowledge-base/**.md', comment: 'more files holding knowledge related to the project' },
{
path: 'tickets/**.md',
comment: `things to potentially work on; format: the "${TICKET_FORMAT_HEADING}" section below; lives on the data branch — read/write per the "${DATA_BRANCH_HEADING}" section below`,
},
{
path: 'TODO_AGENTS.md',
comment: `the AI task queue; format: the "${TODO_FORMAT_HEADING}" section below; lives on the data branch — read/write per the "${DATA_BRANCH_HEADING}" section below`,
},
];
const USER_PROMPT_HEADING = '\n# User prompt\n';
/**
* Render the built-in system prompt against a {@link TfContext} and split it at
* the `# User prompt` heading. The split happens on the *template*, before
* rendering, so a user prompt that itself contains the heading can never move
* the boundary.
*/
export function renderSystemPrompt(tf = DEFAULT_TF) {
const at = SYSTEM_PROMPT_TEMPLATE.indexOf(USER_PROMPT_HEADING);
const systemHalf = at === -1 ? SYSTEM_PROMPT_TEMPLATE : SYSTEM_PROMPT_TEMPLATE.slice(0, at);
const userHalf = at === -1 ? '${{tf.prompt}}' : SYSTEM_PROMPT_TEMPLATE.slice(at + USER_PROMPT_HEADING.length);
return {
system: renderTemplate(systemHalf, { tf }).trim(),
user: renderTemplate(userHalf, { tf }).trim(),
};
}
/**
* Compose the system-prompt block injected into every prompt: the built-in #326
* prompt (unless removed) followed by the user's own prompt. Additive, so a repo
* can keep the built-in *and* add its instructions, remove it and keep only its
* own, or leave both off. Returns `''` when there is nothing to inject. Only the
* template's system half lands here; the user-prompt half is the caller's to
* deliver (see {@link renderSystemPrompt}).
*/
export function systemPromptBlock(opts = {}) {
const parts = [];
// The #439 context line goes first, so it frames whatever prompt follows (or stands
// alone under `--vanilla`, where there is no built-in prompt to frame).
const dirs = opts.context?.map(d => d.trim()).filter(Boolean) ?? [];
// The context docs ride with the built-in prompt, not with the user's dirs: they are
// ours, and `--vanilla` means no framework-authored prompt at all (#547 rule 3). They
// render as commented bullets under the dirs (#559), so the agent sees what each is for.
// Vanilla drops both the framework's context docs and its built-in prompt; one boolean drives
// both so they can't fall out of step.
const includeBuiltin = opts.vanilla !== true;
const docs = includeBuiltin ? CONTEXT_DOCS : [];
if (dirs.length || docs.length) {
const head = `Context:${dirs.length ? ` ${dirs.join(', ')}` : ''}`;
const bullets = docs.map(d => `- \`${d.path}\` (${d.comment})`);
parts.push([head, ...bullets].join('\n'));
}
// The formats the two format-bearing bullets name, right under the list that names them (#1163).
if (includeBuiltin)
parts.push(...CONTEXT_FORMATS, renderSystemPrompt(opts.tf).system);
const user = opts.user?.trim();
if (user)
parts.push(user);
return parts.join('\n\n');
}
/**
* Assemble an agent's full system channel — the single place it is composed (#501), so the
* build path and the direct-prompt path, before D2 collapsed them into one {@link runAgent}
* cannot drift. That drift is exactly what dropped the session-action (#326) layer from `--vanilla`
* builds (#500): the two sites each inlined the composition and one nested the protocols
* inside the built-in-prompt branch.
*
* Order is fixed: the built-in system prompt (#326) block (context / built-in prompt / user SYSTEM.md)
* first, then the emit protocols. Nothing else is appended — a build agent's system channel
* is exactly this (#547), which is what lets the dashboard show the whole prompt before an agent
* starts (#520). The protocols are otherwise unconditional — they are the *emit contract* (how
* the agent signals an awaited choice and the setSessionName()/setReadyForMerge() lifecycle),
* not prompt content — so the agent needs them even with the built-in prompt off (`--vanilla`).
*
* The one exception is transparent mode (#625): there is no framework behavior to signal to, so
* the whole channel is empty and the agent runs as raw `claude -p`.
*/
export function composeAgentSystem(opts = {}) {
if (opts.transparent)
return '';
const promptBlock = systemPromptBlock(opts);
// The browser section rides with the protocols, not with the built-in prompt: like them it
// describes what this agent can do, so `--vanilla` (no framework prompt) still gets it — the
// tools are there either way.
// Ahead of the protocols, so the signal protocol stays the last thing in the channel (#547).
const browser = opts.browser ? [BROWSER_PROTOCOL] : [];
// Right after the await protocol it amends (#1234): the gates are taught, then declared
// unavailable, which keeps the emit contract intact for the parser while telling the agent
// not to reach for it. The signal protocol stays last (#547).
const handsOff = opts.handsOff ? [HANDS_OFF_PROTOCOL] : [];
return [...(promptBlock ? [promptBlock] : []), ...browser, AWAIT_PROTOCOL, ...handsOff, SIGNAL_PROTOCOL].join('\n\n');
}
//# sourceMappingURL=system-prompt.js.map