@plinth-music/cli 0.11.0 → 0.13.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 +267 -0
- package/README.md +19 -10
- package/dist/build-stamp.json +2 -2
- package/dist/cli.js +3462 -1897
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,273 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
|
|
4
4
|
|
|
5
|
+
## 0.13.0 - 2026-08-30
|
|
6
|
+
|
|
7
|
+
Four code PRs since `0.12.0` (`v0.12.0..07a74f8`, #144-#147; #143 is 0.12.0's own History row).
|
|
8
|
+
One new sync surface, which is why this is a minor: a file the cli refuses to upload is now
|
|
9
|
+
visible to the whole team. The rest is a data-loss fix on task conflicts, a wider deny family,
|
|
10
|
+
and a `/plinth:close` that writes less. The desktop app pins `0.12.0` exact and picks none of
|
|
11
|
+
this up until a desktop release repins it. Server prerequisite: the rejection route shipped in
|
|
12
|
+
`plinth` on 30 Aug and is live; a 0.12.0 client keeps working against it, it simply does not post.
|
|
13
|
+
|
|
14
|
+
### A file one machine refuses is visible to every machine in the workspace
|
|
15
|
+
|
|
16
|
+
Until now an over-ceiling file (`over_cap`, 4.0 MB) or a symlink the cli refused showed only in
|
|
17
|
+
`plinth status` on the machine that refused it. A site plan for a show the next day sat on one
|
|
18
|
+
laptop while everyone else saw the folder without it and no sign anything was missing.
|
|
19
|
+
|
|
20
|
+
The cli now posts each file refusal to Plinth once, reads the workspace's refusals on every
|
|
21
|
+
sync into `state.json`, and `plinth status` shows the team's rows under your own, each with who
|
|
22
|
+
reported it, when it was first seen, and when the list was last read ("start the daemon for a
|
|
23
|
+
fresher list" - `status` never touches the network). Only the machine-neutral half of the
|
|
24
|
+
sentence travels; the remedy stays with the machine that can act on it. A refusal clears itself
|
|
25
|
+
when the file syncs - the server retires the row on that upload - and deleting a refused file
|
|
26
|
+
withdraws its row rather than orphaning it. Two things were wrong before this and are fixed by
|
|
27
|
+
it: a file shrunk while the daemon was stopped pushed cleanly and kept its `over_cap` record,
|
|
28
|
+
and deleting a refused file destroyed the only record that this machine had reported it.
|
|
29
|
+
Entity refusals (`bad_request`, `invalid_frontmatter`, `cloud_row_deleted`) stay local. Two
|
|
30
|
+
machines belonging to one person are indistinguishable to the server, so a row your laptop
|
|
31
|
+
reported reads as "this machine" on your desktop too. (#147)
|
|
32
|
+
|
|
33
|
+
### A task edited in Plinth and locally in the same window keeps the losing side
|
|
34
|
+
|
|
35
|
+
The conflict stash used to hold a byte-copy of the side that WON, and the cloud's pre-merge
|
|
36
|
+
prose was nowhere on disk; `status` and `views/conflicts.md` described which side won
|
|
37
|
+
backwards. The stash now holds the loser - the cloud's prose on a landed merge, your copy on the
|
|
38
|
+
arms that do not land - and every stash filename carries `.cloud` or `.local`, with existing
|
|
39
|
+
stashes still parsing. The boot summary names the stash ("1 merged field by field, the cloud's
|
|
40
|
+
description stashed to ~/.plinth/_conflicts/"), and if the pre-overwrite read of the cloud row
|
|
41
|
+
fails the merge does not land at all rather than landing and recording the inverse. Structured
|
|
42
|
+
fields still resolve row-wins, as documented. (#146)
|
|
43
|
+
|
|
44
|
+
### Mail deletion joins the deny family
|
|
45
|
+
|
|
46
|
+
`trash`, `delete` and `spam` are denied by verb across every mounted mail MCP, with `mail` as a
|
|
47
|
+
third noun stem beside `message` and `email`; Drive's `trash_file` is denied on the same word.
|
|
48
|
+
61 patterns become 91. Documented holes, measured against real servers: deletion by move
|
|
49
|
+
(`outlook_move_message` - denying `move` would deny filing), folder deletion, and Microsoft's
|
|
50
|
+
`markAsJunk`. Gmail expresses trash and spam as labels, and the label tools stay allowed because
|
|
51
|
+
filing is how review works - a decision, not an oversight. In a mirror an agent can no longer
|
|
52
|
+
discard its own wrong draft; delete-then-restage is a person's move. (#144)
|
|
53
|
+
|
|
54
|
+
### `/plinth:close` writes less, places it better, and trims in the open
|
|
55
|
+
|
|
56
|
+
Anything rule-shaped is routed before it is written - a tool to refuse becomes a proposed deny,
|
|
57
|
+
a procedure a proposed skill, a stable fact goes to `workspace-rules.md`, a preference is
|
|
58
|
+
binned. A never-save list gates the memory step, and every banked fact carries a `source:` it
|
|
59
|
+
can name or `source: unknown`, never an inferred one. The trim is a list of line proposals
|
|
60
|
+
accepted one at a time; removed lines go to `artists/<slug>/memory-archive.md`, which the
|
|
61
|
+
daemon never injects. A contradiction check reads each index for two lines that disagree, and
|
|
62
|
+
the conflict order is stated once, matching the server's scaffolds. The rendered `close.md` is
|
|
63
|
+
25,509 bytes / 55 rule-shaped lines. (#145)
|
|
64
|
+
|
|
65
|
+
## 0.12.0 - 2026-08-29
|
|
66
|
+
|
|
67
|
+
Eleven PRs since `0.11.0` (`v0.11.0..a7067e1`, #131-#141; #130 is 0.11.0's own History row).
|
|
68
|
+
Two data-loss fixes on the entity mirror worth reading first, a send boundary that was
|
|
69
|
+
catching almost nothing on Outlook, and `plinth status` gaining four things it could not say. There is no server
|
|
70
|
+
prerequisite - every change is client-side, and a 0.11.0 client talks to the same API.
|
|
71
|
+
The desktop app pins `0.11.0` exact and picks none of this up until a desktop release
|
|
72
|
+
repins it.
|
|
73
|
+
|
|
74
|
+
### ⚠️ Every send tool on every Outlook and Microsoft 365 MCP was outside the boundary
|
|
75
|
+
|
|
76
|
+
The generated `permissions.deny` list matched seven verb names **exactly**
|
|
77
|
+
(`mcp__*__send_message`), so a vendor prefix was enough to walk past it. Read off four
|
|
78
|
+
community Outlook servers rather than guessed: not one of their send tools was caught.
|
|
79
|
+
`outlook_send_message`, `outlook_reply`, `outlook_forward`, `send-mail`, `sendEmail`,
|
|
80
|
+
`send_outlook_mail` and their calendar-write siblings were all callable in a Plinth
|
|
81
|
+
workspace while the workspace's own rules asserted an agent cannot send.
|
|
82
|
+
|
|
83
|
+
Each verb is now anchored at a word boundary in four positions - start, after `_`,
|
|
84
|
+
after `-`, and at a camel hump - with calendar mutation verbs carrying the event noun as
|
|
85
|
+
a full cross-product. **15 patterns become 61.** Anchoring is what keeps `resend_verification`
|
|
86
|
+
and `list_sent` callable, and matching is case-sensitive, which is what makes that possible.
|
|
87
|
+
`outlook_send_draft` is denied while `outlook_create_draft` is not: the discriminator is
|
|
88
|
+
the verb, never the noun.
|
|
89
|
+
|
|
90
|
+
⚠️ **If you had an Outlook or Microsoft 365 MCP mounted, an agent that could send from it
|
|
91
|
+
yesterday cannot today.** The seven old exact-verb globs are retired, not deleted, and each
|
|
92
|
+
is pinned as still denied by a surviving pattern. Known holes are recorded rather than
|
|
93
|
+
implied closed: dotted REST spellings (`users.messages.send`), Graph's `accept`/`decline`,
|
|
94
|
+
Google's `insert`/`patch`, and nouns other than `event`.
|
|
95
|
+
|
|
96
|
+
### ⚠️ A row deleted in Plinth no longer takes your replacement file with it
|
|
97
|
+
|
|
98
|
+
Three related faults on the mirror's entity lane, all of which could destroy a file the
|
|
99
|
+
daemon never wrote.
|
|
100
|
+
|
|
101
|
+
**Ownership is now proved, not assumed from the filename.** Three places acted on a file
|
|
102
|
+
because of the path it sat at rather than proof the row owned it. Measured A/B against the
|
|
103
|
+
same cloud state on a scratch workspace: a replacement document created at a slug whose old
|
|
104
|
+
row had been deleted rotated through ten distinct inodes over ten polls, was absent from
|
|
105
|
+
disk for windows of 2.8 to 5.2 seconds each time, and was gone at the end. A document with
|
|
106
|
+
an unpushed local edit whose row was deleted in Plinth was deleted locally - and
|
|
107
|
+
`plinth status` then said *"All local writes synced."* Both are now kept, and the daemon
|
|
108
|
+
unlinks only what it can prove it wrote: the acked body and frontmatter hashes, the same
|
|
109
|
+
test reconciliation already uses. Inode identity was the old proof and is wrong in both
|
|
110
|
+
directions - an in-place edit keeps the inode while replacing every byte, and a
|
|
111
|
+
write-tmp-and-rename rotates it while changing nothing.
|
|
112
|
+
|
|
113
|
+
**The daemon's own unlink no longer escapes as a cloud delete.** On both the `files/` and
|
|
114
|
+
the entity lanes, an unlink event the daemon itself caused could be pushed as a real
|
|
115
|
+
`DELETE` of the live row that had just been written at that path - and on the entity lane
|
|
116
|
+
the id is resolved by slug against state the pull has just repointed, so it named the new
|
|
117
|
+
row both ways. Guarded now by a re-stat: an unlink event for a path that exists pushes
|
|
118
|
+
nothing. On the entity lane the guard re-dispatches as an upsert rather than returning,
|
|
119
|
+
because a bare return would silently drop a genuine restore-from-backup instead - no delete,
|
|
120
|
+
but nothing pushed either, and `plinth status` reading clean over an edit the cloud has
|
|
121
|
+
never seen.
|
|
122
|
+
|
|
123
|
+
Both arms log `[FILE_DELETE_SUPPRESSED path=… reason=pull_echo|path_exists]` and
|
|
124
|
+
`TOMBSTONE_SKIP` / `TOMBSTONE_KEEP` with the owning row's id, so the next escape is
|
|
125
|
+
diagnosable from the log rather than from a re-run.
|
|
126
|
+
|
|
127
|
+
**A tombstone over malformed YAML no longer aborts the whole sync.** One hand-edited file
|
|
128
|
+
with unparseable frontmatter, plus a tombstone for its row, used to kill every later entity
|
|
129
|
+
type, the whole `files/` lane and context generation, naming no file. Unparseable is now
|
|
130
|
+
unprovable, which takes the refuse arm.
|
|
131
|
+
|
|
132
|
+
### ⚠️ `/plinth:close` offers to trim a memory file that has grown too long
|
|
133
|
+
|
|
134
|
+
Nothing on any memory surface said "too long". `plinth status` now names each file past its
|
|
135
|
+
bar, and `/plinth:close` gains a step that offers a trim the member confirms - never one
|
|
136
|
+
that just happens. On yes it proposes and writes nothing: each surviving fact shown
|
|
137
|
+
unchanged or beside its rewrite, a wrong fact edited where it stands rather than deleted,
|
|
138
|
+
provenance and dates preserved, and nothing banked in the last 30 days dropped. The write
|
|
139
|
+
waits for confirmation of the proposal.
|
|
140
|
+
|
|
141
|
+
**Two bars, not one.** A rule file (`voice.md`, `workspace-rules.md`) gets **6,000 bytes**;
|
|
142
|
+
a fact store (`artists/<slug>/memory.md`) gets **12,000**. The split is not stylistic: what
|
|
143
|
+
decays as a context grows is instruction-following, and reference facts cost room rather
|
|
144
|
+
than follow-budget. On one live workspace `voice.md` was 9,324 bytes carrying roughly 64
|
|
145
|
+
rules while the two largest artist stores were nearly twice its size and cost far less. A
|
|
146
|
+
single byte bar would have flagged all three for the same reason and been wrong about which
|
|
147
|
+
one mattered. Every file under its bar renders nothing at all.
|
|
148
|
+
|
|
149
|
+
`voice.md` is named and nothing else - the agent is denied `Edit(//**/voice.md)` by the
|
|
150
|
+
settings this same generator ships, so the deny is the control and the prose is the honesty.
|
|
151
|
+
|
|
152
|
+
### `plinth status` stops misreporting twice, and reports where the injection's bytes go
|
|
153
|
+
|
|
154
|
+
- **A `Links` block** over `workspace-memory.md` and `artists/*/memory.md`, naming
|
|
155
|
+
`[[pointers]]` that resolve to nothing. A memory fact is a hook plus a pointer to its
|
|
156
|
+
source, so a dead pointer can mean the hook is the only surviving copy. Two facts on a
|
|
157
|
+
live workspace had pointed at a directory that did not exist for months and nothing
|
|
158
|
+
reported it. Resolution reuses `plinth backlinks`'s own ladder; prose links only, never
|
|
159
|
+
ambiguous ones, and anchors are stripped before resolving. Measured read-only against a
|
|
160
|
+
1,357-file mirror at 19 to 21 ms.
|
|
161
|
+
- **The retired-machinery row stops inviting you to delete a working command.** It ended
|
|
162
|
+
every row with "Review it.", and the only token it lists is `memory-gate`, which is a
|
|
163
|
+
live subcommand the grounding block hands every session. The verdict is now derived from
|
|
164
|
+
the command registry rather than asserted, so a token that still resolves is described as
|
|
165
|
+
still working.
|
|
166
|
+
- **A row for "this was deleted in Plinth and your copy diverged."** That is a permanent
|
|
167
|
+
write failure and it was being swallowed - `status` printed one of two remedies that
|
|
168
|
+
cannot work, *"start the daemon"* (no daemon can push a deleted row) or *"re-sync"* (no
|
|
169
|
+
sync can restore one). It never advises deleting the `revision:` line on its own, because
|
|
170
|
+
that is the advice for a duplicated file and following it re-POSTs a document you
|
|
171
|
+
deliberately deleted.
|
|
172
|
+
- **The `Injection` row now shows the split** - seven categories summing to the block by
|
|
173
|
+
construction, so you can see how much of the budget is Plinth's own prose and how much is
|
|
174
|
+
yours. On one live workspace it reads: Plinth's grounding 14,278 bytes, trim notes 641,
|
|
175
|
+
voice pointer 801, your workspace's rules 188, workspace facts 1,982, your own facts
|
|
176
|
+
1,901.
|
|
177
|
+
|
|
178
|
+
⚠️ **Facts that do not fit are now injected as titles, and titles are preferred over whole
|
|
179
|
+
facts.** A title is the fact's own first sentence with its `[[pointer]]` kept, so an agent
|
|
180
|
+
knows the fact exists and where to read it. The naming pass runs first and packs; whole
|
|
181
|
+
facts compete for what is left. **That trades depth for breadth, and on a full index the
|
|
182
|
+
depth can go to zero.** Measured today with a 0.11.0 and a 0.12.0 binary against the same
|
|
183
|
+
live files: 0.11.0 injected 7 of 23 workspace facts and all 6 personal facts, every one of
|
|
184
|
+
them whole, and said nothing about the other 16. 0.12.0 injects **no workspace fact whole**,
|
|
185
|
+
names 10 of 23 by their opening line, and carries 4 of 6 personal facts whole with 1 more as
|
|
186
|
+
an opening. So 15 facts are named where 13 were, and 4 arrive complete where 13 did. The
|
|
187
|
+
agent is told which is which and told to read the file.
|
|
188
|
+
|
|
189
|
+
The finding worth carrying is that a title pays in proportion to how well the hook was
|
|
190
|
+
written. This Fiction's facts average a 196-byte first sentence, so they barely compress.
|
|
191
|
+
*"Shorten each fact's first sentence"* is what buys room; *"trim the file"* is measurably
|
|
192
|
+
near-useless advice.
|
|
193
|
+
|
|
194
|
+
### The session names you, and a new workspace arrives with its folders
|
|
195
|
+
|
|
196
|
+
The grounding block opens by naming the member: who you are, your role, the workspace and
|
|
197
|
+
your timezone, and that tasks the agent creates are assigned to you unless told otherwise.
|
|
198
|
+
It renders from a principal persisted at login rather than a call at render time, since the
|
|
199
|
+
block runs on every session start and a round trip would tax all of them and fail offline.
|
|
200
|
+
An existing login self-heals on the next sync; no re-login is needed, and a server that does
|
|
201
|
+
not yet carry `role` renders a shorter true line rather than a dangling clause.
|
|
202
|
+
|
|
203
|
+
`plinth sync` now scaffolds `files/` and the six entity folders, and the folder map reads the
|
|
204
|
+
disk rather than asserting. `daily-log.md` is retired. Routing is stated once - the rule that
|
|
205
|
+
live state does not belong in a memory file moved into the grounding block, where the other
|
|
206
|
+
six already were.
|
|
207
|
+
|
|
208
|
+
### Every mirror file carries its row `id:`, including the ones with no frontmatter
|
|
209
|
+
|
|
210
|
+
0.11.0 shipped the `id:` backfill but declined to create a frontmatter block where there was
|
|
211
|
+
none, so a file without one never got its id. It does now, on a positive predicate rather
|
|
212
|
+
than the obvious one - "prepend whenever the splice returns null" corrupts a file that opens
|
|
213
|
+
`---` and never closes it, a bare `---`, and any BOM-prefixed file, each of which carries
|
|
214
|
+
real frontmatter meaning and would have had its hash moved and a local edit invented.
|
|
215
|
+
|
|
216
|
+
A locally created entity also gets its id written into the file before the state entry takes
|
|
217
|
+
the inode, rather than going the whole daemon session without one. And the backfill stops
|
|
218
|
+
re-reading every tracked file on every boot: a completion marker in `state.json` ends it,
|
|
219
|
+
which is only sound because the create path above closes the last route to a tracked file
|
|
220
|
+
with no id.
|
|
221
|
+
|
|
222
|
+
### The generated surface: seven claims that were stale, unsafe or wrong
|
|
223
|
+
|
|
224
|
+
Each of these was being told to every agent in every workspace.
|
|
225
|
+
|
|
226
|
+
1. `/plinth:onboard` said `create_task` and `create_project` "write straight through". They
|
|
227
|
+
now take a payload-bound, single-use confirmation token. ⚠️ The asymmetry moved rather
|
|
228
|
+
than closing: `create_artist_contact` takes no token and is written on the first call,
|
|
229
|
+
and that command writes contacts. And a token is not a user gate - it proves the agent
|
|
230
|
+
called twice with an unchanged payload, so "propose, then stop" is what still makes the
|
|
231
|
+
yes real.
|
|
232
|
+
2. A grounding primitive rendered a shell command that was a parse error when pasted, one
|
|
233
|
+
token away from a truncating `>` in a directory that for a cockpit agent is the mirror
|
|
234
|
+
root. Both placeholders are quoted, and the shell-safety scan widened from one sentence
|
|
235
|
+
to the whole block.
|
|
236
|
+
3. Nothing said how to find one artist's rows. A name grep returned 16 of 46 for one artist
|
|
237
|
+
and read as "no such project". Now routed to `artist_id:` matched against that artist's
|
|
238
|
+
own `id:`.
|
|
239
|
+
4. `/plinth:onboard` mandated `AskUserQuestion` at a step that can have one candidate, which
|
|
240
|
+
the tool refuses. Plain-text fallback stated.
|
|
241
|
+
5. Nothing named the 4 MB upload ceiling, which is how a 4.6 MB site plan sat unsynced the
|
|
242
|
+
day before a show. Rendered from the constant so it cannot go stale.
|
|
243
|
+
6. `plinth --help` told members to mount a hook the same binary removes. `voice-gate`'s
|
|
244
|
+
description advertised `--hook` as a PreToolUse mount while the generator un-writes
|
|
245
|
+
exactly that entry on the next sync. The un-mount claim is narrow on purpose: it matches
|
|
246
|
+
on the `_plinth` marker, so a hand-added hook is never touched.
|
|
247
|
+
7. `/plinth:onboard` never mentioned `voice.md`. It now has one rules-first step that asks
|
|
248
|
+
the member to write it, since the agent is denied editing it.
|
|
249
|
+
|
|
250
|
+
### Plinth's own prose was measured, and cut where the numbers justified it
|
|
251
|
+
|
|
252
|
+
Plinth's own prose was 86% of the grounding block and 92% of the imperatives in it. A plain
|
|
253
|
+
session at the mirror root carried roughly 93 of Plinth's own instructions before the member
|
|
254
|
+
typed anything; past the point where a model stops following reliably, decay is uniform - it
|
|
255
|
+
stops following everything, not just the newest rule.
|
|
256
|
+
|
|
257
|
+
Five cuts, each measured and each with its reason: a prohibition on two tools that do not
|
|
258
|
+
exist on the deployed server, a roadmap parenthetical, a fallback voice paragraph that two
|
|
259
|
+
other primitives already said, an argument for a rule stated two sentences earlier, and the
|
|
260
|
+
enumerations the 61 deny patterns now enforce - an enforced rule costs no follow-budget.
|
|
261
|
+
Imperatives came down from 80 to 75. Everything else was measured, had its original incident
|
|
262
|
+
found in the source, and was kept.
|
|
263
|
+
|
|
264
|
+
⚠️ **The release as a whole still added prose, and the facts allocation paid for it.** That
|
|
265
|
+
cut took 654 bytes off the pinned base block, but the member identity primitive, the seven
|
|
266
|
+
generated-surface corrections and the injection split each added more, so across `0.11.0` to
|
|
267
|
+
`0.12.0` the base block went **11,311 to 12,646 bytes, up 1,335**. The session-start block
|
|
268
|
+
itself is capped at 20,000 and does not grow, so the extra prose comes out of what is left
|
|
269
|
+
for facts - which is the other half of the titles trade above. The `Injection` row in
|
|
270
|
+
`plinth status` is there so this is visible rather than inferred.
|
|
271
|
+
|
|
5
272
|
## 0.11.0 - 2026-08-28
|
|
6
273
|
|
|
7
274
|
Six PRs since `0.10.1` (`v0.10.1..bb0312a`, #123-#128). One data-loss fix worth reading
|
package/README.md
CHANGED
|
@@ -23,13 +23,13 @@ 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. **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.
|
|
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. **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. `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
|
|
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, calendar-write 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. **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
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.
|
|
@@ -56,7 +56,12 @@ every sync and on `plinth refresh-context`, the same way it writes `CLAUDE.md` a
|
|
|
56
56
|
three lines, asks what you want to work on), and `/plinth:close` ends one (verifies each
|
|
57
57
|
write landed, banks the session record into each artist's `_artist.md`, routes follow-ups
|
|
58
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,
|
|
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
|
|
60
65
|
conversation — connectors one at a time, each proven by a real read; a roster proposed
|
|
61
66
|
from what those reads saw; the professional team as contacts; then an inbox review that
|
|
62
67
|
becomes projects and tasks — and nothing is created without a yes. A workspace with no
|
|
@@ -86,11 +91,15 @@ has already injected both indexes in full. It uses what is in the window and spe
|
|
|
86
91
|
one live call on unread notifications. If the hook did not run, it falls back to reading
|
|
87
92
|
them.
|
|
88
93
|
|
|
89
|
-
**`/plinth:close`
|
|
90
|
-
direct write
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
**`/plinth:close` banks facts and never rules, and it proposes before it trims.** `user-memory.md`
|
|
95
|
+
is a direct write - that member's own recall. `workspace-memory.md` reaches every member on
|
|
96
|
+
every machine and is injected into every future session; since 0.11.0 (#124) the close no longer
|
|
97
|
+
proposes lines for it - the routing rule in the session grounding says which index a fact belongs
|
|
98
|
+
in - and since #145 anything rule-shaped is sorted first (mechanical -> a deny or hook; procedural
|
|
99
|
+
-> a skill; workspace-wide -> `workspace-rules.md`, which the member writes and the close never
|
|
100
|
+
does; a preference -> binned) and proposed in the closing summary. A trim is a list of line
|
|
101
|
+
proposals accepted one at a time, with every removed line kept in `artists/<slug>/memory-archive.md`
|
|
102
|
+
beside the store it came from.
|
|
94
103
|
|
|
95
104
|
## Develop
|
|
96
105
|
|
package/dist/build-stamp.json
CHANGED