v1.2.0 — Checks that exercise what they report
Summary: doctor reported on things it had not looked at, and three of those gaps close here. The bundled assets are opened before setup links them, a hook matcher is only answered from a subset both engines were measured to read alike, and — behind a flag, because it means running a string out of a settings file — the registered interpreter can be asked whether mpg actually loads in it.
Added
mpg doctor --run-interpreterruns the hook's registered interpreter and reports whether mpg actually loads in it. Without the flag nothing is executed, which is the same behaviour as before and the reason the flag exists: closing this gap means running a string out of a settings file, and a settings file can arrive with a cloned repository. What is checked is the output rather than the exit status — a script that ignores its arguments and exits 0 passes every shape check and every exit-status probe while loading nothing, measured before the check was designed around it — sopresentrequires a line naming mpg and a version. Any version counts: a hook wired to an interpreter holding an older mpg is working, and comparing against the running installation's own version would repeat the mistake the symlink and MCP channels each avoid deliberately. A non-zero exit, output that is not a version line, or an interpreter that cannot be started at all readsdegraded— the last one because Claude Code spawning the same hook meets the same wall, so it is a measured failure rather than an unmeasured one; a timeout, a failure to start for reasons of the running process, or more than four distinct commands to try, readunknown. The execution is bounded: no stdin, four kilobytes of output read, five seconds per interpreter, its own process group killed on timeout, an environment holding onlyPATHandHOME(enough for a pyenv or conda shim to start, and nothing that carries a secret), a scratch working directory, and four interpreters at most. Three of those bounds exist because review broke the first version without them: draining the pipe after a timeout waits on an escaped grandchild that still holds it, and the command never returned; draining it at all reached 2.3 GB resident againstcat /dev/zero; and accepting any space-free token as a version let a child print an erase-line escape and rewrite doctor's own verdict on the terminal. What the bounds do not cover is written beside them rather than left to be discovered — a child that callssetsid()outlives the group kill, the network is open to it, and it can write anything the invoking user can write. (closes #236)
Fixed
mpg doctorreportedpresentfrom evidence that did not establish it, and now measures what it claims. The two symlink channels were judged by the name of the target: a directory calledmodern-python-guidancewith nothing in it, or an emptymodern-python.md, read as healthy — the decay the module's own docstring opens by naming, a link whose target moved still resolving as a name. Both paths topresentnow open the target and read a byte through a non-blocking descriptor confirmed to be a regular file, so aSKILL.mdsymlinked to a fifo cannot hang the command instead of answering it. The hook channel looked at whether the interpreter path existed and at nothing else: it never readargs, never readtype, and never read thematcherthat decides whether the hook fires at all, so a registration pointing atBash— one that cannot run on an edit — reported healthy, and so did/, which exists and is not a file. It also read only the first mpg entry in the file, whilemerge_hookhas always promised to converge "from ANY starting state", meaning a second, broken registration sat behind a healthy verdict. Every mpg entry is now examined, and the count that matters is per hooked tool rather than per group:matcher: "Edit"besidematcher: "Write"covers what mpg's own matcher covers and is not a duplicate, while two entries inside one group are. Matchers follow the documented rule, where simple characters mean an exact name or a|/,-separated list of exact names and anything else means an unanchored regular expression; what this process cannot evaluate is reportedunknownand neverdegraded, because Claude Code evaluates the regular-expression form in JavaScript and calling a matcher broken on the strength of Python's disagreement would report a working registration as broken. Failing to compile turned out to be only half of that problem, and the narrowing that closes the other half is the entry below.doctorexecuted nothing at all when this shipped — the interpreter path in a settings file is an arbitrary string, and running it to find out whether the hook works would make a read-only diagnostic a way to run whatever a settings file names, including one that arrived with a cloned repository. The gap that left was stated in the README rather than hidden, and the entry below closes it behind a flag rather than by default. (closes #231, #232, #233, #234)link_statepromised in its docstring that a link it cannot walk is answered as stale, andos.readlinksat outside thetrythat made that true — a second syscall against a pathis_symlink()no longer owns, which took the caller down instead of classifying. Moving it inside widened whatstalecovers, andsetupdeletes on that verdict, so the fix had to travel: a path that stopped being a symlink is now refused the way the flattened branch already refuses it, rather than unlinked. Measured against the previous code, which printedAgent Skills linkedand returned success after deleting the real file it had been pointed at.mpg setuplinked a project at a bundled source it had never opened._find_skills_diraccepted the directory onis_dir()and_find_rule_sourceaccepted the rule onis_file(), so a packaging accident shipping an emptyskills/modern-python-guidance/satisfied both: setup printedAgent Skills linked …and exited 0, and onlydoctor— run later, if at all — reported that the installation delivers nothing. Both linking call sites now open the source and read a byte before creating the link, using the same content predicatedoctormeasures links with, and refuse with the hollow path named;--dry-runrefuses too, rather than promising a link the real run would decline. The locators themselves stay shape-only, which is the opposite of what the issue proposed: a content predicate inside them stopsdoctorlocating a hollow source at all, so the channel answersunknown("cannot locate") before reaching the branch that says the link is right and there is nothing behind it — turning a measured breakage into an unmeasured one, anddoctor's exit from 1 into 2, against what this file's own[Unreleased]entry above and the README both promise. That direction is now held by a test walking the real locator rather than a patched one; every existing test monkeypatches the locators and none would have caught the regression. The predicate moved tosetup_cmd, besidelink_state, the other classification setup and doctor share, and opens withgetattr(os, "O_NONBLOCK", 0): the bare attribute raisesAttributeErroron Windows, past everyexcept OSErrorin the callers, which would have takenmpg setupdown before it reached MCP or hook registration — a platform the README already documents as losing the two symlink steps and keeping the rest.scripts/verify_wheel_assets.pygained the same check on both assets; the rule file had none at all, so an emptymodern-python.mdshipped undetected. Three test fixtures createdSKILL.mdwithtouch(), pinning "an installation that delivers nothing sets up fine" — the bug itself — and now write content. (closes #238)mpg doctoranswered for an engine it does not run. The regular-expression form of a hook matcher was evaluated with Python'sreand the result reported as what Claude Code would do, but Claude Code evaluates it with JavaScript'sRegExp, and the two languages differ at the edges:(?P<x>Edit)|Writefires in Python and is a syntax error in JavaScript, so the hook never runs while the channel readpresent— a silently wrong answer, which is the one failure mode a diagnostic cannot afford. That form is now evaluated only when the pattern is written in a subset measured to mean the same thing in both engines, and only against a plain tool name; anything else readsunknown. The subset was drawn by exhaustive sweep against node and CPython rather than by reasoning about which constructs differ, and the difference mattered: a first subset chosen by hand admitted( ) + ?and let through every possessive quantifier —Edit*+fires in Python, which added them in 3.11, while JavaScript has no such syntax — disagreeing on 555 of 69,104 admitted patterns, every disagreement a possessive, on every interpreterrequires-pythonallows. Dropping+and?makes those unspellable and dropping(alongside keeps the test single rather than a set plus a(?exception: 271,452 patterns of length five or less, no disagreement. The cost runs one way and is deliberate — matchers that do work, like(Edit|Write)or\d, now readunknown(exit 2, visible) rather thanpresent(silent when wrong), and the channel's detail says whatmpg setup --with-hookwould do, since a matcher whose evaluation is what failed carries nofix. Two limits are stated rather than implied: the simple form (Edit|Write,Edit, Write) reaches no engine on either side and is still answered from the documented rule, and the subset does not make evaluation terminate —".*" * 200 + "Z"is admitted and does not answer within 20 seconds, which predates this change and is tracked in #242. (closes #237)