@plinth-music/cli 0.10.0 → 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.0",
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: {
@@ -27878,7 +28011,7 @@ function buildBody(input) {
27878
28011
  lines.push(`- Closed/archived, meetings & full lists: \`mcp__plinth__list_projects\` / \`mcp__plinth__list_tasks\`${artistIdHint(uuid3)}`);
27879
28012
  lines.push(`- Schema: ${MCP_INTROSPECTION_POINTER}`);
27880
28013
  lines.push(`- Long-term facts → \`./memory.md\`, which syncs to this artist's Plinth record. Most artists have none yet: the file appears once the record carries memory, and creating it here is how you start one. One line per fact with its source and an absolute date; when a fact goes wrong, edit that line rather than adding a second`);
27881
- lines.push(`- Rolling day-to-day activity → \`./daily-log.md\`, local to this machine and never synced. Durable facts do not belong here; they belong in \`./memory.md\` above`);
28014
+ lines.push(`- What a session did → a dated section under \`## Session log\` at the bottom of \`./_artist.md\` but ABOVE any \`<!-- BEGIN: contacts -->\` block, which is rendered from Plinth and overwrites anything inside it on the next sync. That body syncs, so a teammate opens the record in the app. Keep the five most recent; a fact that outlives them belongs in \`./memory.md\` above. \`./daily-log.md\` is local scratch, never synced; an older one may hold real history, and nothing writes there now`);
27882
28015
  lines.push("");
27883
28016
  lines.push(USER_BLOCK_BEGIN);
27884
28017
  lines.push(USER_BLOCK_PLACEHOLDER);
@@ -28479,8 +28612,8 @@ function ensureStub(workspaceSlug, relativePath, content) {
28479
28612
  function dailyLogStub(name) {
28480
28613
  return `# ${name} - Daily log
28481
28614
 
28482
- ` + `<!-- Rolling activity, newest first; edit freely, Plinth never overwrites this file. -->
28483
- ` + `<!-- Durable facts do not belong here - they go in ./memory.md, which syncs to this artist's Plinth record. -->
28615
+ ` + `<!-- Your own scratch notes; edit freely, Plinth never overwrites this file. -->
28616
+ ` + `<!-- Nothing writes here now: a session's record goes under ## Session log in ./_artist.md, and durable facts in ./memory.md. Both sync; this file does not. -->
28484
28617
  `;
28485
28618
  }
28486
28619
  function backupStamp(d) {
@@ -28572,7 +28705,7 @@ function buildStartCommandMd(rosterIsEmpty2) {
28572
28705
  "",
28573
28706
  "## Loading area context",
28574
28707
  "",
28575
- "Once the user names something, resolve it against the workspace before answering: search the Plinth MCP, or read the roster in `CLAUDE.md`. **Never answer from name recognition alone.** Then read the surfaces that carry it. An artist's master context is `artists/<slug>/_artist.md`, their durable facts are `artists/<slug>/memory.md`, their recent activity is `artists/<slug>/daily-log.md`. Brief on what is live, not on what you opened.",
28708
+ "Once the user names something, resolve it against the workspace before answering: search the Plinth MCP, or read the roster in `CLAUDE.md`. **Never answer from name recognition alone.** Then read the surfaces that carry it. An artist's master context is `artists/<slug>/_artist.md`, whose `## Session log` carries what recent sessions did; their durable facts are `artists/<slug>/memory.md`. Older local history may sit in `artists/<slug>/daily-log.md`, which nothing writes to any more. Brief on what is live, not on what you opened.",
28576
28709
  "",
28577
28710
  "If nothing resolves, say so plainly and ask. Do not invent an entity.",
28578
28711
  "",
@@ -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
  "",
@@ -28628,11 +28761,27 @@ function buildCloseCommandMd(workspaceSlug) {
28628
28761
  "",
28629
28762
  "- **Something to do** goes to a Plinth task, scoped to its artist or project, with an owner and a real due date where one exists. Fold it into the existing umbrella task rather than adding a second.",
28630
28763
  "- **State or context that changed** goes to the artist's master context, the body of `artists/<slug>/_artist.md`, or to the project's summary. Prose goes through the mirror file.",
28631
- "- **What happened today** goes to `artists/<slug>/daily-log.md`, a dated entry per artist touched. Local-only and rolling, and never the place for a durable fact.",
28764
+ "- **What happened this session** goes to the artist's own record: a dated section in the body of `artists/<slug>/_artist.md`, one per artist touched. Prose, the way a hub carries it - what was discussed, what was decided, what was done, what is still open. **This is the half a teammate can open.** That body syncs to the artist's Plinth record and the app renders it in the Knowledge Base context pane, so a session banked here is one somebody else can pick up cold. **It is a record of a session, not a statement of where things stand**: current state belongs in the prose above it, edited in place, which is the bullet before this one. A log that accumulates live state rots, and that is the failure this step was moved to escape.",
28765
+ "",
28766
+ "**Where the record goes in the file, and it is not the top.** One `## Session log` heading at the **bottom** of the body, newest section first beneath it. The pane renders the whole body, so the master context has to read first and the log sits under it. If the file carries a `<!-- BEGIN: contacts -->` block, the log goes **above** that marker: everything between the contacts markers is rendered from Plinth and is overwritten on the next sync.",
28767
+ "",
28768
+ "```md",
28769
+ "## Session log",
28770
+ "",
28771
+ "### 2026-03-14",
28772
+ "",
28773
+ "<what was discussed, what was decided, what was done, what is still open>",
28774
+ "```",
28775
+ "",
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.",
28777
+ "",
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.",
28779
+ "",
28780
+ "**A session that touched no artist writes one dated note under `documents/` instead**, with the same content. That is a new file rather than an edit to a tracked one, so it reaches the cloud only with the sync running - Step 2 is where you already saw whether it is.",
28632
28781
  "",
28633
28782
  "## Step 4: bank what was learned, by type first and scope second",
28634
28783
  "",
28635
- "**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.",
28636
28785
  "",
28637
28786
  "### Step 4a: type first. A fact with a typed home never becomes prose.",
28638
28787
  "",
@@ -28640,7 +28789,7 @@ function buildCloseCommandMd(workspaceSlug) {
28640
28789
  "",
28641
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`.",
28642
28791
  "- **Something to do** is a task, and Step 3 has already routed it.",
28643
- "- **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.",
28644
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`.",
28645
28794
  "- **A contract, statement or attachment** is a file under `files/`.",
28646
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.",
@@ -28649,19 +28798,19 @@ function buildCloseCommandMd(workspaceSlug) {
28649
28798
  "",
28650
28799
  "Whatever is left has no typed surface anywhere. That is what memory is for.",
28651
28800
  "",
28652
- "### Step 4b: three piles, and the artist pile comes first",
28801
+ "### Step 4b: two piles, and the artist pile comes first",
28653
28802
  "",
28654
- "**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.",
28655
28804
  "",
28656
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.",
28657
28806
  "",
28658
- "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.",
28659
28808
  "",
28660
28809
  "#### True of one artist: `artists/<slug>/memory.md`. Write it directly.",
28661
28810
  "",
28662
- "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.**",
28663
28812
  "",
28664
- "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.",
28665
28814
  "",
28666
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.",
28667
28816
  "",
@@ -28673,72 +28822,34 @@ function buildCloseCommandMd(workspaceSlug) {
28673
28822
  "<!-- Durable facts about <Artist> that hold across sessions. One line per fact, with",
28674
28823
  " provenance and an absolute date. SUPERSEDE, do not accumulate - when a fact here",
28675
28824
  " becomes wrong, edit that line in place rather than adding a contradicting one.",
28676
- " Live state does NOT belong here - that goes in `_artist.md` or `daily-log.md`. -->",
28825
+ " Live state does NOT belong here - that goes in the body of `_artist.md`. -->",
28677
28826
  "```",
28678
28827
  "",
28679
- "**Live state does not go here.** This week's open threads, one show's logistics, where a release has got to: that is Step 3's business, and it goes to `artists/<slug>/_artist.md` or `artists/<slug>/daily-log.md`. This file is for what stays true after the session that taught it.",
28828
+ "**Live state does not go here.** This week's open threads, one show's logistics, where a release has got to: that is Step 3's business, and it goes in the body of `artists/<slug>/_artist.md`. This file is for what stays true after the session that taught it.",
28680
28829
  "",
28681
28830
  "#### True of this member: `user-memory.md` at the mirror root. Write it directly.",
28682
28831
  "",
28683
- "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.",
28684
28833
  "",
28685
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.",
28686
28835
  "",
28687
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.",
28688
28837
  "",
28689
- "#### True of the whole workspace: `workspace-memory.md` at the mirror root. Propose. Never write unasked.",
28690
- "",
28691
- "**This is the only gated pile**, and a fact that names a single artist is almost never in it. Draft the candidate lines first: durable facts about this workspace that would change how a future session behaves, not this session's detail.",
28692
- "",
28693
- "**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",
28694
28839
  "",
28695
- "**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.",
28696
- "",
28697
- "Then put the candidates to the user with **`AskUserQuestion`**:",
28698
- "",
28699
- "- **One multi-select question, one option per proposed line.** Selected means bank it. Unselected means rejected.",
28700
- "- **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.",
28701
- '- **The free-text "Other" choice is how the user rewrites a line in their own words.** Their wording wins over yours, always.',
28702
- "- 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.**",
28703
- "",
28704
- "**An accepted line that contradicts one already in the file supersedes it in place.** Edit the wrong line. Never leave two lines disagreeing.",
28705
- "",
28706
- "## Step 5: log every decision, then write the file",
28707
- "",
28708
- "**In that order.** The log is the record of what was decided; the file is the consequence.",
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:",
28709
28841
  "",
28710
28842
  "```sh",
28711
- `plinth memory-gate log ${ws} --proposed="<the line as you put it>" --decision accept`,
28712
- `plinth memory-gate log ${ws} --proposed="<the line as you put it>" --decision reject`,
28713
- `plinth memory-gate log ${ws} --proposed="<the line as you put it>" --decision edit --final="<their rewrite>"`,
28714
- "```",
28715
- "",
28716
- "A close that proposed nothing still logs, because *gated and proposed nothing* has to stay distinguishable from *never gated*:",
28717
- "",
28718
- "```sh",
28719
- `plinth memory-gate log ${ws} --nothing-proposed`,
28843
+ `plinth status ${ws}`,
28720
28844
  "```",
28721
28845
  "",
28722
- `\`plinth memory-gate show ${ws} [--json]\` reads the log back.`,
28723
- "",
28724
- "**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 `--`.",
28725
- "",
28726
- "**`--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.",
28727
- "",
28728
- "**Four rules. Break one and a decision is lost:**",
28846
+ `And \`${CONFLICTS_VIEW_REL_PATH}\` beside it, which carries a restore command per file.`,
28729
28847
  "",
28730
- "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.",
28731
- "2. `--final` is **refused** on `accept` and `reject`. An accept records the proposed line; a reject records none.",
28732
- "3. `--nothing-proposed` **cannot** be combined with `--proposed` or `--decision`.",
28733
- '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.',
28848
+ "**Where those two disagree here, believe the command.** Its Conflicts line is read straight off the folder on disk at the moment you run it. The file is a projection the sync rewrites at the end of a pull, so at this point in a close - seconds after your own write, possibly before the next poll - it can still be showing the list from before. That is the one moment in this ritual where the file is the more likely of the two to be stale, which is the opposite of Step 2.",
28734
28849
  "",
28735
- "**Never write this log by hand.** The CLI owns the format so it can still be read back in a year.",
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.",
28736
28851
  "",
28737
- "Only once the decisions are logged, write the accepted lines into `workspace-memory.md`.",
28738
- "",
28739
- "## Step 6: summarise, then end the session",
28740
- "",
28741
- "**Five to seven lines**, and no file-by-file dump: 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.",
28742
28853
  "",
28743
28854
  "Then, as the **very last step**:",
28744
28855
  "",
@@ -28748,7 +28859,7 @@ function buildCloseCommandMd(workspaceSlug) {
28748
28859
  "",
28749
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.",
28750
28861
  "",
28751
- "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."
28752
28863
  ]);
28753
28864
  }
28754
28865
  function buildOnboardCommandMd(workspaceSlug) {
@@ -28894,12 +29005,16 @@ function ensureWorkspaceCommands(deps) {
28894
29005
  const created = [];
28895
29006
  const refreshed = [];
28896
29007
  const noArtists = (deps.readRosterIsEmpty ?? rosterIsEmpty)(workspaceSlug);
28897
- const targets = [
28898
- [START_COMMAND_REL, buildStartCommandMd(noArtists)],
28899
- [CLOSE_COMMAND_REL, buildCloseCommandMd(workspaceSlug)],
28900
- [ONBOARD_COMMAND_REL, buildOnboardCommandMd(workspaceSlug)]
28901
- ];
28902
- 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
+ }
28903
29018
  const existing = readContextFile(workspaceSlug, rel);
28904
29019
  const decision = decideRegen({ existing, currentVersion: "", generated, force });
28905
29020
  if (decision.action === "backup-regen" && decision.backup !== undefined) {
@@ -28922,6 +29037,44 @@ function ensureWorkspaceCommands(deps) {
28922
29037
  function backupStamp2(d) {
28923
29038
  return d.toISOString().replace(/[-:.]/g, "").replace("Z", "");
28924
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
+ }
28925
29078
 
28926
29079
  // src/lib/context/workspace-claude-md.ts
28927
29080
  var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
@@ -28957,7 +29110,7 @@ function buildWorkspaceClaudeMd(input) {
28957
29110
  ``,
28958
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\`).`,
28959
29112
  ``,
28960
- `> **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.`,
28961
29114
  ``,
28962
29115
  `## Folder map`,
28963
29116
  `- \`artists/<slug>/\` one folder per artist, the working dir for artist-scoped sessions · \`threads/<slug>/_thread.md\` one folder per thread`,
@@ -28970,13 +29123,13 @@ function buildWorkspaceClaudeMd(input) {
28970
29123
  `- Each artist's **master context** (stable profile + current state) → \`artists/<slug>/_artist.md\`; edit its body and the daemon syncs both ways. Blocks labelled "rendered from Plinth" are the exception: they are overwritten on the next sync`,
28971
29124
  `- **Contracts, executed agreements & any PDF** → \`files/\` (search here first; \`documents/\` holds markdown bodies, not attachments)`,
28972
29125
  `- Workspace-wide **notes, briefs, bios & context docs** → \`documents/\` (one markdown file per doc, shared across artists)`,
28973
- `- Per-artist long-term facts → \`artists/<slug>/memory.md\`, which syncs to that artist's Plinth record; most have none yet, and creating the file is how you start one · rolling activity → \`artists/<slug>/daily-log.md\`, local-only and never the place for a durable fact`,
29126
+ `- Per-artist long-term facts → \`artists/<slug>/memory.md\`, which syncs to that artist's Plinth record; most have none yet, and creating the file is how you start one · what a session did → a dated section under \`## Session log\` in \`artists/<slug>/_artist.md\`, which syncs too · \`artists/<slug>/daily-log.md\` is local scratch, never synced; an older one may hold real history, and nothing writes there now`,
28974
29127
  `- ${MCP_AVAILABILITY_POINTER}`,
28975
29128
  ``,
28976
29129
  `## Navigating the corpus`,
28977
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`,
28978
29131
  `- **What does this file link to?** → grep \`[[\` in it`,
28979
- `- **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_*\``,
28980
29133
  ``,
28981
29134
  `## Entity model: ${MCP_INTROSPECTION_POINTER}`,
28982
29135
  ...entityLines,
@@ -29777,8 +29930,8 @@ var sessionCommand = defineCommand({
29777
29930
  });
29778
29931
 
29779
29932
  // src/commands/start.ts
29780
- var import_gray_matter10 = __toESM(require_gray_matter(), 1);
29781
- 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";
29782
29935
 
29783
29936
  // src/lib/daemon-log.ts
29784
29937
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync9, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
@@ -30178,6 +30331,9 @@ function fileFailedLine(f) {
30178
30331
  const serverPart = f.serverMessage ? ` server_msg="${f.serverMessage}"` : "";
30179
30332
  return `[FILE_PUSH_FAILED path=${f.path} status=${f.status} reason=${f.reason}${detailPart}${serverPart}]`;
30180
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
+ }
30181
30337
  function pullNoticeLine(kind) {
30182
30338
  const token = kind === "auth" ? "PULL_PAUSED" : "PULL_DEGRADED";
30183
30339
  return `[${token} reason=${kind}]`;
@@ -31114,6 +31270,7 @@ async function pushFile(args) {
31114
31270
  unlinkContext,
31115
31271
  deps
31116
31272
  } = args;
31273
+ const identity = args.identity ?? "auto";
31117
31274
  const fetchImpl = deps?.fetchImpl;
31118
31275
  const now = deps?.now ?? (() => new Date);
31119
31276
  const mutex = deps?.mutex ?? new PerIdMutex;
@@ -31180,7 +31337,7 @@ async function pushFile(args) {
31180
31337
  parsedSlug = strategy.parseMirrorPath(rel)?.slug ?? null;
31181
31338
  } catch {}
31182
31339
  const slugFromPath = parsedSlug ?? basename4(absolutePath, ".md");
31183
- const slug = slugFromFm ?? slugFromPath;
31340
+ const slug = identity === "path" ? slugFromPath : slugFromFm ?? slugFromPath;
31184
31341
  if (!slug) {
31185
31342
  return {
31186
31343
  outcome: { kind: "ignored", absolutePath, reason: "empty slug" },
@@ -31204,7 +31361,8 @@ async function pushFile(args) {
31204
31361
  }
31205
31362
  }
31206
31363
  const liveInode = safeInode2(absolutePath);
31207
- 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);
31208
31366
  if (!stateEntry) {
31209
31367
  return runPushCreate({
31210
31368
  strategy,
@@ -31220,7 +31378,8 @@ async function pushFile(args) {
31220
31378
  fetchImpl,
31221
31379
  now,
31222
31380
  output,
31223
- quarantinedKeys
31381
+ quarantinedKeys,
31382
+ identity
31224
31383
  });
31225
31384
  }
31226
31385
  return runPushUpsert({
@@ -31773,7 +31932,7 @@ async function runPushCreate(args) {
31773
31932
  const strategy = args.strategy;
31774
31933
  const key = `create:${strategy.type}:${args.slug}`;
31775
31934
  return args.mutex.run(key, async () => {
31776
- const inLock = getEntryBySlug(args.state, strategy.type, args.slug);
31935
+ const inLock = args.identity === "path" ? undefined : getEntryBySlug(args.state, strategy.type, args.slug);
31777
31936
  if (inLock) {
31778
31937
  return runPushUpsert({
31779
31938
  strategy,
@@ -35915,8 +36074,500 @@ class PullSuppressor {
35915
36074
  }
35916
36075
  }
35917
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
+
35918
36569
  // src/lib/reconcile.ts
35919
- import { readFileSync as readFileSync18 } from "node:fs";
36570
+ import { readFileSync as readFileSync20 } from "node:fs";
35920
36571
  async function reconcileDivergedRows(args) {
35921
36572
  const { workspaceSlug, baseUrl, pat, fetchImpl, output, isFrozen } = args;
35922
36573
  const now = args.now ?? (() => new Date);
@@ -36002,7 +36653,7 @@ async function reconcileDivergedRows(args) {
36002
36653
  }
36003
36654
  let content;
36004
36655
  try {
36005
- content = readFileSync18(absolutePath, "utf8");
36656
+ content = readFileSync20(absolutePath, "utf8");
36006
36657
  } catch (err) {
36007
36658
  if (err?.code === "ENOENT")
36008
36659
  continue;
@@ -36064,7 +36715,29 @@ async function reconcileDivergedRows(args) {
36064
36715
  }
36065
36716
  }
36066
36717
  }
36067
- 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
+ };
36068
36741
  }
36069
36742
 
36070
36743
  // src/lib/workspace-rules/sync.ts
@@ -37061,8 +37734,8 @@ function createThrottledFetch(baseFetch, opts = {}) {
37061
37734
  import {
37062
37735
  existsSync as existsSync7,
37063
37736
  readdirSync as readdirSync6,
37064
- readFileSync as readFileSync20,
37065
- statSync as statSync12,
37737
+ readFileSync as readFileSync22,
37738
+ statSync as statSync14,
37066
37739
  unlinkSync as unlinkSync7
37067
37740
  } from "node:fs";
37068
37741
  import { join as join16 } from "node:path";
@@ -37188,7 +37861,7 @@ function refreshConflictsView(workspaceSlug) {
37188
37861
  }
37189
37862
 
37190
37863
  // src/lib/files/pull.ts
37191
- 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";
37192
37865
 
37193
37866
  // src/lib/files/extracted-text.ts
37194
37867
  import { createHash as createHash3 } from "node:crypto";
@@ -37300,6 +37973,7 @@ async function pullFiles(args) {
37300
37973
  output: args.output,
37301
37974
  suppressor: args.suppressor,
37302
37975
  isFileFrozen: args.isFileFrozen,
37976
+ loggedTombstoneSkips: args.loggedTombstoneSkips,
37303
37977
  forceTextRewalk: args.forceTextRewalk
37304
37978
  });
37305
37979
  state = outcome.state;
@@ -37448,6 +38122,26 @@ async function applyFileRow(args) {
37448
38122
  }
37449
38123
  const existing = getFileEntry(state, relPath);
37450
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
+ }
37451
38145
  suppressor?.registerDelete(abs);
37452
38146
  const didUnlink = deleteFilesFile(workspaceSlug, relPath);
37453
38147
  const removedText = safeDeleteExtractedText(workspaceSlug, relPath, output);
@@ -37535,7 +38229,7 @@ async function applyFileRow(args) {
37535
38229
  }
37536
38230
  if (!suppressor && existing && existing.content_hash !== row.content_hash && existsSync6(abs)) {
37537
38231
  try {
37538
- const localBytes = readFileSync19(abs);
38232
+ const localBytes = readFileSync21(abs);
37539
38233
  if (sha256Bytes(localBytes) !== existing.content_hash) {
37540
38234
  const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes: localBytes });
37541
38235
  output?.warn(`Conflict on files/${relPath} → stashed local edit to ${stashPath} (cloud-wins on boot).`);
@@ -37576,7 +38270,7 @@ async function applyFileRow(args) {
37576
38270
  }
37577
38271
  let inode;
37578
38272
  try {
37579
- inode = statSync11(abs).ino;
38273
+ inode = statSync13(abs).ino;
37580
38274
  } catch {
37581
38275
  inode = undefined;
37582
38276
  }
@@ -37684,7 +38378,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
37684
38378
  }
37685
38379
  let liveInode;
37686
38380
  try {
37687
- liveInode = statSync12(target).ino;
38381
+ liveInode = statSync14(target).ino;
37688
38382
  } catch {
37689
38383
  return { didUnlink: false, skippedReason: "absent" };
37690
38384
  }
@@ -37831,6 +38525,7 @@ async function runSync(workspaceSlug, deps = {}) {
37831
38525
  output: out,
37832
38526
  suppressor: deps.suppressor,
37833
38527
  isFileFrozen: deps.isFileFrozen,
38528
+ loggedTombstoneSkips: deps.loggedTombstoneSkips,
37834
38529
  commit: commitState,
37835
38530
  forceTextRewalk: needsDerivedTextRewalk
37836
38531
  });
@@ -38046,7 +38741,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
38046
38741
  if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
38047
38742
  let localRaw = null;
38048
38743
  try {
38049
- localRaw = readFileSync20(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
38744
+ localRaw = readFileSync22(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
38050
38745
  } catch {
38051
38746
  localRaw = null;
38052
38747
  }
@@ -38069,7 +38764,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
38069
38764
  const hashes = computeMirrorHashes(strategy, content);
38070
38765
  let inode;
38071
38766
  try {
38072
- inode = statSync12(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38767
+ inode = statSync14(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
38073
38768
  } catch {
38074
38769
  inode = undefined;
38075
38770
  }
@@ -38138,6 +38833,13 @@ var syncCommand = defineCommand({
38138
38833
  process.exit(1);
38139
38834
  }
38140
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
+ }
38141
38843
  }
38142
38844
  let result;
38143
38845
  try {
@@ -38155,7 +38857,7 @@ var syncCommand = defineCommand({
38155
38857
  // src/commands/start.ts
38156
38858
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
38157
38859
  var PULL_HEARTBEAT_MS = 900000;
38158
- function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync13(p)) {
38860
+ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync15(p)) {
38159
38861
  let changed = false;
38160
38862
  const nextEntries = {};
38161
38863
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -38188,7 +38890,7 @@ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync13(p)
38188
38890
  return { state, changed: false };
38189
38891
  return { state: { ...state, entries: nextEntries }, changed: true };
38190
38892
  }
38191
- function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync21(p, "utf8")) {
38893
+ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync23(p, "utf8")) {
38192
38894
  let changed = false;
38193
38895
  const nextEntries = {};
38194
38896
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -38200,7 +38902,7 @@ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync2
38200
38902
  }
38201
38903
  try {
38202
38904
  const path5 = entityFilePath(workspaceSlug, entityType, entry2.slug);
38203
- const bodyLen = import_gray_matter10.default(readFn(path5)).content.length;
38905
+ const bodyLen = import_gray_matter12.default(readFn(path5)).content.length;
38204
38906
  nextBucket[id] = { ...entry2, body_len: bodyLen };
38205
38907
  changed = true;
38206
38908
  } catch {
@@ -38276,7 +38978,7 @@ function makeWatchHandler(deps) {
38276
38978
  }
38277
38979
  let st;
38278
38980
  try {
38279
- st = statSync13(absolutePath);
38981
+ st = statSync15(absolutePath);
38280
38982
  } catch (err) {
38281
38983
  if (err?.code === "ENOENT")
38282
38984
  return;
@@ -38307,7 +39009,7 @@ function makeWatchHandler(deps) {
38307
39009
  spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
38308
39010
  return;
38309
39011
  }
38310
- const bytes = readFileSync21(absolutePath);
39012
+ const bytes = readFileSync23(absolutePath);
38311
39013
  const hash2 = sha256Bytes(bytes);
38312
39014
  if (suppressor?.shouldSuppressUpsert(absolutePath, {
38313
39015
  bodyHash: hash2,
@@ -38365,7 +39067,7 @@ function makeWatchHandler(deps) {
38365
39067
  if (suppressor) {
38366
39068
  let raw = null;
38367
39069
  try {
38368
- raw = readFileSync21(absolutePath, "utf8");
39070
+ raw = readFileSync23(absolutePath, "utf8");
38369
39071
  } catch {
38370
39072
  raw = null;
38371
39073
  }
@@ -38403,7 +39105,7 @@ function makeWatchHandler(deps) {
38403
39105
  if (suppressor) {
38404
39106
  let raw = null;
38405
39107
  try {
38406
- raw = readFileSync21(absolutePath, "utf8");
39108
+ raw = readFileSync23(absolutePath, "utf8");
38407
39109
  } catch {
38408
39110
  raw = null;
38409
39111
  }
@@ -38441,7 +39143,7 @@ function makeWatchHandler(deps) {
38441
39143
  if (suppressor) {
38442
39144
  let raw = null;
38443
39145
  try {
38444
- raw = readFileSync21(absolutePath, "utf8");
39146
+ raw = readFileSync23(absolutePath, "utf8");
38445
39147
  } catch {
38446
39148
  raw = null;
38447
39149
  }
@@ -38479,7 +39181,7 @@ function makeWatchHandler(deps) {
38479
39181
  if (suppressor) {
38480
39182
  let raw = null;
38481
39183
  try {
38482
- raw = readFileSync21(absolutePath, "utf8");
39184
+ raw = readFileSync23(absolutePath, "utf8");
38483
39185
  } catch {
38484
39186
  raw = null;
38485
39187
  }
@@ -38517,7 +39219,7 @@ function makeWatchHandler(deps) {
38517
39219
  if (suppressor) {
38518
39220
  let raw = null;
38519
39221
  try {
38520
- raw = readFileSync21(absolutePath, "utf8");
39222
+ raw = readFileSync23(absolutePath, "utf8");
38521
39223
  } catch {
38522
39224
  raw = null;
38523
39225
  }
@@ -38626,7 +39328,7 @@ function makeWatchHandler(deps) {
38626
39328
  if (strategy) {
38627
39329
  let raw = null;
38628
39330
  try {
38629
- raw = readFileSync21(absolutePath, "utf8");
39331
+ raw = readFileSync23(absolutePath, "utf8");
38630
39332
  } catch {
38631
39333
  raw = null;
38632
39334
  }
@@ -38869,6 +39571,13 @@ async function runStart(workspaceSlug, deps = {}) {
38869
39571
  });
38870
39572
  if (recon.quarantined > 0)
38871
39573
  refreshFrozen();
39574
+ const idBackfill = backfillFrontmatterIds({
39575
+ workspaceSlug: slug,
39576
+ state: loadState(slug)
39577
+ });
39578
+ if (backfillWorthReporting(idBackfill)) {
39579
+ daemonLog.log(frontmatterIdBackfillLine(idBackfill));
39580
+ }
38872
39581
  if (recon.diverged > 0 || recon.quarantined > 0) {
38873
39582
  const parts = [];
38874
39583
  if (recon.pushed > 0)
@@ -38884,6 +39593,33 @@ async function runStart(workspaceSlug, deps = {}) {
38884
39593
  const acted = recon.diverged + recon.quarantined;
38885
39594
  out.info(`Reconciled ${acted} offline edit${acted === 1 ? "" : "s"}${parts.length ? `: ${parts.join(", ")}` : ""}.`);
38886
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
+ }
38887
39623
  const reconFiles = await reconcileFilesSubtree({
38888
39624
  workspaceSlug: slug,
38889
39625
  baseUrl: entry2.api_base_url,
@@ -39022,6 +39758,7 @@ async function runStart(workspaceSlug, deps = {}) {
39022
39758
  const msg = err instanceof Error ? err.message : "unknown error";
39023
39759
  out.warn(`Artist memory reconcile skipped: ${msg}`);
39024
39760
  }
39761
+ const loggedTombstoneSkips = new Set;
39025
39762
  const initial = await runSync(workspaceSlug, {
39026
39763
  fetchImpl,
39027
39764
  keychain,
@@ -39030,7 +39767,8 @@ async function runStart(workspaceSlug, deps = {}) {
39030
39767
  now: deps.now,
39031
39768
  logSink: daemonLog,
39032
39769
  isFrozen,
39033
- isFileFrozen
39770
+ isFileFrozen,
39771
+ loggedTombstoneSkips
39034
39772
  });
39035
39773
  if (initial.exitCode !== 0)
39036
39774
  return initial;
@@ -39259,7 +39997,8 @@ async function runStart(workspaceSlug, deps = {}) {
39259
39997
  logSink: daemonLog,
39260
39998
  suppressor,
39261
39999
  isFrozen,
39262
- isFileFrozen
40000
+ isFileFrozen,
40001
+ loggedTombstoneSkips
39263
40002
  });
39264
40003
  if (result.exitCode === 0) {
39265
40004
  const applied = result.applied ?? 0;
@@ -39720,11 +40459,11 @@ var startCommand = defineCommand({
39720
40459
  });
39721
40460
 
39722
40461
  // src/commands/status.ts
39723
- import { statSync as statSync15 } from "node:fs";
40462
+ import { statSync as statSync17 } from "node:fs";
39724
40463
 
39725
40464
  // src/lib/build-stamp.ts
39726
40465
  import { execFileSync as execFileSync4 } from "node:child_process";
39727
- 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";
39728
40467
  import { dirname as dirname10, join as join17 } from "node:path";
39729
40468
  import { fileURLToPath } from "node:url";
39730
40469
  var BUILD_STAMP_FILENAME = "build-stamp.json";
@@ -39748,7 +40487,7 @@ function readHead(repoDir) {
39748
40487
  }
39749
40488
  function readBuildStamp(distDir) {
39750
40489
  try {
39751
- const parsed = JSON.parse(readFileSync22(stampPath(distDir), "utf8"));
40490
+ const parsed = JSON.parse(readFileSync24(stampPath(distDir), "utf8"));
39752
40491
  if (parsed && typeof parsed.sha === "string" && parsed.sha.length > 0 && typeof parsed.dirty === "boolean" && typeof parsed.built_at === "string") {
39753
40492
  return { sha: parsed.sha, dirty: parsed.dirty, built_at: parsed.built_at };
39754
40493
  }
@@ -39812,9 +40551,10 @@ function checkBuildStaleness(deps = {}) {
39812
40551
  init_output();
39813
40552
 
39814
40553
  // src/lib/pending-writes.ts
39815
- import { readFileSync as readFileSync23, statSync as statSync14 } from "node:fs";
40554
+ import { readFileSync as readFileSync25, statSync as statSync16 } from "node:fs";
39816
40555
  import { relative as relative13 } from "node:path";
39817
- var EMPTY = { writes: [], count: 0 };
40556
+ var import_gray_matter13 = __toESM(require_gray_matter(), 1);
40557
+ var EMPTY2 = { writes: [], count: 0 };
39818
40558
  function reportedElsewhere(state) {
39819
40559
  const entities = new Set;
39820
40560
  const files = new Set;
@@ -39832,13 +40572,14 @@ function scanPendingWrites(args) {
39832
40572
  try {
39833
40573
  mirrorRootPath = workspaceMirrorPath(workspaceSlug);
39834
40574
  } catch {
39835
- return EMPTY;
40575
+ return EMPTY2;
39836
40576
  }
39837
40577
  const ignored = watcherIgnoreMatcher(mirrorRootPath);
39838
40578
  const reported = reportedElsewhere(state);
39839
40579
  const frozenEntities = computeFrozen(workspaceSlug);
39840
40580
  const frozenFiles = computeFrozenFiles(workspaceSlug);
39841
40581
  const writes = [];
40582
+ const discovered = [];
39842
40583
  for (const absolutePath of walkSubtreeFiles(mirrorRootPath, ignored)) {
39843
40584
  const relPath = relative13(mirrorRootPath, absolutePath);
39844
40585
  const fileMatch = classifyFilesPath({ workspaceSlug, absolutePath });
@@ -39850,7 +40591,7 @@ function scanPendingWrites(args) {
39850
40591
  const entry3 = getFileEntry(state, fileMatch.relPath);
39851
40592
  let size;
39852
40593
  try {
39853
- size = statSync14(absolutePath).size;
40594
+ size = statSync16(absolutePath).size;
39854
40595
  } catch {
39855
40596
  continue;
39856
40597
  }
@@ -39858,7 +40599,7 @@ function scanPendingWrites(args) {
39858
40599
  continue;
39859
40600
  let hash2;
39860
40601
  try {
39861
- hash2 = sha256Bytes(readFileSync23(absolutePath));
40602
+ hash2 = sha256Bytes(readFileSync25(absolutePath));
39862
40603
  } catch {
39863
40604
  continue;
39864
40605
  }
@@ -39879,21 +40620,42 @@ function scanPendingWrites(args) {
39879
40620
  continue;
39880
40621
  let liveInode;
39881
40622
  try {
39882
- liveInode = statSync14(absolutePath).ino;
40623
+ liveInode = statSync16(absolutePath).ino;
39883
40624
  } catch {
39884
40625
  liveInode = undefined;
39885
40626
  }
39886
- 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);
39887
40629
  if (entry2 && frozenEntities.has(`${entityType}:${entry2.id}`))
39888
40630
  continue;
39889
40631
  let content;
39890
40632
  try {
39891
- content = readFileSync23(absolutePath, "utf8");
40633
+ content = readFileSync25(absolutePath, "utf8");
39892
40634
  } catch {
39893
40635
  continue;
39894
40636
  }
39895
40637
  if (!entry2) {
39896
- 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
+ }
39897
40659
  continue;
39898
40660
  }
39899
40661
  let hashes;
@@ -39902,10 +40664,38 @@ function scanPendingWrites(args) {
39902
40664
  } catch {
39903
40665
  continue;
39904
40666
  }
39905
- 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) {
39906
40692
  writes.push({ path: relPath, kind: "edited", clears: "boot" });
39907
40693
  }
39908
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
+ }
39909
40699
  for (const lane of [
39910
40700
  {
39911
40701
  filename: WORKSPACE_RULES_FILENAME,
@@ -39982,6 +40772,15 @@ function row(label, value) {
39982
40772
  function cont(value) {
39983
40773
  return `${" ".repeat(LABEL_WIDTH)} ${value}`;
39984
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
+ }
39985
40784
  function formatRelative(deltaMs) {
39986
40785
  const s = Math.floor(Math.max(0, deltaMs) / 1000);
39987
40786
  if (s < 1)
@@ -40021,7 +40820,7 @@ function renderBuild(out, staleness) {
40021
40820
  }
40022
40821
  function lastActivity(now) {
40023
40822
  try {
40024
- const mtimeMs = statSync15(daemonLogPath()).mtimeMs;
40823
+ const mtimeMs = statSync17(daemonLogPath()).mtimeMs;
40025
40824
  return formatRelative(now.getTime() - mtimeMs);
40026
40825
  } catch {
40027
40826
  return null;
@@ -40127,7 +40926,10 @@ async function runStatus(args, deps = {}) {
40127
40926
  out.info(row("Unsynced", "All local writes synced."));
40128
40927
  } else {
40129
40928
  const bootable = pending.writes.filter((w) => w.clears === "boot");
40130
- 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");
40131
40933
  const MAX_SHOWN = 10;
40132
40934
  const listPaths = (writes) => {
40133
40935
  for (const w of writes.slice(0, MAX_SHOWN)) {
@@ -40148,10 +40950,25 @@ async function runStatus(args, deps = {}) {
40148
40950
  listPaths(bootable);
40149
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`."));
40150
40952
  }
40151
- if (resavable.length > 0) {
40152
- head(`${resavable.length} file${resavable.length === 1 ? "" : "s"} ` + `Plinth has never seen.`);
40153
- listPaths(resavable);
40154
- 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."));
40155
40972
  }
40156
40973
  if (unsynced > 0) {
40157
40974
  head(`${unsynced} write${unsynced === 1 ? "" : "s"} awaiting retry (rate-limited or offline).`);
@@ -40186,6 +41003,33 @@ async function runStatus(args, deps = {}) {
40186
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)`));
40187
41004
  }
40188
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("");
40189
41033
  const voiceRecord = localState.user_voice;
40190
41034
  let voiceRaw = null;
40191
41035
  try {
@@ -40207,6 +41051,59 @@ async function runStatus(args, deps = {}) {
40207
41051
  } else {
40208
41052
  out.info(row("Voice", `${VOICE_FILENAME} is present; Plinth has not checked it yet.`));
40209
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("");
40210
41107
  return { exitCode: 0 };
40211
41108
  }
40212
41109
  var statusCommand = defineCommand({
@@ -40246,7 +41143,7 @@ var versionCommand = defineCommand({
40246
41143
  });
40247
41144
 
40248
41145
  // src/commands/voice-gate.ts
40249
- import { readFileSync as readFileSync26 } from "node:fs";
41146
+ import { readFileSync as readFileSync28 } from "node:fs";
40250
41147
 
40251
41148
  // src/lib/voice-gate/api.ts
40252
41149
  var VoiceGateSurfaces = [
@@ -40351,7 +41248,7 @@ async function callVoiceGate(req) {
40351
41248
  import { createHash as createHash4 } from "node:crypto";
40352
41249
  import {
40353
41250
  mkdirSync as mkdirSync12,
40354
- readFileSync as readFileSync24,
41251
+ readFileSync as readFileSync26,
40355
41252
  renameSync as renameSync8,
40356
41253
  unlinkSync as unlinkSync8,
40357
41254
  writeFileSync as writeFileSync9
@@ -40382,7 +41279,7 @@ function emptyFile() {
40382
41279
  }
40383
41280
  function load2(workspaceSlug) {
40384
41281
  try {
40385
- const raw = readFileSync24(attemptsPath(workspaceSlug), "utf8");
41282
+ const raw = readFileSync26(attemptsPath(workspaceSlug), "utf8");
40386
41283
  const parsed = AttemptsFileSchema.safeParse(JSON.parse(raw));
40387
41284
  return parsed.success ? parsed.data : emptyFile();
40388
41285
  } catch {
@@ -40545,7 +41442,7 @@ function denyUngated(detail) {
40545
41442
  }
40546
41443
 
40547
41444
  // src/lib/voice-gate/payload.ts
40548
- import { readFileSync as readFileSync25, statSync as statSync16 } from "node:fs";
41445
+ import { readFileSync as readFileSync27, statSync as statSync18 } from "node:fs";
40549
41446
  import { homedir as homedir2 } from "node:os";
40550
41447
  import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
40551
41448
  var MAX_BODY_BYTES = 256 * 1024;
@@ -40702,11 +41599,11 @@ function isHelperInvocation(tokens) {
40702
41599
  return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
40703
41600
  }
40704
41601
  function defaultReadBodyFile(path5) {
40705
- const stat4 = statSync16(path5);
41602
+ const stat4 = statSync18(path5);
40706
41603
  if (stat4.size > MAX_BODY_BYTES) {
40707
41604
  throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
40708
41605
  }
40709
- return readFileSync25(path5, "utf8");
41606
+ return readFileSync27(path5, "utf8");
40710
41607
  }
40711
41608
  function parseRecipients(...raw) {
40712
41609
  const all = raw.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
@@ -41066,7 +41963,7 @@ async function runVoiceGateDirect(args, deps = {}) {
41066
41963
  let text;
41067
41964
  if (args.file) {
41068
41965
  try {
41069
- text = readFileSync26(args.file, "utf8");
41966
+ text = readFileSync28(args.file, "utf8");
41070
41967
  } catch (err) {
41071
41968
  const msg = err instanceof Error ? err.message : "unknown error";
41072
41969
  out.error(`Could not read ${args.file}: ${msg}`);