feat(scaffold): mdvs scaffold + hook handle for cross-harness integration#66
Merged
Conversation
TODO-0190 captures the design conversation around a unified
`mdvs scaffold {skill,snippet,hook}` command that emits all three
agent-harness integration artifacts. Replaces the narrow
`mdvs skill` command (which is removed in v1; pre-1.0).
Five load-bearing decisions locked:
1. Command name + directory: `mdvs scaffold` + `scaffolding/`
2. Subsume TODO-0187: post/pre-explain/pre-validate hook modes
become v1/v2/v3 of `scaffold hook --platform claude-code`
3. Hard rename `mdvs skill` (no alias surface)
4. jq as the stdin parser in hook scripts
5. Separate `.sh` for the validate hook (~10 lines, walk-up +
capture + envelope-wrap); inline for the search-nudge (~3
lines, literal echo)
Architecture: envelope wrapping lives in per-platform shell
scripts, not in mdvs Rust. `mdvs check` stays harness-agnostic
(`markdown` / `json` output). Per-platform shells under
`scaffolding/hooks/<platform>/` wrap the output in the harness's
JSON envelope and exit 0 — the non-blocking "warning, not block"
path that the schema-evolution loop requires.
Platforms covered v1: Claude Code, Codex, Cursor for hooks; plus
Antigravity for skill + snippet only (hook docs incomplete
upstream). OpenCode hooks refuse with a pointer (TypeScript
plugin surface, different beast). End-to-end test scope: Claude
Code (full loop) + Antigravity (skill + snippet).
Recipe page `agentic-harnesses-and-agentic-ides.md` documents the
current manual-wiring shape pending the scaffold implementation.
TODO-0186 (`mdvs explain`) and TODO-0188 (`mdvs check --stdin`)
get follow-up notes: each unlocks one of the deferred hook modes
(v2 pre-explain, v3 pre-validate) which will ship as separate
TODOs after they land.
11-step implementation plan in TODO-0190.
…esses
Step 2 of TODO-0190. Five content files under crates/mdvs/scaffolding/
that the future `mdvs scaffold {skill,snippet,hook}` commands will
emit via `include_str!`. No Rust code yet — those commands ship in
Steps 5-7. Authoring + hand-testing the content first lets us catch
design mistakes before they're baked into the CLI.
scaffolding/skill/SKILL.md
Refresh + restructure of the prior `mdvs skill` content (347 ->
~390 lines). Imperative description, "Usage" as a flat command
block, "What you must do when invoked" as a procedural 4-step
guide, a "Rules" section of always-do/never-do invariants, then
reference material. Step 4 is the schema-evolution warning loop
the agent participates in when violations come through hooks.
scaffolding/snippet/agents-md.md
Short ~15-line block for AGENTS.md / CLAUDE.md: KB presence,
`mdvs search` preference over Grep/Glob, validation contract,
warning-not-block schema-evolution rule.
scaffolding/snippet/cursor-rules.mdc
Same body wrapped with `.mdc` frontmatter (`alwaysApply: true`)
for Cursor's `.cursor/rules/` slot.
scaffolding/hooks/claude-code/validate.sh
Claude Code PostToolUse hook (Edit | Write | MultiEdit). Reads
`tool_input.file_path` from stdin JSON via `jq`, walks up to
find `mdvs.toml`, runs `mdvs check` twice (markdown for the
agent channel, pretty for the user channel), wraps the output
in the Claude Code envelope with two payloads:
- additionalContext: markdown body + skill pointer
(model-visible, uncapped)
- systemMessage: pretty box-drawing render, capped at
MAX_USER_LINES=15 with a "..." marker
on truncation (user-visible)
Silent gate uses the exit code (`if mdvs check ...; then exit 0`)
not output emptiness — `mdvs check --output markdown` always
emits a status line. Exit 0 unconditionally so the hook is
non-blocking by design.
scaffolding/hooks/claude-code/settings.json
`.claude/settings.json` template. Wires validate.sh as the
Edit/Write/MultiEdit hook plus an inline Bash search-nudge
hook scoped via `*kb/*` in the command string (the comment
tells users to swap that to their KB directory name).
End-to-end tested in this session against example_kb (45 files,
the categorical `status` / `priority` / `author` fields). All four
gates passed: skill discovered from symlink, search-nudge fired on
greps inside example_kb, validate hook surfaced violations through
both channels in their respective formats, silent path on clean
validate produced no output.
AGENTS.md picks up the snippet (this repo has example_kb, so the
project rules legitimately apply here — dogfooding the install).
Out of scope this commit: Codex / Cursor hook variants (Step 3),
directory cutover (Step 4), the Rust `mdvs scaffold` commands
themselves (Steps 5-7).
…udge, dogfood example_kb
Step 3 of TODO-0190 plus an architectural fix surfaced during the
example_kb test setup. This is the last Unix-only state before the
cross-platform pivot (next commit will move hook logic into mdvs).
Codex + Cursor hook scaffolding
scaffolding/hooks/codex/{validate,search-nudge}.sh + hooks.json
scaffolding/hooks/cursor/{validate,search-nudge}.sh + hooks.json
Same shell skeleton as Claude Code with platform-specific deltas:
- Codex: identical envelope shape, config in .codex/hooks.json
- Cursor: camelCase event name (postToolUse), config in
.cursor/hooks.json
All four new shells flagged with a NOTE comment that they're
documented-schema-correct but only Claude Code is smoke-tested
end-to-end.
Search-nudge extracted to its own .sh per platform
scaffolding/hooks/<platform>/search-nudge.sh (new in all three)
scaffolding/hooks/<platform>/{settings,hooks}.json (updated to
call the script instead of inlining the command)
Reverts the "inline for search-nudge" half of TODO-0190 decision
#11. Reason: the previous inline form scoped via `*kb/*` pattern-
matching on the bash command, which is brittle (only fires when
the user's KB directory happens to contain the literal substring
`kb`, misses greps run from inside the KB, misses absolute-path
greps). The script form uses the same walk-up-to-`mdvs.toml`
logic that validate.sh already does, scoped on the agent's `cwd`
from stdin — proper detection of "are we in a vault" regardless
of how the user named their KB directory.
example_kb full per-harness dogfooding
example_kb/AGENTS.md Prismatiq-style guidance + mdvs snippet
example_kb/CLAUDE.md -> AGENTS.md (single source of truth)
example_kb/.agents/skills/mdvs/SKILL.md -> scaffolding/
(cross-harness path)
example_kb/.claude/ skill + both hook scripts + settings.json
example_kb/.codex/ both hook scripts + hooks.json
example_kb/.cursor/ skill + rules + both hook scripts + hooks.json
All file references in the per-harness directories are symlinks
back into crates/mdvs/scaffolding/, so iteration on the source
flows live into every harness install.
Next step (separate commit / branch decision): pivot hook logic into
mdvs itself for cross-platform support. The current .sh scripts only
work on POSIX systems; Windows users get nothing. Pulling the walk-
up + envelope-wrap logic into a `mdvs hook handle` subcommand makes
it cross-platform (mdvs is already a cross-platform Rust binary) and
eliminates the jq runtime dependency. The scaffolding/ tree will
then hold per-platform CONFIG (toml describing envelope shape, event
names, config file paths) rather than executable scripts. See
discussion in TODO-0190.
…l hooks Mid-implementation realised that the shell-script scaffolding shipped in 63311cf + 01d8ff8 won't run on Windows (POSIX shell + jq). Pulling the hook logic into mdvs itself (already a cross-platform Rust binary) eliminates the OS dependency, drops jq, and lets new platforms be added via a platform.toml config file with no Rust changes. Updates to TODO-0190: - Pivot banner at the top of the TODO summarising the shift - New "Pivot to cross-platform mdvs-internal hooks" section with the full architecture: mdvs hook handle subcommand, per-platform platform.toml config, scaffolding/ as config-only, plain Platform struct (no enum, no dyn Trait — config-driven, new platform = new toml file) - Decisions #11 and #12 re-decided (shell scripts and jq dependency both reversed by the pivot; old decisions kept with strikethrough for history) - Implementation impact section rewritten for post-pivot architecture - Implementation plan restructured: steps 1-3 marked DONE (shell scaffolding), new step 3b records the pivot decision, steps 4-14 cover the new Rust implementation path (platform.toml authoring, Platform struct + loader, mdvs hook handle subcommand, mdvs scaffold commands, example_kb cutover, shell script removal, skill refresh, recipe page, E2E tests, PR) - Verification section adds Windows to the matrix (architectural support; no smoke test yet) Total estimate unchanged: 12-15 hours of focused work. The Rust impl trades off against the shell-script polish that no longer needs doing. The work in commits 63311cf + 01d8ff8 isn't lost — those shell scripts are the executable spec that mdvs hook handle will implement in Rust.
Step 4 of TODO-0190. Per-platform behaviour expressed as data, not
code: each `scaffolding/platforms/<name>/platform.toml` declares the
skill install path, snippet target + body selection, and (where
applicable) the hook config path + event-name + matcher patterns.
The Rust `Platform` struct + loader (Step 5) reads these files via
`include_dir!` at build time. Adding a new harness from here on is
just a new toml file — no Rust changes, no release for end-users.
Five files:
claude-code/ skill .claude/skills/, snippet CLAUDE.md, hooks in
.claude/settings.json, PascalCase events
codex/ skill .agents/skills/, snippet AGENTS.md, hooks in
.codex/hooks.json, PascalCase events
cursor/ skill .cursor/skills/, snippet .cursor/rules/mdvs.mdc
(using cursor-rules body with alwaysApply: true),
hooks in .cursor/hooks.json, camelCase events
opencode/ skill .opencode/skills/, snippet AGENTS.md, no
[hooks] section (TypeScript plugin surface — out of
scope for shell-config hook generation)
antigravity/ skill .agents/skills/, snippet AGENTS.md, no [hooks]
section (upstream docs incomplete post-rebrand)
All five validated with tomllib (Python 3.12 via uv).
…t pivot
Step 9 of TODO-0190 (pulled earlier in the sequence to keep working
state clean while implementing Steps 5-7). The shell scripts and
their installed configs documented the executable spec the new
`mdvs hook handle` subcommand will implement in Rust; they're not
lost work, just no longer the production path.
Deleted:
crates/mdvs/scaffolding/hooks/{claude-code,codex,cursor}/
validate.sh, search-nudge.sh, and the settings.json / hooks.json
config templates. The new templates will be generated at runtime
by `mdvs scaffold hook` from platform.toml + Rust code.
example_kb/.{claude,cursor}/hooks/ + the matching settings.json /
hooks.json config files, plus example_kb/.codex/ entirely (Codex
has no skill install there — it reads from .agents/skills/).
Per-platform skill/rules symlinks stay in place; the no-shell
install regenerates the hook configs in Step 8.
Also dropped the now-stale hooks block from the gitignored
.claude/settings.local.json at the repo root — silences the false-
positive search-nudge that fired on any bash command containing the
literal string "grep" / "find" / etc. (e.g. commit message bodies).
Remaining scaffolding tree is config-only:
crates/mdvs/scaffolding/
├── skill/SKILL.md
├── snippet/{agents-md.md, cursor-rules.mdc}
└── platforms/{claude-code,codex,cursor,opencode,antigravity}/
platform.toml
Ready for Step 5 — Platform struct + loader in Rust.
…o binary
Step 5 of TODO-0190. The Rust shape of `platform.toml`, loaded from an
embedded copy of the scaffolding tree.
crates/mdvs/src/scaffold/mod.rs
Module root. Declares `SCAFFOLDING: Dir<'_>` via
`include_dir!("$CARGO_MANIFEST_DIR/scaffolding")` — embeds the whole
scaffolding/ tree (skill, snippet, platforms) into the binary at
build time. No disk access at runtime; works the same regardless of
where the binary is installed.
crates/mdvs/src/scaffold/platform.rs
`Platform { meta, skill, snippet, hooks: Option<HooksConfig> }` plus
the per-section structs (`Meta`, `SkillConfig`, `SnippetConfig`,
`HooksConfig`) and two small closed-set enums (`SnippetBody`,
`HookConfigFormat`).
Loading via `Platform::load(name)` — reads
`scaffolding/platforms/<name>/platform.toml` from the embedded
tree, returns a friendly error with the available platform names
for unknown inputs. `Platform::list()` enumerates bundled
platforms in alphabetical order.
No `enum Platform`, no `dyn Trait`. The "platforms" axis is data,
not code — new harnesses come from new toml files, never from Rust
changes. The project's enum-dispatch rule still applies to
backends/embedders/etc. where finiteness is a real concern; here
it isn't.
10 unit tests under `scaffold::platform::tests` covering:
- all 5 bundled platforms load
- `Platform::list` returns the right names, sorted
- unknown platform names error with the list of known ones
- Claude Code / Codex / Cursor event-name capitalizations match
- Cursor snippet uses the `.mdc` body, targeting `.cursor/rules/`
- OpenCode and Antigravity have `hooks: None`
- hook matchers are consistent across the three platforms with
`[hooks]` (regression check)
Cargo.toml — adds `include_dir = "0.7"` dep, plus `"scaffolding/"`
to the publish `include` list (keeping `"skills/"` until Step 7
removes the old `mdvs skill` path).
Cargo.lock — picks up include_dir 0.7.4 and include_dir_macros 0.7.4.
Tests: 831 unit + 5 + 8 integration pass on
`cargo test --features testing-mocks`. Clippy clean on
`--all-targets --features testing-mocks`.
Next: Step 6 — `mdvs hook handle --platform <name> --kind <kind>`
subcommand that uses `Platform` at runtime to wrap output in the
right envelope.
New subcommand the agent-harness hook config calls every time the
agent triggers an Edit / Write / MultiEdit / Bash. Reads stdin JSON,
runs the per-kind logic, writes the platform-shaped envelope to
stdout. Cross-platform because mdvs is already a cross-platform
Rust binary; no shell, no `jq`, works the same on every OS.
crates/mdvs/src/cmd/hook/mod.rs
Module root + `HookKind` value-enum (Validate | SearchNudge),
kebab-case in clap.
crates/mdvs/src/cmd/hook/handle.rs
The runtime: `run<R: Read, W: Write>(stdin, stdout, platform,
kind) -> Result<()>`. Two paths:
HookKind::Validate
- Pulls `tool_input.file_path` from the harness stdin.
- Walks up to find `mdvs.toml`; silent outside a vault.
- Runs `cmd::check::run` directly (no subprocess) with
`no_update: true` — hook-triggered validation must never
modify the schema as a side effect.
- Silent on clean (uses `step::has_violations` / `has_failed`,
not output emptiness — `mdvs check --output markdown`
always emits a status line).
- Renders twice: markdown for the agent (uncapped, with skill
pointer appended), pretty for the user (capped at
MAX_USER_LINES=15 with `...` truncation).
- Emits `{hookSpecificOutput: {hookEventName, additionalContext},
systemMessage}` with `event_name` from platform.toml.
HookKind::SearchNudge
- Walks up from the harness's `cwd`; silent outside a vault.
- Matches the bash command against 9 search-tool patterns
(`grep`, `rg `, `ripgrep`, `find `, `fd `, `fdfind `, `ag `,
`ack `, `git grep`).
- Emits the tip via `additionalContext` only — no
`systemMessage` (search-nudge stays out of the user UI).
Platforms without [hooks] (OpenCode, Antigravity) — both kinds
return an error explaining the platform doesn't have a shell-
hook surface and pointing at the scaffold skill+snippet path.
23 unit tests in `cmd::hook::handle::tests` covering:
- payload parsing: empty stdin, full Claude Code shape, extra
fields ignored
- walk_up_to_mdvs_toml: from a file inside the vault, from a
nested path, returns None outside vault
- cap_lines: passthrough under limit, truncation + `...` marker,
trailing newline stripping
- append_skill_pointer: uses the platform's install path
- is_search_command: positive + negative cases for all 9
patterns and common non-matches
- build_envelope: shape + event-name + omits systemMessage
when caller passes None
- run() end-to-end with a tempdir fixture vault:
* validate silent on clean
* validate emits envelope with violations
* validate silent on non-md file
* validate silent outside vault
* Cursor uses postToolUse (camelCase) in the envelope
* platform without [hooks] errors loudly
* search-nudge emits when in vault + search command
* search-nudge silent outside vault
* search-nudge silent on non-search command
crates/mdvs/src/cmd/mod.rs — `pub mod hook;`
crates/mdvs/src/main.rs — new `Command::Hook` variant with a
`HookCommand::Handle { platform, kind }`
subcommand that locks stdin/stdout and
calls into `hook::handle::run`.
End-to-end smoke from the binary:
$ echo '{"tool_input":{"file_path":"example_kb/projects/alpha/overview.md"}}' \
| mdvs hook handle --platform claude-code --kind validate
(silent — no violations in example_kb)
$ echo '{"cwd":"example_kb","tool_input":{"command":"grep foo ."}}' \
| mdvs hook handle --platform claude-code --kind search-nudge
{"hookSpecificOutput":{"additionalContext":"Tip: ...","hookEventName":"PostToolUse"}}
Tests: 854 unit + 5 + 8 integration pass on
`cargo test --features testing-mocks`. Clippy clean on
`--all-targets --features testing-mocks`.
Next: Step 7 — `mdvs scaffold {skill,snippet,hook}` (the install-time
generator that emits the JSON config snippet calling `mdvs hook
handle` from the harness's settings file).
Three install-time generator commands, all reading the bundled scaffolding tree + per-platform `Platform` config. Hard renames `mdvs skill` → `mdvs scaffold skill` per decision #6 (pre-1.0, no alias surface). cmd/scaffold/mod.rs Module root + `ScaffoldCommand` clap enum (Skill, Snippet, Hook). All three subcommands write pure body content to stdout (so `mdvs scaffold skill > .claude/skills/mdvs/SKILL.md` works cleanly) and a platform-specific install hint to stderr. cmd/scaffold/skill.rs Emits the bundled `SKILL.md` to stdout. With `--platform`, the body is unchanged (skill content is harness-agnostic); a `Install to: <path> (under <Display Name>)` hint goes to stderr. cmd/scaffold/snippet.rs Reads the platform's `snippet.body` (from `platform.toml`) to pick between the AGENTS.md body and the Cursor `.mdc` wrapped body. Default (no `--platform`) is the universal AGENTS.md body. Stderr carries the install-path hint. cmd/scaffold/hook.rs Builds the platform's PostToolUse hook config programmatically from `HooksConfig` (event_name, matchers, config_path). Emitted JSON's `command:` fields call `mdvs hook handle --platform <p> --kind <kind>` — no shell scripts to install; the runtime lives in mdvs itself. Includes a `_comment` field at the top of the JSON explaining what to merge where. Refuses for OpenCode + Antigravity with a pointer error that still steers users at `mdvs scaffold skill|snippet` (those DO work for those platforms). 16 unit tests covering: skill — body emission, install hint, unknown platform, that we ship the pivot-era (Step 2) SKILL.md snippet — default body, claude-code uses AGENTS.md body, Cursor uses .mdc body w/ alwaysApply, OpenCode uses AGENTS.md target, unknown platform hook — valid JSON for claude-code, Cursor camelCase event, Codex config path, OpenCode/Antigravity refuse with pointer, unknown platform, emitted commands round-trip through clap's value-enum cmd/mod.rs — adds `pub mod scaffold`. main.rs — removes `Command::Skill` (the old surface that printed include_str!("../skills/mdvs/SKILL.md")), adds `Command::Scaffold { subcommand: ScaffoldCommand }` routing to the three subcommand handlers. `mdvs skill` now errors with clap's "unrecognized subcommand" message. End-to-end smoke from the binary: $ mdvs scaffold skill > /tmp/SKILL.md # universal body $ mdvs scaffold snippet --platform cursor # .mdc with frontmatter $ mdvs scaffold hook --platform claude-code # JSON config $ mdvs scaffold hook --platform opencode # refuses with pointer $ mdvs skill # unrecognized subcommand Tests: 870 unit + 5 + 8 = 883 passing. Clippy clean on `--all-targets --features testing-mocks`. Next: the old `crates/mdvs/skills/` directory + the `"skills/"` entry in Cargo.toml's `include` are now orphans (nothing in code references them). Step 8 (example_kb cutover to the no-shell install) and a small Cargo.toml cleanup follow.
…ates
Cursor uses a substantially different hook envelope and config shape
than Claude Code / Codex (snake_case `additional_context`, no wrapper,
no user channel for postToolUse, flat matcher entries, top-level
`"version": 1`). The previous design baked one shape into Rust and
only let `event_name` vary per platform — fundamentally incompatible
with Cursor's actual schema.
Pulls the per-harness JSON shape into platform.toml as `<<NAME>>`-
placeholder templates. Adding a new harness with any JSON shape is now
one toml file; no Rust changes.
src/scaffold/template.rs (new)
`substitute(template, vars)` walks a parsed JSON Value, replacing
`<<MARKER>>` string nodes with values from `vars`. Two rules:
- vars[name] == Some(value) → replace marker string with value
- vars[name] == None (or missing) → prune the containing key (in
objects) or element (in arrays)
14 unit tests cover: bare substitution, prune-on-None for both
keys and array elements, partial markers stay literal, nested
objects recurse, non-string values pass through, empty-name
`<<>>` left literal, empty-string Some("") substitutes (not
pruned), realistic Claude-Code-shaped and Cursor-shaped templates.
src/scaffold/platform.rs
`HooksConfig` shape change:
- Dropped fields: `event_name`, `matcher_validate`, `matcher_search`
(they live inside the templates now as literals)
- Added fields: `envelope: serde_json::Value`,
`config: serde_json::Value`
Both parsed from `[hooks.envelope] template = "..."` and
`[hooks.config] template = "..."` TOML tables via a custom
`deserialize_with` — malformed JSON in a template surfaces at
platform-load time, not at runtime.
Platform-level tests rewritten to inspect the *template content*
instead of struct fields (e.g. `envelope_str.contains("PostToolUse")`
instead of `hooks.event_name == "PostToolUse"`).
scaffolding/platforms/{claude-code,codex,cursor}/platform.toml
Rewritten to declare the envelope and config templates inline.
Claude Code and Codex share an identical shape; Cursor's templates
are structurally different — and the Rust code doesn't care.
src/cmd/hook/handle.rs::build_envelope
Old: hand-built JSON via `json!` macro with conditional
insertion of `systemMessage`.
New: load HashMap of vars (MSG=Some(agent_msg), USER_MSG=user_msg)
and call `template::substitute(&hooks.envelope, &vars)`.
Per-kind difference (validate vs search-nudge) lives in the
runtime: validate passes USER_MSG=Some(...), search-nudge passes
None. Per-platform difference lives in the template: Claude
Code's template has `<<USER_MSG>>` → pruned for search-nudge;
Cursor's template doesn't reference it at all.
src/cmd/scaffold/hook.rs::build_config
Old: hand-built JSON object with platform-specific event-name key.
New: `template::substitute(&hooks.config, vars)` with two vars
(COMMAND_VALIDATE, COMMAND_SEARCH). The `_comment` field is
injected at the top of the resulting object programmatically
so platform.toml templates stay focused on the schema.
Test updates
Two existing tests checked the OLD Cursor schema (camelCase
`postToolUse` inside the nested Claude-Code shape). They now
check the CORRECT Cursor shape: top-level `additional_context`
snake-case for the envelope; `version: 1` + flat matcher entries
for the config.
Verification (real binary output):
$ mdvs scaffold hook --platform cursor
{ "version": 1,
"hooks": { "postToolUse": [
{ "command": "...", "matcher": "Edit|Write|MultiEdit" },
{ "command": "...", "matcher": "Bash" } ] } }
$ mdvs scaffold hook --platform claude-code
{ "hooks": { "PostToolUse": [
{ "matcher": "...", "hooks": [ { "type": "command",
"command": "..." } ] }, ... ] } }
Two structurally different JSON shapes, one Rust codepath. The
"per-platform behavior is data" promise of the pivot is now real.
Tests: 885 unit + 5 + 8 = 898 passing. Clippy clean on
`--all-targets --features testing-mocks`.
Next: Step 8 (cutover example_kb to no-shell install) is naturally
unblocked — `mdvs scaffold hook --platform <name>` now produces
correct configs for all three hook-supporting platforms.
Step 8 of TODO-0190. Replaces the deleted .sh-based hook installs (removed in ff3ec76 during the pivot) with the post-pivot equivalents: JSON configs emitted by `mdvs scaffold hook --platform <name>` that call `mdvs hook handle` directly. No shell scripts, no `jq`, no symlinks under example_kb's hook directories. example_kb/.claude/settings.json (new) example_kb/.codex/hooks.json (new) example_kb/.cursor/hooks.json (new — uses Cursor's flat shape with version: 1, postToolUse, flat matcher entries) Each file generated verbatim via: $ mdvs scaffold hook --platform <name> 2>/dev/null > <config> JSON validity, no `jq` references, all three platforms emit the schema documented for each harness. The Cursor config in particular now matches the actual schema verified against cursor.com/docs/hooks (rather than the Claude-Code-shape extrapolation we shipped pre-pivot). These configs activate only when a harness session is opened on example_kb/ as the workspace root — they're inert in any session rooted elsewhere. The end-to-end gate test (Step 12) is the deliberate-violation cycle against a fresh Claude Code session in example_kb/. Runtime prerequisite for the hooks to actually fire: `mdvs` must be on the harness's PATH. `cargo install --path crates/mdvs` is the expected install path; ~/.local/bin/mdvs symlink works for dev.
The old `crates/mdvs/skills/mdvs/SKILL.md` was the source for the now- removed `mdvs skill` subcommand (replaced by `mdvs scaffold skill` which reads from `crates/mdvs/scaffolding/skill/SKILL.md` instead). Nothing in code references the old path anymore — verified by grepping `../skills/` across `crates/mdvs/src/`. Also drops the `"skills/"` entry from Cargo.toml's `include` list so the published crate doesn't bundle the orphaned file. Tests: 898 still pass on `cargo test --features testing-mocks`. No behaviour change — just removing dead bytes from the tree.
Step 10 of TODO-0190 (skill refresh) plus a new spec page covering
the scaffolding subsystem and how to add a new platform.
SKILL.md changes (crates/mdvs/scaffolding/skill/SKILL.md)
- Removed the "hook output format" subsection that taught the
agent about `jq` and the raw `hookSpecificOutput` envelope. The
agent doesn't need to know the wire format — mdvs handles the
per-harness wrapping internally. Now the skill just says "a
markdown block lands in your context from a PostToolUse hook"
without naming the specific channel field (since it varies per
harness: Claude Code's additionalContext vs Cursor's
additional_context, etc.).
- Reframed the install steps to call `mdvs hook handle` from a
one-line `command:` in the harness settings file, rather than
installing shell scripts that pipe through jq.
- Kept the schema-evolution loop intact (Step 4 of "What you must
do when invoked") — that's the agent's actual contract and
hasn't changed.
docs/spec/scaffolding.md (new — 217 lines)
Comprehensive design doc for the scaffolding subsystem:
- Overview of the four commands (`mdvs scaffold {skill,snippet,
hook}` + `mdvs hook handle`)
- Directory layout under `crates/mdvs/scaffolding/`
- `platform.toml` schema (every field documented)
- Template substitution mechanics: marker syntax, the three
rules (Some/None/missing), the prune-on-None behaviour with a
concrete Claude Code search-nudge example
- How `mdvs scaffold hook` and `mdvs hook handle` use templates
- **"Adding a new platform" walkthrough** — three categories:
(A) Claude-Code-style hook config (copy + tweak),
(B) completely different JSON shape (write template from
scratch — Cursor was the test case),
(C) non-shell hook surface (skill + snippet only).
- Hard-coded `<<MARKER>>` set with where each comes from
- Rust module shape, why no enum / no dyn Trait for Platform
- Why hooks live in mdvs and not in shell scripts (cross-platform,
no jq dependency, single testable contract)
docs/spec/architecture.md
- Module tree updated: `cmd/scaffold/`, `cmd/hook/`, `scaffold/`
- Overview gains a paragraph naming the third subsystem
(alongside Validation + Search) and pointing at scaffolding.md
Tests: 898 still pass on `cargo test --features testing-mocks`. No
code changes — pure docs.
Step 11 of TODO-0190. Splits the old 141-line `agentic-harnesses-and-
agentic-ides.md` monolith (which still had pre-pivot shell-script
content) into an overview page plus one page per supported harness.
Three audiences, one page shape per audience:
- End user with a supported harness: scrolls to their platform,
copy-pastes three install commands. Same shape on every
per-platform page, different paths and quirks filled in.
- Evaluator: reads the overview's "The shape" section, sees the
data-driven architecture, finds the per-platform support
matrix.
- Contributor / advanced user with an unsupported harness: lands
in the overview's "Extending to a new harness" section, follows
a worked end-to-end example (a hypothetical FooAgent), sees the
three categories (Claude-Code-style, novel shape, no-shell-
hooks), knows where to look for deeper internals
(docs/spec/scaffolding.md), knows where to open an issue if the
template approach can't accommodate their harness.
Pages:
recipes/agent-harnesses.md (new, overview)
"The shape" — architecture in ~3 paragraphs.
"How violations reach the agent" — runtime story.
"Per-platform support" — matrix table with linked pages.
"Extending to a new harness" — Option β worked example. Walks
through creating a FooAgent platform.toml from scratch,
rebuilding mdvs, verifying via the scaffold commands. Names
the three category extensions and what to do when the
template approach falls short (open an issue).
"Pre-commit hook" — kept from the old page.
"What's tested" — honest scoping matrix.
recipes/agent-harnesses/{antigravity,claude-code,codex,cursor,opencode}.md
Per-platform pages, alphabetical, same template:
Install commands → What you get → Per-platform notes → Sources
Callout at top of each non-tested page; no callout on
claude-code (the one we verified end-to-end). OpenCode and
Antigravity get a "skill+snippet only" callout explaining why
hooks aren't installed.
book/src/SUMMARY.md
- Agent harnesses chapter promoted to FIRST under Recipes
(mdvs's primary use case; obsidian/hugo/ci come after).
- Six nested entries under it (overview + 5 per-platform pages).
- Old monolith link removed.
book/src/recipes.md
Same reordering — Agent harnesses now leads the index, with a
summary describing the new structure (per-platform pages plus
architecture + extension story in the overview).
book/src/recipes/agentic-harnesses-and-agentic-ides.md (deleted)
Content replaced by the new tree.
Book builds cleanly via `mdbook build book`. No tests touched.
…ridge plugin - Replace "we/us/by us/reviewers" with impersonal phrasing across the agent-harnesses chapter (single-developer repo — first-person plural reads strangely). - Document antigravity's user-level-only hook surface with a workaround section pointing at ~/.gemini/config/hooks.json. - Document the opencode TypeScript bridge plugin (opencode#12472) and ship a working reference at crates/mdvs/examples/opencode-plugin/ mdvs-hooks.ts that calls `mdvs hook handle --platform claude-code` and forwards the envelope via client.session.prompt().
…write `tags = 'rust'` fails at Lance/DataFusion (List(Utf8) vs Utf8 scalar comparison); the working form is `array_has(tags, 'rust')`. Update the documented examples in README.md, --help long_help, and the bundled SKILL.md so users (and agents reading them) don't hit the Lance-level error. TODO-0191 captures the real fix: extend the --where translator to auto-rewrite array-field equality.
When mdvs is compiled with cfg(test) or --features testing-mocks, the `mutate_config` step in `build` synthesizes a mock embedder default for fixture vaults missing an [embedding_model] block. Until now that default was also written to disk via `config_changed = true`, so a dev/test binary running auto-build against a real vault would leak `provider = "mock"` into the user's mdvs.toml — corrupting the vault for the production binary on PATH, which refuses the mock provider. Split the None arm into two cfg-gated branches: production persists, test/mock stays in-memory only. Existing build tests still see the mock embedder; the new regression test in config_mutate's `tests` module asserts that mdvs.toml on disk never gains an [embedding_model] block or the literal `mock` under cfg(test). Closes TODO-0192.
Bump the production default embedding model from `potion-base-8M`
(English-only, ~60 MB) to `potion-multilingual-128M` (101 languages,
~480 MB). New vaults get multilingual coverage out of the box; English-
only vaults can opt down via `mdvs build --set-model minishlab/
potion-base-8M --force`.
Touched: DEFAULT_MODEL constant, example_kb/mdvs.toml, scaffolded
SKILL.md, README, all mdbook pages (configuration, getting-started,
search-guide, concepts/search, commands/{build,info,search}), and the
test fixtures in schema/{config,shared}.rs and index/storage.rs that
embed a model name. The `TEST_MODEL` constant in index/embed.rs's
real-model slow lane stays at potion-base-8M to keep first-run
downloads cheap — that lane exercises the loader, not quality.
Both models share dim=256, so existing indexes built against
potion-base-8M remain shape-compatible (a build with --force still
re-embeds, as the config-change check requires).
Two patched advisories fall out of this: - RUSTSEC-2026-0186 (memmap2 unsound; unchecked pointer offset in advise_range/flush_range syscall handoff): bumped 0.9.10 → 0.9.11. Came in transitively via lance-tokenizer → lindera; not exploitable in mdvs (lindera memory-maps its own bundled dictionary files at known sizes, no user-controlled offsets), but the Rust-level UB is why the audit gate failed PR #66. - RUSTSEC-2026-0185 (quinn-proto 0.11.14 remote memory exhaustion from unbounded out-of-order stream reassembly; CVSS 7.5): bumped 0.11.14 → 0.11.15. Came in via lance's object-store stack. Also picked up opportunistic minor bumps across rustls, jiff, log, quote, webpki-roots, etc. Pruned a handful of transitives that fell out (wit-bindgen family, wasm-encoder, leb128fmt). 894 tests + clippy clean after the sweep. The deny.toml ignore list is unchanged — every entry still fires under cargo deny (verified by emptying the list and re-running). Each ignored advisory is `unmaintained`, has no patched version, and is either compile-time only or guarded by mdvs not exposing the relevant API to user input.
TODO-0190 (mdvs scaffold) shipped on this branch via PR #66 — flip to done with a Resolution section summarizing what landed (scaffold + hook handle, five platforms, recipe pages, scaffolding spec), what's deferred (TODO-0186 / TODO-0188), and the file impact. The design narrative below remains as audit trail, including the mid-implementation pivot from per-platform shell scripts to mdvs- internal hooks — the reasoning (Windows support, drop jq dep, no shell variance) is load-bearing for any future v2 design. TODO-0187 (agent harness hook recipe) is fully covered by what 0190 shipped — mark done + subsumed_by 190. Original scope retained.
… cells
`mdvs build --output markdown` was emitting the per-file list as a
single key-value table row, joining all filenames with `\n` which
escape_cell then converted to `<br>` for GFM-table safety. On a 584-
file vault the result was a single GFM cell with hundreds of files
jammed into it via `<br>` — unreadable in any renderer. Terminal
output was also bad (the same multi-line cell stretched the table
border into a wall).
Drop `embedded files` and `removed files` from the summary key-value
table. Emit them after the table as Block::Section { label, children }
so both formatters render them the right way: markdown gets a proper
`## embedded files` heading with one file per line; pretty gets an
`embedded files:` header with indented child lines. Empty lists emit
no section at all (no orphan heading with nothing under it).
Four regression tests in outcome::commands::build cover: the summary
table no longer carries the row, the Section block exists with the
right children, markdown emits `## embedded files` (not <br>-joined),
and empty lists stay quiet.
…based)
Closes TODO-0191. `--where "tags = 'rust'"` against an Array(String)
field no longer fails at Lance — the translator detects the array-vs-
scalar shape and rewrites to `array_has(data.tags, 'rust')`. Four
rules, both operand orderings supported:
tags = 'x' → array_has(data.tags, 'x')
tags != 'x' → NOT array_has(data.tags, 'x')
tags IN ('x', 'y') → array_has(data.tags, 'x') OR array_has(...)
tags NOT IN ('x', 'y') → NOT (array_has(data.tags, 'x') OR ...)
Element semantics for ! and NOT IN — "the array does not contain this
element", which is what users expect; the "array doesn't equal the
singleton" interpretation is what 1% of users mean and Lance can't
express it cleanly anyway. Documented as the contract.
Translator switched from a 100-line regex pass to a sqlparser-rs AST
walk. The regex shape was already at the limit of what it could
handle; adding the four new rules above plus the function-call
invariant ("don't double-rewrite array_has(tags, 'x')") would have
compounded fragility. The AST gets structural awareness for free —
Expr::BinaryOp { op: Eq, left: Identifier, right: Value } is exactly
the pattern we want, an Identifier nested inside Expr::Function is
structurally distinct from a top-level operand, etc. sqlparser was
already in the dependency graph transitively via datafusion → lance,
so no new tree weight; added as a direct dep pinned to the version
Lance uses to keep emitted SQL parseable downstream.
Rewrites are surfaced to the user as a Note block at the top of the
search output ("Note — rewrote N array-field expressions for List-
column matching: tags = 'x' → array_has(data.tags, 'x')") so the
rewrite isn't magic.
Tests: 10 new in translator (4 rules × 2 orderings + 4 invariants).
9 existing tests' expected strings updated for sqlparser
canonicalization (AND/OR uppercase, DATE/TIMESTAMP keywords
uppercase, != normalized to <>, function-call whitespace). All
semantics preserved.
Docs: README, --help long_help, and bundled SKILL.md switched back
to `tags = 'x'` as the natural form (was `array_has(tags, 'x')` —
the documentation-only fix from PR #66).
The note rendered as `tags = 'x' → array_has(data.tags, 'x')` — leaking the internal `data.` Lance prefix that the translator exists specifically to hide from users. Switched `make_array_has` to emit an unqualified `Expr::Identifier` for the column argument; the recursive walk that runs after still qualifies it to `data.tags` for the Lance side, but the rewrite string is captured before that walk, so the note reads `tags = 'x' → array_has(tags, 'x')`. Lance still receives `array_has(data.tags, 'x')` — no behavior change, just a cleaner abstraction at the user-facing layer.
Agents (and humans) consistently read --where as "frontmatter filter" because that's how SKILL.md, README, and commands/search.md introduce it — even though the translator routes references to the always- present `filepath` column straight through to Lance and has all along. Add filepath examples to all three surfaces, with an explicit note that the last path component is the filename (so `LIKE '%foo.md'` is the filename-match idiom). Also a one-line model note: --where operates on ANY column in the Lance index, not just frontmatter — both auto-discovered fields and the persistent filepath. Other internal columns exist (start_line, end_line, built_at, chunk_text) but are rarely useful for filtering, so they stay out of the examples to keep noise down.
Initial smoke tests on the four non-Claude-Code harnesses turned up worse results than the docs claimed: - Codex: hook firing inconclusive (no observable feedback either way) - Cursor: hook not observed firing after a markdown edit - OpenCode (TS bridge plugin): not observed firing either - Antigravity: hooks remain not-applicable (user-level only upstream) Update the per-platform support table, the per-harness callouts, the "What's tested" section in the recipes overview, and TODO-0190's resolution to be honest about this. Only Claude Code's hook path is verified end-to-end. The skill and snippet halves work everywhere they were tried. Re-frame the OpenCode bridge plugin as a "reference, please help" rather than "working reference implementation". Invite PRs that diagnose specific failure modes.
…ommit) The previous commit was intended to update these two as well but the edits errored out silently and didn't land. Re-applying.
…atforms Live smoke tests against Refractions showed the cursor + codex hook configs (built from each harness's docs) didn't fire, and the OpenCode bridge plugin didn't load. Rather than ship integrations that don't work, retract them and point users at the per-platform mdbook page where mdvs documents the current state and how to wire `mdvs hook handle` into each harness's own hook system using its docs. Code changes: - Drop the [hooks] table from cursor/platform.toml + codex/platform.toml. - `mdvs scaffold hook --platform <name>` for any non-claude-code platform now refuses with a one-line pointer at the per-platform mdbook page (https://edochi.github.io/mdvs/recipes/agent-harnesses/ <name>.html) plus the unchanged skill/snippet install commands. - Update each platform.toml's `documentation_url` to point at the same mdbook page (the place we control and can keep updated as integration status changes). - Delete crates/mdvs/examples/opencode-plugin/mdvs-hooks.ts — the TypeScript bridge plugin that didn't fire in smoke testing. - Drop the four cursor-envelope tests (no longer relevant) and consolidate the per-platform-table tests into one `only_claude_code_ships_hooks` regression check. 895 unit tests pass; refusal tests added for cursor + codex. Skill and snippet halves are unchanged — they work in every harness that loads them via the standard paths and remain shipped for all five.
`crates/mdvs/examples/{probe_lance_incremental,profile_pipeline}.rs`
are internal dev probes (LanceDB incremental-write surface check,
pipeline profiler) — not user-facing usage examples. They were being
built by `cargo build --all-targets` and `cargo clippy --all-targets`
(CI's default), even though no other build path needs them and they
aren't shipped to crates.io.
Add a `dev-examples` feature and tag both examples with
`required-features = ["dev-examples"]`. Effect:
cargo build --all-targets # skips examples ✓
cargo clippy --all-targets --features ... # skips examples ✓
cargo build --release / cargo install # already skipped ✓
cargo build --examples # "no targets matched" warning ✓
cargo run --features dev-examples --example probe_lance_incremental
# still works on demand
Crates.io publish already excludes examples/ via the include = [...]
list in Cargo.toml — that's unchanged.
…re examples Three doc surfaces aligned to the scope reduction in the previous fix: **Book — agent-harness recipes.** - agent-harnesses.md: dropped "Reality check" + "What's tested" scary sections; reduced the per-platform table to Skill / Snippet / Hooks (Claude Code ✓ for hooks, others link directly to the harness's own hook documentation); fixed the broken Gemini CLI hooks docs link (was 404). - Per-harness pages (codex/cursor/antigravity/opencode): each gets a tight 2-paragraph Hooks section — "mdvs doesn't ship a verified config; follow [harness] docs" + the pre-commit fallback pointer. No alarmist callouts. - pre-commit-hook section expanded: defines what a pre-commit hook is, walks through install (incl. uv option), explains each YAML knob, flags the GUI-git-client PATH gotcha, notes the language: rust / additional_dependencies pin alternative. **SKILL.md — agent skill content.** - Reframed scaffold + harness-integration narrative around "Claude Code: full integration; others: skill + snippet". Removes claims the agent shouldn't trust (Cursor's `additional_context`, etc.). - --where reference rewritten into 7 capability categories (scalar comparison, LIKE, null filters, array fields, dates, paths, combinations, functions). Every clause was end-to-end verified against Refractions; 38/38 parse and execute cleanly. - All real-name examples (Pat Helland, Simon Peyton Jones) replaced with fictional placeholders (Federica Bianchi, Lorenzo Conti). - Added a Things-to-know bullet documenting .mdvsignore + .gitignore scan behavior (per crates/mdvs/src/discover/scan.rs:248-249). **Book — introduction.** - Same name swap (Giulia Ferretti → Federica Bianchi) for consistency. - "The problem" → "The challenge". **TODO-0190.** - Resolution updated to reflect what actually shipped (Claude Code hooks verified; others: skill + snippet only).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ships TODO-0190:
mdvs scaffold {skill,snippet,hook}andmdvs hook handle, a cross-platform install + runtime surface for wiring mdvs into agent coding harnesses (Claude Code, Codex, Cursor, Antigravity, OpenCode). Also closes TODO-0192 (mock-embedder leak), opens TODO-0191 (array=rewrite), and ships a few collateral fixes.Headline changes
mdvs scaffold skill | snippet | hook --platform <name>— installs a per-platform agent skill, prefers-mdvs-search snippet (appended to the rule file), and a PostToolUse hook config. The hook config delegates tomdvs hook handle, so the JSON envelope shapes (hookSpecificOutput.additionalContextvs Cursor's flat snake-case form) are mdvs's responsibility, not the user's.mdvs hook handle --platform <name> --kind <validate|search-nudge>— reads the harness's tool-call JSON on stdin, walks up to findmdvs.toml, runscheck(validate) or formats a search nudge (search-nudge), and prints the platform-specific envelope. Silent on clean / outside-a-vault / hook-disabled — never interrupts the agent.crates/mdvs/scaffolding/<platform>/platform.tomldeclares the JSON skeleton with<<MARKER>>placeholders (prune-on-None). Adding a sixth harness is a config edit, not a new Rust enum variant.crates/mdvs/examples/opencode-plugin/mdvs-hooks.ts.Collateral
mdvs.toml(TODO-0192). A dev/test binary running auto-build against a real vault no longer writesprovider = \"mock\"into the user's committed config and breaks it for the production binary.potion-base-8M(English-only, ~60 MB) topotion-multilingual-128M(101 languages, ~480 MB). Both share dim=256, so existing indexes are shape-compatible.tags = 'rust'examples in README /--help/ SKILL.md — Lance rejects scalar=List comparison; usearray_has(tags, 'rust'). TODO-0191 opened for the proper auto-rewrite in the--wheretranslator.Out of scope
--whereauto-rewrite) — documented, deferred..cast/.gifrebuild around the new model + new scaffolding (TODO-0178 territory).Test plan
cargo test --features testing-mocks— 886 unit + 8 integration + 21 + 31 doc/property tests pass.cargo clippy --all-targets --features testing-mocksclean.cargo fmtclean.mock_embedder_default_is_not_persisted_to_mdvs_tomlasserts test-modemutate_configdoesn't write[embedding_model]to disk.CLAUDE.md,mdvs hook handlefires from PostToolUse, violations reach the agent asadditionalContext.