Source:
lib/plan-merge.mjs(deterministic merge engine),agents/mp-spec-decomposer.md,agents/mp-planner.md. Schema version:"6.0"(constant atlib/plan-merge.mjs:23).
The LLM never authors the final plan.index.json bytes. Subsystem drafters propose task
fragments; deterministic JavaScript (lib/plan-merge.mjs) owns global id assignment, wave
computation, codex normalisation, and schema validation. This eliminates two anomaly classes
that plagued earlier designs: (1) the codex routing annotation arriving as an object or
boolean instead of the required string enum, silently falling through to the heuristic; and
(2) drafters authoring wave numbers directly, producing scattered single-task waves instead of
maximally-parallel groups.
mp-planner (routing-policy lane frontier) reads the approved spec and writes both plan.index.json and plan.md
directly into the run-bundle directory. It is the sole producer in this path — it emits the
tasks array with id, wave, and codex already set. validatePlanIndex is still run by
L1 after the planner returns to catch any silent-fallthrough trap violations before the plan
is accepted. L1 is the single durable state writer (CD-7); mp-planner writes only the plan
artifacts.
Used when mp-spec-decomposer (routing-policy lane frontier) returns recommend_parallel: true and the config
planning.mode is auto or parallel, with ≥ 2 subsystems in the decomposition.
Step 1 — Decompose. mp-spec-decomposer reads the spec and carves it into a list of
subsystems — each a file-disjoint, coherent responsibility slice — and emits a digest of the
form { subsystems: [...], recommend_parallel: bool, reason: string }. It does not plan
tasks. Its subsystem list is the seam map the fan-out drives.
Step 2 — Fan-out. Planning fan-out is the native spawn-plan path — one
mp-subsystem-planner descriptor per subsystem (class planned-execution, a
writes:false role), executed by the harness's parallel subagent API. Each drafter returns a
fragment — a subsystem-scoped task list without global ids or waves. The fragment schema
(enforced at the dispatch boundary) is:
{
"key": "auth",
"tasks": [
{
"key": "auth.token-store",
"description": "...",
"files": ["src/auth/tokens.ts"],
"verify_commands": ["npx tsc --noEmit"],
"deps": [],
"codex": "ok"
}
]
}Critically, the fragment schema pins codex to string|null with enum: ["ok","no",null] —
an object or boolean shape cannot be returned at the tool boundary, making the layer-1 defence
against anomaly 1.
The planning fan-out returns { subsystems: [<fragment>, ...], specPath, repoRoot } and never
writes artifacts or commits.
Step 3 — Deterministic merge (lib/plan-merge.mjs). mergePlanFragments runs:
-
Flatten and assign ids. Tasks are numbered 1-based in fragment order, then task order. Duplicate task keys across fragments throw immediately (fail loud, never silent collision).
-
Validate dependency references. Every
depsentry must reference an existing task key. Dangling deps throw. -
Wave layering via Kahn topological order. A deterministic topological sort (lowest id wins among ready nodes) produces a processing order. For each task in that order:
wave = max(wave of each dep) + 1, or0if no deps.- File-conflict bump: while any already-placed task at the same wave shares a declared
file with this task, increment
wave. The bump repeats until the task's file set is disjoint from every same-wave peer.
Result: disjoint, dependency-free tasks share the lowest possible wave (maximal safe parallelism); tasks that must sequence do so. Wave numbers are derived entirely from the dependency DAG and declared file sets — never authored by the LLM.
A cycle in the dependency DAG throws:
"dependency cycle among tasks [a, b, ...]". -
Emit canonical
plan.index.json. Fields:id(int),description(str),wave(int ≥ 0),files(array),verify_commands(array),codex("ok"|"no"|null), plus optionalsensitive,conversational,spec_refs. Internalkeyanddepsfields are dropped from the output.
Called during merge step 1 on every task's raw codex value:
| Input shape | Output |
|---|---|
"ok" or "no" |
unchanged |
true |
"ok" |
false |
"no" |
{ eligible: true } |
"ok" |
{ eligible: false } |
"no" |
| anything else (missing, null, unrecognised) | null (heuristic) |
This is the belt-and-suspenders layer (layer 2 of the anomaly-1 defence). Even if the tool
boundary admits a malformed value, normalizeCodex collapses it to the string enum before it
can reach lib/dispatch/routing.mjs.
Run against both the merge output (belt-and-suspenders) and the serial-path index (the explicit gate). Checks:
idis an integer, non-duplicate.descriptionis a non-empty string.waveis an integer ≥ 0.codexis"ok","no",null, or absent. Any other shape is an error; the error message names the silent-routing-fallthrough trap explicitly.filesandverify_commands, if present, are arrays.- No two tasks in the same wave share a declared file.
Returns an array of human-readable error strings; empty means valid. Never throws.
plan.md is always generated by renderPlanMd(index, meta) — it is never hand-authored
independently of the index. This keeps the human-readable and machine-readable plans from
drifting. The render groups tasks by wave, listing each task's files, verify commands, and
codex annotation. Because plan.md is a pure function of the index, any edit to task scope
or ordering is made in the index; plan.md is regenerated.
plan.html is a second, additive projection of the index — plan.md stays canonical;
plan.html is the rendered, browser-openable view (planf3-inspired). Like renderPlanMd,
renderPlanHtml(index, meta) is a pure, deterministic function: identical (index, meta)
yields byte-identical output (including its SVG), with no clock/randomness/measured text — any
timestamp shown comes from meta.generated_at/index.generated_at, never new Date(). It is
self-contained (inline CSS, no JS, no remote resources). The meta bag threads render context:
title, taskStatus ({id → status}), refs, narrative, bundleDir, generated_at,
amendmentsMd/amendments, and an injectable fileExists (for offline trust-boundary tests).
The document body is assembled top-to-bottom in one fixed order (renderPlanHtml,
lib/plan-merge.mjs); every block after the header is conditionally emitted and collapses
to an empty string when its data is absent, so an old minimal index still renders cleanly:
- Header + refs —
<h1>title, a one-linesummary(N task(s) across M wave(s).), an optionalGenerated:stamp (only whenmeta/index.generated_atis set), the optional hero image, then the References block (refsBlock):back:/forward:cross-bundle ref links frommeta.refs({ back, forward }), omitted when both are empty. Each ref renders as an<a href>only when its targetplan.htmlexists (by-presence), else inert text. - Narrative meta (
narrativeBlock) —Purpose/Problem/Solution<h2>+<p>pairs, each emitted only when its narrative string is present and non-empty. - Wave SVG (
renderWaveSvg) — the inline wave-banded node diagram (below), wrapped in<figure class="diagram">. - Task table — one
<section class="wave">per wave, each with an optionalassets/wave-<n>.pngillustration then a table whose columns are #, Status (badge), Task, Files, Verify, Codex, Spec refs. - Goals (
goalsBlock) — the distinct goal ids cited across all tasks, de-duped and sorted for determinism; omitted when no task cites a goal. - Amendments timeline (
amendmentsBlock) — the parsed## Amendmentshistory rendered as an ordered<ol class="timeline">(below).
The inline <svg> (renderWaveSvg) is a wave-banded node layout, not a dependency graph —
the merged index carries no deps (the merge drops internal key/deps), so drawing dependency
edges would be fabricated. Each wave is a horizontal band; tasks (id-sorted) are #id nodes
within it, colored by status. Geometry is a pure function of node counts — no measured text, no
generated ids, no clock — so the SVG is byte-stable for a given (index, taskStatus).
Images are embedded strictly by presence on disk, never by any render flag: the imgTag
helper resolves a slot to a path via resolveAssetSrc(bundleDir, slot) and emits an <img>
only when bundleDir is set and fileExists(abs) is true — otherwise it returns '', so
a missing asset yields no broken image. Two slots are consulted: hero (rendered once in the
header) and wave-<n> per wave <section>. The <img src> uses the bundle-relative path and
is HTML-escaped like every other interpolated value.
parseAmendments(md) turns the ## Amendments section that the F2 amend flow appends to
plan.md into an ordered timeline. It accepts either the whole plan.md or just the section,
and is a pure string parse (no fs, no clock): it scans for the ## Amendments H2, then reads
### <date> — <summary> entry headings (em-dash separated; a heading with no em-dash keeps an
empty date and treats the whole heading as summary), collecting following non-### lines
as that entry's detail body. A subsequent ## H2 ends the section. Each entry becomes
{ date, summary, detail }. render-plan reads plan.md best-effort (meta.amendmentsMd);
an absent/unreadable plan.md yields no timeline, never a throw.
Two independent trust boundaries keep untrusted plan text and stored slugs from becoming executable markup, a remote fetch, or a filesystem-traversal primitive:
- Escaping. Every interpolated string field (descriptions, files, commands, spec_refs,
title, ref labels, asset
src, amendment date/summary/detail) routes throughescapeHtml(& < > " '), so untrusted markup can never open a<script>orsrc=/href=fetch. The numericid/wavefields are interpolated raw (not viaescapeHtml) but areNumber()-coerced up front, so a hand-edited index that smuggles markup through them renders asNaN, not an open tag. Task status reaches a CSS class only through thePLAN_HTML_STATUSESwhitelist (pending/done/failed/blocked; anything else →pending), so a hostile status string cannot inject a class. - Path traversal. Asset and ref paths are pure
node:pathmath (no fs) with a confinement check.resolveAssetSrckeeps an embedded<img>inside the bundle'sassets/dir — a slot that resolves outsideassets/returnsnull(no tag).resolveRefTargetre-validates the stored ref slug againstSLUG_RE(^[a-z0-9][a-z0-9-]*$) and resolves the target under the ref's stored canonical repo root (ref.repo, else the bundle's<root>/docs/masterplan/<slug>→<root>ancestor); a target that escapes that root returnsnull. So a hostilestate.refsslug renders as inert text, never a traversal.
- Auto-emit at the plan→execute seam:
mp load-planbest-effort writes a staticplan.html(all taskspending) right after the index validates and before the atomic state write. A render/write failure is swallowed (logged) — it never failsload-planor perturbsstate.yml. mp render-plan(therenderverb): re-rendersplan.htmlwith live per-task status read fromstate.tasks(badge valuespending/done/failed/blocked; anything else →pending), plus refs from state, narrative fromindex.meta, and the amendments timeline fromplan.md. Read-only w.r.t. state — it never callswriteState, so F1 (refs) and F2 (amend) callers can idempotently retry the render after a post-commit render failure without touchingstate.yml.
Whether a run arms image/diagram render artifacts is a per-bundle toggle held in a nested
state.render object (currently just state.render.images, 'on'|'off'), symmetric with
state.review/state.codex:
- Seed default.
buildSeedStatewritesstate.render = { images: <seed> }, defaulting to'off'unlessseed --render-images=onis passed. The bin boundary enum-validates--render-imagestoon|off; migration back-fills a missingstate.renderto{ images: 'off' }. mp set-render-config --images=on|off. The reversible setter (mirrorsset-review-config):setRenderConfigmerge-updates only the supplied facet so other render keys survive, and every{...state}writer round-tripsstate.renderuntouched. This is the single non-seed writer of the key — the toggle is flipped via the verb, never a CD-7 hand-edit ofstate.yml.
The optional narrative meta ({ purpose, problem, solution }, each 1–3 plain-prose sentences
distilled from spec.md) is carried into index.meta by both planning paths so the
Purpose/Problem/Solution sections render identically regardless of path:
- Parallel path.
merge-plan-fragments --meta=<JSON>forwards the parsed meta tomergePlanFragments(fragments, { meta }), which distils{purpose, problem, solution}intoindex.meta. Non-object meta, missing fields, and non-string/empty values are soft-ignored (never a throw); when no valid narrative field is present,index.metais omitted entirely so an old no-meta index keeps its exact byte shape. - Serial path.
mp-planneremits the same optional top-levelmetaobject directly inplan.index.json(same{purpose, problem, solution}contract), keeping the two paths in sync. validatePlanIndexback-compat. The validator is accept-and-ignore forindex.meta: it inspects onlyindex.tasks, so an old index carrying no meta stays valid, a new index with meta stays valid, and a malformed meta value is a soft-ignore, never a hard error.
schema_version: "6.0"
tasks:
- id: integer, 1-based, unique
description: string (required)
wave: integer >= 0
files: array of repo-relative paths
verify_commands: array of shell strings
codex: "ok" | "no" | null
sensitive: bool (optional)
conversational: bool (optional)
spec_refs: array of strings (optional)
Example bundle layout: /home/user/project/docs/masterplan/my-feature/plan.index.json.