@plinth-music/cli 0.10.1 → 0.11.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/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.10.1",
7587
+ version: "0.11.0",
7588
7588
  description: "Plinth capstone sync daemon and CLI",
7589
7589
  type: "module",
7590
7590
  private: false,
@@ -22354,6 +22354,7 @@ var ArtistLiveSchema = exports_external.object({
22354
22354
  });
22355
22355
  var ArtistsPageResponseSchema = makePageResponseSchema("artists", ArtistLiveSchema);
22356
22356
  var SERVER_AUTHORITATIVE_KEYS = new Set([
22357
+ "id",
22357
22358
  "slug",
22358
22359
  "revision",
22359
22360
  "last_edited_at",
@@ -22385,6 +22386,7 @@ var WIRE_EDITABLE_TOP_LEVEL_KEYS = new Set([
22385
22386
  "youtube_url"
22386
22387
  ]);
22387
22388
  var PROJECTION_KEYS = [
22389
+ "id",
22388
22390
  "slug",
22389
22391
  "name",
22390
22392
  "status",
@@ -22492,6 +22494,7 @@ var DocumentLiveSchema = exports_external.object({
22492
22494
  var DocumentRowSchema = exports_external.union([DocumentLiveSchema, TombstoneSchema]);
22493
22495
  var DocumentsPageResponseSchema = makePageResponseSchema("documents", DocumentLiveSchema);
22494
22496
  var PROJECTION_KEYS2 = [
22497
+ "id",
22495
22498
  "slug",
22496
22499
  "revision",
22497
22500
  "last_edited_at",
@@ -22502,6 +22505,7 @@ var PROJECTION_KEYS2 = [
22502
22505
  "project_id"
22503
22506
  ];
22504
22507
  var SERVER_AUTHORITATIVE_KEYS2 = new Set([
22508
+ "id",
22505
22509
  "slug",
22506
22510
  "revision",
22507
22511
  "last_edited_at",
@@ -22631,6 +22635,7 @@ var MeetingLiveSchema = exports_external.object({
22631
22635
  });
22632
22636
  var MeetingsPageResponseSchema = makePageResponseSchema("meetings", MeetingLiveSchema);
22633
22637
  var SERVER_AUTHORITATIVE_KEYS3 = new Set([
22638
+ "id",
22634
22639
  "slug",
22635
22640
  "revision",
22636
22641
  "last_edited_at",
@@ -22658,6 +22663,7 @@ var WIRE_EDITABLE_TOP_LEVEL_KEYS3 = new Set([
22658
22663
  "follow_up_summary"
22659
22664
  ]);
22660
22665
  var PROJECTION_KEYS3 = [
22666
+ "id",
22661
22667
  "slug",
22662
22668
  "name",
22663
22669
  "date",
@@ -23028,6 +23034,7 @@ var ProjectLiveSchema = exports_external.object({
23028
23034
  });
23029
23035
  var ProjectsPageResponseSchema = makePageResponseSchema("projects", ProjectLiveSchema);
23030
23036
  var SERVER_AUTHORITATIVE_KEYS4 = new Set([
23037
+ "id",
23031
23038
  "slug",
23032
23039
  "revision",
23033
23040
  "last_edited_at",
@@ -23048,6 +23055,7 @@ var WIRE_EDITABLE_TOP_LEVEL_KEYS4 = new Set([
23048
23055
  "summary"
23049
23056
  ]);
23050
23057
  var PROJECTION_KEYS4 = [
23058
+ "id",
23051
23059
  "slug",
23052
23060
  "name",
23053
23061
  "category",
@@ -23198,6 +23206,7 @@ var TaskLiveSchema = exports_external.object({
23198
23206
  });
23199
23207
  var TasksPageResponseSchema = makePageResponseSchema("tasks", TaskLiveSchema);
23200
23208
  var SERVER_AUTHORITATIVE_KEYS5 = new Set([
23209
+ "id",
23201
23210
  "slug",
23202
23211
  "revision",
23203
23212
  "last_edited_at",
@@ -23222,6 +23231,7 @@ var WIRE_EDITABLE_TOP_LEVEL_KEYS5 = new Set([
23222
23231
  "recurrence_end_date"
23223
23232
  ]);
23224
23233
  var PROJECTION_KEYS5 = [
23234
+ "id",
23225
23235
  "slug",
23226
23236
  "name",
23227
23237
  "status",
@@ -23392,6 +23402,7 @@ var ThreadLiveSchema = exports_external.object({
23392
23402
  });
23393
23403
  var ThreadsPageResponseSchema = makePageResponseSchema("threads", ThreadLiveSchema);
23394
23404
  var SERVER_AUTHORITATIVE_KEYS6 = new Set([
23405
+ "id",
23395
23406
  "slug",
23396
23407
  "revision",
23397
23408
  "last_edited_at",
@@ -23407,6 +23418,7 @@ var WIRE_EDITABLE_TOP_LEVEL_KEYS6 = new Set([
23407
23418
  "artist_id"
23408
23419
  ]);
23409
23420
  var PROJECTION_KEYS6 = [
23421
+ "id",
23410
23422
  "slug",
23411
23423
  "name",
23412
23424
  "artist_id",
@@ -25874,7 +25886,69 @@ function renderFreshnessStamp(today, updatedAt) {
25874
25886
  const age = days === 0 ? "today" : days === 1 ? "1 day ago" : `${days} days ago`;
25875
25887
  return `This index was last updated on ${bankedDay}, ${age}.`;
25876
25888
  }
25877
- function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = null, userMemory = null, freshness = {}, rosterIsEmpty = false, voiceProfile = null, voiceMalformed = false) {
25889
+ var GROUNDING_BUDGET_BYTES = 20000;
25890
+ function bankedDate(line) {
25891
+ const matches = line.match(/\d{4}-\d{2}-\d{2}/g);
25892
+ if (matches === null || matches.length === 0)
25893
+ return null;
25894
+ let latest = matches[0];
25895
+ for (const m of matches)
25896
+ if (m > latest)
25897
+ latest = m;
25898
+ return latest;
25899
+ }
25900
+ function groupFacts(body) {
25901
+ const facts = [];
25902
+ for (const line of body.split(`
25903
+ `)) {
25904
+ if (line.trim().length === 0)
25905
+ continue;
25906
+ if (/^\s/.test(line) && facts.length > 0) {
25907
+ facts[facts.length - 1] = `${facts[facts.length - 1]}
25908
+ ${line}`;
25909
+ } else {
25910
+ facts.push(line);
25911
+ }
25912
+ }
25913
+ return facts;
25914
+ }
25915
+ function selectRecentLines(body, capBytes) {
25916
+ const lines = groupFacts(body);
25917
+ if (Buffer.byteLength(body) <= capBytes) {
25918
+ return { text: body, kept: lines.length, total: lines.length };
25919
+ }
25920
+ const ranked = lines.map((line, index) => ({ line, index, date: bankedDate(line) })).sort((a, b) => {
25921
+ const ad = a.date ?? "";
25922
+ const bd = b.date ?? "";
25923
+ if (ad !== bd)
25924
+ return ad < bd ? 1 : -1;
25925
+ return b.index - a.index;
25926
+ });
25927
+ const chosen = [];
25928
+ let used = 0;
25929
+ for (const entry of ranked) {
25930
+ const add = Buffer.byteLength(entry.line) + (chosen.length > 0 ? 1 : 0);
25931
+ if (used + add > capBytes)
25932
+ break;
25933
+ chosen.push(entry.index);
25934
+ used += add;
25935
+ }
25936
+ chosen.sort((a, b) => a - b);
25937
+ return {
25938
+ text: chosen.map((i) => lines[i]).join(`
25939
+ `),
25940
+ kept: chosen.length,
25941
+ total: lines.length
25942
+ };
25943
+ }
25944
+ function trimmedIndexNote(kept, total, filename) {
25945
+ if (kept === 0) {
25946
+ return `None of this index is shown here: it is over the size this injection ` + `carries, so it lives in \`${filename}\` at the mirror root and nowhere ` + `else. READ THAT FILE IN FULL before you rely on what this workspace has ` + `taught you, and before you answer that no fact covers something.`;
25947
+ }
25948
+ return `Those are the ${kept} most recently banked of ${total} facts, not all of ` + `them: this injection carries a fixed budget and the rest of the index did ` + `not fit. The other ${total - kept} are in \`${filename}\` at the mirror ` + `root. READ THAT FILE IN FULL before you rely on what this workspace has ` + `taught you, and before you answer that no fact covers something. An index ` + `that is silent because it was trimmed reads exactly like one that has ` + `settled a subject.`;
25949
+ }
25950
+ function composeGroundingBlock(today, workspaceRules, workspaceMemory, userMemory, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, workspaceMemoryNote, userMemoryNote) {
25951
+ const gateFlag = workspaceSlug === null ? `--workspace="<this workspace>"` : `--workspace ${workspaceSlug}`;
25878
25952
  const block = [
25879
25953
  `Plinth session grounding:`,
25880
25954
  ``,
@@ -25902,23 +25976,25 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
25902
25976
  ` + workspaceRules);
25903
25977
  }
25904
25978
  if (voiceProfile !== null) {
25905
- block.push(``, `Draft in this member's own voice. The profile below is theirs: how they ` + `open, how they sign off, what they never do. It is a rubric, not a set ` + `of facts, so read it whole and write to it from the start rather than ` + `drafting in your own register and correcting after. It is theirs to ` + `write and not yours: you cannot edit \`${VOICE_FILENAME}\`, and if ` + `something in it looks wrong or out of date, say so and let them change ` + `it. Like this workspace's house style it refines the rules above and ` + `never relaxes them. The profile:
25906
-
25907
- ` + voiceProfile);
25979
+ block.push(``, `Draft in this member's own voice, and read it before you do. Their voice ` + `profile is \`${VOICE_FILENAME}\` at the mirror root, beside this ` + `workspace's house style: how they open, how they sign off, what they ` + `never do. It is not reproduced here. READ THAT FILE IN FULL before you ` + `draft anything that goes out in their name (an email, a message, a ` + `note, any prose that carries their name), and write to it from the ` + `start rather than ` + `drafting in your own register and correcting after. Nothing inspects a ` + `draft between you and the person reading it, so that file is the whole ` + `of it. It is theirs to write and not yours: you cannot edit ` + `\`${VOICE_FILENAME}\`, and if something in it looks wrong or out of ` + `date, say so and let them change it. Like this workspace's house style ` + `it refines the rules above and never relaxes them.`);
25908
25980
  if (voiceMalformed) {
25909
- block.push(``, `That voice profile does not currently parse. Plinth read it and found ` + `its structure broken, most often a code fence that was opened and ` + `never closed, so any machine-read setting inside it is being ignored ` + `rather than applied. The prose above is still the member's own writing ` + `and is still worth following. Treat anything that looks like a ` + `configured setting in it as NOT in force, say so if it comes up, and ` + `tell them their profile needs fixing rather than fixing it yourself.`);
25981
+ block.push(``, `That voice profile does not currently parse. Plinth read it and found ` + `its structure broken, most often a code fence that was opened and ` + `never closed, so any machine-read setting inside it is being ignored ` + `rather than applied. The prose in it is still the member's own ` + `writing and is still worth following. Treat anything that looks like ` + `a configured setting in it as NOT in force, say so if it comes up, ` + `and tell them their profile needs fixing rather than fixing it ` + `yourself.`);
25910
25982
  }
25911
25983
  }
25912
- block.push(``, `Keep your memory on the Plinth surface. Your memory here IS the Plinth ` + `surface (\`workspace-memory.md\`, your own \`user-memory.md\`, and the ` + `per-artist warm stores the daemon syncs), and it is the ONLY memory you ` + `keep. Do not use any other memory store: no private notes-to-self, no ` + `separate scratch file, no tool-native memory of your own. What you learn ` + `here is recorded on the Plinth surface, or nowhere. When you learn a ` + `durable fact worth keeping, bank it by adding a line to the right index, ` + `which the daemon syncs up, following the write-back conventions in that ` + `file's header: one line, at least one \`[[pointer]]\` to where the full ` + `fact lives, an absolute date, and where you learned it. Only ever record ` + `what you have actually been taught, never public knowledge and never your ` + `own priors. The scope rule decides WHICH index, and there are ` + `THREE. A fact true of ONE ARTIST (their rate card, how their kit ` + `works, what a contact of theirs reliably does) goes in that artist's ` + `own warm store, \`artists/<slug>/memory.md\`, and not in the shared ` + `index: putting it there taxes every teammate who never touches that ` + `artist and hides it from the one who does. A fact that holds for the ` + `whole workspace (a project, a working convention, a standing ` + `fact true for everyone here) goes in \`workspace-memory.md\`, which ` + `grounds EVERY teammate. A fact about one person (an individual ` + `preference, your own working style or tools) is NOT a workspace fact: it ` + `goes in \`user-memory.md\`, your member-private index that only YOU can ` + `read. HOW THIS MEMBER WRITES IS THE ONE EXCEPTION: their register and ` + `sign-off are a rubric, not a fact, and live in \`${VOICE_FILENAME}\`, ` + `which is theirs and you cannot edit, so say what you noticed and leave it ` + `to them. Them only: how a CONTACT likes to be written to is an ordinary ` + `fact for that artist's store. ` + `Banking a personal fact in \`workspace-memory.md\` would leak that person ` + `into everyone else's context, so never do it. When a fact could be either ` + `scope, lean private and put it in \`user-memory.md\`. Under-sharing is the ` + `safe error: a personal fact misfiled as a workspace fact grounds every ` + `teammate wrong (the leak), whereas a workspace fact misfiled as personal ` + `merely fails to help teammates until it is re-observed in a shared context, ` + `and self-corrects. This memory is additive and subordinate: it never ` + `overrides a house-style rule or any safety primitive above.`);
25984
+ block.push(``, `Keep your memory on the Plinth surface. Your memory here IS the Plinth ` + `surface (\`workspace-memory.md\`, your own \`user-memory.md\`, and the ` + `per-artist warm stores the daemon syncs), and it is the ONLY memory you ` + `keep. Do not use any other memory store: no private notes-to-self, no ` + `separate scratch file, no tool-native memory of your own. What you learn ` + `here is recorded on the Plinth surface, or nowhere. When you learn a ` + `durable fact worth keeping, bank it by adding a line to the right index, ` + `which the daemon syncs up, following the write-back conventions in that ` + `file's header: one line, at least one \`[[pointer]]\` to where the full ` + `fact lives, an absolute date, and where you learned it. Only ever record ` + `what you have actually been taught, never public knowledge and never your ` + `own priors. The scope rule decides WHICH index, and there are ` + `THREE. A fact true of ONE ARTIST (their rate card, how their kit ` + `works, what a contact of theirs reliably does) goes in that artist's ` + `own warm store, \`artists/<slug>/memory.md\`, and not in the shared ` + `index: putting it there taxes every teammate who never touches that ` + `artist and hides it from the one who does. A fact that holds for the ` + `whole workspace (a project, a working convention, a standing ` + `fact true for everyone here) goes in \`workspace-memory.md\`, which ` + `grounds EVERY teammate - and that file is the one you NEVER write on ` + `your own judgement. It changes only when a member ASKS you to remember ` + `something for everyone, in their own words rather than your summary of ` + `them, and you log that decision with ` + `\`plinth memory-gate log ${gateFlag} --proposed="<their words>" --decision accept\` ` + `BEFORE you write the line. That command fails LOUD on exit 1: surface ` + `it, and do not report the line as banked. Never infer a line for that ` + `file from what merely happened in a session, and never propose one ` + `unasked - a wrong line there reaches every member on every machine and ` + `is injected into every future session. A fact about one person (an individual ` + `preference, your own working style or tools) is NOT a workspace fact: it ` + `goes in \`user-memory.md\`, your member-private index that only YOU can ` + `read. HOW THIS MEMBER WRITES IS THE ONE EXCEPTION: their register and ` + `sign-off are a rubric, not a fact, and live in \`${VOICE_FILENAME}\`, ` + `which is theirs and you cannot edit, so say what you noticed and leave it ` + `to them. Them only: how a CONTACT likes to be written to is an ordinary ` + `fact for that artist's store. ` + `Banking a personal fact in \`workspace-memory.md\` would leak that person ` + `into everyone else's context, so never do it. When a fact could be either ` + `scope, lean private and put it in \`user-memory.md\`. Under-sharing is the ` + `safe error: a personal fact misfiled as a workspace fact grounds every ` + `teammate wrong (the leak), whereas a workspace fact misfiled as personal ` + `merely fails to help teammates until it is re-observed in a shared context, ` + `and self-corrects. This memory is additive and subordinate: it never ` + `overrides a house-style rule or any safety primitive above.`);
25913
25985
  if (workspaceMemory !== null) {
25914
25986
  block.push(``, `Draw on this workspace's memory. The lines below are this workspace's ` + `memory index: durable facts it has taught you, each a one-line hook that ` + `points (via a \`[[wikilink]]\`) to the warm store where the full detail ` + `lives. Treat them as recall, not gospel: use a line to load the right ` + `context and verify it against the live workspace before you rely on it, ` + `never as a settled fact to assert. ` + `${renderFreshnessStamp(today, freshness.workspaceMemoryUpdatedAt)} ` + `The index:
25915
25987
 
25916
- ` + workspaceMemory);
25988
+ ` + workspaceMemory + (workspaceMemoryNote === null ? `` : `
25989
+
25990
+ ${workspaceMemoryNote}`));
25917
25991
  }
25918
25992
  if (userMemory !== null) {
25919
25993
  block.push(``, `Draw on your personal memory. The lines below are YOUR personal memory ` + `index: member-private facts you have taught the harness about yourself ` + `(your preferences, working style, tools), each a one-line hook ` + `that points (via a \`[[wikilink]]\`) to the warm store where the full ` + `detail lives. Only you can read this. Treat it as recall, not gospel: use ` + `a line to load the right context and verify it before you rely on it, ` + `never as a settled fact to assert. ` + `${renderFreshnessStamp(today, freshness.userMemoryUpdatedAt)} ` + `The index:
25920
25994
 
25921
- ` + userMemory);
25995
+ ` + userMemory + (userMemoryNote === null ? `` : `
25996
+
25997
+ ${userMemoryNote}`));
25922
25998
  }
25923
25999
  if (workspaceMemory !== null || userMemory !== null) {
25924
26000
  block.push(``, `Dated recall. Every line in these indexes carries the date it was banked, ` + `and today's date is injected above, so the age of any line is ` + `computable. When you rely on a line in an answer, say how old it is - ` + `"banked 21 July, seventeen days ago" - rather than presenting it as ` + `current. Age is not a reason to distrust a line, it is what tells the ` + `reader whether to check: a working convention banked a year ago is ` + `probably still the convention, a fact about a live negotiation banked ` + `three weeks ago probably is not. And say what the indexes do NOT cover. ` + `If you are asked about something no line here addresses, say so plainly ` + `rather than answering only from what happens to be present - an index ` + `that is silent on a subject reads exactly like one that has settled it.`);
@@ -25930,6 +26006,59 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
25930
26006
  return block.join(`
25931
26007
  `);
25932
26008
  }
26009
+ function buildGroundingReport(today, workspaceRules = null, workspaceMemory = null, userMemory = null, freshness = {}, rosterIsEmpty = false, voiceProfile = null, voiceMalformed = false, workspaceSlug = null) {
26010
+ const compose = (wmBody, umBody, wmNote, umNote) => composeGroundingBlock(today, workspaceRules, wmBody, umBody, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, wmNote, umNote);
26011
+ const whole = compose(workspaceMemory, userMemory, null, null);
26012
+ const untrimmed = () => ({
26013
+ block: whole,
26014
+ picks: {
26015
+ wmPick: workspaceMemory === null ? null : { text: workspaceMemory, kept: groupFacts(workspaceMemory).length, total: groupFacts(workspaceMemory).length },
26016
+ umPick: userMemory === null ? null : { text: userMemory, kept: groupFacts(userMemory).length, total: groupFacts(userMemory).length }
26017
+ }
26018
+ });
26019
+ if (Buffer.byteLength(whole) <= GROUNDING_BUDGET_BYTES)
26020
+ return untrimmed();
26021
+ if (workspaceMemory === null && userMemory === null)
26022
+ return untrimmed();
26023
+ const factCount = (body) => body === null ? 0 : groupFacts(body).length;
26024
+ const wmFacts = factCount(workspaceMemory);
26025
+ const umFacts = factCount(userMemory);
26026
+ const measureFixed = (wmNoted, umNoted) => Buffer.byteLength(compose(workspaceMemory === null ? null : "", userMemory === null ? null : "", wmNoted ? trimmedIndexNote(wmFacts, wmFacts, WORKSPACE_MEMORY_FILENAME) : null, umNoted ? trimmedIndexNote(umFacts, umFacts, USER_MEMORY_FILENAME) : null));
26027
+ const wmSize = workspaceMemory === null ? 0 : Buffer.byteLength(workspaceMemory);
26028
+ const umSize = userMemory === null ? 0 : Buffer.byteLength(userMemory);
26029
+ const allocate = (remaining) => {
26030
+ const half = Math.floor(remaining / 2);
26031
+ let umCap;
26032
+ let wmCap;
26033
+ if (umSize <= half) {
26034
+ umCap = umSize;
26035
+ wmCap = remaining - umSize;
26036
+ } else if (wmSize <= half) {
26037
+ wmCap = wmSize;
26038
+ umCap = remaining - wmSize;
26039
+ } else {
26040
+ umCap = half;
26041
+ wmCap = remaining - half;
26042
+ }
26043
+ const umPick = userMemory === null ? null : selectRecentLines(userMemory, umCap);
26044
+ const umUsed = umPick === null ? 0 : Buffer.byteLength(umPick.text);
26045
+ return {
26046
+ wmPick: workspaceMemory === null ? null : selectRecentLines(workspaceMemory, remaining - umUsed),
26047
+ umPick
26048
+ };
26049
+ };
26050
+ const trimmed = (pick2) => pick2 !== null && pick2.kept !== pick2.total;
26051
+ const noteFor = (pick2, filename) => pick2 === null || pick2.kept === pick2.total ? null : trimmedIndexNote(pick2.kept, pick2.total, filename);
26052
+ const build = (picks) => compose(picks.wmPick === null ? null : picks.wmPick.text, picks.umPick === null ? null : picks.umPick.text, noteFor(picks.wmPick, WORKSPACE_MEMORY_FILENAME), noteFor(picks.umPick, USER_MEMORY_FILENAME));
26053
+ const conservative = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - measureFixed(true, true)));
26054
+ const refined = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - Buffer.byteLength(compose(workspaceMemory === null ? null : "", userMemory === null ? null : "", noteFor(conservative.wmPick, WORKSPACE_MEMORY_FILENAME), noteFor(conservative.umPick, USER_MEMORY_FILENAME)))));
26055
+ const kept = (p) => (p.wmPick?.kept ?? 0) + (p.umPick?.kept ?? 0);
26056
+ const candidates = [refined, conservative].map((picks) => ({ picks, text: build(picks) })).map((c) => ({ ...c, bytes: Buffer.byteLength(c.text), facts: kept(c.picks) }));
26057
+ const fitting = candidates.filter((c) => c.bytes <= GROUNDING_BUDGET_BYTES);
26058
+ const best = fitting.length > 0 ? fitting.reduce((a, b) => b.facts > a.facts ? b : a) : candidates.reduce((a, b) => b.bytes < a.bytes ? b : a);
26059
+ return { block: best.text, picks: best.picks };
26060
+ }
26061
+ var buildGroundingReportFor = buildGroundingReport;
25933
26062
  function formatLocalDate(d) {
25934
26063
  const year = d.getFullYear();
25935
26064
  const month = String(d.getMonth() + 1).padStart(2, "0");
@@ -26558,6 +26687,9 @@ function resolveVoiceMalformed(raw, record2) {
26558
26687
  return stableHash(raw) === record2.body_hash;
26559
26688
  }
26560
26689
  function runGrounding(deps = {}) {
26690
+ return runGroundingReport(deps).block;
26691
+ }
26692
+ function runGroundingReport(deps = {}) {
26561
26693
  const now = deps.now ?? (() => new Date);
26562
26694
  const readWorkspaceRules = deps.readWorkspaceRules ?? readWorkspaceRulesFile;
26563
26695
  const readWorkspaceMemory = deps.readWorkspaceMemory ?? readWorkspaceMemoryFile;
@@ -26575,34 +26707,35 @@ function runGrounding(deps = {}) {
26575
26707
  userVoice: null
26576
26708
  };
26577
26709
  let noArtists = false;
26578
- if (deps.workspaceSlug) {
26710
+ const workspaceSlug = isValidWorkspaceSlug(deps.workspaceSlug ?? "") ? deps.workspaceSlug : null;
26711
+ if (workspaceSlug) {
26579
26712
  try {
26580
- rawRules = readWorkspaceRules(deps.workspaceSlug);
26713
+ rawRules = readWorkspaceRules(workspaceSlug);
26581
26714
  } catch {
26582
26715
  rawRules = null;
26583
26716
  }
26584
26717
  try {
26585
- rawMemory = readWorkspaceMemory(deps.workspaceSlug);
26718
+ rawMemory = readWorkspaceMemory(workspaceSlug);
26586
26719
  } catch {
26587
26720
  rawMemory = null;
26588
26721
  }
26589
26722
  try {
26590
- rawUserMemory = readUserMemory(deps.workspaceSlug);
26723
+ rawUserMemory = readUserMemory(workspaceSlug);
26591
26724
  } catch {
26592
26725
  rawUserMemory = null;
26593
26726
  }
26594
26727
  try {
26595
- rawVoiceProfile = readVoiceProfile(deps.workspaceSlug);
26728
+ rawVoiceProfile = readVoiceProfile(workspaceSlug);
26596
26729
  } catch {
26597
26730
  rawVoiceProfile = null;
26598
26731
  }
26599
26732
  try {
26600
- freshness = readFreshness(deps.workspaceSlug);
26733
+ freshness = readFreshness(workspaceSlug);
26601
26734
  } catch {
26602
26735
  freshness = { workspaceMemory: null, userMemory: null, userVoice: null };
26603
26736
  }
26604
26737
  try {
26605
- noArtists = readRosterIsEmpty(deps.workspaceSlug);
26738
+ noArtists = readRosterIsEmpty(workspaceSlug);
26606
26739
  } catch {
26607
26740
  noArtists = false;
26608
26741
  }
@@ -26611,10 +26744,10 @@ function runGrounding(deps = {}) {
26611
26744
  const memory = extractWorkspaceMemory(rawMemory);
26612
26745
  const userMemory = extractUserMemory(rawUserMemory);
26613
26746
  const voiceProfile = extractVoiceProfile(rawVoiceProfile);
26614
- return buildGroundingBlock(formatLocalDate(now()), rules, memory, userMemory, {
26747
+ return buildGroundingReportFor(formatLocalDate(now()), rules, memory, userMemory, {
26615
26748
  workspaceMemoryUpdatedAt: resolveUpdatedAt(rawMemory, freshness.workspaceMemory),
26616
26749
  userMemoryUpdatedAt: resolveUpdatedAt(rawUserMemory, freshness.userMemory)
26617
- }, noArtists, voiceProfile, resolveVoiceMalformed(rawVoiceProfile, freshness.userVoice));
26750
+ }, noArtists, voiceProfile, resolveVoiceMalformed(rawVoiceProfile, freshness.userVoice), workspaceSlug);
26618
26751
  }
26619
26752
  var groundingCommand = defineCommand({
26620
26753
  meta: {
@@ -28587,7 +28720,7 @@ function buildCloseCommandMd(workspaceSlug) {
28587
28720
  const ws = `--workspace ${workspaceSlug}`;
28588
28721
  return withUserBlock([
28589
28722
  "---",
28590
- "description: Close a Plinth session. Verifies what you wrote landed, routes follow-ups to a durable surface, asks before anything reaches shared memory, and logs what was decided.",
28723
+ "description: Close a Plinth session. Verifies what you wrote landed, routes follow-ups to a durable surface, and banks what was learned where it belongs.",
28591
28724
  "---",
28592
28725
  "",
28593
28726
  OWNERSHIP_NOTE,
@@ -28600,7 +28733,7 @@ function buildCloseCommandMd(workspaceSlug) {
28600
28733
  "",
28601
28734
  "## Step 1: reconstruct the session",
28602
28735
  "",
28603
- "From the **transcript**, not from your memory of it. Which entities were touched, which writes were made, what was decided, what was learned. If the session was pure reading with no changes, say so and go to Step 4 anyway: a read-only close still logs, and the `--nothing-proposed` rule below is why.",
28736
+ "From the **transcript**, not from your memory of it. Which entities were touched, which writes were made, what was decided, what was learned. If the session was pure reading with no changes, say so and **work through every step below anyway.** A session that answered a real question has a record worth banking in Step 3, and reading can teach you a durable fact as readily as writing can.",
28604
28737
  "",
28605
28738
  "## Step 2: verify each write landed",
28606
28739
  "",
@@ -28640,7 +28773,7 @@ function buildCloseCommandMd(workspaceSlug) {
28640
28773
  "<what was discussed, what was decided, what was done, what is still open>",
28641
28774
  "```",
28642
28775
  "",
28643
- "**Re-read `_artist.md` at the moment you write it, and add only your own dated section.** Other people close sessions in this workspace too, and this file syncs: when two closes touch one artist at once the cloud copy wins and the other edit is set aside, seconds after you wrote it. **Step 2's conflicts read cannot catch that: it ran before this write.** Step 6 re-reads it, and that is the check covering what you write here. So read the file now rather than trusting what it said earlier in the session, and never reword a section another session wrote.",
28776
+ "**Re-read `_artist.md` at the moment you write it, and add only your own dated section.** Other people close sessions in this workspace too, and this file syncs: when two closes touch one artist at once the cloud copy wins and the other edit is set aside, seconds after you wrote it. **Step 2's conflicts read cannot catch that: it ran before this write.** Step 5 re-reads it, and that is the check covering what you write here. So read the file now rather than trusting what it said earlier in the session, and never reword a section another session wrote.",
28644
28777
  "",
28645
28778
  "**Keep the five most recent dated sections.** Adding a sixth means dropping the oldest - but first take anything in it that is still true and write it into `artists/<slug>/memory.md`, where durable facts live. If nothing in it survives that test it was not durable, and dropping it costs nothing. **Removing an old section is the one edit you may make to someone else's: you may drop it, never reword it.** Without this bound the record grows without limit on a synced column, and every teammate loads the whole of it on every session.",
28646
28779
  "",
@@ -28648,7 +28781,7 @@ function buildCloseCommandMd(workspaceSlug) {
28648
28781
  "",
28649
28782
  "## Step 4: bank what was learned, by type first and scope second",
28650
28783
  "",
28651
- "**Two sorts, in this order, and the order is the whole of it.** First: does this fact already have a surface of its own? Then, and only then: whose fact is it? Sorting by scope alone is how a contact record ends up in shared memory wearing a memory line's clothes.",
28784
+ "**Two sorts, in this order, and the order is the whole of it.** First: does this fact already have a surface of its own? Then, and only then: whose fact is it? Sorting by scope alone is how a contact record ends up in a memory file wearing a memory line's clothes.",
28652
28785
  "",
28653
28786
  "### Step 4a: type first. A fact with a typed home never becomes prose.",
28654
28787
  "",
@@ -28656,7 +28789,7 @@ function buildCloseCommandMd(workspaceSlug) {
28656
28789
  "",
28657
28790
  "- **A person and their details** - name, company, email, role, how they relate to the work - is a **contact on that artist**. Write it with `create_artist_contact`; it renders in the `## Contacts` block of that artist's `artists/<slug>/_artist.md`.",
28658
28791
  "- **Something to do** is a task, and Step 3 has already routed it.",
28659
- "- **A note, brief or bio that is a piece of writing in its own right** is a document under `documents/`. That means standalone material, NOT the detail sitting behind a memory line - those two are not competing destinations, and Step 4b says how they fit together.",
28792
+ "- **A note, brief or bio that is a piece of writing in its own right** is a document under `documents/`. That means standalone material somebody would open and read on its own, not a fact you are recording because you learned it - a fact is a memory line, and Step 4b routes it.",
28660
28793
  "- **A stable attribute of an artist** - their website, a handle, their bio - is that artist's own field, or the body of `artists/<slug>/_artist.md`.",
28661
28794
  "- **A contract, statement or attachment** is a file under `files/`.",
28662
28795
  "- **A date is live state and belongs in no memory file.** A deadline rides its task as a due date. Every other date is a calendar matter: say plainly what the date is, say that a person adds it in Plinth's calendar view, and name it in the closing summary so it is not quietly dropped.",
@@ -28665,19 +28798,19 @@ function buildCloseCommandMd(workspaceSlug) {
28665
28798
  "",
28666
28799
  "Whatever is left has no typed surface anywhere. That is what memory is for.",
28667
28800
  "",
28668
- "### Step 4b: three piles, and the artist pile comes first",
28801
+ "### Step 4b: two piles, and the artist pile comes first",
28669
28802
  "",
28670
- "**Most of what a session learns is true of exactly one artist**, and that is the pile that gets forgotten. Sending an artist's fact to shared memory pushes it into every teammate's context on every future session, including everyone who never touches that artist, and buries it where the person who does will not look.",
28803
+ "**Most of what a session learns is true of exactly one artist**, and that is the pile that gets forgotten. The other is true of the member in front of you. Write both directly: they are scoped to the person or the act they describe, and a wrong line in either is theirs to prune.",
28671
28804
  "",
28672
28805
  "**The sorting test: would a teammate who never touches that artist be worse off without this line?** If not, it is the artist pile.",
28673
28806
  "",
28674
- "The three piles are treated differently on purpose, and the difference is not politeness. A wrong line in the **shared** index reaches every member on every machine and is injected into every future session, so it is the only pile that is gated. A wrong line in an artist's store or a member's own is theirs to prune.",
28807
+ "**A fact that is true of neither - a way this whole team works - is named in the closing summary and written nowhere by this close.** Asking for something to hold for everyone here is the member's to do, not yours to infer from what happened. This close has no third pile, and that is deliberate rather than an omission.",
28675
28808
  "",
28676
28809
  "#### True of one artist: `artists/<slug>/memory.md`. Write it directly.",
28677
28810
  "",
28678
- "That artist's rate card, a contact of theirs whose behaviour you had to learn, how their kit actually works, what a venue reliably does. No gate. It syncs to that artist's Plinth record, so a teammate opening that artist gets it. **List what you wrote here in the closing summary.**",
28811
+ "That artist's rate card, a contact of theirs whose behaviour you had to learn, how their kit actually works, what a venue reliably does. It syncs to that artist's Plinth record, so a teammate opening that artist gets it. **List what you wrote here in the closing summary.**",
28679
28812
  "",
28680
- "Follow the conventions in the file's own header: one line per fact, what taught you it, an absolute date, and **supersede in place** - when a fact here goes wrong, edit that line rather than adding one that contradicts it.",
28813
+ "**Follow the conventions in the file's own header comment**, which states them and is the version that stays current. Do not work from a copy of them here.",
28681
28814
  "",
28682
28815
  "**Re-read the file at the moment you write it, not when you were planning.** The daemon can pull a `memory.md` down from Plinth at any point in a session, so a file that was missing minutes ago may exist now - and if it does, add your line to it rather than creating it over the top. This is not hypothetical: a close on 26 Aug 2026 checked, found nothing, and clobbered a copy the daemon had pulled in ninety seconds earlier.",
28683
28816
  "",
@@ -28696,71 +28829,15 @@ function buildCloseCommandMd(workspaceSlug) {
28696
28829
  "",
28697
28830
  "#### True of this member: `user-memory.md` at the mirror root. Write it directly.",
28698
28831
  "",
28699
- "Facts true of this member: what they prefer, how they work, their own identifiers. No gate. **List what you wrote in the closing summary**, so it is visible without opening the file.",
28832
+ "Facts true of this member: what they prefer, how they work, their own identifiers. **List what you wrote in the closing summary**, so it is visible without opening the file.",
28700
28833
  "",
28701
28834
  "**How they WRITE does not go here, and it is not a fact.** Their register, greetings, sign-off and the things they never do are a rubric, and they live in `voice.md` at the mirror root - a file the member owns and you are denied from editing, because the profile you are judged against is not yours to rewrite. So do not bank a voice rule in this index or any other. Say what you noticed in the closing summary and leave it for them to put in their own profile.",
28702
28835
  "",
28703
28836
  "If the file is not there yet, create it the same way as the artist store: a one-line title, the same conventions in a comment, and a real fact in the same edit. **Check what the workspace already says before you write a personal line** - a convention that is already in `workspace-rules.md` or the workspace's own house-style file is in force for everyone, and copying it here duplicates a live rule into a second injected surface instead of adding anything.",
28704
28837
  "",
28705
- "#### True of the whole workspace: `workspace-memory.md` at the mirror root. Propose. Never write unasked.",
28706
- "",
28707
- "**This is the only gated pile**, and a fact that names a single artist is almost never in it.",
28708
- "",
28709
- `**The test is what the user SAID, not what you concluded.** A line belongs here only if they stated it as how this workspace operates - "always", "never", "from now on", "our policy is", or a confirmation in the transcript that amounts to one. Something you inferred from a single event is not that, however durable it looks: it is this session's detail, Step 3 has already banked it in the record, and it is not proposed here.`,
28710
- "",
28711
- "**Most sessions have nothing for this pile, and that is the expected outcome rather than a failure to look.** Proposing nothing is a clean close: log it with `--nothing-proposed` below, which is this step's ordinary line and not an exception to it. A gate that finds a candidate every session is not discriminating, it is manufacturing them out of what happened.",
28712
- "",
28713
- "For anything that does pass, draft the candidate lines first.",
28714
- "",
28715
- "**Each line has a required shape, and it is the file's own, not this command's.** `workspace-memory.md` is an INDEX, and the grounding block tells every future session that each line is a one-line hook pointing via a `[[wikilink]]` to the warm store where the full detail lives. So a line carries: the hook, a `[[wikilink]]` to where the detail actually is, an **absolute** date (turn \"last week\" into a real one), and what taught you it. **A line with no warm store behind it makes the grounding block lie to every session that loads it.** Read the file's header comment and follow the conventions there; where it and this command disagree, the file wins.",
28838
+ "## Step 5: summarise, then end the session",
28716
28839
  "",
28717
- "**Writing the warm store is PART of banking a line, not an alternative to it.** The hook goes in the index; the full detail goes where its `[[wikilink]]` points. An artist-scoped fact already has a warm store (`artists/<slug>/memory.md`), and a workspace-wide fact with no other home takes a document under `documents/`. Write that target in this same close, and **never bank a hook whose target you did not write** - that is precisely how the grounding block ends up asserting a pointer to nothing.",
28718
- "",
28719
- "Then put the candidates to the user with **`AskUserQuestion`**:",
28720
- "",
28721
- "- **One multi-select question, one option per proposed line.** Selected means bank it. Unselected means rejected.",
28722
- "- **With exactly one candidate, ask a two-option question instead: bank it, or leave it.** The tool refuses a question carrying fewer than two options, so the one-option-per-line form above cannot run on its own - and one candidate is the ordinary case, not an edge. Record the answer exactly as you would from the multi-select.",
28723
- '- **The free-text "Other" choice is how the user rewrites a line in their own words.** Their wording wins over yours, always.',
28724
- "- The tool takes **four options per question and four questions**. If you have more than four candidate lines, you are banking session detail rather than durable facts: **say so and cut the list. Do not paginate.**",
28725
- "",
28726
- "**An accepted line that contradicts one already in the file supersedes it in place.** Edit the wrong line. Never leave two lines disagreeing.",
28727
- "",
28728
- "## Step 5: log every decision, then write the file",
28729
- "",
28730
- "**In that order.** The log is the record of what was decided; the file is the consequence.",
28731
- "",
28732
- "```sh",
28733
- `plinth memory-gate log ${ws} --proposed="<the line as you put it>" --decision accept`,
28734
- `plinth memory-gate log ${ws} --proposed="<the line as you put it>" --decision reject`,
28735
- `plinth memory-gate log ${ws} --proposed="<the line as you put it>" --decision edit --final="<their rewrite>"`,
28736
- "```",
28737
- "",
28738
- "A close that proposed nothing still logs, because *gated and proposed nothing* has to stay distinguishable from *never gated*:",
28739
- "",
28740
- "```sh",
28741
- `plinth memory-gate log ${ws} --nothing-proposed`,
28742
- "```",
28743
- "",
28744
- `\`plinth memory-gate show ${ws} [--json]\` reads the log back.`,
28745
- "",
28746
- "**The `=` in `--proposed=` and `--final=` is load-bearing. Do not rewrite it as a space.** A `workspace-memory.md` line is a markdown bullet, so the value starts with `- ` - and in the space form the parser swallows a value beginning with a dash, leaving the command to report that `--proposed` carried nothing. Measured 26 Aug 2026: the space form fails on exactly the lines this step exists to log, the equals form takes them. The same applies to any line starting with `--`.",
28747
- "",
28748
- "**`--workspace` is pinned on these commands on purpose. Do not drop it.** The CLI otherwise resolves whichever workspace is *active* on this machine, not the mirror you are standing in - so a close run here would write its decisions into a different workspace's log, silently and with exit 0. That log has no delete and no edit, so the wrong row is permanent.",
28749
- "",
28750
- "**Four rules. Break one and a decision is lost:**",
28751
- "",
28752
- "1. `--decision edit` **requires** `--final`. The rewrite is the record: it is the user's own words for their own standard, and it is the most valuable row in the log.",
28753
- "2. `--final` is **refused** on `accept` and `reject`. An accept records the proposed line; a reject records none.",
28754
- "3. `--nothing-proposed` **cannot** be combined with `--proposed` or `--decision`.",
28755
- '4. **`memory-gate` fails LOUD**: exit 1, with a message on stderr. **Surface it. Never swallow it.** A silently dropped decision produces exactly the "never gated" state the log exists to make visible, while looking like a clean close. If it fails, tell the user, and do not report the line as banked.',
28756
- "",
28757
- "**Never write this log by hand.** The CLI owns the format so it can still be read back in a year.",
28758
- "",
28759
- "Only once the decisions are logged, write the accepted lines into `workspace-memory.md`.",
28760
- "",
28761
- "## Step 6: summarise, then end the session",
28762
- "",
28763
- "**First, re-read the conflicts list.** Everything this close wrote - the session record in Step 3, the memory files in Step 4, the accepted lines at the end of Step 5 - landed AFTER Step 2 looked, and a losing write is set aside within seconds of being made. Step 2 therefore cannot have seen it. Read the list again now:",
28840
+ "**First, re-read the conflicts list.** Everything this close wrote - the session record in Step 3, the memory files in Step 4 - landed AFTER Step 2 looked, and a losing write is set aside within seconds of being made. Step 2 therefore cannot have seen it. Read the list again now:",
28764
28841
  "",
28765
28842
  "```sh",
28766
28843
  `plinth status ${ws}`,
@@ -28772,7 +28849,7 @@ function buildCloseCommandMd(workspaceSlug) {
28772
28849
  "",
28773
28850
  "**Anything named there was NOT banked, whatever the write appeared to return.** Say so plainly, name the file, and point the user at the restore command rather than reporting the work as done. A close that reports a stashed session record as banked is the one failure this step exists to prevent, because the record was the point of the close.",
28774
28851
  "",
28775
- "**Five to seven lines**, and no file-by-file dump: **where the session record went, named per artist**, what now lives on a durable surface, what is still in flight and on whom, **what was banked to memory and to which of the three piles**, anything you named as having no home yet, any date you handed to a person, and the obvious next move.",
28852
+ "**Five to seven lines**, and no file-by-file dump: **where the session record went, named per artist**, what now lives on a durable surface, what is still in flight and on whom, **what was banked to memory and to which of the two piles**, anything you named as having no home yet, any fact you judged true of the whole team and left unwritten, any date you handed to a person, and the obvious next move. **This is how the user sees what was stored without opening a file**, so name the files rather than saying that memory was updated.",
28776
28853
  "",
28777
28854
  "Then, as the **very last step**:",
28778
28855
  "",
@@ -28782,7 +28859,7 @@ function buildCloseCommandMd(workspaceSlug) {
28782
28859
  "",
28783
28860
  "**The `=` in `--summary=` is load-bearing. Do not rewrite it as a space.** A summary line often starts with `- `, and in the space form the parser swallows a value beginning with a dash. `session end` will tell you it dropped it and end the session anyway, so the cost is a lost summary rather than a lost close - but the equals form simply never loses it.",
28784
28861
  "",
28785
- "This is the opposite of `memory-gate`: it **always exits 0**, whatever happens, and prints nothing on success. It marks this session ended, and when it is running inside the Plinth desktop dock it tells the dock the session is finished so the tab can close itself. **Outside the dock it does nothing visible, and that is correct.** Nothing in this command depends on the dock; it runs the same in a bare terminal. The one thing it will ever print is a warning on stderr saying your `--summary` was lost before it could be read; the session still ends."
28862
+ "It **always exits 0**, whatever happens, and prints nothing on success. It marks this session ended, and when it is running inside the Plinth desktop dock it tells the dock the session is finished so the tab can close itself. **Outside the dock it does nothing visible, and that is correct.** Nothing in this command depends on the dock; it runs the same in a bare terminal. The one thing it will ever print is a warning on stderr saying your `--summary` was lost before it could be read; the session still ends."
28786
28863
  ]);
28787
28864
  }
28788
28865
  function buildOnboardCommandMd(workspaceSlug) {
@@ -28928,12 +29005,16 @@ function ensureWorkspaceCommands(deps) {
28928
29005
  const created = [];
28929
29006
  const refreshed = [];
28930
29007
  const noArtists = (deps.readRosterIsEmpty ?? rosterIsEmpty)(workspaceSlug);
28931
- const targets = [
28932
- [START_COMMAND_REL, buildStartCommandMd(noArtists)],
28933
- [CLOSE_COMMAND_REL, buildCloseCommandMd(workspaceSlug)],
28934
- [ONBOARD_COMMAND_REL, buildOnboardCommandMd(workspaceSlug)]
28935
- ];
28936
- for (const [rel, generated] of targets) {
29008
+ const bodies = {
29009
+ [START_COMMAND_REL]: buildStartCommandMd(noArtists),
29010
+ [CLOSE_COMMAND_REL]: buildCloseCommandMd(workspaceSlug),
29011
+ [ONBOARD_COMMAND_REL]: buildOnboardCommandMd(workspaceSlug)
29012
+ };
29013
+ for (const rel of GENERATED_COMMAND_RELS) {
29014
+ const generated = bodies[rel];
29015
+ if (generated === undefined) {
29016
+ throw new Error(`ensureWorkspaceCommands: no generated body for ${rel}`);
29017
+ }
28937
29018
  const existing = readContextFile(workspaceSlug, rel);
28938
29019
  const decision = decideRegen({ existing, currentVersion: "", generated, force });
28939
29020
  if (decision.action === "backup-regen" && decision.backup !== undefined) {
@@ -28956,6 +29037,44 @@ function ensureWorkspaceCommands(deps) {
28956
29037
  function backupStamp2(d) {
28957
29038
  return d.toISOString().replace(/[-:.]/g, "").replace("Z", "");
28958
29039
  }
29040
+ var RETIRED_COMMAND_TOKENS = [
29041
+ { commandRel: CLOSE_COMMAND_REL, token: "memory-gate", version: "0.11.0" }
29042
+ ];
29043
+ var GENERATED_COMMAND_RELS = [
29044
+ START_COMMAND_REL,
29045
+ CLOSE_COMMAND_REL,
29046
+ ONBOARD_COMMAND_REL
29047
+ ];
29048
+ function scanStaleUserBlocks(deps) {
29049
+ const retired = deps.retired ?? RETIRED_COMMAND_TOKENS;
29050
+ const read = deps.read ?? readContextFile;
29051
+ const stale = [];
29052
+ const unchecked = [];
29053
+ let checked = 0;
29054
+ for (const rel of GENERATED_COMMAND_RELS) {
29055
+ let content;
29056
+ try {
29057
+ content = read(deps.workspaceSlug, rel);
29058
+ } catch {
29059
+ unchecked.push({ commandRel: rel, reason: "unreadable" });
29060
+ continue;
29061
+ }
29062
+ if (content === null)
29063
+ continue;
29064
+ const block = extractUserBlock(content);
29065
+ if (block === null) {
29066
+ unchecked.push({ commandRel: rel, reason: "no-markers" });
29067
+ continue;
29068
+ }
29069
+ checked++;
29070
+ const haystack = block.toLowerCase();
29071
+ const tokens = retired.filter((r) => r.commandRel === rel && haystack.includes(r.token.toLowerCase()));
29072
+ if (tokens.length > 0) {
29073
+ stale.push({ commandRel: rel, invocation: invocationFor(rel), tokens });
29074
+ }
29075
+ }
29076
+ return { checked, unchecked, stale };
29077
+ }
28959
29078
 
28960
29079
  // src/lib/context/workspace-claude-md.ts
28961
29080
  var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
@@ -28991,7 +29110,7 @@ function buildWorkspaceClaudeMd(input) {
28991
29110
  ``,
28992
29111
  `> **Session grounding**: the current date, an entity-resolution rule, and a write-confirmation rule are injected at every session start by the Plinth hook in \`.claude/settings.json\` (and each \`artists/<slug>/.claude/settings.json\`).`,
28993
29112
  ``,
28994
- `> **Filing a file the user hands you - BOTH halves, and the first one is unconditional**: put a copy in \`files/\`, which is how Plinth gets the text (folder map below) - do this even if everything else here fails. Then put the **original** in the user's own file storage with whatever tool you have for that, and write down where it went somewhere durable: \`artists/<slug>/memory.md\` if it is that artist's, otherwise \`workspace-memory.md\`. A pointer that exists only in this conversation is lost when it ends. If you have no tool that reaches their file storage, SAY SO - do not leave them believing the original was filed.`,
29113
+ `> **Filing a file the user hands you - BOTH halves, and the first one is unconditional**: put a copy in \`files/\`, which is how Plinth gets the text (folder map below) - do this even if everything else here fails. Then put the **original** in the user's own file storage with whatever tool you have for that, and write down where it went somewhere durable: \`artists/<slug>/memory.md\` if it is that artist's, otherwise your own \`user-memory.md\`. A pointer that exists only in this conversation is lost when it ends. If you have no tool that reaches their file storage, SAY SO - do not leave them believing the original was filed.`,
28995
29114
  ``,
28996
29115
  `## Folder map`,
28997
29116
  `- \`artists/<slug>/\` one folder per artist, the working dir for artist-scoped sessions · \`threads/<slug>/_thread.md\` one folder per thread`,
@@ -29010,7 +29129,7 @@ function buildWorkspaceClaudeMd(input) {
29010
29129
  `## Navigating the corpus`,
29011
29130
  `- **What points at X?** → \`plinth backlinks "<name>"\`, a mirror path, or a wikilink target as written. Render-only blocks, code spans and comments are narrowed out; \`--unfiltered\` shows them. A bare name several files answer to is refused with the candidates listed, so re-run with the path it names`,
29012
29131
  `- **What does this file link to?** → grep \`[[\` in it`,
29013
- `- **Find a file by name** → glob under \`files/\` \`documents/\` \`artists/\` · **text inside a PDF** → \`${EXTRACTED_TEXT_DIR}\` (folder map above) · **a task, project, artist or contact by field** → the \`mcp__plinth__*\` \`search_entities\` / \`list_*\` tools`,
29132
+ `- **Find a file by name** → glob under \`files/\` \`documents/\` \`artists/\` · **text inside a PDF** → \`${EXTRACTED_TEXT_DIR}\` (folder map above) · **write to a row you have the mirror file for** → pass its frontmatter \`id:\` to the \`mcp__plinth__*\` write tool; if you do not have the file, find it with \`search_entities\` / \`list_*\``,
29014
29133
  ``,
29015
29134
  `## Entity model: ${MCP_INTROSPECTION_POINTER}`,
29016
29135
  ...entityLines,
@@ -29811,8 +29930,8 @@ var sessionCommand = defineCommand({
29811
29930
  });
29812
29931
 
29813
29932
  // src/commands/start.ts
29814
- var import_gray_matter10 = __toESM(require_gray_matter(), 1);
29815
- import { readFileSync as readFileSync21, statSync as statSync13 } from "node:fs";
29933
+ var import_gray_matter12 = __toESM(require_gray_matter(), 1);
29934
+ import { readFileSync as readFileSync23, statSync as statSync15 } from "node:fs";
29816
29935
 
29817
29936
  // src/lib/daemon-log.ts
29818
29937
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync9, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
@@ -30212,6 +30331,9 @@ function fileFailedLine(f) {
30212
30331
  const serverPart = f.serverMessage ? ` server_msg="${f.serverMessage}"` : "";
30213
30332
  return `[FILE_PUSH_FAILED path=${f.path} status=${f.status} reason=${f.reason}${detailPart}${serverPart}]`;
30214
30333
  }
30334
+ function frontmatterIdBackfillLine(r) {
30335
+ return `[FRONTMATTER_ID_BACKFILL scanned=${r.scanned} written=${r.written} ` + `inodes_restamped=${r.inodesRestamped} failed=${r.failed} ` + `skipped_frozen=${r.skipped.frozen} ` + `skipped_foreign_id=${r.skipped.foreignId} ` + `skipped_no_frontmatter=${r.skipped.noFrontmatter} ` + `skipped_slug_collision=${r.skipped.slugCollision} ` + `skipped_diverged=${r.skipped.diverged}]`;
30336
+ }
30215
30337
  function pullNoticeLine(kind) {
30216
30338
  const token = kind === "auth" ? "PULL_PAUSED" : "PULL_DEGRADED";
30217
30339
  return `[${token} reason=${kind}]`;
@@ -31148,6 +31270,7 @@ async function pushFile(args) {
31148
31270
  unlinkContext,
31149
31271
  deps
31150
31272
  } = args;
31273
+ const identity = args.identity ?? "auto";
31151
31274
  const fetchImpl = deps?.fetchImpl;
31152
31275
  const now = deps?.now ?? (() => new Date);
31153
31276
  const mutex = deps?.mutex ?? new PerIdMutex;
@@ -31214,7 +31337,7 @@ async function pushFile(args) {
31214
31337
  parsedSlug = strategy.parseMirrorPath(rel)?.slug ?? null;
31215
31338
  } catch {}
31216
31339
  const slugFromPath = parsedSlug ?? basename4(absolutePath, ".md");
31217
- const slug = slugFromFm ?? slugFromPath;
31340
+ const slug = identity === "path" ? slugFromPath : slugFromFm ?? slugFromPath;
31218
31341
  if (!slug) {
31219
31342
  return {
31220
31343
  outcome: { kind: "ignored", absolutePath, reason: "empty slug" },
@@ -31238,7 +31361,8 @@ async function pushFile(args) {
31238
31361
  }
31239
31362
  }
31240
31363
  const liveInode = safeInode2(absolutePath);
31241
- const stateEntry = (liveInode !== undefined ? getEntryByInode(state, entityType, liveInode) : undefined) ?? getEntryBySlug(state, entityType, slug);
31364
+ const byInode = liveInode !== undefined ? getEntryByInode(state, entityType, liveInode) : undefined;
31365
+ const stateEntry = identity === "path" ? byInode : byInode ?? getEntryBySlug(state, entityType, slug);
31242
31366
  if (!stateEntry) {
31243
31367
  return runPushCreate({
31244
31368
  strategy,
@@ -31254,7 +31378,8 @@ async function pushFile(args) {
31254
31378
  fetchImpl,
31255
31379
  now,
31256
31380
  output,
31257
- quarantinedKeys
31381
+ quarantinedKeys,
31382
+ identity
31258
31383
  });
31259
31384
  }
31260
31385
  return runPushUpsert({
@@ -31807,7 +31932,7 @@ async function runPushCreate(args) {
31807
31932
  const strategy = args.strategy;
31808
31933
  const key = `create:${strategy.type}:${args.slug}`;
31809
31934
  return args.mutex.run(key, async () => {
31810
- const inLock = getEntryBySlug(args.state, strategy.type, args.slug);
31935
+ const inLock = args.identity === "path" ? undefined : getEntryBySlug(args.state, strategy.type, args.slug);
31811
31936
  if (inLock) {
31812
31937
  return runPushUpsert({
31813
31938
  strategy,
@@ -35949,8 +36074,500 @@ class PullSuppressor {
35949
36074
  }
35950
36075
  }
35951
36076
 
36077
+ // src/lib/discover-entities.ts
36078
+ var import_gray_matter10 = __toESM(require_gray_matter(), 1);
36079
+ import { readFileSync as readFileSync18, statSync as statSync11 } from "node:fs";
36080
+ var DISCOVERY_WRITE_CAP_DEFAULT = 10;
36081
+ function discoveryWriteCap() {
36082
+ const raw = process.env.PLINTH_DISCOVERY_WRITE_CAP;
36083
+ if (raw === undefined || raw === "")
36084
+ return DISCOVERY_WRITE_CAP_DEFAULT;
36085
+ const n = Number.parseInt(raw, 10);
36086
+ return Number.isFinite(n) && n >= 0 ? n : DISCOVERY_WRITE_CAP_DEFAULT;
36087
+ }
36088
+ function looksLikeCloudRow(args) {
36089
+ const { frontmatter, entitySlug } = args;
36090
+ if (frontmatter.revision === undefined || frontmatter.revision === null) {
36091
+ return false;
36092
+ }
36093
+ return frontmatter.slug === entitySlug;
36094
+ }
36095
+ function classifyUntrackedEntity(args) {
36096
+ if (args.entityType === "artists")
36097
+ return { kind: "hold", held: "artist" };
36098
+ if (looksLikeCloudRow({
36099
+ frontmatter: args.frontmatter,
36100
+ entitySlug: args.entitySlug
36101
+ })) {
36102
+ return { kind: "hold", held: "cloud-row" };
36103
+ }
36104
+ return { kind: "create" };
36105
+ }
36106
+ function classifyRenameDestination(args) {
36107
+ const occupant = getEntryBySlug(args.state, args.entityType, args.entitySlug);
36108
+ if (occupant && occupant.id !== args.movedEntryId) {
36109
+ return { kind: "hold", held: "rename-taken" };
36110
+ }
36111
+ return { kind: "rename" };
36112
+ }
36113
+ var EMPTY = {
36114
+ discovered: 0,
36115
+ created: 0,
36116
+ collisions: 0,
36117
+ renamed: 0,
36118
+ renameConflicts: 0,
36119
+ renameVanished: 0,
36120
+ heldCap: 0,
36121
+ heldArtist: 0,
36122
+ heldCloudRow: 0,
36123
+ refusedRename: 0,
36124
+ failed: 0,
36125
+ rejected: 0
36126
+ };
36127
+ function planDiscoveryWrites(args) {
36128
+ const { workspaceSlug, mirrorRootPath, state, counts, output, isFrozen } = args;
36129
+ const spokenFor = new Set;
36130
+ for (const op of [...listUnsynced(state), ...listRejected(state)]) {
36131
+ if (op.target !== "file")
36132
+ spokenFor.add(op.path);
36133
+ }
36134
+ const ignored = watcherIgnoreMatcher(mirrorRootPath);
36135
+ const planned = [];
36136
+ for (const absolutePath of walkSubtreeFiles(mirrorRootPath, ignored)) {
36137
+ const match = classifyMirrorPath({ workspaceSlug, absolutePath });
36138
+ if (!match)
36139
+ continue;
36140
+ const { entityType, entitySlug } = match;
36141
+ const strategy = getStrategy(entityType);
36142
+ if (!strategy)
36143
+ continue;
36144
+ const relPath = strategy.mirrorRelativePath(entitySlug);
36145
+ if (spokenFor.has(relPath))
36146
+ continue;
36147
+ let liveInode;
36148
+ try {
36149
+ liveInode = statSync11(absolutePath).ino;
36150
+ } catch {
36151
+ continue;
36152
+ }
36153
+ const byInode = liveInode !== undefined ? getEntryByInode(state, entityType, liveInode) : undefined;
36154
+ if (byInode) {
36155
+ if (byInode.slug === entitySlug)
36156
+ continue;
36157
+ if (isFrozen?.(entityType, byInode.id))
36158
+ continue;
36159
+ const destination = classifyRenameDestination({
36160
+ state,
36161
+ entityType,
36162
+ entitySlug,
36163
+ movedEntryId: byInode.id
36164
+ });
36165
+ if (destination.kind === "hold") {
36166
+ counts.refusedRename++;
36167
+ output?.warn(`Offline rename of ${entityType}/${byInode.slug} to ${entitySlug} was not ` + `synced: ${entitySlug} already belongs to another ${entityType} row. ` + `Rename it to a free name and Plinth will pick it up.`);
36168
+ continue;
36169
+ }
36170
+ planned.push({ kind: "rename", entityType, entitySlug, absolutePath, entry: byInode });
36171
+ continue;
36172
+ }
36173
+ if (getEntryBySlug(state, entityType, entitySlug))
36174
+ continue;
36175
+ counts.discovered++;
36176
+ let frontmatter;
36177
+ try {
36178
+ frontmatter = import_gray_matter10.default(readFileSync18(absolutePath, "utf8")).data ?? {};
36179
+ } catch {
36180
+ counts.discovered--;
36181
+ continue;
36182
+ }
36183
+ const verdict = classifyUntrackedEntity({ entityType, entitySlug, frontmatter });
36184
+ if (verdict.kind === "hold") {
36185
+ if (verdict.held === "artist")
36186
+ counts.heldArtist++;
36187
+ else
36188
+ counts.heldCloudRow++;
36189
+ continue;
36190
+ }
36191
+ planned.push({ kind: "create", entityType, entitySlug, absolutePath });
36192
+ }
36193
+ return planned;
36194
+ }
36195
+ async function discoverUntrackedEntities(args) {
36196
+ const { workspaceSlug, baseUrl, pat, fetchImpl, output, isFrozen } = args;
36197
+ const now = args.now ?? (() => new Date);
36198
+ let state = args.state;
36199
+ const counts = { ...EMPTY };
36200
+ let mirrorRootPath;
36201
+ try {
36202
+ mirrorRootPath = workspaceMirrorPath(workspaceSlug);
36203
+ } catch {
36204
+ return { state, ...counts };
36205
+ }
36206
+ let planned;
36207
+ try {
36208
+ planned = planDiscoveryWrites({
36209
+ workspaceSlug,
36210
+ mirrorRootPath,
36211
+ state,
36212
+ counts,
36213
+ output,
36214
+ isFrozen
36215
+ });
36216
+ } catch (err) {
36217
+ const msg = err instanceof Error ? err.message : "unknown error";
36218
+ output?.warn(`Could not check this workspace for files added while Plinth was closed: ${msg} ` + `(everything already synced is unaffected).`);
36219
+ return { state, ...EMPTY };
36220
+ }
36221
+ const cap = discoveryWriteCap();
36222
+ const creates = planned.filter((w) => w.kind === "create");
36223
+ const toRun = creates.length > cap ? planned.filter((w) => w.kind === "rename") : planned;
36224
+ if (creates.length > cap) {
36225
+ counts.heldCap = creates.length;
36226
+ output?.log(`[DISCOVERY_HELD reason=cap creates=${creates.length} cap=${cap} source=entity-discovery]`);
36227
+ }
36228
+ state = await runPlannedWrites({
36229
+ planned: toRun,
36230
+ workspaceSlug,
36231
+ baseUrl,
36232
+ pat,
36233
+ state,
36234
+ fetchImpl,
36235
+ now,
36236
+ output,
36237
+ counts
36238
+ });
36239
+ return { state, ...counts };
36240
+ }
36241
+ async function runPlannedWrites(args) {
36242
+ const { planned, workspaceSlug, baseUrl, pat, fetchImpl, now, output, counts } = args;
36243
+ let state = args.state;
36244
+ for (const write of planned) {
36245
+ try {
36246
+ if (write.kind === "rename") {
36247
+ state = await runDiscoveredRename({
36248
+ write,
36249
+ workspaceSlug,
36250
+ baseUrl,
36251
+ pat,
36252
+ state,
36253
+ fetchImpl,
36254
+ now,
36255
+ output,
36256
+ counts
36257
+ });
36258
+ continue;
36259
+ }
36260
+ const result = await pushFile({
36261
+ workspaceSlug,
36262
+ baseUrl,
36263
+ pat,
36264
+ absolutePath: write.absolutePath,
36265
+ entityType: write.entityType,
36266
+ state,
36267
+ exists: true,
36268
+ unlinkContext: null,
36269
+ identity: "path",
36270
+ deps: { fetchImpl, now, output }
36271
+ });
36272
+ state = result.state;
36273
+ switch (result.outcome.kind) {
36274
+ case "created":
36275
+ case "pushed":
36276
+ counts.created++;
36277
+ break;
36278
+ case "conflict":
36279
+ case "create_slug_conflict":
36280
+ counts.collisions++;
36281
+ break;
36282
+ case "sync_error":
36283
+ counts.created++;
36284
+ break;
36285
+ default:
36286
+ break;
36287
+ }
36288
+ } catch (err) {
36289
+ state = recordDiscoveryFailure({
36290
+ err,
36291
+ write,
36292
+ workspaceSlug,
36293
+ state,
36294
+ now,
36295
+ output,
36296
+ counts
36297
+ });
36298
+ }
36299
+ }
36300
+ return state;
36301
+ }
36302
+ async function runDiscoveredRename(args) {
36303
+ const { write, workspaceSlug, baseUrl, pat, fetchImpl, now, output, counts } = args;
36304
+ let state = args.state;
36305
+ let fromPath;
36306
+ try {
36307
+ fromPath = entityFilePath(workspaceSlug, write.entityType, write.entry.slug);
36308
+ } catch (err) {
36309
+ if (!(err instanceof MirrorPathError))
36310
+ throw err;
36311
+ return state;
36312
+ }
36313
+ const renameResult = await pushRename({
36314
+ workspaceSlug,
36315
+ baseUrl,
36316
+ pat,
36317
+ entityType: write.entityType,
36318
+ fromPath,
36319
+ toPath: write.absolutePath,
36320
+ id: write.entry.id,
36321
+ expectedUpdatedAt: write.entry.updated_at,
36322
+ fromSlug: write.entry.slug,
36323
+ state,
36324
+ deps: { fetchImpl, now, output }
36325
+ });
36326
+ state = renameResult.state;
36327
+ if (renameResult.outcome.kind === "not_found") {
36328
+ counts.renameVanished++;
36329
+ output?.warn(`${write.entityType}/${write.entry.slug} was renamed here, but it no longer ` + `exists in Plinth — the next sync will remove the file.`);
36330
+ return state;
36331
+ }
36332
+ if (renameResult.outcome.kind !== "renamed") {
36333
+ if (renameResult.outcome.kind === "conflict" || renameResult.outcome.kind === "rename_slug_conflict") {
36334
+ counts.renameConflicts++;
36335
+ }
36336
+ return state;
36337
+ }
36338
+ counts.renamed++;
36339
+ let upsert;
36340
+ try {
36341
+ upsert = await pushFile({
36342
+ workspaceSlug,
36343
+ baseUrl,
36344
+ pat,
36345
+ absolutePath: write.absolutePath,
36346
+ entityType: write.entityType,
36347
+ state,
36348
+ exists: true,
36349
+ unlinkContext: null,
36350
+ identity: "path",
36351
+ deps: { fetchImpl, now, output }
36352
+ });
36353
+ } catch (err) {
36354
+ return recordDiscoveryFailure({
36355
+ err,
36356
+ write,
36357
+ workspaceSlug,
36358
+ state,
36359
+ now,
36360
+ output,
36361
+ counts
36362
+ });
36363
+ }
36364
+ if (upsert.outcome.kind === "conflict")
36365
+ counts.renameConflicts++;
36366
+ return upsert.state;
36367
+ }
36368
+ function recordDiscoveryFailure(args) {
36369
+ const { err, write, workspaceSlug, now, output, counts } = args;
36370
+ let state = args.state;
36371
+ const msg = err instanceof Error ? err.message : "unknown error";
36372
+ output?.log(pushFailedLine({
36373
+ entityType: write.entityType,
36374
+ slug: write.entitySlug,
36375
+ id: write.kind === "rename" ? write.entry.id : "",
36376
+ ...categorizeError(err)
36377
+ }));
36378
+ if (isPermanentReject(err)) {
36379
+ counts.rejected++;
36380
+ output?.warn(`Plinth refused ${write.entityType}/${write.entitySlug}: ${msg} ` + `(see \`plinth status\`; fix the file and save it again).`);
36381
+ return recordEntityRejected({
36382
+ state,
36383
+ workspaceSlug,
36384
+ absolutePath: write.absolutePath,
36385
+ op: "upsert",
36386
+ err,
36387
+ now
36388
+ });
36389
+ }
36390
+ counts.failed++;
36391
+ output?.warn(`Could not sync ${write.entityType}/${write.entitySlug}: ${msg} ` + `(file kept on disk; will retry).`);
36392
+ if (isRecordableFailure(err)) {
36393
+ state = recordEntityUnsynced({
36394
+ state,
36395
+ workspaceSlug,
36396
+ absolutePath: write.absolutePath,
36397
+ op: "upsert",
36398
+ err,
36399
+ now
36400
+ });
36401
+ }
36402
+ return state;
36403
+ }
36404
+
36405
+ // src/lib/frontmatter-id-backfill.ts
36406
+ var import_gray_matter11 = __toESM(require_gray_matter(), 1);
36407
+ import { readFileSync as readFileSync19, statSync as statSync12 } from "node:fs";
36408
+ function backfillWorthReporting(r) {
36409
+ return r.written > 0 || r.failed > 0 || r.skipped.foreignId > 0;
36410
+ }
36411
+ function emptyResult() {
36412
+ return {
36413
+ scanned: 0,
36414
+ written: 0,
36415
+ alreadyPresent: 0,
36416
+ missing: 0,
36417
+ skipped: {
36418
+ frozen: 0,
36419
+ foreignId: 0,
36420
+ noFrontmatter: 0,
36421
+ slugCollision: 0,
36422
+ diverged: 0
36423
+ },
36424
+ failed: 0,
36425
+ inodesRestamped: 0
36426
+ };
36427
+ }
36428
+ function heldByAnyBatch(workspaceSlug) {
36429
+ const out = new Set;
36430
+ for (const batch of listPendingBatches(workspaceSlug)) {
36431
+ for (const op of batch.ops) {
36432
+ if (op.target === "entity")
36433
+ out.add(`${op.entityType}:${op.id}`);
36434
+ }
36435
+ }
36436
+ return out;
36437
+ }
36438
+ function safeInode3(path5) {
36439
+ try {
36440
+ return statSync12(path5).ino;
36441
+ } catch {
36442
+ return;
36443
+ }
36444
+ }
36445
+ function spliceFrontmatterId(content, id) {
36446
+ const eol = /^---\r\n/.test(content) ? `\r
36447
+ ` : `
36448
+ `;
36449
+ if (!content.startsWith(`---${eol}`))
36450
+ return null;
36451
+ const closeAt = content.indexOf(`${eol}---`, 3);
36452
+ if (closeAt === -1)
36453
+ return null;
36454
+ return `${content.slice(0, closeAt)}${eol}id: ${id}${content.slice(closeAt)}`;
36455
+ }
36456
+ function backfillFrontmatterIds(args) {
36457
+ const { workspaceSlug, state } = args;
36458
+ const splice = args.splice ?? spliceFrontmatterId;
36459
+ const result = emptyResult();
36460
+ let held;
36461
+ try {
36462
+ held = heldByAnyBatch(workspaceSlug);
36463
+ } catch {
36464
+ return result;
36465
+ }
36466
+ let nextState = state;
36467
+ let stateChanged = false;
36468
+ for (const [entityType, bucket] of Object.entries(state.entries)) {
36469
+ const strategy = getStrategy(entityType);
36470
+ if (!strategy)
36471
+ continue;
36472
+ const slugCounts = new Map;
36473
+ for (const e of Object.values(bucket)) {
36474
+ slugCounts.set(e.slug, (slugCounts.get(e.slug) ?? 0) + 1);
36475
+ }
36476
+ for (const [id, entry2] of Object.entries(bucket)) {
36477
+ result.scanned++;
36478
+ if ((slugCounts.get(entry2.slug) ?? 0) > 1) {
36479
+ result.skipped.slugCollision++;
36480
+ continue;
36481
+ }
36482
+ if (held.has(`${entityType}:${id}`)) {
36483
+ result.skipped.frozen++;
36484
+ continue;
36485
+ }
36486
+ let raw;
36487
+ try {
36488
+ raw = readFileSync19(entityFilePath(workspaceSlug, entityType, entry2.slug), "utf8");
36489
+ } catch (err) {
36490
+ if (err?.code === "ENOENT") {
36491
+ result.missing++;
36492
+ } else {
36493
+ result.failed++;
36494
+ }
36495
+ continue;
36496
+ }
36497
+ let existing;
36498
+ let rawBody;
36499
+ try {
36500
+ const parsed = import_gray_matter11.default(raw);
36501
+ existing = (parsed.data ?? {})["id"];
36502
+ rawBody = parsed.content;
36503
+ } catch {
36504
+ result.failed++;
36505
+ continue;
36506
+ }
36507
+ if (existing !== undefined) {
36508
+ if (existing === id)
36509
+ result.alreadyPresent++;
36510
+ else
36511
+ result.skipped.foreignId++;
36512
+ continue;
36513
+ }
36514
+ const hashes = computeMirrorHashes(strategy, raw);
36515
+ if (hashes.bodyHash !== entry2.body_hash || hashes.frontmatterHash !== entry2.frontmatter_hash) {
36516
+ result.skipped.diverged++;
36517
+ continue;
36518
+ }
36519
+ const next = splice(raw, id);
36520
+ if (next === null) {
36521
+ result.skipped.noFrontmatter++;
36522
+ continue;
36523
+ }
36524
+ try {
36525
+ const check2 = import_gray_matter11.default(next);
36526
+ if (check2.data?.["id"] !== id || check2.content !== rawBody) {
36527
+ result.failed++;
36528
+ continue;
36529
+ }
36530
+ } catch {
36531
+ result.failed++;
36532
+ continue;
36533
+ }
36534
+ let ino;
36535
+ try {
36536
+ const stillRaw = readFileSync19(entityFilePath(workspaceSlug, entityType, entry2.slug), "utf8");
36537
+ if (stillRaw !== raw) {
36538
+ result.skipped.diverged++;
36539
+ continue;
36540
+ }
36541
+ writeMirrorFile(workspaceSlug, entityType, entry2.slug, next);
36542
+ result.written++;
36543
+ ino = safeInode3(entityFilePath(workspaceSlug, entityType, entry2.slug));
36544
+ } catch {
36545
+ result.failed++;
36546
+ continue;
36547
+ }
36548
+ if (ino !== undefined && ino !== entry2.inode) {
36549
+ const up = upsertEntry(nextState, entityType, { ...entry2, inode: ino });
36550
+ nextState = up.state;
36551
+ if (up.changed) {
36552
+ stateChanged = true;
36553
+ result.inodesRestamped++;
36554
+ }
36555
+ }
36556
+ }
36557
+ }
36558
+ if (stateChanged) {
36559
+ try {
36560
+ commitState(state, nextState);
36561
+ } catch {
36562
+ result.failed++;
36563
+ result.inodesRestamped = 0;
36564
+ }
36565
+ }
36566
+ return result;
36567
+ }
36568
+
35952
36569
  // src/lib/reconcile.ts
35953
- import { readFileSync as readFileSync18 } from "node:fs";
36570
+ import { readFileSync as readFileSync20 } from "node:fs";
35954
36571
  async function reconcileDivergedRows(args) {
35955
36572
  const { workspaceSlug, baseUrl, pat, fetchImpl, output, isFrozen } = args;
35956
36573
  const now = args.now ?? (() => new Date);
@@ -36036,7 +36653,7 @@ async function reconcileDivergedRows(args) {
36036
36653
  }
36037
36654
  let content;
36038
36655
  try {
36039
- content = readFileSync18(absolutePath, "utf8");
36656
+ content = readFileSync20(absolutePath, "utf8");
36040
36657
  } catch (err) {
36041
36658
  if (err?.code === "ENOENT")
36042
36659
  continue;
@@ -36098,7 +36715,29 @@ async function reconcileDivergedRows(args) {
36098
36715
  }
36099
36716
  }
36100
36717
  }
36101
- return { state, scanned, diverged, pushed, conflicts, noop, failed, quarantined, syncErrors };
36718
+ const discovery = await discoverUntrackedEntities({
36719
+ workspaceSlug,
36720
+ baseUrl,
36721
+ pat,
36722
+ state,
36723
+ fetchImpl,
36724
+ now,
36725
+ output,
36726
+ isFrozen
36727
+ });
36728
+ state = discovery.state;
36729
+ return {
36730
+ state,
36731
+ scanned,
36732
+ diverged,
36733
+ pushed,
36734
+ conflicts,
36735
+ noop,
36736
+ failed,
36737
+ quarantined,
36738
+ syncErrors,
36739
+ discovery
36740
+ };
36102
36741
  }
36103
36742
 
36104
36743
  // src/lib/workspace-rules/sync.ts
@@ -37095,8 +37734,8 @@ function createThrottledFetch(baseFetch, opts = {}) {
37095
37734
  import {
37096
37735
  existsSync as existsSync7,
37097
37736
  readdirSync as readdirSync6,
37098
- readFileSync as readFileSync20,
37099
- statSync as statSync12,
37737
+ readFileSync as readFileSync22,
37738
+ statSync as statSync14,
37100
37739
  unlinkSync as unlinkSync7
37101
37740
  } from "node:fs";
37102
37741
  import { join as join16 } from "node:path";
@@ -37222,7 +37861,7 @@ function refreshConflictsView(workspaceSlug) {
37222
37861
  }
37223
37862
 
37224
37863
  // src/lib/files/pull.ts
37225
- import { existsSync as existsSync6, readFileSync as readFileSync19, statSync as statSync11 } from "node:fs";
37864
+ import { existsSync as existsSync6, readFileSync as readFileSync21, statSync as statSync13 } from "node:fs";
37226
37865
 
37227
37866
  // src/lib/files/extracted-text.ts
37228
37867
  import { createHash as createHash3 } from "node:crypto";
@@ -37334,6 +37973,7 @@ async function pullFiles(args) {
37334
37973
  output: args.output,
37335
37974
  suppressor: args.suppressor,
37336
37975
  isFileFrozen: args.isFileFrozen,
37976
+ loggedTombstoneSkips: args.loggedTombstoneSkips,
37337
37977
  forceTextRewalk: args.forceTextRewalk
37338
37978
  });
37339
37979
  state = outcome.state;
@@ -37482,6 +38122,26 @@ async function applyFileRow(args) {
37482
38122
  }
37483
38123
  const existing = getFileEntry(state, relPath);
37484
38124
  if (row.deleted_at !== null) {
38125
+ if (existing && existing.id !== row.id) {
38126
+ const key = `${row.id}:${existing.id}:${relPath}`;
38127
+ if (!args.loggedTombstoneSkips?.has(key)) {
38128
+ args.loggedTombstoneSkips?.add(key);
38129
+ output?.log(`[FILE_TOMBSTONE_SKIP path=${relPath} row_id=${row.id} tracked_id=${existing.id} reason=id_mismatch]`);
38130
+ }
38131
+ return { state, action: "skipped" };
38132
+ }
38133
+ let localBytes;
38134
+ try {
38135
+ localBytes = readFileSync21(abs);
38136
+ } catch {
38137
+ localBytes = undefined;
38138
+ }
38139
+ const isOurs = localBytes === undefined || existing !== undefined && sha256Bytes(localBytes) === existing.content_hash;
38140
+ if (!isOurs) {
38141
+ output?.log(`[FILE_TOMBSTONE_KEEP path=${relPath} row_id=${row.id} reason=${existing === undefined ? "untracked" : "diverged"}]`);
38142
+ const next2 = existing ? removeFileEntry(state, relPath).state : state;
38143
+ return { state: next2, action: "skipped" };
38144
+ }
37485
38145
  suppressor?.registerDelete(abs);
37486
38146
  const didUnlink = deleteFilesFile(workspaceSlug, relPath);
37487
38147
  const removedText = safeDeleteExtractedText(workspaceSlug, relPath, output);
@@ -37569,7 +38229,7 @@ async function applyFileRow(args) {
37569
38229
  }
37570
38230
  if (!suppressor && existing && existing.content_hash !== row.content_hash && existsSync6(abs)) {
37571
38231
  try {
37572
- const localBytes = readFileSync19(abs);
38232
+ const localBytes = readFileSync21(abs);
37573
38233
  if (sha256Bytes(localBytes) !== existing.content_hash) {
37574
38234
  const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes: localBytes });
37575
38235
  output?.warn(`Conflict on files/${relPath} → stashed local edit to ${stashPath} (cloud-wins on boot).`);
@@ -37610,7 +38270,7 @@ async function applyFileRow(args) {
37610
38270
  }
37611
38271
  let inode;
37612
38272
  try {
37613
- inode = statSync11(abs).ino;
38273
+ inode = statSync13(abs).ino;
37614
38274
  } catch {
37615
38275
  inode = undefined;
37616
38276
  }
@@ -37718,7 +38378,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
37718
38378
  }
37719
38379
  let liveInode;
37720
38380
  try {
37721
- liveInode = statSync12(target).ino;
38381
+ liveInode = statSync14(target).ino;
37722
38382
  } catch {
37723
38383
  return { didUnlink: false, skippedReason: "absent" };
37724
38384
  }
@@ -37865,6 +38525,7 @@ async function runSync(workspaceSlug, deps = {}) {
37865
38525
  output: out,
37866
38526
  suppressor: deps.suppressor,
37867
38527
  isFileFrozen: deps.isFileFrozen,
38528
+ loggedTombstoneSkips: deps.loggedTombstoneSkips,
37868
38529
  commit: commitState,
37869
38530
  forceTextRewalk: needsDerivedTextRewalk
37870
38531
  });
@@ -38080,7 +38741,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
38080
38741
  if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
38081
38742
  let localRaw = null;
38082
38743
  try {
38083
- localRaw = readFileSync20(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
38744
+ localRaw = readFileSync22(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
38084
38745
  } catch {
38085
38746
  localRaw = null;
38086
38747
  }
@@ -38103,7 +38764,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
38103
38764
  const hashes = computeMirrorHashes(strategy, content);
38104
38765
  let inode;
38105
38766
  try {
38106
- inode = statSync12(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38767
+ inode = statSync14(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38107
38768
  } catch {
38108
38769
  inode = undefined;
38109
38770
  }
@@ -38172,6 +38833,13 @@ var syncCommand = defineCommand({
38172
38833
  process.exit(1);
38173
38834
  }
38174
38835
  handle = acquired.handle;
38836
+ const idBackfill = backfillFrontmatterIds({
38837
+ workspaceSlug: slug,
38838
+ state: loadState(slug)
38839
+ });
38840
+ if (backfillWorthReporting(idBackfill)) {
38841
+ out.log(frontmatterIdBackfillLine(idBackfill));
38842
+ }
38175
38843
  }
38176
38844
  let result;
38177
38845
  try {
@@ -38189,7 +38857,7 @@ var syncCommand = defineCommand({
38189
38857
  // src/commands/start.ts
38190
38858
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
38191
38859
  var PULL_HEARTBEAT_MS = 900000;
38192
- function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync13(p)) {
38860
+ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync15(p)) {
38193
38861
  let changed = false;
38194
38862
  const nextEntries = {};
38195
38863
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -38222,7 +38890,7 @@ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync13(p)
38222
38890
  return { state, changed: false };
38223
38891
  return { state: { ...state, entries: nextEntries }, changed: true };
38224
38892
  }
38225
- function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync21(p, "utf8")) {
38893
+ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync23(p, "utf8")) {
38226
38894
  let changed = false;
38227
38895
  const nextEntries = {};
38228
38896
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -38234,7 +38902,7 @@ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync2
38234
38902
  }
38235
38903
  try {
38236
38904
  const path5 = entityFilePath(workspaceSlug, entityType, entry2.slug);
38237
- const bodyLen = import_gray_matter10.default(readFn(path5)).content.length;
38905
+ const bodyLen = import_gray_matter12.default(readFn(path5)).content.length;
38238
38906
  nextBucket[id] = { ...entry2, body_len: bodyLen };
38239
38907
  changed = true;
38240
38908
  } catch {
@@ -38310,7 +38978,7 @@ function makeWatchHandler(deps) {
38310
38978
  }
38311
38979
  let st;
38312
38980
  try {
38313
- st = statSync13(absolutePath);
38981
+ st = statSync15(absolutePath);
38314
38982
  } catch (err) {
38315
38983
  if (err?.code === "ENOENT")
38316
38984
  return;
@@ -38341,7 +39009,7 @@ function makeWatchHandler(deps) {
38341
39009
  spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
38342
39010
  return;
38343
39011
  }
38344
- const bytes = readFileSync21(absolutePath);
39012
+ const bytes = readFileSync23(absolutePath);
38345
39013
  const hash2 = sha256Bytes(bytes);
38346
39014
  if (suppressor?.shouldSuppressUpsert(absolutePath, {
38347
39015
  bodyHash: hash2,
@@ -38399,7 +39067,7 @@ function makeWatchHandler(deps) {
38399
39067
  if (suppressor) {
38400
39068
  let raw = null;
38401
39069
  try {
38402
- raw = readFileSync21(absolutePath, "utf8");
39070
+ raw = readFileSync23(absolutePath, "utf8");
38403
39071
  } catch {
38404
39072
  raw = null;
38405
39073
  }
@@ -38437,7 +39105,7 @@ function makeWatchHandler(deps) {
38437
39105
  if (suppressor) {
38438
39106
  let raw = null;
38439
39107
  try {
38440
- raw = readFileSync21(absolutePath, "utf8");
39108
+ raw = readFileSync23(absolutePath, "utf8");
38441
39109
  } catch {
38442
39110
  raw = null;
38443
39111
  }
@@ -38475,7 +39143,7 @@ function makeWatchHandler(deps) {
38475
39143
  if (suppressor) {
38476
39144
  let raw = null;
38477
39145
  try {
38478
- raw = readFileSync21(absolutePath, "utf8");
39146
+ raw = readFileSync23(absolutePath, "utf8");
38479
39147
  } catch {
38480
39148
  raw = null;
38481
39149
  }
@@ -38513,7 +39181,7 @@ function makeWatchHandler(deps) {
38513
39181
  if (suppressor) {
38514
39182
  let raw = null;
38515
39183
  try {
38516
- raw = readFileSync21(absolutePath, "utf8");
39184
+ raw = readFileSync23(absolutePath, "utf8");
38517
39185
  } catch {
38518
39186
  raw = null;
38519
39187
  }
@@ -38551,7 +39219,7 @@ function makeWatchHandler(deps) {
38551
39219
  if (suppressor) {
38552
39220
  let raw = null;
38553
39221
  try {
38554
- raw = readFileSync21(absolutePath, "utf8");
39222
+ raw = readFileSync23(absolutePath, "utf8");
38555
39223
  } catch {
38556
39224
  raw = null;
38557
39225
  }
@@ -38660,7 +39328,7 @@ function makeWatchHandler(deps) {
38660
39328
  if (strategy) {
38661
39329
  let raw = null;
38662
39330
  try {
38663
- raw = readFileSync21(absolutePath, "utf8");
39331
+ raw = readFileSync23(absolutePath, "utf8");
38664
39332
  } catch {
38665
39333
  raw = null;
38666
39334
  }
@@ -38903,6 +39571,13 @@ async function runStart(workspaceSlug, deps = {}) {
38903
39571
  });
38904
39572
  if (recon.quarantined > 0)
38905
39573
  refreshFrozen();
39574
+ const idBackfill = backfillFrontmatterIds({
39575
+ workspaceSlug: slug,
39576
+ state: loadState(slug)
39577
+ });
39578
+ if (backfillWorthReporting(idBackfill)) {
39579
+ daemonLog.log(frontmatterIdBackfillLine(idBackfill));
39580
+ }
38906
39581
  if (recon.diverged > 0 || recon.quarantined > 0) {
38907
39582
  const parts = [];
38908
39583
  if (recon.pushed > 0)
@@ -38918,6 +39593,33 @@ async function runStart(workspaceSlug, deps = {}) {
38918
39593
  const acted = recon.diverged + recon.quarantined;
38919
39594
  out.info(`Reconciled ${acted} offline edit${acted === 1 ? "" : "s"}${parts.length ? `: ${parts.join(", ")}` : ""}.`);
38920
39595
  }
39596
+ const disc = recon.discovery;
39597
+ if (disc.created > 0 || disc.renamed > 0 || disc.collisions > 0 || disc.renameConflicts > 0) {
39598
+ const parts = [];
39599
+ if (disc.created > 0)
39600
+ parts.push(`${disc.created} synced`);
39601
+ if (disc.renamed > 0)
39602
+ parts.push(`${disc.renamed} renamed in Plinth`);
39603
+ if (disc.collisions > 0) {
39604
+ parts.push(`${disc.collisions} already in Plinth, local copy stashed to ~/.plinth/_conflicts/`);
39605
+ }
39606
+ if (disc.renameConflicts > 0) {
39607
+ parts.push(`${disc.renameConflicts} changed in Plinth first, local copy stashed to ~/.plinth/_conflicts/`);
39608
+ }
39609
+ out.info(`Caught up on files changed while Plinth was closed: ${parts.join(", ")}.`);
39610
+ }
39611
+ if (disc.heldCap > 0) {
39612
+ out.info(`Found ${disc.heldCap} new file${disc.heldCap === 1 ? "" : "s"} in this ` + `workspace — more than Plinth adds on its own (limit ${discoveryWriteCap()}), ` + `so none were added. Run \`plinth status\` to see them.`);
39613
+ }
39614
+ if (disc.heldArtist > 0) {
39615
+ out.info(`${disc.heldArtist} artist folder${disc.heldArtist === 1 ? "" : "s"} ` + `${disc.heldArtist === 1 ? "has" : "have"} no artist in Plinth. Plinth ` + `never adds an artist on its own — see \`plinth status\`.`);
39616
+ }
39617
+ if (disc.refusedRename > 0) {
39618
+ out.info(`${disc.refusedRename} renamed file${disc.refusedRename === 1 ? "" : "s"} ` + `${disc.refusedRename === 1 ? "has a name" : "have names"} already taken in ` + `Plinth, so ${disc.refusedRename === 1 ? "it was" : "they were"} left alone — ` + `see \`plinth status\`.`);
39619
+ }
39620
+ if (disc.heldCloudRow > 0) {
39621
+ out.info(`${disc.heldCloudRow} file${disc.heldCloudRow === 1 ? "" : "s"} came from ` + `Plinth but this copy has lost track of ${disc.heldCloudRow === 1 ? "it" : "them"}. ` + `Nothing is lost — see \`plinth status\`.`);
39622
+ }
38921
39623
  const reconFiles = await reconcileFilesSubtree({
38922
39624
  workspaceSlug: slug,
38923
39625
  baseUrl: entry2.api_base_url,
@@ -39056,6 +39758,7 @@ async function runStart(workspaceSlug, deps = {}) {
39056
39758
  const msg = err instanceof Error ? err.message : "unknown error";
39057
39759
  out.warn(`Artist memory reconcile skipped: ${msg}`);
39058
39760
  }
39761
+ const loggedTombstoneSkips = new Set;
39059
39762
  const initial = await runSync(workspaceSlug, {
39060
39763
  fetchImpl,
39061
39764
  keychain,
@@ -39064,7 +39767,8 @@ async function runStart(workspaceSlug, deps = {}) {
39064
39767
  now: deps.now,
39065
39768
  logSink: daemonLog,
39066
39769
  isFrozen,
39067
- isFileFrozen
39770
+ isFileFrozen,
39771
+ loggedTombstoneSkips
39068
39772
  });
39069
39773
  if (initial.exitCode !== 0)
39070
39774
  return initial;
@@ -39293,7 +39997,8 @@ async function runStart(workspaceSlug, deps = {}) {
39293
39997
  logSink: daemonLog,
39294
39998
  suppressor,
39295
39999
  isFrozen,
39296
- isFileFrozen
40000
+ isFileFrozen,
40001
+ loggedTombstoneSkips
39297
40002
  });
39298
40003
  if (result.exitCode === 0) {
39299
40004
  const applied = result.applied ?? 0;
@@ -39754,11 +40459,11 @@ var startCommand = defineCommand({
39754
40459
  });
39755
40460
 
39756
40461
  // src/commands/status.ts
39757
- import { statSync as statSync15 } from "node:fs";
40462
+ import { statSync as statSync17 } from "node:fs";
39758
40463
 
39759
40464
  // src/lib/build-stamp.ts
39760
40465
  import { execFileSync as execFileSync4 } from "node:child_process";
39761
- import { existsSync as existsSync8, mkdirSync as mkdirSync11, readFileSync as readFileSync22, writeFileSync as writeFileSync8 } from "node:fs";
40466
+ import { existsSync as existsSync8, mkdirSync as mkdirSync11, readFileSync as readFileSync24, writeFileSync as writeFileSync8 } from "node:fs";
39762
40467
  import { dirname as dirname10, join as join17 } from "node:path";
39763
40468
  import { fileURLToPath } from "node:url";
39764
40469
  var BUILD_STAMP_FILENAME = "build-stamp.json";
@@ -39782,7 +40487,7 @@ function readHead(repoDir) {
39782
40487
  }
39783
40488
  function readBuildStamp(distDir) {
39784
40489
  try {
39785
- const parsed = JSON.parse(readFileSync22(stampPath(distDir), "utf8"));
40490
+ const parsed = JSON.parse(readFileSync24(stampPath(distDir), "utf8"));
39786
40491
  if (parsed && typeof parsed.sha === "string" && parsed.sha.length > 0 && typeof parsed.dirty === "boolean" && typeof parsed.built_at === "string") {
39787
40492
  return { sha: parsed.sha, dirty: parsed.dirty, built_at: parsed.built_at };
39788
40493
  }
@@ -39846,9 +40551,10 @@ function checkBuildStaleness(deps = {}) {
39846
40551
  init_output();
39847
40552
 
39848
40553
  // src/lib/pending-writes.ts
39849
- import { readFileSync as readFileSync23, statSync as statSync14 } from "node:fs";
40554
+ import { readFileSync as readFileSync25, statSync as statSync16 } from "node:fs";
39850
40555
  import { relative as relative13 } from "node:path";
39851
- var EMPTY = { writes: [], count: 0 };
40556
+ var import_gray_matter13 = __toESM(require_gray_matter(), 1);
40557
+ var EMPTY2 = { writes: [], count: 0 };
39852
40558
  function reportedElsewhere(state) {
39853
40559
  const entities = new Set;
39854
40560
  const files = new Set;
@@ -39866,13 +40572,14 @@ function scanPendingWrites(args) {
39866
40572
  try {
39867
40573
  mirrorRootPath = workspaceMirrorPath(workspaceSlug);
39868
40574
  } catch {
39869
- return EMPTY;
40575
+ return EMPTY2;
39870
40576
  }
39871
40577
  const ignored = watcherIgnoreMatcher(mirrorRootPath);
39872
40578
  const reported = reportedElsewhere(state);
39873
40579
  const frozenEntities = computeFrozen(workspaceSlug);
39874
40580
  const frozenFiles = computeFrozenFiles(workspaceSlug);
39875
40581
  const writes = [];
40582
+ const discovered = [];
39876
40583
  for (const absolutePath of walkSubtreeFiles(mirrorRootPath, ignored)) {
39877
40584
  const relPath = relative13(mirrorRootPath, absolutePath);
39878
40585
  const fileMatch = classifyFilesPath({ workspaceSlug, absolutePath });
@@ -39884,7 +40591,7 @@ function scanPendingWrites(args) {
39884
40591
  const entry3 = getFileEntry(state, fileMatch.relPath);
39885
40592
  let size;
39886
40593
  try {
39887
- size = statSync14(absolutePath).size;
40594
+ size = statSync16(absolutePath).size;
39888
40595
  } catch {
39889
40596
  continue;
39890
40597
  }
@@ -39892,7 +40599,7 @@ function scanPendingWrites(args) {
39892
40599
  continue;
39893
40600
  let hash2;
39894
40601
  try {
39895
- hash2 = sha256Bytes(readFileSync23(absolutePath));
40602
+ hash2 = sha256Bytes(readFileSync25(absolutePath));
39896
40603
  } catch {
39897
40604
  continue;
39898
40605
  }
@@ -39913,21 +40620,42 @@ function scanPendingWrites(args) {
39913
40620
  continue;
39914
40621
  let liveInode;
39915
40622
  try {
39916
- liveInode = statSync14(absolutePath).ino;
40623
+ liveInode = statSync16(absolutePath).ino;
39917
40624
  } catch {
39918
40625
  liveInode = undefined;
39919
40626
  }
39920
- const entry2 = (liveInode !== undefined ? getEntryByInode(state, entityType, liveInode) : undefined) ?? getEntryBySlug(state, entityType, entitySlug);
40627
+ const byInode = liveInode !== undefined ? getEntryByInode(state, entityType, liveInode) : undefined;
40628
+ const entry2 = byInode ?? getEntryBySlug(state, entityType, entitySlug);
39921
40629
  if (entry2 && frozenEntities.has(`${entityType}:${entry2.id}`))
39922
40630
  continue;
39923
40631
  let content;
39924
40632
  try {
39925
- content = readFileSync23(absolutePath, "utf8");
40633
+ content = readFileSync25(absolutePath, "utf8");
39926
40634
  } catch {
39927
40635
  continue;
39928
40636
  }
39929
40637
  if (!entry2) {
39930
- writes.push({ path: relPath, kind: "new", clears: "resave" });
40638
+ let frontmatter;
40639
+ try {
40640
+ frontmatter = import_gray_matter13.default(content).data ?? {};
40641
+ } catch {
40642
+ continue;
40643
+ }
40644
+ const verdict = classifyUntrackedEntity({
40645
+ entityType,
40646
+ entitySlug,
40647
+ frontmatter
40648
+ });
40649
+ if (verdict.kind === "hold") {
40650
+ writes.push({
40651
+ path: relPath,
40652
+ kind: "new",
40653
+ clears: "resave",
40654
+ held: verdict.held
40655
+ });
40656
+ } else {
40657
+ discovered.push({ path: relPath, kind: "new" });
40658
+ }
39931
40659
  continue;
39932
40660
  }
39933
40661
  let hashes;
@@ -39936,10 +40664,38 @@ function scanPendingWrites(args) {
39936
40664
  } catch {
39937
40665
  continue;
39938
40666
  }
39939
- if (hashes.bodyHash !== entry2.body_hash || hashes.frontmatterHash !== entry2.frontmatter_hash) {
40667
+ const edited = hashes.bodyHash !== entry2.body_hash || hashes.frontmatterHash !== entry2.frontmatter_hash;
40668
+ if (byInode && byInode.slug !== entitySlug) {
40669
+ const destination = classifyRenameDestination({
40670
+ state,
40671
+ entityType,
40672
+ entitySlug,
40673
+ movedEntryId: byInode.id
40674
+ });
40675
+ if (destination.kind === "hold") {
40676
+ writes.push({
40677
+ path: relPath,
40678
+ kind: edited ? "edited" : "renamed",
40679
+ clears: "resave",
40680
+ held: destination.held
40681
+ });
40682
+ } else {
40683
+ writes.push({
40684
+ path: relPath,
40685
+ kind: edited ? "edited" : "renamed",
40686
+ clears: "boot"
40687
+ });
40688
+ }
40689
+ continue;
40690
+ }
40691
+ if (edited) {
39940
40692
  writes.push({ path: relPath, kind: "edited", clears: "boot" });
39941
40693
  }
39942
40694
  }
40695
+ const overCap = discovered.length > discoveryWriteCap();
40696
+ for (const d of discovered) {
40697
+ writes.push(overCap ? { path: d.path, kind: d.kind, clears: "resave", held: "cap" } : { path: d.path, kind: d.kind, clears: "boot" });
40698
+ }
39943
40699
  for (const lane of [
39944
40700
  {
39945
40701
  filename: WORKSPACE_RULES_FILENAME,
@@ -40016,6 +40772,15 @@ function row(label, value) {
40016
40772
  function cont(value) {
40017
40773
  return `${" ".repeat(LABEL_WIDTH)} ${value}`;
40018
40774
  }
40775
+ function uncheckedLines(items) {
40776
+ const n = items.length;
40777
+ const head = `${n} generated command file${n === 1 ? "" : "s"} could not be checked, ` + `so ${n === 1 ? "its block was" : "their blocks were"} not compared.`;
40778
+ const why = {
40779
+ unreadable: "could not be opened; check its permissions and that it is a file",
40780
+ "no-markers": "its BEGIN/END markers are gone; the next sync backs your copy up and restores them"
40781
+ };
40782
+ return [head, ...items.map((i) => `${i.commandRel} — ${why[i.reason]}`)];
40783
+ }
40019
40784
  function formatRelative(deltaMs) {
40020
40785
  const s = Math.floor(Math.max(0, deltaMs) / 1000);
40021
40786
  if (s < 1)
@@ -40055,7 +40820,7 @@ function renderBuild(out, staleness) {
40055
40820
  }
40056
40821
  function lastActivity(now) {
40057
40822
  try {
40058
- const mtimeMs = statSync15(daemonLogPath()).mtimeMs;
40823
+ const mtimeMs = statSync17(daemonLogPath()).mtimeMs;
40059
40824
  return formatRelative(now.getTime() - mtimeMs);
40060
40825
  } catch {
40061
40826
  return null;
@@ -40161,7 +40926,10 @@ async function runStatus(args, deps = {}) {
40161
40926
  out.info(row("Unsynced", "All local writes synced."));
40162
40927
  } else {
40163
40928
  const bootable = pending.writes.filter((w) => w.clears === "boot");
40164
- const resavable = pending.writes.filter((w) => w.clears === "resave");
40929
+ const heldByCap = pending.writes.filter((w) => w.held === "cap");
40930
+ const heldArtists = pending.writes.filter((w) => w.held === "artist");
40931
+ const heldCloudRows = pending.writes.filter((w) => w.held === "cloud-row");
40932
+ const heldRenames = pending.writes.filter((w) => w.held === "rename-taken");
40165
40933
  const MAX_SHOWN = 10;
40166
40934
  const listPaths = (writes) => {
40167
40935
  for (const w of writes.slice(0, MAX_SHOWN)) {
@@ -40182,10 +40950,25 @@ async function runStatus(args, deps = {}) {
40182
40950
  listPaths(bootable);
40183
40951
  out.info(cont(pushing ? "A file you just saved clears within seconds. If something " + "stays listed, save it again or restart the daemon." : "Start the daemon so it can push them — the Plinth desktop app " + "runs it for you; in a terminal, `plinth start`."));
40184
40952
  }
40185
- if (resavable.length > 0) {
40186
- head(`${resavable.length} file${resavable.length === 1 ? "" : "s"} ` + `Plinth has never seen.`);
40187
- listPaths(resavable);
40188
- out.info(cont("New files are only picked up when they change. With the daemon " + "running, open and save each one and it will sync."));
40953
+ if (heldByCap.length > 0) {
40954
+ head(`${heldByCap.length} file${heldByCap.length === 1 ? "" : "s"} ` + `Plinth has never seen — more than it adds unattended in one go.`);
40955
+ listPaths(heldByCap);
40956
+ out.info(cont(`Plinth adds at most ${discoveryWriteCap()} new files per start. ` + "With the daemon running, open and save the ones you want now — or " + "set PLINTH_DISCOVERY_WRITE_CAP higher and restart it."));
40957
+ }
40958
+ if (heldArtists.length > 0) {
40959
+ head(`${heldArtists.length} artist file${heldArtists.length === 1 ? "" : "s"} ` + `with no artist in Plinth.`);
40960
+ listPaths(heldArtists);
40961
+ out.info(cont("Plinth never adds an artist on its own. Create the artist in Plinth, " + "or open and save this file with the daemon running to add them."));
40962
+ }
40963
+ if (heldRenames.length > 0) {
40964
+ head(`${heldRenames.length} renamed file${heldRenames.length === 1 ? "" : "s"} ` + `whose new name is already taken in Plinth.`);
40965
+ listPaths(heldRenames);
40966
+ out.info(cont("Two files cannot share a name in Plinth. Rename this one to something " + "free and it will sync."));
40967
+ }
40968
+ if (heldCloudRows.length > 0) {
40969
+ head(`${heldCloudRows.length} file${heldCloudRows.length === 1 ? "" : "s"} ` + `that came from Plinth but this copy has lost track of.`);
40970
+ listPaths(heldCloudRows);
40971
+ out.info(cont("If this copy simply lost track of them, re-sync with `plinth login " + `--workspace ${slug} --reset-sync\`. If you made this file by ` + "duplicating another, delete its `revision:` line and Plinth will " + "add it as new."));
40189
40972
  }
40190
40973
  if (unsynced > 0) {
40191
40974
  head(`${unsynced} write${unsynced === 1 ? "" : "s"} awaiting retry (rate-limited or offline).`);
@@ -40220,6 +41003,33 @@ async function runStatus(args, deps = {}) {
40220
41003
  out.info(cont(`Full list with restore commands: ${CONFLICTS_VIEW_REL_PATH} (written by the sync, so this line is the fresher of the two)`));
40221
41004
  }
40222
41005
  out.info("");
41006
+ const commandScan = scanStaleUserBlocks({ workspaceSlug: slug });
41007
+ const unchecked = commandScan.unchecked;
41008
+ if (commandScan.stale.length > 0) {
41009
+ const n = commandScan.stale.length;
41010
+ out.info(row("Commands", n === 1 ? `1 customisable block names machinery ${commandScan.stale[0].invocation} no longer uses.` : `${n} customisable blocks name machinery their commands no longer use.`));
41011
+ for (const f of commandScan.stale) {
41012
+ const versions2 = new Set(f.tokens.map((t) => t.version));
41013
+ const names = f.tokens.map((t) => versions2.size === 1 ? `\`${t.token}\`` : `\`${t.token}\` (${t.version})`);
41014
+ const joined = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
41015
+ const inVersion = versions2.size === 1 ? ` in ${f.tokens[0].version}` : "";
41016
+ out.info(cont(`${f.commandRel} names ${joined}, retired from ${f.invocation}` + `${inVersion}. Review it.`));
41017
+ }
41018
+ if (unchecked.length > 0) {
41019
+ for (const line of uncheckedLines(unchecked))
41020
+ out.info(cont(line));
41021
+ }
41022
+ } else if (unchecked.length > 0) {
41023
+ const [head, ...rest] = uncheckedLines(unchecked);
41024
+ out.info(row("Commands", head));
41025
+ for (const line of rest)
41026
+ out.info(cont(line));
41027
+ } else if (commandScan.checked === 0) {
41028
+ out.info(row("Commands", "No generated commands yet. They arrive on the next sync."));
41029
+ } else {
41030
+ out.info(row("Commands", "Your customisable blocks match the current commands."));
41031
+ }
41032
+ out.info("");
40223
41033
  const voiceRecord = localState.user_voice;
40224
41034
  let voiceRaw = null;
40225
41035
  try {
@@ -40241,6 +41051,59 @@ async function runStatus(args, deps = {}) {
40241
41051
  } else {
40242
41052
  out.info(row("Voice", `${VOICE_FILENAME} is present; Plinth has not checked it yet.`));
40243
41053
  }
41054
+ out.info("");
41055
+ let report = null;
41056
+ try {
41057
+ report = runGroundingReport({ workspaceSlug: slug });
41058
+ } catch {
41059
+ report = null;
41060
+ }
41061
+ if (report === null) {
41062
+ out.info(row("Injection", "Could not render the session grounding to measure it."));
41063
+ } else {
41064
+ const injected = report.block;
41065
+ const bytes = Buffer.byteLength(injected);
41066
+ const overBudget = bytes > GROUNDING_BUDGET_BYTES;
41067
+ out.info(row("Injection", `${bytes.toLocaleString("en-GB")} bytes at every session start` + (overBudget ? "" : ` (budget ${GROUNDING_BUDGET_BYTES.toLocaleString("en-GB")})`)));
41068
+ const contributors = [];
41069
+ const rides = (name, reduce) => {
41070
+ let raw = null;
41071
+ try {
41072
+ raw = readContextFile(slug, name);
41073
+ } catch {
41074
+ raw = null;
41075
+ }
41076
+ const body = reduce(raw);
41077
+ if (body !== null)
41078
+ contributors.push({ name, bytes: Buffer.byteLength(body) });
41079
+ };
41080
+ rides(WORKSPACE_MEMORY_FILENAME, extractWorkspaceMemory);
41081
+ rides(USER_MEMORY_FILENAME, extractUserMemory);
41082
+ contributors.sort((a, b) => b.bytes - a.bytes);
41083
+ const largest = contributors[0];
41084
+ const indexes = [
41085
+ { name: WORKSPACE_MEMORY_FILENAME, pick: report.picks.wmPick },
41086
+ { name: USER_MEMORY_FILENAME, pick: report.picks.umPick }
41087
+ ].flatMap((i) => i.pick === null ? [] : [{ name: i.name, ...i.pick }]);
41088
+ const dropped = indexes.filter((i) => i.kept === 0 && i.total > 0);
41089
+ const partial2 = indexes.filter((i) => i.kept > 0 && i.kept < i.total);
41090
+ if (overBudget) {
41091
+ out.info(cont(`Over the ${GROUNDING_BUDGET_BYTES.toLocaleString("en-GB")}-byte budget.` + (indexes.length > 0 ? " Your memory indexes are already trimmed as far as they go, so the rest is Plinth's own grounding." : " There is no memory index to trim, so all of it is Plinth's own grounding and this workspace's rules.") + " Report it."));
41092
+ } else if (dropped.length > 0 || partial2.length > 0) {
41093
+ for (const i of dropped) {
41094
+ out.info(cont(`NONE of ${i.name} fits in the injection: all ${i.total} facts are read from the file on demand, not recalled.`));
41095
+ }
41096
+ for (const i of partial2) {
41097
+ out.info(cont(`Only ${i.kept} of ${i.total} facts in ${i.name} fit in the injection. The rest stay in the file and are read on demand.`));
41098
+ }
41099
+ if (largest !== undefined) {
41100
+ out.info(cont(`${largest.name} is the largest of these at ${largest.bytes.toLocaleString("en-GB")} bytes. Shorter lines fit more facts: the convention is one line per fact, a hook and a [[pointer]], not a paragraph.`));
41101
+ }
41102
+ } else {
41103
+ out.info(cont("Every memory fact fits; nothing is being trimmed."));
41104
+ }
41105
+ }
41106
+ out.info("");
40244
41107
  return { exitCode: 0 };
40245
41108
  }
40246
41109
  var statusCommand = defineCommand({
@@ -40280,7 +41143,7 @@ var versionCommand = defineCommand({
40280
41143
  });
40281
41144
 
40282
41145
  // src/commands/voice-gate.ts
40283
- import { readFileSync as readFileSync26 } from "node:fs";
41146
+ import { readFileSync as readFileSync28 } from "node:fs";
40284
41147
 
40285
41148
  // src/lib/voice-gate/api.ts
40286
41149
  var VoiceGateSurfaces = [
@@ -40385,7 +41248,7 @@ async function callVoiceGate(req) {
40385
41248
  import { createHash as createHash4 } from "node:crypto";
40386
41249
  import {
40387
41250
  mkdirSync as mkdirSync12,
40388
- readFileSync as readFileSync24,
41251
+ readFileSync as readFileSync26,
40389
41252
  renameSync as renameSync8,
40390
41253
  unlinkSync as unlinkSync8,
40391
41254
  writeFileSync as writeFileSync9
@@ -40416,7 +41279,7 @@ function emptyFile() {
40416
41279
  }
40417
41280
  function load2(workspaceSlug) {
40418
41281
  try {
40419
- const raw = readFileSync24(attemptsPath(workspaceSlug), "utf8");
41282
+ const raw = readFileSync26(attemptsPath(workspaceSlug), "utf8");
40420
41283
  const parsed = AttemptsFileSchema.safeParse(JSON.parse(raw));
40421
41284
  return parsed.success ? parsed.data : emptyFile();
40422
41285
  } catch {
@@ -40579,7 +41442,7 @@ function denyUngated(detail) {
40579
41442
  }
40580
41443
 
40581
41444
  // src/lib/voice-gate/payload.ts
40582
- import { readFileSync as readFileSync25, statSync as statSync16 } from "node:fs";
41445
+ import { readFileSync as readFileSync27, statSync as statSync18 } from "node:fs";
40583
41446
  import { homedir as homedir2 } from "node:os";
40584
41447
  import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
40585
41448
  var MAX_BODY_BYTES = 256 * 1024;
@@ -40736,11 +41599,11 @@ function isHelperInvocation(tokens) {
40736
41599
  return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
40737
41600
  }
40738
41601
  function defaultReadBodyFile(path5) {
40739
- const stat4 = statSync16(path5);
41602
+ const stat4 = statSync18(path5);
40740
41603
  if (stat4.size > MAX_BODY_BYTES) {
40741
41604
  throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
40742
41605
  }
40743
- return readFileSync25(path5, "utf8");
41606
+ return readFileSync27(path5, "utf8");
40744
41607
  }
40745
41608
  function parseRecipients(...raw) {
40746
41609
  const all = raw.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
@@ -41100,7 +41963,7 @@ async function runVoiceGateDirect(args, deps = {}) {
41100
41963
  let text;
41101
41964
  if (args.file) {
41102
41965
  try {
41103
- text = readFileSync26(args.file, "utf8");
41966
+ text = readFileSync28(args.file, "utf8");
41104
41967
  } catch (err) {
41105
41968
  const msg = err instanceof Error ? err.message : "unknown error";
41106
41969
  out.error(`Could not read ${args.file}: ${msg}`);