Two user-reported problems from the muscle-protein-synthesis session, both diagnosed from the live data (chat_mtxeml9e_6118e59f). The freeze: after probe_done, the open session never showed the teacher's wake turn — only navigating away and back loaded it. Not an agent-switch problem: the server-side handoff worked perfectly (write-ahead persist, liveness registry, the sidebar's busy dot saw the wake the whole time). The gap was client attach: the only mid-session wake detector the open page has is the jobs poll, and probe-phase sessions have zero job traffic, so that poll is OFF; the post-turn adoption poll read turnInFlight=false at ~0ms (the wake fires at ~0.75-1.2s, after the pump's grace), adopted, and stopped. Fix: the post-turn adoption effect now keeps a short WATCH WINDOW after adopting (~4s, 8x500ms) — if turnInFlight flips true, it hands off to the existing wake-adoption poll (busy shimmer, adopt at finish). The mermaid failures: four failed plan_init calls, all caused by ONE hidden rule — underscores are forbidden in node ids (mps_translation, net_balance, per_meal). The id regex rejected '_' BEFORE the slug check, so the specific, actionable message never fired; the generic 'can't read the edge — expected a[title] --> b[title]' lied (the syntax was exactly that), and the teacher spent three attempts hunting punctuation it suspected (colons, commas, multi-word labels) — visible in its own reasoning trail. Per the user's principle — enforce only the slugs, free everything else: underscores are first-class slug chars; the id CANDIDATE class is broad so every bad id reaches the specific slug message; chained edges (a --> b --> c) expand to pairs; ==>, -.-> are edge synonyms; any direction header accepted (we layout ourselves); id["title"] quote pairs are stripped. The tool description, zod describe, and the teacher's planning prompt now state the one strict rule instead of the old subset liturgy. Verified: tsc, 305/305 (the exact muscle-protein mermaid is now a passing test), lint, build; real-browser puppeteer smoke on live GLM — the teacher's wake turn appeared in the open page ~50s after the probe concluded with ZERO navigation, verdict gate + first teach check + plan window all live in-page (the single console 404 is the app's missing favicon, environmental noise).
164 lines
6.5 KiB
TypeScript
164 lines
6.5 KiB
TypeScript
/**
|
|
* Mermaid → PlanTree authoring format (the author-once protocol, 2026-09-03;
|
|
* free-form pass 2026-09-11 — the muscle-protein session post-mortem: four
|
|
* failed plan_init calls over ids the parser silently hated).
|
|
*
|
|
* The teacher writes the session map as a mermaid block — the format models
|
|
* are most fluent in — and plan_init canonicalizes it into the structured
|
|
* PlanTree. Hand-parsed subset: deterministic, dependency-free, precise
|
|
* line-numbered errors the teacher can act on (the same validate-and-retry
|
|
* loop as the question schemas). Not the mermaid runtime — the server never
|
|
* renders, it only needs the graph.
|
|
*
|
|
* THE ONE STRICT RULE: node ids are permanent slugs the teacher references
|
|
* via quiz nodeId — lowercase words (a-z, 0-9) joined by hyphens OR
|
|
* underscores (`chain-rule-intuition`, `mps_translation`). Everything else
|
|
* is as free-form as mermaid allows:
|
|
* graph|flowchart TD|TB|LR|RL|BT (direction is cosmetic — we layout
|
|
* ourselves; case-insensitive)
|
|
* %% comment lines
|
|
* node — bare id (title falls back to the id)
|
|
* id[title] — square-bracket label; any single-line title, a
|
|
* surrounding "…" pair is stripped
|
|
* id("title") — paren label
|
|
* a --> b — requires edge (from = prerequisite); ==>, -.-> are
|
|
* synonyms; chains a --> b --> c expand to pairs
|
|
* a[title] --> b[title] — inline labels anywhere an endpoint appears
|
|
* Bare endpoints register as nodes; forward references are fine; duplicate
|
|
* edges dedupe; the first label a node gets wins.
|
|
*/
|
|
import type { PlanEdge, PlanNode } from "@/src/core";
|
|
|
|
export interface ParsedPlan {
|
|
nodes: PlanNode[];
|
|
edges: PlanEdge[];
|
|
}
|
|
|
|
export interface PlanParseResult {
|
|
ok: boolean;
|
|
/** All problems, each prefixed with its 1-based line number. */
|
|
problems: string[];
|
|
plan?: ParsedPlan;
|
|
}
|
|
|
|
const HEADER_RE = /^(graph|flowchart)\s+(td|tb|lr|rl|bt)\s*$/i;
|
|
const SLUG_RE = /^[a-z0-9]+([_-][a-z0-9]+)*$/;
|
|
/** Any arrow spelling counts as a requires-edge; chains split on it. */
|
|
const ARROW_RE = /-->|==>|-\.->/;
|
|
/** One edge endpoint: `id`, `id[title]`, or `id("title")`. */
|
|
// The id CANDIDATE class is deliberately broad — anything but whitespace,
|
|
// brackets, parens — so EVERY bad id (UPPER, dots, underscores…) reaches
|
|
// register()'s SPECIFIC slug message instead of dying at the regex layer
|
|
// with a generic parse error. The muscle-protein post-mortem: underscores
|
|
// failed HERE, the actionable message never fired, and the teacher spent
|
|
// four attempts hunting punctuation the parser didn't care about.
|
|
const ENDPOINT_RE = /^([^\s()\[\]]+)\s*(?:\[([^\[\]]*)\]|\(\s*"([^"]*)"\s*\)|\(([^()]*)\))?$/;
|
|
|
|
interface Endpoint {
|
|
id: string;
|
|
label?: string;
|
|
}
|
|
|
|
function parseEndpoint(text: string): Endpoint | null {
|
|
const m = text.trim().match(ENDPOINT_RE);
|
|
if (!m || !m[1]) return null;
|
|
let label = (m[2] ?? m[3] ?? m[4] ?? "").trim();
|
|
// `id["title"]` is idiomatic mermaid — don't keep the quote pair in the title.
|
|
if (
|
|
label.length >= 2 &&
|
|
((label.startsWith('"') && label.endsWith('"')) || (label.startsWith("'") && label.endsWith("'")))
|
|
) {
|
|
label = label.slice(1, -1).trim();
|
|
}
|
|
return { id: m[1], label };
|
|
}
|
|
|
|
/** Parse the mermaid subset. Collects ALL problems (never just the first). */
|
|
export function parseMermaidPlan(mermaid: string): PlanParseResult {
|
|
const problems: string[] = [];
|
|
const lines = mermaid.split(/\r?\n/);
|
|
|
|
// Header: required, first non-empty non-comment line.
|
|
const first = lines.findIndex((l) => l.trim().length > 0 && !l.trim().startsWith("%%"));
|
|
if (first === -1) {
|
|
return { ok: false, problems: ["The plan is empty — expected a `graph TD` block with nodes and `-->` requires-edges."] };
|
|
}
|
|
const header = lines[first]?.trim() ?? "";
|
|
if (!HEADER_RE.test(header)) {
|
|
problems.push(`line ${first + 1}: expected the header \`graph TD\` (or flowchart TD/TB) first, got \`${header}\`.`);
|
|
}
|
|
|
|
const nodes = new Map<string, string>(); // id → title (first label wins)
|
|
const edges: PlanEdge[] = [];
|
|
const seenEdges = new Set<string>();
|
|
|
|
const register = ({ id, label }: Endpoint, lineNo: number): void => {
|
|
if (!SLUG_RE.test(id)) {
|
|
problems.push(
|
|
`line ${lineNo}: node id \`${id}\` must be a lowercase slug (a-z, 0-9, joined by hyphens or underscores — e.g. \`chain-rule-intuition\`); ids are permanent and checks reference them.`,
|
|
);
|
|
return;
|
|
}
|
|
if (!nodes.has(id)) nodes.set(id, label && label.length > 0 ? label : id);
|
|
else if (label && label.length > 0 && nodes.get(id) === id) nodes.set(id, label);
|
|
};
|
|
|
|
for (let i = first + 1; i < lines.length; i++) {
|
|
const line = lines[i]?.trim() ?? "";
|
|
const no = i + 1;
|
|
if (line.length === 0 || line.startsWith("%%")) continue;
|
|
|
|
// Edges (incl. chains): split on any arrow spelling; every segment is
|
|
// an endpoint, consecutive pairs become requires-edges.
|
|
const segments = line.split(ARROW_RE);
|
|
if (segments.length >= 2) {
|
|
const endpoints: Endpoint[] = [];
|
|
let unreadable = false;
|
|
for (const seg of segments) {
|
|
const ep = parseEndpoint(seg);
|
|
if (!ep) {
|
|
problems.push(
|
|
`line ${no}: can't read the edge \`${line}\` — expected \`a[title] --> b[title]\` (chains like \`a --> b --> c\` are fine).`,
|
|
);
|
|
unreadable = true;
|
|
break;
|
|
}
|
|
endpoints.push(ep);
|
|
}
|
|
if (!unreadable) {
|
|
for (const ep of endpoints) register(ep, no);
|
|
for (let k = 0; k + 1 < endpoints.length; k++) {
|
|
const from = endpoints[k]!;
|
|
const to = endpoints[k + 1]!;
|
|
if (from.id === to.id) {
|
|
problems.push(`line ${no}: self-loop \`${from.id} --> ${to.id}\` — a node can't require itself.`);
|
|
continue;
|
|
}
|
|
const key = `${from.id}->${to.id}`;
|
|
if (!seenEdges.has(key)) {
|
|
seenEdges.add(key);
|
|
edges.push({ from: from.id, to: to.id });
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
const node = parseEndpoint(line);
|
|
if (!node) {
|
|
problems.push(
|
|
`line ${no}: can't parse \`${line}\` — ids are the strict part (lowercase slugs: a-z, 0-9, hyphens, underscores); node lines (\`slug[title]\`) and requires-edges (\`a --> b\`) are the accepted forms; subgraphs, styles, and classes are not part of the subset.`,
|
|
);
|
|
continue;
|
|
}
|
|
register(node, no);
|
|
}
|
|
}
|
|
|
|
const plan: ParsedPlan = {
|
|
nodes: [...nodes.entries()].map(([id, title]) => ({ id, title })),
|
|
edges,
|
|
};
|
|
if (problems.length > 0) return { ok: false, problems };
|
|
if (plan.nodes.length === 0) return { ok: false, problems: ["No nodes found — the plan needs at least one."] };
|
|
return { ok: true, problems: [], plan };
|
|
}
|