Author-once plan protocol: single mermaid plan_init + derived statuses + live quiz nodeId validation

- plan_init is now the ONLY plan tool: a mermaid 'graph TD' block parsed by a
  constrained-subset parser (mermaid-plan.ts) with line-precise errors; the
  seven mutation/tracking tools are gone by design (2026-09-03 design pass).
- Re-call plan_init = sanctioned wholesale rewrite; slugs are permanent.
- derivePlanStatuses (contracts/plan.ts): done = passed check tagged with a
  quiz nodeId; current = open check's node else first topo-ready node — the
  tracking the teacher no longer declares by hand.
- questionToolsFor(ctx) (tools.ts): the quiz schema chains a dynamic plan-
  nodeId validation against the LIVE record — an invalid nodeId is rejected
  at the call boundary and the teacher retries mid-turn, before any UI.
  Static questionTools stay validator-less for the default path.
- Prompts rewritten for mermaid authoring + derived position.
- Out-of-lane surgical touches (flagged for master review):
  src/core/question-core.ts (quizInputObject export + nodeId description),
  src/core/contracts/plan.ts (derivePlanStatuses + doc).
This commit is contained in:
amosblomqvist
2026-09-09 03:04:16 +03:00
parent fce477dd24
commit 67fbc0575f
10 changed files with 691 additions and 297 deletions
+78 -7
View File
@@ -44,14 +44,85 @@ export function planNodeStatus(tree: PlanTree, nodeId: string): PlanNodeStatus {
return "upcoming";
}
// ─── derived statuses: the author-once protocol (2026-09-03) ─────────────────────
//
// The teacher AUTHORS the map (plan_init, mermaid — once at the seam,
// re-callable to rewrite) and never tracks progress: statuses derive from
// the transcript, pure and at render time. A check names its node (quiz
// input nodeId — validated at the call boundary against the live tree);
// the derivation reads outcomes:
// done — any check on the node that the learner PASSED (demonstration-
// only: "explaining is yours, done is theirs")
// current — the node of the last UNANSWERED check; if none, the first
// node in authoring order whose prerequisites are all done and
// that isn't done itself (forward-looking: the panel leads the
// lesson — during teaching it points at what's coming, and holds
// through the check that follows)
// upcoming — everything else
// Untagged checks and nodeIds missing from the current tree are lost signal
// (the standing doctrine); a rewritten map keeps honest progress for reused
// slugs because the derivation matches ids against the CURRENT tree.
/** Minimal structural message slice — anything with typed parts (mirrors
* phases.ts: pure, no runtime deps, safe on server, client, and in tests). */
interface PartsCarrier {
parts: Array<{ type: string; state?: string; input?: unknown; output?: unknown }>;
}
export interface DerivedPlanStatuses {
current: string | null;
/** Node ids demonstrated by the learner (passed checks). */
done: ReadonlySet<string>;
}
/** Statuses of a node: the derived view the plan panel should render. */
export function derivedNodeStatus(statuses: DerivedPlanStatuses, nodeId: string): PlanNodeStatus {
if (statuses.current === nodeId) return "current";
if (statuses.done.has(nodeId)) return "done";
return "upcoming";
}
export function derivePlanStatuses(tree: PlanTree, messages: readonly PartsCarrier[]): DerivedPlanStatuses {
const ids = new Set(tree.nodes.map((n) => n.id));
const done = new Set<string>();
let current: string | null = null;
for (const m of messages) {
for (const part of m.parts) {
if (part.type !== "tool-quiz") continue;
const nodeId = (part.input as { nodeId?: unknown } | undefined)?.nodeId;
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown = lost signal
if (part.state === "input-available") {
current = nodeId; // a check is out — that's where we are
} else if (part.state === "output-available") {
if ((part.output as { correct?: unknown } | undefined)?.correct === true) done.add(nodeId);
// failed checks change nothing: resume teaching, re-check later
}
}
}
if (current === null) {
// No check out: the first node (authoring order) whose prerequisites
// are all demonstrated and that isn't done itself — the next lesson.
const prereqs = new Map<string, string[]>();
for (const e of tree.edges) prereqs.set(e.to, [...(prereqs.get(e.to) ?? []), e.from]);
current =
tree.nodes.find(
(n) => !done.has(n.id) && (prereqs.get(n.id) ?? []).every((p) => done.has(p)),
)?.id ?? null;
}
return { current, done };
}
/**
* Plan tools (W1 implements; server-side WITH execute, mutate the chat's
* stored tree, and return the full updated tree so the panel can render the
* latest state straight from the stream). Every tool returns PlanToolResult.
* Tool names: plan_init, plan_add_node, plan_update_node, plan_remove_node,
* plan_add_edge, plan_remove_edge, plan_set_current, plan_mark_done.
* The tree panel derives its display from the LATEST plan tool output in the
* transcript (live during streaming) — no separate state channel.
* The plan tool result (author-once protocol, 2026-09-03): ONE server-side
* tool, plan_init (tools/plan.ts) — a mermaid `graph TD` block canonicalized
* into the structured tree, called once at the probe→teach seam and
* re-callable to rewrite. The former mutation/tracking tools are gone:
* statuses derive from check outcomes (derivePlanStatuses above), so the
* tree carries structure, never progress. The tree panel still derives its
* display from the LATEST plan tool output in the transcript — plus the
* derived statuses — with no separate state channel.
*/
export interface PlanToolResult {
ok: boolean;