|
| 1 | +// Section registry for director prompt assembly (CL-8685 render-style experiment). |
| 2 | +// |
| 3 | +// Every director system prompt is a preamble plus `#`/`##` sections. The |
| 4 | +// markdown arm renders sections as `## Title` (byte-identical to the shipped |
| 5 | +// prompts — the markdown path returns `formatDirectorSystemPrompt` untouched); |
| 6 | +// the xml arm renders those same sections as `<tag>body</tag>` with a tag |
| 7 | +// from the fixed vocabulary below. Tags wrap whole sections only: inline |
| 8 | +// prose, deeper sub-headings (`###` and below pass through verbatim), and the |
| 9 | +// identity header (no `#` headings, so it stays preamble) are never tagged. |
| 10 | +// |
| 11 | +// Switch: `CORBITS_PROMPT_RENDER_STYLE=markdown|xml` (default `markdown`). |
| 12 | +// The style resolves once per process and is cached, so the prompt-cache |
| 13 | +// prefix cannot split mid-session. Tests reset the cache via |
| 14 | +// `resetCachedPromptRenderStyleForTests`. |
| 15 | + |
| 16 | +import type { DirectorPackage } from "./types.js"; |
| 17 | +import { formatDirectorSystemPrompt } from "./identity.js"; |
| 18 | + |
| 19 | +export type PromptRenderStyle = "markdown" | "xml"; |
| 20 | + |
| 21 | +/** Environment variable selecting the director-prompt render style. */ |
| 22 | +export const PROMPT_RENDER_STYLE_ENV_VAR = "CORBITS_PROMPT_RENDER_STYLE"; |
| 23 | + |
| 24 | +// Fixed tag vocabulary: one tag per `#`/`##` section title shipped in |
| 25 | +// `src/agent/directors/*/package.ts`, normalized as `normalizeSectionTag` |
| 26 | +// does. Titles outside this set (future packages) fall back to `section`. |
| 27 | +export const PROMPT_SECTION_TAG_VOCABULARY = [ |
| 28 | + "acknowledgment", |
| 29 | + "actually_overall_assessment", |
| 30 | + "anti_cascade_stall_dig_diagnose", |
| 31 | + "architecture_recommendations_all_terrible", |
| 32 | + "architecture_review_criteria", |
| 33 | + "blockers", |
| 34 | + "build_a_shared_glossary", |
| 35 | + "build_gate", |
| 36 | + "capabilities", |
| 37 | + "cargo_cult_programming_patterns_you_re_missing", |
| 38 | + "case_template", |
| 39 | + "common_neckbeard_phrases", |
| 40 | + "corbits_report_shape", |
| 41 | + "critical_reminder", |
| 42 | + "cross_document_analysis", |
| 43 | + "design_in_report_workflow", |
| 44 | + "document_discovery", |
| 45 | + "document_types", |
| 46 | + "documents_not_found", |
| 47 | + "effort_scaling_implementation_orchestration", |
| 48 | + "error_handling", |
| 49 | + "execution_steps", |
| 50 | + "fetch_urls_primary_mounted", |
| 51 | + "final_recommendation", |
| 52 | + "findings", |
| 53 | + "findings_route_to_follow_ups_never_silent_retunes", |
| 54 | + "guidelines", |
| 55 | + "harness_consume_do_not_build_a_second_one", |
| 56 | + "hold_the_line_on_scope", |
| 57 | + "how_you_work", |
| 58 | + "if_communication_answer_directly", |
| 59 | + "if_implementation_diy_when_tiny_spawn_when_substantial", |
| 60 | + "if_orchestration_coordinate", |
| 61 | + "implement_and_test", |
| 62 | + "implementation_review_criteria", |
| 63 | + "incomplete_document_set", |
| 64 | + "insufferable_details_x_found", |
| 65 | + "insufferable_mode_default", |
| 66 | + "key_suggestions_all_terrible", |
| 67 | + "maddening_nitpicks_x_found", |
| 68 | + "malformed_documents", |
| 69 | + "neckbeard_perspective", |
| 70 | + "neckbeard_review_project_name", |
| 71 | + "neckbeard_review_project_name_maximum_pedantry_edition", |
| 72 | + "non_negotiables", |
| 73 | + "operator_updates_mandatory_while_fleet_is_live", |
| 74 | + "out_of_lane", |
| 75 | + "parent_tools", |
| 76 | + "paths", |
| 77 | + "peak_neckbeard_issues_x_found", |
| 78 | + "plan", |
| 79 | + "prerequisites", |
| 80 | + "product_review_criteria", |
| 81 | + "protocol_in_order_no_shortcuts", |
| 82 | + "push_back_on_the_vision_itself_gently", |
| 83 | + "recommendation", |
| 84 | + "remember", |
| 85 | + "report", |
| 86 | + "report_contract", |
| 87 | + "report_shape", |
| 88 | + "report_when_dispatched_as_a_worker", |
| 89 | + "reporting_back", |
| 90 | + "request_shape_implementation_orchestration_communication", |
| 91 | + "review_framework", |
| 92 | + "review_modes", |
| 93 | + "riff_first", |
| 94 | + "risk_prioritization", |
| 95 | + "rules", |
| 96 | + "sec_0_document_discovery", |
| 97 | + "sec_1_analyze_and_classify_input", |
| 98 | + "sec_2_route_and_deepen", |
| 99 | + "sec_3_update_document", |
| 100 | + "sec_4_cross_document_consistency_significant_only", |
| 101 | + "sec_5_gap_detection_significant_only", |
| 102 | + "sec_6_report", |
| 103 | + "session_initialization", |
| 104 | + "spawn_graph", |
| 105 | + "spawn_handoff", |
| 106 | + "stay_in_lane", |
| 107 | + "step_1_load_prerequisites", |
| 108 | + "step_2_discover_documents_and_code_when_asked", |
| 109 | + "step_3_determine_review_mode", |
| 110 | + "step_4_read_all_targets", |
| 111 | + "step_5_perform_analysis", |
| 112 | + "step_6_synthesize_nitpicks", |
| 113 | + "step_7_report_nitpicks", |
| 114 | + "summary", |
| 115 | + "tools", |
| 116 | + "unbearable_issues_x_found", |
| 117 | + "utterly_unbearable_mode", |
| 118 | + "verify_after_ship", |
| 119 | + "voice", |
| 120 | + "what_to_measure", |
| 121 | + "what_you_do_not_do", |
| 122 | + "who_you_are", |
| 123 | + "workflow_scribe_core", |
| 124 | + "write_the_brief_when_it_is_ready", |
| 125 | + "your_role", |
| 126 | +] as const; |
| 127 | + |
| 128 | +const SECTION_TAG_SET: ReadonlySet<string> = new Set( |
| 129 | + PROMPT_SECTION_TAG_VOCABULARY, |
| 130 | +); |
| 131 | + |
| 132 | +/** Fallback tag for section titles outside the fixed vocabulary. */ |
| 133 | +export const FALLBACK_SECTION_TAG = "section"; |
| 134 | + |
| 135 | +export interface PromptSection { |
| 136 | + readonly title: string; |
| 137 | + readonly level: 1 | 2; |
| 138 | + readonly body: string; |
| 139 | +} |
| 140 | + |
| 141 | +export interface SplitPrompt { |
| 142 | + readonly preamble: string; |
| 143 | + readonly sections: readonly PromptSection[]; |
| 144 | +} |
| 145 | + |
| 146 | +// Section boundary: an ATX `#` or `##` heading line. `###` and deeper are |
| 147 | +// sub-section detail and never split — tags stay at section granularity. |
| 148 | +const SECTION_HEADING_PATTERN = /^#{1,2} ([^\n]*)(?:\n|$)/gm; |
| 149 | + |
| 150 | +/** Split prompt text into its preamble and `#`/`##` sections (offsets preserved). */ |
| 151 | +export function splitPromptSections(text: string): SplitPrompt { |
| 152 | + const matches = [...text.matchAll(SECTION_HEADING_PATTERN)]; |
| 153 | + if (matches.length === 0) return { preamble: text, sections: [] }; |
| 154 | + const first = matches[0]; |
| 155 | + if (first === undefined || first.index === undefined) |
| 156 | + return { preamble: text, sections: [] }; |
| 157 | + const preamble = text.slice(0, first.index); |
| 158 | + const sections = matches.map((match, i) => { |
| 159 | + const level = match[0].startsWith("## ") ? 2 : 1; |
| 160 | + const next = matches[i + 1]; |
| 161 | + const bodyStart = (match.index ?? 0) + match[0].length; |
| 162 | + const bodyEnd = next?.index ?? text.length; |
| 163 | + return { |
| 164 | + title: (match[1] ?? "").trim(), |
| 165 | + level: level as 1 | 2, |
| 166 | + body: text.slice(bodyStart, bodyEnd), |
| 167 | + }; |
| 168 | + }); |
| 169 | + return { preamble, sections }; |
| 170 | +} |
| 171 | + |
| 172 | +// Normalize a section title to its tag: lowercase, runs of non-alphanumerics |
| 173 | +// to `_`, trimmed. Leading-digit results take a `sec_` prefix (bare digits |
| 174 | +// are not valid XML tag starts); empty results take the fallback tag. |
| 175 | +export function normalizeSectionTag(title: string): string { |
| 176 | + const base = title |
| 177 | + .toLowerCase() |
| 178 | + .replace(/[^a-z0-9]+/g, "_") |
| 179 | + .replace(/^_+|_+$/g, ""); |
| 180 | + if (base === "") return FALLBACK_SECTION_TAG; |
| 181 | + return /^[0-9]/.test(base) ? `sec_${base}` : base; |
| 182 | +} |
| 183 | + |
| 184 | +/** Map a section title to its render tag: vocabulary hit, else the fallback. */ |
| 185 | +export function tagForSectionTitle(title: string): string { |
| 186 | + const tag = normalizeSectionTag(title); |
| 187 | + return SECTION_TAG_SET.has(tag) ? tag : FALLBACK_SECTION_TAG; |
| 188 | +} |
| 189 | + |
| 190 | +/** |
| 191 | + * Render prompt text in a style. Markdown returns the input untouched |
| 192 | + * (byte-identical by construction); xml wraps each section body in its tag. |
| 193 | + */ |
| 194 | +export function renderPromptBody( |
| 195 | + text: string, |
| 196 | + style: PromptRenderStyle, |
| 197 | +): string { |
| 198 | + if (style === "markdown") return text; |
| 199 | + const { preamble, sections } = splitPromptSections(text); |
| 200 | + if (sections.length === 0) return text; |
| 201 | + const parts = preamble.trim() === "" ? [] : [preamble.trim()]; |
| 202 | + for (const section of sections) { |
| 203 | + const tag = tagForSectionTitle(section.title); |
| 204 | + parts.push(`<${tag}>\n${section.body.trim()}\n</${tag}>`); |
| 205 | + } |
| 206 | + return parts.join("\n\n"); |
| 207 | +} |
| 208 | + |
| 209 | +let cachedStyle: PromptRenderStyle | undefined; |
| 210 | + |
| 211 | +/** |
| 212 | + * Resolve the render style once per process and cache it: the director prompt |
| 213 | + * prefix must not change mid-session or the prompt cache splits. Unknown and |
| 214 | + * absent values fall back to markdown. |
| 215 | + */ |
| 216 | +export function resolvePromptRenderStyle( |
| 217 | + env: Record<string, string | undefined> = process.env, |
| 218 | +): PromptRenderStyle { |
| 219 | + if (cachedStyle === undefined) { |
| 220 | + const raw = env[PROMPT_RENDER_STYLE_ENV_VAR]?.trim().toLowerCase(); |
| 221 | + cachedStyle = raw === "xml" ? "xml" : "markdown"; |
| 222 | + } |
| 223 | + return cachedStyle; |
| 224 | +} |
| 225 | + |
| 226 | +/** Test-only reset for the once-per-session style cache. */ |
| 227 | +export function resetCachedPromptRenderStyleForTests(): void { |
| 228 | + cachedStyle = undefined; |
| 229 | +} |
| 230 | + |
| 231 | +/** |
| 232 | + * Render a director package system prompt (identity header + body) in a |
| 233 | + * style. The default arm resolves the once-per-session style; markdown is |
| 234 | + * `formatDirectorSystemPrompt` verbatim. |
| 235 | + */ |
| 236 | +export function renderDirectorSystemPrompt( |
| 237 | + pkg: DirectorPackage, |
| 238 | + style: PromptRenderStyle = resolvePromptRenderStyle(), |
| 239 | +): string { |
| 240 | + const full = formatDirectorSystemPrompt(pkg); |
| 241 | + return style === "xml" ? renderPromptBody(full, "xml") : full; |
| 242 | +} |
0 commit comments