/** * Tailor — compose accepted rules + stack + templates into a final * `constitution.md` string via a single LLM call. Validates the LLM output * against a strict schema (must include `## Maina Workflow` and `## File * Layout` sections verbatim). Retries once on violation, then falls through * to a deterministic generic builder. */ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Rule } from "./adopt"; import { formatProvenanceComment } from "./adopt"; import type { StackContext } from "./context"; const __dirname = dirname(fileURLToPath(import.meta.url)); const WORKFLOW_TEMPLATE_PATH = join( __dirname, "templates", "workflow-section.md", ); const FILE_LAYOUT_TEMPLATE_PATH = join( __dirname, "templates", "file-layout-section.md", ); // ── Types ──────────────────────────────────────────────────────────────────── export interface TailorInput { acceptedRules: Rule[]; stack: StackContext; toplevelDirs: string[]; languages: string[]; /** * Injected by callers — receives the composed prompt and returns model * output text. The production caller wires this to `resolveSetupAI()`, * tests inject a stub. */ generate: (prompt: string) => Promise; } export interface TailorOutput { text: string; degraded: boolean; reason?: string; } export type ValidateResult = { ok: true } | { ok: false; reason: string }; // ── Public API ─────────────────────────────────────────────────────────────── export async function tailorConstitution( input: TailorInput, ): Promise { const prompt = buildPrompt(input); // Attempt 1 const first = await tryGenerate(input.generate, prompt); if (first !== null) { const validation = validateConstitution(first); if (validation.ok) { return { text: first, degraded: false }; } // Retry once with a sterner instruction. const sternPrompt = `${prompt}\n\nIMPORTANT: your previous output was rejected because: ${validation.reason}. You MUST include the sections \`## Maina Workflow\` and \`## File Layout\` verbatim.`; const second = await tryGenerate(input.generate, sternPrompt); if (second !== null) { const second_validation = validateConstitution(second); if (second_validation.ok) { return { text: second, degraded: false }; } } } // Fall through. return { text: buildGenericConstitutionFromInput(input), degraded: true, reason: "schema_violation", }; } export function validateConstitution(text: string): ValidateResult { if (!/^##\s+Maina Workflow\b/m.test(text)) { return { ok: false, reason: "missing `## Maina Workflow` section" }; } if (!/^##\s+File Layout\b/m.test(text)) { return { ok: false, reason: "missing `## File Layout` section" }; } // The workflow template is constant — an LLM that paraphrased it instead // of substituting it verbatim is violating the spec. We compare the // extracted body (content after the `## Maina Workflow` heading up to the // next top-level `##`) against the template's body, normalising whitespace // so trailing blanks or re-wrapped lines don't false-fail. const expectedBody = normaliseSectionText( stripFirstHeading(renderWorkflowSection()), ); const workflowSection = extractSection(text, "Maina Workflow"); if (workflowSection === null || workflowSection.trim().length === 0) { return { ok: false, reason: "`## Maina Workflow` section is empty" }; } if (normaliseSectionText(workflowSection) !== expectedBody) { return { ok: false, reason: "`## Maina Workflow` section was paraphrased, not substituted verbatim", }; } return { ok: true }; } function stripFirstHeading(section: string): string { // `\r?\n` so CRLF checkouts (Windows) strip the same way as LF — otherwise // the heading line lingers in `expectedBody` and validation false-fails. return section.replace(/^##\s+[^\r\n]*\r?\n/, ""); } function normaliseSectionText(text: string): string { return text .replace(/\r\n/g, "\n") .split("\n") .map((l) => l.trimEnd()) .filter((l) => l.length > 0) .join("\n") .trim(); } function extractSection(text: string, heading: string): string | null { // JavaScript RegExp has no `\Z` end-of-input anchor — what the earlier // pattern used as `\Z` was being parsed as a literal `Z`. We normalise // the input first and append a sentinel `\n## __END__` so the lookahead // always has the next heading to terminate on, even when the section we // want is the last one in the document. // The heading is regex-escaped to close the (currently-theoretical) regex // injection vector flagged by ast-grep — a future caller passing // metacharacters would otherwise break the pattern. const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const normalised = text.replace(/\r\n/g, "\n"); const sentinel = `${normalised}\n## __END__`; const re = new RegExp( `^##\\s+${escapedHeading}\\b[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s)`, "m", ); const m = re.exec(sentinel); return m ? (m[1] ?? null) : null; } export function renderWorkflowSection(): string { return readFileSync(WORKFLOW_TEMPLATE_PATH, "utf-8").trimEnd(); } export function renderFileLayoutSection(input: { languages: string[]; toplevelDirs: string[]; }): string { const template = readFileSync(FILE_LAYOUT_TEMPLATE_PATH, "utf-8"); const langs = input.languages.length > 0 ? input.languages.join(", ") : "(none detected)"; const dirs = input.toplevelDirs.length > 0 ? input.toplevelDirs.map((d) => `- \`${d}/\``).join("\n") : "- (no notable top-level directories detected)"; return template .replaceAll("{languages}", langs) .replaceAll("{toplevelDirs}", dirs) .trimEnd(); } /** * Deterministic constitution builder used when the LLM is unavailable or * its output fails validation. Always includes both required sections. */ export function buildGenericConstitutionFromInput(input: TailorInput): string { const langs = input.languages.length > 0 ? input.languages.join(", ") : "your stack"; const linters = input.stack.linters.length > 0 ? input.stack.linters.join(", ") : "your linter"; const tests = input.stack.testRunners.length > 0 ? input.stack.testRunners.join(", ") : "your test runner"; const pm = input.stack.packageManager; const sections: string[] = [ "# Project Constitution", "", "> Generated by `maina setup` — refine this after the wizard finishes.", "", "## Stack", "", `- Languages: ${langs}`, `- Package manager: ${pm}`, `- Linters: ${linters}`, `- Test runners: ${tests}`, "", ]; if (input.acceptedRules.length > 0) { sections.push("## Rules", ""); for (const rule of input.acceptedRules) { sections.push(`- ${rule.text} ${formatProvenanceComment(rule)}`); } sections.push(""); } sections.push( "## Principles", "", "1. **Tests first.** Write a failing test, then make it pass.", "2. **Small, reviewable diffs.** Prefer many small PRs over one large PR.", "3. **Conventional commits.** `type(scope): subject` — keep history greppable.", "4. **No silent failures.** Use `Result` or explicit error returns; never swallow exceptions.", "5. **Lint and type-check before push.** Block on errors locally so CI stays green.", "", ); sections.push(renderWorkflowSection()); sections.push(""); sections.push( renderFileLayoutSection({ languages: input.languages, toplevelDirs: input.toplevelDirs, }), ); sections.push(""); return `${sections.join("\n")}\n`; } // ── Prompt composition ─────────────────────────────────────────────────────── function buildPrompt(input: TailorInput): string { const parts: string[] = []; parts.push( "You are the Maina setup assistant. Produce a project `constitution.md` by merging the accepted rules below with the detected stack. DO NOT invent new rules — only use what you are given or what is clearly implied by the stack. Output markdown only, no preamble.", ); parts.push(""); parts.push("REQUIRED SECTIONS (in order):"); parts.push("1. `# Project Constitution`"); parts.push("2. `## Stack` — the detected stack, verbatim."); parts.push( "3. `## Rules` — each accepted rule as a bullet. Preserve the provenance comment on lines that have one.", ); parts.push("4. `## Maina Workflow` — copied verbatim from the template."); parts.push( "5. `## File Layout` — copied verbatim from the template, with detected dirs substituted.", ); parts.push(""); parts.push("### Detected stack"); parts.push(""); parts.push("```json"); parts.push(JSON.stringify(summariseStack(input.stack), null, 2)); parts.push("```"); parts.push(""); parts.push("### Accepted rules"); parts.push(""); for (const rule of input.acceptedRules) { parts.push(`- ${rule.text} ${formatProvenanceComment(rule)}`); } if (input.acceptedRules.length === 0) { parts.push( "(No rules adopted or confirmed — emit an empty Rules section.)", ); } parts.push(""); parts.push("### Workflow section (paste verbatim)"); parts.push(""); parts.push(renderWorkflowSection()); parts.push(""); parts.push("### File-layout section (paste verbatim)"); parts.push(""); parts.push( renderFileLayoutSection({ languages: input.languages, toplevelDirs: input.toplevelDirs, }), ); parts.push(""); parts.push( "Remember: the two template sections above MUST appear in your output literally (headings included). Do NOT paraphrase, reorder, or split them.", ); return parts.join("\n"); } function summariseStack(stack: StackContext): Record { return { languages: stack.languages, frameworks: stack.frameworks, packageManager: stack.packageManager, buildTool: stack.buildTool, linters: stack.linters, testRunners: stack.testRunners, cicd: stack.cicd, }; } async function tryGenerate( generate: (prompt: string) => Promise, prompt: string, ): Promise { try { const text = await generate(prompt); return typeof text === "string" && text.trim().length > 0 ? text : null; } catch { return null; } }