@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 +67 -0
- package/README.md +2 -2
- package/dist/build-stamp.json +2 -2
- package/dist/cli.js +474 -47
- package/package.json +1 -1
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
|
|
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.
|
package/dist/build-stamp.json
CHANGED
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.
|
|
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,
|
|
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
|
|
29310
|
-
"
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
32948
|
+
const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
|
|
32752
32949
|
suppressor.registerWrite(target, computeMirrorHashes(strategy, content));
|
|
32753
32950
|
}
|
|
32754
|
-
writeMirrorFile(workspaceSlug, strategy.type,
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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,
|