diff --git a/AGENTS.md b/AGENTS.md index 2da0e45d..d9be61dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ Every command takes `--json`. Use it. plainsong chord "G7alt" --explain # what a symbol means, degree by degree plainsong voicing "C13" # which notes actually sound, and why those plainsong lyrics song.song # which note each syllable is sung on +plainsong chart song.song -o out.svg # a chord chart you can embed in a document plainsong info song.song --verbose # every diagnostic available plainsong check docs README.md # compiles fenced blocks out of markdown too plainsong fingerprint mysongs --check baseline.txt # did anything change? @@ -213,6 +214,7 @@ refused and handed the current state to rebase onto. See | [docs/chords.md](docs/chords.md) | Every chord spelling, and the rules that derive the notes | | [docs/voicing.md](docs/voicing.md) | Which notes sound when a chord names more than fit | | [docs/lyrics.md](docs/lyrics.md) | Binding syllables to notes, and why a column is not a promise | +| [docs/chart.md](docs/chart.md) | Drawing a chord chart, and what it deliberately is not | | [docs/integration.md](docs/integration.md) | `--json`, the Python API, HTTP, MCP | | [docs/performance.md](docs/performance.md) | Arrival-centric timing and the `[Stage]` block | | [CLAUDE.md](CLAUDE.md) | How the codebase is organised, and why each rule exists | diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c4cc9f..64ea54d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ Notable changes, newest first. Dates are ISO 8601. ## Unreleased +### Housekeeping: two rename leftovers, and documentation that had drifted + +- **`examples/plainsong-4-tap-closing-time.song`** still carried `tap` from + before the rename, as did `docs/traditions/03-the-tap-songbook.md`. Both + renamed. The fingerprint is keyed on path, so its baseline was re-recorded — + and proved inert the way the earlier renames were, by comparing the note-hash + multiset across all 6,321 files: `b850e4729399e7e5068a13c0` before and after. + Not one note moved. +- **The README claimed nine complete examples and ships eight.** +- **`SHIPPING.md` said 527 tests and 6,333 sources**; it is 667 and 6,340. It + opens by saying every claim in it was checked against the working tree, so + stale numbers make it lie about itself. +- **It also still listed the four-note chord cap as an open limitation** — + `D9` sounds like `D7` — which 1.0.1 fixed. Corrected, with the measurement and + the `core.voicing = "stack"` escape hatch. +- **Its claim about the browser demo was the pre-fix one**, describing note + counts as the guard. Counting was exactly what failed to catch `.` and `-` + being read as rests. Rewritten to describe what actually checks it now. +- `chart` and `merge` reached `CLAUDE.md`, `AGENTS.md` and + `docs/architecture.md`, which described neither. + +### A third-party MCP client has now connected + +The long-standing caveat — *"the MCP server has never had a real MCP client +connect to it"* — is closed. Driven with the official `mcp` Python SDK 2.0.0 +over stdio, as any client would: `initialize` returns `plainsong 1.1.0` on +protocol `2025-06-18`, and 27 tools, 9 resources and 2 prompts enumerate. +`compile_score` round-trips inline notation, a resource reads back, and a call +missing a required argument correctly returns `isError: true`. + +**One thing worth knowing.** Notation the compiler cannot read returns +`isError: false`, with the failure in the *content* (`error: no sections +found`). The tool ran; the music did not compile. That is defensible under the +specification, which reserves `isError` for execution failures — but it means an +agent client has to read the diagnostics rather than the flag, which is the +"success is not evidence" trap `AGENTS.md` warns about, served over the wire. +Recorded rather than changed: altering `isError` semantics is a decision about +the protocol surface, not a tidy-up. + ### A conflict between two agents is now decidable `notation/merge.py` three-way merges two edits of one score. An edit occupies a diff --git a/CLAUDE.md b/CLAUDE.md index dda42b65..9898e3ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,12 +38,17 @@ python3 -m pytest tests -q # works too, if pytest is installe python3 -m plainsong spec # exits non-zero on failure python3 -m plainsong doctor --specs python3 -m plainsong check docs examples plainsong/songbook README.md # every source, prose included +python3 -m plainsong fingerprint plainsong/songbook examples docs --check tests/corpus-fingerprint.txt + +# Checks CI cannot run -- no browser there. Run by hand after touching either side. +python3 tools/demo_differential.py # the browser demo against the compiler # Working with notation python3 -m plainsong new "Title" -o song.song python3 -m plainsong compile song.song -o out.mid --audio out.wav python3 -m plainsong info song.song --verbose # every diagnostic, parser and arranger python3 -m plainsong lyrics song.song # which note each syllable is sung on +python3 -m plainsong chart song.song -o out.svg # a chord chart, embeddable as python3 -m plainsong transpose song.song Dm # The browser demo -- open it, no server needed @@ -298,9 +303,13 @@ famous for came from those rows and are now 2. package stops carrying it. Until that lands, a change to one copy must be made to the other or they will drift — which is precisely the failure mode the rule exists to prevent. Do not build anything new on this copy. -- The MCP server has never had a third-party MCP client connect to it. Its - protocol behaviour is verified by hand-driven JSON-RPC against the - specification, which is strong evidence and not the same thing. +- The MCP server has now been driven by a third-party client: the official + `mcp` Python SDK 2.0.0, over stdio. `initialize`, `tools/list`, + `resources/list`, `prompts/list`, `tools/call` and `resources/read` all + behave, and a missing required argument returns `isError: true`. Notation the + compiler cannot read returns `isError: false` with the error in the content, + which is defensible — the tool ran — but means a client has to read the + diagnostics rather than the flag. ## Emitting notation must round-trip diff --git a/README.md b/README.md index 1b29972d..8f35ad0f 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Every command takes `--json`. Use it when parsing output. ## Examples to start from -Nine complete pieces ship in the repository, written to be read rather than +Eight complete pieces ship in the repository, written to be read rather than generated. Each one is a template: copy it, change the chords, recompile. ```bash @@ -389,7 +389,7 @@ plainsong compile examples/plainsong-1-creatures-of-interval.song --audio out.wa | [Creatures of Interval](examples/plainsong-1-creatures-of-interval.song) | Four voices, plain 4/4 — the simplest complete piece | | [The Room Is Safe](examples/plainsong-2-the-room-is-safe.song) | Lyrics and melody together | | [Hermes Blues](examples/plainsong-3-hermes-blues.song) | Twelve-bar blues with swing | -| [Closing Time](examples/plainsong-4-tap-closing-time.song) | The relative dialect — roman numerals and scale degrees | +| [Closing Time](examples/plainsong-4-closing-time.song) | The relative dialect — roman numerals and scale degrees | | [Deck Work](examples/plainsong-5-deck-work.song) | Several named players | | [Spacing: melody](examples/plainsong-6-spacing-melody.song) · [chords](examples/plainsong-7-spacing-chords.song) · [dashes](examples/plainsong-8-spacing-dashes.song) | How duration-by-spacing behaves | diff --git a/SHIPPING.md b/SHIPPING.md index c49c131a..034b6f65 100644 --- a/SHIPPING.md +++ b/SHIPPING.md @@ -15,16 +15,20 @@ These have been proven, not assumed. | Works on Python 3.10 | Hid `tomllib` entirely and ran the CLI (`info`, `compile`) and all specs through the fallback reader. | | The TOML fallback matches the real one | Differential test: every TOML file in the repo plus 17 edge cases parsed by both readers and compared. | | Arrival timing is arithmetically right | Hand-checked a three-voice stage: 14 m at 343.21 m/s is 40.8 ms, plus 140 ms pipe speech and 60 ms perceptual attack gives the organ's -241 ms. Spread is 0 ms at the podium and non-zero at every player's desk. | -| Notation compatibility held | 6,333 sources compile with 0 errors, including every fenced `plainsong` block in the prose. | +| Notation compatibility held | 6,340 sources compile with 0 errors, including every fenced `plainsong` block in the prose, and 6,321 files compile to exactly the music they did -- which `check` cannot see and `fingerprint` can. | | No hidden dependencies | CI installs nothing on 4 Python versions x 3 operating systems. | | No hardcoded paths | A test greps the package for `~/.openclaw`, `/home/eileen`, `/Users/`. | | Nothing half-written | No TODO, FIXME, XXX, HACK or `NotImplementedError` anywhere in `plainsong/`. | -| The browser demo agrees with the compiler | `docs/demo/index.html` carries its own parser and arranger in JavaScript. It states the note count it produces for each preset, and `tests/test_demo.py` compiles the same notation with the real compiler and requires the same answer -- so a change to the arranger that moves a count fails CI rather than making the page quietly lie. | +| The browser demo agrees with the compiler | Checked by running both implementations over the same notation and comparing **pitch, start and duration** of every note (`tools/demo_differential.py`). Counting alone was not enough and this is not hypothetical: `.` and `-` sat in the demo's REST set where the compiler has them in SUSTAIN, so every held note was cut to a single subdivision while the note *counts* stayed identical and CI stayed green. A second implementation checked by counting is not checked. | | A wheel actually works | Built, installed into a clean venv outside the source tree, and exercised there. This is the only way the two packaging faults were visible: `plainsong spec` and `plainsong library` both did nothing for anyone who installed rather than cloned. | +| A chart renders without a font | `plainsong chart` emits a standalone SVG; inspected in headless Chromium at three viewport sizes, light and dark. Text carries `textLength` so layout survives font substitution, and the flat sign is folded to ASCII because Liberation Sans has U+266F and lacks U+266D. | +| A merge conflict is decidable | Two edits occupy sets of `(section, row, bar)` cells and collide exactly when those intersect. Three rules -- run-on bar numbering, removal counting as a change, and requiring the base -- were each mutated and the suite confirmed red. | +| The rename moved no music | `examples/plainsong-4-tap-closing-time.song` became `plainsong-4-closing-time.song`. Proved inert by comparing the note-hash multiset across all 6,321 files before and after: `b850e4729399e7e5068a13c0`, both sides. | | The new tests would catch a regression | Each guard was removed and the suite confirmed to go red: retryable statuses, the `[DONE]` stream terminator, the User-Agent and header merge, the same-origin refusal, the `/files/` basename and containment guards together, the null-byte guard, percent-decoding, the `MAX_BODY` limit, `ConnectorResult.__bool__`, the broken-connector skip, and the availability check in `run()`. | -527 tests, 7 specs, 6,333 sources checked including every fenced example in the -prose, ruff clean. +667 tests, 7 specs, 6,340 sources checked including every fenced example in the +prose, ruff clean. Published: `pip install plainsong` installs 1.1.0 from PyPI +and its specs pass on a machine that has never seen this repository. ## Blockers @@ -201,19 +205,27 @@ passes on Windows across 3.10 through 3.13. - The TUI needs `curses`, which stock Python on Windows does not ship. It reports this and points at `windows-curses` or the web interface. Found by the new TUI tests running on Windows CI for the first time. -- The MCP server has never had a real MCP client connect to it. The protocol is - verified by hand-driven JSON-RPC, which is strong evidence but not the same - thing as Claude Desktop or an SDK client connecting. -- **The renderer discards chord extensions.** `arrange.Options.max_chord_notes` - is 4 and the notes are taken from the bottom, so a five-note chord keeps - root-third-fifth-seventh and drops what sat above. `D9` sounds like `D7`; - `E7#9` sounds like `E7`; `G7alt` renders as four notes that are not a chord - anybody would name. 459 occurrences, 0.3% of the corpus, concentrated - entirely in the chords where the dropped note is the whole point. The cap is - defensible; taking the bottom four is not — a player drops the fifth first, - then the root, because the third and seventh carry the identity. Left alone - because fixing it changes how existing files sound, and that wants its own - reviewed diff rather than a ride along with a parser change. +- ~~The MCP server has never had a real MCP client connect to it.~~ **Closed.** + Driven with the official `mcp` Python SDK 2.0.0 as any third-party client + would: `initialize` returns `plainsong 1.1.0` on protocol `2025-06-18`, and + 27 tools, 9 resources and 2 prompts enumerate. `compile_score` round-trips + inline notation, a resource reads back, and a call with a missing required + argument correctly returns `isError: true`. + **One thing to know:** notation the compiler cannot read returns + `isError: false` with the failure in the *content* (`error: no sections + found`). The tool ran; the music did not compile. That is defensible under the + specification, which reserves `isError` for execution failures, but it means + an agent client must read the diagnostics rather than the flag — the same + "success is not evidence" trap `AGENTS.md` warns humans about, presented over + the wire. +- ~~**The renderer discards chord extensions.**~~ **Fixed in 1.0.1.** The + four-note cap took the lowest four notes, so `D9` sounded like `D7` and + `G7alt` rendered as four notes nobody would name. A player drops the fifth + first and the root second, because the third and seventh carry the identity; + taking the bottom four does the opposite. Chosen by measurement over the 277 + occurrences where the cap actually bites: keeping the symbol's defining note + went from 50.1% to 94.4%. It changed how seventy-two bundled files sound, so + `core.voicing = "stack"` restores the old rendering exactly. - **`plainsong/mcp/` also exists in `SuperInstance/plainsong-mcp`.** The one open violation of "one of everything", and it is temporary: the extraction happened while this branch was in review. Until the copy here is removed, a @@ -225,7 +237,7 @@ passes on Windows across 3.10 through 3.13. - `master` has been red on its own older workflow since before this branch, for reasons unrelated to it. - **Dialect auto-detection fails on a relative-dialect file, and it looks like - corrupt source.** `examples/plainsong-4-tap-closing-time.song` is written in + corrupt source.** `examples/plainsong-4-closing-time.song` is written in the relative dialect -- `I`, `IV`, `vi` as roman-numeral chords and `1 . 3 | 5 . . |` as scale degrees. `dialect: auto` reads it as absolute, so every scale degree becomes an unreadable token: 42 notes and 51 warnings. Told diff --git a/docs/architecture.md b/docs/architecture.md index c101faa9..09f13273 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,8 +21,8 @@ Around that core: | Package | Holds | |---|---| -| `notation/` | theory, parser, arranger, the intermediate representation | -| `render/` | MIDI writer, synthesiser, voices, optional backends | +| `notation/` | theory, parser, arranger, the intermediate representation, the time grid, lyric binding, merge | +| `render/` | MIDI writer, synthesiser, voices, the SVG chart and its font metrics, optional backends | | `runtime/` | paths, layered configuration, host capability probing | | `llm/` | provider-neutral types, transport, catalogue, adapters | | `agent/` | the loop, the tool registry, prompts | @@ -56,15 +56,21 @@ computed by one function, so a lyric and a note are placed by the same arithmetic. `Arrangement.grid` carries it. `unit` is the load-bearing field: a token's position within its own bar, from 0.0 to just under 1.0. -Three separate problems become the same projection: - -- rendering is `x = unit * bar_width`, a coordinate transform rather than a - layout engine; -- merging is set intersection on `(row, bar, unit)`, so two agents editing - different rows provably cannot collide — the row axis is disjoint, and player - rows are keyed by name; -- alignment linting can finally be *expressed*: `grid.disagreements()` names - each bar whose rows divide it differently. +Three separate problems become the same projection, and two of them are built: + +- **Rendering.** `render/chart.py` places a chord at `x = unit * bar_width` — a + coordinate transform rather than a layout engine, so a chart cannot disagree + with the audio about when a chord arrives. +- **Merging.** `notation/merge.py` decides a conflict by set intersection on + `(section, row, bar)`: two edits collide exactly when those sets overlap. The + row axis is disjoint and player rows are keyed by name, so two agents on + `@bass` and `Melody:` cannot collide — and, less obviously, neither can two + agents rewriting bars 1–4 and 5–8 of *one* melody. That second case is what a + coordinate per bar buys over a file per voice. +- **Linting**, not yet built. `grid.disagreements()` names each bar whose rows + divide it differently, which is the `came`/`C5` lie stated as data. Nothing + raises a diagnostic from it: uneven subdivision is legal and usually + deliberate, so what to warn about is still an open question. The grid observes; it does not steer. The arranger populates it from positions it has already computed, so if building it ever moved a note, the grid would be diff --git a/docs/traditions/03-the-tap-songbook.md b/docs/traditions/03-the-songbook.md similarity index 99% rename from docs/traditions/03-the-tap-songbook.md rename to docs/traditions/03-the-songbook.md index 70326575..620da0cb 100644 --- a/docs/traditions/03-the-tap-songbook.md +++ b/docs/traditions/03-the-songbook.md @@ -1,4 +1,4 @@ -# The Tap Songbook +# The Songbook *(Shared, stolen, and sung slightly off-key)* --- diff --git a/examples/plainsong-4-tap-closing-time.song b/examples/plainsong-4-closing-time.song similarity index 100% rename from examples/plainsong-4-tap-closing-time.song rename to examples/plainsong-4-closing-time.song diff --git a/proposals/02-the-voyage.md b/proposals/02-the-voyage.md index b44014f4..72cd5239 100644 --- a/proposals/02-the-voyage.md +++ b/proposals/02-the-voyage.md @@ -11,12 +11,13 @@ Not claims — things this repository can demonstrate on demand. | | | |---|---| -| Notation compiles | 6,333 sources, 0 errors, including every fenced example in the prose | +| Notation compiles | 6,340 sources, 0 errors, including every fenced example in the prose | | The music is pinned | 6,321 files hashed; CI fails if any file's *notes* change | | Chord symbols | An open grammar. `C7b9#11` works without being enumerated | | Voicing | Decided by measurement: 94.4% of named notes survive, against 50.1% | -| Tests | 578, plus 7 specs, on 3.10–3.13 across three operating systems, nothing installed | -| Packaging | Wheel built and exercised in a clean venv outside the tree | +| Tests | 667, plus 7 specs, on 3.10–3.13 across three operating systems, nothing installed | +| Packaging | On PyPI. `pip install plainsong` and the specs pass on a machine that never saw this repository | +| Rendering | `plainsong chart` draws an SVG chord chart with no font and no dependency | Two properties matter more than the numbers. **Parsing is already whitespace invariant** — tight, loose, ragged and tab-separated layouts of the same bar diff --git a/tests/corpus-fingerprint.txt b/tests/corpus-fingerprint.txt index b74e8f20..4a8674f3 100644 --- a/tests/corpus-fingerprint.txt +++ b/tests/corpus-fingerprint.txt @@ -2490,7 +2490,7 @@ b1ef7007b7a0f712 34 examples/edge-cases/edge-4-players-only.song 73ff48fe72d8bf12 157 examples/plainsong-1-creatures-of-interval.song a8567c72abd8ec82 143 examples/plainsong-2-the-room-is-safe.song 87a9033c4c06bcce 224 examples/plainsong-3-hermes-blues.song -c4040d29fc2a8835 111 examples/plainsong-4-tap-closing-time.song +c4040d29fc2a8835 111 examples/plainsong-4-closing-time.song 1c2186b5371e33f7 101 examples/plainsong-5-deck-work.song d9419c3c1bc05fc0 29 examples/plainsong-6-spacing-melody.song a351ccbd50ff2d4e 23 examples/plainsong-7-spacing-chords.song