@plinth-music/cli 0.9.0 → 0.10.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,232 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.10.0 — 2026-08-26
6
+
7
+ Eleven PRs since `0.9.1` (`v0.9.1..9e27349`, #106 and #108–#117; #107 was superseded
8
+ by #109). Three new surfaces — a `voice.md` in every mirror, a `/plinth:onboard`
9
+ command, and a `views/conflicts.md` listing every local edit the daemon set aside — and
10
+ three behaviour changes worth reading first: the CLI refuses a flag whose value the
11
+ parser lost, a workspace admits one daemon at a time, and a symlink inside the mirror
12
+ is refused rather than followed. The server side of `voice.md` (`/api/sync/voice`) is
13
+ already live and a 0.9.1 client talks to the same API. The desktop app pins `0.9.1`
14
+ exact and does not pick any of this up until a desktop release repins it.
15
+
16
+ ### ⚠️ A flag whose value the parser lost is refused, not acted on
17
+
18
+ Three ways a value used to go missing while the command ran anyway: a value beginning
19
+ with `-` (`--proposed "- a line"` arrived as an empty string and the bullet was parsed
20
+ as six boolean flags); a `--no-` token, which the argument parser deletes before the
21
+ command sees it, so the flag swallows the next word — usually the next flag's name; and
22
+ a value simply left off, which arrived as `""`. `plinth session end --summary "--no-x"`
23
+ exited 0 and lost the summary; `plinth memory-gate log --proposed "--no-longer needed"
24
+ --member <uuid>` could file a permanent row against the wrong member.
25
+
26
+ The parser is now pinned (citty `0.2.2` exact), and every string flag on every command — 27 flags across 13 commands —
27
+ refuses a value shaped like a flag and names the form that cannot mis-parse:
28
+ `--flag=value`. Four flags carry text a person wrote and may legitimately begin with
29
+ `-` (`--proposed`, `--final`, `--summary`, `--label`); the rest take slugs, uuids, paths
30
+ and counts and refuse any leading `-`. A flag added later gets the strict rule until
31
+ someone classifies it. One exception, by design: `plinth session end` still ends the
32
+ session at exit 0 and reports the dropped value on stderr, because it is the last step
33
+ of every `/plinth:close` and must not fail on a malformed flag.
34
+
35
+ Also: `plinth memory-gate log --dry-run` validates and prints without writing to the
36
+ append-only log, and a refusal names the value that arrived rather than describing the
37
+ caller's job.
38
+
39
+ Not fixed: `--help` / `-h` in a value slot still prints help and exits 0. That lives
40
+ inside the parser, ahead of any command.
41
+
42
+ ### ⚠️ One workspace, one daemon
43
+
44
+ A second `plinth start` on a workspace used to stomp the first daemon's pidfile and
45
+ both kept running — two writers on one mirror. The daemon now holds an exclusive kernel
46
+ lock on `~/.plinth/daemon-<workspace>.lock` for its whole life: a second
47
+ `plinth start`, or a standalone `plinth sync` while a daemon is up, exits 1 naming the
48
+ holder's pid. The kernel releases the lock when the holder dies, `kill -9` included, so
49
+ there is no stale state and no cleanup step. **Never delete the lock file**: the lock
50
+ lives on the inode, and removing the path is exactly what lets a second daemon in beside
51
+ a live one. The only file a refusal will ever tell you to delete is the legacy pidfile
52
+ left by a pre-0.10.0 daemon.
53
+
54
+ macOS only. Linux ignores the lock flag silently, so rather than appear to work,
55
+ `plinth start` says so on other platforms.
56
+
57
+ From the same change: a malformed `state.json` names the file and the recovery instead
58
+ of throwing a bare `SyntaxError`, and `state.json` / `config.json` writes use
59
+ per-process temporary names so two processes cannot rename each other's half-written
60
+ file into place.
61
+
62
+ ### ⚠️ A symlink inside the mirror is refused, not followed
63
+
64
+ `ln -s ~/Important ~/Plinth/<workspace>/files/docs` used to be enough for the daemon to
65
+ write cloud rows into `~/Important`, delete real files out there on a tombstone, and
66
+ ship a linked private file upstream as a workspace file. Every write, delete and watch
67
+ path now checks each path component below the workspace folder and refuses a link. A
68
+ refused row is skipped, recorded in `plinth status` (reason `symlink`), and the pull
69
+ cursor is held back so the row is delivered the moment the link is removed; the reject
70
+ clears itself on that first clean apply. A linked mirror ROOT
71
+ (`~/Plinth -> /Volumes/Drive/Plinth`) is still allowed — that redirects the whole mirror
72
+ consistently and is a legitimate setup.
73
+
74
+ ### `voice.md`: your voice profile, in the mirror and in every session
75
+
76
+ A new file at the mirror root beside `user-memory.md`. It round-trips to the server's
77
+ voice store — which the web app's 26 Aug data migration moved out of `user-memory.md`,
78
+ so until this release the profile was editable nowhere — and is injected into every
79
+ Claude Code session as a rubric the agent reads whole and cannot write
80
+ (`Edit(//**/voice.md)` is in the generated deny list at every depth, the per-artist
81
+ folders included). If the file's structure is broken the server reports `malformed`,
82
+ and that is shown as a `Voice` row in `plinth status`, in the daemon's output, and as a
83
+ caveat on the injected rubric — never written into the file itself, so the rubric's own
84
+ hash is undisturbed. The first pull writes a scaffold to fill in.
85
+
86
+ ⚠️ An older daemon strips the field this lane adds to `state.json` — the desktop app's
87
+ bundled `0.9.1` included. Until the desktop is repinned, do not alternate a 0.10.0 daemon
88
+ and the desktop's on the same workspace.
89
+
90
+ ### `/plinth:onboard`: a new workspace sets itself up by conversation
91
+
92
+ The third generated command. Connectors one at a time, each proven by a real read; a
93
+ roster proposed from what those reads saw, with a Spotify link per artist; the
94
+ professional team as contacts; then an inbox review that turns running threads into
95
+ projects with their tasks. Every step ends with something you can see and nothing is
96
+ created without a yes. A workspace with no artists finds it without being told the
97
+ name: the session-start grounding gains one line (only while the roster is empty, so a
98
+ workspace with artists pays nothing) and `/plinth:start` offers onboarding instead of
99
+ briefing a roster of nothing.
100
+
101
+ ### `views/conflicts.md`: the local edits the daemon set aside, listed where you work
102
+
103
+ When the cloud wins a conflict the daemon stashes your version in
104
+ `~/.plinth/_conflicts/` and writes the cloud copy over the file. On 26 Aug that happened
105
+ to a 32 KB edit an agent had just read back clean and reported as banked, and nothing
106
+ said so for an hour. The daemon now writes `views/conflicts.md` on every sync — before
107
+ the network call as well as after, so a run that fails still reports — listing each
108
+ stash with a `cp` command that restores it. The zero case is written too, so an absent
109
+ file means an old daemon, not a clean one. `views/` is never pushed. `/plinth:start` and
110
+ `/plinth:close` both read it. The first live sync surfaced a stash from 20 June that no
111
+ surface had shown.
112
+
113
+ ### `plinth status` no longer says "All local writes synced" over files it never pushed
114
+
115
+ With the daemon down nothing is tried, so the failure lane was empty and the line
116
+ printed a positive claim over edits sitting on disk — including a file a live run had
117
+ created hours earlier. `status` now scans the mirror against what was last acknowledged,
118
+ using each lane's own comparison, and lists every local write that has not reached
119
+ Plinth with the remedy that clears it: start the daemon, or — for an entity file created
120
+ while the daemon was down, which no push path reaches — open and save it once with the
121
+ daemon running. What cannot clear (rejected, held for confirmation, over cap) is
122
+ excluded, so the count can reach zero and stay believed. Adds 0.1–0.3 s.
123
+
124
+ Two more from the same pass: `plinth status` no longer crashes with a raw
125
+ `YAMLException` on a tracked file with malformed frontmatter, and an over-cap
126
+ `user-memory.md` / `workspace-rules.md` / `workspace-memory.md` is no longer counted
127
+ forever.
128
+
129
+ ### `/plinth:close` files each fact where it belongs
130
+
131
+ Step 4 sorted by scope only, so a contact, task or document with a typed home of its
132
+ own went to prose, and a fact true of one artist went to the shared gate by default. It
133
+ now sorts by type first — a contact, task, document, artist attribute or file goes to
134
+ that surface and is never offered as a memory line — then by scope, artist first, with
135
+ only the workspace pile gated. Every `plinth` command in the generated text is pinned
136
+ to `--workspace <slug>`, because the CLI resolves the ACTIVE workspace from
137
+ `~/.plinth/config.json` and not from the folder you are in: `memory-gate show` run
138
+ inside one mirror returned another workspace's log. The text also now says truthfully
139
+ that a contact with no artist is refused by the server
140
+ (`Either artist_id or artist_name is required`), where an earlier draft claimed the
141
+ write succeeded silently.
142
+
143
+ ### A live session is no longer forgotten when two terminals disagree on locale
144
+
145
+ `plinth session list` and `plinth status` compare a process's recorded start time to
146
+ decide whether a pid has been recycled, and the probe was locale-formatted: the same
147
+ live pid reads `Wed Aug 26 07:46:04 2026` in one terminal and
148
+ `mer. 26 août 07:46:04 2026` in another, the mismatch read as a recycled pid, and that
149
+ deleted a live session's record. The probe is pinned to the C locale, and a record
150
+ written by an older build is reported as "cannot compare" rather than reaped.
151
+
152
+ ## 0.9.1 — 2026-08-25
153
+
154
+ Seven PRs since `0.9.0` (`v0.9.0..1efa7d7`, #98–#104). Every one of them is the
155
+ same bug seen from a different side: the daemon treated a failed compare-and-swap
156
+ as proof that the cloud content had changed, and then let the cloud win. It had
157
+ not changed. Fixes only, all client-side; there is no server prerequisite and a
158
+ 0.9.0 client talks to the same API. Upgrading is strongly recommended — the
159
+ defects below lose user work silently, and `plinth status` said "All local writes
160
+ synced" the whole time.
161
+
162
+ ### ⚠️ A cloud write that touched only a timestamp no longer costs you your edit
163
+
164
+ This is the one to read. The CAS token is `updated_at`, so **any** write that moves
165
+ that column fails the precondition — including a server-side backfill that changes
166
+ no bytes at all. On 24 Aug a backfill moved `updated_at` on 161 of 307 live rows to
167
+ the same microsecond; every later local edit to one of those rows was stashed out
168
+ of sight into `~/.plinth/_conflicts/` and overwritten with a cloud copy that was
169
+ byte-identical to what the daemon had already acknowledged.
170
+
171
+ A 409 is now a question rather than a verdict: the daemon asks the row what its
172
+ content hash is, compares it with the hash it last acked, and re-pushes when they
173
+ match. Nothing is stashed and nothing is overwritten, because there was no conflict
174
+ to reconcile. A genuine cloud edit still conflicts, still stashes, and still wins —
175
+ that half is unchanged and is pinned by its own tests.
176
+
177
+ Fixed on every path, not just the one that bit: `files/` rows, entity rows, tasks,
178
+ and the three singleton lanes (`user-memory.md`, `workspace-rules.md`,
179
+ `workspace-memory.md`), which shared an engine that had been missed.
180
+
181
+ ### ⚠️ A local status, priority or due-date edit is no longer silently discarded
182
+
183
+ Found while fixing the above. On an entity conflict the retry dropped the
184
+ structured fields the first attempt had carried, as "cloud's win" — so a metadata
185
+ bump on a row you had just re-prioritised threw the re-prioritisation away with no
186
+ stash and no record. A bump is not a conflict, so it no longer gets a conflict's
187
+ resolution.
188
+
189
+ ### ⚠️ `plinth login` no longer wipes your sync cursors, and `--reset-sync` is new
190
+
191
+ Logging in again to the same workspace is a token rotation, not a re-registration,
192
+ and it now keeps your cursors — the next sync resumes instead of re-pulling the
193
+ world. Cursors are still cleared when the token belongs to a different workspace or
194
+ user, and login now says which happened rather than leaving you to guess.
195
+
196
+ That removed the only way to force a full re-pull, so `--reset-sync` restores it as
197
+ something you ask for. Use it after rebuilding a mirror from scratch; without it, a
198
+ mirror rebuilt from an empty ledger would never refill, silently, behind a green
199
+ sync.
200
+
201
+ ### Two files saved at the same time no longer lose one file's bookkeeping
202
+
203
+ Every write of `state.json` used to land on a snapshot taken before the network
204
+ call that preceded it, so two saves a moment apart — or a scheduled pull arriving
205
+ while a push was in flight — could leave the file missing one of the two
206
+ acknowledgements. The losing write's record simply vanished, and the next edit to
207
+ that file hit the defect above through a door the fix could not close.
208
+
209
+ State is now merged at write time rather than overwritten, an acknowledgement can
210
+ never be committed over a newer one, and a cursor is never advanced ahead of the
211
+ rows it describes — so an interrupted pull leaves cursors, state and disk agreeing
212
+ rather than stranding rows that would never be delivered again. HTTP concurrency is
213
+ untouched; the cost is about 2 ms per state write.
214
+
215
+ ### `plinth status` shows conflict stashes
216
+
217
+ The Conflicts block lists how many local copies are set aside in
218
+ `~/.plinth/_conflicts/<workspace>/`, names the newest, and says what to do with
219
+ them. They had been accumulating unseen since May.
220
+
221
+ ### Proven against production
222
+
223
+ The changes above were reproduced end to end against a live workspace before this
224
+ release rather than only in tests: a metadata-only bump followed by a local edit
225
+ survives on both the file and entity paths, two overlapping pushes both keep their
226
+ acknowledgement, and — the part that makes the rest mean anything — a genuine cloud
227
+ content change still conflicts and still stashes under otherwise identical
228
+ conditions. The pull-ordering half and the three singleton lanes remain
229
+ test-proven only; both are named in the repo's `CLAUDE.md`.
230
+
5
231
  ## 0.9.0 — 2026-08-24
6
232
 
7
233
  Two PRs since `0.8.0` (`v0.8.0..4842d40`). An agent is now told to read a draft
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Plinth capstone sync daemon and CLI. Mirrors your Plinth workspace to `~/Plinth/<workspace>/` and keeps it bidirectionally synced. AI-native: pair with Claude Code or any LLM agent for context-aware artist management.
4
4
 
5
- **Status: early access.** Two-way sync runs as a daemon across documents, projects, tasks, artists, meetings, threads and a user-writable `files/` subtree. See `V1_SPEC` §5 M3 in the plinth monorepo for the full milestone plan, and [`CHANGELOG.md`](CHANGELOG.md) for what landed when.
5
+ **Status: early access.** Two-way sync runs as a daemon across documents, projects, tasks, artists, meetings, threads, a user-writable `files/` subtree, the three memory files and `voice.md` (your voice profile, round-tripped to the server and injected read-only into every session). See `V1_SPEC` §5 M3 in the plinth monorepo for the full milestone plan, and [`CHANGELOG.md`](CHANGELOG.md) for what landed when.
6
6
 
7
7
  ## Install
8
8
 
@@ -22,20 +22,22 @@ plinth start # run the daemon: continuous pull + watch + pu
22
22
 
23
23
  ## Commands
24
24
 
25
- - `plinth login --workspace <slug>` — 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).
25
+ - `plinth login --workspace <slug> [--reset-sync]` — issue a personal access token in the browser, paste it back, store it in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager). Logging in again to the same workspace is a token rotation and **keeps your sync cursors**, so the next sync picks up where the last one left off; it clears them only when the token belongs to a different workspace or user. `--reset-sync` discards them deliberately, making the next sync a full re-pull — use it after rebuilding a mirror from scratch, which is otherwise unreachable now that rotation preserves them.
26
26
  - `plinth sync [--workspace <slug>]` — one-shot pull into `~/Plinth/<workspace>/`. Also generates the workspace-root `CLAUDE.md` (a thin TOC + entity model + skill pointers), an empty `CLAUDE.local.md` stub on first sync, and the two session-boundary commands (below), and silently refreshes `CLAUDE.md` on a schema-version change. Defaults to the active workspace if `--workspace` is omitted.
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`.
28
- - `plinth status` — local sync state: daemon liveness, last sync and per-type counts, and any destructive batches being held. Warns when a source install's `dist/` is behind its checkout.
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.
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
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
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
- - `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.
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
- - `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. Machine-local: a session on another machine is not counted.
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
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.**
38
38
 
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
+
39
41
  ⚠️ `--no-color` is honoured on `plinth session *` and `plinth memory-gate *`. It is **still inert on every other command** (`confirm`, `status`, `declare`, `review-tier`, `backlinks`): citty folds `--no-color` into `{ color: false }`, so the `args["no-color"] === true` test those call sites use is always false. `src/lib/output.ts` exports `noColorFromArgs` for the sweep; `NO_COLOR` in the environment is unaffected and works throughout.
40
42
  - `plinth version` — print the installed version.
41
43
 
@@ -45,15 +47,20 @@ Planned (not yet implemented):
45
47
  - `plinth update` — detect install method (brew vs npm-global) and upgrade in place.
46
48
  - `plinth logout --workspace <slug>` — revoke the local PAT reference, retain mirror files.
47
49
 
48
- ## Generated into every mirror: `/plinth:start` and `/plinth:close`
50
+ ## Generated into every mirror: `/plinth:start`, `/plinth:close` and `/plinth:onboard`
49
51
 
50
- Plinth writes two Claude Code slash commands into `<mirror>/.claude/commands/plinth/` on
52
+ Plinth writes three Claude Code slash commands into `<mirror>/.claude/commands/plinth/` on
51
53
  every sync and on `plinth refresh-context`, the same way it writes `CLAUDE.md` and
52
54
  `.claude/settings.json`. They are the session boundary: `/plinth:start` opens a session
53
55
  (reads `workspace-memory.md` **and** `user-memory.md`, pulls what is unread, briefs in
54
56
  three lines, asks what you want to work on), and `/plinth:close` ends one (verifies each
55
57
  write landed, routes follow-ups to a durable surface, banks memory, and runs
56
- `plinth session end` as its last step).
58
+ `plinth session end` as its last step). `/plinth:onboard` sets a new workspace up by
59
+ conversation — connectors one at a time, each proven by a real read; a roster proposed
60
+ from what those reads saw; the professional team as contacts; then an inbox review that
61
+ becomes projects and tasks — and nothing is created without a yes. A workspace with no
62
+ artists is offered it by `/plinth:start`, and by one grounding line that ships only while
63
+ the roster is empty.
57
64
 
58
65
  **Why generated and not synced.** The memory layer is bidirectional and nothing was
59
66
  writing it — on 19 Aug 2026 the live `this-fiction` mirror held 9 artist folders, 9
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "fb065c13301d65750f1a98761148486678da5b6d",
2
+ "sha": "a1cc216dbf0d4336ad42f856026fb73d99a32e70",
3
3
  "dirty": false,
4
- "built_at": "2026-08-24T19:03:09.765Z"
4
+ "built_at": "2026-08-26T16:05:40.993Z"
5
5
  }