Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 |
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img>
python3 -m plainsong transpose song.song Dm

# The browser demo -- open it, no server needed
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

Expand Down
48 changes: 30 additions & 18 deletions SHIPPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 17 additions & 11 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# The Tap Songbook
# The Songbook
*(Shared, stolen, and sung slightly off-key)*

---
Expand Down
7 changes: 4 additions & 3 deletions proposals/02-the-voyage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/corpus-fingerprint.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading