Interactive HMD editing is a host-mediated loop:
HMD source -> semantic HTML -> PatchIntent -> HmdPatch -> validated HMD source -> rerender
The browser or webview is UI glue. It should read semantic attributes, build a PatchIntent, and hand that intent to Rust through WASM, CLI, or a local/editor host. It must not reimplement HMD parsing, validation, or patch semantics.
cp fixtures/valid/decision-basic.hmd /tmp/decision.hmd
cargo run -p hmd-cli -- action /tmp/decision.hmd fixtures/intents/decision-select-rust.json --print-patch > /tmp/select-rust.patch.json
cargo run -p hmd-cli -- patch /tmp/decision.hmd /tmp/select-rust.patch.json --write
cargo run -p hmd-cli -- validate /tmp/decision.hmd
cargo run -p hmd-cli -- render /tmp/decision.hmd -o /tmp/decision.updated.html
rg 'CH-D-runtime-rust|selected|choice' /tmp/decision.updated.htmlFor a browser preview:
cargo run -p hmd-cli -- render examples/decision.hmd --interactive -o examples/decision.interactive.html
python3 -m http.server --directory examples 8000Open http://127.0.0.1:8000/decision.interactive.html. The static page can build and preview patch data. It does not write files.
For a write-capable local host:
cargo run -p hmd-cli -- preview examples/decision.hmd --write --port 0The preview server binds only to 127.0.0.1, injects a per-run token, and rejects writes without that token.
{
"intentVersion": "0.1",
"action": "decision.select_option",
"target": "/blocks/rust",
"context": {
"documentProfile": "decision@0.1",
"sourceHash": "sha256-...",
"decisionTarget": "/blocks/D-runtime"
},
"params": {
"choiceId": "CH-D-runtime-rust",
"choiceStatus": "selected",
"markdown": "Selected Rust."
}
}decision.select_option appends a choice block. It does not modify decision status. Use meta.set for field updates and body.replace for body edits.
| Host | Can write files | Transport | Safety gate |
|---|---|---|---|
| Static HTML | no | copy intent/patch, optional WASM preview | no file write |
| WASM | no | createPatch, applyPatch, applyIntent |
hash and validation errors are thrown |
| CLI | yes with --write or -o |
files or stdin | stale check, duplicate ID check, validate before write |
| Local preview | yes with --write |
localhost POST /api/intent or /api/patch |
localhost, token, stale check, validate, atomic write |
| VS Code/webview | extension host only | postMessage intent/patch |
document version/hash before WorkspaceEdit |
| npm CLI | yes through native binary | npm exec ... hmd action |
same CLI gates |
Webviews never access the filesystem. They send versioned messages to the extension host:
type HmdWebviewMessage =
| { version: "0.1"; type: "hmd.intent"; intent: PatchIntent }
| { version: "0.1"; type: "hmd.patch"; patch: HmdPatch }
| { version: "0.1"; type: "hmd.requestSource" }
| { version: "0.1"; type: "hmd.source"; source: string; documentVersion: number }
| { version: "0.1"; type: "hmd.applyResult"; ok: boolean; diagnostics?: unknown[] };The extension host should call WASM or CLI to create/apply patches, compare the active document version or source hash, use WorkspaceEdit to update the buffer, and rerender the preview after success. Unsaved editor changes must not be overwritten blindly.
Prototype adapter: editors/vscode-hmd/host-prototype.mjs.
Minimal documented adapter:
webview.onDidReceiveMessage(async (message: HmdWebviewMessage) => {
if (message.version !== "0.1") throw new Error("Unsupported HMD webview message");
if (message.type !== "hmd.intent" && message.type !== "hmd.patch") return;
const document = vscode.window.activeTextEditor?.document;
const version = document?.version;
const source = document?.getText() ?? "";
const patch =
message.type === "hmd.intent"
? JSON.parse(await wasm.createPatch(source, JSON.stringify(message.intent)))
: message.patch;
if (version !== document?.version) throw new Error("Document changed while patch was prepared");
const patched = await wasm.applyPatch(source, JSON.stringify(patch));
const edit = new vscode.WorkspaceEdit();
edit.replace(document.uri, fullDocumentRange(document), patched);
await vscode.workspace.applyEdit(edit);
webview.postMessage({ version: "0.1", type: "hmd.applyResult", ok: true });
});- Stale
sourceHashortargetHash: return conflict and do not write. - Write-capable CLI and local preview paths require
context.sourceHashon intents ortargetHashon patches. - Missing or ambiguous target: return non-zero CLI status or HTTP 400/409.
- Duplicate ID append: fail before source replacement.
- Invalid patched HMD: return validation error and do not write unless the CLI user passes
--allow-invalid. - Static pages must say that patch data is generated or previewed; they must not imply a local file was saved.