UNPKG

paqad-ai

Version:

Spec-driven development framework — AI agents that think before they type

443 lines (437 loc) • 15.2 kB
import { createRequire as __paqadCreateRequire } from 'module'; const require = __paqadCreateRequire(import.meta.url); import { isSafeSessionId, stageFromAgentType } from "./chunk-MSV5BZFH.js"; import { PAQAD_STAGE_HELPER_MATCHER, RECIPE_BUDGET_CHARS, fitRecipe, resolveRecipe } from "./chunk-KXN6TRQP.js"; import { isValidSlug, levenshtein, normaliseSlug } from "./chunk-Y3O45QYV.js"; import { appendFeatureStageRow, currentFeature, readFeatureStageUnit, recordedLaneOrFull } from "./chunk-5M55PDWL.js"; import { RECIPE_INJECTED_KIND, readFeatureRecord } from "./chunk-7TBVBCDH.js"; import { splitFrontMatter } from "./chunk-RJMJYPHL.js"; import { MANDATORY_STAGE_AGENTS, STAGE_AGENT_HARD_RULES, agentNameForStage, loadFeatureDevelopmentPolicy, moduleAttributionMode, stageNoteLines } from "./chunk-NCYDV2N5.js"; import { readTextIfExistsSync } from "./chunk-ACJQW7SB.js"; import { keepingLedgerSessionCache, resolveHookSession } from "./chunk-SG6LXLDH.js"; import { featureFilePath } from "./chunk-AZLA53A6.js"; import { init_esm_shims } from "./chunk-IDFRWBMN.js"; // src/stage-isolation/stage-brief.ts init_esm_shims(); import { existsSync, readdirSync } from "fs"; import { join } from "path"; // src/module-decisions/extractor.ts init_esm_shims(); var PATTERNS = [ { label: "ticket-header-module", re: /(?:^|\n)\s*Module:\s*([A-Za-z0-9][A-Za-z0-9 _-]{0,40})\s*(?:\n|$)/g }, { label: "ticket-header-component", re: /(?:^|\n)\s*Component:\s*([A-Za-z0-9][A-Za-z0-9 _-]{0,40})\s*(?:\n|$)/g }, { label: "ticket-header-area", re: /(?:^|\n)\s*Area:\s*([A-Za-z0-9][A-Za-z0-9 _-]{0,40})\s*(?:\n|$)/g }, { label: "ticket-header-subsystem", re: /(?:^|\n)\s*Subsystem:\s*([A-Za-z0-9][A-Za-z0-9 _-]{0,40})\s*(?:\n|$)/g }, { label: "inline-module-slug", re: /\bmodule:\s*([a-z0-9][a-z0-9-]{0,40})\b/g }, { label: "new-module-name", re: /\bnew module\s+([A-Z][A-Za-z0-9_-]*(?:\s+[A-Z][A-Za-z0-9_-]*){0,4})\b/g }, { label: "in-the-module", re: /\bin the\s+([A-Za-z0-9][A-Za-z0-9 _-]{0,30}?)\s+module\b/g } ]; function deriveDisplayName(slug, raw) { const cleaned = raw.trim(); if (cleaned.length > 0 && cleaned.length <= 60) { return cleaned; } return slug.split("-").map((p) => p.length === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)).join(" "); } function findNearCollision(slug, existing, bound) { let bestSlug = null; let bestDist = bound + 1; for (const cand of existing) { if (cand === slug) continue; const d = levenshtein(slug, cand, bound); if (d <= bound && d < bestDist) { bestDist = d; bestSlug = cand; } } return bestSlug; } function classify(slug, existing, bound) { if (existing.has(slug)) { return { kind: "exact-match", collision_with: slug }; } const near = findNearCollision(slug, Array.from(existing), bound); if (near !== null) { return { kind: "near-collision", collision_with: near }; } return { kind: "unknown", collision_with: null }; } function extractCandidates(opts) { const { prompt, existingSlugs } = opts; const bound = opts.nearCollisionDistance ?? 2; const existing = new Set(existingSlugs); const seenSlugs = /* @__PURE__ */ new Set(); const out = []; for (const { label, re } of PATTERNS) { re.lastIndex = 0; let match; while ((match = re.exec(prompt)) !== null) { const raw = match[1]; if (raw === void 0) continue; const slug = normaliseSlug(raw); if (slug === null || !isValidSlug(slug)) continue; if (seenSlugs.has(slug)) continue; seenSlugs.add(slug); const { kind, collision_with } = classify(slug, existing, bound); out.push({ slug, display_name: deriveDisplayName(slug, raw), kind, collision_with, pattern: label, excerpt: match[0].trim().slice(0, 200) }); } } return out; } function candidatesNeedingDecision(candidates) { return candidates.filter((c) => c.kind !== "exact-match"); } // src/stage-isolation/stage-brief.ts var STAGE_BRIEF_MAX_CHARS = 9e3; var STAGE_BRIEF_ENTRY_LINE = "paqad records your entry check when you start; if an edit is blocked, create the marker it names."; var STAGE_BRIEF_NO_CHANGE_LINE = "No active change was found; ask nothing, use the change ref the orchestrator gave you."; function stageBriefMoreLine(stage) { return `This brief was shortened. Run \`paqad-ai stage brief ${stage} --full\` to see every instruction for this stage.`; } var PREVIOUS_PILLARS = { planning: [], specification: ["plan"], development: ["plan", "specification"], review: ["specification"], checks: ["review"], documentation_sync: ["review", "checks"] }; var CODE_EDITING_STAGES = /* @__PURE__ */ new Set(["development"]); var STAGE_HELPER_PATTERN = new RegExp(PAQAD_STAGE_HELPER_MATCHER); function stageHelperStage(agentType) { if (typeof agentType !== "string" || !STAGE_HELPER_PATTERN.test(agentType)) { return null; } return stageFromAgentType(agentType); } function stageDef(stage) { const def = MANDATORY_STAGE_AGENTS.find((candidate) => candidate.ledgerStage === stage); if (!def) { throw new Error(`unknown stage "${stage}"`); } return def; } function existingPillars(projectRoot, dirName, stage) { return PREVIOUS_PILLARS[stage].map((file) => featureFilePath(dirName, file)).filter( (path) => existsSync(join(projectRoot, path)) ); } function changeSection(projectRoot, sessionId, stage) { const dirName = currentFeature(projectRoot, sessionId); if (!dirName) { return ["## Your change", "", STAGE_BRIEF_NO_CHANGE_LINE, ""]; } const pillars = existingPillars(projectRoot, dirName, stage); return [ "## Your change", "", `- Change: \`${dirName}\``, `- Lane: ${recordedLaneOrFull(projectRoot, sessionId, dirName)}`, pillars.length === 0 ? "- Previous pillar files: none" : "- Previous pillar files:", ...pillars.map((path) => ` - \`${path}\``), "" ]; } function documentedModules(projectRoot) { try { return readdirSync(join(projectRoot, "docs/modules"), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name); } catch { return []; } } function changeRequestText(projectRoot, dirName) { const title = (readFeatureRecord(projectRoot, dirName)?.title ?? "").replace(/-/g, " "); const request = readTextIfExistsSync(join(projectRoot, featureFilePath(dirName, "request"))); return [title, request === null ? "" : splitFrontMatter(request).body].join("\n"); } function attributedModuleReads(projectRoot, sessionId) { const dirName = currentFeature(projectRoot, sessionId); if (!dirName) return []; const slugs = documentedModules(projectRoot); if (slugs.length === 0) return []; return extractCandidates({ prompt: changeRequestText(projectRoot, dirName), existingSlugs: slugs }).filter((candidate) => candidate.kind === "exact-match").map((candidate) => `docs/modules/${candidate.slug}/**`); } function stageReads(stage, projectRoot, sessionId, read) { if (stage !== "planning") return [...read]; return [.../* @__PURE__ */ new Set([...read, ...attributedModuleReads(projectRoot, sessionId)])]; } function bulletList(items, empty) { return items.length === 0 ? [empty] : items.map((item) => `- ${item}`); } function safeAgentId(agentId) { if (typeof agentId !== "string") return null; const trimmed = agentId.trim(); return isSafeSessionId(trimmed) ? trimmed : null; } function stageCommandLines(stage, sessionId, agentId) { const prefix = agentId === null ? `SE_SESSION=${sessionId} paqad-ai` : `SE_SESSION=${sessionId} SE_AGENT_ID=${agentId} paqad-ai`; const agent = agentNameForStage(stage); const begins = CODE_EDITING_STAGES.has(stage) ? " It records the start and loads the rules before your first source edit." : " It records the start."; return [ `- \`${prefix} stage begin ${stage} --agent ${agent}\` first, and no single start command.${begins} It prints one JSON object with the request, the previous pillars and the paths you need.`, "- Do the stage work, then write its rigid artifact with the stage verb, or make the edits", " for a mutation stage.", `- \`${prefix} stage end ${stage} --agent ${agent} --artifact <the-rigid-file>\` (mutation stages need no artifact).` ]; } function render(parts, truncated) { return [ ...parts.head, "## Read first", "", ...bulletList(parts.read, "Nothing extra to read for this stage."), "", "## Stage instructions", "", ...bulletList(parts.instructions, "No extra instructions for this stage."), ...truncated ? ["", stageBriefMoreLine(parts.stage)] : [], "", ...parts.tail ].join("\n"); } function fitToBudget(parts) { let read = [...parts.read]; let instructions = [...parts.instructions]; let truncated = false; let text = render(parts, truncated); while (text.length > STAGE_BRIEF_MAX_CHARS && (instructions.length > 0 || read.length > 0)) { if (instructions.length > 0) { instructions = instructions.slice(0, -1); } else { read = read.slice(0, -1); } truncated = true; text = render({ ...parts, read, instructions }, truncated); } return text; } function introLines(stage, role, resumed) { if (resumed) { return [ `You are the paqad **${stage}** stage agent, resumed for the same feature-development change.`, `Your job: ${role}.`, "", `This is the ${stage} stage brief, so you do not need to load AGENT-BOOTSTRAP.md or`, `AGENT-ROUTER.md. ${STAGE_BRIEF_ENTRY_LINE}` ]; } return [ `You are the paqad **${stage}** stage agent. You run one stage of one feature-development`, `change in your own isolated context and then return. Your job: ${role}.`, "", "paqad added this stage brief to your context when you started, so you do not need to load", `AGENT-BOOTSTRAP.md or AGENT-ROUTER.md. ${STAGE_BRIEF_ENTRY_LINE}` ]; } function exportLines(sessionId, agentId) { const session = [ `- **Export \`SE_SESSION=${sessionId}\`** for every \`paqad-ai\` command you run, so every row`, " and artifact you write is recorded under the one change." ]; if (agentId === null) return session; return [ ...session, `- **Export \`SE_AGENT_ID=${agentId}\`** with it, so a stage you start for this change is`, " recorded as yours." ]; } function buildStageBrief(stage, projectRoot, sessionId, options = {}) { const def = stageDef(stage); if (!isSafeSessionId(sessionId)) { throw new Error("unsafe session id"); } const agentId = safeAgentId(options.agentId); const fullPolicy = loadFeatureDevelopmentPolicy(projectRoot).policy; const policy = fullPolicy.stages[stage]; const notes = [ ...stageNoteLines(stage), ...stage === "planning" ? [`Module attribution: ${moduleAttributionMode(fullPolicy)}`] : [] ]; const head = [ ...introLines(stage, def.role, options.resumed === true), "", ...notes.length === 0 ? [] : [...notes, ""], ...changeSection(projectRoot, sessionId, stage) ]; const tail = [ "## Hard rules", "", ...STAGE_AGENT_HARD_RULES, ...exportLines(sessionId, agentId), "", "## Commands", "", ...stageCommandLines(stage, sessionId, agentId) ]; const parts = { stage, head, read: stageReads(stage, projectRoot, sessionId, policy.read), instructions: policy.instructions, tail }; return options.full === true ? render(parts, false) : fitToBudget(parts); } function stageBriefHookOutput(brief) { return JSON.stringify({ hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: brief } }); } function resolveBriefSession(projectRoot, payload, env = process.env) { for (const raw of [payload.session_id, env.SE_SESSION, env.CLAUDE_SESSION_ID]) { if (typeof raw === "string" && raw.trim() !== "" && !isSafeSessionId(raw.trim())) { throw new Error("unsafe session id"); } } return resolveHookSession(projectRoot, payload, env); } function overBudgetBrief(stage, sessionId, agentId) { return [ `You are the paqad **${stage}** stage agent. Your stage brief is too long to send here.`, `Run \`paqad-ai stage brief ${stage}\` to print every instruction line, uncut.`, "", "## Commands", "", ...stageCommandLines(stage, sessionId, safeAgentId(agentId)) ].join("\n"); } function seedStageAgentDetailed(stage, projectRoot, payload, env = process.env) { return keepingLedgerSessionCache(projectRoot, () => { const sessionId = resolveBriefSession(projectRoot, payload, env); const notes = []; let brief = buildStageBrief(stage, projectRoot, sessionId, { agentId: payload.agent_id, full: true }); if (brief.length > RECIPE_BUDGET_CHARS) { notes.push( `the ${stage} brief passed the ${RECIPE_BUDGET_CHARS} character limit, so it was sent as a pointer` ); brief = overBudgetBrief(stage, sessionId, payload.agent_id); } let injected = null; try { const recipe = resolveRecipe(stage); const fitted = fitRecipe(brief.length, recipe); if (fitted === null) { notes.push(`the ${recipe.name} recipe did not fit beside the brief`); } else { brief = `${brief} ${fitted.text}`; injected = { recipe: recipe.name, recipe_hash: fitted.hash, recipe_root: recipe.root, truncated: fitted.truncated }; } } catch (error) { notes.push(error.message); } return { output: stageBriefHookOutput(brief), injected, notes }; }); } function seedStageAgent(stage, projectRoot, payload, env = process.env) { return seedStageAgentDetailed(stage, projectRoot, payload, env).output; } function recordRecipeInjected(projectRoot, stage, payload, injected, env = process.env) { const sessionId = resolveBriefSession(projectRoot, payload, env); const dirName = currentFeature(projectRoot, sessionId); if (dirName === null) return false; const agentId = safeAgentId(payload.agent_id); const alreadyDelivered = readFeatureStageUnit(projectRoot, dirName).some( (row) => row.kind === RECIPE_INJECTED_KIND && row.stage === stage && row.recipe_hash === injected.recipe_hash && agentId !== null && row.agent_id === agentId ); if (alreadyDelivered) return true; appendFeatureStageRow(projectRoot, sessionId, dirName, { kind: RECIPE_INJECTED_KIND, stage, agent: agentNameForStage(stage), ...agentId === null ? {} : { agent_id: agentId }, recipe: injected.recipe, recipe_hash: injected.recipe_hash, recipe_root: injected.recipe_root, ...injected.truncated ? { truncated: true } : {} }); return true; } export { extractCandidates, candidatesNeedingDecision, STAGE_BRIEF_MAX_CHARS, STAGE_BRIEF_ENTRY_LINE, STAGE_BRIEF_NO_CHANGE_LINE, stageBriefMoreLine, stageHelperStage, attributedModuleReads, safeAgentId, stageCommandLines, buildStageBrief, stageBriefHookOutput, resolveBriefSession, seedStageAgentDetailed, seedStageAgent, recordRecipeInjected };