// lib/parsers/plan-schema.mjs // Plan schema parser — heading shape, per-step fields, ceremony, design part. // // The canonical step heading is `### Step N: ` (literal colon-space). // Forbidden narrative drift formats (introduced in v1.8.0 to defend against // Opus 4.7 schema-drift): `## Fase N`, `### Phase N`, `### Stage N`, `### Steg N`. // // plan_version 1.8 adds per-step field parsing (`parseStepFields`), the // ceremony class the plan was made under (`small | bounded | architectural`, // absent = architectural) and the design-part helpers (`splitSections`, // `findModuleCycle`). The rules that use them live in plan-validator.mjs. import { ok, fail, issue } from '../util/result.mjs'; import { parseDocument } from '../util/frontmatter.mjs'; export const STEP_HEADING_REGEX = /^### Step (\d+):\s+(.+?)\s*$/m; export const STEP_HEADING_GLOBAL = /^### Step (\d+):\s+(.+?)\s*$/gm; export const FORBIDDEN_HEADING_REGEX = /^(?:##|###) (?:Fase|Phase|Stage|Steg) \d+/m; export const FORBIDDEN_HEADING_GLOBAL = /^(?:##|###) (?:Fase|Phase|Stage|Steg) \d+/gm; // Matches plan_version in either location the schema allows: at line start // (frontmatter) or backtick-wrapped inside the prose "Generated by" metadata // line the plan template emits (`> Generated by ... — `plan_version: 1.7``). export const PLAN_VERSION_REGEX = /(?:^|`)plan_version:\s*['"]?([\d.]+)['"]?/m; /** * Find all step heading positions in plan text. * @returns {Array<{n: number, title: string, line: number, offset: number}>} */ export function findSteps(text) { if (typeof text !== 'string') return []; const out = []; STEP_HEADING_GLOBAL.lastIndex = 0; let m; while ((m = STEP_HEADING_GLOBAL.exec(text)) !== null) { const offset = m.index; const line = text.slice(0, offset).split(/\r?\n/).length; out.push({ n: Number.parseInt(m[1], 10), title: m[2].trim(), line, offset }); } return out; } /** * Find forbidden narrative-drift heading occurrences (Fase/Phase/Stage/Steg N). * @returns {Array<{form: string, line: number, offset: number, raw: string}>} */ export function findForbiddenHeadings(text) { if (typeof text !== 'string') return []; const out = []; FORBIDDEN_HEADING_GLOBAL.lastIndex = 0; let m; while ((m = FORBIDDEN_HEADING_GLOBAL.exec(text)) !== null) { const offset = m.index; const line = text.slice(0, offset).split(/\r?\n/).length; const raw = m[0]; out.push({ form: raw, line, offset, raw }); } return out; } /** * Slice plan text into per-step sections. * @returns {Array<{n: number, title: string, body: string, line: number}>} */ export function sliceSteps(text) { const heads = findSteps(text); const sections = []; for (let i = 0; i < heads.length; i++) { const start = heads[i].offset; const end = i + 1 < heads.length ? heads[i + 1].offset : text.length; const block = text.slice(start, end); sections.push({ n: heads[i].n, title: heads[i].title, body: block, line: heads[i].line, }); } return sections; } /** * Extract `plan_version: X.Y` from frontmatter or doc body. */ export function extractPlanVersion(text) { const m = typeof text === 'string' ? text.match(PLAN_VERSION_REGEX) : null; return m ? m[1] : null; } /** * Validate plan structure at the heading level. * Strict mode: forbidden-heading count > 0 → error. Step numbers must be 1..N contiguous. * @returns {import('../util/result.mjs').Result} */ export function validatePlanHeadings(text, opts = {}) { const strict = opts.strict !== false; const errors = []; const warnings = []; if (typeof text !== 'string') { return fail(issue('PLAN_INPUT', 'Plan text is not a string')); } const forbidden = findForbiddenHeadings(text); if (forbidden.length > 0) { const list = forbidden.map(f => `line ${f.line}: ${f.raw}`).join('; '); const errorIssue = issue( 'PLAN_FORBIDDEN_HEADING', `Found ${forbidden.length} forbidden narrative-drift heading(s): ${list}`, 'Use canonical "### Step N: <title>". Forbidden forms: Fase/Phase/Stage/Steg.', ); if (strict) errors.push(errorIssue); else warnings.push(errorIssue); } const steps = findSteps(text); if (steps.length === 0) { errors.push(issue('PLAN_NO_STEPS', 'No step headings found', 'Expected at least one "### Step 1: <title>".')); } else { const numbers = steps.map(s => s.n); for (let i = 0; i < numbers.length; i++) { if (numbers[i] !== i + 1) { errors.push(issue( 'PLAN_STEP_NUMBERING', `Step numbering breaks at position ${i + 1} (got Step ${numbers[i]})`, 'Steps must be 1..N contiguous and ordered.', )); break; } } } return { valid: errors.length === 0, errors, warnings, parsed: { steps, forbidden } }; } // ---- plan_version 1.8 ------------------------------------------------------ export const PLAN_VERSION_CURRENT = '1.8'; // 1.7 plans stay valid: the 1.8 rules apply only from 1.8 on. export const PLAN_VERSIONS_SUPPORTED = Object.freeze(['1.7', '1.8']); /** True when dotted version `v` is >= `min` (numeric per part; null → false). */ export function versionAtLeast(v, min) { if (typeof v !== 'string' || !/^\d+(\.\d+)*$/.test(v)) return false; const a = v.split('.').map(Number); const b = min.split('.').map(Number); for (let i = 0; i < Math.max(a.length, b.length); i++) { const x = a[i] ?? 0; const y = b[i] ?? 0; if (x !== y) return x > y; } return true; } // ---- ceremony (the brief's size class) ------------------------------------- export const CEREMONY_VALUES = Object.freeze(['small', 'bounded', 'architectural']); // Heaviest on doubt: a plan or brief that does not say is architectural. export const CEREMONY_DEFAULT = 'architectural'; // A plan states its ceremony in exactly two places: frontmatter, or the // "Generated by" header line (`> Generated by … · `ceremony: architectural``). // Prose elsewhere ("the brief sets `ceremony: small`") never counts. const HEADER_CEREMONY = /^> Generated by .*`ceremony:\s*([^`\s]+)\s*`/m; /** @returns {{value: string, raw: string|null, defaulted: boolean, valid: boolean}} */ export function resolveCeremony(raw) { if (raw === null || raw === undefined || raw === '') { return { value: CEREMONY_DEFAULT, raw: null, defaulted: true, valid: true }; } const s = String(raw).trim(); return { value: s, raw: s, defaulted: false, valid: CEREMONY_VALUES.includes(s) }; } function frontmatterCeremony(text) { const doc = typeof text === 'string' ? parseDocument(text) : null; const fm = doc?.parsed?.frontmatter; return fm && typeof fm === 'object' && fm.ceremony !== undefined ? fm.ceremony : null; } export function extractPlanCeremony(text) { if (typeof text !== 'string') return resolveCeremony(null); const fm = frontmatterCeremony(text); if (fm !== null) return resolveCeremony(fm); const m = text.match(HEADER_CEREMONY); return resolveCeremony(m ? m[1] : null); } /** Read `ceremony:` from a brief's frontmatter. Absent (or no frontmatter) → architectural. */ export function ceremonyFromBrief(briefText) { return resolveCeremony(frontmatterCeremony(briefText)); } // ---- fences ------------------------------------------------------------------ /** * Tracks fenced code blocks the CommonMark way: a fence closes only on the same * character (` or ~), at least as long as the opener, with nothing after it. * `feed(line)` returns true when the line is inside a fence or is a fence line. */ export function fenceTracker() { let open = null; // { ch, len } return { feed(line) { const m = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/); if (!open) { if (m && !(m[1][0] === '`' && m[2].includes('`'))) { open = { ch: m[1][0], len: m[1].length }; return true; } return false; } if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === '') open = null; return true; }, get inside() { return open !== null; }, }; } // ---- per-step fields ------------------------------------------------------- // A top-level field bullet: `- **Name:** rest`, `- **Name**: rest` or `- Name: rest`. const FIELD_LINE = /^- (?:\*\*)?([A-Z][A-Za-z -]*?)(?::\*\*|\*\*:|:)(?:\s+(.*))?$/; /** * Parse a step body into its top-level `- **Field:**` bullets. A field's block * runs until the next top-level field bullet; indented lines (sub-bullets, the * Manifest YAML fence) belong to the field above them. Parsing stops at the * first heading after the step's own, outside fenced code: sliceSteps gives the * last step the rest of the plan, and a later section must not lend it fields. * @returns {Map<string, {name: string, inline: string, text: string}>} * `inline` = text on the bullet line; `text` = inline + the indented block. */ export function parseStepFields(body) { const fields = new Map(); if (typeof body !== 'string') return fields; const fence = fenceTracker(); let current = null; let first = true; for (const line of body.split(/\r?\n/)) { const inFence = fence.feed(line); if (!inFence && /^#{1,6} /.test(line)) { if (!first) break; first = false; continue; } first = false; if (inFence) { if (current) current.lines.push(line); continue; } const m = line.match(FIELD_LINE); if (m) { current = { name: m[1].trim(), inline: (m[2] ?? '').trim(), lines: [] }; if (!fields.has(current.name)) fields.set(current.name, current); continue; } if (current && /^\s/.test(line)) current.lines.push(line); else if (current && line.trim() !== '') current = null; } for (const f of fields.values()) { f.text = [f.inline, ...f.lines].join('\n').trim(); delete f.lines; } return fields; } // ---- `On failure:` (K11) --------------------------------------------------- // The recovery words /trekexecute reads from a step's `On failure:` ("Parse // steps"). Free text that opens with none of them left the executor to guess: // "retry once; if still failing, revert ... and STOP" met a ladder of fix rounds // (voyage-lekerepo trial 2, 2026-10-08). export const ON_FAILURE_ACTIONS = Object.freeze(['revert', 'retry', 'skip', 'escalate']); /** * The recovery word an `On failure:` text opens with, or null. Case-insensitive; * leading markdown (`**`, backticks, a sub-bullet's `-`) is stripped. */ export function onFailureAction(text) { const s = String(text ?? '').trim().replace(/^[*_`\s-]+/, '').toLowerCase(); const m = s.match(/^([a-z]+)\b/); return m && ON_FAILURE_ACTIONS.includes(m[1]) ? m[1] : null; } // ---- sections (fence-aware) ------------------------------------------------ /** * Split text at headings of exactly `level` (#-count), ignoring headings inside * fenced code blocks. Text before the first heading has title null. * @returns {Array<{title: string|null, start: number, end: number, text: string}>} * start/end are line indexes (end exclusive). */ export function splitSections(text, level = 2) { const lines = typeof text === 'string' ? text.split('\n') : []; const prefix = '#'.repeat(level) + ' '; const fence = fenceTracker(); const out = []; let cur = { title: null, start: 0 }; for (let i = 0; i < lines.length; i++) { const l = lines[i]; if (fence.feed(l)) continue; if (l.startsWith(prefix)) { out.push({ ...cur, end: i }); cur = { title: l.slice(prefix.length).trim(), start: i }; } } out.push({ ...cur, end: lines.length }); return out .filter(s => s.end > s.start || s.title !== null) .map(s => ({ ...s, text: lines.slice(s.start, s.end).join('\n') })); } /** First section of `level` whose title equals `title` (case-insensitive), or null. */ export function findSection(text, title, level = 2) { const want = title.toLowerCase(); return splitSections(text, level).find(s => s.title && s.title.toLowerCase() === want) ?? null; } /** Every fenced block whose info string is `lang`, in order. */ export function fencedBlocks(text, lang) { const out = []; const lines = typeof text === 'string' ? text.split('\n') : []; const fence = fenceTracker(); let cur = null; for (const l of lines) { const wasInside = fence.inside; fence.feed(l); if (!wasInside && fence.inside) { cur = new RegExp(`^\\s{0,3}(\`{3,}|~{3,})\\s*${lang}\\s*$`).test(l) ? [] : null; } else if (wasInside && !fence.inside) { if (cur) out.push(cur.join('\n')); cur = null; } else if (cur) { cur.push(l); } } return out; } // ---- module map ------------------------------------------------------------ // Directives that are not node statements: the keyword must stand alone or be // followed by whitespace, so a node id like `end-user` or `graph.mjs` is kept. const DIRECTIVE = /^(graph|flowchart|subgraph|end|classDef|class|style|linkStyle|direction|click|accTitle|accDescr)(\s|$)/; // Directed and undirected links, after labels are stripped: // --> ---> ==> ===> -.-> -..-> with an optional leading < (both ways); // --- === -.- ~~~ (no direction: not a dependency). const LINK = /\s*(<?-{2,}>|<?={2,}>|<?-\.+->|-{3,}|={3,}|-\.+-|~{3,})\s*/; const NODE_ID = /^[A-Za-z0-9_][\w.\-/]*$/; const ARROWISH = /-->|==>|-\.+->|<--|<==/; function stripLabels(line) { return line .replace(/"[^"]*"/g, '') .replace(/:::[\w-]+/g, '') .replace(/\[\[[^\]]*\]\]|\[\([^\]]*\)\]|\[\/[^\]]*\/\]|\[\\[^\]]*\\\]|\[[^\]]*\]/g, '') .replace(/\(\(\([^)]*\)\)\)|\(\([^)]*\)\)|\(\[[^\]]*\]\)|\([^)]*\)/g, '') .replace(/\{\{[^}]*\}\}|\{[^}]*\}/g, '') .replace(/>[^\]\s][^\]]*\]/g, '') .replace(/\|[^|]*\|/g, '') // link text: `-- text -->`, `-. text .->`, `== text ==>` .replace(/--\s+[^-<>=]+?\s+(-{2,}>)/g, '$1') .replace(/-\.\s+[^.<>]+?\s+\.(-+>)/g, '-.$1') .replace(/==\s+[^=<>]+?\s+(={2,}>)/g, '$1'); } /** * Parse a mermaid flowchart into directed edges. * Handles chains (a --> b --> c), `&` groups, `;`-separated statements, * `<-->` (one edge each way), long arrows, labels and `:::class`. * @returns {{edges: Array<[string, string]>, unparsed: string[]}} * unparsed = statements that look like links but yielded no edge. */ export function parseMermaid(mermaid) { const edges = []; const unparsed = []; if (typeof mermaid !== 'string') return { edges, unparsed }; for (const raw of mermaid.split(/\r?\n/)) { for (const stmt0 of raw.split(';')) { const stmt = stmt0.trim(); if (!stmt || stmt.startsWith('%%') || DIRECTIVE.test(stmt)) continue; const parts = stripLabels(stmt).split(LINK).map(p => p.trim()); if (parts.length < 3) { if (ARROWISH.test(stmt)) unparsed.push(stmt); continue; } let ok = true; const groups = []; for (let i = 0; i < parts.length; i += 2) { const ids = parts[i].split('&').map(s => s.trim()); if (ids.length === 0 || ids.some(id => !NODE_ID.test(id))) { ok = false; break; } groups.push(ids); } if (!ok) { unparsed.push(stmt); continue; } for (let i = 1, g = 0; i < parts.length; i += 2, g++) { const link = parts[i]; const forward = link.endsWith('>'); const backward = link.startsWith('<'); if (!forward) continue; // undirected: not a dependency for (const a of groups[g]) { for (const b of groups[g + 1]) { edges.push([a, b]); if (backward) edges.push([b, a]); } } } } } return { edges, unparsed }; } /** Directed edges [from, to] from mermaid flowchart text (see parseMermaid). */ export function parseMermaidEdges(mermaid) { return parseMermaid(mermaid).edges; } /** * Find one directed cycle in a module map (mermaid text or an edge list). * Iterative depth-first search: a deep chain cannot overflow the stack. * @returns {string[]|null} the cycle as a closed path (first === last), or null. */ export function findModuleCycle(input) { const edges = Array.isArray(input) ? input : parseMermaidEdges(input); const adj = new Map(); for (const [a, b] of edges) { if (!adj.has(a)) adj.set(a, []); if (!adj.has(b)) adj.set(b, []); adj.get(a).push(b); } const state = new Map(); // unset = unvisited, 1 = on the path, 2 = done for (const root of adj.keys()) { if (state.has(root)) continue; const path = [root]; const iters = [0]; state.set(root, 1); while (path.length > 0) { const n = path[path.length - 1]; const next = adj.get(n); const i = iters[iters.length - 1]; if (i >= next.length) { state.set(n, 2); path.pop(); iters.pop(); continue; } iters[iters.length - 1] = i + 1; const nx = next[i]; if (state.get(nx) === 1) return [...path.slice(path.indexOf(nx)), nx]; if (!state.has(nx)) { state.set(nx, 1); path.push(nx); iters.push(0); } } } return null; }