@plinth-music/cli 0.16.0 → 0.18.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,73 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.18.0 - 2026-09-04
6
+
7
+ Three code PRs since `0.17.0` (`v0.17.0..3ece28b`, #170, #171, #173; #169 and #172 docs).
8
+ A minor because every member session is now told something different, and the generated
9
+ deny list refuses 32 tool names it did not refuse before.
10
+
11
+ ⚠️ **Your sessions are no longer told that writing a calendar date is impossible.** Plinth's
12
+ MCP server gained `preview_` / `create_` / `update_calendar_entry` on 3 September, and 0.17.0
13
+ shipped injected grounding and command files that denied any such route existed — a false
14
+ denial an agent acts on by giving up and leaving the date wrong. The grounding block and both
15
+ `/plinth:start` and `/plinth:close` now name the route and the order it runs in: propose with
16
+ `preview_calendar_entry`, put the proposal to a person, and only once they agree pass the
17
+ token it minted to the writer. Agreement comes before the token in every one of those
18
+ sentences, deliberately — nothing outside the desktop cockpit asks a human anything, so on a
19
+ plain install that instruction is the whole gate.
20
+
21
+ ⚠️ **Calendar deletion and cancellation are refused again, on every MCP server: the deny list
22
+ goes 91 to 123 patterns.** The rename that opened the sanctioned write path also retired a
23
+ guarantee, because the old patterns were anchored on the noun `event` — so
24
+ `delete_calendar_entry` on anyone's server was refused by nothing. Thirty-two patterns
25
+ anchored on `calendar` and `entry` restore it. Two consequences worth knowing: this refuses a
26
+ Plinth `delete_calendar_entry` too (none exists and none is planned, so if one ever ships it
27
+ arrives denied rather than surprising somebody), and `delete` matches inside `deleted`, so a
28
+ `get_deleted_calendar_entry` would be refused as well.
29
+
30
+ - The injected grounding block grows 215 bytes (12,699 to 12,914). The injection budget is
31
+ fixed, so on a workspace already at it those bytes come out of the memory facts that ride
32
+ into the session head. The facts themselves are untouched on disk and still readable.
33
+
34
+ The desktop app pins `@plinth-music/cli` exact (0.17.0 as of v1.10.0) and picks none of this
35
+ up until its own release repins.
36
+
37
+ ## 0.17.0 - 2026-09-03
38
+
39
+ One code PR since `0.16.0` (`v0.16.0..a6209c8`, #165; #167 is tests and comments,
40
+ #163 / #164 / #166 docs). A minor because sync gains a new refusal and a file on your
41
+ disk can now move on its own.
42
+
43
+ - ⚠️ **Renaming a task or project in Plinth now moves the mirror file instead of
44
+ writing a second one.** The server regenerates a row's slug when its name changes.
45
+ Until now the mirror wrote the new name to a new file and left the old one orphaned,
46
+ and an edit to that orphan created a duplicate row in Plinth. The file is moved now,
47
+ and a local copy that had diverged from the cloud is stashed to
48
+ `~/.plinth/_conflicts/<workspace>/` before the move rather than pushed.
49
+ - ⚠️ **A rename that would land on a file already sitting there is refused, not
50
+ overwritten.** Nothing is moved, `plinth status` names it, and Plinth re-offers the
51
+ rename on every poll until the name is free. Where the file in the way belongs to
52
+ another record the message says so and tells you **not** to delete it — deleting it
53
+ would delete that record in Plinth. Where it is a file of yours, rename or move it
54
+ and the rename lands on the next poll.
55
+ - **An unreadable file at a renamed row's old path no longer stalls the whole sync.**
56
+ It used to abort the pull, and in the daemon back that off to a five-minute retry
57
+ indefinitely, leaving the mirror silently stale behind a notice naming no file. Only
58
+ that row is held now, and `plinth status` names the file and says to check its
59
+ permissions.
60
+
61
+ ⚠️ **One boundary changed underneath this release, with no CLI change in it.** Plinth's
62
+ MCP server renamed its calendar tools (`create_calendar_event` and its siblings became
63
+ `create_calendar_entry`), which is what lets the cockpit write calendar entries. The
64
+ side effect is that the generated deny list no longer covers calendar *deletion* on
65
+ Plinth's own server, which it had been doing by accident of the name. No such tool
66
+ exists today, so the hole is latent rather than open. #167 measured this and pinned it
67
+ with tests; no deny pattern was added, removed or edited, and the list stays at 91.
68
+
69
+ The desktop app pins `@plinth-music/cli` exact (0.16.0 as of v1.9.4) and picks none of
70
+ this up until its own release repins.
71
+
5
72
  ## 0.16.0 - 2026-09-02
6
73
 
7
74
  Three code PRs since `0.15.0` (`v0.15.0..5c60627`, #157, #159, #161; #158 and #160 are
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. 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.
26
+ - `plinth sync [--workspace <slug>]` — one-shot pull into `~/Plinth/<workspace>/`. Also generates the workspace-root `CLAUDE.md` (a thin TOC + entity model + skill pointers), an empty `CLAUDE.local.md` stub on first sync, and the two session-boundary commands (below), and silently refreshes `CLAUDE.md` on a schema-version change. Defaults to the active workspace if `--workspace` is omitted. **Every mirrored task, project, document, artist, meeting and thread file carries its row `id:` in frontmatter**, so an agent holding a file can address the MCP write for that row directly instead of going through `search_entities` and risking a duplicate on an empty result; files already on disk when you upgrade get it from a one-time backfill, since a cursor feed never re-sends an unchanged row. The key is server-authoritative and stripped before the wire, so it moves no hash and pushes nothing. Since 0.17.0 a row **renamed in Plinth moves its mirror file** rather than leaving a second copy at the old name; a file already sitting at the new name is never overwritten, and `plinth status` names the clash until it clears. It also **scaffolds `files/` and the six entity folders**, so a new workspace arrives with somewhere to put things instead of an empty directory, and the folder map in the generated `CLAUDE.md` reads the disk rather than asserting what ought to be there.
27
27
  - `plinth start` — run the long-running sync daemon: pull the latest, then watch the mirror for local edits and push them. It also projects each mapped artist's Google calendar into `views/calendar/` in the mirror — a file per artist plus an `upcoming.md` — on its own 15-minute timer (`PLINTH_CALENDAR_INTERVAL_MS`), with one run at boot. That is deliberately not the 15-second pull cadence: one tick makes a Google call per mapped calendar, which would breach the server route's rate limit inside a minute. A refresh that fails keeps the last known bodies and rewrites only the header, so a stale view says it is stale rather than reading as an empty diary. Logs to `~/.plinth/daemon.log`. **One daemon per workspace:** it holds an exclusive kernel lock on `~/.plinth/daemon-<workspace>.lock` for its life (macOS only; other platforms are refused and told why), so a second `plinth start`, or a standalone `plinth sync` while it runs, exits 1 naming the holder's pid. **Never delete the lock file** — the lock lives on the inode, and removing the path is what lets a second daemon in beside a live one. It also writes `views/conflicts.md` on every sync: every local edit the cloud won and the daemon set aside in `~/.plinth/_conflicts/`, each with a `cp` command that restores it; the zero case is written too, so an absent file means an old daemon, not a clean one.
28
28
  - `plinth status` — local sync state: daemon liveness, last sync and per-type counts, any destructive batches being held, writes awaiting retry or permanently rejected, **every local write that has not reached Plinth** (scanned from the mirror against the last acknowledged state, with the remedy that clears each — start the daemon, or open and save a file that was created while it was down), the state of `voice.md`, and any conflict stashes in `~/.plinth/_conflicts/<workspace>/` — local copies that were set aside when the cloud version won. Warns when a source install's `dist/` is behind its checkout. Four further rows. **Commands** warns when the customisable block in a generated command still names machinery that command retired — the block is preserved verbatim forever and renders after the generated body, so a hand-written step that runs a retired ritual wins, and the generator may never edit it. **Its verdict is derived from the command registry, not asserted**: a token that still resolves to a live subcommand is described as still working and left to you, because the one token it lists is `memory-gate`, which the grounding block hands every session. **Links** names `[[pointers]]` in `workspace-memory.md` and `artists/*/memory.md` that resolve to nothing — a memory fact is a hook plus a pointer to its source, so a dead pointer can mean the hook is the only surviving copy of the fact. It reuses `plinth backlinks`'s resolution ladder, reports prose links only (never one inside a code fence), never reports an ambiguous target, and strips anchors before resolving. **Injection** reports the session-start block's size against its 20,000-byte budget and **where those bytes go** — seven categories that sum to the block by construction, marking which are yours to change — plus how many facts of each index ride whole, how many ride as their opening line only, and which file to shorten. **Too long** names each memory file past the size it is useful at: a rule file (`voice.md`, `workspace-rules.md`) at **6,000 bytes**, a fact store (`artists/<slug>/memory.md`) at **12,000**. The two bars are not one bar: what a long rule file spends is the model's instruction-following budget, which reference facts do not touch. A file under its bar renders nothing at all. Since 0.16.0 it also reports master-context walls (one block past 2,000 characters), played entries still under an "upcoming" label, and a "Current state" heading with no as-of or a stale one.
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, **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).
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 and mail-deletion verbs on any mounted MCP**, not seven tool names — each verb anchored at a word boundary in four positions (start, after `_`, after `-`, and at a camel hump), which is what catches `outlook_send_message` and `send-mail` while leaving `resend_verification` and `list_sent` alone. Spam is the one family that is *not* anchored, and deliberately: no English word contains `spam` as a substring, so the unanchored form has nothing to over-reach into and catches `reportSpam` and `move_to_spam` as well as `mark_message_spam`. Deletion joined on 30 Aug 2026, taking the list from 61 patterns to 91: `trash` bare (nothing reads by *starting* with it, and `empty_trash` falls out for free), `delete` against the `message` / `thread` / `mail` nouns (bare, it would refuse every `mcp__plinth__delete_*` tool), and spam-marking, which Gmail purges after thirty days and is therefore deletion on a fuse. **Plinth's own calendar-write route is deliberately outside the list** (4 Sept 2026). The server renamed those tools to `preview_` / `create_` / `update_calendar_entry` on 3 September, and because the deny dialect has no negation an exception cannot be written as a rule — the carve-out *is* the name. The same rename retired a guarantee, since the calendar families were anchored on the noun `event` and `delete_calendar_entry` on anyone's server was then refused by nothing, so the list went from 91 patterns to **123**: `delete` and `cancel` anchored on `calendar` and `entry`, which refuses calendar deletion on every server again, Plinth's own included. **Drafting, filing, labelling and reading are untouched** — `outlook_send_draft` is denied while `outlook_create_draft` is not, because the discriminator is the verb, never the noun, and archiving a message is a move rather than a deletion. **Two costs are disclosed rather than discovered:** a few reads are caught by name (`getForwardingSettings`, a `list_trash`), and the bare `trash` family also refuses Google Drive's `trash_file` and the only route the live Gmail mount has for discarding a staged draft — that connector exposes no `delete_draft`, so a wrong draft is repaired with `update_draft` instead. Ten holes are recorded as assertions in `tests/context-session-hook.test.ts` rather than as prose — deletion-by-move and Gmail's own label route among them, the latter being the widest, since Gmail expresses TRASH and SPAM as labels and labelling stays allowed. The list is an enumeration and the tests say so out loud. The command still ships and still works, for a hand-wired mount: `--hook` runs it as a PreToolUse hook that blocks non-compliant Gmail drafts; direct mode takes `--file <path>` or stdin and prints the verdict (`--json` for the raw response).
33
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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "4d6d17c915cb0b2fd774da4ae8876a450a658eb0",
2
+ "sha": "d641e2f41a54942541eeddfa55776d42e226e011",
3
3
  "dirty": false,
4
- "built_at": "2026-09-02T18:56:56.610Z"
4
+ "built_at": "2026-09-04T17:57:36.767Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -7584,7 +7584,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
7584
7584
  // package.json
7585
7585
  var package_default = {
7586
7586
  name: "@plinth-music/cli",
7587
- version: "0.16.0",
7587
+ version: "0.18.0",
7588
7588
  description: "Plinth capstone sync daemon and CLI",
7589
7589
  type: "module",
7590
7590
  private: false,
@@ -23654,6 +23654,46 @@ function writeMirrorFile(workspaceSlug, entityType, entitySlug, content) {
23654
23654
  throw err;
23655
23655
  }
23656
23656
  }
23657
+
23658
+ class MirrorDestinationOccupiedError extends MirrorPathError {
23659
+ fromSlug;
23660
+ toSlug;
23661
+ constructor(fromSlug, toSlug) {
23662
+ super(`Cannot move ${JSON.stringify(fromSlug)} to ${JSON.stringify(toSlug)}: ` + `a file already exists there`);
23663
+ this.name = "MirrorDestinationOccupiedError";
23664
+ this.fromSlug = fromSlug;
23665
+ this.toSlug = toSlug;
23666
+ }
23667
+ }
23668
+ function moveEntityFile(workspaceSlug, entityType, fromSlug, toSlug, beforeMove) {
23669
+ const strategy = getStrategy(entityType);
23670
+ const rel = strategy ? strategy.mirrorRelativePath(fromSlug) : flatMirrorRelativePath(entityType)(fromSlug);
23671
+ if (rel.split("/").length !== 2) {
23672
+ throw new MirrorPathError(`Entity type ${JSON.stringify(entityType)} keeps its rows in a folder; ` + `moving one is not supported`);
23673
+ }
23674
+ const from = entityFilePath(workspaceSlug, entityType, fromSlug);
23675
+ const to = entityFilePath(workspaceSlug, entityType, toSlug);
23676
+ if (from === to)
23677
+ return "absent";
23678
+ try {
23679
+ lstatSync(to);
23680
+ throw new MirrorDestinationOccupiedError(fromSlug, toSlug);
23681
+ } catch (err) {
23682
+ if (err instanceof MirrorDestinationOccupiedError)
23683
+ throw err;
23684
+ if (err?.code !== "ENOENT")
23685
+ throw err;
23686
+ }
23687
+ beforeMove?.();
23688
+ try {
23689
+ renameSync(from, to);
23690
+ return "moved";
23691
+ } catch (err) {
23692
+ if (err?.code === "ENOENT")
23693
+ return "absent";
23694
+ throw err;
23695
+ }
23696
+ }
23657
23697
  var ARTIST_MEMORY_ANCHOR = "memory.md";
23658
23698
  function artistMemoryFilePath(workspaceSlug, entitySlug) {
23659
23699
  assertSlug("entity slug", entitySlug);
@@ -27032,7 +27072,7 @@ function composeGroundingBlock(today, workspaceRules, workspaceMemory, userMemor
27032
27072
  add("grounding", `Plinth session grounding:`, ``, `Today's date: ${today}. You have no inherent sense of the current date and ` + `your training has a knowledge cutoff, so treat this injected date as ` + `authoritative for anything time-relative: deadlines, what counts as ` + `"recent" or "upcoming", and the age of any data or of your own knowledge.`);
27033
27073
  if (identity !== null)
27034
27074
  add("identity", ``, identity);
27035
- add("grounding", ``, `Resolve Plinth entities before answering about them. For any answer grounded ` + `in a specific artist, project, task, or other workspace entity, first look ` + `the name up in THIS workspace with the Plinth MCP tools ` + `(\`mcp__plinth__search_entities\`, \`mcp__plinth__list_artists\`, or ` + `\`mcp__plinth__list_projects\`) and ground your answer in what they return. ` + `Do not answer from name recognition alone: a globally famous name may not ` + `exist in this workspace, and an entity here may differ from the public ` + `figure of the same name. If the lookup returns nothing, say so instead of ` + `inventing details.`, ``, `Plan before you write. The first time you call any mutating Plinth MCP tool ` + `in a session (create_*, update_*, delete_*, mark_*: anything that changes ` + `workspace data), do not commit silently: state exactly what you would write, ` + `naming the tool, the entity, and the fields and values, and proceed only after ` + `the user confirms or amends.`, ``, `Write through the canonical surface. In this cockpit the file tree is your edit ` + `surface, so reach for it before the most direct tool. Change prose (document ` + `bodies, free-form notes, anything textual) by editing the mirror markdown ` + `file; the daemon syncs it up. An artist's master context is the clearest case: ` + `it lives in the body of that artist's \`artists/<slug>/_artist.md\` mirror file, ` + `the file the web context pane renders, and one of only two files under ` + `\`artists/<slug>/\` the daemon syncs both ways (\`memory.md\` is the other), ` + `so change it by editing that body, never by creating ` + `a separate document or context entity (e.g. \`create_document\`) the pane won't ` + `render. One carve-out inside that file: a block labelled as rendered from ` + `Plinth is regenerated on every sync and your edits to it are discarded, so ` + `write those through the tool the block names. Change structured fields (task ` + `status, priority, assignees, due dates, a project's category) through the ` + `Plinth MCP (\`mcp__plinth__*\`) or the task file's frontmatter, which is ` + `row-canonical but still written through the validated API. Make a bulk or ` + `destructive change (a mass delete, a gutting overwrite) by editing the file ` + `path, so the daemon's confirm-gate can quarantine it for \`plinth confirm\`. ` + `Never write the database directly, which rules out raw SQL and ` + `\`execute_sql\`, \`apply_migration\` and \`psql\`: those bypass the daemon, the ` + `mirror, the confirm-gate, canonicality, and API validation.`, ``, `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Reading ` + `dates is yours, and this mirror already holds them: the daemon projects every ` + `mapped calendar into \`views/calendar/upcoming.md\` and ` + `\`views/calendar/<slug>.md\`, so read those before reaching for a tool ` + `(\`list_artist_calendars\`). They are derived and overwritten, ` + `so they are current only as of the \`generated_at\` in their header - quote that ` + `stamp, never edit them, and check the status line, because a calendar Plinth ` + `could not reach is unknown rather than empty. When a tool is the only way, ` + `resolve the calendar through \`mcp__plinth__list_artist_calendars\` and use its ` + `\`calendar_id\` field, not the mapping row's \`id\`, because the default ` + `\`primary\` is almost never theirs.`, ``, `Writing a date is not yours, by any route: not a calendar tool, and not a ` + `shell command line either, the Google Workspace one included. The calendar ` + `tools are refused for you where Plinth has configured this workspace. ` + `Nothing refuses a shell, anywhere, and where Plinth has not configured ` + `this workspace nothing is refused at all: you are the only thing holding ` + `those. The way ` + `through is a person, not a workaround, so when a date needs adding or ` + `correcting say plainly what the change is, say that it goes in through ` + `Plinth's calendar view, and say that a person makes it. Say why, too: a date ` + `left looking handled is worse than one openly outstanding, and a calendar ` + `write goes out to everyone invited the moment it lands and cannot be recalled.`, ``, `Draft email. Never send it. Writing the draft is yours: use the draft tools ` + `(\`create_draft\`, \`update_draft\`), then say where the draft is and what it ` + `says, so the person can read it and send it in their own name. Sending, ` + `replying, forwarding and deleting are not yours, by any route: not a mail tool, and ` + `not a shell command line either. The mail tools are refused for you where ` + `Plinth has configured this workspace. Nothing refuses a shell, anywhere, ` + `and where Plinth has not configured this workspace nothing is refused at ` + `all: you are the only thing holding those. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and one deleted out of a mailbox is not yours to lose; the person whose name ` + `is on it is the one who decides.`);
27075
+ add("grounding", ``, `Resolve Plinth entities before answering about them. For any answer grounded ` + `in a specific artist, project, task, or other workspace entity, first look ` + `the name up in THIS workspace with the Plinth MCP tools ` + `(\`mcp__plinth__search_entities\`, \`mcp__plinth__list_artists\`, or ` + `\`mcp__plinth__list_projects\`) and ground your answer in what they return. ` + `Do not answer from name recognition alone: a globally famous name may not ` + `exist in this workspace, and an entity here may differ from the public ` + `figure of the same name. If the lookup returns nothing, say so instead of ` + `inventing details.`, ``, `Plan before you write. The first time you call any mutating Plinth MCP tool ` + `in a session (create_*, update_*, delete_*, mark_*: anything that changes ` + `workspace data), do not commit silently: state exactly what you would write, ` + `naming the tool, the entity, and the fields and values, and proceed only after ` + `the user confirms or amends.`, ``, `Write through the canonical surface. In this cockpit the file tree is your edit ` + `surface, so reach for it before the most direct tool. Change prose (document ` + `bodies, free-form notes, anything textual) by editing the mirror markdown ` + `file; the daemon syncs it up. An artist's master context is the clearest case: ` + `it lives in the body of that artist's \`artists/<slug>/_artist.md\` mirror file, ` + `the file the web context pane renders, and one of only two files under ` + `\`artists/<slug>/\` the daemon syncs both ways (\`memory.md\` is the other), ` + `so change it by editing that body, never by creating ` + `a separate document or context entity (e.g. \`create_document\`) the pane won't ` + `render. One carve-out inside that file: a block labelled as rendered from ` + `Plinth is regenerated on every sync and your edits to it are discarded, so ` + `write those through the tool the block names. Change structured fields (task ` + `status, priority, assignees, due dates, a project's category) through the ` + `Plinth MCP (\`mcp__plinth__*\`) or the task file's frontmatter, which is ` + `row-canonical but still written through the validated API. Make a bulk or ` + `destructive change (a mass delete, a gutting overwrite) by editing the file ` + `path, so the daemon's confirm-gate can quarantine it for \`plinth confirm\`. ` + `Never write the database directly, which rules out raw SQL and ` + `\`execute_sql\`, \`apply_migration\` and \`psql\`: those bypass the daemon, the ` + `mirror, the confirm-gate, canonicality, and API validation.`, ``, `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Reading ` + `dates is yours, and this mirror already holds them: the daemon projects every ` + `mapped calendar into \`views/calendar/upcoming.md\` and ` + `\`views/calendar/<slug>.md\`, so read those before reaching for a tool ` + `(\`list_artist_calendars\`). They are derived and overwritten, ` + `so they are current only as of the \`generated_at\` in their header - quote that ` + `stamp, never edit them, and check the status line, because a calendar Plinth ` + `could not reach is unknown rather than empty. When a tool is the only way, ` + `resolve the calendar through \`mcp__plinth__list_artist_calendars\` and use its ` + `\`calendar_id\` field, not the mapping row's \`id\`, because the default ` + `\`primary\` is almost never theirs.`, ``, `Writing a date is not yours to do unasked, and the route is Plinth's own: ` + `propose it with \`preview_calendar_entry\`, put that proposal to the person, ` + `and only once they agree pass the token it minted for exactly that proposal ` + `to \`create_calendar_entry\` or \`update_calendar_entry\`. Nothing else writes ` + `one: ` + `no other calendar tool, and not a shell command line either, the ` + `Google Workspace one included. ` + `Nothing refuses a shell, anywhere, and where Plinth has not configured ` + `this workspace nothing is refused at all: you are the only thing holding ` + `those. Where no calendar is mapped, or a token is refused, that is not a ` + `wall. The way ` + `through is a person, not a workaround, so ` + `say plainly what the change is, say that it goes in through ` + `Plinth's calendar view, and say that a person makes it. Say why, too: a date ` + `left looking handled is worse than one openly outstanding, and a calendar ` + `write goes out to everyone invited the moment it lands and cannot be recalled.`, ``, `Draft email. Never send it. Writing the draft is yours: use the draft tools ` + `(\`create_draft\`, \`update_draft\`), then say where the draft is and what it ` + `says, so the person can read it and send it in their own name. Sending, ` + `replying, forwarding and deleting are not yours, by any route: not a mail tool, and ` + `not a shell command line either. The mail tools are refused for you where ` + `Plinth has configured this workspace. Nothing refuses a shell, anywhere, ` + `and where Plinth has not configured this workspace nothing is refused at ` + `all: you are the only thing holding those. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and one deleted out of a mailbox is not yours to lose; the person whose name ` + `is on it is the one who decides.`);
27036
27076
  if (workspaceRules === null && voiceProfile === null) {
27037
27077
  add("voice", ``, `Check the voice yourself, because nothing else checks it now. Two files carry ` + `it: \`workspace-rules.md\` at the mirror root holds this workspace's house ` + `style, and \`${VOICE_FILENAME}\` beside it holds your own voice profile, the ` + `sign-off and the habits that belong to one member rather than the team. Read ` + `them before you write and follow them as written, rather than drafting in ` + `your own register and correcting after. Nothing inspects a draft between you ` + `and the person reading it, so the rules in those files are the whole of it.`);
27038
27078
  }
@@ -29306,8 +29346,10 @@ var MANAGED_COMMENT = [
29306
29346
  "or in .claude/settings.local.json.",
29307
29347
  "The permissions.deny entries below are managed the same way. They stop an agent",
29308
29348
  "in this workspace sending, replying to or forwarding mail, deleting or trashing",
29309
- "it, marking it spam, and writing a calendar; drafting, filing, labelling and",
29310
- "reading stay allowed. The refusal covers a draft the agent staged itself: on a",
29349
+ "it, marking it spam, and writing a calendar - create, update, delete and cancel",
29350
+ "wherever the name also carries event, plus delete and cancel wherever it carries",
29351
+ "calendar entry; drafting, filing, labelling and reading stay allowed.",
29352
+ "The refusal covers a draft the agent staged itself: on a",
29311
29353
  "Gmail mount there is no delete-draft tool, so trashing was the only way to",
29312
29354
  "discard one and it is now refused. Fix a wrong draft with update_draft, which",
29313
29355
  "keeps its thread. They match by VERB FAMILY rather than by",
@@ -29315,6 +29357,8 @@ var MANAGED_COMMENT = [
29315
29357
  "rule set, not four - and the matcher cannot express an alternation, so each",
29316
29358
  "spelling needs its own line. That width also catches a few reads whose name",
29317
29359
  "merely begins with a send verb - getForwardingSettings is the live example -",
29360
+ "and a few whose name merely CONTAINS one - a calendar read called",
29361
+ "get_deleted_calendar_entry is refused, because delete sits inside deleted -",
29318
29362
  "and it stops an agent trashing any file, not only mail: nothing here can tell",
29319
29363
  "a mailbox's trash from a drive's.",
29320
29364
  "You cannot re-open one of these here: a deny beats an allow, including yours,",
@@ -29323,6 +29367,10 @@ var MANAGED_COMMENT = [
29323
29367
  "message that has gone cannot be recalled. Nor is a deleted one yours to lose:",
29324
29368
  "it is somebody else's mailbox, spam-marking is deletion on a thirty-day fuse,",
29325
29369
  "and neither is undone by noticing afterwards.",
29370
+ "One thing is deliberately NOT on this list, and it is the only exception:",
29371
+ "Plinth's own create_calendar_entry and update_calendar_entry, the sanctioned",
29372
+ "calendar write path, which take a two-step preview-and-confirm of their own",
29373
+ "before anything reaches a calendar.",
29326
29374
  "One more entry stops an agent EDITING voice.md, the",
29327
29375
  "voice profile it is judged against: that file is yours to write and theirs to read.",
29328
29376
  "Your own allow list is untouched, and your own",
@@ -29401,6 +29449,38 @@ var MANAGED_DENY = [
29401
29449
  "mcp__*__*_cancel*Event*",
29402
29450
  "mcp__*__*-cancel*Event*",
29403
29451
  "mcp__*__*Cancel*Event*",
29452
+ "mcp__*__delete*calendar*entry*",
29453
+ "mcp__*__*_delete*calendar*entry*",
29454
+ "mcp__*__*-delete*calendar*entry*",
29455
+ "mcp__*__*Delete*calendar*entry*",
29456
+ "mcp__*__delete*calendar*Entry*",
29457
+ "mcp__*__*_delete*calendar*Entry*",
29458
+ "mcp__*__*-delete*calendar*Entry*",
29459
+ "mcp__*__*Delete*calendar*Entry*",
29460
+ "mcp__*__delete*Calendar*entry*",
29461
+ "mcp__*__*_delete*Calendar*entry*",
29462
+ "mcp__*__*-delete*Calendar*entry*",
29463
+ "mcp__*__*Delete*Calendar*entry*",
29464
+ "mcp__*__delete*Calendar*Entry*",
29465
+ "mcp__*__*_delete*Calendar*Entry*",
29466
+ "mcp__*__*-delete*Calendar*Entry*",
29467
+ "mcp__*__*Delete*Calendar*Entry*",
29468
+ "mcp__*__cancel*calendar*entry*",
29469
+ "mcp__*__*_cancel*calendar*entry*",
29470
+ "mcp__*__*-cancel*calendar*entry*",
29471
+ "mcp__*__*Cancel*calendar*entry*",
29472
+ "mcp__*__cancel*calendar*Entry*",
29473
+ "mcp__*__*_cancel*calendar*Entry*",
29474
+ "mcp__*__*-cancel*calendar*Entry*",
29475
+ "mcp__*__*Cancel*calendar*Entry*",
29476
+ "mcp__*__cancel*Calendar*entry*",
29477
+ "mcp__*__*_cancel*Calendar*entry*",
29478
+ "mcp__*__*-cancel*Calendar*entry*",
29479
+ "mcp__*__*Cancel*Calendar*entry*",
29480
+ "mcp__*__cancel*Calendar*Entry*",
29481
+ "mcp__*__*_cancel*Calendar*Entry*",
29482
+ "mcp__*__*-cancel*Calendar*Entry*",
29483
+ "mcp__*__*Cancel*Calendar*Entry*",
29404
29484
  "mcp__*__trash*",
29405
29485
  "mcp__*__*_trash*",
29406
29486
  "mcp__*__*-trash*",
@@ -29905,7 +29985,7 @@ function buildStartCommandMd(rosterIsEmpty2, offerTrim = false, stores = BLOB_ME
29905
29985
  "",
29906
29986
  "Then the calendar. It is Plinth's projection of every artist's calendar.",
29907
29987
  "",
29908
- "**This is a FILE READ, not a calendar call, and the rule in Step 2 stands unchanged: you still never open a calendar tool.** The file is here so that you do not have to.",
29988
+ "**This is a FILE READ, not a calendar call, and Step 2's budget stands unchanged: no calendar call in this brief.** The file is here so that you do not have to make one. **That is a cost rule about the opener, not a closed door** - Plinth has a calendar-write route of its own, named below. Opening a session is not when you take it.",
29909
29989
  "",
29910
29990
  '**Brief it as of its own `generated_at` stamp, and say the stamp.** A projection is a cache. It is exactly as current as the header says it is and no more, so "nothing on this week" is only ever true as of that moment.',
29911
29991
  "",
@@ -29913,7 +29993,7 @@ function buildStartCommandMd(rosterIsEmpty2, offerTrim = false, stores = BLOB_ME
29913
29993
  "",
29914
29994
  "If the file carries `fetch_state: stale`, the last refresh failed: say how old the dates are rather than reporting them as current. If the file is absent altogether, say the projection has not run yet and carry on without it.",
29915
29995
  "",
29916
- "**Dates are read here and changed by a person, in Plinth's calendar view.** If something needs moving, say what the change is and hand it over.",
29996
+ "**Dates are read here, and changing one is not part of this brief.** If something needs moving, say plainly what the change is. After this session's brief it goes one of two ways: propose it with `preview_calendar_entry`, put that proposal to the person, and only once they agree pass the token it minted to `create_calendar_entry` or `update_calendar_entry` - or hand the change to a person to make in Plinth's calendar view.",
29917
29997
  "",
29918
29998
  "## Step 4: brief, in three lines",
29919
29999
  "",
@@ -30022,7 +30102,7 @@ function buildCloseCommandMd(workspaceSlug, stores = BLOB_MEMORY_STORES) {
30022
30102
  "- **A note, brief or bio that is a piece of writing in its own right** is a document under `documents/`. That means standalone material somebody would open and read on its own, not a fact you are recording because you learned it - a fact is a memory line, and Step 4b routes it.",
30023
30103
  "- **A stable attribute of an artist** - their website, a handle, their bio - is that artist's own field, or the body of `artists/<slug>/_artist.md`.",
30024
30104
  "- **A contract, statement or attachment** is a file under `files/`.",
30025
- "- **A date is live state and belongs in no memory file.** A deadline rides its task as a due date. Every other date is a calendar matter: say plainly what the date is, say that a person adds it in Plinth's calendar view, and name it in the closing summary so it is not quietly dropped.",
30105
+ "- **A date is live state and belongs in no memory file.** A deadline rides its task as a due date. Every other date is a calendar matter: say plainly what the date is, and either propose it with `preview_calendar_entry`, put that proposal to the member, and only once they agree pass the token it minted to `create_calendar_entry` or `update_calendar_entry` - or hand it to a person to add in Plinth's calendar view. Name it in the closing summary either way, so it is not quietly dropped.",
30026
30106
  "",
30027
30107
  '**A typed fact with no home today is named in the closing summary as "no home yet", and written nowhere.** The case that actually occurs is a contact who belongs to several artists or to none - a publisher who routes writers across the whole roster. Do not pick one artist arbitrarily and do not flatten it into a memory line. **You cannot write it with no artist either: `create_artist_contact` requires `artist_id` or `artist_name` and errors with `Either artist_id or artist_name is required`.** So this is a real gap with no surface behind it today, not a shortcut you are declining to take. Name it in the summary and leave it unwritten; a named gap is useful, and a contact filed under an artist it does not belong to is worse than nothing.',
30028
30108
  "",
@@ -31007,6 +31087,31 @@ function recordEntitySymlinkRefused(args) {
31007
31087
  rejected_at: args.now().toISOString()
31008
31088
  }));
31009
31089
  }
31090
+ function recordEntitySlugClash(args) {
31091
+ const path3 = args.mirrorRelativePath;
31092
+ const withoutStale = removeUnsynced(args.state, "entity", path3).state;
31093
+ const held = `Until then new ${args.entityType} changes are held on this machine.`;
31094
+ const message = args.reason === "source_unreadable" ? `${args.entityType}/${args.fromSlug} was renamed in Plinth to ` + `${args.toSlug}, but ${path3} could not be read, so it was left exactly ` + `where it is. Check the file's permissions; Plinth will move it once ` + `it can read it. ${held}` : args.occupantTracked ? `${args.entityType}/${args.fromSlug} was renamed in Plinth to ` + `${args.toSlug}, but ${args.entityType}/${args.toSlug}.md belongs to ` + `another ${args.entityType}, so nothing was moved or overwritten. ` + `This usually clears itself once that record's own rename arrives. ` + `Do not delete that file — it would delete that record in Plinth. ` + `${held}` : `${args.entityType}/${args.fromSlug} was renamed in Plinth to ` + `${args.toSlug}, but ${args.entityType}/${args.toSlug}.md already ` + `exists here, so nothing was moved or overwritten. Rename or remove ` + `that file and Plinth will move this one into place. ${held}`;
31095
+ const existing = (withoutStale.rejected ?? []).find((r) => r.target === "entity" && r.path === path3 && r.reason === "slug_clash");
31096
+ if (existing && existing.message === message)
31097
+ return args.state;
31098
+ return persist(withoutStale, upsertRejected(withoutStale, {
31099
+ target: "entity",
31100
+ op: "upsert",
31101
+ path: path3,
31102
+ status: 0,
31103
+ reason: "slug_clash",
31104
+ message,
31105
+ rejected_at: args.now().toISOString()
31106
+ }));
31107
+ }
31108
+ function clearSlugClash(state, mirrorRelativePaths) {
31109
+ const list = state.rejected ?? [];
31110
+ const next = list.filter((o) => !(o.target === "entity" && o.reason === "slug_clash" && mirrorRelativePaths.includes(o.path)));
31111
+ if (next.length === list.length)
31112
+ return state;
31113
+ return commitState(state, { ...state, rejected: next });
31114
+ }
31010
31115
  function clearSymlinkRefused(state, target, path3) {
31011
31116
  const list = state.rejected ?? [];
31012
31117
  const next = list.filter((o) => !(o.target === target && o.path === path3 && o.reason === "symlink"));
@@ -32727,10 +32832,102 @@ function computeMirrorHashes(strategy, content) {
32727
32832
  function hashableBody(strategy, body) {
32728
32833
  return strategy.stripLocalBlocks?.(body) ?? body;
32729
32834
  }
32730
- function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor) {
32835
+ function relocateForSlugChange(args) {
32836
+ const { workspaceSlug, strategy, fromSlug, toSlug, acked, echo, now } = args;
32837
+ if (fromSlug === toSlug)
32838
+ return { kind: "not_needed", writeSlug: toSlug };
32839
+ if (!echo) {
32840
+ return { kind: "skipped", writeSlug: toSlug, fromSlug, reason: "unguarded" };
32841
+ }
32842
+ if (strategy.mirrorRelativePath(fromSlug).split("/").length !== 2) {
32843
+ return { kind: "skipped", writeSlug: toSlug, fromSlug, reason: "foldered" };
32844
+ }
32845
+ let fromPath;
32846
+ try {
32847
+ fromPath = entityFilePath(workspaceSlug, strategy.type, fromSlug);
32848
+ entityFilePath(workspaceSlug, strategy.type, toSlug);
32849
+ } catch (err) {
32850
+ if (err instanceof MirrorPathError) {
32851
+ return {
32852
+ kind: "skipped",
32853
+ writeSlug: toSlug,
32854
+ fromSlug,
32855
+ reason: "unresolvable"
32856
+ };
32857
+ }
32858
+ throw err;
32859
+ }
32860
+ let raw = null;
32861
+ try {
32862
+ raw = readFileSync15(fromPath, "utf8");
32863
+ } catch (err) {
32864
+ const code = err?.code;
32865
+ if (code === "ENOENT") {
32866
+ return { kind: "absent", writeSlug: toSlug, fromSlug };
32867
+ }
32868
+ return {
32869
+ kind: "refused",
32870
+ writeSlug: fromSlug,
32871
+ fromSlug,
32872
+ reason: "source_unreadable"
32873
+ };
32874
+ }
32875
+ let diverged = true;
32876
+ try {
32877
+ const hashes = computeMirrorHashes(strategy, raw);
32878
+ diverged = hashes.bodyHash !== acked.bodyHash || hashes.frontmatterHash !== acked.frontmatterHash;
32879
+ } catch {
32880
+ diverged = true;
32881
+ }
32882
+ const stashPath = diverged ? stashConflict({
32883
+ workspaceSlug,
32884
+ entityType: strategy.type,
32885
+ entitySlug: fromSlug,
32886
+ timestamp: now(),
32887
+ contents: raw,
32888
+ side: "local"
32889
+ }) : undefined;
32890
+ try {
32891
+ let registered = false;
32892
+ const result = moveEntityFile(workspaceSlug, strategy.type, fromSlug, toSlug, () => {
32893
+ if (echo.watcher === "live") {
32894
+ echo.suppressor.registerDelete(fromPath);
32895
+ registered = true;
32896
+ }
32897
+ });
32898
+ if (result === "moved") {
32899
+ return { kind: "moved", writeSlug: toSlug, fromSlug, stashPath };
32900
+ }
32901
+ if (registered && echo.watcher === "live") {
32902
+ echo.suppressor.cancelDelete(fromPath);
32903
+ }
32904
+ return { kind: "absent", writeSlug: toSlug, fromSlug };
32905
+ } catch (err) {
32906
+ if (err instanceof MirrorDestinationOccupiedError) {
32907
+ return {
32908
+ kind: "refused",
32909
+ writeSlug: fromSlug,
32910
+ fromSlug,
32911
+ reason: "destination_occupied",
32912
+ stashPath
32913
+ };
32914
+ }
32915
+ if (err instanceof MirrorPathError) {
32916
+ return {
32917
+ kind: "skipped",
32918
+ writeSlug: toSlug,
32919
+ fromSlug,
32920
+ reason: "unresolvable"
32921
+ };
32922
+ }
32923
+ throw err;
32924
+ }
32925
+ }
32926
+ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor, pathSlug) {
32927
+ const targetSlug = pathSlug ?? doc2.slug;
32731
32928
  let content = strategy.serialise(doc2);
32732
32929
  if (strategy.preserveLocalBodyOnPull) {
32733
- const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
32930
+ const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
32734
32931
  let existing = null;
32735
32932
  try {
32736
32933
  existing = readFileSync15(target, "utf8");
@@ -32748,10 +32945,10 @@ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor)
32748
32945
  }
32749
32946
  }
32750
32947
  if (suppressor) {
32751
- const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
32948
+ const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
32752
32949
  suppressor.registerWrite(target, computeMirrorHashes(strategy, content));
32753
32950
  }
32754
- writeMirrorFile(workspaceSlug, strategy.type, doc2.slug, content);
32951
+ writeMirrorFile(workspaceSlug, strategy.type, targetSlug, content);
32755
32952
  return content;
32756
32953
  }
32757
32954
 
@@ -32859,7 +33056,8 @@ function cloudMatchesAcked(strategy, cloud, acked) {
32859
33056
  }
32860
33057
  function stampStateFromMirror(args) {
32861
33058
  const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
32862
- const mirrorPath = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
33059
+ const targetSlug = args.pathSlug ?? doc2.slug;
33060
+ const mirrorPath = entityFilePath(workspaceSlug, strategy.type, targetSlug);
32863
33061
  const mirrorRaw = readFileSync16(mirrorPath, "utf8");
32864
33062
  const mirrorParsed = import_gray_matter10.default(mirrorRaw);
32865
33063
  const mirrorFm = mirrorParsed.data ?? {};
@@ -32868,7 +33066,7 @@ function stampStateFromMirror(args) {
32868
33066
  const mirrorHashBody = hashableBody(strategy, mirrorParsed.content);
32869
33067
  const refreshed = {
32870
33068
  id: doc2.id,
32871
- slug: doc2.slug,
33069
+ slug: targetSlug,
32872
33070
  updated_at: doc2.updated_at,
32873
33071
  revision: doc2.revision,
32874
33072
  body_hash: stableHash(mirrorHashBody),
@@ -32879,6 +33077,73 @@ function stampStateFromMirror(args) {
32879
33077
  };
32880
33078
  return persistEntry(state, strategy.type, refreshed);
32881
33079
  }
33080
+ function adoptCloudCopy(args) {
33081
+ const { strategy, workspaceSlug, doc: doc2, output } = args;
33082
+ const now = args.now ?? (() => new Date);
33083
+ let state = args.state;
33084
+ const prior = getEntry(state, strategy.type, doc2.id);
33085
+ let writeSlug = doc2.slug;
33086
+ let refused = false;
33087
+ if (prior && prior.slug !== doc2.slug) {
33088
+ const relocation = relocateForSlugChange({
33089
+ workspaceSlug,
33090
+ strategy,
33091
+ fromSlug: prior.slug,
33092
+ toSlug: doc2.slug,
33093
+ acked: {
33094
+ bodyHash: prior.body_hash,
33095
+ frontmatterHash: prior.frontmatter_hash
33096
+ },
33097
+ echo: args.echo,
33098
+ now
33099
+ });
33100
+ writeSlug = relocation.writeSlug;
33101
+ const move = `entityType=${strategy.type} from=${prior.slug} to=${doc2.slug} id=${doc2.id}`;
33102
+ if (relocation.kind === "moved") {
33103
+ output?.log(`[SLUG_MOVE ${move} source=push]`);
33104
+ state = clearSlugClash(state, [
33105
+ strategy.mirrorRelativePath(prior.slug),
33106
+ strategy.mirrorRelativePath(doc2.slug)
33107
+ ]);
33108
+ } else if (relocation.kind === "refused") {
33109
+ output?.log(`[SLUG_MOVE_REFUSED ${move} reason=${relocation.reason} source=push]`);
33110
+ refused = true;
33111
+ state = recordEntitySlugClash({
33112
+ state,
33113
+ mirrorRelativePath: strategy.mirrorRelativePath(prior.slug),
33114
+ entityType: strategy.type,
33115
+ fromSlug: prior.slug,
33116
+ toSlug: doc2.slug,
33117
+ reason: relocation.reason,
33118
+ occupantTracked: getEntryBySlug(state, strategy.type, doc2.slug) !== undefined,
33119
+ now
33120
+ });
33121
+ } else if (relocation.kind === "skipped") {
33122
+ output?.log(`[SLUG_MOVE_SKIP ${move} reason=${relocation.reason} source=push]`);
33123
+ }
33124
+ if (relocation.kind === "moved" || relocation.kind === "refused") {
33125
+ if (relocation.stashPath) {
33126
+ output?.warn(`Unsynced local edit on ${strategy.type}/${prior.slug} → stashed to ` + `${relocation.stashPath} (the row was renamed in Plinth; the cloud ` + `copy is authoritative).`);
33127
+ }
33128
+ }
33129
+ }
33130
+ if (refused) {
33131
+ return prior ? persistEntry(state, strategy.type, {
33132
+ ...prior,
33133
+ updated_at: doc2.updated_at,
33134
+ revision: doc2.revision
33135
+ }) : state;
33136
+ }
33137
+ writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, undefined, writeSlug);
33138
+ return stampStateFromMirror({
33139
+ strategy,
33140
+ workspaceSlug,
33141
+ doc: doc2,
33142
+ state,
33143
+ priorMemoryHash: args.priorMemoryHash,
33144
+ pathSlug: writeSlug
33145
+ });
33146
+ }
32882
33147
  async function pullAndWriteMirror(args) {
32883
33148
  const cloud = await getEntity({
32884
33149
  strategy: args.strategy,
@@ -32887,13 +33152,15 @@ async function pullAndWriteMirror(args) {
32887
33152
  id: args.id,
32888
33153
  fetchImpl: args.fetchImpl
32889
33154
  });
32890
- writeMirrorWithBodyPreserve(args.workspaceSlug, args.strategy, cloud);
32891
- return stampStateFromMirror({
33155
+ return adoptCloudCopy({
32892
33156
  strategy: args.strategy,
32893
33157
  workspaceSlug: args.workspaceSlug,
32894
33158
  doc: cloud,
32895
33159
  state: args.state,
32896
- priorMemoryHash: getEntry(args.state, args.strategy.type, args.id)?.memory_hash
33160
+ priorMemoryHash: getEntry(args.state, args.strategy.type, args.id)?.memory_hash,
33161
+ echo: args.echo,
33162
+ output: args.output,
33163
+ now: args.now
32897
33164
  });
32898
33165
  }
32899
33166
  async function pushFile(args) {
@@ -32914,6 +33181,7 @@ async function pushFile(args) {
32914
33181
  const mutex = deps?.mutex ?? new PerIdMutex;
32915
33182
  const output = deps?.output;
32916
33183
  const suppressor = deps?.suppressor;
33184
+ const echo = deps?.echo;
32917
33185
  const strategy = getStrategy(entityType);
32918
33186
  if (!strategy) {
32919
33187
  return {
@@ -32927,6 +33195,8 @@ async function pushFile(args) {
32927
33195
  }
32928
33196
  if (!exists) {
32929
33197
  return runPushDelete({
33198
+ echo,
33199
+ now,
32930
33200
  strategy,
32931
33201
  workspaceSlug,
32932
33202
  baseUrl,
@@ -32945,6 +33215,8 @@ async function pushFile(args) {
32945
33215
  } catch (err) {
32946
33216
  if (err?.code === "ENOENT") {
32947
33217
  return runPushDelete({
33218
+ echo,
33219
+ now,
32948
33220
  strategy,
32949
33221
  workspaceSlug,
32950
33222
  baseUrl,
@@ -33004,6 +33276,7 @@ async function pushFile(args) {
33004
33276
  const stateEntry = identity === "path" ? byInode : byInode ?? getEntryBySlug(state, entityType, slug);
33005
33277
  if (!stateEntry) {
33006
33278
  return runPushCreate({
33279
+ echo,
33007
33280
  strategy,
33008
33281
  workspaceSlug,
33009
33282
  baseUrl,
@@ -33024,6 +33297,7 @@ async function pushFile(args) {
33024
33297
  });
33025
33298
  }
33026
33299
  return runPushUpsert({
33300
+ echo,
33027
33301
  strategy,
33028
33302
  workspaceSlug,
33029
33303
  baseUrl,
@@ -33082,7 +33356,10 @@ async function reconcileContextDocConflict(args) {
33082
33356
  pat,
33083
33357
  id: existingId,
33084
33358
  state,
33085
- fetchImpl
33359
+ fetchImpl,
33360
+ echo: args.echo,
33361
+ output,
33362
+ now
33086
33363
  });
33087
33364
  output?.log(contextDocConflictLine({
33088
33365
  entityType: strategy.type,
@@ -33199,7 +33476,10 @@ async function reconcileContextDocConflict(args) {
33199
33476
  pat,
33200
33477
  id: existingId,
33201
33478
  state,
33202
- fetchImpl
33479
+ fetchImpl,
33480
+ echo: args.echo,
33481
+ output,
33482
+ now
33203
33483
  });
33204
33484
  output?.log(contextDocConflictLine({
33205
33485
  entityType: strategy.type,
@@ -33421,6 +33701,7 @@ async function runPushUpsert(args) {
33421
33701
  }
33422
33702
  if (patchResult.reason === "context_doc_conflict") {
33423
33703
  return reconcileContextDocConflict({
33704
+ echo: args.echo,
33424
33705
  strategy,
33425
33706
  workspaceSlug: args.workspaceSlug,
33426
33707
  baseUrl: args.baseUrl,
@@ -33439,6 +33720,7 @@ async function runPushUpsert(args) {
33439
33720
  }
33440
33721
  if (strategy.fieldTypedConflict) {
33441
33722
  return reconcileTaskConflict({
33723
+ echo: args.echo,
33442
33724
  strategy,
33443
33725
  workspaceSlug: args.workspaceSlug,
33444
33726
  baseUrl: args.baseUrl,
@@ -33469,7 +33751,10 @@ async function runPushUpsert(args) {
33469
33751
  pat: args.pat,
33470
33752
  id: liveEntry.id,
33471
33753
  state: workingState,
33472
- fetchImpl: args.fetchImpl
33754
+ fetchImpl: args.fetchImpl,
33755
+ echo: args.echo,
33756
+ output: args.output,
33757
+ now: args.now
33473
33758
  });
33474
33759
  return {
33475
33760
  outcome: {
@@ -33517,7 +33802,10 @@ async function reconcileTaskConflict(args) {
33517
33802
  pat,
33518
33803
  id: liveEntry.id,
33519
33804
  state,
33520
- fetchImpl
33805
+ fetchImpl,
33806
+ echo: args.echo,
33807
+ output,
33808
+ now
33521
33809
  });
33522
33810
  return {
33523
33811
  outcome: {
@@ -33566,13 +33854,15 @@ async function reconcileTaskConflict(args) {
33566
33854
  });
33567
33855
  if (patchResult.ok) {
33568
33856
  const stashPath2 = stashCloudSide();
33569
- writeMirrorWithBodyPreserve(workspaceSlug, strategy, patchResult.doc);
33570
- state = stampStateFromMirror({
33857
+ state = adoptCloudCopy({
33571
33858
  strategy,
33572
33859
  workspaceSlug,
33573
33860
  doc: patchResult.doc,
33574
33861
  state,
33575
- priorMemoryHash: liveEntry.memory_hash
33862
+ priorMemoryHash: liveEntry.memory_hash,
33863
+ echo: args.echo,
33864
+ output,
33865
+ now
33576
33866
  });
33577
33867
  output?.log(`[PATCH entityType=${strategy.type} slug=${patchResult.doc.slug} id=${patchResult.doc.id} revision=${patchResult.doc.revision} note=field_typed_conflict]`);
33578
33868
  return {
@@ -33604,7 +33894,10 @@ async function reconcileTaskConflict(args) {
33604
33894
  pat,
33605
33895
  id: liveEntry.id,
33606
33896
  state,
33607
- fetchImpl
33897
+ fetchImpl,
33898
+ echo: args.echo,
33899
+ output,
33900
+ now
33608
33901
  });
33609
33902
  return {
33610
33903
  outcome: {
@@ -33641,6 +33934,7 @@ async function runPushCreate(args) {
33641
33934
  const inLock = args.identity === "path" ? undefined : getEntryBySlug(args.state, strategy.type, args.slug);
33642
33935
  if (inLock) {
33643
33936
  return runPushUpsert({
33937
+ echo: args.echo,
33644
33938
  strategy,
33645
33939
  workspaceSlug: args.workspaceSlug,
33646
33940
  baseUrl: args.baseUrl,
@@ -33737,6 +34031,7 @@ async function runPushCreate(args) {
33737
34031
  }
33738
34032
  if (createResult.reason === "context_doc_conflict") {
33739
34033
  return reconcileContextDocConflict({
34034
+ echo: args.echo,
33740
34035
  strategy,
33741
34036
  workspaceSlug: args.workspaceSlug,
33742
34037
  baseUrl: args.baseUrl,
@@ -33768,7 +34063,10 @@ async function runPushCreate(args) {
33768
34063
  pat: args.pat,
33769
34064
  id: createResult.existing.id,
33770
34065
  state: args.state,
33771
- fetchImpl: args.fetchImpl
34066
+ fetchImpl: args.fetchImpl,
34067
+ echo: args.echo,
34068
+ output: args.output,
34069
+ now: args.now
33772
34070
  });
33773
34071
  return {
33774
34072
  outcome: {
@@ -33856,7 +34154,10 @@ async function runPushDelete(args) {
33856
34154
  pat: args.pat,
33857
34155
  id,
33858
34156
  state: args.state,
33859
- fetchImpl: args.fetchImpl
34157
+ fetchImpl: args.fetchImpl,
34158
+ echo: args.echo,
34159
+ output: args.output,
34160
+ now: args.now
33860
34161
  });
33861
34162
  return {
33862
34163
  outcome: {
@@ -33887,6 +34188,7 @@ async function pushRename(args) {
33887
34188
  const now = deps?.now ?? (() => new Date);
33888
34189
  const mutex = deps?.mutex ?? new PerIdMutex;
33889
34190
  const output = deps?.output;
34191
+ const echo = deps?.echo;
33890
34192
  const strategy = requireStrategy(entityType);
33891
34193
  let toSlug;
33892
34194
  try {
@@ -33995,7 +34297,10 @@ async function pushRename(args) {
33995
34297
  pat,
33996
34298
  id: patchResult.existing.id,
33997
34299
  state,
33998
- fetchImpl
34300
+ fetchImpl,
34301
+ echo,
34302
+ output,
34303
+ now
33999
34304
  });
34000
34305
  const finalState = await pullAndWriteMirror({
34001
34306
  strategy,
@@ -34004,7 +34309,10 @@ async function pushRename(args) {
34004
34309
  pat,
34005
34310
  id,
34006
34311
  state: afterColliding,
34007
- fetchImpl
34312
+ fetchImpl,
34313
+ echo,
34314
+ output,
34315
+ now
34008
34316
  });
34009
34317
  return {
34010
34318
  outcome: {
@@ -34036,7 +34344,10 @@ async function pushRename(args) {
34036
34344
  pat,
34037
34345
  id,
34038
34346
  state,
34039
- fetchImpl
34347
+ fetchImpl,
34348
+ echo,
34349
+ output,
34350
+ now
34040
34351
  });
34041
34352
  return {
34042
34353
  outcome: {
@@ -34094,7 +34405,8 @@ async function applyBatch(batch, deps, mutex) {
34094
34405
  fetchImpl: deps.fetchImpl,
34095
34406
  now: deps.now,
34096
34407
  mutex,
34097
- output: deps.output
34408
+ output: deps.output,
34409
+ echo: deps.echo
34098
34410
  }
34099
34411
  });
34100
34412
  deps.setState(result.state);
@@ -34163,7 +34475,10 @@ async function discardBatchOps(batch, deps) {
34163
34475
  pat: deps.pat,
34164
34476
  id: op.id,
34165
34477
  state: deps.getState(),
34166
- fetchImpl: deps.fetchImpl
34478
+ fetchImpl: deps.fetchImpl,
34479
+ echo: deps.echo,
34480
+ output: deps.output,
34481
+ now: deps.now
34167
34482
  });
34168
34483
  deps.setState(next);
34169
34484
  } catch (err) {
@@ -34756,7 +35071,7 @@ function recordFileUnsynced(args) {
34756
35071
  });
34757
35072
  }
34758
35073
  async function drainUnsyncedOps(args) {
34759
- const { workspaceSlug, baseUrl, pat, getState, setState, fetchImpl, mutex, output } = args;
35074
+ const { workspaceSlug, baseUrl, pat, getState, setState, fetchImpl, mutex, output, echo } = args;
34760
35075
  const now = args.now ?? (() => new Date);
34761
35076
  const ops = listUnsynced(getState());
34762
35077
  let attempted = 0;
@@ -34789,7 +35104,7 @@ async function drainUnsyncedOps(args) {
34789
35104
  state: getState(),
34790
35105
  exists: op.op === "upsert",
34791
35106
  unlinkContext: null,
34792
- deps: { fetchImpl, now, mutex, output }
35107
+ deps: { fetchImpl, now, mutex, output, echo }
34793
35108
  });
34794
35109
  setState(result.state);
34795
35110
  } else {
@@ -38304,6 +38619,11 @@ class PullSuppressor {
38304
38619
  expiresAt: this.now() + this.ttlMs
38305
38620
  });
38306
38621
  }
38622
+ cancelDelete(absPath) {
38623
+ const entry2 = this.entries.get(absPath);
38624
+ if (entry2?.kind === "delete")
38625
+ this.entries.delete(absPath);
38626
+ }
38307
38627
  shouldSuppressUpsert(absPath, hashes) {
38308
38628
  const entry2 = this.entries.get(absPath);
38309
38629
  if (!entry2)
@@ -38872,7 +39192,7 @@ async function reconcileDivergedRows(args) {
38872
39192
  state,
38873
39193
  exists: true,
38874
39194
  unlinkContext: null,
38875
- deps: { fetchImpl, now, output }
39195
+ deps: { fetchImpl, now, output, echo: args.echo }
38876
39196
  });
38877
39197
  state = result.state;
38878
39198
  switch (result.outcome.kind) {
@@ -41373,7 +41693,13 @@ async function runSync(workspaceSlug, deps = {}) {
41373
41693
  let cursor = startCursor;
41374
41694
  let lastAdvancedCursor = startCursor;
41375
41695
  let cursorBlocked = false;
41376
- const ctr = { fetched: 0, written: 0, deleted: 0 };
41696
+ const ctr = {
41697
+ fetched: 0,
41698
+ written: 0,
41699
+ deleted: 0,
41700
+ moved: 0,
41701
+ refused: 0
41702
+ };
41377
41703
  while (true) {
41378
41704
  const page = await syncEntity({
41379
41705
  strategy,
@@ -41407,6 +41733,12 @@ async function runSync(workspaceSlug, deps = {}) {
41407
41733
  ctr.written++;
41408
41734
  else if (outcome.action === "deleted")
41409
41735
  ctr.deleted++;
41736
+ else if (outcome.action === "moved")
41737
+ ctr.moved = (ctr.moved ?? 0) + 1;
41738
+ else if (outcome.action === "slug_refused") {
41739
+ ctr.refused = (ctr.refused ?? 0) + 1;
41740
+ cursorBlocked = true;
41741
+ }
41410
41742
  }
41411
41743
  commitPending();
41412
41744
  if (page.next_cursor === null)
@@ -41628,7 +41960,9 @@ async function runSync(workspaceSlug, deps = {}) {
41628
41960
  await syncFileRejections();
41629
41961
  const totalWritten = Object.values(counters).reduce((sum, c) => sum + c.written, 0);
41630
41962
  const totalDeleted = Object.values(counters).reduce((sum, c) => sum + c.deleted, 0);
41631
- if (totalWritten === 0 && totalDeleted === 0) {
41963
+ const totalMoved = Object.values(counters).reduce((sum, c) => sum + (c.moved ?? 0), 0);
41964
+ const totalRefused = Object.values(counters).reduce((sum, c) => sum + (c.refused ?? 0), 0);
41965
+ if (totalWritten === 0 && totalDeleted === 0 && totalMoved === 0 && totalRefused === 0) {
41632
41966
  out.success("Already up to date.");
41633
41967
  return { exitCode: 0, applied: 0 };
41634
41968
  }
@@ -41639,12 +41973,16 @@ async function runSync(workspaceSlug, deps = {}) {
41639
41973
  const parts = [`${c.fetched} fetched`];
41640
41974
  if (c.written > 0)
41641
41975
  parts.push(`${c.written} written`);
41976
+ if ((c.moved ?? 0) > 0)
41977
+ parts.push(`${c.moved} renamed`);
41978
+ if ((c.refused ?? 0) > 0)
41979
+ parts.push(`${c.refused} rename held`);
41642
41980
  if (c.deleted > 0)
41643
41981
  parts.push(`${c.deleted} deleted`);
41644
41982
  out.info(` ${type}: ${parts.join(", ")}`);
41645
41983
  }
41646
41984
  out.success(`Done in ${elapsed}s.`);
41647
- return { exitCode: 0, applied: totalWritten + totalDeleted };
41985
+ return { exitCode: 0, applied: totalWritten + totalMoved + totalDeleted };
41648
41986
  }
41649
41987
  function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen, loggedTombstoneSkips) {
41650
41988
  if (isFrozen?.(strategy.type, row.id)) {
@@ -41704,7 +42042,59 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41704
42042
  if (strategy.shouldMirror && !strategy.shouldMirror(live) && !existingEntry) {
41705
42043
  return { state, action: "skipped" };
41706
42044
  }
41707
- if (existingEntry && existingEntry.updated_at === live.updated_at) {
42045
+ let relocation = { kind: "not_needed", writeSlug: live.slug };
42046
+ if (existingEntry && existingEntry.slug !== live.slug) {
42047
+ const echo = suppressor ? { watcher: "live", suppressor } : { watcher: "none" };
42048
+ relocation = relocateForSlugChange({
42049
+ workspaceSlug,
42050
+ strategy,
42051
+ fromSlug: existingEntry.slug,
42052
+ toSlug: live.slug,
42053
+ acked: {
42054
+ bodyHash: existingEntry.body_hash,
42055
+ frontmatterHash: existingEntry.frontmatter_hash
42056
+ },
42057
+ echo,
42058
+ now
42059
+ });
42060
+ const move = `entityType=${strategy.type} from=${existingEntry.slug} to=${live.slug} id=${live.id}`;
42061
+ switch (relocation.kind) {
42062
+ case "moved":
42063
+ out.log(`[SLUG_MOVE ${move}]`);
42064
+ state = clearSlugClash(state, [
42065
+ strategy.mirrorRelativePath(existingEntry.slug),
42066
+ strategy.mirrorRelativePath(live.slug)
42067
+ ]);
42068
+ break;
42069
+ case "refused":
42070
+ out.log(`[SLUG_MOVE_REFUSED ${move} reason=${relocation.reason}]`);
42071
+ const occupantTracked = getEntryBySlug(state, strategy.type, live.slug) !== undefined;
42072
+ state = recordEntitySlugClash({
42073
+ state,
42074
+ mirrorRelativePath: strategy.mirrorRelativePath(existingEntry.slug),
42075
+ entityType: strategy.type,
42076
+ fromSlug: existingEntry.slug,
42077
+ toSlug: live.slug,
42078
+ reason: relocation.reason,
42079
+ occupantTracked,
42080
+ now
42081
+ });
42082
+ out.warn(relocation.reason === "source_unreadable" ? `${strategy.type}/${existingEntry.slug} was renamed to ${live.slug} in ` + `Plinth, but ${strategy.mirrorRelativePath(existingEntry.slug)} could ` + `not be read — it was left exactly where it is. Check its permissions.` : occupantTracked ? `${strategy.type}/${existingEntry.slug} was renamed to ${live.slug} in ` + `Plinth, but ${strategy.mirrorRelativePath(live.slug)} belongs to ` + `another ${strategy.type} — nothing was moved or overwritten. This ` + `usually clears itself once that row's own rename arrives; do not ` + `delete the file, which would delete that record in Plinth.` : `${strategy.type}/${existingEntry.slug} was renamed to ${live.slug} in ` + `Plinth, but ${strategy.mirrorRelativePath(live.slug)} already exists ` + `here — nothing was moved or overwritten. Rename or remove that file ` + `and Plinth will move this one into place.`);
42083
+ return { state, action: "slug_refused" };
42084
+ case "skipped":
42085
+ out.log(`[SLUG_MOVE_SKIP ${move} reason=${relocation.reason}]`);
42086
+ break;
42087
+ case "absent":
42088
+ case "not_needed":
42089
+ break;
42090
+ }
42091
+ const stashPath = relocation.kind === "moved" ? relocation.stashPath : undefined;
42092
+ if (stashPath) {
42093
+ out.warn(`Unsynced local edit on ${strategy.type}/${existingEntry.slug} → stashed ` + `to ${stashPath} (the row was renamed in Plinth; the cloud copy is ` + `authoritative).`);
42094
+ }
42095
+ }
42096
+ const writeSlug = relocation.writeSlug;
42097
+ if (existingEntry && existingEntry.updated_at === live.updated_at && relocation.kind === "not_needed") {
41708
42098
  const path5 = entityFilePath(workspaceSlug, strategy.type, live.slug);
41709
42099
  if (existsSync7(path5)) {
41710
42100
  if (strategy.type === "artists") {
@@ -41730,10 +42120,10 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41730
42120
  return { state, action: "noop_overwrite" };
41731
42121
  }
41732
42122
  }
41733
- if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
42123
+ if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at && relocation.kind !== "moved") {
41734
42124
  let localRaw = null;
41735
42125
  try {
41736
- localRaw = readFileSync24(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
42126
+ localRaw = readFileSync24(entityFilePath(workspaceSlug, strategy.type, writeSlug), "utf8");
41737
42127
  } catch {
41738
42128
  localRaw = null;
41739
42129
  }
@@ -41753,11 +42143,11 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41753
42143
  }
41754
42144
  }
41755
42145
  }
41756
- const content = writeMirrorWithBodyPreserve(workspaceSlug, strategy, live, suppressor);
42146
+ const content = writeMirrorWithBodyPreserve(workspaceSlug, strategy, live, suppressor, writeSlug);
41757
42147
  const hashes = computeMirrorHashes(strategy, content);
41758
42148
  let inode;
41759
42149
  try {
41760
- inode = statSync16(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
42150
+ inode = statSync16(entityFilePath(workspaceSlug, strategy.type, writeSlug)).ino;
41761
42151
  } catch {
41762
42152
  inode = undefined;
41763
42153
  }
@@ -41780,7 +42170,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41780
42170
  }
41781
42171
  const { state: nextState } = upsertEntry(state, strategy.type, {
41782
42172
  id: live.id,
41783
- slug: live.slug,
42173
+ slug: writeSlug,
41784
42174
  updated_at: live.updated_at,
41785
42175
  revision: live.revision,
41786
42176
  body_hash: hashes.bodyHash,
@@ -41790,7 +42180,8 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41790
42180
  memory_hash: memoryHash,
41791
42181
  memory_generated: memoryGenerated
41792
42182
  });
41793
- return { state: nextState, action: "written" };
42183
+ const action = relocation.kind === "moved" ? "moved" : "written";
42184
+ return { state: nextState, action };
41794
42185
  }
41795
42186
  var syncCommand = defineCommand({
41796
42187
  meta: {
@@ -42266,6 +42657,22 @@ function makeWatchHandler(deps) {
42266
42657
  });
42267
42658
  if (!classifiedFrom)
42268
42659
  return;
42660
+ if (suppressor?.shouldSuppressDelete(event.fromPath)) {
42661
+ const strategy = getStrategy(classifiedFrom.entityType);
42662
+ let toRaw = null;
42663
+ try {
42664
+ toRaw = readFileSync25(event.toPath, "utf8");
42665
+ } catch {
42666
+ toRaw = null;
42667
+ }
42668
+ const isEcho = strategy !== undefined && toRaw !== null && suppressor.shouldSuppressUpsert(event.toPath, computeMirrorHashes(strategy, toRaw));
42669
+ if (isEcho) {
42670
+ output?.log(`[RENAME_SUPPRESSED entityType=${classifiedFrom.entityType} ` + `from=${event.fromSlug} id=${event.id} reason=pull_echo]`);
42671
+ return;
42672
+ }
42673
+ await handle({ kind: "upsert", absolutePath: event.toPath });
42674
+ return;
42675
+ }
42269
42676
  const result2 = await pushRename({
42270
42677
  workspaceSlug,
42271
42678
  baseUrl,
@@ -42277,7 +42684,13 @@ function makeWatchHandler(deps) {
42277
42684
  expectedUpdatedAt: event.expectedUpdatedAt,
42278
42685
  fromSlug: event.fromSlug,
42279
42686
  state: getState(),
42280
- deps: { fetchImpl, now, mutex, output }
42687
+ deps: {
42688
+ fetchImpl,
42689
+ now,
42690
+ mutex,
42691
+ output,
42692
+ echo: suppressor ? { watcher: "live", suppressor } : undefined
42693
+ }
42281
42694
  });
42282
42695
  setState(result2.state);
42283
42696
  reportOutcome(spinner, workspaceSlug, result2.state, result2.outcome);
@@ -42409,7 +42822,14 @@ function makeWatchHandler(deps) {
42409
42822
  slug: event.stateEntry.slug,
42410
42823
  updated_at: event.stateEntry.updated_at
42411
42824
  } : null,
42412
- deps: { fetchImpl, now, mutex, output, suppressor }
42825
+ deps: {
42826
+ fetchImpl,
42827
+ now,
42828
+ mutex,
42829
+ output,
42830
+ suppressor,
42831
+ echo: suppressor ? { watcher: "live", suppressor } : undefined
42832
+ }
42413
42833
  });
42414
42834
  setState(clearEntityRejected(result.state, workspaceSlug, absolutePath));
42415
42835
  reportOutcome(spinner, workspaceSlug, getState(), result.outcome);
@@ -42470,7 +42890,8 @@ async function flushHeldEntityOp(deps) {
42470
42890
  fetchImpl: deps.fetchImpl,
42471
42891
  now,
42472
42892
  mutex: deps.mutex,
42473
- output: deps.output
42893
+ output: deps.output,
42894
+ echo: deps.echo
42474
42895
  }
42475
42896
  });
42476
42897
  deps.setState(result.state);
@@ -42606,6 +43027,7 @@ async function runStart(workspaceSlug, deps = {}) {
42606
43027
  frozenFiles = computeFrozenFiles(slug);
42607
43028
  };
42608
43029
  const recon = await reconcileDivergedRows({
43030
+ echo: { watcher: "none" },
42609
43031
  workspaceSlug: slug,
42610
43032
  baseUrl: entry2.api_base_url,
42611
43033
  pat,
@@ -42819,6 +43241,7 @@ async function runStart(workspaceSlug, deps = {}) {
42819
43241
  spinner.start(`Watching ${slug} · ${watchingSummary(state)}`);
42820
43242
  const mutex = new PerIdMutex;
42821
43243
  const bootDrain = await drainResolvedBatches({
43244
+ echo: { watcher: "none" },
42822
43245
  workspaceSlug: slug,
42823
43246
  baseUrl: entry2.api_base_url,
42824
43247
  pat,
@@ -42836,6 +43259,7 @@ async function runStart(workspaceSlug, deps = {}) {
42836
43259
  out.info(`Resolved ${bootDrain.applied + bootDrain.discarded} held batch${bootDrain.applied + bootDrain.discarded === 1 ? "" : "es"}: ${bootDrain.applied} applied, ${bootDrain.discarded} discarded.`);
42837
43260
  }
42838
43261
  const bootUnsynced = await drainUnsyncedOps({
43262
+ echo: { watcher: "none" },
42839
43263
  workspaceSlug: slug,
42840
43264
  baseUrl: entry2.api_base_url,
42841
43265
  pat,
@@ -42873,6 +43297,7 @@ async function runStart(workspaceSlug, deps = {}) {
42873
43297
  };
42874
43298
  const suppressor = new PullSuppressor;
42875
43299
  const flushHeldOp = (op) => flushHeldEntityOp({
43300
+ echo: { watcher: "live", suppressor },
42876
43301
  workspaceSlug: slug,
42877
43302
  baseUrl: entry2.api_base_url,
42878
43303
  pat,
@@ -43060,6 +43485,7 @@ async function runStart(workspaceSlug, deps = {}) {
43060
43485
  draining = true;
43061
43486
  try {
43062
43487
  const drained = await drainResolvedBatches({
43488
+ echo: { watcher: "live", suppressor },
43063
43489
  workspaceSlug: slug,
43064
43490
  baseUrl: entry2.api_base_url,
43065
43491
  pat,
@@ -43077,6 +43503,7 @@ async function runStart(workspaceSlug, deps = {}) {
43077
43503
  spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
43078
43504
  }
43079
43505
  const redriven = await drainUnsyncedOps({
43506
+ echo: { watcher: "live", suppressor },
43080
43507
  workspaceSlug: slug,
43081
43508
  baseUrl: entry2.api_base_url,
43082
43509
  pat,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plinth-music/cli",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Plinth capstone sync daemon and CLI",
5
5
  "type": "module",
6
6
  "private": false,