@plinth-music/cli 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,87 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.20.1 - 2026-09-07
6
+
7
+ One code PR since `0.20.0` (`v0.20.0..fcfc83b`, #185; #183, #184, #186 and #187 docs). A
8
+ patch: no new command and no new sync surface.
9
+
10
+ ⚠️ **Plinth no longer tells you it set aside an edit you did not make.** When a memory store
11
+ switches from a stored blob to a generated index, the daemon refuses the local copy and keeps
12
+ a copy of what it replaced. It was doing that even when your file already matched the
13
+ canonical body byte for byte — so it wrote a conflict stash that preserved nothing and
14
+ reported a lost edit that never existed. Both stop. The canonical body is still written, so
15
+ the daemon's own change is still recognised as its own; nothing else about the refusal moves.
16
+
17
+ Existing conflict stashes are untouched: this changes what the daemon writes from now on and
18
+ prunes nothing. An audit of one live workspace during the 0.20.0 release found 30 stashes, 9
19
+ of them byte-identical to the file they sat beside; those stay until a separate pass decides
20
+ what is safe to remove.
21
+
22
+ ## 0.20.0 - 2026-09-07
23
+
24
+ Two code PRs since `0.19.0` (`v0.19.0..f583f98`, #180 and #181; #179 docs). A minor: there
25
+ is a new command, and every session now carries more of the workspace's memory than it did.
26
+
27
+ ⚠️ **More of your memory reaches every session.** The session-start injection budget rises
28
+ from 20,000 bytes to **20,588**, and the raise is three measured rows rather than a rounder
29
+ number — 470 bytes for the date every index line now carries, 12 for the source rule 0.19.0
30
+ added, 106 for the per-artist carve-out that shipped with it. Each row is named in the code
31
+ beside the figure it was measured at and the date it was taken, so a later raise has to be
32
+ attributed instead of absorbed. Measured on a real workspace, both builds against the same
33
+ files: workspace facts reaching the session head go from **8 of 28 to 12 of 28**, personal
34
+ facts from **7 of 10 to 8 of 10**. Five more facts arrive whole, and none was displaced to
35
+ pay for them.
36
+
37
+ The budget also has a test that pins it, for the first time. Until now nothing did, so it
38
+ could have been raised to fit whatever was failing.
39
+
40
+ ⚠️ **A dated memory line survives being carried as a title.** When an index does not fit
41
+ whole, a fact that cannot ride is named by its first sentence — and that cut discarded the
42
+ date at the end of the line, so the file would have been dated while the agent held undated
43
+ hooks. The date is re-appended after the pointer and anchored at the end of the line, so a
44
+ date sitting in the middle of your own prose is never lifted and presented as provenance.
45
+
46
+ **New: `plinth context [--workspace <slug>] [--json]`** — one complete account of everything
47
+ that can reach a session in this workspace, grouped by how it gets there, with Plinth's share
48
+ separated from yours. `plinth status`'s **Injection** row measures the hook and nothing else,
49
+ and the generated-file bar counts lines of the generated body, so the largest thing a session
50
+ loads can sit inside a passing row and be reported nowhere. On a real workspace the root
51
+ `CLAUDE.md` is **40,268 bytes, of which 29,055 is the member's own block** — loaded at every
52
+ session start in that folder, and named by no existing row. It also surfaces **83,150 bytes
53
+ of per-artist `.claude/settings.json`**, which is the settings file that applies inside an
54
+ artist folder, because settings do not walk up.
55
+
56
+ ⚠️ **Bytes on disk, rendered hook bytes, loaded bytes and billed tokens are four different
57
+ quantities, and they are never summed into one.** A file that reaches a session only through
58
+ the hook contributes its injected share and never its size on disk, so trimming advice cannot
59
+ double-charge it. There is exactly one combined estimate and it prints its four assumptions
60
+ beside it. **Nothing here observes a session:** actual loading, tokens and cached usage are
61
+ reported as not measured, never as zero, and the harness's own tool-schema overhead is named
62
+ as unknown rather than guessed from a catalogue size. Missing, unreadable, refused, unresolved
63
+ and unlistable are five distinct states, since all five otherwise read as "0 bytes".
64
+
65
+ `plinth context` is read-only: it writes no file, starts no daemon and makes no network call.
66
+ That is proven against a v1 `state.json`, which the ordinary state read would migrate and
67
+ rewrite.
68
+
69
+ - **`plinth status` gains a `Dates` row**, reporting whether the injected facts carry the date
70
+ they were banked, and naming the file when the two indexes disagree. Silent where no index
71
+ carries dates.
72
+ - **`/plinth:close`'s artist-memory guidance renders on the arm that needs it.** The banking
73
+ instruction is written per store now, so a workspace whose artist store is generated and
74
+ whose named stores are not is told the right thing for each.
75
+ - The generated pointer to a fact body no longer states a shape that stops being true the day
76
+ the index lines carry dates.
77
+
78
+ The desktop app pins `@plinth-music/cli` exact, and **v1.13.0 (7 September, the same evening)
79
+ repins it to 0.20.0** — so this release reaches the packaged app as well as npm.
80
+
81
+ *As published to npm, this entry closed by saying the desktop pinned 0.19.0 and that the release
82
+ did not reach the app. That was true at 19:03 and the repin shipped at 19:27. The npm tarball
83
+ keeps the older sentence; `npm unpublish` is a 72-hour window that a superseded line does not
84
+ justify spending.*
85
+
5
86
  ## 0.19.0 - 2026-09-06
6
87
 
7
88
  One code PR since `0.18.0` (`v0.18.0..1062fb2`, #177; #175 and #176 docs). A minor because
@@ -38,8 +119,8 @@ either sentence going wrong.
38
119
  goes **19,967 to 19,979 bytes of the 20,000 budget, and no memory fact is displaced** — 15
39
120
  of 26 workspace facts and 7 of 10 member facts ride into the session head on both builds.
40
121
 
41
- The desktop app pins `@plinth-music/cli` exact (0.17.0 as of v1.10.0), so neither this release
42
- nor 0.18.0 reaches the app until its own release repins.
122
+ The desktop app pins `@plinth-music/cli` exact. v1.11.0 (5 September) repinned it to 0.18.0, so
123
+ that release does reach the app; this one does not until a further desktop release repins again.
43
124
 
44
125
  ## 0.18.0 - 2026-09-04
45
126
 
package/README.md CHANGED
@@ -25,10 +25,11 @@ plinth start # run the daemon: continuous pull + watch + pu
25
25
  - `plinth login --workspace <slug> [--reset-sync]` — issue a personal access token in the browser, paste it back, store it in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager). Logging in again to the same workspace is a token rotation and **keeps your sync cursors**, so the next sync picks up where the last one left off; it clears them only when the token belongs to a different workspace or user. `--reset-sync` discards them deliberately, making the next sync a full re-pull — use it after rebuilding a mirror from scratch, which is otherwise unreachable now that rotation preserves them.
26
26
  - `plinth sync [--workspace <slug>]` — one-shot pull into `~/Plinth/<workspace>/`. Also generates the workspace-root `CLAUDE.md` (a thin TOC + entity model + skill pointers), an empty `CLAUDE.local.md` stub on first sync, and the two session-boundary commands (below), and silently refreshes `CLAUDE.md` on a schema-version change. Defaults to the active workspace if `--workspace` is omitted. **Every mirrored task, project, document, artist, meeting and thread file carries its row `id:` in frontmatter**, so an agent holding a file can address the MCP write for that row directly instead of going through `search_entities` and risking a duplicate on an empty result; files already on disk when you upgrade get it from a one-time backfill, since a cursor feed never re-sends an unchanged row. The key is server-authoritative and stripped before the wire, so it moves no hash and pushes nothing. Since 0.17.0 a row **renamed in Plinth moves its mirror file** rather than leaving a second copy at the old name; a file already sitting at the new name is never overwritten, and `plinth status` names the clash until it clears. It also **scaffolds `files/` and the six entity folders**, so a new workspace arrives with somewhere to put things instead of an empty directory, and the folder map in the generated `CLAUDE.md` reads the disk rather than asserting what ought to be there.
27
27
  - `plinth start` — run the long-running sync daemon: pull the latest, then watch the mirror for local edits and push them. It also projects each mapped artist's Google calendar into `views/calendar/` in the mirror — a file per artist plus an `upcoming.md` — on its own 15-minute timer (`PLINTH_CALENDAR_INTERVAL_MS`), with one run at boot. That is deliberately not the 15-second pull cadence: one tick makes a Google call per mapped calendar, which would breach the server route's rate limit inside a minute. A refresh that fails keeps the last known bodies and rewrites only the header, so a stale view says it is stale rather than reading as an empty diary. Logs to `~/.plinth/daemon.log`. **One daemon per workspace:** it holds an exclusive kernel lock on `~/.plinth/daemon-<workspace>.lock` for its life (macOS only; other platforms are refused and told why), so a second `plinth start`, or a standalone `plinth sync` while it runs, exits 1 naming the holder's pid. **Never delete the lock file** — the lock lives on the inode, and removing the path is what lets a second daemon in beside a live one. It also writes `views/conflicts.md` on every sync: every local edit the cloud won and the daemon set aside in `~/.plinth/_conflicts/`, each with a `cp` command that restores it; the zero case is written too, so an absent file means an old daemon, not a clean one.
28
- - `plinth status` — local sync state: daemon liveness, last sync and per-type counts, any destructive batches being held, writes awaiting retry or permanently rejected, **every local write that has not reached Plinth** (scanned from the mirror against the last acknowledged state, with the remedy that clears each — start the daemon, or open and save a file that was created while it was down), the state of `voice.md`, and any conflict stashes in `~/.plinth/_conflicts/<workspace>/` — local copies that were set aside when the cloud version won. Warns when a source install's `dist/` is behind its checkout. Four further rows. **Commands** warns when the customisable block in a generated command still names machinery that command retired — the block is preserved verbatim forever and renders after the generated body, so a hand-written step that runs a retired ritual wins, and the generator may never edit it. **Its verdict is derived from the command registry, not asserted**: a token that still resolves to a live subcommand is described as still working and left to you, because the one token it lists is `memory-gate`, which the grounding block hands every session. **Links** names `[[pointers]]` in `workspace-memory.md` and `artists/*/memory.md` that resolve to nothing — a memory fact is a hook plus a pointer to its source, so a dead pointer can mean the hook is the only surviving copy of the fact. It reuses `plinth backlinks`'s resolution ladder, reports prose links only (never one inside a code fence), never reports an ambiguous target, and strips anchors before resolving. **Injection** reports the session-start block's size against its 20,000-byte budget and **where those bytes go** — seven categories that sum to the block by construction, marking which are yours to change — plus how many facts of each index ride whole, how many ride as their opening line only, and which file to shorten. **Too long** names each memory file past the size it is useful at: a rule file (`voice.md`, `workspace-rules.md`) at **6,000 bytes**, a fact store (`artists/<slug>/memory.md`) at **12,000**. The two bars are not one bar: what a long rule file spends is the model's instruction-following budget, which reference facts do not touch. A file under its bar renders nothing at all. Since 0.16.0 it also reports master-context walls (one block past 2,000 characters), played entries still under an "upcoming" label, and a "Current state" heading with no as-of or a stale one.
28
+ - `plinth status` — local sync state: daemon liveness, last sync and per-type counts, any destructive batches being held, writes awaiting retry or permanently rejected, **every local write that has not reached Plinth** (scanned from the mirror against the last acknowledged state, with the remedy that clears each — start the daemon, or open and save a file that was created while it was down), the state of `voice.md`, and any conflict stashes in `~/.plinth/_conflicts/<workspace>/` — local copies that were set aside when the cloud version won. Warns when a source install's `dist/` is behind its checkout. Four further rows. **Commands** warns when the customisable block in a generated command still names machinery that command retired — the block is preserved verbatim forever and renders after the generated body, so a hand-written step that runs a retired ritual wins, and the generator may never edit it. **Its verdict is derived from the command registry, not asserted**: a token that still resolves to a live subcommand is described as still working and left to you, because the one token it lists is `memory-gate`, which the grounding block hands every session. **Links** names `[[pointers]]` in `workspace-memory.md` and `artists/*/memory.md` that resolve to nothing — a memory fact is a hook plus a pointer to its source, so a dead pointer can mean the hook is the only surviving copy of the fact. It reuses `plinth backlinks`'s resolution ladder, reports prose links only (never one inside a code fence), never reports an ambiguous target, and strips anchors before resolving. **Injection** reports the session-start block's size against its 20,588-byte budget and **where those bytes go** — seven categories that sum to the block by construction, marking which are yours to change — plus how many facts of each index ride whole, how many ride as their opening line only, and which file to shorten. **Too long** names each memory file past the size it is useful at: a rule file (`voice.md`, `workspace-rules.md`) at **6,000 bytes**, a fact store (`artists/<slug>/memory.md`) at **12,000**. The two bars are not one bar: what a long rule file spends is the model's instruction-following budget, which reference facts do not touch. A file under its bar renders nothing at all. Since 0.16.0 it also reports master-context walls (one block past 2,000 characters), played entries still under an "upcoming" label, and a "Current state" heading with no as-of or a stale one.
29
29
  - `plinth confirm` — review and release destructive batches the daemon has quarantined (apply, or `--discard`).
30
+ - `plinth context [--workspace <slug>] [--json]` — **the one complete cost account**: every source that can reach a session in this workspace, grouped by how it gets there, with Plinth's share separated from yours. `plinth status`'s **Injection** row measures the hook and nothing else, so a root `CLAUDE.md` can pass its bar and still be the largest thing a session loads — the bar counts LINES OF THE GENERATED BODY, and on a real workspace 29,055 of that file's 40,268 bytes are the preserved member block it cannot see. This reports the root and `CLAUDE.local.md` (loaded by Claude Code itself), `@`-imports, the rendered hook and each file's share of it, per-artist scoped files, demand-read stores and fact bodies, command bodies with their discovery metadata counted separately, and configuration — **including the per-artist `.claude/settings.json`, which is the one that applies inside an artist folder, because settings do not walk up**. ⚠️ **Bytes on disk, rendered hook bytes, loaded bytes and billed tokens are four different quantities and are never summed into one.** A file that reaches a session only through the hook contributes its injected share and never its size on disk, so trimming advice cannot double-charge it. There is exactly one combined figure and it prints its assumptions beside it, because nothing here observes a session: **actual loading, tokens and cached usage are reported as not measured, never as zero**, and the harness's own tool-schema overhead is named as unknown rather than guessed from a catalogue size. Missing, unreadable, refused, unresolved and unlistable are five distinct states, since all five otherwise read as "0 bytes". **Read-only**: it writes no file, starts no daemon and makes no network call — proven against a v1 `state.json`, which the ordinary state read would migrate and rewrite.
30
31
  - `plinth refresh-context [--workspace <slug>]` — force-regenerate the workspace-root `CLAUDE.md` **and the two generated commands** from the current Plinth schema + workspace. The user-customisable block between the `<!-- BEGIN: user-customisable -->` / `<!-- END -->` markers is always preserved verbatim in every generated file; `CLAUDE.local.md` is never touched.
31
- - `plinth grounding` — print the session-start grounding block (current date, **who the member is**, entity resolution, write confirmation, workspace rules, workspace memory, user memory). It opens by naming the member: display name, email, role, workspace and machine timezone, and that tasks the agent creates are assigned to them unless told otherwise. That renders from a principal **persisted at login**, not a call at render time — this command runs on every session start, so a round trip would tax all of them and fail offline. An existing login self-heals on the next sync, so no re-login is needed, and a server that does not yet carry `role` renders a shorter true line rather than a dangling clause. Invoked by the generated Claude Code SessionStart hook. **Bounded at 20,000 bytes total.** Only the two memory indexes give: a generated safety primitive is never cut, and if the generated block alone exceeded the budget the indexes collapse to a pointer and the block goes over rather than losing a rule. An index that does not fit whole keeps the **most recently banked** facts, by the date each line carries rather than by position — `workspace-memory.md` supersedes in place, so an updated line keeps its original slot and position does not track age — and renders them in the file's own order, with a note saying how many of how many are shown. **A fact that does not fit whole is named by its title** — its own first sentence, a boundary the member already wrote, with its `[[pointer]]` kept — so the agent knows the fact exists and where to read it. ⚠️ **Naming is preferred over carrying, and that trades depth for breadth:** the naming pass runs first and packs, and whole facts compete for what is left, so on a full index no fact may ride whole at all. A title pays in proportion to how well the hook was written; a fact whose first sentence runs long compresses barely at all, which is why *shorten each fact's first sentence* buys room where *trim the file* does not. `voice.md` is a **pointer, not a payload**: it is read on demand when drafting, never injected, which is what took a live workspace's block from 43,578 bytes to 19,911.
32
+ - `plinth grounding` — print the session-start grounding block (current date, **who the member is**, entity resolution, write confirmation, workspace rules, workspace memory, user memory). It opens by naming the member: display name, email, role, workspace and machine timezone, and that tasks the agent creates are assigned to them unless told otherwise. That renders from a principal **persisted at login**, not a call at render time — this command runs on every session start, so a round trip would tax all of them and fail offline. An existing login self-heals on the next sync, so no re-login is needed, and a server that does not yet carry `role` renders a shorter true line rather than a dangling clause. Invoked by the generated Claude Code SessionStart hook. **Bounded at 20,588 bytes total.** That is 20,000 plus three measured rows for material added since — dated index lines, M5's source rule, F3's artist carve-out — each named in `grounding.ts` with the figure it was measured at, so a raise is attributable rather than absorbed. Only the two memory indexes give: a generated safety primitive is never cut, and if the generated block alone exceeded the budget the indexes collapse to a pointer and the block goes over rather than losing a rule. An index that does not fit whole keeps the **most recently banked** facts, by the date each line carries rather than by position — `workspace-memory.md` supersedes in place, so an updated line keeps its original slot and position does not track age — and renders them in the file's own order, with a note saying how many of how many are shown. **A fact that does not fit whole is named by its title** — its own first sentence, a boundary the member already wrote, with its `[[pointer]]` kept — so the agent knows the fact exists and where to read it. ⚠️ **Naming is preferred over carrying, and that trades depth for breadth:** the naming pass runs first and packs, and whole facts compete for what is left, so on a full index no fact may ride whole at all. A title pays in proportion to how well the hook was written; a fact whose first sentence runs long compresses barely at all, which is why *shorten each fact's first sentence* buys room where *trim the file* does not. `voice.md` is a **pointer, not a payload**: it is read on demand when drafting, never injected, which is what took a live workspace's block from 43,578 bytes to 19,911.
32
33
  - `plinth voice-gate` — gate agent-originated copy against the workspace voice rubric. **No longer mounted by the mirror generator** (20 Aug 2026): a generated mirror carries no PreToolUse hook, voice is carried as an instruction by `workspace-rules.md`, and the outbound boundary is a `permissions.deny` list in the generated `.claude/settings.json` that refuses the **send, reply, forward, calendar and mail-deletion verbs on any mounted MCP**, not seven tool names — each verb anchored at a word boundary in four positions (start, after `_`, after `-`, and at a camel hump), which is what catches `outlook_send_message` and `send-mail` while leaving `resend_verification` and `list_sent` alone. Spam is the one family that is *not* anchored, and deliberately: no English word contains `spam` as a substring, so the unanchored form has nothing to over-reach into and catches `reportSpam` and `move_to_spam` as well as `mark_message_spam`. Deletion joined on 30 Aug 2026, taking the list from 61 patterns to 91: `trash` bare (nothing reads by *starting* with it, and `empty_trash` falls out for free), `delete` against the `message` / `thread` / `mail` nouns (bare, it would refuse every `mcp__plinth__delete_*` tool), and spam-marking, which Gmail purges after thirty days and is therefore deletion on a fuse. **Plinth's own calendar-write route is deliberately outside the list** (4 Sept 2026). The server renamed those tools to `preview_` / `create_` / `update_calendar_entry` on 3 September, and because the deny dialect has no negation an exception cannot be written as a rule — the carve-out *is* the name. The same rename retired a guarantee, since the calendar families were anchored on the noun `event` and `delete_calendar_entry` on anyone's server was then refused by nothing, so the list went from 91 patterns to **123**: `delete` and `cancel` anchored on `calendar` and `entry`, which refuses calendar deletion on every server again, Plinth's own included. **Drafting, filing, labelling and reading are untouched** — `outlook_send_draft` is denied while `outlook_create_draft` is not, because the discriminator is the verb, never the noun, and archiving a message is a move rather than a deletion. **Two costs are disclosed rather than discovered:** a few reads are caught by name (`getForwardingSettings`, a `list_trash`), and the bare `trash` family also refuses Google Drive's `trash_file` and the only route the live Gmail mount has for discarding a staged draft — that connector exposes no `delete_draft`, so a wrong draft is repaired with `update_draft` instead. Ten holes are recorded as assertions in `tests/context-session-hook.test.ts` rather than as prose — deletion-by-move and Gmail's own label route among them, the latter being the widest, since Gmail expresses TRASH and SPAM as labels and labelling stays allowed. The list is an enumeration and the tests say so out loud. The command still ships and still works, for a hand-wired mount: `--hook` runs it as a PreToolUse hook that blocks non-compliant Gmail drafts; direct mode takes `--file <path>` or stdin and prints the verdict (`--json` for the raw response).
33
34
  - `plinth backlinks <target>` — report what points at a target, by reading the local mirror rather than making a network call. `<target>` can be a mirror path (`projects/breadcrumb-trail.md` — including a mirror-root file like `CLAUDE.md`), a bare name (`breadcrumb-trail`), or a wikilink target as written (`Aligned Timeline - 4 June 2026`). Output is file paths with line numbers and the link as written; `--paths-only` emits deduped paths for piping, and stdout carries the answer ALONE — withheld rows never reach the pipe, whatever the display flags say. **Every narrowing is disclosed, and the counts print even when they are zero**, because a filter the caller cannot see is indistinguishable from an empty corpus, and a disclosure that only appears when it bites gives a reader no baseline to judge it against. Two footers carry them: links withheld from the answer (daemon-rendered marker blocks, code spans / HTML comments, and links to a bare name several files answer to), and the corpus line — which workspace was searched, how much of the mirror was read against its full size, and what went unread: `this-fiction · 859 of 880 files scanned (21 non-markdown, 0 unreadable) in 34ms`. `--unfiltered` shows the withheld rows, each tagged with the narrowing that hid it. Non-markdown files are not scanned for links but **do** count as existing, so asking about a PDF in `files/` answers rather than denying it. Exit codes: `0` results found or the target file exists, `1` no such target and nothing links to that name, `2` a bare name several files answer to (it lists them rather than guessing — and every candidate it prints resolves when pasted back), `3` no active workspace, an unknown or malformed workspace slug, or a mirror with nothing to search.
34
35
  - `plinth declare <path> --scratchpad <dir> [--label=<text>]` — declare a file in the session scratchpad as a work product, so it appears in the cockpit's work-products panel instead of being lost with the session. Appends one line to an append-only `.plinth-deliverables.jsonl` manifest inside `<dir>`; the manifest records the **path, never the content**, so the panel always points at the live file rather than a snapshot that can go stale. Grounding primitive (j) instructs the agent to run this the moment it writes a deliverable whose only home is the scratchpad. **The scratchpad directory is required and never inferred** (`--scratchpad`, or `PLINTH_SCRATCHPAD_DIR`): this repo learns it from neither the app that spawns the terminal nor the harness that told the agent, so a guessed root would silently widen the containment check every refusal rests on. Refuses, with exit 1, anything outside that directory — including a symlink whose target is outside it — plus a directory, a file that does not exist yet, and a missing root.
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "3f94e18ba675aa091a366eb8e57bcb3b8bd04b30",
2
+ "sha": "ce2c0b25730aa0568e286215be1ad6d1dc066688",
3
3
  "dirty": false,
4
- "built_at": "2026-09-06T19:15:56.134Z"
4
+ "built_at": "2026-09-07T21:05:46.844Z"
5
5
  }