1.0.1: the setting 1.0.0 documented but never read, the MCP seam, a recovered melody, and a demo that agrees with the compiler - #3
Merged
Conversation
A plan for what Plainsong becomes, written as a first draft on purpose. Two of its six phases open with [research] because I do not know the answer yet, and a plan that hides its unknowns is a wish. The finding that reorders the brief: rows subdivide independently and non-sounding rows carry no time at all. `Lyrics: | the tide came |` divides a bar into thirds while `Chords: | Am . . . |` divides it into quarters, so "came" -- written directly beneath "C5" -- is sung a twelfth of a bar away from it, and lyrics have no internal position whatsoever. The alignment a reader trusts is a fiction the compiler cannot see, warn about, or render. That single gap is the root of three of the four stated challenges. A renderer has nothing to lay lyrics against; a merge tool has no coordinate to detect conflicts in; a linter cannot express "this row disagrees with its neighbours". So the plan is built around one structure -- a time matrix giving every token in every row a position whether or not it sounds -- and everything downstream is a projection of it. SVG becomes a coordinate transform rather than a layout engine. Merge becomes set intersection, which makes row-disjoint concurrent edits provably safe rather than heuristically safe. Worth recording, because it corrects the brief: parsing is already whitespace invariant. Tight, loose, ragged and tab-separated layouts of the same bar produce byte-identical output, so monospace alignment is a human convention and not a machine dependency. The decoupling that was asked for is already there; the problem is one layer down. The invariants section is scars rather than style. Measure where the decision happens -- averaging voicing strategies over the whole corpus read 99.4% against 99.9% and was useless, while scoring only the chords where a note must be dropped read 50.1% against 94.4%. Absence of imports measures coupling, not life. Never re-record a fingerprint baseline to make CI green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
The two scouts came back and turned Phases 2 and 3 from open questions into decisions. Both worked around a blocked egress proxy by cloning the projects' own source repositories and reading the documentation at HEAD, which is the same primary-source standard reached a different way, and both flagged what they could not verify instead of filling the gaps in. Lyrics. Syllables bind to notes, never to raw time -- uncontested across LilyPond, MusicXML, ABC, MEI and Humdrum, which disagree on mechanism and not on the target. So a lyric row subdividing its bar independently of the melody is not a variant convention, it is simply wrong. The barline resyncs, quoted from the ABC standard: "if there are not as many syllables as notes in a measure, typing a | automatically advances to the next bar." A miscount costs one bar and then recovers instead of shifting every remaining word. Plainsong already writes | in every row, so the mechanism is in the syntax today and merely unhonoured. And the part worth the whole errand: melisma and skip need no new characters. A syllable held across notes is a sustain; a note with no syllable is a rest. Plainsong's existing token classes already mean exactly those. The mapping is by meaning rather than by borrowing a spelling, which matters because the spellings conflict -- `_` is a skip in LilyPond and a melisma in ABC, and Plainsong's own semantics land on the right side of both. One genuine conflict surfaced. Every other format stacks repeated lyric rows as parallel verses; Plainsong's rule is that a repeated row of the same kind runs on in time. Two `Lyrics:` rows therefore mean verse-then-verse here and verse 1 over verse 2 everywhere else. One reading has to give, and the run-on rule is load-bearing for `Melody:`. Recorded rather than resolved, because it needs deciding before the renderer has to draw them. SVG. Text measurement is solved and it is what decides the design: `textLength` and `lengthAdjust` are geometry rather than CSS, so the browser fits text to a width we declare. Compute from the Core-14 AFM tables in Python, emit `textLength` to match, and the rendering snaps to the intended width even under font substitution. No font library, no measurement pass. Everything scales from one number, because SMuFL fixes 1 staff space = 0.2 em. Horizontal space is logarithmic in duration, with LilyPond's constants giving a directly implementable formula. Chords are left-aligned to the beat, not centred -- the casual "centred" phrasing in circulation is imprecise. GitHub strips raw <svg> from markdown; only <img src=...> renders. That kills webfonts and interactivity, survives textLength, and is the strongest argument yet that the worker should be a render service, since an <img> pointing at a URL is the only way a .song embeds in a document on a platform we do not control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
`plainsong/mcp/` and the sibling's `plainsong_mcp/` were ~2,900 duplicated lines with 109 already drifted apart. The framing that unlocked it: this is not one thing forked in two places, it is four pieces of technology wearing one name, which is why neither copy could evolve. `features.py` is the clearest case. Three hundred lines computing sixteen per-bar musical features, with nothing protocol-shaped in it, living in `mcp/` for no reason except that MCP was what needed it first. It now sits beside `notation/` and `perform/` where it belongs, and the sibling imports it instead of carrying a byte-identical copy. `protocol.py` was byte-identical too -- checked raw as well as with the package names normalised -- so the sibling now imports rather than retypes it. 490 lines of duplication gone entirely. The measured drift went up rather than down, from 113 lines to 135, and that is the honest number: files importing across packages necessarily read differently from files importing locally, and each `__init__` now describes a smaller scope. Two whole files stopped existing in duplicate, which is the result that matters. `tools.py` and `resources.py` were assessed and deliberately left alone, and the reason is the most valuable thing to come out of this. They look mergeable -- 11 and 16 diff lines, all import style -- but `tools.py` calls into the ensemble module seventeen times through a plain relative import. Sharing them as they stand would silently bind the sibling's tool surface to the *compiler's* stale `ensemble.py` rather than its own, and the two copies have already drifted 18 lines. That is a live correctness bug, not a hypothetical one. Decoupling it wants an injected dependency and its own decision, not a rider on a file move. The fingerprint is unchanged at 6321 files, which is the point: moving a Python module cannot alter what notation compiles to, and if it had, the move would have been wrong. 578 tests, 7 specs, ruff clean; the sibling's 91 tests pass and its server still answers with 27 tools. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
`| Gb maj7 . . . |` is two tokens where one was meant: `Gb`, a chord, and `maj7`, which nothing can read. It loses the quality *and* makes the bar five tokens wide instead of four, so the bar subdivides wrongly as well. Six files, nineteen chords, warnings from 85 to 54. Fixed by rule rather than by a list: only chord rows, only inside pipes, and only where joining the two tokens produces something that parses as a chord -- so it cannot invent a chord that was not already implied. Every edited bar had its other chords already joined, which is what makes the intent unambiguous. Also recorded, and worth more than the warning count: **dialect auto-detection fails on a relative-dialect file and it looks exactly like corrupt source.** `examples/tapscript-4-tap-closing-time.song` writes `I`, `IV`, `vi` as roman numerals and `1 . 3 | 5 . . |` as scale degrees. `dialect: auto` reads it as absolute, so every degree becomes unreadable: 42 notes, 51 warnings. Told `--dialect relative`, the same file gives 111 notes. The melody is real; the detector is wrong. That single misdetection is 51 of the 54 warnings left. How close that came is the part worth keeping. An agent clearing warnings read the bare digits as generation artefacts and replaced all 51 with rests. Note count held at 42, every test passed, the fingerprint moved by exactly the one file it should have, warnings went to zero -- a clean bill of health for an edit that deleted a tune. A warning that a parser cannot read something is evidence about the parser at least as often as about the file. The four bar-count warnings stay: a section header may legitimately disagree with its rows. So does the one in `examples/edge-cases/`, a directory whose job is holding malformed input. Fingerprint moved for exactly the six files edited and no others -- checked before re-recording, which is the only circumstance in which re-recording is honest. 578 tests, 7 specs, ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
The demo is the front door -- a compiler in one file that runs from file:// with nothing installed -- and its chord parser was a table of 18 enumerated qualities while Python moved to an open grammar. Twelve ordinary symbols worked in the CLI and failed silently in the browser, `CM7` among them. A visitor types a normal chord, gets nothing, and concludes the project is broken. `tests/test_demo.py` sailed straight past it, because it pinned note counts for four presets and said nothing about vocabulary. The guard that let the drift happen was as much the defect as the drift. Now it compares the two implementations directly: the core aliases, the seventh-implied spellings, the `alt` tables, the degree table and every core, each asserted against the Python grammar it was ported from. Six tests where there was one, and a divergence fails CI instead of waiting for a visitor. Verified in real headless Chromium rather than inferred: all four presets still compile to the counts the page claims, all twelve previously-failing symbols read, and the voicing agrees note for note -- D9 gives D Gb C E on both sides, G7alt gives B F Ab Db, C13 gives the rootless E Bb D A. The demo carried the same `intervals.slice(0,4)` truncation Python had, so it had the D9-sounds-like-D7 bug too; the guide strategy is ported with it. One thing to correct in the record. The JavaScript port itself is not in this commit -- it landed in 7ca17ad, a commit about chord spacing in `.song` files, because I ran `git add -A` while a subagent was still writing to the same working tree. 183 lines of unreviewed JavaScript went in under a message that does not mention them, and I pushed it. The code turns out to be right, which is luck rather than process: I have reviewed and tested it only now, after it was already on the remote. Four write-agents in one checkout is what caused this, and the same mistake earlier had one agent revert another's work. 579 tests, 7 specs, fingerprint unchanged, ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
`detect_dialect` only examined lines that begin with a pipe. The relative dialect names its harmony first -- `I | 1 . 3 | 5 . . |` -- so in a file written that way the detector saw no relative evidence at all and fell through to absolute. Every scale degree then became a token nothing could read. The measurements, on the one file in this repository written that way: 0 lines start with a pipe, 14 have a roman numeral in front of one. It compiled to 42 notes with 51 warnings. It should be 111 notes with none. A roman numeral standing where a row label goes is sufficient evidence by itself, because it means nothing in the absolute dialect, so the fix is to count that line and scan the tokens after its first bar rather than the whole line. Warnings across every source fall from 54 to 3, and what remains is honest: two sections whose header disagrees with their rows, which is allowed, and one file in `examples/edge-cases/`, a directory whose job is holding malformed input. Blast radius is exactly one file -- checked against the fingerprint before re-recording, not after. That it is one file is the point: this is a detector that was wrong about a single piece, not a change in how the dialect works. This is the bug that nearly cost the tune. An agent asked to clear warnings read those bare digits as generation artefacts and replaced all 51 with rests; the note count held at 42, every test passed, and the warnings went to zero. A warning that a parser cannot read something is evidence about the parser at least as often as about the file, and here it was entirely about the parser. Three tests, and the guard was removed and confirmed to fail two of them. 584 tests, 7 specs, ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
Nothing has ever shipped, so a split between Unreleased and a dated 1.0.0 was fiction: it was all one unreleased release. Folded, and dated the day it is actually being cut, because a version that has not shipped has no date worth trusting. `docs/releasing.md` asks for exactly this before a tag, and a tag is now the only step left. Pre-flight run from a clean venv outside the source tree: version agreement between pyproject and version.py, wheel and sdist build, install, then --version, new, compile, spec (7 passed, so the packaged data files are intact), library (the songbook ships), and chord (the grammar ships). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
`tools.py` reached into the ensemble module seventeen times through `from . import ensemble as ens` -- a plain relative import, which resolves to whichever copy of `ensemble.py` the file physically sits next to. Sharing this module with the sibling repository would therefore have wired its tool surface to *this* repository's `ensemble.py` rather than its own, and the two copies had already drifted eighteen lines. A live correctness bug, and the reason the previous consolidation pass stopped here rather than forcing it. The fix turned out to be one line, because all seventeen uses live inside `register()`'s own body as closures over nested handlers. There was no generic core to split out; the implicit binding existed at exactly one point, the import statement. So `register()` and `Resources.__init__()` take an optional `ensemble`, defaulting to a lazily-resolved import of this package's own copy. Not one of the seventeen call sites changed -- they still read `ens.find_session(...)` verbatim; only what `ens` is bound to moved, from "whatever lives next to this file" to "what the caller passed, or that same default". Behaviour is unchanged by construction: with no argument the default path runs byte-for-byte the same import that used to run at module scope, merely deferred. `server.py` calls both without the new parameter and needed no edit. Proved rather than asserted. `tests/test_mcp_ensemble_injection.py` registers the tools against a fake stand-in module carrying the same seven names, and checks every `ensemble_*` call and every session resource routes through the injected module instead of this repository's -- while the no-argument path still reaches the real one and the counts stay at 27 tools and 9 resources either way. 592 tests, 7 specs, fingerprint unchanged, ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
The first thing anyone hears is the judgement they make of the project, and the built-in synthesiser is a preview renderer -- mono, approximate timbres, pure Python. That it needs nothing installed is a real achievement and the reason it exists, but it is not what the work should be judged on, and the quality path was a footnote in a limitations list at the bottom of the README. Now the README says both things where a reader meets them: it works with nothing installed, and fluidsynth with a General MIDI soundfont is one command away. `docs/audio.md` carries the detail -- what the built-in renderer is and honestly is not, installation on macOS, Linux and Windows, where to find a soundfont, what ffmpeg adds, what mido and a hardware port add. The configuration in it was read rather than guessed, which is the failure mode for a document like this: `PLAINSONG_SOUNDFONT` is real at `runtime/capabilities.py:131`, and the documented search paths are the actual contents of `SOUNDFONT_HINTS`. No config key appears in the document that does not appear in the code. What it deliberately does not say is how much better fluidsynth sounds. Nobody here can listen to it. It describes what each backend *is* -- a sampled soundfont against synthesised waveforms -- and leaves the judgement to the ear that has one. 6333 sources check with 3 warnings, unchanged; every relative link resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
The big rename replaced `tapscript` everywhere inside files and moved every `.tap` to `.song`, but it never touched filenames that carried the old name. Seventeen of them survived: nine example pieces, the VS Code grammar and snippets, four proposals, a knowledge-base note. Nobody would have noticed from the tests -- everything compiled, every link resolved, and the word appeared nowhere in any file's contents. It only shows up when you list a directory, which is exactly where a new reader looks first. Found by inventorying what the README could honestly link to, which is a better audit than grepping for a word. Verified inert the same way the original rename was: the multiset of note hashes is identical across all 6,321 files, only the paths moved. A rename that changed music would not be a rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
The README assumed a reader who already knew what this was. It now opens with three doors -- you read music but not code, you write code but not music, you are an agent -- and points each at the shortest path to a sound coming out. It also lists every example piece in the repository with what each one demonstrates, which was previously only discoverable by listing a directory. AGENTS.md is new, and is the document I wish had existed before an agent replaced fifty-one scale degrees with rests to clear a warning. Its longest section is the mistakes, because those are the reusable part: - Success is not evidence. The compiler turns what it cannot read into a rest and reports ok. Read the note count, not the exit code. - A warning about the parser is not a warning about the file. The melody that got deleted was fine; detect_dialect was not. - Never re-record a fingerprint baseline to make CI green. That check is the only thing standing between a refactor and a silently transposed corpus, and --write disarms it in one command. - Do not find-and-replace on a word. "Symphony No. 5 in C minor" became "Symphony No. 5 in Cm" across 193 files that way. - Verify from somewhere neutral. The working directory is on sys.path, so importing the package from inside the checkout tells you nothing about the wheel. Every command quoted in either file was run, both compile clean under plainsong check, and all relative links resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
1.0.0 changed how chords with more than four notes sound. That change was
right and was arrived at by measurement, but it shipped with no way out:
docs/voicing.md told readers to set `render.voicing`, `ArrangeOptions`
carried the field, and nothing in the codebase read it from configuration.
The setting existed in every place except the one that mattered.
This repository's own rule is that a change to how existing notation sounds
goes behind a setting. The setting is now real:
[core]
voicing = "stack" # the pre-1.0.0 rendering, exactly
`PLAINSONG_CORE_VOICING` does it for one run. It sits in [core] beside
bar_fill because it decides which notes exist, so it changes the MIDI and
not merely the audio. `render.voicing` is still honoured -- that spelling
was published, and ignoring it in silence is the same fault as the one
being fixed.
A value that is not a strategy now reports. It previously fell through to
the default without a word, which looks exactly like having been obeyed.
Both guards were removed and the suite confirmed red before being restored.
The changelog was worse than the code. 1.0.0 shipped a section headed
"Known, recorded rather than fixed" explaining that D9 sounds like D7 --
in the same release as the commit that fixed it. Readers were told the
opposite of what the software does, and were not told the thing they
actually needed to know: seventy-two files in the bundled corpus render
differently than they used to. That section now says what happened.
597 tests, 7 specs, ruff clean, and 6,321 files compile exactly as
recorded -- the default is untouched, which is the point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
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.
plainsong 1.0.0is on PyPI, cut frommaster. This is everything master didnot have when that happened, and it bumps the version to 1.0.1.
1.0.0 took away a rendering and shipped no way back
This is the one I would want reviewed first, because it is a fault in the
released version rather than in a branch.
1.0.0 changed how chords naming more than four notes sound. The change is right
and was arrived at by measurement — a player thinning a voicing drops the fifth
first and the root second, so taking the bottom four does exactly the wrong
thing, and
D9had been rendering asD7. But it shipped with no way out:docs/voicing.mdtold readers to setrender.voicing,ArrangeOptionscarriedthe field, and nothing in the codebase read it from configuration. The setting
existed everywhere except where it mattered.
This repository's own rule is that a change to how existing notation sounds goes
behind a setting. It is now real:
PLAINSONG_CORE_VOICINGdoes it for a single run.[core]besidebar_fillbecause it decides which notes exist,so it changes the MIDI and not merely the audio.
render.voicingis stillhonoured — that spelling was published, and ignoring it in silence is the
same fault being fixed.
the default without a word, which is indistinguishable from being obeyed.
Both new guards were removed and the suite confirmed red before restoring them.
The corpus fingerprint is unchanged: 6,321 files compile to exactly the music
they did, which is the point — the default does not move.
And the changelog was worse than the code
1.0.0 shipped a section headed "Known, recorded rather than fixed" explaining
that
D9sounds likeD7— in the same release as the commit that fixed it.Readers were told the opposite of what the software does, and were not told
the thing they needed: seventy-two files in the bundled corpus render
differently than they used to. That section now says what happened, including
the breaking-change notice it owed.
The dialect detector was deleting a melody
detect_dialectonly examined lines beginning with|. The relative dialectnames its harmony first —
I | 1 . 3 | 5 . . |— so in a file written that wayit saw no relative evidence at all and fell through to absolute. Every scale
degree then became a token nothing could read.
Measured on the one file here written that way: 0 lines start with a pipe, 14
have a roman numeral in front of one. It compiled to 42 notes with 51
warnings. It is 111 notes with none.
The near-miss is the part worth keeping. An agent asked to clear those warnings
read the bare digits as generation artefacts and replaced all 51 with rests. The
note count held at 42, every test passed, the fingerprint moved by exactly the
one file it should have, and the warnings went to zero — a clean bill of health
for an edit that deleted a tune. A warning that a parser cannot read something
is evidence about the parser at least as often as about the file.
Warnings across all sources: 85 → 3. What remains is honest — two sections
whose header disagrees with their rows, and one file in
examples/edge-cases/,a directory whose job is holding malformed input.
The demo had fallen behind the compiler
The browser demo is the front door, and its chord parser was a table of 18
enumerated qualities while Python moved to an open grammar. Twelve ordinary
symbols worked in the CLI and failed silently in the browser,
CM7among them.It also carried the same
slice(0,4)truncation.tests/test_demo.pysailed past all of it, because it pinned note counts forfour presets and said nothing about vocabulary. It now compares the two
implementations directly — core aliases, seventh-implied spellings,
alttables, degree table, every core. Verified in real headless Chromium: presets
match, and the voicing agrees note for note (
C13→E Bb D Aon both sides).The MCP seam, cut where the names fall
plainsong/mcp/and the sibling repo were ~2,900 duplicated lines with 109already drifted. Not one thing forked twice — four pieces of technology
wearing one name.
features.pyis 300 lines of per-bar musical analysis withnothing protocol-shaped in it; it now sits beside
notation/andperform/.protocol.pywas byte-identical. 490 lines stopped existing in duplicate.tools.pywas assessed and deliberately left — it reached into the ensemblemodule seventeen times through a relative import, so sharing it would have
silently bound the sibling's tool surface to this repo's stale
ensemble.py.That is now fixed by injection, and the fix was one line: all seventeen uses
live inside
register()'s body as closures, so the implicit binding existed atexactly one point. Proved with a test that registers against a fake ensemble
module and checks every call routes through it.
The two audiences with no front door
The README assumed a reader who already knew what this was. It now opens with
three doors — you read music but not code, you write code but not music, you are
an agent — and lists every example piece with what each one demonstrates, which
was previously discoverable only by listing a directory.
AGENTS.mdis new, and its longest section is a catalogue of mistakes,because those are the reusable part. Every one was made by an agent working on
this repository: success is not evidence (the compiler turns what it cannot read
into a rest and reports
ok); a warning about the parser is not a warning aboutthe file; never re-record a fingerprint baseline to make CI green; do not
find-and-replace on a word ("Symphony No. 5 in C minor" became "…in Cm"
across 193 files that way); verify from somewhere neutral, because the working
directory is on
sys.path.Also
| Gb maj7 . . . |was twotokens where one was meant — losing the quality and making the bar five
tokens wide. Six files, nineteen chords, fixed by rule rather than by list.
N.C.reads as a rest. It already compiled to silence; it just complainedwhile doing it.
and it is the first thing anyone hears; fluidsynth is now documented where a
reader meets it, in
docs/audio.md, with every config key read from the coderather than guessed.
resolved, tests passed — the old name only showed when you listed a directory.
proposals/02-the-voyage.md, a plan whose two research phases areanswered and whose remaining unknowns are marked as unknowns.
Verification
597 tests, 7 specs,
ok 6334 file(s) checked, 3 warning(s), ruff clean, and6321 file(s) compile exactly as recorded.Every change that could alter what notation compiles to was checked against the
fingerprint before re-recording it, never after. The two renames were proved
inert by comparing the multiset of note hashes across all 6,321 files — identical
both times, only paths moved.
What this unblocks
plainsong-mcppins the compiler by git URL because 1.0.0 predatesplainsong.features. Merging this and releasing 1.0.1 makes that pin a versionspecifier, which is the only line in that repository that has to change.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc