@plinth-music/cli 0.16.0 → 0.17.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,41 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.17.0 - 2026-09-03
6
+
7
+ One code PR since `0.16.0` (`v0.16.0..a6209c8`, #165; #167 is tests and comments,
8
+ #163 / #164 / #166 docs). A minor because sync gains a new refusal and a file on your
9
+ disk can now move on its own.
10
+
11
+ - ⚠️ **Renaming a task or project in Plinth now moves the mirror file instead of
12
+ writing a second one.** The server regenerates a row's slug when its name changes.
13
+ Until now the mirror wrote the new name to a new file and left the old one orphaned,
14
+ and an edit to that orphan created a duplicate row in Plinth. The file is moved now,
15
+ and a local copy that had diverged from the cloud is stashed to
16
+ `~/.plinth/_conflicts/<workspace>/` before the move rather than pushed.
17
+ - ⚠️ **A rename that would land on a file already sitting there is refused, not
18
+ overwritten.** Nothing is moved, `plinth status` names it, and Plinth re-offers the
19
+ rename on every poll until the name is free. Where the file in the way belongs to
20
+ another record the message says so and tells you **not** to delete it — deleting it
21
+ would delete that record in Plinth. Where it is a file of yours, rename or move it
22
+ and the rename lands on the next poll.
23
+ - **An unreadable file at a renamed row's old path no longer stalls the whole sync.**
24
+ It used to abort the pull, and in the daemon back that off to a five-minute retry
25
+ indefinitely, leaving the mirror silently stale behind a notice naming no file. Only
26
+ that row is held now, and `plinth status` names the file and says to check its
27
+ permissions.
28
+
29
+ ⚠️ **One boundary changed underneath this release, with no CLI change in it.** Plinth's
30
+ MCP server renamed its calendar tools (`create_calendar_event` and its siblings became
31
+ `create_calendar_entry`), which is what lets the cockpit write calendar entries. The
32
+ side effect is that the generated deny list no longer covers calendar *deletion* on
33
+ Plinth's own server, which it had been doing by accident of the name. No such tool
34
+ exists today, so the hole is latent rather than open. #167 measured this and pinned it
35
+ with tests; no deny pattern was added, removed or edited, and the list stays at 91.
36
+
37
+ The desktop app pins `@plinth-music/cli` exact (0.16.0 as of v1.9.4) and picks none of
38
+ this up until its own release repins.
39
+
5
40
  ## 0.16.0 - 2026-09-02
6
41
 
7
42
  Three code PRs since `0.15.0` (`v0.15.0..5c60627`, #157, #159, #161; #158 and #160 are
package/README.md CHANGED
@@ -23,7 +23,7 @@ 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`).
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "4d6d17c915cb0b2fd774da4ae8876a450a658eb0",
2
+ "sha": "bc49180c1d2e3f217f3eedda18bd075870cc36aa",
3
3
  "dirty": false,
4
- "built_at": "2026-09-02T18:56:56.610Z"
4
+ "built_at": "2026-09-03T21:05:22.806Z"
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.17.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);
@@ -31007,6 +31047,31 @@ function recordEntitySymlinkRefused(args) {
31007
31047
  rejected_at: args.now().toISOString()
31008
31048
  }));
31009
31049
  }
31050
+ function recordEntitySlugClash(args) {
31051
+ const path3 = args.mirrorRelativePath;
31052
+ const withoutStale = removeUnsynced(args.state, "entity", path3).state;
31053
+ const held = `Until then new ${args.entityType} changes are held on this machine.`;
31054
+ 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}`;
31055
+ const existing = (withoutStale.rejected ?? []).find((r) => r.target === "entity" && r.path === path3 && r.reason === "slug_clash");
31056
+ if (existing && existing.message === message)
31057
+ return args.state;
31058
+ return persist(withoutStale, upsertRejected(withoutStale, {
31059
+ target: "entity",
31060
+ op: "upsert",
31061
+ path: path3,
31062
+ status: 0,
31063
+ reason: "slug_clash",
31064
+ message,
31065
+ rejected_at: args.now().toISOString()
31066
+ }));
31067
+ }
31068
+ function clearSlugClash(state, mirrorRelativePaths) {
31069
+ const list = state.rejected ?? [];
31070
+ const next = list.filter((o) => !(o.target === "entity" && o.reason === "slug_clash" && mirrorRelativePaths.includes(o.path)));
31071
+ if (next.length === list.length)
31072
+ return state;
31073
+ return commitState(state, { ...state, rejected: next });
31074
+ }
31010
31075
  function clearSymlinkRefused(state, target, path3) {
31011
31076
  const list = state.rejected ?? [];
31012
31077
  const next = list.filter((o) => !(o.target === target && o.path === path3 && o.reason === "symlink"));
@@ -32727,10 +32792,102 @@ function computeMirrorHashes(strategy, content) {
32727
32792
  function hashableBody(strategy, body) {
32728
32793
  return strategy.stripLocalBlocks?.(body) ?? body;
32729
32794
  }
32730
- function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor) {
32795
+ function relocateForSlugChange(args) {
32796
+ const { workspaceSlug, strategy, fromSlug, toSlug, acked, echo, now } = args;
32797
+ if (fromSlug === toSlug)
32798
+ return { kind: "not_needed", writeSlug: toSlug };
32799
+ if (!echo) {
32800
+ return { kind: "skipped", writeSlug: toSlug, fromSlug, reason: "unguarded" };
32801
+ }
32802
+ if (strategy.mirrorRelativePath(fromSlug).split("/").length !== 2) {
32803
+ return { kind: "skipped", writeSlug: toSlug, fromSlug, reason: "foldered" };
32804
+ }
32805
+ let fromPath;
32806
+ try {
32807
+ fromPath = entityFilePath(workspaceSlug, strategy.type, fromSlug);
32808
+ entityFilePath(workspaceSlug, strategy.type, toSlug);
32809
+ } catch (err) {
32810
+ if (err instanceof MirrorPathError) {
32811
+ return {
32812
+ kind: "skipped",
32813
+ writeSlug: toSlug,
32814
+ fromSlug,
32815
+ reason: "unresolvable"
32816
+ };
32817
+ }
32818
+ throw err;
32819
+ }
32820
+ let raw = null;
32821
+ try {
32822
+ raw = readFileSync15(fromPath, "utf8");
32823
+ } catch (err) {
32824
+ const code = err?.code;
32825
+ if (code === "ENOENT") {
32826
+ return { kind: "absent", writeSlug: toSlug, fromSlug };
32827
+ }
32828
+ return {
32829
+ kind: "refused",
32830
+ writeSlug: fromSlug,
32831
+ fromSlug,
32832
+ reason: "source_unreadable"
32833
+ };
32834
+ }
32835
+ let diverged = true;
32836
+ try {
32837
+ const hashes = computeMirrorHashes(strategy, raw);
32838
+ diverged = hashes.bodyHash !== acked.bodyHash || hashes.frontmatterHash !== acked.frontmatterHash;
32839
+ } catch {
32840
+ diverged = true;
32841
+ }
32842
+ const stashPath = diverged ? stashConflict({
32843
+ workspaceSlug,
32844
+ entityType: strategy.type,
32845
+ entitySlug: fromSlug,
32846
+ timestamp: now(),
32847
+ contents: raw,
32848
+ side: "local"
32849
+ }) : undefined;
32850
+ try {
32851
+ let registered = false;
32852
+ const result = moveEntityFile(workspaceSlug, strategy.type, fromSlug, toSlug, () => {
32853
+ if (echo.watcher === "live") {
32854
+ echo.suppressor.registerDelete(fromPath);
32855
+ registered = true;
32856
+ }
32857
+ });
32858
+ if (result === "moved") {
32859
+ return { kind: "moved", writeSlug: toSlug, fromSlug, stashPath };
32860
+ }
32861
+ if (registered && echo.watcher === "live") {
32862
+ echo.suppressor.cancelDelete(fromPath);
32863
+ }
32864
+ return { kind: "absent", writeSlug: toSlug, fromSlug };
32865
+ } catch (err) {
32866
+ if (err instanceof MirrorDestinationOccupiedError) {
32867
+ return {
32868
+ kind: "refused",
32869
+ writeSlug: fromSlug,
32870
+ fromSlug,
32871
+ reason: "destination_occupied",
32872
+ stashPath
32873
+ };
32874
+ }
32875
+ if (err instanceof MirrorPathError) {
32876
+ return {
32877
+ kind: "skipped",
32878
+ writeSlug: toSlug,
32879
+ fromSlug,
32880
+ reason: "unresolvable"
32881
+ };
32882
+ }
32883
+ throw err;
32884
+ }
32885
+ }
32886
+ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor, pathSlug) {
32887
+ const targetSlug = pathSlug ?? doc2.slug;
32731
32888
  let content = strategy.serialise(doc2);
32732
32889
  if (strategy.preserveLocalBodyOnPull) {
32733
- const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
32890
+ const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
32734
32891
  let existing = null;
32735
32892
  try {
32736
32893
  existing = readFileSync15(target, "utf8");
@@ -32748,10 +32905,10 @@ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor)
32748
32905
  }
32749
32906
  }
32750
32907
  if (suppressor) {
32751
- const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
32908
+ const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
32752
32909
  suppressor.registerWrite(target, computeMirrorHashes(strategy, content));
32753
32910
  }
32754
- writeMirrorFile(workspaceSlug, strategy.type, doc2.slug, content);
32911
+ writeMirrorFile(workspaceSlug, strategy.type, targetSlug, content);
32755
32912
  return content;
32756
32913
  }
32757
32914
 
@@ -32859,7 +33016,8 @@ function cloudMatchesAcked(strategy, cloud, acked) {
32859
33016
  }
32860
33017
  function stampStateFromMirror(args) {
32861
33018
  const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
32862
- const mirrorPath = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
33019
+ const targetSlug = args.pathSlug ?? doc2.slug;
33020
+ const mirrorPath = entityFilePath(workspaceSlug, strategy.type, targetSlug);
32863
33021
  const mirrorRaw = readFileSync16(mirrorPath, "utf8");
32864
33022
  const mirrorParsed = import_gray_matter10.default(mirrorRaw);
32865
33023
  const mirrorFm = mirrorParsed.data ?? {};
@@ -32868,7 +33026,7 @@ function stampStateFromMirror(args) {
32868
33026
  const mirrorHashBody = hashableBody(strategy, mirrorParsed.content);
32869
33027
  const refreshed = {
32870
33028
  id: doc2.id,
32871
- slug: doc2.slug,
33029
+ slug: targetSlug,
32872
33030
  updated_at: doc2.updated_at,
32873
33031
  revision: doc2.revision,
32874
33032
  body_hash: stableHash(mirrorHashBody),
@@ -32879,6 +33037,73 @@ function stampStateFromMirror(args) {
32879
33037
  };
32880
33038
  return persistEntry(state, strategy.type, refreshed);
32881
33039
  }
33040
+ function adoptCloudCopy(args) {
33041
+ const { strategy, workspaceSlug, doc: doc2, output } = args;
33042
+ const now = args.now ?? (() => new Date);
33043
+ let state = args.state;
33044
+ const prior = getEntry(state, strategy.type, doc2.id);
33045
+ let writeSlug = doc2.slug;
33046
+ let refused = false;
33047
+ if (prior && prior.slug !== doc2.slug) {
33048
+ const relocation = relocateForSlugChange({
33049
+ workspaceSlug,
33050
+ strategy,
33051
+ fromSlug: prior.slug,
33052
+ toSlug: doc2.slug,
33053
+ acked: {
33054
+ bodyHash: prior.body_hash,
33055
+ frontmatterHash: prior.frontmatter_hash
33056
+ },
33057
+ echo: args.echo,
33058
+ now
33059
+ });
33060
+ writeSlug = relocation.writeSlug;
33061
+ const move = `entityType=${strategy.type} from=${prior.slug} to=${doc2.slug} id=${doc2.id}`;
33062
+ if (relocation.kind === "moved") {
33063
+ output?.log(`[SLUG_MOVE ${move} source=push]`);
33064
+ state = clearSlugClash(state, [
33065
+ strategy.mirrorRelativePath(prior.slug),
33066
+ strategy.mirrorRelativePath(doc2.slug)
33067
+ ]);
33068
+ } else if (relocation.kind === "refused") {
33069
+ output?.log(`[SLUG_MOVE_REFUSED ${move} reason=${relocation.reason} source=push]`);
33070
+ refused = true;
33071
+ state = recordEntitySlugClash({
33072
+ state,
33073
+ mirrorRelativePath: strategy.mirrorRelativePath(prior.slug),
33074
+ entityType: strategy.type,
33075
+ fromSlug: prior.slug,
33076
+ toSlug: doc2.slug,
33077
+ reason: relocation.reason,
33078
+ occupantTracked: getEntryBySlug(state, strategy.type, doc2.slug) !== undefined,
33079
+ now
33080
+ });
33081
+ } else if (relocation.kind === "skipped") {
33082
+ output?.log(`[SLUG_MOVE_SKIP ${move} reason=${relocation.reason} source=push]`);
33083
+ }
33084
+ if (relocation.kind === "moved" || relocation.kind === "refused") {
33085
+ if (relocation.stashPath) {
33086
+ 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).`);
33087
+ }
33088
+ }
33089
+ }
33090
+ if (refused) {
33091
+ return prior ? persistEntry(state, strategy.type, {
33092
+ ...prior,
33093
+ updated_at: doc2.updated_at,
33094
+ revision: doc2.revision
33095
+ }) : state;
33096
+ }
33097
+ writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, undefined, writeSlug);
33098
+ return stampStateFromMirror({
33099
+ strategy,
33100
+ workspaceSlug,
33101
+ doc: doc2,
33102
+ state,
33103
+ priorMemoryHash: args.priorMemoryHash,
33104
+ pathSlug: writeSlug
33105
+ });
33106
+ }
32882
33107
  async function pullAndWriteMirror(args) {
32883
33108
  const cloud = await getEntity({
32884
33109
  strategy: args.strategy,
@@ -32887,13 +33112,15 @@ async function pullAndWriteMirror(args) {
32887
33112
  id: args.id,
32888
33113
  fetchImpl: args.fetchImpl
32889
33114
  });
32890
- writeMirrorWithBodyPreserve(args.workspaceSlug, args.strategy, cloud);
32891
- return stampStateFromMirror({
33115
+ return adoptCloudCopy({
32892
33116
  strategy: args.strategy,
32893
33117
  workspaceSlug: args.workspaceSlug,
32894
33118
  doc: cloud,
32895
33119
  state: args.state,
32896
- priorMemoryHash: getEntry(args.state, args.strategy.type, args.id)?.memory_hash
33120
+ priorMemoryHash: getEntry(args.state, args.strategy.type, args.id)?.memory_hash,
33121
+ echo: args.echo,
33122
+ output: args.output,
33123
+ now: args.now
32897
33124
  });
32898
33125
  }
32899
33126
  async function pushFile(args) {
@@ -32914,6 +33141,7 @@ async function pushFile(args) {
32914
33141
  const mutex = deps?.mutex ?? new PerIdMutex;
32915
33142
  const output = deps?.output;
32916
33143
  const suppressor = deps?.suppressor;
33144
+ const echo = deps?.echo;
32917
33145
  const strategy = getStrategy(entityType);
32918
33146
  if (!strategy) {
32919
33147
  return {
@@ -32927,6 +33155,8 @@ async function pushFile(args) {
32927
33155
  }
32928
33156
  if (!exists) {
32929
33157
  return runPushDelete({
33158
+ echo,
33159
+ now,
32930
33160
  strategy,
32931
33161
  workspaceSlug,
32932
33162
  baseUrl,
@@ -32945,6 +33175,8 @@ async function pushFile(args) {
32945
33175
  } catch (err) {
32946
33176
  if (err?.code === "ENOENT") {
32947
33177
  return runPushDelete({
33178
+ echo,
33179
+ now,
32948
33180
  strategy,
32949
33181
  workspaceSlug,
32950
33182
  baseUrl,
@@ -33004,6 +33236,7 @@ async function pushFile(args) {
33004
33236
  const stateEntry = identity === "path" ? byInode : byInode ?? getEntryBySlug(state, entityType, slug);
33005
33237
  if (!stateEntry) {
33006
33238
  return runPushCreate({
33239
+ echo,
33007
33240
  strategy,
33008
33241
  workspaceSlug,
33009
33242
  baseUrl,
@@ -33024,6 +33257,7 @@ async function pushFile(args) {
33024
33257
  });
33025
33258
  }
33026
33259
  return runPushUpsert({
33260
+ echo,
33027
33261
  strategy,
33028
33262
  workspaceSlug,
33029
33263
  baseUrl,
@@ -33082,7 +33316,10 @@ async function reconcileContextDocConflict(args) {
33082
33316
  pat,
33083
33317
  id: existingId,
33084
33318
  state,
33085
- fetchImpl
33319
+ fetchImpl,
33320
+ echo: args.echo,
33321
+ output,
33322
+ now
33086
33323
  });
33087
33324
  output?.log(contextDocConflictLine({
33088
33325
  entityType: strategy.type,
@@ -33199,7 +33436,10 @@ async function reconcileContextDocConflict(args) {
33199
33436
  pat,
33200
33437
  id: existingId,
33201
33438
  state,
33202
- fetchImpl
33439
+ fetchImpl,
33440
+ echo: args.echo,
33441
+ output,
33442
+ now
33203
33443
  });
33204
33444
  output?.log(contextDocConflictLine({
33205
33445
  entityType: strategy.type,
@@ -33421,6 +33661,7 @@ async function runPushUpsert(args) {
33421
33661
  }
33422
33662
  if (patchResult.reason === "context_doc_conflict") {
33423
33663
  return reconcileContextDocConflict({
33664
+ echo: args.echo,
33424
33665
  strategy,
33425
33666
  workspaceSlug: args.workspaceSlug,
33426
33667
  baseUrl: args.baseUrl,
@@ -33439,6 +33680,7 @@ async function runPushUpsert(args) {
33439
33680
  }
33440
33681
  if (strategy.fieldTypedConflict) {
33441
33682
  return reconcileTaskConflict({
33683
+ echo: args.echo,
33442
33684
  strategy,
33443
33685
  workspaceSlug: args.workspaceSlug,
33444
33686
  baseUrl: args.baseUrl,
@@ -33469,7 +33711,10 @@ async function runPushUpsert(args) {
33469
33711
  pat: args.pat,
33470
33712
  id: liveEntry.id,
33471
33713
  state: workingState,
33472
- fetchImpl: args.fetchImpl
33714
+ fetchImpl: args.fetchImpl,
33715
+ echo: args.echo,
33716
+ output: args.output,
33717
+ now: args.now
33473
33718
  });
33474
33719
  return {
33475
33720
  outcome: {
@@ -33517,7 +33762,10 @@ async function reconcileTaskConflict(args) {
33517
33762
  pat,
33518
33763
  id: liveEntry.id,
33519
33764
  state,
33520
- fetchImpl
33765
+ fetchImpl,
33766
+ echo: args.echo,
33767
+ output,
33768
+ now
33521
33769
  });
33522
33770
  return {
33523
33771
  outcome: {
@@ -33566,13 +33814,15 @@ async function reconcileTaskConflict(args) {
33566
33814
  });
33567
33815
  if (patchResult.ok) {
33568
33816
  const stashPath2 = stashCloudSide();
33569
- writeMirrorWithBodyPreserve(workspaceSlug, strategy, patchResult.doc);
33570
- state = stampStateFromMirror({
33817
+ state = adoptCloudCopy({
33571
33818
  strategy,
33572
33819
  workspaceSlug,
33573
33820
  doc: patchResult.doc,
33574
33821
  state,
33575
- priorMemoryHash: liveEntry.memory_hash
33822
+ priorMemoryHash: liveEntry.memory_hash,
33823
+ echo: args.echo,
33824
+ output,
33825
+ now
33576
33826
  });
33577
33827
  output?.log(`[PATCH entityType=${strategy.type} slug=${patchResult.doc.slug} id=${patchResult.doc.id} revision=${patchResult.doc.revision} note=field_typed_conflict]`);
33578
33828
  return {
@@ -33604,7 +33854,10 @@ async function reconcileTaskConflict(args) {
33604
33854
  pat,
33605
33855
  id: liveEntry.id,
33606
33856
  state,
33607
- fetchImpl
33857
+ fetchImpl,
33858
+ echo: args.echo,
33859
+ output,
33860
+ now
33608
33861
  });
33609
33862
  return {
33610
33863
  outcome: {
@@ -33641,6 +33894,7 @@ async function runPushCreate(args) {
33641
33894
  const inLock = args.identity === "path" ? undefined : getEntryBySlug(args.state, strategy.type, args.slug);
33642
33895
  if (inLock) {
33643
33896
  return runPushUpsert({
33897
+ echo: args.echo,
33644
33898
  strategy,
33645
33899
  workspaceSlug: args.workspaceSlug,
33646
33900
  baseUrl: args.baseUrl,
@@ -33737,6 +33991,7 @@ async function runPushCreate(args) {
33737
33991
  }
33738
33992
  if (createResult.reason === "context_doc_conflict") {
33739
33993
  return reconcileContextDocConflict({
33994
+ echo: args.echo,
33740
33995
  strategy,
33741
33996
  workspaceSlug: args.workspaceSlug,
33742
33997
  baseUrl: args.baseUrl,
@@ -33768,7 +34023,10 @@ async function runPushCreate(args) {
33768
34023
  pat: args.pat,
33769
34024
  id: createResult.existing.id,
33770
34025
  state: args.state,
33771
- fetchImpl: args.fetchImpl
34026
+ fetchImpl: args.fetchImpl,
34027
+ echo: args.echo,
34028
+ output: args.output,
34029
+ now: args.now
33772
34030
  });
33773
34031
  return {
33774
34032
  outcome: {
@@ -33856,7 +34114,10 @@ async function runPushDelete(args) {
33856
34114
  pat: args.pat,
33857
34115
  id,
33858
34116
  state: args.state,
33859
- fetchImpl: args.fetchImpl
34117
+ fetchImpl: args.fetchImpl,
34118
+ echo: args.echo,
34119
+ output: args.output,
34120
+ now: args.now
33860
34121
  });
33861
34122
  return {
33862
34123
  outcome: {
@@ -33887,6 +34148,7 @@ async function pushRename(args) {
33887
34148
  const now = deps?.now ?? (() => new Date);
33888
34149
  const mutex = deps?.mutex ?? new PerIdMutex;
33889
34150
  const output = deps?.output;
34151
+ const echo = deps?.echo;
33890
34152
  const strategy = requireStrategy(entityType);
33891
34153
  let toSlug;
33892
34154
  try {
@@ -33995,7 +34257,10 @@ async function pushRename(args) {
33995
34257
  pat,
33996
34258
  id: patchResult.existing.id,
33997
34259
  state,
33998
- fetchImpl
34260
+ fetchImpl,
34261
+ echo,
34262
+ output,
34263
+ now
33999
34264
  });
34000
34265
  const finalState = await pullAndWriteMirror({
34001
34266
  strategy,
@@ -34004,7 +34269,10 @@ async function pushRename(args) {
34004
34269
  pat,
34005
34270
  id,
34006
34271
  state: afterColliding,
34007
- fetchImpl
34272
+ fetchImpl,
34273
+ echo,
34274
+ output,
34275
+ now
34008
34276
  });
34009
34277
  return {
34010
34278
  outcome: {
@@ -34036,7 +34304,10 @@ async function pushRename(args) {
34036
34304
  pat,
34037
34305
  id,
34038
34306
  state,
34039
- fetchImpl
34307
+ fetchImpl,
34308
+ echo,
34309
+ output,
34310
+ now
34040
34311
  });
34041
34312
  return {
34042
34313
  outcome: {
@@ -34094,7 +34365,8 @@ async function applyBatch(batch, deps, mutex) {
34094
34365
  fetchImpl: deps.fetchImpl,
34095
34366
  now: deps.now,
34096
34367
  mutex,
34097
- output: deps.output
34368
+ output: deps.output,
34369
+ echo: deps.echo
34098
34370
  }
34099
34371
  });
34100
34372
  deps.setState(result.state);
@@ -34163,7 +34435,10 @@ async function discardBatchOps(batch, deps) {
34163
34435
  pat: deps.pat,
34164
34436
  id: op.id,
34165
34437
  state: deps.getState(),
34166
- fetchImpl: deps.fetchImpl
34438
+ fetchImpl: deps.fetchImpl,
34439
+ echo: deps.echo,
34440
+ output: deps.output,
34441
+ now: deps.now
34167
34442
  });
34168
34443
  deps.setState(next);
34169
34444
  } catch (err) {
@@ -34756,7 +35031,7 @@ function recordFileUnsynced(args) {
34756
35031
  });
34757
35032
  }
34758
35033
  async function drainUnsyncedOps(args) {
34759
- const { workspaceSlug, baseUrl, pat, getState, setState, fetchImpl, mutex, output } = args;
35034
+ const { workspaceSlug, baseUrl, pat, getState, setState, fetchImpl, mutex, output, echo } = args;
34760
35035
  const now = args.now ?? (() => new Date);
34761
35036
  const ops = listUnsynced(getState());
34762
35037
  let attempted = 0;
@@ -34789,7 +35064,7 @@ async function drainUnsyncedOps(args) {
34789
35064
  state: getState(),
34790
35065
  exists: op.op === "upsert",
34791
35066
  unlinkContext: null,
34792
- deps: { fetchImpl, now, mutex, output }
35067
+ deps: { fetchImpl, now, mutex, output, echo }
34793
35068
  });
34794
35069
  setState(result.state);
34795
35070
  } else {
@@ -38304,6 +38579,11 @@ class PullSuppressor {
38304
38579
  expiresAt: this.now() + this.ttlMs
38305
38580
  });
38306
38581
  }
38582
+ cancelDelete(absPath) {
38583
+ const entry2 = this.entries.get(absPath);
38584
+ if (entry2?.kind === "delete")
38585
+ this.entries.delete(absPath);
38586
+ }
38307
38587
  shouldSuppressUpsert(absPath, hashes) {
38308
38588
  const entry2 = this.entries.get(absPath);
38309
38589
  if (!entry2)
@@ -38872,7 +39152,7 @@ async function reconcileDivergedRows(args) {
38872
39152
  state,
38873
39153
  exists: true,
38874
39154
  unlinkContext: null,
38875
- deps: { fetchImpl, now, output }
39155
+ deps: { fetchImpl, now, output, echo: args.echo }
38876
39156
  });
38877
39157
  state = result.state;
38878
39158
  switch (result.outcome.kind) {
@@ -41373,7 +41653,13 @@ async function runSync(workspaceSlug, deps = {}) {
41373
41653
  let cursor = startCursor;
41374
41654
  let lastAdvancedCursor = startCursor;
41375
41655
  let cursorBlocked = false;
41376
- const ctr = { fetched: 0, written: 0, deleted: 0 };
41656
+ const ctr = {
41657
+ fetched: 0,
41658
+ written: 0,
41659
+ deleted: 0,
41660
+ moved: 0,
41661
+ refused: 0
41662
+ };
41377
41663
  while (true) {
41378
41664
  const page = await syncEntity({
41379
41665
  strategy,
@@ -41407,6 +41693,12 @@ async function runSync(workspaceSlug, deps = {}) {
41407
41693
  ctr.written++;
41408
41694
  else if (outcome.action === "deleted")
41409
41695
  ctr.deleted++;
41696
+ else if (outcome.action === "moved")
41697
+ ctr.moved = (ctr.moved ?? 0) + 1;
41698
+ else if (outcome.action === "slug_refused") {
41699
+ ctr.refused = (ctr.refused ?? 0) + 1;
41700
+ cursorBlocked = true;
41701
+ }
41410
41702
  }
41411
41703
  commitPending();
41412
41704
  if (page.next_cursor === null)
@@ -41628,7 +41920,9 @@ async function runSync(workspaceSlug, deps = {}) {
41628
41920
  await syncFileRejections();
41629
41921
  const totalWritten = Object.values(counters).reduce((sum, c) => sum + c.written, 0);
41630
41922
  const totalDeleted = Object.values(counters).reduce((sum, c) => sum + c.deleted, 0);
41631
- if (totalWritten === 0 && totalDeleted === 0) {
41923
+ const totalMoved = Object.values(counters).reduce((sum, c) => sum + (c.moved ?? 0), 0);
41924
+ const totalRefused = Object.values(counters).reduce((sum, c) => sum + (c.refused ?? 0), 0);
41925
+ if (totalWritten === 0 && totalDeleted === 0 && totalMoved === 0 && totalRefused === 0) {
41632
41926
  out.success("Already up to date.");
41633
41927
  return { exitCode: 0, applied: 0 };
41634
41928
  }
@@ -41639,12 +41933,16 @@ async function runSync(workspaceSlug, deps = {}) {
41639
41933
  const parts = [`${c.fetched} fetched`];
41640
41934
  if (c.written > 0)
41641
41935
  parts.push(`${c.written} written`);
41936
+ if ((c.moved ?? 0) > 0)
41937
+ parts.push(`${c.moved} renamed`);
41938
+ if ((c.refused ?? 0) > 0)
41939
+ parts.push(`${c.refused} rename held`);
41642
41940
  if (c.deleted > 0)
41643
41941
  parts.push(`${c.deleted} deleted`);
41644
41942
  out.info(` ${type}: ${parts.join(", ")}`);
41645
41943
  }
41646
41944
  out.success(`Done in ${elapsed}s.`);
41647
- return { exitCode: 0, applied: totalWritten + totalDeleted };
41945
+ return { exitCode: 0, applied: totalWritten + totalMoved + totalDeleted };
41648
41946
  }
41649
41947
  function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen, loggedTombstoneSkips) {
41650
41948
  if (isFrozen?.(strategy.type, row.id)) {
@@ -41704,7 +42002,59 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41704
42002
  if (strategy.shouldMirror && !strategy.shouldMirror(live) && !existingEntry) {
41705
42003
  return { state, action: "skipped" };
41706
42004
  }
41707
- if (existingEntry && existingEntry.updated_at === live.updated_at) {
42005
+ let relocation = { kind: "not_needed", writeSlug: live.slug };
42006
+ if (existingEntry && existingEntry.slug !== live.slug) {
42007
+ const echo = suppressor ? { watcher: "live", suppressor } : { watcher: "none" };
42008
+ relocation = relocateForSlugChange({
42009
+ workspaceSlug,
42010
+ strategy,
42011
+ fromSlug: existingEntry.slug,
42012
+ toSlug: live.slug,
42013
+ acked: {
42014
+ bodyHash: existingEntry.body_hash,
42015
+ frontmatterHash: existingEntry.frontmatter_hash
42016
+ },
42017
+ echo,
42018
+ now
42019
+ });
42020
+ const move = `entityType=${strategy.type} from=${existingEntry.slug} to=${live.slug} id=${live.id}`;
42021
+ switch (relocation.kind) {
42022
+ case "moved":
42023
+ out.log(`[SLUG_MOVE ${move}]`);
42024
+ state = clearSlugClash(state, [
42025
+ strategy.mirrorRelativePath(existingEntry.slug),
42026
+ strategy.mirrorRelativePath(live.slug)
42027
+ ]);
42028
+ break;
42029
+ case "refused":
42030
+ out.log(`[SLUG_MOVE_REFUSED ${move} reason=${relocation.reason}]`);
42031
+ const occupantTracked = getEntryBySlug(state, strategy.type, live.slug) !== undefined;
42032
+ state = recordEntitySlugClash({
42033
+ state,
42034
+ mirrorRelativePath: strategy.mirrorRelativePath(existingEntry.slug),
42035
+ entityType: strategy.type,
42036
+ fromSlug: existingEntry.slug,
42037
+ toSlug: live.slug,
42038
+ reason: relocation.reason,
42039
+ occupantTracked,
42040
+ now
42041
+ });
42042
+ 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.`);
42043
+ return { state, action: "slug_refused" };
42044
+ case "skipped":
42045
+ out.log(`[SLUG_MOVE_SKIP ${move} reason=${relocation.reason}]`);
42046
+ break;
42047
+ case "absent":
42048
+ case "not_needed":
42049
+ break;
42050
+ }
42051
+ const stashPath = relocation.kind === "moved" ? relocation.stashPath : undefined;
42052
+ if (stashPath) {
42053
+ out.warn(`Unsynced local edit on ${strategy.type}/${existingEntry.slug} → stashed ` + `to ${stashPath} (the row was renamed in Plinth; the cloud copy is ` + `authoritative).`);
42054
+ }
42055
+ }
42056
+ const writeSlug = relocation.writeSlug;
42057
+ if (existingEntry && existingEntry.updated_at === live.updated_at && relocation.kind === "not_needed") {
41708
42058
  const path5 = entityFilePath(workspaceSlug, strategy.type, live.slug);
41709
42059
  if (existsSync7(path5)) {
41710
42060
  if (strategy.type === "artists") {
@@ -41730,10 +42080,10 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41730
42080
  return { state, action: "noop_overwrite" };
41731
42081
  }
41732
42082
  }
41733
- if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
42083
+ if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at && relocation.kind !== "moved") {
41734
42084
  let localRaw = null;
41735
42085
  try {
41736
- localRaw = readFileSync24(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
42086
+ localRaw = readFileSync24(entityFilePath(workspaceSlug, strategy.type, writeSlug), "utf8");
41737
42087
  } catch {
41738
42088
  localRaw = null;
41739
42089
  }
@@ -41753,11 +42103,11 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41753
42103
  }
41754
42104
  }
41755
42105
  }
41756
- const content = writeMirrorWithBodyPreserve(workspaceSlug, strategy, live, suppressor);
42106
+ const content = writeMirrorWithBodyPreserve(workspaceSlug, strategy, live, suppressor, writeSlug);
41757
42107
  const hashes = computeMirrorHashes(strategy, content);
41758
42108
  let inode;
41759
42109
  try {
41760
- inode = statSync16(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
42110
+ inode = statSync16(entityFilePath(workspaceSlug, strategy.type, writeSlug)).ino;
41761
42111
  } catch {
41762
42112
  inode = undefined;
41763
42113
  }
@@ -41780,7 +42130,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41780
42130
  }
41781
42131
  const { state: nextState } = upsertEntry(state, strategy.type, {
41782
42132
  id: live.id,
41783
- slug: live.slug,
42133
+ slug: writeSlug,
41784
42134
  updated_at: live.updated_at,
41785
42135
  revision: live.revision,
41786
42136
  body_hash: hashes.bodyHash,
@@ -41790,7 +42140,8 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
41790
42140
  memory_hash: memoryHash,
41791
42141
  memory_generated: memoryGenerated
41792
42142
  });
41793
- return { state: nextState, action: "written" };
42143
+ const action = relocation.kind === "moved" ? "moved" : "written";
42144
+ return { state: nextState, action };
41794
42145
  }
41795
42146
  var syncCommand = defineCommand({
41796
42147
  meta: {
@@ -42266,6 +42617,22 @@ function makeWatchHandler(deps) {
42266
42617
  });
42267
42618
  if (!classifiedFrom)
42268
42619
  return;
42620
+ if (suppressor?.shouldSuppressDelete(event.fromPath)) {
42621
+ const strategy = getStrategy(classifiedFrom.entityType);
42622
+ let toRaw = null;
42623
+ try {
42624
+ toRaw = readFileSync25(event.toPath, "utf8");
42625
+ } catch {
42626
+ toRaw = null;
42627
+ }
42628
+ const isEcho = strategy !== undefined && toRaw !== null && suppressor.shouldSuppressUpsert(event.toPath, computeMirrorHashes(strategy, toRaw));
42629
+ if (isEcho) {
42630
+ output?.log(`[RENAME_SUPPRESSED entityType=${classifiedFrom.entityType} ` + `from=${event.fromSlug} id=${event.id} reason=pull_echo]`);
42631
+ return;
42632
+ }
42633
+ await handle({ kind: "upsert", absolutePath: event.toPath });
42634
+ return;
42635
+ }
42269
42636
  const result2 = await pushRename({
42270
42637
  workspaceSlug,
42271
42638
  baseUrl,
@@ -42277,7 +42644,13 @@ function makeWatchHandler(deps) {
42277
42644
  expectedUpdatedAt: event.expectedUpdatedAt,
42278
42645
  fromSlug: event.fromSlug,
42279
42646
  state: getState(),
42280
- deps: { fetchImpl, now, mutex, output }
42647
+ deps: {
42648
+ fetchImpl,
42649
+ now,
42650
+ mutex,
42651
+ output,
42652
+ echo: suppressor ? { watcher: "live", suppressor } : undefined
42653
+ }
42281
42654
  });
42282
42655
  setState(result2.state);
42283
42656
  reportOutcome(spinner, workspaceSlug, result2.state, result2.outcome);
@@ -42409,7 +42782,14 @@ function makeWatchHandler(deps) {
42409
42782
  slug: event.stateEntry.slug,
42410
42783
  updated_at: event.stateEntry.updated_at
42411
42784
  } : null,
42412
- deps: { fetchImpl, now, mutex, output, suppressor }
42785
+ deps: {
42786
+ fetchImpl,
42787
+ now,
42788
+ mutex,
42789
+ output,
42790
+ suppressor,
42791
+ echo: suppressor ? { watcher: "live", suppressor } : undefined
42792
+ }
42413
42793
  });
42414
42794
  setState(clearEntityRejected(result.state, workspaceSlug, absolutePath));
42415
42795
  reportOutcome(spinner, workspaceSlug, getState(), result.outcome);
@@ -42470,7 +42850,8 @@ async function flushHeldEntityOp(deps) {
42470
42850
  fetchImpl: deps.fetchImpl,
42471
42851
  now,
42472
42852
  mutex: deps.mutex,
42473
- output: deps.output
42853
+ output: deps.output,
42854
+ echo: deps.echo
42474
42855
  }
42475
42856
  });
42476
42857
  deps.setState(result.state);
@@ -42606,6 +42987,7 @@ async function runStart(workspaceSlug, deps = {}) {
42606
42987
  frozenFiles = computeFrozenFiles(slug);
42607
42988
  };
42608
42989
  const recon = await reconcileDivergedRows({
42990
+ echo: { watcher: "none" },
42609
42991
  workspaceSlug: slug,
42610
42992
  baseUrl: entry2.api_base_url,
42611
42993
  pat,
@@ -42819,6 +43201,7 @@ async function runStart(workspaceSlug, deps = {}) {
42819
43201
  spinner.start(`Watching ${slug} · ${watchingSummary(state)}`);
42820
43202
  const mutex = new PerIdMutex;
42821
43203
  const bootDrain = await drainResolvedBatches({
43204
+ echo: { watcher: "none" },
42822
43205
  workspaceSlug: slug,
42823
43206
  baseUrl: entry2.api_base_url,
42824
43207
  pat,
@@ -42836,6 +43219,7 @@ async function runStart(workspaceSlug, deps = {}) {
42836
43219
  out.info(`Resolved ${bootDrain.applied + bootDrain.discarded} held batch${bootDrain.applied + bootDrain.discarded === 1 ? "" : "es"}: ${bootDrain.applied} applied, ${bootDrain.discarded} discarded.`);
42837
43220
  }
42838
43221
  const bootUnsynced = await drainUnsyncedOps({
43222
+ echo: { watcher: "none" },
42839
43223
  workspaceSlug: slug,
42840
43224
  baseUrl: entry2.api_base_url,
42841
43225
  pat,
@@ -42873,6 +43257,7 @@ async function runStart(workspaceSlug, deps = {}) {
42873
43257
  };
42874
43258
  const suppressor = new PullSuppressor;
42875
43259
  const flushHeldOp = (op) => flushHeldEntityOp({
43260
+ echo: { watcher: "live", suppressor },
42876
43261
  workspaceSlug: slug,
42877
43262
  baseUrl: entry2.api_base_url,
42878
43263
  pat,
@@ -43060,6 +43445,7 @@ async function runStart(workspaceSlug, deps = {}) {
43060
43445
  draining = true;
43061
43446
  try {
43062
43447
  const drained = await drainResolvedBatches({
43448
+ echo: { watcher: "live", suppressor },
43063
43449
  workspaceSlug: slug,
43064
43450
  baseUrl: entry2.api_base_url,
43065
43451
  pat,
@@ -43077,6 +43463,7 @@ async function runStart(workspaceSlug, deps = {}) {
43077
43463
  spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
43078
43464
  }
43079
43465
  const redriven = await drainUnsyncedOps({
43466
+ echo: { watcher: "live", suppressor },
43080
43467
  workspaceSlug: slug,
43081
43468
  baseUrl: entry2.api_base_url,
43082
43469
  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.17.0",
4
4
  "description": "Plinth capstone sync daemon and CLI",
5
5
  "type": "module",
6
6
  "private": false,