@plinth-music/cli 0.9.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,85 @@
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
+
5
84
  ## 0.9.0 — 2026-08-24
6
85
 
7
86
  Two PRs since `0.8.0` (`v0.8.0..4842d40`). An agent is now told to read a draft
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": "fb065c13301d65750f1a98761148486678da5b6d",
2
+ "sha": "4c34a869837b50a27a80e0a4adbf349d146298fa",
3
3
  "dirty": false,
4
- "built_at": "2026-08-24T19:03:09.765Z"
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.9.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;
@@ -27102,6 +27118,140 @@ function writeState(state) {
27102
27118
  throw err;
27103
27119
  }
27104
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
+ }
27105
27255
  function upsertEntry(state, entityType, entry) {
27106
27256
  const bucket = state.entries[entityType] ?? {};
27107
27257
  const existing = bucket[entry.id];
@@ -27798,12 +27948,22 @@ var macKeychain = {
27798
27948
 
27799
27949
  // src/commands/login.ts
27800
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
+ }
27801
27961
  function defaultOpenBrowser(url2) {
27802
27962
  const child = spawn("open", [url2], { stdio: "ignore", detached: true });
27803
27963
  child.on("error", () => {});
27804
27964
  child.unref();
27805
27965
  }
27806
- async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
27966
+ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}, options2 = {}) {
27807
27967
  const out = deps.stdout ?? process.stdout;
27808
27968
  const err = deps.stderr ?? process.stderr;
27809
27969
  const keychain = deps.keychain ?? macKeychain;
@@ -27852,13 +28012,22 @@ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
27852
28012
  `);
27853
28013
  return 1;
27854
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;
27855
28023
  try {
27856
- upsertWorkspace(workspaceSlug, {
27857
- workspace_id: principal.workspace_id,
27858
- user_id: principal.user_id,
27859
- api_base_url: base2,
27860
- last_synced_at: null,
27861
- 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
27862
28031
  });
27863
28032
  } catch (e2) {
27864
28033
  keychain.delete(workspaceSlug, principal.user_id);
@@ -27867,7 +28036,15 @@ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
27867
28036
  `);
27868
28037
  return 1;
27869
28038
  }
27870
- 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}
27871
28048
  `);
27872
28049
  return 0;
27873
28050
  }
@@ -27881,11 +28058,17 @@ var loginCommand = defineCommand({
27881
28058
  type: "string",
27882
28059
  description: "Workspace slug to log in to",
27883
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."
27884
28065
  }
27885
28066
  },
27886
28067
  async run({ args }) {
27887
28068
  const baseUrl = process.env.PLINTH_API_URL ?? DEFAULT_API_BASE_URL;
27888
- const code = await runLogin(args.workspace, baseUrl);
28069
+ const code = await runLogin(args.workspace, baseUrl, {}, {
28070
+ resetSync: args["reset-sync"] === true
28071
+ });
27889
28072
  if (code !== 0)
27890
28073
  process.exit(code);
27891
28074
  }
@@ -30362,7 +30545,7 @@ var sessionCommand = defineCommand({
30362
30545
 
30363
30546
  // src/commands/start.ts
30364
30547
  var import_gray_matter10 = __toESM(require_gray_matter(), 1);
30365
- import { readFileSync as readFileSync20, statSync as statSync12 } from "node:fs";
30548
+ import { readFileSync as readFileSync20, statSync as statSync13 } from "node:fs";
30366
30549
 
30367
30550
  // src/lib/daemon-log.ts
30368
30551
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync8, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
@@ -30609,7 +30792,7 @@ class DestructiveGate {
30609
30792
  import { readFileSync as readFileSync15 } from "node:fs";
30610
30793
 
30611
30794
  // src/lib/conflicts.ts
30612
- 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";
30613
30796
  import { dirname as dirname7, join as join10 } from "node:path";
30614
30797
  function conflictsRoot(workspaceSlug) {
30615
30798
  return join10(plinthHome(), "_conflicts", workspaceSlug);
@@ -30637,9 +30820,60 @@ function stashFileConflict(args) {
30637
30820
  writeFileSync7(target, args.bytes, { mode: 384 });
30638
30821
  return target;
30639
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
+ }
30640
30874
 
30641
30875
  // src/lib/files/push.ts
30642
- import { readFileSync as readFileSync12, statSync as statSync6 } from "node:fs";
30876
+ import { readFileSync as readFileSync12, statSync as statSync7 } from "node:fs";
30643
30877
 
30644
30878
  // src/lib/log-tokens.ts
30645
30879
  function categorizeError(err) {
@@ -30729,9 +30963,9 @@ function isPermanentReject(err) {
30729
30963
  return PERMANENT_REJECT_REASONS.has(categorizeError(err).reason);
30730
30964
  }
30731
30965
  function persist(state, next) {
30732
- if (next.changed)
30733
- writeState(next.state);
30734
- return next.state;
30966
+ if (!next.changed)
30967
+ return state;
30968
+ return commitState(state, next.state);
30735
30969
  }
30736
30970
  function recordEntityRejected(args) {
30737
30971
  const cat = categorizeError(args.err);
@@ -30796,15 +31030,15 @@ function clearEntityRejected(state, workspaceSlug, absolutePath) {
30796
31030
  path5 = absolutePath;
30797
31031
  }
30798
31032
  const next = removeRejected(state, "entity", path5);
30799
- if (next.changed)
30800
- writeState(next.state);
30801
- return next.state;
31033
+ if (!next.changed)
31034
+ return state;
31035
+ return commitState(state, next.state);
30802
31036
  }
30803
31037
  function clearFileRejected(state, relPath) {
30804
31038
  const next = removeRejected(state, "file", relPath);
30805
- if (next.changed)
30806
- writeState(next.state);
30807
- return next.state;
31039
+ if (!next.changed)
31040
+ return state;
31041
+ return commitState(state, next.state);
30808
31042
  }
30809
31043
 
30810
31044
  // src/lib/files/api.ts
@@ -31139,26 +31373,26 @@ function inferMime(relPath) {
31139
31373
  // src/lib/files/push.ts
31140
31374
  function persistFiles(state, entry2) {
31141
31375
  const { state: next, changed } = upsertFileEntry(state, entry2);
31142
- if (changed)
31143
- writeState(next);
31144
- return next;
31376
+ if (!changed)
31377
+ return state;
31378
+ return commitState(state, next);
31145
31379
  }
31146
31380
  function dropFiles(state, relPath) {
31147
31381
  const { state: next, changed } = removeFileEntry(state, relPath);
31148
- if (changed)
31149
- writeState(next);
31150
- return next;
31382
+ if (!changed)
31383
+ return state;
31384
+ return commitState(state, next);
31151
31385
  }
31152
31386
  function safeInode(absolutePath) {
31153
31387
  try {
31154
- return statSync6(absolutePath).ino;
31388
+ return statSync7(absolutePath).ino;
31155
31389
  } catch {
31156
31390
  return;
31157
31391
  }
31158
31392
  }
31159
31393
  async function restoreFileFromCloud(args) {
31160
31394
  const { workspaceSlug, baseUrl, pat, id, relPath, state, fetchImpl, suppressor, output } = args;
31161
- const got = await getFile({ baseUrl, pat, id, fetchImpl });
31395
+ const got = args.prefetched ?? await getFile({ baseUrl, pat, id, fetchImpl });
31162
31396
  const abs = filesSubtreePath(workspaceSlug, relPath);
31163
31397
  if (!got.ok) {
31164
31398
  suppressor?.registerDelete(abs);
@@ -31204,7 +31438,7 @@ async function pushFilesUpsert(args) {
31204
31438
  } else {
31205
31439
  let st;
31206
31440
  try {
31207
- st = statSync6(absolutePath);
31441
+ st = statSync7(absolutePath);
31208
31442
  } catch (err) {
31209
31443
  if (err?.code === "ENOENT") {
31210
31444
  return pushFilesDelete({ workspaceSlug, baseUrl, pat, relPath, state, deps });
@@ -31254,6 +31488,42 @@ async function pushFilesUpsert(args) {
31254
31488
  return { outcome: { kind: "noop", relPath }, state };
31255
31489
  }
31256
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
+ };
31257
31527
  const res = await postFile({
31258
31528
  baseUrl,
31259
31529
  pat,
@@ -31264,43 +31534,38 @@ async function pushFilesUpsert(args) {
31264
31534
  expectedUpdatedAt: existing?.updated_at ?? null,
31265
31535
  fetchImpl
31266
31536
  });
31267
- if (res.ok) {
31268
- const action = existing ? "updated" : "created";
31269
- output?.log(filePushLine({ path: relPath, id: res.row.id, action }));
31270
- const next = persistFiles(state, {
31271
- id: res.row.id,
31272
- path: relPath,
31273
- content_hash: hash2,
31274
- size_bytes: res.row.size_bytes,
31275
- mime_type: res.row.mime_type,
31276
- updated_at: res.row.updated_at,
31277
- inode
31278
- });
31279
- return { outcome: { kind: action, relPath, id: res.row.id }, state: next };
31280
- }
31281
- if (res.code === 413) {
31282
- const serverMax = res.maxBytes;
31283
- const max = serverMax ?? MAX_WORKSPACE_FILE_BYTES;
31284
- const source = serverMax === null ? "client" : "server";
31285
- output?.log(fileOverCapLine({ path: relPath, size, max, source }));
31286
- const next = recordFileOverCap({
31287
- state,
31288
- relPath,
31289
- size,
31290
- max,
31291
- source,
31292
- tracked: existing !== undefined,
31293
- now
31294
- });
31295
- return { outcome: { kind: "over_cap", relPath, size, max, source }, state: next };
31296
- }
31297
- if (res.code === 400) {
31298
- output?.log(fileFailedLine({ path: relPath, status: 400, reason: "hash_mismatch" }));
31299
- 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
+ }
31300
31566
  }
31301
31567
  const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes });
31302
31568
  output?.log(fileConflictLine({ path: relPath, stash: stashPath }));
31303
- let nextState = state;
31304
31569
  if (existing) {
31305
31570
  nextState = await restoreFileFromCloud({
31306
31571
  workspaceSlug,
@@ -31311,7 +31576,8 @@ async function pushFilesUpsert(args) {
31311
31576
  state,
31312
31577
  fetchImpl,
31313
31578
  suppressor,
31314
- output
31579
+ output,
31580
+ prefetched: cloudRow
31315
31581
  });
31316
31582
  }
31317
31583
  return { outcome: { kind: "conflict", relPath, stashPath }, state: nextState };
@@ -31376,7 +31642,7 @@ class PerIdMutex {
31376
31642
 
31377
31643
  // src/lib/push.ts
31378
31644
  var import_gray_matter9 = __toESM(require_gray_matter(), 1);
31379
- import { readFileSync as readFileSync14, statSync as statSync7 } from "node:fs";
31645
+ import { readFileSync as readFileSync14, statSync as statSync8 } from "node:fs";
31380
31646
  import { basename as basename4, relative as relative4 } from "node:path";
31381
31647
 
31382
31648
  // src/lib/pull-write.ts
@@ -31474,22 +31740,31 @@ function omitKeys(obj, keys) {
31474
31740
  }
31475
31741
  function safeInode2(absolutePath) {
31476
31742
  try {
31477
- return statSync7(absolutePath).ino;
31743
+ return statSync8(absolutePath).ino;
31478
31744
  } catch {
31479
31745
  return;
31480
31746
  }
31481
31747
  }
31482
31748
  function persistEntry(state, entityType, entry2) {
31483
31749
  const { state: nextState, changed } = upsertEntry(state, entityType, entry2);
31484
- if (changed)
31485
- writeState(nextState);
31486
- return nextState;
31750
+ if (!changed)
31751
+ return state;
31752
+ return commitState(state, nextState);
31487
31753
  }
31488
31754
  function dropEntry(state, entityType, id) {
31489
31755
  const { state: nextState, changed } = removeEntry(state, entityType, id);
31490
- if (changed)
31491
- writeState(nextState);
31492
- 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;
31493
31768
  }
31494
31769
  function stampStateFromMirror(args) {
31495
31770
  const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
@@ -31938,7 +32213,7 @@ async function runPushUpsert(args) {
31938
32213
  state: nextState2
31939
32214
  };
31940
32215
  }
31941
- const patchResult = await patchEntity({
32216
+ let patchResult = await patchEntity({
31942
32217
  strategy,
31943
32218
  baseUrl: args.baseUrl,
31944
32219
  pat: args.pat,
@@ -31949,6 +32224,34 @@ async function runPushUpsert(args) {
31949
32224
  topLevel: wireTopLevel,
31950
32225
  fetchImpl: args.fetchImpl
31951
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
+ }
31952
32255
  if (patchResult.ok) {
31953
32256
  const updated = {
31954
32257
  id: patchResult.doc.id,
@@ -32421,7 +32724,7 @@ async function pushRename(args) {
32421
32724
  return mutex.run(id, async () => {
32422
32725
  const live = getEntry(state, entityType, id);
32423
32726
  const effectiveExpected = live?.updated_at ?? expectedUpdatedAt;
32424
- const patchResult = await patchEntity({
32727
+ let patchResult = await patchEntity({
32425
32728
  strategy,
32426
32729
  baseUrl,
32427
32730
  pat,
@@ -32430,6 +32733,26 @@ async function pushRename(args) {
32430
32733
  slug: toSlug,
32431
32734
  fetchImpl
32432
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
+ }
32433
32756
  if (patchResult.ok) {
32434
32757
  const doc2 = patchResult.doc;
32435
32758
  const prior = live ?? null;
@@ -32864,7 +33187,7 @@ class FileDestructiveGate {
32864
33187
  }
32865
33188
 
32866
33189
  // src/lib/files/reconcile.ts
32867
- import { readFileSync as readFileSync16, statSync as statSync9 } from "node:fs";
33190
+ import { readFileSync as readFileSync16, statSync as statSync10 } from "node:fs";
32868
33191
 
32869
33192
  // src/lib/unsynced.ts
32870
33193
  import { join as join11, relative as relative6 } from "node:path";
@@ -32879,9 +33202,9 @@ function isRecordableFailure(err) {
32879
33202
  }
32880
33203
  function persistUpsert(state, candidate) {
32881
33204
  const { state: next, changed } = upsertUnsynced(state, candidate);
32882
- if (changed)
32883
- writeState(next);
32884
- return next;
33205
+ if (!changed)
33206
+ return state;
33207
+ return commitState(state, next);
32885
33208
  }
32886
33209
  function recordEntityUnsynced(args) {
32887
33210
  const cat = categorizeError(args.err);
@@ -32920,11 +33243,10 @@ async function drainUnsyncedOps(args) {
32920
33243
  let stillFailing = 0;
32921
33244
  let rejected = 0;
32922
33245
  const clear = (target, path5) => {
32923
- const { state: next, changed } = removeUnsynced(getState(), target, path5);
32924
- if (changed) {
32925
- writeState(next);
32926
- setState(next);
32927
- }
33246
+ const before = getState();
33247
+ const { state: next, changed } = removeUnsynced(before, target, path5);
33248
+ if (changed)
33249
+ setState(commitState(before, next));
32928
33250
  };
32929
33251
  for (const op of ops) {
32930
33252
  attempted += 1;
@@ -32976,7 +33298,8 @@ async function drainUnsyncedOps(args) {
32976
33298
  const cat = categorizeError(err);
32977
33299
  if (isPermanentReject(err)) {
32978
33300
  rejected += 1;
32979
- const removed = removeUnsynced(getState(), op.target, op.path).state;
33301
+ const beforeReject = getState();
33302
+ const removed = removeUnsynced(beforeReject, op.target, op.path).state;
32980
33303
  const { state: next2 } = upsertRejected(removed, {
32981
33304
  target: op.target,
32982
33305
  op: op.op,
@@ -32987,22 +33310,20 @@ async function drainUnsyncedOps(args) {
32987
33310
  message: cat.serverMessage,
32988
33311
  rejected_at: now().toISOString()
32989
33312
  });
32990
- writeState(next2);
32991
- setState(next2);
33313
+ setState(commitState(beforeReject, next2));
32992
33314
  continue;
32993
33315
  }
32994
33316
  stillFailing += 1;
32995
- const { state: next, changed } = upsertUnsynced(getState(), {
33317
+ const beforeRetry = getState();
33318
+ const { state: next, changed } = upsertUnsynced(beforeRetry, {
32996
33319
  target: op.target,
32997
33320
  op: op.op,
32998
33321
  path: op.path,
32999
33322
  last_status: cat.status,
33000
33323
  last_reason: cat.reason
33001
33324
  });
33002
- if (changed) {
33003
- writeState(next);
33004
- setState(next);
33005
- }
33325
+ if (changed)
33326
+ setState(commitState(beforeRetry, next));
33006
33327
  }
33007
33328
  }
33008
33329
  return { attempted, resolved, stillFailing, rejected };
@@ -34621,7 +34942,7 @@ function watch(paths, options2 = {}) {
34621
34942
  var chokidar_default = { watch, FSWatcher };
34622
34943
 
34623
34944
  // src/lib/watcher.ts
34624
- import { statSync as statSync8 } from "node:fs";
34945
+ import { statSync as statSync9 } from "node:fs";
34625
34946
  import { sep as sep5 } from "node:path";
34626
34947
 
34627
34948
  // src/lib/rename-classifier.ts
@@ -34837,7 +35158,7 @@ function startWatcher(opts, handler) {
34837
35158
  pairingWindowMs: opts.renamePairingWindowMs ?? DEFAULT_DEBOUNCE_MS,
34838
35159
  classifyPath: opts.classifyPath,
34839
35160
  getStateEntry: opts.getStateEntry,
34840
- statSync: opts.statSync ?? ((p) => ({ ino: statSync8(p).ino }))
35161
+ statSync: opts.statSync ?? ((p) => ({ ino: statSync9(p).ino }))
34841
35162
  });
34842
35163
  const factory = opts.chokidarFactory ?? ((paths, watcherOpts) => chokidar_default.watch(paths, watcherOpts));
34843
35164
  const watcher = factory(workspaceRoot, {
@@ -34872,7 +35193,7 @@ function readBlobForPush(args) {
34872
35193
  const { absolutePath, relPath, output } = args;
34873
35194
  let st;
34874
35195
  try {
34875
- st = statSync9(absolutePath);
35196
+ st = statSync10(absolutePath);
34876
35197
  } catch (err) {
34877
35198
  if (err?.code === "ENOENT")
34878
35199
  return { kind: "gone" };
@@ -35110,9 +35431,9 @@ function conflictSlug(slug) {
35110
35431
  }
35111
35432
  function persistEntry2(state, entry2) {
35112
35433
  const { state: next, changed } = upsertEntry(state, ENTITY_TYPE, entry2);
35113
- if (changed)
35114
- writeState(next);
35115
- return next;
35434
+ if (!changed)
35435
+ return state;
35436
+ return commitState(state, next);
35116
35437
  }
35117
35438
  function memoryStub(name) {
35118
35439
  return `# ${name} — Long-term memory
@@ -36574,9 +36895,9 @@ function writeLocal(workspaceSlug, body, suppressor) {
36574
36895
  }
36575
36896
  function persistRules(state, entry2) {
36576
36897
  const { state: next, changed } = setWorkspaceRulesState(state, entry2);
36577
- if (changed)
36578
- writeState(next);
36579
- return next;
36898
+ if (!changed)
36899
+ return state;
36900
+ return commitState(state, next);
36580
36901
  }
36581
36902
  function classifyWorkspaceRulesPath(args) {
36582
36903
  let root;
@@ -36692,6 +37013,30 @@ async function pushWorkspaceRules(args) {
36692
37013
  output?.warn(`${WORKSPACE_RULES_FILENAME} is over the ${Math.round(MAX_WORKSPACE_RULES_BYTES / 1024)}KB cap — not synced.`);
36693
37014
  return { state, outcome: { kind: "over_cap" } };
36694
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
+ }
36695
37040
  const stashPath = stashConflict({
36696
37041
  workspaceSlug,
36697
37042
  entityType: CONFLICT_ENTITY,
@@ -36834,9 +37179,9 @@ function writeLocal2(workspaceSlug, body, suppressor) {
36834
37179
  }
36835
37180
  function persistMemory(state, entry2) {
36836
37181
  const { state: next, changed } = setWorkspaceMemoryState(state, entry2);
36837
- if (changed)
36838
- writeState(next);
36839
- return next;
37182
+ if (!changed)
37183
+ return state;
37184
+ return commitState(state, next);
36840
37185
  }
36841
37186
  function classifyWorkspaceMemoryPath(args) {
36842
37187
  let root;
@@ -36934,6 +37279,30 @@ async function pushWorkspaceMemory(args) {
36934
37279
  output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
36935
37280
  return { state, outcome: { kind: "over_cap" } };
36936
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
+ }
36937
37306
  const stashPath = stashConflict({
36938
37307
  workspaceSlug,
36939
37308
  entityType: CONFLICT_ENTITY2,
@@ -37078,9 +37447,9 @@ function writeLocal3(descriptor, workspaceSlug, body, suppressor) {
37078
37447
  }
37079
37448
  function persist2(descriptor, state, entry2) {
37080
37449
  const { state: next, changed } = descriptor.setState(state, entry2);
37081
- if (changed)
37082
- writeState(next);
37083
- return next;
37450
+ if (!changed)
37451
+ return state;
37452
+ return commitState(state, next);
37084
37453
  }
37085
37454
  function classifySingletonPath(descriptor, args) {
37086
37455
  let root;
@@ -37178,6 +37547,30 @@ async function pushSingleton(descriptor, args) {
37178
37547
  output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
37179
37548
  return { state, outcome: { kind: "over_cap" } };
37180
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
+ }
37181
37574
  const stashPath = stashConflict({
37182
37575
  workspaceSlug,
37183
37576
  entityType: descriptor.conflictEntity,
@@ -37326,15 +37719,15 @@ function createThrottledFetch(baseFetch, opts = {}) {
37326
37719
  // src/commands/sync.ts
37327
37720
  import {
37328
37721
  existsSync as existsSync6,
37329
- readdirSync as readdirSync5,
37722
+ readdirSync as readdirSync6,
37330
37723
  readFileSync as readFileSync19,
37331
- statSync as statSync11,
37724
+ statSync as statSync12,
37332
37725
  unlinkSync as unlinkSync7
37333
37726
  } from "node:fs";
37334
37727
  import { join as join14 } from "node:path";
37335
37728
 
37336
37729
  // src/lib/files/pull.ts
37337
- 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";
37338
37731
 
37339
37732
  // src/lib/files/extracted-text.ts
37340
37733
  import { createHash as createHash3 } from "node:crypto";
@@ -37430,6 +37823,7 @@ async function pullFiles(args) {
37430
37823
  let cursorBlocked = false;
37431
37824
  while (true) {
37432
37825
  const page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
37826
+ const pageBase = state;
37433
37827
  let pageTextFailed = false;
37434
37828
  for (const row of page.rows) {
37435
37829
  fetched++;
@@ -37460,6 +37854,8 @@ async function pullFiles(args) {
37460
37854
  if (outcome.textFailed)
37461
37855
  pageTextFailed = true;
37462
37856
  }
37857
+ if (args.commit && state !== pageBase)
37858
+ state = args.commit(pageBase, state);
37463
37859
  if (pageTextFailed)
37464
37860
  cursorBlocked = true;
37465
37861
  if (page.next_cursor === null)
@@ -37665,7 +38061,7 @@ async function applyFileRow(args) {
37665
38061
  }
37666
38062
  let inode;
37667
38063
  try {
37668
- inode = statSync10(abs).ino;
38064
+ inode = statSync11(abs).ino;
37669
38065
  } catch {
37670
38066
  inode = undefined;
37671
38067
  }
@@ -37747,11 +38143,11 @@ function hasExistingContent(workspacePath) {
37747
38143
  if (!existsSync6(workspacePath))
37748
38144
  return false;
37749
38145
  try {
37750
- return readdirSync5(workspacePath, { withFileTypes: true }).some((e2) => {
38146
+ return readdirSync6(workspacePath, { withFileTypes: true }).some((e2) => {
37751
38147
  if (!e2.isDirectory())
37752
38148
  return true;
37753
38149
  try {
37754
- return readdirSync5(join14(workspacePath, e2.name)).length > 0;
38150
+ return readdirSync6(join14(workspacePath, e2.name)).length > 0;
37755
38151
  } catch {
37756
38152
  return true;
37757
38153
  }
@@ -37771,7 +38167,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
37771
38167
  }
37772
38168
  let liveInode;
37773
38169
  try {
37774
- liveInode = statSync11(target).ino;
38170
+ liveInode = statSync12(target).ino;
37775
38171
  } catch {
37776
38172
  return { didUnlink: false, skippedReason: "absent" };
37777
38173
  }
@@ -37828,7 +38224,13 @@ async function runSync(workspaceSlug, deps = {}) {
37828
38224
  out.info(`Syncing ${slug}…`);
37829
38225
  const startedAt = now();
37830
38226
  let state = loadState(slug);
37831
- 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
+ };
37832
38234
  const counters = {};
37833
38235
  try {
37834
38236
  for (const entityType of allEntityTypes()) {
@@ -37850,13 +38252,13 @@ async function runSync(workspaceSlug, deps = {}) {
37850
38252
  const outcome = applyRow(slug, strategy, row, state, out, deps.suppressor, now, deps.isFrozen);
37851
38253
  if (outcome.state !== state) {
37852
38254
  state = outcome.state;
37853
- stateDirty = true;
37854
38255
  }
37855
38256
  if (outcome.action === "written")
37856
38257
  ctr.written++;
37857
38258
  else if (outcome.action === "deleted")
37858
38259
  ctr.deleted++;
37859
38260
  }
38261
+ commitPending();
37860
38262
  if (page.next_cursor === null)
37861
38263
  break;
37862
38264
  lastAdvancedCursor = page.next_cursor;
@@ -37883,21 +38285,21 @@ async function runSync(workspaceSlug, deps = {}) {
37883
38285
  output: out,
37884
38286
  suppressor: deps.suppressor,
37885
38287
  isFileFrozen: deps.isFileFrozen,
38288
+ commit: commitState,
37886
38289
  forceTextRewalk: needsDerivedTextRewalk
37887
38290
  });
37888
38291
  if (filesResult.state !== state) {
37889
38292
  state = filesResult.state;
37890
- stateDirty = true;
37891
38293
  }
37892
38294
  if (needsDerivedTextRewalk && filesResult.textUnknown === 0) {
37893
38295
  state = { ...state, derived_text_version: DERIVED_TEXT_VERSION };
37894
- stateDirty = true;
37895
38296
  }
37896
38297
  counters["files"] = {
37897
38298
  fetched: filesResult.fetched,
37898
38299
  written: filesResult.written + filesResult.textWritten,
37899
38300
  deleted: filesResult.deleted + filesResult.textDeleted
37900
38301
  };
38302
+ commitPending();
37901
38303
  if (filesResult.lastCursor !== filesStartCursor) {
37902
38304
  updateSyncCursor(slug, "files", filesResult.lastCursor);
37903
38305
  }
@@ -37913,8 +38315,8 @@ async function runSync(workspaceSlug, deps = {}) {
37913
38315
  });
37914
38316
  if (rulesResult.state !== state) {
37915
38317
  state = rulesResult.state;
37916
- stateDirty = true;
37917
38318
  }
38319
+ commitPending();
37918
38320
  if (rulesResult.action === "written") {
37919
38321
  counters["workspace-rules"] = { fetched: 1, written: 1, deleted: 0 };
37920
38322
  }
@@ -37930,8 +38332,8 @@ async function runSync(workspaceSlug, deps = {}) {
37930
38332
  });
37931
38333
  if (memoryResult.state !== state) {
37932
38334
  state = memoryResult.state;
37933
- stateDirty = true;
37934
38335
  }
38336
+ commitPending();
37935
38337
  if (memoryResult.action === "written") {
37936
38338
  counters["workspace-memory"] = { fetched: 1, written: 1, deleted: 0 };
37937
38339
  }
@@ -37947,8 +38349,8 @@ async function runSync(workspaceSlug, deps = {}) {
37947
38349
  });
37948
38350
  if (userMemoryResult.state !== state) {
37949
38351
  state = userMemoryResult.state;
37950
- stateDirty = true;
37951
38352
  }
38353
+ commitPending();
37952
38354
  if (userMemoryResult.action === "written") {
37953
38355
  counters["user-memory"] = { fetched: 1, written: 1, deleted: 0 };
37954
38356
  }
@@ -37964,8 +38366,7 @@ async function runSync(workspaceSlug, deps = {}) {
37964
38366
  }
37965
38367
  try {
37966
38368
  updateLastSyncedAt(slug, now().toISOString());
37967
- if (stateDirty)
37968
- writeState(state);
38369
+ commitPending();
37969
38370
  } catch (err) {
37970
38371
  const msg = err instanceof Error ? err.message : "unknown error";
37971
38372
  out.error(`Sync state could not be persisted: ${msg}`);
@@ -38101,7 +38502,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
38101
38502
  const hashes = computeMirrorHashes(strategy, content);
38102
38503
  let inode;
38103
38504
  try {
38104
- inode = statSync11(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38505
+ inode = statSync12(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38105
38506
  } catch {
38106
38507
  inode = undefined;
38107
38508
  }
@@ -38158,7 +38559,7 @@ var syncCommand = defineCommand({
38158
38559
  // src/commands/start.ts
38159
38560
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
38160
38561
  var PULL_HEARTBEAT_MS = 900000;
38161
- function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync12(p)) {
38562
+ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync13(p)) {
38162
38563
  let changed = false;
38163
38564
  const nextEntries = {};
38164
38565
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -38288,7 +38689,7 @@ function makeWatchHandler(deps) {
38288
38689
  }
38289
38690
  let st;
38290
38691
  try {
38291
- st = statSync12(absolutePath);
38692
+ st = statSync13(absolutePath);
38292
38693
  } catch (err) {
38293
38694
  if (err?.code === "ENOENT")
38294
38695
  return;
@@ -38944,12 +39345,13 @@ async function runStart(workspaceSlug, deps = {}) {
38944
39345
  if (initial.exitCode !== 0)
38945
39346
  return initial;
38946
39347
  let state = loadState(slug);
39348
+ const beforeBackfill = state;
38947
39349
  const inodeBackfill = backfillStateInodes(slug, state);
38948
39350
  state = inodeBackfill.state;
38949
39351
  const lenBackfill = backfillBodyLengths(slug, state);
38950
39352
  state = lenBackfill.state;
38951
39353
  if (inodeBackfill.changed || lenBackfill.changed) {
38952
- writeState(state);
39354
+ state = commitState(beforeBackfill, state);
38953
39355
  }
38954
39356
  const spinner = (deps.spinnerFactory ?? (() => createSpinner({ noColor: deps.noColor })))();
38955
39357
  spinner.start(`Watching ${slug} · ${watchingSummary(state)}`);
@@ -39582,7 +39984,7 @@ var startCommand = defineCommand({
39582
39984
  });
39583
39985
 
39584
39986
  // src/commands/status.ts
39585
- import { statSync as statSync13 } from "node:fs";
39987
+ import { statSync as statSync14 } from "node:fs";
39586
39988
 
39587
39989
  // src/lib/build-stamp.ts
39588
39990
  import { execFileSync as execFileSync4 } from "node:child_process";
@@ -39718,7 +40120,7 @@ function renderBuild(out, staleness) {
39718
40120
  }
39719
40121
  function lastActivity(now) {
39720
40122
  try {
39721
- const mtimeMs = statSync13(daemonLogPath()).mtimeMs;
40123
+ const mtimeMs = statSync14(daemonLogPath()).mtimeMs;
39722
40124
  return formatRelative(now.getTime() - mtimeMs);
39723
40125
  } catch {
39724
40126
  return null;
@@ -39823,6 +40225,17 @@ async function runStatus(args, deps = {}) {
39823
40225
  out.info(cont(`…and ${rejected.length - MAX_SHOWN} more (see ~/.plinth/daemon.log).`));
39824
40226
  }
39825
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
+ }
39826
40239
  return { exitCode: 0 };
39827
40240
  }
39828
40241
  var statusCommand = defineCommand({
@@ -40161,7 +40574,7 @@ function denyUngated(detail) {
40161
40574
  }
40162
40575
 
40163
40576
  // src/lib/voice-gate/payload.ts
40164
- import { readFileSync as readFileSync23, statSync as statSync14 } from "node:fs";
40577
+ import { readFileSync as readFileSync23, statSync as statSync15 } from "node:fs";
40165
40578
  import { homedir as homedir2 } from "node:os";
40166
40579
  import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
40167
40580
  var MAX_BODY_BYTES = 256 * 1024;
@@ -40318,7 +40731,7 @@ function isHelperInvocation(tokens) {
40318
40731
  return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
40319
40732
  }
40320
40733
  function defaultReadBodyFile(path5) {
40321
- const stat4 = statSync14(path5);
40734
+ const stat4 = statSync15(path5);
40322
40735
  if (stat4.size > MAX_BODY_BYTES) {
40323
40736
  throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
40324
40737
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plinth-music/cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Plinth capstone sync daemon and CLI",
5
5
  "type": "module",
6
6
  "private": false,