@plinth-music/cli 0.10.1 → 0.12.0

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,304 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.12.0 - 2026-08-29
6
+
7
+ Eleven PRs since `0.11.0` (`v0.11.0..a7067e1`, #131-#141; #130 is 0.11.0's own History row).
8
+ Two data-loss fixes on the entity mirror worth reading first, a send boundary that was
9
+ catching almost nothing on Outlook, and `plinth status` gaining four things it could not say. There is no server
10
+ prerequisite - every change is client-side, and a 0.11.0 client talks to the same API.
11
+ The desktop app pins `0.11.0` exact and picks none of this up until a desktop release
12
+ repins it.
13
+
14
+ ### ⚠️ Every send tool on every Outlook and Microsoft 365 MCP was outside the boundary
15
+
16
+ The generated `permissions.deny` list matched seven verb names **exactly**
17
+ (`mcp__*__send_message`), so a vendor prefix was enough to walk past it. Read off four
18
+ community Outlook servers rather than guessed: not one of their send tools was caught.
19
+ `outlook_send_message`, `outlook_reply`, `outlook_forward`, `send-mail`, `sendEmail`,
20
+ `send_outlook_mail` and their calendar-write siblings were all callable in a Plinth
21
+ workspace while the workspace's own rules asserted an agent cannot send.
22
+
23
+ Each verb is now anchored at a word boundary in four positions - start, after `_`,
24
+ after `-`, and at a camel hump - with calendar mutation verbs carrying the event noun as
25
+ a full cross-product. **15 patterns become 61.** Anchoring is what keeps `resend_verification`
26
+ and `list_sent` callable, and matching is case-sensitive, which is what makes that possible.
27
+ `outlook_send_draft` is denied while `outlook_create_draft` is not: the discriminator is
28
+ the verb, never the noun.
29
+
30
+ ⚠️ **If you had an Outlook or Microsoft 365 MCP mounted, an agent that could send from it
31
+ yesterday cannot today.** The seven old exact-verb globs are retired, not deleted, and each
32
+ is pinned as still denied by a surviving pattern. Known holes are recorded rather than
33
+ implied closed: dotted REST spellings (`users.messages.send`), Graph's `accept`/`decline`,
34
+ Google's `insert`/`patch`, and nouns other than `event`.
35
+
36
+ ### ⚠️ A row deleted in Plinth no longer takes your replacement file with it
37
+
38
+ Three related faults on the mirror's entity lane, all of which could destroy a file the
39
+ daemon never wrote.
40
+
41
+ **Ownership is now proved, not assumed from the filename.** Three places acted on a file
42
+ because of the path it sat at rather than proof the row owned it. Measured A/B against the
43
+ same cloud state on a scratch workspace: a replacement document created at a slug whose old
44
+ row had been deleted rotated through ten distinct inodes over ten polls, was absent from
45
+ disk for windows of 2.8 to 5.2 seconds each time, and was gone at the end. A document with
46
+ an unpushed local edit whose row was deleted in Plinth was deleted locally - and
47
+ `plinth status` then said *"All local writes synced."* Both are now kept, and the daemon
48
+ unlinks only what it can prove it wrote: the acked body and frontmatter hashes, the same
49
+ test reconciliation already uses. Inode identity was the old proof and is wrong in both
50
+ directions - an in-place edit keeps the inode while replacing every byte, and a
51
+ write-tmp-and-rename rotates it while changing nothing.
52
+
53
+ **The daemon's own unlink no longer escapes as a cloud delete.** On both the `files/` and
54
+ the entity lanes, an unlink event the daemon itself caused could be pushed as a real
55
+ `DELETE` of the live row that had just been written at that path - and on the entity lane
56
+ the id is resolved by slug against state the pull has just repointed, so it named the new
57
+ row both ways. Guarded now by a re-stat: an unlink event for a path that exists pushes
58
+ nothing. On the entity lane the guard re-dispatches as an upsert rather than returning,
59
+ because a bare return would silently drop a genuine restore-from-backup instead - no delete,
60
+ but nothing pushed either, and `plinth status` reading clean over an edit the cloud has
61
+ never seen.
62
+
63
+ Both arms log `[FILE_DELETE_SUPPRESSED path=… reason=pull_echo|path_exists]` and
64
+ `TOMBSTONE_SKIP` / `TOMBSTONE_KEEP` with the owning row's id, so the next escape is
65
+ diagnosable from the log rather than from a re-run.
66
+
67
+ **A tombstone over malformed YAML no longer aborts the whole sync.** One hand-edited file
68
+ with unparseable frontmatter, plus a tombstone for its row, used to kill every later entity
69
+ type, the whole `files/` lane and context generation, naming no file. Unparseable is now
70
+ unprovable, which takes the refuse arm.
71
+
72
+ ### ⚠️ `/plinth:close` offers to trim a memory file that has grown too long
73
+
74
+ Nothing on any memory surface said "too long". `plinth status` now names each file past its
75
+ bar, and `/plinth:close` gains a step that offers a trim the member confirms - never one
76
+ that just happens. On yes it proposes and writes nothing: each surviving fact shown
77
+ unchanged or beside its rewrite, a wrong fact edited where it stands rather than deleted,
78
+ provenance and dates preserved, and nothing banked in the last 30 days dropped. The write
79
+ waits for confirmation of the proposal.
80
+
81
+ **Two bars, not one.** A rule file (`voice.md`, `workspace-rules.md`) gets **6,000 bytes**;
82
+ a fact store (`artists/<slug>/memory.md`) gets **12,000**. The split is not stylistic: what
83
+ decays as a context grows is instruction-following, and reference facts cost room rather
84
+ than follow-budget. On one live workspace `voice.md` was 9,324 bytes carrying roughly 64
85
+ rules while the two largest artist stores were nearly twice its size and cost far less. A
86
+ single byte bar would have flagged all three for the same reason and been wrong about which
87
+ one mattered. Every file under its bar renders nothing at all.
88
+
89
+ `voice.md` is named and nothing else - the agent is denied `Edit(//**/voice.md)` by the
90
+ settings this same generator ships, so the deny is the control and the prose is the honesty.
91
+
92
+ ### `plinth status` stops misreporting twice, and reports where the injection's bytes go
93
+
94
+ - **A `Links` block** over `workspace-memory.md` and `artists/*/memory.md`, naming
95
+ `[[pointers]]` that resolve to nothing. A memory fact is a hook plus a pointer to its
96
+ source, so a dead pointer can mean the hook is the only surviving copy. Two facts on a
97
+ live workspace had pointed at a directory that did not exist for months and nothing
98
+ reported it. Resolution reuses `plinth backlinks`'s own ladder; prose links only, never
99
+ ambiguous ones, and anchors are stripped before resolving. Measured read-only against a
100
+ 1,357-file mirror at 19 to 21 ms.
101
+ - **The retired-machinery row stops inviting you to delete a working command.** It ended
102
+ every row with "Review it.", and the only token it lists is `memory-gate`, which is a
103
+ live subcommand the grounding block hands every session. The verdict is now derived from
104
+ the command registry rather than asserted, so a token that still resolves is described as
105
+ still working.
106
+ - **A row for "this was deleted in Plinth and your copy diverged."** That is a permanent
107
+ write failure and it was being swallowed - `status` printed one of two remedies that
108
+ cannot work, *"start the daemon"* (no daemon can push a deleted row) or *"re-sync"* (no
109
+ sync can restore one). It never advises deleting the `revision:` line on its own, because
110
+ that is the advice for a duplicated file and following it re-POSTs a document you
111
+ deliberately deleted.
112
+ - **The `Injection` row now shows the split** - seven categories summing to the block by
113
+ construction, so you can see how much of the budget is Plinth's own prose and how much is
114
+ yours. On one live workspace it reads: Plinth's grounding 14,278 bytes, trim notes 641,
115
+ voice pointer 801, your workspace's rules 188, workspace facts 1,982, your own facts
116
+ 1,901.
117
+
118
+ ⚠️ **Facts that do not fit are now injected as titles, and titles are preferred over whole
119
+ facts.** A title is the fact's own first sentence with its `[[pointer]]` kept, so an agent
120
+ knows the fact exists and where to read it. The naming pass runs first and packs; whole
121
+ facts compete for what is left. **That trades depth for breadth, and on a full index the
122
+ depth can go to zero.** Measured today with a 0.11.0 and a 0.12.0 binary against the same
123
+ live files: 0.11.0 injected 7 of 23 workspace facts and all 6 personal facts, every one of
124
+ them whole, and said nothing about the other 16. 0.12.0 injects **no workspace fact whole**,
125
+ names 10 of 23 by their opening line, and carries 4 of 6 personal facts whole with 1 more as
126
+ an opening. So 15 facts are named where 13 were, and 4 arrive complete where 13 did. The
127
+ agent is told which is which and told to read the file.
128
+
129
+ The finding worth carrying is that a title pays in proportion to how well the hook was
130
+ written. This Fiction's facts average a 196-byte first sentence, so they barely compress.
131
+ *"Shorten each fact's first sentence"* is what buys room; *"trim the file"* is measurably
132
+ near-useless advice.
133
+
134
+ ### The session names you, and a new workspace arrives with its folders
135
+
136
+ The grounding block opens by naming the member: who you are, your role, the workspace and
137
+ your timezone, and that tasks the agent creates are assigned to you unless told otherwise.
138
+ It renders from a principal persisted at login rather than a call at render time, since the
139
+ block runs on every session start and a round trip would tax all of them and fail offline.
140
+ An existing login self-heals on the next sync; no re-login is needed, and a server that does
141
+ not yet carry `role` renders a shorter true line rather than a dangling clause.
142
+
143
+ `plinth sync` now scaffolds `files/` and the six entity folders, and the folder map reads the
144
+ disk rather than asserting. `daily-log.md` is retired. Routing is stated once - the rule that
145
+ live state does not belong in a memory file moved into the grounding block, where the other
146
+ six already were.
147
+
148
+ ### Every mirror file carries its row `id:`, including the ones with no frontmatter
149
+
150
+ 0.11.0 shipped the `id:` backfill but declined to create a frontmatter block where there was
151
+ none, so a file without one never got its id. It does now, on a positive predicate rather
152
+ than the obvious one - "prepend whenever the splice returns null" corrupts a file that opens
153
+ `---` and never closes it, a bare `---`, and any BOM-prefixed file, each of which carries
154
+ real frontmatter meaning and would have had its hash moved and a local edit invented.
155
+
156
+ A locally created entity also gets its id written into the file before the state entry takes
157
+ the inode, rather than going the whole daemon session without one. And the backfill stops
158
+ re-reading every tracked file on every boot: a completion marker in `state.json` ends it,
159
+ which is only sound because the create path above closes the last route to a tracked file
160
+ with no id.
161
+
162
+ ### The generated surface: seven claims that were stale, unsafe or wrong
163
+
164
+ Each of these was being told to every agent in every workspace.
165
+
166
+ 1. `/plinth:onboard` said `create_task` and `create_project` "write straight through". They
167
+ now take a payload-bound, single-use confirmation token. ⚠️ The asymmetry moved rather
168
+ than closing: `create_artist_contact` takes no token and is written on the first call,
169
+ and that command writes contacts. And a token is not a user gate - it proves the agent
170
+ called twice with an unchanged payload, so "propose, then stop" is what still makes the
171
+ yes real.
172
+ 2. A grounding primitive rendered a shell command that was a parse error when pasted, one
173
+ token away from a truncating `>` in a directory that for a cockpit agent is the mirror
174
+ root. Both placeholders are quoted, and the shell-safety scan widened from one sentence
175
+ to the whole block.
176
+ 3. Nothing said how to find one artist's rows. A name grep returned 16 of 46 for one artist
177
+ and read as "no such project". Now routed to `artist_id:` matched against that artist's
178
+ own `id:`.
179
+ 4. `/plinth:onboard` mandated `AskUserQuestion` at a step that can have one candidate, which
180
+ the tool refuses. Plain-text fallback stated.
181
+ 5. Nothing named the 4 MB upload ceiling, which is how a 4.6 MB site plan sat unsynced the
182
+ day before a show. Rendered from the constant so it cannot go stale.
183
+ 6. `plinth --help` told members to mount a hook the same binary removes. `voice-gate`'s
184
+ description advertised `--hook` as a PreToolUse mount while the generator un-writes
185
+ exactly that entry on the next sync. The un-mount claim is narrow on purpose: it matches
186
+ on the `_plinth` marker, so a hand-added hook is never touched.
187
+ 7. `/plinth:onboard` never mentioned `voice.md`. It now has one rules-first step that asks
188
+ the member to write it, since the agent is denied editing it.
189
+
190
+ ### Plinth's own prose was measured, and cut where the numbers justified it
191
+
192
+ Plinth's own prose was 86% of the grounding block and 92% of the imperatives in it. A plain
193
+ session at the mirror root carried roughly 93 of Plinth's own instructions before the member
194
+ typed anything; past the point where a model stops following reliably, decay is uniform - it
195
+ stops following everything, not just the newest rule.
196
+
197
+ Five cuts, each measured and each with its reason: a prohibition on two tools that do not
198
+ exist on the deployed server, a roadmap parenthetical, a fallback voice paragraph that two
199
+ other primitives already said, an argument for a rule stated two sentences earlier, and the
200
+ enumerations the 61 deny patterns now enforce - an enforced rule costs no follow-budget.
201
+ Imperatives came down from 80 to 75. Everything else was measured, had its original incident
202
+ found in the source, and was kept.
203
+
204
+ ⚠️ **The release as a whole still added prose, and the facts allocation paid for it.** That
205
+ cut took 654 bytes off the pinned base block, but the member identity primitive, the seven
206
+ generated-surface corrections and the injection split each added more, so across `0.11.0` to
207
+ `0.12.0` the base block went **11,311 to 12,646 bytes, up 1,335**. The session-start block
208
+ itself is capped at 20,000 and does not grow, so the extra prose comes out of what is left
209
+ for facts - which is the other half of the titles trade above. The `Injection` row in
210
+ `plinth status` is there so this is visible rather than inferred.
211
+
212
+ ## 0.11.0 - 2026-08-28
213
+
214
+ Six PRs since `0.10.1` (`v0.10.1..bb0312a`, #123-#128). One data-loss fix worth reading
215
+ first, a session-start injection that is now less than half the size, and a `/plinth:close`
216
+ that stops asking about shared memory. There is no server prerequisite - every change is
217
+ client-side, and a 0.10.1 client talks to the same API. The desktop app pins `0.10.1`
218
+ exact and picks none of this up until a desktop release repins it.
219
+
220
+ ### ⚠️ A tombstone never deletes a file the daemon did not write
221
+
222
+ Renaming a tracked file in `files/` and then writing a new file at the old path destroyed
223
+ the new file. No stash, no conflict entry, `plinth status` clean throughout - and it lost
224
+ the cloud row as well, so no copy survived on either side. Reproduced on a live workspace
225
+ in under fifteen seconds.
226
+
227
+ Two faults, both fixed. The tombstone branch unlinked by path, with neither guard the
228
+ entity lane has; and the pull suppressor held one slot per path, so a pull that carries a
229
+ delete and a write for the same path silently dropped the delete and let the unlink escape
230
+ as a real delete of the live row.
231
+
232
+ The rule is now that the daemon unlinks only what it can prove it wrote: a state entry at
233
+ that path whose id matches the deleted row and whose content hash matches the bytes on
234
+ disk. A path owned by a different live row is skipped. Anything unprovable gets no disk
235
+ write at all - the file stays, untracked but present, and the next reconcile pushes it up
236
+ as a new row. Normal operation is unchanged and silent.
237
+
238
+ ### ⚠️ Session start is less than half the size, and `voice.md` is read on demand
239
+
240
+ Measured on one live workspace with the same files on both ends: **43,578 bytes before,
241
+ 19,911 after**. That block is injected before your first word, in every session, on every
242
+ workspace, and nothing bounded it.
243
+
244
+ - **`voice.md` is now a pointer, not a payload.** It was 37% of the block and rode
245
+ verbatim - every line of it, including a retired gate's measurement history. The pointer
246
+ carries every instruction the wrapper did, and warns about a malformed file before the
247
+ agent opens it rather than after.
248
+ - **A total budget of 20,000 bytes.** Only the two memory indexes give; a generated safety
249
+ primitive is never cut. If the generated block alone exceeded the budget the indexes
250
+ collapse to a pointer and the block goes over rather than losing a rule.
251
+ - **The cut keeps the newest facts, by the date each line carries**, and renders them in
252
+ the file's own order. Position does not track age in `workspace-memory.md` - superseding
253
+ in place means an updated line keeps its original slot - so a positional head would have
254
+ dropped thirteen recent facts to keep three older ones.
255
+
256
+ `plinth status` gains an Injection row: the byte count, the budget, how many facts fit, and
257
+ which file to shorten.
258
+
259
+ ### ⚠️ `/plinth:close` stops asking you about shared memory
260
+
261
+ The close asks nothing about memory now. An artist's fact goes to `artists/<slug>/memory.md`,
262
+ a member's to `user-memory.md`, both written directly; a fact true of neither is named in the
263
+ closing summary and written nowhere, so nothing is silently dropped. The confirm gate that
264
+ used to live in the close now lives in the session-start grounding block, which is where the
265
+ mid-session "remember this for everyone" path actually runs - it had no gate at all before
266
+ this. `plinth memory-gate` is still live and still injected for that path.
267
+
268
+ ### `plinth status` warns when your own block names machinery a command retired
269
+
270
+ The customisable block in a generated command is preserved verbatim, forever, and it renders
271
+ after the generated body - so a hand-written step that still runs a retired ritual wins. The
272
+ generator may never edit that block, so `status` says so instead: it names the file, the
273
+ token and the release, and the remedy is always "review it". A token qualifies only if Plinth
274
+ authored it and then withdrew it from that command.
275
+
276
+ ### Every task, project and document mirror file carries its row id
277
+
278
+ Mirror files now carry `id:` in their frontmatter, on all six entity types. An agent holding
279
+ a mirror file had no way to reach the MCP write for that row except `search_entities`, which
280
+ misses ordinary phrasings - `"Zubin vacation days"` returned nothing against a live task
281
+ called "Chase Zubin's vacation-days number", and an agent that trusts the empty result
282
+ creates a duplicate. A one-time backfill splices `id:` into files already on disk, since a
283
+ cursor feed never re-sends an unchanged row. The key is server-authoritative and stripped
284
+ before the wire, so adding it moves no hash and pushes nothing.
285
+
286
+ ### An entity file created or renamed while the daemon was down reaches the cloud
287
+
288
+ A task, project or document file written into the mirror while the daemon was off never
289
+ synced - no push path could see it - while `plinth status` said everything was synced. An
290
+ offline rename had the same hole from the other side: the old slug stayed in the cloud with
291
+ nothing saying so. A boot discovery walk now finds both.
292
+
293
+ Creates are capped at ten per boot (`PLINTH_DISCOVERY_WRITE_CAP`) and decided as a batch, so
294
+ a mirror that lost its state file does not fan a hundred rows into the cloud unattended. An
295
+ untracked artist folder is never created by the daemon whatever the cap - the MCP gates that
296
+ write behind a confirmation, and an unattended path must not be weaker than the agent path.
297
+ Your own save with the daemon running still creates one.
298
+
299
+ `plinth status` stops offering a remedy that no longer works: the untracked-entity lane now
300
+ says "start the daemon", and the three cases the boot walk still will not push - over the
301
+ cap, a held artist, a file that came out of a pull - each get their own sentence.
302
+
5
303
  ## 0.10.1 - 2026-08-26
6
304
 
7
305
  One PR since `0.10.0` (`v0.10.0..82441a1`, #121). Generated command text and its tests only, so a patch. Every mirror receives it on its next sync; there is no server prerequisite.
package/README.md CHANGED
@@ -23,18 +23,18 @@ plinth start # run the daemon: continuous pull + watch + pu
23
23
  ## Commands
24
24
 
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
- - `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.
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. 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.
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.
29
29
  - `plinth confirm` — review and release destructive batches the daemon has quarantined (apply, or `--discard`).
30
30
  - `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, entity resolution, write confirmation, workspace rules, workspace memory, user memory). Invoked by the generated Claude Code SessionStart hook.
32
- - `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 Gmail send/reply/forward tools and the Calendar write tools while leaving drafts and reads alone. 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).
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 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 and calendar-write 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. Drafts and reads are untouched, and `outlook_send_draft` is denied while `outlook_create_draft` is not: the discriminator is the verb, never the noun. 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
33
  - `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
34
  - `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.
35
35
  - `plinth review-tier [--repo <path>] [--base <ref>]` — print the code-review tier the current diff earns, `low` or `high`, for use as `/code-review $(plinth review-tier)`. Reads the diff against the merge base — committed, uncommitted **and untracked**, since a brand-new never-added file is invisible to `git diff` and would otherwise be classified as absent — and routes `high` on the risk classes the review rubric names: auth/RLS, optimistic concurrency/CAS, financial math, data-moving migrations, and large **and** multi-subsystem together. **It is a router, not a ceiling**: size alone never escalates, because a blanket cap would kill the concurrency reviews that earn their keep. **The output contract is the safety story.** It is invoked inside `$( )`, where an empty stdout or a non-zero exit would collapse the caller to an unqualified `/code-review` that silently inherits whatever global effort is configured — safe by accident, and indistinguishable from this working. So stdout carries **exactly one token and nothing else**, the exit code is **always 0**, and the reasoning goes **unconditionally to stderr** on both tiers. Anything it cannot classify — not a git repo, no merge base, no default branch, an empty diff — prints `high` and says why on stderr.
36
36
  - `plinth session register` / `plinth session list` / `plinth session end` — machine-local visibility for the Claude sessions writing this workspace, plus the session boundary's end verb. `register` runs as a generated SessionStart hook and is fail-quiet by construction; it records the session id, pid, cwd and — inside the Plinth desktop dock — the dock **tab id** (`PLINTH_DOCK_TAB_ID`), which is null in a plain terminal and is what lets the dock ask whether a given tab holds a live agent. `list` derives liveness at read time from pid **and** process start time (a recycled pid reads dead), reaps dead records as a side effect, and reports what it could not check so a short list is never mistaken for a full one; `--json` adds `tab_id` per session and an `ended` count. `end [--summary "<one line>"]` is the last step of a `/close` ritual: it marks the record ended — a field, not a delete, so the reap still owns deletion — and then, **only** when `PLINTH_DOCK_SIGNAL_PORT`, `PLINTH_DOCK_SIGNAL_TOKEN` and `PLINTH_DOCK_TAB_ID` are all present, POSTs `{"summary": …}` to the dock's loopback listener on `/closed` with the same `x-plinth-token` / `x-plinth-tab` header pair the desktop's edit hook already sends. **Marking happens before the signal**, so a dock that hears `/closed` and immediately asks `session list` already sees the session ended. Every failure path — no dock env, listener down, POST refused, no record — exits 0 and prints nothing: the close ritual runs identically in the dock, in another terminal harness, or in a bare shell, and the dock is optional by construction. **`end` is the one command the flag guard warns rather than refuses**, for that reason: a `--summary` the parser lost is dropped and reported on stderr, and the session still ends at exit 0. Ending a session is the safety action and must not fail because a flag was malformed. Machine-local: a session on another machine is not counted.
37
- - `plinth memory-gate log` / `plinth memory-gate show` — the decision record behind a `/close` ritual's shared-memory confirm gate. `log` appends one versioned JSON line per proposed `workspace-memory.md` line — timestamp, session id, member, the proposed text, `--decision accept|reject|edit`, and the final text — to `~/.plinth/_memory_gate/<workspace>/log.jsonl`; `--nothing-proposed` records a close that had nothing to bank, because *gated and proposed nothing* must stay distinguishable from *never gated*. **Accepts are logged, not only rejections**, for the same reason. The CLI owns the format rather than the agent writing it free-hand, so the log can still be read back in a year — and unlike the session verbs this one **fails loud** (a decision log that silently drops a decision produces the exact state it exists to make visible). A corrupt line is skipped and counted on read, never repaired and never allowed to block the next append. **Nothing under `~/.plinth` syncs; this log is machine-local, and that is intended for V1.**
37
+ - `plinth memory-gate log` / `plinth memory-gate show` — the decision record behind the confirm gate on `workspace-memory.md`, the workspace-wide shared pile. **`/plinth:close` no longer runs it** (0.11.0): the close asks nothing about memory, and the gate now lives in the session-start grounding block, which is where the mid-session "remember this for everyone" path actually runs. The command is live and injected there. `log` appends one versioned JSON line per proposed `workspace-memory.md` line — timestamp, session id, member, the proposed text, `--decision accept|reject|edit`, and the final text — to `~/.plinth/_memory_gate/<workspace>/log.jsonl`; `--nothing-proposed` records a close that had nothing to bank, because *gated and proposed nothing* must stay distinguishable from *never gated*. **Accepts are logged, not only rejections**, for the same reason. The CLI owns the format rather than the agent writing it free-hand, so the log can still be read back in a year — and unlike the session verbs this one **fails loud** (a decision log that silently drops a decision produces the exact state it exists to make visible). A corrupt line is skipped and counted on read, never repaired and never allowed to block the next append. **Nothing under `~/.plinth` syncs; this log is machine-local, and that is intended for V1.**
38
38
 
39
39
  ⚠️ **Every string flag refuses a value the argument parser lost, rather than acting on it.** Three ways a value goes missing and the command still runs: a `--no-` token, which citty deletes from argv before parsing so the flag swallows whatever follows; a bare `--` in a value slot, which node's `parseArgs` consumes AS the value instead of honouring as a terminator; and simply omitting the value. All three end with the flag holding something that is not a value — usually the next flag's name — and until 0.10.0 only `plinth memory-gate log` noticed. Now `src/lib/cli/flag-guard.ts` wraps every command, so `plinth session end --summary "--no-x"` and `plinth status --workspace` say so instead of quietly using a default. **`--flag=value` is the form that can never mis-parse and is what every refusal advises.** Four flags carry text a person wrote and may legitimately begin with `-` (`--proposed`, `--final`, `--summary`, `--label`); the rest take slugs, uuids, paths, refs and counts, which never do, and **an unclassified flag gets the strict rule** so a flag added later is guarded before anyone remembers it.
40
40
 
@@ -54,8 +54,14 @@ every sync and on `plinth refresh-context`, the same way it writes `CLAUDE.md` a
54
54
  `.claude/settings.json`. They are the session boundary: `/plinth:start` opens a session
55
55
  (reads `workspace-memory.md` **and** `user-memory.md`, pulls what is unread, briefs in
56
56
  three lines, asks what you want to work on), and `/plinth:close` ends one (verifies each
57
- write landed, routes follow-ups to a durable surface, banks memory, and runs
58
- `plinth session end` as its last step). `/plinth:onboard` sets a new workspace up by
57
+ write landed, banks the session record into each artist's `_artist.md`, routes follow-ups
58
+ to a durable surface, files an artist's fact to `artists/<slug>/memory.md` and a member's to
59
+ `user-memory.md` without asking, **offers to propose a trim for any memory file `plinth status` has flagged as
60
+ past its bar**, and runs `plinth session end` as its last step). The trim is offered, never taken: on yes it
61
+ proposes and writes nothing — each surviving fact shown unchanged or beside its rewrite, a wrong fact edited
62
+ where it stands rather than deleted, provenance and absolute dates preserved, and nothing banked in the last 30
63
+ days dropped — and waits for confirmation of the proposal. `voice.md` is named and nothing else, because the
64
+ agent is denied editing it. `/plinth:onboard` sets a new workspace up by
59
65
  conversation — connectors one at a time, each proven by a real read; a roster proposed
60
66
  from what those reads saw; the professional team as contacts; then an inbox review that
61
67
  becomes projects and tasks — and nothing is created without a yes. A workspace with no
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "c2f7a82d90bb492d81a36384093430a58cd7a912",
2
+ "sha": "dce17fb2bcf32a22f432c5efc9ecd50fd7eec6bd",
3
3
  "dirty": false,
4
- "built_at": "2026-08-26T22:13:39.014Z"
4
+ "built_at": "2026-08-29T10:45:13.962Z"
5
5
  }