@plinth-music/cli 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.15.0 - 2026-08-31
6
+
7
+ One code PR since `0.14.0` (`v0.14.0..f293dd0`, #153). A minor because sync gains a
8
+ refusal: there is now a case where Plinth declines to upload a file.
9
+
10
+ - ⚠️ **A memory file that looks like it carries a credential is held back instead of
11
+ synced.** The file is never modified and nothing is lost - it stays on your machine
12
+ until you decide. Plinth is not approved to store credentials, so it will not take one
13
+ without you saying so explicitly. Sharing one with your own team is not the problem and
14
+ the message does not treat it as one.
15
+ - **You clear it in one of two ways:** take the line out, or mark it `plinth-allow-secret`
16
+ on the line itself. The marker sits in the file where your teammates can see it, so an
17
+ allow is a decision on the record rather than a setting nobody can find.
18
+ - **`plinth status` names the file, the line and the remedy**, and the daemon logs it. The
19
+ finding is local only - it is never posted to your team, because "there is a key at line
20
+ 42 of workspace-memory.md" is a pointer straight at the key.
21
+ - **It matches vendor-prefixed shapes only** (AWS, GitHub, Slack, Stripe, Google,
22
+ OpenAI/Anthropic, private-key blocks, JWTs, signed URLs). Recall is deliberately low:
23
+ this stops a recognisable token going up by accident, and it is not a guarantee against
24
+ a determined leak. Measured against every real memory file on the first workspace
25
+ (81,897 bytes across 12 files): no false positives.
26
+ - Covers `workspace-memory.md`, `user-memory.md` and `artists/<slug>/memory.md`.
27
+
28
+ ⚠️ **The check runs under the daemon only.** `plinth sync` is pull-only and never scans, so
29
+ a workspace with no daemon running is not protected.
30
+
31
+ The desktop app pins `0.14.0` exact and picks none of this up until its own release repins.
32
+
5
33
  ## 0.14.0 - 2026-08-31
6
34
 
7
35
  One code PR since `0.13.0` (`v0.13.0..50ab852`, #150; #149 was 0.13.0's own History row).
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "4746a5177bbf94305bc067f283c50818cd24b045",
2
+ "sha": "71f22efc85823f5d401c67709224d315e7504974",
3
3
  "dirty": false,
4
- "built_at": "2026-08-31T14:55:47.045Z"
4
+ "built_at": "2026-08-31T19:26:38.895Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -7584,7 +7584,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
7584
7584
  // package.json
7585
7585
  var package_default = {
7586
7586
  name: "@plinth-music/cli",
7587
- version: "0.14.0",
7587
+ version: "0.15.0",
7588
7588
  description: "Plinth capstone sync daemon and CLI",
7589
7589
  type: "module",
7590
7590
  private: false,
@@ -30541,6 +30541,108 @@ function calendarNoticeLine(kind) {
30541
30541
  return `[${token} reason=${kind}]`;
30542
30542
  }
30543
30543
 
30544
+ // src/lib/context/secret-scan.ts
30545
+ var HOLD_ON_SECRET = true;
30546
+ var ALLOW_SECRET_MARKER = "plinth-allow-secret";
30547
+ var SECRET_HELD_REASON = "possible_secret";
30548
+ var SECRET_RULES = [
30549
+ {
30550
+ id: "aws-access-key-id",
30551
+ label: "an AWS access key id",
30552
+ pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/
30553
+ },
30554
+ {
30555
+ id: "github-token",
30556
+ label: "a GitHub token",
30557
+ pattern: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/
30558
+ },
30559
+ {
30560
+ id: "slack-token",
30561
+ label: "a Slack token",
30562
+ pattern: /\bxox[baprse]-[0-9A-Za-z-]{10,}/
30563
+ },
30564
+ {
30565
+ id: "google-api-key",
30566
+ label: "a Google API key",
30567
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/
30568
+ },
30569
+ {
30570
+ id: "anthropic-openai-key",
30571
+ label: "an Anthropic or OpenAI API key",
30572
+ pattern: /\bsk-(?:ant-|proj-)?(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{20,}/
30573
+ },
30574
+ {
30575
+ id: "stripe-secret-key",
30576
+ label: "a Stripe secret key",
30577
+ pattern: /\b[sr]k_live_[0-9A-Za-z]{16,}\b/
30578
+ },
30579
+ {
30580
+ id: "private-key-block",
30581
+ label: "a private key block",
30582
+ pattern: /-----BEGIN(?: [A-Z]+)* PRIVATE KEY-----/
30583
+ },
30584
+ {
30585
+ id: "jwt",
30586
+ label: "a JSON Web Token",
30587
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}/
30588
+ },
30589
+ {
30590
+ id: "signed-url",
30591
+ label: "a signed URL carrying its own credential",
30592
+ pattern: /[?&](?:X-Amz-Signature|X-Goog-Signature|Signature|sig)=[A-Za-z0-9%._-]{16,}/i
30593
+ }
30594
+ ];
30595
+ function redactMatch(match) {
30596
+ const prefix = match.length >= 12 ? match.slice(0, 4) : "";
30597
+ return `${prefix}… ${match.length} characters`;
30598
+ }
30599
+ function lineIsAllowed(line) {
30600
+ return line.toLowerCase().includes(ALLOW_SECRET_MARKER);
30601
+ }
30602
+ function scanForSecrets(raw) {
30603
+ const findings = [];
30604
+ const lines = raw.split(`
30605
+ `);
30606
+ for (let i = 0;i < lines.length; i++) {
30607
+ const line = lines[i] ?? "";
30608
+ if (lineIsAllowed(line))
30609
+ continue;
30610
+ for (const rule of SECRET_RULES) {
30611
+ const m = rule.pattern.exec(line);
30612
+ if (!m)
30613
+ continue;
30614
+ findings.push({
30615
+ line: i + 1,
30616
+ ruleId: rule.id,
30617
+ label: rule.label,
30618
+ excerpt: redactMatch(m[0])
30619
+ });
30620
+ break;
30621
+ }
30622
+ }
30623
+ return findings;
30624
+ }
30625
+ var MAX_NAMED_SECRET_FINDINGS = 3;
30626
+ function secretHoldReason(findings, hold = HOLD_ON_SECRET) {
30627
+ const named = findings.slice(0, MAX_NAMED_SECRET_FINDINGS);
30628
+ const rest = findings.length - named.length;
30629
+ const detail = named.map((f) => `line ${f.line} looks like ${f.label} (${f.excerpt})`).join("; ");
30630
+ const more = rest > 0 ? `, and ${rest} more` : "";
30631
+ return `${detail.charAt(0).toUpperCase()}${detail.slice(1)}${more}. ` + `Plinth is not approved to store credentials yet, so ` + (hold ? `this file stays on this machine until you say otherwise. ` + `Remove the value, or add ${ALLOW_SECRET_MARKER} to that line to ` + `store it anyway. ` : `it has already reached Plinth on this sync. Rotate it if it is ` + `live, then remove the value — or add ${ALLOW_SECRET_MARKER} to that ` + `line to stop being told. `) + `Nothing is changed in your file either way.`;
30632
+ }
30633
+ function secretHoldSummary(findings) {
30634
+ const n = findings.length;
30635
+ return `${n} possible credential${n === 1 ? "" : "s"} found`;
30636
+ }
30637
+ function secretHoldMessage(filename, findings, hold = HOLD_ON_SECRET) {
30638
+ const verb = hold ? "was not synced" : "was synced";
30639
+ return `${filename} ${verb}. ${secretHoldReason(findings, hold)}`;
30640
+ }
30641
+ function secretHoldRecord(findings, hold = HOLD_ON_SECRET) {
30642
+ const verdict = hold ? "so this file was not synced" : "and it synced anyway";
30643
+ return `${secretHoldSummary(findings)}, ${verdict}. ${secretHoldReason(findings, hold)}`;
30644
+ }
30645
+
30544
30646
  // src/lib/rejected.ts
30545
30647
  var PERMANENT_REJECT_REASONS = new Set(["bad_request", "invalid_frontmatter"]);
30546
30648
  function isPermanentReject(err) {
@@ -30690,6 +30792,45 @@ function clearReportedSafeFileRejected(state, relPath) {
30690
30792
  return state;
30691
30793
  return clearFileRejected(state, relPath);
30692
30794
  }
30795
+ function recordMemorySecretHeld(args) {
30796
+ let path3;
30797
+ try {
30798
+ path3 = relative3(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
30799
+ } catch {
30800
+ path3 = args.absolutePath;
30801
+ }
30802
+ const withoutStale = removeUnsynced(args.state, "entity", path3).state;
30803
+ return persist(withoutStale, upsertRejected(withoutStale, {
30804
+ target: "entity",
30805
+ op: "upsert",
30806
+ path: path3,
30807
+ reason: SECRET_HELD_REASON,
30808
+ message: args.record,
30809
+ rejected_at: args.now().toISOString()
30810
+ }));
30811
+ }
30812
+ function secretHoldRecordFor(state, workspaceSlug, absolutePath) {
30813
+ let path3;
30814
+ try {
30815
+ path3 = relative3(workspaceMirrorPath(workspaceSlug), absolutePath);
30816
+ } catch {
30817
+ path3 = absolutePath;
30818
+ }
30819
+ return (state.rejected ?? []).find((o) => o.target === "entity" && o.path === path3 && o.reason === SECRET_HELD_REASON);
30820
+ }
30821
+ function clearMemorySecretHeld(state, workspaceSlug, absolutePath) {
30822
+ let path3;
30823
+ try {
30824
+ path3 = relative3(workspaceMirrorPath(workspaceSlug), absolutePath);
30825
+ } catch {
30826
+ path3 = absolutePath;
30827
+ }
30828
+ const list = state.rejected ?? [];
30829
+ const next = list.filter((o) => !(o.target === "entity" && o.path === path3 && o.reason === SECRET_HELD_REASON));
30830
+ if (next.length === list.length)
30831
+ return state;
30832
+ return commitState(state, { ...state, rejected: next });
30833
+ }
30693
30834
 
30694
30835
  // src/lib/context/workspace-claude-md.ts
30695
30836
  var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
@@ -36186,6 +36327,41 @@ async function reconcileFilesSubtree(args) {
36186
36327
 
36187
36328
  // src/lib/artist-memory/sync.ts
36188
36329
  import { relative as relative9 } from "node:path";
36330
+
36331
+ // src/lib/context/secret-gate.ts
36332
+ function applySecretGate(args) {
36333
+ const hold = args.hold ?? HOLD_ON_SECRET;
36334
+ const release = () => ({
36335
+ state: clearMemorySecretHeld(args.state, args.workspaceSlug, args.absolutePath),
36336
+ held: false,
36337
+ findings: []
36338
+ });
36339
+ if (args.alreadyAcked)
36340
+ return release();
36341
+ const findings = scanForSecrets(args.body);
36342
+ if (findings.length === 0)
36343
+ return release();
36344
+ const record2 = secretHoldRecord(findings, hold);
36345
+ const standing = secretHoldRecordFor(args.state, args.workspaceSlug, args.absolutePath);
36346
+ if (standing?.message === record2) {
36347
+ return { state: args.state, held: hold, findings };
36348
+ }
36349
+ args.output?.warn(secretHoldMessage(args.filename, findings, hold));
36350
+ args.output?.log(`[${args.logTag}_SECRET_HELD ws=${args.workspaceSlug} ` + `findings=${findings.length} held=${hold}]`);
36351
+ return {
36352
+ state: recordMemorySecretHeld({
36353
+ state: args.state,
36354
+ workspaceSlug: args.workspaceSlug,
36355
+ absolutePath: args.absolutePath,
36356
+ record: record2,
36357
+ now: args.now
36358
+ }),
36359
+ held: hold,
36360
+ findings
36361
+ };
36362
+ }
36363
+
36364
+ // src/lib/artist-memory/sync.ts
36189
36365
  var MAX_MEMORY_PUSH_ATTEMPTS = 3;
36190
36366
  var ENTITY_TYPE = "artists";
36191
36367
  function conflictSlug(slug) {
@@ -36293,11 +36469,26 @@ async function pushArtistMemory(args) {
36293
36469
  }
36294
36470
  const localHash = stableHash(local);
36295
36471
  const runPatch = async () => {
36296
- const base2 = args.mutex ? loadState(workspaceSlug) : state;
36472
+ let base2 = args.mutex ? loadState(workspaceSlug) : state;
36297
36473
  const entry2 = getEntryBySlug(base2, ENTITY_TYPE, slug);
36298
36474
  if (!entry2)
36299
36475
  return { state: base2, outcome: { kind: "ignored", slug } };
36300
- if (entry2.memory_hash === localHash) {
36476
+ const alreadyAcked = entry2.memory_hash === localHash;
36477
+ const gate = applySecretGate({
36478
+ state: base2,
36479
+ workspaceSlug,
36480
+ absolutePath: artistMemoryFilePath(workspaceSlug, slug),
36481
+ filename: `artists/${slug}/${ARTIST_MEMORY_ANCHOR}`,
36482
+ body: local,
36483
+ alreadyAcked,
36484
+ output,
36485
+ logTag: "ARTIST_MEMORY",
36486
+ now
36487
+ });
36488
+ base2 = gate.state;
36489
+ if (gate.held)
36490
+ return { state: base2, outcome: { kind: "secret_held", slug } };
36491
+ if (alreadyAcked) {
36301
36492
  return { state: base2, outcome: { kind: "noop", slug } };
36302
36493
  }
36303
36494
  const acked = entry2.memory_hash;
@@ -38581,11 +38772,26 @@ async function pushWorkspaceMemory(args) {
38581
38772
  }
38582
38773
  const localHash = stableHash(local);
38583
38774
  const acked = getWorkspaceMemoryState(state);
38584
- if (acked && acked.body_hash === localHash) {
38585
- return { state, outcome: { kind: "noop" } };
38775
+ const alreadyAcked = Boolean(acked && acked.body_hash === localHash);
38776
+ const gate = applySecretGate({
38777
+ state,
38778
+ workspaceSlug,
38779
+ absolutePath: contextFilePath(workspaceSlug, WORKSPACE_MEMORY_FILENAME),
38780
+ filename: WORKSPACE_MEMORY_FILENAME,
38781
+ body: local,
38782
+ alreadyAcked,
38783
+ output,
38784
+ logTag: "WORKSPACE_MEMORY",
38785
+ now
38786
+ });
38787
+ const gated = gate.state;
38788
+ if (gate.held)
38789
+ return { state: gated, outcome: { kind: "secret_held" } };
38790
+ if (alreadyAcked) {
38791
+ return { state: gated, outcome: { kind: "noop" } };
38586
38792
  }
38587
38793
  if (extractWorkspaceMemory(local) === null && (acked?.updated_at ?? null) === null) {
38588
- const next2 = persistMemory(state, { body_hash: localHash, updated_at: null });
38794
+ const next2 = persistMemory(gated, { body_hash: localHash, updated_at: null });
38589
38795
  return { state: next2, outcome: { kind: "noop" } };
38590
38796
  }
38591
38797
  const res = await putWorkspaceMemory({
@@ -38596,7 +38802,7 @@ async function pushWorkspaceMemory(args) {
38596
38802
  fetchImpl
38597
38803
  });
38598
38804
  if (res.ok) {
38599
- const next2 = persistMemory(state, {
38805
+ const next2 = persistMemory(gated, {
38600
38806
  body_hash: localHash,
38601
38807
  updated_at: res.row.updated_at
38602
38808
  });
@@ -38605,7 +38811,7 @@ async function pushWorkspaceMemory(args) {
38605
38811
  }
38606
38812
  if (res.code === 413) {
38607
38813
  output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
38608
- return { state, outcome: { kind: "over_cap" } };
38814
+ return { state: gated, outcome: { kind: "over_cap" } };
38609
38815
  }
38610
38816
  {
38611
38817
  const probe2 = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
@@ -38618,7 +38824,7 @@ async function pushWorkspaceMemory(args) {
38618
38824
  fetchImpl
38619
38825
  });
38620
38826
  if (retry.ok) {
38621
- const next2 = persistMemory(state, {
38827
+ const next2 = persistMemory(gated, {
38622
38828
  body_hash: localHash,
38623
38829
  updated_at: retry.row.updated_at
38624
38830
  });
@@ -38627,7 +38833,7 @@ async function pushWorkspaceMemory(args) {
38627
38833
  }
38628
38834
  if (retry.code === 413) {
38629
38835
  output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
38630
- return { state, outcome: { kind: "over_cap" } };
38836
+ return { state: gated, outcome: { kind: "over_cap" } };
38631
38837
  }
38632
38838
  }
38633
38839
  }
@@ -38639,7 +38845,7 @@ async function pushWorkspaceMemory(args) {
38639
38845
  contents: local,
38640
38846
  side: "local"
38641
38847
  });
38642
- const next = await restoreFromCloud2(args);
38848
+ const next = await restoreFromCloud2({ ...args, state: gated });
38643
38849
  output?.warn(`Conflict on ${WORKSPACE_MEMORY_FILENAME} -> stashed local edit to ${stashPath} (cloud-wins; cloud copy restored).`);
38644
38850
  return { state: next, outcome: { kind: "conflict", stashPath } };
38645
38851
  }
@@ -38851,11 +39057,29 @@ async function pushSingleton(descriptor, args) {
38851
39057
  }
38852
39058
  const localHash = stableHash(local);
38853
39059
  const acked = descriptor.getState(state);
38854
- if (acked && acked.body_hash === localHash) {
38855
- return { state, outcome: { kind: "noop" } };
39060
+ const alreadyAcked = Boolean(acked && acked.body_hash === localHash);
39061
+ let gated = state;
39062
+ if (descriptor.secretScanned) {
39063
+ const gate = applySecretGate({
39064
+ state,
39065
+ workspaceSlug,
39066
+ absolutePath: filePath(descriptor, workspaceSlug),
39067
+ filename: descriptor.filename,
39068
+ body: local,
39069
+ alreadyAcked,
39070
+ output,
39071
+ logTag: descriptor.logTag,
39072
+ now
39073
+ });
39074
+ gated = gate.state;
39075
+ if (gate.held)
39076
+ return { state: gated, outcome: { kind: "secret_held" } };
39077
+ }
39078
+ if (alreadyAcked) {
39079
+ return { state: gated, outcome: { kind: "noop" } };
38856
39080
  }
38857
39081
  if (descriptor.extractActive(local) === null && (acked?.updated_at ?? null) === null) {
38858
- const next2 = persist2(descriptor, state, { body_hash: localHash, updated_at: null });
39082
+ const next2 = persist2(descriptor, gated, { body_hash: localHash, updated_at: null });
38859
39083
  return { state: next2, outcome: { kind: "noop" } };
38860
39084
  }
38861
39085
  const res = await descriptor.api.put({
@@ -38866,13 +39090,13 @@ async function pushSingleton(descriptor, args) {
38866
39090
  fetchImpl
38867
39091
  });
38868
39092
  if (res.ok) {
38869
- const next2 = persist2(descriptor, state, ackedFrom(res.row, localHash));
39093
+ const next2 = persist2(descriptor, gated, ackedFrom(res.row, localHash));
38870
39094
  output?.log(`[${descriptor.logTag}_PUT ws=${workspaceSlug} is_empty=${res.row.is_empty}` + `${res.row.status ? ` status=${res.row.status}` : ""}]`);
38871
39095
  return { state: next2, outcome: { kind: "pushed" } };
38872
39096
  }
38873
39097
  if (res.code === 413) {
38874
39098
  output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
38875
- return { state, outcome: { kind: "over_cap" } };
39099
+ return { state: gated, outcome: { kind: "over_cap" } };
38876
39100
  }
38877
39101
  {
38878
39102
  const probe2 = await descriptor.api.get({ baseUrl, pat, fetchImpl });
@@ -38885,13 +39109,13 @@ async function pushSingleton(descriptor, args) {
38885
39109
  fetchImpl
38886
39110
  });
38887
39111
  if (retry.ok) {
38888
- const next2 = persist2(descriptor, state, ackedFrom(retry.row, localHash));
39112
+ const next2 = persist2(descriptor, gated, ackedFrom(retry.row, localHash));
38889
39113
  output?.log(`[${descriptor.logTag}_PUT ws=${workspaceSlug} is_empty=${retry.row.is_empty}` + `${retry.row.status ? ` status=${retry.row.status}` : ""}]`);
38890
39114
  return { state: next2, outcome: { kind: "pushed" } };
38891
39115
  }
38892
39116
  if (retry.code === 413) {
38893
39117
  output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
38894
- return { state, outcome: { kind: "over_cap" } };
39118
+ return { state: gated, outcome: { kind: "over_cap" } };
38895
39119
  }
38896
39120
  }
38897
39121
  }
@@ -38903,7 +39127,7 @@ async function pushSingleton(descriptor, args) {
38903
39127
  contents: local,
38904
39128
  side: "local"
38905
39129
  });
38906
- const next = await restoreFromCloud3(descriptor, args);
39130
+ const next = await restoreFromCloud3(descriptor, { ...args, state: gated });
38907
39131
  output?.warn(`Conflict on ${descriptor.filename} -> stashed local edit to ${stashPath} (cloud-wins; cloud copy restored).`);
38908
39132
  return { state: next, outcome: { kind: "conflict", stashPath } };
38909
39133
  }
@@ -38925,6 +39149,7 @@ var USER_MEMORY_DESCRIPTOR = {
38925
39149
  mutexKey: USER_MEMORY_MUTEX_KEY,
38926
39150
  logTag: "USER_MEMORY",
38927
39151
  maxBytes: MAX_USER_MEMORY_BYTES,
39152
+ secretScanned: true,
38928
39153
  extractActive: extractUserMemory,
38929
39154
  getState: getUserMemoryState,
38930
39155
  setState: setUserMemoryState,
@@ -38957,6 +39182,7 @@ var VOICE_DESCRIPTOR = {
38957
39182
  mutexKey: VOICE_MUTEX_KEY,
38958
39183
  logTag: "VOICE",
38959
39184
  maxBytes: MAX_USER_VOICE_BYTES,
39185
+ secretScanned: false,
38960
39186
  extractActive: extractVoiceProfile,
38961
39187
  getState: getUserVoiceState,
38962
39188
  setState: setUserVoiceState,
@@ -42127,6 +42353,7 @@ function reportWorkspaceMemoryOutcome(spinner, slug, state, outcome) {
42127
42353
  spinner.warn(`Conflict on ${WORKSPACE_MEMORY_FILENAME} → ${outcome.stashPath} (cloud-wins; local copy stashed)`);
42128
42354
  spinner.update(`Watching ${slug} · ${summary}`);
42129
42355
  return;
42356
+ case "secret_held":
42130
42357
  case "over_cap":
42131
42358
  case "noop":
42132
42359
  return;
@@ -42143,6 +42370,7 @@ function reportUserMemoryOutcome(spinner, slug, state, outcome) {
42143
42370
  spinner.warn(`Conflict on ${USER_MEMORY_FILENAME} → ${outcome.stashPath} (cloud-wins; local copy stashed)`);
42144
42371
  spinner.update(`Watching ${slug} · ${summary}`);
42145
42372
  return;
42373
+ case "secret_held":
42146
42374
  case "over_cap":
42147
42375
  case "noop":
42148
42376
  return;
@@ -42195,6 +42423,7 @@ function reportArtistMemoryOutcome(spinner, slug, state, outcome) {
42195
42423
  spinner.warn(`Conflict on artists/${outcome.slug}/memory.md → ${outcome.stashPath} (cloud-wins; local copy stashed)`);
42196
42424
  spinner.update(`Watching ${slug} · ${summary}`);
42197
42425
  return;
42426
+ case "secret_held":
42198
42427
  case "not_found":
42199
42428
  case "noop":
42200
42429
  case "ignored":
@@ -42541,6 +42770,8 @@ function scanPendingWrites(args) {
42541
42770
  continue;
42542
42771
  if (Buffer.byteLength(local) > lane.maxBytes)
42543
42772
  continue;
42773
+ if (reported.entities.has(lane.filename))
42774
+ continue;
42544
42775
  const localHash = stableHash(local);
42545
42776
  if (lane.acked && lane.acked.body_hash === localHash)
42546
42777
  continue;
@@ -42566,6 +42797,8 @@ function scanPendingWrites(args) {
42566
42797
  continue;
42567
42798
  if (isPristineMemoryStub(local))
42568
42799
  continue;
42800
+ if (reported.entities.has(`artists/${entry2.slug}/memory.md`))
42801
+ continue;
42569
42802
  const localHash = stableHash(local);
42570
42803
  if (entry2.memory_hash === localHash)
42571
42804
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plinth-music/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Plinth capstone sync daemon and CLI",
5
5
  "type": "module",
6
6
  "private": false,