@plinth-music/cli 0.10.1 → 0.11.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 +91 -0
- package/README.md +7 -6
- package/dist/build-stamp.json +2 -2
- package/dist/cli.js +1016 -153
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,97 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
|
|
4
4
|
|
|
5
|
+
## 0.11.0 - 2026-08-28
|
|
6
|
+
|
|
7
|
+
Six PRs since `0.10.1` (`v0.10.1..bb0312a`, #123-#128). One data-loss fix worth reading
|
|
8
|
+
first, a session-start injection that is now less than half the size, and a `/plinth:close`
|
|
9
|
+
that stops asking about shared memory. There is no server prerequisite - every change is
|
|
10
|
+
client-side, and a 0.10.1 client talks to the same API. The desktop app pins `0.10.1`
|
|
11
|
+
exact and picks none of this up until a desktop release repins it.
|
|
12
|
+
|
|
13
|
+
### ⚠️ A tombstone never deletes a file the daemon did not write
|
|
14
|
+
|
|
15
|
+
Renaming a tracked file in `files/` and then writing a new file at the old path destroyed
|
|
16
|
+
the new file. No stash, no conflict entry, `plinth status` clean throughout - and it lost
|
|
17
|
+
the cloud row as well, so no copy survived on either side. Reproduced on a live workspace
|
|
18
|
+
in under fifteen seconds.
|
|
19
|
+
|
|
20
|
+
Two faults, both fixed. The tombstone branch unlinked by path, with neither guard the
|
|
21
|
+
entity lane has; and the pull suppressor held one slot per path, so a pull that carries a
|
|
22
|
+
delete and a write for the same path silently dropped the delete and let the unlink escape
|
|
23
|
+
as a real delete of the live row.
|
|
24
|
+
|
|
25
|
+
The rule is now that the daemon unlinks only what it can prove it wrote: a state entry at
|
|
26
|
+
that path whose id matches the deleted row and whose content hash matches the bytes on
|
|
27
|
+
disk. A path owned by a different live row is skipped. Anything unprovable gets no disk
|
|
28
|
+
write at all - the file stays, untracked but present, and the next reconcile pushes it up
|
|
29
|
+
as a new row. Normal operation is unchanged and silent.
|
|
30
|
+
|
|
31
|
+
### ⚠️ Session start is less than half the size, and `voice.md` is read on demand
|
|
32
|
+
|
|
33
|
+
Measured on one live workspace with the same files on both ends: **43,578 bytes before,
|
|
34
|
+
19,911 after**. That block is injected before your first word, in every session, on every
|
|
35
|
+
workspace, and nothing bounded it.
|
|
36
|
+
|
|
37
|
+
- **`voice.md` is now a pointer, not a payload.** It was 37% of the block and rode
|
|
38
|
+
verbatim - every line of it, including a retired gate's measurement history. The pointer
|
|
39
|
+
carries every instruction the wrapper did, and warns about a malformed file before the
|
|
40
|
+
agent opens it rather than after.
|
|
41
|
+
- **A total budget of 20,000 bytes.** Only the two memory indexes give; a generated safety
|
|
42
|
+
primitive is never cut. If the generated block alone exceeded the budget the indexes
|
|
43
|
+
collapse to a pointer and the block goes over rather than losing a rule.
|
|
44
|
+
- **The cut keeps the newest facts, by the date each line carries**, and renders them in
|
|
45
|
+
the file's own order. Position does not track age in `workspace-memory.md` - superseding
|
|
46
|
+
in place means an updated line keeps its original slot - so a positional head would have
|
|
47
|
+
dropped thirteen recent facts to keep three older ones.
|
|
48
|
+
|
|
49
|
+
`plinth status` gains an Injection row: the byte count, the budget, how many facts fit, and
|
|
50
|
+
which file to shorten.
|
|
51
|
+
|
|
52
|
+
### ⚠️ `/plinth:close` stops asking you about shared memory
|
|
53
|
+
|
|
54
|
+
The close asks nothing about memory now. An artist's fact goes to `artists/<slug>/memory.md`,
|
|
55
|
+
a member's to `user-memory.md`, both written directly; a fact true of neither is named in the
|
|
56
|
+
closing summary and written nowhere, so nothing is silently dropped. The confirm gate that
|
|
57
|
+
used to live in the close now lives in the session-start grounding block, which is where the
|
|
58
|
+
mid-session "remember this for everyone" path actually runs - it had no gate at all before
|
|
59
|
+
this. `plinth memory-gate` is still live and still injected for that path.
|
|
60
|
+
|
|
61
|
+
### `plinth status` warns when your own block names machinery a command retired
|
|
62
|
+
|
|
63
|
+
The customisable block in a generated command is preserved verbatim, forever, and it renders
|
|
64
|
+
after the generated body - so a hand-written step that still runs a retired ritual wins. The
|
|
65
|
+
generator may never edit that block, so `status` says so instead: it names the file, the
|
|
66
|
+
token and the release, and the remedy is always "review it". A token qualifies only if Plinth
|
|
67
|
+
authored it and then withdrew it from that command.
|
|
68
|
+
|
|
69
|
+
### Every task, project and document mirror file carries its row id
|
|
70
|
+
|
|
71
|
+
Mirror files now carry `id:` in their frontmatter, on all six entity types. An agent holding
|
|
72
|
+
a mirror file had no way to reach the MCP write for that row except `search_entities`, which
|
|
73
|
+
misses ordinary phrasings - `"Zubin vacation days"` returned nothing against a live task
|
|
74
|
+
called "Chase Zubin's vacation-days number", and an agent that trusts the empty result
|
|
75
|
+
creates a duplicate. A one-time backfill splices `id:` into files already on disk, since a
|
|
76
|
+
cursor feed never re-sends an unchanged row. The key is server-authoritative and stripped
|
|
77
|
+
before the wire, so adding it moves no hash and pushes nothing.
|
|
78
|
+
|
|
79
|
+
### An entity file created or renamed while the daemon was down reaches the cloud
|
|
80
|
+
|
|
81
|
+
A task, project or document file written into the mirror while the daemon was off never
|
|
82
|
+
synced - no push path could see it - while `plinth status` said everything was synced. An
|
|
83
|
+
offline rename had the same hole from the other side: the old slug stayed in the cloud with
|
|
84
|
+
nothing saying so. A boot discovery walk now finds both.
|
|
85
|
+
|
|
86
|
+
Creates are capped at ten per boot (`PLINTH_DISCOVERY_WRITE_CAP`) and decided as a batch, so
|
|
87
|
+
a mirror that lost its state file does not fan a hundred rows into the cloud unattended. An
|
|
88
|
+
untracked artist folder is never created by the daemon whatever the cap - the MCP gates that
|
|
89
|
+
write behind a confirmation, and an unattended path must not be weaker than the agent path.
|
|
90
|
+
Your own save with the daemon running still creates one.
|
|
91
|
+
|
|
92
|
+
`plinth status` stops offering a remedy that no longer works: the untracked-entity lane now
|
|
93
|
+
says "start the daemon", and the three cases the boot walk still will not push - over the
|
|
94
|
+
cap, a held artist, a file that came out of a pull - each get their own sentence.
|
|
95
|
+
|
|
5
96
|
## 0.10.1 - 2026-08-26
|
|
6
97
|
|
|
7
98
|
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.
|
|
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. Two further rows: **Commands**, which 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; and **Injection**, which reports the session-start block's size against its 20,000-byte budget, how many facts of `workspace-memory.md` fit, and which file to shorten.
|
|
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.
|
|
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
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).
|
|
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
|
|
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,9 @@ 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,
|
|
58
|
-
|
|
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, and runs `plinth session end` as its last step). `/plinth:onboard` sets a new workspace up by
|
|
59
60
|
conversation — connectors one at a time, each proven by a real read; a roster proposed
|
|
60
61
|
from what those reads saw; the professional team as contacts; then an inbox review that
|
|
61
62
|
becomes projects and tasks — and nothing is created without a yes. A workspace with no
|
package/dist/build-stamp.json
CHANGED