@plinth-music/cli 0.8.0 → 0.9.1

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,129 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.9.1 — 2026-08-25
6
+
7
+ Seven PRs since `0.9.0` (`v0.9.0..1efa7d7`, #98–#104). Every one of them is the
8
+ same bug seen from a different side: the daemon treated a failed compare-and-swap
9
+ as proof that the cloud content had changed, and then let the cloud win. It had
10
+ not changed. Fixes only, all client-side; there is no server prerequisite and a
11
+ 0.9.0 client talks to the same API. Upgrading is strongly recommended — the
12
+ defects below lose user work silently, and `plinth status` said "All local writes
13
+ synced" the whole time.
14
+
15
+ ### ⚠️ A cloud write that touched only a timestamp no longer costs you your edit
16
+
17
+ This is the one to read. The CAS token is `updated_at`, so **any** write that moves
18
+ that column fails the precondition — including a server-side backfill that changes
19
+ no bytes at all. On 24 Aug a backfill moved `updated_at` on 161 of 307 live rows to
20
+ the same microsecond; every later local edit to one of those rows was stashed out
21
+ of sight into `~/.plinth/_conflicts/` and overwritten with a cloud copy that was
22
+ byte-identical to what the daemon had already acknowledged.
23
+
24
+ A 409 is now a question rather than a verdict: the daemon asks the row what its
25
+ content hash is, compares it with the hash it last acked, and re-pushes when they
26
+ match. Nothing is stashed and nothing is overwritten, because there was no conflict
27
+ to reconcile. A genuine cloud edit still conflicts, still stashes, and still wins —
28
+ that half is unchanged and is pinned by its own tests.
29
+
30
+ Fixed on every path, not just the one that bit: `files/` rows, entity rows, tasks,
31
+ and the three singleton lanes (`user-memory.md`, `workspace-rules.md`,
32
+ `workspace-memory.md`), which shared an engine that had been missed.
33
+
34
+ ### ⚠️ A local status, priority or due-date edit is no longer silently discarded
35
+
36
+ Found while fixing the above. On an entity conflict the retry dropped the
37
+ structured fields the first attempt had carried, as "cloud's win" — so a metadata
38
+ bump on a row you had just re-prioritised threw the re-prioritisation away with no
39
+ stash and no record. A bump is not a conflict, so it no longer gets a conflict's
40
+ resolution.
41
+
42
+ ### ⚠️ `plinth login` no longer wipes your sync cursors, and `--reset-sync` is new
43
+
44
+ Logging in again to the same workspace is a token rotation, not a re-registration,
45
+ and it now keeps your cursors — the next sync resumes instead of re-pulling the
46
+ world. Cursors are still cleared when the token belongs to a different workspace or
47
+ user, and login now says which happened rather than leaving you to guess.
48
+
49
+ That removed the only way to force a full re-pull, so `--reset-sync` restores it as
50
+ something you ask for. Use it after rebuilding a mirror from scratch; without it, a
51
+ mirror rebuilt from an empty ledger would never refill, silently, behind a green
52
+ sync.
53
+
54
+ ### Two files saved at the same time no longer lose one file's bookkeeping
55
+
56
+ Every write of `state.json` used to land on a snapshot taken before the network
57
+ call that preceded it, so two saves a moment apart — or a scheduled pull arriving
58
+ while a push was in flight — could leave the file missing one of the two
59
+ acknowledgements. The losing write's record simply vanished, and the next edit to
60
+ that file hit the defect above through a door the fix could not close.
61
+
62
+ State is now merged at write time rather than overwritten, an acknowledgement can
63
+ never be committed over a newer one, and a cursor is never advanced ahead of the
64
+ rows it describes — so an interrupted pull leaves cursors, state and disk agreeing
65
+ rather than stranding rows that would never be delivered again. HTTP concurrency is
66
+ untouched; the cost is about 2 ms per state write.
67
+
68
+ ### `plinth status` shows conflict stashes
69
+
70
+ The Conflicts block lists how many local copies are set aside in
71
+ `~/.plinth/_conflicts/<workspace>/`, names the newest, and says what to do with
72
+ them. They had been accumulating unseen since May.
73
+
74
+ ### Proven against production
75
+
76
+ The changes above were reproduced end to end against a live workspace before this
77
+ release rather than only in tests: a metadata-only bump followed by a local edit
78
+ survives on both the file and entity paths, two overlapping pushes both keep their
79
+ acknowledgement, and — the part that makes the rest mean anything — a genuine cloud
80
+ content change still conflicts and still stashes under otherwise identical
81
+ conditions. The pull-ordering half and the three singleton lanes remain
82
+ test-proven only; both are named in the repo's `CLAUDE.md`.
83
+
84
+ ## 0.9.0 — 2026-08-24
85
+
86
+ Two PRs since `0.8.0` (`v0.8.0..4842d40`). An agent is now told to read a draft
87
+ back from the tool that staged it before calling it ready. No server prerequisite:
88
+ the change is client-side, and a 0.8.0 client is unaffected.
89
+
90
+ ### An agent reads its own draft back before saying it is ready
91
+
92
+ Primitive (e)'s outbound group gains a paragraph. The voice guidance added on
93
+ 20 Aug already told the agent that nothing inspects a draft between it and the
94
+ reader, then aimed that observation only at the WORDS. This is the other half:
95
+ inspect the artefact. Fetch the staged draft, look at its body, its recipients
96
+ and the thread it landed on, and only then say it is ready. A tool's own
97
+ description of how it handles your text is a claim like any other, and the first
98
+ use in a session is checked against what actually came out.
99
+
100
+ **The gap was the trigger, not the rule.** The workspace `CLAUDE.md` already
101
+ carried "where a document and the running system disagree, the system wins", and
102
+ it did not fire — a line describing tool usage does not read like a claim needing
103
+ verification. So this ships as a concrete read-back step rather than as another
104
+ principle the agent grades itself against.
105
+
106
+ Found by ordinary management work rather than by a sweep. An agent staged a mail
107
+ reply whose body was visible `<p>` and `<b>` markup and reported it staged; the
108
+ user found it in his own mailbox. The cause was not the tool but prose describing
109
+ it — a rule said a local helper "handles `text/html`", true of what that script
110
+ emits and false of what it takes. Two more defects of the same shape surfaced
111
+ while repairing it: an update path that silently strips `In-Reply-To` and
112
+ `References`, and a re-run that duplicates a draft rather than replacing it.
113
+
114
+ The prose deliberately names no email tool, and a test pins that: Plinth ships
115
+ none and cannot know what the user has. Grounding budget 9,050 → 9,944 bytes,
116
+ all of it in (e); primitive (j) held at 1,266 for the seventh time.
117
+
118
+ Honest limit, recorded rather than argued away: this is prose, and prose gets
119
+ skipped. The durable fix is a Plinth-owned outbound draft path that owns escaping
120
+ and threading in code for every user, at which point this paragraph becomes a
121
+ backstop rather than the surface.
122
+
123
+ ### Housekeeping
124
+
125
+ The test baseline in `CLAUDE.md` now reads 2052 pass / 0 fail / 107 files at
126
+ `b4d8ed3`, re-measured on a clean checkout rather than carried forward.
127
+
5
128
  ## 0.8.0 — 2026-08-23
6
129
 
7
130
  Two PRs and two direct commits since `0.7.0` (`v0.7.0..af83c59`). The daemon
package/README.md CHANGED
@@ -22,10 +22,10 @@ plinth start # run the daemon: continuous pull + watch + pu
22
22
 
23
23
  ## Commands
24
24
 
25
- - `plinth login --workspace <slug>` — issue a personal access token in the browser, paste it back, store it in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager).
25
+ - `plinth login --workspace <slug> [--reset-sync]` — issue a personal access token in the browser, paste it back, store it in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager). Logging in again to the same workspace is a token rotation and **keeps your sync cursors**, so the next sync picks up where the last one left off; it clears them only when the token belongs to a different workspace or user. `--reset-sync` discards them deliberately, making the next sync a full re-pull — use it after rebuilding a mirror from scratch, which is otherwise unreachable now that rotation preserves them.
26
26
  - `plinth sync [--workspace <slug>]` — one-shot pull into `~/Plinth/<workspace>/`. Also generates the workspace-root `CLAUDE.md` (a thin TOC + entity model + skill pointers), an empty `CLAUDE.local.md` stub on first sync, and the two session-boundary commands (below), and silently refreshes `CLAUDE.md` on a schema-version change. Defaults to the active workspace if `--workspace` is omitted.
27
27
  - `plinth start` — run the long-running sync daemon: pull the latest, then watch the mirror for local edits and push them. It also projects each mapped artist's Google calendar into `views/calendar/` in the mirror — a file per artist plus an `upcoming.md` — on its own 15-minute timer (`PLINTH_CALENDAR_INTERVAL_MS`), with one run at boot. That is deliberately not the 15-second pull cadence: one tick makes a Google call per mapped calendar, which would breach the server route's rate limit inside a minute. A refresh that fails keeps the last known bodies and rewrites only the header, so a stale view says it is stale rather than reading as an empty diary. Logs to `~/.plinth/daemon.log`.
28
- - `plinth status` — local sync state: daemon liveness, last sync and per-type counts, and any destructive batches being held. Warns when a source install's `dist/` is behind its checkout.
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, and any conflict stashes in `~/.plinth/_conflicts/<workspace>/` — local copies that were set aside when the cloud version won. Warns when a source install's `dist/` is behind its checkout.
29
29
  - `plinth confirm` — review and release destructive batches the daemon has quarantined (apply, or `--discard`).
30
30
  - `plinth refresh-context [--workspace <slug>]` — force-regenerate the workspace-root `CLAUDE.md` **and the two generated commands** from the current Plinth schema + workspace. The user-customisable block between the `<!-- BEGIN: user-customisable -->` / `<!-- END -->` markers is always preserved verbatim in every generated file; `CLAUDE.local.md` is never touched.
31
31
  - `plinth grounding` — print the session-start grounding block (current date, entity resolution, write confirmation, workspace rules, workspace memory, user memory). Invoked by the generated Claude Code SessionStart hook.
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "4a3be5d977539351a0cc5c0adc5617cb706b676b",
2
+ "sha": "4c34a869837b50a27a80e0a4adbf349d146298fa",
3
3
  "dirty": false,
4
- "built_at": "2026-08-23T20:04:35.148Z"
4
+ "built_at": "2026-08-25T14:49:33.553Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -9193,7 +9193,7 @@ async function runMain(cmd, opts = {}) {
9193
9193
  // package.json
9194
9194
  var package_default = {
9195
9195
  name: "@plinth-music/cli",
9196
- version: "0.8.0",
9196
+ version: "0.9.1",
9197
9197
  description: "Plinth capstone sync daemon and CLI",
9198
9198
  type: "module",
9199
9199
  private: false,
@@ -26007,12 +26007,28 @@ function writeAtomic(config2) {
26007
26007
  throw err;
26008
26008
  }
26009
26009
  }
26010
- function upsertWorkspace(slug, entry) {
26010
+ function sameWorkspaceIdentity(a2, b2) {
26011
+ return a2.workspace_id === b2.workspace_id && a2.user_id === b2.user_id && a2.api_base_url === b2.api_base_url;
26012
+ }
26013
+ function carryForwardSyncState(prior, entry) {
26014
+ if (!prior || !sameWorkspaceIdentity(prior, entry))
26015
+ return entry;
26016
+ return {
26017
+ ...entry,
26018
+ last_synced_at: prior.last_synced_at,
26019
+ ...prior.sync_cursors !== undefined ? { sync_cursors: prior.sync_cursors } : {}
26020
+ };
26021
+ }
26022
+ function upsertWorkspace(slug, entry, options2 = {}) {
26011
26023
  const existing = loadConfig() ?? emptyConfig(slug);
26024
+ const prior = options2.resetSyncState === true ? undefined : existing.workspaces[slug];
26012
26025
  const next = {
26013
26026
  ...existing,
26014
26027
  active_workspace: slug,
26015
- workspaces: { ...existing.workspaces, [slug]: entry }
26028
+ workspaces: {
26029
+ ...existing.workspaces,
26030
+ [slug]: carryForwardSyncState(prior, entry)
26031
+ }
26016
26032
  };
26017
26033
  writeAtomic(next);
26018
26034
  return next;
@@ -26916,7 +26932,9 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
26916
26932
  ``,
26917
26933
  `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 and forwarding are not yours, and neither is any shell command line ` + `that does the same thing. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, nothing changes ` + `except that you are the only thing holding it. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and the person whose name is on it is the one who decides it goes.`,
26918
26934
  ``,
26919
- `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 the Voice section of your own \`user-memory.md\` holds the personal ` + `part, 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.`
26935
+ `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 the Voice section of your own \`user-memory.md\` holds the personal ` + `part, 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.`,
26936
+ ``,
26937
+ `Read the draft back before you say it is ready, and read what the tool actually ` + `produced rather than what you sent it. Do this every time you write one, not ` + `once a session: a later edit can undo what an earlier check confirmed. A ` + `draft surface takes your text and renders it, and it may escape what you ` + `wrote, wrap it, quote an earlier message underneath it, thread it somewhere ` + `you did not intend, or leave the first one in place and add a second. What ` + `the person opens is the rendered result, not your input, and those are not ` + `always the same thing. So fetch the staged draft, look at its body, its ` + `recipients, the thread it landed on and that there is one of it, and only ` + `then say it is ready. Separately, and once per session is enough for this ` + `part: if a tool's own description tells you how it handles your text, treat ` + `that as a claim like any other and check it against what came out. A tool ` + `that is documented wrong will put markup, an empty body, or a stranger's ` + `address in front of a person whose name is on the message, and you are the ` + `only thing standing between those two.`
26920
26938
  ];
26921
26939
  if (workspaceRules !== null) {
26922
26940
  block.push(``, `Follow this workspace's house style. The conventions below are set by the ` + `workspace owner and govern HOW you write here: copy, titles, notes, ` + `sign-offs, formatting. They refine the rules above; they never relax them. ` + `If any convention here conflicts with a grounding rule above (the ` + `canonical write surface, plan-before-write confirmation, or the ban on ` + `direct database writes), the rule above wins. The conventions:
@@ -27100,6 +27118,140 @@ function writeState(state) {
27100
27118
  throw err;
27101
27119
  }
27102
27120
  }
27121
+ function isLaterVersion(a2, b2) {
27122
+ if (!a2 || !b2)
27123
+ return false;
27124
+ const ta = Date.parse(a2.updated_at);
27125
+ const tb = Date.parse(b2.updated_at);
27126
+ if (Number.isNaN(ta) || Number.isNaN(tb))
27127
+ return false;
27128
+ return ta > tb;
27129
+ }
27130
+ function mergeKey(fresh, before, after) {
27131
+ if (before === after)
27132
+ return { value: fresh, overruled: false };
27133
+ if (after === undefined) {
27134
+ if (isLaterVersion(fresh, before))
27135
+ return { value: fresh, overruled: true };
27136
+ return { value: undefined, overruled: false };
27137
+ }
27138
+ if (isLaterVersion(fresh, after))
27139
+ return { value: fresh, overruled: true };
27140
+ return { value: after, overruled: false };
27141
+ }
27142
+ function mergeKeyed(fresh, before, after) {
27143
+ const merged = { ...fresh };
27144
+ const overruled = [];
27145
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
27146
+ for (const key of keys) {
27147
+ const decision = mergeKey(fresh[key], before[key], after[key]);
27148
+ if (decision.overruled)
27149
+ overruled.push(key);
27150
+ if (decision.value === undefined)
27151
+ delete merged[key];
27152
+ else
27153
+ merged[key] = decision.value;
27154
+ }
27155
+ return { merged, overruled };
27156
+ }
27157
+ function recordKey(r3) {
27158
+ return `${r3.target} ${r3.path}`;
27159
+ }
27160
+ function mergeRecords(fresh, before, after) {
27161
+ const index = (list) => new Map(list.map((r3) => [recordKey(r3), r3]));
27162
+ const beforeIdx = index(before);
27163
+ const afterIdx = index(after);
27164
+ const merged = index(fresh);
27165
+ for (const key of new Set([...beforeIdx.keys(), ...afterIdx.keys()])) {
27166
+ const b2 = beforeIdx.get(key);
27167
+ const a2 = afterIdx.get(key);
27168
+ if (b2 === a2)
27169
+ continue;
27170
+ if (a2 === undefined)
27171
+ merged.delete(key);
27172
+ else
27173
+ merged.set(key, a2);
27174
+ }
27175
+ return Array.from(merged.values());
27176
+ }
27177
+ function mergedList(fresh, before, after) {
27178
+ const merged = mergeRecords(fresh ?? [], before ?? [], after ?? []);
27179
+ if (merged.length > 0)
27180
+ return merged;
27181
+ return fresh === undefined && after === undefined ? undefined : merged;
27182
+ }
27183
+ function mergeScalar(fresh, before, after) {
27184
+ return before === after ? fresh : after;
27185
+ }
27186
+ function mergeVersioned(fresh, before, after) {
27187
+ if (before === after)
27188
+ return { value: fresh, overruled: false };
27189
+ if (fresh?.updated_at && after?.updated_at) {
27190
+ const tf = Date.parse(fresh.updated_at);
27191
+ const ta = Date.parse(after.updated_at);
27192
+ if (!Number.isNaN(tf) && !Number.isNaN(ta) && tf > ta) {
27193
+ return { value: fresh, overruled: true };
27194
+ }
27195
+ }
27196
+ return { value: after, overruled: false };
27197
+ }
27198
+ function commitState(before, after) {
27199
+ const fresh = loadState(after.workspace_slug);
27200
+ const entityTypes = new Set([
27201
+ ...Object.keys(fresh.entries),
27202
+ ...Object.keys(before.entries),
27203
+ ...Object.keys(after.entries)
27204
+ ]);
27205
+ const entries = {};
27206
+ const overruledEntries = [];
27207
+ for (const entityType of entityTypes) {
27208
+ const { merged: merged2, overruled } = mergeKeyed(fresh.entries[entityType] ?? {}, before.entries[entityType] ?? {}, after.entries[entityType] ?? {});
27209
+ if (Object.keys(merged2).length > 0 || fresh.entries[entityType] !== undefined) {
27210
+ entries[entityType] = merged2;
27211
+ }
27212
+ for (const id of overruled)
27213
+ overruledEntries.push({ entityType, id });
27214
+ }
27215
+ const files = mergeKeyed(fresh.files ?? {}, before.files ?? {}, after.files ?? {});
27216
+ const mergedFiles = Object.keys(files.merged).length > 0 || fresh.files !== undefined || after.files !== undefined ? files.merged : undefined;
27217
+ const rules = mergeVersioned(fresh.workspace_rules, before.workspace_rules, after.workspace_rules);
27218
+ const memory = mergeVersioned(fresh.workspace_memory, before.workspace_memory, after.workspace_memory);
27219
+ const userMemory = mergeVersioned(fresh.user_memory, before.user_memory, after.user_memory);
27220
+ const merged = {
27221
+ ...fresh,
27222
+ version: 2,
27223
+ workspace_slug: after.workspace_slug,
27224
+ entries,
27225
+ files: mergedFiles,
27226
+ unsynced: mergedList(fresh.unsynced, before.unsynced, after.unsynced),
27227
+ rejected: mergedList(fresh.rejected, before.rejected, after.rejected),
27228
+ workspace_rules: rules.value,
27229
+ workspace_memory: memory.value,
27230
+ user_memory: userMemory.value,
27231
+ derived_text_version: mergeScalar(fresh.derived_text_version, before.derived_text_version, after.derived_text_version)
27232
+ };
27233
+ writeState(merged);
27234
+ const singletonOverruled = rules.overruled || memory.overruled || userMemory.overruled;
27235
+ if (overruledEntries.length === 0 && files.overruled.length === 0 && !singletonOverruled) {
27236
+ return after;
27237
+ }
27238
+ let corrected = after;
27239
+ if (rules.overruled)
27240
+ corrected = { ...corrected, workspace_rules: rules.value };
27241
+ if (memory.overruled)
27242
+ corrected = { ...corrected, workspace_memory: memory.value };
27243
+ if (userMemory.overruled)
27244
+ corrected = { ...corrected, user_memory: userMemory.value };
27245
+ for (const { entityType, id } of overruledEntries) {
27246
+ const committed = merged.entries[entityType]?.[id];
27247
+ corrected = committed ? upsertEntry(corrected, entityType, committed).state : removeEntry(corrected, entityType, id).state;
27248
+ }
27249
+ for (const path3 of files.overruled) {
27250
+ const committed = merged.files?.[path3];
27251
+ corrected = committed ? upsertFileEntry(corrected, committed).state : removeFileEntry(corrected, path3).state;
27252
+ }
27253
+ return corrected;
27254
+ }
27103
27255
  function upsertEntry(state, entityType, entry) {
27104
27256
  const bucket = state.entries[entityType] ?? {};
27105
27257
  const existing = bucket[entry.id];
@@ -27796,12 +27948,22 @@ var macKeychain = {
27796
27948
 
27797
27949
  // src/commands/login.ts
27798
27950
  var DEFAULT_API_BASE_URL = "https://app.plinthmusic.com";
27951
+ function identityDifference(prior, entry2) {
27952
+ if (prior.workspace_id !== entry2.workspace_id)
27953
+ return "a different workspace";
27954
+ if (prior.user_id !== entry2.user_id)
27955
+ return "a different user";
27956
+ return "a different server";
27957
+ }
27958
+ function storedCursorCount(entry2) {
27959
+ return Object.keys(entry2.sync_cursors ?? {}).length;
27960
+ }
27799
27961
  function defaultOpenBrowser(url2) {
27800
27962
  const child = spawn("open", [url2], { stdio: "ignore", detached: true });
27801
27963
  child.on("error", () => {});
27802
27964
  child.unref();
27803
27965
  }
27804
- async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
27966
+ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}, options2 = {}) {
27805
27967
  const out = deps.stdout ?? process.stdout;
27806
27968
  const err = deps.stderr ?? process.stderr;
27807
27969
  const keychain = deps.keychain ?? macKeychain;
@@ -27850,13 +28012,22 @@ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
27850
28012
  `);
27851
28013
  return 1;
27852
28014
  }
28015
+ const entry2 = {
28016
+ workspace_id: principal.workspace_id,
28017
+ user_id: principal.user_id,
28018
+ api_base_url: base2,
28019
+ last_synced_at: null,
28020
+ workspace_name: principal.workspace_name
28021
+ };
28022
+ let prior;
27853
28023
  try {
27854
- upsertWorkspace(workspaceSlug, {
27855
- workspace_id: principal.workspace_id,
27856
- user_id: principal.user_id,
27857
- api_base_url: base2,
27858
- last_synced_at: null,
27859
- workspace_name: principal.workspace_name
28024
+ prior = loadConfig()?.workspaces[workspaceSlug];
28025
+ } catch {
28026
+ prior = undefined;
28027
+ }
28028
+ try {
28029
+ upsertWorkspace(workspaceSlug, entry2, {
28030
+ resetSyncState: options2.resetSync === true
27860
28031
  });
27861
28032
  } catch (e2) {
27862
28033
  keychain.delete(workspaceSlug, principal.user_id);
@@ -27865,7 +28036,15 @@ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
27865
28036
  `);
27866
28037
  return 1;
27867
28038
  }
27868
- out.write(`Logged in to ${workspaceSlug} as ${principal.profile.email} ` + `(token ending …${patLastFour(pat)}). ` + `Run \`plinth sync\` to begin syncing.
28039
+ let note = "Run `plinth sync` to begin syncing.";
28040
+ if (prior !== undefined && storedCursorCount(prior) > 0) {
28041
+ if (options2.resetSync === true) {
28042
+ note = "Sync cursors cleared as asked - the next `plinth sync` is a full re-pull.";
28043
+ } else {
28044
+ note = sameWorkspaceIdentity(prior, entry2) ? "Sync cursors kept - the next sync picks up where the last one left off." : `The stored entry was for ${identityDifference(prior, entry2)}, so sync ` + "cursors were cleared - the next `plinth sync` is a full re-pull.";
28045
+ }
28046
+ }
28047
+ out.write(`Logged in to ${workspaceSlug} as ${principal.profile.email} ` + `(token ending …${patLastFour(pat)}). ${note}
27869
28048
  `);
27870
28049
  return 0;
27871
28050
  }
@@ -27879,11 +28058,17 @@ var loginCommand = defineCommand({
27879
28058
  type: "string",
27880
28059
  description: "Workspace slug to log in to",
27881
28060
  required: true
28061
+ },
28062
+ "reset-sync": {
28063
+ type: "boolean",
28064
+ description: "Discard stored sync cursors so the next sync re-pulls everything. " + "Use after rebuilding a mirror from scratch."
27882
28065
  }
27883
28066
  },
27884
28067
  async run({ args }) {
27885
28068
  const baseUrl = process.env.PLINTH_API_URL ?? DEFAULT_API_BASE_URL;
27886
- const code = await runLogin(args.workspace, baseUrl);
28069
+ const code = await runLogin(args.workspace, baseUrl, {}, {
28070
+ resetSync: args["reset-sync"] === true
28071
+ });
27887
28072
  if (code !== 0)
27888
28073
  process.exit(code);
27889
28074
  }
@@ -30360,7 +30545,7 @@ var sessionCommand = defineCommand({
30360
30545
 
30361
30546
  // src/commands/start.ts
30362
30547
  var import_gray_matter10 = __toESM(require_gray_matter(), 1);
30363
- import { readFileSync as readFileSync20, statSync as statSync12 } from "node:fs";
30548
+ import { readFileSync as readFileSync20, statSync as statSync13 } from "node:fs";
30364
30549
 
30365
30550
  // src/lib/daemon-log.ts
30366
30551
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync8, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
@@ -30607,7 +30792,7 @@ class DestructiveGate {
30607
30792
  import { readFileSync as readFileSync15 } from "node:fs";
30608
30793
 
30609
30794
  // src/lib/conflicts.ts
30610
- import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync7 } from "node:fs";
30795
+ import { mkdirSync as mkdirSync9, readdirSync as readdirSync5, statSync as statSync6, writeFileSync as writeFileSync7 } from "node:fs";
30611
30796
  import { dirname as dirname7, join as join10 } from "node:path";
30612
30797
  function conflictsRoot(workspaceSlug) {
30613
30798
  return join10(plinthHome(), "_conflicts", workspaceSlug);
@@ -30635,9 +30820,60 @@ function stashFileConflict(args) {
30635
30820
  writeFileSync7(target, args.bytes, { mode: 384 });
30636
30821
  return target;
30637
30822
  }
30823
+ var STAMP_RE2 = /\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(\.md)?$/;
30824
+ function parseStamp(stamp) {
30825
+ const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z");
30826
+ const d2 = new Date(iso);
30827
+ return Number.isNaN(d2.getTime()) ? null : d2;
30828
+ }
30829
+ function labelFor(file2) {
30830
+ const m2 = STAMP_RE2.exec(file2);
30831
+ if (!m2)
30832
+ return file2;
30833
+ const head = file2.slice(0, m2.index);
30834
+ if (head.startsWith("files.")) {
30835
+ return `files/${head.slice("files.".length).replace(/__/g, "/")}`;
30836
+ }
30837
+ const dot = head.indexOf(".");
30838
+ if (dot === -1)
30839
+ return head;
30840
+ return `${head.slice(0, dot)}/${head.slice(dot + 1)}`;
30841
+ }
30842
+ function listConflictStashes(workspaceSlug) {
30843
+ const dir = conflictsRoot(workspaceSlug);
30844
+ let names;
30845
+ try {
30846
+ names = readdirSync5(dir);
30847
+ } catch {
30848
+ return [];
30849
+ }
30850
+ const out = [];
30851
+ for (const file2 of names) {
30852
+ const absPath = join10(dir, file2);
30853
+ let st;
30854
+ try {
30855
+ st = statSync6(absPath);
30856
+ } catch {
30857
+ continue;
30858
+ }
30859
+ if (!st.isFile())
30860
+ continue;
30861
+ if (file2.startsWith("."))
30862
+ continue;
30863
+ const m2 = STAMP_RE2.exec(file2);
30864
+ out.push({
30865
+ file: file2,
30866
+ absPath,
30867
+ label: labelFor(file2),
30868
+ stashedAt: m2 ? parseStamp(m2[1]) : null,
30869
+ mtimeMs: st.mtimeMs
30870
+ });
30871
+ }
30872
+ return out.sort((a2, b2) => (b2.stashedAt?.getTime() ?? b2.mtimeMs) - (a2.stashedAt?.getTime() ?? a2.mtimeMs));
30873
+ }
30638
30874
 
30639
30875
  // src/lib/files/push.ts
30640
- import { readFileSync as readFileSync12, statSync as statSync6 } from "node:fs";
30876
+ import { readFileSync as readFileSync12, statSync as statSync7 } from "node:fs";
30641
30877
 
30642
30878
  // src/lib/log-tokens.ts
30643
30879
  function categorizeError(err) {
@@ -30727,9 +30963,9 @@ function isPermanentReject(err) {
30727
30963
  return PERMANENT_REJECT_REASONS.has(categorizeError(err).reason);
30728
30964
  }
30729
30965
  function persist(state, next) {
30730
- if (next.changed)
30731
- writeState(next.state);
30732
- return next.state;
30966
+ if (!next.changed)
30967
+ return state;
30968
+ return commitState(state, next.state);
30733
30969
  }
30734
30970
  function recordEntityRejected(args) {
30735
30971
  const cat = categorizeError(args.err);
@@ -30794,15 +31030,15 @@ function clearEntityRejected(state, workspaceSlug, absolutePath) {
30794
31030
  path5 = absolutePath;
30795
31031
  }
30796
31032
  const next = removeRejected(state, "entity", path5);
30797
- if (next.changed)
30798
- writeState(next.state);
30799
- return next.state;
31033
+ if (!next.changed)
31034
+ return state;
31035
+ return commitState(state, next.state);
30800
31036
  }
30801
31037
  function clearFileRejected(state, relPath) {
30802
31038
  const next = removeRejected(state, "file", relPath);
30803
- if (next.changed)
30804
- writeState(next.state);
30805
- return next.state;
31039
+ if (!next.changed)
31040
+ return state;
31041
+ return commitState(state, next.state);
30806
31042
  }
30807
31043
 
30808
31044
  // src/lib/files/api.ts
@@ -31137,26 +31373,26 @@ function inferMime(relPath) {
31137
31373
  // src/lib/files/push.ts
31138
31374
  function persistFiles(state, entry2) {
31139
31375
  const { state: next, changed } = upsertFileEntry(state, entry2);
31140
- if (changed)
31141
- writeState(next);
31142
- return next;
31376
+ if (!changed)
31377
+ return state;
31378
+ return commitState(state, next);
31143
31379
  }
31144
31380
  function dropFiles(state, relPath) {
31145
31381
  const { state: next, changed } = removeFileEntry(state, relPath);
31146
- if (changed)
31147
- writeState(next);
31148
- return next;
31382
+ if (!changed)
31383
+ return state;
31384
+ return commitState(state, next);
31149
31385
  }
31150
31386
  function safeInode(absolutePath) {
31151
31387
  try {
31152
- return statSync6(absolutePath).ino;
31388
+ return statSync7(absolutePath).ino;
31153
31389
  } catch {
31154
31390
  return;
31155
31391
  }
31156
31392
  }
31157
31393
  async function restoreFileFromCloud(args) {
31158
31394
  const { workspaceSlug, baseUrl, pat, id, relPath, state, fetchImpl, suppressor, output } = args;
31159
- const got = await getFile({ baseUrl, pat, id, fetchImpl });
31395
+ const got = args.prefetched ?? await getFile({ baseUrl, pat, id, fetchImpl });
31160
31396
  const abs = filesSubtreePath(workspaceSlug, relPath);
31161
31397
  if (!got.ok) {
31162
31398
  suppressor?.registerDelete(abs);
@@ -31202,7 +31438,7 @@ async function pushFilesUpsert(args) {
31202
31438
  } else {
31203
31439
  let st;
31204
31440
  try {
31205
- st = statSync6(absolutePath);
31441
+ st = statSync7(absolutePath);
31206
31442
  } catch (err) {
31207
31443
  if (err?.code === "ENOENT") {
31208
31444
  return pushFilesDelete({ workspaceSlug, baseUrl, pat, relPath, state, deps });
@@ -31252,6 +31488,42 @@ async function pushFilesUpsert(args) {
31252
31488
  return { outcome: { kind: "noop", relPath }, state };
31253
31489
  }
31254
31490
  const mimeType = inferMime(relPath);
31491
+ const acceptPush = (row, action) => {
31492
+ output?.log(filePushLine({ path: relPath, id: row.id, action }));
31493
+ return {
31494
+ outcome: { kind: action, relPath, id: row.id },
31495
+ state: persistFiles(state, {
31496
+ id: row.id,
31497
+ path: relPath,
31498
+ content_hash: hash2,
31499
+ size_bytes: row.size_bytes,
31500
+ mime_type: row.mime_type,
31501
+ updated_at: row.updated_at,
31502
+ inode
31503
+ })
31504
+ };
31505
+ };
31506
+ const rejectOverCap = (serverMax) => {
31507
+ const max = serverMax ?? MAX_WORKSPACE_FILE_BYTES;
31508
+ const source = serverMax === null ? "client" : "server";
31509
+ output?.log(fileOverCapLine({ path: relPath, size, max, source }));
31510
+ return {
31511
+ outcome: { kind: "over_cap", relPath, size, max, source },
31512
+ state: recordFileOverCap({
31513
+ state,
31514
+ relPath,
31515
+ size,
31516
+ max,
31517
+ source,
31518
+ tracked: existing !== undefined,
31519
+ now
31520
+ })
31521
+ };
31522
+ };
31523
+ const rejectHashMismatch = () => {
31524
+ output?.log(fileFailedLine({ path: relPath, status: 400, reason: "hash_mismatch" }));
31525
+ return { outcome: { kind: "ignored", relPath, reason: "hash_mismatch" }, state };
31526
+ };
31255
31527
  const res = await postFile({
31256
31528
  baseUrl,
31257
31529
  pat,
@@ -31262,43 +31534,38 @@ async function pushFilesUpsert(args) {
31262
31534
  expectedUpdatedAt: existing?.updated_at ?? null,
31263
31535
  fetchImpl
31264
31536
  });
31265
- if (res.ok) {
31266
- const action = existing ? "updated" : "created";
31267
- output?.log(filePushLine({ path: relPath, id: res.row.id, action }));
31268
- const next = persistFiles(state, {
31269
- id: res.row.id,
31270
- path: relPath,
31271
- content_hash: hash2,
31272
- size_bytes: res.row.size_bytes,
31273
- mime_type: res.row.mime_type,
31274
- updated_at: res.row.updated_at,
31275
- inode
31276
- });
31277
- return { outcome: { kind: action, relPath, id: res.row.id }, state: next };
31278
- }
31279
- if (res.code === 413) {
31280
- const serverMax = res.maxBytes;
31281
- const max = serverMax ?? MAX_WORKSPACE_FILE_BYTES;
31282
- const source = serverMax === null ? "client" : "server";
31283
- output?.log(fileOverCapLine({ path: relPath, size, max, source }));
31284
- const next = recordFileOverCap({
31285
- state,
31286
- relPath,
31287
- size,
31288
- max,
31289
- source,
31290
- tracked: existing !== undefined,
31291
- now
31292
- });
31293
- return { outcome: { kind: "over_cap", relPath, size, max, source }, state: next };
31294
- }
31295
- if (res.code === 400) {
31296
- output?.log(fileFailedLine({ path: relPath, status: 400, reason: "hash_mismatch" }));
31297
- return { outcome: { kind: "ignored", relPath, reason: "hash_mismatch" }, state };
31537
+ if (res.ok)
31538
+ return acceptPush(res.row, existing ? "updated" : "created");
31539
+ if (res.code === 413)
31540
+ return rejectOverCap(res.maxBytes);
31541
+ if (res.code === 400)
31542
+ return rejectHashMismatch();
31543
+ let nextState = state;
31544
+ let cloudRow;
31545
+ if (existing) {
31546
+ cloudRow = await getFile({ baseUrl, pat, id: existing.id, fetchImpl });
31547
+ if (cloudRow.ok && cloudRow.row.download_url !== null && cloudRow.row.content_hash === existing.content_hash) {
31548
+ const retry = await postFile({
31549
+ baseUrl,
31550
+ pat,
31551
+ relPath,
31552
+ bytes,
31553
+ contentHash: hash2,
31554
+ mimeType,
31555
+ expectedUpdatedAt: cloudRow.row.updated_at,
31556
+ fetchImpl
31557
+ });
31558
+ if (retry.ok)
31559
+ return acceptPush(retry.row, "updated");
31560
+ if (retry.code === 413)
31561
+ return rejectOverCap(retry.maxBytes);
31562
+ if (retry.code === 400)
31563
+ return rejectHashMismatch();
31564
+ cloudRow = undefined;
31565
+ }
31298
31566
  }
31299
31567
  const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes });
31300
31568
  output?.log(fileConflictLine({ path: relPath, stash: stashPath }));
31301
- let nextState = state;
31302
31569
  if (existing) {
31303
31570
  nextState = await restoreFileFromCloud({
31304
31571
  workspaceSlug,
@@ -31309,7 +31576,8 @@ async function pushFilesUpsert(args) {
31309
31576
  state,
31310
31577
  fetchImpl,
31311
31578
  suppressor,
31312
- output
31579
+ output,
31580
+ prefetched: cloudRow
31313
31581
  });
31314
31582
  }
31315
31583
  return { outcome: { kind: "conflict", relPath, stashPath }, state: nextState };
@@ -31374,7 +31642,7 @@ class PerIdMutex {
31374
31642
 
31375
31643
  // src/lib/push.ts
31376
31644
  var import_gray_matter9 = __toESM(require_gray_matter(), 1);
31377
- import { readFileSync as readFileSync14, statSync as statSync7 } from "node:fs";
31645
+ import { readFileSync as readFileSync14, statSync as statSync8 } from "node:fs";
31378
31646
  import { basename as basename4, relative as relative4 } from "node:path";
31379
31647
 
31380
31648
  // src/lib/pull-write.ts
@@ -31472,22 +31740,31 @@ function omitKeys(obj, keys) {
31472
31740
  }
31473
31741
  function safeInode2(absolutePath) {
31474
31742
  try {
31475
- return statSync7(absolutePath).ino;
31743
+ return statSync8(absolutePath).ino;
31476
31744
  } catch {
31477
31745
  return;
31478
31746
  }
31479
31747
  }
31480
31748
  function persistEntry(state, entityType, entry2) {
31481
31749
  const { state: nextState, changed } = upsertEntry(state, entityType, entry2);
31482
- if (changed)
31483
- writeState(nextState);
31484
- return nextState;
31750
+ if (!changed)
31751
+ return state;
31752
+ return commitState(state, nextState);
31485
31753
  }
31486
31754
  function dropEntry(state, entityType, id) {
31487
31755
  const { state: nextState, changed } = removeEntry(state, entityType, id);
31488
- if (changed)
31489
- writeState(nextState);
31490
- return nextState;
31756
+ if (!changed)
31757
+ return state;
31758
+ return commitState(state, nextState);
31759
+ }
31760
+ function cloudMatchesAcked(strategy, cloud, acked) {
31761
+ const { bodyHash, frontmatterHash } = computeMirrorHashes(strategy, strategy.serialise(cloud));
31762
+ if (cloud.slug !== acked.slug)
31763
+ return false;
31764
+ if (strategy.preserveLocalBodyOnPull) {
31765
+ return frontmatterHash === acked.frontmatter_hash;
31766
+ }
31767
+ return bodyHash === acked.body_hash && frontmatterHash === acked.frontmatter_hash;
31491
31768
  }
31492
31769
  function stampStateFromMirror(args) {
31493
31770
  const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
@@ -31936,7 +32213,7 @@ async function runPushUpsert(args) {
31936
32213
  state: nextState2
31937
32214
  };
31938
32215
  }
31939
- const patchResult = await patchEntity({
32216
+ let patchResult = await patchEntity({
31940
32217
  strategy,
31941
32218
  baseUrl: args.baseUrl,
31942
32219
  pat: args.pat,
@@ -31947,6 +32224,34 @@ async function runPushUpsert(args) {
31947
32224
  topLevel: wireTopLevel,
31948
32225
  fetchImpl: args.fetchImpl
31949
32226
  });
32227
+ if (!patchResult.ok && patchResult.code === 409 && patchResult.reason === "precondition_failed") {
32228
+ let cloud = null;
32229
+ try {
32230
+ cloud = await getEntity({
32231
+ strategy,
32232
+ baseUrl: args.baseUrl,
32233
+ pat: args.pat,
32234
+ id: liveEntry.id,
32235
+ fetchImpl: args.fetchImpl
32236
+ });
32237
+ } catch (err) {
32238
+ if (!(err instanceof ApiError) || err.status !== 404)
32239
+ throw err;
32240
+ }
32241
+ if (cloud !== null && cloudMatchesAcked(strategy, cloud, liveEntry)) {
32242
+ patchResult = await patchEntity({
32243
+ strategy,
32244
+ baseUrl: args.baseUrl,
32245
+ pat: args.pat,
32246
+ id: liveEntry.id,
32247
+ expectedUpdatedAt: cloud.updated_at,
32248
+ body: wireBody,
32249
+ frontmatter: split ? wireFrontmatter : undefined,
32250
+ topLevel: wireTopLevel,
32251
+ fetchImpl: args.fetchImpl
32252
+ });
32253
+ }
32254
+ }
31950
32255
  if (patchResult.ok) {
31951
32256
  const updated = {
31952
32257
  id: patchResult.doc.id,
@@ -32419,7 +32724,7 @@ async function pushRename(args) {
32419
32724
  return mutex.run(id, async () => {
32420
32725
  const live = getEntry(state, entityType, id);
32421
32726
  const effectiveExpected = live?.updated_at ?? expectedUpdatedAt;
32422
- const patchResult = await patchEntity({
32727
+ let patchResult = await patchEntity({
32423
32728
  strategy,
32424
32729
  baseUrl,
32425
32730
  pat,
@@ -32428,6 +32733,26 @@ async function pushRename(args) {
32428
32733
  slug: toSlug,
32429
32734
  fetchImpl
32430
32735
  });
32736
+ if (!patchResult.ok && patchResult.code === 409 && patchResult.reason === "precondition_failed" && live !== undefined) {
32737
+ let probe2 = null;
32738
+ try {
32739
+ probe2 = await getEntity({ strategy, baseUrl, pat, id, fetchImpl });
32740
+ } catch (err) {
32741
+ if (!(err instanceof ApiError) || err.status !== 404)
32742
+ throw err;
32743
+ }
32744
+ if (probe2 !== null && cloudMatchesAcked(strategy, probe2, live)) {
32745
+ patchResult = await patchEntity({
32746
+ strategy,
32747
+ baseUrl,
32748
+ pat,
32749
+ id,
32750
+ expectedUpdatedAt: probe2.updated_at,
32751
+ slug: toSlug,
32752
+ fetchImpl
32753
+ });
32754
+ }
32755
+ }
32431
32756
  if (patchResult.ok) {
32432
32757
  const doc2 = patchResult.doc;
32433
32758
  const prior = live ?? null;
@@ -32862,7 +33187,7 @@ class FileDestructiveGate {
32862
33187
  }
32863
33188
 
32864
33189
  // src/lib/files/reconcile.ts
32865
- import { readFileSync as readFileSync16, statSync as statSync9 } from "node:fs";
33190
+ import { readFileSync as readFileSync16, statSync as statSync10 } from "node:fs";
32866
33191
 
32867
33192
  // src/lib/unsynced.ts
32868
33193
  import { join as join11, relative as relative6 } from "node:path";
@@ -32877,9 +33202,9 @@ function isRecordableFailure(err) {
32877
33202
  }
32878
33203
  function persistUpsert(state, candidate) {
32879
33204
  const { state: next, changed } = upsertUnsynced(state, candidate);
32880
- if (changed)
32881
- writeState(next);
32882
- return next;
33205
+ if (!changed)
33206
+ return state;
33207
+ return commitState(state, next);
32883
33208
  }
32884
33209
  function recordEntityUnsynced(args) {
32885
33210
  const cat = categorizeError(args.err);
@@ -32918,11 +33243,10 @@ async function drainUnsyncedOps(args) {
32918
33243
  let stillFailing = 0;
32919
33244
  let rejected = 0;
32920
33245
  const clear = (target, path5) => {
32921
- const { state: next, changed } = removeUnsynced(getState(), target, path5);
32922
- if (changed) {
32923
- writeState(next);
32924
- setState(next);
32925
- }
33246
+ const before = getState();
33247
+ const { state: next, changed } = removeUnsynced(before, target, path5);
33248
+ if (changed)
33249
+ setState(commitState(before, next));
32926
33250
  };
32927
33251
  for (const op of ops) {
32928
33252
  attempted += 1;
@@ -32974,7 +33298,8 @@ async function drainUnsyncedOps(args) {
32974
33298
  const cat = categorizeError(err);
32975
33299
  if (isPermanentReject(err)) {
32976
33300
  rejected += 1;
32977
- const removed = removeUnsynced(getState(), op.target, op.path).state;
33301
+ const beforeReject = getState();
33302
+ const removed = removeUnsynced(beforeReject, op.target, op.path).state;
32978
33303
  const { state: next2 } = upsertRejected(removed, {
32979
33304
  target: op.target,
32980
33305
  op: op.op,
@@ -32985,22 +33310,20 @@ async function drainUnsyncedOps(args) {
32985
33310
  message: cat.serverMessage,
32986
33311
  rejected_at: now().toISOString()
32987
33312
  });
32988
- writeState(next2);
32989
- setState(next2);
33313
+ setState(commitState(beforeReject, next2));
32990
33314
  continue;
32991
33315
  }
32992
33316
  stillFailing += 1;
32993
- const { state: next, changed } = upsertUnsynced(getState(), {
33317
+ const beforeRetry = getState();
33318
+ const { state: next, changed } = upsertUnsynced(beforeRetry, {
32994
33319
  target: op.target,
32995
33320
  op: op.op,
32996
33321
  path: op.path,
32997
33322
  last_status: cat.status,
32998
33323
  last_reason: cat.reason
32999
33324
  });
33000
- if (changed) {
33001
- writeState(next);
33002
- setState(next);
33003
- }
33325
+ if (changed)
33326
+ setState(commitState(beforeRetry, next));
33004
33327
  }
33005
33328
  }
33006
33329
  return { attempted, resolved, stillFailing, rejected };
@@ -34619,7 +34942,7 @@ function watch(paths, options2 = {}) {
34619
34942
  var chokidar_default = { watch, FSWatcher };
34620
34943
 
34621
34944
  // src/lib/watcher.ts
34622
- import { statSync as statSync8 } from "node:fs";
34945
+ import { statSync as statSync9 } from "node:fs";
34623
34946
  import { sep as sep5 } from "node:path";
34624
34947
 
34625
34948
  // src/lib/rename-classifier.ts
@@ -34835,7 +35158,7 @@ function startWatcher(opts, handler) {
34835
35158
  pairingWindowMs: opts.renamePairingWindowMs ?? DEFAULT_DEBOUNCE_MS,
34836
35159
  classifyPath: opts.classifyPath,
34837
35160
  getStateEntry: opts.getStateEntry,
34838
- statSync: opts.statSync ?? ((p) => ({ ino: statSync8(p).ino }))
35161
+ statSync: opts.statSync ?? ((p) => ({ ino: statSync9(p).ino }))
34839
35162
  });
34840
35163
  const factory = opts.chokidarFactory ?? ((paths, watcherOpts) => chokidar_default.watch(paths, watcherOpts));
34841
35164
  const watcher = factory(workspaceRoot, {
@@ -34870,7 +35193,7 @@ function readBlobForPush(args) {
34870
35193
  const { absolutePath, relPath, output } = args;
34871
35194
  let st;
34872
35195
  try {
34873
- st = statSync9(absolutePath);
35196
+ st = statSync10(absolutePath);
34874
35197
  } catch (err) {
34875
35198
  if (err?.code === "ENOENT")
34876
35199
  return { kind: "gone" };
@@ -35108,9 +35431,9 @@ function conflictSlug(slug) {
35108
35431
  }
35109
35432
  function persistEntry2(state, entry2) {
35110
35433
  const { state: next, changed } = upsertEntry(state, ENTITY_TYPE, entry2);
35111
- if (changed)
35112
- writeState(next);
35113
- return next;
35434
+ if (!changed)
35435
+ return state;
35436
+ return commitState(state, next);
35114
35437
  }
35115
35438
  function memoryStub(name) {
35116
35439
  return `# ${name} — Long-term memory
@@ -36572,9 +36895,9 @@ function writeLocal(workspaceSlug, body, suppressor) {
36572
36895
  }
36573
36896
  function persistRules(state, entry2) {
36574
36897
  const { state: next, changed } = setWorkspaceRulesState(state, entry2);
36575
- if (changed)
36576
- writeState(next);
36577
- return next;
36898
+ if (!changed)
36899
+ return state;
36900
+ return commitState(state, next);
36578
36901
  }
36579
36902
  function classifyWorkspaceRulesPath(args) {
36580
36903
  let root;
@@ -36690,6 +37013,30 @@ async function pushWorkspaceRules(args) {
36690
37013
  output?.warn(`${WORKSPACE_RULES_FILENAME} is over the ${Math.round(MAX_WORKSPACE_RULES_BYTES / 1024)}KB cap — not synced.`);
36691
37014
  return { state, outcome: { kind: "over_cap" } };
36692
37015
  }
37016
+ {
37017
+ const probe2 = await getWorkspaceRules({ baseUrl, pat, fetchImpl });
37018
+ if (acked && stableHash(probe2.body_md) === acked.body_hash) {
37019
+ const retry = await putWorkspaceRules({
37020
+ baseUrl,
37021
+ pat,
37022
+ bodyMd: local,
37023
+ expectedUpdatedAt: probe2.updated_at,
37024
+ fetchImpl
37025
+ });
37026
+ if (retry.ok) {
37027
+ const next2 = persistRules(state, {
37028
+ body_hash: localHash,
37029
+ updated_at: retry.row.updated_at
37030
+ });
37031
+ output?.log(`[WORKSPACE_RULES_PUT ws=${workspaceSlug} is_empty=${retry.row.is_empty}]`);
37032
+ return { state: next2, outcome: { kind: "pushed" } };
37033
+ }
37034
+ if (retry.code === 413) {
37035
+ output?.warn(`${WORKSPACE_RULES_FILENAME} is over the ${Math.round(MAX_WORKSPACE_RULES_BYTES / 1024)}KB cap — not synced.`);
37036
+ return { state, outcome: { kind: "over_cap" } };
37037
+ }
37038
+ }
37039
+ }
36693
37040
  const stashPath = stashConflict({
36694
37041
  workspaceSlug,
36695
37042
  entityType: CONFLICT_ENTITY,
@@ -36832,9 +37179,9 @@ function writeLocal2(workspaceSlug, body, suppressor) {
36832
37179
  }
36833
37180
  function persistMemory(state, entry2) {
36834
37181
  const { state: next, changed } = setWorkspaceMemoryState(state, entry2);
36835
- if (changed)
36836
- writeState(next);
36837
- return next;
37182
+ if (!changed)
37183
+ return state;
37184
+ return commitState(state, next);
36838
37185
  }
36839
37186
  function classifyWorkspaceMemoryPath(args) {
36840
37187
  let root;
@@ -36932,6 +37279,30 @@ async function pushWorkspaceMemory(args) {
36932
37279
  output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
36933
37280
  return { state, outcome: { kind: "over_cap" } };
36934
37281
  }
37282
+ {
37283
+ const probe2 = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
37284
+ if (acked && stableHash(probe2.body_md) === acked.body_hash) {
37285
+ const retry = await putWorkspaceMemory({
37286
+ baseUrl,
37287
+ pat,
37288
+ bodyMd: local,
37289
+ expectedUpdatedAt: probe2.updated_at,
37290
+ fetchImpl
37291
+ });
37292
+ if (retry.ok) {
37293
+ const next2 = persistMemory(state, {
37294
+ body_hash: localHash,
37295
+ updated_at: retry.row.updated_at
37296
+ });
37297
+ output?.log(`[WORKSPACE_MEMORY_PUT ws=${workspaceSlug} is_empty=${retry.row.is_empty}]`);
37298
+ return { state: next2, outcome: { kind: "pushed" } };
37299
+ }
37300
+ if (retry.code === 413) {
37301
+ output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
37302
+ return { state, outcome: { kind: "over_cap" } };
37303
+ }
37304
+ }
37305
+ }
36935
37306
  const stashPath = stashConflict({
36936
37307
  workspaceSlug,
36937
37308
  entityType: CONFLICT_ENTITY2,
@@ -37076,9 +37447,9 @@ function writeLocal3(descriptor, workspaceSlug, body, suppressor) {
37076
37447
  }
37077
37448
  function persist2(descriptor, state, entry2) {
37078
37449
  const { state: next, changed } = descriptor.setState(state, entry2);
37079
- if (changed)
37080
- writeState(next);
37081
- return next;
37450
+ if (!changed)
37451
+ return state;
37452
+ return commitState(state, next);
37082
37453
  }
37083
37454
  function classifySingletonPath(descriptor, args) {
37084
37455
  let root;
@@ -37176,6 +37547,30 @@ async function pushSingleton(descriptor, args) {
37176
37547
  output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
37177
37548
  return { state, outcome: { kind: "over_cap" } };
37178
37549
  }
37550
+ {
37551
+ const probe2 = await descriptor.api.get({ baseUrl, pat, fetchImpl });
37552
+ if (acked && stableHash(probe2.body_md) === acked.body_hash) {
37553
+ const retry = await descriptor.api.put({
37554
+ baseUrl,
37555
+ pat,
37556
+ bodyMd: local,
37557
+ expectedUpdatedAt: probe2.updated_at,
37558
+ fetchImpl
37559
+ });
37560
+ if (retry.ok) {
37561
+ const next2 = persist2(descriptor, state, {
37562
+ body_hash: localHash,
37563
+ updated_at: retry.row.updated_at
37564
+ });
37565
+ output?.log(`[${descriptor.logTag}_PUT ws=${workspaceSlug} is_empty=${retry.row.is_empty}]`);
37566
+ return { state: next2, outcome: { kind: "pushed" } };
37567
+ }
37568
+ if (retry.code === 413) {
37569
+ output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
37570
+ return { state, outcome: { kind: "over_cap" } };
37571
+ }
37572
+ }
37573
+ }
37179
37574
  const stashPath = stashConflict({
37180
37575
  workspaceSlug,
37181
37576
  entityType: descriptor.conflictEntity,
@@ -37324,15 +37719,15 @@ function createThrottledFetch(baseFetch, opts = {}) {
37324
37719
  // src/commands/sync.ts
37325
37720
  import {
37326
37721
  existsSync as existsSync6,
37327
- readdirSync as readdirSync5,
37722
+ readdirSync as readdirSync6,
37328
37723
  readFileSync as readFileSync19,
37329
- statSync as statSync11,
37724
+ statSync as statSync12,
37330
37725
  unlinkSync as unlinkSync7
37331
37726
  } from "node:fs";
37332
37727
  import { join as join14 } from "node:path";
37333
37728
 
37334
37729
  // src/lib/files/pull.ts
37335
- import { existsSync as existsSync5, readFileSync as readFileSync18, statSync as statSync10 } from "node:fs";
37730
+ import { existsSync as existsSync5, readFileSync as readFileSync18, statSync as statSync11 } from "node:fs";
37336
37731
 
37337
37732
  // src/lib/files/extracted-text.ts
37338
37733
  import { createHash as createHash3 } from "node:crypto";
@@ -37428,6 +37823,7 @@ async function pullFiles(args) {
37428
37823
  let cursorBlocked = false;
37429
37824
  while (true) {
37430
37825
  const page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
37826
+ const pageBase = state;
37431
37827
  let pageTextFailed = false;
37432
37828
  for (const row of page.rows) {
37433
37829
  fetched++;
@@ -37458,6 +37854,8 @@ async function pullFiles(args) {
37458
37854
  if (outcome.textFailed)
37459
37855
  pageTextFailed = true;
37460
37856
  }
37857
+ if (args.commit && state !== pageBase)
37858
+ state = args.commit(pageBase, state);
37461
37859
  if (pageTextFailed)
37462
37860
  cursorBlocked = true;
37463
37861
  if (page.next_cursor === null)
@@ -37663,7 +38061,7 @@ async function applyFileRow(args) {
37663
38061
  }
37664
38062
  let inode;
37665
38063
  try {
37666
- inode = statSync10(abs).ino;
38064
+ inode = statSync11(abs).ino;
37667
38065
  } catch {
37668
38066
  inode = undefined;
37669
38067
  }
@@ -37745,11 +38143,11 @@ function hasExistingContent(workspacePath) {
37745
38143
  if (!existsSync6(workspacePath))
37746
38144
  return false;
37747
38145
  try {
37748
- return readdirSync5(workspacePath, { withFileTypes: true }).some((e2) => {
38146
+ return readdirSync6(workspacePath, { withFileTypes: true }).some((e2) => {
37749
38147
  if (!e2.isDirectory())
37750
38148
  return true;
37751
38149
  try {
37752
- return readdirSync5(join14(workspacePath, e2.name)).length > 0;
38150
+ return readdirSync6(join14(workspacePath, e2.name)).length > 0;
37753
38151
  } catch {
37754
38152
  return true;
37755
38153
  }
@@ -37769,7 +38167,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
37769
38167
  }
37770
38168
  let liveInode;
37771
38169
  try {
37772
- liveInode = statSync11(target).ino;
38170
+ liveInode = statSync12(target).ino;
37773
38171
  } catch {
37774
38172
  return { didUnlink: false, skippedReason: "absent" };
37775
38173
  }
@@ -37826,7 +38224,13 @@ async function runSync(workspaceSlug, deps = {}) {
37826
38224
  out.info(`Syncing ${slug}…`);
37827
38225
  const startedAt = now();
37828
38226
  let state = loadState(slug);
37829
- let stateDirty = false;
38227
+ let committed = state;
38228
+ const commitPending = () => {
38229
+ if (state === committed)
38230
+ return;
38231
+ state = commitState(committed, state);
38232
+ committed = state;
38233
+ };
37830
38234
  const counters = {};
37831
38235
  try {
37832
38236
  for (const entityType of allEntityTypes()) {
@@ -37848,13 +38252,13 @@ async function runSync(workspaceSlug, deps = {}) {
37848
38252
  const outcome = applyRow(slug, strategy, row, state, out, deps.suppressor, now, deps.isFrozen);
37849
38253
  if (outcome.state !== state) {
37850
38254
  state = outcome.state;
37851
- stateDirty = true;
37852
38255
  }
37853
38256
  if (outcome.action === "written")
37854
38257
  ctr.written++;
37855
38258
  else if (outcome.action === "deleted")
37856
38259
  ctr.deleted++;
37857
38260
  }
38261
+ commitPending();
37858
38262
  if (page.next_cursor === null)
37859
38263
  break;
37860
38264
  lastAdvancedCursor = page.next_cursor;
@@ -37881,21 +38285,21 @@ async function runSync(workspaceSlug, deps = {}) {
37881
38285
  output: out,
37882
38286
  suppressor: deps.suppressor,
37883
38287
  isFileFrozen: deps.isFileFrozen,
38288
+ commit: commitState,
37884
38289
  forceTextRewalk: needsDerivedTextRewalk
37885
38290
  });
37886
38291
  if (filesResult.state !== state) {
37887
38292
  state = filesResult.state;
37888
- stateDirty = true;
37889
38293
  }
37890
38294
  if (needsDerivedTextRewalk && filesResult.textUnknown === 0) {
37891
38295
  state = { ...state, derived_text_version: DERIVED_TEXT_VERSION };
37892
- stateDirty = true;
37893
38296
  }
37894
38297
  counters["files"] = {
37895
38298
  fetched: filesResult.fetched,
37896
38299
  written: filesResult.written + filesResult.textWritten,
37897
38300
  deleted: filesResult.deleted + filesResult.textDeleted
37898
38301
  };
38302
+ commitPending();
37899
38303
  if (filesResult.lastCursor !== filesStartCursor) {
37900
38304
  updateSyncCursor(slug, "files", filesResult.lastCursor);
37901
38305
  }
@@ -37911,8 +38315,8 @@ async function runSync(workspaceSlug, deps = {}) {
37911
38315
  });
37912
38316
  if (rulesResult.state !== state) {
37913
38317
  state = rulesResult.state;
37914
- stateDirty = true;
37915
38318
  }
38319
+ commitPending();
37916
38320
  if (rulesResult.action === "written") {
37917
38321
  counters["workspace-rules"] = { fetched: 1, written: 1, deleted: 0 };
37918
38322
  }
@@ -37928,8 +38332,8 @@ async function runSync(workspaceSlug, deps = {}) {
37928
38332
  });
37929
38333
  if (memoryResult.state !== state) {
37930
38334
  state = memoryResult.state;
37931
- stateDirty = true;
37932
38335
  }
38336
+ commitPending();
37933
38337
  if (memoryResult.action === "written") {
37934
38338
  counters["workspace-memory"] = { fetched: 1, written: 1, deleted: 0 };
37935
38339
  }
@@ -37945,8 +38349,8 @@ async function runSync(workspaceSlug, deps = {}) {
37945
38349
  });
37946
38350
  if (userMemoryResult.state !== state) {
37947
38351
  state = userMemoryResult.state;
37948
- stateDirty = true;
37949
38352
  }
38353
+ commitPending();
37950
38354
  if (userMemoryResult.action === "written") {
37951
38355
  counters["user-memory"] = { fetched: 1, written: 1, deleted: 0 };
37952
38356
  }
@@ -37962,8 +38366,7 @@ async function runSync(workspaceSlug, deps = {}) {
37962
38366
  }
37963
38367
  try {
37964
38368
  updateLastSyncedAt(slug, now().toISOString());
37965
- if (stateDirty)
37966
- writeState(state);
38369
+ commitPending();
37967
38370
  } catch (err) {
37968
38371
  const msg = err instanceof Error ? err.message : "unknown error";
37969
38372
  out.error(`Sync state could not be persisted: ${msg}`);
@@ -38099,7 +38502,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
38099
38502
  const hashes = computeMirrorHashes(strategy, content);
38100
38503
  let inode;
38101
38504
  try {
38102
- inode = statSync11(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38505
+ inode = statSync12(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38103
38506
  } catch {
38104
38507
  inode = undefined;
38105
38508
  }
@@ -38156,7 +38559,7 @@ var syncCommand = defineCommand({
38156
38559
  // src/commands/start.ts
38157
38560
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
38158
38561
  var PULL_HEARTBEAT_MS = 900000;
38159
- function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync12(p)) {
38562
+ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync13(p)) {
38160
38563
  let changed = false;
38161
38564
  const nextEntries = {};
38162
38565
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -38286,7 +38689,7 @@ function makeWatchHandler(deps) {
38286
38689
  }
38287
38690
  let st;
38288
38691
  try {
38289
- st = statSync12(absolutePath);
38692
+ st = statSync13(absolutePath);
38290
38693
  } catch (err) {
38291
38694
  if (err?.code === "ENOENT")
38292
38695
  return;
@@ -38942,12 +39345,13 @@ async function runStart(workspaceSlug, deps = {}) {
38942
39345
  if (initial.exitCode !== 0)
38943
39346
  return initial;
38944
39347
  let state = loadState(slug);
39348
+ const beforeBackfill = state;
38945
39349
  const inodeBackfill = backfillStateInodes(slug, state);
38946
39350
  state = inodeBackfill.state;
38947
39351
  const lenBackfill = backfillBodyLengths(slug, state);
38948
39352
  state = lenBackfill.state;
38949
39353
  if (inodeBackfill.changed || lenBackfill.changed) {
38950
- writeState(state);
39354
+ state = commitState(beforeBackfill, state);
38951
39355
  }
38952
39356
  const spinner = (deps.spinnerFactory ?? (() => createSpinner({ noColor: deps.noColor })))();
38953
39357
  spinner.start(`Watching ${slug} · ${watchingSummary(state)}`);
@@ -39580,7 +39984,7 @@ var startCommand = defineCommand({
39580
39984
  });
39581
39985
 
39582
39986
  // src/commands/status.ts
39583
- import { statSync as statSync13 } from "node:fs";
39987
+ import { statSync as statSync14 } from "node:fs";
39584
39988
 
39585
39989
  // src/lib/build-stamp.ts
39586
39990
  import { execFileSync as execFileSync4 } from "node:child_process";
@@ -39716,7 +40120,7 @@ function renderBuild(out, staleness) {
39716
40120
  }
39717
40121
  function lastActivity(now) {
39718
40122
  try {
39719
- const mtimeMs = statSync13(daemonLogPath()).mtimeMs;
40123
+ const mtimeMs = statSync14(daemonLogPath()).mtimeMs;
39720
40124
  return formatRelative(now.getTime() - mtimeMs);
39721
40125
  } catch {
39722
40126
  return null;
@@ -39821,6 +40225,17 @@ async function runStatus(args, deps = {}) {
39821
40225
  out.info(cont(`…and ${rejected.length - MAX_SHOWN} more (see ~/.plinth/daemon.log).`));
39822
40226
  }
39823
40227
  }
40228
+ out.info("");
40229
+ const stashes = listConflictStashes(slug);
40230
+ if (stashes.length === 0) {
40231
+ out.info(row("Conflicts", "No conflict stashes."));
40232
+ } else {
40233
+ out.info(row("Conflicts", `${stashes.length} stashed local cop${stashes.length === 1 ? "y" : "ies"} in ${conflictsRoot(slug)}`));
40234
+ const newest = stashes[0];
40235
+ const when = newest.stashedAt ? `${formatAbsolute(newest.stashedAt)} (${formatRelative(now.getTime() - newest.stashedAt.getTime())})` : "time unknown";
40236
+ out.info(cont(`Newest: ${newest.label} — ${when}`));
40237
+ out.info(cont("Each is your version of a file that was replaced by the cloud copy. Compare it with the file in your mirror, re-apply anything you still want, then delete the stash."));
40238
+ }
39824
40239
  return { exitCode: 0 };
39825
40240
  }
39826
40241
  var statusCommand = defineCommand({
@@ -40159,7 +40574,7 @@ function denyUngated(detail) {
40159
40574
  }
40160
40575
 
40161
40576
  // src/lib/voice-gate/payload.ts
40162
- import { readFileSync as readFileSync23, statSync as statSync14 } from "node:fs";
40577
+ import { readFileSync as readFileSync23, statSync as statSync15 } from "node:fs";
40163
40578
  import { homedir as homedir2 } from "node:os";
40164
40579
  import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
40165
40580
  var MAX_BODY_BYTES = 256 * 1024;
@@ -40316,7 +40731,7 @@ function isHelperInvocation(tokens) {
40316
40731
  return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
40317
40732
  }
40318
40733
  function defaultReadBodyFile(path5) {
40319
- const stat4 = statSync14(path5);
40734
+ const stat4 = statSync15(path5);
40320
40735
  if (stat4.size > MAX_BODY_BYTES) {
40321
40736
  throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
40322
40737
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plinth-music/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Plinth capstone sync daemon and CLI",
5
5
  "type": "module",
6
6
  "private": false,